1. 题目整体架构与硬件资源映射
蓝桥杯嵌入式组第16届模拟题3是一套典型的多任务状态机驱动型心率监测系统,其核心逻辑围绕“数据采集—状态判断—人机交互—参数管理”四层闭环展开。该题不追求高精度生理信号处理,而着重考察参赛者对STM32外设资源统筹能力、状态迁移设计严谨性以及边界条件处理的工程直觉。整个系统运行在STM32G0系列(主流备赛平台为STM32G071RBT6)上,所有功能模块均需在HAL库框架下完成,且必须严格遵循蓝桥杯竞赛环境约束:禁用动态内存分配、禁用浮点运算、所有延时必须基于HAL_Delay或SysTick,且不得修改标准外设初始化模板结构。
硬件资源分配是本题第一个关键决策点,直接决定后续软件架构的可维护性与调试效率。题目明确指定三类核心信号源:PB4输入频率信号、R37电位器模拟电压、以及B1–B4四个独立按键。这些信号并非孤立存在,而是通过特定物理路径耦合进系统:
PB4频率输入:该引脚被复用为TIM16_CH1输入捕获通道。选择TIM16而非更常见的TIM2/TIM3,是因为TIM16是高级定时器子集中的单通道通用定时器,具备独立预分频器和捕获/比较寄存器,且在G0系列中其时钟源默认来自APB1总线(32MHz),无需额外配置时钟树分支。将PB4配置为AF1功能(TIM16_CH1)后,需启用TIM16时钟并设置上升沿触发捕获,这是获取稳定周期测量的前提。
R37模拟电压采样:R37作为可调电位器,其滑动端连接至PA0引脚。PA0在G0系列中默认映射至ADC1_IN0通道,属于规则组单通道采样模式。此处需特别注意ADC时钟分频配置——若系统主频为64MHz,ADCCLK必须≤16MHz,因此需在RCC配置中将ADC预分频器设为4分频(64MHz/4=16MHz)。同时,为保证电压读数稳定,必须启用ADC内部校准(HAL_ADCEx_Calibration_Start)并在每次采样前执行一次软件触发(HAL_ADC_Start),避免因电源波动导致基准漂移。
LED指示灯资源:题目要求L1–L6六颗LED分别承担不同语义。根据蓝桥杯标准底板布局,L1–L3对应PC8–PC10,L4–L6对应PC6–PC7+PB0。这种非连续端口分配意味着GPIO初始化不能简单使用
GPIO_PIN_All,而必须逐位配置:GPIO_PIN_6 | GPIO_PIN_7 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_0。更关键的是L4/L5的动态语义——它们仅在参数界面下根据当前选中项(上线/下线)亮起,这要求软件层必须维护一个current_param_target枚举变量,并在LCD刷新周期中同步更新LED状态,而非依赖中断即时响应。按键扫描机制:B1–B4全部采用上拉输入模式(GPIO_PULLUP),其硬件消抖由RC滤波电路完成,软件层只需实现防抖检测。但题目隐含一个易错点:B2/B3/B4的功能域高度受限于当前界面状态。这意味着按键扫描函数返回的原始键值(如
KEY_B2_PRESSED)不能直接触发动作,而必须经过状态机过滤。例如,在数据界面下检测到B2按下,应忽略;而在参数界面下则需切换param_selection_mode状态。这种“键值+上下文=有效指令”的设计,本质上是将UI状态提升为系统一级实体,而非简单的回调函数注册。
整个系统的数据流可抽象为三层管道:底层是PB4频率→心率值、R37电压→报警阈值的物理量转换;中层是三个界面(数据/记录/参数)构成的状态空间,每个状态持有独立的数据视图与操作权限;顶层是按键事件驱动的状态跃迁引擎。理解这三层解耦关系,是避免后期陷入“按钮失灵”“界面卡死”等典型调试困境的根本前提。
2. 心率值计算原理与TIM16捕获实现
心率值的本质是单位时间内心脏搏动次数,其工程实现需将PB4引脚上的方波信号周期转换为BPM(Beats Per Minute)。题目未给出具体换算系数,但明确指出“心率值取决于PB4引脚的输入频率”,这暗示二者存在线性比例关系:HeartRate = k × Frequency。其中k为标定常数,其物理意义是将每秒脉冲数(Hz)映射为每分钟搏动数(BPM),理论值应为60。但在实际硬件中,由于传感器信号整形电路引入的倍频或分频,k值需通过实测确定。蓝桥杯竞赛环境下,通常采用简化模型:假设PB4每接收一个完整心电信号周期即产生一个上升沿,则k=60即为标准解。
TIM16捕获模块的配置必须服务于这一数学模型。其核心挑战在于如何从连续的上升沿序列中提取稳定周期值。若仅依赖单次捕获,当信号存在抖动时,测量误差可达±20%。因此必须采用多次采样取平均策略,而TIM16的输入捕获模式恰好支持此需求。
具体实现分为四个技术步骤:
2.1 时钟与引脚初始化
// 使能TIM16时钟及GPIOB时钟 __HAL_RCC_TIM16_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); // PB4配置为复用推挽输出(AF1对应TIM16_CH1) GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = GPIO_PIN_4; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; GPIO_InitStruct.Alternate = GPIO_AF1_TIM16; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);此处GPIO_SPEED_FREQ_HIGH不可省略,因高频方波边沿陡峭,低速模式会导致上升时间延长,影响捕获精度。
2.2 TIM16基础参数配置
TIM_HandleTypeDef htim16; htim16.Instance = TIM16; htim16.Init.Prescaler = 31; // APB1=32MHz → 计数器时钟=1MHz (32MHz/(31+1)) htim16.Init.CounterMode = TIM_COUNTERMODE_UP; htim16.Init.Period = 0xFFFF; // 自动重装载值,不影响捕获 htim16.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim16.Init.RepetitionCounter = 0; HAL_TIM_Base_Init(&htim16); // 配置输入捕获通道 TIM_IC_InitTypeDef sConfigIC; sConfigIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler = TIM_ICPSC_DIV1; // 不分频,直接捕获 sConfigIC.ICFilter = 3; // 数字滤波器采样3次,抑制毛刺 HAL_TIM_IC_ConfigChannel(&htim16, &sConfigIC, TIM_CHANNEL_1);关键参数解析:
-Prescaler=31:将32MHz APB1时钟降至1MHz计数频率,使计数器每微秒加1,满足心率测量所需的μs级分辨率;
-ICFilter=3:启用3次连续采样滤波,要求连续3个时钟周期检测到高电平才确认上升沿,有效过滤机械开关抖动及EMI干扰;
-ICSelection=TIM_ICSELECTION_DIRECTTI:选择TI1直接输入,避免经内部触发路径引入延迟。
2.3 捕获中断服务程序(ISR)
uint32_t capture_buffer[4] = {0}; // 存储最近4次捕获值 uint8_t capture_index = 0; uint32_t last_captured = 0; uint32_t current_period = 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM16 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { uint32_t current_capture = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); // 计算两次捕获间的计数值差(处理溢出) if (current_capture > last_captured) { current_period = current_capture - last_captured; } else { current_period = (0xFFFF - last_captured) + current_capture + 1; } // 环形缓冲区存储 capture_buffer[capture_index] = current_period; capture_index = (capture_index + 1) % 4; last_captured = current_capture; } }此ISR需在stm32g0xx_it.c中注册,并确保NVIC优先级高于其他外设(建议设为NVIC_PRIORITYGROUP_4下的2级)。代码中current_period即为当前周期对应的计数值,其物理单位为微秒(因计数频率为1MHz)。若current_period=10000,则对应周期为10ms,频率为100Hz。
2.4 心率值计算与滤波
在主循环或定时任务中,需定期从缓冲区提取有效周期值:
// 计算4次采样的中位数(抗脉冲干扰) uint32_t sorted[4]; for (int i = 0; i < 4; i++) sorted[i] = capture_buffer[i]; qsort(sorted, 4, sizeof(uint32_t), compare_uint32); uint32_t median_period = sorted[2]; // 取中位数 // 转换为频率(Hz)并计算心率(BPM) if (median_period > 0) { float frequency_hz = 1000000.0f / (float)median_period; // 1MHz计数器 heart_rate_bpm = (uint16_t)(60.0f * frequency_hz); // k=60 }此处采用中位数滤波而非算术平均,因其对异常值(如电磁干扰导致的虚假边沿)鲁棒性更强。当median_period超过200000(对应频率5Hz,心率300BPM)或低于3333(对应180BPM)时,应视为无效数据并保持上次有效值,此为竞赛评分的关键容错点。
3. 三界面状态机设计与LCD显示逻辑
本题的UI系统本质是一个有限状态机(FSM),其状态集合为{DATA_VIEW, RECORD_VIEW, PARAM_VIEW},状态转移由B1按键唯一驱动。与传统轮询式菜单不同,蓝桥杯要求状态切换必须瞬时生效且无视觉延迟,这要求LCD刷新逻辑与状态机完全解耦——即状态变更立即更新内部变量,而LCD仅在固定刷新周期(如200ms)内读取当前状态并渲染对应视图。
3.1 状态机定义与转移规则
typedef enum { DATA_VIEW, RECORD_VIEW, PARAM_VIEW } ui_state_t; ui_state_t current_ui_state = DATA_VIEW; uint8_t param_selection_mode = UPPER_LIMIT; // UPPER_LIMIT or LOWER_LIMIT uint8_t edit_mode_active = 0; // 0: view mode, 1: edit mode // B1按键状态机 void handle_b1_press(void) { switch(current_ui_state) { case DATA_VIEW: current_ui_state = RECORD_VIEW; break; case RECORD_VIEW: current_ui_state = PARAM_VIEW; // 进入参数界面时默认选中上限值,且退出编辑模式 param_selection_mode = UPPER_LIMIT; edit_mode_active = 0; break; case PARAM_VIEW: current_ui_state = DATA_VIEW; break; } }该设计满足题目“B1键完成三个界面流转”的硬性要求。值得注意的是,状态转移不涉及任何延时或动画,纯粹是变量赋值操作,确保响应时间<10μs。
3.2 LCD显示内容组织
LCD采用128x64点阵OLED,显示内容按行划分。题目要求三个界面各显示两个数据项,需严格遵循坐标定位:
| 界面 | 行1内容 | 行2内容 | 坐标位置 |
|---|---|---|---|
| 数据界面 | “HR: XXX” | “ALARM: XX” | (0,0) 和 (0,16) |
| 记录界面 | “MAX: XXX” | “MIN: XXX” | (0,0) 和 (0,16) |
| 参数界面 | “UP: XXX” | “LOW: XXX” | (0,0) 和 (0,16) |
关键实现细节:
-数字对齐:所有数值显示必须右对齐,避免因位数变化导致字符跳动。例如”HR: 72”与”HR: 127”的”72”和”127”右端需严格对齐,可通过sprintf(buffer, "HR:%3d", value)实现;
-报警次数清零反馈:当B4在数据界面按下时,除清零变量外,需在LCD第二行短暂显示”CLEARED”(持续1秒),此为隐藏评分点;
-参数界面双态显示:当edit_mode_active==1时,当前选中项(UP/Low)需添加反显标记,如”UP:[120]”,未选中项为”LOW: 80”,括号内为可编辑数值。
3.3 LED状态同步机制
L1–L3作为界面指示灯,其控制逻辑必须与current_ui_state严格绑定:
void update_ui_leds(void) { // 先关闭所有界面LED HAL_GPIO_WritePin(GPIOC, GPIO_PIN_8|GPIO_PIN_9|GPIO_PIN_10, GPIO_PIN_SET); // 根据当前状态点亮对应LED switch(current_ui_state) { case DATA_VIEW: HAL_GPIO_WritePin(GPIOC, GPIO_PIN_8, GPIO_PIN_RESET); // L1 break; case RECORD_VIEW: HAL_GPIO_WritePin(GPIOC, GPIO_PIN_9, GPIO_PIN_RESET); // L2 break; case PARAM_VIEW: HAL_GPIO_WritePin(GPIOC, GPIO_PIN_10, GPIO_PIN_RESET); // L3 break; } // L4/L5仅在参数界面有效 if (current_ui_state == PARAM_VIEW) { HAL_GPIO_WritePin(GPIOC, GPIO_PIN_6|GPIO_PIN_7, GPIO_PIN_SET); // 先熄灭 if (param_selection_mode == UPPER_LIMIT) { HAL_GPIO_WritePin(GPIOC, GPIO_PIN_6, GPIO_PIN_RESET); // L4亮 } else { HAL_GPIO_WritePin(GPIOC, GPIO_PIN_7, GPIO_PIN_RESET); // L5亮 } } }此函数应在每次LCD刷新前调用,确保LED状态与界面显示完全一致。若在PARAM_VIEW下param_selection_mode切换时未同步更新L4/L5,则会被判定为功能缺陷。
4. 报警逻辑与阈值管理的工程实现
报警机制是本题最易失分的核心模块,其复杂性不在于算法难度,而在于对状态变迁边界的精确把控。题目明确要求:“当心率值大于上线报警值或小于下线报警值时,总报警次数加一”,且强调“不重复计数”——即同一越界事件不可被多次触发。这实质上定义了一个带滞回的比较器行为,其工程实现必须引入“越界状态记忆”。
4.1 报警状态机设计
定义两个布尔变量:
-is_above_upper:记录当前心率是否处于上线之上
-is_below_lower:记录当前心率是否处于下线之下
初始值均为false。每次新心率值到达时,执行以下逻辑:
void check_alarm_condition(uint16_t current_hr) { uint8_t was_above = is_above_upper; uint8_t was_below = is_below_lower; // 更新当前越界状态 is_above_upper = (current_hr > upper_limit_value); is_below_lower = (current_hr < lower_limit_value); // 仅在状态由“未越界”变为“越界”时触发报警 if (!was_above && is_above_upper) { alarm_count++; HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); // L6亮 alarm_timer_start = HAL_GetTick(); // 启动5秒定时器 } if (!was_below && is_below_lower) { alarm_count++; HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); // L6亮 alarm_timer_start = HAL_GetTick(); } }此设计完美解决“不重复计数”问题:若心率持续高于上线,was_above与is_above_upper始终同为true,条件不满足;仅当心率从≤上线回升至>上线的瞬间,才触发一次增量。同理适用于下限。
4.2 阈值参数管理
上线/下线阈值并非固定常量,而是由R37电位器电压实时映射。题目说明“R37输出模拟电压决定数值”,其映射关系为线性:
- R37电压范围:0~3.3V
- 对应阈值范围:40~200 BPM(典型心率区间)
ADC采样值转换公式:
// PA0采样得到12位ADC值(0~4095) uint32_t adc_value = HAL_ADC_GetValue(&hadc1); // 映射到40~200区间 upper_limit_value = 40 + (adc_value * 160) / 4095; lower_limit_value = 40 + (adc_value * 160) / 4095;但题目要求“上线与下线可独立设置”,故需两路独立电位器。实际硬件中R37仅提供一路电压,因此必须通过B2/B3按键组合实现“虚拟双通道”:B2切换目标参数(UP/LOW),B3进入编辑模式后,旋转R37调整当前目标值。此即题目所述“B2切换,B3进入修改”。
4.3 参数编辑状态管理
编辑模式需维护三个关键状态:
-edit_mode_active:是否处于编辑状态(0/1)
-param_selection_mode:当前编辑目标(UPPER_LIMIT/LOWER_LIMIT)
-pending_value:待确认的临时值(避免直接修改生效)
B3按键逻辑:
void handle_b3_press(void) { if (current_ui_state != PARAM_VIEW) return; if (edit_mode_active == 0) { // 进入编辑:复制当前值到pending_value if (param_selection_mode == UPPER_LIMIT) { pending_value = upper_limit_value; } else { pending_value = lower_limit_value; } edit_mode_active = 1; } else { // 退出编辑:保存pending_value到目标变量 if (param_selection_mode == UPPER_LIMIT) { upper_limit_value = pending_value; } else { lower_limit_value = pending_value; } edit_mode_active = 0; } }此设计确保“离开界面未退出则恢复原值”:若用户进入编辑后直接切走,pending_value不会覆盖原值,下次进入时仍显示旧值。
5. 按键事件处理与防抖策略
蓝桥杯竞赛对按键响应有严苛时序要求:B1必须在200ms内完成状态切换并刷新LCD;B4清零操作需在按键释放后100ms内生效。这要求按键扫描不能依赖阻塞式延时,而必须采用非阻塞状态机。
5.1 按键扫描状态机
定义每个按键的五种状态:
-KEY_IDLE:未按下
-KEY_DEBOUNCE:检测到低电平,启动10ms防抖计时
-KEY_PRESSED:10ms后仍为低,确认按下
-KEY_LONG_PRESS:持续按下>1s,用于特殊功能(本题未启用)
-KEY_RELEASED:检测到高电平,启动10ms释放防抖
状态转移图(以B1为例):
IDLE → (检测低电平) → DEBOUNCE → (10ms后仍低) → PRESSED PRESSED → (检测高电平) → RELEASED → (10ms后仍高) → IDLE5.2 非阻塞实现
在主循环中调用扫描函数:
#define KEY_SCAN_INTERVAL 10 // 10ms扫描周期 uint32_t key_last_scan = 0; void scan_keys(void) { if (HAL_GetTick() - key_last_scan < KEY_SCAN_INTERVAL) return; key_last_scan = HAL_GetTick(); // 读取所有按键电平(B1-B4对应GPIO_PIN_0~3) uint8_t raw_state = ~(GPIOB->IDR & 0x0F); // 低电平有效 for (int i = 0; i < 4; i++) { uint8_t bit = (raw_state >> i) & 0x01; switch(key_state[i]) { case KEY_IDLE: if (bit == 1) { key_state[i] = KEY_DEBOUNCE; key_debounce_counter[i] = 0; } break; case KEY_DEBOUNCE: if (bit == 1) { key_debounce_counter[i]++; if (key_debounce_counter[i] >= 10) { // 10*10ms=100ms key_state[i] = KEY_PRESSED; on_key_pressed(i); // 触发按键事件 } } else { key_state[i] = KEY_IDLE; } break; case KEY_PRESSED: if (bit == 0) { key_state[i] = KEY_RELEASED; key_release_counter[i] = 0; } break; case KEY_RELEASED: if (bit == 0) { key_release_counter[i]++; if (key_release_counter[i] >= 10) { key_state[i] = KEY_IDLE; } } else { key_state[i] = KEY_PRESSED; } break; } } }此实现将防抖逻辑完全融入状态机,无需HAL_Delay阻塞,且100ms防抖时间远超典型机械按键抖动期(5~10ms),确保可靠性。
5.3 按键功能路由
所有按键事件最终路由至统一处理器:
void on_key_pressed(uint8_t key_id) { switch(key_id) { case KEY_B1: handle_b1_press(); break; case KEY_B2: if (current_ui_state == PARAM_VIEW) { param_selection_mode = (param_selection_mode == UPPER_LIMIT) ? LOWER_LIMIT : UPPER_LIMIT; } break; case KEY_B3: if (current_ui_state == PARAM_VIEW) { handle_b3_press(); } break; case KEY_B4: if (current_ui_state == DATA_VIEW) { alarm_count = 0; // LCD显示CLEARED提示 lcd_show_message("CLEARED", 1000); } break; } }此架构确保B2/B3/B4的功能域检查在事件入口处完成,避免在各处重复判断,提升代码可维护性。
6. 系统集成与调试要点
完成各模块编码后,系统集成需关注三个致命陷阱,这些是历年蓝桥杯选手高频踩坑点:
6.1 中断优先级冲突
TIM16捕获中断与SysTick中断若优先级相同,可能导致心率值计算丢失。必须确保:
-HAL_NVIC_SetPriority(TIM16_IRQn, 0, 0);// 最高抢占优先级
-HAL_NVIC_SetPriority(SysTick_IRQn, 1, 0);// 次高,保证HAL_Delay可用
在stm32g0xx_hal_msp.c中配置,而非在main函数中,否则可能被CubeMX覆盖。
6.2 ADC采样时序竞争
当TIM16捕获与ADC采样同时发生,若共用同一APB1总线,可能引发总线仲裁延迟。解决方案是错开采样时机:在TIM16 ISR中设置标志位,主循环检测到标志后再启动ADC采样,而非在ISR中直接调用HAL_ADC_Start。
6.3 LCD刷新与状态更新竞态
若在HAL_TIM_PeriodElapsedCallback中直接调用LCD刷新函数,可能因中断嵌套导致显示错乱。正确做法是:所有UI更新(包括LCD、LED)仅在主循环的while(1)中执行,中断服务程序只负责更新共享变量并置位标志。
最后分享一个实战经验:在参数界面反复切换UP/LOW并快速进出编辑模式时,曾遇到L4/L5闪烁异常。排查发现是update_ui_leds()函数中未对GPIO_PIN_6|GPIO_PIN_7执行原子操作——当先写GPIO_PIN_6再写GPIO_PIN_7时,中间存在微秒级窗口导致两灯短暂全灭。解决方案是合并写操作:HAL_GPIO_WritePin(GPIOC, GPIO_PIN_6|GPIO_PIN_7, GPIO_PIN_SET),利用STM32的BSRR寄存器实现单指令位操作。这种硬件级细节,往往决定竞赛成败。