
简介这份代码包对应2024年蓝桥杯第十五届物联网省赛的完整实现面向备赛学生、指导教师及需要复现赛题功能的嵌入式学习者聚焦矩阵按键扫描、电位器取值与LoRa无线通信等核心任务。压缩包共371个文件、约11.27MB以.h头文件、.c源文件、.o/.d/.crf编译中间文件为主并含.uvprojx/.uvoptx工程配置、.ioc CubeMX配置以及.hex/.axf烧录产物保留了两套LoRa A/B节点工程的开发痕迹。目前已有189人学习。读者可从中获取双节点通信的赛题级代码框架理解STM32L0 HAL库下I2C、TIM、SPI等外设的初始化与调用方式对照矩阵按键输入处理、电位器模拟量采集和LoRa数据收发的实现逻辑用于实验复现、代码对照与调试排错适合具备一定嵌入式基础的学习者查漏补缺。1. 三个模块、一张卷子省赛真正拉开分差的地方考前最后一次模拟有人矩阵按键一次按出三个键值LoRa 那头整包丢了——不是驱动不会写是时序没排开。物联网省赛的卷面通常就压在三块上4×4 矩阵按键做本地输入LoRa 做点对点收发电位器做 ADC 取值再上报显示。三块单独调都简单串成一条链路就到处是坑。拿分顺序几乎是固定的先让按键和 ADC 这两条本地链路稳下来再打通 LoRa 的一问一答最后把显示和上报接成闭环。这篇笔记按考什么、高频怎么考、代码怎么落地一路拆适合已经能点亮板子、但题面一变就卡壳的人。2. 考点分布一张卷子三块分先抢哪一块省赛这类题的评分逻辑和平时写 Demo 完全不是一回事。平时写 Demo你只要功能跑通一次就算成功考场上评的是稳定性——按键连按 20 次不能多报也不能漏报电位器拧到档位边界不能来回跳LoRa 丢一包要能自己补回来。所以别急着写功能先判断哪一块分最稳、哪一块最容易翻车。2.1 三个模块的权重与投入顺序按往年卷面结构的大致权重看三块的分布大致是这样具体分值以当年题面为准这里的比例只是帮你决定时间怎么切模块常见考查深度题面出现概率建议投入时间占比易翻车点矩阵按键短按选菜单、长按确认、键值上报高25%消抖和长按判定写错电位器 ADC取原始值、换算百分比、分档显示高25%不滤波、档位来回跳LoRa 通信点对点一问一答、周期性上报高35%收发状态机阻塞显示与整体联调本地显示 变化上报中15%刷屏拖慢主循环这个比例背后的道理很直白LoRa 是三块里唯一“你写对了也可能不通”的部分它占的时间必须最多而且要预留出排查接不通的时间。按键和 ADC 是纯本地逻辑只要扫描函数和采样函数写干净分基本就是送分的。现实里血泪经验是——很多人把 60% 时间耗在调 LoRa结果按键的长按没做直接丢一整问。2.2 真题常见组合本地采集 → 无线上报 → 显示回读这类卷子很少单独问“按键怎么扫”它喜欢把三块串成一条动作链。典型的题面长这样按下某个矩阵按键本地读取电位器的当前档位并显示同时通过 LoRa 把这组数据发给对端对端收到后回一个应答本地收到应答才点亮对应指示灯。拆开看这条链里藏了四个判分点按键事件是否只触发一次不能连发三帧、ADC 值是否经过换算而不是直接甩原始值、LoRa 帧里有没有必要的标识字段对端得知道这包是“档位上报”而不是别的东西、应答超时后有没有重发或至少不卡死。很多人前面都对卡在最后一条——发完不等应答就继续发下一包或者死等应答把主循环堵死了。答题时建议按这个顺序落笔先把“采样—显示”这条本地闭环做出来并在屏幕上看到数字变化再在按键事件里挂上“把当前值填进帧、置发送标志”最后补 LoRa 的发送与应答解析。每完成一段就烧一次板子验证别一口气写完再调那样出问题时你根本不知道断在哪一环。2.3 主循环怎么排非阻塞是唯一的正确答案考场上最常见的结构性问题是把HAL_Delay()和while(等待标志)写进主循环。按键一按程序在那儿等 20ms 消抖LoRa 一发程序在那儿等 TxDone。两个等待叠一起接收中断里的数据就没人取帧直接丢。正确做法是节拍驱动用HAL_GetTick()切出 1ms、10ms、100ms 三个时间片1ms 给按键扫描和消抖计时10ms 给 LoRa 状态机推进100ms 给屏幕刷新。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_SPI1_Init(); /* LoRa 走 SPI */ Lora_Init(); OLED_Init(); uint32_t t1 HAL_GetTick(), t10 t1, t100 t1; while (1) { uint32_t now HAL_GetTick(); if (now - t1 1) { /* 1ms按键状态机 消抖计时 */ t1 now; key_event_t ev Key_Tick1ms(); if (ev ! KEY_EVT_NONE) Key_OnEvent(ev); } if (now - t10 10) { /* 10ms无线收发状态机推进 */ t10 now; Lora_TxTick10ms(); Lora_RxTick10ms(); } if (now - t100 100) { /* 100ms刷屏别放主循环里裸刷 */ t100 now; UI_Refresh(); } } }这段骨架的关键在三个时间片的职责划分按键扫描必须在 1ms 节拍里跑因为消抖窗口就是按毫秒数算的LoRa 状态机放 10ms既不会太频繁占用 SPI也不会让超时判定粗糙到几百毫秒才反应屏幕刷新压到 100ms一是屏幕本身刷太快肉眼看不出差别二是 I2C 刷一整屏动辄几毫秒塞进 1ms 节拍里会把按键采样拖出抖动。三个if用的是now - t 周期而不是now % 周期 0前者不怕HAL_GetTick()回绕后者在 tick 溢出时会漏一拍——这个细节平时看不出来跑够 49 天才会现形但改起来就一行没必要省。3. 矩阵按键与电位器两套输入代码怎么写才不翻车这两块放一起讲是因为它们的错法高度相似都能读出数但读出来的数“不干净”。按键不干净表现为一次按下报多次事件电位器不干净表现为数值抖、档位跳。考场评分对这两处的容忍度极低题面只要写了“每按一次”多报一次就是错。3.1 4×4 矩阵按键的扫描原理4 行 4 列共 16 个键靠的是逐行拉低、读列线判断哪一列为低。硬件上一般行线配成推挽输出列线配成输入并开内部上拉——列线必须是上拉否则没按的时候是悬空读出来是随机值这是新手最常犯的硬件配置错误。扫描函数要写成“读一次就返回”不带任何延时等待/* 行线推挽输出列线输入 内部上拉。返回 0~15无键返回 0xFF */ static GPIO_TypeDef *const ROW_PORT[4] {ROW0_GPIO_Port, ROW1_GPIO_Port, ROW2_GPIO_Port, ROW3_GPIO_Port}; static const uint16_t ROW_PIN[4] {ROW0_Pin, ROW1_Pin, ROW2_Pin, ROW3_Pin}; static GPIO_TypeDef *const COL_PORT[4] {COL0_GPIO_Port, COL1_GPIO_Port, COL2_GPIO_Port, COL3_GPIO_Port}; static const uint16_t COL_PIN[4] {COL0_Pin, COL1_Pin, COL2_Pin, COL3_Pin}; static uint8_t Key_RawScan(void) { for (uint8_t row 0; row 4; row) { /* 只把当前行拉低其余行拉高避免两行同时导通读出错键 */ for (uint8_t i 0; i 4; i) HAL_GPIO_WritePin(ROW_PORT[i], ROW_PIN[i], (i row) ? GPIO_PIN_RESET : GPIO_PIN_SET); for (volatile uint8_t d 0; d 40; d) { __NOP(); } /* 几微秒等行列电平稳定 */ for (uint8_t col 0; col 4; col) { if (HAL_GPIO_ReadPin(COL_PORT[col], COL_PIN[col]) GPIO_PIN_RESET) return (uint8_t)(row * 4u col); /* 键值约定行*4列 */ } } return 0xFF; /* 无键按下 */ }__NOP()那个小循环替代了HAL_Delay(1)作用是给行线电平一点稳定时间。用HAL_Delay(1)也能跑但一次扫描 4 行就是 4ms放进 1ms 节拍里会让节拍整体被拉长消抖计时就失真了。键值约定成行*4列是为了后面写映射表方便题面如果给了具体键位定义比如左上角是 1 号键在Key_OnEvent里查一张转换表就行别把映射逻辑焊死在扫描函数里。3.2 消抖、长按一个状态机全包消抖不需要复杂算法本质就一句话连续 20ms 读到同一个键值才算真按下。长按同理按住满 1000ms 报一次长按事件之后不再重复报直到松开。typedef enum { KEY_EVT_NONE 0, KEY_EVT_DOWN, KEY_EVT_LONG, KEY_EVT_UP } key_event_t; typedef enum { KS_IDLE 0, KS_DEBOUNCE, KS_HOLD, KS_WAIT_RELEASE } key_state_t; static key_state_t s_ks KS_IDLE; static uint8_t s_raw 0xFF; /* 消抖期间锁定的键值 */ static uint16_t s_tick 0; /* 计时单位 ms */ /* 每 1ms 调用一次返回本拍产生的按键事件 */ key_event_t Key_Tick1ms(void) { uint8_t cur Key_RawScan(); switch (s_ks) { case KS_IDLE: if (cur ! 0xFF) { s_raw cur; s_tick 0; s_ks KS_DEBOUNCE; } break; case KS_DEBOUNCE: if (cur ! s_raw) { s_ks KS_IDLE; } /* 电平变了判为抖动重来 */ else if (s_tick 20) { s_tick 0; s_ks KS_HOLD; return KEY_EVT_DOWN; } break; case KS_HOLD: if (cur 0xFF) { s_ks KS_WAIT_RELEASE; s_tick 0; } else if (s_tick 1000) { s_tick 0; s_ks KS_WAIT_RELEASE; return KEY_EVT_LONG; } break; case KS_WAIT_RELEASE: if (cur 0xFF) { s_ks KS_IDLE; return KEY_EVT_UP; } break; } return KEY_EVT_NONE; }四个状态的职责很清楚KS_DEBOUNCE负责剔除抖动并产生按下事件KS_HOLD负责计时长按并且“只报一次”KS_WAIT_RELEASE负责等松手、防止松手瞬间再触发一次按下。s_raw在这里的作用是锁定候选键值——如果 20ms 窗口内读到的键值换了说明是抖动或者手滑直接退回空闲这样多键误触基本不会漏到上层。一个常被忽略的点长按事件在KS_HOLD里报完之后状态立刻切到KS_WAIT_RELEASE所以长按之后不会再跟一个KEY_EVT_DOWN但松手时会有一个KEY_EVT_UP。上层如果只关心“按下了”就在KEY_EVT_DOWN里做事如果题面要求长按进入设置、短按切换选项那就要在KEY_EVT_UP时判断这次按压有没有产生过长按用一个标志位记住即可别在KEY_EVT_DOWN里就把动作做掉。3.3 电位器 ADC采样、滤波、映射成档位电位器接的是 ADC 单通道12 位分辨率参考电压一般是 3.3V所以原始值是 0~4095。题面常见的三问取原始值、换算成百分比、分成若干档并显示。/* 采样 n 次取算术平均n8 是抖动抑制和采样耗时的折中 */ static uint16_t Pot_ReadRaw(uint8_t n) { uint32_t sum 0; for (uint8_t i 0; i n; i) { HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 2) HAL_OK) sum HAL_ADC_GetValue(hadc1); HAL_ADC_Stop(hadc1); } return (uint16_t)(sum / n); /* 0 ~ 4095 */ } /* 原始值换算百分比用 32 位中间量避免 raw*100 溢出 16 位 */ uint8_t Pot_ToPercent(uint16_t raw) { return (uint8_t)((uint32_t)raw * 100u / 4095u); }n取 8 不是随手写的单次采样在电位器拧动时会有十几到几十个 LSB 的跳变8 次平均能把抖动压到肉眼看不到的程度而 8 次轮询转换总耗时通常在几百微秒量级塞进 100ms 的 UI 节拍里毫无压力。(uint32_t)raw * 100u这个强制转换必须加——raw最大 4095乘以 100 是 409500已经超出 16 位无符号整数的范围不转换就会截断成错误值。分档比百分比更容易踩坑。如果按固定阈值切档电位器停在边界附近时噪声会让档位在相邻两档之间反复横跳屏幕上的数字看着就像坏了。解决办法是给每档边界加迟滞回差/* 5 档边界 819/1638/2457/3276每档进出留 80 的迟滞防止档位抖动 */ static uint8_t Pot_UpdateLevel(uint16_t raw) { static uint8_t lv 0; static const uint16_t edge[5] {0, 819, 1638, 2457, 3276}; const uint16_t hys 80; if (lv 4 raw edge[lv 1] hys) lv; /* 明显超过上一档边界才升档 */ else if (lv 0 raw hys edge[lv]) lv--; /* 明显低于本档下边界才降档 */ return lv; }迟滞的含义是升档要比边界高 80 才升降档要比下边界低 80 才降中间这 160 的带宽就是“缓冲区”。lv用static保存上一拍结果因为档位是有记忆的——这一步很多人写成纯函数、每次从raw硬算那迟滞就完全失效了。边界值 819/1638/2457/3276 是把 4096 五等分得到的分界点题面如果要求四档或六档按同样的等分方式改数组即可迟滞量保持在单档宽度的 5%~10% 比较合适。4. LoRa 通信从初始化到一问一答的完整时序LoRa 这块的高频失分不在“发不出去”而在“发出去之后乱了套”。没等模块发完就切接收、收到包不校验就当成有效数据、超时了不重发也不退出——这三件事任意一件出问题题面要求的一问一答就跑不通。4.1 初始化参数与信道配对LoRa 的初始化绕不开几个参数频段、扩频因子、带宽、编码率、前导码长度、同步字。这些参数收发两端必须完全一致任意一项不同模块照样返回“发送成功”对端就是收不到。所以调不通时第一件事不是怀疑代码是逐项核对两端的参数表。竞赛场景里这些参数通常由驱动层的初始化函数一次性配好题面不要求你改但你要知道去哪确认参数典型取值不一致的后果频段470MHz 段完全收不到像没发射扩频因子7~12能收到但解不出误码率极高带宽125kHz同上且速率对不上同步字两端私有值收发都在工作但互相听不见前导码长度8~12 符号偶尔丢前几包容易被误判成“不稳定”最后一行的坑特别隐蔽前导码短了接收端有时能同步上、有时同步不上表现出来就是“十包丢一包”你会以为是天线或者距离问题实际是参数配得太勉强。所以拿到板子先别改业务代码把两端参数表对齐、用最简的一发一收验证通路这一步花十分钟能省掉后面两小时的玄学排查。4.2 帧格式给每包数据加个身份裸发几个字节是很危险的做法。对端收到 5 个字节它怎么知道这是“档位上报”还是“应答包”题面一旦要求双向通信就必须自己定一层帧格式。/* 自定义帧帧头 命令字 长度 载荷 校验总长不超过 20 字节 */ typedef struct { uint8_t head; /* 固定 0xAA用于丢包后重新对齐 */ uint8_t cmd; /* 0x01 上报数据0x02 应答0x03 心跳 */ uint8_t len; /* payload 有效长度 */ uint8_t payload[16]; uint8_t crc; /* 从 head 到 payload 逐字节异或 */ } lora_frame_t; static uint8_t Frame_Crc(const lora_frame_t *f) { uint8_t c 0; const uint8_t *p (const uint8_t *)f; for (uint8_t i 0; i 3u f-len; i) c ^ p[i]; /* head/cmd/len payload */ return c; }四个字段各有分工head是重新对齐用的接收端在缓冲区里找 0xAA 就能跳过噪声cmd让对端知道该走哪条处理分支len决定了后面读多少字节也顺便限制了载荷长度防止越界crc是最便宜的完整性检查异或校验强度不高但足以筛掉大部分误码而且计算只要几微秒不影响主循环节拍。发之前先算crc再填进去收之后先重算一遍比对不一致直接丢包。这一步看起来多余实际上它把“收到一包乱码然后拿着乱码去解析”这种事故挡在门外——乱码进到业务层之后往往表现为屏幕显示奇怪的数字或者档位跳到不存在的值排查起来比丢包难受得多。4.3 收发状态机三个状态解决超时重发发送最怕写成阻塞等待。正确形态是一个小状态机每 10ms 推一步超时就重发重发满三次就放弃并回到空闲绝不把主循环焊死。typedef enum { TX_IDLE 0, TX_WAIT_DONE, TX_WAIT_ACK } tx_state_t; static tx_state_t s_tx TX_IDLE; static uint8_t s_retry 0; static uint16_t s_timeout 0; static lora_frame_t g_tx_frame; volatile uint8_t g_ack_flag 0; /* 接收中断里置位主循环里清 */ void Lora_StartTx(const lora_frame_t *f) { if (s_tx ! TX_IDLE) return; /* 上一包没走完直接丢弃新请求 */ g_tx_frame *f; s_retry 0; Lora_SendFrame(g_tx_frame); /* 驱动层写 FIFO 并置 TX 模式 */ s_tx TX_WAIT_DONE; s_timeout 0; } /* 每 10ms 调用一次非阻塞推进收发流程 */ void Lora_TxTick10ms(void) { switch (s_tx) { case TX_IDLE: break; case TX_WAIT_DONE: if (Lora_IsTxDone()) { /* 模块置起 TxDone 标志 */ s_tx TX_WAIT_ACK; s_timeout 0; } else if (s_timeout 50) { /* 500ms 还没发完判为异常并中止 */ Lora_AbortTx(); s_tx TX_IDLE; } break; case TX_WAIT_ACK: if (g_ack_flag) { /* 收到应答本次事务结束 */ g_ack_flag 0; s_tx TX_IDLE; s_retry 0; } else if (s_timeout 100) { /* 1s 无应答重发最多 3 次 */ if (s_retry 3) { Lora_SendFrame(g_tx_frame); s_tx TX_WAIT_DONE; } else { s_tx TX_IDLE; /* 放弃交给上层决定是否提示 */ s_retry 0; } s_timeout 0; } break; } }三个状态的切换逻辑对应三件现实里必须处理的事TX_WAIT_DONE处理的是“模块卡在发射状态”有些驱动在信号差的时候 TxDone 迟迟不来不设这个超时程序就永远等在那儿TX_WAIT_ACK处理的是“发出去但对端没收到”或者“对端收到了但应答丢了”靠重发兜底s_tx ! TX_IDLE时直接丢弃新请求是为了避免按键连按时把发送队列搞乱——题面要求“按一次发一包”那多余的请求本来就该丢。g_ack_flag必须加volatile。它在接收中断里置位、在主循环里读取编译器如果没看到volatile可能把if (g_ack_flag)优化成只读一次结果是中断置了位、主循环永远看不到表现就是“明明收到了应答却一直重发”。这个 bug 不会报错只会让你的成功率看起来忽高忽低属于典型的后悔药场景。接收侧同理中断服务函数只做两件事把数据搬进缓冲区、置标志位。解析、校验、应答组装全部放到主循环的 10ms 节拍里做这样即使解析耗时略长也不会阻塞其他中断。5. 易错点五个当场失分的写法下面五条都是考场上真实高频的错法按出现概率排。每一条都按“错在哪、为什么错、怎么答对”拆开对号入座比你从头重读代码更快。5.1 扫描函数里带延时把节拍拖垮错在哪Key_RawScan()里每个行用HAL_Delay(1)一次扫描 4ms放进 1ms 节拍后实际节拍变成 5ms消抖的 20ms 计数就只等于 4 次扫描长按 1000ms 也缩水成 200ms 左右。为什么错把“等电平稳定”和“消抖”两件事混在一起了。消抖本来就该由状态机按时间算扫描函数只需要保证这一次读到的电平是当下的真实电平。怎么答对扫描函数里只留几微秒的__NOP()空转所有时间判定交给 1ms 节拍里的计数器。如果担心扫描太快读到毛刺就在状态机里把消抖窗口从 20 加到 30而不是在扫描函数里加延时。5.2 列线没开上拉读出来全是低电平错在哪把列线配成普通输入浮空没按的时候引脚悬空读到的值随环境飘Key_RawScan()经常直接返回某个键值屏幕上数字自己跳。为什么错矩阵扫描依赖“按下才导通到低电平”这个前提前提成立的条件是没按时列线被上拉到高电平。浮空输入没有这个基准判断就失效了。怎么答对列线一律配成GPIO_MODE_INPUTGPIO_PULLUP。判键条件写成“读到低电平”而不是“读到高电平”。如果板子外部已经焊了上拉电阻内部上拉再开一次也无妨两者并联只会让上拉更强不影响逻辑。5.3 发完立刻切接收第一包应答总是收不到错在哪Lora_SendFrame()后面紧跟一句Lora_RxMode()模块还在发射状态就被切走了包没发完就算发出去了切换瞬间的应答也错过了。为什么错发送是异步的函数返回只代表数据写进了 FIFO不代表空口发完了。发送过程中模块状态机不允许直接跳到接收。怎么答对等Lora_IsTxDone()返回真再切接收并且用状态机的TX_WAIT_DONE兜一个 500ms 超时。切到接收后再开一个 1s 的应答窗口窗口内没等到就重发。这三步连起来才是一个完整的发送事务。5.4 ADC 取单次值直接显示数字乱跳错在哪HAL_ADC_GetValue()读一次就送去显示和分档屏幕末位一直在变档位在边界上反复横跳。为什么错电位器是机械器件滑动触点本身有接触噪声加上 ADC 量化误差单次采样波动几十个 LSB 很正常。不做处理就当成真值用等于把噪声直接展示出去。怎么答对采样 8 次取平均分档时加迟滞。这两步配合起来平均压住高频抖动迟滞压住边界抖动。注意迟滞需要静态变量保存上一拍档位写成纯函数等于没加。5.5 中断里改的变量没加 volatile错在哪g_ack_flag、接收缓冲区索引这类变量在中断里写、在主循环里读编译开优化后主循环读到的永远是初值表现为“中断明明进了标志位就是不生效”。为什么错编译器看不到中断会修改这个变量把循环里的读取提到循环外只做一次。这不是逻辑错误是编译器按标准允许的优化。怎么答对所有跨中断共享的简单变量都加volatile。共享的缓冲区或者结构体除了volatile还要保证写入顺序——先写数据再置标志读取端先看标志再读数据必要时在临界区关一下中断。这类问题没有报错信息只能靠写的时候就养成习惯。6. 答题技巧拿到题面后的 30 分钟动作清单题面发下来别急着开工程前 30 分钟做四件事比多写 200 行代码有用。时间块动作交付物0~8 分钟把题面拆成“输入 / 处理 / 输出”三列标出每个动作的触发条件一张手写链路图8~15 分钟核对硬件按键行列有没有上拉、LoRa 两端参数表是否一致、ADC 通道对不对三条勾选项15~25 分钟只写骨架三个时间片的主循环 空的事件处理函数烧进去确认不卡死能跑的空壳工程25~30 分钟填第一个模块通常是按键或 ADC验证通过再填下一个一条可验证的闭环这个顺序的核心是“先保证结构对再填功能”。空壳工程能跑起来说明时钟、初始化、主循环都没问题后面出问题就只可能是业务逻辑排查范围直接缩小一半。反过来如果一上来就写业务等到最后联调时发现卡死你分不清是驱动问题、时序问题还是逻辑问题只能从头一遍遍试。几个具体动作值得单独记住拿到题面第一件事是把“每按一次只发一包”“数值变化才上报”“超时重发三次”这类字眼圈出来这些词就是判分点写 LoRa 之前先验证两端参数表别信“默认配置应该一样”ADC 的换算一律用 32 位中间量别问为什么按这个写就行。还有一条带队时反复强调的经验每完成一个模块就把它单独烧一次板子验证验证通过了再动下一个模块。我见过太多人一口气写完三个模块再烧结果板子毫无反应只能一行行注释掉往回找最后发现是某个初始化函数调用顺序写反了本来两分钟能定位的事拖了四十分钟。还有一次屏幕显示正常、按键也有反应唯独 LoRa 十包丢九包重发逻辑、应答解析、帧格式全查了一遍都没问题最后拿两块板子背对背做了一发一收测试把两端初始化代码并排打印出来比对才发现同步字那一项写的是两个不同的值——模块一直报发送成功实际对面根本听不见。从那以后我每次开始无线部分之前都强制走一遍“两端参数逐行对齐、先做最简一发一收”的流程不管时间多紧都不跳。希望这些能帮你在考场上少走两步弯路。本文还有配套的精品资源点击获取