【Linux】进程地址空间

文章通过一个富翁与私生子的比喻介绍了进程地址空间的概念,指出每个进程都有一个独立的4GB虚拟地址空间,但实际上占用的物理内存远小于这个值。进程间的变量地址可能相同但值不同,是因为它们通过页表映射到不同的物理地址。malloc的本质是在进程的虚拟地址空间中分配空间,并在实际需要时分配物理内存,这种机制称为写时拷贝,提高了内存管理的效率和安全性。

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

📕 引入

执行下列代码,结果如下图。

 1 #include<stdio.h>
  2 #include<unistd.h>
  3 
  4 int main()
  5 {
  6     int val=100;
  7     pid_t ret = fork();
  8     if(ret == 0)
  9     {
 10         //子进程
 11         while(1)
 12         {
 13             printf("我是子进程, 我的pid是: %d, 我的父进程是: %d,val:%d,&val:%p \n", getpid(), getppid(),val,&val);           
 14             sleep(1);
 15         }
 16     }
 17     else if(ret > 0)
 18     {
 19         //父进程
 20         while(1)
 21         {
 22             printf("我是父进程, 我的pid是: %d, 我的父进程是: %d,val:%d,&val:%p \n", getpid(), getppid(),val,&val);
 23             sleep(1);
 24             val++;
 25         }
 26     }
 27     return 0;
 28 }

如下所示,定义的变量 val,父子进程里面的值不同,但是取地址却是相同的!!这足以说明,显示出来的 val 的地址绝对不是物理地址(同一个变量物理地址,不可能读到不同的值),那么它就只能是虚拟地址!!由此可以引出进程地址空间的概念!!
请添加图片描述

📕 进程地址空间

下面我们先看一个小故事。

假设有一个富翁,他有十亿的家产,但是这个富翁有10个私生子,每一个私生子都不知道彼此的存在,都以为自己是富翁唯一的儿子,理所当然地可以继承富豪的所有财产。每一位私生子都有着自己的工作,有的是理发师、有的是老师、创业者等等。这些私生子固然会向富豪要钱,比如私生子A向富翁要10万块,富翁觉得这点钱不算什么,直接给私生子A了;私生子 B 想向富豪要一千万,富豪觉得一次性给这么多不合适,于是理所当然地拒绝了,私生子B也觉得没什么,毕竟以后可以继承富豪的所有财产。无论是给,还是不给,决定权都在富豪这里,私生子们也不会有任何怨言。

在这个例子中,富翁就等于操作系统(假设操作系统拥有4GB的内存),私生子们就相当于一个个进程,每个进程都在做自己的任务,操作系统给进程们构造了一个虚拟的地址空间的概念(继承财产),32位系统下,是从 00000000H 到 FFFFFFFFH 。进程认为自己拥有4GB的内存,每个进程都可以向操作系统申请空间,但是给不给是操作系统决定的,操作系统觉得合理就给,否则就不给,但这并不影响进程认为自己拥有4GB的空间。
请添加图片描述

如下,是进程地址空间的概念图片。操作系统拥有4GB的内存,于是进程认为自己也拥有4GB的内存,进程管理自己的内存的时候,是按照自己有4GB的内存来进行管理的!!

请添加图片描述
但是,如何证明呢?如何证明进程是按照自己拥有4GB来进行管理的呢?

我们可以通过执行下列代码,来证明!代码起始地址、常量区已初始化数据,未初始化数据……按照上面图片 由低地址到高地址 来定义变量并打印其地址,就可以观察到各种数据的存放地址。

  1 #include<stdio.h>                                                                                                            
  2 #include<stdlib.h>  
  3 int g_unval;//未初始化  
  4 int g_val = 100;//初始化  
  5 int main(int argc,char *argv[],char *env[])  
  6 {  
  7     printf("code addr:           %p\n",main);//代码区起始地址  
  8     const char* p = "hello world";  //p是指针变量(栈区),p指向字符常量h(字符常量区)  
  9     printf("read only :          %p\n",p);  
 10     printf("global val:          %p\n",&g_val);  
 11     printf("global uninit val:   %p\n",&g_unval);  
 12     char *q = (char *)malloc(10);  
 13     printf("heap addr:           %p\n",q);  
 14     
 15     printf("stack addr:          %p\n",&p);//p先定义,先入栈  
 16     printf("stack addr:          %p\n",&q);  
 17     
 18     printf("args addr            %p\n",argv[0]);//命令行参数  
 19     printf("args addr            %p\n",argv[argc-1]);  
 20     
 21     printf("env addr:            %p\n",env[0]);//环境变量  
 22     return 0;  
 23 }  

如下,这是程序运行的结果,之前已经说过,这些打印出来的地址,实际上是虚拟地址,进程认为自己拥有 4GB 的空间,所以是按照 4GB 的空间来分布数据的。可是,如何去理解栈区、堆区呢?这种区域会扩大自己所占的存储空间。
请添加图片描述

在理解栈区、堆区之前,可以先引入一个概念。在小学的时候,同桌之间经常会画一条“三八线”,以此来区分各自的区域。假设现在小明、小红是同桌,两个人从桌子中间画了一条“三八线”,左边是小明的区域,右边是小红的区域(下图左)。但是小明呢比较淘气,经常会占用小红的一部分区域,小红于是就让小明把三八线往她那边挪一点,三八线左边是小明的活动区域(下图右)。

请添加图片描述
将上面的概念扩展到栈区、堆区,就可以理解它们!比如栈区,它只需要画一条“线”,就可以标识哪些区域是属于栈区的,如果想要更多的空间,就把“线” 挪一挪
由于地址空间本质上是线性的,如下图,所以,这样子“挪一挪”就可以使得对应区域的空间变大。
请添加图片描述

如下,是 Linux 的内核源码,该段代码是 mm_struct 这样的结构体,描述了进程内部的空间划分。如果栈区想要更多的空间,就把 stack_end 这个变量的值改变一下,就代表它拥有了更多的空间!!!

当然,mm_struct 结构体里面保存数据,是在进程认为自己拥有 4GB 的内存情况下设置的!

struct mm_struct
{
    unsigned long code_start;//代码区
    unsigned long code_end;
    
    unsigned long init_start;//初始化区
    unsigned long init_end;
    
    unsigned long uninit_start;//未初始化区
    unsigned long uninit_end;
    
    unsigned long heap_start;//堆区
    unsigned long heap_end;
    
    unsigned long stack_start;//栈区
    unsigned long stack_end;
    //...等等
}

但是它的数据最终还是要存储在内存里面的,而内存不可能将自己的空间全部分给一个进程使用,4GB只不过是进程自己的一厢情愿,最终它占有的内存空间 是远小于 4 GB的。可是由于进程是拿着 4GB 的空间(想象出来的),去分配给各个区域分配的,这和数据实际在内存中存储的位置是不一样的,所以要进行一个转换,这里会通过 “页表” 来进行虚拟地址到物理地址的转换!!

如下图,页表有很多行,一个进程的地址空间对应一个页表。在每一行中,有两列,左边的数据表示地址空间的地址(虚拟地址),右边的地址表示物理地址,这样子就可以把虚拟地址和物理地址对应起来!
请添加图片描述

📕 父子进程的地址空间

通过上面的内容,我们已经大致理解了进程地址空间是什么——它是进程虚拟出来的,自己所拥有的空间,但是进程的数据是要存储在实际的物理内存中的,它认为自己的数据放在虚拟内存的某处。但是还要要通过 页表 ,实现 从虚拟内存的地址,到物理内存的地址之间的转换,这样才可以找到进程的数据。

到这里,我们就可以大概理解最开始的问题了。父子进程是独立的,所以它们都认为自己拥有4GB的内存,这个内存是虚拟的(实际存储数据是在物理内存上的)。所以会出现父子进程的某个变量地址是一样的,但是变量的值却不一样,这是因为父进程的变量A 和 子进程的变量A,虽然虚拟地址相同,但是通过页表转换之后,找到的物理地址是不同的!!

但是,为什么父子进程的变量A,虚拟地址会相同呢?

首先,子进程会继承父进程的绝大多数东西。父进程的代码、数据,包括父进程 pcb 里面的绝大部分数据(少量的比如 子进程的PID 无法继承父进程的),子进程都是和父进程完全相同的。这样设计的原因是为了节省空间,子进程的存在,是为了执行父进程的部分代码(或者执行一段新的代码)。如果创建了子进程,又给它重新分配内存,存储其代码和数据,未免过于浪费。
请添加图片描述

但是,子进程里面有可能涉及到数据的更改。考虑到进程的独立性,子进程更改某些数据,不能影响父进程的数据。所以,这里就会发生之前所说的**“写时拷贝”** —— 要修改子进程某个数据的时候,在物理内存找一块同样大小的空间,存放更改之后的数据,再将子进程的页表里对应区域指向这块空间
可是,真正存放数据的物理地址虽然修改,但从进程的视角来看,它的数据依然存放在虚拟内存中原来的地方,数据并没有更改存储位置!!!只不过在使用数据的时候,通过页表的转换,才找到了数据更改后存放的物理内存区域

没有发生写时拷贝的空间,父子进程都是使用同样的物理内存区域。只有发生了写时拷贝,才会使用物理内存中其他的区域。
请添加图片描述

到这里,就能解释为什么最开始的父子进程中,变量 val ,取地址相同,但是值却不同。这就是因为父进程发生了写时拷贝!

📕 地址空间的意义

  1. 防止进程随意访问物理内存,保护物理内存与其他进程。
  2. 将进程管理和内存管理进行解耦合。
  3. 可以让进程以统一的视角,看待自己的代码和数据。

对于第一点,这是毫无疑问的,通过页表的转换,可以十分有效地防止进程去访问不属于自己的物理内存空间。如果一个进程,访问到了其他进程存储数据的物理内存区域,那么进程的独立性就没有保障。

对于第二点,解耦合,也就是说将进程管理和内存管理分开。如下图,对于进程而言,它只需要对左半部分的内容进行管理,只需要对自己的4GB(虚拟出来的)空间进行操作。对于内存而言,它也只需要管理好自己的内存,这是实实在在的 4GB 空间。
如果不进行解耦,那么进程就可以直接访问物理内存,就会导致第一点里面所说的问题。
请添加图片描述

至于第三点,每一个进程都认为自己拥有 4GB 的内存空间,进程也是按这样的地址去排布数据,分配空间的,这就是每一个进程都可以用统一的视角来看待自己的代码和数据。(但是每一个进程实际上拥有的物理空间,是要看操作系统给了这个进程多少的物理内存)

📕 malloc 的本质

C语言中,可以通过 malloc 动态申请内存,但是malloc 的本质我们却并不了解。有了上面的知识储备,就可以剖析 malloc 动态申请内存的本质。

如下,malloc 是在堆区开辟内存,那么 heap_end 就会更改。

struct mm_struct
{
    unsigned long code_start;//代码区
    unsigned long code_end;
    
    unsigned long init_start;//初始化区
    unsigned long init_end;
    
    unsigned long uninit_start;//未初始化区
    unsigned long uninit_end;
    
    unsigned long heap_start;//堆区
    unsigned long heap_end;
    
    unsigned long stack_start;//栈区
    unsigned long stack_end;
    //...等等
}

如下,malloc 一块空间,对于进程而言,只需要在虚拟内存中给堆区增加一块空间即可;但是由于这块空间是要存储数据的,所以实际上还要在物理内存里面开辟一块空间。通过页表进行虚拟地址和物理地址之间的映射关系,并返回申请空间的起始地址(虚拟地址)。

但是,操作系统是在申请的时候就把内存给你,还是你使用的时候给你呢?
答案是,在使用的时候给你。

首先,操作系统不允许任何的 浪费/不高效 。操作系统作为计算机最基础的软件,要为所有进程提供资源,所以不允许有任何浪费或者不高效的行为。
其次,在我们申请空间之后,不一定立马就使用。一方面,可能写代码的时候,申请空间之后,又写了几十行代码,才开始使用申请的空间;另一方面,有可能这个进程刚申请了空间,但是时间片到了,然后就被操作系统切换到其他进程来运行了。
最后,在申请成功之后,和使用之前,会有一段小小的时间窗口,使得这块空间没有被正常使用,但是其他进程用不了,那么这块空间就处于闲置状态!!有那么一两个进程出现这种情况倒无所谓,但是如果有几十、上百个进程这样,就会造成极大的资源浪费!
请添加图片描述

所以,malloc 申请空间的时候,首先只需要在 进程地址空间 里面申请空间,这只需要改变 heap_end 的值即可;其次,在页表中建立 申请的虚拟地址 到 物理地址 的映射关系,页表中,该行的左边就填申请的虚拟地址的首地址,右边(物理地址)暂时什么都不填,也不需要在物理内存中申请空间;最后,返回在进程地址空间中申请的空间的首地址。这就完成里申请空间的过程。
当需要对这块空间写入数据的时候,再在物理地址申请空间,并且将页表对应映射关系完善好。

上面所说的这种操作系统再需要使用内存再申请,或者说 检测到代码和数据不在内存,当访问的时候再换入,这样的操作系统的内部机制,叫做缺页中断

理解了malloc 的本质,就能更好理解 解耦合 的概念。进程只需要进行进程管理,由于有页表的映射,进程完全不需要关系自己的数据实际上被存放到物理地址的哪一块地方,它只需要管理好自己的进程地址空间即可!!

📕 重新理解地址空间

当我们的程序在被编译的时候,没有被加载到内存,那么程序内部有没有地址呢?可以通过 VS 环境下的反汇编来验证。(由.c 文件编译的过程,可以知道生成汇编代码,是在编译的时候进行的:.c文件是如何变成 .exe 文件的

如下,调试的时候转换到反汇编,发现汇编代码里面已经在使用地址了。所以,源代码在翻译的时候,就是按照虚拟地址空间的方式对代码和数据进行编制!

所以,编译器也是遵守进程地址空间的规则!!

请添加图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

努力努力再努力.xx

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

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

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

打赏作者

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

抵扣说明:

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

余额充值