结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

1.exec族系统调用

在linux中,把程序载入内存并执行程序映像的操作与创建新进程的操作是分离的。一次系统调用会把二进制程序加载到内存中,替换地址空间原来的内容,并开始执行。

这个过程称为“执行(Executing)”一个新的程序,是通过一系列executing系统调用来完成的。同时,另一个不同的系统调用是用于创建一个新的进程,它基本上相当于复制其父进程。

通常情况下,新的进程会立即执行新的程序。创建新进程的操作称为派生(fork),是系统调用fork()来完成这个功能。

在新进程中执行一个新的程序需要两个步骤:首先,创建一个新的进程。然后,通过exec系统调用把新的二进制程序加载到该进程中。

不存在单一的exec函数,而是基于单个系统调用,由一系列的exec函数构成。打开execv族系统调用的man手册如下所示:

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

先看其中最简单的调用execl():该调用会把path所指路径的映像载入内存,替换当前进程的映像。参数arg是它的第一个参数。省略号表示可变长度的参数列表(必须以NULL结尾)。                                                                                                                              通常情况下,execl()不会返回。调用成功时,会跳转到新的程序入口点,而刚刚运行的代码是不再存在于进程的地址空间中。错误时,execl()会返回-1,并设置相应的errno值,表示错误信息。

成功的execl()系统调用不仅改变了地址空间和进程映像,还改变了进程的其他一些属性:

1.所有挂起的信号都会丢失。

2.捕捉到的所有信号都会还原为默认处理方式,因为信号处理函数已经不存在于地址空间中了。

3.重置大多数进程相关的统计信息。

4.清空和进程内存地址空间相关的所有数据,包括所有映射文件。

5.清空所有只存在于用户空间的数据,包括C库的一些功能。

但是,进程的某些属性还是没有改变,如pid、父进程的pid、优先级、所属的用户和组。

通常,打开的文件描述符也通过exec继承下来。这意味着如果新进程知道原进程所打开的文件描述符,它就可以访问所有这些文件。但是,这通常不是所期望的行为。

所以实际操作过程中一般会调用exec前关闭打开的文件,当然,也可以通过fcntl(),让内核去自动完成关闭操作。

当然,除了execl()外,exec族还有其他5个函数,如上所示。这些函数很容易记住。l和v分别表示参数是以列表方式还是数组方式提供的。

p表示会在用户的绝对路径path下查找可执行文件。使用变量p的命令可以只指定文件名,该文件必须在用户路径下。

最后,e表示会为新进程提供新的环境变量。奇怪的是,exec函数中没有一个同时可以搜索路径和使用新环境变量的函数。

这可能是因为带p的exec函数主要是用于shell的,因为shell执行的进程通常会从shell本身继承环境变量。

exec族函数也可以接受数组,先构建数组,再把该数组作为参数传递。使用数组可以支持在运行时确定参数。对于可变参数列表,数据必须以NULL结束。

在Linux中,exec族函数中只有一个是真正的系统调用,其他的都是基于该系统调用在C库中封装的函数。

由于处理变长参数的系统调用难于实现,而且用户的路径只存在于用户空间中,所以execve是唯一的系统调用,其原型和用户调用完全相同。

下面举例分析execve系统调用的过程:

在真正的开始执行系统调用函数之前,系统调用服务程序已经将一些系统调用的函数的参数传递给了相应的寄存器,比如这里的ebx,ecx,edx都分别保存了系统调用的参数,

ebx保存的是第一个参数,依次类推(当然最多传递的参数个数不能大于5个),首先这个函数通过ebx获取需要执行的文件的绝对路径,可以通过这样一个函数实现:

1 filename = getname((char __user *) regs.ebx);
2     error = PTR_ERR(filename);

execve获取参数列表后调用do_execve()。

ecx,edx寄存器就是传递给可执行文件的参数指针和环境变量参数指针,这里会生成一个记录可执行文件的信息的结构体--struct linux_binprm,结构如下:

 1 struct linux_binprm{
 2     char buf[BINPRM_BUF_SIZE];
 3     struct page *page[MAX_ARG_PAGES];
 4     struct mm_struct *mm;
 5     unsigned long p; /* current top of mem */
 6     int sh_bang;
 7     struct file * file;
 8     int e_uid, e_gid;
 9     kernel_cap_t cap_inheritable, cap_permitted, cap_effective;
10     void *security;
11     int argc, envc;
12     char * filename;    /* Name of binary as seen by procps */
13     char * interp;        /* Name of the binary really executed. Most
14                    of the time same as filename, but could be
15                    different for binfmt_{misc,script} */
16     unsigned interp_flags;
17     unsigned interp_data;
18     unsigned long loader, exec;
19 };

这个结构体记录可执行文件的信息,用于比对可执行文件的格式找到相应的加载函数,如a.out就调用load_aout_binary()函数开始通过 linux_binprm结构体里面的信息准备此程序的执行工作。

这个函数里面会调用open_exec()函数通过获取到的filename将文件打开,并且通过寄存器ecx,edx指向的参数先通过count()计算函数参数的个数和环境变量的个数,

然后根据路径和个数从用户空间拷贝过来保存在这个结构体里面,同时还要拷贝可执行文件头部128字节的信息到这个结构体里面的buff中(可执行文件的头部包含了大量的可执行文件的信息,

如:文件的类型,文件的大小,代码段在本文件中的偏移量等信息,这些信息后边都要用到),这个时候通过用户传递的信息已经全部从用户空间读取到了这个内核linux_binprm这个临时结构体中,

此时就要做的工作就是:在内核支持的可执行文件的对应的加载函数队列中循环遍历,找到适合此文件类型的加载函数(通过search_binary_handle()实现搜索),如a.out格式的文件的加载函数是:load_aout_binary()
注释:内核中有一个formats队列,相当于各种文件的代理人,由它来通过读进来的128字节头部信息来认领这个可执行文件。到了这个函数就是要建立新进程了,与过去告别的时候了,

它通过调用flush_old_exec()函数来独立门户,准备成为一个独立的进程,比如更新PCB,更新信号处理表,内存等(如果这里的信号处理表是通过指针共享父进程的信号处理表,

这里就要对其进行复制)。这个函数就专门负责与过去告别,他先调用exec_mmap()函数将内存结构释放,他将从父进程复制过来的(fork())mm_struct下的vm_area_struct全部释放掉,

但是如果通过vfork()到此步骤就不需要释放(因为vfork()他全部是通过指针共享父进程的mm_struct的等这种结构),因为fork()之后不一定要调用exec ve()函数,

到了这里非替换不可时在替换,其实提高了fork()的效率。当然要是调用execve()这里是很浪费效率的,于是就有了vfork()他就是通过共享指针共享父进程的结构这里就不需要释放,

大大提高了效率,虽然vm_area_struct不用释放,但是mm_struct结构也是共享,此时要独立就需要自己有一个独立的,因此要开辟一个mm_struct结构,

同时释放父进程mm_struct的引用计数(如果释放之后mm_struct的引用计数变为零了,就表明这个结构没有进程使用了,就需要释放他,这里用mmdrop()函数实现释放),

当然对于vfork()的父进程的mm_struct是不可能为零的,此时就成为了真正的进程,还需要把其从父进程的线程组中脱离出来,这个通过de_thread()实现,此时vfork()之后调用的execve()就成为了一个进程了。

虚拟内存反面独立之后就完成了一大步了,这里就是信号的独立了,信号处理表是从父进程复制过来的,而信号处理表指向的信号服务程序有三种方式,他们分别是:忽视此信号,采用默认的方式,

采用进程注册的用户处理方法,但是从父进程复制过来的信号处理表指向父进程的用户注册的函数,并不在次进程空间,因此这 里要调用flush_signal_handle()函数来遍历一遍信号处理表将第三种方式的处里信号的方式改为默认的,

这里完成了信号的独立。最后就是关闭从父进程复制过来的文件系统,将父进程打开的文件在这里关闭,这里可以保留0,1,2,三个文件不关闭(stdin,stdout,stderr)这里就完全和父进程独立成为了一个真正的进程了,

然后就是建立可执行文件和虚拟内存之间的映射,建立vm_area_struct结构,将text,data,bss,建立起虚拟映射,同时还要将在运行main()函数的参数和环境变量建立起映射,这个都在linux_binprm结构中,

到了这里execve()函数的任务也就完成了在真正执行main()函数之前,例如gcc会先执行真正的其实起始函数_start(),通过libc_start_main()来调用main()函数,在调用main()函数之前,会初始化IO,堆等执行环境,

这样在执行main()的时候用户才可以尽情使用malloc,printf,scanf等操作,main()执行完之后,会调用_exit()结束进程的生命(释放内存等),它不带返回值,因为它是进程结束前最后一个函数。

2.fork系统调用

通过fork系统调用,可以创建一个和当前进程映像一样的进程。其man手册如下所示:

 结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

当fork()调用成功时,会创建一个新的进程,它几乎与调用fork()的进程完全相同,只是运行在不同的内存空间。

这两个进程都会继续运行,调用进程从fork()返回后,还是照常运行。新进程称为原进程的“子进程”,原进程称为“父进程”。

在子进程中,成功的fork()调用会返回0。在父进程中,fork()会返回子进程的pid。

除了一些本质性区别外,父进程和子进程之间在其他各个方面都完全相同:

1.子进程的pid是新分配的,与父进程不同。

2.子进程的ppid会设置成父进程的pid。

3.子进程中的资源统计信息(Resource statistics)会清零。

4.所有挂起的信号都会清除,也不会被子进程继承。

5.所有的文件锁也都不会被子进程所继承。

出错时,不会创建子进程,fork()返回-1,并设置相应的errno值。有两种可能值:

EAGAIN  内核申请资源时失败,例如达到了进程数上限。

ENOMEM  内核内存不足,无法满足所请求的操作。

fork()系统调用的用法如下:

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

编译运行如下所示:

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

最常见的fork()用法是创建一个新的进程,载入新的二进制映像(类似shell为用户创建一个新的进程,或者一个进程创建了一个辅助进程)。

首先,该进程创建了新的进程,而这个新建的子进程会执行一个新的二进制可执行文件的映像。这种“派生/执行“的方式很常见,而且非常简单。

下面的例子创建了一个新的进程来运行/bin/pwd:

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

除了创建一个子进程外,父进程会照常继续运行。调用execv()会使子进程运行/bin/pwd。

编译运行如下图所示:

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

在调用fork时,内核采用了写时复制(copy-on-write)的方式,而不是对父进程空间的整体复制。

3.写时复制

写时复制是一种基于惰性算法的优化策略,为了避免复制时的系统开销。其前提假设很简单:如果有多个进程要读取它们自己那部分资源的副本,

那么复制是不必要的。每个进程只要保存一个指向这个资源的指针就可以了。只要没有一个进程修改自己的“副本”,每个进程就好像独占那个资源,

从而避免了复制带来的开销。如果某个进程想要修改自己的那份资源“副本”,就会开始复制该资源,并把副本提供给这个进程。复制过程对于进程而言是“透明”的。

这个进程后面就可以反复修改其持有的副本,而其他进程还是共享原来那份没有修改过的资源。这就是“写时复制”这个名称的由来:只有在写入时才执行复制。

写时复制的主要好处在于:如果进程从未修改资源,则都不需要执行复制。一般来说,惰性算法的好处就在于它们会尽量延迟代价高的操作,直到必要时才执行。

4.Linux系统的一般执行过程

首先是正在运行的用户态进程发生中断(包括异常、系统调用等),CPU完成load cs:rip(entry of a specific ISR),即跳转到中断处理程序入口。

中断上下文切换,具体包括如下几点:
 1.swapgs指令保存现场即保存当前CPU寄存器状态。
 2.rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。
 3.save cs:rip/ss:rsp/rflags:将当前CPU关键上下文压入中断进程的内核堆栈,快速系统调用是由系统调用入口处的汇编代码实现的。
 此时完成了中断上下文切换,即从中断进程的用户态到内核态。

中断处理过程中或中断返回前调用了schedule函数,其中完成了进程调度算法选择next进程、进程地址空间切换、以及switch_to关键的进程上下文切换等。

switch_to调用了__switch_to_asm汇编代码做了关键的进程上下文切换。将当前进程的内核堆栈切换到进程调度算法选出来的next进程的内核堆栈,

并完成了进程上下文所需的指令指针寄存器状态切换。之后开始运行切换进程。中断上下文恢复,与中断上下文切换相对应。

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

上一篇:安装git-macOS系统


下一篇:编写shell脚本实现tomcat定时重启