我前些年做一批带升级功能的设备最开始只留了SWD口出货之后才发现一个要命的问题产品已经铺到客户现场想要更新固件要么派人带着ST-Link跑现场拆机要么让客户把板子寄回来。后来改用串口IAP升级配合Ymodem协议一根USB转TTL线就能远程搞定问题迎刃而解。这篇文章就把我基于STM32 HAL库做串口Ymodem升级的完整方案拆开讲清楚从协议原理、Flash分区、代码实现到上位机操作最后把踩过的一堆坑也一并整理出来给正在做IAP升级的朋友当个参考。1. 整体方案设计IAP升级到底在解决什么问题1.1 为什么不用SWD非得上IAPIAP的全称是In-Application Programming中文叫在应用编程。它和ISPIn-System Programming最大的区别是ISP需要借助芯片内部的Bootloader通过串口把固件烧进去一般在出厂时使用而IAP是用户自己在Flash里写了一段引导程序通过这段程序去更新另一段应用程序整个过程可以由用户程序自己控制。用IAP做升级最直接的好处就是不用额外硬件。只要设备上有一个串口不管这个串口是通过USB转TTL芯片引出来的还是直接走RS485总线都能升级。这就意味着产品出货之后你不需要拆机、不需要专业烧录器只要把升级文件发到现场让操作人员用一根数据线接上就能完成固件更新。我见过不少团队做产品时把升级功能砍掉理由是“先跑通功能再说”。但实际上只要产品需要迭代IAP几乎是早晚要补的功能。哪怕你只在实验室里调试IAP也能让你省掉反复插拔下载器的麻烦。尤其当程序做到后期Flash空间紧张需要调整分区时没有IAP就只能动SWD接口量产后的维护成本会直线上升。1.2 Flash分区规划Bootloader和App怎么摆做IAP的第一步不是写代码而是先想清楚程序在Flash里怎么放。以STM32F103系列为例Flash起始地址是0x08000000容量通常从64KB到512KB不等。我们要把这块空间分成两个区域一段放Bootloader一段放App另外还需要一个专门存放升级标志位的地方。Bootloader区域的大小取决于你的引导程序有多复杂。如果只是跑个Ymodem协议接收数据、写Flash再跳转1KB到8KB基本够用。保险起见我给Bootloader分16KB也就是从0x08000000到0x08003FFF。App区从0x08004000开始剩下的空间全给App。分区规划这里是关键Bootloader和App的分区大小一定要留够余量。我见过有人只给Bootloader分4KB结果后期想加个加密校验功能空间直接不够用只能从头调整分区App工程也要跟着改起始地址非常麻烦。宁可一开始多分一点也别让自己后期陷入被动。升级标志位的存放位置也需要提前想好。常见方案是存在Flash最后一个扇区但要注意F103的Flash是均匀分扇区的F4系列的小扇区在前面、大扇区在后面规则不一样。还要注意标志位所在扇区不能和App区重叠否则每次擦写App时会把标志位也擦掉。1.3 方案选型Ymodem协议为什么比自研协议靠谱有些朋友会想既然就是传个文件那我随便定义一个协议不就行了比如包头、包号、长度、数据、校验和一套组合拳下来不也能收文件吗逻辑上没问题但自研协议有几个绕不开的短板一是工作量大收包、组包、超时重传、断点续传全都要自己测试二是纠错能力弱校验和只能发现错误不能可靠保证数据在传输过程中被篡改三是没有现成上位机配合你还得自己写个PC端软件这一下工作量就翻倍了。Ymodem协议好就好在成熟、简单、有现成工具链。它是Xmodem协议的改进版每个数据包除了数据之外还带了包序号、包序号的补码、CRC16校验值。而且Ymodem的第一个数据包会带着文件名和文件大小信息接收方可以根据文件大小判断什么时候接收完成非常方便。更关键的是SecureCRT、Xshell这些主流的串口终端都原生支持Ymodem协议发送你不需要额外开发上位机软件。把Bootloader烧进去打开SecureCRT选择“发送Ymodem文件”整个升级过程就启动了。2. Ymodem协议原理帧格式与时序全面拆解2.1 协议的核心帧结构Ymodem协议的定义其实非常紧凑。它把数据分成一个个数据块每个数据块最核心的结构是一个133字节的“标准包”包含起始字节SOH0x01表示本包数据长度为128字节包序号1字节从0x00开始每发一个包加1到0xFF后回卷到0x00包序号补码1字节也就是取反数据区128字节如果数据不足128字节用0x1ACtrlZ填充CRC16校验值2字节高字节在前低字节在后还有一种扩展包起始字节是STX0x02数据区是1024字节。使用STX包可以明显减少传输次数尤其当固件文件有几MB时能省不少时间。协议规定发送方会先发STX包如果接收方不支持回复NAK发送方就会退化成SOH包。实际使用中我建议直接兼容两种包反正代码里就是多一个分支的问题。关于结束时序这里是最容易搞混的地方发送方传完最后一个数据包后会发送一个EOT0x04接收方正确收到后回复ACK这时发送方会再发一个EOT接收方回复NAK这一步是Ymodem特有的二次握手发送方收到NAK后发送一个全空的数据包数据区全部为0x00接收方收到后回复ACK整个传输过程才算结束。很多从Xmodem迁移过来的代码在这里都会踩坑直接把第二个EOT也回了ACK导致发送方不结束。2.2 文件信息帧的解析Ymodem和Xmodem最大的不同就是第一个数据包不是普通数据而是文件信息包。这个包同样用SOH开头包序号固定是0x00数据区里包含了文件名、文件大小等信息。文件信息包的格式是这样的数据区最开始是文件名文件名以0x00结尾文件名后面是文件大小用十进制ASCII字符串表示同样以0x00结尾如果还有剩余空间全部填0x00。举个例子假设要发送的文件是app.bin大小是65288字节那信息包的数据区就是“app.bin\0 65288\0”后面全部用0x00补齐。接收方解析文件信息包时要注意一个细节有些上位机比如某些国产串口工具在文件名后面还会附加一些额外字段比如文件修改时间。如果代码里只是简单地找一个0x00就把文件名取出来那没问题但如果你按固定偏移去解析就容易出错。稳妥的做法是从数据区开头依次解析遇到0x00就认为字段结束再往下读一个字段就是文件大小。2.3 CRC16校验的计算细节Ymodem使用的CRC16是CCITT标准多项式是0x1021初值是0x0000。注意这里的初值是0x0000不是Modbus常用的0xFFFF也不是CRC32。如果你把Modbus的CRC代码搬过来校验绝对过不了。CRC16的计算逻辑不复杂但STM32的HAL库里没有现成的软件CRC函数硬件CRC模块一般也只支持CRC32所以要自己写。我习惯用查表法256项的表提前算好放到常量数组里运行速度非常快。如果不想查表逐位法也能用但接收1MB固件时要算几百上千次CRC逐位法会明显拖慢速度。这里有个容易忽略的坑Ymodem的CRC校验值是先高字节后低字节发送的接收方把收到的两个字节组合时要拼对顺序。我见过有人按低字节在前解析结果每次都报CRC错误排查了半天才发现是大小端搞反了。3. Bootloader代码实现基于HAL库从零搭建3.1 串口配置与数据接收策略Bootloader里串口的作用是接收Ymodem数据帧配置成中断接收模式就够用了。我的建议是直接使用HAL_UART_Receive_IT配合一个环形缓冲区来缓存收到的字节。虽然用DMA接收可以减少CPU开销但在Bootloader这个场景下中断接收的实时性更好代码也更简单。串口参数方面波特率建议固定为1152008个数据位、1个停止位、无校验。这个配置比较通用SecureCRT和各类串口助手都能直接匹配。要注意的是Bootloader启动后立刻初始化串口时如果上位机还没打开串口Bootloader会一直等在那里这时候最好加一个超时机制比如等待3秒没有收到任何数据就默认检查App区是否有程序有就直接跳到App没有就继续等待。Ymodem接收状态机是整个Bootloader的核心我把它拆成了五个状态等待帧头、等待包序号、等待数据体、等待CRC校验、等待帧结束。每收到一个字节就根据当前状态做相应的处理。状态机的好处是不会丢数据处理一个字节的时间极短即使串口中断连续进来也不怕。3.2 Ymodem接收协议栈完整实现Ymodem接收端的协议栈说白了就是“按照协议规定控制接收节奏”。我先说整体逻辑初始化时接收方先发送‘C’0x43表示准备好接收发送方收到‘C’之后开始发送文件信息包接收方解析出文件名和大小后回复ACK接着发送方一个包一个包地发数据接收方每收一个包校验CRC后回复ACK或NAK全部收完后就是前面说的EOT、ACK、EOT、NAK、空包、ACK流程。这里给出Ymodem接收端的核心伪代码框架typedef enum { YM_STATE_WAIT_SOH 0, YM_STATE_WAIT_SEQ, YM_STATE_WAIT_SEQ_COMPL, YM_STATE_WAIT_DATA, YM_STATE_WAIT_CRC_H, YM_STATE_WAIT_CRC_L } YmState_t; uint8_t Ym_RxStateMachine(uint8_t ch) { static YmState_t state YM_STATE_WAIT_SOH; static uint8_t seq 0, seqComp 0; static uint8_t data[1024]; static uint8_t dataLen 0; static uint8_t crcH 0, crcL 0; static uint16_t crcCalc 0; static uint8_t index 0; switch (state) { case YM_STATE_WAIT_SOH: if (ch SOH) { dataLen 128; state YM_STATE_WAIT_SEQ; } else if (ch STX) { dataLen 1024; state YM_STATE_WAIT_SEQ; } else if (ch EOT) { /* 处理EOT流程 */ } else if (ch CAN) { /* 连续两个CAN表示取消 */ } break; case YM_STATE_WAIT_SEQ: seq ch; state YM_STATE_WAIT_SEQ_COMPL; break; case YM_STATE_WAIT_SEQ_COMPL: seqComp ch; if ((seq ^ seqComp) ! 0xFF) { state YM_STATE_WAIT_SOH; return YM_RES_NAK; } index 0; crcCalc 0; state YM_STATE_WAIT_DATA; break; case YM_STATE_WAIT_DATA: data[index] ch; if (index dataLen) { state YM_STATE_WAIT_CRC_H; } break; case YM_STATE_WAIT_CRC_H: crcH ch; state YM_STATE_WAIT_CRC_L; break; case YM_STATE_WAIT_CRC_L: crcL ch; crcCalc CalcCrc16(data, dataLen); if ((crcH (crcCalc 8)) (crcL (crcCalc 0xFF))) { state YM_STATE_WAIT_SOH; return YM_RES_ACK; // 数据有效 } else { state YM_STATE_WAIT_SOH; return YM_RES_NAK; // CRC错误 } default: state YM_STATE_WAIT_SOH; break; } return YM_RES_NONE; }实际工程里我建议把Ymodem协议部分封装成一个独立的模块和Bootloader的业务逻辑解耦。Protocol层只负责“收到一个字节告诉你这包是否有效”上层负责把有效数据写入Flash。这样做的好处是后期如果你要支持Xmodem或者自研协议只需要替换协议层Flash写入、跳转代码完全不需改动。3.3 Flash擦除、写入与App跳转Flash操作是Bootloader里最需要小心的地方。STM32的Flash必须先擦除再写入擦除以扇区F1系列按扇区或页F4系列按页为单位写入最小单位是半字16位。HAL库提供了HAL_FLASHEx_Erase和HAL_FLASH_Program两个函数代码层面比较简单但有一些操作顺序和性能细节要注意。擦除App区时建议一次性把所有要用的扇区全部擦完再开始逐包写入。不要收一个包就把整个扇区擦一遍那样既慢又容易把Flash擦坏。一个折中的办法是第一包数据来的时候先判断当前包在哪个扇区然后擦除该扇区后续数据只要还在这个扇区范围内就只写不擦跨扇区时再擦下一个扇区。关于写入性能HAL_FLASH_Program一次只能写8字节或64位取决于芯片对大数据量的固件来说一包128字节数据要循环调16次HAL_FLASH_Program效率不高。我测试过直接用寄存器操作比HAL库快了不少而且代码也不复杂。不过在Bootloader里升级频率一般不高用HAL库也够用关键是把中断优先级和Flash操作时的全局中断状态处理好。跳转部分的核心思想是先关掉全局中断把主栈指针(MSP)设置为App的栈顶地址把程序计数器(PC)跳转到App的复位向量的值。对于Cortex-M3/M4内核还需要把中断向量表偏移量VTOR寄存器指向App的起始地址这样才能保证App的中断能正常响应。typedef void (*AppFunction)(void); void JumpToApp(uint32_t appAddr) { AppFunction jumpToApp; uint32_t appStack *(volatile uint32_t *)appAddr; uint32_t appReset *(volatile uint32_t *)(appAddr 4); __disable_irq(); // 先关全局中断 SCB-VTOR appAddr; // 重设中断向量表 jumpToApp (AppFunction)appReset; // 获取App复位函数地址 __set_MSP(appStack); // 设置App的栈顶指针 jumpToApp(); // 跳转不会返回 }跳转之前还有个容易忽略的动作把Bootloader初始化时打开的串口、定时器、DMA等外设全部关掉或者至少把中断全部清掉。否则跳到App之后外设中断可能还在触发但App的中断向量表已经变了很容易跑飞。3.4 App端的配合修改Bootloader做的再好App不做配合也无法运行。App工程需要修改两处一是把编译出来的固件起始地址改为App区地址二是在启动文件或初始化代码里把系统时钟重新配置一遍。地址修改在Keil里操作很简单Options for Target → Target页面把IROM1的Start改成0x08004000Size改成0x0001C000假设App区是112KB。注意这里改的是分散加载文件里的地址同时禁止生成Hex时要勾选“Create HEX File”生成bin文件则可以用用户命令“fromelf --bin -o app.bin”来完成。App初始化代码里还需要加上VTOR寄存器设置。因为Bootloader跳转前已经修改了VTOR但实际上App的启动文件在启动时会把VTOR重置为0x08000000如果App内部的中断用到固定地址就会再次指向Bootloader区域。稳妥的做法是在App的main函数最先执行的地方重新设置SCB-VTOR确保中断向量指向App区。4. 上位机配合与实操流程演示4.1 生成bin文件的三种方式做IAP升级烧录的文件格式一般用bin因为它没有附加的地址信息烧到哪就是哪非常直观。Hex文件虽然也能用但里面每个段都带着起始地址如果上位机解析不正确很容易把文件内容写到错误地址。生成bin文件的方法有三个Keil里通过User标签页的After Build命令调用fromelf工具IAR里通过“Output Converter”勾选Raw binary或者用Python等工具直接从hex转换。我个人建议直接在Keil的命令行里配置好每次编译完自动生成bin文件省心省力。4.2 SecureCRT配置与发送步骤SecurCRT是最常用的Ymodem发送工具。配置要点是新建一个串口连接选择正确的COM口号CH340或CP2102驱动安装好后会自动识别波特率115200数据位8停止位1校验None关闭流控尤其要关掉RTS/CTS否则串口通信会异常。整套升级流程是这样走的给板子通电Bootloader启动串口端输出提示字符串“等待升级…”打开SecureCRT串口连接如果能看到提示字符说明串口通路正常鼠标点击会话窗口选择“发送Ymodem文件”菜单选中刚才编译好的app.binSecureCRT自动开始传输进度条推进传输完成Bootloader校验通过后自动跳转到App此时App开始运行串口输出App的启动日志这里面有一个小细节SecureCRT发送Ymodem文件之前会先向串口发送字符‘C’。所以Bootloader上电后要主动发一个‘C’作为应答双方才能建立连接。如果时序没对上SecureCRT会一直卡在“等待接收方回复”的状态这时候点一下“传输”菜单里的“重新开始”就能重来。4.3 实测传输过程解读以我常测试的F103开发板为例编译出来的App固件大小大约是43KB用115200波特率传输整个升级过程不到30秒。传输过程中串口会看到一屏的ACK、NAK字符不要觉得奇怪这就是Ymodem协议在“握手”。SecureCRT的日志窗口会显示“Sending: app.bin”、“Ymodem transfer complete”之类的提示。如果传输失败日志窗口会显示“Retries: 1”之类的字样说明接收方回复了NAK发送方在重传。如果连续重传多次还失败基本可以断定是物理链路有问题优先检查USB转TTL线是否虚接、波特率是否一致。5. 常见问题与避坑指南从实践里总结的血泪经验5.1 升级失败后设备变成砖怎么办这是做IAP最担心的问题升级到一半断电或者传输出错App区被写坏了Bootloader跳过去之后程序跑飞整个设备彻底没反应。解决办法是做好“固件合法性检查”。常见做法有三种一是Bootloader在跳转前检查App区前4个字节是否是合法的栈顶地址通常应该是0x200xxxxx这样的RAM地址如果不是就认为App无效二是在App编译时预留一块区域存放固件CRC校验值每次上电Bootloader先算一遍CRC不对就不跳转三是设置“App有效标志位”每次完整收到固件并且校验通过后在一个独立Flash地址写一个标识符App启动成功后清掉这个标识符Bootloader启动时发现标志位不存在就说明App没启动成功。我在实际项目里是用第二种和第三种结合的方式。上电后先看标志位标志位有效再算CRCCRC也通过才跳转。这样即使升级中途断电Bootloader下一次启动时会发现标志位无效或者CRC不对继续停留在Bootloader状态等待重新升级设备永远不会变砖。5.2 串口DMA和中断不能正常收发的坑到现在还有人在问“STM32 HAL库串口DMA发送为什么只能发一次”这个问题在IAP场景里同样存在。HAL库的UART DMA发送默认是单次模式发完之后DMA通道状态变成禁用如果不重新调用HAL_UART_DMAStop再配置或者使用HAL_UART_Transmit_DMA时没有处理传输完成回调就会出现发第二次时没反应。我在Bootloader里直接用中断接收没有用DMA接收主要原因是Ymodem接收时每个字节的间隔不固定而DMA接收需要配合IDLE中断来确认一帧数据的结束处理起来复杂。中断接收加环形缓冲区这套方案已经非常成熟可靠在115200波特率下完全够用没必要增加复杂度。不过这里有个坑要提醒F1系列串口中断里如果处理时间过长下一个字节一到就会触发溢出错误(ORE)导致后续数据全部丢失。解决方案是在中断回调里只做“把字节放进缓冲区”的工作状态机解析放在主循环里跑。5.3 Ymodem帧序号回卷问题Ymodem的包序号只有8位从0x00开始到0xFF后再发就是0x00。如果固件大小超过一定阈值比如用128字节包传输超过32KB就会出现序号回卷。有些代码判断“当前包序号是否等于上次包序号加1”遇到回卷就会误判成异常包直接回复NAK导致传输永远失败。解决思路很简单不比较序号是否连续而是比较序号和上次序号是否相同。相同就认为是重发包直接回复ACK但不再写入Flash不同就认为是新包正常写入。这样无论是重发还是回卷都能正确处理。5.4 波特率与线路质量引起的隐性故障有些时候传输不是完全失败而是传了一会儿才失败。这种问题很多时候不在代码而在物理链路。USB转TTL线质量差、杜邦线过长、电源纹波大都会让串口数据出现偶发错误。Ymodem有CRC校验能在接收端发现这些错误但重传次数多了会影响传输效率严重时直接超时失败。我调试时喜欢吃几颗“定心丸”链路可靠性不确定时先用回环测试把USB转TTL的TX和RX短接用串口助手发一串数据看能不能原样收到再把波特率从115200降到57600甚至38400很多偶发问题会明显改善。量产产品建议直接用带隔离的串口方案或者上RS485抗干扰能力完全不一样。5.5 常见问题速查表现象可能原因排查方向SecureCRT一直显示等待接收Bootloader没有发送‘C’检查Bootloader初始化逻辑确认发送字符的时机第一包就NAKCRC计算错误或帧格式不正确对照协议核对帧头、序号取反、CRC字节序收到一部分后卡住数据包字节丢失检查波特率、接线质量、环形缓冲区是否溢出全部传完但跳转失败VTOR未重设或栈顶地址无效单步进入跳转函数检查MSP值和复位向量App能跑但中断不响应中断向量表未正确偏移确认App启动代码里SCB-VTOR设置是否执行复位后再次进入BootloaderApp标志位没有被清除检查固件合法性检查逻辑确认App启动后清除标志位6. 升级方案还能怎么扩展到这里一个基于HAL库和Ymodem协议的串口IAP方案就完整落地了。这个方案不光适用于STM32F1系列的板子F4、G0、L4系列基本可以照搬只需要针对不同型号调整Flash扇区大小和擦除方式。如果你后续把Ymodem接收端代码整理成模块再配合蓝牙透传模块或者WiFi模块甚至可以直接改成无线升级上位机只要把bin文件通过TCP/UDP发送过来Bootloader侧的逻辑是不变的。如果做批量产线升级还可以考虑把Ymodem发送端集成到一个自己写的上位机里通过工装夹具自动下载固件配合产品序列号记录升级日志这样生产效率和可追溯性都能提升一大截。不过这些都是后续的事先把串口IAP跑通后面的路就好走多了。