date: 2014-10-24 12:09
1 用户空间的编程接口
这部分详情请参考APUE(第2版)第8章。
1.1 六种exec函数
有6种不同的exec函数可供使用,这些函数最终都是通过系统调用execve来实现的:
<unistd.h>
int execl(const char *pathname, const char *arg1, ... /* (char*)0 */ );
int execlp(const char *filename, const char *arg1, ... /* (char*)0 */ );
int execle(const char *pathname, const char *arg1, ...
/* (char*)0, char * const *envp */);
int execv(const char *pathname, char * const argv[]);
int execvp(const char *filename, char * const argv[]);
int execve(const char *pathname, char * const argv[], char * const envp[]);
它们间的关系如下图:
1.2 进程相关的ID
在《进程四要素》中我们简单看了下task_struct结构,其中有6个与进程相关的ID:
ID | 意义 | 备注 |
---|---|---|
uid/gid | 实际用户ID/实际组ID | 我实际上是谁 |
euid/egid | 有效用户ID/有效组ID | 我还具有哪些额外的“特权” |
suid/sgid | 保存的设置用户ID/保存的设置组ID | 由exec函数保存 |
通常进程的有效ID就是用户的实际ID,但当进程执行一个程序文件时(通过execve系统调用),如果可执行文件设置了set-user-ID(设置用户ID)位或set-group-ID(设置组ID)位,那么执行该程序文件的进程,其有效用户ID将被设置为程序文件的所有者ID,其有效组ID将被被设置为程序文件所在组的ID,这样,进程就具有一些额外的“特权“了。同时execve系统调用还会将设置后的有效用户ID保存到“保存的设置用户ID”中(对“保存的设置组ID”也是同样的处理),以方便其他函数使用,比如setuid函数需会根据“保存的设置用户ID”来判断是否可以将进程的有效ID设置为某个指定的用户ID。
2 系统调用execve
这部分我们重点关注下如下问题:
- 子进程是如何摆脱父进程自立门户的?子进程如何摆脱对父进程用户空间的依赖?
- 为什么说execve“一去不复返”?即为什么execve无法返回到(父进程)用户空间调用execve的地方?那么该系统调用返回到用户空间时,又返回到了哪里?
- 有效用户ID及有效组ID的处理。
- 传递给execve系统调用的argv如何传递给可执行文件的入口main函数?
这里假定execve执行的程序文件为aout格式的,具体来说是aout格式中的“非可重入代码”,即可执行程序包含正文段(text)、数据段(data)和未初始化数据段(bss)。虽然aout格式已非主流,elf才是当前流行的可执行程序文件的格式,但elf格式比较复杂,涉及到动态加载(loader)与动态链接(linker),而aout格式相对简单,用来了解上述问题是比较合适的。这些问题的答案同样适用于elf格式(或其他格式)的可执行文件。
系统调用execve的内核入口为sys_execve,定义在<arch/kernel/process.c>中:
asmlinkage int sys_execve(struct pt_regs regs)
{
int error;
char * filename;
filename = getname((char *) regs.ebx);
error = PTR_ERR(filename);
if (IS_ERR(filename))
goto out;
error = do_execve(filename, (char **) regs.ecx, (char **) regs.edx, ®s);
if (error == 0)
current->ptrace &= ~PT_DTRACE;
putname(filename);
out:
return error;
}
regs.ebx保存着系统调用execve的第一个参数,即可执行文件的路径名。因为路径名存储在用户空间中,这里要通过getname拷贝到内核空间中。getname在拷贝文件名时,先申请了一个page作为缓冲,然后再从用户空间拷贝字符串。为什么要申请一个页面而不使用进程的系统空间堆栈?首先这是一个绝对路径名,可能比较长,其次进程的系统空间堆栈大约为7K,比较紧缺,不宜滥用。用完文件名后,在函数的末尾调用putname释放掉申请的那个页面。
sys_execve的核心是调用do_execve函数,传给do_execve的第一个参数是已经拷贝到内核空间的路径名filename,第二个和第三个参数仍然是系统调用execve的第二个参数argv和第三个参数envp,它们代表的传给可执行文件的参数和环境变量仍然保留在用户空间中。
2.1 do_execve主要流程
do_execve定义在<fs/exec.c>中。它的主要流程(忽略掉异常情况的处理)如下:
2.2 linux_binprm结构
可执行文件(目标文件)作为一个文件之外,还有一些其他的专属信息,为了将运行一个可执行文件时所需的信息组织在一起,内核定义了linux_binprm结构,其定义如下:
<include/linux/binfmts.h>
struct linux_binprm{
char buf[BINPRM_BUF_SIZE];
struct page *page[MAX_ARG_PAGES];
unsigned long p; /* current top of mem */
int sh_bang;
struct file * file;
int e_uid, e_gid;
kernel_cap_t cap_inheritable, cap_permitted, cap_effective;
int argc, envc;
char * filename; /* Name of binary */
unsigned long loader, exec;
};
buf用来从可执行文件中读入前128个字节,据此可以判断处可执行文件的类型(比如aout、elf、java、或者脚本等)。
page是一个物理页面指针数组,这些物理页面用来存储execve系统调用中参数argv以及envp所指向的字符串表。数组的size为MAX_ARG_PAGES(32),但具体会分配多少个物理页面,取决于argv已经envp所指向的字符串表的大小。
p用来指向page数组所代表的存储空间的“游标”。
file即可执行文件对应的文件表项。
当可执行文件设置了set-user-ID或者set-group-ID,e_uid和e_gid分别用来存储可执行文件的所有者ID和所在组ID.
filename指向可执行文件的路径(该路径字符串已经拷贝到内核空间)。
2.3 linux_binfmt结构以及search_binary_handler
每一种可执行文件都有对应的“装载器”,用来处理可执行文件的加载甚至是链接,此即linux_binfmt结构。其定义如下:
<include/linux/binfmts.h>
struct linux_binfmt {
struct linux_binfmt * next;
struct module *module;
int (*load_binary)(struct linux_binprm *, struct pt_regs * regs);
int (*load_shlib)(struct file *);
int (*core_dump)(long signr, struct pt_regs * regs, struct file * file);
unsigned long min_coredump; /* minimal dump size */
};
其中关键的是几个函数指针,顾名思义,load_binary用来加载可执行文件;load_shlib用来加载共享库;而core_dump用来生成转储文件。
不同的“加载器”通过next指针构成一个链表,链表头即为formats。
每个加载器就像是内核为每种格式的可执行文件设置的代理人,每当执行一个可执行文件时,内核遍历formats中的每个代理人,查看该可执行文件是否归某个代理人处理,如果对上了号,代理人则“认领”该可执行文件,负责后续的加载、执行等事务。这就是search_binary_handler函数的主要工作工程。但具体情况比这复杂,需要考虑内核尚未为某种格式的可执行文件设置代理人的情形。
aout格式对应的inux_binfmt结构为aout_format,其定义如下:
<fs/binfmt_aout.c>
static struct linux_binfmt aout_format = {
NULL,
THIS_MODULE,
load_aout_binary,
load_aout_library,
aout_core_dump,
PAGE_SIZE
};
可见aout类可执行文件的加载函数为load_aout_binary,这是流程图中的重点。
2.4 目标文件在内存中的布局如下图所示:
2.5 start_thread
在可执行文件加载完成,并且传递给main函数的argc和argv参数处理完毕后,load_aout_binary调用start_thread来设置子进程返回用户空间后的入口(即main函数)以及用户空间堆栈的栈顶指针。
start_thread(regs, ex.a_entry, current->mm->start_stack);
start_thread的实现如下:
<include/asm/processor.h>
#define start_thread(regs, new_eip, new_esp) do { \
__asm__("movl %0,%%fs ; movl %0,%%gs": :"r" (0)); \
set_fs(USER_DS); \
regs->xds = __USER_DS; \
regs->xes = __USER_DS; \
regs->xss = __USER_DS; \
regs->xcs = __USER_CS; \
regs->eip = new_eip; \
regs->esp = new_esp; \
} while (0)
可见,这里将aout文件的入口ex. a_entry写进eip,而将准备好argc以及argv之后用户空间堆栈的栈顶current->mm->start_stack写进esp,这样当从系统调用返回到子进程的用户空间中时,将从aout文件的入口main函数开始执行,并且通过esp可以获取传递给main函数的argc和argv参数。