基于STM32F103的抢答器设计:按键消抖、外部中断与状态机实现
简介面向STM32初学者的抢答器工程资料基于STM32F103微控制器设计覆盖LCD液晶显示、按键输入、蜂鸣器提示、外部中断与定时器等嵌入式常见外设帮助新手掌握从外设初始化到中断处理的完整流程。压缩包共365个文件约9.07MB包含71个C源文件、66个头文件以及Keil工程配置、HEX烧录文件、AXF调试镜像和项目文档虽然混有编译中间文件但源码和工程仍便于直接打开查看。设计围绕LCD显示、按键扫描、蜂鸣器控制、定时器计时和中断响应展开读者可从中学习GPIO操作、按键消抖、字符显示、时序控制等要点。STM32F103系列采用Cortex-M3内核主频72MHz外设丰富适合作入门练习。该工程已有1600人学习下载可用于课程设计、毕业设计或作为嵌入式开发的完整参考。1. 抢答器不等于轮询按键STM32F103 要处理的真实问题一个按键按下去到数码管显示“1号选手”中间隔了多长时间如果你用while(1)轮询五个按键那这个时间可能是 0 毫秒也可能是 50 毫秒——取决于主循环转到了哪里。而对抢答器来说这不叫“软件延迟”这叫“判罚不公”。真正的问题是多个按键在几乎同一时刻按下时STM32F103 如何在微秒级完成信号同步、消抖、优先级判断和结果锁存。有人会说按键消抖 20ms 之内都不算数何必较真。但抢答器的核心体验恰恰是那个“裁判计时器”选手看的是谁先亮灯而不是谁在代码里运气好先被执行。所以这篇要讲的是用 STM32F103 的定时器、外部中断和状态机把“谁的信号先到”这个事实变成可复现、可验证的电平变化再配上显示和提示电路组成完整系统。适合正在做课设、竞赛板或想彻底搞懂 GPIO 外部中断与定时器协作方式的嵌入式开发者。2. 硬件设计STM32F103 最小系统与按键/显示电路的引脚分配2.1 STM32F103 最小系统电源、复位、启动引脚与外部晶振STM32F103C8T6 这类芯片的“最小系统”比 51 单片机多几个讲究供电、复位、启动模式、时钟源哪一路没处理好都会让下载器连不上或者程序跑飞。常见做法是给 VDD 接 3.3V每个电源引脚旁边放一个 100nF 去耦电容再加一个 10μF 的钽电容稳住整体。复位引脚 NRST 接一个 10kΩ 上拉电阻到 3.3V并联一个 100nF 电容到地这样上电时能保证可靠的复位脉冲。启动引脚 BOOT0 和 BOOT1 各接一个 10kΩ 下拉电阻让芯片默认从主闪存启动BOOT0 拉高则进入系统存储器引导也就是常说的 ISP 下载模式。外部晶振部分习惯用 8MHz 晶振配合两个 20pF 负载电容经过内部 PLL 倍频到 72MHz。若想省掉晶振也可以用内部 HSI但定时器定时的精度和 USB 通信的稳定性都会打折抢答器的倒计时精度要求不算高但数码管动态扫描和蜂鸣器音调都依赖稳定的系统时钟所以按标准做法保留外部晶振更稳妥。启动文件startup_stm32f10x_hd.s中定义了中断向量表和堆栈大小第一次建工程时很容易漏加导致一进中断就 HardFault——这个问题后面在第 5 章单独说。2.2 按键电路与 RC 硬件消抖的参数计算抢答器按键不能直接一根线连到 PA0原因有两个一是浮空输入模式下引脚电平不确定二是机械按键抖动会造成一次按压变成多次触发。常见的按键接法是按键一端接 GND另一端接到 GPIO同时 GPIO 通过一个上拉电阻到 3.3V。按下时引脚拉低释放时恢复高电平。这个上拉电阻选 10kΩ 即可太大则抗干扰差太小则静态电流偏大10k 是多数开发板的选择。机械抖动的时间通常在 5ms 到 20ms 之间纯靠软件延时消抖容易阻塞中断服务函数。一个更可靠的做法是硬件 RC 滤波配合软件确认在按键输出端串联 1kΩ 电阻再接 0.1μF 电容到地形成一个低通滤波器时间常数 τ R × C 1kΩ × 0.1μF 100μs。它能把亚毫秒级的抖动尖峰削掉剩下的毛刺再由软件里 10ms 的稳定电平确认处理。MCU 检测到 GPIO 为低电平后并不立即认定“有人抢答”而是启动一个短延时比如 10ms再读一次仍是低才确认有效。这样组合的好处是响应延迟可控RC 滤波只拦高频抖动10ms 软件确认只做逻辑上的二次验证两者的延迟累计只有 10ms 出头对人工按键抢答场景完全够用且比纯软件 20ms 消抖的“误判窗口”小一半。2.3 显示与提示电路四位数码管及蜂鸣器的驱动方式显示部分选四位一体共阴数码管段选 a~g 和 dp 分别通过 470Ω 限流电阻连接到 STM32F103 的 GPIO位选端接 PNP 三极管如 8550来控制公共端导通。为什么不用灌电流驱动因为 GPIO 的灌电流能力有限四位同时点亮时钟电流可能超过芯片规格加上三极管后由外部电源承担驱动电流GPIO 只负责控制三极管基极电平。位选信号低电平导通三极管高电平截止动态扫描时逐位点亮利用人眼视觉暂留产生“四位数同时显示”的效果。蜂鸣器有有源和无源之分抢答器建议用有源蜂鸣器因为内部自带振荡源只要通电就响软件只需控制通断时长来产生“滴——滴——”的提示音不必用定时器输出 PWM 去驱动特定频率。驱动电路用 NPN 三极管如 8050做开关基极串联 1kΩ 电阻接到 PB1可选任意 GPIO发射极接地集电极接蜂鸣器负极正极接 5V 或 3.3V 电源。注意有源蜂鸣器工作电流约 20~30mA直接用 GPIO 拉不动必须经过三极管放大。2.4 完整引脚与 GPIO 分配表功能引脚模式说明选手按键 K1~K4PA0~PA3上拉输入按下为低电平裁判复位按键PA4上拉输入用于系统复位蜂鸣器PB1推挽输出高电平导通数码管段选 a~dpPB0、PB5~PB10推挽输出经限流电阻连接数码管位选 1~4PB12~PB15推挽输出经三极管驱动备用串口PA9 (TX)、PA10 (RX)复用推挽调试输出时间戳实际分配时把按键放在 PA0~PA3 还有个好处这四个引脚都能配置为 EXTI 外部中断输入线方便做中断触发的抢答检测。PA4 做复位按键需要软件里过滤长按避免误触。引脚确定后硬件和固件两边就能对着同一张表开发不会出现“代码里写的 PB12 实际接的是段选”这种低级问题。3. 固件实现GPIO 配置、按键消抖与抢答优先权判断逻辑3.1 标准外设库 v3.50 与启动文件的选择STM32F103 的开发方式有寄存器、标准外设库、HAL 库和 LL 库四种。学生项目和快速原型推荐标准外设库它的函数封装比寄存器直观执行效率又比 HAL 库高网上资料存量也最大。开发环境常用 Keil MDK按下面步骤建立一个库函数工程下载 STM32F10x 标准外设库 v3.50ST 官网或第三方镜像。解压后把Libraries文件夹复制到工程目录。在 Keil 中新建工程选择芯片型号 STM32F103C8。添加启动文件startup_stm32f10x_md.s注意 C8T6 属于中容量产品应该用md版本而不是hd。把内核相关文件core_cm3.c添加到工程并在 C/C 选项里添加宏USE_STDPERIPH_DRIVER和STM32F10X_MD。包含路径指向Libraries/STM32F10x_StdPeriph_Driver/inc、Libraries/CMSIS/CM3/DeviceSupport/ST/STM32F10x等目录。这里有个容易忽略的点标准外设库 v3.50 的时钟配置函数用的是SystemInit()它在system_stm32f10x.c里默认把系统时钟设到 72MHz如果你的外部晶振不是 8MHz就要先修改这个文件里的 PLL 倍频系数。下载时如果出现No target connected检查 BOOT0 有没有接地以及是否选了 SWD 模式这和第 5 章要讲的下载失败问题直接相关。3.2 按键与显示的 GPIO 初始化的库函数写法GPIO 初始化分为三块按键输入、蜂鸣器和数码管推挽输出。标准外设库里的写法如下。void GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; // 开启 GPIOA 和 GPIOB 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); // 按键 PA0~PA4上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); // 蜂鸣器 PB1推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); // 数码管段选和位选推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7 | GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 | GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); }函数的执行逻辑很直接先打开外设时钟然后分别对不同引脚组配置模式与速度。GPIO_Mode_IPU表示上拉输入对应按键按下时引脚读到低电平GPIO_Mode_Out_PP是推挽输出能主动拉高或拉低。GPIO_Speed_50MHz在这里不是让引脚立即翻转 50MHz而是配置输出驱动器的最大翻转速率对数码管这种带外部限流电阻的负载选择 50MHz 档是为了保留足够的驱动边沿避免段选切换时波形畸变。如果你在调试时发现数码管亮度不稳定可以试试把速度档降到 2MHz边沿变缓反而能减少振铃。3.3 软件消抖与非阻塞状态机读取用HAL_Delay(10)或Delay(10)消抖是新手最常见的写法检测到按键按下去就死等 10ms再读一次。这在抢答器里有个隐患——如果选手在消抖延时期间松手再按下比如快速双击状态机会漏判或误判。更好的做法是用状态机配合一个系统时基来读取按键不阻塞主循环。#define KEY_SAMPLE_INTERVAL_MS 2 // 每 2ms 采样一次 #define KEY_DEBOUNCE_THRESHOLD 5 // 连续 5 次采样10ms电平相同则确认 typedef enum { KEY_STATE_IDLE, KEY_STATE_PRESSED, KEY_STATE_CONFIRMED } KeyState; void Key_Scan(KeyState *state, uint8_t raw_level) { static uint8_t stable_count 0; switch (*state) { case KEY_STATE_IDLE: if (raw_level 0) { // 检测到低电平 stable_count 1; *state KEY_STATE_PRESSED; } break; case KEY_STATE_PRESSED: if (raw_level 0) { stable_count; if (stable_count KEY_DEBOUNCE_THRESHOLD) { *state KEY_STATE_CONFIRMED; // 消抖完成 } } else { stable_count 0; // 抖动导致电平还原状态回退 *state KEY_STATE_IDLE; } break; default: break; } }这段代码的核心逻辑是“连续 N 次采样都为低电平才确认按下”而不是“延时后读一次”。参数里KEY_SAMPLE_INTERVAL_MS决定采样频率KEY_DEBOUNCE_THRESHOLD决定稳定窗口的宽度。2ms 采样、5 次确认总确认时间是 10ms如果按键所在环境有较强工频干扰可以提高到 10 次20ms代价是抢答判定延迟增加 10ms。由于每个按键的消抖时间一致这不会造成“谁先按被误判成谁后按”的问题只会整体延后结果锁存时刻。抢答器对绝对延迟不敏感对相对顺序敏感这是设计时的一个关键认知。3.4 抢答状态机的实现锁定、判定与复位抢答器的核心状态机只有三个状态待机、已锁存、裁判复位。待机时主循环不断扫描四个按键一旦检测到某个按键消抖确认立即进入“已锁存”状态此时不再响应其他按键同时把选手编号写入显示缓冲区触发蜂鸣器提示。裁判按下复位按键后回到待机状态并清除锁存标志。这个机制保证了“先按先得后按无效”。优先级判断用顺序扫描足够循环从 K1 到 K4 依次检查如果两个选手在同一个扫描周期内都按下了几乎不可能但仍有可能先扫到的 K1 获胜。若要做到严格的微秒级判定可以改用外部中断四个按键对应 EXTI0~EXTI3在中断服务函数里判断是哪个中断线触发并关闭其他中断线的触发使能。中断方案的核心代码void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { // 只有未锁存时才处理 if (system_locked 0) { system_locked 1; winner_id 1; // 关闭其余中断线防止后续按键打断 EXTI-IMR ~(EXTI_Line1 | EXTI_Line2 | EXTI_Line3); TIM_Cmd(TIM2, ENABLE); // 启动倒计时停止或记录时间戳 } EXTI_ClearITPendingBit(EXTI_Line0); } }注意system_locked一定要在中断里先置位再执行其他操作否则低优先级的中断或主循环可能在这个间隙里再次进入判定流程。用中断方案的优点是响应时间在微秒级缺点是按键抖动会直接触发中断必须在中断里做标记后在主循环中完成消抖确认或者依赖硬件 RC 滤波把抖动压到阈值以下。我一般会把中断当作“唤醒信号”真正的消抖和锁存仍放在主循环状态机中处理。3.5 数码管动态显示的实现数码管动态扫描的原理是“逐位点亮、快速轮换”。每次只导通一位位选输出该位对应的段码延时 1~2ms 后切换到下一位。四位数码管完整扫描周期约 4~8ms刷新频率超过 120Hz人眼不会察觉闪烁。// 共阴数码管段码表0~9 const uint8_t seg_code[10] { 0x3F, // 0 0x06, // 1 0x5B, // 2 0x4F, // 3 0x66, // 4 0x6D, // 5 0x7D, // 6 0x07, // 7 0x7F, // 8 0x6F, // 9 }; void Display_Scan(uint8_t *digits, uint8_t *current_digit) { // 关闭所有位选防止串位 GPIOB-BRR GPIO_Pin_12 | GPIO_Pin_13 | GPIO_Pin_14 | GPIO_Pin_15; // 输出段码 uint8_t code seg_code[digits[*current_digit]]; GPIOB-BSRR ((code 0x01) ? GPIO_Pin_0 : 0) | ((code 0x02) ? GPIO_Pin_5 : 0) | ((code 0x04) ? GPIO_Pin_6 : 0); // 实际应用时用循环逐位判断更清晰 // 打开当前位选 GPIOB-BSRR (GPIO_Pin_12 (*current_digit)); *current_digit (*current_digit 1) % 4; }段码表本质是“共阴数码管里哪些段亮对应哪些二进制位”的映射。0x3F 表示 g、f、e、d、c、b、a 中除了 g 之外全部点亮即数字 0。注意位选切换前必须先把所有位选关闭否则切换到下一位的瞬间前一位的末尾段码会残留在新一位上造成亮度不均或重影。动态显示函数应在定时器中断里周期的调用而不是靠while(1)里的软件延时因为主循环还要做按键扫描和状态机处理。一个典型安排是SysTick 每 1ms 产生中断中断服务函数里调用Display_Scan刷新一位同时给按键消抖提供时基。4. 定时器与计时逻辑TIM 配置、倒计时与蜂鸣器提示4.1 定时器预分频与自动重载值的换算抢答器的“裁判计时”功能和蜂鸣器提示都可以由 STM32F103 的通用定时器 TIM2 来完成。系统时钟 72MHz 经过 PSC 预分频和 ARR 自动重载可以产生精确到 1ms 的中断周期。计算公式为中断频率 72MHz / (PSC 1) / (ARR 1)。要得到 1kHz即 1ms中断设 PSC 为 71则定时器时钟变成 1MHzARR 设为 999就得到 1ms 的更新事件。void TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; // 开启 TIM2 时钟APB1 定时器时钟为 72MHz RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Prescaler 71; // 72MHz / (711) 1MHz TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 999; // 1MHz / (9991) 1kHz TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); TIM_Cmd(TIM2, DISABLE); // 先关闭锁存触发后再开启 }TIM_Prescaler 71和TIM_Period 999是 STM32F103 定时器配置里最常用的一组参数。TIM_CounterMode_Up表示向上计数模式即从 0 数到 ARR 后溢出并产生更新事件。TIM_ClockDivision在这里不影响定时周期主要用于数字滤波的采样时钟分频保持默认TIM_CKD_DIV1即可。中断优先级设置为抢占优先级 1、子优先级 0可以让它打断主循环里的耗时操作但又被 SysTick如果优先级更高约束避免关键显示刷新被计时中断拖垮。4.2 倒计时逻辑与显示刷新的一体化设计倒计时一但启用需要在 TIM2 中断服务函数里做三件事情维护剩余秒数、刷新显示缓冲区、判断是否超时。一个常见设计是设置一个全局变量count_down初始为 10代表 10 秒每 1000 次 1ms 中断减一。volatile uint16_t timer_cnt 0; volatile uint8_t count_down 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { timer_cnt; if (timer_cnt 1000) { timer_cnt 0; if (count_down 0) { count_down--; // 把倒计时值写入显示缓冲区 display_digits[0] count_down / 10; display_digits[1] count_down % 10; } else { // 倒计时结束锁定为超时状态 system_locked 1; TIM_Cmd(TIM2, DISABLE); } } TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } }这里把“计时”和“显示刷新”绑定在同一个中断里省去了主循环中额外的状态轮询。timer_cnt累计到 1000 表示 1 秒count_down从 10 减到 0。注意count_down 0的判断应该在递减之后立刻执行否则会出现“显示 0 秒后还继续倒计一次”的边界错误。抢答器判定成功后主循环应当立刻停止 TIM2 计时只保留数码管显示当前结果。这种做法让主循环只需要处理按键状态机和蜂鸣器控制不需要每毫秒都去刷新定时相关的逻辑整体结构更清爽。4.3 蜂鸣器发声模式与判定后的多种提示蜂鸣器在有源驱动方式下的控制很简单置高电平响置低电平停。不同场景需要不同的提示模式抢答成功时短鸣两声每声 100ms间隔 100ms超时时长鸣一声500ms。可以用 TIM2 中断里的计数器实现精确时长控制但更简单可靠的方式是让主循环用一个阻塞式延时函数驱动发声。void Buzzer_Beep(uint16_t duration_ms, uint16_t interval_ms, uint8_t times) { for (uint8_t i 0; i times; i) { GPIO_SetBits(GPIOB, GPIO_Pin_1); Delay_ms(duration_ms); GPIO_ResetBits(GPIOB, GPIO_Pin_1); if (i times - 1) { Delay_ms(interval_ms); } } }阻塞式发声在功能上没问题但它占用主循环时间。如果发声期间选手按复位按键按键要等蜂鸣器放完才能响应。改进方法是用 TIM2 中断维护一个发声状态机设置beep_remaining_ms、beep_interval_ms、beep_times三个变量每秒中断里根据状态切换蜂鸣器电平。这样发声不会阻塞任何其他逻辑。两种方案在简单抢答器里都能工作但建议在一开始就采用非阻塞方式后面如果要加串口打印调试信息或扩展无线模块主循环的空闲时间会变得非常宝贵。5. 下载失败、PA11 引脚冲突与扩展调试技巧5.1 下载失败与 boot 引脚设置的典型原因用 ST-Link 或 DAP 下载器连接 STM32F103 出现No target connected或Error: Flash Download failed排查顺序一般从启动引脚和供电开始。BOOT0 必须接地BOOT1 接地或悬空都行但最好也接地确保芯片从主闪存启动。若 BOOT0 被浮空或拉高上电后芯片进入系统存储器模式用户程序闪存不可访问下载失败率极高。还有一个不起眼但常见的坑SWDIO 和 SWCLK 引脚分别是 PA13 和 PA14如果你的按键或数码管占用了这两个引脚即使 BOOT0 正确调试器也无法通信。解决方法是交换引脚分配或者按复位按钮的同时点击下载让调试器在芯片复位瞬间抢到总线控制权。另外注意 3.3V 供电质量。抢答器电路里数码管瞬态电流变化很大如果电源纹波高芯片在擦写闪存时容易出现校验失败。在 ST-Link 供电时尤其容易遇到因为此时是 USB 的 5V 经过板载稳压降到 3.3V若去耦电容布局不合理大电流瞬间会把电压拉低到复位阈值以下。这时候用万用表测 3.3V 引脚静态电压往往看不出来需要使用示波器观察下载瞬间的电压跌落。5.2 PA11/PA12 的 USB 复用注意事项如果你在原方案基础上扩展了 USB 通信或者把按键接到了 PA11、PA12 上就要注意 STM32F103 的这两个引脚默认复用为 USB D- 和 D且在芯片内部有专门的上拉/下拉控制逻辑。标准的 3.3V 电压下PA11 和 PA12 的输入阈值与普通 IO 略有差异如果直接接按键做上拉输入可能检测不到稳定的低电平这就是检索里常说的“PA11 bug”。实际上它并非硬件故障而是引脚被默认分配的 USB 外设功能干扰了普通 GPIO 模式。解决方法是避免把按键接在 PA11 和 PA12 上改到 PA0~PA3如果必须使用这两个引脚做其他功能在初始化时要先调用GPIO_PinRemapConfig(GPIO_Remap_USB, DISABLE)确认 USB 外设时钟未被开启。还有一个相关坑是如果固件中初始化了 USB 但没有正确配置 DP 上拉电阻DP 引脚电平不确定主机会反复枚举失败同时 PA12 的普通输入功能也受到干扰。调试时若发现某按键在插上 USB 后才“失灵”优先考虑引脚复用冲突而不是按键硬件损坏。5.3 验证抢答先后串口时间戳与电平边缘观察固件写完后怎样验证“先按先得”真的生效肉眼观察数码管只能判断结果对不对不能量化响应时间。更严谨的方式是用串口输出时间戳在按键状态机确认按下的瞬间读取 SysTick 或 TIM2 的当前计数值连同选手编号通过串口打印出来。void UART_SendWinner(uint8_t id, uint32_t timestamp) { char buf[32]; sprintf(buf, Winner:%d time:%lu\r\n, id, timestamp); // 串口发送函数可替换为你自己的 USART 发送实现 }把两个按键接到逻辑分析仪或示波器的 CH1 和 CH2同时把 GPIO 锁存信号接到 CH3。按下两个按键的间隔保持接近观察屏幕上哪个通道先变成低电平再对比串口输出的时间戳是否一致。我一般会在锁存代码里加一个临时调试引脚判定成功时拉高这样时间戳和示波器波形可以相互佐证。如果发现时间戳顺序和逻辑分析仪不一致说明中断优先级或状态机里存在延迟漏洞检查是否有更高优先级的中断长期占用 CPU导致 EXTI 响应被推迟。注意串口打印本身会占用几十微秒到几百微秒打印必须在完成锁存之后进行不能先打印再锁存否则打印期间另一个按键按下会造成误判。5.4 扩展方向SPI 接口读/写驱动的移植与 FreeRTOS 的实时抢占原方案如果只靠 GPIO 直驱数码管显示内容会被锁死在这四位数上。一个常见扩展是改用 SPI 接口驱动的 74HC595 或 MAX7219 来驱动数码管或者通过 SPI 读写无线模块如 NRF24L01实现多组抢答器的无线同步。用 STM32F103 的 SPI1在标准外设库下初始化主模式只需设置波特率、极性、相位和帧格式。操作 NRF24L01 这类模块时通常需要把 CE 引脚当作普通 GPIO 控制而 CSN、SCK、MOSI、MISO 分别映射到 SPI 引脚。SPI 读写函数不要直接阻塞延时可以利用等待 TXE/ RXNE 事件判断发送完成避免死等导致数据包丢失。另一个进阶方向是移植 FreeRTOS利用它的信号量或事件组来锁定抢答结果。任务划分很简单display_task处理动态扫描key_task阻塞等待按键事件judge_task负责状态机调度。按键中断用xSemaphoreGiveFromISR发送信号量key_task收到后做消抖确认再调用xEventGroupSetBits通知judge_task。相比裸机状态机FreeRTOS 的优势在于每个任务的实时性上限更容易分析也方便后续加串口命令解析或 OTA 升级逻辑。但要注意 STM32F103 只有 20KB RAMC8 型号的 RAM 更小创建任务栈时不要每个任务都分配 256 字节以上否则很容易触发堆栈溢出。在这类移植里KEY 采样仍然沿用裸机时的消抖算法只是把延时换成了vTaskDelay配合信号量机制可以做到多按键信号严格按到达时间排队进入处理流程而不是靠运气被主循环扫到。本文还有配套的精品资源点击获取