STM32平衡车代码从标准库移植到HAL库:分层设计与实战指南

发布时间:2026/11/4 11:02:35
STM32平衡车代码从标准库移植到HAL库:分层设计与实战指南

1. 从标准库到HAL库:平衡车代码移植这件事到底值不值得干

平衡车这个项目在STM32圈子里算是经典中的经典了。B站铁头山羊那套标准库平衡车教程,估计不少人都跟着做过一遍——从MPU6050读姿态、互补滤波或者卡尔曼滤波算角度、串级PID调直立环和速度环、PWM驱动电机,一整套下来对STM32的理解能上一个大台阶。但问题也很明显:那套代码是基于STM32标准外设库(Standard Peripheral Library)写的,而ST现在主推的是HAL库,新出的芯片像G4、H7、U5系列压根就没有标准库支持。你拿F103的标准库代码往G431上搬,基本等于重写。

所以把铁头山羊那套标准库平衡车代码移植成HAL库版本,不只是“换个库调用”这么简单。它解决的核心问题是:让平衡车的控制逻辑能跑在更新的芯片平台上,同时借助HAL库的CubeMX工具链把底层初始化配置标准化,降低后续换芯片、加外设的成本。这件事适合谁?适合已经跟着标准库教程做过一遍平衡车、对PID和姿态解算有基本理解、想进一步掌握HAL库开发方式的嵌入式学习者;也适合那些手里有G4、F4等只有HAL库支持的芯片、想拿平衡车练手的开发者。

我这次移植的目标很明确:保留铁头山羊原版的控制算法结构和参数框架,把底层驱动层从标准库换成HAL库,中间层(姿态解算、PID)尽量不动,上层业务逻辑(直立、速度、转向)保持一致的调用接口。这样做的原因是,控制算法是平衡车的灵魂,那部分经过验证的参数和逻辑不应该在移植中被破坏;而底层驱动是跟芯片和库强相关的,必须换。把这两层解耦开,移植工作才有章法。

整个移植过程涉及的核心技术点包括:HAL库的GPIO/定时器/串口/I2C配置方式与标准库的差异、SysTick和HAL_Delay的时基管理、PWM输出的定时器配置、编码器接口模式在HAL库下的读取方式、MPU6050的I2C通信在HAL库下的实现、中断优先级分组设置,以及最关键的——控制周期的精确保证。下面我按实际移植的顺序,把每个环节拆开讲。

2. 移植前的整体设计与分层思路

2.1 为什么不能直接“替换函数名”了事

很多人第一反应是:标准库的GPIO_Init()换成HAL_GPIO_Init()不就行了?理论上函数是对应的,但实际移植中你会发现几个要命的问题。第一,标准库的GPIO初始化是直接填结构体然后调GPIO_Init,HAL库虽然也是填结构体调HAL_GPIO_Init,但结构体成员名字和类型变了,比如标准库的GPIO_Speed_50MHz在HAL库对应的是GPIO_SPEED_FREQ_HIGH,模式定义也从GPIO_Mode_AF_PP变成了GPIO_MODE_AF_PP,大小写和命名规则全变了。第二,标准库的定时器配置是直接操作寄存器结构体,HAL库引入了TIM_HandleTypeDef句柄机制,初始化流程变成了HAL_TIM_Base_Init加HAL_TIM_PWM_Init分步走。第三,也是最容易踩坑的——标准库默认用SysTick做延时基准,HAL库也用SysTick但默认优先级是最低的,如果你在中断里调HAL_Delay直接死给你看。

所以移植的正确姿势不是逐行替换,而是重新梳理分层架构。我的做法是把代码分成三层:底层驱动层(GPIO、定时器、I2C、串口)、中间算法层(姿态解算、PID控制器)、应用逻辑层(平衡控制主循环)。底层驱动层完全用CubeMX生成加手动补充,中间算法层从标准库代码里原样搬过来只改数据类型,应用逻辑层调整调用接口适配HAL库的API风格。

2.2 用CubeMX打底还是手写初始化

这个问题我纠结过。CubeMX的好处是图形化配置时钟树、引脚复用、中断优先级,生成的代码规范且不容易漏配置;坏处是它生成的代码结构比较“霸道”,你想在main.c里塞自己的控制循环会被各种USER CODE BEGIN/END注释框限制,而且重新生成代码时容易覆盖手写内容。手写初始化的好处是结构完全可控,坏处是F4系列以上的时钟树配置比较复杂,手动算PLL参数容易出错。

我的选择是:用CubeMX生成底层初始化框架,但把控制相关的代码全部放到独立的.c/.h文件里,main.c里只保留CubeMX生成的初始化和一个主循环调用。这样即使重新生成代码,控制逻辑也不会丢。具体操作是在CubeMX里配置好时钟、GPIO、定时器PWM、定时器编码器模式、I2C、串口,然后生成MDK或STM32CubeIDE工程,再把标准库版本里的control.c、pid.c、imu.c这些文件加进来,改掉里面直接操作寄存器的部分。

2.3 控制周期怎么保证——这是移植成败的关键

标准库版本里,铁头山羊通常用定时器中断来保证固定的控制周期,比如5ms一次中断,在中断里读姿态、算PID、更新PWM。移植到HAL库后,定时器中断的配置方式变了,但思路不变。我实测下来,用TIM6或者TIM7这种基本定时器做控制周期中断最稳,因为它们不涉及PWM输出和编码器接口,不会跟其他定时器冲突。配置成5ms周期,以72MHz主频为例,预分频器设成7200-1,自动重装载值设成50-1,这样7200*50/72000000 = 5ms,计算过程很清晰。

注意:HAL库的定时器中断回调函数是HAL_TIM_PeriodElapsedCallback,所有定时器共用这一个回调,你需要在里面判断是哪个定时器触发的。标准库是每个定时器有独立的中断服务函数,这个差异一定要记住,否则会出现“明明配了中断但进不去回调”的情况。

另外,HAL库默认的SysTick优先级是TICK_INT_PRIORITY,通常是0(最高)或者15(最低),取决于CubeMX里的设置。如果你在控制中断里调用了任何带超时等待的HAL函数(比如HAL_I2C_Mem_Read),而SysTick优先级低于控制中断,那HAL_Delay和超时计数就不会更新,直接卡死。我的做法是把SysTick优先级设成0,控制中断设成1或2,确保时基永远能走。

3. 核心驱动层的HAL库实现细节

3.1 GPIO与电机方向控制

标准库版本里,电机方向控制通常是两个GPIO推挽输出,一个控制正反转,一个控制使能。标准库的写法是:

GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure);

HAL库对应的是:

GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

看起来差不多,但有个细节:HAL库多了Pull成员,必须显式设置,否则可能是随机值。另外HAL库的引脚定义是GPIO_PIN_0而不是GPIO_Pin_0,这个大小写差异在批量替换时特别容易漏。我建议直接用CubeMX配置GPIO,生成代码后把引脚标签(User Label)设成MOTOR_A_IN1这种有意义的名字,这样代码里用MOTOR_A_IN1_Pin和MOTOR_A_IN1_GPIO_Port来操作,可读性比直接写GPIO_PIN_0好得多,换引脚时也不用满代码找。

电机PWM输出我用的是TIM1的CH1和CH2,配置成PWM Generation模式,预分频器设成0,自动重装载值设成7200-1,这样PWM频率是72MHz/7200 = 10kHz。为什么选10kHz?因为电机驱动一般用有刷直流电机,10kHz在人耳听觉范围边缘,噪音相对小,同时不会因为频率太高导致MOS管开关损耗过大。占空比通过__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, duty)来设置,duty范围0到7199。

3.2 编码器接口模式的HAL库读取

标准库读编码器一般是配置TIM_EncoderInterfaceConfig,然后读TIM_GetCounter。HAL库的配置方式是在CubeMX里把定时器设成Encoder Mode,生成代码后调用HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL)启动,读取用__HAL_TIM_GET_COUNTER(&htim3)。这里有个坑:编码器计数是16位的,正转加到65535后溢出回0,反转减到0后下溢回65535。标准库版本里通常用(short)TIM_GetCounter(TIM3)强制转成有符号数来得到正负速度,HAL库下同样可以用(int16_t)__HAL_TIM_GET_COUNTER(&htim3)。

但更稳妥的做法是每个控制周期读取一次计数,然后清零计数器,这样得到的是周期内的增量,不会累积溢出。我实测下来,5ms周期内编码器增量一般在几十到几百之间,远不会溢出,所以直接读增量最安全。具体代码:

int16_t encoder_left = (int16_t)__HAL_TIM_GET_COUNTER(&htim3); __HAL_TIM_SET_COUNTER(&htim3, 0);

注意:清零计数器要在读取之后立刻做,否则下一个周期读到的就是累积值。另外编码器定时器的ARR建议设成65535,不要设成其他值,否则溢出行为会变。

3.3 MPU6050的I2C通信移植

标准库版本通常用软件I2C或者硬件I2C读MPU6050。铁头山羊的教程里我记得用的是软件I2C,因为F103的硬件I2C有已知的锁死问题。移植到HAL库后,如果你用的是F4或者G4系列,硬件I2C已经稳定很多了,可以直接用HAL_I2C_Mem_Read。但如果你还是用F103,我建议继续用软件I2C,只是把GPIO操作从标准库换成HAL库的HAL_GPIO_WritePin和HAL_GPIO_ReadPin。

软件I2C的延时函数要注意:标准库版本里可能用的是delay_us这种自己写的循环延时,移植到HAL库后如果直接换成HAL_Delay,最小单位是1ms,对于I2C的400kHz速率来说太慢了。我的做法是保留一个基于DWT或者SysTick的微秒延时函数。用DWT的话,先使能DWT->CTRL的CYCCNT,然后delay_us就是读DWT->CYCCNT做差值比较,精度很高。代码大概这样:

void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }

如果不想折腾DWT,也可以用TIM做一个微秒延时,但DWT最省事。MPU6050的初始化序列和寄存器地址跟库无关,直接从标准库代码里搬过来就行,只是读写函数换成HAL库的I2C接口或者软件I2C的GPIO操作。

3.4 串口调试输出的重定向

标准库版本里用printf输出调试信息通常重定向fputc到USART_SendData。HAL库下重定向到HAL_UART_Transmit:

int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 10); return ch; }

但这里有个性能问题:HAL_UART_Transmit是阻塞发送,每发一个字节都要等发送完成,如果波特率是115200,发一行几十个字符要好几毫秒,放在控制循环里会严重影响控制周期。我的做法是调试输出只在需要的时候开,正常跑控制的时候关掉,或者用DMA发送。如果非要在控制循环里输出,至少把串口波特率提到460800或者921600,减少阻塞时间。

提示:Keil MDK里使用printf重定向需要勾选“Use MicroLIB”,否则会卡在__use_no_semihosting相关错误上。STM32CubeIDE则不需要MicroLIB,但要注意syscalls.c里的_write函数实现。

4. 控制算法层的移植与参数整定

4.1 姿态解算:互补滤波还是卡尔曼

铁头山羊标准库版本里用的是互补滤波,代码简单,计算量小,对F103这种没有FPU的芯片很友好。移植到HAL库后,如果你用的是F4系列(有FPU),可以尝试卡尔曼滤波,但互补滤波其实已经够用了。我的建议是:先用互补滤波把车调稳,再考虑要不要换卡尔曼。互补滤波的核心公式:

angle = 0.98 * (angle + gyro_y * dt) + 0.02 * accel_angle;

其中gyro_y是陀螺仪Y轴角速度(度/秒),dt是控制周期(秒),accel_angle是从加速度计算出的倾角。这个0.98和0.02是权重系数,标准库版本里通常用0.98/0.02,但实际调试时可以根据车体振动情况调整。振动大的时候把陀螺仪权重调高(比如0.99),让角度更平滑但响应变慢;振动小的时候可以调到0.95,响应更快但可能有高频抖动。

移植时要注意数据类型:标准库版本里可能用了float,HAL库下如果芯片有FPU(比如F4、G4),float运算很快,直接用没问题。如果是F103这种没有FPU的,float运算靠软件模拟,5ms周期内做几十次浮点运算可能会超时。这时候要么用定点数,要么把控制周期放宽到10ms。我实测F103在72MHz下,5ms周期做互补滤波加串级PID,CPU占用大概60%左右,还能跑,但余量不大。

4.2 串级PID的结构与参数含义

平衡车的串级PID分三个环:直立环(角度环)、速度环、转向环。直立环是内环,响应最快;速度环是外环,输出叠加到直立环的目标角度上;转向环独立控制两个电机的差速。标准库版本里这三个环的参数通常是分开定义的,移植时直接搬过来:

typedef struct { float Kp; float Ki; float Kd; float integral; float prev_error; float output; } PID_TypeDef;

直立环的Kp一般在100到300之间(取决于电机和机械结构),Kd在0.5到2之间,Ki通常为0。速度环的Kp在0.5到2之间,Ki在0.01到0.1之间。这些参数跟库无关,移植后不需要改,但要注意HAL库下控制周期的实际值可能跟标准库版本有微小差异,如果周期变了,积分项和微分项需要按比例调整。比如标准库版本是5ms周期,你移植后变成6ms,那Ki和Kd都要乘以5/6。

注意:PID的积分项一定要做限幅,否则车体长时间倾斜时积分会累积到很大,一旦车体回正,积分项会猛冲导致过冲甚至翻车。我的做法是积分限幅在输出限幅的50%左右。

4.3 电机输出混合与死区处理

两个电机的PWM输出由直立环输出、速度环输出、转向环输出混合得到:

float motor_left = upright_output + speed_output + turn_output; float motor_right = upright_output + speed_output - turn_output;

然后做限幅,确保在-7200到7200之间(对应PWM的0到7199,负值代表反转)。这里有个细节:电机有死区,PWM占空比低于某个值(比如5%)时电机不转。如果不处理死区,车在微小倾斜时会“抖”但不走,积分项会累积。我的做法是在输出后加一个死区补偿:如果输出绝对值小于死区阈值但大于0,就强制拉到死区阈值。这样电机能立刻响应微小控制量。

if (motor_left > 0 && motor_left < DEAD_ZONE) motor_left = DEAD_ZONE; if (motor_left < 0 && motor_left > -DEAD_ZONE) motor_left = -DEAD_ZONE;

死区阈值需要实测,不同电机和驱动不一样。我用的370减速电机加TB6612驱动,死区大概在3%到5%之间,对应PWM值216到360。你可以从0开始慢慢加,直到电机刚好能稳定转动。

4.4 控制周期的实测与校准

移植完成后,第一件事是实测控制周期是否准确。我的做法是在控制中断里翻转一个GPIO,然后用示波器或者逻辑分析仪看波形周期。如果没有仪器,可以用另一个定时器做输入捕获,测出中断的实际周期。我实测下来,CubeMX配置的5ms定时器中断,实际周期在5.02ms左右,误差来自时钟精度和中断响应延迟。这个误差对平衡车影响不大,但如果你的控制周期是1ms,误差比例就大了。

如果发现周期偏差较大,可以微调ARR值来补偿。比如实测5.02ms,目标5ms,那ARR从50改成50*5/5.02≈49.8,取整49或50都行,影响很小。关键是周期要稳定,不能一会儿5ms一会儿6ms,那样PID会震荡。

5. 常见问题与排查技巧实录

5.1 HAL库下平衡车移植的典型问题速查

问题现象可能原因排查方法解决方法
编译报错GPIO_Pin_0未定义标准库和HAL库引脚宏大小写不同检查所有GPIO_Pin_x改成GPIO_PIN_x
程序卡在HAL_DelaySysTick优先级低于当前中断查看NVIC配置把SysTick优先级设为0
定时器中断进不去回调函数名写错或没启动中断检查HAL_TIM_Base_Start_IT是否调用确认回调函数是HAL_TIM_PeriodElapsedCallback
编码器读数一直是0编码器模式配置错误或没启动用示波器看编码器A/B相波形确认HAL_TIM_Encoder_Start已调用
PWM无输出定时器PWM没启动或引脚复用没配量引脚波形调用HAL_TIM_PWM_Start,检查CubeMX引脚配置
MPU6050读不到数据I2C地址错误或时序不对用逻辑分析仪抓I2C波形确认地址是0x68<<1,检查上拉电阻
车体震荡严重PID参数不适配或控制周期不稳先关速度环只调直立环降低Kp,增大Kd,检查周期
串口输出乱码波特率不匹配或时钟配置错误检查串口波特率和系统时钟确认HAL库时钟配置与外部晶振一致

5.2 我踩过的三个坑

第一个坑是HAL_TIM_PeriodElapsedCallback的重入问题。这个回调是所有定时器共用的,如果你在回调里做了耗时操作(比如串口打印),而另一个定时器也触发了中断,就会重入。我的做法是在回调里只做标志位设置,实际控制逻辑放在主循环里根据标志位执行。这样中断响应快,也不会重入。

第二个坑是HAL库的HAL_I2C_Mem_Read超时时间设得太短。默认的HAL_MAX_DELAY是无限等待,但如果I2C总线被拉死,程序就卡住了。我设成10ms超时,超时后重新初始化I2C,这样即使MPU6050偶尔抽风,车也不会完全死掉。当然,控制循环里读I2C失败时要用上一次的角度值,不能直接返回0,否则车会突然倒。

第三个坑是CubeMX重新生成代码后覆盖了手写的中断回调。CubeMX生成的stm32f4xx_it.c里有TIM6_DAC_IRQHandler,里面调用了HAL_TIM_IRQHandler,这个不能删。但如果你在main.c里写了HAL_TIM_PeriodElapsedCallback,重新生成时CubeMX不会覆盖USER CODE BEGIN 4区域的内容,所以回调要放在那个区域里。我一开始不知道,重新生成后回调没了,车直接不控制,找了半天才发现。

5.3 调试平衡车的实用技巧

调平衡车最痛苦的是参数整定。我的经验是:先调直立环,把速度环和转向环关掉,用手扶着车,感受电机响应方向对不对。如果倾斜时电机往反方向转,说明极性反了,把电机线对调或者代码里取反。然后慢慢加Kp,直到车能勉强立住但会来回晃,再加Kd抑制晃动。Kd太大会导致高频抖动,太小会晃得厉害。找到临界点后,Kp和Kd各留20%余量。

速度环的调试要在直立环稳定之后。先给一个很小的Kp,观察车是否能缓慢移动。如果车往一个方向跑,说明速度环极性反了。速度环的Ki要很小,否则会跟直立环打架。我一般从0.01开始加,每次加0.005,直到车能稳定在原地附近。

提示:调试时一定要用电池供电,不要用USB供电。USB的电流能力有限,电机一启动电压就掉,会导致单片机复位,你以为是程序问题,其实是供电问题。我在这上面浪费过一整个下午。

5.4 从标准库迁移到HAL库的检查清单

移植完成后,按这个清单过一遍,能避免90%的问题:

  1. 所有GPIO_Pin_x改成GPIO_PIN_x,GPIO_Speed_50MHz改成GPIO_SPEED_FREQ_HIGH
  2. 所有定时器初始化改成HAL_TIM_Base_Init加HAL_TIM_PWM_Init或HAL_TIM_Encoder_Init
  3. 所有中断服务函数改成对应的HAL_XXX_IRQHandler,回调函数名用HAL库规定的
  4. SysTick优先级设为0,控制中断优先级设为1到3
  5. 所有delay_us确认是基于DWT或定时器的,不能用HAL_Delay替代
  6. I2C读写函数确认超时时间合理,失败后有降级处理
  7. 串口重定向确认MicroLIB已勾选(Keil)或_write已实现(CubeIDE)
  8. 控制周期用示波器实测,偏差超过5%要调整ARR
  9. PID参数根据实际周期按比例微调
  10. 电机死区实测并补偿

这套流程走下来,标准库平衡车代码基本能平滑迁移到HAL库平台。我后来把这套代码往G431上搬,只改了时钟配置和I2C部分,控制逻辑一行没动,半小时就跑起来了。这就是分层设计的好处——底层换血,上层不动。