简介这是一套面向2024年蓝桥杯第十五届物联网省赛的完整工程代码与配套资料适合备赛选手、指导教师及希望练习嵌入式物联网综合开发的初中级读者可用来复盘赛题实现、搭建训练工程并对照排查编译与烧录问题。压缩包共371个文件约11.27MB以C源码、头文件、编译中间文件和Keil工程文件为主同时包含STM32CubeMX配置文件、HEX烧录文件及少量htm说明页工程结构与外设初始化配置较为完整。资源已有189人学习内容围绕矩阵按键扫描、电位器模拟量取值以及LoRa A/B双节点通信展开读者可参考其中的模块划分、HAL库外设调用和无线数据传输流程理解省赛题目的软硬件协同思路。对于需要快速建立可运行工程、对照调试记录或补齐物联网通信环节的读者这份代码包具有较高的参考与复用价值。1. 省赛现场那套三件套矩阵按键、LoRa 通信、电位器取值桌面上摆一块底板4×4 矩阵按键、一个带旋钮的电位器、一块插着天线的 LoRa 模块主控是一颗 32 位 MCU。任务书要求把按键值和电位器电压采上来组帧后用 LoRa 发到对面那块板子对面收到再回一帧确认。这就是省级物联网赛项里最典型的一类题目——外设不多但每一路都得稳其中一处发毛联调就会卡死。2024 年这类赛项的省赛代码真正能打的不是能跑而是四小时内跑到稳。矩阵按键要能扛住手抖和连击电位器取值要压掉 ADC 的抖动LoRa 通信要把 SPI 配置、频率换算、帧结构、超时重发全串起来。下面按我自己拆这套代码的顺序走一遍从工程骨架到三个模块的实现再到联调时最容易翻车的几个点。2. 工程骨架外设清单、目录分层与初始化顺序拿到这块板子第一件事不是写业务是把外设清单钉死。赛题改硬件是常事但省赛这一套的引脚和外设基本固定先把配置表列出来后面所有代码都围着它转改一处不会牵动全身。2.1 把外设和引脚先钉成一张表我一般会在工程根目录放一份board_map.h把每一路外设的端口、引脚、复用功能全写成宏。好处是后面不管是换板子还是补功能只动这一个文件。矩阵按键 4 行接推挽输出、4 列接上拉输入电位器走 ADC1 的一个通道LoRa 走 SPI1另外还要 NSS、RESET、DIO0 三根 GPIO调试口留一个串口用来打印状态和临时看变量。外设资源关键配置用途矩阵按键 行GPIO 推挽输出 ×4低速、初始高电平逐行拉低扫描矩阵按键 列GPIO 上拉输入 ×4内部上拉读列线判按下电位器ADC1 通道12 位、连续转换、DMA 循环取模拟电压LoRa 模块SPI1主机、8 位、CPOL0/CPHA0、≤10MHz读写寄存器与 FIFOLoRa 控制GPIO ×3NSS 推挽、RESET 推挽、DIO0 上拉输入片选、硬复位、中断调试串口USART115200-8-N-1打日志、临时观测这张表里有几个参数不能随手填。SPI 的时钟极性必须和模块手册对齐SX1278 这类芯片支持模式 0 和模式 3但模组厂给的例程一般用模式 0跟着用最省事改成模式 3 时如果 CPHA 弄反读回来全是 0xFF排查起来能耗掉半小时。ADC 那边采样时间别用默认的最小值电位器是机械滑动端源阻抗偏高采样时间短了读数会明显跳。2.2 目录按 BSP / APP / Protocol 三层切赛题代码最容易写成一个大main.c从头到尾几百行改一处崩一片。哪怕时间紧我也建议按三层切BSP 层只管寄存器和外设动作APP 层管采样、状态机、业务逻辑Protocol 层只管帧的打包和解析。这样 LoRa 换成串口透传时只需替换 BSP 里收发两个函数业务层一行不用动。Core/ ├── Inc/ │ ├── board_map.h # 引脚与外设宏定义唯一的硬件真相源 │ ├── bsp_key.h # 矩阵按键 扫描 事件 │ ├── bsp_adc.h # 电位器 采样 滤波 映射 │ ├── bsp_lora.h # LoRa 寄存器读写、收发 │ ├── proto_frame.h # 帧结构与 CRC │ └── app_task.h # 业务调度 └── Src/ ├── main.c # 只做初始化 主循环节拍 ├── bsp_key.c ├── bsp_adc.c ├── bsp_lora.c ├── proto_frame.c └── app_task.c这个结构的好处是联调时能快速定位。串口打出来按键值不对直接盯bsp_key.c帧解析失败proto_frame.c里有单测入口收发不通串口把bsp_lora.c里读回来的寄存器值打出来对一遍就知道卡在哪一步。分层不是给别人看的是自己半夜改代码时少走弯路的后悔药。2.3 main 里只留初始化和节拍main.c越薄越好初始化顺序有讲究先时钟和 GPIO再通信外设最后才是业务模块。LoRa 的复位脚要在 SPI 初始化之后再拉早拉了模块还没准备好接收配置。int main(void) { HAL_Init(); SystemClock_Config(); /* 先定主频后面所有时序都依赖它 */ MX_GPIO_Init(); /* 引脚复用、上下拉必须最先到位 */ MX_SPI1_Init(); /* LoRa 挂在 SPI1 上 */ MX_USART1_UART_Init(); /* 调试口出问题时唯一的眼睛 */ MX_ADC1_Init(); /* 电位器通道规则组 DMA */ BSP_Key_Init(); BSP_ADC_Start(); /* DMA 一旦启动就自己刷不用管 */ BSP_LoRa_Init(); /* 内部含硬复位 寄存器配置较慢 */ uint32_t t_key 0, t_sample 0; while (1) { uint32_t now HAL_GetTick(); if (now - t_key 10) { /* 按键 10ms 扫一次配合状态机消抖 */ t_key now; App_Key_Process(); } if (now - t_sample 100) { /* 采样 100ms 一次够用又不占带宽 */ t_sample now; App_Sample_Process(); } App_LoRa_Process(); /* 收帧、超时重发非阻塞 */ } }主循环里没有一句HAL_Delay全部用HAL_GetTick()做时间片。这是有血泪经验的一旦在业务里塞了HAL_Delay(100)LoRa 的接收窗口就会被错过对面发的帧你听不见现象是偶尔丢包实际是自己把耳朵堵上了。三个任务的周期分别是 10ms、100ms 和每圈都跑按键要快是为了不漏掉短按采样慢一点是为了让滤波窗口有意义LoRa 每圈轮询是为了及时取 FIFO。3. 矩阵按键与电位器两路输入的完整链路这两路放在一起讲是因为它们都是输入但脾气完全不同。按键是数字量问题出在机械抖动和时间维度上电位器是模拟量问题出在噪声和参考电压上。处理思路也不一样一个是状态机一个是滤波器。3.1 4×4 矩阵按键的行列扫描矩阵按键省引脚代价是任何时刻只能有一行被拉低其余行保持高电平然后去读四根列线哪根被拉低就说明哪一行的哪一列被按下。这个其余行保持高很关键扫描完一行必须把行线全部恢复成高电平否则下一轮扫描的起点就是脏的会出现按一个键出来两个键值的玄学现象。/* bsp_key.c —— 单次扫描返回 0~15 的键号0xFF 表示无键 */ static const uint16_t row_pin[4] {KEY_R0_PIN, KEY_R1_PIN, KEY_R2_PIN, KEY_R3_PIN}; static const uint16_t col_pin[4] {KEY_C0_PIN, KEY_C1_PIN, KEY_C2_PIN, KEY_C3_PIN}; uint8_t BSP_Key_ScanRaw(void) { uint8_t key 0xFF; for (uint8_t row 0; row 4; row) { /* 先把所有行拉高再只拉低当前行避免上一轮的残留电平 */ for (uint8_t i 0; i 4; i) HAL_GPIO_WritePin(KEY_ROW_PORT, row_pin[i], GPIO_PIN_SET); HAL_GPIO_WritePin(KEY_ROW_PORT, row_pin[row], GPIO_PIN_RESET); /* 行线电平建立需要时间配合列线上拉电阻的 RC 常数 */ for (volatile uint32_t d 0; d 300; d) { __NOP(); } for (uint8_t col 0; col 4; col) { if (HAL_GPIO_ReadPin(KEY_COL_PORT, col_pin[col]) GPIO_PIN_RESET) { key (uint8_t)(row * 4 col); break; } } if (key ! 0xFF) break; } /* 收尾全部拉高给下一轮一个干净起点 */ for (uint8_t i 0; i 4; i) HAL_GPIO_WritePin(KEY_ROW_PORT, row_pin[i], GPIO_PIN_SET); return key; }逐段看。外层循环遍历 4 行每轮先全高再单低这两步不能合并全高是为了清残留单低是为了选中。中间那个空循环是等电平稳定长度和列线上拉电阻、走线电容有关300 次空转在 80MHz 主频下大概几微秒够用如果板子走线长、上拉电阻大这个值要往上加加不够的表现是按下不灵敏要用力按才有反应。内层读列读到低电平就立刻算出键号并break跳出。注意break只跳出内层所以外面还要再判一次key ! 0xFF才跳出外层否则同一轮会把后面几行也扫完浪费时间。键号的编码方式是row * 4 col这个映射关系后面要和应用层的按键含义表对上。我一般会在bsp_key.h里再补一张表把 0~15 映射成上、下、确认、返回这类语义值业务代码只认语义值这样换个按键板布局只改表不改逻辑。3.2 消抖状态机与单击事件扫描函数只能告诉你此刻有没有键按下不能告诉你用户按了一下。手按下去的瞬间电平会抖十几毫秒扫描周期 10ms 的话会连续读到好几次有效值如果每次都给业务层发按键事件就会出现按一下菜单跳三层。解决办法是状态机。四个状态空闲、消抖中、已按下、等待释放。核心思路是连续两次读到同一个键才算真的按下以及释放也要确认一次才算完。typedef enum { KEY_IDLE, KEY_DEBOUNCE, KEY_HOLD, KEY_RELEASE } key_state_t; /* 每 10ms 调一次返回 0xFF 表示无事件否则返回一次按下事件 */ uint8_t BSP_Key_GetEvent(void) { static key_state_t st KEY_IDLE; static uint8_t last_raw 0xFF, stable 0xFF, cnt 0; uint8_t raw BSP_Key_ScanRaw(); switch (st) { case KEY_IDLE: if (raw ! 0xFF) { last_raw raw; cnt 0; st KEY_DEBOUNCE; } break; case KEY_DEBOUNCE: if (raw last_raw) { if (cnt 2) { stable raw; st KEY_HOLD; return stable; } } else { st KEY_IDLE; /* 抖回去了重新来 */ } break; case KEY_HOLD: if (raw 0xFF) { cnt 0; st KEY_RELEASE; } break; case KEY_RELEASE: if (raw 0xFF) { if (cnt 2) { st KEY_IDLE; } /* 释放确认准备下一次 */ } else { st KEY_HOLD; /* 是抖动不是松开退回去 */ } break; } return 0xFF; }参数上cnt 2配合 10ms 周期等于 20ms 的消抖窗口覆盖绝大多数轻触按键的抖动时间。如果你的按键手感偏硬、弹片回弹慢把阈值调到 330ms也行但别调太大超过 50ms 用户会觉得按了没反应。返回时机放在KEY_DEBOUNCE迁到KEY_HOLD的那一刻保证一次物理按下只产生一个事件。KEY_HOLD状态里如果还想做长按可以在这里加计时超过 1 秒再发一个长按事件短按和长按就能共用同一个键。3.3 电位器 ADC 的 DMA 循环采样电位器接 ADC 的一个通道最土的写法是在主循环里HAL_ADC_Start/HAL_ADC_GetValue/HAL_ADC_Stop轮询问题是这种写法会阻塞而且采样点不均匀。工程上的常见做法是规则组 连续转换 DMA 循环模式让 ADC 自己跑CPU 只在需要时去缓冲区取最新值。/* bsp_adc.c —— 双通道循环采样ch0 电位器ch1 内部基准 */ #define ADC_CH_NUM 2 #define ADC_OVS 16 /* 每通道 16 个采样点 */ uint16_t g_adc_buf[ADC_CH_NUM * ADC_OVS]; /* DMA 目的地必须是全局的 */ void BSP_ADC_Start(void) { /* 循环模式下 DMA 会无限刷新这块内存顺序是 ch0,ch1,ch0,ch1... */ HAL_ADC_Start_DMA(hadc1, (uint32_t *)g_adc_buf, ADC_CH_NUM * ADC_OVS); }g_adc_buf定义成全局不是为了好看而是因为 DMA 是直接在内存上搬数的局部变量的地址在函数返回后就失效了DMA 还在往那块栈上写表现是程序莫名跑飞或者数值随机——这种 Bug 极其难查属于典型的黑匣子。缓冲区里的排列是交错的索引 0 是 ch0 第 1 次索引 1 是 ch1 第 1 次索引 2 是 ch0 第 2 次……所以取某一通道的第 i 个采样点下标是i * ADC_CH_NUM ch。这个规律要记牢写错了取出来的就是隔壁通道的值现象是拧电位器数值不变但按键的时候数值会跳。3.4 中值平均滤波与量程映射12 位 ADC 的原始值在 0~4095电位器滑动端的接触噪声会让它在几个 LSB 之间来回跳。直接把这个值发出去对面显示的数字会一直在闪。先做滤波。16 个采样点排个序砍掉两头各 4 个中间 8 个求平均。这种做法比单纯的滑动平均响应更快又能压掉偶发的尖峰。static uint16_t ADC_ReadFiltered(uint8_t ch) { uint16_t tmp[ADC_OVS]; uint32_t sum 0; for (uint8_t i 0; i ADC_OVS; i) tmp[i] g_adc_buf[i * ADC_CH_NUM ch]; /* 插入排序16 个元素比快排省事且更快 */ for (uint8_t i 1; i ADC_OVS; i) { uint16_t v tmp[i]; int8_t j i - 1; while (j 0 tmp[j] v) { tmp[j 1] tmp[j]; j--; } tmp[j 1] v; } for (uint8_t i 4; i ADC_OVS - 4; i) sum tmp[i]; return (uint16_t)(sum / (ADC_OVS - 8)); }再做映射。赛题通常要求输出 0~100 的百分比或者实际的毫伏值。换算成毫伏要知道参考电压假设是 3.3Vuint16_t raw ADC_ReadFiltered(0); uint32_t mv (uint32_t)raw * 3300U / 4095U; /* 电位器两端不是满量程掐掉两头的死区再映射避免旋到底还是 98 */ int32_t pct ((int32_t)mv - 120) * 100 / (3200 - 120); if (pct 0) pct 0; if (pct 100) pct 100;掐头去尾这一步经常被忽略。电位器转动角度和阻值不是严格线性的两端还有一段接触不良的死区直接用mv * 100 / 3300会得到最小 3、最大 97这种结果题目要求 0 和 100 就扣分了。死区值没有标准答案拿万用表或者在串口上把两端的最小最大值打出来反填进去就行。4. LoRa 通信SPI 寄存器、配置序列与帧结构LoRa 模块不是调个库就能收发的外设它有一堆寄存器要按顺序写。好在流程是固定的复位、进睡眠、配频率、配调制参数、配 FIFO、切收发模式。把这套序列固化成函数剩下的就是帧结构和重发逻辑。4.1 SPI 读写寄存器的时序细节模块的 SPI 协议很简单地址字节的最高位表示读还是写写是 1读是 0后面跟着数据字节。难点在片选和时序。NSS 必须在整个两字节传输期间保持低电平中途拉高会把地址和数据切断芯片收到的就是废数据。/* bsp_lora.c —— 寄存器读写地址 bit7 1 表示写 */ #define REG_FIFO 0x00 #define REG_OP_MODE 0x01 #define REG_FRF_MSB 0x06 #define REG_IRQ_FLAGS 0x12 #define REG_PAYLOAD_LEN 0x22 static void LoRa_WriteReg(uint8_t addr, uint8_t val) { uint8_t tx[2] { (uint8_t)(addr | 0x80), val }; HAL_GPIO_WritePin(LORA_NSS_PORT, LORA_NSS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, tx, 2, 100); HAL_GPIO_WritePin(LORA_NSS_PORT, LORA_NSS_PIN, GPIO_PIN_SET); } static uint8_t LoRa_ReadReg(uint8_t addr) { uint8_t tx[2] { (uint8_t)(addr 0x7F), 0x00 }; uint8_t rx[2] { 0 }; HAL_GPIO_WritePin(LORA_NSS_PORT, LORA_NSS_PIN, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi1, tx, rx, 2, 100); HAL_GPIO_WritePin(LORA_NSS_PORT, LORA_NSS_PIN, GPIO_PIN_SET); return rx[1]; }读用的是TransmitReceive而不是先发后收因为 SPI 是全双工的发地址的同时就在收数据分成两次调用会把时序拉长某些模组在这个间隙会出问题。rx[0]是发地址时收到的无效字节真正有用的是rx[1]。超时参数给的 100ms对 10MHz 的 SPI 传两个字节来说绰绰有余。如果这里返回HAL_TIMEOUT八成是 SPI 外设没初始化好或者 NSS 被别的地方占了。4.2 从睡眠到发射的配置序列SX1278 这类芯片上电后处于睡眠模式要在睡眠模式下配置频率和调制参数配完再切待机、切发射。顺序错了芯片不认。static void LoRa_SetFrequency(uint32_t freq_hz) { /* FRF fRF * 2^19 / 32MHz芯片手册里的固定公式 */ uint32_t frf (uint32_t)((uint64_t)freq_hz * 524288ULL / 32000000ULL); LoRa_WriteReg(REG_FRF_MSB, (frf 16) 0xFF); LoRa_WriteReg(REG_FRF_MID, (frf 8) 0xFF); LoRa_WriteReg(REG_FRF_LSB, frf 0xFF); } void BSP_LoRa_Init(void) { /* 硬复位拉低至少 1ms再放开等模块内部起振 */ HAL_GPIO_WritePin(LORA_RST_PORT, LORA_RST_PIN, GPIO_PIN_RESET); HAL_Delay(2); HAL_GPIO_WritePin(LORA_RST_PORT, LORA_RST_PIN, GPIO_PIN_SET); HAL_Delay(10); LoRa_WriteReg(REG_OP_MODE, 0x80); /* 睡眠 LoRa 模式bit71 */ while ((LoRa_ReadReg(REG_OP_MODE) 0x80) 0) { } /* 等模式切换生效 */ LoRa_SetFrequency(470000000UL); /* 470MHz两端必须完全一致 */ LoRa_WriteReg(0x1D, 0x72); /* 带宽 125k / 编码率 4/5 / 显式头 */ LoRa_WriteReg(0x1E, 0x74); /* 扩频因子 SF7 CRC 使能 */ LoRa_WriteReg(0x20, 0x00); /* 前导码高字节 */ LoRa_WriteReg(0x21, 0x08); /* 前导码长度 8 */ LoRa_WriteReg(0x39, 0x12); /* 同步字两端一致才收得到 */ LoRa_WriteReg(0x0E, 0x00); /* FIFO 发送基址 */ LoRa_WriteReg(0x0D, 0x00); /* FIFO 读写指针归零 */ LoRa_WriteReg(0x09, 0x8F); /* PA 配置最大功率档 */ LoRa_WriteReg(REG_OP_MODE, 0x81); /* 切待机 */ }几个参数必须成对出现。频率决定载波两端写到同一个值才有物理层可言同步字相当于频道密码写 0x12 和 0x34 是识别不了的但接收端不会报错只是永远收不到这是最阴的一类坑。扩频因子和带宽必须一致SF 越大传得越远但速率越低赛题这种短距离场景 SF7 就够了。CRC 使能位在0x1E的 bit2两边都开或者都关一边开一边关的话收到的数据长度是对的但内容全错。4.3 自定义帧结构直接发裸数据在赛题里也能过但一旦加上对面回确认的要求就必须有帧头和校验。我用的结构是固定的两个字节帧头、目标地址、源地址、命令字、长度、负载、CRC8。偏移字段长度说明0帧头 11固定 0xAA用来快速对齐1帧头 21固定 0x55和帧头 1 组成双字节同步2目标地址1对端板子编号3源地址1本机编号4命令字10x01 上报数据、0x02 确认、0x03 心跳5长度1负载字节数不含头部和 CRC6 ~ 6len-1负载变长按键值与电位器百分比末字节CRC81对从帧头到负载末尾计算/* proto_frame.c —— CRC8多项式 0x07初值 0x00 */ static uint8_t CRC8(const uint8_t *p, uint8_t len) { uint8_t crc 0x00; while (len--) { crc ^ *p; for (uint8_t i 0; i 8; i) crc (crc 0x80) ? (uint8_t)((crc 1) ^ 0x07) : (uint8_t)(crc 1); } return crc; } uint8_t Proto_Build(uint8_t *out, uint8_t dst, uint8_t src, uint8_t cmd, const uint8_t *payload, uint8_t len) { if (len 32) return 0; /* 别超过模块 FIFO 的舒适区 */ uint8_t i 0; out[i] 0xAA; out[i] 0x55; out[i] dst; out[i] src; out[i] cmd; out[i] len; for (uint8_t k 0; k len; k) out[i] payload[k]; out[i] CRC8(out, i); /* CRC 覆盖帧头到负载末尾 */ return (uint8_t)(i 1); }CRC 的覆盖范围要固定算的时候从out[0]开始、长度为当前已写入的字节数校验的时候同理只是长度少一。这两处如果一边多算一个字节一边少算一个结果是自己发的自己校验不过很容易误判成模块问题。4.4 发送与接收的状态机发送不能阻塞等要给超时。发完立刻切回接收否则对面回的确认帧会漏掉。uint8_t BSP_LoRa_Send(const uint8_t *buf, uint8_t len) { LoRa_WriteReg(REG_OP_MODE, 0x81); /* 待机 */ LoRa_WriteReg(REG_IRQ_FLAGS, 0xFF); /* 清所有中断标志 */ LoRa_WriteReg(0x0D, 0x00); /* FIFO 指针归零 */ for (uint8_t i 0; i len; i) LoRa_WriteReg(REG_FIFO, buf[i]); /* 逐个写进 FIFO */ LoRa_WriteReg(REG_PAYLOAD_LEN, len); /* 告诉芯片发多少字节 */ LoRa_WriteReg(REG_OP_MODE, 0x83); /* 切发射 */ uint32_t t0 HAL_GetTick(); while (!(LoRa_ReadReg(REG_IRQ_FLAGS) 0x08)) { /* 等 TxDone */ if (HAL_GetTick() - t0 300) { /* 超时兜底 */ LoRa_WriteReg(REG_OP_MODE, 0x85); return 0; } } LoRa_WriteReg(REG_IRQ_FLAGS, 0x08); /* 清 TxDone */ LoRa_WriteReg(REG_OP_MODE, 0x85); /* 立刻回连续接收 */ return 1; }0x08是 TxDone 的标志位0x85是待机 LoRa 连续接收。发送超时设 300ms比一帧的空中时间宽裕得多超时基本就意味着硬件或者配置有问题不是再等等就好。接收侧我一般用 DIO0 中断置标志主循环里查标志再读 FIFO这样不会丢帧。读之前先读0x13RegRxNbBytes拿到本帧长度再把 FIFO 指针指到接收基址然后连续读。长度字段一定要用芯片给的不要用自己以为的长度否则多读的字节会把下一帧的数据吃掉。联调顺序建议这样走先单机自发自收验证配置把频率、同步字、扩频因子都确认一遍再两台对发一台只发一台只收串口打 RSSI 和收包计数最后才上双向确认和重发。跳过前两步直接上业务出问题时你分不清是配置错还是逻辑错。5. 避坑与排查联调时最耗时的五个坑下面这几条都是我自己踩过或者帮别人排过的按出现什么现象、为什么、怎么解决写遇到类似症状可以直接对号入座。5.1 按一下键触发好几次现象是菜单按一次跳两三层或者串口上看到连续好几个相同的键值。原因有两个层面。一是没做消抖扫描函数每 10ms 返回一次有效值业务层直接当事件处理一次物理按下就产生了五六次事件。二是状态机写漏了释放确认KEY_HOLD里一检测到0xFF就立刻回IDLE手松开时的抖动又会产生一次按下。解决是把事件返回点唯一化只在消抖确认的那一刻返回一次KEY_HOLD期间不再返回。释放也要走两步确认。另外检查一下扫描函数结尾有没有把行线全部拉高没拉高的话下一轮起点是脏的也会产生幽灵键值。5.2 发送正常对面收不到现象是本机串口打印发送成功TxDone 也置位了但对面一帧都没收到。先查频率和同步字。这两个参数不一致的时候物理层上确实在发但接收端解不出来也不会报错。把两边的REG_FRF_MSB/MID/LSB读出来用串口打印对比三个字节必须完全一样。同步字同理0x39寄存器的值要一致。再查扩频因子和带宽。0x1D和0x1E两个寄存器的值如果一边是 SF7 一边是 SF9空中速率和编码方式都对不上收不到是必然的。最后查天线模块没插天线时近距离可能勉强能通稍远一点就断而且这种现象很不稳定容易误判成代码问题。5.3 电位器数值乱跳或者完全不动数值乱跳通常是采样和滤波的问题。检查 ADC 的采样时间是否设得太短源阻抗高的信号需要更长的采样窗口检查 DMA 缓冲区是不是定义成了局部变量局部变量在函数返回后地址失效DMA 还在往里写读出来的就是随机值。完全不动则要往反方向查。先确认 DMA 是不是真的启动了HAL_ADC_Start_DMA调用之后有没有返回错误再确认取数时的下标公式i * ADC_CH_NUM ch有没有写反写成ch * ADC_OVS i就会一直读到同一个位置表现就是数值恒定。还有一种情况是 ADC 通道配置里把引脚复用配错了读的是悬空引脚值会固定在一个中间值附近。5.4 SPI 读回来全是 0x00 或 0xFF0xFF 一般是 MISO 一直高说明从机没响应。查三件事NSS 有没有在传输期间保持低电平、SPI 模式CPOL/CPHA和模块要求是否一致、模块有没有正常供电和复位。复位脚拉低的时间不够也会导致芯片没起来手册要求至少 1ms我给 2ms 是留余量。0x00 则可能是 MOSI 没接对或者发送缓冲区的地址位搞反了。写寄存器时地址要| 0x80读的时候要 0x7F这两个操作如果写反芯片会把写当读、读当写返回的自然全是 0。用逻辑分析仪抓一下前两个字节一眼就能看出来。5.5 两块板子必须共地才能通信现象是两块板子各自接自己的电源时完全通不了把两块板子的地连起来就正常了。LoRa 是差分信号没错但 SPI 和 GPIO 是单端的两块板子如果不共地电平参考点不一致NSS 和 DIO0 这些控制信号就会被误判。赛题现场经常是两块板子各插一个 USB 供电USB 的地是通的还好如果是两块独立的电池供电就必须额外拉一根地线。这个坑的隐蔽之处在于有时候能通因为浮地的电位差是变化的接触一下桌子、手碰一下板子都可能改变状态。解决办法很简单联调阶段永远把地线接上别图省事。6. 二次开发把赛题代码改成能复用的工程骨架赛题代码的特点是刚好够用功能都在但耦合度高、参数写死、错误处理基本没有。如果打算在这套代码上继续做自己的物联网小项目有几个改造点性价比最高。第一件是把协议层彻底独立出来。现在的Proto_Build直接操作字节数组改成结构体加序列化函数会好很多typedef struct { uint8_t dst, src, cmd, len; uint8_t payload[32]; } lora_frame_t; uint8_t Proto_Pack(const lora_frame_t *f, uint8_t *out); /* 结构体 - 字节流 */ uint8_t Proto_Unpack(const uint8_t *in, uint8_t n, lora_frame_t *f); /* 反向 */这样一来底层从 LoRa 换成串口、换成 Wi-Fi 模组上层业务一行都不用动只是换一个BSP_xxx_Send的实现。我在别的项目里就是这么干的换通信方式的那天下午只改了一个文件。第二件是把按键的事件模型从返回值改成队列。现在是在主循环里主动调用取事件如果以后加了屏幕刷新、加了低功耗休眠轮询周期会变得不确定按键就可能漏。改成扫描函数往环形队列里塞事件业务侧按需取解耦得更彻底。第三件是给 ADC 加一次上电标定。把电位器旋到两端各采一组数据存到 Flash 或者静态变量里作为映射的上下限就不用每次手填死区值了。这个改动很小但对不同批次的板子兼容性提升明显。改造点改动量收益协议层结构体化中换通信方式不动业务代码按键事件队列小主循环节拍变化时不丢键ADC 上电标定小不同板子量程自适应参数集中到 board_map.h小换板子只改一个文件从那以后我每次拿到这类外设固定、时间紧张的板子都强制先把board_map.h和三层目录搭好再动手写第一行业务代码。看起来多花了二十分钟实际省下的是后面反复改引脚、反复找 Bug 的几个小时——赛题现场最贵的就是时间。希望帮到你。本文还有配套的精品资源点击获取