本篇文章通过将execve系统调用添加到系统menu中,来说明在Linux系统中加载和执行一个可执行程序的流程。
相关知识:
首先关于这篇文章会介绍一些用到的知识。
一、编译系统
编译系统由四个阶段的程序构成。
二、虚拟存储器是建立在主存--辅存物理结构基础上,有附加的硬件装置及操作系统存储管理软件组成的一种存储体系。三个重要功能:
a) 高效的使用主存。
b) 简化存储器管理。
c) 保护每个进程的地址空间不被其他进程破坏。
三、链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可以被加载(或者拷贝)到存储器并执行。现在的链接是由叫做链接器的程序自动执行。链接可以分为三种情形:1、编译时链接,也就是我们常说的静态链接;2、装载时链接;3、运行时链接。装载时链接和运行时链接合称为动态链接。
四、静态链接
以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。链接器有两个任务:
a) 符号解析:目标文件定义和引用符号。
b) 重定位:编译器和汇编器生成从地址0开始的代码和数据节。链接后可执行文件中的各个段的虚拟地址都已经确定。链接器就修改所有对这些符号的引用,从而重定位这些节。
五、目标文件
a) 可重定位目标文件:包括二进制代码和数据。(形式name.o)
b) 可执行目标文件:包括二进制代码和数据。可以拷贝到存储器并执行。(形式name.out)
c) 共享目标文件:一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载到存储器并链接。
六、可重定位目标文件
下面我们看一下ELF头:以一个16字节的序列开始。
详细内容请参考深入理解计算机体系第七章。
实验过程:
环境的配置,以及gdb的使用,请参考我的上篇博客:《Linux操作系统分析》之分析Linux内核创建一个新进程的过程。
下面是实验过程中的一些截图:
设置断点进行跟踪:
运行时断点执行的过程如上
可以查看hello的ELF头内容,如下图:
分析:
当我们要运行可执行目标文件exec,如果是在Linux中的话,外壳会认为exec是一个可执行文件,通过调用某个驻留在存储器中称为加载器(loader)的操作系统代码来运行它。这个调用器可以通过execve函数来调用。加载器将可执行目标文件的代码和数据拷贝到存储器,然后通过跳转到程序的第一条指令来运行程序。
当加载器运行时,它的存储器映像如图:
对于32位的Linux机器来说,代码段的开始总是从地址0x08048000开始的。
我们从exec函数调用开始分析,execve对应的系统调用是sys_execve,系统调用陷入内核中时,sys_execve被调用,涉及的主要函数为:
do_execve -> do_execve_common -> exec_binprm-> search_binary_handler -> load_elf_binary -> start_thread -> sys_close
我们顺序的往下分析。
sys_call
SYSCALL_DEFINE3(execve, const char __user *, filename, const char __user *const __user *, argv, const char __user *const __user *, envp) { return do_execve(getname(filename), argv, envp); }
然后调用了do_execve
int do_execve(struct filename *filename, const char __user *const __user *__argv, const char __user *const __user *__envp) { struct user_arg_ptr argv = { .ptr.native = __argv };//复制环境变量和参数信息 struct user_arg_ptr envp = { .ptr.native = __envp }; return do_execve_common(filename, argv, envp); }然后执行 do_execve_common
static int do_execve_common(struct filename *filename, struct user_arg_ptr argv, struct user_arg_ptr envp) { struct linux_binprm *bprm; struct file *file; struct files_struct *displaced; int retval; if (IS_ERR(filename)) //检查文件其有效性 return PTR_ERR(filename); //省略 //将文件名、环境变量和命令行参数拷贝到新分配的页面中 retval = copy_strings_kernel(1, &bprm->filename, bprm); if (retval < 0) goto out; bprm->exec = bprm->p; retval = copy_strings(bprm->envc, envp, bprm); if (retval < 0) goto out; retval = copy_strings(bprm->argc, argv, bprm); if (retval < 0) goto out; //调用exec_binprm,保存当前的pid并且调用 search_binary_handler retval = exec_binprm(bprm); if (retval < 0) goto out; /* execve succeeded */ current->fs->in_exec = 0; current->in_execve = 0; acct_update_integrals(current); task_numa_free(current); free_bprm(bprm); putname(filename); if (displaced) put_files_struct(displaced); return retval; //省略一些函数 }在 do_execve_common 中调用了retval = exec_binprm(bprm);,这个函数的作用是调用exec_binprm,保存当前的pid并且调用 search_binary_handler。
int search_binary_handler(struct linux_binprm *bprm) { bool need_retry = IS_ENABLED(CONFIG_MODULES); struct linux_binfmt *fmt; int retval; /* This allows 4 levels of binfmt rewrites before failing hard. */ if (bprm->recursion_depth > 5)//如果递归的深度大于5 则失败 return -ELOOP; retval = security_bprm_check(bprm); if (retval) return retval; retval = -ENOENT; retry: read_lock(&binfmt_lock);//加锁 list_for_each_entry(fmt, &formats, lh) { if (!try_module_get(fmt->module)) continue; read_unlock(&binfmt_lock); bprm->recursion_depth++; retval = fmt->load_binary(bprm);//加载ELF文件 read_lock(&binfmt_lock); put_binfmt(fmt); bprm->recursion_depth--; if (retval < 0 && !bprm->mm) { /* we got to flush_old_exec() and failed after it */ read_unlock(&binfmt_lock); force_sigsegv(SIGSEGV, current); return retval; } if (retval != -ENOEXEC || !bprm->file) { read_unlock(&binfmt_lock); return retval; } } read_unlock(&binfmt_lock);//解锁 //省略 return retval; }
对于ELF文件,retval = fmt->load_binary(bprm)实际上执行的就是load_elf_binary,其内部就是按照ELF文件格式来加载ELF文件的。这里,我们也可以看到Linux是可以支持多种可执行文件格式的,所有的格式处里信息用一个结构体存储在一个链表中,其中的load_binary是一个函数指针,search_binary_handler对应于该中格式的可执行文件的加载方式;要想支持一种新的可执行文件,在init_elf_binfmt里把变量注册进了内核对应的forma链表里面就可以了,此种设计类似观察者模式,具有很好的扩展性。
static int __init init_elf_binfmt(void) { register_binfmt(&elf_format); return 0; }
elf_format 的结构体:
static struct linux_binfmt elf_format = { .module = THIS_MODULE, .load_binary = load_elf_binary, .load_shlib = load_elf_library, .core_dump = elf_core_dump, .min_coredump = ELF_EXEC_PAGESIZE, };
在load_elf_binary中会调用start_thread。将返回地址ip和堆栈地址sp更新为新的程序的地址。其他的不变。
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp) { set_user_gs(regs, 0); regs->fs = 0; regs->ds = __USER_DS; regs->es = __USER_DS; regs->ss = __USER_DS; regs->cs = __USER_CS; regs->ip = new_ip; regs->sp = new_sp; regs->flags = X86_EFLAGS_IF; /* * force it to the iret return path by making it look as if there was * some work pending. */ set_thread_flag(TIF_NOTIFY_RESUME); }当load_elf_binary()执行完毕,返回至do_execve()在返回至sys_execve()时,系统调用的返回地址已经被改写成了被装载的ELF程序的入口地址了。
总结:
一、可执行程序的装载是一个系统调用。可执行程序执行时,由execve系统调用后便陷入到内核态里,而后加载可执行文件,把当前进程的可执行程序覆盖掉,当execve系统调用返回的时,返回的则是新的可执行程序(执行起点main处)。
二、execve函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但是并没有创建一个新的进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有的文件描述符。
三、execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数串即可执行目标的名字。envp变量结构与argv变量一样,不同的是每个环境变量串是形如:“NAME=VALUE”的名字-值对。
四、execve函数与fork函数的差异:只有当出现错误的时候,execve才会返回到调用程序。也就是说execve系统调用和fork系统调用的区别是前者成功不返回,不成功返回-1,后者是返回两次。
备注:
杨峻鹏 + 原创作品转载请注明出处 + 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000