第四章 ARM Cortex-M启动详解:中断向量表、堆栈初始化、C运行时环境

好,咱们今天聊点硬核的。上一章我们把启动流程的骨架搭起来了,这一章,我们要往里面填血肉。

ARM Cortex-M 的启动,说白了就是三件事:把中断向量表摆对位置、把堆栈指针指对地方、把 C 语言的世界准备好。这三件事做不好,你的程序连个 printf 都跑不起来。

我个人习惯把启动过程分成两个阶段:硬件自动完成的,和软件需要手动做的。咱们一个一个来拆。

4.1 中断向量表:处理器的第一张地图

Cortex-M 上电后,处理器会干一件很聪明的事——它自动从地址 0x00000000 读取两个值:

  • 地址 0x00000000:存放主堆栈指针(MSP)的初始值
  • 地址 0x00000004:存放复位向量,也就是复位后第一条指令的地址

嗯,这里要注意:向量表里存的不是函数,是地址。Cortex-M 的向量表,每个条目占 4 字节,第 0 项是栈顶,第 1 项是复位向量,后面依次是各种异常和中断的入口地址。

核心要点:向量表必须 256 字节对齐。因为 Cortex-M 用向量表偏移寄存器(VTOR)的低 9 位作为标志位,所以对齐要求是 2^9 = 512 字节?不对,实际上是 256 字节。我当年第一次写链接脚本时,就因为对齐搞错了,程序死活跳不到 main——后来查了一下午才发现是向量表没对齐。

来看一个典型的向量表定义:

__attribute__ ((section(".isr_vector")))
void (* const g_pfnVectors[])(void) = {
    (void (*)(void))(&_estack),          // 0: 栈顶地址
    Reset_Handler,                       // 1: 复位向量
    NMI_Handler,                         // 2: NMI
    HardFault_Handler,                   // 3: 硬错误
    MemManage_Handler,                   // 4: 内存管理错误
    BusFault_Handler,                    // 5: 总线错误
    UsageFault_Handler,                  // 6: 使用错误
    // ... 后面还有一堆
};

你想想看,这个数组被放在 .isr_vector 段里,链接脚本会保证这个段从 0x08000000(Flash 起始地址)开始。上电后,处理器自动从这里取数,启动就开始了。

避坑指南:我曾经在一个项目里,把向量表放在了 RAM 里,想实现运行时动态修改中断优先级。结果忘了在启动早期把 VTOR 寄存器指向 RAM 地址,导致所有中断都跑飞了。记住:如果向量表不在默认位置,必须在 C 运行时环境初始化之前,手动设置 SCB->VTOR

4.2 堆栈初始化:给 C 语言一个落脚点

堆栈是 C 语言的命根子。局部变量、函数调用、参数传递,全得靠它。

Cortex-M 支持两种堆栈模式:主堆栈(MSP)进程堆栈(PSP)。简单说,MSP 给中断服务程序用,PSP 给线程代码用。但大多数裸机程序只用 MSP,省心。

堆栈初始化其实就两件事:

  1. 告诉链接器栈顶在哪——通常在链接脚本里定义 _estack 符号
  2. 确保向量表第一项指向这个地址——就是我们刚才看到的 &_estack

链接脚本里一般这么写:

_estack = ORIGIN(RAM) + LENGTH(RAM);  // 栈顶在 RAM 的最高地址

为什么栈顶要放在 RAM 末尾?因为栈是向下生长的。你把栈顶放在最高地址,栈往低地址长,就不会跟全局变量区冲突。这个设计很巧妙,对吧?

警告:堆栈大小一定要算清楚。我见过一个同事,在中断里递归调用了 3 层函数,每层又分配了 256 字节的局部数组,结果栈直接溢出了。现象很诡异——程序跑着跑着就进 HardFault,而且每次崩溃的位置还不一样。最后用调试器看 SP 寄存器,发现已经跑到 RAM 的全局变量区了。

4.3 C 运行时环境初始化:BSS 清零与数据段拷贝

好,现在处理器跑起来了,栈也设好了。但 C 语言的世界还没准备好。

你想想看,C 语言里那些全局变量,比如 int g_counter = 0; 或者 char g_buffer[1024];,它们初始值从哪来?

答案是:从 Flash 拷贝到 RAM

这里有两个关键段:

段名 存放内容 初始化方式
.bss 未初始化或初始化为 0 的全局/静态变量 运行时清零
.data 已初始化的全局/静态变量 从 Flash 拷贝到 RAM

链接脚本会生成几个关键符号:

_sdata, _edata    // data 段的起始和结束地址(RAM 中)
_sidata           // data 段的初始值在 Flash 中的地址
_sbss, _ebss      // bss 段的起始和结束地址

启动代码里,我们得手动做这两件事:

void __attribute__((naked)) Reset_Handler(void)
{
    // 1. 设置栈指针
    __set_MSP((uint32_t)&_estack);

    // 2. 拷贝 data 段
    uint32_t *pSrc = &_sidata;
    uint32_t *pDst = &_sdata;
    while (pDst < &_edata) {
        *pDst++ = *pSrc++;
    }

    // 3. 清零 bss 段
    for (uint32_t *p = &_sbss; p < &_ebss; p++) {
        *p = 0;
    }

    // 4. 跳转到 main
    main();

    // 5. main 不应该返回,但如果返回了...
    while (1);
}

嗯,这里要注意:__attribute__((naked)) 告诉编译器,不要在这个函数里生成栈帧。因为此时栈还没初始化好,编译器要是自作主张 push/pop,程序就崩了。

个人经验:我习惯在 BSS 清零之前,先做一次简单的栈测试——往栈顶写一个特殊值,等初始化完成后检查这个值有没有被覆盖。这样能早期发现栈溢出问题。代码大概长这样:

// 栈顶标记
volatile uint32_t stack_magic = 0xDEADBEEF;
// 在 main 里检查
if (stack_magic != 0xDEADBEEF) {
    // 栈溢出了!
}

4.4 跳转到 main:最后的临门一脚

所有初始化都完成后,最后一步就是调用 main()

但这里有个细节:main 函数不应该返回。嵌入式程序是个死循环,main 里通常有个 while(1)。如果 main 真的返回了,我们得有个兜底方案——要么重新初始化,要么直接死循环。

我见过一些启动代码,在 main 返回后会调用 exit() 或者 _sys_exit()。但在裸机环境下,这些函数通常是个空实现,或者直接触发一个断言。我个人更倾向于:

void _start(void)
{
    // ... 初始化 ...
    main();
    // main 返回后,直接软复位
    NVIC_SystemReset();
}

这样至少保证了系统不会在 main 返回后处于一个不确定的状态。

一个小技巧:如果你用 GCC,可以在链接脚本里把 main 的入口地址改成 _start,然后在 _start 里做所有初始化。这样你的启动代码就跟编译器无关了,移植起来特别方便。

4.5 总结:启动流程全景图

好,我们把这一章的内容串起来,看看完整的启动流程:

  1. 上电复位:处理器从 0x00000000 取栈顶指针,从 0x00000004 取复位向量
  2. 硬件自动设置 MSP:处理器把栈顶值写入主堆栈指针寄存器
  3. 跳转到 Reset_Handler:执行第一条指令
  4. 软件初始化
    • (可选)重新设置 MSP
    • 拷贝 .data 段从 Flash 到 RAM
    • 清零 .bss 段
    • (可选)设置 VTOR 寄存器
    • (可选)初始化堆管理器
  5. 调用 main:进入用户程序

这个过程,说白了就是给 C 语言铺好路。路铺好了,main 才能跑得稳。

下一章,我们会深入链接脚本,看看那些 _sdata_ebss 符号到底是怎么来的。到时候你会发现,链接脚本才是启动代码的真正幕后黑手。