从零实现一个操作系统内核——寻址机制:段机制与分页机制
1. 寻址的通俗理解
对于一个处理器来说,可以访问的地址空间大小取决于它自身地址线的数量(有些不严谨哈),这个地址空间是cpu可以访问的最大内存空间,实际上可能小于计算机实际拥有的存储器数量。该地址空间被称为物理地址空间,这里说的内存地址被称为物理地址。
cpu想要访问一块内存区域,需要将物理地址通过地址总线送入存储器。古早时期,物理地址被硬编码入代码中,此时不需要寻址这种机制,因为代码中的地址就是cpu所需要的真实内存地址。但是这就造成了cpu如果同时处理多个程序时,就需要好好地规划地址空间,这显然会造成存储空间的浪费,以及程序编写时的繁琐。那么如何将一个程序加载到任意的存储区域,又不需要在程序编写时考虑地址空间的规划,寻址(地址重定位)的概念应运而生了。
简而言之,寻址遵循以下公式
实际地址 = 基地址 + 偏移量
此时,我们可以把这个偏移量理解为用户(程序)所使用的地址,也称为虚拟地址,用户所拥有的地址空间也被称为虚拟地址空间。
在8086处理器时代,cpu的运行模式被称为实模式(其实这个概念是80286处理器时期提出的,目的是为了兼容8086的操作模式),该模式将程序分为多个段存入内存,寻址方式为
段基址+偏移量
段基址保存在CS、DS、ES等16位段寄存器内,通过将段基址偏移向左偏移4位,加上16位段内偏移量,形成一个20位的物理地址,注意,这时计算出来的既是真实的物理地址。在实模式下,cpu可以访问最大1MB的物理内存空间。但是实模式不提供内存保护,也就是任意一个程序都可访问任意一个内存区域,这使得它的安全性大打折扣。
进入80286时代,处理器引入了保护模式,我们将通过介绍80386来介绍这一沿用至今的cpu运行模式的寻址方式。80386将地址总线数扩大到32位,同时引入32位寄存器,使cpu可以访问的内存空间扩大到4GB。
80386的寻址方式可以分为段机制和页机制两种,实际上页机制是基于段机制的寻址方式,下面我们就来介绍这两种寻址方式。
2. 段机制
保护模式下的段机制寻址方式与实模式下的类似,但为了实现内存保护,即进程不能访问任意内存空间,保护模式下的段机制将段基址,段的保护属性等信息汇总为一个段描述符,段描述符的结构是
每一部分的含义可参考 全局描述符表
并且将内存中所有段的段描述符保存在一张表中,这张表被称为全局描述符表(Global Descriptor Table,GDT),cpu设置了一个48位寄存器来保存它的信息,这个寄存器被称为全局描述符表寄存器(GDTR),它的结构如下图所示
由它的结构可以看出,2^16是65536个字节,每个描述符占8个字节,所以一共可以容纳8192个段描述符(实际上后来为了扩展段的数量,又在全局描述符表的基础上添加了局部描述符表)
那么我们如何根据GDT与GDTR来将虚拟地址转换为物理地址呢?实际上,这里利用了多个被称为段选择子的寄存器,段选择子的结构如下所示
段选择子的低2位含义是请求特权级(RPL);
第3位TI如果为0表示从GDT中查找段描述符,
如果为1表示在局部描述符表(LDT)中查找段描述符;
剩余的13位用来索引描述符,2^13 = 8192,可以看到,与GDT中的段描述符数量基本一致
程序中使用的虚拟地址,实际上是段的偏移量,通过 描述符 + 偏移量
的组合,可以得出实际的物理地址。具体步骤如下所示:
1. 在段选择子中装入段选择符,同时把32位地址偏移量装入某个寄存器(比如ESI、EDI等)中。
2. 根据选择符中的索引值、TI及RPL值,再根据相应描述符表中的段基地址和段界限,进行一系列合法性检查(如特权级检查、界限检查),如果该段无问题,就取出相应的描述符放入段描述符高速缓冲寄存器中。
4. 将描述符中的32位段基地址和放在ESI、EDI等中的32位有效地址(偏移量)相加,就形成了32位物理地址。
值得一提的是,为了实现操作系统内核与普通程序的隔离,linux采用0与3特权级,0特权级表示内核段,3特权表示普通程序段,3特权的普通程序无权访问0特权的内核段内存区域,具体而言,linux创建了4个全局段描述符——内核的代码段和数据段,普通程序的代码段与数据段。所有的进程(普通程序)都是使用这个代码段描述符和数据段描述符,它们是__USER_CS和__USER_DS,也就是每个进程处于用户态时,它们的CS寄存器和DS寄存器中的值是相同的。当任何进程或者中断异常进入内核后,都是使用相同的内核代码段描述符和内核数据段描述符,它们是__KERNEL_CS和__KERNEL_DS。
这样做从两方面对段进行了保护:(1) 在一个段内,如果偏移量大于段界限,虚拟地址将没有意义,系统将产生异常。(2) 如果要对一个段进行访问,系统会根据段的保护属性检查访问者是否具有访问权限,如果没有,则产生异常。例如,如果要在只读段中进行写入,系统将根据该段的属性检测到这是一种违规操作,则产生异常。
3. 分页机制
3.1 平坦模式
对于很多其他体制,并不要求段机制必选,但是IA32在设计上是无法避开段机制的,为了让操作系统内核具有更好的可移植性(大多数硬件平台不支持段机制,只支持分页机制),因此linux操作系统采用了一种被称为平坦模式的方法。也就是将整个地址空间看成一个段,段基址为0,限长为0xFFFFFFFF,大小为4GB。具体而言,将每个段描述符的段基址设计为0,限长为0xFFFFFFFF。这样就使得程序的虚拟地址就等于物理地址,因为段基址为0。
但是这样设计又引出一个问题,如果这样定义,则上节所说的段的内存保护的作用就失去了,因为这些段使用完全相同的线性地址空间(0~4GB),它们互相覆盖。段机制所提供的通过“基地址:界限”方式本来将线性地址空间分割,以让段与段之间完全隔离,这种实现段保护的方式根本就不起作用了。那么为什么要这样设计呢?因为为了更好的采用分页机制
3.2 分页机制
分页机制的优点就不多赘述了,重点来谈一下它的原理。这里我们要引入线性地址这一概念,线性地址是虚拟地址转换为物理地址的中间层。简言之,通过段机制转换的地址被称为线性地址,由此可见,如果没有采用分页机制,那么线性地址就是物理地址。
分页机制使用相同大小的模块将虚拟地址空间与物理地址空间在逻辑上分为多个模块,称为页。常用的页大小有4KB。并且通过一种数据结构来描述这种映射。这样的话,程序看到的是一个连续的地址空间,但实际指向的物理地址空间不一定是连续的。CPU在寻址时,其MMU组件自动完成对虚拟地址空间到物理地址空间的映射。
由此可以引出虚拟内存的实现原理:把物理内存中暂时用不到的内容暂时换出到外存里,空出内存放置现阶段需要的数据。
那么,用来管理页的数据结构是什么呢?这个数据结构被称为页表,由于每个进程都有一个虚拟地址空间,我们为了节省物理内存(因为要将这个页表放置在内存中),采用了一种分级页表机制,即还有一个页目录负责记录每个进程页表的地址。具体如下图所示
由上图可知,32位地址被还分为3部分,其中高10位作为地址在页目录中的偏移量,我们可以结合CR3寄存器中的基址找到页表的地址,中间10位表示地址在页表中的偏移量,可以找到物理页的地址,而末12为表示在物理页中的偏移量,这就得到了最终的物理地址。具体计算公式如下
PDX =LA >> 22;
PTX =(LA >> 12)&0x3FF;
PDE = ∗ (CR3 + 4 ∗ PDX);
PTE = ∗ ((PDE&0xFFFFF000) + 4 ∗ PTX);
PA =(PTE&0xFFFFF000) + (LA&0xFFF);
这就是分页机制的基本原理。
4. 总结
段机制:虚拟地址 -> 段机制处理 -> 线性地址 = 物理地址
分页机制:虚拟地址 -> 段机制处理 -> 线性地址 -> 页机制处理 -> 物理地址