每次带学员做训练赛,我都会先让他们做一件事:别急着动代码,先把赛题读三遍,再把评分点画成一张清单贴在屏幕边上。第十一届蓝桥杯嵌入式模拟赛题(二)这道题,我前后带过几轮学员重做,也用来做过内部考核。它看起来是典型的功能综合题,知识点不外乎按键、ADC、PWM、LCD显示这一套,但真正拉开分差的地方往往不在主功能本身,而在几个容易被忽视的细节,比如启动瞬间的ADC跳变、按键边沿处理、界面刷新策略。如果你正在备赛,或者想找一个能系统练手STM32外设的综合性项目,这道模拟题很值得完整走一遍。
下面我按自己的理解,把这道题从框架设计到具体实现拆开讲。文章里的思路和代码风格都是我在实际带训时验证过、用得比较稳的版本,你可以直接参考,也可以在此基础上按自己的习惯调整。
1. 赛题整体拆解:它到底在考什么
1.1 题面核心功能概述
先把题目还原成一个完整的场景。这道模拟题大致描述了一台带显示和交互功能的嵌入式控制设备,核心功能包括:通过电位器调节输出PWM的占空比,占空比数值和实时电压值显示在LCD屏上,同时支持按键切换显示页面、修改阈值参数,当采集值超过设定阈值时产生报警输出(蜂鸣器或LED变化)。串口同步输出设备状态信息,方便上位机监控。
这种设计其实非常贴近真实产品的小型原型:传感器输入(电位器模拟)、控制输出(PWM)、人机交互(按键和屏幕)、通信上报(串口)。评分点一般分布在:
- 基础外设初始化正确,时钟配置无误;
- ADC采集稳定,数据换算合理;
- PWM频率和占空比调节连续平滑;
- LCD显示无闪烁,字符和数字位置正确;
- 按键响应及时,无抖动误触发;
- 阈值比较和报警动作可靠;
- 串口输出格式正确,可被工具正确解析。
这些点单独拎出来都不算高难度,但综合在一起,对工程的规范性要求一下就上来了。很多学员单独写某个外设没问题,一到综合题就翻车,原因基本都出在外设之间的配合、资源冲突和状态管理上。
1.2 为什么说它是“外设全家桶”式的综合题
从训练价值看,这道模拟题覆盖了嵌入式开发里最常用的一批外设资源:GPIO(按键和LED)、ADC(电位器采样)、定时器(PWM或系统时基)、串口(数据上报)、LCD或OLED(显示)。如果你用的是常见的STM32平台,这几乎就是一份“外设交叉使用”的标准练习。
更重要的是,这道题天然要求多外设并行工作。ADC在采样、PWM在输出、LCD在刷新、按键在检测,这些任务如果全都堆在while(1)主循环里裸奔,你会发现响应和显示效果很难同时顾好。你会被迫去思考调度问题:什么操作不能阻塞、什么数据需要缓存、什么逻辑得放到中断里。搞明白这层,比单纯会配置外设要有价值得多。
还有一层很多人忽略:这道题的输入是模拟量,输出是数字量控制的执行器,中间夹着人机交互和阈值判断。它考验的是“把物理世界的变化翻译成数字世界的处理,再回过去影响物理世界”的闭环能力,这也是嵌入式工程师日常工作的核心模型。
2. 硬件平台与基础工程准备
2.1 开发板与核心外设清单
蓝桥杯嵌入式竞赛最常见的硬件平台是STM32系列开发板,配套板载LCD屏、按键、LED和可调电位器。用这套平台来做这道模拟题,你需要确认以下几个部分都能正常工作:
| 模块 | 对应外设 | 关键点 |
|---|---|---|
| LCD屏 | FSMC或SPI接口 | 初始化时序、清屏、字符和数字绘制 |
| 按键 | GPIO输入 | 上拉/下拉配置、硬件消抖 |
| LED | GPIO输出 | 初始电平、与报警逻辑联动 |
| 电位器 | ADC输入 | 参考电压、采样通道、换算比例 |
| PWM输出 | 定时器PWM通道 | 频率稳定、占空比可调 |
| 蜂鸣器 | GPIO或定时器 | 报警输出、音调区别 |
| 串口 | USART | 波特率、数据帧格式 |
拿到板的第一个小时,不要急着写业务逻辑,先做一件枯燥但收益很高的事情:把每颗外设单独用最简单的例程点亮跑通。LCD能显示hello、按键按下能翻转LED、ADC能把原始值打印到串口、PWM能调亮度。这一步全通之后,后面汇合功能时你才有底气判断问题出在自己逻辑还是外设配置上。
2.2 基础工程搭建的三种方案
工程搭建方式因人而异,我见过的基本有三类:
第一种是标准库+HAL库手动建工程,把启动文件、系统时钟、外设驱动都自己加进去。这种方式对底层理解最透彻,但建工程环节耗时,容易踩时钟配置的坑。
第二种是使用CubeMX快速生成初始化代码,再在用户代码区写业务逻辑。这种方式效率最高,生成的初始化代码比较规范,适合把精力花在逻辑设计上。竞赛训练我比较推荐这个做法,注意勾选外设时就把引脚映射确认对,避免生成后大改。
第三种是在现成例程基础上改,比如用开发板厂商提供的工程模板。这种方式最快,但存在隐患:例程里的外设配置可能和你需要的不完全一样,排查问题时容易绕晕。
我个人建议用第二种为主,配合第一种去理解关键配置项。工程不管怎么建,有四个地方必须自己心里有数:系统时钟树怎么分频、GPIO引脚速度设多快、ADC采样周期设多长、定时器分频和周期算出来的PWM频率是多少。这四个地方是后面排查故障的高频区域。
2.3 基础外设初始化参数参考
以STM32平台为例,给出我常用的一组参数供参考:
- 系统时钟:外部晶振,主频按平台最高主频配置,稳定优先;
- ADC:使用独立ADC模块,采样通道对应电位器引脚,采样周期设为尽量长(比如239.5个周期),开启扫描但不需要多通道,12位分辨率;
- PWM定时器:分频后让计数频率在1MHz左右,自动重载值按目标PWM频率算,比如此时输出频率就是计数频率除以重载值;
- 按键GPIO:设置为输入模式,选择内部上拉,打开外部中断或采用轮询扫描,我通常轮询加消抖更稳;
- 串口:波特率115200,8位数据位,1位停止位,无校验;
- LCD:按板载屏幕接口初始化,注意片选引脚和读写时序。
这些参数初始值不一定要精妙,但要可预期。比如ADC采样周期尽量拉长,虽然单次转换速度慢一点,但对这种低频设备采集场景完全够用,换来的稳定性很有价值。
3. 核心功能模块的实现思路
3.1 按键检测:边沿处理比电平判断重要
这道题的按键数量通常是2到3个,承担切换页面、加减参数等功能。按键处理最容易犯的错是直接在主循环里读GPIO电平,然后根据电平去执行操作。这样做的问题在于:一次长按会被当成无数次重复触发,按键抖动会造成状态乱跳。
我建议所有按键都做“边沿检测”而不是“电平检测”。具体做法是:每个按键用一个变量记录上一周期的电平状态,当检测到当前为按下且上一周期为释放时,才认为产生一次有效按下事件。配合10到20毫秒的消抖延时,事件既可以放在轮询循环里判断,也可以放在定时中断里更新状态。
再进一步,可以把按键状态定义成独立的枚举,比如无动作、短按、长按、双击,需要扩展时状态机也不混乱。这道模拟题里按键数量不多,我通常只在循环里做20ms周期的扫描,记下事件标志,主逻辑统一处理,逻辑会很干净。
3.2 ADC采集:原始值到物理量的换算要单拎出来
电位器采集的目标是得到一个0到100%的百分比值,或者映射到0到3.3V的电压值。ADC寄存器读出来的是一个12位原始数,范围0到4095。换算公式很简单:
电压值 = ADC原始值 × 参考电压 / 4096
百分比值 = ADC原始值 × 100 / 4096
这里的坑有几个。第一个是参考电压:如果开发板供电是3.3V,但实际经过稳压后是3.29V或3.31V,换算结果和万用表实测会有偏差。竞赛题一般允许小误差,但如果你追求精确,可以把基准电压设为实测值。
第二个坑是ADC的初始值不稳。上电后第一次转换结果往往偏离实际值,我习惯在初始化后连续采样几次并丢弃,让采样通道稳定后再参与计算。
第三个坑是单次采样会有噪声波动,显示在屏幕上的数值会跳来跳去。解决办法是多次采样取平均,或者加一阶低通滤波。低通滤波代码很简单:
filtered = filtered * 0.8 + new_sample * 0.2;这个系数需要根据采样频率调,采样越频繁,滤波效果越平滑。带学员时我让他们先看原始值和滤波后值在串口上的差异,理解“显示稳定”这个目标是怎么来的,比直接告诉他们套滤波公式更有用。
3.3 PWM输出与占空比调节
PWM部分的目标是输出频率固定、占空比随ADC采样值变化的方波信号。控制的物理对象一般是LED亮度或蜂鸣器音调。用定时器的PWM输出模式,设置比较寄存器的值改变占空比即可。
要特别注意的是PWM频率的选择。如果控制LED亮度,几百赫兹到几千赫兹都可以,太低会看到闪烁,太高调光效果非线性不明显。如果控制蜂鸣器,频率决定了音调,一般用几kHz到几十kHz。还需要考虑到定时器是有限位数的,如果重载值太大,占空比调节步进就变细但定时器溢出中断频率变高,CPU负担增加。
我的参考配置:控制LED亮度时,PWM频率约1kHz,定时器计数频率1MHz,自动重载值1000。这样占空比步进就是0.1%,调节非常平滑。占空比与ADC换算可以直接映射:占空比百分比就是ADC百分比值,逻辑直接。
另一个细节是占空比突变带来的视觉或听觉冲击。如果你把电位器从0快速转到100%,PWM占空比瞬间跳到最大,LED亮度会猛变,蜂鸣器突然响会很刺耳。实际产品里会做渐变或限幅,这道模拟题如果要求不高可不做,但加一段简单的限幅过渡会让整体表现更“专业”。
3.4 LCD显示与界面刷新策略
LCD显示这部分,很多人能显示内容,但显示效果很糟。核心问题是刷新方式不对。常见错误是每次数据变化就把整个屏幕清屏重绘,结果是屏幕肉眼可见地闪烁,而且CPU全部耗在绘图上。
正确思路是把界面拆成静态部分和动态部分。静态部分包括标题、单位、提示文字,只在界面切换或初始化时绘制一次。动态部分只有变化的数字,每次更新时只重画数字区域。比如显示电压值,先用背景色填充原来的数字区域,再在新的位置绘制本次数值,就不会闪。
多页面切换时,建议每个页面定义一个独立的绘制函数,并记录当前页面ID。页面ID变化时才调用对应绘制函数完成全量刷新,平时只根据页面ID刷新各自的数据区域。这个策略在处理模拟题里的参数设置页和数据展示页时非常实用。
LCD驱动如果是从底层自己写的,还要注意字符绘制函数是否支持指定背景色,以及数字清除是否方便。如果用的是现成库,基操是熟读绘制接口的参数含义,避免传错坐标导致显示错位。
4. 主逻辑架构与代码组织
4.1 用状态机管理设备工作模式
这道模拟题天然适合用状态机来组织逻辑,而不适合把所有操作平铺在主循环里。设备至少有以下几种状态:
- 数据展示状态:显示当前电压、占空比和状态信息;
- 参数设置状态:显示当前阈值,允许按键修改;
- 报警触发状态:当前电压超过阈值,产生报警动作;
- 自由切换状态:任意状态通过按键切换页面。
状态机的好处是逻辑分支清晰,不会出现“按A键在页面1执行加法但在页面2却变成切换”这种混乱。每个状态下按键的含义可以不同,代码里只需判断当前状态再决定按键动作,扩展性和可读性都大幅提升。
我习惯用一个枚举定义所有状态,主循环每一轮先采集输入(按键事件)、然后根据当前状态机处理事务、最后更新输出(屏幕和PWM)。这套流程固定下来后,新增功能只是往状态机里加一个状态的事。
4.2 一个轻量级调度框架
如果所有任务都在主循环里无条件执行,显示刷新、串口发送、按键扫描、ADC采样互相穿插,时序会非常不稳定。我的做法是把任务按时间片轮询:用一个定时器产生1ms时基,在主循环里根据累计溢出次数驱动不同任务执行。
while (1) { if (timer_1ms_flag) { timer_1ms_flag = 0; key_scan_20ms(); // 每20ms扫描一次按键 adc_sample_50ms(); // 每50ms采集一次电位器 display_update_100ms(); // 每100ms刷新一次屏幕数值 uart_report_500ms(); // 每500ms上报一次串口数据 } }触发间隔可以按需调整。这样做的优势是:时间要求高的任务(比如按键消抖)稳定执行,时间要求低的任务(串口上报)不会挤占CPU;各任务之间不会因为执行耗时不同而互相干扰。实际在竞赛和真实项目中,这个模式都足够好用,而且不依赖RTOS,适合大多数人的习惯。
需要注意定时器中断频率不要太高,1ms比较合理,中断服务函数里只做标志置位,不要处理耗时操作。数据处理全部放到主循环完成,这样可以避免中断与主循环抢资源导致的数据竞争。
4.3 模块化文件划分
写这道题时,我不建议把所有代码堆在一个main.c里。工程规范化能让你在调试时更精准地定位问题。我的文件组织方式:
| 文件/模块 | 职责范围 |
|---|---|
| main.c | 主循环、状态机调度入口 |
| adc.c/h | ADC初始化、采集、滤波、换算 |
| pwm.c/h | PWM初始化、占空比设置 |
| key.c/h | 按键扫描、事件检测 |
| lcd.c/h | 屏幕初始化、绘制函数 |
| uart.c/h | 串口初始化、格式上报 |
| app.c/h | 业务状态机、阈值判断、报警控制 |
这个分层思路很简单:硬件驱动只负责把外设“包装”成函数,业务逻辑只关心调用这些函数,不直接操作寄存器。这样当你在状态机里写判断时,面对的是“adc_get_percentage()”、“pwm_set_duty()”这种语义清晰的接口,而不是一堆寄存器赋值。调试效率高很多,也更容易让新人建立模块化思维。
5. 调试实录与高频问题排查
5.1 问题一:ADC上电后第一笔数据明显跳变
现象:程序启动后,电位器保持在中间位置,但LCD和串口显示的第一个电压值接近满量程,然后才回落到正常值。
排查过程:先用串口把每轮采样的原始值打印出来,发现跳变只发生在启动后第一轮。原因基本可以确定是ADC外设上电后内部采样保持电容还未稳定,第一次转换结果异常。
处理方式:初始化ADC后连续开启几次转换但不保存结果,等待几个周期的稳定时间,然后再开始正常采集。代码上可以简单在初始化函数末尾加几次无效采样,或者在首次读取时连续丢弃三次数据。这个问题几乎所有用ADC的工程都有可能遇到,提前处理能省掉很多后续排查的麻烦。
5.2 问题二:按键按下后偶尔触发两次操作
现象:短按一次按键,屏幕上的参数却加了两档,或页面切了两次。
排查过程:按键状态日志显示,一次物理按下的过程里产生了两个有效事件。原因是主循环扫描频率太快,抖动期间的多个高电平被当成多次边沿处理,消抖时间不够。
处理方式:把消抖时间从传统意义上的10ms拉长到20ms左右,并且消抖期间采用“连续读到稳定电平才确认”的方式,而不是简单延时后读一次。另外要确认按下和释放两条边沿是否都计为事件,这道题里我通常只处理按下边沿,释放只在需要长按判定时才使用。
5.3 问题三:LCD刷新时数字残影
现象:电压值从12.3变成9.8后,屏幕上的数字看起来有重影,像旧的“2”和“3”没有完全擦掉。
排查过程:查看绘制接口后发现,数字绘制函数没有填充背景色,新增字符只覆盖了原有字符的笔画区域,而不同数字的宽度不一致,所以残留了边缘像素。
处理方式:在绘制动态数字前,先调用背景色填充函数把目标区域整体清一下,再绘制新数字。如果库函数支持指定自动背景,把背景色参数设置成和屏幕底色一致即可。另一个办法是先用固定数量的空格覆盖原位置,但要注意中文字符宽度和ASCII宽度不一样,容易踩坑。
5.4 问题四:串口工具收到乱码或丢帧
现象:串口显示内容大部分正确,但偶尔出现个别错字符,或一条数据被拆成两段。
排查过程:先检查波特率是否完全一致,平台常见的坑是外部晶振频率配置与实际板子不一致,导致时钟树算出的串口波特率偏差超限。确认波特率无误后,再看发送方式:如果直接在中断服务函数里发送长字符串,会阻塞中断导致丢帧。
处理方式:串口发送统一使用阻塞式发送放在主循环里调用,或使用发送缓冲区加中断发送机制。对这道模拟题来说,把上报放在时间片里串行发送帧格式字符串,完全够用,还不会打乱其他任务的时序。
5.5 问题五:状态机在高频报警时界面卡住
现象:电压超过阈值后,报警LED闪烁,蜂鸣器鸣叫,但此时按键切页无响应,调整电位器也不能及时退出。
排查过程:报警逻辑里存在阻塞延时,比如用delay_ms(500)控制蜂鸣器的响停节奏,这个延时让主循环整个卡死,按键扫描和ADC采集全都停了。
处理方式:报警节奏不要用阻塞延时实现,改成基于时基的非阻塞判断。在时间片框架里记录上一次翻转的时刻,达到500ms间隔时才翻转一次状态。这样报警动作和主循环调度互不干扰,界面始终可操作。
5.6 调试工具与技巧补充
我调试时一定会开的几个辅助手段:
- 串口打印开关宏:开发阶段打开打印关键状态,比赛或正式运行时关掉,避免影响时序;
- LCD左上角常驻一个运行计数:观察主循环是否持续运行,程序卡死一眼就能发现;
- 按键事件整形输出:把按键状态机的所有事件分类打印,比看实物现象更容易定位逻辑问题;
- 示波器或逻辑分析仪检查PWM输出波形:确认频率和占空比是否和代码计算结果一致。
这些手段都不复杂,但能在调试阶段帮你把“现象异常”和“代码逻辑异常”快速解耦。很多新手一看到现象不对就直接翻代码,结果对着屏幕盯了半小时也没头绪。先借助工具确认输入输出是否正常,往往能更早发现问题。
6. 个人实操中的一点体会
这道模拟赛题这两年我反复让不同基础的学员做,最明显的分水岭不在于谁的外设初始化背得熟,而在于是不是能有一种“全局掌控”的感觉。每次拿到综合题,先画出功能框图,标注数据流向,再明确哪些量由谁产生、被谁消费、以什么节奏更新,工程实现起来会顺畅很多。
另外建议大家练习时亲手搭一套稳定的时间片调度框架,哪怕以后不做竞赛,进入实际项目开发也会发现这套思路非常通用。最后一个小建议:比赛状态下时间紧张,不要追求把每个外设的功能都写到“最完美”,先把基础功能稳定跑通,再逐步加分。稳定通过全部基本评分点,往往已经超过相当一部分选手了。