其实只是一点笔记,所以肯定会有错误,好在计算机科学是实践性很强的科学,一切都将以实验数据得出,我绝不会妄下结论,这也就减少了错误的发生。
阅读本文手头上应该有一份linux0.11源代码,引导程序调试软件bochs(其实是个虚拟机,不过它的调试功能实在是完美)和配套的linux0.11内核img(linux-0.11-devel-040329.zip)。最好再有一本代码注释,推荐赵炯博士的《Linux内核完全注释——内核版本0.11》。显然,bochs的使用方法必须知道,具体操作请参阅《Linux内核完全注释》第14章;在bochs能够正确运行之后,使用bochsdbg进行调试,其使用方法见“Linux内核调试基本方法”。要想很好的理解操作系统,应具备一定的底层知识,推荐《深入理解计算机系统》。如果对老版本的linux很有兴趣,建议去OldLinux论坛,感谢赵炯博士的无私奉献。
下载linux-0.11-devel-040329.zip,解压缩到bochs的安装目录,其中包含一个bochs2.1.1的安装程序和linux内核img,找到bochsrc-hd.bxrc文件的12、36行,修改其中的$ BXSHARE为bochs的安装路径,如果就是上级目录,则可直接改为“..”,如:
12> romimage: file=../BIOS-bochs-latest, address=0xf0000
编辑run.bat文件,将其中所有内容改为:
"../bochsdbg" -q -f bochsrc-Hd.bxrc
运行run.bat,即启动调试工具bochsdbg。
在0x0000:0x7c00处设置一个断点,在Linux引导程序开始处暂停。命令行如下:
<bochs:1> vbreak 0x0:0x7c00
<bochs:2> c
(0) Breakpoint 1, 0x7c00 (0x0:0x7c00)
Next at t=16252460
(0) [0x00007c00] 0000:7c00 (unk. ctxt): mov ax, 0x7c0 ; b8c007
0x0000:0x7c00即以实模式下分段机制书写的逻辑地址,其物理地址的计算方法为0x0000<<4 + 0x7c00 = 0x00007c00,从输出信息可以看到程序在物理地址0x00007c00处暂停。
引导程序一开始将其自身代码从0x7c00处复制到0x90000处,这个过程首先将0x7c0赋值给ds做为源数据段,将0x9000赋值给es做为目的数据段,即将ds:si的数据复制到es:di所在位置。现在通过调试来验证这一复制过程,以及进一步了解实模式下的分段行为。由ds:si => 0x7c0:0x0 => 0x7c0<<4+0x0 = 0x7c00,由es:di => 0x9000:0x0 => 0x9000<<4+0x0 = 0x90000,对比这两个绝对地址0x7c00和0x90000的数据即可验证上述复制过程。命令行如下:
<bochs:3> x /8 0x7c00
[bochs]:
0x00007c00 <bogus+ 0>: 0x8e07c0b8 0x9000b8d8 0x00b9c08e
0x29f62901
0x00007c10 <bogus+ 16>: 0xeaa5f3ff 0x90000018 0xd88ec88c
0xd08ec08e
<bochs:4> u /10
00007c00: ( ): mov ax, 0x7c0 ; b8c007
00007c03: ( ): mov ds, ax ; 8ed8
00007c05: ( ): mov ax, 0x9000 ; b80090
00007c08: ( ): mov es, ax ; 8ec0
00007c0a: ( ): mov cx, 0x100 ; b90001
00007c0d: ( ): sub si, si ; 29f6
00007c0f: ( ): sub di, di ; 29ff
00007c11: ( ): rep movsw word ptr es:[di], word ptr ds:[si] ;
f3a5
00007c13: ( ): jmp far 9000:0018 ; ea18000090
00007c18: ( ): mov ax, cs ; 8cc8
<bochs:5> break 0x7c13
<bochs:6> c
(0) Breakpoint 2, 0x7c13 in ?? ()
Next at t=16252723
(0) [0x00007c13] 0000:7c13 (unk. ctxt): jmp far 9000:0018 ; ea18000090
<bochs:7> x /8 0x90000
[bochs]:
0x00090000 <bogus+ 0>: 0x8e07c0b8 0x9000b8d8 0x00b9c08e
0x29f62901
0x00090010 <bogus+ 16>: 0xeaa5f3ff 0x90000018 0xd88ec88c
0xd08ec08e
在反汇编代码中看到jmp far 9000:0018这一行,通过调试可看到其实际效果:将cs段设置为0x9000,从偏移量0x18处开始执行,也就是设置eip为0x18。命令行如下:
<bochs:8> info r
……
eip 0x7c13 0x7c13
eflags 0x246 582
cs 0x0 0
……
<bochs:9> n
Next at t=16252724
(0) [0x00090018] 9000:0018 (unk. ctxt): mov ax, cs ; 8cc8
<bochs:10> info r
……
eip 0x18 0x18
eflags 0x246 582
cs 0x9000 36864
……
红色标记的cs和eip组合起来的值cs:eip即指向实模式下的代码逻辑位置。同样通过cs<<4 + eip来计算出实际地址。
在保护模式下,段寄存器所存储的将是段描述符表的某个索引值,索引值指定的段描述符项中含有需要寻址的内存段的基地址、段的最大长度值和段的访问级别等信息。计算线性地址的示意图如下:
图1: 实模式和保护模式下寻址方式比较(摘自《Linux内核完全注释》)
因此,在进入保护模式之前,需要建立GDT表,并让gdtr指向该表基址。在进入保护模式之前,Setup.s中的代码将设置GDT表,其指令为:
end_move:
mov ax,#SETUPSEG ! right, forgot this at first. didn't work :-)
mov ds,ax
lidt idt_48 ! load idt with 0,0
lgdt gdt_48 ! load gdt with whatever appropriate
首先进入Setup程序(0x9020:0x0000处),找到lgdt gdt_48的指令位置,继续调试,命令行如下:
<bochs:11> break 0x90200
<bochs:12> c
(0) Breakpoint 3, 0x90200 in ?? ()
Next at t=16483610
(0) [0x00090200] 9020:0000 (unk. ctxt): mov ax, 0x9000 ; b80090
<bochs:13> u /100
……
0009029d: ( ): lidt ds:0x12c ; 0f011e2c01
000902a2: ( ): lgdt ds:0x132 ; 0f01163201
……
<bochs:14> break 0x902a2
<bochs:15> c
(0) Breakpoint 4, 0x902a2 in ?? ()
Next at t=16750806
(0) [0x000902a2] 9020:00a2 (unk. ctxt): lgdt ds:0x132 ; 0f01163201
反汇编代码lgdt ds:0x132表明将ds:0x132所在位置的数据赋给gdtr,lgdt总共需要6个字节,其中两个字节为GDT表的长度,另外4个字节表明GDT表的基址。通过调试可以看到这条指令的实际作用,命令行如下:
<bochs:16> info r
……
ds 0x9020 36896
……
<bochs:17> xp /4 0x9020:0x132
[bochs]:
0x00090332 <bogus+ 0>: 0x03140800 0x00000009 0x00000000
0x00000000
<bochs:18> n
Next at t=16750807
(0) [0x000902a7] 9020:00a7 (unk. ctxt): call .+0x109 ; e85f00
<bochs:19> dump_cpu
……
gdtr:base=0x90314, limit=0x800
idtr:base=0x0, limit=0x0
……
0x00090332开始的8个字节分别是:0x03140800 0x00000009,intel机器采用的小端法,即0x0009为GDT表基址的高16位,0x0314为GDT表基址的低16位,0x0800为GDT表的长度。调试输出信息gdtr:base=0x90314, limit=0x800即验证这一结果。这些常数数据在Setup.s的205到224行定义。可以通过GDT表基址来查看一下GDT表,命令行如下:
<bochs:20> x /10 0x90314
[bochs]:
0x00090314 <bogus+ 0>: 0x00000000 0x00000000 0x000007ff
0x00c09a00
0x00090324 <bogus+ 16>: 0x000007ff 0x00c09200 0x00000000
0x08000000
0x00090334 <bogus+ 32>: 0x00090314 0x00000000
按照一个描述符8字节长度整理一下得:
0x00000000 0x00000000 ! dummy
0x000007ff 0x00c09a00 ! 内核代码段描述符
0x000007ff 0x00c09200 ! 内核数据段描述符
0x00000000 0x08000000 !
0x00090314 0x00000000 ! GDT表项设置后紧接的idt_48,gdt_48的常数数据,在这个临时GDT表中无意义,实际也不会被索引到
接下来将进入保护模式,并使用这个临时GDT表进行寻址。
在Setup.s中找到进入保护模式的代码:
mov ax,#0x0001 ! protected mode (PE) bit
lmsw ax ! This is it!
jmpi 0,8 ! jmp offset 0 of segment 8 (cs)
前两行指令设置保护模式比特位PE,第三行代码用保护模式下的寻址方式进行跳转。首先进入程序找到jmpi 0,8这一行代码所在位置,命令行如下:
<bochs:21> u /100
……
000902fe: ( ): mov ax, 0x1 ; b80100
00090301: ( ): lmsw ax ; 0f01f0
00090304: ( ): jmp far 0008:0000 ; ea00000800
……
jmp far 0008:0000指令的实际效果是设置cs为0x0008,设置eip为0x0000,这里的0x0008即为保护模式下的段选择符,写成二进制形式0000000000001000,前两位00表示特权级0,第三位0表示该选择符用于选择全局描述符表,高13位0000000000001表示使用全局描述符的第一项,即前面提到的内核代码段选择符:0x00007fff 0x00c09a00,0x00007fff表示这个段基址为0x0000,段限长0x7fff。调试验证这一分析结果,命令行如下:
<bochs:22> break 0x00090304
<bochs:23> c
(0) Breakpoint 5, 0x90304 in ?? ()
Next at t=16750869
(0) [0x00090304] 9020:00000104 (unk. ctxt): jmp far 0008:0000 ; ea000008
00
<bochs:24> n
Next at t=16750870
(0) [0x00000000] 0008:00000000 (unk. ctxt): mov eax, 0x10 ; b8100000
00
在执行完jmp指令后,程序跳转到绝对地址0x00000000处,也就是保护模式下的逻辑地址0x0008:0x00000000,这实际上就是Head.s的代码了。Head.s的开始代码首先将ds,es,gs,fs各个段寄存器的值设置为0x10,这个段选择符写成二进制形式:0000000000010000,它表示特权级0,选择全局描述符表的第2项,即前面提到的内核数据选择符:0x000007ff 0x00c09200,这个段基址为0x0000,段限长0x7fff。
Head.s一开始重新设置IDT表和GDT表,建立方法和在Setup.s中相差不大,下面来看看重新建立的GDT表。命令行如下:
<bochs:25> u /10
00000000: ( ): mov eax, 0x10 ; b810000000
00000005: ( ): mov ds, ax ; 8ed8
00000007: ( ): mov es, ax ; 8ec0
00000009: ( ): mov fs, ax ; 8ee0
0000000b: ( ): mov gs, ax ; 8ee8
0000000d: ( ): lss ds:0x192a4 ; 0fb225a4920100
00000014: ( ): call .+0x6f ; e856000000
00000019: ( ): call .+0x9f ; e881000000
0000001e: ( ): mov eax, 0x10 ; b810000000
00000023: ( ): mov ds, ax ; 8ed8
<bochs:26> b 0x1e
<bochs:27> c
(0) Breakpoint 6, 0x1e in ?? ()
Next at t=16752168
(0) [0x0000001e] 0008:0000001e (unk. ctxt): mov eax, 0x10 ; b8100000
00
<bochs:28> dump_cpu
……
gdtr:base=0x5cb8, limit=0x7ff
idtr:base=0x54b8, limit=0x7ff
……
<bochs:29> x /10 0x5cb8
[bochs]:
0x00005cb8 <bogus+ 0>: 0x00000000 0x00000000 0x00000fff
0x00c09a00
0x00005cc8 <bogus+ 16>: 0x00000fff 0x00c09200 0x00000000
0x00000000
0x00005cd8 <bogus+ 32>: 0x00000000 0x00000000
这个GDT表的值是由Head.s代码末尾234行开始的常数数据定义的:
_gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a0000000fff /* 16Mb */
.quad 0x00c0920000000fff /* 16Mb */
.quad 0x0000000000000000 /* TEMPORARY - don't use */
.fill 252,8,0 /* space for LDT's and TSS's etc */
由此可以看到,这个GDT表的第0项未定义,第1项是内核代码段,第2项是内核数据段,第3项未定义,剩余的252项用于放置创建任务的局部描述符和任务状态段描述符。Linux内核使用的描述符表在内存中的示意图如下:
图2 :Linux内核使用描述符表示意图(摘自《Linux内核完全注释》)
在建立完GDT表后,Head.s代码将继续进行分页,并开启分页机制,Linux将以分段机制将逻辑地址转换成线性地址,以分页机制将线性地址转换成物理地址。在其后的多任务作业时,GDT表末尾252项将得到填充使用。
Linus有句名言:“Read the F**king code”,事实上以调试的方法来辅助阅读是相当有益的。