xhys121zero2025-11-09文章来源:SecHub网络安全社区
在Linux环境下,使用GCC(GNU Compiler Collection)编译一个C程序是一个常见的任务。假设我们有一个名为test.c的C源文件,使用简单的编译命令gcc test.c会生成一个可执行文件a.out。这个编译过程实际上包含了一系列复杂且有序的步骤,我们可以将这些步骤细分为四个主要阶段:预处理、编译、汇编和链接
描述:
预处理阶段是对源代码进行最初的文本处理。这一阶段,编译器会处理所有以#开头的指令,例如#include、#define等。这些指令告诉编译器在实际编译之前需要做什么。
过程:
结果:
预处理后,生成一个.i文件(默认情况下不直接生成,但可以通过选项-E生成),该文件包含了经过宏展开和头文件包含处理后的源代码。
描述:
编译阶段将预处理后的源代码转换为汇编代码。这是通过词法分析、语法分析、语义分析以及生成中间代码(如抽象语法树)和最终的汇编代码来完成的。
过程:
结果:
编译后,生成一个.s文件(可以通过选项-S生成),该文件包含了汇编代码。
描述:
汇编阶段将汇编代码转换为机器代码(也称为目标代码或二进制代码)。这是通过汇编器(assembler)完成的。
过程:
汇编器逐行读取汇编代码,并将其转换为计算机可以直接执行的机器指令。
结果:
汇编后,生成一个.o文件(目标文件,可以通过选项-c生成),该文件包含了机器代码和符号表等信息。
描述:
链接阶段将多个目标文件(以及可能需要的库文件)合并为一个可执行文件。这是通过链接器(linker)完成的。
过程:

结果:
链接后,生成一个可执行文件(默认名为a.out,但可以通过选项-o指定其他名称),该文件包含了程序的所有代码和数据,并且可以直接在计算机上运行。
通过这四个阶段(预处理、编译、汇编和链接),GCC将C源文件test.c转换为一个可执行文件a.out。每个阶段都有其特定的任务和目标,共同确保最终生成的可执行文件是正确的、可运行的。
我们首先要知道的是程序想要运行,就需要把指令和数据存入内存中
但是我们用户所用的内存并没有那么大,此时怎么办呢?
我们将程序常用的部分存在内存中,而不常用的存在磁盘中
那么我们常用的动态装载有两种,即覆盖装入和页映射
这种方式比较老日,我们只提一下大概原理
所谓的覆盖装载即是远古程序员在写程序的时候,将我们的程序分成若干个小块
之后还要写一下辅助代码,这个辅助代码的作用,即是确定我们程序的这些模块什么时候要驻留在内存里,什么时候要被替换
掉而这些都是需要程序员自己确定的
页映射随着我们虚拟存储的发明而诞生
它将我们的内存和磁盘中的数据和指令按”页”为单位划分成了若干个页
因此,我们装载程序和操作的单位也就是页了,我们拿一张图来说明

为了能使我们的共享对象在任意地址装载,我们需要使用我们的重定位的方法
也就是利用我们的相对地址不变的逻辑,即假设我们的A0函数相对于代码段的地址为0xff
那么假设我们代码段被加载到了0x10000的地方,那么我们的A0)函数就应该在0x100ff的地方
但这样会导致我们共享库的内容无法被共享,这显然是不合适的
我们的地址无关代码就是让我们的程序中那些共享的指令在装载的时候不用因为我们装载地址的不同而改变那么如何实现呢?
也很简单,就是把我们指令中那些需要被修改的部分给分离出来,然后和数据部分放到一起
根据不同的调用需求和情况,我们大致将地址的引用分为了四种情况:
模块内部的函数调用和跳转
模块内部数据的访问
模块外部的函数调用和跳转
模块外部数据的访问
那么第一种模块内的函数调用,我们可以通过相对偏移量来进行指令的调用
然后我们来看看模块内的数据访问,我们首先要清楚的是我们任意指令和他要访问的模块内的数据的相对位置是确定的那么我们只需要知道当前指令的地址和偏移量即可访问啦
模块间数据访问:这里我们引入一个特殊的数组,GOT表(全局偏移表),这个表会存储指向这些变量的指针
模块间函数调用:模块间函数调用也是类似的,我们直接到GOT表去查我们的地址即可
动态链接的基本思想,就是将我们的程序按模块拆成多个相互独立的部分
程序运行时将他们链接到一起变成一个完整的程序
在linux中,共享文件就是我们以后会常用到的以.so文件结尾的库文件了
而实现这个过程的,一般被称为我们的动态链接器
在程序装载的时候,系统的动态链接器就会将我们程序用到的共享库装载到进程的虚拟空间
这么看的话是不是效率低下呢?那么我们就来看看如何来避免这个情况,即我们的延迟绑定技术
延迟绑定:
这里有人可能会说我们的全局偏移表(GOT表)去哪了
首先要知道在ELF文件中,我们的GOT表被分为了.got表和.got.plt两个表,其中我们的.got表保存的是全局变量引用的地址而我们的.got.plt则用于保存函数引用的地址,其中保存三项内容:
第一项是,dynamic段的地址,该段描述了本模块动态链接的信息
第二项是本模块的ID
第三项保存了 dl runtime resolve函数的地址
那么我们的延迟绑定的基本思想就是当我们的函数第一次被用到的时候才进行绑定
这个绑定就是进行符号查找和重定位等操作
这里我们首先来思考,如果我们是写程序的人,那么我们想做这个绑定操作需要知道什么信息呢?
我们举一个例子:
Libc.so文件中有一个system函数,那么现在程序第-次调用system函数了
此时我们有一个函数来负责我们的延迟绑定,他就是我们的 dl runtime resolve函数
我们先来看看这个函数:dl _runtime_resolve(link_map_obj,reloc_index)
那么我们需要传入的,就是libc.so和system,然后该怎么做呢?
Linux为了解决这个问题,在直接从GOT跳转到函数中间加了一个小部分,即PLT表,这里我们页来举一个plt表的例子
example@plt:
Jmp \*(example@GOT)
Push n
Jump dl runtime resolve
那么这里面的N和ID又是什么呢?
这里就不得不提重定位表.rel.,plt了,这个n就是我们所要调用函数的下标了,而我们的 dlruntime resolve函数会将我们函数的真实地址存入我们的got表,这也就意味着下一次我们调用的时候,就可以直接从GOT表跳转到函数的真实地址了
在编译、链接与库的上下文中,“库”指的是一组预先编写好的、可重用的代码集合。这些代码集合通常被打包成库文件,以便在软件开发过程中被程序员调用和使用。库是软件开发中常见的组织和管理代码的方式,广泛应用于各种操作系统和编程语言。
库主要分为两种类型:静态库和动态库。


库的作用
库在软件开发中起着至关重要的作用,它们提供了许多常用的、标准化的功能,如数学计算、字符串处理、文件操作等。通过使用库,程序员可以避免重复编写相同的代码,提高代码的复用性和开发效率。同时,库还可以帮助程序员遵循最佳实践和标准,提高代码的质量和可维护性。
链接过程与库
在编译和链接过程中,链接器负责将目标文件(由编译器生成)和库文件链接在一起,形成一个完整的可执行文件。链接过程主要解决模块间相互引用问题,包括地址和空间分配、符号解析和重定位等步骤。在链接时,链接器会根据目标文件和库文件中的符号名称和类型,找到相应的符号定义,并将它们链接在一起。
链接装载和库在软件开发中紧密相连。库是包含可重用代码的文件,链接器在编译时将库中的代码与目标文件结合,形成可执行文件。装载则是将可执行文件加载到内存中运行的过程。三者共同确保软件能够正确编译、链接并运行。