c/c++编译过程

C/C++ 是高级编译型语言,执行的时候需要将容易阅读的源码编译成机器可以识别的机器指令,交由 CPU 执行。这篇文章我们来研究一下编译是怎样一个过程。

绝大多数编译器并不是一个单一的庞大程序,它通常由六七个稍小的程序组成,这些程序由一个叫做『编译器驱动器』(compiler driver) 的控制程序来调度。整个编译过程大致可以分为四个主要阶段:

  • 预处理
  • 编译
  • 汇编
  • 链接

我们以一个简单的 c 程序来举例,c 代码如下

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

#define add(a, b) (a + b)

// here is comment

#ifdef DEBUG
void myfunc() {
printf("DEBUG defined\n");
}
#else
#error DEBUG not defined!
#endif

int main(void) {
int a = 5, b = 4;
printf("addition is: %d\n", add(a, b));
return 0;
}

在 centos 环境利用 gcc 进行编译,添加选项 -save-temps 我们可以获取编译过程每个阶段产生的临时文件

1
$ gcc -Wall -save-temps -DDEBUG filename.c -o filename

可以看到分别生成了以下文件

image-20180821150535977

其中

filename.c 源码文件

filename.i 预编译后文件

filename.s 汇编文件

filename.o 编译目标文件

filename 链接后的可执行文件

下面我们分别看看每个阶段具体在进行哪些操作。

预处理

预处理阶段,编译器驱动调动预处理器处理 预处理指令 ,所谓预处理指令是指以 # 开头的指令,主要有注释、宏、头文件包含、条件编译等,因此这个阶段包括以下几个操作

  • 去除注释
  • 展开宏定义
  • 展开头文件包含
  • 条件编译#ifdef#ifndef 处理
  • error 抛出错误信息

我们打开预处理后的 filename.i 文件可以看到:注释被去除了,参数宏 add(a, b) 也被 (a + b) 替换,包含的头文件 <stdio.h> 中的内容被拷贝到源文件 filename.c 中;此外编译时候有没有指定 -DDEBUG 得到的结果 (是否有myfunc) 也不一样。

其他的预处理指令还包括 pragma、 line 等,这里就不详细说明了

编译

编译阶段,主要将扩展的源码文件如filename.i 编译成汇编文件filename.s。其中又分为三个主要步骤:

  • Lexical Analysis / tokenization 词法分析。

    主要就是将输入的源码文件从左到右逐个字符(character)的分析,划分成不同的 token:symbols, numbers, identifiers, strings, operators 等。下面看个例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    int main() {
    int a;
    int b;
    a = b = 4;
    return a - b;
    }

    //经过词法分析,会产生以下 token
    Scanner production:
    [Keyword(Int), Id("main"), Symbol(LParen), Symbol(RParen), Symbol(LBrace), Keyword(Int), Id("a"), Symbol(Semicolon), Keyword(Int), Id("b"), Symbol(Semicolon), Id("a"), Operator(Assignment), Id("b"),
    Operator(Assignment), Integer(4), Symbol(Semicolon), Keyword(Return), Id("a"), Operator(Minus), Id("b"), Symbol(Semicolon), Symbol(RBrace)]
  • Parsing 语法解析。

    该过程是将上面词法分析产生的 tokens 进行匹配,看是否符合特定的模式:如函数调用、变量赋值、数学运算、表达式求值等等。最终,语法分析会产生一种称为 AST (abstract syntax tree) 的语法树结构。

    image-20181217144627779

    上面就是 12 + 3 对应的AST结构

    image-20181216215749680

    上面是 C 代码 if(net>0.0)total+=net*(1.0+tax/100.0); 经过词法分析和语法分析生成的AST 结构

  • Code generator 代码生成。

    代码生成的主要任务,就是将上述的中间结果即 AST 树结构转换成一行行线性的机器指令。包括

    • 选用什么样的指令。通过对 AST 树执行后续遍历 (postorder traversal) 依次选用指令。例如,对于 AST W := ADD(X,MUL(Y,Z)) ,根是 ADD ,左右节点分别是 XMUL(Y,Z),指令会是 t1 := Xt2 := MUL(Y,Z) ,最后是 ADD W, t1, t2

    • 安排指令顺序。由于CPU流水线机制,可能会有乱序执行以提高代码执行效率。

    • 变量的寄存器分配

    • 调试代码生成

    此外它还会对生成的指令进行一定的优化。

很多情况下,代码生成的机器指令为汇编代码,包含了很多汇编指令。

下面是 filename.c 编译生成的汇编指令代码 filename.s

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
  1     .file   "filename.c"
2 .section .rodata
3 .LC0:
4 .string "DEBUG defined"
5 .text
6 .globl myfunc
7 .type myfunc, @function
8 myfunc:
9 .LFB0:
10 .cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $.LC0, %edi
call puts
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size myfunc, .-myfunc
.section .rodata
.LC1:
.string "addition is: %d\n"
.text
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $5, -8(%rbp)
movl $4, -4(%rbp)
movl -4(%rbp), %eax
movl -8(%rbp), %edx
addl %eax, %edx
movl $.LC1, %eax
movl %edx, %esi
movq %rax, %rdi
movl $0, %eax
call printf
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (GNU) 4.4.7 20120313 (Red Hat 4.4.7-17)"
.section .note.GNU-stack,"",@progbits

编译过程还可以被分为 前端、后端,前端包括前面提到的词法分析、语法分析,生成 AST 结构;后端指代码生成,输入是 AST,输出机器指令代码或者汇编代码。其实,前后端中间还有个中端,做些优化的工作。

前后端的工作是独立的,互相不依赖。对于不同的语言,编译器可能使用不同类型的前端来生成 AST,而后端其输入只要求是AST,因此可以复用。举例来说,对于知名编译器 GCC (the GNU Compiler Collection),其不同的前端可以编译 C,C++,Objective-C,Fortran,Ada,Go,D 成 AST,而通过统一的后端将 AST 转换成汇编代码。

编译的整个过程用一张图来表示就是

image-20181217143846903

汇编(Assembly)

该过程通过调度汇编器(as)来完成,将汇编指令文件filename.s 翻译成与处理器结构有关的机器指令文件filename.o,这是一个二进制文件,不能直接查看,用 file 命令查看可知

1
2
$ file filename.o 
filename.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

这一个64位 ELF 文件,可以使用 objdump -x 命令来查看所有文件头,包含符号表和重定位信息等

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
43
44
45
46
47
48
49
50
51
$ objdump -x filename.o

filename.o: file format elf64-x86-64
filename.o
architecture: i386:x86-64, flags 0x00000011:
HAS_RELOC, HAS_SYMS
start address 0x0000000000000000

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000049 0000000000000000 0000000000000000 00000040 2**2
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 0000008c 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000000 0000000000000000 0000000000000000 0000008c 2**2
ALLOC
3 .rodata 0000001f 0000000000000000 0000000000000000 0000008c 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002e 0000000000000000 0000000000000000 000000ab 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000d9 2**0
CONTENTS, READONLY
6 .eh_frame 00000058 0000000000000000 0000000000000000 000000e0 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 filename.c
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 l d .data 0000000000000000 .data
0000000000000000 l d .bss 0000000000000000 .bss
0000000000000000 l d .rodata 0000000000000000 .rodata
0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
0000000000000000 l d .eh_frame 0000000000000000 .eh_frame
0000000000000000 l d .comment 0000000000000000 .comment
0000000000000000 g F .text 0000000000000010 myfunc
0000000000000000 *UND* 0000000000000000 puts
0000000000000010 g F .text 0000000000000039 main
0000000000000000 *UND* 0000000000000000 printf


RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000005 R_X86_64_32 .rodata
000000000000000a R_X86_64_PC32 puts-0x0000000000000004
000000000000002f R_X86_64_32 .rodata+0x000000000000000e
000000000000003e R_X86_64_PC32 printf-0x0000000000000004


RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
0000000000000040 R_X86_64_PC32 .text+0x0000000000000010

上面符号表里的 text 表示在代码段找到了定义,而有 UND 标识的 puts函数和printf函数因为属于库函数,尚未链接进来,因此属于未定义。

上面的filename.o 文件虽然是二进制指令文件,但是还不能被执行。最终的可执行文件还要经过下面的链接过程。

链接

该阶段由编译器驱动程序驱动链接器 linker 来完成。linker 只和平台有关,是不同操作系统下的工具程序。对于windows平台,linker.exe 是链接器 (集成在Visual Studio中),它将多个.obj文件和库文件一起链接成 .exe可执行文件或库文件;对于 linux 和 mac 平台,ld (也是GNU出品) 是链接器,它将多个 .o 文件和库文件一起链接成二进制可执行文件或库文件。

链接器的工作主要包含:地址和空间分配(Address and Storage Allocation),符号决议(Symbol Resolution),重定位(Relocation)等。

image-20181217141632692

链接主要分为动态连接和静态链接。对于静态链接,linker 会将静态库的代码直接加到可执行文件中,因此文件大小比较大。而动态链接则是指链接阶段仅仅只加入一些描述信息,而程序执行时再从系统中把相应动态库加载到内存中去。

GCC 默认使用动态链接方式链接库文件。对比目标文件filename.o 和我们链接完成的可执行文件 filename大小可知,text 部分增加的大小为必要的描述信息

1
2
3
4
5
6
$ size filename.o
text data bss dec hex filename
192 0 0 192 c0 filename.o
$ size filename
text data bss dec hex filename
1319 500 16 1835 72b filename

再看file命令的输出

1
2
$ file filename
filename: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.18, not stripped

可见与上面的filename.o不同的是该文件已经链接(GCC 默认动态连接 Linux 的 c 运行时库 libc.so.6,即 glibc )

此外,利用 ldd 命令可以看到可执行程序依赖的动态链接库

1
2
3
4
5
6
7
8
9
10
11
$ ldd -v filename
linux-vdso.so.1 => (0x00007fff36f24000)
libc.so.6 => /lib64/libc.so.6 (0x00007f8f61001000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8f613a0000)

Version information:
./filename:
libc.so.6 (GLIBC_2.2.5) => /lib64/libc.so.6
/lib64/libc.so.6:
ld-linux-x86-64.so.2 (GLIBC_PRIVATE) => /lib64/ld-linux-x86-64.so.2
ld-linux-x86-64.so.2 (GLIBC_2.3) => /lib64/ld-linux-x86-64.so.2

总结

用一个图来表示整个编译的过程如下

image-20180821172053123

参考资料

<C专家编程>

Compiling a C program:- Behind the Scenes

Understanding Compilers — For Humans (Version 2)

C程序编译过程浅析