💡 本文是《STM32内核精讲》栏目的第十篇。前九篇我们学习了寄存器模型、异常处理、AAPCS 等。从本篇开始,我们将聚焦于代码的“源头”——从上电到 main() 之间发生了什么。理解复位序列和启动文件,是读懂启动代码、实现 Bootloader、排查 HardFault 的根本前提。


📌 一、引言:从“上电”到“main()”之间发生了什么?

很多嵌入式初学者以为,把程序烧写到 Flash 里,上电后就会自动执行 main()。但实际上,main() 只是故事的结局,而不是开始。

在上电到 main() 之间,经历了一系列硬件自动执行启动代码配合完成的关键步骤:

  • 硬件从向量表自动取出初始 MSP 和复位向量;
  • 启动代码设置堆栈、初始化中断向量表;
  • 可选的 SystemInit() 配置系统时钟;
  • C 运行时初始化(__main)负责将已初始化数据从 Flash 复制到 RAM,并将未初始化数据清零;
  • 最后才跳转到 main(),开始执行用户的 C 程序。

这一整套流程,通常被封装在芯片厂商提供的 startup_xxx.s 启动文件中。但理解其内部机制,对调试 HardFault、优化代码尺寸、实现 Bootloader 乃至手写裸机程序都至关重要。


📌 二、复位序列:硬件自动完成的两件大事

当 MCU 上电或外部复位引脚被拉低后,Cortex‑M 内核会自动执行一个非常简洁但关键的序列。在离开复位状态后,Cortex‑M 做的第一件事就是读取以下两个 32 位整数的值:

  • 从地址 0x0000_0000 取出 MSP(主堆栈指针) 的初始值
  • 从地址 0x0000_0004 取出 PC 的初始值(复位向量)

然后将这两个值分别写入 MSP 和 PC 寄存器。这整个过程是硬件自动完成的,不需要任何软件指令干预

Cortex‑M 与传统 ARM 的一个重要区别在于:传统 ARM 架构总是从 0 地址执行第一条指令,而 Cortex‑M 的 0 地址处提供的是 MSP 的初始值,而非指令。

由于 Cortex-M 使用向下生长的满栈,MSP 的初始值必须是堆栈内存的末地址加 1。举个例子:

  • 如果堆栈区域占据 0x20007C00 ~ 0x20007FFF,则 MSP 初始值应为 0x20008000(即堆栈起始地址 + 堆栈大小

这种预先初始化堆栈的机制确保了:即使复位后的第一条指令还没来得及执行就发生了 NMI 或其他 Fault,中断服务程序也已经有了可用的堆栈。


📌 三、向量表的结构与填充

3.1 向量表的结构

向量表(Vector Table)是一个以 WORD(32位)为单元的数组,存储在 MCU 内存的起始位置(默认 0x0000_0000),每个条目存放对应异常或中断服务程序(ISR)的入口地址。

在 Cortex-M3/M4 架构中,向量表的内容依次为:

偏移地址 内容 说明
0x000 __initial_sp 初始主堆栈指针
0x004 Reset_Handler 复位处理程序地址
0x008 NMI_Handler 不可屏蔽中断处理程序
0x00C HardFault_Handler 硬故障处理程序
0x010 MemManage_Handler 存储器管理故障(仅 M3/M4/M7 支持)
0x014 BusFault_Handler 总线故障
0x018 UsageFault_Handler 用法故障
0x03C PendSV_Handler PendSV 处理程序
0x040 SysTick_Handler SysTick 定时器中断
0x044+ IRQ0_Handler 外部中断

在向量表中放置的是 32 位地址而非跳转指令。由于 Cortex-M 在 Thumb 状态下执行,向量表中的每个地址都必须将最低位(LSB)置 1(即地址为奇数),表示 Thumb 模式。

启动文件 startup.s 通过 AREA 伪指令定义 RESET 段,将其放置在 Flash 的 0 地址处。MDK 生成的分散加载文件将 RESET 段设置在 Flash 的起始地址,从而确保了向量表在正确的位置。

3.2 向量表重定位(VTOR)

向量表默认位于 Flash 起始地址(0x00000000),但在 Bootloader 跳转、OTA 升级或需要动态修改中断处理函数时,可能需要将向量表重定位到 RAM 或其他地址。

Cortex‑M3/M4/M7/M33 等内核通过 VTOR(Vector Table Offset Register)寄存器支持向量表重定位。VTOR 位于系统控制块(SCB)中,地址为 0xE000ED08。在绝大多数 STM32 型号中,VTOR 复位值为 0,即向量表从地址 0x00000000 开始。

VTOR 对齐要求

  • 架构下限:Cortex‑M 架构规定 VTOR 的 bit[6:0] 为保留位(必须为 0),因此最低要求 128 字节对齐
  • 芯片实现:以 STM32 为例,其外部中断数量约 84 个,加上 16 个系统异常,总异常数向上取整到 128 个。128 × 4 = 512 字节,因此实际要求 512 字节对齐。不同系列可能要求不同,请以对应参考手册为准。

重定位的核心代码(增加了更严谨的对齐检查):

// 设置 VTOR 寄存器
#define SCB_VTOR (*((volatile uint32_t *)0xE000ED08))

void relocate_vector_table(uint32_t new_address) {
    // 实际要求:对齐到 512 字节(最低 128 字节,STM32 通常要求 512)
    if (new_address & 0x1FF) {   // 检查低 9 位是否为 0(512 对齐)
        return;  // 对齐错误,不执行
    }
    SCB_VTOR = new_address;
    __DSB();
    __ISB();   // 确保后续指令使用新向量表
}

在 Bootloader 跳转到应用程序前,通常需要从应用程序的起始地址读取初始 MSP 和复位向量,设置 MSP 后跳转到复位向量执行。


📌 四、SystemInit() 与时钟配置(仅内核视角)

在启动代码中,Reset_Handler 通常会调用 SystemInit() 函数,然后才进入 C 运行时初始化。SystemInit() 通常由芯片厂商提供(位于 system_stm32xxx.c),用于配置系统时钟、PLL 等

SystemInit() 的主要职责(依据 CMSIS 规范)包括:

  • 选择系统时钟源(HSI/HSE/PLL)
  • 配置 PLL 倍频和分频因子
  • 设置 AHB/APB 总线预分频器
  • 更新 SystemCoreClock 全局变量,供 SysTick 等外设使用。

部分 STM32 系列还在 SystemInit() 中配置 Flash 等待周期(Flash Wait State),这属于芯片厂商的实现细节,并非 CMSIS 强制要求。但它是保证高主频下 Flash 访问稳定的必要步骤。

与专栏定位的衔接
需要强调的是,SystemInit() 的时钟配置是内核能够以正确频率运行的前提——SysTick 定时器的精确延时、指令执行速度、总线访问时序,都建立在正确的时钟配置之上。如果时钟配置错误,后续所有依赖时间基准的内核功能(如 SysTick 的中断周期)都会出现偏差。本专栏不深入具体寄存器配置细节(属于芯片厂商外设范畴),但读者应当知道:在启动代码中,系统时钟的配置优先级高于 C 运行时初始化,因为时钟正确后,后续的 Flash 复制(从 Flash 到 RAM)才能以正确的时序完成。


📌 五、__main 与 C 运行时初始化

链接器会将 C 库中的 __main 入口函数链接到程序中,负责建立 C 运行环境(__main 不是用户写的 main 函数)。以 ARM Compiler(MDK)为例,其初始化序列包含三个功能块:

  1. __main → __scatterload:负责建立运行时的存储器映射。
  2. __scatterload:将已初始化数据(RW 段)从加载地址(Flash)复制到执行地址(RAM),并将未初始化数据(ZI 段)清零。
  3. __rt_entry:设置应用程序的栈和堆,初始化 C 库,然后跳转到用户 main()

当用户程序执行完毕后,__rt_entry 负责关闭 C 库并将控制权交还给调试器(在裸机嵌入式应用中,main() 通常不会返回)。

5.1 __scatterload 的作用

映像文件在 Flash 中的布局为:RO → RW → ZI。其中 RO(代码和只读数据)在支持 XIP(就地执行)的系统中(如 STM32)在 Flash 中原地执行;RW(已初始化的全局变量)必须从 Flash 复制到 RAM;而 ZI(未初始化的全局变量)则只需在 RAM 中保留空间并清零,没有数据从 Flash 复制。

__scatterload 执行的核心工作包括:

  • 将 RW 段(初始值存储在 Flash 中)复制到 RAM 中的目标地址;
  • 将 ZI 段所在的 RAM 区域清零;
  • 最后跳转到 __rt_entry

若跳过 RW 段复制和 ZI 段清零,全局变量的值将不可预测,这是程序运行时出现“未初始化变量不归零”或“初始值与预期不符”的根源。

5.2 __rt_entry 的作用

__rt_entry 负责:

  • 设置应用程序的栈和堆边界;
  • 初始化标准 C 库函数及其静态数据;
  • 在 C++ 程序中调用全局对象的构造函数;
  • 最后跳转到 main() 函数。

使用微库(–specs=nano.specs)时,初始化过程有所简化,但基本逻辑不变。

5.3 为什么需要 __main?

main() 函数的存在本身会强制链接器链接 __main__rt_entry 中的初始化代码。如果没有 main()(例如直接用汇编编写的裸机程序),这些初始化步骤就不会被链接,C 库中的某些功能(如 printfmalloc)将无法正常工作。

对于手工编写的裸机程序(如 10.5 节的最小化启动),往往绕过 __main,在汇编中直接跳转到 main,但这要求用户自行完成 RW/ZI 初始化,且不依赖 C 库的标准输入输出功能。


📌 六、链接脚本与内存布局概述

启动代码和 C 运行时初始化的行为,依赖于链接脚本(Linker Script,.ld 文件)定义的内存布局。以下是链接脚本如何与本章前文内容衔接的关键点:

  • 链接脚本的 MEMORY 区域FLASHRAM)定义了 Flash 和 RAM 的地址范围,直接影响 §4 中 SystemInit() 的时钟配置可行性;
  • 向量表 通过 SECTIONS.isr_vector 段放置到 Flash 起始地址,与 §3 的向量表位置要求直接对应;
  • RW 段和 ZI 段 在链接脚本中分别被分配加载地址(AT > FLASH)和执行地址(> RAM),§5 中 __scatterload 正是依据这些链接器符号完成 RW 段的复制和 ZI 段的清零;
  • 链接脚本定义的 _estack 符号直接对应 §2 中 MSP 初始值的来源。

链接脚本与启动代码的协作关系可以概括为:链接脚本告诉链接器代码和数据放在哪里(编译时),启动代码在运行时根据这些信息建立正确的 C 运行环境。这是嵌入式裸机系统的基本“分工协作”模式。

以下是一份简化版的链接脚本关键片段(以 STM32 为例,其他芯片地址可能不同):

MEMORY
{
  FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 2M   /* STM32 Flash 起始地址 */
  RAM (rw)    : ORIGIN = 0x20000000, LENGTH = 128K /* STM32 RAM 起始地址 */
}

SECTIONS   
{
  /* 向量表段,必须位于 Flash 起始地址 */
  .isr_vector :
  {
    . = ALIGN(4);
    KEEP(*(.isr_vector))
  } > FLASH
  /* 注:.isr_vector 作为第一个段配合 MEMORY 中 Flash 起始地址,可保证向量表位于 Flash 起始处。实际工程中也可通过 AT 表达式显式指定。 */
  /* 代码段 */
  .text : { *(.text) } > FLASH

  /* 只读数据段 */
  .rodata : { *(.rodata) } > FLASH

  /* 已初始化数据段——加载地址在 Flash,运行地址在 RAM */
  .data : 
  {
    . = ALIGN(4);
    _sdata = .;
    *(.data)
    . = ALIGN(4);
    _edata = .;
  } > RAM AT > FLASH
  _sidata = LOADADDR(.data);

  /* 未初始化数据段 */
  .bss :
  {
    . = ALIGN(4);
    _sbss = .;
    *(.bss)
    . = ALIGN(4);
    _ebss = .;
  } > RAM

  /* 堆和栈区域 */
  ._user_heap_stack :
  {
    . = ALIGN(8);
    . = . + _Min_Heap_Size;
    . = . + _Min_Stack_Size;
    . = ALIGN(8);
  } > RAM
}

注意:以上链接脚本语法适用于 GNU Linker (LD),与 ARM Compiler 的分散加载文件(.sct)语法不同。不同开发环境请选择对应的链接脚本。

理解链接脚本的结构和含义,是进一步深入理解 §5 C 运行时初始化、以及自行编写最小化启动代码的必由之路。


📌 七、最小化启动:纯汇编实现

在某些极简场景中,我们希望绕过庞大的启动文件和 C 运行时库,仅用纯汇编实现一个最简单的“裸机”程序。这种思路常用于:

  • 理解启动过程的最小化演示;
  • 开发 Bootloader 的核心框架;
  • 极度受限的资源环境(代码尺寸只有几百字节)。

7.1 最小启动代码(纯汇编)

以下是一份可直接运行的 Cortex‑M4 最小启动汇编代码(ARM Compiler / Keil 语法

; minimal_startup.s
; 适用于 Cortex-M4 (STM32F4 等)
; 语法:ARM Compiler (Keil MDK)

    AREA    RESET, CODE, READONLY
    THUMB

; 导出向量表符号,供链接器使用
    EXPORT  __Vectors
    EXPORT  Reset_Handler

; 定义向量表
__Vectors
    DCD     __initial_sp          ; 0x00: 初始 MSP
    DCD     Reset_Handler         ; 0x04: 复位向量

; 复位处理程序
Reset_Handler
    ; 设置堆栈指针(若硬件已加载,本行可省略,但显式设置更安全)
    LDR     SP, =__initial_sp

    ; 可选:设置全局指针(某些 ABI 需要)
    ; LDR    R0, =_sdata
    ; LDR    R1, =_edata
    ; ... 复制 RW 段(如果需要)

    ; 跳转到主程序(C 函数或汇编循环)
    BL      main
    B       .                     ; 死循环(防止跑飞)

    END

同时需要提供一个 main.c(最简单的 C 程序,但不依赖完整 C 运行时):

// main.c - 不依赖标准库的最小 C 程序
void main(void) {
    while (1) {
        // 用户代码(例如 GPIO 翻转,不涉及外设配置)
    }
}

7.2 绕过 C 运行时初始化

上面的代码中,Reset_Handler 直接调用 main跳过了标准的 C 运行时初始化过程。这意味着:

  • 全局变量不会自动初始化:初值非零的全局变量将保持未初始状态(未从 Flash 复制)。
  • ZI 段(BSS,未初始化的全局变量和静态局部变量)不会自动清零。
  • 标准 C 库(如 printfmalloc)无法正常工作。

因此,这种最小启动方式仅适用于:

  • 完全用汇编或纯 C 且不依赖全局变量和静态局部变量的程序;
  • 用户自己实现 RW 段复制和 ZI 段清零的场景;
  • 用于教学演示,帮助理解启动代码本质。

如果需要支持全局变量,需要在 Reset_Handler 中手工添加 RW 段复制和 ZI 段清零的代码,并通过链接脚本获得 _sdata_edata_sbss_ebss 等符号。有兴趣的读者可以参考编译器和链接器手册中的详细说明。


📌 八、总结

8.1 本篇核心要点

  1. 复位序列:硬件自动从 0x00000000 取初始 MSP,从 0x00000004 取复位向量,然后跳转执行。
  2. 向量表:位于 Flash 起始地址,第一项是初始 MSP,第二项是 Reset_Handler,后面依次为各异常和中断的入口地址。通过 VTOR 可实现向量表重定位(架构下限 128 字节对齐,STM32 通常要求 512 字节对齐)。
  3. SystemInit():在进入 C 运行时前配置系统时钟和 PLL,为内核运行提供正确的时间基准,直接关系 SysTick 等定时器功能的准确性。Flash 等待周期等属于厂商实现细节。
  4. C 运行时初始化__main__scatterload 负责 RW 段复制和 ZI 段清零,__rt_entry 设置堆栈并初始化 C 库,最后跳转到 main()
  5. 链接脚本:定义了 Flash 和 RAM 的地址范围、各内存段(.isr_vector.text.data.bss 等)的映射关系,以及堆栈边界。启动代码和 C 运行时的行为依赖链接脚本生成的关键符号。不同工具链语法有差异。
  6. 最小化启动:纯汇编实现的最简启动代码,可跳过 C 运行时初始化,适用于资源受限或教学场景。注意绕过初始化会导致全局变量和静态局部变量未初始化。

8.2 下篇预告:《低功耗模式 —— 内核的睡眠哲学》

从下篇开始,我们将探索内核的低功耗机制:睡眠、深度睡眠与待机模式的区别,WFI/WFE 指令的使用,以及 RTOS 空闲任务中如何优雅进入低功耗(Tickless 模式本质)。


💬 读者问题专栏 · 问题征集

你在阅读启动代码或调试系统启动问题时,是否遇到过:

  • 程序跳转到 main() 前卡死在某个地方(通常与 RW/ZI 段初始化或 VTOR 配置相关)?
  • 全局变量初值不符合预期,怀疑 RW 段复制环节有问题?
  • 手工编写裸机程序时,不知道如何正确设置向量表和堆栈?
  • Bootloader 跳转到应用程序后,中断无法响应(通常与向量表重定位缺失有关)?

欢迎留言,我会在 《Cortex‑M 有问必答》 中专题解答。


📢 关于作者与更多内容

我是 BackCatK Chen,长期关注嵌入式底层、国产半导体与 AI 算力芯片。

如果你对芯片架构、行业趋势感兴趣,欢迎关注我的公众号,获取更多宏观技术观察。


文章标签Cortex-M 复位序列 启动文件 向量表 VTOR SystemInit C运行时 链接脚本

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐