做嵌入式的同学一定听过 IAP,但很多人一开始搞不明白:程序不是用烧录器下载的吗?怎么自己给自己升级?
本文从最基础的原理讲起,用最直白的方式讲清楚 IAP 到底是什么、为什么能实现、怎么实现。读完你就能自己写一个最简单的 Bootloader。
📌 什么是 IAP?
IAP = In-Application Programming(在应用中编程),通俗说就是单片机自己给自己烧程序。
正常情况下,我们用 ST-Link、J-Link 这种烧录器把程序下载到单片机里。但产品卖出去之后,总不能让客户把壳拆了、接个烧录器升级吧?
IAP 就是解决这个问题的:产品已经在用户手里了,也能通过串口/网络/蓝牙等方式升级固件。
🔹 一、IAP 的核心原理:两个程序分工
IAP 能实现的核心秘密是:Flash 里同时放着两个程序。
- Bootloader(引导程序):上电第一个运行的小程序,负责判断「我是直接跳去 App,还是先升级固件?」
- App(应用程序):用户的主程序,也就是正常功能的固件
💡一句话理解:Bootloader 是「烧录工具」,App 是「被烧的程序」。只不过这个烧录工具也在芯片里面,上电先运行它。
上电后发生了什么?
单片机上电 ↓ 运行 Bootloader ↓ 判断:需要升级吗? ├─ 需要 → 接收新固件 → 写到 App 区 → 跳去 App └─ 不需要 → 直接跳去 App就这么简单。Bootloader 就是个「入口保安」,决定让新固件进来,还是直接放行去主程序。
🔹 二、Flash 分区:地址怎么分?
Bootloader 和 App 都在 Flash 里,怎么区分?靠地址划分。
- Bootloader放在 Flash 最开头,从
0x08000000开始,占 16KB - App紧跟在后面,从
0x08004000开始,占剩下的 240KB
为什么 Bootloader 要放最开头?因为 Cortex-M 内核上电后,默认从 Flash 起始地址(
0x08000000)取中断向量表,然后从复位向量开始执行。所以 Bootloader 必须在最前面。
Bootloader 工程的地址设置
分好区之后,两个工程(Bootloader 和 App)的链接地址要分别设置,不能都从0x08000000开始,否则会重叠。
Bootloader 工程:保持在 Flash 最开头,不用改。以 STM32 + Keil 为例:
IROM1 Start: 0x08000000, Size: 0x4000 (16KB)- Start =
0x08000000:Flash 的起始地址,Bootloader 从这里开始 - Size =
0x4000(16KB):Bootloader 自己能用的空间大小
⚠️关键点:这里的
Size不能随便写,它必须和 App 的起始地址对得上。Bootloader 占 16KB(0x4000),App 就必须从0x08000000 + 0x4000 = 0x08004000开始。两边一旦对不上,跳转就会跑飞。
💡Bootloader 大小怎么定?先按经验给个值(比如 16KB 或 32KB),把功能写完编译一下,看看实际占了多少,再留 20%~30% 余量。Bootloader 功能越多(加密、双备份、协议栈),占的空间越大。
App 工程的地址设置和 Bootloader 是配套的,我们在第四节详细讲。
🔹 三、Bootloader 怎么跳转到 App?
这是 IAP 最核心的操作之一:Bootloader 执行完了,怎么跳到 App 去运行?
跳转的原理
Cortex-M 的启动过程是这样的:
- 从 Flash 起始地址读第 1 个 4 字节 → 作为栈顶地址(MSP)
- 从 Flash 起始地址读第 2 个 4 字节 → 作为复位中断的入口地址
- 设置 MSP,然后跳到复位中断地址执行
Bootloader 跳转到 App,本质上就是模拟这个启动过程。
只不过 App 的中断向量表不是从0x08000000开始,而是从0x08004000开始。所以我们要:
- 从 App 的起始地址读栈顶地址,设置 MSP
- 从 App 的起始地址读复位向量,跳过去
跳转代码(STM32 示例)
// App 的起始地址(根据你的分区来改)#defineAPP_START_ADDR0x08004000typedefvoid(*pFunction)(void);// 函数指针类型voidJumpToApp(void){pFunction Jump_To_Application;uint32_tJumpAddress;// 1. 检查 App 是否有效:栈顶地址应该在 RAM 范围内// (STM32F103 的 RAM 地址是 0x20000000 ~ 0x20005000)if(((*(__IOuint32_t*)APP_START_ADDR)&0x2FFE0000)==0x20000000){// 2. 获取 App 的复位中断向量地址(App 起始地址 + 4 字节)JumpAddress=*(__IOuint32_t*)(APP_START_ADDR+4);Jump_To_Application=(pFunction)JumpAddress;// 3. 设置 App 的栈顶地址__set_MSP(*(__IOuint32_t*)APP_START_ADDR);// 4. 跳转!Jump_To_Application();}}💡为什么要检查栈顶地址?因为如果 App 区是空的(全是 0xFF),读出来的值就是 0xFFFFFFFF,这显然不是一个合法的栈地址。这时候跳过去就是跑飞,所以先检查一下,不合法就不跳。
跳转前别忘了做清理
跳转到 App 之前,Bootloader 要把自己用过的硬件都复位干净,不然 App 运行起来可能出问题:
- 关闭所有中断(NVIC)
- 关闭所有外设(串口、SPI、GPIO 等)
- 把系统时钟复位到默认状态
- 清除所有中断挂起位
否则 App 启动后,可能会遇到莫名其妙的中断或者外设异常。
🔹 四、App 程序要改什么?
App 程序不能直接用原来的工程,需要改两个地方:起始地址和中断向量表偏移。
1. 修改 App 的链接地址
以 STM32 + Keil 为例:
原来的设置(App 从 0x08000000 开始):
IROM1 Start: 0x08000000, Size: 0x40000 (256KB)改完之后(App 从 0x08004000 开始):
IROM1 Start: 0x08004000, Size: 0x3C000 (240KB)也就是把 Flash 的前 16KB(0x4000)让给 Bootloader,App 从后面开始。
2. 设置中断向量表偏移
Cortex-M 默认从 Flash 起始地址(0x08000000)取中断向量。但 App 的中断向量表在0x08004000,所以要告诉 CPU:「我的向量表不在默认位置,偏移了 0x4000」。
在 App 的main()函数最开头加一行:
// 设置中断向量表偏移:从 Flash 偏移 0x4000SCB->VTOR=FLASH_BASE|0x4000;或者用 ST 标准库 / HAL 库的函数:
// HAL 库HAL_NVIC_SetVectorTable(NVIC_VectTab_FLASH,0x4000);// 标准库NVIC_SetVectorTable(NVIC_VectTab_FLASH,0x4000);⚠️这一步非常重要!不改的话,App 运行时触发中断会跑到 Bootloader 的向量表去,结果就是莫名其妙的崩溃或者死机。
🔹 五、怎么升级?接收固件写到 App 区
前面讲了 Bootloader 怎么跳 App,现在讲升级的核心:怎么把新固件写进 App 区?
基本思路
- Bootloader 等待接收固件数据(比如通过串口)
- 收到一包数据,就往 App 区对应的地址写
- 全部写完后,校验一下对不对
- 校验通过,跳转到新的 App
传输方式可以是串口、CAN、网络等等,原理都一样:一边收数据,一边往 Flash 里写。为了把原理讲透,这里我们用一个最简单的自定义协议来演示。
简单自定义协议设计
每一包的格式如下:
┌──────────┬──────────┬─────────────┬──────────┐ │ 包头 │ 包序号 │ 数据内容 │ 校验和 │ │ (2字节) │ (2字节) │ (N字节) │ (1字节) │ └──────────┴──────────┴─────────────┴──────────┘- 包头:固定值(比如
0xAA 0x55),标记一包的开始,方便接收端对齐 - 包序号:从 0 开始递增,用来发现丢包、重复包
- 数据内容:真正的固件数据,比如每包 1KB
- 校验和:对这包数据做校验,防止传输出错
💡 这就是所有传输协议的核心套路:包头 + 序号 + 数据 + 校验。不管哪种协议,本质都是这个结构,只是细节更复杂。
升级流程
Bootloader 启动 ↓ 检测:有没有升级请求? ├─ 有 → 进入升级模式 └─ 没有 → 直接跳 App 升级模式: ① 等待上位机发送固件数据包 ↓ ② 收到一包 → 检查包头对不对 ├─ 不对 → 丢弃,继续等 └─ 对 → 继续 ↓ ③ 校验这包数据(校验和) ├─ 失败 → 回 NACK,要求重发 └─ 成功 → 继续 ↓ ④ 把数据写到 Flash 的 App 区(按包序号算好偏移地址) ↓ ⑤ 回 ACK,告诉上位机:这包收到了,发下一包 ↓ ⑥ 重复 ②-⑤,直到收完所有数据 ↓ ⑦ 校验整个 App 区的固件(CRC 校验) ├─ 通过 → 跳转 App └─ 失败 → 留在 Bootloader,等重新升级包序号有什么用?
包序号不只是用来排序,它还能解决两个实际问题:
- 丢包检测:收到序号 5,下一包直接来了序号 7,说明序号 6 丢了 → 要求重发
- 断点续传的基础:如果中途断电重启,Bootloader 可以从上次记录的序号继续接收,不用从头再来
💡 这就是「断点续传」的思路——记录已收到的包序号(或字节偏移量),重启后接着传。这个后面单独讲。
接收一包数据的代码框架
// 每包数据的最大长度#definePKG_DATA_MAX1024// 固件写入的起始地址#defineAPP_START_ADDR0x08004000// 处理一包固件数据:成功返回 0,失败返回 -1inthandle_firmware_pkg(uint8_t*pkg,uint16_tpkg_len){// 1. 检查包头if(pkg[0]!=0xAA||pkg[1]!=0x55){return-1;// 包头不对,丢弃}// 2. 取出包序号和数据长度uint16_tpkg_index=pkg[2]|(pkg[3]<<8);uint16_tdata_len=pkg_len-5;// 减去 包头2 + 序号2 + 校验1// 3. 校验这包数据(累加和取低 8 位)uint8_tchecksum=0;for(uint16_ti=0;i<pkg_len-1;i++){checksum+=pkg[i];}if(checksum!=pkg[pkg_len-1]){return-1;// 校验失败,要求重发}// 4. 按包序号算出这包要写到 Flash 的哪个地址uint32_twrite_addr=APP_START_ADDR+(uint32_t)pkg_index*PKG_DATA_MAX;// 5. 写入 Flashflash_write(write_addr,&pkg[4],data_len);return0;// 成功}💡地址怎么算的?
包序号 × 每包长度 + App 起始地址。第 0 包写到0x08004000,第 1 包写到0x08004400(0x400 = 1024)。每包都有固定位置,天然支持乱序和重传。
写 Flash 的注意事项
写 Flash 不是像写 RAM 那样直接赋值,要按 Flash 的操作流程来:
- 解锁 Flash:Flash 写操作默认是锁着的,要先解锁
- 擦除页/扇区:Flash 不能直接改,必须先擦除(变成全 0xFF)才能写
- 按字/半字写入:一次写 32 位或 16 位,要看具体芯片
- 等待操作完成:检查 FLASH_SR 寄存器的 BSY 位
- 锁定 Flash:写完再锁上,防止误写
// 伪代码:往指定地址写数据voidflash_write(uint32_taddr,uint8_t*data,uint32_tlen){// 1. 解锁 FlashFLASH_Unlock();// 2. 擦除要写的页(如果需要)// ... 根据地址计算页号,然后 FLASH_ErasePage()// 3. 按字写入for(uint32_ti=0;i<len;i+=4){uint32_tword=*(uint32_t*)(data+i);FLASH_ProgramWord(addr+i,word);}// 4. 锁定 FlashFLASH_Lock();}⚠️注意:擦除操作是按页(Page)或者扇区(Sector)来的,不能只擦一个字节。所以写入前要先算好地址在哪一页,别把不该擦的内容擦掉了。
🔹 六、Bootloader 怎么判断「要不要升级」?
前面讲了怎么收包、怎么跳转,但还有个关键问题没解决:Bootloader 上电后,怎么知道这次是要升级,还是直接跑 App?
这个判断逻辑是 IAP 的「大脑」。下面讲三种最常用的方式,从简单到复杂。
方式一:按键强制升级(最简单)
上电时如果检测到某个按键被按住,就进入升级模式;否则直接跳 App。
上电 / 复位 ↓ 检测按键是否按下(比如 PA0 拉低) ├─ 按下 → 进入升级模式,等上位机发数据包 └─ 没按 → 直接跳 App// 检查是否要进入升级模式:返回 1 表示要升级,0 表示不升级intCheckUpgradeRequest(void){// 上电后先延时一下,等电平稳定,顺便做消抖Delay_ms(50);// 按键按下是低电平,表示要强制升级if(GPIO_ReadInputDataBit(GPIOA,GPIO_Pin_0)==Bit_RESET){return1;}return0;}优点:简单可靠,不依赖 App 和上位机。就算 App 已经跑不起来,也能救回来。
缺点:需要人工操作,用户得能碰到设备、知道按哪个键。适合调试和售后返修。
💡 实际产品里这个方式几乎都会保留一份,作为最后的兜底手段。
方式二:App 收到升级请求 → 记录标志 → 重启进 Bootloader(最常用)
这是量产产品最常用的方式。核心思路是:App 正常运行时收到上位机的升级指令,把「要升级」这个标志记下来,然后主动重启;Bootloader 上电检测到这个标志,就知道该升级了。
① App 正常运行中 ↓ ② App 收到上位机的升级请求(串口 / 4G / WiFi 等) ↓ ③ App 把「要升级」标志写到 RAM(no-init 区)或备份寄存器 ↓ ④ App 调用 NVIC_SystemReset() 主动重启 ↓ ⑤ 重启后先跑 Bootloader ↓ ⑥ Bootloader 检查标志位 ├─ 标志有效 → 进入升级模式 → 回复上位机「开始升级」 │ → 收包 → 写 Flash → 校验 → 跳转新 App └─ 标志无效 → 直接跳 App关键在第三步和第六步:标志存在哪、怎么保证重启后还能读到?
标志存在哪里?
有两种常见做法。
做法 A:no-init RAM 区(简单,最常用)
C 语言里全局变量默认会被启动代码清零,所以不能直接用普通变量。要在链接脚本里专门划一块「不初始化」的 RAM 区:
// 约定的标志值(随便定,只要别和随机值撞上)#defineUPGRADE_MAGIC0x5A5A1234// 放在不初始化的 RAM 段,重启后内容保留// Keil 里通过 scatter file 指定到 no-init 段__attribute__((section(".noinit"),zero_init))uint32_tupgrade_flag;// App 里调用:请求升级voidApp_RequestUpgrade(void){upgrade_flag=UPGRADE_MAGIC;// 1. 写标志__DSB();// 2. 确保写入完成NVIC_SystemReset();// 3. 主动重启}// Bootloader 里调用:检查标志intCheckUpgradeRequest(void){if(upgrade_flag==UPGRADE_MAGIC){upgrade_flag=0;// 清掉标志,防止下次误判return1;// 要升级}return0;// 不升级}💡为什么要用 no-init 区?因为软件复位(
NVIC_SystemReset)不会清 RAM,内容还在。但启动代码会主动把.bss/.data段清零,所以标志必须放在不被清零的段里,才能活过重启。
做法 B:备份寄存器(BKP / RTC Backup Register)
STM32 有一组备份寄存器(BKP_DRx),由 VBAT 供电,连掉电都能保住:
// App 里调用:请求升级voidApp_RequestUpgrade(void){RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR|RCC_APB1Periph_BKP,ENABLE);PWR_BackupAccessCmd(ENABLE);BKP_WriteBackupRegister(BKP_DR1,UPGRADE_MAGIC);// 写备份寄存器NVIC_SystemReset();// 主动重启}// Bootloader 里调用:检查标志intCheckUpgradeRequest(void){RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR|RCC_APB1Periph_BKP,ENABLE);PWR_BackupAccessCmd(ENABLE);if(BKP_ReadBackupRegister(BKP_DR1)==UPGRADE_MAGIC){BKP_WriteBackupRegister(BKP_DR1,0);// 清标志return1;// 要升级}return0;// 不升级}💡两种做法的区别:no-init RAM 简单,但只在软件复位 / 看门狗复位时有效,真断电就没了;备份寄存器由 VBAT 供电,掉电也能保住,更适合「升级中途断电、重启后继续升级」的场景。按需求选。
优点:全自动,用户无感(App 里点个「检查更新」就行),适合远程 OTA。
缺点:依赖 App 正常工作。如果 App 已经跑不起来(上次升级失败、程序跑飞),这条路就走不通了——所以还得留个兜底(方式一)。
方式三:上电后短暂等待升级指令(折中)
每次上电,Bootloader 先等一小段时间(比如 3 秒),看看上位机有没有发升级指令:
上电 ↓ Bootloader 等待 3 秒,同时监听串口 ├─ 3 秒内收到升级指令 → 进入升级模式 └─ 3 秒内没收到 → 直接跳 AppintCheckUpgradeRequest(void){// 等待 3 秒,看串口有没有收到升级指令if(UART_WaitForCmd(3000)==CMD_UPGRADE){return1;}return0;}优点:不用按按键,也不用改 App,上位机随时能触发。
缺点:每次上电都要多等几秒,影响开机速度。适合调试阶段,或对开机时间不敏感的产品。
三种方式怎么选?
| 方式 | 触发方式 | 需要 App 配合 | 掉电可用 | 适用场景 |
|---|---|---|---|---|
| 按键强制 | 人工按键 | 否 | 是 | 兜底救砖、调试、返修 |
| App 触发 | 上位机指令 | 是 | 看标志存哪 | 量产远程 OTA |
| 上电等待 | 上位机指令 | 否 | 是 | 调试阶段 |
💡实战建议:方式一 + 方式二 组合。平时用 App 触发做远程升级(用户无感),万一 App 挂了,还能按住按键上电救回来。两个搭配起来,基本覆盖所有情况。
🔹 七、完整的 Bootloader 主逻辑
把上面的内容合起来,Bootloader 的main函数大概长这样:
intmain(void){// 1. 初始化系统时钟、GPIO、串口SystemClock_Config();UART_Init();// 2. 检查升级条件(比如某个引脚被拉低,或串口收到特定指令)if(CheckUpgradeRequest()){// 3. 进入升级模式:循环接收固件数据包if(Receive_Firmware()==OK){// 4. 全部接收完,校验整个 App 区if(CheckAppCRC()==OK){// 5. 校验通过,跳转到新 AppJumpToApp();}}// 升级失败,停在 Bootloader,等待重新升级while(1){// 可以闪个灯提示升级失败}}else{// 6. 不需要升级,直接跳 AppJumpToApp();}}其中Receive_Firmware()就是不断收包、调handle_firmware_pkg()写 Flash 的过程:
// 接收完整固件:成功返回 OK,失败返回 ERRORintReceive_Firmware(void){uint8_tpkg[PKG_DATA_MAX+5];while(1){// 1. 读一包数据(超时和长度检查在底层完成)uint16_tlen=UART_ReadPkg(pkg,sizeof(pkg));if(len==0){returnERROR;// 超时或读失败}// 2. 数据长度为 0 的结束包,表示传输结束if(len==5){returnOK;}// 3. 处理这一包if(handle_firmware_pkg(pkg,len)!=0){UART_SendNack();// 校验失败,要求重发}else{UART_SendAck();// 成功,请求下一包}}}整个升级流程就串起来了:收包 → 校验 → 写 Flash → 回 ACK → 下一包 → 收完 → 整体校验 → 跳转。
📝 总结
IAP 的原理其实很简单,核心就四件事:
- Flash 分区:Bootloader 在前,App 在后,两个工程的地址要分别设置且互相对得上
- 跳转机制:Bootloader 读完 App 的向量表,设置 MSP,跳复位向量
- 升级判断:上电后靠按键、标志位(no-init RAM / 备份寄存器)或等待指令,决定是升级还是直接跑 App
- 在线写入:通过串口(或其他方式)接收新固件,擦写 Flash 的 App 区
搞懂了这四点,IAP 就算入门了。后面的断点续传、双备份分区、回滚、加密签名等等,都是在这个基础上不断叠加的高级功能。
🚀 下一篇预告
本篇讲了最基础的 IAP 原理和实现。下一篇我们会讲:
- 怎么做到升级中途断电不砖
- 双分区备份方案
- 断点续传怎么实现
- 固件加密和签名防篡改
如果觉得有用,点赞 + 收藏 + 关注,不迷路~ 有问题欢迎评论区交流!
本文为原创内容,转载请注明出处。