关注

深入理解Linux进程地址空间:从虚拟地址到物理内存的完整刨析

封面


一、 从现象出发:C语言程序的内存布局

1.1 教材中的空间布局图

我们在学习C语言的时候,曾见过这样的空间布局图:
看到这个布局图,就引出了一个问题:下面的这个空间布局图所描述的内容是内存吗?
空间布局图

这个空间布局描述的不是内存,这个叫进程地址空间/虚拟地址空间

程序地址空间这种说法是不准确的,准确的说应该叫进程地址空间

1.2 如何用代码来验证各段的空间地址

我们有如下测试代码:

#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);//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;

}

空间地址分配

堆区向地址增大的方向增长,栈区向地址减小的方向增长

堆栈相对而生
变量类型决定访问多少个空间,整体向下开辟空间,使用的时候又从低地址向高地址访问

1.3 初见虚拟内存空间

1.3.1 示例代码一:现象复现

我们一开始说虚拟内存空间,那么我们能否见一见虚拟内存空间呢?

#include<stdio.h>
#include<unistd.h>
int g_val = 100;
int main()
{
    printf("g_val: %d,&g_val: %p\n",g_val,&g_val);
    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;
}

fork()的本质是创建进程,子进程创建时要以父进程为模板,说人话就是:
进程 = 内核数据结构+代码和数据–对于fork()这里不过多赘述

测试结果如下:
初见

我们发现,输出出来父子的变量值和地址一模一样,这很好理解因为子进程按照父进程为模板,父和子并没有对变量进行任何修改。

1.3.2 示例代码二:一个矛盾现象

那么如果我们对代码稍加改动:

#include<stdio.h>
#include<unistd.h>
int g_val = 0;
int main()
{
    printf("g_val: %d,&g_val: %p\n",g_val,&g_val);
    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(3);
            printf("parent[%d]: %d : %p\n",getpid(),g_val,&g_val);
    }
    sleep(1);
    return 0;
}

然后我们就可以发现一些神奇现象:
父子进程打印出来的虚拟地址是一样的,那为什么值不一样呢?
再见
父子进程打印出来地址是一样的,一个地址怎么可能即等于100又大于100!!!这个地址可不可能是物理地址?答案是这个地址不是物理地址。指针不是物理地址,我们语言上所学的地址都不是物理地址,我们学的那些地址都叫做虚拟地址,虚拟地址与虚拟地址空间有关

子进程对变量进行修改,父进程不变,子进程对父进程的任何修改都不会影响父进程。

二、 核心概念:虚拟地址空间

2.1 什么叫做虚拟地址空间?

2.1.1 大富翁与私生子类比

经过上述的引入,我们知道系统会有两个空间,两种地址:

  1. 虚拟地址空间
    程序加载到内存变成进程,开辟一个虚拟地址空间,操作系统利用页表,页表会将虚拟地址和物理地址进行映射
  2. 物理内存
    程序启动时,将内容加载到内存中

但问题是虚拟地址空间,我们应该怎样理解呢??我们可以举一个例子:
假设有一个大富翁,他有10亿的财产和若干的公司,但是他有一群私生子,私生子之间相互不知道对方的存在…
有一天,大富翁对自己的每一个私生子许下承诺:孩儿啊~等我死了我的十亿家产都是你的,你要好好努力啊!
站在大富翁的角度,他并不可能将自己的所有财产都给自己的某一个孩子,并且私生子敢一次性要走自己老爹的所有财产吗?老爹说滚蛋吧我还没死呢,当然是不敢的,但是站在私生子的角度,老爹的所有钱都是我的。这个种行为叫什么???–画大饼
在计算机的维度上大富翁就是OS,10亿资产就是物理内存的大小,画的大饼当然是虚的嘛也就是虚拟内存的大小,私生子就是计算机中的进程们。
虚拟地址空间的理解
上述都是抽象的概念,那么我们实际理解虚拟地址空间的落脚点是什么?OS怎么管理虚拟地址空间的呢?
实际上虚拟地址在OS内部就是一个struct结构体对象–struct mm_struct()内核数据结构。

2.1.2 本质:一个内核数据结构(mm_struct)

虚拟地址空间的本质其实是:一个内核数据结构

2.2 虚拟地址如何映射到物理内存

2.2.1 页表的作用

第一个问题:上面的理论在运用层是怎样运转的呢?我们接着从解决问题的角度继续探讨:

  1. 为什么全局变量,static变量生命周期是全局的
    默认一个变量是在栈区开辟的,用static修饰后变量所处区域就变到了和全局变量一起,全局和静态数据都在未初始化和初始化数据区。只要进程地址空间存在,那么全局数据区和常量数据区就会一直存在。

本质是生命周期是跟随着进程的,只要进程还活着它就存在。

  1. 常量字符串和代码为什么是只读的?页表项(PTE)中包含 R/W 位(读写权限)、U/S 位(用户/内核态权限)、P 位(Present,表示页面是否在内存中)等标志位,正是这些标志位实现了对不同区域的访问控制。
  2. 是否存在标志位(描述一个地址是否在内存里,若为0,则说明该段代码被挂起到磁盘中了)

挂起:将物理内存中的数据转到磁盘中swap区,要访问的话会重新加载内存

注意:
虚拟地址不转为物理地址,所保存的内容就无法修改

虚拟地址空间是一个系统级的概念,所有语言都有,而且JAVA虚拟机和Python解释器本质上都是一个进程,这样我们就可以进一步的解释编译型语言和解释型语言的区别了。
无论是编译型语言(如 C/C++)还是解释型语言(如 Python),最终都需要以进程的形式运行。区别在于:编译型语言的源码先被编译为机器码,执行时操作系统直接加载机器码创建进程;解释型语言的源码由解释器(本身是一个进程)逐行读取、解释并执行。

第二个问题:查表的工作是谁做的?
不是软件,是MMU内存管理单元的硬件,集成在CPU中,硬件报错,OS一定会知道

2.2.2 写时拷贝(Copy-on-Write)机制

上面一个小节我们解释虚拟地址空间,那么下面我们来解释一下第二个问题中父进程和子进程的虚拟地址一样,那为什么值不一样?
首先:在写代码的过程中用户拿到的其实是虚拟地址,通过页表拿着虚拟地址查物理地址,拿到物理地址后将数据写入到物理地址中。
但是:同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址上:
分页和虚拟地址空间

结论一:虚拟地址空间和页表,每一个进程各自有一套
结论二:fork之后,父子共享代码和数据,为什么?因为子进程会拷贝父进程的页表,类似于语言当中的浅拷贝
那么我们引入一个概念:写时拷贝
写时拷贝:当任一进程尝试修改共享数据时,操作系统会为该进程分配新的物理页面,将原数据拷贝过去,然后修改该进程的页表映射,使其虚拟地址指向新的物理地址。虚拟地址本身保持不变。(也就是将映射重新修改)这个就类似于深拷贝。

2.3 虚拟地址空间如何保证进程的独立性?

之前我们说过:进程之间是具有独立性的,进程之间是如何保证独立性的呢?
如果代码是只读的,父子之间不会影响。但是,如果数据被修改,就会导致进程不独立。所以操作系统规定父子中,任何一个进程,尝试对共享的变量进行修改,不能直接被修改,而要发生"写时拷贝"
进程具有独立性的原因:进程具有独立性是因为他在代码层面上和数据层面上全部都做到了解耦,进程的代码和数据全部都是独立的,包括进程控制块

再次引入问题:物理地址我们能看到吗?
答案是用户看不到,物理地址被OS隐藏起来的。
为什么要被隐藏?因为要是可以利用指针可以访问物理内存的任一数据,对数据而言是不安全的,隐藏起来可以变相的保护用户的数据。

总和上述知识就能回答上面的问题:
pid_t id = fork();
if(id == 0)
{}
else if(id >0)
{}父子进程中 &g_val的虚拟地址是一样的,但经过写时拷贝后,它们映射到的物理地址是不一样的。正因为物理地址不同,所以同一个虚拟地址可以读出不同的值。

三、 内核实现:地址空间的管理方式

3.1 mm_struct整个地址空间的描述符

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

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

可以说,mm_struct结构是对整个用户空间的描述,每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才不会互不干扰。那么task_struct到mm_struct进程地址空间是如何分布的呢?
进程地址空间分布情况
在内核代码中,mm_struct文件所在的位置和task_struct所在路径是一样的,但是二者所在的文件是不一样的。

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;
}

3.2 vm_area_struct 每个独立区域的描述

3.2.1 链表 vs 红黑树:两种组织方式

既然每一个进程都会有自己独立的mm_struct,OS是怎样将那么多进程的mm_struct组织起来的呢?虚拟空间的组织形式有两种:

  1. 当虚拟区较少时采取单链表,由mmap指针指向这个链表
  2. 当虚拟区间多时采取红黑树进行管理,由mm_rb指向这棵树
    Linux内核使用vm_area_struct结构来表示一个独立虚拟内存区域(VMA)。由于每个不同的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存地址。上面提到的两种组织方式使用的就是vm_area_struct结构来连接各个VMA,方便进程快速访问。

3.2.2 VMA中的关键字段解读

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
    struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;

虚拟内存区域

四、总结:为什么要有虚拟地址空间?

4.1 直接使用物理内存的缺陷

在早期的计算机系统中,程序直接运行在物理内存上,这种模式存在三个严重问题:

4.1.1 安全风险

每个进程都可以访问任意物理内存地址,这意味着恶意程序能够随意读写系统相关内存区域,包括内核数据和其他进程的数据,系统毫无安全防护能力。

4.1.2 地址不确定性

程序每次加载到物理内存的位置都不固定,导致编译时无法确定最终的运行地址,需要复杂的重定位机制。同一程序在不同时刻运行,其代码和数据的物理地址可能完全不同。

4.1.3 内存操作效率低下

进程作为整体在内存和磁盘之间进行换入换出操作,当物理内存不足时,需要将整个进程全部拷贝到磁盘交换分区,这种整进整出的方式涉及大量数据拷贝,性能极差。

4.2 虚拟地址空间的解决方案

4.2.1 内存访问控制与保护

地址空间和页表由操作系统创建并维护。所有内存访问必须先经过页表的映射转换,而页表的建立和维护完全处于操作系统的监管之下。页表中包含权限标志位(读/写/执行),进程无法访问未经授权的物理地址,从而在硬件层面实现了对物理内存的保护。

4.2.2 进程管理与内存管理的解耦

引入虚拟地址空间和页表映射后,物理内存中数据的实际存放位置与进程视角的地址布局相互独立。进程管理模块不再关心物理内存的分配策略,内存管理模块也可以独立优化物理页框的分配与回收,两者通过页表完成映射关联,实现了模块间的解耦。

4.2.3 物理内存布局的有序化

页表可以将虚拟地址映射到物理内存的任意位置。从进程的角度看,其虚拟地址空间始终保持着代码段、数据段、堆、栈等有序的布局;而从物理内存的角度看,进程的数据可以分散存放在不同的物理页框中,无需连续。这种映射机制使得物理内存的碎片化问题对进程完全透明。

4.2.4 按需分页(Demand Paging)机制

虚拟地址空间的申请与实际物理内存的分配被拆分为两个独立步骤。进程创建时,仅分配虚拟地址空间并建立页表,页表项标记为"不在内存中",真正的物理页面在首次访问时通过缺页中断按需分配。

具体体现为两个典型场景:

  • 进程创建:Linux系统中,fork()exec()创建进程时,先分配task_structmm_struct等内核数据结构,建立虚拟地址空间,之后才逐步加载代码和数据;
  • 堆内存申请malloc()new操作仅调整虚拟地址空间中的堆区边界(brk指针),并不立即分配物理内存。只有当进程真正读写该内存区域时,缺页中断才触发物理页框的实际分配。

这种延迟分配策略减少了不必要的物理内存占用,加快了进程创建速度,并使得系统能够运行比物理内存更大的程序。


欢迎大家批评指正!!!

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2402_90030627/article/details/162911490

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--