麦克纳姆轮小车底盘驱动实战:STM32运动学解算到PID闭环

📅 发布时间:2026/9/16 14:36:35
麦克纳姆轮小车底盘驱动实战:STM32运动学解算到PID闭环
简介基于STM32的麦克纳姆轮智能小车底盘驱动工程面向电子/嵌入式方向的小白与进阶学习者可直接用于毕业设计、课程设计或工程实训。项目以底盘驱动为核心涵盖运动控制算法、电机驱动、整车调试等完整环节特别在底盘驱动部分包含电机PWM控制、编码器反馈、速度环与麦克纳姆轮运动解算可帮助读者从原理到实现系统理解。压缩包共985个文件、约24.13MB以C/C源码为主561个c、245个h、51个汇编s另包含Keil/IAR工程配置、hex固件、CMSIS DSP数学库及编译中间文件整体目录结构完整便于打开工程对照学习或二次开发。已有369人学习下载适合希望掌握STM32小车底盘驱动原理、快速搭建实物验证并完成项目报告的读者。1. 为什么单独拆出底盘驱动麦克纳姆轮小车的核心矛盾麦克纳姆轮智能小车看起来有两个大难题一个是运动学解算另一个是电机控制。实际做下来绝大多数问题都出在后面——底盘驱动。轮子空转、跑不直、起步抖动、刹车点头十有八九是PWM频率不对、死区没补偿、PID没调好。运动学解算反而简单四个轮速一算就完但算出来的轮速能不能被物理车体执行到位才是决定智能小车性能的分水岭。这篇内容面向用STM32做麦克纳姆轮底盘、尤其以stm32f103zet6这类芯片为主控的开发者把底盘驱动从硬件接口到代码闭环拆开讲重点落在怎么让四个麦轮按照预期的速度转起来。2. 麦克纳姆轮运动学解算从轮速到 Vx、Vy、Ω 的换算在写底层驱动之前先得把轮速的目标值算清楚。底盘驱动不只是“让电机转”而是让四个电机按特定比例关系协同运转。这个比例关系由麦克纳姆轮的辊子角度决定理论上辊子与轮毂轴线呈 45 度角所以每个轮子对地面的作用力分解为沿辊子方向的分量和垂直于辊子的分量。想要车体前后移动、横向平移或自旋就需要组合出不同的轮子转速组合。2.1 辊子角度与坐标系约定我习惯把坐标系定在车体中心X 轴正方向为前进方向Y 轴正方向为向左横移逆时针旋转为 Ω 正方向。四个轮子按逆时针编号左前轮 FL、右前轮 FR、左后轮 RL、右后轮 RR。辊子方向需要根据安装方式定义常见的是左前和右后轮辊子角度为 135 度右前和左后轮为 45 度。如果装反运动学公式的符号就会错误车会原地转圈而不是直线平移。实际工程中很多老手的做法不是死记公式而是先通过“只给单个轮子正转”的试验确认辊子对地的滑动方向。这样能避开说明书上辊子角度标错、或者装成 O 型和 X 型差异造成的坑。麦克纳姆轮有 X 型和 O 型两种装配关系区别在于辊子与地面的接触线是否构成 X 形X 型安装最常用本文公式也按 X 型展开。2.2 逆运动学公式推导与代码实现设车体速度在底盘坐标系下为 Vx、Vy、Ω半轮距为 a半轴距为 b轮子半径为 r。逆运动学把目标速度映射为四个轮子的线速度FL: (Vx - Vy - (a b) * Ω) / rFR: (Vx Vy (a b) * Ω) / rRL: (Vx - Vy (a b) * Ω) / rRR: (Vx Vy - (a b) * Ω) / r注意符号取决于轮子安装方向和辊子方向上面的式子对应的是最常见 X 型安装且左轮正转为前进方向的情况。如果你发现横移方向相反就把 Vy 的符号整体取反如果自旋方向相反就把 Ω 前的符号全部取反。在 STM32 工程里我一般用一个结构体存底盘速度再写一个函数把速度转换成轮速。关键代码如下typedef struct { float vx; // 前进速度单位 m/s float vy; // 横移速度向左为正单位 m/s float omega; // 逆时针自旋速度单位 rad/s } ChassisSpeed; typedef struct { float fl; float fr; float rl; float rr; } WheelSpeed; WheelSpeed chassis_kinematics(ChassisSpeed speed, float a, float b, float r) { WheelSpeed out; out.fl ( speed.vx - speed.vy - (a b) * speed.omega) / r; out.fr ( speed.vx speed.vy (a b) * speed.omega) / r; out.rl ( speed.vx - speed.vy (a b) * speed.omega) / r; out.rr ( speed.vx speed.vy - (a b) * speed.omega) / r; return out; }这段代码里a 是半轮距也就是左右轮中心距的一半b 是半轴距前后轮中心距的一半。单位要保持一致速度用 m/s距离用 mω 用 rad/s轮速结果就是 m/s。注意这里求出的只是线速度后续要换算成 RPM 或者编码器计数周期取决于电机减速比和编码器线数。2.3 正运动学与底盘速度反馈逆运动学用于下发目标正运动学则用于从轮子实际转速反推底盘当前速度这是闭环控制的基础。正运动学公式可以从上面的矩阵求逆得到Vx (FL FR RL RR) * r / 4Vy (-FL FR - RL RR) * r / 4Ω (-FL FR RL - RR) * r / (4 * (a b))这个计算通常放在定时器中断里周期不等可能是 10ms 或 20ms。算出来的 Vx、Vy、Ω 不仅给 PID 用还能用于给上层导航算法提供里程计数据。需要注意的是正运动学是理想模型它没有考虑轮子打滑所以实际底盘速度反馈会比真实速度偏高尤其是在急加速或光滑地面时。可以配合麦克纳姆轮陀螺仪定位来做航迹推算把正运动学算出的速度积分成位置再用陀螺仪的角速度修正 Ω 的漂移。这是目前工创赛智能物流小车里很常见的做法思路是把轮速里程计和陀螺仪做简单融合不需要上卡尔曼滤波也能获得不错的短时定位效果。2.4 车体几何参数的实际修正车体尺寸看起来直接拿尺子量就行但运行时有两个修正点。第一轮距和轴距的取值要参考轮子与地面的接触点而不是底盘板的边缘。第二麦克纳姆轮安装后可能有一定倾角导致实际有效轮距和理论值偏差这会让自旋运动产生横向分量车体绕着不是中心的点旋转。我一般会在调车时做一次“原地自旋测试”给定 Ω观察车体中心是否保持不动。如果自旋时有明显平移就微调 a 和 b 的值直到平移最小。也可以在代码里加入参数偏移项例如把 a 写成 a ΔaΔa 通过测试标定。这个参数对导航累计误差的影响很大值得花时间标定。3. STM32 底盘驱动硬件设计电机驱动器选型与电路接口运动学只解决“目标轮速是多少”真正让轮子转起来的是电机驱动回路。这部分是底盘驱动里最容易出硬件故障的地方。选型不当可能导致驱动芯片发烫烧毁、PWM 干扰复位、电机电流不足导致堵转这些都是实际项目中高频踩坑点。对于 STM32 控制的麦克纳姆轮智能小车常见的驱动方案有几种我根据自己的经验做一个对比。3.1 电机驱动方案对比TB6612、BTN7971、DRV8701TB6612 是最常见的入门级双路电机驱动内置 H 桥最大持续电流 1.2A 左右峰值 3A。适合体积小、电流小的麦克纳姆轮小车比如使用 N20 减速电机或 1:30 的 370 电机。BTN7971 是半桥芯片需要两片组成一个全桥单路可达 40A 级别但外围电路复杂适合大功率底盘。DRV8701 是一款栅极驱动器需要外加 MOS 管灵活性最高。方案每路持续电流外围复杂度适合电机类型成本区间TB66121.2A低N20、微型直流电机低BTN7971数十A高大功率直流电机高DRV8701 MOS由MOS决定中高任意需计算散热中L298N2A低常见教学电机低对于大部分基于 stm32 的毕业设计或竞赛底盘TB6612 足够用。要注意的是 TB6612 的电源电压范围是 2.7V 到 5.5V它的 VM 脚接电机电源VCC 接逻辑电源两者不能接反。很多新手直接把 TB6612 的 VCC 接 12V芯片立刻就烧了。3.2 用定时器产生 PWM 与方向控制信号STM32 的电机驱动接口主要分三路信号PWM、方向、刹车可选。PWM 接定时器通道方向接普通 GPIO。底盘驱动需要四路 PWMstm32f103zet6 有多个高级定时器和通用定时器我常用 TIM2、TIM3、TIM4、TIM5 各自输出两路 PWM 给四个电机也可以用两个定时器各输出四路 PWM但要注意通道复用冲突。以下是用标准库配置定时器输出 PWM 的一段核心代码void TIM_Config(void) { TIM_TimeBaseInitTypeDef TIM_BaseInit; TIM_OCInitTypeDef TIM_OCInit; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef GPIO_Init; GPIO_Init.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; // TIM3_CH1, CH2 GPIO_Init(GPIOA, GPIO_Init); TIM_TimeBaseStructure.TIM_Prescaler 71; // 72MHz / 72 1MHz TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 999; // PWM 频率 1MHz / 1000 1kHz TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseInit(TIM3, TIM_BaseInit); TIM_OCInit.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInit.TIM_OutputState TIM_OutputState_Enable; TIM_OCInit.TIM_Pulse 0; TIM_OCInit.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM3, TIM_OCInit); TIM_OC2Init(TIM3, TIM_OCInit); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); }这段配置把定时器预分频设为 71得到 1MHz 计数频率自动重载值为 999所以 PWM 频率是 1kHz。对于大多数直流电机驱动芯片1kHz 到 20kHz 都可以工作。但要注意TB6612 的耐压和开关损耗在 10kHz 以上时会明显增加如果用高频率驱动芯片发热会更大。我一般习惯把底盘驱动 PWM 频率设在 10kHz 左右因为麦克纳姆轮电机电感较小太高会造成电流纹波太大太低会听到明显的电机啸叫。方向控制通过 GPIO 输出高低电平实现。需要直接操作寄存器时也可以使用 BRR 和 BSRR这里给一个通用控制函数void Motor_SetSpeed(GPIO_TypeDef* dirPort, uint16_t dirPin1, uint16_t dirPin2, TIM_TypeDef* tim, uint16_t channel, int16_t speed) { if (speed 0) { GPIO_WriteBit(dirPort, dirPin1, Bit_SET); GPIO_WriteBit(dirPort, dirPin2, Bit_RESET); } else if (speed 0) { GPIO_WriteBit(dirPort, dirPin1, Bit_RESET); GPIO_WriteBit(dirPort, dirPin2, Bit_SET); } else { // 停止时可以让两个方向引脚都输出低电平通过驱动芯片实现刹车 GPIO_WriteBit(dirPort, dirPin1, Bit_RESET); GPIO_WriteBit(dirPort, dirPin2, Bit_RESET); } // speed 范围限制在 -1000 ~ 1000 if (speed 1000) speed 1000; if (speed -1000) speed -1000; uint32_t pulse (speed 0) ? speed : -speed; switch (channel) { case 1: TIM_SetCompare1(tim, pulse); break; case 2: TIM_SetCompare2(tim, pulse); break; case 3: TIM_SetCompare3(tim, pulse); break; case 4: TIM_SetCompare4(tim, pulse); break; } }这里用的是 -1000 到 1000 的标量表示 PWM 占空比方便上层命令直接传入。注意当 speed 为 0 时PWM 比较值也是 0但此时方向引脚同时拉低驱动芯片是否进入刹车取决于芯片逻辑。TB6612 的 IN1、IN2 同时为低电机处于停止但未刹车状态要让芯片真正刹车需要把 STBY 脚拉低或者同时输入相同逻辑。实际底盘驱动中我通常会在急停时单独操作驱动芯片的刹车引脚而不是只把速度设为 0。3.3 编码器接口正交解码与倍频底盘驱动必须知道轮子实际转速所以带编码器的减速电机是标配。常见的是霍尔编码器比如 13 线、17 线配合减速比得到每转脉冲数。STM32 的定时器有编码器模式能自动处理 A 相和 B 相的正交信号省去外部中断计数开销。编码器接线A 相接定时器 CH1B 相接 CH2。以 TIM4 为例void Encoder_Init(void) { GPIO_InitTypeDef GPIO_Init; TIM_TimeBaseInitTypeDef TIM_Init; TIM_ICInitTypeDef TIM_ICInit; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_Init.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_Init.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOB, GPIO_Init); TIM_Init.TIM_Prescaler 0; TIM_Init.TIM_CounterMode TIM_CounterMode_Up; TIM_Init.TIM_Period 0xFFFF; TIM_Init.TIM_ClockDivision 0; TIM_TimeBaseInit(TIM4, TIM_Init); TIM_ICInit.TIM_Channel TIM_Channel_1; TIM_ICInit.TIM_ICPolarity TIM_ICPolarity_Rising; TIM_ICInit.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInit.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInit.TIM_ICFilter 0x0A; TIM_ICInit(TIM4, TIM_ICInit); TIM_ICInit.TIM_Channel TIM_Channel_2; TIM_ICInit.TIM_ICPolarity TIM_ICPolarity_Rising; TIM_ICInit.TIM_ICSelection TIM_ICSelection_IndirectTI; TIM_ICInit.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInit.TIM_ICFilter 0x0A; TIM_ICInit(TIM4, TIM_ICInit); TIM_EncoderInterfaceConfig(TIM4, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_Cmd(TIM4, ENABLE); }编码器模式配置后TIM4-CNT 会随着 A、B 相脉冲增减正转时加计数反转时减计数。需要定时读取 CNT 并清零。由于计数范围可能溢出最好使用 16 位无符号变量并做强制转换避免符号处理错误。我通常在 10ms 定时中断里读取int16_t encoder_count (int16_t)TIM4-CNT; TIM4-CNT 0;这样能识别正反方向同时把速度换算成 rpm 或 m/s。换算公式为轮速 计数值 / (每转脉冲数 * 4 * 减速比) / 采样周期 * 2πr。很多电机标称编码器线数比如 13 线经过四倍频后每转计数为 13 * 4 52再乘以减速比比如 1:30则电机轴每转编码器计数为 52 * 30 1560。3.4 供电与电流保护底盘驱动的供电设计经常被忽略。四个电机同时启动时瞬时电流可能达到电机堵转电流的几倍。如果用电池供电需要确保电源线足够粗最好每根线承载 3A 以上电流。控制板电源与电机电源要分开走线可以共地但不要共用导线否则电机启动时电压跌落会造成 STM32 复位。保险丝或自恢复保险丝建议加在电池正极和电机驱动电路之间。如果使用 TB6612可以在 VM 脚加一个 100μF 电解电容和 0.1μF 陶瓷电容就近去耦。这是让底盘驱动稳定工作的关键很多人的小车一加大油门就重启就是供电布线不合理。4. 底层驱动代码实现从 PWM 输出到速度闭环 PID硬件接口准备好之后底盘驱动的核心就在代码层。我在 stm32f103zet6 上一般把代码分为三层底层初始化、速度采集、PID 控制。这一章给出可复用的代码结构和注释直接抄到自己的工程里改引脚就能用。4.1 初始化流程与代码组织先建一个chassis_driver.c文件集中管理电机和编码器的初始化。初始化顺序建议先配置 GPIO 时钟、再配置定时器 PWM、再配置编码器、最后开启定时器中断。如果先开启定时器中断再初始化编码器可能出现第一次读取的计数值是垃圾值导致 PID 瞬间出现大偏差。void Chassis_Init(void) { Motor_GPIO_Init(); // 方向引脚 TIM_PWM_Init(); // 四路PWM Encoder_TIM_Init(); // 编码器接口 TIM_Interrupt_Init(); // 5ms定时中断 pid_init(); }主循环只负责通过串口或遥控器更新目标速度PID 计算全部放在中断里保证控制周期稳定。控制周期一般取 5ms 到 20ms我常用 10ms。太短会加大中断负载太长会让底盘响应迟钝。4.2 速度采集与滤波速度采集放在定时器中断里先读取四个编码器计数再除以采样周期得到轮速。原始计数噪声较大尤其是齿轮电机霍尔编码器输出在低速时有明显抖动。我一般做一阶低通滤波使速度曲线平滑但要注意滤波系数会带来响应延迟。#define ALPHA 0.8f float wheel_speed_filtered; void Read_Encoder_Speed(void) { int16_t cnt[4]; cnt[0] (int16_t)TIM2-CNT; TIM2-CNT 0; cnt[1] (int16_t)TIM3-CNT; TIM3-CNT 0; cnt[2] (int16_t)TIM4-CNT; TIM4-CNT 0; cnt[3] (int16_t)TIM5-CNT; TIM5-CNT 0; for (int i 0; i 4; i) { float speed_rps (float)cnt[i] / (4.0f * PULSE_PER_REV); float speed_ms speed_rps * WHEEL_CIRCUMFERENCE; wheel_speed[i] speed_ms; wheel_speed_filtered[i] ALPHA * wheel_speed[i] (1.0f - ALPHA) * wheel_speed_filtered[i]; } }这里的PULSE_PER_REV是电机轴每转一圈的编码器总计数WHEEL_CIRCUMFERENCE是轮子外径乘以 π。注意读取计数器时要一次性读完每个定时器的 CNT否则间隔时间内计数器已经更新四个轮子的时间基准不一致。滤波系数 ALPHA 越大响应越快但噪声越大越小越平滑但延迟越大。底盘驱动建议先用 ALPHA 0.7 起步再根据 PID 响应微调。4.3 PID 控制器实现速度闭环一般用增量式 PID输出的是 PWM 增量每次累加得到最终占空比。增量式 PID 的优势是只依赖最近三次误差计算量小而且天然带限幅不会在误差突变时输出过大的控制量。typedef struct { float kp; float ki; float kd; float err[3]; float out; } PID_TypeDef; void PID_Calc(PID_TypeDef* pid, float target, float actual, float dt) { pid-err[2] pid-err[1]; pid-err[1] pid-err[0]; pid-err[0] target - actual; float d_err pid-err[0] - pid-err[1]; float d_err2 pid-err[0] - 2.0f * pid-err[1] pid-err[2]; pid-out pid-kp * d_err pid-ki * pid-err[0] * dt pid-kd * d_err2 / dt; if (pid-out 1000) pid-out 1000; if (pid-out -1000) pid-out -1000; }这段代码中dt是控制周期单位秒。积分项使用误差乘以 dt可以让不同控制周期下的积分效果一致。调用示例PID_Calc(pid_fl, target_speed_fl, wheel_speed_filtered[0], 0.01); Motor_SetSpeed(GPIOA, GPIO_Pin_0, GPIO_Pin_1, TIM2, 1, (int16_t)pid_fl.out);这里的目标速度来自运动学解算的轮速单位 m/s。可以发现 PID 的输入输出单位不同但增量式 PID 不要求单位一致因为系数会吸收比例关系。这既是优点也是坑调参时如果单位变了Kp 也要跟着变。4.4 串口调试与参数整定PID 调参不能盲调。我把轮速、误差、PWM 输出通过串口输出到 PC 端用串口助手或简单的 Python 脚本绘图。以下是一个 5ms 中断里输出的调试信息printf(FL:%.2f FR:%.2f RL:%.2f RR:%.2f\n, wheel_speed_filtered[0], wheel_speed_filtered[1], wheel_speed_filtered[2], wheel_speed_filtered[3]);如果不想用 printf 占用中断时间可以准备一个 DMA 发送缓冲区后台定时搬运。调参步骤先只给单个轮子设定阶跃速度观察超调量和稳态误差。Kp 从小往大加到出现轻微振荡然后加 Ki 消除静差。Kd 对快速底盘驱动帮助不大有时反而会放大噪声我通常先设为 0除非发现明显振荡再补一点。调试时还要注意四个电机的机械特性不一定一致。同样是占空比 500四个轮子的转速可能有差异。所以速度闭环正是为了让四个轮子各自收敛到同一个目标值这比开环 PWM 直接给满占空比靠谱得多。这也是为什么底盘驱动必须做闭环的原因。5. 底盘驱动实战调参上电抖动、跑偏与急停的 3 个绝招底盘驱动做完基本功能和 PID 后真正影响使用体验的是细节。这里分享三个我实践下来最有效的处理技巧各自都能解决一类高频问题。绝招一死区补偿电机驱动器存在死区STM32 输出的 PWM 占空比小于某个阈值时电机不转。死区大小跟驱动芯片和电机内阻相关我经常遇到的起始占空比在 4% 到 8% 之间。不补偿的话PID 在目标速度接近 0 时会输出很小的 PWM电机不转但编码器计数为 0误差一直累积最终导致输出突跳车会“咔哒”一下。处理方式是在Motor_SetSpeed里增加死区逻辑if (pulse 0 pulse 80) pulse 80; // 死区下限假设10kHzPWM, 80对应8%这个值需要实测从 0 开始逐步提高 PWM 占空比记录电机开始连续转的最小值作为死区下限。但加死区后PID 输出在死区阈值附近会出现非线性所以更精细的做法是让 PID 积分项在死区内停止累积只保留比例项输出。绝招二加速度限制麦克纳姆轮底盘如果直接给阶跃速度轮子会瞬间打滑车体猛冲里程计误差立刻增大。限制加速度是底盘驱动里最划算的优化。我一般用斜坡函数让目标速度以每 100ms 增加一定比例的方式逼近最终目标。float target_ramped; float accel_limit 0.5f; // m/s^2 最大值 void Speed_Ramp(float target, float dt, float accel, float* out) { float max_delta accel * dt; float delta target - *out; if (delta max_delta) delta max_delta; if (delta -max_delta) delta -max_delta; *out delta; }把这个斜坡作用于最终的目标速度而不是 PID 输出能显著减少轮子打滑。对于麦克纳姆轮横向加速度过大时轮子侧滑明显尤其在地砖和木地板上加速度限制要比两轮差速底盘更保守一些。绝招三急停时的双级制动普通停止只需把 PWM 设为 0但麦克纳姆轮底盘在全向运动模式下惯性大如果高速横移时急停车体会因为各轮辊子摩擦力不均匀而滑出一道弧线。我常用的双级制动先通过驱动芯片的刹车引脚让电机短接减速等车速下降到接近 0 后再释放刹车并清零 PWM。对于 TB6612把 STBY 拉低即可快速停转但要控制刹车时间一般 50ms 以内否则过大的机械冲击会损伤减速齿轮。底盘驱动的稳定性是在这些细节里堆出来的。每个参数背后都对应一个物理现象死区对应静摩擦限幅对应轮胎侧偏特性刹车对应惯性力矩。把这些想明白再回来看 STM32 的定时器和中断配置会发现一切都很自然。本文还有配套的精品资源点击获取