GDT是X86上操作系统的一个最基础的问题。这个文章只在介绍GDT的基本知识。并没有任何一个RayCommand版本对应这一段东西。因为实在是太基础了,我也不想单独拿这个作为一个Milestone。但是,下文中介绍的任何实现,均在RayCommand的最新版本中/kernel/driver/x86arch/GDT中,有对应的实现。本文主体翻译自这里。但是有一些自己的改变。如果想看原文,请参考英文版。
在Intel X86架构上,有很多保护内存访问的方法,使得用户程序禁止访问内核程序的内存,或者其他程序的内存。其中一个重要的方法是使用全局描述符表(Global Descriptor Table), 也就是GDT。GDT定义了某一段特定内存的权限。我们可以使用GDT中的一个字段,定义某一段内存不能被出了内核程序之外的程序访问。现代操作系统使用的是"分页"技术实现这一点。使用分页技术会具有更大的灵活性。GDT基本上可以说是段式的内存,但是X86平台中,必须设置GDT,也算是一个历史遗留的问题。在GDT中还可以设置"任务状态段"(Task State Segments, Tss)。这个TSS段可以进行硬件的任务切换,但是不再本篇文章的讨论范围中。并且,TSS也不是唯一的多任务的方法。
值得注意的是Grub在加载系统的时候,已经载入了默认的GDT.但是,如果你对GRUB GDT的内存区域进行复写,会导致GDT的失效,引发一个'triple fault'异常。如果要解决这个问题,我们需要自己建立GDT,并将GDT放到一个可控的不会复写的内存中。再载入自己的GDT。载入后,再将cs,ds,es等段寄存器,设置成GDT中对应字段的偏移。例如,cs中为代码段的偏移。如果GDT中,描述代码段的内存属性,位于第0x10处偏移的话,则将cs设置成0x10.(抑或,某些书上翻译的叫做段选择子,但我实际上觉得,这个东西只是简单的偏移而已,说的那么复杂会让他人产生困惑)
GDT本身是一个数组。它内部的每一个元素都是一个64位长的字段(原文为Entry,但是我觉得字段的意思更明确,当然实际上字段应该是Field,Entry应该是入口点)。每一个字段设置了一段内存的属性,权限等等。一个通常的规范是,GDT的第0个字段,应该是个NULL字段,也就是全为0的字段。没有任何一个CS,ES这样的段寄存器应该设置为0。由于GDT设置了权限,在越权访问的情况下,CPU会产生一个"General Protection"异常。
GDT中的每一个字段,同时说明了这段内存运行在什么状态下。究竟是运行在内核空间(Ring 0)还是用户空间(Ring 3)。当然,X86系统还有其他的Ring,但是那些大多数情况都不会用到。在Ring 3中,程序被限制只能执行一些基础的命令。例如在用户状态下,就不可以关中断。这实际上是对操作系统内核程序的一种保护。
在每一个GDT的字段中,均有一些基地址,偏移地址,和一些属性为,他们不是依次排列的,其内存中状态如下图所示。
在每一个GDT的字段中,有几位说明了他的权限和访问情况。如下图所示。
上图中,每一个位代表的意思如下。
- Pr: Present Bit,当前是否在内存中的标志位。对于任何一个有效的代码段,都必须是1.
- Privl: Privilege, 特权位,两位。标志着这段的Ring等级。最高级为Ring0(内核状态),最低级为Ring3(用户状态)。
- Ex: Executable,是否可执行位。如果为1,则该段内存为可执行的代码,即代码段。否则为数据段。
- DC: Direction bit/Conforming bit, 方向或一致性位。
- 如果该段为代码段,则位表示方向,如果是0,则该段是向上增长的,反之则是向下增长的。换句话说,向下增长意味着偏移地址要大于基地址。
- 如果该段为数据段,则该位表示一致性。即地位的代码段是否能够方位该数据段。
- RW: Readable/Writeable位。如果该段为1,则对应代码段的话为可读,对应的数据段可写。注意的是,代码段永远是不可写的,数据段永远是可读的。
- Ac:Accessed bit. 当CPU访问过的时候,设置这位为1,当然,初始化的时候,我们需要将这位设置为0.
- Gr:Granularity bit.粒度位。如果是0,则表示在这个GDT中,任何地址单位都为Byte。如果是1,则表示其单位为4KB
- Sz:Operand Size bit. 如果为0,该段为16位的段,也就是IP每次会取16位指令。为1,则为32位的段。
下面是一些示例代码,在操作系统中载入三个GDT字段。为什么是3个呢?和开始说的一样,第0个为NULL的字段,再加上一个数据段一个代码段正好三段。当我们准备好这三个字段组成的数组后,我们需要一个新的数据结构去加载它,这个数据结构叫做GDT的指针,是个48位的地址,里面包括GDT的内存地址和GDT的长度。
在GDT.c中,我们定义了GDT的一些数据结构和数据。
/* Defines a GDT entry. We say packed, because it prevents the
* compiler from doing things that it thinks is best: Prevent
* compiler "optimization" by packing */
struct gdt_entry
{
unsigned short limit_low;
unsigned short base_low;
unsigned char base_middle;
unsigned char access;
unsigned char granularity;
unsigned char base_high;
} __attribute__((packed));
/* Special pointer which includes the limit: The max bytes
* taken up by the GDT, minus 1. Again, this NEEDS to be packed */
struct gdt_ptr
{
unsigned short limit;
unsigned int base;
} __attribute__((packed));
/* Our GDT, with 3 entries, and finally our special GDT pointer */
struct gdt_entry gdt[3];
struct gdt_ptr gp;
/* This will be a function in start.asm. We use this to properly
* reload the new segment registers */
extern void gdt_flush();
/* Setup a descriptor in the Global Descriptor Table */
void gdt_set_gate(int num, unsigned long base, unsigned long limit, unsigned char access, unsigned char gran)
{
/* Setup the descriptor base address */
gdt[num].base_low = (base & 0xFFFF);
gdt[num].base_middle = (base >> 16) & 0xFF;
gdt[num].base_high = (base >> 24) & 0xFF;
/* Setup the descriptor limits */
gdt[num].limit_low = (limit & 0xFFFF);
gdt[num].granularity = ((limit >> 16) & 0x0F);
/* Finally, set up the granularity and access flags */
gdt[num].granularity |= (gran & 0xF0);
gdt[num].access = access;
}
/* Should be called by main. This will setup the special GDT
* pointer, set up the first 3 entries in our GDT, and then
* finally call gdt_flush() in our assembler file in order
* to tell the processor where the new GDT is and update the
* new segment registers */
void gdt_install()
{
/* Setup the GDT pointer and limit */
gp.limit = (sizeof(struct gdt_entry) * 3) - 1;
gp.base = &gdt;
/* Our NULL descriptor */
gdt_set_gate(0, 0, 0, 0, 0);
/* The second entry is our Code Segment. The base address
* is 0, the limit is 4GBytes, it uses 4KByte granularity,
* uses 32-bit opcodes, and is a Code Segment descriptor.
* Please check the table above in the tutorial in order
* to see exactly what each value means */
gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF);
/* The third entry is our Data Segment. It's EXACTLY the
* same as our code segment, but the descriptor type in
* this entry's access byte says it's a Data Segment */
gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF);
/* Flush out the old GDT and install the new changes! */
gdt_flush();
}
在上述代码中,GDTInstall为安装GDT的过程,每个gdt_set_gate是设置GDT中每个字段的函数。具体的设置方法是根据GDT
; This will set up our new segment registers. We need to do
; something special in order to set CS. We do what is called a
; far jump. A jump that includes a segment as well as an offset.
; This is declared in C as 'extern void gdt_flush();'
global _gdt_flush ; Allows the C code to link to this
extern _gp ; Says that '_gp' is in another file
_gdt_flush:
lgdt [_gp] ; Load the GDT with our '_gp' which is a special pointer
mov ax, 0x10 ; 0x10 is the offset in the GDT to our data segment
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
jmp 0x08:flush2 ; 0x08 is the offset to our code segment: Far jump!
flush2:
ret ; Returns back to the C code!
的数据结构设置。最开始设置了一个NULL字段,之后设置的是代码段,最后设置的是数据段。细心的朋友会发现,GDTFlush并没有定义,这个函数的目的,是将新设置好的GDT加载给CPU。这段函数使用汇编进行书写。单独文件GDT_ASM.S,汇编器为NASM。代码如下:
由此,我们加载完了新的GDT,在汇编中,将数据段(0x10,因为是GDT中第三个字段,每个字段长64bit,也就是长0x08。第三个为0x08*(3-2) = 0x10 )设置给了ds,es等等。又使用jmp,将代码段0x08设置给了cs。
现在,只要在Main函数中调用GDTInstall,即可完成GDT的设置。