内存管理的目标 #
- 抽象:给每个进程独立、连续的虚拟地址空间(“整块内存归我一个人用"的幻觉)
- 保护:进程 A 踩不到进程 B,用户态碰不到内核的关键数据
- 共享:需要共享的页(如共享库、mmap 文件)能被多个进程同时映射
- 透明:缺页换入换出全部由 OS 自动完成,进程无感知
- 效率:地址翻译要快(TLB)、缺页次数要少(合理的置换算法)、内存利用率要高(稀疏分配)
程序何时进入内存 #
- 编译时:编译器生成可执行文件,逻辑地址(虚拟地址)已固定,代码和数据在磁盘上
- 载入时(load time):加载器从磁盘读入可执行文件,建立虚拟地址空间(VMA),页表结构建立但物理页框尚未分配
- 运行时(run time):CPU 首次访问某页 → 页表项为空 → 缺页异常 → OS 分配物理页框、从磁盘读数据、建立 PTE
这叫 惰性加载(demand paging)——进程不是一次性全部进入内存,而是用到的页面才调入。
实际 Linux 中 execve 时建立 VMA、映射代码段和数据段,物理内存直到进程被调度运行、触发缺页时才分配。
进程空间与物理内存的对应(重定位) #
动态重定位(运行时):每个进程有基址寄存器(或页表),CPU 发出的每个 VA 自动加基址 → PA。VA: 0x1000 → [基址: 0x50000] → PA: 0x51000
页式重定位(现代系统):VA 拆为 VPN + 页内偏移 → MMU 查页表 → 得 PFN → 拼接 PA。同一 VA 0x1000,进程 A 的页表指向物理页 5,进程 B 指向物理页 12,互不干扰——这就是"地址空间是对内存的抽象"的硬件实现。
x86-64 Linux 的虚拟地址空间 #
48 位(256 TB)。64 位 CPU 只用了低 48 位:
- 用户空间:
0x0000000000000000 ~ 0x00007FFFFFFFFFFF(128 TB) - 内核空间:
0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF(128 TB) - 中间空洞:未使用
高 16 位必须与第 47 位一致,否则硬件报错,这叫规范地址。
虚拟内存涉及的数据结构 #
进程级:
mm_struct:进程地址空间总入口,含 VMA 链表、页目录指针(pgd)vm_area_struct:每个 VMA 记录起止地址、权限、映射类型pgd_t / pmd_t / pud_t / pte_t:四级页表项,pgd即 CR3 指向的页全局目录
全局级:
- 页表:VPN → PFN 的映射(多级树或反转表)
- 页框数据库(PFN Database):追踪每个物理页框状态(Active/Standby/Modified 等)
- 交换区映射(swap cache):记录被换出页面在磁盘的位置
- TLB:VPN → PFN 硬件高速缓存,OS 在上下文切换时管理刷新
地址转换的作用 #
VA → PA 的翻译。没有它,地址空间隔离、按需调页、写时复制全部无法实现——每个进程都能直接看到物理地址,互相踩踏不可避免。地址转换是实现内存抽象和保护的基础硬件机制。
存储体系与虚拟内存空间的关系 #
虚拟内存空间横跨整个存储体系:
寄存器 → Cache → DRAM(物理页框) → 磁盘(交换区/文件)
- DRAM 是虚拟内存的"高速层”——热数据驻留
- 磁盘是虚拟内存的"容量层"——冷数据暂存
- 进程看到 256TB 地址空间,实际物理 DRAM 只有几 GB,其余靠磁盘的页换入换出来支撑
- 虚拟内存是连接硬件加速和软件管理的纽带
PTE 中引发 Page Fault 的控制位 #
- P = 0:页面不在内存 → 缺页异常
- R/W 违例:写只读页(COW 场景)→ PF
- U/S 违例:用户态访问内核页 → PF
- XD/NX:执行不可执行页 → PF
由 MMU 硬件检测,是硬件发出的,CPU 和 MMU 协作完成检测和处理:MMU 在地址翻译过程中发现 PTE 异常 → 硬件产生中断 0xe → CPU 陷入内核 → OS 缺页处理程序接管。
COW 为什么必须结合缺页异常 #
fork 后父子共享物理页,全部标记只读。进程写时 MMU 报 PF,OS 才分配新页、拷贝、更新 PTE。没有缺页异常,fork 只能把全部物理页立即复制,失去快速返回的能力。COW 借助 PF 把拷贝推迟到真正需要时(惰性),且 exec 直接覆盖后连拷贝都省了。
fork() 流程 #
- 分配新 PID +
task_struct - 复制
mm_struct(VMA 新建副本,不复制物理页) - 两进程所有用户页标记只读,每个 VMA 标记私有 COW
- 创建子进程页表,PTE 指向与父进程相同的物理页
- 继承父进程文件描述符表、工作目录、信号处理等
- 子进程状态设为就绪,插入调度队列
- 父进程返回子进程 PID,子进程返回 0
- 后续任何一方的写入通过 COW 缺页触发真正的页复制
mmap 与 COW 的关系 #
MAP_PRIVATE 基于 COW 实现:映射后页面只读共享 → 进程写入时 MMU 报 PF → 内核分配新页、拷贝、更新 PTE。其他进程不受影响。MAP_SHARED 不触发 COW,写了直接改共享页,所有映射者可见。fork 的 COW 和 mmap 的 COW 底层走同一个 do_wp_page()。
放置、置换、清除策略 #
放置策略:页放哪。分页系统任何空闲页框等效,段式需为变长段找合适位置。
置换策略:页框用完时赶谁。局部(只在缺页进程驻留集选) vs 全局(所有未锁定页框)。算法:OPT → FIFO → Second Chance → Clock → NRU → LRU → NFU/Aging → 工作集。
清除策略:脏页何时写回。分页守护进程批量写回脏页保证有大量干净空闲页框。双指针时钟:前指针写回脏页,后指针用于置换。
文件系统是对磁盘的抽象 #
磁盘只是一堆线性 LBA。文件系统在其上建了三层抽象:
- 按名存取:给 LBA 起名字,查目录 → inode → 数据块 LBA
- 结构化:目录树分门别类
- 隐藏细节:碎片分配、空闲管理、读写调度由 FS 内部处理,用户只看到整齐的文件
类比:虚拟地址空间 = 对物理内存的抽象;文件系统 = 对磁盘地址的抽象。
万物皆是文件 #
不是所有东西都是文件,而是 OS 把所有东西当成文件操作。键盘、磁盘、socket、管道、/proc——不管底层是什么,都用 open/read/write/close 同一套接口。
OS 给每种资源分配 inode + VFS 接口 + file_operations。同一个 read 根据 inode 类型自动路由:普通文件 → ext4 驱动,socket → TCP/IP 协议栈,管道 → pipe 驱动。用户态完全不需要知道底层差异。
目录管理 #
目的:按名存取——文件名 → FCB/inode 的映射。
| 结构 | 特点 | 局限 |
|---|---|---|
| 单级 | 所有文件一个目录,简单 | 重名冲突,查找遍历全部 |
| 两级 | 每个用户一个目录 | 不能共享 |
| 树形 | 多级嵌套,绝对/相对路径 | 需处理路径解析 |
| 无环图 | 树 + 硬链接/软链接 | 删除需引用计数防悬空指针 |
文件打开流程 open() #
- 路径解析:逐级查目录
/→usr→ast→mbox,拿到 inode 号 - 查系统打开文件表:inode 已打开 → 引用计数+1;否则填入空表项,计数=1
- 权限检查:对比打开方式与用户权限
- 填用户打开文件表:进程 PCB 中取空表项,填打开方式、读写指针(初值0),指向系统打开文件表
- 返回 fd:非负整数,后续 read/write 靠它索引
同步问题解法 #
软件解法:
| 方案 | 思路 | 问题 |
|---|---|---|
| 方案1 | while(free); free=true; — free=false | lock() 不原子 |
| 方案2 | while(not turn); — turn=other | 强制轮流,无空不让进 |
| 方案3 | pturn=true; while(qturn); — pturn=false | After you 死锁 |
| Dekker(1965) | pturn/qturn + turn 裁决 | 首个正确解决 |
| Peterson(1981) | interested[2] + turn | 更简洁,替代 Dekker |
硬件解法——关中断:关中断 → 临界区 → 开中断。符合"有空让进"和"无空等待",但不符合"有限等待"和"让权等待":中断关闭期间调度器无法抢占,其他进程连 CPU 都得不到。仅限内核使用(保证临界区极短),不能暴露给用户进程。
硬件解法——TSL 指令:原子"读-改-写"。对多处理器有效——TSL 靠总线锁保证原子性,执行时锁住内存总线,所有 CPU 都无法同时访问同一地址。关中断只锁本 CPU 所以多核失效,TSL 用总线锁替代,对所有 CPU 一视同仁。但忙等待形成自旋锁,且存在优先级反转问题。
如何感知设备状态并管理设备 #
1. 感知设备状态 #
三种方式从主动到被动:
| 方式 | 原理 | CPU开销 | 适用 |
|---|---|---|---|
| 轮询 | CPU 循环读状态寄存器,检测 ready/busy 位 | 高(空转) | 简单低速设备 |
| 中断 | 设备完成操作后发中断信号,CPU 响应后读状态 | 低(只响应时参与) | 大多数设备 |
| DMA 完成中断 | DMA 控制器传输完一块数据后发中断 | 最低 | 磁盘、网卡 |
设备状态信息存储在设备控制器的寄存器中:控制寄存器(CPU 写命令)、状态寄存器(CPU 读 busy/ready/error)、数据寄存器(读写数据)。
2. 管理设备的数据结构 #
- 设备控制块(DCB / UCB):每设备一个,记录设备类型、标识符、状态(忙/闲)、等待队列指针
- 控制器控制块:记录控制器状态、连接设备
- 设备队列:将同类设备链成队列,方便分配时遍历
- I/O 请求包(IORB / IRP):每个 I/O 请求的动态结构,含操作类型、LBA、缓冲区地址、优先级等
3. 设备分配策略 #
| 策略 | 说明 | 适用设备 |
|---|---|---|
| 静态分配 | 进程创建时分配,结束时回收 | 独占设备 |
| 动态分配 | 请求时分配,用完立即回收 | 独占设备 |
| 共享/分时 | 请求排队,分时轮流服务 | 磁盘等共享设备 |
| 虚拟分配(SPOOLing) | 用磁盘模拟独占设备,请求排队 | 打印机 |
4. 设备驱动与中断处理协同 #
应用程序: read() → VFS → 文件系统 → 块层
↓
设备驱动程序: 填命令 → 写控制寄存器 → 启动 I/O → 阻塞自己
↓
设备控制器: 独立完成操作
↓
中断处理程序: 读状态寄存器 → 检查错误 → 唤醒驱动程序
↓
驱动程序: 从数据寄存器读结果 → 拷到用户缓冲区 → 返回5. 设备无关性 #
OS 通过分层屏蔽硬件差异——用户使用逻辑设备名,系统维护逻辑设备→物理设备的映射表。设备作为特殊文件统一用 open/read/write/ioctl 访问,不同设备对应不同 file_operations 实现。
如何解决设备差异性大的问题 #
核心思路:分层 + 抽象 + 多态 #
不同设备接口、命令集、速度、数据格式完全不同。OS 的对策是把差异封装在驱动层,向上提供统一接口。
1. 设备分类抽象 #
将设备归为三大类,每类一个统一接口:
| 类型 | 统一接口 | 底层差异在哪 |
|---|---|---|
| 字符设备 | read/write/ioctl(字节流) |
键盘/鼠标/串口各不同,驱动实现各自的 read |
| 块设备 | read/write(块为单位)+ 文件系统 |
HDD/SSD/NVMe 寻址方式不同,驱动翻译 LBA |
| 网络设备 | send/recv(报文) |
以太网/WiFi/蓝牙协议栈不同 |
2. 设备驱动的多态实现 #
Unix/Linux 使用 file_operations 函数指针表实现多态:
struct file_operations {
int (*open)(struct inode *, struct file *);
ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
int (*ioctl)(struct inode *, struct file *, unsigned int, unsigned long);
// ...
};用户调用 read(fd) → VFS 查 fd 对应的 file → file->f_op->read() → 路由到具体设备驱动。切换设备只需换函数指针,上层代码一行不改。
3. 分层递进 #
用户程序 "打开摄像头" / "播放音频" / "读文件"
↓ ↑ 统一接口,与设备无关
VFS / 设备无关层 设备命名、权限检查、缓冲区管理
↓ ↑ 统一块大小,缓冲策略
设备驱动程序 键盘驱动 / 磁盘驱动 / 网卡驱动 (设备特异部分)
↓ ↑ 读写寄存器、处理中断
设备控制器 硬件接口4. 设备独立性的关键机制 #
- 逻辑设备名 → 物理设备映射:用户用
/dev/tty,OS 查表转为具体设备号 - inode 中的设备号:
major号定驱动,minor号定具体设备实例 - I/O 重定向:标准输入可来自键盘、管道、文件,进程不用改代码
- 缓冲区统一管理:不同块设备共用同一缓冲池,屏蔽扇区大小差异
以打印机为例说明 I/O 软件的分层思想 #
场景:用户执行 lpr mydoc.txt
#
用户进程: lpr mydoc.txt
↓ 系统调用 write()
──────────────────────────────────────────────
用户级 I/O: 格式化输出 → 放入 spool 目录 "/var/spool/printer/mydoc"
↓ 发消息给打印 daemon
──────────────────────────────────────────────
设备无关层: 命名解析(/dev/lp0 → major,minor)
权限检查(用户有写权限吗?)
分配设备(打印机空闲吗? 否则排队)
SPOOLing: 用磁盘文件模拟独占
↓ I/O 请求包(写操作 + 数据缓冲区)
──────────────────────────────────────────────
设备驱动程序: 接收抽象请求
翻译: 通用格式 → 打印机控制命令(ESC/P)
写控制寄存器: 设置页长、份数
写数据寄存器: 逐个字节送打印缓冲区
启动 I/O → 阻塞等待完成
↓
──────────────────────────────────────────────
中断处理程序: 打印机缺纸/完成一页时产生中断
读状态寄存器 → 缺纸? 完成? 错误?
正常完成 → 唤醒驱动程序
缺纸 → 发信号通知用户
↓
──────────────────────────────────────────────
硬件: 打印机控制器接收命令 → 物理打印分层的价值 #
- 用户:只需要
lpr,不知道打印机型号 - 设备无关层:只管排队、权限、SPOOLing,不关心 ESC/P 还是 PostScript
- 驱动程序:只管翻译命令和操作寄存器,换打印机只换这一层
- 中断处理:只管状态响应,不关心业务逻辑
每层职责单一,上层不依赖下层细节,新设备只需写一个新的驱动程序即可接入整个 I/O 栈。
为什么引入缓冲技术 #
缓冲是操作系统中最早引入的技术之一。核心动机:
1. 解决 CPU 与 I/O 速度不匹配 #
CPU 处理数据是纳秒级,磁盘读写是毫秒级,差 6 个数量级。没有缓冲,每一次 read 都要等磁盘寻道+旋转,CPU 几乎全部时间在空转。
无缓冲: CPU → 等磁盘 → 拿到 1 字节 → CPU → 等磁盘 → 拿到 1 字节 → ...
有缓冲: CPU → 等磁盘 → 一次读 4KB 进缓冲区 → CPU 从缓冲区逐字节取 → 全部命中一次磁盘 I/O 搬一卡车(4KB),后续 CPU 从缓冲区零取——99% 的"读"直接走内存,不需要磁盘。
2. 减少中断次数 #
没有缓冲时每处理一个字符都要发一次中断(键盘每敲一个键就是一个中断)。有了缓冲,中断处理只把数据丢进缓冲区,等缓冲区积攒一批后再一次性通知上层处理——中断频率从"每字节一次"降到"每缓冲区一次"。
3. 提高 CPU 与设备的并行性 #
双缓冲的精髓在于流水线:
缓冲区 1 被 CPU 处理时 ← 同时 → 设备向缓冲区 2 填入新数据
缓冲区 2 被 CPU 处理时 ← 同时 → 设备向缓冲区 1 填入新数据CPU 和设备永不互相等待,吞吐量接近两者的最大速率。
4. 缓冲区类型对比 #
| 方案 | 原理 | 并行度 | 开销 |
|---|---|---|---|
| 单缓冲 | 1 个缓冲区,CPU 和设备串行 | 低 | 最小 |
| 双缓冲 | 2 个缓冲区交替使用 | 中 | 中 |
| 缓冲池 | N 个缓冲区,生产者-消费者模型 | 高 | 管理复杂 |
5. 实际案例:UNIX 缓冲池 #
约 200 个缓冲区(各 512/1024B),每个挂在 av 链(空闲)和 b 链(设备散列队列)上。读到缓冲后一直保留在 b 链中——同一盘块被再次访问时直接命中,零磁盘 I/O。这就是 page cache 的前身。
如何提升 CPU 与设备的访问性能 #
1. 减少 CPU 等待 I/O #
| 技术 | 原理 | 效果 |
|---|---|---|
| 中断驱动 I/O | 发起 I/O 后 CPU 切去干别的事,设备完成时中断通知 | CPU 无需轮询等待 |
| DMA | 设备控制器直接访问总线,数据在内存和外设间搬运,CPU 只参与传输开始和结束 | CPU 彻底摆脱数据搬运 |
| 异步 I/O | 发起 I/O 后立即返回,CPU 继续执行,I/O 完成后回调通知 | I/O 和计算完全重叠 |
| I/O 通道 | 专用的 I/O 协处理器,有自己的指令集和局部内存 | CPU 只需发高级指令 |
2. 减少 I/O 频率 #
| 技术 | 原理 | 例 |
|---|---|---|
| 缓冲/缓存 | 内存中保留热数据,读命中直接返回 | page cache、buffer pool |
| 预读 | 空间局部性——顺序读时提前把后续块调入 | 一次读 4KB → 预取后 64KB |
| 批处理 | 多个小 I/O 合并为一个大 I/O | elevator 算法合并相邻请求 |
| 延迟写 | 多次写合并到缓冲区,到期一次写回 | 写磁盘攒够一批再刷 |
3. 提高设备端效率 #
| 技术 | 原理 |
|---|---|
| 磁盘调度算法 | SSTF → SCAN → C-SCAN,减少寻道时间 |
| 电梯算法 | 磁头来回扫,沿途处理所有请求,减少平均寻道 |
| I/O 请求重排 | 块层将请求按 LBA 排序后下发,减少机械运动 |
| 多队列 | NVMe 多队列并行处理,每个 CPU 核一个提交队列 |
4. 演进路径 #
CPU 直接控制 → 控制器 + 可编程 I/O → 中断驱动 → DMA → I/O 处理器
每一步都在让 CPU 离设备更远、做更少的事。
Windows PFN 数据库的设计思想 #
核心思想:状态机统一管理所有物理页框 #
PFN 数据库以一维数组(下标 = 页框号)追踪系统中每一个物理页框的状态,每个页框在以下状态间流转:
Active → Standby/Modified → Free → Zeroed → Active
体现的设计思想 #
1. 缓存思维的延续——软缺页
进程访问了一个不在工作集中但仍在 Standby 链表中的页,不需要任何磁盘 I/O,直接取回即用。物理内存本身被当作磁盘的缓存——被移出的页面不立即丢弃,而是保留内容挂在 Standby 链表,作为"可能还会用的缓存"。这和 CPU 的 L1/L2/L3 缓存层级在本质上是一个思想:用空间换时间,用上层缓存避免下层慢速访问。
2. 生产者-消费者分工
| 系统线程 | 角色 | 负责的状态转换 |
|---|---|---|
| 工作集管理器 | 决策者 | Active → Standby/Modified(修剪) |
| Modified Page Writer | 写回者 | Modified → Standby(写回脏页) |
| 零页线程 | 准备者 | Free → Zeroed(清零备用) |
| 平衡集管理器 | 调节者 | 全局并发度控制 |
每个线程干一件专一的事,通过状态机的约束自然协同——不需要复杂的锁协议,各自改各自负责的链表。
3. 懒惰策略(Lazy)
- 脏页不立即写回,等攒一批由 Modified Page Writer 批量写(减少磁盘寻道)
- 空闲页不立即清零,由零页线程后台异步做(分配时直接从 Zeroed 取)
- 页面被移出工作集后不立即释放,先放 Standby 待复用(软缺页)
三个"不立即"反映同一个原则:推迟开销到真正需要的时候,用后台线程在 CPU 空闲时做脏活。
4. 物理内存的反向索引
传统页表是 VA → PA,PFN 数据库是 PA → 状态。它回答了"这个物理页框现在在干什么、被谁用着、能回收吗"——这是页面置换决策、工作集修剪、内存压力处理的基础。
5. 集中式管理 vs 分散式管理
Linux 用 buddy system + slab 管理物理内存,信息分散在各分配器数据结构中。Windows 选择集中式 PFN 数据库——查任何一个物理页框的状态只需一次数组索引 PFN[frame_num]。集中式的好处是 O(1) 查询,缺点是需要维护全局一致的状态转换规则。
I/O 管理的主要任务 #
- 设备分配与回收:响应进程请求,根据设备状态分配/回收独占或共享设备
- 提供统一接口:用户用逻辑设备名,系统完成逻辑→物理映射,屏蔽硬件差异
- 驱动 I/O 执行:翻译抽象请求为设备命令,写控制寄存器,启动 I/O 操作
- 中断处理:响应设备中断信号,读状态寄存器,唤醒阻塞进程
- 缓冲区管理:管理 I/O 缓冲池,减少 CPU 与设备速度差距
- 性能优化:利用中断、DMA、异步 I/O 提高 CPU 与设备的并行度
如何提升文件系统性能 #
1. 缓存——减少磁盘 I/O 的根本手段 #
| 缓存类型 | 缓存什么 | 命中效果 |
|---|---|---|
| page cache | 文件数据页 | 读写在内存完成,零磁盘 I/O |
| dentry cache | 目录项(文件名→inode) | 路径解析不走磁盘 |
| inode cache | 文件的 inode 信息 | 省去读 inode 区的磁盘访问 |
| buffer cache | 磁盘块原始数据 | 老 UNIX 的块级缓存 |
2. 预读与延迟写——减少 I/O 次数 #
| 技术 | 原理 | 效果 |
|---|---|---|
| 预读 | 顺序读时提前把后续块读入 page cache | 读文件时大部分命中缓存 |
| 延迟写 | 修改先写缓存,后台定期批量写回 | 多次写合并为一次磁盘写 |
| 预分配 | 创建文件时一次分配多个连续块 | 减少后续扩展时的分配开销 |
3. 磁盘布局优化 #
| 技术 | 原理 | 代表 |
|---|---|---|
| 块组 | 数据块和 inode 放在同一块组,减少寻道 | ext2/ext4 |
| extent(区段) | 用"起始块+连续长度"代替逐个块号,减少元数据 | ext4、NTFS |
| 延迟分配 | 写入缓存时不立即分配磁盘块,写回时一次分配连续块 | ext4、XFS |
| 碎片整理 | 后台重组文件数据为连续块 | Windows 碎片整理 |
4. 目录查找加速 #
| 技术 | 原理 |
|---|---|
| 目录项索引 | B 树代替线性表,大目录查找 O(log N) |
| 散列 dentry | 文件名 hash 直接定位,O(1) |
| 目录项缓存 | 常用目录项在内存中,不走磁盘 |
5. 磁盘调度 #
| 算法 | 原理 |
|---|---|
| SSTF | 选寻道距离最近的请求先服务 |
| SCAN/电梯 | 磁头来回扫,沿途处理所有请求 |
| C-SCAN | 单向扫描到底,减少最远端的等待方差 |
| Deadline | SCAN + 超时强制处理,防饥饿 |
6. 日志/写时复制——原子性换性能 #
| 技术 | 原理 |
|---|---|
| 日志(journal) | 元数据修改先记日志,crash 后 replay,避免 fsck 慢扫全盘 |
| 写时复制(COW) | 修改时写新位置,不原地覆盖,写入集中为连续写 |