ROS2 话题通讯原理(MQ5 气体传感器)
传感器数据像河水一样持续流动,如何让"生产数据的节点"和"使用数据的节点"互不干扰地协作?本实验以真实 MQ5 可燃气体传感器为载体,讲透 ROS2 发布/订阅模型、QoS 三要素与 MQ5 对数换算三条原理主线,并完成"DAC 采集 → 物理量换算 → Float32 发布 → 报警判定"全链路实操。
目标本课学完,你应该能做到
能阐述 Publisher 与 Subscriber 如何通过话题名解耦、DDS 如何自动完成发现与分发,并说明 QoS 三要素(Reliability / Durability / History Depth)如何影响通信行为。
掌握 gas_sensor_node(发布)与 gas_monitor(订阅)从参数声明、QoS 配置到消息收发回调的完整编程链路。
能独立运行 MQ5 收发节点,用 topic echo/hz/info 观察浓度、频率与类型,并用 --ros-args -p 动态改 publish_period 与报警阈值。
能把「ADC 采集 → 物理量换算 → Float32 发布」这套模型迁移到温湿度、心率血氧等其它持续数据流传感器。
难点:把 MQ5 的电压读数经对数换算成 ppm(parts per million,百万分之一,气体浓度单位);以及理解"发布者根本不知道有几个订阅者"。
gas_sensor_node 在 EXP-05 已从"节点结构"角度拆过;本课换到"话题"角度再看一遍,会发现同一份代码能讲出两层知识。01跑出现象:一步步跑出 MQ5 浓度在话题上流动
先别急着啃原理——把系统跑起来,看它到底长什么样。下面按终端分成四段,一条命令一张卡:每条都告诉你为什么敲它,以及敲完屏幕上应该出现什么。逐条对照,现象对上了;再带着「为什么会这样」的疑问进入后面的原理与代码,会轻松很多。
ros_sensor 是存放传感器节点的功能包(gas_sensor_node / gas_monitor 都在里面);ros_sensor_interfaces 是它依赖的接口包。用 --packages-select 点名编译这两个包——工作空间里包一多,全量编译既慢又容易连带报错。编译顺序由 colcon 按依赖自动排,不用你操心。source 的作用是让当前终端知道去哪儿找这些包,每新开一个终端都要重新 source 一次。
# ① 进工作空间,并先加载 ROS2 本体(underlay) cd ~/ros2_ws source /opt/ros/humble/setup.bash # ② 只编译本实验需要的两个包:节点包 + 它依赖的接口包 # 用 --packages-select 点名,不碰工作空间里其它包,快且不易连带报错 colcon build --packages-select ros_sensor ros_sensor_interfaces # ③ 把工作空间注册进当前终端(新终端必做,不能省) source install/setup.bash
✔ 结尾出现 Summary: N packages finished(N = 你工作空间里的包总数,全编了就行);source 本身没有任何输出(沉默即成功)
等一下日志里会一直出现
ppm=312.91 这样的数,先说明它是什么:PPM 是 "parts per million" 的缩写,字面意思就是"百万分之一",用来表示极小的比例。· 用在气体浓度上:一百万份空气里,混了多少份目标气体。312.91 ppm ≈ 每 100 万份空气里有约 313 份可燃气体。
· 换算成百分比更直观:312.91 ppm ≈ 0.031%;本课默认报警阈值 500 ppm ≈ 0.05%——浓度都很低,靠鼻子闻不出来,所以必须靠传感器。
· 记住两个量级参照:1000 ppm = 0.1%;10000 ppm = 1%。看到 4 位数的 ppm 就说明已经是"百分之几"了。
· 为什么不用百分比:可燃气体在爆炸下限之前往往只有几百到几千 ppm,写成 0.03% 既不方便比大小,也不方便设阈值,所以行业统一用 ppm。
这就是数据的源头:它每秒读一次 ADC,把电压换算成 ppm 浓度(换算公式在第 4 节讲),然后把数值"喊"到 /gas_sensor 这个话题上。
# 新终端必做:进目录 + 加载本工作空间(不然找不到 ros_sensor) cd ~/ros2_ws source install/setup.bash # 启动 MQ5 采集节点(普通用户即可,不需要 sudo) ros2 run ros_sensor gas_sensor_node
✔ 每秒稳定打印一行 raw=… voltage=…V ppm=…:raw 是 ADC 原始值、voltage 是换算出的电压、ppm 是最终浓度。光标停住不返回,正是节点在持续工作的样子,停止请按 Ctrl+C。
读取 ADC 失败,先回来检查接线。它订阅同一个话题。注意:它完全不知道发布端在哪、是谁——只知道"我去 /gas_sensor 这个频道听"。
# 同样的固定开场:进目录 + source(新终端必做) cd ~/ros2_ws source install/setup.bash # 启动订阅 / 报警节点 ros2 run ros_sensor gas_monitor
✔ 数值与终端 B 的 ppm 一一对应——这就是消息真的走通了。默认阈值 500 ppm,所以此刻打印的是"浓度正常"。
[gas_monitor]: 浓度正常: xx.xx ppm,数值与终端 B 的 ppm 同步跳动。一旦人为靠近气源,这里就会变成 [报警] 气体浓度超标。这条命令给整个系统"点名":现在到底有哪些节点活着。
# 新终端开场:进目录 + source(后面的命令都在这个终端里敲,不用再 source) cd ~/ros2_ws source install/setup.bash # 给整个系统的节点"点名" ros2 node list
✔ 两个节点都在,说明发布与订阅程序都活着
列出当前活跃的话题。除了业务话题,还会看到两个以 / 开头的系统话题。
# 列出当前活跃的话题(本终端已 source 过,直接敲)
ros2 topic list
✔ 看到 /gas_sensor 就说明发布端已经成功登记了话题
这条命令回答两个关键问题:发的什么类型?谁在发、谁在收?加 --verbose 还能看到两端的 QoS(见 03 节)。
# 看这条话题的类型 + 谁在发、谁在收 ros2 topic info /gas_sensor # 再加一把 --verbose:还能看到两端的 QoS 参数 ros2 topic info /gas_sensor --verbose
✔ 类型是 std_msgs/msg/Float32;此时如果你在另一个终端再开 ros2 topic echo,Subscription count 会变成 2 —— 这就是"一对多"的直接证据
这是话题调试最重要的命令:不改任何代码,直接把自己变成第三个订阅者。发布端完全不知道你的存在——这正是话题"位置透明"的体现。
# 变成第三个订阅者实时"偷听";随时 Ctrl+C 停止
ros2 topic echo /gas_sensor
✔ 每秒打印一条 data: …,与终端 B、C 的数值同步
频率是判断"数据流是否健康"最直观的指标。当前 publish_period 默认为 1.0 秒,所以应该是 1Hz。
# 测这条话题的发布频率;随时 Ctrl+C 停止
ros2 topic hz /gas_sensor
✔ average rate: 1.000 说明发布端严格按 1 秒一次的节奏在发
发布周期做成了参数,所以不用改源码就能调快。注意参数覆盖必须用 :=,用等号 = 不生效。
# 在终端 B 按 Ctrl+C 停掉,再用参数重启 ros2 run ros_sensor gas_sensor_node --ros-args -p publish_period:=0.5
✔ 终端 B 的日志打印变快一倍,终端 C 的"浓度正常"也跟着变快——发布节奏变了,所有订阅者自动跟着变
默认阈值 500 ppm,传感器在正常环境读数只有几百以内,很难触发;把阈值压低,就能立刻看到报警路径。
# 在终端 C 按 Ctrl+C 停掉,再用参数重启 ros2 run ros_sensor gas_monitor --ros-args -p alarm_threshold:=100.0
✔ 日志从 INFO 浓度正常 变成 WARN [报警]——同一个话题、同一份数据,阈值一变,行为立刻改变
[报警]——这说明"采集 → 换算 → 发布 → 订阅 → 判定"整条链路同时正确。测试完请开窗通风。读取 ADC 失败: …。这不影响理解话题机制,但看不到真实浓度。可以改用话题名与类型完全相同的手动发布来替代第 2 步:ros2 topic pub /gas_sensor std_msgs/msg/Float32 "{data: 320.0}" -r 1终端 C 与终端 D 的全部命令照常可用。
| 操作 | 你应该看到 |
|---|---|
ros2 run ros_sensor gas_sensor_node | 每秒一行 raw=1404 voltage=1.714V ppm=312.91 |
ros2 run ros_sensor gas_monitor | 每秒一行 浓度正常: 312.91 ppm(与发布端数值一致) |
ros2 node list | /gas_sensor_node 与 /gas_monitor 同时出现 |
ros2 topic info /gas_sensor | Type: std_msgs/msg/Float32,Publisher 1,Subscription ≥1 |
ros2 topic hz /gas_sensor | default 下 1.000 Hz;publish_period:=0.5 后 2.000 Hz |
第三方 ros2 topic echo | 可同时收到数据(一对多验证),发布端毫无感知 |
alarm_threshold:=100.0 | 订阅端出现 [报警] 气体浓度超标 的 WARN 日志 |
| 关闭发布端(终端 B 按 Ctrl+C) | 订阅端静默等待,不报错,只是不再有新数据 |
02问题引入与原理:一份数据,为什么要经过"话题"
刚才那一屏数据已经跑通了。但它凭什么能跑通?先看需求——想象你在家做一个"燃气安全管家":厨房装了 MQ5 可燃气体传感器,一旦浓度超标要立刻做四件事:手机弹通知、客厅声光报警器响、云端留一份记录、物业大屏同步显示。传感器每秒读一次数据,这四个"想听的人"都要拿到同一份。
gas_sensor_node 根本不知道 gas_monitor 和 ros2 topic echo 在听。如果不用话题,直接让传感器程序"挨个通知"这四个人呢?这是最朴素的点对点直连,一上手就会撞到四堵墙:
话题通信把这四堵墙一次性拆掉:传感器只管把消息放到 /gas_sensor 这个话题上,谁想用谁就自己"竖起耳朵"订阅,随时加入、随时离开,发布者永远不需要知道现在有几个听众。
ros2 topic echo 就是这么插进去的);② 异步——发布只管把消息放到话题上就走,订阅者处理得慢也不拖累发布者;③ 位置透明——双方只需约定话题名 + 消息类型,谁都不需要知道对方的 IP、端口、进程号。那它内部到底怎么跑通的?四个环节
"解耦"听起来抽象,其实全过程只有四步,每一步都有明确的承担者。看这张结构图——它把两个节点的内部拆开了,你会发现真正干活的并不是 publish() 那一行,而是它背后 DDS 的"收发管道"。
create_publisher(Float32, '/gas_sensor', qos) 只是向本机 DDS 登记了一个"我在这里发这种消息"的端点,此刻还没有任何连接。publish(msg) 把对象序列化成二进制(CDR),塞进发送队列后立刻返回——它不等任何订阅者。队列按 QoS 深度决定留多少条。callback(msg)。事件驱动,不空转轮询。publish() 做的事是"把消息交给话题",全程并不关心有没有订阅者在线。所以没有订阅者时发布照样成功;订阅者处理得慢,发布的频率也不会被拖慢。这就是"异步"的全部含义。ros2 topic echo 能一边看、gas_monitor 能一边判报警,就是各自收各自的副本。| 对比维度 | 话题(本实验) | 服务(EXP-07) |
|---|---|---|
| 通信关系 | 一对多,多对多 | 一对一 |
| 是否阻塞 | 异步,发完即走 | 同步,等应答 |
| 数据方向 | 持续单向流 | 请求 → 应答往返 |
| 典型场景 | 传感器读数、速度指令 | 查询、校准、触发一次动作 |
ros2 topic info /gas_sensor --verbose 可以看到类型、发布/订阅端数量,以及两端的 QoS 参数——"谁在发、谁在收、按什么规矩收"一目了然,不用读一行代码。03QoS:给话题签一份传输合同,并亲手签坏它
同样是发数据,激光雷达"丢几帧无所谓、延迟必须低",控制指令却"一条都不能丢"。ROS2 把这个选择权交给了 QoS(Quality of Service):每个话题都附带一套传输策略——发布端提供一套,订阅端要求一套,DDS 逐项比对,匹配成功才建立连接。
按下场景选档位
| 场景 | Reliability | Durability | Depth | 为什么 |
|---|---|---|---|---|
| MQ5 浓度(本实验) | RELIABLE | VOLATILE | 10 | 1Hz 的关键安全数据,丢一条就漏一次报警 |
| 激光雷达点云 / 相机图像 | BEST_EFFORT | VOLATILE | 1~5 | 几十 Hz 且数据量大,宁可丢帧也别拖慢 |
| 地图 / 静态配置 | RELIABLE | TRANSIENT_LOCAL | 1 | 只发一次,晚启动的节点必须能拿到 |
| 速度指令 /cmd_vel | RELIABLE | VOLATILE | 1 | 只要最新的一条,旧指令补发反而危险 |
· 发布
BEST_EFFORT + 订阅 RELIABLE ⇒ 连不上(订阅者要的可靠性,发布者给不了)· 发布
RELIABLE + 订阅 BEST_EFFORT ⇒ 能连(订阅者自降要求,发布者照给)动手验证:把合同"签坏"看看
光看懂规则不够,下面这个实验台把抽象的兼容规则变成了几秒就能验证的实验:左边是发布端 gas_sensor_node,右边是订阅端 gas_monitor,改完档位点一下"跑 10 条消息",模拟器就按 DDS 的规则逐条演示。建议按这张任务单操作,边做边对照上面的规则:
| # | 把两端改成 | 你会观察到 |
|---|---|---|
| 1 | 发布 BEST_EFFORT,订阅 RELIABLE | DDS 拒绝匹配,订阅端一条都收不到 |
| 2 | 两端都 RELIABLE,发布 depth 改成 KEEP_LAST(1) | 慢消费者场景下,中间的消息被新消息覆盖丢弃 |
| 3 | 勾选"订阅端晚到":Durability 先 VOLATILE 再 TRANSIENT_LOCAL | 前者错过加入前的全部数据;后者会补发最后一条 |
🎛️ 实验台就在下面:改两端档位,点"跑 10 条消息",按上表的三个任务依次验证。
📤 发布端 gas_sensor_node(提供)
📥 订阅端 gas_monitor(要求)
互动 6-1|改档位 → 跑消息 → 读结论。三条推荐实验见本节上方的任务单
① Reliability 决定"传的过程中丢不丢"——BEST_EFFORT 会真的有消息到不了;
② Depth 决定"收端来不及处理时挤掉多少"——KEEP_LAST(1) 在慢消费者面前只剩最新一条;
③ Durability 决定"我晚到了能不能补课"——TRANSIENT_LOCAL 能把加入前最后一条补给你,VOLATILE 则永远错过。
04实验步骤:先看懂框架,再读代码,最后自己跑出结果
这一节换个顺序:先看程序骨架(三份文件各管什么、谁调谁)→ 再逐段读关键代码(只讲非讲不可的几十行)→ 最后动手跑、把结果和预期对上。反过来"先啃代码"最容易迷路——你会记住语法,却说不清这段代码为什么长这样。
第 1 步 · 先看骨架:三份文件,一条数据流
刚才屏幕上的一切,来自三份代码。别急着看细节,先记住它们的分工——这份骨架看懂了,后面读代码只是填肉。
publish() 到 /gas_sensor。/gas_sensor → 收到就回调 → 与阈值比较 → 打印"正常 / 报警"。common.py 负责算,gas_sensor_node.py 负责发,gas_monitor.py 负责判。中间的"搬运"一行代码都不用你写——这正是 02 节讲的位置透明。想换成温度传感器?只改 common.py 的读取函数,发布和订阅端几乎不用动。~/ros2_ws/src/xflz_eai_car1/driver/ros_sensor/ros_sensor/ros_sensor/(外层 ros_sensor/ 是包,里层同名目录才是放 .py 的),其中 common.py 被两个节点共用。建议边看边打开源码对照。第 2 步 · 再读代码:三段非讲不可的地方
下面按数据流动的顺序看三段代码。每段只抓关键几行——其余的 import、初始化、日志格式,你打开源码一眼就懂,这里不占篇幅。
① common.py —— ppm 是算出来的,不是读出来的
ppm 是什么在 01 节已经说过(parts per million,百万分之一)。这一节补的关键点是:它不是传感器直接读出来的数字。MQ5 是半导体气敏元件,接触可燃气体时内阻下降;电路通过分压把这个电阻变化变成电压。而"电压"和"浓度"不是线性关系——它的灵敏特性曲线只有在双对数坐标下才近似直线,所以必须在对数域里插值,才能把电压反推成 ppm。
raw 是 ADC 数出来的"格数",12 位的一刀把量程切成 4096 格,取值 0~4095,本身不带单位;scale 是内核驱动按"满量程毫伏 ÷ 4096"提前算好、放进 sysfs 的系数,含义是 1 格等于多少毫伏(5 V 满量程约为 1.2207)。两者相乘得到毫伏,再 ÷1000 才是伏特:v = raw × scale ÷ 1000。拿日志验算一遍:1404 × 1.2207 ≈ 1714 mV,正是那行的 voltage=1.714。板子上的真值可以现场读:cat /sys/bus/iio/devices/iio:device0/in_voltage_scale 和 …/in_voltage7_raw,各板参考电压不同,以你 cat 出来的数为准。① 手册的灵敏度特性曲线放在双对数纸上近似一条直线,横轴 lg(ppm)、纵轴 lg(ratio),套直线方程得
lg(ratio) = M × lg(ppm) + B;② 把浓度反解出来:
lg(ppm) = (lg ratio − B) ÷ M;③ 两边同时取 10 的幂:
ppm = 10^((lg ratio − B) ÷ M)。代入拟合系数 B = 0.186、M = −0.362,就是④显示的那一行。整条链上 B、M 是查手册曲线拟合来的固定值,每次采样真正变的只有
ratio。
# 来源:ros_sensor/common.py L13-33 V_REF, RL, R0 = 5.0, 1.0, 10.0 # 参考电压、负载电阻 kΩ、标定电阻 kΩ CURVE_B, CURVE_M = 0.186, -0.362 # 灵敏特性曲线截距与斜率(斜率为负!) def voltage_to_ppm(v): # 电压 → 浓度 if v <= 0 or v >= V_REF: # ← 除零保护:量程外的电压不算 return 0.0 rs = RL * (V_REF - v) / v # ③ 由分压关系反推气敏电阻 Rs ratio = rs / R0 # ③ 与标定点 R0 的比值 return 10 ** ((math.log10(ratio) - CURVE_B) / CURVE_M) # ④ 对数域线性插值
| 代码 | 它在做什么 | 不这么写会怎样 |
|---|---|---|
if v <= 0 or v >= V_REF: return 0.0 | 电压落在量程外(未接线、短路)直接返回 0,并让调用方看到"这次读数无效" | 分母趋近 0 → ZeroDivisionError 或天文数字 |
rs = RL*(V_REF-v)/v | 分压电路:v 越低说明传感器内阻越大,反推出 Rs | 拿电压当浓度用,读数完全错误 |
ratio = rs / R0 | 归一化:把"当前电阻"表示成"标定电阻的几倍",去掉元件个体差异 | 换一块传感器就要重新标定整套算法 |
10 ** ((lg(ratio)-B)/M) | 在双对数坐标的直线上反解浓度。直线来自手册的灵敏度特性曲线(手册只给图不给公式),B、M 是对这条曲线拟合出的截距和斜率 | 线性插值会严重低估高浓度区间 |
raw 只是 0~4095 的"格数",本身不是伏特。每格到底等于多少毫伏,内核驱动已经算好写进 in_voltage_scale:格数 × 毫伏/格得到毫伏,再 ÷1000 才是伏特。换算式里不用出现 4096,因为它已经被封进 scale 里了,板上 cat in_voltage_scale 一眼可见。采集入口:read_mq5_once() 每秒钟被调用一次
# 来源:ros_sensor/common.py L36-47 def read_mq5_once(): try: scale = float(read_adc_attr("in_voltage_scale")) # ① 量程系数:1 格 = 多少毫伏 raw = int(read_adc_attr(f"in_voltage{MQ5_CH}_raw")) # ② 原始格数(12 bit,0~4095) v = raw * scale / 1000.0 # ③ 格数×毫伏/格=毫伏,÷1000→伏特 return raw, v, voltage_to_ppm(v), "" # ④ 一次返回四件事 except Exception as e: return None, None, None, f"{type(e).__name__}: {e}" # 失败信息交给调用方
四个返回值 (raw, v, ppm, err) 就是节点日志里那一行的全部来源。函数不打印、不抛异常,只把结果和错误一起交出去——打印交给节点,这是数据层与表现层分离的好习惯。
互动 6-2|拖滑杆或直接输入电压(0~5 V):红点就是当前输入在曲线上的位置;输入 0 或 5,亲手触发代码里的量程保护
② gas_sensor_node.py —— 发布端只做四件事
# 来源:ros_sensor/gas_sensor_node.py L16-44 from rclpy.node import Node from std_msgs.msg import Float32 from rclpy.qos import QoSProfile, HistoryPolicy, ReliabilityPolicy from .common import read_mq5_once # 复用 ① 里的采集函数 class GasSensorNode(Node): def __init__(self): super().__init__('gas_sensor_node') # ① 注册节点名 self.declare_parameter('publish_period', 1.0) # ② 声明可调参数 qos = QoSProfile( # ③ 传输合同 history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.RELIABLE, ) self.pub = self.create_publisher(Float32, '/gas_sensor', qos) # ④ 创建发布端 period = float(self.get_parameter('publish_period').value) self.timer = self.create_timer(period, self.timer_cb) # ⑤ 定时器:每 period 秒叫一次 def timer_cb(self): # ⑥ 每秒被触发一次的回调 raw, v, ppm, err = read_mq5_once() # 读 + 算 if err: self.get_logger().error(f'读取 ADC 失败: {err}') # 出错只记日志,不崩溃 return msg = Float32() # 装消息 msg.data = float(ppm) self.pub.publish(msg) # ⑦ 发布:入队即返回 self.get_logger().info(f'raw={raw} voltage={v:.3f}V ppm={ppm:.2f}') def main(): rclpy.init() # 初始化 rclpy node = GasSensorNode() # 构造节点(此时才注册进计算图) try: rclpy.spin(node) # 进入事件循环:驱动定时器与回调 except KeyboardInterrupt: pass finally: node.destroy_node(); rclpy.shutdown() # Ctrl+C 后干净退出
| 环节 | 关键行 | 它在做什么 |
|---|---|---|
| ① 注册节点 | super().__init__('gas_sensor_node') | 节点名必须全局唯一,重名时 ROS2 会自动改名为 gas_sensor_node_1。这一步之后 ros2 node list 才能看到它。 |
| ② 声明参数 | declare_parameter('publish_period', 1.0) | 把"发布周期"变成运行时可调项——这就是第 9 步 -p publish_period:=0.5 能生效的原因。必须先声明再读取。 |
| ③ 配置 QoS | QoSProfile(...) | 确定这份"传输合同":可靠 + 只留最近 10 条。订阅端要能对上才连得通(见 03 节 QoS)。 |
| ④ 创建发布端 | create_publisher(Float32, '/gas_sensor', qos) | 三要素:消息类型 + 话题名 + QoS。这一行执行后,话题才真正出现在 ros2 topic list 里。 |
| ⑤ 定时器 | create_timer(period, self.timer_cb) | 周期性触发回调,相当于节点的"心跳"。1.0 秒一次的节奏就是它给的。 |
| ⑥ 回调 | timer_cb(self) | 整个业务逻辑只有五步:读 → 判错 → 装消息 → 发布 → 记日志。 |
| ⑦ 发布 | self.pub.publish(msg) | 把消息交给话题即返回。此刻有没有订阅者在听,它完全不关心——这就是能"一对多、随时加入"的根本原因。 |
③ gas_monitor.py —— 订阅端与发布端镜面对称
# 来源:ros_sensor/gas_monitor.py L13-36 class GasMonitor(Node): def __init__(self): super().__init__('gas_monitor') # ① 另一个节点名 self.declare_parameter('alarm_threshold', 500.0) # ② 报警阈值也是参数 self.threshold = float(self.get_parameter('alarm_threshold').value) qos = QoSProfile( # ③ QoS 必须与发布端相容 history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.RELIABLE, ) self.sub = self.create_subscription( # ④ 订阅:类型 + 话题 + 回调 + QoS Float32, '/gas_sensor', self.callback, qos) def callback(self, msg): # ⑤ 消息到了才会被调用 ppm = msg.data if ppm >= self.threshold: self.get_logger().warn(f'[报警] 气体浓度超标: {ppm:.2f} ppm >= {self.threshold} ppm') else: self.get_logger().info(f'浓度正常: {ppm:.2f} ppm')
| 对比点 | 发布端 gas_sensor_node | 订阅端 gas_monitor |
|---|---|---|
| 核心 API | create_publisher() | create_subscription() |
| 谁主动 | 由定时器主动触发(我要发) | 被动等待(消息来了才叫我) |
| 业务代码在哪 | timer_cb:采集并发布 | callback:判断并报警 |
| 必需三件套 | 消息类型 必须相同 · 话题名 必须相同 · QoS 必须相容(请求 ≤ 提供) | |
| 少一个会怎样 | 话题名不同 ⇒ 谁也遇不到谁;类型不同 ⇒ 发现阶段就匹配失败;QoS 不兼容 ⇒ 连不上,且不报错,只是安静地收不到 | |
spin() 内部是事件循环:消息到达 → 放入队列 → 调度 callback。没有消息时线程在等待而不是空转,CPU 占用极低。ros2 node info /gas_monitor 就是按名字查它。名字重复会导致管理命令指向不确定的对象。declare_parameter 就 get_parameter → 运行时报"参数未声明";② 命令行覆盖参数必须用 :=(-p publish_period:=0.5),写成 = 无效。第 3 步 · 最后跑出结果:把代码和现象对上号
代码读完了,现在回到终端,验证你读到的每一段在屏幕上长什么样。这一步不是重复 01 节,而是让"代码里的某一行"和"屏幕上的某一行"建立一一对应。
| 你在代码里看到的 | 终端里对应的现象 | 说明 |
|---|---|---|
self.timer = self.create_timer(period, self.timer_cb) | 发布端每秒滚一行日志,周期稳定 | 定时器到点才调 timer_cb,间隔由参数 publish_period 决定 |
self.pub.publish(msg) | 发布端日志出现后,订阅端几乎同瞬间也打印 | publish 入队即返回,DDS 搬运和你的代码无关 |
def callback(self, msg) | 订阅端打印 浓度正常: 312.91 ppm | 回调收到的 msg.data,正是发布端那一行的 ppm |
if ppm >= self.alarm_threshold | 阈值调低后日志从 INFO 变 WARN [报警] | 同一个回调,判断条件一变,输出就变 |
declare_parameter('publish_period', 1.0) | ros2 param get 能查到,-p …:=0.5 能改 | 声明过的参数才能被外部读写(EXP-10 细讲) |
ppm=312.91,说出它是哪个函数算出来的、经哪一行发出去的、在订阅端哪一行被打印出来。说得出这条链路,这节就算过了——比记住语法重要得多。05动手任务:改点新东西,验证你真的懂了
01 节和 04 节第 3 步都是"跟着看现象"。这一节的任务故意不重复那些操作——每个任务都要你改一处新东西(改代码、改参数、加节点),然后自己判断"结果应该是什么样"再去验证。能预判结果,才算真懂。
gas_sensor_node.py,把 '/gas_sensor' 改成 '/gas_sensor2',只重编发布端所在的包,重启发布端。先预判:订阅端还能收到吗?
然后验证:
ros2 topic list 现在有几个话题;再把订阅端的名字也改成 /gas_sensor2 看是否恢复。要记录:改名前后的
topic list 两张截图。timer_cb 里加一行日志,把 publish_period 的当前值打印出来(self.get_parameter('publish_period').value)。先预判:用
-p publish_period:=0.5 启动时,日志里的数字是 1.0 还是 0.5?要记录:加参数启动后日志里那行数字的截图。
这题考的是"参数在 init 里读一次还是每周期读一次"的差别。
ros2 topic pub 反向操作:先停掉发布端,用 pub 手动发数据,看订阅端是否照常报警。先预判:手动发的消息和真实传感器数据,订阅端能分辨吗?
要记录:命令与订阅端输出,并解释"为什么分不清"。
reliability 改成 BEST_EFFORT,订阅端保持 RELIABLE。先预判:会报什么错?
然后验证:不会报任何错,只是收不到。用
ros2 topic info /gas_sensor --verbose 找出两端的档位差,再改回来。要记录:verbose 输出里两端 QoS 不一致的那几行。
要记录:一张架构草图 + 三要素的选择理由,不用真写代码。
先预判:终端 B 的 raw / voltage / ppm 三个数,哪个会变、方向如何?
然后验证:ppm 跳升时 voltage 是升还是降?回到
common.py 的公式 rs = RL·(V_REF−v)/v 解释这个方向。要记录:跳升瞬间的日志 + 你用公式写出的解释。测完开窗通风。
| 证据 | 来源命令/终端 | 对应知识点 |
|---|---|---|
| 发布端每秒日志(raw / voltage / ppm) | 终端 B | ADC → 电压 → ppm 换算链路(04 节 ①) |
| 订阅端"浓度正常 / [报警]"日志 | 终端 C | 订阅回调与阈值判断(04 节 ③) |
ros2 topic info 输出 | 终端 D | 话题类型、收发端数量、QoS 实况(03 节 QoS) |
ros2 topic hz 改前/改后两张 | 终端 D | 参数化设计:运行时不改代码调频率 |
ros2 topic echo 与 Subscription count=2 | 终端 D | 一对多与位置透明(02 节三个承诺) |
| 打火机放气时的 ppm 跳升 | 实物 + 终端 B/C | 端到端链路的物理验证 |
06注意事项与验收清单
| ⚠️ 注意项 | 说明 |
|---|---|
| QoS 一致性 | 发布/订阅端须同为 Reliable + KeepLast(10),否则可能收不到消息 |
| 环境加载 | 每次新开终端都要 source install/setup.bash |
| 话题命名 | /gas_sensor 以 / 开头为全局命名空间 |
| 安全提示 | MQ5 通电后表面发热勿用手触;测试仅低浓度演示,严禁明火 |
- 双节点运行,ros2 node list 同时显示两个节点
- ros2 topic info /gas_sensor 显示 Pub=1、Sub≥1,类型 std_msgs/msg/Float32
- ros2 topic hz ≈ 1.000 Hz,改 publish_period:=0.5 后变 ≈ 2Hz
- 第三方 ros2 topic echo 可同时接收(一对多验证)
- 调低 alarm_threshold:=100.0 后出现 [报警] 日志
- 口头解释:为什么发布端不用知道订阅端 IP?MQ5 对数换算四步链路?
07实验总结与思考题
--ros-args -p 动态调参。