xv6 内存布局与页表 / xv6 Memory Layout And Page Tables
1. 这篇文章解决什么问题
虚拟内存是 xv6 学习里第一道真正的硬门槛。
很多人读 proc.c、trap.c、exec.c 卡住,根源其实在页表。
你会看到这些名字:
pagetable_tpte_twalkmappagesuvmallocuvmcopycopyincopyoutTRAMPOLINETRAPFRAMEsatp
如果不知道它们之间的关系,代码就会像咒语。
这篇文章要建立一张完整地图:
address
|
v
virtual address vs physical address
|
v
page and page table
|
v
kernel memory layout
|
v
physical page allocator
|
v
kernel page table
|
v
user process page table
|
v
copyin / copyout学完后,你应该能解释:
- 为什么用户地址不能直接当内核指针用。
- 为什么每个进程有自己的页表。
- 为什么
fork()要调用uvmcopy()。 - 为什么
exec()要重建用户地址空间。 - 为什么
TRAMPOLINE要映射在特殊高地址。 - 为什么
trapframe和页表关系很紧。 kalloc()分配的是物理页,不是普通 C 对象。
1.1 本文基础词小注释
内存篇最容易卡在词上。
下面这些词先用朴素版本理解。
内存
|
+-- memory
+-- 程序运行时存放代码、数据、栈、页表的地方
页
|
+-- page
+-- 操作系统管理内存的固定大小块
+-- xv6 里通常一页是 4096 字节
虚拟地址
|
+-- virtual address
+-- 程序看到的地址
+-- 需要页表翻译
物理地址
|
+-- physical address
+-- 真实 RAM 或设备地址
页表
|
+-- page table
+-- 虚拟地址到物理地址的翻译表
PTE
|
+-- Page Table Entry
+-- 页表项,一条具体映射记录2. 先分清三种“地址”
读 xv6 内存代码时,最重要的是分清地址。
至少有三类。
C pointer
|
+-- a typed address used by C code in current address space
virtual address
|
+-- address used by CPU before translation
+-- meaning depends on current page table
physical address
|
+-- address of actual RAM or memory-mapped device普通 C 代码里,指针通常看起来就是地址。
但内核里不能这么简单。
例如系统调用参数中的用户 buffer。
用户程序传入:
read(fd, buf, n);buf 对用户程序来说是 char *。
进入内核后,它是用户虚拟地址。
内核不能直接写:
*buf = value;因为这个地址属于用户页表。
内核需要通过 copyout()。
kernel data
|
v
copyout(pagetable, user_va, kernel_src, len)
|
v
user memory这就是本篇的核心边界。
地址不是孤立数字。
地址要和页表一起理解。
3. 页:内存管理的基本单位
xv6 按页管理很多内存。
一页通常是 4096 字节。
也就是 PGSIZE。
physical memory
+---------+---------+---------+---------+---------+
| page 0 | page 1 | page 2 | page 3 | page 4 |
+---------+---------+---------+---------+---------+
4KB 4KB 4KB 4KB 4KB为什么用页?
因为硬件页表按页映射。
操作系统也可以按页分配、回收、保护内存。
页表不是把每个字节都记录一遍。
它记录的是虚拟页到物理页的映射。
virtual page number
|
v
page table entry
|
v
physical page number + permission flags权限标志包括:
PTE_V: valid
PTE_R: readable
PTE_W: writable
PTE_X: executable
PTE_U: user accessible如果没有 PTE_V,页表项无效。
如果没有 PTE_U,用户态不能访问。
如果没有 PTE_W,写入会失败。
这些位让页表不仅负责地址翻译,也负责保护。
4. RISC-V Sv39 页表的直觉模型
xv6-riscv 使用 RISC-V 的页表机制。
常见是 Sv39 模式。
你不需要一开始记完所有位。
先建立三级页表模型。
virtual address
|
+-- VPN[2]
+-- VPN[1]
+-- VPN[0]
+-- page offset三级查找:
root page table
|
| index VPN[2]
v
level-1 page table
|
| index VPN[1]
v
level-0 page table
|
| index VPN[0]
v
PTE points to physical page
|
+-- offset selects byte inside page图:
VA
|
+-- VPN2 ----> root page table entry
|
v
next page table
|
+-- VPN1 --------+
|
v
next page table
|
+-- VPN0 --------+
|
v
leaf PTE -> physical page
|
+-- offset ------------------+
v
final physical addresswalk() 的作用就是走这棵页表树。
如果中间页表不存在,在某些场景下它还可以分配页表页。
mappages() 则负责把一段虚拟地址映射到物理地址。
5. satp:当前页表的根
页表存在内存里。
CPU 怎么知道当前用哪张页表?
答案是 satp。
satp 是 RISC-V 的 supervisor address translation and protection 寄存器。
可以理解成当前页表根寄存器。
satp
|
v
root page table physical address
|
v
hardware page table walk切换 satp 会改变地址翻译。
这就是上下文切换和用户/内核切换复杂的原因之一。
Process A satp -> page table A
Process B satp -> page table B
Kernel satp -> kernel page table同一个虚拟地址,在不同页表下可以映射到不同物理页。
VA 0x1000 under process A -> PA A
VA 0x1000 under process B -> PA B这提供进程隔离。
用户程序以为自己拥有连续地址空间。
内核和硬件通过页表把它映射到真实物理内存。
6. memlayout.h:xv6 的地址地图
memlayout.h 是读内存管理前必须看的文件。
它定义了内核认为重要的地址。
这些地址包括:
- 设备地址。
- 物理内存起点。
- 内核基址。
- 最大物理内存。
- trampoline 地址。
- trapframe 地址。
可以先把它理解成一张地图。
high virtual address
+-------------------------------+ TRAMPOLINE
| trampoline code |
+-------------------------------+ TRAPFRAME
| per-process trapframe page |
+-------------------------------+
| user stack / heap / data/text |
| ... |
+-------------------------------+ 0
low virtual address内核侧还有设备和物理内存映射。
physical / device address space
+-------------------------------+
| PLIC |
+-------------------------------+
| VIRTIO disk MMIO |
+-------------------------------+
| UART |
+-------------------------------+
| kernel text/data |
+-------------------------------+
| free physical memory |
+-------------------------------+设备地址不是普通 RAM。
它们是内存映射 I/O。
CPU 访问这些地址,实际上是在读写设备寄存器。
这就是为什么 memlayout.h 同时和内存、设备相关。
7. kalloc.c:物理页分配器
kalloc.c 管理物理页。
它提供:
kinit()
kalloc()
kfree()kinit() 初始化空闲页链表。
kalloc() 分配一页。
kfree() 释放一页。
它不是通用 malloc()。
它每次处理一整页。
free physical pages
+--------+ +--------+ +--------+
| page A | -> | page B | -> | page C |
+--------+ +--------+ +--------+一个很有意思的技巧是:
空闲页本身被当作链表节点使用。
也就是说,一页空闲时,内核可以在这页里放一个 next 指针。
free page memory
+--------------------------+
| struct run { next } |
| unused bytes |
+--------------------------+这说明:
对象的含义取决于管理者。
一页被分配后,可能变成页表页、用户内存页、trapframe 页。
一页空闲时,可以变成 allocator 的链表节点。
8. vm.c:虚拟内存核心
vm.c 是 xv6 虚拟内存核心。
它可以按功能分组。
kernel page table
|
+-- kvmmake
+-- kvminit
+-- kvminithart
page table walking
|
+-- walk
+-- walkaddr
mapping
|
+-- mappages
+-- uvmunmap
user memory lifecycle
|
+-- uvmcreate
+-- uvmfirst
+-- uvmalloc
+-- uvmdealloc
+-- uvmcopy
+-- uvmfree
copy between kernel and user
|
+-- copyin
+-- copyout
+-- copyinstr不要从第一行硬读。
先按这五组理解。
walk() 是基础。
mappages() 依赖它。
uvmalloc()、uvmcopy() 等用户内存操作也依赖页表映射。
copyin/copyout 又依赖用户页表查找。
调用关系可以粗略画成:
walk
|
+-- mappages
| |
| +-- kernel page table setup
| +-- user page table setup
|
+-- walkaddr
|
+-- copyin
+-- copyout
+-- copyinstr9. 内核页表
内核也需要页表。
内核页表负责让内核访问:
- 内核代码。
- 内核数据。
- 物理内存。
- 设备寄存器。
- trampoline。
kernel virtual address
|
v
kernel page table
|
+-- kernel text: read + execute
+-- kernel data: read + write
+-- physical memory direct map
+-- devices
+-- trampoline权限很重要。
内核代码段通常可执行,不应该随便可写。
设备映射需要读写。
trampoline 需要执行。
简化图:
+------------------+------------------+------------------+
| virtual region | physical target | purpose |
+------------------+------------------+------------------+
| KERNBASE | kernel loaded PA | kernel text/data |
| UART0 | UART device | console I/O |
| VIRTIO0 | disk device | block I/O |
| PLIC | interrupt ctrl | device interrupt |
| TRAMPOLINE | trampoline code | trap transition |
+------------------+------------------+------------------+内核页表初始化完成后,每个 hart 通过 kvminithart() 启用。
启用意味着写 satp 并刷新相关地址翻译缓存。
10. 用户进程页表
每个进程有自己的用户页表。
这让每个进程有独立地址空间。
典型用户地址空间:
high address
+-------------------------------+ TRAMPOLINE
| trampoline |
+-------------------------------+ TRAPFRAME
| trapframe |
+-------------------------------+
| user stack |
+-------------------------------+
| guard page |
+-------------------------------+
| heap |
| ... |
+-------------------------------+
| data |
+-------------------------------+
| text |
+-------------------------------+ 0
low address这里最特殊的是顶部两页:
TRAMPOLINE 映射 trampoline 代码。
TRAPFRAME 映射当前进程的 trapframe。
trampoline 需要在用户页表中可见。
因为从用户态 trap 到内核、从内核返回用户态时,页表切换过程中仍要执行这段代码。
trapframe 需要在切换过程中保存/恢复用户寄存器。
struct proc
|
+-- pagetable
|
+-- trapframe
|
v
user page table maps TRAPFRAME to this physical page这就是 proc、trap、页表三者交汇的地方。
11. walk():走页表
walk() 的作用是找到某个虚拟地址对应的 PTE。
PTE 是 page table entry。
也就是页表项。
简化过程:
walk(pagetable, va, alloc)
|
v
use VPN[2] to find level 2 entry
|
v
use VPN[1] to find level 1 entry
|
v
use VPN[0] to find leaf entry
|
v
return pointer to PTE如果中间页表不存在:
alloc == 0
|
+-- return 0
alloc == 1
|
+-- allocate a page table page
+-- install it
+-- continue walking这里要注意:
walk() 返回的是 PTE 的地址。
也就是指向页表项的指针。
不是直接返回最终物理地址。
如果要得到可访问的物理地址,还要解析 PTE。
这就是为什么 walk() 和 walkaddr() 是不同函数。
12. mappages():建立映射
mappages() 把一段虚拟地址映射到物理地址。
它会逐页建立映射。
virtual range
|
+-- page 0 -> physical page 0
+-- page 1 -> physical page 1
+-- page 2 -> physical page 2伪流程:
for each page in range
|
v
pte = walk(pagetable, va, alloc=1)
|
v
fill pte with physical address and permission映射包含两部分:
PTE
|
+-- physical page number
+-- permission flags权限决定谁能访问、如何访问。
例如用户代码页可能是:
PTE_R | PTE_X | PTE_U | PTE_V用户数据页可能是:
PTE_R | PTE_W | PTE_U | PTE_V内核页通常不带 PTE_U。
这样用户态不能直接访问内核内存。
13. uvmalloc() 和 uvmdealloc():用户内存增长和收缩
用户进程的地址空间不是固定不变。
sbrk() 可以增长用户内存。
内核会通过 uvmalloc() 分配并映射新页。
old size
|
v
allocate physical pages
|
v
map new virtual pages
|
v
new size图:
before
+---------+---------+
| text | data |
+---------+---------+
after uvmalloc
+---------+---------+---------+---------+
| text | data | heap | heap |
+---------+---------+---------+---------+uvmdealloc() 则负责缩小地址空间。
它会取消映射并释放对应物理页。
这里要注意:
虚拟地址空间变大,不等于物理内存凭空出现。
必须有物理页支撑映射。
这就是 uvmalloc() 会调用 kalloc()。
14. uvmcopy():fork 时复制用户地址空间
fork() 需要创建子进程。
子进程看起来拥有和父进程一样的用户内存内容。
xv6 的简单做法是复制。
parent pagetable
|
v
for each mapped user page
|
+-- allocate child physical page
+-- copy bytes from parent page
+-- map child virtual page
v
child pagetable图:
parent VA 0x0 -> parent PA A
|
| copy content
v
child VA 0x0 -> child PA B父子进程虚拟地址可以相同。
但物理页不同。
这就是进程隔离。
same virtual address
|
+-- parent maps to physical page A
|
+-- child maps to physical page B现代系统常用写时复制优化。
xv6 基础版本更直接。
它真的复制页面内容。
这更适合教学。
15. uvmfree():释放用户地址空间
进程退出或 exec 替换地址空间时,需要释放旧用户内存。
uvmfree() 负责释放用户页表和映射的物理页。
生命周期图:
process created
|
v
user pagetable created
|
v
pages allocated and mapped
|
v
process exits or exec replaces image
|
v
uvmfree
|
v
physical pages returned to allocator释放内存时要小心两类东西:
leaf physical pages
|
+-- actual user memory
page table pages
|
+-- pages used to store page table entries页表本身也占物理页。
所以释放页表不只是释放用户数据页。
还要释放页表结构页。
16. copyin():从用户地址复制到内核
copyin() 用于从用户地址空间复制数据到内核缓冲区。
例如系统调用参数是用户指针时。
user memory
|
| copyin
v
kernel buffer函数参数大致含义:
copyin(pagetable, dst, srcva, len)
|
+-- pagetable: user page table
+-- dst: kernel destination pointer
+-- srcva: user virtual address
+-- len: bytes to copy重点:
srcva 不是内核指针。
它是用户虚拟地址。
copyin() 需要通过页表查找每个虚拟页对应的物理地址。
如果用户地址非法,复制失败。
这保护内核不被用户传入坏地址破坏。
17. copyout():从内核复制到用户地址
copyout() 方向相反。
kernel buffer
|
| copyout
v
user memory函数参数:
copyout(pagetable, dstva, src, len)
|
+-- pagetable: user page table
+-- dstva: user virtual destination address
+-- src: kernel source pointer
+-- len: bytes to copy典型场景:
read() 从文件读到内核缓冲,再复制到用户 buffer。
stat() 把内核构造的文件状态复制到用户传入的地址。
图:
disk / file / pipe
|
v
kernel data
|
v
copyout
|
v
user buffer这条边界是安全边界。
内核不能随便信任用户地址。
用户不能随便访问内核地址。
copyout() 就是跨边界搬运数据。
18. copyinstr():复制用户字符串
字符串比普通字节数组多一个问题:
要找到结尾。
C 字符串以 \0 结束。
copyinstr() 从用户地址复制字符串到内核。
它需要:
- 逐页检查用户地址。
- 复制字节。
- 遇到
\0停止。 - 避免超过最大长度。
user string
|
v
copyinstr
|
+-- check page mapping
+-- copy byte by byte
+-- stop at '\0'
v
kernel string buffer典型用途:
系统调用传入路径名。
例如:
open("file", ...)
exec("sh", ...)
chdir("/")这些路径字符串来自用户空间。
内核必须安全复制后再使用。
19. exec() 和地址空间替换
exec() 是理解地址空间的关键。
fork() 创建新进程。
exec() 不创建新进程。
它替换当前进程的用户地址空间。
before exec
proc pid = 3
|
+-- old pagetable
+-- old user text/data/stack
after exec
same proc pid = 3
|
+-- new pagetable
+-- new program text/data/stackexec() 会读取 ELF 文件。
ELF 是可执行文件格式。
它告诉内核程序各段应该加载到哪些虚拟地址。
简化流程:
read ELF header
|
v
create new page table
|
v
load program segments
|
v
allocate user stack
|
v
commit new pagetable to process
|
v
free old pagetable这就是为什么 exec.c 和 vm.c 关系紧密。
exec() 是用户地址空间重建。
20. TRAMPOLINE 和 TRAPFRAME
TRAMPOLINE 和 TRAPFRAME 是 xv6 地址空间中最特殊的两个位置。
top of user virtual address space
+-------------------------------+ TRAMPOLINE
| trampoline code |
+-------------------------------+ TRAPFRAME
| current process trapframe |
+-------------------------------+
| user stack / heap / data/text |
+-------------------------------+TRAMPOLINE 映射一段汇编代码。
它负责用户态和内核态切换。
TRAPFRAME 映射当前进程保存用户寄存器的那页。
为什么放在高地址?
为了不和普通用户程序的 text、data、heap、stack 混在一起。
为什么 trampoline 要同时在用户页表和内核页表可见?
因为切换页表时,CPU 仍然要继续执行这段代码。
execute trampoline
|
v
switch satp
|
v
continue executing trampoline at same virtual address这是 xv6 trap 设计的核心。
21. 页表和 proc 的关系
进程结构体里有页表字段。
简化:
struct proc
|
+-- pagetable
+-- sz
+-- trapframe
+-- kstack关系:
proc
|
+-- pagetable -------> user page table
|
+-- sz --------------> size of user memory
|
+-- trapframe -------> physical page mapped at TRAPFRAME
|
+-- kstack ----------> kernel stack virtual address一个进程不仅是 pid。
它包含一个地址空间。
fork() 操作 pagetable。
exec() 替换 pagetable。
exit() 释放 pagetable。
copyin/copyout 使用 pagetable。
trap 返回用户态也依赖 pagetable。
所以 pagetable 是 proc 的核心字段之一。
22. 内存相关源码职责图
memlayout.h
|
+-- address constants
+-- devices
+-- KERNBASE / PHYSTOP
+-- TRAMPOLINE / TRAPFRAME
riscv.h
|
+-- page table flags
+-- satp helpers
+-- PTE macros
kalloc.c
|
+-- physical page allocator
+-- kalloc / kfree
vm.c
|
+-- page table walk/map/unmap
+-- kernel page table
+-- user page table lifecycle
+-- copyin/copyout/copyinstr
exec.c
|
+-- builds new user address space from ELF
proc.c
|
+-- allocates process page table
+-- copies/free page table during fork/exit
trampoline.S
|
+-- depends on TRAMPOLINE mapping
+-- switches between user and kernel page tables内存不是单独模块。
它连接启动、进程、trap、系统调用、exec、文件系统。
23. 常见误解
误解一:虚拟地址就是物理地址。
不是。
虚拟地址要经过页表翻译。
误解二:pagetable_t 不是指针。
它表面没有星号,但底层是指针类型。
一定要追 typedef。
误解三:用户指针可以直接解引用。
不能。
用户地址需要通过 copyin/copyout。
误解四:kalloc() 类似普通 malloc。
不准确。
kalloc() 按页分配物理内存。
误解五:页表只负责映射,不负责权限。
不对。
PTE flags 同时表达访问权限。
误解六:fork() 只是复制 struct proc。
不是。
它还要复制用户地址空间、trapframe、文件引用等。
误解七:exec() 创建新进程。
不是。
exec() 替换当前进程的用户程序镜像。
误解八:trampoline 是普通函数。
不是。
它是特殊映射的汇编桥接代码。
24. 小实验:观察进程页表
实验目标:
观察不同进程有不同页表。
可以在 fork() 或 userinit() 中打印:
pid
pagetable address
sz
trapframe address示意输出:
userinit: pid=1 pagetable=0x... sz=...
fork: parent=1 pgtbl=0x...
fork: child=2 pgtbl=0x...观察问题:
- 父子进程页表地址是否不同?
- 虚拟地址是否可以相同?
- trapframe 地址是否不同?
sz代表什么?
不要只看数值。
要把输出放回对象图。
parent proc -> parent pagetable
child proc -> child pagetable25. 小实验:触发非法地址访问
实验目标:
观察用户程序访问非法地址如何进入 trap。
可以写一个用户程序,故意访问明显非法地址。
预期:
user invalid access
|
v
page fault trap
|
v
kernel detects error
|
v
process killed观察点:
scause。stval。- 当前进程 pid。
这个实验连接:
user address
|
v
page table permission
|
v
hardware trap
|
v
kernel handling它能帮助你理解页表不只是“地址转换表”。
它也是保护机制。
26. 本篇总结
xv6 虚拟内存的核心可以压缩成一张图:
physical memory pages
|
v
kalloc / kfree
|
v
page tables
|
+-- kernel page table
|
+-- per-process user page table
|
+-- text/data/heap/stack
+-- trapframe
+-- trampoline读内存代码时,要持续问:
- 这是虚拟地址还是物理地址?
- 使用哪张页表?
- 这个页从哪里分配?
- 这个映射权限是什么?
- 用户态能不能访问?
- 什么时候释放?
下一篇进入:
07-trap-interrupt-and-system-call.md页表是 trap 的前置。
因为用户态和内核态切换时,寄存器、页表、trampoline、trapframe 都会一起出现。