☰
蓝桥杯嵌入式省赛STM32驱动实战:状态机、ADC与PWM避坑指南
2026/10/20 19:12:03 网站建设 项目流程

简介:第十五届蓝桥杯嵌入式省赛真题解析包,面向备赛全国软件和信息技术专业人才大赛嵌入式设计赛项的考生及指导教师,旨在解决真题理解、代码复现与系统调试中的常见痛点。包内共1338个文件,以689个C源文件、289个头文件、97个汇编文件为主体,辅以TXT说明、Keil工程配置(uvprojx/uvoptx)、链接脚本和可烧录HEX固件,完整覆盖从工程创建、外设配置到固件生成的开发链路,压缩包整体38.92MB,目录结构清晰便于按模块检索。内容重点涉及嵌入式系统组件、C/C++底层编程、RTOS实时调度、硬件调试方法及物联网通信扩展,与智能家居、环境监测等典型省赛场景紧密贴合。结合真题解析和源码注释,可快速定位关键外设初始化、中断处理与低功耗设计等得分点。已有8506人学习下载,适合需要系统备赛、对照真题练手和排查设计思路的中高级竞赛选手。

1. 第十五届蓝桥杯嵌入式省赛到底在考什么:题型结构和备赛主线

很多人以为嵌入式省赛拼的是算法和板子外设数量,实际上第十五届省赛真正筛人的点,是「在规定时间内把硬件驱动写得足够稳」。备赛群里最常见的翻车不是程序跑不通,而是现场环境一变,按键抖动没消、ADC 读数漂了、串口打印被中断抢占打乱,直接丢掉客观题之外的大半分。这个比赛考的不只是你会不会点灯,而是你在 4 小时内写出来的代码,能不能在陌生的评审板上照样跑得稳。

先说清楚整体结构:省赛分客观题和程序设计题两块。客观题考的是电路基础、处理器架构、外设原理和 C 语言功底;程序设计题则给一套固定的嵌入式竞赛板,要求你基于 HAL 库完成指定功能。第十五届省赛的板载资源包括按键、LED、LCD、电位器、传感器接口和 PWM 输出等,题目一般会要求你在这些外设之间做联动,而不是单测某一个模块。适合的人群很明确:学过 STM32 基础、想短时间冲刺拿奖的人,以及准备下一届想提前摸清题路的人。本文从硬件驱动、数据采集、输出控制、避坑到考场验证逐一拆开讲,保证你看完能直接照着写代码。

2. 按键和 LED 的状态机驱动:比赛代码的骨架

2.1 为什么状态机比简单延时扫描更靠谱

嵌入式省赛的程序设计题基本都绕不开按键。有的题目要求短按切换界面,长按进入设置,双击确认修改,这些操作如果靠在主循环里 delay 一下再判断电平,现场评审时十有八九会翻车。原因在于:主循环里一旦有 LCD 刷新、ADC 转换或者串口打印,按键扫描的周期就会变得极不稳定,松手检测经常漏掉,轻则界面乱跳,重则直接把数据改错。

我一般会把按键模块写成独立的状态机,用定时器中断固定每 5ms 扫描一次。状态机只认三种状态:按键释放、按键按下、按键确认触发。触发逻辑靠边沿而不是电平,这样就算你按下 100ms 还是 300ms,只会产生一次有效动作。稳定性靠的是固定节奏,不靠延时,这是工程做法和竞赛做法的本质区别。

typedef enum { KEY_RELEASE, KEY_PRESSED, KEY_TRIGGERED } KeyState; KeyState key_state = KEY_RELEASE; uint8_t key_filter_cnt = 0; void Key_Scan(void) { uint8_t key_level = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin); switch (key_state) { case KEY_RELEASE: if (key_level == 0) { // 检测到低电平,进入按下确认 key_filter_cnt++; if (key_filter_cnt >= 3) { // 连续 3 次扫描都是按下,才算真按下 key_filter_cnt = 0; key_state = KEY_PRESSED; } } else { key_filter_cnt = 0; } break; case KEY_PRESSED: if (key_level == 0) { key_state = KEY_TRIGGERED; // 边沿触发,一次按下只产生一次事件 KEY_Event = KEY_PRESS_EVENT; } else { key_state = KEY_RELEASE; } break; case KEY_TRIGGERED: if (key_level == 1) { key_state = KEY_RELEASE; // 等待释放,回到初始状态 } break; } }

代码逻辑里最值得注意的地方是消抖阈值key_filter_cnt >= 3。按 5ms 扫描周期算,连续 3 次有效低电平意味着按键稳定按下至少 15ms,机械按键在这段时间内基本已经完成抖动;而按下去之后的边沿事件只派发一次,长按和短按的区分可以继续依靠计时状态扩展,不会影响其他模块。触发事件用全局变量KEY_Event派发,主循环只在事件发生时响应,这样 LCD 刷新再慢也不会把按键事件丢掉。实际使用时要把Key_Scan()放进定时器中断回调里,确认中断优先级高于主循环业务代码。

2.2 LED 状态刷新:用查表法替代一堆 if-else

省赛题目里 LED 的用法一般有两种:一种是直接指示某个系统状态,比如报警亮、正常灭;另一种是配合按键切换显示模式,LED 的亮灭组合代表当前处在第几档。第二种用法如果写一堆if(mode == 1) {LED1_ON; LED2_OFF;},一旦档位多了,代码会膨胀到没法读,而且改需求时容易漏改某一个分支。

我的做法是把每个 LED 的开关状态压进一个字节,用数组做状态映射表。假设板子上有 4 个 LED,bit0 对应 LED1,bit1 对应 LED2,以此类推。每档模式对应一个索引值,查表一次搞定全部 LED 刷新。

const uint8_t led_mode_table[] = { 0b0000, // 模式 0:全灭 0b0001, // 模式 1:LED1 亮 0b0010, // 模式 2:LED2 亮 0b0101, // 模式 3:LED1 + LED3 亮 0b1111 // 模式 4:全亮 }; void LED_Update(uint8_t mode) { uint8_t led_state = led_mode_table[mode]; HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, (led_state & 0x01) ? GPIO_PIN_RESET : GPIO_PIN_SET); HAL_GPIO_WritePin(LED2_GPIO_Port, LED2_Pin, (led_state & 0x02) ? GPIO_PIN_RESET : GPIO_PIN_SET); HAL_GPIO_WritePin(LED3_GPIO_Port, LED3_Pin, (led_state & 0x04) ? GPIO_PIN_RESET : GPIO_PIN_SET); HAL_GPIO_WritePin(LED4_GPIO_Port, LED4_Pin, (led_state & 0x08) ? GPIO_PIN_RESET : GPIO_PIN_SET); }

注意这里用的是GPIO_PIN_RESET表示亮,因为竞赛板上的 LED 大多是低电平驱动,阳极接电源、阴极接引脚。如果你拿到的是高电平驱动板子,把条件反过来即可。查表法的好处不只在于代码短,更重要的是模式增加时只需要改数组,不用动刷新函数。评审老师看代码时,这种写法比一整串 if-else 好解释得多。

3. ADC 多通道采集与数据换算:省赛拿分的关键模块

3.1 ADC 采样不稳定的根源在哪里

第十五届省赛程序设计题里大概率会出现电位器或者传感器输出电压采集,要求把模拟量显示在 LCD 上,或者根据电压值控制 PWM 输出。很多人在这一步丢分,不是因为不会初始化 ADC,而是采集出来的数值在屏幕上不断跳动,看起来就像没做滤波一样,直接被扣了可靠性分。

ADC 读数跳动的来源主要有三个:一是芯片内部参考电压本身有微小波动;二是采样引脚外围电路有阻抗,采样瞬间电容充放电造成电压跌落;三是主循环里连续做多次转换,每次转换之间没有间隔,内部采样电容来不及完全建立电压。第三个原因最容易被忽视,很多人习惯在一个循环里连读五六次然后取平均值,觉得这样总该稳了,实际上连续转换读出来的值是强相关的,平均之后噪声只降低了一点点。

3.2 一个可靠的多通道 ADC 读取序列

常见做法是用 DMA 配合定时器触发转换,让 ADC 以固定频率自动采样,主循环只在需要时取最新结果。DMA 的好处是转换结果不占 CPU 时间,而且每通道的数据会固定存在数组的固定位置,不会出现读取竞争。

#define ADC_CH_NUM 4 uint16_t adc_raw[ADC_CH_NUM]; // DMA 搬运目标,顺序对应 4 个通道 void ADC_Start_DMA(void) { HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_raw, ADC_CH_NUM); } float ADC_Get_Voltage(uint8_t ch) { uint32_t sum = 0; const uint8_t sample_cnt = 8; for (uint8_t i = 0; i < sample_cnt; i++) { sum += adc_raw[ch]; // 读取当前最新一轮的采样值 HAL_Delay(2); // 间隔 2ms,让采样电容充分恢复 } float voltage = (float)sum / sample_cnt * 3.3f / 4095.0f; return voltage; }

HAL_ADC_Start_DMA启动后,DMA 会持续把转换结果搬到数组里,每通道每轮都会刷新。CPU 读到的一定是最新值,但如果在两次转换之间反复读,数据变化幅度就会叠加噪声,所以ADC_Get_Voltage里做了 8 次分散读取再平均。这里的 2ms 间隔不能省,它保证了每次读取时采样电容都有充足时间恢复,8 次读取之间可以穿插 LCD 刷新等操作,不会造成明显的 CPU 阻塞。

参数上注意两点:一是 ADC 分辨率尽量设为 12 位,对应满量程 4095,这样电压分辨率约为 0.8mV,足够大部分题目使用;二是我习惯把参考电压设为 3.3V 内部参考,因为竞赛板的 VDDA 通常比较稳,用 VREF 引脚外接参考反而可能因为跳线接触不良引入噪声。如果你发现某个通道读数明显异常,先查引脚对应的通道号是否配置错误,再查 DMA 缓冲区长度和ADC_CH_NUM是否匹配。

4. PWM 输出和定时器配置:频率、占空比和相位那些事

4.1 省赛里的 PWM 考法不只在灯效

第十五届省赛的 PWM 题点通常藏在两个方向:一是用按键调节输出占空比,控制 LED 亮度或者直流电机转速,并把当前占空比显示在 LCD 上;二是要求输出特定频率的方波信号,比如用示波器量到 1kHz 整。前一种只要配置通用定时器输出比较通道,后一种则需要精确设置预分频系数和自动重载值,一步算错,输出频率就和要求对不上。

我见过不少人直接在初学模板上改参数,把Prescaler设成 72、Period设成 1000,以为这样就是 1kHz,实际上算出来是 72MHz / 72 / 1000 = 1kHz,前提是定时器时钟确实是 72MHz。一旦板子的外部晶振不是 8MHz,或者 APB1 定时器时钟倍频关系和教材不一致,这个值就是错的。所以要养成先读HAL_RCC_GetHCLKFreq()再反推参数的习惯,不要死记数值。

4.2 用 TIM3 输出可调占空比 PWM 的完整配置

我一般选用 TIM3 的通道 1,因为竞赛板例程普遍把 PE5 映射到这个通道,不需要额外改复用引脚,评审时不至于对不上板子丝印。

void PWM_Init(void) { GPIO_InitTypeDef gpio = {0}; TIM_OC_InitTypeDef oc = {0}; __HAL_RCC_TIM3_CLK_ENABLE(); __HAL_RCC_GPIOE_CLK_ENABLE(); gpio.Pin = GPIO_PIN_5; gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; gpio.Alternate = GPIO_AF2_TIM3; // PE5 复用为 TIM3_CH1 HAL_GPIO_Init(GPIOE, &gpio); htim3.Instance = TIM3; htim3.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz 计数频率 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 1000 - 1; // 1MHz / 1000 = 1kHz PWM 频率 htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim3); oc.OCMode = TIM_OCMODE_PWM1; oc.Pulse = 500; // 初始占空比 50% oc.OCPolarity = TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(&htim3, &oc, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); } void PWM_Set_Duty(uint8_t duty_percent) { uint32_t pulse = (uint32_t)duty_percent * 10; // 1000 周期对应 0~100% __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, pulse); }

Prescaler设成 71,Period设成 999,这是 HAL 库里的减一写法,对应实际分频值 72 和自动重载值 1000。计数频率 1MHz、周期 1000,自然得到 1kHz 方波。oc.Pulse决定初始占空比是 50%,运行时用__HAL_TIM_SET_COMPARE动态修改。PWM 输出频率不变时,改占空比只是改变比较值,不需要重启定时器,这一点比重新调用HAL_TIM_PWM_Start更稳妥。

4.3 相位对齐和频率微调的一个实操技巧

有的年份省赛出现过双路 PWM 配合的要求,两路波形需要保持固定相位差,比如一路在另一路延时半周期。这里有个坑:两个定时器即使参数完全一样,如果启动时间不同步,它们的计数起点就不一致,相位关系完全随机。解决方法是把两路 PWM 放在同一个定时器的不同通道上,利用同一个计数基准天然同步;如果必须用两个定时器,则先把两路的Period和Prescaler配成一致,再同时调用__HAL_TIM_SET_COUNTER把计数器清零,最后依次启动,相位差就会固定。

另外往届有题要求 LED 渐变,很多人直接在主循环里把占空比从 0 循环加到 100,效果看着还行,但一帧一帧地跳变,非常生硬。比较好的做法是每 20ms 让占空比变化 1%,这样人眼感知到的才是平滑过渡,而且不会因为主循环被 ADC 采集阻塞导致渐变速度忽快忽慢。把渐变步进放到定时器中断里做,主循环只读当前占空比显示到 LCD,逻辑干净得多。

5. 第十五届省赛真题避坑指南:从代码到流程的踩坑记录

5.1 坑一:LCD 刷新占用了串口打印导致调试假死

现象:程序功能看起来正常,但串口调试信息打了几条就再也不动了,按键也没反应,复位之后又重复同样的问题,百思不得其解。

原因:LCD 刷新函数里用了耗时的循环清屏和逐字节发送,正好和串口中断抢 CPU 时间。我遇到这个情况是在某届模拟练习里,LCD 用 SPI 刷一屏大约 30ms,串口中断优先级低,打印缓冲被覆盖,中断标志位一直没清,系统卡死在某个中断回调里。

解决:把串口优先级提到比 LCD 刷新所在的中断更高,并且每次打印前判断上一次发送是否完成。更稳妥的办法是设计一个环形缓冲区,打印内容只入队不阻塞,实际发送交给串口空闲中断或 DMA 完成,主循环只在缓冲为空时才去刷 LCD。比赛答题时优先保证功能稳定,调试打印可以留到最后再清掉。

5.2 坑二:按键事件在长按时重复触发

现象:题目要求长按 2 秒进入设置界面,实际长按之后又弹回去了,或者短按偶尔变成两次操作,逻辑乱成一团。

原因:我最初的状态机只在释放时返回初态,没有在触发后锁定,长按期间定时器中断每次扫描都满足按下条件,如果触发判定写成电平判断而不是边沿判断,就会重复派发事件。按键抖动叠加长按计时,短按检测也会错位。

解决:在状态机里加一个触发后锁定阶段,只有检测到释放沿才回到KEY_RELEASE。长按事件则另开一个计时变量,达到阈值后只派发一次。另外长按和短按的区分要放在同一个状态机里处理,不要在KEY_TRIGGERED里反复判断电平,那样等于又回到了电平触发的旧逻辑里。

5.3 坑三:ADC 的 DMA 缓冲区长度和通道数不匹配

现象:只开了 3 个 ADC 通道,DMA 却配了 4 个缓冲位置,采集到的第 4 个值永远不变,或者第一个通道的值偶尔跳到满量程。

原因:DMA 搬运长度是 4,但 ADC 规则组只转换 3 个通道,转换序列结束后第 4 个 DMA 请求永远不触发,缓冲区里最后一个值停滞。更隐蔽的是某些芯片的规则组转换顺序是从后往前排的,你的通道顺序和代码里数组顺序不一致,值全部错位。

解决:把 DMABufferLength和 ADC 通道数严格对应。另外养成打印每个通道原始值的习惯,确认数组下标和通道映射关系。规则序列配置用sConfig.Channel、sConfig.Rank来指定,Rank 从 1 开始,不要从 0。遇到错位时先检查 Rank 赋值,再检查 DMA 写到数组首地址的方向是哪一边。

5.4 坑四:外部晶振频率判断失误导致波特率偏差

现象:串口打印偶尔出现乱码,整个系统跑起来感觉慢半拍,LCD 刷新速度明显比预期慢。

原因:某块板子用的外部晶振是 8MHz,但代码里HSE_VALUE被改成了 25MHz,系统时钟实际配置错得离谱,所有外设参数全部失真。省赛现场发的板子不一定和你练习用的完全一致,这个参数要是没核对,后面全是玄学问题。

解决:比赛开始先花两分钟读一遍板子丝印,确认晶振频率再写工程。stm32f1xx_hal_conf.h和stm32f4xx_hal_conf.h里的HSE_VALUE必须和原理图一致。养成在初始化后读取HAL_RCC_GetSysClockFreq()并用串口打印的习惯,一个简单的波特率校验能救回一整场比赛。

5.5 坑五:按键扫描放主循环导致状态切换闪烁

现象:短按一次按键,LCD 界面切换了两次,或者从界面 A 跳到界面 B 又立刻弹回 A。

原因:主循环里同时放了Key_Scan()和 LCD 刷新,按键事件处理在刷新之前检查了一遍,刷新完成后又因为扫描周期恰好到点被处理了一遍。本质上是按键状态机在主循环里执行周期不稳定,两个不同的执行点都满足触发条件。

解决:按键扫描固定放到定时器中断里,主循环只查询全局事件标志,处理完立刻清除。这样无论主循环执行到哪个位置,按键事件的检测频率都是固定的 5ms 一个周期。界面切换逻辑要保证一次事件只改变一次状态,状态切换之后不要再落到同一个分支里继续触发。

6. 考场上的最后 30 分钟:一套自检序列和代码骨架

比赛最后的半小时永远不稳定,与其反复改业务逻辑,不如把手里的代码按一套固定序列验证一遍。我是从第三次模拟开始固定用这组自检流程的:先把串口波特率设为 9600,开机打印SYSTEM OK;然后用按键短按切换几个界面,确认 LCD 刷新不粘连;再旋转电位器观察 ADC 电压显示是否连续变化;最后把 PWM 输出接到自己带的小示波器探头或者用逻辑分析仪看频率和占空比。每项检查通过后,在代码里打一个分号注释标记,哪一个没过就立刻定位到对应模块。

void System_SelfCheck_Print(void) { printf("SYSTEM OK\r\n"); printf("VDD=%.2fV\r\n", ADC_Get_Voltage(0)); printf("PWM_FREQ=%luHz\r\n", (unsigned long)(HAL_RCC_GetPCLK1Freq() / 7200000 / 72 / 1000 * 1000000)); }

这段打印本质上不是功能代码,而是让评审官看到你在有意识地验证系统状态。HAL_RCC_GetPCLK1Freq()读到的数值在竞赛板上通常接近 72MHz,如果差得远,说明时钟链有问题,这时候别去调业务代码,先修时钟树。PWM 频率那一串除法写得很直白,一次打印就知道定时器分频配对了没有,比自己心算快得多。

最后我想说的是,蓝桥杯嵌入式省赛的真题,题目年年换面,但骨子里的考察点从来没有变过:状态机是否稳、数据转换是否精确、临界条件是否考虑到位。我见过很多代码写得很漂亮的人最终挂在最简单的初始化遗漏上,也见过状态机写得并不花哨却全套功能一次通过的同学。这比赛的教训就是:技术上的亮点永远只对稳定系统有意义,不稳定等于没做。希望这些踩过的坑能帮你少走一段弯路,祝你在赛场上顺顺利利。

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

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

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

立即咨询