结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程
要求:
1.以fork和execve系统调用为例分析中断上下文的切换;
2.分析execve系统调用中断上下文的特殊之处;
3.分析fork子进程启动执行时进程上下文的特殊之处;
4.以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程。
一、fork
fork系统调用用于从已存在进程中创建一个新进程,新进程称为子进程,而原进程称为父进程。fork调用一次,返回两次,这两个返回分别带回它们各自的返回值,其中在父进程中的返回值是子进程的进程号,而子进程中的返回值则返回 0。因此,可以通过返回值来判定该进程是父进程还是子进程。
fork系统调用的原型为:
所需头文件 |
#include <sys/types.h> // 提供类型 pid_t 的定义 #include <unistd.h> |
函数说明 |
建立一个新的进程 |
函数原型 | pid_t fork(void) |
函数返回值 | 0:返回给子进程 |
子进程的ID(大于0的整数):返回给父进程 | |
-1:出错,返回给父进程,错误原因存于errno中 | |
错误代码 |
EAGAIN:内存不足 |
|
使用fork函数得到的子进程是父进程的一个复制品,它从父进程处继承了整个进程的地址空间,包括进程上下文、进程堆栈、内存信息、打开的文件描述符、信号控制设定、进程优先级、进程组号、当前工作目录、根目录、资源限制、控制终端等,而子进程所独有的只有它的进程号、计时器等。因此可以看出,使用fork系统调用的代价是很大的,它复制了父进程中的数据段和堆栈段里的绝大部分内容,使得fork系统调用的执行速度并不很快。
fork的返回值这样设计是有原因的,fork在子进程中返回0,子进程仍可以调用getpid函数得到自己的进程ID,也可以调用getppid函数得到父进程的进程ID。在父进程中使用getpid函数可以得到自己的进程ID,然而要想得到子进程的进程ID,只有将fork的返回值记录下来,别无它法。
fork的另一个特性是所有由父进程打开的文件描述符都被复制到子进程中。父、子进程中相同编号的文件描述符在内核中指向同一个file结构体,也就是说,file结构体的引用计数要增加。
由于代码段(加载到内存的执行码)在内存中是只读的,所以父子进程可共用代码段,而数据段和堆栈段子进程则完全从父进程复制拷贝了一份。
假设id=fork(),父进程进行fork系统调用时,fork所做工作如下:
① 为新进程分配task_struct任务结构体内存空间;
② 把父进程task_struct任务结构体复制到子进程task_struct任务结构体;
③ 为新进程在其内存上建立内核堆栈;
④ 对子进程task_struct任务结构体中部分变量进行初始化设置;
⑤ 把父进程的有关信息复制给子进程,建立共享关系;
⑥ 把子进程加入到可运行队列中;
⑦ 结束fork()函数,返回子进程ID值给父进程中栈段变量id;
⑧ 当子进程开始运行时,操作系统返回0给子进程中栈段变量id。
下面代码是fork函数调用模板,fork函数调用后常与if-else语句结合使用使父子进程执行不同的流程。假设下面代码执行时产生的是P1进程,fork后产生子进程的是P2进程:
int pid ;
pid = fork();
if (pid < 0) {
fprintf(stderr, "Fork Failed\n");
exit(1);
}else if (pid == 0) {
fprintf("This is the child/n");
exit(0);
}
在调用fork之前,内存中只有P1进程。调用fork后,内存中不仅有P1进程(父进程),还有P2进程(子进程)。fork的时候,系统几乎把父进程整个堆栈段(除代码段,代码段父子进程是共享的)复制给了子进程,复制完成后,P1进程和P2进程是两个独立的进程,P1进程栈中变量id值此时为P2进程的pid,而P2进程栈中变量id值为0。fork调用完成后,P1进程由系统态回到用户态。此后,P1进程和P2进程各自都需要从自己代码段指针指向的代码点继续往下执行,父进程P1往下执行时判断id大于0,所以执行大于0的程序段,而子进程P2往下执行时判断id等于0,所以执行等于0的程序段。
现举一实例表示fork系统调用的中断上下文执行过程:
#include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> int main(int argc, char** argv){ pid_t pid; char *message; int n; pid = fork(); if (pid < 0) { perror("fork failed"); exit(-1); }else if (pid == 0) { message = "This is the child\n"; n = 3; }else { wait(0) ; /*阻塞等待子进程返回*/ message = "This is the parent\n"; n = 1; } for(; n > 0; n--) { printf(message); sleep(1); } return 0; }
将文件进行编译和执行,其命令和输出结果如下:
gcc fork.c –o fork ./fork This is the child This is the child This is the child This is the parent
修改sleep(n)中的参数,效果可以更明显。
二、execve
进程创建的过程中,子进程先按照父进程复制出来,然后与父进程分离,单独执行一个可执行程序。这要用到系统调用execve(),在c语言库中提供一整套库函数。
execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。
execve() 系统调用通常与 fork() 系统调用配合使用。从一个进程中启动另一个程序时,通常是先 fork() 一个子进程,然后在子进程中使用 execve() 变身为运行指定程序的进程。 例如,当用户在 Shell 下输入一条命令启动指定程序时,Shell 就是先 fork() 了自身进程,然后在子进程中使用 execve() 来运行指定的程序。
execve() 系统调用的函数原型为:
int execve(const char *filename, char *const argv[], char *const envp[]);
filename 用于指定要运行的程序的文件名,argv 和 envp 分别指定程序的运行参数和环境变量。除此之外,该系列函数还有很多变体,它们执行大体相同的功能,区别在于需要的参数不同,包括 execl、execlp、execle、execv、execvp、execvpe 等。它们的参数意义和使用方法请读者自行查看帮助手册。
需要注意的是,exec 系列函数的返回值只在遇到错误的时候才有意义。如果新程序成功地被执行,那么当前进程的所有数据就都被新进程替换掉了,所以永远也不会有任何返回值。
对于已打开文件的处理,在 exec() 系列函数执行之前,应该确保全部关闭。因为 exec() 调用之后,当前进程就完全变身成另外一个进程了,老进程的所有数据都不存在了。如果 exec() 调用失败,当前打开的文件状态应该被保留下来。让应用层处理这种情况会非常棘手,而且有些文件可能是在某个库函数内部打开的,应用对此并不知情,更谈不上正确地维护它们的状态了。
所以,对于执行 exec() 函数的应用,应该总是使用内核为文件提供的执行时关闭标志(FD_CLOEXEC)。设置了该标志之后,如果 exec() 执行成功,文件就会被自动关闭;如果 exec() 执行失败,那么文件会继续保持打开状态。使用系统调用 fcntl() 可以设置该标志。
系统调用execve的内核入口为sys_execve,定义在<arch/kernel/process.c>中,代码为:
/* *sys_execve() executes a new program. */ asmlinkage int sys_execve(struct pt_regs regs) { int error; char * filename; /*regs.ebx中存放指向第一个参数的指针,通过getname函数将指针指向的参数有用户空间拷贝到系统空间*/ 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; }
这个函数的主要部分是do_execve。regs.ebx保存着系统调用execve的第一个参数,即可执行文件的路径名。因为路径名存储在用户空间中,这里要通过getname拷贝到内核空间中。getname在拷贝文件名时,先申请了一个page作为缓冲,然后再从用户空间拷贝字符串。为什么要申请一个页面而不使用进程的系统空间堆栈?首先这是一个绝对路径名,可能比较长,其次进程的系统空间堆栈大约为7K,比较紧缺,不宜滥用。用完文件名后,在函数的末尾调用putname释放掉申请的那个页面。
简单分析一下这个函数的思路:先通过open_err()函数找到并打开可执行文件,然后要从打开的文件中将可执行文件的信息装入一个数据结构linux_binprm,do_execve先对参数和环境变量的技术,并通过prepare_binprm读入开头的128个字节到linux_binprm结构的bprm缓冲区,最后将执行的参数从用户空间拷贝到数据结构bprm中。内核中有一个formats队列,该队列的每个成员认识并只处理一种格式的可执行文件,bprm缓冲区中的128个字节中有格式信息,便要通过这个队列去辨认。do_execve()中的关键是最后执行一个search_binary_handler()函数,找到对应的执行文件格式,并返回一个值,这样程序就可以执行了。
有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[]);
它们间的关系如下图:
sys_execve的核心是调用do_execve函数,传给do_execve的第一个参数是已经拷贝到内核空间的路径名filename,第二个和第三个参数仍然是系统调用execve的第二个参数argv和第三个参数envp,它们代表的传给可执行文件的参数和环境变量仍然保留在用户空间中。
do_execve定义在<fs/exec.c>中。关于do_execve函数的执行流程,这里找一张个人认为总结得比较好的网图:
总结:Linux系统的一般执行过程
一、Linux系统的一般执行过程:正在运行的用户态进程X切换到运行用户态进程Y的过程
1、正在运行的用户态进程X 2、发生中断——save cs:eip/esp/eflags(current) to kernel stack,then load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack). 3、SAVE_ALL //保存现场 4、中断处理过程中或中断返回前调用了schedule(),其中的switch_to做了关键的进程上下文切换 5、标号1之后开始运行用户态进程Y(这里Y曾经通过以上步骤被切换出去过因此可以从标号1继续执行) 6、restore_all //恢复现场 7、iret - pop cs:eip/ss:esp/eflags from kernel stack 8、继续运行用户态进程Y
二、Linux系统执行过程中的几个特殊情况
1、通过中断处理过程中的调度时机,用户态进程与内核线程之间互相切换和内核线程之间互相切换,与最一般的情况非常类似,只是内核线程运行过程中发生中断没有进程用户态和内核态的转换; 2、内核线程主动调用schedule(),只有进程上下文的切换,没有发生中断上下文的切换; 3、创建子进程的系统调用在子进程中的执行起点及返回用户态,如fork; 4、加载一个新的可执行程序后返回到用户态的情况,如execve;
从宏观的视角来看操作系统,为了确保操作系统的正确运行,必须区分操作系统代码和用户代码的执行。大多数计算机系统采用硬件支持,以便区分各种执行模式。
至少需要两种单独运行模式:用户模式(user mode)和内核模式(kernel mode)(也称为监视模式(supervisor mode)、系统模式(system mode)或特权模式(privileged mode))。计算机硬件可以通过一个模式位(mode bit)来表示当前模式:内核模式(0)和用户模式(1)。
有了模式位,就可区分为操作系统执行的任务和为用户执行的任务。当计算机系统执行用户应用时,系统处于用户模式。然而,当用户应用通过系统调用,请求操作系统服务时,系统必须从用户模式切换到内核模式,以满足请求。正如将会看到的,这种架构改进也可用于系统操作的许多其他方面。
当系统引导时,硬件从内核模式开始。操作系统接着加载,然后开始在用户模式下执行用户程序。一旦有陷阱或中断,硬件会从用户模式切换到内核模式(即将模式位的状态设为 0)。因此,每当操作系统能够控制计算机时,它就处于内核模式。在将控制交给用户程序前,系统会切换到用户模式(将模式位设为 1)。
系统调用——为用户程序提供手段,以便请求操作系统完成某些特权任务。系统调用可有多种方式,取决于底层处理器提供的功能。不管哪种,它都是进程请求操作系统执行功能的方法。系统调用通常会陷入中断向量的某个指定位置。这一般可由通用trap指令来完成,不过也有的系统(如 MIPS 系列)由专用syscall指令来完成系统调用。
当要执行系统调用时,硬件通常将它作为软件中断。控制通过中断向量转到操作系统的中断服务程序,并且模式位也设为内核模式。系统调用服务程序是操作系统的一部分。内核检查中断指令,判断发生了什么系统调用;参数表示用户程序请求何种服务。请求所需的其他信息可以通过寄存器、堆栈或内存(内存指针也可通过寄存器传递)来传递。内核首先验证参数是否正确和合法,然后执行请求,最后控制返回到系统调用之后的指令。
参考资料:
《庖丁解牛Linux内核分析》 孟宁老师 著