[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,这个是我们之前都没见过的。

posted on 2023-09-06 00:40  winterYANGWT  阅读(45)  评论(0编辑  收藏  举报