12 实模式到保护模式 下
参考
https://www.cnblogs.com/wanmeishenghuo/tag/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/
https://blog.51cto.com/13475106/category6.html


这一节,我们深入研究一下保护模式:定义显存段
为了显示数据,必须存在两大硬件:显卡+显示器
显卡:
1、为显示器提供需要显示的数据
2、控制显示器的模式和状态
显示器:
1、将目标数据以可见的方式呈现在屏幕上
显存的概念和意义:
1、显卡拥有自己内部的数据存储器,简称显存
2、显存在本质上和普通内存无差别,用于存储目标数据
3、操作显存中的数据将导致显示器上的内容改变
显存在本质上和内存没有差别,只不过它存在与显卡的内部
显卡的工作模式有文本模式和图形模式:

在不同的模式下,显卡对显存内容的解释是不同的
可以使用专属指令或者int 0x10中断改变显卡的工作模式
在文本模式下:
显存的地址范围映射为:[0xB8000,0xBFFFF],直接往这个地址写数据,显示器上就会显示数据

一屏幕可以显示25行,每行80个字符,每个字符占两个字节,一个字节为实际的字符(低字节),一个字节为字符的属性(高字节)

下面我们来完成在屏幕的指定位置上打印指定字符串的功能,在保护模式下打印指定内存中的字符串,步骤如下:
定义全局堆栈段(.gs),用于保护模式下的函数调用
定义全局数据段(.dat),用于定义只读数据(D.T.OS!)
利用对显存段的操作定义字符串打印函数(PrintString)
汇编知识:
32位保护模式下的乘法操作(mul):
被乘数放到AX寄存器
乘数放到通用寄存器或内存单元(16位)
相乘的结果放到EAX寄存器中
再论$$和$:
$表示当前行相对于代码起始位置处的偏移量
$$表示当前代码节(section)的起始位置
下面直接给出打印字符串的程序:
%include "inc.asm"
org 0x9000
jmp CODE16_SEGMENT
[section .gdt]
; GDT definition
; 段基址, 段界限, 段属性
GDT_ENTRY : Descriptor 0, 0, 0
CODE32_DESC : Descriptor 0, Code32SegLen - 1, DA_C + DA_32
VIDEO_DESC : Descriptor 0xB8000, 0x07FFF, DA_DRWA + DA_32
DATA32_DESC : Descriptor 0, Data32SegLen - 1, DA_DR + DA_32
STACK_DESC : Descriptor 0, TopOfStackInit, DA_DRW + DA_32
; GDT end
GdtLen equ $ - GDT_ENTRY
GdtPtr:
dw GdtLen - 1
dd 0
; GDT Selector
Code32Selector equ (0x0001 << 3) + SA_TIG + SA_RPL0
VideoSelector equ (0x0002 << 3) + SA_TIG + SA_RPL0
Data32Selector equ (0x0003 << 3) + SA_TIG + SA_RPL0
StackSelector equ (0x0004 << 3) + SA_TIG + SA_RPL0
; end of [section .gdt]
TopOfStackInit equ 0x7c00
[section .dat]
[bits 32]
DATA32_SEGMENT:
DTOS db "D.T.OS!", 0
DTOS_OFFSET equ DTOS - $$
HELLO_WORLD db "Hello World!", 0
HELLO_WORLD_OFFSET equ HELLO_WORLD - $$
Data32SegLen equ $ - DATA32_SEGMENT
[section .s16]
[bits 16]
CODE16_SEGMENT:
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, TopOfStackInit
; initialize GDT for 32 bits code segment
mov esi, CODE32_SEGMENT
mov edi, CODE32_DESC
call InitDescItem
mov esi, DATA32_SEGMENT
mov edi, DATA32_DESC
call InitDescItem
; initialize GDT pointer struct
mov eax, 0
mov ax, ds
shl eax, 4
add eax, GDT_ENTRY
mov dword [GdtPtr + 2], eax
; 1. load GDT
lgdt [GdtPtr]
; 2. close interrupt
cli
; 3. open A20
in al, 0x92
or al, 00000010b
out 0x92, al
; 4. enter protect mode
mov eax, cr0
or eax, 0x01
mov cr0, eax
; 5. jump to 32 bits code
jmp dword Code32Selector : 0
; esi --> code segment label
; edi --> descriptor label
InitDescItem:
push eax
mov eax, 0
mov ax, cs
shl eax, 4
add eax, esi
mov word [edi + 2], ax
shr eax, 16
mov byte [edi + 4], al
mov byte [edi + 7], ah
pop eax
ret
[section .s32]
[bits 32]
CODE32_SEGMENT:
mov ax, VideoSelector
mov gs, ax
mov ax, StackSelector
mov ss, ax
mov ax, Data32Selector
mov ds, ax
mov ebp, DTOS_OFFSET
mov bx, 0x0C
mov dh, 12
mov dl, 33
call PrintString
mov ebp, HELLO_WORLD_OFFSET
mov bx, 0x0C
mov dh, 13
mov dl, 31
call PrintString
jmp $
; ds:ebp --> string address
; bx --> attribute
; dx --> dh : row, dl : col
PrintString:
push ebp
push eax
push edi
push cx
push dx
print:
mov cl, [ds:ebp]
cmp cl, 0
je end
mov eax, 80
mul dh
add al, dl
shl eax, 1
mov edi, eax
mov ah, bl
mov al, cl
mov [gs:edi], ax
inc ebp
inc dl
jmp print
end:
pop dx
pop cx
pop edi
pop eax
pop ebp
ret
Code32SegLen equ $ - CODE32_SEGMENT
对上述程序做一个解析:
第12行定义了显存段,这个段可以直接根据起始地址和段界限给出
第13行定义了32位的数据段,其中的起始地址需要在运行时计算,因为具体的起始物理地址和段寄存器有关,而段界限可以在编译时期计算出来
第14行定义了保护模式下的堆栈段
定义了段描述符后必然要定义相应的段选择子,第27、28、29分别定义了对应上述三个段的段选择子。
保护模式下的堆栈栈顶依然定义为0x7c00。
32位数据段中(35-43行)定义了一些需要打印的字符串。
16位实模式下的代码和上一节几乎一样,这一节我们需要在运行期初始化两个段描述符中的基地址,因此,我们将初始化的过程封装成了函数InitDescItem,这个函数需要两个参数,esi代表代码段或者数据段的标签,edi代表段描述符的标签。有了这个函数之后,55-63行就可以方便的调用这个函数来初始化段描述符中的基地址了。
下面进入32位代码段,从111行开始,114-121行,分别将相应的段选择子存入相应的段寄存器中,显存段存入了gs,堆栈段存入了ss,数据段存入了ds。当需要用到堆栈时,CPU自动根据ss和sp的值计算真正的物理地址,sp在16位代码中初始化为了栈顶,虽然在16位代码中有函数调用,但是向32位代码跳转时这些函数已经全部返回了,因此,在32位代码的起始处,sp还是指向栈顶的。当取32数据段中的字符时,我们显式的使用ds作为段寄存器。当向显存写数据时,我们显式的使用gs作为段寄存器,这样可以保证地址计算不会出错。有些指令会有默认的段寄存器,但是为了保险我们显式的指定。这在打印函数中会看到。
32位代码段设置完几个段寄存器后就开始调用打印函数了,先是将参数写入相应的寄存器,然后调用函数。我们分析一下130-135行的调用过程:
第130行将字符串“Hello World!”的起始地址相对于32位数据段的偏移量存入ebp,然后在其他几个寄存器存入打印属性和打印的行和列,然后调用打印函数。
下面进入139-172行打印字符串的函数,它是在32位代码段中的,这个函数接受三个参数,ds:ebp存放字符串地址,bx存放属性,dx存放行和列,也就是要打印在第几行第几列。
我们看一下具体的打印过程:
142-147行将一些寄存器先保存起来,第150行中将目标地址中的字符取出来,目标地址是根据 段+偏移 的方式算出来的,mov cl, [ds:ebp]指令中,我们显式的指明段寄存器为ds,ds中存放的是32位数据段的选择子,这也是我们在前面初始化好了的,这样可以保证计算出的字符的地址是正确的。151行判断要打印的字符是否为0,为0就不打印了,跳到end,不为零的话就继续根据行和列的值计算出地址(这个地址是相对于显存起始地址的偏移量),把这个偏移量存入edi寄存器, 第160行使用指令mov [gs:edi], ax将字符写入显存中,这里的显存物理地址是根据gs和edi计算出来的,我们显式的指明gs作为段选择子,其中存放的是显存段的选择子,这也是我们在前面初始化好了的,这样可以保证写入到正确的位置。
打印字符串的效果如下:

我们来看一下92-108行,这一段代码作用是计算某个段的物理基地址,并写入到段描述符中。esi存放段标签,edi存放的是段描述符标签。
假设现在esi存放了DATA32_SEGMENT的地址,edi存放的是DATA32_DESC。计算段的物理基地址时使用了eax(里面是cs的值),这样计算出了物理地址,向段描述符中写时使用的是mov byte [edi + 4], al 指令,edi中的值是相对于0x9000计算出来的值,而真正的物理地址还需要根据ds段寄存器的值计算出来(此时处于16位实模式,段寄存器存放的就是段基地址),此时的ds中的值必须和cs中的值一样才行,这在48-51行也保证了,如果ds的值不等于cs的话,程序会发生错误(已经实验验证)。
在boot.asm程序中,我们也是将cs,ds,ss等段寄存器弄成了一样的值,在加载loader.asm时,我们只给出了偏移地址,不管段基址用哪一个计算,结果都是一样的,我们将loader加载到了0x9000处,如果段寄存器中的值是0的话,那就真正加载到了物理地址的0x9000处,boot.asm有一句跳转到指定地址的指令jnb 0x9000,如果段寄存器为0,它就可以跳到物理地址0x9000处,和实际的加载地址正好对应上。如果段寄存器不为0,它就要根据cs寄存器计算真正的物理地址,而如果cs不为0,则ds和ss等也不为0(它们三个是一样的值,代码中有赋值操作),而计算出来的物理加载地址也就不是0x9000,但是不管是多少,只要保证jnb跳转时的偏移地址和加载loader.asm时的偏移地址是一样的就行(程序中都是0x9000),这样就不会出错,因为段寄存器中的值都是一样的,计算出来的物理地址肯定也是一样的。
到了loader.asm中,又对几个段寄存器进行了一次操作,还是要保证cs,ds,ss的值是一样的,这样就跟上面标红的一段对应起来了,使用cs计算一个段的真实物理起始地址可以得到正确的值(这个值本应该按ds计算,因为加载loader.asm是按ds计算的,但是cs和ds相等,所以结果一样),使用ds计算段描述符的起始地址也可以得到描述符正确的值,mov byte [edi+4], al中默认使用ds作为段寄存器。标签的值是相对于代码的起始地址0x9000计算出来的地址,如果段寄存器都为0,标签的值等于真实的物理地址。如果段寄存器不为0,只要保证它们在实模式下都相等也可以,这样它们也算有一个统一的“0”地址。
将编译后生成的data.img放到windows下的虚拟机路径,选择从这个光驱文件移动,也可看到如下结果:

总结:

posted on 2020-12-02 11:09 lh03061238 阅读(156) 评论(0) 收藏 举报
浙公网安备 33010602011771号