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_InitHAL库虽然也是填结构体调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_Transmitint 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响应更快但可能有高频抖动。移植时要注意数据类型标准库版本里可能用了floatHAL库下如果芯片有FPU比如F4、G4float运算很快直接用没问题。如果是F103这种没有FPU的float运算靠软件模拟5ms周期内做几十次浮点运算可能会超时。这时候要么用定点数要么把控制周期放宽到10ms。我实测F103在72MHz下5ms周期做互补滤波加串级PIDCPU占用大概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波形确认地址是0x681检查上拉电阻车体震荡严重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%的问题所有GPIO_Pin_x改成GPIO_PIN_xGPIO_Speed_50MHz改成GPIO_SPEED_FREQ_HIGH所有定时器初始化改成HAL_TIM_Base_Init加HAL_TIM_PWM_Init或HAL_TIM_Encoder_Init所有中断服务函数改成对应的HAL_XXX_IRQHandler回调函数名用HAL库规定的SysTick优先级设为0控制中断优先级设为1到3所有delay_us确认是基于DWT或定时器的不能用HAL_Delay替代I2C读写函数确认超时时间合理失败后有降级处理串口重定向确认MicroLIB已勾选Keil或_write已实现CubeIDE控制周期用示波器实测偏差超过5%要调整ARRPID参数根据实际周期按比例微调电机死区实测并补偿这套流程走下来标准库平衡车代码基本能平滑迁移到HAL库平台。我后来把这套代码往G431上搬只改了时钟配置和I2C部分控制逻辑一行没动半小时就跑起来了。这就是分层设计的好处——底层换血上层不动。
阅读完成 · 觉得有帮助?