读者在linux内核-系统调用fork、vfork与clone中已经看到,进程通常是按其父进程的原样复制出来的,在多数情况下,如果复制出来的子进程不能与父进程分道扬镳,走自己的路,那就没多大意义。所以,执行一个新的可执行程序是进程生命历程中关键性的一步。linux为此提供了一个系统调用execve,而在C语言的程序库中则又在此基础上向应用程序提供一整套的库函数,包括execl、execlp、execle、execlco、execve和execvp。此外,还有库函数system,也与execve有关,不过system是fork、execve、wait4的组合。我们已经在前面介绍过应用程序怎样调用execve,现在我们就来介绍execve的实现。
系统调用execve内核入口是sys_execve,代码如下:
/*
* sys_execve() executes a new program.
*/
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中的内容为应用程序中调用相应库函数时的第一个参数。在前面讲的例子中,这个参数为指向字符串"/bin/echo"的指针。现在,指针存放在regs.ebx中,但字符串本身还在用户空间中,所以730行的getname要把这个字符串从用户空间拷贝到系统空间,在系统空间中建立起一个副本。让我们看看具体是怎么做的。函数getname的代码如下:
sys_execve=>getname
char * getname(const char * filename)
{
char *tmp, *result;
result = ERR_PTR(-ENOMEM);
tmp = __getname();
if (tmp) {
int retval = do_getname(filename, tmp);
result = tmp;
if (retval < 0) {
putname(tmp);
result = ERR_PTR(retval);
}
}
return result;
}
先通过__getname分配一个物理页面作为缓冲区,然后调用do_getname从用户空间拷贝字符串。那么,为什么要专门为此分配一个物理页面作为缓冲区呢?首先,这个字符串确有可能相当长,因为这是一个绝对路径名。其次,我们以前讲过,进程系统空间堆栈的大小是大约7KB,不能滥用,不宜在getname中定义一个局部的4KB的字符数组(注意,局部变量所占据的空间是在堆栈中分配的)。函数do_getname的代码如下:
sys_execve=>getname=>do_getname
/* In order to reduce some races, while at the same time doing additional
* checking and hopefully speeding things up, we copy filenames to the
* kernel data space before using them..
*
* POSIX.1 2.4: an empty pathname is invalid (ENOENT).
*/
static inline int do_getname(const char *filename, char *page)
{
int retval;
unsigned long len = PATH_MAX + 1;
if ((unsigned long) filename >= TASK_SIZE) {
if (!segment_eq(get_fs(), KERNEL_DS))
return -EFAULT;
} else if (TASK_SIZE - (unsigned long) filename < PAGE_SIZE)
len = TASK_SIZE - (unsigned long) filename;
retval = strncpy_from_user((char *)page, filename, len);
if (retval > 0) {
if (retval < len)
return 0;
return -ENAMETOOLONG;
} else if (!retval)
retval = -ENOENT;
return retval;
}
如果指针filename的值大于等于TASK_SIZE,就表示filename实际上在系统空间中,读者应该还记得TASK_SIZE的值是3GB。具体的拷贝是通过strncpy_from_user进行的,代码如下:
sys_execve=>getname=>do_getname=>strncpy_from_user
long
strncpy_from_user(char *dst, const char *src, long count)
{
long res = -EFAULT;
if (access_ok(VERIFY_READ, src, 1))
__do_strncpy_from_user(dst, src, count, res);
return res;
}
这个函数的主体__do_strncpy_from_user是一个宏操作,也在同一个源文件中,与前面介绍过的__generic_copy_from_user相似,读者可以自行对照阅读。
在系统空间中建立一份可执行文件的路径名副本以后,sys_execve就调用do_execve,以完成其主体部分的工作。当然,完成以后还要通过putname将所分配的物理页面而释放。函数do_execve的代码逐段往下看:
sys_execve=>do_execve
/*
* sys_execve() executes a new program.
*/
int do_execve(char * filename, char ** argv, char ** envp, struct pt_regs * regs)
{
struct linux_binprm bprm;
struct file *file;
int retval;
int i;
file = open_exec(filename);
retval = PTR_ERR(file);
if (IS_ERR(file))
return retval;
显然,先要将给定的可执行程序文件找到并打开,open_exec就是为此而调用的。其代码也在同一个文件中,读者可以结合文件系统系列博客中有关打开文件操作的内容,特别是path_walk的代码自行阅读。
假定目标文件已经打开,下一步就要从文件中读入可执行程序了。内核中为可执行程序的装入定义了一个数据结构linux_binprm,以便将运行一个可执行文件时所需的信息组织在一起,定义如下:
/*
* This structure is used to hold the arguments that are used when loading binaries.
*/
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;
};
其中各个成分的作用读了一个下面的代码就会清楚,我们继续在do_execve中往下看:
sys_execve=>do_execve
bprm.p = PAGE_SIZE*MAX_ARG_PAGES-sizeof(void *);
memset(bprm.page, 0, MAX_ARG_PAGES*sizeof(bprm.page[0]));
bprm.file = file;
bprm.filename = filename;
bprm.sh_bang = 0;
bprm.loader = 0;
bprm.exec = 0;
if ((bprm.argc = count(argv, bprm.p / sizeof(void *))) < 0) {
allow_write_access(file);
fput(file);
return bprm.argc;
}
if ((bprm.envc = count(envp, bprm.p / sizeof(void *))) < 0) {
allow_write_access(file);
fput(file);
return bprm.envc;
}
retval = prepare_binprm(&bprm);
if (retval < 0)
goto out;
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;
代码中的linux_binprm数据结构bprm是个局部变量。函数open_exec返回一个file结构指针,代表着读入可执行文件的上下文,所以将其保存在数据结构bprm中。变量bprm.sh_bang的值说明可执行文件的性质,当可执行文件是一个shell过程(shell script,用shell语言编写的命令文件,由shell解释执行)时置成1。而现在还不知道,所以暂且将其置成0,也就是先假定而二进制文件。数据结构中的其他两个变量也暂时设置成0。接着就处理可执行文件的参数和环境变量。
与执行文件路径的处理办法一样,每个参数的最大程度也定为一个物理页面,所以bprm中有一个页面指针数组,数组的大小允许的最大参数个数MAX_ARG_PAGES,目前这个常数定义为32。前面已通过memset将这个指针数组初始化成全0。现在将bprm.p设置成这些页面的总和减去一个指针的大小,因为第0个参数也就是argv[0]是可执行程序本身的路径名。函数count是在exec.c中定义的。这里用它对字符串指针数组argv[]中参数的个数进行计数。注意这里的数组argv和envp是在用户空间而不在系统空间,所以计数的操作并不那么简单。函数count本身的代码很简单,但是引用的宏定义get_user却颇有挑战性,值得一读。它与__generic_copy_from_user相似,我们把它留给读者作为练习。如果count失败,即返回负值,则要对目标文件执行一次allow_write_access,这个函数与deny_write_access是在打开可执行文件时在open_exec中调用的。
完成了对参数和环境变量的计数以后,do_execve又调用prepare_binprm,进一步做数据结构bprm的准备工作,从可执行文件中读入开头的128个字节到linux_binprm结构bprm中的缓冲区。当然,在读之前还要先检查当前进程是否有这个权力,以及该文件是否有可执行属性。如果可执行文件具有set uid特性则要做相应的设置。这个函数的代码也在该文件中。由于涉及文件操作的细节,我们建议读者在学习了文件系统以后自行阅读。此处先说明为什么只是先读128个字节。这是因为,不管目标文件是ELF格式还是a.out格式,或者别的格式,在开头128个字节中包括了关于可执行文件属性的必要而充分的信息。等一下读者就会看到这些信息的用途。
最后的准备工作就是把执行的参数,也就是argv,以及运行的环境,也就是envp,从用户空间拷贝到数据结构bprm中。其中的第一个参数argv[0]就是可执行文件的路径名,已经在bprm.filename中了,所以用copy_strings_kernel从系统空间中拷贝,其他的就要用copy_strings从用户空间拷贝。
至此,所有的准备工作都已完成,所有必要的信息都已经收集到了linux_binprm结构bprm中,接下来就要装入并运行目标程序了:
sys_execve=>do_execve
retval = search_binary_handler(&bprm,regs);
if (retval >= 0)
/* execve success */
return retval;
out:
/* Something went wrong, return the inode and free the argument pages*/
allow_write_access(bprm.file);
if (bprm.file)
fput(bprm.file);
for (i = 0 ; i < MAX_ARG_PAGES ; i++) {
struct page * page = bprm.page[i];
if (page)
__free_page(page);
}
return retval;
}
显然,这里的关键是search_binary_handler。在深入到这个函数内部之前,先介绍一个大概。内核中有个队列,叫formats,挂入此队列中的成员是代表着各种可执行文件格式的代理人,每个成员只认识并且处理一种特定格式的可执行文件的运行。在前面的准备阶段中。已经从可执行文件头部读入128个字节存放在bprm的缓冲区,而且运行所需的参数和环境变量也已经收集在bprm中。现在就由formats队列中的成员逐个来认领,谁要是辨认到了它所代表的可执行文件格式,运行的事就交给它,要是都不认识呢?那就根据文件头的信息再找找看,是否有为此种格式设计,仍是作为可动态安装模块实现的代理人存在于文件系统中。如果有的话就把这模块安装进来并且将其挂入到formats队列中,然后让formats队列中的各个代理人再来试一次。
函数search_binary_handler的代码也在同一个文件中,其中有一段是专门针对alpha处理器的条件编译,在下列的代码中跳过了这段条件编译语句:
sys_execve=>do_execve=>search_binary_handler
/*
* cycle the list of binary formats handler, until one recognizes the image
*/
int search_binary_handler(struct linux_binprm *bprm,struct pt_regs *regs)
{
int try,retval=0;
struct linux_binfmt *fmt;
......
for (try=0; try<2; try++) {
read_lock(&binfmt_lock);
for (fmt = formats ; fmt ; fmt = fmt->next) {
int (*fn)(struct linux_binprm *, struct pt_regs *) = fmt->load_binary;
if (!fn)
continue;
if (!try_inc_mod_count(fmt->module))
continue;
read_unlock(&binfmt_lock);
retval = fn(bprm, regs);
if (retval >= 0) {
put_binfmt(fmt);
allow_write_access(bprm->file);
if (bprm->file)
fput(bprm->file);
bprm->file = NULL;
current->did_exec = 1;
return retval;
}
read_lock(&binfmt_lock);
put_binfmt(fmt);
if (retval != -ENOEXEC)
break;
if (!bprm->file) {
read_unlock(&binfmt_lock);
return retval;
}
}
read_unlock(&binfmt_lock);
if (retval != -ENOEXEC) {
break;
#ifdef CONFIG_KMOD
}else{
#define printable(c) (((c)=='\t') || ((c)=='\n') || (0x20<=(c) && (c)<=0x7e))
char modname[20];
if (printable(bprm->buf[0]) &&
printable(bprm->buf[1]) &&
printable(bprm->buf[2]) &&
printable(bprm->buf[3]))
break; /* -ENOEXEC */
sprintf(modname, "binfmt-%04x", *(unsigned short *)(&bprm->buf[2]));
request_module(modname);
#endif
}
}
return retval;
}
程序中有两层嵌套的for循环。内层是对formats队列中的每个成员循环,让队列中的成员逐个试试它们的load_binary函数,看看能不能够对上号。如果对上了号,那就把目标文件装入并将其投入运行,再返回一个正整数或0。当CPU从系统调用返回时,该目标文件的执行就真正开始了。否则,如果不能辨别,或者在处理的过程中出了错,就返回一个负数,出错代码-ENOEXEC表示只是对不上号,而并没有发生其他的错误,所以循环回去,让队列中的下一个成员再来试试。但是如果出了错又并不是-ENOEXEC,那就表示对上了号但出了其他的错,这就不用再让其它的成员来试了。
内层循环结束以后,如果失败的原因是-ENOEXEC,就说明队列中所有的成员都不认识目标文件的格式。这时候,如果内核支持动态安装模块(取决于编译选项CONFIG_KMOD),就根据目标文件的第二个和第三个字节生成一个linux_binprm模块名,通过request_module试着将相应的模块装入(见文件系统和设备驱动系列博客中的有关内容),外层的for循环供进行两次,正是为了在安装了模块以后再来试一次。
能在linux系统上运行可执行程序的开头几个字节,特别是开头4个字节,往往构成一个所谓的magic number,如果把它拆开成字节,则往往又是说明文件格式的字符。例如,ELF格式的可执行文件的头四个字节为0x7e、e、l、f;而Java的可执行文件头部四个字节则为c、a、f、e。如果可执行文件为shell过程或perl文件,即第一行的格式为#!/usr/bin/perl,此时第一个字符为#,第二个字符为!,后面就是相应解释程序的路径名。
数据结构linux_binfmt的定义前面已经看到了。结构中有三个函数指针:load_binary用来装入可执行程序,load_shlib用来装入动态安装的公用库程序,而core_dump的作用则不言自明。显然,这里最根本的是load_binary。同时,如果不搞清楚具体的装载程序怎样工作,就很难对execve、进而对linux进程的运行由深刻的理解。下面我们以a.out格式为例,讲述装入并启动执行目标程序的过程。其实,a.out格式的可执行文件已经渐渐淘汰了,取而代之的是ELF格式。但是,a.out格式要简单得多,并且方便使我们通过它来讲述目标程序的装入与投入运行的过程,所以从篇幅考虑我们选择了a.out。读者搞清了a.out格式的装载与投入运行过程以后,可以自行阅读有关ELF格式的相关代码。
a.out格式目标文件的装载和投入运行
与a.out格式可执行文件有关的代码都在fs/binfmt_aout.c中。先来看a.out格式的linux_binfmt数据结构,这个数据结构就是在formats队列中代表a.out格式的:
static struct linux_binfmt aout_format = {
NULL, THIS_MODULE, load_aout_binary, load_aout_library, aout_core_dump, PAGE_SIZE
};
读者可以将它与前面的数据结构的类型定义相对照。装载和投入运行a.out格式目标文件的函数为load_aout_binary。可以想象,这是个比较复杂的过程,函数也比较大。我们还是老办法,一段一段往下看,其代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary
/*
* These are the functions used to load a.out style executables and shared
* libraries. There is no binary dependent code anywhere else.
*/
static int load_aout_binary(struct linux_binprm * bprm, struct pt_regs * regs)
{
struct exec ex;
unsigned long error;
unsigned long fd_offset;
unsigned long rlim;
int retval;
ex = *((struct exec *) bprm->buf); /* exec-header */
if ((N_MAGIC(ex) != ZMAGIC && N_MAGIC(ex) != OMAGIC &&
N_MAGIC(ex) != QMAGIC && N_MAGIC(ex) != NMAGIC) ||
N_TRSIZE(ex) || N_DRSIZE(ex) ||
bprm->file->f_dentry->d_inode->i_size < ex.a_text+ex.a_data+N_SYMSIZE(ex)+N_TXTOFF(ex)) {
return -ENOEXEC;
}
首先是检查目标文件的格式,看看是否对上号。所以a.out格式可执行文件(二进制代码)的开头都应该是一个exec数据结构,定义如下:
struct exec
{
unsigned long a_info; /* Use macros N_MAGIC, etc for access */
unsigned a_text; /* length of text, in bytes */
unsigned a_data; /* length of data, in bytes */
unsigned a_bss; /* length of uninitialized data area for file, in bytes */
unsigned a_syms; /* length of symbol table data in file, in bytes */
unsigned a_entry; /* start address */
unsigned a_trsize; /* length of relocation info for text, in bytes */
unsigned a_drsize; /* length of relocation info for data, in bytes */
};
结构中的第一个无符号长整型a_info在逻辑上分成两部分:其高16位是一个代表目标CPU类型的代码,对于i386 CPU这部分的值为100(0x64),而低16位就是magic number。不过,a.out文件的magic number并不像在有的格式中那样是可以打印字符,而是表示某些属性的编码,一共有四种,即OMAGIC、NMAGIC、ZMAGIC和QMAGIC,定义如下:
/* Code indicating object file or impure executable. */
#define OMAGIC 0407
/* Code indicating pure executable. */
#define NMAGIC 0410
/* Code indicating demand-paged executable. */
#define ZMAGIC 0413
/* This indicates a demand-paged executable with the header in the text.
The first page is unmapped to help trap NULL pointer references */
#define QMAGIC 0314
如果magic number不符,或者exec结构中提供的信息与实际不符,那就不能认为这个目标文件是a.out格式的,所以返回 -ENOEXEC。
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary
fd_offset = N_TXTOFF(ex);
/* Check initial limits. This avoids letting people circumvent
* size limits imposed on them by creating programs with large
* arrays in the data or bss.
*/
rlim = current->rlim[RLIMIT_DATA].rlim_cur;
if (rlim >= RLIM_INFINITY)
rlim = ~0;
if (ex.a_data + ex.a_bss > rlim)
return -ENOMEM;
/* Flush all traces of the currently running executable */
retval = flush_old_exec(bprm);
if (retval)
return retval;
/* OK, This is the point of no return */
各种a.out格式的文件因目标代码的特性不同,其正文的起始位置也就不同,为此提供了一个宏操作N_TXTOFF,以便根据代码的特性取得正文在目标文件中的起始位置,定义如下:
#define _N_HDROFF(x) (1024 - sizeof (struct exec))
#if !defined (N_TXTOFF)
#define N_TXTOFF(x) \
(N_MAGIC(x) == ZMAGIC ? _N_HDROFF((x)) + sizeof (struct exec) : \
(N_MAGIC(x) == QMAGIC ? 0 : sizeof (struct exec)))
#endif
以前曾经讲过,每个进程的task_struct结构中有个数组rlim,规定了该进程使用各种资源的限制,其中也包括对用于数据的内存空间的限制。所以,目标文件所确定的data和bss两个段的总和不能超出这个限制。
顺利通过了这些检查就表示具备了执行该目标文件的条件,所以就到了与过去告别的时候,这种告别过去意味着放弃从父进程继承下来的全部用户空间,不管是通过复制还是通过共享继承下来的。不过,下面读者会看到,这种告别也并非彻底地决裂。
函数flush_old_exec的代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>flush_old_exec
int flush_old_exec(struct linux_binprm * bprm)
{
char * name;
int i, ch, retval;
struct signal_struct * oldsig;
/*
* Make sure we have a private signal table
*/
oldsig = current->sig;
retval = make_private_signals();
if (retval) goto flush_failed;
/*
* Release all of the old mmap stuff
*/
retval = exec_mmap();
if (retval) goto mmap_failed;
/* This is the point of no return */
release_old_signals(oldsig);
current->sas_ss_sp = current->sas_ss_size = 0;
if (current->euid == current->uid && current->egid == current->gid)
current->dumpable = 1;
name = bprm->filename;
for (i=0; (ch = *(name++)) != '\0';) {
if (ch == '/')
i = 0;
else
if (i < 15)
current->comm[i++] = ch;
}
current->comm[i] = '\0';
flush_thread();
de_thread(current);
if (bprm->e_uid != current->euid || bprm->e_gid != current->egid ||
permission(bprm->file->f_dentry->d_inode,MAY_READ))
current->dumpable = 0;
/* An exec changes our domain. We are no longer part of the thread
group */
current->self_exec_id++;
flush_signal_handlers(current);
flush_old_files(current->files);
return 0;
mmap_failed:
flush_failed:
spin_lock_irq(¤t->sigmask_lock);
if (current->sig != oldsig)
kfree(current->sig);
current->sig = oldsig;
spin_unlock_irq(¤t->sigmask_lock);
return retval;
}
首先是进程的信号(软中断)处理表。我们讲过,一个进程的信号处理表就好像一个系统中的中断向量表,虽然运用的层次不同,其概念是相似的。当子进程被创建出来时,父进程的信号处理表可能已经复制过来,但也有可能只是把父进程的信号处理函数指针复制了过来,而通过这指针来共享父进程的信号处理表。现在,子进程最终要自立门户了,所以要看一下如果还在共享父进程的信号处理表的话,就要把它复制过来。正因为这样,make_private_signals的代码与do_fork中调用的copy_sighand基本相同。
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>flush_old_exec=>make_private_signals
/*
* This function makes sure the current process has its own signal table,
* so that flush_signal_handlers can later reset the handlers without
* disturbing other processes. (Other processes might share the signal
* table via the CLONE_SIGNAL option to clone().)
*/
static inline int make_private_signals(void)
{
struct signal_struct * newsig;
if (atomic_read(¤t->sig->count) <= 1)
return 0;
newsig = kmem_cache_alloc(sigact_cachep, GFP_KERNEL);
if (newsig == NULL)
return -ENOMEM;
spin_lock_init(&newsig->siglock);
atomic_set(&newsig->count, 1);
memcpy(newsig->action, current->sig->action, sizeof(newsig->action));
spin_lock_irq(¤t->sigmask_lock);
current->sig = newsig;
spin_unlock_irq(¤t->sigmask_lock);
return 0;
}
读者也许要问,既然最终还是要把它复制过来,何不在当初一步就把它复制好了?这就是所谓lazy computation的概念:一件事只有在非做不可时才做。虽然新创建的进程一般都会执行execve,走自己的路,但这是没有保证的。如果创建的是线程那就不一定执行execve,如果一律在创建时候复制就可能造成浪费而不符合要求。再说,检查一下是否还在于父进程共享信号处理表(通过检查共享计数)所花费的代码是很小的。当然,如果子进程是通过fork创建出来的话(而不是vfork或clone),那就一定都已经复制好了,这里的make_private_signals只不过还是检查一下共享计数就马上回来了。
相比之下,exec_mmap是更为关键的行动,从父进程继承下来的用户空间就是在这里放弃的。其代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>flush_old_exec=>exec_mmap
static int exec_mmap(void)
{
struct mm_struct * mm, * old_mm;
old_mm = current->mm;
if (old_mm && atomic_read(&old_mm->mm_users) == 1) {
flush_cache_mm(old_mm);
mm_release();
exit_mmap(old_mm);
flush_tlb_mm(old_mm);
return 0;
}
mm = mm_alloc();
if (mm) {
struct mm_struct *active_mm = current->active_mm;
if (init_new_context(current, mm)) {
mmdrop(mm);
return -ENOMEM;
}
/* Add it to the list of mm's */
spin_lock(&mmlist_lock);
list_add(&mm->mmlist, &init_mm.mmlist);
spin_unlock(&mmlist_lock);
task_lock(current);
current->mm = mm;
current->active_mm = mm;
task_unlock(current);
activate_mm(active_mm, mm);
mm_release();
if (old_mm) {
if (active_mm != old_mm) BUG();
mmput(old_mm);
return 0;
}
mmdrop(active_mm);
return 0;
}
return -ENOMEM;
}
同样,子进程的用户空间可能是父进程用户空间的复制品,也可能只是通过一个指针来共享父进程的用户空间,这一点只要检查一下对用户空间、也就是current->mm的共享计数就可清楚。当共享计数为1时,表明对此空间的使用是独占的,也就是说这是从父进程复制过来的,那就要先释放mm_struct数据结构以下的所有vm_area_struct数据结构(但是不包括mm_struct结构本身),并且将页面表中的表项都设置成0。具体地这是由exit_mmap完成的。读者可自行阅读。在调用exit_mmap之前还调用了一个函数mm_release,对此我们将在稍后加以讨论,因为在后面也调用了这个函数。至于flush_cache_mm和flush_tlb_mm,那只是使高速缓存与内存相一致,不在我们现在关心之列,而且前面对i386处理器而言根本就是空语句。这里倒是要问一句,在父进程fork子进程的时候,辛辛苦苦地复制了代表用户空间的所有数据结构,难道目的就是在于稍后在执行execve时又辛辛苦苦地把它们全部释放?既有今日,何必当初?是的,这确实不合理。这就是在有了fork系统调用以后又增加了一个vfork系统调用(从bsd Unix开始)的原因。让我们回顾一下sys_fork与sys_vfork在调用do_fork时的不同:
asmlinkage int sys_fork(struct pt_regs regs)
{
return do_fork(SIGCHLD, regs.esp, ®s, 0);
}
asmlinkage int sys_vfork(struct pt_regs regs)
{
return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs.esp, ®s, 0);
}
可见,sys_vfork在调用do_fork时比sys_fork多了两个标志位,一个是CLONE_VFORK,另一个是CLONE_VM。当CLONE_VM标志位为1时,内核并不将父进程的用户空间(数据结构)复制给子进程,而只是将指向mm_struct数据结构的指针复制给子进程。让子进程通过这个指针来共享父进程的用户空间。这样,创建子进程时可以免去复制用户空间的麻烦。而当子进程调用execve就可以跳过释放用户空间这一步。直接就为子进程分配新的用户空间。但是,这样一来省事是省事了,却可能带来新的问题。以前讲过,fork以后,execve之前,子进程显然有它自己的一整套代表用户空间的数据结构,但是最终在物理上还是与父进程共用相同的页面。不过,由于子进程在其独立的页面目录与页面表,但是最终物理上还是与父进程共用相同的页面。不过,由于子进程由其独立的页面目录与页面表,可以在子进程的页面表里把所有页面的访问权限设置成只读。这样,当子进程企图改变某个页面的内容时,就会因权限不符而导致页面异常,在页面异常的处理程序中为子进程复制所需的物理页面。这就叫copy on write。相比之下,如果子进程所写入的内容就真正进入了父进程的空间中。我们知道,当一个进程在用户空间运行时,其堆栈也在用户空间。这意味着在这种情况下子进程可以改变父进程的堆栈,反过来父进程也可以改变子进程的堆栈!因为这个原因。vfork的使用时很危险的,在子进程尚未放弃对父进程用户空间的共享之前,决不能让两个进程都进入系统空间运行。所以,在sys_vfork调用do_fork时结合使用了另一个标志位CLONE_VFORK。当这个标志位1时,父进程在创建了子进程以后就进入睡眠装填,等候子进程通过execve执行另一个目标程序,或者通过exit寿终正寝。在这两种情况下子进程都会释放其共享的用户空间,使父进程可以安全地继续运行。即使如此,也还是有危险,子进程绝对不能从调用vfork的那个函数中返回,否则还是可能破坏父进程的返回地址。所以,vfork实际上是建立在进程在创建以后立即就会调用execve这个前提之上的。
那么,怎样使父进程进入睡眠而等待子进程调用execve或exit呢?当然可以有不同的实现。读者已经在do_fork的代码中看到了内核让父进程在一个0资源的信号量上执行一次down操作而进入睡眠的安排,这里的mm_release则让子进程在此信号量上执行一次up操作将父进程唤醒。函数mm_release的代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>flush_old_exec=>exec_mmap=>mm_release
/* Please note the differences between mmput and mm_release.
* mmput is called whenever we stop holding onto a mm_struct,
* error success whatever.
*
* mm_release is called after a mm_struct has been removed
* from the current process.
*
* This difference is important for error handling, when we
* only half set up a mm_struct for a new process and need to restore
* the old one. Because we mmput the new mm_struct before
* restoring the old one. . .
* Eric Biederman 10 January 1998
*/
void mm_release(void)
{
struct task_struct *tsk = current;
/* notify parent sleeping on vfork() */
if (tsk->flags & PF_VFORK) {
tsk->flags &= ~PF_VFORK;
up(tsk->p_opptr->vfork_sem);
}
}
回到exec_mmap中,如果子进程的用户空间是通过指针共享而不是复制的,或者根本就没有用户空间,那就不需要调用exit_mmap释放代表用户空间的那些数据结构了。但是,此时要为子进程分配一个mm_struct数据结构及页面目录,使得稍后可以在此基础上建立起子进程的用户空间。对于i386结构的CPU,这里的init_new_context是空操作,永远返回0,所以把它跳过。把当前进程的task_struct结构中的指针mm和active_mm设置成指向新分配的mm_struct数据结构以后,就要通过active_mm切换到这个新的用户空间。这是一个宏操作:
#define activate_mm(prev, next) \
switch_mm((prev),(next),NULL,smp_processor_id())
我们将在"linux内核-进程的调度与切换"一节中阅读switch_mm的代码,在这里只要知道当前进程的用户空间切换到了由新分配mm_struct数据结构所代表的空间就可以了。还要指出,现在新的用户空间实际上只是一个框架,一个空壳,里面一个页面也没有。另一方面,现在是在内核中运行,所以用户空间的切换对目前的运行并无影响。
可是,原来的用户空间则从此与当前进程无关了。也就是说,当前进程最终放弃了对原来用户空间的共享。当然,此时要执行mm_release将父进程唤醒。实际上,CLONE_VFORK通常都是与CLONE_VM标志相联系的,所以这里对mm_release的调用更为关键,而前面的mm_release则只是以防万一而已。那么,对于父进程的用户空间呢?当然要减少它的共享计数。此外,如果将它的共享计数减1以后达到了0,则还要将其下属的数据结构释放,因为此时已没有进程还在使用这个空间了。这是由mmput完成的,代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>flush_old_exec=>exec_mmap=>mmput
/*
* Decrement the use count and release all resources for an mm.
*/
void mmput(struct mm_struct *mm)
{
if (atomic_dec_and_lock(&mm->mm_users, &mmlist_lock)) {
list_del(&mm->mmlist);
spin_unlock(&mmlist_lock);
exit_mmap(mm);
mmdrop(mm);
}
}
就是说,将mm->mm_users减1,如果减1以后变成了0,就对mm执行exit_mmap和mmdrop。我们已经介绍过exit_mmap的作用,它释放mm_struct下面的所有vm_area_struct数据结构,并且将页面表中与用户空间相对应的表项都设置成0,使整个用户空间成为了一个空壳。而mmdrop,则进一步将这个壳,也就是页面表和页面目录以及mm_struct数据结构本身,也全都释放了。不过,这只是将父进程的mm->mm_users减1以后变成了0这种特殊情况下发生。而在我们现在这个情景中,既然子进程通过指针共享父进程的用户空间,则父进程应该睡眠等待,所以当子进程释放对空间的共享时不会使共享计数达到0。
回到前面exec_mmap的代码中,最后还有一个特殊情况要考虑,那就是当子进程进入exec_mmap时,其task_struct结构中的mm_struct结构指针mm为0,也就是没有用户空间(所以是内核线程)。但是,另一个mm_struct结构指针active_mm却不为0,这是因为在进程切换时的一个特殊要求而引起的。进程的task_struct中有两个mm_struct结构指针:一个是mm,指向进程的用户空间,另一个是active_mm。对于具有用户空间的进程这里两个指针始终是一致的。但是,当一个不具备用户空间的进程(内核线程)被调度运行时,要求它的active_mm一定要指向某个mm_struct结构,所以只好暂借一个。在这种情况,内核将其active_mm设置成在其之前运行的那个进程的active_mm相同,而在调度其停止运行时又将该指针设置成0。也就是说,一个内核线程在受调度运行时要借用在它之前运行的那个进程的active_mm(详见“linux内核-进程的调度与切换”),因而要递增这个mm_struct结构的使用计数。而现在,已经为这内核线程分配了它自己的mm_struct结构,使其升格为了进程,就不再使用借来的active_mm了。所以,要调用mmdrop,递减其使用计数。这是一个inline函数,其代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>flush_old_exec=>exec_mmap=>mmdrop
static inline void mmdrop(struct mm_struct * mm)
{
if (atomic_dec_and_test(&mm->mm_count))
__mmdrop(mm);
}
__mmdrop的代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>flush_old_exec=>exec_mmap=>mmdrop=>__mmdrop
/*
* Called when the last reference to the mm
* is dropped: either by a lazy thread or by
* mmput. Free the page directory and the mm.
*/
inline void __mmdrop(struct mm_struct *mm)
{
if (mm == &init_mm) BUG();
pgd_free(mm->pgd);
destroy_context(mm);
free_mm(mm);
}
可见,mmdrop在将一个mm_struct数据结构释放之前也要递减并检查其使用计数mm_count,只有在递减后变成0才会将其释放。注意两个计数器,即mm_users与mm_count的区别。在mm_struct结构分配之初二者都设成1,然后mm_users随子进程对用户空间的共享而增减,而mm_count则因内核中对该mm_struct数据结构的使用而增减。
从exec_mmap返回到flush_old_exec时,子进程从父进程继承的用户空间已经释放,其用户空间变成了一个独立的空壳,也就是一个大小为0的独立的用户空间。这时候的进程已经是义无反顾了,回不到原来的用户空间中去了(见代码中的注释)。前面讲过,当前进程(子进程)原来可能是通过指针共享父进程的信号处理表的,而现在有了自己的独立的信号处理表,所以也要递减父进程信号处理表的共享计数,并且如果递减后为0就要将其所占的空间释放,这就是release_old_signals所做的事情。此外,进程的task_struct结构中有一个字符数组comm,用于保存进程所执行的程序名,所以还要把bprm->filename的目标程序路径名中的最后一段抄过去。接着的flush_thread只是处理与debug和i387协处理器有关的内容,不是我们所关心的。
如果当前进程原来只是一个线程,那么它的task_struct结构通过结构中的队列头thread_group挂入由其父进程为首的线程组队列。现在,它已经在通过execve升级为进程,放弃了对父进程用户空间的共享,所以就要通过de_thread从这个线程组中脱离出来。这个函数的代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>flush_old_exec=>de_thread
/*
* An execve() will automatically "de-thread" the process.
* Note: we don't have to hold the tasklist_lock to test
* whether we migth need to do this. If we're not part of
* a thread group, there is no way we can become one
* dynamically. And if we are, we only need to protect the
* unlink - even if we race with the last other thread exit,
* at worst the list_del_init() might end up being a no-op.
*/
static inline void de_thread(struct task_struct *tsk)
{
if (!list_empty(&tsk->thread_group)) {
write_lock_irq(&tasklist_lock);
list_del_init(&tsk->thread_group);
write_unlock_irq(&tasklist_lock);
}
/* Minor oddity: this might stay the same. */
tsk->tgid = tsk->pid;
}
前面说过,进程的信号处理表就好像是一个中断向量表。但是,这里还有个重要的不同,就是中断向量表中的表项要么指向一个服务程序,要么就没有;而信号处理表中则还可以有对各种信号预设的(default)响应,并不是非要指向一个服务程序。当把信号处理表从父进程复制过来时,其中每个表项的值有三种可能:一种可能是SIG_IGN,表示不理睬;第二种是SIG_DFL
,表示采取预设的响应方式(例如受到SIGQUIT就exit);第三种就是指向一个用户空间的子程序。可是,现在整个用户空间都已经放弃了,怎么还能让信号处理表的表项指向用户空间的子程序呢?所以还得检查一遍,将指向服务程序的表项改成SIG_DEL。这是由flush_signal_handlers完成的,代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>flush_old_exec=>flush_signal_handlers
/*
* Flush all handlers for a task.
*/
void
flush_signal_handlers(struct task_struct *t)
{
int i;
struct k_sigaction *ka = &t->sig->action[0];
for (i = _NSIG ; i != 0 ; i--) {
if (ka->sa.sa_handler != SIG_IGN)
ka->sa.sa_handler = SIG_DFL;
ka->sa.sa_flags = 0;
sigemptyset(&ka->sa.sa_mask);
ka++;
}
}
最后,是对原有已打开文件的处理,这是由flush_old_files完成的。进程的task_struct结构中有个指向一个files_struct结构的指针files,所指向的数据结构中保存着已打开文件的信息。在files_struct结构中有个位图close_on_exec,里面存储着表示哪些文件在执行一个新目标程序时应予关闭的信息。而flush_old_files要做的就是根据这个位图的指示将这些文件关闭,并且将此位图清成全0。其代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>flush_old_exec=>flush_old_files
/*
* These functions flushes out all traces of the currently running executable
* so that a new one can be started
*/
static inline void flush_old_files(struct files_struct * files)
{
long j = -1;
write_lock(&files->file_lock);
for (;;) {
unsigned long set, i;
j++;
i = j * __NFDBITS;
if (i >= files->max_fds || i >= files->max_fdset)
break;
set = files->close_on_exec->fds_bits[j];
if (!set)
continue;
files->close_on_exec->fds_bits[j] = 0;
write_unlock(&files->file_lock);
for ( ; set ; i++,set >>= 1) {
if (set & 1) {
sys_close(i);
}
}
write_lock(&files->file_lock);
}
write_unlock(&files->file_lock);
}
一般来说,进程的开头三个文件,即fd为0、1、2(或者stdin、stdout以及stderr)的已打开文件是不关闭的;其它的已打开文件则都应关闭,但是也可以通过ioctl系统调用来加以改变。
从flush_old_exec返回到load_aout_binary中时,当前进程已经完成了与过去告别,准备迎新的使命了。我们继续沿着代码往下看:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary
/* OK, This is the point of no return */
#if !defined(__sparc__)
set_personality(PER_LINUX);
#else
set_personality(PER_SUNOS);
#if !defined(__sparc_v9__)
memcpy(¤t->thread.core_exec, &ex, sizeof(struct exec));
#endif
#endif
current->mm->end_code = ex.a_text +
(current->mm->start_code = N_TXTADDR(ex));
current->mm->end_data = ex.a_data +
(current->mm->start_data = N_DATADDR(ex));
current->mm->brk = ex.a_bss +
(current->mm->start_brk = N_BSSADDR(ex));
current->mm->rss = 0;
current->mm->mmap = NULL;
compute_creds(bprm);
current->flags &= ~PF_FORKNOEXEC;
这里是对新的mm_struct数据结构中的一些变量进行初始化,为以后分配存储空间并读入可执行代码的映像做好准备。目标代码的映像分成text、data以及bss三段,mm_struct结构中为每个段都设置了start和end两个指针。每段的起始地址定义如下:
/* Address of text segment in memory after it is loaded. */
#if !defined (N_TXTADDR)
#define N_TXTADDR(x) (N_MAGIC(x) == QMAGIC ? PAGE_SIZE : 0)
#endif
#define _N_SEGMENT_ROUND(x) (((x) + SEGMENT_SIZE - 1) & ~(SEGMENT_SIZE - 1))
#define _N_TXTENDADDR(x) (N_TXTADDR(x)+(x).a_text)
#ifndef N_DATADDR
#define N_DATADDR(x) \
(N_MAGIC(x)==OMAGIC? (_N_TXTENDADDR(x)) \
: (_N_SEGMENT_ROUND (_N_TXTENDADDR(x))))
#endif
/* Address of bss segment in memory after it is loaded. */
#if !defined (N_BSSADDR)
#define N_BSSADDR(x) (N_DATADDR(x) + (x).a_data)
#endif
可见,装入内存以后的程序映像从正文段(代码段)开始,其起始地址为0或PAGE_SIZE,取决于具体的格式。正文段上面是数据段;然后是bss段,那就是不加初始化的数据段。再往上就是动态分配的内存堆以及用户空间的堆栈了。
然后,通过compute_creds确定进程在开始执行新的目标代码以后所具有的权限,这是根据bprm中的内容和当前的权限确定的。其代码在exec.c中,读者可自行阅读。
接下来,就取决于特殊a.out格式可执行代码的特性了(fs/binfmt_aout.c):
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary
#ifdef __sparc__
if (N_MAGIC(ex) == NMAGIC) {
loff_t pos = fd_offset;
/* Fuck me plenty... */
/* <AOL></AOL> */
error = do_brk(N_TXTADDR(ex), ex.a_text);
bprm->file->f_op->read(bprm->file, (char *) N_TXTADDR(ex),
ex.a_text, &pos);
error = do_brk(N_DATADDR(ex), ex.a_data);
bprm->file->f_op->read(bprm->file, (char *) N_DATADDR(ex),
ex.a_data, &pos);
goto beyond_if;
}
#endif
if (N_MAGIC(ex) == OMAGIC) {
unsigned long text_addr, map_size;
loff_t pos;
text_addr = N_TXTADDR(ex);
#if defined(__alpha__) || defined(__sparc__)
pos = fd_offset;
map_size = ex.a_text+ex.a_data + PAGE_SIZE - 1;
#else
pos = 32;
map_size = ex.a_text+ex.a_data;
#endif
error = do_brk(text_addr & PAGE_MASK, map_size);
if (error != (text_addr & PAGE_MASK)) {
send_sig(SIGKILL, current, 0);
return error;
}
error = bprm->file->f_op->read(bprm->file, (char *)text_addr,
ex.a_text+ex.a_data, &pos);
if (error < 0) {
send_sig(SIGKILL, current, 0);
return error;
}
flush_icache_range(text_addr, text_addr+ex.a_text+ex.a_data);
} else {
前面讲过,a.out格式目标代码中的magic number表示着代码的特性,或者说类型。当magic number为OMAGIC时,表示该文件中的可执行代码并非纯代码。对于这样的代码,先通过do_brk为正文段和数据段合在一起分配空间,然后就把这两部分从文件中读进来。函数do_brk我们在内存管理中介绍过,而从文件读入则在文件系统和块设备驱动系列有关详细叙述,读者可以参阅,这里就不重复了。不过要指出,读入代码时是从文件中位移为32的地方开始,读入到进程用户空间中从地址0开始的地方,读入的总长度为ex.a_text+ex.a_data。对于i386 CPU而言,flush_icache_range为一空语句。至于bss段,则无需从文件读入,只要分配空间就行了,所以放在后面再处理。对于OMAGIC类型的a.out可执行文件而言,装入程序的工作就基本完成了。
可是,如果不是OMAGIC类型呢?请接着往下看:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary
if ((ex.a_text & 0xfff || ex.a_data & 0xfff) &&
(N_MAGIC(ex) != NMAGIC) && (jiffies-error_time2) > 5*HZ)
{
printk(KERN_NOTICE "executable not page aligned\n");
error_time2 = jiffies;
}
if ((fd_offset & ~PAGE_MASK) != 0 &&
(jiffies-error_time) > 5*HZ)
{
printk(KERN_WARNING
"fd_offset is not page aligned. Please convert program: %s\n",
bprm->file->f_dentry->d_name.name);
error_time = jiffies;
}
if (!bprm->file->f_op->mmap||((fd_offset & ~PAGE_MASK) != 0)) {
loff_t pos = fd_offset;
do_brk(N_TXTADDR(ex), ex.a_text+ex.a_data);
bprm->file->f_op->read(bprm->file,(char *)N_TXTADDR(ex),
ex.a_text+ex.a_data, &pos);
flush_icache_range((unsigned long) N_TXTADDR(ex),
(unsigned long) N_TXTADDR(ex) +
ex.a_text+ex.a_data);
goto beyond_if;
}
down(¤t->mm->mmap_sem);
error = do_mmap(bprm->file, N_TXTADDR(ex), ex.a_text,
PROT_READ | PROT_EXEC,
MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE,
fd_offset);
up(¤t->mm->mmap_sem);
if (error != N_TXTADDR(ex)) {
send_sig(SIGKILL, current, 0);
return error;
}
down(¤t->mm->mmap_sem);
error = do_mmap(bprm->file, N_DATADDR(ex), ex.a_data,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_FIXED | MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE,
fd_offset + ex.a_text);
up(¤t->mm->mmap_sem);
if (error != N_DATADDR(ex)) {
send_sig(SIGKILL, current, 0);
return error;
}
}
在a.out格式的可执行文件中,除OMAGIC以外其它三种均为纯代码;也就是所谓的可重入代码。此类代码中,不但其正文段的执行代码在运行时不会改变,其数据段的内容也不会在运行时改变。凡是要在运行过程中改变内容的东西都在堆栈中(局部变量),要不然就是动态分配的缓冲区中。所以,内核干脆将可执行文件映射到了进程的用户空间中,这样连通常swap所需的盘上空间也省去了。在这三种类型的可执行文件中,除OMAGIC以外都要求正文段及数据段的长度与页面大小对齐。如发现没有对齐就要通过printk发出警告信息。但是,发出警告信息太频繁也不好,所以就设置了一个静态变量error_time2,使警告信息之间的间隔不小于5秒。接下来的操作取决于具体的文件系统是否提供mmap、就是将一个已打开文件映射到虚存空间的操作,以及正文段及数据段的长度是否与页面大小对齐。如果不满足映射的条件,就分配空间并且将正文段和数据段一起读入至进程的用户空间,这次是从文件中位移为fd_offset,即N_TXTOFF(ex)的地方开始,读入到由文件的头部所指定的地址 N_TXTADDR(ex),长度为两段的总和。如果满足映射的条件,那就更好了,那就通过do_mmap分别将文件的正文段和数据段映射到进程的用户空间中,映射的地址则与装入的地址一致。调用mmap之前无需分配空间,那已经包含在mmap之中了。
至此,正文段和数据段都已经装入就绪了,接下来就是bss段和堆栈段了:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary
beyond_if:
set_binfmt(&aout_format);
set_brk(current->mm->start_brk, current->mm->brk);
retval = setup_arg_pages(bprm);
if (retval < 0) {
/* Someone check-me: is this error path enough? */
send_sig(SIGKILL, current, 0);
return retval;
}
current->mm->start_stack =
(unsigned long) create_aout_tables((char *) bprm->p, bprm);
函数set_binfmt的操作很简单:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>set_binfmt
void set_binfmt(struct linux_binfmt *new)
{
struct linux_binfmt *old = current->binfmt;
if (new && new->module)
__MOD_INC_USE_COUNT(new->module);
current->binfmt = new;
if (old && old->module)
__MOD_DEC_USE_COUNT(old->module);
}
如果当前进程原来执行的代码格式与新的代码格式都不是由可安装模块支持,则实际上只剩下一行语句,那就是设置current->binfmt。
函数set_brk为可执行代码的bss段分配空间并建立起页面映射,其代码在同一文件中:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>set_brk
static void set_brk(unsigned long start, unsigned long end)
{
start = PAGE_ALIGN(start);
end = PAGE_ALIGN(end);
if (end <= start)
return;
do_brk(start, end - start);
}
读者在内存管理系列博客中读过do_brk的代码,应该理解为什么bss段中内容的初始值为全0。
接着,还要在用户空间的堆栈区顶部为进程建立起一个虚存区间,并将执行参数以及环境变量所占的物理页面与此虚存区间建立起映射。这是由setup_arg_pages完成的,代码如下:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>setup_arg_pages
int setup_arg_pages(struct linux_binprm *bprm)
{
unsigned long stack_base;
struct vm_area_struct *mpnt;
int i;
stack_base = STACK_TOP - MAX_ARG_PAGES*PAGE_SIZE;
bprm->p += stack_base;
if (bprm->loader)
bprm->loader += stack_base;
bprm->exec += stack_base;
mpnt = kmem_cache_alloc(vm_area_cachep, SLAB_KERNEL);
if (!mpnt)
return -ENOMEM;
down(¤t->mm->mmap_sem);
{
mpnt->vm_mm = current->mm;
mpnt->vm_start = PAGE_MASK & (unsigned long) bprm->p;
mpnt->vm_end = STACK_TOP;
mpnt->vm_page_prot = PAGE_COPY;
mpnt->vm_flags = VM_STACK_FLAGS;
mpnt->vm_ops = NULL;
mpnt->vm_pgoff = 0;
mpnt->vm_file = NULL;
mpnt->vm_private_data = (void *) 0;
insert_vm_struct(current->mm, mpnt);
current->mm->total_vm = (mpnt->vm_end - mpnt->vm_start) >> PAGE_SHIFT;
}
for (i = 0 ; i < MAX_ARG_PAGES ; i++) {
struct page *page = bprm->page[i];
if (page) {
bprm->page[i] = NULL;
current->mm->rss++;
put_dirty_page(current,page,stack_base);
}
stack_base += PAGE_SIZE;
}
up(¤t->mm->mmap_sem);
return 0;
}
进程的用户空间中地址最高处为堆栈区,这里的常数STACK_TOP就是TASK_SIZE,也就是3GB。堆栈区的顶部为一个数组,数组中的每一个元素都是一个页面。数组的大小为MAX_ARG_PAGES,而实际映射的页面数量则取决于这些执行参数和环境变量的数量。
然后,在这些页面的下方,就是过程的用户空间堆栈了。另一方面,大家知道任何用户程序的入口都是main,而main有两个参数argc和argv。其中参数argv是字符指针数组,argc则为数据的大小,但是实际上还有个隐藏着的字符指针数组envp用来传递环境变量,只是不在用户程序的视野之内而已。所以,用户空间堆栈中从一开始就要设置好三项数据,即envp、argv、argc。此外,还要将保存着的(字符串形式的)参数和环境变量复制到用户空间的顶端。这都是由create_aout_tables完成的,其代码也在同一文件中:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary=>create_aout_tables
/*
* create_aout_tables() parses the env- and arg-strings in new user
* memory and creates the pointer tables from them, and puts their
* addresses on the "stack", returning the new stack pointer value.
*/
static unsigned long * create_aout_tables(char * p, struct linux_binprm * bprm)
{
char **argv, **envp;
unsigned long * sp;
int argc = bprm->argc;
int envc = bprm->envc;
sp = (unsigned long *) ((-(unsigned long)sizeof(char *)) & (unsigned long) p);
#ifdef __sparc__
/* This imposes the proper stack alignment for a new process. */
sp = (unsigned long *) (((unsigned long) sp) & ~7);
if ((envc+argc+3)&1) --sp;
#endif
#ifdef __alpha__
/* whee.. test-programs are so much fun. */
put_user(0, --sp);
put_user(0, --sp);
if (bprm->loader) {
put_user(0, --sp);
put_user(0x3eb, --sp);
put_user(bprm->loader, --sp);
put_user(0x3ea, --sp);
}
put_user(bprm->exec, --sp);
put_user(0x3e9, --sp);
#endif
sp -= envc+1;
envp = (char **) sp;
sp -= argc+1;
argv = (char **) sp;
#if defined(__i386__) || defined(__mc68000__) || defined(__arm__)
put_user((unsigned long) envp,--sp);
put_user((unsigned long) argv,--sp);
#endif
put_user(argc,--sp);
current->mm->arg_start = (unsigned long) p;
while (argc-->0) {
char c;
put_user(p,argv++);
do {
get_user(c,p++);
} while (c);
}
put_user(NULL,argv);
current->mm->arg_end = current->mm->env_start = (unsigned long) p;
while (envc-->0) {
char c;
put_user(p,envp++);
do {
get_user(c,p++);
} while (c);
}
put_user(NULL,envp);
current->mm->env_end = (unsigned long) p;
return sp;
}
读者应该能看明白,这是在堆栈的顶端构筑envp、argv和argc。请读者注意看一下这段代码中的228至234行(以及237至243行),然后回答一个问题:为什么是get_user(c,p++)而不是get_user(&c,p++)?以前我们曾经讲过,get_user是一段颇具挑战性的代码,并建议读者自行阅读。现在简要地介绍一下,看看你是否读懂了。
/*
* These are the main single-value transfer routines. They automatically
* use the right size if we just have the right pointer type.
*
* This gets kind of ugly. We want to return _two_ values in "get_user()"
* and yet we don't want to do any pointers, because that is too much
* of a performance impact. Thus we have a few rather ugly macros here,
* and hide all the uglyness from the user.
*
* The "__xxx" versions of the user access functions are versions that
* do not verify the address space, that must have been done previously
* with a separate "access_ok()" call (this is used when we do multiple
* accesses to the same area of user memory).
*/
extern void __get_user_1(void);
extern void __get_user_2(void);
extern void __get_user_4(void);
#define __get_user_x(size,ret,x,ptr) \
__asm__ __volatile__("call __get_user_" #size \
:"=a" (ret),"=d" (x) \
:"0" (ptr))
/* Careful: we have to cast the result to the type of the pointer for sign reasons */
#define get_user(x,ptr) \
({ int __ret_gu,__val_gu; \
switch(sizeof (*(ptr))) { \
case 1: __get_user_x(1,__ret_gu,__val_gu,ptr); break; \
case 2: __get_user_x(2,__ret_gu,__val_gu,ptr); break; \
case 4: __get_user_x(4,__ret_gu,__val_gu,ptr); break; \
default: __get_user_x(X,__ret_gu,__val_gu,ptr); break; \
} \
(x) = (__typeof__(*(ptr)))__val_gu; \
__ret_gu; \
})
先看一下122行,它回答了为什么引用时的第一个参数时c而不是&c的问题。其次,经过gcc的预处理以后,__get_user_x就变成了__get_user_1、__get_user_2或__get_user_4,分别用于从用户空间读取一个字节、一个短整数或一个长整数。宏操作get_user根据第二个参数的类型确定目标的大小而分别调用__get_user_1、__get_user_2或__get_user_4。调用时目标地址(ptt)在寄存器EAX中;而返回EAX中的返回的函数值(出错代码),EDX为从用户空间读过来的数值。这几个函数的代码都在arch/i386/lib/getuser.S中,以__get_user_1为例:
addr_limit = 12
.text
.align 4
.globl __get_user_1
__get_user_1:
movl %esp,%edx
andl $0xffffe000,%edx
cmpl addr_limit(%edx),%eax
jae bad_get_user
1: movzbl (%eax),%edx
xorl %eax,%eax
ret
.....
bad_get_user:
xorl %edx,%edx
movl $-14,%eax
ret
读者最好自己下载一份源码,然后对照着看,这里的第30和31行将当前进程的系统空间堆栈指针与8K,即两个页面的边界对齐,从而取得当前进程的task_struct结构指针。在task_struct结构中位移12处为当前进程用户空间地址的上限,所以作为参数传递过来的地址不得高于这个上限。这也说明,对task_struct结构的定义(开头几个部分)是不能随意改变的。如果地址没有超出范围就从用户空间把其内容读入寄存器DX,并将EAX清0作为返回的函数值。
另一个宏操作put_user与此类似,只是方向相反。
当CPU从create_aout_tables返回到load_aout_binary时,堆栈顶端的argv和argc都已经准备好。我们再继续往下看:
sys_execve=>do_execve=>search_binary_handler=>load_aout_binary
#ifdef __alpha__
regs->gp = ex.a_gpvalue;
#endif
start_thread(regs, ex.a_entry, current->mm->start_stack);
if (current->ptrace & PT_PTRACED)
send_sig(SIGTRAP, current, 0);
return 0;
}
这里只剩下最后一个关键性的操作了,那就是start_thread。这是个宏操作,定义如下:
#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)
读者对这里的regs指针已经很熟悉,它指向保留在当前进程系统空间堆栈中的各个寄存器副本。当进程从系统调用返回时,这些数值就会被恢复到CPU的各个寄存器中。所以,那时候的堆栈指针将是current->mm->start_stack;而返回地址,也就是EIP的内容,则将是ex.a_entry。显然,这正是我们所需要的。
至此,可执行代码的装入和投入运行已经完成。而do_execve在调用了search_binary_handler以后也就结束了。当CPU从系统调用返回到用户空间时,就会从由ex.a_entry确定的地址开始执行。
文字形式可执行文件的执行
前面介绍了a.out格式可执行文件的装入和投入运行过程,我们把这作为二进制可执行文件的代表。现在,再来简要地看一下字符形式的可执行文件(为shell过程或perl文件)的执行。有关的代码都在binfmt_script.c中。由于已经比较详细地阅读了二进制可执行文件的处理,读者在阅读下面代码时应该比较轻松了。所以我们只做一些简要的提示:
struct linux_binfmt script_format = {
NULL, THIS_MODULE, load_script, NULL, NULL, 0
};
以前我们提到过,script文件的开头的几个字符是"#!",然后是解释程序的路径名,如/bin/sh,/usr/bin/perl等等,后面还可以有参数。但是,第一行的长度不得长于127个字符。我们来看script文件的装载,这是由load_script完成的:
sys_execve=>do_execve=>search_binary_handler=>load_script
static int load_script(struct linux_binprm *bprm,struct pt_regs *regs)
{
char *cp, *i_name, *i_arg;
struct file *file;
char interp[BINPRM_BUF_SIZE];
int retval;
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!') || (bprm->sh_bang))
return -ENOEXEC;
/*
* This section does the #! interpretation.
* Sorta complicated, but hopefully it will work. -TYT
*/
bprm->sh_bang++;
allow_write_access(bprm->file);
fput(bprm->file);
bprm->file = NULL;
bprm->buf[BINPRM_BUF_SIZE - 1] = '\0';
if ((cp = strchr(bprm->buf, '\n')) == NULL)
cp = bprm->buf+BINPRM_BUF_SIZE-1;
*cp = '\0';
while (cp > bprm->buf) {
cp--;
if ((*cp == ' ') || (*cp == '\t'))
*cp = '\0';
else
break;
}
for (cp = bprm->buf+2; (*cp == ' ') || (*cp == '\t'); cp++);
if (*cp == '\0')
return -ENOEXEC; /* No interpreter name found */
i_name = cp;
i_arg = 0;
for ( ; *cp && (*cp != ' ') && (*cp != '\t'); cp++)
/* nothing */ ;
while ((*cp == ' ') || (*cp == '\t'))
*cp++ = '\0';
if (*cp)
i_arg = cp;
strcpy (interp, i_name);
得到了解释程序的路径名以后,问题就转化成了对解释程序的装入,而script文件本身则转化成了解释程序的运行参数。虽然script文件本身不是二进制格式的可执行文件,解释程序的映射却是一个二进制的可执行文件。继续往下看:
sys_execve=>do_execve=>search_binary_handler=>load_script
/*
* OK, we've parsed out the interpreter name and
* (optional) argument.
* Splice in (1) the interpreter's name for argv[0]
* (2) (optional) argument to interpreter
* (3) filename of shell script (replace argv[0])
*
* This is done in reverse order, because of how the
* user environment and arguments are stored.
*/
remove_arg_zero(bprm);
retval = copy_strings_kernel(1, &bprm->filename, bprm);
if (retval < 0) return retval;
bprm->argc++;
if (i_arg) {
retval = copy_strings_kernel(1, &i_arg, bprm);
if (retval < 0) return retval;
bprm->argc++;
}
retval = copy_strings_kernel(1, &i_name, bprm);
if (retval) return retval;
bprm->argc++;
/*
* OK, now restart the process with the interpreter's dentry.
*/
file = open_exec(interp);
if (IS_ERR(file))
return PTR_ERR(file);
bprm->file = file;
retval = prepare_binprm(bprm);
if (retval < 0)
return retval;
return search_binary_handler(bprm,regs);
}
可见,script文件的使用在装入运行的过程中引入了递归性,script_format最后又调用search_binary_handler。不管递归有多深,最终执行的一定是个二进制可执行文件,例如/bin/sh、/usr/bin/perl等解释程序。在递归的过程中,逐层的可执行文件路径名形成一个参数堆栈,传递给最终的解释程序。