news 2026/10/31 3:30:41

STM32物联网赛题代码:矩阵按键、ADC与LoRa通信联调实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32物联网赛题代码:矩阵按键、ADC与LoRa通信联调实战

简介:《2024蓝桥杯15届物联网省赛完整代码》面向备战蓝桥杯物联网省赛的选手及嵌入式初学者,围绕矩阵按键扫描、LoRa A/B双端通信与电位器取值等赛题考点,提供可编译的完整工程与源码参考。压缩包共371个文件,约11.27MB,以C源文件与H头文件为主体,包含STM32L0系列HAL驱动、Keil工程配置(uvprojx/uvoptx)、IOC引脚配置、HEX固件及少量说明页面,便于对照硬件外设与软件模块理解工程组织。已有189人学习下载,适合用于赛前复盘和方案比对。代码覆盖双端LoRa数据收发、按键输入处理、模拟量采集与数据打包传输等环节,读者可据此梳理省赛题目的模块划分、接口设计与调试思路,并结合HAL库文件快速定位外设初始化与业务逻辑,提升物联网综合实践能力。

1. 从一块练习板说起:矩阵按键、LoRa 和电位器怎么串成一条链

桌上摆着一块物联网省赛的练习板:4×4 矩阵按键、一个电位器、一片 LoRa 模块、一块 OLED。赛题要求很朴素——按下哪个键,键值要通过 LoRa 发到对端板子上显示;电位器拧到哪,电压值要同步过去。听起来三个外设各干各的,真跑起来才发现它们是串在一条链上的:按键扫描的节奏决定事件什么时候产生,ADC 采样的滤波决定数值稳不稳,LoRa 的帧格式决定对端能不能正确解析,任何一环抖动,整条链就表现为"对端数字乱跳"这种玄学现象。

这套省赛代码的价值不在于拿了多少分,而在于它把这三件事做成了可以复用的模块:按键有状态机消抖和短按/长按事件,ADC 有 DMA 循环采样加中值滤波,LoRa 有带帧头、长度和校验的协议层。适合正在啃物联网赛题、或者想把板子上的外设从"能点亮"推进到"能联调"的开发者。下面按我实际拆的顺序讲。

2. 工程骨架:CubeMX 出图之后我怎么分层

CubeMX 生成出来的main.c是一坨:时钟初始化、外设初始化、while(1)全挤在一起。赛题代码短,很多人就直接在while(1)里堆,改到第三天自己都找不到哪行是按键扫描。我的做法是先把工程切三层:BSP 层只碰寄存器和 HAL 句柄,Protocol 层只管帧的打包解包,App 层管业务逻辑和调度。这样现场加一路传感器,只需要新增一个bsp_xxx.c,不动其它文件。

2.1 目录与模块边界

工程目录大概长这样,CubeMX 生成的Core保持原样不动,新增的模块统一挂在Bsp和App下面:

Core/ Inc/ main.h stm32g4xx_it.h Src/ main.c CubeMX 生成,只调用 App_Init / App_MainLoop stm32g4xx_it.c 中断入口,只做转发,不写业务 Bsp/ Inc/ bsp_key.h bsp_adc.h bsp_lora.h bsp_oled.h Src/ bsp_key.c bsp_adc.c bsp_lora.c bsp_oled.c Protocol/ Inc/ proto_frame.h ring_buffer.h Src/ proto_frame.c ring_buffer.c App/ Inc/ app_main.h app_key.h app_ui.h Src/ app_main.c app_key.c app_ui.c

各层的职责边界我用一张表钉死,越界就说明分层没做对:

层允许做什么禁止做什么
Bsp操作 HAL 句柄、读写 GPIO、启动 DMA判断业务含义、调用printf
Protocol组帧、拆帧、CRC 校验、环形缓冲直接操作串口寄存器
App调度、状态机、显示刷新直接写GPIOx->ODR

提示:stm32g4xx_it.c里我只留一行转发调用,比如USART2_IRQHandler里只调Lora_RxIrqHandler()。中断服务函数里写业务,出问题连断点都不好下。

2.2 时钟与外设配置表

CubeMX 里几个关键配置我列成表,复位后先对着表核一遍,比事后猜省事得多:

外设引脚关键参数用途
ADC1PA0 / PA112 位、扫描模式、连续转换、DMA Circular、采样时间 47.5 周期电位器 + 备用通道
USART2PA2 / PA3115200、8N1、开接收中断LoRa 模块
TIM3内部预分频到 1 kHz,只开更新中断1 ms 系统节拍(可选)
GPIO 行线PB0~PB3推挽输出、初始高电平矩阵按键行扫描
GPIO 列线PB4~PB7上拉输入矩阵按键列读取

中断优先级是容易翻车的地方。SysTick 由 HAL 占用,优先级默认最低,HAL_Delay就靠它计时;串口接收中断的抢占优先级要高于定时器,否则一次长按键扫描就可能把 RX 缓冲挤爆。我一般把 USART2 设成抢占 1、子优先级 0,TIM3 设成抢占 2,业务定时任务全部走主循环时间片,不进中断。

2.3 主循环的时间片调度

赛题场景用不上 RTOS,裸机时间片足够,而且赛场上少一层调度就少一层调试成本。主循环长这样:

/* app_main.c —— 裸机时间片调度,所有周期任务在这里排队 */ void App_MainLoop(void) { static uint32_t t_key = 0, t_adc = 0, t_lora = 0, t_ui = 0; uint32_t now = HAL_GetTick(); /* 按键 10ms 一轮:低于 8ms 会放大抖动,高于 20ms 手感发木 */ if (now - t_key >= 10) { t_key = now; Key_Task(); } /* ADC 20ms 一轮:滤波窗口 8 点,40ms 内能凑满一次完整输出 */ if (now - t_adc >= 20) { t_adc = now; Adc_Task(); } /* LoRa 5ms 一轮:只处理状态机推进和超时判定,不做阻塞发送 */ if (now - t_lora >= 5) { t_lora = now; Lora_Task(); } /* UI 100ms 一轮:OLED 刷太快没意义,还占 I2C 时间 */ if (now - t_ui >= 100) { t_ui = now; Ui_Task(); } }

这里有两个细节值得说。第一,所有时间比较都写成now - t >= period的无符号减法形式,HAL_GetTick()是 32 位、49 天回绕一次,直接写now > t + period在回绕点会卡死一个周期,这种坑在连续跑几天的演示里才会冒出来。第二,Lora_Task只推进状态机,真正的发送在状态机内部用非阻塞方式发起,绝不能在主循环里等应答,否则按键响应会跟着一起卡。

main.c里则干净到只剩三行:App_Init();然后while (1) { App_MainLoop(); }。CubeMX 重新生成代码时不会被覆盖,这是把业务挪出main.c的最大好处。

3. 板载输入:4×4 矩阵按键扫描与电位器 ADC 取值

板载输入看着简单,实际是两个完全不同的技术路线:按键是数字量的"时序问题",电位器是模拟量的"噪声问题"。放在一章里讲,是因为它们在主循环里共用同一个节奏——按键 10 ms 扫一轮,ADC 20 ms 采一轮,两者的结果最终都要进同一条 LoRa 帧发出去。

3.1 矩阵按键的硬件接法与扫描原理

4×4 矩阵按键 16 个键只占 8 根 IO:4 根行线做推挽输出,4 根列线做上拉输入。扫描逻辑是"逐行拉低、读列线"——把第 r 行拉低,其余行拉高,此时如果第 c 列有键按下,该列线就会被这一行拉低,读到低电平就说明 (r, c) 这个键被按下。

/* bsp_key.c —— 4x4 矩阵按键行扫描,返回 0~15 键号,无键返回 0xFF */ #include "bsp_key.h" 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}; uint8_t Key_ScanRaw(void) { for (uint8_t r = 0; r < 4; r++) { /* 先把四行全部拉高,再只拉低当前行,避免上一轮的残留电平干扰 */ for (uint8_t i = 0; i < 4; i++) HAL_GPIO_WritePin(row_port[i], row_pin[i], GPIO_PIN_SET); HAL_GPIO_WritePin(row_port[r], row_pin[r], GPIO_PIN_RESET); /* 行线电平建立需要时间,20~50us 的短延时不能省 */ for (volatile uint32_t d = 0; d < 240; d++) { __NOP(); } for (uint8_t c = 0; c < 4; c++) { if (HAL_GPIO_ReadPin(col_port[c], col_pin[c]) == GPIO_PIN_RESET) return (uint8_t)(r * 4 + c); /* 键号 = 行 * 4 + 列 */ } } return KEY_NONE; /* 0xFF 表示无键按下 */ }

Key_ScanRaw只做一次物理扫描,返回裸键号,不带任何消抖。那个volatile空循环是行线电平的建立时间,配合列线上拉电阻(常见 10 kΩ)和走线电容,实际需要几十微秒才能稳定;去掉它之后在长排线上会读到隔壁列的"幽灵按键"。键号编码用r * 4 + c,好处是键位表可以直接写成 16 元素数组,改键位映射只改数组不改逻辑。

3.2 消抖状态机与事件化封装

机械按键的抖动 5~20 ms,直接在扫描函数里HAL_Delay(10)是最常见的写法,也是最容易把整个系统拖慢的写法。我改成状态机,每 10 ms 被Key_Task调一次,连续两次读到同一个键号才认账:

/* bsp_key.c —— 消抖状态机,每 10ms 调用一次 */ typedef enum { KEY_IDLE, KEY_DEBOUNCE, KEY_HOLD, KEY_RELEASE } key_state_t; void Key_Task(void) { static key_state_t st = KEY_IDLE; static uint8_t last_key = KEY_NONE; static uint8_t stable_cnt = 0; static uint32_t press_tick = 0; uint8_t now_key = Key_ScanRaw(); switch (st) { case KEY_IDLE: if (now_key != KEY_NONE) { last_key = now_key; stable_cnt = 0; st = KEY_DEBOUNCE; } break; case KEY_DEBOUNCE: if (now_key == last_key) { if (++stable_cnt >= 2) { /* 连续 2 次即 20ms,抖动基本滤干净 */ press_tick = HAL_GetTick(); Key_PostEvent(last_key, KEY_EV_DOWN); st = KEY_HOLD; } } else { st = KEY_IDLE; } /* 键号变了,判定为抖动,重来 */ break; case KEY_HOLD: if (now_key == KEY_NONE) { st = KEY_RELEASE; } else if (HAL_GetTick() - press_tick >= 800) { Key_PostEvent(last_key, KEY_EV_LONG); /* 800ms 判定长按 */ press_tick = HAL_GetTick(); /* 重置计时,支持长按连发 */ } break; case KEY_RELEASE: if (now_key == KEY_NONE) { Key_PostEvent(last_key, KEY_EV_UP); st = KEY_IDLE; } else { st = KEY_HOLD; } break; } }

状态机的关键在于"键号变了就回 IDLE"这一条:如果是同一个键的机械抖动,两次读到的键号一定相同,计数加一;如果是手指从一个键滑到另一个键,键号变了就该重新开始判定,而不是把新键当成抖动丢掉。KEY_HOLD里每 800 ms 补发一次长按事件,就顺带实现了"按住加减键连续调参数",不用再写第二套连发逻辑。

事件本身不在这里处理,而是丢进一个 8 深度的循环队列,主循环里Key_GetEvent取出来分发给业务。这样按键扫描的频率和业务处理的耗时彻底解耦,OLED 刷屏慢一点也不会漏按键。

3.3 电位器 ADC:多通道 DMA + 中值滤波

电位器接在 PA0,走 ADC1 的规则组。配置上用扫描模式 + 连续转换 + DMA 循环模式,DMA 缓冲区里永远躺着最新一轮的转换结果,CPU 完全不用等 ADC:

/* bsp_adc.c —— DMA 循环搬运,缓冲区始终是最新转换值 */ #define ADC_CH_NUM 2 /* 通道 0:电位器;通道 1:备用 */ volatile uint16_t adc_dma_buf[ADC_CH_NUM]; void Adc_Init(void) { HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_dma_buf, ADC_CH_NUM); } /* 取电位器原始码值:对最近 8 次采样做中值 + 均值,抑制单点野值 */ uint16_t Adc_GetPotRaw(void) { static uint16_t win[8]; static uint8_t idx = 0; uint16_t tmp[8], sum = 0; win[idx++ & 0x07] = adc_dma_buf[0]; /* idx 自增并在 0~7 回绕 */ for (uint8_t i = 0; i < 8; i++) tmp[i] = win[i]; for (uint8_t i = 0; i < 7; i++) /* 冒泡排序,8 个元素开销可忽略 */ for (uint8_t j = 0; j < 7 - i; j++) if (tmp[j] > tmp[j + 1]) { uint16_t t = tmp[j]; tmp[j] = tmp[j + 1]; tmp[j + 1] = t; } for (uint8_t i = 2; i < 6; i++) sum += tmp[i]; /* 掐头去尾,取中间 4 个 */ return sum / 4; }

adc_dma_buf必须加volatile,否则开-O2之后编译器可能把读操作优化成常量。中值加均值的组合比单纯平均更抗野值:电位器滑片经过磨损区时偶尔会跳出一个离谱的码值,单点就被排序掐掉了。窗口长度取 8 是权衡——再大延迟就明显了,拧到底和屏幕上数字跟上之间能感觉到迟滞。

3.4 两点标定把码值变成工程量

原始码值 0~4095 只是"数",要变成 0~3.3 V 或者 0~100% 需要一次线性映射。我是用两点标定算系数,而不是写死raw * 3.3f / 4095.0f——排针上供电有压降、参考电压也有偏差,写死公式在演示现场会和万用表对不上:

/* bsp_adc.c —— 两点标定:给两个已知输入点,反算 y = k*x + b */ static float cal_k = 3.3f / 4095.0f; static float cal_b = 0.0f; void Adc_Calibrate(uint16_t raw_low, float volt_low, uint16_t raw_high, float volt_high) { if (raw_high == raw_low) return; /* 两点重合,拒收 */ cal_k = (volt_high - volt_low) / (float)(raw_high - raw_low); cal_b = volt_low - cal_k * (float)raw_low; } float Adc_GetPotVolt(void) { return cal_k * (float)Adc_GetPotRaw() + cal_b; }

标定时把电位器拧到最小端、万用表读数记作volt_low,拧到最大端记作volt_high,两次的原始码值一起传进去。cal_k和cal_b直接存 RAM 里够用;如果要做掉电保存,就再写一段 Flash 读写把它们存到参数区。注意raw_high == raw_low那个判断不能省,电位器没拧到底或者采样通道接错时两点会重合,除零之后的浮点结果是 NaN,显示出来就是满屏乱码,查半天以为屏坏了。

4. LoRa 通信:帧协议、环形缓冲与超时重传

LoRa 模块在赛题里基本都是透传玩法:串口进什么,空中就发什么。麻烦在于透传没有"包"的概念,串口是字节流,空中也是字节流,接收方怎么知道一帧从哪开始、到哪结束,得自己定协议。这一层不定好,后面所有联调问题都会被误判成"无线不稳定"。

4.1 模块接线与工作模式

模块和 MCU 之间只有四根线有意义:VCC、GND、TX、RX,另外还有 M0/M1 两个模式脚。M0/M1 决定模块处于哪档状态:配置模式、透传模式、休眠模式,具体电平组合看模块手册,不同厂家不一样。我这边固化的做法是:上电后先给 M0/M1 一个确定电平进配置模式,用 AT 指令把波特率、频点、发射功率写进去,再切回透传模式开始收发。

/* bsp_lora.c —— 上电初始化:进配置 → 写参数 → 回透传 */ void Lora_Init(void) { HAL_GPIO_WritePin(LORA_M0_PORT, LORA_M0_PIN, GPIO_PIN_SET); /* 配置模式 */ HAL_GPIO_WritePin(LORA_M1_PORT, LORA_M1_PIN, GPIO_PIN_SET); HAL_Delay(50); /* 模块切换状态需要稳定时间 */ Lora_SendAt("AT+UART=115200,8,1,0\r\n", 300); Lora_SendAt("AT+ADDR=0x0001\r\n", 300); Lora_SendAt("AT+CH=0x0F\r\n", 300); HAL_GPIO_WritePin(LORA_M0_PORT, LORA_M0_PIN, GPIO_PIN_RESET); /* 回透传 */ HAL_GPIO_WritePin(LORA_M1_PORT, LORA_M1_PIN, GPIO_PIN_RESET); HAL_Delay(50); }

Lora_SendAt内部就是发一串字节然后等期望回显,超时 300 ms 返回失败。这里两个细节:一是 M0/M1 切换后必须留稳定时间,切完立刻发 AT 指令大概率被吞;二是发射瞬间模块电流能到 100 mA 以上,USB 供电的板子经常在这掉压复位,我一般给模块电源并一个 100 µF 电解加 0.1 µF 瓷片,这个不是玄学,是血泪经验。

4.2 帧格式设计

帧结构我固定成五段:帧头、长度、命令、载荷、校验。帧头用两字节0xAA 0x55是为了降低误同步概率——单字节帧头在透传链路上很容易被载荷里的随机字节撞上;长度字段只算载荷长度,不含帧头长度和校验;CRC8 覆盖长度到载荷全部字节。

| 0xAA | 0x55 | LEN | CMD | PAYLOAD (0~32B) | CRC8 | 帧头1 帧头2 载荷长 命令 载荷内容 校验
/* proto_frame.c —— 组帧:返回值是实际写入 out 的字节数,0 表示失败 */ uint8_t Proto_BuildFrame(uint8_t *out, uint8_t cmd, const uint8_t *payload, uint8_t len) { if (len > PROTO_MAX_PAYLOAD) return 0; out[0] = 0xAA; out[1] = 0x55; out[2] = len; out[3] = cmd; for (uint8_t i = 0; i < len; i++) out[4 + i] = payload[i]; out[4 + len] = Proto_Crc8(&out[2], 2 + len); /* 从 LEN 开始算到载荷末尾 */ return (uint8_t)(5 + len); } /* CRC-8/ATM:多项式 0x07,初值 0x00,逐位计算 */ uint8_t Proto_Crc8(const uint8_t *p, uint8_t n) { uint8_t crc = 0x00; while (n--) { crc ^= *p++; for (uint8_t i = 0; i < 8; i++) crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x07) : (uint8_t)(crc << 1); } return crc; }

PROTO_MAX_PAYLOAD定 32 字节,够装下"键号 + 电压 + 序号"这一组状态还有富余,又不至于让接收缓冲开太大。命令字我分了三种:0x01上行状态上报、0x02下行参数配置、0x03应答。应答帧的载荷里回填请求帧的序号,用来做超时重传的匹配。

4.3 串口中断收 + 环形缓冲 + 解帧状态机

串口接收绝对不能在中断里直接解帧。中断的职责只有一件事:把字节塞进环形缓冲,然后立刻退出。

/* ring_buffer.c —— 单生产者单消费者,head 只在中中断写,tail 只在主循环写 */ typedef struct { uint8_t buf[RING_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_t; void Ring_Push(ring_t *r, uint8_t byte) { uint16_t next = (uint16_t)((r->head + 1) % RING_SIZE); if (next != r->tail) { /* 满了就丢新字节,保留旧数据 */ r->buf[r->head] = byte; r->head = next; } } uint8_t Ring_Pop(ring_t *r, uint8_t *byte) { if (r->head == r->tail) return 0; *byte = r->buf[r->tail]; r->tail = (uint16_t)((r->tail + 1) % RING_SIZE); return 1; }

RING_SIZE取 2 的幂(比如 256),把取模换成& (RING_SIZE - 1)能省一次除法,在 72 MHz 的核上差别不大,但习惯养成之后代码更好读。head和tail都加volatile,中断和主循环各写各的,不加会被优化掉。

解帧用状态机,逐个字节推进,中途任何一步校验不过就回到找帧头状态:

/* proto_frame.c —— 解帧状态机,每收到一个字节喂一次 */ typedef enum { ST_HEAD1, ST_HEAD2, ST_LEN, ST_CMD, ST_DATA, ST_CRC } parse_state_t; void Proto_ParseByte(uint8_t byte, proto_frame_t *out, void (*on_frame)(proto_frame_t *)) { static parse_state_t st = ST_HEAD1; static proto_frame_t frm; static uint8_t idx = 0; switch (st) { case ST_HEAD1: if (byte == 0xAA) st = ST_HEAD2; break; case ST_HEAD2: st = (byte == 0x55) ? ST_LEN : ST_HEAD1; break; case ST_LEN: if (byte > PROTO_MAX_PAYLOAD) { st = ST_HEAD1; break; } /* 长度非法,重找帧头 */ frm.len = byte; idx = 0; st = ST_CMD; break; case ST_CMD: frm.cmd = byte; st = (frm.len > 0) ? ST_DATA : ST_CRC; break; case ST_DATA: frm.data[idx++] = byte; if (idx >= frm.len) st = ST_CRC; break; case ST_CRC: { uint8_t calc = Proto_Crc8((uint8_t *)&frm.len, (uint8_t)(2 + frm.len)); if (calc == byte) { *out = frm; on_frame(&frm); } /* 校验通过才回调 */ st = ST_HEAD1; break; } } }

状态机的好处是它对"半个帧"是天然容错的:链路中途丢字节,最多只丢当前这一帧,下一个帧头一到就自动重新同步。而如果改用"收满 N 字节再整体解析",一次丢字节会导致后面所有帧错位,表现就是"明明发得出来,对端一直乱码"。

4.4 发送应答与超时重传

无线链路丢包是常态,赛题现场周围一堆 2.4G 和 LoRa 设备,不重传基本没法演示。发送上下文用一个结构体记着当前在等哪一帧的应答:

/* bsp_lora.c —— 应答 + 重传,在 Lora_Task 里每 5ms 推进一次 */ typedef struct { uint8_t pending; /* 1 = 有帧在等应答 */ uint8_t seq; /* 当前待确认序号 */ uint8_t retry; /* 已重传次数 */ uint32_t t_send; /* 上次发送时刻 */ uint8_t frame[40]; uint8_t frame_len; } tx_ctx_t; static tx_ctx_t tx; void Lora_SendWithAck(const uint8_t *frame, uint8_t len) { if (tx.pending) return; /* 上一帧还没确认,直接丢弃新请求 */ memcpy(tx.frame, frame, len); tx.frame_len = len; tx.retry = 0; tx.pending = 1; tx.t_send = HAL_GetTick(); HAL_UART_Transmit_DMA(&huart2, tx.frame, tx.frame_len); } void Lora_Task(void) { if (!tx.pending) return; if (HAL_GetTick() - tx.t_send > 300) { /* 300ms 未收到应答 */ if (++tx.retry <= 3) { tx.t_send = HAL_GetTick(); HAL_UART_Transmit_DMA(&huart2, tx.frame, tx.frame_len); } else { tx.pending = 0; /* 重传 3 次仍失败,上报链路异常 */ App_ReportLinkFail(); } } }

三个参数是有讲究的:300 ms 超时对应 LoRa 在常用扩频因子下的单次空中时间,太短会在对端还没回之前就重发,太长则界面反馈迟钝;重传 3 次是丢包率和演示时间的折中,再多现场观众就等不住了;pending期间丢弃新请求这一点很关键,否则按键连按时会把发送队列塞满,反而让最重要的那帧发不出去。

5. 联调避坑:五个把我按在调试台的坑

赛题代码跑通只是开始,真正耗时间的是联调。下面这五个坑我基本每一届都能重新踩一遍,按"现象 → 原因 → 解决"记下来,方便下次直接查。

5.1 按一个键,对端显示两个键值

现象:按下第 3 号键,本端只上报一次,对端偶尔收到 3 号和 7 号两个键值,概率大概十几次一次。

原因是行扫描的建立时间不够。四行全部拉高再拉低当前行的那一瞬间,被拉低的行线上还带着上一轮的电荷,列线电平没稳定就被读了,读到的是相邻行的状态。短延时 240 次空循环在排线短的时候够用,接了杜邦线延长之后就未必。解决是把空循环加到 500 次,并且把列线的上拉电阻从内部上拉改成外部 10 kΩ——内部上拉阻值偏大(30~50 kΩ),配合线间电容建立更慢。

5.2 LoRa 发得出去,对端一直乱码

现象:本端HAL_UART_Transmit返回 HAL_OK,示波器上 TX 也有波形,对端收到的全是乱码,偶尔还能收到自己刚发出去的数据。

八成是模块还停在配置模式。M0/M1 的电平组合在某些模块上是"配置模式"和"透传模式"反过来的,Lora_Init里那 50 ms 稳定时间如果不够,AT 指令没生效就把脚拉回去了,模块实际还在配置态,串口数据被当成 AT 指令吃掉了。解决的顺序是:先确认模块手册里 M0/M1 的真值表,再把切换后的延时从 50 ms 加到 100 ms,最后在Lora_Init末尾加一句回读指令,读回波特率和地址打印出来核对。别信"应该没问题",回读一次心里才有底。

5.3 ADC 读数在小范围内不停跳变

现象:电位器不动,串口打出来的电压值在 ±0.05 V 之间来回跳,屏幕上数字最后一位一直在闪。

这种跳变有三个来源叠加:一是 ADC 采样时间太短,源阻抗高的电位器(常见 10 kΩ)充电不充分,采样时间至少要 47.5 周期以上;二是没有滤波,12 位 ADC 的固有噪声就有 ±2 LSB;三是 DMA 缓冲读到了半更新的数据。解决是采样时间加到 247.5 周期、中值滤波窗口取 8、并且在 20 ms 的周期任务里统一读,避免主循环里到处直接访问adc_dma_buf。改完跳变能压到 ±1 LSB。

5.4 串口偶发丢字节,帧校验通不过

现象:连续发 100 帧,有 3~5 帧 CRC 校验失败,重传之后能恢复,但概率不低。

先看串口错误标志。HAL 库的HAL_UART_Receive_IT在发生溢出错误(ORE)之后会停止接收,如果不处理,后面所有字节都丢。常见做法是在HAL_UART_ErrorCallback里重新调用HAL_UART_Receive_IT把接收续上,同时清 ORE 标志。另一个原因是中断里做了耗时操作——我见过在USART2_IRQHandler里直接调OLED_ShowString的写法,115200 波特率下一个字节间隔只有 87 µs,刷一次屏足够丢掉十几个字节。接收中断里只做Ring_Push一件事,这条纪律不能破。

5.5 加了定时器中断之后程序随机卡死

现象:原来的裸机代码跑得好好的,加了 TIM3 中断处理按键之后,HAL_Delay时不时卡住不返回,OLED 停在半屏。

原因是HAL_Delay依赖 SysTick 中断递增的uwTick,而 SysTick 的优先级被设成了最低。TIM3 中断里如果调用了HAL_Delay,就只能等 TIM3 中断退出、SysTick 才能进,uwTick才涨——逻辑上就锁死了。解决两条:中断服务函数里绝不调HAL_Delay,需要延时就用空循环或者状态机;所有中断的抢占优先级都高于 SysTick 的默认值,别把业务中断设成最低。

6. 二次开发:把这套赛题代码改成能维护的骨架

赛题代码是"功能验收通过"就结束,真实项目是"三个月后还要改"。这套代码如果要往产品方向挪,我一般做四件事,顺序别乱。

第一步是把协议层从透传里摘出来。现在Proto_BuildFrame和Proto_ParseByte已经跟串口无关,只吃字节数组,所以换物理层几乎零成本——把HAL_UART_Transmit_DMA换成 LoRa 的定点发送,或者换成另一路串口接 4G 模块,上层一行不用改。我会在bsp_lora.c外面再包一层comm_port_t结构体,把"发字节"和"收字节"抽象成两个函数指针,物理层切换就变成填一个结构体:

/* comm_port.h —— 物理层抽象,换模块只改这里 */ typedef struct { int (*send)(const uint8_t *data, uint16_t len); void (*start_rx)(void); uint8_t (*is_ready)(void); } comm_port_t; extern const comm_port_t comm_lora; /* bsp_lora.c 里实现 */ extern const comm_port_t comm_uart; /* 调试串口实现,方便上位机联调 */ /* 业务层只认 comm_port_t,不认具体模块 */ const comm_port_t *g_comm = &comm_lora;

第二步是把参数区做成可掉电保存的。赛题里cal_k、cal_b、LoRa 地址这些参数写完就丢,重新上电又要标定一遍。做法是拿最后一页 Flash 开个结构体,加一个魔数和一个 CRC,上电时先读、校验不过就用默认值并重新写入。这里有个细节:Flash 写之前要按页擦除,擦除期间 CPU 取指会停,所以别在中断开着的时候擦,先把中断关掉。

第三步是把主循环时间片换成表驱动。现在是四个if手写,加第五个任务就要复制五行。改成任务表之后,新增任务只需要在数组里加一项:

typedef struct { void (*task)(void); uint32_t period; uint32_t last; } sched_item_t; static sched_item_t sched[] = { { Key_Task, 10, 0 }, { Adc_Task, 20, 0 }, { Lora_Task, 5, 0 }, { Ui_Task, 100, 0 }, }; void App_Scheduler(void) { uint32_t now = HAL_GetTick(); for (uint8_t i = 0; i < sizeof(sched) / sizeof(sched[0]); i++) { if (now - sched[i].last >= sched[i].period) { sched[i].last = now; sched[i].task(); } } }

第四步是加一层最小可用的日志。不上文件系统,就用一个 32 帧的环形缓冲存最近的事件(按键事件、发帧、收帧、校验失败),出问题时通过调试串口一次性 dump 出来。这个在赛场上救过我一次——对端偶发乱码,dump 出来发现是接收状态机在ST_CRC之后没有回到ST_HEAD1,改成统一回到帧头状态就好了。

验证方法也顺手说一下:改完协议层之后,先不接 LoRa,用两块板子各接一根 USB 转串口,把两端 TX/RX 交叉短接,跑 1000 帧看丢帧率和误码率。这一步能过,再把无线加回来,问题就一定出在射频侧而不是代码侧,排查范围直接砍一半。

从那以后我每次动这类通信代码,都强制先把串口线短接跑一遍回环,跑不过就不接模块——这个习惯比任何调试技巧都省时间。希望帮到你。

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

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

兴趣社区架构设计与性能优化实战

1. 项目概述&#xff1a;吧里BaLi社区定位解析吧里BaLi社区是一个典型的兴趣导向型网络社群平台&#xff0c;其命名采用谐音双关手法——"吧里"既暗示论坛版块&#xff08;贴吧&#xff09;的互动场景&#xff0c;又借用了度假胜地巴厘岛的轻松意象。这种命名策略常见…

作者头像 李华
网站建设 2026/10/31 3:30:18

openrig:AI编程助手配置管理与环境编排实战指南

1. 从零认识 openrig&#xff1a;它到底解决什么问题第一次看到 openrig 这个名字&#xff0c;很多人会以为是某个硬件项目或者机械臂相关的工具&#xff0c;实际上它跟物理世界没有半点关系。openrig 是一个围绕 AI 编程助手生态构建的配置管理与环境编排工具&#xff0c;核心…

作者头像 李华
网站建设 2026/10/31 3:29:51

城市井盖智能监测系统的双模通信与低功耗设计

1. 项目背景与需求分析城市井盖作为地下管网的重要出入口&#xff0c;其安全状态直接关系到公共安全。传统井盖管理主要依赖人工巡检&#xff0c;存在响应滞后、成本高昂等问题。我们团队在市政部门实地调研中发现&#xff0c;仅2023年某省会城市就发生井盖相关事故127起&#…

作者头像 李华
网站建设 2026/10/31 3:28:13

AI编程工具成本优化:9.9元方案实战指南

1. 背景与现状&#xff1a;AI编程工具集体涨价潮2024年Q2以来&#xff0c;主流AI编程工具陆续开启涨价模式。GitHub Copilot企业版从每月19美元涨至39美元&#xff0c;Tabnine Pro版本价格翻倍&#xff0c;Cursor团队订阅费用上涨60%。这波涨价潮背后是AI算力成本飙升和资本回报…

作者头像 李华
网站建设 2026/10/31 3:26:43

小语种学习者的免费背单词工具与技术解析

1. 小语种学习者的痛点与需求分析在全球化日益深入的今天&#xff0c;掌握一门小语种已经成为职场竞争和个人发展的重要加分项。但小语种学习者普遍面临着几个核心痛点&#xff1a;资源稀缺性&#xff1a;相比英语、日语等主流语种&#xff0c;小语种学习资料往往分散且不系统记…

作者头像 李华
网站建设 2026/10/31 3:26:31

Fakes、Mocks、Stubs 分不清?一文讲透测试替身选型与实战

测试代码写到一定阶段&#xff0c;几乎每个人都会撞上同一堵墙&#xff1a;明明只是想验证一个分支逻辑&#xff0c;结果为了跑通一个函数&#xff0c;不得不把数据库、消息队列、第三方接口全部拉起来&#xff0c;测试跑一次要等半分钟&#xff0c;改一行代码重跑一遍&#xf…

作者头像 李华