STM32纯平衡小车:5ms控制链路与串级PID调参避坑 简介这份资源是面向STM32初学者与嵌入式控制入门者的自平衡小车完整工程包聚焦纯平衡控制这一核心目标可用于学习姿态解算与闭环控制的实际落地。压缩包共234个文件、约8.57MB以41个c源文件与47个h头文件为主体涵盖STM32外设驱动、MPU6050数据读取与DMP解算、定时器与串口配置配套的uvprojx、uvoptx工程文件与axf、hex、map等编译产物可直接用Keil打开烧录验证另附sct分散加载文件与批处理脚本便于清理与构建。已有2215人学习下载说明该工程在同类入门项目中具有一定参考基础。读者可从中获取传感器数据解析、PID参数整定、电机驱动与调试排错的完整思路并借助现成工程结构快速搭建实验环境对照源码理解比例、积分、微分环节对平衡效果的实际影响适合作为课程设计或自学练手的参考工程。1. 纯平衡小车到底在跑什么先看懂这条 5ms 控制链路平衡小车程序纯平衡这类压缩包真正值钱的地方不是那几个 PID 系数而是它把 STM32 上一整套控制链路切得很干净姿态采样、角度融合、串级 PID、电机输出全部挂在同一个定时中断里周期固定。很多新手拿到手先改 kp车一倒就开始怀疑代码其实问题往往出在链路没跑通比如角度方向反了、编码器读数没清零、控制周期抖动。纯平衡版本的好处是它砍掉了循迹、避障、遥控这些干扰项只剩“站住”这一件事对想搞懂嵌入式闭环控制的人来说是块很干净的地板。这份代码适合手上已经有 F103 开发板、电机驱动和一块六轴模块想从零把车调站起来的人也适合已经能跑 Demo、但说不清串级 PID 到底谁套谁的人。2. 工程骨架拆解目录、外设与中断的时间片怎么分拿到源码包先别急着打开 main.c先看目录。这类工程一般由 CubeMX 生成 HAL 库骨架后面的业务代码被拆进几个独立模块彼此只通过头文件里的函数原型打交道。把目录读一遍你大概就知道作者把复杂度放在了哪。2.1 目录结构与模块职责典型的目录长这样不同人做法略有出入但分层思路基本一致BalanceCar/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── pid.h # PID 结构体与通用计算 │ │ ├── attitude.h # 姿态MPU6050 读写 互补滤波 │ │ ├── motor.h # 电机 PWM 与方向控制 │ │ ├── encoder.h # 编码器测速 │ │ ├── oled.h # 本地显示可选 │ │ └── debug.h # 串口调试输出 │ └── Src/ │ ├── main.c # 初始化 主循环不放控制逻辑 │ ├── pid.c │ ├── attitude.c │ ├── motor.c │ ├── encoder.c │ ├── oled.c │ ├── debug.c │ └── stm32f1xx_it.c # 中断入口只做转发 ├── Drivers/ # HAL 库 CMSIS └── MDK-ARM/ # Keil 工程文件如果包里的 Drivers 目录下出现的是STM32F10x_FWLib说明这是标准库版本模块划分不变只是初始化 API 从HAL_xxx_Init换成了xxx_Init。两种版本的业务代码几乎可以直接对拷真正要改的只有外设初始化和中断回调的写法。main.c 在这套结构里应该很薄时钟、GPIO、定时器、串口、I2C 初始化完启动定时中断然后进 while(1) 做慢任务显示刷新、串口打印、按键扫描。所有跟“车会不会倒”相关的计算一律不放在主循环。这个边界一旦破了后面调参基本没法调——主循环里加一句 printf控制周期就从 5ms 变成 6msPID 参数全部要重来。我带过的新手最常见的翻车点就是在主循环里塞了一堆东西然后奇怪为什么加个屏幕车就抖。2.2 外设分配谁在什么时候干活一张表把外设职责列清楚后面查问题会快很多。引脚按工程实际为准下面是一组常见映射功能外设常见引脚配置要点姿态传感器I2C1PB6 / PB7400kHz上拉 4.7k左电机 PWMTIM1_CH1PA810~20kHz避免啸叫右电机 PWMTIM1_CH2PA9同上电机方向GPIOPB12~PB15两个一组控制正反转左编码器TIM3PA6 / PA7编码器模式四倍频右编码器TIM4PB6 / PB7若与 I2C 冲突就换脚或改软件 I2C控制周期TIM6无引脚5ms 更新中断调试串口USART1PA9 / PA10115200注意别和 PWM 抢脚PWM 频率这一项经常被忽略。用 1kHz 驱动电机人耳能听到明显啸叫电流纹波也大拉到 15~20kHz 就听不见了但驱动芯片的开关损耗会上去TB6612 这类模块一般扛得住。频率改完记得同步改预分频和自动重装值否则占空比分辨率会掉到没法做细调。2.3 控制周期那条 5ms 中断整套代码的心脏是这个回调所有实时计算都在里面/* TIM6 更新中断5ms 触发一次对应 200Hz 控制频率 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { static uint8_t slow_tick 0; if (htim-Instance ! TIM6) return; /* 1. 采姿态滤波系数与 dt 强相关dt 变了这里必须跟着改 */ attitude_update(g_angle, CTRL_DT); /* 2. 读编码器增量读到就清零供下个周期重新累计 */ g_enc_l encoder_read_delta(htim3); g_enc_r encoder_read_delta(htim4); /* 3. 串级 PID先算速度环再算直立环和转向环 */ balance_control(g_angle.pitch, g_angle.gyro_y, g_enc_l g_enc_r, CTRL_DT); /* 4. 输出到电机 */ motor_set(g_pwm_l, g_pwm_r); /* 5. 慢任务分频10ms 打印一次绝不在 5ms 里做阻塞 IO */ if (slow_tick 2) { slow_tick 0; debug_output(); } }CTRL_DT就是 0.005f定义成宏而不是到处写 0.005是为了以后想改周期时只改一处。中断里只做计算和寄存器写入不做任何等待printf 靠分频挪到 10ms 一次而且用的串口发送要配 DMA 或至少是非阻塞的写法——半双工阻塞发送一次 40 字节在 115200 下大约 3.5ms5ms 周期里塞这个控制就废了。编码器读数这里有个小技巧值得单独说因为很多人第一次写会踩/* 读增量并清零5ms 内计数不会超过 int16 范围直接强转即可 */ static int16_t encoder_read_delta(TIM_HandleTypeDef *htim) { int16_t cnt (int16_t)__HAL_TIM_GET_COUNTER(htim); __HAL_TIM_SET_COUNTER(htim, 0); return cnt; }计数器是 16 位的转成有符号数就能同时表达正反转。清零的好处是每个周期拿到的是纯增量不用自己维护上一次的值做减法也不会因为累计溢出算错。前提是周期足够短5ms 内电机转速对应的脉冲数远小于 32767。如果控制周期拉到 50ms高速时就会溢出翻负表现为速度环突然反向修正车猛地一顿——这种偶发问题最难查。3. 姿态解算链路MPU6050 读数、互补滤波和开机零漂校准车能不能站住七成看角度准不准。角度不准PID 参数调到天上去也是白搭。这条链路分三段把原始寄存器读成物理量、把加速度和陀螺仪融合成一个角度、开机时把零漂扣掉。3.1 初始化与原始数据读取初始化要关注三个寄存器电源管理、采样率分频、低通滤波。采样率和滤波带宽必须跟 200Hz 的控制周期匹配配太高会把噪声全引进来配太低相位滞后严重直立环会明显发软。#define MPU_ADDR 0xD0 #define REG_SMPLRT_DIV 0x19 #define REG_CONFIG 0x1A #define REG_GYRO_CFG 0x1B #define REG_ACCEL_CFG 0x1C #define REG_ACCEL_XOUT 0x3B #define REG_PWR_MGMT_1 0x6B void mpu_init(void) { mpu_write(REG_PWR_MGMT_1, 0x00); /* 解除休眠 */ HAL_Delay(50); /* 上电稳定别省这一步 */ mpu_write(REG_SMPLRT_DIV, 0x04); /* 1kHz / (14) 200Hz 输出率 */ mpu_write(REG_CONFIG, 0x03); /* DLPF 约 42Hz压住电机振动噪声 */ mpu_write(REG_GYRO_CFG, 0x08); /* 陀螺 ±500 dps灵敏度 65.5 LSB/dps */ mpu_write(REG_ACCEL_CFG, 0x00); /* 加速度 ±2g灵敏度 16384 LSB/g */ }量程选择是个权衡。陀螺选 ±500dps 而不是 ±2000dps是因为小车倒下瞬间角速度也就两三百度每秒量程小一位分辨率就高一位静态噪声明显小。但如果你的车特别高、倒得特别快±500 会削顶表现为倒到某个角度后数据不动了。读原始值一次连续读 14 个字节比逐个寄存器读快得多也避免两次读取之间数据不同步typedef struct { int16_t ax, ay, az; int16_t gx, gy, gz; } mpu_raw_t; void mpu_read_raw(mpu_raw_t *r) { uint8_t buf[14]; mpu_read(REG_ACCEL_XOUT, buf, 14); /* 一次读满保证同一采样点 */ r-ax (int16_t)((buf[0] 8) | buf[1]); r-ay (int16_t)((buf[2] 8) | buf[3]); r-az (int16_t)((buf[4] 8) | buf[5]); r-gx (int16_t)((buf[8] 8) | buf[9]); /* 6、7 是温度跳过 */ r-gy (int16_t)((buf[10] 8) | buf[11]); r-gz (int16_t)((buf[12] 8) | buf[13]); }换成物理量时除以对应灵敏度就行浮点运算在 F103 上是软件模拟的能少算就少算。常见做法是加速度只在算角度时用一次陀螺仪在滤波里用一次别在每个环节反复换算。3.2 互补滤波为什么小车很少上卡尔曼加速度计算角度用反正切长期稳定但噪声大陀螺仪积分短期准但会漂。互补滤波干的事就是给两者分配权重高通给陀螺、低通给加速度一行代码搞定#define ALPHA 0.98f #define RAD2DEG 57.29578f void attitude_update(attitude_t *a, float dt) { mpu_raw_t raw; mpu_read_raw(raw); float ax raw.ax / 16384.0f; float ay raw.ay / 16384.0f; float az raw.az / 16384.0f; float gyro_y (raw.gy - a-gyro_bias_y) / 65.5f; /* 扣零漂后转 dps */ /* 用 atan2 而不是 atan(ax/az)不会除零也不用管象限 */ float acc_pitch atan2f(ay, az) * RAD2DEG; /* 核心一行陀螺积分占 98%加速度校正占 2% */ a-pitch ALPHA * (a-pitch gyro_y * dt) (1.0f - ALPHA) * acc_pitch; a-gyro_y gyro_y; }ALPHA是这套代码里最玄学的一个参数。取 0.98时间常数大约 0.25 秒跟得上车的动作又能压住振动取 0.999角度很平滑但会明显滞后直立环表现为“反应慢半拍”车总是往倒的方向多走一步才修正取 0.9加速度噪声直接灌进来电机发出沙沙声。我一般从 0.98 起调如果车在原地高频小幅抖动先往 0.99 调一点看看如果反而更晃说明是机械共振不是滤波问题。至于卡尔曼网上教程一片一片但在这类小车上收益有限。F103 没有硬件浮点五阶矩阵运算一次几百微秒200Hz 下 CPU 占用不低而且卡尔曼的过程噪声、观测噪声两个协方差矩阵实际调起来比 ALPHA 一个参数难得多。除非你的车要上坡、要扛冲击否则互补滤波够用。这是个典型的“教程最优”和“工程够用”之间的差别。3.3 开机校准把零漂扣干净陀螺仪静止时输出不为零这是物理特性不是坏块。零漂不扣积分出来的角度会匀速漂移车会一直以为自己在往一个方向倒于是持续给反向修正表现为上电后慢慢往一边跑。/* 开机静置 1 秒采样 500 次求均值作为零漂 */ void mpu_calibrate(attitude_t *a) { int32_t sx 0, sy 0, sz 0; mpu_raw_t raw; for (uint16_t i 0; i 500; i) { mpu_read_raw(raw); sx raw.gx; sy raw.gy; sz raw.gz; HAL_Delay(2); } a-gyro_bias_x (float)sx / 500.0f; a-gyro_bias_y (float)sy / 500.0f; a-gyro_bias_z (float)sz / 500.0f; }调用这个函数期间小车必须完全静止放在桌上电机不能通电。校准完再启动控制中断顺序反了会把电机振动一起算进零漂里那这个校准就白做了。另外零漂随温度变化冷机开机和跑十分钟后能差出零点几度每秒长跑场景下有人会做慢速跟踪补偿车静止判断成立时缓慢把零漂往当前陀螺读数上拉用时间换精度。这套纯平衡代码里不一定带属于可以自己加的改造点。4. 串级 PID 实现直立环、速度环、转向环的代码与参数这是整个项目最核心的一段。很多人卡在“三个环怎么套”其实理顺一句话就通了直立环负责让车不倒速度环不直接驱动电机而是去改直立环的目标角度转向环最后叠加到左右轮差速上。4.1 通用 PID 结构体与计算函数先把三个环共用的部分抽出来避免复制三份几乎一样的代码typedef struct { float kp, ki, kd; float target; float integral; /* 积分累计项 */ float last_err; float out_max; /* 输出限幅 */ float integral_max; /* 积分限幅防止饱和 */ } pid_t; float pid_calc(pid_t *p, float measure, float dt) { float err p-target - measure; p-integral err * dt; if (p-integral p-integral_max) p-integral p-integral_max; if (p-integral -p-integral_max) p-integral -p-integral_max; float out p-kp * err p-ki * p-integral p-kd * (err - p-last_err) / dt; p-last_err err; if (out p-out_max) out p-out_max; if (out -p-out_max) out -p-out_max; return out; }积分限幅和输出限幅这两行看着不起眼实际上是血泪经验。速度环积分不限车被手挡住两秒积分项能攒到几千一松手车直接蹿出去这就是典型的积分饱和。integral_max一般取out_max / ki的量级再留点余量具体数值配合实际调参定。4.2 直立环PD 控制与机械中值直立环只用 PD不用 I。因为它要的是快速响应积分项在这里只会拖慢节奏、引入过冲。float balance_pid(float angle, float gyro_y) { /* 目标是机械中值不是 0 度。车重心偏前偏后都要靠这个补偿 */ float err angle - g_mech_zero; /* P 拉回角度D 用陀螺仪而不是对角度差分 */ float out g_balance.kp * err - g_balance.kd * gyro_y; return out; }D 项直接拿陀螺仪读数这一点值得展开。如果用(err - last_err) / dt对角度做数值微分角度本身的噪声会被放大十几倍电机听着像在打嗝。陀螺仪输出的本来就是角速度物理意义上正好对应微分项还自带低通噪声小得多。代价是符号要自己确认车往前倾时角度增大、陀螺读数方向可能相反第一次调的时候把 D 的符号搞反车会瞬间变成正反馈一上电就全速冲出去非常危险。g_mech_zero是机械中值也叫平衡点。它不是 0 度因为电池、驱动板、传感器的重量分布决定了重心不在几何中心。判断方法手扶车让它静止在刚好不往任何方向倒的姿态读此时的角度值写进这个变量。这个值调不准车会一直有个恒定的前后偏移速度环再怎么调也只能把它压成缓慢蠕动。4.3 速度环PI 串进直立环的中值速度环的输出不去驱动电机而是叠加到机械中值上。这是串级的精髓所在void balance_control(float angle, float gyro_y, int32_t enc_sum, float dt) { /* 编码器原始计数转换成 cm/s系数靠实测标定 */ float speed enc_sum * ENC_TO_SPEED; /* 一阶低通滤掉编码器量化噪声系数 0.3 左右 */ static float speed_f 0; speed_f speed_f * 0.7f speed * 0.3f; /* 第一级速度环 PI目标是零速 */ g_speed.target 0.0f; float speed_out pid_calc(g_speed, speed_f, dt); /* 速度环输出改变直立环的目标角度而不是直接给 PWM */ float target_angle g_mech_zero speed_out; /* 第二级直立环 PD */ float err angle - target_angle; float balance_out g_balance.kp * err - g_balance.kd * gyro_y; /* 转向环单独算纯平衡版本一般只给 0留接口 */ float turn_out turn_pid(); /* 合成到左右轮转向是差速叠加 */ int16_t pwm_l (int16_t)(balance_out turn_out); int16_t pwm_r (int16_t)(balance_out - turn_out); g_pwm_l pwm_limit(pwm_l); g_pwm_r pwm_limit(pwm_r); }为什么不能速度环直接给 PWM因为直立车是欠驱动系统唯一的控制量是轮子加速度而轮子加速度同时影响倾角和位移。速度环直接输出会跟直立环抢控制权两个环互相打架表现为车在原地前后低频摆动怎么调参数都压不住。串起来之后速度环只管“我要往哪边倾一点才能回到原点”直立环只管“怎么快速倾到那个角度”职责清晰参数也好调。速度环一般只给 PI不给 D。编码器信号本来就有量化噪声再微分会炸。P 决定回到原点的力度I 消除稳态误差——只有 P 的话车会停在一个偏离原点的位置不动因为速度为零时 P 项输出为零没有力量把它拉回去。4.4 输出限幅与死区最后一道关卡是限幅和死区这两个参数直接决定车是“稳”还是“抖”static int16_t pwm_limit(int16_t v) { /* 死区占空比太小电机转不动反而发热直接归零 */ if (v 0 v MOTOR_DEADZONE) return 0; if (v 0 v -MOTOR_DEADZONE) return 0; if (v PWM_MAX) return PWM_MAX; if (v -PWM_MAX) return -PWM_MAX; return v; }MOTOR_DEADZONE是电机启动的最小占空比减速电机带齿轮箱一般要 5%~10% 才转得动。不加死区PID 在零点附近输出的几十个 LSB 占空比推不动车只在电机线圈里发热还会让积分项持续累积。加了死区零点附近变成开关式的输出反而更干净。PWM_MAX也别设成满量程。留 20% 余量一是防止瞬间大电流拉垮电池电压导致单片机复位二是给转向差速留空间——如果balance_out已经顶到最大值再加减turn_out就会削顶转向直接失效。5. 避坑排查上电就倒、高频抖动、慢漂的 5 条现场经验代码看懂了不等于车能站起来。下面这几条是调车过程中几乎必然会撞上的按“现象、原因、解决”的顺序排好对号入座能省不少时间。5.1 现象一上电就朝一个方向全速冲扶都扶不住原因基本只有一个——某个环节的符号反了。可能是电机相线接反、编码器 A/B 相接反、陀螺仪方向与安装方向不一致也可能是直立环 D 项符号写反。这几个错误的表现都是正反馈车越倒输出越往倒的方向推。解决方法是上电前逐项验证不要靠猜。第一步架空车轮用手把车头往下压看轮子是不是往车头方向转追着倒的方向跑才对第二步转动车轮看串口打印的速度符号跟实际方向是否一致第三步把车倾斜一个固定角度看打印的角度符号。三项全部对上再落地。从那以后我每次重新接线都会走一遍这个流程比事后排查快十倍。5.2 现象车能站住但原地高频抖动电机发出“嗡嗡”或“哒哒”声这个要分两种。嗡嗡声一般是滤波或 D 参数问题哒哒声更像机械间隙或者控制周期不稳。常见诱因有三个D 参数给太大把传感器噪声放大成了抖动ALPHA 取值偏小加速度噪声漏进角度里MPU6050 没有做减振电机振动直接传上来。处理顺序是先减振再调参。在传感器和车架之间垫一层厚双面胶或者减振棉效果立竿见影很多“调不出来的抖动”其实换个安装方式就没了。然后再把 kd 往小调 20%看抖动频率有没有下降。如果调小 kd 后车变得软塌塌站不稳说明抖动不是 D 引起的回头查 ALPHA 和 PWM 频率。5.3 现象车静止放着角度值以每分钟几度的速度缓慢漂移零漂没扣干净或者校准的时候车没放稳。还有一种容易被忽略校准期间电机驱动虽然没输出但使能脚是开的线圈有微弱电流磁场干扰传感器。解决方法是把校准函数挪到电机初始化之前调用并且在校准前加 300~500ms 的延时等芯片内部稳定。校准完把零漂值通过串口打出来正常情况下三轴都应该在几十 LSB 以内如果某一轴到了几百说明校准环境有问题桌子在晃、旁边有音箱或磁铁。跑久了才漂的属于温漂可以加一个静止检测连续若干周期角度变化小于阈值且编码器为零就慢速更新零漂时间常数给到几十秒不影响动态响应。5.4 现象车能站但总是匀速往一个方向慢慢跑越跑越快先看机械中值。手扶车找到真正静止平衡的角度跟代码里的g_mech_zero对一下差个一两度很正常差五度以上就要重新标定。标定不准速度环会持续输出一个偏置去补偿补偿量一大就容易触发积分饱和。如果中值没问题检查速度环的积分限幅是不是设得太大或者编码器方向接反导致速度环正反馈——这个隐蔽性很强车会表现为慢慢加速然后突然失控。用串口把速度值打出来手推车往前看数值是不是跟预期方向一致一眼就能看出来。5.5 现象一开串口打印车立刻开始抖控制周期被破坏了。115200 波特率下发 40 字节大约 3.5ms如果放在 5ms 中断里做阻塞发送实际周期就变成 8ms 以上而且抖动不定。PID 对周期抖动极其敏感dt在公式里是除数周期一变等效的 D 项增益就跟着变。解决办法有三层把打印挪出中断用环形缓冲区加 DMA 发送降低打印频率到 50ms 甚至 100ms 一次只打关键变量别一次打十几个浮点。我一般调试期只打角度、速度、PWM 三个量够看趋势了。真要精细分析把数据按二进制帧发出去上位机解析比文本协议省一半带宽。6. 二次开发调参顺序、串口协议与验证手法这套纯平衡代码的扩展性其实很好因为它把控制律和硬件访问分开了。想加功能优先在balance_control外面套而不是改里面的公式。我自己改这类工程有个固定顺序先保证原始版本能站住再加新东西每加一样就回归测试一次。调参顺序千万别乱。正确顺序是机械中值 → 直立环 P → 直立环 D → 速度环 P → 速度环 I → 转向环。跳过中值直接调 P你会发现无论怎么调车都有个固定偏移先调速度环再调直立环车根本站不住没有调参的物理基础。参数调大后的表现调小后的表现建议步长直立环 kp回正快过大则低频摆动站不住软塌塌每次 10%直立环 kd抑制摆动过大则高频抖动有过冲来回晃每次 15%速度环 kp回原点快过大则前后振荡漂移纠正慢每次 20%速度环 ki消除稳态误差过大则蹿车停不到原点每次 20%ALPHA更平滑但滞后噪声大、抖0.005 一档加串口调参是最值得先做的一件事。不用改代码重新烧录调参效率完全不同/* 帧格式0xAA 0x55 | 参数ID | float 四字节 | 校验和 */ void param_parse(uint8_t *buf, uint8_t len) { if (len ! 8 || buf[0] ! 0xAA || buf[1] ! 0x55) return; uint8_t sum 0; for (uint8_t i 0; i 7; i) sum buf[i]; if (sum ! buf[7]) return; /* 校验不过直接丢别信坏帧 */ float val; memcpy(val, buf[3], 4); /* 用 memcpy 避免对齐问题 */ switch (buf[2]) { case 1: g_balance.kp val; break; case 2: g_balance.kd val; break; case 3: g_speed.kp val; break; case 4: g_mech_zero val; break; default: break; } }再加一条命令让参数能存进 Flash下次上电自动加载就不用来回调。注意写 Flash 会阻塞几十毫秒一定要在车停稳、电机断使能的状态下执行运行中写 Flash 会导致控制中断丢失车直接趴下。验证改动是否有效别只看“感觉稳了”。我的做法是给一个固定扰动用手在同一位置、同一力度推车头看它回到平衡位置需要几次摆动。好的参数一般一到两次就收住三次以上说明阻尼不够零次但回得很慢说明 P 偏小。把这个过程录下来对比比凭记忆靠谱得多。最后说个习惯。从那以后我每次改完参数、重新烧录之前都会先把车轮架空再上电确认三个方向的动作都符合预期才落地。这个动作看着多余但它拦下过好几次符号写反导致的全速冲车。希望帮到你。本文还有配套的精品资源点击获取