实验要求:
结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
一、中断上下文的切换分析及特殊之处
1.fork系统调用中断上下文切换分析及特殊之处
fork 相对于其他系统调用有些特殊的地方,fork 用于创建一个子进程。对于普通系统调用,当通过指令int $0x80 或者 syscall 触发系统调用时,系统会在当前进程的内核堆栈上保存⼀些寄存器的值,包括当前执行程序的用户堆栈栈顶地址(SS:ESP)、当时的状态字(EFlags)、当时的 CS:EIP的值。同时会将当前进程内核堆栈的栈顶地址、内核的状态字等放⼊CPU 对应的寄存器,并且 CS:EIP 寄存器的值会指向中断处理程序的入口,对于系统调用来讲是指向系统调用处理 的入口。最后中断处理完毕之后执行 iret 指令,就会把之前保存的关键上下文和现场恢复到 CPU 中。
而对于fork系统调用,用户程序调用fork函数实际上会最终调用__do_fork函数,这个函数主要通过调用copy_process()来复制父进程的进程描述符、进程状态设置为TASK_RUNNING、采用写时复制技术逐一复制所有其他进程资源、调用copy_thread_tls初始化子进程内核栈、设置子进程pid等,然后调用wake_up_new_task将子进程加入就绪队列等待调度,最后系统调用返回。copy_thread_tls非常关键,因为这个函数不仅分配了子进程的内核堆栈而且对内核堆栈和thread等进程关键上下文进行了初始化,所以当子进程被调用运行时会直接从fork系统调用的下一条语句开始执行。而对于父进程来说,其关键上下文切换过程像普通系统调用一样如上文所述。
查看__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) { struct task_struct *p; int trace = 0; long nr; /* * Determine whether and which event to report to ptracer. When * called from kernel_thread or CLONE_UNTRACED is explicitly * requested, no event is reported; otherwise, report if the event * for the type of forking is enabled. */ 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; } p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, trace); /* * 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 = get_task_pid(p, PIDTYPE_PID); nr = pid_vnr(pid); if (clone_flags & CLONE_PARENT_SETTID) put_user(nr, parent_tidptr); if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); get_task_struct(p); } wake_up_new_task(p); /* forking complete and child started to run, tell ptracer */ if (unlikely(trace)) ptrace_event_pid(trace, pid); 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; }
可以看到:copy_process(clone_flags, stack_start, stack_size, 30 child_tidptr, NULL, trace)函数生成了新的进程,copy_process()执行完后返回do_fork(),do_fork()执行完毕后,虽然子进程处于可运行状态,但是它并没有立刻运行。至于子进程何时执行则取决于调度程序。
总的来说,fork和其他系统调用不同之处是它在陷入内核态之后有两次返回,第一次返回到原来的父进程的位 置继续向下执行,这和其他的系统调用是一样的。在子进程中fork也返回了一次,会返回到一个特定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调用返回到用户态。
2. execve系统调用中断上下文切换分析及特殊之处
进程创建的过程中,子进程先按照父进程复制出来,然后与父进程分离,单独执行一个可执行程序。这要用到系统调用execve(),在c语言库中提供一整套库函数。
execve() 系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。
execve() 系统调用通常与 fork() 系统调用配合使用。从一个进程中启动另一个程序时,通常是先 fork() 一个子进程,然后在子进程中使用 execve() 变身为运行指定程序的进程。 例如,当用户在 Shell 下输入一条命令启动指定程序时,Shell 就是先 fork() 了自身进程,然后在子进程中使用 execve() 来运行指定的程序。
execve系统调用号为56,函数入口为__x64_sys_execve,调用了do_execve,后者调用了do_execveat_common,最终的工作由__do_execve_file完成。我们查看关键代码:
/* * sys_execve() executes a new program. */ 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; // ... bprm->file = file; // ... retval = prepare_binprm(bprm); // ... retval = copy_strings(bprm->envc, envp, bprm); // ... retval = exec_binprm(bprm); // ... return retval; }
static int exec_binprm(struct linux_binprm *bprm)
{
pid_t old_pid, old_vpid;
int ret;
/* Need to fetch pid before load_binary changes it */
old_pid = current->pid;
rcu_read_lock();
old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
rcu_read_unlock();
ret = search_binary_handler(bprm);
if (ret >= 0) {
audit_bprm(bprm);
trace_sched_process_exec(current, old_pid, bprm);
ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
proc_exec_connector(current);
}
return ret;
}
二、分析Linux系统的一般执行过程
对于用户态进程的相互之间切换,主要有如下步骤:
- 发生中断,将当前进程的eip、esp、eflags保存到内核栈中
- 加载新进程的eip、esp
- 中断处理过程中调用schedule()函数,其中的switch_to做了关键的进程上下文切换
- 运行新的用户态进程。
系统调用的层次为:用户程序->INT 0x80->system_call->系统调用进程->内核程序
通过系统调用,当前运行的程序便从用户态转至内核态,在这个过程中,就涉及上下文的切换。
进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行。
中断上下文,为硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境。硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。
Linux内核工作在进程上下文或者中断上下文。提供系统调用服务的内核代码代表发起系统调用的应用程序运行在进程上下文;另一方面,中断处理程序,异步运行在中断上下文。中断上下文和特定进程无关。