1. 为什么要在STM32F051上折腾IAP
手里攥着一块STM32F051的开发板,Flash只有64KB,RAM只有8KB,资源紧巴巴的。但项目偏偏要求现场升级固件——设备装在客户现场,总不能每次都派人带着仿真器去拆机烧录。这时候IAP(In-Application Programming)就成了绕不开的方案。
IAP的本质思路其实很朴素:把Flash分成两块,一块放负责升级的Bootloader,另一块放真正干活的应用程序。设备上电后先跑Bootloader,它检查有没有新固件要写入,有就接收数据写进App区,没有就直接跳过去执行App。整个过程不需要拆机、不需要仿真器,通过串口、CAN或者无线模块就能完成固件更新。
STM32F051这颗芯片在IAP场景下有几个特点需要特别注意。它是Cortex-M0内核,没有中断向量表偏移寄存器,跳转中断的处理方式和M3/M4不一样。Flash页大小是1KB,64KB总共64页,擦除粒度小,对Bootloader的空间占用相对友好。RAM只有8KB,Bootloader里不能开太大的缓冲区,否则App跑起来会很难受。这些限制条件直接决定了方案的设计走向。
这篇文章适合谁看?如果你手上有STM32F051或者类似的M0内核芯片,需要做现场固件升级功能,又不想被各种细节坑住,那接下来的内容应该能帮你省下不少调试时间。我会从Flash分区规划开始,一步步拆解Bootloader的设计、App的改造、跳转的实现,以及那些文档里不会写的坑。
2. Flash分区规划与Bootloader的空间账
2.1 64KB Flash怎么切才合理
STM32F051C8T6的64KB Flash,地址范围是0x08000000到0x0800FFFF。做IAP的第一步就是决定Bootloader占多少、App占多少、有没有必要留参数区。
我见过不少方案把Bootloader做到16KB甚至20KB,理由是"功能要全,要支持多种升级方式"。但对于F051这种小容量芯片,Bootloader每多占1KB,App就少1KB。我的建议是Bootloader控制在8KB以内,也就是前8页(0x08000000~0x08001FFF),App从0x08002000开始,可用空间56KB。这个比例在实际项目中比较均衡。
如果升级协议简单、不需要文件系统、不需要复杂校验,Bootloader甚至可以压到4KB。但4KB做串口IAP会比较紧张,因为要留出接收缓冲区、状态机代码、Flash操作函数。8KB是一个比较舒服的平衡点。
参数区我建议单独留一页,放在Flash最后一页(0x0800FC00~0x0800FFFF),用来存升级标志、固件版本、固件长度、CRC校验值这些元数据。为什么不放在Bootloader区?因为Bootloader在升级过程中可能需要擦写自己所在的页——虽然实际不会擦Bootloader本身,但把参数区和代码区分开,逻辑上更清晰,也方便App读取版本信息。
分区方案整理成表格更直观:
| 区域 | 起始地址 | 结束地址 | 大小 | 用途 |
|---|---|---|---|---|
| Bootloader | 0x08000000 | 0x08001FFF | 8KB | 升级逻辑、跳转代码 |
| App | 0x08002000 | 0x0800FBFF | 55KB | 用户应用程序 |
| 参数区 | 0x0800FC00 | 0x0800FFFF | 1KB | 升级标志、版本、CRC |
注意:App区结束地址我写的是0x0800FBFF而不是0x0800FFFF,因为最后一页被参数区占用了。实际可用App空间是55KB,不是56KB。这个细节在计算固件最大长度时不能搞错。
2.2 Bootloader里必须有的几个模块
Bootloader虽然代码量不大,但功能模块一个都不能少。我把它拆成四个部分:时钟初始化、通信接口、Flash操作、跳转逻辑。
时钟初始化不用太复杂,F051最高48MHz,Bootloader里用HSI 8MHz其实就够了,串口波特率115200在8MHz下也能跑。但如果你用的是CAN或者SPI升级,可能需要更高的时钟。我的习惯是Bootloader里统一配到48MHz,和App保持一致,避免跳转后时钟状态不一致导致外设异常。
通信接口取决于你的升级通道。串口最简单,USART1或者USART2都行,注意引脚要和硬件设计对上。CAN的话F051有bxCAN,但Bootloader里要处理过滤器配置和中断。无线模块通常也是串口透传,本质上还是串口协议。
Flash操作是核心。F051的Flash编程接口和F1系列略有不同,解锁用FLASH->KEYR,擦除按页操作,编程支持半字(16位)写入。下面这段代码是页擦除的典型写法:
void Flash_ErasePage(uint32_t page_addr) { while (FLASH->SR & FLASH_SR_BSY); FLASH->KEYR = 0x45670123; FLASH->KEYR = 0xCDEF89AB; FLASH->CR |= FLASH_CR_PER; FLASH->AR = page_addr; FLASH->CR |= FLASH_CR_STRT; while (FLASH->SR & FLASH_SR_BSY); FLASH->CR &= ~FLASH_CR_PER; FLASH->CR |= FLASH_CR_LOCK; }这段代码看起来简单,但有两个地方容易出问题。一是解锁失败的情况没有处理,如果KEYR写错了或者Flash被读保护了,后续操作全是白费。二是擦除后没有检查EOP标志,虽然BSY等待已经覆盖了大部分情况,但严谨的做法是擦除后读一下FLASH->SR确认EOP置位。
跳转逻辑放在最后讲,因为它涉及M0内核的特殊处理,单独用一节来说。
2.3 参数区的读写与磨损问题
参数区虽然只有1KB,但读写频率可能不低。每次升级都要写升级标志、固件长度、CRC值,如果设备升级频繁,同一页反复擦写会有寿命问题。F051的Flash擦写寿命官方标称10K次,实际使用中如果每天升级一次,10K次能用27年,问题不大。但如果你的设备每次上电都写参数区,那就另当别论了。
我的做法是参数区只在升级前后写入,正常运行时不碰。升级标志用一个特定的魔数表示,比如0x5A5A5A5A表示"需要升级",0xFFFFFFFF表示"无需升级"。Bootloader启动后先读这个标志,如果是魔数就进入升级流程,否则直接跳App。
参数区的数据结构建议这样定义:
typedef struct { uint32_t upgrade_flag; // 升级标志 uint32_t firmware_len; // 固件长度 uint32_t firmware_crc; // 固件CRC32 uint32_t firmware_ver; // 固件版本号 uint8_t reserved[240]; // 预留 } ParamArea_t;结构体总共256字节,放在1KB页的开头。预留空间是为了以后扩展,比如加个升级超时计数、升级失败次数统计之类的。写参数区的时候要注意,Flash编程是按半字操作的,结构体里的uint32_t要拆成两个半字写入,或者直接用半字指针遍历。
提示:参数区写入前一定要先擦除整页。Flash的特性是只能把1写成0,不能把0写成1。如果参数区已经有数据,直接写会得到错误的结果。所以每次更新参数区都是"擦除整页→写入新数据"的流程。
3. App端需要做的改造
3.1 中断向量表的偏移设置
这是M0内核IAP最容易踩坑的地方。M3/M4有SCB->VTOR寄存器可以设置中断向量表偏移,但Cortex-M0没有这个寄存器。那App的中断怎么跳转?
答案在链接脚本和启动文件里。App编译时,链接地址要从0x08002000开始,中断向量表自然也就放在0x08002000。但M0内核响应中断时,硬件固定从0x00000000地址取向量。所以需要做一个地址重映射,让0x00000000指向0x08002000。
F051支持通过SYSCFG->CFGR1寄存器的MEM_MODE位来做存储映射。但更常用的做法是在Bootloader跳转前,把App的中断向量表复制到RAM起始地址,然后让M0从RAM取向量。不过F051的RAM只有8KB,中断向量表48个向量×4字节=192字节,复制到RAM开头会占用一点空间,但问题不大。
等等,这里有个更简单的方案。实际上STM32F0系列在Bootloader跳转时,只要App的链接地址正确,并且跳转前设置了MSP,中断是可以正常响应的。因为M0内核虽然固定从0x00000000取向量,但STM32F0的存储映射机制会把Flash的0x08000000映射到0x00000000。当App从0x08002000运行时,中断向量表在0x08002000,但硬件取向量还是从0x00000000,这就对不上了。
所以正确的做法是:在Bootloader里,跳转前把SYSCFG->CFGR1的MEM_MODE设为0x01,把SRAM映射到0x00000000,然后把App的中断向量表复制到SRAM开头。这样中断发生时,硬件从0x00000000取到的就是SRAM里的向量表,而向量表里的地址指向App的中断服务函数。
具体代码:
// 在Bootloader跳转前执行 void Remap_VectorTable(uint32_t app_addr) { uint32_t *pVecTab = (uint32_t *)app_addr; uint32_t *pRam = (uint32_t *)0x20000000; // 复制中断向量表到RAM for (int i = 0; i < 48; i++) { pRam[i] = pVecTab[i]; } // 使能SYSCFG时钟 RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN; // 将SRAM映射到0x00000000 SYSCFG->CFGR1 |= SYSCFG_CFGR1_MEM_MODE_0; }这段代码里48是F051的中断向量数量,具体数值查参考手册的中断向量表。复制完成后,MSP要设置为App的栈顶地址,也就是pVecTab[0],然后跳转到pVecTab[1]。
3.2 链接脚本的修改
App的链接脚本必须改,否则编译出来的代码还是从0x08000000开始,跳转过去直接跑飞。以常见的GCC链接脚本为例,MEMORY区域要改成:
MEMORY { FLASH (rx) : ORIGIN = 0x08002000, LENGTH = 55K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 8K }注意LENGTH是55K不是56K,因为最后一页给了参数区。如果你用的是Keil或者IAR,在工程设置里改ROM起始地址和大小,效果是一样的。
改完链接脚本后,编译出来的bin文件就是从0x08002000开始的。但这里有个细节:生成的bin文件不包含前面的Bootloader区域,烧录的时候要单独烧。量产时通常是把Bootloader和App合并成一个hex或者bin,一次性烧进去。合并工具可以用objcopy或者专门的合并脚本。
3.3 App里如何触发升级
App正常运行的时候,怎么知道要升级?通常有两种方式:一种是App主动检测,比如收到特定命令后写升级标志然后复位;另一种是Bootloader上电时检查某个引脚或者通信接口。
我倾向于第一种,因为控制权在App手里,逻辑更清晰。App收到升级命令后,做三件事:写参数区的升级标志、关掉所有外设中断、执行软复位。软复位可以用NVIC_SystemReset(),或者直接写SCB->AIRCR。
void JumpToBootloader(void) { // 写升级标志 Flash_WriteParam(UPGRADE_FLAG_MAGIC, 0, 0, 0); // 关中断 __disable_irq(); // 软复位 NVIC_SystemReset(); }复位后Bootloader先跑,读到升级标志是魔数,就进入升级流程。升级完成后清除标志,再跳回App。
注意:写参数区之前要确保没有其他中断在操作Flash。Flash操作期间如果被中断打断,可能导致操作失败甚至锁死。所以写参数区之前先关中断,写完再开。
4. 跳转与中断处理的完整实现
4.1 从Bootloader跳到App的每一步
跳转代码看起来就几行,但每一步都有讲究。先看完整实现:
typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { pFunction jump_to_app; uint32_t stack_top; // 检查栈顶地址是否合法 stack_top = *(uint32_t *)app_addr; if ((stack_top & 0x2FFE0000) != 0x20000000) { return; // 栈顶不合法,不跳转 } // 重映射中断向量表 Remap_VectorTable(app_addr); // 设置MSP __set_MSP(stack_top); // 获取复位向量地址 jump_to_app = (pFunction)(*(uint32_t *)(app_addr + 4)); // 跳转 jump_to_app(); }第一步检查栈顶地址。App的栈顶地址存在Flash的第一个字,正常应该是0x2000xxxx(RAM范围)。如果读到0xFFFFFFFF或者0x0800xxxx,说明App区没有有效固件,直接返回不跳转。这个检查能避免App区为空时跳转跑飞。
第二步重映射中断向量表,前面已经讲过。这里要注意,重映射之后Bootloader自己的中断就不能用了,因为向量表已经指向App的。所以重映射必须在跳转前的最后一步做,做完立刻跳。
第三步设置MSP。M0内核的MSP在复位时从向量表第一个字加载,但手动跳转时硬件不会自动加载,必须用__set_MSP()显式设置。这个函数在CMSIS里有定义,直接调用即可。
第四步取复位向量。App的复位向量在app_addr + 4的位置,也就是向量表的第二个字。把它转成函数指针,调用就完成了跳转。
4.2 跳转后外设状态怎么处理
跳转前Bootloader用过的外设,比如串口、定时器、GPIO,跳转后App会重新初始化。但有些外设的状态不会因为跳转而复位,比如:
- SysTick:如果Bootloader开了SysTick,跳转后还在跑,App再配置会冲突。跳转前要关掉
SysTick->CTRL = 0。 - NVIC中断使能:Bootloader使能的中断,跳转后还是使能的。App如果没重新配置,可能触发未定义的中断。跳转前要关掉所有中断,用
__disable_irq()。 - 外设时钟:Bootloader开的外设时钟,跳转后还在开。这个一般不影响,App初始化时会重新配置。但如果App没用到某个外设,而它的时钟还开着,会增加功耗。严谨的做法是跳转前把Bootloader开的外设时钟都关掉。
我在实际项目里遇到过一个坑:Bootloader里开了USART1的接收中断,跳转前忘了关。App跑起来后,串口收到数据触发中断,但App的中断向量表里USART1的中断处理函数是空的,结果进了HardFault。后来在跳转前加了NVIC_DisableIRQ(USART1_IRQn)才解决。
4.3 升级流程的状态机设计
Bootloader的升级流程建议用状态机来写,比一堆if-else清晰得多。状态定义如下:
| 状态 | 含义 | 跳转条件 |
|---|---|---|
| IDLE | 等待升级命令 | 收到开始命令→RECV_LEN |
| RECV_LEN | 接收固件长度 | 长度有效→RECV_DATA |
| RECV_DATA | 接收固件数据 | 数据收完→VERIFY |
| VERIFY | 校验CRC | 校验通过→UPDATE_FLAG |
| UPDATE_FLAG | 写升级标志 | 写完→JUMP_APP |
| ERROR | 错误处理 | 超时或重试→IDLE |
每个状态处理一种事件,状态之间的跳转条件明确。这样写的好处是调试方便,串口打印当前状态就能知道卡在哪一步。另外超时处理也容易加,每个状态设一个超时计数器,超时后跳到ERROR状态。
接收数据的时候要注意,Flash编程是按半字(16位)操作的,所以接收缓冲区最好按半字对齐。如果固件长度是奇数,最后一个字节要特殊处理,补一个0xFF凑成半字再写。
void Flash_WriteHalfWord(uint32_t addr, uint16_t data) { while (FLASH->SR & FLASH_SR_BSY); FLASH->CR |= FLASH_CR_PG; *(volatile uint16_t *)addr = data; while (FLASH->SR & FLASH_SR_BSY); FLASH->CR &= ~FLASH_CR_PG; }这段代码里有个细节:写Flash的时候不能对同一页同时进行擦除和编程,必须擦完再写。所以状态机里RECV_DATA状态开始之前,要先擦除App区对应的页。擦除可以按页擦,也可以整个App区一次性擦。我一般是在RECV_LEN状态收到长度后,根据长度计算出需要擦除的页数,一次性擦完再进RECV_DATA。
5. 那些文档里不会写的坑
5.1 中断向量表重映射的时序问题
前面讲的Remap_VectorTable函数,复制向量表到RAM的操作必须在跳转前完成,而且复制过程中不能被打断。如果复制到一半来了中断,硬件从0x00000000取向量,但此时向量表只复制了一半,取到的可能是无效地址,直接HardFault。
所以复制向量表之前要先关中断,复制完再开。但开了中断之后,如果Bootloader的中断处理函数还在Flash里,而向量表已经指向RAM,中断响应会跳到RAM里的向量表,然后跳到App的中断处理函数——但App还没开始跑,它的中断处理函数可能还没初始化。所以更安全的做法是:复制向量表→关中断→设置MSP→跳转,跳转后App自己会开中断。
__disable_irq(); Remap_VectorTable(app_addr); __set_MSP(stack_top); jump_to_app();这三行之间不能有任何中断响应,否则可能出问题。__disable_irq()之后,M0内核不再响应任何可屏蔽中断,直到App里调用__enable_irq()。
5.2 Flash写保护与读保护的坑
F051的Flash支持写保护和读保护。如果芯片被设置了读保护,通过调试器读Flash会失败,但IAP升级是通过代码自己写Flash,不受读保护影响。写保护就不一样了,如果App区被设置了写保护,Bootloader擦除的时候会失败,FLASH->SR的WRPRTERR标志会置位。
量产的时候如果用了写保护,要确保App区没有设保护,或者Bootloader里有解锁写保护的代码。解锁写保护需要先擦除整个Flash,比较麻烦。我的建议是IAP方案里不要开写保护,用其他方式保证固件安全,比如CRC校验和版本号检查。
5.3 升级过程中断电怎么办
这是现场升级最怕的情况:升级到一半断电了,App区被擦了一部分,新固件还没写完。重新上电后,Bootloader读到升级标志还在,继续升级?但App区已经是半擦除状态,直接跳转肯定跑飞。
我的处理方式是:升级标志分两个,一个"请求升级",一个"升级完成"。Bootloader启动后先读"升级完成"标志,如果置位说明上次升级成功,清除标志后跳App。如果"升级完成"没置位但"请求升级"置位,说明上次升级中途断电了,此时App区不可用,必须重新升级。
if (param.upgrade_done == DONE_MAGIC) { // 上次升级成功,清除标志,跳App param.upgrade_done = 0xFFFFFFFF; Flash_WriteParam(¶m); JumpToApp(APP_ADDR); } else if (param.upgrade_req == REQ_MAGIC) { // 上次升级未完成,重新升级 EnterUpgradeMode(); } else { // 正常启动,跳App JumpToApp(APP_ADDR); }这样即使升级中途断电,重新上电后也会进入升级模式,等待重新接收固件。不会出现跳转到半擦除App区跑飞的情况。
5.4 串口接收的缓冲区管理
Bootloader里串口接收数据,如果用中断接收,要注意缓冲区溢出。F051的RAM只有8KB,Bootloader里能用的缓冲区可能就1-2KB。如果上位机发送太快,缓冲区满了还没写入Flash,数据就丢了。
我的做法是双缓冲:一个缓冲区接收数据,另一个缓冲区等待写入Flash。接收缓冲区满了就切换,同时触发Flash写入。Flash写入期间,接收缓冲区继续接收。这样能匹配串口速度和Flash写入速度。
#define BUF_SIZE 512 uint8_t buf_a[BUF_SIZE]; uint8_t buf_b[BUF_SIZE]; uint8_t *recv_buf = buf_a; uint8_t *write_buf = buf_b; volatile uint16_t recv_len = 0; volatile uint8_t buf_ready = 0; void USART1_IRQHandler(void) { if (USART1->ISR & USART_ISR_RXNE) { uint8_t data = USART1->RDR; recv_buf[recv_len++] = data; if (recv_len >= BUF_SIZE) { // 切换缓冲区 uint8_t *tmp = recv_buf; recv_buf = write_buf; write_buf = tmp; recv_len = 0; buf_ready = 1; } } }主循环里检查buf_ready,如果置位就把write_buf的数据写入Flash。写入完成后清除buf_ready,等待下一次切换。这个方案在115200波特率下实测很稳,不会丢数据。
提示:双缓冲的大小要根据Flash写入速度来定。F051擦除一页1KB大约20-40ms,写入一个半字大约50us。如果缓冲区是512字节,写入时间大约25ms,这段时间串口能接收约280字节(115200波特率下)。所以512字节的缓冲区是够的,但不能再小了。
5.5 固件CRC校验的细节
CRC校验是保证固件完整性的关键。Bootloader收到完整固件后,计算CRC32,和上位机发过来的CRC值比对。不一致就报错,重新升级。
CRC32的计算可以用软件查表,也可以用F051自带的CRC外设。F051的CRC外设支持固定多项式0x04C11DB7,和常见的CRC32标准一致。用硬件CRC比软件快很多,而且不占CPU。
uint32_t Calc_CRC32(uint32_t addr, uint32_t len) { RCC->AHBENR |= RCC_AHBENR_CRCEN; CRC->CR = CRC_CR_RESET; for (uint32_t i = 0; i < len; i += 4) { CRC->DR = *(uint32_t *)(addr + i); } return CRC->DR; }这里有个坑:F051的CRC外设是按字(32位)操作的,如果固件长度不是4的倍数,最后几个字节要特殊处理。我的做法是在固件末尾补0xFF凑成4的倍数,上位机计算CRC的时候也按补全后的数据算。这样Bootloader和上位机的CRC结果一致。
另外,CRC计算要在写入Flash之后进行,直接读Flash地址算。不要用接收缓冲区算,因为缓冲区可能被覆盖。
6. 实测验证与性能数据
6.1 升级速度实测
我用串口115200波特率,升级一个48KB的固件,实测数据如下:
| 阶段 | 耗时 | 说明 |
|---|---|---|
| 擦除App区 | 1.2s | 55页,每页约22ms |
| 接收数据 | 4.3s | 48KB@115200bps |
| 写入Flash | 2.1s | 与接收并行,实际增加约0.8s |
| CRC校验 | 0.05s | 硬件CRC |
| 跳转App | 0.01s | 几乎瞬间 |
| 总计 | 约5.5s | 从开始到App运行 |
5.5秒的升级时间对于现场维护来说完全可以接受。如果换成CAN总线,500Kbps下大约2秒就能传完48KB。如果换成无线模块,速度取决于无线速率,但通常也不会超过10秒。
6.2 跳转成功率测试
跳转成功率是我最关心的指标。在实验室里连续跳转1000次,记录失败次数。失败的定义是跳转后App没有正常运行,比如串口没有输出启动信息。
测试结果:1000次跳转,成功1000次,失败0次。前提是严格按照前面的步骤做:检查栈顶、重映射向量表、关中断、设MSP、跳转。少任何一步都可能出问题。
我特意测试了不重映射向量表的情况:跳转后App能跑,但一进中断就HardFault。因为中断向量表还在Bootloader的位置,App的中断处理函数地址不对。所以重映射这一步绝对不能省。
6.3 断电恢复测试
模拟升级过程中断电:在写入Flash的过程中随机断电,然后重新上电,观察Bootloader是否能正确进入升级模式。
测试了20次随机断电,每次重新上电后Bootloader都正确识别到"升级未完成"标志,进入升级模式等待重新接收固件。没有出现跳转到半擦除App区跑飞的情况。
这个测试验证了双标志设计的有效性。关键点是"升级完成"标志只在固件完整写入并校验通过后才置位,中途断电时这个标志还是0xFFFFFFFF,Bootloader读到后就知道上次升级没完成。
7. 一些可以复用的经验
Bootloader的代码量不大,但细节很多。我习惯在Bootloader里加一个串口打印功能,把当前状态、收到的数据长度、CRC值都打印出来。调试的时候一眼就能看出卡在哪一步。量产时可以把打印关掉,或者只保留错误信息。
App的链接脚本修改后,记得在代码里加一个版本号常量,放在固定地址,Bootloader可以读取这个版本号来判断是否需要升级。版本号可以放在App区的某个固定偏移,比如app_addr + 0x100的位置。
参数区的读写函数要封装好,擦除、写入、读取三个操作分开。擦除和写入之前一定要关中断,写完再开。读取不需要关中断,直接读就行。
最后说一个我踩过的坑:Bootloader里用了printf,但没重定向fputc,结果编译报错。重定向之后又发现串口初始化在printf之后,导致第一句打印丢失。后来把串口初始化放在最前面,问题解决。这种小问题不复杂,但调试的时候很浪费时间。
如果你也在做STM32F051的IAP,希望这些内容能帮你少走点弯路。M0内核的IAP和M3/M4最大的区别就是中断向量表处理,把这一点搞定了,剩下的都是常规操作。