当前位置: 首页 > 科技观察

从零开始学习Linux:x86处理器如何执行-内存保护层?

时间:2023-03-13 15:52:19 科技观察

实模式:bootloader为程序计算段基地址保护模式:bootloader为自己创建一个段描述符来确定GDT的地址为代码段创建一个描述符为数据段创建一个描述符创建一个descriptorforthestacksegment段描述符是如何保证段的安全?段寄存器高速缓存保护段寄存器本身并检查段边界。在上一篇文章中,我们已经成功地从实模式转换到保护模式。保护模式和实模式最本质的区别是:保护模式使用全局描述符表来保存每个程序(bootloader,操作系统,应用程序)使用的每个段信息:起始地址,长度,以及其他一些保护参数。本篇我们先来看看bootloader是如何进化到保护模式的,再深入了解一下保护模式是如何保护内存安全的。作为背景知识,让我们看一下x86中的地址转换过程:x86处理器中的分页机制是可以关闭的。这时候线性地址就等于物理地址,这就是我们一直在讨论的。在下一篇文章中,我们将打开x86中的分页机制,并与Linux中的分段和分页机制进行比较。实模式:bootloader计算程序段基地址在上一篇文章:从零开始学习Linux06:16结构图,彻底理解【代码重定位】的底层原理,我们讨论了bootloader如何读取应用程序to在内存中,最后跳转到程序的入口地址。这里所说的程序可以是操作系统,也可以是应用程序。下图是程序加载到内存后的header中的信息:因为程序是由bootloader动态读入内存的,所以不知道放在内存的哪个位置,所以不知道start自己的代码段、数据段、栈的地址。但是,程序要想能够正常执行,就必须要知道这些信息,那我们该怎么办呢?只有bootloader可以解决这个问题,因为它是将程序从硬盘加载到内存中。因此,bootloader在跳转到程序入口地址之前,必须先计算出代码段、数据段、堆栈段的基地址,然后写入程序头,如下图所示:这种情况下程序开始执行,可以从自己的header中获取这3个段基地址,赋值给相应的寄存器,这样程序就可以顺利执行了。也就是说:程序的头空间作为bootloader与之交互的媒介,用来传递三个段寄存器的基地址。上面的进程一直工作在实模式下,所以不存在段描述符这个东西。在以后的文章中,我们还会看到在保护模式下,bootloader仍然会使用OS的头空间来传递段的索引号。然后操作系统使用这个段索引号来查找GDT表,找到每个段的基地址和一些其他的保护信息。保护模式:引导加载程序为自己创建一个段描述符。bootloader从BIOS接管系统后,一开始以实模式运行。当它完成一些准备后,就可以进入保护模式,即将CR0寄存器的bit0设置为1。在这个准备中,最重要的是建立GDT表,并将GDT的起始地址存放在寄存器GDTR中.下图是bootloader加载到内存的布局:bootloader加载到地址0x0000_7C00。它至少需要创建3个段描述符:代码段、数据段和堆栈段。确定GDT的地址在创建段描述符之前,需要确定:将GDT表放在内存中的什么位置?暂时放在地址0x0001_0000,距离零地址64K。根据处理器的要求,第一个条目(称为item或entry,每本书不同)必须是空描述符(index=0)。创建代码段描述符bootloader的代码放在0x0000_7C00开始的地址,长度为512B。根据这些信息,可以构建代码段的描述符:创建数据段描述符bootloader将需要将操作系统或其他应用程序从硬盘读取到内存中,例如:读取到0x0002_0000的位置。那么bootloader必须能够访问这个位置,并在数据段进行读写。为了利用整个4G内存空间,bootloader可以将这4G空间作为一个数据段来定义它的描述符,如下:创建一个栈段描述符理论上,bootloader可以使用内存中的任意空闲空间作为自己的栈.因为栈在入栈操作的时候是往低位地址增长的。因此,很多书籍都会将栈顶的基地址设置为bootloader的起始地址,即地址0x0000_7C00,并将栈的大小限制在4K以内。根据以上信息,可以创建栈段描述符,如下:上述段描述符创建完成后,可以将GDT地址(0x0001_0000)设置到GDTR寄存器中。最后将CR0寄存器的bit0设置为1,正式进入保护模式,在bootloader中执行以下代码。段描述符如何确保对段的安全访问?段寄存器缓存进入保护模式后,虽然对段寄存器内容的解释发生了变化,但每条指令仍然需要使用这些段寄存器:cs、ds、ss等。想象一下:每执行一条指令,都会从逻辑地址中获取段索引号,然后查找GDT表,定位段的基地址。大家都知道程序有一个“局部性”原则,即连续执行的代码集中在一个连续的程序空间中。这个连续的程序空间,它们都在同一个代码段,所以段基地址相同,那么它们都属于GDT中同一个代码段描述符所代表的段空间。如果每条指令都是查表,会影响程序的执行效率。因此,处理器在内部为每个段寄存器都安排了一个缓存。以代码段寄存器cs为例:执行一条指令时,如果与上一条指令中的段索引号不同,则根据新的段索引号在GDT中查找对应的段描述符项。找到后,把这个表项的内容复制到cs寄存器的缓存中。当继续执行后续指令时,如果逻辑地址中的段索引号没有变化,处理器直接从缓存中读取段描述,从而避免了查表操作,提高了系统效率。段寄存器本身的保护当段寄存器在逻辑地址中的索引号发生变化时,会根据新的索引号去GDT查表。当然,这个索引号不能超过GDT的范围。在找到某个描述符条目后,开始进行一系列检查。我们看一下每个段描述符中的8个字节的内容:bit8~bit11定义了当前段的类型。如果:当我们切换代码段空间的时候,不小心弄错了,在GDT中定位到一个数据段描述符入口,那么处理器可以及时发现:“当前段描述符类型是数据段,但是你使用了它作为代码段,禁止,杀无赦!”因此,处理器会拒绝将这个段描述符复制到代码段的缓存中,从而保护代码段寄存器。段边界检查通过第一层的段类型保护后,还要继续检查段边界,这就需要用到逻辑地址中的偏移地址(EIP)。如果偏移量超过描述符中指定的限制,则发生错误。例如:在bootloader的代码段描述符中,最大限制是512B,如果你把EIP设置为0x0000_1000,那肯定是错误的。因为这个地址根本不属于代码段的空间范围。数据段比较有意思,因为我们把数据段描述符的基地址设置为0x0000_0000,而段的边界就是整个4G空间,所以可以对整块内存进行操作。再想一步:代码段也属于这4G空间,所以可以通过数据段改写代码段空间中的指令内容。也就是说:如果要修改代码段的指令,直接通过代码段操作是不行的。因为代码段描述符规定了代码段的内容只能读取和执行,不能写入。这时候可以想办法:把代码段也放在4G空间,那么通过数据段的可写特性,就可以改写代码段中的指令。想想gdb的调试过程,是不是利用了这个原理?