3. 链接脚本(Linker Script)入门:链接脚本的作用、MEMORY命令定义内存区域、SECTIONS命令分配段

说实话,我刚入行那会儿,觉得链接脚本就是个黑盒子。编译能过就行,谁管它里面写了啥?直到有一次,我调试一个Bootloader,程序死活跑不起来,用JTAG单步一看,中断向量表根本没放在起始地址。折腾了两天,最后发现是链接脚本里少写了一行。嗯,从那以后,我再也不敢小看这个文件了。

链接脚本,说白了就是告诉链接器:你的代码该放哪儿,数据该放哪儿。对于Bootloader来说,这尤其重要——你想想看,Bootloader通常跑在SRAM里,或者从Flash启动后要重定位,没有链接脚本,编译器根本不知道你的内存布局长什么样。

3.1 链接脚本到底在干什么?

我们平时写C代码,用int a = 0;定义一个全局变量,用void func(void) {}定义一个函数。编译器把这些符号翻译成目标文件(.o),但目标文件里只有相对地址。链接器拿到这些目标文件后,需要根据链接脚本的指示,给每个段(Section)分配一个绝对地址。

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

  • 定义内存区域:芯片有哪些物理内存?Flash多大?SRAM多大?地址范围是多少?
  • 分配段:代码段(.text)放Flash还是RAM?数据段(.data)初始值放哪儿?运行时要搬到哪里?

我个人的习惯是,每做一个新平台,第一件事就是打开芯片手册,把Flash和RAM的地址范围抄下来,然后写链接脚本。这一步做扎实了,后面能省很多调试时间。

核心观点:链接脚本是嵌入式工程师和硬件之间的契约。你告诉链接器内存长什么样,链接器帮你把程序安排得明明白白。

3.2 MEMORY命令:定义你的内存地图

MEMORY命令用来描述目标芯片的物理内存。语法很简单,但参数很关键。

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

每个内存区域都有三个属性:

参数 含义 常见值
ORIGIN 起始地址 芯片手册里的基地址
LENGTH 大小 以K或M为单位
属性 访问权限 r读、w写、x执行

这里有个坑,我曾经踩过:属性里的r、w、x不是随便写的。如果你把Flash写成rwx,链接器可能会把可写数据放进去,但Flash在运行时是不能写的(除非你做了特殊处理)。所以Flash我一般只给rx,SRAM给rwx。

注意:ORIGIN和LENGTH必须和芯片手册严格一致。多写一个字节,链接器都不会报错,但程序跑起来就可能访问到不存在的地址,导致HardFault。

3.3 SECTIONS命令:把段安排得明明白白

SECTIONS命令是链接脚本的核心。它定义了每个段放在哪个内存区域,以及段的排列顺序。

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

SECTIONS
{
    . = ORIGIN(FLASH);
    
    .text :
    {
        KEEP(*(.isr_vector))   /* 中断向量表,必须放在最前面 */
        *(.text*)              /* 代码段 */
        *(.rodata*)            /* 只读数据 */
        . = ALIGN(4);
    } > FLASH

    .data : AT(ADDR(.text) + SIZEOF(.text))
    {
        _sdata = .;           /* 数据段起始地址(RAM中) */
        *(.data*)             /* 初始化数据 */
        . = ALIGN(4);
        _edata = .;           /* 数据段结束地址 */
    } > SRAM AT> FLASH

    .bss :
    {
        _sbss = .;            /* BSS段起始地址 */
        *(.bss*)              /* 未初始化数据 */
        . = ALIGN(4);
        _ebss = .;            /* BSS段结束地址 */
    } > SRAM
}

这段脚本里,有几个关键点我得跟你聊聊:

  • KEEP:告诉链接器,这个段即使没人引用也要保留。中断向量表就是典型例子——它没有函数调用它,但CPU上电后会直接跳过去。
  • AT:指定加载地址(LMA)和运行地址(VMA)。比如.data段,运行时要放在SRAM里(VMA),但初始值存在Flash里(LMA)。Bootloader启动后,需要把数据从Flash拷贝到SRAM。
  • ALIGN:对齐。ARM Cortex-M要求中断向量表4字节对齐,有些外设DMA要求8字节甚至16字节对齐。不加对齐,程序可能莫名其妙地崩溃。

我的经验:_sdata、_edata这些符号,是给启动代码用的。你可以在C代码里用extern uint32_t _sdata;声明它们,然后取地址来拷贝数据。这是Bootloader里最常用的手法。

3.4 一张图看懂链接脚本的工作流程

下面这张SVG图,展示了从目标文件到最终可执行文件的映射过程。我画这张图的时候,特意把MEMORY和SECTIONS的关系标清楚了。

链接脚本工作流程 链接脚本工作流程:从目标文件到内存布局 目标文件 (.o) .text 代码段 .data 数据段 .bss 未初始化段 链接脚本 (.ld) MEMORY 定义 FLASH: 0x08000000 SRAM: 0x20000000 可执行文件 (.elf) .text → FLASH .data → SRAM (LMA:FLASH) .bss → SRAM 输入 输出 最终内存布局 FLASH (0x08000000) .text + .rodata + .data初始值 SRAM (0x20000000) .data (运行时) + .bss + 堆栈 启动时拷贝

从这张图你能看到,链接脚本就像个调度员。它把目标文件里的各个段,按照你的要求,安排到Flash和SRAM的对应位置。其中.data段最特殊——它有两个地址:一个在Flash里存着初始值,一个在SRAM里运行。Bootloader启动后,必须把数据从Flash拷贝到SRAM,否则全局变量就是错的。

3.5 常见陷阱与避坑指南

链接脚本写起来不难,但坑不少。我把自己踩过的坑列出来,你遇到了可以少走弯路:

  • 段顺序搞反:中断向量表必须放在Flash的最开头(地址0x08000000)。如果你把其他段放在它前面,CPU上电后找不到向量表,直接跑飞。
  • 忘记KEEP:链接器默认会优化掉“未被引用”的段。中断向量表没人调用它,不加KEEP就会被丢掉。我有个同事就因为这个,调试了整整一天。
  • 对齐没对齐:ARM的__align(8)不是摆设。有些外设的DMA要求缓冲区8字节对齐,链接脚本里不加ALIGN(8),DMA传输就会出错。
  • LMA和VMA搞混:.data段的AT> FLASH很容易漏掉。漏掉的话,链接器会把数据段放在SRAM的地址上,但烧录时不会把初始值写进Flash。启动后SRAM里的数据是随机的。

我曾经在一个项目里,把.bss段也加了AT属性,结果启动代码把一堆零从Flash拷贝到SRAM,白白浪费了Flash空间和启动时间。其实.bss段不需要初始值,启动时直接清零就行。

3.6 小结

链接脚本是Bootloader开发的基石。你只要记住三件事:

  1. 用MEMORY告诉链接器芯片有哪些内存。
  2. 用SECTIONS告诉链接器每个段放哪儿。
  3. 用AT处理.data段的加载地址和运行地址。

做到这三点,你的Bootloader内存布局就不会出大问题。剩下的,就是在实际项目中慢慢积累经验了。