结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程
结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程
一、实验要求
结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
二、系统调用简介
计算机系统的各种硬件资源是有限的,在现代多任务操作系统上同时运行的多个进程都需要访问这些资源,为了更好的管理这些资源进程是不允许直接操作的,所有对这些资源的访问都必须有操作系统控制。也就是说操作系统是使用这些资源的唯一入口,而这个入口就是操作系统提供的系统调用(System Call)。在linux中系统调用是用户空间访问内核的唯一手段,除异常和陷入外,他们是内核唯一的合法入口。
一般情况下应用程序通过应用编程接口API,而不是直接通过系统调用来编程。在Unix世界,最流行的API是基于POSIX标准的。操作系统一般是通过中断从用户态切换到内核态。中断就是一个硬件或软件请求,要求CPU暂停当前的工作,去处理更重要的事情。比如,在x86机器上可以通过int指令进行软件中断,而在磁盘完成读写操作后会向CPU发起硬件中断。
中断有两个重要的属性,中断号和中断处理程序。中断号用来标识不同的中断,不同的中断具有不同的中断处理程序。在操作系统内核中维护着一个中断向量表(Interrupt Vector Table),这个数组存储了所有中断处理程序的地址,而中断号就是相应中断在中断向量表中的偏移量。
一般地,系统调用都是通过软件中断实现的,x86系统上的软件中断由int $0x80指令产生,而128号异常处理程序就是系统调用处理程序system_call(),它与硬件体系有关,在entry.S中用汇编写。接下来就来看一下Linux下系统调用具体的实现过程。
系统调用处理程序与其他异常程序处理程序的结构类似,执行下列操作:
- 在内核态栈保存大多数寄存器的内容(这个操作对所有的系统调用都是通用的,并用汇编语言编写)。
- 调用名为系统调用的服务历程(system call service routeine)的相应的C函数来处理系统调用。
- 退出系统调用处理程序,用保存在内核栈中的值加载寄存器,cpu从内核态切换回到用户态(所有的系统调用函数都要执行这一相同的操作,该操作用汇编语言代码实现)。
我假设执行xyz()的系统调用,那么执行流程图如下:
上述的内容总结来自《深入理解Linux内核》,从上述内容我们了解到;
- 系统调用是中断的一种。
- 系统调用处理程序执行的第一步和第三步都是相同的,不同的在于相应的C函数不同。
三、execve系统调用
3.1 execve简述
当前的可执?程序在执?,执?到execve系统调?时陷?内核态,在内核???do_execve加载可执??件,把当前进程的可执?程序给覆盖掉。当execve系统调?返回 时,返回的已经不是原来的那个可执?程序了,?是新的可执?程序。
execve返回的是新的可执?程序执?的起点,静态链接的可执??件也就是main函数的?致位置,动态链接的可执??件还需 要ld链接好动态链接库再从main函数开始执?。
Linux系统?般会提供了execl、execlp、execle、execv、execvp和execve等6个?以加载执? ?个可执??件的库函数,这些库函数统称为exec函数,差异在于对命令?参数和环境变量参数 的传递?式不同。
exec函数都是通过execve系统调?进?内核,对应的系统调?内核处理函数为sys_execve或__x64_sys_execve,它们都是通过调?do_execve来具体执?加载可执??件的 ?作。
下面用一个C代码来简单看一下execve函数的作用,这里有两个C代码,分别是main.c和execve_test.c其中第一个代码在第9行调用第二个代码。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> int main(int argc, char *argv[]) { char *envp[] = {"T1=222","T2=333",NULL}; char *argv_send[] = {"./execve_test","1","2",NULL}; execve("./execve_test",argv_send,envp); printf("this is main\r\n"); return 0; }
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> extern char **environ; int main(int argc, char *argv[]) { int i = 0; //打印envp,新环境变量 for( i = 0; environ[i] != NULL; i++ ) { printf("environ[%d]:%s\r\n",i,environ[i]); } printf("\r\n\r\n"); //打印argv,参数 for( i = 0; i < argc; i++ ) { printf("argv[%d]:%s\r\n",i,argv[i]); } return 0; }
执行结果如上,但是我们可以发现在main.c的第十行我写了这个 printf("this is main\r\n");但是确没有执行,这是为什么呢,就像我们上述讲的那样在执行了系统调用 execve 后,子进程得到了执行,但父进程却没有被执行。参考 exec 系列函数的说明,也可印证这个结果,execve 函数执行成功后不会返回,而且代码段、数据段、bss段和调用进程的栈会被加载进来的程序覆盖掉。
3.2 execve分析
首先execve函数的入口函数在 sys_execve中
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保存着系统调用execve的第一个参数,即可执行文件的路径名。因为路径名存储在用户空间中,这里要通过getname拷贝到内核空间中。getname在拷贝文件名时,先申请了一个page作为缓冲,然后再从用户空间拷贝字符串。为什么要申请一个页面而不使用进程的系统空间堆栈?首先这是一个绝对路径名,可能比较长,其次进程的系统空间堆栈大约为7K,比较紧缺,不宜滥用。用完文件名后,在函数的末尾调用putname释放掉申请的那个页面。
sys_execve的核心是调用do_execve函数,传给do_execve的第一个参数是已经拷贝到内核空间的路径名filename,第二个和第三个参数仍然是系统调用execve的第二个参数argv和第三个参数envp,它们代表的传给可执行文件的参数和环境变量仍然保留在用户空间中。
下面是execve函数的核心。
static int do_execveat_common(int fd, struct filename *filename, struct user_arg_ptr argv, struct user_arg_ptr envp, int flags) { char *pathbuf = NULL; struct linux_binprm *bprm; struct file *file; struct files_struct *displaced; int retval; if (IS_ERR(filename)) return PTR_ERR(filename); if ((current->flags & PF_NPROC_EXCEEDED) && atomic_read(¤t_user()->processes) > rlimit(RLIMIT_NPROC)) { retval = -EAGAIN; goto out_ret; } current->flags &= ~PF_NPROC_EXCEEDED; // 1. 调用unshare_files()为进程复制一份文件表; retval = unshare_files(&displaced); if (retval) goto out_ret; retval = -ENOMEM; // 2、调用kzalloc()在堆上分配一份structlinux_binprm结构体; bprm = kzalloc(sizeof(*bprm), GFP_KERNEL); if (!bprm) goto out_files; retval = prepare_bprm_creds(bprm); if (retval) goto out_free; check_unsafe_exec(bprm); current->in_execve = 1; // 3、调用open_exec()查找并打开二进制文件; file = do_open_execat(fd, filename, flags); retval = PTR_ERR(file); if (IS_ERR(file)) goto out_unmark; // 4、调用sched_exec()找到最小负载的CPU,用来执行该二进制文件; sched_exec(); // 5、根据获取的信息,填充structlinux_binprm结构体中的file、filename、interp成员; bprm->file = file; if (fd == AT_FDCWD || filename->name[0] == ‘/‘) { bprm->filename = filename->name; } else { if (filename->name[0] == ‘\0‘) pathbuf = kasprintf(GFP_TEMPORARY, "/dev/fd/%d", fd); else pathbuf = kasprintf(GFP_TEMPORARY, "/dev/fd/%d/%s", fd, filename->name); if (!pathbuf) { retval = -ENOMEM; goto out_unmark; } /* * Record that a name derived from an O_CLOEXEC fd will be * inaccessible after exec. Relies on having exclusive access to * current->files (due to unshare_files above). */ if (close_on_exec(fd, rcu_dereference_raw(current->files->fdt))) bprm->interp_flags |= BINPRM_FLAGS_PATH_INACCESSIBLE; bprm->filename = pathbuf; } bprm->interp = bprm->filename; // 6、调用bprm_mm_init()创建进程的内存地址空间,并调用init_new_context()检查当前进程是否使用自定义的局部描述符表;如果是,那么分配和准备一个新的LDT; retval = bprm_mm_init(bprm); if (retval) goto out_unmark; // 7、填充structlinux_binprm结构体中的命令行参数argv,环境变量envp bprm->argc = count(argv, MAX_ARG_STRINGS); if ((retval = bprm->argc) < 0) goto out; bprm->envc = count(envp, MAX_ARG_STRINGS); if ((retval = bprm->envc) < 0) goto out; // 8、调用prepare_binprm()检查该二进制文件的可执行权限;最后,kernel_read()读取二进制文件的头128字节(这些字节用于识别二进制文件的格式及其他信息,后续会使用到); retval = prepare_binprm(bprm); if (retval < 0) goto out; // 9、调用copy_strings_kernel()从内核空间获取二进制文件的路径名称; retval = copy_strings_kernel(1, &bprm->filename, bprm); if (retval < 0) goto out; bprm->exec = bprm->p; // 10.1、调用copy_string()从用户空间拷贝环境变量 retval = copy_strings(bprm->envc, envp, bprm); if (retval < 0) goto out; // 10.2、调用copy_string()从用户空间拷贝命令行参数; retval = copy_strings(bprm->argc, argv, bprm); if (retval < 0) goto out; /* 至此,二进制文件已经被打开,struct linux_binprm结构体中也记录了重要信息; 下面需要识别该二进制文件的格式并最终运行该文件 */ retval = exec_binprm(bprm); if (retval < 0) goto out; /* execve succeeded */ current->fs->in_exec = 0; current->in_execve = 0; acct_update_integrals(current); task_numa_free(current); free_bprm(bprm); kfree(pathbuf); putname(filename); if (displaced) put_files_struct(displaced); return retval; out: if (bprm->mm) { acct_arg_size(bprm, 0); mmput(bprm->mm); } out_unmark: current->fs->in_exec = 0; current->in_execve = 0; out_free: free_bprm(bprm); kfree(pathbuf); out_files: if (displaced) reset_files_struct(displaced); out_ret: putname(filename); return retval; }
而在上面函数中比较重要的就是binprm数据结构,简单介绍一下
<include/linux/binfmts.h> struct linux_binprm{ //buf用来从可执行文件中读入前128个字节,据此可以判断处可执行文件的类型(比如aout、elf、java、或者脚本等)。 char buf[BINPRM_BUF_SIZE]; //page是一个物理页面指针数组,这些物理页面用来存储execve系统调用中参数argv以及envp所指向的字符串表。数组的size为 MAX_ARG_PAGES(32),但具体会分配多少个物理页面,取决于argv已经envp所指向的字符串表的大小。 struct page *page[MAX_ARG_PAGES]; //p用来指向page数组所代表的存储空间的“游标”。 unsigned long p; /* current top of mem */ int sh_bang; //file即可执行文件对应的文件表项。 struct file * file; //当可执行文件设置了set-user-ID或者set-group-ID,e_uid和e_gid分别用来存储可执行文件的所有者ID和所在组ID. int e_uid, e_gid; kernel_cap_t cap_inheritable, cap_permitted, cap_effective; int argc, envc; //filename指向可执行文件的路径(该路径字符串已经拷贝到内核空间)。 char * filename; /* Name of binary */ unsigned long loader, exec; };
最后总结一下execv的执行过程
execve系统调用陷入内核,并传入命令行参数和shell上下文环境
execve陷入内核的第一个函数:do_execve,do_execve封装命令行参数和shell上下文
do_execve调用do_execve_common,do_execve_common打开ELF文件并把所有的信息一股脑的装入linux_binprm结构体
do_execve_common中调用search_binary_handler,寻找解析ELF文件的函数
search_binary_handler找到ELF文件解析函数load_elf_binary
load_elf_binary解析ELF文件,把ELF文件装入内存,修改进程的用户态堆栈(主要是把命令行参数和shell上下文加入到用户态堆栈),修改进程的数据段代码段
load_elf_binary调用start_thread修改进程内核堆栈(特别是内核堆栈的ip指针)
进程从execve返回到用户态后ip指向ELF文件的main函数地址,用户态堆栈中包含了命令行参数和shell上下文环境
四、fork系统调用
4.1 fork简介
一个进程,包括代码、数据和分配给进程的资源。fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。
一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。
同样使用一个代码来看看这个函数
#include <unistd.h> #include <stdio.h> int main () { pid_t fpid; //fpid表示fork函数返回的值 int count=0; fpid=fork(); if (fpid < 0) printf("error in fork!"); else if (fpid == 0) { printf("I am fork process, process id is %d/n",getpid()); count++; } else { printf("I am main process, process id is %d/n",getpid()); count++; } printf("统计结果是: %d/n",count); return 0; }
代码执行结果如上,从代码中我们可以看到,如果我们不fork一个进程,if判断语句只可能执行一个,但是通过fork,我们生成了另一个进程 在语句fpid=fork()之前,只有一个进程在执行这段代码,但在这条语句之后,就变成两个进程在执行了,这两个进程的几乎完全相同,将要执行的下一条语句都是if(fpid<0
为什么两个进程的fpid不同呢,这与fork函数的特性有关。fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
1)在父进程中,fork返回新创建子进程的进程ID;
2)在子进程中,fork返回0;
3)如果出现错误,fork返回一个负值;
在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。
4.2 fork函数分析
long do_fork(unsigned long clone_flags, unsigned long stack_start, unsigned long stack_size, int __user *parent_tidptr, int __user *child_tidptr) { return _do_fork(clone_flags, stack_start, stack_size, parent_tidptr, child_tidptr, 0); }
首先是对do_fork的参数的介绍
- clone_flags : 用来控制进程复制过的一些属性信息, 描述你需要从父进程继承那些资源。该标志位的4个字节分为两部分。最低的一个字节为子进程结束时发送给父进程的信号代码,通常为SIGCHLD;剩余的三个字节则是各种clone标志的组合(本文所涉及的标志含义详见下表),也就是若干个标志之间的或运算。通过clone标志可以有选择的对父进程的资源进行复制;
- stack_start : 子进程用户态堆栈的地址
- stack_size : 用户状态下栈的大小, 该参数通常是不必要的, 总被设置为0
- parent_tidptr: 父进程在用户态下pid的地址,该参数在CLONE_PARENT_SETTID标志被设定时有意义
- child_tidptr : 子进程在用户态下pid的地址,该参数在CLONE_CHILD_SETTID标志被设定时有意义
当然这个函数主要还是调用了_do_fork函数
long _do_fork(unsigned long clone_flags, unsigned long stack_start, unsigned long stack_size, int __user *parent_tidptr, int __user *child_tidptr, unsigned long tls) { struct task_struct *p; int trace = 0; long nr; if (!(clone_flags & CLONE_UNTRACED)) { if (clone_flags & CLONE_VFORK) trace = PTRACE_EVENT_VFORK; else if ((clone_flags & CSIGNAL) != SIGCHLD) trace = PTRACE_EVENT_CLONE; else trace = PTRACE_EVENT_FORK; if (likely(!ptrace_event_enabled(current, trace))) trace = 0; } /* 复制进程描述符,copy_process()的返回值是一个 task_struct 指针 */ p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, trace, tls); /* * Do this prior waking up the new thread - the thread pointer * might get invalid after that point, if the thread exits quickly. */ if (!IS_ERR(p)) { struct completion vfork; struct pid *pid; trace_sched_process_fork(current, p); /* 得到新创建的进程的pid信息 */ pid = get_task_pid(p, PIDTYPE_PID); nr = pid_vnr(pid); if (clone_flags & CLONE_PARENT_SETTID) put_user(nr, parent_tidptr); /* 如果调用的 vfork()方法,初始化 vfork 完成处理信息 */ if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); get_task_struct(p); } /* 将子进程加入到调度器中,为其分配 CPU,准备执行 */ wake_up_new_task(p); /* forking complete and child started to run, tell ptracer */ if (unlikely(trace)) ptrace_event_pid(trace, pid); /* 如果是 vfork,将父进程加入至等待队列,等待子进程完成 */ if (clone_flags & CLONE_VFORK) { if (!wait_for_vfork_done(p, &vfork)) ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid); } put_pid(pid); } else { nr = PTR_ERR(p); } return nr; }
因为_do_fork代码较多,不能完全展示出来,而其中最主要的就是copy_process()函数,copy_process()此函数会做fork的大部分事情,它主要完成讲父进程的运行环境复制到新的子进程,比如信号处理、文件描述符和进程的代码数据等。而copy_process() 主要功能如下。
dup_task_struct()。分配一个新的进程控制块,包括新进程在kernel中的堆栈。新的进程控制块会复制父进程的进程控制块,但是因为每个进程都有一个kernel堆栈,新进程的堆栈将被设置成新分配的堆栈。
初始化一些新进程的统计信息,如此进程的运行时间
copy_semundo()复制父进程的semaphore undo_list到子进程。
copy_files()、copy_fs()。复制父进程文件系统相关的环境到子进程
copy_sighand()、copy_signal()。复制父进程信号处理相关的环境到子进程。
copy_mm()。复制父进程内存管理相关的环境到子进程,包括页表、地址空间和代码数据。
copy_thread()/copy_thread_tls。设置子进程的执行环境,如子进程运行时各CPU寄存器的值、子进程的kernel栈的起始地址。
sched_fork()。设置子进程调度相关的参数,即子进程的运行CPU、初始时间片长度和静态优先级等。
将子进程加入到全局的进程队列中
五、系统调用的一般过程
首先通过对execve和fork系统调用的学习和分析以及孟老师和李老师课上所讲,我了解到中断的一般处理流程是
1,确定与中断或者异常关联的向量i(0~255)
2,读idtr寄存器指向的IDT表中的第i项
3,从gdtr寄存器获得GDT的基地址,并在GDT中查找, 以读取IDT表项中的段选择符所标识的段描述符
4,确定中断是由授权的发生源发出的。
? 中断:中断处理程序的特权不能低于引起中断的程 序的特权(对应GDT表项中的DPL vs CS寄存器中的 CPL)
? 编程异常:还需比较CPL与对应IDT表项中的DP
5,检查是否发生了特权级的变化,一般指是否由 用户态陷入了内核态。 如果是由用户态陷入了内核态,控制单元必须开始使用与新的特权级相关的堆栈
? a,读tr寄存器,访问运行进程的tss段
? b,用与新特权级相关的栈段和栈指针装载ss和esp寄存 器。这些值可以在进程的tss段中找到
? c,在新的栈中保存ss和esp以前的值,这些值指明了与 旧特权级相关的栈的逻辑地址
6,若发生的是故障,用引起异常的指令地址修改cs 和eip寄存器的值,以使得这条指令在异常处理结 束后能被再次执行
7,在栈中保存eflags、cs和eip的内容
8,如果异常产生一个硬件出错码,则将它保存在栈 中
9,装载cs和eip寄存器,其值分别是IDT表中第i项门 描述符的段选择符和偏移量字段。这对寄存器值 给出中断或者异常处理程序的第一条指定的逻辑 地址
中断/异常处理完后,相应的处理程序会 执行一条iret汇编指令,这条汇编指令让 CPU控制单元做如下事情:
1,用保存在栈中的值装载cs、eip和eflags寄 存器。如果一个硬件出错码曾被压入栈中, 那么弹出这个硬件出错码
2,检查处理程序的特权级是否等于cs中最低 两位的值(这意味着进程在被中断的时候是 运行在内核态还是用户态)。若是,iret终止 执行;否则,转入3
3,从栈中装载ss和esp寄存器。这步意味着返 回到与旧特权级相关的栈
4,检查ds、es、fs和gs段寄存器的内容,如 果其中一个寄存器包含的选择符是一个段描 述符,并且特权级比当前特权级高,则清除 相应的寄存器。这么做是防止怀有恶意的用 户程序利用这些寄存器访问内核空间
而系统调用是一种中断的一种,因此系统调用的一般流程为,其中的细节和中断类似。
首先Linux内核为每一个系统调用分配了一个唯一编号.
同时在内核中保存了一张系统调用表,该表中保存了系统调用编号和其对应服务的入口地址。
系统调用号陷入内核前,需要把系统调用号一起传入内核。
这样系统调用处理程序一旦运行,就可以获取相应的服务入口地址。
而系统调用的相关代码分析如下
ENTRY(system_call) RING0_INT_FRAME ASM_CLAC pushl_cfi %eax //保存系统调用号; SAVE_ALL //可以用到的所有CPU寄存器保存到栈中 GET_THREAD_INFO(%ebp) //ebp用于存放当前进程thread_info结构的地址 testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp) jnz syscall_trace_entry cmpl $(nr_syscalls), %eax //检查系统调用号(系统调用号应小于NR_syscalls), jae syscall_badsys //不合法,跳入到异常处理 syscall_call: call *sys_call_table(,%eax,4) //合法,对照系统调用号在系统调用表中寻找相应服务例程 movl %eax,PT_EAX(%esp) //保存返回值到栈中 syscall_exit: testl $_TIF_ALLWORK_MASK, %ecx //检查是否需要处理信号 jne syscall_exit_work //需要,进入 syscall_exit_work restore_all: TRACE_IRQS_IRET //不需要,执行restore_all恢复,返回用户态 irq_return: INTERRUPT_RETURN //相当于iret
六、总结
通过对execve和fork系统调用的分析,使得我深入了解对系统调用和中断上下文的处理,同时对Linux系统中的程序执行有了更进一步的认识。