第五章:包租公 - 内存管理
从前面的章节中,我们知道进程需要独立的内存空间来运行,IPC 需要在进程间传递数据。但这引出了一个核心问题:计算机的物理内存是有限的,如何让每个进程都觉得自己拥有无限的内存?如何防止进程之间互相干扰?
答案就是虚拟内存——操作系统历史上最伟大的发明之一。
5.1 虚拟内存:黑客帝国的矩阵
你以为你拥有整个世界,其实你只是生活在一个虚拟的矩阵里。 对于进程来说,也是如此。
每个进程都觉得自己拥有完整的 4GB 内存(在 32 位系统上)。它想读哪里就读哪里,想写哪里就写哪里。 但实际上,这只是操作系统给它编织的虚拟内存 (Virtual Memory) 幻象。
真正的物理内存(RAM)可能只有 1GB,而且被几十个进程瓜分得七零八落。
5.2 分页:把世界切成小块
操作系统是怎么做到这一点的呢?答案是分页 (Paging)。
它把内存切成一块块大小相等的小方块,通常是 4KB,叫做页 (Page)。
- 虚拟页 (Virtual Page):进程看到的页。
- 物理页 (Physical Page):内存条上真正的页。
操作系统维护着一本页表 (Page Table),就像一个巨大的映射字典:
- 进程 A 的第 1 页 -> 物理内存的第 100 页
- 进程 A 的第 2 页 -> 物理内存的第 5 页
- 进程 B 的第 1 页 -> 物理内存的第 200 页
虚拟内存的诞生:一个天才的灵光一闪
虚拟内存的概念在 1950 年代逐渐成形,真正将其付诸实践的是 Manchester 大学的 Atlas 计算机(1962 年),由 Tom Kilburn 领导的团队开发。
当时,计算机的内存非常昂贵。一台 Atlas 只有 16KB 的核心内存(相当于现在一张小图片的大小!)。程序员必须精打细算,把程序拆成小块,手动加载到内存中。这种"覆盖技术 (Overlay)"繁琐且容易出错。
Atlas 的设计师们想出了一个革命性的主意:让硬件自动完成这个工作。他们设计了一个分页系统,当程序访问不在内存中的数据时,硬件会自动从磁盘加载。程序员完全不需要知道这个过程——他们只需要假设有"无限的内存"。
这个设计如此超前,以至于 50 年后,几乎所有现代操作系统(Windows、Linux、macOS)都沿用了这个架构。虚拟内存让程序员从内存管理的地狱中解放出来,专注于算法和逻辑。
5.3 MMU:幕后的魔术师
谁来负责查字典呢?如果每次读写内存都要软件去查字典,那太慢了。 CPU 里有一个专门的硬件组件,叫做 MMU (Memory Management Unit,内存管理单元)。
- 进程发出指令:读取虚拟地址
0x1000。 - MMU 拦截指令,查看当前的页表。
- MMU 发现
0x1000对应物理地址0x5000。 - MMU 偷偷把地址替换成
0x5000,去访问物理内存。 - 进程完全不知道发生了什么,它以为自己真的读了
0x1000。
5.4 缺页异常:空头支票
有时候,进程想访问一个页面,但 MMU 发现页表里没有记录(或者标记为“不在内存中”)。 这时,MMU 会大喊一声:“停!出错了!” 这就是 缺页异常 (Page Fault)。
操作系统听到喊声,赶紧跑过来处理:
- 懒加载 (Lazy Allocation):进程申请了内存,但操作系统为了省事,先开个空头支票(只分配虚拟地址,不给物理页)。等进程真的要用了,触发缺页异常,操作系统才赶紧找一块物理页补上。
- 交换 (Swapping):内存不够了,操作系统把很久不用的页写到硬盘上(Swap 分区),腾出空间。等进程又要用时,再从硬盘读回来。
5.5 EwokOS 的内存管理
EwokOS 的内存管理代码主要在 kernel/kernel/src/mm 目录下。
它实现了基本的页表映射和内存分配 (kmalloc/kfree)。
每个进程都有自己的页目录 (proc_t->space->vm)。当切换进程时,内核会告诉 MMU:“换字典了!现在用进程 B 的页表。”
这样,进程 A 就算把自己的虚拟内存写烂了,也绝对影响不到进程 B 的物理内存。这就是内存保护。
下一章,我们将看看那些在用户空间辛勤工作的“打工仔”——驱动程序。
核心内存管理模块:
kernel/kernel/src/mm/
├── mmu.c # MMU 配置和页表操作
├── kmalloc.c # 内核内存分配器
├── kalloc.c # 物理页帧分配器
├── shm.c # 共享内存实现
├── trunkmem.c # 大块内存管理
└── vm.c # 虚拟内存管理
5.6 写时复制 (COW) 实现细节
写时复制是 EwokOS 的一个重要优化。当进程 fork() 时:
- 创建子进程:复制
proc_t结构,但不复制物理页。 - 标记只读:将父子进程的所有可写页面都标记为只读。
- 共享页表:父子进程的页表都指向同一块物理内存。
- 触发缺页:当其中一个进程试图写入时,MMU 发现页面是只读的,触发缺页异常。
- 复制页面:内核的缺页处理程序分配一个新的物理页,复制内容,更新页表。
- 恢复写权限:将新页面标记为可读写,继续执行。
关键代码 (kernel/kernel/src/mm/vm.c):
// fork 时设置 COW
void vm_set_cow(proc_t* proc) {
for (每个可写页面) {
标记为只读;
设置 COW 标志位;
}
}
// 缺页处理
void handle_page_fault(uint32_t addr) {
if (页面标记为 COW) {
分配新物理页;
复制内容;
更新页表,标记为可写;
清除 COW 标志;
}
}
优势:
- 大部分情况下,子进程只读取父进程的数据,不需要真正复制。
fork()后立即exec()的场景(如 Shell 启动程序),根本不需要复制内存。- 节省内存,加快进程创建速度。
5.7 内存布局
EwokOS 的进程虚拟内存布局(32 位为例):
0xFFFFFFFF +------------------+
| 内核空间 | (1GB)
| (所有进程共享) |
0xC0000000 +------------------+
| 用户栈 | (向下增长)
| ↓ |
~ - - - - - - - ~
| ↑ |
| 用户堆 | (向上增长)
+------------------+
| .bss (未初始化) |
+------------------+
| .data (已初始化) |
+------------------+
| .text (代码) |
0x00010000 +------------------+
| 保留区域 |
0x00000000 +------------------+
5.8 本章小结与展望
我们刚刚探索了操作系统的"土地规划局"——内存管理。让我们回顾关键概念:
- 虚拟内存的魔法:通过页表和 MMU,给每个进程创造"独占 4GB 内存"的幻觉
- 分页的智慧:把内存切成 4KB 的小块,灵活分配,避免碎片
- 历史的启示:从 1962 年的 Atlas 计算机到今天,虚拟内存解放了程序员
- COW 的优雅:写时复制让
fork()变得飞快,体现了"懒惰是美德"的哲学
内存管理确保了进程的隔离和安全,但还有一个重要的问题:操作系统如何与硬件打交道?键盘、鼠标、硬盘、网卡,这些千差万别的设备如何被统一管理?
在宏内核中,驱动直接运行在内核态,拥有最高权限。但在微内核中,驱动只是普通的用户进程——它们如何安全地访问硬件?它们如何向应用程序提供服务?
这就是下一章的主题——驱动与 VFS。我们将看到:
- 驱动如何从"御前侍卫"变成"打工仔",但依然高效工作
- VFS(虚拟文件系统)如何实现"一切皆文件"的 Unix 哲学
- 一个真实的驱动(如时钟驱动
timerd)的完整生命周期 - 微内核架构如何通过"特权委派"让普通进程安全地访问硬件
内存管理是系统的"土地规划局",而驱动就是"服务窗口"——连接软件世界和硬件世界的桥梁。让我们继续探索。