折腾到第三个晚上,我终于在网页上点下“前进”这个按钮,两秒之后,茶几上那辆用扎带固定着控制板和电池的小车,轮子真的转了。对于大部分时间都在跟终端日志打交道的我来说,那一刻确实有点奇妙——在浏览器里按下一个按钮,物理世界就有反应,这种“闭环感”比跑通任何一份官方案例都更上瘾。
“点一下网页按钮,车真的动了”,这听起来不复杂,但真正动手做的人会知道:它横跨了浏览器事件、HTTP 请求、ROS2 话题通信、串口协议、电机驱动、编码器反馈这六层。我做的是一个人改造 ROS2 小车,不搞地图、不搞导航、不搞机械臂,第一期只做一件事——打通“网页按钮到电机转动”的完整链路,并且让小车能通过网页遥控走直线、原地转圈。
这篇文章写给三类人:手里有一台吃灰底盘但不知道从哪一步下手的人;已经学了 ROS2 基础但对硬件连接和串口协议发怵的人;单纯想看一个人怎么把浏览器和真实轮子串起来的人。我不会贴几十页的完整代码,但会把协议设计、选型理由、接线坑点、调参思路这些“常规文档不写的东西”尽量说透。
1. 一个人折腾到最后,才发现“车能动”才是项目的起点
做这个项目之前,我其实犹豫了很久。市面上的教程要么只讲 ROS2 通信,跑到最后小车还在模拟器里转圈;要么只讲 Arduino 驱动电机,跟机器人操作系统没有半点关系。真正缺的是一座桥——把“网页按钮”这种直观的东西,和“cmd_vel 话题”这种机器人世界的标准语言连起来。
第一期我不打算追求太多。目标很明确:浏览器里有一个控制面板,按下前进键小车前进,松开就停;按下左转键小车原地左转;页面上能实时看到左右轮测出来的转速。听起来像玩具?但正是这个“玩具”,让我把上司丢给我的一堆“物联网+机器人”的概念全部落到了实处。
为了说清楚这座桥怎么搭,我把整个系统拆成了三层:Web 控制层、ROS2 中间层、Arduino 执行层。每一层只跟相邻层对话,上一层的输出就是下一层的输入。这个拆法让我后来排查问题省了巨多时间——哪一层出毛病,拿工具一测就知道,根本不用从头到尾猜。
如果你现在也在盘算改造一台小车,我的建议是:别一上来就想着让它在房间里自己溜达。先把“点一下按钮,车动一下”这个最基础的反馈链路做出来,再谈 SLAM、避障、路径规划。没有闭环,一切上层建筑都是纸糊的。
2. 三块“翻译官”:网页、ROS2、底盘之间到底怎么说话
2.1 一条消息从浏览器到轮子的完整旅行
整条链路是这样的:
浏览器按钮按下 -> 通过 HTTP POST 发送 JSON 指令 -> FastAPI 小服务接收 -> 转换成 ROS2 的 Twist 消息发布到 /cmd_vel 话题 -> serial_link 节点订阅话题 -> 根据差速模型拆出左右轮速度 -> 通过串口发送 ASCII 文本帧 -> Arduino 解析文本帧 -> 控制 TB6612 驱动模块输出 PWM -> 电机转动 -> 编码器脉冲反馈回 Arduino -> 打包成心跳帧发回 ROS2 -> 网页轮询 /status 接口显示轮速。
这条链路里,每一层都只做一件事,而且都“说”着自己这层最熟悉的语言。网页说 HTTP,ROS2 说话题和消息,Arduino 说串口字节流,电机只听 PWM。我做的事情本质上就是四个字:协议翻译。
2.2 为什么选“薄网页 + 小服务”而不是 rosbridge
当时摆在面前的有两条路:一条是直接在网页里用 roslibjs 连接 rosbridge_server,前端直连 ROS2 话题;另一条是我最终采用的 FastAPI 中间层方案。我选了后者,原因很实际。
roslibjs 方案在前端体验上确实更“原生”,按下按钮直接 publish 一条 Twist,少跳一层。但它有几个让个人项目头疼的问题:浏览器跨域限制、需要额外维护 rosbridge 服务、以后想加用户鉴权或者录像功能都得往 rosbridge 的插件体系里钻。而 FastAPI 方案就是一个普通 HTTP 接口,逻辑清晰,想扩展什么路由就加什么路由,前后端完全解耦。
对于小车遥控这种场景,命令频率本来就很低——人手点按钮最多一秒十几次,HTTP 完全扛得住。与其追求极致低延迟,不如先把系统边界理清楚。真到了需要高频状态推送的时候,再在 /status 接口里上 WebSocket 也不迟。
2.3 为什么执行层用 Arduino 而不是直接用树莓派 GPIO
如果树莓派直接控制电机驱动板,电气上是可以的,但工程上不是好选择。原因有三个。
第一,电机的 PWM 驱动需要高频且稳定的方波信号,TB6612 这类模块通常工作在 20kHz 左右。树莓派虽然有硬件 PWM,但引脚少、配置麻烦,软件模拟 PWM 在系统负载波动时很容易抖动。Arduino 的定时器是纯硬件行为,说多少赫兹就是多少赫兹,稳得很。
第二,实时性。树莓派跑着完整操作系统,进程调度、中断响应都有不确定性。Arduino 的前后台循环和硬件定时中断,响应时间微秒级。电机控制这种对时序敏感的事,交给它更放心。
第三,大电流和干扰。电机启动瞬间电流可能到几安培,GPIO 口很容易被拉坏或者被干扰得乱跳。Arduino 板子便宜,烧了换一块就是几十块钱的事,树莓派烧了可心疼。稳妥起见,执行层必须有一个专门的“小脑”。
3. 硬件不是越贵越好:我这版配置、接线和供电
3.1 主要硬件清单
| 组件 | 数量 | 选型理由 |
|---|---|---|
| 带编码器直流减速电机 | 2 个 | 没有编码器,第二期闭环就是空中楼阁 |
| 差速小车底盘 | 1 套 | 四轮两驱,结构简单,转弯靠差速 |
| TB6612 电机驱动模块 | 1 块 | 比 L298N 压降小、发热低、体积小 |
| Arduino Nano | 1 块 | 有硬件中断引脚,USB 下载方便 |
| 2S 锂电池(7.4V) | 1 块 | 电压足够驱动电机,容量选 1800mAh 左右 |
| 1000µF 电解电容 | 1 个 | 并联在电源端,吸收电机启动压降 |
不需要买顶配。淘宝上几十块的底盘、几块钱的 TB6612、二十块的 Nano,够用了。唯一不能省的是“带编码器的电机”,这是第一期和后续所有进阶功能的分水岭。
3.2 接线图和最容易翻车的三个点
先说接线关系:TB6612 的 VM 接电池正极,VCC 接 5V(从 Arduino 的 5V 脚取),GND 与电池负极、Arduino GND 三端共地。PWMA 接 Nano 的 D9,AIN1/AIN2 接 D7/D8,控制左电机;另一路同理。编码器 A 相、B 相分别接 D2、D3 两个中断引脚,VCC 接 5V,GND 共地。
第一个坑是共地。电机用电池供电,Arduino 用 USB 供电,如果两块电源的负极不接到一起,信号线的参考电平对不上,结果就是电机像抽风一样乱转甚至不转。这是新手最容易忽略的,务必把电池负极、Arduino GND、TB6612 的 GND 三者接在同一个节点上。
第二个坑是供电干扰。我第一次按下前进按钮的时候,Nano 直接重启了。原因很简单:电机启动瞬间把锂电池电压拉低,Arduino 供电不足。解决办法是在电池输出端并联一个 1000µF 电容,或者干脆用独立的 5V 稳压模块给逻辑电路单独供电,电机电源和逻辑电源在物理上分开,只共地不共用线。
第三个坑是编码器接线。别把编码器的 VCC 和 GND 接反,A/B 相位线别悬空。我一开始图省事,把信号线直接插在面包板上,结果轮子一转数值乱跳,查了半天是因为接触不良。后来换成压接端子,问题立刻消失。对这种会震动的应用场景,线束连接必须固定可靠。
3.3 组装习惯:纸板、扎带和线束分类
不要急着把控制板锁死在车架上。我用一块硬纸板加扎带做了个临时平台,控制板、驱动板、电池都用扎带固定在纸板上,哪块要换都方便。线束分两组:电源线一组、信号线一组,分别用扎带理好,避免信号线贴着电机线走。电机线里的电流变化会产生电磁干扰,离远一点能少很多玄学问题。
上电顺序也有讲究:先接电池给驱动板供电,再打开 USB 串口调试。如果反过来,Arduino 已经上电而驱动板还没供电,逻辑引脚会处于不确定状态,电机可能突然动一下,吓你一跳也伤硬件。
4. Arduino 端:让底盘听懂串口,同时把轮速“说”回来
4.1 通信协议的设计:文本行比二进制帧更适合调试点
Arduino 跑不了完整的 ROS2,所以中间必须有一根串口线“传话”。通信协议我选了最朴素的文本行格式,没有用二进制帧。
原因特别简单:文本可以用串口监视器直接看,肉眼能明白“哦,这一行是命令,那一行是心跳”,排查问题的时候不用先写解码器。小车控制的数据量本身就小,一行命令十几个字节,就算 115200 波特率也绰绰有余,完全没必要用二进制压缩带宽。
协议设计成两种行:
- 命令行:
M 120 -120\n,M 表示设置左右电机 PWM,正数前进、负数后退 - 心跳行:
T 118 122 12\n,T 表示回传左右轮编码器计数值和电池电压(可扩展)
4.2 非阻塞循环:编码器中断和心跳怎么共存
Arduino 的 loop 里最忌讳的就是delay()。一旦 delay,串口响应会卡住,心跳会卡住,控制周期也会乱掉。我的做法是把 loop 写成一个状态机,用millis()记录时间戳,到时间就执行对应动作。
unsigned long lastControl = 0; unsigned long lastHeartbeat = 0; void loop() { // 1. 读串口,解析一帧完整的命令行(以 '\n' 结尾) while (Serial.available() > 0) { char c = Serial.read(); if (c == '\n') { parseCommand(buffer); buffer.clear(); } else { buffer += c; } } // 2. 每 10ms 刷新一次电机 PWM,保证响应及时 if (millis() - lastControl >= 10) { applyMotorPWM(pwmLeft, pwmRight); lastControl = millis(); } // 3. 每 100ms 计算一次编码器速度并发送心跳 if (millis() - lastHeartbeat >= 100) { sendHeartbeat(); lastHeartbeat = millis(); } }4.3 编码器中断、死区处理和心跳发送
编码器计数用硬件中断实现。直流减速电机自带的光电编码器,每转输出若干个脉冲,经过减速比之后,轮子每转一圈有几百个脉冲。我的电机参数是每轮转 390 个脉冲,对应轮径 66mm,一算就能推出轮速。
volatile long counterL = 0; volatile long counterR = 0; void isrLeftA() { // 接 D2 中断 if (digitalRead(2) == digitalRead(3)) counterL++; else counterL--; } void setup() { attachInterrupt(0, isrLeftA, CHANGE); // D2 对应中断 0 attachInterrupt(1, isrRightA, CHANGE); // D3 对应中断 1 }A 相和 B 相的电平关系可以判断方向,这样前进后退都能正确计数。每 100ms 读一次计数值,除以时间就是轮速。发送心跳的代码很简单:
void sendHeartbeat() { Serial.print("T "); Serial.print(counterL); Serial.print(" "); Serial.println(counterR); }还有一个必须处理的死区问题:TB6612 在 PWM 值小于某个阈值时,电机根本不动。我实测下来阈值大约在 65 左右。所以代码里做了一个映射:小于死区直接给 0,否则映射到 65~255 区间。如果不做这个处理,你会看到网页上明明命令速度是 0.05m/s,换算成 PWM 才 40,车却纹丝不动,还以为是哪里坏了。
5. ROS2 中间层:把 Twist 变成左右轮速,再把反馈接回网页
5.1 节点划分:一个管接收命令,一个管串口翻译
ROS2 侧我写了两个节点,职责分明:
web_command:启动 FastAPI HTTP 服务,接收网页发来的指令,转换成 Twist 消息发布到/cmd_velserial_link:订阅/cmd_vel,做差速运动学拆分,把左右轮速通过 pyserial 写入串口;同时读串口回传的心跳,解析后发布到/motor_feedback
为什么不合并成一个节点?因为这两个职责未来的演化方向完全不一样。web_command以后可以被手机 App、游戏手柄、语音指令替换;serial_link以后可以直接换成直接驱动无刷电机的新方案。接口留好了,替换不用动另一层。
5.2 差速运动学:左右轮速度到底怎么算
ROS2 里标准的速度消息是 Twist,包含线速度 linear.x(m/s)和角速度 angular.z(rad/s)。对差速小车来说,左右轮速度由这两个量合成:
左轮速度 = linear.x - angular.z * 轮距 / 2 右轮速度 = linear.x + angular.z * 轮距 / 2轮距是我车轮子中心到中心的距离,约 0.22m。原地转圈就是 linear.x = 0,左右轮速度互为相反数。这个公式只有三行,但它是我整个项目里最核心的“翻译”环节。
从轮速(m/s)到 PWM 的映射,需要先做标定(后面实测章细说)。我这里用了一个简单的线性映射:PWM = speed / max_speed * max_PWM,同时用 clamp 限制在 ±255 区间内。
def cmd_vel_callback(msg): linear_x = msg.linear.x angular_z = msg.angular.z left_speed = linear_x - angular_z * wheel_base_half right_speed = linear_x + angular_z * wheel_base_half pwm_l = clamp(int(left_speed / max_speed * max_pwm), -max_pwm, max_pwm) pwm_r = clamp(int(right_speed / max_speed * max_pwm), -max_pwm, max_pwm) serial.write(f"M {pwm_l} {pwm_r}\n".encode())5.3 看门狗:网页卡了、断线了,车必须自己停
这是整个项目里我认为最值得强调的设计。网页遥控跟本地手柄不一样,中间隔着一层无线网络,随时可能卡住。如果浏览器突然崩溃,或者 Wi-Fi 抖动,而小车还维持着刚才的指令继续跑,轻则撞墙,重则伤人。
我的实现思路:web_command节点里记录最近一次收到网页指令的时间戳,启动一个 ROS2 定时器,每 0.5 秒检查一次。如果当前时间距离上次指令超过 1 秒,就立刻发布一条零速 Twist,等价于“刹车”,同时把/status接口里的看门狗标志置位。网页端看到这个状态,知道自己发出去的指令已经超时失效了。
1 秒这个值不是随便拍的。太快了,网页正常的长按操作会频繁触发误停;太慢了,紧急情况下车会多跑一段距离。实测遥控场景下,网络正常时命令每 80ms 刷新一次,1 秒的预算非常充裕。
5.4 rclpy 和 FastAPI 共存的线程问题
Python 主程序里,rclpy 的回调需要spin()才能持续处理,而 FastAPI 又有自己的 asyncio 事件循环。两者不能天真地放在同一个线程里。我的做法是:主线程跑 rclpy spin,FastAPI 用 uvicorn 在单独的 daemon 线程启动。
# 伪代码示意 def start_http(): uvicorn.run(app, host="0.0.0.0", port=8080) if __name__ == "__main__": rclpy.init() node = WebCommandNode() http_thread = threading.Thread(target=start_http, daemon=True) http_thread.start() rclpy.spin(node)这里有一个我踩过的坑:在 FastAPI 的 async 函数里直接调用 rclpy 的 publish 是不可靠的,因为两个事件循环的线程安全完全没过脑子。我最后的方案是在回调函数里用一个全局的“待发布指令”变量 + 锁,由 rclpy 侧的一个定时器轮询该变量并真正执行 publish。这样 HTTP 线程只负责把指令放到共享区,ROS2 侧负责安全消费。
6. 网页端:按钮只有几个,遥控体验要跟手
6.1 页面布局和第一次交互设计
页面我刻意做得极简:五个按钮(前进、后退、左转、右转、停止),一个速度滑杆,一个实时状态区域。没有任何框架,一个index.html加上一个app.js,丢给 FastAPI 的静态目录就能跑。
按钮交互的要求是“跟手”:按住前进就动,松开立刻停。这是我在心里反复强调的原则——千万不要设计成“按一下前进,再按一下停止”的开关模式。遥控车的安全逻辑是默认停止,只有安全状态改变才动。
6.2 长按发送的节流逻辑
如果按一下只发一次指令,松开再发一次停止,那看门狗会导致一个尴尬的问题:按钮卡片时间大于 1 秒时,中间没有新指令刷新,小车自己停了,按钮还按着呢。所以长按期间必须循环发送指令。
我用pointerdown和pointerup事件来做。按下时开启一个定时器,每 80ms 发送一次当前动作;松开时清除定时器,补发一条停止指令。
let activeAction = null; let intervalId = null; function startAction(action) { activeAction = action; sendAction(action); if (intervalId) clearInterval(intervalId); intervalId = setInterval(() => sendAction(action), 80); } function stopAction() { if (intervalId) clearInterval(intervalId); intervalId = null; if (activeAction) { sendAction("stop"); activeAction = null; } }按钮上还要加一条 CSS:touch-action: none; user-select: none;。否则在手机上长按按钮会触发原生手势,比如选中文字、弹出系统菜单,体验非常割裂。
6.3 实时状态回显:轮速终于“看得见”了
我在页面上放了四个数字:左轮速度、右轮速度、心跳时间戳、看门狗状态。每 500ms 通过GET /status接口拉一次,刷新 DOM。
看到轮速从 0 变成 12 的那一瞬间,整个链路的“闭环感”才算真正建立起来。你不是在盲猜小车有没有执行指令,而是确确实实看到轮子转了多少圈。这对调试下一个阶段的 PID 闭环也是必须的观察窗口。
还有个细节:FastAPI 要加 CORSMiddleware,否则浏览器跨端口访问会报跨域错误。
app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], )7. 实测调参:从“会转”到“走得顺”,我调了五个参数
7.1 第一次按下按钮:一片混乱
第一次按下“前进”按钮,小车的反应让我哭笑不得:左轮往前转,右轮往后转,整台车原地打转。原因很直白——TB6612 两路的电机线序不一致,A 路接线方向和 B 路相反。排查方法也简单:手动给一路 PWM,看轮转方向,然后把软件里的方向位取反。
第二次按下,车猛冲出去,差点撞到桌腿。因为我初始max_pwm给了 255,电机在全速狂转。这个教训告诉我:任何第一次调试,都要把速度上限压到很低的水平。
第三次按下,车“突突突”抖动,编码器数值乱跳。排查到最后是串口粘包:Arduino 端解析命令行时没有做字符串清洗,\r没有过滤掉。在解析函数里加一个.trim()就解决了。
7.2 速度标定:PWM 不等于速度
网页端我用的单位是 m/s,但 Arduino 最终要的是 PWM 值。这两个量之间不是理论上的线性关系,必须实测标定。
我的标定方法:把车架起来,给定一个 PWM 值,用秒表和卷尺测轮子在地面跑过的距离和时间,算出实际速度。多测几组点,形成一张“PWM-速度”对照表。
| PWM 值 | 实测速度 (m/s) | 观察 |
|---|---|---|
| 0~65 | 0 | 死区,电机根本不转 |
| 90 | 0.08 | 缓慢爬行,适合微调 |
| 120 | 0.15 | 中速,适合室内演示 |
| 150 | 0.22 | 较快,转向还能兼顾 |
| 180 | 0.30 | 快速,急停会滑行 |
| 200 以上 | 明显打滑 | 不建议使用 |
所以我在最大速度参数上直接填了 0.3,对应 PWM 约 180。这样既给死区留出缓冲,又不会一上来就失控。
7.3 转向死区和最小转弯动力
转向时最容易翻车的地方是速度太小:左右轮差速很小,两边都落在死区里,等于没转。我调参时发现,原地转圈需要差速达到 40 以上才有明显效果——也就是左轮 PWM 比如 100、右轮 140,转速差 40 左右。
如果想让转向更柔和,可以把基础速度提上去,左右差速减小;如果想让转向更灵活,就保持基础速度为 0,让左右轮一正一反。实测下来,基础速度 0.14m/s + 差速 0.25m/s,是我这个底盘最舒服的转弯手感。
7.4 开环直线偏差和后续闭环的伏笔
把速度调顺之后,我让车沿着长约 2 米的走廊直线行驶,结果偏移了大约 18~25 厘米,方向时左时右。原因也直白:左右电机的减速箱摩擦力、轮子直径、负载都存在差异,开环系统根本不可能走直。
如果不考虑电机差异,直接调高某一侧 PWM 强行拉直线,只能针对当下的电池电压和路况,换个环境又歪了。所以,第一期我果断接受了这个偏移量,把它明确列为第二期的核心任务:加上编码器闭环,让左右轮速度闭环一致,才能从根上解决直线问题。
在这张调参表里我记录下了每个参数在当前硬件上的“脾气”,下次换电机或换电池,直接照着表重新标定就行。
8. 这一期折腾完,留下的不是代码,是三句话
第一期做完,小车终于能从网页按钮那里“听懂”前进、后退、转向。我复盘了一下整个项目,真正值得记住的其实只有三句话。
第一,先打通链路,再谈性能。如果指令从按钮到轮子的通路都断断续续,后面 PID 闭环做得再好也没有意义。第一期所有工作的核心,就是让六个环节的“翻译”一次通过。
第二,文本协议和看门狗是会一直留到后面的设计。文本协议省了我太多调试时间,看门狗则把遥控这件事从“危险动作”变成了“可控行为”。这两样是我以后做任何机器人控制栈都会保留的。
第三,轮速反馈是分水岭。没有编码器反馈,小车只是一台遥控车;有了反馈,它才配叫“机器人”。第一期虽然只做到了“看得见轮速”,但这扇门一打开,第二期的 PID 闭环、第三期的避障,都会顺着这条路走下去。
下一期我打算把编码器闭环补上,让小车走直线不再跑偏,然后把机载摄像头接到网页上,做一个真正能“看得见、控得住”的遥控小车。这一期先到这里,我去给这辆小车换一对抓地力更好的轮子。