本文译自 0xAX 的 Linux-insides 系列,原文地址。
在编写 linux-insides 一书的过程中,我收到了许多关于链接器和链接器脚本的问题。因此,我决定撰写这篇文章,来深入探讨链接器以及目标文件链接的相关知识。
链接器是什么?
打开维基百科的链接器页面,你会看到这样的定义:
在计算机科学中,链接器(英文:Linker),是一个计算机程序,它将一个或多个由编译器生成的目标文件链接为一个单独的可执行文件,库文件或者另外一个目标文件。
如果你曾用 C 语言编写过程序,就一定见过以 *.o 扩展名结尾的文件。这些文件就是目标文件。你可以把目标文件看作一块块的机器码和数据,其中包含了对其他目标文件或库中数据和函数的引用(占位符地址),也包含了自身函数和数据的列表。链接器的主要任务正是收集并处理每一个目标文件中的代码和数据,将它们组合成最终的可执行文件或库。接下来,让我们一步步剖析这个流程。
链接流程初探
首先,我们创建一个简单的项目,结构如下:
*-linkers
*--main.c
*--lib.c
*--lib.h
我们的 main.c 源文件内容如下:
#include <stdio.h>
#include "lib.h"
int main(int argc, char **argv) {
printf("factorial of 5 is: %d\n", factorial(5));
return 0;
}
lib.c 文件包含阶乘函数的实现:
int factorial(int base) {
int res,i = 1;
if (base == 0) {
return 1;
}
while (i <= base) {
res *= i;
i++;
}
return res;
}
lib.h 头文件则是对该函数的声明:
#ifndef LIB_H
#define LIB_H
int factorial(int base);
#endif
现在,让我们单独编译 main.c 源文件:
$ gcc -c main.c
使用 nm 工具查看生成的目标文件 main.o:
$ nm -A main.o
main.o: U factorial
main.o:0000000000000000 T main
main.o: U printf
nm 工具可以列出给定目标文件的符号表。输出包含三列:第一列是目标文件名和解析到的符号地址;第二列是一个表示符号状态的字符。这里的 U 表示未定义,T 表示该符号位于 .text 段。输出显示 main.c 包含了三个符号:
factorial - 在 lib.c 中定义的函数。由于我们只编译了 main.c,因此它对此一无所知;
main - 主函数;
printf - 来自 glibc 库的函数。main.c 同样不知道它。
我们能从 nm 的输出中了解到什么?main.o 目标文件包含了一个位于地址 0000000000000000 的本地符号 main(在链接后会被赋予正确地址),以及两个无法解析的符号。我们还可以通过反汇编来进一步查看:
$ objdump -S main.o
main.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 48 89 75 f0 mov %rsi,-0x10(%rbp)
f: bf 05 00 00 00 mov $0x5,%edi
14: e8 00 00 00 00 callq 19 <main+0x19>
19: 89 c6 mov %eax,%esi
1b: bf 00 00 00 00 mov $0x0,%edi
20: b8 00 00 00 00 mov $0x0,%eax
25: e8 00 00 00 00 callq 2a <main+0x2a>
2a: b8 00 00 00 00 mov $0x0,%eax
2f: c9 leaveq
30: c3 retq
这里我们重点关注两个 callq 操作。这两个 callq 操作包含链接器存根,或者说函数的名称及其相对于下一条指令的偏移量。这些存根将在链接时被更新为函数的真实地址。通过 objdump 的 -r 选项,我们可以看到这些函数的名字:
$ objdump -S -r main.o
...
14: e8 00 00 00 00 callq 19 <main+0x19>
15: R_X86_64_PC32 factorial-0x4
19: 89 c6 mov %eax,%esi
...
25: e8 00 00 00 00 callq 2a <main+0x2a>
26: R_X86_64_PC32 printf-0x4
2a: b8 00 00 00 00 mov $0x0,%eax
...
objdump 的 -r 或 --reloc 选项会打印文件的重定位条目。这就引出了链接过程中的核心概念——重定位。
理解重定位
重定位是连接符号引用和符号定义的过程。再看一遍前面的 objdump 输出片段:
14: e8 00 00 00 00 callq 19 <main+0x19>
15: R_X86_64_PC32 factorial-0x4
注意第一行的 e8 00 00 00 00。e8 是 call 指令的操作码,后面跟着一个四字节的相对偏移地址。为什么是4字节而不是64位机器上的8字节?这是因为我们(隐式地)使用了 -mcmodel=small 模型编译 main.c。根据 GCC 文档,此模型假定程序将链接到低于2GB的地址空间,因此4字节偏移足够了。于是,我们得到了一条 call 指令和一个待填充的未知地址。
当我们编译 main.c 并将其与依赖项链接成可执行文件后,再观察对 factorial 函数的调用:
$ gcc main.c lib.c -o factorial | objdump -S factorial | grep factorial
factorial: file format elf64-x86-64
...
...
0000000000400506 <main>:
40051a: e8 18 00 00 00 callq 400537 <factorial>
...
...
0000000000400537 <factorial>:
400550: 75 07 jne 400559 <factorial+0x22>
400557: eb 1b jmp 400574 <factorial+0x3d>
400559: eb 0e jmp 400569 <factorial+0x32>
40056f: 7e ea jle 40055b <factorial+0x24>
...
...
现在,main 函数的地址是 0x0000000000400506,factorial 函数的地址是 0x0000000000400537,而调用 factorial 的指令变成了 e8 18 00 00 00。0x18 就是从 callq 指令的下一条指令到 factorial 函数入口的偏移量(注意 call 指令本身长5字节)。
>>> hex(0x40051a + 0x18 + 0x5) == hex(0x400537)
True
这个计算验证了偏移量的正确性。编译器在创建目标文件时,通常从地址零开始。但当程序由多个目标文件生成时,这些地址就会重叠。重定位流程正是为了解决这个问题:它为程序的各个部分分配加载地址,并调整程序中的代码和数据,以反映这些被分配的地址。
动手使用 GNU 链接器
如标题所示,本文将使用 GNU 链接器,即 ld。当然,我们可以直接用 gcc 链接项目:
$ gcc main.c lib.o -o factorial
之后得到可执行文件 factorial:
$ ./factorial
factorial of 5 is: 120
但 gcc 本身并不直接链接目标文件,而是调用 GNU ld 链接器的封装——collect2。现在,让我们尝试直接用 ld 实现相同的链接过程:
$ ld main.o lib.o -o factorial
尝试后你会看到错误:
$ ld main.o lib.o -o factorial
ld: warning: cannot find entry symbol _start; defaulting to 00000000004000b0
main.o: In function `main':
main.c:(.text+0x26): undefined reference to `printf'
这里有两个问题:
- 链接器找不到
_start 符号;
- 链接器不知道
printf 是什么。
很多人以为 main 函数是程序的入口点,但实际上 _start 才是。_start 符号由 crt1.o 定义。我们需要将其加入链接:
$ ld /usr/lib/gcc/x86_64-linux-gnu/4.9/../../../x86_64-linux-gnu/crt1.o \
main.o lib.o -o factorial
但这样又会引入更多未定义的引用,如 __libc_csu_fini, __libc_csu_init, __libc_start_main。这些都是 glibc 初始化例程的一部分。我们还需要处理初始化(.init)和终止(.fini)段,这需要 crti.o 和 crtn.o:
$ ld \
/usr/lib/gcc/x86_64-linux-gnu/4.9/../../../x86_64-linux-gnu/crt1.o \
/usr/lib/gcc/x86_64-linux-gnu/4.9/../../../x86_64-linux-gnu/crti.o \
/usr/lib/gcc/x86_64-linux-gnu/4.9/../../../x86_64-linux-gnu/crtn.o main.o lib.o \
-o factorial
我们还需要链接 C 标准库(-lc):
$ ld \
/usr/lib/gcc/x86_64-linux-gnu/4.9/../../../x86_64-linux-gnu/crt1.o \
/usr/lib/gcc/x86_64-linux-gnu/4.9/../../../x86_64-linux-gnu/crti.o \
/usr/lib/gcc/x86_64-linux-gnu/4.9/../../../x86_64-linux-gnu/crtn.o main.o lib.o -lc \
-o factorial
现在生成了可执行文件,但运行时会报错“No such file or directory”。用 readelf 查看会发现,程序需要动态链接器(解释器):
$ readelf -l factorial | grep interpreter
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
因此,我们需要通过 -dynamic-linker 选项指定动态链接器的路径:
$ gcc -c main.c lib.c
$ ld \
/usr/lib/gcc/x86_64-linux-gnu/4.9/../../../x86_64-linux-gnu/crt1.o \
/usr/lib/gcc/x86_64-linux-gnu/4.9/../../../x86_64-linux-gnu/crti.o \
/usr/lib/gcc/x86_64-linux-gnu/4.9/../../../x86_64-linux-gnu/crtn.o main.o lib.o \
-dynamic-linker /lib64/ld-linux-x86-64.so.2 \
-lc -o factorial
现在,程序可以正常运行了:
$ ./factorial
factorial of 5 is: 120
以上就是一个使用 ld 手动链接C程序的完整过程。除了 -o、-dynamic-linker,ld 还支持大量其他选项。
实用的 GNU 链接器命令行选项
ld 拥有丰富的命令行选项,下面介绍几个实用的:
@file:从指定文件 file 中读取命令行选项。例如,可将所有参数存入 linker.ld 文件,然后执行 ld @linker.ld。
-b 或 --format:指定输入目标文件的格式(如 ELF, COFF)。对应的输出格式选项是 --oformat=output-format。
--defsym=symbol=expression:在输出文件中创建一个全局符号,其值为给定的绝对地址表达式。例如,Linux内核构建中用于定义BSS段大小的符号:LDFLAGS_vmlinux = --defsym _kernel_bss_size=$(KBSS_SZ)。
-shared:创建共享库。
-M 或 --map <filename>:打印详细的链接映射(含符号信息)到文件或标准输出。
--help 和 --version:打印帮助信息和版本号。
当然,ld 的选项远不止这些,完整文档可在其手册中找到。
链接器控制语言
ld 支持一种专用的链接器控制语言(Linker Script),它基于 AT&T 链接器语法,提供了对链接过程的精确控制。我们可以通过 -T 选项将脚本文件传递给 ld。
链接器脚本中最重要的指令是 SECTIONS,它决定了输出文件中各段的“映射”布局。特殊变量 . 表示当前输出位置计数器。
让我们通过一个简单的汇编程序来体验链接器脚本。这是一个“Hello World”程序 (hello.asm):
.data
msg: .ascii "hello, world!\n"
.text
.global _start
_start:
mov $1,%rax
mov $1,%rdi
mov $msg,%rsi
mov $14,%rdx
syscall
mov $60,%rax
mov $0,%rdi
syscall
通常编译链接命令是:
$ as -o hello.o hello.asm
$ ld -o hello hello.o
现在,我们编写一个链接器脚本 linker.script 来控制链接:
/*
* Linker script for the hello program
*/
OUTPUT(hello)
OUTPUT_FORMAT("elf64-x86-64")
INPUT(hello.o)
SECTIONS
{
. = 0x200000;
.text : {
*(.text)
}
. = 0x400000;
.data : {
*(.data)
}
}
脚本解释:
OUTPUT 和 OUTPUT_FORMAT 指定输出文件名和格式。
INPUT 指定输入文件。
SECTIONS 内:. = 0x200000; 将当前位置计数器设为 0x200000,随后的 .text : { *(.text) } 将所有输入文件的 .text 段放入输出文件的 .text 段,并使其起始于地址 0x200000。同理,.data 段被设置在 0x400000。
使用脚本进行链接:
$ as -o hello.o hello.asm && ld -T linker.script hello.o && ./hello
hello, world!
用 objdump 验证,可以看到段确实被定位到了我们指定的地址:
$ objdump -D hello
...
Disassembly of section .text:
0000000000200000 <_start>:
200000: 48 c7 c0 01 00 00 00 mov $0x1,%rax
...
Disassembly of section .data:
0000000000400000 <msg>:
400000: 68 65 6c 6c 6f pushq $0x6f6c6c65
...
链接器脚本还支持其他命令,例如:
ASSERT(exp, message):确保表达式不为零,否则报错退出。例如Linux内核脚本中检查设置头偏移:. = ASSERT(hdr == 0x1f1, "The setup header has the wrong offset!");
INCLUDE filename:包含另一个链接器脚本文件。
- 赋值操作:支持
=、+=、-=、*=、/=、<<=、>>=、&=、|= 等C风格的赋值操作符。
- 内嵌函数:如
ABSOLUTE(绝对值)、ADDR(段地址)、ALIGN(对齐)、SIZEOF(段大小)等。
总结
希望通过本文,你能对链接器的作用、工作流程以及GNU ld的基本使用有了更清晰的认识。从目标文件中的未解析符号,到重定位过程如何填充地址,再到如何手动调用链接器并编写脚本控制输出布局,这些都是理解程序从源码到可执行文件这一关键环节的基础知识。如果你对更底层的编译原理和系统知识感兴趣,欢迎在云栈社区与其他开发者交流探讨。
相关链接