
简介基于树莓派4B与STM32的ROS机器人项目资料包面向嵌入式、单片机方向的毕业设计、课程设计、大作业、工程实训、大创项目及学科竞赛等场景提供一套经过严格测试、可直接编译烧录运行的完整实现方案。压缩包内共一千一百零二个文件以C语言源文件、头文件和汇编文件为主另有Keil工程配置、ROS功能包yaml/launch/xacro、STM32CubeMX初始化文件、烧录hex文件与说明文档内容覆盖底层驱动、传感器数据采集、电机控制、ROS节点通信、机器人建模与启动配置等关键模块整体结构清晰便于按模块检索和二次开发。项目代码运行稳定若不会画电路板可采用面包板加杜邦线连接外设模块的方式快速复现资源包体积仅约28.29MB已有293人学习参考适合需要在有限时间内完成完整机器人项目的开发者借鉴使用并可在此基础上扩展更多功能。1. 树莓派4B与STM32的ROS小车两级控制架构怎么分工才靠谱树莓派4B跑ROS、STM32管电机闭环这种上下位机拆分几乎是大赛小车和毕设作品的默认架构但不少失败案例恰恰死在两片芯片之间的串口线上协议随意、波特率对不上、里程计标定靠猜。这套基于树莓派4B与STM32的ROS机器人资源提供完整源码、Keil工程与说明文档STM32端含电机驱动、编码器读取和CMSIS-DSP信号处理树莓派端负责ROS节点与运动控制。硬件不强制画板子按引脚定义用面包板加杜邦线接好电机驱动和编码器模块烧录源码即可复现。适合毕设、课程设计、大创和电子竞赛缺底盘的团队也适合想搞懂上位机ROS与下位机实时控制怎么协作的嵌入式开发者。文章按通信协议、固件实现、ROS节点到整机标定这条线拆开。2. 串口帧协议设计与STM32/树莓派通信实现先把两片芯片的职责边界画清楚再落串口帧格式和解析代码。很多人拿到现成工程直接烧录改波特率只改树莓派一侧结果交互全乱。协议先行后续所有排查才有依据这一步别省。2.1 上下位机职责边界与选型依据树莓派4B的Cortex-A72四核处理器跑Linux和ROS体量刚好SLAM建图、路径规划、传感器融合这些重计算任务都放在这一层缺点是Linux内核调度不可控普通GPIO输出PWM抖动明显做不了毫秒级电机闭环。STM32的Cortex-M内核配合定时器硬件PWM和编码器接口速度环可以可靠跑在50到200Hz中断响应是确定性的这是树莓派替代不了的。这套资源里出现了arm_rfft_init_f32.c、arm_dct4_init_q31.c、arm_common_tables.c这些CMSIS-DSP源文件说明固件侧集成了ARM官方数学库常见用途是传感器信号的频谱分析第三章会展开。两片处理器的分工可以归纳为下表后期加功能时先对号入座再动代码。层次处理器职责实时性决策层树莓派4BROS master、导航、建图、人机交互软实时执行层STM32电机闭环、编码器计数、IO保护、底层传感器采集硬实时2.2 串口帧格式与命令集设计串口是上下位机之间唯一通信链路。默认115200波特率在50Hz上报频率下余量很大每帧约13字节按10bit折算一字节传输时间约87微秒50Hz全双工也占不满2%带宽。所以初期调试用115200够用等做高速循迹再把STM32和树莓派两侧同步提到460800注意两侧必须一致。帧格式采用帧头加命令加长度的结构校验和覆盖命令字、长度和载荷不包含帧头这样解析时可以从任意字节开始同步。偏移字段长度说明0HEAD11固定0xAA1HEAD21固定0x552CMD1命令字3LEN1DATA字节数4DATALEN载荷末尾CHK1CMDLENDATA取低8位命令字按功能分组0x01设置目标速度载荷是两个float线速度vx加角速度wz共8字节0x02上报里程与轮速载荷是vx、wz加左右轮转速共12字节0x10是心跳查询载荷为空。校验和计算规则是CHK (CMD LEN sum(DATA)) 0xFFSTM32和ROS节点必须用同一套算法任何一侧算错都会整帧丢弃。2.3 帧解析与校验实现STM32端推荐用状态机解析串口字节流而不是收到什么直接按位置取。串口是字节流可能从帧中间开始接收状态机在帧头同步之前会丢弃垃圾字节收到完整帧后回到等待帧头状态天然具备重新同步能力。// STM32端串口帧解析状态机 typedef enum { ST_WAIT_H1, ST_WAIT_H2, ST_CMD, ST_LEN, ST_DATA, ST_CHK } FrameState; static FrameState state ST_WAIT_H1; static uint8_t rx_cmd, rx_len, rx_sum, data_idx; static uint8_t rx_data[32]; void uart_byte_handler(uint8_t b) { switch (state) { case ST_WAIT_H1: if (b 0xAA) state ST_WAIT_H2; break; case ST_WAIT_H2: state (b 0x55) ? ST_CMD : ST_WAIT_H1; break; case ST_CMD: rx_cmd b; rx_sum b; state ST_LEN; break; case ST_LEN: rx_len b; rx_sum b; data_idx 0; state (rx_len 0) ? ST_CHK : ST_DATA; break; case ST_DATA: rx_sum b; rx_data[data_idx] b; if (data_idx rx_len) state ST_CHK; break; case ST_CHK: if (b (rx_sum 0xFF)) { process_frame(rx_cmd, rx_data, rx_len); } state ST_WAIT_H1; break; } }参数说明rx_sum从CMD开始累加变量命名对应帧格式的字段语义process_frame按rx_cmd分发0x01解析两个float给速度环0x10回一个状态帧。注意LEN为0时直接跳到校验状态避免空载荷帧卡死在数据阶段。接收路径上逐字节HAL_UART_Receive_IT回调在115200下每字节触发一次中断对同时跑PID和编码器计数的MCU来说开销偏高。常见做法是串口DMA接收加空闲中断IDLECubeMX里配置UART的DMA循环接收在HAL_UARTEx_RxEventCallback里拿到一包不定长数据后丢给状态机CPU占用能降一半以上。3. STM32固件实现PWM、编码器与CMSIS-DSP滤波固件侧的任务闭环是整车的底层保障PWM输出驱动电机编码器反馈测速PID做速度闭环CMSIS-DSP库负责传感器信号的频域分析。源码包的Keil工程文件结构比较典型先梳理再改避免误删编译依赖。3.1 Keil工程文件梳理工程根目录的contrl02.uvguix.62362是Keil uVision的GUI布局文件记录窗口位置、断点和仿真器配置换电脑后多半失效删除后Keil会按默认重建不影响编译。接下来的keilkilll.bat是清理编译中间产物的脚本类似于下面这段行为echo off del /s /q *.o *.d *.crf *.htm *.axf *.lnp *.sct rmdir /s /q Objects rmdir /s /q Listings运行一次能把Objects、Listings目录下的.o、.d、.crf等文件清空。源码包在发布前跑过这类脚本体积小很多自己改代码后也建议跑一遍避免旧目标文件造成改了代码不生效的假象。CMSIS-DSP文件里arm_common_tables.c是FFT/IFFT的位反转表和旋转因子表arm_rfft_init_f32.c是实数FFT的初始化函数float32版本arm_dct4_init_q31.c是DCT4变换的q31定点初始化表arm_linear_interp_data.c是插值查表数据。这些文件默认被编进工程会增加编译时间。如果只用FFT可以在编译选项里排除transform_tests_common_data.c等测试数据文件Flash空间紧张时这一步能省出几十KB。3.2 PWM与编码器接口配置电机驱动接口按定时器通道划分常见分配如下具体引脚以原理图为准功能定时器通道参数左轮PWMTIM1CH120kHzPWM模式1右轮PWMTIM1CH220kHzPWM模式1左轮编码器TIM2CH1/CH2编码器模式x4倍频右轮编码器TIM3CH1/CH2编码器模式x4倍频PWM频率由f_pwm 72MHz / ((PSC1)*(ARR1))决定20kHz处在人耳听觉范围之外电机不会发出明显啸叫。如果所用驱动芯片开关频率上限偏低降到10kHz再测。编码器模式在CubeMX里把TIM2配置为Encoder ModeTIM2的CH1和CH2分别接编码器A相B相__HAL_TIM_GET_COUNTER读到的值就是脉冲增量。换算线速度的公式// PPR为编码器每转脉冲数ratio为减速比wheel_d为轮径(米) float mm_per_pulse (float)(PI * wheel_d) / (PPR * 4 * ratio); float left_speed (float)encoder_cnt_delta * mm_per_pulse / dt;参数说明4倍频来自TI1和TI2两个输入的双沿计数。以减速比30:1、PPR11的霍尔编码器为例电机输出轴每转一圈产生11×4×301320个脉冲dt取0.02s时测速分辨率约0.015m/s闭环精度足够。encoder_cnt_delta要取两次读取之差而不是累计值否则速度会随运行时间收敛到零。3.3 速度环PID与增量式调节速度环用增量式PID输出的是PWM占空比增量天然不需要保存绝对位置掉电重启也不会跳变。typedef struct { float kp, ki, kd; float integral, last_err, out; } Pid_t; // dt固定为0.02s速度环50Hz float pid_step(Pid_t *p, float target, float current) { float err target - current; p-integral err * 0.02f; if (p-integral 1000.0f) p-integral 1000.0f; if (p-integral -1000.0f) p-integral -1000.0f; p-out p-kp * err p-ki * p-integral p-kd * (err - p-last_err); p-last_err err; return p-out; }积分限幅是刹车的核心不然堵转时积分会无限累加恢复转动后产生巨大过冲。Kp从0.3起步直线来回出现高频抖动就降Kp加KdKi解决低速爬行静差但过大时转弯回正会过冲。速度环周期必须固定周期抖动会让PID参数整体失效。差速模型从ROS速度指令换算左右轮目标速度float v_left vx - wz * wheel_base / 2.0f; float v_right vx wz * wheel_base / 2.0f;wheel_base是两个驱动轮中心距单位米。这个参数标定不准会直接导致旋转半径错误第五章会讲标定方法。3.4 用CMSIS-DSP做共振频率扫描整车装完某个速度区间车体抖动万用表量不出问题常见做法是固定PWM占空比采一段编码器速度或IMU加速度数据做FFT找出频谱峰对应机械共振频率。// 128点实数FFT采样率500Hz频率分辨率约3.9Hz #include arm_math.h #define FFT_N 128 #define FS 500.0f arm_rfft_fast_instance_f32 fft_inst; float32_t in[FFT_N], out[FFT_N]; void fft_analysis(const float32_t *sample) { arm_rfft_fast_init_f32(fft_inst, FFT_N); memcpy(in, sample, FFT_N * sizeof(float32_t)); arm_rfft_fast_f32(fft_inst, in, out, 0); // 0为正变换 uint16_t peak_bin 1; float32_t peak_mag 0; for (uint16_t i 1; i FFT_N / 2; i) { float32_t mag sqrtf(out[2*i]*out[2*i] out[2*i1]*out[2*i1]); if (mag peak_mag) { peak_mag mag; peak_bin i; } } // 峰值频率 peak_bin * FS / FFT_N }参数说明arm_rfft_fast_f32的输出数组里DC和Nyquist分量的虚部为0其余频点实部虚部交替存放所以第i个频点的模取out[2*i]和out[2*i1]。peak_bin乘FS/FFT_N得到实际频率。找到共振峰后优先查机械固定和轮子动平衡若改结构成本高再用arm_biquad_cascade_df1_init_f32搭陷波滤波器滤掉该频段。FFT适合调试期做频谱扫描不适合放进50Hz控制循环里逐帧跑。提示PID环路里只做简单低通的话一阶IIR就够FFT是找问题用的工具不是控制链路的常驻算法。4. 树莓派4B上的ROS节点从环境搭建到cmd_vel闭环树莓派4B这一侧重点在ROS节点编排和串口桥接。把系统装好、权限配好、桥接节点跑通teleop_twist_keyboard一按方向键轮子就动这条链路才算验证完成。4.1 系统与串口权限准备树莓派4B建议跑64位系统Ubuntu Server 20.04配合ROS Noetic是常见组合也有不少人用树莓派OS 64位加Docker跑ROS。社区里鱼香ROS一键安装脚本被大量教程采用自动完成换源、依赖安装和ROS本体部署适合不想在环境搭建上耗时间的场景装完后再手动跑一遍rosdep和catkin_make即可。ROS节点访问串口需要权限默认用户不在dialout组里打开/dev/ttyUSB0会报PermissionErrorsudo usermod -aG dialout $USER # 重新登录终端生效 ls -l /dev/ttyUSB0USB转串口模块每次插入设备名可能漂移用udev规则固定成稳定路径以CH340芯片为例# /etc/udev/rules.d/99-robot-uart.rules SUBSYSTEMtty, KERNELttyUSB*, ATTRS{idVendor}1a86, \ MODE0666, GROUPdialout, SYMLINKrobot_uart规则生效后执行sudo udevadm control --reload重新插拔模块/dev/robot_uart就会指向同一个串口。idVendor不确定时用lsusb查实际值。如果走树莓派GPIO 14/15的原生UART还需要在config.txt加enable_uart1并释放console占用这个坑放在第五章排错部分。4.2 串口桥接节点的实现桥接节点订阅/cmd_vel按第二章的帧格式打包下发给STM32同时读取STM32的0x02上报帧解包后发里程计。完整最小实现如下#!/usr/bin/env python3 # serial_bridge.py import rospy, serial, struct from geometry_msgs.msg import Twist from nav_msgs.msg import Odometry from tf import transformations from math import cos, sin class SerialBridge: def __init__(self): rospy.init_node(serial_bridge) self.ser serial.Serial(/dev/robot_uart, 115200, timeout0.05) rospy.Subscriber(/cmd_vel, Twist, self.on_twist) self.odom_pub rospy.Publisher(/odom, Odometry, queue_size10) self.x self.y self.th 0.0 self.last_t rospy.Time.now().to_time() def on_twist(self, msg): payload struct.pack(ff, msg.linear.x, msg.angular.z) head bytes([0xAA, 0x55, 0x01, len(payload)]) chk (0x01 len(payload) sum(payload)) 0xFF self.ser.write(head payload bytes([chk])) def read_loop(self): rate rospy.Rate(50) while not rospy.is_shutdown(): head self.ser.read(4) if len(head) ! 4 or head[0] ! 0xAA or head[1] ! 0x55: continue cmd, ln head[2], head[3] data self.ser.read(ln) if self.ser.read(1)[0] ! (cmd ln sum(data)) 0xFF: continue if cmd 0x02: vx, wz, _ struct.unpack(fff, data) self.publish_odom(vx, wz) rate.sleep() def publish_odom(self, vx, wz): now rospy.Time.now().to_time() dt now - self.last_t self.last_t now self.x vx * cos(self.th) * dt self.y vx * sin(self.th) * dt self.th wz * dt odom Odometry() odom.header.stamp rospy.Time.now() odom.header.frame_id odom q transformations.quaternion_from_euler(0, 0, self.th) odom.pose.pose.position.x self.x odom.pose.pose.position.y self.y odom.pose.pose.orientation.x q[0] odom.pose.pose.orientation.y q[1] odom.pose.pose.orientation.z q[2] odom.pose.pose.orientation.w q[3] odom.twist.twist.linear.x vx odom.twist.twist.angular.z wz self.odom_pub.publish(odom) if __name__ __main__: bridge SerialBridge() bridge.read_loop()参数说明struct.pack(ff表示小端两个float与STM32端float内存布局一致校验和与第二章STM32解析算法完全对齐任何一侧算错都会丢帧。read_loop的50Hz读取频率跟STM32上报频率对齐漏帧会被校验和拦下。里程计用直线近似累积dt取墙钟时间差而非固定假设长时间运行不会因为计时漂移积累误差。话题和TF的布局保持ROS标准命名后续接navigation和map_server时不需要改节点名话题类型方向典型频率/cmd_velgeometry_msgs/Twist树莓派→STM3210Hz/odomnav_msgs/OdometrySTM32→树莓派50Hz/tftf2_msgs/TFMessage桥接节点发布随odom4.3 用teleop_twist_keyboard验证整条链路环境里装好teleop直接键盘控制验证通道完整性sudo apt install ros-noetic-teleop-twist-keyboard rosrun teleop_twist_keyboard teleop_twist_keyboard.py按下方向键后rostopic echo -n1 /cmd_vel能看到Twist消息轮子不动时先查串口权限和校验和再查STM32侧是否收到帧。在STM32的process_frame里加一个GPIO翻转的调试钩子收到合法帧就翻转LED能快速分辨是链路断了还是命令内容不对。rostopic hz /odom应稳定显示50Hz附近频率掉一半先看串口拥堵和read超时。5. 整机联调与里程计标定技巧联调阶段按顺序来能省一半排查时间先单独验证STM32侧开环PWM输出再验证闭环测速最后才把树莓派4B接入。上电顺序建议STM32先上电树莓派4B后上电电机电源独立供电。树莓派4B对供电跌落很敏感常见的红灯常亮绿灯不亮故障多半是5V被电机启动瞬间拉垮。用万用表量电机启动瞬间的5V电压低于4.8V就务必把电机驱动电源和小板供电分开共用电池也先过各自的稳压模块。里程计标定按两步走。直线标定发送固定vx直行3米量横向偏差d。若偏右说明左轮实际轮径偏大把左轮直径乘以修正系数(1 - d/L)再做一轮。旋转标定原地旋转10圈量实际角度θ_actual里程计角度θ_odom修正轮距L_cal L_used * (θ_odom / θ_actual)轮距偏小会让里程计算出的角速度偏大表现在原地转圈时ROS说转了360度实际只转340度。修正后重复一次偏差收敛到3度以内再继续导航。如果走树莓派GPIO 14/15原生串口Linux console默认占用这条线ROS节点会收到乱码。在raspi-config里关闭serial login shell、保留hardware serial等效于在config.txt写enable_uart1并清掉cmdline.txt里的consoleserial0,115200。重启后执行cat /dev/ttyAMA0不再弹登录提示而是静默等待说明console已经把串口让给应用层了。方向符号统一是最后一道坎指令左转实际右转先交换电机A/B相线里程计显示倒退把编码器AB相在CubeMX里对调或改计数方向。用rostopic echo -n1 /odom看twist.angular.z的正负号再对照车体实际转向两者相反就是符号约定不一致。调完符号再跑一遍直线和旋转标定这组参数才能落到navigation里当初始值用。本文还有配套的精品资源点击获取