news 2026/9/26 4:04:22

【进程】-9-进程虚拟地址空间

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【进程】-9-进程虚拟地址空间

**🎬 博主名称**:迷途之人不知返

🔥 个人专栏: 《C语言》、《数据结构》、《C++》、《Linux》

🗂️ Gitee仓库: 《C语言》、《数据结构》、《C++》、《Linux》

</> 算法专栏: 《算法精选集》


进程虚拟地址空间

  • 1、回顾空间分布
    • 1.1、三个细节
    • 1.2、两个问题
  • 2、进程虚拟地址空间
    • 2.1、虚拟地址的引出
    • 2.2、“地址相同,内容不同”的解释
      • 2.2.1、进程还包括:虚拟地址空间、页表
      • 2.2.2、写时拷贝
    • 2.3、真正理解虚拟地址空间
      • 2.3.1、什么是虚拟地址空间
      • 2.3.2、理解区域划分
      • 2.3.3、调整区域大小
    • 2.4、虚拟内存空间的存在意义
      • 2.4.1、理由一:可以更好地对内存进行保护
      • 2.4.2、理由二:空间排布无序变有序
      • 2.4.3、理由三:进程管理与内存管理解耦
    • 2.5、补充说明

首先说好,我们当前研究的环境:

  • kernel:linux-2.6.32
  • 32位平台下

1、回顾空间分布

我们以前学习C/C++的时候,涉及过一点点所谓空间分布的知识。

事实真如上面的图一样吗?

#include<stdio.h>#include<stdlib.h>#include<unistd.h>intg_val=100;// 已初始化的全局变量intg_unval;// 未初始化的全局变量intmain(intargc,char*argv[],char*env[]){printf("code addr: %p\n",main);printf("init global addr: %p\n",&g_val);printf("uninit global addr: %p\n",&g_unval);constchar*str="helloworld";printf("const string addr: %p\n",str);printf("str addr: %p\n",&str);staticinttest=10;printf("test static addr: %p\n",&test);char*heap_mem1=(char*)malloc(10);char*heap_mem2=(char*)malloc(10);char*heap_mem3=(char*)malloc(10);char*heap_mem4=(char*)malloc(10);printf("heap addr: %p\n",heap_mem1);printf("heap addr: %p\n",heap_mem2);printf("heap addr: %p\n",heap_mem3);printf("heap addr: %p\n",heap_mem4);printf("stack addr: %p\n",&heap_mem1);printf("stack addr: %p\n",&heap_mem2);printf("stack addr: %p\n",&heap_mem3);printf("stack addr: %p\n",&heap_mem4);inti=0;for(;argv[i];++i)printf("argv[%d]: %p\n",i,argv[i]);for(i=0;env[i];++i)printf("env[%d]: %p\n",i,env[i]);return0;}

结果是:

仔细分类,果然最开始的图片描述的那样。只要我们仔细观察打印的地址,就能发现实际的空间分布和图片上描述的是相符的。

这里有三个细节我们要了解一下。

1.1、三个细节

1、实际程序中,栈空间的分布可能不太一样

在某些函数栈帧内,变量(栈)的分布可能不太一样。但是总体是呈图中所示,即向低地址生长的。

2、堆 vs 栈 vs 堆栈

目前我们只需要知道,堆就是堆,栈就是栈。而堆栈属于栈。

3、空间总大小是4GB?

首先,我们来复习一下内存单位的换算:

1(Byte)=8(bit)1(KB)=1024(Byte)1(MB)=1024(KB)1(GB)=1024(MB)1(TB)=1024(GB)

32位机器,意味着有32根地址总线。每一根线都可以用0或1表示,那么每一根线都可以表示两种状态,32根线就可以表示232种状态。

访问内存的基本单位是字节,那么空间的总大小就是232(Byte)。经过上面的内存大小换算,空间的总大小,就是4GB。

1.2、两个问题

当我们回答了上面三个细节后,面对给出的空间分布图片,和验证代码,我们又提出了下面两个问题:

1、常量字符串不能被修改

""包裹的常量字符串,不能被人为的修改。观察发现,常量字符串的空间位置和main函数代码的空间位置非常接近,即常量字符串处于正文代码区。所以常量字符串是只读的,用户若想修改,操作系统就会拦截并且报错。


所以,当我们尝试修改上述代码的str时,比如这样修改:

*str='H';

就会发生报错:

有意思的是,当我们不用const修饰内容为常量字符串的变量str时,修改str,代码编译能通过,但是执行时出现段错误:

不加const编译能过,而执行时出现严重问题;加上const编译阶段就弹出报错。意味着const的作用是对编译器做进一步限制,将运行报错转化为编译报错,从而帮助我们更快发现错误。

2、static修饰局部变量

继续观察,static修饰变量的所在空间,与全局变量所在的空间也是非常接近的。

// test3.c#include<stdio.h>intg_val=100;intung_val;intmain(){staticintval=10;printf("static int addr: %p\n",&val);printf("init global addr: %p\n",&g_val);printf("uninit global addr: %p\n",&ung_val);return0;}

实际上,当一个全局变量被static修饰的时候,这个变量的生命周期已经变成全局变量的生命周期了。只不过操作系统对这个被static修饰的变量做了限制,使之只能在函数内访问,即作用域不变。

2、进程虚拟地址空间

文章开头展示的图片,真的指的就是内存空间吗?不是的,文章开头图片,展示的是进程的虚拟地址空间。

2.1、虚拟地址的引出

// test4.c#include<stdio.h>#include<unistd.h>#include<sys/types.h>intg_val=10;intmain(){pid_tid=fork();if(id<0){perror("fork\n");return0;}elseif(id==0)while(1){printf("child. pid: %d, g_val: %d, &g_val: %p\n",getpid(),g_val,&g_val);sleep(1);}elsewhile(1){printf("parent. pid: %d, g_val: %d, &g_val: %p\n",getpid(),g_val,&g_val);sleep(1);}return0;}


创建子进程,父进程与子进程共享代码和数据。当我们未对全局变量g_val做修改时,父子两个进程拥有的g_val变量内容相同,变量地址相同。

然而,当我们尝试在子进程将g_val不断++,但是在父进程对g_val不做任何修改。我们就会看到非常奇妙的现象:

父子两个进程中,g_val变量内容不同,但是变量地址却是相同的!

那么这个地址,就绝对不是真实的物理地址。实际上,被打印出来的,我们能看到的地址,都是虚拟地址。

2.2、“地址相同,内容不同”的解释

要解释这个问题,我们首先要知道,创建好的进程还包含哪些东西。

2.2.1、进程还包括:虚拟地址空间、页表

进程创建后,操作系统会为它“开辟”一块虚拟地址空间。操作系统还会开辟一张“页表”,完成虚拟地址到物理地址的映射:

页表将会填入虚拟地址空间的地址,和物理内存空间的地址,然后建立某种对应关系,最终完成虚拟地址到物理地址的映射。简单流程图如下:

所以,进程还需包含哪些东西呢?我们可以得出一个小结论:

  • 进程需要包含三大块:
    • PCB
    • 虚拟地址空间
    • 页表

2.2.2、写时拷贝

创建子进程的时候,子进程需拷贝父进程的PCB、虚拟地址空间、页表。

实际上,子进程不仅拷贝了父进程的PCB、虚拟地址空间、页表,虚拟地址空间和页表里的地址,也拷贝下来了:

只拷贝地址,这不就是我们之前学过的浅拷贝吗?

还记得吗?浅拷贝的特点是两个指针同时指向一块资源,也就是资源共享。意味着父进程与子进程共享同一块物理内存资源。未来子进程想对这块内存资源中的内容做修改,由于父子共享,父进程中的数据也会被改变。这不就与“进程之间相互独立”相违背了吗?

这时,写时拷贝登场。

  1. 子进程未对虚拟地址0x601058处做修改,那么操作系统什么也不会做,父子进程依旧共享内存数据。
  2. 当操作系统检测到子进程想对虚拟地址0x601058处做修改,
    • 操作系统就会在物理内存中重新开辟一个空间(假设为0x1111),然后干两件事:
      • 将物理地址0x1234中的内容拷贝到0x1111中;
      • 对于子进程的页表,断开旧联系,进而与新开辟的0x1111建立新联系;
    • 最后按子进程的要求,对新空间做修改。

这就是写时拷贝,即“要修改的时候再拷贝”。上面代码中的g_val,正是通过了创建子进程时的写时拷贝,才达到了我们看到的“地址相同,内容不同”的结果。究其原因,就是写时拷贝之后,虚拟地址没有改变,而最终访问到的物理地址不同,访问到的内容也就不同。

写时拷贝,有两大优点:

  • 父与子能够真正独立。写时拷贝技术的存在,父进程与子进程能够真正分离,保证了进程的独立性;
  • 提高内存的利用率。写时拷贝是一种延时申请技术。不修改就不拷贝,省下的空间可以给别的任务使用,这就提高了内存的利用率,进而提高了整机的运行效率。

2.3、真正理解虚拟地址空间

要真正理解虚拟地址空间,我们就要从以下三个方面具体阐述:

  1. 到底什么是虚拟地址空间?
  2. 如何理解区域划分?
  3. 如何调整区域大小?

2.3.1、什么是虚拟地址空间

来讲一个故事:

大家可能都知道,国外一些富豪的私生活挺乱的(😅),因此他们往往有很多私生子(😅)。

私生子之间,往往不知道彼此的存在,这就跟“进程的独立性”很像。

现在有一个大富豪,手握家产10个亿。大富豪一共有4个私生子。大富豪对这四个私生子的每一个人都许诺,将来可以继承大富豪的这10亿。

而今天,四个私生子分别提出了自己的要求:

  • 私生子1:私生子1也是个生意人。私生子1要求大富豪给10000当作启动资金。大富豪认为这是一笔小钱,很快就给了。
  • 私生子2:私生子2是一个科研工作者。私生子2要求大富豪给5000买实验器材,大富豪很快也给了。
  • 私生子3:私生子3是一个女孩。私生子3出于爱美的心理,想要大富豪给6000买一个名牌挎包,大富豪依旧爽快地给了。
  • 私生子4:私生子4还在上高中。私生子4向同学吹牛,说他手里有老爹的10亿,所以私生子4想要大富豪给10亿让同学们开眼,大富豪想也没想就给了一巴掌(😅)。

在这个故事中,

  • 私生子,就是进程;
  • 大富豪,就是操作系统;
  • 10个亿,就是真实的物理内存;
  • 大富豪给每个人承诺都会给10亿——画饼;
  • 大富豪画的每一张饼——虚拟地址空间。

那么问题来了:画的饼本身,需不需要管理?假如公司老板今天给员工A承诺干完项目就涨工资,给员工B承诺做完报告就升职;员工A干完了项目,老板说做的好,再做点项目就给升职;员工A一听就会发现老板的承诺牛头不对马嘴,马上就会认为老板是在画饼,同理员工B也是这样。

所以,画的饼需要管理,那么虚拟地址空间也需要管理。如何管理——
先描述,再组织。所以虚拟地址空间实际上是一种内核数据结构对象。

mm_struct就是Linux下进程的虚拟内存空间。

小贴士1


正如上述大富豪给私生子的画饼行为一样,大富豪向私生子承诺会给10亿,但是实际上只会给很小一部分,那么进程的虚拟地址空间表面上计算下来有4个G,实际进程真正占有的肯定是远远小于4个G。

虚拟内存空间,本质上就是一个结构体。那么结构体如何表示区域划分呢?

2.3.2、理解区域划分

我们小学的时候,或多或少会遇到好动的小朋友,所以我们往往会在桌子上画一条“三八线”。“三八线”的本质,就是区域划分。

那么计算机是如何实现区域划分的呢?

例如,小朋友A与小朋友B做同桌。A与B约定,将共用的桌子分成100份,A分到50份,B分到50份。

那么我们就可以这样标记区域划分:

structdesk{intsize=100;intmate_a_start=1;intmate_a_end=50;intmate_b_start=51;intmate_b_end=100;}

我们就通过规定区域的开始与结束,划分出了一个个区域。

观察源码:


所以,源码的mm_struct中,就会有很多个XXX_start和XXX_end,这些成员是用来做区域划分的。

小贴士2


这里理解区域划分的时候,可不要只盯着“1”“50”看。通过区域的开始与结束来划分区域,意味着从开始到结束的每一个位置,都是可以随意使用的。例如,以1和50划分出某个区域,那么[2], [3], [4], …, [49]都是可以使用的。

2.3.3、调整区域大小

如何调整区域大小?调整数值即可。

例如,对于上面同学A与B的课桌空间划分,今天A想分到60份空间,那么B就只能分到40个空间。那么我们就可以这样调整:

structdesk{intsize=100;intmate_a_start=1;intmate_a_end=60;// 调整数值intmate_b_start=61;// 调整数值intmate_b_end=100;}

这里有两个细节。

1、unsigned long对象表示一个地址

地址本质上也是一个十六进制数值。

  • 32位机器的地址用32根地址总线表示,地址的大小就是32bit;
  • 而unsigned long在32位下占4字节,合32bit;

所以unsigned long对象刚好就可以表示一个地址。有了首地址和尾地址,我们自然就能够划分出一块行使特定功能的区域。

2、代码区、数据区,大小由什么决定

答案是由磁盘上的程序决定。

我们对于程序的定义是:

程序 = 代码 + 数据

创建进程时,程序的数据和代码需要加载到物理内存中,然后与进程PCB建立联系。这就好比“看菜吃饭”的道理,你程序有多大的代码和数据,我进程就开辟多大的虚拟内存空间,然后虚拟地址与物理地址建立联系。

2.4、虚拟内存空间的存在意义

2.4.1、理由一:可以更好地对内存进行保护

虚拟内存空间,充当了进程与物理内存之间的“中间人”。进程若想访问物理地址空间,必须通过虚拟地址在页表中的转化。“中间人”遇到会危害整个操作过程的问题时,就会提醒操作系统拦截申请,必要时强行“干掉”进程。

虚拟内存空间作为“中间人”,可能遇到的问题主要有两点:

1、进程访问的地址非法

假设没有虚拟内存空间,进程还是直接用的物理内存。进程PCB中若有指针对象释放资源,变成野指针,那么这个野指针,很有可能会指向进程PCB中其它指针指向的资源当中;接下来野指针一系列未定义的操作,就会影响到其它指针指向的内容,这就会导致安全隐患。

这时,虚拟地址空间的存在就能解决这个问题。当某一个指针变成野指针,假设进程需要访问这个野指针的时候,操作系统就会识别并拦截进程的请求,转化失败。

2、进程操作的权限不匹配

实际上,页表除了存虚拟地址与物理地址的对应关系,还存在一项内容叫“权限标志位”。

例如,常量字符串在代码区,页表就会给常量字符串标记上"r"只读权限。未来某个进程想对常量字符串做修改,操作系统也会识别并拦截,转化也会失败。

2.4.2、理由二:空间排布无序变有序

实际上,操作系统给进程分配物理空间的时候,是随便申请的。目前我们可以理解为,物理空间上哪里有空位置,操作系统就占住送给进程。

有了虚拟内存空间,物理空间排布再怎么杂乱无章,虚拟地址与物理地址经过页表建立映射关系后,进程看到的地址空间也是连续、有序的。

2.4.3、理由三:进程管理与内存管理解耦

有了虚拟内存空间,进程想要申请空间,只需要好好调整虚拟内存空间,腾出一片位置用来存储就行;而操作系统面对一个虚拟地址,只需好好在物理内存上寻找一块空间足够的物理地址,建立映射关系即可。意味着操作系统对进程管理,和对内存管理的过程,几乎是分开的,耦合度是很低的,这样就既保证了安全又保证了效率。

2.5、补充说明

整机运行的情况下,堆区可能会存在“中间释放”的情况,比如刚刚malloc出来的空间,进程还没终止之前就free释放了,导致堆区不连续。

这种情况下,进程的虚拟内存空间如何表示?换句话说,进程的虚拟内存空间还是如文章开头所示的图片一样吗?

实际上,更详细的虚拟内存空间分布图,应该是长这样的:

即用链表(队列)来维护。当需要释放中间资源的时候,我们只需将其它无需释放的资源转移到新节点中,然后释放旧节点,连同需要被销毁的中间资源一起被释放即可。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 4:03:35

Jev哑巴模型是什么?密钥申请与Codex接入实操指南

作为一个常年泡在技术社区、天天跟各种模型打交道的人&#xff0c;最近被问得最多的一个问题就是&#xff1a;Jev是什么&#xff1f;而且每次有人问&#xff0c;后面都会跟着一串新热搜词&#xff0c;比如"哑巴模型""Jev密钥""Jev在Codex中使用"…

作者头像 李华
网站建设 2026/9/26 4:01:53

OpenClaw本地部署实战:Cherry Studio+Ollama Cloud两小时跑通智能体

上周帮一个做运营的朋友装OpenClaw&#xff0c;她提了两个硬性要求&#xff1a;两小时内必须跑通&#xff0c;而且别给她整一堆黑框框的命令行。最后实际用时一小时五十分钟&#xff0c;全程用到的核心组合就是本地部署OpenClaw&#xff0c;再配合Cherry Studio和Ollama Cloud。…

作者头像 李华
网站建设 2026/9/26 4:01:33

ESP32上运行WASM的真正门槛:WAMR运行时与硬件绑定实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 4:01:24

从 PoC 到生产:AI Agent Harness Engineering 上线清单与 TaoToken 配置骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华