4. 固件组成与格式

说实话,很多做固件的工程师,写了好几年代码,都没怎么碰过 .ld 文件。编译器一把梭,烧进去能跑就行。但一旦遇到芯片内存不够、需要精确控制变量存放位置、或者要做 bootloader 分区,你就绕不开它了。

链接脚本,说白了就是告诉链接器:你的代码和变量,应该放在硬件的哪个地址上。它是一张内存地图,也是一份段布局的施工图。今天我就带你把它彻底吃透。

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

我们先看一个最简单的例子。假设你写了一个 main.c:

int global_var = 42;          // 已初始化的全局变量
int uninit_var;               // 未初始化的全局变量
const char msg[] = "Hello";   // 只读常量

void func(void) {
    static int count = 0;     // 静态局部变量
    int local = 10;           // 栈变量
}

编译之后,这些符号会被分配到不同的段(Section):

  • func() 的指令 → .text
  • msg[] 和字符串常量 → .rodata
  • global_var = 42 → .data
  • uninit_var 和 count → .bss
  • local → 运行时栈,不占用固定段

链接脚本的任务,就是把这些段,按你指定的顺序和地址,拼接到最终的二进制文件里。

核心认知:链接脚本 = 内存布局 + 段映射 + 符号地址决议。没有它,编译器生成的 .o 文件只是一堆碎片。

4.2 内存布局:ROM 和 RAM 的分工

嵌入式系统里,ROM(Flash)和 RAM 的分工非常明确:

存储介质 存放内容 特点
ROM / Flash .text, .rodata, .data 的初始值 掉电不丢失,只读执行
RAM .data 运行时副本, .bss, 堆, 栈 掉电丢失,可读写

这里有个关键点:.data 段在 Flash 里存了一份初始值,但运行时必须搬到 RAM 里。为什么?因为全局变量是要被修改的,不能放在只读的 Flash 里。这个搬运过程,就是所谓的「重定位」或「初始化」。

我画了一张图,帮你把整个内存布局串起来:

固件内存布局:ROM ↔ RAM 映射关系 ROM / Flash .text(代码段) func(), main(), 中断向量表 .rodata(只读数据) 字符串常量, const 变量 .data 初始值(LMA) global_var = 42 的原始值 .bss 清零表(可选) 启动时搬运 (C 运行时初始化) RAM .data(VMA 运行时副本) global_var = 42(可读写) .bss(零初始化段) uninit_var, static count 堆(Heap) malloc / new 动态分配 栈(Stack) 局部变量, 函数调用, 中断

我的习惯:在链接脚本里,我会把 .data 的 LMA(加载地址)明确指向 Flash,VMA(运行地址)指向 RAM。然后在启动代码里用 memcpy 完成搬运。很多新手直接让 LMA = VMA,结果变量值一上电就丢了。

4.3 段(Section)的定义与重定位

链接脚本里,段是用 SECTIONS 块来描述的。我直接拿一个实际项目里的脚本片段来拆解:

SECTIONS
{
    /* 中断向量表,必须放在 Flash 起始地址 */
    .vectors : ALIGN(4)
    {
        __vectors_start = .;
        KEEP(*(.vectors))
        __vectors_end = .;
    } > FLASH

    /* 代码段 */
    .text : ALIGN(4)
    {
        *(.text)           /* 所有 .text 段 */
        *(.text.*)         /* 所有 .text.xxx 子段 */
        *(.glue_7)
        *(.glue_7t)
    } > FLASH

    /* 只读数据段 */
    .rodata : ALIGN(4)
    {
        *(.rodata)
        *(.rodata.*)
    } > FLASH

    /* 已初始化数据段 - 加载在 Flash,运行在 RAM */
    .data : ALIGN(4)
    {
        __data_start = .;
        *(.data)
        *(.data.*)
        __data_end = .;
    } > RAM AT> FLASH

    /* 零初始化段 */
    .bss : ALIGN(4)
    {
        __bss_start = .;
        *(.bss)
        *(.bss.*)
        *(COMMON)
        __bss_end = .;
    } > RAM
}

这里有几个概念,我当年刚接触时也绕了很久:

  • VMA(Virtual Memory Address):段在运行时的地址。比如 .data 的 VMA 在 RAM。
  • LMA(Load Memory Address):段在烧录时的地址。比如 .data 的 LMA 在 Flash。
  • ALIGN(n):强制地址对齐到 n 字节。ARM 通常要求 4 字节对齐,否则可能触发硬件异常。
  • KEEP():告诉链接器,即使这个段没被引用,也不要优化掉。中断向量表必须加这个。

我曾经踩过的坑:有一次 .bss 段忘记清零,结果全局变量上电后是随机值。设备偶尔启动正常,偶尔死机。查了两天才发现是启动代码里漏了 __bss_start 到 __bss_end 的 memset。从那以后,我每次写链接脚本都会手动检查清零逻辑。

4.4 如何自定义链接脚本?

实际项目中,很少有直接用默认链接脚本的。我给你列几个最常见的自定义场景:

场景 做法 关键点
Bootloader + App 分区 定义两个 .text 段,分别映射到不同 Flash 地址 用 ORIGIN 和 LENGTH 划分 Flash 区域
关键变量放在特定 RAM 地址 用 . = 0x20000000; 强制定位 注意不要覆盖其他段
将日志字符串放到外部 Flash 单独定义 .log_text 段,映射到 QSPI 地址空间 需要修改链接器命令行,添加内存区域
保留一段 RAM 给 DMA 使用 在 .bss 之后预留 . = . + 0x1000; 配合 NOLOAD 避免初始化

举个例子,假设你要把某个大数组强制放到 RAM 的 0x20001000 地址:

/* 在 C 代码中 */
__attribute__((section(".dma_buf")))
uint8_t dma_buffer[4096] __attribute__((aligned(32)));

/* 在链接脚本中 */
.dma_buf 0x20001000 : ALIGN(32)
{
    *(.dma_buf)
} > RAM

这样,dma_buffer 的地址就固定了。DMA 控制器不需要计算偏移,直接硬编码地址就行。

我个人的建议:自定义链接脚本时,一定要在脚本开头加上 MEMORY 命令,明确声明芯片的 Flash 和 RAM 范围。这样链接器会在地址越界时报错,而不是静默溢出。比如:

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

4.5 链接脚本的调试技巧

写链接脚本,最怕的就是地址算错了。我分享几个调试方法:

  • 查看 .map 文件:编译时加 -Wl,-Map=output.map,链接器会输出每个符号的最终地址。这是最直接的调试手段。
  • 使用 PROVIDE 关键字:在脚本里定义符号,比如 PROVIDE(__stack_top = 0x20020000);,然后在 C 代码里用 extern 引用。这样启动代码就能拿到栈顶地址。
  • 检查段大小:用 size 命令查看各段占用。如果 .bss 比预期大很多,可能是某个大数组没初始化。

一句话总结:链接脚本就是固件的「城市规划图」。哪里放代码,哪里放变量,哪里留给堆栈,全由它说了算。花半小时把 .ld 文件吃透,能省下后面数小时的调试时间。