搞过轮式小车项目的朋友应该都有体会:真正费时间的往往不是电机驱动,也不是某个传感器,而是“树莓派里的 ROS2 话题数据,怎么可靠地送到 STM32 去控制电机”。ROS2 侧/cmd_vel用起来很方便,但串口另一头的单片机就像一个黑盒,帧格式、校验、粘包半包、电机方向、超时停车,每个环节都可能埋坑。
这篇文章围绕“树莓派运行 ROS2、通过串口控制 STM32 小车”这套完整链路展开,从系统架构讲到通信协议,再到两端代码和联调排错。内容偏向一套可以直接照做的闭环教程,适合 ROS2 初学者、STM32 玩家,以及正在做竞赛小车或课程设计轮式机器人的人。
1. 项目背景与整体架构
1.1 为什么是树莓派 + ROS2 + STM32
很多入门小车方案是“STM32 直接控制电机”,代码简单但缺少上层调度能力;也见过“树莓派直接接电机驱动板”,接线省事但受 Linux 实时性和 GPIO 数量的限制。把两者组合起来,是比较主流且适合做扩展的架构:
- 树莓派负责“大脑”工作:运行 ROS2、接收手柄/键盘指令、跑导航算法、处理摄像头、做路径规划。
- STM32 负责“小脑”工作:接收串口指令、产生 PWM、控制电机方向、读取编码器、做底层速度闭环。
- 两者通过串口(UART)通信。ROS2 话题里的速度消息经过协议打包后发送给 STM32,STM32 解析后驱动电机。
这个组合的优点是分工明确。Linux 生态丰富,开发效率高;STM32 实时性好,适合直接操作定时器、GPIO 和中断。后续你想加激光雷达、IMU、摄像头,都只需要在树莓派这端扩展 ROS2 功能包,底盘端不必反复改。
1.2 系统模块与数据流
整个小车系统的数据流可以概括为:
键盘/手柄/Rviz2 ↓ /cmd_vel 话题 (geometry_msgs/Twist) ↓ 树莓派 ROS2 串口桥接节点 serial_bridge ↓ 串口协议帧 USB 转 TTL / 板载 UART ↓ STM32 串口接收解析 ↓ 运动学解算:v、w 换算左右轮速度 ↓ TIM 定时器 PWM 输出 + GPIO 方向控制 ↓ 电机驱动模块(TB6612 / L298N) ↓ 直流减速电机带动轮子从 ROS2 的角度看,最核心的是如何将/cmd_vel话题中的Twist消息,转换成一段适合串口传输的二进制协议。STM32 端不需要关注 ROS2 是什么,只需要能正确解析协议帧并执行控制。
1.3 本文覆盖的内容
本文不会把 ROS2 和 STM32 的所有细节从头讲一遍,而是把两端的衔接点讲透。读完你可以掌握:
- 树莓派上 ROS2 环境与工作空间的搭建思路。
/cmd_vel话题的含义和使用方式。- 一套适合 STM32 解析的串口帧协议设计。
- 树莓派端桥接节点的 Python 实现。
- STM32 端串口接收状态机、PWM 输出的核心逻辑。
- 两端联调时需要使用的命令和排查手段。
2. 环境准备与版本规划
2.1 树莓派系统与 ROS2 版本
树莓派 4B 和树莓派 5 都可以运行 ROS2。本文以 Ubuntu 22.04 64 位系统加 ROS2 Humble 为例,这也是目前资料最丰富、遇到问题最容易搜到解决方案的组合。
如果你手里是树莓派 5,同样可以安装 Ubuntu 22.04 或 Ubuntu 24.04。需要留意的是:ROS2 版本和 Ubuntu 版本有对应关系,不能随便混用。
| Ubuntu 版本 | 对应 ROS2 LTS 版本 |
|---|---|
| Ubuntu 22.04 | ROS2 Humble |
| Ubuntu 24.04 | ROS2 Jazzy |
版本差异会影响 apt 安装包名和部分 API,但本文的核心思路是通用的。无论你装的是 Humble 还是 Jazzy,cmd_vel、串口协议、STM32 控制逻辑基本一致。
ROS2 的安装建议直接参考官方二进制安装流程。安装完成后,在终端中执行:
apt-cache search ros-humble | head -n 5如果能搜索到大量 ROS2 功能包,说明源已经配置好了。然后安装桌面版:
sudo apt update sudo apt install ros-humble-desktop -y对于纯命令行调试的树莓派,也可以安装ros-humble-ros-base,之后按需装rviz2等可视化工具。
安装完成后,将 ROS2 环境写入 shell 配置:
echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc source ~/.bashrc如果下载速度不理想,可以先把 Ubuntu 的 apt 源更换为速度较好的镜像源,再去安装 ROS2。务必注意:镜像源要与系统版本严格匹配,不要混用不同版本的源。
2.2 STM32 开发环境
STM32 端推荐使用 STM32CubeMX 生成初始化工程,再配合 Keil MDK 或 STM32CubeIDE 编写业务代码。无论你用哪一套 IDE,核心思路都相同:
- 串口 USART1:用于接收树莓派指令。
- 定时器 TIM2 / TIM3:输出 PWM,分别控制左右电机。
- GPIO:控制电机驱动模块的方向引脚。
- 可选编码器接口:后续做速度闭环时使用。
MCU 型号以 STM32F103 系列为例。如果你用的是 STM32F407、STM32G4 等型号,底层库函数会有差异,但串口解析状态机和 PWM 控制思路完全一致。
2.3 通信硬件准备
树莓派与 STM32 之间最常见、最稳定的连接方式是 USB 转 TTL 串口模块:
树莓派 USB 口 ↓ USB 转 TTL 模块 ↓ TXD RXD GND STM32 的 USART1注意交叉连接:USB 转 TTL 的 TXD 接 STM32 的 RX,RXD 接 STM32 的 TX,GND 必须共地。如果使用 STM32 板载的 USB 转串口芯片,树莓派端看到的设备名通常是/dev/ttyACM0;如果使用独立 CH340/CP2102 模块,则通常是/dev/ttyUSB0。
也可以直接连接树莓派的 GPIO UART 引脚,但需要先关闭系统串口控制台功能,还要注意电平匹配问题。新手阶段建议优先使用 USB 转 TTL,既省事又便于排查。
3. ROS2 话题与通信协议设计
3.1 /cmd_vel 话题说明
在 ROS2 机器人系统中,速度控制指令一般通过/cmd_vel话题发布,消息类型是geometry_msgs/msg/Twist。它包含两部分:
linear:线速度,单位 m/s。轮式小车通常只使用linear.x。angular:角速度,单位 rad/s。差速小车通常只使用angular.z。
下面命令可以手动发布一个“直行 0.2 m/s”的指令:
ros2 topic pub -r 10 /cmd_vel geometry_msgs/msg/Twist \ "{linear: {x: 0.2, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}"按下Ctrl + C停止发布后,小车并不会自动刹车。因此实际控制中必须做“超时停车”处理,否则树莓派端程序异常退出后,STM32 还在执行最后一条速度指令,这是非常危险的行为。
3.2 串口通信帧格式设计
ROS2 的Twist消息是 float 类型,而单片机解析浮点并不方便,而且不同平台浮点存储格式也存在隐患。因此上位机先将速度转换成整数,再通过二进制帧发送。
这里给出一种简单可靠的帧格式:
| 0xAA | 0x55 | cmd | data_len | data ... | checksum |字段说明:
| 字段 | 长度 | 说明 |
|---|---|---|
| 0xAA | 1 字节 | 帧头 1 |
| 0x55 | 1 字节 | 帧头 2 |
| cmd | 1 字节 | 命令字,例如 0x01 表示运动控制 |
| data_len | 1 字节 | 数据段长度 N |
| data | N 字节 | 数据内容 |
| checksum | 1 字节 | 校验和,等于前面所有字节累加后取低 8 位 |
以运动控制命令为例:
0xAA 0x55 0x01 0x04 [VX_L] [VX_H] [WZ_L] [WZ_H] CHECKSUM其中线速度VX的单位是 mm/s,角速度WZ的单位是 0.01 rad/s,都使用 int16 小端字节序。为什么要放大?因为 ROS2 中linear.x可能是 0.23 这样的浮点数,串口传输整数更高效、更容易调试,所以在树莓派端放大 1000 或 100,STM32 端再缩小还原。
校验和计算示例:
假设发送linear.x = 0.2 m/s,angular.z = 0 rad/s:
- VX = 200 mm/s,十六进制为
0xC8 0x00 - WZ = 0,十六进制为
0x00 0x00
此时 CHECKSUM =(0xAA + 0x55 + 0x01 + 0x04 + 0xC8 + 0x00 + 0x00 + 0x00) & 0xFF。
这套协议的好处是结构简单,可以在命令字上继续扩展,比如增加舵机控制命令、编码器回传命令、蜂鸣器命令等。
3.3 从 Twist 到左右轮速度
差速小车底盘有两个主动轮,ROS2 下发的是车体中心线速度 v 和角速度 w,需要先换算成左右轮速度:
v_left = v - w * wheel_base / 2 v_right = v + w * wheel_base / 2其中wheel_base是两个驱动轮之间的轮距。做这一步换算可以在 STM32 端,也可以放在树莓派端。放在 STM32 端的优势是,STM32 直接控制电机,后续加入编码器闭环时不依赖上层。本文示例采用在 STM32 端做运动学换算的方案。
3.4 稳定通信的三个原则
第一,控制周期稳定。ROS2 端不需要每次收到话题都立刻发串口帧,而是用一个固定周期定时器发送最新速度值,例如 50ms 发送一次,防止话题发布频率忽高忽低。
第二,两端都要做超时保护。STM32 如果超过 500ms 没有收到新指令,就立即停车。这是最后一道安全防线。
第三,程序调试阶段要分级。先把 PC 串口助手跑通,再对接树莓派;先把固定速度指令跑通,再接键盘遥控。
4. 树莓派 ROS2 节点实现
4.1 创建 ROS2 工作空间与功能包
先创建 ROS2 工作空间:
mkdir -p ~/ros2_ws/src cd ~/ros2_ws/src ros2 pkg create car_bringup --build-type ament_python不过ros2 pkg create自动生成的文件不够完整,需要手动补充。建议结构如下:
~/ros2_ws/src/car_bringup/ ├── package.xml ├── setup.py ├── setup.cfg ├── launch/ │ └── car_launch.py ├── resource/ │ └── car_bringup └── car_bringup/ ├── __init__.py └── serial_bridge.py在setup.py中声明节点入口:
entry_points={ 'console_scripts': [ 'serial_bridge = car_bringup.serial_bridge:main', ], },在package.xml中添加运行依赖:
<exec_depend>rclpy</exec_depend> <exec_depend>geometry_msgs</exec_depend> <exec_depend>python3-serial</exec_depend>还需要安装 PySerial 库:
sudo apt install python3-serial -y如果使用虚拟环境,也可以使用pip install pyserial。
4.2 串口桥接节点完整代码
文件路径:~/ros2_ws/src/car_bringup/car_bringup/serial_bridge.py。
#!/usr/bin/env python3 import rclpy from rclpy.node import Node from geometry_msgs.msg import Twist from rclpy.qos import QoSProfile, ReliabilityPolicy import serial import struct import math class SerialBridgeNode(Node): def __init__(self): super().__init__('serial_bridge') # 参数可以通过 launch 文件或命令行覆盖 self.declare_parameter('port', '/dev/ttyUSB0') self.declare_parameter('baudrate', 115200) self.declare_parameter('send_hz', 20.0) self.declare_parameter('stop_timeout', 0.5) port = self.get_parameter('port').value baudrate = self.get_parameter('baudrate').value send_hz = self.get_parameter('send_hz').value self.stop_timeout = self.get_parameter('stop_timeout').value # 打开串口 try: self.ser = serial.Serial(port, baudrate, timeout=0.05) self.get_logger().info( f'串口已打开: {port} @ {baudrate}' ) except Exception as e: self.get_logger().fatal(f'无法打开串口 {port}: {e}') raise SystemExit(1) # 订阅/cmd_vel qos = QoSProfile( depth=10, reliability=ReliabilityPolicy.BEST_EFFORT ) self.subscription = self.create_subscription( Twist, '/cmd_vel', self.cmd_vel_callback, qos ) # 保存最新速度指令 self.current_vx = 0.0 self.current_wz = 0.0 self.last_cmd_time = self.get_clock().now() # 固定周期发送 self.send_period =