ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32纯平衡小车控制链路拆解:互补滤波与串级PID调参实践

STM32纯平衡小车控制链路拆解:互补滤波与串级PID调参实践

简介:这份平衡小车程序(纯平衡)压缩包面向嵌入式初学者、课程设计者及平衡车爱好者,围绕STM32平台给出纯平衡控制的完整工程,帮助读者理解从传感器采集、姿态解算到PID闭环驱动电机的实现路径。压缩包共234个文件、约8.57MB,以h头文件、c源文件、o目标文件、crf交叉引用文件为主,另含Keil工程配置、hex烧录文件及少量说明与脚本,能较完整地保留编译与调试现场。已有2215人学习下载,具备一定参考热度,对想入门嵌入式控制的学习者较为友好。工程内含MPU6050/DMP相关驱动、定时器与ADC等外设配置、PID控制逻辑和电机输出模块,适合对照学习STM32标准外设库编程、陀螺仪与加速度计数据融合、参数整定及实时控制思路;也可作为课设、竞赛或自制平衡车的起步代码,便于在其结构上修改机械参数与控制策略。

1. 只保留平衡环的 STM32 小车:砍掉扩展功能后,反而更难调

很多人第一次拆开平衡车代码,动作都差不多:先找蓝牙在哪,再加块屏幕显示角度,顺手把循迹也点上,结果车连站都站不住。这份纯平衡的 STM32 工程反着来,扩展功能全砍,只留姿态解算、串级 PID 和电机输出三段,main 里几行初始化,剩下的全压在定时器中断里跑。它适合刚摸到嵌入式 PID 门槛、想把一个控制环从头到尾吃透的人;代码量不大,但每一行都挂在实时链路上,改错一个系数,车立刻倒给你看。也正因为砍得干净,变量少、现象直接,出了问题大概率就是你刚改的那一行。

2. 控制链路拆解:一个 5ms 周期里从陀螺仪读数跑到两路 PWM

平衡车的代码看着零散,实际上是一条很短的流水线:定时器每 5ms 打一次节拍,中断里读一次姿态、算三个 PID、写两路 PWM,然后退出。整条链路跑完必须远小于 5ms,因为下一拍很快就会来。把这条链拆开看,你会发现问题几乎不会出在“算法看不懂”上,而是出在各段的量纲、符号和时序对不对得上。

2.1 定时器节拍决定一切:为什么控制周期不能随便改

倒立摆这种被控对象的机械摆动频率通常只有一两赫兹,200Hz 的采样率完全够用,所以 5ms 是一个很舒服的默认值。往上压到 1ms 未必更好:六轴传感器走 I2C 读一次原始数据就要接近 1ms,一旦总线被别的外设占住,PID 这次就算不完,输出会晚一拍,现象是车莫名其妙地抽一下。往下放到 20ms,角度环的微分项就变得很粗,车会表现为反应迟钝、越晃越大。

/* TIM3 更新中断,周期 5ms,整车的实时逻辑都挂在这里 */ void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); g_tick_5ms++; /* 低频任务用的软计时,别在中断里做耗时的事 */ Balance_ControlLoop(); /* 整条控制链路,预算 200us 以内 */ } }

这段中断函数本身没什么技术含量,关键在于它承担了“时钟源”的角色。g_tick_5ms是个全局计数器,主循环里的串口上报、电池检测都靠它分频,这样主循环里加多少东西都不会拖慢控制节拍。我一般会把控制周期的宏单独抽出来:

#define CTRL_PERIOD_S 0.005f /* 秒,改周期时只改这一处 */

互补滤波、积分项、微分项都要用到这个数,如果各处硬编码0.005,某天你把定时器改成 4ms,姿态和 PID 会同时出现说不清的偏差,排查起来相当折磨人。

2.2 姿态解算:互补滤波把陀螺仪和加速度计拧成一股绳

陀螺仪积分出来的角度短期很准,但零漂会随时间累加,一分钟能漂好几度;加速度计长期看是准的,可车体一旦有振动,输出就抖得厉害。互补滤波做的事很简单:用陀螺仪负责高频,用加速度计负责低频,两条曲线加权拼起来。系数取 0.98 是嵌入式里最常见的经验值,对应大约 0.25 秒的时间常数,既能压住振动噪声,又不会让角度跟得太慢。

#define COMP_K 0.98f /* 互补系数,越大越信陀螺仪,越小越信加速度计 */ #define GYRO_SCALE 16.4f /* LSB/(deg/s),对应 ±2000dps 量程 */ float g_angle = 0.0f; /* 融合后的俯仰角,单位度 */ float g_gyro_bias = 0.0f; /* 上电静止标定出来的零漂 */ float Get_Pitch(void) { float acc_angle, gyro_rate; /* 加速度计反算角度:atan2 出来的单位是弧度,乘 57.29578 转成度 */ acc_angle = atan2f(acc_y, acc_z) * 57.29578f; /* 陀螺仪减去零漂,再换算成 deg/s */ gyro_rate = (gyro_y - g_gyro_bias) / GYRO_SCALE; /* 一阶互补滤波 */ g_angle = COMP_K * (g_angle + gyro_rate * CTRL_PERIOD_S) + (1.0f - COMP_K) * acc_angle; return g_angle; }

几个参数值得说明。GYRO_SCALE跟量程绑死,量程从 ±2000dps 改成 ±500dps 时这个值要跟着换成 65.5,忘了改的后果是角度变化速度被放大四倍,车会剧烈前后抽。atan2f(acc_y, acc_z)里的坐标顺序取决于传感器在车上的贴装方向,如果板子是竖着插的,两个参数要对调,符号也可能要取反。这一步最稳的验证方法是:把车慢慢往前倾,串口打印出来的角度应该单调变大,中途不跳变、不变号。

有人会问要不要上卡尔曼。纯平衡版本里两种实现都很常见,卡尔曼效果确实更平滑,但代码长、矩阵参数多,一旦现象不对,能查的变量反而更多。互补滤波只有一个COMP_K,配合静态零漂校准,对一辆只有两个轮子的小车完全够用,调车阶段我更愿意留这种“一眼能看出谁在捣乱”的实现。

2.3 串级 PID 的三层分工:直立环、速度环、转向环

这是整套代码里最容易写错的地方。很多新手把三个环的输出直接加起来送进电机,结果车会缓慢加速跑掉——因为速度环的输出语义搞错了。正确的结构是串级:直立环是内环,直接决定 PWM;速度环是外环,它的输出不是 PWM,而是叠加到直立环的目标角度上;转向环独立,只做左右轮差速。

#define ANGLE_MID 0.5f /* 机械中值:车真正立住时的角度,必须实测 */ #define ANGLE_KP 30.0f #define ANGLE_KD 0.8f #define SPEED_KP 1.2f #define SPEED_KI 0.02f #define TURN_KP 8.0f #define TURN_KD 0.3f PID_t pid_angle, pid_speed, pid_turn; float g_target_angle, g_pwm_base, g_pwm_turn; void Balance_ControlLoop(void) { float angle = Get_Pitch(); /* 当前倾角,度 */ float gyro_y = Get_GyroY(); /* 俯仰角速度,度/秒 */ float speed = (Encoder_SpeedL() + Encoder_SpeedR()) * 0.5f; /* mm/s,带符号 */ float turn = Get_GyroZ(); /* 绕 Z 轴角速度,度/秒 */ /* 外环:速度环。车往前跑就把目标角度往后压,让它自己减速 */ g_target_angle = ANGLE_MID + PID_Calc(&pid_speed, 0.0f, speed); /* 内环:直立环。目标角 - 实际角,PD 输出直接给 PWM */ g_pwm_base = PID_Calc(&pid_angle, g_target_angle, angle); /* 转向环:只产生差速,不参与前后平衡 */ g_pwm_turn = PID_Calc(&pid_turn, 0.0f, turn); Motor_Output(g_pwm_base + g_pwm_turn, g_pwm_base - g_pwm_turn); }

ANGLE_MID是整份代码里最需要动手实测的参数,它不是 0,而是车靠自身重力站住时那块板子的真实倾角,可能是 0.5 度,也可能是 -1.8 度,全看电池和电机怎么装。写错这个值,车会稳定地朝一个方向匀速跑掉,速度环的积分项要很久才能拉回来。

符号方向也得说清楚。以“正角度等于前倾”为例,车往前倾过头时angle > target,直立环误差为负,输出的 PWM 为负,这时候必须保证负 PWM 让轮子向前追。如果实测是往后退,说明电机方向反了,改接线或者改方向宏,不要用把ANGLE_KP写成负数的方式绕过去——那样转向环的差速方向会跟着一起错。

2.4 输出合成与死区补偿:PWM 最后怎么落到两个轮子上

算完的 PWM 是个浮点值,可正可负,直接丢给定时器比较寄存器之前还要过两道处理:限幅和死区补偿。直流减速电机在低占空比下克服不了静摩擦,占空比给 5% 它纹丝不动,但电流照样在跑,热量全堆在驱动芯片上。所以低于死区的非零值要么拉到死区,要么干脆给 0,别让它悬在中间。

#define PWM_MAX 7200 /* 定时器 ARR=7200,对应 100% 占空比 */ #define MOTOR_DEAD_ZONE 380 /* 实测出来的起步占空比,每台车不一样 */ static int16_t Constrain(int16_t v, int16_t lo, int16_t hi) { if (v > hi) return hi; if (v < lo) return lo; return v; } void Motor_Output(float left, float right) { int16_t pwm_l = Constrain((int16_t)left, -PWM_MAX, PWM_MAX); int16_t pwm_r = Constrain((int16_t)right, -PWM_MAX, PWM_MAX); /* 死区补偿:非零但要不动,直接抬到死区线上 */ if (pwm_l > 0 && pwm_l < MOTOR_DEAD_ZONE) pwm_l = MOTOR_DEAD_ZONE; if (pwm_l < 0 && pwm_l > -MOTOR_DEAD_ZONE) pwm_l = -MOTOR_DEAD_ZONE; if (pwm_r > 0 && pwm_r < MOTOR_DEAD_ZONE) pwm_r = MOTOR_DEAD_ZONE; if (pwm_r < 0 && pwm_r > -MOTOR_DEAD_ZONE) pwm_r = -MOTOR_DEAD_ZONE; /* 方向脚 + 占空比,两路 H 桥分别控制左右轮 */ if (pwm_l >= 0) { AIN1_SET(); AIN2_CLR(); TIM_SetCompare1(TIM4, pwm_l); } else { AIN1_CLR(); AIN2_SET(); TIM_SetCompare1(TIM4, -pwm_l); } if (pwm_r >= 0) { BIN1_SET(); BIN2_CLR(); TIM_SetCompare2(TIM4, pwm_r); } else { BIN1_CLR(); BIN2_SET(); TIM_SetCompare2(TIM4, -pwm_r); } }

MOTOR_DEAD_ZONE必须实测,方法是写个开环测试:占空比从 0 慢慢往上加,记下轮子刚开始转动的那个值,左右轮各测一遍,取大的那个当统一死区。左右电机的机械特性不会完全一样,差得多的时候可以给两边分别设死区,车走直线的能力会明显好一些。

3. 工程结构与核心代码:从 main 到中断,每层只干一件事

拿到一个 zip,先看目录比看代码有用。平衡车工程的分层逻辑其实非常固定:硬件驱动一层、控制算法一层、应用逻辑一层、工程文件一层。分得清楚的工程,改一个电机引脚只需要动一个宏;分得乱的工程,改引脚得全局搜索。

3.1 目录分层:一份能直接编译的 Keil 工程长什么样

目录放什么改动频率
User/main.c、中断服务函数、全局参数宏调参时天天改
Hardware/电机、编码器、传感器、按键的驱动与引脚宏换硬件时改
Control/互补滤波、PID_t结构体与PID_Calc算法升级时改
BSP/定时器、PWM、串口、I2C 的初始化基本不动
Project/Keil 工程文件、启动文件、分散加载基本不动

这份工程的价值在于Control/是独立的,里面没有任何一行代码去碰寄存器,输入是角度和速度,输出是 PWM 浮点值。这意味着你可以把整个Control/目录原样搬到另一个底盘上,只换硬件驱动层,参数重调一遍就能跑。

3.2 main.c 只做三件事:初始化、校准、开中断

main里不该出现任何控制逻辑。它只负责把外设喂饱、把零漂标定完、把中断打开,然后就进入一个以低频任务为主的死循环。

int main(void) { /* 1. 时钟与基础外设,顺序不能乱:先时钟再外设 */ SystemClock_Config(); /* 72MHz 主频,定时器和串口的波特率都依赖它 */ Delay_Init(); UART1_Init(115200); IMU_Init(); /* I2C 初始化 + 传感器量程配置 */ Encoder_Init(); Motor_Init(); /* PWM 定时器 + 方向脚 */ KEY_Init(); /* 2. 上电校准,车必须放平且静止,这两秒别碰桌子 */ IMU_Calibrate(); PID_Init(&pid_angle, ANGLE_KP, 0.0f, ANGLE_KD, PWM_MAX, 0.0f); PID_Init(&pid_speed, SPEED_KP, SPEED_KI, 0.0f, 20.0f, 300.0f); PID_Init(&pid_turn, TURN_KP, 0.0f, TURN_KD, 1500.0f, 0.0f); /* 3. 开控制节拍 */ TIM3_Init(5000); /* 5ms 更新中断 */ g_enable = 1; while (1) { /* 主循环只放非实时任务,20ms 一轮 */ if (g_tick_5ms % 4 == 0) { Battery_Update(); Debug_SendScope(); /* 波形上报,注意别用阻塞 printf 拖太久 */ } KEY_Scan(); /* 按键启停,出事了能一键停机 */ } }

三个PID_Init的最后一个参数是积分限幅,pid_speed给了 300,意思是角度补偿最多累加到 300 个单位,防止车被手按住时积分项一直涨、松手瞬间窜出去。这个值设多大要结合SPEED_KI一起看,ki乘的是积分累加和,所以ki通常只有 0.001~0.05 这个量级,看到有人写SPEED_KI = 5.0基本可以断定单位搞混了。

3.3 中断服务函数:所有实时逻辑都在这里

中断函数里做的事情越少越好,但该做的必须一次做完。顺序上先读传感器、再算姿态、再算 PID、最后输出,中间不能有任何阻塞调用。判断有没有阻塞最直接的办法是在函数开头拉高一个空闲 GPIO,结尾拉低,用示波器看这个引脚的高电平时间,超过 1ms 就得拆。

void Balance_ControlLoop(void) { static uint8_t fault_cnt = 0; /* 单次读取失败不要立刻停机,连续失败才认为传感器掉了 */ if (IMU_ReadRaw() != 0) { if (++fault_cnt > 20) { Motor_Stop(); g_enable = 0; } return; } fault_cnt = 0; /* 角度超过 35 度,说明已经扶不住了,直接断电保护电机和驱动 */ if (fabsf(Get_Pitch()) > 35.0f) { Motor_Stop(); g_enable = 0; return; } if (g_enable) Balance_ControlLoop(); else Motor_Stop(); }

这段保护逻辑在调车阶段特别有用。参数调错的时候车会疯狂输出,电机堵转几分钟就能把驱动芯片烧了,加一个角度阈值判断,车一倒就掐断输出,能省下不少返修时间。fault_cnt那种累计计数也不可少,I2C 偶发一次读失败很正常,直接停机反而会让车在路上莫名熄火。

3.4 PID 实现细节:位置式、积分限幅与微分先行

平衡车用位置式 PID 就够了,增量式在这个场景没什么优势。真正值得注意的是两处细节:积分限幅和微分先行。微分如果对误差求,目标角度被速度环一改,微分项就会跳出一个尖峰,车会“咯噔”一下;改成对测量值求微分,目标突变就不会冲击输出了。

typedef struct { float kp, ki, kd; float integral; /* 误差累加和 */ float last_measure; /* 上一次的测量值,用于微分先行 */ float out_max; /* 输出限幅 */ float integral_max; /* 积分限幅 */ float output; } PID_t; void PID_Init(PID_t *p, float kp, float ki, float kd, float out_max, float integral_max) { p->kp = kp; p->ki = ki; p->kd = kd; p->integral = 0.0f; p->last_measure = 0.0f; p->out_max = out_max; p->integral_max = integral_max; p->output = 0.0f; } float PID_Calc(PID_t *p, float target, float measure) { float err = target - measure; /* 积分累加 + 限幅,防止长时间卡死后的积分饱和 */ p->integral += err; if (p->integral > p->integral_max) p->integral = p->integral_max; if (p->integral < -p->integral_max) p->integral = -p->integral_max; /* 微分先行:对测量值求微分,前面带负号 */ p->output = p->kp * err + p->ki * p->integral - p->kd * (measure - p->last_measure); p->last_measure = measure; if (p->output > p->out_max) p->output = p->out_max; if (p->output < -p->out_max) p->output = -p->out_max; return p->output; }

ki * integral里没有乘采样周期,所以ki的物理含义是“每累加一个单位的误差贡献多少输出”,单位不直观,但也正因为如此,改控制周期时不用重调ki——这一点比标准形式省心。kd项用的是相邻两次测量值之差,同样没除周期,等效于把微分增益吸收进了系数里,调参时按数量级增减就行。

4. 让它站起来:编译配置、零漂校准与分环调参的完整顺序

代码能编译通过只是第一步,真正花时间的是让车稳住。这一步没有捷径,但有顺序:先保证机械和传感器是对的,再单独调直立环,稳了之后加速度环,最后才是转向。顺序乱了,你会同时面对三个变量,任何一个现象都推不出原因。

4.1 编译环境与工程配置:下载前值得检查的五项

用 Keil MDK 打开Project/下的工程文件,编译前把这几项过一遍,能省掉大量“编译过了但不跑”的时间。

  • 器件型号要和板子对上,选错型号最常见的结果是下载成功但外设全不工作。
  • 外部晶振频率宏要和板上实际晶振一致,8MHz 写成 12MHz,串口会出一堆乱码,定时器周期也会整体偏掉。
  • C99 模式要打开,工程里用了不少中途声明变量的写法,老标准会直接报错。
  • 下载方式选对,SWD 要比 JTAG 少占引脚,调试口被复用成 GPIO 是很常见的问题。
  • 优化等级调参阶段建议先用-O0,优化开高了单步调试会跳行,排查问题时容易怀疑人生。

下载之后先别急着让车跑,把车架起来让轮子悬空,串口打印角度和 PWM,用手转动车体看数值跟不跟得上。这一步能提前发现九成的接线和量程问题。

4.2 上电前的机械中值标定与陀螺仪零漂校准

零漂标定的原理很朴素:车静止的时候,陀螺仪输出的理论值应该是 0,实际读到的那个非零常数就是零漂。采 500 次求平均,5ms 一次大约 2.5 秒,够稳定了。

#define CALI_SAMPLES 500 /* 500 × 5ms ≈ 2.5s */ void IMU_Calibrate(void) { int32_t sum_gyro_y = 0; float sum_angle = 0.0f; for (int i = 0; i < CALI_SAMPLES; i++) { IMU_ReadRaw(); sum_gyro_y += raw_gyro_y; sum_angle += atan2f(acc_y, acc_z) * 57.29578f; Delay_ms(5); } g_gyro_bias = (float)sum_gyro_y / CALI_SAMPLES; /* 零漂 */ g_angle = sum_angle / CALI_SAMPLES; /* 静止初值 */ /* 把 g_angle 打到串口上,它就是 ANGLE_MID 的参考值 */ printf("bias=%.2f angle=%.2f\r\n", g_gyro_bias, g_angle); }

标定过程有两个硬性要求:车必须放平、必须静止,手不能搭在车上,桌子也不能晃。我有一个血泪经验是标定时旁边有人走动,地板轻微振动,标出来的零漂偏了 0.6,车能站但一直缓慢往一个方向漂,查了半天以为是机械中值的问题。

打印出来的angle就是ANGLE_MID的参考值,把它填进宏里重新编译。也有人图省事,让程序每次上电自动把当前角度当机械中值,这在地面绝对水平的环境里能用,但只要上电时车被放在有坡度的地方,程序就会记住那个歪掉的角度,然后一直往坡下追。

4.3 分环调试:先直立、再速度、最后转向

三个环一起开是调不出来的。把不参与调试的环的输出直接置零,逐个击破。

阶段开哪个环参数起点合格标准
一仅直立环ANGLE_KP=15,ANGLE_KD=0.3手扶起车后能自己站 3 秒以上,前后小幅摆动
二直立 + 速度环SPEED_KP=0.5,SPEED_KI=0.005用手轻推,车能回到原位,不持续加速跑掉
三三个环全开TURN_KP=5,TURN_KD=0.1给一个转向指令,车能原地转且不前后窜

第一阶段最容易出问题。ANGLE_KP从小往大加,加到车能明显感觉到“有人在扶”但开始高频抖动时,退回上一档,然后加ANGLE_KD压抖动。KD不是越大越好,超过某个点车会发出“嗡嗡”的高频声,那是微分项在放大传感器噪声,这时候要把KD减半。

第二阶段的速度环只调KP,先让车被推动后能慢慢回到原点,再补一点点KI消掉静差。SPEED_KI一上来就给大值,现象是车站在原地前后小幅来回“点头”,那是积分项跑过头了,直接砍掉一半再看。

4.4 用串口波形看数据:判断是参数问题还是硬件问题

光靠眼睛看车晃,很难区分是角度算错了还是参数不对。把关键变量以固定格式打到串口,用上位机画成曲线,三条线一起看,判断起来快很多。

/* 放在主循环里,20ms 一次;绝对不能塞进 5ms 中断,printf 是阻塞的 */ void Debug_SendScope(void) { printf("%.2f,%.2f,%.2f,%d\r\n", g_angle, /* 实际角度 */ g_target_angle, /* 速度环改写后的目标角度 */ g_pwm_base, /* 直立环输出的基础 PWM */ (int)Encoder_SpeedL()); }

看曲线有几个固定的判据。角度曲线如果本身就是毛刺,说明传感器读数或滤波有问题,先别调 PID;角度平滑但 PWM 一会儿顶到正限幅一会儿顶到负限幅,那是ANGLE_KP太大;目标角度曲线缓慢往一个方向爬,那是速度环积分在积累,车确实在单向漂。这套东西相当于给车装了个黑匣子,出问题回看几秒数据,比盯着车猜有用得多。

5. 避坑与排查:调平衡车时最容易翻车的五个现场

5.1 上电抖动、站不住、往一个方向跑

现象一:上电就高频抖动,轮子发出“哒哒”声,越抖越厉害直到倒下。原因基本是微分项在放大传感器噪声,或者陀螺仪零漂没标定就开环跑了。判断方法很简单,把车静置不动,串口看陀螺仪原始值,正常应该在 ±2 以内,超过 ±10 说明量程配置或者通信有问题。解决顺序是:先重做一遍静止校准,再把ANGLE_KD减半,最后检查COMP_K是不是被改小了,COMP_K越小加速度计的振动越容易进来。

现象二:车能站住,但一直往一个方向缓慢加速,直到撞墙。这是ANGLE_MID不准的典型表现,也可能是直立环本身存在静差。方法是用手扶着车让它刚好能自己站住,串口读那个角度,直接填进ANGLE_MID,每次微调 0.1 度。如果调完还是在漂,就把速度环的SPEED_KI加上去,用积分把这点静差吃掉。

现象三:手一推车,轮子朝反方向跑,车瞬间倒地。直立环符号反了。检验标准只有一条:车往前倒的时候,轮子必须往前追。反了就去改电机方向宏,别改KP的正负号,因为转向环的差速方向跟电机方向是绑在一起的,改错了一个,另一个必然跟着错。

5.2 电机发热、驱动发烫、加了外设就不稳

现象四:车能跑但电机和驱动芯片很快发烫,续航也明显变短。通常是死区补偿没做或者做得不对。占空比给 5% 时电机不转但电流一直流,热量全堆在 H 桥上。实测每台车的起步占空比,填进MOTOR_DEAD_ZONE,同时确认限幅PWM_MAX没有超过定时器的 ARR。还有一种可能是方向脚切换时没有留死区时间,同一路 H 桥上下管瞬间直通,这个只能靠示波器看两路驱动波形是否重叠。

现象五:加了屏幕显示或者无线模块后,车就开始抖,去掉就正常。典型的资源抢占。I2C 读屏是阻塞操作,一次刷屏几百微秒,正好卡在控制中断里,节拍就乱了。解决办法是把显示刷新降到 5Hz,并且只在主循环里刷;串口调试同理,printf一定要放在主循环,或者改成 DMA 发送。中断里只做读传感器、算 PID、写 PWM 这三件事,其他统统赶出去。

6. 二次开发:把纯平衡版本改成能远程调参、会自我保护的底盘

纯平衡版本跑稳之后,大部分人下一步都会想把参数整定做得舒服一点。因为调一次参数就编译下载一次,一天下来能把 Flash 擦写几十遍,效率极低。我一般会给它加一条极简的串口命令通道,收到特定字符串就改某个参数,改完立刻生效,这样调车时电脑放在旁边,边推车边改数。

/* 主循环里轮询,格式:P,角度环KP,850 -> 把 pid_angle.kp 改成 0.85 */ void Cmd_Parse(const char *line) { char name[4]; float value; if (sscanf(line, "%3[^,],%f", name, &value) != 2) return; if (name[0] == 'A' && name[1] == 'P') pid_angle.kp = value; /* AP */ else if (name[0] == 'A' && name[1] == 'D') pid_angle.kd = value; else if (name[0] == 'S' && name[1] == 'P') pid_speed.kp = value; else if (name[0] == 'S' && name[1] == 'I') pid_speed.ki = value / 1000.0f; else if (name[0] == 'M' && name[1] == 'D') g_angle_mid = value; /* 改完参数顺手清零积分,避免旧积分和新参数打架 */ pid_speed.integral = 0.0f; printf("OK %s=%.3f\r\n", name, value); }

这段代码的关键不在解析,而在最后那行清零积分。调参时把SPEED_KI调大,如果旧积分还留着,车会立刻窜一下,很容易误判成参数给大了。MID参数改完也建议停机重开一次,让姿态从新的中值重新收敛。

另一个值得加的是分级的保护策略。单个角度阈值太粗暴,车在正常颠簸时也会误触发;我一般分两档:超过 25 度先做输出限幅,把 PWM 压到一半,超过 35 度直接停机并锁死,必须按按键才能恢复。锁死这个设计是必须的,自动恢复的后果是车在地上疯狂抽搐,把轮胎和齿轮磨平。

#define ANGLE_WARN 25.0f #define ANGLE_STOP 35.0f void Safety_Check(float angle) { if (fabsf(angle) > ANGLE_STOP) { Motor_Stop(); g_enable = 0; /* 锁死,等按键复位 */ g_fault = FAULT_FALL; return; } if (fabsf(angle) > ANGLE_WARN) g_out_scale = 0.5f; /* 输出打折,先保命 */ else g_out_scale = 1.0f; }

再往后想扩展,比如让它按指令走直线、定距离停车,就轮到编码器里程闭环上场了。做法是在速度环外面再套一层位置环,把目标速度改成由位置误差算出来的值,SPEED_KP那一层完全不用动。这个结构的好处是位置环可以给得很慢,慢到几乎不影响平衡,调起来不会把已经稳定的直立环搞崩。

最后说个自己的习惯:每次改完参数,我一定先把车架起来空转十秒,看串口打印的角度和 PWM 是否在合理范围,再放到地上跑。从那以后,我每次烧录新固件都不会直接上手扶车试,而是先让轮子悬空跑一遍,确认没有满占空比输出、没有角度跳变,再落地。这套流程看着慢,实际上比摔坏一次电机省时间得多。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进