结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
1、普通系统调用
系统调用是一种特殊的中断,中断分外部中断(硬件中断)和内部中断(软件中断),内部中断?称为异常(Exception),异常?分为故障(fault)和陷阱(trap),系统调?就是利?陷阱(trap)这种软件中断?式是主动从?户态进?内核态的。但是,一般从用户态进入内核态,是由两种方式触发,第一种是硬件中断,就是当硬件中断信号来到的时候,就会执行这个中断对应的中断服务例程。第二种是用户态程序在执行的过程当中,调用了一个系统调用,产生了一个内部中断,陷入内核态,也称为陷阱。
C库函数内部使用了系统调用的封装例程,所以当用户态程序调用一个系统调用时,该封装例程发布系统调用,通过特定的陷阱向内核发出服务请求,且int 0x80和sysacall指令会触发一个系统调用,因为这条汇编指令时产生中断向量为128的编程异常。CPU切换到内核态,并开始执行system_call对应汇编代码,也就是entry_INT80_32或者entry_SYSCALL_64(分别对应于int 0x80和syscall),并根据系统调用号,调用对应的内核处理函数,用户态程序通过给EAX寄存器传递一个名为系统调用号的参数来告知CPU应该执行哪个系统调用,除此之外,系统调用也需要传递其他参数。32位x86体系结构下,系统调用通过寄存器的方式传递参数,除了EAX传递系统调用号之外,参数依次赋值给EBX、ECX、EDX、ESI、EDI、EBP。在64位x86体系结构下,系统调用也是通过寄存器传递参数,使用RDI、RSI、RDX、RCX、R8、R9这6个寄存器作为参数传递寄存器。
int $0x80指令或syscall指令触发系统调?机制会在堆栈上保存?些寄存器的值,会保存中断发?时当前执?程序的栈顶地址(ESP、RSP)、当时的状态字(EFlags、RFlags)、当时的 CS:EIP/RIP 的值。同时会将当前进程内核态的栈顶地址、内核态的状态字放? CPU 对应的寄存器,并且 CS:EIP/RIP 寄存器的值会指向中断处理程序的??,对于系统调?来讲是指向系统调?处理的??。系统调用的内核堆栈展示如下:
中断是在?个进程当中从进程的?户态到进程的内核态,或从进程的内核态返回到进程的?户态,中断上下?代表当前进程执?,所以中断上下?中的get_current可获取?个指向当前进程描述符的指针,即指向被中断进程,相应的中断上下?切换的信息存储于该进程的内核堆栈中。中断有多种类型,?如有不可屏蔽中断、可屏蔽中断、异常、陷阱(系统调?)等。
2、fork系统调用
fork?进程的内核堆栈示意图中struct pt_regs就是内核堆栈中保存的中断上下?,struct inactive_task_frame就是fork?进程的进程上下?。__switch_to_asm汇编代码中完成内核堆栈切换后的代码,正好与struct inactive_task_frame对应??出栈。fork子进程的内核堆栈展示如下,对应的文件位置是/arch/x86/include/asm/switch_to.h:
在文件/kernel/fork.c中,有如下声明,通过上?的代码可以看出fork创建一个新进程,是通过_do_fork函数来创建进程的,传递结构体kernel_clone_args类型变量args就行。
SYSCALL_DEFINE0(fork) { #ifdef CONFIG_MMU struct kernel_clone_args args = { .exit_signal = SIGCHLD, }; return _do_fork(&args); #else /* can not support in nommu mode */ return -EINVAL; #endif }
_do_fork具体进程的创建?概就是把当前进程的描述符等相关进程资源复制?份,从?产??个?进程,并根据?进程的需要对复制的进程描述符做?些修改,然后把创建好的?进程放?运?队列(操作系统原理中的就绪队列),更细致点说,它主要完成了调?copy_process()复制?进程、获得pid、调?wake_up_new_task将?进程加?就绪队列等待调度执?等,它在/kernel/fork.c中有如下定义:
long _do_fork(struct kernel_clone_args *args) { //复制进程描述符和执?时所需的其他数据结构 p = copy_process(NULL, trace, NUMA_NO_NODE, args); wake_up_new_task(p); //将?进程添加到就绪队列 return nr; //返回?进程pid(?进程中fork返回值为?进程的pid) } 因此,可以总结,fork系统调用的执行过程?致是当前进程通过调用fork()系统调?函数进?内核态,执行_do_fork函数,如下图所示复制进程描述符pid及相关进程资源(采?写时复制技术)、调?copy_thread_tls初始化?进程内核栈、设置?进程pid等。其中最关键的就是dup_task_struct复制当前进程(?进程)描述符task_struct和copy_thread_tls初始化?进程内核栈。最后将?进程 放?就绪队列,fork系统调?返回;??进程则在被调度执?时根据设置的内核堆栈和thread等进程关键上下?开始执?。 写一段代码a.c来验证一下: #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <unistd.h> int main(void) { int count = 1; int child; child = fork(); if (child < 0) { perror("error fork"); } else if (child == 0) { printf("Child process : %d (%p), %d\n", ++count, &count, getpid()); } else { printf("Parent process : %d (%p), %d\n", count, &count, getpid()); } return EXIT_SUCCESS; } 1 Parent process : 1 (0x7ffeeeb5e7c8), 9408 2 Child process : 2 (0x7ffeeeb5e7c8), 9409 结果显示如上,可见,fork?个?进程的过程中,复制?进程的资源时采?了Copy On Write(写时复制)技术,不需要修改的进程资源??进程是共享内存存储空间的。事实显示的确如此,不同于普通的系统调用,fork系统调用的一个奇妙之处就是函数仅仅被调用一次,却能够返回两次,有两个返回结果,而且它可能有三种不同的返回值: 1)在父进程中,fork返回新创建子进程的进程ID; 2)在子进程中,fork返回0; 3)如果出现错误,fork返回一个负值; 在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程,辨别方法如上述。 3、分析execve系统调用 execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,当前进程的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和main函数开始运行。但是,进程的ID将保持不变。函数原型是sys_execve(),sys_execve()根据参数中指定的二进制文件路径,执行相应的二进制文件。在tools/include/nolibc/nolibc.h中sys_execve函数有有如下定义: int sys_execve(const char *filename, char *const argv[], char *const envp[]) { return my_syscall3(__NR_execve, filename, argv, envp); } 在tools/include/nolibc/nolibc.h文件中,my_syscall3函数有如下定义,num中记录系统调用号,其余则是触发该系统调用的各种参数。 1 #define my_syscall3(num, arg1, arg2, arg3) 2 ({ 3 long _ret; 4 register long _num asm("rax") = (num); 5 register long _arg1 asm("rdi") = (long)(arg1); 6 register long _arg2 asm("rsi") = (long)(arg2); 7 register long _arg3 asm("rdx") = (long)(arg3); 8 9 asm volatile ( 10 "syscall\n" 11 : "=a" (_ret) 12 : "r"(_arg1), "r"(_arg2), "r"(_arg3), 13 "0"(_num) 14 : "rcx", "r8", "r9", "r10", "r11", "memory", "cc" 15 ); 16 _ret; 17 }) 写一段程序验证一点,可以发现execve系统调用的确用被加载的进程替换掉原来的进程。如下图程序及运行结果所示,第23行,没有执行,是因为子进程被加载的系统调用给替换掉了,所以不会出现子进程的运行过程,而且execve函数执行成功后不会返回,而且代码段,数据段,bss段和调用进程的栈会被被加载进来的程序覆盖掉: #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <unistd.h> int main(void) { const char *oldpath = "execl_ln.c"; const char *newpath = "a.c"; int count = 1; int child; child = fork(); if (child < 0) { perror("error fork"); } else if (child == 0) { execlp("ls", "ls", NULL); printf("Child process : %d (%p), %d\n", ++count, &count, getpid()); } else { printf("Parent process : %d (%p), %d\n", count, &count, getpid()); } return EXIT_SUCCESS; }
Parent process : 1 (0x7ffee5ab97d4), 11041 a a.c execl_ln execl_ln.c forktest ln.s
事实上,内核中实际执行execv()或execve()系统调用的程序是do_execve(),这个函数先打开目标映像文件,并从目标文件的头部(第一个字节开始)读入若干字节,然后调用另一个函数search_binary_handler(),在此函数里面,它会搜索我们上面提到的Linux支持的可执行文件类型队列,让各种可执行程序的处理程序前来认领和处理。如果类型匹配,则调用load_binary函数指针所指向的处理函数来处理目标映像文件。在ELF文件格式中,处理函数是load_elf_binary函数,调用链为sys_execve()->do_execve()->do_execveat_common()->__do_execve_file()->exec_binprm()->search_binary_handler()->load_binary(),代码段展示如下:
int do_execve(struct filename *filename, const char __user *const __user *__argv, const char __user *const __user *__envp) { struct user_arg_ptr argv = { .ptr.native = __argv }; struct user_arg_ptr envp = { .ptr.native = __envp }; return do_execveat_common(AT_FDCWD, filename, argv, envp, 0); }
static int do_execveat_common(int fd, struct filename *filename, struct user_arg_ptr argv, struct user_arg_ptr envp, int flags) { return __do_execve_file(fd, filename, argv, envp, flags, NULL); }
static int __do_execve_file(int fd, struct filename *filename, struct user_arg_ptr argv, struct user_arg_ptr envp, int flags, struct file *file) { char *pathbuf = NULL; struct linux_binprm *bprm; struct files_struct *displaced; int retval; /* We‘re below the limit (still or again), so we don‘t want to make * further execve() calls fail. */ current->flags &= ~PF_NPROC_EXCEEDED; ...... retval = bprm_mm_init(bprm); //为ELF文件分配内存 would_dump(bprm, bprm->file); retval = exec_binprm(bprm);//开始执行加载到内存中的ELF文件 if (retval < 0) goto out; /* execve succeeded */ return retval; ...... }
static int exec_binprm(struct linux_binprm *bprm) { pid_t old_pid, old_vpid; ...... int ret; ret = search_binary_handler(bprm); ...... return ret; }
int search_binary_handler(struct linux_binprm *bprm) { bool need_retry = IS_ENABLED(CONFIG_MODULES); struct linux_binfmt *fmt; int retval; ...... retval = security_bprm_check(bprm);//检查是否具有运行权限 if (retval) return retval; retval = -ENOENT; retry: read_lock(&binfmt_lock); list_for_each_entry(fmt, &formats, lh) {//尝试每一种格式的解析函数 if (!try_module_get(fmt->module)) continue; read_unlock(&binfmt_lock); bprm->recursion_depth++; retval = fmt->load_binary(bprm);//最关键的步骤,调用合适格式的处理函数加载该可执行文件 bprm->recursion_depth--; ......... return retval; }
在load_binary函数中,加载进来的可执行文件,也就是系统调用"ls",将把当前正在执行的进程的内存空间,也就是fork出来的子进程,完全覆盖掉,由于当前的进程都会被新加载进来的ls系统调用程序完全替换掉,所以我们的测试程序的第23行没有在terminal上打印信息。
3、Linux系统的一般执行过程