解构内存迷宫:串联虚拟地址、页表与内存使用(二)

0 阅读12分钟

介绍

上篇文章解构内存迷宫:串联虚拟地址、页表与内存使用(一)说了使用虚拟地址和多级页表的原因,接下来介绍下内存使用的流程,我们会先从程序的编译说起,然后介绍内存的分配流程,进程访问内存数据的流程,通过这些流程,可以让读者对内存的管理和使用有个整体的了解。

程序的编译

要运行一个程序,需要在机器上将程序的源代码编译成可执行文件,这一步其实就是编译程序根据当前的系统的各个信息将源代码编译成可以被操作系统识别并运行的格式,最常见的可执行文件格式是 ELF。这个可执行文件扮演了说明书的角色,包括了以下常见的信息:

  1. 程序从哪里开始执行? (入口点地址)
  2. 有哪些部分需要被加载到内存? (通常是代码段.text和数据段.data、.bss)
  3. 每个部分应该被放在虚拟内存的哪个位置? (虚拟地址vaddr)
  4. 每个部分在文件中的位置和大小是多少? (文件偏移offset和大小filesz)

其中虚拟内存这部分就涉及到了我们之前说的使用虚拟地址的好处,就算同时启动多个进程的实例,因为使用的是虚拟地址,他们代码段存储的虚拟地址是一样的,但是映射的物理地址可以不一样,这样就减轻了编译器的复杂度,可以提前确定很多数据的虚拟地址。

进程启动时的初始化流程

当执行运行程序的命令时,操作系统会解析这个可执行文件,为各个段(代码段.text和数据段.data、.bss)预留虚拟地址,创建一个进程实例专属的页表,通过内存映射机制,将程序代码对应位置的虚拟地址设置映射,将其标记为磁盘上文件的对应位置。然后提前分配一定的空闲内存给程序的堆和栈使用,如果程序是动态链接的(绝大多数都是),内核还会将动态链接器(如 /lib/ld-linux.so.2)也映射到进程的地址空间中。

开始执行与按需加载

  1. 内核将指令指针设置为ELF头中指定的入口点地址。
  2. 当CPU跳转到这个地址执行第一条指令时,它发现该虚拟页虽然合法,但对应的物理页还未加载数据。
  3. 触发缺页异常。
  4. 内核的缺页处理程序被调用,它检查地址,发现这是一个有效的、映射到文件的地址。
  5. 于是,内核从磁盘上读取该页的文件内容到一个空闲的物理页框中。
  6. 然后,内核更新页表,建立虚拟页到物理页的最终映射,并标记为有效。
  7. 进程恢复执行,这次成功读取到了指令。

此后,进程在运行过程中,任何时候访问到一个尚未加载的代码页或数据页,都会重复第6步的“缺页->加载->映射”过程。这就是所谓的按需分页。

进程申请内存流程

当进程读取到指令后,cpu就会开始执行指令,假设此时运行到了一个申请内存的库函数,比如malloc,他会执行系统调用,因为系统调用都需要进入内核态执行,用户态下的进程是没有权限执行的,此时会触发一个软中断,或者执行一个syscall指令,其目的都是一样的,触发用户态到内核态的切换,此时cpu需要执行一些操作:

  1. 权限提升:从用户态(Ring 3)切换到内核态(Ring 0)。
  2. 现场保存:将当前的用户态指令指针、寄存器等上下文信息压入内核栈(注意,不是用户栈)
  3. 查找处理程序:根据中断描述符表,找到并跳转到对应的系统调用处理入口函数。
  4. 在内核态下执行系统调用的逻辑,比如在内核态的空闲内存的数据结构中找到满足大小的空闲内存并分配,更新进程的页表中对应地址的物理内存地址映射
  5. 完成这些后,会返回用户态,继续执行用户程序代码

从上面可以看出,其实内核态和用户态的区别就是cpu给进程相关结构设置了一个标志位,之所以区分出这两个态,其实也是为了安全起见,操作系统的相关数据都只能在内核态才能访问,也只有合法的操作系统的代码才能被加载使用,这样做确保了系统的安全。

要是没有这个区分,一个程序都能访问到内核的数据的话,就很容易导致系统奔溃,或者恶意攻击导致其他进程出问题或者重要数据泄漏等问题,不过因为用户态和内核态的切换需要cpu额外执行一些操作,所以也算重型操作,能少用就少用,不然会影响系统性能。

进程访问内存

这时候cpu继续执行程序的代码逻辑,假设执行到了访问一个变量的代码,比如a = b+3 ;这时候就需要去内存中查询b这个变量的数值。cpu会将这个b这个变量的虚拟地址传给cpu中的MMU这个组件来完成虚拟地址到物理地址的转换。MMU是cpu中的一个硬件电路,专门负责虚拟地址到物理地址的转换,他会执行如下操作:

  1. cpu提供给他一个虚拟地址
  2. mmu先去他的tlb缓存中查找这个虚拟地址,如果有,则找到了对应的物理地址,跳转到第七步。这里说明下,tlb是一个硬件结构,用于缓存少量的映射关系数据。
  3. 如果在tlb缓存中没找到,mmu就需要通过页表去执行地址转换,找到虚拟地址对应的物理地址
  4. mmu去指定的寄存器(cpu中的一个硬件,能存储几个字节这种非常少量的数据)中找到顶级页表的物理地址,这个物理地址是进程启动时,操作系统给进程创建专属的页表时放到这个寄存器中的,当进程切换时,这个寄存器中的值也会切换,变为对应进程的顶级页表的物理地址
  5. 通过虚拟地址的前几位,比如之前说的前10位字节,找到顶级页表中对应的页表项,这个页表项存着二级页表的物理地址
  6. 根据虚拟地址的第11到20位,在二级页表的物理地址中找到对应的页表项,因为假设操作系统一共有二级页表,这时候找到的就是对应的物理内存页的物理地址(物理页就是linux的4k大小的内存单位)。算法是用之前找到的二级页表的物理地址,加上虚拟地址的第11到20位的值(这个代表了虚拟地址在二级页表项中的偏移量),找到的页表项就是需要访问的数据所在的页的物理地址。
  7. 根据页表的物理地址,加上虚拟地址的第21到32位(这个是物理页中的偏移量),把结果值传给内存管理器
  8. 内存管理器从内存中获取对应地址的值,并返回给MMU
  9. 此时cpu就拿到了对应虚拟地址中的值,也就是获取到了b的值

从上面的流程可以看出,如果mmu能在tlb缓存中找到对应关系,则可以免去查询页表查找对应关系的过程,因为tlb缓存查询速度极快,比内存访问快几个数量级,而页表查询需要多次访问内存,因此缓存命中的话,就会比缓存没命中的情况性能好上特别多,因此操作系统也会进行一些优化,尽量把热点访问数据保留在缓存中,从而提高系统性能。

访问内存流程中的优化处理

我们能看到,进程访问虚拟地址时,虽然用到了内核管理的数据结构 页表,但是并不需要进行用户态和内核态的切换,始终在用户态执行上述操作,这也保证了能拥有使用虚拟地址的优点的情况下,同时也保证访问的性能。其实为了系统的安全性,进程是无法直接访问和修改页表相关的数据的,不然进程自己改了下页表映射的物理地址,不就可以访问到其他程序的数据了么。

这也是页表都是由内核管理的原因,页表的数据由内核生成,修改和管理。通过内核和mmu的协作,在进程的是叫看来,他只能感受到虚拟地址,感受不到背后的物理地址相关的管理工作,在保障安全性的同时,也提升了系统的灵活性和易用性。

页表的用户空间和内核空间

之前讲述了页表的使用,其实这里面还有一点细节可以讲解下。之前讲了每个进程都有一张完整的页表,这张页表描述了整个虚拟地址空间,其实这部分空间包括用户空间和内核空间。内核空间的映射对于所有进程来说都是完全相同的,当发生进程切换时,只是切换了页表中用户空间部分的映射,内核空间的映射保持不变。 举个例子,在32位的系统中,虚拟地址空间被划分为两部分:

  1. 用户空间,处于虚拟地址的开始位置 特点:每个进程的这部分映射都是私有的,各不相同。这是你的代码、堆、栈所在的地方。
  2. 内核空间,处于虚拟地址的高端位置,例如从0xC0000000 到最大的地址0xFFFFFFFF。 特点:所有进程的这部分映射到的物理内存都是完全相同的。

这样设计有什么好处呢?

  1. 系统调用/中断无需切换页表:当进程通过系统调用或中断进入内核态时,CPU仍然使用当前进程的页表。由于所有进程的页表在内核部分都是一样的,所以内核代码可以无缝地继续执行,访问它需要的内核数据结构,而无需刷新TLB(页表缓存)。
  2. 内核永远“在场”:无论当前运行的是哪个用户进程,内核的代码和数据始终映射在固定的虚拟地址上,随时可以访问。

可以看出,内核空间在页表中的设计,通过虚拟地址的特点,实现了共享的效果,提升了系统调用执行时的性能(不需要切换页表,不需要刷新TLB缓存)。

内核内存空间的特殊映射方式

那既然内核空间也是通过页表访问的,而页表又是内核进行管理的,那怎么通过页表来管理页表呢? 这听起来像是一个鸡生蛋,蛋生鸡的问题。其实说起来也简单,主要原因就是内核内存空间的映射方式和用户内存空间的页表映射方式不一样,我们来看下这个流程。

  1. 在内核启动早期,他是使用了一个简单的内存管理系统,没有使用页表,即虚拟地址=物理地址,在这个模式下,内核可以很方便的访问所有物理地址
  2. 在内核初始化好之后,他会切换内存管理系统,比如使用伙伴系统管理和分配内存,虚拟地址映射到物理地址的方式也会改变,在内核空间(例如0xC0000000以上)进行内存访问时,会建立线性映射,使得虚拟地址 = 物理地址 + 一个固定偏移,例如,物理地址0x1000被映射到虚拟地址0xC0001000。 这样,访问内核内存空间时,虽然也是用的虚拟地址,但是只要加上一个固定偏移量,就能获取到物理地址。
  3. 进程创建时,内核会为新进程创建一个页表,这个页表的用户空间部分,是使用之前说明的映射方式,由内核分配空闲的随机内存,而针对内核空间部分,新进程的页表直接复制了内核主线程(或一个模板进程)的相应条目。

通过上述内容可以看出,内核通过使用不同的内存映射方式,一方面给用户程序的内存使用提供了安全和灵活的特性,同时也通过线性映射的方式,让内核的内存管理更加简单。可以看出,用户空间的内存使用特点是虚拟内存连续,但是实际使用的物理内存大概率不连续,这样可以充分利用系统的内存碎片,提高系统的内存使用率。 而内核空间的内存基本是一片固定的连续内存,这样在方便内存管理的同时,也获得了最佳性能。不过需要注意的是,内核也在部分地址提供不连续的内存映射。

结语

本篇文章到此结束,希望读者有所收获。