保护模式前的最后一次折腾内存
上篇品读完第一个操作系统源码文件bootsect.s,之后便跳转到0x90200地址开始执行,这个位置的代码就是位于setup.s的开头:
start:
mov ax,#0x9000 ; 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.
又有个int
指令。前面的文章好好看过的话,一下就能猜出它要干嘛。还记不记得之前有个int 0x13
表示触发 BIOS 提供的读磁盘中断程序?这个int 0x10
也是一样的,它也是触发 BIOS 提供的显示服务中断处理程序,而 ah 寄存器被赋值为 0x03 表示显示服务里具体的读取光标位置功能。
具体 BIOS 提供了哪些中断服务,如何去调用和获取返回值,请大家自行寻找资料,这里只说结果。
这个int 0x10
中断程序执行完毕并返回时,dx 寄存器里的值表示光标的位置,具体说来其高八位 dh 存储了行号,低八位 dl 存储了列号。
说明:计算机在加电自检后会自动初始化到文字模式,在这种模式下,一屏幕可以显示 25 行,每行 80 个字符,也就是 80 列。
那下一步 mov [0],dx
就是把这个光标位置存储在 [0] 这个内存地址处。注意,前面我们说过,这个内存地址仅仅是偏移地址,还需要加上 ds 这个寄存器里存储的段基址,最终的内存地址是在 0x90000 处,这里存放着光标的位置,以便之后在初始化控制台的时候用到。
所以从这里也可以看出,这和我们平时调用一个方法没什么区别,只不过这里的寄存器的用法相当于入参和返回值,这里的 0x10 中断号相当于方法名。
这里又应了之前说的一句话,操作系统内核的最开始也处处都是 BIOS 的调包侠,有现成的就用呗。
再接下来的几行代码,都是和刚刚一样的逻辑,调用一个 BIOS 中断获取点什么信息,然后存储在内存中某个位置,我们迅速浏览一下就好咯。
;1.比如获取内存信息。
; Get memory size (extended mem, kB)
mov ah,#0x88
int 0x15
mov [2],ax
;2.获取显卡显示模式。
; Get video-card data:
mov ah,#0x0f
int 0x10
mov [4],bx ; bh = display page
mov [6],ax ; al = video mode, ah = window width
;3.检查显示方式并取参数
; check for EGA/VGA and some config parameters
mov ah,#0x12
mov bl,#0x10
int 0x10
mov [8],ax
mov [10],bx
mov [12],cx
;4.获取第一块硬盘的信息。
; Get hd0 data
mov ax,#0x0000
mov ds,ax
lds si,[4*0x41]
mov ax,#INITSEG
mov es,ax
mov di,#0x0080
mov cx,#0x10
rep
movsb
;5.获取第二块硬盘的信息。
; Get hd1 data
mov ax,#0x0000
mov ds,ax
lds si,[4*0x46]
mov ax,#INITSEG
mov es,ax
mov di,#0x0090
mov cx,#0x10
rep
movsb
以上原理都一样,就没必要细琢磨了,对操作系统的理解作用不大,只需要知道最终存储在内存中的信息是什么,在什么位置,就好了,之后会用到他们的。
内存地址 | 长度(字节) | 名称 |
---|---|---|
0x90000 | 2 | 光标位置 |
0x90002 | 2 | 扩展内存数 |
0x90004 | 2 | 显示页面 |
0x90006 | 1 | 显示模式 |
0x90007 | 1 | 字符列数 |
0x90008 | 2 | 未知 |
0x9000A | 1 | 显示内存 |
0x9000B | 1 | 显示状态 |
0x9000C | 2 | 显卡特性参数 |
0x9000E | 1 | 屏幕行数 |
0x9000F | 1 | 屏幕列数 |
0x90080 | 16 | 硬盘1参数表 |
0x90090 | 16 | 硬盘2参数表 |
0x901FC | 2 | 根设备号 |
说明:由于之后很快就会用 c 语言进行编程,虽然汇编和 c 语言也可以用变量的形式进行传递数据,但这需要编译器在链接时做一些额外的工作,所以这么多数据更方便的还是双方共同约定一个内存地址,我往这里存,你从这里取,就完事了。这恐怕是最最原始和直观的变量传递的方式了。
把这些信息存储好之后,操作系统又要做什么呢?我们继续往下看。
cli ; no interrupts allowed ;
就一行 cli,表示关闭中断的意思。
因为后面我们要把原本是 BIOS 写好的中断向量表给覆盖掉,也就是给破坏掉了,写上我们自己的中断向量表,所以这个时候是不允许中断进来的。
继续看:
; first we move the system to it's rightful place
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
; then we load the segment descriptors
end_move:
...
看到后面那个 rep movsw
熟不熟悉,一开始我们把操作系统代码从 0x7c00 移动到 0x90000 的时候就是用的这个指令:
同前面的原理一样,也是做了个内存复制操作,最终的结果是,把内存地址 0x10000 处开始往后一直到 0x90000 的内容,统统复制到内存的最开始的 0 位置,大概就是这么个效果。
由于之前的各种加载和复制,导致内存看起来很乱,是时候进行一波取舍和整理了,我们重新梳理一下此时的内存布局。
栈顶地址仍然是 0x9FF00 没有改变。
0x90000 开始往上的位置,原来是 bootsect 和 setup 程序的代码,现 bootsect 的一部分代码在已经被操作系统为了记录内存、硬盘、显卡等一些临时存放的数据给覆盖了一部分。
内存最开始的 0 到 0x80000 这 512K 被 system 模块给占用了,之前讲过,这个 system 模块就是除了 bootsect 和 setup 之外的全部程序链接在一起的结果,可以理解为操作系统的全部。
那么现在的内存布局就是这个样子。
好了,记住上面的图就好了,操作系统又折腾了一下内存,之后的很长一段时间内存布局就不会变了,终于稳定下来了。0 地址开始处存放着操作系统的全部代码吗,也就是 system 模块,0x90000 位置处往后的几十个字节存放着一些设备的信息,方便以后使用。之前的什么 0x7c00,已经是过去式了,赶紧忘掉它,向前看!
段寄存器的历史包袱
接下来,就要进行一项大工程了,那就是模式的转换,需要从现在的 16 位的实模式转变为之后 32 位的保护模式!当然,虽说是一项非常难啃的大工程,但从代码量看,却是少得可怜,所以不必太过担心
每次讲这里都十分的麻烦,因为这是 x86 的历史包袱问题,现在的 CPU 几乎都是支持 32 位模式甚至 64 位模式了,很少有还仅仅停留在 16 位的实模式下的 CPU。所以我们要为了这个历史包袱,写一段模式转换的代码,如果 Intel CPU 被重新设计而不用考虑兼容性,那么今天的代码将会减少很多甚至不复存在。
仍然是 setup.s 文件中的代码:
lidt idt_48 ; load idt with 0,0
lgdt gdt_48 ; load gdt with whatever appropriate
idt_48:
.word 0 ; idt limit=0
.word 0,0 ; idt base=0L
要理解这两条指令,就涉及到实模式和保护模式的第一个区别了。我们现在还处于实模式下,这个模式的 CPU 计算物理地址的方式还记得么?不记得的话看一下 (一)中最开始的两行代码
是不是觉得很别扭,那更别扭的地方就要来了。当 CPU 切换到保护模式后,同样的代码,内存地址的计算方式还不一样,你说气不气人?
变成啥样了呢?刚刚那个 ds 寄存器里存储的值,在实模式下叫做段基址,在保护模式下叫段选择子。段选择子里存储着段描述符的索引。
通过段描述符索引,可以从全局描述符表 gdt 中找到一个段描述符,段描述符里存储着段基址。
段基址取出来,再和偏移地址相加,就得到了物理地址,整个过程如下。
你就说烦不烦吧?同样一段代码,实模式下和保护模式下的结果还不同,但没办法,x86 的历史包袱我们不得不考虑,谁让我们没其他 CPU 可选呢。
总结一下就是,段寄存器(比如 ds、ss、cs)里存储的是段选择子,段选择子去全局描述符表中寻找段描述符,从中取出段基址。
好了,那问题自然就出来了,**全局描述符表(gdt)**长什么样?它在哪?怎么让 CPU 知道它在哪?
长什么样先别管,一定又是一个令人头疼的数据结构,先说说它在哪?在内存中呗,那么怎么告诉 CPU 全局描述符表(gdt)在内存中的什么位置呢?答案是由操作系统把这个位置信息存储在一个叫 gdtr 的寄存器中。
怎么存呢?就是刚刚那条指令:
lgdt gdt_48
其中 lgdt 就表示把**后面的值(gdt_48)**放在 gdtr 寄存器中,gdt_48 标签,我们看看它长什么样。
gdt_48:
.word 0x800 ; gdt limit=2048, 256 GDT entries
.word 512+gdt,0x9 ; gdt base = 0X9xxxx
可以看到这个标签位置处表示一个 48 位的数据,其中高 32 位存储着的正是全局描述符表 gdt 的内存地址0x90200 + gdt
gdt 是个标签,表示在本文件内的偏移量,而本文件是 setup.s,编译后是放在 0x90200 这个内存地址的,还记得吧?所以要加上 0x90200 这个值。
那 gdt 这个标签处,就是全局描述符表在内存中的真正数据了。
gdt:
.word 0,0,0,0 ; dummy
.word 0x07FF ; 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ; base address=0
.word 0x9A00 ; code read/exec
.word 0x00C0 ; granularity=4096, 386
.word 0x07FF ; 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 ; base address=0
.word 0x9200 ; data read/write
.word 0x00C0 ; granularity=4096, 386
根据刚刚的段描述符格式。
可以看出目前全局描述符表有三个段描述符,第一个为空,第二个是代码段描述符(type=code),第三个是数据段描述符(type=data),第二个和第三个段描述符的段基址都是 0,也就是之后在逻辑地址转换物理地址的时候,通过段选择子查找到无论是代码段还是数据段,取出的段基址都是 0,那么物理地址将直接等于程序员给出的逻辑地址(准确说是逻辑地址中的偏移地址)。先记住这点就好。
具体段描述符的细节还有很多,就不展开了,比如这里的高 22 位就表示它是代码段还是数据段。
接下来我们看看目前的内存布局
这里我把 idtr 寄存器也画出来了,这个是中断描述符表,其原理和全局描述符表一样。全局描述符表是让段选择子去里面寻找段描述符用的,而中断描述符表是用来在发生中断时,CPU 拿着中断号去中断描述符表中寻找中断处理程序的地址,找到后就跳到相应的中断程序中去执行,具体我们后面遇到了再说。
本节讲了操作系统设置了个全局描述符表 gdt,为后面切换到保护模式后,能去那里寻找到段描述符,然后拼凑成最终的物理地址,就这个作用。当然,还有很多段描述符,作用不仅仅是转换成最终的物理地址,不过这是后话了。
进入保护模式
仍然是 setup.s 中:
mov al,#0xD1 ; command write
out #0x64,al
mov al,#0xDF ; A20 on
out #0x60,al
这段代码的意思是,打开 A20 地址线。
说人话就是,打开 A20 地址线。哈哈,开玩笑,到底什么是 A20 地址线呢?
简单理解,这一步就是为了突破地址信号线 20 位的宽度,变成 32 位可用。这是由于 8086 CPU 只有 20 位的地址线,所以如果程序给出 21 位的内存地址数据,那多出的一位就被忽略了,比如如果经过计算得出一个内存地址为
1 0000 00000000 00000000
那实际上内存地址相当于 0,因为高位的那个 1 被忽略了,地方不够。
当 CPU 到了 32 位时代之后,由于要考虑兼容性,还必须保持一个只能用 20 位地址线的模式,所以如果你不手动开启的话,即使地址线已经有 32 位了,仍然会限制只能使用其中的 20 位。
接下来的一段代码,你完全完全不用看,但为了防止你一直记挂在心上,我给你截出来说道说道,这样以后我说完全不用看的代码时,你就真的可以放宽心完全不看了。
就是这一大坨,还有 Linus 自己的注释。
; well, that went ok, I hope. Now we have to reprogram the interrupts :-(
; we put them right after the intel-reserved hardware interrupts, at
; int 0x20-0x2F. There they won't mess up anything. Sadly IBM really
; messed this up with the original PC, and they haven't been able to
; rectify it afterwards. Thus the bios puts interrupts at 0x08-0x0f,
; which is used for the internal hardware interrupts as well. We just
; have to reprogram the 8259's, and it isn't fun.
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
.word 0x00eb,0x00eb
mov al,#0x20 ; start of hardware int's (0x20)
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x28 ; start of hardware int's 2 (0x28)
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x04 ; 8259-1 is master
out #0x21,al
.word 0x00eb,0x00eb
mov al,#0x02 ; 8259-2 is slave
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0x01 ; 8086 mode for both
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
.word 0x00eb,0x00eb
mov al,#0xFF ; mask off all interrupts for now
out #0x21,al
.word 0x00eb,0x00eb
out #0xA1,al
这里是对可编程中断控制器 8259 芯片进行的编程。
因为中断号是不能冲突的, Intel 把 0 到 0x19 号中断都作为保留中断,比如 0 号中断就规定为除零异常,软件自定义的中断都应该放在这之后,但是 IBM 在原 PC 机中搞砸了,跟保留中断号发生了冲突,以后也没有纠正过来,所以我们得重新对其进行编程,不得不做,却又一点意思也没有。这是 Linus 在上面注释上的原话。
所以我们也不必在意,只要知道重新编程之后,8259 这个芯片的引脚与中断号的对应关系,变成了如下的样子就好。
PIC 请求号 | 中断号 | 用途 |
---|---|---|
IRQ0 | 0x20 | 时钟中断 |
IRQ1 | 0x21 | 键盘中断 |
IRQ2 | 0x22 | 接连从芯片 |
IRQ3 | 0x23 | 串口2 |
IRQ4 | 0x24 | 串口1 |
IRQ5 | 0x25 | 并口2 |
IRQ6 | 0x26 | 软盘驱动器 |
IRQ7 | 0x27 | 并口1 |
IRQ8 | 0x28 | 实时钟中断 |
IRQ9 | 0x29 | 保留 |
IRQ10 | 0x2a | 保留 |
IRQ11 | 0x2b | 保留 |
IRQ12 | 0x2c | 鼠标中断 |
IRQ13 | 0x2d | 数学协处理器 |
IRQ14 | 0x2e | 硬盘中断 |
IRQ15 | 0x2f | 保留 |
好了,接下来的一步,就是真正切换模式的一步了,从代码上看就两行。
mov ax,#0x0001 ; protected mode (PE) bit
lmsw ax ; This is it;
jmpi 0,8 ; jmp offset 0 of segment 8 (cs)
前两行,将 cr0 这个寄存器的位 0 置 1,模式就从实模式切换到保护模式了。
所以真正的模式切换十分简单,重要的是之前做的准备工作。
再往后,又是一个段间跳转指令 jmpi,后面的 8 表示 cs(代码段寄存器)的值,0 表示偏移地址。请注意,此时已经是保护模式了,之前也说过,保护模式下内存寻址方式变了,段寄存器里的值被当做段选择子。
回顾下段选择子的模样。
8 用二进制表示就是
00000,0000,0000,1000
对照上面段选择子的结构,可以知道描述符索引值是 1,也就是要去全局描述符表(gdt)中找第一项段描述符。上节说了,第0项是空值,第一项被表示为代码段描述符,是个可读可执行的段,第二项为数据段描述符,是个可读可写段,不过他们的段基址都是 0。
所以,这里取的就是这个代码段描述符,段基址是 0,偏移也是 0,那加一块就还是 0 咯,所以最终这个跳转指令,就是跳转到内存地址的 0 地址处,开始执行。
0地址处是什么呢?还是回顾之前的内存布局图。
就是操作系统全部代码的 system 这个大模块,system 模块怎么生成的呢?由 Makefile 文件可知,是由 head.s 和 main.c 以及其余各模块的操作系统代码合并来的,可以理解为操作系统的全部核心代码编译后的结果。
tools/system: boot/head.o init/main.o \
$(ARCHIVES) $(DRIVERS) $(MATH) $(LIBS)
$(LD) $(LDFLAGS) boot/head.o init/main.o \
$(ARCHIVES) \
$(DRIVERS) \
$(MATH) \
$(LIBS) \
-o tools/system > System.map
所以,接下来,我们就要重点阅读 head.s 了。
这也是 boot 文件夹下的最后一个由汇编写就的源代码文件,哎呀,不知不觉就把两个操作系统源码文件(bootsect.s 和 setup.s)讲完了,而且是汇编写的令人头疼的代码。
head.s 这个文件仅仅是为了顺利进入由后面的 c 语言写就的 main.c 做的准备,所以咬咬牙看完这个之后,我们就终于可以进入 c 语言的世界了!也终于可以看到我们熟悉的 main 函数了!
资料
[1] 保护模式下逻辑地址到线性地址(不开启分页时就是物理地址)的转化,看 Intel 手册:Volume 3 Chapter 3.4 Logical And Linear Addresses
[2] 段描述符结构和详细说明,看 Intel 手册:Volume 3 Chapter 3.4.5 Segment Descriptors
比如文中说的数据段与代码段的划分,其实还有更细分的权限控制