【Linux我做主】细说进程地址空间
进程地址空间
- 进程地址空间
- github地址
- 0. 前言
- 1. 验证内存分区
- 内存分区地址的演示
- 验证堆栈地址的增长方向
- static变量所在的区域
- 2. 虚拟地址的引入
- 3. 进程地址空间引入
- 父子进程刚开始是如何实现代码和数据共享的
- 写时拷贝的过程
- 解释最开始的现象和历史遗漏问题
- 4. 深入理解地址空间
- 什么是地址空间
- 深入理解地址空间及其区域划分
- 为什么要有进程地址空间
- 1. 统一的内存视角
- 2. 安全与隔离
- 3. 进程管理和内存管理解耦
- 5. 页表初识
- cr3寄存器
- 页表中的权限位
- 页表中的“是否加载到内存”标记位与惰性加载
- 1. 问题提出
- 2. 大文件加载的挑战
- 3. 分批加载与惰性加载
- 3.1 分批加载(批量搬运的思路)
- 3.2 惰性加载(现代操作系统的实际做法)
- 4. 页表标记位与内存管理解耦
- 5. 总结
- 6. 进程的再认识与提高
- 6.1 进程的创建与惰性加载
- 6.2 进程创建的顺序
- 6.3 进程的再定义
- 6.4 进程切换的本质
- 6.5 进程独立性的体现
- 6.6 统一的视角与乱序的物理内存
- 7. 验证命令行参数和环境变量的地址比栈的地址高
- 8. 结语
进程地址空间
github地址
有梦想的电信狗
0. 前言
在操作系统中,内存是最核心的资源之一,而进程作为资源管理的基本单位,必须拥有对内存的“统一视角”。然而,真实的物理内存分布往往复杂且无序,直接暴露给进程会导致管理混乱、数据安全性不足。于是,Linux 通过 进程地址空间 和 页表机制,为进程营造了一个连续、独立且受保护的虚拟世界。
本文将以实验与源码为切入点,从 内存分区、虚拟地址、进程地址空间 到 页表与缺页中断,系统地剖析进程是如何“看到”内存,以及操作系统如何在背后完成高效的管理与隔离。通过这些内容,你将对进程与内存的关系有更深入的理解,并掌握 Linux 内核设计中“解耦”思想的精髓。
1. 验证内存分区
内存分区地址的演示
在C/C++
中,我们之前将内存分为以下几个区域(以32位操作系统为例
):
- 栈(Stack):存储非静态局部变量、函数参数、返回值等,由编译器自动管理,向下增长。
- 堆(Heap):动态内存分配区域,需手动管理(
malloc/free
或new/delete
),向上增长。 - 数据段(静态区),也叫全局变量区(包括已初始化全局变量和未初始化全局变量区):存储全局变量和静态变量(如
static int
)。 - 代码段(常量区):存放可执行代码和只读常量(如字符串常量
"abcd"
)。
以上内存分布也可以转化成以下分布图:
- 从下向上,地址由低地址处向高地址增加。
操作系统中,地址用16进制数表示,在32位环境下,最低的地址为 0000 0000
,最高的地址为 FFFF FFFF
。
1位16进制数可以转换为4位二进制数。16进制下,1个0代表4个二进制比特位,最低的地址为 0000 0000
八个零,代表着 4*8 == 32
比特位,即对应32位环境
- 我们用以下代码来验证不同区的地址分布:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>int g_val_1; // 未初始化全局变量
int g_val_2 = 100; // 已初始化全局变量
int main() {printf("code addr: %p\n", main); // main 函数是代码,其地址代表代码区的地址const char* str = "hello world"; // 字符串常量,存储在常量区printf("read onlt string addr: %p\n", str); // 字符串常量的地址printf("init global value addr: %p\n", &g_val_2); // 已初始化全局变量printf("uninit global value addr: %p\n", &g_val_1); // 未初始化全局变量char* mem = (char*) malloc(100); // 堆区的数据printf("heap addr: %p\n", mem); // 打印堆区地址printf("stack addr: %p\n", &str); // 打印栈区地址return 0;
}
可以看到,地址值的大小正如分布图所示,
验证堆栈地址的增长方向
- 堆栈相对而生,栈区地址向下增长,堆区地址向上增长
- 我们用以下代码来验证栈区地址的向下增长:
void test(int n) {int local_var = n; // 每层递归都会有一个新的局部变量printf("递归深度 %2d, 局部变量地址: %p\n", n, &local_var);if (n > 0)test(n - 1); // 继续递归
}
int main() {test(10); // 从 10 层开始递归return 0;
}
局部变量存储在栈区中。可以看到随着递归不停的建立栈帧,新建立的栈帧中,局部变量的地址越来越小。
因此栈区地址是向下增长的
- 我们用以下代码来验证堆区地址的向上增长:
int g_val_1; // 未初始化全局变量
int g_val_2 = 100; // 已初始化全局变量
int main() {printf("code addr: %p\n", main);const char* str = "hello world"; // 字符串常量printf("read only string addr: %p\n", str);printf("init global value addr: %p\n", &g_val_2); // 已初始化全局变量printf("uninit global value addr: %p\n", &g_val_1); // 未初始化全局变量char* mem = (char*) malloc(100); // 堆区的数据char* mem1 = (char*) malloc(100); // 堆区的数据char* mem2 = (char*) malloc(100); // 堆区的数据printf("heap addr: %p\n", mem); // 打印堆区地址printf("heap addr: %p\n", mem1); // 打印堆区地址printf("heap addr: %p\n", mem2); // 打印堆区地址return 0;
}
可以看到最后三行中,堆区的三个地址
mem,,mem1,mem2
依次增大
因此堆区地址是向上增长的
static变量所在的区域
-
static
修饰的局部变量是具有全局变量的属性的,只不过是受到局部作用域的限制,static
修饰的局部变量编译后会被存储到全局数据区 -
static
修饰的局部变量存储在全局数据区,作用域为其局部作用域,只会在局部初始化一次,生命周期和全局变量一样长,不会随着函数调用的结束而销毁
- 运行结果如上,
static
修饰的局部变量和全局变量仅仅相差8字节,存储地址几乎十分相近 - 因此
static
修饰的局部变量编译后会被存储到全局数据区
2. 虚拟地址的引入
观察以下代码的现象:
- 我们定义一个全局变量,创建一个子进程,分别使用子进程和父进程不断地读取这个全局变量的值
- 运行五秒后,子进程修改该全局变量的值为200
int g_val = 100;
int main() {pid_t id = fork();if (id == 0) {//子进程读取int cnt = 5;while (1) {printf("i am child, pid: %d, ppid: %d, g_val = %d, &g_val = %p\n", getpid(), getppid(), g_val, &g_val);sleep(1);if (cnt)cnt--;else {g_val = 200;printf("子进程 change g_val: 100->200\n");cnt--;}}} else {//父进程读取while (1) {printf("i am parent, pid: %d, ppid: %d, g_val = %d, &g_val = %p\n", getpid(), getppid(), g_val, &g_val);sleep(1);}}return 0;
}
- 现象如下:
观察我们可以发现:
- 子进程修改数据前,父子进程访问同一个全局变量,全局变量的值相同,地址也相同
- 子进程修改数据后,父子进程访问同一个全局变量,子进程中是200,父进程中是100。
- 这个很好理解,往期文章中我们提到,进程本身具有独立性,
fork
之后,父子进程代码共享,子进程暂时和父进程共享同一份数据,但父子进程的数据不能互相干扰,子进程对数据做写入时,是对父进程数据的拷贝作写入,也就是读时共享,写时拷贝。因此父子进程中全局变量的值分别为100和200
- 这个很好理解,往期文章中我们提到,进程本身具有独立性,
- 但为什么子进程修改数据后,父子进程再次访问全局变量时,访问的是同一个变量g_val,同一个地址,同时读取,读取到的内容不同,为什么呢?。
- 但我们可以先得出一个结论::
- 如果变量的地址,是内存条中真实的的物理地址,不可能存在同一个地址的变量内容却不相同的现象,因此变量的地址不可能是物理地址
- 因此我们得出:
- 这里的地址不可能是物理地址,这里实际上是虚拟地址,或者也叫线性地址
- 平时我们写的
C/C++
用的指针,指针中保存的地址,全部都不是物理内存中的地址,而是虚拟地址
3. 进程地址空间引入
- 针对以上代码父进程被CPU调度,父进程创建子进程,子进程修改数据
g_val
后父子进程分别读取的情形,以下草图大致描述了父子进程是如何做到访问的是同一个变量,同一个地址,同时读取,读取到的内容不同 - 进程 == 内核数据结构 + 代码和数据,之前只是简单的将内核数据结构解读为PCB(task_struct),这样的说法并不够完善,现在应该对其进行拓展:
- 即 内核数据结构 == PCB + 进程地址空间 + 页表
前文中引入的虚拟地址,便可以理解为我们所说的进程地址空间。
每一个进程运行起来,操作系统都会为其创建内核数据结构,包括三个数据结构对象:
- PCB(task_struct),
- 进程地址空间
- 页表
进程地址空间是内核为进程创建的一个结构体对象,进程的PCB中有对应的指针,指向该进程地址空间结构体,
进程地址空间,是一种软件层面的数据结构对象,是用结构体实现的。
结构体中规定了不同的内存分区的起始位置,32位系统中定义了一段4GB的连续的空间,分区过后就形成了我们常见的内存分区
这里我们将他理解为一层软件结构即可
- 每一个进程都有自己独立的
task_struct
、进程地址空间、页表
关于进程地址空间的本质,以及页表的介绍,我们后文中会给出具体的说明。
父子进程刚开始是如何实现代码和数据共享的
父子进程刚开始是如何实现代码和数据共享的:
- 每一个进程都有自己独立的**
task_struct
、进程地址空间、页表**。C/C++
中使用的地址,实际上是进程地址空间中的虚拟地址,程序编译过后,操作系统将数据的地址编排为虚拟地址,保存在操作系统为进程创建的虚拟进程地址空间中。- 访问数据或执行代码时,先查找进程地址空间中相应数据的虚拟地址,再通过页表,将虚拟地址映射到内存中的物理地址,从而访问内存中的数据。
- 进程的
task_struct
中保存了一个指针,指向当前进程地址空间,方便进程通过虚拟地址和页表访问数据
- 父进程创建后,操作系统为其创建内核数据结构(
task_struct
、进程地址空间、页表)。通过task_strcut
中的指针,可以找到进程地址空间的结构体对象,从而实现父进程对数据的访问- 父进程创建子进程,是系统中多了一个进程,也要被操作系统管理起来,因此子进程也有自己的
task_struct
、进程地址空间、页表 - 初始时,子进程复制父进程的
task_struct
、进程地址空间、页表,子进程用父进程的task_struct
绝大多数字段初始化自己的task_struct
,进程地址空间和页表的内容父子进程完全相同 - 因此子进程的地址空间中有着和父进程一样的虚拟地址,页表中地址的映射关系也相同
- 父进程创建子进程,是系统中多了一个进程,也要被操作系统管理起来,因此子进程也有自己的
- 因此,当子进程通过进程地址空间中的虚拟地址,经过页表,映射到了相同的物理地址,于是就访问到了同一份代码和数据,这样就做到了初始时父子进程共享代码和数据
写时拷贝的过程
写时拷贝的过程:
父子进程代码共享,父子再不写入时,数据也是共享的。当任意一方试图写入,便以写时拷贝的方式各自一份副本
-
初始时,子进程复制父进程的
task_struct
、进程地址空间、页表,子进程用父进程的task_struct
绝大多数字段初始化自己的task_struct
,进程地址空间和页表的内容父子进程完全相同- 因此子进程的地址空间中有着和父进程一样的虚拟地址,页表中地址的映射关系也相同
-
当子进程要对数据做写入(代码为
g_val = 200
)时,操作系统识别到当前子进程中g_val
变量的虚拟地址映射到的物理地址中,这块数据是和父进程共享的,操作系统识别到子进程要对共享的那部分数据做写入,为了维护进程的独立性,会发生写时拷贝。
写时拷贝:
- 数据写入之前,操作系统会在物理内存上重新开辟空间,将父进程原来的
g_val = 100
拷贝到新开辟的空间上(在物理内存中),再将新开辟的物理空间的地址填入到子进程页表中的物理地址。- 该写时拷贝行为是由操作系统自动完成的,(在这个过程中,页表左侧的虚拟地址是0感知的,不会影响虚拟地址,即左侧的虚拟地址不会改变)
- 改完子进程页表中的物理地址后(此时子进程中
g_val
的虚拟地址不变,但映射的物理地址已发生改变),子进程再来执行代码g_val=200
,子进程会根据进程地址空间中g_val的虚拟地址,在页表中查找,找到映射的新物理地址,将物理内存新空间中的值改为200 - 经过以上过程,就完成了写时拷贝
解释最开始的现象和历史遗漏问题
最开始的现象:
- 为什么子进程修改数据后,父子进程再次访问全局变量时,访问的是同一个变量g_val,同一个地址,同时读取,读取到的内容不同?
经过上文的分析我们可以轻松的得出原因:
- 父进程创建子进程,子进程复制了父进程的task_struct的大部分、进程地址空间和页表的全部,未对
g_val
做修改时,父子进程的地址空间和页表的内容完全相同(虚拟地址相同),虚拟地址到物理地址的映射也完全相同,共享一份数据和代码。因此访问数据时,g_val
的值相同 - 子进程尝试去修改
g_val
的值时,操作系统识别到后,会触发写时拷贝。在物理内存中新开辟一段空间,复制g_val
,并将新空间的物理地址填入到子进程的页表中。修改完页表后,子进程再通过虚拟地址去修改物理地址中的数据,将g_val
的值改为200。 - 子进程和父进程的进程地址空间中,各自的全局变量
g_val
的虚拟地址是相同的,但父子进程的虚拟地址在页表中映射到的物理地址不相同。在访问数据时,相同的虚拟地址经过页表的映射,会到物理内存中对应不同的物理地址去访问g_val
的值,所以就会呈现出变量的值不相同,但是地址是相同的这种现象
历史遗留问题:
- 同一个变量里面怎么会有两个值
int main(){pid_t id = fork();if(id == 0){// 子进程...}else{// 父进程...}
}
有了以上内容的理解,我们对fork()
两个返回值的理解就信手拈来了。
fork()
进行返回时,本质是向id
值进行写入的过程,会发生写时拷贝。我们用凝练的语言表达该过程,具体细节上文已给出
-
return
对id
进行写入前,父子进程都已存在,各自有独立的 PCB, 地址空间,页表,父子进程三种结构内容几乎相同 -
由于父子进程地址空间内容几乎相同,各自的虚拟地址空间中,各有一个
id
变量,且id
变量的虚拟地址相同,- 未做写入时,相同的虚拟地址经过相同的页表的映射,指向同一块物理地址,父子进程共享数据,
-
子进程
return
,操作系统识别到子进程是对父子进程的共享数据做写入,发生写时拷贝,经历以下流程:-
在物理内存中为子进程的
id
开辟新空间 -
将新空间的物理地址填入到页表中,子进程中
id
的虚拟地址不变,但经过页表映射到了新的物理地址处 -
子进程再通过虚拟地址和页表访问新的物理地址中的数据,进行修改
-
-
最终,物理内存中的不同地址处都存放了
id
变量,访问时,父子进程各自查id
的虚拟地址,通过不同的页表映射关系,到不同的物理地址处访问id
变量,因此同一个变量名中有两个不同的值。最终物理内存中保存了两个不同值的id
变量
4. 深入理解地址空间
什么是地址空间
- 在32位计算机中,有32位的地址和数据总线,也就是说有32条地址总线,每一根地址总线根据电平高低有0,1两种状态,那么32根地址总线,可以表示232种状态。
CPU
访问内存的过程,本质是CPU对内存中的地址寄存器进行充放电的过程。32位地址总线会分别识别出高低电平,最终产生232种高低电平组合,每一个组合都是一个32位的二进制数,这个数就是地址。- 访问地址的最小基本单元是
byte(字节)
,那么32位计算机的可访问地址的范围总大小就是232 * 1byte,而1GB = 210 MB = 220 KB = 230byte,所以32位计算机的内存大小就是232 * 1byte = 230byte * 4 = 4GB
总结上述内容可得:
- 地址空间就是地址总线高低电平所形成的二进制数,自由组合所形成的地址范围
- 32位的计算机的地址范围为**[0, 232)**
深入理解地址空间及其区域划分
-
Linux
中会运行数量众多的进程,每一个进程都要有自己独立的进程地址空间。 -
进程数量众多,进程地址空间数量也就众多,为了方便管理,Linux中使用结构体描述进程地址空间,符合先描述,再组织的思想
在Linux
中,使用mm_struct
结构体描述进程地址空间,且task_struct
中存放了一个指针 mm_struct*
,为进程创建task_struct
时,也创建相应的进程地址空间
// PCB task_struct
struct task_struct{mm_struct* mm;// ...
}
// 32位系统中,默认划分的区域是4GB
struct mm_struct{unsigned long total_vm,locked_vm, shared_vm,exec_vm;unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;unsigned long start_code,end_code,start_data,end_data;unsigned long start_brk, end_brk, start_stack, end_stack;unsigned long arg_start, arg_end, env_start, env_end;|
}
操作系统创建每一个进程时,要创建进程的task_struct
,同时要创建该进程的地址空间mm_strcut
,且task_struct
中存放了mm_struct
的地址,可以方便的找到进程对应的代码和数据
struct mm_struct
保存了各个区域的起始和结束地址,如:- 代码区起止:
unsigned long start_code, end_code;
- 堆区起止:
unsigned long start_brk, end_brk;
- 栈区起止:
unsigned long start_stack, end_stack;
- 代码区起止:
- 还有一些其他分区,不同的分区,都使用整数区间来描述可使用地址的范围
- 进程地址空间的每个分区都是连续的空间,空间中每一个最小单位都可以有地址,且最小单位地址可以直接使用!!!
综合以上,可以得出进程地址空间的最终理解:
- 进程地址空间,是一个描述进程可视的内存范围的大小的结构体(内核数据结构对象),表示进程可使用的内存范围,在操作系统中用结构体
mm_strcut
来表示,结构体中规定了各个虚拟内存分区的起始位置和结束位置,是线性地址 - 进程地址空间内存在各种区域划分,即对线性的虚拟地址进行划分,规定各个分区的起止位置(start,end)
每一个进程在启动时,操作系统会为进程创建task_struct
,进程地址空间(mm_struct
),以及页表,用进程地址空间这个结构体,表示每一个进程可使用内存的空间范围。
- 一个进程不可能把整个物理内存空间占完,每个进程都有自己独立的进程地址空间
为什么要有进程地址空间
总结如下:
- 让所有的进程以统一的视角看待内存。
- 所有的进程都有相同布局的虚拟地址,内存布局是相同的,而不用让各个进程记录自己的代码和数据在物理内存的什么位置,进程就不需要去维护冗余的自己的代码和数据了。通过虚拟内存和页表映射可以直接找到物理内存中的代码和数据
- 有了进程地址空间,我们访问物理内存时,可以增加一个从进程虚拟地址空间和页表到物理内存的映射,增加一个转换的过程,可以对从虚拟到物理的寻址请求进行审查,一旦异常访问,直接拦截非法的内存访问操作,使该请求不会到达物理内存,保护物理内存
- 进程地址空间和页表的存在,可以将进程管理模块,和内存管理模块进行解耦合
✅ 总结一句话:
进程地址空间的存在,让进程看到的是一个“统一且虚拟”的世界,操作系统则在背后通过页表把虚拟和物理关联起来,既降低了进程负担,又提升了系统安全性,同时实现了进程管理与内存管理的解耦。
1. 统一的内存视角
-
在没有虚拟内存的时候:
每个进程都必须直接使用物理内存地址。比如进程 A 的代码在物理地址0x1000
,进程 B 的代码在物理地址0x3000
。那就需要进程自己去记录这些物理地址,否则一旦访问错误,就会直接读写到别的进程的数据。 -
引入虚拟内存之后:
每个进程都拥有一个一模一样的 虚拟地址空间布局,比如:代码段:0x400000 ~ 0x4FFFFF 数据段:0x500000 ~ 0x5FFFFF 堆 :0x600000 ~ ... 栈 :高地址向低地址增长
不管物理内存真实分布在哪里,对进程来说看到的都是虚拟地址,总是一样的布局。
👉 好处:进程只需要“相信”自己看到的虚拟地址,不用关心代码和数据实际存放在哪个物理位置,也不需要维护额外的物理内存信息。这样减少了冗余和复杂度。
2. 安全与隔离
-
访问物理内存不是直接访问,而是先通过 页表 做一次“翻译”:虚拟地址到物理地址的映射审查
虚拟地址 → 页表查找 → 物理地址
-
在这个翻译过程中,操作系统和硬件(MMU,内存管理单元)可以进行检查:
- 这个虚拟地址是否在进程的合法范围?
- 是否有读/写/执行权限?
- 是否被标记为无效(比如还没分配物理页)?
-
一旦发现非法访问(比如越界、读写只读内存、访问空指针地址),硬件就会抛出 缺页异常/保护性异常,OS 能立刻拦截,防止进程直接破坏物理内存。
👉 好处:保证不同进程之间的隔离性和安全性,保护物理内存不被随意破坏。
3. 进程管理和内存管理解耦
- 如果没有虚拟内存:
进程调度和内存分配会强耦合。
例如:进程切换时,操作系统必须考虑“当前进程的代码和数据在物理内存的什么位置”,否则无法运行。进程管理必须深度参与内存的细节。 - 有了虚拟内存:
- 进程管理模块:只需要知道进程有自己的一份独立地址空间,并不需要关心物理内存怎么分配。
- 内存管理模块:只需要负责把虚拟地址空间里的页映射到合适的物理内存页即可,进程切换时只需切换页表。
👉 好处:
- 解耦:进程管理和内存管理各司其职。
- 进程调度时无需关心物理地址,直接切换页表即可。
- 内存管理可以独立优化(比如做换页、内存回收、共享内存),而不用担心影响进程调度逻辑。
5. 页表初识
- 为了完成从虚拟地址到物理地址的映射,操作系统为每个进程设计了一个页表,是一个地址映射表
- CPU要访问当前进程的进程地址空间时,该进程一定正在被CPU运行
cr3寄存器
CPU
内部有一个寄存器,为cr3寄存器,该寄存器内保存当前正在运行的进程的页表的起始地址,这里的地址是页表的物理地址
进程切换时,担不担心找不到当前进程的页表呢?
答案是不担心
- cr3寄存器中的页表地址,也叫当前正在运行的进程的临时数据,本质上属于进程的硬件上下文。
CPU
进行进程切换时,会将cr3
寄存器中的页表地址保存下来。当前进程再次被调度时,会把曾经保存的页表地址再恢复到cr3寄存器中。- 所以,一个进程自始至终都可以找到自己的页表
每个进程都有自己的页表地址,保存在自己的上下文数据中。CPU想要访问某个变量,会先找到该变量的虚拟地址,再去cr3寄存器中找到页表的地址,根据虚拟地址,在页表中查找对应的虚拟地址,最终映射到实际的物理地址
页表中的权限位
-
我们知道代码区和字符常量区的数据是只读的,但是,操作系统如何知道要访问的这块内存中的内容是只读的、可读可写的、还是可被执行的呢?
-
页表中除了记录数据的虚拟地址和物理地址,还有另一个条目,称为权限位,用于标识当前物理内存中的内容的访问权限
r
:当前地址中的内容为只读rw
:当前地址中的内容为可读可写x
:当前地址中的内容为可执行
这里我们不考虑执行,因为可执行在页表中并没有直接的体现。因为
CPU
内本身就有一个eip
寄存器,保存下一条指令的地址寄存器中保存的是哪条指令的地址,就代表对应地址中的内容是可以被执行的。
- 初始化数据区:权限为
rw
可读可写- 当要对初始化数据区(其它内存分区类似)中的数据进行读写操作,操作系统通过变量的虚拟地址,在页表中查到数据的权限是可读可写的,因此允许进行读写操作。
- 代码区:权限为
r
只读- 当要对代码区进行写入时,操作系统通过代码区的虚拟地址,在页表中查到所访问的数据的权限位为只读,此时不允许进行写入操作,所以操作系统直接拦截我们的操作,并且会直接报错,不允许对代码区的数据进行修改。对字符常量区的数据进行修改报错也是一样的道理
- 以下代码会报错
- 因为字符串常量存储字符常量区,页表的权限位为只读,不能修改
int main() {char* str = "hello world";*str = 'H';return 0;
}
但我们的疑问是,如果代码区和字符常量区是只读的,那么代码和数据是如何加载到物理内存的只读区域的?(只读区域不应该不能写入吗)
原因就在于:
- 物理内存没有只读只写的概念,也没有被权限位控制的概念,我们可以将代码和数据加载到物理内存的任何位置
- 代码和字符常量区是只读的,这一权限限制的实现,是通过页表实现的。
- 进程地址空间中的代码区和字符常量区的虚拟地址,在页表中映射的标志位的权限为
r
只读,所以进行对代码区和字符常量区进行写入操作时,操作系统才会进行拦截。
- 进程地址空间中的代码区和字符常量区的虚拟地址,在页表中映射的标志位的权限为
页表中的“是否加载到内存”标记位与惰性加载
- 回想,进程是可以被挂起的。那么:
- 操作系统如何知道进程是否处于挂起状态呢?进程状态中并没有挂起状态
- 操作系统如何知道该进程的代码和数据是否在物理内存中呢
- 这样的行为依然和页表有关
1. 问题提出
在操作系统中,进程是可以被挂起的,这引出两个问题:
- 操作系统如何知道进程是否处于挂起状态呢?
- 进程状态模型中常见的有 运行、阻塞、睡眠 等状态,并没有明确的“挂起”状态。
- 所以,操作系统并不是通过“进程状态”来判断挂起与否,而是通过调度和内存映射情况来感知。
- 操作系统如何知道某个进程的代码和数据是否已经在物理内存中呢?
- 这就要依赖 页表(Page Table)。
- 页表中除了存放虚拟地址到物理地址的映射关系以及权限位,还包含一个重要的标记位(Present Bit / 有效位),用于指示该虚拟页是否真正加载到物理内存中。
0
→ 当前虚拟页不在内存中,需要从磁盘调入;1
→ 当前虚拟页已在内存中,可以直接访问。
- 操作系统可以通过页表中的标识,判断进程的代码和数据是否被加载到内存中
先补充一个知识,现代操作系统不做任何浪费时间和空间的行为
现代操作系统几乎不做任何浪费时间和空间的事情,任何浪费时间和空间的事情,几乎都操作系统被优化了
2. 大文件加载的挑战
设想一个场景:
我们在电脑上运行一个游戏,游戏安装后体积大小可能高达 几十 GB。而我们的电脑总内存只有 4GB,其中系统本身要占用一部分,实际可供应用使用的可能只有 3GB。
疑问:
👉 如果程序必须在运行前把所有代码和数据一次性加载进内存,那么显然 10GB 的程序在 3GB 的内存里根本放不下,应该会卡死甚至崩溃。
但实际上,游戏运行得很流畅,说明操作系统在背后做了优化。
3. 分批加载与惰性加载
3.1 分批加载(批量搬运的思路)
操作系统可以选择只把程序的一部分加载到内存中。例如:
- 假设先加载 500MB 的内容,让程序能启动运行;
- 当执行到新的区域时,再把对应的部分加载进来;
- 执行完一段后,可以释放旧的部分,给其他数据腾出空间。
这样,超大程序就能在有限内存中运行,看似占用 10GB,实际上只需 3GB 左右的物理内存。
问题:
如果每次都“批量”加载太多,比如加载 500MB,而代码是一行一行执行的,短时间只用到其中 15MB,那么剩余的 485MB 会被闲置,其他进程无法利用,造成内存浪费。
3.2 惰性加载(现代操作系统的实际做法)
现代操作系统采用更精细的策略 —— 惰性加载(Lazy Loading)。
原理:
- 在进程启动时,操作系统并不会真正把所有代码和数据读入内存。
- 页表会先记录该整个程序的虚拟地址范围,将虚拟地址填入页表,但对应的物理页表项为空,有效位(Present Bit)设置为 0。
- 当 CPU 访问这些虚拟地址时,会发现页表项无效,触发 缺页中断(Page Fault)。
- 操作系统这时才会从磁盘把对应的代码和数据加载到物理内存中,更新页表,把有效位置该为
1
,建立虚拟地址和物理地址的映射。 - 下一次访问时,CPU 就能直接命中页表,避免重复开销。
好处:
- 不再提前加载无用的部分,避免内存浪费;
- 只在真正需要时才加载,提高运行效率;
- 支持大程序在小内存机器上运行,实现了虚拟内存的按需调度。
4. 页表标记位与内存管理解耦
有了页表中的标记位,进程管理和内存管理可以做到解耦:
- 进程管理模块:只管分配和使用虚拟地址空间。至于这个虚拟地址有没有物理内存支撑,它不关心。
- 内存管理模块:只在缺页中断发生时,才为进程分配实际的物理页,并负责页面置换。
这样:
- 进程并不直接与物理内存打交道,而是通过操作系统和页表间接完成;
- 即便物理内存不足,操作系统也能通过置换机制保证进程继续运行;
- 避免了进程和内存之间的强耦合,提升了系统的可扩展性与调度灵活性。
5. 总结
- 页表中有一个 有效位(Present Bit),用来标识某个虚拟页是否已加载到内存。
- 大型程序不可能一次性全部加载进内存,操作系统采用 分批加载 与 惰性加载 策略。
- 惰性加载通过缺页中断机制实现“按需调页”,避免浪费内存空间。
- 页表机制让进程管理和内存管理解耦,进程无需关心物理内存的实际情况,只需使用虚拟地址即可。
👉 这就是为什么我们能在一台 4GB 内存的电脑上流畅运行一个占用几十 GB 的大型游戏。
6. 进程的再认识与提高
6.1 进程的创建与惰性加载
当一个进程被创建时,操作系统 并不会立刻把所有代码和数据全部加载到物理内存中。
从技术角度上讲,完全可以做到以下行为:
- 只创建进程的内核数据结构(PCB、进程地址空间、页表),
- 而对应的代码和数据,一行都不加载。
等到进程真正要被调度执行时,操作系统再通过 缺页中断 + 惰性加载 的方式,边运行边加载。
也就是说,程序虽然看起来“一瞬间运行起来了”,实际上代码和数据是逐步被加载到内存中的。
- 真正要调度执行的时候,操作系统自动再将代码和数据慢慢惰性加载,此时就可以实现边使用边加载
6.2 进程创建的顺序
问题:进程在被创建时,是先加载代码数据,还是先创建内核数据结构?
答案:
- 必须是 先创建内核数据结构,包括:
task_struct
(进程控制块 PCB);mm_struct
(进程的虚拟地址空间描述);- 页表(虚拟地址与物理地址的映射结构)。
- 并处理好三者之间的对应关系。
- 之后,当进程第一次访问某段虚拟地址时:
- CPU 通过页表发现该虚拟页还未映射到物理内存;
- 触发 缺页中断;
- 操作系统的 内存管理模块 负责:
- 申请合适的物理内存页;
- 从可执行文件或交换分区中加载所需数据;
- 更新页表,填入物理页帧地址,并设置有效位。
这些过程全部由操作系统完成,进程本身既不需要感知,也不需要管理。
每一个进程都有进程地址空间,这是操作系统层面做的工作,和编程语言无关,正式因为页表和地址空间的存在,实现了进程管理和内存管理在软件层面的解耦
6.3 进程的再定义
综合来看,一个进程可以重新定义为:
- 进程 = 内核数据结构(
task_struct
+mm_struct
+ 页表) + 代码和数据
其中:
task_struct
:进程控制块(PCB),记录进程的标识、状态、调度信息等;mm_struct
:描述进程的虚拟地址空间结构;页表
:实现虚拟地址到物理地址的映射。
6.4 进程切换的本质
进程切换时,不仅仅要切换PCB,还包含 地址空间和页表的切换:
-
task_struct
一旦切换,→task_strcut
所匹配的地址空间mm_struct
自动被切换 -
页页表的基地址存放在 CR3 寄存器 中,属于进程的上下文数据,当 CPU 切换进程的上下文数据时,CR3 被更新,进程的页表也随之切换。
因此,本质上:
👉 只要 CPU 切换了进程上下文数据(包括寄存器、CR3),那么该进程的 PCB、地址空间、页表就一并切换了。
6.5 进程独立性的体现
进程的独立性体现在两个层面:内核数据结构层面和代码和数据层面
- 内核数据结构层面
- 每个进程都有独立的 PCB(
task_struct
)、地址空间(mm_struct
)、页表; - 父子进程也各自维护独立的数据结构。
- 因此内核数据结构层面,进程间是互相独立的
- 每个进程都有独立的 PCB(
- 代码和数据层面
- 在虚拟地址空间中,每个进程看到的地址是连续且统一的;
- 在物理内存中,不同进程的代码和数据可以存放在完全不同的位置;
- 页表实现了“虚拟地址一致,物理地址独立”的效果:在页表层面上,不同进程的虚拟地址可以完全一样,但物理地址可以完全不一样,只需要将页表中的虚拟地址映射到物理内存的不同位置,每个进程的代码和数据就互相解耦了。
- 父子进程只需让页表中的代码区指向一样,数据区指向不一样,就实现了父子进程数据层面的解耦
- 父子进程可以共享同一段代码区(只读共享),
- 而数据区则指向不同的物理页,从而实现数据的解耦。
- 当一个进程异常终止时,只需释放它独有的
task_struct
、mm_struct
和页表,就能保证该进程完全退出,而不影响其他进程。 - 至于进程代码和数据在物理内存中加载的具体位置,何时加载、何时释放,都不需要进程关心,完全交由操作系统的内存管理模块负责。
- 有了页表的映射,可以把代码和数据在物理内存的任何地方存放(同一个进程在物理内存中的地址是乱序的),但左侧的虚拟地址是线性的,可以呈现给进程连续线性的空间,这就叫做以统一的视角管理所有进程的代码和数据。
6.6 统一的视角与乱序的物理内存
得益于页表的存在:
- 程序在物理内存中加载到什么地方,什么时候加载,根本不重要了,可能是 乱序分布 的,分散在不同物理页帧中;
- 但在虚拟地址空间里,操作系统可以给进程提供一个 连续线性、统一的视角。
这就是“虚拟内存”的本质:把无序变成有序,让进程觉得自己独享一块完整、连续的内存空间。
7. 验证命令行参数和环境变量的地址比栈的地址高
可以看到,内存分区中命令行参数和环境变量的地址是在栈的地址的上面,我们使用程序来验证他们之间的地址关系:
验证程序:
int g_val_1; // 未初始化全局变量
int g_val_2 = 100; // 已初始化全局变量
int main(int argc, char* argv[], char* env[]) {printf("code addr: %p\n", main);const char* str = "hello world"; // 字符串常量printf("read only string addr: %p\n", str);printf("init global value addr: %p\n", &g_val_2); // 已初始化全局变量printf("uninit global value addr: %p\n", &g_val_1); // 未初始化全局变量static int static_int = 100;printf("static local value addr: %p\n", &static_int); // static 修饰的局部变量 存储在全局数据区char* mem = (char*) malloc(100); // 堆区的数据char* mem1 = (char*) malloc(100); // 堆区的数据char* mem2 = (char*) malloc(100); // 堆区的数据printf("heap addr: %p\n", mem); // 打印堆区地址printf("heap addr: %p\n", mem1); // 打印堆区地址printf("heap addr: %p\n", mem2); // 打印堆区地址// 打印栈区的地址printf("stack addr:%p\n", &mem);printf("stack addr:%p\n", &mem1);printf("stack addr:%p\n", &mem2);// 打印命令行参数和环境变量的地址int i = 0;for (; argv[i]; ++i)printf("argv[%d] addr: %p\n", i, argv[i]);for (i = 0; env[i]; ++i)printf("env[%d] addr: %p\n", i, env[i]);return 0;
}
运行结果如下:
-
可以看到,命令行参数和环境变量的地址比栈的地址更高
- 环境变量的地址比命令行参数的地址更高
-
编译器视角下,变量名只在编译阶段存在,帮助分配地址;生成目标文件后,只剩下地址或偏移量,不再有“变量名”。
-
子进程继承环境变量的原因
- 环境变量就是一块普通的内存区域,保存在进程地址空间里。
- fork后,子进程会复制父进程的地址空间和页表;
- 父子进程最初共享同一份物理页,通过写时拷贝保证读写隔离;
- 因此子进程自然能继承父进程的环境变量。
8. 结语
进程地址空间不仅是 Linux 内存管理的基石,更是进程独立性与系统安全性的体现。通过虚拟地址、页表和写时拷贝等机制,操作系统实现了 进程视角的统一、进程间的隔离,以及进程管理与内存管理的解耦。
理解这些概念,不仅能帮助我们更好地掌握系统编程的底层原理,也能为后续深入学习 进程调度、内存管理优化、操作系统内核设计 打下坚实基础。
以上就是本文的所有内容了,如果觉得文章对你有帮助,欢迎 点赞⭐收藏 支持!如有疑问或建议,请在评论区留言交流,我们一起进步
分享到此结束啦
一键三连,好运连连!
你的每一次互动,都是对作者最大的鼓励!
征程尚未结束,让我们在广阔的世界里继续前行!
🚀