程序的链接与执行

本文以实验的形式对Linux下的可执行程序链接与执行过程进行了基本的探讨,而对于理论没有过度深究,仅作为初学者对于程序链接与执行入门有一个整体的了解。

目录

1 概述

2 实验对象

3 程序的静态链接

3.1 实验目的

3.2 实验环境与内容

3.3 实验步骤

4 进一步控制程序的链接

4.1 实验目的

4.2 实验环境与内容

4.3 实验步骤

5 程序的装载与启动流程

5.1 实验目的

5.2 实验环境与内容

5.3 实验步骤

6 扩展思考

可参考的内容


 概述

       编译器编译源代码生成的文件叫做目标文件。目标文件实际上与可执行文件的文件格式是一样的,只是在结构上与最终的可执行文件有稍许不同,其中主要是一些符号信息(变量/函数等)以及代码段中相应的地址没有进行调整。以Linux为例,其可执行文件的格式为ELF(Executable Linkable Format),目标文件通常以”.o”为后缀,ELF格式的文件分类如下所示:

表1-1 ELF 格式的文件分类

ELF 文件类型

基本描述

实例

可重定位文件(Relocatable File)

包含了可用于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。

Linux的 “.o” 文件

可执行文件(Executable File)

包含了可直接在对应系统下执行的程序,携带了exec() 创建程序进程映像的代码和数据。

Linux的可执行文件一般无后缀,如 ls, rm、gcc等

共享目标文件(Shared Object File) 

包含了相关的代码和数据可用于:

1.与其他可重定位文件和共享目标文件进行静态链接,产生新的目标文件;

2. 动态链接时将若干共享目标文件与可执行文件结合,一同载入进程映像。

Linux的 “.so” 文件

核心转储文件(Core Dump File)

在进程意外终止时,系统将该进程终止时的相关信息存储到此类文件之中,可作为调试相关错误的信息渠道之一。

Linux的Core Dump

       类似地,在Windows系统下的可执行文件格式为PE(Portable Executable),PE文件与EFL均为COFF(Common file format)的变种,PE 文件也有类似的文件分类,如”.obj”、“.exe”以及”.dll”等。

       在拥有目标文件后,若想要得到最终的可执行文件,则需将相应的目标文件(.o)进行链接(静态)。简单来讲,所谓静态链接即将程序的各个部分(不同目标文件)”拼接”成一个最终的可执行文件,其主要任务在于将各个模块之间互相引用的部分都处理好,最终使得它们的功能能够良好地衔接在一起。而程序各个模块之间的关系,即对应着不同模块中变量或函数间的引用关系,变量与函数均以符号信息的形式存储于文件之中,符号可以视作链接的接口。链接过程通常分为两步:

       1. 空间与地址分配。扫描各个目标文件,收集它们的符号定义与符号引用,以一定顺序分配它们的空间与地址;此步可理解为先讲不同模块间的大体布局先规划好。

       2. 符号解析和重定位。对符号表中的符号信息以及代码节中相关的地址引用进行调整。此步的目的即根据布局对不同的模块进行”粘合”。

       本文的前两个子实验将引导大家进行可执行文件的链接,并从中了解链接对目标文件所做的改变。需要说明的是,本文的第一个子实验(3)主要关注于静态链接(包括libc 也进行静态链接);而为了能够在后续帮助大家同时理解静态链接与动态链接的差别,本文的第二个子实验(1.4)中对于libc(libc.so)采用动态链接的方式,以便大家比较静态链接与动态链接生成的可执行文件的差别,并衔接后续第三个子实验(5)。

       在得到最终的可执行文件后,我们就可以在对应的机器下运行了。在学习C/C++语言时,我们常被告知:程序从main函数开始运行。但事实是否如此?答案显然是否定的。在程序的控制权转移到main函数的第一行代码之前,程序已经完成了诸如堆栈初始化、全局变量构造等操作以初始化程序的运行环境。

       事实上,当我们运行一个程序时,操作系统会根据可执行文件提供的信息将程序及其所依赖的动态共享库装载到其进程的虚拟地址空间之中,随后经过一系列环境初始化,才正式运行”程序开发者定义的入口点“:main函数。这之间发生了什么?本文的第三个子实验(5)将通过调试跟踪的方法,引导大家一探究竟。

2 实验对象

test_a.c

test_b.c

#include<stdio.h>

extern void b();

extern int share_data;

void a(){

        printf("Hello World from test_a!\n");

        share_data ++;

        b();

}

int main(int argc, char **argv){

        a();

        return 0;

}

#include<stdio.h>

int share_data = 1;

void b(){

        printf("Hello World from test_b! - %d\n", share_data);

}

编译:

    gcc -c test_a.c -o test_a.o

    gcc -c test_b.c -o test_b.o

       使用 gcc -c 等方式将上述源码按照默认选项分别编译为test_a.o,test_b.o 文件即可开始后续实验。

3 程序的静态链接

3.1 实验目的

1. 了解并基本掌握静态链接生成可执行文件的步骤(ld 工具的使用);

2. 了解并验证静态链接时所进行的操作;

3. 了解并验证可执行文件与目标文件(.o)的区别。

3.2 实验环境与内容

实验环境:x86_64,Linux OS,GNU binutils(ld, objdump, readelf等),libc..a(一般位于/usr/lib/x86_64-linux-gnu/ 文件夹下,使用 -L指定目录后使用 -lc 即可)

实验内容:

1. 使用 ld 链接给定目标文件(.o)生成最终的可执行文件;

2. 使用 objdump、readelf 、file等常见工具查看比较原目标文件与可执行文件的区别。

3.3 实验步骤

1. 使用 ld 链接目标文件:

       直接使用 ld -static test_a.o test_b.o -o test 尝试一下:

        不行,为什么?这是因为缺少很多运行时库的支持(CRT,C Run-time Lirary),它们提供给了诸如运行环境、I/O初始化等功能。

       完整的链接指令:

  

       需要注意的是,上述所需的目标文件的路径在不同系统、机器上可能不同,我们可以通过find命令的-name选项来搜索对应的文件路径进行替换,一般都在/usr/lib或者/lib/之下;此外,静态链接时应按照一定顺序提供所需文件,大家在进行实验时可以思考下为什么,此处不做探讨。此时我们得到了一个完全静态链接的可执行文件,它的特点为所依赖的代码和数据均包含在同一文件中,但相对于直接使用 gcc 进行默认的动态链接而言,其文件大小会显得十分”膨胀”:

   

2. 使用 readelf 查看目标文件与可执行文件的文件头以及节表头,观察文件结构的区别:

       首先,我们使用 readelf -h 比较 test_a.o 与 test 的差别(test_b.o 也是类似的):

test_a.o

test

        可以看到,可执行文件头中的 type字段发生了改变,除此之外,test节表(section)数量发生了变化,且出现了程序段(program header,segment),所谓程序段,如下图所示,可以简单理解为将静态链接视图的若干节表进行合并以便后续装载。

         通常对”节”和”段”不做区分,不做特殊说明一般指的都是静态链接视图中的section。我们可以通过 readelf -S 与 -l 来比较它们,并查阅资料来充分理解各个section的作用,其中-S 用于查看节表,-l 用于查看程序段表。

3. 使用 objdump 查看目标文件与可执行文件指令内容的差别:

       那么,我们自己的指令部分发生了什么变化呢?我们可以通过 objdump -d 来反汇编查看 .text 段的情况:(注:由于test是静态链接,所以它的代码段包含了所有依赖库的代码,为简化起见,这里主要比较我们所写的 main 函数与 a、 b 三个函数的变化):

test

test_a.o

test_b.o

          由上述信息,我们可以看到,在尚未链接的目标文件test_a.o与test_b.o中,变量share_data以及函数printf、a、b的地址均为\x00000000,如test_a.o中main函数的48:行应该调用函数a,但此处对a的地址进行了留空,在函数a中,f: 与 28: 行分别调用了printf与函数b,也同样进行了留空,14: ~ 1a: 两条指令用于对变量share_data进行++操作,其地址也进行了留空。

       相对的,在左侧链接后的可执行文件test中,对应位置的符号与地址均已完成了解析和重定位,因此,左侧的文件只需按照其文件结构指定的空间布局进行装载,随后代码即可在相应的操作系统、处理器上运行。

4 进一步控制程序的链接

       通过3,我们已经可以通过静态链接在默认选项下得到一个可执行文件,实际上,编译选项与链接选项非常多(本节也只是使用了若干基本的链接选项),我们可以在特定的条件下通过合理利用它们来更好地达到目的。

       在本小节,我们对目标文件采取静态链接(test_a.o与test_b.o) + 动态链接(其他依赖库如libc等)的方式,通常大多数程序均是采用这样的方式。前面我们已经知道,静态链接相当于把所依赖的对象直接放入同一个文件中,而动态链接则可以理解为,我们预留好空间和位置给相应的对象,在程序开始运行甚至是运行过程中在进程的空间中载入它们,相当于将链接这一操作推迟到了运行时进行。

4.1 实验目的

1. 了解并掌握如何指定程序代码段位置及入口点;

2. 了解并掌握在链接时控制程序符号的可见性的基本方法。

4.2 实验环境与内容

实验环境:x86_64,Linux OS,GNU binutils(ld, objdump, readelf等)

实验内容:

1. 使用 ld 指定可执行文件的程序代码段等位置以及入口点等;

2. 结合 objdump、readelf 等工具查看比较可执行文件的结构变化。

4.3 实验步骤

1. 使用 ld 重新对目标文件进行链接,并指定代码段(Ttext)位置、入口点(--entry)等:

       这里我们直接将3中的 -static 选项去掉,并添加动态链接器,修改libc:

  

       这与我们之前直接使用gcc test_a.o test_b.o -o test_dynamic 的效果是基本一样的。

       我们可以使用 –entry 以及 -Ttext 参数来修改入口点以及代码段位置:

  

2. 使用 readelf/objdump 查看修改前后的区别:

       使用 readelf -h + objdump -d 来查看修改 –entry 前后的区别:

修改前

修改后

       由于程序入口点往往要做很多初始化的工作,因此我们直接将入口点设置为main,运行时尽管我们程序本身的功能看起来似乎正常执行了,但在退出释放运行环境时会出错,在下一小节我们将追踪这些初始化工作: 

       使用readelf -S 查看使用 -Ttext前后.text段位置的区别:

  

       可以看到,我们成功修改了.text段的位置,而这一般不会影响程序的运行,仅会对程序的结构造成影响。

3. 再次使用 ld 对目标文件进行链接,并修改符号的可见性:

       使用 -s 将程序中的符号表”symtab”中的所有符号都隐藏,使用 -E 选项在动态符号节添加程序中的符号:

        其他诸如-x等参数也与生成的文件中的符号可见性相关。

4. 使用 readelf/objdump 查看修改前后的区别:

       使用-s后,符号表”.symtab”不再存在,只剩下”.dynsym”:

        这样使得我们在逆向代码时无法很好地判断函数的位置,如main函数等:

test_dyn

test_dyn_s

        可以看到,通过-s处理后,我们就丧失了函数名与函数大小等信息,这使得我们只能利用二进制函数边界定位等技术来从汇编代码处理得到更高层次的信息,但受限于环境、编译优化等多方面原因,这会明显提高逆向的难度。因此,通常商业二进制软件在编译或链接时都会使用-s参数,隐藏不必要暴露的函数信息,只保留如提供给外部使用的API、全局变量等相关符号。

       而使用-E后,则在”.dynsym”动态符号节中会出现程序本身定义的符号:

5 程序的装载与启动流程

5.1 实验目的

1. 了解程序装载及动态链接时的基本操作;

2. 帮助学生对程序的启动流程建立一个整体的认知;

3. 了解并基本掌握 gdb、ltrace、strace等工具的使用。

5.2 实验环境与内容

实验环境:x86_64,Linux OS,GNU binutils,strace等

实验内容:

1. gdb调试程序的启动,观察程序从启动到入口点的所进行的操作;

2. 结合strace等工具深入理解程序的装载及启动流程。

5.3 实验步骤

1. 使用strace跟踪程序运行时所使用的系统调用情况,了解控制权到达入口点前做的事情:

        其中ld.so.cache为一个文本配置文件,用于快速查找共享库的位置。结合上述系统调用的顺序和信息,我们可以推测,实际上在execve时动态链接器实际上已经被加载到了程序的内存空间,为进一步验证,我们可以使用strace追踪动态链接器对程序的加载过程:

        可以看到,在装载程序后,后续的调用信息与直接对test_dyn进行追踪得到的内容是一致的,即此前在直接运行test_dyn时动态链接器应该已经被装载到内存中了(终端进行fork后,execve程序时内存中即装载了动态链接器),大家感兴趣可以进一步验证其何时被装载。

       后续我们可以通过gdb ./test_dyn来进一步调试,并将断点下在mmap等函数,然后逐步跟踪其启动流程,同时结合/proc/<pid>/maps来查看对应进程的内存空间布局:

运行时机

内存布局

初始布局

完成第1个mmap后(装载so-name的缓存文件 ld.so.cache)

完成第2-7个mmap及所有mprotect后(为libc分配空间、装载并设置权限)

到达程序入口点_start

       在到达程序入口点_start后,进程的内存空间装载了所依赖的共享库libc.so,与此同时,动态链接器ld.so(rtld,run-time ld)也依然存在于进程的内存空间中。

        由此,我们可以得到程序在正式运行到入口点前的基本过程:a. 在shell接收到程序执行的指令后,fork一个新的进程,随后使用execve来执行该进程;b. 动态链接器ld.so会被装载进内存并获得控制权,随后进行动态链接操作,如装载所需的共享库libc.so,并进行一系列内存权限设置等;c. 动态链接器将控制权交到程序的入口点。        

2. 在到达程序自身的入口点之后,我们可以通过gdb调试后续流程:

       删除之前断点,下断点到 main与_start:

        然后使用s或si慢慢单步执行(对于没有符号信息的二进制调试通常使用si或ni),由于此过程相对漫长,限于篇幅此处不做过多展示……过程中的关键函数有__libc_start_main, __libc_csu_init__, __init, frame_dummy 等。

       在逐步调试到main函数的过程中可以看到,到达入口点_start后,会对程序的运行环境进行一些初始化,本例相对较为简单。而一般来讲,诸如堆、I/O、全局变量的构造等都会在此阶段进行初始化,随后才将控制权转移到main函数,执行程序主体逻辑,在主体逻辑执行完之后,程序会回到__libc_start_main完成一系列退出的操作。

       如此,我们对程序的执行过程就可以有一个整体的了解。类似地,我们可以对此前静态链接生成的可执行文件 test 进行启动流程跟踪,以比较两者的不同。

6 扩展思考

       上述实验抛转引玉,和大家一起对程序基本的静态链接方法、装载与动态链接以及启动的流程有了整体把握,但限于篇幅,内容更多停留在做了什么,而没有过多讨论为什么做以及具体是如何做的等细节,因此相对于真正”深入”了解尚有不小的距离。为帮助大家在进行进一步学习时有更为清晰的切入思路,现提供如下问题作为扩展思考,大家可自主查阅资料并设计实验来了解、解释、验证相关问题:

  1. 目标文件与可执行文件的区别?
  2. 完全静态链接时所需的一大堆目标文件的作用是什么?
  3. 静态链接是如何进行符号解析和重定位的?
  4. 程序装载时的虚拟地址空间是如何分配的?
  5. 程序启动时环境变量与参数存储在哪里?
  6. 捕获程序启动时传入的参数有哪些思路?
  7. 静态链接与动态链接的区别有哪些(如对可执行文件结构、符号解析与重定位的方法及启动流程的影响等),分别有何优缺点?
  8. 动态链接器本身是静态链接还是动态链接?
  9. 动态链接时共享库及符号是如何进行查找的?
  10. 程序运行退出过程中进行了什么操作?

       相信在思考上述问题后,大家会对程序的链接与装载有更为全面、深刻的认知。

可参考的内容

《深入理解计算机系统,csapp》

《程序员的自我修养 - 链接、装载与库》

  http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html - 程序的启动过程

……

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值