编译
从源文件编译链接形成 ELF 文件的过程如下图所示#include <stdio.h>char* hello; int main() { printf(hello); return 0;} #include <stdio.h> extern char* hello="Hello,world!";
编译
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来源于此。或者使用 gcc 命令从 C 源代码文件开始,经过预编译、编译和汇编直接输出目标文件(Object File):gcc -c hello.c -o hello.o
静态链接
静态链接是在编译过程的最后阶段将多个目标文件(如 .o 文件)以及所需的库文件合并在一起,生成最终的可执行文件或共享库的过程。可以使用如下命令将 a.o 和 b.o 链接为目标文件 ab 。ld a.o b.o -o abgcc 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 # 符号表
正在发布程序会给符号表去掉,strip a.outida识别的时候函数就行变成sub这种东西,不去掉是会识别main printf等函数的符号重定位(Symbol Relocation)
链接器通过符号表对应的 Elf32_Rel 的 st_value 表示该符号在段中的偏移,进而可以根据重定位类型计算出重定位入口所要修正的值。最后将对应的重定位入口 patch 成正确的值。32 位静态链接常用到的重定位类型如下:R_386_PC32:相对于当前指令地址的下一条指令相对地址。readelf -r a.out
重定位
解析库依赖关系(Library Dependency Resolution)如果目标文件依赖于外部库文件(如标准库或其他第三方库),链接器会解析这些库的依赖关系,并将所需的库文件链接到最终的可执行文件或静态库中。这样,在运行时,可执行文件或静态库就能够访问和使用这些库中提供的功能。/lib/x86_64-linux-gnu/libc.a
链接器生成重定位表,记录了需要进行符号重定位的位置和相关信息。这些重定位表将在最终的可执行文件或静态库中被使用,以便在加载和执行时进行正确的符号重定位。readelf -r a.out
重定位
制作签名文件
导入之后就可以对去掉符号表的可执行文件选择加载signatures。去修订符号表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 ,它的原型如下: environment ENVP. ARGV and ENVP are terminated by NULL pointers. */int execve (const char *__path, char *const __argv[], char *const __envp[]);
它的三个参数分别是被执行的程序文件名、执行参数和环境变量。在 linux 程序中,通过调用 execve ,进程能够以全新程序来替换当前运行的程序。在此过程中,将丢弃旧有程序,进程的栈,数据以及堆段会被新程序所替换。Glibc 对 execvp() 系统调用进行了包装,提供了 execl() 、execlp() 、execle() 、execv() 和 execvp() 等5个不同形式的 exec 系列 API ,它们只是在调用的参数形式上有所区别,但最终都会调用到 execve() 这个系统调用。下面是一个简单的使用 fork() 和 execlp() 实现的 minibash :#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <sys/wait.h>#include <stdbool.h> #define MAX_COMMAND_LENGTH 1024 int main() { char command[MAX_COMMAND_LENGTH]; while (true) { printf("minibash$ "); fgets(command, sizeof(command), stdin); command[strcspn(command, "n")] = '�'; if (strcmp(command, "exit") == 0) { break; } if (strlen(command) == 0) { continue; } pid_t pid = fork(); if (pid == 0) { if (execlp(command, command, NULL) < 0) { perror("minibash"); exit(1); } } else if (pid > 0) { int status; waitpid(pid, &status, 0); } else { printf("fork errorn"); exit(1); } } return
在进入 execve() 系统调用之后,Linux 内核就开始进行真正的装载工作。在内核中, execve() 系统调用相应的入口是 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 ,这时候前两个字节和 ! 就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
当 do_execve() 读取了这 128 个字节的文件头部之后,然后调用 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 可执行文件装载完成。 进程虚拟地址空间
在现代操作系统中,每个进程都有自己的虚拟地址空间,这是一个抽象的地址空间,由连续的虚拟地址组成。每个进程在其虚拟地址空间中运行,不会直接访问物理内存地址。操作系统将每个进程的虚拟地址空间划分为多个区域,例如代码段、数据段、堆和栈等。每个区域具有特定的用途和权限。数据段:包含静态和全局变量的。(.bss+.data+...)加载器将这些段从 ELF 文件中复制到相应的虚拟内存地址,并建立虚拟地址与物理内存地址的映射关系。