编译
预编译
首先是源代码文件和相关的头文件,如 stdio.h 等被预编译器 cpp 预编译成一个 .i 文件。
对于 C++ 程序来说
源代码文件的扩展名可能是 .cpp 或 .cxx
头文件的扩展名可能是 .hpp
预编译 后的文件扩展名是 .i
第一步预编译的过程相当于如下命令( -E 表示只进行预编译):
gcc -E hello.c -o hello.i 或者: cpp hello.c > hello.i
预编译过程主要处理那些源代码文件中的以 # 开始的预编译指令。比如 #include 、 #define 等,主要处理规则如下:
- 将所有的 #define 删除,并且展开所有的宏定义。
- 处理所有条件预编译指令,比如 #if 、 #ifdef 、 #elif 、 #else 、 #endif 。
- 处理 #include 预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递 归进行的,也就是说被包含的文件可能还包含其他文件。
- 删除所有的注释 // 和 /* */ 。
- 添加行号和文件名标识,比如 #2"hello.c"2 ,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
- 保留所有的 #pragma 编译器指令,因为编译器须要使用它们。
经过预编译后的 .i 文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入 到 .i 文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题。
编译
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。 上面的编译过程相当于如下命令:
gcc -S hello.i -o hello.s
编译成更容易看的汇编代码的命令是
gcc -S hello.i -o hello.s -masm=intel
汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来源于此。 上面的汇编过程我们可以调用汇编器 as 来完成:
as hello.s -o hello.o
或者使用 gcc 命令从 C 源代码文件开始,经过预编译、编译和汇编直接输出目标文件(Object File):
gcc -c hello.c -o hello.o
静态链接
静态链接是在编译过程的最后阶段将多个目标文件(如 .o 文件)以及所需的库文件合并在一起,生成最终的可执行文件或共享库的过程。 可以使用如下命令将 a.o 和 b.o 链接为目标文件 ab 。
ld a.o b.o -o ab
gcc hello.o world.o
合并代码和数据段(Code and Data Segment Merging)
链接器将多个目标文件中的代码段和数据段合并成一个更大的代码段和数据段。这样,所有的目标文件中的代码和数据都会被整合到最终的可执行文件或静态库中。
readelf -S a.out #节表
readelf -l a.out #程序头表
符号解析(Symbol Resolution)
链接器负通过重定位表解析目标文件中的符号引用。每个目标文件都包含对其他目标文件或库中定义的符号的引用,例如函数、变量等。链接器会检查这些引用并确定对应的定义位置。
对于可重定位的 ELF 文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的 ELF 段都有一个对应的重定位表,而一个重定位表往往就是 ELF 文件中的一个段,所以其实重定位表也可以叫重定位段。
比如代码段 .text 如有要被重定位的地方,那么会有一个相对应叫 .rel.text 的段保存了代码段的重定位表;如果代码段 .data 有要被重定位的地方,就会有一个相对应叫 .rel.data 的段保存了数据段的重定位表。
链接器通过 Elf32_Rel 的 r_offset 加上所在段的起始位置得到重定位入口的位置;通过 r_info 的低 8 为得知重定位类型;通过 r_info 的高 24 位得到重定位符号在符号表( .symtab )中的下标。
readelf -r a.out #重定位
readelf -s a.out #符号表
符号重定位(Symbol Relocation)
链接器通过符号表对应的 Elf32_Rel 的 st_value 表示该符号在段中的偏移,进而可以根据重定位类型计算出重定位入口所要修正的值。最后将对应的重定位入口 patch 成正确的值。32 位静态链接常用到的重定位类型如下:
R_386_32 :绝对地址。
R_386_PC32 :相对于当前指令地址的下一条指令相对地址。
readelf -r a.out #重定位
解析库依赖关系(Library Dependency Resolution)
如果目标文件依赖于外部库文件(如标准库或其他第三方库),链接器会解析这些库的依赖关系,并将所需的库文件链接到最终的可执行文件或静态库中。这样,在运行时,可执行文件或静态库就能够访问和使用这些库中提供的功能。
/lib/x86_64-linux-gnu/libc.a
生成重定位表(Relocation Table)
链接器生成重定位表,记录了需要进行符号重定位的位置和相关信息。这些重定位表将在最终的可执行文件或静态库中被使用,以便在加载和执行时进行正确的符号重定位。
readelf -r a.out #重定位
制作签名文件
sudo cp /lib/x86_64-linux-gnu/libc.a
./pelf libc.a libc-2.31.pat
./sigmake ./libc-2.31.pat libc-2.31.sig
装载
Linux 内核装载 ELF 过程
首先在用户层面,bash 进程会调用 fork() 系统调用创建一个新的进程,然后新的进程调用execve() 系统调用执行指定的 ELF 文件,原先的 bash 进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。 execve() 系统调用被定义在 unistd.h ,它的原型如下:
int execve (const char *__path, char *const __argv[], char *const __envp[]);
它的三个参数分别是被执行的程序文件名、执行参数和环境变量。在 linux 程序中,通过调用 execve,进程能够以全新程序来替换当前运行的程序。在此过程中,将丢弃旧有程序,进程的栈,数据以及堆段会被新程序所替换。
Glibc 对 execvp() 系统调用进行了包装,提供了 execl() 、 execlp() 、 execle() 、 execv() 和execvp() 等5个不同形式的 exec 系列 API ,它们只是在调用的参数形式上有所区别,但最终都会调用到 execve() 这个系统调用。
在进入 execve() 系统调用之后,Linux 内核就开始进行真正的装载工作.
- 在内核中, excve() 系统调用相应的入口是 sys_execve(), 它被定义在 archi386kernelProcess.c 。 sys_execve() 进行一些参数的检查复制之后,调用 do_execve() 。
- do_execve() 会首先查找被执行的文件,如果找到文件,则 do_execve() 读取文件的前128个字节判断文件的格式
- 每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称做魔数(Magic Number),通过对魔数的判断可以确定文件的格式和类型。
- 比如 ELF 的可执行文件格式的头 4 个字节为 x7felf ;
- 而 Java 的可执行文件格式的头4个字节为 cafe ;
- 如果被执行的是 Shell 脚本或 perl 、python 等这种解释型语言的脚本,那么 它的第一行往往是 #!/bin/sh 或 #!/usr/bin/perl 或 #!/usr/bin/python ,这时候前两个字节 #和 ! 就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
- 然后调用 search_binary_handle() 去搜索和匹配合适的可执行文件装载处理过程。Linux中所有被支持的可执行文件格式都有相应的装载处理过程, search_binary_handle() 会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。
- 比如 ELF 可执行文件的装载处理过程叫做 load_elf_binary() ; a.out 可执行文件的装载处理过程叫做 load_aout_binary() ;而装载可执行脚本程序的处理过程叫做 load_script() 。
这里我们只关心 ELF 可执行文件的装载, load_elf_binary() 被定义在 fs/Binfmt_elf.c ,这个函数的代码比较长,它的主要步骤是:
- 检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
- 寻找动态链接的 .interp 段,设置动态链接器路径。
- 根据 ELF 可执行文件的程序头表的描述,对 ELF 文件进行映射,比如代码、数据、只读数据。
- 初始化 ELF 进程环境,比如进程启动时 EDX 寄存器的地址应该是 DT_FINI 的地址(参照动态链接).
- 将系统调用的返回地址修改成 ELF 可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的 ELF 可执行文件,这个程序入口就是 ELF 文件的文件头中 e_entry 所指的地址;对于动态链接的 ELF 可执行文件,程序入口点是动态链接器。
当 load_elf_binary() 执行完毕,返回至 do_execve() 再返回至 sys_execve() 时,上面的第 5步中已经把系统调用的返回地址改成了被装载的 ELF 程序的入口地址了。所以当 sys_execve() 系统调用从内核态返回到用户态时,EIP 寄存器直接跳转到了 ELF 程序的入口地址,于是新的程序开始执行,ELF 可执行文件装载完成。
虚拟地址空间
在现代操作系统中,每个进程都有自己的虚拟地址空间,这是一个抽象的地址空间,由连续的虚拟地址组成。每个进程在其虚拟地址空间中运行,不会直接访问物理内存地址。
操作系统将每个进程的虚拟地址空间划分为多个区域,例如代码段、数据段、堆和栈等。每个区域具有特定的用途和权限。
代码段:包含可执行程序的机器指令。
数据段:包含静态和全局变量的。
动态链接段:包含动态链接所需的信息。
加载器将这些段从 ELF 文件中复制到相应的虚拟内存地址,并建立虚拟地址与物理内存地址的映射关系。