第 5 章 内存系统

第 5 章 内存系统

《计算机系统:系统架构与操作系统的高度集成》

Umakishore Ramachandran & William D. Leahy Jr. 著


5.1 内存层次结构概述

复制代码
内存层次结构(Memory Hierarchy):

层次    | 类型      | 容量        | 速度        | 价格/GB
--------|-----------|-------------|-------------|--------
寄存器  | SRAM      | < 1KB       | < 1ns       | 极贵
L1 Cache| SRAM      | 32~64KB    | 1~4ns      | 很贵
L2 Cache| SRAM      | 256KB~1MB  | 4~12ns     | 贵
L3 Cache| SRAM      | 4~32MB     | 12~40ns    | 较贵
主存    | DRAM      | 4~64GB     | 50~100ns   | 适中
SSD     | Flash     | 256GB~4TB  | 0.1~1ms    | 便宜
HDD     | 磁盘      | 1~20TB     | 5~20ms     | 很便宜
网络存储| 各种      | PB级        | 毫秒~秒    | 极便宜

局部性原理(Principle of Locality):
  时间局部性(Temporal Locality):
    最近访问的数据很可能再次被访问
    例:循环变量 i 在每次迭代中都被访问
  
  空间局部性(Spatial Locality):
    访问某个地址后,很可能访问其附近的地址
    例:数组元素按顺序访问

Cache 的工作原理:
  利用局部性原理,将常用数据放在快速的 Cache 中
  Cache 命中(Hit):数据在 Cache 中,直接返回(快)
  Cache 缺失(Miss):数据不在 Cache 中,从下一层取(慢)
  
  平均访问时间 = 命中时间 + 缺失率 × 缺失惩罚
  例:命中时间=4ns,缺失率=5%,缺失惩罚=100ns
  平均访问时间 = 4 + 0.05 × 100 = 9ns

5.2 SRAM 与 DRAM

复制代码
SRAM(Static RAM,静态随机存储器):
  存储原理:使用触发器(6个晶体管/位)
  特点:
    不需要刷新
    速度快(1~10ns)
    功耗大,成本高,密度低
  用途:Cache、寄存器文件

DRAM(Dynamic RAM,动态随机存储器):
  存储原理:使用电容(1个晶体管+1个电容/位)
  特点:
    需要周期性刷新(电容会漏电,约每64ms刷新一次)
    速度慢(50~100ns)
    功耗小,成本低,密度高
  用途:主存(内存条)

DRAM 的发展历程:
  SDRAM → DDR → DDR2 → DDR3 → DDR4 → DDR5
  
  DDR(Double Data Rate):
    在时钟上升沿和下降沿都传输数据
    数据传输速率是时钟频率的2倍
  
  DDR4-3200 参数:
    时钟频率:1600MHz
    数据传输率:3200MT/s(每秒3200M次传输)
    总线宽度:64位
    带宽:3200 × 64 / 8 = 25.6 GB/s

SRAM vs DRAM 对比:
  特性      | SRAM          | DRAM
  ---------|---------------|-------------
  存储单元  | 6个晶体管     | 1个晶体管+1个电容
  需要刷新  | 否            | 是(约64ms)
  速度      | 1~10ns       | 50~100ns
  密度      | 低            | 高
  功耗      | 高            | 低
  成本      | 高            | 低
  用途      | Cache         | 主存

5.3 Cache 存储器

5.3.1 Cache 的基本原理

复制代码
Cache 行(Cache Line):
  Cache 的基本存储单位,通常 64 字节(现代处理器)
  每次从内存取数据时,取整个 Cache 行(利用空间局部性)

Cache 行结构:
  ┌──────────┬──────────────┬──────────────────────────┐
  │ 有效位(V)│   标记(Tag)  │      数据(Data,64字节)   │
  └──────────┴──────────────┴──────────────────────────┘
  有效位:该行是否包含有效数据(1=有效,0=无效)
  标记:标识该行存储的是哪个内存地址的数据
  数据:实际存储的数据

内存地址的分解:
  地址 = [标记(Tag) | 索引(Index) | 块内偏移(Offset)]
  
  例:32位地址,64字节Cache行,256行Cache
  块内偏移:log2(64) = 6位(选择行内的字节)
  索引:log2(256) = 8位(选择 Cache 行)
  标记:32 - 6 - 8 = 18位(标识内存块)

Cache 访问过程:
  1. 从地址中提取索引,找到对应的 Cache 行
  2. 检查有效位是否为1
  3. 比较标记是否匹配
  4. 如果有效且标记匹配:Cache 命中,返回数据
  5. 否则:Cache 缺失,从内存加载整个 Cache 行

5.3.2 直接映射 Cache

复制代码
直接映射 Cache(Direct-Mapped Cache):
  每个内存块只能映射到一个固定的 Cache 行
  映射规则:Cache 行号 = 内存块号 mod Cache 行数

优点:实现简单,查找速度快(只需比较一个标记)
缺点:冲突缺失(Conflict Miss)多

示例(4行Cache,每行4字节):
  地址分解:[标记(1位) | 索引(2位) | 偏移(2位)]
  
  内存块 → Cache 行映射:
  块0(地址0-3)   → Cache行0(标记=0)
  块1(地址4-7)   → Cache行1(标记=0)
  块2(地址8-11)  → Cache行2(标记=0)
  块3(地址12-15) → Cache行3(标记=0)
  块4(地址16-19) → Cache行0(标记=1,与块0冲突!)
  块5(地址20-23) → Cache行1(标记=1,与块1冲突!)

冲突缺失示例:
  交替访问地址0和地址16
  → 每次访问都是 Cache 缺失(抖动/Thrashing)
  → 解决:使用组相联 Cache

5.3.3 全相联 Cache

复制代码
全相联 Cache(Fully Associative Cache):
  内存块可以映射到任意 Cache 行
  查找时需要并行比较所有 Cache 行的标记

地址分解:[标记(Tag) | 块内偏移(Offset)](没有索引字段)

优点:冲突缺失最少(最灵活)
缺点:
  硬件复杂(需要并行比较所有标记)
  查找慢(随 Cache 大小增加而变慢)

适用场景:
  TLB(快表):通常只有 64~1024 项,全相联可行
  小型 Cache(如 Victim Cache)

5.3.4 组相联 Cache

复制代码
组相联 Cache(Set-Associative Cache):
  折中方案:将 Cache 分为多个组,每组有多个路(Way)
  内存块映射到固定的组,但可以放在组内任意路

N路组相联:每组有N个 Cache 行

地址分解:[标记(Tag) | 组索引(Set Index) | 块内偏移(Offset)]

示例:2路组相联,4组,每行4字节
  Cache 总大小:2路 × 4组 × 4字节 = 32字节
  地址分解:[标记(2位) | 组索引(2位) | 偏移(2位)]
  
  内存块 → 组映射:
  块0 → 组0(可放在路0或路1)
  块1 → 组1
  块4 → 组0(与块0同组,但可以共存!)
  块5 → 组1(与块1同组,但可以共存!)

现代处理器常用配置:
  L1 Cache:4路或8路组相联,32KB
  L2 Cache:8路组相联,256KB
  L3 Cache:16路组相联,8MB~32MB

三种 Cache 组织方式对比:
  类型      | 冲突缺失 | 硬件复杂度 | 查找速度
  ---------|---------|-----------|--------
  直接映射  | 多       | 低         | 快
  组相联    | 中       | 中         | 中
  全相联    | 少       | 高         | 慢

5.3.5 Cache 替换策略

复制代码
当 Cache 满时,需要替换一个 Cache 行:

1. LRU(Least Recently Used,最近最少使用):
   替换最长时间未被访问的行
   最接近最优策略,但实现复杂(需要维护访问顺序)
   
2. FIFO(First In First Out,先进先出):
   替换最早进入 Cache 的行
   实现简单,但性能不如 LRU
   
3. 随机替换(Random):
   随机选择一行替换
   实现最简单,性能接近 LRU
   
4. LFU(Least Frequently Used,最不常用):
   替换访问次数最少的行
   需要维护计数器,开销大

现代处理器:
  通常使用近似 LRU(伪 LRU,Pseudo-LRU)
  完全 LRU 的硬件开销太大(需要 log2(N!) 位状态)
  
  2路组相联的 LRU:只需1位(记录哪路最近被访问)
  4路组相联的 LRU:需要3位(树形 LRU)

5.3.6 写策略(写直达与写回)

复制代码
写命中(Write Hit)时的策略:

写直达(Write Through):
  同时写 Cache 和内存
  优点:Cache 和内存始终一致,简单可靠
  缺点:每次写都要访问内存,速度慢
  改进:写缓冲区(Write Buffer):写操作先放入缓冲区,异步写入内存

写回(Write Back):
  只写 Cache,不立即写内存
  Cache 行被替换时才写回内存
  使用脏位(Dirty Bit)标记是否被修改
  优点:减少内存访问次数,速度快
  缺点:Cache 和内存可能不一致(一致性问题)

写缺失(Write Miss)时的策略:

写分配(Write Allocate):
  先将内存块加载到 Cache,再写
  通常与写回配合使用

非写分配(No Write Allocate):
  直接写内存,不加载到 Cache
  通常与写直达配合使用

常见组合:
  写回 + 写分配(现代处理器常用)
  写直达 + 非写分配(简单系统)

5.3.7 多级 Cache

复制代码
现代处理器的多级 Cache:

L1 Cache(一级缓存):
  最快,最小(32~64KB)
  通常分为指令 Cache(I-Cache)和数据 Cache(D-Cache)
  访问时间:4个时钟周期
  每个核心独有

L2 Cache(二级缓存):
  较快,较大(256KB~1MB)
  通常统一(指令和数据共用)
  访问时间:12个时钟周期
  每个核心独有(或2个核心共享)

L3 Cache(三级缓存):
  较慢,较大(4~32MB)
  通常多核共享
  访问时间:40个时钟周期

主存(DRAM):
  最慢,最大(GB级)
  访问时间:200个时钟周期

Cache 缺失的代价:
  L1 缺失 → L2:约 8 个周期
  L2 缺失 → L3:约 28 个周期
  L3 缺失 → 主存:约 160 个周期
c 复制代码
/* Cache 性能测试:顺序访问 vs 随机访问 */
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <string.h>

#define SIZE (1 << 24)  /* 16MB,超过L3 Cache */

/* 顺序访问(Cache 友好,利用空间局部性)*/
long sequential_access(int *arr, int n) {
    long sum = 0;
    for (int i = 0; i < n; i++) {
        sum += arr[i];  /* 顺序访问,每次访问都在Cache行内 */
    }
    return sum;
}

/* 随机访问(Cache 不友好,大量Cache缺失)*/
long random_access(int *arr, int *indices, int n) {
    long sum = 0;
    for (int i = 0; i < n; i++) {
        sum += arr[indices[i]];  /* 随机跳跃,频繁Cache缺失 */
    }
    return sum;
}

/* 步长访问(测试空间局部性)*/
long stride_access(int *arr, int n, int stride) {
    long sum = 0;
    for (int i = 0; i < n; i += stride) {
        sum += arr[i];
    }
    return sum;
}

int main() {
    int *arr = (int *)malloc(SIZE * sizeof(int));
    int *indices = (int *)malloc(SIZE * sizeof(int));

    /* 初始化 */
    for (int i = 0; i < SIZE; i++) {
        arr[i] = i;
        indices[i] = rand() % SIZE;
    }

    clock_t start, end;
    long sum;

    /* 测试顺序访问 */
    start = clock();
    sum = sequential_access(arr, SIZE);
    end = clock();
    printf("顺序访问: %.3f秒, sum=%ld\n",
           (double)(end-start)/CLOCKS_PER_SEC, sum);

    /* 测试随机访问 */
    start = clock();
    sum = random_access(arr, indices, SIZE);
    end = clock();
    printf("随机访问: %.3f秒, sum=%ld\n",
           (double)(end-start)/CLOCKS_PER_SEC, sum);
    /* 随机访问通常比顺序访问慢 5~10 倍! */

    /* 测试不同步长 */
    printf("\n步长访问测试:\n");
    int strides[] = {1, 2, 4, 8, 16, 32, 64};
    for (int s = 0; s < 7; s++) {
        start = clock();
        sum = stride_access(arr, SIZE, strides[s]);
        end = clock();
        printf("步长=%2d: %.3f秒\n", strides[s],
               (double)(end-start)/CLOCKS_PER_SEC);
    }
    /* 步长超过Cache行大小(16个int=64字节)后,性能急剧下降 */

    free(arr);
    free(indices);
    return 0;
}

5.4 虚拟内存

5.4.1 虚拟内存的概念

复制代码
虚拟内存(Virtual Memory)的动机:

问题1:程序大小超过物理内存
  解决:将程序的一部分放在磁盘上,按需加载

问题2:多个程序共享内存,需要隔离
  解决:每个程序有独立的虚拟地址空间

问题3:程序加载地址不固定
  解决:虚拟地址到物理地址的动态映射

虚拟内存的核心思想:
  每个进程有独立的虚拟地址空间(如 0~4GB)
  操作系统负责将虚拟地址映射到物理地址
  物理内存不够时,将部分数据换出到磁盘(交换/Swapping)

虚拟地址空间布局(32位 Linux):
  0xFFFFFFFF ┌─────────────────┐
             │   内核空间(1GB)│ ← 所有进程共享
  0xC0000000 ├─────────────────┤
             │   栈(向下增长) │
             │        ↓        │
             │   ...(空洞)   │
             │        ↑        │
             │   堆(向上增长) │
             ├─────────────────┤
             │   BSS 段        │ ← 未初始化全局变量
             ├─────────────────┤
             │   数据段         │ ← 已初始化全局变量
             ├─────────────────┤
             │   代码段         │ ← 程序指令(只读)
  0x08048000 ├─────────────────┤
             │   保留           │
  0x00000000 └─────────────────┘

5.4.2 分页机制

复制代码
分页(Paging)机制:

基本概念:
  虚拟地址空间和物理内存都被分成固定大小的页(Page)
  页大小:通常 4KB(2^12 字节)
  
  虚拟页(Virtual Page):虚拟地址空间中的页
  物理页帧(Physical Frame):物理内存中的页

地址转换:
  虚拟地址 = [虚拟页号(VPN)| 页内偏移(Offset)]
  物理地址 = [物理页帧号(PFN)| 页内偏移(Offset)]
  
  页内偏移不变,只需将 VPN 转换为 PFN

地址转换示例(32位,4KB页):
  虚拟地址:0x00401234
  VPN = 0x00401234 >> 12 = 0x401 = 1025
  Offset = 0x00401234 & 0xFFF = 0x234
  
  查页表:页表[1025] = PFN 0x200(假设)
  物理地址 = (0x200 << 12) | 0x234 = 0x200234

5.4.3 页表结构

复制代码
页表(Page Table):
  存储 VPN → PFN 的映射关系
  每个进程有自己的页表
  存储在内存中(由操作系统管理)

页表项(PTE,Page Table Entry):
  ┌──────────────────────────────────────────────────┐
  │  PFN(物理页帧号)  │ V │ R │ W │ X │ D │ A │...│
  └──────────────────────────────────────────────────┘
  V(Valid):该页是否在物理内存中(1=在,0=不在)
  R(Read):是否可读
  W(Write):是否可写
  X(Execute):是否可执行
  D(Dirty):该页是否被修改(写回时使用)
  A(Accessed):该页是否被访问(LRU 替换使用)

页表大小问题:
  32位地址空间,4KB页,页表项4字节
  页表大小 = 2^20 × 4 = 4MB(每个进程!)
  100个进程 = 400MB 仅用于页表!
  
  解决:多级页表(见5.4.5节)

5.4.4 TLB(快表)

复制代码
TLB(Translation Lookaside Buffer,快表):

问题:每次内存访问都需要查页表(额外一次内存访问)
解决:TLB 缓存最近使用的页表项

TLB 的工作原理:
  TLB 命中(Hit):直接从 TLB 获取 PFN,无需访问页表
  TLB 缺失(Miss):访问页表,将结果存入 TLB

TLB 的特点:
  容量小(通常 64~1024 项)
  速度极快(与 L1 Cache 同级,< 1ns)
  全相联或高度组相联
  命中率通常 > 99%(局部性原理)

TLB 缺失处理:
  硬件处理(x86):CPU 自动遍历页表(硬件页表遍历器)
  软件处理(MIPS):触发 TLB 缺失异常,OS 处理

上下文切换时的 TLB:
  切换进程时,TLB 中的映射失效(不同进程的虚拟地址映射不同)
  解决方案1:切换时刷新整个 TLB(开销大)
  解决方案2:使用 ASID(Address Space ID)标记 TLB 项
    每个 TLB 项包含 ASID,只有 ASID 匹配才命中
    切换进程时只需更新 ASID,不需要刷新 TLB

有效内存访问时间(EMAT):
  EMAT = TLB命中率 × (TLB访问时间 + 内存访问时间)
       + TLB缺失率 × (TLB访问时间 + 页表访问时间 + 内存访问时间)
  
  例:TLB命中率=99%,TLB=1ns,内存=100ns,页表=100ns
  EMAT = 0.99 × (1+100) + 0.01 × (1+100+100)
       = 0.99 × 101 + 0.01 × 201
       = 99.99 + 2.01 = 102ns
  (比没有TLB的200ns快了近一倍)

5.4.5 多级页表

复制代码
多级页表(Multi-Level Page Table):

问题:单级页表太大(32位系统每进程4MB)

解决:多级页表(只为实际使用的地址分配页表)

两级页表(32位):
  虚拟地址 = [L1索引(10位) | L2索引(10位) | 偏移(12位)]
  
  L1页表(页目录,Page Directory):1024项,每项指向一个L2页表
  L2页表:1024项,每项是一个PTE
  
  优点:未使用的地址空间不需要L2页表
  例:程序只使用 4MB 内存(1个L2页表)
    只需 1个L1页表(4KB) + 1个L2页表(4KB) = 8KB
    而非单级页表的 4MB!

四级页表(64位 Linux,x86-64):
  虚拟地址 = [PGD(9位) | PUD(9位) | PMD(9位) | PTE(9位) | 偏移(12位)]
  支持 48位虚拟地址空间(256TB)
  
  PGD(Page Global Directory)
  PUD(Page Upper Directory)
  PMD(Page Middle Directory)
  PTE(Page Table Entry)

地址转换过程(四级页表):
  1. CR3寄存器 → PGD基地址
  2. PGD[VPN[47:39]] → PUD基地址
  3. PUD[VPN[38:30]] → PMD基地址
  4. PMD[VPN[29:21]] → PTE基地址
  5. PTE[VPN[20:12]] → 物理页帧号
  6. 物理地址 = PFN × 4096 + 偏移

5.4.6 段式存储管理

复制代码
段式存储管理(Segmentation):
  将程序分为逻辑段(代码段、数据段、栈段等)
  每段有独立的基地址和长度

段表(Segment Table):
  段号 | 基地址 | 长度 | 权限
  0    | 0x1000 | 4096 | R-X(代码段)
  1    | 0x5000 | 2048 | RW-(数据段)
  2    | 0x8000 | 8192 | RW-(栈段)

地址转换:
  逻辑地址 = [段号 | 段内偏移]
  物理地址 = 段基地址 + 段内偏移
  
  检查:段内偏移 < 段长度(否则段越界异常)

优点:
  符合程序的逻辑结构
  便于代码共享(多个进程共享代码段)
  便于保护(每段有独立的权限)

缺点:
  外部碎片(External Fragmentation):
    段大小不固定,内存中会产生碎片
  段的大小可能超过物理内存

5.4.7 段页式存储管理

复制代码
段页式存储管理(Segmented Paging):
  结合段式和页式的优点
  先分段,再对每段分页

地址转换:
  逻辑地址 = [段号 | 页号 | 页内偏移]
  
  1. 段号 → 段表 → 该段的页表基地址
  2. 页号 → 页表 → 物理页帧号
  3. 物理地址 = 物理页帧号 × 页大小 + 页内偏移

Linux 的实现:
  Linux 实际上使用段页式(但段的使用很简单)
  所有段的基地址都是0,长度都是最大值
  实际上退化为纯分页
  段的保护功能通过页表的权限位实现

5.5 内存保护

c 复制代码
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#include <signal.h>
#include <setjmp.h>

/* 内存保护机制演示 */

/* 1. 页面保护(通过页表项的保护位)*/
/* 代码段:只读+可执行(R-X)*/
/* 数据段:读写,不可执行(RW-)*/
/* 栈:读写,不可执行(RW-)*/

/* 2. 缓冲区溢出(安全漏洞)*/
void vulnerable_function(char *input) {
    char buffer[10];
    /* 危险!没有检查输入长度 */
    /* strcpy(buffer, input); */
    /* 如果 input 超过10字节,会覆盖栈上的返回地址! */
    
    /* 安全版本 */
    strncpy(buffer, input, sizeof(buffer) - 1);
    buffer[sizeof(buffer) - 1] = '\0';
    printf("输入: %s\n", buffer);
}

/* 3. 内存访问权限控制 */
void memory_protection_demo() {
    int pagesize = getpagesize();
    printf("页大小: %d 字节\n", pagesize);

    /* 分配一页内存(读写权限)*/
    void *page = mmap(NULL, pagesize,
                      PROT_READ | PROT_WRITE,
                      MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (page == MAP_FAILED) { perror("mmap"); return; }

    /* 写入数据 */
    *(int *)page = 42;
    printf("写入成功: %d\n", *(int *)page);

    /* 改为只读 */
    mprotect(page, pagesize, PROT_READ);
    printf("已改为只读\n");

    /* 读取仍然成功 */
    printf("读取成功: %d\n", *(int *)page);

    /* 尝试写入(会导致 SIGSEGV)*/
    /* *(int *)page = 100; */  /* 取消注释会导致段错误! */

    /* 恢复读写权限 */
    mprotect(page, pagesize, PROT_READ | PROT_WRITE);
    *(int *)page = 100;
    printf("恢复读写后写入: %d\n", *(int *)page);

    munmap(page, pagesize);
}

/* 4. 内存映射文件 */
#include <fcntl.h>
void mmap_file_demo() {
    /* 创建测试文件 */
    int fd = open("/tmp/test_mmap.dat", O_RDWR | O_CREAT | O_TRUNC, 0644);
    ftruncate(fd, 4096);

    /* 映射文件到内存 */
    char *mapped = (char *)mmap(NULL, 4096,
                                 PROT_READ | PROT_WRITE,
                                 MAP_SHARED, fd, 0);

    /* 通过内存操作写文件(零拷贝!)*/
    strcpy(mapped, "Hello, Memory-Mapped File!");
    printf("写入文件: %s\n", mapped);

    /* 同步到磁盘 */
    msync(mapped, 4096, MS_SYNC);

    munmap(mapped, 4096);
    close(fd);

    /* 重新读取验证 */
    fd = open("/tmp/test_mmap.dat", O_RDONLY);
    char buf[64] = {0};
    read(fd, buf, sizeof(buf));
    printf("从文件读取: %s\n", buf);
    close(fd);
}

int main() {
    printf("=== 内存保护演示 ===\n\n");

    printf("1. 缓冲区安全访问:\n");
    vulnerable_function("Hello, World! This is a long string.");

    printf("\n2. 内存访问权限控制:\n");
    memory_protection_demo();

    printf("\n3. 内存映射文件:\n");
    mmap_file_demo();

    return 0;
}

本章小结

知识点 核心内容 重要程度
内存层次结构 寄存器→Cache→主存→磁盘,局部性原理 ⭐⭐⭐⭐⭐
SRAM vs DRAM 触发器vs电容,速度vs密度 ⭐⭐⭐⭐
Cache基本原理 Cache行,地址分解,命中/缺失 ⭐⭐⭐⭐⭐
直接映射Cache 映射规则,冲突缺失 ⭐⭐⭐⭐
组相联Cache N路组相联,折中方案 ⭐⭐⭐⭐⭐
替换策略 LRU/FIFO/随机,伪LRU ⭐⭐⭐⭐
写策略 写直达vs写回,写分配vs非写分配 ⭐⭐⭐⭐
虚拟内存 地址空间隔离,按需加载 ⭐⭐⭐⭐⭐
分页机制 VPN→PFN,页表项结构 ⭐⭐⭐⭐⭐
TLB 页表缓存,命中率>99%,ASID ⭐⭐⭐⭐⭐
多级页表 节省页表空间,四级页表 ⭐⭐⭐⭐⭐
内存保护 页面权限,缓冲区溢出,mmap ⭐⭐⭐⭐

思考题

  1. 为什么 Cache 行通常是 64 字节而不是 1 字节或 1MB?
  2. 直接映射 Cache 的"抖动"问题是什么?如何解决?
  3. TLB 的命中率为什么通常能达到 99% 以上?
  4. 为什么多级页表比单级页表节省内存?
  5. 写回策略比写直达策略快,但有什么潜在问题?

参考文献:Umakishore Ramachandran & William D. Leahy Jr.,《计算机系统:系统架构与操作系统的高度集成》