核心课程 / ROS2 应用与开发 / EXP-10
EXP-10 · 第 2 章 · 设计性 · 2 学时

参数服务:把配置参数从代码里拉出来

PID 系数、采样周期、报警阈值——硬编码意味着"改参 = 改码 → 编译 → 部署"。本课讲清参数作为"运行时可读写配置层"的原理、declare/get/set 生命周期与动态回调闭环。

难度 ⭐⭐2 学时设计性关键词 Parameter · Descriptor · YAML

目标本课学完,你应该能做到

① 说清原理
参数是什么

能阐述参数是"节点内部状态的运行时可读写配置",与话题(数据流)、服务(操作)有本质区别;并说清 ParameterDescriptor 在自描述与范围声明中的作用。

② 掌握流程
声明 → 读取 → 动态回调

以 gas_sensor_node 为基础,完成 declare_parameter 声明、get_parameter 读取、add_on_set_parameters_callback 动态回调的完整编程流程。

③ 独立完成
做一个可调节点

独立实现"发布周期 / 采样使能 / 报警阈值可在线调整"的 MQ5 节点,注册 entry_points 后用 ros2 param list/get/set/describe/dump 完成验证。

④ 迁移应用
把配置参数外置

能为实际系统设计参数化配置——PID 系数、速度上限、触发阈值都通过参数暴露,不重启节点即可调优。

本课的重点与难点 重点:declare_parameter 的默认值同时决定了参数类型(1.0→double,1→int),类型定错后续读写会失败。
难点:理解"设置参数不是直接覆盖"——必须先过 on_set_parameters 这道校验闸门。
和前后的关系 本课的节点以 EXP-06 的 gas_sensor_node.py 为起点逐步加参数能力,建议对比着看两份代码。

01先动手感受:亲手调一调这三个参数

这一节不用敲任何命令——下面是一个跑在页面里的 param_sensor 模拟器。三个滑块,就是真实节点的三个参数(发布周期、报警阈值、使能开关),你拖动它们,右侧日志会立刻响应。先玩两分钟,你就会明白参数到底解决了什么问题,再去实验箱跑真节点。

🎛️ 请按这个顺序试三件事
① 拖 publish_period 滑块——从 1.00s 拖到 0.10s,看日志刷新变快;拖到 5s,看它慢下来。节点没有重启,行为已经变了。
② 把 alarm_threshold_ppm 拖到 400 以下——模拟读数在 300~700 之间波动,阈值一低,[报警] 就开始刷屏。
③ 点"越界测试"几个按钮——set period 100.0、set period 0.01、set 阈值 -100。观察"闸门状态"那一栏:它们全被拒绝了,日志里留下一条拒绝原因。
1.00 s
500
true (模拟读数在 300~700 ppm 缓慢波动——调低阈值即可看到报警刷屏)
定时器周期 1.000s 已发布 0 条 闸门状态 空闲

▲ 调 publish_period 看日志节奏(0.02s 最快、10s 最慢);关掉 sensor_enabled 看采样停止;点越界测试看回调如何拒绝。这些现象稍后在真实节点上会一模一样复现。

💡 刚才那两分钟,其实暴露了三个值得追问的点
① 为什么拖一下滑块,节点行为就变了,却完全不用重启?
② 为什么有的值被接受、有的被拒绝(100.0 被拒、5 被拒),拒绝的判断发生在哪里?
③ 关掉 sensor_enabled 后采样停了,但定时器还在跑——参数是怎么"控制行为"而不是"控制线程"的?
带着这三个问题往下看,下一节先讲没有参数系统时有多痛。

02问题引入:改一个数字为什么这么贵

刚才你在页面上拖滑块,改一个值的代价是零。但在没有参数系统之前,同样的事有多贵?

1
巡线小车的 PID 三个系数、MQ5 采样周期、报警阈值 500 ppm——都写死在源码里。
2
现场调参要走完整链:改源码 → 重新编译 → 重新部署 → 重启节点,一轮几分钟,一天调不了几组。
3
更糟的是:不同场地要用不同阈值,难道维护 N 个代码分支?
4
ROS2 的答案:参数系统——把可调值从代码里"拉出来",运行时用一条命令修改,节点立即按新值工作。

换个生活的说法:阈值写死在代码里,就像把功放音量旋钮焊死在机箱里——想调个音量,得拆机、改电路、再装回去。参数系统则是把旋钮装回面板:设备不用停,伸手一拧就行。(这里的"旋钮"就是参数,只不过它的"拧"是在命令行里发一条 ros2 param set。)

😤 硬编码:改一个数字的完整代价
  • 改源码 500 → 600
  • 重新 colcon build(几十秒到几分钟)
  • 重新部署到小车 / 实验箱
  • 重启节点,采样中断
  • 现场调 PID?改一次等一轮,一天调不了几组
😎 参数化:一条命令,热生效
  • ros2 param set /param_sensor alarm_threshold_ppm 600.0
  • 节点回调校验后立即生效,日志实时响应
  • 不用停采样、不用重编译
  • 就像空调遥控器按温度——空调不重启,风已经变了
✗ 硬编码之路 —— 改一次走完全程 改源码 colcon build 部署到小车 重启节点 采样中断 1 ~ 5 分钟 VS ✓ 参数化之路 —— 一条命令热生效 ros2 param set … 600.0 回调校验 毫秒级 新阈值热生效 采样不中断 参数把"改配置"从工程行为降级为日常操作 —— 这就是把配置参数从代码里拉出来

图 10-1 · 同样是把报警阈值从 500 改成 600:硬编码走完全程 vs 参数化一条命令

参数是什么(俗称"旋钮")参数是节点内部状态的运行时可读写配置层:不定义通信通道、没有消息类型流转,直接读写节点内存变量——这与话题(异步数据流)、服务(同步 RPC 操作)有本质区别。
"旋钮"只是个比喻:说的是它像音量旋钮一样"随时能拧、拧了立刻生效",具体到代码里,它就是节点上一个个有名字、有类型的值(如 publish_period=1.0)。

03核心原理:声明 → 读取 → 设置,与两次校验

声明 declare
登记存在 + 默认值 + 类型

declare_parameter('publish_period', 1.0) 在 __init__ 执行一次;默认值同时决定类型(1.0→float,1→int)。参数服务将声明注册,使 CLI / rqt 能发现它。

读取 get
任意时刻取当前值

self.get_parameter('name').value——启动时读一次缓存进成员变量,或在每次回调里现取,都不受限制。

设置 set
外部写入并触发回调

CLI ros2 param set 或代码 set_parameter() 修改后,不是直接覆盖:先触发 set_parameters_callback,校验通过才算成功。

声明 vs 读取
一次 vs 任意次

declare 只能在 __init__ 里执行一次(重复声明报 already declared);get 可在任何地方、任何次数调用。

外部 CLI / rqt get 读取入口 外部 CLI / rqt ros2 param set 节点内部 · /param_sensor ① declare 登记 · 仅 __init__ 一次 节点 __init__ 启动时 门① declare 名字 · 类型 · 默认值 ② get 读取 · 任意时刻 · 任意次 门② get 读当前值 · 立即返回 ③ set 写入 · 唯一必过闸门 门③ set 写值入口 回调闸门 on_set_params 校验 → 应用 → 回执 ✓ 落库 参数注册表 唯一真相源 publish_period = 1.0 sensor_enabled = true alarm_threshold = 500.0 use_sim_time = false 值一变 → 回调重建定时器 (行为变化的起点) ✗ 拒绝:successful=False + reason CLI 显示 failed,注册表原值不动 三条路汇入同一个仓:登记定型 · 读取直通 · 写入必过闸门 —— set 永远不是直接写内存

图 10-2 · 参数服务解剖图:三条路(declare 登记 / get 读取 / set 写入)汇入同一个参数注册表,只有写入路必须过回调闸门

通信方式本质典型用途
话题 Topic异步数据流,多对多传感器 50Hz 连续读数
服务 Service同步一问一答 RPC"验证指纹"一次性操作
参数 Parameter节点内存变量的外部读写口发布周期 / 阈值 / 使能开关
为什么没有网络开销参数读写本质是"经参数服务转发的内存访问",不存在持续数据流;参数值只活在运行时内存——节点重启即恢复默认值,持久化要靠 dump 出 YAML 再加载。

一次 param set 的完整数据流

外部 CLI / rqt ros2 param set 0.2 另一终端 节点参数服务 parameter_server 节点内部注册表 回调闸门 set_parameters_cb 校验范围 → 应用行为 业务逻辑 重建定时器 0.2s 周期生效 ✓ successful=True → CLI 显示 Set parameter successful ✗ 校验失败 → successful=False + reason,注册表原值不动 set 不是直接写内存:先过回调这道闸门 —— 校验、应用、回执三件事决定这次修改是成功还是被拒绝

图 10-3 · 一次 param set 的完整数据流:请求经参数服务转交回调闸门,校验通过才落到业务逻辑

两层约束:描述符(静态)与回调(动态)

# 声明时附带描述符:自描述 + 可视化元数据
from rcl_interfaces.msg import ParameterDescriptor, FloatingPointRange

period_desc = ParameterDescriptor(
    description='MQ5 数据发布周期(秒),越小采样越快。',
    floating_point_range=[
        FloatingPointRange(from_value=0.02, to_value=10.0, step=0.01)
    ]
)
self.declare_parameter('publish_period', 1.0, period_desc)   # 第三参即描述符
约束层在哪里生效谁来执行用途
ParameterDescriptor声明时登记的元数据describe 命令展示;rqt_reconfigure 据此渲染滑块/下拉文档化 + UI 生成,不做强制拦截
回调手动校验set_parameters_callback 内你的代码:越界即返回 successful=False业务规则的真正闸门
两层不冲突描述符是"说明书",回调是"门卫"——CLI 尝试越界值时,先触发的是回调(参数服务直接调用回调做业务校验);描述符范围用于工具展示与提示。省掉回调 = 越界值畅通无阻。

04实验步骤:读源码 → 跑节点 → 九步联调

这一节把"读代码"和"动手跑"合在一起:先看源码的三站结构(知道每个参数在哪被消费),再启动节点验证(看参数变成日志),最后九步 CLI 联调(把声明、读取、校验、热生效、持久化全走一遍)。

第 1 步 · 先看源码:param_sensor_node 的三站结构

源码位于 ros_sensor/ros_sensor/param_sensor_node.py(与 EXP-06 的 gas_sensor_node 同目录,from .common import read_mq5_once 复用真实读取)。按数据流分三站读:

第一站 · __init__:声明、读取、接线

# 来源:param_sensor_node.py __init__(声明 → 读取 → 发布器/定时器)
self.declare_parameter('publish_period', 1.0)        # ① 声明:名字+默认值,1.0 同时定型为 double
self.declare_parameter('sensor_enabled', True)        #     True → bool;'x' 才是 str
self.declare_parameter('alarm_threshold_ppm', 500.0)  #     声明即登记进"参数注册表"(图 10-2)

self.publish_period = float(self.get_parameter('publish_period').value)     # ② 读取:注册表 → 成员变量
self.sensor_enabled = bool(self.get_parameter('sensor_enabled').value)   #     启动时读一次缓存
self.alarm_threshold = float(self.get_parameter('alarm_threshold_ppm').value)

self.pub = self.create_publisher(Float32, '/gas_sensor', qos)   # ③ 接线:与实验6完全一致
self.timer = self.create_timer(self.publish_period, self.timer_cb)   #     周期就是参数本身
行在做什么为什么这样写
declare_parameter在节点参数服务里登记参数不声明 = 外部工具看不见它;默认值同时决定类型(1.0→double,1→int)
get_parameter(...).value把注册表值缓存进成员变量__init__ 里读一次;之后动态更新靠回调(第三站),这里不会自动刷新
create_timer(period, ...)周期直接取参数值定时器周期在创建时固定——改周期必须重建(第三站的伏笔)

第二站 · timer_cb:参数怎样控制行为

# 来源:param_sensor_node.py timer_cb(使能开关 + 阈值报警两处消费参数)
def timer_cb(self):
    if not self.sensor_enabled:          # 消费点①:使能开关——false 时直接返回,
        return                           #   定时器仍在走,只是采样被"跳过"(功耗极低)
    raw, v, ppm, err = read_mq5_once()   # 复用实验6 的真实 MQ5 读取
    msg = Float32(); msg.data = float(ppm)
    self.pub.publish(msg)
    if ppm >= self.alarm_threshold:      # 消费点②:阈值判断——参数一改,下一拍就用新值
        self.get_logger().warn(f'[报警] ppm={ppm:.2f} >= {self.alarm_threshold}ppm')
    else:
        self.get_logger().info(f'raw={raw} voltage={v:.3f}V ppm={ppm:.2f}')

第三站 · 回调:校验 → 应用 → 回执

# __init__ 末尾注册(必须先导入:from rcl_interfaces.msg import SetParametersResult)
self.add_on_set_parameters_callback(self.on_parameters_set)

def _recreate_timer(self):
    """按新的发布周期重建定时器 —— 参数真正改变行为的关键一步"""
    self.destroy_timer(self.timer)
    self.timer = self.create_timer(self.publish_period, self.timer_cb)

def on_parameters_set(self, params):
    """params: 本次被修改的参数列表(可能一次多个)"""
    for p in params:
        if p.name == 'publish_period':
            if p.value < 0.02 or p.value > 10.0:        # ① 校验:范围外一票否决
                return SetParametersResult(successful=False,
                    reason=f'publish_period 必须在 [0.02, 10.0] 内,收到: {p.value}')
            self.publish_period = float(p.value)           # ② 应用:更新变量
            self._recreate_timer()                      #    + 重建定时器(行为才变)
            self.get_logger().info(f'发布周期已更新: {self.publish_period} s')
        elif p.name == 'sensor_enabled':
            self.sensor_enabled = bool(p.value)
        elif p.name == 'alarm_threshold_ppm':
            if p.value < 0.0:
                return SetParametersResult(successful=False, reason='报警阈值不能为负数')
            self.alarm_threshold = float(p.value)
    return SetParametersResult(successful=True)          # ③ 回执:每个出口都必须返回
环节代码缺了会怎样
① 校验越界 return successful=False荒谬值(0 秒周期、负阈值)畅通无阻,运行期才爆炸
② 应用更新变量 + _recreate_timer()CLI 显示成功但周期纹丝不动——最高频的"改了没用"
③ 回执return SetParametersResult(...)返回 None 被判定设置失败,报错还含糊
描述符版本声明时可传第三参 ParameterDescriptor(description='MQ5 数据发布周期(秒)…', floating_point_range=[FloatingPointRange(from_value=0.02, to_value=10.0, step=0.01)])——它是给 ros2 param describe 和 rqt_reconfigure 看的"说明书"(见上面"两层约束"),不承担拦截职责,门卫仍是上面的回调。
关键在"应用"很多同学回调里只改了变量值,周期却不变——因为定时器周期在 create_timer 时已固定。参数生效的完整链 = 校验 → 更新变量 → 触发后续行为(重建定时器 / 切换开关),缺一步都叫"改了没用"。

第 2 步 · 启动节点:看参数变成日志

源码看懂了,打开终端实际跑一遍。先确认每个参数在真实节点上的表现,再进入命令行联调。

终端 B编译并注册入口新节点必须重编才能被 ros2 run 找到
1编译 + source终端 A

param_sensor_node.py 放进包内并注册 entry_points(setup.py 加 'param_sensor = ros_sensor.param_sensor_node:main')后,重编一次:

cd ~/ros2_ws
colcon build --packages-select ros_sensor
source install/setup.bash
$ colcon build --packages-select ros_sensor Finished <<< ros_sensor [X.XXs]
终端 B启动节点:看参数怎么变成日志
2默认参数启动终端 B

启动日志会打印三个参数的初始值,随后以 1s 周期持续采样:

ros2 run ros_sensor param_sensor
$ ros2 run ros_sensor param_sensor [INFO] [param_sensor]: 节点已启动: 发布周期: 1.0 s, 传感器: 开启, 报警阈值: 500.0 ppm [INFO] [param_sensor]: 新定时器已创建: 周期=1.000s [INFO] [param_sensor]: [1] 正常: 0.0 ppm [INFO] [param_sensor]: [2] 正常: 0.0 ppm # 每秒一条……这就是 publish_period=1.0 在"工作"
终端 C外部视角:发现并读取参数
3列出参数注册表终端 C

验证三个自定义参数 + 一个系统自带参数都已登记:

ros2 param list
$ ros2 param list /param_sensor: alarm_threshold_ppm publish_period sensor_enabled use_sim_time
4读取 + 一次性覆盖启动终端 C

get 读当前值;-p 可在启动瞬间覆盖默认值(走的是"声明时以外部值为默认"的机制):

ros2 param get /param_sensor publish_period
ros2 run ros_sensor param_sensor --ros-args -p publish_period:=0.1 -p alarm_threshold_ppm:=100.0
$ ros2 param get /param_sensor publish_period Double value is: 1.0 # 带 -p 启动则直接以 0.1s 周期(10Hz)/ 100ppm 起跑,无需 set

✔ 注意:此时就算 param set 显示成功,节点行为也不会变——因为这一版还没写参数回调(成员变量只在构造时读了一次)。这正是第 1 步第三站回调要解决的问题。

第 3 步 · 九步 CLI 联调:把参数系统走一遍

前面已经验证了"参数在册"和"启动覆盖"。这一步把剩下的环节补齐——描述符、热生效、越界被拒、开关控制、持久化,每一步都有明确的观测点。前提:第 1 步的回调版代码已编译,终端 B 的节点保持运行。

终端 C读说明书 → 正常调参 → 恶意调参
1describe:看参数的"说明书"终端 C

描述符(见 03 节"两层约束")登记的描述与范围在这里展示:

ros2 param describe /param_sensor publish_period
$ ros2 param describe /param_sensor publish_period Parameter name: publish_period Type: double Description: MQ5 数据发布周期(秒),越小采样越快 Constraints: Min value: 0.02 Max value: 10.0 Step: 0.01
2一成一败:热生效 vs 被拒绝终端 C

合法值直通(节点日志立即变快),越界值被回调闸门拦下——对照 01 节模拟器里你亲手触发过的两个场景:

ros2 param set /param_sensor publish_period 0.2
ros2 param set /param_sensor publish_period 100.0
ros2 param set /param_sensor sensor_enabled false
ros2 param set /param_sensor sensor_enabled true
$ ros2 param set /param_sensor publish_period 0.2 Set parameter successful # 同时终端 B:[INFO] 发布周期已更新: 0.2 s → 日志从每秒 1 条变 5 条 $ ros2 param set /param_sensor publish_period 100.0 Setting parameter failed: publish_period 必须在 [0.02, 10.0] 内,收到: 100.0 # 同时终端 B:[WARN] 拒绝发布周期: 100.0 $ ros2 param set /param_sensor sensor_enabled false Set parameter successful # 终端 B 日志停止;改回 true 恢复
终端 C持久化:配置即代码
3dump 导出 → params-file 加载终端 C

参数只活在运行时内存,重启即失忆。把调好的值 dump 成 YAML,下次启动预加载——这是"拿手调参数当代码管"的标准流程:

ros2 param dump /param_sensor > param_sensor.yaml
cat param_sensor.yaml
ros2 run ros_sensor param_sensor --ros-args --params-file ./param_sensor.yaml
$ cat param_sensor.yaml /param_sensor: ros__parameters: alarm_threshold_ppm: 500.0 publish_period: 0.2 sensor_enabled: false use_sim_time: false # 新节点一睁眼就是 0.2s 周期 / 阈值500 / 关闭态 —— 上次的调参成果全部继承
#证据项记录方式合格标准
1参数在册param list 截图4 个参数齐全
2描述符生效param describe 截图显示描述文本与 [0.02, 10.0] 范围
3热生效set 0.2 后终端 B 日志截图发布周期更新 INFO + 日志变密
4越界被拒set 100.0 双端截图CLI failed + 节点端 WARN 拒绝原因
5开关控制set false/true 各一张日志停止 / 恢复
6持久化yaml 内容 + 带文件重启后 param get 截图新节点读到 dump 的值

05注意事项与验收清单

  • 默画参数生命周期:声明(一次)→ 读取(任意)→ 外部 set → 回调 → 应用
  • 说出回调返回值的硬性格式:SetParametersResult(successful, reason)
  • 解释"更新了变量但行为没变"的原因:定时器等行为载体未重建
  • 独立跑通 CLI 九步:list / describe / get / set 成功 / set 被拒 / 开关 / dump / params-file
  • 说清 Descriptor 与回调校验两层的分工(说明书 vs 门卫)
  • 知道参数不持久化及 YAML 落盘的正确姿势
坑症状解法
重复声明同名参数报 "already declared" 运行时错误声明前 if not self.has_parameter('name') 判断
类型不匹配set 报 Type mismatch(1.0 声明成 1 设)默认值定类型:float 写 1.0、int 写 1,set 时同型
回调只改变量不改行为CLI 显示成功,采样频率却不变回调里补 _update_timer():destroy 旧定时器再重建
回调返回 None参数服务判定设置失败但报错含糊每个出口都 return SetParametersResult(...)
回调线程直接动定时器崩溃偶发崩溃/未定义行为回调存 _pending_rate,主循环定时器里检查并安全应用;共享状态加 threading.Lock
重启后参数"丢了"一切恢复默认值这不是 bug:dump → YAML → --params-file 加载

06本课总结与思考题

定位
运行时配置层

不走数据流、不走 RPC,直接门卫式读写节点内存——与话题/服务三足鼎立。

生命周期
declare → get → set

声明一次定型定类;get 随取;set 必过回调。

动态闭环
callback 三件事

校验、应用(重建定时器)、回执 SetParametersResult——缺则"改了没用"。

工程化
Descriptor + YAML

描述符自描述供 UI 渲染;dump/params-file 让参数配置即代码。

思考题 ① 手动回调校验与 ParameterDescriptor 范围两层约束,CLI 越界时哪一层先触发?设计一个实验(如把两层范围设成不一致)验证你的判断。
② 巡线小车 PID 三参数相互关联,改 Kp 后需要重置积分累计值防"积分突变"——_on_param_changed 该怎样处理一次 set 两个参数的场景?是否要连带重置 Ki/Kd 的累计?
③ 参数服务回调在参数服务线程而非主线程执行——这对"回调里做模型加载"这类重活意味着什么?给出两种改造思路。