我前些年做一批带升级功能的设备,最开始只留了SWD口,出货之后才发现一个要命的问题:产品已经铺到客户现场,想要更新固件,要么派人带着ST-Link跑现场拆机,要么让客户把板子寄回来。后来改用串口IAP升级,配合Ymodem协议,一根USB转TTL线就能远程搞定,问题迎刃而解。这篇文章就把我基于STM32 HAL库做串口Ymodem升级的完整方案拆开讲清楚,从协议原理、Flash分区、代码实现到上位机操作,最后把踩过的一堆坑也一并整理出来,给正在做IAP升级的朋友当个参考。
1. 整体方案设计:IAP升级到底在解决什么问题
1.1 为什么不用SWD,非得上IAP
IAP的全称是In-Application Programming,中文叫在应用编程。它和ISP(In-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字节的“标准包”,包含:
- 起始字节SOH(0x01),表示本包数据长度为128字节
- 包序号(1字节),从0x00开始,每发一个包加1,到0xFF后回卷到0x00
- 包序号补码(1字节),也就是取反
- 数据区(128字节),如果数据不足128字节,用0x1A(Ctrl+Z)填充
- CRC16校验值(2字节),高字节在前,低字节在后
还有一种扩展包,起始字节是STX(0x02),数据区是1024字节。使用STX包可以明显减少传输次数,尤其当固件文件有几MB时,能省不少时间。协议规定,发送方会先发STX包,如果接收方不支持,回复NAK,发送方就会退化成SOH包。实际使用中我建议直接兼容两种包,反正代码里就是多一个分支的问题。
关于结束时序,这里是最容易搞混的地方:发送方传完最后一个数据包后,会发送一个EOT(0x04),接收方正确收到后回复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这个场景下,中断接收的实时性更好,代码也更简单。
串口参数方面,波特率建议固定为115200,8个数据位、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改成0x08004000,Size改成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.bin
- SecureCRT自动开始传输,进度条推进
- 传输完成,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没启动成功。
我在实际项目里是用第二种和第三种结合的方式。上电后先看标志位,标志位有效再算CRC,CRC也通过才跳转。这样即使升级中途断电,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初始化逻辑,确认发送字符的时机 |
| 第一包就NAK | CRC计算错误或帧格式不正确 | 对照协议核对帧头、序号取反、CRC字节序 |
| 收到一部分后卡住 | 数据包字节丢失 | 检查波特率、接线质量、环形缓冲区是否溢出 |
| 全部传完但跳转失败 | VTOR未重设或栈顶地址无效 | 单步进入跳转函数,检查MSP值和复位向量 |
| App能跑但中断不响应 | 中断向量表未正确偏移 | 确认App启动代码里SCB->VTOR设置是否执行 |
| 复位后再次进入Bootloader | App标志位没有被清除 | 检查固件合法性检查逻辑,确认App启动后清除标志位 |
6. 升级方案还能怎么扩展
到这里,一个基于HAL库和Ymodem协议的串口IAP方案就完整落地了。这个方案不光适用于STM32F1系列的板子,F4、G0、L4系列基本可以照搬,只需要针对不同型号调整Flash扇区大小和擦除方式。如果你后续把Ymodem接收端代码整理成模块,再配合蓝牙透传模块或者WiFi模块,甚至可以直接改成无线升级,上位机只要把bin文件通过TCP/UDP发送过来,Bootloader侧的逻辑是不变的。
如果做批量产线升级,还可以考虑把Ymodem发送端集成到一个自己写的上位机里,通过工装夹具自动下载固件,配合产品序列号记录升级日志,这样生产效率和可追溯性都能提升一大截。不过这些都是后续的事,先把串口IAP跑通,后面的路就好走多了。