C 语言如何在你的 PC 上运行

该文章作为本人学习《Computer Systems : A Programmer’s Prespective》(以下简称 CSAPP) 以及《Operating System Three Easy Pieces》(以下简称 OSTEP) 的读书笔记,如有错误或者需要讨论之处,欢迎联系我。

OS:Arch Linux

GCC Version:12.2.0

第一个例子

C 语言从 PC 上的源文件到操作系统上执行,需要通过预处理、编译、链接等过程,最后得到一个 ELF 格式的二进制文件,对于以下一个最简单的 hello 程序,我们通过 gcc hello.c 命令可以获得一个 20552 字节的 a.out 文件,通过 file 命令我们可以知道这个 a.out 是一个动态链接的 ELF 格式可执行文件,运行 a.out 我们也成功获得了 hello 输出。

1
2
3
4
5
#include <stdio.h>
int main(){
printf("hello");
return 0;
}
1
2
sh> file a.out                 ─╯
a.out: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c94de1fc1f3994f86f1c0921b41c612d9bf4221f, for GNU/Linux 4.4.0, with debug_info, not stripped
1
2
sh> ./a.out
hello

从上面的例子我们可以知道,其实我们的 C 程序就是在磁盘上面的一些字节序列,当我们运行时,操作系统就会执行里面的代码,从而得到想要的结果,这是最抽象的想法,接下来我们深入里面的细节。

编译过程

我们跳过了预处理过程,事实上预处理过程对于以上的 hello.c 来说就是将 stdio.h 中的所有内容复制拷贝到了 hello.i,仅此而已。

然后就是 Complier 执行的编译过程,我们可以使用 gcc -c hello.c 的命令只编译而不链接 hello.o 文件,该文件是还未链接的可重定向文件,我们可以使用 objdump -d ./hello.o 命令查看。

1
sh> gcc -c ./hello.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sh> objdump -d ./hello.o                                                                            ─╯

./hello.o: 文件格式 elf64-x86-64


Disassembly of section .text:

0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # b <main+0xb>
b: 48 89 c7 mov %rax,%rdi
e: b8 00 00 00 00 mov $0x0,%eax
13: e8 00 00 00 00 call 18 <main+0x18>
18: b8 00 00 00 00 mov $0x0,%eax
1d: 5d pop %rbp
1e: c3 ret

可以看到我们得到了一系列的 x86-64 汇编代码(这里可能需要你有一些汇编代码的基础,不过不用担心,我们只会触及一些简单的指令),我们可以看到 main 从 0x0 的地址开始,地址 0x0 的字节为 0x55,这是一条 push %rbp 指令,这些我们暂时略过,看到 0x13 地址的指令,对应了 call 18 <main+0x18> ,在 x86-64 汇编代码中 call 代表了函数跳转指令,对应于我们的源代码,可以猜到这一条汇编指令应该是对应于 printf("hello"); ,但是在这里 call 的明明是地址 0x18 对应的指令,并没有到 printf,这就是因为我们并没有链接,并没有将 printf.o 链接进来,我们的 hello.o 并不知道 printf 这个函数的地址在哪,因此我们接下来需要链接过程。

至于源代码文件是如何到汇编代码,这些是编译器做的事情,我们暂时不谈及,事实上在 CSAPP 中我们可以看到编译器在翻译源代码时候使用了许多 trick,让编译得到的汇编代码运行得最好。

链接过程

链接一共分为两个部分:符号解析和重定位。首先我们先宏观的看待一下链接过程。

我们准备了以下两个文件的源代码,一个是 add.c,一个是 main.c。

1
2
3
4
// add.c
int add(int a, int b) {
return a + b;
}
1
2
3
4
5
6
7
8
// main.c
int add(int a,int b);

int main() {
int sum;
sum = add(1,1);
return 0;
}

我们还是重复上面编译的过程,使用 gcc -c mian.o main.c gcc -c add.o add.c 来编译上面两个源文件,然后用过 objdump 命令查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sh> objdump -d ./main.o
./main.o: 文件格式 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: be 01 00 00 00 mov $0x1,%esi
d: bf 01 00 00 00 mov $0x1,%edi
12: e8 00 00 00 00 call 17 <main+0x17>
17: 89 45 fc mov %eax,-0x4(%rbp)
1a: b8 00 00 00 00 mov $0x0,%eax
1f: c9 leave
20: c3 ret

与我们前面的例子一样,在 0x12 地址的地方我们明明要调用 add 函数,但是由于未链接,所以我们并不知道 add 函数的入口在哪,所以编译器在编译的时候把 0x12 地址的字节写成了 0xe800000000,0xe8 之后本来应该是 add 的相对地址,这里设置为 0,其实也是在提醒后面的连接器,这里需要链接。

x86-64 汇编语言中 call 指令的语义:首先 0xe800000000 中的 e8 代表 call 指令,后面四个字节其实是一个相对地址,例如上面的 0xe800000000 如果为 0xe8000000aa,那么我们实际跳转的地址为 call 指令后四个字节加上 call 下一条指令的地址,这里就是 0x17+0xaa,而我们没有链接的汇编代码直接把相对偏移设置为 0,就跳转到下一条指令了。

然后我们开始链接,使用 gcc main.o add.o -static 完成链接得到 a.out 可执行文件,我们又使用 objdump 来看看 a.out 中的汇编代码,代码很长,我们挑选其中一些看。

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
29
30
31
32
33
34
35
36
37
./a.out:     文件格式 elf64-x86-64


Disassembly of section .init:

0000000000401000 <_init>:
.....

Disassembly of section .plt:

..........

0000000000401605 <main>:
401605: 55 push %rbp
401606: 48 89 e5 mov %rsp,%rbp
401609: 48 83 ec 10 sub $0x10,%rsp
40160d: be 01 00 00 00 mov $0x1,%esi
401612: bf 01 00 00 00 mov $0x1,%edi
401617: e8 0a 00 00 00 call 401626 <add>
40161c: 89 45 fc mov %eax,-0x4(%rbp)
40161f: b8 00 00 00 00 mov $0x0,%eax
401624: c9 leave
401625: c3 ret

0000000000401626 <add>:
401626: 55 push %rbp
401627: 48 89 e5 mov %rsp,%rbp
40162a: 89 7d fc mov %edi,-0x4(%rbp)
40162d: 89 75 f8 mov %esi,-0x8(%rbp)
401630: 8b 55 fc mov -0x4(%rbp),%edx
401633: 8b 45 f8 mov -0x8(%rbp),%eax
401636: 01 d0 add %edx,%eax
401638: 5d pop %rbp
401639: c3 ret
40163a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
....

我们再来看 main 的汇编代码,首先我们的地址并不是刚刚只编译的从 0 开始了,而是从 0x0000000000401605 开始,其次我们的 0x401617 地址的 call 指令也有了真正的偏移量,成功跳转到了 add 函数中(根据 call 指令的语义,0x40161c+0x00000a=0x401626,刚好就是 add 的入口地址),至此我们的 a.out 就算是真真正正一个可执行文件了。

静态链接

可以看到我们上面的过程就是静态链接的过程,下面来探究其中的细节。

首先我们的 main.o 和 add.o 都是可重定位文件,Linux 下 ELF 可重定位文件的结构如下:

可以看到整个文件分成了很多个 section(节),其实你回到我们 objdump -d ./main.o 命令的结果也可以看到 Disassembly of section .text ,他反汇编了. text 节,让我们看到了汇编代码。

一些比较重要的 section:

.text:已编译程序的汇编代码。

.rodata:只读数据。

.data:已初始化的全局变量和静态 C 变量。

.bss:未初始化的全局变量和静态 C 变量。

.rel.text 和. rel.data:一些重定位信息,一般是该程序引用的外部的符号,例如这里 main.o 中引用的 add 函数,最后是通过这里的信息进行重定位。

总之每一个. o 文件中有一些自己的代码、数据,自己定义的一些符号,还有引用的别的文件的符号,需要通过链接解析。

通过了解 ELF 文件格式,我们就可以知道,在我们静态链接的时候,其实就是将一系列. o 文件中的符号各自解析。

再看到上面 main 和 add 的例子,main.o 在链接的时候已经可以把自己的 add 符号与 add.o 中的 add 联系起来,但是具体到可执行文件 a.out 中时,还需要考虑各自的地址,因为我们看到 main.o 和 add.o 中的地址都是从 0 开始,具体执行的时候还需要将他们放在合适的地址。

这里又要回到我们的编译过程,上面编译得到的 main.o 在 call 指令的时候,偏移地址设置成了 0,因为编译器并不知道 add 函数入口到底在什么地方,所以在这里就会在. rel.text 节中加入一条 add 函数的重定位条目,提醒 linker 在链接的时候进行重定位获取正确的地址,我们可以通过 readelf -a main.o 进行查看:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
sh> readelf -a main.o

ELF 头: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
Version: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: REL (可重定位文件)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x0
程序头起点: 0 (bytes into file)
Start of section headers: 528 (bytes into file)
标志: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 13
Section header string table index: 12

.......

重定位节 '.rela.text' at offset 0x170 contains 1 entry:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000013 000400000004 R_X86_64_PLT32 0000000000000000 add - 4

重定位节 '.rela.eh_frame' at offset 0x188 contains 1 entry:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0


Symbol table '.symtab' contains 5 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 33 FUNC GLOBAL DEFAULT 1 main
4: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND add

.......

我们可以具体看到 ELF 文件中的 .symtab 节,其中包含了许多符号,可以看到 add 符号是 UND(undefined),我们还可以看到 add 的重定位条目,通过这一条重定位信息,就可以在静态链接的时候将 add 的地址真正填入,具体算法为:call 指令的后四个字节对应的 32bit = S + A - P,其中 S 为 add 的地址,在上面 objdump 的时候可以看到 S = 0x401626,A 为重定向条目中的加数,A = -4,P = main + offset = 0x401605 + 0x13 = 0x401618,算出 S + A - P = 0x401626 - 0x4 -0x401618 = 0xa,所以链接之后 call 变成了401617: e8 0a 00 00 00 call 401626 <add>,将后面的 0x00000000 变成了 0x0a000000。

1
2
3
重定位节 '.rela.text' at offset 0x170 contains 1 entry:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000013 000400000004 R_X86_64_PLT32 0000000000000000 add - 4

到这里,链接要做的符号解析和重定位就已经完成了,得到了可执行文件 a.out。事实上 a.out 的文件格式十分类似 ELF 格式,不过 a.out 中的. data 节现在是 main.o 和 add.o 中的. data 结合起来,其他的类似. text 节也是这样将两者结合起来,但是没有了. rel 节,因为 a.out 已经完全不需要再重定位了。

动态链接

一般来说,我们静态链接的 a.out 文件都非常大,这个时候我们就需要有动态链接。动态链接是在加载或运行的时候将共享库(.so 文件)加载到内存中的过程,因此我们并不需要每次链接的时候都把很大的. o 文件中的代码节和数据节复制到 a.out 中,而且一个共享库(.so 文件)中的代码节还可以被其他的进程使用。

这一次我们用动态链接上面的 main 和 add,使用 gcc -shared -fpic -o add.so add.c 命令生成 so 文件,gcc -o a.out main.c ./add.so 命令完成动态链接得到 a.out 并成功运行。然后我们再使用 objdump -d ./a.out 看看 a.out 中的内容:

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
29
30
31
32
33
34
35
36
37
38
39
40
41

./a.out: 文件格式 elf64-x86-64


Disassembly of section .init:

0000000000001000 <_init>:
.....

Disassembly of section .plt:

0000000000001020 <add@plt-0x10>:
1020: ff 35 ca 2f 00 00 push 0x2fca(%rip) # 3ff0 <_GLOBAL_OFFSET_TABLE_+0x8>
1026: ff 25 cc 2f 00 00 jmp *0x2fcc(%rip) # 3ff8 <_GLOBAL_OFFSET_TABLE_+0x10>
102c: 0f 1f 40 00 nopl 0x0(%rax)

0000000000001030 <add@plt>:
1030: ff 25 ca 2f 00 00 jmp *0x2fca(%rip) # 4000 <add@Base>
1036: 68 00 00 00 00 push $0x0
103b: e9 e0 ff ff ff jmp 1020 <_init+0x20>

Disassembly of section .text:

0000000000001040 <_start>:
.....

0000000000001139 <main>:
1139: 55 push %rbp
113a: 48 89 e5 mov %rsp,%rbp
113d: 48 83 ec 10 sub $0x10,%rsp
1141: be 01 00 00 00 mov $0x1,%esi
1146: bf 01 00 00 00 mov $0x1,%edi
114b: e8 e0 fe ff ff call 1030 <add@plt>
1150: 89 45 fc mov %eax,-0x4(%rbp)
1153: b8 00 00 00 00 mov $0x0,%eax
1158: c9 leave
1159: c3 ret

Disassembly of section .fini:
....

首先 a.out 中的内容少了很多,这得益于动态链接,可以看到我们 main 中调用的 add 也有了地址,事实上,linker 在链接的时候会把一些重定位和符号表信息链接到 a.out,但是并没有将代码和数据真正放到这里,在加载运行的时候,通过动态连接器(ld-linux-x86-64.so)将代码和数据放到进程地址空间中的共享库代码处。

加载与运行

当你在 Linux Shell 中键入 ./a.out 时,Shell 接收到命令后,在操作系统中使用 fork 创建一个新的进程。在子进程中使用 execve 加载 a.out。

首先 fork 这个系统调用(syscall)会创建一个进程,还会开辟一个进程的地址空间,如下:

地址空间从 0 开始,在 0x400000 处是 a.out 中的代码节、数据节、只读数据节等,再往上就是 heap(malloc 使用的堆区),然后就是共享库的内存映射区域,然后就是用户可以使用的 stack(栈区),再往上就是内核内存位置。每一个进程都是这样的地址空间。

然后就是 execve 系统调用,他会在磁盘中读取 a.out 文件,拿出里面的代码节、数据节等,映射到进程地址空间。

然后操作系统内核中的加载器识别出 a.out 是一个动态链接文件,做出必要的内存映射,从 ld-linux-x86-64.so 的代码开始执行,把动态链接库(例如 printf)映射到进程的地址空间中,然后跳转到 a.out 的 _start 执行(在 objdump 命令的结果中可以看到更多细节),初始化 C 语言运行环境,最终开始执行 main 中的机器代码。