链接脚本与内存布局:从入门到实战
链接脚本,说白了就是告诉编译器「你的代码该往哪儿放」。很多新手觉得这玩意儿可有可无,直接用默认设置就行。我刚开始做嵌入式时也这么想,直到有一次芯片死活跑不起来——查了三天,最后发现是 .bss 段没对齐,中断向量表直接错位了。
嗯,从那以后我再也不敢轻视链接脚本了。今天咱们就把这块硬骨头啃下来。
链接脚本语法详解
链接脚本的基本结构其实不复杂。我习惯把它拆成三块:内存区域定义、段分配、符号处理。先看一个最简模板:
SECTIONS
{
. = 0x80000000;
.text : { *(.text) }
.data : { *(.data) }
.bss : { *(.bss) }
}
这段代码干了什么?它把 .text 段放在 0x80000000,后面依次跟 .data 和 .bss。但实际项目中这么写肯定出事——你没考虑对齐,也没处理只读数据段。
ld --verbose 把默认脚本导出来,在此基础上改,而不是从零开始。
内存区域定义:MEMORY 命令
MEMORY 命令用来描述芯片的物理内存布局。你想想看,一颗芯片可能有 Flash、SRAM、DDR,它们的地址范围和属性都不一样。用 MEMORY 命令可以清晰地把它们划分出来:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
DDR (rw) : ORIGIN = 0x80000000, LENGTH = 64M
}
这里每个区域都有三个属性:起始地址(ORIGIN)、长度(LENGTH)、访问权限(r/w/x)。我在项目中遇到过有人把 Flash 写成 rwx,结果链接器报了一堆警告——Flash 本来就不能写,你给它 w 权限干嘛?
| 属性 | 含义 | 典型用途 |
|---|---|---|
| r | 可读 | 所有内存区域 |
| w | 可写 | RAM、DDR |
| x | 可执行 | Flash、SRAM(代码段) |
段(Section)的分配与对齐
段分配是链接脚本的核心。每个段都有名字、内容和放置规则。常见的段包括:
- .text:代码段,只读可执行
- .rodata:只读数据,比如字符串常量
- .data:已初始化全局变量,可读写
- .bss:未初始化全局变量,可读写
- .stack:栈空间(有些架构需要显式定义)
对齐是个容易踩坑的地方。我记得有一次调试一个网络协议栈,数据包总是校验失败。查到最后发现是 .data 段没按 4 字节对齐,结构体成员错位了。来看一个带对齐的段分配:
SECTIONS
{
. = ALIGN(4);
.text :
{
*(.text)
*(.text.*)
} > FLASH
. = ALIGN(16);
.rodata : { *(.rodata) } > FLASH
. = ALIGN(4);
.data :
{
_sdata = .;
*(.data)
_edata = .;
} > SRAM AT> FLASH
}
这里有个关键点:> SRAM AT> FLASH 表示 .data 段运行时在 SRAM,但初始数据存储在 Flash。启动代码需要把数据从 Flash 拷贝到 SRAM——这个拷贝逻辑你得自己写。
_sdata、_edata 这类符号,然后在启动汇编里直接用。这样代码更清晰,也方便调试时查看内存布局。
符号解析与重定位
符号解析和重定位是链接器干的两件大事。简单说:
- 符号解析:把每个符号引用找到对应的定义
- 重定位:把符号的虚拟地址改成实际地址
为什么会需要重定位?因为编译器编译每个 .c 文件时,不知道其他函数在哪儿。它只能生成一个「占位符」,等链接器来填真实地址。这个过程在链接脚本里可以通过 PROVIDE 和 EXTERN 来控制:
PROVIDE(_stack_top = ORIGIN(SRAM) + LENGTH(SRAM));
EXTERN(_start);
PROVIDE 的意思是「如果用户没定义这个符号,我就提供一个默认值」。EXTERN 则是强制链接器把某个符号当作外部引用,即使当前没用到也要保留。
实战:一个完整的链接脚本
下面是我在一个 STM32 项目里用过的链接脚本,去掉了一些平台相关细节,保留了核心结构:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
.text :
{
. = ALIGN(4);
*(.text)
*(.text.*)
. = ALIGN(4);
_etext = .;
} > FLASH
.rodata :
{
. = ALIGN(4);
*(.rodata)
*(.rodata.*)
. = ALIGN(4);
} > FLASH
_sidata = LOADADDR(.data);
.data :
{
. = ALIGN(4);
_sdata = .;
*(.data)
*(.data.*)
. = ALIGN(4);
_edata = .;
} > RAM AT> FLASH
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss.*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
._user_heap_stack :
{
. = ALIGN(8);
PROVIDE(_end = .);
PROVIDE(_heap_start = .);
. = . + _heap_size;
. = . + _stack_size;
. = ALIGN(8);
PROVIDE(_heap_end = .);
} > RAM
}
这个脚本里我特别加了 KEEP 关键字——中断向量表不能被优化掉,否则芯片上电直接跑飞。还有 LOADADDR 用来获取 .data 段在 Flash 里的存储地址,启动代码拷贝时要用。
知识结构图
下面这张图总结了链接脚本的核心知识体系:
常见避坑指南
最后分享几个我踩过的坑:
- 段重叠:两个段放到同一个地址范围,链接器不会报错,但运行时会互相覆盖。我建议每次改完链接脚本都用
readelf -S检查段地址。 - 对齐不一致:C 代码里用
__attribute__((aligned(16))),但链接脚本里只做了 4 字节对齐——这种不匹配会导致性能下降甚至崩溃。 - 启动代码缺失:.data 段的拷贝和 .bss 段的清零必须在 main() 之前完成。我见过有人忘了写这段启动代码,全局变量全是随机值。
- 符号冲突:两个 .o 文件定义了同名全局变量,链接器不会报错,只会用第一个。用
-Wl,--warn-common可以提前发现这种问题。
ld -M 生成内存映射文件,看看每个段实际放到了哪里。这个文件比任何调试器都直观。
链接脚本看起来只是几行配置,但它决定了整个固件能否正确运行。我每次做新项目,第一件事就是根据芯片手册重写链接脚本——虽然麻烦,但能避免后面很多莫名其妙的 bug。