2. 嵌入式启动流程基础

链接脚本,说白了就是告诉链接器:你的代码该放哪儿,数据该放哪儿。很多嵌入式开发者写了好几年代码,却从来没碰过 .ld 文件。嗯,这其实是个大坑。我记得刚入行那会儿,就因为链接地址配错了,整块板子死活跑不起来,折腾了两天才发现是 .text 段跑到 RAM 里去了。

今天我们就来彻底搞懂 GNU LD 链接脚本。我会带着你从最简单的脚本开始,一步步深入到 SECTIONS 命令的每个细节。

2.1 链接脚本的基本结构

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

ENTRY(Reset_Handler)

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

SECTIONS
{
    .text :
    {
        *(.isr_vector)
        *(.text)
        *(.text.*)
        *(.rodata)
        _etext = .;
    } > FLASH

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

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

别被这堆符号吓到。我们拆开来看,其实就三个核心部分:ENTRY、MEMORY、SECTIONS。

命令 作用 我常用的坑
ENTRY 指定程序入口点 忘了写,默认是 _start,但你的启动文件可能叫 Reset_Handler
MEMORY 定义可用内存区域 ORIGIN 和 LENGTH 写错,链接器直接报错
SECTIONS 控制各段的放置位置 这是最复杂的,也是我们今天的主菜

2.2 MEMORY 命令:定义你的硬件资源

MEMORY 命令说白了就是告诉链接器:你的芯片上有哪些内存,分别在哪,多大。我个人习惯把 MEMORY 写在 SECTIONS 前面,这样后面引用起来一目了然。

语法很简单:

MEMORY
{
    名字 (属性) : ORIGIN = 起始地址, LENGTH = 长度
}

属性有四种:

  • r:只读
  • w:可写
  • x:可执行
  • !:取反,比如 !x 表示不可执行
我的经验:Flash 一般给 rx,RAM 给 rwx。别把 Flash 写成 rwx,虽然不会报错,但链接器可能会把可写段塞进 Flash,运行时写 Flash 就出大事了。

2.3 SECTIONS 命令:核心中的核心

SECTIONS 命令才是链接脚本的灵魂。它决定了每个段放在哪个内存区域,以及段之间的顺序。我曾经在一个项目里,因为 .bss 段放在了 .data 前面,导致全局变量初始化全乱了,查了两天才发现是链接脚本顺序问题。

2.3.1 段定义的基本语法

SECTIONS
{
    .段名 :
    {
        内容
    } > 内存区域
}

每个段定义包含三部分:

  1. 段名:比如 .text、.data、.bss,也可以自定义
  2. 内容:用 *() 语法指定哪些输入段放进来
  3. 放置位置:> 内存区域 指定放哪个 MEMORY 里

2.3.2 通配符与输入段匹配

*(.text) 这个写法,星号代表所有目标文件。说白了就是:把所有目标文件里的 .text 段都收集过来。

更精细的写法:

  • *(.text):所有 .text 段
  • *(.text.*):所有 .text.xxx 子段
  • startup.o(.text):只取 startup.o 的 .text 段
  • *(EXCLUDE_FILE(*startup.o) .text):排除 startup.o
避坑指南:我曾经在项目里用 *(.text .text.*) 漏掉了 .rodata,结果只读常量全丢了。后来我习惯写成 *(.text) *(.text.*) *(.rodata),一个不落。

2.4 位置计数器与符号赋值

链接脚本里有个特殊符号 .,叫位置计数器。它代表当前地址。你可以用它来记录段的边界:

.text :
{
    _stext = .;      // 记录 .text 起始地址
    *(.text)
    _etext = .;      // 记录 .text 结束地址
} > FLASH

这些符号在 C 代码里可以这样用:

extern uint32_t _stext;
extern uint32_t _etext;
// 注意:取地址才是真正的值
uint32_t text_size = (uint32_t)&_etext - (uint32_t)&_stext;
重要:链接脚本里定义的符号,在 C 代码里要用 & 取地址。很多人直接拿变量值用,结果拿到的是地址本身,这个坑我踩过不止一次。

2.5 加载地址与运行地址:AT 命令

这是嵌入式链接脚本最精妙的地方。想想看:代码在 Flash 里执行没问题,但 .data 段需要初始化值,这些值也得存在 Flash 里。可运行时 .data 必须在 RAM 里。怎么办?

答案就是 加载地址(LMA) 和 运行地址(VMA) 分离:

.data :
{
    _sdata = .;
    *(.data)
    _edata = .;
} > RAM AT> FLASH   // VMA 在 RAM,LMA 在 Flash

> RAM 指定运行地址(VMA),AT> FLASH 指定加载地址(LMA)。启动代码需要把数据从 Flash 拷贝到 RAM:

extern uint32_t _sdata, _edata, _sidata;
// _sidata 是 Flash 里的加载地址,需要自己在链接脚本里定义
uint32_t *src = &_sidata;
uint32_t *dst = &_sdata;
while (dst < &_edata) {
    *dst++ = *src++;
}

2.6 BSS 段的清零处理

.bss 段存放未初始化的全局变量。运行时必须清零。链接脚本里记录边界:

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

启动代码里清零:

extern uint32_t _sbss, _ebss;
uint32_t *p = &_sbss;
while (p < &_ebss) {
    *p++ = 0;
}
我的习惯:BSS 清零一定要在 .data 拷贝之后做。为什么?因为 .data 拷贝可能用到全局变量,如果先清零就把拷贝源给清了。这个顺序问题,我见过好几个同事栽跟头。

2.7 完整示例:一个实际项目的链接脚本

下面是我在一个 STM32F4 项目里用过的脚本,加了详细注释:

ENTRY(Reset_Handler)

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 1024K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
    CCM   (rw)  : ORIGIN = 0x10000000, LENGTH = 64K   // 内核紧耦合内存
}

SECTIONS
{
    // 中断向量表必须放在最前面
    .isr_vector :
    {
        KEEP(*(.isr_vector))
    } > FLASH

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

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

    // 记录 Flash 里的加载地址
    _sidata = LOADADDR(.data);

    // BSS 段
    .bss : ALIGN(4)
    {
        _sbss = .;
        __bss_start__ = _sbss;
        *(.bss)
        *(.bss.*)
        *(COMMON)
        _ebss = .;
        __bss_end__ = _ebss;
    } > RAM

    // 把 CCM 内存也映射一下,给 DMA 用
    .ccmram (NOLOAD) :
    {
        *(.ccmram)
    } > CCM
}

2.8 知识结构图

下面这张图总结了链接脚本的核心逻辑:

链接脚本核心结构 ENTRY(Reset_Handler) MEMORY 定义硬件资源 FLASH: 0x08000000, 512K RAM: 0x20000000, 128K SECTIONS 命令:段布局控制 .text 段 代码 + 只读数据 VMA = LMA = FLASH .data 段 初始化数据 VMA=RAM, LMA=FLASH .bss 段 未初始化数据 VMA=RAM, 运行时清零 启动代码必须完成的工作 1. 从 _sidata 拷贝数据到 RAM 的 .data 区域 2. 从 _sbss 到 _ebss 清零 3. 跳转到 main()

2.9 常见陷阱与调试技巧

最后分享几个我实战中踩过的坑:

  • 段顺序问题:中断向量表必须放在 .text 最前面,否则芯片上电后 PC 指针会飞到奇怪的地方
  • ALIGN 对齐:ARM 要求代码 4 字节对齐,.text : ALIGN(4) 可以保证
  • KEEP 关键字:KEEP(*(.isr_vector)) 防止链接器优化掉中断向量表
  • NOLOAD 段:像 CCM 这种不需要初始化的内存,加 (NOLOAD) 避免生成初始化数据
调试技巧:链接脚本出问题时,用 arm-none-eabi-objdump -h 你的.elf 查看各段的 VMA 和 LMA。我曾经靠这个命令,五分钟就定位到了 .data 段加载地址配错的问题。

链接脚本这东西,说白了就是个地址映射表。你给它输入段,它给你输出地址。掌握了 SECTIONS 命令,你就能精确控制每一行代码、每一个变量在内存里的位置。下次遇到启动代码跑飞、全局变量初始化失败这类问题,先检查链接脚本——八成是它的问题。