进阶 #os#memory#virtual-memory

内存地址空间(逻辑 vs 物理)

每个程序都以为自己独占全部内存——这是操作系统的"障眼法",通过逻辑地址(虚拟地址)和物理地址的分离,让每个程序拥有独立的地址空间

每个程序都”以为”自己独占内存

打开三个记事本窗口,每个都在编辑不同的文件。在程序看来,每个记事本的地址都是从 0x00000000 到 0xFFFFFFFF(32 位)——它们都用了相同的地址,但互不干扰。

这是怎么做到的?

🏫 类比:酒店的房卡 每个住客拿到的房卡上都写着”301”——但酒店有 10 层楼,每层都有 301 房间吗?不是的。前台(OS)把每个客人分配到了不同的实际房间,但给每个客人的房卡上都写”301”。

  • 逻辑地址 = 房卡上写的”301”(每个客人看到的一样)
  • 物理地址 = 真正的房间号 1208(只有前台知道)

逻辑地址 vs 物理地址

程序看到的世界(逻辑地址空间)        真实的世界(物理地址空间)
┌────────────────────┐             ┌────────────────────┐
│ 0xFFFFFFFF          │             │ 物理内存            │
│  (高地址)          │             │ ┌──────────────────┐│
│                     │             │ │ 其他进程/内核    ││
│                     │             │ ├──────────────────┤│
│ 程序以为自己独占      │    映射     │ │ 进程 A 的代码    ││
│ 整个 4GB 空间       │ ────────→  │ ├──────────────────┤│
│                     │             │ │ 进程 B 的数据    ││
│ ┌──────────────────┐│  MMU 翻译   │ ├──────────────────┤│
│ │ 代码段            ││             │ │ 空闲内存          ││
│ │ 数据段            ││             │ ├──────────────────┤│
│ │ 堆 ↓              ││             │ │ 进程 B 的代码    ││
│ │ ...               ││             │ └──────────────────┘│
│ │ ↑ 栈              ││             └────────────────────┘
│ └──────────────────┘│
│ 0x00000000(低地址)  │
└────────────────────┘
概念含义谁看见的
逻辑地址(虚拟地址)程序指令中使用的地址CPU(执行指令时)
线性地址x86 中经过段式转换后的地址MMU
物理地址内存芯片上的真实地址内存控制器
虚拟地址空间进程看到的假想内存空间进程

地址翻译——MMU 的魔法

CPU 执行 mov rax, [0x12345678] 时,地址 0x12345678 不是直接发给内存的——它先经过 MMU(Memory Management Unit) 翻译:

CPU             MMU             物理内存
┌────┐     ┌──────────┐     ┌──────────┐
│    │     │          │     │          │
│ 执 │ 虚拟地址 │ 查页表    │ 物理地址 │ 读/写数据 │
│ 行 │────→│  0x1234  │───→│  0x9ABC  │─────→    │
│ 指 │     │   ↓↓     │     │          │          │
│ 令 │     │  MMU     │     │          │          │
└────┘     └──────────┘     └──────────┘

地址翻译的层次

CPU 发出虚拟地址 VA


① 段式转换(x86 特有的历史包袱)
   VA → 线性地址(Linear Address)


② 页式转换(所有现代 OS 都用的方式)
   线性地址 → 拆分为 页号 + 偏移量
   查页表 → 找到对应的物理页框号


③ 物理地址 = 物理页框号 × 页大小 + 偏移量

💡 现代 x86-64 Linux 直接绕过了段式转换(“平坦模型”),只使用页式转换。

为什么需要虚拟地址空间?

理由 1:隔离——程序之间不捣乱

没有虚拟内存:                     有虚拟内存:
程序 A 写地址 0x1000           程序 A 写虚拟地址 0x1000
  ↓                                ↓
直接写到物理地址 0x1000          MMU 映射到物理页框 X
  ↓                                ↓
如果程序 B 也在用 0x1000→崩   程序 B 的 0x1000 映射到 Y
                                互不影响 ✅

理由 2:简化编程——不用管物理内存布局

程序编译时,代码、数据、堆、栈各放什么地址——编译器可以假设从 0 开始。不用关心物理内存的碎片和空闲区域。

理由 3:共享与保护

多个进程可以共享同一份物理内存(如动态库的代码),但每个进程的页表映射到同一物理页——只读执行,不能修改。

进程 A 的虚拟地址          物理内存         进程 B 的虚拟地址
  0x7f00 (只读)       ┌──────────┐      0x7f00 (只读)
     ───────────────→ │ libc.so  │ ←──────────────
                      │ 代码段    │
                      └──────────┘

理由 4:使用超过物理内存的大小

虚拟地址空间可以比物理内存大——通过虚拟内存,程序可以”以为”自己有 4TB 空间,但实际物理内存只有 8GB。

进程的地址空间布局

典型的 64 位 Linux 进程地址空间:

0x0000000000000000
  ┌──────────────────────┐
  │ 代码段(Text)         │ ← 只读,程序的机器指令
  ├──────────────────────┤
  │ 数据段(Data)         │ ← 初始化了的全局变量
  ├──────────────────────┤
  │ BSS 段                │ ← 未初始化的全局变量(不占文件空间)
  ├──────────────────────┤
  │ 堆(Heap)            │ ← malloc / new
  │     ↓                 │
  │                      │
  │     ↑                 │
  │ 栈(Stack)           │ ← 局部变量、函数调用
  ├──────────────────────┤
  │ 动态链接库            │ ← libc.so 等
  ├──────────────────────┤
  │ 内核空间(只对内核可见)│ ← 系统调用入口、内核代码
0x7FFFFFFFFFFFFFFF (用户空间顶部)

💡 在 64 位 Linux 上,用户空间一般是 47 位(约 128TB),内核空间也是 47 位。这个分割点在地址空间的中间,而不是 32 位的 3:1 分割。

实际运用:malloc 背后的地址空间变化

// 一个简单的 C 程序
#include <stdio.h>
#include <stdlib.h>

int global_var = 42;       // 数据段
int uninit_var;             // BSS 段

int main() {
    int local = 10;        // 栈
    int* heap = malloc(100);  // 堆
    
    printf("代码段 (main):   %p\n", main);
    printf("数据段 (global): %p\n", &global_var);
    printf("BSS (uninit):    %p\n", &uninit_var);
    printf("堆 (malloc):     %p\n", heap);
    printf("栈 (local):      %p\n", &local);
    
    free(heap);
    return 0;
}

输出示例(每次运行地址可能不同,但相对位置固定):

代码段 (main):   0x4004b6       ← 低地址
数据段 (global): 0x400900       ← 代码段上方
BSS (uninit):    0x400910       ← 数据段上方
堆 (malloc):     0x602010       ← BSS 上方
栈 (local):      0x7fff1234     ← 高地址

关键观察:代码段在最低地址,栈在最高地址,堆在中间向上增长——和你刚学的进程地址空间布局完全吻合。每个程序都从 0x400000(Linux 默认加载地址)开始,认为自己独占整个地址空间。

查看进程地址空间

# 查看进程的内存映射
$ cat /proc/$$/maps
00400000-00452000 r-xp 00000000 08:01 12345  /bin/bash
00651000-00652000 r--p 00051000 08:01 12345  /bin/bash
00652000-00653000 rw-p 00052000 08:01 12345  /bin/bash
01c83000-01ca4000 rw-p 00000000 00:00 0      [heap]
7f5c5d8e0000-7f5c5dac0000 r-xp 00000000 08:01 67890  /lib/x86_64-linux-gnu/libc.so.6
...
7ffc2b5b1000-7ffc2b5d2000 rw-p 00000000 00:00 0      [stack]
7ffc2b5de000-7ffc2b5e1000 r--p 00000000 00:00 0      [vvar]
7ffc2b5e1000-7ffc2b5e3000 r-xp 00000000 00:00 0      [vdso]

每行列出了:

  • 虚拟地址范围
  • 权限(r=读, w=写, x=执行, p/s=私有/共享)
  • 偏移、设备号、inode
  • 映射的文件名

小结

概念要点
逻辑地址程序看到的地址,每个进程独立
物理地址内存芯片上的真实地址
MMU地址翻译的硬件单元
页表虚拟页 → 物理页的映射关系
隔离性每个进程有独立地址空间
共享通过页表映射到同一物理页实现

为什么先学这个? 理解了地址空间的分裂,才能理解分页和虚拟内存。下一节看看分页与页表——地址翻译的具体实现。