linux-0.11中保护模式建立过程的分析[3]

来源:互联网 发布:交通数据 编辑:程序博客网 时间:2024/04/30 23:20

SYSTEM模块中,被链接在0x00000处的是head.s代码。由图4可以知道,段寄存器除了CS是更新了以外,其他的段寄存器还是保留着实模式下的段值。因此我们必须先更新其他段寄存器的值,以保证程序有一个正确的运行环境。head.s的头几行代码就是干了这个事情。代码如下:

18         movl $0x10,%eax
19         mov %ax,%ds
20         mov %ax,%es
21         mov %ax,%fs
22         mov %ax,%gs
23         lss _stack_start,%esp

接下来,Linus又重新设置了IDT表和GDT表。

24         call setup_idt
25         call setup_gdt
 
78 setup_idt:
79         lea ignore_int,%edx
80         movl $0x00080000,%eax
81         movw %dx,%ax            /* selector = 0x0008 = cs */
82         movw $0x8E00,%dx        /* interrupt gate - dpl=0, present */
83 
84         lea _idt,%edi
85         mov $256,%ecx
86 rp_sidt:
87         movl %eax,(%edi)
88         movl %edx,4(%edi)
89         addl $8,%edi
90         dec %ecx
91         jne rp_sidt
92         lidt idt_descr
93         ret
 
105 setup_gdt:
106         lgdt gdt_descr
107         ret
220 .align 2
221 .word 0
222 idt_descr:
223         .word 256*8-1           # idt contains 256 entries
224         .long _idt
225 .align 2
226 .word 0
227 gdt_descr:
228         .word 256*8-1           # so does gdt (not that that's any
229         .long _gdt            # magic number, but it works for me :^)
230 
231         .align 3
232 _idt:   .fill 256,8,0           # idt is uninitialized
233 
234 _gdt:   .quad 0x0000000000000000        /* NULL descriptor */
235         .quad 0x00c09a0000000fff        /* 16Mb */
236         .quad 0x00c0920000000fff        /* 16Mb */
237         .quad 0x0000000000000000        /* TEMPORARY - don't use */
238         .fill 252,8,0           /* space for LDT's and TSS'setc */

这里重新设置这两个表是有一个共同目的,就是重新调整了表的位置。为什么要重新调整表的位置呢?因为在setup.s中设置的表占用的那块内存会在后来给linux用作高速缓存(参见内存管理memory.c),所以必须重新调整。另外,重新设置表还有他们各自的原因。在setup.s中设置的IDT表是个空表,所以在head.s中重新设置了IDT表,让其表项都指向同一个服务程序,该服务程序只是打印了一些信息,那么我们就可以直观的知道系统是否进入了中断。在以后要使用某个中断时还会重新设置,让中断正真指向需要的中断服务程序。重新设置的GDT表还改变了表项所表示的段的段长,因为在setup.s中设置的各段的段长是8M,在这里,Linus把它重新设置成了16M,那是因为linux-0.11要最大支持16M的内存,为了方便管理16M内存(参见内存管理memory.c),所以在这里设置的段长为16M。上面提到,在寻址加载代码指令,又在修改寻址参数(修改GDT表代码段的表项),是比较危险的。但为什么在这里却能正常的运行代码呢?要回答这个问题,我们要知道在保护模式下,段寄存器是分成两个部分的。一个是2字节的“可见”部分,另一个是8字节的“不可见”部分。2字节的“可见”部分是可编程的,其实就是所谓的段选择子。8字节的“不可见”部分是不可编程,是由处理器来处理的,内容是段选择子对应的段描述符中的信息。这样设计就可以避免在每次访问内存时查询描述符表,只有在显式或者隐式地操作段寄存器时,处理器才会去查询描述符表来更新段寄存器的“不可见”部分。所以虽然更改了GDT表的内容,但是并没有改变段寄存器的内容,特别是段寄存器CS的内容。由bochs跟踪可以证实这一点。如图5

5:更改GDT表后各寄存器的值

因为段寄存器CS的内容没变,所以CPU还是可以按照原来的规则准确的寻址的。另一面,为了使用GDT表中的新的信息,那么就必须重新加载各段寄存器的值。所以linux在设置完GDT表以后,接着就是更新各段寄存器的值。如下:

26         movl $0x10,%eax         # reload all the segment registers
27         mov %ax,%ds             # after changing gdt. CS was already
28         mov %ax,%es             # reloaded in 'setup_gdt'
29         mov %ax,%fs
30         mov %ax,%gs
31         lss _stack_start,%esp

但是在这里,linux并没有更新段寄存器CS的值,通过bochs跟踪可以看到,图6

6:没有更新段寄存器CS的内容

这是为什么呢?也许是这样的,由于重新设置的描述符跟在setup.s设置的只有段限长不一样,一个是16M,另一个是8M8M的限长在linux内核初始化过程中是够用的,而且后来内核在执行段间跳转指令时会更新CS的内容。所以在这里虽然没有更新CS的内容,但并没有让程序运行出错。针对这个问题,目前的内核已经在 call setup_gdt 指令之后加了两条指令:

        ljmp $(KERNEL_CS),$1f
1:      movl $(KERNEL_DS),%eax  # reload all the segment registers

    到目前为止,保护模式下的段变换已经是设置好,并能正常工作了。也许你会问:这里只是设置了两个段,代码段和数据段,并且这两个段是重叠的,段长是16M,那么16M以外的逻辑地址怎么转变成线性地址呢?其实这里设置的这两个段是给内核使用的,因为内核最大只支持16M的物理内存,所以这两个段的限长刚好够内核使用。当后来内核创建任务时,内核会在GDT表增加新的描述符给任务使用。提到任务,就要讲讲局部描述表(LDT表)和局部描述符表寄存器(LDTR)了。LDTR的数据结构和GDTR的不一样,它和段寄存器的数据结构一样,包括2字节的“可见”部分和8字节的“不可见”部分。其实LDTR的内容就是DTR表的一个描述符项,它表示的是LDT表所在的基地址和表限长,当更新LDTR的“可见”部分时,处理器就会根据LDTR中的选择子在GDT表中寻找相关信息并且更新LDTR的“不可见”部分,整个过程就跟段寄存器的更新一样。当段寄存器的TI位(位2)置位时,表示该段寄存器是要在LDT表中寻找,那么处理器就会根据LDTR的内容找到当前的的LDT表,并用表中的信息来进行转换。linux-0.11给每个任务分配64M的逻辑地址空间,这64M的逻辑地址空间由一个LDT表来表示。由于linux-0.11内核只是使用了一个页目录表并且任务之间的寻找是独立的(为了保证个任务之间不受到干涉),所以4G的线性地址空间(一个页目录表能够寻址的空间)也就被分成了64段(4G/64M,所以linux-0.11最多能同时运行64个任务。可以由图7表:

7linux系统虚拟地址空间分配图

在保护模式下,寻址过程还可以包含另一种变换:页变换,也就是线性地址到物理地址的变换。要开启页变换功能,那就要设置控制寄存器CR0的标志位PG位来启动内存的分页处理功能。在开启内存的分页管理功能时要先设置好页目录表和页表。说到这里,就不能不称赞Linus在设计head.s时的巧妙。我们先从运行完head.s后总体上来看现在的内存分布。如图8

8system模块在内存中的映像示意图

也许你会问:怎么在地址0x00000处的不是head.s的代码指令而是内存页目录表了呢?要是这样,在从setup.s跳转到head.s时不就因为取不到指令(这时取到的是页目录表的内容)而产生异常了吗?其实是这样的:在刚从setup.s跳转到head.s时,地址0x00000处存放的的确是head.s的代码指令,只是后来在建立内存页目录表和内存页表时,把这一部的代码给覆盖了。这是如何实现的呢?内存页表又是怎样建立的呢?在这里,伪指令:.org 起到了很大的作用。该伪指令是告诉汇编器伪指令以下的代码是从该伪指令指定的位置开始编译的。当然,正常的编译是要有一个条件的,那就是伪指令以上的代码量不能超过伪指令指定的位置,否则就会有一部分代码会给覆盖了。伪指令以上的代码与伪指令之间的空间就会让汇编器填充零。这样编译出的代码要正常运行,还要有一个条件,就是在伪指令以上的代码里要包含有一条跳转指令以跳转到伪指令以下的代码处。刚好,head.s在设计上都符合这样的条件。下面解析。在建立内存页目录表和内存页表时,Linus是先给他们分配好空间,然后再往分配好的空间里填写相关内容的。以下代码可以证实这一点:

16 _pg_dir:

 

114 .org 0x1000
115 pg0:
116 
117 .org 0x2000
118 pg1:
119 
120 .org 0x3000
121 pg2:
122 
123 .org 0x4000
124 pg3:
125 
126 .org 0x5000

16行代码其实是一个符合,代表着这行代码的地址。由于它放在head.s的第一条指令处,所以该符合就代表着地址0x00000,也就是内核代码的页目录表所在的地方。代码114行到代码126行就是为4个页表(pg0pg1pg2pg3)留出16K4K X 4)的空间。很明显,在第114行代码.org 0x1000之前的代码量不可能超过4K的,所以这段代码在编译时是不会有问题的。在第49行代码处又有一条跳转指令以跳转到符号after_page_tables所指的第135行代码处,所以这段代码在运行上也是没问题的。页目录表和页表的空间已经准备好了,剩下的就是往表里填写相关内容了。这个问题会在后面回答。

49         jmp after_page_tables

135 after_page_tables:

在页表紧接着的地方又留出了1K的空间作为软盘缓冲区给软盘的DMA使用。

132 _tmp_floppy_area:
133         .fill 1024,1,0
伪指令.fill是要求汇编器从该地址开始,按照参数的要求填充数据。第一参数是指填充数据的项数,这里是1024项;第二个参数是指每一项的字节数,这里是1字节;第三个参数是每项填充的内容,这里是0。所以在编译的时候,汇编器就在这里留出了1K的空间,并且都填充了0

紧接着的代码就是跳转指令jmp after_page_tables所跳转到的地方,也就是图6映像示意图中的“head.s 部分代码”所表示的地方。我想这是head.s里面最有意思的一段代码了。要看懂这一段代码,就要对C函数调用机制要有一定的了解。函数调用的操作包括从一块代码到另一块代码的双向数据的传递和执行控制转移。运行在 intel CPU 架构上的程序是通过栈来支持函数调用的。我们把单个函数调用操作所使用的栈部分称为栈帧结构。在这里,我不想多说栈帧结构,读者可以自行查找相关的资料。但是不得不说一下支持函数调用和返回的指令 call ret 。调用指令 call 的作用是把下一条指令的地址,也就是返回地址,压入栈中并且跳转到被调用函数处开始执行。返回指令 ret 用于弹出栈顶处的地址并跳转到该地址处。所以在使用该指令之前,要保证栈顶处的地址就是先前 call 指令保存的返回地址。或者我们可以从另一个角度来理解 ret :如果想在运行完指令 ret 后跳转到某个地方去运行代码,那么就把该代码的地址放在栈顶处,并且可以按照C函数调用机制来传递参数,实现像C语言中的函数调用。Linus就是利用这种办法实现从head.s中跳转到main.c中的void main(void)函数的。也许你会问,为什么要那么复杂呢,直接调用不就可以了吗?其实Linus这样做是有一个很重要的原因的。他想利用指令 ret 的一个特性:当执行 ret 指令时,处理器就会更新预取指令队列,就是使得当前的预取指令队列无效,并从栈顶处弹出的地址开始预取指令。那为什么Linus要利用这种特性呢?那是因为开启内存页面管理功能后要求使用转移指令刷新预取指令队列,Linus选择了返回指令 ret 指令。这么巧妙的设计,就使得在开启分页管理功能后可以马上准确地把CPU的控制权交给用C语言编写的main()函数。更通俗的说法就是,linux为运行C语言代码准备好了运行环境。下面把相关代码逐条解析:

135 after_page_tables:
136         pushl $0          # These are the parameters to main :-)
137         pushl $0
138         pushl $0
139         pushl $L6       # return address for main, if it decides to.
140         pushl $_main
141         jmp setup_paging
142 L6:
143         jmp L6              # main should never return here, but
144                             # just in case, we know what happens.

从第136行代码到第138行代码这三行代码的压栈行为是为了传递参数给main()函数,但是main()函数并没有使用。符号’$’表示这是一个立即操作数。第139行代码把main()函数返回地址压栈,也就是说从main()函数返回时就会执行第143行代码,这是个死循环。Linus也解析说main()函数不会返回到这里来,如果真的是发生这种情况了,我们就知道发生事了。第140行代码是把准备跳转到的main()函数的地址压栈,当运行到setup_paging:模块中ret指令时(218行),就会跳转到main()函数处执行了。_main是编译器对main的内部表示方法。第141行代码是跳转到setup_paging:模块去运行,这里并没有使用call指令,因为并不需要使用call指令,使用call指令时,call指令会把第143行代码的地址压栈,那么在运行完setup_paging:模块执行第128 ret 指令时就会返回到第143行处,进入死循环。这并不是我们想要的。要想更好的理解这几行压栈指令的作用,最好能对C函数调用机制和栈帧结构有一定的了解。到这里,还有一个问题没给读者解析,就是如何向前面准备好的页目录表和页表空间填写相关内容以及如何开启分页管理功能的。

原创粉丝点击