STM32平衡车从标准库迁移到HAL库:核心移植与调参实战 做平衡车这件事很多人的第一块板子都是STM32F103C8T6开发环境从标准库到HAL库都摸过一遍。这次我折腾的项目就是基于B站铁头山羊那套标准库版平衡车思路把整套代码移植到HAL库上重写。说实话标准库那套代码写得很清晰裸机逻辑也很好懂但HAL库在工程复用、外设初始化配置和跨平台迁移上的优势实在太明显尤其当你后面想换芯片型号、加蓝牙/OLED/无线模块时CubeMX生成的工程骨架能省掉大量重复劳动。这篇就把我移植过程中踩过的坑、改过的代码、调参的思路全部整理出来给想用HAL库做平衡车、或者正准备把手头标准库项目迁到HAL库的朋友做个参考。这篇文章适合什么人看第一种是已经跟着视频或者参考代码做过标准库平衡车、想用HAL库重新做一遍的人第二种是刚学STM32不久想直接用HAL库起步做平衡车的初学者。我会尽量把姿态解算、PID控制、编码器读取这些核心环节拆开讲清楚也会把CubeMX里那些容易配错的地方单独拉出来说算是给后来者一份可以直接照着操作的移植笔记。1. 项目思路为什么非要把标准库改成HAL库1.1 标准库版本到底香在哪又痛在哪铁头山羊那套标准库平衡车代码最大的优点就是逻辑直白。它把姿态解算、直立环、速度环、电机控制全部分开你看代码就像看一张流程图init之后进主循环主循环里读MPU6050、算角度、算PID、输出PWM所有步骤一目了然。对于学控制原理的人来说这是最好的入门材料没有之一。但标准库的痛点也很实际。首先是外设初始化代码特别啰嗦GPIO、定时器、I2C、PWM每一个都要手动翻参考手册配置寄存器像USART波特率、定时器预分频这些代码换个芯片型号就得重新查数据手册。其次是工程结构很松散你很难直接复制粘贴到另一个项目里用每个外设的初始化函数跟业务逻辑混在一起维护起来非常痛苦。我前前后后用标准库做过好几个项目每次新建工程都要重新整一遍启动文件、外设库文件和头文件路径时间全耗在重复劳动上了。1.2 HAL库和CubeMX给平衡车项目带来了什么换到HAL库之后变化是颠覆性的。CubeMX里勾选外设、配置时钟树、设置引脚复用生成工程后所有初始化代码自动搞定。对于平衡车这个项目来说最爽的是定时器编码器模式、PWM输出、I2C这些外设的配置都是在图形界面里点点点就完成的生成的代码结构统一、命名规范出问题也比标准库更容易定位。HAL库的抽象层也让我改起来省心得多。比如用HAL_I2C_Mem_Read读MPU6050用HAL_TIM_IC_CaptureCallback处理编码器计数用__HAL_TIM_SET_COMPARE设置PWM占空比这些API在不同型号的STM32上几乎一样。也就是说今天用F103调通的核心逻辑明天换到F407、G431上只需要改改CubeMX配置核心的控制代码一行都不用动。这种体验在标准库里是没有的。1.3 移植的整体规划分层解耦不直接照抄我在动手移植之前先做了个规划不是拿到标准库代码就一行行翻译而是把代码按功能拆成四个层次底层硬件驱动MPU6050读写、编码器读取、PWM输出、中间层数据处理角度解算、滤波、控制层直立环、速度环、应用层主循环调度。标准库代码里的控制算法逻辑我原样保留只是把底层的寄存器操作全部替换成HAL函数把初始化和读取接口重新封装。这样既保留了原项目的思路精髓又让代码变得更容易维护和扩展。2. 硬件准备与工程搭建2.1 移植用到的硬件清单我做这版HAL库平衡车用的硬件跟铁头山羊那套基本一致STM32F103C8T6最小系统板、MPU6050六轴传感器、TB6612FNG电机驱动模块、两个带霍尔编码器的N20减速电机、3.7V锂电池加升压模块给电机供电逻辑部分用板载3.3V。OLED显示模块是可选的但强烈建议加一个调试的时候能实时看角度和PID输出值省得猜。这里有一个细节容易踩坑STM32F103C8T6的PB3、PB4、PA15这三个引脚默认是JTAG调试口如果你想把这几个引脚用作普通GPIO必须在CubeMX里先把Debug模式改成Serial Wire否则引脚怎么配都不生效。我第一次就是把编码器的A相接到了PB4上结果死活读不到脉冲查了半天才发现是JTAG占用的坑。2.2 用CubeMX建工程外设配置一次到位打开STM32CubeMX新建工程芯片选STM32F103C8Tx然后按下面的列表配置外设RCC外部晶振HSE时钟树调成72MHz主频SYSDebug选Serial Wire这个必须改原因上面说了I2C1配置成Standard Mode速度100K接MPU6050的SCL和SDATIM2编码器模式接左电机编码器的A/B相计数值范围0到65535TIM3编码器模式接右电机编码器A/B相TIM4PWM输出CH1和CH2分别控制两个电机的PWM引脚GPIO电机方向控制引脚配置成输出模式我用的是PA1、PA2、PA3、PA4USART1调试串口9600波特率方便打印角度和PID数据时钟树那里要注意I2C外设总线频率不要超过36MHzAPB2时钟不要超过72MHz。我见过有人直接把所有时钟拉到最大结果I2C通信不稳定MPU6050偶尔读出来全是0把时钟配置改规范之后问题就消失了。2.3 工程结构把HAL生成的代码和业务代码分开CubeMX生成的工程默认把所有业务逻辑都塞在main.c里这对平衡车这种代码量不小的项目来说会变成灾难。我按功能建了这样的文件结构Core/Src/ main.c // 主循环负责调度 mpu6050.c // MPU6050初始化、读取原始数据 balance.c // 姿态解算、互补滤波 pid.c // PID控制器实现 motor.c // 电机PWM和方向控制 encoder.c // 编码器读取和速度计算每个模块的.h文件里把对外接口声明清楚比如解算后的角度、目标速度、PID输出量等等。这样做的好处是以后想在这套代码基础上加蓝牙遥控或者超声波避障只需要在main.c里加几个函数调用不需要动底层任何一个文件。我实测下来分层清晰之后调试时的思路也顺畅很多不会像以前那样一个变量改来改去最后都不知道哪里影响了哪里。3. 核心代码实现从原始数据到控制输出3.1 MPU6050读取HAL库的I2C时序问题MPU6050的读取是整个项目的起点。标准库时代我习惯直接操作I2C寄存器发起始信号、发地址、读数据到HAL库时代就简单多了直接用HAL_I2C_Mem_Read函数。读加速度和陀螺仪原始值的代码如下uint8_t mpu6050_read_accel(int16_t *ax, int16_t *ay, int16_t *az) { uint8_t buf[6]; if (HAL_I2C_Mem_Read(hi2c1, MPU6050_ADDR 1, MPU6050_ACCEL_XOUT_H, I2C_MEMADD_SIZE_8BIT, buf, 6, 100) ! HAL_OK) { return 1; } *ax (int16_t)((buf[0] 8) | buf[1]); *ay (int16_t)((buf[2] 8) | buf[3]); *az (int16_t)((buf[4] 8) | buf[5]); return 0; } uint8_t mpu6050_read_gyro(int16_t *gx, int16_t *gy, int16_t *gz) { uint8_t buf[6]; if (HAL_I2C_Mem_Read(hi2c1, MPU6050_ADDR 1, MPU6050_GYRO_XOUT_H, I2C_MEMADD_SIZE_8BIT, buf, 6, 100) ! HAL_OK) { return 1; } *gx (int16_t)((buf[0] 8) | buf[1]); *gy (int16_t)((buf[2] 8) | buf[3]); *gz (int16_t)((buf[3] 8) | buf[5]); return 0; }这里有个特别容易出问题的点MPU6050的器件地址是0x68但在HAL库里调用HAL_I2C_Mem_Read时地址要左移一位变成0xD0因为I2C库函数期望的是包含读写位的8位地址。我一开始没左移读出来的数据全是FF排查了很久才发现是这个坑。同样要注意的是读取陀螺仪数据时最后一个参数是超时时间单位是毫秒别写太长不然控制器卡在那里不动车就失去响应了。3.2 姿态解算互补滤波不整复杂的姿态解算这块我用了最简单的互补滤波没有上Mahony或者卡尔曼。原因很朴素对平衡车这种应用场景车体的倾斜角度变化不是特别剧烈一阶互补滤波完全够用而且代码量小、参数调节直观。陀螺仪积分出来的角度有零漂加速度计算出来的角度有震动干扰互补滤波的本质就是一个加权融合float complementary_filter(float acc_angle, float gyro_rate, float dt) { float k 0.98f; return k * (previous_angle gyro_rate * dt) (1.0f - k) * acc_angle; }这里的k是关键参数它代表对陀螺仪的信任程度。k越大陀螺仪权重越高角度越平滑但跟随慢k越小加速度计权重越高角度响应快但抖动大。实际调试中我先把k设成0.98然后观察车静止时角度是否稳定如果跳动厉害就往下调到0.95如果反应迟钝就往上调。一次调参就用串口把滤波前后的角度数据打印出来对着波形看比盲调靠谱得多。加速度计计算倾斜角度的方式也不能搞错。我用的公式是atan2(ax, az)注意不是atan(ax / az)因为当z轴接近水平时除法会得到很大的值甚至无穷大而atan2能正确处理所有象限。还有一点平衡车在静止或者匀速运动时加速度计测到的重力方向是固定的一旦车子加减速加速度计会引入线性加速度误差这也是为什么互补滤波里陀螺仪的权重必须高不然车一动角度就开始飘。3.3 PID控制直立环和速度环怎么配合铁头山羊那套代码的控制思路非常经典直立环用PD控制速度环用PI控制串级起来输出PWM。我在HAL库版本里把这个逻辑原封不动地继承下来只是把PID计算封装成了一个通用函数typedef struct { float kp; float ki; float kd; float target; float error; float last_error; float integral; float output; } PID_TypeDef; float pid_update(PID_TypeDef *pid, float current) { pid-error pid-target - current; pid-integral pid-error; pid-output pid-kp * pid-error pid-ki * pid-integral pid-kd * (pid-error - pid-last_error); pid-last_error pid-error; return pid-output; }主循环里是这么串起来的先把MPU6050的原始数据换算成角度和角速度用互补滤波得到当前倾角然后直立环把倾角作为输入目标值是0度车的平衡位置输出一个PWM补偿量再把编码器读到的轮子速度作为速度环输入目标值是0或者遥控给的期望速度输出另一个PWM补偿量两个补偿量相加作为最终PWM值送给电机。很多人第一次做平衡车时想不明白为什么直立环要带D项。其实D项对应的是角速度相当于给系统加了阻尼。如果没有D项车会来回震荡像喝醉了一样根本站不住。我在调直立环时一个教训是P项和D项必须一起调只调P会让车越来越像钟摆幅度越摆越大D项拉不住就倒了。铁头山羊的视频里推荐的流程是先P后D、逐步加大我实测按这个流程来稳定站稳的时间比瞎调快好几倍。速度环的作用很多人理解偏了。它不是让车跑起来而是让车在受到扰动之后能回到原来的位置。速度环的输出本质上是把车的平衡角度目标值做一个微调比如车往前走了就让车的目标倾角向后偏一点利用重力把车拉回来。理解了这一层就不会去把速度环调得过于猛烈不然车会一抽一抽地前后挪动。3.4 编码器读取与电机控制从Count到速度编码器模式是定时器的一个特殊功能TIM会自己计数A相和B相的电平变化会让计数器加一或者减一。读取速度时用__HAL_TIM_GET_COUNTER(htim2)拿到当前计数值然后每50毫秒计算一次和前一次的差值除以时间就是速度。需要注意TIM计数器的范围F103的定时器是16位的当速度很快时计数值可能超过65535溢出所以每次计算差值时要把溢出情况处理好或者直接用比较短的采样周期避免累加过多。PWM输出我用的是定时器的比较通道通过__HAL_TIM_SET_COMPARE(htim4, TIM_CHANNEL_1, pwm_value)设置占空比。电机方向控制用两路GPIO方向引脚高低组合决定正转反转。这里有个常见坑TB6612的PWM频率如果太低电机会嗡嗡叫太高驱动芯片可能跟不上。我测试下来用15kHz到20kHz比较合适在这个区间电机噪音很小发热也正常。CubeMX里TIM4的PSC和ARR要配合主频算好72MHz主频下PSC设为71、ARR设为49得到的就是20kHz的PWM频率。电机方向和编码器极性也是坑重灾区。不同牌子的N20电机编码器A相和B相的线序可能不一样直接导致速度方向与期望方向相反。我的解决办法是在代码里加一个极性的宏定义#define MOTOR_LEFT_DIRECTION 1 #define MOTOR_RIGHT_DIRECTION -1如果发现车往一边偏或者速度环方向反了改这个宏的值就能翻转方向不用去改硬件接线。这个经验也是被逼出来的之前每次换电机都要把接线重新焊一遍后来直接就留了软件反转的口子。4. 调试实录这台车站起来之前我都经历了什么4.1 上电后车轮直接打死根本没机会站起来第一次给HAL库版本上电我满怀期待地按下开关结果车猛地往一个方向冲出去直接翻倒。排查思路是先看MPU6050的数据是不是正常。我把加速计原始值通过串口打印出来发现静止时z轴读数只有几百分之一明显不对。查到最后还是I2C地址的问题MPU6050的AD0引脚被拉高时地址是0x69拉低是0x68我的模块上AD0默认悬空读出来不稳定。把AD0焊到地之后数据正常了。4.2 车一直在原地高频抖动像踩了电门能站一小会儿但一直抖这种问题十有八九出在D项上。D项太大会放大高频噪声陀螺仪数据的微小波动经过微分之后会被放大得非常吓人。我在代码里给陀螺仪原始数据加了一个简单的一阶低通滤波gyro_filtered 0.8f * gyro_filtered 0.2f * gyro_raw;滤波系数要合理别过滤太多导致相位滞后不然D项会看到虚假的角速度变化反而激起震荡。我调完滤波之后再把D项从0慢慢往上加车身的抖动幅度明显开始收敛。4.3 静止时角度输出不停漂移视觉上没动数据在动这是陀螺仪零漂在作怪。即使车完全静止陀螺仪的输出也不是零积分之后角度就会慢慢飘。解决办法有两个一个是在初始化时采样几百次求平均作为零偏另一个是代码里预留零点校准的按键接口。我用了前者在里设了一个静态变量存零偏每次上电自动校准。顺便说一句如果你的车运行时间长了温度变化会引起零偏变化这时候最简单的缓解方式是重新上电校准一遍。4.4 串口调试的实战建议数据可视化真的能救命调平衡车时串口打印是最重要的调试工具。我会周期性把当前角度、目标角度、直立环输出、速度环输出、最终PWM这几个关键量打印出来。刚开始都是裸数据看起来费劲后来我改成用串口绘图软件直接画曲线角度变化和PWM输出之间的关系一眼就能看明白。比如车开始抖动时能看到角度和PWM之间有明显的相位滞后这时候就知道该调整滤波参数或者D项了。强烈建议把所有调参过程都用数据曲线来辅助判断千万不要靠肉眼去盯车子的姿态变化然后凭感觉改参数那样效率太低了。4.5 常见问题速查表现象可能原因排查步骤MPU6050读数全为FFI2C地址错、上拉电阻缺失检查地址是否左移一位检查SCL/SDA是否加上拉上电就往一边冲电机方向反、速度环方向反先断开速度环单独测试直立环再检查方向宏车在原地高频抖动D项过大、陀螺仪噪声大降低D项给陀螺仪数据加低通滤波角度输出不断漂移陀螺仪零偏未校准上电后自动校准零偏电机有嗡嗡声不转PWM频率不合适、占空比太低调整PWM频率到15-20kHz检查占空比是否太小编码器读数不变JTAG引脚占用、定时器模式配错检查Debug模式是否设为Serial Wire检查编码器模式配置5. 移植完成后的经验总结这套代码能怎么扩展5.1 从标准库思维到HAL库思维的转变做完这次移植我最大的感悟是标准库和HAL库本质上不是竞争关系而是适合不同阶段的工具。做学习项目时标准库让你看得更透做工程化项目时HAL库让你效率更高。真正重要的是把控制逻辑和硬件访问剥离开这样无论底层怎么变你的算法代码都能保留下来。以后哪怕从F103换到G431我也只需要改CubeMX的配置和驱动层的接口PID和互补滤波这部分完全能原样带过去。5.2 如果想加功能从哪里下手现在这版代码是纯裸机逻辑主循环一直在跑读传感器、解算、PID、输出PWM这套流程。如果接下来想加蓝牙遥控可以把速度环的target从固定的0改成外部输入的目标速度同时加一个遥控通道来设定这个目标值。如果想加OLED显示就在主循环里每隔100毫秒调用一次显示刷新函数把角度和PID数据从全局变量结构体里读出来画到屏幕上。如果想做自平衡小车独轮版本或者加机械臂核心思路也是一样的只是控制链路会增加更多环节。我还留了一个小优化技巧主循环里的延时不要太长。我这版的循环周期大概是2到3毫秒如果你加了显示、通信之类的功能导致循环变慢可以把姿态解算放到定时器中断里执行主循环只负责处理逻辑和显示这样既能保持控制频率稳定又不损失功能扩展的灵活性。5.3 最后再分享一个关于调参的实在话调平衡车的过程本质上是在跟物理世界对话。每个人的车、电机、重心高度、轮子直径都不一样别人的参数抄过来只能让你看到车能动但绝对没法直接一站起来就稳如磐石。我现在的调试习惯是先确保数据是对的再调直立环站稳然后加上速度环稳定最后才考虑遥控和扩展功能。每一步都要确认效果之后再走下一步不要跳步也不要试图用一个大神级的PID参数掩盖硬件上的问题。硬件接线、安装重心、传感器固定方式这些因素对控制效果的影响有时候比PID参数本身还要大这一点真的是反复折腾之后才体会到的。