当我们在shell环境中输入ls命令时程序是怎么运行起来了呢?在底层做了哪些事情才输出了我们想要的结果?我们通过对程序的加载与运行过程分析来梳理整个流程,让我们对linux程序的运行过程理解更透彻。
整体流程
- 当我们在bash下输入ls命令时,bash进程首先通过fork系统调用创建子进程。如果我们不做任何改变,fork出的子进程将执行父进程后面的代码。
- 在子进程中我们调用execve系统调用执行我们想要执行的ls进程。原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
execve系统调用流程
execve函数的形式如下:
int execve(const char *filename, char *const argv[], char *const envp[]);
它一共有三个参数,分别是需要执行程序的文件名、执行参数和环境变量。进入execve系统调用后,linux内核就开始进行真正的装载工作。
execve系统调用的函数调用流程如下:
execve--->sys_exeve--->do_execve--->search_binary_handle--->load_elf_binary
- 在do_execve函数中,首先读取可执行文件的头结构,即前128字节。通过该头结构可判断可执行文件的格式,毕竟linux支持多种格式可执行文件。
elf文件的头4个字节分别为0x4f、e、l、f,如下:
- 根据获取的可执行文件格式,调用search函数获取该可执行文件对应的状态处理接口。如elf格式文件的装载处理接口为load_elf_binary。
load_elf_binary函数的主要步骤如下:
- 检查elf可执行文件格式的有效性,如魔数、程序头中段的数量等。
- 解析.interp段获取动态链接器的位置,设置动态链接器的路径。
- 根据可执行文件的程序头表的描述,对elf文件进行映射,如代码、数据、只读数据。
- 初始化elf进程环境。
- 将系统调用的返回地址修改为可执行文件的入口地址,移交cpu的执行权。针对可执行文件类型的不同,其入口地址有两类。
- 若elf可执行文件为静态链接可执行文件,则入口地址即为可执行文件中头结构的entry point address指向地址(即为可执行文件中的_start函数)。
elf文件头结构entry point address:
入口地址0x08048615对应的函数_start:
- 若可执行文件为动态链接可执行文件,入口地址为动态链接器。
可执行文件的.interp段中保存了动态链接器的路径,如下:
当load_elf_binary执行完毕,依次返回至do_execve、sys_execve时,由于上面的步骤5已经将系统调用的返回地址修改为可执行文件的入口地址,因此当完成系统调用从内核态返回到用户态时,cpu将执行elf程序的入口地址,即开始执行新程序,至此elf可执行文件的装载完成。
execve流程总结如下:
从操作系统角度看可执行文件装载
从操作系统角度看可执行文件的加载,该过程主要分如下三步:
- 创建进程的虚拟地址空间。通常就是创建进程的页表,此时还不需要建立虚拟内存到物理内存的映射,到真正使用时再建立映射关系。
- 根据elf可执行文件的程序头(program header)建立可执行文件到虚拟内存的映射,这种映射关系保存到进程task_struct结构体中的vma_area_struct结构中。每一段虚拟内存段称为虚拟内存区域(VMA,virtual memory area)。这一步也是真正意义上的装载。 相关结构体层次关系: task_struct(描述进程) ---> mm_struct(描述进程的用户态内存空间,包括代码段、数据段位置等信息) ---> vma_area_struct(描述一个虚拟内存段,包括该虚拟内存段的起始地址、大小,以及对应的文件和文件中偏移。mmap时通常需要创建一个独立的vma_area_struct结构进行映射) 只有完成了这种elf文件到虚拟内存的映射关系,当程序运行发生缺页异常时,陷入内核后操作系统才能根据这种映射关系找到该地址对应的具体elf文件位置,这样内核分配了物理内存后将该部分文件读取到内存中,并建立虚拟内存到对应物理内存的映射关系。最后返回到用户空间后程序才能继续执行下去。
- 将CPU的指令寄存器设置为elf可执行文件的入口地址,即操作系统将控制权交给可执行文件的入口,开始运行新的程序。
操作系统角度看可执行文件加载流程总结如下:
动态链接器链接步骤
针对动态链接的可执行文件,其运行时需要动态链接器先帮忙完成依赖的动态库加载与重定位,这样该可执行程序才能开始运行。动态链接器的链接步骤如下:
- 实现动态链接器自举,完成动态链接器自身的全局变量和静态变量重定位工作。
- 根据可执行文件的.dynamic段递归获取所有依赖的动态库,并将这些动态库映射到虚拟地址空间,将它们的动态符号表合并到全局符号表。(类似于静态链接的“空间与地址分配”)
- 完成引用的外部符号的解析与重定位,根据动态库的.init段进行初始化,如C++中的全局/静态对象的构造函数。类似的.finit段中代码用来实现C++全局对象的析构操作。(这一步类似于静态链接的“符号解析与重定位”)
- 完成了上面3步后,动态链接器将控制权交给elf可执行文件的入口,即elf可执行文件头结构的Entry point address地址。(该地址实际指向_start函数)
动态链接器的链接步骤总结如下:
进程自身执行过程
当历经千辛万苦终于将控制权转交到elf可执行文件的入口地址后,可执行程序终于要开始正式运行了。在glibc中,elf可执行程序的入口地址为_start函数。这个入口地址由ld连接器的默认脚本指定,我们可以通过相关参数进行设置。
_start函数由汇编实现,其主要完成了_libc_start_main函数的调用。__libc_start_main函数的形式如下:
该函数总共有7个参数,含义分别如下:
- 参数main:即我们通常写的main函数入口地址。
- 参数argc:main函数的参数个数。
- 参数argv:main函数的参数列表。main函数的环境变量参数列表紧跟其后,因此可以通过argv顺带获取。
- 参数init:执行main函数前调用,用于完成一些初始化工作,实际填充的是__libc_csu_init函数。如执行调用C++的构造函数、声明为__attribute((constructor))类型的构造函数。
- 参数fini:执行完main函数调用,用于完成一些结束收尾工作,时机填充的是__libc_csu_fini函数。如执行调用C++的析构函数、声明为__attribute((destructor))类型的析构函数。
- 参数rtld_fini:和动态加载相关的收尾工作。该函数和fini函数一样在main函数执行完后调用。rtld是runtime loader缩写。(即为动态链接器的析构函数)
- stack_end:栈底地址,即栈的最高地址(sp寄存器)。
__libc_start_main函数的主要执行流程如下:
- 调用__cxa_atexit函数(等同于atexit函数)将rtld_fini函数添加到对应链表中,用于在exit函数中调用。(main函数结束后会调用exit函数)
- 执行相关运行环境的初始化工作,如堆、I/O、线程等等。
- 调用__cxa_atexit函数将fini函数添加到对应链表中,用于在exit函数中调用(main函数结束后会调用exit函数)
- 调用init函数执行进程相关的构造函数。
- 调用main函数,执行用户的流程。
- 在main函数执行完后调用exit函数。在exit函数中逐个调用所有相关的析构函数,如上面步骤1、3通过__cxa_atexit函数注册的rtld_fini、fini函数。
相关函数调用流程如下:
elf可执行文件进程自身执行过程总结如下: