【Linux修炼】10.进程地址空间

简介: 【Linux修炼】10.进程地址空间

本节目标

1. 回顾C/C++地址空间

1.1 提出问题

1.2 见问题产生的现象

1.3 解释现象

2. 虚拟地址空间

2.1 感性理解虚拟地址空间

2.2 如何“画饼”

2.3 地址空间的区域划分

3. 进程地址空间与内存的关系

3.1 虚拟地址和物理地址

3.2 多进程的映射关系

4. 如何理解进程地址空间

4.1 为什么存在地址空间

1:保证安全性

2:保证独立性

3:保证统一性(最难点)

4.2 存在地址空间的总结



1.回顾C/C++地址空间


1.1提出问题


在我们以往学习的C/C++中,对于变量分配相对地址中的格局也就是C/C++的地址空间已经有了大致的印象:


微信图片_20230222014226.png那么对于这个所谓的C/C++地址空间是什么呢?是内存吗?在我们以前学习指针的刻板印象中的确如此,但现在提到了,就足以说明当初因知识储备的不足从而错误的理解了这个问题。


1.2见问题产生的现象


在此之前,有一个额外的知识需要了解:对于我们在pid_t id = fork()所创建出的父子进程,这两个的先后进行顺序是不确定的,因为这是由调度器决定的。


那接下来就好好研究从哪里发现了这个问题:打开Linux环境,创建一个如下的mytest.c:


#include<stdio.h>
#include<unistd.h>
int global_val = 100;
int main()
{
  pid_t id = fork();
  if (id < 0)
  {
    printf("fork error\n");
    return 1;
  }
  else if (id == 0)
  {
      int cnt = 0;
    while (1)
      {
          printf("我是子进程,pid: %d, ppid: %d | global_value: %d, &global_value: %p\n", getpid(), getppid(), global_value, &global_value);
      sleep(1);
      cnt++;
      if (cnt == 10)
        {
          global_value = 300;
          printf("子进程已经更改了全局变量啦!\n");
        }
    }
  }
  else
  {
    while (1)
    {
      printf("我是父进程,pid: %d, ppid: %d | global_value: %d, &global_value: %p\n", getpid(), getppid(), global_value, &global_value);
      sleep(2);
    }
  }
  return 0;
}


微信图片_20230222014356.png

首先,对于子进程改变成300,父进程仍然是100,我们可以理解,毕竟是两个不同的进程,执行的情况也一定是独立的,但当我们同时看两个进程中global_val的变量的地址以及变量的值,有了一个惊人的发现,同一个地址的值竟然不相同!这就与我们之前理解的指针大相径庭。我们之前所理解的指针,是内存中变量的地址,但是我们现在打印的地址同样是以指针的形式打印的,那么到现在发生的情况也证实了我们之前理解的指针指向的空间是错误的,指针指向的位置并不是物理内存!(如果是物理内存,那么不可能发生同一个地址的变量的值不相同的情况)


1.3 解释现象


那么多个进程在读取同一个地址的时候,怎么可能出现不同的结果?我们打印时,地址并没有发生变化,但是值不同,也就说明了我们所看到的地址绝对不是物理地址!曾经我们学习C/C++的基本的地址(指针)也一定不是对应的物理地址!因此对于这个现象得出了一个结论:我们所看到的打印出来的地址空间分布都是虚拟地址(也可称为线性地址、逻辑地址)! 我们称这种地址为虚拟地址空间。


因此上述我们所看到的地址分布也不是真正的物理内存上的空间。


2.虚拟地址空间



2.1感性理解虚拟地址空间


设计进程的理念——进程它会认为自己是独占系统资源的(事实上并不是)


就好比当你游戏打累睡觉的时候,你的室友悄悄地把你从床上挪到外面,等你将要醒的时候再挪回来,你仍会以为你的床的位置是一直被你占用的,但事实上并不是。


那么这其中,大富翁对应的就是操作系统,它所拥有的十亿美金就相当于内存,而一看三个儿子伸手要钱的嘴脸,这三个儿子就分别对应三个进程(正如我们在编写程序时的malloc或者new时朝着系统要内存的时候,就好比这三个儿子找peter要钱,emm……写着写着自己变成了儿子,安能辨我是谁儿?)而这三个儿子朝peter要钱,就相当于进程找操作系统要内存(也可称为对象空间),而对于经过的最后一个情节,就好比进程找操作系统要16G的内存空间(一般的电脑一共就16G),操作系统会直接给你16G的空间吗?那是不可能的,因为操作系统还要兼顾其他的进程,但每一个进程被操作系统拒绝后,也仍然会认为自己拥有全部的内存的使用权。因此,操作系统给进程画的大饼,就是进程(虚拟)地址空间。


2.2如何“画饼”


被画饼的对象的记性要好,否则再大的饼也是白化。画饼的本质:在你的大脑中构建一个蓝图 ——数据结构对象:


struct 蓝图
{
    char* who;
    char* when;
    char* target;
    char* money;
    //岗位
}

微信图片_20230225113024.png


如果画的饼太多,记不住了,怎么办?比如:老板:今年好好干,明年升你为部门总监;一天之后,对同一个人说:今年好好干,明年升你为经理,员工:……你个老b登。因此为了避免这样被群众吃瓜的事情发生,不仅员工需要被管理,饼也需要被管理。


微信图片_20230225113039.png

地址空间的本质:是内核的一种数据结构:mm_struct,因此就可以通过管理mm_struct结构体从而管理地址空间。


2.3地址空间的区域划分


在此之前,大家要确定一个共识:


  1. 地址空间描述的基本空间大小是字节。
  2. 32位下,有2的32次方个地址。
  3. 2的32次方个地址 * 1字节 = 4GB的空间范围
  4. 每一个字节都有唯一的一个地址,并且都是虚拟地址(unsigned int (32bits))



确定共识之后,接下来就需要引入地址空间区域的划分了,为了能够更好理解,这里举个例子:


男孩与女孩为同桌,因男孩经常欺负女孩,于是女孩讨厌这个男孩。一天,女孩发现自己无法容忍,于是画了一条边界平分桌子的空间,并且对男孩说:你要是过了这个边界,我就揍你,男孩不以为意,随口答应了下来。


微信图片_20230225113219.png

但是,这个男孩仍然经常不注意,由于体型原因总是越过这条线,于是就和女孩解释他也不是故意的,为了避免出现这种情况,女孩想了一个办法,各自将线退后5cm,余下的10cm就是缓冲地带,两个人都可以使用,这样也可以防止越界的情况发生。


微信图片_20230225113224.png

但即便这样,一段时间后,男孩更加肆无忌惮,女生忍无可忍,又提出改变了一次划分,只给男孩3/10的空间,越过了就揍男孩。

微信图片_20230225113228.png

那实际上,上面的区域的划分我们可以通过一个结构体去表示:

struct Destop
{
    //给男孩划分了区域
    unsigned int boy_start;
    unsigned int boy_end;
    //给女孩划分的区域
    unsigned int girl_start;
    unsigned int girl_end;
}

那么上面多次对桌子分配的区域所进行的调整,就可以看成定义的结构体初始化变量不断改变内部成员变量的过程:

struct Destop d = {1, 50, 51, 100};//一开始
struct Destop fix1 = {1, 45, 55, 100};//第一次改变
struct Destop fix2 = {1, 30, 31, 100};//第二次改变

这个女孩做的就是所谓的区域的扩大 , 而我们实际上的地址空间的划分以及调整,与我们所举的例子是一模一样的,就是通过改变边界的大小,从而实现地址空间的动态分配! 而桌子在规定的界限范围内,放置书包、笔记本等物品,也就对应了我们在计算机系统的虚拟内存中指定的区域进行数据的分配。而男孩和女孩也就对应mm_struct结构体。


那么我们所理解的虚拟地址空间的区域划分,实际上也就是根据这个结构体中的变量划分的:


微信图片_20230225113423.png


2的32次方个地址,每一个都是虚拟地址,整个空间也都是虚拟空间。而我们知道堆和栈都有可能进行空间的扩大(malloc, new),本质上就是修改各个区域的start或者end。free空间的时候也就对应着缩小stack/heap。


我们上面也提到过,操作系统会给进程画大饼,也就是说,每一个进程被创建出来,形成对应的task_struct(进程控制块),都会有2的32次方个空间(4GB)里面包括进程的pid、ppid、进程优先级、进程属性等,每个task_struct都会对应一个mm_struct(每一个都是大饼),task_struct通过其中的指针变量指向对应的mm_struct。


微信图片_20230225113445.png


纠正根深蒂固的错误:


我们通过查看struct task_struct的源码,发现确实有这么一个指针mm:


微信图片_20230225113515.png

那我们继续看mm指向的内容究竟是什么:

微信图片_20230225113519.png


正如我们所料,其中指向的空间里面的内容,就是各种进程的边界变量(截取部分)


这也就恰恰证明了,我们之前一直所谈的C/C++地址空间这个叫法是个错误的,其实际上是进程的地址空间! (学语言的时候没办法,涉及不到这些没办法解释)

微信图片_20230225113523.png



3.进程地址空间与内存的关系


3.1虚拟地址和物理地址


1. 虚拟地址的排布


通过上述的描述,我们已经知道,我们所编写的代码的位置都是在进程地址空间上,也就是虚拟的地址。虚拟地址都是连续的,因此我们也称之为线性地址。就比如二维数组,其地址也都是连续的,我们可以两个for循环打印,也可以一个for循环打印。


微信图片_20230225113708.png



2. 物理地址的排布


那么我们通常所说的物理地址也就是内存与磁盘经常会产生联系,即数据在内存与磁盘间传输的过程我们称为IO,IO的单位是4KB,那么我们就将内存中4KB的大小空间看成一个page页,因此对于内存的数据来说,如果内存的全部大小为4GB,那么我们可以把内存分割成4GB/4KB个page页,即我们可以将内存想象为一个结构体数组:struct page mem[4GB/4KB],通过偏移量就可以访问内存中所有的page页,也就可以访问到内存的所有数据。



微信图片_20230225113740.png


3. 二者之间的关联


而对于这些虚拟的地址实际上作为数据来说,也需要存放在物理地址的某一个位置,因此这就会与内存产生关联。而虚拟地址与物理地址产生关联的媒介就这样产生了,我们将这个媒介称之为页表。(由于页表的内容过于复杂,在这里仅仅是引出这么个名词方便后续解释)


在这里解释一下为什么页表过于复杂,通过下面的页表我们可以看到,每一个地址就有一个页表需要映射,一个页表中有两个地址也就是8个字节需要存储,那么2^32*8想一想也是个非常复杂的空间,并且其中的页表还是以树状的形式存在,因此关于页表的知识这里不做重点,后续会讲。


微信图片_20230225113809.png


即如果内存中的某一个位置c=10,当我们编写代码时,代码的数据首先会被加载到虚拟地址中,通过页表的映射,映射到了相应的物理地址,假设机缘巧合下恰好映射到了如上图的位置,就会将原有的数据修改为新的数据,而这个映射的虚拟地址和物理地址之间也肯定是不同的(毕竟一个是虚拟的,一个是物理的)


因此我们能做的,就是编辑代码让其在虚拟地址上保存,而通过页表映射到内存等其他的所有工作,都是由操作系统自动帮你完成的。


3.2 多进程的映射关系


在上面我们所讲解的内容,是基于一个进程的逻辑对应的关系,那如果我们有两个进程,我们先来看看逻辑图:


微信图片_20230225113848.png




到了多个进程,我们就以最先举的大富翁的例子来解释,当进程1和进程2同时编辑代码数据时,当我们开始运行,其不可能直接看到内存,这两个进程只能看到自己所对应的mm_struct(虚拟地址空间),这也正对应了大富翁并不允许儿子们直接看到他的十亿资产,而正如大富翁给儿子们画大饼一样,操作系统在处理这两个进程时将其编译到虚拟地址空间以及页表的过程就是操作系统给进程画的大饼,因为mm_struct都对应着2^32个地址,实际上操作系统并不允许任何一个进程完全占用所有的内存空间。


4.如何理解进程地址空间



4.1为什么存在地址空间


1.保证安全性

1. 如果让进程直接访问物理内存,万一进程越界非法操作呢?这其实是非常不安全的。通过页表可以对非法的虚拟地址进行拦截,相当于变相的保护物理内存。


2.保证独立性


在谈第二点之前,有件事情需要我们处理——>解释1.2中的现象


那为什么相同地址下父进程和子进程的数值不同呢?


当我们创建完mytest.c文件时,说明已经将数据存放到了磁盘中,而当程序开始运行时,其数据就会被加载到物理内存中,global_val=100也就被存放在了内存的某一块地址,由于父进程和子进程都需要访问global_val,于是global的内存中的地址就会通过页表映射到虚拟空间的某一个地址中,于是父进程和子进程就可以通过虚拟空间中的地址去访问global,并且打印时父进程和子进程对应的global对应的虚拟地址也是相同的,因此开始时我们能看到父进程和子进程对应的global_val的数值和地址都相同。


微信图片_20230225114010.png


当子进程要改变global_val的值,由于进程与进程之间的独立性,一个进程改变变量不能影响另一个进程的同一个变量的改变,因此子进程一旦要改变global_val,操作系统就会将子进程页表与内存的物理地址之间的联系断开,并在物理内存的另一个位置将原来物理地址的数据拷贝过来,这一操作被称为写时拷贝。 这样子进程改变global_val的值也不会影响到父进程的global_val,这个操作与虚拟地址也没有任何关系。因此我们所看到的子进程与父进程的虚拟地址仍是相同的地址。


微信图片_20230225114036.png


即我们所提到的进程的独立性,就是因为:进程=内核数据结构+进程对应的代码和数据 ,内核数据结构是独立的,不同进程对应的代码和数据也是不一样的,因此进程就是独立的。因此我们也总结出了第二点:


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


3:保证统一性(最难点)


接下来就要引入第三点,这一点也是从初学Linux到现在所碰到的第一个难关!(在Linux下,虚拟地址和逻辑地址是一样的。)


先来个灵魂拷问:当我们写了一个程序在磁盘中,当他未载入到磁盘时,其内部的函数和变量有地址吗?

答案当然是肯定的。在程序编译链接的时候,磁盘中的程序就有了地址,这个地址也被我们称为逻辑地址(虚拟地址)


虚拟地址空间的规则只有操作系统会遵守吗?

当然不是,不仅OS需要遵守,编译器同样需要遵守!也就是说上个问题中(编译链接的过程)编译器在编译你的代码的时候,就是按照虚拟地址空间的方式进行编址的!


下面就看一下具体的划分:


微信图片_20230225114212.png




假设是以32位地址空间编址的。在编译时,main中的fun()会通过逻辑地址跳转到定义的fun()函数,当代码加载到内存时,这个逻辑地址仍然存在,也就是程序内部使用的地址在加载到内存中时仍然存在,但当我们将代码加载到内存时,代码既然也是数据,那么就一定需要在物理内存中的某个物理地址进行保存,此时这段代码既有外部的物理地址,也有内部的逻辑地址,相当于有了两套地址!


那么,当这段代码通过页表的映射加载到进程的mm_struct(虚拟地址)时,这段代码就被存放在这个进程对应的进程地址空间中,这个过程就是物理地址通过映射传输的,也就是说物理地址此时扮演的只是交通工具的作用;**那么当CPU的寄存器比如pc指针通过指令读取此代码时,出来的是物理地址还是虚拟地址呢?一定是虚拟地址!**深思熟虑许久,我觉得可以同时从两个方面去理解:其一是因为在Linux系统中的指令,天然的CPU指令读取的自然是虚拟地址;其二是因为物理地址在这个过程中只有映射作用,也就是说程序加载到虚拟地址空间时,原本的物理地址还停留在内存中,代码剩下的仅仅是虚拟地址。


当CPU找到了虚拟地址之后,就会通过页表的映射,按照来时的路线去寻找内存中的main()函数的代码,将这个实际存在的代码通过CPU读取:


微信图片_20230225114258.png


当从main函数中出来再次调用fun()函数,出来的是物理地址还是虚拟地址呢?答案当然还是虚拟地址!原因与上述的理解相同,那么此时在cpu中执行完了main()函数,通过找到的fun()函数的虚拟地址再次原路返回映射到内存,这时候CPU就调用了内存中fun()函数的代码,因为内存中的fun()函数的代码才是实际存在的。


微信图片_20230225114301.png


我们发现,通过上述的物理内存的映射与寄存器的读取,整个代码跳转的逻辑就那么一点点的转起来了,这个过程确实抽象!我们也发现CPU在运行中根本接触不到物理地址,接触到的都是虚拟地址!


  • 那么第三点也就出来了:

3.让进程以统一的视角,来看待进程对应的代码和数据等各个区域,方便编译器也以统一的视角来进行编译代码(使用和编译的统一是指都是在虚拟地址空间的统一,因为规则一样,所以编完即可使用。)


4.2存在地址空间的总结



1. 如果让进程直接访问物理内存,万一进程越界非法操作呢?这其实是非常不安全的。通过页表可以对非法的虚拟地址进行拦截,相当于变相的保护物理内存。


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


3.让进程以统一的视角,来看待进程对应的代码和数据等各个区域,方便编译器也以统一的视角来进行编译代码(使用和编译的统一是指都是在虚拟地址空间的统一,因为规则一样,所以编完即可使用。)



相关文章
|
23天前
|
算法 Linux 调度
深入理解Linux操作系统的进程管理
本文旨在探讨Linux操作系统中的进程管理机制,包括进程的创建、执行、调度和终止等环节。通过对Linux内核中相关模块的分析,揭示其高效的进程管理策略,为开发者提供优化程序性能和资源利用率的参考。
54 1
|
11天前
|
存储 监控 Linux
嵌入式Linux系统编程 — 5.3 times、clock函数获取进程时间
在嵌入式Linux系统编程中,`times`和 `clock`函数是获取进程时间的两个重要工具。`times`函数提供了更详细的进程和子进程时间信息,而 `clock`函数则提供了更简单的处理器时间获取方法。根据具体需求选择合适的函数,可以更有效地进行性能分析和资源管理。通过本文的介绍,希望能帮助您更好地理解和使用这两个函数,提高嵌入式系统编程的效率和效果。
66 13
|
18天前
|
SQL 运维 监控
南大通用GBase 8a MPP Cluster Linux端SQL进程监控工具
南大通用GBase 8a MPP Cluster Linux端SQL进程监控工具
|
26天前
|
运维 监控 Linux
Linux操作系统的守护进程与服务管理深度剖析####
本文作为一篇技术性文章,旨在深入探讨Linux操作系统中守护进程与服务管理的机制、工具及实践策略。不同于传统的摘要概述,本文将以“守护进程的生命周期”为核心线索,串联起Linux服务管理的各个方面,从守护进程的定义与特性出发,逐步深入到Systemd的工作原理、服务单元文件编写、服务状态管理以及故障排查技巧,为读者呈现一幅Linux服务管理的全景图。 ####
|
1月前
|
缓存 监控 Linux
linux进程管理万字详解!!!
本文档介绍了Linux系统中进程管理、系统负载监控、内存监控和磁盘监控的基本概念和常用命令。主要内容包括: 1. **进程管理**: - **进程介绍**:程序与进程的关系、进程的生命周期、查看进程号和父进程号的方法。 - **进程监控命令**:`ps`、`pstree`、`pidof`、`top`、`htop`、`lsof`等命令的使用方法和案例。 - **进程管理命令**:控制信号、`kill`、`pkill`、`killall`、前台和后台运行、`screen`、`nohup`等命令的使用方法和案例。
147 4
linux进程管理万字详解!!!
|
1月前
|
缓存 算法 Linux
Linux内核的心脏:深入理解进程调度器
本文探讨了Linux操作系统中至关重要的组成部分——进程调度器。通过分析其工作原理、调度算法以及在不同场景下的表现,揭示它是如何高效管理CPU资源,确保系统响应性和公平性的。本文旨在为读者提供一个清晰的视图,了解在多任务环境下,Linux是如何智能地分配处理器时间给各个进程的。
|
1月前
|
存储 运维 监控
深入Linux基础:文件系统与进程管理详解
深入Linux基础:文件系统与进程管理详解
87 8
|
1月前
|
网络协议 Linux 虚拟化
如何在 Linux 系统中查看进程的详细信息?
如何在 Linux 系统中查看进程的详细信息?
102 1
|
1月前
|
Linux
如何在 Linux 系统中查看进程占用的内存?
如何在 Linux 系统中查看进程占用的内存?
|
1月前
|
算法 Linux 定位技术
Linux内核中的进程调度算法解析####
【10月更文挑战第29天】 本文深入剖析了Linux操作系统的心脏——内核中至关重要的组成部分之一,即进程调度机制。不同于传统的摘要概述,我们将通过一段引人入胜的故事线来揭开进程调度算法的神秘面纱,展现其背后的精妙设计与复杂逻辑,让读者仿佛跟随一位虚拟的“进程侦探”,一步步探索Linux如何高效、公平地管理众多进程,确保系统资源的最优分配与利用。 ####
73 4