树莓派4B+STM32构建ROS机器人:上下位机通信与里程计融合实战
2026/9/16 13:54:03 网站建设 项目流程

简介:基于树莓派4B与STM32协同设计的ROS机器人完整项目包,面向嵌入式、单片机方向的毕设、课设、竞赛及工程实训人群。内含完整源码、工程文件与说明文档,可帮助快速复现机器人系统,并支持在既有框架上扩展功能。压缩包共1102个文件,以C源码、头文件、汇编与链接脚本为主,辅以yaml/launch等ROS配置、STM32CubeMX的ioc工程及hex固件,便于从底层驱动到上层ROS节点进行整体学习。包体大小28.29MB,已有293人学习下载。对于硬件基础较弱的初学者,还可参考描述中的面包板加杜邦线替代方案,降低复刻门槛,适合项目开发、毕业设计、课程作业与大创等场景。

1. 从一块板子到一个会跑的ROS机器人,中间缺的到底是什么

树莓派4B加STM32,这几乎是国内高校做ROS机器人课设、毕设和竞赛最经典的一套组合。硬件成本压得下来,社区资料足够多,而且两层架构恰好踩在嵌入式开发和机器人操作系统学习的交汇点上。但大多数人在这个项目上卡住,不是因为某个芯片不会用,而是因为“上下位机的职责边界”一开始就没划清楚:树莓派上装了Ubuntu和ROS,却不知道哪些节点该跑在派上,哪些逻辑该下沉到STM32;STM32写好了电机驱动,却不知道怎么跟ROS端的话题和服务对接。这个标题里的zip文件拆开来看,本质上就是一套已经跑通的最小系统——树莓派跑ROS做感知和决策,STM32做底层运动控制和传感器采集,两者通过串口或CAN通信。这篇文章会按这条主线,从通信协议设计讲到里程计融合,再到供电和调试技巧,给出一套可以照着复现的方案。适合正在做课程设计、备战电赛或ROS竞赛、以及第一次把树莓派和STM32拼成移动底盘的工程师。

2. 先想清楚:为什么是树莓派4B + STM32,而不是单板直接干

2.1 树莓派跑ROS的边界在哪,STM32负责什么才算合理

树莓派4B的CPU是四核Cortex-A72,跑Ubuntu 20.04加ROS Noetic,日常跑激光雷达驱动、导航栈和rviz可视化没有问题。但它的GPIO是致命的短板:没有硬件PWM输出,没有正经的编码器接口,实时性更是完全没保证。如果直接在树莓派的GPIO上驱动两个直流电机,你会发现PID控制周期抖动得厉害,跑起来底盘一顿一顿的。这就是为什么业界默认把底盘控制下放给STM32,而不是硬塞给树莓派。

STM32F103C8T6或者F407系列在电机控制上几乎是教科书级的搭档:TIM的编码器模式直接接AB相,PWM输出配死区刹车,ADC采样电池电压,中断里做电流环或速度环,实时性以微秒计。而树莓派这边跑ROS的master节点、激光雷达驱动、move_base、AMCL定位,这些CPU密集型的任务才真正吃满它的性能。

上下位机的划分核心原则是:凡是需要硬实时的逻辑放STM32,凡是需要Linux生态和ROS通信栈的逻辑放树莓派。我见过有人非要把IMU数据在STM32里跑完卡尔曼滤波再发给树莓派,结果发现树莓派端做传感器融合反而更灵活,因为ROS里现成的robot_localization包就是干这个的。千万别重复造轮子,上下位机的信任边界一旦模糊,后面的调试时间会翻倍。

2.2 通信链路选型:串口是默认答案,但波特率和帧格式要自己定

树莓派和STM32之间最常见的连接方式是UART串口。树莓派4B的40pin排针上,物理引脚8是TX,物理引脚10是RX,对应/dev/ttyAMA0。这一代树莓派的mini UART和PL011 UART已经做了优化,不再像3B那样锁频不稳,所以直接用串口通信完全够用。

波特率我一般选115200或者460800。115200在长线传输时抗干扰好,460800适合需要高频发送里程计数据的场景。如果跑的是两轮差速底盘,里程计数据频率20Hz、一个数据包大概20个字节,115200完全富裕。但如果你要在STM32上发编码器原始值、IMU九轴数据再加目标速度控制字,那20Hz×50字节就有点紧了,建议直接上460800。

帧格式不要用现成的协议库,自己定一个简单的带校验的格式就行。网上不少项目直接裸发结构体,这在实验室环境跑得通,但一旦电机启动,串口线上全是电磁干扰,裸结构体大概率出乱码。我一般用这种帧格式:

字段长度说明
帧头2字节0xAA 0x55,用于找帧同步
数据长度1字节有效载荷字节数
数据类型1字节0x01=速度控制,0x02=里程计反馈,0x03=IMU数据
有效载荷N字节按具体类型解析,浮点数转4字节小端
CRC162字节从数据类型到有效载荷末尾的校验

这种帧格式的好处是,树莓派端不管收到什么垃圾数据,只要扫描到0xAA 0x55就能重新对齐。CRC校验在10米线缆和电机启停的恶劣环境下也就损失一点点CPU,换来的是定位数据不会突然跳变。

2.2.1 树莓派端串口权限和映射的坑

树莓派跑Ubuntu Server时,用户默认不在dialout组里,直接开串口会报Permission denied。常见做法是把当前用户加进dialout组,或者写一个udev规则把串口映射成固定名称。

# 将用户加入dialout组,重新登录生效 sudo usermod -aG dialout $USER # 查看串口设备 ls -l /dev/ttyAMA0

如果多次插拔USB转串口模块(比如CP2102或者CH340),设备名会在ttyUSB0和ttyUSB1之间跳。写一个udev规则固定在/dev/ttyRobot:

sudo nano /etc/udev/rules.d/99-robot.rules

内容写一行,其中idVendor和idProduct通过lsusb查你的USB转串口芯片:

KERNEL=="ttyUSB*", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", MODE:="0666", SYMLINK+="ttyRobot"

保存后执行sudo udevadm control --reload-rules,重新插拔设备,以后代码里就固定写/dev/ttyRobot。别小看这一步,串口设备名漂移是竞赛现场最容易把你心态搞崩的问题之一。

2.2.2 数据协议怎么定义才能让ROS端解析最省事

定义数据载荷的时候,尽量贴近ROS消息类型。速度控制字直接从geometry_msgs/Twist取线速度x和角速度z,转成两个float32发下去;里程计反馈则按odom消息的位姿和速度填充。这样树莓派端写解析节点时,几乎就是一个memcpy加字节序转换的事。

有一个细节需要提前约好:float的字节序。x86和ARM都是小端,STM32也是小端,但如果你中间插了一个串口转以太网的模块,或者以后要跟大疆RoboMaster的裁判系统通信,就要统一处理。我在协议里干脆就写死小端,谁不服谁转。

3. 树上装ROS环境:最小化安装,跑通第一帧激光雷达

3.1 不要用树莓派桌面版,Ubuntu Server是更优解

很多树莓派ROS教程推荐直接烧录官方Ubuntu Desktop镜像,打开终端敲安装命令。这么做的代价是桌面环境吃掉CPU和内存,编译一个navigation相关的包能跑到80度。我建议用Ubuntu Server 20.04.5 LTS,装完ROS Noetic之后,如果你想要可视化,直接树莓派上跑roscore和激光雷达驱动,在你自己电脑上跑rviz并设置ROS_MASTER_URI连过去。

安装ROS Noetic不要用国内某些教程里的半残脚本,直接按官方步骤,配置好镜像源之后安装ros-noetic-desktop。这个版本包含rqt、rviz和常用可视化工具,但不带gazebo,省几个GB的磁盘空间:

# 换清华源后更新 sudo apt update && sudo apt upgrade -y # 安装完整版ROS,包含rviz/rqt/slam库 sudo apt install ros-noetic-desktop -y # 初始化rosdep sudo rosdep init rosdep update

rosdep init如果报错,多半是网络问题,可以用rosdepc替代,这是小鱼的一键安装工具集里的一个组件,功能一致:

pip install rosdepc sudo rosdepc init rosdepc update

装完这些,在.bashrc里加上source /opt/ros/noetic/setup.bash。接下来验证ROS能跑起来:

# 终端1 roscore # 终端2 显示节点列表 rosnode list

能输出/rosout就说明环境OK。这一步走通之后,再装自己需要的功能包。

3.2 雷达驱动编译与串口权限问题

如果你的底盘没用激光雷达而是纯视觉方案,这节可以跳过,但绝大多数课设和竞赛都选的是思岚A1或者A2激光雷达。它们走串口或USB,树莓派上需要编译slamtec的驱动包。

使用catkin_make还是catkin build取决于你是否装了catkin_tools,这里用传统方式:

mkdir -p ~/catkin_ws/src && cd ~/catkin_ws/src git clone https://github.com/Slamtec/rplidar_ros.git cd ~/catkin_ws && catkin_make source devel/setup.bash

连接雷达后,先排除权限问题:

# 查看雷达对应的串口 ls -l /dev/ttyUSB* # 临时授权 sudo chmod 666 /dev/ttyUSB0

然后启动雷达:

roslaunch rplidar_ros view_rplidar_a1.launch

如果你看到rviz里没有点云,先检查雷达的绿灯是否常亮,再确认串口权限。这里注意不扫描帧数据的代码,只解决驱动能不能起来的问题。能起来之后再接线到下一章。

3.3 STM32端串口收发代码的骨架

STM32这边,我用的是标准外设库加HAL库混合写。初始化USART2(PA2/PA3),波特率460800,开启空闲中断和接收中断。接收用DMA加空闲中断,这是嵌入式里解析不定长帧最舒服的方式。

// 串口接收缓冲区 uint8_t rx_buf[128]; uint8_t frame_buf[64]; uint8_t frame_len = 0; uint8_t frame_ready = 0; // 配置USART2,波特率460800 void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 460800; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.StopBits = UART_STOPBITS_1; HAL_UART_Init(&huart2); __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart2, rx_buf, sizeof(rx_buf)); } // DMA空闲中断回调 void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart2) { uint16_t len = sizeof(rx_buf) - __HAL_DMA_GET_COUNTER(&hdma_usart2_rx); if (len >= 5) { memcpy(frame_buf, rx_buf, len); frame_len = len; frame_ready = 1; } __HAL_UART_CLEAR_IDLEFLAG(&huart2); HAL_UART_Receive_DMA(&huart2, rx_buf, sizeof(rx_buf)); } }

代码里有个细节:__HAL_DMA_GET_COUNTER拿到的是DMA还剩下的空间,用缓冲区总长减去它才是实际接收到的数据长度。DMA接收完成回调HAL_UART_RxCpltCallback在循环缓冲区模式下不会触发,所以必须用空闲中断来判断一帧数据结束。STM32的USART空闲中断在帧间隔大于一个字节时间时触发,正好适应我们这种不定长协议。

4. 上下位机联调:让ROS收到第一个整数

4.1 写一个串口节点包,别用rosserial现成的

用rosserial_arduino或者rosserial_stm32确实能省事,它把订阅、发布都映射成协议栈,看起来很美。但物尽其用一个道理:懒得动脑的方案,将来调试的时候会加倍还回来。rosserial生成的代码里面带了它自己的帧协议和掉线重连逻辑,一旦出问题你很难定位是它内部状态机的问题还是你业务逻辑的问题。自己写一个串口节点,三五十行,数据帧格式和协议完全掌控在自己手里。

创建功能包:

cd ~/catkin_ws/src catkin_create_pkg robot_serial roscpp std_msgs geometry_msgs nav_msgs

源码写在src/serial_node.cpp里,核心逻辑是打开串口、读写数据、发布里程计话题、订阅速度话题。串口读写用的是Linux原生termios库,不是boost.asio,因为自带的更轻量,没有回调地狱。

打开串口的配置函数是这样:

#include <fcntl.h> #include <termios.h> #include <unistd.h> int open_serial(const char* port, speed_t baud) { int fd = open(port, O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) { ROS_ERROR("无法打开串口 %s", port); return -1; } struct termios options; tcgetattr(fd, &options); cfsetispeed(&options, baud); cfsetospeed(&options, baud); options.c_cflag |= (CLOCAL | CREAD); options.c_cflag &= ~CSIZE; options.c_cflag |= CS8; options.c_cflag &= ~PARENB; options.c_cflag &= ~CSTOPB; options.c_cflag &= ~CRTSCTS; options.c_iflag &= ~(IXON | IXOFF | IXANY); options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); options.c_oflag &= ~OPOST; tcsetattr(fd, TCSANOW, &options); tcflush(fd, TCIOFLUSH); return fd; }

注意几个关键点:c_cflag里的CLOCAL和CREAD必须开,否则串口会监视调制解调器状态线,导致open阻塞;CRTSCTS必须关,否则树莓派的UART引脚上没有CTS/RTS信号,收发会直接卡住。c_lflag关闭ICANON,避免串口按行缓冲把数据吞掉。这些配置少一个,联调的时候就会出现“树莓派能发不能收”或者“程序卡在open不往下走”的妖孽现象。

4.2 发布里程计,格式要按nav_msgs/Odometry的规范来

里程计话题是底盘跟导航栈交互的接口。树莓派端收到STM32发来的编码器计数,换算成线速度和角速度,填充进nav_msgs/Odometry消息,同时查tf树发布odom到base_footprint的变换。很多课设的项目这一步做得很潦草,或者干脆不发tf,导致后面跑AMCL或者move_base的时候直接报错。

nav_msgs::Odometry odom; odom.header.stamp = ros::Time::now(); odom.header.frame_id = "odom"; odom.child_frame_id = "base_footprint"; // 位置,由编码器里程计推算 odom.pose.pose.position.x = x_pos; odom.pose.pose.position.y = y_pos; odom.pose.pose.orientation.z = sin(yaw / 2.0); odom.pose.pose.orientation.w = cos(yaw / 2.0); // 速度,由底盘运动学换算 odom.twist.twist.linear.x = vx; odom.twist.twist.angular.z = vz; odom_pub.publish(odom); // 发布tf变换 geometry_msgs::TransformStamped odom_tf; odom_tf.header.stamp = ros::Time::now(); odom_tf.header.frame_id = "odom"; odom_tf.child_frame_id = "base_footprint"; odom_tf.transform.translation.x = x_pos; odom_tf.transform.translation.y = y_pos; odom_tf.transform.rotation = odom.pose.pose.orientation; tf_broadcaster.sendTransform(odom_tf);

这里的x_pos、y_pos和yaw是STM32端对编码器脉冲进行累计积分的结果。STM32每20ms发一次编码器原始值,树莓派端负责把它积分成位置,这样一旦串口偶尔丢一帧,位置只是短暂不更新,不会累积错误。相反,如果STM32端直接积分再发位置,丢一帧就永远少一块,整个导航彻底没法用。

4.3 订阅/cmd_vel,直接转换成底盘运动学

下行链路是ROS端发布geometry_msgs/Twist到/cmd_vel话题,serial_node订阅后解析线速度和角速度,打包通过串口发给STM32。STM32收到后按差速运动学反解左右轮目标速度,再进PID控制器闭环。

void cmdVelCallback(const geometry_msgs::Twist::ConstPtr& msg) { float linear_x = msg->linear.x; float angular_z = msg->angular.z; // 差速运动学解算,wheel_base是两轮间距,单位米 float v_left = linear_x - angular_z * wheel_base / 2.0; float v_right = linear_x + angular_z * wheel_base / 2.0; // 打包成协议帧,左轮速度右轮速度各4字节float // 帧头 0xAA 0x55 长度 0x09 类型 0x01 数据8字节 CRC16 2字节 uint8_t frame[15]; frame[0] = 0xAA; frame[1] = 0x55; frame[2] = 9; frame[3] = 0x01; memcpy(frame + 4, &v_left, sizeof(float)); memcpy(frame + 8, &v_right, sizeof(float)); uint16_t crc = crc16(frame + 3, 9); frame[13] = crc & 0xFF; frame[14] = crc >> 8; write(serial_fd, frame, 15); }

wheel_base这个参数要量准,别拿尺子量两轮中心距就算完。实际运行时轮胎打滑、地盘形变都会让等效轮距和理论值有偏差,后期用轨迹对比法校准,让机器人原地旋转360度,观察odom的yaw变化量,如果大于或小于2π,按比例修正wheel_base的数值。

STM32端收到这个帧后,在定时中断里做速度闭环控制。PID参数整定有个推荐做法:先调P让电机不抖,再加上I消除静态误差,D一般用不上,除非你的电机响应特别慢或者底盘轻到一加速就震荡。我习惯把速度环频率设在50Hz,和STM32的PWM频率分开,PWM是20kHz,速度环是50Hz中断触发PID计算然后更新占空比。

5. 底盘动起来但跑不直:编码器数据融合与轮径校准

5.1 编码器读数不等于真实距离,你需要校准轮径

新组装的底盘接上电往前跑,大概率发现两米直线实际跑成了1.85米。原因是轮子的理论直径和实际有效直径不一样,胎压、负载和地面材质都会改变每转对应的实际行进距离。

校准方法是做直线测试:让机器人以固定PWM或固定目标速度走一段已知距离,比如3米,记录编码器累计脉冲数。STM32端把编码器计数发给树莓派,用ROS里的rostopic echo直接抓到数据:

rostopic echo /odom

看position.x的增量,和真实3米做比值,把轮径修正系数写进参数服务器或者STM32的宏定义里。这样校准之后,里程计的scale factor误差能压到1%以内。

5.2 转向不灵光,轮距和角速度积分怎么调

只校准轮径还不够。原地旋转测试中,机器人实际转过的角度和odom里报出来的角度往往有5%到10%的偏差。这个偏差来源基本集中在两个地方:一是轮距设置不准确,二是左右轮直径不一致导致的差速误差。

我一般做两组实验:第一组,原地旋转,记录IMU的角度真值跟odom的yaw对比,按比例修正wheel_base;第二组,直线往返,分别前进和后退,对比x的正负距离是否一致,如果后退更远说明左右轮有效直径的均值偏大或者有反向间隙。这两个参数调完之后,用robot_localization做EKF融合时,输入数据的噪声协方差可以设置得更自信。

5.3 IMU参与航向修正,但不要无脑融合

如果你的底板上装了IMU,比如MPU6050或ICM20602,建议把IMU数据用DMP读取四元数再转成yaw角,通过串口发给树莓派。在robot_localization里配置odom和imu两个输入源,odom负责位置和线速度,imu负责航向和角速度。配置dual_filter还是single_filter要根据你的发送频率设计,建议直接参考官方示例写一个ekf_localization_node的launch文件。

一个常见踩坑是IMU的yaw角在机器人上电瞬间没有归零,导致EKF把初始朝向当成0度,底盘实际朝向和地图坐标系差了90度甚至更多。解决方法是:在陀螺仪初始化完成后,连续采样100次取平均,减去零偏,再把yaw强制设为0。注意这个零偏必须在电机不转的时候采样,电机一启动,电磁干扰会让陀螺仪零偏漂移好几个量级。

6. 竞赛现场最容易翻车的三个细节:供电、时间戳和波特率

6.1 电源拓扑:树莓派和舵机、电机必须分开供电

树莓派4B的官方建议是5V 3A USB-C供电。但如果你的机器人上同时有电机驱动模块、舵机和激光雷达,共用一个电源会让树莓派在电机启动瞬间掉电压,轻则WiFi断连,重则SD卡文件系统损坏。正确做法是:12V锂电池给电机驱动板,驱动板自带5V/5A的BEC输出给树莓派,舵机直接吃12V或者独立5V,STM32开发板从树莓派的5V引脚取电或者独立降压模块。所有地线在电源输入端单点汇接,避免形成地环路。

6.2 时间戳对不上,cartographer建图锯齿状

建图时如果发现地图边缘有锯齿、回环检测疯狂报错,大概率是时间戳问题。ROS里所有传感器数据的header.stamp必须用传感器的实际采集时间,不是节点收到数据那一刻的墙钟时间。树莓派没有硬件RTC模块时,每次开机时间会重置成1970年,如果不同节点的时间基准不一致,tf和时间同步就会乱掉。

在没有外部网络的环境下,建议在启动脚本里用chrony做一次软件校时,或者接受时间漂移但保证所有节点用的是同一个ros::Time::now()来源。实战中最高效的做法是给树莓派加一个DS3231 RTC模块,几块钱,i2c接口,配置好之后时间永远准。

6.3 串口通信波特率不一致,程序假死的排查顺序

如果树上节点正常启动、STM32也正常跑,但rostopic echo /odom就是没有任何数据,先用逻辑分析仪或者示波器量一下UART TX/RX引脚的活动状态。没有示波器就听:树莓派TX引脚接一个无源蜂鸣器,能听到嘶嘶的噪声说明有数据在发送,那就是波特率或者电平不匹配。

使用ch340或者CP2102这类USB转串口模块时,有一个隐蔽的坑:模块上的TXD/RXD是3.3V TTL电平,树莓派的GPIO也是3.3V,兼容。但如果你用的是某宝几块钱的MAX3232模块,模块上做了电平转换,输出是RS232电平,±12V,直接怼到树莓派GPIO上会烧毁引脚。排查方法很简单:测模块供电电压是3.3V还是5V,TTL模块贴片芯片一般是MAX3232或SP3232,RS232模块则有大块头的DB9接口。

6.4 终极验证:用rqt_graph和plotjuggler看整条链路

联调完成后,用rqt_graph看所有节点的话题连接关系,确认serial_node和move_base之间的连线没有断开。再用plotjuggler订阅/odom的twist.linear.x和/cmd_vel的linear.x,绘制两条曲线对比,正常情况应该是cmd_vel先变化,odom延迟几十毫秒后跟随。如果odom响应滞后超过200毫秒,检查STM32端速度环的PID周期是不是被其他中断阻塞了。这个验证方法特别适用于答辩演示前快速定位是底盘的锅还是算法的锅,省得在评委面前反复重启节点。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询