news 2026/9/14 6:00:11

物联网竞赛灯控题解:STM32状态机、定时器中断与按键消抖实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
物联网竞赛灯控题解:STM32状态机、定时器中断与按键消抖实践

简介:面向2021年物联网竞赛B卷的NB/LoRa模块亮灯模式源程序,完整覆盖赛题要求的菜单显示、按键切换与LED多模式控制逻辑。程序基于STM32与LoRa节点工程,包含HAL库驱动、LoRaMac通信中间层及任务调度相关代码,适合物联网竞赛备赛学生、嵌入式初学者参考LCD液晶屏菜单设计与GPIO/PWM控制。资源共642个文件,以C源码、H头文件为主,另有编译生成的o、d、crf及链接脚本、hex固件等,便于直接查看工程结构或烧录验证,压缩包整体17.76MB。已有857人学习下载。压缩包内工程目录层级清晰,可对照赛题要求逐步理解常亮、呼吸、交替亮灭三种模式的实现思路,并复用按键扫描与状态切换代码,是一份贴近真实赛题的完整参考方案。

1. 拆题:物联网竞赛里的本地灯控,考的不是通信而是状态机

2021 物联网竞赛 B 卷这套 NB/LoRa 源程序,名字里带着通信模块,实际任务核心却是一道典型的「端侧本地控制」题:通电后 LED1/LED2 亮,液晶屏显示三档亮灯模式,用 KEY2/KEY3 移动光标,按 KEY4 确认进入对应模式,并且全程可重复操作。这类题目在物联网安装调试员竞赛、院校物联网技能大赛里出现频率很高,因为大赛真正想检验的不是 LoRa 模块能不能发出数据,而是你对单片机 GPIO、定时器中断、按键消抖、状态机这些基础工程能力的熟练度。把通信任务拆掉后,剩下的逻辑和一份普通嵌入式点灯程序没有本质区别,但要做到「不卡死、不乱跳、能循环」就需要认真设计。适合正在备赛的学生,同时也适合做 NB 模组、LoRa 节点这类低功耗终端开发的工程师——竞赛题里的菜单交互往往就是产品端上位机或本地调试界面的雏形。本篇就按工程推进的顺序,从 CubeMX 初始化一路拆到按键边沿检测的边界处理。

2. CubeMX 初始化与 HAL 库外设配置:按键、LED、定时器、I2C 与液晶屏

2.1 硬件资源划分:引脚给谁,为什么用定时器不用 delay

拿到题先做资源映射。B 卷工程一般跑在 STM32L0 或 STM32L1 系列上,从源文件列表能看到stm32l0xx_hal_i2c.cstm32l1xx_hal_tim.c同时存在,说明题目原本就是要兼顾两个平台移植的——这也是我用 STM32CubeMX 生成工程的直接原因:换芯片型号后外设代码能自动重生成,main.c里用户代码区的业务逻辑不受影响。

引脚分配遵循一个常见做法:LED 接推挽输出引脚,按键接内部上拉输入引脚,I2C 给液晶屏。竞赛底板上按键通常一端接 GND,按下时引脚读到低电平,所以配置成GPIO_MODE_INPUT+GPIO_PULLUP最稳。LED 则看底板是否经过反相驱动,如果直连 MCU 引脚就用推挽输出;如果走 ULN2003 或三极管,驱动逻辑要反着写,这点后面单说。

延时方案是这道题第一个技术分水岭。新手多半在main里用HAL_Delay(500)实现交替亮灭,但HAL_Delay依赖 SysTick,一旦中断里也调用就会互相阻塞,而且呼吸灯需要每 10ms 甚至更短间隔调整一次占空比,用延时写出来的代码几乎没法加入按键响应。正确的做法是建立一个 10ms 的定时器时基中断,在主循环里用计数器做节拍,按键扫描、呼吸灯步进、交替亮灭间隔全部挂在这同一个节拍上。

2.2 定时器时基:TIM2 产生 10ms 中断的参数计算

L0 系列内部默认用 MSI 或 HSI 时钟,竞赛场景直接选内部 RC 即可,8MHz 或 16MHz 都够用。这里以 8MHz 时钟、TIM2 为例子:

static void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceCfg = {0}; TIM_MasterConfigTypeDef sMasterCfg = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 79; /* 8MHz / 80 = 100kHz,即 0.01ms 计一次 */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 999; /* 计满 1000 次产生更新中断:1000 * 0.01ms = 10ms */ htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_Base_Init(&htim2) != HAL_OK) { Error_Handler(); } if (HAL_TIM_Base_Start_IT(&htim2) != HAL_OK) { Error_Handler(); } }

分频系数选 79 而不是 80,是因为 STM32 定时器分频值实际等于Prescaler + 1,常用写法是PSC = 79表示 80 分频。8MHz 经过 80 分频得到 100kHz 计数频率,周期 0.01ms;再计 1000 个数触发一次中断,就是整整 10ms。这个时基看起来简单,但后面所有时间参数都建立在它上面:按键消抖需要连续采样 2~3 次,20~30ms;呼吸灯从灭到最亮经验值是 1 秒左右,也就是 100 个节拍;交替亮灭题目要求间隔 0.5 秒,正好 50 个节拍。选择 10ms 是这几个需求的最小公分母,改短了中断太频繁浪费功耗,改长了呼吸灯步进会变粗出现跳变。

注意这里用的是基本定时器中断而不是 PWM 输出模式,原因是呼吸灯占空比动态变化,用硬件 PWM 要额外配置 TIM3 的通道映射和 GPIO 复用功能,增加移植工作量;而用定时器中断 + 主循环更新 IO 电平,任意 GPIO 都能实现,代码可读性也更好。

2.3 按键 GPIO 初始化与消抖思路

GPIO 初始化同样在 CubeMX 里做,核心代码如下:

void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); /* LED1、LED2:推挽输出,初始化为低电平,避免上电瞬间误亮 */ GPIO_InitStruct.Pin = LED1_Pin | LED2_Pin; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(LED_GPIO_Port, &GPIO_InitStruct); /* KEY2、KEY3、KEY4:上拉输入,按下为低电平 */ GPIO_InitStruct.Pin = KEY2_Pin | KEY3_Pin | KEY4_Pin; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(KEY_GPIO_Port, &GPIO_InitStruct); }

按键消抖不在 GPIO 初始化里做,而在中断节拍里做状态机判断。一个容易踩的坑是:直接在HAL_GPIO_EXTI_Callback里处理菜单移动,因为按键按下的瞬间会产生多次毛刺,一次物理按压可能触发多次回调,菜单光标会跳过一格。后面第 4 章会写边沿检测的具体实现。

2.4 I2C 与液晶屏初始化的工程组织

从源文件里的stm32l0xx_hal_i2c.cstm32l1xx_hal_i2c.c能看出来,工程已经初始化了 I2C 外设,液晶屏大概率挂在 I2C 总线上。竞赛底板常见的做法是 1602 字符屏或 0.96 寸 OLED,挂在 I2C1 上,地址 0x27 或 0x3C,CubeMX 配置时钟为 Standard Mode 100kHz 即可。这里有个值得注意的工程细节:所有显示初始化放在外设初始化之后、主循环之前,因为屏幕驱动里往往有自检延时和清屏操作,阻塞式执行 50ms 不影响业务逻辑,只要别放进中断里就行。

USB 转串口、ADC、SD 卡这些外设初始化代码在源文件里也存在,但 B 卷这道题用不到,CubeMX 生成后保留即可,不要改动它们的初始化顺序——这能避免竞赛现场临时发现其他考题功能异常。

3. LED 三种工作模式的驱动实现:常亮、呼吸、交替亮灭

3.1 呼吸灯原理:为什么线性占空比就够了

呼吸灯的关键是让 LED 亮度周期性变化,本质是周期性改变 PWM 占空比或导通时间。严格说人眼对亮度的感知是对数关系,线性变化的占空比在人眼看来会是「前半段变化慢、后半段变化快」,而正弦或 Gamma 曲线更平滑。但竞赛评分看的是肉眼效果和 0.5 秒间隔是否准确,线性三角波已经完全够用,而且代码边界比正弦查表简单得多,不容易出现数组越界。

实现思路:在 10ms 节拍里维护一个计数器,计数器从 0 递增到峰值后翻转方向递减,计数器的值映射成 LED 的导通时间。按 1 秒到达峰值、2 秒一个完整呼吸周期计算,10ms 节拍下需要 100 步完成上升,每步步进 1%。我通常将占空比放大 100 倍存储,避免浮点运算:

#define BREATH_PEAK_TICKS 100 /* 100 * 10ms = 1s 到达峰值,一个周期约 2s */ #define DUTY_MAX 1000 /* 归一化占空比最大值 1000,对应 100.0% */ void Breathe_Task(void) { static uint16_t tick_cnt = 0; static uint8_t rising = 1; uint16_t duty_x100; /* 每 10ms 执行一次 */ tick_cnt++; if (tick_cnt >= BREATH_PEAK_TICKS) { tick_cnt = 0; rising = !rising; } /* 上升段:0 -> 990;下降段:990 -> 0 */ duty_x100 = rising ? (tick_cnt * 10) : ((BREATH_PEAK_TICKS - tick_cnt) * 10); /* 将归一化占空比换算成引脚电平脉宽: 一个 10ms 节拍内,前 duty_x100/1000 时间置高,剩余置低 */ Set_LED_Duty(duty_x100); }

Set_LED_Duty是具体驱动函数,两种常见写法。第一种用硬件 PWM:配置 TIM3 的 CH1/CH2 输出,ARR = 999,CCR 范围 0~999,调用__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty_x100)直接赋值,PWM 频率 1kHz 左右,50Hz 以上人眼就看不到闪烁。第二种用软件模拟:记录duty_x100,在下一个 10ms 中断里把 LED 引脚拉高,同时启动一个微秒延时到对应占空比后拉低,这种方式不占用定时器通道,但中断里做延时要注意总时长不能超过节拍周期,否则会丢失节拍。

这里最容易被忽视的是步进精度。如果直接用tick_cnt * 100~1000范围内步进,看起来没问题,但假如题目要求呼吸周期是 3 秒而不是 2 秒,峰值点数变成 150,步进就需要 1000/150 ≈ 6.67,取整后会产生累计误差。我的习惯是把节拍数和占空比上限都设为 2 的整数倍关系,或者用分子分母分别计数,考试现场改参数时不容易出错。

3.2 交替亮灭:0.5 秒间隔的精准节拍控制

交替模式的题目要求是:LED1 亮则 LED2 灭,LED1 灭则 LED2 亮,间隔 0.5 秒。也就是每 0.5 秒翻转一次状态,周期 1 秒。代码格外简单:

#define ALT_TOGGLE_TICKS 50 /* 50 * 10ms = 0.5s */ void Alternate_Task(void) { static uint16_t alt_tick = 0; alt_tick++; if (alt_tick >= ALT_TOGGLE_TICKS) { alt_tick = 0; HAL_GPIO_TogglePin(LED_GPIO_Port, LED1_Pin | LED2_Pin); } }

这里有一个隐含前提:LED1 和 LED2 的输出电平必须初始化为互补状态。如果上电时两个引脚都是低电平,第一次翻转后两个一起亮,交替逻辑就错了。正确的初始化是在MX_GPIO_Init之后立刻把 LED2 拉高,让状态从「LED1 灭、LED2 亮」开始,这样第一次翻转恰好变成「LED1 亮、LED2 灭」,与题目表述一致。另一个更稳妥的写法是只翻转 LED1,然后 LED2 的状态取反:

HAL_GPIO_WritePin(LED_GPIO_Port, LED1_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LED_GPIO_Port, LED2_Pin, GPIO_PIN_RESET);

这样用两条语句显式表达互补状态,而不是依赖 Toggle 的初始条件,排错时更容易看出问题。

3.3 电平反相问题:经过 ULN2003 驱动的 LED 怎么写

竞赛底板不是所有 LED 都直连单片机的。有些板子为了驱动大电流数码管和多个 LED,会引入 ULN2003 达林顿管阵列,输入高电平输出低电平,LED 接在输出端和 VCC 之间。这时你会发现:单片机引脚输出 1,LED 灭;输出 0,LED 亮,逻辑刚好反了。

处理方式是在驱动层做一次电平转换,而状态机里仍然用「亮 / 灭」的逻辑值:

底板类型LED 亮LED 灭驱动写法
直连 GPIOGPIO_PIN_SETGPIO_PIN_RESET逻辑值直接写引脚
ULN2003 反相GPIO_PIN_RESETGPIO_PIN_SET写前取反

封装这样一个底层函数,让模式状态机完全不感知硬件差异:

void Led_Set(uint16_t pin, uint8_t on) { #if defined(LED_DRIVE_INVERTED) HAL_GPIO_WritePin(LED_GPIO_Port, pin, on ? GPIO_PIN_RESET : GPIO_PIN_SET); #else HAL_GPIO_WritePin(LED_GPIO_Port, pin, on ? GPIO_PIN_SET : GPIO_PIN_RESET); #endif }

这样写之后,呼吸和交替模式里所有对 LED 的赋值都走Led_Set,万一竞赛现场发现灯亮灭反了,只需要在编译宏里切换LED_DRIVE_INVERTED一个开关,不用改任何业务代码。IO 不够时用 ULN2003 救急方案也是同样的思路:它解决的是驱动能力问题,而不是逻辑问题,逻辑必须自己封装好。

3.4 模式状态机:三种模式的无缝切换

三种模式虽然逻辑独立,但共享同一组 LED 引脚,所以需要一个顶层状态机统一调度,否则从一个模式切换到另一个模式时,残留的 PWM 占空比和电平状态会互相冲突:

typedef enum { MODE_ALWAYS_ON = 0, MODE_BREATH, MODE_ALTERNATE } LightMode_t; static LightMode_t cur_mode = MODE_ALWAYS_ON; void Mode_Task(void) { switch (cur_mode) { case MODE_ALWAYS_ON: /* 常亮:占空比锁定 100%,同时确保两个 LED 都亮 */ Led_Set(LED1_Pin, 1); Led_Set(LED2_Pin, 1); break; case MODE_BREATH: Breathe_Task(); break; case MODE_ALTERNATE: Alternate_Task(); break; default: break; } }

进入呼吸模式前应该把 LED 置为灭,因为呼吸任务从tick_cnt = 0开始,第一个节拍亮度接近 0,但如果上一个模式是常亮且没有清引脚,LED 会先保持满亮再突然变暗,视觉上有跳变。最好的做法是在菜单确认处调一个Led_ModePrepare()函数做模式前置条件复位。呼吸模式的全局duty_x100tick_cnt也要在切换时归零,让每次进入都从灭到亮的完整流程开始。

4. 菜单光标移动与重复进入的边界处理

4.1 按键事件:用边沿检测而不是电平检测

菜单需求是 KEY2 向上移动光标,KEY3 向下移动,KEY4 确认。如果直接在HAL_GPIO_ReadPin检测到低电平就移动光标,会导致按住按键时光标连续跳动,甚至一次按下移动多格。正确做法是识别「按下」这个动作,而不是「按下」这个状态,也就是边沿检测。在 10ms 节拍里每拍读一次按键,与上一次采样的稳定值比较,只有从 1 变到 0 时才产生一次事件:

typedef struct { uint8_t stable_level; /* 消抖后的稳定电平 */ uint8_t last_raw; /* 上一次原始采样值 */ uint8_t press_event; /* 本次节拍是否产生按下事件 */ } Key_t; void Key_Scan(Key_t *key) { uint8_t level = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY2_Pin); if (level == key->stable_level) { key->press_event = 0; /* 电平未变化,不产生事件 */ } else { /* 连续两次采样一致,说明不是毛刺 */ if (level == key->last_raw) { key->stable_level = level; key->press_event = (level == 0) ? 1 : 0; } else { key->press_event = 0; } } key->last_raw = level; }

这段代码的消抖逻辑是:第一次读到变化时不立刻采信,等下一次采样仍然是新电平才确认。10ms 的采样间隔意味着抖动宽度小于 20ms 的毛刺都会被过滤掉。机械按键抖动一般 5~15ms,这个参数是足够的。如果你希望更保险,可以在stable_level变化后再连续确认 3 次再产生事件,代价是按键响应延迟到 30ms,实际手感和灵敏度会有可感知的差别,竞赛场景 20ms 更合适。

4.2 光标移动的菜单边界:到顶和到底怎么处理

菜单只有三项,移动范围是 0、1、2。题目要求是「对 < 符号进行上下的移动」,并没有说光标到底后要循环回顶部。实际竞赛中两种风格都有人写,循环式(0 减 1 变 2)更适合选项少的菜单,锁死式(到 0 后不再上移)更符合题目截图里「三项」的直觉。我用的锁死式:

static uint8_t menu_pos = 0; /* 0=常亮,1=呼吸,2=交替 */ void Menu_Task(void) { if (key2_event) { if (menu_pos > 0) { menu_pos--; } } if (key3_event) { if (menu_pos < 2) { menu_pos++; } } if (key4_event) { /* 直接执行当前高亮项,不需要额外模式切换函数 */ cur_mode = (LightMode_t)menu_pos; mode_dirty = 1; } }

这里有个容易忽略的点:key4_event确认之后不要清空menu_pos。如果每次确认后回到第一项,用户操作三次便需要按两次 KEY2 才能重新选呼吸模式,明显违背题目「能重复以上步骤」的要求。保持光标位置不变,下一次按 KEY4 直接执行同一模式,才是合理的用户体验。mode_dirty标志位的作用是告诉显示层「菜单内容变了」,在液晶屏上刷新光标位置和对应的模式名称,这是 LOW 层LCD_DispStr驱动之外的业务逻辑层设计,显示刷新频率没必要做到每 10ms 一次,只在实际变化时刷新可以避免屏幕闪烁。

4.3 确认后的模式初始化:避免模式切换时的残影

最后一个细节在key4_event分支里补一个模式预处理。前面提到呼吸模式内部有静态计数器tick_cntrising方向标志,如果上次退出呼吸模式时刚好停在rising = 0(下降段),再次进入时 LED 会从接近灭的状态开始下降,最终停在灭状态,用户会以为呼吸灯没工作。确认代码里补上复位:

if (key4_event) { cur_mode = (LightMode_t)menu_pos; /* 复位所有模式内部状态,保证每次进入都从初始相位开始 */ Breathe_Reset(); Alternate_Reset(); Led_Set(LED1_Pin, 0); Led_Set(LED2_Pin, 0); }

Breathe_Resettick_cntrising清零,Alternate_Resetalt_tick清零并把 LED1 置为灭、LED2 置为亮。这套「账户初始化」动作在按键确认时统一执行,比在每个模式任务内部做条件判断要干净得多——状态机切换的职责只属于菜单层,模式任务本身只负责「运行」,不负责「进入时怎么办」。全部逻辑做完后再看按键事件:key2_eventkey3_eventkey4_event这三个事件标志必须在主循环末尾统一清零,而不是在各自分支里清零,否则多个按键同时按下时会发生事件丢失或重复处理。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 5:59:45

基层老年人体检系统实战:SpringBoot+Vue闭环业务设计

简介&#xff1a;本资源是一套完整的基于SpringBoot的老年人体检管理系统毕业设计项目源码&#xff0c;面向计算机专业本科生及Java初学者&#xff0c;解决老年人健康档案管理、体检预约、报告查询等实际业务场景需求。包内共813个文件&#xff0c;涵盖125个Java后端逻辑文件、…

作者头像 李华
网站建设 2026/9/14 5:59:16

Python处理地级市债务数据:zip解开到面板重打包全流程

简介&#xff1a;这份数据集覆盖2007—2023年我国地级市层面的地方债、政府债与隐性债务关键指标&#xff0c;适用于研究地方政府债务风险、财政可持续性、区域金融稳定以及城市面板数据建模的学者、研究生和行业分析人员。数据按地级市口径逐年整理&#xff0c;时间跨度17年&a…

作者头像 李华
网站建设 2026/9/14 5:57:59

PhoneGap 2.x视频处理插件开发:跨平台缩略图生成实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 5:56:40

Matlab GNSS观测仿真:从卫星信号到定位残差的全链路建模

简介&#xff1a;本资源是一套基于MATLAB开发的全球导航卫星系统&#xff08;GNSS&#xff09;观测数据处理与仿真教学系统&#xff0c;面向计算机、电子信息工程及应用数学等专业的本科生开展课程设计、期末大作业或毕业设计参考使用。系统完整实现GNSS观测值模拟、误差建模、…

作者头像 李华
网站建设 2026/9/14 5:56:30

Unity老项目迁移WebGL实战:两小时将塔防Demo搬进浏览器

开头就不另起标题了&#xff0c;咖啡还没来得及凉&#xff0c;项目已经从Unity工程变成浏览器里能直接跑的一关塔防。我说的就是这个事&#xff1a;2018年写的一个Unity版“保卫萝卜”Demo&#xff0c;三个月前还躺在硬盘里连工程文件都快忘了&#xff0c;这次周末抽了两个小时…

作者头像 李华
网站建设 2026/9/14 5:56:05

基于Keras实现Faster R-CNN的人群口罩检测实战指南

简介&#xff1a;面向需要完成毕业设计或算法实践的读者&#xff0c;这份资源以Keras搭建Faster R-CNN框架&#xff0c;在VOC格式的口罩数据集上完成训练&#xff0c;实现人群场景中是否佩戴口罩的自动检测与识别。压缩包内共57个文件&#xff0c;包含Python模型脚本、数据集标…

作者头像 李华