xv6 学习:进程管理B fork&exec

您所在的位置:网站首页 fork子进程执行起始点 xv6 学习:进程管理B fork&exec

xv6 学习:进程管理B fork&exec

2023-04-13 04:46| 来源: 网络整理| 查看: 265

主要参考自 《给操作系统捋条线》,加上了自己的笔记。

有了前面进程切换的铺垫,理解进程的创建就简单多了。在 xv6 或者 Linux 里除了第一个 init 进程需要内核来创建之外,其他的所有进程都是使用 fork 来创建。

这里首先介绍 fork 和 exec 的原理,最后是创建第一个进程完成一些初始化操作,并创建我们熟悉的 shell 进程与用户进行交互。

主要内容:

创建普通进程加载可执行文件创建init进程1. 创建普通进程

fork 函数大家应该听得多也用得多了,fork 就好比分身术,以父进程为模板克隆出一个几乎一模一样的子进程出来。克隆的方式也分种类,有朴实无华(傻不拉几)版本的,也有十分巧妙(写时拷贝)的版本。

写时拷贝版本可以参考 JOS lab4,两者的区别就是:

朴实无华:立即分配新的物理页写时拷贝:只建立映射而不立即分配新的物理页,推迟到要修改页时才分配

xv6 的 fork 实现就很朴实无华,将父进程所有的东西几乎都复制了一份。代码位于 proc.c 的 fork 函数中,具体要执行下面操作:

分配任务结构体,初始化任务结构体分配内核栈,模拟上下文填充内核栈复制父进程数据、创建“新”页表复制文件描述符表修改进程结构体属性1.1 分配和初始化任务结构体

在 fork 函数的开头调用了 allocproc 函数,就是分配了一个进程,此时处于 EMBRO 状态,os 只给进程分配一小部分资源,还没有完全完成初始化。

acquire(&ptable.lock); for(p = ptable.proc; p state == UNUSED) goto found; release(&ptable.lock); return 0;

allocproc 首先是在 pcb 队列中找到一个 unused 的进程进行使用。为了防止并行时,两个处理器选到同一个 pcb,所以遍历之前需要请求锁。

found: p->state = EMBRYO; p->pid = nextpid++; release(&ptable.lock);

如果找到了之后进入 found,没找到直接放弃锁并返回。由于修改进程信息需要和其他进程互斥,所以设置完进程信息后才会放弃锁。

这里其实也可以看出 EMBRO 状态的一些作用,如果没有这个状态,一直在 fork 函数的过程中占用pcb数组的锁,等到初始化工作结束后才标记为 RUNNABLE 让进程上 CPU 工作,那么就会大大减少并发度,影响进程执行的性能。设置了 EMBRO 之后,其他进程就不会选中这个 pcb 来分配或者执行,那么就不用一直占据锁,增加了程序并发度。

由于进程此时的状态为 EMBYO,其他处理器无法选择他,所以只会读取到 np->state,因此,修改进程结构体其他变量的时候不需要加锁,只需要在修改 state 的时候加锁即可。

nextpid 是一个全局变量,初始值为 1,每创建一个进程该值就会递增。

1.2 分配和初始化内核栈// Allocate kernel stack. if((p->kstack = kalloc()) == 0){ p->state = UNUSED; return 0; } sp = p->kstack + KSTACKSIZE;

下面是使用 kalloc 函数,在物理内存中分配一个页作为新进程的内核栈,它位置不固定,取决于物理内存的使用情况。如果分配失败就将任务结构体回收再退出,这里修改没有加锁是因为,变成 EMBRO 之后,根本没有其他进程会使用它。

sp 表示内核栈顶,kstack 说的是内核栈那个page的起始地址嘛,由于此时内核栈是空的,所以设置为 kstack + KSTACKSIZE。内核栈不需要映射到用户进程的地址空间,因为他只有在内核态才使用到,保存到 kstack 里就行了。

此时需要在分配内核栈里面做文章,这部分主要涉及到与进程切换相关的部分。我们思考下新进程需要做什么,就能够知道要往内核栈里面填充什么东西。

首先新进程是 scheduler 使用 swtch 函数调度,所以需要一个切换上下文保存到新进程的内核栈上。新进程被调度到后,在内核中继续执行,此时要返回到用户代码。所以需要 trapframe 中断上下文,和一个返回地址:trapret 存储在内核栈上。进程先进入 trapret 函数,然后在这里面弹出中断上下文,最后 iret 返回用户代码。

所以栈中结构依次为:

img

// Leave room for trap frame. sp -= sizeof *p->tf; p->tf = (struct trapframe*)sp; // Set up new context to start executing at forkret, // which returns to trapret. sp -= 4; *(uint*)sp = (uint)trapret; sp -= sizeof *p->context; p->context = (struct context*)sp; memset(p->context, 0, sizeof *p->context); p->context->eip = (uint)forkret; return p;

这里是依次填充每个内容,trapframe 此时只是预留了空间还没有复制父进程的信息。子进程在内核中执行的代码不需要和父进程的一样,所以 context 没有复制。这里 context->eip 设置为 forkret(将在后面讲解),其余的 context 设置为0。当子进程被调度到时,将会首先在 forkret 函数执行,然后使用内核栈上的返回地址,返回执行中断返回函数,最后执行完后回到用户态执行用户程序。

1.3 拷贝父进程的进程空间// Copy process state from proc. if((np->pgdir = copyuvm(curproc->pgdir, curproc->sz)) == 0){ kfree(np->kstack); np->kstack = 0; np->state = UNUSED; return -1; }

返回到 fork 函数后,父进程执行 copyuvm 函数,拷贝父进程页表,如果复制过程出错,就回收全部已分配资源再返回。

pde_t* copyuvm(pde_t *pgdir, uint sz) { if((d = setupkvm()) == 0) return 0; for(i = 0; i ofile[i]); np->cwd = idup(curproc->cwd);

父子进程都有文件描述符表这个结构, fork 复制一份父进程的文件描述符表给子进程,这里虽然将文件描述符表复制了一份,但是文件描述符表里面存放的是指针,指向文件表,所以它两就是共享文件表。

最后修改子进程的当前工作路径为父进程的路径,所有的这些文件管理都要调用专门的复制函数 dup,因为文件系统对文件系统的引用数链接数有着严格的管理。

复制进程文件描述符表其实很有用,使用它可以用来建立管道通信等一些操作。这个 xv6 book 中有提到过。

1.5 修改进程结构体np->sz = curproc->sz; np->parent = curproc; *np->tf = *curproc->tf; // Clear %eax so that fork returns 0 in the child. np->tf->eax = 0;

在前面 allocproc 初始化内核栈时,在内核栈中分配了中断栈帧 trapframe 的空间,但是没有对他进行初始化,上面的代码中实现了对 trapframe 初始化。为了保证子进程返回到用户态后的环境和父进程一致,trapframe 中所有的通用寄存器和段寄存器的值都应和父进程一样,毕竟进程执行环境就等价于寄存器嘛。

除了一个寄存器:eax,因为 fork 是一个系统调用,eax 负责返回系统调用的返回值。子进程返回值设为0,以区分子进程和父进程,父进程的 eax 会被设置为 pid。

为什么没有复制切换上下文?

切换上下文是进程切换的时候产生的,父进程执行 fork 函数的时候没有被调度切换,也就没有产生切换上下文,进程的 context 此时不是这次 trap 产生的。子进程被调度到时,需要切换上下文,为此我们在 allocproc 中设置子进程切换上下文由于切换上下文和父进程不一致,为了返回用户态,eip 设置为 forkret,forkret 的返回地址设置为 trapretpid = np->pid; acquire(&ptable.lock); np->state = RUNNABLE; release(&ptable.lock);

fork的最后就是设置 pid 和修改进程状态为 runnable。假如提前将进程状态设置为 runnable,而初始化工作还没有结束,为了防止这个进程被调度到,就需要一直占据pcb表锁,所以这里最后才修改进程状态比较合理。

修改 state 的时候必须要加锁,保证读写操作的原子性,因为其他进程只会读进程结构体的 state,所以为了防止写到一半被读,或者读到一半改写,需要加锁。

IMG

这里进程就完成了初始化工作,进程空间和内核栈的状态如上所示。

1.6 子进程回到用户态void forkret(void) { static int first = 1; // Still holding ptable.lock from scheduler. release(&ptable.lock); if (first) { // Some initialization functions must be run in the context // of a regular process (e.g., they call sleep), and thus cannot // be run from main() first = 0; iinit(ROOTDEV); initlog(ROOTDEV); } // Return to "caller", actually trapret (see allocproc). }

forkret 函数对 init 进程来说才会初始化 inode 和日志。因此,这里新创建的子进程相当于执行了一个空函数。

执行完之后就设置 eip 为返回地址 trapret,随后执行 trapret。trapret 就是根据设置好的 trapframe 直接弹出上下文,使用 iret 返回中断到用户态。

2. exec

使用分身术(fork)创建出来的进程执行的是与父进程相同的程序,通常不是咱们想要的,咱们想要的是子进程去执行不同的程序,这就需要学会另一门技能变身术(exec)

exec 负责磁盘上的文件程序装载到内存里面去,创建新的内存映像,这个过程说简单也简单,说复杂也复杂。因为 elf 文件里面包括了可装载段的所有信息:比如这个段多大,要加载哪里去,都记录好了,exec 需要做的就是从磁盘读数据,然后将数据搬到对应位置。复杂就在于这个过程有些繁复,咱们一步步来看:

int exec(char *path, char **argv);

参数有两个:path 为文件路径,argv 为指向字符串数组的指针;path 用来获得磁盘中的可执行文件,argv 是这个程序main函数的参数。这两个参数后面都会被用到。

exec 执行的流程:

读取可执行文件,验证是否可以加载到进程空间中创建新的页表。将文件拷贝到进程空间,分配堆区、栈区、数据段、代码段将参数拷贝到进程用户栈中提交修改,新页表成为进程页表exec 返回到用户进程2.1 验证可执行文件if((ip = namei(path)) == 0){ //获取该文件的inode end_op(); cprintf("exec: fail\n"); return -1; } ilock(ip); //对该inode上锁,使得数据有效

这一步就是解析路径然后取得该文件的 inode ,如果失败则返回,成功的话将该 inode 上锁使用,同时 inode 会使 inode 的数据有效。

// Check ELF header if(readi(ip, (char*)&elf, 0, sizeof(elf)) != sizeof(elf)) goto bad; if(elf.magic != ELF_MAGIC) goto bad;

读取 elf 头到 elf 中,elf 的前四个字节 magic 分别是 0x7F、E、L、F。如果一个文件前 4 个字节是这 4 个,说明它是一个 elf 文件。

2.2 加载文件数据if((pgdir = setupkvm()) == 0) goto bad;

setupkvm 创建了一个新页表,并且初始化新页表的内核部分,原页表这里还没有被释放,后面会看到。

接下来就是读取可装载段的数据,然后”搬”到新的地址空间。目前的虚拟地址空间就只映射了内核部分,用户部分没有映射到实际的物理内存,要把数据搬到用户部分,在这之前肯定得有一个分配物理内存,然后将用户部分的虚拟内存映射到分配的物理内存的过程,这就是 allocuvm 函数;还需要将磁盘中的可装载段拷贝到物理页中,这就是 loaduvm 函数。

xv6 拷贝段的思路:

先使用 allocuvm 为这一段在用户进程的虚拟地址空间建立映射,分配物理页然后 loaduvm 将数据从磁盘拷贝到虚拟内存中2.2.1 allocuvmint allocuvm(pde_t *pgdir, uint oldsz, uint newsz);

allocuvm 在 pgdir 指向的虚拟地址空间中,在 oldsz~newsz 这一段虚拟空间分配虚拟页。分配虚拟内存就是上述所说的那两步:分配物理内存,填写页表项建立映射。

a = PGROUNDUP(oldsz); for(; a tf->eip = elf.entry; // 设置执行的入口点 curproc->tf->esp = sp; // 新的用户栈顶 switchuvm(curproc); // 切换页表 freevm(oldpgdir); // 释放旧的用户空间 return 0;

由于 exec 属于系统调用,当前进程还是处于 RUNNING 状态,所以可以不加锁直接修改状态体。这里主要做:

切换了 curproc->pgdir修改了 cr3 寄存器存储的页表修改了 tf->eip 和 tf->esp,其他的寄存器信息没有被改变,譬如通用寄存器和段选择子等释放了进程旧页表

这部分修改进程任务结构体的一些属性,将栈帧中的 eip 修改为 elf 的入口点,当进程在此被调度时就会从这儿开始执行。

这里抛出一个问题?为什么不直接基于原进程页表修改呢?而是要成功了才切换页表:

exec 失败了还是会执行原来的进程代码基于原页表修改,假如修改失败了,那么 exec 就破坏了页表内容,无法执行原来的进程代码exec 执行时使用的当前页表内核部分,修改的是页表的用户部分2.5 返回

IMG

由于 tf->eip 已经被修改了,所以返回回去时,是返回到进程的 entry 入口。exec 是一个系统调用,exec 系统调用的执行过程如上所示。

2.6 总结

img

exec 总共的执行顺序如上所示

3. 创建第一个进程

有了前面创建普通进程和程序加载的铺垫,创建第一个进程是很简单的,相关代码在 。第一个进程就像是 fork 和 exec 的结合体,因为是第一个,也就没有父进程的内存映像克隆,所以要调用 exec 来加载 init 程序。

xv6 的实际情况稍有不同,它是先加载一个 initcode 程序,这个程序再系统调用 exec 加载 init 程序。不直接创建一个 init 程序的原因大概是为了复用代码,因为开始没有进程,无法直接调用 exec 加载 init 程序(curproc为空),所以先创建一个进程执行 initcode,再 exec 修改进程地址空间可以更好的复用 exec 的代码。

第一个进程的历程:

执行 initcode 程序执行 exec,被修改为 init 程序执行 init 程序

这里具体的代码有:

加载 initcode 程序执行 initcode执行 init3.1 加载 initcodevoid userinit(void) { struct proc *p; extern char _binary_initcode_start[], _binary_initcode_size[]; p = allocproc(); // 分配任务结构体,预留上下文空间 initproc = p; if((p->pgdir = setupkvm()) == 0) // 建立页表的内核部分 panic("userinit: out of memory?"); inituvm(p->pgdir, _binary_initcode_start,(int)_binary_initcode_size); //初始化虚拟地址空间,将initcode程序装进去 p->sz = PGSIZE;

userinit 负责加载 initcode 到进程空间中。

首先 allocproc 分配进程结构体调用 inituvm 初始化虚拟地址空间void inituvm(pde_t *pgdir, char *init, uint sz) { char *mem; if(sz >= PGSIZE) panic("inituvm: more than a page"); mem = kalloc(); //分配一页物理内存 memset(mem, 0, PGSIZE); // 清零 mappages(pgdir, 0, PGSIZE, V2P(mem), PTE_W|PTE_U); // 映射到虚拟地址空间0-4KB memmove(mem, init, sz); // 将要运行的初始化程序搬到0-4KB }

inituvm 需要做的事情如下:

分配一个物理页,将数据清零将物理页映射到虚拟地址空间的 0~4kb将二进制初始化程序加载到 0~4kb

在 exec 里面我们是加载的 elf 文件的可装载段,这里初始化 initcode 程序在编译的时候没有编译成 elf 可执行文件,而是编译成了只有机器码的二进制文件,没有多余的信息,可以直接加载到内存运行,而不像 elf 文件只能加载可装载部分而后运行。

initcode 也不是一个单独的程序文件,与其他一些东西一起编译成了整个内核文件,所以直接使用 memmove 就可。

对于 initcode 程序,所有的数据段、代码段和栈段只分配一个页就行了,因为共用一个页,所以将权限设置为用户可读写。栈段设置为页顶,向下增长,因为程序小且立即就 exec,不会发生冲突。

接着回到 userinit 函数:

memset(p->tf, 0, sizeof(*p->tf)); p->tf->cs = (SEG_UCODE ds = (SEG_UDATA es = p->tf->ds; p->tf->ss = p->tf->ds; p->tf->eflags = FL_IF; p->tf->esp = PGSIZE; p->tf->eip = 0; // beginning of initcode.S

在 allocproc 中预留了中断栈帧的空间,这里填充内容。对于通用寄存器,由于是新进程,设置为0就行,不会影响执行情况,而对于其他寄存器做如下设置:

cs 设置为用户代码段选择子ds、es、ss 共享用户数据栈段选择子eflags 设置为开中断esp 设置为 PGSIZE,使用刚才分配的那个页顶作为栈底eip 设置为 0,它跟 elf 文件不同有个专门的入口点,这里直接就从头开始执行,所以 eip 设置为 0 就可。

exec & fork 都不会修改段寄存器和eflags寄存器,所以第一个进程这里初始化好了段寄存器和eflags,其他进程这些寄存器就会一直使用。

safestrcpy(p->name, "initcode", sizeof(p->name)); p->cwd = namei("/"); acquire(&ptable.lock); p->state = RUNNABLE; release(&ptable.lock);

这里和前面一样,为了防止读取到中间状态,需要加锁

IMG

3.2 执行 initcode

这第一个进程被调度上 cpu 执行的第一个函数就是 forkret ,这个函数我们前面说过如果如果是普通函数的话就可以看作一个空函数,但如果是第一个函数的话,会做实际的事情

void forkret(void) { static int first = 1; release(&ptable.lock); if (first) { first = 0; iinit(ROOTDEV); //初始化inode initlog(ROOTDEV); //初始化日志,从日志记录中恢复数据,保持磁盘数据的一致性 } }

主要就是做两件事:

初始化 inode,主要是对 inode 的锁的初始化初始化日志,并且从日志记录中恢复数据,保证磁盘数据的一致性。

forkret 是每个新进程被调度后都要执行的一个函数,它不是 initcode 中的函数。当弹出上下文后,第一个进程进入了 initcode.S 的第一条指令

.globl start start: pushl $argv // 压入参数argv pushl $init // 压入参数路径init pushl $0 // 无效返回地址 movl $SYS_exec, %eax // exec系统调用号 int $T_SYSCALL // 执行系统调用 # for(;;) exit(); 正常情况不会执行到这 exit: movl $SYS_exit, %eax // exit系统调用号 int $T_SYSCALL // 执行exit系统调用 jmp exit // char init[] = "/init\0"; 准备exec第一个参数路径 init: .string "/init\0" // // char *argv[] = { init, 0 }; 准备exec第二个参数字符串数组 .p2align 2 argv: .long init .long 0

所以 initcode 程序只是调用 exec 去加载 init 程序,压入参数和返回地址、系统号,进入内核执行系统调用。

这里只说一点 exec 如果执行成功是不会返回执行 exit 函数的。当进程执行成功时,eip 被设置为 init 程序的 entry 入口。

3.3 执行 initint main(void) { int pid, wpid; if(open("console", O_RDWR) = 0 && wpid != pid) // wait() printf(1, "zombie!\n"); } }

init 程序主要做两件事:

打开控制台文件,然后 fork 出一个子进程运行 shell 程序wait 子进程,其中就包括孤儿进程

除非关机,那么 init 就一直会作为一个进程运行,如果 shell 关闭就创建新的 shell。

4. **总结

大致总结 fork 和 exec 的区别:

IMG

第一个进程的流程:

执行 initcode, 初始化日志和inodeexec 转为执行 initinit 初始化打开文件描述符表,fork 子进程 exec 创建 shell


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3