参数服务:把配置参数从代码里拉出来
PID 系数、采样周期、报警阈值——硬编码意味着"改参 = 改码 → 编译 → 部署"。本课讲清参数作为"运行时可读写配置层"的原理、declare/get/set 生命周期与动态回调闭环。
目标本课学完,你应该能做到
能阐述参数是"节点内部状态的运行时可读写配置",与话题(数据流)、服务(操作)有本质区别;并说清 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 这道校验闸门。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。观察"闸门状态"那一栏:它们全被拒绝了,日志里留下一条拒绝原因。
▲ 调 publish_period 看日志节奏(0.02s 最快、10s 最慢);关掉 sensor_enabled 看采样停止;点越界测试看回调如何拒绝。这些现象稍后在真实节点上会一模一样复现。
① 为什么拖一下滑块,节点行为就变了,却完全不用重启?
② 为什么有的值被接受、有的被拒绝(
100.0 被拒、5 被拒),拒绝的判断发生在哪里?③ 关掉
sensor_enabled 后采样停了,但定时器还在跑——参数是怎么"控制行为"而不是"控制线程"的?带着这三个问题往下看,下一节先讲没有参数系统时有多痛。
02问题引入:改一个数字为什么这么贵
刚才你在页面上拖滑块,改一个值的代价是零。但在没有参数系统之前,同样的事有多贵?
换个生活的说法:阈值写死在代码里,就像把功放音量旋钮焊死在机箱里——想调个音量,得拆机、改电路、再装回去。参数系统则是把旋钮装回面板:设备不用停,伸手一拧就行。(这里的"旋钮"就是参数,只不过它的"拧"是在命令行里发一条 ros2 param set。)
😤 硬编码:改一个数字的完整代价
- 改源码
500→600 - 重新
colcon build(几十秒到几分钟) - 重新部署到小车 / 实验箱
- 重启节点,采样中断
- 现场调 PID?改一次等一轮,一天调不了几组
😎 参数化:一条命令,热生效
ros2 param set /param_sensor alarm_threshold_ppm 600.0- 节点回调校验后立即生效,日志实时响应
- 不用停采样、不用重编译
- 就像空调遥控器按温度——空调不重启,风已经变了
图 10-1 · 同样是把报警阈值从 500 改成 600:硬编码走完全程 vs 参数化一条命令
"旋钮"只是个比喻:说的是它像音量旋钮一样"随时能拧、拧了立刻生效",具体到代码里,它就是节点上一个个有名字、有类型的值(如
publish_period=1.0)。03核心原理:声明 → 读取 → 设置,与两次校验
declare_parameter('publish_period', 1.0) 在 __init__ 执行一次;默认值同时决定类型(1.0→float,1→int)。参数服务将声明注册,使 CLI / rqt 能发现它。
self.get_parameter('name').value——启动时读一次缓存进成员变量,或在每次回调里现取,都不受限制。
CLI ros2 param set 或代码 set_parameter() 修改后,不是直接覆盖:先触发 set_parameters_callback,校验通过才算成功。
declare 只能在 __init__ 里执行一次(重复声明报 already declared);get 可在任何地方、任何次数调用。
图 10-2 · 参数服务解剖图:三条路(declare 登记 / get 读取 / set 写入)汇入同一个参数注册表,只有写入路必须过回调闸门
| 通信方式 | 本质 | 典型用途 |
|---|---|---|
| 话题 Topic | 异步数据流,多对多 | 传感器 50Hz 连续读数 |
| 服务 Service | 同步一问一答 RPC | "验证指纹"一次性操作 |
| 参数 Parameter | 节点内存变量的外部读写口 | 发布周期 / 阈值 / 使能开关 |
一次 param 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 | 业务规则的真正闸门 |
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 步 · 启动节点:看参数变成日志
源码看懂了,打开终端实际跑一遍。先确认每个参数在真实节点上的表现,再进入命令行联调。
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
启动日志会打印三个参数的初始值,随后以 1s 周期持续采样:
ros2 run ros_sensor param_sensor
验证三个自定义参数 + 一个系统自带参数都已登记:
ros2 param list
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
✔ 注意:此时就算 param set 显示成功,节点行为也不会变——因为这一版还没写参数回调(成员变量只在构造时读了一次)。这正是第 1 步第三站回调要解决的问题。
第 3 步 · 九步 CLI 联调:把参数系统走一遍
前面已经验证了"参数在册"和"启动覆盖"。这一步把剩下的环节补齐——描述符、热生效、越界被拒、开关控制、持久化,每一步都有明确的观测点。前提:第 1 步的回调版代码已编译,终端 B 的节点保持运行。
描述符(见 03 节"两层约束")登记的描述与范围在这里展示:
ros2 param describe /param_sensor publish_period
合法值直通(节点日志立即变快),越界值被回调闸门拦下——对照 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
参数只活在运行时内存,重启即失忆。把调好的值 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
| # | 证据项 | 记录方式 | 合格标准 |
|---|---|---|---|
| 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,直接门卫式读写节点内存——与话题/服务三足鼎立。
声明一次定型定类;get 随取;set 必过回调。
校验、应用(重建定时器)、回执 SetParametersResult——缺则"改了没用"。
描述符自描述供 UI 渲染;dump/params-file 让参数配置即代码。
② 巡线小车 PID 三参数相互关联,改 Kp 后需要重置积分累计值防"积分突变"——_on_param_changed 该怎样处理一次 set 两个参数的场景?是否要连带重置 Ki/Kd 的累计?
③ 参数服务回调在参数服务线程而非主线程执行——这对"回调里做模型加载"这类重活意味着什么?给出两种改造思路。