深入理解进程之大杂烩

948 阅读8分钟

本篇是进程第三篇,也是最后一篇,涉及的类容方面有些多,所以取了个名大杂烩。本篇主要就是来解决余下的一些问题:程序的加载,第一个进程的创建,进程的休眠唤醒,等待退出,还有锁同步的问题,最后还简单聊了聊运行库,废话不多说,一个一个来看。

加载程序

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

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

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

参数有两个:pathpath 为文件路径,argvargv 为指向字符串数组的指针

if((ip = namei(path)) == 0){    //获取该文件的inode
    end_op();
    cprintf("exec: fail\n");
    return -1;
  }
ilock(ip);   //对该inode上锁,使得数据有效

这一步就是解析路径然后取得该文件的 inodeinode,如果失败则返回,成功的话将该 inodeinode 上锁使用,同时 ilockilock 会使 inodeinode 的数据有效,inodeinode 部分详见前面文件系统系列文章关于 inode 部分

if(readi(ip, (char*)&elf, 0, sizeof(elf)) != sizeof(elf))  //读取elf头
    goto bad;
if(elf.magic != ELF_MAGIC)  //判断文件类型
    goto bad;
//#define ELF_MAGIC 0x464C457FU  // "\x7FELF" in little endian

elfelf 前 4 个字节分别是 0x7F, 'E', 'L', 'F',如果一个文件前 4 个字节是这 4 个东东,说明它是一个 elfelf 文件。

if((pgdir = setupkvm()) == 0)
    goto bad;

建立新页表的内核部分,原页表是旧进程的,这里创建一个新的属于自己的页表,也就是创建了一个新的虚拟地址空间。

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

分配虚拟内存

int allocuvm(pde_t *pgdir, uint oldsz, uint newsz);

allocuvmallocuvmpgdirpgdir 指向的虚拟地址空间中,分配 newszoldsznewsz - oldsz 大小的虚拟内存,返回 newsznewsz分配虚拟内存就是上述所说的那两步:分配物理内存,填写页表项建立映射,来看具体实现:

a = PGROUNDUP(oldsz);    //4K对齐

for(; a < newsz; a += PGSIZE){
	mem = kalloc();     //分配物理内存
	if(mem == 0){       //如果分配失败
  		cprintf("allocuvm out of memory\n");
  		deallocuvm(pgdir, newsz, oldsz);  //回收newsz到oldsz这部分空间
  		return 0;
	}
	memset(mem, 0, PGSIZE);   //清除内存中的数据
	if(mappages(pgdir, (char*)a, PGSIZE, V2P(mem), PTE_W|PTE_U) < 0){  //填写页表项建立映射
  		cprintf("allocuvm out of memory (2)\n");   //如果建立映射失败
  		deallocuvm(pgdir, newsz, oldsz);   //回收newsz到oldsz这部分空间
  		kfree(mem);   //回收刚分配的mem这部分空间
  		return 0;
	}
}
return newsz;

整个流程就是我说的那两步,如果其中哪一步出错,就调用 deallocuvmdeallocuvm 函数将已分配的空间回收,deallocuvmdeallocuvm 就完全是 allocuvmallocuvm 的逆操作,释放回收空间之后清除页表项,这里就不再赘述。

因为程序在内存中的虚拟地址是从 0 开始的,所以 sizesize 值不止是程序大小,也是程序末尾的地址,而 allocuvmallocuvm 分配的虚拟内存就是接着当下程序的大小 oldsizeoldsize 开始分配的,所以 allocuvmallocuvm 函数的作用如下图所示:

“搬运”数据

int loaduvm(pde_t *pgdir, char *addr, struct inode *ip, uint offset, uint sz){
  if((uint) addr % PGSIZE != 0)   //地址必须是对齐的
    panic("loaduvm: addr must be page aligned");
    
  for(i = 0; i < sz; i += PGSIZE){  
    if((pte = walkpgdir(pgdir, addr+i, 0)) == 0)  //addr+i地址所在页的页表项地址
      panic("loaduvm: address should exist");
    pa = PTE_ADDR(*pte);  //页表项记录的物理地址

    if(sz - i < PGSIZE)  //确定一次能够搬运的字节数
      n = sz - i;
    else
      n = PGSIZE;
      
    if(readi(ip, P2V(pa), offset+i, n) != n) //调用readi将数据搬运到地址P2V(pa)处
      return -1;
  }
  return 0;
}

从磁盘搬运数据到内存就是对 readireadi 函数的封装,readireadi 函数会根据文件 inodeinode 将数据读取到相应位置,有关 readireadi 函数详见前文 inode、目录、路径 一文。

有了上述的了解之后,回到 execexec 函数:

sz = 0;
for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){
    if(readi(ip, (char*)&ph, off, sizeof(ph)) != sizeof(ph))  //读取程序头
      goto bad;
    if(ph.type != ELF_PROG_LOAD)  //如果是可装载段
      continue;
    if(ph.memsz < ph.filesz)  //memsz不应该小于filesz
      goto bad;
    if(ph.vaddr + ph.memsz < ph.vaddr)  //memsz也不应该是负数
      goto bad;
    if((sz = allocuvm(pgdir, sz, ph.vaddr + ph.memsz)) == 0)  //分配sz到ph.vaddr + ph.memsz之间的虚拟内存
      goto bad;
    if(ph.vaddr % PGSIZE != 0)  //地址应该是对齐的
      goto bad;
    if(loaduvm(pgdir, (char*)ph.vaddr, ip, ph.off, ph.filesz) < 0) //从磁盘搬运filesz字节的数据到vaddr
      goto bad;
}

这段代码就是装载程序的过程,有上述的铺垫之后整个流程应该是非常的清晰,如果不是,可能是对 elfelf 文件不太熟悉,我在使用分身术变身术创建新进程一文中讲述了一定的 elfelf 文件,本文就不赘述了。只是提一个比较重要的点:filesz<memszfilesz < memsz,也就是一个段在磁盘上占据的空间大小是要小于装载到内存后的大小的,这是因为 .bss.bss 节的存在,.bss.bss 存放未初始化的全局变量或初始化为 0 的全局变量,它不占据实际的磁盘空间,只是一个占位符,但是加载到内存时,也要为这些变量分配空间,所以 filesz<memszfilesz < memsz

上述将程序从磁盘装载到内存,接着就应该为该进程分配资源准备运行环境了,

分配用户栈

sz = PGROUNDUP(sz);   //size对齐
if((sz = allocuvm(pgdir, sz, sz + 2*PGSIZE)) == 0)  //分配两页虚拟内存
	goto bad;
clearpteu(pgdir, (char*)(sz - 2*PGSIZE));  //清除第一页的页表项USER位,使得用户态下不可访问
sp = sz;

首先将 szsz 向上取整,进程在内存中的映像各个段都是 4K4K 对齐的,这里栈段也不例外,所以其实各个段之间并不是挨在一起的,如果不是刚好对齐,那么其间是有缝隙的。

调用 allocuvmallocuvm 分配两页虚拟内存,“高页”作为栈,因为栈是向下扩展的所以"低页"作为保护页。所谓保护页就是将这页对应的页表项 USERUSER 属性位置 0,置 0 之后用户态就不能访问这个页

将目前的程序大小赋值给 spspspsp 就是未来的栈指针,现下程序的内存映像如下图所示:

准备参数

要运行的程序从 mainmain 函数开始,mainmain 函数有两个参数:

  • argcargc 是参数个数
  • argvargv 一个指向字符串数组的指针

在这一部分我们需要将 mainmain 函数的这两个参数以及 argvargv 指向的那些字符串“搬运”到栈里面。因为 mainmain 函数也是个函数,是函数就要遵循调用约定,将 mainmain 需要的参数压栈,先来看看参数布局图:

搬运参数是个很有意思的事,它不像前面从磁盘搬运数据到内存,磁盘对于每个进程来说共享的,进程 A 将数据从磁盘搬运到 进程 B 的地址空间,没问题,一个 readireadi 函数搞定。但是搬运参数不太一样,参数位于在进程 AA 的地址空间,目的地在进程 BB 的地址空间,两个空间独立不互通,没办法直接搬运。

咋个办?很自然的想法就是要找一个两者共享互通的地儿来中转,这个地方就是内核,将内核当作中转站,来看相关函数实现:

char* uva2ka(pde_t *pgdir, char *uva)
{
  pte_t *pte;

  pte = walkpgdir(pgdir, uva, 0);
  if((*pte & PTE_P) == 0)
    return 0;
  if((*pte & PTE_U) == 0)
    return 0;
  return (char*)P2V(PTE_ADDR(*pte));
}

这个函数将用户部分的虚拟地址转化为内核虚拟地址,初看这句话是不是觉得挺荒谬?可事实的确如此,要理解这点,就要深入理解 xv6xv6 内存的管理方式,再来看遍这张图:

内核部分(不是全部)映射到了物理内存的 0PHYSTOP0-PHYSTOP,用户部分映射到物理内存的空闲部分,所以物理内存的空闲部分既映射到了用户部分又映射到了内核部分两个值相同的用户部分虚拟地址对应的物理地址是不同的,但是两个值相同的内核部分虚拟地址对应的物理地址确实相同的

内核虚拟地址与物理地址之间就相差一个 KERNBASE(0x80000000)KERNBASE(0x8000 0000),所以内核虚拟地址转物理地址不需要查页表,减去 KERNBASEKERNBASE 就是物理地址值。相反,物理地址加上 KERNBASEKERNBASE 就是它在内核的虚拟地址

但是用户部分的虚拟地址没有直接对应关系,地址转换必须查页表,用户部分的虚拟地址转换成物理地址,之后再加上 KERNBASEKERNBASE 就是转化到内核的虚拟地址了。上述函数就是做的这件事。

用户虚拟地址与内核虚拟地址转换之后就可以进行参数的搬运了,来看:

int copyout(pde_t *pgdir, uint va, void *p, uint len)
{
  char *buf, *pa0;
  uint n, va0;

  buf = (char*)p;
  while(len > 0){
    va0 = (uint)PGROUNDDOWN(va);
    pa0 = uva2ka(pgdir, (char*)va0);  //va0在pgdir的映射下的内核地址
    if(pa0 == 0)
      return -1;
    n = PGSIZE - (va - va0);   //一次搬运的字节数
    if(n > len)
      n = len;
    memmove(pa0 + (va - va0), buf, n);  //从buf搬运n字节
    len -= n;
    buf += n;
    va = va0 + PGSIZE;
  }
  return 0;
}

代码初看可能有些麻杂,这里举个例子,现下我要从进程 AA 用户部分的地址 va_a_uva\_a\_u 搬运 n 字节到进程 BB 用户部分的地址 va_b_uva\_b\_u 处,要做些什么呢?

  1. 调用 uva2kauva2kava_b_uva\_b\_u 转化为内核虚拟地址 va_b_kva\_b\_k
  2. 调用 memmovememmoveva_a_uva\_a\_u 搬运 n 字节到 va_b_kva\_b\_k

看似将数据搬运到了内核 va_b_kva\_b\_k,但是 va_b_kva\_b\_k 又对应着 va_b_uva\_b\_u,所以从逻辑上讲的确是将数据从 va_a_uva\_a\_u 搬运到了 va_b_uva\_b\_u

有了上述了解接着来看 execexec 函数:

uint ustack[3+MAXARG+1];
//#define MAXARG  32  // max exec arguments
for(argc = 0; argv[argc]; argc++) {
    if(argc >= MAXARG)
      goto bad;
    sp = (sp - (strlen(argv[argc]) + 1)) & ~3;  //栈指针向下移准备放字符串,保证4字节对齐
    if(copyout(pgdir, sp, argv[argc], strlen(argv[argc]) + 1) < 0) //搬运字符串
      goto bad;
    ustack[3+argc] = sp;  //记录字符串在栈中的地址
}

这部分搬运字符串到新的用户空栈里面,是字符串,不是参数。栈指针向下移动,留出的空间放字符串,这部分空间首地址地址要满足 44 字节对齐,将地址记录在 ustackustack 中。

ustack[3+argc] = 0;

ustack[0] = 0xffffffff;  // 假的返回地址
ustack[1] = argc;    //参数
ustack[2] = sp - (argc+1)*4;  // argv pointer 参数

sp -= (3+argc+1) * 4;   //栈指针向下移
if(copyout(pgdir, sp, ustack, (3+argc+1)*4) < 0)  //将字符串地址搬运到新的用户空间栈里面
	goto bad;

这部分搬运参数和字符串的地址,没什么可说的,来看搬运完之后的图:

回来继续看 execexec 函数:

for(last=s=path; *s; s++)  //解析程序名
  if(*s == '/')   
    last = s+1;
safestrcpy(curproc->name, last, sizeof(curproc->name)); //strncpy

这部分根据 execexec 的参数路径修改进程名字,路径本就是一个个文件名组合而成,最后一项就是程序的名称。

oldpgdir = curproc->pgdir;   //旧页目录
curproc->pgdir = pgdir;   //换新页目录
curproc->sz = sz;    //更改程序大小
curproc->tf->eip = elf.entry;  //设置执行的入口点
curproc->tf->esp = sp;  //新的用户栈顶
switchuvm(curproc);   //切换页表
freevm(oldpgdir);   //释放旧的用户空间
return 0;

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

freevmfreevm 用来释放用户空间映射的所有物理内存和页表本身占用的内存,来看如何实现的:

void freevm(pde_t *pgdir)
{
  uint i;

  if(pgdir == 0)
    panic("freevm: no pgdir");
  deallocuvm(pgdir, KERNBASE, 0);     //free 用户空间映射的物理内存
  for(i = 0; i < NPDENTRIES; i++){    //free 页表占用的物理内存 
    if(pgdir[i] & PTE_P){
      char * v = P2V(PTE_ADDR(pgdir[i]));
      kfree(v);
    }
  }
  kfree((char*)pgdir);   //free 页目录占用的物理内存
}

过程很清晰,涉及到的函数前面也说过了,分为三步,注释已经标明。这一部分就将进程原本的内存映像全部给删掉了,被新的映像替换掉

返回

execexec 的最后一部分我们来讨论返回相关的问题,execexec 是个系统调用,系统调用的流程在前文系统调用如何实现的时候出过一张图,当时是用 writewrite 为例子来说的,这里在来看一眼:

每个系统调用的基本流程都是差不多的,如上图所示,这里我们主要关注返回的部分,当内核功能函数(系统调用实际工作的函数)执行完后,就会去内核栈获取返回地址:traprettrapret 中断退出函数的地址,执行这个函数就是中断退出操作,最后一条指令为 iretiret,执行之后就返回到用户态。

但是 execexec 有所不同,举个例子,进程内存映像 aa 的某个函数调用了 execexec,随后进入内核后执行 sys_execsys\_exec 这个实际的功能函数,sys_execsys\_exec 创建了一个新的内存映像 bbsys_execsys\_exec 执行完之后同样的去内核栈获取返回地址:traprettrapret,但是执行这个函数不会返回原来的内存映像 aa,而是返回到新的内存映像 bb,因为 aa 已经没了,原因如下:

  • 用户级上下文 trapframetrapframeeipeip 已经修改为 elfelf 的入口点
  • 用户栈指针已经修改为新用户栈顶
  • 切换到了新页表,旧页表已经释放,页表就可以看作虚拟地址空间。

来看看 execexec 系统调用的过程图:

再来看看 execexec 实际干了哪些事情的流程图:

好了 execexec 到此结束,这条线捋得应该还是蛮清晰得。

创建第一个进程

有了前面创建普通进程和程序加载的铺垫,创建第一个进程是很简单的,相关代码在 proc.c/userinitproc.c/userinit。第一个进程就像是 fork 和 exec 得结合体,因为是第一个,也就没有父进程得内存映像克隆,所以要调用 execexec 来加载 initinit 程序。xv6xv6 得实际情况稍有不同,它是先加载一个 initcodeinitcode 程序,这个程序再调用 execexec 加载 initinit 程序。来看实际源码:

void 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;

这一部分分配任务结构体,然后调用 inituvminituvm 初始化虚拟地址空间,将 initcodeinitcode 程序装进去。initcodeinitcode 程序是进程回到用户空间要执行的初始化程序,程序要在用户空间运行,就要在用户空间分配内存然后将相应的程序加载进去,这就是 inituvminituvm 所作的事情,来看码:

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
}

inituvminituvm 所做的事情具体如下:

  • 分配一页物理内存清零
  • 将这页物理内存映射到虚拟地址空间的 04KB0-4KB
  • 将二进制初始化程序加载到 04KB0-4KB 这个区域

execexec 里面我们是加载的 elfelf 文件的可装在段,这里初始化 initcodeinitcode 程序在编译的时候没有编译成 elfelf 可执行文件,而是编译成了只有机器码的二进制文件,没有多余的信息,可以直接加载到内存运行,而不像 elfelf 文件只能加载可装载部分而后运行initcodeinitcode 也不是一个单独的程序文件,与其他一些东西一起编译成了整个内核文件,所以直接使用 memmovememmove 就可。具体的原因可以去看看 MakefileMakefile,这里不赘述了。

接着回到 userinituserinit 函数:

p->sz = PGSIZE;
memset(p->tf, 0, sizeof(*p->tf));
p->tf->cs = (SEG_UCODE << 3) | DPL_USER;
p->tf->ds = (SEG_UDATA << 3) | DPL_USER;
p->tf->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

allocprocallocproc 中预留了中断栈帧的空间,这里填充内容,代码段选择子为用户代码段选择子,数据段选择子为用户数据段选择子,栈段 SSSS 附加数段 ESES 等等都共用数据段的选择子。设置栈帧中的 eflagseflagsIFIF 位,当中断退出将其弹到 EFLAGSEFLAGS 寄存器之后就会允许响应中断。

对这个临时的初始化程序并没有专门为它分配一个用户栈,而是直接使用映射的 04KB0-4KB 的高地址,将 4KB4KB 作为栈底,后面我们会看到这个初始化程序很小,所以这样用没有问题。

栈帧里面的 eipeip 设置为 0,因为从前面的 inituvminituvm 程序中能够看到,它是将 initcodeinitcode 搬到了 0 地址处,它跟 elfelf 文件不同有个专门的入口点,这里直接就从头开始执行,所以 eipeip 设置为 0 就可。

  safestrcpy(p->name, "initcode", sizeof(p->name)); //进程名字
  p->cwd = namei("/");  //工作在根目录

  acquire(&ptable.lock);
  p->state = RUNNABLE;     //修改状态为RUNNABLE
  release(&ptable.lock);
}

最后这一部分就很简单了,跟 forkfork 一样,设置进程的名字工作路径和状态,没多少说的。

来看第一个进程刚加载 initcodeinitcode 程序准备执行时的内存映像示意图:

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

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

主要就是做两件事:

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

forkretforkret 是每个新进程被调度后都要执行的一个函数,它不是 initcodeinitcode 中的函数,下面来看看这个 initcodeinitcode 程序做了啥:

.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

所以 initcodeinitcode 程序就是调用 execexec 去加载 initinit 程序,这段代码本身应该是很好懂的,如果不太明白,可以回看前面关于 execexec 的讲解以及前文如何实现调用。

这里只说一点 execexec 如果执行成功是不会返回执行 exitexit 函数的,原因在 execexec 那块已说过。最后来看 initinit 程序是什么样子的,其中有些东西我还没说过,所以这里就看个大概流程:

int main(void)
{
  int pid, wpid;
 
  if(open("console", O_RDWR) < 0){ //打开控制台
    mknod("console", 1, 1);
    open("console", O_RDWR);
  }
  dup(0);  // stdout
  dup(0);  // stderr

  for(;;){  
    printf(1, "init: starting sh\n");
    pid = fork();   //fork一个子进程
    if(pid < 0){
      printf(1, "init: fork failed\n");
      exit();
    }
    if(pid == 0){      //子进程运行shell
      exec("sh", argv);
      printf(1, "init: exec sh failed\n");
      exit();
    }
    while((wpid=wait()) >= 0 && wpid != pid)  //wait()
      printf(1, "zombie!\n");
  }
}

initinit 程序主要做两件事:

  • 打开控制台文件,然后 forkfork 出一个子进程运行 shellshell 程序
  • waitwait 子进程,其中就包括孤儿进程

好了,第一个进程就讲述这么多,来看张总图结束:

休眠与唤醒

休眠我们前面提到了很多次,今天在这里得见它们的真身,休眠和唤醒两者本身一点都不复杂,甚至可以说是简单,难就难在用锁来同步,关于锁的问题咱们后面几种讨论,这里先来看休眠唤醒的实际操作部分

void sleep(void *chan, struct spinlock *lk);
void wakeup(void *chan);

进入休眠

sleepsleep 原型:

void sleep(void *chan, struct spinlock *lk);

sleepsleep 使进程休眠在某个对象 chanchan 上面,lklk 是管理这个对象的锁,但不一定归 chanchan 所有,比如 sleep(curproc, &ptable.lock),所有任务结构体表就一把锁,用来管理任务结构体,而比如 sleep(&log, &log.lock),这把锁就是 loglog 所独有的。

要注意我们这里所说的休眠 sleepsleep 不是系统调用 sleepsleep,为避免冲突我后面说 sleep_syssleep\_sys (sys_sleepsys\_sleep 是实际存在的内核函数) 。xv6xv6 的代码就是有这问题,一些用户接口,内核函数,还有个别文件里面的函数取名一样,但是我们实际看码的时候要区别对待。sleep_syssleep\_sys 是根据这里的 sleepsleep 实现的,简单提一句,每次时钟中断都会增加滴答数,根据滴答数就可以判断休眠的时间到没,如果没到就持续调用 sleepsleep 来休眠。实现原理很简单的,可以自行看一下,本文就不多说了,这里主要还是看具体的休眠是如何进行的。

void sleep(void *chan, struct spinlock *lk){
    /*********略**********/
	p->chan = chan;    //休眠在chan上
    p->state = SLEEPING;  //状态更改为SLEEPING

    sched();  //让出CPU调度

    p->chan = 0;  //休眠对象改为0
    /*********略**********/
}

整个 sleepsleep 函数实际就做了这么点事,设置当前进程休眠对象和状态,然后再调用 schedsched 让出 CPUCPU,从 schedsched 返回的时候表示当前进程已经休眠完了并再次被调度上了 CPUCPU,此时不再是休眠状态所以将休眠对象更改为 0 表没有

退出休眠

static void wakeup1(void *chan)
{
  struct proc *p;

  for(p = ptable.proc; p < &ptable.proc[NPROC]; p++)
    if(p->state == SLEEPING && p->chan == chan)  //寻找休眠状态且休眠在chan上的进程
      p->state = RUNNABLE;   //将其状态更改为RUNNABLE
}

唤醒操作就更简单了,挨个查询任务结构体,寻找状态为 SLEEPINGSLEEPING 且休眠对象为参数 chanchan 的进程,然后将其状态更改为 RUNNABLERUNNABLE,表示这个进程被唤醒又可以被调度上 CPUCPU

可以看出进入休眠状态和退出休眠状态是很简单的,简单到不需要多做解释,只是注意调用 schedsched 之后是不会返回的,而是去执行其他进程了,这就是休眠的好处,提高 CPUCPU 的利用率

等待与退出

父进程等待子进程退出,waitwaitexitexit 这两个函数其实都主要来释放资源的,exitexit 是子进程自己调用,释放一部分资源,但是肯定是释放不完的,因为执行 exitexit 函数本身就需要资源,比如栈,所以 exitexit 只能释放一部分,剩下的交给父进程调用 waitwait 来处理

子进程退出

void exit(void){
    struct proc *curproc = myproc();
    if(curproc == initproc)
    panic("init exiting");

第一个进程是不能退出的,它有特殊作用,比如回收孤儿进程的资源

    for(fd = 0; fd < NOFILE; fd++){
        if(curproc->ofile[fd]){
          fileclose(curproc->ofile[fd]);
          curproc->ofile[fd] = 0;
        }
    }

这一部分关闭所有文件,如果减到 0,再释放该文件的 inodeinode,如果文件的链接数和引用数都为 0 了,就删除该文件。详见前文关于文件系统调用的讲解

    begin_op();
    iput(curproc->cwd);  //放下当前工作路径的inode
    end_op();
    curproc->cwd = 0;  //当前工作路径设为0表空

这一部分释放当前工作路径的 inodeinode,工作路径就是个目录,将其设为 0 表空。另外涉及磁盘读写的操作都要用日志系统来保证原子操作,文件操作不细说,有问题见前文。

    acquire(&ptable.lock);   //取锁
    wakeup1(curproc->parent);  //唤醒父进程

父进程可能因为 waitwait 调用休眠,这里唤醒,锁的问题后面一起说

    for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){   //将被遗弃的孩子过继给init进程
        if(p->parent == curproc){
          p->parent = initproc;
          if(p->state == ZOMBIE)
            wakeup1(initproc);
        }
    }

如果将要退出的进程有子进程,这些进程就会变成孤儿进程,因为当前进程(它们的父进程)要退出了,不管它们了,不会调用 waitwait 来为他们善后回收进程了,所以叫做孤儿进程。孤儿进程也是需要处理的,将它们全部过继给 initinit 进程,如果其中有处于僵尸状态的进程,则唤醒 initinit 进程让其立即处理

    curproc->state = ZOMBIE;  //状态变为僵尸状态
    sched();    //调度,永远不会返回
    panic("zombie exit");   //因为不会返回,正常情况是不可能执行到这的
}

进程的状态变为僵尸态,父进程的 waitwait 会来处理僵尸态的进程,schedsched 之后是不会再返回的,因为能被调度的只有 RUNNABLERUNNABLE 的进程,况且该进程被父进程的 waitwait 处理之后,该进程都不存在了,何来返回一说

等待子进程退出

waitwait 函数主要就是寻找状态为 ZOMBIEZOMBIE 的进程,找到一个就回收它的资源,来看码

for(;;){   //"无限循环"
    havekids = 0;
    for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){ //循环寻找子进程
      if(p->parent != curproc)  //如果进程p的父进程不是当前进程
        continue;
      havekids = 1;   //当前进程有子进程
      if(p->state == ZOMBIE){  //如果子进程的状态是ZOMBIE,回收它的资源
        // Found one.
        pid = p->pid;      
        kfree(p->kstack);  //回收内核栈
        p->kstack = 0;
        freevm(p->pgdir);  //回收用户空间以及页表占用的五ii内存
        p->pid = 0;   
        p->parent = 0;
        p->name[0] = 0;
        p->killed = 0;
        p->state = UNUSED; //状态变为UNUSED,表该结构体空闲了
        release(&ptable.lock);  //释放锁
        return pid;
      }
    }

这部分是如果当前进程有子进程且状态为 ZOMBIEZOMBIE 的话,就回收它的资源

    if(!havekids || curproc->killed){
      release(&ptable.lock);
      return -1;
    }

这部分是如果当前进程没有子进程或者当前进程被杀死了了,释放锁,返回 1-1 表出错了。killkill 与锁的问题后面详述

	sleep(curproc, &ptable.lock);  //DOC: wait-sleep
}

这部分是如果当前进程有子进程,但是子进程还没有退出,父进程休眠等待子进程退出。

上述就是 waitwaitexitexit 函数,可以看出两者的主要工作的确就是回收子进程的资源,当然还有其他工作,比如如果要退出的进程有子进程,则将其子进程过继给 initinit 进程

有了上述的了解再来看状态变化图应该有更深刻的理解:

KILLKILL

exitexit 是一个进程主动调用然后退出,killkill 是一个进程强迫另一个进程调用 exitexit 退出每个任务结构体都有个 killedkilled 属性,将其置 1 就表示该进程被 “杀死” 了但实际还未实际进行 killkill 这个操作

int kill(int pid)
{
  struct proc *p;

  acquire(&ptable.lock);  //取锁
  for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){ //循环寻找pid号进程
    if(p->pid == pid){  //找到了
      p->killed = 1;   //killed置1
      // Wake process from sleep if necessary.
      if(p->state == SLEEPING)  //如果该进程在睡瞌睡
        p->state = RUNNABLE;   //唤醒
      release(&ptable.lock);  //放锁
      return 0;   //返回正确
    }
  }
  release(&ptable.lock);  //放锁
  return -1;   //返回错误
}

killkill 函数就只是简单的将 killedkilled 置为 1,如果该进程在休眠,则唤醒该进程。但是 killkill 其实并没有将真正地杀死进程——调用 exitexit 使其退出,那在什么地儿退出呢?

每个进程总有中断的时候,所以在中断服务总程序 traptrap 里面检查 killedkilled 值,如果发现 killed==1killed == 1,则调用 exitexit 退出:

void trap(struct trapframe *tf){
  /**********略*********/
  if(myproc() && myproc()->killed && (tf->cs&3) == DPL_USER) //如果被killed
     exit();  //退出
    
  if(myproc() && myproc()->state == RUNNING &&  //发生了时钟中断
     tf->trapno == T_IRQ0+IRQ_TIMER)
     yield();  //主动让出CPU

  if(myproc() && myproc()->killed && (tf->cs&3) == DPL_USER) //再次检查如果被killed
     exit();  //退出
}

每个进程总会有进入内核的时候,而在离开内核的时候检查 killedkilled 值,如果被 killedkilled 则调用退出,之所以检查两次是因为在 yieldyield 让出 CPUCPU 的前后都可能被 killedkilled,为了更实时地杀死进程,做了两次检查,但其实后面不做检查也行,因为进程总要再此进入内核,到那时 killed==1killed == 1 依然会被捕获,但及时性可能就没那么好。

另外某些情况发现 killed==1killed==1 后会直接返回一个错误值,外层函数捕获到这个错误值就会 panicpanicpanicpanicxv6xv6 中随处可见,而 panicpanic 。而 panicpanic 就是打印一串错误消息后就冻住 CPUCPU(无限循环),总的来说 kill(pid)kill(pid) 就是强迫该进程调用 exit 退出

LOCKLOCK

锁同步的问题一直是操作系统里面最为复杂的问题之一,xv6xv6 中锁的设计在 LOCK 一篇中已经聊过,xv6 的锁设计本身不难,难得是锁的使用,这里就根据进程这一块使用锁的地方来简单聊一聊。进程中与锁的有关地方主要有休眠,唤醒,等待,退出,调度,切换,一个一个地慢慢来看。

休眠唤醒

休眠是休眠在某个对象上,唤醒是唤醒休眠在某个对象上的进程,所以想当然的可以这样来声明 sleepsleepwakeupwakeup

void sleep(void *obj);
void wakeup(void *obj);

那这样声明对不对呢?来看个简单的变种生产者消费者的例子:

Send:
	while(obj != NULL)  //对象还在
		;            //循环
	obj = 1;       //制作对象
	wakeup(obj);   //唤醒休眠在其上的消费者
	
Recv:
	while(obj == NULL)  //没有对象可消费
		sleep(obj);  //休眠
	obj = NULL;    //消耗对象

乍一看感觉没什么问题,但是如果 wakeupwakeup 发生在 sleepsleep 之前就会引起死锁:

比如中断这种典型情况:假如此时 obj=NULLobj = NULLRecvRecv 准备休眠,但在调用 sleepsleep 前发生中断使得 sleepsleep 调用延后。这段时间内 SendSend 在另一个 CPUCPU 上执行了,将 objobj 置为 1 然后唤醒休眠在 objobj 上的 RecvRecv。而 RecvRecv 呢,中断结束后它却休眠去了,所以休眠在唤醒之后,休眠错过了唤醒,死锁

就算不是中断,因为是多处理器,两者运行的时间是不确定的,完全也可能出现上述的情况。避免这种前后感知的信息不一致的办法就是加锁,来看第二个版本:

Send:
	lock(obj_lock);
	while(obj != NULL)  //对象还在
		;            //循环
	obj = 1;       //制作对象
	wakeup(obj);   //唤醒休眠在其上的消费者
	unlock(obj_lock);

Recv:
	lock(obj_lock);
	while(obj == NULL)  //没有对象可消费
		sleep(obj);  //休眠
	obj = NULL;    //消耗对象
	unlock(obj_lock);

如此,还是上述同样的情况,RecvRecv 在准备 sleepsleep 之前获得了 obj_lockobj\_lockSendSend 没有 obj_lockobj\_lock,是不可能 wakeupwakeup 的,所以休眠就不会错过唤醒。

这个问题倒是解决了,又有一个新问题,如果 RecvRecv 带着 obj_lockobj\_lock 休眠不释放的话,同样死锁

因此将 sleepsleep 的接口定义为:

void sleep(void *obj, void *obj_lock);

将这个锁也作为 sleepsleep 的参数,在 RecvRecv 进入休眠状态时释放该锁 ,在 RecvRecv 返回时重新获取该锁

我们需要在“进入休眠后释放该锁”,如果在这之前释放锁,可能会出现 sleepsleep 错过 wakeupwakeup 的情况,原因同前。

但是都进入休眠状态了,怎么可能释放锁呢?我们在前面已经看过进入休眠状态就是修改当前进程状态为 SLEEPINGSLEEPING,且让出 CPUCPUCPUCPU 都让出去了,怎么可能释放锁呢?所以释放锁还得在进入休眠状态前操作,那不是又与上面矛盾吗?因此释放锁和进入休眠状态这两个步骤得是一个原子操作,也就是说,再来一个锁

涉及到了进程状态的改变,那必然是任务结构体数组的那把锁 ptable.lockptable.lock,用它来保证释放休眠对象锁和进入休眠状态这两个步骤是个原子操作,但是 xv6xv6 对其处理有少许不同,更为精巧一些,来看完整的 sleepsleep 代码:

void sleep(void *chan, struct spinlock *lk)
{
  struct proc *p = myproc();

  if(lk != &ptable.lock){  //如果lk不是ptable.lock
    acquire(&ptable.lock);  //获取ptable.lock
    release(lk);    //释放lk
  }
  // Go to sleep.
  p->chan = chan;
  p->state = SLEEPING;
  sched();
  // Tidy up.
  p->chan = 0;
    
  // Reacquire original lock.
  if(lk != &ptable.lock){  //如果lk不是ptable.lock
    release(&ptable.lock); //释放ptable.lock
    acquire(lk);   //重新获取lk锁
  }
}

整个代码上下来看十分对称,先不考虑休眠对象锁就是任务结构体锁这种情况,可以很清楚地看到释放休眠对象锁 lklk 和进入休眠状态这两个步骤在 ptable.lockptable.lock 这把锁的保护下进行,是个原子操作,经过上面的分析,这样就不会出现什么幺蛾子了。

在调用 sleepsleep 的函数通常有对休眠对象锁的取放操作,而 sleepsleep 本身从逻辑上来说并不需要释放锁,只是为了避免死锁所以才临时释放。而在同一个函数中对锁的获取和释放通常是成双成对的,所以 sleepsleep 在返回时还要重新将休眠对象锁取回来。

再来看 lk=ptble.locklk = ptble.lock,休眠对象锁就是任务结构体锁的情况,也就是说休眠在一个进程上,这种情况只有一种:在 waitwait 函数中,父进程需要等待子进程退出,这个时候它就休眠在自己身上:

sleep(curproc, &ptable.lock);

这种情况从 sleepsleep 函数来看,根本不会释放 ptable.lockptable.lock,如此不会死锁吗?xv6xv6 对此作如下处理:

static void wakeup1(void *chan)
{
  struct proc *p;

  for(p = ptable.proc; p < &ptable.proc[NPROC]; p++)
    if(p->state == SLEEPING && p->chan == chan)
      p->state = RUNNABLE;
}

void wakeup(void *chan)
{
  acquire(&ptable.lock);
  wakeup1(chan);
  release(&ptable.lock);
}

wakeupwakeup 函数分为两部分,实际干事的为 wakeup1wakeup1,但它不需要锁,所以可以回头看看在 exitexit 中唤醒父进程实际使用的 wakeup1wakeup1,这样就不会造成死锁。

调度切换

调度切换的过程是进程 AA 切换到调度程序,调度程序根据轮询算法选取一个进程 BB,然后切换到进程 BB

进程 AA 切换到调度程序一般是调用 schedsched 函数,schedschedswtchswtch 的封装,在这之前一定要获取到 ptable.lockptable.lock 这把锁来保证进程的上下文。例如 yieldyield 通常用在因时间片到了而重新调度,代码如下:

void yield(void)
{
  acquire(&ptable.lock);  //DOC: yieldlock
  myproc()->state = RUNNABLE;
  sched();
  release(&ptable.lock);
}

在调用 swtchswtch 取了 ptable.lockptable.lock 这把锁,并把当前进程的状态更改为 RUNNABLERUNNABLE,这之后才来调用 schedsched

void sched(void)
{
  int intena;
  struct proc *p = myproc();

  if(!holding(&ptable.lock))
    panic("sched ptable.lock");
  if(mycpu()->ncli != 1)
    panic("sched locks");
  if(p->state == RUNNING)
    panic("sched running");
  if(readeflags()&FL_IF)
    panic("sched interruptible");
  intena = mycpu()->intena;
  swtch(&p->context, mycpu()->scheduler);
  mycpu()->intena = intena;
}

schedsched 函数在需要重新调度的情况都会被调用,进行了许多检查,调用 schedsched 当前进程的状态不该是 RUNNINGRUNNING,因为 schedsched 之前已经把状态修改了。此时不该允许中断,因为取锁的时候关中断了,xv6xv6 为了保险,取锁的时候就将中断关闭,避免死锁,这部分详见 LOCK 一文。intenaintenapushclipushcli 之前 CPUCPU 允许中断的情况,先把这个值保存起来待到该进程再次被调度的时候恢复,保证了同一个进程里中断允许情况一致。下面重点说说为什么 swtchswtch 时必须要取得 ptable.lockptable.lock 这把锁

这之上总总会发现 switchswitch 的整个过程都持有 ptable.lockptable.lock,要知道执行 swtchswtch 是会切换进程的,因而这个锁在一个进程中获取,在另一个进程中释放。这样使用锁很不常见,但这里是必须的

如果 swtchswtch 执行的过程中没有持有 ptable.lockptable.lock 会怎么样呢?举个例子,运行在 CPU0CPU0 的进程 AA 的时间片到了,调用 yieldyield 将进程 A 的进程设置为 RUNNABLERUNNABLECPU0CPU0 在执行 schedsched 之前另一个 CPU1CPU1 可能调度进程 AA,这样的话一个进程运行在了两个 CPUCPU 上,其内核栈的上下文就乱套了,所以 swtchswtch 的整个执行期间必须持有锁,保证进程的状态和上下文一致

由此,schedsched 前有取锁,后有放锁,但这两个操作不是配对的,进程 AA 来取锁,进程 BB 来放锁。schedsched 之后的语句是进程再次被调度重上 CPUCPU 后执行的语句,一般就会先来个释放锁的操作,即使是进程是第一个进程也不例外,在前面我们看到,创建一个新进程,就是把这个新进程模拟成一个旧进程,往它的内核站里面填充各种虚假的上下文,使得它像一个已经上过 CPUCPU 执行的旧进程。不仅上下文要模仿,行为也要模仿才得行,所以在填充内核级上下文的时候将 eipeip 设置为 forkretforkret 函数的地址

void forkret(void)
{
    static int first = 1;
    release(&ptable.lock);
  
    if (first) {
    first = 0;
    iinit(ROOTDEV);
    initlog(ROOTDEV);
  }
}

这个函数表示如果创建的进程不是第一个进程的话就干一件事:释放 ptable.lockptable.lock,这就是模仿一个旧进程,因为旧进程执行 swtchswtch 切换了上下文之后就是释放 ptable.lockptable.lock

但也有 schedsched 之后不释放的,比如 exitexit 代码中最后两行:

  curproc->state = ZOMBIE;
  sched();
  panic("zombie exit");

exitexit 中调度 schedsched 之后就永远也不会回来了,因为进程都被销毁了,怎么可能还回来。那取锁放锁岂不是没配对?这里再次注意,schedsched 前后取锁放锁本来就不配对,进程 AA 取锁,进程 BB 放锁,然后进程 AA 没了,有问题吗?没有问题。

运行库

最后再来简单聊一聊运行库,运行库是标准库的扩展,这与我们进程有什么关系?mainmain 函数我们都很熟悉,对此有两个常问的问题:程序从 mainmain 开始吗?mainmain 执行完之后又到哪儿去了呢?这两个问题都与运行库相关,mainmain 也是个函数,它也是被调用执行的,而且 mainmain 执行之前还要为它准备参数,执行完之后还要对程序”善后“,这一切都是运行库来做的。

运行库会对程序的运行环境进行初始化,比如参数,堆,IOIO,线程等等,之后调用 mainmain 执行程序主体,mainmain 执行完成之后进行堆的销毁等等操作然后调用 exitexit 退出,咱们这里来看看极简极简的运行库伪码意思意思:

push argv    #准备main函数的参数
push argc

call main   #调用main

push eax    #准备exit的参数
call exit   #调用exit

很清晰地看到在 mainmain 运行之前,压栈 argvargvargcargc 两个参数,然后调用 mainmain 函数,之后将 mainmain 函数的返回值压栈,将其作为 exitexit 的参数调用 exitexit 使得进程退出。所以这里应该明白 mainmain 函数最后的 returnreturn 有什么用处了吧,它会作为 exitexit 的参数执行 exitexit 系统调用,根据值的不同系统就可以做出不同的反应

xv6xv6 并没有实现运行库,关于 mainmain 函数的环境准备隐含的包括在了 execexec 函数里面,execexec 就在用户栈里面压入了 mainmain 函数的参数。而用户程序执行完之后需要显示的调用 exitexit 来退出,因为它没有运行库来为它调用 exitexit。像上述那样简单的运行库实现起来也不复杂,但本文这儿就不叙述了,后面有机会专门写写这方面的文章。

最后总结

关于 xv6xv6 的进程方面大概就这么多,关于进程这条线私以为捋得还是够清楚得了,进程如何创建的,第一个进程又是如何创建的,怎么被调度上的 CPUCPU,第一次上 CPUCPU 又是什么情况,程序加载是什么意思,进程如何休眠又如何被唤醒,僵尸进程孤儿进程是如何产生的,程序是从 mainmain 开始的吗,mainmain 结束之后又去了哪,以及比较困难的锁同步问题。

上面的这些问题自己感觉还是说明白了,但是我也只是站在 xv6xv6 代码上面去理解它,但实际上为什么要这么设计,这么设计有什么好处,不这样设计可不可以,对此我的水平就不够了,一些问题实在不太清楚。比如锁的粒度问题,开关中断的时机,有些操作看似多余但实际上对于多处理器下的设计十分重要,比如内核页表的切换,类似的问题还有很多。

所以其实我所讲述的都是比较片面简单的东西,xv6xv6 虽小,但五脏俱全,要真细究,跳出原有代码谈设计,这个问题就很难很难,我目前的水平还达不到,所以还需要努力修炼,提升修为。

好了,本文就到这里,有什么问题还请批评指正,也欢迎大家来同我交流学习进步。