可执行文件的加载

当我们在 Linux 下的bash下输入一个命令执行可执行程序时,

  1. bash 进程会调用fork()创建一个新的进程
  2. 新的进程调用execve()系统调用来执行指定的可执行程序。
  3. bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户的输入。

execve()的系统调用原型:

// filename: 可执行程序文件名
// argv: 执行参数
// envp: 环境变量
int execve(const char *filename, char *const argv[], char *const envp[]);

execve()的执行流程:

  1. 读取ELF头,校验有效性:内核读取文件开头的特殊字节序列,用于标识文件类型。ELF文件的魔数是\x7fELF
  2. 读取程序头表:内核解析ELF文件的程序头表,获取各个Segment的物理/虚拟地址、大小、类型等信息。
  3. 内存映射:内核为这些段在进程的虚拟地址空间为每个Segment分配虚拟内存,使用mmap将ELF文件内容映射到虚拟地址,并根据段属性设置只读、可执行、可写等权限。
  4. 如果可执行文件是动态链接的(.interp节存在):
    1. 内核在ELF文件的.interp节中查找动态链接器位置(通常是/lib64/ld-linux-x86-64.so.2,虽然有.so,但它不是动态库,而是一个纯静态链接的可执行二进制文件)。
    2. 内核将动态连接器加载到进程的虚拟地址空间。
    3. 加载完成后,将控制权交给动态链接器的入口。
    4. 动态连接器获得控制权后,负责加载并重定位所需的动态库及依赖库,完成符号解析和重定位。
    5. 动态链接器跳转到可执行文件的入口点(e_entry,通常是_start函数)。
  5. 如果可执行文件是静态链接链接的(.interp节不存在):
    1. 内核直接将控制权转交给可执行文件的入口点(e_entry,通常是_start函数)。

备注:e_entry字段一直是程序的_start地址,不会变。区别仅在于:

  • 静态链接:内核直接跳转到e_entry。
  • 动态链接:动态链接器准备好后跳转到e_entry。

动态库的加载

动态链接器获得控制权后:

  1. 加载所有依赖的动态库:解析程序和它依赖的所有动态库。根据.dynamic节中的信息,递归地查找和加载主程序依赖的所有共享库。
  2. 映射动态库到进程地址空间:动态链接器使用mmap()将这些共享库映射到进程的虚拟地址空间中。并解析对库中函数的引用,将它们替换为实际的内存地址。
  3. 符号重定位:共享库被加载到随机的虚拟地址上。链接器需要进行 重定位(relocation),来修正主程序和这些库之间所有函数调用和全局变量引用的地址。这是通过解析 ELF 文件的 重定位表(Relocation Table) 来完成的。
  4. 在所有的共享库都加载并完成重定位后,动态链接器会按特定的顺序调用每个库的初始化函数。

符号重定位

1. 可执行文件中的地址

在编译和链接阶段,编译器和链接器就已经为可执行文件中的符号分配好了地址,此时,这些地址是虚拟地址。

  • 如果是地址无关代码:那么这些地址是相对于某个基地址的偏移量。
  • 如果是非地址无关代码:那么这些地址是固定的绝对地址(这里的绝对不是指物理内存地址,而是指它不依赖于相对偏移量,是一个固定的虚拟地址)

2. 地址的转换

当操作系统加载可执行文件到内存中时,会进行地址重定位

  • 加载:操作系统会将代码和数据加载到内存中的某个位置。这个位置是随机的,每次程序运行时都可能不同。
  • 地址映射:操作系统会为该进程创建一个虚拟地址空间,将可执行文件中的逻辑地址映射到这个虚拟地址空间中。这个过程由内存管理单元(MMU)硬件完成。

当程序运行时,它所使用的所有地址都是虚拟地址。这些虚拟地址通过页表最终被转换为物理内存中的物理地址。

可执行文件的运行

  1. 初始化: 链接器完成所有共享库的加载和重定位后,它会调用共享库中的 初始化函数(_init),这些函数通常用于设置库内部的一些全局状态。
  2. 入口点: 链接器最后将控制权转移给主程序的真正入口点。这个入口点通常是C运行库(如 glibc)中的 _start 函数。
  3. 调用main(): _start 函数会进行一些最后的初始化工作,比如设置命令行参数 argc 和 argv,然后调用main() 函数,程序正式开始执行。