一、理论基础
1.1 用户空间和内核空间
内核是OS的核心,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,OS将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。如下图:
1.2 用户栈和内核栈
操作系统中,每个进程会有两个栈,一个用户栈,存在于用户空间,一个内核栈,存在于内核空间。当进程在用户空间运行时,cpu堆栈指针寄存器里面的内容是用户堆栈地址,使用用户栈;当进程在内核空间时,cpu堆栈指针寄存器里面的内容是内核栈空间地址,使用内核栈
1.3 进程的描述
通过进程控制块——PCB来刻画进程,感知进程的存在。在Linux内核中,⽤⼀个结构体task_struct来描述进程:
其中,thread的类型是一个thread_struct结构体,用于保存进程上下⽂切换过程中CPU相关的⼀些状态信息的关键数据结构,比如保存寄存器ip,esp的旧值。
1.4 linux中进程的状态
linux中进程的状态是由上面的结构体task_struct中的成员state来描述的,它可以取的值比较多,如TASK_RUNNING(就绪或者正在运行),TASK_INTERRUPTIBLE(可中断睡眠态),TASK_UNINTERRUPTIBLE(不可中断睡眠态)等等
二、fork系统调用
什么是fork:fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。调用fork之后,数据、堆、栈有两份,代码仍然为一份但是这个代码段成为两个进程的共享代码段都从fork函数中返回。
通过fork系统调用我们可以创建进程,下面我们就梳理一下fork的大概过程,主要关注进程上下文切换已经相关函数的作用。最后在我们之前构建的gdb环境中跑一跑以验证。
2.1 0号、1号和2号进程
既然进程是通过父进程通过fork系统调用来创建的,那么父进程又是怎么来的呢,类似于一个鸡生蛋,蛋生鸡的问题。其实,0号进程是由系统硬编码直接编码创建的,运行在内核态,它是唯一一个没有通过fork或kernel_thread创建的进程。
正如我们知道的,start_kernel完成内核的初始化,也就是内核的启动函数。而上图中的set_task_stack_end_magic(&init_task)就是设置整个系统的第一个进程。其中init_task是用来描述0号进程的结构体,它在init_task.c中被填充:
1号进程,也即init进程。它由0号进程通过kernel_thread创建,是系统中所有其它用户进程的祖先进程
2号进程kthreadd也是由0号进程创建,并始终运行在内核空间, 负责所有内核线程的调度和管理
2.2 fork的过程
进程的创建过程⼤致是⽗进程通过fork系统调⽤进⼊内核_do_fork函数,如下图所示复制进程描述符及相关进程资源(采⽤写时复制技术)、分配⼦进程的内核堆栈并对内核堆栈和thread等进程关键上下⽂进⾏初始化,最后将⼦进程放⼊就绪队列, fork系统调⽤返回;⽽⼦进程则在被调度执⾏时根据设置的内核堆栈和thread等进程关键上下⽂开始执⾏:
系统调用的过程在实验二我们已经了解了——通过某个系统调用执行该系统调用的内核函数,现在我们直接来看fork对应的内核函数_do_fork。它主要调用了两个关键函数:copy_process和wake_up_new_task。其中copy_process完成复制⽗进程、获得pid,wake_up_new_task将⼦进程加⼊就绪队列等待调度执⾏。一个个来看:
copy_process:
它会用当前进程的一个副本来创建新进程并分配pid。它会复制寄存器中的值、所有与进程环境相关的部分,每个clone标志。新进程的实际启动由调用者来完成。
dup_task_struct:
copy_process会调用函数dup_task_struct。dup_task_struct复制当前进程(⽗进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时⼦进程置为就绪态)、采⽤写时复制技术逐⼀复制所有其他进程资源
copy_thread_tls :
初始化⼦进程内核栈、设置⼦进程pid等
wake_up_new_task :
⼦进程创建好了进程描述符、内核堆栈等,就可以将⼦进程添加到就绪队列,使之有机会被调度执⾏,进程的创建⼯作就完成了,⼦进程就可以等待调度执⾏,⼦进程的执⾏从这⾥设定的ret_from_fork开始
2.3 通过gdb跟踪进程fork的过程
2.3.1 构建new_fork小程序,源码如下:
#include
#include
#include
#include
#include
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 a Child Process!
");
}
else
{
/* parent process */
printf("This is a Parent Process
");
wait(NULL);
printf("Child Compelete!
");
}
return 0;
}
2.3.2 生成汇编代码,查看系统调用号
确认是56号系统调用,查询系统调用表知对应__x64_sys_clone内核处理函数
2.3.3 gdb跟踪
首先,还是和上次实验一样,将fork可执行文件拷贝至home文件下下并打包成内存根文件系统镜像。然后进入gdb调试环境,并依次给上面提到的函数打上断点后continue
可以看到,在内核初始化时就多次捕捉到了_do_fork。进入内核后,我们运行new_fork,
捕捉到__x64_sys_clone,并继续:
2.3.4 fork子进程的进程上下文
由于fork产生的子进程其实是从未运行过的,因此需要构造子进程的内核堆栈,这是由copy_thread_tls来完成的。fork子进程的内核堆栈结构如下:
可以看到,在上次实验我们所讲的系统调用内核堆栈的pt_regs的基础上,fork子进程还添加了struct inactive_task_frame,用于系统调用返回,为子进程的执行建立了内核堆栈环境
三、execve系统调用
什么是execve:进程创建的过程中,子进程先按照父进程复制出来,然后与父进程分离,单独执行一个可执行程序。这要用到系统调用execve。在调⽤execve系统调⽤时,当前的执⾏环境是从⽗进程复制过来的,execve系统调⽤加载完新的可执⾏程序之后已经覆盖了原来⽗进程的上下⽂环境。 execve在内核中帮我们重新布局了新的⽤户态执⾏环境即初始化了进程的用户态堆栈
3.1 execve的执行过程
首先,execve系统调用对应的内核处理函数为sys_execve或者__x64_sys_execve,他们都是通过do_execve来完成具体的加载可执行文件的工作。do_execve的流程如下:
其中:
search_binary_handler:解析当前可执行文件的入口
load_xxx_binary(aout或者elf):校验文件,加载文件到内存,映射到进程的地址空间。具体的:
current->mm->end_code = end_code;
current->mm->start_code =start_code;
current->mm->start_data =start_data;
current->mm->end_data = end_data;
这四行代码把当前进程的代码段、数据段起始和终止位置改为ELF文件中指明的数据段和代码段位置,execve系统调用返回用户态后进程就拥有了新的代码段、数据段
start_thread:由于execve使得新加载可执⾏程序已经覆盖了原来⽗进程的上下⽂环境,因此需要修改原来的中断上下文,使得系统调用返回后能够指向现在加载的这个可执行程序的入口,比如main函数的地址(静态链接下)
四、对比fork、execve和普通的系统调用
系统调用可以视为一种特殊的中断,老的32位linux就是采用int 0x80中断指令进入内核,因此自然涉及中断上下文,也就是切换到用户内核栈,同时保存相关的寄存器使得中断结束后能够正常返回。当执行系统调用时,用户内核栈的结构如下:
而fork系统调用特殊之处在于他创建了一个新的进程,且有两次返回。对于fork的父进程来说,fork系统调用和普通的系统调用并无两样。但是对fork子进程来说,需要设置子进程的进程上下文环境,这样子进程才能从fork系统调用后返回。
而对于execve而言,由于execve使得新加载可执⾏程序已经覆盖了原来⽗进程的上下⽂环境,而原来的中断上下文就是保存的是原来的、被覆盖的进程的上下文,因此需要修改原来的中断上下文,使得系统调用返回后能够指向现在加载的这个可执行程序的入口,比如main函数的地址(静态链接下)。
五、从中断上下文、进程上下文到linux的一般执行过程
5.1 中断上下文和进程上下文
程序在执行过程中通常有用户态和内核态两种状态,CPU对处于内核态根据上下文环境进一步细分,因此有了下面三种状态:
(1)内核态,运行于进程上下文,内核代表进程运行于内核空间。
(2)内核态,运行于中断上下文,内核代表硬件运行于内核空间。
(3)用户态,运行于用户空间。
中断是从一个进程的用户态切换到该进程的内核态,而中断上下文就是在中断这个过程中需要入栈保存的相关信息,以使得中断结束后能够正常返回进程的用户态。它包括ss、esp、eflag、cs、eip等。具体的:
1. 确定与中断或者异常关联的向量i(0~255)
2. 读idtr寄存器指向的IDT表中的第i项 3. 从gdtr寄存器获得GDT的基地址, 并在GDT中查找,以读取IDT表项中的段选择符所标识的段描述符4. 确定中断是由授权的发生源发出的5.查是否发生了特权级的变化, 一般指是否由用户态陷入了内核态。如果是由用户态陷入了内核态, 控制单元必须开始使用与新的特权级相关的堆栈
a. 读tr寄存器, 访问运行进程的tss段 b. 用与新特权级相关的栈段和栈指针装载ss和esp寄存器。 这些值可以在进程的tss段中找到
c. 在新的栈中保存ss和esp以前的值, 这些值指明了与旧特权级相关的栈的逻辑地址
6. 若发生的是故障, 用引起异常的指令地址修改cs和eip寄存器的值, 以使得这条指令在异常处理结束后能被再次执行7. 在栈中保存eflags、 cs和eip的内容8.如果异常产生一个硬件出错码, 则将它保存在栈中
9. 装载cs和eip寄存器, 其值分别是IDT表中第i项门描述符的段选择符和偏移量字段。 这对寄存器值给出中断或者异常处理程序的第一条指定的逻辑地址
中断上下文内核栈结构:
而进程切换是发生在两个不同的进程中,进程上下文就可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。
由于进程切换也是由内核完成的,所以必然要通过中断进入内核,也就是说进程上下文其实是包含了中断上下文的,就好像我们之前所说的fork子进程中的pt_regs和inactive_task_frame 。
对比这两个上下文,中断是由cpu实现的,因此那些寄存器的入栈是由cpu来帮助完成的,也就是我们所说的硬件保存现场。而进程切换是靠内核完成的,栈顶寄存器sp切换是通过进程描述符的thread.sp实现的,指令指针寄存器ip的切换是在内核堆栈切换的基础上巧妙利⽤call/ret指令实现的。进程切换的细节,我们在第一次的mykernel实验中已经详细说明,这里不再赘述。
5.2 linux的进程管理
了解了上面的内容,linux的一般执行过程我们就能描绘出一个模子了。主要就是以下几种:进程的执行,进程切换和中断陷入内核。具体到进程的切换,它又涉及到不同的调度策略,如linux中的SCHED_NORMAL、SCHED_FIFO、 SCHED_RR、 SCHED_BATCH 等等。综合的一个例子如下,作为总结: