简介面向参加电子设计竞赛、尤其是备战2024年电赛H题的嵌入式学习者这份代码包提供了一套基于32位微控制器从工程文件看为STM32F10x系列的完整实现参考可用于理解赛题系统架构、外设驱动编写与工程组织方式。压缩包共254个文件约8.49MB以C源文件与头文件为核心配套汇编启动文件、Keil工程配置、编译中间文件及map映射文件另有少量说明网页与批处理脚本基本覆盖从源码到编译产物的完整链路。已有787人学习说明其在赛题复盘与方案对照中具有一定参考价值。读者可从中梳理定时器、ADC、I2C、CAN等外设的初始化与调用关系观察中断处理、数据处理和模块分层写法并借助工程配置与映射文件排查链接、内存占用等问题适合作为电赛备赛阶段的对照样本和二次开发起点。1. 备赛最后一个星期最容易让人当场懵的往往不是算法不会而是细节没对齐去年封箱前一晚我看到旁边工位的小车在直道上跑得很顺一进弯道就原地打转查到最后发现是编码器四倍频的换算系数写成了两倍。这种事在电赛里太常见了PID 原理谁都会背代码一烧进去就开始出现各种说不清的问题。这份「24电赛H题代码20241020」是一套把细节都对齐过的完整工程底层用 STM32 HAL 库打底往上依次是编码器电机闭环、多路灰度循迹、姿态解算、OLED 现场显示和串口在线调参跑通之后可以直接当二次开发的底座。适合两类人一是刚拿到题目、还没搭起框架的参赛队二是想把控制链路吃透、以后还要改题型的选手。下面按我拆包的实际顺序从工程骨架一路讲到改造点。2. 工程骨架解压之后先看的四层目录和编译链路拿到一个压缩包我的习惯是先不读 main.c而是先看目录分层和工程文件因为这一层决定了「这包能不能直接编译」也决定后面改代码时会不会在无关的目录里浪费大量时间。这份工程的目录结构属于 CubeMX 生成后人工二次分层的典型做法把自动生成的代码和业务代码彻底分开好处是以后重新生成底层代码时不会把业务逻辑冲掉。2.1 目录分层与编译链路解压后大致是这样几块H题代码(20241020)/ ├── Core/ │ ├── Inc/ # main.h、外设句柄声明、全局宏 │ └── Src/ # main.c、stm32f1xx_it.c、外设 MX_xxx_Init ├── Drivers/ │ ├── CMSIS/ # 内核头文件一般不动 │ └── STM32F1xx_HAL_Driver/ ├── Hardware/ # 自己写的外设驱动层 │ ├── motor.c/.h # 电机 PWM 方向控制 │ ├── encoder.c/.h # 编码器测速 │ ├── gray.c/.h # 灰度循迹 │ ├── imu.c/.h # 姿态读取 │ └── oled.c/.h # 显示 ├── App/ # 业务层题目相关逻辑全在这 │ ├── control.c/.h # PID 与控制节拍 │ ├── track.c/.h # 循迹决策 │ └── protocol.c/.h # 串口调参协议 ├── MDK-ARM/ # Keil 工程文件与启动文件 └── README.md这个分层方式是我比较推荐的Hardware只管“把某个芯片驱动起来”App只管“题目要它怎么动”Core里除了main.c的初始化和调度其它基本不碰。改题型的时候你只需要动App底层驱动九成可以原样带走。编译链路是 Keil MDK ARMCC工程文件在MDK-ARM下。打开之后先确认三件事目标芯片型号是否和你手上的板子一致、Options for Target → C/C → Include Paths里有没有把Hardware和App两个目录加进去、下载器选的是 ST-Link 还是 DAP。这三条任何一条不对编译能过但下载会失败新手最容易在这卡半天。提示如果换的是同系列但 Flash/RAM 不同的芯片除了改 Device还要在system_stm32f1xx.c和启动文件里对齐容量否则会出现下载成功但一上电就卡死的情况。时钟树这一块工程里用的是外部晶振 8MHz 倍频到 72MHzAPB1 分频 2 得 36MHzAPB2 保持 72MHz。定时器时钟要按这个关系单独算挂在 APB1 上的定时器实际时钟是 72MHz挂在 APB2 上的也是 72MHz这一点在算编码器测速和 PWM 频率时非常关键后面第 3 章会再提。2.2 外设资源分配与引脚表控制类题目最容易出问题的不是算法而是外设抢占两个定时器抢同一个通道、串口和编码器共用中断向量、ADC 采样被 PWM 干扰。先看清资源分配能省掉大量排查时间。外设用途配置要点TIM1 CH1~CH4四路电机 PWM频率 20kHz避免可听噪声TIM2左编码器编码器模式 TI12四倍频TIM3右编码器编码器模式 TI12四倍频TIM4控制节拍中断10ms 一次作为整个系统的时基USART1调试串口 / 调参协议115200 8N1中断接收I2C1OLED 与姿态模块400kHz 快速模式ADC1 CH0电池电压检测分压后采样用于低压告警GPIO灰度阵列、按键、蜂鸣器灰度走输入上拉这张表建议你在自己工程里也照着列一份贴在手边。我见过不止一次因为 TIM2 的编码器通道和某个 PWM 通道冲突导致电机一转编码器计数就乱跳的情况单纯看代码根本看不出来只能对着手册逐个通道核对。main.c里的初始化顺序也有讲究通常是先HAL_Init()再时钟、再 GPIO、再通信外设、再定时器最后才是业务层初始化。之所以把定时器放后面是因为有些驱动初始化时会短暂拉低引脚如果 PWM 已经启动会看到电机抽一下。这个细节在赛场上电机突然抖一下、把小车顶出赛道的情况我是真遇到过。int main(void) { HAL_Init(); SystemClock_Config(); /* 72MHz */ MX_GPIO_Init(); MX_USART1_UART_Init(); MX_I2C1_Init(); MX_ADC1_Init(); Motor_Init(); /* 先占住 PWM 引脚输出 0 占空比 */ Encoder_Init(); /* 编码器定时器 */ Gray_Init(); IMU_Init(); OLED_Init(); HAL_TIM_Base_Start_IT(htim4); /* 最后才开控制节拍 */ Control_Init(); while (1) { App_Loop(); /* 状态机主循环见第 4 章 */ } }这段初始化的逻辑说明Motor_Init()一定要在 PWM 定时器启动之前把占空比写成 0、方向引脚写成刹车状态否则定时器一启动就是默认占空比电机会直接冲出去。HAL_TIM_Base_Start_IT(htim4)放在最后是保证中断第一次进来的时候所有外设和业务变量都已经是就绪状态避免中断里读到未初始化的句柄。3. 核心链路拆解一次控制周期从采样到 PWM 输出的完整路径这一章是整份代码里最值得仔细看的部分。控制类题目的本质就是一条固定节拍的环路采样 → 计算偏差 → 输出 → 再采样。环路上任何一环的时序或量纲错了表现出来都是“调不出来”但原因完全不同。3.1 编码器测速定时器编码器模式与速度换算编码器用的是定时器的硬件编码器模式CH1 和 CH2 接 A/B 相配置成 TI12 双边沿计数相当于硬件自动做了四倍频。这样 CPU 完全不参与计数只在一个固定周期里去读计数器的值读完之后清零或者记录差值。void Encoder_Init(void) { TIM_Encoder_InitTypeDef sConfig {0}; TIM_MasterConfigTypeDef sMaster {0}; htim3.Instance TIM3; htim3.Init.Prescaler 0; /* 编码器模式不分频 */ htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 65535; /* 满量程靠软件求差值 */ htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; sConfig.EncoderMode TIM_ENCODERMODE_TI12; /* A/B 相都计数四倍频 */ sConfig.IC1Polarity TIM_ICPOLARITY_RISING; sConfig.IC1Selection TIM_ICSELECTION_DIRECTTI; sConfig.IC1Prescaler TIM_ICPSC_DIV1; sConfig.IC1Filter 6; /* 滤掉电机换向的毛刺 */ sConfig.IC2Polarity TIM_ICPOLARITY_RISING; sConfig.IC2Selection TIM_ICSELECTION_DIRECTTI; sConfig.IC2Prescaler TIM_ICPSC_DIV1; sConfig.IC2Filter 6; HAL_TIM_Encoder_Init(htim3, sConfig); HAL_TIM_Encoder_Start(htim3, TIM_CHANNEL_ALL); }参数说明里IC1Filter 6是个容易被忽略但很关键的项。电机在换向瞬间会有反电动势毛刺滤波器等级太低时编码器会多计数表现出来就是速度环输出一跳一跳的像发热一样越调越乱。等级太高又会引入延迟建议在 4~8 之间根据实际电机试。测速函数按固定周期调用用差值法而不是清零法好处是不会因为读取时刻和计数器溢出撞上而丢数#define PPR 13.0f /* 编码器线数按实际电机改 */ #define CTRL_DT 0.01f /* 10ms 控制周期单位秒 */ static int16_t s_last_cnt 0; float Encoder_GetSpeedRPM(TIM_HandleTypeDef *htim) { int16_t now (int16_t)__HAL_TIM_GET_COUNTER(htim); int16_t diff (int16_t)(now - s_last_cnt); /* 强转 int16 自动处理回绕 */ s_last_cnt now; /* 四倍频后每转脉冲数 PPR * 4每秒转数 diff / (PPR*4) / CTRL_DT */ return (float)diff / (PPR * 4.0f) / CTRL_DT * 60.0f; }这里的换算逻辑值得单独拆开讲。diff是这 10ms 内增加的计数值因为做了四倍频转一圈对应PPR * 4个计数所以diff / (PPR*4)是这 10ms 内转了多少圈再除以CTRL_DT得到每秒圈数乘 60 就是 RPM。第 1 章提到的那个问题就出在这里把PPR * 4写成PPR * 2速度反馈直接偏大一倍PID 参数按正确值调好之后换到真机上就发散。(int16_t)(now - s_last_cnt)这个写法也是刻意为之的。计数器是 16 位从 65535 变成 0 的时候直接相减会得到一个巨大的正数先做无符号减法再强转成有符号就能正确得到负数等价于处理了回绕。这是不用判断溢出分支的常用写法。3.2 PID 控制器位置式与增量式的取舍工程里速度环和循迹环用的都是位置式 PID只有方向环用了增量式。这个选择不是随便定的位置式输出直接对应 PWM 占空比物理意义清楚调试时看输出值就能知道电机该给多大劲增量式适合执行机构带积分特性的场景比如舵机打角。typedef struct { float kp, ki, kd; float err, last_err, prev_err; float integral; float out_max, out_min; float out; } PID_t; float PID_Calc(PID_t *p, float target, float feedback) { p-err target - feedback; /* 积分分离偏差大时不积分防止起步阶段积分饱和 */ if (fabsf(p-err) 30.0f) { p-integral p-err; /* 积分限幅上限取输出上限的一半左右比较稳 */ if (p-integral 500.0f) p-integral 500.0f; if (p-integral -500.0f) p-integral -500.0f; } else { p-integral 0.0f; } p-out p-kp * p-err p-ki * p-integral p-kd * (p-err - p-last_err); p-last_err p-err; /* 输出限幅对应 PWM 占空比上下限 */ if (p-out p-out_max) p-out p-out_max; if (p-out p-out_min) p-out p-out_min; return p-out; }逐段说明。积分分离那一段是这份代码里比较值钱的地方起步或者目标速度突变的时候偏差可能有几百 RPM这时候如果继续积分积分项会迅速堆到一个很大的值等实际速度追上来了积分还得花很久才能退回去表现就是超调、来回振荡很难收敛。加了fabsf(p-err) 30.0f这个门限偏差大的时候直接不积分只在接近目标时才让积分起作用相当于给积分加了个开关。积分限幅的数值 500 是个经验值一般取输出上限的 30%~50% 比较合适具体要看你的out_max是多少。ki乘上 500 之后如果已经超过out_max说明限幅给大了实际上等于没有限幅。输出限幅必须做而且out_min不一定是-out_max。如果题目要求不能倒车out_min直接设 0如果允许倒车但要求倒车速度慢一些就设成-out_max * 0.6之类。这个不对称限幅在赛场上很实用可以让小车在弯道里更稳。调参顺序我一般是先把ki、kd都置 0只加kp加到电机能跟着目标速度走但明显有静差然后加ki消静差加到静差消失、超调可接受就停最后加一点点kd压超调kd一般不超过kp的十分之一。3.3 循迹与姿态多传感器加权偏差的融合策略灰度阵列用的是常见的 5 路或 7 路数字量传感器输出是一组 0/1。最朴素的做法是用 if-else 判断哪种组合对应哪个位置但这种写法在传感器临界状态会频繁抖动弯道上尤其明显。工程里用的是加权偏差法给每一路传感器分配一个位置权重把所有触发传感器的权重取平均得到一个连续的位置偏差量。/* 7 路灰度权重从左到右为 -3 ~ 3 */ static const int8_t weight[7] {-3, -2, -1, 0, 1, 2, 3}; float Gray_GetError(void) { uint8_t mask Gray_ReadAll(); /* bit0 ~ bit6 对应左到右 */ int sum_w 0, cnt 0; for (int i 0; i 7; i) { if (mask (1 i)) { sum_w weight[i]; cnt; } } if (cnt 0) { /* 全丢线保持上一帧偏差靠记忆往回找 */ return g_last_gray_err; } g_last_gray_err (float)sum_w / (float)cnt; return g_last_gray_err; }逻辑说明sum_w / cnt得到的是一个 -3 到 3 之间的小数比如只有最右边一路触发就是 3.0中间两路触发可能是 -0.5精度比离散的 if-else 高一个数量级。PID 的输入是连续的弯道里输出就会平滑地变化不会一跳一跳。丢线处理是这段代码的另一处关键。全黑或者全白的时候直接返回 0 会让小车突然认为自己在赛道正中方向立刻回正然后冲出赛道。返回上一帧偏差相当于让小车记住“我上次是在往右偏”继续往右找线成功率会高不少。如果想再稳一点可以把灰度偏差和姿态模块的偏航角融合直道上用偏航角做主反馈弯道上用灰度做主反馈用一个根据偏差大小动态调整的权重把两者加起来。这类融合不做也能跑但做了之后过连续 S 弯的差别很明显。4. 时间与任务中断节拍和主循环状态机怎么配合控制类工程最常见的软件结构是把所有事都塞进一个 while(1) 里靠延时凑时序。这种写法在小 demo 里没问题一旦加上串口调参、OLED 刷新、按键扫描就会出现“按一下按键小车抖一下”的现象。这份工程采用的是固定时基中断 主循环状态机的组合。4.1 定时器中断里的控制节拍TIM4 配置成 10ms 中断一次中断里只做时间敏感、执行时间确定的事读编码器、跑速度环、更新 PWM。所有可能阻塞的操作一律不进中断。volatile uint32_t g_tick_10ms 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM4) { g_tick_10ms; /* 1) 采样读编码器得到实际转速 */ g_speed_l Encoder_GetSpeedRPM(htim2); g_speed_r -Encoder_GetSpeedRPM(htim3); /* 右侧电机反向安装取负 */ /* 2) 计算速度环输出 */ float out_l PID_Calc(pid_speed_l, g_target_l, g_speed_l); float out_r PID_Calc(pid_speed_r, g_target_r, g_speed_r); /* 3) 输出换算成 PWM 比较值 */ Motor_SetPWM(MOTOR_L, out_l); Motor_SetPWM(MOTOR_R, out_r); } }这里的执行时间要控制在 1ms 以内10ms 的节拍里留 90% 的余量给主循环。如果以后要在这个中断里加东西先算一下时间一次浮点乘法在 72MHz 的 M3 上没有 FPU大约几十个周期几十次运算就是几百微秒加多了就会挤占主循环。右侧编码器取负这件事也是踩过坑的。两个电机对称安装正转的时候编码器计数方向相反如果不取负速度环看到的是负反馈越调越离谱。判断方法很简单手动把两个轮子都往前转串口打印出来的两个速度值应该同号。4.2 主循环状态机与串口调参主循环负责所有“可以慢一点”的事循迹决策、OLED 刷新、按键扫描、串口协议解析。用一个简单的状态机把这些事串起来typedef enum { ST_IDLE 0, ST_RUN, ST_ADJUST, ST_STOP } app_state_t; void App_Loop(void) { static uint32_t last_oled 0, last_key 0; switch (g_state) { case ST_IDLE: Motor_Stop(); if (Key_Pressed()) g_state ST_RUN; break; case ST_RUN: /* 循迹环20ms 一次比速度环慢避免两个环互相打架 */ if (g_tick_10ms % 2 0) { float err Gray_GetError(); float turn PID_Calc(pid_track, 0.0f, err); g_target_l g_base_speed turn; g_target_r g_base_speed - turn; } if (g_tick_10ms - last_oled 10) { /* 100ms 刷一次屏 */ last_oled g_tick_10ms; OLED_ShowSpeed(g_speed_l, g_speed_r); } break; case ST_ADJUST: Protocol_Poll(); /* 串口调参非阻塞 */ break; case ST_STOP: Motor_Stop(); break; } }关键点是两个环的频率差。速度环 10ms 一次循迹环 20ms 一次让内环比外环快一倍以上这是串级控制的基本要求。如果两个环都是 10ms外环输出的目标速度还没被内环跟上就又被改掉系统会一直处于“追不上”的状态表现出来就是直道上也左右轻微摆动。串口调参用的是非阻塞的环形缓冲中断里只把字节塞进缓冲区主循环里再解析uint8_t rx_byte; uint8_t rx_buf[64]; volatile uint16_t rx_head 0, rx_tail 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx_buf[rx_head % 64] rx_byte; HAL_UART_Receive_IT(huart1, rx_byte, 1); /* 立刻重新挂接收 */ } }HAL_UART_Receive_IT必须在回调里重新挂上否则只能收到一个字节就再也不进了。这个点在 HAL 库里属于必须自己处理的坑CubeMX 生成的代码不会替你重新挂。协议格式我一般用[0xAA][cmd][len][data...][checksum]这种简单帧cmd 区分是设参数、读参数还是启停checksum 用一个字节的累加和。有了这个赛场上改 PID 参数不用重新编译下载直接串口助手发一帧就行能省下大量反复烧录的时间。5. 避坑与排查跑不起来的时候先看这几处下面这几条都是实际调试中真出现过的问题按“现象 → 原因 → 解决”整理。遇到类似表现可以先对照着查比盲目改参数快得多。5.1 电机只朝一个方向转给负速度也没反应现象串口设目标速度为负小车还是往前冲或者干脆停住不动。原因H 桥的方向控制引脚和 PWM 通道不是一一对应的Motor_SetPWM里只改了占空比方向引脚没跟着翻。另一种情况是 PWM 用了互补输出但死区配置不对负占空比被硬件直接截掉了。解决先单独测方向控制——写一个测试函数固定占空比 50%循环切换方向引脚用万用表量电机两端电压正负应该对称。如果不对称检查Motor_SetPWM里方向判断的分支常见错误是把if (pwm 0)写成了if (pwm 0)。另外确认定时器是 PWM 模式 1 还是模式 2模式选反了占空比和电平的关系也是反的。5.2 速度环一加积分就发散去掉积分勉强能跑现象ki从 0 加到 0.5 之后电机开始周期性忽快忽慢越加越明显最后直接堵转保护。原因九成是积分项没有限幅或者限幅值远超过输出上限。另一个隐蔽原因是控制周期和积分系数的量纲没对齐——CTRL_DT是 0.01 秒但算积分的时候用的是integral err相当于把采样周期当成了 1 秒实际积分作用放大了 100 倍。解决把积分改成integral err * CTRL_DT让积分项带上时间量纲ki的物理意义就明确了。同时把积分限幅压到输出上限的 30%~50%并且确认积分分离的门限生效了。改完之后重新从很小的ki开始加。5.3 串口助手改了参数程序里没生效现象按协议发了一帧设置kp的数据回读发现还是老值。原因帧解析里的长度字段被当成了包含帧头的总长度实际只表示数据段长度或者校验和计算时把帧头也算进去了和发送端不一致帧被直接丢弃但没有任何提示。解决把解析函数单独拿出来做单元测试在 PC 上写个模拟发送的脚本把要发的字节打印出来逐字节比对。另外加一个“收到无效帧就回一个 0xFF”的反馈至少能知道帧到底有没有进来。这类问题最难的地方在于它不报错只能靠打印。5.4 换一块板子程序直接卡死连串口都不输出现象下载成功指示灯正常但是串口没输出电机也不动。原因多半是晶振起振失败SystemClock_Config卡在等待 HSE ready 的循环里HAL_Delay也就永远不出来。另一块板子上如果没焊外部晶振或者晶振负载电容不匹配都会这样。解决把SystemClock_Config里的 HSE 等待改成带超时超时后自动切到 HSI并在OLED或者串口上打一行提示。这样至少系统能跑起来你还能通过串口知道问题在哪。另外在HAL_Init()之后把HAL_InitTick的优先级调到最低避免系统节拍被高优先级中断长期阻塞。5.5 循迹在弯道上抖直道正常现象直道上跑得很稳一进弯道小车就开始左右横跳感觉随时会出去。原因循迹环的周期和速度环一样快外环还没来得及观察到偏差变化内环已经执行了好几次或者灰度传感器的安装高度不对多路同时触发导致cnt很大加权平均被拉平偏差量变小。解决把循迹环周期拉到速度环的两倍以上并在循迹 PID 的输出上再加一层低通滤波比如out 0.7 * out 0.3 * last_out。传感器高度调整到只有 1~2 路同时触发的程度最好太多路触发说明装得太高太少说明太低容易磨地。6. 二次开发换题型、换硬件时从哪几个点下手这份代码的价值不在于它能原样跑通某一个题目而在于它把“驱动层—控制层—决策层”这三层的边界划清楚了。真要改的时候改动量和你改的层数基本成正比。换传感器是最轻的改造。比如把灰度换成摄像头循迹只需要在App/track.c里重写Gray_GetError()这个接口返回同样量纲的偏差量上面的 PID 和电机控制一行都不用动。工程里所有模块都是通过.h里声明的接口互相调用的只要保持接口签名不变替换实现就是解耦的。这也是我建议你在自己工程里强制执行的规矩上层不许直接操作寄存器或者 HAL 句柄。换控制对象是最需要小心的。如果题目从小车换成云台或者平衡类反馈量的量纲会变速度环的out_max、积分限幅、CTRL_DT这三个数要一起重新推一遍。我的习惯是在control.h里把所有跟量纲有关的宏集中定义改的时候只改这一处不在各个.c里散落魔法数字。再加一层功能也很方便比如加速度闭环或者航向闭环。工程里的App_Loop是个状态机加一个状态、加一个分支就行不需要重构。串口协议里留一个cmd值给新功能调参通道现成的。改造方向需要改的文件不需要动的部分换循迹传感器App/track.c、Hardware/gray.cPID、电机、状态机换成云台控制App/control.c的参数、Hardware/motor.c的限幅串口协议、OLED增加一个闭环App/control.c新增 PID 实例、App_Loop增加分支底层驱动、初始化换主控芯片Core全部、各驱动的初始化部分App层的业务逻辑验证方法上我一般按三段走先让电机空转串口看编码器读数是否线性和过零正常再接上闭环用手捏住轮子感受反向力矩是否平滑最后才上赛道。跳过任何一段后面出问题都得回头补。说个我自己的教训。早期我总觉得调参是最后一步代码写完直接上赛道结果一旦出问题就在“到底是硬件焊错了还是参数不对”之间反复横跳一天下来什么也没定下来。从那以后我每次改完代码都强制先把车架空跑一遍编码器和 PWM确认采样和输出这两端都是准的再落地调环。这一步只花十分钟但能挡掉大部分说不清的问题。这份工程里那些看起来啰嗦的自检和打印其实就是这么攒出来的。希望帮到你。本文还有配套的精品资源点击获取