在8086/8088时代,处理器只存在一种操作模式(Operation Mode),当时由于不存在其它操作模式,因此这种模式也没有被命名。自从80286到80386开始,处理器增加了另外两种操作模式——保护模式PM(Protected Mode)和系统管理模式SMM(System Management Mode),因此,8086/8088的模式被命名为实地址模式RM(Real-address Mode)。
PM是处理器的native模式,在这种模式下,处理器支持所有的指令和所有的体系结构特性,提供最高的性能和兼容性。对于所有的新型应用程序和操作系统来说,建议都使用这种模式。为了保证PM的兼容性,处理器允许在受保护的,多任务的环境下执行RM程序。这个特性被称做虚拟8086模式(Virtual-8086 Mode),尽管它并不是一个真正的处理器模式。Virtual-8086模式实际上是一个PM的属性,任何任务都可以使用它。
RM提供了Intel 8086处理器的编程环境,另外有一些扩展(比如切换到PM或SMM的能力)。当主机被Power-up或Reset后,处理器处于RM下。
SMM是一个对所有Intel处理器都统一的标准体系结构特性。出现于Intel386 SL芯片。这个模式为OS实现平台指定的功能(比如电源管理或系统安全)提供了一种透明的机制。当外部的SMM interrupt pin(SMI#)被激活或者从APIC(Advanced Programming Interrupt Controller)收到一个SMI,处理器将进入SMM。在SMM下,当保存当前正在运行程序的整个上下文(Context)时,处理器切换到一个分离的地址空间。然后SMM指定的代码或许被透明的执行。当从SMM返回时,处理器将回到被系统管理中断之前的状态。
由于机器在Power-up或Reset之后,处理器处于RM状态,而对于Intel 80386以及其后的芯片,只有使用PM才能发挥出最大的作用。所以我们就面临着一个从RM切换到PM的问题。
本文不讨论SMM,本节的重点集中于在Booting阶段如何从RM切换到PM,这里不会过多的讨论PM的细节,因为《Intel Architecture Software Developer’s Manual Volume 3: System Programming》中有非常详尽和准确的介绍。
1. What is GDT
在Protected Mode下,一个重要的必不可少的数据结构就是GDT(Global Descriptor Table)。
为什么要有GDT?我们首先考虑一下在Real Mode下的编程模型:
在Real Mode下,我们对一个内存地址的访问是通过Segment:Offset的方式来进行的,其中Segment是一个段的Base Address,一个Segment的最大长度是64 KB,这是16-bit系统所能表示的最大长度。而Offset则是相对于此Segment Base Address的偏移量。Base Address+Offset就是一个内存绝对地址。由此,我们可以看出,一个段具备两个因素:Base Address和Limit(段的最大长度),而对一个内存地址的访问,则是需要指出:使用哪个段?以及相对于这个段Base Address的Offset,这个Offset应该小于此段的Limit。当然对于16-bit系统,Limit不要指定,默认为最大长度64KB,而16-bit的Offset也永远不可能大于此Limit。我们在实际编程的时候,使用16-bit段寄存器CS(Code Segment),DS(Data Segment),SS(Stack Segment)来指定Segment,CPU将段积存器中的数值向左偏移4-bit,放到20-bit的地址线上就成为20-bit的Base Address。
到了Protected Mode,内存的管理模式分为两种,段模式和页模式,其中页模式也是基于段模式的。也就是说,Protected Mode的内存管理模式事实上是:纯段模式和段页式。进一步说,段模式是必不可少的,而页模式则是可选的——如果使用页模式,则是段页式;否则这是纯段模式。
既然是这样,我们就先不去考虑页模式。对于段模式来讲,访问一个内存地址仍然使用Segment:Offset的方式,这是很自然的。由于Protected Mode运行在32-bit系统上,那么Segment的两个因素:Base Address和Limit也都是32位的。IA-32允许将一个段的Base Address设为32-bit所能表示的任何值(Limit则可以被设为32-bit所能表示的,以2^12为倍数的任何指),而不象Real Mode下,一个段的Base Address只能是16的倍数(因为其低4-bit是通过左移运算得来的,只能为0,从而达到使用16-bit段寄存器表示20-bit Base Address的目的),而一个段的Limit只能为固定值64 KB。另外,Protected Mode,顾名思义,又为段模式提供了保护机制,也就说一个段的描述符需要规定对自身的访问权限(Access)。所以,在Protected Mode下,对一个段的描述则包括3方面因素:[Base Address, Limit, Access],它们加在一起被放在一个64-bit长的数据结构中,被称为段描述符。这种情况下,如果我们直接通过一个64-bit段描述符来引用一个段的时候,就必须使用一个64-bit长的段积存器装入这个段描述符。但Intel为了保持向后兼容,将段积存器仍然规定为16-bit(尽管每个段积存器事实上有一个64-bit长的不可见部分,但对于程序员来说,段积存器就是16-bit的),那么很明显,我们无法通过16-bit长度的段积存器来直接引用64-bit的段描述符。
怎么办?解决的方法就是把这些长度为64-bit的段描述符放入一个数组中,而将段寄存器中的值作为下标索引来间接引用(事实上,是将段寄存器中的高13-bit的内容作为索引)。这个全局的数组就是GDT。事实上,在GDT中存放的不仅仅是段描述符,还有其它描述符,它们都是64-bit长,我们随后再讨论。
GDT可以被放在内存的任何位置,那么当程序员通过段寄存器来引用一个段描述符时,CPU必须知道GDT的入口,也就是基地址放在哪里,所以Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存中某个位置之后,可以通过LGDT指令将GDT的入口地址装入此积存器,从此以后,CPU就根据此积存器中的内容作为GDT的入口来访问GDT了。
GDT是Protected Mode所必须的数据结构,也是唯一的——不应该,也不可能有多个。另外,正象它的名字(Global Descriptor Table)所揭示的,它是全局可见的,对任何一个任务而言都是这样。
除了GDT之外,IA-32还允许程序员构建与GDT类似的数据结构,它们被称作LDT(Local Descriptor Table),但与GDT不同的是,LDT在系统中可以存在多个,并且从LDT的名字可以得知,LDT不是全局可见的,它们只对引用它们的任务可见,每个任务最多可以拥有一个LDT。另外,每一个LDT自身作为一个段存在,它们的段描述符被放在GDT中。
IA-32为LDT的入口地址也提供了一个寄存器LDTR,因为在任何时刻只能有一个任务在运行,所以LDT寄存器全局也只需要有一个。如果一个任务拥有自身的LDT,那么当它需要引用自身的LDT时,它需要通过LLDT将其LDT的段描述符装入此寄存器。LLDT指令与LGDT指令不同的时,LGDT指令的操作数是一个32-bit的内存地址,这个内存地址处存放的是一个32-bit GDT的入口地址,以及16-bit的GDT Limit。而LLDT指令的操作数是一个16-bit的选择子,这个选择子主要内容是:被装入的LDT的段描述符在GDT中的索引值——这一点和刚才所讨论的通过段积存器引用段的模式是一样的。
LDT只是一个可选的数据结构,你完全可以不用它。使用它或许可以带来一些方便性,但同时也带来复杂性,如果你想让你的OS内核保持简洁性,以及可移植性,则最好不要使用它。
引用GDT和LDT中的段描述符所描述的段,是通过一个16-bit的数据结构来实现的,这个数据结构叫做Segment Selector——段选择子。它的高13位作为被引用的段描述符在GDT/LDT中的下标索引,bit 2用来指定被引用段描述符被放在GDT中还是到LDT中,bit 0和bit 1是RPL——请求特权等级,被用来做保护目的,我们这里不详细讨论它。
前面所讨论的装入段寄存器中作为GDT/LDT索引的就是Segment Selector,当需要引用一个内存地址时,使用的仍然是Segment:Offset模式,具体操作是:在相应的段寄存器装入Segment Selector,按照这个Segment Selector可以到GDT或LDT中找到相应的Segment Descriptor,这个Segment Descriptor中记录了此段的Base Address,然后加上Offset,就得到了最后的内存地址。如下图所示:
2. Setup GDT
由上一节的讨论得知,GDT是Protected Mode所必须的数据结构,那么我们在进入Protected Mode之前,必须设定好GDT,并通过LGDT将其装入相应的寄存器。
尽管GDT允许被放在内存的任何位置,但由于GDT中的元素——描述符——都是64-bit长,也就是说都是8个字节,所以为了让CPU对GDT的访问速度达到最快,我们应该将GDT的入口地址放在以8个字节对齐,也就是说是8的倍数的地址位置。
GDT中第一个描述符必须是一个空描述符,也就是它的内容应该全部为0。如果引用这个描述符进行内存访问,则是产生General Protection异常。
如果一个OS不使用虚拟内存,段模式会是一个不错的选择。但现代OS没有不使用虚拟内存的,而实现虚拟内存的比较方便和有效的内存管理方式是页式管理。但是在IA-32上如果我们想使用页式管理,我们只能使用段页式——没有方法可以完全禁止段模式。但我们可以尽力让段的效果降低的最小。
IA-32提供了一种被称作“Basic Flat Model”的分段模式可以达到这种效果。这种模式要求在GDT中至少要定义两个段描述符,一个用来引用Data Segment,另一个用来引用Code Segment。这2个Segment都包含整个线性空间,即Segment Limit = 4 GB,即使实际的物理内存远没有那么多,但这个空间定义是为了将来由页式管理来实现虚拟内存。
在这里,我们只是处于Booting阶段,所以我们只需要初步设置一下GDT,等真正进入Protected Mode,启动了OS Kernel之后,具体OS打算如何设置GDT,使用何种内存管理模式,由Kernel自身来设置,Booting只需要给Kernel的数据段和代码段设置全部线性空间就可以了。
段描述符的格式如下图所示:
具体到代码段和数据段,它们的格式如下图所示:
下面就是在Booting阶段为进入Protected Mode而设置的临时的gdt。这里定义了3个段描述符:第一个是系统规定的空描述符,第2个是引用4 GB线性空间的代码段,第3个是引用4 GB线性空间的数据段。这是"Basic Flat Model"所要求的最下GDT设置,但就booting阶段,只是为了进入Protected Mode,并为内核提供一个连续的,最大的线性空间这个目的而言,已经足够了。
# Descriptor tables
gdt:
.word 0, 0, 0, 0 # dummy
.word 0xFFFF # 4Gb - (0x100000*0x1000 = 4Gb)
.word 0 # base address = 0
.word 0x9A00 # code read/exec
.word 0x00CF # granularity = 4096, 386
# (+5th nibble of limit)
.word 0xFFFF # 4Gb - (0x100000*0x1000 = 4Gb)
.word 0 # base address = 0
.word 0x9200 # data read/write
.word 0x00CF # granularity = 4096, 386
# (+5th nibble of limit)
3. Load GDT
设置好GDT之后,我们需要通过LGDT指令将设定的gdt的入口地址和gdt表的大小装入GDTR寄存器。
GDTR寄存器包括两部分:32-bit的线性基地址,以及16-bit的GDT大小(以字节为单位)。需要注意的是,对于32-bit线性基地址,必须是32-bit绝对物理地址,而不是相对于某个段的偏移量。而我们在Booting阶段,在进入Protected Mode之前,我们CS和DS设置很可能不是0,所以我们必须计算出gdt的绝对物理地址。
为了执行LGDT指令,你需要把这两部分内容放在内存的某个位置,然后将这个位置的内存地址作为操作数传递给LGDT指令。然后LGDT指令会自动将保存在这个位置的这两部分值装入GDTR寄存器。
# 这是存放GDTR所需的两部分内容的位置
gdt_48:
.word 0x8000 # gdt limit=2048,
# 256 GDT entries
.word 0, 0 # gdt base (filled in later)
# 下面这段代码用来计算GDT的32-bit线性地址,并将其装入GDTR寄存器。
xorl %eax, %eax # Compute gdt_base
movw %ds, %ax # (Convert %ds:gdt to a linear ptr)
shll $4, %eax
addl $gdt, %eax
movl %eax, (gdt_48+2)
lgdt gdt_48 # load gdt with whatever is appropriate
4. Other Preparing Stuff
在进入Protected Mode之前,除了需要设置和装入GDT之外,还需要做如下一些事情:
屏蔽所有可屏蔽中断;
装入IDTR;
所有协处理器被正确的Reset。
由于在Real Mode和Protected Mode下的中断处理机制有一些不同,所以在进入Protected Mode之前,务必禁止所有可屏蔽中断,这可以通过下面两种方法之一:
使用CLI指令;
对8259A可编程中断控制器编程以屏蔽所有中断。
即使当我们进入Protected Mode之后,也不能马上将中断打开,这时因为我们必须在OS Kernel中对相关的Protected Mode中断处理所需的数据结构正确的初始化之后,才能打开中断,否则会产生处理器异常。
在Real Mode下,中断处理使用IVT(Interrupt Vector Table),在Protected Mode下,中断处理使用IDT(Interrupt Descriptor Table),所以,我们必须在进入Protected Mode之前设置IDTR。
IDTR的格式和GDTR相同,IDTR的装入方式和GDTR也相同。由于IDT中相关的中断处理程序需要让OS Kernel来设定,所以在Booting阶段,我们只需要将IDTR中IDT的基地址和Size都设为0就可以了,随后,等进入Protected Mode之后,由OS Kernel来真正设置它。
关于中断机制和中断处理,请参考 Interrupt & Exception ,这里不再赘述。
#
# 这是存放IDTR所需的两部分内容的位置
#
idt_48:
.word 0 # idt limit = 0
.word 0, 0 # idt base = 0L
# 对于IDTR的处理,只需要这一条指令即可
lidt idt_48 # load idt with 0,0
#
# 通过设置8259A PIC,屏蔽所有可屏蔽中断
#
movb $0xFF, %al # mask all interrupts for now
outb %al, $0xA1
call delay
movb $0xFB, %al # mask all irq's but irq2 which
outb %al, $0x21 # is cascaded
# 保证所有的协处理都被正确的Reset
xorw %ax, %ax
outb %al, $0xf0
call delay
outb %al, $0xf1
call delay
# Delay is needed after doing I/O
delay:
outb %al,$0x80
ret
5. Let's Go
好了,一切准备就绪,Fire!:)
进入Protected Mode,还是进入Real Mode,完全靠CR0寄存器的PE标志位来控制:如果PE=1,则CPU切换到PM,否则,则进入RM。
设置CR0-PE位的方法有两种:
第一种是80286所使用的LMSW指令,后来的80386及更高型号的CPU为了保持向后兼容,都保留了这个指令。这个指令只能影响最低的4 bit,即PE,MP,EM和TS,对其它的没有影响。
#
#通过LMSW指令进入Protected Mode
#
movw $1, %ax # protected mode (PE) bit
lmsw %ax # This is it!
第二种是Intel所建议的在80386以后的CPU上使用的进入PM的方式,即通过MOV指令。MOV指令可以设置CR0寄存器的所有域的值。
#
#通过MOV指令进入Protected Mode
#
movl %cr0, %eax
xorb $1, %al # set PE = 1
movl %eax, %cr0 # go!!
OK,现在已经进入Protected Mode了。
很简单,right?But It's not over yet!
6. Start Kernel
我们已经从Real Mode进入Protected Mode,现在我们马上就要启动OS Kernel了。
OS Kernel运行在32-bit段模式,而当前我们却仍然处于16-bit段模式。这是怎么回事?为了了解这个问题,我们需要仔细探讨一下IA-32的段模式的实现方法。
IA-32共提供了6个16-bit段寄存器:CS,DS,SS,ES,FS,GS。但事实上,这16-bit只是对程序员可见的部分,但每个寄存器仍然包括64-bit的不可见部分。
可见部分是为了供程序员装载段寄存器,但一旦装载完成,CPU真正使用的就只是不可见部分,可见部分就完全没有用了。
不可见部分存放的内容是什么?具体格式我没有看到相关资料,但可以确定的是隐藏部分的内容和段描述符的内容是一致的(请参考段描述的格式),只不过格式可能不完全相同。但格式对我们理解这一点并不重要,因为程序员不可能能够直接操作它。
我们以CS寄存器为例,对于其它寄存器也是一样的:
在Real Mode下,当我们执行一个装载CS寄存器的指令的时候(jmp,call,ret等),相关的值会被装入CS寄存器的可见部分,但同时CPU也会根据可见部分的内容来设置不可见部分。比如我们执行"ljmp $0x1234, $go "之后,CS寄存器的可见部分的内容就是1234h,同时,不可见部分的32-bit Base Address域被设置为00001234h,20-bit的Limit域被设置为固定值10000h,也就是64 KB,Access Information部分的其它值我们不去考虑,只考虑其D/B位,由于执行此指令时处于Real Mode模式,所以D/B被设置为0,表示此段是一个16-bit段。当对CS寄存器的可见部分和不可见部分的内容都被设置之后,CS寄存器的装载工作完成。随后当CPU需要通过CS的内容进行地址运算的时候,则仅仅引用不可见部分。
在Protected Mode下,当我们执行一个装载CS寄存器的指令的时候,段选择子(Segment Selector)被装入CS寄存器的可见部分,同时CPU根据此选择子到相应的描述符表中(GDT或LDT)找到相应的段描述符并将其内容装载入CS寄存器的不可见部分。随后CPU当需要通过CS的内容进行地址运算的时候,也仅仅引用不可见部分。
从上面的描述可以看出,事实上CPU在引用段寄存器的内容进行地址运算时,Real Mode和Protected Mode是一致的。另外,也明白了为什么我们在Real Mode下设置的段寄存器的内容到了Protected Mode下仍然引用的是16-bit段。
那么我们如何将CS设置为引用32-bit段?方法就像我们前面所讨论的,使用jmp或call指令,引用一个段选择子,到GDT中装载一个引用32-bit段的段描述符。
需要注意的是,如果CS寄存器的内容指出当前是一个16-bit段,那么当前的地址模式也就是16-bit地址模式,这与你当前是出于Real Mode还是Protected Mode无关。而我们装载32-bit段的jmp指令或call指令必须使用的是32-bit地址模式。而我们当前的boot部分代码是16-bit代码,所以我们必须在此jmp/call指令前加上地址转换前缀代码66h。
下面的例子就是使用jmp指令装入32-bit段。Jmpi指令的含义是段间跳转,其Opcode为Eah,其格式为:jmpi Offset, Segment Selector。
# 由于当前的代码是16-bit代码,而我们要执行32-bit地址模式的指令,指令前
# 需要有地址模式切换前缀66h,如果我们直接写jmp指令,由编译器来生成代码
# 的话,是无法作到这一点的,所以我们直接写相关数据。
.byte 0x66, 0xea # prefix + jmpi-opcode
.long 0x1000 # Offset
.word __KERNEL_CS # CS segment selector
上面的代码相当于32-bit指令:
jmpi 0x1000,__KERNEL_CS
如果__KERNEL_CS段选择子所引用的段描述符设置的段空间为线形地址[0,4 GB],而我们将OS Kernel放在物理地址1000h,那么此jmpi指令就跳转到OS Kernel的入口处,并开始执行它。
此时,Booting阶段结束,OS正式开始运行!