简介:一套基于STM32微控制器的两轮平衡小车完整代码工程,面向嵌入式初学者与进阶开发者,适合学习PID控制、传感器数据采集、PWM电机驱动等核心知识。资源共857个文件,压缩包约47.51MB,以C源码(.c/.h)、HAL库头文件、编译中间文件(.o/.d)、Keil工程配置(uvprojx/uvoptx)及CubeMX的ioc文件为主,另含txt说明文档与md笔记,可直接打开工程进行编译和二次开发。已有5960人学习下载。工程按功能模块划分,覆盖MPU6050姿态读取、L298N电机驱动、ADC采样、蓝牙通信等环节,并提供多份不同功能的可执行文件与HAL定时器库代码。通过研读源码可理解PID控制器如何根据倾斜角度误差计算PWM占空比,掌握比例、积分、微分项的整定方法,以及陀螺仪加速度计数据融合、串级反馈控制回路的完整实现流程,有助于快速搭建自己的平衡小车并完成参数调试,是一份可直接对照硬件进行学习的实战资料。
1. 平衡小车不是玄学:先从 PID 说起
做 STM32 平衡小车,最劝退的不是焊电路,也不是写底层驱动,而是车立起来之后疯狂抖、往一边飞、或者根本站不住。很多人第一反应是"我代码抄错了",实际上 90% 的问题出在 PID 参数上。这份代码资源把整个平衡小车的核心逻辑拆成了可复用的模块,从 MPU6050 读取、姿态解算到 PID 输出,每一步都有对应的函数和注释,不是把网上零散的代码拼在一起就完事。
适合谁?刚把单片机外设玩熟、想从"能亮灯"跨到"能闭环控制"的开发者,或者是课程设计选了平衡车、被 deadline 追着跑的学生。它解决的核心问题只有一个:让车站起来,并且站得稳。下面从原理到调参,按我实际调试过的顺序讲。
2. 传感器与姿态解算:MPU6050 的数据到底怎么变成角度
2.1 为什么选 MPU6050 而不是只读加速度计
平衡车需要知道车身倾角,但单独用加速度计有个致命问题——电机振动和车体加速时,加速度计输出的噪声会直接让角度数据跳来跳去。单独用陀螺仪呢?陀螺仪有零漂,积分一段时间角度就飘了。这就是 MPU6050 存在的意义:它同时包含加速度计和陀螺仪,用互补滤波或卡尔曼滤波把两者融合。
代码里用的是互补滤波,核心思路是:加速度计负责长期稳定,陀螺仪负责短期动态。公式长这样:
float angle = 0.98f * (angle + gyro_y * dt) + 0.02f * accel_angle;这个0.98和0.02就是权重系数,合起来等于 1。陀螺仪积分出来的角度在短时间可信,加速度计计算出的角度在长时间不漂,互补即取两者优点。
参数说明:dt是采样周期(单位秒),在定时器中断里固定,比如 5ms 一次。gyro_y是绕 Y 轴的角速度,单位要换算成度每秒,MPU6050 原始输出需要除以 16.4(量程 ±2000dps 时)。accel_angle用反正切算:atan2(accel_x, accel_z) * 57.3。
2.2 原始数据的处理顺序,顺序错了全白搭
读 MPU6050 不是直接调 I2C 读寄存器就行。代码里的读取顺序是:初始化 I2C → 配置电源管理寄存器(唤醒芯片)→ 设置陀螺仪量程 → 设置加速度计量程 → 设置数字低通滤波器(DLPF)。
DLPF 这个参数很多人忽略,但它直接影响融合效果。我一般把 DLPF 配成 20Hz 或 42Hz,把高频振动噪声滤掉。如果你发现角度数据毛刺特别多,先看 DLPF 配置,别急着改 PID。
// 配置MPU6050,量程:陀螺仪±2000dps,加速度计±16g void MPU6050_Init(void) { I2C_WriteReg(MPU6050_ADDR, PWR_MGMT_1, 0x00); // 解除休眠 I2C_WriteReg(MPU6050_ADDR, GYRO_CONFIG, 0x18); // 陀螺仪量程 ±2000dps I2C_WriteReg(MPU6050_ADDR, ACCEL_CONFIG, 0x18);// 加速度计量程 ±16g I2C_WriteReg(MPU6050_ADDR, DLPF_CONFIG, 0x06); // 低通滤波 5Hz }逻辑说明:顺序不能乱,特别是 PWR_MGMT_1,芯片上电默认是休眠态,不唤醒的话读出来的数据全是 0xFF。DLPF_CONFIG 的0x06对应截止频率约 5Hz,这个值因人而异,如果你车体重心高、响应慢,可以放宽到 0x03(44Hz)。
常见做法是把这套初始化封装成独立模块,和 PID 模块分开编译。这样后期换传感器型号,只改这一个文件就行。
2.3 角度计算的坑:零偏校准不能省
陀螺仪零漂是出厂就有的,每颗芯片还不一样。代码里做了上电校准:上电后静止 500ms,采集 100 次陀螺仪数据取平均,把这个平均值作为零偏值存起来,后续每次读取都减掉它。
// 上电校准陀螺仪零偏 void Gyro_Calibrate(void) { int32_t sum = 0; for (int i = 0; i < 100; i++) { sum += Read_Gyro_Y(); delay_ms(5); } gyro_offset = sum / 100; }跳过这一步的后果:车静止时角度积分会慢慢往一个方向飘,PID 会认为车在倾斜,于是电机持续输出,车就会朝一个方向加速冲出去,直到撞墙。你调 PID 调半天发现参数没问题,其实就是零偏没校准。
参数说明:校准时间 500ms 不是固定的,如果你板上电瞬间电源有波动,可以延长到 1s。采集次数越大,零偏值越接近真实,但上电等待时间也越长。
3. 电机驱动与速度环:为什么只调直立环车还是会跑
3.1 从 PWM 到转速:编码器的数据怎么闭环
平衡车只有直立环的话,车能站住,但会往一个方向缓慢加速,直到电机饱和。原因很简单:车身只要有一点点倾角,直立环就会输出一个让车轮加速的力矩,车为了追这个倾角会一直加速。这时候必须加速度环,让车的平均速度归零。
代码里速度环的输出叠加在直立环输出上,结构是串级 PID——内环是直立环,控制角度;外环是速度环,控制位移。
// 速度环 PID,输入是编码器测得的车轮速度 int speed_output = speed_pid(position_error); // 最终输出 = 直立环输出 + 速度环输出 int motor_output = balance_output + speed_output;逻辑说明:速度环的 P 项让车在倾角为零时保持静止,I 项消除静态误差,D 项一般用不上,因为速度信号本身已经有滤波。这个资源里的速度环就只用了 PI,我在实际调的时候也发现加 D 反而容易引入噪声。
3.2 编码器读取:定时器正交解码模式
STM32 的定时器可以配置成编码器模式,直接硬件解码,不占用 CPU 中断。代码里用的是 TIM2 的编码器模式,四倍频计数。
// 编码器模式配置 TIM_EncoderInterfaceConfig(TIM2, TIM_ENCODERMODE_TI12, TIM_IC_POLARITY_RISING, TIM_IC_POLARITY_RISING);参数说明:TIM_ENCODERMODE_TI12表示同时计数 A 相和 B 相的跳变沿,四倍频。如果你用两倍频甚至一倍频,分辨率会降低,低速时速度反馈会顿挫,做速度环会明显感觉不够顺滑。
另一个坑是编码器计数方向。两个电机安装方向不同,编码器计数的正方向可能反了。代码里用了带符号的 short 类型存储计数,如果发现速度环越来越偏,把编码器极性取反就行,不用改硬件。
3.3 电机死区:为什么给 5% 占空比电机不动
有刷电机的启动电压不是 0,PWM 占空比太小的时候电机根本不转,但电机会有嗡鸣声和发热。代码里做了一个死区补偿:当 PID 输出绝对值小于 threshold 时,直接把这个值归零或跳到最小有效占空比。
// 死区补偿 if (motor_output > 0 && motor_output < dead_zone) { motor_output = dead_zone; } else if (motor_output < 0 && motor_output > -dead_zone) { motor_output = -dead_zone; }死区值不是固定的,取决于你的电机和供电电压。常见做法是把死区设成能稳定转动的最小占空比。这个值可以通过开环测试得到:把车架空,手动给一个逐渐增大的 PWM,记录轮子刚转起来的值。
我做的时候用的驱动芯片是 TB6612,供电 7.4V 锂电池,死区大概在 12 左右(占空比约 5%)。如果你用的是 L298N,这个值会更大,因为 L298N 的压降高,而且不能支持高频 PWM——当然这是另一个坑,后面细说。
4. 串级 PID 的具体实现:直立环和速度环的代码长什么样
4.1 位置式 PD 控制直立环,增量式 PI 控制速度环
代码里直立环用的是位置式 PD,速度环是增量式 PI。为什么这样混搭?直立环需要的是绝对输出——输出和当前角度误差直接对应;速度环用增量式是因为它只关心误差的变化,不会因为累积误差导致输出饱和。
直立环的伪代码逻辑:
// 直立环 PD balance_output = Kp_balance * angle_error + Kd_balance * gyro_y; // 注意:这里直接用了陀螺仪角速度,不是角度的微分angel_error是目标角度(通常 0 度)和当前角度的差,gyro_y就是角速度,省去了对角度求微分这一步,噪声小得多。这是实际工程里常用的小技巧:能直接测的量就不要去算导数。
速度环的核心就三行:
// 增量式速度环 PI speed_error = target_speed - current_speed; speed_integral += speed_error * dt; speed_output = Kp_speed * speed_error + Ki_speed * speed_integral;target_speed是遥控或上位机给定的目标速度,默认 0。current_speed由编码器测出。速度环的输出加到直立环输出上,但要注意方向——如果速度环输出方向反了,车会原地振荡然后甩飞。
4.2 两个环的优先级和响应频率
内环(直立环)必须跑得快,我一般放在定时器中断里,频率 200Hz 到 500Hz。外环(速度环)可以慢一点,100Hz 就够。代码里是两个不同的定时器中断,速度环的标志位被直立环中断里检查。
// 定时器中断 ISR,频率 500Hz,每 5 次中断跑一次速度环 void TIMx_IRQHandler(void) { if (TIM_GetITStatus(TIMx) != RESET) { TIM_ClearITPendingBit(TIMx); Balance_PID(); // 直立环,每次中断都执行 if (speed_tick >= 5) { Speed_PID(); // 速度环,100Hz speed_tick = 0; } else { speed_tick++; } } }这个频率分配是有讲究的。直立环响应慢了,车容易产生高频抖动;速度环跑太快,编码器量化噪声会被放大,速度输出会不稳定。这个参数组合我试过很多次,200Hz 直立 + 50Hz 速度也能跑,但动态响应明显不如这个配置。
4.3 电机输出限幅与 PWM 方向处理
PID 算出来的输出不能直接给 PWM 寄存器,必须先限幅,再做方向控制。代码里限幅值一般是 PWM 周期的一半,比如你 PWM 频率 10kHz、ARR 设 720,那输出限幅在 -360 到 360 之间。
// 输出限幅和方向控制 if (motor_output > PWM_LIMIT) motor_output = PWM_LIMIT; if (motor_output < -PWM_LIMIT) motor_output = -PWM_LIMIT; if (motor_output >= 0) { GPIO_SetBits(GPIOA, GPIO_Pin_1); // 方向正 TIM_SetCompare1(TIM1, motor_output); } else { GPIO_ResetBits(GPIOA, GPIO_Pin_1); // 方向反 TIM_SetCompare1(TIM1, -motor_output); }逻辑说明:-motor_output是为了把负数取绝对值给比较寄存器,方向由 GPIO 控制。这里有个容易翻车的细节——限幅必须放在方向判断之前,不然限幅后的正负号丢失,电机方向会混乱。
5. 避坑与常见问题:我调车时翻过的五个车
5.1 现象:车一上电就往前冲,完全不受控
原因分析:陀螺仪零偏没有校准,或者校准的时候车不是在静止状态。有些人把车拿在手里上电,手在抖,校准出来的零偏就是歪的。
解决方案:强制在代码里加一个上电等待 LED 提示,LED 亮了再校准,校准期间车身必须放在地面上。我后来还加了一个 2 秒倒计时,确保不会误触。
5.2 现象:车直立后高频抖动,声音像电锯
原因分析:直立环 Kd 项太大。Kd 的作用是阻尼,给大了会让系统对噪声特别敏感,尤其是陀螺仪角速度没有滤波的时候。
解决方案:先把 Kd 砍到原来的一半,看抖动是否消失,再重新往上加。如果 Kd 已经很小还抖,检查 DLPF 配置,把截止频率调低。
5.3 现象:车能站住,但会缓慢跑偏,最后撞墙
原因分析:速度环的 P 项太小或 I 项不够。直立环保持住了角度,但速度环没能把位移拉回来。
解决方案:先加速度环的 P,观察车是否会有来回走动的趋势,如果有就加 I。注意 I 项的积分要限幅,不然每次开机都有一段积分饱和,车会朝一个方向猛跑。
5.4 现象:PWM 输出正常,但电机不动或只能单方向转
原因分析:PWM 和 GPIO 的初始化顺序错了。先初始化 GPIO,再配置定时器复用功能,最后开启定时器。如果 GPIO 的复用功能没配上,引脚输出不了 PWM。
解决方案:检查 GPIO 配置的 AFIO 映射,特别要注意 STM32 部分定时器的通道需要重映射到别的引脚。另外看看驱动芯片的 IN1/IN2 逻辑——TB6612 在输入全是低电平时是刹车状态,不是停止。
5.5 现象:跑一会儿后车突然失去平衡,复位重启
原因分析:电源跌落。电机急停或大电流瞬间,锂电池电压被拉低,STM32 欠压复位。代码里如果开了看门狗,也会因为主循环卡死而复位。
解决方案:在电源输入端加大电容(我用了两个 470uF 电解电容并联),同时在代码里把看门狗关闭或延长喂狗时间。我最初开着 IWDG 调 PID,车一抖就喂狗超时,后来把看门狗关了才消停。
6. 最后一步:用上位机看波形,把 PID 参数一次调透
盲调 PID 是最低效的,尤其在平衡车上。代码资源里带了一个简单的串口协议,把角度、角速度、直立环输出、速度环输出这些变量通过串口发到上位机,用匿名上位机或者 Vofa 看波形。
我一般调参的流程是这样的:先把速度和直立环的 P、I、D 全部清零,只加直立环的 P,从 100 开始往上加,加到车开始有恢复力矩的感觉。然后加 Kd,让车不抖。直立环稳定后,再加速度环的 P 和 I。整个流程下来,正常半小时能把参数调到一个稳定区间。
Vofa 的配置很简单,串口波特率 115200,数据格式用 float。代码里加一行就搞定:
printf("angle:%f\r\n", angle);要注意的是,STM32 用 printf 重定向到串口,需要把_write或fputc重定向一下。很多人卡在这里,信号死活出不来,其实就是没重定向。常见做法是:
// 重定向 printf 到串口 int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); return ch; }参数说明:USART_FLAG_TXE表示发送数据寄存器空,这个等待保证每个字符发完才发下一个。如果上位机显示的数据全是 0,先检查波特率是否匹配,再检查fputc是否被 main 函数里的stdio.h正确链接。
验证 PID 参数是否调好的标准动作是:手扶着车,让它微微前倾然后松手。如果车能在一个来回内恢复平衡,参数就是合格的。如果来回两次以上才稳,说明阻尼不够,加 Kd。如果车在前倾瞬间猛加速,说明 P 大了。
那次调完,我总结了一个习惯:每次上电必须先看陀螺仪零偏值,小于 0.5 才继续调参,不然全部白调。从那以后我每次调平衡车都强制走一遍这个流程:校准 → 看波形 → 调直立环 → 调速度环 → 下地实测。希望帮到你。
本文还有配套的精品资源,点击获取