简介:面向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.c和stm32l1xx_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.c和stm32l1xx_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 * 10在0~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 灭 | 驱动写法 |
|---|---|---|---|
| 直连 GPIO | GPIO_PIN_SET | GPIO_PIN_RESET | 逻辑值直接写引脚 |
| ULN2003 反相 | GPIO_PIN_RESET | GPIO_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_x100和tick_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_cnt和rising方向标志,如果上次退出呼吸模式时刚好停在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_Reset把tick_cnt和rising清零,Alternate_Reset把alt_tick清零并把 LED1 置为灭、LED2 置为亮。这套「账户初始化」动作在按键确认时统一执行,比在每个模式任务内部做条件判断要干净得多——状态机切换的职责只属于菜单层,模式任务本身只负责「运行」,不负责「进入时怎么办」。全部逻辑做完后再看按键事件:key2_event、key3_event、key4_event这三个事件标志必须在主循环末尾统一清零,而不是在各自分支里清零,否则多个按键同时按下时会发生事件丢失或重复处理。
本文还有配套的精品资源,点击获取