点一下网页按钮车真的动了我一个人改造 ROS2 小车第 1 期开篇我先直说这个标题的画面是真实发生过的。晚上十一点多我把手机浏览器打开调到那台自己攒的 ROS2 小车控制页面上点了一下“前进”按钮隔着半张桌子那台小车带着轻微的电机声往前挪了几厘米。那一刻的成就感比跑通任何 Hello World 都来得实在。这篇文章记录的就是我从零开始把一个只能用手柄推着玩的底盘改造成能用网页远程控制、跑在 ROS2 之上的完整小车的全过程。适合手里有 ROS2 基础、想动手做实物但一直卡在“代码能跑车不动”这个环节的朋友参考。第 1 期的内容我只讲一件事从“网页按钮”到“车轮转动”这整条链路是怎么打通并跑起来的。具体包括硬件端怎么选型、ROS2 环境怎么搭、网页控制端怎么写、以及最终联调阶段那些文档里不会写的坑。1. 先把家底盘清楚从一台只会原地转圈的底盘开始1.1 我手里这台车的原始状态这台车不是从零装配的而是某款常见的 ROS 入门级履带/轮式底盘用的是国产 STM32 主控板板载电机驱动带编码器反馈。卖家给了一套串口协议可以通过 UART 发指令控制左右轮转速。出厂自带的固件不算差但有个问题它在 ROS2 生态里是“半聋半哑”的只有一坨基于 ROS1 的旧驱动包勉强能发 cmd_vel 话题但话题名字、坐标系、频率控制、QoS 策略全都是默认值跑在 ROS2 上的时候各种毛病。我拿着它折腾了几天最后决定彻底改写底层的通信方式自己搭一套可靠性更高的控制链路。硬件配置方面主控板是 32 位 MCU三个串口可用其中一个留给底盘通信。电机是带霍尔编码器的减速电机两轮差速驱动外加一个万向轮。底板上有 12V 电源接口、一个 5V/3A 的降压模块还有几个 GPIO 口。整车的结构其实很简单但“简单”不等于“好改”恰恰因为省掉了中间层所有问题都得自己扛。1.2 为什么我不直接把原厂驱动包拉起来用原厂驱动包的问题不是“能不能用”而是“你敢不敢用”。它虽然名义上支持 ROS2但实际上是 ROS1 时代的代码加了一层简单封装话题名、参数名全部沿袭旧习惯。比如控制指令实际用的是/cmd_vel但反馈里程计发布在/odom发布频率大概只有 2Hz这对于闭环控制来说是灾难级的延迟。更麻烦的是原厂固件对电机的软启停处理很粗糙直接发速度指令会有一个猛的加速冲击车会点头。所以我的第一步改造不是从软件开始的而是从固件侧的通信协议入手。把原来那些花里胡哨的协议字段全部砍掉只保留了三个核心指令通道速度设定、速度反馈、底盘状态回传。速度设定用左右轮线速度表示单位 mm/s频率固定 50Hz 刷新这样上层控制才有足够的实时性。反馈通道也是 50Hz数据包括当前左右轮实际速度、编码器累计数、电源电压、驱动板温度。处理完这些后面的 ROS2 侧才有的商量。2. ROS2 环境搭建与“名字空间”这件小事2.1 开发机上 ROS2 环境的选型与安装踩坑我的开发机是一台 16GB 内存的笔记本装了 Ubuntu 22.04ROS2 版本选的是 Humble。选 Humble 的一个重要原因是它的生命周期覆盖时间长而且跟 Ubuntu 22.04 的兼容性很好apt 直接安装比从源码编译省掉太多麻烦。过程中最大的坑是rosdep初始化如果网络环境不好rosdep会卡在下载索引那一步我先后试了改源、换镜像、关掉 IPv6最终发现问题是/etc/resolv.conf里的 DNS 配置太慢换上公共 DNS 之后几分钟就把索引拉完了。安装完成后我做的第一件事不是急着写代码而是把所有和底层通信相关的依赖包一次性装齐ros-humble-teleop-twist-keyboard键盘遥控调试用、ros-humble-joy手柄控制备用、ros-humble-nav2-*后续做自主导航用的先装上、ros-humble-rviz2和ros-humble-rqt*可视化工具。我用了一个脚本把这些包一次性装完省掉后续频繁补装的麻烦。还需要提一嘴的是如果你打算后续做 SLAM 或者导航建议直接在装环境阶段就把navigation2和slam_toolbox一起拉下来。这是因为这些包彼此之间有复杂的依赖关系分开装经常会遇到某一次apt upgrade把某个库升级到不兼容版本然后整个导航栈全都起不来的情况。一次性装齐后面省心很多。2.2 为什么要给所有话题加上名字空间前缀原厂包另一个让我难受的地方是它发布的话题都是全局裸名比如/cmd_vel、/odom、/imu一旦同时跑两个小车或者想在一台机器上仿真一台、实机一台话题就会互相冲突。ROS2 的 DDS 机制使得这种冲突不仅仅是话题名重复的问题还会导致消息类型不匹配、QoS 策略互相不兼容轻则延迟重则直接丢包。我的做法是所有自定义话题一律挂在/tbot这个命名空间下面比如/tbot/cmd_vel、/tbot/odom、/tbot/status。这样做的另一个好处是后续如果要引入多台车每一台车只需要换掉命名空间前缀所有代码逻辑完全不用动。具体实现上在节点初始化时用create_publisher传一个带命名空间的话题名就行或者在 launch 文件里用namespace参数统一设置二选一没有难度。我在实际项目里的做法是在 launch 文件里给整个节点组加上namespacetbot同时把remap规则写清楚把默认的/cmd_velremap 到/tbot/cmd_vel。这样系统里的每个节点都活在同一个命名空间里不会串线。提示如果你打算用 RViz2 看机器人模型和 TF命名空间记得要跟 TF 的frame_id前缀保持一致否则 RViz 会认为 TF 树断裂显示一片空白。2.3 QoS 策略为什么“发出去的指令”会丢ROS2 跟 ROS1 最大的不同之一就是 QoS 策略。底层是 DDS所以每个话题在创建的时候就要确定可靠性策略RELIABLE还是BEST_EFFORT、历史策略KEEP_LAST还是KEEP_ALL、以及深度depth。如果你的发布端声明的是RELIABLE KEEP_LAST(10)那订阅端必须兼容这个策略否则会报“Incompatible QoS”错误节点直接订阅失败。具体到我们这个小车场景控制话题/tbot/cmd_vel必须用RELIABLE不能丢最好是KEEP_LAST(1)因为控制指令只需要最新状态旧指令没用。反馈话题/tbot/odom用BEST_EFFORT KEEP_LAST(10)因为里程计是周期性数据丢几帧没关系但希望看到最近的状态。/tbot/status状态话题用RELIABLE KEEP_LAST(5)因为状态信息虽然不频繁但每条都重要。如果不注意 QoS 的匹配最典型的现象就是网页上按钮点了没反应或者车走了一下就停了然后终端里全是DDS discovery相关的警告。我调试了一晚上才意识到问题出在发布端和订阅端的 QoS 策略不匹配上。这个细节 一定要记得。3. 网页按钮背后的完整链路“浏览器 → WebSocket → 节点 → 底盘”3.1 网页端用什么技术选了 WebSocket ROS2 Bridge网页端直接跑 ROS2 原生库不是不可以比如ros2-web-bridge方案但我更喜欢把链路简化成“纯 WebSocket JSON 指令”因为网页端不需要感知 ROS2 的复杂类型只需要发一个 JSON 字符串比如{linear: 0.2, angular: 0.0}。具体的实现是这样的网页通过 WebSocket 连到本地的一个桥接服务我用的是 Python 写的websockets库这个桥接服务内部订阅/tbot/cmd_vel和/tbot/odom话题收到网页的 JSON 指令后转换成Twist消息发布出去同时把里程计数据以 JSON 形式发回网页。这里有个很实际的好处浏览器里不需要任何 ROS2 依赖也不需要 DDS 发现协议只要支持 WebSocket随时随地能连。我甚至把手机热点打开来接小车控制端完全没问题。桥接服务的核心代码结构大概是这样的import asyncio import json import websockets import rclpy from rclpy.node import Node from geometry_msgs.msg import Twist from nav_msgs.msg import Odometry class WebBridgeNode(Node): def __init__(self): super().__init__(web_bridge_node) self.publisher self.create_publisher(Twist, /tbot/cmd_vel, 10) self.subscription self.create_subscription(Odometry, /tbot/odom, self.odom_callback, 10) self.odom_data {} def odom_callback(self, msg): self.odom_data { x: msg.pose.pose.position.x, y: msg.pose.pose.position.y, theta: 0.0, # 实际需要从四元数转欧拉角 } async def handle_client(self, websocket, path): async for message in websocket: data json.loads(message) cmd Twist() cmd.linear.x data.get(linear, 0.0) cmd.angular.z data.get(angular, 0.0) self.publisher.publish(cmd) await websocket.send(json.dumps(self.odom_data))不过这个实现只是个雏形实际项目里还加了asyncio的事件循环和rclpy的多线程处理。你写的时候记得要在同一个线程里跑rclpy.spin_once()然后另外开一个线程跑 WebSocket 服务两者通过一个全局队列通信。这个结构我一开始没处理好结果 WebSocket 一启动rclpy节点就卡死了。3.2 网页端的最简实现不用框架也能写网页我用了最简单的纯 HTML JavaScript没有上 Vue 或 React因为控制页面只需要几个按钮和一块状态面板没必要引入构建工具链。按钮动作的逻辑很简单按住某个方向键时每隔 100ms 发送一次对应速度指令松手时立即发送零速度指令。这个“按住有效、松开停止”的模式很重要否则会出现“按一下前进、车就一直跑”的危险情况。网页端还会同时显示里程计信息坐标位置、线速度、角速度。整体结构就是button idforward前进/button button idstop停止/button button idleft左转/button button idright右转/button div idstatus-panel/div script const ws new WebSocket(ws://192.168.1.100:8765); const forwardBtn document.getElementById(forward); forwardBtn.addEventListener(mousedown, () { ws.send(JSON.stringify({ linear: 0.2, angular: 0.0 })); }); forwardBtn.addEventListener(mouseup, () { ws.send(JSON.stringify({ linear: 0.0, angular: 0.0 })); }); /script这个设计有一个好处安全。不管网页端发生什么意外比如页面卡死、按钮事件失灵只要你松开手最后一条指令一定是停车。我建议所有做遥控类项目的人都要遵守这条“手指离开立即停车”的原则。3.3 为什么不在浏览器里直接发 DDS 消息有人可能会问为什么不直接用ros2-web-bridge这类方案省去自定义 WebSocket 服务这一步答案是可以但没必要。官方的 web bridge 方案本身要跑一个rosbridge_server架构比自写的桥接服务要重很多而且它默认的转发层用的是 JSON-RPC 风格的消息格式前端需要额外引入一套roslib.js学习成本不小。我自写的这个 JSON 指令桥接服务核心代码就一两百行前后端协议非常自由以后想加什么自定义指令都简单。而且更重要的是这个桥接服务本身构成了一道“安全过滤层”。我可以在这层做身份校验、指令白名单、速率限制。如果以后把小车开放到公网这些安全手段是刚需。直接在网页里用roslib.js直连 DDS 反而把底层直接暴露给了前端不利于安全。4. 联调阶段实录从“波形正常”到“车真的动了”4.1 第一关先确认底层通信有没有“诈胡”我把改造分成四个阶段波形确认、下桥尝试、真车跑动、故障注入。第一关是波形确认。我不急着接电机先把底盘驱动板的串口线接到 USB 转 TTL 模块上在电脑上开一个串口调试助手用 115200 波特率直接跟底盘通信。发一条速度指令观察电机是否转动、编码器反馈频率是否正确。这一步很关键因为如果这一步就出错后面用 ROS2 去排查只会更麻烦。我之前遇到过一个巨大的坑驱动板的串口发送速度指令后电机完全不动但串口助手显示发送成功。排查了半天发现是驱动板上的“使能引脚”没拉高电机驱动芯片处于待机状态自然不会转。把使能引脚接到高电平之后电机立刻就有了反应。这种小细节不是看说明书能发现的必须自己拿万用表/示波器一点一点测。所以我在改任何小车前都会先准备一个简单可靠的“串口测试脚本”能在 5 分钟内判断底盘是否是“健康状态”。4.2 第二关ROS2 侧能不能收到反馈底层没问题之后我把底盘接到树莓派开发机也可以上启动自己写的 ROS2 底盘驱动节点。这个节点负责真正的串口通信把 ROS2 世界里的Twist消息翻译成串口协议再把串口数据翻译成Odometry和Status话题。节点启动之后我先用ros2 topic list确认话题都出现了然后用ros2 topic echo /tbot/odom查看有没有数据在跳。这一步看起来简单但有个细节你要注意如果姿态信息里的四元数没有正确转换你会在 RViz 里看到一个疯狂绕圈旋转的小车模型而真实小车根本没动。这是“数据能通但不代表数据正确”的典型情况。我在调试这个的时候花了至少一小时检查串口协议里的字节序最后发现是驱动板返回的角速度符号反了导致里程计计算出来一直在原地打转。解决方式是在底盘驱动节点里加了一个sign_invert参数可以直接在 launch 文件里调整不用重新编译。4.3 第三关Web 控制端全链路测试底层、驱动节点都正常后我把桥接服务和网页端拉起来做全链路测试。这一步测试的不是功能而是延迟和稳定性。我写了一个简单的计时器点击网页按钮同时记录当前时间然后通过反馈话题看小车实际开始动作的时间差。实测下来同一局域网内延迟大概在 30~60ms 之间敲击按钮到电机开始转动体感上几乎感觉不到延迟。但如果是跨网段或者 WiFi 环境下延迟会拉到 100ms 以上这时候控制的感觉就变得“粘粘的”。我后来在桥接服务里加了“丢帧丢弃”逻辑如果连续两帧指令的时间间隔小于阈值就丢弃旧指令保留最新的。这样操作手感和实时性会好很多。4.4 第四关真车跑动以及我踩过的那些坑最后一关才是真正的“车动了”。第一次在桌面上跑车一加速就冲出桌面边缘幸好我提前挡了一个纸箱。这些小细节都在提醒你实物项目的每一秒都是真实的物理世界不是仿真器。我总结了一下联调过程里最典型的几个坑用表格列出来方便你排查现象可能原因排查方法车完全不动但话题有数据底盘使能引脚未拉高用万用表查驱动板使能电压车只往一个方向转左右轮速度指令反了交换左右轮串口指令测试车走起来一抖一抖控制频率太低或 QoS 丢帧提高发布频率到 50Hz检查 QoS 策略网页按钮没反应WebSocket 桥接服务和 ROS2 节点线程冲突把 WebSocket 服务放到独立线程运行里程计数据猛跳编码器方向反了或字节解析错误打印原始串口数据逐字节核对按下前进车反而后退驱动板 PWN 方向逻辑和预期相反在驱动节点中增加方向反转参数实测下来最耗时的是“一抖一抖”的问题。它的问题根源不是机械结构而是控制指令发布频率太低。原厂驱动包默认只是 10Hz我改成 50Hz 之后车跑起来明显顺滑了。这个频率其实取决于你的控制周期和底盘的响应速度建议从 50Hz 起步如果感觉不够可以往上调。5. “点一下按钮”背后的 500 毫秒我在这个项目里学到的最重要的东西最后一段我想聊聊这些改造过程里自己最深的体会。如果你只是想让车动起来那这个项目半小时就能搞定接上原厂包用键盘遥控一按车就跑了。但我这次改造的出发点是“我想彻底搞明白一件事情——从最上层的用户交互到最底层的物理动作之间到底经过了多少层抽象、多少层转换。”这个目标比单纯让车跑起来更难但也更有意思。当你在网页上点下前进按钮的那一刻整个系统里发生的实际过程是浏览器将{linear:0.2,angular:0.0}通过 WebSocket 发到桥接服务桥接服务把它包装成Twist消息通过 DDS 发布到/tbot/cmd_vel话题底盘驱动节点订阅到这个消息把线速度转换成左右轮的 RPM然后按协议通过串口发给驱动板驱动板上的 MCU 解析指令输出 PWM 波形驱动电机电机带动轮子转动编码器把实际转速反馈回来一路反向回到网页上的状态面板。整个过程大概几百毫秒每一次点击都走完这么长的一条链。在这个过程中每一个环节都可能断掉网络延迟、DDS 发现失败、QoS 不匹配、串口波特率配置错误、电机使能引脚没拉、电源供电不足、串口数据解析错位……每一个都是“看我帖子觉得简单自己动手就崩”的坎。但也正是这些坑让“车真的动了”这个时刻变得特别有价值。如果你也准备做类似的项目我建议你先放下“我要用最先进的技术”的念头老老实实把串口调试助手玩熟学会看波形和数据包心里有数之后再用 ROS2 逐步叠加上去。这样即使出了故障你也能一眼看出问题到底出在第几层。下一期我打算写“导航”相关的部分如何用 Nav2 让这台小车自主避障以及为什么我认为“让车跑起来”只是开始“让车不乱跑”才是真正的挑战。到时候我会把 Nav2 的参数调优过程、代价地图的配置踩坑和真实房间环境下的调试记录一起分享出来。
阅读完成 · 觉得有帮助?