
大家好,欢迎来到 huangjin007_ 的博客
⭐ 个人主页:huangjin007_
🔥 文章收录专栏:Linux 内功修炼手册(系统篇)
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇
Linux 系统篇(十九) ------ 虚拟地址空间
文章目录
- [Linux 系统篇(十九) ------ 虚拟地址空间](#Linux 系统篇(十九) —— 虚拟地址空间)
-
- 一、从"程序地址空间"说起
-
- [1.1 我们熟悉的"内存布局图"](#1.1 我们熟悉的“内存布局图”)
- [1.2 代码验证各区域地址](#1.2 代码验证各区域地址)
- [1.3 一个颠覆认知的现象:父子进程相同地址不同值](#1.3 一个颠覆认知的现象:父子进程相同地址不同值)
- 二、什么是进程地址空间?
-
- [2.1 大富翁的比喻](#2.1 大富翁的比喻)
- [2.2 虚拟地址空间不是物理内存](#2.2 虚拟地址空间不是物理内存)
- 三、进程地址空间的详细布局
-
- [3.1 代码段(.text)](#3.1 代码段(.text))
- [3.2 只读数据段(.rodata)](#3.2 只读数据段(.rodata))
- [3.3 已初始化数据段(.data)](#3.3 已初始化数据段(.data))
- [3.4 未初始化数据段(.bss)](#3.4 未初始化数据段(.bss))
- [3.5 堆(Heap)](#3.5 堆(Heap))
- [3.6 共享库与文件映射区](#3.6 共享库与文件映射区)
- [3.7 栈(Stack)](#3.7 栈(Stack))
- [3.8 命令行参数和环境变量](#3.8 命令行参数和环境变量)
- [3.9 内核空间](#3.9 内核空间)
- 四、虚拟地址如何映射到物理地址?
-
- [4.1 页表的基本概念](#4.1 页表的基本概念)
- [4.2 映射过程](#4.2 映射过程)
- [4.3 进程初始化时的映射建立](#4.3 进程初始化时的映射建立)
- [4.4 父子进程与写时拷贝(Copy-On-Write)](#4.4 父子进程与写时拷贝(Copy-On-Write))
- 五、为什么需要虚拟地址空间?
-
- [5.1 直接使用物理内存的问题](#5.1 直接使用物理内存的问题)
- [5.2 虚拟地址空间的三大核心优势](#5.2 虚拟地址空间的三大核心优势)
-
- [5.2.1 地址有序化](#5.2.1 地址有序化)
- [5.2.2 保护物理内存](#5.2.2 保护物理内存)
- [5.2.3 进程管理和内存管理解耦](#5.2.3 进程管理和内存管理解耦)
- [5.3 额外的优势:延迟分配和共享](#5.3 额外的优势:延迟分配和共享)
- [六、内核如何管理虚拟地址空间:`mm_struct` 与 `vm_area_struct`](#六、内核如何管理虚拟地址空间:
mm_struct与vm_area_struct) -
- [6.1 先描述,再组织](#6.1 先描述,再组织)
- [6.2 `mm_struct` 结构体解析](#6.2
mm_struct结构体解析) - [6.3 为什么需要 `vm_area_struct`?](#6.3 为什么需要
vm_area_struct?) - [6.4 VMA 的组织:链表与红黑树](#6.4 VMA 的组织:链表与红黑树)
- [6.5 举例:堆的 VMA 增长](#6.5 举例:堆的 VMA 增长)
- 七、深入理解:缺页中断、挂起与内存保护
-
- [7.1 缺页中断(Page Fault)](#7.1 缺页中断(Page Fault))
- [7.2 挂起(Suspend)与交换(Swap)](#7.2 挂起(Suspend)与交换(Swap))
- [7.3 页表权限位与安全](#7.3 页表权限位与安全)
- 八、面试问题总结
-
- [8.1 什么是虚拟地址空间?它与物理内存有什么区别?](#8.1 什么是虚拟地址空间?它与物理内存有什么区别?)
- [8.2 为什么同一个虚拟地址在父子进程中可能对应不同的值?](#8.2 为什么同一个虚拟地址在父子进程中可能对应不同的值?)
- [8.3 `mm_struct` 和 `vm_area_struct` 的作用分别是什么?](#8.3
mm_struct和vm_area_struct的作用分别是什么?) - [8.4 进程地址空间包含哪些区域?各有什么特点?](#8.4 进程地址空间包含哪些区域?各有什么特点?)
- [8.5 什么是缺页中断?什么时候发生?](#8.5 什么是缺页中断?什么时候发生?)
- [8.6 虚拟地址空间如何保证进程独立性?](#8.6 虚拟地址空间如何保证进程独立性?)
- 结语:
一、从"程序地址空间"说起
1.1 我们熟悉的"内存布局图"
在学习 C 语言时,通常会看到这样一张经典的内存布局图:

当时我们只是记住了各个区域的名称和特点,却很少深入思考:这些地址是真实的物理内存地址吗?为什么进程会拥有这样的"整齐"布局?操作系统是如何管理这些区域的?
下面我们通过一个简单的 C 程序来验证这些区域的分布情况。
1.2 代码验证各区域地址
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
const char *str = "helloworld";
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
static int test = 10;
char *heap_mem = (char*)malloc(10);
char *heap_mem1 = (char*)malloc(10);
char *heap_mem2 = (char*)malloc(10);
char *heap_mem3 = (char*)malloc(10);
printf("heap addr: %p\n", heap_mem);
printf("heap addr: %p\n", heap_mem1);
printf("heap addr: %p\n", heap_mem2);
printf("heap addr: %p\n", heap_mem3);
printf("test static addr: %p\n", &test);
printf("stack addr: %p\n", &heap_mem);
printf("stack addr: %p\n", &heap_mem1);
printf("stack addr: %p\n", &heap_mem2);
printf("stack addr: %p\n", &heap_mem3);
printf("read only string addr: %p\n", str);
for(int i = 0; i < argc; i++)
printf("argv[%d]: %p\n", i, argv[i]);
for(int i = 0; env[i]; i++)
printf("env[%d]: %p\n", i, env[i]);
return 0;
}
运行结果(示例):
code addr: 0x40055d
init global addr: 0x601034
uninit global addr: 0x601040
heap addr: 0x1791010
heap addr: 0x1791030
heap addr: 0x1791050
heap addr: 0x1791070
test static addr: 0x601038
stack addr: 0x7ffd0f9a4368
stack addr: 0x7ffd0f9a4360
stack addr: 0x7ffd0f9a4358
stack addr: 0x7ffd0f9a4350
read only string addr: 0x400800
argv[0]: 0x7ffd0f9a4811
env[0]: 0x7ffd0f9a4819
...
从输出可以看出:
- 代码段地址(
main)最低(0x40055d)。- 已初始化全局变量(
g_val)和未初始化全局变量(g_unval)地址相邻,都在 0x601000 附近。- 静态局部变量
test也在这个区域(0x601038),说明静态变量与全局变量存储位置相同。- 堆上分配的四块内存地址依次增加(0x1791010、0x1791030...),说明堆是向上增长的。
- 栈上的局部变量
heap_mem等指针变量的地址都在 0x7ffd... 非常高的位置,且依次递减,说明栈是向下增长的。- 只读字符串
str的地址(0x400800)与代码段非常接近,说明常量字符串被放在了代码段附近的只读区域。- 命令行参数
argv和环境变量env的地址在栈的上方,即用户空间最高地址处。
1.3 一个颠覆认知的现象:父子进程相同地址不同值
我们再看一个经典的 fork 示例:
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ // child
g_val = 100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}else{ // parent
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
输出结果(可能因环境而异):

现象 :父子进程打印出的变量地址完全相同 ,但变量的值却不同。
这说明了什么?
- 变量值不同,说明父子进程访问的绝对不是同一个物理内存位置。
- 地址相同,说明这个地址不是物理地址,否则不可能同时对应两个不同的值。
在 Linux 中,这种地址被称为虚拟地址。我们在 C/C++ 中看到的所有地址,全部都是虚拟地址!物理地址由操作系统统一管理,用户进程永远看不到。
因此,之前常说的"程序地址空间"其实是不准确的,更准确的说法应该是进程地址空间 (每个进程都有自己的虚拟地址空间),或者直接称为虚拟地址空间。
二、什么是进程地址空间?
2.1 大富翁的比喻
为了更好地理解虚拟地址空间,我们先讲一个生动的比喻------大富翁的私生子:
有一个身价 10 亿的大富翁,他有 4 个私生子,彼此互不相识,都以为自己是独生子。富翁对每个孩子都画了大饼说:"儿子,我这 10 亿资产未来全是你的!" 于是每个儿子都认为自己拥有 10 亿。当私生子 A 想要10 万的零花钱,富翁随手给了;私生子 B 想要 100 万买个车,富翁也满足了......但实际上,富翁手里总共只有 10 亿,他通过自己的账本管理着给每个儿子的实际资源,而不是真的让每个儿子都占有全部资产。

在这个比喻中:
- 大富翁 = 操作系统(OS)
- 10 亿资产 = 物理内存
- 4 个私生子 = 系统中的各个进程
- 富翁画的"大饼" = 虚拟地址空间
- 富翁的账本 = 内核中的内存管理数据结构(如
mm_struct、页表)
操作系统通过虚拟地址空间,让每个进程都以为自己独占整个系统的内存资源(32 位系统下是 4GB)。进程可以随意规划自己的内存布局(代码、栈、堆等),但实际上,只有当它真正需要物理内存时,操作系统才通过页表映射在物理内存中分配给它。
2.2 虚拟地址空间不是物理内存
很多人误以为虚拟地址空间就是一块实际的内存区域,其实虚拟地址空间只是一个"描述",它并不存储任何数据。它就像一张"蓝图"或"地图",告诉进程它有哪些区域可以使用,每个区域的起始和结束位置在哪里。真正的数据存储在物理内存中,通过页表与虚拟地址建立映射。
可以这样理解:虚拟地址空间是进程对内存的"幻想",物理内存是现实,页表是幻想与现实之间的桥梁。
三、进程地址空间的详细布局
在 32 位 Linux 系统中,每个进程的虚拟地址空间大小为 4GB,其中用户空间占 3GB(0x00000000 ~ 0xBFFFFFFF),内核空间占 1GB(0xC0000000 ~ 0xFFFFFFFF)。用户空间的布局从上到下(从高地址到低地址)大致如下:

下面我们逐一详细介绍每个区域。
3.1 代码段(.text)
- 存放 CPU 执行的机器指令。
- 通常位于虚拟地址空间的低地址处,但不是从 0 开始 。在经典 32 位系统中,代码段通常从
0x08048000开始。从 0 到该地址之间是一片"保留区域",用于捕获空指针(NULL)解引用错误。- 代码段是只读的,防止程序意外修改自身指令。
3.2 只读数据段(.rodata)
- 存放常量字符串、
const修饰的全局变量等只读数据。- 与代码段相邻,地址略高于代码段。例如前面的实验中
str的地址0x400800与main的0x40055d非常接近。- 也是只读的,任何写操作都会触发段错误(Segmentation Fault)。
3.3 已初始化数据段(.data)
- 存放已明确初始化的全局变量和静态变量(初始值非零)。
- 这些初始值在可执行文件中占空间,程序加载时直接拷贝到内存。
- 例如
int g_val = 100;中的g_val。
3.4 未初始化数据段(.bss)
- 存放未初始化或初始化为 0 的全局变量和静态变量。
- 在可执行文件中不占据实际磁盘空间,只记录大小;程序加载时,操作系统自动将这块内存清零。
- 例如
int g_unval;和static int test = 10;(test已初始化,所以其实放在 .data,但若未初始化则放 .bss)。
注意 :.bss和.data常被统称为"数据段",但二者有本质区别:.data占用文件空间,.bss不占用。
3.5 堆(Heap)
- 用于动态内存分配,如
malloc、new等。- 从低地址向高地址增长(向上增长)。
- 堆的起始位置由内核的
brk指针指定,当需要更多空间时,brk向上移动即可扩展堆区。
3.6 共享库与文件映射区
- 位于堆和栈之间的一大块区域,用于映射动态链接库(如
libc.so)、通过mmap映射的文件、以及进程间通信的共享内存等。- 这块区域的存在使得多个进程可以共享相同的物理内存(例如共享库代码只加载一份物理内存,通过页表映射到不同进程的虚拟地址空间)。
3.7 栈(Stack)
- 存放函数局部变量、函数参数、返回地址等。
- 从高地址向低地址增长(向下增长)。
- 栈顶通常位于用户空间的最高处(接近 3GB),但会因 ASLR(地址空间布局随机化)而有一些浮动。
3.8 命令行参数和环境变量
- 位于栈的上方,通常紧挨着栈的起始位置,存放
argv和envp指向的字符串。- 例如前面的实验中,
argv[0]和env[0]的地址都在0x7ffd0f9a4811附近,比栈上变量的地址稍高。
3.9 内核空间
- 高 1GB 的空间映射到内核代码和数据,所有进程共享同一份内核映射。
- 用户进程不能直接访问内核空间,必须通过系统调用陷入内核。
四、虚拟地址如何映射到物理地址?
前面已经明确,进程看到的所有地址都是虚拟地址,那么 CPU 在执行指令时,如何将虚拟地址转换为物理地址呢?这依赖于页表(Page Table) 和硬件 MMU(内存管理单元)。
4.1 页表的基本概念
页表是一种数据结构,用于记录虚拟地址到物理地址的映射关系。在 32 位系统中,通常将虚拟地址空间划分为固定大小的页(通常 4KB),物理内存也划分为同样大小的页框。页表存储每个虚拟页对应的物理页框号,以及一些权限标志位。
- 32 位系统下,虚拟地址空间 4GB,4KB 一页,共需要 2^20 个页表项。
- 由于页表项数量庞大,通常采用多级页表结构(如二级页表)来减少内存开销。
4.2 映射过程
当 CPU 访问一个虚拟地址时,大致流程如下:
- CPU 将虚拟地址发送给 MMU。
- MMU 根据页表基址寄存器(由操作系统设置)找到页表。
- MMU 将虚拟地址拆分为页号和页内偏移。
- 通过页号在页表中查找对应的物理页框号。
- 将物理页框号与页内偏移组合成物理地址。
- 用物理地址访问内存。
如果页表中没有该虚拟页的映射(即发生了缺页),则触发缺页异常,由操作系统负责加载所需数据并填充页表。
4.3 进程初始化时的映射建立
当一个程序被加载执行时,操作系统的步骤大致如下:
- 创建
task_struct(进程控制块)和mm_struct(内存描述符),初始化虚拟地址空间的各个区域边界。- 从磁盘加载可执行文件,将代码段、数据段等读入物理内存(可能部分加载,参见后面的"缺页中断")。
- 在虚拟地址空间中根据各段的大小,设置相应的起始和结束地址(如
start_code、end_code等)。- 填写页表:将虚拟地址空间中的各段起始地址与物理内存中的实际位置建立映射。
- 进程开始执行,CPU 按需通过页表访问内存。
4.4 父子进程与写时拷贝(Copy-On-Write)
fork() 创建子进程时,子进程会拷贝父进程的 PCB、虚拟地址空间和页表,因此初始状态下父子进程的虚拟地址和物理地址映射完全相同,它们看到的内存内容一模一样。
但当子进程试图修改某个变量(例如 g_val = 100)时,由于页表项中该页通常被标记为只读,写操作会触发页错误。操作系统发现这是一个"写时拷贝"的情况,于是:
- 为子进程分配一块新的物理内存。
- 将原物理内存中的数据拷贝到新物理内存。
- 更新子进程的页表,将该虚拟页映射到新的物理页,并设置为可写。
- 重新执行写操作。
这样,父子进程的虚拟地址仍然相同,但物理地址已经分离,互不影响。这种技术称为写时拷贝(Copy-On-Write, COW),它节省了物理内存(未修改时共享同一物理页)并提高了 fork 的效率。

五、为什么需要虚拟地址空间?
如果让进程直接操作物理内存,会带来一系列严重问题,虚拟地址空间正是为了解决这些问题而设计的。
5.1 直接使用物理内存的问题
- 安全风险
任何进程都能访问任意物理内存,包括系统关键数据。一个恶意程序可以随意修改内核内存,导致系统崩溃或被攻击。- 地址不确定
程序编译后存放在磁盘,运行时才加载到内存。如果直接使用物理地址,每次加载的位置可能不同(因为内存中已有其他进程占用),导致程序中的地址无法固定,需要重定位,非常麻烦。- 效率低下
物理内存不够时,需要将整个进程换出到磁盘(交换分区)。如果直接使用物理地址,必须将整个进程作为一个整体搬移,耗时巨大。而且进程间内存碎片化严重,管理困难。- 进程独立性无法保证
多个进程可能同时使用相同的物理地址,导致冲突。虚拟地址空间为每个进程提供了独立的地址视图,实现了进程间的隔离。
5.2 虚拟地址空间的三大核心优势
5.2.1 地址有序化
通过页表映射,物理内存中杂乱无章的数据可以在虚拟地址空间中表现为整齐有序的布局。例如,一个大型程序可能被加载到物理内存的多个不连续区域,但在虚拟地址空间中它看起来是连续的,这简化了编译器和链接器的设计。
5.2.2 保护物理内存
在地址转换过程中,操作系统可以检查访问的合法性:
- 地址合法性:如果访问的虚拟地址不在任何有效的 VMA 中,则触发段错误。
- 权限检查:页表项中的权限位(如只读、可写、可执行)可以阻止非法操作。例如尝试写只读的字符串常量,MMU 会拒绝并产生段错误。
- 用户/内核隔离:用户态代码不能访问内核空间地址。
5.2.3 进程管理和内存管理解耦
如果没有虚拟地址空间,进程控制块(PCB)中需要直接记录物理内存的分配情况,内存管理模块和进程管理模块会紧密耦合,牵一发而动全身。有了虚拟地址空间,进程只关心自己的虚拟地址,物理内存的分配、回收、换页等操作完全由内存管理模块独立完成,两者通过页表进行交互。
5.3 额外的优势:延迟分配和共享
- 延迟分配 :进程调用
malloc时,内核可能只在虚拟地址空间中预留空间,并不立即分配物理内存。直到进程真正访问该区域时,才触发缺页中断分配物理内存, 提高了内存利用率。- 共享内存:不同进程的虚拟地址可以映射到同一物理页,实现代码共享(如动态库)、进程间通信等。
六、内核如何管理虚拟地址空间:mm_struct 与 vm_area_struct
6.1 先描述,再组织
Linux 内核管理任何对象都遵循"先描述,再组织"的原则。对于进程地址空间,描述它的核心数据结构是 struct mm_struct (内存描述符),而描述其中每个独立区域的则是 struct vm_area_struct(虚拟内存区域,简称 VMA)。


6.2 mm_struct 结构体解析
每个进程的 task_struct 中都包含一个指向 mm_struct 的指针:
c
struct task_struct {
...
struct mm_struct *mm; // 用户进程的地址空间描述
struct mm_struct *active_mm; // 内核线程使用的地址空间
...
};
对于普通用户进程,mm 指向它的虚拟地址空间。
mm_struct 的主要字段包括:
c
struct mm_struct {
// 区域边界
unsigned long start_code, end_code; // 代码段
unsigned long start_data, end_data; // 数据段(已初始化)
unsigned long start_brk, brk; // 堆区(起始和当前边界)
unsigned long start_stack; // 栈起始位置
unsigned long arg_start, arg_end; // 命令行参数区
unsigned long env_start, env_end; // 环境变量区
// 管理 VMA 的指针
struct vm_area_struct *mmap; // VMA 链表头
struct rb_root mm_rb; // VMA 红黑树根
// 其他统计信息
unsigned long total_vm; // 总虚拟内存页数
...
pgd_t *pgd; // 页表基址(页全局目录)
};
区域划分的本质就是设置这些边界变量的值 。例如,当我们在 C 语言中调用 malloc 扩展堆时,内核会调整 brk 指针的值(向上移动),从而扩大堆的虚拟地址范围。

6.3 为什么需要 vm_area_struct?
mm_struct 中的边界变量只能描述几个大的段,但实际程序中,虚拟地址空间会被划分为许多更细粒度的区域:
- 代码段、数据段、BSS 段、堆、栈等基本区域。
- 通过
mmap映射的文件、共享库、匿名映射等。- 堆中多次
malloc产生的多个空闲和已分配块。
这些区域在地址空间中是离散的,每个区域都有自己的属性(如可读、可写、可执行、是否共享等)。为了精细管理,内核引入了 struct vm_area_struct(VMA),每个 VMA 描述一段连续的、具有相同属性的虚拟内存区域。
VMA 的主要字段:
c
struct vm_area_struct {
unsigned long vm_start; // 区域起始虚拟地址
unsigned long vm_end; // 区域结束虚拟地址(不包含)
struct vm_area_struct *vm_next, *vm_prev; // 链表前后指针
struct rb_node vm_rb; // 红黑树节点
struct mm_struct *vm_mm; // 所属的 mm_struct
pgprot_t vm_page_prot; // 页保护权限
unsigned long vm_flags; // 标志位(VM_READ, VM_WRITE, VM_EXEC 等)
struct file *vm_file; // 如果是文件映射,指向文件
unsigned long vm_pgoff; // 文件映射的偏移量
...
};
vm_start 和 vm_end 定义了区域的范围,vm_flags 和 vm_page_prot 定义了访问权限。
6.4 VMA 的组织:链表与红黑树
内核需要频繁查找某个虚拟地址属于哪个 VMA(例如缺页异常时)。为了高效管理,mm_struct 中同时维护了两种数据结构:
- 双向链表 :由
mmap指针指向链表头,每个 VMA 通过vm_next和vm_prev链接。链表按照地址递增排序,方便顺序遍历。- 红黑树 :由
mm_rb指向树根,每个 VMA 包含一个rb_node。红黑树提供 O(log n) 的查找效率,适合 VMA 数量较多的情况。
当 VMA 数量较少时,链表和红黑树都可用,链表顺序遍历很快;当数量增多(如数百个)时,红黑树查找更优。内核会同时维护两者,插入或删除 VMA 时更新两个数据结构。
6.5 举例:堆的 VMA 增长
当我们连续多次调用 malloc 时,如果每次分配的内存都位于堆的连续区域,内核可能只维护一个 VMA,其 vm_end 不断增大;但如果堆中出现了空洞(部分释放),则可能会分裂成多个 VMA。每个 VMA 记录一段连续的地址范围及其属性。
七、深入理解:缺页中断、挂起与内存保护
7.1 缺页中断(Page Fault)
缺页中断是虚拟内存管理的核心机制之一。当进程访问一个虚拟地址时,如果页表中没有对应的映射(或权限不足),CPU 会触发缺页异常,陷入内核,由操作系统处理。
缺页的原因可能包括:
- 真正的缺页:该虚拟地址合法(在某个 VMA 范围内),但尚未分配物理页(延迟分配或换出)。操作系统会分配物理页,从磁盘加载数据(如代码、数据或交换分区),然后填充页表并重新执行指令。
- 非法访问 :虚拟地址不属于任何 VMA,或权限不符(如写只读页)。此时操作系统向进程发送
SIGSEGV信号,导致段错误。
延迟分配的例子 :调用 malloc(1GB) 后,虚拟地址空间立即预留 1GB 范围,但物理内存并未分配。只有当程序开始读写这些地址时,才会逐页触发缺页中断,逐步分配物理内存。这就是为什么 malloc 可以"瞬间"返回,但实际使用内存时可能会变慢。
7.2 挂起(Suspend)与交换(Swap)
当物理内存不足时,操作系统需要将部分进程暂时挂起,将其占用的物理内存换出到磁盘的交换分区(swap)中,以腾出空间。
挂起的过程大致如下:
- 选择一个状态为阻塞(S)的进程。
- 将进程的 PCB 状态改为挂起(T)。
- 保留进程的
mm_struct和虚拟地址空间描述。- 在页表中清空物理页框号,但保留虚拟地址到磁盘位置的映射(通过交换缓存)。
- 将物理内存中的内容写入磁盘交换分区。
- 释放这些物理页给其他进程使用。
当该进程被唤醒时,它的虚拟地址空间仍然完整,但访问内存时会因缺页中断而重新从磁盘加载数据。
7.3 页表权限位与安全
页表项中除了物理页框号,还包含重要的权限位:
- R/W:读写位。0 表示只读,1 表示可读写。
- U/S:用户/特权位。0 表示仅内核可访问,1 表示用户可访问。
- NX:不可执行位。现代 CPU 支持,用于防止在数据区(如堆、栈)执行代码(缓冲区溢出攻击防御)。
例如,字符串常量存放在只读数据段,对应的页表项 R/W 位为 0,任何写操作都会触发缺页异常,操作系统发现是非法写操作后向进程发送 SIGSEGV 信号。这就是为什么以下代码会崩溃:
c
char *str = "helloworld";
*str = 'H'; // 段错误!
八、面试问题总结
8.1 什么是虚拟地址空间?它与物理内存有什么区别?
虚拟地址空间是操作系统为每个进程提供的独立地址范围,大小通常为 4GB(32 位)。它只是一个逻辑概念,不占用实际物理内存。物理内存是真实的硬件存储,由操作系统统一分配。两者通过页表映射关联。
8.2 为什么同一个虚拟地址在父子进程中可能对应不同的值?
因为写时拷贝机制。fork 后父子进程共享同一物理页,但当一方修改时,触发写时拷贝,为新修改方分配新物理页,导致相同虚拟地址映射到不同物理地址。
8.3 mm_struct 和 vm_area_struct 的作用分别是什么?
mm_struct 描述整个进程的地址空间框架,包含各个大区域的边界(如代码段、栈、堆等)以及 VMA 管理结构。vm_area_struct 描述一个具体的、连续的虚拟内存区域,包含该区域的起始/结束地址、权限、映射文件等信息。mm_struct 通过链表和红黑树组织多个 VMA。
8.4 进程地址空间包含哪些区域?各有什么特点?
- 代码段(.text):只读,存放指令。
- 只读数据段(.rodata):存放常量,只读。
- 数据段(.data):已初始化全局/静态变量。
- BSS 段:未初始化或零初始化全局/静态变量,不占文件空间。
- 堆:动态分配,向上增长。
- 文件映射与共享库区:映射动态库和文件,可共享。
- 栈:局部变量,向下增长。
- 命令行参数和环境变量:位于栈上方。
- 内核空间:用户不可访问。
8.5 什么是缺页中断?什么时候发生?
当 CPU 访问的虚拟地址在页表中没有有效映射时,触发缺页异常。可能是延迟分配、数据被换出到磁盘,或非法访问。操作系统根据 VMA 判断合法性,合法则分配物理页并加载数据,非法则发送段错误信号。
8.6 虚拟地址空间如何保证进程独立性?
每个进程有独立的 mm_struct 和页表,虚拟地址空间相互隔离。即使虚拟地址相同,也会映射到不同的物理页(或共享只读页)。写时拷贝进一步保证了修改时的隔离。
结语:
今天的内容到这里就结束了,希望你能有所收获~
干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _