简介:这是一套基于ROS与STM32F1的小车完整代码及项目说明,适合计算机、嵌入式、机器人相关专业学生用于课程设计、期末大作业或毕业设计实践。项目以串口通信为桥梁,完整演示了ROS端任务调度、状态监控与数据处理,以及STM32F1端电机驱动、传感器读取等硬件控制流程,并涉及gmapping、amcl、move_base等导航栈应用,帮助学习者掌握机器人系统与嵌入式协同开发的整体思路。资源压缩包共282个文件,约6.92MB,主要包含C/C++源码、头文件、编译生成的axf/hex固件与o/d文件,以及uvproj工程配置、sct链接脚本、map映射文件和txt说明文档等,代码工程结构清晰,便于按模块查阅。目前已有61人学习使用,尤其适合需要从零搭建ROS小车项目、理解串口通信协议与底层驱动编写的读者。借助这套资料,学习者可结合详细项目说明快速复现小车运动控制流程,并通过实际调试积累排错经验,为更复杂的机器人项目打下基础。
1. 为什么是 ROS + STM32F1 + 串口通信:这套小车架构的真正分工
如果你在找“基于ros和stm32f1的小车代码(串口通信)+详细项目说明.zip”这套资源,大概率是入了机器人开发的坑:手头有一块 STM32F103 核心板、一台玩具车底盘,想在上面跑 ROS,却不知道怎么把两边接起来。市面上大多数教程要么只讲 ROS 侧的 move_base 和 gmapping,要么只讲 STM32 的寄存器操作,真正卡住人的——“下位机收到一串十六进制数据怎么解析、解析完怎么控制电机、编码器数据怎么回传成 ROS 里程计”——恰好是中间那条串口链路。这套架构里,STM32F1 管实时性和硬件,ROS 管决策和算法,串口是唯一桥梁。无论你最终是想要 SLAM 导航还是遥控,这个桥必须先搭对。本文不评析任何具体项目包,而是讲清楚这套组合从零到联调的标准做法,让新手能照做,让老手能对照检查自己的参数选择和边界条件。
2. 串口通信协议设计:让 STM32 和 ROS 说同一种语言
2.1 为什么这里选串口而不是 CAN 或 I2C
在做两轮或四轮差速小车时,不少初学者第一反应是问“能不能用蓝牙”“能不能走 WiFi”。当然能,但串口是这套组合最稳妥、最不容易出错的物理链路:STM32F1 的 USART 外设是标配,ROS 端无论是树莓派还是 PC,USB-TTL 模块即插即用。串口还有一个优势——它足够低级,协议完全由你定义,出现问题时可以拉逻辑分析仪一个字节一个字节地看,不需要像 CAN 那样处理仲裁和掩码,也不需要像 I2C 那样关心上拉电阻和地址冲突。
串口的选型还要考虑波特率。老工程师常选 115200,因为它在 8N1 格式下约等于每秒 11.5KB 有效数据,足够承载 20Hz 的里程计加上 50Hz 的控制指令。而 9600 虽然抗干扰更强,但在大数据量时会成为瓶颈。我自己通常默认 115200,只有在遇到严重丢帧且排除协议问题后才降到 57600。
2.2 一帧数据的标准格式:帧头、长度、指令、数据、校验
通信协议是整个项目的灵魂。很多小车跑不起来,根本不是电机问题,而是下位机和上位机对“一帧数据”的理解不一致。一个健壮的串口帧格式必须包含五个部分:帧头、数据长度、指令字、数据区、校验字节。
STM32 接收端的推荐链路层解析逻辑可以写成:
#define FRAME_HEADER 0xAA #define FRAME_SIZE_MAX 16 uint8_t rx_buffer[FRAME_SIZE_MAX]; uint8_t rx_index = 0; // 在 USART1 中断里调用,按字节喂入 void uart_receive_byte(uint8_t byte) { if (rx_index == 0 && byte != FRAME_HEADER) return; // 未捕获帧头之前,丢弃所有非帧头字节 rx_buffer[rx_index++] = byte; if (rx_index >= 4) // 长度字段在帧头(1) + 长度(1) + 指令(1) + 校验(1) 之后 { uint8_t payload_len = rx_buffer[1]; if (rx_index >= payload_len + 4) { uint8_t checksum = 0; for (uint8_t i = 0; i < payload_len + 3; i++) checksum ^= rx_buffer[i]; if (checksum == rx_buffer[rx_index - 1]) { protocol_handler(rx_buffer[2], &rx_buffer[3], payload_len & 0x0F); } rx_index = 0; // 无论校验是否通过,清空状态机等待下一帧 } } }这个状态机最关键的细节是:只有捕获到帧头 AA 才开始存储,否则直接丢弃。这样做的好处是即使线路中出现噪声字节,状态机也能在下一个帧头前自我恢复。校验用的是单字节 XOR,比累加和更简单,比 CRC8 更快,适合 F1 这种主频 72MHz 的 MCU;如果你传输的是地图或点云这种大块数据,再去考虑 CRC16。
2.3 定义下行的指令集和上行的回馈集
协议里指令字的设计决定了扩展性。我一般会把指令字按字节分割:高四位表示指令类别(1 代表运动控制,2 代表参数设置,3 代表状态查询),低四位表示具体命令。例如0x11是线速度和角速度下发,0x21是设置 PID 参数,0x31是查询电池电压。
上行数据同样需要协议,不是仅回 ACK,而是要把左右编码器累计值、瞬时速度、imu 数值都组帧传回去。特别注意:STM32 端的上行帧和下行帧最好用相同的帧头但不同的指令字区间,方便 ROS 端做区分。这样 ROS 解析代码只需要一个函数,通过读 switch-case 选择处理逻辑。
3. STM32F1 端实现:电机控制、编码器和串口指令解析
3.1 用定时器生成 PWM:TIM2 做控制通道,TIM4 做编码器捕获
STM32F103C8T6 有多个定时器,合理的分工是:TIM2 输出两路 PWM 分别控制左右电机,TIM4 工作在编码器模式同时采集两个电机的正交解码信号。这样硬件上天然隔离了控制与反馈,不会出现同一个定时器既要输出脉冲又要捕获计数器的冲突。
PWM 初始化中最容易被忽略的是死区补偿和频率选择。对于常见的 TB6612 驱动模块,我通常把 PWM 频率定在 10kHz 到 20kHz 之间。频率太低电机会发出尖锐噪声且电流纹波大,频率太高则会增加 MOSFET 的开关损耗。方向控制用两个 GPIO 引脚,PWM 频率只负责速度大小——这比用方向引脚加 PWM 的混合模式更好排查,因为如果小车只往一个方向转,单独测试 GPIO 电平就能定位问题。
编码器模式配置的标准写法:
// TIM4 编码器模式,PA6/PA7 接编码器 A/B 相 void encoder_init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef gpio = { .GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7, .GPIO_Mode = GPIO_Mode_IN_FLOATING, }; GPIO_Init(GPIOA, &gpio); TIM_TimeBaseInitTypeDef tim; TIM_TimeBaseStructInit(&tim); tim.TIM_Prescaler = 0; tim.TIM_Period = 0xFFFF; // 16位自动重装载 TIM_TimeBaseInit(TIM4, &tim); TIM_EncoderInterfaceConfig(TIM4, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_Cmd(TIM4, ENABLE); }编码器模式配置完成之后,读取当前累计值只需要读TIM4->CNT。注意要在主循环中以固定周期(推荐 10ms)读取一次,然后立刻清零或者记录上一次的值做差值。用差值计算速度比用累计值除以时间更稳定,因为不会受到计数器回绕的影响。
3.2 串口指令解析后的控制逻辑:速度环必须放在 MCU 侧
下位机收到0x11指令后,数据区携带的通常是目标线速度v和目标角速度w。MCU 要做的是根据差速模型分解成左右轮目标速度,然后走 PID 闭环。不要把 PID 放在 ROS 侧通过串口周期修正——串口延迟在 10ms 量级时,PID 会严重震荡。
// 差速模型:轮距 wheel_base,轮半径 wheel_radius void set_target_velocity(float v, float w) { float v_left = (v - w * wheel_base / 2.0f) / wheel_radius; float v_right = (v + w * wheel_base / 2.0f) / wheel_radius; pid_left.target = v_left; pid_right.target = v_right; } // 10ms 定时中断里执行一次 void motor_control_tick(void) { int16_t enc_left = TIM4->CNT; TIM4->CNT = 0; float speed_left = enc_to_angular(enc_left); // 编码器脉冲 -> rad/s float pwm_left = pid_update(&pid_left, speed_left); motor_set_speed(MOTOR_LEFT, pwm_left); // 右侧同理 }enc_to_angular的换算要写清楚:角速度 = 脉冲数 / (减速比 * 线数 * 定时周期 * 2π)。如果你的电机是 1:30 减速箱、编码器每圈 13 线,那么 MCU 计数器每转一圈得到的脉冲数就是 30 * 13 * 4 = 1560(4 倍频)。这个数错了,后续里程计会全盘偏掉,误差可达 30% 以上。建议在调试阶段把换算系数打印出来,手动推车 1 米,看反馈值是否接近 1 米。
3.3 串口回传:把编码器数据打包成上行帧
上行帧里除了速度值,我建议顺带把 PID 的 error 值和当前 PWM 占空比也打出来。这样你在 ROS 端看数据时,可以快速分辨是控制问题还是通信问题——如果 error 一直很大但 PWM 已经饱和,说明目标速度超过电机能力;如果 error 很小但 PWM 在震荡,说明 PID 参数过激进。
上行发送不要放在中断里做耗时操作。正确做法是在定时中断里填好发送缓冲区,置一个标志位,然后在主循环里调用UART_SendData逐字节发送。72MHz 的 F1 跑 115200 波特率,发送一帧 16 字节大约需要 1.4ms,这在 10ms 控制周期内完全可行。
4. ROS 端实现:serial 驱动节点、里程计计算与指令下发
4.1 用 C++ 写一个串口驱动节点,不要用 Python 起步
虽然 Python 的 pyserial 写起来更短,但 ROS 里你最终要跑整套导航栈,C++ 节点在效率和与 move_base 的集成性上都有优势。使用serial库,代码结构通常是这样:
#include <ros/ros.h> #include <serial/serial.h> #include <geometry_msgs/Twist.h> #include <nav_msgs/Odometry.h> // 订阅/cmd_vel,回传/odom int main(int argc, char** argv) { ros::init(argc, argv, "stm32_link"); ros::NodeHandle nh("~"); std::string port = nh.param<std::string>("port", "/dev/ttyUSB0"); int baud = nh.param<int>("baudrate", 115200); serial::Serial ser(port, baud, serial::Timeout::simpleTimeout(10)); // 打开串口失败直接返回错误码 if (!ser.isOpen()) { ROS_ERROR("Failed to open port %s", port.c_str()); return -1; } ros::Subscriber sub_twist = nh.subscribe("/cmd_vel", 10, twist_callback); ros::Publisher pub_odom = nh.advertise<nav_msgs::Odometry>("/odom", 10); ros::Rate loop_rate(50); // 和 STM32 上行频率匹配 while (ros::ok()) { // 读取串口,解析一帧 // 调用发布 odom 的函数 ros::spinOnce(); loop_rate.sleep(); } return 0; }4.2 /cmd_vel 到串口帧:数据换算与字节序问题
ROS 里的geometry_msgs::Twist给出的是线速度(m/s)和角速度(rad/s),你需要把它们转成 STM32 侧的浮点格式。常见做法是:不采用 IEEE754 浮点直接传输,而是先乘以 1000 转成 int16。原因有两点:一是数据长度固定为 2 字节,便于协议设计;二是避开大小端问题——传输时统一高字节在前,MCU 端组合即可。
void twist_callback(const geometry_msgs::Twist::ConstPtr& msg) { uint8_t frame[10]; frame[0] = 0xAA; // 帧头 frame[1] = 0x04; // 数据区长度 = 4 frame[2] = 0x11; // 指令:运动控制 int16_t v = (int16_t)(msg->linear.x * 1000); int16_t w = (int16_t)(msg->angular.z * 1000); frame[3] = (v >> 8) & 0xFF; frame[4] = v & 0xFF; frame[5] = (w >> 8) & 0xFF; frame[6] = w & 0xFF; frame[7] = frame[0] ^ frame[1] ^ frame[2] // 省略后续字节 ^ frame[3] ^ frame[4] ^ frame[5] ^ frame[6]; ser.write(frame, 8); }注意这里的1000倍率。如果你后续要跑 SLAM 导航,线速度精度到 1mm/s,角速度精度到 0.001rad/s,完全够用。如果 STM32 端用的是short类型,倍率选 1000 不会溢出(±32.7m/s),而选 10000 在极限速度时会超过 int16 范围,是一个隐蔽 bug。
4.3 从 MCU 帧计算里程计并发布 TF
里程计的计算公式不复杂,难点在于发布时序:必须先发布 TF(odom到base_link),再发布nav_msgs/Odometry消息。因为 move_base 和 AMCL 都会同时监听 TF 和 odom,如果 TF 没到,定位节点会直接报错。
// 假设收到左右轮速度 v_l, v_r,单位 m/s,dt 是距上帧时间 double v = (v_left + v_right) / 2.0; double w = (v_right - v_left) / wheel_base_; double dth = w * dt; x_ += v * cos(th_) * dt; y_ += v * sin(th_) * dt; th_ += dth; tf::Quaternion q; q.setRPY(0, 0, th_); // 先广播 TF transformStamped.transform.translation.x = x_; transformStamped.transform.translation.y = y_; transformStamped.transform.rotation.x = q.x(); // ... 发布 transform // 再发布 Odometry odom.pose.pose.position.x = x_; odom.pose.pose.position.y = y_; odom.pose.pose.orientation.x = q.x(); // ... 填充协方差并发布这里的wheel_base_必须与 STM32 端用到的轮距严格一致,否则会出现“速度控制正确但轨迹画不出来”的怪现象。更隐蔽的问题是:ROS 端的th_是 float 累加,长时间运行后漂移是必然的。工程上每 5 分钟可以接受;如果漂移明显,那是轮距或编码器换算系数的问题,不要用陀螺仪硬掰。
5. 联调流程与常见问题排查:从 USB-TTL 到整机运行
5.1 用分步接法定位故障层,不要直接开 move_base
拿到任何小车代码包,第一件事不是编译后直接上电跑导航。我习惯做三步隔离,每一层通过后再往下走。
第一步,USB-TTL 接 PC,不开 ROS。在 PC 上用串口助手手动发帧头AA 04 11 00 64 00 00,看 STM32 是否回传数据,电机是否转动。这步验证的是 USB-TTL 模块、STM32 解析逻辑和电机驱动。
第二步,用 Python 或serial命令行工具替换串口助手,做同样的发送。这步验证的是 PC 上串口权限与路径是否正确。
第三步,再启动 ROS 节点,用rostopic pub发cmd_vel。
如果你跳过步骤一二直接做第三步,出问题时你会同时面临“是板子问题、协议问题、还是 ROS 节点问题”三个变量。我在实际项目中见过大量案例:最后定位到是 USB-TTL 模块的 TX/RX 接反了。这是串口通信里最高频的错误,排查方法极简单——互相对调 TX 和 RX 再试一次即可。
5.2 串口权限与硬件识别
在 Ubuntu 上跑 ROS,串口权限是一个几乎必踩的坑。接入 USB-TTL 后,设备名通常是/dev/ttyUSB0。直接运行节点会报Permission denied,原因是当前用户不在dialout用户组中。解决方法:
sudo usermod -a -G dialout $USER执行后必须重新登录才能生效(或者重启)。用ls -l /dev/ttyUSB0可以确认当前权限。另一个常见问题是插了两块 USB-TTL 时设备号漂移,解决方案是使用 udev 规则绑定设备的idVendor和idProduct,或者直接用/dev/serial/by-id/下的符号链接来替代写死的/dev/ttyUSB0。
5.3 数据乱码和丢帧:波形、波特率、接地三件事
串口通信乱码有三大诱因。第一,波特率不匹配——这是最白痴也最常见的错误,常发生在你改过 STM32 端配置但 ROS 端 yaml 里的参数没同步改。第二,共地问题——USB-TTL 的 GND 必须和 STM32 的 GND 接在一起,否则两边电平参考不一致,接收端采到的信号全是噪声。第三,干扰导致丢帧——电机转动瞬间电流波动会耦合到串口线上,如果你发现小车一动通信就断,优先检查电源是否隔离,或串口线是否与电机电源线绑扎在一起。
遇到数据偶尔乱码时,先用示波器或逻辑分析仪看 TX 引脚波形。正常 115200 波特率下,一个位宽约 8.68μs,如果波形里有毛刺或上升沿不干净,优先在串口线上串一个 100Ω 电阻,并确保走线尽量短。如果是 STM32F1 的 USART 配置问题,检查是否误开了USART_IT_ORE中断——溢出错误在中断里没处理时,会导致后续所有数据错位。
# 在终端观察原始字节,确认数据是否到达 sudo tio /dev/ttyUSB0 -b 115200 # 或使用 Python 快速抓取 python3 -c "import serial; s=serial.Serial('/dev/ttyUSB0',115200); print(s.read(32))"5.4 上位机和下位机的启动顺序
如果遇到“ROS 节点启动后串口没反应”的情况,多数是下位机先于上位机启动,而上位机在打开串口时对下位机发了一个复位信号(DTR 引脚电平变化)。STM32F103C8T6 的 BOOT0 和 NRST 如果受到 DTR 影响,会导致芯片意外复位或进入 Bootloader 模式。
规避的方式有两种:一是硬件上把 USB-TTL 模块的 DTR/RTS 跳线帽拔掉或断开;二是在软件上让 STM32 收到一个空串口数据时才进行初始化握手,而不是上电就进入运行态。这类问题隐蔽,但如果遇到了,你可以这样验证:先启动 ROS 节点再给 STM32 上电,如果此时一切正常,那就是启动顺序 + DTR 共同导致的问题。
6. 最后值得带走的三个调试技巧:在线改 PID、串口看波形、任务收尾
6.1 在线 PID 调参:用上位机指令免重新烧录
第一个技巧是把 PID 参数做成可在线修改的。在协议中预留0x21指令字,数据区包含 Kp、Ki、Kd 三个浮点值各乘以 1000,STM32 收到后更新到全局变量中。这样你在跑车时只需要在 PC 端发一组数据,就能现场观察响应变化,不用每次改代码重新编译烧录。烧录一次 STM32 至少耗时 5 秒,加上重启和重新初始化,累计起来会严重影响调试节奏——在线调参能把单次实验周期压缩到 3 秒以内。更关键的是,你可以在小车运动过程中连续微调,这在传统烧录模式下根本做不到。
6.2 串口示波器:让调试信息可见化
第二个技巧与 ROS 可视化无关,而是直接用串口打印曲线。我一般会在 STM32 端每 20ms 打印一串格式化数据,形如v_left, v_right, pid_out,用 kst 或 Arduino 串口绘图器连接在同一端口上。它比 ROS 的rqt_plot更轻,因为不经过任何中间节点延迟,看到的是 MCU 侧的第一手数据。如果这里曲线平滑但 ROS 端收到的 /odom 有跳变,问题就在解析代码或数据回传协议上;如果这里就有毛刺,优先检查编码器接线。
调试结束时,记得把这些调试打印关闭——不是删掉,而是用宏或条件编译包起来,因为大量串口打印会占用带宽,导致控制指令帧被延迟处理,表现为“遥控时有明显迟滞”。例如实测下来 115200 波特率留出 20% 带宽给调试打印不会影响控制,但超过 50% 时就开始出现帧间间隔抖动。
6.3 里程计校准的标准动作:跑直线和原地旋转
最后一个技巧是关于里程计的验收。在投入 SLAM 之前,一定要做两个标准的校准动作:让小车沿直线走 2 米,检查 /odom 里的位移是不是 1.9~2.1 米之间;再原地旋转 360°,检查角度是不是 355°~365°。这两个数据不达标,建图一定飘,而且你无法判断是激光雷达问题还是里程计问题——因为两者在 AMCL 里相互耦合。
直线偏差大时调整左右轮换算系数(通常是左右电机减速比有细微差异);旋转偏差大时调整wheel_base_,而且一次调整后必须重新做直线校准。把这个套路称为“先 L 后 R”,养成习惯后,这套 ROS + STM32 的串口链路才算是真正能交付的状态。
本文还有配套的精品资源,点击获取