往期内容回顾
前言:程序地址空间回顾
在现代操作系统(尤其是采用虚拟内存的系统)中,程序地址空间 是进程能看到的、可访问的虚拟地址布局,通常包括几个典型区域:
┌───────────────────────────────┐ 高地址
│ 栈区 Stack │ 向下增长
├───────────────────────────────┤
│ 共享库区 │ 动态库、运行时链接
├───────────────────────────────┤
│ 堆区 Heap │ 向上增长,malloc/new 分配
├───────────────────────────────┤
│ 数据段 Data │ 已初始化的全局/静态变量
├───────────────────────────────┤
│ BSS 段 │ 未初始化的全局/静态变量
├───────────────────────────────┤
│ 代码段 Text │ 程序指令(只读)
└───────────────────────────────┘ 低地址这个地址空间是虚拟的,由操作系统通过 内存管理单元(MMU) 映射到物理内存或磁盘。
区域 存放内容 大小确定方式 生命周期 增长方向 代码段 可执行指令 编译/链接阶段固定 全局 固定 数据段 已初始化的全局/静态变量 编译/链接阶段固定 全局 固定 BSS 段 未初始化的全局/静态变量 编译/链接阶段固定(加载时清零) 全局 固定 堆 动态分配的内存 运行时动态扩展,受系统限制 程序员控制释放 向上增长 栈 局部变量、调用记录 线程创建时固定大小(可修改) 自动回收 向下增长 关键点:
栈的大小 是创建线程时一次性分配的,超了就溢出。
堆的大小 运行时动态申请,只要系统有内存就能扩展。
代码段、数据段、BSS 段大小 都在编译/链接时已经确定,不会变。
堆和栈方向相反,是为了让它们从虚拟地址空间两端往中间长,最大化利用内存
1. 地址空间的意义
-
定义:地址空间就是进程能够访问的所有内存地址的范围,它是逻辑上的概念。
-
为什么要有:
-
隔离性 → 每个进程都认为自己独占整个内存,互不干扰,避免一个程序越界破坏另一个程序数据。
-
统一性 → 不管真实物理内存大小多少,程序看到的地址从 0x0000... 开始,写代码时不必关心内存的物理分布。
-
方便管理 → 操作系统通过地址映射控制进程访问权限、分配内存区域、回收资源。
-
2. 虚拟内存的作用
虚拟内存是地址空间的具体实现方式,它通过**内存管理单元(MMU)**和页表,把进程的虚拟地址映射到物理内存或磁盘。
-
主要优势:
-
内存保护:一个进程访问越界地址时,MMU 能立刻触发异常(段错误),保证安全。
-
扩展性:即使物理内存不足,也能用磁盘空间(Swap)虚拟成"更多的内存"。
-
共享与私有并存:允许不同进程共享一段物理内存(例如共享库),同时其他区域仍保持独立。
-
简化编程:程序员不必关心内存碎片和物理布局,虚拟地址看起来是连续的。
-
3. 物理内存的角色
-
物理内存是真正存放数据的硬件资源(RAM 芯片)。
-
系统通过**分页(Page)或分段(Segment)**把虚拟地址映射到物理地址。
-
物理内存有限,虚拟内存机制让多个进程看起来拥有足够大的可用空间
一、进程管理是如何分配地址空间
操作系统管理进程时,进程控制块(PCB,Linux 里是 task_struct)中会保存 内存管理相关的信息,比如:
-
页表基地址(虚拟地址到物理地址的映射)
-
程序代码段位置
-
堆和栈的起始地址和大小
-
内存映射区(mmap 动态库、文件映射)
进程调度时,操作系统会切换页表(即虚拟地址映射),从而让 CPU 看到的是该进程的地址空间。
也就是说,进程管理和地址空间是绑死在一起的:
-
PCB 记录地址空间信息
-
切换进程 = 切换虚拟地址空间
如果用伪 C 代码表示,简化的 PCB 结构可能是这样:
cppstruct PCB { pid_t pid; // 进程 ID pid_t ppid; // 父进程 ID enum state; // 进程状态 struct CPU_context ctx; // CPU寄存器等上下文信息 struct mm_struct *mm; // 进程的内存描述符(指向地址空间结构) struct file *files[MAX_FILES]; // 打开文件表 struct sched_info sched; // 调度信息 };
重点 :PCB 本身不直接保存整个地址空间,而是保存一个指针 mm ,指向 内存描述符(Memory Descriptor),这个描述符才负责具体的虚拟地址空间布局。
地址空间是怎么被描述的(mm_struct)
在 Linux 内核中,进程的虚拟内存布局是用 struct mm_struct 表示的。
cppstruct mm_struct { unsigned long start_code, end_code; // 代码段范围 unsigned long start_data, end_data; // 数据段范围 unsigned long start_brk, brk; // 堆的起始和当前结尾 unsigned long start_stack; // 栈顶地址 struct vm_area_struct *mmap; // 链表/红黑树管理的 VMA 区域 };
VMA(虚拟内存区域,Virtual Memory Area)
这是描述进程内连续虚拟地址区间的结构体。
比如代码段、数据段、堆、栈、mmap 映射区,每一块都是一个 VMA。
cppstruct vm_area_struct { unsigned long vm_start; unsigned long vm_end; unsigned long vm_flags; // 读写执行权限 struct vm_area_struct *vm_next; // 链表连接 };
所以从数据结构上看,PCB → mm_struct → vm_area_struct 是一条链路:
PCB
└── mm_struct(描述整个虚拟地址空间)
└── 链表/红黑树(每个节点是一个虚拟内存区域 VMA)
二、 PCB 如何"分配"进程地址空间
PCB 本身不直接"存放"进程的内存,而是通过 内存管理信息 (比如页表地址、段表信息)来指向进程的 虚拟地址空间。
参考示意图

(1) 创建进程时(fork())
-
PCB 本身不直接"存放"进程的内存,而是通过 内存管理信息 (比如页表地址、段表信息)来指向进程的 虚拟地址空间。
当新建一个进程时,内核会:
-
创建 PCB (分配内核内存保存它),分配虚拟地址空间 (或者复制父进程的映射关系),初始化页表 (映射虚拟地址 → 物理地址),设置代码段、数据段、堆、栈的起始地址和大小 ,将这些信息写入 PCB 的内存管理部分。
(2) 父子进程的地址空间分配
在 Linux 中,创建进程通常通过 fork():
-
fork() 之后,父子进程会有各自独立的 PCB
-
父子进程的 虚拟地址空间布局相同(代码段、数据段、堆、栈内容初始相同)
-
但是物理内存并不一定复制一份,而是采用 写时复制(Copy-on-Write, COW) 技术

📌 写时复制的过程:
-
fork 之后,父子进程的页表都指向同一块物理内存,并标记为 只读。
-
当任意一个进程试图修改某一页时,内核才会:
-
分配新的物理页
-
复制旧页内容
-
更新该进程的页表,解除只读标记
-
这样保证了修改不会影响另一个进程
-
三、 为什么说是"修改 PCB 分配地址空间"
-
PCB 自身不存放具体的内存,而是通过 mm_struct 维护虚拟地址空间的元信息。
-
所谓"修改 PCB"就是:
-
在创建进程、加载新程序或内存分配时,更新 PCB 里的 mm 指针指向的新内存布局。
-
这个布局用 VMA 链表/红黑树来表示。
-
-
这样 OS 就能根据这些结构,找到进程每个虚拟地址对应的物理页。
[PCB]
├─ PID, 状态, 寄存器...
└─ mm → [mm_struct]
├─ start_code, end_code
├─ start_data, end_data
├─ start_brk, brk (堆)
├─ start_stack (栈)
└─ mmap → [VMA 链表 / 红黑树]
├─ VMA1: 代码段
├─ VMA2: 数据段
├─ VMA3: 堆
├─ VMA4: 栈
└─ ...
-
PCB 里保存了进程的内存映射信息,但本身不存放数据。
-
fork 会复制 PCB 和页表,地址空间初始相同,但物理内存通过 写时复制 节省资源。
-
父子进程不一定共享地址空间,除非用线程或显式共享内存。
-
共享内存 是多进程通信的重要手段。
总结
进程管理在分配地址空间时,不会一次性分配所有物理内存,而是:
-
创建进程 → 操作系统先为其建立虚拟地址空间(代码段、数据段、堆、栈等)。
-
建立页表 → 通过页表记录虚拟地址与物理地址的映射关系。
-
按需分配 → 当进程访问某个虚拟地址时才分配对应的物理内存(缺页中断机制)。
-
保护与隔离 → 每个进程的地址空间互不干扰,访问非法地址会被操作系统拦截。