从进入内核态看内存管理

知乎上搜到一个比较有意思的话题:如何理解「进入内核态」,要回答好这个问题需要对内存管理及程序的运行机制有比较深刻的了解,比如你需要了解内存的分段,分页,中断等机制,信息量比较大,本文将会 Intel CPU 的发展历史讲起,循序渐近地帮助大家彻底掌握这一概念,相信大家看了肯定有帮助,本文目录如下

  • CPU 运行机制
  • Intel CPU 历史发展史
  • 分段
  • 保护模式
  • 特权级
  • 系统调用
  • 中断
  • 分段内存的优缺点
  • 内存分页
  • 总结

CPU 运行机制

我们先简单地回顾一下 CPU 的工作机制,重新温习一下一些基本概念,因为我在查阅资料的过程发现一些网友对寻址,CPU 是几位的概念理解得有些模糊,理解了这些概念再去看 CPU 的发展史就不会再困惑

CPU 是如何工作的呢?它是根据一条条的机器指令来执行的,而机器指令= 操作码+操作数,操作数主要有三类:寄存器地址、内存地址或立即数(即常量)。

我们所熟悉的程序就是一堆指令和数据的集合,当打开程序时,装载器把程序中的指令和数据加载到内存中,然后 CPU 到内存中一条条地取指令,然后再译码,执行。

在内存中是以字节为基本单位来读写数据的,我们可以把内存看作是一个个的小格子(一般我们称其为内存单元),而每个小格子是一个字节,那么对于 B8 0123H 这条指令来说,它在内存中占三字节,如下,CPU 该怎么找到这些格子呢,我们需要给这些格子编号,这些编号也就是我们说的内存地址,根据内存地址就是可以定位指令所在位置,从而取出里面的数据

如图示:内存被分成了一个个的格子,每个格子一个字节,20000~20002 分别为对应格子的编号(即内存地址)

CPU 执行指令主要分为以下几个步骤

  1. 取指令,CPU 怎么知道要去取哪条指令呢,它里面有一个 IP 寄存器指向了对应要取的指令的内存地址, 然后这个内存地址会通过地址总线找到对应的格子,我们把这个过程称为寻址,不难发现寻址能力决定于地址总线的位宽,假设地址总线位数为 20 位,那么内存的可寻址空间为 2^20 * 1Byte = 1M,将格子(内存单元)里面的数据(指令)取出来后,再通过数据总线发往 CPU 中的指令缓存区(指令寄存器),那么一次能传多少数据呢,取决于数据总线的位宽,如果数据总线为 16 位,那么一次可以传 16 bit 也就是两个字节。

  2. 译码:指令缓冲区中的指令经过译码以确定该进行什么操作

  3. 执行:译码后会由控制单元向运算器发送控制指令进行操作(比如执行加减乘除等),执行是由运算器操纵数据也就是操作数进行计算,而操作数保存在存储单元(即片内的缓存和寄存器组)中,由于操作数有可能是内存地址,所以执行中可能需要到内存中获取数据(这个过程称为访存),执行后的结果保存在寄存器或写回内存中

    以指令 mov ax, 0123H 为例,它表示将数据 0123H 存到寄存器 AX 中,在此例中 AX 为 16 位寄存器,一次可以操作 16 位也就是 2 Byte 的数据,所以我们将其称为 16 位 CPU,CPU 是多少位取决于它一次执行指令的数据带宽,而数据带宽又取决于通用寄存器的位宽

  4. 更新 IP:执行完一条指令后,更新 IP 中的值,将其指向下一条指令的起始地址,然后重复步骤 1

由以上总结可知寻址能力与寄存器位数有关

接下来我们以执行四条指令为例再来仔细看下 CPU 是如何执行指令的,动图如下:

看到上面这个动图,细心地你可能会发现两个问题

  1. 前文说指令地址是根据 IP 来获取的吗,但上图显示指令地址却是由「CS 左移四位 + IP」计算而来的,与我们所阐述的指令保存在 IP 寄存器中似乎有些出入,这是怎么回事呢?
  2. 动图显示的地址是真实物理地址,这样进程之间可以互相访问/改写对方的物理地址,显然是不安全的,那如何才能做到安全访问或者说进程间内存的隔离呢

以上两点其实只要我们了解一下 CPU 的发展历史就明白解决方案了,有了以上的铺垫,在明白了寻址16/32/64 位 CPU 等术语的含义后,再去了解 CPU 的发展故事会更容易得多,话不多说,发车

Intel CPU 历史发展史

1971 年世界上第一块 4 位 CPU-4004 微处理器横空出世,1974 年 Intel 研发成功了 8 位 CPU-8080,这两款 CPU 都是使用的绝对物理地址来寻址的,指令地址只存在于 IP 寄存器中(即只使用 IP 寄存器即可确定内存地址)。由于是使用绝对物理地址寻址,也就意味着进程之间的内存数据可能会互相覆盖,很不安全,所以这两者只支持单进程

分段

1978 年英特尔又研究成功了第一款 16 位 CPU - 8086,这款 CPU 可以说是 x86 系列的鼻祖了,设计了 16 位的寄存器和 20 位的地址总线,所以内存地址可以达到 2^20 Byte 即 1M,极大地扩展了地址空间,但是问题来了,由于寄存器只有 16 位,那么 16 位的 IP 寄存器如何能寻址 20 位的地址呢,首先 Intel 工程师设计了一种分段的方法:1M 内存可以分为 16 个大小为 64 K 的段,那么内存地址就可以由「段的起始地址(也叫段基址) + 段内偏移(IP 寄存器中的值)」组成,对于进程说只需要关心 4 个段 ,代码段数据段堆栈段附加段,这几个段的段基址分别保存在 CS,DS,SS,ES 这四个寄存器中

这四个寄存器也是 16 位,那怎么访问 20 位的内存地址呢,实现也很简单,将每个寄存器的值左移四位,然后再加上段内偏移即为寻址地址,CPU 都是取代码段 中的指令来执行的,我们以代码段内的寻址为例来计算内存地址,指令的地址 = CS << 4 + IP ,这种方式做到了 20 位的寻址,只要改变 CS,IP 的值,即可实现在 0 到最大地址 0xFFFFF 全部 20 位地址的寻址

举个例子:假设 CS 存的数据为 0x2000,IP 为 0x0003,那么对应的指令地址为

图示为真实的物理地址计算方式,从中可知, CS 其实保存的是真实物理地址的高 16 位

分段的初衷是为了解决寻址问题,但本质上CS:IP 计算得到的还是真实物理地址,所以它也无法支持多进程,因为使用绝对物理地址寻址意味着进程可以随意修改 CS:IP,将其指向任意地址,很可能会覆盖正在运行的其他进程的内存,造成灾难性后果。

我们把这种使用真实物理地址且未加任何限制的寻址方式称为实模式(real mode,即实际地址模式)

保护模式

实模式上的物理地址由 段寄存器中的段基址:IP 计算而来,而段基址可由用户随意指定,显然非常不安全,于是 Intel 在之后推出了 80286 中启用了保护模式,这个保护是怎么做的呢

首先段寄存器保存的不再是段基址了,而是段选择子(Selector),其结构如下

其中第 3 到 15 位保存的是描述符索引,此索引会根据 TI 的值是 0 还是 1 来选择是到 GDT(全局描述符表,一般也称为段表)还是 LDT 来找段描述符,段描述符保存的是段基址和段长度,找到段基址后再加上保存在 IP 寄存器中的段偏移量即为物理地址,段描述符的长度统一为 8 个字节,而 GDT/LDT 表的基地址保存在 gdtr/ldtr 寄存器中,以 GDT (此时 TI 值为 0)为例来看看此时 CPU 是如何寻址的

可以看到程序中的地址是由段选择子:段内偏移量组成的,也叫逻辑地址,在只有分段内存管理的情况下它也被称为虚拟内存

GDT 及段描述符的分配都是由操作系统管理的,进程也无法更新 CS 等寄存器中值,这样就避免了直接操作其他进程以及自身的物理地址,达到了保护内存的效果,从而为多进程运行提供了可能,我们把这种寻址方式称为保护模式

那么保护模式是如何实现的呢,细心的你可能发现了上图中在段选择子和段描述符中里出现了 RPLDPL 这两个新名词,这两个表示啥意思呢?这就涉及到一个概念:特权级

特权级

我们知道 CPU 是根据机器指令来执行的,但这些指令有些是非常危险的,比如清内存置时钟分配系统资源等,这些指令显然不能让普通的进程随意执行,应该始终控制在操作系统中执行,所以要把操作系统和普通的用户进程区分开来

我们把一个进程的虚拟地址划分为两个空间,用户空间内核空间,用户空间即普通进程所处空间,内核空间即操作系统所处空间

当 CPU 运行于用户空间(执行用户空间的指令)时,它处于用户态,只能执行普通的 CPU 指令 ,当 CPU 运行于内核空间(执行内核空间的指令)时,它处于内核态,可以执行清内存,置时钟,读写文件等特权指令,那怎么区分 CPU 是在用户态还是内核态呢,CPU 定义了四个特权等级,如下,从 0 到 3,特权等级依次递减,当特权级为 0 时,CPU 处于内核态,可以执行任何指令,当特权级为 3 时,CPU 处于用户态,在 Linux 中只用了 Ring 0,Ring 3 两个特权等级

那么问题来了,怎么知道 CPU 处于哪一个特权等级呢,还记得上文中我们提到的段选择子吗

其中的 RPL 表示请求特权((Requested privilege level))我们把当前保存于 CS 段寄存器的段选择子中的 RPL 称为 CPL(current priviledge level),即当前特权等级,可以看到 RPL 有两位,刚好对应着 0,1,2,3 四个特权级,而上文提到的 DPL 表示段描述符中的特权等级(Descriptor privilege level)知道了这两个概念也就知道保护模式的实现原理了,CPU 会在两个关键点上对内存进行保护

  1. 目标段选择子被加载时

  2. 当通过线性地址(在只有段式内存情况下,线性地址为物理地址)访问一个内存页时。由此可见,保护也反映在内存地址转换的过程之中,既包括分段又包括分页(后文分提到分页)

CPU 是怎么保护内存的呢,它会对 CPL,RPL,DPL 进行如下检查

只有 CPL <= DPL 且 RPL <= DPL(申请特权等级待以及当前特权等级必须比调用的目标代码段的特权级更高,以防普通程序直接调用目标代码段) 时,才会加载目标代码段执行,否则会报一般保护异常 (General-protection exception)

那么特权等级(也就是 CPL)是怎么变化的呢,我们之前说了 CPU 运行于用户空间时,处于用户态,特权等级为 3,运行于内核空间时,处于内核态,特权等级为 0,所以也可以换个问法 CPU 是如何从用户空间切换到内核空间或者从内核空间切换到用户空间的,这就涉及到一个概念:系统调用

系统调用

我们知道用户进程虽然不能执行特权指令,但有时候也需要执行一些读写文件,发送网络包等操作,而这些操作又只能让操作系统来执行,那该怎么办呢,可以让操作系统提供接口,让用户进程来调用即可,我们把这种方式叫做系统调用,系统调用可以直接由应用程序调用,或者通过调用一些公用函数库或 shell(这些函数库或 shell 都封装了系统调用接口)等也可以达到间接调用系统调用的目的。通过系统调用,应用程序实现了陷入(trap)内核态的目的,这样就从用户态切换到了内核态中,如下

应用程序通过系统调用陷入内核态
应用程序通过系统调用陷入内核态

那么系统调用又是怎么实现的呢,主要是靠中断实现的,接下来我们就来了解一下什么是中断

中断

陷入内核态的系统调用主要是通过一种 trap gate(陷阱门)来实现的,它其实是软件中断的一种,由 CPU 主动触发给自己一个中断向量号,然后 CPU 根据此中断向量号就可以去中断向量表找到对应的门描述符,门描述符与 GDT 中的段描述符相似,也是 8 个字节,门描述符中包含段选择子,段内偏移,DPL 等字段 ,然后再根据段选择子去 GDT(或者 LDT,下图以 GDT 为例) 中查找对应的段描述符,再找到段基地址,然后根据中断描述符表的段内偏移即可找到中断处理例程的入口点,整个中断处理流程如下

画外音:上图中门描述符和段描述符只画出了关键的几个字段,省略了其它次要字段

当然了,不是随便发一个中断向量都能被执行,只有满足一定条件的中断才允许被普通的应用程序调用,从发出软件中断再到执行中断对应的代码段会做如下的检查

一般应用程序发出软件中断对应的向量号是大家熟悉的 int 0x80(int 代表 interrupt),它的门描述符中的 DPL 为 3,所以能被所有的用户程序调用,而它对应的目标代码段描述符中的 DPL 为 0,所以当通过中断门检查后(即 CPL <= 门描述符中的 DPL 成立),CPU 就会将 CS 寄存器中的 RPL(3) 替换为目标代码段描述符的 DPL(0),替换后的 CPL 也就变成了 0,通过这种方式完成了从用户态到内核态的替换,当中断代码执行后执行 iret 指令又会切换回用户态

另外当执行中断程序时,还需要首先把当前用户进程中对应的堆栈,返回地址等信息,以便切回到用户态时能恢复现场

可以看到 int 80h 这种软件中断的执行又是检查特权级,又是从用户态切换到内核态,又是保存寄存器的值,可谓是非常的耗时,光看一下以下图示就知道像 int 0x80 这样的软件中断开销是有多大了

系统调用
系统调用

所以后来又开发出了 SYSENTER/SYSCALL 这样快速系统调用的指令,它们取消了权限检查,也不需要在中断描述表(Interrupt Descriptor Table、IDT)中查找系统调用对应的执行过程,也不需要保存堆栈和返回地址等信息,而是直接进入CPL 0,并将新值加载到与代码和堆栈有关的寄存器当中(cs,eip,ss 和 esp),所以极大地提升了性能

分段内存的优缺点

使用了保护模式后,程序员就可以在代码中使用了段选择子:段偏移量的方式来寻址,这不仅让多进程运行成为了可能,而且也解放了程序员的生产力,我们完全可以认为程序拥有所有的内存空间(虚拟空间),因为段选择子是由操作系统分配的,只要操作系统保证不同进程的段的虚拟空间映射到不同的物理空间上,不要重叠即可,也就是说虽然各个程序的虚拟空间是一样的,但由于它们映射的物理地址是不同且不重叠的,所以是能正常工作的,但是为了方便映射,一般要求在物理空间中分配的段是连续的(这样只要维护映射关系的起始地址和对应的空间大小即可)

段式内存管理-虚拟空间与实际物理内存的映射
段式内存管理-虚拟空间与实际物理内存的映射

但段式内存管理缺点也很明显:内存碎片可能很大,举个例子

如上图示,连续加载了三个程序到内存中,如果把 Chrome 关闭了,此时内存中有两段 128 M的空闲内存,但如果此时要加载一个 192 M 的程序 X 却有心无力了 ,因为段式内存需要划分出一块连续的内存空间,此时你可以选择把占 256 M 的 Python 程序先 swap 到磁盘中,然后紧跟着 512 M 内存的后面划分出 256 M 内存,再给 Python 程序 swap 到这块物理内存中,这样就腾出了连续的 256 M 内存,从而可以加载程序 X 了,但这种频繁地将几十上百兆内存与硬盘进行 swap 显然会对性能造成严重的影响,毕竟谁都知道内存和硬盘的读写速度可是一个天上一个地上,如果一定要交换,能否每次 swap 得能少一点,比如只有几 K,这样就能满足我们的需求,分页内存管理就诞生了

内存分页

1985 年 intel 推出了 32 位处理器 80386,也是首款支持分页内存的 CPU

和分段这样连续分配一整段的空间给程序相比,分页是把整个物理空间切成一段段固定尺寸的大小,当然为了映射,虚拟地址也需要切成一段段固定尺寸的大小,这种固定尺寸的大小我们一般称其为页,在 LInux 中一般每页的大小为 4KB,这样虚拟地址和物理地址就通过页来映射起来了

当然了这种映射关系是需要一个映射表来记录的,这样才能把虚拟地址映射到物理内存中,给定一个虚拟地址,它最终肯定在某个物理页内,所以虚拟地址一般由「页号+页内偏移」组成,而映射表项需要包含物理内存的页号,这样只要将页号对应起来,再加上页内偏移,即可获取最终的物理内存

于是问题来了,映射表(也称页表)该怎么设计呢,我们以 32 位虚拟地址位置来看看,假设页大小为 4K(2^12),那么至少需要 2^20 也就是 100 多万个页表项才能完全覆盖所有的虚拟地址,假设每一个页表项 4 个字节,那就意味着为一个进程的虚拟地址就需要准备 2^20 * 4 B = 4 M 的页表大小,如果有 100 个进程,就意味着光是页表就要占用 400M 的空间了,这显然是非常巨大的开销,那该怎么解决这个页表空间占用巨大的问题呢

我们注意到现在的做法是一次性为进程分配了占用其所有虚拟空间的页表项,但实际上一个进程根本用不到这么巨大的虚拟空间,所以这种分配方式无疑导致很多分配的页表白白浪费了,那该怎么办,答案是分级管理,等真正需要分配物理空间的时候再分配,其实大家可以想想我们熟悉的 windows 是怎么分配的,是不是一开始只分配了 C 盘,D盘,E盘,等要存储的时候,先确定是哪个盘,再在这个盘下分配目录,然后再把文件存到这个目录下,并不会一开始就把所有盘的空间给分配完的

同样的道理,以 32 位虚拟地址为例,我们也可以对页表进行分级管理, 页表项 2^20 = 2^10 * 2^10 = 1024 * 1024,我们把一个页表分成两级页表,第一级页表 1024 项,每一项都指向一个包含有 1024 个页表项的二级页表

图片来自《图解系统》
图片来自《图解系统》

这样只有在一级页表中的页表项被分配的时候才会分配二级页表,极大的节省了空间,我们简单算下,假设 4G 的虚拟空间进程只用了 20%(已经很大了,大部分用不到这么多),那么由于一级页表空间为 1024 *4 = 4K,总的页表空间为 4K+ 0.2 * 4M = 0.804M,相比于原来的 4M 是个巨大的提升!

那么对于分页保护模式又是如何起作用的呢,同样以 32 位为例,它的二级页表项(也称 page table entry)其实是以下结构

注意第三位(也就是 2 对应的位置)有个 U/S,它其实就是代表特权级,表示的是用户/超级用户标志。为 1 时,允许所有特权级别的程序访问;为 0 时,仅允许特权级为0、1、2(Linux 中没有 1,2)的程序(也就是内核)访问。页目录中的这个位对其所映射的所有页面起作用

既然分页这么好,那么分段是不是可以去掉了呢,理论上确实可以,但 Intel 的 CPU 严格执行了 backward compatibility(回溯兼容),也就是说最新的 CPU 永远可以运行针对早期 CPU 开发的程序,否则早期的程序就得针对新 CPU 架构重新开发了(早期程序针对的是 CPU 的段式管理进行开发),这无论对用户还是开发者都是不能接受的(别忘了安腾死亡的一大原因就是由于不兼容之前版本的指令),兼容性虽然意味着每款新的 CPU 都得兼容老的指令,所背的历史包袱越来越重,但对程序来说能运行肯定比重新开发好,所以既然早期的 CPU 支持段,那么自从 80386 开始的所有 CPU 也都得支持段,而分页反而是可选的,也就意味着这些 CPU 的内存管理都是段页式管理,逻辑地址要先经过段式管理单元转成线性地址(也称虚拟地址),然后再经过页式管理单元转成物理内存,如下

分页是可选项
分页是可选项

在 Linux 中,虽然也是段页式内存管理,但它统一把 CS,DS,SS,ES 的段基址设置为了 0,段界限也设置为了整个虚拟内存的长度,所有段都分布在同一个地址空间,这种内存模式也叫平坦内存模型(flat memory model)

平坦内存模型
平坦内存模型

我们知道逻辑地址由段选择子:段内偏移地址组成,既然段选择子指向的段基地址为 0,那也就意味着段内偏移地址即为即为线性地址(也就是虚拟地址),由此可知 Linux 中所有程序的代码都使用了虚拟地址,通过这种方式巧妙地绕开了分段管理,分段只起到了访问控制和权限的作用(别忘了各种权限检查依赖 DPL,RPL 等特权字段,特权极转移也依赖于段选择子中的 DPL 来切换的)

总结

看完本文相信大家对实模式,保护模式,特权级转换,分段,分页等概念应该有了比较清晰的认识。

我们简单总结一下,CPU 诞生之间,使用的绝对物理内存来寻址(也就是实模式),随后随着 8086 的诞生,由于工艺的原因,虽然地址总线是 20 位,但寄存器却只有 16 位,一个难题出现了,16 位的寄存器该怎么寻址 20 位的内存地址呢,于是段的概念被提出了,段的出现虽然解决了寻址问题,但本质上 CS << 4 + IP 的寻址方式依然还是绝对物理地址,这样的话由于地址会互相覆盖,显然无法做到多进程运行,于是保护模式被提出了,保护就是为了物理内存免受非法访问,于是用户空间,内核空间,特权级也被提出来了,段寄存器里保存的不再是段基址,而是段选择子,由操作系统分配,用户也无法随意修改段选择子,必须通过中断的形式才能从用户态陷入内核态,中断执行的过程也需要经历特权级的检查,检查通过之后特权级从 3 切换到了 0,于是就可以放心合法的执行特权指令了。可以看到,通过操作系统分配段选择子+中断的方式内存得到了有效保护,但是分段可能造成内存碎片过大以倒频繁 swap 会影响性能的问题,于是分页出现了,保护模式+分页终于可以让多进程,高效调度成为了可能

参考

  • CPU 是怎么执行指令的 https://z.itpub.net/article/detail/1468ED259C713472E41638CE8890DA5C
  • 好家伙!原来硬中断就是这样的:(https://mp.weixin.qq.com/s/OWrw6VNTNVZRj5lJqEgY3w)
  • RPL 的故事 https://string.quest/read/15152681
  • RPL,DPL 区别 https://stackoverflow.com/questions/36617718/difference-between-dpl-and-rpl-in-x86
posted @ 2022-06-09 19:30  ErnestEvan  阅读(226)  评论(1编辑  收藏  举报