打造可维护的2024电赛H题代码:STM32模块化与PID控制
2026/9/15 17:50:43 网站建设 项目流程

简介:面向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" /* 电机PWM:TIM1_CH1,16MHz时钟 */ #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 #endif

MOTOR_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/(999+1)=16kHz
Pulse0初始占空比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,这一次是3,int16_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] speed=1520 target=1500”,肉眼就能判断控制是否在收敛。中断服务函数里不要调用这个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设成0,Kp从很小的值开始加,每次增加后给一个阶跃输入,观察输出。直到系统出现等幅振荡,记录这时的Kp作为临界增益。 2 把Kp设成临界增益的0.6倍,开始加Ki。Ki从很小的值开始,每次增加后观察稳态误差是否变小。如果出现低频波动,说明Ki过大。 3 在Ki基本稳定后加Kd,Kd用于抑制超调,从很小值开始加。如果输出出现高频毛刺,说明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(); #endif

Simulator_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进行,万一改坏,第一时间回到最近可用的状态。电赛现场没有时间细查每次改动的diff,tag是成本最低的回退锚点。把打点日志和tag配合起来:出问题先看日志定位耗时,再回退到最近的可用tag,比现场一行行改代码快得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询