4. 链接脚本(Linker Script)详解:内存布局、段、语法与启动配合

说实话,很多嵌入式工程师写了几年代码,都没认真看过链接脚本。我刚开始带项目时也是这样——直到有一次芯片死活跑不起来,折腾了两天,最后发现是 .bss 段没清零。嗯,从那以后我再也不敢跳过链接脚本了。

链接脚本,说白了就是告诉链接器:你的芯片内存长什么样,代码该放哪,数据该放哪。它决定了你编译出来的 .bin 或 .hex 文件能不能在目标芯片上正确运行。

4.1 内存布局:芯片的“地皮规划”

每个芯片都有自己固定的内存地址空间。比如 STM32F103,Flash 从 0x08000000 开始,SRAM 从 0x20000000 开始。链接脚本的第一件事,就是把这些“地皮”划清楚。

核心概念:链接脚本中的 MEMORY 命令用来定义芯片的物理内存区域,包括起始地址、大小和属性(只读/读写/可执行)。

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

这里 (rx) 表示 Flash 是只读、可执行的;(rwx) 表示 SRAM 可读写、可执行。我个人习惯把 Flash 叫 ROM 区,SRAM 叫 RAM 区,这样更直观。

你想想看,如果芯片的 Flash 只有 256K,你却在链接脚本里写了 512K,链接器不会报错,但烧进去的代码跑到超出地址范围就会死机。我曾经见过一个同事,把 Flash 大小写错了,产品量产了 1000 片才发现问题……

4.2 段(Sections):.text, .data, .bss

编译出来的目标文件里,代码和数据是分门别类存放的。这些类别就叫“段”。最常见的三个段是:

段名 内容 存放位置 特点
.text 程序代码、常量、字符串 Flash(只读) 掉电不丢失
.data 已初始化的全局变量、静态变量 运行时在 RAM,初始值在 Flash 需要从 Flash 拷贝到 RAM
.bss 未初始化或初始化为 0 的全局变量 RAM 启动时需清零

这里有个容易踩的坑:.data 段在 Flash 里存了一份初始值,芯片上电后,启动代码要把这些值从 Flash 拷贝到 RAM。如果拷贝逻辑写错了,全局变量初始值就不对。我遇到过最离谱的一次,是拷贝的源地址和目标地址写反了,结果 Flash 里的代码被覆盖了……

避坑指南:.bss 段不清零,全局变量初始值就是随机的。我曾经排查过一个电机控制器的偶发抖动问题,查了三天,最后发现是 .bss 段清零代码被优化掉了。链接脚本里一定要给 .bss 段留出足够的空间,启动文件里一定要执行清零。

4.3 链接脚本语法:SECTIONS 命令详解

SECTIONS 是链接脚本的核心,它定义了每个段怎么放、放哪里。来看一个典型的写法:

SECTIONS
{
    . = ORIGIN(FLASH);
    
    .text :
    {
        *(.isr_vector)    /* 中断向量表 */
        *(.text)          /* 程序代码 */
        *(.rodata)        /* 只读数据 */
        _etext = .;       /* .text 结束地址 */
    } > FLASH
    
    . = ORIGIN(SRAM);
    
    .data : AT(_etext)
    {
        _sdata = .;       /* .data 起始地址 */
        *(.data)          /* 已初始化数据 */
        _edata = .;       /* .data 结束地址 */
    } > SRAM
    
    .bss :
    {
        _sbss = .;        /* .bss 起始地址 */
        *(.bss)           /* 未初始化数据 */
        _ebss = .;        /* .bss 结束地址 */
    } > SRAM
}

注意 .data : AT(_etext) 这一行。AT 表示加载地址(在 Flash 里),> SRAM 表示运行地址(在 RAM 里)。这就是为什么 .data 段需要拷贝——它有两个地址。

我个人习惯在每个段前后都定义符号(如 _sdata、_edata),这样启动文件里可以直接用这些符号做拷贝和清零。你想想看,如果符号名写错了,链接器不会报错,但启动代码就找不到正确的地址了。

4.4 启动文件与链接脚本的配合

启动文件(startup_xxx.s)是芯片上电后执行的第一段代码。它和链接脚本是“搭档”关系——链接脚本定义内存布局,启动文件执行初始化操作。

典型的启动流程是这样的:

  1. 设置栈指针 SP(从链接脚本定义的 _estack 获取)
  2. 调用 SystemInit() 初始化时钟
  3. 拷贝 .data 段:从 _etext 拷贝到 _sdata,长度 _edata - _sdata
  4. 清零 .bss 段:从 _sbss 到 _ebss 全部写 0
  5. 跳转到 main() 函数

来看一段启动文件中的拷贝代码(ARM Cortex-M 汇编):

    ldr r0, =_etext      /* 源地址:Flash 中的 .data 初始值 */
    ldr r1, =_sdata      /* 目标地址:RAM 中的 .data 起始 */
    ldr r2, =_edata      /* 结束地址 */
    subs r2, r1          /* 计算长度 */
    beq .L_clear_bss     /* 如果长度为 0,跳过 */
.L_copy_data:
    ldrb r3, [r0], #1    /* 从 Flash 读一个字节 */
    strb r3, [r1], #1    /* 写入 RAM */
    subs r2, #1
    bne .L_copy_data

提示:链接脚本里定义的符号(如 _etext、_sdata)在汇编代码中可以直接使用。但要注意,这些符号是地址值,不是变量。在 C 代码中引用时要用 extern uint32_t _etext; 然后取地址 &_etext。

这里有个常见问题:如果链接脚本里 .data 段的加载地址和运行地址没配对,启动文件拷贝的数据就是错的。我建议你在调试时,先打印出 _etext、_sdata、_edata 的值,确认它们符合预期。

4.5 知识结构总览

下面这张图把链接脚本、段、启动文件的关系串起来了:

链接脚本与启动文件配合关系图 链接脚本 (Linker Script) MEMORY 命令 定义 Flash/SRAM 地址范围 SECTIONS 命令 定义 .text/.data/.bss 布局 符号定义 _sdata, _edata, _sbss... AT 指定加载地址 .data 在 Flash 中的位置 常见错误 地址写错 / 段重叠 提供符号 启动文件 (Startup) 设置栈指针 SP 从 _estack 获取 SystemInit() 初始化时钟 / 外设 拷贝 .data 段 _etext → _sdata 清零 .bss 段 _sbss → _ebss 跳转 main() 进入用户程序 必须按顺序执行 关键配合点 1. 符号名必须一致 链接脚本定义 _sdata 启动文件引用 _sdata 2. 地址计算要准确 拷贝长度 = _edata - _sdata 3. 段顺序不能乱 先拷贝 .data,再清零 .bss 4. 栈空间要足够 在链接脚本中预留 5. 调试时打印符号值 确认地址符合预期 6. 不同芯片要调整 Flash/SRAM 地址不同

从这张图可以清楚看到:链接脚本负责“画地图”,启动文件负责“按地图施工”。两者必须严格配合,任何一个符号名写错、地址算错,芯片就跑不起来。

4.6 常见问题与避坑

  • .data 段拷贝后变量值不对——检查 AT 地址是否指向正确的 Flash 位置,我曾经把 AT 地址写成了 0,结果从 Flash 开头拷贝了一堆中断向量表到 RAM……
  • .bss 段没清零导致全局变量随机——确认启动文件中清零循环的起始地址和结束地址正确,注意符号是地址值,不是变量值
  • 链接时报错“region overflow”——代码或数据超出了芯片的 Flash/SRAM 大小,检查链接脚本中的 LENGTH 是否写对了
  • 中断向量表没放在开头——芯片复位后会从 Flash 起始地址读取栈指针和复位向量,.isr_vector 段必须放在第一个

调试技巧:在启动文件中加一段代码,把 _sdata、_edata、_sbss、_ebss 的值通过串口打印出来。如果这些值和你预期的不一样,问题大概率出在链接脚本上。我每次移植新芯片都会这么做,能省下大量排查时间。