简介:这份资源面向嵌入式开发初学者与ROS入门实践者,提供一套基于树莓派4B与STM32F103C8T6协同控制的智能小车完整工程。上位机与下位机通过异步串行通信构成分层控制架构,功能模块均经稳定运行验证,可用于工程研发、课程作业、学术课题与创新竞赛等场景。压缩包共236个文件,约8.75MB,涵盖C/C++源码、ROS启动与配置文件、消息与服务定义、仿真模型及技术文档等,其中cpp与h文件承载核心控制逻辑,launch、yaml与xacro支撑ROS节点组织与机器人建模,msg、srv与cfg定义通信接口,另有脚本与说明文档辅助部署。已有101人学习。读者可据此复现完整系统,理解接口定义与通信协议,并在此基础上进行功能扩展与二次开发,逐步提升嵌入式系统设计与物联网控制算法的实践能力。
1. 树莓派4B跑ROS、STM32管底盘:这套双核小车架构到底怎么分工
很多人第一次做智能小车,习惯把树莓派4B当成万能大脑:摄像头接上、电机驱动接上、ROS节点全跑在树莓派里,结果跑SLAM建图时电机一抖、串口一堵,整个系统直接卡死。我见过太多这样的翻车现场。这套「基于ROS与STM32的树莓派4B智能小车系统」的核心思路,恰恰是把实时性要求高的电机控制、编码器读取、超声波测距交给STM32,把建图、导航、视觉识别、多机通信交给树莓派4B上的ROS。两者通过串口协议通信,各司其职。如果你正在做嵌入式开发课程设计、工创赛智能物流小车,或者想从零搭一台能跑自主导航的ROS小车,这套架构是目前最成熟、资料最全、成本最可控的路线。下面我按实际搭建顺序,把选型、接线、代码、参数和踩坑点全部拆开讲。
2. 硬件选型与接线:树莓派4B引脚图怎么对STM32,电机驱动怎么选
2.1 主控分工的底层逻辑
树莓派4B跑的是Linux,不是实时操作系统。ROS的导航栈、AMCL定位、代价地图更新,随便一个回调超时就是几十毫秒。如果你让树莓派直接生成PWM去控电机,一旦CPU被视觉节点占满,PWM波形就会抖动,小车走直线都走不直。STM32F103ZET6这类MCU有硬件定时器,PWM输出稳定,中断响应在微秒级,编码器正交解码可以用定时器硬件完成,不占CPU。所以分工原则很简单:凡是要求周期稳定、响应及时的任务,全部下沉到STM32;凡是需要复杂计算、网络通信、图像处理的任务,全部留给树莓派4B。
常见做法是STM32跑一个简单的调度器,或者上FreeRTOS,周期性地做三件事:读编码器、读超声波、更新PWM。树莓派4B通过串口以固定频率发送速度指令,STM32收到后解析并执行,同时把编码器计数和超声波距离回传。这样即使树莓派短暂卡顿,STM32仍然能维持上一帧速度继续走,不会突然失控。
2.2 树莓派4B引脚图与串口连接
树莓派4B的40针GPIO里,真正能用的硬件串口是/dev/ttyAMA0,对应物理引脚8(TXD)和10(RXD)。但默认情况下这个串口被蓝牙占用,需要手动释放。步骤如下:
# 查看当前串口映射 ls -l /dev/serial* # 编辑config.txt,禁用蓝牙对串口的占用 sudo nano /boot/config.txt # 在文件末尾添加: # dtoverlay=disable-bt # enable_uart=1 # 禁用蓝牙服务 sudo systemctl disable hciuart # 重启后确认 sudo reboot ls -l /dev/serial0 # 应该指向 ttyAMA0逻辑说明:dtoverlay=disable-bt把蓝牙从UART上摘掉,enable_uart=1确保串口时钟正常。重启后/dev/serial0就是可用的硬件串口。参数上,树莓派4B的UART最高支持921600波特率,但实际用115200最稳,再高容易丢包。
接线时注意电平:树莓派GPIO是3.3V,STM32F103的USART也是3.3V,可以直接连。但如果你用的是STM32F407或者某些5V tolerant的板子,务必确认TX/RX交叉连接,即树莓派TXD接STM32 RXD,树莓派RXD接STM32 TXD,共地。
2.3 电机驱动与编码器接口
电机驱动选型看电流。普通TT马达用TB6612或者DRV8833就够,如果是用在工创赛智能物流小车上那种大扭矩电机,建议上DRV8323或者BTS7960。以TB6612为例,STM32需要输出三路信号:PWMA、AIN1、AIN2控制一个电机,PWMB、BIN1、BIN2控制另一个。PWM频率建议10kHz到20kHz,太低会有啸叫,太高驱动发热。
编码器方面,五线四相步进电机和霍尔编码器电机的处理方式不同。霍尔编码器一般输出A/B两相,接到STM32的定时器编码器接口,比如TIM2_CH1和TIM2_CH2,硬件自动计数,不丢脉冲。步进电机如果做闭环,需要额外处理。常见做法是:
// STM32 HAL库配置编码器接口 TIM_Encoder_InitTypeDef encoder_config = {0}; encoder_config.EncoderMode = TIM_ENCODERMODE_TI12; encoder_config.IC1Polarity = TIM_ICPOLARITY_RISING; encoder_config.IC2Polarity = TIM_ICPOLARITY_RISING; encoder_config.IC1Filter = 0x0F; encoder_config.IC2Filter = 0x0F; HAL_TIM_Encoder_Init(&htim2, &encoder_config); HAL_TIM_Encoder_Start(&htim2, TIM_CHANNEL_ALL);参数说明:IC1Filter和IC2Filter设成0x0F是最大滤波,防止电机抖动导致误计数。EncoderMode选TI12表示A/B两相都计数,四倍频。读计数用__HAL_TIM_GET_COUNTER(&htim2),清零用__HAL_TIM_SET_COUNTER(&htim2, 0)。
注意:树莓派4B的GPIO引脚图网上版本很多,认准物理引脚编号,不要看BCM编号。串口是物理8和10,I2C是物理3和5,别接错。
3. STM32端固件怎么写:从PWM调速到超声波测距的完整代码框架
3.1 串口协议设计:定长帧还是变长帧
树莓派和STM32之间通信,最怕的就是粘包和丢包。我一般用定长帧,每帧固定10字节,格式如下:
| 字节 | 含义 | 示例 |
|---|---|---|
| 0 | 帧头0xAA | 0xAA |
| 1 | 帧头0x55 | 0x55 |
| 2 | 左轮速度高8位 | 0x00 |
| 3 | 左轮速度低8位 | 0x64 |
| 4 | 右轮速度高8位 | 0x00 |
| 5 | 右轮速度低8位 | 0x64 |
| 6 | 超声波距离高8位 | 0x00 |
| 7 | 超声波距离低8位 | 0x50 |
| 8 | 校验和 | 前7字节异或 |
| 9 | 帧尾0x0D | 0x0D |
速度值范围-1000到1000,对应PWM占空比-100%到100%。超声波距离单位毫米。校验和用异或,计算快,够用。
STM32端解析代码:
#define FRAME_LEN 10 uint8_t rx_buf[FRAME_LEN]; uint8_t rx_idx = 0; void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { uint8_t byte = USART1->DR; // 状态机找帧头 if (rx_idx == 0 && byte != 0xAA) return; if (rx_idx == 1 && byte != 0x55) { rx_idx = 0; return; } rx_buf[rx_idx++] = byte; if (rx_idx >= FRAME_LEN) { rx_idx = 0; // 校验 uint8_t checksum = 0; for (int i = 0; i < 7; i++) checksum ^= rx_buf[i]; if (checksum == rx_buf[8] && rx_buf[9] == 0x0D) { int16_t left_speed = (rx_buf[2] << 8) | rx_buf[3]; int16_t right_speed = (rx_buf[4] << 8) | rx_buf[5]; // 更新PWM set_motor_speed(left_speed, right_speed); } } } }逻辑说明:中断里逐字节收,状态机先找0xAA再找0x55,凑够10字节后校验。校验通过才更新PWM,否则丢弃。参数上,速度值用int16_t,范围-1000到1000,实际PWM寄存器是0到ARR值,需要线性映射。
3.2 超声波测距的定时器捕获实现
STM32超声波测距,常见做法是用一个定时器发触发信号,另一个定时器捕获回波高电平时间。以HC-SR04为例,Trig接PA0,Echo接PA1(TIM2_CH2)。代码框架:
// 发触发信号 void ultrasonic_trigger(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET); delay_us(10); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET); } // 定时器捕获中断 void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_2) { static uint32_t rise_time = 0; if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_SET) { rise_time = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2); } else { uint32_t fall_time = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2); uint32_t pulse_width = (fall_time > rise_time) ? (fall_time - rise_time) : (0xFFFF - rise_time + fall_time); // 距离 = 脉宽 * 声速 / 2 // 假设定时器1MHz计数,声速340m/s distance_mm = pulse_width * 340 / 2000; } } }参数说明:定时器预分频到1MHz,即每计数1微秒。声速取340m/s,距离毫米等于脉宽微秒乘以0.17。注意温度对声速有影响,高精度场景需要加温度补偿,普通避障不用。
3.3 电机闭环控制:增量式PID怎么调
如果只是开环调速,小车走直线会偏。加编码器闭环后,用增量式PID:
typedef struct { float Kp, Ki, Kd; float last_error, prev_error; float output; } PID; float pid_calc(PID *pid, float target, float actual) { float error = target - actual; float delta = pid->Kp * (error - pid->last_error) + pid->Ki * error + pid->Kd * (error - 2 * pid->last_error + pid->prev_error); pid->prev_error = pid->last_error; pid->last_error = error; pid->output += delta; // 限幅 if (pid->output > 1000) pid->output = 1000; if (pid->output < -1000) pid->output = -1000; return pid->output; }调参经验:先调Kp,让电机能响应但不过冲;再加Ki消除静差;Kd一般给很小或者不给,因为编码器噪声大,微分会放大噪声。我一般Kp从0.5开始试,Ki从0.01开始,Kd先给0。
注意:STM32的串口中断里不要做浮点运算和PID计算,中断里只收数据,主循环里再处理。否则中断时间过长,会丢字节。
4. 树莓派4B端ROS节点怎么搭:从串口驱动到cmd_vel转发的完整链路
4.1 ROS安装与鱼香ROS一键安装的取舍
树莓派4B装ROS,官方推荐Ubuntu 20.04 + ROS Noetic,或者Ubuntu 22.04 + ROS Humble。新手最容易卡在安装上,鱼香ROS一键安装确实省事:
wget http://fishros.com/install -O fishros && . fishros这个脚本会帮你换源、装ROS、配环境变量。但注意,一键安装不是万能的,它默认装的是桌面版,树莓派4B的SD卡如果小于32G,建议选ROS-Base版本,省空间。另外,鱼香ROS的脚本在Ubuntu 22.04上装Humble没问题,但如果你用的是树莓派官方64位系统,要先确认架构是aarch64。
安装完成后,工作空间这样建:
mkdir -p ~/catkin_ws/src cd ~/catkin_ws/ catkin_make source devel/setup.bash4.2 串口驱动节点:从STM32读数据并发布odom
树莓派端要写一个节点,负责三件事:打开串口、按协议发速度指令、读回编码器和超声波数据并发布ROS话题。核心代码:
#!/usr/bin/env python3 import rospy import serial from geometry_msgs.msg import Twist from nav_msgs.msg import Odometry from std_msgs.msg import Float32 class BaseDriver: def __init__(self): self.ser = serial.Serial('/dev/serial0', 115200, timeout=0.1) self.cmd_sub = rospy.Subscriber('/cmd_vel', Twist, self.cmd_cb) self.odom_pub = rospy.Publisher('/odom', Odometry, queue_size=10) self.sonar_pub = rospy.Publisher('/sonar', Float32, queue_size=10) self.left_speed = 0 self.right_speed = 0 def cmd_cb(self, msg): # 线速度转轮速,假设轮距0.2m,轮径0.065m v = msg.linear.x w = msg.angular.z self.left_speed = int((v - w * 0.1) / 0.0325 * 100) self.right_speed = int((v + w * 0.1) / 0.0325 * 100) self.send_frame() def send_frame(self): frame = bytearray([0xAA, 0x55]) frame += self.left_speed.to_bytes(2, 'big', signed=True) frame += self.right_speed.to_bytes(2, 'big', signed=True) frame += (0).to_bytes(2, 'big') # 超声波占位 checksum = 0 for b in frame[:7]: checksum ^= b frame.append(checksum) frame.append(0x0D) self.ser.write(frame) def read_loop(self): while not rospy.is_shutdown(): if self.ser.in_waiting >= 10: data = self.ser.read(10) if data[0] == 0xAA and data[1] == 0x55: left_enc = int.from_bytes(data[2:4], 'big', signed=True) right_enc = int.from_bytes(data[4:6], 'big', signed=True) sonar = int.from_bytes(data[6:8], 'big', signed=True) self.sonar_pub.publish(sonar / 1000.0) # 发布odom,这里省略协方差和tf odom = Odometry() odom.header.stamp = rospy.Time.now() odom.header.frame_id = 'odom' odom.child_frame_id = 'base_link' self.odom_pub.publish(odom) if __name__ == '__main__': rospy.init_node('base_driver') bd = BaseDriver() bd.read_loop()逻辑说明:cmd_cb把/cmd_vel的线速度和角速度转成左右轮速,单位是毫米每秒的百分之一。send_frame按定长帧打包。read_loop读STM32回传的编码器和超声波数据,发布/odom和/sonar。参数上,轮距和轮径要根据实际小车测量,否则odom不准。
4.3 多节点发布cmd_vel时底盘节点如何取舍
ROS里经常有多个节点同时发/cmd_vel,比如键盘遥控节点、导航节点、避障节点。如果底盘节点直接订阅/cmd_vel,会收到所有消息,导致指令冲突。常见做法是加一个cmd_vel_mux节点,按优先级仲裁:
<!-- cmd_vel_mux配置示例 --> <launch> <node pkg="cmd_vel_mux" type="cmd_vel_mux_node" name="cmd_vel_mux"> <rosparam> topics: - name: navigation topic: /cmd_vel_nav timeout: 0.5 priority: 1 - name: teleop topic: /cmd_vel_teleop timeout: 0.5 priority: 2 </rosparam> </node> </launch>优先级高的先通过,超时后自动降级。这样导航和遥控不会打架。如果不想用mux,也可以在底盘节点里自己写仲裁逻辑,但不如现成包稳。
注意:树莓派4B的串口如果同时被ROS节点和系统日志占用,会报权限错误。把用户加入dialout组:
sudo usermod -aG dialout $USER,然后重新登录。
5. 避坑与排查:串口丢包、ROS多机通信、编码器计数异常怎么解
5.1 串口丢包:现象是odom跳变,原因是波特率不匹配或中断优先级低
现象:小车走直线时odom偶尔跳一大截,或者/cmd_vel发下去电机没反应。原因通常是树莓派端Python串口读取不及时,或者STM32端串口中断被其他高优先级中断打断。解决:树莓派端把串口读取放在独立线程,不要和ROS回调混在一起;STM32端把串口中断优先级设到最高,或者用DMA接收。另外,115200波特率下每字节约87微秒,STM32主频72MHz,完全来得及,但如果中断里做浮点运算就会丢。
5.2 ROS多机通信配置:现象是笔记本看不到树莓派话题,原因是ROS_MASTER_URI没设对
现象:笔记本和树莓派连同一个路由器,笔记本上rostopic list看不到树莓派的话题。原因:ROS多机通信要求所有机器ROS_MASTER_URI指向运行roscore的那台机器,且ROS_IP或ROS_HOSTNAME要设成各自的实际IP。解决:
# 树莓派端(运行roscore) export ROS_MASTER_URI=http://192.168.1.100:11311 export ROS_IP=192.168.1.100 # 笔记本端 export ROS_MASTER_URI=http://192.168.1.100:11311 export ROS_IP=192.168.1.101把这两行写进~/.bashrc。注意不要用localhost,也不要用127.0.0.1。如果还不行,检查防火墙和路由器是否隔离了设备。
5.3 编码器计数异常:现象是电机转但odom不动,原因是定时器编码器模式配置错误
现象:电机明明在转,但/odom位置不变。原因:STM32的编码器接口没配对,或者A/B相接反了导致计数方向抵消。解决:先用示波器或者逻辑分析仪看A/B相有没有脉冲;确认TIM的编码器模式是TI12;如果方向反了,把A/B相接线对调,或者在代码里取反。另外,有些编码器是开漏输出,需要上拉电阻,STM32内部上拉不够强,要外接4.7k上拉。
5.4 超声波测距跳变:现象是距离突然变成0或者几千,原因是捕获溢出或多次触发
现象:超声波数据偶尔跳到0或者很大。原因:定时器捕获溢出没处理,或者触发信号发得太快,上一次回波还没回来就发了下一次。解决:捕获中断里判断溢出标志,溢出时给一个默认最大值;触发间隔至少60毫秒,等回波超时后再发下一次。另外,HC-SR04的Echo是高电平脉宽,如果用的是5V版本,要分压到3.3V再接STM32,否则长期会损坏引脚。
5.5 树莓派4B部署YOLOv5时CPU占满导致底盘失控
现象:跑视觉识别时小车突然不动或者乱走。原因:YOLOv5在树莓派4B上跑CPU推理,一帧几百毫秒,ROS节点回调被阻塞,/cmd_vel发不出去。解决:用NCS2或者降低输入分辨率到320x320,或者把视觉识别放到独立进程,用roslaunch的output="screen"看日志。更彻底的做法是底盘控制节点单独跑在一个CPU核上,用taskset绑核。
6. 进阶技巧:用rosbag回放验证底盘协议,以及一个我常用的调试习惯
底盘协议写完,不要急着上车跑。我一般先用rosbag把/cmd_vel录下来,然后离线回放,同时用逻辑分析仪抓STM32的串口波形,确认每一帧都正确解析。具体做法:
# 录制cmd_vel rosbag record /cmd_vel -O test.bag # 回放 rosbag play test.bag --loop回放时观察STM32的PWM输出是否跟随指令变化。如果PWM没动,先查串口线,再查协议解析。这个办法比直接上车试省时间,因为上车后你分不清是协议问题还是机械问题。
另一个习惯是给STM32固件加一个“心跳”机制:树莓派每500毫秒发一帧特殊指令,STM32收到后翻转一个LED。如果LED不闪了,说明通信断了,STM32自动停车。这个后悔药在调试时救过我很多次,尤其是工创赛现场,通信一断小车冲出去就麻烦了。
最后说一个参数:树莓派4B的串口在115200下,如果同时跑ROS和视觉,建议把串口读取线程的优先级调到最高,用chrt -f 50跑Python节点。我试过不调优先级,odom延迟能到200毫秒,调完后稳定在20毫秒以内。这个细节网上很少有人提,但实际影响很大。希望帮到你。
本文还有配套的精品资源,点击获取