基于STM32F103C8T6的麦克纳姆轮小车运动学解算与PID闭环控制

📅 发布时间:2026/10/15 14:34:06
基于STM32F103C8T6的麦克纳姆轮小车运动学解算与PID闭环控制
简介基于STM32F103C8T6主控的麦克纳姆轮小车完整工程资源面向嵌入式初学者、机器人竞赛队伍及课程设计学生适用于智能车竞赛、毕业设计、机器人入门教学等场景主要解决全向移动底盘构建与蓝牙、WiFi两种无线遥控方式的实现问题。资源共128个文件压缩包仅3.77MB核心为33个h和32个c源码覆盖系统初始化、GPIO控制、定时器PWM输出、串口通信及无线指令解析同时包含Altium Designer原理图与PCB工程、Gerber制造文件、可直接烧录的hex固件、txt说明和bat辅助脚本便于从硬件设计到软件调试全链路学习。已有3887人学习实用性和上手难度得到一定验证。下载后可获得完整AD工程源文件与MDK工程源码既能基于蓝牙模块近距离调试也能借助ESP8266实现WiFi远程控制从启动文件、外设驱动到麦克纳姆轮运动控制逻辑均有清晰呈现适合二次开发扩展或直接复刻小车平台。1. 麦克纳姆轮小车为什么“横着走”比“跑得快”更值钱第一次在窄过道里被一台两轮差速小车卡住时我才意识到“能全向移动”在工程现场有多重要。麦克纳姆轮小车是那种一眼就让人记住的结构轮子外圈斜挂着一排可以自由转动的辊子四个轮子一配合车能直行、横移、斜着走、原地转不需要转向机构。用 STM32F103C8T6 这种入门级主控来做这件事成本低、资料多、逻辑也不复杂问题是网上能跑通的代码不少能讲清楚“轮子转速和底盘速度之间那套换算关系”的却不多。这篇笔记就围绕运动学解算、PWM 驱动、编码器闭环和实际调试中的坑展开适合手里有一台散装麦轮底盘、想自己写固件而不是抄现成工程的开发者。2. 麦克纳姆轮的机械与运动学先让“轮子怎么转”这件事立住2.1 辊子与45度角麦轮能横走的根本原因普通轮子的滚动方向固定转向只能靠改变轮子朝向。麦克纳姆轮的外圈是一圈可以自由转动的纺锤形辊子辊子轴线与轮子轴线之间呈 45 度角。轮子转动时辊子与地面的接触点并不沿轮子前进方向滚动而是沿着辊子自身轴线方向打滑于是轮子对地面的作用力被拆成两个分量一个沿轮子前进方向一个沿辊子轴向。这跟全向轮也叫万向轮那种辊子垂直于轮子轴线的结构不同全向轮只有横向的被动自由度麦轮则把纵向驱动力和横向滑动力同时传递给底盘。四个轮子的辊子方向两两相对左前轮和右后轮一组右前轮和左后轮一组。当四个轮子朝同一方向转时纵向分量叠加成前进力横向分量相互抵消当左右两侧轮子转向相反时纵向分量抵消横向分量叠加成横移力。这就是麦轮能“横着走”的根源。理解这一点后很多问题就通了比如为什么麦轮在光滑瓷砖上容易打滑因为辊子与地面的附着系数低横向分量的有效传递依赖摩擦再比如为什么四个轮子的辊子方向不能装错装反一个横向分量就从“抵消”变成“叠加”车会斜着跑。实际做底盘时麦轮分 A 轮和 B 轮两种A 轮的辊子轴线朝左前B 轮朝右前。安装时按“左前 A、右前 B、左后 B、右后 A”或相反的布局来但不同厂家的标注可能不一致。最稳妥的办法不是靠记忆而是装好后用手推一下看车轮是否沿预期方向滚动这一步能省掉后面大量的排查时间。2.2 运动学解算从底盘速度到四轮转速的逆向映射麦轮车的核心数学模型是逆运动学解算给定底盘期望的纵向速度 vx、横向速度 vy 和旋转角速度 ω算出四个轮子各自的转速。解算结果直接决定小车能不能按指令运动。坐标系约定会影响公式正负号我习惯用“前向为 X 正方向、左向为 Y 正方向、逆时针旋转为 ω 正方向”的右手系。轮子编号约定为1 号左前、2 号右前、3 号左后、4 号右后。设 a 为轮距左右轮间距的一半b 为轴距前后轮间距的一半则四轮转速的逆解公式为v1 vx − vy − ω(a b)v2 vx vy − ω(a b)这里 v2 符号要结合辊子方向不同资料的写法可能不同v3 vx vy ω(a b)v4 vx − vy ω(a b)实际工程中我用的是如下形式的公式按 1 左前、2 右前、3 左后、4 右后排列v1 vx - vy - ω * (a b) v2 -vx - vy - ω * (a b) v3 -vx vy - ω * (a b) v4 vx vy - ω * (a b)写进代码时要注意vx、vy、ω 的单位统一成 m/s 和 rad/s最后乘以 60/(2πr) 换算成 RPM。如果直接把 m/s 当成 RPM 用四个轮子的转速比例关系还是对的但速度值会完全偏小PID 怎么调都追不上目标。公式里的 (ab) 是旋转项系数代表轮子到质心的距离对旋转线速度的贡献。底盘尺寸越大这个系数越大同样的 ω 下四个轮子需要产生的转速差也越大。这一步是整台车控制逻辑的基础写代码前先在纸上把公式推一遍比拿着别人的代码改符号要靠谱得多。目标运动vxvyω四轮转速特征前进正00四轮同向同速横移向左0正01、4 轮与 2、3 轮反向原地自转逆时针00正左侧一轮与右侧一轮反向斜向 45 度前进正正0四轮同向但速度不等表格里的“反向”指的是轮子实际旋转方向。悬空测试时可以用手捏住辊子感受轮子的转向趋势如果发现某个轮子的转向与公式预期不符优先检查该轮子的辊子朝向和电机接线方向是否有问题。3. 在 STM32F103C8T6 上跑通运动学最小系统与 PWM 输出3.1 主控选型与引脚分配F103C8T6 的资源到底够不够STM32F103C8T6 是 64 引脚封装、64KB Flash、20KB RAM主频最高 72MHz。对于麦轮小车这种四电机控制场景资源刚好够用四个电机需要至少 4 路 PWM 输出四个编码器需要 4 路正交解码输入或两组定时器编码器接口再加上串口、陀螺仪、OLED 等外设引脚数量需要仔细规划。以我常用的分配方案为例定时器通道功能TIM2CH1~CH4四路 PWM控制四个电机驱动模块的调速引脚TIM3CH1/CH2编码器接口接 1 号、2 号电机编码器TIM4CH1/CH2编码器接口接 3 号、4 号电机编码器USART1TX/RX串口调试或蓝牙模块如果只用一个定时器输出四路 PWM要确认该定时器的四个通道是否都引出了引脚部分小封装板子可能没有把所有通道引出来。另一个方案是 TIM2 输出两路、TIM3 输出两路这样编码器就只剩 TIM4 一组另外两个电机只能用外部中断 GPIO 模拟解码精度差一些。我一般优先保证四路 PWM 和两组编码器接口都在硬件定时器上因为软件模拟解码在高速转动时容易丢脉冲。电机驱动模块的选择也会影响引脚规划。常见的 TB6612 模块有 IN1/IN2、PWM 等引脚需要 6 个控制引脚。注意 IN1/IN2 的电平逻辑要和自己的代码约定一致否则会出现“PWM 给了但电机不动”这类看起来像硬件故障的问题。一些同事用 DRV8833 模块逻辑更简单但电流输出上限较高电机堵转时容易过热需要加散热。3.2 把速度公式写成代码一份可直接抄的运动学模块运动学解算是整个固件里最不该出 bug 的部分。我用一个独立的 kinematics.c 文件封装输入是遥控通道或上位机下发的 vx、vy、ω输出是四个轮的转速设定值单位换算成 RPM 之后再交给 PID。// kinematics.c #include kinematics.h // 底盘参数单位米 #define WHEEL_RADIUS 0.030f // 轮子半径 30mm #define WHEEL_BASE 0.120f // 轴距 120mm即 b 0.06 #define TRACK_WIDTH 0.145f // 轮距 145mm即 a 0.0725 // 输入vx 前向速度(m/s)vy 左向速度(m/s)omega 逆时针角速度(rad/s) // 输出四个轮子的目标转速单位 RPM void Kinematics_Calculate(float vx, float vy, float omega, int16_t *motor1_rpm, int16_t *motor2_rpm, int16_t *motor3_rpm, int16_t *motor4_rpm) { float a TRACK_WIDTH / 2.0f; float b WHEEL_BASE / 2.0f; float l a b; float v1, v2, v3, v4; float rpm1, rpm2, rpm3, rpm4; // 逆运动学解算符号约定1左前 2右前 3左后 4右后 v1 vx - vy - omega * l; v2 -vx - vy - omega * l; v3 -vx vy - omega * l; v4 vx vy - omega * l; // 线速度(m/s)转轮转速(RPM)RPM v / (2*pi*R) * 60 rpm1 v1 / (2.0f * 3.14159f * WHEEL_RADIUS) * 60.0f; rpm2 v2 / (2.0f * 3.14159f * WHEEL_RADIUS) * 60.0f; rpm3 v3 / (2.0f * 3.14159f * WHEEL_RADIUS) * 60.0f; rpm4 v4 / (2.0f * 3.14159f * WHEEL_RADIUS) * 60.0f; // 死区处理数值小于 1 RPM 视为 0避免电机嗡嗡响 if (rpm1 1.0f rpm1 -1.0f) rpm1 0.0f; if (rpm2 1.0f rpm2 -1.0f) rpm2 0.0f; if (rpm3 1.0f rpm3 -1.0f) rpm3 0.0f; if (rpm4 1.0f rpm4 -1.0f) rpm4 0.0f; // 限幅到电机最大转速这里假设电机空载 300RPM if (rpm1 300.0f) rpm1 300.0f; if (rpm1 -300.0f) rpm1 -300.0f; // 2、3、4 轮同理代码省略 *motor1_rpm (int16_t)rpm1; *motor2_rpm (int16_t)rpm2; *motor3_rpm (int16_t)rpm3; *motor4_rpm (int16_t)rpm4; }这段代码的逻辑分三步先用逆运动学公式把底盘速度拆到四个轮子的线速度再把线速度按轮子半径换算成 RPM最后做死区和限幅。死区是给 PID 用的如果设定值小于 1RPM 还继续输出电机处于“要转不转”的临界状态容易产生高频振动限幅值需要根据你实际买的电机空载转速来改不要照抄。注意 2 号轮的公式里 vx 是负号这与辊子方向有关。如果你的轮子布局和我相反把 1/2 轮的符号对调即可。这个正负号是最容易写反的地方验证方法见第 6 章的运动测试矩阵。3.3 用 PWM 把“目标转速”变成“电压占空比”得到目标 RPM 之后通过 PID 输出一个占空比给电机驱动模块。常见的驱动模块用电平方向引脚和 PWM 调速引脚F103C8T6 的定时器输出比较功能可以直接产生 PWM 信号。// motor_pwm.c —— 基于标准库的 TIM2 PWM 初始化 void MotorPWM_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; GPIO_InitTypeDef GPIO_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // PWM 频率 10kHz72MHz / (71) / (900-1) ≈ 10kHz TIM_TimeBaseStructure.TIM_Period 899; TIM_TimeBaseStructure.TIM_Prescaler 7; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 0; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; // 四个通道分别初始化这里省略重复代码 TIM_OC1Init(TIM2, TIM_OCInitStructure); TIM_OC2Init(TIM2, TIM_OCInitStructure); TIM_Cmd(TIM2, ENABLE); TIM_CtrlPWMOutputs(TIM2, ENABLE); // 高级定时器才需要通用定时器可省略 }这里有一个我踩过的坑TIM2 是通用定时器不需要像 TIM1 那样调用 TIM_CtrlPWMOutputs但部分从网上下载的工程模板里写了这行在通用定时器上不会报错只是没有效果不影响运行。PWM 频率选 10kHz是因为常见直流减速电机的电感量不大频率太低会有可听见的啸叫频率太高则开关损耗变大且定时器计数分辨率变差。占空比映射到 PID 输出时还要注意极性PWM1 模式下占空比越大有效电平时间越长电机转速越高如果你的驱动模块是低电平有效要把占空比做一次翻转。4. 编码器测速与 PID 闭环让四个轮子真的“听指挥”4.1 编码器接入与测速一个定时器同时读两路正交信号闭环控制的前提是能真实测量轮子转速。常见的电机编码器是霍尔式正交编码器输出 A、B 两路相位相差 90 度的方波。STM32 的定时器自带编码器接口模式硬件上自动完成鉴相和计数不需要写外部中断。// encoder.c —— 用 TIM3 编码器接口模式读取 1 号电机转速 void Encoder_Init_TIM3(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_EncoderInterfaceConfigTypeDef TIM_ICInitStructure; GPIO_InitTypeDef GPIO_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period 0xFFFF; // 16 位计数器 TIM_TimeBaseStructure.TIM_Prescaler 0; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); // 四倍频模式A、B 两路同时参与计数 TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_ICInit(TIM3, TIM_ICInitStructure); TIM_Cmd(TIM3, ENABLE); } // 读取转速上次 CNT 值到本次 CNT 值的差值除以采样周期 float Encoder_GetSpeed(TIM_TypeDef *TIMx, uint16_t last_cnt, float dt_sec) { int16_t delta (int16_t)(TIMx-CNT - last_cnt); float pulse_per_sec (float)delta / dt_sec; // 转每秒 脉冲数 / (编码器线数 * 倍频数) float rev_per_sec pulse_per_sec / (11.0f * 4.0f); return rev_per_sec * 60.0f; // 返回 RPM }这里的几个参数必须和你的电机匹配。编码器线数是指电机输出轴每转一圈产生的脉冲数常见的是 11 线、13 线。减速电机还要考虑减速比如果电机是 1:30 减速箱编码器装在电机轴上轮子转一圈编码器会输出 11×30 个脉冲。所以测速公式里的“线数”要换成“编码器每轮圈脉冲数 线数 × 减速比”否则算出来的转速会偏大 30 倍PID 永远调不出来。我在代码里把 CNT 差值强转成 int16_t是为了处理计数器从 0xFFFF 回绕到 0 时的情况。如果正转一段时间后 CNT 溢出差值会变成一个很大的负数不做有符号处理的话测速会瞬间跳变到离谱的值。用 int16_t 接收差值回绕问题就自动解决了——前提是你的采样周期足够短不会让差值超过 32767。4.2 增量式 PID 与调参顺序先内环后外环速度环的 PID 我习惯用增量式输出的是 PWM 占空比的增量而不是绝对值这样天然带有积分记忆功能且不会因为积分饱和导致输出长时间卡在极限。代码逻辑如下// pid.c —— 速度环增量式 PID typedef struct { float Kp, Ki, Kd; float err_last, err_last2; float out; // 当前输出占空比 0~100% } SpeedPID; float SpeedPID_Update(SpeedPID *pid, float target_rpm, float actual_rpm) { float err target_rpm - actual_rpm; float inc; // 增量式out Kp*(err - err_last) Ki*err Kd*(err - 2*err_last err_last2) inc pid-Kp * (err - pid-err_last) pid-Ki * err pid-Kd * (err - 2.0f * pid-err_last pid-err_last2); pid-out inc; // 输出限幅 0~100防止占空比溢出 if (pid-out 100.0f) pid-out 100.0f; if (pid-out 0.0f) pid-out 0.0f; pid-err_last2 pid-err_last; pid-err_last err; return pid-out; }增量式 PID 不需要单独做积分限幅输出限幅就同时限制了积分累积。调参顺序上我先把 Kd 设 0只调 Kp 和 Ki。Kp 从 1 开始每次翻倍观察轮子转速是否能在半秒内追上目标且不出现振荡。Kp 过大时轮速会在目标值附近反复横跳伴随电机明显的“呜呜”声。Kp 合适后Ki 从 0.1 开始往上加用来消除稳态误差——如果轮子空载时转速稳定但比目标低 10%这就是 Ki 不够的表现。采样周期我固定在 10ms也就是每 10ms 执行一次编码器读取和 PID 更新。这个周期和编码器分辨率要匹配如果电机转速高、编码器脉冲密10ms 内的计数差值太小测速分辨率不够如果周期太长PID 响应变慢。对于常见的 11 线电机配 1:30 减速箱轮子最高转速约 300RPM10ms 内大约有 220 个脉冲分辨率足够。4.3 转向环与位置环从“轮速一致”到“车走直线”速度环只能保证每个轮子转速准确。麦轮车要走出直线或走一条精确的路径还需要更高层的闭环。最简单的做法是以左右轮编码器速度之差作为误差源理想直线行驶时左右轮平均速度相等如果左侧编码器测得速度偏大就适当降低左侧目标转速让底盘回到直线。有陀螺仪时车可以做得更稳。常见的传感器是 MPU6050 的 Z 轴角速度用它做一个角速度环设定角速度 ω_target 与实测角速度 ω_actual 做 PID输出一个修正量叠加到四个轮子的运动学计算结果上。注意叠加方式不是直接加到 vx 或 vy而是要换算成四个轮子的转速差左前和左后轮加右前和右后轮减或反过来具体看你的坐标系定义。这一步在代码结构上建议分层运动学模块只负责“底盘速度 → 四轮转速”PID 速度环只负责“目标转速 → 占空比”转向环插在两者之间。这样任何一层出问题都能单独悬空测试定位不用从头查到尾。5. 常见问题与排查我在这台麦轮车上踩过的坑5.1 现象上电后小车自己往一侧跑按下电源开关小车还没收到任何指令四个轮子就开始缓缓转动方向还不一致。第一次遇到时我还以为驱动模块坏了换了一个新的依旧如此。原因是驱动模块的 IN 引脚在上电瞬间处于悬空状态逻辑电平不确定正好碰到驱动器的“正转”电平电机短暂通电转动。解决方法是在主循环开始前先把全部 PWM 占空比写 0同时把方向引脚初始化为确定的电平。另外在驱动模块电源入口并联一个大电容能吸收上电瞬间的冲击避免逻辑电平抖动。5.2 现象悬空测试四轮转速正常放到地上推不动这是最容易被误判成“电机扭矩不够”的问题。悬空时辊子不接触地面负载几乎为零很小的占空比就能让轮子转得飞快落地后摩擦力矩变大同样的占空比对应的实际转速明显下降。如果你的 PID 目标转速在悬空时能跟上、落地时却低于目标那多半是占空比太低或 PID 参数过于保守。我一般会在 PID 输出上叠加一个 3%~5% 的最小占空比偏置让电机始终有基础驱动力去克服静摩擦。注意这个偏置要在速度环里做补偿否则会出现低速时停不下来的问题。5.3 现象编码器读数跳动PWM 一开数值乱跳编码器读数不稳定很多时候不是编码器坏了而是干扰。电机线上的大电流变化会在导线周围产生磁场编码器线如果跟电机电源线绑在一起走线就会耦合出错误的脉冲信号。解决方法是编码器线换成双绞线或屏蔽线把信号线跟电源线物理分开电机电源线尽量粗减少回路电感主控板的地和驱动板的地单点相连避免形成地环路。还有一个容易忽略的点编码器的 VCC 要和主控板共用一个稳压源如果电机瞬间拉低了电池电压编码器供电不足也会导致读数乱跳。5.4 现象电机发出高频啸叫像是“吱吱”声PWM 频率落在人耳可听范围内时电机线圈会被激励发出噪声。我最早用 1kHz 的 PWM电机一转就响还以为是电机内部有问题。后来把频率提到 10kHz啸叫消失了。但是要注意PWM 频率不是越高越好定时器时钟 72MHz如果频率推到 50kHz计数器最多 1440 个计数值占空比分辨率只有 0.07%配合 PID 输出会显得很粗糙。另一个隐藏问题是如果你在主循环里频繁修改定时器的 ARR 寄存器来调频而 PWM 输出还没完成一个周期脉宽会出现毛刺加剧啸叫。正确做法是PWM 频率固定不变只改比较寄存器 CCR 的占空比。5.5 现象电池电压掉了一点PID 参数就失灵用两节锂电池供电时满电 7.4V跑几分钟掉到 6.5V原本调好的 PID 突然变得振荡或响应迟钝。原因是电压变化改变了电机“电压→转速”的增益同样的占空比低压下转速更低PID 为了追目标会加大输出导致带宽变化。一个实用的补偿方法是给 PID 输出加前馈项每 100ms 采样一次电池电压电压低时按比例增大占空比输出把电压变化造成的影响在 PID 之前消化掉一部分。更省事的做法是给主控和驱动逻辑用独立的稳压模块只让电机直接吃电池电压。6. 调参与验证技巧没有示波器也能确认这台麦轮车“合格”先做运动学验证再做闭环控制。我习惯在代码里留一个测试模式通过串口发送指令分别让 vx、vy、ω 各等于一个固定值观察车的运动方向是否跟预期一致。测试矩阵很简单——vx0.3 时车应前进vy0.3 时车应左移ω0.3 时车应逆时针原地旋转。如果前进变后退是方向引脚逻辑反了如果横移变斜移说明某个轮子的 vx/vy 符号配错了把运动学模块里那个轮的公式符号对调即可。查符号错误时可以只给一个轮子发 PWM看它转动时辊子的运动趋势。麦轮的结构决定了轮子正向转动时辊子与地面的接触点会朝一个确定方向滑移拿手摸一下就能判断这个轮的辊子朝向是否和公式里的符号一致。这样做的好处是不依赖任何仪器成本为零。闭环调好后用串口把“目标转速、实际转速、PID 输出”三组数据打出来。悬空状态下给一个阶跃速度指令观察实际转速是否在 0.3 秒内稳定到目标值附近落地后再做一次同样的测试对比两种情况下的 PID 输出差异。串口打印我建议用二进制帧格式一次发 6 字节加校验上位机解析成曲线看比用文本输出直观得多。常见的问题是串口打印占用了主循环时间导致 PID 采样周期被拉长所以打印频率要跟 PID 频率分开比如 PID 每 10ms 执行一次串口每 200ms 发一次平均值。最后说一个我自己的习惯给电机做一次“占空比-转速”静态标定。把闭环暂时关掉手动输出 20%、40%、60%、80% 的占空比记录对应的编码器实测转速画出曲线。这组数据能告诉你电机的死区在哪个占空比附近也能在后续排查时快速判断是机械卡死还是电气问题。麦轮小车这东西机械装错一步代码写得再漂亮也白搭先让四个轮子在开环下老实听话再谈闭环和路径规划。希望这些经验对你有所帮助。本文还有配套的精品资源点击获取