2023-06-11  阅读(5)
原文作者:奇小葩 原文地址:https://blog.csdn.net/u012489236/category_9614673.html

在前面的章节中,我们主要关注的是内核的虚拟地址空间的管理。从本节开始,我们重点关注管理用户空间的方法,其中由于种种原因,这个比内核地址空间管理更复杂。本节主要围绕以下内容:

  • 用户进程的虚拟地址空间是Linux的一个重要抽象,它向每个运行进程提供了同样的系统,每个应用程序都有自身的地址空间,与所有的应用程序分割开,不会干扰到其他进程内存的内容。
  • 在内核的虚拟地址空间中,只有很少的段可用于各个用户空间进程,这些段彼此有一定的距离,内核需要一些数据结构来有效的管理这些分布的段
  • 地址空间中只有极少的一部分与物理页直接关联,不经常使用的部分,仅当必要时与页帧关联
  • 内核无法信任用户进程,所以各个操作系统用户地址空间的操作伴随着各种检查,以确保程序的权限不会超出应有的限制,进而危及到系统的稳定性和安全性
  • 用户空间的内存分配方法

1. 进程虚拟地址空间

理论上,64Bit地址支持访问空间是[0, 0xFFFF FFFF FFFF FFFF],而实际上现有的应用程序都不会用这么大的地址空间,而现在ARM64芯片上也不支持访问这么大的地址空间,现有的架构最大支持访问48bit的地址空间。而对于进程有用户态和内核态,同样进程地址空间包括用户地址空间和内核地址空间,用户态访问用户地址空间。对于各个进程的虚拟地址空间起始于地址0,延伸到TASK_SIZE - 1,其上是内核地址空间。

  • 在ARM32系统上,地址空间范围为4GB,总的地址空间通常按照3:1划分,各个用户空间进程可用的部分是3GB

  • 在ARM64系统上,64位虚拟地址中,并不是所有位都用上,除了高16位用于区分内核空间和用户空间外,有效位的配置可以是:36, 39, 42, 47。这可决定Linux内核中地址空间的大小。比如以采用4KB的页,4级页表,虚拟地址为48位的系统为例(从ARMv8.2架构开始,支持虚拟地址和物理地址的大小最多为52位),其虚拟地址空间的范围为256TB ,按照1:1的比例划分,内核空间和用户空间各占128TB。

    202306111243251731.png

对于用户程序只能访问整个地址空间的下半部分,不能访问内核部分。同时无论当前哪个用户进程处于活动状态,虚拟地址空间内核部分的内容总是相同的。

1.1 进程地址空间的布局

一个进程通常由加载一个elf文件启动,而elf文件是由若干segments组成的,同样的,进程地址空间也由许多不同属性的segments组成。虚拟地址空间中包含了若干区域,其分布方式特定于体系结构,但所有的方法都有下列共同的特点,如下图所示

202306111243270202.png

  • text段 :包含了当前运行进程的二进制代码,其起始地址在IA32体系中中通常为0x08048000,在IA64体系中通常为0x0000000000400000

  • data段 :包含程序显式初始化的全局变量和静态变量,即已初始化且初值不为0的全局变量(也包括静态全局变量)和静态局部变量,这些数据是在程序真正运行前就已经确定的数据,所以可以提前加载到内存保存好。

  • bss段 :未初始化的全局变量和静态变量,这些变量的值是在程序真正运行起来并为其赋值后才能确定的,所以程序加载之初,只需要记录它的内存地址和所需大小。出于历史原因,这段空间也称为 BSS 段。

  • heap段 :存储动态分配的内存中的数据,堆用于存储那些生存期与函数调用无关的数据。如用系统调用 malloc 申请的内存便在堆上,这些申请的内存在不需要时必须手动释放,否则便会出现内存泄漏。

  • stack段 :用于保存局部变量和实现函数/过程调用的上下文,它们的大小都是会在进程运行过程中发生变化的,因此中间留有空隙,heap向上增长,stack向下增长,因为不知道heap和stack哪个会用的多一些,这样设置可以最大限度的利用中间的空隙空间。进程每调用一次函数,都将为该函数分配一个栈帧,栈帧中保存了该函数的局部变量、参数值和返回值。

  • 文件映射段 :这个段比较特殊,是mmap()系统调用映射出来的。 mmap映射 的大小也是不确定的。3GB的虚拟地址空间已经很大了,但heap段, stack段,mmap段在动态增长的过程还是有重叠(碰撞)的可能。为了避免重叠发生,通常将mmap映射段的起始地址选在TASK_SIZE/3(也就是1GB)的位置。如果是64位系统,则虚拟地址空间更加巨大,几乎不可能发生重叠。

    我们以最简单的Helloworld程序为例,其内存空间布局如下图所示

202306111243277363.png

1.2 建立布局

那我们了解了进程在运行过程中的内存空间分布情况,那么如何建立起这种内存空间呢?首先,在Linux系统中运行一个可执行的ELF文件时,内核首先需要识别这个文件,然后解析并装载它以构建进程的内存空间,最后切换到新的进程来运行。

首先,我们来看一下elf文件的格式,Section头表包含了描述文件Sections的信息。每个Section在这个表中有一个入口,每个入口给出了该Section的名字,大小等信息。同时可执行文件有一个头部,里面有一些关键信息,Entry point Address,入口地址,即程序的起点,0x8048300,后面有一些代码,数据

202306111243290334.png

当我们在linux的shell命令中执行某个elf可执行文件的时候,linux系统是如何装载该ELF并执行的呢?其主要是以下几个步骤:

  • 创建新进程:首先在用户层面,shell进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用来执行指定的ELF。

  • 检查可执行文件的类型:当进入execve()系统调用之后,Linux内核就开始真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),会执行do_execve()查找被执行的文件,如果找到文件,则读取文件的前128个字节,通过来判断该执行文件是哪一种elf文件,例如a.out,java程序,以及脚本开头的文件。

  • 搜索匹配的装载处理过程:do_execve()读取128个字节的文件头部后,调用search_binary_handle()去搜索和匹配合适的可执行文件,最常见的可执行文件及处理过程如下

    • ELF可执行文件:load_elf_binary
    • a.out 可执行文件:load_aout_library
    • 可执行脚本程序:load_script()

    在装载的过程中,对于可执行文件,应该创建对应的.text段、.data段、stack段等。在Linux中,每个段都用一个vm_area_strcutvm_area_strcut结构体表示,vma是通过一个双向链表串起来,现存的vma按照起始地址依次递增被归入链表中,每个vma是这个链表的一个节点,首先我们来看一个进程有一个struct 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);
            //释放线性区时调用的方法
        	void (*unmap_area) (struct vm_area_struct *area);
            // 标识第一个分配的匿名线性区或者是文件内存映射的线性地址
        	unsigned long mmap_base;		/* base of mmap area */
            //内核从这个地址开始搜索进程地址空间中线性地址的空闲区间
        	unsigned long free_area_cache;		/* first hole */
            //指向页表
        	pgd_t * pgd;
            ...
        }
对于mmap指向的vm\_area\_struct,其定义如下:
        struct vm_area_struct {
        	unsigned long vm_start;		//虚拟内存空间的首地址
        	unsigned long vm_end;		//虚拟内存空间的尾地址
        	//VMA链表的下一个成员和上级成员,进程VMA连接成一个链表
        	struct vm_area_struct *vm_next, *vm_prev;
        	//将本VMA作为一个节点加入到红黑树中,每个进程的struct mm_struct都有一颗这样的红黑树mm_rb
        	struct rb_node vm_rb;
        	unsigned long rb_subtree_gap;
        	//指向该VMA所属的进程struct mm_struct数据结构
        	struct mm_struct *vm_mm;	/* The address space we belong to. */
        	pgprot_t vm_page_prot;		/* Access permissions of this VMA. */
        	unsigned long vm_flags;		/* Flags, see mm.h. */
        	...
        }
vm\_area\_struct结构中包含区域起始和终止地址以及其他相关信息,同时也包含一个vm\_ops指针,其内部可引出所有针对这个区域可以使用的系统调用函数。这样,进程对某一虚拟内存区域的任何操作需要用要的信息,都可以从vm\_area\_struct中获得。mmap函数就是要创建一个新的vm\_area\_struct结构,并将其与文件的物理磁盘地址相连。

至此,我们可以看出,虚拟内存即为由一个个vm\_area\_struct结构体,通过链表组装起来的空间,其示意图如下图所示

202306111243296515.png
用户进程拥有用户空间的地址,其可以通过malloc和mmap等函数来申请内存,malloc和mmap等函数的实现都是基于进程线性区描述struct vm_erea_struct,内核管理进程地址空间的数据结构struct vm_erea_struct,简称VMA。

对于每个进程的内存描述符mm_struct,都有各自的VMA,通过mm->mmap链表将所有的VMA管理起来,同时会记录到mm->mm_rb的红黑树,用于高速查找合并VMA等操作。

2. 虚拟内存区域的表示

先来说说task_struct,task_struct是一个结构体,这个结构体非常的庞大,linux下用它来完整的描述一个进程的所有信息。在每装载一个进程的时候,内核就会帮我们去创建一个新的task_struct结构体。然后我们知道一个每一个独立的进程都有自己独立的虚拟空间,所以,在task_struct结构体里会有一个struct mm_struct *mm成员,这个mm成员就是用来描述和管理进程的虚拟空间的。由上图可知,每个区域通过一个vm_eara_struct实例描述,进程的各区域按照以下两种方式排序

  • 在一个双链表上(开始于mm_struct->mmap)

  • 在一个红黑树上,跟节点位于mm_rb

    总结来说,简单的理解这三者的关系就是task_struct结构体包含了一个mm_sturcut结构体成员,mm_struct结构体包含了一个vm_area_struct结构体成员mmap,然后这个mmap成员指向一个VMA链表,管理所有的VMA。

    用户虚拟地址空间中的每个区域由开始和结束地址描述,现存的区域按照起始地址以递增次序被归入链表中。扫描链表找到与特定地址关联的区域,在有大量区域时是非常低效的操作。因此vm_eara_struct的各个实例可以通过红黑树管理,可以显著加快扫描速度。

202306111243301486.png

3. 总结

当一个进程要运行起来需要以下的内存结构:

用户态:

  • 代码段、全局变量、BSS
  • 函数栈
  • 内存映射区

内核态:

  • 内核的代码、全局变量、BSS
  • 内核数据结构例如 task_struct
  • 内核栈
  • 内核中动态分配的内存

对于64位的系统,其进程运行状态如下图所示:

Linux 为虚拟内存不同的段,提供了不同的数据结构来描述:

在 Linux 内核眼中所有的进程、线程都是 task 都适用 task_struck 描述。

  • task_struck 数据结构中的 mm 字段(mm_struct 类型)描述了进程或者线程用户态的内存信息;

  • mm_struct 中有映射页的统计信息(总页数, 锁定页数, 数据/代码/栈映射页数等)以及各区域地址

  • mm_struct 维护着 vm_area_struct 的链表,每个链表节点都描述了用户空间虚拟内存的布局划分:

    202306111243307827.png

    4. 参考文档

    Linux内核如何启动并装载一个可执行程序

    趣谈Linux操作系统


Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。

它的内容包括:

  • 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
  • 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
  • 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
  • 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
  • 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
  • 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
  • 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
  • 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw

目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:

想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询

同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。

阅读全文