4、链接脚本与启动文件:链接脚本(.ld)结构解析、启动文件(startup.s)详解、堆栈设置与中断向量表、代码重定位基础

好,咱们进入第四讲。这一章可以说是 Bootloader 的骨架——链接脚本和启动文件。很多新手写 Bootloader,代码逻辑没问题,但一跑就死,十有八九是这两块没搞对。

我个人习惯,每做一个新项目,第一件事就是先把链接脚本和启动文件搭好。这就像盖房子打地基,地基歪了,后面装修再漂亮也没用。

4.1 链接脚本(.ld)结构解析

链接脚本,说白了就是告诉编译器:你的代码该放哪儿,数据该放哪儿。对于嵌入式系统来说,这尤其重要——因为我们的程序不是跑在操作系统上,而是直接跑在裸金属上。

一个典型的 .ld 文件长这样:

ENTRY(Reset_Handler)

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    .isr_vector :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))
        . = ALIGN(4);
    } > FLASH

    .text :
    {
        . = ALIGN(4);
        *(.text)
        *(.text*)
        *(.glue_7)
        *(.glue_7t)
        *(.rodata)
        *(.rodata*)
        . = ALIGN(4);
    } > FLASH

    .data :
    {
        . = ALIGN(4);
        _sdata = .;
        *(.data)
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM AT> FLASH

    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss)
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _ebss = .;
    } > RAM
}

这里有几个关键点,我重点说一下:

  • ENTRY(Reset_Handler):指定程序入口。芯片上电后,第一条指令就是 Reset_Handler。
  • MEMORY 区域:定义物理存储。FLASH 是只读的,RAM 是可读可写的。地址别搞错,我见过有人把 STM32F103 的 FLASH 地址写成 0x08000000 以外的值,结果程序死活跑不起来。
  • .isr_vector 段:中断向量表。必须放在 FLASH 的最开头,这是硬件规定的。
  • .data 段:已初始化的全局变量。注意它有个 AT> FLASH,意思是运行时在 RAM,但初始值存在 FLASH 里。启动文件需要把这些值从 FLASH 拷贝到 RAM。
  • .bss 段:未初始化的全局变量。启动文件需要把这部分清零。

重要提示:链接脚本中的符号(如 _sdata、_edata、_sbss、_ebss)是给启动文件用的。启动文件通过访问这些符号,才知道要拷贝多少数据、清零多大区域。

4.2 启动文件(startup.s)详解

启动文件,就是那个 .s 文件。它负责在 main() 函数执行之前,把芯片环境准备好。我刚开始做嵌入式时,总觉得启动文件是编译器自动搞定的,直到有一次调试一个诡异的死机问题,才发现是启动文件里堆栈设置错了。

一个精简的启动文件核心部分:

.syntax unified
.cpu cortex-m3
.fpu softvfp
.thumb

.global g_pfnVectors
.global Default_Handler

/* 栈顶地址 */
.equ _estack, 0x20002000

.section .isr_vector, "a"
.type g_pfnVectors, %object
g_pfnVectors:
    .word _estack
    .word Reset_Handler
    .word NMI_Handler
    .word HardFault_Handler
    /* ... 其他中断向量 ... */
    .word 0
    .word 0
    .word 0
    .word 0
    .word WWDG_IRQHandler
    /* ... 更多中断 ... */
g_pfnVectorsEnd:

.section .text.Reset_Handler
.weak Reset_Handler
.type Reset_Handler, %function
Reset_Handler:
    /* 设置栈指针 */
    ldr sp, =_estack

    /* 拷贝 .data 段 */
    ldr r0, =_sdata
    ldr r1, =_edata
    ldr r2, =_sidata
    movs r3, #0
    b LoopCopyDataInit

CopyDataInit:
    ldr r4, [r2, r3]
    str r4, [r0, r3]
    adds r3, r3, #4

LoopCopyDataInit:
    adds r4, r0, r3
    cmp r4, r1
    bcc CopyDataInit

    /* 清零 .bss 段 */
    ldr r0, =_sbss
    ldr r1, =_ebss
    movs r2, #0
    b LoopFillZerobss

FillZerobss:
    str r2, [r0]
    adds r0, r0, #4

LoopFillZerobss:
    cmp r0, r1
    bcc FillZerobss

    /* 跳转到 main */
    bl main
    b .

这段代码做了三件大事:

  1. 设置栈指针:把 SP 指向我们定义的栈顶地址。这个地址必须在 RAM 范围内,而且要给栈留够空间。
  2. 拷贝 .data 段:把 FLASH 中的初始值搬到 RAM 里。注意这里用到了链接脚本定义的 _sdata、_edata、_sidata。
  3. 清零 .bss 段:把未初始化的全局变量区域全部置零。

小技巧:我习惯在启动文件里加一个 SystemInit() 调用,放在拷贝 .data 之前。这样可以在早期就把系统时钟配置好,避免后续操作因为时钟不对而出问题。

4.3 堆栈设置与中断向量表

堆栈设置,嗯,这里要注意。很多 Bootloader 崩溃,都是因为栈溢出了。

栈的大小怎么定?我一般这样估算:

  • 函数调用深度:每个函数调用压栈约 8-16 字节(看参数个数)
  • 中断嵌套:最坏情况下,所有中断同时发生,每个中断需要 64-128 字节
  • 局部变量:特别是大数组,千万别在函数里定义 char buf[1024],那会瞬间吃掉栈空间

我曾经在一个项目里,把栈设成了 512 字节,结果程序跑着跑着就莫名其妙复位。查了两天才发现,是一个中断服务函数里用了递归调用,栈直接爆了。从那以后,我至少给栈留 2KB 的空间。

中断向量表的结构很简单:

偏移地址 内容 说明
0x0000 栈顶地址 芯片上电后自动加载到 SP
0x0004 Reset_Handler 地址 上电后第一条指令
0x0008 NMI_Handler 不可屏蔽中断
0x000C HardFault_Handler 硬件错误,这个很重要
... ... 其他外设中断

警告:中断向量表的地址必须 4 字节对齐。如果你在链接脚本里把 .isr_vector 放在非对齐地址,芯片会直接 HardFault。我刚开始学的时候踩过这个坑,折腾了一整天。

4.4 代码重定位基础

代码重定位,说白了就是把程序从 FLASH 搬到 RAM 里跑。为什么要这么做?

  • 速度更快:RAM 的访问速度通常比 FLASH 快很多
  • 支持在线升级:Bootloader 需要擦写 FLASH,但自己又在 FLASH 里跑,这就矛盾了。把 Bootloader 搬到 RAM 里,就可以放心擦写 FLASH 了

重定位的基本思路:

  1. 在链接脚本里,把要重定位的段放在 RAM 区域
  2. 启动文件里,把这些段从 FLASH 拷贝到 RAM
  3. 修改向量表偏移寄存器(SCB->VTOR),指向 RAM 中的新向量表

代码示例:

/* 在链接脚本中定义重定位段 */
.relocate :
{
    . = ALIGN(4);
    _srelocate = .;
    *(.relocate)
    *(.relocate*)
    . = ALIGN(4);
    _erelocate = .;
} > RAM AT> FLASH

/* 在启动文件中拷贝 */
ldr r0, =_srelocate
ldr r1, =_erelocate
ldr r2, =_srelocate_load
bl CopyData

/* 设置向量表偏移 */
ldr r0, =_srelocate
ldr r1, =0xE000ED08  /* SCB->VTOR 地址 */
str r0, [r1]

你想想看,为什么重定位后要修改 VTOR?因为中断发生时,硬件会从 VTOR 指向的地址读取中断向量。如果不改,中断来了还是去 FLASH 找向量表,那就乱套了。

核心要点:重定位不是简单的 memcpy,还要考虑中断向量表的更新、链接脚本的配合、以及代码中绝对地址引用的处理。我建议你在做重定位之前,先用调试器单步跑一遍启动文件,确认每个段的位置都正确。

好了,这一章的内容就这些。链接脚本和启动文件是 Bootloader 的基石,花时间把它们搞透,后面写应用代码会顺畅很多。下一章我们讲 Flash 驱动与分区管理,到时候会用到今天学的重定位知识。