引入

先来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int main()
{
pid_t id = fork(); //调用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("child 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);
}
}
}

运行结果:

很奇怪,在父子进程分别运行后,g_val看似是两个进程都有的独立个体,但&g_val却又是一样的。这与现学的知识很矛盾,如果g_val地址相同,那么就意味着在子进程中的改变也会影响父进程,但事实并未如此,二者从变化上看又是独立的。
怎么会同一地址,同一变量,父子进程分别读取到了不同的内容呢?

这是因为&g_val是虚拟地址而非物理地址。

平时我们写的指针,也不是物理地址,而通通都是虚拟地址。
那么虚拟地址又是如何转换为物理地址的呢?
这就是此篇文章的主题了。

地址空间

1和0

计算机中的数据和指令是以二进制形式(0和1)表示的。在数字电子电路中,这两个状态(0和1)通常由高电平(High Level)和低电平(Low Level)来表示。

高电平(1):当电信号处于高电平状态时,这通常表示逻辑上的1。在数字电路中,高电平可以代表“真”(true)状态。在通信系统中,高电平代表“1”比特。
低电平(0):当电信号处于低电平状态时,这通常表示逻辑上的0。在数字电路中,低电平可以代表“假”(false)状态。在通信系统中,低电平代表“0”比特。

这种二进制的表示方式基于数字电路的特性,其中电子元件(如晶体管和集成电路)的工作原理被设计成可以区分高低电平。这种二进制表示方法是计算机内部数据处理和存储的基础,也是现代计算机系统中所有信息的基础。计算机内部的所有操作,包括数据的处理、存储和传输,都可以通过高低电平来实现。

在一个32位计算机中,通常会有32根数据线和32根地址线。每一根线都只有以高低电平的形式来传输1或者0

32根数据线:这表示计算机可以一次性处理32位(4字节)的数据。每根数据线可以传输一个二进制位(0或1)。
32根地址线:这表示计算机可以寻址2^32个不同的内存位置,每个内存位置都可以存储一个数据单元(通常是1字节)。

这样的设计使得计算机可以处理32位宽的数据块,同时也能够访问大约4GB的内存空间(2^32个地址位置)。

地址空间概念与区域划分

Linux系统中的地址空间通常是一个4GB的虚拟地址空间,它用于给每个进程提供一个独立的内存地址范围。这个虚拟地址空间被划分为几个不同的区域,每个区域有不同的用途

也就是说地址空间可以理解为一个范围,在32位计算机里,地址总线的排列组合范围就是空间的范围,也就是[0,2^32]。
划分的概念也很好理解,就像在上小学时,你有没有和同桌互相划过界限呢?linux中的区域划分也可以如此类比,线的两边是两个不同的同学,linux里线的两边就是两个不同用途的区域。

进程地址空间

进程地址空间是地址空间的一个实例,特定于每个运行的进程。它是操作系统提供的虚拟内存管理的核心组成部分,用于将进程的数据和指令存放在内存中,并提供内存保护、地址映射、隔离等功能。每个进程的地址空间在逻辑上独立,允许多个进程并发执行,而它们各自拥有自己的地址空间。
进程地址空间用于描述一个正在运行的进程在内存中的布局和管理方式。每个进程都有其自己的地址空间,它是虚拟内存的抽象表示,允许进程访问物理内存上的数据和指令。
本质上,进程地址空间就是在描述一个进程的可视范围的大小。同时在空间内必须要进行区域的划分
进程地址空间通常包括以下组成部分:

代码段(Text Segment):也称为可执行段,包含进程的可执行指令。这是程序的机器代码,通常是只读的,因为它应该在运行时不被修改。
数据段(Data Segment):用于存储进程的全局和静态变量。这包括初始化的全局变量和静态变量,通常是可读写的。
堆(Heap):用于动态分配内存,通常在运行时通过函数(如malloc和free)来进行内存分配和释放。堆的大小通常是可变的,取决于进程的需求。
栈(Stack):用于存储函数调用的局部变量和函数的调用信息。栈是一个后进先出(LIFO)数据结构,用于跟踪函数的调用和返回。栈的大小通常是固定的或者由操作系统动态管理。
堆栈区域之间的未映射空间(Unmapped Area Between Heap and Stack):这是两者之间的空闲区域,用于防止堆和栈之间的溢出。
内核空间(Kernel Space):这是由操作系统控制的部分内存,包含操作系统内核的代码和数据。用户进程不能直接访问内核空间,而必须通过系统调用来请求内核执行操作。

在这里简单画图表示一下。

每个进程的地址空间都是虚拟的,它将虚拟地址映射到物理内存上,这一步由操作系统的内存管理单元(MMU)来完成。MMU负责将虚拟地址转换为物理地址(中间会用到页表),同时实现了内存保护和隔离,确保一个进程无法访问或干扰其他进程的地址空间,这一点会在下面进行解释。

通过上图可以看出,进程的PCB中有指向进程地址空间的指针,进程地址空间中也进行了区域的划分(进程地址都是虚拟的)。同时,进程地址空间本质上也是一个内核数据对象,也是像PCB一样是需要先描述在组织的。
此时,我们可以更新进程的概念。

进程本质上是内核数据结构(task_struct and mmu_struct and 页表 )加程序代码与数据。

进程地址空间的概念允许多个进程同时运行,每个进程都有自己的独立地址空间,因此它们之间不会相互干扰(可以思考一下如何做到的)。这是操作系统中多任务处理和内存保护的核心原则之一。

说了这么多,os是如何实现虚拟到物理的地址转换,以及这样做的意义是什么呢?
地址转换需要页表的助力。

在这里简单的介绍一下页表。
页表是一种用于虚拟内存管理的关键数据结构,它用于将进程的虚拟地址映射到物理内存地址
虚拟内存是每个进程独立的地址空间,而物理内存是系统上的实际硬件内存。
每个进程都有自己的虚拟地址空间,它由连续的虚拟地址组成,通常从0开始,直到进程的地址空间大小的上限。进程使用虚拟地址来访问内存,而不需要知道物理内存的实际位置。
虚拟地址空间和物理内存都被划分为固定大小的块,这些块称为"页"。在x86架构中,页的大小通常是4KB。
页表则是一种数据结构,它将虚拟地址映射到物理地址。每个进程都有自己的页表,用于跟踪虚拟地址到物理地址的映射关系。
要将虚拟地址转换为物理地址,操作系统需要逐级定位到目标虚拟地址的页表项。一旦找到,就可以通过映射关系获得最终的物理地址。

1
2
3
4
5
6
7
8
+-----------------------+    +-----------------+
| Virtual Address Space | -> | Page Table Entry |
+-----------------------+ +-----------------+
| Frame Number |
+-----------------+
| Flags (e.g., R/W)|
+-----------------+

上图右边表示的是页表中的一条,有的图示会选择横向展示。

“Virtual Address Space” 表示进程的虚拟地址空间。
“Page Table Entry” 表示页表的一个条目,用于将一个虚拟页映射到一个物理页帧。
“Frame Number” 表示物理内存中的页帧号。
“Flags” 表示一些标志位,比如读/写权限。
在Linux系统中,页表使用多级页表结构,通常是二级或三级页表。这种结构可以更有效地管理大量的虚拟地址空间和物理内存。

简单来说,当一个进程访问一个虚拟地址时,操作系统会使用页表来查找虚拟地址对应的物理地址。如果该虚拟地址在页表中找到了对应的物理地址,就会完成地址转换;如果没有找到,就会触发一个页错误,操作系统会加载缺失的页面到物理内存中,然后更新页表。

进程的 PCB(进程控制块)通常包含了指向该进程虚拟地址空间的指针。而要找到页表的对应条目就需要借助虚拟地址空间。
当进程访问一个虚拟地址时,CPU将该地址拆分为两部分:虚拟页号(VPN)和页内偏移
虚拟页号(VPN)被用作索引,以访问页表。操作系统使用虚拟页号来查找对应的页表条目。
在页表中,每个页表条目包含了虚拟页号到物理页帧的映射信息。通过虚拟页号,操作系统可以找到对应的页表条目。
页表条目中包含了指向物理内存中实际数据的页帧号。使用页内偏移,操作系统将页帧号和页内偏移组合在一起,以计算出虚拟地址对应的物理地址。

下图简单的表示了PCB,虚拟地址,以及物理内存,页表间的关系。当然,页表对于虚拟物理地址之间的转化不会这么简单,具体的会在以后提到。

引入虚拟地址空间使得进程可以以统一的视角看待内存。

  1. 每个进程都有自己的虚拟地址空间,使得进程之间的内存相互隔离。这意味着一个进程不能直接访问另一个进程的私有数据,从而提高了系统的安全性和稳定性。
  2. 程序员可以编写与物理内存无关的代码。程序员不需要担心物理内存的具体位置,而是可以专注于逻辑结构和算法的设计。这种抽象层简化了程序的开发过程。
  3. 虚拟地址空间允许操作系统更灵活地管理物理内存。它可以动态地调整虚拟页到物理页的映射,实现更高效的内存利用。
  4. 虚拟地址空间允许不同的进程共享相同的物理内存,从而支持共享数据和通信。这在多进程协作和进程间通信中是非常有用的。我目前能想到最贴合这个观点的就是写时拷贝,比如调用fork函数,就有了父子进程分开运行的实例,在拷贝子进程时,其页表项仍然指向父进程的物理内存,等到需要更改子进程的某个数据时,才会另外开辟空间,这大大的提高了工作效率。
  5. 程序可以被加载到虚拟地址空间的任意位置,而不需要考虑物理内存的可用性。这使得程序的加载和链接更加灵活。
  6. 虚拟内存系统可以使用页面置换算法来优化物理内存的使用,提高整体系统性能。

再单独讲一讲页表的权限条目,引入权限的概念使得让我们访问内存时,会增加一个转化的过程,在这个过程中,os会对我们的操作进行审查,一旦出现访问异常(比如该数据只能读不能写,你却非要写入),就会引发异常,直接拦截。
总的来说,虚拟地址空间提供了一个抽象层,使得程序员和操作系统能够更方便地进行内存管理,同时实现了进程之间的隔离和保护。这种统一的视角简化了系统的设计和程序的开发,同时也提供了更好的性能和灵活性。