[CPP专题]-编译,链接与静态动态库

发布时间 2023-09-06 00:48:34作者: winterYANGWT

本文施工状态

本文有什么?

本文将使用简单的例子介绍如何编译和链接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