一、原理分析
1.进程的描述
进程控制块PCB——task_struct,为了管理进程,内核必须对每个进程进行清晰的描述,进程描述符提供了内核所需了解的进程信息。
struct task_struct{ volatile long state; //进程状态,-1表示不可执行,0表示可执行,大于1表示停止 void *stack; //内核堆栈 atomic_t usage; unsigned int flags; //进程标识符 unsigned int ptrace; …… }
2.进程的创建
道生一(start_ kernel...cpu_ idle),一生二(kernel_ init和kthreadd),二生三(即前面的0、1、2三个进程),三生万物(1号进程是所有用户态进程的祖先,2号进程是所有内核线程的祖先)
start_ kernel创建了cpu_ idle,也就是0号进程。而0号进程又创建了两个线程,一个是kernel_ init,也就是1号进程,这个进程最终启动了用户态;另一个是kthreadd。0号进程是固定的代码,1号进程是通过复制0号进程PCB之后在此基础上做修改得到的。
iret与int 0x80指令对应,一个是弹出寄存器值,一个是压入寄存器的值。
如果将系统调用类比于fork();那么就相当于系统调用创建了一个子进程,然后子进程返回之后将在内核态运行,而返回到父进程后仍然在用户态运行。
Linux中创建进程一共有三个函数:fork,创建子进程 vfork,与fork类似,但是父子进程共享地址空间,而且子进程先于父进程运行。 clone,主要用于创建线程。Linux中所有的进程创建都是基于复制的方式,Linux通过复制父进程来创建一个新进程,通过调用do_ fork来实现。然后对子进程做一些特殊的处理。而Linux中的线程,又是一种特殊的进程。根据代码的分析,do_ fork中,copy_ process管子进程运行的准备,wake_ up_ new_ task作为子进程forking的完成。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; // ... // 复制进程描述符,返回创建的task_struct的指针 p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, trace); if (!IS_ERR(p)) { struct completion vfork; struct pid *pid; trace_sched_process_fork(current, p); // 取出task结构体内的pid pid = get_task_pid(p, PIDTYPE_PID); nr = pid_vnr(pid); if (clone_flags & CLONE_PARENT_SETTID) put_user(nr, parent_tidptr); // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行 if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); get_task_struct(p); } // 将子进程添加到调度器的队列,使得子进程有机会获得CPU wake_up_new_task(p); // ... // 如果设置了 CLONE_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大概做了这么几件事情:
(1)调用copy_process,将当期进程复制一份出来为子进程,并且为子进程设置相应地上下文信息。
(2)初始化vfork的完成处理信息(如果是vfork调用)
(3)调用wake_up_new_task,将子进程放入调度器的队列中,此时的子进程就可以被调度进程选中,得以运行。
(4)如果是vfork调用,需要阻塞父进程,知道子进程执行exec。
3.创建的新进程从哪里开始执行?
kernel中是可以指定新进程开始的位置(也就是通过eip寄存器指定代码行)。fork中也有相似的机制 这涉及子进程的内核堆栈数据状态和task_ struct中thread记录的sp和ip的一致性问题,这是在copy_ thread in copy_ process设定的。
copy_thread的流程如下:
(1) 获取子进程寄存器信息的存放位置
(2) 对子进程的thread.sp赋值,将来子进程运行,这就是子进程的esp寄存器的值。
(3)如果是创建内核线程,那么它的运行位置是ret_from_kernel_thread,将这段代码的地址赋给thread.ip,之后准备其他寄存器信息,退出 。
(4)将父进程的寄存器信息复制给子进程。
(5)将子进程的eax寄存器值设置为0,所以fork调用在子进程中的返回值为0。
(6)子进程从ret_from_fork开始执行,所以它的地址赋给thread.ip,也就是将来的eip寄存器。 从上面的流程中,我们看出,子进程复制了父进程的上下文信息,仅仅对某些地方做了改动,运行逻辑和父进程完全一致。
另外,子进程从ret_from_fork处开始执行,子进程的运行是由这几处保证的:
(1)dup_task_struct中为其分配了新的堆栈 。
(2)copy_process中调用了sched_fork,将其置为TASK_RUNNING 。
(3)copy_thread中将父进程的寄存器上下文复制给子进程,这是非常关键的一步,这里保证了父子进程的堆栈信息是一致的。
(4)将ret_from_fork的地址设置为eip寄存器的值,这是子进程的第一条指令。
二、实验内容
更新menu,删除test_fork.c和test.c文件,重新执行make rootfs。首先还是将QEMU启动,然后在sys_clone处设置一个断点。执行以下代码:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(int argc, char * argv[]) { int pid; /* fork another process */ pid = fork(); if (pid < 0) { /* error occurred */ fprintf(stderr,"Fork Failed!"); exit(-1); } else if (pid == 0) { /* child process */ printf("This is Child Process!\n"); } else { /* parent process */ printf("This is Parent Process!\n"); /* parent will wait for the child to complete*/ wait(NULL); printf("Child Complete!\n"); } }
执行后,来到了断点处
sys_clone实际上调用的还是do_fork,也就是说真正的进程创建是都是最终调用do_fork,然后进入该函数,里面有一个函数copy_process,这里面就是开始了详细的创建。首先是异常判断,然后开始了dup_task_struct,也就是复制父进程,产生子进程。 具体函数为
int __weak arch_dup_task_struct(struct task_struct *dst, struct task_struct *src) { *dst = *src; return 0; }
复制结束后,子进程和父进程是一样的,复制过程中内核堆栈也被复制了,接下来是对子进程进行赋值
复制文件,复制文件系统,复制信号等。 然后单步进入函数copy_thread。
p->thread.sp = (unsigned long) childregs; //子进程栈地址 *childregs = *current_pt_regs(); //子进程的寄存器 childregs->ax = 0; //ax保存返回值,子进程执行fork的返回值是0 p->thread.ip = (unsigned long) ret_from_fork; //子进程的开始执行地址
再往下继续单步
编译内核查看fork命令,得到子进程与父进程结果。
启动gdb调试,并对主要的函数设置断点。
三、总结
对于“特别关注新进程是从哪里开始执行的?为什么从哪里能顺利执行下去?即执行起点与内核堆栈如何保证一致”,由于ret_ from_ fork决定新进程的第一条指令地址。子进程从ret_ from_ fork处开始执行。因为在ret_ from_ fork之前,也就是在copy_ thread()函数中* childregs = * current_ pt_ regs();该句将父进程的regs参数赋值到子进程的内核堆栈。* childregs的类型为pt_ regs,里面存放了SAVE_ ALL中压入栈的参数,因此在之后的RESTORE ALL中能顺利执行下去。
新的进程通过克隆旧的程序(当前进程)而建立。fork() 和 clone()(对于线程)系统调用可用来建立新的进程。这两个系统调用结束时,内核在系统的物理内存中为新的进程分配新的 task_struct 结构,同时为新进程要使用的堆栈分配物理页。Linux 还会为新的进程分配新的进程标识符。然后,新 task_struct 结构的地址保存在链表中,而旧进程的 task_struct 结构内容被复制到新进程的 task_struct 结构中。 在克隆进程时,Linux 允许两个进程共享相同的资源。可共享的资源包括文件、信号处理程序和虚拟内存等。
刘帅
原创作品转载请注明出处
《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000