☰
串口固件升级实战:中断接收、帧协议与双区回滚
2026/10/17 3:48:09 网站建设 项目流程

简介:这份资源是STM32 HAL库串口接收数据的参考代码工程,面向正在学习STM32串口通信、需要实现中断接收与ASCII指令解析的嵌入式开发者。工程基于STM32F405平台,采用HAL库编写,串口以ASCII码形式发送数据,并以回车符作为一帧数据的结束标志,适合用于调试终端交互、上位机指令下发等场景。压缩包共85个文件,约647KB,其中50个h头文件与22个c源文件构成主要代码骨架,另含uvprojx、uvoptx等MDK工程文件、ioc配置、hex固件、启动文件及调试配置,目录按Core、Drivers、CMSIS等模块划分,结构清晰。目前已有2130人学习下载。读者可从中获取可直接编译运行的完整工程,理解串口中断接收、回车结束符判定与缓冲区处理的实现思路,并借助配套博文对照排查接收异常、数据截断等常见问题,快速迁移到自己的STM32项目中。

1. 串口固件升级包到底装了什么:从 UARTRecIT.zip 说起

拿到UARTRecIT.zip这个包名,第一反应往往是「UART 接收 + IT 中断」的组合,但真正拆开看,它更像一套围绕串口固件升级与接收链路验证的工程集合。UART 是嵌入式里最不容易被淘汰的外设,成本低、引脚少、几乎每颗 MCU 都带,可一旦把它用在固件升级、长帧接收、多机通信上,翻车概率立刻飙升。这个包要解决的核心问题很具体:让上位机通过串口把固件或数据块可靠地送进目标板,并在中断接收模式下完成校验、重传和跳转。适合谁?正在做 Bootloader、串口 OTA、工业采集终端固件维护的嵌入式工程师,以及被「接收丢字节、升级到一半卡死」折磨过的人。下面按「先跑通最小链路,再补可靠性,最后处理边界」的顺序拆。

2. 把 UARTRecIT 的接收链路拆开:中断、缓冲与帧协议

2.1 为什么中断接收比轮询更容易丢数据

轮询接收的代码写起来最省事,主循环里不停读状态寄存器,读到数据就塞进数组。问题在于主循环里只要有一个耗时操作——比如 Flash 擦除、屏幕刷新、协议解析——UART 的接收寄存器就会溢出。溢出之后硬件通常只保留最新一个字节,前面的数据直接消失,而且很多 MCU 的溢出标志需要手动清除,不清就再也收不到。

中断接收把「读数据」这件事从主循环里剥离出来,收到一个字节就进一次 ISR,把数据搬进环形缓冲区,主循环再从缓冲区取。这样即使主循环忙几百微秒,只要缓冲区没满,数据就不会丢。代价是 ISR 要足够短,不能在中断里做解析、不能等 Flash 写入、不能调用可能阻塞的函数。

UARTRecIT这个命名里的 IT 就是 Interrupt 的缩写,说明它的接收路径是中断驱动的。常见做法是:ISR 只做「读 DR 寄存器 → 写环形缓冲区 → 更新写指针」,其余全部丢给主循环。下面是一段典型的中断接收骨架,以常见的 HAL 风格为例:

/* 环形缓冲区:容量取 2 的幂,方便用位与代替取模 */ #define UART_RB_SIZE 512 static volatile uint8_t rb_buf[UART_RB_SIZE]; static volatile uint16_t rb_head = 0; /* 中断里写 */ static volatile uint16_t rb_tail = 0; /* 主循环里读 */ /* 串口中断服务函数:只搬数据,不做任何解析 */ void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { /* 收到一个字节 */ uint8_t byte = (uint8_t)(USART1->DR & 0xFF); uint16_t next = (rb_head + 1) & (UART_RB_SIZE - 1); if (next != rb_tail) { /* 缓冲区未满才写入 */ rb_buf[rb_head] = byte; rb_head = next; } /* 满了就丢弃,并置一个溢出标志供主循环统计 */ } if (USART1->SR & USART_SR_ORE) { /* 溢出标志必须清 */ (void)USART1->DR; } } /* 主循环侧:非阻塞取一个字节 */ int uart_rb_get(uint8_t *out) { if (rb_tail == rb_head) return 0; /* 空 */ *out = rb_buf[rb_tail]; rb_tail = (rb_tail + 1) & (UART_RB_SIZE - 1); return 1; }

逻辑说明:rb_head只在中断里改,rb_tail只在主循环里改,单生产者单消费者模型下不需要关中断保护,这是环形缓冲区最省事的用法。参数上,UART_RB_SIZE取 512 或 1024,太小会在高速率下溢出,太大浪费 RAM;取 2 的幂是为了让& (SIZE-1)替代% SIZE,在 ISR 里省几个周期。溢出标志ORE必须读一次 DR 才能清,这是很多 MCU 的硬件行为,漏掉这一步会出现「中断只进一次就再也不进」的玄学现象。

2.2 帧协议怎么定:长度、校验、超时三件套

中断只保证字节不丢,不保证「一帧」完整。串口是字节流,没有天然边界,必须自己定帧协议。UARTRecIT这类升级包通常用「帧头 + 长度 + 载荷 + 校验」的结构,常见有两种:

方案帧结构优点代价
定长帧帧头(2) + 序号(1) + 数据(128) + CRC16(2)解析简单,缓冲区固定短数据浪费带宽
变长帧帧头(2) + 长度(2) + 数据(N) + CRC16(2)灵活,适合升级块需要状态机,长度字段要防越界

升级场景我一般选变长帧,因为固件块大小可能随 Flash 页大小变化。解析用状态机而不是「收完再找帧头」,否则一帧跨两次中断就会解析失败。状态机至少要有四个状态:等帧头1、等帧头2、收长度、收载荷。每收到一个字节喂给状态机,收满一帧再校验 CRC。

超时是第三件套。如果只等长度字段,遇到干扰导致长度字段被改大,状态机会一直等下去,接收缓冲区被占死。做法是每收到一个字节重置一个软件计时器,超过比如 50ms 没有新字节就复位状态机。这个超时值要大于一帧的正常传输时间,115200bps 下传 256 字节约 22ms,取 50ms 留余量。

2.3 最小可跑通的接收验证步骤

不要一上来就接 Bootloader,先用回环验证接收链路本身。步骤:

  1. 把 MCU 的 TX 和 RX 短接,或者用上位机串口工具手动发数据。
  2. 初始化 UART 为 115200、8N1,开 RXNE 中断,NVIC 里使能对应中断。
  3. 主循环里调用uart_rb_get,把取到的字节原样通过 TX 发回,或者点灯计数。
  4. 用上位机连续发送 10KB 随机数据,统计收到多少、丢了多少。

如果丢字节,先看是不是 ISR 里做了耗时操作,再看缓冲区是不是太小,最后查波特率误差。很多开发板用内部 RC 振荡器做时钟源,波特率误差能到 3% 以上,115200 下就会偶发丢字节,换成外部晶振立刻正常。这一步的验证结果决定了后面升级能不能稳。

3. 固件升级流程落地:从握手到跳转的完整链路

3.1 升级状态机与 Flash 分区

串口升级的本质是:上位机把固件切成块,逐块发给目标板,目标板写进 Flash 的升级区,全部收完后校验整体 CRC,再跳转到新固件。Flash 分区必须先规划好,否则跳转后跑不起来。典型分区:

区域起始地址大小用途
Bootloader0x0800000016KB接收、校验、跳转
升级暂存区0x0800400096KB存放新固件
应用区0x0801C00096KB当前运行固件
参数区0x080340004KB版本号、升级标志

升级流程分两种:一种是 Bootloader 直接覆盖应用区,简单但断电就变砖;另一种是先写暂存区,校验通过再搬运到应用区,安全但需要双倍空间。UARTRecIT这类包通常用后者,因为串口升级最怕的就是传一半断电。

状态机大致是:等待握手 → 收到升级请求 → 擦除暂存区 → 逐块接收并写入 → 全部收完校验 → 写升级标志 → 复位 → Bootloader 搬运 → 跳转。每个状态都要有超时和错误回退,不能卡死。

3.2 分块传输与写入的代码骨架

下面是一段接收块并写入 Flash 的核心逻辑,省略了具体 Flash 驱动:

#define BLOCK_SIZE 256 #define STAGING_ADDR 0x08004000UL typedef enum { ST_IDLE, ST_ERASE, ST_RECV, ST_VERIFY, ST_DONE, ST_ERROR } up_state_t; static up_state_t state = ST_IDLE; static uint32_t write_addr = STAGING_ADDR; static uint32_t total_len = 0; static uint32_t recv_len = 0; /* 收到一帧完整数据后调用,payload 已通过 CRC 校验 */ void upgrade_on_frame(const uint8_t *payload, uint16_t len) { switch (state) { case ST_ERASE: /* 上位机发来总长度,擦除对应扇区 */ total_len = *(uint32_t *)payload; if (flash_erase_range(STAGING_ADDR, total_len) != 0) { state = ST_ERROR; break; } write_addr = STAGING_ADDR; recv_len = 0; state = ST_RECV; break; case ST_RECV: /* 按 4 字节对齐写入,不足部分补 0xFF */ if (flash_write(write_addr, payload, len) != 0) { state = ST_ERROR; /* 写入失败,通知上位机重传 */ break; } write_addr += len; recv_len += len; if (recv_len >= total_len) { state = ST_VERIFY; /* 收满,进入整体校验 */ } break; default: break; } }

逻辑说明:flash_erase_range按扇区擦除,擦除粒度通常是 1KB 或 2KB,所以total_len要向上对齐到扇区边界,否则最后一块写不进去。flash_write要求地址 4 字节对齐、长度 4 字节对齐,这是 Cortex-M 的 Flash 编程限制,不对齐会触发硬件错误。参数上,BLOCK_SIZE取 256 是折中:太小则帧数多、握手开销大,太大则单帧传输时间长、出错重传代价高。115200bps 下 256 字节约 22ms,重传一次可以接受。

3.3 跳转前必须做的三件事

收完固件不等于能跑。跳转前必须做:第一,校验整体 CRC,把暂存区所有字节算一遍,和上位机发来的总 CRC 比对,不一致就拒绝跳转;第二,关掉所有外设中断,尤其是 SysTick 和 UART,否则跳转后中断向量还指向 Bootloader,一进中断就 HardFault;第三,设置主栈指针为应用区向量表的第一个字,再跳到复位向量。

typedef void (*app_entry_t)(void); void jump_to_app(uint32_t app_addr) { uint32_t sp = *(volatile uint32_t *)app_addr; /* 栈顶 */ uint32_t pc = *(volatile uint32_t *)(app_addr + 4); /* 复位向量 */ __disable_irq(); /* 关总中断 */ for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; /* 关所有 NVIC 中断 */ NVIC->ICPR[i] = 0xFFFFFFFF; /* 清所有挂起 */ } SysTick->CTRL = 0; /* 关 SysTick */ SCB->VTOR = app_addr; /* 重定位向量表 */ __set_MSP(sp); /* 设主栈指针 */ ((app_entry_t)pc)(); /* 跳转,不返回 */ }

逻辑说明:SCB->VTOR重定位向量表这一步经常被漏掉,漏掉后应用区的中断会跳到 Bootloader 的向量表,表现为「能跑但一进中断就死」。__set_MSP必须在跳转前调用,因为应用区有自己的栈空间。跳转函数声明为不返回,编译器不会在它后面插返回指令。

4. 避坑与排查:串口升级最常见的五类翻车

4.1 现象:升级到 90% 卡死,复位后旧固件还在

原因通常是接收缓冲区溢出后状态机没复位,或者 Flash 写入某一块失败但没有回传错误,上位机一直在等 ACK。解决:在状态机里给每个状态加超时,超时后主动发 NACK 让上位机重传当前块;Flash 写入函数返回错误码,不要用void。另外检查暂存区大小是否够,total_len超过分区容量时擦除会越界。

4.2 现象:跳转后应用不跑,或者跑几秒就 HardFault

先查SCB->VTOR有没有设,再查应用区链接脚本的起始地址是不是和分区一致。很多人的应用工程默认从0x08000000链接,直接烧进去和 Bootloader 重叠,跳转后取到的向量表是错的。应用工程的链接脚本要把 Flash 起始改成应用区地址,中断向量表偏移也要同步改。

4.3 现象:偶发丢字节,重传率很高

按概率排查:波特率误差 > 2% 是首因,换外部晶振或用更高精度的时钟源;其次是 ISR 里调用了printf或 Flash 操作,把 ISR 拖长;再次是缓冲区太小,主循环处理慢。可以用一个计数器统计溢出次数,跑一轮升级看溢出是否为 0,不为 0 就先解决溢出。

4.4 现象:CRC 校验总是不对,但数据看着没错

常见原因是 Flash 写入时没有按对齐补0xFF,导致最后几个字节是擦除后的0xFF而不是实际数据;或者上位机算 CRC 的范围包含了帧头,目标板只算载荷。解决:双方约定清楚 CRC 覆盖范围,写 Flash 时不足对齐的部分显式补0xFF,不要依赖擦除后的默认值。

4.5 现象:升级成功后第一次上电正常,第二次上电又回旧固件

升级标志写在了 RAM 里而不是 Flash 参数区,复位后标志丢失,Bootloader 以为没有新固件。解决:升级标志、版本号、固件长度这些元数据必须写进独立的参数扇区,并且写入后做一次读回校验。参数区也要有 CRC,防止写一半断电导致标志损坏。

5. 把升级做成可回滚:双区切换与版本校验的实操

做到能升级只是及格,能回滚才算能用。工业现场最怕的是升级后新固件有 bug,设备变砖,只能拆机接调试器。双区切换的思路是:应用区再分 A/B 两个槽,Bootloader 根据参数区的「当前有效槽」和「待验证槽」决定跳哪个。新固件写进待验证槽,跳过去跑,跑起来后应用主动往参数区写一个「验证通过」标志,下次上电就固定用这个槽;如果新固件跑不起来,看门狗复位后 Bootloader 发现没有验证标志,自动回退到旧槽。

参数区结构建议至少包含:魔数、当前有效槽、待验证槽、A 槽版本、B 槽版本、各槽 CRC、整个参数区的 CRC。每次读写参数区都做整体 CRC 校验,校验失败就恢复默认值,宁可回退也不要带着损坏的参数跑。

版本校验不能只比版本号,还要比硬件型号和 Flash 容量。曾经遇到过把 A 型号的固件推给 B 型号,版本号一样但外设映射不同,跑起来直接死机。做法是在固件头部放一个固定的描述结构,包含硬件 ID、固件长度、入口地址、整体 CRC,Bootloader 跳转前逐项比对,任何一项不匹配就拒绝。

验证方法上,我习惯做三轮:第一轮正常升级,确认能跳转、能跑、参数正确;第二轮在传输到一半时拔电,重新上电确认能回退到旧固件;第三轮故意发一帧 CRC 错误的数据,确认目标板发 NACK 并重传,而不是把错误数据写进 Flash。三轮都过,这套升级链路才敢放到现场。

最后说个血泪经验:串口升级的调试信息不要用同一个串口打印,否则升级数据和日志混在一起,解析全乱。要么用第二个串口,要么把日志存到 RAM 里升级完再读。我一般会在 Bootloader 里留一个 RAM 日志区,升级结束后通过调试器读出来,比实时打印靠谱得多。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询