Linux的fork()系统调用,就是以父进程为模版创建子进程,是Linux系统的进程管理机制的核心API之一,另一个是调度器函数schedule(),它的用户态API就是之前说自旋锁时提到的sched_yield()。
如果是“21天学写操作系统”,那么最先要实现的就是这两个API。实现了这两个就可以在bochs虚拟机上跑个多进程demo了。
先看fork的man手册介绍:
就是创建子进程,并且返回pid进程号。它的特点是fork一次返回两次,子进程返回0,父进程返回子进程的pid。
Linux有getpid()、getppid()函数,子进程可以使用后者获得父进程的进程号,但父进程可能有多个子进程,所以用fork返回值表示最近创建的子进程号。
它的常见用法就是:
pid_t cpid = fork();
if (-1 == cpid) {
//fork失败,一般是内存不够了
} else if (0 == cpid) {
//子进程代码
} else {
//父进程代码
}
类似的代码在很多介绍fork的书上出现,代码流程在fork之后根据它的返回值是否为0分成了两个分支,就像娜迦海妖的叉状闪电(forked lightning)一样,程序在这里分成了两个分支:(
太极生两仪,这是属于Linus大神的黑魔法之一,我们接下来窥探一下。
如上图,首先要定义一个task_t的结构来代表“进程”,我们这种示例代码只需要设置3个值就行,“进程号”id,栈指针rsp,指令指针rip。task 的成员变量与x64同名寄存器一样,用来在“进程切换”时保存这两个关键信息。
我们只支持2个进程,进程的task_t大小为4096字节,一个内存页。它的低地址是task_t数据结构,高地址是“进程的栈”。
t_fork()函数的前半部分是获取一个task_t结构,然后用memcpy()函数把父进程的task_t拷贝到子进程,完成“子进程的复制”。
子进程与父进程不同的只有3项,即id,栈指针rsp,启动地址rip。
子进程是初次执行,模拟从fork()调用中返回,所以它的启动地址rip设置为do_fork_first汇编函数。
然后设置它的栈指针rsp,这个需要计算,方法就是把当前的栈指针(隶属于父进程)在父进程的task_t里的偏移量,加在子进程里就行。
子进程是父进程的拷贝,除了task_t不一样,数据部分是一样的。
算法:child>rsp = child + parent->rsp - parent,当前的rsp寄存器的值就是父进程的栈指针。
因为要获取寄存器rsp的值,这个计算也放在do_fork()汇编函数里,就几行代码。
子进程的进程号作为最后一个参数传给do_fork(),然后会被do_fork()直接作为返回值,这样父进程的t_fork()就会返回子进程的进程号。
即父进程返回子进程的进程号。
这时子进程还没有运行,它要等到调度器去调度它,即父进程执行t_schedule()函数时。
如下图,t_schedule()函数直接调用了汇编函数switch_to(),把当前的进程t_prev切换到下一个进程t_next。
切换的关键数据就是task_t的rsp、rip项,保存当前进程的这两项,加载下一个进程的这两项。
上图是fork用法的示例代码,else if分支是子进程1,最底部的while是父进程0。
上图是main()函数,初始化进程管理结构,相当于在内核态。最后它手工设置一个0号进程,把自己降低到用户态。
只有这一个进程是手工设置的,其他都是fork()的。
task_t必须在main函数的栈上,否则修改了栈指针之后操作系统不允许压栈,毕竟我们不是“真内核”,没那么大的权限。
那么,我们就在main()里开一个大的局部变量的数组,用来存放task_t结构,把它的高地址作为“进程栈”。
t0_run()的代码必须要少,以防调用层级过多,导致栈增长时覆盖了低地址的task_t关键数据。
在Linux内核里编程也有这个特点,在栈上不能申请大的局部变量空间,因为栈是有限的,早期是4k之后是8k,一旦覆盖了低地址的task_struct关键数据,就挂了。
下图的switch_to()函数是进程切换的重点,它在调度器的调度算法选择完要切换的进程之后,具体执行进程切换。
我们就模拟了两个,自然也没啥调度算法选择了,只能0切换1,1切换0。
1,保存rbp寄存器。
保存图中L1标号的地址,x64没法直接mov两个内存变量,必须拿一个寄存器当中介,这里是rbp。
保存当前的rsp寄存器。
这些操作都是在prev进程的栈上。
2,movq %rdx, %rsp,这行切换寄存器rsp为next进程的栈,它之前作为函数的实参保存在rdx里。
从这开始,操作切换到了next进程的栈上。
也没啥操作了,直接跳转next进程的rip指令位置执行就行,它也作为函数形参保存在rcx里。
如果next进程不是第一次执行,这个地址就是标号L1,因为我们上次把它切换出去时保存的就是这个地址。
如果next进程是第一次执行,这个地址就是do_fork_first()函数的地址,因为我们在t_fork()里设置的就是这个地址。
do_fork_first()就是把表示返回值的rax寄存器设置为0,然后返回。这样子进程“从fork()返回”时的返回值就是0了。
我们这个例子是用户态的进程,即协程。
实际内核里进程切换,要复杂一些,还有页表切换之类的。
举报/反馈