做机器人底盘,很多人第一反应是上工控机直接怼驱动板。这个思路不能说错,但真放到平时项目里折腾一圈,你会发现一个很尴尬的问题:工控机上跑导航、感知已经累得够呛,还得去处理电机换向、编码器中断、PWM输出这种毫秒级甚至微秒级的硬实时任务,随便来个进程调度卡顿,电机就跟着抽搐给你看。所以现在主流的做法,也是我这套项目里实际验证过的方案,就是把整个系统拆成两层:上层用ROS2负责决策、感知、导航,下层用Arduino或者ESP32这类MCU专门干底层的电机控制和里程计采集,两层之间通过串口或者micro-ROS通信。这篇就把这个底盘的完整实现思路、关键代码、调参过程和踩过的坑全部梳理一遍。
这篇文章适合三类人:一是刚开始学ROS2,想把手上的Arduino小车变成一个真正可以被ROS2驱动的机器人底盘的人;二是已经在用树莓派/工控机跑ROS2,但电机控制一直不稳定的朋友;三是纯粹想搞明白“上位机和下位机到底怎么配合”的硬件爱好者。我会从方案选型讲到底盘运动学、再到micro-ROS和自定义串口协议,最后到RViz2里看到实时里程计,整个链路都是我在实车上跑通过的。
1. 方案架构与选型解析:为什么把任务拆给两层
1.1 实时性与通用性的平衡
机器人底盘的核心矛盾是:控制要实时,算法要灵活。电机换向、PWM波动、编码器计数这些事情,要求确定性的响应时间,你不能说系统忙起来就晚个几十毫秒才响应一次中断,那轮子转速会忽快忽慢,里程计数据全是毛刺。而ROS2的节点调度依赖操作系统的进程管理和DDS通信,哪怕是做了实时内核优化,也很难保证微秒级的中断响应。
反过来,Arduino这类MCU是裸机或者轻量RTOS环境,中断响应是硬件级的,几十微秒的延迟完全可接受。把这个最硬实时的部分放到MCU上,ROS2这边只管发目标速度、收里程计,整个系统的稳定性会好非常多。这个思路和工业界常用的“运动控制器+上位机”架构是同一个逻辑,只是我们用开源方案把它做便宜了、做轻了。
1.2 底盘形态与驱动方式对比
做底盘之前先想清楚要什么运动模型。我做的是两轮差速底盘,前边一个万向轮做支撑,成本低、控制简单、室内平地场景足够用。但这个选型不是唯一的,得看你后续要跑什么场景。
| 底盘形态 | 运动能力 | 控制复杂度 | 适用场景 |
|---|---|---|---|
| 两轮差速 | 前后、旋转,不能横移 | 低,适合入门 | 室内平地、SLAM、巡线 |
| 四轮差速 | 前后、旋转,转弯半径大 | 低,但轮胎打滑影响里程计 | 户外粗糙地面、大负载 |
| 麦克纳姆轮 | 全向平移+旋转 | 中,解算稍复杂 | 狭小空间、全向搬运 |
| 舵轮/阿克曼 | 类似汽车转向 | 中高 | 户外移动、竞速类 |
我做的是两轮差速,一方面是因为大多数新手第一个小车就是这种结构,另一方面是后续要讲到的运动学解算和里程计积分,两轮差速是个特别好的教学载体。如果你做四轮差速,把一对轮子的速度并成一路就行,驱动代码几乎不用改。
1.3 主控板选择:Uno还是ESP32还是STM32
这是很多人卡住的第一关。只说结论:如果你打算用micro-ROS直接让MCU作为ROS2节点,别用Arduino Uno,老老实实上ESP32或者STM32;如果你用自定义串口协议做桥接,那Uno也完全能跑。
原因很简单,micro-ROS在MCU上跑需要一个微型的DDS客户端,光是通信栈和节点管理就要占掉不少内存。AVR架构的Uno只有2KB SRAM,我在实验的时候连最基本的micro-ROS示例都很难稳定跑起来,经常出现内存不足导致的不定时重启。而ESP32有320KB SRAM,跑micro-ROS加电机控制加编码器读取绰绰有余,还自带WiFi和蓝牙,可以直接用UDP方式跟ROS2通信,省一条USB线。
我最终的配置是:ESP32做下位机,跑电机驱动、编码器采集、速度闭环和协议解析;上位机用一台装了ROS2的迷你主机,负责跑SLAM和导航。如果你手头只有Uno,也不是不能用,后面我会给一套精简的串口协议方案,Uno只做速度执行器,它没有余力去跑复杂运算,但做好本分工作没问题。
2. 下位机固件开发:电机驱动、编码器与运动学解算
2.1 电机驱动接线与PWM控制
电机选型我用的是带霍尔编码器的直流减速电机,减速比1:30左右,单轴输出扭矩在平地推个小车足够。电机驱动板用的TB6612,比L298N好用太多了:体积小、压降小、发热低,而且逻辑输入可以直接接3.3V,ESP32的GPIO不需要电平转换。L298N那个东西动不动就1.4V压降,电池稍微弱一点底盘就表现得很肉。
TB6612的接线非常简单,每路电机两个方向控制引脚AIN1/AIN2,一个PWM速度引脚PWMA,对应ESP32的GPIO输出。控制逻辑是:
- AIN1=HIGH, AIN2=LOW 时,电机正转
- AIN1=LOW, AIN2=HIGH 时,电机反转
- 如果两个方向脚相同,电机刹停
PWM我用的是LEDC(ESP32的硬件PWM外设),频率设成20kHz,可以明显降低电机啸叫。默认的500Hz左右PWM在驱动电机时会发出很刺耳的噪声,而且电流纹波也大,我实测20kHz下同样占空比电机转速更平滑。下面是ESP32的PWM初始化部分:
#define PWM_FREQ 20000 #define PWM_RES 8 // 8位分辨率,占空比0-255 void motorInit() { ledcSetup(0, PWM_FREQ, PWM_RES); // 左轮PWM通道 ledcSetup(1, PWM_FREQ, PWM_RES); // 右轮PWM通道 ledcAttachPin(LEFT_PWM_PIN, 0); ledcAttachPin(RIGHT_PWM_PIN, 1); } void motorSetSpeed(int leftPwm, int rightPwm) { // 正负值控制方向,取绝对值输出PWM digitalWrite(LEFT_AIN1, leftPwm > 0 ? HIGH : LOW); digitalWrite(LEFT_AIN2, leftPwm > 0 ? LOW : HIGH); digitalWrite(RIGHT_AIN1, rightPwm > 0 ? HIGH : LOW); digitalWrite(RIGHT_AIN2, rightPwm > 0 ? LOW : HIGH); ledcWrite(0, abs(leftPwm)); ledcWrite(1, abs(rightPwm)); }注意:电机启动瞬间电流很大,PWM占空比从0直接跳到200这种突变,很可能把电源电压拉垮,导致MCU重启。我后来在电源管理上吃了不少亏,这个后面专门讲。
2.2 编码器测量:从脉冲数到轮速
带霍尔编码器的直流减速电机,通常电机轴上有个磁环,两个霍尔传感器输出A、B两路相位差90°的方波信号。通过A、B相的关系不仅能测转速,还能判断方向,这就是所谓的正交解码。
我的电机减速比是1:30,电机轴端编码器每转输出20个脉冲(单相)。那么轮子转一圈,单相计数得到的脉冲数是20×30=600。如果使用ESP32的PCNT(脉冲计数)外设做正交解码,还能4倍频,也就是一轮2400个计数。4倍频的好处是低速时分辨率更高,速度环的数据不那么抖。
轮速计算的核心就是一个比例换算。设采样周期为T(秒),采样周期内计数增量为N,那么轮子转速(转/分)就是:
rpm = N / (脉冲数每圈 × T / 60)如果采样周期是50ms,一轮600脉冲,那么测到30个脉冲对应的转速就是:
rpm = 30 / (600 × 0.05 / 60) = 60 rpm再用rpm换算成线速度:
v = 2π × rpm / 60 × rr是轮子半径。我的轮子直径65mm,半径0.0325m,那么60rpm对应的线速度大约是0.204m/s,一个典型的中速移动速度。
实现上最重要的是用硬件外设计数,不要在主循环里做电平轮询。ESP32的PCNT外设可以自动计数,我每50ms读一次并清零,非常省CPU。Uno的话就用中断计数,两个轮子各接一个中断引脚,每次中断对脉冲数加一,注意不要在中断里做耗时操作。
2.3 差速底盘的正逆运动学解算
差速底盘的魅力在于,只需要给左右两轮的线速度,就能合成出底盘整体的线速度和角速度。反过来,ROS2下发的/cmd_vel消息里包含的是机器人坐标系的线速度vx和角速度wz,要通过逆运动学换算成左右轮目标速度。
假设轮距为b(左右轮中心之间的距离),轮半径为r,两个轮子的线速度分别为vL、vR,底盘整体的线速度为v,角速度为ω(逆时针为正),那么正运动学公式是:
v = (vL + vR) / 2 ω = (vR - vL) / b逆运动学公式则是:
vL = v - ω × b / 2 vR = v + ω × b / 2举个例子,我的车实测轮距0.16m。如果ROS2发来一个0.2m/s的线速度和0.5rad/s的角速度,那么左右轮的目标线速度分别是:
vL = 0.2 - 0.5 × 0.16 / 2 = 0.2 - 0.04 = 0.16 m/s vR = 0.2 + 0.5 × 0.16 / 2 = 0.2 + 0.04 = 0.24 m/s明显看出,右轮快、左轮慢,底盘向右旋转的同时向前跑,正好符合角速度为正的定义。这套公式直接写在MCU固件里,每次收到目标速度就解算成左右轮目标线速度,再通过轮径换算成目标RPM,进入速度环。
2.4 PID速度闭环:从调参到跑起来
开环PWM控制只有在空载平地上勉强能用,负载一变、电池电压一掉,转速就飘了。要做真正的机器人底盘,必须给每个轮子加一个速度闭环。我用的是增量式PID,采样周期50ms,控制量是PWM占空比。
// 增量式PID,返回PWM增量 float pidUpdate(float target, float current, float &integral, float lastError) { float error = target - current; integral += error; // 积分限幅,防止积分饱和 if (integral > INTEGRAL_LIMIT) integral = INTEGRAL_LIMIT; if (integral < -INTEGRAL_LIMIT) integral = -INTEGRAL_LIMIT; float output = KP * error + KI * integral + KD * (error - lastError); lastError = error; return output; }调参顺序我记得特别清楚:先只给P,从很小开始加,让轮子响应变快但不要持续振荡;然后加I,消除因为摩擦力、坡道产生的稳态误差;最后如果有超调和振荡,再给一点D。但D这个东西要小心,编码器数据稍微有噪声,D一大就把噪声放大成PWM抖动,电机嗡嗡响。所以我最后把D设得很小,甚至有时候直接为0,靠P和I已经能跑得不错。
还有一个特别实用的技巧是死区补偿。直流减速电机存在静摩擦,PWM占空比低于某个阈值时根本不会转,导致目标速度从小变大的时候响应慢半拍。我在PID输出后面叠加了一个固定偏置,比如deadzone=30,实际输出是pid_output + sign(pid_output)×30,这样能明显改善启动响应。
PID调好之后,用手捏轮子能感到明显的抵抗,放开后轮子能稳定回到目标转速。这个时候底盘才算达到了“可以被上层控制”的基本素质。
3. ROS2与Arduino通信方案:micro-ROS与自定义串口协议
3.1 两种方案怎么选
底盘固件写完之后,接下来是最关键的一步:怎么让ESP32和ROS2通讯。目前主流的做法就两种。
一种是micro-ROS,直接在MCU上面跑一个微型的ROS2节点,ESP32可以订阅/cmd_vel,也能发布/odom,一切都像在PC上写ROS2节点一样自然。另一种是自定义串口协议桥接,MCU只通过串口收发字节流,PC上跑一个Python或者C++的串口桥接节点,负责把串口数据转换成ROS2话题。
我个人的建议是:如果主控是ESP32,优先上micro-ROS,因为它生态成熟、调试方便;如果主控是Uno,或者你想彻底搞懂通信协议是怎么设计的,那就老老实实写自定义串口协议。
3.2 micro-ROS实战:Agent与固件配置
micro-ROS的架构是Client-Server模式:ESP32上是micro-ROS Client(也就是你的固件),PC上跑一个micro-ROS Agent,负责把DDS数据桥接到串口或者UDP。先启动Agent,再让MCU连上来。
我用的环境是Ubuntu 22.04 + ROS2 Humble,启动Agent最方便的方式是Docker:
docker run -it --rm --net=host ros:humble \ ros2 run micro_ros_agent micro_ros_agent serial --dev /dev/ttyUSB0 -b 115200如果连不上,优先检查两件事:串口号是否正确、当前用户是否在dialout组里。权限问题我用一句话解决:
sudo usermod -aG dialout $USER然后重新登录终端,不然每次都要sudo跑Agent。
固件这边,在Arduino IDE的库管理器里搜micro_ros_arduino,注意要选支持你板子的版本。ESP32的micro-ROS初始化非常简单:
#include <micro_ros_arduino.h> #include <rcl/rcl.h> #include <geometry_msgs/msg/twist.h> #include <nav_msgs/msg/odometry.h> rcl_node_t node; rcl_subscription_t sub; rclc_support_t support; rclc_executor_t executor; void setup() { // 通过串口连接Agent set_microros_serial_transports(Serial); delay(2000); rclc_support_init(&support, 0, NULL, allocator); rclc_node_init_default(&node, "chassis_node", "", &support); rclc_subscription_init_default(&sub, &node, ROSIDL_GET_MSG_TYPE_SUPPORT(geometry_msgs, msg, Twist), "/cmd_vel"); rclc_executor_init(&executor, &support, 1, &allocator); rclc_executor_add_subscription(&executor, &sub, &twist_msg, &twistCallback, ON_NEW_DATA); } void loop() { rclc_executor_spin_some(&executor, RCL_MS_TO_NS(10)); // 每次循环处理运动学解算和PID updateChassis(); }这里一个关键点是,micro-ROS的Executor不是实时任务调度器,所以你不能让控制逻辑完全依赖它。我的做法是loop循环里同时跑Executor和控制逻辑,控制逻辑按照自己的定时器节奏执行,Executor只是负责收消息和发消息。
实测下来,ESP32通过115200波特率串口连Agent,/cmd_vel消息的延迟在几个毫秒以内,完全够底盘控制用。如果你用WiFi的UDP方式,还能彻底摆脱USB线,但前提是ESP32和PC在同一网段,而且网络得稳,不然消息丢失率高,电机容易抖。
3.3 轻量串口协议设计
如果你的主控是Uno,那micro-ROS就别想了,直接走自定义协议。但即便主控够用,我也建议你懂一套协议怎么设计,因为这个思路能迁移到任何嵌入式设备上。
我的协议帧格式是:
帧头0xAA 0x55 | 数据长度 | 命令字 | 数据区 | 校验字节(累加和)比如下发线速度0.2m/s、角速度0.5rad/s:
// 左轮16位有符号速度,右轮16位有符号速度,单位mm/s // 帧头2字节 + 长度1字节 + 命令字1字节 + 数据4字节 + 校验1字节 = 9字节 byte frame[9] = {0xAA, 0x55, 0x04, 0x01, highByte(left), lowByte(left), highByte(right), lowByte(right), checksum};长度字段指命令字+数据区的长度,校验是前面所有字节的累加和低位。MCU端解析用一个有限状态机,按字节匹配帧头,状态依次切换:找帧头AA → 找帧头55 → 读长度 → 读命令字和数据 → 读校验 → 验帧执行。这个状态机的代码也就几十行,但是比用Serial.readString这种阻塞式解析稳得多,因为串口数据什么时候来、来多少都是不确定的。
PC端对应写一个桥接节点,订阅/cmd_vel,把Twist通过差速逆解算成左右轮速度,组帧发串口。反向的里程计、电池电压也是同样的帧格式,只是命令字不同。这套协议的好处是每条消息大小固定、解析快、出错能及时发现,Uno的串口缓冲区只有64字节,但9字节一帧十几条攒下来也完全放得下。
4. 上位机集成:URDF、TF树与RViz2可视化调试
4.1 底盘URDF模型与TF树的搭建
ROS2里的机器人描述文件我建议直接用URDF或者Xacro,不要用之前ROS1里那套太老的方式。一个两轮差速底盘的URDF至少包含三个link:base_link、left_wheel、right_wheel,以及两个continuous类型的joint。
写URDF的时候要特别注意坐标系朝向。base_link的X轴定义为机器人的前进方向,Z轴向上,这是ROS的REP-103规范。轮子joint的Z轴指向轮子旋转轴,也就是垂直于车体侧面。之前我有一次把joint轴的方向写反了,结果RViz2里模型转的方向跟实际底盘完全反着的,排查了半天。
TF树的发布我直接用robot_state_publisher这个现成节点,它读取URDF里的joint定义,发布base_link到左右轮子的TF。这里有个容易漏的地方:如果你用Gazebo,可能会有额外的odom到base_link的变换,这个变换应该由里程计节点发布,不要重复用robot_state_publisher发。
用Xacro写的好处是可以定义宏,比如轮距、轮径这种参数只写一遍,后面调整底盘尺寸时不用满文件找数字。我把轮距0.16m、轮径0.065m定义成了变量,改起来非常方便。
4.2 RViz2里验证模型和里程计
底盘的灵魂是TF,而RViz2是看TF最直观的工具。正常启动之后,RViz2里应该能看到:urdf定义的机器人模型、从odom到base_link再到左右轮子的TF连线,以及odom话题里的里程计箭头。
RViz2显示里程计很容易,添加Odometry显示,选好话题,然后把颜色调成亮绿色,箭头的长度和颜色能直接反映机器人的位移和朝向。我把底盘放在地上,用手推着往前走半米,屏幕上的箭头也跟着移动半米,那一刻是真的有成就感。
如果发现模型位置和实际不一致,优先检查TF和里程计的坐标系是否对齐。我在实际中遇到的坑是,URDF里轮子joint的origin设置和实际装配尺寸差了一点点,导致轮子看起来悬浮在地面上。URDF里轮子的origin是相对base_link的,必须加上轮子半径。比如base_link底面高度0.065m,轮子半径0.0325m,那轮子中心的Z应该是0.0325m,不是0。
4.3 ROS2与Python桥接节点的框架示例
这里给出PC端桥接节点的核心框架,用的是rclpy,订阅/cmd_vel话题,然后通过串口下发控制指令。串口通信我用pyserial,波特率115200,超时设成0.1秒,避免阻塞。
import serial import rclpy from rclpy.node import Node from geometry_msgs.msg import Twist class ChassisBridge(Node): def __init__(self): super().__init__('chassis_bridge') self.serial_port = serial.Serial('/dev/ttyUSB0', 115200, timeout=0.1) self.sub = self.create_subscription(Twist, '/cmd_vel', self.cmd_callback, 10) self.timer = self.create_timer(0.1, self.check_serial) def cmd_callback(self, msg): left, right = self.inverse_kinematics(msg.linear.x, msg.angular.z) frame = self.pack_frame(left, right) self.serial_port.write(frame) self.get_logger().info(f'send: left={left}, right={right}') def inverse_kinematics(self, v, omega): wheel_base = 0.16 wheel_radius = 0.0325 target_left = (v - omega * wheel_base / 2) / wheel_radius target_right = (v + omega * wheel_base / 2) / wheel_radius return target_left * 60 / (2 * 3.1415926), target_right * 60 / (2 * 3.1415926)这段代码里有两个容易踩的细节。第一,串口写操作本身是阻塞的,如果串口拥堵或者MCU断电,write会卡住节点,导致/cmd_vel积压,所以最好启动之前先确认串口是真的通的。第二,逆解算出的左右轮速度单位是m/s,但固件里约定的是RPM,所以转了60/(2π)。我在联调时因为漏了这个换算,发0.2m/s实际跑出了十几倍的速度,差点把小车撞墙上。
4.4 用teleop_twist_keyboard遥控验证
底盘运动学解算对不对,先不用急着跑SLAM,最简单的方法是拿一个键盘遥控器节点手动发/cmd_vel。ROS2里直接用teleop_twist_keyboard:
ros2 run teleop_twist_keyboard teleop_twist_keyboard按键控制的时候,i键是前进,逗号是后退,j和l是左右转,k是停止。我每次调完PID底盘,都是先按一个i,看车是否走直线。这里有个关键的判断方法:走直线实际上极其考验两轮的一致性,如果左右轮速度闭环参数不一致,车会往一边偏。我最初的PID参数左右轮一模一样,但装配时两边的负载不一样,导致实际速度有微小偏差,长距离走久了就歪了,后来我不得不给两个轮子分别标定。
键盘遥控能顺畅跑起来之后,再上RVIZ2和里程计同时显示,就能直观看到遥控转向时TF的旋转和箭头方向的对应关系。
4.5 用Gazebo仿真做预验证
如果你还没有实体底盘,或者每次调试都要蹲在地上看轮子转太累了,建议先用Gazebo把整个逻辑跑通。我用的方式是,把同一个URDF文件作为Gazebo模型加载,给两个轮子装上驱动插件,然后让ROS2直接发/cmd_vel到仿真底盘,照常通过teleop控制。
Gazebo的好处是可以提前验证URDF模型、底盘运动学解算和TF发布逻辑是否正确,坏处是仿真里的PID和现实完全不一样,所以仿真通过不等于实车通过。我通常把仿真当成一个“语法检查”加上“逻辑检查”,帮我把低级错误挡在实车之前。等实车出问题的时候,大概率就是机械、电路和真实PID的事了。
5. 实际问题排查与调优经验
5.1 串口通信故障排查
这大概是每个做ROS2+MCU项目的人都会撞上的墙。我遇到过几类典型问题,整理成一个速查表:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| Agent启动报Permission denied | 当前用户不在dialout组 | 用usermod加组后重启终端 |
| 串口打开了但收不到数据 | 波特率不匹配 | Agent和MCU都改成115200或确认两者一致 |
| MCU重启导致串口断开 | 电源不足 | 检查供电,参考5.4的电源处理 |
| Agent连上又断开 | micro-ROS创建内存不足 | 换ESP32,或者精简创建的话题数量 |
排查串口问题我有个固定套路:先关掉Agent,在PC上直接用minicom或python打开串口,手动发一帧数据看看MCU回不回。如果这个环节都不通,就不可能是micro-ROS的问题,先解决物理链路。如果MCU回了,再接Agent。分层排查能快速缩小范围,别一上来就在Agent日志里瞎猜。
5.2 编码器数据跳变
编码器数据跳变的表现是:车停着不动,里程计却自己在漂。我在实车调试中踩到的主要坑有三个。
第一是接线接触不良和干扰。霍尔编码器的信号线很长的话,很容易受到电机PWM电流的电磁干扰,尤其在我最开始用的L298N那个电路上,PWM一拉高,编码器数据就乱跳。解决办法是把编码器信号线远离电机电源线,能加屏蔽更好,另外信号线尽量短粗,不要和电机线扎在一起。第二是方向判断错。霍尔编码器A、B相如果接反了,方向解算就反了,表现出来是轮子正转时里程计是负的。PCNT外设的解码模式可以配置,或者干脆交换两根信号线试一下就能确认。第三是采样周期不稳。如果是用delay循环采样,期间有别的耗时操作会把采样间隔拉长,导致计算出的转速偏低。解决方式是改成固定频率定时器中断,到时间就采样一次,不能依赖主循环的节奏。
还有一个细节,编码器计数是有上限的,ESP32的PCNT计数器是16位的,如果采样周期太长,高速时可能溢出。我采50ms在最高速也没有溢出过,但如果你把采样周期放到1秒,就得注意这个溢出问题。
5.3 PID调试的典型症状
PID调参是很多人都头大的地方,我把症状和调参方向整理一下。
- 症状一:轮子持续振荡,PWM忽大忽小。一般是P太大了,或者编码器数据噪声被D放大。先降P,再降D,如果还振荡,检查编码器数据是不是真的干净。
- 症状二:低速时抖,高速时稳。往往是死区补偿没做好,PWM输出在启动阈值附近反复横跳。把死区补偿加回来,并且在目标速度低于某个值时用开环启动,等速度上来了再切闭环。
- 症状三:跑起来之后稳态误差一直存在。比如目标60rpm实际只有55rpm,这是摩擦或坡道造成的,光靠P压不下去,加大KI,同时注意积分限幅别无限积分。我一般把积分限幅设成PWM满量程的20%左右,再多就会过冲,刹车反应也慢。
- 症状四:响应慢,推一下轮子半天才回来。P太小或者采样周期太长。我的经验是50ms采样周期对入门小车够用,但如果你想让底盘更跟手,可以缩到20ms,PID代码的执行开销在ESP32上完全不是问题。
调PID最忌讳的是两三个参数同时动。我后来养成一个习惯:每次只调一个参数,调完记录下当时的波形和数据,跑一段路看效果。别看“差不多”就行,机器人的重复性就靠这些细节堆出来的。
5.4 电源纹波与MCU重启
这是很多底盘的隐形杀手。电机启动瞬间电流能达到正常工作电流的好几倍,如果控制板和电机共用一块电池,电机一启动,电压跌落就会导致MCU复位。我排查过的最诡异的一次是:车一加速就重启,怀疑是代码死循环,查了半天都没有,最后用示波器一看,电机启动瞬间VCC直接被拉到3V以下,ESP32当然复位了。
解决方式我实测有效的是三个方案并行:
- 电源分区:电机用单独的电池或电源,控制板用另一个电源,两边的地线必须连在一起,否则编码器信号会乱。
- 在MCU的5V或3.3V电源入口并联一个大电容,我用了470uF电解电容,板子上的5V口如果设计上允许,可以加在Vin附近。
- 电机供电线上加一个1000uF的电容,同时给电机并联一个续流二极管,能有效抑制反电动势的冲击。
有热词提到“arduino在5v端口接电容”,说的就是这个问题。很多时候MCU系统的不稳定根本不是程序的问题,是供电的问题。
5.5 没有硬件时用Wokwi仿真验证逻辑
如果你在等快递、或者只是想先跑通控制逻辑,Wokwi这个在线仿真平台值得用一下。它支持Arduino Uno、ESP32等常见板子,而且可以添加电机、编码器、舵机这些元件,直接在浏览器里跑Arduino代码。
我自己的使用心得是:Wokwi验证不了真实的PID效果,也模拟不了霍尔编码器的电气噪声和机械摩擦,但用来验证串口协议解析、状态机逻辑、运动学换算这些纯代码层面的东西特别方便。比如你把自定义串口协议写好了,想快速确认帧头检测、校验计算对不对,直接在Wokwi里放一个虚拟串口,仿真跑一遍逻辑,比烧录到板子上再连示波器快得多。
而且Wokwi还支持模拟外设的printf输出,调试协议解析的时候可以直接打印每一帧解析出来的速度值,代码写起来体验非常接近用开发板加串口监视器。如果是刚入门Arduino的新手,还没买齐硬件,先拿Wokwi把电机控制和串口协议跑通了,再上手实体硬件就不会那么手忙脚乱。
6. 联调与整机验证流程总结
把下位机固件、通信、上位机都准备好之后,我的联调流程是固定的,每一步都验证到位再往下走,不要一次把所有环节接起来。
第一步,单独验证下位机。不启动ROS2,用串口监视器直接给ESP32发命令帧,观察左右轮转速是不是符合预期。可以用固定PWM开环测试,也可以直接发目标RPM验证PID闭环。
第二步,验证桥接节点。在PC上运行Python桥接脚本,用一个简单的定时器往/cmd_vel里发固定的线速度,看ESP32是否收到并执行。这一层通了,说明通信链路没问题。
第三步,接入teleop和RViz2。启动teleop_twist_keyboard,手动按键控制底盘,同时在RViz2里观察底盘模型、TF和里程计。这一步验证整个TF树和里程计发布是否正常。
第四步,长距离直线测试。让底盘以固定速度前进5到10米,看终点位置和里程计累计的距离是否匹配,偏差控制在5%以内我认为是可以接受的。如果偏差明显,先检查轮径参数设置是否准确,再检查编码器每转脉冲数是否算对了。
第五步,转弯测试。手动下发一个纯角速度,观察底盘是否在原地旋转,并且旋转角度和里程计积分是否一致。两轮差速底盘如果轮距参数写错,旋转测试会非常明显,转90度实际转了80度或者100度就能发现问题。
这个流程走下来,底盘基本就具备了后续做SLAM、导航的基本条件。至于后续要不要接激光雷达、深度相机、IMU,那就是在这个底盘平台上叠加的事了。
最后分享一个我自己项目里的小技巧:底盘调试的时候,一定要在代码里加一个看门狗。如果上位机超过一定时间(比如500ms)没有收到新的/cmd_vel指令,底盘应该自动刹停,而不是保持最后一帧的速度一直跑下去。我最初调试的时候遥控器掉线过一次,底盘直接朝着墙冲了过去,幸亏速度不快。加上这个看门狗机制之后,不管上位机是崩溃了还是通信断开了,底盘都会停在原地,安全性提升一大截。家用机器人、教学机器人,第一原则始终是“不知道该怎么动的时候就别动”。
这个底盘的方案做完之后,我已经在它上面跑了简单的SLAM建图、路径点导航,效果虽然谈不上惊艳,但整个系统的稳定性和可控性是真的稳。如果你也想做机器人底盘,我建议不要一上来就追求复杂的功能,先把底盘的运动控制做扎实,把通信链路跑通,把里程计数据弄准,后面加再多的上层算法都有底气。