结合中断上下文切换和进程上下文切换分析Linux内核通常执行过程node
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用做为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的通常执行过程
完成一篇博客总结分析Linux系统的通常执行过程,以期对Linux系统的总体运做造成一套逻辑自洽的模型,并能将所学的各类OS和Linux内核知识/原理融通进模型中linux
发行版本:Ubuntu 18.04.4 LTS算法
处理器:Intel® Core™ i7-8850H CPU @ 2.60GHz × 3shell
图形卡:Parallels using AMD® Radeon pro 560x opengl engine网络
GNOME:3.28.2数据结构
中断发生之后,CPU跳到内核设置好的中断处理代码中去,由这部份内核代码来处理中断。这个处理过程当中的上下文就是中断上下文。异步
几乎全部的体系结构,都提供了中断机制。当硬件设备想和系统通讯的时候,它首先发出一个异步的中断信号去打断处理器的执行,继而打断内核的执行。中断一般对应着一个中断号,内核经过这个中断号找到中断服务程序,调用这个程序响应和处理中断。当你敲击键盘时,键盘控制器发送一个中断信号告知系统,键盘缓冲区有数据到来,内核收到这个中断号,调用相应的中断服务程序,该服务程序处理键盘数据而后通知键盘控制器能够继续输入数据了。为了保证同步,内核可使用停止---既能够中止全部的中断也能够有选择地中止某个中断号对应的中断,许多操做系统的中断服务程序都不在进程上下文中执行,它们在一个与全部进程无关的、专门的中断上下文中执行。之因此存在这样一个专门的执行环境,为了保证中断服务程序可以在第一时间响应和处理中断请求,而后快速退出。xss
对同一个CPU来讲,中断处理比进程拥有更高的优先级,因此中断上下文切换并不会与进程上下文切换同时发生。因为中断程序会打断正常进程的调度和运行,大部分中断处理程序都短小精悍,以便尽量快的执行结束。函数
一个进程的上下文能够分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。学习
用户级上下文: 正文、数据、用户堆栈以及共享存储区;
寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
当发生进程调度时,进行进程切换就是上下文切换(context switch)。操做系统必须对上面提到的所有信息进行切换,新调度的进程才能运行。而系统调用进行的是模式切换(mode switch)。模式切换与进程切换比较起来,容易不少,并且节省时间,由于模式切换最主要的任务只是切换进程寄存器上下文的切换。
fork()系统调用会经过复制一个现有进程来建立一个全新的进程. 进程被存放在一个叫作任务队列的双向循环链表当中。链表当中的每一项都是类型为task_struct成为进程描述符的结构。
首先咱们来看一段代码
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(){ pid_t pid; char *message; int n; pid = fork(); if(pid<0){ perror("fork failed"); exit(1); } if (pid == 0){ message = "this is the child \n"; n=6; }else { message = "this is the parent \n"; n=3; } for(;n>0;n--){ printf("%s",message); sleep(1); } return 0; }
在Linux环境中编写和执行
# 建立一个C文件,名为t.c,将上面的代码拷贝进去 touch t.c # 进行编译 gcc t.c # 执行 ./a.out
之因此输出是这样的结果,是由于程序的执行流程以下图所示:
以上的fork()例子的执行流程大体以下:
fork
,这是一个系统调用,所以进入内核。fork
进入内核,尚未从内核返回。fork
进入内核等待从内核返回(实际上fork
只调用了一次),此外系统中还有不少别的进程也等待从内核返回。是父进程先返回仍是子进程先返回,仍是这两个进程都等待,先去调度执行别的进程,这都不必定,取决于内核的调度算法。fork
函数返回,保存在变量pid
中的返回值是子进程的id,是一个大于0的整数,所以执下面的else
分支,而后执行for
循环,打印"This is the parent\n"
三次以后终止。fork
函数返回,保存在变量pid
中的返回值是0,所以执行下面的if (pid == 0)
分支,而后执行for
循环,打印"This is the child\n"
六次以后终止。fork
调用把父进程的数据复制一份给子进程,但此后两者互不影响,在这个例子中,fork
调用以后父进程和子进程的变量message
和n
被赋予不一样的值,互不影响。sleep(1);
去掉看程序的运行结果如何。This is the child
的下一行,这时用户仍然能够敲命令,即便命令不是紧跟在提示符后面,Shell也能正确读取。fork()最特殊之处在于:成功调用后返回两个值,是因为在复制时复制了父进程的堆栈段,因此两个进程都停留在fork函数中,等待返回。因此fork函数会返回两次,一次是在父进程中返回,另外一次是在子进程中返回,这两次的返回值不一样,
其中父进程返回子进程pid,这是因为一个进程能够有多个子进程,可是却没有一个函数可让一个进程来得到这些子进程id,那谈何给别人你建立出来的进程。而子进程返回0,这是因为子进程能够调用getppid得到其父进程进程ID,但这个父进程ID却不可能为0,由于进程ID0老是有内核交换进程所用,故返回0就可表明正常返回了。
从fork函数开始之后的代码父子共享,既父进程要执行这段代码,子进程也要执行这段代码.(子进程得到父进程数据空间,堆和栈的副本. 可是父子进程并不共享这些存储空间部分. (即父,子进程共享代码段.)。如今不少实现并不执行一个父进程数据段,堆和栈的彻底复制. 而是采用写时拷贝技术。这些区域有父子进程共享,并且内核地他们的访问权限改成只读的.若是父子进程中任一个试图修改这些区域,则内核值为修改区域的那块内存制做一个副本, 也就是若是你不修改咱们一块儿用,你修改了以后对于修改的那部份内容咱们分开各用个的。
再一个就是,在重定向父进程的标准输出时,子进程标准输出也被重定向。这就源于父子进程会共享全部的打开文件。 由于fork的特性就是将父进程全部打开文件描述符复制到子进程中。当父进程的标准输出被重定向,子进程本是写到标准输出的时候,此时天然也改写到那个对应的地方;与此同时,在父进程等待子进程执行时,子进程被改写到文件show.out中,而后又更新了与父进程共享的该文件的偏移量;那么在子进程终止后,父进程也写到show.out中,同时其输出还会追加在子进程所写数据以后。
在fork以后处理文件描述符通常有如下两种状况:
同时父子进程也是有区别的:它们不只仅是两个返回值不一样;它们各自的父进程也不一样,父进程的父进程是ID不变的;还有子进程不继承父进程设置的文件锁,子进程未处理的信号集会设置为空集等不一样
事实上linux平台经过clone()系统调用实现fork()。fork(),vfork()和clone()库函数都根据各自须要的参数标志去调用clone(),而后由clone()去调用do_fork(). 再而后do_fork()完成了建立中的大部分工做,他定义在kernel/fork.c当中.该函数调用copy_process()。
具体的流程能够参考下图:
execve() 系统调用的做用是运行另一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和全部的段数据都会被新进程相应的部分代替,而后会重新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。
execve() 系统调用一般与 fork() 系统调用配合使用。从一个进程中启动另外一个程序时,一般是先 fork() 一个子进程,而后在子进程中使用 execve() 变身为运行指定程序的进程。 例如,当用户在 Shell 下输入一条命令启动指定程序时,Shell 就是先 fork() 了自身进程,而后在子进程中使用 execve() 来运行指定的程序。
Linux提供了execl、execlp、execle、execv、execvp和execve等六个用以执行一个可执行文件的函数(统称为exec函数,其间的差别在于对命令行参数和环境变量参数的传递方式不一样)。这些函数的第一个参数都是要被执行的程序的路径,第二个参数则向程序传递了命令行参数,第三个参数则向程序传递环境变量。以上函数的本质都是调用在arch/i386/kernel/process.c文件中实现的系统调用sys_execve来执行一个可执行文件。
asmlinkage int sys_execve(struct pt_regs regs) { int error; char * filename; //将可执行文件的名称装入到一个新分配的页面中 filename = getname((char __user *) regs.ebx); error = PTR_ERR(filename); if (IS_ERR(filename)) goto out; //执行可执行文件 error = do_execve(filename, (char __user * __user *) regs.ecx, (char __user * __user *) regs.edx, ®s); if (error == 0) { task_lock(current); current->ptrace &= ~PT_DTRACE; task_unlock(current); set_thread_flag(TIF_IRET); } putname(filename); out: return error; }
该系统调用所须要的参数pt_regs在include/asm-i386/ptrace.h文件中定义。该参数描述了在执行该系统调用时,用户态下的CPU寄存器在核心态的栈中的保存状况。经过这个参数,sys_execve能够得到保存在用户空间的如下信息:可执行文件路径的指针(regs.ebx中)、命令行参数的指针(regs.ecx中)和环境变量的指针(regs.edx中)。
struct pt_regs { long ebx; long ecx; long edx; long esi; long edi; long ebp; long eax; int xds; int xes; long orig_eax; long eip; int xcs; long eflags; long esp; int xss; };
regs.ebx保存着系统调用execve的第一个参数,便可执行文件的路径名。由于路径名存储在用户空间中,这里要经过getname拷贝到内核空间中。getname在拷贝文件名时,先申请了一个page做为缓冲,而后再从用户空间拷贝字符串。为何要申请一个页面而不使用进程的系统空间堆栈?首先这是一个绝对路径名,可能比较长,其次进程的系统空间堆栈大约为7K,比较紧缺,不宜滥用。用完文件名后,在函数的末尾调用putname释放掉申请的那个页面。
sys_execve的核心是调用do_execve函数,传给do_execve的第一个参数是已经拷贝到内核空间的路径名filename,第二个和第三个参数仍然是系统调用execve的第二个参数argv和第三个参数envp,它们表明的传给可执行文件的参数和环境变量仍然保留在用户空间中。简单分析一下这个函数的思路:先经过open_err()函数找到并打开可执行文件,而后要从打开的文件中将可执行文件的信息装入一个数据结构linux_binprm,do_execve先对参数和环境变量的技术,并经过prepare_binprm读入开头的128个字节到linux_binprm结构的bprm缓冲区,最后将执行的参数从用户空间拷贝到数据结构bprm中。内核中有一个formats队列,该队列的每一个成员认识并只处理一种格式的可执行文件,bprm缓冲区中的128个字节中有格式信息,便要经过这个队列去辨认。do_execve()中的关键是最后执行一个search_binary_handler()函数,找到对应的执行文件格式,并返回一个值,这样程序就能够执行了。
do_execve 定义在 <fs/exec.c> 中,关键代码解析以下。
int do_execve(char * filename, char __user *__user *argv, char __user *__user *envp, struct pt_regs * regs) { struct linux_binprm *bprm; //保存要执行的文件相关的数据 struct file *file; int retval; int i; retval = -ENOMEM; bprm = kzalloc(sizeof(*bprm), GFP_KERNEL); if (!bprm) goto out_ret; //打开要执行的文件,并检查其有效性(这里的检查并不完备) file = open_exec(filename); retval = PTR_ERR(file); if (IS_ERR(file)) goto out_kfree; //在多处理器系统中才执行,用以分配负载最低的CPU来执行新程序 //该函数在include/linux/sched.h文件中被定义以下: // #ifdef CONFIG_SMP // extern void sched_exec(void); // #else // #define sched_exec() {} // #endif sched_exec(); //填充linux_binprm结构 bprm->p = PAGE_SIZE*MAX_ARG_PAGES-sizeof(void *); bprm->file = file; bprm->filename = filename; bprm->interp = filename; bprm->mm = mm_alloc(); retval = -ENOMEM; if (!bprm->mm) goto out_file; //检查当前进程是否在使用LDT,若是是则给新进程分配一个LDT retval = init_new_context(current, bprm->mm); if (retval 0) goto out_mm; //继续填充linux_binprm结构 bprm->argc = count(argv, bprm->p / sizeof(void *)); if ((retval = bprm->argc) 0) goto out_mm; bprm->envc = count(envp, bprm->p / sizeof(void *)); if ((retval = bprm->envc) 0) goto out_mm; retval = security_bprm_alloc(bprm); if (retval) goto out; //检查文件是否能够被执行,填充linux_binprm结构中的e_uid和e_gid项 //使用可执行文件的前128个字节来填充linux_binprm结构中的buf项 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; //查询可以处理该可执行文件格式的处理函数,并调用相应的load_library方法进行处理 retval = search_binary_handler(bprm,regs); if (retval >= 0) { free_arg_pages(bprm); //执行成功 security_bprm_free(bprm); acct_update_integrals(current); kfree(bprm); return retval; } out: //发生错误,返回inode,并释放资源 for (i = 0 ; i MAX_ARG_PAGES ; i++) { struct page * page = bprm->page; if (page) __free_page(page); } if (bprm->security) security_bprm_free(bprm); out_mm: if (bprm->mm) mmdrop(bprm->mm); out_file: if (bprm->file) { allow_write_access(bprm->file); fput(bprm->file); } out_kfree: kfree(bprm); out_ret: return retval; }
该函数用到了一个类型为linux_binprm的结构体来保存要执行的文件相关的信息,该结构体在include/linux/binfmts.h文件中定义:
struct linux_binprm{ char buf[BINPRM_BUF_SIZE]; //保存可执行文件的头128字节 struct page *page[MAX_ARG_PAGES]; struct mm_struct *mm; unsigned long p; //当前内存页最高地址 int sh_bang; struct file * file; //要执行的文件 int e_uid, e_gid; //要执行的进程的有效用户ID和有效组ID kernel_cap_t cap_inheritable, cap_permitted, cap_effective; void *security; int argc, envc; //命令行参数和环境变量数目 char * filename; //要执行的文件的名称 char * interp; //要执行的文件的真实名称,一般和filename相同 unsigned interp_flags; unsigned interp_data; unsigned long loader, exec; };
在该函数的最后,又调用了fs/exec.c文件中定义的search_binary_handler函数来查询可以处理相应可执行文件格式的处理器,并调用相应的load_library方法以启动进程。这里,用到了一个在include/linux/binfmts.h文件中定义的linux_binfmt结构体来保存处理相应格式的可执行文件的函数指针以下:
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 *); // 将当前进程的上下文保存在一个名为core的文件中 int (*core_dump)(long signr, struct pt_regs * regs, struct file * file); unsigned long min_coredump; };
Linux内核容许用户经过调用在include/linux/binfmt.h文件中定义的register_binfmt和unregister_binfmt函数来添加和删除linux_binfmt结构体链表中的元素,以支持用户特定的可执行文件类型。
在调用特定的load_binary函数加载必定格式的可执行文件后,程序将返回到sys_execve函数中继续执行。该函数在完成最后几步的清理工做后,将会结束处理并返回到用户态中,最后,系统将会将CPU分配给新加载的程序。
execve系统调用的过程总结以下:
Linux系统的通常执行过程
正在运行的用户态进程X切换到运行用户态进程Y的过程
发生中断 ,完成如下步骤:
save cs:eip/esp/eflags(current) to kernel stack
load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack)
SAVE_ALL //保存现场,这里是已经进入内核中断处里过程
中断处理过程当中或中断返回前调用了schedule(),其中的switch_to作了关键的进程上下文切换
标号1以后开始运行用户态进程Y(这里Y曾经经过以上步骤被切换出去过所以能够从标号1继续执行)
restore_all //恢复现场
继续运行用户态进程Y
进程间的特殊状况
此次实验主要作了以下的事情: