购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.2.4 进入保护模式

进入保护模式,GDT是必需的,而页式管理则不是。分页机制可以通过cr0寄存器的最高位,也就是PG位来控制。本节的实验将实现进入保护模式,并通过保护模式访问在显存中显示白色的字母B,以验证代码的正确性。为了使代码尽量简单,这里先不开启分页,实验的步骤包括:

1)正确地设置GDT。

2)打开A20地址线。

3)设置cr0的PE位。

4)跳转进入保护模式。

5)打印白色的字母B。

前4个步骤都可以在setup.S中实现,因为setup.S是以16位模式进行编译的,所以前4个步骤就可以放在setup模块。第5个步骤的代码就应该以32位模式进行编译,所以它会被安排在一个新的文件中,这个文件可以看作操作系统内核的真正开始,所以被命名为head.S。

head.S应该被独立地编译成新的模块并且由build工具把它拼接在setup模块之后,然后再由bootloader加载进内存的特定位置,由setup模块的最后一条跳转语句转移到head中执行。所以这里先把它准备好。新建kernel目录,并在这个目录下新建head.S文件,它的内容如代码清单2-5所示。

代码清单2-5 在保护模式打印字母(head.S)

1  .code32

2  .text

3  .globl startup_32

4  startup_32:

5   movl $0x18, %eax

6   movw %ax,%gs

7   movl $0x0,%edi

8   movb $0xf,%ah

9   movb $0x42,%al

10   movw %ax,%gs:(%edi)

11

12   movl $0x10, %eax

13   movw %ax,%ds

14   movw %ax,%es

15   movw %ax,%fs

16

17 loop:

18   jmploop

第1行指明head.S应该以32位模式进行编译。第3行导出startup_32这个符号供链接器使用。第5行和第6行使用0x18来指示gs寄存器中的段选择子是GDT中的第三项,且特权级为0。CPU加载以后就一直在特权级0上运行。到现在为止,特权级的概念尚未完全说明,而且也从未切换过特权级,所以这里可以先忽略。本节的实验将会把GDT的第三项的基地址设为0xb8000,这是显存地址,所以第10行就是把一个白色的字母B显示到屏幕的最左上角。

第12~15行是把ds、es、fs寄存器的值设为0x10,它代表GDT中的第二项,它指向数据段。如果对于0x18和0x10还有疑惑的话,也不必急于现在搞清楚,等这一节的最后将GDT设置完成了,再回过头来看这两个段选择子就会比较清楚。学习的时候,适当地囫囵吞枣,遇到不懂的地方先跳过去,也许等到后面就会发现,前边的这些问题不攻自破了。

在kernel目录里同时新建makefile文件以构建内核的二进制文件,如代码清单2-6所示。

代码清单2-6 新建makefile文件以构建内核

1  GCC:=gcc

2  CCFLAG:=-mcpu=i386-I../include-nostdinc-Wall-fomit-frame-pointer-c

3  LDFLAG:=-Ttext 0x0-s--oformat binary

4  OBJS  :=head.o

5

6  system: $(OBJS)

7   $(LD) $(LDFLAG)-e startup_32-o$@ $^

8

9  head.o:head.S

10   $(GCC)-traditional-c-o$@ $<

11

12 clean:

13   rm *.o

14   rm system

注意,GCC的编译选项要指明CPU指令架构是i386,并且需要使用nostdinc明确地告诉编译器,而不要使用标准C语言的运行时。要知道,操作系统的环境是完全“干净”的,没有任何的C语言内建函数可以用,所以必须指定这个选项来产生不依赖任何C语言运行时的二进制文件。

通过这个makefile文件就可以编译出一个名为system的二进制文件。在工程的根目录下的makefile文件中,要把kernel中的makefile关联起来,如代码清单2-7所示。

代码清单2-7 节选自根目录下的makefile

1  ...

2  image:linux.img

3

4  linux.img:tools/build bootsect setup kernel/system

5   ./tools/build bootsect setup kernel/system>$@

6

7  tools/build:tools/build.c

8   gcc-o$@ $<

9

10 kernel/system:

11   cd kernel;make system;cd..

12 ...

第4行的规则,使用build工具将bootsect、setup模块和kernel/system三部分简单地拼接在一起。第10行的规则调用shell命令进入kernel目录构建system模块。

接下来就要修改bootsect,将system加载进内存的0x10000位置。选择这个位置的原因是低地址的内存被BIOS中断服务程序占用了,在实模式下,操作系统要依赖BIOS做很多事情,所以在进入保护模式之前,低于0x10000的地址是不能被覆写的。

在Linux 0.11的时代,因为Linus假设操作系统内核不会超过512KB(0x80000),所以就把bootsect和setup放到了0x90000,把操作系统内核(也就是system模块)放在了0x10000的位置。这一部分代码主要是通过BIOS中断来实现的,逻辑很简单,但代码比较冗长,所以本书就不再列出它的源码了,如果读者感兴趣的话,可以直接阅读最终版本的bootsect.S代码。它不仅包括了使用软盘中断加载内核,清除屏幕等操作,还有一些控制软盘驱动的代码,用于及时地关闭软盘驱动马达,如果不这么做,仿真软件的软盘驱动将会一直亮着。

bootsect将system模块加载进内存以后,setup模块还要再把它搬到内存地址0的位置。因为进入保护模式以后,BIOS所使用的数据就全部失效了,所以它所占用的低地址处的内存就可以全部被操作系统使用了,把操作系统内核的代码搬到0的位置是合理的。对setup.S进行修改,如代码清单2-8所示。

代码清单2-8 修改setup.S

1  ...

2  is_disk1:

3   /* 为进入保护模式做准备 */

4   cli

5

6   movw $0x0000,%ax

7   cld

8  do_move:

9   movw %ax,%es

10   addw$0x1000,%ax

11   cmpw$0x9000,%ax

12   jzend_move

13   movw %ax,%ds

14   subw%di,%di

15   subw%si,%si

16   movw $0x8000,%cx

17   rep

18   movsw

19   jmpdo_move

20

21 end_move:

22   movw $0xb800,%ax

23   movw %ax,%gs

24   movb $0xf,%ah

25   movb $0x41,%al

26   movl $0x100,%edi

27   movw %ax,%gs:(%di)

28

29   movw $SETUPSEG,%ax

30   movw %ax,%ds

31   lgdt gdt_48

32

33   call empty_8042

34   movb $0xD1,%al

35   outb%al,$0x64

36   call empty_8042

37   movb $0xDF,%al

38   outb%al,$0x60

39   call empty_8042

40

41   movl%cr0, %eax

42   xorb$1,%al

43   movl %eax,%cr0

44

45   .byte 0x66,0xea

46   .long 0x0

47   .word 0x8

48

49 empty_8042:

50   .word 0x00eb,0x00eb

51   inb$0x64,%al

52   testb$2,%al

53   jnz empty_8042

54   ret

55

56 gdt:

57   .word 0,0,0,0

58

59   .word 0x07ff

60   .word 0x0000

61   .word 0x9A00

62   .word 0x00C0

63

64   .word 0x07ff

65   .word 0x0000

66   .word 0x9200

67   .word 0x00c0

68

69   .word 0xffff

70   .word 0x8000

71   .word 0x920b

72   .word 0x00c0

73 gdt_48:

74   .word 0x800

75   .word 512+gdt,0x9

这段代码比较长,它一共完成了4项工作,接下来逐一进行分析。

1.搬移操作系统内核

这段代码的第7行是cld指令,它的作用是将标志寄存器eflags的方向标志位DF清零。使变址寄存器si或di的地址指针自动增加。

之后的8~19行其实是一个循环,第10行每次会将ax加0x1000,第11行和0x9000进行比较,所以这个循环的迭代次数就是8次。然后就是前面讲过多次的内存复制操作,cx的值为0x8000,意味着movsw这条指令会重复0x8000次,因为每次复制一个word(即两字节),所以每次大的循环会复制0x8000×2B=64KB。第14行和第15行使用减法指令将di、si寄存器清零。es与ds的初始值分别为0x0000和0x1000,每次迭代之后加0x1000。这样就实现了将system模块,从0x10000搬运到0x00000的起始位置的目标。

简单来说,这一段代码的作用就是使用一个执行了8次的循环,从0x10000开始,每次向地址0处复制0x10000个字节。

完成内存移动动作以后,从第22行开始,通过直接往显存里写数据的方式在屏幕上方打印了一个白色的大写字母A。关于字母的颜色和背景色的设置,请参考附录。

2.加载GDT

GDT是在保护模式下用于寻址所依赖的一张表,里面存放的内容是段描述符。之前已经介绍过,GDT是由操作系统设置供CPU使用的,CPU通过查询gdtr寄存器来找到GDT的起始地址。所以操作系统就必须使用lgdt指令将GDT的起始地址加载进gdtr寄存器(第31行)。lgdt指令的参数是48位,前16位是GDT的大小,后32位是GDT的存放地址。第74行表示lgdt参数的前16位,lgdt的值为0x800=2048,而一个描述符的大小是8B,所以这个GDT可以容纳2048/8=256个元素。第75行表示GDT的存放地址,因为符号gdt是在setup.S中定义的,而setup模块又被加载到0x90200这个地址。那么真实的GDT存放位置就是0x90200加符号gdt在setup模块的相对偏移。其中0x90200的地址被拆成了两段,高地址在后,低地址在前,这一点是要加以注意的。

在上面的代码中,GDT里总共定义了4个描述符。第一个描述符全是0,这是CPU预留的,不能使用的,必须全部设为0(第58行)。第二个描述符是代码段的描述符。对照图2-2的结构可知这个描述符的各个段的含义如下。

❑基地址为0。

❑第6字节为0xC0,对应的G位为1,作为段的界限(以4KB为单位)。

❑D/B位为1,代表指令使用32位地址。

❑第5字节为0x9A,对应的P位为1,代表该段在内存中存在。

❑S位为1,代表数据段或者代码段描述符。

❑DPL为0,代表段描述符的特权级为0。开机引导到现在,所有的特权级都是0,其他特权级尚未使用。

❑TYPE为0xA,结合S的值可知段为可执行、可读。

❑段界限为0x7ff,结合G位的值可知段的总长度为0x800×4KB=8MB。

第3个描述符存放数据段的描述符,它的起始地址也为0x0,段界限也是8MB。作为练习,请读者自行分析数据段描述符的各字段的含义。从这两个描述符的特点就可以看出来,Linux将代码段和数据段的基地址都设成了0,相当于没有分段,这里只是使用了段的保护机制而已。

第4个描述符有点特殊,如果你去查看Linux源码的话会发现并没有这一项,这是本书为了方便实验而故意引入的。它是一个基地址为0xb8000的数据段。这个地址指向的是显存的地址,引入这样一个段描述符,就可以使得直接读写显存变得简单很多。同样,这一项任务也交给读者自己完成。

3.打开A20地址线

从第33开始到第39行,这一段代码的作用是打开A20地址线。其中,第39行使用的empty_8042的定义在49行。

第50行是直接用二进制表示的代码,0x00eb是一个跳转指令,表示向前跳转0,其实就是下一条指令。这条指令只是让CPU空转一条指令,可以起到延时的作用。因为in和out等I/O指令的执行速度比较慢,所以操作系统经常使用空指令的方式进行等待。Linus在写Linux 0.11的时候使用的汇编编译器并不支持跳转,所以就使用硬编码的方式来写指令,现在的GCC工具链已经很好地支持了各种跳转,你也可以使用向前跳转的方式来改写这一行代码。这里为了和Linux源码保持一致,也采用了老式的写法。

第51行读取端口0x64,并检测它的第1位。这个端口对应8042的状态寄存器(一个8位的只读寄存器):第1位为1时,表示输入缓冲器满;第1位为0时,表示输入缓冲器空。如果该位为1,则说明输入缓冲已满,就跳回去继续执行,直到该位为0,然后才返回到34行继续执行。所以,empty_8042的作用为了等待输入缓冲器为空的状态出现。

第34行准备数据,第35行的outb指令中出现的0xD1是命令码,表示写8042的输出端口P2,原IBM PC使用P2的第1位控制A20的开闭。此命令后面带一个字节的参数,这个参数由端口0x60写入。也就是第37行的0xDF,翻译到二进制表示就是1101_1111,其中第0位和第1位为1就表示要打开A20地址线。

4.正式进入保护模式

进入保护模式是通过将cr0寄存器的PE位置1来实现的。cr0寄存器的结构如图2-8所示,其中第0位代表是否开启保护模式。cr0寄存器在后文还会遇到,这里先不全面介绍它的每一位的作用,当用到的时候再介绍。PE位置1,CPU就会进入保护模式;PE位置0,CPU就进入实模式。所以第41行至43行把cr0寄存器的最低位置1,用于开启保护模式。

图2-8 cr0寄存器的结构

第45行至47行使用了长跳转,0xea是跳转指令,0x66是x86指令集用于扩展指令的前缀,跳转的目标地址是0x8:0x0。前边已经介绍了,0x8是代码段的选择子,而代码段的基地址是0,所以这条指令的作用就是跳入物理地址为0的地方执行,而这个位置正是head模块所在的位置。

编译并执行,如图2-9所示,可以看到屏幕的中上部打印了一个字母A,左上角打印了字母B,虽然看上去这两个字母并没有什么特别大的差别,但实际上,一个是在实模式下对显存进行修改,一个是在保护模式下,即通过GDT对显存进行修改,它们的内涵是完全不同的。从这里开始,CPU就可以正常地在保护模式下工作了。

图2-9 进入保护模式

接下来,继续研究保护模式下中断的工作机制。 zn5TV7HRwU614lfoNQQuDrXlmczDixduGGVzZMQeltkxDXmC3voGZqpKw8bWjpn4

点击中间区域
呼出菜单
上一章
目录
下一章
×