内存管理
- 在程序里面,指令写入的地址是虚拟地址。例如,位置为 10M 的内存区域,操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
- 当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
操作系统的内存管理,主要分为三个方面
- 第一,物理内存的管理,相当于会议室管理员管理会议室。
- 第二,虚拟地址的管理,也即在项目组的视角,会议室的虚拟地址应该如何组织。
- 第三,虚拟地址和物理地址如何映射,也即会议室管理员如果管理映射表。
一个内存管理系统至少应该做三件事情:
- 第一,虚拟内存空间的管理,每个进程看到的是独立的、互不干扰的虚拟地址空间;
- 第二,物理内存的管理,物理内存地址只有内存管理模块能够使用;
- 第三,内存映射,需要将虚拟内存和物理内存映射、关联起来。
查看进程内存空间的布局的命令:
cat /proc/进程id/maps
分段机制的原理
- 分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。虚拟地址中的段内偏移量应该位于 0 和段界限之间。如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
- 在 Linux 里面,段表全称段描述符表(segment descriptors),放在 全局描述符表 GDT(Global Descriptor Table) 里面,会有下面的宏来初始化段描述符表里面的表项。
Linux 倾向于另外一种从虚拟地址到物理地址的转换方式,称为分页(Paging)
- 对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理,例如有的内存页面长时间不用了,可以暂时写到硬盘上,称为换出。一旦需要的时候,再加载进来,叫作换入。这样可以扩大可用物理内存的大小,提高物理内存的利用率。
- 换入和换出都是以页为单位的。页面的大小一般为
4KB
。为了能够定位和访问每个页,需要有个页表,保存每个页的起始地址,再加上在页内的偏移量,组成线性地址,就能对于内存中的每个位置进行访问了。
- 虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。
虚拟内存中的页通过页表映射为物理内存中的页。
- 32 位环境下,虚拟地址空间共 4GB。如果分成 4KB 一个页,那就是 1M 个页。每个页表项需要 4 个字节来存储,那么整个 4GB 空间的映射就需要 4MB 的内存来存储映射表。如果每个进程都有自己的映射表,100 个进程就需要 400MB 的内存。对于内核来讲,有点大了 。可以试着将页表再分页,4G 的空间需要 4M 的页表来存储映射。我们把这 4M 分成 1K(1024)个 4K,每个 4K 又能放在一页里面,这样 1K 个 4K 就是 1K 个页,这 1K 个页也需要一个表进行管理,我们称为页目录表,这个页目录表里面有 1K 项,每项 4 个字节,页目录表大小也是 4K。
- 对于 64 位的系统,两级肯定不够了,就变成了四级目录,分别是全局页目录项 PGD(Page Global Directory)、上层页目录项 PUD(Page Upper Directory)、中间页目录项 PMD(Page Middle Directory)和页表项 PTE(Page Table Entry)。
把内存管理系统精细化为下面三件事情:
- 第一,虚拟内存空间的管理,将虚拟内存分成大小相等的页;
- 第二,物理内存的管理,将物理内存分成大小相等的页;
- 第三,内存映射,将虚拟内存也和物理内存也映射起来,并且在内存紧张的时候可以换出到硬盘中。
进程空间管理
整个虚拟内存空间要一分为二,一部分是用户态地址空间,一部分是内核态地址空间.
当 exec 运行一个二进制程序的时候,除了解析 ELF 的格式之外,另外一个重要的事情就是建立内存映射。
load_elf_binary 会完成以下的事情:
- 调用 setup_new_exec,设置内存映射区 mmap_base;
- 调用 setup_arg_pages,设置栈的 vm_area_struct,这里面设置了 mm->arg_start 是指向栈底的,current->mm->start_stack 就是栈底;
- elf_map 会将 ELF 文件中的代码部分映射到内存中来;
- set_brk 设置了堆的 vm_area_struct,这里面设置了 current->mm->start_brk = current->mm->brk,也即堆里面还是空的;
- load_elf_interp 将依赖的 so 映射到内存中的内存映射区域。
内存映射图
内核态的虚拟空间和某一个进程没有关系,所有进程通过系统调用进入到内核之后,看到的虚拟地址空间都是一样的。
一个进程要运行起来需要以下的内存结构
- 用户态:
- 代码段、全局变量、BSS
- 函数栈
- 堆
- 内存映射区
- 内核态:
- 内核的代码、全局变量、BSS
- 内核数据结构例如 task_struct
- 内核栈
- 内核中动态分配的内存
进程运行状态在 32 位下对应关系
进程运行状态在 64 位下对应关系
物理内存管理
DMA 是这样一种机制:要把外设的数据读入内存或把内存的数据传送到外设,原来都要通过 CPU 控制完成,但是这会占用 CPU,影响 CPU 处理其他事情,所以有了 DMA 模式。CPU 只需向 DMA 控制器下达指令,让 DMA 控制器来处理数据的传送,数据传送完毕再把信息反馈给 CPU,这样就可以解放 CPU。
- 如果一个页被加载到 CPU 高速缓存里面,这就是一个热页(Hot Page),CPU 读起来速度会快很多,如果没有就是冷页(Cold Page)。
- 如果有多个 CPU,那就有多个节点。每个节点用 struct pglist_data 表示,放在一个数组里面。
- 每个节点分为多个区域,每个区域用 struct zone 表示,也放在一个数组里面。
- 每个区域分为多个页。为了方便分配,空闲页放在 struct free_area 里面,使用伙伴系统进行管理和分配,每一页用 struct page 表示。
- 内存页总共分两类,一类是匿名页,和虚拟地址空间进行关联;一类是内存映射,不但和虚拟地址空间关联,还和文件管理关联。
对于物理内存来讲,从下层到上层的关系及分配模式如下:
- 物理内存分 NUMA 节点,分别进行管理;
- 每个 NUMA 节点分成多个内存区域;
- 每个内存区域分成多个物理页面;
- 伙伴系统将多个连续的页面作为一个大的内存块分配给上层;
- kswapd 负责物理页面的换入换出;
- Slub Allocator 将从伙伴系统申请的大内存块切成小块,分配给其他系统。
内核线程kswapd。这个内核线程,在系统初始化的时候就被创建。这样它会进入一个无限循环,直到系统停止。在这个循环中,如果内存使用没有那么紧张,那它就可以放心睡大觉;如果内存紧张了,就需要去检查一下内存,看看是否需要换出一些内存页。
用户态内存映射
- 内存映射不仅仅是物理内存和虚拟内存之间的映射,还包括将文件中的内容映射到虚拟内存空间。这个时候,访问内存空间就能够访问到文件里面的数据。而仅有物理内存和虚拟内存的映射,是一种特殊情况。
- 如果一个进程想映射一个文件到自己的虚拟内存空间,也要通过 mmap 系统调用。这个时候 mmap 是映射内存空间到物理内存再到文件。可见 mmap 这个系统调用是核心。
内存管理并不直接分配物理内存,因为物理内存相对于虚拟地址空间太宝贵了,只有等你真正用的那一刻才会开始分配。
四级页表的概念
- pgd_t 用于全局页目录项,pud_t 用于上层页目录项,pmd_t 用于中间页目录项,pte_t 用于直接页表项。
- 每个进程都有独立的地址空间,为了这个进程独立完成映射,每个进程都有独立的进程页表,这个页表的最顶级的 pgd 存放在 task_struct 中的 mm_struct 的 pgd 变量里面。
一个进程的虚拟地址空间包含用户态和内核态两部分。为了从虚拟地址空间映射到物理页面,页表也分为用户地址空间的页表和内核页表。在内核里面,映射靠内核页表,这里内核页表会拷贝一份到进程的页表。
-
cr3 是 CPU 的一个寄存器,它会指向当前进程的顶级 pgd。如果 CPU 的指令要访问进程的虚拟内存,它就会自动从 cr3 里面得到 pgd 在物理内存的地址,然后根据里面的页表解析虚拟内存的地址为物理内存,从而访问真正的物理内存上的数据.
- 第一点,cr3 里面存放当前进程的顶级 pgd,这个是硬件的要求。cr3 里面需要存放 pgd 在物理内存的地址,不能是虚拟地址。
- 第二点,用户进程在运行的过程中,访问虚拟内存中的数据,会被 cr3 里面指向的页表转换为物理地址后,才在物理内存中访问数据,这个过程都是在用户态运行的,地址转换的过程无需进入内核态。
-
页表一般都很大,只能存放在内存中。操作系统每次访问内存都要折腾两步,先通过查询页表得到物理地址,然后访问该物理地址读取指令、数据。
-
TLB(Translation Lookaside Buffer),我们经常称为快表,专门用来做地址映射的硬件设备。它不在内存中,可存储的数据比较少,但是比内存要快。所以,我们可以想象,TLB 就是页表的 Cache,其中存储了当前最可能被访问到的页表项,其内容是部分页表项的一个副本。
-
有了 TLB 之后,地址映射的过程就像图中画的。我们先查块表,块表中有映射关系,然后直接转换为物理地址。如果在 TLB 查不到映射关系时,才会到内存中查询页表。
用户态的内存映射机制包含以下几个部分
- 用户态内存映射函数 mmap,包括用它来做匿名映射和文件映射。
- 用户态的页表结构,存储位置在 mm_struct 中。
- 在用户态访问没有映射的内存会引发缺页异常,分配物理页表、补齐页表。如果是匿名映射则分配物理内存;如果是 swap,则将 swap 文件读入;如果是文件映射,则将文件读入。
内核态内存映射
- CPU 在保护模式下访问虚拟地址的时候,就会用 CR3 这个寄存器,这个寄存器是 CPU 定义的,作为操作系统,我们是软件,只能按照硬件的要求来。
- 在用户态可以通过 malloc 函数分配内存,当然 malloc 在分配比较大的内存的时候,底层调用的是 mmap,当然也可以直接通过 mmap 做内存映射,在内核里面也有相应的函数。
整个内存管理的体系
- 物理内存根据 NUMA 架构分节点。每个节点里面再分区域。每个区域里面再分页。
- 物理页面通过伙伴系统进行分配。分配的物理页面要变成虚拟地址让上层可以访问,
kswapd
可以根据物理页面的使用情况对页面进行换入换出。 - 对于内存的分配需求,可能来自内核态,也可能来自用户态。
- 对于内核态,kmalloc 在分配大内存的时候,以及
vmalloc
分配不连续物理页的时候,直接使用伙伴系统,分配后转换为虚拟地址,访问的时候需要通过内核页表进行映射。 - 对于
kmem_cache
以及kmalloc
分配小内存,则使用 slub 分配器,将伙伴系统分配出来的大块内存切成一小块一小块进行分配。 kmem_cache
和kmalloc
的部分不会被换出,因为用这两个函数分配的内存多用于保持内核关键的数据结构。内核态中vmalloc
分配的部分会被换出,因而当访问的时候,发现不在,就会调用do_page_fault
。- 对于用户态的内存分配,或者直接调用 mmap 系统调用分配,或者调用 malloc。调用
malloc
的时候,如果分配小的内存,就用 sys_brk 系统调用;如果分配大的内存,还是用 sys_mmap 系统调用。正常情况下,用户态的内存都是可以换出的,因而一旦发现内存中不存在,就会调用do_page_fault
。
你知道的越多,你不知道的越多。
有道无术,术尚可求,有术无道,止于术。
如有其它问题,欢迎大家留言,我们一起讨论,一起学习,一起进步