链接器究竟做了什么?一次手把手带你玩的链接器脚本实验
编译器可以在不知道机器指令最终会存放在内存何处的情况下生成这些指令。我们把 C 代码和汇编代码传递给像 Clang 或 GCC 这样的编译器驱动程序,最终就会生成一个可执行文件。
但严格来说,编译器并不直接生成可执行二进制文件。它们生成的是可重定位目标文件(Relocatable Object Files),其中填满了未经修正的机器码、独立的符号表以及明确的“修补指令”(即重定位信息 Relocations)。
链接器的作用,就是将这些分散的、地址未确定的碎片合并为一个已分配地址的镜像文件。它决定了各个节区(Section)和符号(Symbol)的位置,解析目标文件之间的引用,修补机器指令与数据,并将最终结果写入 ELF 等可执行文件格式中。
编译器:
输出字节码、符号表与重定位请求
链接器脚本:
描述内存布局约束
链接器:
确定最终地址并修补指令引用
可执行文件格式:
记录生成的字节码与元数据
操作系统加载器 / 引导加载程序(Bootloader):
将这些字节码映射或复制到内存中
CPU:
从入口地址开始执行
链接器接收一个或多个目标文件,并生成最终的可执行文件:
目标文件字节码 + 符号 + 重定位信息
↓
分配最终地址
↓
修补地址引用
↓
可执行文件 / 内核镜像
本文中的实验将创建 ELF 目标文件,使用 LLVM LLD 和标准的 GNU 风格链接器脚本对其进行链接,并带你观察整个过程中的每一个阶段。
1. 准备工作
请确保已安装所需工具(Mac 环境):
brew install llvm lld
定义路径变量:
export LLVM_BIN="$(brew --prefix llvm)/bin"
export LLD_BIN="$(brew --prefix lld)/bin"
检查版本信息:
"$LLVM_BIN/clang" --version
"$LLD_BIN/ld.lld" --version
"$LLVM_BIN/llvm-readelf" --version
2. 源代码
首先,用汇编语言创建一个入口点:
/* 汇编伪指令:
* 选择一个可执行、可分配的目标文件节区
*/
.section .text.start, "ax"
/* 汇编伪指令:
* 将 _start 导出为全局符号
*/
.global _start
/* 汇编伪指令:
* 将 _start 标注为 ELF 函数类型符号
*/
.type _start, %function
/* 标号:
* 将名称 _start 绑定到当前地址
*/
_start:
/* CPU 指令:
* 调用 kernel_main,并将返回地址保存在 x30 寄存器中
*/
bl kernel_main
/* _start 内部的局部标号 */
hang:
/* CPU 指令:
* 无限自跳转(死循环)
*/
b hang
/* 汇编伪指令:
* 记录函数大小为当前位置减去 _start 地址
*/
.size _start, . - _start
-
汇编伪指令(Assembler directives):给汇编器的指令,例如
.section、.global和.type。 -
AArch64 CPU 指令:由处理器实际执行的指令,例如
bl和b。
接下来创建 main.c:
# main.c
extern int add(int a, int b);
const char message[] = "Hello from the linker";
int initialized_value = 40;
int uninitialized_value;
void kernel_main(void)
{
uninitialized_value =
add(initialized_value, 2);
for (;;) {
}
}
该文件故意包含了多种类型的数据结构:
-
kernel_main→ 代码段(Code / Text) -
message→ 只读数据(Read-only Data) -
initialized_value→ 已初始化的可写数据(Initialized Data) -
uninitialized_value→ 未初始化的零值数据(BSS)
最后创建 math.c,用于提供 add 符号:
# math.c
int add(int a, int b)
{
return a + b;
}
3. 编译源文件
编译汇编代码:
"$CC" \
--target=aarch64-none-elf \
-c start.S \
-o start.o
编译 C 语言代码:
"$CC" \
--target=aarch64-none-elf \
-ffreestanding \
-fno-stack-protector \
-fno-pic \
-fno-unwind-tables \
-fno-asynchronous-unwind-tables \
-ffunction-sections \
-fdata-sections \
-O0 \
-c main.c \
-o main.o
"$CC" \
--target=aarch64-none-elf \
-ffreestanding \
-fno-stack-protector \
-fno-pic \
-fno-unwind-tables \
-fno-asynchronous-unwind-tables \
-ffunction-sections \
-fdata-sections \
-O0 \
-c math.c \
-o math.o
此时查看目录文件:
ls
main.c
main.o
math.c
math.o
start.S
start.o
4. 链接前的符号检查
运行 file 命令检查目标文件:
file start.o main.o math.o
输出类似于:
ELF 64-bit LSB relocatable, ARM aarch64
其中 relocatable(可重定位) 意味着这些文件虽然包含了机器码,但它们的最终运行内存地址尚未确定。
使用 llvm-nm 工具列出目标文件中的符号名称:
"$LLVM_BIN/llvm-nm" start.o
示例输出:
0000000000000000 T _start
0000000000000004 t hang
U kernel_main
符号类型说明:
-
T/t:代码段(Text)中的对象。大写T表示全局(Global)符号,小写t表示局部(Local)符号。 -
U:未定义符号(Undefined)。表示该符号在当前文件中被引用,但定义在外部其他文件中。
第一列中的数字(0000000000000000 和 0000000000000004)代表该符号相对于其所在目标文件节区(Section)起始位置的字节偏移量。因为 _start 函数的机器指令恰好占用 4 个字节,所以 0x04 - 0x00 = 4 字节。
查看 main.o 的符号:
"$LLVM_BIN/llvm-nm" main.o
示例输出:
U add
0000000000000000 D init_i
0000000000000000 T kernel_main
0000000000000000 R msg
0000000000000000 B uninit_i
我们可以看到这些符号的初始偏移量都是 0,这是因为每个符号都各自位于不同的独立节区中,偏移量是相对于各自节区起始地址计算的。
查看未决的重定位信息:
"$LLVM_BIN/llvm-readelf" -r start.o
5. 创建链接器脚本 layout.ld
LLD 的 ELF 端口支持 GNU 风格的链接器脚本特性,包括 SECTIONS、输入节区匹配(input-section matching)、地址表达式计算以及 KEEP 关键字。
创建文件 layout.ld:
ENTRY(_start)
SECTIONS
{
/*
* 位置计数器(Location Counter)。
* 将镜像的起始虚拟地址设置为 0x400000。
*/
. = 0x400000;
__image_start = .;
/*
* 收集可执行代码节区
*/
.text : ALIGN(0x1000)
{
KEEP(*(.text.start))
*(.text)
*(.text.*)
}
/*
* 收集只读数据
*/
.rodata : ALIGN(0x1000)
{
*(.rodata)
*(.rodata.*)
}
/*
* 收集已初始化的可写数据
*/
.data : ALIGN(0x1000)
{
*(.data)
*(.data.*)
}
/*
* 收集零初始化的可写数据(BSS)
*/
.bss : ALIGN(0x1000)
{
__bss_start = .;
*(.bss)
*(.bss.*)
*(COMMON)
__bss_end = .;
}
__image_end = .;
/DISCARD/ :
{
*(.comment)
*(.note*)
}
}
6. 使用链接器脚本进行链接与反汇编
运行 LLD 执行链接:
"$LLD_BIN/ld.lld" \
-m aarch64elf \
-T layout.ld \
-Map=kernel.map \
start.o main.o math.o \
-o kernel.elf
查看链接生成的可执行文件符号表(-n 表示按地址排序):
"$LLVM_BIN/llvm-nm" -n kernel.elf
对最终生成的可执行文件进行反汇编查看:
"$LLVM_BIN/llvm-objdump" \
--disassemble \
--symbolize-operands \
kernel.elf