LV005-IAP编程简介
一、STM32编程方式
1. 在线编程
1.1 基本概念
ICP,全称为In-Circuit Programming,即在线编程。是通过 JTAG或者SWD 协议或者系统加载程序(Bootloader)下载用户应用程序到微控制器中。这种方式就是我们平时用的下载程序方式,就是通过JTAG下载器将我们通过Keil生成的hex文件下载到STM32开发板中。
注意:这里我也不知道教程为什么叫这个,前边学习启动方式的时候也有提到过,它其实也可以称为 In System Program,也就是ISP,主要依靠ST官方在芯片内固化的BootLoader来接收来自串口的程序。
1.2 下载流程

从串口下载过来的程序会通过ST固化在系统存储器的Bootloader程序写入到对应的Flash地址中,但是JTAG或者SWD可以直接将程序烧写到指定的Flash地址中。
2.应用编程
2.1基本概念
IAP,全称为In Application Programming,即在应用程序中编程,就是应用编程。它是通过任何一种通信接口(如IO口、USB、CAN、UART、I2C、或者SPI等)下载程序或者应用数据到存储器中。也就是说,STM32允许用户在应用程序中重新烧写闪存存储器中的内容。但是,IAP 需要至少有一部分程序已经使用 ICP 方式烧到闪存存储器中(Bootloader)。通过这种方式可以在不需要操作硬件平台的情况下实现升级(远程)。
2.2下载流程

(1)编写一个用户自己的Bootloader,通过ICP的方式下载到FLASH中。
(2)编写一个用于IAP应用编程的APP程序,这个程序是用户的Bootloader来进行接收,可以是串口、I2C、SPI、CAN、USB等,接收的数据可以写入SRAM或者FLASH,比较常用的就是先写入SRAM进行缓存,接收完毕后校验是否完整,然后再决定是否要写入FLASH中。这整个过程都是用户的Bootloader来完成的。
3. FOTA升级
如果设备有无线通信只功能,应用系统运行起来后,通过无线通信的方式下载程序进行程序升级是-个主常理想的方式。事实上,带无线通信功能的产品大多数也是采用这种升级方式,比如,手机。通过无线通信式进行升级的方式称之为OTA(Over The Air),通常称之为远程升级。
OTA分为两类:SOTA(Sofware OTA)和FOTA(Firmware OTA):
SOTA就是只对应用软件进行升级,比如Android 的 APP软件进行升级。它只更新上层应用,影响特定功能,风险较低,比较灵活。
FOTA就是对底层固件进行升级,比如,手机的ROM固件。它会更新底层固件,影响整个系统,需要Bootloader,像单片机IOT设备使用较多。
对于单片机来说,我们通过网口,wifi等进行更新程序,更加精确的说法是 FOTA。
3.1 基本概念
OTA是Over the Air的缩写,顾名思义通过空中方式升级,所谓“空中”指的是远程无线方式,OTA可以理解为一种远程无线升级技术。
FOTA 是 Firmware Over the Air 的缩写,即固件空中升级,最常见的就是手机固件的升级。固件的定义范围比较模糊,windows操作系统升级、手机升级、嵌入式系统、单片机控制程序等都的远程升级可以笼统地称为FOTA。没有特别说明的情况下,FOTA可以认为是一切远程升级的统称。
3.2 升级流程
对于单片机来说,其实使用的也是应用编程的逻辑,只是bin文件的来源并不是通过串口、USB等方式,而是变成了通过wifi、WLAN等从服务器下载下来,下载下来后,剩下的更新逻辑就和应用编程一样了。这一部分重点还是应用编程,后续再深入学习FOTA。
二、IAP编程
1. IAP简介
前边已经提过,IAP(In Application Programming)即在应用编程, IAP 是用户自己的程序在运行过程中对 User Flash 的部分区域进行烧写,目的是为了在产品发布后可以方便地通过预留的通信口对产品中的固件程序进行更新升级。
通常实现 IAP 功能时,即用户程序运行中作自身的更新操作,需要在设计固件程序时编写两个项目代码,第一个项目程序不执行正常的功能操作,而只是通过某种通信方式(如 USB、 USART)接收程序或数据,然后执行对第二部分代码的更新;第二个项目代码才是真正的功能代码。这两部分项目代码都同时烧录在 User Flash 中,当芯片上电后,首先是第一个项目代码开始运行,它会做如下操作:
- (1)检查是否需要对第二部分代码进行更新。
- (2)如果不需要更新则转到(4)。
- (3)执行更新操作。
- (4)跳转到第二部分代码执行。
第一部分代码必须通过其它手段,如 JTAG 或 ISP 烧入;第二部分代码可以使用第一部分代码 IAP 功能烧入,也可以和第一部分代码一起烧入,以后需要程序更新时再通过第一部分 IAP 代码更新。
我们将第一个项目代码称之为 Bootloader 程序,第二个项目代码称之为 APP 程序,它们存放在 STM32中FLASH 的不同地址范围,一般从最低地址区开始存放 Bootloader,紧跟其后的就是 APP 程序(注意,如果 FLASH 容量足够,是可以设计很多 APP 程序的)。
2. 正常应用程序执行
Tips:这里中断向量表起始地址表述不准确,起始地址应当是在栈顶地址这里,在 10-基础篇/20-STM32的启动/LV001-启动文件.md这里有说明的。
正常情况下,程序执行的过程如下图所示:

STM32 的内部闪存(FLASH)地址起始于 0x0800 0000,一般情况下,程序文件就从此地址开始写入。此外 STM32 是基于 Cortex-M3 内核的微控制器,其内部通过一张“中断向量表”来响应中断,程序启动后,将首先从“中断向量表”取出复位向量执行复位中断程序完成启动。这张“中断向量表”的起始地址是 0x08000000(表的第一个字是初始栈顶地址 MSP,0x08000004 处才是复位向量表项);当中断来临,STM32 的内部硬件机制会根据向量表基址寄存器(SCB->VTOR,复位后默认指向 0x08000000)定位到“中断向量表”,并根据中断源取出对应的中断向量执行中断服务程序。
(1)STM32 在复位后,硬件先从 0x08000000 取出初始栈顶地址装入 MSP,再从 0x08000004 地址取出复位向量的地址,并跳转到复位中断服务程序,如图标号①所示;
(2)在复位中断服务程序执行完之后,会跳转到我们的 main 函数,如图标号②所示,main函数在Reset_Handler中被调用;
(3)我们的 main 函数一般都是一个死循环,在 main 函数执行过程中,如果收到中断请求(发生中断),此时 STM32 硬件按 VTOR 指向的中断向量表取出对应中断源的向量,如图标号③所示;
(4)根据中断源进入相应的中断服务程序,如图标号④所示;
(5)在执行完中断服务程序以后,程序再次返回 main 函数执行,如图标号⑤所示 。
3. 加入APP应用程序
我们加入APP后,程序执行的过程如下图:

Tips:这里中断向量表起始地址表述不准确,起始地址应当是在栈顶地址这里,在 10-基础篇/20-STM32的启动/LV001-启动文件.md这里有说明的。
(1)STM32 复位后,硬件先从 0x08000000 取出初始栈顶地址装入 MSP,再从 0x08000004 地址取出复位向量的地址,并跳转到复位中断服务程序,在运行完复位中断服务程序之后跳转到 IAP(Bootloader)的 main 函数,如图标号①所示,
(2)在执行完 IAP 以后(即将新的 APP 代码写入 STM32 的 FLASH,新程序的中断向量表起始地址为 0x08000000+X+M,其复位向量位于 0x08000004+X+M),IAP 先取新向量表第一个字设置好 MSP,再取出新程序的复位向量地址,跳转执行新程序的复位中断服务程序,随后跳转至新程序的 main 函数,如图标号②和③所示,
(3)同样 main 函数为一个死循环,此时 STM32 的 FLASH 在不同位置上共有两个中断向量表。APP 启动时会将 SCB->VTOR 设置为新向量表基址(偏移量 X+M,需满足 0x200 对齐)。在 main 函数执行过程中,如果 CPU 得到一个中断请求,硬件会按 VTOR 直接到新程序的中断向量表中取向量,而不是旧向量表,如图标号④所示;
(4)程序根据中断源,从新向量表中取出对应向量,跳转到新的中断服务程序中,如图标号⑤所示;
(5)在执行完中断服务程序后,程序返回 main 函数继续运行,如图标号⑥所示。
通过以上两个过程的分析,我们知道 IAP 程序必须满足两个要求:
- 新程序必须在 IAP 程序之后的某个偏移量为 M 的地址开始;
- 必须将新程序的中断向量表相应的移动,移动的偏移量为 X+M(通过设置 SCB->VTOR 实现,且偏移量必须满足 0x200 对齐);
4. 跳转执行的原理
上面提到的“跳转到 APP 执行”,在代码上就是通过一个函数指针 + 重设栈顶指针实现的(具体实现在 LV010-IAP编程基础示例 的 iap_load_app() 中)。要理解它为什么能这样工作,需要从 Cortex-M 内核的复位启动流程说起。
4.1 向量表的结构
前面说过,复位后硬件会“取出栈顶地址装入 MSP,再取出复位向量跳转执行”。硬件的这个取数动作,位置是固定的:从向量表的第 0 个字取初始 MSP,从第 1 个字取复位向量。也就是说,一个 APP 程序的 bin 文件开头 8 个字节,天然就是这样的结构:
| 偏移 | 内容 | 说明 |
|---|---|---|
| +0x00 | 初始栈顶地址(MSP 初值) | 由启动文件中的 __initial_sp 决定 |
| +0x04 | 复位向量(Reset_Handler 的地址) | 最低位为 1,表示 Thumb 状态 |
| +0x08 | NMI 向量、后续各中断向量 | …… |
由于我们编译 APP 时已经把起始地址设置为 0x08000000+X(如 0x08010000),所以这个向量表被整体搬到了 APP 的起始地址处。APP 起始地址处放的第一个字就是 APP 自己的栈顶地址,第二个字就是 APP 的复位中断服务程序地址。
4.2 跳转代码的本质:把"取栈顶、取复位向量"这两步照做一遍
Cortex-M 复位时硬件做的事情是:MSP ← *(0x00000000)、PC ← *(0x00000004)。跳转代码做的,就是把这两个动作“手动”对 APP 的向量表再来一遍:
void iap_load_app(uint32_t appxaddr)
{
if (((*(__IO uint32_t *)appxaddr) & 0x2FFE0000) == 0x20000000) // 检查栈顶地址是否合法
{
jump2app = (pIAPFunc)(*(__IO uint32_t *)(appxaddr + 4)); // 取APP复位向量作为函数指针
__set_MSP(*(__IO uint32_t *)appxaddr); // 取APP向量表第一个字作为MSP初值
jump2app(); // 跳转执行,等效于复位后进入APP的Reset_Handler
}
}逐行对应关系如下:
(*(__IO uint32_t *)appxaddr) & 0x2FFE0000) == 0x20000000:取出 APP 向量表第一个字(即 APP 的栈顶地址),检查它是否落在 SRAM 地址范围内(0x2000 0000 ~ 0x2001 FFFF),防止跳转到一个没有烧写程序、内容全为 0xFF 的区域导致跑飞;jump2app = *(__IO uint32_t *)(appxaddr + 4):把 APP 向量表第二个字(复位向量)取出来强制转换成函数指针。调用一个函数本质就是把 PC 指向该函数地址,这里等于让 PC 指向 APP 的 Reset_Handler;__set_MSP(*(__IO uint32_t *)appxaddr):把 APP 的初始栈顶地址写入 MSP;jump2app():执行函数调用,CPU 就从 Bootloader 的代码流切换到了 APP 的 Reset_Handler,后续 APP 会依次执行 SystemInit()(在其中设置 SCB->VTOR 指向 APP 自己的向量表)→ __main(完成数据搬运和 ZI 段清零)→ main()。
从这一刻起,APP 的走向与"上电直接从 0x08000000+X 启动"是同一条软件路径,但芯片本身并没有复位:跳转只是把 MSP、VTOR、PC 三个值换成了 APP 的,外设寄存器、时钟树、SRAM 内容、中断使能全都停在 Bootloader 离开时的样子。这个区别决定了很多后续代码的写法,见下文 4.6 节。
注意:图中从
iap_load_app()进入 APP 的Reset_Handler这一段是软件跳转,没有经过芯片复位。它只是把MSP、SCB->VTOR、PC换成了 APP 的值,外设、时钟、SRAM 内容一律保持原样。
4.3 为什么必须重设栈顶指针
跳转前把 MSP 换成 APP 的栈顶,不是“顺手做一下”,而是必须做。原因要从 MSP 此刻的值说起:
- MSP 当前值并不是“栈顶”:Bootloader 走到
iap_load_app()时,MSP 已经被若干层函数调用压低了,它是“栈顶 − Bootloader 当前调用深度”。把这个值原样带进 APP,等于 APP 一开局就少了一截可用的栈,而少多少取决于 Bootloader 恰好嵌套多深——这种依赖完全不可控; - APP 的栈顶只记录在它自己的向量表里:APP 链接时由启动文件里的
__initial_sp决定,编译完成就固化成 APP 向量表的第 0 个字。除了 Bootloader 替它取出来装进 MSP,没有任何别的时机能把这个值交给硬件; - 链接时的假定会被破坏:APP 是按自己链接脚本里的 RAM 布局算好栈区间(以及
.data/.bss/ 堆)的。若沿用 Bootloader 的 MSP,APP 以为的栈范围与实际指针不符,栈向下生长时就会越界,压到自己的.data/.bss,或者反向挤进 Bootloader 的变量区; - 两者栈区本来就可能不重合:Bootloader 小、APP 大,或两者被规划在不同 RAM 段(例如各自在 CCM / 普通 SRAM 里放栈)时,沿用旧 MSP 不是“空间少一点”,而是彻底错位。
注意区分两件事:
__set_MSP()换的是指针,不是内容。旧栈里的数据仍留在原地,只是从此不再被当作栈使用——这一点在 4.6 节还会再说一次。
4.4 为什么顺序不能颠倒
上面说的是“必须换”,这一条说的是“必须换在跳转之前”,顺序不能反过来:
jump2app()本身就是一次普通的 C 函数调用。编译器会把它编译成 BL 指令,BL 在跳转前会把返回地址(LR)压入当前的栈中。如果此时 MSP 还指着 Bootloader 的栈甚至未初始化的内存,这次压栈就会破坏数据;所以必须先把 MSP 换成 APP 的栈顶地址,再发起调用;- 反过来“先跳过去再设 MSP”在代码上根本写不出来:跳转之后执行的已经是 APP 的
Reset_Handler,APP 的第一条 C 代码就已经在用自己的栈了,不存在“等它跑起来我再帮它设栈”的时机。
因此顺序只有一种正确写法:校验镜像 → 取复位向量 → 换 MSP → 跳转,这也正是 iap_load_app() 的行序。
4.5 为什么跳转之后就不会再返回
从流程上看,jump2app() 和普通函数调用一样保留了“返回”的可能,但实际上不会返回:
- jump2app 指向的是 APP 的 Reset_Handler,它最终进入 APP 的 main,而 main 是一个死循环,永不退出;
- APP 启动后 SystemInit() 会把 SCB->VTOR 重新指向 APP 自己的向量表,此后所有中断都由 APP 响应,整个程序的控制权已经完全交给 APP;
- 即使代码流程真的“返回”到 Bootloader,此时 MSP 已被 APP 大量使用,Bootloader 的 C 运行环境早已不复存在,返回也没有意义。
另外需要注意:函数地址(复位向量)的最低位为 1 是 Thumb 状态标志,函数指针调用 / BX 指令会自动据此切换处理器状态,我们不需要手动处理。
4.6 这不是一次复位
看图容易留下一个错误印象:IAP 好像又让芯片复位了一次,然后从头启动。并没有。硬件复位做的事情远不止"取栈顶、取向量、跳到 Reset_Handler",跳转代码只把中间最表面的那两步用软件重做了一遍:
| 对比项 | 硬件复位 | IAP 跳转 |
|---|---|---|
| 触发方式 | RESET 引脚、上电、看门狗等硬件事件 | Bootloader 里的一段普通代码 |
| 作用范围 | 整个芯片,几乎所有寄存器强制回默认 | 只动 MSP、VTOR、PC 三个寄存器 |
| 启动序列 | 硬件完整执行:上电时序、时钟稳定等待、BOOT 引脚采样 | 只做取 MSP、取 PC 两步 |
| 向量表位置 | 硬件固定在 0x00000000,再由启动区映射决定 | 软件按帧头地址去 APP 区取 |
| VTOR | 硬件回默认基址 | 不自动改,必须软件写 |
| 外设寄存器 | 全部回默认值 | 保持 Bootloader 的配置 |
| RCC / 时钟树 | 回 HSI 8 MHz,等软件重配 | 继续保持 PLL 配置 |
| DMA、NVIC 使能 | 关闭 | 保持 |
| 挂起中断 | 清除 | 保留,需软件清 |
| SRAM 与栈内容 | 上电为随机值,由 __main 清 ZI | 原样保留,不清零 |
| IWDG | 停止 | 继续计数,软件关不掉 |
SystemInit / __main | 由启动文件自动执行 | 由 APP 自己的 Reset_Handler 走到,CPU 并未处于复位态 |
更准确的定性是:IAP 跳转是一次受控的软件控制权转移(可以叫它伪复位)。它只完成让 CPU 开始执行 APP 的最小必要条件:取 APP 向量表第 0 个字装进 MSP、把第 1 个字交给 PC,再顺手把 VTOR 指过去。前两个动作正是硬件复位启动序列的开头两步,除此之外的一切都没有被复位——芯片仍停在 Bootloader 运行结束时的那套配置上。
顺带说明启动地址:STM32F1 上
0x00000000是启动区别名,实际映射到 BOOT 引脚选定的 Flash / SRAM / 系统存储器。从 Flash 启动时,硬件真正取向量的是0x08000000,两者等价。
【必须手动补上的六件事】
| 要点 | 不做会怎样 | 应该怎么做 |
|---|---|---|
| 关全局中断 | 换栈与跳转之间被中断打断,处理函数还指着旧向量表 | 跳转前 __disable_irq(),跳完 __set_PRIMASK(0) 放开 |
| 停掉已使能的中断源并清挂起 | 旧中断在 APP 里立刻被响应,而此时入口还没准备好 | 关掉 Bootloader 打开过的中断源(SysTick 的 CTRL / LOAD / VAL 清零、NVIC 通道 DisableIRQ),并用 ClearPendingIRQ 清掉挂起位 |
| 设置 VTOR | 中断仍按 Bootloader 的向量表找入口,几乎必然 HardFault | 跳转前写 SCB->VTOR 指向 APP 向量表基址;APP 的 SystemInit() 再设一次 |
| 校验镜像有效 | 跳到擦除后全 0xFF 的区域,直接跑飞 | 查向量表第 0 字(初始 MSP)是否落在 RAM 区间内,第 1 字(复位向量)是否落在代码区且最低位为 1 |
| 处理外设与时钟残留 | 共用外设配置冲突,APP 初始化可能失败 | 共用且配置不同的外设先 HAL_xxx_DeInit() 或直接复位该外设;其余至少保证 APP 启动时显式重配 |
| 看门狗 | 没人喂,超时后复位 | 能关的(如 WWDG)跳转前关掉;关不掉的(IWDG)必须在跳转前交代给 APP 接手喂狗 |
上表有三处容易走形的地方,单独说清:
- 外设要不要逐个
DeInit():取决于 Bootloader 与 APP 是否共用外设。共用且配置不同(典型是串口、FSMC、定时器)时,跳转前必须 DeInit 或复位该外设,否则 APP 会在半配置状态下初始化失败;不共用时,只要 APP 启动后完整执行自己的初始化序列,可以不逐个 DeInit。共同前提是APP 不能假设任何寄存器还停在上电默认值。 - VTOR 设在哪一步:稳妥做法是两处都设——跳转前设一次,让跳转动作自身完整;APP 的
SystemInit()再设一次,覆盖不经 Bootloader 直接加载 APP 的场景。只设一处时必须保证:从跳转到设置完成之间,没有任何中断被使能。 - 换栈不等于栈干净:
__set_MSP()只改栈指针,旧栈内容仍在。这不影响正确性,但有两个前提:链接脚本要把 Bootloader 与 APP 的 RAM 段规划开,且跳转后不再引用 Bootloader 的全局变量——它们随时可能被 APP 的.data/.bss覆盖。
还有一层含义:没有复位,跳转就是一扇单向门。控制权一旦交出去,Bootloader 的 C 运行环境已经被覆盖,回不去;想重新进 Bootloader 只能 reboot 走一次真正的复位。