简易内核实现笔记(二)

简易内核实现笔记(二)——内存寻址与安全机制

前面叙述了x86架构的CPU在加电后是怎么一步步把内核加载到内存中去运行的,但有的东西说的比较仓促,在这里会结合硬件讲述x86架构的CPU是如何与软件结合进行内存寻址的,并且在保护模式下为内存寻址提供了哪些最基本的安全机制,有的内容会和笔记(一)重复。

内存寻址

在x86 CPU的硬件支持下,保护模式的寻址(假如分页已经打开)是如下的过程:

1
逻辑地址 --分段机制--> 线性地址 --分页机制--> 物理地址

可用看到就是分段与分页机制共同作用的结果。在x86架构的CPU下,分页并不是一个必要的过程,但在将控制寄存器CR0的第31位写为1开启分页模式后,分页机制就会帮助CPU实现虚拟地址空间的寻址,这样的一层抽象能够让各个进程处于好像是自己霸占了所有的内存资源一样,简化了软件对内存的访问与使用,因此linux是使用了虚拟地址空间的。但分段是x86 CPU强加的,这个下面会详细介绍。

分段机制

x86 CPU为段机制的实现提供了专门的寄存器:CS,DS,SS,ES,FS,GS。其中前三个看缩写也明白,分别对应了代码段(code segment),数据段(data segment),栈段(stack segment)。后面三个寄存器的用途x86 CPU在硬件实现上没有强加,因此功能是软件定义的,暂且不聊。我们先说前三个寄存器。

所谓的段(segment),还是得从硬件的角度去理解。CPU通过总线连接了其他硬件设备,并通过总线与它们实现交互,而总线又可以分为三类:地址线,数据线,控制线。本质上说,所有的计算机信息都是二进制0和1,CPU也只认识0和1,不认识什么是代码,什么是数据,什么是地址,对于两个完全相同的01序列,CPU可用有不同的解释:

1
2
1000100111011000 -> 89DH      (数据)
1000100111011000 -> mov ax,bx (代码)
CPU-architecture

CPU是通过什么机制将一些01序列视为数据,另一些01序列视为代码的呢?答案就是看它们通过什么样的线,通过数据线的就是数据,通过地址线的就是地址,通过控制线的就是指令。自然地,为了在硬件上帮助这个机制实现地更彻底一点,那就创造一种分段的机制,把内存地址空间中的01序列分成一段一段的,如果在数据段中,那这段内存中的01序列CPU就把它当成数据,处于栈段中的,CPU就把它当成栈,处于代码段中的,CPU就把它当成代码。

于是x86 CPU就专门设置了三个寄存器实现分段,CS存储的就是代码段的段基址,DS存储的就是数据段的段基址,SS存储的就是栈段的段基址,然后我们再给它们配套一个寄存器用来存储段的偏移地址,那么CPU只要用段基址:偏移地址的形式就能得到对应段的物理地址,于是就将代码,数据,栈在形式上分了开来,程序就能有条理地被执行了。

8086分段机制

由于8086在硬件架构上是有20位的地址线,也就是寻址上是1MB的内存空间,但寄存器只有16位,为了能够实现20位的寻址模式,分段机制就采用物理地址=段基址*16+偏移地址的方式来凑出20位地址。这里就可用看出分段机制不仅仅是为了将代码,数据和栈分离开,它也是8086 CPU实现20位寻址的必要机制,所以在分段上x86 家族的CPU从8086开始就深入骨髓中了。8086的运行模式在新一代的x86 CPU中也称之为实模式,所有x86家族CPU加电的瞬间都是在实模式下运行,目的就是做到向下兼容。

保护模式分段机制

在保护模式下,除了段寄存器之外,其余寄存器都扩展到了32位(IA32架构),那么寻址空间就从1MB变成了32位4GB,并且理论上只需要提供偏移地址的32位寄存器就可以独立完成32位的寻址,因此在保护模式下,分段机制的作用只剩下了将代码,数据和栈进行分离了。并且保护模式下硬件将与软件一起实现分段机制。

全局描述符表

抛砖引玉,我们先考虑这个问题:既然保护模式下,32位寄存器已经能在理论上脱离段寄存器独立寻址,那么段寄存器在保护模式下的意义除了向下兼容8086实模式以外还有什么?没错,答案就是为描述符表而存在!保护模式下的段寄存器存储的东西不再称为“段基址”,而是“段选择子”(selector),而这个选择的目标就是对应的段描述符。段选择子的16位二进制结构对应的意义如下:

1
2
|15|      ...   | 3| 2| 1| 0|
| index |TI| RPL |

1-0位是请求特权级,这个后面详细说。第2位是表指示符,用于指代后面的指标indexing的是GDT还是LDT,后13位就是段描述符表的index了,从长度来看总共可以索引8192个段描述符。

描述符表分为两种,一个是全局描述符表GDT,另外一个是局部描述符表LDT,GDT是被所有进程共享的,LDT是单个进程独有的,由于linux kernel在2.4之后并不使用LDT,这里就略过了,但它们都是一样的东西,唯一区别只是公用和私用而已。

所谓的全局描述符表就是一个位于内存中的描述符的数组,它的首位地址就是第一个描述符地址,一个描述符大小为8字节64位,每一位分别对应的意义如下:

descriptor

可以看到这里面是有段基址的,所以保护模式的分段实际过程就是段寄存器通过存储全局描述符表基址的寄存器GDT再加上index*8寻址到对应的段描述符,然后取出对应的段基址再加上偏移地址就可以了。

seg-process

绕了大半天,其实就是为了让寻址时加上额外的一些段信息,它们意义如下,由于一些向下兼容的原因,一些东西是不连续的,但不妨碍我们理解:

  • 段界限:一个段的最大大小是20位,如果索引超过段界限CPU会触发异常。
  • G:段界限的粒度,如果G为0就代表粒度是1位,对应到段界限就是20位1MB。G为1就代表粒度为4KB,对应到段界限就是4GB,因此实际的段界限大小等=粒度大小*段界限-1
  • 段基址:顾名思义,不用说了
  • D/B:一个用来兼容80286保护模式的位,表示有效地址和操作数的位数。D为0表示16位,D为1表示32位(所以对我们不用80286的就没什么用)
  • L:为1表示64位代码段,0表示32位
  • AVL:available字段,这个available是对于用户来说的,不是硬件,所以是可以随便用的
  • P:用于指示段是否存在于内存中,用到这个段时如果它不存在,就会触发CPU的异常,然后跳转到异常处理程序中把它加载到内存中。
  • DPL:Descriptor Privilege Level,表示描述符的特权级。
  • S:为1表示系统段,0表示非系统段
  • type:段的类型,这三位对于系统段和非系统段有不同的定义:
descriptor-type

这些信息提供了保护模式下的安全机制,这个后面再说。BTW linux kernel认为段基址是没有意义的,因为偏移地址已经可以给出完整的线性地址,因此linux kernel的全局描述符表中的段基址位全都置为了0用以规避分段机制,因此在linux下偏移地址就等于线性地址。因此GDT对于linux存在的唯一意义就是实现内存访问的安全机制了。

分页机制

分页机制实际上就是将线性地址看作了虚拟地址,通过页表实现了虚拟地址到物理地址的映射,由于笔记(一)已经详细讲述,这里就直接复制粘贴了:

虚拟地址空间

在进入保护模式之后,我们所访问的32位地址仍然是物理地址,虚拟地址为我们提供了一层抽象,使得每个进程都可以在32位地址空间中运行,我们只需要通过页表将它们映射到物理地址即可,这样写程序就不用再自己去管地址从哪里开始了。

页表

页表是虚拟地址与物理地址的映射关系,由于将来每个操作系统下的进程,包括操作系统自己都是在32位虚拟地址空间中运行的,因此每个进程都需要有自己的页表,我们将物理地址分页,每个页占有4kB的大小,一个页表项就占32位4字节,检索4GB的虚拟内存空间总共需要1M个页表,在内存中占4MB,这个大小显然是无法接受的,因此我们再创建一个页表的页表,也就是页目录表,一个页目录项也是32位4字节,因此一个页目录项也可以索引4kB的空间,那么检索4GB的虚拟地址空间只需要4GB/4kB/4kB=1024个页目录,只需要4096个字节就可以了,这样的开销就可以接受。

对于1024个页目录,我们需要10位地址来进行索引,这10位地址就是虚拟地址中的高10位,我们将这10位地址4就是对应页表的偏移地址,再加上页目录表的起始地址就得到了对应页表所在的物理地址,一个页表中有1024个页,因此检索它也需要10位地址,这10位地址就是虚拟地址中的中间10位,我们用这中间10位地址4就得到了所在页的偏移地址,加上前面得到的页表物理地址就得到了对应页所在物理地址,这个页中存储的就是真实物理地址的偏移量,再加上最低12位虚拟地址就得到了对应的真实物理地址了。

page-process

因为每个页表项都是4字节,因此它们的值里面低12位全是0,因此为了避免浪费就要往里面加一些关于页表的安全信息:

page

其中:

  • P:该页存在于物理地址中
  • R/W:读写权限,0表示只读,1表示可读可写
  • US:普通用户/超级用户位,为1表示在普通用户级,普通用户在特权级3
  • PWT:通写位,1表示处于通写模式,表示改该页是高速缓存
  • PCD:打开使用高速缓存
  • A:访问位,如果CPU访问过该页,就会把它置为1,之后的操作系统我们会将它置为0,通过count置为1的次数就能判断它是否常常被使用,是就将这个页存入缓存中
  • D:脏页位,CPU对一个页进行写操作时,就会把这个位置为1,仅对页表项有效
  • G:global位,若为global,那么这个页表就会一直在高速缓存TLB中保存
  • AVL:软件的可用为,CPU不会管,怎么用就是软件定义的了

内存的安全机制

访问特权级

除了页表项以及段描述符中的那些索引界限以及读写权限的设置以外,x86 CPU保护模式还设计了特权级来为操作系统提供安全支持。特权级从0-3一共4级,0级最高,也是操作系统内核的特权级,3级最低,是普通用户的特权级,对于linux来说,只用到了特权级0和3,因此0级特权下又称为内核态,3级特权下又称为用户态。CPU对内核态完全信任,也就是操作系统内核对硬件资源拥有完全的访问权限,低级特权无法访问被指定了高级特权能访问的硬件资源,也就是用户态的进程无法直接访问操作系统的内存空间以及代码,只能通过中断陷入内核,然后调用内核的异常处理程序来向内核请求服务,这样就保证了操作系统基本的安全。

那么这种机制是如何实现的呢?首先就是在段寄存器中储存的段选择子上,选择子的第1-0位上就是请求特权级,编码上的00,01,10,11就对应了0,1,2,3这四级特权级。对于栈段和数据段来说,这个特权级就代表了请求访问它们对应的段所需要的最小特权级,而对于代码段来说,这个特权级就代表了这段代码执行的特权级,因此代码段的RPL叫CPL(current privilege level),也就是当前指令的特权级。前面说描述符的时候有提到,描述符里也有它自己的特权级DPL,因此DPL也在安全特权检查之列。

在CS:EIP指向了内存中的一个指令地址的时候,如果不考虑特权级转换,CPU会做的完整步骤如下:

  • 首先根据CS的index检索到对应代码段的段描述符,得到描述符的DPL,然后用CS的CPL比较,如果CPL>DPL,则报保护错(数字越小特权越高)
  • CPL大于等于代码段描述符DPL,则通过描述符提供的段基址+EIP的偏移地址得到指令的线性虚拟地址,然后通过页表缓存或页表查询到物理地址,取指令执行
  • 指令执行时会如果访问到相应的数据段或者栈段,则对应段选择则先indexing到对应的段描述符,然后检查保证CPL或者访问段选择子的RPL有一个小于等于该段描述符的DPL,如果max{CPL,RPL}>DPL,则报保护错
  • 检查通过,然后访问相应资源,指令执行完毕后加载下一条指令跳回第一步

特权级的提升与降低

CPU还要考虑陷入内核态后上下文的保存问题,进程触发异常后会陷入内核态,然后内核调用相应的异常处理程序,此时特权级就从3提升到0,在执行完内核代码之后(如果不是终止异常)又返回用户态。那么一个进程从3跳到0的过程要有4组栈寄存器来对应每个特权级的栈段和栈底。32位机器下4GB的寻址空间中最高位的1GB是内核才能访问的,这里面就有内核使用的栈段,肯定要和用户用的低3GB地址下的栈段区分,并且在进程陷入内核态之时,用户态的上下文信息肯定要保存下来,等待内核代码做完事情以后恢复现场。实现的方法就是一个叫TSS(task state segment)的数据结构:

02-TSS

可以看到TSS只能保留3组栈,因为用户态本来就是最低权限,已经降不下去了,,而且汇编指令的int,call会将用户态的栈保存,TSS就只用记录0,1,2这三个特权级的栈寄存器就OK。每个进程都有自己的TSS,并且x86 CPU会有专门的寄存器TR(task register)来保存它的地址,当用户态的进程陷入内核态时,除了SS,ESP以外的上下文信息就会被保存,然后使用0级特权栈配合CRL为0的内核代码完成相应异常处理程序,最后再恢复现场,把特权级降回用户态就完事了。

降低特权级可以通过恢复进程上下文实现,但还得考虑怎么提升特权级的问题。CPU又提供了一组和硬件支持的数据结构来实现,这种数据结构称为‘门’。一共四种,任务门,中断门,陷阱门和调用门:

task-gate
intr-gate
trap-gate
call-gate

门也是一种描述符,只不过和全局描述符表中的描述符不一样的是,全局描述符表是记录数据的描述符,而门中除了任务门以外记录的是一段历程地址的描述符,用于支持内核的系统调用:

  • call和jmp指令的选择子会成为调用门的参数,指令CPL通过调用门的DPL特权检查后call会以调用高CPL函数例程形式实现特权级提升,jmp只能转移到CPL平行的代码上。
  • int指令会触发中断,指令CPL通过中断门的DPL特权检查后,linux并根据中断类型调用相应的异常处理程序,然后以中断形式进入内核态实现特权提升。
  • int3指令通过触发中断形式在陷阱门中实现特权提升,一般是编译器调试用,不用管
  • 任务(进程)在中断发生后如果中断向量号是任务门,则通过任务门以TSS为单位实现任务切换,不过linux并没有使用这样的硬件机制,所以不用管

综上,一个指令在执行的时候,它的CPL必须满足以下条件:

  • 访问门(向内核请求服务):CPL≤DPL(gate) and CPL≥DPL(seg)
  • 访问段:max{CPL,RPL}≤DPL

这就是特权在保护模式下提供的安全机制,可见这些安全机制一部分是由硬件实现,一部分是由操作系统内核实现的。

 wechat
scan and following my wechat official account.