程序员的自我修养

链接、装载与库

第四章:静态链接

4.1 空间与地址分配

空间分配

静态链接时,链接器需要为每个目标文件分配空间。主要包括:

  1. 代码段空间
  2. 数据段空间
  3. BSS 段空间

示例代码:

1
2
3
4
5
6
7
8
#include <stdio.h>

int a = 10;
int b = 20;

void func_a() {
printf("a = %d, b = %d\n", a, b);
}
1
2
3
4
5
6
7
8
#include <stdio.h>

int c = 30;
int d = 40;

void func_b() {
printf("c = %d, d = %d\n", c, d);
}
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

extern void func_a();
extern void func_b();

int main() {
func_a();
func_b();
return 0;
}

编译和链接命令:

1
2
3
4
5
6
7
8
9
10
# 编译
gcc -c static_a.c -o static_a.o
gcc -c static_b.c -o static_b.o
gcc -c static_main.c -o static_main.o

# 链接
gcc static_a.o static_b.o static_main.o -o static_program

# 查看空间分配
objdump -h static_program

地址分配

链接器需要为每个符号分配地址。主要包括:

  1. 代码段地址
  2. 数据段地址
  3. BSS 段地址

使用以下命令查看地址分配:

1
2
3
4
5
# 查看符号地址
nm static_program

# 查看详细的段信息
readelf -S static_program

符号解析

链接器需要解析所有符号的引用。主要包括:

  1. 全局符号解析
  2. 局部符号解析
  3. 弱符号解析

示例代码:

1
2
3
4
5
6
7
8
#include <stdio.h>

int global_var = 10;
static int static_var = 20;

void func_a() {
printf("global_var = %d, static_var = %d\n", global_var, static_var);
}
1
2
3
4
5
6
7
8
#include <stdio.h>

extern int global_var;
static int static_var = 30;

void func_b() {
printf("global_var = %d, static_var = %d\n", global_var, static_var);
}
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

extern void func_a();
extern void func_b();

int main() {
func_a();
func_b();
return 0;
}

编译和链接命令:

1
2
3
4
5
6
7
8
9
10
# 编译
gcc -c symbol_resolve_a.c -o symbol_resolve_a.o
gcc -c symbol_resolve_b.c -o symbol_resolve_b.o
gcc -c symbol_resolve_main.c -o symbol_resolve_main.o

# 链接
gcc symbol_resolve_a.o symbol_resolve_b.o symbol_resolve_main.o -o symbol_resolve_program

# 查看符号解析结果
nm symbol_resolve_program

4.2 符号解析与重定位

符号解析

符号解析的过程:

  1. 收集所有目标文件的符号
  2. 建立符号表
  3. 解析符号引用

示例代码:

1
2
3
4
5
6
#include <stdio.h>

int a = 10;
void func_a() {
printf("a = %d\n", a);
}
1
2
3
4
5
6
#include <stdio.h>

extern int a;
void func_b() {
printf("a = %d\n", a);
}
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

extern void func_a();
extern void func_b();

int main() {
func_a();
func_b();
return 0;
}

编译和链接命令:

1
2
3
4
5
6
7
8
9
10
# 编译
gcc -c resolve_a.c -o resolve_a.o
gcc -c resolve_b.c -o resolve_b.o
gcc -c resolve_main.c -o resolve_main.o

# 链接
gcc resolve_a.o resolve_b.o resolve_main.o -o resolve_program

# 查看符号解析结果
nm resolve_program

重定位

重定位的过程:

  1. 计算符号的最终地址
  2. 修改符号引用
  3. 更新重定位表

示例代码:

1
2
3
4
5
6
#include <stdio.h>

int a = 10;
void func_a() {
printf("a = %d\n", a);
}
1
2
3
4
5
6
#include <stdio.h>

extern int a;
void func_b() {
printf("a = %d\n", a);
}
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

extern void func_a();
extern void func_b();

int main() {
func_a();
func_b();
return 0;
}

编译和链接命令:

1
2
3
4
5
6
7
8
9
10
11
12
# 编译
gcc -c relocate_a.c -o relocate_a.o
gcc -c relocate_b.c -o relocate_b.o
gcc -c relocate_main.c -o relocate_main.o

# 链接
gcc relocate_a.o relocate_b.o relocate_main.o -o relocate_program

# 查看重定位信息
objdump -r relocate_a.o
objdump -r relocate_b.o
objdump -r relocate_main.o

重定位表

重定位表记录了需要重定位的位置。主要包括:

  1. 重定位类型
  2. 重定位符号
  3. 重定位地址

使用以下命令查看重定位表:

1
2
3
4
5
# 查看重定位表
readelf -r relocate_program

# 查看详细的段信息
readelf -S relocate_program

实践练习

  1. 空间分配实验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 创建示例文件
cat > space_test.c << EOL
#include <stdio.h>

int global_var = 10;
static int static_var = 20;

void func() {
printf("global_var = %d, static_var = %d\n", global_var, static_var);
}

int main() {
func();
return 0;
}
EOL

# 编译
gcc -c space_test.c -o space_test.o

# 查看空间分配
objdump -h space_test.o
  1. 符号解析实验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 创建示例文件
cat > resolve_test.c << EOL
#include <stdio.h>

extern int global_var;
void func() {
printf("global_var = %d\n", global_var);
}

int main() {
func();
return 0;
}
EOL

# 编译
gcc -c resolve_test.c -o resolve_test.o

# 查看符号表
nm resolve_test.o
  1. 重定位实验
1
2
3
4
5
# 查看重定位信息
objdump -r resolve_test.o

# 查看详细的段信息
readelf -S resolve_test.o

思考题

  1. 静态链接时,链接器如何分配空间?
  2. 符号解析的过程是什么?
  3. 重定位的作用是什么?
  4. 重定位表的作用是什么?
  5. 如何处理符号冲突?

参考资料

  1. 《程序员的自我修养:链接、装载与库》
  2. GNU Binutils 文档
  3. System V ABI
  4. ELF 文件格式规范
  5. GCC 在线文档

第三章:目标文件里有什么

3.1 目标文件的格式

ELF 文件格式

ELF(Executable and Linkable Format)是一种用于可执行文件、目标代码、共享库和核心转储的标准文件格式。

ELF 文件的基本结构:

  1. ELF 头(ELF Header)
  2. 程序头表(Program Header Table)
  3. 节头表(Section Header Table)
  4. 节(Sections)

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

int global_var = 10;
static int static_var = 20;

void func() {
static int local_static = 30;
int local_var = 40;
printf("Values: %d, %d, %d, %d\n",
global_var, static_var, local_static, local_var);
}

int main() {
func();
return 0;
}

使用以下命令查看 ELF 文件信息:

1
2
3
4
5
6
7
8
9
10
11
# 编译
gcc -c elf_demo.c -o elf_demo.o

# 查看 ELF 头
readelf -h elf_demo.o

# 查看节头表
readelf -S elf_demo.o

# 查看符号表
readelf -s elf_demo.o

段的概念

ELF 文件中的主要段:

  1. .text:代码段,存放可执行指令
  2. .data:数据段,存放已初始化的全局变量和静态变量
  3. .bss:未初始化数据段,存放未初始化的全局变量和静态变量
  4. .rodata:只读数据段,存放常量
  5. .comment:注释信息
  6. .debug:调试信息

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdio.h>

// .text 段
void func() {
printf("Hello\n");
}

// .data 段
int global_init = 10;

// .bss 段
int global_uninit;

// .rodata 段
const char *str = "Hello, World!";

int main() {
// .text 段
func();

// .data 段
static int static_init = 20;

// .bss 段
static int static_uninit;

return 0;
}

使用以下命令查看段信息:

1
2
3
4
5
# 编译
gcc -c sections.c -o sections.o

# 查看段信息
objdump -h sections.o

符号表

符号表记录了目标文件中定义和引用的符号信息。主要包括:

  1. 全局符号
  2. 局部符号
  3. 调试符号

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

// 全局符号
int global_var = 10;

// 静态全局符号
static int static_global = 20;

// 函数符号
void func() {
// 局部符号
int local_var = 30;
printf("%d\n", local_var);
}

int main() {
func();
return 0;
}

使用以下命令查看符号表:

1
2
3
4
5
# 编译
gcc -c symbols.c -o symbols.o

# 查看符号表
nm symbols.o

3.2 链接的接口——符号

符号的定义与引用

符号分为:

  1. 强符号:函数和已初始化的全局变量
  2. 弱符号:未初始化的全局变量

示例代码:

1
2
3
4
5
6
7
// 强符号
int strong_var = 10;
void strong_func() {}

// 弱符号
int weak_var;
void weak_func() {}
1
2
3
4
5
6
7
8
9
10
11
// 引用符号
extern int strong_var;
extern void strong_func();
extern int weak_var;
extern void weak_func();

int main() {
strong_func();
weak_func();
return 0;
}

符号表

符号表的结构:

  1. 符号名
  2. 符号值
  3. 符号大小
  4. 符号类型
  5. 符号绑定信息
  6. 符号可见性

使用以下命令查看详细的符号信息:

1
2
3
4
5
6
7
# 编译
gcc -c symbol_def.c -o symbol_def.o
gcc -c symbol_ref.c -o symbol_ref.o

# 查看符号表
readelf -s symbol_def.o
readelf -s symbol_ref.o

符号修饰与函数签名

C++ 中的符号修饰:

  1. 函数名修饰
  2. 类名修饰
  3. 命名空间修饰

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>

namespace MySpace {
class MyClass {
public:
void myFunc(int x) {
std::cout << x << std::endl;
}
};
}

int main() {
MySpace::MyClass obj;
obj.myFunc(10);
return 0;
}

使用以下命令查看修饰后的符号:

1
2
3
4
5
# 编译
g++ -c name_mangling.cpp -o name_mangling.o

# 查看符号表
nm name_mangling.o

实践练习

  1. ELF 文件分析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 创建示例文件
cat > elf_test.c << EOL
#include <stdio.h>

int global_var = 10;
static int static_var = 20;

void func() {
printf("Hello\n");
}

int main() {
func();
return 0;
}
EOL

# 编译
gcc -c elf_test.c -o elf_test.o

# 分析 ELF 文件
readelf -h elf_test.o
readelf -S elf_test.o
readelf -s elf_test.o
  1. 段分析
1
2
3
4
5
# 查看段信息
objdump -h elf_test.o

# 查看段内容
objdump -s elf_test.o
  1. 符号分析
1
2
3
4
5
# 查看符号表
nm elf_test.o

# 查看详细的符号信息
readelf -s elf_test.o

思考题

  1. ELF 文件格式的主要组成部分是什么?
  2. 代码段和数据段的区别是什么?
  3. 强符号和弱符号的区别是什么?
  4. 为什么需要符号修饰?
  5. 如何解决符号冲突问题?

参考资料

  1. 《程序员的自我修养:链接、装载与库》
  2. ELF 文件格式规范
  3. GNU Binutils 文档
  4. System V ABI
  5. C++ 名字修饰

第二章:编译和链接

2.1 被隐藏了的过程

预编译(Preprocessing)

预编译阶段主要处理以下内容:

  1. 处理所有的预处理指令
  2. 展开所有的宏定义
  3. 处理条件编译指令
  4. 删除注释

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

#define MAX 100
#define SQUARE(x) ((x) * (x))

#ifdef DEBUG
#define LOG(msg) printf("Debug: %s\n", msg)
#else
#define LOG(msg)
#endif

int main() {
int a = MAX;
int b = SQUARE(a);
LOG("Calculation completed");
return 0;
}

使用以下命令查看预处理后的文件:

1
gcc -E preprocessing.c -o preprocessing.i

编译(Compilation)

编译阶段将预处理后的文件转换为汇编代码。主要包括以下步骤:

  1. 词法分析

    • 将源代码分解成标记(token)
    • 识别关键字、标识符、运算符等
  2. 语法分析

    • 将标记组织成语法树
    • 检查语法正确性
  3. 语义分析

    • 检查类型匹配
    • 检查变量声明
    • 检查函数调用
  4. 中间代码生成

    • 生成中间表示(IR)
    • 优化中间代码
  5. 目标代码生成

    • 生成汇编代码
    • 寄存器分配
    • 指令选择

示例代码:

1
2
3
4
5
6
7
8
9
10
int add(int a, int b) {
return a + b;
}

int main() {
int x = 10;
int y = 20;
int result = add(x, y);
return result;
}

使用以下命令生成汇编代码:

1
gcc -S compilation.c -o compilation.s

汇编(Assembly)

汇编阶段将汇编代码转换为机器码。主要包括:

  1. 将汇编指令转换为机器指令
  2. 生成目标文件(.o文件)
  3. 生成符号表

使用以下命令生成目标文件:

1
gcc -c compilation.s -o compilation.o

链接(Linking)

链接阶段将多个目标文件合并成一个可执行文件。主要包括:

  1. 地址和空间分配
  2. 符号决议
  3. 重定位

示例代码(多文件):

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

extern int add(int a, int b);

int main() {
int x = 10;
int y = 20;
int result = add(x, y);
printf("Result: %d\n", result);
return 0;
}
1
2
3
int add(int a, int b) {
return a + b;
}

编译和链接命令:

1
2
3
gcc -c link_main.c -o link_main.o
gcc -c link_add.c -o link_add.o
gcc link_main.o link_add.o -o program

2.2 编译器做了什么

词法分析

词法分析器将源代码分解成标记(token)。例如:

1
int a = 10;

会被分解为:

  • 关键字:int
  • 标识符:a
  • 运算符:=
  • 常量:10
  • 分号:;

语法分析

语法分析器将标记组织成语法树。例如:

1
2
3
4
5
if (a > b) {
return a;
} else {
return b;
}

会生成类似这样的语法树:

1
2
3
4
5
6
7
    if
/ \
> else
/ \ / \
a b return return
| |
a b

语义分析

语义分析器检查程序的语义正确性。例如:

1
2
3
int a = "hello";  // 类型不匹配错误
int b = 10;
int c = a + b; // 类型不匹配错误

中间代码生成

中间代码生成器生成中间表示。例如:

1
2
3
int a = 10;
int b = 20;
int c = a + b;

可能生成类似这样的中间代码:

1
2
3
t1 = 10
t2 = 20
t3 = t1 + t2

目标代码生成

目标代码生成器生成最终的汇编代码。例如:

1
2
3
int add(int a, int b) {
return a + b;
}

可能生成类似这样的汇编代码:

1
2
3
4
5
6
7
8
9
10
add:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx
pop rbp
ret

实践练习

  1. 预编译实验
1
2
3
4
5
6
7
8
9
10
11
12
13
# 创建预编译示例文件
cat > preprocess.c << EOL
#include <stdio.h>
#define SQUARE(x) ((x) * (x))
int main() {
int a = 5;
printf("%d\n", SQUARE(a));
return 0;
}
EOL

# 查看预编译结果
gcc -E preprocess.c -o preprocess.i
  1. 编译实验
1
2
3
4
5
# 生成汇编代码
gcc -S preprocess.c -o preprocess.s

# 查看生成的汇编代码
cat preprocess.s
  1. 链接实验
1
2
3
4
5
6
# 编译多个源文件
gcc -c preprocess.c -o preprocess.o
gcc preprocess.o -o preprocess

# 运行程序
./preprocess

思考题

  1. 预编译阶段为什么要处理条件编译指令?
  2. 词法分析和语法分析的区别是什么?
  3. 为什么需要中间代码生成阶段?
  4. 链接阶段的主要任务是什么?
  5. 编译器优化在哪个阶段进行?

参考资料

  1. 《程序员的自我修养:链接、装载与库》
  2. 《编译原理》(龙书)
  3. GCC 在线文档
  4. LLVM 文档
  5. GNU Binutils 文档

第一章:温故而知新

1.1 从 Hello World 说起

程序从源代码到可执行文件的完整过程

让我们通过一个简单的 Hello World 程序来了解整个编译过程:

1
2
3
4
5
6
#include <stdio.h>

int main() {
printf("Hello, World!\n");
return 0;
}

1. 预处理(Preprocessing)

预处理阶段主要处理以下内容:

  • 处理所有的预处理指令(以 # 开头的指令)
  • 展开所有的宏定义
  • 处理条件编译指令
  • 删除注释

可以使用以下命令查看预处理后的文件:

1
gcc -E hello_world.c -o hello_world.i

2. 编译(Compilation)

编译阶段将预处理后的文件转换为汇编代码:

1
gcc -S hello_world.i -o hello_world.s

生成的汇编代码大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    .section    __TEXT,__text,regular,pure_instructions
.build_version macos, 10, 15 sdk_version 10, 15
.globl _main ## -- Begin function main
.p2align 4, 0x90
_main: ## @main
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
leaq L_.str(%rip), %rdi
movb $0, %al
callq _printf
xorl %ecx, %ecx
movl %eax, -4(%rbp)
movl %ecx, %eax
addq $16, %rsp
popq %rbp
retq

3. 汇编(Assembly)

汇编阶段将汇编代码转换为机器码:

1
gcc -c hello_world.s -o hello_world.o

4. 链接(Linking)

链接阶段将目标文件与所需的库文件链接,生成可执行文件:

1
gcc hello_world.o -o hello_world

编译、链接、装载的基本概念

编译

编译是将高级语言转换为汇编语言的过程,主要包括:

  • 词法分析:将源代码分解成标记(token)
  • 语法分析:将标记组织成语法树
  • 语义分析:检查语法树的语义正确性
  • 中间代码生成:生成中间表示
  • 目标代码生成:生成汇编代码

链接

链接是将多个目标文件合并成一个可执行文件的过程,主要包括:

  • 地址和空间分配
  • 符号决议
  • 重定位

装载

装载是将可执行文件加载到内存中运行的过程,主要包括:

  • 创建进程
  • 分配虚拟地址空间
  • 加载可执行文件
  • 设置程序入口点

1.2 万变不离其宗

计算机系统的基本组成

硬件系统

  1. CPU(中央处理器)

    • 控制单元
    • 运算单元
    • 寄存器组
  2. 内存

    • RAM(随机访问存储器)
    • ROM(只读存储器)
    • 缓存
  3. 外设

    • 输入设备
    • 输出设备
    • 存储设备

软件系统

  1. 操作系统

    • 进程管理
    • 内存管理
    • 文件系统
    • 设备驱动
  2. 应用程序

    • 系统程序
    • 用户程序
  3. 系统库

    • 标准库
    • 运行时库
    • 第三方库

程序运行的基本原理

程序加载

  1. 可执行文件格式

    • ELF(Linux)
    • PE(Windows)
    • Mach-O(macOS)
  2. 程序入口点

    • 主函数入口
    • 启动代码
    • 初始化代码
  3. 内存映射

    • 代码段
    • 数据段

程序执行

  1. 指令执行

    • 取指令
    • 译码
    • 执行
    • 写回
  2. 数据访问

    • 寄存器访问
    • 内存访问
    • 外设访问
  3. 系统调用

    • 进程控制
    • 文件操作
    • 设备操作
    • 网络通信

程序终止

  1. 正常退出

    • 返回主函数
    • 调用 exit()
    • 调用 _exit()
  2. 异常处理

    • 信号处理
    • 异常处理
    • 错误处理
  3. 资源释放

    • 内存释放
    • 文件关闭
    • 设备释放

实践练习

  1. 编译过程实验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 预处理
gcc -E hello_world.c -o hello_world.i

# 2. 编译
gcc -S hello_world.i -o hello_world.s

# 3. 汇编
gcc -c hello_world.s -o hello_world.o

# 4. 链接
gcc hello_world.o -o hello_world

# 5. 运行
./hello_world
  1. 查看文件信息
1
2
3
4
5
6
7
8
# 查看可执行文件信息
file hello_world

# 查看文件大小
ls -l hello_world

# 查看文件依赖
ldd hello_world

思考题

  1. 为什么需要预处理阶段?预处理阶段主要处理哪些内容?
  2. 编译和汇编的区别是什么?
  3. 链接阶段的主要任务是什么?
  4. 程序加载到内存后,内存布局是怎样的?
  5. 系统调用和普通函数调用的区别是什么?

参考资料

  1. 《程序员的自我修养:链接、装载与库》
  2. 《深入理解计算机系统》
  3. 《编译原理》(龙书)
  4. GCC 在线文档
  5. ELF 文件格式规范
0%