在Linux下,我们在使用GCC来编译一个程序时,我们只需要使用最简单的命令
$gcc hello.c
$./a.out
事实上,这个过程包含了4个步骤,即预处理(Prepressing),编译(Compilation),汇编(Assembly)和链接(LinKing)
预编译
首先,源文件hello.c和相关的头文件会被预编译期cpp预编译成为一个.i文件;当然对于C++来说,源文件可能是cpp或者cxx格式的,预编译后的文件扩展名为.ii文件。
在GCC中我们可以使用如下的命令完成预编译:
$gcc -E hello.c -o hello.i
预编译的处理规则:
将所有的 #define 删除掉,并且展开所有的宏定义
处理所有的条件预编译指令,比如 #if #ifdef #elif #else #endif
处理 #include 预编译命令,将被包含的文件插入到该预编译指令的位置,该过程是递归进行的
删除所有的注释
添加行号和文件名标识,便于编译器产生调试用的行号信息以及用于编译时产生编译错误或者告警时能够显示行号
保留所有的#pragma 编译器指令,因为编译器需要使用他们。
经过预编译后的.i文件不包含任何宏定义,并且包含的文件也已经被插入到.i文件中。
# 1 "hello.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "hello.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 27 "/usr/include/stdio.h" 3 4
# 1 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 1 3 4
# 33 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 3 4
# 1 "/usr/include/features.h" 1 3 4
...
extern int __overflow (FILE *, int);
# 873 "/usr/include/stdio.h" 3 4
# 2 "hello.c" 2
# 3 "hello.c"
int main()
{
printf("Hello World!");
return 0;
}
可以看到,这里<stdio.h>头文件被展开产生了很多信息。
编译
编译过程就是将预处理完的文件进行一系列的词法分析,语法分析,语义分析以及优化后生成相应的汇编文件,是程序构建中一个比较核心的部分。
上面说的过程采用的是如下命令:
$gcc -S hello.i -o hello.s
新版本的GCC把预编译和编译两个步骤合成了一个步骤,使用了一个叫cc1的程序来完成这个步骤,它的位置在 /usr/lib/gcc/i486-linux-gnu/4.1/里面
归根到底gcc命令只是这些后台程序的包装,他会根据不同的参数要求去调用预编译编译程序 cc1,汇编器 as,以及链接器 ld 我们通过命令将预处理文件转换成了汇编文件:
.file "hello.c"
.text
.section .rodata
.LC0:
.string "Hello World!"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 9.3.0-10ubuntu2) 9.3.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
这其中 LC0 指示了程序的全局符号表的位置,用于指定程序中全局变量和函数的地址;LFB0 用于指定程序局部符号表的位置,用于指定局部变量和函数的地址。LFE0通常被用作函数内的分支目标,也就代表着程序的出口点,当代码执行到这里就表示控制权将返回函数被调用的位置。这种标签的使用可以提到代码编译的准确性和效率。
汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编过程只需要根据机器指令对照表一一翻译过来即可,以上的汇编过程我们可以使用汇编器 as 来完成:
$ as hello.s -o hello.o
或者
$ gcc -c hello.s -o hello.o
我们使用objdump查看目标文件内部的结构,参数 -h 是将目标文件的各个段的基本信息都打印出来
hello.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000020 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000060 2**0
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 00000060 2**0
ALLOC
3 .rodata 0000000d 0000000000000000 0000000000000000 00000060 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 00000025 0000000000000000 0000000000000000 0000006d 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 00000092 2**0
CONTENTS, READONLY
6 .note.gnu.property 00000020 0000000000000000 0000000000000000 00000098 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
7 .eh_frame 00000038 0000000000000000 0000000000000000 000000b8 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
可以看见除了最基本的代码段,数据段和BSS段以外,还有只读数据段(.rodata),注释信息段(.comment)和堆栈提示段(.note.GNU-stack)
链接
链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确的衔接。链接的过程主要包括地址和空间分配,符号决议和重定位。
最基本的静态链接过程就是将目标文件和库一起链接成可执行文件,最常见的库就是运行时库,它是支持程序运行的基本函数的集合。
ELF文件头
我们可以使用readelf指令查看文件头的信息:
$ readelf -h hello.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 792 (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: 14
Section header string table index: 13
elf文件头结构及相关常数被定义在"/usr/include/elf.h",这个文件头中定义了ELF魔数,文件机器字节长度,数据存储方式,版本,运行平台,硬件平台,硬件平台版本等等一系列信息。
段表
段表表示了各个段的基本属性结构。例如段名,段长度,在文件中的偏移,读写权限等。编译器,连接器和装载器都是依据段表来定位和访问各个段的属性的,段表在ELF文件中的位置由ELF文件头中的 e_shoff 来决定,也就是 “Start of section headers” 来决定的,也就是是上面表中的 792
我们同样使用readelf -S来查看elf文件的段表信息:
$ readelf -S hello.o
There are 14 section headers, starting at offset 0x318:
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
0000000000000020 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000258
0000000000000030 0000000000000018 I 11 1 8
[ 3] .data PROGBITS 0000000000000000 00000060
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 00000060
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .rodata PROGBITS 0000000000000000 00000060
000000000000000d 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 0000006d
0000000000000025 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 00000092
0000000000000000 0000000000000000 0 0 1
[ 8] .note.gnu.propert NOTE 0000000000000000 00000098
0000000000000020 0000000000000000 A 0 0 8
[ 9] .eh_frame PROGBITS 0000000000000000 000000b8
0000000000000038 0000000000000000 A 0 0 8
[10] .rela.eh_frame RELA 0000000000000000 00000288
0000000000000018 0000000000000018 I 11 9 8
[11] .symtab SYMTAB 0000000000000000 000000f0
0000000000000138 0000000000000018 12 10 8
[12] .strtab STRTAB 0000000000000000 00000228
000000000000002b 0000000000000000 0 0 1
[13] .shstrtab STRTAB 0000000000000000 000002a0
0000000000000074 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
其中第【2】个段 .rela.text 这个段被称为重定位表,他存在的意义是程序在链接的时候,由于内存地址和物理地址不同,需要将程序中的地址进行转换,以便于程序可以正常的在内存中运行。重定位表就是用来记录这些转换关系的。
其中第【11】段的"symtab"也就是我们常说的符号表,那么符号表的结构又是怎么样的呢?
我们可以使用指令 readelf -s hello.o来查看符号表
Symbol table '.symtab' contains 13 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS hello.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: 0000000000000000 0 SECTION LOCAL DEFAULT 7
7: 0000000000000000 0 SECTION LOCAL DEFAULT 8
8: 0000000000000000 0 SECTION LOCAL DEFAULT 9
9: 0000000000000000 0 SECTION LOCAL DEFAULT 6
10: 0000000000000000 32 FUNC GLOBAL DEFAULT 1 main
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
它是一个至关重要的部分,他主要管理和存储程序中的符号信息,这些符号通常包括变量名,函数名等,可以被视为地址的引用,例如函数和变量的地址;他们在编译和链接的过程中,进行定位或者重定位。符号表可以视为一个数组,数组中的每一个元素都是一个结构体。此外,符号表还能提供局部变量和全局变量以及原代码行号等信息。
日常开发过程中我们也可以通过符号表信息来判断某些功能或者接口有没有编译进版本当中。
其中第【12】和第【13】段被称为字符串表和段表字符串表。字符串表主要存储程序中若干个以 ‘\0’结尾的字符串,这些字符串通常包含符号的名字或者节的名字,通过将所有的字符串集中放到一个表中,ELF文件实现了对文件的加载,链接和调试等功能。