我是如何学习写一个操作系统(三):操作系统的启动之保护模式

前言

上一篇其实已经说完了boot的大致工作,但是Linux在最后进入操作系统之前还有一些操作,比如进入保护模式。在我自己的FragileOS里进入保护模式是在引导程序结束后完成的。

实模式到保护模式属于操作系统的一个大坎,所以需要先提一下

从实模式到保护模式

实模式和保护模式都是CPU的工作模式,它们的主要区别就是寻址方式

实模式出现于早期8088CPU时期。当时由于CPU的性能有限,一共只有20位地址线(所以地址空间只有1MB),以及8个16位的通用寄存器,以及4个16位的段寄存器。所以为了能够通过这些16位的寄存器去构成20位的主存地址,必须采取一种特殊的方式。访问内存的就变成了:

  物理地址 = 段基址 << 4 + 段内偏移

随着CPU的发展,可以访问的内存空间也从1MB变为现在4GB,寄存器的位数也变为32位。并且在实模式下,用户程序对内存的访问非常自由,没有任何限制,随随便便就可以修改任何一个内存单元。所以实模式已经不能满足时代的要求了,保护模式就应运而生了

保护模式的偏移值变成了32位,寻址方式仍然需要段寄存器,但是这些段寄存器存放的不再是段基址了,而是类似一个数组的索引

而这个数组就是一个就做全局描述符表 (GDT)的东西,GDT中含有一个个表项,每一个表项称为段描述符。

而我们通过段寄存器里的的这个索引,可以找到对应的表项。段描述符存放了段基址、段界限、内存段类型属性

处理器内部有一个 48 位的寄存器,称为全局描述符表寄存器(GDTR)。也就是为了来记录GDT的

段描述符

FragileOS里进入保护模式

  • 根据上面的描述,在进入保护模式时就先需要构造一个GDT
  • 当然中间还需要一些其它的初始化,在后面详细提
  • 然后再根据特定操作来让CPU识别该进入保护模式了

一部分代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[SECTION .gdt]                              ; 利用宏定义定义gdt
; 段基址 段界限 属性
LABEL_GDT: Descriptor 0, 0, 0
LABEL_DESC_CODE32: Descriptor 0, 0fffffh, DA_C | DA_32 | DA_LIMIT_4K
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0fffffh, DA_DRW
LABEL_DESC_VRAM: Descriptor 0, 0fffffh, DA_DRW | DA_LIMIT_4K

in al, 92h ; 切换到保护模式
or al, 00000010b
out 92h, al

mov eax, cr0
or eax , 1
mov cr0, eax

Linux启动前的最后准备

现在来看看Linux在启动前最后还做了什么

获得系统数据和进入保护模式

setup.s主要的任务就是从BIOS拿到系统数据然后存放到一个内存位置

获取当前光标的位置

1
2
3
4
5
6
mov	ax,#INITSEG	! this is done in bootsect already, but...
mov ds,ax
mov ah,#0x03 ! read cursor pos
xor bh,bh
int 0x10 ! save it in known place, con_init fetches
mov [0],dx ! it from 0x90000.

获取内存大小

1
2
3
mov	ah,#0x88
int 0x15
mov [2],ax

检查现在的显示方式

1
2
3
4
mov	ah,#0x0f
int 0x10
mov [4],bx ! bh = display page
mov [6],ax ! al = video mode, ah = window width

进入保护模式

进入保护模式的代码也在setup中

首先先把内核SYSTEM部分移动到0位置,在之前它是被读入在0x10000位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
	mov	ax,#0x0000
cld ! 'direction'=0, movs moves forward
do_move:
mov es,ax ! destination segment
add ax,#0x1000
cmp ax,#0x9000
jz end_move
mov ds,ax ! source segment
sub di,di
sub si,si
mov cx,#0x8000
rep
movsw
jmp do_move

然后就是加载上面说的全局描述符表和中断向量表

中断向量表前面没有提过,但是比较简单,有点类似GDT,就是 操作系统必须维护一份中断向量表,每一个表项纪录一个中断处理程序(ISR,Interrupt Service Routine)的地址

1
2
3
4
5
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

再接着就是打开A20地址线,如果不打开A20地址线,即使在保护模式下最大寻址还是1M

1
2
3
4
5
6
7
call	empty_8042
mov al,#0xD1 ! command write
out #0x64,al
call empty_8042
mov al,#0xDF ! A20 on
out #0x60,al
call empty_8042

初始化8259A芯片,8259A是专门为了对8085A和8086/8088进行中断控制而设计的芯片,它是可以用程序控制的中断控制器。单个的8259A能管理8级向量优先级中断。 对于对硬件的初始化其实就是依照CPU的固定套路

部分代码

1
2
3
4
mov	al,#0x11		! initialization sequence
out #0x20,al ! send it to 8259A-1
.word 0x00eb,0x00eb ! jmp $+2, jmp $+2
out #0xA0,al ! and to 8259A-2

最后的最后,终于可以正式进入保护模式,可以看到这里进入保护模式的方法和我上面的move cr0 ax不太一样,Linux之所以使用这种方法是为了兼容286之前的CPU,另外需要注意的是在进入保护模式之后需要立马执行一条段间跳转来让CPU刷新指令队列,这里跳转的描述就已经是用段值来描述了,段指的第三位到第十五位用来指向GDT里的索引(1000),也就是跳到第2个段描述符里记录的地址

1
2
3
mov	ax,#0x0001	! protected mode (PE) bit
lmsw ax ! This is it!
jmpi 0,8 ! jmp offset 0 of segment 8 (cs)

第二个GTD段描述符,所以上面也就是跳转到内存0处

1
2
3
4
.word	0x07FF		! 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ! base address=0
.word 0x9A00 ! code read/exec
.word 0x00C0 ! granularity=4096, 386

IDT和分页管理机制

再往下就是正式进入到了内核部分,在此之前需要再提一下IDT和分页管理机制

IDT

中断描述符表把每个中断或异常编号和一个指向中断处理事件服务程序的描述符联系起来。同GDT和LDT一样,IDT是一个8-字节的描述符数组。和GDT、LDT不同的是,IDT的第一项可以包含一个描述符。为了形成一个在IDT内的索引,处理器把中断、异常标识号乘以8以后来做为IDT的索引。因为只有256个编号,IDT不必包含超过256个描述符。它可以包含比256更少的项,只是那些需要使用的中断、异常的项。

IDT可以在内存的任意位置。处理器通过IDT寄存器(IDTR)来定位IDT。指令LIDT和SIDT用来操作IDTR。

分页机制

将用户程序(进程)的逻辑地址空间分成若干个页(4KB)并编号,同时将内存的物理地址也分成若干个块或页框 4KB)并编号,这样也就是为了让所有的应用程序看都像是独占一片内存,起始地址都是为0,最后再建立一个页表存储着页到页框也就是真实内存地址的映射

在内存里有一个寄存器(PTR)来存储页表

映射的完成

  • 进程访问某个逻辑地址
  • 由线性地址的页号,以及页表寄存器中的始址,找到页表并找到对应的页表项
  • 由页表项上的块号,找到物理内存中的块号
  • 根据块号,和线性地址的页内地址,找到物理地址

我们通过设置CR0寄存器的PG位来开启分页功能,而其它操作就都由CPU来完成,当然前提是我们有一张页表

两级页表结构

为了减少内存的占用量,80X86采用了分级页表

页目录有2的十次方个4字节的表项,这些表项指向对应的二级表,线性地址的最高10位作为页目录用来寻找二级表的索引

二级页表里的表项含有相关页面的20位物理基地址,二级页表使用线性地址中间10位来作为寻找表项的索引

  • 进程访问某个逻辑地址
  • 由线性地址中的页号,以及外层页表寄存器(CR3)中的外层页表始址,找到二级页表的始址
  • 由二级页表的始址,加上线性地址中的外层页内地址,找到对应的二级页表中的页表项
  • 由页表项中的物理块号,加上线性地址中的页内地址,找到对物理地址

所以说CPU寻址一共需要进行两步:

  1. 首先将给定一个逻辑地址 (其实是段内偏移量)
  2. CPU利用段式内存管理单元,先将为个逻辑地址转换成一个线程地址 (也就是前面说的GDT)
  3. 再利用其页式内存管理单元,转换为最终物理地址。(二级页表)

进入到了内核部分

head.s这部分其实已经是进入了内核部分了,但是在Linux0.12里还是把它归为Boot部分。这一部分的主要工作是重新设置GDT和IDT,然后在设置管理内存的分页处理机制 (在进入保护模式后,Linux用的就是AT&T的汇编语法了,最显著的差别就是源操作数和目的数的位置对调了)

  • 设置IDT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
  • 设置GDT
1
2
3
4
5
6
7
8
9
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 :^)

.align 8
  • 这里就是已经准备跳入C语言的main部分了,也就是汇编里的函数调用,先把main的地址压入栈中,当下一个函数执行完ret的时候,就会去执行main了
1
2
3
4
5
6
7
8
9
10
after_page_tables:
pushl $0 # These are the parameters to main :-)
pushl $0
pushl $0
pushl $L6 # return address for main, if it decides to.
pushl $main
jmp setup_paging
L6:
jmp L6 # main should never return here, but
# just in case, we know what happens.
  • 最后就是设置分页机制了

STOS指令:将AL/AX/EAX的值存储到[EDI]指定的内存单元
CLD清除方向标志和STD设置方向标志,当方向标志是0,该指令通过递增的指针数据每一次迭代之后(直到ECX是零或一些其它条件,这取决于REP前缀的香味)工作,而如果该标志是1,指针递减。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setup_paging:
movl $1024*5,%ecx /* 5 pages - pg_dir+4 page tables */
xorl %eax,%eax
xorl %edi,%edi /* pg_dir is at 0x000 */
cld;rep;stosl
movl $pg0+7,pg_dir /* set present bit/user r/w */
movl $pg1+7,pg_dir+4 /* --------- " " --------- */
movl $pg2+7,pg_dir+8 /* --------- " " --------- */
movl $pg3+7,pg_dir+12 /* --------- " " --------- */
movl $pg3+4092,%edi
movl $0xfff007,%eax /* 16Mb - 4096 + 7 (r/w user,p) */
std
1: stosl /* fill pages backwards - more efficient :-) */
subl $0x1000,%eax
jge 1b
xorl %eax,%eax /* pg_dir is at 0x0000 */
movl %eax,%cr3 /* cr3 - page directory start */
movl %cr0,%eax
orl $0x80000000,%eax
movl %eax,%cr0 /* set paging (PG) bit */
ret /* this also flushes prefetch-queue */

小结

这一节主要是描述了保护模式和一些CPU需要的数据结构。这几篇文章相当于讲述了一台计算机启动的时候都发生了什么。

  • 通过引导程序boot来加载真正的内核代码
  • 获得一些硬件上的系统参数保存在一些内存里供后面使用
  • 最后是初始化像GDT、IDT等,然后设置分页等等