ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32串口IAP升级:Ymodem协议Bootloader分区实现详解

STM32串口IAP升级:Ymodem协议Bootloader分区实现详解

简介:一套基于STM32F103C8T6与STM32F407ZGT6的Ymodem串口IAP远程升级代码资源,面向嵌入式固件开发与物联网产品维护场景,适合需要实现bootloader引导升级、远程固件更新的开发者学习参考。压缩包共1103个文件,包含197个h头文件、194个c源文件,以及bin/hex烧录文件、uvprojx工程文件等典型Keil MDK项目结构,另有o、axf、map等编译中间文件,包体约40.34MB。两份工程代码均包含点灯型APP测试程序(呼吸灯与亮暗灯),已通过SecureCRT上位机实际发送文件验证完整升级流程,能帮助读者理解Ymodem协议移植、串口收发、Flash分区跳转及APP与bootloader协同工作的关键细节。目前已有1645人学习/下载,可用作IAP远程升级的入门资料,也可直接抽取协议栈与启动逻辑移植到实际产品项目中,节省协议对接与调试时间。

1. STM32 串口 IAP 的现场:为什么选 Ymodem 而不是裸传固件

做过产线固件维护的人都有这种体会:板子已经封壳、没有引出 SWD,或者设备部署在远端,改一版逻辑就得寄回工厂开盖烧录,来回一周没了。STM32F103C8T6 和 STM32F407ZGT6 这两颗芯片,一个只有 64KB Flash、20KB RAM,一个拥有 1MB Flash、192KB RAM,Flash 擦除单位和容量差异非常大,但“不能开盖升级”的需求完全一样。IAP(In-Application Programming)就是解决这个问题的标准路子:出厂时烧一个 bootloader,后续固件通过串口下载到指定 Flash 区,重启跳转运行。传输协议里 Ymodem 是我一直推荐的选择,它有文件名、文件大小、CRC16 校验和分包重传,复杂度比 Zmodem 低得多,在 Cortex-M 上几百行 C 代码就能跑起来,而且主流串口终端都原生支持,不需要额外写上位机。这篇笔记把这套 bootloader 的 Flash 分区、协议实现、App 侧配合和踩过的坑一次讲清楚,适合正在做远程升级方案、或者想把当前固件升级链路换成 Ymodem 的开发者。

2. Flash 分区与链接脚本:bootloader 和 app 的边界不能靠感觉

IAP 的核心前提是“Flash 里有两套程序,各自不越界”。这个边界不是在代码里用 if 判断出来的,而是靠链接脚本和下载地址硬性约束的。分区错了,轻则 app 起不来,重则擦除操作把 bootloader 自己抹掉。

2.1 F103C8T6 与 F407ZGT6 的 Flash 擦除结构差异

先把两颗芯片的 Flash 物理结构摆出来,这是所有分区决策的基础。

STM32F103C8T6 是 64KB Flash,按 1KB 页擦除,总共 64 页,没有 sector 的概念。擦除函数只认页地址,只要地址对齐到 1KB 边界,就不会误伤相邻区域。它的 RAM 只有 20KB,所以 bootloader 里的缓冲区、协议栈、擦写函数要精打细算,不适合开大的接收缓冲。

STM32F407ZGT6 是 1MB Flash,擦除单位是 sector,而且 sector 大小不均匀,具体划分如下:

Sector地址范围大小
Sector 00x08000000 - 0x08003FFF16KB
Sector 10x08004000 - 0x08007FFF16KB
Sector 20x08008000 - 0x0800BFFF16KB
Sector 30x0800C000 - 0x0800FFFF16KB
Sector 40x08010000 - 0x0801FFFF64KB
Sector 5 - 110x08020000 - 0x080FFFFF各 128KB

也就是说,F407 的 app 起始地址必须落在某个 sector 的起始地址上。如果 app 从 0x08010000 开始,擦除 Sector 4 就能整体覆盖;如果从 0x08018000 这种不对齐的地址开始,擦除 Sector 4 会把 0x08010000 之前的区域也清掉,那里面可能是 bootloader 的尾巴或者变量区,升级一次直接变砖。

2.2 分区方案:bootloader 占哪、app 从哪开始、参数区放尾

我常用的分区方案是“bootloader 独占头部,app 紧跟其后,参数区放 Flash 尾部”。

对 F103C8T6:

区域地址大小
Bootloader0x08000000 - 0x08007FFF32KB
App0x08008000 - 0x0800EFFF28KB
参数区0x0800F000 - 0x0800FFFF4KB

对 F407ZGT6:

区域地址大小
Bootloader0x08000000 - 0x0800FFFF64KB(Sector 0-3)
App0x08010000 - 0x080FEFFF约 956KB
参数区0x080FF000 - 0x080FFFFF4KB(Sector 11 尾部)

参数区放的是升级标志、固件版本号、app 长度和 CRC32 校验值,bootloader 启动时先读这一块决定“进升级模式”还是“直接跳 app”。

为什么 bootloader 要占 32KB 甚至 64KB?因为 Ymodem 接收状态机、环形缓冲区、Flash 擦写函数、CRC 校验表、串口驱动外加调试打印,轻松超过 16KB。我见过有人把 bootloader 压到 8KB,结果中途加个回滚功能就得删功能,极其被动。

2.3 链接脚本:bootloader 和 app 的 LD / 散列文件怎么改

bootloader 的链接脚本基本不用动,Flash 起点保持 0x08000000,长度按分区改小即可。以 GCC 环境下 F407 的 bootloader 为例:

MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 0x10000 RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 0x30000 }

LENGTH 0x10000 就是 64KB,程序超过这个值,链接阶段就会报错,比运行期翻车好排查得多。

app 的链接脚本是重头戏。F407 app 的 Flash 起点必须改成 0x08010000:

MEMORY { FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 0xE0000 RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 0x30000 }

LENGTH 0xE0000 是 1MB 减掉 bootloader 占用的 64KB 和参数区的 4KB,正好对齐 0x080FF000 参数区起点。F103 的 app 则把 FLASH ORIGIN 改为 0x08008000,LENGTH 改为 0x7000。

如果你用的是 Keil MDK,不用手改 .sct 文件,在 Options -> Target 页面里设置 IROM1 的 Start 和 Size 就行。Start 填 0x08010000,Size 填 0xE0000,MDK 会自动生成对应的散列文件。注意这里有一个非常容易忽略的点:改完链接脚本后,app 工程里如果有绝对地址取址的代码(比如某些 Bootloader 跳转库),要同步检查这些地址是否还落在新 Flash 范围内。

3. Ymodem 协议落地:帧格式、CRC16 校验与接收状态机

Ymodem 协议不复杂,但细节极其容易翻车。它跟 Xmodem 最大的区别是:第一个帧是文件头帧,包含文件名、文件大小等信息,接收端可以据此校验文件长度;数据帧支持 128 字节和 1024 字节两种长度,通过帧首字符区分。

3.1 握手流程与帧格式:从发 C 到 EOT 的状态转换

Ymodem 的主动权在接收端。bootloader 进入升级模式后,先往外发一个字符 C(0x43),表示“我准备好了,请开始传文件”。PC 端串口工具收到 C 后,开始发送文件头帧。

帧格式如下:

  • SOH(0x01)表示本帧是 128 字节块,STX(0x02)表示本帧是 1024 字节块
  • 序号:第一帧为 0x00,第二帧为 0x01,第三帧又回到 0x00,如此循环,序号范围 0-255
  • 序号取反:对序号按位取反,校验序号完整性
  • 数据区:128 字节或 1024 字节,文件头帧的数据区前 8 字节是文件名,紧接一个 0x00,然后是文件大小 ASCII 字符串,再一个 0x00,剩余补 0x00
  • CRC16:两个字节,高字节在前

文件头帧示例:

/* 文件名 APP.BIN,文件大小 50000 字节 */ uint8_t header_frame[133] = { 0x01, /* SOH, 128 字节帧 */ 0x00, /* 序号 0 */ 0xFF, /* 序号取反 */ 'A', 'P', 'P', '.', 'B', 'I', 'N', 0x00, '5', '0', '0', '0', '0', 0x00, /* 后面全部补 0x00 */ };

接收端收到文件头帧后,解析出文件名和大小,回一个 ACK(0x06)。发送端接着发序号为 1 的数据帧,接收端每收一帧就校验 CRC16,通过回 ACK,失败回 NAK(0x15)。数据全部传完后,发送端发 EOT(0x04),接收端回 ACK,然后发送端再发一个 EOT,接收端再回 ACK,整个传输结束。

这里要特别注意:很多串口工具实现的是“EOT 后回 ACK 再发 C 等待下一个文件”,bootloader 如果没实现第二个 EOT 的处理,PC 端会一直等。我一般建议 bootloader 在第二个 EOT 后直接判断“所有文件已收到”,退出升级模式。

3.2 CRC16 校验:XMODEM 多项式与查表法实现

Ymodem 用的是 CRC-16/XMODEM,多项式 0x1021,初值 0x0000,输入输出都不反转,结果也不异或。这个算法跟 Modbus 的 CRC16(多项式 0x8005,初值 0xFFFF,结果异或 0x0000)完全是两回事,混用必然翻车。

逐位实现的版本:

uint16_t crc16_xmodem(const uint8_t *buf, uint32_t len) { uint16_t crc = 0x0000; while (len--) { crc ^= ((uint16_t)*buf++) << 8; for (int i = 0; i < 8; i++) { if (crc & 0x8000) { crc = (crc << 1) ^ 0x1021; } else { crc <<= 1; } } } return crc; }

代码逻辑很直白:每个输入字节先异或到 CRC 高八位,然后左移 8 次,最高位为 1 就与多项式异或。逐位版本在 F103 上跑 50000 字节固件大概要几十毫秒,完全可接受;如果嫌慢,可以预生成一张 256 项的查表。

字节序是另一个坑:Ymodem 规定 CRC 高字节先发送,低字节后发送。有部分国产串口工具发的是低字节在前,bootloader 如果用统一收包流程会校验失败。我做兼容处理时会同时算两种字节序,或者先按高字节在前试,失败后交换再试一次。在代码里用宏控制:

if (rx_crc_hi == (crc >> 8) && rx_crc_lo == (crc & 0xFF)) { /* CRC 匹配,序列号正确 */ } else if (rx_crc_lo == (crc >> 8) && rx_crc_hi == (crc & 0xFF)) { /* 字节序反了,按低字节在前处理 */ } else { /* 直接 NAK */ }

强烈建议保留第二个分支,它在现场救过我两次,尤其是对接不可控的第三方升级工具时。

3.3 串口接收状态机:环形缓冲区加主循环解析

串口中断里做协议解析是初学者的常见错误:中断里调用 CRC 函数,一个字节一个字节地算,遇上高波特率时中断占用极重,还会跟 Flash 擦除互相干扰。正确做法是中断只收数据进环形缓冲区,主循环里做协议解析。

环形缓冲区用最经典的结构:

typedef struct { uint8_t buf[1025]; /* 必须大于 1024,留出帧首字节 */ volatile uint16_t head; volatile uint16_t tail; } ringbuf_t; ringbuf_t g_uart_rx; void UARTx_IRQHandler(void) { if (USART_GetITStatus(UARTx, USART_IT_RXNE)) { uint16_t next = (g_uart_rx.head + 1) % sizeof(g_uart_rx.buf); if (next != g_uart_rx.tail) { g_uart_rx.buf[g_uart_rx.head] = USART_ReceiveData(UARTx); g_uart_rx.head = next; } } }

这里有个参数细节:环形缓冲区大小设为 1025,是因为 Ymodem 的 1024 字节帧加帧首一共 1025 字节。如果缓冲区只有 1024,数据稍一积压就会丢帧。F103 的 RAM 只有 20KB,这个 1KB 缓冲区占掉 5%,但 Ymodem 传大数据块时性能收益值这个代价;F407 的 192KB RAM 完全没压力。

主循环的接收状态机,我一般拆成四个状态:

状态含义处理
IDLE等待文件头收到 SOH/STX 后进入 WAIT_HDR
WAIT_HDR正在收文件头帧收满 128 字节,解析文件名和大小,回 ACK
WAIT_DATA正在收数据帧按 SOH/STX 区分帧长,收满后算 CRC
WAIT_EOT数据接收完收到 EOT 回 ACK,收第二个 EOT 后退出

超时处理是状态机的灵魂。bootloader 每隔 1 秒检查一次当前状态从上次收到字节到现在的时间,超过 1000ms 没有新数据,就重新发一个 C 或者回一个 NAK。这个超时值不是随便定的:太小会在 PC 端工具处理文件时误触发重传,太大会让用户觉得升级卡死。

4. App 侧改造与跳转逻辑:中断向量表偏移和跳转前清理

bootloader 写好了,app 这边不改是跑不起来的。很多人卡在“bootloader 跳过去了但 app 不跑”或者“app 的外设中断一触发就死机”,基本都是 app 侧的两个改动没做到位。

4.1 App 工程的三个必备改动:VTOR、链接脚本、中断处理

第一,链接脚本,第 2.3 节已经写过,app 的 Flash 起点必须改。第二,中断向量表偏移。Cortex-M 内核默认从中断向量表的起始地址取栈顶指针和复位入口,偏移后必须告诉内核“向量表搬家了”。

F407 在标准库或 HAL 库下:

SCB->VTOR = 0x08010000;

F103 的老库不一定定义 VTOR 寄存器,可以直接操作地址:

#define SCB_VTOR (*(volatile uint32_t *)0xE000ED08) SCB_VTOR = 0x08008000;

这一行必须放在 app 的 main 函数最前面,在外设初始化之前。因为只要外设中断一打开,而向量表还指向 bootloader 的地址,中断来了就会取错入口,直接 HardFault。

第三,如果 app 用到了加密库或者 Bootloader 跳转库里的绝对地址变量,要确保这些符号在 app 的链接范围内。一个常见排查动作:编译 app 后看 .map 文件里 app 的 RESET 段是不是落在 0x08010000 开始的区域,而不是 0x08000000。

4.2 Bootloader 跳转 App 的干净姿势:关中断、读栈顶、跳转函数

跳转代码几乎是固定模板:

typedef void (*pFunction)(void); void jump_to_app(uint32_t app_base) { uint32_t sp_val = *(volatile uint32_t *)app_base; uint32_t pc_val = *(volatile uint32_t *)(app_base + 4); /* 栈顶指针必须落在 RAM 区域,否则直接放弃跳转 */ if ((sp_val & 0xFFF00000) != 0x20000000) { return; } __disable_irq(); /* 关闭 Systick,清中断标志 */ SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; /* 关闭外设时钟 */ RCC->AHB1ENR = 0; RCC->APB1ENR = 0; RCC->APB2ENR = 0; /* 清空 NVIC 挂起中断 */ NVIC->ICER[0] = 0xFFFFFFFF; NVIC->ICER[1] = 0xFFFFFFFF; NVIC->ICER[2] = 0xFFFFFFFF; __set_MSP(sp_val); ((pFunction)pc_val)(); }

逻辑分四步:先取 app 向量表前两个字的栈顶指针和复位向量地址;校验栈顶指针是否真落在 RAM 区间,这是跳转失败的第一道保险;然后关中断、关外设时钟、清 NVIC 挂起标志;最后设置主栈指针并跳转。

这里有个需要强调的细节:__set_MSP(sp_val)不能省。虽然复位后 SP 本来就从上电地址加载,但 bootloader 运行期间如果用过中断、嵌套或者 RTOS,MSP 已经被改写,不显式设置会让 app 一开始就栈错乱。__set_MSP在 CMSIS 里直接提供,F103 和 F407 通用。

NVIC 清挂起这步特别容易被忽略。bootloader 在 Ymodem 传输过程中开了串口中断,跳转前如果不把 NVIC 的 pending 位清掉,app 一启动就会触发残留中断,而 app 那边可能根本没有对应的中断服务函数,于是又 HardFault。

4.3 F103 与 F407 的跳转细节差异

F103 的 app 起始地址按 1KB 页对齐即可,0x08008000 这种地址完全没问题。F407 的 app 起始地址必须对齐 sector 边界,0x08010000 是默认选择。

F103 的 NVIC 只有 60 个中断,清挂起时 ICER[0] 和 ICER[1] 就够了;F407 的中断更多,ICER[0]、[1]、[2] 全清。这也是跳转模板里为什么写三行而不是一行的原因。

另外,F407 有 D-Cache 和 I-Cache,如果 bootloader 开了 Flash 预取或缓存,跳转前最好把 CACHE 也关闭,避免 app 执行到被缓存的旧指令。实际场景里 bootloader 很少开缓存,但如果你在 bootloader 里启用过 CCM 或者 ART 加速,跳转前清一下更稳妥。

5. 避坑清单:协议不兼容、扇区擦除翻车与升级掉电

这一章写的是我在几个真实项目里踩过的坑,每个都对应一个现象、一个深层原因和一个已验证的解决办法。

5.1 现象:PC 端发 1024 字节帧,bootloader 收到乱码或丢数据

原因:Ymodem 允许发送端在传输过程中自由切换 SOH(128 字节帧)和 STX(1024 字节帧),很多工具在文件较大会自动切到 1024 字节块。bootloader 如果只按 128 字节处理,或者环形缓冲区只有 512 字节,STX 帧一进来直接溢出。

解决:状态机里必须同时处理 SOH 和 STX 两种帧首,帧长分别取 128 和 1024;环形缓冲区大小至少要 1025 字节。解析时不要边收边算 CRC,等整帧齐了再校验。代码里判断帧长的位置如下:

if (frame_start == 0x01) { frame_len = 128; } else if (frame_start == 0x02) { frame_len = 1024; } else { send_nack(); return; }

5.2 现象:F407 升级完第一次跳转正常,第二次升级擦除了 bootloader

原因:这是 app 起始地址没对齐 sector 引起的。app 放在 0x08018000 这种地址,擦除 Sector 4 时把 0x08010000 到 0x08018000 的 bootloader 尾部数据一并清了。

解决:F407 的 app 起始地址只能选 sector 起始地址。推荐 0x08010000(Sector 4 起点),如果 app 特别大,后续 sector 都是 128KB 对齐,0x08020000、0x08040000 都可以。改完分区表之后,把所有 Flash 擦除的起始地址都对照这张表过一遍,别只改一处。

5.3 现象:跳转后 app 卡死在 HardFault,调试发现 SP 指向非法地址

原因:bootloader 跳转前没有校验栈顶指针,或者没有调用__set_MSP。app 的向量表读出来的第一个字根本不是合法栈地址,跳过去当然跑飞。

解决:跳转前强制检查(sp_val & 0xFFF00000) == 0x20000000,不合法就不跳,留在 bootloader 重新进入 Ymodem 等待。同时 app 侧确认向量表第一项确实是初始 SP。判断栈区范围时,F407 的 RAM 上限是 0x20030000,F103 是 0x20005000,如果你用高 12 位判断,两个芯片都适用。

5.4 现象:升级到一半断电,板子彻底不开机

原因:app 区被擦了一半,新固件没写完,旧固件已经没了。bootloader 启动时没有校验 app 有效性,还试图跳转,跳转失败卡死。

解决:引入“参数区 + app 头校验”机制。app 区最前面 16 字节预定义一个描述头,包含 magic、固件长度、CRC32,bootloader 每次启动都算一遍 app 区的 CRC32,跟描述头比对。不匹配就进 Ymodem 等待升级。这样断电后只是“升级失败”,至少还能重连串口重新升级,不算变砖。远程升级的产品里我还会在参数区写一个“升级进行中”的标志,bootloader 看到这个标志就直接进升级模式,而不是跳 app。

5.5 现象:串口波特率对,但 PC 端一直显示等待接收

原因:发送端(PC)和接收端(bootloader)在等谁先说话的问题。Ymodem 的发起方是接收端,必须由 bootloader 先发 C,PC 端收到 C 才会弹文件选择框或自动发文件。如果 bootloader 没发 C,PC 端就一直干等。

解决:bootloader 进入升级模式后,先延时 200ms 等串口线稳定,然后循环发 C,直到收到有效帧或超时。延时太短会造成第一个 C 在 PC 端初始化前就丢了。部分 PC 工具有“手动发送”模式,可以先发一帧测试握手是否顺畅。

6. 把串口升级做成真正的远程:升级命令字、CRC 校验与回滚验证

串口直连升级只是第一步,项目里真正要的是“远程”——设备部署在别处,通过 4G 模块、DTU 或者以太网把固件推下去。Ymodem 本身跑在串口上,所以只要把远端链路透传成串口即可。我习惯在 Ymodem 外面再包一层轻量命令帧,用来做升级状态控制。

命令帧格式可以自定义,我常用的是:帧头 0xAA55、命令字、长度、数据区、CRC32。bootloader 上电后先串口监听这些命令帧:

命令字含义用途
0x01版本查询返回当前 bootloader 和 app 版本号
0x02进入升级设置参数区标志,重启后进 Ymodem
0x03读取固件信息返回 app 长度、CRC、打包时间
0x04复位设备软重启

配合第 5.4 节的 app 描述头,这套流程可以做到三个目标:升级前查版本判断要不要升;升级后校验 CRC 确认成功;失败还能自动回滚或等待重传。我自己测试时固定走一遍下表的流程:

步骤操作预期
1串口发 0x01 查版本bootloader 返回版本号
2发 0x02 进入升级设备重启,串口出现 C
3PC 端选择 bin 文件 Ymodem 发送进度条走完,返回 OK
4重新上电app 正常运行,输出新版本号
5升级到一半断开电源再上电bootloader 检测 app 校验失败,重新发 C 等待升级

有一次我就是偷懒省掉了步骤 5 的断电测试,结果现场设备升级到一半断电,bootloader 还傻乎乎地往一个半擦除的 app 区跳,板子彻底没响应,最后只能靠人工拆机救回来。从那以后我每次改升级流程都强制把“断电注入”跑一遍,校验不过就进 Ymodem 等待,这已经是习惯动作了。

这套资源把 F103C8T6 和 F407ZGT6 的 Ymodem 串口 IAP 都实现了,bootloader 和 app 工程齐全,拿过来改一下串口号和 Flash 起点就能直接编译使用。开发时多花半小时看完这篇笔记,现场就能少熬一个通宵。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进