[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
多文件的编译
上面,我们讲了单文件的编译。但是在实际的环境中,为了使得代码结构更清晰,多人分工合作以及减少编译时间等等原因,我们会把一个项目分成多个文件,因此就需要进行多文件的编译。那么,什么是多文件的编译呢?一个或许令人想不到的答案就是,多文件的编译就是多个文件分别进行编译,最后再通过链接合在一起。
我们将上面的代码分别写为main.cpp,add.cpp和main.h。
// add.h
int add(int a, int b);
// main.cpp
#include "add.h"
#define one 1
int main(void)
{
int i = one;
int j = one;
int k = add(i, j);
return 0;
}
// add.cpp
#include "add.h"
int add(int a, int b)
{
return a + b;
}
接下来,我们先编译main.cpp并查看其内容。
$ g++ -c main.cpp -o 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
main.cpp能够成功编译。但细心的你一定会发现与之前不一样的是,add函数去哪了?我们的main函数需要调用add函数,但编译出来的main.o里却没有add函数的踪影。我们再试着对main.o进行链接,看看会发生什么。
$ g++ main.o -o a.out
/usr/bin/ld: main.o: in function `main':
main.cpp:(.text+0x25): undefined reference to `add(int, int)'
collect2: error: ld returned 1 exit status
可以看到发生了错误,链接器说找不到add函数。
在这里,我们也解答一下之前的一个问题(为什么main函数地址为0x24的call指令调用的函数地址是00 00 00 00,也就是占位符)。其实在编译main.cpp的时候,编译器根本不知道add函数的具体内容是什么。它只是通过导入的add.h知道有一个函数叫做add,接受两个int参数作为输入,并且在main函数中被调用。于是,编译器留下了个占位地址和符号名,告诉链接器在链接的过程中记得把占位符换成函数真实的地址。而在后续的链接过程中,链接器会通过符号名去寻找符号真正的地址,并将占位符换成符号地址。
现在,我们把add.cpp也编译了,并与main.o一起进行链接。
$ g++ -c add.cpp -o add.o
$ g++ main.o add.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
链接成功,链接器也在add.o中找到了add函数,并将main函数中调用地址从占位符换成了add函数的地址。从上面的展示,我们知道了每个CPP文件都是单独进行编译,不需要知道外部函数的具体定义,只需要知道外部函数声明。多个CPP文件被编译为多个单元后,最后再通过链接器将编译好的各个单元进行组合并将占位符替换为符号真正的地址,最终得到一个可执行二进制文件。
静态库的编译与使用
在很多时候,我们不需要独立完成所有代码功能的编写。例如我们想写一个带有图形界面的程序,还需要自己编写图形控件以及图形的渲染吗?这样做实在是太没有效率了。在很多时候,我们会使用已经编写好的图形界面库(如Qt,MFC)来降低我们的开发难度。库可以简单理解为其他人为了简化开发难度所提供的一组功能的集合,这些功能可以在其他代码中进行调用。
在了解了CPP的编译链接后,我们可以开始创建自己的库。这个库的功能十分的简单,就是两个int变量的加减乘除,其中为了进行演示,乘法是通过调用加法进行实现的。
// math.h
int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);
int div(int a, int b);
// add.cpp
#include "math.h"
int add(int a, int b)
{
return a + b;
}
// sub.cpp
#include "math.h"
int sub(int a, int b)
{
return a - b;
}
// mul.cpp
#include "math.h"
int mul(int a, int b)
{
int tmp = 0;
for (int i = 0; i < b; i++)
{
tmp = add(tmp, a);
}
return tmp;
}
// div.cpp
#include "math.h"
int div(int a, int b)
{
return a / b;
}
接下来,我们把这些代码都编译了。
$ g++ -c add.cpp -o add.o
$ g++ -c sub.cpp -o sub.o
$ g++ -c mul.cpp -o mul.o
$ g++ -c div.cpp -o div.o
然后,我们将这些编译好的代码整合为静态库,并查看静态库的内容。
$ ar -rcs libmath.a add.o sub.o mul.o div.o
$ objdump -d libmath.a > libmath_a.txt
静态库的文件名为libmath.a,这是库的一个取名规则。一般静态库都被命名为libxxx.a,.a是静态库的文件后缀,再在库名前添加lib。
// libmath_a.txt
// 内容过长,仅展示关于add函数和mul函数的部分
add.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <_Z3addii>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 8b 55 fc mov -0x4(%rbp),%edx
11: 8b 45 f8 mov -0x8(%rbp),%eax
14: 01 d0 add %edx,%eax
16: 5d pop %rbp
17: c3 ret
mul.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <_Z3mulii>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 20 sub $0x20,%rsp
c: 89 7d ec mov %edi,-0x14(%rbp)
f: 89 75 e8 mov %esi,-0x18(%rbp)
12: c7 45 f8 00 00 00 00 movl $0x0,-0x8(%rbp)
19: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
20: eb 16 jmp 38 <_Z3mulii+0x38>
22: 8b 55 ec mov -0x14(%rbp),%edx
25: 8b 45 f8 mov -0x8(%rbp),%eax
28: 89 d6 mov %edx,%esi
2a: 89 c7 mov %eax,%edi
2c: e8 00 00 00 00 call 31 <_Z3mulii+0x31>
31: 89 45 f8 mov %eax,-0x8(%rbp)
34: 83 45 fc 01 addl $0x1,-0x4(%rbp)
38: 8b 45 fc mov -0x4(%rbp),%eax
3b: 3b 45 e8 cmp -0x18(%rbp),%eax
3e: 7c e2 jl 22 <_Z3mulii+0x22>
40: 8b 45 f8 mov -0x8(%rbp),%eax
43: c9 leave
44: c3 ret
可以看到,libmath.a与add.o,mul.o的内容基本是一致的。这是因为ar是一个打包指令,静态库其实就是各个二进制文件打包成一块。并且,mul函数内0x2c处调用的add函数的地址依然是占位符,说明静态库的创建并没有进行链接操作。
完成静态库的创建后,我们开始使用这个库。
// main.cpp
#include "math.h"
int main(void)
{
int a = 5;
int b = 2;
int c = add(a, b);
int d = sub(a, b);
int e = mul(a, b);
int f = div(a, b);
return 0;
}
第一种调用方法就是直接指明链接这个库。
$ g++ main.cpp -o main.o
$ g++ main.o libmath.a -o a.out
这里,因为libmath.a就在当前路径下,所以直接就直接添加了。如果libmath.a不在当前路径下也可以通过指定绝对路径或相对路径的方法进行添加。
第二种方法是更常用的方法。
$ g++ main.o -static -lmath -o a.out
/usr/bin/ld: cannot find -lmath: No such file or directory
collect2: error: ld returned 1 exit status
-static选项指定链接方式为静态链接。这种方法使用-lmath代替了libmath.a,链接器会自动补全lib和文件后缀。在这里会链接失败,链接器提示找不到-lmath。这是因为链接器会在它的搜索路径下搜索libmath.a,但是现在的搜索路径下并不存在libmath.a,因而链接失败。
首先,我们可以添加链接器的搜索路径来进行解决。
$ g++ main.o -L. -static -lmath -o a.out
-L选项后跟着当前路径,将当前路径加入到链接器的搜索路径中,libmath.a就可以被找到了。
另外,也可以将libmath.a放到/usr/lib下。这是一种更加常用的方法,大多数的库都会放在该文件夹下。
$ mv libmath.a /usr/lib
$ g++ main.o -static -lmath -o a.out
链接完成后,我们会发现得到的a.out文件体积非常大。使用objdump查看该文件,会发现文件内有很多并不是我们编写的函数。我们之前说过,在链接阶段会将一些运行时必要的支持。这些支持之前是通过动态链接的方式添加的。在进行静态链接的时候,这些支持会全部以静态链接的方式一起链接进最后的可执行文件中。
动态链接的编译与使用
在介绍了静态库的编译与使用后,我们开始介绍动态库。动态链接是一种更加常用的方法。创建动态链接的第一步还是将我们库的代码先进行编译,然后再创建动态链接并查看其内容。
$ g++ -c add.cpp -o add.o
$ g++ -c sub.cpp -o sub.o
$ g++ -c mul.cpp -o mul.o
$ g++ -c div.cpp -o div.o
$ g++ -fPIC -shared add.o sub.o mul.o div.o -o libmath.so
$ objdump -d libmath.so > libmath_so.txt
// libmath_so.txt
// 内容过长,仅展示关于add函数和mul函数的部分
0000000000001050 <_Z3addii@plt>:
1050: f3 0f 1e fa endbr64
1054: f2 ff 25 bd 2f 00 00 bnd jmp *0x2fbd(%rip) # 4018 <_Z3addii+0x2eff>
105b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
0000000000001119 <_Z3addii>:
1119: f3 0f 1e fa endbr64
111d: 55 push %rbp
111e: 48 89 e5 mov %rsp,%rbp
1121: 89 7d fc mov %edi,-0x4(%rbp)
1124: 89 75 f8 mov %esi,-0x8(%rbp)
1127: 8b 55 fc mov -0x4(%rbp),%edx
112a: 8b 45 f8 mov -0x8(%rbp),%eax
112d: 01 d0 add %edx,%eax
112f: 5d pop %rbp
1130: c3 ret
0000000000001147 <_Z3mulii>:
1147: f3 0f 1e fa endbr64
114b: 55 push %rbp
114c: 48 89 e5 mov %rsp,%rbp
114f: 48 83 ec 20 sub $0x20,%rsp
1153: 89 7d ec mov %edi,-0x14(%rbp)
1156: 89 75 e8 mov %esi,-0x18(%rbp)
1159: c7 45 f8 00 00 00 00 movl $0x0,-0x8(%rbp)
1160: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
1167: eb 16 jmp 117f <_Z3mulii+0x38>
1169: 8b 55 ec mov -0x14(%rbp),%edx
116c: 8b 45 f8 mov -0x8(%rbp),%eax
116f: 89 d6 mov %edx,%esi
1171: 89 c7 mov %eax,%edi
1173: e8 d8 fe ff ff call 1050 <_Z3addii@plt>
1178: 89 45 f8 mov %eax,-0x8(%rbp)
117b: 83 45 fc 01 addl $0x1,-0x4(%rbp)
117f: 8b 45 fc mov -0x4(%rbp),%eax
1182: 3b 45 e8 cmp -0x18(%rbp),%eax
1185: 7c e2 jl 1169 <_Z3mulii+0x22>
1187: 8b 45 f8 mov -0x8(%rbp),%eax
118a: c9 leave
118b: c3 ret
与静态编译不同的是,mul函数中地址为0x1173处调用的add函数,其后地址不再是占位符,而是_Z3addii@plt,这个是我们之前都没见过的。