程序(进程)地址空间(1)

1 C C++内存空间布局验证

我们在讲C语言的时候,老师给大家画过这样的空间布局图

可是我们对他并不理解!可以先对其进行各区域分布验证:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
    const char *str = "helloworld";
    printf("code addr: %p\n", main);
    printf("init global addr: %p\n", &g_val);
    printf("uninit global addr: %p\n", &g_unval);
    static int test = 10;
    char *heap_mem = (char*)malloc(10);
    char *heap_mem1 = (char*)malloc(10);
    char *heap_mem2 = (char*)malloc(10);
    char *heap_mem3 = (char*)malloc(10);
    printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
    printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
    printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
    printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
    printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
    printf("read only string addr: %p\n", str);
    return 0;
}

我们在Windows下可能看不到这样的布局,因为不同的平台内存空间布局情况不同

下面我们来提出一个问题,上面的内存空间布局是物理内存吗?

不是;其实上面的空间是进程地址空间,也叫做虚拟地址空间

2 引入虚拟地址

一个例子引入虚拟地址,来段代码感受一下

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

通过上面的输出结果,我们可以发现一个问题:同一个地址怎么会查出不同的值

但我们又知道进程之间具有独立性,即便是父子进程,这样看,显然结果是没有问题的

那这个地址又是怎么回事呢?

我们目前不知道这个地址是怎么回事,但我们可以确定的是,这个地址一定不是物理内存的地址,而是一个虚拟地址,我们历史所学的所有地址都是虚拟地址,我们看不到物理地址!

我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:

• 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量

• 但地址值是一样的,说明,该地址绝对不是物理地址!

• 在Linux地址下,这种地址叫做虚拟地址

• 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理

OS必须负责将虚拟地址转化成物理地址。

3 什么是虚拟地址空间?一句话概括

虚拟地址空间是操作系统为每个运行的程序"创造"的一个私有的、统一的、巨大的内存假象。它让每个程序都感觉自己独占了整个计算机的内存资源。

4 为什么需要它?(1)------ 要解决的问题

在没有虚拟地址空间之前,程序直接使用物理内存地址(即内存条上每个字节的实际地址),这会带来几个严重的问题:

安全问题:一个程序可以随意读写其他程序甚至操作系统本身的内存数据,这会导致崩溃、数据泄露或恶意攻击。

稳定性问题:如果某个程序出现指针错误(如访问了错误的内存地址),它可能会破坏另一个正在运行的程序,导致整个系统不稳定。

内存管理问题

地址碎片化:程序不断启动和关闭,会在物理内存中留下许多"空洞",很难为一个大程序找到一整块连续的物理内存空间。

空间限制:程序能使用的内存最大不能超过物理内存的大小,而且很难在多个程序之间高效地共享内存。

5 虚拟地址空间是如何工作的?

为了解决上述问题,操作系统和硬件(CPU中的内存管理单元MMU)联手引入了虚拟地址空间。

核心思想:增加一个中间层------地址转换

程序的所有操作(读、写、执行指令)使用的都是虚拟地址 。当CPU需要访问内存时,会由内存管理单元(MMU) 这个硬件部件,实时地将虚拟地址转换为真实的物理地址,然后再去物理内存条上存取数据。

页表是操作系统在内存中创建和维护的一种数据结构。它是一个映射表,记录了虚拟地址空间和物理地址空间之间所有页的对应关系。

6 从虚拟地址的角度解释fork之后为什么父子进程代码数据共享

在操作系统中,fork()系统调用创建子进程时,父子进程最初共享相同的物理内存页 ,但通过虚拟地址机制写时拷贝 技术实现高效共享。

1. 虚拟地址空间的结构

每个进程拥有独立的虚拟地址空间 ,由操作系统通过页表映射到物理内存。

fork()时,子进程复制父进程的页表结构,因此两者的虚拟地址空间初始布局完全相同:

代码段 → 指向同一物理页(只读)。

数据段 、堆 、栈 → 指向相同的物理页(但标记为 COW(写保护))。

2. 共享的底层原理

代码段共享(只读):

代码段是只读 的,父子进程的虚拟地址直接映射到相同的物理页,无需复制。

例如:父子进程的指令 0x4000(虚拟地址)均指向物理地址 0x8000。

数据段/堆/栈的共享(写时拷贝):

初始时,数据区域的虚拟地址映射到相同的物理页

但这些页被内核标记为 COW (写保护)。当任一进程尝试写入数据时:发生写时拷贝操作

触发页错误(Page Fault)。

内核为该进程复制物理页,更新其页表指向新页。

解除写保护,进程继续执行。

修改后,父子进程的虚拟地址相同,但映射到不同的物理页

该工作由操作系统自动完成,用户并不清楚

以该程序为例,子进程修改数据前

子进程修改数据后

7 如何理解空间划分

我们可以看到,虚拟内存被划分为一个个区域,那么该如何理解空间划分呢?

举一个例子,你和你的女同桌共用一张课桌,有一天你们发生了矛盾,决定划分38线,课桌长100cm,则[0,49]是你的区域,[50,99]是你同桌的区域,用数据结构解释如下:

扩大区域或缩小区域只用减少区域的start或end的数字

区域划分本质其实只要有线性空间的一段开始和结束地址表明一段范围即可,开始和结束区域内部的内容都属于我

8 虚拟内存管理(1)

虚拟地址空间实际上是操作系统给进程画的一个饼,本质一定是一个内核数据架构

核心思想是:虚拟地址空间本质上是一个由操作系统内核为每个进程精心维护的、极其复杂的数据结构。 这个数据结构的核心使命是映射------将进程看到的虚拟地址映射到物理内存(或磁盘)上的实际位置。

描述linux下进程的地址空间的所有的信息的结构体是mm_struct (内存描述符)。每个进程只有一个mm_struct结构,在每个进程的task_struct结构中,有一个指向该进程的结构。

cpp 复制代码
struct task_struct
{
    /*...*/
    struct mm_struct *mm; //对于普通的用户进程来说该字段指向他的虚拟地址空间的用户空间部分,
                          //对于内核线程来说这部分为NULL。
    struct mm_struct *active_mm; // 该字段是内核线程使用的。当该进程是内核线程时,它的mm字段为NULL
            //表示没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是一样的,
            //内核线程可以使用任意进程的地址空间。
    /*...*/
}

可以说,mm_struct结构是对整个用户空间的描述。每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰。先来看看由task_struct到mm_struct,进程的地址空间的分布情况:

cpp 复制代码
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组织起来的!虚拟空间的组织方式有两种:

  1. 当虚拟区较少时采取单链表,由mmap指针指向这个链表;

  2. 当虚拟区间多时采取红黑树进行管理,由mm_rb指向这棵树。

linux内核使用 vm_area_struct 结构来表示一个独立的虚拟内存区域(VMA),由于每个不同的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。上面提到的两种组织方式使用的就是vm_area_struct结构来连接各个VMA,方便进程快速访问。

所以我们可以对上图在进行更细致的描述,如下图所示:

9 为什么要有虚拟地址空间(2)

这个问题其实可以转化为:如果程序直接可以操作物理内存会造成什么问题?

在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。

那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。

这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。

• 安全风险

每个进程都可以访问任意的内存空间,这也就意味着任意一个进程都能够去读写系统相关内存区域,如果是一个木马病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。

• 地址不确定

众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中去运行,如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝的实际内存地址每一次运行都是不确定的,比如:第一次执行a.out时候,内存当中一个进程都没有运行,所以搬移到内存地址是0x00000000,但是第二次的时候,内存已经有10个进程在运行了,那执行a.out的时候,内存地址就不一定了

• 效率低下

如果直接使用物理内存的话,一个进程就是作为一个整体(内存块)操作的,如果出现物理内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区中,好腾出内存,但是如果是物理地址的话,就需要将整个进程一起拷走,这样,在内存和磁盘之间拷贝时间太长,效率较低。

存在这么多问题,有了虚拟地址空间和分页机制(后续介绍)就能解决了吗?当然!

• 地址空间和页表是OS创建并维护的!是不是也就意味着,凡是想使用地址空间和页表进行映射,也一定要在OS的监管之下来进行访问!!也顺便,包括各个进程以及内核的相关有效数据!保护了物理内存中的所有的合法数据

• 因为有地址空间的存在和页表的映射的存在,我们的物理内存中可以对未来的数据进行任意位置的加载!物理内存的分配 和 进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合(这样两个模块出问题时就不会影响到彼此)

◦ 因为有地址空间的存在,所以我们在C、C++语言上new, malloc空间的时候,其实是在地址空间上申请的,物理内存可以甚至一个字节都不给你。而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成,用户包括进程完全0感知!!

• 因为页表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射,在进程视角所有的内存分布都可以是有序的。

10 补充

1 解释结论

通过以上内容,我们可以解释几个结论

结论1:定义的全局变量全局有效

地址空间只要存在,那么全局数据区就存在,所以全局变量会一直存在,包括static静态变量

结论2:字符串常量不能修改

字符串是和代码编译在一起的,都是只读的,字符常量区被页表映射的时候有权限约束,不让写入操作进行转换,如果修改了发现页表权限不允许操作系统会终止进程,即运行时报错,而const的作用其实是约束编译器,让编译器进行写入检查,如果有,就是编译时报错,不会终止进程

结论3:命令行参数和环境变量属于父进程的地址空间内的数据资源,和代码区数据一样,子进程会继承父进程的地址空间,所以子进程也能看到命令行参数和环境变量

2 解释问题

创建一个进程的时候,先有内核数据结构还是先加载进程的代码和数据?

答案是先有内核数据结构,即先创建PCB

创建PCB后一定要立即加载进程的代码和数据吗?

不是,创建PCB后可以先把它放在调度队列,等到需要执行时再加载。进程的加载是惰性加载,即边执行边加载,需要用到时加载,不需要时就不加载。惰性加载可以提高内存使用率(写时拷贝也是一种惰性申请)

到此,程序(进程)地址空间(1)就讲完了,怎么样,是不是感觉大脑里面多了很多新知识。

如果觉得博主讲的还可以的话,就请大家多多支持博主,收藏加关注,追更不迷路

如果觉得博主哪里讲的不到位或是有疏漏,还请大家多多指出,博主一定会加以改正

博语小屋将持续为您推出文章

相关推荐
孤岛与风3 分钟前
CentOS扩容非LVM分区
linux·运维·centos
BIBI20493 分钟前
如何使用 Xshell 8 连接到一台 CentOS 7 电脑(服务器)
linux·服务器·centos
卑微的小李9 分钟前
Qt在Linux下编译发布 -- linuxdeployqt的使用
linux·c++·qt
小猪写代码22 分钟前
Ubuntu 的磁盘管理
linux·ubuntu
用户51681661458412 小时前
[VMware 无法检测此光盘中映像中的操作系统] VMware创建虚拟机无法检测操作系统iso镜像文件
linux·前端
MacroZheng2 小时前
斩获 7.8K star!一款堪称开源监控新标杆的项目,牛皮!
java·linux·后端
yunyi3 小时前
使用acme.sh来实现自动化申请和续订TLS证书
linux·nginx·docker
未来可期LJ4 小时前
【Linux 小实战】自定义 Shell 的编写
linux·服务器
_君落羽_4 小时前
Linux操作系统——TCP服务端并发模型
linux·服务器·c++