4. 存储介质

链接脚本,说白了就是告诉链接器:你的芯片内存长什么样,代码该放哪儿,数据该放哪儿。很多做应用层开发的同事觉得这东西可有可无,但做固件的人都知道——链接脚本写不对,轻则程序跑飞,重则直接变砖。

我记得刚入行那会儿,接手一个老项目,发现启动后全局变量全是乱的。查了两天,最后发现是链接脚本里 .bss 段的起始地址和 .data 段重叠了。嗯,从那以后我写链接脚本都会反复核对地址范围。

4.1 内存布局定义:告诉链接器你的硬件长什么样

每个芯片都有不同的存储资源。常见的包括:

  • Flash(或 ROM):存放代码和只读数据,掉电不丢失
  • SRAM:存放可读写数据,掉电丢失
  • ITCM / DTCM:紧耦合内存,访问速度极快
  • DDR:大容量外部内存,常用于 Linux 或复杂 RTOS

在链接脚本里,我们用 MEMORY 命令来定义这些区域。来看一个典型的 Cortex-M 芯片定义:

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

每个区域都有三个属性:

  • ORIGIN:起始地址,必须和芯片手册一致
  • LENGTH:大小,单位可以是 K、M
  • 访问权限:r(读)、w(写)、x(执行)
我曾经踩过的坑:某次把 FLASH 的 LENGTH 写成了 512,结果链接器报错说空间不够。后来才发现少写了 K,实际只分配了 512 字节。这种低级错误,排查起来真的很浪费时间。

4.2 段(Section)分配:把代码和数据安排得明明白白

段是链接脚本的核心概念。每个段都有名字、类型和存放位置。常见的标准段包括:

段名 内容 存放位置 特点
.text 可执行代码、常量 Flash 只读、可执行
.rodata 只读数据(字符串、const 变量) Flash 只读
.data 已初始化的全局/静态变量 SRAM(初始值在 Flash) 读写、需搬运
.bss 未初始化的全局/静态变量 SRAM 读写、需清零
.stack 栈空间 SRAM 运行时动态使用
.heap 堆空间(malloc 用) SRAM 运行时动态分配

段分配用 SECTIONS 命令来实现。下面是一个典型的分配脚本:

SECTIONS
{
    /* 代码段:放在 Flash 开头 */
    .text :
    {
        *(.isr_vector)      /* 中断向量表,必须放在最前面 */
        *(.text*)           /* 所有 .text 子段 */
        *(.rodata*)         /* 只读数据 */
        _etext = .;         /* 标记代码段结束地址 */
    } > FLASH

    /* 已初始化数据段:运行时在 SRAM,初始值在 Flash */
    .data : AT(_etext)
    {
        _sdata = .;         /* 数据段起始地址 */
        *(.data*)
        _edata = .;         /* 数据段结束地址 */
    } > SRAM

    /* BSS 段:运行时在 SRAM,需要清零 */
    .bss :
    {
        _sbss = .;
        *(.bss*)
        *(COMMON)
        _ebss = .;
    } > SRAM
}

关键点:.data 段用了 AT(_etext) 语法。这表示段运行时在 SRAM,但初始值存放在 Flash 中紧接 .text 段之后的位置。启动代码需要把这段数据从 Flash 搬运到 SRAM。

4.3 符号定位:让代码能找到它该找的东西

符号定位是链接脚本里最容易被忽视但又极其重要的部分。说白了,就是给某些关键地址起个名字,让 C 代码能直接引用。

常见的符号定位场景:

  • 段边界标记:如 _sdata、_ebss,用于启动代码搬运和清零
  • 栈顶地址:如 _estack,用于设置 MSP(主栈指针)
  • 堆边界:如 _heap_start、_heap_end,用于 malloc 实现
  • 自定义锚点:如某个特殊功能区的基地址

来看一个完整的符号定义示例:

/* 在链接脚本中定义符号 */
_estack = ORIGIN(SRAM) + LENGTH(SRAM);  /* 栈顶在 SRAM 末尾 */
_Min_Heap_Size = 0x200;                  /* 最小堆大小 512 字节 */
_Min_Stack_Size = 0x400;                 /* 最小栈大小 1KB */

SECTIONS
{
    /* ... 段定义 ... */

    /* 在 C 代码中引用这些符号 */
    /* extern uint32_t _estack; */
    /* extern uint32_t _sdata; */
    /* extern uint32_t _ebss; */
}

我个人习惯:在链接脚本里把所有关键符号集中放在文件开头,用注释说明每个符号的用途。这样后续维护时,不用翻遍整个文件去找某个符号定义在哪里。

4.4 链接脚本的完整结构图

下面这张图展示了链接脚本从内存定义到符号定位的完整流程:

链接脚本核心流程 输入:目标文件 (.o) MEMORY 命令:定义硬件内存区域 FLASH (rx) / SRAM (rwx) / ITCM / DTCM SECTIONS 命令:段分配与定位 .text → FLASH(代码 + 只读数据) .data → SRAM(初始值在 FLASH,需搬运) .bss → SRAM(需清零) 符号定位:给关键地址命名 _estack / _sdata / _ebss / _heap_start 输出:可执行文件 (.elf) 注意点 • 地址不能重叠 • 对齐要求(4/8/16字节) • 启动代码需配合搬运 常见错误 • 段溢出 • 符号未定义 • 地址对齐错误

4.5 实战中的避坑指南

写链接脚本这么多年,我总结了几条血泪教训:

  • 地址对齐很重要:ARM Cortex-M 要求中断向量表必须 256 字节对齐。我曾经因为没对齐,导致中断响应异常,查了整整一天。
  • 段顺序不能乱:.isr_vector 必须放在 .text 段的最前面,否则芯片上电后找不到中断入口。
  • 符号名不要冲突:链接脚本里定义的符号和 C 代码中的变量名不要重复,否则链接器会报多重定义错误。
  • 注意 LENGTH 的单位:K 是 1024,M 是 1048576,别搞混了。

我曾经遇到过一个特别隐蔽的问题:某次在 .data 段搬运时,启动代码里用的搬运长度是 _edata - _sdata,但链接脚本里这两个符号定义错了顺序,导致搬运长度变成负数。程序跑起来后,全局变量全是乱的。从那以后,我每次写完链接脚本都会用 objdump -t 检查符号地址。

4.6 多分区场景下的链接脚本

在实际项目中,我们经常需要把固件分成多个分区。比如:

  • Bootloader 分区:放在 Flash 起始地址,负责固件升级和启动校验
  • App 分区:放在 Bootloader 之后,存放主应用程序
  • 参数分区:存放设备配置、校准数据等
  • 备份分区:OTA 升级时存放新固件

每个分区都需要独立的链接脚本,或者通过条件编译来切换。来看一个 Bootloader 的链接脚本片段:

MEMORY
{
    /* Bootloader 占用 Flash 前 32KB */
    BOOT_FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 32K
    /* App 从 0x08008000 开始 */
    APP_FLASH  (rx)  : ORIGIN = 0x08008000, LENGTH = 480K
    /* 参数区在 Flash 末尾 */
    PARAM_FLASH (r)  : ORIGIN = 0x0807E000, LENGTH = 8K
    SRAM       (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

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

    /* ... 其他段定义 ... */
}

我建议:在多分区项目中,为每个分区单独维护一个链接脚本模板。这样修改某个分区的内存布局时,不会影响到其他分区。同时,在 Makefile 或 CMakeLists.txt 中通过变量来指定使用哪个脚本。