第3章:链接脚本与内存布局

链接脚本,说白了就是告诉编译器:「你的代码该放哪儿,数据该放哪儿」。很多做Bootloader的朋友,遇到启动不起来的问题,最后查来查去,发现是链接脚本写错了。我当年就吃过这个亏,折腾了两天才找到原因。

3.1 链接脚本(.lds)到底在干什么

链接脚本的核心任务就三个:

  • 把目标文件(.o)合并成可执行文件
  • 给每个段分配内存地址
  • 生成符号表,让代码能正确跳转

你想想看,Bootloader的入口地址、中断向量表的位置、栈顶指针的初始值,全得靠它来定。一旦定错了,CPU上电后第一条指令就跑飞了。

核心概念:链接脚本定义了「虚拟地址」和「加载地址」的映射关系。对于Bootloader这种裸机程序,两者通常相同,但有些场景(比如从Flash拷贝到RAM执行)就需要区分。

3.2 代码段/数据段/BSS段的定位

一个典型的Bootloader,内存布局大概长这样:

/* 一个简化的Bootloader链接脚本示例 */
ENTRY(Reset_Handler)

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

SECTIONS
{
    /* 中断向量表 + 代码段 */
    .isr_vector : ALIGN(4)
    {
        _svector = .;
        KEEP(*(.isr_vector))
        _evector = .;
    } > FLASH

    .text : ALIGN(4)
    {
        _stext = .;
        *(.text*)
        *(.rodata*)
        _etext = .;
    } > FLASH

    /* 初始化数据段 - 运行时在RAM,初始值在Flash */
    .data : ALIGN(4)
    {
        _sdata = .;
        *(.data*)
        _edata = .;
    } > RAM AT> FLASH

    /* BSS段 - 零初始化 */
    .bss : ALIGN(4)
    {
        _sbss = .;
        *(.bss*)
        *(COMMON)
        _ebss = .;
    } > RAM

    /* 堆与栈 */
    .heap : ALIGN(8)
    {
        _sheap = .;
        . = . + HEAP_SIZE;
        _eheap = .;
    } > RAM

    .stack : ALIGN(8)
    {
        _sstack = .;
        . = . + STACK_SIZE;
        _estack = .;
    } > RAM
}

个人经验:我习惯在链接脚本里给每个段都定义起始和结束符号(比如_sdata、_edata),这样C代码里可以直接用extern声明来访问这些地址,做数据拷贝和零初始化特别方便。

3.3 各段的作用与初始化时机

段名 存储位置 运行时位置 初始化方式
.isr_vector Flash起始 Flash 编译时确定
.text Flash Flash 编译时确定
.rodata Flash Flash 编译时确定
.data Flash RAM 启动时从Flash拷贝
.bss 无 RAM 启动时清零
.heap 无 RAM 启动时设定边界
.stack 无 RAM 启动时设定SP指针

这里有个关键点:.data段在Flash里存了一份初始值,运行时却要搬到RAM里。为什么?因为RAM读写快,但掉电丢失。Flash存得久,但直接执行效率低。所以Bootloader启动时,得先把.data从Flash拷贝到RAM,再把.bss清零。

我曾经踩过的坑:有一次忘了在启动代码里做.data段的拷贝,结果全局变量全是默认值0,程序跑起来各种诡异。查了两天才发现,原来.data段在RAM里根本没初始化。从那以后,我每次写启动代码都会反复确认拷贝逻辑对不对。

3.4 栈与堆的初始化

栈和堆的初始化,是Bootloader启动流程里最容易出问题的地方之一。

栈的初始化

栈指针SP的初始值,通常由链接脚本提供。在ARM Cortex-M系列里,CPU上电后会自动从0地址加载SP和PC:

/* 启动文件中的栈指针设置 */
__Vectors:
    .word _estack          /* 栈顶地址,由链接脚本定义 */
    .word Reset_Handler    /* 复位入口 */
    .word NMI_Handler
    .word HardFault_Handler
    /* ... 其他中断向量 */

这里要注意:栈是向下生长的。所以_estack实际上是RAM的最高地址,而不是最低地址。我见过有人把栈顶设在RAM中间,结果栈一增长就把数据段给覆盖了。

堆的初始化

堆用于动态内存分配(malloc/free)。在Bootloader里,堆用得不多,但如果有复杂的数据结构(比如解析FAT32文件系统),堆就派上用场了。

堆的初始化其实很简单:

  1. 在链接脚本里定义堆的起始地址和大小
  2. 在启动代码里调用一个堆初始化函数(通常是__heap_init或类似名字)
  3. 设置堆管理器的内部数据结构

我的建议:Bootloader里尽量少用动态内存分配。如果非用不可,一定要在链接脚本里给堆留够空间,并且做好内存泄漏检查。我一般会在堆的末尾放一个「哨兵值」,每次分配后检查哨兵有没有被踩坏。

3.5 内存布局的SVG结构图

下面这张图展示了典型的Bootloader内存布局:

Bootloader内存布局结构图 Flash 存储器 (0x08000000) .isr_vector (中断向量表) .text (代码段 + .rodata) Bootloader 主逻辑、只读数据 .data 初始值 (备份) 启动时拷贝 RAM 存储器 (0x20000000) .data (运行时数据段) .bss (零初始化段) .heap (堆) 动态内存分配 .stack (栈) 向下生长,SP指向栈顶 注意:.data段在Flash中存有初始值,启动时需拷贝到RAM .bss段无需初始值,启动时直接清零即可

3.6 常见链接脚本错误与排查

我整理了几个最常见的链接脚本问题,你在调试Bootloader时肯定会遇到:

  • 段重叠:两个段的地址范围有交集,导致数据互相覆盖。用objdump -h查看各段的地址范围就能发现。
  • 符号未定义:链接脚本里引用了某个符号,但没在任何目标文件里定义。检查_estack、_sdata这些符号有没有正确定义。
  • 对齐问题:ARM指令要求4字节对齐,如果链接脚本没加ALIGN(4),可能会生成非对齐访问,触发HardFault。
  • 加载地址与运行地址混淆:.data段用AT> FLASH指定加载地址在Flash,但运行地址在RAM。如果忘了这个区别,拷贝逻辑就会写错。

重要提醒:修改链接脚本后,一定要做三件事——1) 重新编译所有文件;2) 用objdump检查生成的elf文件的内存布局;3) 在调试器里单步验证SP和PC的初始值是否正确。少一步都可能出问题。

3.7 实战:手写一个最小链接脚本

假设你用的是STM32F103,Flash从0x08000000开始,RAM从0x20000000开始。一个最小可用的链接脚本长这样:

ENTRY(Reset_Handler)

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

_estack = ORIGIN(RAM) + LENGTH(RAM);

SECTIONS
{
    .isr_vector : { *(.isr_vector) } > FLASH
    .text       : { *(.text*) *(.rodata*) } > FLASH
    .data       : { *(.data*) } > RAM AT> FLASH
    .bss        : { *(.bss*) *(COMMON) } > RAM
}

这个脚本虽然简单,但五脏俱全。你可以在它的基础上,根据实际需求添加堆、栈、自定义段等。

个人习惯:我每次新建一个Bootloader项目,都会先从这个最小脚本开始,然后逐步添加功能。这样万一出了问题,回退起来也方便。

嗯,链接脚本这块儿就讲这么多。记住一句话:内存布局是Bootloader的骨架,链接脚本就是骨架的设计图。设计图画错了,骨架肯定长歪。多花点时间在链接脚本上,后面调试能省一半的功夫。