news 2026/9/29 20:12:59

IAP 升级入门:从原理到实现,手把手搞懂单片机在线升级(入门篇)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
IAP 升级入门:从原理到实现,手把手搞懂单片机在线升级(入门篇)

做嵌入式的同学一定听过 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 的启动过程是这样的:

  1. 从 Flash 起始地址读第 1 个 4 字节 → 作为栈顶地址(MSP)
  2. 从 Flash 起始地址读第 2 个 4 字节 → 作为复位中断的入口地址
  3. 设置 MSP,然后跳到复位中断地址执行

Bootloader 跳转到 App,本质上就是模拟这个启动过程。

只不过 App 的中断向量表不是从0x08000000开始,而是从0x08004000开始。所以我们要:

  1. 从 App 的起始地址读栈顶地址,设置 MSP
  2. 从 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 区?

基本思路

  1. Bootloader 等待接收固件数据(比如通过串口)
  2. 收到一包数据,就往 App 区对应的地址写
  3. 全部写完后,校验一下对不对
  4. 校验通过,跳转到新的 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 的操作流程来:

  1. 解锁 Flash:Flash 写操作默认是锁着的,要先解锁
  2. 擦除页/扇区:Flash 不能直接改,必须先擦除(变成全 0xFF)才能写
  3. 按字/半字写入:一次写 32 位或 16 位,要看具体芯片
  4. 等待操作完成:检查 FLASH_SR 寄存器的 BSY 位
  5. 锁定 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 秒内没收到 → 直接跳 App
intCheckUpgradeRequest(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 的原理其实很简单,核心就四件事:

  1. Flash 分区:Bootloader 在前,App 在后,两个工程的地址要分别设置且互相对得上
  2. 跳转机制:Bootloader 读完 App 的向量表,设置 MSP,跳复位向量
  3. 升级判断:上电后靠按键、标志位(no-init RAM / 备份寄存器)或等待指令,决定是升级还是直接跑 App
  4. 在线写入:通过串口(或其他方式)接收新固件,擦写 Flash 的 App 区

搞懂了这四点,IAP 就算入门了。后面的断点续传、双备份分区、回滚、加密签名等等,都是在这个基础上不断叠加的高级功能。


🚀 下一篇预告

本篇讲了最基础的 IAP 原理和实现。下一篇我们会讲:

  • 怎么做到升级中途断电不砖
  • 双分区备份方案
  • 断点续传怎么实现
  • 固件加密和签名防篡改

如果觉得有用,点赞 + 收藏 + 关注,不迷路~ 有问题欢迎评论区交流!


本文为原创内容,转载请注明出处。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 20:11:52

考试是一场熵减竞赛

你这句话点破了一个很本质的东西。 考试确实是一场熵减竞赛,而且比“竞赛”更准确地说,是一场把无限知识压缩成有限答案的筛选游戏。 一、考题本身就是熵减的指令 考场上的每一道题,从命题人写出题目的那一刻起,就已经在收敛了。一道题看似开放,但阅卷标准里藏着一条明…

作者头像 李华
网站建设 2026/9/29 20:10:27

同质化内卷:线束企业如何跳出价格战?

同质化内卷&#xff1a;线束企业如何跳出价格战&#xff1f;线束行业的价格战&#xff0c;已经打到了"肉搏"的程度。"你报0.85元&#xff0c;我就报0.82元&#xff1b;你报0.82元&#xff0c;我就报0.80元。"——在招投标场景中&#xff0c;线束企业之间的…

作者头像 李华
网站建设 2026/9/29 20:09:42

激光振镜:原理、应用与选型指南

1. 引言 激光振镜(Galvo Scanner,全称 Galvanometer Scanner)是激光加工、激光打标、激光雕刻等设备中的核心光学扫描部件。它通过高速偏转激光光束,实现光斑在工件表面的快速定位与轨迹扫描,从而完成打标、切割、焊接、钻孔等加工任务。 相比传统的机械运动平台,激光振…

作者头像 李华