> 设备已经出货装到现场,程序要加个功能。工程师带着 ST-Link 拆机箱、找排针、连 SWD------如果设备还埋在墙里、泡在配电柜里,这条路就走不通了。串口 IAP(In Application Programming)就是为这种场景准备的:产品出厂时装一个 Bootloader,以后升级只靠一根串口线,甚至远程透传。本文从分区设计、上电跳转、帧协议、Flash 擦写到掉电保护,把 F103 上做 IAP 的完整套路讲透,全程可复现。 ## 一、分区设计:Boot 区与 App 区,一别两宽 Flash 只有一块,程序怎么共存?答案是把 64KB 切成两半,各管各的: !Flash 双区布局:Boot 16KB + App 48KB + magic 标记(https://i-blog.csdnimg.cn/direct/fa45a47fac004df5b8893167984dffa1.png) > 配图说明:0x08000000 起 16KB 给 Boot(64KB 总容量四分之一),0x08004000 起 48KB 给 App;App 区末尾 0x0800FFFC 存 magic 标记。 以 F103C8T6 为例:Flash 共 64KB、每页 1KB,从 0x08000000 编址。**Boot 区占 0x08000000~0x08003FFF(16KB,16 页),App 区占 0x08004000~0x0800FFFF(48KB,48 页)**------地址从 0x08004000 开始,注意是 0x4000 字节不是 0x4000 行。Boot 里只放两件事:上电体检 + 串口升级协议,十几 KB 绰绰有余;App 正常开发,唯一区别是**链接地址要改到 0x08004000**,且向量表偏移 0x4000(0x200 对齐:F103 最多 128 个中断向量 × 4B = 0x200,满足硬件要求)。 App 区最后一字(0x0800FFFC)留一个 magic 值 **0xA5A5A5A5**,它是「App 有效」的凭证。为什么需要它?看第二节。 ## 二、上电体检:magic 在,跳 App;magic 不在,等升级 每次上电(或复位)都从 0x08000000 启动,Boot 先做体检再决定去向: !上电流程:体检通过跳 App,体检失败进升级模式(https://i-blog.csdnimg.cn/direct/0978cd24cb0b4aa6be72c60ef757b8dc.png) > 配图说明:左侧体检通过:关中断 → 改 VTOR → 切 MSP → 跳复位向量;右侧体检失败:串口等握手 → 擦 48 页 → 逐帧写 → 写 magic → 重启。 体检就两项:**① App 首字是合法 SRAM 地址**(防止烧了空数据),**② 末尾 magic 等于 0xA5A5A5A5**。两项都过才跳转,跳转序列必须严格按顺序: ```c #define APP_ADDR 0x08004000 #define APP_MAGIC 0xA5A5A5A5 void JumpToApp(void) { uint32_t app_sp = *(volatile uint32_t *)APP_ADDR; /* App 首字:初始 MSP */ uint32_t app_pc = *(volatile uint32_t *)(APP_ADDR + 4); /* 第二字:复位向量 */ /* ① 栈指针合法性检查:首字必须是 0x20000000~0x2000FFFF 的 SRAM 地址 */ if ((app_sp & 0x2FFE0000) != 0x20000000) return; /* ② 体检:magic 必须是写完成功后落下的 0xA5A5A5A5 */ if (*(volatile uint32_t *)0x0800FFFC != APP_MAGIC) return; __disable_irq(); /* ③ 关全局中断:跳转瞬间不许进任何中断 */ SCB->VTOR = APP_ADDR; /* ④ 向量表搬到 App 区(0x200 对齐) */ __set_MSP(app_sp); /* ⑤ 主栈指针换成 App 的 */ ((void (*)(void))app_pc)(); /* ⑥ 函数指针跳复位向量,App 接管 */ while (1); /* 跳不回来 */ } ``` 三个细节容易翻车:**跳转前必须关中断**------跳转瞬间 SysTick 或串口中断从旧向量表查入口,一跳就 HardFault;**F103 的 VTOR 是能用的**(SCB->VTOR 在 0xE000ED08),所以 `SCB->VTOR = APP_ADDR` 直接搬向量表,不用老教程里「复制 128 个向量到 RAM」的土办法;**跳过去之后 App 自己还要再设一次 VTOR**------链接地址变了,但默认 VTOR 还是 0x08000000,App 里不设,一进中断向量又指回 Boot 区。App 侧只需一行(工程选项里把 VECT_TAB_OFFSET 设为 0x4000,或 main 开头直接写): ```c int main(void) { SCB->VTOR = 0x08004000; /* App 内重设向量表偏移,否则中断全乱 */ __enable_irq(); /* Boot 跳转前关了中断,这里要开回来 */ SystemInit(); /* 时钟重新配置(Boot 的配置对 App 不负责) */ ... } ``` ## 三、帧协议:一问一答,错一帧重发一帧 升级通道是串口(115200 8N1),Boot 与上位机按帧对话。帧格式和四条命令: !帧协议:帧头 + 命令 + 长度 + 数据 + 校验(https://i-blog.csdnimg.cn/direct/b0d61c58c65541e8adac0c589dda4f65.png) > 配图说明:每帧 0x5A 0xA5 开头,后跟 CMD 1B、LEN 1B、DATA LEN 字节、SUM 校验 1B;命令表 0x01 握手 / 0x02 擦除 / 0x03 写块 / 0x04 完成。 **SUM = 0x100 −(CMD + LEN + 全部数据)& 0xFF**,双方同算法重算比对------和校验比,哪个字节错了都能抓住,而且两补码写法一个字节就能表达校验和。Boot 每次应答 0xAA(成功)/ 0x55(失败),上位机收到 0x55 直接重发本帧,**出错重试被限制在单帧 252B 内,不用整包重来**------这比「传完整个固件再校验」的设计稳健得多:50KB 固件传到最后差一个字节,整包重传的代价是灾难性的。 ```c /* Boot 串口收帧:状态机解析,命令分发(骨架) */ uint8_t rx_buf8 + 256; uint8_t expect_cmd, expect_len, rx_pos, sum; int main(void) /* Boot 主循环 */ { uint8_t b; UART1_Init(115200); while (1) { if (uart_rx_byte(&b)) { /* 收到一字节,逐字节喂状态机 */ if (rx_pos == 0 && b != 0x5A) continue; /* 帧头 0x5A */ if (rx_pos == 1 && b != 0xA5) { rx_pos = 0; continue; } /* 帧头 0xA5 */ rx_bufrx_pos++ = b; if (rx_pos == 4) { expect_len = rx_buf3; } /* LEN 定了,等 DATA */ if (rx_pos == 5 + expect_len) { /* 收满一帧(含 SUM) */ if (checksum_ok(rx_buf)) Dispatch(rx_buf); /* 校验过才分发 */ rx_pos = 0; } } } } ``` 上位机发帧同样按此格式组装,校验和与 Boot 同一算法: ```c /* 上位机(PC 工具)发送一帧:组装 + 校验和 */ void send_frame(uint8_t cmd, uint8_t *data, uint8_t len) { uint8_t sum = cmd + len; /* 累加 CMD + LEN + 全部数据 */ for (uint8_t i = 0; i < len; i++) sum += datai; sum = 0x100 - sum; /* 取两补码:0x100 只取低 8 位 */ uart_send(0x5A); uart_send(0xA5); /* 帧头 */ uart_send(cmd); uart_send(len); for (uint8_t i = 0; i < len; i++) uart_send(datai); uart_send(sum); /* 帧尾校验字节 */ } ``` 四条命令各司其职:**0x01 握手**------上位机先问,Boot 回自己的版本号和 App 当前状态(有效/无效),上位机据此决定「直接升级还是先擦」;**0x02 擦除**------擦掉 App 区全部 48 页(地址硬编码限定在 App 区,绝不擦 Boot);**0x03 写数据块**------载荷 4B 起始地址 + ≤252B 数据,Boot 写入后**回读比对**,不符回 0x55;**0x04 完成**------写 magic 0xA5A5A5A5,软复位,让 Boot 重新体检进 App。 ## 四、升级时序:掉电窗口里断电,下次照常能升级 把整个升级过程摊开在时间轴上,每帧一问一答、步步有回执: !升级时序:握手 → 擦除 → 逐帧写入 → 写 magic → 重启(https://i-blog.csdnimg.cn/direct/ba999ddfc8e34cffb595f8034777b5be.png) > 配图说明:橙区为掉电窗口(擦除完成到 magic 写回之间),期间掉电 App 必为无效态;写帧窗口内每帧回读校验。 设计上有一个刻意的顺序:**先擦除,后写帧,最后写 magic**。magic 在最开始擦除时就没了,所以从「擦除完成」到「magic 写回」的整个窗口内,任何时候掉电,重启后体检必失败 → Boot 留在串口等重刷,**绝不会跳进半成品 App**------升级掉电不白写、不写坏,这是 magic 方案比「读版本号判断」可靠的原因:版本号数字存在就是 0,程序只有一半也能编出个版本号。整条链路唯一不可挽回的事故是**擦掉 Boot 区**(变砖,只能重新烧录),所以擦除命令的范围在 Boot 代码里硬编码死,上位机传什么地址都不越界。 ## 五、Flash 擦写:解锁、半字写、回读校验 App 区是 Flash,写 Flash 有硬规矩:**只能 1→0,写前必须整页擦除**(擦完变全 0xFF),最小写单位是半字(2 字节),每次操作都要轮询 BSY 位。标准流程五步: !Flash 擦写五步:解锁 → 擦页 → 半字写 → 回读校验 → 加锁(https://i-blog.csdnimg.cn/direct/7c7c38a2c3eb4a43bc9de49255f3ddd6.png) > 配图说明:1KB 页 = 512 个半字;写一块 252B 数据 = 126 个半字循环,右侧网格演示写进度。 ```c void Flash_WriteBlock(uint32_t addr, uint8_t *data, uint16_t len) { FLASH_Unlock(); /* ① 写 KEY1=0x45670123, KEY2=0xCDEF89AB */ while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET); /* 等空闲 */ FLASH_ErasePage(addr & 0xFFFFFC00); /* ② 擦所在页(1KB 页对齐) */ while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET); for (uint16_t i = 0; i < len; i += 2) { /* ③ 半字循环写 */ FLASH_ProgramHalfWord(addr + i, (uint16_t)(datai | (datai + 1 << 8))); while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET); } /* ④ 回读逐半字比对,不符 → 回 0x55 让上位机重发本帧 */ for (uint16_t i = 0; i < len; i += 2) { uint16_t w = *(volatile uint16_t *)(addr + i); if (w != (uint16_t)(datai | (datai + 1 << 8))) { FLASH_Lock(); return; /* 校验失败,上位机收到 0x55 重发 */ } } FLASH_Lock(); /* ⑤ 加锁:恢复写保护,防误写 */ } ``` 三个坑要提前避开:**擦除地址必须 1KB 页对齐**(C8T6 是 1KB/页,大容量芯片才是 2KB/页,写死 2KB 会错位误擦);**72MHz 主频下必须 FLASH_SetLatency(2)**,不设读 Flash 会取错数据;**擦写期间别开中断**------中断代码在 Flash 里执行时总线冲突,标准库的 Program/Erase 函数内部会处理大部分,但自己的代码别在这段里触发中断。回读校验是最后一道保险:Flash 物理写失败(电压波动、老化)不常见但会发生,每帧核对一次,把损坏控制在单帧 252B 内。 ## 六、六个最容易翻车的点 !IAP 六个最容易翻车的点(https://i-blog.csdnimg.cn/direct/c93d0d6dc7404c289186ca9dca2e9d39.png) 1. **跳转前没关中断**:跳转瞬间中断从旧向量表查入口,直接 HardFault------`__disable_irq()` 且外设中断逐个关。 2. **向量表偏移没设 / 设错**:`SCB->VTOR = 0x08004000` 必须在 App 里再设一次;偏移必须 0x200 对齐且与链接地址一致,否则中断全乱。 3. **栈指针没校验就跳**:App 首字不是合法 SRAM 地址就跳------跳到废数据,`(首字 & 0x2FFE0000) == 0x20000000` 先验再跳。 4. **页大小写错 / 擦除越界**:C8T6 是 1KB/页,地址不按页对齐会误擦;擦除范围不限定 App 区 = 擦掉 Boot = 变砖。 5. **升级掉电没保护**:没有 magic 标记就跳半成品 App------写完成功后最后一步写 0xA5A5A5A5,Boot 体检不通过就不跳。 6. **App 里忘开中断 / 忘重配时钟**:Boot 里 `__disable_irq()` 了,App 启动要 `__enable_irq()`;SysTick 和外设时钟在 App 里重新配。 ## 小结 IAP 的本质是把「升级」做成出厂自带的例行能力:**Boot 体检 + 串口协议 + Flash 擦写**三件套,配上 magic 标记保证任何时刻掉电都不写坏。工程上真正决定成败的往往不是协议,而是跳转时序(关中断、VTOR、MSP 三步缺一不可)和擦除边界(硬编码限定 App 区)。最后留个彩蛋:F103 出厂自带一个更简单的升级通道------BOOT0 拉高、BOOT1 拉低,芯片从 0x1FFFF000 的**系统存储区**启动,里面是 ST 出厂写死的 ISP 程序,用串口工具发 0x7F 同步、收到 0x79 应答就能刷全片------不用自己写 Boot 也能救砖,适合量少的小批量;做产品还是要自己的 Bootloader,因为它能把升级逻辑(校验、加密、版本管理)都攥在自己手里。 下篇回到外设:K1 任务堆栈实战。上篇的看门狗和这次的 Bootloader 都是裸机视角,任务堆栈讲的是 uC/OS-II 内核里「栈会不会爆、爆了怎么救」,系列交叉内容一次讲透。
相关推荐
Lost of 程序猿27 分钟前
ASP.NET Core 多租户架构深度实战:一套系统,管理一支船队深念Y36 分钟前
视频平台架构重构:从微服务到可插拔基础设施汤姆yu38 分钟前
基于Django的学习资料共享与智能推荐系统Jesse_EC41 分钟前
从 getStore() 到 execute() —— 单连接 IMAP 管理器的五次迭代记录摇滚侠42 分钟前
《SpringBoot 3:入门与应用实战》第 9 章 使用 WebMvc 开发应用 阅读笔记 20AI多Agent协作实战派1 小时前
AI多Agent协作系统实战(五十三):同一套系统,Linux沉默,Windows刷屏那咋乎吧1 小时前
TCP状态机11个状态全梳理(以java netty 结合bio,nio, io多路复用模型为线索在linux上梳理)用户7813667114451 小时前
RGW 对象多版本功能系统架构与代码解析Z思学1 小时前
Nest 第一步 · 第 3 篇:理解 Controller / Service / Module 三层架构