简介基于ROSSTM32树莓派4B设计的智能小车完整工程面向嵌入式、物联网及机器人方向的本科/高职学生与开发者可用于毕业设计、课程设计、学科竞赛、实训或初期项目开发。系统以树莓派4B作为ROS上位机STM32F103C8T6作为下位机执行底盘运动控制二者通过串口通信覆盖感知、控制、通信的典型开发链路。资源压缩包共196个文件、约8.74MB以C/C源码42个.h、27个.cpp、ROS启动与配置28个.launch、10个.yaml、6个.xacro、消息/服务定义.msg/.srv、rviz可视化配置及说明文档为主模块划分清楚便于按功能查阅与移植。内容包括PID调试、激光雷达驱动、底盘控制、网络/串口通信等关键环节并附完整工程文件与说明可直接烧录复现对不熟悉PCB绘制的初学者可按引脚定义改用面包板、杜邦线和外设模块完成硬件连接降低复现门槛。已有862人学习/下载适合想从零搭建智能小车或基于该项目二次扩展功能的开发者参考。1. 一套能毕业、能打比赛的智能小车为什么 stm32 和树莓派4B 要分层干活一辆挂着 ROS 的智能小车最值得警惕的不是电机不转而是树莓派4B 和 stm32 之间那根串口线先翻车。这个组合在毕设、课设、工创赛里反复出现不是因为它新鲜而是因为它把活分得足够清楚stm32 管电机、编码器、超声波这些需要微秒级响应的实时活树莓派4B 管 ROS 的导航、视觉、上层逻辑。两边的代码都可以单独调试哪边出了问题一眼就能定位。这套资源就是按这个链路打包的stm32 固件、树莓派端 ROS 包、通信协议、多机通信配置一次给全。适合第一次做小车、答辩时间紧、或者想在竞赛里快速跑通底盘的你。2. 上下位机分工与 ROS 话题设计先定协议再写代码2.1 为什么不直接拿树莓派当唯一主控很多人一上来就问我树莓派4B 性能这么强GPIO 也能输出 PWM为什么还要挂一块 stm32答案不在性能在实时性和生态。树莓派跑的是 Linux系统调度的不确定性意味着你没法保证某个中断在 1 毫秒内被处理。编码器计数这种事靠 Python 轮询读 GPIO转速一快就丢脉冲丢着丢着你都不知道车实际跑了多远。而 stm32f103zet6 这种片子定时器编码器模式是硬件行为电机 PWM 由定时器产生CPU 就算被串口中断占满计数和输出也不受影响。这就是「实时活交给单片机复杂活交给 Linux」的根本原因。从生态上说激光雷达驱动、move_base 导航、YOLOv5 推理这些现成 ROS 工具全都长在 x86 或树莓派侧stm32 上是没有的。你不可能在 stm32 上跑 gmapping。所以合理的分工是树莓派4B 跑 ROS 主节点通过串口下发速度指令stm32 执行底层控制把编码器、超声波、电池电压回传。功能模块承担者原因电机 PWM、方向控制stm32 定时器 GPIO微秒级响应不受 Linux 调度影响编码器计数与测速stm32 定时器编码器模式硬件计数不丢脉冲超声波测距stm32 输入捕获中断延迟可控ROS 主节点、话题通信树莓派4BROS 生态都在 Linux 侧路径规划、SLAM、视觉树莓派4Bmove_base / YOLO 等依赖 x86/ARM Linux人机交互、远程调试PC rviz多机通信看树莓派章节2.2 ROS 话题与节点划分先定接口再写代码我拆这类工程的习惯是先不开 IDE先把话题图画出来再定串口协议最后才写代码。这个顺序能省掉后面 70% 的联调时间。底盘相关的节点通常这样分serial_bridge是唯一的硬件访问节点它负责读写串口、发布/odom和/ultrasonic、订阅/cmd_vel。teleop_twist_keyboard是键盘遥控节点move_base是导航节点两者发布的都是geometry_msgs/Twist但来源不同。底层不关心是谁发的指令只管执行/cmd_vel上层在发之前自己决定谁有控制权。这里有一个初学者最容易犯的错让多个节点直接发/cmd_vel结果键盘和导航同时抢车在原地画圈。后面 2.4 会专门说怎么取舍。话题命名我一般固定成一套/cmd_vel统一收速度指令/odom发里程计/ultrasonic发超声波距离/battery发电压。这套命名和 ROS 标准完全对齐后面接 rviz、接 navigation stack 都不需要额外改名字。2.3 多个节点抢发 /cmd_vel 时底盘节点怎么取舍这是热词「ROS 多个节点发布移动指令话题时底盘节点如何取舍」背后真实的问题。常见场景调试时键盘握着控制权跑导航时 move_base 又要抢。我的做法是加一个cmd_mux仲裁节点所有速度指令先经过它再统一发给底盘。仲裁规则很简单键盘类高优先级且带 1 秒锁定期导航类只有在键盘超过 1 秒没动作时才放行。#!/usr/bin/env python3 import rospy from geometry_msgs.msg import Twist LOCK_SECS 1.0 # 键盘最后一次指令后锁住导航的时间 class CmdMux: def __init__(self): self.pub rospy.Publisher(/cmd_vel, Twist, queue_size1) self.key_ts 0.0 rospy.Subscriber(/cmd_vel_key, Twist, self.key_cb) rospy.Subscriber(/cmd_vel_nav, Twist, self.nav_cb) def key_cb(self, msg): self.key_ts rospy.Time.now().to_sec() self.pub.publish(msg) def nav_cb(self, msg): now rospy.Time.now().to_sec() if now - self.key_ts LOCK_SECS: self.pub.publish(msg) if __name__ __main__: rospy.init_node(cmd_mux) CmdMux() rospy.spin()这个逻辑里最关键的是LOCK_SECS键盘停止后留 1 秒窗口防止导航指令瞬间接管导致车突然变向。实际使用中teleop_twist_keyboard的发布话题改成/cmd_vel_keymove_base 的发布话题通过 remap 改成/cmd_vel_nav即可不需要改导航源码。如果你用topic_tools里的mux也能实现但那是按消息到达时间切换没有锁定期急停场景下不够安全所以我习惯自写。类似思路也可以用在竞赛里视觉循迹节点和遥控节点抢控制权时把循迹节点当作低优先级处理人工介入永远优先。3. stm32 下位机实现串口协议、PWM 调速与编码器测速3.1 工程骨架与串口协议解析stm32 端我以 stm32f103zet6 为例这是热词里出现频率最高的型号512KB Flash 足够放下编码器、超声波、ADC、FreeRTOS 全套逻辑。用 CubeMX 生成工程开启 USART1 接树莓派波特率 1152008N1TIM2 做编码器模式TIM3 输出两路 PWM。工程结构上分几个模块不要堆在 main.c 里motor.c、encoder.c、ultrasonic.c、protocol.c各管一摊。树莓派到 stm32 的帧格式我一般用一个很简短的协议两个帧头 长度 命令字 数据 校验。下面以速度指令为例。字节偏移内容说明00xAA帧头 110x55帧头 220x05长度命令字 4 字节数据30x01命令字速度指令4-5int16 left左轮 PWM 值范围 ±10006-7int16 right右轮 PWM 值范围 ±10008uint8 check前面 8 字节和校验取低 8 位中断里逐字节接收状态机组帧。这样比 DMA空闲中断的写法更容易被初学者看懂出错时也方便用串口助手排查。#define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 uint8_t rx_byte; uint8_t rx_buf[128]; uint8_t rx_index 0; uint8_t rx_state 0; uint8_t rx_len 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uint8_t d rx_byte; switch (rx_state) { case 0: if (d FRAME_HEAD1) rx_state 1; else rx_state 0; break; case 1: rx_state (d FRAME_HEAD2) ? 2 : 0; break; case 2: rx_len d; // 数据长度 rx_index 0; rx_state 3; break; case 3: rx_buf[rx_index] d; if (rx_index rx_len) { uint8_t sum 0; for (int i 0; i rx_len - 1; i) sum rx_buf[i]; if (sum rx_buf[rx_len - 1]) { process_cmd(rx_buf); // 命令字在 rx_buf[0] } rx_state 0; } break; } HAL_UART_Receive_IT(huart1, rx_byte, 1); } }这段代码把每个字节都当作状态机的一步先找 0xAA 0x55再收长度然后逐字节攒数据最后校验。注意process_cmd里要根据rx_buf[0]判断命令字0x01 是速度指令0x02 是查询状态指令。实际跑起来以后树莓派每 100ms 发一次速度指令就够了太频繁会使 stm32 忙于解析而挤占编码器读取的时间。3.2 PWM 调速与转向控制电机驱动我优先选 TB6612压降比 L298N 小热损耗低手里只有 L298N 也能跑但要给它留散热。驱动芯片的 PWM 频率不是随便给的几百 Hz 会让电机产生尖锐啸叫而且低速段存在死区表现为「给一点占空比不动再给大一点突然窜出去」。我用定时器输出 20kHzCubeMX 里按 72MHz 主频算预分频 71自动重载 49就得到 72M / 72 / 50 20kHz。这个频率下电机运行安静线性度也最接近理想。void motor_set_pwm(int16_t left, int16_t right) { // 限幅避免超出定时器重载值 if (left 1000) left 1000; if (left -1000) left -1000; if (right 1000) right 1000; if (right -1000) right -1000; // 方向引脚正转 IN11 IN20反转反过来 if (left 0) { MOTOR_L_IN1_GPIO_Port-BSRR MOTOR_L_IN1_Pin; MOTOR_L_IN2_GPIO_Port-BRR MOTOR_L_IN2_Pin; } else { MOTOR_L_IN2_GPIO_Port-BSRR MOTOR_L_IN2_Pin; MOTOR_L_IN1_GPIO_Port-BRR MOTOR_L_IN1_Pin; } // 占空比取绝对值写入比较寄存器 __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, abs(left)); __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_2, abs(right)); }__HAL_TIM_SET_COMPARE的第三个参数是 0 到 49 之间的比较值也就是占空比 0% 到 100%。这里我把协议里的 ±1000 当作千分比占空比开环状态下够用。如果你以后要跑闭环建议把协议值改成 mm/sPWM 换算放到 stm32 端做带 PI 调节器。3.3 编码器测速与速度闭环编码器测速有个好消息stm32f1 的通用定时器自带编码器模式硬件直接对 A/B 相脉冲计数不占用 CPU。但 HAL 库对编码器模式的封装很弱我一般直接操作寄存器代码量反而更少。void encoder_tim_init(TIM_TypeDef *TIMx) { TIMx-CR1 0x0001; // 使能计数器向上计数 TIMx-SMCR 0x0003; // SMS011编码器模式TI1TI2 都计数 TIMx-CCMR1 0xF1F1; // IC1F/IC2F 滤波 1111CC1S/CC2S01 映射到 TI TIMx-CCER 0x0011; // CC1P1 CC2P1反相处理 AB 相 90° 相位差 TIMx-ARR 0xFFFF; // 16 位计数器最大值 } int16_t encoder_read(TIM_TypeDef *TIMx) { int16_t cnt (int16_t)TIMx-CNT; // 用有符号数读取方向自动体现 TIMx-CNT 0; return cnt; }读取周期我固定在 50ms也就是encoder_read每 50ms 调用一次。实际转速换算公式是转速(r/min) cnt * 60 / (减速比 * 线数 * 4 * 0.05)。比如减速比 30、编码器线数 13四倍频后每圈脉冲是 30 * 13 * 4 156050ms 内读到 156 个脉冲转速就是 156 * 60 / 1560 6 r/min。换算成车速还要乘轮径和圆周率这部分我放在树莓派端因为 PID 闭环在 stm32 里跑轮径标定在树莓派端改起来更方便。闭环我建议从速度环开始不要一上来就做位置环。速度环的目标值来自 3.1 协议里的速度指令反馈值来自编码器用增量式 PID 输出 PWM。先把空载 PID 调好再上地跑不然车一打滑就会过冲。3.4 超声波测距与 ADC 电压采集超声波测距的坑比想象中多。HC-SR04 的 Echo 高电平时间就是距离对应的时间最稳的读法是开一个 72MHz 定时器TRIG 拉高 10us 后在 Echo 上升沿把计数器清零下降沿读走计数值。距离厘米 计数值 / 72 / 58。这个公式是从「声速 340m/s往返」推出来的1cm 往返需要 2 * 0.01 / 340 ≈ 58.8us72MHz 下就是 4235 个计数。不要用阻塞延时读 Echo等到高电平结束 CPU 就卡死了40ms 的超声周期对 PID 循环是很大的干扰。ADC 采集电池电压时多通道切换有个容易阴人的细节stm32 的 ADC 在切换通道后的第一次转换结果往往不准因为采样电容还残留上一个通道的电荷。我一般开启扫描模式把电池电压、电位器放在同一个序列里连续采集并且在初始化后先丢弃前两次结果。这个特点在热词「stm32 ADC 切换通道」里被反复问到属于典型症状。// 单次转换模式切换通道前先复位再设置通道 ADC1-CR2 ~ADC_CR2_ADON; // 停止转换 ADC1-SQR3 ~ADC_SQR3_SQ1_M; // 清空通道位 ADC1-SQR3 | ADC_SQR3_SQ1(ADC_CHANNEL_1); // 切到目标通道 ADC1-CR2 | ADC_CR2_ADON; // 重新启动 uint32_t v ADC1-DR; // 第一次读取丢弃 v ADC1-DR; // 第二次才是有效值这段寄存器操作和 CubeMX 生成的 ADC 初始化共存用 HAL 库的HAL_ADC_ConfigChannel切换也一样核心就是「先停再切」。电压值经过分压电阻换算后通过 2.2 的/battery话题发出去树莓派端可以做一个低电压语音提醒或者自动停车。4. 树莓派4B 上位机落地ROS 环境搭建与多机通信配置4.1 鱼香ROS 一键安装与基础环境树莓派4B 的系统选择上如果主要做 ROS 开发推荐 Ubuntu 20.04 Server 配合 Noetic如果必须上 Ubuntu 22.04那就走 Humble处理器的差别不大。树莓派的官方系统没有 ROS需要自己装。手动装 ROS 的步骤里最大的翻车点在软件源和密钥很多人卡在apt-key超时上。# 方式 A官方二进制源若网络超时把 packages.ros.org 换成镜像源 sudo sh -c echo deb http://packages.ros.org/ros/ubuntu $(lsb_release -sc) main /etc/apt/sources.list.d/ros-latest.list sudo apt update sudo apt install ros-noetic-ros-base # 方式 B鱼香ROS 一键脚本社区里也叫鱼香肉丝一键安装 # 从仓库 README 拉取脚本后执行脚本内可选 noetic / humble 版本 # wget 仓库给出的链接 -O fishros bash fishros我推荐方式 B尤其是第一次接触 Linux 的读者。一键脚本会检测你的 Ubuntu 版本、自动配置源、装完整版ros-noetic-desktop-full并把source /opt/ros/noetic/setup.bash写进.bashrc。装完以后开一个新的终端敲roscore能起来就说明环境通了。注意树莓派4B 跑完整版桌面会有内存压力内存 4G 版本建议关闭桌面自启动用 Server 版加 VNC 的方式能省出 1G 多内存给 move_base 和建图节点。4.2 serial_bridge 节点串口数据怎么变成话题树莓派4B 引出的串口默认不是/dev/ttyAMA0而是/dev/ttyS0。原因是 4B 的 PL011 串口被蓝牙占用了GPIO14/15 映射到 mini UART。如果直接打开/dev/ttyAMA0收发你会发现完全没数据这不是 stm32 的问题是树莓派内核配置的问题。想用 ttyAMA0 的话在/boot/config.txt里加一行dtoverlayminiuart-bt把蓝牙挤到 mini UART 上去。# 树莓派4B 引脚图对应关系 # GPIO14 物理引脚 8 UART_TXD - stm32 的 PA10 (USART1_RX) # GPIO15 物理引脚 10 UART_RXD - stm32 的 PA9 (USART1_TX) # GND 物理引脚 6 - stm32 的 GND必须共地接线时注意交叉树莓派的 TXD 接 stm32 的 RX树莓派的 RXD 接 stm32 的 TX。两者都是 3.3V 逻辑可以直接连不需要电平转换板。下面是一个最简的serial_bridge节点核心片段负责订阅/cmd_vel、下发速度指令帧。#!/usr/bin/env python3 import serial import rospy from geometry_msgs.msg import Twist WHEEL_RADIUS 0.065 # 轮子半径米 WHEEL_BASE 0.25 # 轮距米 PWM_PER_MPS 2000.0 # 1m/s 对应的 PWM 千分比需实车标定 def cmd_cb(msg): v msg.linear.x w msg.angular.z left v - w * WHEEL_BASE / 2.0 right v w * WHEEL_BASE / 2.0 left_pwm int(left * PWM_PER_MPS) right_pwm int(right * PWM_PER_MPS) frame bytes([0xAA, 0x55, 0x05, 0x01]) # 帧头 长度 命令 frame left_pwm.to_bytes(2, little, signedTrue) frame right_pwm.to_bytes(2, little, signedTrue) check sum(frame) 0xFF frame bytes([check]) ser.write(frame) ser serial.Serial(/dev/ttyS0, 115200, timeout0) rospy.init_node(serial_bridge) sub rospy.Subscriber(/cmd_vel, Twist, cmd_cb) rospy.spin()PWM_PER_MPS这个参数是从线速度到 PWM 千分比的换算系数它必须实车标定。做法很简单把车悬空发一个已知线速度 0.2m/s 的指令测实际转速反推系数。每个车的减速比、轮径不同轮子磨损以后也会变。我一般在车里放一个测试模式长按开关进入标定状态转 10 圈记录脉冲数自动算这个系数。4.3 多机通信配置PC 上看 rviz 与远程调试小车跑起来以后你不会想蹲在地上盯树莓派屏幕正确做法是 PC 上跑 rviz通过 ROS 多机通信连到树莓派的 master。这也是热词「ROS 多机通信配置」里最常见的诉求。多机通信只需记住一个原则ROS_MASTER_URI指向运行roscore的那台机器ROS_HOSTNAME填本机 IP所有机器必须在同一子网。假设树莓派 IP 是 192.168.1.100PC 是 192.168.1.101。# 树莓派端master export ROS_MASTER_URIhttp://192.168.1.100:11311 export ROS_HOSTNAME192.168.1.100 # PC 端slaveIP 换成自己的 export ROS_MASTER_URIhttp://192.168.1.100:11311 export ROS_HOSTNAME192.168.1.101这两行建议直接写进两边的~/.bashrc不要每次手动敲。然后修改/etc/hosts把主机名对应上避免 ROS 把主机名解析成 localhost 导致话题收不到192.168.1.100 raspberrypi 192.168.1.101 mypc验证多机通信是否打通不要只看rostopic list。正确做法是 PC 上跑rqt_graph能看到树莓派端的节点和话题才说明 master 连上了再看rosrun rqt_topic rqt_topic确认/odom在刷新。如果 list 能看到但 echo 没数据检查两边ROS_HOSTNAME是不是填错成对方 IP 了。另外树莓派若开着 ufw记得sudo ufw disable或者放行 11311 端口不然 PC 发来的连接请求会被丢掉。5. 避坑实录串口、电源、多机通信最常见的五类翻车5.1 树莓派4B 串口连上 stm32 后一直乱码现象树莓派和 stm32 通过串口直连minicom打开/dev/ttyAMA0全是乱码或者 stm32 收不到完整帧。原因树莓派4B 的 PL011 串口默认分给了蓝牙GPIO14/15 对应的实际是/dev/ttyS0而且树莓派系统默认还把串口当控制台输出内核日志会往串口上打印干扰数据。解决在/boot/config.txt里加dtoverlayminiuart-bt把蓝牙踢到 mini UART同时把cmdline.txt里consoleserial0,115200删掉避免系统日志混进数据链路。改完重启用ls -l /dev/ttyAMA0 /dev/ttyS0确认映射再跑echo test /dev/ttyS0看 stm32 那边能不能收到。树莓派和 stm32 的 GND 必须接在一起不共地是一切乱码里最像玄学的元凶。5.2 ROS 多机通信节点跑起来但彼此看不到现象树莓派上rostopic list正常PC 上也能看到部分话题但 echo 没数据或者 PC 上完全看不到树莓派的话题。原因ROS_MASTER_URI写成了 localhost或者ROS_HOSTNAME没有设置ROS 将主机名解析成了 127.0.1.1导致两台机器实际走的是回环地址。解决master 端和 slave 端的.bashrc都按 4.3 的方式设置并确认hostname -I拿到的 IP 和ROS_HOSTNAME一致。在两端互相ping一下不通就先解决网络。最后检查 ufw 状态有防火墙就放行 11311或者在只做实验的小网段直接关掉。5.3 PWM 频率不对电机像在打摆子现象占空比给到 20% 电机不动加到 30% 突然窜出去或者电机低速时一顿一顿的还伴随尖锐啸叫。原因PWM 频率设置太低。常见 CubeMX 默认配置可能让定时器工作在 1kHz 甚至更低电机的感性负载在低频 PWM 下电流断续低速段明显死区。解决把 PWM 频率调到 10kHz 到 20kHz 之间超出听觉范围电机线性度也会改善。同时检查电源TB6612 的逻辑电源和电机电源要分开电机启动瞬间的压降会拉低逻辑电平导致 stm32 复位。给电机电源并联一个大电容比如 470uF能缓解启动瞬间的电压塌陷。5.4 编码器计数乱跳A/B 相接反与悬空现象轮子静止时编码器计数却在正负跳变或者轮子前进速度反馈是负的PID 一闭环就直接反向跑满。原因A/B 相接反了。编码器模式里TI1 和 TI2 的相位关系决定计数方向另外 A/B 相引脚悬空时边沿噪声会被定时器当成有效脉冲。解决先确认 A/B 相分别接到定时器 CH1/CH2接反就换线或者在初始化里把CCER的极性位反过来。引脚浮空的问题把编码器 A/B 对应的 GPIO 配置为输入上拉滤波开到IC1F1111最大档能滤掉大部分机械抖动和线缆串扰。我遇到过一次编码器线束和电机线绑在一起电机一加速计数就跳把线束分开就恢复正常了。5.5 CAN 总线突然 bus off接线与波特率的双重问题现象竞赛车上 stm32 和外设通过 CAN 通信跑着跑着突然收发全部失效重启后恢复过一会儿又断。原因总线波特率不匹配时错误计数器快速累积超过 256 次后控制器进入 Bus Off 状态。更隐蔽的是终端电阻缺失信号在总线末端反射导致边沿抖动偶发错误帧。解决CANH 和 CANL 两端各接一个 120Ω 终端电阻不要省。波特率必须全总线统一用CAN_BTR仔细配置或者直接用 CubeMX 的 CAN 配置界面选中相同的波特率参数。Bus Off 恢复不能光靠HAL_CAN_Start代码里要监听错误中断做一次完整的HAL_CAN_DeInit再重新初始化。这个场景在工创赛里特别常见多块板子挂同一路 CAN 时问题八成出在电阻和地线。6. 先仿真后实车Gazebo 验证套路与竞赛改装的取舍小车底盘能跑之后下一步不是直接上导航而是先在 Gazebo 里把算法链路打通。ROS 小车自主导航仿真的标准套路是先写 URDF 描述车体把轮距、轮径、质量写进去在 Gazebo 里加载然后跑gmapping建图再用move_base发布目标点。仿真环境里的 PID 参数直接拿来实车是不行的但话题链路、状态机逻辑、导航框架的验证价值极高。仿真里的坑是「车是刚体地是平的」实车的打滑、电池欠压、机械间隙全都不会出现所以我把仿真当作框架测试不上它验证控制参数。实车调试我习惯留一个「逃生舱」在 stm32 固件里写死一个急停逻辑当串口超过 200ms 没收到新指令时自动停车。这个逻辑平时不触发掉线时救了我很多次属于毕设答辩时最值得讲的细节之一。竞赛场景改装则是另一回事工创赛智能物流小车往往要加 K210 视觉模组做色块识别或者加五线四相步进电机做个抬升机构。我的取舍原则是加一个功能就重新跑一遍第 5 章的 5 条检查优先保证底盘不抽风再谈识别和抓取。从那以后我每次拿到一块新底盘都强制自己走一遍「仿真建图 → 实车遥控 → 编码器标定 → 速度闭环 → 导航」的顺序这个顺序里每一步都在给下一步兜底跳一步后面就要用两倍时间补。希望帮到你。本文还有配套的精品资源点击获取