6. 程序地址空间

目录

一、知识回归

1.1. 回答下面的问题:

1.2. 程序地址分布图

二、回顾之前的一个问题

2.1. 代码

2.2. 分析

三、进程地址空间

3.1 画图

3.2. 引出页表

四、虚拟地址和物理内存地址

4.1. 举个例子

4.2. 对应关系

4.3. 一个空间中,有很多的区域,是怎么进行区域划分的?

五、操作系统中的虚拟内存管理、进程内存布局以及物理内存与磁盘交互的核心原理

5.1. mm_struct

5.2. 问题:虚拟地址的区域划分在页表中都有对应的物理内存,请问 char * str = "hello" str = "h" 为什么不能运行成功?

5.3. 动态生命周期(加载、缺页、换出)

5.3.1. 惰性加载

5.3.2. 缺页中断

5.3.3. 换出与挂起

六、虚拟内存管理

6.1. 普通进程 vs. 内核线程

6.1.1. 对于普通用户进程

6.1.2. 对于内核线程 

6.2. mm_struct的相关介绍

6.3. 虚拟空间的组织方式


一、知识回归

1.1. 回答下面的问题:

#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
using namespace std;

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_mem1
    printf("heap addr: %p\n", heap_mem2);       // heap_mem3
    printf("heap addr: %p\n", heap_mem3);       // heap_mem1
    printf("test static addr: %p\n", &test);    // heap_mem1
    printf("stack addr: %p\n", &heap_mem);      // heap_mem2
    printf("stack addr: %p\n", &heap_mem3);     // heap_mem2
    printf("read only string addr: %p\n", str); // heap_mem3
    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;
}
  • 问题一:上面有那些变量,都属于什么变量?
  • 问题二:每个变量 / 数据的存储位置
  • 问题三:画出具体的程序地址分布图

答案一:

全局变量

  • g_val (已初始化,int g_val = 100)

  • g_unval (未初始化,int g_unval)

静态局部变量

  • test(static int test = 10,定义在 main 内)

局部变量(自动存储期)

  • str (const char *,指向常量字符串)

  • heap_mem (char *,指向堆内存)

  • heap_mem1 (char *,指向堆内存)

  • heap_mem2 (char *,指向堆内存)

  • heap_mem3 (char *,指向堆内存)

  • argc (int,main 的参数)

  • argv (char **,main 的参数)

  • env (char **,main 的第三个参数)

  • i (for 循环中的 int)

无名内存实体(不是变量,但与变量紧密相关)

  • 四次 malloc(10) 申请的堆内存块(分别由 heap_mem、heap_mem1、heap_mem2、heap_mem3 指向)

  • 字符串字面量 "helloworld"(由 str 指向)

  • argv 数组指向的命令行参数字符串

  • env 数组指向的环境变量字符串

答案二:

变量 / 数据存储区域
main 函数的代码代码段(.text)
"helloworld" 字面量只读数据段(.rodata)
g_val(全局已初始化)数据段(.data)
test(静态局部,已初始化)数据段(.data)
g_unval(全局未初始化)BSS 段(.bss)
四个 malloc(10) 返回的堆块堆(heap)
str 指针变量本身栈(stack)
heap_mem 指针变量本身
heap_mem1 指针变量本身
heap_mem2 指针变量本身
heap_mem3 指针变量本身
argc
argv 指针变量本身
env 指针变量本身
i
argv[] 中的字符串栈顶部(初始栈)
env[] 中的字符串栈顶部(初始栈)

1.2. 程序地址分布图

相关区域的解释:

代码段
        代码段存放程序的机器指令(编译后的可执行代码),通常是只读的,防止意外修改,并允许多个进程共享同一份代码。包含以下子区域:

  • .init:程序启动时由系统自动调用的初始化代码。

  • .text:主程序的指令,如 main() 函数、自定义函数的机器指令。

  • .rodata:只读数据,如字符串常量 "Hello World!"。

数据段

        存放全局变量和静态变量(static),分为两部分:

  • .data:已初始化(显式赋初值)的全局变量和静态变量,如 int x = 5;。

  • .bss:未初始化的全局变量和静态变量,如 int y;。

进程控制块(PCB)

        虽然 PCB 是管理进程的核心数据结构,但它不属于进程的虚拟地址空间。它由操作系统内核维护,存放在系统内核区域,对用户进程不可见。

        程序运行时动态申请内存的区域,通过 malloc() 等函数分配,用 free() 释放。堆从低地址向高地址增长,大小不固定,适合生命周期不确定的数据。例如,int ptr = (int) malloc(4); 在堆上分配 4 字节。

        用于支持函数调用,存放局部变量、函数参数、返回地址等临时信息,由系统自动管理。遵循后进先出(LIFO)原则,从高地址向低地址扩展。例如,调用 add(3, 5) 时,参数和返回地址被压栈,函数返回后弹出。

共享库的存储映射区

        共享库用于提供进程所依赖的公共函数代码(如 printf() 等标准库函数)。它们在运行时被动态映射到进程的地址空间,代码是只读的,且可被多个进程共享,从而节省内存资源。

整体概述:

        其中,最高地址处是用户栈,随函数调用向下增长;其下方是共享库的映射区域,用于加载动态库;再往下是堆,由 malloc() 等函数动态扩展;堆下方是读/写数据段(.data 和 .bss);底部是只读代码段(.init、.text、.rodata);注意,代码段和数据段的大小在程序加载时就已确定,而堆和栈则可在运行过程中动态扩展和收缩。

上面的内容过于复杂,下面我们会用这个图来代替:


二、回顾之前的一个问题

2.1. 代码

#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
using namespace std;

int gval = 100;
int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        while (1)
        {
            printf("子进程: gval: %d,&gval: %p,pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());
            sleep(1);
            gval++;
        }
    }
    else
    {
        while (1)
        {
            printf("父进程: gval: %d,&gval: %p,pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());
            sleep(1);
        }
    }
   

2.2. 分析

已知知识:

  • 进程具有独立性,子进程将数据做修改,即使是全局变量,也并不影响父进程。
  • 一台电脑只有一个物理内存,即只有一个物理地址

观察到的信息:

  • 可观察到,数据修改,但其地址是一样的,确互不影响
  • 父子进程,输出地址是一致的,但是变量内容不一样!

得出如下结论:

  • 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
  • 但地址值是一样的,说明,该地址绝对不是物理地址!
  • 在Linux地址下,这种地址叫做 虚拟地址
  • 我们在用C/C++等几乎所有语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
  • OS必须负责将 虚拟地址(也叫线性地址) 转化成 物理地址 。
     

三、进程地址空间

3.1 画图

通过上面代码的跑出来的结果,可知,这两个进程的 g_val 指向的 虚拟地址是一样,
但是 g_val 的值是不一样的,说明这个 g_val 在内存中一定存在两份。

通过上面画图的过程和结果,我们可以发现:

  •         拷贝的是指针,发生浅拷贝,我们就理解,为什么父进程创建子进程,全部变量 g_val 的地址是一样的,因为发生了浅拷贝,使得他们的虚拟地址空间的值是一样的。
  •         同理也知道了,为什么全局变量默认父子共享,是因为映射关系,从虚拟到物理是一样的。指向一个内存

3.2. 引出页表

虚拟地址空间,是如何跟内存联系在一起的?

通过页表

页表是用来将 虚拟地址空间 和 物理内存空间 进行映射的

整理的流程就是:

        使用系统调用接口创建新的进程时,fork后的数据代码,父子进程将会同时执行,同时增加新的进程控制块,父子进程通过刚开始相同的页表指向相同的物理空间,其所使用的进程地址空间对应的位置也是相同的,父子进程指向同一个g_val,因此,父进程和子进程对应的g_val的地址是相同的,但是,当子进程尝试修改g_val变量时,为保证进程的独立性,操作系统识别到当前子进程通过页表找到g_val,想修改g_val,此时,操作系统会重新开辟一段空间,将上述值拷贝下来,修改映射关系,因此使用不同的物理内存地址,互不影响,互相独立。

         父子进程创建时使用相同的虚拟地址,而进行修改时,经操作系统识别,重新复制一份,并开辟新的空间,经过页表映射的是不同的物理地址,此时修改的是不同的物理地址的数据,其虚拟地址不受影响,这种策略叫做写时拷贝。

        再次回答之前,为什么 fork 函数可以返回两个返回值?

        fork函数在return时之前,会创建新的进程控制块,而此时return便会同时执行两次,而return的本质,就是对父子进程ret变量进行写入,此时便会进行写时拷贝,父子进程各自拥有属于自己的物理空间,虽然对应虚拟地址相同,但其对应的物理地址的值不同,只不过在用户层我们用同一个变量(虚拟地址)来标识了


四、虚拟地址和物理内存地址

4.1. 举个例子

现在一个老板,有四名员工。
这个老板经常画大饼,
给员工A说,等到年底,干得好的话,给你奖励10w年终奖
给员工B说,等到年底,绩效不赖的话,给你奖励一台奔驰
给员工C说,等到年底,所有工作都做好,给你奖励60m^2的小房子
给员工D说,看你新来的,干得不错,继续保持,等到年底,让你去进修

画大饼,这个事情,懂的都懂,老板容易做不到,或者就是容易记错。
有一天,给员工A说,等到年底,干得不错的话,奖励一个60m^2的房子
给B说,等到年底,干得不错的话,奖励你去进修……
这不都闹乌龙了呀。

因此,作为老板,这个大饼,这个承诺,是不是也要进行管理呢?
需要,那该怎么管理呢?(根据之前的知识)
先描述,在组织!!!

说明,需要有结构体,还需要链表进行维护!!!

4.2. 对应关系

老板:os

员工:进程

大饼:虚拟地址空间

大饼的具体内容:让每个进程都认为自己有4GB的物理内存或者,每个进程都认为自己在独占物理内存。

4.3. 一个空间中,有很多的区域,是怎么进行区域划分的?

举个例子,大家在童年小学肯定遇到过的,三八线问题:

小明和小红做同桌,一个长桌子100cm

其中两个人五五分,各有50cm

现在两个人,要把东西放到桌子上,有书、文具等,该怎么合理的分配自己的空间呢?

有一天,两人生气了,55分,变成了37分,这个新的区域又是如何划分的呢?

  1. 给桌子的总长度进行刻度
  2. 把两个人的的分界线画出来即可
  3. 把各自的长度按照一定的比例,进行刻度,这10cm放书,那10cm放文具

其中,

  • 桌子 --- 地址空间
  • 量取桌子的长度 --- 对空间进行编址
  • 按照一定的规律进行刻度 --- 地址空间上的每个地址
  • 使用分界线划分出不同的区域 --- 不同地址的区域划分
  • 将之前的55分转化成37分 --- 调整区域

此时就可以上述的逻辑,用计算机进行量化,就可以得到区域划分

struct Destop
{
int size;
int a_start;
int a_end;
}

struct Destop area = {0,49,50,100};

调整区域

area.a_start -= 20;
area.b_end = -=20;

一个整型变量,有四个字节,而我们只获得到一个地址,得到的是地址数最小的。

通过得到的地址+偏移量 就可以得到上层的数值了。

通过这些例子,我们就知道了,这个虚拟地址空间,其实就是个结构体 (mm_struct)

struct mm_struct {
	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;
    .......
}

五、操作系统中的虚拟内存管理、进程内存布局以及物理内存与磁盘交互的核心原理

现在我们把最上面的例子,改成一个 task_struct 和磁盘的代码和数据进行分析

5.1. mm_struct

1. 开辟空间  
2. 初始化值从哪里来? 加载的时候,进行初始化  

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

   其中,1 <-> 2 -- > 页表进行映射

这就使得物理地址转化成虚拟地址!!!
提供给上层用户使用

为什么这样子设计?

  1.  将地址从无序变成有序:磁盘中很多的可执行程序,把对应的数据和代码加载到物理内存,是有序的吗?  不需要,因为可以通过页表进行一一对应
  2. 地址转换的过程中,也可以对地址和从操作进行合法性判定,进而保护物理内存。例如,为什么在字符常量去写入,会崩溃?查找页表的时候,被权限拦截
  3. 让进程管理和内存管理,进行一定程度上的解耦!!!

5.2. 问题:虚拟地址的区域划分在页表中都有对应的物理内存,请问 char * str = "hello" str = "h" 为什么不能运行成功?

        这是因为在这个页表中,有一个类似备注的一列,显示权限

        这个 str 是常量字符串,是在初始化数据中,不能进行修改,所以他的权限是 r 没有 w

5.3. 动态生命周期(加载、缺页、换出)

我们下面需要用言语说一下整个执行流程:

5.3.1. 惰性加载

        操作系统内核开始为它构建虚拟地址空间。此时,进程的 task_struct 结构体中的 struct mm_struct *mm 被正式开辟并初始化,内核会根据可执行文件的头部信息,在虚拟地址空间中划定好清晰的区域:正文代码、初始化数据、未初始化数据、堆、共享区、栈及命令行参数/环境变量;高地址的1G则预留给内核空间

        然而,此时物理内存是“零占用”的。内核仅仅创建了页表,并在页表项的“左半边”填上了关键坐标——记录着“该段数据位于磁盘可执行文件的哪个偏移位置”;而指向物理内存页框号的“右半边”全部留空,标记为无效。这个就是操作系统惰性加载(按需调页)的精妙起点。

5.3.2. 缺页中断

        当CPU真正开始执行进程指令,访问某个虚拟地址时,硬件MMU查找页表发现“右半边”为空,立即触发缺页中断。进程被暂停,内核接管控制权:

  1. 内核依据页表“左半边”记录的磁盘偏移,从可执行文件中读取该页数据。

  2. 分配一个空闲的物理页框,将数据载入。

  3. 将该物理页框号填入页表的“右半边”,标记为有效,并建立映射关系。此时,物理地址成功转化为进程可用的虚拟地址。

        在这个过程中,权限拦截起到了至关重要的保护作用,就像上面一样:内核在填充页表项时,会根据该区域所属的段类型设置权限位(R/W/X)。字符串常量 "hello "位于只读数据段,其页表权限被标记为 只读(r--),当CPU尝试写入时,MMU在地址转换阶段检查权限发现不匹配,直接抛出页错误,内核随即向进程发送 SIGSEGV 信号导致崩溃。物理内存中的数据甚至还没来得及修改,在逻辑转换层面就被坚决拦截了。

5.3.3. 换出与挂起

        当物理内存资源极度紧张时,内核的回收机制会被激活。它会挑选合适的物理页进行换出(Swap Out)

  1. 将该物理页框的数据写入磁盘的Swap分区

  2. 清除页表中的“右半边”(物理页框号),并重新标记为无效。

  3. 关键一步:“左半边”被保留并更新,指向Swap分区中的新存储位置。

        如果被换出的页过多,进程甚至可能被挂起(进入挂起态)。这相当于一次逆向的缺页中断—— 拆掉物理映射,为其他进程腾出生存空间。当该进程被再次唤醒,访问那些已被换出的地址时,缺页中断会再次上演,只是这次内核会从Swap分区读回数据,重新分配物理页框,再次填满“右半边”。

        此时:

  • 变无序为有序:磁盘中的可执行程序和数据加载到物理内存时是杂乱无章的,但通过页表的映射,每个进程都认为自己独享一个从低地址到高地址的连续、有序的3G虚拟空间。

  • 完美解耦进程管理只认虚拟地址,内存管理只关心物理页框和磁盘块,页表则充当了灵活的“翻译官”,让两者互不干扰。创建、加载、换出、再加载,这个闭环循环往复,让有限的物理内存撑起了远超自身容量的程序世界。


六、虚拟内存管理

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

struct task_struct {
    struct mm_struct *mm, *active_mm;
    ......
}
  •  mm (Memory Descriptor):代表进程自己拥有的真正的用户空间地址空间。

  • active_mm (Active Memory Descriptor):代表进程当前实际正在使用的内存地址空间

6.1. 普通进程 vs. 内核线程

理解二者区别的关键在于区分进程类型:

6.1.1. 对于普通用户进程

对于大多数拥有独立地址空间的进程,这两个指针总是指向同一个 mm_struct 结构体

  • mm  指向该进程自己的地址空间。

  • active_mm  也指向同一个地址空间,因为该进程运行时,使用的正是它自己的地址空间。

6.1.2. 对于内核线程 

内核线程不拥有用户空间,它们只在内核空间运行。

  • mm = NULL:因为内核线程没有自己的用户地址空间。

  • active_mm :指向前一个运行的用户进程的 mm_struct  ,即“借用”来的地址空间。这样,当内核线程被调度运行时,它不必切换页表(Page Table),而是直接复用上一个进程的地址空间,从而节省了开销。

这个了解即可,等到讲到 线程的时候,还会重新谈及虚拟内存管理。

6.2. mm_struct的相关介绍

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

定位mm_struct文件所在位置和task_struct所在路径是一样的,不过他们所在文件是不一样的,
mm_struct所在的文件是mm_types.h。

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;
    /*...*/
}

6.3. 虚拟空间的组织方式

那既然每一个进程都会有自己独立的mm_struct,操作系统肯定是要将这么多进程的mm_struct组织起来的!虚拟空间的组织方式有两种:

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

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

linux内核使用 vm_area_struct 结构来表示一个独立的虚拟内存区域(VMA),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。上面提到的两种组织方式使用的就是vm_area_struct结构来连接各个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; //所属的 mm_struct
    struct mm_struct *vm_mm;
    pgrpt_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

#ifndef CONFIG_NUMA
    struct mempolicy *vm_policy; // NUMA policy for the VMA *
#endif

struct vm_usefulutfdt_ctx vm_usefulutfdt_ctx;
__randomize_layout;

画图所示:


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值