4 意犹未尽
好了,现在来详细讲一下昨天遗留下来的问题。首先来说明一下naskfunc.nas的_load_gdtr。
_load_gdtr: ; void load_gdtr(int limit, int addr); MOV AX, [ESP+4] ; limit MOV [ESP+6], AX LGDT [ESP+6] RET
这个函数用来将指定的段上限(limit)和地址值赋值给名为GDTR的48位寄存器。这是一个很特别的48位寄存器,并不能用我们常用的MOV指令来赋值。给它赋值的时候,唯一的方法就是指定一个内存地址,从指定的地址读取6个字节(也就是48位),然后赋值给GDTR寄存器。完成这一任务的指令,就是LGDT。
该寄存器的低16位(即内存的最初2个字节)是段上限,它等于“GDT的有效字节数 -1”。今后我们还会偶尔用到上限这个词,意思都是表示量的大小,一般为“字节数 -1”。剩下的高32位(即剩余的4个字节),代表GDT的开始地址。
在最初执行这个函数的时候,DWORD[ESP+4]里存放的是段上限,DWORD[ESP+8]里存放的是地址。具体到实际的数值,就是0x0000ffff和0x00270000。把它们按字节写出来的话,就成了[FF FF 00 00 00 00 27 00](要注意低位放在内存地址小的字节里)。为了执行LGDT,笔者希望把它们排列成[FF FF 00 00 27 00]的样子,所以就先用“MOV AX, [ESP+4]”读取最初的0xffff,然后再写到[ESP+6]里。这样,结果就成了[FF FF FF FF 00 00 27 00],如果从[ESP+6]开始读6字节的话,正好是我们想要的结果。
■■■■■
naskfunc.nas的_load_idtr设置IDTR的值,因为IDTR与GDTR结构体基本上是一样的,程序也非常相似。
最后再补充说明一下dsctbl.c里的set_segmdesc函数。这个有些难度,我们仅介绍一些与本书相关的内容。
本次的dsctbl.c节选
struct SEGMENT_DESCRIPTOR { short limit_low, base_low; char base_mid, access_right; char limit_high, base_high; }; void set_segmdesc(struct SEGMENT_DESCRIPTOR *sd, unsigned int limit, int base, int ar) { if (limit > 0xfffff) { ar |= 0x8000; /* G_bit = 1 */ limit /= 0x1000; } sd->limit_low = limit & 0xffff; sd->base_low = base & 0xffff; sd->base_mid = (base >> 16) & 0xff; sd->access_right = ar & 0xff; sd->limit_high = ((limit >> 16) & 0x0f) | ((ar >> 8) & 0xf0); sd->base_high = (base >> 24) & 0xff; return; }
说到底,这个函数是按照CPU的规格要求,将段的信息归结成8个字节写入内存的。这8个字节里到底填入了什么内容呢?昨天已经讲到,有以下3点:
❏ 段的大小
❏ 段的起始地址
❏ 段的管理属性(禁止写入,禁止执行,系统专用等)
为了写入这些信息,我们准备了struct SEGMENT_DESCRIPTOR这样一个结构体。下面我们就来说明这个结构体。
■■■■■
首先看一下段的地址。地址当然是用32位来表示。这个地址在CPU世界的语言里,被称为段的基址。所以这里使用了base这样一个变量名。在这个结构体里base又分为low(2字节), mid(1字节), high(1字节)3段,合起来刚好是32位。所以,这里只要按顺序分别填入相应的数值就行了。虽然有点难懂,但原理很简单。程序中使用了移位运算符和AND运算符往各个字节里填入相应的数值。
为什么要分为3段呢?主要是为了与80286时代的程序兼容。有了这样的规格,80286用的操作系统,也可以不用修改就在386以后的CPU上运行了。
■■■■■
下面再说一下段上限。它表示一个段有多少个字节。可是这里有一个问题,段上限最大是4GB,也就是一个32位的数值,如果直接放进去,这个数值本身就要占用4个字节,再加上基址(base),一共就要8个字节,这就把整个结构体占满了。这样一来,就没有地方保存段的管理属性信息了,这可不行。
因此段上限只能使用20位。这样一来,段上限最大也只能指定到1MB为止。明明有4GB,却只能用其中的1MB,有种又回到了16位时代的错觉,太可悲了。在这里英特尔的叔叔们又想了一个办法,他们在段的属性里设了一个标志位,叫做Gbit。这个标志位是1的时候,limit的单位不解释成字节(byte),而解释成页(page)。页是什么呢?在电脑的CPU里,1页是指4KB。
这样一来,4KB × 1M = 4GB,所以可以指定4GB的段。总算能放心了。顺便说一句,G bit的“G”,是“granularity”的缩写,是指单位的大小。
这20位的段上限分别写到limit_low和limit_high里。看起来它们好像是总共有3字节,即24位,但实际上我们接着要把段属性写入limit_high的高4位里,所以最后段上限还是只有20,好复杂呀。
■■■■■
最后再来讲一下12位的段属性。段属性又称为“段的访问权属性”,在程序中用变量名access_right或ar来表示。因为12位段属性中的高4位放在limit_high的高4位里,所以程序里有意把ar当作如下的16位构成来处理:
xxxx0000xxxxxxxx(其中x是0或1)
ar的高4位被称为“扩展访问权”。为什么这么说呢?因为这高4位的访问属性在80286的时代还不存在,到386以后才可以使用。这4位是由“GD00”构成的,其中G是指刚才所说的G bit, D是指段的模式,1是指32位模式,0是指16位模式。这里出现的16位模式主要只用于运行80286的程序,不能用于调用BIOS。所以,除了运行80286程序以外,通常都使用D=1的模式。
■■■■■
ar的低8位从80286时代就已经有了,如果要详细说明的话,够我们说一天的了,所以这里只是简单地介绍一下。
00000000(0x00):未使用的记录表(descriptor table)。 10010010(0x92):系统专用,可读写的段。不可执行。 10011010(0x9a):系统专用,可执行的段。可读不可写。 11110010(0xf2):应用程序用,可读写的段。不可执行。 11111010(0xfa):应用程序用,可执行的段。可读不可写。
“系统专用”,“应用程序用”什么的,听着让人摸不着头脑。都是些什么东西呀?在32位模式下,CPU有系统模式(也称为“ring0”)和应用模式(也称为“ring3”)之分。操作系统等“管理用”的程序,和应用程序等“被管理”的程序,运行时的模式是不同的。
比如,如果在应用模式下试图执行LGDT等指令的话,CPU则对该指令不予执行,并马上告诉操作系统说“那个应用程序居然想要执行LGDT,有问题!”。另外,当应用程序想要使用系统专用的段时,CPU也会中断执行,并马上向操作系统报告“那个应用程序想要盗取系统信息。也有可能不仅要盗取信息,还要写点东西来破坏系统呢。”
“想要盗取系统信息这一点我明白,但要阻止LGDT的执行这一点,我还是不懂。”可能有人会有这种疑问。当然要阻止啦。因为如果允许应用程序执行LGDT,那应用程序就会根据自己的需要,偷偷准备GDT,然后重新设定LGDT来让它执行自己准备的GDT。这可就麻烦了。有了这个漏洞,操作系统再怎么防守还是会防不胜防。
CPU到底是处于系统模式还是应用模式,取决于执行中的应用程序是位于访问权为0x9a的段,还是位于访问权为0xfa的段。