C++编译流程
编译器其实就是一个翻译器,把我们的文件内容翻译成机器能够看懂的指令,但如何合理翻译是核心。
C语言编译
需要经过以下几步:
- 词法分析:扫描代码,确定单词类型,比如是变量还是函数,是标识符还是运算符等等,这样操作后每个token都有自己的性质
- 语法分析:根据语法规则构建分析树
- 语义分析:主要是检查代码里是否有语义错误,比如函数返回值是否一致/是否访问超出作用域的变量/变量函数是否已经声明等
- 生成中间代码
- 汇编器生成目标机器语言
C++编译流程
预处理
将一些预处理指令的东西进行处理,比如#include、#pragma #define
对于include而言,预处理器会直接打开这个文件,然后将其copy进我们的cpp文件里。 (宏是直接进行字符串替换的)
此时生成的文件是.i文件
编译
通过编译器和汇编器将.cpp文件编译成.o文件,这里的.o其实就是一些01码,为了方便理解,可以先让编译器输出汇编指令(其实本质都是差不多的,做过CPU的应该知道汇编转01码怎么做)
换句话说,如果代码没有语法错误,编译是不应该报错的,比如缺少main函数入口等
// MyClass.cpp
class MyClass {
public:
int value;
void setValue(int v) {
value = v;
helper(); // 调用自己的成员函数
}
void helper() {
// 空函数,只为了演示调用过程
}
};
实际编译后.o:
[ MyClass.o ] ←←←←← 编译器输出
┌──────────────────────────────┐
│ .text 段 │←─ 代码段(真正的机器码存放区)
│ │
│ 0x0000: MyClass::helper() │←─ helper 的汇编已生成在此位置
│ push rbp │
│ mov rsp, rbp │
│ ... │
│ ret │
│ │
│ 0x0040: MyClass::setValue() │←─ setValue 的汇编在这里
│ mov [this], v │
│ call helper() │←─ 这条 call 指令地址还没填
└──────────────────────────────┘
┌──────────────────────────────┐
│ 符号表(.symtab) │←─ 存储函数名、地址、段类型等元信息
│ │
│ _ZN7MyClass6helperEv → 0x0000 ← helper() 的地址
│ _ZN7MyClass8setValueEi → 0x0040 ← setValue() 的地址
└──────────────────────────────┘
附上如何只编译文件+看汇编结果
g++ -c -O0 -fno-inline -o main.o main.cpp
objdump -d main.o
需要注意的是,每个.o文件独立,函数留的地址也是相对地址,链接器还需要解决相对地址转绝对地址的问题。
链接
那么问题来了,如果我只有一个cpp文件,那么到编译这一步就可以了。但是在大型工程文件里,我们往往是有多个模块的,多个模块之间彼此还有调用关系,如何能生成正确的指令呢?其实就是依赖链接器,将多个cpp的.o文件给链接在一起。
多个模块之间的调用关系有:
- 你去访问别人的变量
- 调用别人的成员函数:在汇编指令中这一句话是call xxx,这个xxx就是函数签名
如果你在一个文件里声明并且定义了函数func,在另一个文件里也定义了func,对于include了这两个文件的文件来说,链接器不知道要链接具体哪一个,因此会报错称重复定义xxx。
举个具体的例子
MyClass.h代码:
class MyClass {
public:
int value;
void setValue(int v);
void helper();
};
MyClass.cpp代码:
#include "MyClass.h"
void MyClass::setValue(int v) {
value = v;
helper();
}
void MyClass::helper() { }
main的cpp代码:
#include "MyClass.h"
int main() {
MyClass obj;
obj.setValue(42);
return 0;
}
main的汇编:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 30 sub $0x30,%rsp
8: e8 00 00 00 00 callq d <main+0xd>
d: 48 8d 45 fc lea -0x4(%rbp),%rax
11: ba 2a 00 00 00 mov $0x2a,%edx
16: 48 89 c1 mov %rax,%rcx
19: e8 00 00 00 00 callq 1e <main+0x1e>
1e: b8 00 00 00 00 mov $0x0,%eax
23: 48 83 c4 30 add $0x30,%rsp
27: 5d pop %rbp
28: c3 retq
0x08 | 构造初始化 |
---|---|
0x16 | 设置this指针 |
0x19 | 调用setValue |
编译器在生成这段机器码的时候,先写了一条机器指令,但是不知道setValue的地址,因此在e8后面跟的都是00000000。同时在.o的重定位表里添加记录:
偏移地址:0x19
类型:CALL
目标符号:_ZN7MyClass8setValueEi
myclass.o的汇编
0000000000000000 <_ZN7MyClass8setValueEi>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 20 sub $0x20,%rsp
8: 48 89 4d 10 mov %rcx,0x10(%rbp)
c: 89 55 18 mov %edx,0x18(%rbp)
f: 48 8b 45 10 mov 0x10(%rbp),%rax
13: 8b 55 18 mov 0x18(%rbp),%edx
16: 89 10 mov %edx,(%rax)
18: 48 8b 4d 10 mov 0x10(%rbp),%rcx
1c: e8 07 00 00 00 callq 28 <_ZN7MyClass6helperEv>
21: 90 nop
22: 48 83 c4 20 add $0x20,%rsp
26: 5d pop %rbp
27: c3 retq
0000000000000028 <_ZN7MyClass6helperEv>:
28: 55 push %rbp
29: 48 89 e5 mov %rsp,%rbp
2c: 48 89 4d 10 mov %rcx,0x10(%rbp)
30: 90 nop
31: 5d pop %rbp
32: c3 retq
33: 90 nop
34: 90 nop
35: 90 nop
36: 90 nop
37: 90 nop
38: 90 nop
39: 90 nop
3a: 90 nop
3b: 90 nop
3c: 90 nop
3d: 90 nop
3e: 90 nop
3f: 90 nop
因此,在链接器处理.o时会读取每个.o文件的重定位表,当看到
main.o:
offset 0x19 → CALL → _ZN7MyClass8setValueEi
记录时,就会查找所有.o文件中是否有这个定义的,然后获取它的地址,并且计算当前call指令的位置到这个地址的相对偏移。每个.o文件会有符号表,用来存储每个函数的入口地址,方便补地址
动态链接和静态链接
此前提到的都是静态链接,直接打包进可执行文件中。那什么是动态链接呢?动态链接就是在运行的时候将程序用到的函数、库从外部共享库中加载进来。
优势就是节省空间,只需要加载一份,可以共享。支持更新和修复,但是稳定性和独立性差,性能也不好
生成exe文件
链接需要为exe指明程序的入口位置,这也就是main的作用