
做嵌入式开发这几年我越来越觉得IAPIn-Application Programming属于“早晚都得做”的功能。量产后的固件迭代、现场设备的维护升级、开发阶段频繁调参改Bug总不能每次都让工程师拎着一根J-Link去现场拆机接线。用STM32 HAL库配合串口Ymodem协议做IAP升级是我在项目里摸爬滚打总结出来的一套比较稳妥的方案既能直接在SecureCRT、XShell、各类串口助手里传输固件又不需要额外的上位机开发成本。这篇文章把完整的实现思路、协议拆解、关键代码和避坑点都记下来给正准备做串口升级的朋友一个参考。1. 为什么IAP升级首选串口Ymodem协议1.1 IAP与ICP、ISP的差别很多人第一次听到IAP时会把它和ICP、ISP搞混。三者的核心区别在于“谁在烧写”和“通过什么接口烧写”。ICPIn-Circuit Programming通过SWD/JTAG调试接口由外部烧录器如ST-Link、J-Link直接把程序写进Flash。这种方式需要在硬件上预留调试口开发阶段用起来很顺手但产品发布后就不太现实了。ISPIn-System Programming利用芯片出厂固化的Boot ROM通过串口把程序写入Flash。STM32的BOOT0引脚拉高后芯片会自动跑系统存储区里的Bootloader用户不用写任何代码就能通过串口烧写。缺点是升级过程完全依赖厂家的Bootloader灵活性差。IAPIn-Application Programming芯片正在运行用户程序时程序自己擦除和重写Flash实现“自己升级自己”。Bootloader程序负责通信协议接收新固件App程序负责业务逻辑两者分工配合。用一句话总结ICP是“换电池”ISP是“换手机壳”IAP是“系统里在线升级”。我们做产品固件升级明显更关心IAP这种方式。1.2 常见升级方案横向对比IAP的传输通道有很多选择串口、USB、CAN、以太网、无线都有人用。我在不同项目里都试验过实际选型时还是得看产品的硬件基础和升级场景。升级方案硬件基础优势劣势典型场景串口YmodemUSART USB转TTL实现简单、几乎每块板子都有串口、上位机现成速度一般、需现场接线下发文件大多数工控、消费电子设备USB DFUUSB设备接口速度快、可通过USB口直接升级需要做USB设备栈驱动兼容坑多带USB接口的消费类产品CAN总线升级CAN收发器抗干扰强、传输距离远、可挂多节点需要CAN上位机、协议要自己定义车载、工业控制网络OTA以太网/WiFi模块可远程升级、可做差分增量协议栈复杂、需考虑断点续传和安全性物联网网关、智能家居在大多数单片机项目里串口几乎是标配USB转TTL模块常见的是CH340、CP2102也便宜。对于需要本地升级、又不希望引入复杂通信协议栈的产品串口升级是性价比最高的选择。1.3 Ymodem兼顾效率与可靠的折中方案确定串口作为物理通道后传输协议又得选一轮。Xmodem、Ymodem、Zmodem这几兄弟经常被放在一起比较。Xmodem数据块固定128字节每发一帧都要等ACK效率低而且只支持单文件传输。Ymodem在Xmodem基础上做了改进支持128字节和1024字节两种帧长传输效率提升明显还支持文件信息文件名、大小一起发送。Zmodem传输能力和断点续传更强但对嵌入式环境来说支持Zmodem的串口工具相对少协议本身也更复杂单片机端解析代码量会大不少。Ymodem的巧妙之处在于它把“文件头信息”和“数据块”都定义在协议里接收方不需要预先知道文件名和大小收到起始帧后就能自动解析。这个特性被SecureCRT、Xshell、超级终端以及各种串口调试助手广泛支持意味着我们不需要专门写上位机软件直接用现成工具就能升级固件。1.4 HAL库为这个场景带来的实际收益STM32开发库有标准外设库SPL、HAL库、LL库这几个选择。做IAP这种涉及串口、Flash、中断、时钟等多个外设的功能我更推荐HAL库。HAL库封装了底层寄存器操作代码的可移植性非常好。我有一次需要把STM32F103的工程适配到某国产兼容芯片上由于对方也提供了HAL风格的驱动库大部分代码只需要改一下时钟配置和引脚定义就能跑起来这比当初用SPL标准库时省事太多。另外HAL库的HAL_FLASH_Program、HAL_UART_Transmit等API封装清晰IAP的代码逻辑即使换个芯片型号也能复用。需要留个心眼的是HAL库的初始化代码相对“重”中断回调机制也比较绕。用HAL做IAP时串口接收到每个字节后进中断回调实际业务逻辑要在回调里快速处理不能让中断拖太久否则会影响传输时序。2. Flash分区与整体架构先分好地盘再动手2.1 以STM32F103RCT6为例的Flash规划IAP架构的第一步不是写代码而是规划Flash地址。Flash分区一旦定下来后面Bootloader和App的链接地址、向量表偏移全都跟着走改起来非常痛苦。拿常用的STM32F103RCT6举例这颗芯片有256KB Flash每页大小为1KB。我的分区方案是分区地址范围大小内容Bootloader0x08000000 - 0x08003FFF16KB接收固件、校验、跳转逻辑App区0x08004000 - 0x0803FFFF240KB用户应用程序备份参数区0x08040000 - 0x0803FFFF预留升级标志、设备参数实际产品如果App固件很大Bootloader也可以分配到32KB。分区原则有两个一是Bootloader区要预留余量避免升级功能扩展时空间不够二是App起始地址尽量对齐页边界方便擦除和写入。2.2 Bootloader与App的职责边界Bootloader程序的任务很纯粹检查升级标志如果不需要升级直接跳转到App如果需要升级通过串口接收Ymodem数据校验CRC后将数据写入Flash。它不承担业务逻辑代码量控制在几千行以内。App程序是实际运行的应用。它在启动时要做两件事一是设置正确的向量表偏移让中断能正常响应二是预留一个命令入口比如收到特定串口指令后设置升级标志位并软复位让系统进入Bootloader。值得强调的是Bootloader和App是两个独立的Keil工程编译生成两个独立的hex/bin文件。Flash分区地址、向量表偏移、升级标志位这些参数需要在两个工程里保持一致这是最容易出错的地方。2.3 向量表偏移App启动的命门Cortex-M3内核的MCU中断向量表默认在地址0x08000000。App程序链接到0x08004000后如果不改向量表偏移芯片一旦产生中断仍然会跳转到0x08000000取向量结果取到的是Bootloader的中断向量轻则功能异常重则直接HardFault。设置向量表偏移有两种方式在SystemInit函数里修改VTOR寄存器SCB-VTOR 0x08004000;在main函数最开头修改SCB-VTOR FLASH_BASE | 0x4000;如果你用的是HAL库SystemInit函数在芯片上电时就会先执行所以把VTOR设置放在SystemInit里最保险。我踩过的坑是有些型号的HAL库SystemInit里默认不清VTOR只改了链接地址结果中断全乱套。老老实实在main函数开头加上VTOR设置反而更直观可靠。3. Ymodem协议拆解握手时序与CRC16计算3.1 帧格式SOH、STX、ACK这些字节到底什么意思Ymodem协议是基于字节流的每个帧都有固定的格式。初学者看到一堆控制字符容易蒙圈其实只要记住协议里只有几种帧控制字符值含义SOH0x01128字节数据帧起始STX0x021024字节数据帧起始EOT0x04传输结束ACK0x06确认接收NAK0x15否认接收CAN0x18取消传输C0x43等待接收大写字母C128字节数据帧格式为SOH [序号] [255-序号] [128字节数据] [CRC高8位] [CRC低8位]1024字节数据帧格式为STX [序号] [255-序号] [1024字节数据] [CRC高8位] [CRC低8位]起始帧比较特殊它本质上是一个128字节的数据帧但数据区前几个字节是文件名和文件大小SOH 0x00 0xFF [文件名] 0x00 [文件大小] 0x00 [填充0x00] [CRC高8位] [CRC低8位]传输结束前的结束帧则是一个文件名为空的起始帧SOH 0x00 0xFF [全0x00填充] [CRC高8位] [CRC低8位]。3.2 完整的收发交互流程理解Ymodem协议最好的方式是直接看握手时序我画不出图就用文字把完整流程理一遍接收方Bootloader发送字符C表示准备接收。发送方串口工具收到C后发送起始帧含文件名、文件大小。接收方收到起始帧后解析文件信息回ACK再发送C开始收数据。发送方按序发送数据帧每发一帧接收方校验通过后回ACK校验失败回NAK发送方重发当前帧。数据全部发完后发送方发EOT接收方回ACK。发送方再发送一个两字节的结束帧文件名为空接收方回ACK整个传输过程结束。发送方连续发几个CAN表示取消传输接收方连续收到多个CAN也应该主动退出升级流程。这个交互逻辑虽然简单但状态机稍微没设计好很容易卡在某个环节后面避坑部分我会详细展开。3.3 CRC16-CCITT的软件实现Ymodem协议使用CRC16-CCITT校验多项式为0x1021初始值为0x0000。我参考经典算法写过一版实测稳定uint16_t Ymodem_CRC16(const uint8_t *buf, uint32_t len) { uint16_t crc 0x0000; while (len--) { crc ^ *buf 8; for (uint8_t i 0; i 8; i) { if (crc 0x8000) { crc (crc 1) ^ 0x1021; } else { crc 1; } } } return crc; }我在调试过程中发现一个常见问题CRC字节的高低顺序容易搞反。Ymodem规定先发CRC高字节再发低字节如果接收解析顺序反了每一帧都会校验失败表现为一直回NAK。解决思路是先用串口调试助手在PC端测试确认上位机计算出的CRC值能够和单片机端计算结果对上。3.4 传输协议状态机设计Ymodem接收端的核心逻辑是一个状态机定义几个关键状态typedef enum { YM_STATE_WAIT_C 0, // 等待发送方响应 YM_STATE_WAIT_START, // 等待起始帧 YM_STATE_RECEIVING, // 正在接收数据帧 YM_STATE_WAIT_EOT, // 等待EOT YM_STATE_WAIT_END_FRAME, // 等待结束帧 YM_STATE_FINISH // 传输完成 } YM_State;每个状态下收到不同的字节走不同的分支。比如YM_STATE_RECEIVING状态下收到SOH/STX就进入一帧数据的完整接收收到EOT说明发送方数据传输完了回ACK后进入YM_STATE_WAIT_END_FRAME收到CAN则放弃本次升级。在实际工程里我习惯把状态机函数写在单独的文件里用uint8_t Ymodem_ProcessByte(uint8_t byte)作为对外接口。串口中断每收到一个字节就调用一次这个函数可以在几微秒内返回不会影响实时性。4. Bootloader端核心代码实现4.1 串口收发的底层配置Bootloader的串口用中断方式接收每收到一个字节立刻交给Ymodem状态机处理。初始化代码如下// 串口初始化 void MX_USART1_UART_Init(void) { huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; HAL_UART_Init(huart1); HAL_UART_Receive_IT(huart1, rx_byte, 1); }串口中断回调函数void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart huart1) { Ymodem_ProcessByte(rx_byte); HAL_UART_Receive_IT(huart1, rx_byte, 1); } }这里有个细节每次HAL_UART_Receive_IT只接收一个字节中断回调里处理完当前字节后必须重新调用HAL_UART_Receive_IT否则后续字节不再进中断。很多人在网上问“为什么串口只收到第一个字节”多半是漏了这一步。4.2 Flash擦写流程STM32F103的Flash以1KB为一页擦除必须按页操作。Ymodem每帧最多1024字节恰好一页数据的写入逻辑就比较自然。擦写命令如下void Flash_ErasePages(uint32_t start_addr, uint32_t num_pages) { FLASH_EraseInitTypeDef erase {0}; uint32_t page_err 0; HAL_FLASH_Unlock(); erase.TypeErase FLASH_TYPEERASE_PAGES; erase.PageAddress start_addr; erase.NbPages num_pages; HAL_FLASHEx_Erase(erase, page_err); HAL_FLASH_Lock(); } void Flash_WriteData(uint32_t addr, uint8_t *data, uint32_t len) { HAL_FLASH_Unlock(); for (uint32_t i 0; i len; i 4) { uint32_t word 0; word | (uint32_t)data[i]; word | (uint32_t)data[i 1] 8; word | (uint32_t)data[i 2] 16; word | (uint32_t)data[i 3] 24; HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr i, word); } HAL_FLASH_Lock(); }注意F1系列Flash写入必须按字4字节对齐如果数据不是4字节整数倍最后一帧要单独处理。另外在擦除或写入Flash期间CPU会暂停执行如果这段时间里串口还在收数据就存在丢字节风险。解决方案是写入Flash前暂时关闭串口接收中断写完再打开后面避坑部分会专门讲。4.3 接收状态与数据落盘逻辑Ymodem状态机每次遇到完整的数据帧会将该帧数据拷贝到临时缓冲区然后写入Flash。关键代码框架void Ymodem_OnDataFrame(uint8_t frame_seq, uint8_t *data, uint32_t len) { static uint32_t write_addr APP_START_ADDR; Flash_WriteData(write_addr, data, len); write_addr len; // 回复ACK UART_SendByte(0x06); }为了应对传输中断后重刷的场景每次擦除后App区从0x08004000开始写入。起始帧解析出的文件大小可以用来统计进度我在Bootloader里用定时器在板上LED上闪烁来指示传输状态也可以把进度信息通过串口打印到调试工具。void Ymodem_OnStartFrame(const uint8_t *filename, uint32_t filesize) { uint32_t pages (filesize 1023) / 1024; if (filesize APP_MAX_SIZE) { // 文件过大主动取消 UART_SendByte(0x18); return; } Flash_ErasePages(APP_START_ADDR, pages); UART_SendByte(0x06); // ACK UART_SendByte(0x43); // C }4.4 跳转函数从Bootloader到App的最关键一步升级完成或者不需要升级时Bootloader要通过函数指针跳转到App。跳转之前先把外设和中断清理干净设置主栈指针为App堆栈的起始地址再跳转。typedef void (*pFunction)(void); #define APP_START_ADDR 0x08004000 void JumpToApp(void) { uint32_t app_stack *(volatile uint32_t *)APP_START_ADDR; pFunction jump_to_app (pFunction)(*(volatile uint32_t *)(APP_START_ADDR 4)); __disable_irq(); // 关闭全部中断 HAL_UART_DeInit(huart1); // 复位串口外设 HAL_RCC_DeInit(); // 恢复时钟到默认状态 SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; __set_MSP(app_stack); // 设置App堆栈指针 jump_to_app(); // 跳转 }跳转的原理是芯片复位后CPU从0x08004000地址读栈顶指针从0x08004004地址读复位向量然后跳到复位向量处执行。我们的函数就是模拟了这个过程手动读栈顶指针、读复位向量、跳转。只要App工程的链接起始地址和数据正确这个流程就一定能跑通。跳转前如果串口外设没有DeInitApp里再初始化串口时可能出现异常。所以我在跳转前把用到的串口、定时器和所有外设都做一次DeInit这步不能省。5. App端需要同步配合的改动5.1 工程起始地址与启动文件Connecting App工程时第一步要修改链接地址。在Keil MDK里打开工程配置找到Target标签页将IROM1的Start地址改为0x08004000Size设为0x3C000240KB。这一步决定了所有代码和函数的链接地址都落在App区。启动文件startup_stm32f103xe.s无需修改栈指针和向量表自动链接到新地址。但前提是您必须确认启动文件的开头部分没有写死为0x08000000正版ST官方启动文件一般只定义堆栈大小不写死地址。5.2 系统时钟与向量表重定向App的main函数第一件事就是设置向量表偏移int main(void) { SCB-VTOR APP_START_ADDR; // 0x08004000 HAL_Init(); SystemClock_Config(); // ... 后续初始化 }注意如果先调用HAL_Init再设置VTOR中间一旦发生中断比如SysTick异常CPU还是会按Bootloader的向量表取向量很多时候直接卡死。所以VTOR最好放在最开头。我在某个项目里就因为把VTOR放在了初始化之后导致App一跑就进HardFault排查了半天才反应过来。5.3 编译生成bin文件Keil默认生成的是hex文件Ymodem升级过程中需要的是bin文件。在User标签页的After Build/Rebuild里添加一条fromelf命令即可自动生成fromelf --bin --output.\Output\app.bin .\Output\app.axf生成的bin文件就是最终通过串口工具发送给Bootloader的固件。我习惯把hex和bin都保留一份hex用于ST-Link直接烧录调试bin用于走IAP流程验证。6. 避坑实践我在实际项目中踩过的那些坑6.1 坑1跳进App就HardFault问题出在向量表和堆栈有一次我把Bootloader和App都编译好Bootloader正常接收完固件一执行跳转函数板子直接卡死。用ST-Link Utility读Flash确认App数据确实写入正确问题就出在跳转逻辑。排查链路是这样走的先查App的栈顶指针值是否落在RAM范围内再用调试器看跳转后PC指针跑到了哪里最后对照汇编代码。最终发现我在跳转前调用了HAL_DeInit()这个函数会把所有外设的句柄复位但也会把SysTick的中断关掉如果App的HAL_Init()没有重新配置SysTick系统就会一直卡在某个等待中断的地方。正确做法是跳转前只做RCC_DeInit和串口DeInit不要调用完整的HAL_DeInit。另外跳转前务必__disable_irq()App启动后会重新配置中断这一步能避免旧中断状态污染新程序。提示跳转前还要确认App固件里的向量表偏移设置已生效。一个快速验证方法是在App的main函数里点几个断点单步看PC是否从0x08004000开始执行。6.2 坑2HAL库串口DMA发送断续导致传输卡死Ymodem升级时Bootloader回复给上位机的ACK、C、NAK等字符都是用串口发送的。我最初图省事用HAL_UART_Transmit_DMA发送回复结果出现一个诡异现象升级偶尔卡住重试几次又好了完全没有规律。排查到最后发现DMA发送虽然是非阻塞的但如果上一次DMA传输还没结束就立刻启动下一次发送会出现数据覆盖或丢失。HAL库的DMA发送模式下连续调用HAL_UART_Transmit_DMA需要等待上一次传输完成否则会返回HAL_BUSY。解决方案有两种一是直接用阻塞式HAL_UART_Transmit发送这些单字节回复Ymodem每帧之间本来就有时延阻塞几毫秒完全不影响二是用HAL_UART_Transmit_DMA之前检查HAL_UART_GetState确保状态是HAL_UART_STATE_READY。6.3 坑3SecureCRT传输时文件名带路径解析直接失败Ymodem起始帧里会带上文件名和文件大小。有很多串口工具在发送文件时默认会把文件的完整路径也放到文件名里。比如发送D:\项目\firmware\app.bin时协议里的文件名可能是D:\项目\firmware\app.binBootloader解析文件名时如果对路径分隔符处理不严谨就有可能导致帧解析异常。SecureCRT里传输Ymodem文件时正确的做法是取消勾选“Send file with full pathname”之类的选项只发送裸文件名。如果用的不是SecureCRT而是自己写的小工具或某个串口助手也要留意这个问题。我处理这个坑的办法是在起始帧解析时只提取文件名中最后一个\或/之后的部分并且对文件名字段做长度限制Ymodem协议规定文件名最大32字节。万一上位机发了超长文件名也只取前31个字节避免缓冲区溢出。6.4 坑4结束帧处理不当升级完成后Bootloader不退出Ymodem协议有个细节非常容易忽略数据发完收到EOT后Bootloader回完ACK还会再收到一个“空文件名的结束帧”。如果状态机只处理到EOT就以为传输结束那么最后一次ACK之后Bootloader会一直卡在等待状态表现为“固件写完了但是不跳转”。正确的状态迁移是收完所有数据帧后发送方发EOTBootloader回ACK。发送方继续发一个结束帧SOH 0x00 0xFF数据区全0Bootloader收到后回ACK。此时才真正完成传输置标志位跳转到App。我遇到升级完成后不跳转的问题十有八九都出在这个结束帧上。调试时可以打开串口助手的HEX显示看最后几个字节是不是EOT后面还有一帧SOH 00 FF ...。6.5 坑5擦写Flash期间开了中断导致帧校验失败Ymodem传输过程中Bootloader需要把接收到的数据写入Flash。擦除页或编程字期间Flash控制器会暂停CPU对Flash的访问如果此时发生中断CPU进入中断服务函数后又会去读Flash中的中断向量和代码两边竞争访问Flash控制器轻则时序超时重则遗漏数据。我的做法是写Flash前关闭全局中断写完后重新打开__disable_irq(); Flash_WriteData(write_addr, data, len); __enable_irq();有些资料还会建议把Bootloader的栈区放在RAM避免中断处理时访问Flash但实际做起来比较麻烦。对于一些实时性要求不高的IAP场景直接关中断写Flash是最简单可靠的办法。6.6 坑6USB转TTL转换器的驱动和电气稳定性做串口Ymodem升级离不开USB转TTL模块这里面的坑其实比想象中多。CH340和CP2102是市面上最常见的方案CH340的驱动安装包小、兼容性好出问题概率低杂牌CP2102的驱动在Win10/Win11下偶尔会出现“设备无法识别”“串口号漂移”的毛病。电气稳定性方面USB转TTL模块输出的是3.3V TTL电平还是5V电平必须和目标板子的电平匹配。曾经有一次我拿了一根5V输出电平的USB转TTL线去连3.3V供电的电路板结果串口收发乱码单帧CRC校验一直失败浪费了将近一天才排查出来。此外线材过长时115200波特率下还可能出现高电平衰减建议线长控制在20cm以内或者降波特率到57600。提示如果调试阶段发现单帧CRC一直错可以先排除接线和电平问题。拿个万用表量一下TX、RX的电平或者在PC上用串口调试助手自发自收测试先把物理链路验证通再怀疑协议代码。6.7 补充用虚拟串口软件辅助调试在没有硬件或者不想反复烧录Bootloader时可以用虚拟串口软件比如VSPD创建一对互连的虚拟串口一个口给串口调试助手发送固件另一个口给上位机模拟Bootloader的PC端逻辑这样可以先把Ymodem协议的握手和帧解析逻辑调通。不过虚拟串口毕竟是PC上模拟的和真实MCU的波特率容差、电气毛刺场景有差异最终必须在真机上完整实测一遍。最后再分享一个小技巧升级流程完整跑通后建议在固件末尾追加一个固定的校验标志比如App最后4字节写入一个魔术字。Bootloader在跳转前先检查这个魔术字是否存在如果不存在说明固件不完整或写入失败可以停留在Bootloader等待重试。这个小技巧相当于给IAP加了一道保险能避免由于固件损坏导致的产品变砖。我在实际项目中还习惯在升级完成后自动回读Flash中前几页数据和bin文件做对比全部一致才允许跳转多一次校验少一次事故。