4. 链接脚本与内存布局

链接脚本,说白了就是告诉链接器:你的代码该放哪儿,数据该放哪儿。很多做嵌入式开发的朋友,写了好几年代码,都没碰过 .lds 文件。但做 Bootloader 开发,这关你绕不过去。

为什么?因为 Bootloader 跑在裸机上,没有操作系统帮你管理内存。你得自己告诉编译器,哪块地址是 ROM,哪块是 RAM,中断向量表放哪个位置。搞错了,芯片直接跑飞。

我个人习惯,每做一个新平台,第一件事就是先把链接脚本搭好。这一步稳了,后面才踏实。

4.1 链接脚本语法基础

GNU LD 的链接脚本,扩展名通常是 .lds。语法看着有点怪,但核心结构其实不复杂。

OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)

SECTIONS
{
    . = 0x00000000;
    .text : { *(.text) }
    .data : { *(.data) }
    .bss  : { *(.bss) }
}

上面这个是最简版本。咱们拆开看:

  • OUTPUT_FORMAT:指定输出文件的格式。ARM 平台常用 elf32-littlearm。
  • OUTPUT_ARCH:目标架构。告诉链接器这是给 ARM 用的。
  • ENTRY:入口点。程序第一条指令的标号,通常是 _start。
  • SECTIONS:核心部分。定义各个段怎么放。

这里有个关键符号——.(点号)。它叫位置计数器,代表当前输出地址。你给 . 赋个值,就等于说「从这儿开始放」。我在项目中见过有人忘了设置 . 的初始值,结果链接出来的镜像地址全乱套了。

小技巧:调试链接脚本时,可以用 ld -M 或 objdump -h 查看最终的内存映射。这比盯着代码猜靠谱多了。

4.2 内存区域定义

实际项目中,芯片的内存空间不是连续的。Flash 在 0x08000000,SRAM 在 0x20000000。你得用 MEMORY 命令把这些区域告诉链接器。

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

每个区域有三个属性:

属性 含义 说明
r 可读 几乎所有内存区域都有
w 可写 RAM 必须有,Flash 一般没有
x 可执行 代码段所在区域必须有

定义好 MEMORY 之后,在 SECTIONS 里就可以用 >FLASH 这样的语法,把段分配到指定区域。

注意:ORIGIN 和 LENGTH 必须跟芯片手册一致。我曾经把 SRAM 的起始地址写错了 0x100,结果程序一跑就死,查了整整两天。

4.3 段的放置

一个典型的嵌入式程序,至少包含三个段:.text、.data、.bss。咱们一个一个说。

.text 段:代码和只读数据

这个段放的是程序指令和常量。它必须放在非易失性存储器里,比如 Flash。上电就能直接执行。

.text :
{
    *(.text)
    *(.text.*)
    *(.rodata)
    *(.rodata.*)
} >FLASH

注意我加了 .text.* 和 .rodata.*。这是通配符,用来捕获编译器生成的子段。不加的话,有些段会漏掉。

.data 段:已初始化的全局变量

这个段有点特殊。变量有初始值,但值存在 Flash 里,运行时却要搬到 RAM 里才能读写。所以 .data 在链接脚本里要出现两次:

.data : AT (ADDR(.text) + SIZEOF(.text))
{
    _sdata = .;
    *(.data)
    *(.data.*)
    _edata = .;
} >SRAM

这里 AT 指定了加载地址(在 Flash 里),而 >SRAM 指定了运行地址(在 RAM 里)。启动代码里需要把数据从 Flash 拷贝到 RAM,这个拷贝的起止地址就是 _sdata 和 _edata。

关键点:_sdata 和 _edata 是链接器生成的符号,启动代码里可以直接引用。这是 Bootloader 初始化过程中必不可少的一步。

.bss 段:未初始化的全局变量

.bss 段不占 Flash 空间,只占 RAM 空间。启动时要把这段内存清零。

.bss :
{
    _sbss = .;
    *(.bss)
    *(.bss.*)
    *(COMMON)
    _ebss = .;
} >SRAM

COMMON 是编译器生成的未初始化全局变量,也要归到 .bss 里。

4.4 栈与堆的配置

栈和堆在链接脚本里没有专门的段,但它们占用的内存你得预留出来。我一般这样处理:

_estack = ORIGIN(SRAM) + LENGTH(SRAM);
_heap_size = 4K;
_stack_size = 8K;

然后在 .bss 后面加上:

_heap_start = _ebss;
_heap_end = _heap_start + _heap_size;
_stack_start = _heap_end;
_stack_end = _stack_start + _stack_size;

这样,RAM 的布局就是:.data → .bss → 堆 → 栈。栈顶指向 RAM 的最高地址,向下生长。

经验之谈:栈大小别抠得太紧。我一般给 Bootloader 留 8KB 栈,堆 4KB。如果功能复杂,适当加大。栈溢出是嵌入式开发里最难查的 bug 之一。

4.5 完整示例与内存布局图

下面是一个完整的 STM32 Bootloader 链接脚本:

OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(Reset_Handler)

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

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

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

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

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

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

    . = ALIGN(4);
    _heap_start = .;
    _heap_end = _heap_start + 4K;
    _stack_start = _heap_end;
    _stack_end = _stack_start + 8K;
}

对应的内存布局,我画了张图,方便你理解:

Bootloader 内存布局图 Flash (32KB) .isr_vector 中断向量表 (0x08000000) .text 代码 + 只读数据 (含 .text, .rodata) .data 加载映像 (运行时拷贝到 SRAM) SRAM (20KB) .data (运行时) _sdata ~ _edata (从 Flash 拷贝而来) .bss _sbss ~ _ebss (启动时清零) 堆 (4KB) _heap_start ~ _heap_end 栈 (8KB) _stack_start ~ _stack_end (向下生长,顶 = _estack) 拷贝 加载 0x08000000 0x08007FFF 0x20000000 0x20004FFF
核心要点:.data 段在 Flash 和 SRAM 里各有一份。Flash 里是初始值,SRAM 里是运行时的副本。启动代码必须完成这个拷贝,否则全局变量全是错的。

4.6 常见坑与避坑指南

  • 对齐问题:ARM 要求代码 4 字节对齐。忘了加 . = ALIGN(4),可能触发硬件异常。
  • 段顺序:中断向量表必须放在最前面,地址 0 或 0x08000000。放错了,芯片上电直接跑飞。
  • 栈顶地址:_estack 必须是 RAM 的最高地址。我见过有人写成 ORIGIN(SRAM) + LENGTH(SRAM) - 1,结果栈顶偏了一个字节,虽然能用但心里不踏实。
  • 符号可见性:启动代码里引用的 _sdata、_ebss 等符号,必须在链接脚本里用 . 赋值。如果忘了,链接时会报 undefined reference。
曾经踩过的坑:有一次我把 .data 的加载地址写成了 ADDR(.text) + SIZEOF(.text),但忘了考虑对齐。结果 .data 的加载地址和 .text 的结束地址之间差了 2 个字节,拷贝出来的数据全错位了。从那以后,我每个段前后都加 . = ALIGN(4),再也没出过这个问题。

链接脚本这东西,看着不起眼,但它是整个 Bootloader 的地基。地基歪了,上面盖的楼再漂亮也没用。我建议你拿到一个新板子,先把链接脚本调通,确认每个段都落在了正确的位置,再往下写代码。这一步省不了。