第 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 | ⭐⭐⭐⭐ |
思考题
- 为什么 Cache 行通常是 64 字节而不是 1 字节或 1MB?
- 直接映射 Cache 的"抖动"问题是什么?如何解决?
- TLB 的命中率为什么通常能达到 99% 以上?
- 为什么多级页表比单级页表节省内存?
- 写回策略比写直达策略快,但有什么潜在问题?
参考文献:Umakishore Ramachandran & William D. Leahy Jr.,《计算机系统:系统架构与操作系统的高度集成》