概述
在计算机加电之后,bios检查硬件,并且把第一个扇区中的bootloader代码加载到0000: 07c00h处,开始执行bootloader代码.bootloader主要做两件事情:
- 从实模式进入保护模式
- 从硬盘(或者其他)中读取OS kernel到内存的固定位置处,然后跳转到OS中执行.
这里先讨论如何从实模式进入保护模式
参考链接:
- 学堂在线 - 清华大学OS课程
先让代码跑起来
- 先下在freedos,并将其中的a.img复制到代码在的工作目录,改名为freedos.img.下载链接:freedos
- 用bximg生成软盘映像,pm.img
- 修改bochsrc,添加下面三行
floppya: 1_44=freedos.img, status=inserted
floppyb: 1_44=pm.img, status=inserted
boot: a
# 相当于有两个软盘,软盘a作为bootloader,引导操作系统启动,软盘b里面放置我们自己
# 的对操作系统的操作(相当于操作系统内核)
- 启动Bochs,待FreeDos其中之后,使用
format b:
命令格式化B:盘 - 把代码编译成.com文件,
nasm pmtest1.asm -o pmtest1.com
- 把pmtest1.com复制到虚拟软盘pm.img上:一种拷贝方法,值得学习
sudo mkdir /mnt/floppy
sudo mount -o loop pm.img /mnt/floppy
sudo cp pmtest1.com /mnt/floppy
sudo umount /mnt/floppy
- 现在我们的.com文件已经拷贝到软盘pm.img中了,在freedos中使用
B:\pmtest1.com
即可执行
GDT(Global Descriptor Table)
参考:GDT,LDT,GDTR,LDTR 详解,包你理解透彻
GDT就是一个装有段描述符的大数组.在GDT中每一个段描述符占用8字节,段描述符最主要的包含两个信息:
- 段的起始地址(段基址)
- 段的大小(段界限)
代码段和数据段描述符如下所示:
GDT表的基地址存放在GDTR寄存器中.GDTR是一个48位寄存器,其低16位保存GDT表的长度,高32位保存表的基址.所以GDT表的最大长度是1M,因为每一个描述符是8字节,所以一共可以存放213个段描述符,但是注意:第一个描述符为空,不使用.
在实模式下的寻址方式为:
在保护模式下,段寄存器实际存放的是选择子,选择子实际上就是GDT表的index.所以在保护模式下,段寄存器就相当于一个指针.
在下图中,逻辑地址由一个16位的段寄存器和32位EIP寄存器指出.根据段寄存器中的值(选择子,index),在GDT中找到段描述符(描述了段的基址和界限),线性地址=基址+EIP.需要注意的是:在没有页机制的情况下,线性地址和物理地址是等价的.
选择子(Selector)
选择子的结构如图:
包括3个部分:
- 描述符索引(index):表示所需要的段的描述符在描述符表的位置,由这个位置再根据在GDTR中存储的描述符表基址就可以找到相应的描述符。
- TI:段选择子中的TI值只有一位0或1,0代表选择子是在GDT选择,1代表选择子是在LDT(Local Descriptor Table)选择。
- 请求特权级(RPL):代表选择子的特权级,共有4个特权级(0级、1级、2级、3级).0代表最高级(操作系统在0级),3代表最低级(应用程序在3级).任务中的每一个段都有一个特定的级别。每当一个程序试图访问某一个段时,就将该程序所拥有的特权级与要访问的特权级进行比较,以决定能否访问该段。系统约定,CPU只能访问同一特权级或级别较低特权级的段。
一个栗子
例如给出逻辑地址:21h:12345678h转换为线性地址
- 选择子SEL=21h=0000000000100 0 01b 他代表的意思是:选择子的index=4即100b选择GDT中的第4个描述符;TI=0代表选择子是在GDT选择;左后的01b代表特权级RPL=1
- OFFSET=12345678h若此时GDT第四个描述符中描述的段基址(Base)为11111111h,则线性地址=11111111h+12345678h=23456789h
段描述符属性
- P位:存在位,P=1表示段在内存中存在;P=0表示段在内存中不存在.
- DPL描述特权级.特权级可以是0,1,2,3.数字越小特权级越大
- S位指明描述符是数据段/代码段描述符(S=1)还是系统段/门描述符(S=0)
- TYPE
- G位段界限粒度位.G=0时段界限粒度为字节;G=1时段界限粒度为4KB
- D/B位
- AVL位保留位,可以被系统软件使用
代码理解
- 问题1:为什么还要初始化32位代码段描述符??????为什么gdt里面的段基地址都是0????
解释:这段代码是把32位代码段的物理地址加载到段描述符的"段基址"位置处(见上文段描述符图示).mov ax, cs
shl eax, 4
add eax, LABEL_SEG_CODE32
这3句是在计算物理地址,mov word [LABEL_DESC_CODE32+2], ax
是把ax的内容放到byte2和byte3处,后面的类似.
所以这也可以解释为什么在gdt表中,段基址写成0(LABEL_DESC_TEST和LABEL_DESC_VIDEO除外,因为这两个已经明确知道段基址了),因为这个0只是占位子而已,后面的初始化工作,才是真正的设置成正确的段基址.所以,要分清楚编译和运行是不一样的.
; 初始化 32 位代码段描述符
xor eax, eax
mov ax, cs
shl eax, 4
add eax, LABEL_SEG_CODE32
mov word [LABEL_DESC_CODE32 + 2], ax
shr eax, 16
mov byte [LABEL_DESC_CODE32 + 4], al
mov byte [LABEL_DESC_CODE32 + 7], ah
[SECTION .gdt]
; GDT
; 段基址, 段界限 , 属性
LABEL_GDT: Descriptor 0, 0, 0 ; 空描述符
LABEL_DESC_NORMAL: Descriptor 0, 0ffffh, DA_DRW ; Normal 描述符
LABEL_DESC_CODE32: Descriptor 0, SegCode32Len-1, DA_C+DA_32; 非一致代码段, 32
LABEL_DESC_CODE16: Descriptor 0, 0ffffh, DA_C ; 非一致代码段, 16
LABEL_DESC_DATA: Descriptor 0, DataLen-1, DA_DRW ; Data
LABEL_DESC_STACK: Descriptor 0, TopOfStack, DA_DRWA+DA_32; Stack, 32 位
LABEL_DESC_TEST: Descriptor 0500000h, 0ffffh, DA_DRW
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0ffffh, DA_DRW ; 显存首地址
; GDT 结束
- 问题2:计算描述符的宏为什么这样写?
; 宏 ------------------------------------------------------------------------------------------------------
;
; 描述符
; usage: Descriptor Base, Limit, Attr
; Base: dd
; Limit: dd (low 20 bits available)
; Attr: dw (lower 4 bits of higher byte are always 0)
%macro Descriptor 3
dw %2 & 0FFFFh ; 段界限1
dw %1 & 0FFFFh ; 段基址1
db (%1 >> 16) & 0FFh ; 段基址2
dw ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; 属性1 + 段界限2 + 属性2
db (%1 >> 24) & 0FFh ; 段基址3
%endmacro ; 共 8 字节
- 问题3:为什么界限=长度-1??
GdtLen equ $ - LABEL_GDT ; GDT长度
GdtPtr dw GdtLen - 1 ; GDT界限
解释:段界限表达的是段内的最大偏移,而不是段的最大长度。 这个偏移从0开始计数,有点类似于C数组中的index.一个简单的例子,假如一个段有如下内存:
var1 db 0x01 ; 偏移0
var2 db 0x02 ; 偏移1
那么段界限应该是1还是2呢?答案是1,最大偏移是1。
访问段中数据使用:段基址 + 偏移(所以段界限说明的是这个最大偏移)
参考:段界限为什么要减1
保护模式进阶
这一章主要研究如何从保护模式跳回实模式
下面的代码展示了如何从保护模式进入实模式.
我们可以看到,前面那个jmp并没有直接跳转到实模式,而是选择跳转到一个保护模式下的16位代码段,在16位保护模式代码段下的jmp才真正跳入到实模式。
那么,你也许会问,为什么要这么麻烦,我们在实模式下不是直接从16位代码段跳到32位代码段的保护模式下了么,那为什么从保护模式就不能直接跳回去呢?
弄明白这个问题很重要,下面我们慢慢详解。
我们还是先来搞清楚一个概念:段描述符高速缓冲寄存器。
在实模式下,段寄存器含有段值,为访问存储器形成物理地址时,处理器引用相应的某个段寄存器并将其值乘以16,形成20位的段基地址。在保护模式下,段寄存器含有段选择子,如上所述,为了访问存储器形成线性地址时,处理器要使用选择子所指定的描述符中的基地址等信息。为了避免在每次存储器访问时,都要访问描述符表而获得对应的段描述符,从80286开始每个段寄存器都配有一个高速缓冲寄存器,称之为段描述符高速缓冲寄存器或描述符投影寄存器,对程序员而言它是不可见的。每当把一个选择子装入到某个段寄存器时,处理器自动从描述符表中取出相应的描述符,把描述符中的信息保存到对应的高速缓冲寄存器中。此后对该段访问时,处理器都使用对应高速缓冲寄存器中的描述符信息,而不用再从描述符表中取描述符。
这些高速缓冲寄存器在实方式下仍发挥作用,只是内容上与保护模式下有所不同。如上表所示,其中“Y”表示“是”; “N”表示“否”;“B”表示字节;“U”表示向上扩展,“W”表示以字方式操作堆栈。段基地址仍是 32位,其值是相应段寄存器值(段值)乘以16,在把段值装载到段寄存器时刷新。由于其值是16位段值乘上16,所以在实模式下基地址实际上有效位只有 20位。每个段的32位段界限都固定为0FFFFH,段属性的许多位也是固定的。所谓固定是指在实方式下不可设置这些属性值,只能继续沿用保护方式下所设置的值。因此,在准备结束保护模式回到实模式之前,要通过加载一个合适的描述符选择子到有关段寄存器,以使得对应段描述符高速缓冲寄存器中含有合适的段界限和属性。GDT中的描述符Normal就是这样一个描述符,在返回实模式之前把对应选择子Normal加载到DS和ES就是此目的。
CS段的段描述符高速缓冲寄存器的D位属性值为1表示代码当前运行在保护模式,D位属性值为0表示代码当前运行在实模式。而这个D位属性值在实模式下是固定的,也就是不容许改变的,只能在保护模式下改变。我们从上一篇日志看到,进入保护模式的标志是cr0的PE位置1,此时CPU就运行在了保护模式下,因此可以直接加载32位代码段的描述符,同时改变CS段描述符高速缓冲寄存器的D位属性值,这就是为什么可以直接从16位实模式代码段跳转到32位保护模式代码段的原因,因为跳转后的保护模式可以改变D位属性值。但是反过来就出问题了,由于实模式下D位属性值不容许改变,只能在保护模式下的时候就提前将D位属性值置0。那怎么置呢?我们想到一个方法就是先跳转到16位保护模式代码段,这样就可以置D位属性值为0,然后再由此跳入实模式。至此,这个问题我们算是弄明白了~
; 16 位代码段. 由 32 位代码段跳入, 跳出后到实模式
[SECTION .s16code]
ALIGN 32
[BITS 16]
LABEL_SEG_CODE16:
; 跳回实模式:
mov ax, SelectorNormal
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
;ES 附加段寄存器
;CS 代码段寄存器
;SS 堆栈段寄存器
;DS 数据段寄存器
;FS 附加段寄存器
;GS 附加段寄存器
mov eax, cr0
and al, 11111110b
mov cr0, eax
LABEL_GO_BACK_TO_REAL:
jmp 0:LABEL_REAL_ENTRY ; 段地址会在程序开始处被设置成正确的值
Code16Len equ $ - LABEL_SEG_CODE16
; END of [SECTION .s16code]
再来看看下面这一段代码,这个jmp实现了从保护模式跳转到实模式.首先,这个jmp是一个段间跳转,占用5字节(jmp1字节,段地址2字节(:前面的部分),偏移量2字节(:后面的部分)).指令图示
在编译之后,指令的"Segment"部分就是0000,但是在运行过程中,存放此指令的内存单元被mov [LABEL_GO_BACK_TO_REAL+3], ax
修改了,即此时"Segment"部分放置的是ax寄存器中的值.
LABEL_GO_BACK_TO_REAL:
jmp 0:LABEL_REAL_ENTRY ; 段地址会在程序开始处被设置成正确的值