简介:面向STM32嵌入式开发者的IAP Bootloader实践资料,基于YModem协议实现串口在线升级方案,完整覆盖bootloader启动、数据接收、CRC校验、Flash写入及跳转APP的关键流程。压缩包共475个文件,以C语言源码、头文件、编译生成的.o/.hex目标文件及Keil工程文件(uvprojx/uvoptx)为主,附带调试配置、映射文件与说明文档,便于直接打开工程对照学习。资源约9.71MB,已有2366人学习下载。资料中同时提供“1_IAP”与“2_APP程序”两部分,清晰展示Bootloader与应用程序的地址分区和交互机制;针对YModem协议中的分帧、校验与重传逻辑也给出完整实现,可直接移植到实际项目或作为二次开发基础。同时,工程内丰富的源文件和编译中间文件有助于深入理解STM32 Flash编程规范及IAP运行流程,适合希望掌握STM32在线升级与串口传输协议的嵌入式工程师参考学习。 STM32 IAP 这块,只要做过产品量产或者后期维护的工程师,基本都绕不开 bootloader 的设计。我之前给一个跑 RTOS 的 STM32F103 项目做远程升级功能时,第一版用的是最简单粗暴的做法:擦除一整片 Flash,跑一个固定的串口协议,把 bin 文件整包收下来再跳转。功能倒是能跑,但稍微一改固件大小、一换调试工具,就得跟着改上位机,后来干脆把协议换成了 YMODEM 才彻底理顺。这篇文章就把整个 bootloader 的实现思路、YMODEM 协议细节、App 侧需要配合的改动全部写清楚,同时也把「IAP 跳转后卡死在 HAL_Delay」这类高频问题做个完整复盘。
这篇文章适合正在做 STM32 固件升级、量产烧录、OTA 差分升级的开发者,也适合那些已经跑通了 bootloader 但被各种"跳转后异常""程序跑飞"问题折磨的同学。我会从 Flash 分区讲到 YMODEM 帧格式,从 bootloader 状态机写到 App 向量表偏移,全部是能直接落地的方案。
1. IAP 不是"抄一段跳转代码"那么简单:先想清楚启动架构
很多新手接触 IAP(In Application Programming)时,第一反应是"bootloader 不就是判断一下要不要升级,然后跳转到 App 吗"。这话对了一半。跳转确实是最后一步,但真正的难点在跳转之前:固件从哪来、放在哪、怎么保证写入过程中断电不出问题、App 改完地址之后还能不能正常跑中断。
1.1 上电启动流程与 IAP 的位置
STM32 上电后,从0x08000000取出栈顶指针 MSP,再从0x08000004取出复位向量,然后跳转到复位处理函数执行。如果没有 bootloader,那么0x08000000就是 App 的起始地址。一旦引入 IAP,Flash 就被分成两块(或更多块):Bootloader 区和App 区。
启动流程: 0x08000000 Bootloader 区:上电先跑这里 0x08008000 App 区:正常业务代码 Bootloader 做的事: while(1) { 判断是否需要升级; 如果需要升级 -> 接收固件 -> 写入 Flash -> 跳转; 如果不需要升级 -> 直接跳转 App; }这样设计的核心价值有两点:一是产品出厂后可以通过串口、USB、CAN 等接口升级固件,不必拆机接 SWD;二是升级失败还能停在 bootloader 里重新升级,不至于变砖。
1.2 Flash 分区规划:大小和起始地址怎么定
分区是 IAP 的第一步决策,也是最容易被低估的一步。我见过有人 bootloader 只分 8KB,用的还是标准外设库,结果编译出来 9KB 多,直接塞不下。bootloader 的 Flash 占用要预留充足,尤其是用了 HAL 库之后,一个简单的串口接收程序动不动就 5~8KB。
以 STM32F103C8T6(64KB Flash)为例,我的分区方式是:
| 区域 | 起始地址 | 大小 | 说明 |
|---|---|---|---|
| Bootloader | 0x08000000 | 16KB(0x4000) | 包含启动代码、YMODEM 接收、Flash 驱动 |
| App 区 | 0x08004000 | 48KB(0xC000) | 业务代码,向量表从这里开始 |
| App 末尾预留 | 0x0800F800 | 2KB | 存放升级标志、版本信息 |
这里有个细节:App 区起始地址必须按扇区/页对齐。F103 是 1KB 一页(小容量)或 2KB 一页(中容量),F4 是扇区结构(16KB、64KB、128KB 不等)。如果 App 起始地址没对齐页边界,擦除时会把 App 开头给擦掉,或者出现写入覆盖。F103 中容量每页 1KB,所以 0x08004000 是页对齐的,没问题。
1.3 升级标志位的设计
不需要升级时,bootloader 直接跳 App。但怎么判断"需不需要升级"?常见方式有几种:
- 按键检测:上电时按住某个按键进入升级模式
- 上位机命令:bootloader 里跑一段超时窗口,等待主机发送升级指令,超时则跳转
- 标志位:App 里收到升级指令后,在备份寄存器或 Flash 末尾写一个标记,然后软复位,bootloader 检测到标记后进入升级模式
我项目里用的是"上位机命令 + 备份寄存器标志"的组合。App 收到升级指令后,把标志写到 RTC 备份寄存器(RTC->BKPxR),然后NVIC_SystemReset()。Bootloader 启动后先查标志,有则进入 YMODEM 接收流程,没有则直接跳 App。用备份寄存器比 Flash 写标志好一点,因为不占 Flash 寿命,但要注意PWR时钟和备份域访问必须在跳转前使能。
1.4 升级流程的完整时序
整个升级过程最理想的状态是:主机(PC 端串口助手或自研上位机)发一个"进入升级"指令,设备重启进 bootloader,bootloader 回复一个 "C" 字符,主机选择固件文件通过 YMODEM 发送,bootloader 收到完整文件后校验通过,写入完成,跳转 App。App 启动后向上位机回一条版本信息,完成闭环。
这套流程里,YMODEM 解决的是"固件文件怎么可靠地通过串口发过去"的问题。它有文件名字段、文件大小字段、块编号、CRC 校验,比裸传 bin 靠谱得多,也比 XMODEM 更适合固件升级场景。
2. YMODEM 协议拆解:为什么它是固件升级的优选方案
YMODEM 不是新东西,上世纪 80 年代就有了,但到今天依然是嵌入式 IAP 的常客。它的设计初衷是文件传输,具备 XMODEM 没有的文件元数据传递能力(文件名、大小、时间戳),同时保留 128/1024 字节两种数据块、CRC16 校验和自动重传机制。在串口这种容易受干扰的物理链路上,可靠性足够高。
2.1 YMODEM 帧格式与关键控制字符
YMODEM 传输的基本单位是"块"(Block),每块分为头部、序号、数据、校验四个部分。数据区有两种长度:128 字节(SOH)和 1024 字节(STX)。协议中几个关键控制字符:
| 字符 | 值 | 含义 |
|---|---|---|
| SOH | 0x01 | 128 字节数据块头 |
| STX | 0x02 | 1024 字节数据块头 |
| EOT | 0x04 | 发送结束 |
| ACK | 0x06 | 确认 |
| NAK | 0x15 | 否定确认,请求重发 |
| CAN | 0x18 | 取消传输 |
| C | 0x43 | 请求发送(CRC 模式) |
YMODEM 的起始帧(文件信息帧)是 128 字节块,结构为:
SOH | 00 | FF | filename\0filesize\0mtime\0 | ...NUL填充... | CRC_HI | CRC_LO注意序号:第一块是00,第二块的补码是FF,这不是 bug,是协议规定。序号从 0 开始,每发送一块加 1,到 255 后回绕到 0。接收端判断顺序就是靠这个序号。
2.2 CRC16 校验的计算
YMODEM 用的是CRC-16/XMODEM,多项式0x1021,初始值0x0000,没有反转输入输出。我最初在这里踩过一个坑:直接用标准 CRC-16/CCITT 的查表法,算出来的值怎么都对不上。后来才发现 YMODEM 的 CRC 是 XMODEM 变体,查表法虽然多项式相同但初始值和处理方式有差异。
手写计算逻辑:
uint16_t ym_crc16(const uint8_t *data, uint32_t len) { uint16_t crc = 0x0000; for (uint32_t i = 0; i < len; i++) { crc ^= (uint16_t)data[i] << 8; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x8000) crc = (crc << 1) ^ 0x1021; else crc <<= 1; } } return crc; }接收端每收到一块,先算 CRC,再和帧尾的两个字节比较,一致才回 ACK,否则回 NAK 请求重发。如果发的是 1024 字节块,CRC 覆盖的是整个 1024 字节数据区。
2.3 起始帧的解析细节
YMODEM 的起始帧是整个协议里最容易解析出错的地方。数据区以\0分隔三个字段:文件名、文件大小(十进制 ASCII 字符串)、文件修改时间。例如:
"firmware.bin\0123456\01123456789\0"文件大小字段是十进制 ASCII 字符串,不是十六进制。我第一次写解析时想当然按strtol的 16 进制解析,结果固件大小全错,写入一半 Flash 就溢出了。正确做法是拿到\0分隔的字符串后,用十进制方式strtoul(str, NULL, 10)转成整数。另外起始帧的序号是 0,如果收到序号非 0 的起始帧,应该直接回 CAN 取消传输。
2.4 数据块大小协商与切换
YMODEM 的发送方会根据文件大小自动选择数据块长度:文件剩余数据超过 1024 字节发 STX 块,不足 1024 字节的最后一块发 SOH 块。接收端无需协商,只需同时支持两种头字符的解析。
实际测试中我发现一个细节:某些 YMODEM 上位机实现(尤其是改版过的)在整个传输过程中固定用 STX 块,最后不足 1024 字节的部分用0x1A(SUB,DOS 文件填充符)补齐。bootloader 写 Flash 时要按实际文件大小截断,不能把填充字节也写进去,否则 App 区尾部会多出一段垃圾数据。分区够大时问题不大,但强迫症看着难受,而且如果固件签名校验覆盖整个 App 区,填充字节会导致校验失败。
最后是结束流程:发送方发 EOT,接收方回 ACK,然后再发一个C字符,发送方回一个全 0 的起始帧(或直接结束),接收方回 ACK,整个传输完成。有些实现省略了结束帧,直接发完 EOT 就等 ACK,bootloader 做容错时要能处理这两种情况。
3. Bootloader 侧实现:从串口收包到 Flash 写入的完整链路
Bootloader 的代码结构不需要多花哨,一个状态机就够。核心思路是:不阻塞、不超时、不丢包。我第一版用阻塞式HAL_UART_Receive接收,结果主机那边发快了丢字节,发慢了 bootloader 卡在等待中出不来。后来改成"中断接收 + 主循环状态机"的方式才稳定。
3.1 串口接收与环形缓冲区
Bootloader 的串口配置没什么特殊之处,但接收方式要特别处理。我用的方案是 DMA 空闲中断 + 环形缓冲区,逻辑上等同于把串口收到的每个字节都塞进一个 FIFO,主循环从 FIFO 里取字节喂给 YMODEM 协议状态机。
#define RING_BUF_SIZE 1024 static uint8_t ring_buf[RING_BUF_SIZE]; static volatile uint16_t head = 0, tail = 0; void UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint16_t next = (head + 1) % RING_BUF_SIZE; if (next != tail) { ring_buf[head] = rx_byte; head = next; } HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }串口中断接收每次只收 1 字节,效率不高但简单可靠。在主循环里不断检查环形缓冲区有没有数据,有则取出来喂状态机。这个模式在 bootloader 场景下完全够用——YMODEM 传输速率最高也就 115200bps,每秒约 11.5KB,1KB 的环形缓冲区足够扛住中断突发。
3.2 YMODEM 接收状态机
YMODEM 接收端的状态机大致分四个阶段:
IDLE -> START_RECV(收到起始帧)-> DATA_RECV(收数据块)-> FINISH(收到EOT)伪代码逻辑:
switch (ym_state) { case YM_IDLE: if (rx_char == 'C') // 收到发送方的块请求 // 实际上接收端主动发 C 进入等待起始帧 break; case YM_WAIT_START: if (rx_char == SOH) { 接收 128 字节帧;解析文件名和大小; 回 ACK;发 C; 状态切到 YM_WAIT_DATA; 擦除 App 区; } else if (rx_char == CAN) { 取消传输; } break; case YM_WAIT_DATA: if (rx_char == STX) { 接收 1024 字节帧;校验 CRC;写入 Flash;回 ACK/NAK; } else if (rx_char == SOH) { 接收 128 字节帧;校验 CRC;写入 Flash;回 ACK/NAK; } else if (rx_char == EOT) { 回 ACK;发 C;等待结束帧; 状态切到 YM_FINISH; } else if (rx_char == CAN) { // 连续两个 CAN 才是取消 取消传输; } break; case YM_FINISH: if (rx_char == SOH) { // 结束帧,全 0 回 ACK;升级完成; } else if (rx_char == EOT) { 回 ACK;升级完成; } break; }注意一个细节:取消传输需要连续收到两个 CAN 字符,单个 CAN 可能是干扰。bootloader 里要加一个简单的计数逻辑,连续收到两个 CAN 才确认取消,防止误判。
3.3 Flash 写入流程与擦除策略
Flash 写入有几个必须遵守的规则:写之前必须先擦除;擦除以页/扇区为单位;写入按半字(16位)或字(32位)对齐;写入过程中不能有中断打断(尤其不能有 Flash 操作的中断)。
F103 的页大小是 1KB(中容量),App 区从 0x08004000 到 0x0800F7FF,共 46 个页。接收起始帧、解析出文件大小后,就可以按需擦除这些页。擦除时机放在起始帧校验通过、进入数据接收前,避免先擦后收导致中途失败丢原有固件。
页擦除和写入用 HAL 库:
static uint32_t flash_write_buf(uint32_t addr, const uint8_t *buf, uint32_t len) { uint32_t write_addr = addr; uint16_t half_word; for (uint32_t i = 0; i < len; i += 2) { half_word = buf[i] | (buf[i + 1] << 8); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, write_addr, half_word) != HAL_OK) { return 0; } write_addr += 2; } return 1; }写入时按半字处理,因为 STM32F1 的 Flash 编程最小单位就是半字。如果用字节写会直接报错。F4 最少按字(32位)写,这个要区分开。
一个经验:边收边写优于收完再写。收到一块数据、CRC 校验通过后立即写入 Flash,这样即使传输中断,之前的数据已经固化了,重传时可以从断点继续(当然 YMODEM 本身没有断点续传机制,但至少不需要整包重来)。而且边收边写对 RAM 需求低,不需要开大数组缓存整个固件。
3.4 跳转前的安全检查与执行
Bootloader 在跳转前必须做两件检查:一是 App 区起始地址的栈顶值是否合法(在 RAM 地址范围内),二是 App 的复位向量是否合理。如果跳转到一块空白 Flash 或者擦了一半的 Flash,跑出来的结果就是 HardFault。
typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_stack = *(volatile uint32_t *)app_addr; uint32_t app_reset = *(volatile uint32_t *)(app_addr + 4); // 栈顶地址必须在 RAM 范围内 if (app_stack < SRAM_BASE || app_stack >= SRAM_BASE + SRAM_SIZE) { return; // 固件无效,留在 bootloader } // 复位向量必须在 Flash 范围内 if (app_reset < app_addr || app_reset >= FLASH_END_ADDR) { return; } __disable_irq(); HAL_UART_DeInit(&huart1); // 复位所有外设时钟 __HAL_RCC_DEInit(); // 关闭 SysTick 并清除 pending SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 清所有 NVIC pending for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } // 设置 MSP 为 App 栈顶 __set_MSP(app_stack); // 获取并执行 App 复位函数 pFunction jump = (pFunction)app_reset; jump(); }这里的__HAL_RCC_DEInit()是容易漏掉的一步。它把所有外设时钟复位到默认状态,避免 bootloader 里开了某个外设的中断,跳转到 App 后中断还在触发,导致 App 初始化顺序混乱或跑飞。
4. App 侧的三个关键改动:向量表、链接脚本与编译选项
Bootloader 写好之后,App 不是拿来就能跑的。最典型的问题就是跳转过去以后程序死在启动文件里。原因基本都是向量表位置没改对。
4.1 中断向量表偏移(VTOR)
ARM Cortex-M 内核的中断向量表默认在0x00000000(或0x08000000)。App 的向量表在0x08004000,如果系统发生任何中断,CPU 去0x08000000取中断向量,取到的是 bootloader 的中断处理函数,App 的中断就全乱了。
解决办法是用SCB->VTOR设置向量表偏移。在 App 的main最开始处(甚至启动文件里)加一行:
SCB->VTOR = 0x08004000;对于 STM32F1 系列,VTOR 是存在的,但有些老版本的启动文件没有初始化它,需要在 main 里手动设置。F4 系列部分型号(如 F405)VTOR 不支持任意偏移,只支持对齐到 0x400 的倍数,分区时已经按 0x400 对齐就没问题。设置完 VTOR 之后,所有中断才能正确跳转到 App 的向量表。
这个设置要放在任何外设初始化和中断使能之前。如果开启了某个外设中断后才设置 VTOR,中断可能已经触发了,向量表还没切过去,就会跳到一个错误的地址执行。
4.2 链接脚本和 Keil 分散加载设置
App 的烧录地址也要改。如果用 Keil,在 Options for Target -> Target 标签页里:
- IROM1 起始地址改为
0x08004000,大小改为0xC000(48KB) - IRAM 保持默认
如果用 GCC,则修改链接脚本.text段的起始地址:
FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 48K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K这里有一个我踩过的坑:IROM1 大小改了之后,App 的可执行文件大小不能超过 48KB。编译时如果没注意,链接器会报错,但有些优化选项下可能不会立即报错,而是静默生成了一个超出分区大小的 bin。这个 bin 烧进去会覆盖下一个区域的数据,运行时各种诡异问题。建议在编译后脚本里加一步检查 bin 文件大小的步骤,超过分区大小直接编译失败。
4.3 烧录时的地址设置
App 开发阶段用 ST-Link 烧录时,程序烧到0x08004000而不是默认的0x08000000。Keil 在 Debug 设置的 Flash Download 里要勾选 "Erase Sectors" 并设置起始地址;如果用 STM32CubeProgrammer,则把起始地址填成0x08004000。
如果直接一键烧录,默认地址会覆盖 bootloader,导致设备变砖(需要重新 SWD 连回来刷 bootloader)。所以 App 工程里我会单独维护一个烧录配置,确保不覆盖 bootloader 区。
4.4 中断向量表与启动文件的关系
一个常被忽略的细节:如果 App 是从 bootloader 跳转过来的(不是复位上电),系统时钟、外设时钟、中断向量表都要由 App 重新初始化。HAL 库的SystemInit()在启动文件里被调用,负责设置时钟源、PLL 等。跳转过去后,启动文件里的SystemInit已经在 bootloader 阶段不执行了,如果 App 的main里不手动调用SystemInit(),系统时钟可能停留在 bootloader 配置的状态。保险做法是 App 的 main 最开始就调用SystemInit(),然后SystemCoreClockUpdate(),再设置 VTOR。
5. 跳转后卡死是头号翻车现场:一次完整的 root cause 排查
如果你在网上搜 STM32 IAP,出现频率最高的坑就是"跳转后卡死"和"HAL_Delay 不执行"或者干脆 HardFault。我当时调第一版 bootloader 时也栽在这里,前前后后花了两个下午才定位到原因。这里把排查过程完整复盘一遍。
5.1 现象描述与初步判断
现象是:bootloader 正常跳转,App 的 LED 初始化和串口打印都正常(说明基本启动流程跑通了),但主循环里第一次调用HAL_Delay(500)就再也回不来了,程序卡死。
当时第一反应是看门狗——但项目没开 IWDG,排除;然后怀疑是某个中断没关干净,打断导致死循环,也不像;最后打开调试器,在HAL_Delay里打断点,发现卡死在while循环等待 SysTick 中断置位。
5.2 卡死根因:SysTick 与 HAL_Delay 的绑定关系
HAL 库的HAL_Delay依赖uwTick变量,而这个变量是在 SysTick 中断里HAL_IncTick()累加的。SysTick 中断要正常工作,必须满足:
- SysTick 定时器使能,中断使能
- NVIC 中 SysTick 的优先级配置正确
- SysTick 的中断向量指向 App 的
SysTick_Handler
App 里通过HAL_Init()调用了HAL_InitTick()来配置 SysTick,但问题在于:从 bootloader 跳转过来时,SysTick 的向量表还指向 bootloader 区(因为 VTOR 设置的位置不对,或者 VTOR 设置的地址在 Flash 上但向量表内容没更新),中断触发后 CPU 跳到 bootloader 的SysTick_Handler,而这个函数在 App 的启动阶段没执行——它属于 bootloader 的代码,此时 bootloader 已经跳走,不会做任何有效操作。于是uwTick永远不增长,HAL_Delay死等。
5.3 排查链路的三个关键步骤
如果你自己也遇到类似卡死,建议按下述顺序排查,每走一步都能排除一类原因。
第一步:确认 VTOR 是否正确设置。在调试器中查看SCB->VTOR的值,必须是 App 区的起始地址(如0x08004000)。如果不是,在 main 开头手动赋值:
SCB->VTOR = 0x08004000;这是系统性原因,一次设错会导致所有中断异常。
第二步:确认跳转前是否禁用了中断。__disable_irq()之后,如果 App 没有重新使能,SysTick 中断永远不会触发。HAL 库的HAL_Init()会调用HAL_InitTick(),其中就有使能 SysTick 中断的操作。你在调试时看SysTick->CTRL的 TICKINT 位和 NVIC 的使能位。
第三步:确认 App 的SysTick_Handler是否在向量表正确位置。打开 App 工程的启动文件.s文件,查看__Vectors表中SysTick_Handler的入口是否是 App 的SysTick_Handler函数地址。如果启动文件里 SysTick 向量缺失或写错(某些剪裁过的启动文件只有部分中断向量),也会卡死。
5.4 另一个隐蔽坑:bootloader 里的串口中断残留
F103 的串口接收中断如果在跳转前没有关闭,跳转到 App 之后串口 RXNE 中断会持续触发。因为 App 一开始可能没初始化串口、没清标志位,中断一进来就进busy状态,甚至死循环。这就是为什么跳转函数里要先HAL_UART_DeInit再关中断。
排查这类问题的一个有效技巧是:跳转前把所有外设 RCC 复位(前面代码里的__HAL_RCC_DEInit()),让所有外设回到上电默认状态。这样不管 bootloader 里用了什么外设,都不会把状态污染给 App。代价是 App 启动时要重新初始化这些外设,但这是正常流程,不影响功能。
5.5 跳转后 HardFault 的另一类原因
有些情况下卡死变成了 HardFault,多见于 App 的栈指针设置错误。跳转代码里__set_MSP(app_stack)如果拿到的栈顶值不在 RAM 范围内(比如从 Flash 里读出来的是0xFFFFFFFF,说明该地址没有有效代码),跳转后第一条指令就会触发异常。所以我前面特意写了两层检查:栈顶值范围检查和复位向量范围检查。这两步可以拦截绝大多数无效固件导致的崩溃。
6. 上位机与整机测试:工具选择、接收链路验证与生产注意点
Bootloader 本身跑通还不够,整个升级链路要能经受住真实场景的考验。这里讲讲上位机如何选、怎么验证接收链路、以及量产时要特别注意的几个点。
6.1 上位机工具:几个可用的 YMODEM 串口工具
市面上支持 YMODEM 的串口工具不少,但质量参差不齐。我实际用过几种,列个对比:
| 工具 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|
| SecureCRT | 支持 YMODEM,稳定,能在脚本里自动交互 | 收费,配置项多 | 高 |
| ExtraPuTTY | 免费,内置 YMODEM,可直接选文件发送 | 界面老,部分版本对中文文件名支持差 | 高 |
| Tera Term | 免费开源,YMODEM 支持完善 | 默认界面英文 | 中高 |
| MobaXterm | 集成多种协议,串口 + YMODEM 组合好用 | 体积大 | 中 |
| 自研脚本 | 完全可控,支持自定义校验和日志 | 需要开发成本 | 条件允许时最佳 |
最稳妥的方式是自己写一个 Python 脚本调用串口发送 YMODEM,因为可以在发送前做文件大小检查、CRC 预计算、发送后对比返回值。如果只是调试,用 SecureCRT 或 Tera Term 就够了,关键是串口参数要对:波特率、8N1(8 数据位、无校验、1 停止位),流控关闭。
6.2 YMODEM 与裸 bin 发送的对比
有人可能会问:既然要发 bin 文件,为什么不用更简单的裸串口发送?对比一下就能理解:
| 项目 | 裸 bin 发送 | YMODEM |
|---|---|---|
| 文件信息(大小、名称) | 需自行定义协议 | 协议自带 |
| 错误检测 | 需自行加 CRC | 每块自带 CRC16 |
| 重传机制 | 需自行实现 | 协议自带 NAK/重传 |
| 接收端对齐 | 需自行处理 | 块边界清晰 |
| 调试工具支持 | 需自研上位机 | 现成工具多 |
裸发送的一个致命问题是:如果传输过程中串口丢了一个字节,整个数据流从那个字节开始错位,后续所有数据全错,而接收端很可能毫无察觉。YMODEM 有块序号和 CRC 校验,错一块检测到就重传,不会累计错误。
6.3 升级链路验证:从 App 写入到版本确认
验证升级功能时,不要直接升级大固件。我建议按这个顺序测试:
- 空升级测试:bootloader 进入升级模式但不发数据,等待超时,看是否能正常跳转 App
- 小文件测试:放一个只翻转 LED 的小程序,验证 YMODEM 接收、CRC 校验、Flash 写入全流程
- 完整 App 测试:用当前版本的固件重新升级一遍,验证升级后所有功能(包括中断、RTOS、外设)正常
- 断电测试:升级过程中断电几次,确认下次上电能回到 bootloader 并重新升级,不会变砖
- 干扰测试:串口线靠近电机或继电器干扰源,观察重传机制是否有效
升级完成后的确认最好做双向:bootloader 在写完固件后读回几个关键地址做比对,App 启动后向上位机上报版本号。我之前做产品时,App 启动第一件事就是串口打印"APP v2.1.0",测试人员看到版本号变新了才算升级成功。
6.4 批量生产时的注意事项
量产烧录场景下,IAP 面临的问题略有不同。建议做三件事:
一是 bootloader 区加写保护。通过 option bytes 设置 Flash 写保护,防止量产时误擦 bootloader。F103 的写保护以页为单位,保护 bootloader 区不影响 App 区升级。
二是把上位机升级软件做成命令行工具。在产线自动化里集成升级脚本,不要靠人工点"发送文件"。人工操作在批量场景下误触率太高,容易把固件发到错误设备。
三是固件文件加版本号和校验和。YMODEM 只负责传输,不负责"这个固件能不能在这个硬件上跑"。产品规模大了之后,不同硬件版本对应不同固件,建议在 bin 文件尾部追加一段元数据(版本号、硬件 ID、CRC 校验),bootloader 收到后先验元数据,不匹配就拒绝升级。
7. 实际项目中积累的几条经验
这一节放一些零散但很有价值的实操心得,不像前面那么系统,但每一条都是实际跑过项目之后的教训。
第一,bootloader 的串口波特率不要定 115200 以上。有些方案鼓吹 921600 高速升级,实际在普通杜邦线和 USB 转串口模块上,高速率下误码率明显上升,CRC 重传频繁,总耗时反而可能更慢。115200 是最稳妥的选择,升级一个 64KB 的固件大约 6 秒,完全可接受。
第二,YMODEM 的起始帧要宽容解析。某些上位机实现的文件名字段会带路径(比如C:\Users\...\firmware.bin),有些只带文件名。bootloader 解析时统一取最后一个\之后的部分,或者干脆忽略文件名只取大小字段。
第三,升级过程中不要喂独立看门狗。如果产品开了 IWDG,bootloader 的 YMODEM 接收等待过程必须定期喂狗,否则等待主机发送时看门狗超时,设备重启,升级永远无法完成。喂狗的位置放在状态机主循环里即可,确保没有长时间阻塞。
第四,App 区一定要留够余量。产品迭代过程中固件只会越来越大,很少缩小。分区时按当前固件大小的 1.5~2 倍预留 App 区,否则半年后加个新功能就发现 App 区装不下了。
第五,调试时开着日志,量产时关掉。bootloader 里的printf调试信息在产线升级时可能干扰 YMODEM 通信——严格来说不会,因为 YMODEM 只在特定状态窗口有效,但调试信息会占用串口时间,某些实现下可能抢占总线导致丢字节。正式版本建议把调试输出全部关掉,只保留协议应答。
这套方案我在 STM32F103、F303、F405 上都跑过,核心代码基本可以平移。YMODEM + 串口 + Flash 分区的组合,虽然谈不上多先进,但胜在简单可靠、调试工具多、排障方便。如果有更复杂的场景(比如无线升级、加密固件、签名校验),可以在这一套基础上迭代扩展。
本文还有配套的精品资源,点击获取