跳到主要内容

Bootloader

以STM32为例,讲讲Bootloader的工作,系统的启动流程。

1. Bootloader工作流程

芯片上电后,系统是怎么启动的?事实上是需要先寻址,再执行。从哪个地址开始执行代码?这是一个有意思的问题,而这个问题由Bootloader来决定答案。

STM32有三种启动模式,主要是启动区域的区别:

  1. Main Flash memory:主FLASH
  2. System memory:系统存储器
  3. Embedded SRAM:内置SRAM

X表示引脚电平可以为任意值(0或1), BOOT1在此模式下无效。

这三种启动模式是可以选择的,通过BOOT引脚和BOOT位来选择。BOOT引脚通常是BOOT0或者BOOT1。一般会有跳线帽,可供选择。这是完全依赖Reset(即系统复位)结束时的引脚电平决定的。所以即便你在软件中配置了,也必须完整Reset一次才能生效。

STM32的各个系列的单片机中,有的不提供BOOT1引脚(如G4x、H74x/75x就是没有的),此时通过BOOT0的状态决定进入的方式。

Reset之后的前几个上升沿都有作用,但第4个上升沿是比较关键的,因为此时BOOT引脚的值将被锁存。

锁存的作用就是:在那个采样的瞬间,把引脚的状态“抓拍”下来,存入内部一个寄存器(相当于一个1位的存储器)。此后无论引脚电平如何变化,芯片始终按照锁存住的模式启动。

从待机模式退出时,BOOT引脚的值将被被重新锁存;因此,在待机模式下BOOT引脚不能乱改,保持为想要的状态。

在启动延迟之后,首先获取地址,CPU从地址0x0000 0000获取堆栈顶的地址。在之后执行代码,从启动存储器的0x0000 0004指示的地址开始执行。

注意

Cortex-M3的CPU始终从ICode总线获取复位向量,即启动仅适合于从代码区开始(典型地从Flash启动)。

而STM32F10xxx系统可以不仅仅从Flash存储器或系统存储器启动,还可以从内置SRAM启动。

信息

自举(bootstrapping),在不同领域有不同的解释,在STM32中特指由STM32芯片内部工厂预置的“自举程序”(Bootloader),来决定芯片上电后从哪个地址开始执行代码,为后续的调试、烧录和程序运行做好准备。

在更复杂一些的笔记本、台式机CPU中,Bootloader升级为BIOS,此时指的是电脑开机时,BIOS(基本输入输出系统)加载很小的启动代码,层层加载,最终加载更大的操作系统。

EE领域还有一个概念叫自举电路,利用输出信号抬高输入端电位,从而提高电路输入阻抗或效率。例如,在射极跟随器中,自举电容能让输入电阻“虚拟增大”,使信号源驱动起来更轻松。

STM32有一段内嵌的自举程序,存放在系统存储区(ROM),这个是ST给我们写好了的,可以用USART等方式对FLASH重新编程。

对于我们常见的MCU系列(非互联型),可以用USART1接口启动自举程序。

以STM32F10x器件举例:

1.1 从主FLASH启动

我们讲到Bootloader需要决定从哪个区域执行代码,所以不同区域启动就要从不同的地址开始执行代码。

主FLASH启动,内置FLASH起始地址将被重映射到0x00000000,从此处开始执行。亦或者,从原有的地址0x08000000访问。

这也是使用JTAG、SWD模式下载的时候的目标地址。

0x00000000是中断向量表起始地址的存放位置。

我们打开startup_stm32fxxx_md.s这个文件可以看到__Vectors这个标识符,它就是中断向量表(Interrupt Vector Table)。通常位于 Flash 的起始地址(例如 0x08000000),用于存储一些关键信息,包括堆栈顶部,复位函数,中断回调函数的地址。

DCD表示定义常量数据(Define Constant Data),所以其实是数据而不是指令。

看到第一条数据就是__initial_sp,它指的是栈顶地址,可以设置栈顶指针,复位时 CPU 会把这个值加载到堆栈指针 SP。然后到下一个地址0x08000004开始执行指令,也就是Reset_Handler(调用SystemInit,然后进入__main即运行时环境)。如此之后执行其他初始化流程。

我们深入看看__initial_sp,它被定义在文件开头,如下:

startup_stm32xxxx.s是ST官方提供的启动文件,这是第一级启动引导程序。负责芯片上电后最基本的环境初始化。

我们要写IAP的话还需要自定义一个bootloader,使得startup_stm32xxxx.s执行了基本启动之后,会跳转到bootloader的地址执行。当然这个是要配置才能生效的,怎么配置呢?通过配置bootloader程序起始地址即可。

这一步需要再options for Target中的Target选项中配置。

1.2 从ROM(系统存储器)启动

ROM启动,ROM起始地址将被重映射到0x00000000,从此处开始执行。它是通过ICode和DCode总线访问的。

当你需要使用CAN/USB/UART烧录的时候,使用此模式。因为ROM中有一段预置代码,起到桥的作用,允许CAN/USB/UART等方式写入到内置FLASH。

这样的方式被称为ISP烧录。

1.3 从SRAM启动

SRAM启动,内置SRAM起始地址不会重映射,而是只能在原有地址0x20000000,从此处开始执行。它是通过系统总线访问的。

SRAM是掉电丢失的模块,所以虽然下载烧录很快,但是掉电丢失,一般用来调试。

危险

当从内置SRAM启动,在应用程序的初始化代码中,必须使用NVIC的异常表和偏移寄存器,从新映射向量表之SRAM中。

1.4 Bootloader时序

2. 三种烧录方式

2.1 ISP(In System Programming, 系统上编程)

ISP说的是可以在板级进行编程,一般通过ISP接口线来写。使用USB/SPI/UART/RS485/CAN等等接口烧录.一般是从System Memory启动的,它不在flash里面。对于F103这块板子是0x1FFFF000。

ISP程序占2KB,地址起始到结束从0x1FFFF000~0x1FFFF7FF。

一般支持ISP的MCU上有一段ISP自举程序,PC上位机可以使用类似于STC-ISP的软件进行烧录。通过USB/UART进行烧录。

2.2 IAP(In Application Programming, 在应用编程)

IAP指的是在程序运行的过程中进行烧录。对User Flash的部分区域烧录。这个启动地址是可以自定义的。

默认IAP程序占空间是自定的但不超过512KB(但你还要在FLASH里面放应用程序,肯定远小于512KB),地址起始从0x08000000,FLASH里面先放IAP程序,再放应用程序。

IAP的通信接口非常多,其实只要是能传数据的口都可以用来做IAP。

在较新的STM32产品中已经支持一个开源的Bootloader Middleware库"OpenBL",就是基于IAP方式的bootloader中间件库,大家感兴趣可以看看。但是旧的产品我们还是要自己写IAP程序。

IAP也是依赖Bootloader的,接收到更新指令之后刷新固件到Flash里面去。

做OTA联网远程升级用户固件,就要用到IAP。所以IAP的开发和迭代工作也是比较多的。

下图是IAP的执行流程:

  • Start: 系统上电或复位。
  • Key button is pressed?: 检查是否有特定按键被按下。
    • No (否): 认为是正常开机。执行 "Jump to user program",直接跳转到已烧录的用户应用程序运行,不进入升级模式。
    • Yes (是): 强制进入升级模式。执行 "Initialize IAP",初始化通信接口(如 UART/USB)和相关硬件。

初始化完成后,进入交互循环:

  • Main menu: 显示菜单,等待用户通过串口/终端输入指令。
  • Menu item choice: 根据输入的数字(1, 2, 3, 4)执行不同功能。

分支 1:接收二进制文件 (Receive binary file) - 核心升级功能

  • 动作: 准备接收外部发送的固件文件(.bin/.hex)。
  • Success? (接收校验):
    • No: 接收出错(如校验和错误)。跳转至 "Display error message",随后返回主菜单重试。
    • Yes: 接收成功。
  • Write the data to Flash: 将接收到的数据写入 MCU 内部 Flash。
  • Success? (写入校验):
    • No: 写入失败(如写保护未解除)。跳转至 "Display error message",随后返回主菜单重试。
    • Yes: 写入成功。返回主菜单等待下一指令。

分支 2:发送二进制文件 (Transmit binary file) - 备份/读回功能

  • 动作: 从 Flash 读取当前固件数据并发送出去(用于备份)。
  • Success?:
    • No: 发送失败。跳转至 "Display error message",随后返回主菜单重试。
    • Yes: 发送成功。返回主菜单。

分支 3 & 4:切换写保护 (Toggle the write protection)

  • 动作: 开启或关闭 Flash 的读写保护功能。
  • 流程: 执行完毕后,箭头指向左侧,最终汇入 "Jump to user program" 的上方。
    • 含义: 用户调整完保护设置后,通常意味着退出 IAP 模式,准备让系统正常运行。

退出与跳转 (Exit & Jump)

  • 当用户完成升级或设置后(通过分支 3/4 的逻辑暗示),系统执行 "Jump to user program"
  • 这会重置程序计数器 (PC),CPU 开始执行用户应用程序,完成从 Bootloader 到 App 的切换。

需要注意存储中还有向量表的存在:

2.3 ICP(In Circuit Programming, 在电路编程)

ICP指的是通过在线仿真器对MCU烧录,MCU装到应用板上面之后可以通过仿真器对芯片进行编程调试。启动在FLASH(0X08000000)。

ICP烧录较快,一般是调试用的,也就是投产之前我们研发人员用来开发调试的。我说JTAG和SWD大家就懂了,这就属于ICP。

提示

当你拿到一个项目,可以这样选择:

  1. 需要在线调试代码、找Bug? → 必须用 ICP。
  2. 产品在工厂,要给空芯片烧录第一个程序? → 用 ICP(生产线上有时也会用ISP,但ICP更稳定可靠)。
  3. 产品已组装好,希望用一根USB线或串口线让用户自己升级? → 用 ISP(设计时留出BOOT引脚跳线)。
  4. 产品已部署在野外/墙上,想通过Wi-Fi/4G远程升级固件? → 必须设计 IAP。
  5. 产品支持插入U盘/SD卡来升级音乐或系统? → 必须设计 IAP。

3. IAP分包CRC

刚刚讲到IAP是OTA这种场景常见的烧录方式,那么在IAP的场景下,我们理所当然要用到不同方式的远程传输。

我们编译出的固件可能有几十到几百KB,但实际通信时不能整个包发出去,主要因为缓冲区限制(MCU的RAM很小,无法一次性缓存整个固件)、通信协议限制(串口、CAN总线等有最大帧长限制)以及抗干扰的需要。

我们希望每个包在传输过程中都没有出错。所以引入CRC校验,将一个完整的固件文件切分成多个数据包(分包),并为每一个独立的包附加一个CRC校验值。校对"指纹",就知道有没有出错了。

典型的一次升级流程如下:

发送方(电脑/云服务器):

  1. 读取固件文件。

  2. 将文件切分成N个包,比如每个包1KB的数据。

  3. 对第1个包的1024字节数据,计算一个CRC32值(比如结果是 0x12AB34CD)。

  4. 将[数据包编号 + 1024字节数据 + CRC32值]打包成一个更大的帧,发送给MCU。

接收方(MCU):

  1. 收到一个完整的帧。

  2. 提取出其中的1024字节数据和发送方附带的CRC值(0x12AB34CD)。

  3. 用同样的CRC算法,对收到的1024字节数据自己计算一遍,得出一个值(比如 0x12AB34CD)。

  4. 比较:如果自己算出的值等于收到的值 0x12AB34CD,就判定此分包无误,将其写入Flash。

  5. 如果不相等,说明数据坏了,就回复“包1错误,请重传”。

在实际工程中,通常存在两层CRC:

  1. 分包CRC(传输层校验):针对每个单独的数据包。作用是实时发现错误,触发重传。这里一般会使用CRC-16

  2. 整体CRC(文件完整性校验):当所有包都收齐后,计算整个固件的MD5或CRC。作用是防止分包重传机制本身存在漏洞(比如某个包被重复计数了),确保最终写入Flash的固件和原始文件完全一致。这里一般会使用CRC-32

上一段伪代码,大家感受下:

// 定义包结构
struct Packet {
uint32_t index; // 包序号
uint8_t data[1024]; // 数据区
uint32_t crc; // 针对data[]计算的CRC值
};

// MCU接收函数
void Receive_Packet(struct Packet* pkt) {
// 1. 计算接收到的数据的CRC
uint32_t calc_crc = HAL_CRC_Calculate(&hcrc, pkt->data, 1024);

// 2. 对比
if (calc_crc == pkt->crc) {
// 校验通过,把这一包数据写入Flash
Flash_Write(pkt->index * 1024, pkt->data, 1024);
Send_ACK(pkt->index); // 回复确认
} else {
// 校验失败,请求重传
Send_NACK(pkt->index);
}
}

4. 日志诊断

在开发过程中我们需要用到系统日志,这个系统日志可以当成文件系统,可以划分为三个部分:目录区、参数区、日志区。

5. APP分区

现代的嵌入式开发常常将APP分为两个区。APP1和APP2(在这之后可能会有一个参数区存放参数配置,占几十到几百KB不等)。

APP1是主程序,APP2是备份程序。APP1存放应用代码,APP2存放暂存的升级代码。通过IAP升级。

Bootloader-APP1-APP2是连续的地址。比如说:

  • Bootloader:0x08000000 - 0x08004FFFF
  • APP1:0x08005000 - 0x0800EFFF
  • APP2:0x0800F000 - 0x08018FFF

总体的执行流程是这样的:

先执行Bootloader,再查看APP2区有没有程序,如果有,就将APP2的程序拷贝到APP1区来,然后跳转APP1的程序开始执行。

你可以在APP2的最后一个字节存放一个标志位,来判断是否有升级代码。当然这只是举例。

因为Bootloader和APP1的向量表不一样,所以要先改一下程序现在用的向量表,才能正常执行APP1的程序。

在程序中加入应用升级的部分,拿到升级之后放到Backup区。

危险

如果 BootLoader 直接跳到 App1 执行,但没有重定位向量表,那么一旦 App1 运行中触发任何中断(如 SysTick、UART、USB),CPU 依然会去 0x08000000 找中断服务函数。而那里存放的是 BootLoader 的代码,最终必然触发 HardFault 死机。