《一个人,一辆 ROS2 小车》连载第 1 期。 这一期讲:接到"做个网页遥控台"的需求后,我为什么先不写代码;这辆车的真实结构是什么;技术选型为什么放弃了现成方案;以及第一个深夜里程碑------点按钮,车真的动了。 读完你能拿走:一套"真机接入 Web 平台"的选型思路,和三条我踩出来的原则。
一、先说清楚这个连载要写什么
我手上有一辆 ROS2 教学小车,和一个需求:在网页上点按钮,让真车动起来,最后做成一套校园巡检平台。
听起来是个前后端活儿,实际上它横跨了五层:网页 → 后端 → 消息中间件 → 车上的机器人操作系统 → 单片机和电机。任何一层出问题,现象都是同一个------ "我点了,车不动" 。
这个连载不是 ROS2 入门教程。它记录的是中间那段没人写的东西:真车和你以为的不一样、代码说完成了但其实没生效、报错信息骗了你。
第 1 期从最开头讲起。
二、Day 0:需求到手,我先忍住没写代码
需求很长,一句话概括:做一个平台,能看小车的位置、电量、摄像头画面和雷达,能遥控它,最后能让它自己按路线巡检。
我在第一天做的事情很反直觉:一行代码都没写。
我先去把能拿到的资料全扒了一遍------车的手册、例程、参数表都放在一个网盘分享里。
第一个坑来得比想象中早。
想通过脚本直接转存网盘里的分享文件,接口会返回
errno=9019 need verify(触发风控)。换工具同样失败。结论:这类"他人分享"的转存接口走不通,只能在浏览器里人工点一次"存到我的网盘",之后再用自己网盘(own-disk)的接口去拉------自己网盘的操作不需要额外校验。
这一天真正的收获不是资料,是确立了一条贯穿全程的原则:
先确认"数据从哪来、接口什么形状",再动手写代码。
后面所有返工,几乎都发生在"我假设了接口形状"的地方。
三、认车:从手册到真机,差了不止一点
资料到手后,第一件事是搞清楚这辆车到底是什么。
它是一辆幻尔 JetAuto ,一架典型的双层架构教学车:
| 层 | 硬件 / 系统 | 它负责什么 |
|---|---|---|
| 上层 · 主控 | Jetson Orin NX,Ubuntu 22.04.5 + ROS2 Humble | 感知、决策、通信 |
| 连接 | USB 串口,RRC 协议(0xAA 0x55 帧头 + 功能码 + CRC 校验,小端) |
传输速度与转向指令 |
| 下层 · 底盘 | STM32F407 + FreeRTOS | 把速度和转向变成电机 PWM |
一句话:上层的 Jetson 管"想什么",下层的 STM32 只管"怎么转"。
这个结构决定了后面所有的技术选择:我要控制车,本质上是"把指令送到 Jetson,让 Jetson 在 ROS2 里发布出来"。既不用碰单片机,也碰不到电机。
手册和真机的差距,从这里开始
手册里主控型号写了四种可能,我把它标成"必须现场确认",没有赌。
后来上电实测------是第五种。而这只是开始,"手册 vs 实测"的差异清单最终长成这样:
| 手册/预期 | 实测真相 |
|---|---|
| 主控型号"四选一" | 实测是第五种(Jetson Orin NX,Ubuntu 22.04.5 + ROS2 Humble) |
电池话题是 Float32 |
实际是 std_msgs/UInt16(单位是毫伏) |
/cmd_vel 是控制入口 |
真正的底盘驱动入口是 /controller/cmd_vel;/cmd_vel 是 App 入口,而且内部限幅 ±0.2 m/s |
| 底盘"应该"有失联保护 | 驱动回调里零超时保护,只保存最后速度 → 车能不能停下,全靠车侧桥兜着 |
| 底盘只有一个指令源 | 开机时 /controller/cmd_vel 挂着 7 个发布者,包含手柄和语音(张嘴就开车) |
最后一行后来变成了一个大坑。 但这已经是第 5 期的事了,先记着。
四、选型:三条路,为什么我选了最土的那条
现在到了最关键的决策:平台和车之间放什么?
当时有三条路可选:
| 方案 | 做法 | 问题 |
|---|---|---|
| A. 自写车侧桥 + MQTT | 车上跑一个常驻进程,订阅 MQTT 指令,转成 ROS2 话题 | 要自己写,要自己维护 |
| B. rosbridge 直连 | 直接连车上的 rosbridge_websocket (9090),走 WebSocket 发 ROS 指令 |
零开发,但没有 deadman(失联刹车) |
| C. 现成 mqtt_client 包 | 直接装厂商/社区提供的桥接组件 | 只给"转发",给不了业务语义 |
我选了 A。 理由不是 A 更好写------恰恰相反,A 是最费事的。理由在于B 和 C 给不了的东西:
一个遥控系统的真正难点不是"把速度发过去",而是:
- 失联了怎么办? 网络抖一下,车会不会带着最后的速度一直冲?
- 指令过期了怎么办? 我告诉用户"发送失败",3 秒后网络恢复,这条指令该不该执行?
- 重复指令怎么去重? 断线重连后,序号从哪开始?
- 怎么判断"车真收到了"? 中间件回一个"已接受",等于车执行了吗?
这四件事------deadman、ttl 过期丢弃、去重、会话序号------都是业务语义,现成组件不会给你。而它们每一件都直接关系到"车会不会撞墙"。
所以结论是:
遥测(往上走的数据)可以借现成的,指令闭环(往下走的控制)必须自己写。
顺便,B 方案后来实测还有一个致命短板:直连 rosbridge 发一条速度指令有明显的启动延迟 ------发 500ms 的脉冲,车纹丝不动(被完全吃掉);要 1500ms 才稳定走出 83mm。也就是说它只适合"按住持续走",不适合"点一下走一点"。于是 rosbridge 被降级成备用通道。
五、全链路长这样
定完型,架构就出来了:
| 层 | 组件 | 它负责什么 |
|---|---|---|
| ① 呈现 | 浏览器 · Vue 3 + Canvas | 画面渲染、操作采集 |
| ② 服务 | 后端 · Spring Boot 3 / JDK 17 | 状态机、看门狗、控制权、急停 |
| ③ 桥接 | 车侧桥 car_agent.py |
MQTT ⇄ ROS2 双向转发(唯一自己写的车侧代码) |
| ④ 主控 | /controller/cmd_vel |
真正驱动底盘的话题 |
| ⑤ 执行 | STM32F407 底盘 | 电机 PID、编码器、IMU |
一条指令往下走,完整路径是:
浏览器 →(WebSocket)→ 后端 →(MQTT QoS1)→ 车侧桥 →(ROS2)→ /controller/cmd_vel →(USB 串口)→ 底盘
有两个刻意的设计:
① 后端留了一个适配器接口。 控制逻辑不关心指令是发给模拟车还是真车,mock 和 mqtt 两种实现对上层完全透明。这样没有车在场的时候也能开发(第 11 期细讲)。
② 车侧桥要尽可能地薄。 它是"平台 ↔ 车"的唯一物理落点,越薄越好维护。代码里只允许出现两类东西:纯映射 (前进 = 正向速度)和安全机制(deadman / ttl / 去重)。任何"让车按我想的动"的业务逻辑,都应该在平台侧解决。
六、当天晚上 11 点:点按钮,车动了
车侧桥写完,当天晚上就上了真车。
流程是:起 broker → 部署桥 → 起桥 → 打开网页 → 按下"前进"。
然后车动了。
当天的证据是三方对得上的:
平台侧 :连续下发 FORWARD × 207、LEFT × 125、STOP × 82、RIGHT × 69、BACKWARD × 69。
车侧 :收到会话开始帧 → 序号归零 → STOP 指令走完 EXEC 闭环。
真车里程计 :x: 0.011 → 0.245 m,yaw: 0.24 → 1.123 rad(约 64°)。
那一刻确实挺爽的。但真正有价值的发现,是紧接着排查"车动得不太对"时挖出来的。
七、别急着庆祝:当天就发现的三个真相
真相 1:车侧脚本默认限速,把平台显示的速度变成了谎言
现象:平台滑块拉到 0.6,车实际只跑 0.15。
原因:车侧桥的 --max-linear 默认值是 0.15 ,启动脚本又没传这个参数。于是平台显示"0.6 m/s",车老老实实跑 0.15------腰斩四倍。
这不是显示 bug,这是诚信问题:控制台上写的数字,必须是真实发生的数字。
真相 2:电量永远是 0%,而且没有任何报错
现象:平台电量一直 0%。
原因:车侧桥的电池电压换算区间(--battery-min-v / max-v)默认值是 0------当初拿 0 当"关闭这个功能"的哨兵值。启动脚本没传,于是换算直接失效。
同一个"默认值选成 0"的坑,我踩了两次(另一次就是上面的限速)。
教训:默认值必须是"最常见的真实场景",不能拿 0 当关闭开关。静默降级比崩溃可怕得多。
真相 3:底盘的"失联即停"不存在
打开驱动源码一看,接收速度的那个回调函数只做了一件事:
def cmd_vel_callback(self, msg): self.last_cmd = msg------ 把最后一条速度存下来,没有任何超时判断。
也就是说,只要没人发新指令,底盘就会保持最后的速度一直跑下去。厂商没有做失联保护。
于是"网络断了车会停下"这件事,完全是车侧桥里那 600ms deadman 撑着的。
这一条后来成了整个项目安全设计的地基。
八、这一期留下的三条原则
- 先钉死接口形状,再写代码。 所有返工都来自"我以为接口是这样"。
- 静默失败是最危险的失败模式。 报错崩溃很容易发现;"看起来成功但没生效"才要命(限速、电量都是这一类)。
- "能跑"和"安全"是两件事。 遥控链路当天就跑通了,但急停、断链刹车的完整回归还没做------那时候的"能用",还不能给人演示。
九、下一篇
链路跑通了,但真正的战斗刚刚开始。
下一篇讲车侧那 753 行代码:为什么说它是车和平台之间唯一的"脐带",以及里面四个绝对不能省掉的机制------如果删掉任何一条,车就可能在你不想要的时候动起来。
第 2 期:《车和车之间的那根脐带:753 行车侧桥拆解》
本文是《一个人,一辆 ROS2 小车》连载第 1 期。文中所有数字均来自本地实测日志,涉及身份与网络信息的部分已做脱敏。