Linux进程地址空间

文章探讨了C/C++程序中的地址空间概念,指出它实际上是虚拟地址空间而非物理内存地址。通过一个fork示例,展示了进程间的独立性,即进程地址空间的改变不会互相影响。操作系统为每个进程分配虚拟地址空间,进程无法直接访问物理地址,保证了安全性。地址空间通过页表与物理内存关联,实现了进程间的数据隔离和独立性,同时也方便了编译器的工作。写时拷贝技术用于维护进程独立性,确保修改只影响单个进程的地址空间。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

C/C++地址空间

image.png
我们在学习C/C++的时候一定见过这种图,叫做C/C++地址空间,我们创建的变量在那个区,new的对象在那个区等等。
但是现在有一个问题,这个地址空间是内存嘛?
下面的代码可以帮我们验证一下。
image.png
这段代码在fork前只有父进程,fork创建子进程之后就是父进程和子进程同时跑了,但是父子进程谁先运行时随机的,由Linux的调度器来决定的。

这段代码的逻辑就是,fork后,子进程打印自己的PID,PPID,全局变量g_val,和g_val的地址。运行10s后修改g_val的值。父进程就一直打印自己的PID,PPID,全局变量g_val,和g_val的地址。

image.png
在g_val没修改的时候,运行很正常,但是当子进程将g_val修改之后我们发现,子进程打印的g_val确实变了,但是父子进程中g_val的地址是一样的。这是符合进程的独立性的,子进程的改变不会影响到父进程。

现在的情况就是同一地址空间上的变量的值不同。

那么此时也就说明了g_val的地址一定不是物理地址,因为物理地址如果一样的话,他们的值一定是一样的。所以g_val的地址在Linux下叫做虚拟地址。也可以叫做线性地址,逻辑地址。这三个地址在Linux下是一样的,但是在其他情况下的意义可能不同。

因此我们以前学习的语言级别的地址,指针等都不是物理地址。

所以C/C++地址空间不是内存而是虚拟地址空间

进程地址空间

进程独立性解释

每个进程都认为自己是独占系统资源的(但实际并不是)
进程的独立性也说明了,进程之间是相互独立的,也就是说进程是看不到其他进程的,他认为只有自己在使用所有的内存和cpu资源。
内存资源
image.png

操作系统给每个进程都从内存中分配了一个进程地址空间,进程地址空间里面的地址都是虚拟地址,在32位系统下是4GB,每个进程都认为自己独占这4GB,实际上每个进程在申请空间的时候根本申请不到这么大的空间,操作系统可以拒绝你的申请请求。

如何更好的理解进程地址空间,在这里有一个富豪(操作系统)他有四个亿(内存),现在他有三个私生子(进程),他的私生子之间互相不知道对方的存在,富豪对三个私生子的说明都是儿子你是唯一的继承者,所以这三个儿子都认为自己独占所有的资源。每次需要的时候就向富豪申请要多少钱(内存空间)但是不会说一次性要四个亿,就算你要了,富豪也可以拒绝,并且儿子也不会怀疑这四个亿是不是不属于他了。所以富豪给每个儿子画饼的这四个亿,就是进程地址空间。

CPU资源
进程认为自己独占系统资源其中当然包括CPU,当进程运行的时候,只有一个进程在CPU上跑,此时进程当然认为自己是独占CPU的,当进程的时间片结束,从CPU上剥离下来的时候此时进程从运行状态变成了休眠状态,变成休眠状态的进程就不知道外面发生了什么了,所以进程是不知道自己被从CPU下剥离下来了。当时间片再次轮转到这个进程的时候,这个进程就会被唤醒继续运行。

操作系统怎么描述进程地址空间

进程有很多,每个进程对应了一个PCB,也对应了一个进程地址空间,进程被管理的方式是先描述再组织,那么进程地址空间被管理的方式同样是先描述再组织。
进程地址空间的本质:是内核的一种数据结构 (struct mm_struct)
描述进程地址空间的规则:

  1. 地址空间描述的基本大小是字节
  2. 32位下总共有2^32个字节,也就是4GB
  3. 每个字节都要有唯一的地址,所以虚拟地址有2^32个
  4. 所以使用unsigned int 就可以标识这里所有的地址

image.png
所以整个地址空间已经被分成了2^32个小区域,我们在对进程地址空间进行描述实际就是对区域进行划分,比如划分代码区是[0x1111 1111 , 0x2111 1111] 全局变量区[0x2111 1112 , 0x3111 1111]等等。
因此在描述地址空间的结构体中,需要表现出每个区域划分的开始和结束

struct mm_struct
{
	unsigned int code_start, code_end;
	unsigned int data_start, data_end;
	unsigned int heap_start, heap_end;
	unsigned int stack_start, stack_end;
	....
}

上面的mm_struct只是演示简化了内容,实际Linux源码中的mm_struct如下:
image.png
注意下面的这部分,内容的描述和我们举例大致相同。
在PCB中是存在一个struct mm_struct *类型的指针变量的,这个指针变量指向的就是这个进程对应的地址空间,方便CPU执行该进程时通过PCB找到指令的虚拟地址,再转到物理地址执行指令。
image.png

区域划分就是通过设定好的start和end值来将进程地址划分出各个区域。

因为堆区时向上增长的,栈区是向下增长的,所以区域划分完之后还要可以调整。
区域调整实际就是通过修改start和end值实现,比如堆区向上增长就可以让堆区的end变大。栈就可以让start变小来实现向下增长。

我们实际的代码中也必然存在栈区和堆区的调整,比如:定义局部变量或者是new,都会增大栈区和堆区。
函数调用完毕局部变量释放,delete变量都会缩小栈区和堆区。

进程地址空间与内存之间的关联

image.png
进程地址空间的虚拟地址有叫做线性地址,因为它是连续的,从0000 0000到FFFF FFFF。
磁盘:可执行程序在磁盘中就是一个文件
内存:将mv.exe加载到内存中,操作系统开辟了1k字节用来保存可执行程序的指令和数据。内存中的最小单位是页(page)每一页是4kb。
进程地址空间上的堆上的一个变量的虚拟地址是0x1234 5678是通过页表来和物理地址,也就是内存进行关联的。
进程在堆上开辟了一个空间后,在内存中会有一个物理地址,在进程地址空间上会有一个虚拟地址,将虚拟地址和物理地址都放入页表中建立映射,CPU访问的时候就可以通过虚拟地址映射访问到物理地址上的变量。
页表:实际页表并不是上图画的这么简单,如果页表像上面一样进行映射,一个虚拟地址需要四个字节保存,一个物理地址也需要四个字节,保存一个地址的映射就需要8个字节,那么如果建立所有虚拟地址的映射内存还不够页表使用的别说是进程的代码和数据了。
image.png
实际的页表是一个多级页表,通过页目录进行多级索引来减小页表的大小。很复杂不多讲。
image.png
内存中有很多进程,每个进程都是这样进行映射的。这就建立了进程地址空间和内存之间的映射关系。

进程地址空间的概念

进程地址空间是操作系统为了实现进程管理所设定的一种虚拟化的解决方案,通过进程地址空间可以让每个进程认为自己独占系统资源。

为什么要有进程地址空间

1.进程地址空间使得进程不能直接访问到内存上的物理地址,同样的CPU也无法直接访问物理地址。使得操作系统更加安全。

如果让进程直接访问物理地址是很危险的。
如果进程直接访问物理地址发生越界访问了呢?此时可能会将其他进程的数据修改,破坏了进程的独立性。
有了进程地址空间之后,如果进程进行了越界访问,那么越界的这个虚拟地址在页表进行映射的时候会被拒绝,这样就保护了其他进程的数据不会被非法修改。
如果让进程直接访问物理地址,恶意进程就可以直接通过物理地址将内存中所有的数据进行扫描,其中包含很多重要数据,比如你的登录名和密码。此时是非常不安全的。

2.进程地址空间的存在可以更方便的进行进程与进程之间数据和代码的解耦,保证了进程独立性这样的特征。

在开篇举例中,fork创建子进程之后,父子进程一开始打印的g_val其实是同一个g_val,因为创建子进程的时候也需要创建PCB和进程地址空间,子进程的创建一般都是拷贝父进程的。所以在没有修改g_val的时候,父子进程共用了内存上的g_val.
image.png
当我们对子进程的g_val进行了修改后,因为要保证进程的独立行,操作系统将父进程的g_val在内存中拷贝一份,然后更改子进程的页表映射,最后对子进程对g_val进行修改。这种技术叫做写时拷贝。
image.png
操作系统为了保证进程的独立性做了很多工作,通过虚拟地址空间,页表,让不同的进程映射到不同的物理地址处。

3.进程地址空间让进程以统一的视角来看待代码和数据等各个区域,方便编译器也以统一的视角来编译代码。因为遵循的地址规则是一样的所以编译完的代码可以直接用在进程地址空间上。

我们写的代码编译出来的可执行程序内部有没有地址?
答案是有的,因为我们在编译的时候,比如main函数调用了func函数,我们会将func函数的地址填入到调用main函数调用func指令的地方。同时我们查看汇编代码的时候也会发现也是有地址的。既然编译阶段都有地址,那么最后链接完地址也不会消失。
其实我们编译的时候就是按照虚拟地址空间的方式对代码指令进行编址的。
image.png
当我们的可执行程序以虚拟地址空间的方式编址好之后,每条指令都会有一个虚拟地址,这个虚拟地址在这里叫做**逻辑地址,**然后将mv.exe加载到内存之后,在内存中保存的指令都会具有一个物理地址,同时进程地址空间里的代码区因为也是虚拟地址所以直接使用逻辑地址填充即可。

CPU执行该进程的代码的时候首先将main函数的虚拟地址加载到寄存器,然后CPU通过页表映射到了物理地址,执行main函数的指令,然后将当前正在执行的指令的下一条指令的虚拟地址加载到eip寄存器,然后CPU取出下一条指令的虚拟地址继续执行。
所以从CPU内进出的地址都是虚拟地址,CPU不能直接访问物理内存地址。
CPU读入的指令地址都是虚拟地址,因为在执行指令的时候有的指令内部也会带有地址这个地址也是虚拟地址。

所以当我们的指令加载到内存之后是有两套地址

  1. 标识物理内存中的代码和数据的物理地址
  2. 在程序内部相互跳转的时候用的虚拟地址

实际我们的进程地址空间是如下的样子,并不是4GB都是给用户使用的。
image.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

KissKernel

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值