一个C / C++程序到底是如何从磁盘上的源码文件变为可执行文件的?一个可执行文件又是如何变为进程运行在操作系统中的?
简单的事物背后往往蕴含着非常复杂的机制。本文就来讲讲这些屠龙之技。
一、GCC干了什么
#include <stdio.h>
int main() {
printf("Hello World\n");
return 0;
}
在 Linux 下,使用 GCC 来编译这个 Hello World 程序,只需要执行:
$ gcc hello.c -o hello.out
$ ./hello.out
Hello World
这个过程中,gcc 到底扮演了什么角色?
预处理
执行
$ gcc -E hello.c -o hello.i
或者
$ cpp hello.c > hello.i
即可得到预编译后的 .i 文件。预编译阶段,预编译器主要任务是:
- 展开所有宏定义
- 处理所有条件预编译指令(#if等)
- 处理
#include
预编译指令,将被包含的文件插入到该指令位置 - 删除所有的注释
- 添加行号和文件名标识,用于编译器产生错误时有行号信息等
编译
编译过程就是讲预处理后的文件进行一系列处理后,产生相应的汇编代码文件。执行
$ gcc -S hello.i -o hello.s
汇编
汇编器是将汇编代码转变为机器可以执行的指令,得到目标文件(Object File),执行
$ gcc -c hello.s -o hello.o
或
$ as hello.s -o hello.o
$ file hello.o
hello.o: ELF 64-bit LSB relocatable,
AMD x86-64, version 1 (SYSV), not stripped
链接
汇编器输出的是一个目标文件而不是可执行文件。需要通过链接器对目标文件进行处理才能得到可执行文件,如果直接执行:
$ ld hello.o
得到如下结果:
ld: warning: cannot find entry symbol _start; defaulting to 00000000004000e8
hello.o(.text+0xf): In function `main':
: undefined reference to `printf'
报错提示我们,程序中找不到 printf
这个符号的定义。实际上,我们知道,printf 函数是定义在其他文件中,我们回过头看 hello.s的一个片段:
.LC0:
.string "Hello World\n"
.text
...
.LCFI1:
movl $.LC0, %edi
movl $0, %eax
call printf
movl $0, %eax
leave
ret
瞧,printf 的地址没有被确定!这就是链接器的任务。链接器在链接的时候,会根据目标文件中所引用的符号,去相应的模块里查找对应的地址,然后将所有对这个符号的指令重新修正,这就是静态链接的过程和作用。
$ file hello.out
hello.out: ELF 64-bit LSB executable, AMD x86-64, version 1 (SYSV),
for GNU/Linux 2.4.0, dynamically linked (uses shared libs), not stripped
现在回过头来回答标题的问题,gcc干了什么呢?
实际上,gcc这个命令只是预处理器(cpp)、编译器(cc1)、汇编器(as)、链接器(ld)这些后台程序的包装,gcc会根据不同的参数去调用相应的子程序,最终完成从程序源码到可执行文件这个过程。
二、目标文件里是什么
目标文件就是源代码编译后但未进行链接的那些中间文件(Linux下的 .o 文件),它与可执行文件的内容和结构非常相似。在 Linux 下,我们可以将它们统称为 ELF(Executable Linkable Format)文件。
ELF文件类型 | 实例 |
---|---|
可重定位文件 | Linux 下的 .o 文件 |
可执行文件 | a.out |
共享目标文件 | Linux 下的 .so 文件 |
核心转储文件 | Linux下的 core dump |
本节以 SimpleSection.c 作为分析对象。
int printf(const char *format, ...);
int global_init_var = 84;
int global_uninit_var;
void func1(int i) {
printf("%d\n", i);
}
int main() {
static int static_var = 85;
static int static_var_2;
int a = 1;
int b;
func1(static_var + static_var_2 + a + b);
return a;
}
ELF 文件结构
ELF Header(文件头) |
---|
.text |
.data |
.bss |
…other sections |
Section header table(段表) |
Symbol Table(符号表) |
String Tables(字符串表) |
ELF文件最开始的是ELF文件头,接着是ELF文件中的各个段,然后是描述各个段信息的段表,之后就是ELF中辅助的结构(字符串表、符号表)。
ELF 文件头
使用 readelf -h SimpleSection.o 得到ELF文件头内容如下:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 ELF 魔数
Class: ELF64 文件机器字节长度
Data: 2's complement, little endian 数据存储方式
Version: 1 (current) 版本
OS/ABI: UNIX - System V 运行平台
ABI Version: 0 ABI 版本
Type: REL (Relocatable file) ELF 重定位类型
Machine: Advanced Micro Devices X86-64 硬件平台
Version: 0x1 硬件平台版本
Entry point address: 0x0 入口地址
Start of program headers: 0 (bytes into file) 程序头入口
Start of section headers: 384 (bytes into file) 段表的位置
Flags: 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: 10
段表
使用 readelf -S SimpleSection.o 命令,可以得到段表如下:
Section Headers:
[Nr] Name Type Address Offset Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040 000000000000004c 0000000000000000 AX 0 0 4
[ 2] .rela.text RELA 0000000000000000 000006a8 0000000000000078 0000000000000018 11 1 8
[ 3] .data PROGBITS 0000000000000000 0000008c 0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 00000094 0000000000000004 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 00000094 0000000000000004 0000000000000000 A 0 0 1
[ 6] .eh_frame PROGBITS 0000000000000000 00000098 0000000000000058 0000000000000000 A 0 0 8
[ 7] .rela.eh_frame RELA 0000000000000000 00000720 0000000000000030 0000000000000018 11 6 8
[ 8] .note.GNU-stack PROGBITS 0000000000000000 000000f0 0000000000000000 0000000000000000 0 0 1
[ 9] .comment PROGBITS 0000000000000000 000000f0 000000000000002d 0000000000000000 0 0 1
[10] .shstrtab STRTAB 0000000000000000 0000011d 0000000000000061 0000000000000000 0 0 1
[11] .symtab SYMTAB 0000000000000000 000004c0 0000000000000180 0000000000000018 12 11 8
[12] .strtab STRTAB 0000000000000000 00000640 0000000000000061 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
由段表就可以得出SimpleSection.o所有段的位置和长度了:
重定位表
从段表中可以看到,有一个”.rel.text“的段,TYPE 为 RELA,也就是说它是一个重定位表。重定位表会记录重定位的信息。
每个需要重定位的代码段或数据段都有一个重定位表。
字符串表
字符串表的段名一般为 ”.strtab“ 或 ”.shstrtab”。这两个字符串表分别为 字符串表 和 段表字符串表。
字符串表用于保存普通的字符串,如符号的名字。段表字符串表用来保存段表中用到的字符串,最常见的就是段名。
符号表
ELF 文件中的符号表是文件中的一个段,段名为 “.symtab”。 readelf -s SimpleSection.o 可以查看符号表信息。
Symbol table '.symtab' contains 16 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS SimpleSection.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 static_var.0
7: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 static_var_2.1
8: 0000000000000000 0 SECTION LOCAL DEFAULT 6
9: 0000000000000000 0 SECTION LOCAL DEFAULT 8
10: 0000000000000000 0 SECTION LOCAL DEFAULT 9
11: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_init_var
12: 0000000000000000 31 FUNC GLOBAL DEFAULT 1 func1
13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
14: 000000000000001f 45 FUNC GLOBAL DEFAULT 1 main
15: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM global_uninit_var
其他段
使用 objdump -h SimpleSection.o
可以查看到 ELF文件的各个段。
SimpleSection.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000004c 0000000000000000 0000000000000000 00000040 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 0000008c 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 0000000000000000 0000000000000000 00000094 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 00000094 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .eh_frame 00000058 0000000000000000 0000000000000000 00000098 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000f0 2**0
CONTENTS, READONLY
6 .comment 0000002d 0000000000000000 0000000000000000 000000f0 2**0
CONTENTS, READONLY
- .text :代码段,包含了 func1()和main()的指令。
- .data:数据段,保存已经初始化了的全局静态变量和局部静态变量。
- .rodata:只读数据段,保存只读数据,一般是程序里面的只读变量和字符串常量。
- .bss:存放未初始化的全局变量和局部静态变量。该段在ELF文件中并没有实际分配空间,只是为这些变量预留位置。
三、静态链接
本节以下面两个源代码 “a.c” 和 “b.c” 展开分析。
// a.c
extern int shared;
int main() {
int a = 100;
swap(&a, &shared);
}
// b.c
int shared = 1;
void swap(int *a, int *b) {
*a ^= *b ^= *a ^= *b;
}
空间与地址分配
这里的空间和地址特指虚拟地址空间的分配。
下面分别查看 a.o / b.o / ab 各个段的属性:
// objdump -h a.o
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000024 0000000000000000 0000000000000000 00000040 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000064 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000064 2**2
ALLOC
3 .eh_frame 00000038 0000000000000000 0000000000000000 00000068 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000a0 2**0
CONTENTS, READONLY
5 .comment 0000002d 0000000000000000 0000000000000000 000000a0 2**0
CONTENTS, READONLY
// objdump -h b.o
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000003e 0000000000000000 0000000000000000 00000040 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 0000000000000000 0000000000000000 00000080 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000084 2**2
ALLOC
3 .eh_frame 00000038 0000000000000000 0000000000000000 00000088 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000c0 2**0
CONTENTS, READONLY
5 .comment 0000002d 0000000000000000 0000000000000000 000000c0 2**0
CONTENTS, READONLY
// objdump -h ab
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000062 00000000004000e8 00000000004000e8 000000e8 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .eh_frame 00000058 0000000000400150 0000000000400150 00000150 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .data 00000004 00000000005001a8 00000000005001a8 000001a8 2**2
CONTENTS, ALLOC, LOAD, DATA
3 .bss 00000004 00000000005001ac 00000000005001ac 000001ac 2**2
ALLOC
4 .comment 0000005a 0000000000000000 0000000000000000 000001ac 2**0
CONTENTS, READONLY
其中,VMA指的是虚拟地址,LMA指的是加载地址,这两个一般是一样的。
可以看出,在链接之前,目标文件没有分配虚拟地址,链接之后,可执行文件有了虚拟地址。而 .text 段分配的起始地址为 0x4000e8,在Linux下,ELF可执行文件有一个默认起始地址,在我的电脑里是 ox400000。
再将相似段合并并分配虚拟地址后,各个符号的地址也能得到确定,只需要将段的虚拟地址加上符号在段的偏移即可。
重定位
在完成了空间地址分配后,链接器的任务就是对符号进行解析以及重定位。
使用 objdump -d 来查看 “a.o” 和 “ab” 的反汇编结果:
// a.o
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: c7 45 fc 64 00 00 00 movl $0x64,0xfffffffffffffffc(%rbp)
f: 48 8d 7d fc lea 0xfffffffffffffffc(%rbp),%rdi
13: be 00 00 00 00 mov $0x0,%esi
18: b8 00 00 00 00 mov $0x0,%eax
1d: e8 00 00 00 00 callq 22 <main+0x22>
22: c9 leaveq
23: c3 retq
// ab
Disassembly of section .text:
00000000004000e8 <main>:
4000e8: 55 push %rbp
4000e9: 48 89 e5 mov %rsp,%rbp
4000ec: 48 83 ec 10 sub $0x10,%rsp
4000f0: c7 45 fc 64 00 00 00 movl $0x64,0xfffffffffffffffc(%rbp)
4000f7: 48 8d 7d fc lea 0xfffffffffffffffc(%rbp),%rdi
4000fb: be a8 01 50 00 mov $0x5001a8,%esi
400100: b8 00 00 00 00 mov $0x0,%eax
400105: e8 02 00 00 00 callq 40010c <swap>
40010a: c9 leaveq
40010b: c3 retq
000000000040010c <swap>:
40010c: 55 push %rbp
40010d: 48 89 e5 mov %rsp,%rbp
可以看到,在 a.o 中,左边的还表示的是偏移量,到了ab,就是具体的虚拟地址了。
其中,a.o 文件中,“shared”和“swap”是定义在ab当中,我们可以看到对应于 “shared” 在 a.o 和 ab 之间的改变:
// shared - a.o
13: be 00 00 00 00 mov $0x0,%esi
// shared - ab
4000fb: be a8 01 50 00 mov $0x5001a8,%esi
“swap”的改变如下所示:
// swap - a.o
1d: e8 00 00 00 00 callq 22 <main+0x22>
// swap - ab
400105: e8 02 00 00 00 callq 40010c <swap>
那么,链接器是如何知道哪些指令是需要调整的呢?这就靠之前提到的重定位表了。
使用 objdump -r a.o
查看 a.o 的重定位表:
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000014 R_X86_64_32 shared
000000000000001e R_X86_64_PC32 swap+0xfffffffffffffffc
四、装载
页表和缺页异常大家都懂,但是在操作系统课程中最困扰的一点在于虚拟地址空间在操作系统层面上到底是什么?可执行文件是如何映射的?
操作系统角度
对于操作系统而言,装载可执行文件只需要三个步骤:
- 创建一个独立的虚拟地址空间:虚拟空间的核心在于将虚拟空间的各个页映射到物理内存空间,所有这一步,操作系统只需要创建这种映射所需要的数据结构,也就是页目录(页目录,页表这里就不展开说了)。
- 读取可执行文件头,建立虚拟空间与可执行文件的映射关系:虚拟存储是以页为单位,以页的大小为对齐大小。这种映射关系也是操作系统的一个数据结构。操作系统把虚拟空间中的一个段称为 VMA。操作系统创建进程后,在进行的数据结构中设置好各个段的VMA。
- 将CPU指令寄存器设置为可执行文件入口地址。
虚拟内存空间分布
我们先查看 “ab” 这个可执行文件的段表:
Section Headers:
[Nr] Name Type Address Offset Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 00000000004000e8 000000e8 0000000000000062 0000000000000000 AX 0 0 4
[ 2] .eh_frame PROGBITS 0000000000400150 00000150 0000000000000058 0000000000000000 A 0 0 8
[ 3] .data PROGBITS 00000000005001a8 000001a8 0000000000000004 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 00000000005001ac 000001ac 0000000000000004 0000000000000000 WA 0 0 4
[ 5] .comment PROGBITS 0000000000000000 000001ac 000000000000005a 0000000000000000 0 0 1
[ 6] .shstrtab STRTAB 0000000000000000 00000206 000000000000003f 0000000000000000 0 0 1
[ 7] .symtab SYMTAB 0000000000000000 00000488 0000000000000198 0000000000000018 8 11 8
[ 8] .strtab STRTAB 0000000000000000 00000620 0000000000000032 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
可以看到,ab有8个段,如果给每个段按页作为单位进行分配,那么会很大程序的浪费内存空间。
实际上,对于操作系统而言,操作系统不关系段的实际内容,只关心段的权限(可读、可写、可执行)。所有,ELF实际上是通过“程序头(Program Header)”来定义“Segment”,通过将权限相同的段合并为一起,作为一个“Segment”,进行装载。
使用 readelf -l ab
来查看程序头:
Entry point 0x4000e8
There are 3 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x00000000000001a8 0x00000000000001a8 R E 100000
LOAD 0x00000000000001a8 0x00000000005001a8 0x00000000005001a8
0x0000000000000004 0x0000000000000008 RW 100000
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 8
Section to Segment mapping:
Segment Sections...
00 .text .eh_frame
01 .data .bss
02
其中,只有 LOAD 类型的 Segment 需要映射。
实际上,进程地址空间里的堆和栈也是以VMA的形式存在的。
// cat /proc/41731/maps
00400000-0046f000 r-xp 00000000 fd:10 1099862 /home/jinke02/repos/gcc_learn/SectionMapping.elf
0056e000-00570000 rw-p 0006e000 fd:10 1099862 /home/jinke02/repos/gcc_learn/SectionMapping.elf
00570000-00593000 rw-p 00000000 00:00 0 [heap]
7fff52c1b000-7fff52c30000 rw-p 00000000 00:00 0 [stack]
7fff52c9d000-7fff52c9e000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]