1. 为什么IAP BootLoader要选单分区,而不是网上流传的双分区方案
很多刚接触STM32F103的朋友,一搜IAP相关资料,看到的方案几乎都是把Flash分成两个区。一个放BootLoader,一个放用户App,上电先跑BootLoader,BootLoader检测到需要更新就把App写到另一个分区,更新完跳过去执行。这个思路本身没错,但放到F103这种小容量芯片上,问题就来了。
F103C8T6的Flash只有64KB,F103RCT6是256KB,F103ZET6是512KB。双分区方案意味着用户App的实际可用空间直接砍半。对一个稍微复杂点的产品来说,App体积动不动就七八十KB,双分区根本塞不下。我一开始做项目时也迷信双分区,后来发现产品加了一个图形界面库之后,App编译出来直接逼近100KB,只能老老实实重新设计。
实际项目里更常用的方案是单分区BootLoader加单分区App,也就是标题里的方案:BootLoader固定占用Flash开头的一段区域,App抢占剩余全部空间。这样做有三个明显好处:
- Flash利用率最大化,App能用的空间几乎等于芯片总Flash减去BootLoader大小。
- 更新流程简单直接,不需要处理“哪个分区是当前运行区”的切换逻辑。
- 出问题好排查,代码量少,BootLoader本身就是几KB的事。
当然单分区也有代价,最典型的一个是:如果App升级中途断电,设备可能变成砖。这个问题后面我会专门讲怎么从设计层面规避,这里先记住结论:单分区是性价比最高的方案,尤其适用于F103这种资源不算富裕的芯片。
BootLoader的大小一般控制在8KB到16KB之间。F103的Flash页大小是1KB(大容量型号)或2KB(中容量型号),一次擦除的最小单位就是页,所以BootLoader占用空间最好是页大小的整数倍。我用8KB比较多,因为BootLoader代码加上简单的通信协议栈、Flash驱动、跳转逻辑,8KB绰绰有余。
系统的内存映射大致是这样规划的:
| 区域 | 起始地址 | 大小 | 内容 |
|---|---|---|---|
| BootLoader区 | 0x08000000 | 8KB | IAP程序 + 中断向量表 |
| 用户App区 | 0x08002000 | 剩余全部 | 用户程序,含App中断向量表 |
| 标志位区 | 0x08002000 - 偏移量或另选页 | 1页 | 升级标志、版本信息等 |
这里有个细节值得注意:App区起始地址在Flash里是0x08002000,对应内存里的映射地址就是0x08002000,而App的中断向量表偏移设置成0x2000。这串数字不是拍脑袋定的,是因为BootLoader占了8KB,也就是0x2000个字节,App的光标必须从这之后开始。编译链接的时候,所有跟地址相关的信息都要围绕这个偏移来调整,后面会详细说。
2. 存储规划与链接脚本,App跳转前必须处理好的三件事
动手写代码之前,先把硬件和编译环境的底子打好。F103的IAP实现有几个硬性前提,任何一个没做好,后面Debug到怀疑人生。
2.1 中断向量表的偏移机制
STM32上电后,CPU会从0x08000000处读取栈顶地址和复位中断向量,然后跳过去执行。BootLoader跑起来之后,如果要跳到App,必须自己把栈指针(MSP)和复位向量找出来,然后手动完成跳转。
App那边也不是什么都不用做。F103的App如果直接编译默认参数,中断向量表会被放在0x08000000,但那里是BootLoader的地盘,会造成混乱。所以App需要两个改动:
- 编译时把Flash起始地址改成0x08002000。
- 运行时把中断向量表的基地址重映射到0x08002000。
修改中断向量表偏移,F103上目前有两种主流做法。老一点的方式是操作SCB->VTOR寄存器:
SCB->VTOR = 0x08002000;这是Cortex-M3内核的标准做法,把中断向量表偏移到App的起始位置,中断响应才能正常找到对应的处理函数。不过需要注意,F103的启动代码在跳转到main之前就会使能一部分中断,所以VTOR的设置必须尽量靠前。最稳妥的位置是放在启动文件里,在SystemInit之后、进入main之前执行;或者退一步,放在main函数的第一行,但在任何外设中断使能之前。
另一种做法是直接在链接脚本里指定中断向量表的位置,同时代码里保留VTOR设置。两者配合才能保证万无一失。
2.2 链接脚本(.icf或.sct)怎么改
用Keil MDK开发F103时,默认链接脚本是.sct文件,通常可以在Options for Target的Linker选项卡里看到。改成App模式时,需要手动指定ROM区地址。
举个例子,如果BootLoader占了8KB,App的分散加载文件大致需要这样写:
LR_IROM1 0x08002000 0x0001E000 { ER_IROM1 0x08002000 0x0001E000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00005000 { .ANY (+RW +ZI) } }注意几个关键词:起始地址0x08002000、长度0x0001E000(也就是128KB减8KB,假设芯片是128KB Flash)。RAM区域一般不用动,但如果你App里用了大数组或者RTOS,RAM分配也要提前算好。
用GCC工具链的朋友,对应的就是.ld链接脚本,本质逻辑完全一样,只是语法不同:
MEMORY { FLASH (rx) : ORIGIN = 0x08002000, LENGTH = 120K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K }LENGTH这里取的是120K,等于128KB减掉BootLoader的8KB。如果你用的是F103C8T6(64KB Flash),那就是64KB减8KB等于56KB。
2.3 编译地址对齐检查,防止低级错误
脚本改完之后,编译出来要立刻检查.map文件,确认以下几项:
- App的复位向量在0x08002000处。
- 所有函数地址都在0x08002000之后。
- 栈顶初始值被正确放在0x08002000的最前面。
我自己吃过一次亏,App编译通过但一跑起来就进HardFault,查了半下午才发现是分散加载文件里ROM起始地址写错了,App代码全堆在了Flash开头,把BootLoader覆盖了。所以每次改完脚本,第一件事不是Debug烧程序,而是打开.map文件对照地址。
3. BootLoader的完整实现思路,不只写个跳转函数那么简单
BootLoader的核心职责是:上电后检查是否需要更新固件,如果需要则接收上位机发来的固件数据并写入Flash,全部完成后跳转到App执行。
3.1 跳转函数的设计
跳转这个动作看起来只有几行代码,写起来却有不少讲究。一个能稳定工作的跳转函数大致长这样:
typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_stack = *(volatile uint32_t *)app_addr; pFunction app_entry = (pFunction)*(volatile uint32_t *)(app_addr + 4); // 简单检查:栈顶地址是否在RAM范围内,入口地址是否在Flash范围内 if ((app_stack & 0x2FFE0000) != 0x20000000) return; if ((app_entry & 0x2FFE0000) != 0x08000000) return; __disable_irq(); SCB->VTOR = app_addr; // 重新设置中断向量表 __set_MSP(app_stack); // 重设主栈指针 app_entry(); // 跳转 }这段代码里的第一步值得展开讲:从App起始地址读取前两个32位数据,第一个是App的栈顶地址,第二个是复位向量。这是Cortex-M程序启动的内在要求,编译器会把这两个信息放在镜像文件最前面。校验栈顶地址在RAM范围、入口地址在Flash范围,是防盗跳坑的重要防线,防止因为Flash数据异常而跳到非法地址。
跳转之前还需要做一件很多人会忽略的事情:关闭所有外设时钟、关闭SysTick、失能所有中断。如果不做,跳转过去之后App启动时可能遇到中断状态残留,导致莫名其妙的问题。最彻底的方案是调用HAL_DeInit()或者使用库的复位机制,把外设恢复到默认状态。
3.2 Flash写入与擦除的关键流程
F103内置Flash的操作规则是:读随意,擦除按页,写入需要按字(32位)对齐。写Flash前必须先擦除对应的页,而Flash写寄存器在擦除期间是不可写的,所以代码逻辑要严格按“擦除 - 查询BSY标志 - 解锁 - 写入 - 查询BSY标志”的顺序来。
一个标准的App接收和写入流程大致如下:
- 上位机发送擦除指令,BootLoader收到后按页擦除App区域的Flash。
- 上位机分包发送固件数据,每包通常256字节或1KB,BootLoader边收边写。
- BootLoader每写完一包,向上位机回复确认帧,上位机根据确认决定是否发下一包。
- 全部数据写完后,BootLoader校验整个区域的CRC或累加和,校验通过则更新标志位,跳转执行。
写Flash的底层函数可以这样封装:
static int flash_write_word(uint32_t addr, uint32_t *data, uint32_t len) { FLASH_Unlock(); while (len > 0) { if (FLASH_ProgramWord(addr, *data) != FLASH_COMPLETE) { return -1; } addr += 4; data++; len -= 4; } FLASH_Lock(); return 0; }这里有个经验:写入的数据长度和地址都必须是4的倍数,因为F103的Flash控制器按字编程。上位机发过来的固件文件如果不满足对齐,BootLoader侧需要做填充处理,把最后一个字补充到4字节边界。
3.3 升级标志位的设计,以及如何避免“升级断电变砖”
Flash不够做双分区,又要防止断电变砖,业界常用的方案是在App区域之外定义一个升级标志字。每次上电,BootLoader检查这个标志:
- 标志为“待升级”:说明上一次升级没完成,BootLoader继续等待接收新固件。
- 标志为“已完成”:直接跳转App。
- 没有任何标志:直接跳转App。
这个标志字要放在一个独立的Flash页里,因为修改它需要整个页擦除重写。我们把标志页放在App区结束之后或者专门的预留页。如果外部加了EEPROM,也可以把标志放在EEPROM里,这样更灵活,但是断电丢失的风险要看EEPROM的类型和写周期。
BootLoader逻辑里,收到更新指令后第一件事就是把标志写为“待升级”,等固件全部传输并校验通过后,再把标志更新为“已完成”。这样只要“已完成”标志没出现,BootLoader就一直停留在接收状态,不会跳去执行一个半截的App,设备自然就避免变砖了。
4. 通信协议怎么设计,直接决定升级稳定性
IAP项目中,上位机和BootLoader之间一般通过UART通信。串口虽然简单,但如果协议设计得不严谨,升级过程中丢包、粘包、错位会让人崩溃。我调优过的方案里,一套完整可靠的串口IAP协议至少包括这几种帧:
4.1 帧结构设计
每一帧的数据结构可以统一为:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 固定为0xAA 0x55 |
| 命令字 | 1字节 | 0x01握手,0x02擦除,0x03数据,0x04校验,0x05跳转,0x06复位 |
| 数据长度 | 2字节 | 小端模式,说明数据字段长度 |
| 数据 | N字节 | 具体数据 |
| 校验 | 4字节 | CRC32,从帧头到数据末尾 |
帧头的设计很有讲究。0xAA和0x55都是交替比特形式,串口空闲状态下不会频繁出现,可以比较可靠地做帧同步。接收状态机在空闲时不断搜索帧头,一旦捕获到完整的0xAA 0x55,就进入接收状态。
数据长度字段也做了明确限制,例如最大值不超过1024+12字节,防止错误数据导致接收缓冲区溢出。
4.2 通信过程的握手与超时处理
升级过程的起始,BootLoader上电后进入接收模式时,会先向上位机发送一个固定握手信息,比如字符串BOOT_READY。上位机收到后,若需要升级就回复START_UPDATE,然后双方进入正式流程。
握手之后,关键的设计是超时机制。BootLoader端用定时器实现超时判断,如果1秒钟没收到上位机的数据,就主动发一次询问帧;连续3次询问都没响应,则整个升级流程重新开始,把Flash中的标志位清掉,防止卡死。
上位机端也必须实现超时重发。我见过不少项目,BootLoader做得很完善,但上位机没有超时机制,一旦串口线接触不良,整个升级就永远卡在某一包。上位机每发出一个数据包,应该开一个300ms到500ms的重发定时器,收到确认帧就清零,没收到就补发。一次性补发次数上限建议设3到5次,超过上限就报错并终止升级。
4.3 为什么我不用Modbus或者XModem这类现成协议
可能有朋友会问,这些活现成的协议也能干,为什么非自己设计。我试过几种方案,最大的问题是它们的效率或可靠性在IAP场景下有短板。
- Modbus的RTU帧格式固定,数据吞吐量有限,传一个50KB的固件要来回好几千次交互,速度太慢。
- XModem是文件传输场景的老协议,1KB一包,CRC校验是16位的,可靠性够用,但缺少一些升级场景需要的信息,比如起始地址、总长度、版本校验。
自定协议虽然要自己写轮子,但可以按项目需要设计:支持按地址擦除、支持CRC32强校验、支持带版本信息的首包、支持断点续传(记录已写入的地址),这些在真正的量产项目里都很重要。
5. 用户App侧需要改什么,否则跳转过去就黑屏
很多人以为App侧只要改个编译地址就行,结果一顿操作后,发现跳转过去要么直接跑飞,要么外设中断触发就死机。问题大多出在下面这几处。
5.1 启动文件里的中断向量偏移
按照Keil的启动文件写法,SystemInit会在进入main之前调用。SystemInit里有时会做时钟配置,但默认情况下并不会设置VTOR。所以最可靠的方式是在SystemInit函数末尾直接加入:
SCB->VTOR = 0x08002000;这里有个并行方案,如果你用了标准外设库或HAL库,也可以在SystemInit开头加。关键是在任何外设中断使能之前,VTOR必须设置到位。
5.2 把App编译地址和实际运行地址对齐
这一步在2.2节里已经提过,App的ROM起始地址必须等于BootLoader规划的App区起始地址。以8KB BootLoader为例,就是0x08002000。编译完成后再次检查.map文件,确认向量表、所有函数、常量都放在0x08002000之后。
5.3 两个容易忽略的坑
第一,如果App里用了实时操作系统(比如FreeRTOS)或自写了任务调度,注意系统时钟节拍不能跨过了跳转边界。跳转时把SysTick关了,App启动后它会自己重新配置,否则App的中断节拍可能还带着BootLoader时期的残余配置。
第二,App里的Flash读写操作要注意自己的擦除范围。比如App自身实现了参数存储,如果写入地址不小心落在App自己的Flash区内,有可能把正在执行的代码段擦掉,直接跑飞。参数存储区建议规划在App区之后独立的一块Flash页,并且进行擦除范围检查。
6. 上位机开发,串口配置、进度显示与异常处理
上位机这部分是整个IAP体验的最后一公里。很多嵌入式工程师上位机经验相对少,我这里把我在实际项目中验证过的几个要点列出来。
6.1 串口通信基础配置
如果用C# WinForms或者WPF做上位机,串口通信推荐用System.IO.Ports.SerialPort,设置波特率115200、8位数据、1位停止位、无校验。和BootLoader约定好就行。
关键点在于接收数据不能放在UI线程里读,必须用独立线程或DataReceived事件去接收串口数据。我之前在WinForms里直接在UI线程里循环读串口,结果界面卡顿严重,进度条一跳一跳的,用户体验极差。
6.2 协议实现中的注意事项
上位机端的协议状态机和BootLoader端对称,收到数据先做帧同步,再做CRC校验。数据包有效之后再往上抛事件,UI层只处理解析好的数据。
这里要特别强调帧同步的设计:串口是流式数据,假设上位机和BootLoader时钟有偏差,可能在传输中产生错位。处理思路是收到一帧数据,必须验证帧头、长度、CRC都正确,才认为这一帧有效;任何一步不匹配,就丢弃当前字节,继续在流里搜索下一个帧头。
另外一个实际经验是串口缓冲区不要开太小。如果底层接收缓冲只有256字节,而数据包是1KB,上位机可能每次都收到截断的数据。建议把串口接收缓冲区开到4KB以上,或者改成边收边处理的方式。
6.3 固件文件的解析与发送
上位机拿到.bin或.hex文件后,需要先解析成可以直接烧录的镜像数据。.hex文件是Intel HEX格式,包含地址信息,需要逐条解析记录;.bin文件更朴实,就是纯二进制数据,没有地址,发送时按约定地址顺序发送即可。
建议让编译工具链直接输出.bin文件。Keil里在User选项卡的After Build里加一行命令即可:
fromelf --bin --output .\Output\App.bin .\Output\App.axf这样每次编译完自动生成.bin,上位机直接选这个文件就行。
发送的时候按包切分。比如每包256字节,最后一包如果不足256字节就补0xFF。上位机界面显示:
- 当前包的序号/总包数。
- 已写入百分比。
- 当前速度(KB/s)。
- 成功/失败状态日志。
日志这点别小看,量产现场出现升级失败时,全靠日志定位是哪一步、哪一包出的问题。
6.4 上位机侧的现成轮子方向
如果不想从零写上位机,可以了解一下MCUISP、FlyMcu这类现成工具,它们本身支持串口IAP下载。但坦白说,这类工具多数是为自家协议或通用协议设计的,自定义程度低。如果你的BootLoader协议和它们的协议不匹配,还是得自己开发。
我建议开发上位机时直接做成可复用的框架:串口通信层、协议层、业务层分开,以后给别的项目做IAP时只改协议层和UI文案。
7. 量产联调时的几个大坑,每一个我都付过学费
到这里,代码和上位机都写得差不多了,但真正让IAP方案从“能跑”变成“可靠”,还需要过量产这一关。下面这些坑是我亲测踩出来的,分享出来能帮你省掉大量排查时间。
7.1 USB转串口芯片的兼容性问题
开发机上试验一切正常,一到产线就隔三差五失败。排查下来发现,问题出在USB转串口芯片上。不同厂商的USB转串口驱动对数据流的延迟处理不一样,有些芯片会在缓冲里攒够一定字节才往外吐,导致上位机发数据没问题、收数据超时。最直接的办法是在协议层做重发机制;同时,产线用的USB转串口工具要统一型号,不要杂牌混用。
7.2 电源纹波在擦写Flash时的干扰
BootLoader擦除和写入Flash的瞬间,电流会有一个明显抖动。如果你的板子电源设计不太好,此时很可能出现串口通信丢字节或者芯片复位。这个问题在实验室里不明显,因为开发板电源都比较干净;量产环境里电源如果只有简单的LDO,抖动很可观。
推荐的应对手段是:升级期间禁止驱动继电器、电机、蜂鸣器等大电流外设;BootLoader代码里把不用的外设时钟全部关闭,降低功耗波动;如果条件允许,在电源入口加大容量电容。
7.3 看门狗的正确处理姿势
BootLoader里的看门狗必须慎重使用。升级过程中,看门狗不能导致BootLoader中途复位。有的项目使能了独立看门狗IWDG,如果BootLoader里忘了及时喂狗,升级传一个50KB的固件,中途稍微慢一点就被狗咬复位,循环往复。最稳妥的做法:BootLoader不使能看门狗,或者只在“等待上位机响应”的空闲期喂狗。
7.4 Flash的写入次数寿命,不是无限擦写
F103的Flash擦写寿命通常是1万次左右,这在量产测试中是一个需要提醒的点。如果产线测试每次都给同一块板子刷几遍固件,寿命测试阶段又连续刷几十次,Flash会坏得比预想快得多。成品升级虽然一般不会频繁刷,但烧录脚本里建议记录刷写次数,超过一定数量就要警告,防止测试板“莫名其妙”再也刷不进去。
7.5 简单有效的固件保护——防误刷新
量产现场偶尔会出现“本来这台设备不用升级,测试人员却点了刷新”的情况。为了防止这种低级误操作,可以在上位机里做版本校验:升级文件里包含目标版本信息,上位机先让BootLoader读出当前App版本,如果不匹配就直接拒绝。这样既能防止旧固件覆盖新固件,也能防止刷错设备。
8. 最后分享一点个人体会
整套IAP方案做下来,比起单纯写一个静态固件,复杂度确实上了一个台阶。但正是这一点复杂度,让设备具备了远程可维护性,后续的功能升级、Bug修复都能通过串口甚至远程通信通道完成,不用开壳接下载器。
如果你刚开始动手做,我建议的实践路径是:先在最简单的板子上跑通“BootLoader + App跳转”,再逐步加通信协议、上位机、异常处理。不要一上来就追求完美方案,先把链路打通,把地基打牢,后面做得越深你会越发现,工程量最大的不是代码本身,而是对各种边界情况和异常情况的设计。
跳转之后App起不来的问题,十有八九出在VTOR没设置对、编译地址没改对、跳转前没有关闭外设中断这三件事上。能把这三点先彻底搞明白,你的IAP就成功了一大半。