【寻找Linux的奥秘】第七章:虚拟地址空间

发布于:2025-05-22 ⋅ 阅读:(26) ⋅ 点赞:(0)

QQ20250519-134735

前言

本专题将基于Linux操作系统来带领大家学习操作系统方面的知识以及学习使用Linux操作系统。上一章我们简单认识了环境变量,本章将讲解操作系统中另一个重要的概念——程序地址空间。

1. 初步认识

之前在我们学习C语言和C++时我们知道,在我们的程序中不同类型的数据存储在不同的内存区域中,如下图所示(以32位平台为例):

QQ20250520-194119

我们再次来认识一下每个区域的名称以及保存什么类型的数据:

区域 与上图对应 存放内容类型 特点说明
内核空间 内核空间 内核代码、内核数据结构、驱动、系统调用接口等 用户态不可访问,系统保护区
栈(Stack) 函数调用栈帧、局部变量、函数参数、返回地址等 向下增长,自动分配和释放
共享库区域 共享区 动态链接库(如libc.so) 映射方式加载,可供多个进程共享
堆(Heap) 动态分配的变量(如:mallocnew 向上增长,由程序员控制释放
BSS段 未初始化数据 未初始化的全局变量和静态变量(默认为0) 加载时系统自动清零
数据段(Data) 初始化数据 已初始化的全局变量和静态变量 来自程序的数据部分
代码段(Text) 正文代码 可执行指令(函数体、主函数、库函数) 权限通常为只读,防止被修改

光看显然是不够的,下面让我们用代码进行验证,看看这些是不是结论是不是正确的:

#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);
    for(int i = 0 ;i < argc; 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;
}

我们来看一下运行结果:

QQ20250520-195520

可以看到这些数据对应的地址的大小变化与上图是一致的。那么,这些地址是我们的数据在内存上的真实地址吗?

2. 深入了解

俗话说的好:“实践是检验真理的唯一标准。”下面,让我们通过一段代码来探究一下这个问题:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
    pid_t id = fork();
    if(id < 0){
        perror("fork");
        return 0;
    } 
    else if(id == 0){ //child
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }else{ //parent
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    } 
    sleep(1);
    return 0;
}

上述代码的逻辑也十分简单:我们创建一个子进程,父进程和子进程执行不同的逻辑,打印全局变量g_val的值以及它的地址。下面让我们来看一下运行结果:

QQ20250520-200054

可以看到,父进程和子进程对于全局变量g_val打印的地址是相同的,这也很好理解,我们在前面讲解进程的时候说过:子进程会复制父进程的代码,并且访问的是同一变量,只有当子进程要对变量进行修改时,会发生写时拷贝,这样,父子进程在修改变量时便只会修改属于自己的那一份,使进程之间具有了独立性。

那么接下来让我们修改一下代码,让子进程对g_val变量进行修改,再观察运行结果。代码如下:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
    pid_t id = fork();
    if(id < 0){
        perror("fork");
        return 0;
    } 
    else if(id == 0){ //child
        g_val = 100;
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }else{ //parent
        sleep(2)
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    } 
    sleep(1);
    return 0;
}

上述代码中,我们让子进程先进行打印,后让父进程打印,也就是先让子进程获得g_val的地址,后让父进程获得g_val的地址。下面让我们来看一看运行结果:

QQ20250521-111625

可以看到运行结果出乎我们的意料。子进程不是应该发生写时拷贝吗?为什么父子进程中的g_val的地址一样?为什么相同的地址会有两个不同的值出现?

我们一点点来分析:

  • 首先:变量内容不一样,说明父子进程输出的变量不是同一个变量,也就是说子进程的确是进行了写时拷贝;
  • 其次:我们看到两个不同的变量却有相同的地址,那么只有两种情况可以解释:要么是出现了bug,要么这个地址不是真实的物理地址。

结果已经很明显了,这显然不是bug,唯一的解释就是这些地址不是我们真实的物理地址,在Linux中,这种地址叫做虚拟地址。我们在C/C++语言中见到的所有地址,全部都是虚拟地址。物理地址用户是看不到的,由OS统一管理。管理的方式就是将虚拟地址和物理地址通过页表的方式进行映射。

页表

我们简单来认识一下什么是页表:在操作系统(OS)中,页表(Page Table)是用于实现虚拟内存管理的关键数据结构。

页表是操作系统为每一个进程维护的一个数据结构,用来将虚拟地址转换为物理地址。当程序运行时,它访问的是虚拟地址,而不是直接访问物理内存。CPU 通过页表将这些虚拟地址映射成实际的物理地址,从而访问真正的内存。

在页表中除了有虚拟地址以及其所对应的物理地址,还包括这块空间的读写权限等信息。

对于页表我们目前只需要了解它的作用即可。现在我们知道了,我们目前所看到以及访问的地址都是虚拟地址,对于每一个进程都有一块属于自己的虚拟地址空间,也叫做进程地址空间

对于32位机器下虚拟地址空间大小通常为4G(从0x00000000到0xFFFFFFFF),64位机器下虚拟地址空间大小通常为8G。(从0地址开始,32位机器表示 CPU 使用 32 位的虚拟地址,那么 32 位地址最多能表示地址的数量也就是232字节,也就是4G)

那么对于上面的代码我们就可以进行解释了:

当子进程被创建时,两个进程的页表都指向同一份物理内存(包括代码段),也就是说父子进程都指向同一块物理空间:

QQ20250521-202012

一旦某个进程尝试修改内存如写堆或数据段,就相当于上述代码中子进程更改g_val的值,操作系统就会为它分配一个新的物理内存,拷贝旧数据,并把页表中之前的物理地址进行更改:

QQ20250521-214439

这样一来,虽然我们查看的地址相同,那是因为我们查看的是虚拟地址,而实际上这个虚拟地址所映射的物理地址已经发生了改变,子进程的数据有了自己新的物理空间,从而保障了进程之间的独立性。

3. 理解虚拟地址空间

我们已经简单的了解了虚拟地址空间,也知道了虚拟地址空间的存在可以有效地对物理地址进行保护。那么虚拟地址空间究竟是什么呢?我们该如何去理解它呢?

虚拟地址空间的本质是一个数据结构struct mm_struct(在Linux中的名字),这是因为每一个进程都有其对应的虚拟地址空间,我们需要对这些空间进行管理,所以也需要先进行组织。虚拟地址空间也是描述进程的一种属性,所以结构体mm_struct也存放在task_struct(PCB)中。

那么是不是说对于每一个进程我们都会给其分配一个的4G的物理空间呢(32位机器)?实际上并不是,这里所谓的4G是每一个虚拟地址空间的最大大小。也就是说实际上每一个进程所占据的物理内存实际上要远小于虚拟内存(具体原因后面再说),虚拟地址空间的大小只是让每一个进程都认为自己有4G的物理内存,或者说让进程认为自己在独占物理内存。

那么在mm_struct中是如何来划分每个区域的大小呢?实际上在mm_struct中我们用整数来表示每一个区域的开始和结束,也就是说我们只需要知道一个区域的开始和结束便可以划分出一块区域,因为我们的虚拟地址空间的大小是固定的。当需要调整区域大小时,只需要调整相应区域的开始与结束的数字即可。

下面让我们看一看Linux的内核代码中是否和我们描述的一样(2.6.18版本):

QQ20250521-222507

这里面包括了各个区域的开始和结束。

我们再来梳理一下进程与虚拟地址空间的关系:

当我们的程序写好后相应的数据和代码都保存在磁盘中,程序运行时变为一个进程,这时磁盘中的代码和数据便会加载到物理内存中,以代码为例,例如我们的程序代码有10兆字节,那么OS就会在物理内存中给它分配10兆大小的空间,对进程的虚拟地址空间中的代码段也分配相应的大小,然后通过页表将虚拟地址和物理地址形成一一对应的映射关系。

也就是说我们要把一个程序加载到内存需要做两件事情:

  1. 在对应的虚拟地址空间中申请指定大小的空间(调整区域划分)。
  2. 加载程序,申请物理空间。

然后再通过页表构建映射关系即可。那我们其实就可以理解为是OS将物理地址转为虚拟地址提供给上层用户使用。

我们知道mm_struct是一个结构体,那么当我们构建它时也需要进行初始化,那么它初始化的值是从哪得到的呢?大部分的值是我们的程序加载到内存的过程中拿到的。

可以说,mm_struct结构是对整个进程空间的描述。每⼀个进程都会有⾃⼰独⽴的mm_struct,这样每⼀个进程都会有⾃⼰独⽴的地址空间,这样才能互不⼲扰。

每⼀个进程都有⾃⼰独⽴的mm_structmm_struct结构体中存在许多虚拟内存区域(VMA)。那么这些VMA肯定是要组织起来的,VMA的组织⽅式有两种:

  1. 当VMA较少时采取单链表,由mmap指针指向这个链表;
  2. 当VMA较多时采取红⿊树进⾏管理,由mm_rb指向这棵树。

Linux内核使⽤ vm_area_struct 结构来表⽰⼀个独⽴的VMA,由于每个不同质的虚拟内存区域功能和内部机制都不同,因此⼀个进程使⽤多个vm_area_struct结构来分别表⽰不同类型的虚拟内存区域,对于堆和栈来说可能会存在多个vm_area_struct,因为它们的大小是动态分配的。上⾯提到的两种组织⽅式使⽤的就是vm_area_struct结构来连接各个VMA,⽅便进程快速访问。

下面是Linux内核中vm_area_struct结构体的定义:

struct vm_area_struct {
    unsigned long vm_start; //虚存区起始
    unsigned long vm_end; //虚存区结束
    struct vm_area_struct *vm_next, *vm_prev; //前后指针
    struct rb_node vm_rb; //红⿊树中的位置
    unsigned long rb_subtree_gap;
    struct mm_struct *vm_mm; //所属的 mm_struct
    pgprot_t vm_page_prot;
    unsigned long vm_flags; //标志位
    struct {
        struct rb_node rb;
        unsigned long rb_subtree_last;
    } shared;
    struct list_head anon_vma_chain;
    struct anon_vma *anon_vma;
    const struct vm_operations_struct *vm_ops; //vma对应的实际操作
    unsigned long vm_pgoff; //⽂件映射偏移量
    struct file * vm_file; //映射的⽂件
    void * vm_private_data; //私有数据
    atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMU
    struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
    struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
    	truct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;

我们还可以通过图示来更加深入的理解:
QQ20250521-230438

4. 为什么存在虚拟地址空间

4.1 进程隔离和安全性

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

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

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

  • **安全⻛险:**每个进程都可以访问任意的内存空间,这也就意味着任意⼀个进程都能够去读写系统相关内存区域,如果是⼀个⽊⻢病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。
  • **地址不确定:**众所周知,编译完成后的程序是存放在硬盘上的,当运⾏的时候,需要将程序搬到内存当中去运⾏,如果直接使⽤物理地址的话,我们⽆法确定内存现在使⽤到哪⾥了,也就是说拷⻉的实际内存地址每⼀次运⾏都是不确定的,⽐如:第⼀次执⾏a.out时候,内存当中⼀个进程都没有运⾏,所以搬移到内存地址是0x00000000,但是第⼆次的时候,内存已经有10个进程在运⾏了,那执⾏a.out的时候,内存地址就不⼀定了
  • **效率低下:**如果直接使⽤物理内存的话,⼀个进程就是作为⼀个整体(内存块)操作的,如果出现物理内存不够⽤的时候,我们⼀般的办法是将不常⽤的进程拷⻉到磁盘的交换分区中,好腾出内存,但是如果是物理地址的话,就需要将整个进程⼀起拷⾛,这样,在内存和磁盘之间拷⻉时间太⻓,效率较低。

当有了虚拟地址空间和分页机制就能解决这些问题:

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

举一个很简单的例子:为什么当我们在程序中对一个常量字符串进行更改时程序会崩溃?最根本的原因就是当我们在查找页表时,页表对于这一部分地址的数据的权限是只读,所以我们的操作就是不合法操作,程序自然就会崩溃。

4.2 简化程序设计

我们要知道,一个程序的代码和数据加载到物理内存上是无序的,也就是说对于一部分数据哪里有位置就加载到哪,也就是说一个程序加载到物理内存上时它的代码和数据的位置都是随机的,这很不方便我们进行管理,有了虚拟地址空间,我们不仅可以将这些在物理上无序的地址变得看似有序。因此程序员在写程序时不必考虑系统中已有进程的内存分配情况。

  • 所有程序都认为内存从地址 0x00000000 开始,逻辑简单。
  • 不需要关心其他程序的存在或物理内存的布局。

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

4.3 解耦合

因为有虚拟地址空间的存在和⻚表的映射的存在,我们的物理内存中可以对未来的数据进⾏任意位置的加载!物理内存的分配进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合

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

4.4 支持虚拟内存和更大地址空间

在没有虚拟地址空间的情况下,进程只能访问直接映射到物理内存的地址,也就是说,进程需要直接管理和访问实际的物理内存。每个进程的虚拟地址空间是由操作系统抽象出来的,它在进程和物理内存之间提供了一层“虚拟化”。

虚拟地址空间 让每个进程感觉到自己拥有完整且独立的内存地址空间,而不需要关心实际的物理内存布局。操作系统通过虚拟内存机制将虚拟地址映射到物理内存中。

  • 每个进程都有自己的虚拟地址空间,操作系统可以在虚拟地址空间中分配更多的内存区域,而不受物理内存的限制。程序可以使用比实际物理内存更大的内存空间
  • 通过页面置换等机制,把不常用数据临时换到磁盘,而不是一直占用物理内存。
  • 支持大程序运行在小物理内存的机器上。

比如你物理内存只有 8GB,但程序可以运行 16GB 的数据。

对于虚拟地址空间还有更多的知识,本章我们进行初步的认识和了解。更多的我们将在后面随着学习的深入再慢慢了解。

尾声

本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!


网站公告

今日签到

点亮在社区的每一天
去签到