ARM Cortex-M 单片机启动流程与向量表深度解析(保姆级复习笔记)

0. 引言:上电后,CPU 在干什么?

当你给 STM32 这样的单片机接通电源,按下复位键,或者看门狗超时复位,CPU 都会重新启动。那么,CPU 启动后的第一件事是什么?它怎么知道自己该执行哪条指令?这一切的起点,都隐藏在芯片内部的 向量表复位序列 中。

很多初学者虽然能写出流水灯程序,却对启动过程一知半解。一旦遇到程序跑飞、Bootloader 跳转失败、中断不响应等问题,往往无从下手。本文将带你彻底搞懂 ARM Cortex-M 内核的单片机从复位到执行 main 函数的全过程,即使你是汇编小白,也能跟上节奏。


1. ARM Cortex-M 处理器核心寄存器速览

首先,我们要认识 CPU 内部用来暂存数据的"小盒子"------寄存器。以 Cortex-M3/M4/M33 为例,它们有 16 个通用寄存器(R0~R15),其中三个有特殊用途:

寄存器 别名 作用
R0-R12 - 通用寄存器,用于算术运算、数据暂存
R13 SP (Stack Pointer) 栈指针,指向当前栈顶位置,分为 MSP(主栈)和 PSP(进程栈),复位后默认使用 MSP
R14 LR (Link Register) 链接寄存器,保存函数调用的返回地址
R15 PC (Program Counter) 程序计数器,指向当前正在执行的指令地址,修改它就能实现跳转
xPSR - 程序状态寄存器,保存运算标志(零、进位等)和中断状态

形象理解:R0~R12 就像你的临时便签,随手记数据;SP 是你的书签,告诉你当前读到哪一页;LR 是回家的路标;PC 是你正在看的书页。


2. 内存映射:Flash 和 RAM 在哪里?

Cortex-M 是统一编址的,Flash、RAM、外设都映射到同一地址空间。以 STM32F103 为例:

  • Flash(代码存储) :起始地址 0x08000000,容量几十到几百 KB。

  • RAM(数据存储) :起始地址 0x20000000,容量几十 KB。

  • 系统存储器 (Bootloader 区域)、外部 RAM 等也在其他地址。

关键点 :CPU 复位后,会从地址 0x00000000 取指。但芯片设计时,通常把 0x00000000 映射到 Flash 的起始地址(0x08000000),这样就能从 Flash 开始执行。


3. 向量表:程序的"黄页目录"

3.1 什么是向量表?

向量表是一张存放在 Flash 最开头的表格,每一项都是一个 地址(4 字节),记录了当异常或中断发生时,CPU 应该跳转到哪里去执行处理函数。

3.2 向量表的结构(以汇编代码为例)

看下面这段典型的启动文件(如 startup_stm32f103xe.s)中的片段:

cs 复制代码
assembly

; 向量表放在名为 RESET 的段中,只读
AREA    RESET, DATA, READONLY
EXPORT  Vectors
EXPORT  Vectors_End
EXPORT  Vectors_Size

Vectors
    DCD     __initial_sp          ; 0x00000000  栈顶地址(MSP初始值)
    DCD     Reset_Handler          ; 0x00000004  复位处理函数
    DCD     NMI_Handler            ; 0x00000008  NMI异常
    DCD     HardFault_Handler      ; 0x0000000C  硬件错误
    DCD     MemManage_Handler      ; 0x00000010  内存管理错误
    DCD     BusFault_Handler       ; 0x00000014  总线错误
    DCD     UsageFault_Handler     ; 0x00000018  用法错误
    ; ... 后面还有更多异常和中断向量
  • DCD 是汇编伪指令,表示分配一个 4 字节空间并填入指定值(类似 C 语言的 uint32_t var = value;)。

  • 向量表的第一个条目(地址 0x00000000)存放的是 栈顶地址__initial_sp)。

  • 第二个条目(地址 0x00000004)存放的是 复位处理函数的入口地址Reset_Handler)。

  • 从第三个条目开始,依次是各种异常(NMI、HardFault 等)和中断的入口地址。

为什么第一个是栈顶地址?

因为 C 语言程序需要栈来支持函数调用、局部变量。CPU 复位后,必须立刻有一个可用的栈,才能执行后续代码。所以硬件设计者干脆把栈顶地址放在最前面,复位后第一条指令就是初始化 SP。

3.3 向量表的位置

向量表默认必须放在地址 0x00000000(映射到 Flash 起始)。但在程序运行后,可以通过修改 SCB->VTOR 寄存器将向量表重定位到其他地址(如 RAM 或另一块 Flash),这在 Bootloader 中非常有用。


4. 复位后的两步关键操作(硬件自动完成)

当 CPU 从复位中醒来,它不需要软件干预,自动执行以下两个步骤:

  1. 从地址 0 读取 4 字节 → 写入 MSP(主栈指针)

    CPU 将 Flash 中 0x00000000 处的值(即 __initial_sp)加载到 SP 寄存器(具体是 MSP)。
    目的:建立栈空间,为调用 C 函数做准备。

  2. 从地址 4 读取 4 字节 → 写入 PC(程序计数器)

    CPU 将 0x00000004 处的值(即 Reset_Handler 的地址)加载到 PC。
    目的:跳转到复位处理函数,开始执行用户代码。

这两步之后,CPU 就开始执行 Reset_Handler 的第一条指令了。

细节:有些 Cortex-M 芯片在复位后还会检查 BOOT 引脚,决定是从主 Flash 还是系统存储器启动,但核心的向量表机制不变。


5. Reset_Handler 里到底做了什么?

Reset_Handler 是用汇编或 C 编写的,它的主要任务是初始化 C 运行环境,然后跳转到 main 函数。典型的流程包括:

  1. 复制已初始化数据(data段)从 Flash 到 RAM

    全局变量和静态变量如果被赋了初值(如 int a = 5;),这些初值在编译后存放在 Flash 中。程序运行时需要将它们复制到 RAM 中,因为这些变量可能被修改。

  2. 清零未初始化数据(bss段)

    未初始化的全局/静态变量(如 int b;)默认应为 0,需要将 RAM 中对应的区域全部清零。

  3. 调用系统初始化函数(如 SystemInit)

    设置时钟、配置 Flash 等待周期等硬件基础。

  4. 跳转到 main 函数

    至此,C 世界的大门打开,用户程序开始运行。

下面是一段简化版的 Reset_Handler 汇编示例:

cs 复制代码
assembly

Reset_Handler PROC
    EXPORT Reset_Handler
    ; 1. 复制 data 段
    LDR R0, =_sdata        ; RAM 中 data 段起始地址
    LDR R1, =_edata        ; RAM 中 data 段结束地址
    LDR R2, =_sidata       ; Flash 中 data 段起始地址(存储初值的地方)
    BL   copy_data

    ; 2. 清零 bss 段
    LDR R0, =_sbss
    LDR R1, =_ebss
    BL   zero_bss

    ; 3. 系统初始化
    BL   SystemInit

    ; 4. 跳转到 main(注意 main 是 C 函数,不会返回)
    BL   main

    ; 如果 main 返回,则死循环
    B    .
    ENDP

注意_sdata_edata 等符号由链接脚本定义,指示各段的内存边界。


6. 汇编基础:几条关键指令详解

理解启动过程需要掌握几条基础的 ARM 汇编指令。我们结合启动代码中的实例来讲解。

6.1 读内存:LDR

cs 复制代码
assembly

LDR R0, [R1, #4]   ; 从地址 R1+4 处读取 4 字节,存入 R0
  • LDR = Load Register,从内存加载数据到寄存器。

  • 方括号表示内存地址,[R1, #4] 是"基址+偏移"的寻址方式。

  • 例子中,假设 R1 = 0x20000000,则读取地址 0x20000004 的内容。

6.2 写内存:STR

cs 复制代码
assembly

STR R0, [R1, #4]   ; 将 R0 的值写入地址 R1+4
  • STR = Store Register,将寄存器数据存储到内存。

6.3 算术运算:ADD / SUB

cs 复制代码
assembly

ADD R0, R1, R2     ; R0 = R1 + R2
ADD R0, R0, #1     ; R0 = R0 + 1
SUB R0, R1, R2     ; R0 = R1 - R2
SUB R0, R0, #1     ; R0 = R0 - 1

6.4 比较:CMP

cs 复制代码
assembly

CMP R0, R1         ; 比较 R0 和 R1,结果影响 PSR 中的标志位(Z、C 等)

后面常跟条件跳转指令,如 BEQ(相等时跳转)、BNE(不等时跳转)。

6.5 跳转:B / BL / BX

  • B label :无条件跳转到 label 处,不保存返回地址。

  • BL label:跳转前将下一条指令的地址保存到 LR(R14),用于子程序返回(通常函数末尾用 BX LR 返回)。

  • BX R1 :跳转到 R1 中保存的地址,并切换指令集(如果地址最低位为 1,则切换到 Thumb 模式)。Cortex-M 只支持 Thumb 模式,地址最低位必须为 1。

示例:Reset_Handler 最后调用 main 用的是 BL main,这样 main 返回后可以执行后续的 B . 死循环。


7. 结合向量表理解"启动跳转"全过程

我们用一张图总结复位后的流程:

  1. 硬件复位 → CPU 从 0x00000000 读取 4 字节存入 SP,从 0x00000004 读取 4 字节存入 PC。

  2. PC 指向 Reset_Handler → 执行汇编初始化代码。

  3. 初始化 data/bss → 准备 C 环境。

  4. 调用 SystemInit → 配置时钟等。

  5. 跳转到 main → 进入用户程序。

如果在 main 中发生了中断,CPU 会暂停当前任务,根据中断号在向量表中找到对应的处理函数地址,跳转过去执行,执行完后再用 BX LR 返回。


8. 为什么理解启动流程很重要?

  • 调试启动问题:如果程序烧录后不运行,可能是向量表被破坏,或者栈顶地址错误,导致 SP 初始化失败。

  • Bootloader 开发:需要手动跳转到应用程序,必须知道应用程序的起始地址,并正确设置 MSP 和 PC。通常做法是:读取应用程序向量表的前两个字,分别写入 MSP 和 PC,然后跳转。

  • 中断向量重定位:当应用程序不在 0x08000000 运行时(比如在 0x08004000),必须修改 VTOR 寄存器,告诉 CPU 新的向量表位置。


9. 一个生动的比喻:把启动过程想象成"新书店开业"

  • 向量表 = 书店的"导览图":第一页写着"经理办公室在 10 楼",第二页写着"仓库在 20 楼",后面是各个部门的地址。

  • 复位 = 书店刚开门,经理(CPU)需要先知道自己的办公桌在哪(SP),然后去看仓库在哪(PC)。

  • 从地址 0 读 SP = 经理先看导览图第一页,得知"办公室在 10 楼",于是他拿着钥匙去 10 楼(初始化栈)。

  • 从地址 4 读 PC = 经理再看第二页,得知"仓库在 20 楼",于是他前往仓库开始工作(跳转到 Reset_Handler)。

  • Reset_Handler = 仓库管理员,负责把货物(data 段)从货车上搬到货架(RAM),把空箱子(bss 段)堆好,然后请总经理(main)来主持大局。


10. 动手验证:查看实际启动过程

如果你手头有 STM32 开发板和调试器(如 ST-Link),可以尝试以下操作:

  1. 烧录一个简单的程序(如 LED 闪烁)。

  2. 在调试模式下,复位后暂停,查看寄存器:

    • SP 的值应该等于向量表第一项(__initial_sp)。

    • PC 的值应该等于 Reset_Handler 的地址。

  3. 单步执行,观察 SP 和 PC 的变化,体会硬件自动完成的两步。

你还可以修改链接脚本,将应用程序放到另一个地址,然后手动设置 VTOR,看看中断是否还能正常工作。


11. 常见疑问解答

Q:向量表必须从 0 地址开始吗?

A:复位后默认从 0 开始,但可以通过 SCB->VTOR 重新定位。例如在 Bootloader 中,可能将应用程序的向量表设在 0x08004000,并设置 VTOR 指向那里。

Q:为什么栈顶地址必须放在第一个?

A:因为复位后 CPU 需要立即使用栈来保存现场、调用函数。如果没有栈,第一个 PUSH 指令就会出错。硬件设计者巧妙地利用了向量表的第一个位置来初始化 SP。

Q:如果向量表被破坏了会怎样?

A:如果第一个字不是有效的栈地址(比如超出了 RAM 范围),或者第二个字不是可执行地址,程序可能会立即进入 HardFault,或者直接跑飞。这也是为什么烧录错误的程序会导致芯片变"砖"的原因之一。

Q:模拟信号需要向基址地址空间写入吗?

A:这个问题与启动无关,属于外设操作。模拟信号(如 ADC 采样)通常不直接"写入"地址空间,而是配置寄存器启动转换,然后读取结果寄存器。但所有外设寄存器的操作都是通过向特定地址空间写入控制字实现的,这点与内存读写类似。


12. 总结

ARM Cortex-M 的启动流程设计得非常精巧,用简单的两步硬件操作就为 C 程序铺好了道路。理解这一过程,不仅能帮你快速定位启动故障,更是深入学习 Bootloader、RTOS 移植、中断管理的基础。

希望这篇笔记能成为你随时翻阅的"启动流程百科全书"。下次调试时,不妨想想:此刻 CPU 正在从 Flash 的哪个地址读取什么内容?向量表是不是放对了地方?SP 和 PC 的值合理吗?带着这些问题,你会对嵌入式底层有更深刻的认识。

相关推荐
叫我韬韬2 小时前
硬核调试:在 Keil 中通过全手动栈回溯定位 FreeRTOS 死机任务
stm32·单片机·freertos
cqbzcsq2 小时前
MC Forge1.20.1 mod开发学习笔记(个人向)
笔记·学习·mod·mc·forge
jyhappy1232 小时前
深入理解 STM32 的 GPIO — 从零开始点亮第一颗 LED
c语言·stm32·单片机·嵌入式硬件·mcu
蒸蒸yyyyzwd2 小时前
cpp学习笔记
笔记·学习
济6172 小时前
ARM Linux 驱动开发篇---Linux 设备树(DTS)语法-- Ubuntu20.04
arm开发·嵌入式linux驱动开发
济6172 小时前
ARM Linux 驱动开发篇---Linux 设备树简介-- Ubuntu20.04
linux·arm开发·嵌入式linux驱动开发
浅念-2 小时前
C++ STL vector
java·开发语言·c++·经验分享·笔记·学习·算法
qyhua2 小时前
春节怀旧:翻出 20 年前的 VB6 书籍与老 CPU 记忆
笔记·其他
winfreedoms2 小时前
ROS2主题通讯——黑马程序员ROS2课程上课笔记(2)
笔记