实验要求:
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
实验内容:
1. 以fork为例分析中断上下文的切换
fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。它不需要参数并返回一个整数值。下面是fork()返回的不同值。一个现有进程可以调用fork函数创建一个新进程。由fork创建的新进程被称为子进程(child process)。fork函数被调用一次但返回两次。两次返回的唯一区别是子进程中返回0值而父进程中返回子进程ID。因此,可以通过返回值来判定该进程是父进程还是子进程。
fork系统调用是通过do_fork来实现的,具体过程如下:首先是用户程序调用fork(),然后是库函数fork(),系统调用fork(通过系统调用号),通过sys_call_table中寻到sys_fork()的函数地址,调用sys_fork,最后调用do_fork();
do_fork()的关键代码:
long _do_fork(struct kernel_clone_args *args) { //复制进程描述符和执行时所需的其他数据结构 p = copy_process(NULL, trace, NUMA_NO_NODE, args); //将子进程添加到就绪队列 wake_up_new_task(p); //返回子进程pid(父进程中fork返回值为子进程的pid) return nr; }
_do_fork以调用copy_process开始, 后者执行生成新的进程的实际工作, 并根据指定的标志复制父进程的数据。
- 调用 copy_process 为子进程复制出一份进程信息
- 如果是 vfork(设置了CLONE_VFORK和ptrace标志)初始化完成处理信息
- 调用 wake_up_new_task 将子进程加入调度器,为之分配 CPU
- 如果是 vfork,父进程等待子进程完成 exec 替换自己的地址空间
copy_process函数代码:
static struct task_struct *copy_process(struct pid *pid, int trace, int node, struct kernel_clone_args *args) { //复制进程描述符task_struct、创建内核堆栈等 p = dup_task_struct(current, node); /* copy all the process information */ shm_init_task(p); ... // 初始化子进程内核栈和thread retval = copy_thread_tls(clone_flags, args->stack, args->stack_size, p, args->tls); ... return p;//返回被创建的子进程描述符指针 }
copy_process函数主要完成了调用dup_task_struct复制当前进程(父进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时子进程置为就绪态)、采用写时复制技术逐一复制所有其他进程资源、调用copy_thread_tls初始化子进程内核栈、设置子进程pid等。其中最关键的就是dup_task_struct复制当前进程(父进程)描述符task_struct和copy_thread_tls初始化子进程内核栈。
- 调用 dup_task_struct 复制当前的 task_struct
- 检查进程数是否超过限制
- 初始化自旋锁、挂起信号、CPU 定时器等
- 调用 sched_fork 初始化进程数据结构,并把进程状态设置为 TASK_RUNNING
- 复制所有进程信息,包括文件系统、信号处理函数、信号、内存管理等
- 调用 copy_thread_tls 初始化子进程内核栈
- 为新进程分配并设置新的 pid
copy_thread_tls函数代码:
int copy_thread_tls(unsigned long clone_flags, unsigned long sp, unsigned long arg, struct task_struct *p, unsigned long tls) { struct pt_regs *childregs = task_pt_regs(p); struct task_struct *tsk; int err; /* 获取寄存器的信息 */ p->thread.sp = (unsigned long) childregs; p->thread.sp0 = (unsigned long) (childregs+1); memset(p->thread.ptrace_bps, 0, sizeof(p->thread.ptrace_bps)); if (unlikely(p->flags & PF_KTHREAD)) { /* kernel thread 内核线程的设置 */ memset(childregs, 0, sizeof(struct pt_regs)); p->thread.ip = (unsigned long) ret_from_kernel_thread; task_user_gs(p) = __KERNEL_STACK_CANARY; childregs->ds = __USER_DS; childregs->es = __USER_DS; childregs->fs = __KERNEL_PERCPU; childregs->bx = sp; /* function */ childregs->bp = arg; childregs->orig_ax = -1; childregs->cs = __KERNEL_CS | get_kernel_rpl(); childregs->flags = X86_EFLAGS_IF | X86_EFLAGS_FIXED; p->thread.io_bitmap_ptr = NULL; return 0; } /* 将当前寄存器信息复制给子进程 */ *childregs = *current_pt_regs(); /* 子进程 eax 置 0,因此fork 在子进程返回0 */ childregs->ax = 0; if (sp) childregs->sp = sp; /* 子进程ip 设置为ret_from_fork,因此子进程从ret_from_fork开始执行 */ p->thread.ip = (unsigned long) ret_from_fork; task_user_gs(p) = get_user_gs(current_pt_regs()); p->thread.io_bitmap_ptr = NULL; tsk = current; err = -ENOMEM; if (unlikely(test_tsk_thread_flag(tsk, TIF_IO_BITMAP))) { p->thread.io_bitmap_ptr = kmemdup(tsk->thread.io_bitmap_ptr, IO_BITMAP_BYTES, GFP_KERNEL); if (!p->thread.io_bitmap_ptr) { p->thread.io_bitmap_max = 0; return -ENOMEM; } set_tsk_thread_flag(p, TIF_IO_BITMAP); } err = 0; /* * Set a new TLS for the child thread? * 为进程设置一个新的TLS */ if (clone_flags & CLONE_SETTLS) err = do_set_thread_area(p, -1, (struct user_desc __user *)tls, 0); if (err && p->thread.io_bitmap_ptr) { kfree(p->thread.io_bitmap_ptr); p->thread.io_bitmap_max = 0; } return err; }
copy_thread_tls是一个特定于体系结构的函数,用于复制进程中特定于线程(thread-special)的数据, 重要的就是填充task_struct->thread的各个成员,这是一个thread_struct类型的结构, 其定义是依赖于体系结构的。它包含了所有寄存器(和其他信息),内核在进程之间切换时需要保存和恢复的进程的信息。该函数用于设置子进程的执行环境,如子进程运行时各CPU寄存器的值、子进程的内核栈的起始地址(指向内核栈的指针通常也是保存在一个特别保留的寄存器中)
总结来说,进程的创建过程?致是?进程通过fork系统调?进?内核_do_fork函数,如下图所示复制进程描述符及相关进程资源(采?写时复制技术)、分配?进程的内核堆栈并对内核堆栈和thread等进程关键上下?进?初始化,最后将?进程放?就绪队列, fork系统调?返回;??进程则在被调度执?时根据设置的内核堆栈和thread等进程关键上下?开始执?。
2.以execve为例分析中断上下文的切换
当前的可执?程序在执?,执?到execve系统调?时陷?内核态,在内核???do_execve加载可执??件,把当前进程的可执?程序给覆盖掉。当execve系统调?返回时,返回的已经不是原来的那个可执?程序了,?是新的可执?程序。 execve返回的是新的可执?程序执?的起点,静态链接的可执??件也就是main函数的?致位置,动态链接的可执??件还需要ld链接好动态链接库再从main函数开始执?。
系统调用execve的内核入口为sys_execve,定义在<arch/kernel/process.c>中:
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; }
execve系统调用的执行过程:
- 陷入内核
- 加载新的可执行文件并进行可执行性检查
- 将新的可执行文件映射到当前运行进程的进程空间中,并覆盖原来的进程数据
- 将EIP的值设置为新的可执行程序的入口地址。如果可执行程序是静态链接的程序,或不需要其他的动态链接库,则新的入口地址就是新的可执行文件的main函数地址;如果可执行程序还需要其他的动态链接库,则入口地址是加载器ld的入口地址
- 返回用户态,程序从新的EIP出开始继续往下执行。至此,老进程的上下文已经被新的进程完全替代了,但是进程的PID还是原来的。从这个角度来看,新的运行进程中已经找不到原来的对execve调用的代码了,所以execve函数的一个特别之处是他从来不会成功返回,而总是实现了一次完全的变身。
最终execve系统调用从内核返回到用户态时,返回到的不是触发execve触发的下一条指令,下一条指令已经不存在了,已经被覆盖掉了,最终返回到new_ip,也就是ELF entry的位置。特殊之处像fork一样修改了内核堆栈栈低的关键的cpu上下文,但与fork不同,execve修改了中断上下文,fork修改的是进程上下文。
3.execve系统调用和fork子进程启动执行时中断上下文的特殊之处
系统调用可以看做是一种特殊的中断,因此自然涉及中断上下文,也就是切换到用户内核栈,同时保存相关的寄存器使得中断结束后能够正常返回。
fork系统调用,特殊之处在于其创建了一个新的进程,并且在父子进程中各有一次返回。对于fork的父进程来说,fork系统调用和普通的系统调用基本相同。但是对fork子进程来说,需要设置子进程的进程上下文环境,这样子进程才能从fork系统调用后返回。
execve系统调用,由于execve使得新加载可执?程序已经覆盖了原来?进程的上下?环境,而原来的中断上下文就是保存的是原来的、被覆盖的进程的上下文,因此需要修改原来的中断上下文,使得系统调用返回后能够指向现在加载的这个可执行程序的入口。
4.Linux 系统的一般执行过程
以正在运行的用户态进程X切换到用户态进程Y为例具体表述如下:
- 正在运行的用户态进程X
- 发生中断(包括异常、系统调用等),硬件完成以下动作:1)save cs:eip/ss:eip/eflags:当前CPU上下文压入用户态进程X的内核堆栈;2)load cs:eip/ss:esp:加载当前进程内核堆栈相关信息,跳转到中断处理程序处,即中断处理程序的起点
- SAVE_ALL,保存现场,此时完成了中断上下文的切换,即从进程X的用户态到进程X的内核态
- 中断处理过程中或中断返回前调用了schedule函数进行进程上下文切换。将当前用户进程X的内核堆栈切换到挑选出的next进程Y的内核堆栈,并完成进程上下文所需的EIP等寄存器的状态切换;
- 标号1,即$1f,之后开始运行用户态进程Y
- restore_all,恢复现场,与SAVE_ALL保存现场相对应
- 从Y进程的内核堆栈弹出步骤2硬件完成的压栈内容,此时完成中断上下文的切换,即从进程Y的内核态返回进程Y的用户态;
- 继续运行进程Y