上节跳转到内存地址0x0处开始执行内核代码前,已经开启了保护模式,所以在讲解Linux代码前,不得不先给小伙伴们介绍下保护模式下的基础知识(可以先粗略看下,如果初试神功的内容理解有点困难,再回头来看会有更好效果)。
1.初练神功
- 九阴真经残卷(一)------段描述符
为什么要搞出个段描述符呢?这个就要将下CPU的历史了,很早的实地址模式的时候CPU的更新速度并没有内存块,所以主板有20位地址线,而当时的CPU的段寄存器只有16位,怎么将16位变成20位呢?当时人们用16位基地址左移4位+16位偏移=20位地址的方式解决了这一问题(ds:di==物理addr==ds<<4+di),原先的段基地址等于段寄存器内容左移4位。后来CPU也拥有了32位寄存器,但是为了保持向下兼容,他们不能修改段寄存器的位数,而且如果以后继续发展,每次都更新段寄存器位数代价太大,于是他们想到了一个利用软硬件结合的方法来弥补这一问题,也就是我们的保护模式下寻址,首先把真正的段基地址放到一个结构里面----段描述符,然后把段描述符数组放到内存的某个位置,并用一个新的寄存器指向它,原先的段寄存器不在直接保存基地址,而是保存段描述符数组的下标,这样段寄存器依然只需要16位,而段描述结构的大小可以根据实际需要变化(32位系统上是64位),并且每个进程都可以拥有自己的代码段和数据段描述符,这也是多任务操作系统的必要条件。这个描述符数组的名字叫全局描述符表GDT,其位置由GDTR寄存器保存;局部描述符表相当于用GDT中的段描述符来充当二级GDTR,二级GDTR指向内容的就是局部描述符表,段寄存器保存的是全局描述符表/局部描述符表中的描述符索引值。之所以分全局描述符表和局部描述符表是因为进程切换的时候,全局描述符表不变,局部描述符表则跟着进程变,而我们前面说过独立的段描述符是进程能否并行的必要条件,所以才需要区分,这都是CPU强制操作系统遵守的,如果CPU让全局描述符表跟着进程切换变化的话,也就不需要局部描述符表了。
- 九阴真经残卷(二)------保护模式寻址
我们通过一副图来简单了解下保护模式下根据段寄存器确定实际段基址的过程,其中没有分号的数字是全局描述符表寻址流程,有分号的数字是局部描述符表寻址流程(摘自《琢石成器》)
- 九阴真经残卷(三)------段寄存器和段描述符结构
怎么知道什么时候使用全局描述符表寻址,什么时候使用局部描述符表呢?想知道这个,我们就必须先看下段寄存器现在的结构了(LDTR/ CS/DS/ES/FS/GS/SS)
可以看到段寄存器的倒数第三位决定了是使用1-局部描述符表还是0-全局描述符表寻址,这边提下,内核态数据段选择子是0x10,用户态数据段选择子是0x17,简单解释就是:不管什么进程数据段描述符都是第二项(第0项不用,第1项是代码段描述符),所以index是2==0000 0000 0001 0b,内核态用全局描述符表(内核态是属于操作系统的,对于所有进程它的代码段及地址和数据段基地址都是一样的,所以用全局描述符表),用户态用局部描述符表,最后RPL内核态级别为0,用户态为3,对照上面的图应该知道为什么选择子是这样了吧?
接下来我们看下我们通过选择子找到的段描述符到底什么样的?
下面是我百度到的对这个结构的解释,不需要背,碰到段描述符需要解释的时候,用下面的对照翻译就好了,内核中提供了很多宏所以我们也不需要去记,只需要知道大概结构就好
(1) G颗粒度标志(Granularity):确定段限长字段Limit值的单位,G=0 单位为B,G=1 单位为4KB(不影响基地址颗粒度,总为B);
(2) D/B:
对于可执行代码段,称为D标志,D=0 默认16位地址和16位或8位操作数,D=1 默认32位地址和32位操作数或8位操作数.(指令前缀0x66:选择非默认值的操作数大小,0x67 选择非默认值的地址大小);
对于栈段,称为B标志,B=0 使用16位栈指针,B=1 使用32位栈指针.
对于下扩数据段,称为B标志,B=0 堆栈段上界限0xFFFF(64KB),B=1 堆栈段上界限0xFFFFFFFF(4GB)
(32位代码段和数据段总是设置为1,16位代码段和数据段总是设置为0)
(上扩段:偏移地址 0~段限长;下扩段:偏移地址 段限长~ 最大地址(0xffff或 0xffffffff,根据B标志决定))
(3) 21保留位:总是设置为0;
(4) AVL:软件可利用位。80386对该位的使用未做规定,Intel公司也保证今后开发生产的处理器只要与80386兼容,就不会对该位的使用做任何定义或 规定。
(5) 段限长(Segment limit field):段的长度,有多个段限长字段的由处理器合并;
(6) P段存在标志(Segment present):p=0 段不在内存中,p=1 段在内存中;
(7) DPL描述符特权级字段(Descriptor privilege level);
(8) S描述符类型标志(Descriptor type flag):S=0 系统段描述符,S=1 代码段或数据段描述符
(9) TYPE:段类型字段(Type field)
对于数据段描述符,4bit由高到低,0EWA,E=0 向上扩展 E=1 向下扩展,W=0 只读W=1 可写,A=0 未访问 A=1 已访问
对于代码段描述符,4bit由高到低,1CRA,C=0 非一致代码段C=1 一致代码段,R=0 只执行R=1执行/可读,A=0 未访问 A=1 已访问
对于TSS描述符,4bit由高到低,10B1 ,B=0 任务处于非活动状态, B=1 任务正忙
对于系统段描述符和门描述符如下:
1.1残卷学习心得
保护模式寻址文字版:选择子的索引值是对应描述符在描述符表的下标,而选择子的Table Indicator位(倒数第三Bit)指明了这个“数组名”是全局描述符还是局部描述符,如果是全局描述符表,那么寻址就类似一维数组,GDTR指向的地址就是“数组”的起始地址,段选择子对应“一维数组的列”(GDTR[CS/DS/ES/FS/GS/SS]);如果是局部描述符,那么索引就类似二维数组,GDTR指向的是“二维数组”的起始地址,LDTR中的索引值指向的就是“二维数组的行”,而段选择子则对应“二维数组的列”(GDTR[LDTR][CS/DS/ES/FS/GS/SS]),找到段描述符结构后,取出其中的段基址,加上偏移后就是我们要查询的线性地址。LDTR这个寄存器的值怎么来的?这边我简单介绍下,Linux操作系统最后的GDT的布局是0-NULL 1-CS 2-DS 3-syscall 4-TSS0 5-LDT0 6-TSS1 7-LDT1 8-TSS2 9-LDT2...的样子(推算LDTX前面的索引数字很容易吧?不容易?你数学体育老师教的?),程序切换到第X项任务的时候,CPU自动将任务的TSS中对应LDTR的内容加载到LDTR寄存器,而这个值是我们自己在创建X任务的时候计算的X任务项的LDT在GDT中的索引并将其保存到任务的TSS中的(TSS是任务段描述符,用于进程切换的结构,每个进程一个TSS),现在不完全理解没关系,内核学习就是这样,学到后面前面的自然就明白了~
2.初试神功
在学完几张残卷热身后,丹田是否能感受到有股弱弱的真气?接下来,我们要找点对象来试试效果了
2.1设置中断描述符表和全局描述符表
.text
.globl _idt,_gdt,_pg_dir
_pg_dir:
startup_32:
movl $0x10,%eax
mov %ax,%ds
mov %ax,%es
mov %ax,%fs
mov %ax,%gs
lss _stack_start,%esp
call setup_idt
call setup_gdt
.word 0x07FF | 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 | base address=0
.word 0x9200 | data read/write
.word 0x00C0 | granularity=4096, 386
Linus大神的注释也很清晰了,数据段基址是0x0(也就是我们逻辑地址就是线性地址的意思,现在还未开启分页,所以线性地址就是物理地址,也就是说逻辑地址不需要转换,直接可以当物理地址使用),段限长为8M(其他信息参见残卷三),接下来将要讲解上面代码的最后两个重点call setup_idt(所有系统调用和异常处理都是通过中断完成的,想要详细了解中断知识的可以看下8086汇编相关书籍)和call setup_gdt(所有任务的局部描述符和任务描述符都保存在这里)
2.1.1设置中断描述符表
- 九阴真经残卷(四)------各种门
setup_idt:
lea ignore_int,%edx
movl $0x00080000,%eax
movw %dx,%ax /* selector = 0x0008 = cs */
movw $0x8E00,%dx /* interrupt gate - dpl=0, present */
lea _idt,%edi
mov $256,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl $8,%edi
dec %ecx
jne rp_sidt
lidt idt_descr
ret
;下面代码是在head.s的末尾,不过为了理解上面代码,就写到这来
idt_descr:
.word 256*8-1 # idt contains 256 entries
.long _idt
_idt: .fill 256,8,0 # idt is uninitialized
2.1.2设置中断描述符表
setup_gdt:
lgdt gdt_descr
ret
;同中断描述符表,这边的代码也是在文件尾部,为了里面上面代码而写到这边的
gdt_descr:
.word 256*8-1 # so does gdt (not that that's any
.long _gdt # magic number, but it works for me :^)
_gdt: .quad 0x0000000000000000 /* NULL descriptor */
.quad 0x00c09a00000007ff /* 8Mb */
.quad 0x00c09200000007ff /* 8Mb */
.quad 0x0000000000000000 /* TEMPORARY - don't use */
.fill 252,8,0 /* space for LDT's and TSS's etc */
同理加载全局描述表也就很容易理解了(lgdt加载格式和lidt一样,详情见2.1.1),这边代码段和数据段描述符和Boot.s的是一样的,只不过在后面预留了子进程的LDT和TSS的空间(还记得我在残卷总结说的的GDT布局吗?0-NULL 1-CS 2-DS 3-syscall 4-TSS0 5-LDT0 6-TSS1 7-LDT1 8-TSS2 9-LDT2...现在能理解了吧~)。之所以不在Boot.s代码就做好这些步骤,是因为Boot.s代码所在的那段内存后面会被操作系统用作其他用途,所以这边要重新设置下,把全局描述符表放到安全的地方
现在我们已经完成了保护模式寻址的分段机制的设置,但是保护模式除了分段还有个分页机制,这个是实现程序部分加载的关键技术,由于避免文章篇幅过大,我将分页机制的介绍放入下节,欢迎大家届时收看~