Linux进程创建(二)

系统调用exec

上一步中我们完成了子进程的创建,但是子进程还没有开始运行自己的程序,因此在这一步子进程开始执行新的可执行程序,在进行详细介绍之前,借用书中的例子,我们调用fork创建子进程,fork在返回时会给父进程和子进程返回不同的值,因此两个进程通过该返回值判断自己是父进程还是子进程。 child_parent

execve的基本流程

注意到子进程中调用了execve(),其函数声明如下:

int execve(const char *filename, char *const argv[], char *const envp[]);

其中第一个参数表示执行的程序路径,第二个参数argv表示程序的参数,envp表示子进程的执行环境,即上下文。 首先介绍下execve()的基本流程:

  1. 首先将程序路径指针从用户空间拷贝到系统空间,这其中的过程比较复杂,考虑到该字符串可能非常长,为了不占用系统堆栈,系统会分配一个物理页面作为缓冲区,之后从用户空间拷贝字符串
  2. 第一步可以看作执行文件之前的预处理,之后execve通过调用do_execve执行主体部分工作:打开可执行文件并进行加载,值得注意的是加载时Linux内核定义了linux_binprm结构体来保存加载时所需的参数和字段
  3. 打开文件之后,函数开始解析每个参数,这里的参数也位于用户堆栈,因此采用与第一步相同的方式加载到内核空间,因此每有一个参数就会新建一个页面缓冲区,在拷贝参数的同时调用count()对参数计数,需要注意的是argv[0]永远是可执行文件的路径,每个可执行文件的参数数量等于传入的参数数量加一
  4. 第三步中虽然打开了文件,但是此时系统还不知道文件的格式,这里提点题外话,Linux中文件的前128个字节包含了该文件的属性信息,因此不像windows那样,Linux并不通过后缀名判断文件格式,我们可以通过file命令判断文件的类型。
  5. 最后把程序执行的参数和运行环境,从用户空间拷贝到系统空间中,由bprm统一管理

完成以上步骤的预处理之后我们执行目标文件的加载工作,首先运行search_binary_handler来寻找与目标文件类型对应的处理工具handler,不同的handler构成一个数组format,函数会遍历整个数组查找对应的handler,当找不到时去查看目标文件的第2、3个字节通过request_module()尝试读入相应的handler

加载目标文件的代码框架如下:

    for (try=0; try<2; try++){//尝试两次,第一次找不到handler之后尝试安装handler再试一次
        for (fmt = formats ; fmt ; fmt = fmt->next) {//遍历format数组
                调用load_binary
                if(load_binary不成功){ 
                    尝试下一个handler
                }
                //load_binary成功
                装载目标文件直接运行
                ret=运行返回结果
                if(ret >=0){
                    //说明找到了对应的handler
                    执行目标文件
                    }
                if(ret不为0且不是-ENOEXEC){
                    //说明找到了handler但出现其他错误,直接退出
                    }
                //ret=ENOEXEC说明handler与文件类型不匹配
            }
        //内层循环结束
        if(ret为-ENOEXEC){ //说明handler对不上号
            读取目标文件第2、3字节生成bitfmt模块并读入
                再次遍历format
            }
        }

从上面的代码分析中可以看出关键在于load_binary的内部机制,这也是目标文件的加载核心代码

书中以a.out作为可执行文件的例子说明load_library的装载和执行的过程,旧版本的Linux系统内核编译输出a.out格式的可执行文件,如centos和redhat等,而比较新的系统如ubuntu等已经使用elf格式

目标文件的装载和投入运行(a.out格式为例)

解析目标文件格式

前面说过在不同类型的目标文件对应不同的handler,各个handler都存在format数组中,下面的数据结构对应的就是a.out的handler

static struct linux_binfmt aout_format = {
    NULL, THIS_MODULE, load_aout_binary, load_aout_library, aout_core_dump, PAGE_SIZ
};

该结构体中load_aout_binary负责a.out格式的目标文件的装载和运行,我们看这个函数的开头部分: load_aout_binary 回忆一下bprm中包含了装载所需要的参数,其中就包括文件类型,代码里面ex变量是一个exec数据结构,该结构中包含着文件的MagicNumber, 这里需要介绍一下MagicNumber的概念:

MagicNumber本身是一种编码,也可以是字符,a.out文件格式一共有四种MagicNumber:ZMAGIC、OMAGIC、QMAGIC和NMAGIC

如果找不到与ex变量中匹配的magic number,那么说明该文件不是a.out格式。如果匹配到了magic number,那么我们可以获取目标文件的正文位置。但是在这之前还需要做一些检查,如PCB中对于数据最大空间的限制等,如果这些检查都通过,子进程这时可以放弃用户空间,因为需要拷贝的内容已经都在内核空间了

flush_old_exec清空用户空间

完成用户空间内容到系统空间的复制之后load_aout_binary执行flush_old_exec函数,首先处理信号处理表,如果子进程在这时与父进程共享一个信号处理表的话,需要将该表复制给子进程。

前面我们提到子进程可能共享父进程的用户空间,同样程序也只需要检查用户空间的共享计数,如果父进程独占用户空间,那么需要释放子进程mm_struct数据结构下面的所有页面,调用exec_mmap函数放弃父进程的用户空间。

然而我们fork创建进程时先拷贝了父进程用户空间的所有数据结构,却又在这里释放了所有空间,为什么这么大费周章呢,所以我们需要vfork作为fork的补充,我们调用vfor就不需要拷贝这些用户空间,vfork仅复制父进程的系统堆栈空间和PCB,且与父进程共享用户空间,因此这两个函数的实现不同点在于sys_vfork多了两个标志位指示子进程通过指针共享还是复制父进程的用户空间。

但是共享的情形下会出现一个问题:前面我们提到过Copy on Write, 子进程的页面访问权限设置为只读,只有当子进程需要写页面时才在页面异常处理程序为子进程复制物理页面,在共享状态下,子进程写页面会影响父进程,因此绝对不能让两个进程同时在内核态运行,所以Linux系统在创建子进程之后让父进程进入睡眠状态,等待子进程运行结束之后唤醒或者调用exit()退出,来避免这个问题。

唤醒和睡眠通过信号量的操作来实现,唤醒操作mm_release的代码如下: mm_release 通过上面的分析,我们可以得出如果子进程与父进程共享用户空间,那么其实也就没有释放用户空间这一说了,但此时需要为子进程分配一个mm_struct数据结构以及页面目录,以便于为子进程建立自己的用户空间,之后切换到新的用户空间。

到这里为止,当前进程的用户空间与父进程完全独立,可以唤醒父进程,并更新父进程用户空间的共享计数。

值得注意的是作者又详细介绍了PCB中的mm与active_mm指针的细节,mm指向用户空间,如果本进程是内核线程,那么mm为空,但是该进程被调用时要求active_mm不能为空,所以在调度时内核将其设置为在其之前运行的某个进程的active_mm,在运行时又将这个指针设置为0。

对于一般进程而言,mm不为空,因此我们不使用active_mm,我们调用mmdrop释放active_mm,递减其共享计数。

释放继承下来的用户空间之后,子进程的用户空间变为空,如果子进程原来只是一个线程的话,它的PCB会被挂入由其父进程为首的“线程组”队列,然而调用execve之后,子进程变成了真正意义上的进程,因此需要调用de_thread从线程组中脱离出来。同时由于子进程生成了独立的信号处理表,那么这个时候需要更新父进程信号处理表的共享计数,如果为0那么就释放。

这里需要介绍一下信号处理表的具体内容,基本与中断向量表类似,但不同的是,信号处理表中的每一项又对各种信号的预设响应,由于上面一步我们已经将内核线程的用户空间置为0并把它变为进程,我们需要将原来指向用户空间服务程序的表项更改为SIG_DFL,即默认预设值。

最后处理已打开的文件,PCB中有专门保存已打开文件信息的变量,同时还包含一个位图用来指示哪些文件在执行新的程序时应关闭的信息。所以flush_old_files最后一步工作就是根据这个位图关闭这些文件,并将该位图清0。一般情况下进程的头三个文件分别指stdin,stdout以及stderr,这三个文件不应关闭,而其他应该关闭。

子进程用户空间分配

Flush_old_exec函数返回之后,子进程与父进程之间已经完全独立,但是子进程的用户空间依旧为空,现在load_aout_binary函数将对mm_struct结构中的变量进行初始化并分配存储空间。

熟悉可执行目标文件格式的读者知道,目标文件的映像分为text、data及bss三段,分别用来存储指令、已初始化的全局变量和未初始化的全局变量。mm_struct为每个段都设置了start和end指针,程序映像的正文从text段开始,这里我们需要回顾一下之前讲到的magic number,当magic number为OMAGIC时,说明该文件中的可执行代码并不是纯代码,对这类文件分配空间时首先为text和data段合在一起分配空间,之后再把这两部分从文件中读进来,对于bss段只需要分配空间就可以。

除了OMAGIC之外的其他三种均为纯代码,这类代码在执行时不会改变,其data段的内容也不会改变,因此内核将可执行文件映射到用户空间中,之后是一些比较细节的工作,如果文件系统提供mmap,则需要将已打开的文件映射到虚存空间,此时检查text段与data段与页面大小对齐,如果不满足以上条件则分配空间并将text段与data段读入到用户空间,如果满足条件则直接映射。

至此,text段与data段的装载全部完成,下面就是bss段和堆栈段了。

接下来load_aout_binary函数通过set_brk函数为bss段分配空间并建立页面映射,接着在用户空间的堆栈顶部建立一个虚存空间,将执行参数以及进程上下文所占的物理页面与此空间建立映射。

用户空间中最高地址为堆栈区,堆栈区的顶部为一个数组,数组中每个元素都是一个页面,我们知道一般程序默认有两个参数,参数列表argv与参数数量argc,还有一个隐含的之前提到的上下文参数envp,该参数也是execve的参数之一,用户堆栈中从一开始就是设置好这三项数据,并将这些参数复制到用户空间的顶端。这些工作由create_aout_tables函数完成。

在这里作者又提出了一个问题,如果我们要从用户空间读取某个变量到内核空间,那么我们应该使用get_user 函数,我们来看一下get_user的函数声明: get_user 首先get_user是一个宏定义,用于从用户空间读取简单类型的变量,如一个字节,短整数和长整数,那么为什么要用x而不是x的地址呢? get_user中实际调用的是__get_user_x这个汇编函数,该函数只有一条汇编指令,该指令执行以下操作:

  1. eax寄存器和ret绑定
  2. edx寄存器与x绑定
  3. 以上两个寄存器作为__get_user_x的输出
  4. ptr指针存入eax寄存器 我们结合__get_user_x看,可以发现__ret_gu中存着用户空间的变量地址,122行的代码表示考虑到符号原因,我们要强制对x做类型转换,所以不得不使用变量,最后get_user返回ptr的值

因此实际的赋值过程只是寄存器操作,这也就是我们不使用变量地址的原因。

从create_aout_tables返回之后,堆栈顶端的参数已经准备好,这里只剩下最后一步start_thread的工作就是保留保存上下文,当进程从系统调用返回时,这些数值会恢复到CPU的各个寄存器中。

至此,可执行代码的装载已经完成,do_execve在调用search_binary_handler之后也结束了。