XV6 Homework - Lazy page allocation

进程的地址空间除了进程的代码,数据,栈外,还有堆内存。其中堆内存是按需分配的,当进程需要的时候,通过调用 malloc 标准库函数,向操作系统动态申请物理堆内存。

Part one

Homework 要求修改 sbrk syscall,去掉实际分配内存的代码,进入 shell 后运行命令会发送错误。

sbrk 禁止内存分配

作业很简单,注释掉内存分配代码 growproc(但是别忘记修改进程的大小),运行 shell 命令,出现类似错误

$ echo hi
pid 3 sh: trap 14 err 6 on cpu 0 eip 0x13bf addr 0x4004--kill proc

当 CPU 访问内存时,MMU 将线性地址转换为物理地址,期间会访问页目录表 -> 页表 -> 页。如果页表或者页不存在(table entry 的 P 位为0),将会导致 page fault,会产生中断号为14的中断,trap.c:trap 没有处理这个中断,只是简单的打印相应的信息。

为什么执行 shell 命令的时候,会发生 page fault,查看 sh.c,发现有通过 malloc 动态申请内存的地方,比如

struct cmd*
execcmd(void)
{
  struct execcmd *cmd;

  cmd = malloc(sizeof(*cmd))

为什么注释掉 sys_sbrk 分配内存的代码,调用 malloc 就会发生 page fault?下面来看看 malloc/free 的实现机制

malloc/free

当进程需要动态申请内存时,是调用 syscall:sbrk 向操作系统申请内存,不用的时候调用 syscall:free 向操作系统释放内存。如果每次都调用 syscall,性能会受到影响。具体原因可以参考 syscall 的分析,每次发生系统调用,都需要保存 user space 的上下文,切换栈和上下文等耗时操作。所以 C 语言标准库会提供相应函数,用来管理进程的堆内存,提高内存使用的效率。

这两个库函数来自 TCPL 1 8.7 节,具体实现就是通过链表来管理动态分配的内存。链表头 base 存放在进程的数据段中,链表的节点是在堆中。

malloc

需要注意的是

  • 空闲块包括 header 和 后面的空闲空间
  • 空闲块大小是 header 大小的整数倍
  • malloc 返回指针指向的是 header 后面的空闲空间的地址

所以 malloc 分配的内存,header 部分进程用不到的,利用率有一定的损失

具体代码解析参考 TCPL。这里只关注和 sbrk 相关的代码,在 morecore 中,进程会调用 sbrk 向 kernel 申请内存,如果成功则返回新内存的首地址。

sysproc.c:sys_sbrk 会调用 proc.c:growproc 分配内存,逻辑比较简单

  • 调用 kalloc 申请物理页内存
  • 建立虚拟内存和物理内存的映射关系
  • 更新 CPU 的段寄存器,TSS,重新加载页表

在进程的虚拟地址空间里面,进程的代码,数据,栈,堆内存是连续的。每个进程的进程信息里面都有一个字段,表示进程的大小。每次新申请内存的首地址,就是申请前进程的大小。申请成功后,需要将进程大小更新。

page fault 解析

回到前面的问题,为什么注释 sysproc.c:sys_sbrkgrowproc,执行 shell 命令的时候,发生了 page fault。

malloc 如果没有空闲内存,会向 kernel 申请,虽然 malloc 返回了申请内存的首地址,但是其实没有物理内存分配出来。当访问一个线性地址时,CPU会访问页目录和页表,如果页目录项或者页表项的 P 位为0,表示当前页表或者页不存在,就会发生 page fault。

具体看一下 umalloc:morecore 代码

static Header*
morecore(uint nu)
{
  char *p;
  Header *hp;

  if(nu < 4096)
    nu = 4096;
  p = sbrk(nu * sizeof(Header));
  if(p == (char*)-1)
    return 0;
  hp = (Header*)p;
  hp->s.size = nu;
  free((void*)(hp + 1));
  return freep;
}

前面说到虽然首地址返回了,但是并没有物理内存分配出来,访问这段内存就会出现 page fault。代码有初始化 header 结构体字段的逻辑,正是这行代码导致了 page fault。

  hp->s.size = nu;

查看 sh.asm,发现这行代码地址是 0x13bf,shell 命令异常退出的信息显示,正是 eip 这段代码导致了 page fault。

  p = sbrk(nu * sizeof(Header));
    1395:	8b 45 08             	mov    0x8(%ebp),%eax
    1398:	c1 e0 03             	shl    $0x3,%eax
    139b:	89 04 24             	mov    %eax,(%esp)
    139e:	e8 51 fc ff ff       	call   ff4 <sbrk>
    13a3:	89 45 f4             	mov    %eax,-0xc(%ebp)
  if(p == (char*)-1)
    13a6:	83 7d f4 ff          	cmpl   $0xffffffff,-0xc(%ebp)
    13aa:	75 07                	jne    13b3 <morecore+0x34>
    return 0;
    13ac:	b8 00 00 00 00       	mov    $0x0,%eax
    13b1:	eb 22                	jmp    13d5 <morecore+0x56>
  hp = (Header*)p;
    13b3:	8b 45 f4             	mov    -0xc(%ebp),%eax
    13b6:	89 45 f0             	mov    %eax,-0x10(%ebp)
  hp->s.size = nu;
    13b9:	8b 45 f0             	mov    -0x10(%ebp),%eax
    13bc:	8b 55 08             	mov    0x8(%ebp),%edx
    13bf:	89 50 04             	mov    %edx,0x4(%eax)

虽然汇编显示代码多次用到了 sbrk 返回的地址,但都是比较或者存储这个地址本身,并没有访问这个地址所指的内存,只有 mov %edx, 0x4(%eax) 访问了 这个地址+4 处的内容,而这个地址的物理页是不存在的。

但是细心的同学会发现,发生中断时, eip 是指向产生中断代码的下一行指令,但是这里的 eip 却是产生中断的那条指令。

前面没有讲太清楚,page fault 其实是属于 Exception,而且是 Faults 类型的 Exception,这种类型的 Exception,eip 是指向当前行,原因是这种异常是可以恢复的,恢复后应该继续尝试刚才出错的指令。比如 page fault 后,kernel 进行 lazy allocation,完成后应该继续执行刚才那条 page fault 的指令,按上面的例子,就是完成 hp->s.size = nu 赋值操作。

Exception 相应的解释,可以参考 《Intel(R) 64 and IA-32 Architectures Software Developer’s Manuals-vol-3A》2

不管是 interrupt,还是 exception,xv6 都采用同样的机制处理,所以 page fault 和 syscall 的代码逻辑基本上一致。

Part two

第二部分要求实现 Lazy allocation,有了前面的分析,这一部分难度不是很大。

首先发生 page fault 的时候,系统会产生中断,中断号是 14

#define T_PGFLT         14      // page faul

在系统调用的文章中,已经分析了中断处理流程,这里只需要关注 interrupt handler,代码在 trap.c:trap 中。

switch 语句的 default 分支中,需要处理 T_PGFLT 中断。在 page allocte 之前,需要知道哪个线性地址发生了 page fault,这个地址是存放在 cr2 寄存器上的。

后面的逻辑和 proc.c:growproc 相差不大,分配物理内存,建立虚拟内存和物理内存的映射关系,更新 CPU 相应的状态和进程的页表信息。

处理完这些流程后,page fault handler 就应该返回,当 trap.c:trap() 返回后,trapasm.S 就会恢复发生 page fault 的上下文,如果是 malloc 发生了 page fault,那么就切换栈,切换回用户空间,继续执行发生 page fault 的指令。

总结

因为 syscall 消耗性能,所以 C 标准库提供 malloc/free 来管理用户使用的堆内存。但是当用户申请堆内存后,不一定会立即使用,为了提高性能,homework 采用 lazy allocation 机制。用户申请堆内存的时候,kernel 实际没有分配物理内存,而是在需要的时候才会分配。这样就尽量减少不必要的 syscall,提高性能。

具体逻辑就是,用户申请内存时,不分配物理内存。当实际用到物理内存时,发生 page fault 的时候,产生 Faults 类型的 Exception (中断号14 T_PGFLT) 。在 中断/异常 handler(trap.c:trap()) 中捕获 page fault,实现 lazy allocation

Reference