前言:从“线性堆”到“虚拟地址空间”
在前面的章节中,我们的进程内存模型极其原始:代码段、数据段固定加载,堆通过brk/sbrk线性增长,栈从顶部向下延伸。这种模型能运行简单程序,但面对真实软件生态时立刻崩塌:动态链接器无法将.so映射到指定地址;malloc的大块分配被迫复制而非直接映射物理页;共享内存IPC需要内核拷贝;JIT编译器无法生成可执行代码;甚至vim打开大文件都要先read()到堆里再处理。
mmap的本质是“将虚拟地址区间与后端存储解耦”。它不再假设内存必须是“分配的”,而是声明“这段虚拟地址由某个对象(文件/匿名页/设备)按需填充”。这一抽象转变,使内存从“被动容器”变为“主动计算资源”。本章我们将构建完整的VMA(Virtual Memory Area)管理系统,让你的OS拥有现代虚拟内存的核心能力。
本章里程碑:
- ✅ VMA数据结构:rb_tree按地址排序,支持快速查找/插入/合并
- ✅
sys_mmap完整实现:MAP_ANONYMOUS / MAP_FILE / MAP_SHARED / MAP_PRIVATE - ✅
sys_munmap:区间拆分、合并、页表项清除与TLB刷新 - ✅
sys_mprotect:运行时修改VMA权限,触发页表更新 - ✅ 缺页异常处理:按需调页、写时复制(COW)、文件回填
- ✅ 验证:动态库加载、共享内存通信、mprotect JIT代码生成
核心概念:VMA不是“内存列表”,而是“地址空间的语义地图”
VMA的三重身份:描述符、策略、缓存键
许多初学者把VMA当作“已分配区间的记录表”。实际上,每个VMA同时承担三种角色:
| 角色 | 作用 | 缺失后果 |
|---|---|---|
| 描述符 | 记录[start, end, prot, flags, offset, file] | munmap/mprotect无法定位目标区间 |
| 策略 | 决定缺页时如何填充页面(匿名零页/文件读取/COW拷贝) | 缺页异常无法正确响应 |
| 缓存键 | 相同(file, offset, prot)的VMA可共享物理页 | 共享内存退化为每进程独立副本 |
⚠️ 关键洞察:VMA是惰性求值的承诺,而非即时分配的实体。
mmap(1GB, MAP_ANONYMOUS)不分配任何物理页,只在VMA树中插入一个节点。只有当进程访问该地址触发缺页异常时,内核才根据VMA的策略分配物理页并建立映射。这种“声明式”设计使得mmap可以表示远超物理内存的地址空间,也使得fork()只需复制VMA树而非整个页表。如果你的实现在mmap时就分配物理页,就彻底丧失了虚拟内存的核心优势。
MAP_PRIVATE vs MAP_SHARED:COW的语义分叉
两者在只读时行为相同,但在写入时分道扬镳:
- MAP_SHARED:写入直接修改底层对象(文件或共享内存),所有映射者立即可见。缺页时分配物理页并标记为脏,回写时同步到文件。
- MAP_PRIVATE:写入触发写时复制(Copy-on-Write)。首次写缺页时,内核分配新物理页、拷贝原内容、将当前进程的页表项指向新页并设为可写。原页保持不变,其他映射者不受影响。
COW不是优化技巧,而是MAP_PRIVATE的语义定义。没有COW,fork()+exec()会强制复制父进程全部内存;mmap(MAP_PRIVATE)的文件修改会污染原始文件。实现COW需要在页表项中标记“可读但不可写且为COW页”,缺页异常中区分“真缺页”和“COW缺页”。
VMA合并:性能与正确性的永恒权衡
相邻同属性VMA应合并以减少树节点数和查找开销。但合并条件极其严格:地址连续、prot相同、flags相同、file相同、offset连续、都不是特殊映射(如VM_PFNMAP)。漏掉任一条件都会导致灾难:合并了不同文件的VMA会导致mmap返回错误偏移的数据;合并了SHARED和PRIVATE会导致COW语义被破坏;合并了不同prot的VMA会使mprotect失效。教学实现中建议先禁用合并保证正确性,再用单元测试验证合并逻辑后启用。
实战代码
VMA数据结构与管理
// kernel/mm/vma.h
typedef struct vm_area_struct {
uint32_t vm_start; // 起始地址(包含)
uint32_t vm_end; // 结束地址(不包含)
uint32_t vm_prot; // PROT_READ/WRITE/EXEC
uint32_t vm_flags; // MAP_SHARED/PRIVATE/ANONYMOUS等
uint32_t vm_pgoff; // 文件内页偏移(仅文件映射)
struct file *vm_file; // NULL=匿名映射
struct rb_node vm_rb; // 红黑树节点
struct mm_struct *vm_mm; // 所属地址空间
} vma_t;
// kernel/mm/vma.c
#include "vma.h"
#include "rbtree.h"
// ★ 查找包含addr的VMA
vma_t *find_vma(struct mm_struct *mm, uint32_t addr) {
struct rb_node *node = mm->vma_tree.rb_node;
vma_t *result = NULL;
while (node) {
vma_t *vma = rb_entry(node, vma_t, vm_rb);
if (addr < vma->vm_start) {
node = node->rb_left;
} else if (addr >= vma->vm_end) {
node = node->rb_right;
} else {
return vma; // addr ∈ [start, end)
}
}
return NULL;
}
// ★ 插入VMA(简化版,不含合并)
int insert_vma(struct mm_struct *mm, vma_t *new_vma) {
struct rb_node **link = &mm->vma_tree.rb_node;
struct rb_node *parent = NULL;
while (*link) {
parent = *link;
vma_t *vma = rb_entry(parent, vma_t, vm_rb);
// ★ 检查重叠
if (new_vma->vm_end <= vma->vm_start)
link = &parent->rb_left;
else if (new_vma->vm_start >= vma->vm_end)
link = &parent->rb_right;
else
return -EEXIST; // 重叠!
}
rb_link_node(&new_vma->vm_rb, parent, link);
rb_insert_color(&new_vma->vm_rb, &mm->vma_tree);
return 0;
}
sys_mmap实现
// kernel/mm/mmap.c
#define PROT_READ 0x1
#define PROT_WRITE 0x2
#define PROT_EXEC 0x4
#define MAP_SHARED 0x01
#define MAP_PRIVATE 0x02
#define MAP_ANONYMOUS 0x20
#define MAP_FIXED 0x10
uint32_t sys_mmap(uint32_t addr, size_t len, int prot,
int flags, int fd, off_t offset) {
// 基本校验
if (len == 0) return -EINVAL;
if (offset & (PAGE_SIZE - 1)) return -EINVAL; // offset必须页对齐
// ★ 确定映射地址
uint32_t map_addr;
if (flags & MAP_FIXED) {
map_addr = addr;
// FIXED模式下,若已有VMA重叠则unmap旧映射
unmap_overlap(current_process->mm, map_addr, len);
} else {
map_addr = find_free_region(current_process->mm, len);
if (map_addr == 0) return -ENOMEM;
}
// ★ 创建VMA
vma_t *vma = kmalloc(sizeof(vma_t));
vma->vm_start = map_addr;
vma->vm_end = PAGE_ALIGN(map_addr + len);
vma->vm_prot = prot;
vma->vm_flags = flags;
vma->vm_pgoff = offset >> PAGE_SHIFT;
vma->vm_mm = current_process->mm;
if (flags & MAP_ANONYMOUS) {
vma->vm_file = NULL;
} else {
vma->vm_file = get_file_by_fd(fd);
if (!vma->vm_file) { kfree(vma); return -EBADF; }
}
// ★ 插入VMA树(此时不分配物理页!)
int ret = insert_vma(current_process->mm, vma);
if (ret < 0) {
if (vma->vm_file) put_file(vma->vm_file);
kfree(vma);
return ret;
}
return map_addr;
}
缺页异常处理核心
// kernel/mm/fault.c
void do_page_fault(uint32_t addr, uint32_t error_code) {
struct mm_struct *mm = current_process->mm;
vma_t *vma = find_vma(mm, addr);
if (!vma) {
send_signal(current_process, SIGSEGV);
return;
}
// ★ 权限检查
int write = (error_code & FAULT_WRITE);
if (write && !(vma->vm_prot & PROT_WRITE)) {
send_signal(current_process, SIGSEGV);
return;
}
uint32_t page_addr = addr & PAGE_MASK;
pte_t *pte = get_pte(mm->pgd, page_addr);
// ★ COW检测:页存在但不可写,且VMA为PRIVATE
if (pte_present(*pte) && write && !pte_write(*pte) &&
(vma->vm_flags & MAP_PRIVATE)) {
handle_cow_fault(page_addr, pte);
return;
}
// ★ 真缺页:分配物理页并按VMA策略填充
uint32_t phys = alloc_page();
if (!phys) { oom_kill(); return; }
if (vma->vm_flags & MAP_ANONYMOUS) {
memset((void *)phys_to_virt(phys), 0, PAGE_SIZE);
} else {
// 文件映射:从文件读取对应页
off_t file_off = (vma->vm_pgoff << PAGE_SHIFT) +
(page_addr - vma->vm_start);
size_t to_read = min(PAGE_SIZE,
vma->vm_file->inode->size - file_off);
vfs_read(vma->vm_file, (char *)phys_to_virt(phys),
to_read, file_off);
// 不足一页的部分清零
if (to_read < PAGE_SIZE)
memset((void *)(phys_to_virt(phys) + to_read),
0, PAGE_SIZE - to_read);
}
// ★ 建立页表映射
uint32_t pte_flags = PTE_PRESENT | PTE_USER;
if (vma->vm_prot & PROT_WRITE) pte_flags |= PTE_WRITE;
if (vma->vm_prot & PROT_EXEC) pte_flags |= PTE_EXEC;
// COW页初始设为只读
if ((vma->vm_flags & MAP_PRIVATE) && (vma->vm_prot & PROT_WRITE))
pte_flags &= ~PTE_WRITE; // 标记为COW
set_pte(pte, phys | pte_flags);
flush_tlb_single(page_addr);
}
// ★ COW缺页处理
void handle_cow_fault(uint32_t page_addr, pte_t *pte) {
uint32_t old_phys = pte_phys(*pte);
// 引用计数>1才真正复制,否则直接提权
if (page_refcount(old_phys) == 1) {
*pte |= PTE_WRITE;
flush_tlb_single(page_addr);
return;
}
// 分配新页并拷贝
uint32_t new_phys = alloc_page();
memcpy((void *)phys_to_virt(new_phys),
(void *)phys_to_virt(old_phys), PAGE_SIZE);
// 替换页表项
*pte = new_phys | PTE_PRESENT | PTE_WRITE | PTE_USER;
flush_tlb_single(page_addr);
// 释放旧页引用
put_page(old_phys);
}
sys_munmap与sys_mprotect
// kernel/mm/mmap.c (续)
int sys_munmap(uint32_t addr, size_t len) {
if (addr & (PAGE_SIZE - 1)) return -EINVAL;
uint32_t end = PAGE_ALIGN(addr + len);
struct mm_struct *mm = current_process->mm;
vma_t *vma, *next;
// ★ 遍历并删除/拆分覆盖[addr, end)的VMA
for (vma = find_vma(mm, addr); vma && vma->vm_start < end; ) {
next = vma_next(mm, vma);
if (vma->vm_start >= addr && vma->vm_end <= end) {
// 完全覆盖:删除整个VMA
unmap_vma_pages(mm, vma);
rb_erase(&vma->vm_rb, &mm->vma_tree);
if (vma->vm_file) put_file(vma->vm_file);
kfree(vma);
} else if (vma->vm_start < addr && vma->vm_end > end) {
// 中间挖洞:拆分为两个VMA
split_vma(mm, vma, addr, end);
} else if (vma->vm_start < addr) {
// 截断右端
unmap_vma_pages_range(mm, vma, addr, vma->vm_end);
vma->vm_end = addr;
} else { // vma->vm_end > end
// 截断左端
unmap_vma_pages_range(mm, vma, vma->vm_start, end);
vma->vm_start = end;
}
vma = next;
}
flush_tlb_all(); // 保守刷新
return 0;
}
int sys_mprotect(uint32_t addr, size_t len, int prot) {
// ★ 类似munmap的区间遍历,但修改prot而非删除
// 对每个受影响的VMA:
// 1. 若prot变化需拆分VMA边界
// 2. 更新vma->vm_prot
// 3. 遍历对应页表项,更新权限位
// 4. flush_tlb
// 注意:PROT_WRITE+MAP_PRIVATE需将现有可写页降级为COW只读
// ... 实现略,结构与munmap对称 ...
return 0;
}
关键细节解析
1. 为什么mmap不立即建立页表映射?
因为虚拟地址空间的稀缺性远低于物理内存。一个进程可能mmap了数GB的地址范围,但实际访问的只有几MB。如果mmap时预建页表,不仅浪费页表内存(每4MB虚拟空间需1个PT页),还会使fork()变慢(需复制大量无用页表项)。更重要的是,延迟映射允许内核在缺页时做智能决策:匿名页可以延迟到首次访问时才分配;文件页可以根据系统负载决定是否预读;COW页可以在写入时才复制。这种“按需物化”是虚拟内存区别于物理内存的根本特征。
2. 为什么COW页要初始设为只读而非用特殊标志位?
因为x86页表硬件没有“COW”标志位。利用现有的Writable位作为COW指示器,使得正常读操作无需任何额外检查——页表项Present+Readable即可通过MMU。只有写入时触发#PF,内核才在异常处理中判断是否为COW。如果用软件标志位,每次内存访问都需要查VMA树确认是否COW,性能不可接受。COW的精妙之处在于它将语义编码进了硬件已有的权限机制中,实现了零开销的读路径。
3. 为什么munmap需要拆分VMA而非简单标记无效?
因为VMA是后续操作的查询依据。如果munmap(0x2000, 0x1000)作用于[0x1000, 0x4000)的VMA,不拆分的话,后续mprotect(0x1000, 0x1000, PROT_NONE)会错误地影响已被unmap的区域。VMA树必须始终精确反映当前有效的映射状态。拆分虽然增加了节点数,但保证了语义正确性。生产级内核还会在munmap后尝试合并相邻的同属性VMA,以抵消拆分带来的膨胀。
调试Checklist:VMA与mmap排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| mmap返回-EEXIST | find_free_region未跳过已有VMA/MAP_FIXED未unmap重叠区 | dump VMA树确认空闲区间计算;验证MAP_FIXED路径调用unmap_overlap;测试连续mmap小区域 |
| 访问mmap地址SIGSEGV | VMA未插入/find_vma查找逻辑错/页表未建立 | dump VMA树确认节点存在;kprintf do_page_fault中find_vma返回值;验证set_pte后flush_tlb |
| COW写入后数据错乱 | handle_cow_fault未拷贝完整页/引用计数错误/旧页未put | hexdump新旧物理页内容对比;dump page_refcount变化;验证memcpy长度为PAGE_SIZE |
| munmap后仍可访问 | 页表项未清除/VMA未删除/TLB未刷新 | dump页表确认PTE cleared;dump VMA树确认节点移除;验证flush_tlb调用时机 |
| mprotect无效 | 页表权限位未更新/VMA拆分边界错/TLB未刷新 | dump VMA prot字段和对应PTE flags;验证split_vma边界对齐;测试mprotect后立即写入验证权限 |
| 文件映射内容错误 | vm_pgoff计算错/vfs_read偏移量错/不足一页未清零 | hexdump映射内容与文件原始内容对比;kprintf file_off和to_read值;验证memset清零范围 |
🔧 黄金法则:VMA调试的终极武器是地址空间可视化函数。实现
debug_dump_mm(mm),以ASCII艺术形式打印整个VMA树:每行显示[start-end) prot flags file:offset,用缩进表示红黑树结构,用颜色标注匿名/文件/COW页。VMA bug几乎总是“区间边界不一致”:end未对齐导致find_vma漏匹配;split后左右VMA的vm_pgoff未重新计算;merge条件遗漏导致非法合并。只有可视化的全局视图才能发现这些结构性错误。不要只看单个mmap/munmap的返回值——那是局部操作的结果,地址空间的全局拓扑才是真相。
本章小结与下一步
今天我们让内存管理从“线性堆”进化为“虚拟地址空间”:
- ✅ 构建了基于红黑树的VMA管理系统
- ✅ 实现了完整的mmap/munmap/mprotect系统调用
- ✅ 缺页异常支持匿名页、文件页、COW三种策略
- ✅ 验证了惰性映射、写时复制、权限动态修改的正确性
- ✅ 为动态链接、共享内存、JIT编译奠定了基础设施
从此,你的操作系统拥有了现代虚拟内存的核心抽象。当你第一次看到动态库被mmap到正确地址、父子进程通过共享内存零拷贝通信、JIT生成的代码通过mprotect变为可执行时,你见证的是OS从“能管理内存”到“能支撑真实软件栈”的决定性跨越。
下一章预告:《动态链接基础:ELF解析、符号解析与重定位》
当前的execve只能加载静态链接的ELF。下一章将实现动态链接器的核心功能:解析DT_NEEDED、构建符号表、执行PLT/GOT重定位,让你的OS能够运行动态链接的真实世界程序。
参考资料
- Linux Kernel:
mm/mmap.c,mm/memory.c,include/linux/mm_types.h - Robert Love, "Linux Kernel Development" Ch.15 The Virtual Memory Subsystem
- Intel SDM Vol.3 Ch.4 Paging (尤其§4.4 Protection Keys and COW)
- POSIX.1-2017: mmap(), munmap(), mprotect()
- 本系列完整代码:[你的GitHub仓库链接](Commit:
m1m2a3p)
📝 作者注:这是《从零手写操作系统》系列的第32篇。VMA管理是整个教程中数据结构最复杂、边界条件最多的章节。红黑树的旋转、区间的拆分合并、页表与VMA的一致性维护,每一处都是bug温床。强烈建议先用最简单的匿名MAP_PRIVATE+mprotect验证VMA插入→缺页→页表建立完整通路,确认基础框架稳固后再添加文件映射、COW、合并等特性。把“VMA CRUD”、“缺页处理”、“页表同步”分成三个独立里程碑,并用地址空间dump反复验证每一步的拓扑一致性,是避免在区间算术迷宫中崩溃的关键纪律。 下一章,我们让程序加载从“静态镜像”走向“动态组装”!



438

被折叠的 条评论
为什么被折叠?



