高级语言的编译:链接及装载过程介绍

目标文件的链接

首先我们尝试一下对上一小节得到的目标文件链接一下看看有什么结果:gcc test.o -o test

test.o: In function `main':
test.c:(.text+0x2c): undefined reference to `g_x'
collect2: ld returned 1 exit status

当我们尝试把这个目标文件进行链接成为可执行文件时,链接器报错了。
因为我们之前通过变量声明承诺过的变量并没有在其他的目标文件或者库文件中找到,所以链接器无法得到一个完整可执行程序。
我们尝试用另外一个C程序修复这个问题:

int g_x = 100;
int sub() {}

把这个文件编译成目标文件gcc -c test2.c -o test2.o; nm test2.o

0000000000000000 D g_x
0000000000000000 T sub

现在我们尝试把这两个目标文件链接成为可执行文件:gcc test.o test2.o -o test; nm test,
这时我们发现输出了比目标文件多很多的信息,其中定义了很多为了实现不同语言级别的功能而需要的段,在这里我们关心的是源文件中定义的那些变量对应的 symbol 及其地址,如下图所示:

00000000004005e8 T _fini
0000000000400390 T _init
00000000004003d0 T _start
...
0000000000601018 D g_a
0000000000601038 B g_b
0000000000601030 b g_c
000000000060101c D g_x
00000000004004c8 T main
0000000000601034 b s_a.1597
0000000000400504 T sub
00000000004004b4 T sum

在最终的可以执行文件里面,我们可以看到,首先,之前在第一个源文件中声明的变量 g_x 和声明的函数 sub 最终在第二个目标文件中找到了定义;
其次,在不同目标文件中定义的变量,比如 g_a,g_x 都会放在了数据段中(段类型为 D);
还有,之前在目标文件中变量的相对地址全部变成了绝对地址。

所以我们再一次进行总结一下链接器需要对源代码进行的处理:

1. 对各个目标文件中没有定义的变量,在其他目标文件中寻找到相关的定义。
2. 把不同目标文件中生成的同类型的段进行合并。
3. 对不同目标文件中的变量进行地址重定位。

这也是链接器所需要实现的最基本的功能。

装载运行

上面的几个小节中我们讨论了编译器把一个 C 源码文件编译成一个目标文件需要做的最基本的处理,也讨论了链接器把多个目标文件链接成可执行文件时需要具备的最基本的功能。
在这一个小节我们来讨论一下可执行文件如何被系统装载运行的。

动态链接库

我们都知道,在我们写程序的过程中,不会自己实现所有的功能,一般情况下会调用我们所需要的系统库和第三方库来实现我们的功能。
在上面两个小节的示例代码中,为了说明问题的简单起见,我们仅仅声明,定义了几个变量和函数,并没有使用任何的库函数。
那么现在假设我们需要调用一个库函数提供的功能,这个时候可执行文件又是什么样的呢,我们再看一个小例子:

#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[]) {
 char buf[32];
 strncpy(buf, "Hello, World\n", 32);
 printf("%s",buf);
}

我们把这个文件编译成可执行文件并且查看一下它的symbolsgcc test3.c -o test3; nm test3 :

00000000004005b4 T main
              U printf@@GLIBC_2.2.5
              U strncpy@@GLIBC_2.2.5

我们应该能看到类似上述的输出,我们在“目标文件”这一小节曾经看到过这种类型的 symbol
当时是在目标文件中,同样也是没有地址,我们说这是编译器留给链接器到后面的目标文件中寻找变量定义的。
但是现在我们检查的是可执行文件,为什么可执行文件里面仍然有这种没有地址的 symbols 呢?

我们前面提到过,编译器没有什么特别的,它做的所有事情都是为了支持编程语言级别的功能,这里同样不例外。
这里可执行文件中的“未定义”的 symbols 其实是为了支持动态链接库的功能。

我们先来回顾一下动态链接库应该有一个什么样的功能。
所谓动态链接库是指,程序在运行的时候才去定位这个库,并且把这个库链接到进程的虚拟地址空间。对于某一个动态链接库来说,所有使用这个库的可执行文件都共享同一块物理地址空间,这个物理地址空间在当前动态链接库第一次被链接时 load 到内存中。

现在我们看一下二进制文件中对动态链接库中的函数怎么处理的,objdump -D test3 | less,搜索printf我们应该能看到以下内容:

0000000000400490 <strncpy@plt>:
400490:       ff 25 6a 0b 20 00       jmpq   *0x200b6a(%rip)        # 601000 <_GLOBAL_OFFSET_TABLE_+0x18>
400496:       68 00 00 00 00          pushq  $0x0
40049b:       e9 e0 ff ff ff          jmpq   400480 <_init+0x20>
...
00000000004004b0 <printf@plt>:
4004b0:       ff 25 5a 0b 20 00       jmpq   *0x200b5a(%rip)        # 601010 <_GLOBAL_OFFSET_TABLE_+0x28>
4004b6:       68 02 00 00 00          pushq  $0x2
4004bb:       e9 c0 ff ff ff          jmpq   400480 <_init+0x20>

我们看到可执行文件中为 strncpyprintf 分别生成了三个代理 symbol
然后代理 symbol 指向的第一条指令就是跳转到_GLOBAL_OFFSET_TABLE_这个 symbol 对应的代码段中的一个偏移位置,
而在 linux 中,这个_GLOBAL_OFFSET_TABLE_对应的代码段是为了给“地址无关代码”做动态地址重定位用的。
我们提过,动态链接库可以映射到不同进程的不同的虚拟地址空间,所以属于“地址无关代码”,链接器把对这个函数的调用代码跳转到程序运行时动态装载地址。

Linux 提供了一个很方便的命令查看一个可执行文件依赖的动态链接库,我们查看一下当前可执行文件的动态库依赖情况:ldd test3:

 linux-vdso.so.1 =>  (0x00007fff413ff000)
 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fe202ae7000)
 /lib64/ld-linux-x86-64.so.2 (0x00007fe202eb2000)

ldd 命令模拟加载可执行程序需要的动态链接库,但并不执行程序,后面的地址部分表示模拟装载过程中动态链接库的地址。
如果尝试多次运行 ldd 命令,我们会发现每次动态链接库的地址都是不一样的,因为这个地址是动态定位的。
我们平常工作中,如果某一个二进制可执行文件报错找不到某个函数定义,可以用这个命令检查是否系统丢失或者没有安装某一个动态链接库。

我们在上面的小程序最后加一个sleep(1000);,然后查看一下运行时的内存映射分配,cd /proc/21509 && cat maps,应该可以看到下面这一段:

7feeef61f000-7feeef7d4000 r-xp 00000000 fd:01 135891                     /lib/x86_64-linux-gnu/libc-2.15.so
7feeef7d4000-7feeef9d3000 ---p 001b5000 fd:01 135891                     /lib/x86_64-linux-gnu/libc-2.15.so
7feeef9d3000-7feeef9d7000 r--p 001b4000 fd:01 135891                     /lib/x86_64-linux-gnu/libc-2.15.so
7feeef9d7000-7feeef9d9000 rw-p 001b8000 fd:01 135891                     /lib/x86_64-linux-gnu/libc-2.15.so

我们可以看到进程运行时,系统为 libc 库在进程地址空间中映射了四个段,因为每个段权限不同,所以不能合并为一个段。对这些动态链接库的调用最终会跳转到这里显示的地址中。

根据以上这些信息,我们在这里继续总结一下链接器需要对动态链接库需要做的最基本的事情:

  1. 链接库在将目标文件链接成可执行文件的时候如果发现某一个变量或者函数在目标文件中找不到,会按照 gcc预定义的动态库寻找路径寻找动态库中定义的变量或者函数。
  2. 如果链接库在某一个动态链接库中找到了该变量或者函数定义,链接库首先会把这个动态链接库写到可执行文件的依赖库中,然后生成这个当前变量或者函数的代理symbol.
  3. _GLOBAL_OFFSET_TABLE_代码中生成真正的动态跳转指令,并且在库函数(比如strncpy,printf)代理symbol中跳转到_GLOBAL_OFFSET_TABLE_中相应的偏移位置。

前面我们一直在讨论动态链接库(so库),其实在各个平台下面都有静态链接库,静态链接库的链接行为跟目标文件非常类似,但是由于静态库有一些问题,比如因为每个可执行文件都有静态库的一个版本,这导致库升级的时候很麻烦等问题,现在静态库用的非常少,所以这里我们不去深究。

main函数之前

在“操作系统”这一小节中,我们曾简单提过,在程序的main 函数执行之前,进程需要做一些初始化工作,然后才会调用 main 函数执行程序逻辑。
在“动态链接库”在这一小节中,我们提到了对于动态链接库,我们需要在系统启动的时候把需要的库动态链接到进程的地址空间。
在本节中,我们综合这些步骤,从可执行文件的目标代码中简单跟踪一下,Linux是如何把 elf 文件 load 到内存中并且最终调用到 main 函数的。

在“目标文件的链接”这一小节中,我们展示了部分nm test的结果,其中_start这个 symbol 是故意被留下来的,因为对于 elf 文件格式来说,linux 系统在为进程分配完虚拟地址空间并且把代码 load 到内存之后,是从这_start对应的地址开始执行的。
这个地址记录在 elf 文件的头中,系统读取 elf 文件时可以得到这个地址。
下面我们就从_start这个 symbol 对应的指令开始并追踪一下我们感兴趣的关键点。

0000000000400510 <_start>:
...
400526:       48 c7 c1 70 06 40 00    mov    $0x400670,%rcx
40052d:       48 c7 c7 f4 05 40 00    mov    $0x4005f4,%rdi
400534:       e8 b7 ff ff ff          callq  4004f0 <__libc_start_main@plt>
/*

.start这个段会去执行libc库中的__libc_start_main的指令,

这里需要注意一下传给这个函数的两个参数值“0x400670”“0x4005f4”

其中一个是__libc_csu_init的地址,一个是main函数的地址

*/
...
00000000004004f0 <__libc_start_main@plt>:
4004f0:       ff 25 22 0b 20 00       jmpq   *0x200b22(%rip)        # 601018 <_GLOBAL_OFFSET_TABLE_+0x30>
0000000000400670 <__libc_csu_init>:
...
4006b0:       e8 e3 fd ff ff          callq  400498 <_init>
...
0000000000400498 <_init>:
...
4004a6:       e8 65 02 00 00          callq  400710 <__do_global_ctors_aux>
...
00000000004005f4 <main>:
...
400626:       e8 95 fe ff ff          callq  4004c0 <strncpy@plt>
...
40063f:       e8 9c fe ff ff          callq  4004e0 <printf@plt>
...
400649:       e8 b2 fe ff ff          callq  400500 <sleep@plt>
...

我们先来简单解释一下上述贴的几段指令的意思,首先在_start 对应的指令中,经过一些处理之后,会用__libc_csu_init的地址和main的地址作为参数调用__libc_start_main
这个函数是在libc库中实现的,也就是linux中所有的可执行程序都共享同一段初始化代码,篇幅原因我们不去查看__libc_start_main的实现了。
我们需要知道的是,在__libc_start_main作为一些处理之后,会先调用__libc_csu_init对应的指令,然后调用main对应的指令。

main对应的指令就是我们自己的main函数了,__libc_csu_init接着会调用_init的指令,
然后会调用__do_global_ctors_aux这个 C++ 程序员都应该熟悉的 symbol 对应的指令,__do_global_ctors_aux对应的指令会进行所有的全局变量初始化,或者 C++ 中的全局对象构造等操作。

根据上述信息,我们总结一下当我们通过bash运行一个程序的时候,Linux 做了哪些事情:

  1. 首先 bash 进行 fork 系统调用,生成一个子进程,接着在子进程中运行 execve 函数指定的 elf 二进制程序(Linux中执行二进制程序最终都是通过 execve 这个库函数进行的),execve 会调用系统调用把 elf 文件 load 到内存中的代码段(_text)中。
  2. 如果有依赖的动态链接库,会调用动态链接器进行库文件的地址映射,动态链接库的内存空间是被多个进程共享的。
  3. 内核从 elf文件头得到_start的地址,调度执行流从_start指向的地址开始执行,执行流在_start执行的代码段中跳转到libc中的公共初始化代码段__libc_start_main,进行程序运行前的初始化工作。
  4. __libc_start_main的执行过程中,会跳转到_init中全局变量的初始化工作,随后调用我们的main函数,进入到主函数的指令流程。
    在这里插入图片描述
    至此,我们讨论了从一个 C 语言程序的源代码,到运行中的进程的全过程。

一个小例子

在明白了编译器如何把我们的源代码“转变”成二进制可执行程序之后,我们就能够知道怎么样去看某一段代码被编译成二进制之后是一个什么样子,然后就可以按照编译器的“习惯”写出高效的代码。 这一小节我们分析一个网上的小例子,下面是一个网友列出的两段程序,在面试中被问到各有什么优缺点。

程序1:

if(k > 8){
 for (int h=0;h<100;h++) { //doSomething }
} else {
 for (int h=0;h<100;h++) { //doSomething }
}

程序2:

for (int h=0;h<100;h++) {
 if (k>8) { //doSomething }
 else { //doSomething }
}

从编程规范上看,很明显程序2是好于程序1 的,因为如果“doSomething”的部分比较复杂,程序2 紧凑而不冗余,而且可以把 ifelse 分支“doSomething”公共的部分提取出来放在 for 循环下面。
但是有经验的工程师马上也能看出来,虽然程序1稍显冗余,但是其执行速度比程序2 是要快的,为什么快我们从编译器生成的目标文件分析一下,我们的测试程序如下:

程序1:

if(type == 0) {
 for(i=0; i<cnt; i++) {
     sum += data[i];
 }  
}else if(type == 1) {
 for(i=0; i<cnt; i++) {
     sum += (i&0x01)?(-data[i]):data[i];
 }  
}

程序2:

for(i=0; i<cnt; i++) {
 if(type == 0) {
     sum += data[i];
 }else {
     sum += (i&0x01)?(-data[i]):data[i];
 }  
}

编译成可执行文件后的片段为:

程序1

4005d7:       83 7d ec 00             cmpl   $0x0,-0x14(%rbp)        /* type==0判断 */
4005db:       75 29                   jne    400606 <calc_1+0x44>    /* 条件判断失败则跳到else分支 */
4005dd:       c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)
------------------------------循环体开始---------------------------
4005e4:       eb 16                   jmp    4005fc <calc_1+0x3a>    /* 跳到循环条件比较指令 */
4005e6:       8b 45 fc                mov    -0x4(%rbp),%eax         /* 循环内第一条指令 */
...
4005f8:       83 45 fc 01             addl   $0x1,-0x4(%rbp)
4005fc:       8b 45 fc                mov    -0x4(%rbp),%eax
4005ff:       3b 45 e8                cmp    -0x18(%rbp),%eax        /* 循环条件比较 */
400602:       7c e2                   jl     4005e6 <calc_1+0x24>    /* 跳到循环开始阶段 */
------------------------------循环体结束----------------------------
400604:       eb 4a                   jmp    400650 <calc_1+0x8e>
400606:       83 7d ec 01             cmpl   $0x1,-0x14(%rbp)        /* else分支,type==1判断 */
...
/* type==1 分支基本与type==0的分支是一致的 */    
程序2:
400671:       eb 4d                   jmp    4006c0 <calc_2+0x6b>
------------------------------循环体开始---------------------------
400673:       83 7d ec 00             cmpl   $0x0,-0x14(%rbp)        /* type==0 */
400677:       75 14                   jne    40068d <calc_2+0x38>    /* 条件判断失败则跳到else分支 */
...
400686:       8b 00                   mov    (%rax),%eax
400688:       01 45 f8                add    %eax,-0x8(%rbp)
40068b:       eb 2f                   jmp    4006bc <calc_2+0x67>
40068d:       8b 45 fc                mov    -0x4(%rbp),%eax         /* else分支 */
400690:       83 e0 01                and    $0x1,%eax
400693:       84 c0                   test   %al,%al
400695:       74 13                   je     4006aa <calc_2+0x55>
...
4006c0:       8b 45 fc                mov    -0x4(%rbp),%eax
4006c3:       3b 45 e8                cmp    -0x18(%rbp),%eax        /* 循环条件比较 */
4006c6:       7c ab                   jl     400673 <calc_2+0x1e>    /* 跳到循环开始阶段 */
------------------------------循环体结束----------------------------  

通过对比源程序和汇编指令,我们程序1和程序2 编译完之后的汇编指令分别进行了对比标注。
我们可以对比一下,在程序1 的汇编指令中,经过一次条件判断之后,执行流会跳到相应的循环指令段中(if/else),然后循环执行整段的指令。
而在程序2 的汇编指令中,在每一次执行循环指令段的过程中,都有条件的判断和跳转(if/else)。 所以这里我们可以总结一下程序2 比程序1速度快的原因:

  1. 程序2 中每次循环体的执行都需要执行比较指令和跳转指令,如果循环次数非常多(比如大于百万次),就相当于多执行百万条指令。
  2. 现代的 CPU 都是流水线模式模式,也有指令预取模块,也就是说同一时间段内,有多条指令在 CPU内运行,同时也有预测的指令预取。如果发生了指令跳转,就很有可能造成后续的指令全部被刷出 CPU,重新跳转到新地址执行,浪费多个 CPU周期。

通过我们的分析,我们可以说,如果此程序段处于整个项目中非瓶颈的位置,程序2作为优先选择的是可以接受的。但是如果此程序段处于速度瓶颈位置,程序1是占有优势的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值