☰
从网页按钮到电机转动:ROS2小车遥控链路搭建实战
2026/10/18 1:45:26 网站建设 项目流程

折腾到第三个晚上,我终于在网页上点下“前进”这个按钮,两秒之后,茶几上那辆用扎带固定着控制板和电池的小车,轮子真的转了。对于大部分时间都在跟终端日志打交道的我来说,那一刻确实有点奇妙——在浏览器里按下一个按钮,物理世界就有反应,这种“闭环感”比跑通任何一份官方案例都更上瘾。

“点一下网页按钮,车真的动了”,这听起来不复杂,但真正动手做的人会知道:它横跨了浏览器事件、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 Nano1 块有硬件中断引脚,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_vel
  • serial_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~650死区,电机根本不转
900.08缓慢爬行,适合微调
1200.15中速,适合室内演示
1500.22较快,转向还能兼顾
1800.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 闭环、第三期的避障,都会顺着这条路走下去。

下一期我打算把编码器闭环补上,让小车走直线不再跑偏,然后把机载摄像头接到网页上,做一个真正能“看得见、控得住”的遥控小车。这一期先到这里,我去给这辆小车换一对抓地力更好的轮子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询