不管你是做小仪表、电机驱动还是工控板,stm32f103的Bootloader IAP串口升级这套东西迟早都会遇到。很多项目才用F103这种小资源单片机,外壳一盖上、设备往现场一装,再想用ST-Link烧程序基本就是拆机的节奏。更现实的需求是:客户发来一个固件,要你远程或者现场串口升级,这时候Bootloader + IAP串口升级就是唯一可行方案。而串口协议这块,YMODEM协议几乎是最好的选择——它比XMODEM多了文件名和文件大小,支持1024大块传输,还有CRC校验,够稳也够快。这篇就是把我做这类IAP升级的完整经验整理出来:从分区规划、Bootloader写法、YMODEM状态机、跳转隐患到实测配置,一次讲透。适合正在做F103固件升级、或者手头有产品要加在线升级能力的人参考,哪怕你没接触过IAP,顺着思路也能把一个能用的方案搭起来。
1. 为什么F103的升级方案里一定要有Bootloader:从拆壳刷机说起
1.1 IAP的基本分工:Loader负责收,App负责跑
IAP的全称是In-Application Programming,翻译过来就是“在应用运行中编程”。这句话听起来有点绕,其实拆开看就很简单:在单片机的Flash里放两个部分——开头放一个独立的小程序,叫Bootloader,后面的地址空间放真正的应用,叫用户App。单片机一上电,先跑的是Bootloader,它检查一下“要不要升级”?如果要,就通过串口接收新固件,写成App;如果不要,就直接跳转到App的入口,让App接管。
这个结构本质上就像是电脑的BIOS和操作系统的关系。BIOS先起来,它决定是进系统还是进恢复模式刷机。单片机里的Bootloader就是精简版的BIOS。你可能会问:“我直接把程序烧在0x08000000不就好了,为什么要多跳这么一下?”关键就在这里:没有Bootloader,你只能靠烧录器烧,想从App自己给自己换血是不可能实现的。因为App运行的时候,程序计数器,也就是PC指针,一直指向App所在的Flash区域,App自己擦掉自己所在的Flash,这事儿在硬件上执行得太勉强,而且一旦中途错乱,设备直接变砖。
而Bootloader独立于App,它只负责“收包、写Flash、跳转”这三件事。哪怕App区已经写得乱七八糟,Bootloader依旧在正常跑,你依旧有办法重新拉一个固件进去。这是IAP方案最核心的价值——它能给产品一条“后路”。
1.2 ISP、SWD与IAP三种升级方式的取舍
很多刚开始接触固件升级的人会混淆ISP和IAP。ISP是系统内编程,靠的是STM32出厂时固化的ROM引导程序,把BOOT0引脚拉高,复位后芯片进入ISP模式,然后用串口配合上位机软件把程序烧进去。IAP则是我们自己写一个引导程序,主动权完全掌握在开发者手里,不需要依赖芯片出厂固件,也不需要拨BOOT0引脚。
SWD或JTAG则是通过调试器直接烧写,好用是好用,但必须有实体调试接口,而且设备一旦封装、安装到现场,这路基本就断掉了。
三种方式的对比,我用一个表列出来:
| 升级方式 | 是否需要拆外壳 | 能否远程/现场升级 | 能否定制流程 | 适合场景 |
|---|---|---|---|---|
| SWD/JTAG调试器 | 需要 | 不能 | 不能 | 开发调试、量产烧录 |
| ISP串口 | 需要,BOOT0得拉高 | 很难 | 不能 | 第一次烧+少量应急 |
| Bootloader+IAP | 不需要 | 能 | 完全可控 | 产品量产后的固件维护 |
我个人的建议是:开发阶段用SWD,量产烧录阶段可以让产线用SWD或者ISP都行,但产品一旦装好外壳、有了实际用户,就必须给Bootloader让路。这也是很多成熟产品出厂前就把Bootloader固化进去的原因——宁可前期多点一点功夫,也别后期让工程师提着螺丝刀去现场拆壳子。
1.3 Flash分区怎么划:以F103C8的64KB为例
Flash规划是一切IAP方案的地基,地基没打对,后面全是坑。
STM32F103的主Flash从0x08000000开始。我用的比较多的F103C8T6,Flash总容量是64KB,也就是地址范围到0x0800FFFF。常见的分法是把Bootloader放在最前面的16KB,也就是0x08000000到0x08003FFF,App从0x08004000开始,占剩下的48KB。
为什么Bootloader要放在最前面?因为MCU上电后默认从Flash起始地址取向量表、取复位入口。向量表必须放在0x08000000,而Bootloader的“重置向量”就在这个位置,所以Bootloader只能放最前。
那Bootloader给多大合适?一个功能完整的串口YMODEM升级Bootloader,包含串口驱动、Flash驱动、YMODEM协议解析、必要的打印信息,用标准外设库编译下来通常在6KB~10KB左右。留16KB已经算很宽裕,再大就挤占App空间了。
如果是更大容量的F103,比如256KB的F103RCT6或者512KB的F103ZET6,分区就更从容了。可以划32KB给Bootloader,App直接用剩下来的。更激进的方案还可以划一个中间“暂存区”出来:先下载到暂存区、校验通过后再搬到App区,这样升级失败还能继续跑旧App。F103C8这种64KB容量的芯片就玩不了双区,因为地方太小。这种情况下,重点应该放在把YMODEM协议和强制的升级入口做扎实,让Bootloader“永远有入口、随时能重刷”,这也算一种务实的最小化容灾设计。
对了,Flash是按页管理的,F103的页大小是1KB,也就是一次擦除最少擦1KB。分区的时候尽量让App起始地址对齐到1KB,后面擦写会顺很多。0x08004000这个地址天然就是1KB对齐的,不用额外操心。
2. Bootloader里的核心动作:串口收发、YMODEM帧解析与Flash写入
2.1 串口初始化和“要不要升级”的判定
Bootloader的代码量不大,但结构要比App更严密。一般我写Bootloader会分这么几个文件:系统时钟初始化、串口驱动、Flash驱动、YMODEM协议解析、主流程。串口用的最多的就是USART1,因为它的引脚PA9、PA10好外接,很多USB转TTL模块默认也是这个脚位。
波特率选多少?我一般默认115200、8位数据位、1位停止位、无校验。这个组合兼容性最好,也是上位机软件最常见的默认配置。如果你以后要用自制脚本批量升级,115200是底线,大多数USB转串口的模块在115200下都很稳定。波特率再往上,比如给F103上256000甚至460800,代码本身没问题,但USB串口模块的质量、连接线长短都会影响稳定性,不是特别必要就别追求太高。
串口初始化本身没什么稀奇的,关键是“升级判定”这部分——怎么知道上电后是升级还是直接跳App?
我首推的方案是“按键优先 + 标志位配合”。Bootloader上电后先检查一个按键,比如PA0接地按键按住了,就无条件进入升级模式。然后检查备份寄存器里有没有App写入的升级标志位,如果有,也进入升级模式。两个条件都没有,就去检查App区是否已有合法程序,有就跳转,没有就停留在Bootloader等待串口升级。
这种设计的好处是:在生产第一次烧录时,Flash里没有App,Bootloader不会跳出,只会等升级;产品运行中,App需要升级时,可以自己写一个升级标志再软复位,然后自动进入Bootloader升级;万一App写崩了、标志位也没来得及写,现场工程师按一下按键强制升级,照样能救回来。三层路口全留好了,基本不会把自己困死。
2.2 YMODEM帧结构拆开来看
YMODEM不是STM32的什么协议,它是一个通用的串口文件传输协议,脱胎于XMODEM。XMODEM因为只能传128字节一帧,速度慢,也没有文件名信息,所以YMODEM做了改进:支持1024字节数据帧,加上一个文件信息首帧。
作为接收端,你首先要知道YMODEM发送方会发来这么几类字节:
| 字节 | 名称 | 含义 |
|---|---|---|
| 0x01 | SOH | 头标志之一,后面跟128字节数据块 |
| 0x02 | STX | 头标志之二,后面跟1024字节数据块 |
| 0x04 | EOT | 发送方说“文件发完了” |
| 0x06 | ACK | 接收方回复“这帧没问题” |
| 0x15 | NAK | 接收方回复“这帧坏了,重发” |
| 0x18 | CAN | 取消传输,双方都不要继续了 |
| 0x43 | 'C' | 接收方就绪信号,支持CRC校验 |
一帧数据的完整结构是:头字节 + 序号 + 序号反码 + 数据 + 两个CRC字节。比如一帧普通的数据帧,用STX开头表示这帧带1024字节:0x02、序号、0xFF减序号、1024字节数据、CRC高字节、CRC低字节。序号的长度只有一个字节,从0开始按帧递增,到255再回绕。
首帧特殊一点,它一定是用SOH发送的128字节帧,数据内容是ASCII字符串,格式类似:
firmware.bin 32768也就是“文件名 文件大小”,中间用空格隔开,文件大小是十进制数字。接收端可以从首帧里解析出文件大小,用来判断固件是否放得下、以及可以给用户显示进度条。
CRC校验这部分,YMODEM用的是CRC-16/XMODEM算法,多项式是0x1021,初始值为0x0000,计算得到的两个字节在发送时低字节在前。这个算法在各个编程语言里都有现成实现,但如果你写Bootloader时打算自己实现一遍,要注意一点:入参是“数据域”,不包含头和序号,校验的范围就是纯数据。
2.3 接收状态机怎么设计,缓冲给多少
YMODEM的接收端在嵌入式上最容易写成一团乱麻,因为数据不是一个包一个包地整齐到来,而是字节不断地往串口里塞。如果把“接收数据”“校验”“写Flash”“回ACK”全堆在一个地方,逻辑会乱到没法维护。
正确做法是搭一个简单的状态机,状态切换就是靠收到的字节引导。
我的做法是这样的:定义一个枚举表示当前处于哪个解析阶段,从“等待帧头”开始,逐个字节接收。收到SOH或STX之后,记录是128字节模式还是1024字节模式,接着收序号、序号反码,再收数据区,最后收两个CRC字节。所有字节都收完后,统一做一次序号校验和CRC校验,校验通过就交给Flash写入流程,写完以后回ACK;校验失败就回NAK,并让状态机复位,继续等待下一帧。
这个状态机的代码量不大,核心就几十行。伪代码大致长这样:
static YmodemRxStatus_t RxYmodemByte(uint8_t ch) { static uint8_t frame_buffer[1024]; static uint32_t received = 0; switch (rx_state) { case WAIT_HEADER: if (ch == SOH) { current_len = 128; rx_state = WAIT_BLOCK_NO; } else if (ch == STX) { current_len = 1024; rx_state = WAIT_BLOCK_NO; } else if (ch == EOT) { return YMODEM_EOT; } else if (ch == CAN) { return YMODEM_CANCEL; } break; case WAIT_BLOCK_NO: file_block = ch; rx_state = WAIT_BLOCK_NO_COMP; break; case WAIT_BLOCK_NO_COMP: if ((uint8_t)(file_block + ch) != 0xFF) { rx_state = WAIT_HEADER; return YMODEM_ERROR; } received = 0; rx_state = WAIT_DATA; break; case WAIT_DATA: frame_buffer[received++] = ch; if (received >= current_len) { rx_state = WAIT_CRC_HI; } break; case WAIT_CRC_HI: crc_hi = ch; rx_state = WAIT_CRC_LO; break; case WAIT_CRC_LO: crc_lo = ch; if (CheckCRC16(frame_buffer, current_len, crc_hi, crc_lo)) { rx_state = WAIT_HEADER; return YMODEM_OK; } else { rx_state = WAIT_HEADER; return YMODEM_ERROR; } } } }缓冲区给多大?一帧最大1024字节,那RAM就准备一个1024字节的数组,顶多加几个操作标志。F103C8的20KB RAM完全没问题,不用像伺候小资源单片机一样抠到用几百字节。
这套状态机虽然没有用到DMA和空闲中断,但在115200波特率下,一个字节间隔接近87微秒,单片机的处理完全来得及。逐字节中断收的好处是代码直观、调试容易,尤其第一次跑协议的时候,任何一个中间步骤有问题,串口打印一加就能定位。
2.4 Flash擦写:按页擦除、半字编程、写完要读回
STM32的Flash写操作有严格的约束:必须先擦除再写、每次写入最小单位是16位半字、对F103来说擦除的最小单位是1KB一页。这些语句每一个都对应一个实际的坑。
Bootloader里写Flash,用的标准外设库是这么几个函数,顺序也很固定:上行解锁、擦除对应的页、按半字编程、再次上锁。
先解锁:
FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR);擦除App对应的页时,按1KB一页去算:
for (addr = APP_START_ADDR; addr < APP_START_ADDR + app_size; addr += 1024) { FLASH_ErasePage(addr); }写数据的时候,把接收缓冲区里的每两个字节组合成一个半字,调用FLASH_ProgramHalfWord:
uint16_t half_word = (uint16_t)(buf[i] | (buf[i + 1] << 8)); FLASH_ProgramHalfWord(flash_addr, half_word);有一件很容易被忽略的事:写完之后要立刻读回,并和源数据比较,确认写入成功才回ACK。这虽然不是YMODEM规范要求的,但Flash写入偶发失败是存在的,尤其芯片在恶劣供电环境下或者擦写次数已经很大的时候。写完不验证,等App跑起来随机崩了,那排查起来比多写几行校验代码痛苦一百倍。
3. 把YMODEM跑在F103上最容易掉的三个坑
3.1 起始'C'发多久、谁来先开口
YMODEM和XMODEM的握手有个方向问题容易绕晕。串口升级时,谁是发送方、谁是接收方?电脑加终端软件是发送方,单片机Bootloader是接收方。YMODEM的规矩是接收方先发'C'表示“我用CRC校验模式,你准备好就可以发数据了”。发送方收到'C'之后,才会开始发第一个包含文件信息的SOH首帧。
我一开始做的时候犯过迷糊,写完Bootloader,以为先在电脑端点“发送文件”就会自动传。结果不是的,如果单片机不加主动发送'C'的逻辑,上位机的YMODEM发送会一直处于等待状态,像是在问“喂,你倒是发个信号啊”。两边都干瞪眼。
正确做法就是Bootloader进入升级模式后,周期性地向串口发送一个'C'字符,比如每500毫秒发一次。上位机的YMODEM发送一旦收到这个'C',就会开始传输首帧。整个接受方状态机在“发C等待首帧”这个阶段循环,收到非首帧的乱码数据就忽略,收到首帧就进入正常接收流程。
等待时间设多长也是个实用问题。我建议不要在Bootloader里无限等待,否则一台没有升级需求的设备每次开机都卡在Bootloader等人来刷系统,白白耽误启动时间。设定一个超时,比如5到10秒:超时了如果还没有进入升级流程,就自动跳转App。这样既给上位机留够了操作时间,又不影响产品正常启动速度。
3.2 包序号、1024字节块和最后那个128字节短包
YMODEM的数据帧序号是从1开始编号的,首帧的文件信息包序号是0。如果你在代码里做Flash地址映射时直接用帧序号减1再乘1024,大方向是对的,但有两个特殊情况必须处理。
第一,不是所有数据帧都是1024字节。发送方在文件的最后,如果剩余数据不足1024字节,就会用SOH头来发一个128字节的短包。也就是说,你判断“当前帧数据长度到底是多少字节”,不能靠假设,必须要看包头是SOH还是STX。收到STX就当1024字节处理,收到SOH就当128字节处理,然后Flash写入长度也要跟着变,不然会把最后不足1024字节的数据写到错误的位置,或者把一堆垃圾也写进去。
第二,帧序号是8位,传大文件时序号超过255会回绕。一个固件往往也好几百KB,到了后面序号归零再递增,如果代码里判断序号递增用的是“必须比上一帧大一”这种逻辑,回绕之后就永远接收不到了。正确写法是判断“当前序号减去上一帧序号,结果对256取模等于1”,或者干脆不依赖序号连续性,直接按收到的数据帧数累加计算地址。这样既能容忍回绕,也更简单。
3.3 EOT之后的空帧别漏了
文件数据全部传完之后,YMODEM还有一段收尾:发送方会发送一个EOT字节,表示数据发完了。接收方按协议要求,第一反应是回一个NAK,表示“你再来一次,我想确认你别突然掉线”;发送方收到NAK后,会再发一次EOT;这一次接收方回ACK,表示“确实结束了,我收到了”。
紧接着还有一个小尾巴,发送方会再发一个SOH空帧,块号为0,数据区全为0。这个空帧的意思是“整个会话结束”。接收方要回一个ACK,然后升级流程才算真正闭环。
很多文章讲YMODEM只讲到EOT就完了,空帧这部分不提,但真写出来跑了之后就会碰到:EOT收尾都处理完了,结果上位机还报“transfer error”,或者发送方一直在等最后那个ACK。原因很简单——空帧没回ACK。所以在状态机的设计里,收完EOT、回到等待帧头时,要专门加一个判断:如果收到序号为0的SOH帧,就把它当作会话结束标志来回ACK。
3.4 为什么我推荐“收完一帧再擦写”,而不是边收边写
Flash擦写是一个比较重的操作,一次擦除1KB的页,根据芯片体质大概要20到40毫秒,写入1024字节要执行512次半字写,中间处理器虽然还能响应中断,但总线的访问冲突会让代码执行变得很慢。如果一边收串口一边擦写Flash,没有DMA的话,串口中断极容易在这段时间丢字节。
那有没有更稳妥的处理方式?有,就是“帧内串行”:整个1032或133字节的一帧完整收完后,校验也通过了,再去做擦除和写入。这时候下一帧数据还没到,或者还在上位机的发送缓冲里排队,Flash操作有时间窗口可以安静地完成,写完再回ACK。这样MCU处理压力最小,也最容易保证每个包都完整。
有人会用DMA加环形缓冲区做到边收边写,性能确实好,但工程复杂度会高一截。对F103这种芯片,又不是传几百MB的文件,一次几十KB到几百KB固件,用串行帧处理加115200波特率,完全够用。升级体验差不了多少,代码却好调试得多。我很推荐作为第一次做IAP的默认选择。
4. APP端要改的不只是地址:向量表重映射、Keil配置与bin提取
4.1 Keil工程里IROM1的位置和大小
Bootloader那边接线、协议、分区都打通了,下一步就是让App“从新家”正常跑起来。这些年我见过太多次“Bootloader下载没问题,跳过去就是黑屏”的情况,一问十有八九是App工程的启动地址没改。
在Keil MDK里,选择Project、Options for Target、Target标签页,有一个IROM1配置项。默认情况下Start是0x08000000,Size是0x40000或者根据芯片自动给的。如果Bootloader占了前面的16KB,你就要把App的IROM1改成Start=0x08004000,Size=0x8000000-0x4000=0xFFC000?不对,我就是不想用浮点。App的Size应该是芯片总Flash大小减去Bootloader占的偏移。比如F103C8,总Flash是0x10000,Bootloader占用前0x4000,那App的IROM1 Size就写0xC000,也就是48KB。如果是F103ZET6,总Flash是0x80000,Bootloader占0x8000,那App的Size就写0x78000。
注意,这个Size不改错地方很容易出错:只改Start,Size不改,Keil会提示地址溢出;只把Size改小了,一旦固件超过范围,链接直接报错,起码能发现。
IRAM的设置一般不用动。有人会把IRAM的起始地址也偏移一下,说是什么向量表需要放到RAM里,其实F103用SCB->VTOR方案之后,根本不需要把向量表复制到RAM,IRAM老老实实从0x20000000、16KB或按芯片RAM大小填就行。
4.2 向量表重映射写在哪一行
App如果从0x08004000运行,而向量表仍然指向0x08000000,那么只要发生任何中断,MCU就会从Flash最前面取中断处理函数地址。那里是Bootloader的向量表,不是App的。结果就是:App里点个串口、开个定时器,一触发中断就直接跑飞或者进HardFault。
Cortex-M3内核允许通过SCB->VTOR这个寄存器设置向量表偏移。F103这边,只需要在App的main函数最开头,任何外设初始化之前,执行一行:
SCB->VTOR = APP_START_ADDR;这里的APP_START_ADDR要跟你Keil里设置的IROM1 Start保持一致,也就是0x08004000。
有的老工程会改system_stm32f10x.c里的VECT_TAB_OFFSET宏,把0x0改成0x8000。这个宏最终也会被用来自动设置VTOR。两种方法都可以,但我更推荐直接在自己的main第一行写死地址,理由很简单:代码一眼能看见,别人接手的时候不用去翻库文件里某个宏到底配成多少。
还有个细节:VTOR设置完之后,后面所有中断的入口都从App的向量表取了,包括SysTick、串口、定时器等等。所以这行代码必须在“任何可能开中断的代码”之前执行。如果放在初始化串口之后,一开串口中断就崩了,调试起来会怀疑人生。
4.3 从App跳回Bootloader:软复位加备份寄存器
常见的升级场景是产品已经烧好了App,某天需要升级。这时候App可以通过界面、串口命令或按键触发,自己进入“准备升级”的状态。可是App并不能直接调用Bootloader里的函数,两者是两个独立的程序,同时App也不能直接跳转到Bootloader的中断向量去执行,因为你不知道Bootloader内部的状态,何况两个程序本来就该解耦。
工业上最稳妥的做法是:App先在一个掉电不丢失的存储区域写一个升级标志,然后执行软复位。芯片复位后Bootloader先跑起来,它发现升级标志,就留在Bootloader里等待串口升级。升级完成后,Bootloader清掉标志,再跳转App。
升级标志放哪?F103上我推荐放在RTC的备份寄存器里,使用BKP_DR1这样的寄存器。它由VBAT供电维持,芯片软复位不会丢,断电也不丢。App这边写入标志的代码很简单:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); PWR_BackupAccessCmd(ENABLE); BKP_WriteBackupRegister(BKP_DR1, 0xA5A5); NVIC_SystemReset();Bootloader那边就是上电读,判断相等就进入升级模式:
if (BKP_ReadBackupRegister(BKP_DR1) == 0xA5A5) { // 清除标志,进入升级模式 BKP_WriteBackupRegister(BKP_DR1, 0x0000); }强调一点:万一设备的VBAT电池没接,备份寄存器的内容是不可靠的。所以不能只依赖这个标志,外部按键强制升级入口一定得留一个,双保险才是可靠设计。
4.4 输出bin文件:fromelf参数和一个小陷阱
Keil编译默认产出的是hex文件,但YMODEM升级建议用bin文件。bin是纯二进制数据,没有地址信息,大小和固件实际体积一致,传输时简单;hex是文本格式带记录和地址,虽然也能传,但解析起来没必要地麻烦。
让Keil在编译后自动生成bin,需要在Options for Target的User标签页、After Build/Rebuild那一栏,填一行fromelf命令。先勾选“Run User Program #1”,然后内容按你自己的工程名改:
fromelf.exe --bin --output=..\Output\firmware.bin ..\Obj\project.axffromelf是Keil ARM编译器自带的工具。重要提醒:命令行里的路径和文件名要跟你工程实际的输出axf位置对照好,很多人的bin文件一直没生成,就是路径写错了。另外,不要加引号,不要写绝对路径,用相对当前工程文件所在目录的相对路径最不容易踩坑。
配合上固件大小校验,生成bin之后顺手看一眼大小,比如编译器里显示的Program Size,再和自己预期的固件体积对比一下。如果bin文件的体积比预料的大了不少,多半是开了某些调试信息或者数组开太大了,Flash容易爆掉。
5. 跳转那一下,机器重启瞬间最容易翻车
5.1 跳转前把“外部世界”收拾干净
Bootloader收完固件、写好Flash,最后一步就是跳转到App。很多人觉得这一步不就是“把PC指针指过去”吗?其实不是,这中间隐藏着一个非常容易翻车的环节——外设状态。
Bootloader跑的过程中,时钟、串口、SysTick、可能还有GPIO和看门狗都已经初始化了。你直接跳转到App,App的main函数从头初始化,通常是不介意重新配一遍的。但问题是:中断可能正在挂着,比如Bootloader的串口接收中断可能刚刚触发又还没来得及响应;SysTick的计数器还在运行;DMA可能还有未完成的传输。这些残留状态到了App里就是定时炸弹。
我跳转前一定会做这几件事,按顺序执行:
__disable_irq(); // 关闭SysTick SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 清掉所有中断的pending状态,只清保留寄存器即可 for (uint32_t i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } // 恢复系统时钟到默认状态 RCC_DeInit();__disable_irq()那一步,是关掉全局中断。这个关中断的动作至少保证在跳转切换的这几条指令期间,不会有任何中断进来捣乱。跳进App的main之后,App自己会重新初始化RCC、外设和NVIC,再自己开中断,链路是干净的。
看门狗这块要单独说:如果App之前启用了独立看门狗IWDG,复位之后IWDG仍然在跑。Bootloader如果停留了几秒才升级完,不及时喂狗就会在升级途中被看门狗复位。稳妥办法是Bootloader里开一个定时器周期喂狗,或者在升级状态下重新初始化IWDG把超时时间拉长;更狠一点的做法是整个Bootloader升级期间不碰IWDG,IWDG的窗口期要长于Bootloader整个最长停留时间。这个值要根据你自己产品的需求算好,别拿着默认的几百毫秒就去升级。
5.2 合法的栈顶和应用入口,怎么判断
Bootloader要跳转之前,Flash里新烧的App是不是合理的程序,不能“蒙着眼睛”直接跳。一个合法的Cortex-M程序,它的Flash最开始4字节必须是启动时用的主栈指针,紧接着4字节是复位中断处理函数的地址。Bootloader至少应该检查这两种地址看起来是不是合理的。
栈顶指针有非常明显的特征:它必须落在SRAM地址范围内。F103的SRAM一般在0x20000000开头,C8有20KB,ZE有64KB。检查时不能只看大于等于0x20000000,还要看它有没有超出SRAM上边界,不然一个非法栈顶会让代码第一时间陷入更深的位置。
复位函数地址也应该落在Flash范围内,也就是App区段内部。简单校验逻辑大概这样:
#define APP_START_ADDR 0x08004000 uint32_t stack_addr = *(volatile uint32_t *)APP_START_ADDR; uint32_t reset_addr = *(volatile uint32_t *)(APP_START_ADDR + 4); if ((stack_addr & 0x2FFE0000) == 0x20000000 && // 栈顶在SRAM内 (reset_addr & 0x2FF80000) == 0x08000000) // 入口在Flash内 { // 跳转 } else { // 留在Bootloader,打印错误 }校验不通过就停在Bootloader,等待重新升级,而不是硬着头皮跳过去。这一个判断能避免大量“升级后设备啥反应没有”的莫名其妙问题。
跳转本身也就是改一下栈指针,然后调用函数指针:
typedef void (*pFunction)(void); void JumpToApp(void) { uint32_t jump_addr = *(volatile uint32_t *)(APP_START_ADDR + 4); pFunction jump_to_app = (pFunction)jump_addr; __set_MSP(*(volatile uint32_t *)APP_START_ADDR); jump_to_app(); }注意是__set_MSP设置主栈指针,不是__set_PSP。中断服务函数、main函数用的都是MSP,特权模式下默认用的也是MSP。很多人写跳转的时候这里一马虎,栈指针指错了地方,App一跑就崩。
5.3 几个常见失败现象和排查对照
把我在实际调试中碰到过的失败现象和对应原因列成一张表,方便你排查。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 升级完成后无反应,芯片像死了一样 | 栈顶指针校验没通过,停留在Bootloader了 | 检查烧进去的bin文件首4字节是否符合SRAM地址 |
| 一上电LED亮几下就重启 | 跳转后App里开了中断,但向量表没重映射 | 检查SCB->VTOR是否设置,位置是否在App_main开头 |
| 升级过程超时卡住 | 收完一帧后长时间擦写Flash,串口丢失下一帧头 | 把“帧内串行”逻辑调好,让擦写在空闲窗口完成;或加上超时重发机制 |
| 能升级、能跳转,但串口打印乱码 | 波特率不符或时钟配置被打乱 | 检查Bootloader跳转前的RCC_DeInit,以及App自己的SystemInit是否正常 |
| 升级完成后仍运行旧App | 跳转条件没判定到,直接跳回旧入口 | 检查App起始地址处内容是不是新固件,Keil IROM1设置和bin烧写地址是否一致 |
| 升级时设备反复复位 | 看门狗没喂或超时时间不够 | 检查IWDG配置,在Bootloader升级循环里增加喂狗 |
排查工具上,Keil的调试器其实可以看得很细:先在Bootloader里打上断点,看升级完成后Flash对应地址上的数据是不是新固件;再看跳转前栈顶和复位入口两个变量的值。这种方法比瞎猜快得多,如果跳转目标本身错了,断点一看就暴露了。
5.4 升级中途断电能不能救:可靠性的最低配置
“升级到一半拔了电”是嵌入式升级绕不开的话题。IAP方案的薄弱点就是Fly升级阶段App区可能写到一半,另一头旧App已经被擦掉,新App还没写完,设备处于既没程序又没人理的阶段。
F103C8这种芯片没地方做双Bank备份,现实可行的容灾思路是:Bootloader要保证在任何情况下都能自己重新出现在升级模式。这意味着三点。
第一,Bootloader所在的16KB空间不能被App任何操作擦掉。App里千万不要对0x08000000到0x08003FFFF范围做Flash擦除和写操作,这个红线要在App的Flash驱动函数里加Address范围判断,从代码层面就杜绝误擦。
第二,保留强制进入升级模式的物理入口。那个升级按键不能省,即使有备份寄存器标志位,也得留一手。真正的现场救援,最后基本靠这个按键:按住、上电、Bootloader停在升级模式,重新传一个稳定固件。
第三,升级标志要设计成“写完新固件并且校验通过后才清除”。如果Bootloader能区分“上电时App区域有效”和“升级失败App区域无效”这两种状态,就可以做到:升级中途断电后再次上电,仍然停留Bootloader并提示“App无效,请重新升级”。这比摔在那里强制跳转要安全得多。
对于Flash容量更大的F103型号,如果产品对可靠性要求很高,那就上双Bank。Bootloader把新固件写到备用区,全部校验完成后再一次性切换启动地址,旧App一直留到最后一刻,新固件启动验证成功后才舍得淘汰旧版。代价是Flash至少要多留出整个App体积的空间,看具体产品是否划算。
6. 实测记录:从第一次跑通到连续升级不翻车
6.1 需要准备的东西和第一次烧录路径
硬件上的准备是这个方案里最省心的一部分。用F103C8T6的常用开发板,带一个USB转TTL模块,几根杜邦线,再加一个ST-Link。第一次把Bootloader烧进Flash,我建议用SWD,之后升级全部走串口。
接线很简单,USB转TTL的TX接板子的PA10,也就是USART1的RX,RX接PA9,GND必须共地。这里有个很低级但很容易犯的错误:USB转TTL模块和板子之间如果没共地,串口电平参考点不一致,收据会随机错乱到怀疑人生。
第一次烧录路径是这样的:
- ST-Link接到板子SWD口,Keil里设置好Debug、Flash Download,把编译好的Bootloader hex下载到0x08000000。
- 打开串口终端,波特率115200。复位板子,看到Bootloader打印提示、开始周期发'C'。
- 打开终端软件的YMODEM发送功能,选择App的bin文件,发送。
- 升级完成后板子自动跳转,串口打印App的启动信息,流程跑通。
之后每次升级就不需要任何调试器了,串口走YMODEM直接搞定。这也是整套IAP系统的意义所在。
6.2 用终端软件发送YMODEM的操作流
常见的终端软件里,Tera Term这类工具就带YMODEM收发功能,上手门槛很低。启动Tera Term连接串口后,确认波特率和Bootloader一致。然后复位开发板,等Bootloader进入升级模式,看到终端窗口里出现周期性的C字符。
然后在菜单里选择File、Transfer、YMODEM、Send,弹出文件选择框,选中编译生成的bin文件,点OK。软件会自动回应Bootloader的'C',开始发送。屏幕上会看到块号在跳动,传完自动关闭窗口。这一步就完成了。
有两点经验:第一,不要在Bootloader还没打印出'C'的时候就点Send,那样上位机发送模块会等一个接收端信号,等半天发现没有就报错;第二,传输过程中不要碰USB线,尤其不要拔串口线或者重新插拔USB转串口模块,不然协议直接断掉,又得从头再来。
对SecureCRT也可以这么玩,只是菜单从File、Transfer里进,底层走的还是同一个YMODEM协议。关键是接收端也就是Bootloader要主动发'C',这是整个流程能跑通的前提。
6.3 给自己写一个Python升级工具的思路
终端软件好用归好用,但在产线或者批量升级场景下,几十台设备一台一台去鼠标点发送显然不现实。这时候用一个简单脚本去驱动整个升级流程,效率会远超手动操作。
Python方案,最简单的就是pyserial加一个YMODEM协议发送函数。核心是模拟发送方的那几步:等'C'、发文件首帧、按1024发数据帧、处理EOT。有一个简单的思路可以复用,就是只发、不折腾接收端回包判断,难点在于要在每个环节等ACK/NAK。
一个能用但不过分复杂的脚本框架大概长这样:
import serial from xmodem import YMODEM ser = serial.Serial('COM3', 115200, timeout=1) def getc(size, timeout=1): return ser.read(size) or None def putc(data, timeout=1): return ser.write(data) ymodem = YMODEM(getc, putc) with open('firmware.bin', 'rb') as f: ymodem.send(f)实际用的时候要加不少校验和重试逻辑:比如先检查串口是不是正常打开了,发送前复位设备、等待Bootloader发出'C',发送过程中给每个块加个超时重发,最后要确认整包都发完。完整脚本大概一百多行。Python的好处是这种脚本可以很方便地跟产线系统集成,局域网设备、或者加个界面控制都容易扩展。
6.4 实测耗时和稳定性参考
我用一个约48KB的App固件做过完整升级测试,YMODEM每帧1024字节,加上首帧和收尾,整个流程跑下来大概是5到7秒。这个时间包含文件传输、Flash擦写、ACK回包以及上位机软件自身的处理停顿。几次实测里,115200波特率的纯传输时间占大头,约4.5秒,剩下的1到2秒是擦写Flash和协议交互带来的开销。
如果固件体积上到256KB,115200波特率下就大概要半分钟到四十秒。这个时间对现场升级来说还可以接受,但如果客户要求“快一点”,有两个立竿见影的办法:一是把串口波特率拉到256000甚至更高,传输时间直接减半;二是在Bootloader和上位机都做1024字节块,不要退回128字节模式。
稳定性这块,在排除了硬件电平不稳、供电噪声这些硬伤之后,YMODEM配合Flash读回校验的设计,连续升级二三十次没见过一次失败。偶发的失败往往集中在USB转串口模块劣质、接收缓冲区溢出、或者中途拔线这些外部因素上。真正跑起来之后,意料之外的问题几乎都出在上位机软件对YMODEM协议末端空帧的处理差异,或者Bootloader在跳转前没有处理好外设残留状态这两类。把这两个地方盯住了,整套系统就非常皮实了。
最后分享一个实际工作中让我省了不少事的习惯:拿一个最小系统板,先把Bootloader烧进去,然后用一根串口线连着,从头到尾把“升级-断电-再升级”这个循环在开发桌上反复操练个十几次,再去动真设备。这种类型的软件最怕的从来不是正常流程,而是那些“升级一半停电了”“串口线松了”“客户多按了一下复位键”的意外路径。把这些意外路径都提前模拟过一遍,心里才踏实,设备发到现场才真的敢说“这升级不会把人坑了”。