[CPP专题]-编译,链接与静态动态库
本文施工状态
本文有什么?
本文将使用简单的例子介绍如何编译和链接CPP代码,以及这些行为背后发生了什么改变。在此基础上介绍如何编译出静态库和动态库,以及如何使用这些库。适合对CPP具有一定了解的朋友。
本文所使用的环境为Ubuntu,使用编译器为g++。
正文
单文件的编译
当我们在写完代码后,就需要将代码从文本文件通过编译链接的方式生成二进制可执行文件。让我们从下面这个简单的代码开始演示。
// main.cpp
#define ONE 1
int add(int a, int b);
int main(void)
{
int i = ONE;
int j = ONE;
int k = add(i, j);
return 0;
}
int add(int a, int b)
{
return a + b;
}
这段代码首先定义了两个变量i和j,并使用ONE进行初始化赋值;然后定义了变量k,它的赋值为执行函数add(i, j)的返回值。
接下来,我们一步一步通过预处理,编译,汇编和链接将这段代码变为可执行文件。
预处理
预处理阶段会执行代码中的预处理指令,比如#include会将头文件进行展开,#define定义的常量会进行替换等操作。这里就不做详细叙述,感兴趣的朋友可以自行搜索了解更多的内容。
通过-E选项,g++对main.cpp执行预处理并生成出预处理文件main.i。
$ g++ -E main.cpp -o main.i
// main.i
int add(int a, int b);
int main(void)
{
int i = 1;
int j = 1;
int k = add(i, j);
return 0;
}
int add(int a, int b)
{
return a + b;
}
与main.cpp不同的是我们预定义的常量ONE都被替换成了1。完成预处理后,我们得到的文件依然是CPP代码。
编译
编译阶段会将完成预处理后的代码通过编译器生成中间的汇编代码。
通过-S选项,g++对main.i执行编译并生成汇编代码main.s
$ g++ -S main.i -o main.s
// main.s
// 内容过长,这里只展示部分内容
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $1, -12(%rbp)
movl $1, -8(%rbp)
movl -8(%rbp), %edx
movl -12(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call _Z3addii
movl %eax, -4(%rbp)
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.globl _Z3addii
.type _Z3addii, @function
_Z3addii:
.LFB1:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
完成编译后,代码从高级的CPP转换为了与底层硬件相关的汇编代码。
在过程main中,调用了过程_Z3addii。_Z3addii其实就是我们定义的add函数,Z后的3意味着函数名长度为3,add后面的两个i意味着接受两个int参数。
汇编
汇编阶段会将得到的汇编代码通过汇编器生成二进制文件。
通过-c选项,g++对main.s执行汇编并生成二进制文件main.o
$ g++ -c main.s -o main.o
生成得到的main.o,我们没有办法直接进行查看,但我们可以通过objdump对main.o进行反汇编。
$ objdump -d main.o > main.txt
// main.txt
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 10 sub $0x10,%rsp
c: c7 45 f4 01 00 00 00 movl $0x1,-0xc(%rbp)
13: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%rbp)
1a: 8b 55 f8 mov -0x8(%rbp),%edx
1d: 8b 45 f4 mov -0xc(%rbp),%eax
20: 89 d6 mov %edx,%esi
22: 89 c7 mov %eax,%edi
24: e8 00 00 00 00 call 29 <main+0x29>
29: 89 45 fc mov %eax,-0x4(%rbp)
2c: b8 00 00 00 00 mov $0x0,%eax
31: c9 leave
32: c3 ret
0000000000000033 <_Z3addii>:
33: f3 0f 1e fa endbr64
37: 55 push %rbp
38: 48 89 e5 mov %rsp,%rbp
3b: 89 7d fc mov %edi,-0x4(%rbp)
3e: 89 75 f8 mov %esi,-0x8(%rbp)
41: 8b 55 fc mov -0x4(%rbp),%edx
44: 8b 45 f8 mov -0x8(%rbp),%eax
47: 01 d0 add %edx,%eax
49: 5d pop %rbp
4a: c3 ret
这个时候,我们直接运行main.o,看看是什么样的结果。
$ chmod +x main.o
$ ./main.o
bash: ./main.o: cannot execute binary file: Exec format error
明明已经是二进制文件了,为什么还无法运行呢?我们看到main函数里起始地址为0x24的call指令后跟随的函数地址是00 00 00 00,根本不是_Z3addii的地址。原来,在main.o中函数的调用会使用00 00 00 00进行替代,这段地址被称为占位符。为什么会进行这样的设计呢?我们将在多文件的编译中进行解释,在这里稍微卖个关子。
除此之外,main.o中也缺少一些运行所必要的支持。这些支持会在链接阶段进行添加。
链接
链接阶段会将main.o文件中的占位符进行替换为函数的真实地址并添加运行支持。
通过g++和objdump,main.o文件被链接为二进制可执行文件a.out并查看其内容。
$ g++ main.o -o a.out
$ objdump -d a.out > a.txt
// a.txt
// 内容过程,只展示重点部分
0000000000001129 <main>:
1129: f3 0f 1e fa endbr64
112d: 55 push %rbp
112e: 48 89 e5 mov %rsp,%rbp
1131: 48 83 ec 10 sub $0x10,%rsp
1135: c7 45 f4 01 00 00 00 movl $0x1,-0xc(%rbp)
113c: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%rbp)
1143: 8b 55 f8 mov -0x8(%rbp),%edx
1146: 8b 45 f4 mov -0xc(%rbp),%eax
1149: 89 d6 mov %edx,%esi
114b: 89 c7 mov %eax,%edi
114d: e8 0a 00 00 00 call 115c <_Z3addii>
1152: 89 45 fc mov %eax,-0x4(%rbp)
1155: b8 00 00 00 00 mov $0x0,%eax
115a: c9 leave
115b: c3 ret
000000000000115c <_Z3addii>:
115c: f3 0f 1e fa endbr64
1160: 55 push %rbp
1161: 48 89 e5 mov %rsp,%rbp
1164: 89 7d fc mov %edi,-0x4(%rbp)
1167: 89 75 f8 mov %esi,-0x8(%rbp)
116a: 8b 55 fc mov -0x4(%rbp),%edx
116d: 8b 45 f8 mov -0x8(%rbp),%eax
1170: 01 d0 add %edx,%eax
1172: 5d pop %rbp
1173: c3 ret
可以看到,main函数里起始地址0x114d的call指令后所跟的地址已经是_Z3addii的地址了。并且在命令行中运行a.out也能够成功运行,虽然该程序没有任何的输入和输出。
通过上面的步骤,我们终于从最开始的CPP代码逐步得到了可运行的二进制文件。在实际的使用中,我们通常会直接使用g++生成二进制文件,跳过这些中间的步骤。
$ g++ main.cpp -o a.out