3.6: 进程地址空间(Process address space)
每个进程都有自己的页表,当 xv6 在不同进程之间切换时,也会切换页表。

如一个进程的用户内存从虚拟地址 0 开始,可以增长到 MAXVA(kernel/riscv.h:375),理论上允许进程寻址多达 256 GB 的内存。
// one beyond the highest possible virtual address.
// MAXVA is actually one bit less than the max allowed by
// Sv39, to avoid having to sign-extend virtual addresses
// that have the high bit set.
#define MAXVA (1L << (9 + 9 + 9 + 12 - 1))
一个进程的地址空间由包含程序代码的页面(xv6 用权限 PTE_R、PTE_X 和 PTE_U 映射)、包含程序预初始化数据的页面、用于栈的页面以及用于堆的页面组成。xv6 使用权限 PTE_R、PTE_W 和 PTE_U 来映射数据、栈和堆。
在用户地址空间中设置权限是一种常见的使得用户进程更加健壮的技术。如果程序代码被映射为 PTE_W,进程可能会意外修改自己的程序代码;例如,一个编程错误可能导致程序写入一个空指针,修改地址 0 处的指令,然后继续运行,从而可能造成更多混乱。
为了立即检测此类错误,xv6 将代码区域映射为没有 PTE_W;如果程序意外尝试向地址 0 存储数据,硬件将拒绝执行该存储操作,并引发页面错误(参见第 4.6 节)。随后,内核会终止该进程并打印出一条信息性消息,以便开发人员追踪问题。
类似地,通过不给数据区域设置 PTE_X,用户程序无法意外跳转到程序数据中的某个地址并从该地址开始执行。
在实际操作中,通过仔细设置权限来使得进程的健壮性,也有助于防御安全攻击。攻击者可能向程序(例如 Web 服务器)提供精心构造的输入,从而触发程序中的一个漏洞,并试图将该漏洞转化为一种攻击手段。仔细设置权限以及其他技术(如随机化用户地址空间的布局)使得这种攻击更加困难。
栈是一个单页,图中显示了 exec 创建的初始内容。栈的最顶部是包含命令行参数的字符串以及指向它们的指针数组。在其下方是一些值,这些值允许程序像刚调用函数 main(argc, argv) 一样从 main 开始执行。
为了检测用户栈溢出的风险,xv6在栈正下方放置了一个不可访问的保护页,通过清除 PTE_U 标记位实现。如果用户栈溢出并尝试使用栈下方的地址,由于保护页对运行在用户模式下的程序不可访问,硬件会生成页面故障异常(一个真实的操作系统可能会在栈溢出时自动为用户栈分配更多内存)。
当进程请求 xv6 分配更多用户内存时,xv6会扩展进程的堆。xv6 首先使用 kalloc 分配物理页面,然后向进程的页表中添加指向新物理页面的 PTE。xv6 在这些 PTE 中设置 PTE_W、PTE_R、PTE_U 和 PTE_V 标志。大多数进程不会使用整个用户地址空间;xv6 对未使用的 PTE 清除 PTE_V。
这里可以看到一些关于页表的优美用例。
首先,不同进程的页表将用户地址翻译到不同的物理内存页,从而使得每个进程都拥有独立的用户内存。
其次,每个进程看到的内存是从 0 开始的连续虚拟地址,而进程的物理内存可以是非连续的。
第三,内核在用户地址空间的顶部映射了一个包含跳板代码的页面(没有 PTE_U),因此一页物理内存可以显示在所有地址空间中,但只能被内核使用。
在 xv6 等小型操作系统中,初始的栈大小通常只有一个物理页(4KB),足够简单的用户程序使用。但在复杂程序(尤其是存在递归函数或需要较大栈空间的场景)中,一个物理页可能远远不够。现代操作系统通过动态栈扩展解决这个问题,而在 xv6 中,可以通过手动修改栈的分配逻辑来实现更大的栈空间。
相关代码实现
创建空置的页表
// create an empty user page table.
// returns 0 if out of memory.
pagetable_t
uvmcreate()
{
pagetable_t pagetable;
pagetable = (pagetable_t) kalloc();
if(pagetable == 0)
return 0;
memset(pagetable, 0, PGSIZE);
return pagetable;
}
fork进程时复制页表
// proc.c
int fork(void)
{
int i, pid;
struct proc *np;
struct proc *curproc = myproc();
// 为子进程分配内存页表
if((np = allocproc()) == 0)
return -1;
np->pgdir = uvmcreate(); // 创建子进程的页表
if(np->pgdir == 0)
return -1;
// 复制父进程的地址空间到子进程的页表
if(uvmcopy(curproc->pgdir, np->pgdir, curproc->sz) < 0){
freeproc(np);
return -1;
}
// 其他初始化过程...
return pid;
}
在fork进程之后调用exec执行新的elf程序是页表的内容如何更新 在 xv6 中,当一个进程通过 fork 创建新进程后,子进程的页表会被复制,基本上和父进程共享同样的虚拟地址空间。然后,当子进程调用 exec 系统调用执行一个新的 ELF 程序时,子进程的页表内容会发生更新,新的 ELF 程序会替代原来的进程映像。
exec 系统调用的工作流程 exec 系统调用的作用是将当前进程的地址空间替换为一个新的程序的地址空间,通常包括以下几个步骤:
加载 ELF 程序: exec 调用会加载 ELF 文件的头部和各个段,将程序的代码、数据、堆栈等部分加载到进程的虚拟地址空间。为了实现这一点,exec 必须创建新的页表映射,将新程序的段映射到虚拟地址空间中。
更新页表: 当 exec 被调用时,进程的页表内容会被更新。进程的原有虚拟地址空间会被清空并重新映射,以映射到 ELF 文件的各个段。具体的页表更新步骤包括:
释放进程原来的页表(如果需要)。 为新的程序加载 ELF 文件的各个段,设置对应的虚拟地址映射。 清理和设置新的堆栈空间,通常是在 ELF 文件的末尾部分创建新的堆栈空间。 更新堆栈、数据段、堆、BSS 段等的映射。 执行新程序: 执行 ELF 程序后,进程开始使用新的虚拟地址空间,这时的页表就完全对应于新程序的地址空间。
exec 系统调用中的代码 以下是 xv6 中 exec 的实现过程中的关键步骤:
加载 ELF 文件头: exec 首先读取 ELF 文件的头部,检查它是否是一个有效的 ELF 文件,并获取程序的各个段的信息。
清除原有页表: 在加载新的 ELF 程序之前,必须释放进程原来的虚拟内存。这通常通过 freevm 函数来完成,它释放进程的页表以及所有的映射。
创建新的页表并映射段: exec 会调用 mappages 或类似的函数,将 ELF 文件中的段映射到进程的新虚拟地址空间中。每个段会被映射到一个连续的虚拟地址区间,并设置合适的权限(如只读、可写、可执行等)。
设置堆栈空间: 在 ELF 文件加载完成后,exec 还会为新的进程分配堆栈空间,并设置堆栈的虚拟地址。
跳转到新程序: 最后,进程会跳转到 ELF 文件的入口点,开始执行新的程序。此时,进程的虚拟地址空间已经完全被新的程序覆盖,原来 fork 的进程页表已经被新的映射替换。
代码示例 在 xv6 中,exec 函数的实现大致如下:
c 复制 编辑 // exec系统调用:加载ELF程序 int exec(char *path, char **argv) { char buf[512], *mem; int i, fd, off; struct elfhdr elf; struct proghdr ph; struct inode *ip; pde_t *pgdir;
// 打开程序文件
if((fd = open(path, O_RDONLY)) < 0)
return -1;
// 读取 ELF 头部
if(read(fd, &elf, sizeof(elf)) != sizeof(elf))
return -1;
// 检查 ELF 头部是否合法
if(elf.magic != ELF_MAGIC)
return -1;
// 创建新的页表
pgdir = setupkvm();
if(pgdir == 0)
return -1;
// 加载 ELF 文件中的各个段
off = elf.phoff;
for(i = 0; i < elf.phnum; i++){
if(read(fd, &ph, sizeof(ph)) != sizeof(ph))
return -1;
if(ph.type == ELF_PROG_LOAD){
// 为段映射虚拟地址
if(ph.memsz > 0){
if(mappages(pgdir, (void*)ph.vaddr, ph.memsz, ph.paddr, PTE_W|PTE_U) < 0)
return -1;
}
}
}
// 设置堆栈空间
// 通常会在程序的末尾位置分配堆栈空间
// 这里简化了堆栈初始化
// 关闭程序文件
close(fd);
// 将新创建的页表加载到 CR3 寄存器
switchpgdir(pgdir);
// 跳转到新的程序入口
return 0;
} 详细步骤 打开 ELF 文件: 使用 open 打开目标 ELF 文件,并读取 ELF 文件头部,验证其是否合法。
创建新的页表: 使用 setupkvm 创建新的进程页表,清除旧的页表内容。
加载程序段: 遍历 ELF 文件的程序头部(ph),将每个加载段映射到虚拟地址空间。mappages 用于将 ELF 程序段映射到虚拟地址,设置适当的权限(例如可读、可写等)。
堆栈初始化: 在 ELF 加载后,通常会为进程分配堆栈空间,这一步在 xv6 中简单化了,但实际情况会涉及到设置栈的虚拟地址和物理地址映射。
跳转到新的程序入口: 最后,进程跳转到 ELF 文件的入口点(由 ELF 文件头的 entry 字段指定),开始执行新的程序。
总结
在 exec 执行时,进程的页表会被清空,并重新映射为新的 ELF 程序的虚拟地址空间。通过逐段加载 ELF 文件的各个段(如代码段、数据段等),进程的虚拟地址空间会被重新填充,并设置适当的权限。最后,进程会跳转到新的程序入口,开始执行新的程序。