程序地址空间

研究背景:

  • Linux 操作系统:kernel 2.6.32 或 kernel 3.10.0。

    2.6.32是 32 位平台, 3.10.0 是 32 或 64 位都兼容的平台。

    可通过 uname -r 查看。

  • Windows 11 操作系统。

程序地址空间回顾

若有认真学习 C 语言的话,可能了解过这样的空间布局图。但 C 语言和 C++ 想要深入必须学习操作系统,因此这一块知识只能被搁置。

Java 和 Python 因为程序是由虚拟机去解释的,所以一般谈虚拟机的行为。

而 C、C++ 程序是在操作系统中添加进程,调用计算机内部资源,所以需要讨论程序地址空间。
请添加图片描述

现在将通过代码来进行验证:

  • C 语言是面向过程的函数,它就有对应的函数,函数就是代码。因此找一个代码区的代表,可以选择输出 main 的地址。
  • 指针变量虽然存储其他区域的地址,但若本身是在函数内部申请的话,也属于栈区的局部变量。
#include<stdio.h>
#include<stdlib.h>

//从低地址到高地址,选取代表
int un_gval;
int init_gval=100;

int main() {
    //代码区
    printf("code addr: %p\n", main);
    //字符常量区
    const char *str = "hello Linux";
    printf("read only char addr: %p\n", str);
    //已初始化数据段
    printf("init global value addr: %p\n", &init_gval);
    //未初始化数据段
    printf("uninit global value addr: %p\n", &un_gval);
    //堆区
    char *heap1 = (char*)malloc(100);
    printf("heap1 addr : %p\n", heap1);
    //栈区
    printf("stack addr : %p\n", &str);
    
    return 0;
}

Linux 交互界面:

[Bjarne@VM-8-8-centos cTest]$ gcc a.c -o a.exe
[Bjarne@VM-8-8-centos cTest]$ ./a.exe
code addr: 0x40057d  # 代码区
read only char addr: 0x4006bf  # 字符常量区,也叫只读数据段
init global value addr: 0x60103c  # 已初始化数据段
uninit global value addr: 0x601044  # 未初始化数据段
heap1 addr : 0xf14010  # 堆区
stack addr : 0x7ffdec1d0510  # 栈区
[Bjarne@VM-8-8-centos cTest]$ 

可以看到,Linux 下的 GCC 系编译器编译出的程序生成的进程基本满足上图的分布

可执行程序内的全局变量会赋予初始值,在编译时会将全局变量和它的初始值写进可执行程序中,这个区域就是 .data 全局数据段。同样被写进可执行程序的还有字符串常量。

而栈区、堆区的变量都是在加载成进程后,才会开辟物理内存存储变量数据。

大端和小端只影响 “多字节数据在内存中的字节排列顺序” ,而代码打印的是内存地址和虚拟地址空间的布局,由擦欧总系统和链接器决定,与 CPU 的字节序无关。后续的堆、栈生长方向也是如此。

Windows的C程序内存布局

但同样的代码,Windows 11 下 MSVC 的输出:

code addr: 007712D0  # 代码区
read only char addr: 00777B44  # 字符常量区,也叫只读数据段
init global value addr: 0077A000  # 已初始化数据段
uninit global value addr: 0077A59C  # 未初始化数据段
heap1 addr : 0154D418  # 堆区
stack addr : 012FFD08  # 栈区

D:\vs2019test\Debug\vs2019test.exe (进程 16892)已退出,代码为 0。
按任意键关闭此窗口. . .

Windows 11默认启用 ASLR(地址空间布局随机化),是微软为防止入侵者预测内存地址,从而对进程进行攻击的保护措施。

Windows 11下的 GCC(32bit,Debug模式)的输出:

code addr: 00401500
read only char addr: 0040400F
init global value addr: 00403004
uninit global value addr: 00405430
heap1 addr : 00C71660
stack addr : 0062FE98

--------------------------------
Process exited after 0.07949 seconds with return value 0
请按任意键继续. . .

虽然 Windows 11 下也基本满足连续布局,但和 Linux 相比,文件格式存在差异,Windows 的地址分配策略不完全相同。具体在研究 Windows 时再讨论。

堆栈相向生长

因为 Windows 和 Linux 的差异和 Windows 的保护机制,这里目前只研究 Linux。

再修改代码:

#include<stdio.h>
#include<stdlib.h>

int main() {
    int a=3;
    int b=4;
    int c=5;
    int* pa=(int*)malloc(4);
    int* pb=(int*)malloc(4);
    int* pc=(int*)malloc(4);
    printf("stack addr:%p\n",&a);
    printf("stack addr:%p\n",&b);
    printf("stack addr:%p\n",&c);
    printf("heap addr:%p\n",pa);
    printf("heap addr:%p\n",pb);
    printf("heap addr:%p\n",pc);
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cTest]$ gcc a.c -o a.exe -std=c99
[Bjarne@VM-8-8-centos cTest]$ ./a.exe
stack addr:0x7ffd4ad2c374
stack addr:0x7ffd4ad2c370  # 栈区变量地址减小,说明栈向下生长
stack addr:0x7ffd4ad2c36c
heap addr:0x1d8e010
heap addr:0x1d8e030  # 堆区变量地址增加,说明堆向上生长
heap addr:0x1d8e050
[Bjarne@VM-8-8-centos cTest]$ 

可以看到,栈区变量地址减小,说明 “栈向下生长”,而堆区变量地址增加,说明 “堆向上生长“,基本符合程序地址空间分布图的描述。

数组和结构体的情况

再修改代码:

#include<stdio.h>
#include<stdlib.h>

struct Global{
    int a;
    int b;
    int c;
};
struct Global obj_g={0,0,0};
int ga[100]={2};

int main() {
    struct Stack{
        int a;
        int b;
        int c;
    };
    struct Stack obj_s={1,1,1};
    int sa[100]={3};
    int* pa=(int*)malloc(400);
    struct Global obj_gs={1,1,1};
    struct Stack obj_ss={1,2,3};

    printf("global array:&ga[1]=%p,&ga[2]=%p\n",&ga[1],&ga[2]);
    printf("stack array:&sa[1]=%p,&sa[2]=%p\n",&sa[1],&sa[2]);
    printf("heap array:&pa[1]=%p,&pa[2]=%p\n",&pa[1],&pa[2]);
    printf("global struct, global var:%p,%p,%p\n",&obj_g.a,&obj_g.b,&obj_g.c);
    printf("global struct, stack var:%p,%p,%p\n",&obj_gs.a,&obj_gs.b,&obj_gs.c);
    printf("stack struct, stack var:%p,%p,%p\n",&obj_ss.a,&obj_ss.b,&obj_ss.c);
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cTest]$ make
gcc a.c -o a.exe -std=c99
[Bjarne@VM-8-8-centos cTest]$ ./a.exe
global array:&ga[1]=0x601064,&ga[2]=0x601068
stack array:&sa[1]=0x7ffe27d1c6c4,&sa[2]=0x7ffe27d1c6c8
heap array:&pa[1]=0x130e014,&pa[2]=0x130e018
global struct, global var:0x6011f4,0x6011f8,0x6011fc
global struct, stack var:0x7ffe27d1c6b0,0x7ffe27d1c6b4,0x7ffe27d1c6b8
stack struct, stack var:0x7ffe27d1c6a0,0x7ffe27d1c6a4,0x7ffe27d1c6a8
[Bjarne@VM-8-8-centos cTest]$ 

每个数组都按照程序地址空间进行排布,但使用时都是从低地址开始使用。因为数组的下标访问的本质是 a[i] ⇔ \Leftrightarrow *(a+i)

结构体无论是在哪里声明,都和结构体变量的存储位置无关。结构体变量的存储位置取决于在哪里申请的结构体和数组一样,结构体变量都按照程序地址空间进行排布,但内部成员按照顺序先使用低地址再使用高地址。C++ 的类就是结构体加成员函数组成,但计算大小时只计算成员变量,和结构体在内存上并无区别。

且经过程序地址空间的了解,可以得出一个结论:C 语言的类型的本质就是偏移量

  • 例如 32 bit 的数据,intfloat 都是 4 bytes,按照 intfloat 分别解读会得到不同的数据;
  • 例如结构体成员的地址满足默认偏移量的若干倍(见c语言的自定义结构-CSDN博客)。

因此只要是二进制数据,C 语言都可以通过修改偏移量和指定阅读规则来对比特位进行计算和处理。

static 修饰的局部变量

根据 c++内存管理和new、delete的使用-CSDN博客 ,经过 static 修饰的局部变量和全局变量在同一个区域,只是因为作用域限制无法被外部访问,但可以传递指针或引用返回。

这里只是在 Linux 进行二次验证。

#include<stdio.h>
#include<stdlib.h>

int gv=1;

int main() {
    int a=3;
    static int b=4;
    printf("global val:%p\n",&gv);
    printf("stack area val:%p\n",&a);
    printf("static stack area val:%p\n",&b);
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cTest]$ make
gcc a.c -o a.exe -std=c99
[Bjarne@VM-8-8-centos cTest]$ ./a.exe
global val:0x601034
stack area val:0x7ffc3abfeefc
static stack area val:0x601038
[Bjarne@VM-8-8-centos cTest]$ 

可以看到,static 修饰的局部变量被当成了全局变量参与布局。

所以所谓的静态区就是全局数据区,静态数据区和全局数据区在概念上是差不多的,都是同一个存储数据的区域的不同描述。

字符区常量

例如这个代码:

int main(){
    char* str="helloworld";
    *str='H';//必定报错
    return 0;
}

这个代码在 GCC 系编译器会报错,在 MSVC 直接不给编译通过,需要使用 conststr 指向的内容进行修饰。

因为 helloworld 字符串是被安排到字符常量区,字符常量区和代码区一样同属只读数据段,所以 str 指针指向这个只读数据段的地址,不可通过指针对这个区域进行修改。

字符常量区和代码区一样同属只读数据段,但前者主要用于存储字符串的字面常量,而后者则是 C 语言代码翻译成的指令。对这个区域的研究后续再深入。

命令行参数和环境变量

根据程序地址空间,命令行参数和环境变量在栈区上层。这里只做简单的验证。

#include<stdio.h>
#include<stdlib.h>

int main(int argc,char* argv[],char* env[]){
    int a=233;
    printf("statc area var:%p\n",&a);
    for(int i=0;argv[i];i++){
        printf("argv[%d]: %p\n",i,argv[i]);
    }
    for(int i=0;env[i];i++){
        printf("env[%d]: %p\n",i,env[i]);
    }
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cTest]$ make
gcc a.c -o a.exe -std=c99
[Bjarne@VM-8-8-centos cTest]$ ./a.exe Solution lie within
statc area var:0x7ffc15070014
argv[0]: 0x7ffc15070777
argv[1]: 0x7ffc1507077f
argv[2]: 0x7ffc15070788
argv[3]: 0x7ffc1507078c
env[0]: 0x7ffc15070793
env[1]: 0x7ffc150707a9
env[2]: 0x7ffc150707c0

可以看到,局部变量的地址是 14,而命令行参数的地址比局部变量的地址高,环境变量比命令行参数的地址高。基本符合程序地址空间的分布。

虚拟地址

先来一段代码。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
int g_val = 0;
int main() {
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return 0;
    }
    else if (id == 0) { //子进程
        int cnt=5;
        while(1){
            printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
            sleep(1);
            if(cnt==0){
                g_val=200;
                printf("child change g_val: 100->200\n");
            }
            --cnt;
        }
    }
    else { //父进程
        while(1){
            printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    		sleep(1);
        }
    }
    return 0;
}

交互界面:

# 界面1
[Bjarne@VM-8-8-centos cTest]$ make
gcc a.c -o a.exe -std=c99
[Bjarne@VM-8-8-centos cTest]$ ./a.exe
parent[15032]: 0 : 0x601060
child[15033]: 0 : 0x601060
# 省略重复信息
parent[15032]: 0 : 0x601060
child change g_val: 100->200 # 修改全局变量
child[15033]: 200 : 0x601060
parent[15032]: 0 : 0x601060
child[15033]: 200 : 0x601060
child[15033]: 200 : 0x601060
parent[15032]: 0 : 0x601060
child[15033]: 200 : 0x601060
parent[15032]: 0 : 0x601060
^C
# 界面2
[Bjarne@VM-8-8-centos cTest]$ while :; do ps ajx|head -1&&ps ajx|grep a.exe|grep -v grep;echo 
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
-----
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
28434 15032 15032 28434 pts/0    15032 S+    1001   0:00 ./a.exe
15032 15033 15032 28434 pts/0    15032 S+    1001   0:00 ./a.exe
-----
# 省略大量重复信息
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
-----
^C
[Bjarne@VM-8-8-centos cTest]$ 

父子进程输出出来的变量值和地址开始是一样的,因为子进程按照父进程为模版,父子并没有对变量进行进行任何修改。

但当子进程对全局变量进行修改时,父子进程输出的全局变量地址一样,但内容不一样。

根据现象可以判断父子进程输出的全局变量绝对不是同一个变量。但地址值是一样的,且两个进程都能读到不同的内容,用 C 语言或 C++ 的知识无法解释这个现象。

若这是错的结论还好,但操作系统,也就是前人这样设计,这时只能猜测该地址绝对不是物理地址。

在 Linux 地址下,这种地址叫做虚拟地址。用户用 C/C++ 语言的函数看到的地址,全部都是虚拟地址。

物理地址用户看不到,由 OS 统一管理。且物理地址和虚拟地址必定存在关系,两个进程根据某种转换规则将同一个虚拟地址添加其他因素找到不同的物理地址。

所以上文的程序地址空间不是内存,更准确的说法应该叫进程地址空间

之前说 “程序的地址空间” 也是不准确的,因为程序充其量就是在磁盘上的文件,不调用它便什么事都不会发生。之前的实验都是由一个进程输出变量的四肢,属于进程的行为。

进程地址空间

程序以进程的方式运行在操作系统中,必定存在物理地址和 PCB,没有前者,程序无法存储数据;没有后者,进程将毫无意义。

且经过含 fork 的程序查看公共资源的地址时可以看到,程序读取到的数据绝不是指向真实的物理地址。且对进程(程序)地址空间的代表变量的地址进行观测,发现进程地址空间的概念确实存在。

页表的概念

做一个假设,若一个进程只由 PCB 、物理空间组成,操作系统对数据的管理机制是写时拷贝。当该进程生成一个子进程时,PCB 会拷贝一份继承给子进程,然后子进程修改部分数据例如独属于自己的 PID。此时若子进程没有修改内存的数据的指令时,物理内存内的数据并不会修改。

当父进程和它所有的子进程中,有一个对它们共同管理的物理内存进行修改时,根据写时拷贝的理论,物理内存会单独分出一个空间给这个需要修改数据的进程。

问题就出在这,分出的物理内存按理说地址应该和父进程指向的物理内存,同一个区域的不同数据理应不相同但实际上却是相同的

就像天王星会偏离计算好的轨道这一奇特现象,没有人告诉这一现象的原因,也没有人能完全证明牛顿定律是错误的,因此能做的就是假设存在一个未被观测的行星干扰天王星的轨道。

于是计算出这个行星的轨道,并在预测的位置通过望远镜找到了这个行星,并将它命名为海王星。

学计算机也是如此,无法证明之前的知识点是错误的,但也无法用之前的知识解释现在的现象,就只能引入造成现象的干涉实体和假设干涉机制,再去证明(前提是开发者没有透露任何信息)。

这个案例就是假说演绎法的直接应用:

  • 天王星的轨道和计算结果不符是观察现象
  • 天王星之外可能还存在一颗行星是提出假说
  • 大佬对这个可能存在的行星,根据已有的数据计算轨迹,这是演绎推理
  • 天文学家用天文望远镜观察被预测出来的轨道,发现海王星,这是检验验证

这个概念在高中的生物学有解释,但在很多领域都有应用,包括计算机。这里也使用假说演绎法对父子进程的异常现象进行解释。

既然一个进程存在 PCB、进程地址空间和物理内存,光靠这三者无法解释这个现象,那就引入一个以进程地址空间内的虚拟地址为键真实的物理内存为值数据结构。这里就假设这个数据结构为 “页表” 。

  • 进程访问物理内存的数据时,先根据虚拟地址,通过页表找到物理内存中对应的地址,再对物理内存进行访问。

  • 当父进程生成子进程时,PCB、进程地址空间和页表各自拷贝一份给子进程,因为子进程没有修改数据,所以父子进程指向同一块物理内存,物理内存的引用计数增加 1 。

  • 当子进程修改物理内存的数据时(例如修改全局变量g_val=200;),触发操作系统内的某种异常机制,于是物理内存就额外分出一块物理内存给子进程,子进程的页表中的键不做修改,但值发生变化。

至此,引入页表之后,现象得到了合理的解释。整个过程如下图所示。
请添加图片描述

接着就是分析页表的实现原理。例如,页表存储虚拟地址和物理地址之间的映射关系(错误的,后面分析):
请添加图片描述

进程访问物理内存的数据的前提是进程在运行过程中,用进程状态来描述就是运行状态,用实际情况就是进程的代码被 CPU 执行。

运行中的进程通过寄存器来找到自己的页表。这个寄存器在 x86 架构中叫做 CR3;在 ARM 架构叫做 TTBR0。为方便描述,这里统一叫 CR3 寄存器。

这里假设CR3 寄存器会保存当前进程的页表的地址。CPU 每个时间片运行不同的进程时,就是通过 CR3 寄存器找到每个进程的页表。

页表的本质

数据的基本单位

操作系统和磁盘(这里为方便描述,将磁盘、固态硬盘等存储数据的零部件统称为磁盘)这个设备进行 IO 交互是以某个大小的内存为单位进行数据交互。若查阅资料的话可以知道这个大小是 4KB ,这个 4KB 不是一成不变的,取决于系统。这里就以 4KB 进行分析和计算。

参考计算机组成原理。

这里简单描述一下有关计算机组成。物理内存在硬件上可以想象成是由无数个小的可以进行充、放电的门电路单位,或者是一些触发器单位构成。把数据写在物理内存本质是向物理内存进行充点或放电的过程

物理内存和磁盘在硬件上是可以做到把二进制数据从一个设备转移到另一个设备,本质上都是电路信号的低电平从一个设备到另一个设备。而数据从一个设备经过类似的过程转移到另一个设备,就可以认为这是一个拷贝的过程

操作系统从磁盘读取可执行程序的文件的时候,根本不关心读到的是文件的属性还是内容,或者说不直接关心,而是把它 4KB 为单位从磁盘先拷贝到内存当中的某一个位置,当数据有修改时(例如添加各种外部链接数据生成进程)再写入对应的内容。

所以计算机设计的时候,它在软件上的可执行程序即进程,也可以认为它 4KB 的大小在逻辑上进行划分。即可执行程序无论是属性,还是它的数据块,或者说无论是磁盘,还是物理内存,都是 4KB 为单位进行划分

未来无论是什么文件,基本都是以 4KB 为单位加载到物理内存的某一块。这一个个 4KB 的大小在操作系统原理上把它叫做页帧 (page size) 。

假设这个页帧的大小改成例如 8KB , 16KB,则存储文件的系统、内存管理也要改,此时编译形成的可执行程序最终也要按照新的 page size 进行加载。

I/O 的基本单位也是这个大小。毫不夸张地说,进程哪怕只修改了 1 个 bit 位,也必须先将这 1bit 关联的 4KB 拷贝到内存进行修改,修改完成之后再拷贝会内存。

根据之前的描述,进程存储数据的内存也是分为多个地址互相不连续的 4KB 内存单位。这些内存单位和进程的数据通过进程内的某种数据结构关联起来,在逻辑上形成一个完整的整体,访问它们时需要通过进程中的管理数据进行解读才能访问。

哪怕是磁盘中的可执行程序文件,也是以 4KB 即页帧为单位进行划分。

可以类比为盒子和杯子,无论杯子的大小如何,最终都要使用大小相同的盒子进行包装,4KB 就是盒子的基本单位。

I/O 配套的文件 struct file 除了指针还有一个文件缓冲区,这个文件缓冲区和页帧建立联系才能通过这个结构体修改磁盘中的文件

操作系统为每个进程分配的空间大小是 4 GB,即 4 , 294 , 967 , 296 4,294,967,296 4,294,967,296 byte,一个页帧的大小是 4096 byte,则一个进程需要有 1 , 048 , 576 1,048,576 1,048,576 即一百多万个页帧才能描述完一个完整的进程。当描述进程时,这里的页帧也可以叫页框

操作系统为管理庞大数量的页框,在内核当中也会维护一个数据结构叫做 struct page 。操作系统会从内存中划出十几个 KB 存储 struct page 结构:

struct page {
    int flag; // 每个bit位都可存储信息,一个int即可描述绝大多数情况
    // page的属性
    // ...
};
struct page pages[1048576]; // 结构体数组描述一个进程的所有页框

此时对内存的管理就变成了对 struct page 数组元素的增删查改。

推翻假说

这里算一笔账,若页表真的存储虚拟地址和物理地址会如何。

若真按照页表存储虚拟地址和物理地址这一对数据的话,则应该存在一个 8byte 的数据类型( 4byte 存储键,4byte 存储值)。

一个 32 位的进程理论上的虚拟地址数是 0 ∼ 2 31 − 1 0\sim 2^{31}-1 02311 2 31 2^{31} 231 个虚拟地址,这些地址每个都用 8byte 的内存来存储的话,那这个结构体组成的页表占用的内存就是 8 × 2 31 = 2 34 8\times 2^{31}=2^{34} 8×231=234 byte = = = 16 GB ,即页表光用于存储虚拟地址和物理地址的键值表就占用这么大的内存,则计算机没运行几个进程,内存空间就不够用了。所以之前的推测不成立。

既然页表存储虚拟地址和物理地址的假设不成立,那就只剩下了虚拟地址通过某种计算得到物理地址这个可能。

虚拟内存向物理内存的转换

在有的资料中,会把虚拟地址叫做逻辑地址。都是一个意思,这里统一叫做虚拟地址。

任何进程的代码指令只有被 CPU 执行,进程才算是真正地被运行。其中虚拟地址啊会经过 CPU 内部集成的一个小组件叫做单元 MMU ,虚拟地址通过 MMU 就能转化成物理地址。

转化过程中,整个虚拟地址会被分成 3 个部分,前 2 个部分占用 10 个比特位,最后一个部分占用 12 个比特位。

在 32 位系统的情况下,页表会被分成 2 级,第 1 级叫做页目录,这样的页目录每个进程只有一个,刚好对应 2 10 2^{10} 210 个数据。CPU 根据页目录通过虚拟地址的前十个比特位找到指定的二级页表, 2 级页表有 2 10 2^{10} 210 个,这样的页表叫做页表项。页表项会存储一个 4KB 页框的起始位置。

虚拟地址最后的 12 个比特位存储一个偏移量。而内存当中的任何一个数据,它一定要隶属于物理内存的某一个页框,所以二级页表存储的 4KB 内存块编号,加上后 12 个比特位的偏移量,即可找到一个 4KB 内存块的任何一个字节的内存。
请添加图片描述

这个过程就类似 C 语言的二维数组的地址查询加偏移量

int sys_find(const int&x) {
    // 为解释原理瞎编的查询函数
    // 动态规划有一大类叫做状态压缩dp,用的也是这个原理
    return add[(x>>22)&((1<<22)-1)][(x>>12)&((1<<12)-1)][x&((1<<12)-1];
}

所以采用页框的起始地址加页内偏移,就可以找到具体的物理内存。

使用这个方法,需要维护 1025 个页表,每个页表存储 1024 个数据,每个数据用 4 byte 存储,消耗的内存相比直接存储虚拟地址和物理地址的映射少了不少。

有了这个机制,甚至还可以允许一个大文件,只有局部加载到内存。

且一个进程在绝大多数情况下是用不完所有的二级页表的,很可能访问的绝大部分数据都集中在前 2、 3 个页框,所以压根就不会创建完整的 1024 个二级页表。只有当进程真正访问新地址时,通过页框索引时发现没有页表,于是发生缺页中断,重新创建页表构建映射。这样一来活跃的页表数量又会大大降低。

页目录的起始地址,在 CPU 中有 CR3 寄存器进行存储。每次进程的时间片一到切换下一个进程时,进程和它的页目录的起始地址都会重新加载到 CR3 寄存器。

如果是 64 位的进程,则虚拟地址只有 48 个比特位用于查询物理地址,页表则分成 3 级,每级对应 9 个 bit 位,则有 36 个 bit 位用于查询物理页框,最后 12 个 bit 位用于存储偏移量,和 32 位的进程是一样的。

缺页中断概念

一个可执行程序占用的内存可能非常大,例如 LOL ,光是磁盘上的可执行程序文件就是几个甚至十几个 GB,其他的软件的可执行程序可能会更大。而内存的大小就只有 8 GB,16 GB这种,基本不可能装下完整的可执行程序。

所以可执行程序启动时,生成 PCB 和按需申请物理内存,之后进程就可以在权限规定的范围内使用内存,用完后内存被操作系统回收。

进程的数据可能因为暂时挂起被交换到磁盘,释放出的内存由其他进程使用。这个时候原来的进程通过页表访问内存时,操作系统就将进程的访问请求暂停,然后进行如下工作:

  1. 内存中重新开辟空间
  2. 把对应可执行程序需要执行的虚拟地址对应的数据加载到内存里。
  3. 填充页表,或填充对应的虚拟地址
  4. 将页表的是否分配有内容的字段改为是或 1,代表的是内存已经分配,对应的内容已经填充。
  5. 继续进程的访问

进程访问虚拟地址时,它对应的物理地址指向的空间没有被申请,所以操作系统就暂停执行转而进行申请内存,然后加载内存并修改页表,这个工作在操作系统的概念中被称之为缺页中断

申请内存时,进程 PCB 并没有任何字段可以获取内存的状态信息,所以进程管理并不关心内存管理,进程管理和内存管理在工作上实现了在操作系统层面上的模块的解耦

操作系统能够读取或知道外设就绪的这样的一种技术,在计算机组成原理的概念中叫中断技术。CPU 和其他外部设备通过针脚(引脚)与主板相连,外部设备的光电信号通过主板上的传输线路和针脚被 CPU 获取,这些经过各种协议处理后的光电信号就是中断

通过中断技术, CPU 能快速对所有硬件进行快速反应。

进程地址空间的理论大小

相关概念详细见计算机组成原理。

计算机组件交流方式

冯诺依曼体系中描述的组件为了构成一个总体,一定存在信息交流方式,在现代的计算机中,这些计算机组件通过电流来进行连接。例如外部设备和内存之间的连线叫 I/O 总线,CPU 和内存之间的连线叫系统总线,它们都通过电流来传输信息。

计算机中的二进制,实际上是通过电压的大小来实现。在传统的 TTL 逻辑(晶体管-晶体管逻辑)中,+5V 代表高电平;在现代CPU中,可能低至 +1.2V 代表高电平。而而低电平则是接近 0 V。高电平表示1,低电平表示0,用这个方法可以以较低成本实现二进制。

成本高的比如人列计算机,几个人通过挥动黑、白旗帜表示二进制。或者部分沙盒游戏例如 Minecraft ,通过有无红石信号表示二进制。

这样,两个计算机组件之间通过电线通过电压的大小就能实现信息交流。不同时代的计算机的电线并不完全相同,最早的 ENIAC 是绝缘体包括导体组成的电线,现代计算机的电线可以是电路板上的精密铜走线(把铜蚀刻在电路板上)。

所以冯诺依曼体系中描述的组件通过电线进行信息交流,这样的电线被称为总线。这样的总线有 32 根,64 根或 128 根,128 根很少见,现代计算机几乎都是 64 根。且计算机中除了 2 的若干次幂条总线,还会有多的若干条,用于进行控制。

在物理学中,电流会从高电压流向低电压,内存中的数据拷贝到 CPU ,只需要把和二者关联的硬件开关进行调整电流就顺着总线流到了 CPU 内部。

进程地址空间的大小

计算机中 CPU 和内存的用于主要信息交流的总线有 32 根或 64 根。

以 32 根为例,每根总线的高、低电平表示二进制,根据乘法原理,32 根总线就是 2 32 2^{32} 232 种组合。而内存的1个数据单元是 1 byte,即 8 bit 位,每个组合表示 1 byte 空间的数据信息,内存之间的总线使 CPU 理论可控制的内存是 2 32 × 8  bit = 4 GB 2^{32}\times 8\ \text{bit}=4 \text{GB} 232×8 bit=4GB。即 CPU 可通过调整硬件开关的方式控制 4 GB 4\text{GB} 4GB 的比特位用于存储数据。

所以 在 32 位的计算机中,进程地址空间的大小是 4 GB 4 \text{GB} 4GB,同理若是 64 位则是 8 GB 8 \text{GB} 8GB。但实际上因为各种原因(例如操作系统内核本身要占用 1 GB 1\text{GB} 1GB等), 32 位的计算机实际可用的虚拟空间是 2 ∼ 3 GB 2\sim 3\text{GB} 23GB

当然,这个所谓的 4 GB 4\text{GB} 4GB 实际上是操作系统给进程画的大饼”,实际计算机运行有很多进程,每个进程运行需要空间,但内存只有这么多,所以采用进程间轮流调度来利用有限的资源,也就有了优先级、进程状态等各种进程相关概念。这个 4 GB 4\text{GB} 4GB 也就叫做虚拟地址空间

在 Linux 可通过 free -h 或查看文件/proc/meminfo 的内容查看。例如,这是一个标准云服务器的内存信息,显示了内存大小为 2GB 。

[Bjarne@VM-8-8-centos ~]$ free -h
              total        used        free      shared  buff/cache   available
Mem:           2.0G        220M        183M        628K        1.6G        1.5G
Swap:            0B          0B          0B
[Bjarne@VM-8-8-centos ~]$ cat /proc/meminfo
MemTotal:        2046500 kB
MemFree:          187864 kB
# 省略

在 Windows,通过图形化界面,可以通过查看系统信息来获取内存信息。其中机带 RAM 就表示内存大小。

常见的范围:

  • 轻薄笔记本: 8 GB ∼ \sim 16 GB。
  • 主流台式机/游戏本: 16 GB ∼ \sim 32 GB,部分厂家会预留凹槽使内存可扩充。
  • 高性能工作站/服务器: 64 GB ∼ \sim 数 TB。

区域划分

区域划分,本质上就是对一段线性空间,设置好它的起点和终点。

例如两个互相扯皮的男女同桌,在桌子上用粉笔画一条线,就将一张桌子划分成了两个区域,粉笔画的线就是同桌拥有的区域的起点和终点。

进程地址空间本质就是在被分配的 “大饼” 上划定若干区域的起点和终点,将这个 “大饼” 划分为栈区、堆区等。

操作系统给每个进程的 “大饼” 即内存,同样需要先描述再组织来对每个进程的内存进行管理。而组织的方式和 PCB 一样,同样是通过结构体对象组成的数据结构进行组织。

例如linux-2.6.32.19\include\linux\mm_types.h 中的mm_struct 就是内核地址空间的结构体描述。

struct mm_struct {
	struct vm_area_struct * mmap;		/* list of VMAs */
	struct rb_root mm_rb;//rb表示红黑树
	struct vm_area_struct * mmap_cache;	/* last find_vma result */
	//省略暂时不重要的信息

	unsigned long total_vm, locked_vm, shared_vm, exec_vm;
	unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
	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;

	//省略暂时不重要的信息
};

例如 env_startenv_end 表示环境变量被划定的区域。所以这个结构体的内存划分可以分成如下结构(这里是大概描述,内核中划分更复杂):

struct mm_struct{
    long code_start;//代码区
    long code_end;
    long data_start;//全局数据段
    long data_end;
    long heap_start;//堆区
    long heap_end;
    long stack_start;//栈区
    long stack_end;
//  ...
};

所以进程地址空间本质上是一种内核数据结构,C 语言的描述就是结构体。其中的地址叫做虚拟地址。

内存碎片问题

进程地址空间中,常见的操作之一是向堆区申请空间和释放申请的空间。

例如这个场景:

int main(){
    int* a=(int*)malloc(100);
    int* b=(int*)malloc(100);
    int* c=(int*)malloc(100);
    //...
    free(b);
    //...
    return 0;
}

Linux 的堆区的空间申请时是相对连续的,但使用过的空间被回收时,周边的空间可能并没有完全释放,造成后续产生更大的内存需求时无法在同样的位置申请,转而寻找其他更大的空间

且进程可能会频繁地向堆区申请少量的内存且不会及时释放,这时会将堆区的内存分割成很多小块的内存碎片,这就是内存碎片问题

内存碎片带来的问题主要有 3 个:

  1. 内存浪费: 这是最直接的问题。大量内存因为碎片化而无法被使用,降低了有效内存容量。
  2. 分配失败: 即使系统显示有足够的 “可用内存”,程序也可能因为无法找到连续足够大的内存块而崩溃。例如,在 C/C++ 中 malloc 返回 NULL,在Java 中抛出 OutOfMemoryError 的异常。
  3. 性能下降: 内存分配器需要花更多时间去寻找一个合适大小的空闲块,可能需要遍历很多个碎片块才能找到,或者触发更耗时的操作,如垃圾回收、内存压缩。

解决方案有很多,例如物理内存被划分为固定大小的 “页”( 4 KB),进程的数据被分散在物理内存中的不连续的单位,通过页表进行统一管理。

不止堆区,栈区也有大量的空间没被使用过。这时可以对这些内存进行二次分配,分配方式和进程地址空间的区域划分基本差不多,都是指定一个区域的起点和终点。

详细内容见内存池的设计。应用最多的地方就是 STL 的空间配置器。

地址空间存在的理由

理由:

  1. 方便管理无序的内存数据
  2. 方便进行安全检查
  3. 进程管理和内存管理进行解耦

分别对每个理由进行解释:

  1. 方便管理无序的内存数据。

在物理内存,可执行程序在内存中可以随意去加载,完全不考虑代码和数据在物理内存当中加载的位置顺序,因为进程对应的页表本身就是虚拟地址和物理地址的映射集合。

这种虚拟地址加页表的方式可以将分散在物理内存中的各个位置的数据块能作为统一的集合,从而方便管理。最形象的比喻就是花名册上登记的所有人的住址,管理者例如老师或者老板,可以通过这个住址找到住在这里的人,这样人就可以按照自己的情况随意选择住址。

所以任一一个进程,它可以通过地址空间加页表,将乱序的内存数据,变成有序的数据进行规划。

  1. 方便进行安全检查。

页表中除了虚拟地址和物理地址,还为每个虚拟地址安排了 2 个字段:访问权限字段是否分配有内容

页表的字段不止这些,具体取决于 CPU 的体系结构。

其他字段在进程调度算法时会描述一部分。

访问权限字段可以规定进程对这个物理内存的访问权限。常见的权限是只读或可读可写。

例如这个代码:

int main(){
    char* str="hello, world!";
    *str=H;
    return 0;
}

str 作为指针肯定无法存储 “hello, world!” 这个字符串,所以它存储的是这个字符串的虚拟地址

而这个字符串被安排在字符常量区权限是只读。当进程通过地址 str 试图修改字符常量区的数据时,会因为不符合页表划定的权限被拦截,这时操作系统会反馈段错误 : Segmentation fault

[Bjarne@VM-8-8-centos cTest]$ make
gcc a.c -o a.exe -std=c99
[Bjarne@VM-8-8-centos cTest]$ ./a.exe
Segmentation fault  # Linux常反馈这个信息
[Bjarne@VM-8-8-centos cTest]$ 
  1. 将进程管理和内存管理进行解耦。

解耦是软件工程术语,简单理解就是 “降低组件之间的依赖程度”,让它们变得相对独立,修改一个组件时,不需要或者很少需要修改另一个组件。解耦可以让软件更容易理解、维护、测试和扩展。

页表的字段分配有内容应用于进程挂起的情况。当进程被挂起时,进程有关的数据被操作系统强制交换到磁盘,在这种情况下页表里的虚拟地址对应的物理地址需要一一变更,对效率至上的操作系统来说这是不允许的。于是这个字段就用于记录物理地址指向的内存内的数据是否被挂起,若挂起了则将数据重新交换回内存再访问。

通过页表的字段是否分配有内容,可以将 PCB 的工作和内存管理的工作分离,使得两个组件的工作变得相对独立。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐