一、16位面临的寻址问题
我们的操作系统在上一章遇到了前进的障碍,那就是没有办法访问1MB以上的内存空间。在16位的运行环境下,所有的段寄存器是16位,在采用分段技术*10h之后,物理地址最大值也只能达到20位。
比如CS取最大值0XFFFF,IP也取最大值0XFFFF,物理地址=0XFFFF*10H+0XFFFF=0X10FFEF.虽然此值是24位,但是由于8086的地址线只有20根,所以这个地址实际上又回卷到了0FFEF.
二、32位的解决方法
1.解决思路
所以,如果要访问1MB以上内存空间,必须要扩展位数。如果我们把地址线扩展到32位,可以访问的内存空间就会达到2^32=4GB。同样,为了方便寄存器能直接存储地址,我们也可以把所有的寄存器都定义成32位。这样一来,8086的分段技术都不需要了,要访问哪个内存地址,直接从寄存器里取就是。
上面的方法是可以的,可是INTEL公司并没有采取这个策略。32位机器中,操作地址的段寄存器(CS,DS,ES,SS,FS,GS)居然仍然是16位的,只有通用寄存器(AX,BX,CX,DX,SP,BP,SI,DI)才扩展成了32位。这是让人颇感意外的结果。
说到寄存器,这里我突然想到需要补充2张8086的寄存器关系图,因为只有搞清楚了寄存器之间的关系,才会深刻理解计算机的运行机制。下图首先对寄存器进行了功能的归类:
8086寄存器种类重要的是,寄存器之间都是有默认固定搭配关系的,我们在汇编语言编程的时候,在没有显性指定寄存器之间的组合关系的情况下,计算机会默认如下组合:
寄存器默认组合接着说32位机器的演化,INTEL公司并没有顺应我的想法,而是仍然将段寄存器保留为16位,那么我们要用这些段寄存器来访问32位的内存地址是不可能办得到的!但是,人家要这样设计(可以理解成为了兼容8086),我们也没有办法姑且接受,那我们应该怎么来面对现实呢?
方法1:将段寄存器两两组合实现32位地址。比如将DS,ES组成一个高16位和低16位的地址用来访问内存数据。理论上讲是可以的,但是这样做就失去了各段寄存器功能的分类意义,因为ES(Extra Segment)就是为了补充DS而额外增设(Extra)的,你这样做,还不如直接就把DS、ES设置成32位更直接。
方法2:将段寄存器映射到内存中来提供32位地址。做个单片机项目的人都知道,我们经常会在有限的ROM空间内建表,表最重要的功能就是实现数据索引,这样我们需要访问数据(如打印LED字符)的时候,操作数据的索引号就行了。表这个概念可以照搬到这里,段寄存器16位不是不够玩吗?可是内存的空间很大啊,你想怎么玩就怎么玩。现在我给你在内存整一片空间来存放32的地址数据,我们需要做的事情就是指定好各段寄存器和这个内存空间的映射关系,这个映射关系就叫索引。比如把CS的映射地址放在逻辑0位,后面依次放DS,ES,SS,FS,GS等等。这样我们就可以定义如下的索引关系:
段寄存器在内存中的映射关系为什么每个段寄存器的映射区长度要定义成8个字节呢?首先,我们的目标是要映射32字节的内存地址,就需要4个字节。除此之外,我们还想明确一下这个我们映射的这个目标地址是用来干啥子的(比如区分程序、数据和堆栈);另外,我还想规定映射目标区有多大,目标区能否支持读写操作,目标区给谁用等等。总之,我想加好多信息进去,就是为了日后管理目标区更方便。好吧,那我就给你这些辅助信息留出4个字节总够了吧?如此一来长度4+4=8字节。
我前面说了,这片内存的开辟是借用了单片机中的“表”概念,所以它终究就是一个表,这个表是用来干什么的?是用来“描述”段寄存器指向32位的目标地址以及一些辅助属性的。结合以上特征,我们就把这段内存区叫全局描述表GDT( Global Descriptor Table)。所谓全局,就是代表这个表是为32位机器整体运行进行寻址服务的。
那么这个GDT的格式怎么定义比较好呢?8个字节啊,是很够用了,我们不妨这样定义吧:
这是INTEL公司定义的格式,可以看出有很多的属性字段哟,但这些字段都是缺一不可的。我们举个简单的例子来说明:在第8-11的4位里面有一个“TYPE”属性,具体它的定义是:
通过对这个字段的定义,就完全可以让用户来严格划分内存区域是存放数据还是程序还是堆栈。比如在一个已经定义存放代码的内存区域,如果我们编程的时候不小心错误地即将要把这个区域的内存值进行修改,在16位模式下是没有约束的,这个错误操作完全可以执行,这样我们就会在错误的道路上越走越远。但是在保护模式下,对不起,CPU会报错进而起到“保护”的作用,这样就督促你自己去好好的检错并规范自己编程行为。又比如我们要设置一个堆栈区,TYPE字段的"E"位就必须置1,这是因为堆栈栈顶指针在内存上是向低处走的,这就叫向下拓展。所以综合方方面面,你可以体会到设计者的良苦用心了吧!
2.继续努力
有了GDT,用16位段寄存器来提供32内存地址的问题就解决了,我们只需要根据段寄存器的逻辑索引号到GDT读出映射的目标地址即可。问题是,GDT是一片内存区,我们要到GDT取数据,首先需要知道它的内存地址啊。前面已说,GDT是我们自己开辟的内存区域,因此需要我们自己指定它的地址,要指定地址只有一种方式:向寄存器里写数据,用专用的寄存器来实现。这里就体现了INTEL公司的思路了,它专门提供了一个叫GDTR的专用寄存器,叫全局描述符表寄存器。这个寄存器除了指定GDT的起始地址之外,它还规定了GDT的大小:
写GDTR有一个专用的指令:LGDT。该指令在实模式和保护模式下都可以执行,用这条指令就可以成功定位GDT在内存中的位置。
3.成功解决
到这里,我们访问32位地址的内存就没有障碍了,方法是把寄存器GDTR和表GDT的内容分别设置好,就能通过操控16位段寄存器(CS,DS,ES,SS,FS,GS)来实现目标。整个流程控制都是由CPU实现的,这种控制模式就叫保护模式。“保护模式”是学习操作系统的第一个难点,但是通过我的这个推理过程发现真的是太简单了。要让32机器成功启动保护模式,还需以下2点:
(1)打开A20地址线。这是一个历史遗留问题,很简单,留给读者自己去查证资料学习。
(2)开启保护模式标记。CR0是32位的寄存器,包含了一系列用于控制处理器操作模式和运行状态的标志位。它的第1位(位0)是保护模式允许位(Protection Enable,PE),是开启保护模式大门的门把手,如果把该位置“1”,则处理器进入保护模式。
(3) 禁用中断。保护模式下中断机制尚未建立,应禁止中断。
当以上工作都准备就绪之后,就可以进入保护模式了。一旦进入保护模式,16位的段寄存器(CS,DS,ES,SS,FS,GS)的数据结构定义就将发生变化:不再是实模式下的具体段地址了,而是一个带有“索引”的逻辑地址,通过这个索引值到GDT中寻找32位的内存起始地址。
这时候,这些段寄存器就有了一个新的称呼:段选择子。我们要访问任何内存数据,就必须首先定义好这个段选择子。
三、程序验证
下面我们就用详细的程序来让机器从16位实模式进入32位保护模式。
1.程序结构
程序结构沿用一直以来我们写的操作系统结构,主要区域如下:
(1)GDT:蓝色部分为本次新增的数据区,这次我们定义了程序段、数据段和堆栈段3个描述符,再加上系统默认的0号描述符,一共4*8=32B,我们把GDT放在0x7e00开始之处。
(2)显存区:进入保护模式之后,我们需要访问这个区域的数据来验证是否能正常读写。
(3)Kernel:内核区,进入保护模式之后,我们定义程序段线性基址是0x00008000。因为我们的操作系统内核程序就是从这里开始放置的。
2. 保护模式演示程序
演示程序沿用“计算机自制操作系统(三):读写磁盘操作”一章中的内容,在操作系统跳出MBR之后,再增加一个步骤STEP3:进入保护模式。进入保护模式之后,我们往显存区域写数据并验证堆栈区是否能正常工作,最终如能顺利打印在屏幕上,则表明在保护模式下,读写内存是成功的。我们先来看程序演示的效果:
下面位该次演示程序的完整源代码:
NUMsector EQU 6 ; 设置读取到的软盘最大扇区编号()
NUMheader EQU 0 ; 设置读取到的软盘最大磁头编号(01)
NUMcylind EQU 0 ; 设置读取到的软盘柱面编号
mbrseg equ 7c0h ;启动扇区存放段地址
newseg equ 800h ;跳出MBR之后的新段地址
dptseg equ 7e0h ;DPT区段地址
jmp start
msgwelcome: db '------Wlecome Jiang Os------','$'
msgstep1: db 'Step1:now is in mbr','$'
msgmem1: db 'Memory Address is---','$'
msgcs1: db 'CS:????H','$'
cylind db 'Cylind:?? $',0 ; 设置开始读取的柱面编号
header db 'Header:?? $',0 ; 设置开始读取的磁头编号
sector db 'Sector:?? $',2 ; 设置从第2扇区开始读
FloppyOK db 'Read OK','$'
Fyerror db 'Read Error' ,'$'
start:
call inmbr
call floppyload
jmp newseg:0 ;通过此条指令跳出MBR区
inmbr:
mov ax,mbrseg ;为显示各种提示信息做准备
mov ds,ax
mov ax,newseg
mov es,ax ;为读软盘数据到内存做准备,因为读软盘需地址控制---ES:BX
call inmbrshow
call showcs
call newline
call newline
call newline
ret
inmbrshow:
mov si,msgwelcome
call printstr
call newline
call newline
mov si,msgstep1
call printstr
call newline
mov si,msgmem1
call printstr
ret
printstr: ;显示指定的字符串, 以'$'为结束标记
mov al,[si]
cmp al,'$'
je disover
mov ah,0eh
int 10h
inc si
jmp printstr
disover:
ret
newline: ;显示回车换行
mov ah,0eh
mov al,0dh
int 10h
mov al,0ah
int 10h
ret
showcs: ;展示CS的值
mov ax,cs
mov dl,ah
call HL4BIT
mov dl, BH
call ASCII
mov [msgcs1+3],dl
mov dl, Bl
call ASCII
mov [msgcs1+4],dl
mov dl,al
call HL4BIT
mov dl, BH
call ASCII
mov [msgcs1+5],dl
mov dl, Bl
call ASCII
mov [msgcs1+6],dl
mov si, msgcs1
call printstr
ret
;-----------------将16进制数字(1位)转换成ASCII码,输入DL,输出DL------
ASCII: CMP DL,9
JG LETTER ;DL>OAH
ADD DL,30H ;如果是数字,加30H即转换成ASCII码
RET
LETTER: ADD DL,37H ;如果是A~F,加37H即转换成ASCII码
RET
;-----------------取出1个字节Byte的高4位和低4位,输入DL,输出BH和BL----------
HL4BIT: MOV DH,dl
MOV BL,dl
SHR DH,1
SHR DH,1
SHR DH,1
SHR DH,1
MOV BH,DH ;取高4位
AND BL,0FH ;取低4位
RET
floppyload:
call read1sector
MOV AX,ES
ADD AX,0x0020
MOV ES,AX ;一个扇区占512B=200H,刚好能被整除成完整的段,因此只需改变ES值,无需改变BP即可。
inc byte [sector+11]
cmp byte [sector+11],NUMsector+1
jne floppyload ;读完一个扇区
mov byte [sector+11],1
inc byte [header+11]
cmp byte [header+11],NUMheader+1
jne floppyload ;读完一个磁头
mov byte [header+11],0
inc byte [cylind+11]
cmp byte [cylind+11],NUMcylind+1
jne floppyload ;读完一个柱面
ret
numtoascii: ;将2位数的10进制数分解成ASII码才能正常显示。如柱面56 分解成出口ascii: al:35,ah:36
mov ax,0
mov al,cl ;输入cl
mov bl,10
div bl
add ax,3030h
ret
readinfo: ;显示当前读到哪个扇区、哪个磁头、哪个柱面
mov si,cylind
call printstr
mov si,header
call printstr
mov si,sector
call printstr
ret
read1sector: ;读取一个扇区的通用程序。扇区参数由 sector header cylind控制
mov cl, [sector+11] ;为了能实时显示读到的物理位置
call numtoascii
mov [sector+7],al
mov [sector+8],ah
mov cl,[header+11]
call numtoascii
mov [header+7],al
mov [header+8],ah
mov cl,[cylind+11]
call numtoascii
mov [cylind+7],al
mov [cylind+8],ah
MOV CH,[cylind+11] ; 柱面从0开始读
MOV DH,[header+11] ; 磁头从0开始读
mov cl,[sector+11] ; 扇区从1开始读
call readinfo ;显示软盘读到的物理位置
mov di,0
retry:
MOV AH,02H ; AH=0x02 : AH设置为0x02表示读取磁盘
MOV AL,1 ; 要读取的扇区数
mov BX, 0 ; ES:BX表示读到内存的地址 0x0800*16 + 0 = 0x8000
MOV DL,00H ; 驱动器号,0表示第一个软盘,是的,软盘。。硬盘C:80H C 硬盘D:81H
INT 13H ; 调用BIOS 13号中断,磁盘相关功能
JNC READOK ; 未出错则跳转到READOK,出错的话则会使EFLAGS寄存器的CF位置1
inc di
MOV AH,0x00
MOV DL,0x00 ; A驱动器
INT 0x13 ; 重置驱动器
cmp di, 5 ; 软盘很脆弱,同一扇区如果重读5次都失败就放弃
jne retry
mov si, Fyerror
call printstr
call newline
jmp exitread
READOK: mov si, FloppyOK
call printstr
call newline
exitread:
ret
times 510-($-$$) db 0
db 0x55,0xaa
;-------------------------------------------------------------------------------
;------------------此为扇区分界线,线上为第1扇区,线下为第2扇区-----------------
;-------------------------------------------------------------------------------
jmp newprogram
gdt_size dw 32-1 ;GDT 表的大小 ;(总字节数减一)
gdt_base dd 0x00007e00 ;GDT的物理地址
msgstep2: db 'Step2:now jmp out mbr','$'
mesmem2: db 'Will Visit Memory Address is---','$'
msgcs2: db '0X:????(CS):XXXX','$'
msgstep3: db 'Step3:now enter protect mode','$'
newprogram:
mov ax,newseg ;跳转到新地址8000H之后,全部寄存器启用新的段地址
sub ax,20h ;要调整一下DS的值才能正确访问新程序中的数据
mov ds,ax ;ds用于打印数据的寻址
call outmbr
call showcsnew
call showprotect
mov ax,dptseg
mov es,ax ;es用于gpt区寻址 gpt存放起始地址:0x00007e00h
call createdpt
jmp next
;创建DPT子程序
createdpt:
lgdt [gdt_size] ;将DPT的地址和大小写入gdtr生效 默认DS
;创建0#描述符,它是空描述符,这是处理器的要求
mov dword [es:0x00],0x00
mov dword [es:0x04],0x00
;创建#1描述符,保护模式下的代码段描述符
mov dword [es:0x08],0x8000ffff
mov dword [es:0x0c],0x00409800
;创建#2描述符,保护模式下的数据段描述符(文本模式下的显示缓冲区)
;mov dword [es:0x10],0x8000ffff
;mov dword [es:0x14],0x0040920b
mov dword [es:0x10],0x0000ffff ;(把DS的基地址定义为0)
mov dword [es:0x14],0x00c09200 ; (标志位G=1,表示以KB为单位)
;创建#3描述符,保护模式下的堆栈段描述符
mov dword [es:0x18],0x00007a00
mov dword [es:0x1c],0x00409600
ret
outmbr:
call newlinenew
call newlinenew
mov si,msgstep2
call printstrnew
call newlinenew
mov si,mesmem2
call printstrnew
ret
showprotect:
call newlinenew
call newlinenew
mov si,msgstep3
call newlinenew
call printstrnew
call newlinenew
ret
showcsnew: ;展示CS的值
mov ax,cs
mov dl,ah
call HL4BITnew
mov dl, BH
call ASCIInew
mov [msgcs2+3],dl
mov dl, Bl
call ASCIInew
mov [msgcs2+4],dl
mov dl,al
call HL4BITnew
mov dl, BH
call ASCIInew
mov [msgcs2+5],dl
mov dl, Bl
call ASCIInew
mov [msgcs2+6],dl
mov si, msgcs2
call printstrnew
ret
printstrnew: ;显示指定的字符串, 以'$'为结束标记
mov al,[si]
cmp al,'$'
je disovernew
mov ah,0eh
int 10h
inc si
jmp printstrnew
disovernew:
ret
newlinenew: ;显示回车换行
mov ah,0eh
mov al,0dh
int 10h
mov al,0ah
int 10h
ret
;-----------------将16进制数字(1位)转换成ASCII码,输入DL,输出DL------
ASCIInew: CMP DL,9
JG LETTERnew ;DL>OAH
ADD DL,30H ;如果是数字,加30H即转换成ASCII码
RET
LETTERnew: ADD DL,37H ;如果是A~F,加37H即转换成ASCII码
RET
;-----------------取出1个字节Byte的高4位和低4位,输入DL,输出BH和BL----------
HL4BITnew: MOV DH,dl
MOV BL,dl
SHR DH,1
SHR DH,1
SHR DH,1
SHR DH,1
MOV BH,DH ;取高4位
AND BL,0FH ;取低4位
RET
next:
in al,0x92 ;打开A20地址线
or al,0000_0010B
out 0x92,al
cli ;保护模式下中断机制尚未建立,应禁止中断
mov eax,cr0 ;打开保护模式开关
or eax,1
mov cr0,eax
;进入保护模式... ...
jmp dword 0x0008:inprotectmode-512 ;16位的描述符选择子:32位偏移;这里需要扣除掉512B的MBR偏移量
[bits 32]
inprotectmode:
;在屏幕上显示"Protect mode",验证保护模式下的数据段设置正确
mov ax,00000000000_10_000B ;加载数据段选择子(0x10)
mov ds,ax
mov byte [0xb8000+20*160+0x00],'P' ;屏幕第20行开始显示
mov byte [0xb8000+20*160+0x01],0x0c
mov byte [0xb8000+20*160+0x02],'R'
mov byte [0xb8000+20*160+0x03],0x0c
mov byte [0xb8000+20*160+0x04],'O'
mov byte [0xb8000+20*160+0x05],0x0c
mov byte [0xb8000+20*160+0x06],'T'
mov byte [0xb8000+20*160+0x07],0x0c
mov byte [0xb8000+20*160+0x08],'E'
mov byte [0xb8000+20*160+0x09],0x0c
mov byte [0xb8000+20*160+0x0a],'C'
mov byte [0xb8000+20*160+0x0b],0x0c
mov byte [0xb8000+20*160+0x0c],'T'
mov byte [0xb8000+20*160+0x0d],0x0c
mov byte [0xb8000+20*160+0x0e],'-'
mov byte [0xb8000+20*160+0x0f],0x0c
mov byte [0xb8000+20*160+0x10],'M'
mov byte [0xb8000+20*160+0x11],0x0c
mov byte [0xb8000+20*160+0x12],'O'
mov byte [0xb8000+20*160+0x13],0x0c
mov byte [0xb8000+20*160+0x14],'D'
mov byte [0xb8000+20*160+0x15],0x0c
mov byte [0xb8000+20*160+0x16],'E'
mov byte [0xb8000+20*160+0x17],0x0c
mov byte [0xb8000+20*160+0x18],' '
mov byte [0xb8000+20*160+0x19],0x0c
mov byte [0xb8000+20*160+0x1a],'!'
mov byte [0xb8000+20*160+0x1b],0x0c
mov byte [0xb8000+20*160+0x1c],'!'
mov byte [0xb8000+20*160+0x1d],0x0c
mov byte [0xb8000+20*160+0x1e],'!'
mov byte [0xb8000+20*160+0x1f],0x0c
;通过堆栈操作,验证保护模式下的堆栈段设置正确
mov ax,00000000000_11_000B ;加载堆栈段选择子
mov ss,ax ;7a00-7c00为此次设计的堆栈区
mov esp,0x7c00 ;7c00固定地址为栈底,
;7a00为栈顶的最低地址(通过载堆栈段选择子的段界限值设置)
mov ebp,esp ;保存堆栈指针
push byte '#' ;压入立即数#(字节)后,执行push指令,esp会自动减4
sub ebp,4
cmp ebp,esp ;判断ESP是否减4
jnz over ;如果堆栈工作正常则打印出pop出来的值和其它字符
pop eax
mov byte [0xb8000+22*160+0x00],'S'
mov byte [0xb8000+22*160+0x01],0x0c
mov byte [0xb8000+22*160+0x02],'t'
mov byte [0xb8000+22*160+0x03],0x0c
mov byte [0xb8000+22*160+0x04],'a'
mov byte [0xb8000+22*160+0x05],0x0c
mov byte [0xb8000+22*160+0x06],'c'
mov byte [0xb8000+22*160+0x07],0x0c
mov byte [0xb8000+22*160+0x08],'k'
mov byte [0xb8000+22*160+0x09],0x0c
mov byte [0xb8000+22*160+0x0a],':'
mov byte [0xb8000+22*160+0x0b],0x0c
mov byte [0xb8000+22*160+0x0c],al ;打印出pop出来的值
mov byte [0xb8000+22*160+0x0d],0x0c
mov byte [0xb8000+22*160+0x0e],','
mov byte [0xb8000+22*160+0x0f],0x0c
mov byte [0xb8000+22*160+0x10],'O'
mov byte [0xb8000+22*160+0x11],0x0c
mov byte [0xb8000+22*160+0x12],'K'
mov byte [0xb8000+22*160+0x13],0x0c
mov byte [0xb8000+22*160+0x14],'!'
mov byte [0xb8000+22*160+0x15],0x0c
over :
jmp $
3. 保护模式程序解析
在上面的演示程序中,我们把涉及保护模式的部分拿出来单独分析教学:
gdt_size dw 32-1 ;GDT 表的大小 ;(总字节数减一)
gdt_base dd 0x00007e00 ;GDT的物理地址
lgdt [gdt_size] ;将DPT的地址和大小写入gdtr生效 默认DS
;GDT创建
;创建0#描述符,它是空描述符,这是处理器的要求
mov dword [es:0x00],0x00
mov dword [es:0x04],0x00
;创建#1描述符,保护模式下的代码段描述符
mov dword [es:0x08],0x8000ffff
mov dword [es:0x0c],0x00409800
;创建#2描述符,保护模式下的数据段描述符(文本模式下的显示缓冲区)
mov dword [es:0x10],0x0000ffff ;(把DS的基地址定义为0)
mov dword [es:0x14],0x00c09200 ; (标志位G=1,表示以4KB为单位)
;创建#3描述符,保护模式下的堆栈段描述符
mov dword [es:0x18],0x00007a00
mov dword [es:0x1c],0x00409600
in al,0x92 ;打开A20地址线
or al,0000_0010B
out 0x92,al
cli ;保护模式下中断机制尚未建立,应禁止中断
mov eax,cr0 ;打开保护模式开关
or eax,1
mov cr0,eax
;进入保护模式... ...
jmp dword 0x0008:inprotectmode ;16位的描述符选择子:32位偏移
[bits 32]
inprotectmode:
;在屏幕上显示"Protect mode",验证保护模式下的数据段设置正确
mov ax,00000000000_10_000B ;加载数据段选择子(0x10)
mov ds,ax
mov byte [20*160+0x00],'P' ;屏幕第20行开始显示
;通过堆栈操作,验证保护模式下的堆栈段设置正确
mov ax,00000000000_11_000B ;加载堆栈段选择子
mov ss,ax ;7a00-7c00为此次设计的堆栈区
mov esp,0x7c00 ;7c00固定地址为栈底,
;7a00为栈顶的最低地址(通过载堆栈段选择子的段界限值设置)
mov ebp,esp ;保存堆栈指针
push byte '#' ;压入立即数#(字节)后,执行push指令,esp会自动减4
sub ebp,4
cmp ebp,esp ;判断ESP是否减4
jnz over ;如果堆栈工作正常则打印出pop出来的值和其它字符
pop eax
mov byte [22*160+0x00],'S'
mov byte [22*160+0x01],0x0c
over :
jmp $
没错,就是上面的20行左右程序就可以进入保护模式了。所以,保护模式绝对不会是非常令人费解的。这里几个关键点加以说明一下:
(1)GDT表地址和大小定义:通过lgdt命令写入GDTR寄存器,这里定义为:0x00007e00。大小为:4*8=32B。
(2)GDT表描述符定义:
(a) #1描述符---代码段CS描述符,该描述符的低32位是0x8000ffff,高32位是0x00409800。表示该段的基本情况为:线性基地址为0x00008000---这不奇怪,因为我们的操作系统是从MBR出来之后跳到8000地址的;段界限为0xffff ;粒度为字节(G=0)(即该段总长度最长是0xffffB,那么该段的最高地址就是0x8000+0xffff=0x17ffff)。属于存储器的段(S=1);这是一个32位的段(D=1);该段目前位于内存中(P=1);段的特权级为0(DPL=00;这是一个只能执行的代码段(TYPE=1000),也即0x8000---0x17ffff之间的内存是不能写的,如果发生,CPU会报错宕机。
(b) #2描述符---数据段DS描述符,该描述符的低32位是0x0000ffff,高32位是0x00c09200。表示该段的基本情况为:线性基地址为0x00000000;段界限为0xffff ;粒度为4KB为单位(G=1)(即该段总长度最长是0xffff*4KB);属于存储器的段(S=1);这是一个32位的段(D=1);该段目前位于内存中(P=1);段的特权级为0(DPL=00;这是一个可读可写、向上扩展的数据段,(TYPE=0010)。
(c)#3描述符---堆栈段SS描述符,该描述符的低32位是0x00007a00 ,高32位是0x00409600。表示该段的基本情况为:线性基地址为0x00000000;段界限为0x7A00;粒度为字节(G=0)(即栈顶最低值是7a00);属于存储器的段(S=1);这是一个32位的段(D=1);该段目前位于内存中(P=1);段的特权级为0(DPL=00;这是一个可读可写、向下扩展的数据段,即堆栈段(TYPE=0110)。
可以看到,一般数据类的段(包括堆栈,堆栈实际也是属于数据区域)定义线性基地址为0,这样做的原因是以后的应用程序在访问内存的时候就可以用32位的绝对偏移值来代替相对偏移值。比如我们要在屏幕坐标(x,y)处显示字符,就可以用偏移地址:0xb8000+160*x+y(一行80个字符,1个字符占2B)来控制;当然我们也可先定义DS的线性基地址为0x000b8000,但就必须用相对偏移地址:0x0+160*x+y来控制。这就是说后面写程序的人必须清楚前面的人是怎么定义DS线性基地址的,这就为程序之前的相互调用和融合制造了障碍。更严重的后果是,如果后面有人需要用DS来访问0xb8000以下的地址就没有办法了,是人的话还可以通过重新再定义一个DS描述符来解决。但是如果是C语言编译后的汇编程序和机器代码呢?这个时候我们要融合C语言编写的程序就会失败!所以,我们一定要养成良好的编程约定和习惯。
这些段定义中比较重要的一点就是段界限,因为后面在程序运行中如果出现某段定义的长度不够,CPU会宕机出错。我在程序调试中就曾遇到这种情况,花了很大的功夫才找到原因。
(2) 实模式向保护模式程序切换:程序中关键字[bits 32]是区分实模式和保护模式的程序边界,最后一条指令:jmp dword 0x0008:inprotectmode是在实模式下编译的,它是切换到保护模式唯一方式。dword字段的含义是指定跳转之后的偏移地址要用32位来定义,也即会将inprotectmode的偏移地址装入EIP。jmp到0x0008的含义是:CS=0x0008,这里就务必要注意,这个时候因为已经打开了保护模式,那么CS就不再是段地址的含义了,而是保护模式下的含义,它代表的是要将段选择子数据设置成0x0008,也即如下的数据结构:
0x0008=0000000000001_000,所以,CS描述符的索引号就是01,那么对应到我们前面创建的#1描述符地址是:0x00008000,这样就保证了程序下一条执行的基地址是:0x00008000。把这个基地址和上面的EIP相加,就得到了最终的32位程序目标地址。也就会从标号inprotectmode的地方开始运行程序。
(3) 保护模式下的内存地址访问:进入保护模式之后,要访问内存首先要定义DS(ES)的值,同上面的CS一样,定义DS(ES)段选择子,程序中可以看到DS我们是这样定义的:00000000000_10_000B。很明显,这个描述符的索引号就是10,那么对应到我们前面创建的#2描述符地址是:0x0000B800。把这个基地址和程序里面[]内的偏移地址相加,就得到了最终的32位数据目标地址。
(4) 保护模式下的堆栈区是否正常工作:这是一个非常重要的地方,关系到32位系统能否正常启动运行。在16位实模式下,一般我们不需要设置SS和SP的值,也能在程序中正常的使用PUSH和POP指令,这是因为机器启动之后,无论SS或SP的初始值是多少,都不影响堆栈区工作,顶多是堆栈区在内存中的位置不合理而已。因为,如果你没有特别注意堆栈区的设置,很可能使用PUSH和POP指令的时候,会把数据写到代码区或者数据区了。在进入32保护模式后,我们要做的第一件事其实就应该是设置SS和ESP的值,因为如果不先把SS选择子对应的内存区域找到,使用PUSH和POP指令的时候,CPU根本就不知道SS段地址的值是多少,这样就会报错宕机。
具体到本例,我们来分析32位保护模式下堆栈区的工作原理:
(a) 我们通过GDT设置堆栈区基地址是:0x00000000,这样当我们执行完下面两句指令:
mov ax,00000000000_11_000B ;加载堆栈段选择子
mov ss,ax ;7a00-7c00为此次设计的堆栈区
(b) 通过段选择子,SS段指定的32位基地址就: 0x00000000,再执行:
mov esp,0x7c00 ;7c00固定地址为栈底,
(c) esp寄存器的32位偏移地址就是:0x00007c00。这个时候,堆栈操作默认寄存器搭配是SS:ESP, 那么现在这个搭配地址就是:0x00000000+0x00007c00=0x00007c00。也即0x00007c00就是我们的栈底地址。
(d) 执行PUSH '#':首先是ESP=ESP-4,也即:ESP=0x00007c00-4=0x00007bfc。这个时候,CPU就要做段界限的判断了,由于我们在GDT中对该段的界限值设置的是0x7a00,只有当ESP>0x7a00的时候,这个PUSH指令才能继续运行,否则报错!现在满足条件,那么立即数'#'就可以进栈,把'#'值写入栈顶,目前由于是空栈,那么栈顶就是栈底:0x00007c00。写入数据之后,新的栈顶就变成:0x00007bfc。
(e) 从以上分析过程可知,此次堆栈空间设置的是:0x7c00---0x7a00(再次强调堆栈是向下拓展的内存方向),堆栈大小为512B,对多可以连续PUSH的次数是:512/32=16。
PS:堆栈是计算机里面一个非常重要的概念,对计算机原理不太了解的人在学习高级语言的时候一般会对堆栈有一个误解:因为堆栈是所谓的“先进先出”,所以很多人总以为堆栈是一种“数据结构”,在计算机里面有一种叫栈的结构或电路。其实,堆栈是一个虚构的概念,在计算机里面没有一种叫栈的东西,它就是普通的内存空间。堆栈最大的作用就是用来做数据现场保护,比如我们在编程的过程中,突然遇到有个需要马上处理的任务,而在处理该任务过程中又会对寄存器AX的值进行修改,但是现在AX里面的数据对用户来说非常重要,我们需要把它暂存起来,以便任务处理完成之后接着用。这个时候,我们实际上可以自己找个内存地址把AX暂存起来,下次回来的时候再把AX从内存里取出来就是。计算机设计者一看用户这样的需求比较多,那就干脆给用户提供这样一种数据暂存的指令:push和pop:当用户调用push ax指令的时候,计算机就直接把AX放入内存某地址,最重要的是这个内存地址是用户通SS:SP (32位下是SS:ESP)来提前指定好了的。当用户想取出这个数据的时候,就用pop ax指令,这个时候计算机会自动在之前push存入的内存处将数据送至AX,因为一直是SP(或ESP)在负责跟踪记录push进入的内存地址(专业术语叫栈顶)。有了这套机制,用户就不用每次暂存数据的时候都去操心我该把数据放在哪里,下次取出来的时候又去哪里取。整个这套数据现场保护的管理机制,就叫做堆栈。堆栈数据和普通数据最大的不同在于:它的存储顺序是从内存的高端地址向低端地址方向向下拓展的,也即是当你连续push数据的时候,它们的存放位置会连续递减。理论上讲堆栈也可以做成传统的向上扩展啊,但为什么规定是向下呢,有兴趣的话可以再深入研究一下,相信有它的道理。
最后,综合以上堆栈工作原理,不难理解push和pop的等效指令:
push eax = esp=esp-4 + mov [ss:esp], eax ;注意先后关系,先挪栈顶指针
pop eax = mov eax, [ss:esp] + esp=esp+4 ;注意先后关系,后挪栈顶指针
说了这么多堆栈的原理,其实主要是为强调很重要的一点:汇编语言编程者在使用push或pop指令的时候,必须要成对出现的!这是因为push指令会改变栈顶指针esp之值,如果不用pop指令或直接操作esp,使之恢复成push之前的栈顶位置,那么就会出现一个结果:程序跑飞,CPU宕机。这点是汇编语言编程最大容易出现的问题,因为我就曾无数次出现过这种悲剧,排查问题原因也是相当的困难。
举例说明:比如现在有一条 call subpro 指令需要调用函数subpro ,CPU在进入函数之前会将当前IP寄存器的地址数据push进堆栈(这是一个隐形操作),那么进入函数之后,栈顶的数据就是IP之值。这样做的目的是CPU在执行完函数之后遇到ret指令之时,会从栈顶取出IP之值,程序便正常返回了。但是如果我们在subpro这个函数里面有一个push eax的操作,而在函数结束ret指令之前忘记pop了(或者用esp=esp+4也行),相当于没有成对使用push指令,那么CPU遇到返回指令ret之时,从栈顶取出的值就不再是原来IP之值,而是eax之值,那就会导致程序乱跑或宕机!