stm32+树莓派4B的ROS智能小车分层设计与实战避坑指南
简介:基于ROS+STM32+树莓派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 主节点、话题通信 | 树莓派4B | ROS 生态都在 Linux 侧 |
| 路径规划、SLAM、视觉 | 树莓派4B | move_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_size=1) 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_key,move_base 的发布话题通过 remap 改成/cmd_vel_nav即可,不需要改导航源码。如果你用topic_tools里的mux也能实现,但那是按消息到达时间切换,没有锁定期,急停场景下不够安全,所以我习惯自写。
类似思路也可以用在竞赛里:视觉循迹节点和遥控节点抢控制权时,把循迹节点当作低优先级处理,人工介入永远优先。
3. stm32 下位机实现:串口协议、PWM 调速与编码器测速
3.1 工程骨架与串口协议解析
stm32 端我以 stm32f103zet6 为例,这是热词里出现频率最高的型号,512KB Flash 足够放下编码器、超声波、ADC、FreeRTOS 全套逻辑。用 CubeMX 生成工程,开启 USART1 接树莓派,波特率 115200,8N1;TIM2 做编码器模式,TIM3 输出两路 PWM。工程结构上分几个模块,不要堆在 main.c 里:motor.c、encoder.c、ultrasonic.c、protocol.c各管一摊。
树莓派到 stm32 的帧格式,我一般用一个很简短的协议:两个帧头 + 长度 + 命令字 + 数据 + 校验。下面以速度指令为例。
| 字节偏移 | 内容 | 说明 |
|---|---|---|
| 0 | 0xAA | 帧头 1 |
| 1 | 0x55 | 帧头 2 |
| 2 | 0x05 | 长度:命令字 + 4 字节数据 |
| 3 | 0x01 | 命令字:速度指令 |
| 4-5 | int16 left | 左轮 PWM 值,范围 ±1000 |
| 6-7 | int16 right | 右轮 PWM 值,范围 ±1000 |
| 8 | uint8 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 会让电机产生尖锐啸叫,而且低速段存在死区,表现为「给一点占空比不动,再给大一点突然窜出去」。
我用定时器输出 20kHz,CubeMX 里按 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; // 方向引脚:正转 IN1=1 IN2=0,反转反过来 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/s,PWM 换算放到 stm32 端做,带 PI 调节器。
3.3 编码器测速与速度闭环
编码器测速有个好消息:stm32f1 的通用定时器自带编码器模式,硬件直接对 A/B 相脉冲计数,不占用 CPU。但 HAL 库对编码器模式的封装很弱,我一般直接操作寄存器,代码量反而更少。
void encoder_tim_init(TIM_TypeDef *TIMx) { TIMx->CR1 = 0x0001; // 使能计数器,向上计数 TIMx->SMCR = 0x0003; // SMS=011,编码器模式,TI1+TI2 都计数 TIMx->CCMR1 = 0xF1F1; // IC1F/IC2F 滤波 1111,CC1S/CC2S=01 映射到 TI TIMx->CCER = 0x0011; // CC1P=1 CC2P=1,反相,处理 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 = 1560,50ms 内读到 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.8us,72MHz 下就是 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里加一行dtoverlay=miniuart-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', signed=True) frame += right_pwm.to_bytes(2, 'little', signed=True) check = sum(frame) & 0xFF frame += bytes([check]) ser.write(frame) ser = serial.Serial('/dev/ttyS0', 115200, timeout=0) 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.100,PC 是 192.168.1.101。
# 树莓派端(master) export ROS_MASTER_URI=http://192.168.1.100:11311 export ROS_HOSTNAME=192.168.1.100 # PC 端(slave,IP 换成自己的) export ROS_MASTER_URI=http://192.168.1.100:11311 export ROS_HOSTNAME=192.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里加dtoverlay=miniuart-bt,把蓝牙踢到 mini UART;同时把cmdline.txt里console=serial0,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 配置为输入上拉,滤波开到IC1F=1111最大档,能滤掉大部分机械抖动和线缆串扰。我遇到过一次编码器线束和电机线绑在一起,电机一加速计数就跳,把线束分开就恢复正常了。
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 条检查,优先保证底盘不抽风,再谈识别和抓取。
从那以后我每次拿到一块新底盘,都强制自己走一遍「仿真建图 → 实车遥控 → 编码器标定 → 速度闭环 → 导航」的顺序,这个顺序里每一步都在给下一步兜底,跳一步,后面就要用两倍时间补。希望帮到你。
本文还有配套的精品资源,点击获取