1. 1. 1. 整体流程
    1. 1.1. 正常启动流程
  2. 2. 2. 内存架构与固件布局
    1. 2.1. 2.1 地址空间
    2. 2.2. 2.2 固件映像与运行时内存
  3. 3. 3. 向量表与固件映像
    1. 3.1. 3.1 为什么需要向量表?
    2. 3.2. 3.2 解读实际的 BIN 示例
    3. 3.3. 3.3 为什么 Reset Handler 地址是奇数?
    4. 3.4. 3.4 Intel HEX 示例
    5. 3.5. 3.5 BIN 与 HEX 的区别
  4. 4. 4. Cortex-M 复位与启动流程
    1. 4.1. 4.1 复位后立即发生了什么?
    2. 4.2. 4.2 什么是 Reset_Handler?
    3. 4.3. 4.3 SystemInit() 做什么?
    4. 4.4. 4.4 main() 之前发生了什么?
  5. 5. 5. MSP、链接布局与 C 运行时初始化
    1. 5.1. 5.1 为什么初始 MSP 由链接器决定?
    2. 5.2. 5.2 理解 .data、.bss、栈、堆与 NOINIT
    3. 5.3. 5.3 为什么需要复制 .data?
    4. 5.4. 5.4 为什么需要清零 .bss?
    5. 5.5. 5.5 什么是 NOINIT?
    6. 5.6. 5.6 为什么两个固件映像的初始 MSP 不同?
    7. 5.7. 5.7 如何在 IAR 中验证?
  6. 6. 6. 向量表重定位:VTOR 与内存重映射
    1. 6.1. 6.1 什么是 VTOR?
    2. 6.2. 6.2 处理器如何找到中断处理函数?
    3. 6.3. 6.3 位于 0x08010000 的应用程序
    4. 6.4. 6.4 内存重映射做了什么?
    5. 6.5. 6.5 还原之前的 SRAM 向量表设计
    6. 6.6. 6.6 Cortex-M3 必须使用 SRAM 重映射吗?
    7. 6.7. 6.7 48 个字足够容纳向量表吗?
    8. 6.8. 6.8 应该使用哪种方法?
  7. 7. 7. IAP bootloader 向应用程序移交控制权
    1. 7.1. 7.1 什么是 IAP?
    2. 7.2. 7.2 为什么跳转到应用程序不同于复位?
    3. 7.3. 7.3 应用程序需要 bootloader 做什么?
    4. 7.4. 7.4 概念性的 C 实现
    5. 7.5. 7.5 为什么应用程序要重新执行启动代码?
    6. 7.6. 7.6 应用程序校验
    7. 7.7. 7.7 一个实用的理解模型
  8. 8. 8. 使用 IAR 进行实际检查与调试
    1. 8.1. 8.1 理解启动相关文件
    2. 8.2. 8.2 IAR 运行时入口
    3. 8.3. 8.3 实用的调试顺序
    4. 8.4. 8.4 一个实际排查练习
  9. 9. 9. 完整启动流程:一页速查
    1. 9.1. 正常硬件启动
    2. 9.2. IAP 控制权移交
  10. 10. 10. 快速参考
    1. 10.1. 重要寄存器与函数
    2. 10.2. 重要概念
    3. 10.3. 值得记住的五个区别
  11. 11. 11. 自测问题
  12. 12. 12. 参考答案
  13. 13. 最终理解模型

STM32 启动流程与固件启动机制

个人学习笔记 · Cortex-M · IAR · IAP bootloader
范围:从 MCU 复位到应用程序 main(),包括 IAP bootloader 向应用程序移交控制权的过程。不涉及 RTOS 调度与上下文切换。

本文重点梳理最容易混淆的机制:向量表查找、MSP 初始化、链接器定义的内存布局、C 运行时初始化、VTOR 与内存重映射的区别,以及 bootloader 的控制权移交。


1. 整体流程

Cortex-M 应用程序并不只是存放在 Flash 中的一组 C 函数。它是一个具有特定内存布局、入口地址和初始化要求的固件映像。

复位时,处理器需要回答两个问题:

  1. 栈应该从哪里开始?
  2. 程序应该从哪里开始执行?

向量表提供了这两个问题的答案。

正常启动流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MCU 复位
|
v
读取向量表
| 载入初始 MSP 和 Reset Handler 地址
v
Reset_Handler
|
v
SystemInit()
| 底层 MCU 初始化(取决于具体工程)
v
C 运行时初始化
| 初始化 .data、.bss 和运行时环境
v
main()

这里涉及三个不同的层次:

层次 职责
Cortex-M 硬件 初始化栈并提供异常入口机制
启动代码 准备 MCU 并进入运行时
C 运行时 准备 C/C++ 程序内存并调用 main()

核心概念: main() 并不是处理器真正的入口。在它执行之前,已经完成了相当多的初始化工作。


2. 内存架构与固件布局

中等深度

2.1 地址空间

典型的 STM32 包含多个存储区域:

存储区域 典型地址 用途
Flash 0x08000000 固件与常量数据
SRAM 0x20000000 运行时变量、栈、堆
外设 0x40000000 GPIO、UART、定时器等
系统存储区 取决于具体器件 ROM bootloader 与系统功能

这些都是处理器内存映射中的地址范围。处理器通过地址访问它们,不需要使用不同的指令集来分别访问 Flash 或 SRAM。

例如:

1
2
3
#define APP_ADDRESS  0x08010000

uint32_t value = *(volatile uint32_t *)APP_ADDRESS;

这段代码从该地址映射的存储区域读取一个 32 位字。

2.2 固件映像与运行时内存

固件映像通常烧录到 Flash 中,但程序运行时并不一定直接在 Flash 中使用所有数据。

例如:

1
2
3
int counter = 10;
int status;
const int version = 3;

典型的安排如下:

  • counter:带初始值的变量,初始内容保存在 Flash 中,运行时在 SRAM 中使用。
  • status:零初始化变量,在 SRAM 中分配空间。
  • version:常量数据,通常保存在 Flash 中。

这里引出了一个重要区别:

固件映像描述初始内容;运行时内存布局描述程序实际使用这些内容的位置。

链接器决定这些段放在哪里,以及启动代码应如何初始化它们。


3. 向量表与固件映像

深入分析

3.1 为什么需要向量表?

处理器复位时,无法直接调用 main(),因为它还不知道应用程序的栈在哪里,也不知道启动代码从哪里开始。

它需要一个标准化的位置来获取这些地址。

这个位置就是向量表。

向量表是一个由 32 位表项组成的数组。每个表项保存与处理器复位、异常或中断处理相关的地址或初始值。

前两个表项尤为重要:

偏移 表项 含义
0x00 初始 MSP 主栈指针的初始值
0x04 Reset Handler 启动代码的地址
0x08 NMI Handler 不可屏蔽中断处理函数
0x0C HardFault Handler 硬故障处理函数
0x10 MemManage Handler 内存管理故障处理函数,仅适用于实现了该异常的内核

第一个表项不是函数地址,而是栈地址。

第二个表项才是开始执行时使用的函数地址。

3.2 解读实际的 BIN 示例

之前查看过这样一段二进制转储:

1
2
3
4
5
Offset  Bytes
0000 F0 05 00 20
0004 D5 40 00 08
0008 61 65 00 08
000C 65 5A 00 08

Cortex-M 以小端格式存储这些 32 位值。

小端序表示最低有效字节存放在最前面。

例如:

1
2
字节:F0 05 00 20
数值:0x200005F0

解码后的表格如下:

偏移 字节 32 位值 含义
0x00 F0 05 00 20 0x200005F0 初始 MSP
0x04 D5 40 00 08 0x080040D5 Reset Handler
0x08 61 65 00 08 0x08006561 NMI Handler
0x0C 65 5A 00 08 0x08005A65 HardFault Handler

注意这些地址所属的不同区域:

  • 0x200005F0 属于 SRAM:这是初始栈指针。
  • 0x080040D5 属于 Flash:这是 Reset Handler。

这正是正常 STM32 应用程序应有的形式。

3.3 为什么 Reset Handler 地址是奇数?

可以注意到:

1
0x080040D5

其最低位为 1。

这是有意设计的。

Cortex-M 处理器执行 Thumb 指令。函数指针使用 bit 0 表示 Thumb 状态。实际指令地址是对齐的,而 bit 0 携带状态标志。

因此:

1
2
3
保存的函数地址:0x080040D5
实际指令地址: 0x080040D4
Thumb 标志: bit 0 = 1

解读向量表项时,不要简单地清除 bit 0。处理器需要这个 Thumb 状态标志。

3.4 Intel HEX 示例

之前还查看过:

1
2
:020000040800F2
:1000000040090020D5000008511E00...

这不是原始 BIN 文件,而是 Intel HEX:一种用文本表示内存记录的格式。

记录格式如下:

1
:LL AAAA TT [DD...] CC
字段 含义
LL 数据字节数
AAAA 16 位地址偏移
TT 记录类型
DD 数据字节
CC 校验和

第一行:

1
:020000040800F2

是一条扩展线性地址记录:

  • 02:两个数据字节。
  • 0000:记录偏移。
  • 04:扩展线性地址记录。
  • 0800:地址的高 16 位。
  • F2:校验和。

它将地址的高位部分设为 0x0800。

下一条记录的数据开头为:

1
40 09 00 20 D5 00 00 08

解码后得到:

1
2
初始 MSP       = 0x20000940
Reset Handler = 0x080000D5

向量表的解读方式完全相同,变化的只是固件文件的表示形式。

3.5 BIN 与 HEX 的区别

特性 BIN Intel HEX
格式 原始二进制字节 ASCII 文本记录
地址信息 文件中不显式保存地址 编码在记录中
地址空洞 通常用填充表示,也可能根据导出方式省略 可以通过缺失的地址范围表示
常见用途 在已知基地址处烧录 固件分发与烧录

BIN 文件需要从外部指定基地址。没有这个地址,仅凭文件中的字节无法判断它们应该放在 Flash 的什么位置。

HEX 文件包含地址信息,因此可以在记录中描述不同位置的数据。

请记住: BIN 文件以向量表开头是一种常见的导出方式,并不是普遍保证。向量表前也可能存在文件头、填充或自定义映像格式。


4. Cortex-M 复位与启动流程

深入分析

4.1 复位后立即发生了什么?

正常的 Cortex-M 启动过程中,处理器从启动映射所指向的向量表获取两个初始值:

  1. 从偏移 0x00 处获取初始 MSP。
  2. 从偏移 0x04 处获取 Reset Handler 地址。

概念上相当于:

1
2
MSP = *(uint32_t *)(vector_base + 0x00);
PC = *(uint32_t *)(vector_base + 0x04);

随后,处理器开始执行 Reset Handler。

这是硬件行为,不需要先运行 main() 或 C 运行时。

启动地址处实际可见的存储区域,取决于器件的启动配置和内存映射。

4.2 什么是 Reset_Handler?

Reset_Handler 是处理器完成复位序列后的第一个软件入口。

它通常在汇编启动代码中实现。

它的职责是为正常执行 C 代码做好系统准备。

简化后的启动流程可以表示为:

1
2
3
4
Reset_Handler:
BL SystemInit
BL C_Runtime_Initialization
BL main

这只是用于说明的伪代码,并不是实际启动文件的精确实现。真实启动代码可能使用不同的跳转指令和运行时入口符号。

4.3 SystemInit() 做什么?

SystemInit() 通常由 STM32 器件支持包或工程启动代码提供。

根据工程的不同,它可能会:

  • 配置时钟树。
  • 设置 PLL。
  • 配置 FPU。
  • 设置向量表偏移。
  • 执行 MCU 特有的早期初始化。

这些操作并不是通用且固定的流程,应始终查看当前工程中的实际实现。

4.4 main() 之前发生了什么?

C 运行时会准备应用程序所需的内存。

例如:

1
2
3
4
5
6
7
8
9
10
int counter = 10;
int status;

int main(void)
{
while (1)
{
counter++;
}
}

在进入 main() 之前:

  • counter 必须为 10。
  • status 必须为 0。

运行时初始化负责完成这些工作。

这就引出了下一个重要主题:链接器与启动代码如何协作准备 RAM。


5. MSP、链接布局与 C 运行时初始化

深入分析

5.1 为什么初始 MSP 由链接器决定?

处理器在执行正常的 C 代码之前,就需要一个有效的栈。

但处理器并不知道当前工程希望把栈放在哪里。

链接器知道工程配置中定义的内存区域与段放置规则,因此能够确定栈区域,并提供栈顶的初始地址。

初始 MSP 通常就是预留栈区域的顶部地址。

请记住,Cortex-M 的栈通常向下增长,也就是向更低的地址增长。

例如,下面是一个简化的 SRAM 布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SRAM 高地址
+------------------------------+
| 栈区域 |
| 初始 MSP 位于栈顶 |
| 栈向下增长 v |
+------------------------------+
| 堆区域 |
| 通常向上增长 ^ |
+------------------------------+
| .bss / ZI |
| 零初始化变量 |
+------------------------------+
| .data |
| 带初始值的变量 |
+------------------------------+
SRAM 低地址

这只是概念性布局。实际的段顺序以及堆、栈的位置取决于链接器配置。

5.2 理解 .data、.bss、栈、堆与 NOINIT

这些名称描述了不同类型的运行时内存。

段 / 区域 用途 启动时的行为
.data 带初始值的全局 / 静态变量 将初始值从 Flash 复制到 RAM
.bss 零初始化的全局 / 静态变量 将对应 RAM 清零
栈 函数调用、局部变量、保存的寄存器 MSP 从栈顶开始
堆 动态内存分配 由内存分配器管理
NOINIT 需要保留现有内容的内存 普通 C 运行时不对其初始化

例如:

1
2
3
4
int counter = 10;           // .data
int status; // .bss
static int retry_count = 3; // .data
static int error_code; // .bss

.data 和 .bss 尤其重要,因为 RAM 不会自动具有 C 程序要求的初始值。

5.3 为什么需要复制 .data?

例如:

1
int counter = 10;

初始值 10 必须保存在固件映像的某个位置,才能在掉电后仍然保留。

因此,初始值会保存在 Flash 中。

但程序希望 counter 是一个位于 SRAM 中、可以修改的变量。

链接器会描述这两个位置:

1
2
Flash:初始值 = 10
SRAM: 运行时变量 counter

启动时,运行时会将初始值从 Flash 复制到 SRAM。

概念上相当于:

1
2
3
4
5
6
7
for (src = __data_load_start,
dst = __data_start;
dst < __data_end;
)
{
*dst++ = *src++;
}

这只是说明性的伪代码。实际启动代码使用链接器生成的符号,也可能需要复制多个段。

5.4 为什么需要清零 .bss?

例如:

1
int status;

C 语言要求:具有静态存储期、但未显式指定初始值的对象,必须进行零初始化。

因此,运行时会清零相应的 RAM 区域:

1
2
3
4
for (dst = __bss_start; dst < __bss_end; )
{
*dst++ = 0;
}

同样,这只是概念性代码。

如果没有这一步,status 可能保留该 SRAM 位置之前的任意值。

5.5 什么是 NOINIT?

NOINIT 段是启动代码有意不修改的一块内存区域。

当数据需要在软件重启后保留,或其他组件已经准备好该内存时,这种机制就很有用。

之前的 IAP 设计就利用这个概念来保存 SRAM 向量表:

  • Bootloader 将应用程序向量表复制到 SRAM。
  • 应用程序预留同一块 SRAM 区域。
  • C 运行时不能清零或覆盖这块区域。

如果向量表预留区被意外划入 .bss,启动过程就可能清除已经复制好的向量表项。

如果它被划入 .data,启动过程就可能用应用程序的初始数据映像覆盖它。

因此,必须正确放置这块预留区,并将其排除在普通初始化之外。

5.6 为什么两个固件映像的初始 MSP 不同?

之前看到过两个初始 MSP 值:

1
2
映像 A:0x200005F0
映像 B:0x20000940

两者之差为:

1
2
0x20000940 - 0x200005F0 = 0x350
= 848 字节

关键在于:

初始 MSP 由最终链接后的内存布局决定,而不只是由配置的栈大小决定。

链接器可能会在栈区域之前放置数量不同的 SRAM 数据。差异可能来自:

  • 全局 / 静态变量数量不同。
  • 带初始值的段或零初始化段不同。
  • 额外的库或中间件数据。
  • 不同的对齐方式与段放置方式。
  • 不同的链接器配置或内存区域。
  • 不同的栈区域放置位置。

栈大小相同,并不保证初始 MSP 地址相同。

5.7 如何在 IAR 中验证?

不要只根据地址猜测,而应查看链接器输出。

有用的文件包括:

  • .icf:链接器配置文件。
  • .map:链接器生成的内存映射文件。

重点查找:

  • SRAM 内存定义。
  • CSTACK 和 HEAP 的放置位置。
  • 可读写段。
  • 零初始化段。
  • 所有 NOINIT 段。
  • 初始栈符号,它通常与 CSTACK 边界相关。

确切的符号名称取决于 IAR 运行时和工程配置。

重要的检查步骤是:

  1. 找到 SRAM 区域。
  2. 确认 .data 和 .bss 的放置位置。
  3. 找到栈预留区。
  4. 确定其顶部地址。
  5. 将该地址与向量表的第一个字进行比较。

不要假设所有工程都使用相同的段顺序。


6. 向量表重定位:VTOR 与内存重映射

深入分析

这是理解之前 bootloader 设计时最重要的区别之一。

这两种机制解决的问题有关联,但作用于不同的层次:

  • VTOR 改变处理器查找向量表的位置。
  • 内存重映射改变某个地址处可见的物理存储区域。

6.1 什么是 VTOR?

VTOR 是 Vector Table Offset Register,即向量表偏移寄存器。

它属于 Cortex-M 的系统控制块(SCB)。

1
SCB->VTOR = 0x08010000;

这会告诉处理器使用位于 0x08010000 的向量表。

假设应用程序存放在:

1
应用程序 Flash 基地址 = 0x08010000

它的向量表也位于这个地址。

将 VTOR 设置为该地址后,处理器就可以在那里找到应用程序的异常和中断处理函数。

向量表本身并没有移动,变化的只是查找向量表时使用的地址。

6.2 处理器如何找到中断处理函数?

对于 Cortex-M 处理器,向量表在外部中断表项之前,包含 16 个内核异常相关表项。

对于一个外部中断:

1
向量索引 = 16 + IRQ 编号

其处理函数表项地址为:

1
处理函数表项地址 = VTOR + 4 × 向量索引

例如,某个外部中断的 IRQ 编号为 5:

1
2
3
向量索引 = 16 + 5 = 21

处理函数表项地址 = VTOR + 21 × 4

处理器从该位置读取处理函数地址,然后进入相应的中断处理函数。

这也解释了为什么 VTOR 设置错误时,中断一发生,处理器就可能跳转到无效地址。

6.3 位于 0x08010000 的应用程序

假设 Flash 布局如下:

1
2
3
4
5
6
7
8
9
0x08000000 +----------------------+
| Bootloader |
0x08010000 +----------------------+
| 应用程序 |
| |
| 向量表 |
| Reset_Handler |
| 其他代码 |
+----------------------+

对于 STM32F469 这样的 Cortex-M4 器件,直接的做法是:

1
SCB->VTOR = 0x08010000;

这样,处理器就直接使用 Flash 中的应用程序向量表。

不需要将向量表复制到 SRAM。

该地址必须满足具体 Cortex-M 实现对 VTOR 的对齐要求。不要认为任意 Flash 地址都有效。

6.4 内存重映射做了什么?

部分 STM32 器件提供 SYSCFG 内存重映射机制。

例如,支持该功能的 STM32 可能提供:

1
__HAL_SYSCFG_REMAPMEMORY_SRAM();

它会改变通过 0x00000000 地址别名访问时可见的存储区域。

概念上如下:

重映射之前:

1
2
3
地址 0x00000000
|
+-- Flash 别名

重映射之后:

1
2
3
地址 0x00000000
|
+-- SRAM 别名

重点是:重映射并不会复制数据,它改变的是内存映射关系。

如果已经将应用程序向量表复制到 SRAM,重映射就可以让这张 SRAM 向量表在零地址处可见。

6.5 还原之前的 SRAM 向量表设计

之前的设计可以按以下步骤理解。

步骤 1 —— Bootloader 将应用程序向量表复制到 SRAM。

1
2
3
4
5
Flash 中的应用程序向量表
|
| 复制
v
位于 0x20000000 的 SRAM

步骤 2 —— 应用程序预留 SRAM 的起始区域。

应用程序为向量表预留一块区域。

之前提到过预留 48 个字:

1
48 × 4 = 192 字节 = 0xC0

概念上如下:

1
2
3
4
5
6
7
8
9
SRAM
0x20000000 +--------------------------+
| 应用程序向量表 |
| 预留区 / NOINIT |
0x200000C0 +--------------------------+
| 其他 RAM 段 |
| .data / .bss / 栈 |
| 堆 |
+--------------------------+

应用程序的 C 运行时初始化不能覆盖这块预留区。

步骤 3 —— 应用程序将 SRAM 重映射到零地址。

1
__HAL_SYSCFG_REMAPMEMORY_SRAM();

此时,处理器在零地址处查找向量表,实际访问的是 SRAM 别名。

步骤 4 —— 中断使用 SRAM 中的向量表。

向量表在物理上位于 SRAM 中,同时也通过零地址别名可见。

应用程序代码仍然可以从正常的 Flash 地址执行。

6.6 Cortex-M3 必须使用 SRAM 重映射吗?

不需要。这是对之前解释的一项重要更正。

Cortex-M3 支持 VTOR。使用 Cortex-M3 的 STM32F103 同样支持 VTOR。

SRAM 重映射是一种替代机制,可能适用于特定的启动或兼容性设计,但并不是 Cortex-M3 强制要求的做法。

机制 改变了什么? 示例
VTOR 向量表查找基地址 SCB->VTOR = 0x08010000
SRAM 重映射 某个地址别名处可见的存储区域 SRAM 在 0x00000000 处可见

6.7 48 个字足够容纳向量表吗?

不一定。

需要考虑:

  1. STM32 的具体型号。
  2. 实际实现的内核向量和外部中断向量数量。
  3. 向量表要求的对齐方式。
  4. SRAM 预留区的大小。
  5. 链接器对预留区的放置方式。

例如,包含 16 个内核表项和 43 个外部中断表项的向量表需要:

1
2
3
16 + 43 = 59 个字

59 × 4 = 236 字节

192 字节的预留区无法容纳完整的这张表。

这说明了为什么必须查看具体 MCU 的参考手册和启动向量定义。不要在未核实的情况下直接照搬其他工程的预留区大小。

6.8 应该使用哪种方法?

对于 STM32F469 应用程序,优先使用直接设置 VTOR 的方法:

1
SCB->VTOR = APP_ADDRESS;

它更简单,也不再需要:

  • 将向量表复制到 SRAM。
  • 预留 SRAM 的起始区域。
  • 保护预留区,避免被运行时初始化覆盖。
  • 将 SRAM 重映射到零地址。

仍然值得在笔记中保留 SRAM 重映射方法,因为它有助于理解旧设计,也适用于确实需要这种机制的器件或启动架构。


7. IAP bootloader 向应用程序移交控制权

深入分析

7.1 什么是 IAP?

IAP 是 In-Application Programming,即在应用编程。

在典型的 STM32 bootloader 设计中,bootloader 可以:

  • 接收固件映像。
  • 擦除并写入应用程序 Flash 区域。
  • 校验映像。
  • 将执行权移交给应用程序。

简化的 Flash 布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Flash
0x08000000 +-------------------------+
| Bootloader |
| |
| 固件更新逻辑 |
| |
0x08010000 +-------------------------+
| 应用程序 |
| |
| 向量表 |
| Reset_Handler |
| 应用程序代码 |
+-------------------------+

Bootloader 和应用程序是两个独立的固件映像,各自拥有向量表和链接器配置。

7.2 为什么跳转到应用程序不同于复位?

MCU 发生硬件复位时,处理器会进入复位序列,并按照硬件复位行为初始化其架构状态。

但 bootloader 跳转到应用程序时,并没有发生硬件复位。

处理器会带着大量现有状态继续运行。

例如:

  • 外设寄存器可能保留之前的配置。
  • NVIC 中断使能状态和挂起状态可能保留。
  • SysTick 可能仍在运行。
  • 时钟配置可能已经被 bootloader 修改。
  • 处理器可能处于不同的栈配置或中断状态。

因此,bootloader 必须在移交控制权之前做好系统准备。

7.3 应用程序需要 bootloader 做什么?

应用程序向量表包含两个重要的初始值:

1
2
APP_ADDRESS + 0x00 → 初始 MSP
APP_ADDRESS + 0x04 → Reset_Handler

例如:

1
2
3
4
APP_ADDRESS = 0x08010000

Initial MSP = *(uint32_t *)0x08010000
Reset_Handler = *(uint32_t *)0x08010004

Bootloader 必须:

  1. 校验应用程序映像是否可用。
  2. 根据需要停止或禁用中断源。
  3. 停止 SysTick。
  4. 清除或禁用相关 NVIC 中断状态。
  5. 设置应用程序的向量表基地址。
  6. 载入应用程序的初始 MSP。
  7. 跳转到应用程序的 Reset Handler。

随后,应用程序运行自己的启动流程。

7.4 概念性的 C 实现

下面展示了关键操作,但并不是可直接用于生产环境的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#define APP_ADDRESS  0x08010000U

typedef void (*AppEntry)(void);

void JumpToApplication(void)
{
uint32_t app_msp;
uint32_t app_reset;

app_msp = *(volatile uint32_t *)(APP_ADDRESS);
app_reset = *(volatile uint32_t *)(APP_ADDRESS + 4U);

/*
* Validate app_msp and app_reset here.
* Also validate the image itself if required.
*/

__disable_irq();

/* Stop SysTick. */
SysTick->CTRL = 0;
SysTick->LOAD = 0;
SysTick->VAL = 0;

/*
* Disable and clear relevant NVIC interrupts.
* Exact handling depends on the MCU and bootloader.
*/

/* Point exception lookup to the application vector table. */
SCB->VTOR = APP_ADDRESS;

__DSB();
__ISB();

/*
* Set the application's initial stack pointer,
* then transfer execution to its Reset_Handler.
*
* This final handover is best implemented in
* carefully controlled assembly.
*/
__set_MSP(app_msp);

((AppEntry)app_reset)();
}

重要的实现注意事项: 在普通 C 函数中修改 MSP,然后再调用另一个 C 函数,可能并不安全。编译器可能生成依赖原栈的栈访问或函数尾声代码。生产环境中的 bootloader 通常使用经过严格控制的汇编移交代码,或使用包含汇编序列的合适裸函数,在载入 MSP 后直接跳转,避免继续依赖旧栈。

此示例用于说明所需的数值和操作,并不是完整、安全的实现。

7.5 为什么应用程序要重新执行启动代码?

Bootloader 跳转到应用程序的 Reset Handler 后,应用程序从自己的启动入口开始执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Bootloader
|
+-- 校验应用程序
|
+-- 禁用中断并准备硬件
|
+-- 设置应用程序 VTOR
|
+-- 载入应用程序 MSP
|
+-- 跳转到应用程序 Reset_Handler
|
v
SystemInit()
|
v
C 运行时初始化
|
v
main()

应用程序的启动代码会初始化自己的 .data 和 .bss,准备运行时环境,并调用 main()。

因此,应用程序应构建为独立映像,拥有自己的向量表和启动代码。

但请记住:重新执行启动代码,并不意味着 MCU 已经复位。 应用程序所看到的外设和时钟,仍可能处于 bootloader 留下的状态。

7.6 应用程序校验

跳转之前,不要盲目信任向量表中的前两个字。

至少应考虑检查:

  • MSP 是否位于应用程序允许使用的 SRAM 范围内。
  • MSP 是否满足所需的对齐要求。
  • Reset Handler 是否指向允许执行代码的区域。
  • Reset Handler 是否具有预期的 Thumb 状态位。
  • 映像是否通过完整性检查,例如 CRC 或密码学签名校验。

确切的校验规则取决于内存布局与安全要求。

7.7 一个实用的理解模型

应把 bootloader 的控制权移交理解为准备一个新的程序运行环境,而不是重启 MCU。

Bootloader 必须为应用程序提供其预期的条件:

要求 由谁准备?
应用程序向量表基地址 Bootloader
应用程序初始 MSP Bootloader
有效的应用程序映像 Bootloader
.data 初始化 应用程序运行时
.bss 初始化 应用程序运行时
应用程序对外设和时钟的预期条件 按设计由 bootloader 和 / 或应用程序启动代码负责
执行 main() 应用程序运行时

8. 使用 IAR 进行实际检查与调试

中等深度

本节将前面的概念与实际开发流程联系起来。

不需要记住每一个启动符号,但需要知道出问题时应该去哪里查看。

8.1 理解启动相关文件

在 IAR STM32 工程中,查找类似下面的文件:

1
2
3
startup_stm32xxxx.s
system_stm32xxxx.c
stm32xxxx_flash.icf

它们的职责各不相同:

文件 职责
startup_stm32xxxx.s Reset Handler、向量表和初始启动流程
system_stm32xxxx.c 系统级配置,通常包含 SystemInit()
.icf 链接器内存区域与段放置规则
.map 最终链接后的内存布局与符号信息

确切的文件名取决于 STM32 器件与工程。

8.2 IAR 运行时入口

可能会看到这样的符号:

1
__iar_program_start

它与 IAR 运行时启动过程有关。

它并不是 ARM Compiler 入口点 __main 所使用的同一个符号。

常见的 IAR 启动流程在概念上如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Reset_Handler
|
v
SystemInit()
|
v
__iar_program_start
|
v
运行时初始化
|
v
main()

确切的顺序与实现取决于启动文件和运行时配置。

还可能遇到 __low_level_init()。它是 IAR 提供的运行时钩子,可用于早期初始化,也可能影响正常的数据初始化行为。修改之前,应查看 IAR 文档和当前工程的实际实现。

重要区别: SystemInit() 通常属于 MCU 支持 / 启动层;__iar_program_start 属于 IAR 运行时。两者职责不同。

8.3 实用的调试顺序

在以下位置设置断点:

断点 检查内容
Reset_Handler 处理器是否进入了预期的启动代码?
SystemInit() 时钟和早期硬件设置是否正确?
__iar_program_start 控制流是否到达 IAR 运行时?
__low_level_init() 是否自定义了早期运行时初始化?
main() 启动过程是否成功完成?

也可以检查:

  • SCB->VTOR
  • MSP
  • 向量表的前两个表项
  • .data 和 .bss 的地址
  • 应用程序 Flash 基地址
  • 链接器 map 文件

8.4 一个实际排查练习

假设应用程序存放在 0x08010000,但 bootloader 跳转过去后立即失败。

按以下顺序排查:

  1. 读取 0x08010000 处的前两个字。
  2. 检查 MSP 是否指向有效的 SRAM。
  3. 检查 Reset Handler 是否指向应用程序 Flash 区域。
  4. 检查 VTOR 是否设置为 0x08010000。
  5. 检查是否有中断通过错误的向量表触发。
  6. 在应用程序的 Reset_Handler 处设置断点。
  7. 继续跟踪 SystemInit() 和 IAR 运行时初始化。
  8. 检查是否到达 main()。

这个顺序遵循实际的启动依赖链,而不是盲目猜测原因。


9. 完整启动流程:一页速查

正常硬件启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
硬件复位
|
v
启动内存映射
| 决定启动地址处可见的存储区域
v
读取向量表
| 第 0 个字 -> MSP
| 第 1 个字 -> Reset Handler
v
Reset_Handler
|
v
SystemInit()
| 时钟与 MCU 初始化
v
C 运行时
| 复制 .data
| 清零 .bss
| 准备运行时环境
v
main()

IAP 控制权移交

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Bootloader
|
+-- 校验应用程序并准备硬件
|
v
设置应用程序 VTOR 和 MSP
|
v
跳转到应用程序 Reset_Handler
|
v
应用程序启动
|
v
main()

在 IAP 路径中,bootloader 通过软件完成最初的控制权移交。两个固件映像之间并没有发生硬件复位。


10. 快速参考

重要寄存器与函数

名称 用途
SCB->VTOR 选择向量表基地址
__set_MSP(value) 设置主栈指针
__get_MSP() 读取主栈指针
__disable_irq() 全局禁用可配置中断
__DSB() 数据同步屏障
__ISB() 指令同步屏障
SystemInit() 工程 / 器件特定的早期初始化
Reset_Handler 固件启动入口
__iar_program_start IAR 运行时启动入口
__low_level_init() 可选的 IAR 早期初始化钩子
__HAL_SYSCFG_REMAPMEMORY_SRAM() 在支持该功能的 STM32 器件上启用 SRAM 重映射

重要概念

概念 可以这样记忆
向量表 处理器查找初始栈和异常处理函数的位置
初始 MSP 初始主栈的栈顶地址
Reset Handler 第一个软件启动入口
VTOR 决定处理器去哪里查找向量表
内存重映射 决定某个地址别名处对应哪块物理存储区域
.data 需要复制到 RAM 的带初始值变量
.bss 零初始化变量
NOINIT 运行时初始化有意不修改的内存
链接器 决定内存布局与段放置方式
C 运行时 在 main() 之前准备 C 程序环境

值得记住的五个区别

  1. 向量表与启动代码: 向量表告诉处理器从哪里开始;启动代码负责准备应用程序。
  2. MSP 与栈大小: MSP 是一个地址;栈大小是预留内存的容量。
  3. Flash 映像与 RAM 运行时: 变量初始值可能保存在 Flash 中,但运行时在 RAM 中使用。
  4. VTOR 与重映射: VTOR 改变向量表查找基地址;重映射改变某个地址处可见的存储区域。
  5. 应用程序跳转与硬件复位: 跳转到 Reset Handler 并不会复位外设或处理器状态。

11. 自测问题

试着不看前面的内容,回答以下问题。

Q1. 正常的 Cortex-M 复位后,处理器立即从哪里获取初始 MSP 和 Reset Handler 地址?

Q2. 为什么向量表的第一个表项指向 SRAM,而第二个通常指向 Flash?

Q3. 如果两个固件映像的栈和堆大小完全相同,为什么初始 MSP 仍然可能不同?

Q4. 为什么 bootloader 必须跳转到应用程序的 Reset Handler,而不能直接调用 main()?

Q5. 修改 VTOR 与将 SRAM 重映射到零地址有什么区别?

Q6. 为什么必须将 SRAM 向量表预留区排除在 .data 和 .bss 初始化之外?

Q7. 为什么经过 IAP 跳转启动的应用程序,行为可能不同于硬件复位后启动的应用程序?


12. 参考答案

Q1. 初始 MSP 和 Reset Handler 的获取位置

处理器读取启动映射所指向的向量表中的前两个 32 位表项。表项 0 提供初始 MSP,表项 1 提供 Reset Handler 地址。

Q2. 为什么分别指向 SRAM 和 Flash?

栈是运行时使用的内存结构,因此放在可写的 SRAM 中。启动代码通常放在非易失性的 Flash 中。

Q3. 为什么初始 MSP 可能不同?

链接器决定栈的实际位置。即使预留大小不变,其他 RAM 段、对齐方式和链接器配置也可能改变栈的位置。

Q4. 为什么要跳转到 Reset_Handler?

Reset Handler 负责执行启动工作,包括底层初始化和进入 C 运行时。直接调用 main() 会绕过这些必要步骤。

Q5. VTOR 与重映射有什么区别?

VTOR 改变查找向量表时使用的地址。内存重映射改变通过某个地址别名(例如 0x00000000)访问时可见的存储区域。

Q6. 为什么要保护 SRAM 向量表?

否则,运行时初始化可能覆盖 bootloader 复制的向量表项,导致异常和中断处理失效。

Q7. 为什么 IAP 启动可能不同于硬件复位?

IAP 跳转不会执行硬件复位。应用程序可能继承 bootloader 留下的外设配置、时钟设置、中断状态以及其他硬件状态。


最终理解模型

如果只记住一条链路,请记住:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
向量表
|
v
初始 MSP + Reset Handler
|
v
启动代码
|
v
系统初始化
|
v
C 运行时初始化
|
v
main()

如果只记住 IAP 的一个关键区别:

Bootloader 跳转只是移交执行权,并不会重新建立硬件复位后的状态。

理解这个区别,就能解释设计可靠 bootloader 时为什么需要采取许多预防措施。


本学习笔记结束。