资讯动态

嵌入式竞赛模拟题二复盘:多外设集成与系统设计实战

发布时间:2026/10/17 1:27:08 来源:尧图企业网站定制
拿模拟题二练手那天我原本的预期是“把模拟题一里的外设换一换再添几个功能”。真正做完才发现这套题考的不再是某个外设怎么初始化而是当ADC、按键、PWM、EEPROM、LCD、串口同时堆在一块儿跑时你怎么把代码组织得不出乱子。这篇文章是我完整做完这套模拟题之后的复盘从需求拆解、系统结构设计、关键模块实现到调试阶段踩过的几个坑一条线讲清楚希望能给正在备赛的朋友一些参考。1. 赛题需求拆解先别急着写代码先画一张状态表很多备赛同学拿到题目后的第一个动作是打开CubeMX点引脚这方向其实反了。嵌入式赛题和算法题不一样它的功能描述往往是一大段文字包含好几个界面、多个按键动作、若干外设联动。你不先把需求整理成表写代码的时候一定会东改一下西补一下最后状态判断乱成一团。1.1 模拟题二的功能需求还原我按自己练习时使用的一套近似题目说明来展开核心模块大体是这样的系统上电后在TFT LCD上显示主界面内容包括运行时间、两路ADC电压值、当前PWM频率与占空比。四个按键分别承担界面切换、参数加减、确认保存功能。两路ADC通过电位器提供0到3.3V输入信号任意一路超过2.5V时蜂鸣器报警。参数设置界面里可以调节PWM输出频率和占空比确认后写入EEPROM重新上电自动恢复。另外通过串口每秒钟输出一次系统状态。把这段文字转成表格功能才会清楚模块行为要求主要考察点LCD显示主界面显示时间、ADC电压、PWM参数参数界面显示可调数值界面切换、格式化显示按键KEY1切换界面KEY2/KEY3调参数KEY4保存消抖、短按长按区分ADC两路电压实时采集并显示超阈值触发报警多通道采样、滤波、阈值判断PWM频率1kHz到20kHz可调占空比0到100%可调定时器PWM参数动态更新EEPROM保存参数重新上电恢复I2C读写、数据校验串口每秒输出一次系统状态printf重定向、状态打印为什么我说这套题的核心是集成因为单个模块你单独写都能跑但把它们放在一个系统里资源怎么分配、事件怎么传递、显示怎么刷新、数据怎么保存这些问题才会真正暴露出来。做模拟题一时可能一个main函数从头写到尾还凑合到模拟题二这种集成度那种写法基本会把自己绕晕。1.2 从题目文字到状态转移表我的做法是先把界面画出来再定义每个按键在每个界面下干什么。这个表看起来简单但它是后面所有代码的“宪法”。界面KEY1KEY2KEY3KEY4主界面进入参数设置界面无无无参数设置界面返回主界面选中项加一选中项减一保存参数到EEPROM保存提示状态任意键返回主界面---界面之间怎么跳转、按键在哪个界面有效全部在表里定死。写代码的时候不需要在脑子里反复琢磨逻辑对着表写状态机就行。这个习惯也是我从之前参加竞赛培训时某位导师反复强调的现在成为我做任何带界面交互项目的固定流程。1.3 硬件资源盘点做完状态表才开始看硬件资源。以我当时手头的竞赛开发板为例主控是STM32G431系列板载TFT LCDSPI接口、四个按键、两个电位器、若干LED、一个蜂鸣器、I2C接口的EEPROM、一个调试串口。这套东西基本覆盖了省赛九成以上的外设组合。我整理了一份引脚分配表注意这个表是示例不同型号的板子引脚映射可能不同用的时候按自己手头的原理图调整功能引脚备注KEY1~KEY4PB0~PB3GPIO输入上拉/下拉按原理图配置LED1PC13系统运行指示LED2PC14报警指示蜂鸣器PC15报警输出电位器1PA4ADC1通道4电位器2PA5ADC1通道5LCD_SCKPB13SPI时钟LCD_SDAPB15SPI数据LCD_DCPB1数据命令选择EEPROM_SCLPB6I2C时钟EEPROM_SDAPB7I2C数据PWM输出PA8定时器通道输出串口TX/RXPA2/PA3调试信息输出这块表的价值在于在写任何一行代码之前你已经知道哪个定时器做PWM、哪个ADC通道接哪个电位器、I2C挂在哪个总线上CubeMX里点引脚的时候不用现场翻原理图省出不少时间。2. 系统方案设计状态机与模块化不然后面必乱模拟题二这类集成题合理的系统架构比某个具体寄存器配置更影响成败。我见过不少同学功能单独调都正常一合起来就跑飞问题就出在事件传递和资源管理上。2.1 界面状态机枚举是你的好朋友界面切换用一组枚举值管理比用数字常量清晰得多。代码里这么定义typedef enum { PAGE_MAIN 0, PAGE_SETTING, PAGE_SAVE_TIP, PAGE_MAX } PageIndex_t; static PageIndex_t currentPage;每次按键产生事件后只在事件处理函数里修改currentPage其他模块如果需要根据页面做不同显示读到这个值去判断就行。这样界面跳转的逻辑集中在一起不会散落在各个按键处理分支里。2.2 按键事件不要在中断里处理逻辑按键扫描我放在定时器中断里只做“采集”不直接改界面状态。中断里定义一个事件变量把按键动作翻译成事件主循环里再消费这个事件。typedef enum { EVT_NONE 0, EVT_KEY1_CLICK, EVT_KEY2_CLICK, EVT_KEY3_CLICK, EVT_KEY4_CLICK, EVT_KEY2_LONG_PRESS, EVT_KEY3_LONG_PRESS } KeyEvent_t; volatile KeyEvent_t keyEvent; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { key_scan(); // 10ms周期扫描检测按键并更新 keyEvent } }主循环里这样处理while (1) { if (keyEvent ! EVT_NONE) { handleKeyEvent(keyEvent); keyEvent EVT_NONE; } // 后续其他任务…… }这里有个细节很多人会忽略事件变量要用volatile修饰因为它在中断里被写入、在主循环里被读取否则编译器优化后读到的可能是缓存值。别看这是个小事我就在这上面栽过跟头后面调试章节细讲。2.3 代码目录组织不要一个main.c写几百行模拟题一时我也习惯把代码全堆在main.c里反正功能少翻起来还能找到。到模拟题二这个代码量就必须分层了。我当时按功能把代码拆成了下面几个文件这个习惯后来一直带到了实际项目中App/ ├── main.c # 主循环、系统初始化 ├── bsp/ │ ├── bsp_adc.c # ADC多通道采集 │ ├── bsp_key.c # 按键扫描 │ ├── bsp_lcd.c # LCD底层驱动与显示接口 │ ├── bsp_pwm.c # PWM频率/占空比设置 │ └── bsp_eeprom.c # I2C EEPROM读写 ├── app/ │ ├── app_ui.c # 界面绘制与刷新 │ ├── app_setting.c # 参数设置逻辑 │ └── app_alarm.c # 阈值报警逻辑这么分层有个明确的好处每个文件只要对外提供几个接口函数其他模块不关心内部实现。调PWM时只需要调用pwm_set_freq(10000)不会去翻定时器寄存器。比赛时时间紧这种模块化能大幅降低排查问题的成本。3. 关键模块实现ADC、按键、LCD、PWM、EEPROM逐个搞定这一章讲每个模块实现时最容易踩的坑和更稳妥的写法。我按实际开发顺序来写这样你照着做的时候体验会更顺。3.1 双通道ADC采集与滑动滤波两路电位器采集最省心的方式是ADC1双通道DMA循环采样。初始化时把两个通道配成规则组开启DMA采集到的数据会不停循环写入数组。代码核心思路是#define ADC_CHANNEL_NUM 2 uint16_t adc_raw[ADC_CHANNEL_NUM]; HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_raw, ADC_CHANNEL_NUM);实时电压计算时不能直接用最新一次DMA数据。电位器转动带来的信号本身存在噪声加上采样过程中系统其他干扰直接显示会出现末位跳变。我加了一个8次滑动平均#define FILTER_BUF_SIZE 8 uint32_t filter_buf[ADC_CHANNEL_NUM][FILTER_BUF_SIZE]; uint8_t filter_index[ADC_CHANNEL_NUM]; float read_filtered_voltage(uint8_t ch) { filter_buf[ch][filter_index[ch]] adc_raw[ch]; filter_index[ch] (filter_index[ch] 1) % FILTER_BUF_SIZE; uint32_t sum 0; for (int i 0; i FILTER_BUF_SIZE; i) { sum filter_buf[ch][i]; } return (float)sum / FILTER_BUF_SIZE * 3.3f / 4096.0f; }滑动滤波的窗口大小不要贪大。8次对这次题目够用了窗口太大会让显示变化迟钝调电位器时数字要隔几百毫秒才跟上看着非常难受。另外注意DMA缓冲是uint16_t数组如果开启了DMA循环模式它是一直在后台跑的不需要主循环里反复调用HAL_ADC_Start_DMA。3.2 按键消抖与长按区分按键模块我强烈建议用状态机而不是简单的Delay消抖。Delay会阻塞主循环多按键组合时整个系统响应就卡了。状态机思路是这样的每10ms扫描一次引脚电平连续两次读到相同电平才认为是稳定值检测到稳定值从“未按下”变为“按下”时记录按下时刻并产生一次短按事件如果按下超过500ms未释放产生长按事件。核心代码可以这样组织#define KEY_DEBOUNCE_MS 10 #define KEY_LONG_PRESS_MS 500 static uint8_t key_raw_level[4]; static uint8_t key_stable_level[4]; static uint8_t key_debounce_cnt[4]; static uint8_t key_pressed[4]; static uint32_t key_press_start[4]; void key_scan(void) { for (int i 0; i 4; i) { uint8_t level HAL_GPIO_ReadPin(KEY_GPIO_Port[i], KEY_Pin[i]); if (level ! key_raw_level[i]) { key_raw_level[i] level; key_debounce_cnt[i] 0; continue; } if (key_debounce_cnt[i] KEY_DEBOUNCE_CNT_TH) { key_debounce_cnt[i]; continue; } key_stable_level[i] level; if (level KEY_PRESSED_LEVEL !key_pressed[i]) { key_pressed[i] 1; key_press_start[i] HAL_GetTick(); keyEvent KEY_EVENT_MAP[i]; // 短按事件 } if (level KEY_PRESSED_LEVEL key_pressed[i]) { if (HAL_GetTick() - key_press_start[i] KEY_LONG_PRESS_MS) { keyEvent KEY_LONG_EVENT_MAP[i]; key_press_start[i] HAL_GetTick(); // 长按触发后从新计时实现连续调整 } } if (level ! KEY_PRESSED_LEVEL key_pressed[i]) { key_pressed[i] 0; } } }这套逻辑里隐含了一个实用设计长按触发一次后刷新key_press_start如果按住不松会每隔一定时间继续触发长按事件参数就能连续加减。这个“连按”效果是很多人漏掉的得分点题目里如果要求“支持连续调节”直接靠它实现。对于KEY1这类只用来切换界面的键我没有给它配长按事件避免误触。3.3 TFT LCD显示框架脏标记与局部刷新LCD如果每次刷新都清全屏再重绘会非常慢肉眼能看到的闪烁会直接降低印象分。我的方案是“脏标记局部刷新”。界面上每个区块用一个bit表示是否需要重绘被修改的区块才重绘。#define DIRTY_TIME (1 0) #define DIRTY_ADC1 (1 1) #define DIRTY_ADC2 (1 2) #define DIRTY_PWM_FREQ (1 3) #define DIRTY_PWM_DUTY (1 4) static uint16_t dirty_flags; void ui_set_dirty(uint16_t mask) { dirty_flags | mask; } void ui_refresh(void) { if (dirty_flags DIRTY_TIME) { lcd_fill_rect(TIME_AREA_X, TIME_AREA_Y, TIME_AREA_W, TIME_AREA_H, BG_COLOR); lcd_show_string(TIME_AREA_X, TIME_AREA_Y, (uint8_t *)time_buf, BLACK, BG_COLOR); dirty_flags ~DIRTY_TIME; } // 其他区域类似…… }用这种写法每秒只有变化的一小块区域被重绘CPU压力小显示也干净。有个额外好处写参数界面时如果某一位数字变了只刷新那个字段即可不需要整行重绘。显示字符串时用snprintf先格式化再送到LCD函数比直接拼接字符串灵活得多。比如显示两路电压snprintf(adc1_buf, sizeof(adc1_buf), ADC1:%.2fV, voltage1);注意snprintf的缓冲区大小一定要给够比赛代码里常会出现缓冲区越界导致的随机复位我曾经吃过这个亏。3.4 PWM频率和占空比动态调节PWM输出的关键是理解预分频器PSC和自动重载值ARR的关系。设定时器时钟为170MHz输出频率为fPWM 170000000 / ((PSC 1) * (ARR 1))我采用的策略是固定PSC、通过修改ARR来调频率这样代码逻辑最清晰也方便判分时用示波器测频率。占空比则由CCR决定在PWM模式下占空比 CCR / (ARR 1)所以调频率时要同步修改CCR否则频率变了占空比会跟着变。下面是核心设置函数void pwm_set_freq_duty(uint32_t freq_hz, uint16_t duty_percent) { uint32_t psc 16; // 根据需求固定 uint32_t arr 170000000 / (psc 1) / freq_hz - 1; uint32_t ccr arr * duty_percent / 100; __HAL_TIM_SET_PRESCALER(htim1, psc); __HAL_TIM_SET_AUTORELOAD(htim1, arr); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, ccr); if (!IS_TIM_PWM_INSTANCE(htim1.Instance)) { return; } }更新ARR和CCR时最好在一个原子操作内完成。HAL库中__HAL_TIM_SET_AUTORELOAD和__HAL_TIM_SET_COMPARE是两个寄存器写操作如果中间来了PWM更新事件边沿处可能产生一个异常脉冲。保险做法是先停止PWM输出、更新完参数再重新启动或者利用定时器影子寄存器让新值在下一个更新事件才生效。比赛代码里我一般选择停止再启动只有毫秒级切换不影响观感。3.5 EEPROM存取与掉电恢复EEPROM写入有几个必须注意的点。第一I2C写EEPROM时写完一个字节后芯片内部要执行擦写周期这期间不响应任何I2C请求。很多同学写完数据马上读回来校验读出来全是0xFF就是因为没有等待内部周期。简单处理是每写一个字节后延时5ms写一页后延时一次判别分场景完全够用。第二是页写边界问题。常见EEPROM单页大小是8字节或16字节跨页连续写会导致高地址部分写失败。我当时写参数结构体时发现结构体大小超过单页容量就要手动拆成两次写或者把结构体字段按页边界对齐。我采用了一个带校验位的存储格式typedef struct { uint8_t magic; uint16_t pwm_freq_hz; uint8_t pwm_duty; uint8_t checksum; } SettingData_t; #define SETTING_MAGIC 0xA5 #define EEPROM_SETTING_ADDR 0x00 uint8_t calc_checksum(SettingData_t *data) { uint8_t sum >if (keyEvent ! EVT_NONE) { handleKeyEvent(keyEvent); keyEvent EVT_NONE; }看起来没问题但突然想到一个问题keyEvent是中断里赋值的普通全局变量如果主循环读的瞬间中断刚好写入读出来还是旧值更严重的是如果扫描周期里连续两次按键动作都发生在一个消费周期内第二个按键事件被覆盖了。于是排查时打印连续按键确实发现快速连续短按时有事件丢失。改进办法是把keyEvent从单事件改成事件标志位每个按键一个bitvolatile uint16_t keyEventFlags; #define KEY_EVT_KEY1 (1 0) #define KEY_EVT_KEY2 (1 1) #define KEY_EVT_KEY3 (1 2) #define KEY_EVT_KEY4 (1 3)中断里置标志位主循环读取后清零并挨个处理。这样即使两次按键间隔很短也不会互相覆盖。加上这个改动后连按、快速切换界面的问题全部消失。4.3 LCD刷新残影局部重绘时忘了清背景界面从主页面切到参数页面时新内容能显示但旧界面的数字会残留在同一位置。最早我以为是LCD驱动底层写显存的问题后来检查发现是局部刷新逻辑的锅。比如主界面显示时间的位置切换到参数页后那块区域还在原来的坐标如果只在新界面重绘参数位置老旧数字没擦掉当然会残留。解决方法是每个区域在刷新正文前先画一个和背景同色的矩形void lcd_clear_area(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { lcd_fill_rect(x, y, w, h, BG_COLOR); }界面切换时先对整个内容区做一次全区域背景色填充再逐区块画新内容。这样既不会全屏清屏造成的闪烁也保证没有残影。还要注意一个显示字符串的函数边界如果数字位数变化比如从“9.99V”变成“10.00V”没有清背景的情况下末尾字符会残留。我统一把数字区域的宽度留够刷新时整块重绘不按字符覆盖。4.4 EEPROM保存后恢复失败连续写没等内部周期第一次调EEPROM时保存后LED闪烁提示成功但断电重启后参数还是默认值。我怀疑是没写进去于是把读回来的数据通过串口打印全是0xFF确认写失败。用示波器看I2C的SCL和SDA波形发现地址帧有响应但数据帧直接NACK。原因是写EEPROM时写完字地址后如果立即发数据芯片正处于内部写周期不响应。而我的工程里没有在每字节写入之间加延时。修改为每写一字节加5ms延时后写入恢复正常。对于数据量小的参数存储这种简单延时方案完全够用没必要做I2C等待空闲的复杂状态机除非你在工程中频繁写大量数据。另外校验逻辑也重要我加了写入后回读比较uint8_t eeprom_write_with_verify(uint16_t addr, uint8_t *buf, uint16_t len) { if (HAL_I2C_Mem_Write(hi2c1, EEPROM_ADDR, addr, 2, buf, len, 100) ! HAL_OK) { return 0; } HAL_Delay(10); uint8_t *rd_buf malloc(len); HAL_I2C_Mem_Read(hi2c1, EEPROM_ADDR, addr, 2, rd_buf, len, 100); int ok (memcmp(buf, rd_buf, len) 0); free(rd_buf); return ok ? 1 : 0; }这段代码在比赛里作为验证手段非常有效能够直接通过串口输出“save ok”还是“save fail”。这个功能本身不复杂但很多人连写失败都不知道有一个回读机制能帮你快速定位是驱动问题还是芯片问题。5. 从模拟题到正式比赛这套题的训练价值在哪模拟题二相比基础模拟题最大的训练价值我认为在于它逼着你用接近正式省赛的状态去组织整个工程。正式比赛中时间只有五小时题目里还包含客观题和程序设计题两部分编程环节不可能给你充足时间从头钻研某个驱动平时模块代码越熟练留给自己联调和检查的时间才越多。5.1 判定维度功能只是基础分接口和流程也是分蓝桥杯嵌入式赛题通常会通过自动测试装置检查部分物理量ADC电压误差、PWM频率和占空比、按键动作触发结果等。所以ADC电压显示值不能有超出范围的偏差PWM频率要和设置值一致占空比设置到50%就应该是接近50%的方波。除自动判分外人工阅卷主要看界面表现。题目要求显示“ADC1:3.30V”你显示成“adc1:3.3 v”功能没错但观感分肯定会打折扣。我见过不少同学功能全实现了最后因为格式不符合题意丢分非常可惜。所以测试时一定要对着题目的显示要求逐字核对包括单位、大小写、小数点位数、冒号、换行位置。设计说明和代码文档也是分值的一部分。重视起来不要只啃功能。5.2 我建议的做题节奏拿到整套题后前20分钟不要动代码。把题目从头到尾读一遍画出状态表和界面布局列清楚需要初始化的外设清单。这一步做完真正的时间分配大概是时间段任务第1小时CubeMX配置引脚和时钟点亮LED、串口、LCD完成基本显示框架第2小时完成按键扫描和界面切换做到按键能翻页第3小时完成ADC采集滤波和PWM参数/输出第4小时完成EEPROM保存恢复、报警联动、串口打印最后1小时对照赛题逐条检查、截图记录、写设计说明这个节奏的前提是模块代码在平时已经比较熟练不用临时查手册。如果哪个模块还不熟建议提前把类似的驱动代码整理成一键复用的模板。5.3 交卷前的检查清单我在这次模拟题练习最后总结了十条自检项目省赛前最后一次练习也是用它来收尾的所有按键在全部界面下都有明确行为吗有没有无响应或误触发的键界面显示字符是否与题目要求完全一致包括单位、小数点、标签文本ADC电压值超阈值时报警是否及时阈值附近是否存在抖动频繁报警PWM频率实测值与设置值是否一致调节步进是否符合题目要求参数保存后断电再上电恢复的数据是否就是之前设置的值串口输出格式、间隔是否符合题目要求LED指示状态是否与题目定义一致有没有常亮闪烁混淆的情况LCD切换界面后是否有残影、闪烁长按/连按调节参数是否顺畅工程编译是否有警告代码位置是否齐全关键函数是否有注释十项检查完这套模拟题才算真正收官。这套题做完我对省赛的信心比之前足了不少不是因为题目简单而是因为你已经提前把所有可能出乱子的环节都排了一遍。后面再遇到什么新题本质上是把这些模块重新组合一次。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑