android linker链接过程_C语言:从源代码到可执行文件的烹饪过程

## 说明

这篇文章主要是对C语言广义上的编译过程进总结。本人尽量使用简洁的语言进行讲解,分享更多的干货,更偏重实用性和整体知识脉络的把握

由于本人能力有限,对C的掌握还不够精深。如果在阅读中发现错误或疏漏,欢迎指正。

## 编译器 (compiler)

通常我们使用的C编译器有三个

* *Visual Studio(MSVC)系列*

由Microsoft开发的基于Win32的闭源编译器实现

* *GCC*

Richard Stallman 发起GNU计划中的基础编译套件,现已广泛移植到各大主流操作系统和平台,是Linux各发行版的默认编译器,使用GPL协议发布。

* *Clang*

由Apple开发的面向OSX的编译器实现,现已移植到各大主流操作系统,使用BSD协议发布。

后面,我们会使用到 GCC。

## 构建可执行文件

在通用操作系统中,从C源代码构建目标可执行文件(或称执行档)主要包含4个流程:预处理、编译、汇编和链接。

在Linux系统中使用Shell环境创建源文件donothinig.c,当然你可以选择Bash或zsh,笔者将使用Bash作为演示环境。

```C

int main() {}

```

编译源文件 donothing.c

```sh

$ gcc donothing.c

```

生成名为a.out 的可执行文件,使用file命令查看文件格式

```sh

$ file a.out

a.out: ELF 64-bit LSB executable, ... not stripped

```

可知a.out格式为64位的 ELF(Executable and Linkable Format)可执行格式,这是类UNIX中通用的执行档格式。LSB表示小端序,指该可执行文件的目标处理器架构是小端序(如ix86、arm处理器),而对于MIPS等大端序处理器,用MSB表示。命令 `gcc donothing.c` 自动执行了上述构建过程的全部,在不指定输出文件名的情况下,默认是 **a.out **。

下面分析构建目标的四个过程

### 预处理 (preprocess)

GNU 提供了预处理命令`cpp`,它的全称是The C Preprocessor,我们使用 helloworld 程序 `hello.c' 做示例,来分析预处理的行为。

```C

/* hello.c */

#include <stdio.h>

int main(){

printf("Hello worldn");

return 0;

}

```

进行预处理

```sh

$ cpp -P hello.c hello.i

```

执行完毕,在当前目录生成 hello.i,打开 hello.i,你会惊奇的发现,`#include <stdio.h>` 消失了,取而代之的是`extern`,`typedef`和其他形式的声明。

这其中包含了最重要的一行语句,就是`printf`函数的声明:

```C

extern int printf (const char *__restrict __format, ...);

```

这中间到底发生了什么?其实预处理就是一个预处理语句的展开过程,常见的预处理语句包括`#define`、`#if...#endif`、`include`、`#line`等,预处理器会递归展开这些语句,最终替换为干净的C程序,不包含任何符号`#‘ 开头的行。(注:cpp命令的 `-P’ 参数会移除预处理文件中的linemarker行,这些行是以符号`#'开头的,这不在文章讨论的范围内)

那么我们猜想,如果把文件hello.c的预处理语句`#include <stdio.h>` 替换为上述printf函数声明。是不是可以被正确编译执行呢?

```C

/* hello.c */

extern int printf (const char *__restrict __format, ...);

int main(){

printf("Hello worldn");

return 0;

}

```

答案是肯定的

```sh

$ gcc hello.c

$ ./a.out

Hello world

```

printf函数在其声明的作用下成功定向到了C库printf函数实现的入口地址。

### 编译(compile)

将上一步骤产生的预处理文件 hello.i 进行编译

```sh

$ gcc -S hello.i

```

此时在当前目录会生成同名但是后缀为.s的文件(你可以通过 `-o <file>' 参数来指定其他的输出文件名)。这个文件是AT&T格式的GAS汇编文件,文件内容如下:(注:笔者当前使用的OS是64位LinuxMint19发行版)

```asm

.file "hello.i"

.globl s

.section .rodata

.LC0:

.string "helloworldn"

.data

.align 8

.type s, @object

.size s, 8

s:

.quad .LC0

.text

.globl main

.type main, @function

main:

.LFB0:

.cfi_startproc

pushq %rbp

.cfi_def_cfa_offset 16

.cfi_offset 6, -16

movq %rsp, %rbp

.cfi_def_cfa_register 6

movq s(%rip), %rax

movq %rax, %rdi

movl $0, %eax

call printf

movl $63, %eax

popq %rbp

.cfi_def_cfa 7, 8

ret

.cfi_endproc

.LFE0:

.size main, .-main

.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609"

.section .note.GNU-stack,"",@progbits

```

如果你同笔者一样,觉得AT&T汇编真的很难看,你可以通过为`gcc`命令添加`-masm=intel'选项来产生Intel格式的汇编代码。

```asm

.file "hello.i"

.intel_syntax noprefix

.section .rodata

.LC0:

.string "Hello world"

.text

.globl main

.type main, @function

main:

.LFB0:

.cfi_startproc

push rbp

.cfi_def_cfa_offset 16

.cfi_offset 6, -16

mov rbp, rsp

.cfi_def_cfa_register 6

mov edi, OFFSET FLAT:.LC0

call puts

mov eax, 0

pop rbp

.cfi_def_cfa 7, 8

ret

.cfi_endproc

.LFE0:

.size main, .-main

.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609"

.section .note.GNU-stack,"",@progbits

```

*掌握i386处理器及其64位扩展汇编应该是一个C程序员基本的技能要求*

很显然,printf函数调用,在编译优化过程中被替换为执行效率更高的puts函数,汇编程序在此不做过多解释,存在阅读困难的同学,可以自行学习一下相关内容。其中一行关键指令`.globl main`,随后我们会用到,.global 是一个汇编器伪指令,用来标明将main函数首地址以符号形式导出(或理解为暴露),以便被其他模块调用。

### 汇编 (assemble)

GNU编译套件提供了`as`命令,对GAS格式文件进行汇编生成可重定位目标文件,这种文件二进制文件并且不能被直接执行,它包含了机器码、调试信息、数据和重定位信息。下面使用`as`命令对上一步生成的文件hello.s进行汇编操作

```sh

$ as -o hello.o hello.s

```

该命令会产生hello.o文件,使用`file`命令查看文件格式

```sh

$ file hello.o

hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

```

这表明生成的文件格式为64位的ELF小端序可重定位目标文件,该种格式文件可以作为模块与其他模块被链接器捆绑在一起,构成单独的可执行程序。

使用GNU开发者工具中的`nm`命令查看该文件的符号表

```sh

$ nm hello.o

0000000000000000 T main

U puts

```

`T'表示代码段(text section)中导出的符号,前面的64位数值表示基于段首的地址偏移,它是用16进制表示的。main函数在最终的可执行程序中,实际被CRT(C运行时环境)调用。

`U'未实现的符号,因为puts符号需要从C库中动态导入,在后续的链接中将被重定向。

### 链接(link)

链接操作是通过链接器(linker),将一个或多个(可重定位)目标文件合并为一个可执行文件。

类UNIX上常用的链接器是GNU ld,而MSVC链接器是 link.exe。

尝试使用gcc命令将hello.o链接为可执行文件并执行

```sh

$ gcc -o hello hello.o

$ ./hello

Hello world

```

字符串 'Hello world' 被正确输出,显然hello.o被成功链接为可执行文件。但这可能并不是我们想看到的链接方式,所有复杂的链接细节都被隐藏了。

下面使用GNU ld改写上述链接过程

```sh

$ ld -dynamic-linker /lib64/ld-linux-x86-64.so.2

> -o hello

> /usr/lib/x86_64-linux-gnu/crt1.o

> /usr/lib/x86_64-linux-gnu/crti.o

> hello.o

> /usr/lib/x86_64-linux-gnu/crtn.o

> -lc

```

对于这条看似古怪的命令行,可能有些读者会产生不适,不过不用担心,它实际并不复杂。

-dynamic-linker <file> 参数指定动态链接器,它是一个共享库(Shared library),在ELF可执行文件加载并映射到内存后,内核将控制权转交给动态链接器。动态链接器会分析可执行文件动态段(Dynamic section),将依赖的共享库映射到进程虚拟地址空间,再针对共享库重复上一步工作,直到将需要的共享库全部加载,然后对可执行文件和需要的共享库执行重定向,提供对共享库初始化和卸载清理的执行环境,然后将控制权转交给程序。

'-lc' 参数会动态链接C库 /lib/x86_64-linux-gnu/libc.so.6,它并不会把libc.so.6文件链接到执行档,而是把运行时依赖关系写入动态段。使用objdump命令可查看ELF执行档依赖的共享库

```sh

objdump -p hello | grep NEEDED

NEEDED libc.so.6

```

crt1.o crti.o crtn.o 是建立C运行时(CRT)环境基本的模块,CRT主要作用是提供进入main函数前的初始化和main函数返回后的清理工作。crt1.o包含程序入口点_start和两个关键导入符号__libc_start_main和main,__libc_start_main负责初始化LIBC及调用main函数,crti.o和ctrn.o共同包含了两个段.init、.fini,这两个段的入口是符号_init和_fini会在main函数执行前与返回后被先后调用。用来初始化C++的全局静态对象。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值