2. 裸机程序启动流程与链接脚本:启动文件分析、向量表、链接脚本(.lds)编写、程序入口与堆栈设置

好,咱们今天聊点实在的。你写了一个 main() 函数,然后点了一下编译按钮,程序就跑起来了。但你想过没有——是谁把 main() 叫醒的?

在 ARM 裸机世界里,没有操作系统帮你打理这些。芯片上电后,第一行代码从哪里开始?全局变量怎么初始化?堆栈指针是谁设的?这些事,全得你自己安排。

我刚开始做裸机开发时,也以为写个 main() 就完事了。结果第一次调试,程序直接跑飞。查了半天才发现——向量表没放对位置。嗯,从那以后,我再也不敢跳过启动流程了。

2.1 芯片上电后,CPU 在干什么?

ARM Cortex-M 系列上电复位后,硬件会自动做两件事:

  1. 从 0x00000000 地址读取 栈顶指针(MSP),加载到 SP 寄存器。
  2. 从 0x00000004 地址读取 复位向量,加载到 PC 寄存器。

说白了,芯片一通电,CPU 就盯着这两个地址看。你放对了,程序就跑;放错了,直接 HardFault。

核心要点:ARM Cortex-M 的启动流程完全由硬件决定。向量表的前两个字必须是 MSP 和 Reset_Handler,顺序不能乱。

2.2 向量表——中断的“电话本”

向量表本质上是一个 函数指针数组。每个中断源对应一个表项,存放着该中断服务函数的地址。

以 STM32F103(Cortex-M3)为例,向量表布局如下:

偏移地址 内容 说明
0x0000_0000 MSP 初始值 栈顶指针,由链接脚本提供
0x0000_0004 Reset_Handler 复位中断入口
0x0000_0008 NMI_Handler 不可屏蔽中断
0x0000_000C HardFault_Handler 硬错误处理
0x0000_0010 MemManage_Handler 内存管理错误(M3/M4)
0x0000_0014 BusFault_Handler 总线错误
0x0000_0018 UsageFault_Handler 用法错误
… … …
0x0000_0040 SVC_Handler 系统服务调用
0x0000_0044 DebugMon_Handler 调试监视器
0x0000_0048 PendSV_Handler 可挂起系统调用
0x0000_004C SysTick_Handler 系统滴答定时器
0x0000_0050 外部中断 0 IRQ0,芯片厂商定义

我的习惯:在启动文件中,我会把不用的中断都指向一个 Default_Handler。这样万一触发了未使能的中断,至少能进一个空函数,而不是 HardFault。调试时也能快速定位问题。

2.3 启动文件——真正的“入口”

启动文件(startup_xxx.s)是汇编写的。它负责三件事:

  1. 定义向量表,把各个中断处理函数的地址按顺序放好。
  2. 初始化全局变量,把 .data 段从 Flash 拷贝到 RAM,把 .bss 段清零。
  3. 调用 main(),把控制权交给 C 语言世界。

我贴一段典型的启动文件核心代码,你感受一下:

; 向量表定义
__Vectors       DCD     __initial_sp          ; 栈顶指针
                DCD     Reset_Handler         ; 复位向量
                DCD     NMI_Handler
                DCD     HardFault_Handler
                ; ... 其他中断向量 ...

; 复位处理函数
Reset_Handler   PROC
                EXPORT  Reset_Handler [WEAK]
                IMPORT  __main
                IMPORT  SystemInit

                LDR     R0, =SystemInit
                BLX     R0                  ; 调用系统初始化
                LDR     R0, =__main
                BX      R0                  ; 跳转到 C 库的 __main
                ENDP

注意看,Reset_Handler 先调了 SystemInit(配置时钟、PLL 等),然后跳转到 __main。这个 __main 是 C 库提供的,它会完成 .data 和 .bss 的初始化,最后才调用你的 main()。

我曾经踩过的坑:有一次我忘了在启动文件里调用 SystemInit,结果芯片跑在默认的 HSI 时钟上,频率只有 8MHz。外设怎么调都不对,查了整整一天。后来用示波器一看时钟脚——嗯,8MHz。从那以后,我每次写启动文件都会确认时钟初始化有没有被调用。

2.4 链接脚本——告诉编译器“东西放哪”

链接脚本(.lds 或 .ld)是链接器的“地图”。它告诉链接器:

  • 代码段(.text)放在 Flash 的哪个地址。
  • 数据段(.data)的 LMA(加载地址)和 VMA(运行地址)分别在哪。
  • BSS 段(.bss)从哪开始、多大。
  • 堆和栈的边界在哪。

一个典型的 STM32 链接脚本长这样:

/* 内存区域定义 */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

/* 段布局 */
SECTIONS
{
    /* 向量表:必须放在 Flash 起始位置 */
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

    /* 代码段 */
    .text :
    {
        . = ALIGN(4);
        *(.text)
        *(.text*)
        *(.glue_7)
        *(.glue_7t)
        *(.rodata)
        *(.rodata*)
        . = ALIGN(4);
        _etext = .;
    } > FLASH

    /* 数据段:LMA 在 Flash,VMA 在 RAM */
    _sidata = .;
    .data : AT(_sidata)
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM

    /* BSS 段:在 RAM 中,运行时清零 */
    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss)
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > RAM

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

这里有几个关键符号:

  • _sidata:.data 段在 Flash 中的起始地址(LMA)。
  • _sdata / _edata:.data 段在 RAM 中的起始和结束地址(VMA)。
  • _sbss / _ebss:.bss 段的起始和结束地址。
  • _Min_Heap_Size / _Min_Stack_Size:堆和栈的最小尺寸。

我个人习惯:在链接脚本里显式定义 _Min_Stack_Size 和 _Min_Heap_Size,而不是用默认值。这样万一堆栈溢出,至少能通过 map 文件快速定位问题。我一般设栈 1KB、堆 512B,够用就行。

2.5 堆栈设置——别让程序“无家可归”

堆栈的设置分两步:

  1. 链接脚本:预留堆栈空间,定义栈顶地址 __initial_sp。
  2. 启动文件:把 __initial_sp 放到向量表的第一个位置。

在链接脚本中,栈顶地址通常这样计算:

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

为什么栈顶要放在 RAM 的最高地址?因为栈是向下生长的。栈顶在最高地址,栈指针递减时不会立即踩到 .data 和 .bss 段。

我曾经遇到的情况:有一次我把栈顶设在了 RAM 的中间位置,结果递归调用一深,栈直接覆盖了全局变量区。程序表现非常诡异——有时候正常,有时候跑飞。后来用 map 文件一查,发现栈和 .bss 段重叠了。嗯,从那以后我坚持把栈顶放在 RAM 末尾。

2.6 完整启动流程——一张图看懂

下面这张图把整个启动流程串起来了:

ARM Cortex-M 裸机启动流程 ARM Cortex-M 裸机启动流程 芯片上电复位 硬件读取 0x0000 的 MSP 读取 0x0004 的 Reset_Handler 跳转到 Reset_Handler SystemInit() 配置时钟 __main() 初始化 .data/.bss 进入 main() 函数 关键说明 向量表必须放在 Flash 起始 前两个字:MSP + Reset_Handler 链接脚本决定各段位置 避坑提示 栈顶放在 RAM 末尾 未用中断指向 Default_Handler

2.7 常见问题与避坑指南

问题 现象 原因 解决方法
程序跑飞 上电后直接 HardFault 向量表地址不对,或 MSP 值错误 检查链接脚本中 .isr_vector 段的位置
全局变量值不对 变量初始值不是代码中写的 .data 段未从 Flash 拷贝到 RAM 检查启动文件中拷贝循环是否正确
堆栈溢出 函数调用深时死机 栈空间不足,或栈顶位置不对 增大栈空间,确认栈顶在 RAM 末尾
中断不响应 触发中断后无反应 中断向量表未正确映射 检查中断号与向量表偏移是否匹配

调试小技巧:第一次调试裸机程序时,我习惯在 Reset_Handler 的第一行加个 GPIO 翻转。如果上电后 GPIO 没反应,说明连启动文件都没跑进去。这时候别查 C 代码,先查链接脚本和向量表。

好了,启动流程和链接脚本就聊到这儿。这些东西看起来琐碎,但它们是裸机程序的根基。你把这些搞明白了,后面写外设驱动、调中断,心里就有底了。