链接是C/C++程序构建过程中的关键一步,它负责将多个目标文件(.o)与系统库组合成最终的可执行文件。这个看似自动化的过程,实际上是由一个名为链接脚本(Linker Script)的配置文件精确控制的。无论是进行底层系统开发还是追求极致的性能优化,理解并掌握链接脚本都是不可或缺的技能。
一、链接脚本的基本概念
GCC的编译过程通常包括四个主要步骤:预处理、编译、汇编和链接。链接作为最后一步,其核心任务是将所有的.o文件与所需的库函数代码“缝合”在一起。而指挥这个缝合过程的“蓝图”,就是链接脚本。
链接器必须使用一个链接脚本。如果开发者不主动提供,链接器则会使用一个内置的默认脚本。你可以通过ld --verbose命令来查看这个默认脚本的内容。一些命令行选项(如-r或-N)会影响默认脚本的行为。更常见的做法是使用-T选项来指定你自己的链接脚本,此时自定义脚本将完全替代默认脚本。
链接脚本的主要目的是描述输入文件中的各个“段”(Section)如何映射到输出文件中,并精确控制输出文件在内存中的存储布局。绝大多数链接脚本都服务于这个目的,但在必要时,它也能指挥链接器执行更多底层操作。
1.1 链接器脚本语言基本概念和术语
链接器将多个输入文件(目标文件)组合成一个输出文件(通常为可执行文件)。每个文件都遵循特定的目标文件格式,并包含一个段列表。输入文件中的段称为输入段,输出文件中的段则称为输出段。
每个段都有名称和大小,多数段还包含实际的数据块,称为段内容。段可以被标记为:
- 可加载的(Loadable):程序运行时,其内容需要被加载到内存中。
- 可分配的(Allocatable):需要预留一块内存区域,但无需加载特定内容(有时需清零)。
- 既不可加载也不可分配的段,通常包含调试信息。
每个可加载或可分配的输出段有两个关键地址:
- VMA(Virtual Memory Address):程序运行时该段所在的虚拟内存地址。
- LMA(Load Memory Address):该段内容被加载到的物理地址。
在大多数情况下,VMA和LMA相同。一个典型的例外是嵌入式系统:数据段可能被固化在ROM(LMA)中,系统启动时再被复制到RAM(VMA)里,以初始化全局变量。
可以使用objdump -h命令查看目标文件中的段信息。
此外,每个目标文件还有一个符号表。符号分为已定义和未定义两种。每个已定义的符号(如函数、全局变量)都有一个地址。可以使用nm命令或objdump -t来查看符号。
二、链接脚本格式
链接脚本是纯文本文件,由一系列命令构成。命令之间用分号分隔,空格通常被忽略。字符串(如文件名)可以直接书写,若包含特殊字符(如逗号),需用双引号括起。
注释采用与C语言相同的格式,即/* 注释内容 */,注释在语法上被视为空格。
三、最简单的链接脚本示例
最简单的链接脚本只需要一个命令:SECTIONS。它用于描述输出文件的内存布局。
假设我们的程序只包含代码(.text)、已初始化数据(.data)和未初始化数据(.bss)段。我们希望代码从地址0x10000开始加载,数据从0x8000000开始。链接脚本可以这样写:
SECTIONS
{
. = 0x10000;
.text : { *(.text) }
. = 0x8000000;
.data : { *(.data) }
.bss : { *(.bss) }
}
SECTIONS命令以关键字SECTIONS开头,后跟由花括号括起来的符号赋值和输出段描述。
- 第一行设置了特殊符号
.的值,它代表位置计数器。如果未指定输出段地址,地址将基于位置计数器的当前值设置,随后位置计数器随段大小递增。SECTIONS开始时,位置计数器为0。
- 第二行定义了输出段
.text。冒号是必须的语法。花括号*(.text)是一个输入段描述,通配符*匹配所有输入文件中的所有.text段。
- 由于定义
.text段时位置计数器为0x10000,链接器会将该段的地址(VMA)设为0x10000。
- 后续行定义了
.data和.bss段。链接器会将.data段放置在0x8000000。放置后,位置计数器变为0x8000000加上.data段的大小,从而使得.bss段紧跟在.data段之后。
- 链接器会确保每个输出段满足其对齐要求,这可能需要在段之间插入填充。
四、简单的链接脚本命令
4.1 设置入口点
程序执行的第一个指令地址称为入口点。使用ENTRY(symbol)命令设置。链接器按以下顺序确定入口点:
-e命令行选项。
- 链接脚本中的
ENTRY(symbol)命令。
- 目标文件格式特定的符号(如
start)。
.text段的起始地址。
- 地址0。
4.2 处理文件的命令
INCLUDE filename:包含另一个链接脚本文件。
INPUT(file, file, …):指示链接器包含指定文件,如同在命令行中输入一样。
GROUP(file, file, …):与INPUT类似,但要求文件是归档文件(.a),会循环搜索直到解析完所有未定义引用。
OUTPUT(filename):指定输出文件名,等同于-o选项。
SEARCH_DIR(path):添加库文件搜索路径,等同于-L选项。
STARTUP(filename):指定第一个输入文件,类似于将该文件放在命令行首位。
4.3 处理目标文件格式的命令
OUTPUT_FORMAT(bfdname):指定输出文件的BFD格式,等同于--oformat选项。也可用OUTPUT_FORMAT(default, big, little)根据-EB(大端)或-EL(小端)选项选择格式。
TARGET(bfdname):指定读取输入文件时使用的BFD格式,影响后续INPUT和GROUP命令。
4.4 为内存区域分配别名名称
使用MEMORY命令定义内存区域后,可以用REGION_ALIAS(alias, region)为其创建别名。这在需要灵活映射输出段到不同内存区域的嵌入式系统中非常有用。
例如,一个嵌入式系统可能有RAM、ROM、ROM2等不同存储器。通过定义通用的SECTIONS脚本和不同的linkcmds.memory文件,可以轻松适配多种硬件配置(变体A、B、C)。
基础链接脚本 (link.ld):
INCLUDE linkcmds.memory
SECTIONS
{
.text :
{
*(.text)
} > REGION_TEXT
.rodata :
{
*(.rodata)
rodata_end = .;
} > REGION_RODATA
.data : AT (rodata_end)
{
data_start = .;
*(.data)
} > REGION_DATA
data_size = SIZEOF(.data);
data_load_start = LOADADDR(.data);
.bss :
{
*(.bss)
} > REGION_BSS
}
变体A的 linkcmds.memory (所有段在RAM):
MEMORY
{
RAM : ORIGIN = 0, LENGTH = 4M
}
REGION_ALIAS("REGION_TEXT", RAM);
REGION_ALIAS("REGION_RODATA", RAM);
REGION_ALIAS("REGION_DATA", RAM);
REGION_ALIAS("REGION_BSS", RAM);
变体B的 linkcmds.memory (代码和只读数据在ROM,数据在RAM/ROM):
MEMORY
{
ROM : ORIGIN = 0, LENGTH = 3M
RAM : ORIGIN = 0x10000000, LENGTH = 1M
}
REGION_ALIAS("REGION_TEXT", ROM);
REGION_ALIAS("REGION_RODATA", ROM);
REGION_ALIAS("REGION_DATA", RAM);
REGION_ALIAS("REGION_BSS", RAM);
系统初始化时,可以用C语言编写通用例程,根据data_start、data_load_start和data_size这些链接脚本定义的符号,将.data段从加载地址(ROM/ROM2)复制到运行地址(RAM)。
4.5 其他链接器脚本命令
ASSERT(exp, message):断言表达式exp非零,否则报错退出。
EXTERN(symbol ...):强制将符号作为未定义符号引入,以链接额外模块,等同于-u选项。
FORCE_COMMON_ALLOCATION:等同于-d,强制为公共符号分配空间。
INHIBIT_COMMON_ALLOCATION:等同于--no-define-common,忽略对公共符号的地址分配。
FORCE_GROUP_ALLOCATION:强制将段组成员视为普通输入段放置。
NOCROSSREFS(section ...):禁止列出的输出段之间互相引用,否则报错。
OUTPUT_ARCH(bfdarch):指定输出文件的机器架构。
LD_FEATURE(string):修改链接器行为,如“SANE_EXPR”使绝对符号和数字被始终视为数字。
五、为符号赋值
可以在链接脚本中定义全局符号。这常用于在程序中暴露内存布局的关键地址。
5.1 简单的赋值
使用C风格的赋值运算符:
symbol = expression;
symbol += expression; /* 等等 */
特殊符号.代表位置计数器,只能在SECTIONS命令内使用。赋值语句可以独立存在,也可以放在SECTIONS命令内或输出段描述中。
5.2 HIDDEN
对于ELF目标,HIDDEN(symbol = expression)定义的符号将被隐藏,不会被导出到其他模块。
5.3 PROVIDE
PROVIDE(symbol = expression)仅在符号被引用但未在任何输入目标文件中定义时才定义它。这解决了用户代码可能使用相同符号名的问题。
5.4 PROVIDE_HIDDEN
类似PROVIDE,但定义的符号是隐藏的。
5.5 源代码引用
重要:链接脚本定义的符号,其本身只是一个地址值(VMA),并没有在对应的内存位置存储该值。因此,在C源代码中引用时,必须取符号的地址,而不是它的“值”。
例如,链接脚本定义:
foo = 0x2345;
在C代码中应这样使用:
extern int foo; /* 实际上是声明 foo 的地址 */
int *p = &foo; /* 正确:获取地址 */
int x = foo; /* 错误:试图读取地址 0x2345 处的值,这通常是非法的 */
更安全的做法是将它们视为数组名:
extern char foo[]; /* 表明 foo 是一个地址 */
memcpy(dest, foo, len); /* 直接使用地址 */
六、SECTIONS 命令
SECTIONS是链接脚本的核心,它详细描述了输入段到输出段的映射及内存放置。
6.1 输出段描述
一个完整的输出段描述语法如下(大多数属性可选):
section [address] [(type)] :
[AT(lma)]
[ALIGN(section_align) | ALIGN_WITH_INPUT]
[SUBALIGN(subsection_align)]
[constraint]
{
output-section-command
output-section-command
...
} [>region] [AT>lma_region] [:phdr :phdr ...] [=fillexp] [,]
每个output-section-command可以是:符号赋值、输入段描述、直接数据值或特殊关键字。
6.4 输入段描述
这是最常用的命令,基本形式为filename(section)。filename和section都可以使用通配符。
*(.text):包含所有输入文件的.text段。
EXCLUDE_FILE (file1.o file2.o) *(.ctors):排除指定文件后的.ctors段。
- 排序:
SORT_BY_NAME(.text*)、SORT_BY_ALIGNMENT(.data*)、SORT_BY_INIT_PRIORITY(.init_array*)。
- 保留段(用于垃圾回收):
KEEP(*(.init))。
6.4.3 通用符号的输入段
通用符号(Common Symbols)被放置在名为COMMON的特殊输入段中。通常将它们与.bss段一起放置:
.bss { *(.bss) *(COMMON) }
6.5 输出段数据
可以在段内直接嵌入数据:
BYTE(1)、SHORT(0x1234)、LONG(addr)、QUAD(0x123456789ABCDEF):分别插入1、2、4、8字节数据。
FILL(0x90909090):设置从当前开始到段结束的填充值。
6.8 输出段属性
- 类型 (type):如
NOLOAD(运行时不被加载)、READONLY。
- 加载地址 (LMA):由
AT(lma_expression)或AT>region指定。如果不指定,LMA通常等于VMA。
- 填充 (fillexp):使用
=fillexp或FILL()命令设置段内未指定区域的填充值。
- 区域约束:使用
>region将段分配到特定内存区域。
七、MEMORY 命令
MEMORY命令定义了目标平台上的物理内存布局,链接器将在此框架内放置输出段。
语法:
MEMORY
{
name [(attr)] : ORIGIN = origin, LENGTH = len
...
}
name:区域名称,在链接脚本内使用。
attr:属性字符串,可选,定义可放入此区域的段类型(R只读, W可写, X可执行, A可分配, I/L已初始化, !取反)。
origin / len:区域的起始地址和长度。
示例:
MEMORY
{
rom (rx) : ORIGIN = 0, LENGTH = 256K
ram (!rx) : ORIGIN = 0x40000000, LENGTH = 4M
}
此例定义了一个只读、可执行的rom区域和一个非可执行、可读写的ram区域。未明确映射的只读/可执行段会进入rom,其他段进入ram。
通过本文对GCC链接脚本从概念到语法的全面梳理,我们可以看到,这个强大的工具为我们提供了精准控制程序内存布局的能力。无论是进行嵌入式开发、操作系统内核移植,还是实现高级的内存管理与优化,深入理解链接脚本都是突破技术瓶颈的关键。希望这份指南能帮助你在云栈社区的交流与实践中,更自信地驾驭程序构建的最终环节。