ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

打造可维护的2024电赛H题代码:STM32模块化与PID控制

2026/9/15 17:50:48 拓冰建站 浏览量
打造可维护的2024电赛H题代码:STM32模块化与PID控制 简介面向2024年电子设计竞赛H题备赛的代码资源主要服务参赛大学生、电子爱好者及准备嵌入式开发项目的人群内容围绕信号处理类任务展开覆盖滤波、频谱分析、编码解码等数字信号处理技术并涉及软件插件环境使用结合STM32微控制器与C语言编程完成硬件控制与算法实现。资料中既能找到整体问题求解框架也包含针对数据或功能模块的拆分处理思路适合用来梳理H题的完整流程。资源压缩包为zip格式大小约114.17MB虽然上游暂未给出具体文件总数与类型明细但内容以源代码、工程配置及必要说明为主便于按模块逐步理解。目前已有91人学习属于体量适中的备赛包适合边看边练、对照赛题进行验证和二次开发对于希望快速搭建H题方案、深入理解STM32外设使用及数字信号处理落地的同学这份代码能提供直观参考并可作为进一步优化的起点。1. 2024电赛H题代码先把“能跑”变成“可维护”写2024电赛H题代码最难的不是某个外设调不通而是当按键、传感器、执行器、显示和通讯全部挤在一个主循环里时代码会迅速变成一团乱麻。比赛到第三天晚上多数队伍面临的问题已经不是算法而是改了一行代码之后不知道哪里跟着出错。解决这个问题的核心不是代码技巧而是模块边界。多数评委看到的整机表现——稳定性、响应速度、异常恢复都建立在代码结构之上。这篇内容用嵌入式竞赛里最常见的代码组织方式来讲从模块拆分、外设驱动、PID调参到现场降级目标是把“能跑”变成“可维护”。2. 拆解H题代码模块状态机优先函数别超过100行2.1 从H题需求反推代码模块清单拿到H题之后先别急着打开CubeMX。用一张纸把题目功能拆成输入、处理、输出三类再映射到代码模块。大多数H题控制类题目都可以套用下面这个分工表。模块输入输出涉及外设按键管理GPIO电平按键事件按键传感器采集ADC/I2C/SPI原始值滤波后数值传感器控制算法目标值反馈值控制量无电机执行控制量PWM占空比/方向电机驱动器显示界面状态信息显示内容屏幕通讯解析串口/无线帧解析结果无线模块这个表的价值在于划定边界。控制算法模块不需要知道电机定时器用哪个通道它只拿目标值和反馈值算出一个控制量然后交给电机执行模块。这样后续单独调PID、换传感器、改引脚都只碰一个模块不会顺手改坏另一个模块。模块之间用接口函数传参比互相读全局变量安全得多。一个典型的H题代码接口层长这样void Key_Scan(void); KeyEvent Key_GetEvent(void); void Sensor_Update(void); float Sensor_GetValue(void); void Controller_SetTarget(float target); float Controller_GetOutput(void); void Motor_SetOutput(float output); void Motor_Stop(void); void Display_ShowState(AppState state); void Com_SendReady(uint8_t ready);每个函数只做一件事函数名直接说明意图。比赛后期加功能比如增加一个校准模式只需要新增一个Calibrate_Update再在上层状态机里加一个状态不需要改动已有驱动。2.2 用头文件把硬件引脚和算法参数集中管理电赛现场最常发生的悲剧是前一天晚上改了一个引脚第二天测试时忘记了改的是哪个文件。把硬件相关宏集中到一个头文件里是成本最低的防呆手段也能让两位队员同时改代码时不至于改了同一个文件产生冲突。#ifndef __HW_CONFIG_H #define __HW_CONFIG_H #include main.h /* 电机PWMTIM1_CH116MHz时钟 */ #define MOTOR_PWM_TIM htim1 #define MOTOR_PWM_CHANNEL TIM_CHANNEL_1 #define MOTOR_PWM_PERIOD 999 #define MOTOR_PWM_MAX_DUTY 800 /* 编码器TIM3 */ #define ENCODER_TIM htim3 #define ENCODER_PERIOD 65535 /* 控制参数 */ #define PID_KP 2.5f #define PID_KI 0.1f #define PID_KD 0.05f #define PID_OUTPUT_LIMIT 600.0f #endifMOTOR_PWM_TIM用到的是TIM_HandleTypeDef指针所以宏里直接写htim1。PWM频率由时钟频率除以周期加一得到16MHz、周期999对应16kHz这个频率在电机驱动上听不到明显啸叫。PID参数也放在这里现场改系数时只需要改一个文件重新编译立刻生效不用在三个.c文件里找同一个Kp。2.3 主循环用状态机别用delay堆时间片常见做法是HAL_Delay一用到底按键消抖delay一下、传感器稳定delay一下、刷新显示再delay一下。问题是HAL_Delay是阻塞的delay期间整颗芯片都在空转其他模块全部停摆。更合理的做法是时间片轮询配合状态机管理运行流程。typedef struct { uint32_t last_tick; uint32_t interval; void (*handler)(void); } TaskItem; static TaskItem tasks[] { {0, 5, Sensor_Update}, {0, 10, Key_Scan}, {0, 20, Controller_Step}, {0, 50, Display_Refresh}, }; typedef enum { STATE_INIT, STATE_CALIBRATE, STATE_RUN, STATE_STOP, STATE_ERROR } AppState; static AppState state STATE_INIT; void App_Run(void) { uint32_t now HAL_GetTick(); for (size_t i 0; i sizeof(tasks)/sizeof(tasks[0]); i) { if (now - tasks[i].last_tick tasks[i].interval) { tasks[i].last_tick now; tasks[i].handler(); } } switch (state) { case STATE_INIT: if (Hardware_Init() HAL_OK) state STATE_CALIBRATE; else state STATE_ERROR; break; case STATE_CALIBRATE: if (Calibrate_IsDone()) state STATE_RUN; break; case STATE_RUN: if (Key_GetEvent() KEY_STOP) state STATE_STOP; break; case STATE_STOP: Motor_Stop(); break; case STATE_ERROR: Display_ShowState(state); break; } }时间片表里每个任务的执行时间必须远小于它的周期。Sensor_Update里只做一次启动ADC转换和读取结果不能在里面加HAL_Delay等转换完成。Controller_Step里只做一次PID计算计算量很小。这样主循环很快绕完一圈所有模块都能稳定地按各自的节奏运行。状态机把“现在处于什么阶段”从业务逻辑里抽出来调试的时候只需要关心当前state不再靠断点数代码。3. STM32外设驱动代码这样写PWM、编码器、串口一次调通3.1 先用定时器把PWM输出跑起来H题里电机或舵机的控制几乎都离不开PWM。在CubeMX里新建工程时把定时器配置成PWM Generation得到下面的参数关系。参数示例值计算依据定时器时钟16MHz来自内部时钟Prescaler0不预分频Period999PWM频率16MHz/(9991)16kHzPulse0初始占空比0对应的驱动代码只有两个函数void MotorPWM_Init(void) { HAL_TIM_PWM_Start(MOTOR_PWM_TIM, MOTOR_PWM_CHANNEL); __HAL_TIM_SET_COMPARE(MOTOR_PWM_TIM, MOTOR_PWM_CHANNEL, 0); } void MotorPWM_SetDuty(uint16_t duty) { if (duty MOTOR_PWM_MAX_DUTY) duty MOTOR_PWM_MAX_DUTY; __HAL_TIM_SET_COMPARE(MOTOR_PWM_TIM, MOTOR_PWM_CHANNEL, duty); }设置占空比就是往比较寄存器写值PWM频率不变高电平时间改变。MOTOR_PWM_MAX_DUTY设成800而不是999是为了避免占空比100%直通降低电机启动电流过大的风险。如果你用的是带方向控制的驱动芯片还需要一个GPIO输出高低电平切换方向这个逻辑可以放在Motor_SetOutput里而不是散落在主循环中。3.2 编码器测速代码位置和速度都从这里来速度闭环离不开编码器。让定时器工作在编码器模式后定时器自动根据两路正交脉冲计数代码只需要读计数器并做差值。#define ENCODER_COUNTS_PER_REV 8192 static uint16_t last_count; int16_t Encoder_Read(void) { return (int16_t)__HAL_TIM_GET_COUNTER(ENCODER_TIM); } void Encoder_SpeedUpdate(void) { int16_t cur Encoder_Read(); int16_t delta cur - (int16_t)last_count; last_count cur; speed_count (int32_t)delta; }这里最关键的是把uint16_t换成int16_t做减法。计数器回绕不是错误比如上一次是65530这一次是3int16_t做差得到的是正确增量而uint16_t直接相减会得到65535方向直接反掉。速度换算公式是实际角速度 speed_count / 每圈脉冲数 / 时间间隔。至于每圈脉冲数取决于编码器线数、倍频方式和减速比比赛现场经常要实测标定一次不能只看数据手册。3.3 串口把printf重定向日志直接打硬件调通不代表算法正确串口日志是H题代码调试里性价比最高的手段。把printf重定向到串口用起来最方便。#include stdio.h int fputc(int ch, FILE *f) { while ((huart2.Instance-SR UART_FLAG_TXE) 0); huart2.Instance-DR ch; return ch; } #define LOG(fmt, ...) printf([%lu] fmt \r\n, HAL_GetTick(), ##__VA_ARGS__)注意这个重定向只适用于不使用操作系统的裸机工程而且在CubeMX里要勾选使用MicroLIB否则printf会引入大量浮点库代码导致Flash不够。LOG带时间戳输出形如“[1234] speed1520 target1500”肉眼就能判断控制是否在收敛。中断服务函数里不要调用这个LOG它会阻塞等待TX完成可能在中断上下文里造成时序问题。如果必须从中断打日志建议改为中断发送加标志位由主循环负责输出。4. H题代码调稳的关键PID参数与传感器滤波的配合4.1 位置式和增量式PID怎么选H题代码里最常见的控制算法是PID没有之一。但很多人直接抄一个公式没想过位置式和增量式到底有什么区别。位置式输出是绝对值适合直流电机速度环、舵机角度环增量式输出是控制量的变化量适合步进电机和不允许输出突变的场合。位置式PID实现typedef struct { float kp, ki, kd; float integral; float prev_error; float out_limit; } PID_T; float PID_Update(PID_T *pid, float target, float feedback) { float error target - feedback; pid-integral error; if (pid-integral pid-out_limit) pid-integral pid-out_limit; if (pid-integral -pid-out_limit) pid-integral -pid-out_limit; float derivative error - pid-prev_error; pid-prev_error error; float out pid-kp * error pid-ki * pid-integral pid-kd * derivative; if (out pid-out_limit) out pid-out_limit; if (out -pid-out_limit) out -pid-out_limit; return out; }积分限幅和输出限幅是电赛H题代码里最容易漏掉的两个保护。积分不做限幅停车时误差一直累积等重新启动会出现很长一段时间的反向超调输出不做限幅PID可能算出超过电机承受范围的占空比。我一般把两个限幅都设成同一个值先保证系统不会跑飞再去追求精度。增量式PID实现float PID_Incremental(PID_T *pid, float target, float feedback) { float error target - feedback; float out pid-kp * (error - pid-prev_error) pid-ki * error pid-kd * (error - 2 * pid-prev_error pid-prev_prev_error); pid-prev_prev_error pid-prev_error; pid-prev_error error; return out; }增量式没有积分项累积天然不饱和所以实现里不需要积分限幅。但输出值是变化量每次调用都要把结果叠加到上一次输出上否则控制量不会变化。这个叠加操作放在Motor_SetOutput里做别放在PID函数内部保持算法纯净。4.2 传感器滤波决定PID反馈质量PID调了半天不见好先看反馈值是否干净。用串口日志把原始采样值打出来如果毛刺很大PID的微分项会把噪声放大系统高频抖动。两种最常见的滤波代码是滑动平均和一阶低通。#define FILTER_LEN 8 static float filter_buf[FILTER_LEN]; static uint8_t filter_idx; float Filter_SlidingAverage(float new_sample) { filter_buf[filter_idx] new_sample; if (filter_idx FILTER_LEN) filter_idx 0; float sum 0.0f; for (uint8_t i 0; i FILTER_LEN; i) sum filter_buf[i]; return sum / FILTER_LEN; } static float lp_out; float Filter_LowPass(float new_sample, float alpha) { lp_out alpha * new_sample (1.0f - alpha) * lp_out; return lp_out; }滑动平均对周期性噪声效果明显但每增加一倍窗口长度响应延迟也多一倍。FILTER_LEN取8到16已经很大再大会让控制系统反应迟钝。一阶低通实时性更好alpha越大越信任新值越小越平滑。alpha取0.2到0.4之间往往是个合理起点。滤波加入后PID调参才有意义否则超调到底是PID参数还是噪声导致的都分不清。滤波方式适合场景代价滑动平均周期性干扰、静止测量相位滞后一阶低通实时控制回路高频噪声衰减有限中值滤波脉冲型毛刺计算量中等4.3 调参步骤先P再I最后D参数表附上不管PID公式写得对不对参数不会调等于白写。通用的工程调参顺序是先比例再积分最后微分1 把Ki和Kd设成0Kp从很小的值开始加每次增加后给一个阶跃输入观察输出。直到系统出现等幅振荡记录这时的Kp作为临界增益。 2 把Kp设成临界增益的0.6倍开始加Ki。Ki从很小的值开始每次增加后观察稳态误差是否变小。如果出现低频波动说明Ki过大。 3 在Ki基本稳定后加KdKd用于抑制超调从很小值开始加。如果输出出现高频毛刺说明Kd过大立即减小。记下每次改动避免比赛现场凭记忆调参现象处理方式响应慢稳态误差大先加Kp再加Ki输出持续振荡减小Kp或减小Ki阶跃超调大增加Kd或降低Kp高频抖动减小Kd并检查传感器滤波调参时每改一组参数至少观察几个完整的响应周期再动下一次。H题代码最后比的不是单纯响应快而是又稳又快留出余量应对赛道干扰和电源波动。5. 比赛现场H题代码的降级方案与快速验证技巧5.1 用编译宏切换仿真数据和真实传感器现场最怕传感器临时失灵整个控制链路没法调试。写驱动时留一个仿真开关可以在没有真实传感器的情况下先跑通控制算法。#ifdef USE_SIMULATOR float feedback Simulator_GetSpeed(); #else float feedback Sensor_GetValue(); #endifSimulator_GetSpeed可以返回一个带噪声的模拟值用于验证PID方向和参数趋势。正式评测编译时不定义USE_SIMULATOR即可两套代码并存不用删除任何一行。5.2 打点测时序别靠眼睛判断延迟如果程序表现迟钝先确认是算法问题还是调度问题。在主循环各任务的入口和出口打印时间戳一次就能找出耗时大户。uint32_t t0 HAL_GetTick(); Controller_Step(); LOG(ctrl step time%lu, HAL_GetTick() - t0);这个日志配合时间片调度表能快速判断某个任务是否超过分配周期。超过就说明函数内部存在阻塞优先检查里面有没有HAL_Delay。5.3 用git tag标记每个验证通过的版本每跑通一个功能立即提交代码并打上清晰的标签例如git tag v0.9_pid_ok。参数标定完成后把参数更新进hw_config.h再打一个tag。之后所有新改动都基于这个tag进行万一改坏第一时间回到最近可用的状态。电赛现场没有时间细查每次改动的difftag是成本最低的回退锚点。把打点日志和tag配合起来出问题先看日志定位耗时再回退到最近的可用tag比现场一行行改代码快得多。本文还有配套的精品资源点击获取