四、execve 加载用户程序具体加载可执行文件的工作是由 execve 系统调用来完成的。
该系统调用会读取用户输入的可执行文件名,参数列表以及环境变量等开始加载并运行用户指定的可执行文件。该系统调用的位置在 fs/exec.c 文件中。
//file:fs/exec.csyscall_define3(execve, const char __user *, filename, ...){ struct filename *path = getname(filename); do_execve(path->name, argv, envp) ...}int do_execve(...){ ... return do_execve_common(filename, argv, envp);}execve 系统调用到了 do_execve_common 函数。我们来看这个函数的实现。
//file:fs/exec.cstatic int do_execve_common(const char *filename, ...){ //linux_binprm 结构用于保存加载二进制文件时使用的参数 struct linux_binprm *bprm; //1.申请并初始化 brm 对象值 bprm = kzalloc(sizeof(*bprm), gfp_kernel); bprm->file = ...; bprm->filename = ...; bprm_mm_init(bprm) bprm->argc = count(argv, max_arg_strings); bprm->envc = count(envp, max_arg_strings); prepare_binprm(bprm); ... //2.遍历查找合适的二进制加载器 search_binary_handler(bprm);}这个函数中申请并初始化 brm 对象的具体工作可以用下图来表示。
在这个函数中,完成了一下三块工作。
第一、使用 kzalloc 申请 linux_binprm 内核对象。该内核对象用于保存加载二进制文件时使用的参数。在申请完后,对该参数对象进行各种初始化。
第二、在 bprm_mm_init 中会申请一个全新的 mm_struct 对象,准备留着给新进程使用。
第三、给新进程的栈申请一页的虚拟内存空间,并将栈指针记录下来。
第四、读取二进制文件头 128 字节。
我们来看下初始化栈的相关代码。
//file:fs/exec.cstatic int __bprm_mm_init(struct linux_binprm *bprm){ bprm->vma = vma = kmem_cache_zalloc(vm_area_cachep, gfp_kernel); vma->vm_end = stack_top_max; vma->vm_start = vma->vm_end - page_size; ... bprm->p = vma->vm_end - sizeof(void *);}在上面这个函数中申请了一个 vma 对象(表示虚拟地址空间里的一段范围),vm_end 指向了 stack_top_max(地址空间的顶部附近的位置),vm_start 和 vm_end 之间留了一个 page 大小。 也就是说默认给栈申请了 4kb 的大小 。最后把栈的指针记录到 bprm->p 中。
另外再看下 prepare_binprm,在这个函数中,从文件头部读取了 128 字节。之所以这么干,是为了读取二进制文件头为了方便后面判断其文件类型。
//file:include/uapi/linux/binfmts.h#define binprm_buf_size 128//file:fs/exec.cint prepare_binprm(struct linux_binprm *bprm){ ...... memset(bprm->buf, 0, binprm_buf_size); return kernel_read(bprm->file, 0, bprm->buf, binprm_buf_size);}在申请并初始化 brm 对象值完后,最后使用 search_binary_handler 函数遍历系统中已注册的加载器,尝试对当前可执行文件进行解析并加载。
在 3.1 节我们介绍了系统所有的加载器都注册到了 formats 全局链表里了。函数 search_binary_handler 的工作过程就是遍历这个全局链表,根据二进制文件头中携带的文件类型数据查找解析器。找到后调用解析器的函数对二进制文件进行加载。
//file:fs/exec.cint search_binary_handler(struct linux_binprm *bprm){ ... for (try=0; tryload_binary; ... retval = fn(bprm); //加载成功的话就返回了 if (retval >= 0) { ... return retval; } //加载失败继续循环以尝试加载 ... } }}在上述代码中的 list_for_each_entry 是在遍历 formats 这个全局链表,遍历时判断每一个链表元素是否有 load_binary 函数。有的话就调用它尝试加载。
回忆一下 3.1 注册可执行文件加载程序,对于 elf 文件加载器 elf_format 来说, load_binary 函数指针指向的是 load_elf_binary。
//file:fs/binfmt_elf.cstatic struct linux_binfmt elf_format = { .module = this_module, .load_binary = load_elf_binary, ......};那么加载工作就会进入到 load_elf_binary 函数中来进行。这个函数很长,可以说所有的程序加载逻辑都在这个函数中体现了。我根据这个函数的主要工作,分成以下 5 个小部分来给大家介绍。
在介绍的过程中,为了表达清晰,我会稍微调一下源码的位置,可能和内核源码行数顺序会有所不同。
4.1 elf 文件头读取在 load_elf_binary 中首先会读取 elf 文件头。
文件头中包含一些当前文件格式类型等数据,所以在读取完文件头后会进行一些合法性判断。如果不合法,则退出返回。
//file:fs/binfmt_elf.cstatic int load_elf_binary(struct linux_binprm *bprm){ //4.1 elf 文件头解析 //定义结构题并申请内存用来保存 elf 文件头 struct { struct elfhdr elf_ex; struct elfhdr interp_elf_ex; } *loc; loc = kmalloc(sizeof(*loc), gfp_kernel); //获取二进制头 loc->elf_ex = *((struct elfhdr *)bprm->buf); //对头部进行一系列的合法性判断,不合法则直接退出 if (loc->elf_ex.e_type != et_exec && ...){ goto out; } ...}4.2 program header 读取在 elf 文件头中记录着 program header 的数量,而且在 elf 头之后紧接着就是 program header tables。所以内核接下来可以将所有的 program header 都读取出来。
//file:fs/binfmt_elf.cstatic int load_elf_binary(struct linux_binprm *bprm){ //4.1 elf 文件头解析 //4.2 program header 读取 // elf_ex.e_phnum 中保存的是 programe header 数量 // 再根据 program header 大小 sizeof(struct elf_phdr) // 一起计算出所有的 program header 大小,并读取进来 size = loc->elf_ex.e_phnum * sizeof(struct elf_phdr); elf_phdata = kmalloc(size, gfp_kernel); kernel_read(bprm->file, loc->elf_ex.e_phoff, (char *)elf_phdata, size); ...}4.3 清空父进程继承来的资源在 fork 系统调用创建出来的进程中,包含了不少原进程的信息,如老的地址空间,信号表等等。这些在新的程序运行时并没有什么用,所以需要清空处理一下。
具体工作包括初始化新进程的信号表,应用新的地址空间对象等。
//file:fs/binfmt_elf.cstatic int load_elf_binary(struct linux_binprm *bprm){ //4.1 elf 文件头解析 //4.2 program header 读取 //4.3 清空父进程继承来的资源 retval = flush_old_exec(bprm); ... current->mm->start_stack = bprm->p;}在清空完父进程继承来的资源后(当然也就使用上了新的 mm_struct 对象),这之后,直接将前面准备的进程栈的地址空间指针设置到了 mm 对象上。这样将来栈就可以被使用了。
4.4 执行 segment 加载接下来,加载器会将 elf 文件中的 load 类型的 segment 都加载到内存里来。使用 elf_map 在虚拟地址空间中为其分配虚拟内存。最后合适地设置虚拟地址空间 mm_struct 中的 start_code、end_code、start_data、end_data 等各个地址空间相关指针。
我们来看下具体的代码:
//file:fs/binfmt_elf.cstatic int load_elf_binary(struct linux_binprm *bprm){ //4.1 elf 文件头解析 //4.2 program header 读取 //4.3 清空父进程继承来的资源 //4.4 执行 segment 加载过程 //遍历可执行文件的 program header for(i = 0, elf_ppnt = elf_phdata; i elf_ex.e_phnum; i++, elf_ppnt++) { //只加载类型为 load 的 segment,否则跳过 if (elf_ppnt->p_type != pt_load) continue; ... //为 segment 建立内存 mmap, 将程序文件中的内容映射到虚拟内存空间中 //这样将来程序中的代码、数据就都可以被访问了 error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt, elf_prot, elf_flags, 0); //计算 mm_struct 所需要的各个成员地址 start_code = ...; start_data = ... end_code = ...; end_data = ...; ... } current->mm->end_code = end_code; current->mm->start_code = start_code; current->mm->start_data = start_data; current->mm->end_data = end_data; ...}其中 load_bias 是 segment 要加载到内存里的基地址。这个参数有这么几种可能
值为 0,就是直接按照 elf 文件中的地址在内存中进行映射值为对齐到整数页的开始,物理文件中可能为了可执行文件的大小足够紧凑,而不考虑对齐的问题。但是操作系统在加载的时候为了运行效率,需要将 segment 加载到整数页的开始位置处。4.5 数据内存申请&堆初始化因为进程的数据段需要写权限,所以需要使用 set_brk 系统调用专门为数据段申请虚拟内存。
//file:fs/binfmt_elf.cstatic int load_elf_binary(struct linux_binprm *bprm){ //4.1 elf 文件头解析 //4.2 program header 读取 //4.3 清空父进程继承来的资源 //4.4 执行 segment 加载过程 //4.5 数据内存申请&堆初始化 retval = set_brk(elf_bss, elf_brk); ......}在 set_brk 函数中做了两件事情:第一是为数据段申请虚拟内存,第二是将进程堆的开始指针和结束指针初始化一下。
//file:fs/binfmt_elf.cstatic int set_brk(unsigned long start, unsigned long end){ //1.为数据段申请虚拟内存 start = elf_pagealign(start); end = elf_pagealign(end); if (end > start) { unsigned long addr; addr = vm_brk(start, end - start); } //2.初始化堆的指针 current->mm->start_brk = current->mm->brk = end; return 0;}因为程序初始化的时候,堆上还是空的。所以堆指针初始化的时候,堆的开始地址 start_brk 和结束地址 brk 都设置成了同一个值。
4.6 跳转到程序入口执行在 elf 文件头中记录了程序的入口地址。如果是非动态链接加载的情况,入口地址就是这个。
但是如果是动态链接,也就是说存在 interp 类型的 segment,由这个动态链接器先来加载运行,然后再调回到程序的代码入口地址。
# readelf --program-headers helloworld......program headers: type offset virtaddr physaddr filesiz memsiz flags align interp 0x00000000000002a8 0x00000000004002a8 0x00000000004002a8 0x000000000000001c 0x000000000000001c r 0x1 [requesting program interpreter: /lib64/ld-linux-x86-64.so.2]对于是动态加载器类型的,需要先将动态加载器(本文示例中是 ld-linux-x86-64.so.2 文件)加载到地址空间中来。
加载完成后再计算动态加载器的入口地址。这段代码我展示在下面了,没有耐心的同学可以跳过。反正只要知道这里是计算了一个程序的入口地址就可以了。
//file:fs/binfmt_elf.cstatic int load_elf_binary(struct linux_binprm *bprm){ //4.1 elf 文件头解析 //4.2 program header 读取 //4.3 清空父进程继承来的资源 //4.4 执行 segment 加载 //4.5 数据内存申请&堆初始化 //4.6 跳转到程序入口执行 //第一次遍历 program header table //只针对 pt_interp 类型的 segment 做个预处理 //这个 segment 中保存着动态加载器在文件系统中的路径信息 for (i = 0; i elf_ex.e_phnum; i++) { ... } //第二次遍历 program header table, 做些特殊处理 elf_ppnt = elf_phdata; for (i = 0; i elf_ex.e_phnum; i++, elf_ppnt++){ ... } //如果程序中指定了动态链接器,就把动态链接器程序读出来 if (elf_interpreter) { //加载并返回动态链接器代码段地址 elf_entry = load_elf_interp(&loc->interp_elf_ex, interpreter, &interp_map_addr, load_bias); //计算动态链接器入口地址 elf_entry += loc->interp_elf_ex.e_entry; } else { elf_entry = loc->elf_ex.e_entry; } //跳转到入口开始执行 start_thread(regs, elf_entry, bprm->p); ...}五、总结看起来简简单单的一行 helloworld 代码,但是要想把它运行过程理解清楚可却需要非常深厚的内功的。
本文首先带领大家认识和理解了二进制可运行 elf 文件格式。在 elf 文件中是由四部分组成,分别是 elf 文件头 (elf header)、program header table、section 和 section header table。
linux 在初始化的时候,会将所有支持的加载器都注册到一个全局链表中。对于 elf 文件来说,它的加载器在内核中的定义为 elf_format,其二进制加载入口是 load_elf_binary 函数。
一般来说 shell 进程是通过 fork + execve 来加载并运行新进程的。执行 fork 系统调用的作用是创建一个新进程出来。不过 fork 创建出来的新进程的代码、数据都还是和原来的 shell 进程的内容一模一样。要想实现加载并运行另外一个程序,那还需要使用到 execve 系统调用。
在 execve 系统调用中,首先会申请一个 linux_binprm 对象。在初始化 linux_binprm 的过程中,会申请一个全新的 mm_struct 对象,准备留着给新进程使用。还会给新进程的栈准备一页(4kb)的虚拟内存。还会读取可执行文件的前 128 字节。
接下来就是调用 elf 加载器的 load_elf_binary 函数进行实际的加载。大致会执行如下几个步骤:
elf 文件头解析program header 读取清空父进程继承来的资源,使用新的 mm_struct 以及新的栈执行 segment 加载,将 elf 文件中的 load 类型的 segment 都加载到虚拟内存中为数据 segment 申请内存,并将堆的起始指针进行初始化最后计算并跳转到程序入口执行
当用户进程启动起来以后,我们可以通过 proc 伪文件来查看进程中的各个 segment。
# cat /proc/46276/maps00400000-00401000 r--p 00000000 fd:01 396999 /root/work_temp/helloworld00401000-00402000 r-xp 00001000 fd:01 396999 /root/work_temp/helloworld00402000-00403000 r--p 00002000 fd:01 396999 /root/work_temp/helloworld00403000-00404000 r--p 00002000 fd:01 396999 /root/work_temp/helloworld00404000-00405000 rw-p 00003000 fd:01 396999 /root/work_temp/helloworld01dc9000-01dea000 rw-p 00000000 00:00 0 [heap]7f0122fbf000-7f0122fc1000 rw-p 00000000 00:00 0 7f0122fc1000-7f0122fe7000 r--p 00000000 fd:01 1182071 /usr/lib64/libc-2.32.so7f0122fe7000-7f0123136000 r-xp 00026000 fd:01 1182071 /usr/lib64/libc-2.32.so......7f01231c0000-7f01231c1000 r--p 0002a000 fd:01 1182554 /usr/lib64/ld-2.32.so7f01231c1000-7f01231c3000 rw-p 0002b000 fd:01 1182554 /usr/lib64/ld-2.32.so7ffdf0590000-7ffdf05b1000 rw-p 00000000 00:00 0 [stack]......虽然本文非常的长,但仍然其实只把大体的加载启动过程串了一下。如果你日后在工作学习中遇到想搞清楚的问题,可以顺着本文的思路去到源码中寻找具体的问题,进而帮助你找到工作中的问题的解。
最后提一下,细心的读者可能发现了,本文的实例中加载新程序运行的过程中其实有一些浪费,fork 系统调用首先将父进程的很多信息拷贝了一遍,而 execve 加载可执行程序的时候又是重新赋值的。所以在实际的 shell 程序中,一般使用的是 vfork。其工作原理基本和 fork 一致,但区别是会少拷贝一些在 execve 系统调用中用不到的信息,进而提高加载性能。
广东移动实现江湛铁路移动网络全覆盖
多路人脸识别解决方案
关于电导检测技术的简单说明(2)
关于英飞凌完善互联设备的语音用户界面的介绍
ABLIC推出通用工业设备的霍尔效应IC——S-576Z R系列
你知道你写的代码是怎样跑起来的吗(下)
电路设计中的“热地”和“冷地”
Atmosic公司成立五周年,用 “芯”赋能绿色经济
推展Matter成绩卓著,获颁CSA联盟中国成员组年度贡献奖
扫地机器人,十大知名品牌排行榜公布
使用AI来自动智能地管理投资者投资组合
自动驾驶领域火热,德尔福宣布将参与新加坡政府的自动驾驶租车测试计划
基于PIC16F877A和CC2500的RFID局域定位系统设计
led节能灯DIY电路图
NLP 2019 Highlights 给NLP从业者的一个参考
VRMMORPG游戏《Zenith》众筹获17万美元,总融资额已超30万美元
ERP系统到底有什么作用能改变一个企业
红人街软件app开发定制
小鹏汽车宣布2月实现电动汽车交付2223台
简易实用的模拟温控电路设计