点一下网页按钮,车真的动了:我一个人改造 ROS2 小车(第 1 期)

《一个人,一辆 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 撑着的。

这一条后来成了整个项目安全设计的地基。


八、这一期留下的三条原则

  1. 先钉死接口形状,再写代码。 所有返工都来自"我以为接口是这样"。
  1. 静默失败是最危险的失败模式。 报错崩溃很容易发现;"看起来成功但没生效"才要命(限速、电量都是这一类)。
  1. "能跑"和"安全"是两件事。 遥控链路当天就跑通了,但急停、断链刹车的完整回归还没做------那时候的"能用",还不能给人演示。

九、下一篇

链路跑通了,但真正的战斗刚刚开始。

下一篇讲车侧那 753 行代码:为什么说它是车和平台之间唯一的"脐带",以及里面四个绝对不能省掉的机制------如果删掉任何一条,车就可能在你不想要的时候动起来。

第 2 期:《车和车之间的那根脐带:753 行车侧桥拆解》


本文是《一个人,一辆 ROS2 小车》连载第 1 期。文中所有数字均来自本地实测日志,涉及身份与网络信息的部分已做脱敏。

相关推荐
天远大数据2 小时前
从调用账单到业务回执:Agent单位成本核算实践
人工智能·后端
默_笙2 小时前
⛄ 让大模型自己写 Cypher:GraphRAG + Text2Cypher 全流程拆解
人工智能
方方洛2 小时前
ray教程-00-前言与导读
人工智能·分布式·机器学习
方方洛2 小时前
ray教程-01-认识Ray
人工智能·分布式·机器学习
乐橙开放平台2 小时前
智慧连锁客流检测和离岗检测怎么对接
大数据·人工智能·笔记·物联网·自动化·音视频·智能家居
zw_onemaker_ai2 小时前
开源一条 14 角色 AI 交付管线:从 3 个零依赖 Demo 讲起
人工智能·ai编程
xn71332 小时前
EmbeddingGemma 2 270M 实测:278 Chunk、32 个查询与 RRF 反例
人工智能·后端·架构
七牛云行业应用2 小时前
OpenCode 报错 Rate limit exceeded:限流原因、日志定位与修复步骤(2026 年 10 月)
人工智能·github
hahaha60162 小时前
FPGA+ARM实现AWB的全流程
人工智能·嵌入式硬件·算法·计算机视觉·fpga开发