10.进程地址空间(初步认识)
文章目录
- 10.进程地址空间(初步认识)
- 一、进程地址空间的实验现象解析
- 二、进程地址空间
- 三、虚拟内存管理
- 补充:数据的写时拷贝(浅谈)
- 补充:页表(浅谈)
- 补充:关于地址空间mm_struct初始化问题(浅谈)
- 四、为什么要有虚拟地址空间
- 五、总结
一、进程地址空间的实验现象解析
这里有一个之前我们验证进程之间的独立性的代码,现在在此基础上做一些改动,得到以下带代码:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>int g_value = 100; // 全局变量定义
int main()
{printf("父进程 PID:%d PPID:%d\n", getpid(), getppid()); // 获取进程信息 pid_t id = fork(); // 创建子进程 if (id < 0) return -1; // 错误处理 if (id == 0) {// 子进程逻辑while(1) {printf("子进程 PID:%d PPID:%d g_value:%d 地址:%p\n",getpid(), getppid(), g_value++, &g_value); // 修改变量 sleep(1); // 延时控制 }} else {while(1){// 父进程逻辑printf("父进程 PID:%d PPID:%d g_value:%d 地址:%p\n",getpid(), getppid(), g_value, &g_value); // 保持原值 sleep(1);}}return 0;
}
运行结果:
[lisihan@hcss-ecs-b735 lession14]$ gcc -o code1 code1.c
[lisihan@hcss-ecs-b735 lession14]$ ./code1
父进程 PID:31004 PPID:27811
父进程 PID:31004 PPID:27811 g_value:100 地址:0x601054
子进程 PID:31005 PPID:31004 g_value:100 地址:0x601054
父进程 PID:31004 PPID:27811 g_value:100 地址:0x601054
子进程 PID:31005 PPID:31004 g_value:101 地址:0x601054
父进程 PID:31004 PPID:27811 g_value:100 地址:0x601054
子进程 PID:31005 PPID:31004 g_value:102 地址:0x601054
父进程 PID:31004 PPID:27811 g_value:100 地址:0x601054
子进程 PID:31005 PPID:31004 g_value:103 地址:0x601054
子进程 PID:31005 PPID:31004 g_value:104 地址:0x601054
父进程 PID:31004 PPID:27811 g_value:100 地址:0x601054
子进程 PID:31005 PPID:31004 g_value:105 地址:0x601054
父进程 PID:31004 PPID:27811 g_value:100 地址:0x601054
父进程 PID:31004 PPID:27811 g_value:100 地址:0x601054
实验结果特征:
-
父进程持续输出原始值:
父进程 PID:1001 PPID:999 g_value:100 地址:0x601044
-
子进程输出递减序列:
子进程 PID:1002 PPID:1001 g_value:101 地址:0x601044
-
地址显示完全相同但值独立变化
- 变量内容不⼀样,所以⽗⼦进程输出的变量绝对不是同⼀个变量
- 但地址值是⼀样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做虚拟地址
- 我们在⽤C/C++语⾔所看到的地址,全部都是虚拟地址!物理地址,⽤⼾⼀概看不到,由OS统⼀管理。OS需要把虚拟地址转化为物理地址
二、进程地址空间
首先要了解一个概念,就是内存布局:
如图所示,如果从低地址到高地址,我们整个程序的内存在布局情况,包括正文部分、初始化数据、未初始化数据、堆区、栈区。我们的程序地址空间布局是依照这张图展开的。它不叫程序地址空间,它全称应该叫做进程地址空间,所以它不属于语言范畴,它属于系统范畴,这是属于系统方面的概念。
我们也可以用一个简单的代码来验证上面的结论:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>int unval;
int gval = 100;int main()
{printf("code addr: %p\n", main);printf("gval addr: %p\n", &gval);printf("unval addr: %p\n", &unval);int *mem = (int*)malloc(10*sizeof(int));printf("heap add: %p\n", mem);int a,b,c;printf("stack addr : %p\n", &a);printf("stack addr : %p\n", &b);printf("stack addr : %p\n", &c);
}
运行结果:
[lisihan@hcss-ecs-b735 lession14]$ gcc -o code2 code2.c
[lisihan@hcss-ecs-b735 lession14]$ ./code2
code addr: 0x40057d
gval addr: 0x60103c
unval addr: 0x601044
heap add: 0x20bc010
stack addr : 0x7ffeeb9704b4
stack addr : 0x7ffeeb9704b0
stack addr : 0x7ffeeb9704ac
可以看到他们的地址数,整个地址是依次增大的。很明显,堆和栈之间,中间有一大块的镂空。所以,我们的程序地址空间布局是依照这张图展开的。
这幅图,遵循《深入理解计算机系统》等权威教材,自底向上进行绘制,认为低地址在下方,高地址在上方。以前学到的进程地址空间,可能只考虑了部分区域,甚至像共享环境变量这样的部分也可能没见过。
所以之前说‘程序的地址空间’是不准确的,准确的应该说成 进程地址空间 ,那该如何理解呢?看图:
图二
-
上⾯的图就⾜矣说明问题,同⼀个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址
-
对虚拟地址的理解:
-
当一个程序在用户态运行时,操作系统会为其创建一个进程(process)实例,并为此进程“画一张大饼”——即分配一个虚拟地址空间,使其认为自己独占整个物理内存。多个进程间彼此隔离,互不干扰;即便父子进程共享同一份代码和数据,其后续修改也会触发写时拷贝,确保各自独立。
-
用户态代码访问虚拟地址时,CPU 先查询当前进程的页表,将虚拟地址转换为物理地址,然后读写实际内存。用户和应用程序感知到的永远是虚拟空间,物理空间由操作系统统一管理。
-
三、虚拟内存管理
在操作系统中,管理进程的虚拟地址空间是其核心职责。具体的管理方式始于对该空间本身的精确刻画,即操作系统需要为每个运行中的程序定义一个专属的描述结构。这种描述随后被整合进程序的核心控制信息块中。
本质上,这个虚拟地址空间是操作系统内核维护的一种关键数据结构。当创建一个程序时,其核心控制信息块内会包含一个指向其物理内存使用情况的引用。为了避免程序直接操作物理内存地址,在一个进程的task_struct
中,操作系统在程序与物理内存之间引入了一个中间数据结构,其类型通常命名为 mm_struct
。该结构虽然内部复杂,但宏观上承担此角色。程序的核心控制信息块内部维护着一个特定指针,该指针直接关联到当前程序对应的专属地址空间描述结构。这意味着每个程序都拥有自己独立的、由操作系统定义的页表。
struct task_struct
{
/*...*///对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。struct mm_struct *mm; // 该字段是内核线程使⽤的。当该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。struct mm_struct *active_mm;
/*...*/
}
操作系统为每个程序构建好这个专属的页表(如图2所示)后,程序在运行过程中始终通过它来访问内存。当程序需要读写内存时,最终都必须经由自身这个页表结构来完成访问操作。
struct mm_struct
{/*...*/struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */struct rb_root mm_rb; /* red_black树 */unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的⼤⼩*//*...*/// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack;unsigned long arg_start, arg_end, env_start, env_end;/*...*/
}
可以说,mm_struct结构是对整个⽤⼾空间的描述。每⼀个进程都会有⾃⼰独⽴的mm_struct,这样每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。
进程的地址空间分布情况:
虽然操作系统通过这个结构体让程序感觉自己独占了巨大的连续内存范围,但程序实际能使用的物理内存量通常远小于此视图范围,并且过量申请会被系统限制或终止。操作系统提供这个页表的承诺,但并不保证其全部空间都必然映射到物理资源。
补充:数据的写时拷贝(浅谈)
fork 创建子进程后,父子进程共享同一份物理页,页表条目被标记为只读。只有在任一进程对共享页执行写入操作时,触发页错误中断,内核才会分配新物理页并复制原内容,修改对应进程的页表映射,以实现各自独立。
通俗来讲:之前提到过,进程具有独立性即使是父子关系的进程也是如此,又因为进程 = PCB + 代码和数据,当创建子进程时,OS会拷贝一份内核进程控制块(PCB),代码子进程和父进程共享,只有当子进程或父进程对数据进行写入操作的时候,数据才会从新拷贝一份给两个进程,换言之如果一份数据对于父子进程都是只读的,就不会拷贝,父子进程公用一套数据。(这里只是简单说个大概,后面会详谈)
补充:页表(浅谈)
页表不只有虚拟地址与物理地址的映射关系,还有一些标志位,比如说读写权限的标志位。
-
如果有一些物理地址的内容是只读的,当进程以写的权限去访问这块物理地址的时候发现不匹配,操作系统会直接清除掉这个进程。
在学C语言的时候有一个经典例子:
char *str = "hello linux"; *str = 'H'
这段代码在编译的时候不会有任何报错,但是运行的时候就会直接崩溃。原因在与str指向的是一个字符串,这个字符串储存在字符常量区,这个区域是只读的,不可以进行写入操作,但在编译的时候编译器是无法判断进程运行时的错误的,所以编译没有报错。我们写好的程序运行之后都是一个一个的进程,根据上面所学的页表的读写权限的标志位也就不难理解出现这种情况的原因了。
野指针也会触发这种情况:野指针指向的地址的访问权限与页表中对应物理地址的权限不匹配或者野指针指向的地址空间在页表中根本不存在,都会使得程序无法正常运行。
查找资料:
权限位(RWX):标记内存区域的可读、可写、可执行属性。例如代码区设为只读(R-X),尝试写入会触发操作系统干预,直接终止进程。这解释了C语言中修改字符串常量导致崩溃的底层原因——字符常量区被映射为只读,
const
关键字本质是编译器层面的辅助检查,而真正的保护由页表硬件机制实现。 -
当一个进程创建的时候,先加载的是内核中的PCB,然后才开始慢慢加载代码和其他数据,这也符合我们之前对进程的理解“先描述,再组织”。所以就可能会存在内核数据结构已经加载完成了,但是代码和数据还没有加载到内存中的情况。还有一种情况,就是之前在进程状态中提到的阻塞挂起状态,此时若当前进程处于阻塞状态,其代码和数据占用内存但无实际意义,操作系统会将该进程的部分代码和数据换出至磁盘。此时也会出现代码和数据还没有加载到内存中的情况。
页表中还有一个标志位可以检查目标内容是否在内存中,方便在进程运行之前把内容加载进来
查找资料:
存在位(Present Bit):指示目标数据是否在物理内存中。若为0,表示数据尚未加载或已被换出到磁盘(如Swap分区)。此时访问会触发“缺页异常”,操作系统负责将所需数据从磁盘调入内存,更新页表后恢复进程执行。此机制支撑了按需加载(Demand Paging):大型程序(如游戏)启动时并非全部载入内存,仅加载必要部分,后续访问时动态调入,极大提高了物理内存利用率,使小内存运行大程序成为可能。
补充:关于地址空间mm_struct初始化问题(浅谈)
进程数据结构中的mm_struct结构体主要用于管理进程的内存空间。每个进程都有一个对应的 mm_struct
实例,它包含了关于该进程虚拟地址空间的所有必要信息。所以本质上这个结构体仍然需要初始化,以代码区为例,比如初始化一个地址空间,代码区有自己的数据结构。在mm_struct
中,有start code
和end code
表示代码区域的开始和结束,从而划分出区域。进程加载时需要创建PCB和地址空间,地址空间和PCB由操作系统初始化。地址空间内有多个属性,这些属性如何初始化?
//完整定义包含关键字段:
struct mm_struct {struct vm_area_struct *mmap; // 内存区域链表pgd_t *pgd; // 页全局目录atomic_t mm_users; // 用户计数atomic_t mm_count; // 引用计数unsigned long start_code; // 代码段起始unsigned long end_code; // 代码段结束unsigned long start_data; // 数据段起始unsigned long end_data; // 数据段结束unsigned long start_brk; // 堆区起始unsigned long brk; // 堆区当前边界unsigned long start_stack; // 栈区起始unsigned long arg_start; // 参数区起始unsigned long arg_end; // 参数区结束unsigned long env_start; // 环境变量起始unsigned long env_end; // 环境变量结束
};
首先介绍一个命令(工具)readelf:
readelf
是一个强大的工具,用于显示 ELF(Executable and Linkable Format)文件的信息。ELF 文件是一种常见的二进制文件格式,在 Linux 系统中广泛使用,包括可执行文件、共享库和目标文件。readelf
可以帮助开发者和系统管理员检查这些文件的内容和结构。
简单来说,readelf可以读取一个可执行文件的每一个分段信息,其他的功能这里暂时不说,从这里可以知道:
- 虚拟地址空间的初始化依赖于可执行程序的ELF格式特性:编译器在生成可执行文件时,已按功能将程序划分为多个逻辑段(Section),包括存储机器指令的代码段(
.text
)、存放字符串常量的只读数据段(.rodata
)、保存已初始化全局变量的数据段(.data
)以及未初始化数据段(.bss
)。 - 通过
readelf -S
命令可解析ELF文件头信息,获取各段关键属性:1) Size字段定义段大小(如代码段16字节);2) Addr字段标识段在虚拟地址空间的预期起始位置;3) Flags权限标记(R
/W
/X
组合)声明段的内存访问规则。
四、为什么要有虚拟地址空间
- 地址空间和⻚表是OS创建并维护的!也就意味着凡是想使⽤地址空间和⻚表进⾏映射,也⼀定要在OS的监管之下来进⾏访问!也顺便保护了物理内存中的所有的合法数据,包括各个进程以及内核的相关有效数据!
- 因为有地址空间的存在和⻚表的映射的存在,我们的物理内存中可以对未来的数据进⾏任意位置的加载!物理内存的分配 和 进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合
- 因为有地址空间的存在,所以我们在C、C++语⾔上new, malloc空间的时候,其实是在地址空间上申请的,物理内存可以甚⾄⼀个字节都不给你。⽽当你真正进⾏对物理地址空间访问的时候,才执⾏内存的相关管理算法,帮你申请内存,构建⻚表映射关系(延迟分配),这是由操作系统⾃动完成,⽤⼾包括进程完全0感知!!
- 因为⻚表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进⾏映射,在进程视⻆所有的内存分布都可以是有序的。
简单表述:
这种设计实现三项关键优势:
-
空间效率:避免一次性加载整个程序;
-
安全隔离:页表基于
mm_struct
配置的权限拦截非法访问(如向代码段写入); -
动态扩展:BSS段等未初始化区域仅预留虚拟地址范围,实际物理页在首次访问时分配。最终,进程通过专属的"内存视图"(
mm_struct
)访问物理内存,而操作系统通过维护编译器预设的段属性与硬件协作,实现了虚拟地址空间的动态管理与安全控制。
了解上述内容,我们可以回答一下问题:
Q:为什么在程序中的全局变量、字符常量具有全局性,在程序运行期间都会有效?
A:全局变量、字符常量一般存放在已初始化数据区、未初始化数据区,它不像栈和堆一样会在进程运行期间创建和销毁,在之前讲的进程地址空间可知,进程地址空间与物理地址的映射关系在进程存在期间一直存在,所以这部分地址的映射关系不会改变,随着进程一直存在,且全局变量的地址可以被整个程序使用。
Q:为什么父进程的环境变量能够被子进程继承?
A:因为在进程内存空间中命令行参数与环境变量也有一块区域用于存放他们的数据,父进程创建子进程的时候会写时拷贝到子进程,因此子进程可以继承父进程的环境变量。
五、总结
AI生成
本文介绍了进程地址空间的基本概念,通过实验展示了父子进程共享相同虚拟地址但实际物理地址不同的现象,解释了虚拟地址与物理地址的区别。文章详细分析了进程地址空间的内存布局结构(包括代码段、数据段、堆栈等区域),并通过代码示例验证了各段地址的分布规律。重点阐述了操作系统如何通过mm_struct结构体管理进程的虚拟地址空间,以及页表机制在虚拟地址到物理地址转换中的作用。最后指出操作系统的内存管理机制使每个进程都拥有独立的地址空间视图,保证了进程间的隔离性。