Linux理解进程地址空间

Posted 阿润菜菜

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux理解进程地址空间相关的知识,希望对你有一定的参考价值。

🍎作者:阿润菜菜
📖专栏:Linux系统编程

​我们在学习C语言的时候,都学过内存区域的划分如栈、堆、代码区、数据区这些。但我们其实并不真正理解内存 — 我们之前一直说的内存是物理上的内存吗?

前言

我们先看一段测试代码:

#include <stdio.h>
#include <assert.h>
#include <unistd.h>

int g_value = 100; //全局变量

int main()

    // fork在返回的时候,父子都有了,return两次,id是不是pid_t类型定义的变量呢?返回的本质,就是写入!
    // 谁先返回,谁就让OS发生写时拷贝
    pid_t id = fork();
    assert(id >= 0);
    if(id == 0)
    
        //child
        while(1)
        
            printf("我是子进程, 我的id是: %d, 我的父进程是: %d, g_value: %d, &g_value : %p\\n",\\
                    getpid(), getppid(), g_value, &g_value);
            sleep(1);
            g_value=200; // 只有子进程会进行修改
        
    
    else
    
        //father
        while(1)
        
            printf("我是父进程, 我的id是: %d, 我的父进程是: %d, g_value: %d, &g_value : %p\\n",\\
                    getpid(), getppid(), g_value, &g_value);
            sleep(1);
        
    

运行结果:
我们可以注意到子进程的变量值内容发生了改变,而父进程的变量值内容一直没有发生改变,并且两个进程的全局变量打印出来的地址值是一样的。
那这地址到底是不是真的物理地址
== 当然不能是,如果是同一个物理地址,不可能读取同一个变量会读取到不同的数值 。==

从上面结果我们可以得出: 子进程对全局变量数据修改,不影响父进程 - — 进程具有独立性 也就是说,我们在语言层面用的地址,不是物理地址

所以我们之前说‘程序的地址空间’是不准确的,那准确的说这是什么地址呢?
我们一般叫:虚拟地址或者线性地址
我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。OS必须负责将 虚拟地址 转化成 物理地址 。

理解进程地址空间

通过故事引入

我们知道操作系统会帮助我们用于管理计算机硬件和软件资源.假设操作系统是个大富翁,手底下有10个亿的内存,大富翁同时有四个私生子,四个分别从事不同的活动,彼此不知道互相的存在(对应进程的独立性),在大富翁老去时,会将自己的财产继承给私生子,那么由于每个私生子彼此不知道存在,大富翁让每个私生子都会以为自己可以继承10个亿的财产(画的大饼),是孩子就总会有给老爹要钱的时候,那么为了管理自己的10个亿财产,大富翁很有必要将其描述组织起来,以供自己四个彼此独立的私生子使用。
那么大富翁(操作系统)将画的大饼先描述,在组织,其实就是管理进程地址空间的过程 ---- 本质就是:一个内核数据结构,struct mm_struct

现在我们知道了地址空间就是内核数据结构 ---- 那它是怎么对应物理内存的?
先来看一下地址空间是怎么划分的:

代码区、数据区、堆区等这些区域如何理解?

在我们小学的时候可能会遇到课桌上有一道“线”的情况,是什么线?三八线。那当时画三八线的本质就是:区域划分 — 地址空间就是线性区域
同样在Linux内核数据结构中也存在区域划分,类似下面的这种代码,用来管理内存空间:

struct area

int start;
int end;

同时,我们对线性区域进行指定start和end即可完成区域划分 ---- 类似于这样

struct area  owner_1 (1,50;
struct area  owner_2 (50,100;

如果限定了区域,那区域之间的数据是什么?以一个4GB的内存为例,大概是这样的:

struct mm_struct   //4GB

long code_start;
long code_end;
long init_start;
long init_end;
//.....
long stack_start;
long stack_end;


通过上述,我们可以知道地址空间区域是可以进行动态调整大小的 ---- 即更改 start或者end

理解页表
进程地址空间毕竟只是逻辑上的内存,并不是真正的物理内存,是不能存储数据的,进程的数据和代码是只能存储在物理内存上的,但是进程使用的是虚拟内存!进程只能访问虚拟地址,但是实际的数据是存储在物理内存上的,那么进程是如何通过虚拟地址拿到数据和代码的?
实际上在虚拟地址与物理地址之间是有一种映射关系的,这种映射关系被存储在页表中!每个进程都有自己的页表
同时虚拟地址是经过页表(+MMU(集成在cpu中))映射到物理地址 ---- 像是我们大学生会被学号编号,进行确认

同时根据上述知识可以知道,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址
所以说内存分布实际上并不是真实(物理内存)的内存的分布,而是OS给进程画的一张“大饼”,就是让进程认为自己一个就拥有整个内存!说白了进程空间就是OS欺骗进程的一种手段!
每个进程都有属于自己的一张进程地址空间和对应的页表!

找到地址不是目的,而是一种手段(页表),目的是该地址对应的内容!

到这里还能回答原始问题:子进程的mm_struct继承父进程 ---- 即虚拟地址一样 进程独立,映射到不同的物理地址

深入扩展去理解!

地址空间为什么要存在?
---- 比如野指针越界问题,破坏了进程独立性,进程的数据会遭到破坏,影响到进程运行

  • 防止地址随意访问,保护物理内存与其他进程运行
    • 同时页表具有读写权限控制属性,解释了为什么代码段只是可读的,为什么有些变量不能赋值
  • 进程管理和内存管理进行解耦合 | 通过malloc本质讲解
  • 可以让进程以统一视角看待自己的代码和数据
    虚拟内存是OS欺骗进程的一种手段,进程在看待进程的时候都认为自己拥有整块内存,然后开始对着“这块内存”开始布局自己的代码和数据,但是实际上这些代码和数据到底存没存储起来,还得看OS,但是站在进程的角度,他是认为我们已经布局完整个内存了!进程是看不到真实物理内存的!进程只能看到进程地址空间!
  • 可以充分的利用内存资源,让内存的利用率变的高效起来!
    比如:两个进程可能都需要访问某个动态库;如果没有进程地址空间的话,OS就会将这个动态库加载内存两次,也就是内存中会有两份一模一样的数据!这是没必要的!但是有了进程地址空间过后,我们可以让两个进程的虚拟地址同时映射到这同一份数据!也就是说两个进程可以共享这份数据!这份数据也就只需要在内存中存在一份就行了!但是在进程看来他们都认为这份数据是自己独享的!

malloc 本质

作为一款优秀的操作系统,不能允许任何的浪费或者不高效的存在。
所以操作系统使用1缺页中断方式来管理内存 ---- 即先在虚拟地址空间申请虚拟内存。
然后通过2页表映射 ,在实际物理内存上开辟空间 同时不需要关心数据放在物理内存哪个位置 因为通过页表映射都能找到
也就是说:在CPU上读取到的地址,全是进程空间上的地址,也就是虚拟地址!CPU不会直接去物理内存上读取数据!

重新理解地址空间

  1. 我们的程序在被编译的时候,没有被加载到内存,那么我们的程序内部有没有地址呢?
    答案是:有的。源代码被编译的时候,就已经按照虚拟地址的方式进行了代码和数据的编址(使用ELF格式:划分数据区域)
    比如我们简单写一段代码:
  1 #include <stdio.h>  
  2 int main()  
  3   
  4  printf("hello world!\\n");  
  5                                                                                                                                         
  6  return 0;  
  7   

我们将这段程序先编译成可执行程序,然后利用命令objdump -S对其进行反汇编:

我们会发现,在我们的程序在未加载进内存的时候,编译器就已经确定好了各条指令的地址!
为什么?源代码在被编译的时候,就已经按照虚拟地址空间的方式对代码和数据进行了地址的编制,只不过只些代码和数据的地址都是虚拟地址,并不是真实的物理地址!只有当我们的程序被加载进内存了,才会真正的拥有物理地址!

那么整个程序的运行过程基本上是:

1、OS将我们的程序加载进内存(注意并不是一次性全部加载进去,而是先加载一些比较重要的代码和数据);
2、OS为该程序建立pcb,来管理该进程;
3、OS为该进程创建地址空间地址和页表;
4、cpu从特定的进程空间地址处读取数据!然后OS在根据cpu提供的虚拟地址,映射到对应物理地址,获取对应的数据给cpu,cpu开始处理!如果OS在根据cpu提供虚拟地址没有建立起对应的物理地址时,OS会暂停cpu对于该进程的处理,然后重新加载一部分数据进入内存,然后再建立映射关系,出现这种情况:叫做缺页中断

所以虚拟地址这样的策略不只是影响OS,我们的编译器同样遵守这样的规则!

  1. 进程的代码和数据必须一直在内存中吗?
    答:不是。OS会将暂时不用的进程代码、数据和部分进程控制块通过我们的页表技术交换至磁盘中存储。

  2. 怎么理解写时拷贝呢?

写时拷贝(copy-on-write, COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。
写时拷贝主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
写时拷贝常见的应用场景有:

  • 虚拟内存管理中的写时复制:当一个进程fork一个子进程时,并不会立即复制父进程占用的所有内存页,而是与父进程共享相同的内存页,并把这些页面标记为只读。当父进程或子进程试图修改这些页面时,系统才会为修改者分配新的页面,并将原来页面上的内容复制过去。
  • 字符串处理中的写时复制:当一个字符串被赋值给另一个字符串变量时,并不会立即复制字符串内容,而是让两个变量指向相同的字符串。当其中一个变量试图修改字符串内容时,系统才会为修改者分配新的字符串空间,并将原来字符串上的内容复制过去⁵。

我们知道在fork调用了子进程后,只有当子进程进行内容修改时,物理地址才会发生改变! 并且页表项中可以具有读写权限,本质就是:按需申请资源,提高效率!


  1. CPU从特定的进程空间地址处读取数据!然后OS在根据cpu提供的虚拟地址,映射到对应物理地址,获取对应的数据给cpu,cpu开始处理!如果OS在根据cpu提供虚拟地址没有建立起对应的物理地址时,OS会暂停cpu对于该进程的处理,然后重新加载一部分数据进入内存,然后再建立映射关系,出现这种情况:叫做缺页中断! ↩︎

  2. 实际上在虚拟地址与物理地址之间是有一种映射关系的,这种映射关系被存储在页表中!每个进程都有自己的页表!
    当进程需要访问虚拟地址上某一处的数据时,OS就会拿着进程提供的虚拟地址,根据该进程提供的页表转换成对于的物理地址,然后去对于的物理内存上取数据在交给进程!这个过程进程是看不到的,站在进程的角度就是,我(进程)需要访问虚拟地址为0x11223344处的数据,然后就直接拿到了数据,在进程看来它就认为自己的数据是存储在虚拟内存上的,只要自己需要,随时都可以拿到,殊不知其真实数据是存储在物理内存上的,进程之所以能随时拿到数据,都是由OS完成的!
    ↩︎

Linux地址空间

进程地址空间

在这里插入图片描述

进程地址空间是内存地址吗?

#include<iostream>
 #include<unistd.h>
using namespace std;
int g_val=100;
 int main()
  {
       cout<<"this is my process"<<endl;
       pid_t pd=fork();
       if(pd<0)
      {
          cerr<<"failed"<<endl;
      }
     else if(pd==0)
     {
         g_val=1000;
        cout<<"i am son"<<"my val is"<<g_val<<" my address "<<&g_val<<endl;
      }
     else
      {
          sleep(2);
          cout<<"i am father"<<"my val is"<<g_val<<" my address "<<&g_val<<endl;
      }
      return 0;                                                                                                                                                                         
  }

实验结果:

在这里插入图片描述
结论:地址空间绝对不是数据存储的真实物理地址。它是一段虚拟地址。最终一定要以某种方式转化为物理地址。
由此可知,我们在任何情况下打印出来的地址都是虚拟地址,而不是其真实物理地址。

什么是地址空间

在这里插入图片描述
地址空间就是一个结构体,通过结构体将我们所虚拟化出的栈、堆、数据段、代码段等组织起来的一个结构体

其中进程的PCB中含有该结构体(mm_struct),因为内存的分配是离散分配的,所以我们不得不让该结构体将数据组织起来。
解释一下:为什么两个不一样的数据其地址是相同的?
1.因为子进程要拷贝父进程的PCB,所以它们是相同的。
2.因为子进程在执行时对全局变量g_val进行了修改,此时它们虽然看起来地址是一样的,但在物理内存中,一定存在额外的物理地址去存储子进程的数据。这是进程独立性的特点。
接下来回答一下到底什么是地址空间:
地址空间,是对物理内存的一段虚拟化表示,虚拟地址一定要通过某种映射关系将其转化为物理地址。 通常有页表、段表、段页式结合,来将其变为物理内存的地址,后面阐述。

地址空间提出的原因?

如果没有地址空间的话,我们访问的永远都是真实的物理地址,会有两个弊端。
1,保护内存。如果直接对物理内存进行操作的话,则有可能会对操作范围之外的数据进行错误的操作,比如野指针等概念。
2.数据在内存中的存储是离散分配的,如果采用直接访存的话,非常的不方便,增加了越界访存的概率。

地址空间是怎么工作的?

地址空间,利用虚拟内存将空间连续化处理了。
地址空间的描述:
通过mm_struct结构体组织出来

struct mm_struct {

    //指向线性区对象的链表头
    struct vm_area_struct * mmap;       /* list of VMAs */
    //指向线性区对象的红黑树
    struct rb_root mm_rb;
    //指向最近找到的虚拟区间
    struct vm_area_struct * mmap_cache; /* last find_vma result */

    //用来在进程地址空间中搜索有效的进程地址空间的函数
    unsigned long (*get_unmapped_area) (struct file *filp,
                unsigned long addr, unsigned long len,
                unsigned long pgoff, unsigned long flags);

       unsigned long (*get_unmapped_exec_area) (struct file *filp,
                unsigned long addr, unsigned long len,
                unsigned long pgoff, unsigned long flags);

    //释放线性区时调用的方法,          
    void (*unmap_area) (struct mm_struct *mm, unsigned long addr);

    //标识第一个分配文件内存映射的线性地址
    unsigned long mmap_base;        /* base of mmap area */


    unsigned long task_size;        /* size of task vm space */
    /*
     * RHEL6 special for bug 790921: this same variable can mean
     * two different things. If sysctl_unmap_area_factor is zero,
     * this means the largest hole below free_area_cache. If the
     * sysctl is set to a positive value, this variable is used
     * to count how much memory has been munmapped from this process
     * since the last time free_area_cache was reset back to mmap_base.
     * This is ugly, but necessary to preserve kABI.
     */
    unsigned long cached_hole_size;

    //内核进程搜索进程地址空间中线性地址的空间空间
    unsigned long free_area_cache;      /* first hole of size cached_hole_size or larger */

    //指向页表的目录
    pgd_t * pgd;

    //共享进程时的个数
    atomic_t mm_users;          /* How many users with user space? */

    //内存描述符的主使用计数器,采用引用计数的原理,当为0时代表无用户再次使用
    atomic_t mm_count;          /* How many references to "struct mm_struct" (users count as 1) */

    //线性区的个数
    int map_count;              /* number of VMAs */

    struct rw_semaphore mmap_sem;

    //保护任务页表和引用计数的锁
    spinlock_t page_table_lock;     /* Protects page tables and some counters */

    //mm_struct结构,第一个成员就是初始化的mm_struct结构,
    struct list_head mmlist;        /* List of maybe swapped mm's.  These are globally strung
                         * together off init_mm.mmlist, and are protected
                         * by mmlist_lock
                         */

    /* Special counters, in some configurations protected by the
     * page_table_lock, in other configurations by being atomic.
     */

    mm_counter_t _file_rss;
    mm_counter_t _anon_rss;
    mm_counter_t _swap_usage;

    //进程拥有的最大页表数目
    unsigned long hiwater_rss;  /* High-watermark of RSS usage *///进程线性区的最大页表数目
    unsigned long hiwater_vm;   /* High-water virtual memory usage */

    //进程地址空间的大小,锁住无法换页的个数,共享文件内存映射的页数,可执行内存映射中的页数
    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;

    unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */

    struct linux_binfmt *binfmt;

    cpumask_t cpu_vm_mask;

    /* Architecture-specific MM context */
    mm_context_t context;

    /* Swap token stuff */
    /*
     * Last value of global fault stamp as seen by this process.
     * In other words, this value gives an indication of how long
     * it has been since this task got the token.
     * Look at mm/thrash.c
     */
    unsigned int faultstamp;
    unsigned int token_priority;
    unsigned int last_interval;

    //线性区的默认访问标志
    unsigned long flags; /* Must use atomic bitops to access the bits */

    struct core_state *core_state; /* coredumping support */
#ifdef CONFIG_AIO
    spinlock_t      ioctx_lock;
    struct hlist_head   ioctx_list;
#endif
#ifdef CONFIG_MM_OWNER
    /*
     * "owner" points to a task that is regarded as the canonical
     * user/owner of this mm. All of the following must be true in
     * order for it to be changed:
     *
     * current == mm->owner
     * current->mm != mm
     * new_owner->mm == mm
     * new_owner->alloc_lock is held
     */
    struct task_struct *owner;
#endif

#ifdef CONFIG_PROC_FS
    /* store ref to file /proc/<pid>/exe symlink points to */
    struct file *exe_file;
    unsigned long num_exe_file_vmas;
#endif
#ifdef CONFIG_MMU_NOTIFIER
    struct mmu_notifier_mm *mmu_notifier_mm;
#endif
#ifdef CONFIG_TRANSPARENT_HUGEPAGE
    pgtable_t pmd_huge_pte; /* protected by page_table_lock */
#endif
    /* reserved for Red Hat */
#ifdef __GENKSYMS__
    unsigned long rh_reserved[2];
#else
    /* How many tasks sharing this mm are OOM_DISABLE */
    union {
        unsigned long rh_reserved_aux;
        atomic_t oom_disable_count;
    };

    /* base of lib map area (ASCII armour) */
    unsigned long shlib_base;
#endif
};

地址空间的组织:
每个进程的PCB中都有一个mm_struct结构体,此时对虚拟地址进行映射,得到真实的物理地址。

接下来深刻理解一下什么是进程

进程就是,是程序的一次执行,是处理机调度和处理机资源分配的一个基本单位。由进程常见的数据结构(struct task_struct(进程控制块)&&struct mm_struct(地址空间))和代码、数据组成。

地址空间的三种映射

页表映射

将程序的逻辑地址空间划分为固定大小的页(page),而物理内存划分为同样大小的页框(page frame)。程序加载时,可将任意一页放人内存中任意一个页框,这些页框不必连续,从而实现了离散分配。在页式存储管理方式中地址结构由两部构成,前一部分是页号,后一部分为页内地址w(位移量);

在这里插入图片描述
需要几次访存?
两次,首先明确页表是位于内存中的,第一次访存是为了获取块号,通过的出的块号+偏移地址得出它的物理地址,第二次访存去取数据或代码
优缺点:
1.没有外碎片,每个内碎片不超过页大比前面所讨论的几种管理方式的最大进步是
2,一个程序不必连续存放。

3.便于改变程序占用空间的大小(主要指随着程序运行,动态生成的数据增多,所要求的地址空间相应增长)。

缺点:要求程序全部装入内存,没有足够的内存,程序就不能执行;

段表映射

在这里插入图片描述
在段式存储管理中,将程序的地址空间划分为若干个段(segment),这样每个进程有一个二维的地址空间。在前面所介绍的动态分区分配方式中,系统为整个进程分配一个连续的内存空间。而在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
简单来说,对于一个进程而言,其中不同的数据可能在栈上,可能在堆上,于是基于这种特点引进了分段式内存管理。
当然, 虚拟地址和物理地址之间是通过段表来进行映射的。可以参考分页式内存管理。
分段分页的异同
1.需求 是信息的物理单位,分页是为了实现离散分配方式,以减少内存的碎片,提高内存的利用率。或者说,分页仅仅是由于系统管理的需要,而不是用户的需要。段是信息的逻辑单位,它含有一组其意义相对完整的信息。分段的目的是为了更好地满足用户的需要。

2.指令 一条指令或一个操作数可能会跨越两个页的分界处,而不会跨越两个段的分界处。

3.大小 页大小固定且由系统决定,把逻辑地址划分为页号和页内地址两部分,是由机器硬件实现的。段的长度不固定,且决定于用户所编写的程序,通常由编译系统在对源程序进行编译时根据信息的性质来划分。

4.逻辑地址表示 页式系统地址空间是一维的,即单一的线性地址空间,程序员只需利用一个标识符,即可表示一个地址。分段的作业地址空间是二维的,程序员在标识一个地址时,既需给出段名,又需给出段内地址。

5.查找 比页大,因而段表比页表短,可以缩短查找时间,提高访问速度。
需要几次访存?
两次,第一次访问的是段的起始地址,进而通过与偏移地址相加得到的是真实物理地址,第二次取出相应的数据或代码。

段页式内存管理

在这里插入图片描述

段页式内存管理,结合了分页分段的优点,在进行分段的基础上又进行了分页式的内存管理。
唯一缺点就是要求程序全部装入内存,没有足够的内存,程序就不能执行;
虚拟地址空间是一个mm_struct结构体,是操作系统为进程描述的一个完整线性以及连续的内存空间,实现了进程在物理地址的离散式存储,提高了内存访问率。通过页表段表对内存进行控制。
需要几次访存?
段页式系统中,须三次访问内存。第一次访问是
访问内存中的段表,从中取得页表始址
第二次是访问内存中的页表,从中取出该页所在的物理块号,并将该块号与业内地址一起形成指令或数据的物理地址;第三次访问从第二次访问所得的地址中,取出指令或数据。

以上是关于Linux理解进程地址空间的主要内容,如果未能解决你的问题,请参考以下文章

Linux进程概念——下验证进程地址空间的基本排布 | 理解进程地址空间 | 进程地址空间如何映射至物理内存(页表的引出) | 为什么要存在进程地址空间 | Linux2.6内核进程调度队列

Linux理解进程地址空间

Linux地址空间

Linux下进程以及相关概念理解

Linux详解——进程地址空间

Linux内核空间-理解高端内存