简介:这份2024年蓝桥杯第十五届物联网省赛完整代码,面向备赛选手与嵌入式学习者,聚焦矩阵按键扫描、LoRa无线通信和电位器模拟量采集等赛题核心任务,可用于复盘省赛方案、搭建双机通信实验及查补STM32开发短板。压缩包共371个文件、约11.27MB,以C语言源文件和H头文件为主体,配合Keil工程配置、STM32CubeMX初始化文件、编译中间文件及HEX烧录文件,完整保留从外设初始化到应用逻辑的工程结构。现有189人学习,说明其在物联网赛题圈具有一定参考价值。代码按LoRa A、LoRa B两套工程组织,便于对照理解点对点收发流程;读者可从中梳理矩阵按键消抖与键值映射、ADC电位器取值处理、LoRa数据封装发送与接收解析,以及低功耗MCU外设驱动和调试排错思路,适合作为赛前训练与工程实践的对照样本。
1. 四小时里真正拖慢你的,往往不是难题
板子发下来、电源灯亮起,真正吃掉时间的常常是这些情况:矩阵按键按一下上报三次,或者长按没有反应;电位器旋到中间,串口打印的数值在 ±40 之间乱跳;LoRa 一端已经发出,另一端等了几十秒也没有回应。2024 蓝桥杯 15 届物联网省赛里,矩阵按键、LoRa 通信、电位器取值这三件事都属于“原理半小时能讲完、现场调两小时不一定稳”的活,卡人的多半是时序、状态机和参数对齐,而不是算法。这篇把这三块拆成一套能直接搬进工程的骨架:按键用状态机消抖,电位器用 DMA 加三级滤波,LoRa 用定长头加变长负载并配合超时重发。备赛的人可以照着搭,拿 LoRa 做点对点采集节点的人也能直接借用。
2. 骨架先立住:10 ms 时间片、按键状态机与不阻塞的主循环
2.1 三个模块怎么分层,文件怎么放
先把“谁该干什么”定下来,后面调试才有地方下手。矩阵按键、电位器、LoRa 这三块,最忌讳的是把它们揉进main.c一个大循环里:按键扫描里顺手发一帧 LoRa,ADC 采完直接在中断里做浮点滤波,最后谁卡住谁都说不清。我一般按四层拆:
| 文件 | 职责 | 不该做的事 |
|---|---|---|
bsp_key.c/h | 行列扫描、消抖状态机、事件入队 | 不解析业务,不直接点灯 |
bsp_adc.c/h | 定时触发采样、DMA 搬运、滤波、标定换算 | 不决定数据发不发、发给谁 |
bsp_lora.c/h | 模块上电时序、参数下发与回读、收发字节流 | 不管帧里第几个字节是什么 |
app_proto.c/h | 帧组装、CRC、序号、超时重发 | 不碰 GPIO |
app_sched.c/h | 时间片调度、任务注册 | 不写业务逻辑 |
分层的实际收益在改动需求时最明显:赛题里常见的“按键从控制本地灯改成上报到对端”,只动app层两个函数;如果按键扫描和 LoRa 打包写在一起,这种改动就是全线返工。另一条经验是 BSP 层只做“引脚级动作”,函数里不要出现HAL_Delay(500)这种写法,一旦延时跨过 50 ms,按键扫描周期就被拉长,手感立刻发飘。
2.2 用 HAL_GetTick 做时间片:主循环不再被 HAL_Delay 卡死
四个任务:按键 10 ms、电位器上报 50 ms、LoRa 链路推进 200 ms、界面刷新 500 ms。调度器不复杂,一个结构体数组加一个 tick 比较就够:
/* app_sched.h */ #include <stdint.h> typedef struct { uint32_t period; /* 周期,单位 ms */ uint32_t last; /* 上次执行的时刻 */ void (*task)(void); /* 任务函数,必须短小、不阻塞 */ } task_t; void sched_run(void); /* app_sched.c */ #include "app_sched.h" #include "bsp_key.h" #include "bsp_adc.h" #include "bsp_lora.h" #include "app_proto.h" static task_t g_tasks[] = { { 10, 0, key_task_scan }, /* 10 ms 扫一次键盘,够快也不浪费 */ { 50, 0, adc_task_report }, /* 50 ms 取一次滤波结果 */ { 200, 0, lora_task_poll }, /* 200 ms 推进一次链路状态机 */ { 500, 0, ui_task_refresh }, /* 500 ms 刷一次显示 */ }; #define TASK_NUM (sizeof(g_tasks) / sizeof(g_tasks[0])) void sched_run(void) { uint32_t now = HAL_GetTick(); for (uint32_t i = 0; i < TASK_NUM; i++) { /* 无符号相减,天然处理 tick 回绕(49 天一次,但别赌) */ if ((uint32_t)(now - g_tasks[i].last) >= g_tasks[i].period) { g_tasks[i].last = now; g_tasks[i].task(); } } } /* main.c 里 */ int main(void) { HAL_Init(); SystemClock_Config(); bsp_key_init(); bsp_adc_init(); bsp_lora_init(); while (1) { sched_run(); } }逻辑上只有两点值得强调。一是now - last必须用无符号减法再比较,写成now > last + period在 tick 回绕时会整整停一个周期,这种 bug 一年可能只出现一次,排查起来却非常麻烦。二是任务函数里禁止长时间等待:lora_task_poll只推进一步状态机就返回,真正的“等应答”靠记录时间戳加超时判定,而不是while (!rx_flag);。
参数怎么定:按键 10 ms 是最低要求,低于 5 ms 会被机械抖动吃掉 CPU,高于 20 ms 长按计数误差就明显;链路推进 200 ms 取决于空中时间,SF9/BW125 发一帧 20 字节约 200 ms,状态机周期比它小才不会漏判超时。任务表里每个周期都写注释说明依据,三个月后回看才不会乱改。
2.3 矩阵按键扫描:逐行拉低、列线上拉、30 us 等电平稳定
4×4 矩阵的物理接法决定了扫描方式。常见做法是行线配置成推挽输出、列线配置成上拉输入,扫描时每次只把一行拉低,其余行保持高电平,然后读四根列线:哪一列读到低电平,就说明“这一行这一列”的按键闭合。为什么不能四行一起拉低?那样只能知道“有键按下”,定位不到具体是哪一个;更麻烦的是多键同按时会形成寄生通路,读出来的组合和真实按键对不上。
/* bsp_key.c —— 引脚按原理图替换,示例用 PA0~PA3 作行、PB0~PB3 作列 */ #include "bsp_key.h" #define ROW_NUM 4 #define COL_NUM 4 static GPIO_TypeDef *const row_port[ROW_NUM] = {GPIOA, GPIOA, GPIOA, GPIOA}; static const uint16_t row_pin [ROW_NUM] = {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3}; static GPIO_TypeDef *const col_port[COL_NUM] = {GPIOB, GPIOB, GPIOB, GPIOB}; static const uint16_t col_pin [COL_NUM] = {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2, GPIO_PIN_3}; /* 行输出置位/复位 */ static void row_write(int r, GPIO_PinState st) { HAL_GPIO_WritePin(row_port[r], row_pin[r], st); } /* 短延时:30 us 量级,用空循环即可;不要用 HAL_Delay(1) */ static void delay_us_rough(uint32_t n) { volatile uint32_t k = n * 8; /* 系数按主频实测校准,宁可多不能少 */ while (k--) { __NOP(); } } /* 返回 16 位键位图,bit(n)=1 表示第 n 个键按下,n = row*4 + col */ uint16_t key_raw_scan(void) { uint16_t map = 0; for (int r = 0; r < ROW_NUM; r++) { for (int i = 0; i < ROW_NUM; i++) { row_write(i, (i == r) ? GPIO_PIN_RESET : GPIO_PIN_SET); } delay_us_rough(30); /* 等行线电平稳定,见下方说明 */ for (int c = 0; c < COL_NUM; c++) { if (HAL_GPIO_ReadPin(col_port[c], col_pin[c]) == GPIO_PIN_RESET) { map |= (uint16_t)(1u << (r * COL_NUM + c)); } } } for (int i = 0; i < ROW_NUM; i++) { row_write(i, GPIO_PIN_SET); /* 扫完全部拉高,减少漏电与误触发 */ } return map; }代码里那个 30 us 不是凭感觉加的。行线到列线之间走线有几十 pF 的对地电容,配上 10 kΩ 上拉,时间常数在微秒量级,行线由高变低后要等电平稳定再读;延时太短,会读到上一行的残留电平,表现就是“按 A 键出来 B 键”。这个值也不能太大,4 行 × 30 us ≈ 120 us,10 ms 周期下占用可以忽略,但如果谁写成 1 ms,四行就是 4 ms,按键响应立刻发闷。
引脚配置上还有两个细节。列线必须开内部上拉(或外部上拉),否则按键没按下时是浮空输入,读到的值受手靠近都会变;行线输出速度设成低速或中速,减少振铃造成的误读。如果手上的板子是反过来的(行输入上拉、列输出低),把读写方向对调即可,逻辑完全一致。
2.4 消抖写成状态机:短按、长按、连发各走各的
扫描出来的是原始位图,直接拿去用必然出现“按一下触发三次”。抖动窗口通常 5~20 ms,用 10 ms 周期扫三次一致才确认,等于 30 ms 稳定期,是手感与稳定的折中。计数器采用“加分减分”而非“清零重来”,对单次毛刺更宽容:
/* bsp_key.c 续 —— 状态机 + 事件队列 */ #define KEY_DEBOUNCE_CNT 3 /* 3 次 × 10 ms = 30 ms 稳定 */ #define KEY_LONG_MS 800 /* 长按阈值 */ #define KEY_REPEAT_MS 150 /* 长按后的连发间隔 */ static uint16_t s_stable; /* 已确认按下的键位图 */ static uint8_t s_db_cnt[16]; /* 每个键的消抖计数 */ static uint16_t s_hold_ms[16]; /* 每个键的按住时长 */ /* 事件队列:8 深度环形缓冲,主循环取走 */ static key_ev_t s_q[8]; static uint8_t s_head, s_tail; static void key_ev_push(uint8_t id, key_ev_type_t t) { uint8_t next = (uint8_t)((s_head + 1) & 7); if (next == s_tail) return; /* 满了就丢,避免覆盖未处理事件 */ s_q[s_head].id = id; s_q[s_head].type = t; s_head = next; } uint8_t key_ev_pop(key_ev_t *out) { if (s_tail == s_head) return 0; *out = s_q[s_tail]; s_tail = (uint8_t)((s_tail + 1) & 7); return 1; } void key_task_scan(void) /* 每 10 ms 由调度器调用 */ { uint16_t raw = key_raw_scan(); for (int i = 0; i < 16; i++) { uint8_t pressed = (uint8_t)((raw >> i) & 1u); if (pressed) { if (s_db_cnt[i] < KEY_DEBOUNCE_CNT) s_db_cnt[i]++; if (s_db_cnt[i] == KEY_DEBOUNCE_CNT && !(s_stable & (1u << i))) { s_stable |= (uint16_t)(1u << i); s_hold_ms[i] = 0; key_ev_push((uint8_t)i, KEY_EV_DOWN); } else if (s_stable & (1u << i)) { s_hold_ms[i] += 10; if (s_hold_ms[i] == KEY_LONG_MS) { key_ev_push((uint8_t)i, KEY_EV_LONG); } else if (s_hold_ms[i] > KEY_LONG_MS && ((s_hold_ms[i] - KEY_LONG_MS) % KEY_REPEAT_MS) == 0) { key_ev_push((uint8_t)i, KEY_EV_REPEAT); } } } else { if (s_db_cnt[i]) s_db_cnt[i]--; if (s_db_cnt[i] == 0 && (s_stable & (1u << i))) { s_stable &= (uint16_t)~(1u << i); key_ev_push((uint8_t)i, KEY_EV_UP); } } } }参数说明:KEY_DEBOUNCE_CNT = 3对应 30 ms,如果现场发现按键偏软、回弹慢(劣质轻触键常见),可以提到 4;KEY_LONG_MS = 800是习惯值,改成 600 也行,但要和赛题描述对齐;KEY_REPEAT_MS = 150决定长按连续调整的速度,调电位器屏幕上的数字时,低于 100 会过快,高于 300 又显得迟钝。
队列深度 8 不是随手写的:一次长按连发加上短按,200 ms 内理论上能产生三个事件,主循环 10 ms 一轮必然取完,8 深度留了足够余量。事件类型分开的好处是业务层可以只关心KEY_EV_DOWN,把长按留给“连续调节”这种场景,两者互不影响。
3. 电位器取值:12 位 ADC + DMA + 中值低通,把跳变压到 ±2 LSB
3.1 图形化配置里四个必须改的 ADC 参数
很多人的电位器数值跳,问题不在滤波,而在采样配置。默认配置里“连续转换使能 + 软件启动”这条路径,转换节奏跟着主循环走,主循环一忙采样就稀疏,滤波反而把真实变化抹平。我一般改成定时器触发的离散采样,节奏固定,抖动才好压。
| 配置项 | 取值 | 理由 |
|---|---|---|
| 分辨率 | 12 bit | 4096 级,0~3.3 V 下每级约 0.806 mV |
| 连续转换 | Disable | 由 TIM3 TRGO 每 1 ms 触发一次,节奏可控 |
| 外部触发 | TIM3_TRGO,上升沿 | 采样率 = 1 kHz,远高于人手转动速度 |
| 采样时间 | 71.5 个 ADC 周期 | 电位器源阻抗偏高,采样窗口要够长 |
| DMA | Circular,半字,缓冲 32 | 无需 CPU 干预,半满/全满各处理一次 |
| 数据对齐 | 右对齐 | 与代码里uint16_t读取一致 |
采样时间这一项最容易被忽略。电位器分压后的源阻抗通常是几 kΩ 到几十 kΩ,采样保持电容要在窗口内充到 0.5 LSB 精度,窗口太短就会出现“转得快时读数滞后、转到位后慢慢爬”。按 ADC 时钟 16 MHz 算,71.5 个周期约 4.5 us,对 10 kΩ 级别的源阻抗够用;如果板上电位器阻值到 100 kΩ,建议再往上加一档,并在抽头到地之间并 100 nF,用硬件把尖峰先削掉一层。
3.2 DMA 循环采样与半满、全满双缓冲
/* bsp_adc.c */ #include "bsp_adc.h" #define ADC_N 32 static uint16_t s_dma[ADC_N]; static volatile uint8_t s_half_flag, s_full_flag; void bsp_adc_init(void) { HAL_ADC_Start_DMA(&hadc1, (uint32_t *)s_dma, ADC_N); /* 循环模式已在配置里打开 */ } /* DMA 半满 / 全满回调:只置标志,绝不做运算 */ void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) s_half_flag = 1; } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) s_full_flag = 1; }回调里只置一个字节标志,是这套结构里最值得坚持的一条。DMA 回调运行在中断上下文,一旦在里面做排序或浮点运算,时间随数据量线性上涨,主循环被反复打断,LoRa 的时序也跟着抖。处理放到任务里:
/* 50 ms 任务:处理最近一段数据 */ static uint16_t s_last_raw; static int32_t s_lpf; /* 一阶低通的状态,定点保存 */ void adc_task_report(void) { uint16_t buf[8]; uint8_t got = 0; if (s_half_flag) { s_half_flag = 0; got = 1; } else if (s_full_flag) { s_full_flag = 0; got = 1; } if (!got) return; /* 取最近 8 个样本(半满取前 8,全满取后 8,按实际下标改) */ for (int i = 0; i < 8; i++) buf[i] = s_dma[i]; uint16_t med = median_u16(buf, 8); /* 中值:干掉孤立尖峰 */ s_lpf += ((int32_t)med - s_lpf) / 8; /* 一阶低通:α = 1/8 */ uint16_t out = (uint16_t)s_lpf; /* 死区:变化小于 8 LSB 就不更新,抑制末位跳动 */ if (out > s_last_raw + 8 || out + 8 < s_last_raw) { s_last_raw = out; app_report_pot(out); /* 交给上层组帧 */ } }三段处理各管一件事:中值滤波对付的是偶发的单点尖峰,比如电机或继电器动作瞬间耦合进来的干扰,8 点取中值可以容忍 3 个坏点;一阶低通对付的是连续的小幅噪声,α = 1/8 对应时间常数约 8 个采样窗口,按 1 ms 触发、50 ms 处理一次算,响应时间在几百毫秒量级,正好跟得上手转、又不会被手指的细微抖动带跑;死区是最后一道,8 LSB 约 6.4 mV,低于这个量级的变化对显示和上报都没意义,压掉之后串口日志会干净很多。
中值函数用插入排序就够,8 个元素的开销在毫秒级 MCU 上完全可接受:
static uint16_t median_u16(uint16_t *a, uint8_t n) { for (uint8_t i = 1; i < n; i++) { /* 插入排序,n=8 时最多 28 次比较 */ uint16_t key = a[i]; int8_t j = (int8_t)(i - 1); while (j >= 0 && a[j] > key) { a[j + 1] = a[j]; j--; } a[j + 1] = key; } return (n & 1u) ? a[n / 2] : (uint16_t)((a[n / 2 - 1] + a[n / 2]) / 2); }注意这里会修改传入数组,所以调用前一定要拷贝,别直接把 DMA 缓冲区传进去,否则主循环正在读的原始数据被就地打乱,后面排查噪声来源就没有依据了。
3.3 从原始码到工程量:标定两点与换算表
原始码转百分比,直接用raw * 100 / 4095会有两个问题:电位器两端往往到不了 0 和 4095(接触电阻、串联电阻导致),机械行程也不是满量程。做法是记录两个标定点,做线性映射并夹紧:
/* 标定值:上电时手动转到底/顶,从串口读出来填进去 */ #define POT_RAW_MIN 120u #define POT_RAW_MAX 3980u uint8_t pot_to_percent(uint16_t raw) { if (raw <= POT_RAW_MIN) return 0; if (raw >= POT_RAW_MAX) return 100; uint32_t span = POT_RAW_MAX - POT_RAW_MIN; return (uint8_t)(((uint32_t)(raw - POT_RAW_MIN) * 100u + span / 2) / span); }+ span / 2是四舍五入,不加的话在中间段会稳定偏小 1。标定值一定要写进宏并注释来源,不要每次上电现算,否则换一块板子行为就变。下面这组数据是常见 10 kΩ 电位器配 3.3 V 参考的典型值,可以用来对照判断自己的采样是否正常:
| 旋钮位置 | 典型原始码 | 电压(mV) | 百分比 |
|---|---|---|---|
| 逆时针到底 | 110~140 | 90~115 | 0 |
| 1/4 | 约 1050 | 约 846 | 25 |
| 中间 | 约 2050 | 约 1652 | 50 |
| 3/4 | 约 3050 | 约 2458 | 75 |
| 顺时针到顶 | 3960~3990 | 3190~3215 | 100 |
如果测出来两端离 0 和 4095 很近,说明板上电位器是满量程接法,标定值相应改成 20 和 4070;如果中间段明显非线性(比如 50% 位置读到 2400),多半是分压电阻选型或接触问题,靠软件补偿只能勉强救,不如先量一下抽头电压。
4. LoRa 通信:参数对齐、帧格式与超时重发
4.1 透明传输和定点传输,先定组网形态
市面上常见的 LoRa 模块有两种用法,选错方向会白折腾很久:
| 形态 | 特点 | 适合场景 |
|---|---|---|
| 透明传输 | 串口进、空中出,模块自己打包,两端参数一致即通 | 一对一、链路简单、调试期快速验证 |
| 定点传输 | 帧里带目标地址和信道,可一对多、可中继 | 多点采集、需要区分节点 |
省赛这种一主一从的结构,透明传输足够,调试阶段先把“通不通”确认下来,比一上来就搞地址过滤高效。但透明传输有个陷阱:模块内部有串口缓冲和空中速率匹配,如果 MCU 侧一次printf塞进去 200 字节,模块会分片发出,接收端读到的第一段可能只是半帧,很多人误判成“丢包”,其实是没等整帧到齐就开始解析。解决方式是固定帧长上限(本文用 32 字节负载)并在应用层做帧同步。
4.2 六个必须两端一致的参数
LoRa 不通,八成是参数没对齐,而且往往只差一项。下面这组是必须逐项核对的:
| 参数 | 典型取值 | 不对齐时的表现 |
|---|---|---|
| 频点 | 470.5 MHz(按模块与当地规定选) | 完全收不到,RSSI 也无意义 |
| 扩频因子 SF | 9 | 能收到但全是乱码或 CRC 错 |
| 带宽 BW | 125 kHz | 同上,且空中时间对不上 |
| 编码率 CR | 4/5 | 偶发 CRC 错,距离一远就崩 |
| 前导码长度 | 8 | 接收端同步不稳,丢头包 |
| 模块地址/信道 | 主 0x0001 / 从 0x0002,信道一致 | 定点模式下互相听不见 |
核对方式建议做成“克隆参数”:从机上线后先发一帧读参数命令,主机把自己的配置原样回给从机,从机写入后再回读比对,不一致就点错误灯。这比两边各自照着文档手配可靠得多,尤其是换模块、换批次的时候。
4.3 帧格式:0xAA55 头 + 变长负载 + CRC16
帧结构定死,后面所有问题都能定位:
| 0xAA | 0x55 | ver(1) | dst(2) | type(1) | seq(1) | len(1) | payload(len) | crc16(2, 小端) |dst用大端,是为了串口日志里读起来直观;seq每帧自增,接收端用它去重;len限制 0~32,超过直接丢。CRC 覆盖从帧头到负载末尾,不含 CRC 自身。
/* app_proto.c */ static uint16_t crc16_modbus(const uint8_t *p, uint16_t n) { uint16_t crc = 0xFFFF; while (n--) { crc ^= *p++; for (uint8_t i = 0; i < 8; i++) { crc = (crc & 1u) ? (uint16_t)((crc >> 1) ^ 0xA001) : (uint16_t)(crc >> 1); } } return crc; } uint16_t frame_build(uint8_t *out, uint16_t dst, uint8_t type, uint8_t seq, const uint8_t *payload, uint8_t len) { uint16_t i = 0; if (len > 32) len = 32; out[i++] = 0xAA; out[i++] = 0x55; out[i++] = 0x01; out[i++] = (uint8_t)(dst >> 8); out[i++] = (uint8_t)(dst & 0xFF); out[i++] = type; out[i++] = seq; out[i++] = len; for (uint8_t k = 0; k < len; k++) out[i++] = payload[k]; uint16_t crc = crc16_modbus(out, i); out[i++] = (uint8_t)(crc & 0xFF); out[i++] = (uint8_t)(crc >> 8); return i; /* 返回整帧长度,直接交给串口发送 */ }接收端不要用“一次读满缓冲区再解析”的写法,串口是流式的,半帧、粘帧都会出现。用一个 6 状态的小状态机逐字节喂进去,任何时候出错都回到找帧头:
typedef enum { ST_H0, ST_H1, ST_BODY, ST_CRC0, ST_CRC1 } pst_t; static pst_t s_st = ST_H0; static uint8_t s_buf[40]; static uint8_t s_idx, s_need, s_len; /* 每收到一个字节调用一次;返回 1 表示组出一帧完整数据 */ uint8_t proto_feed(uint8_t byte, frame_view_t *out) { switch (s_st) { case ST_H0: if (byte == 0xAA) { s_buf[0] = byte; s_idx = 1; s_st = ST_H1; } break; case ST_H1: if (byte == 0x55) { s_buf[s_idx++] = byte; s_st = ST_BODY; } else s_st = ST_H0; /* 头错,重找 */ break; case ST_BODY: s_buf[s_idx++] = byte; if (s_idx == 8) { /* 收到 len 字段 */ s_len = s_buf[7]; if (s_len > 32) { s_st = ST_H0; break; } s_need = (uint8_t)(8 + s_len); /* 帧头 + 负载 */ } if (s_idx == s_need + 2) s_st = ST_CRC0; /* 再收 2 字节 CRC */ break; case ST_CRC0: s_buf[s_idx++] = byte; s_st = ST_CRC1; break; case ST_CRC1: s_buf[s_idx++] = byte; s_st = ST_H0; { uint16_t crc_rx = (uint16_t)(s_buf[s_idx - 2] | (s_buf[s_idx - 1] << 8)); uint16_t crc_c = crc16_modbus(s_buf, (uint16_t)(s_idx - 2)); if (crc_rx == crc_c) { out->dst = (uint16_t)((s_buf[3] << 8) | s_buf[4]); out->type = s_buf[5]; out->seq = s_buf[6]; out->len = s_len; out->payload = &s_buf[8]; return 1; } } break; } return 0; }状态机里s_need + 2和s_idx的关系是最容易写错的地方:s_need表示“帧头 8 字节加负载”,CRC 还要再收 2 字节,所以进ST_CRC0的条件是s_idx == s_need + 2。写错这一处,表现是“偶尔能收到、大多数时候 CRC 错”,看起来像射频问题,其实是解析器把 CRC 当成了负载。
4.4 收发展开成状态机:超时、重发、去重
发送不能写成“发完 while 等应答”。用三个状态加时间戳:
typedef enum { L_IDLE, L_WAIT_ACK, L_RETRY } lstate_t; static lstate_t s_ls = L_IDLE; static uint32_t s_tx_tick, s_timeout_ms = 500; static uint8_t s_retry, s_seq; static uint8_t s_txbuf[40]; static uint16_t s_txlen; void lora_send(uint8_t type, const uint8_t *pl, uint8_t len) { if (s_ls != L_IDLE) return; /* 忙则丢弃,避免堆积 */ s_seq = (uint8_t)(s_seq + 1); s_txlen = frame_build(s_txbuf, 0x0002, type, s_seq, pl, len); lora_uart_write(s_txbuf, s_txlen); s_tx_tick = HAL_GetTick(); s_retry = 0; s_ls = L_WAIT_ACK; } void lora_task_poll(void) /* 200 ms 调用一次 */ { switch (s_ls) { case L_WAIT_ACK: case L_RETRY: if ((uint32_t)(HAL_GetTick() - s_tx_tick) >= s_timeout_ms) { if (s_retry < 3) { s_retry++; lora_uart_write(s_txbuf, s_txlen); s_tx_tick = HAL_GetTick(); <p> <a href="https://download.csdn.net/download/Azhuo9527/89129732" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>