# x86
x86指的是x86系列的处理器. IA32,X86-32指的是32位的x86系列处理器.
# 32位模式扩展的寄存器
4个通用寄存器被扩展为32位:
- ax -> eax
- bx -> ebx
- cx -> ecx
- dx -> edx 4个指针寄存器被扩展为32位.
- si -> esi
- di -> edi
- bp -> ebp
- sp -> esp
eax,ebx,ecx,edx的低16位的ax,bx,cx,dx依然可以拆分使用,但是高16位不能拆分.
mov edx,0xf000c000
mov dx,0 ;EDX= 0xf000000
add edx,0xcccc ;EDX=0xf000cccc
指令指针寄存器IP被扩展成32位EIP
标志寄存器FLAGS被扩展成EFLAGS
TIP
段寄存器新增了FS和GS, 但是所有的段寄存器都没有被扩展, 都依然是16位的.
# 16位对比32位的内存寻址方式
16位: 32位:
TIP
16位方式中, 不允许使用栈顶指针寄存器sp作为偏移量. 而32位方式中可以使用esp作为偏移量
# 现代CPU的优化
流水线: 将指令执行拆分为多个步骤:比如,取指,译指,执行. 当前指令在取指时,可能还同时执行着上一条指令.
高速缓存技术: 在内存中和CPU之间引入高速缓存, 利用程序的局部性原理, 之前使用的内存单元可能被再次使用, 相邻的内存单元也可能使用, 读取程序时顺序读取多条指令等等. 每次读取内存时不只是读取一个内存单元,而是读取多个内存单元,并放入高速缓存中, 每次CPU访问首先访问高速缓存, 如果有,直接使用高速缓存的, 没有则从内存中获取,然后再写入缓存.
乱序执行技术: 多条指令之间可以并行执行
mov eax,[mem1] add eax,[mem2]
TIP
一条指令可以进一步的拆分成微指令, 比如第一条语句首先从内存读,然后放入eax, 干了两件事, 第二条指令从内存中读,然后与eax相加. 也是两件事, 实际上可以在mem1读取完毕后,放入到eax的同时,读取mem2到一个临时寄存器. 保证总线没有空闲
寄存器重命名技术. 有时候可能同时使用一个寄存器做多个不想干的事情.
;事情1 mov eax,[mem1] shr eax,3 mov [mem2], eax ;事情2 mov eax,[mem1] shr eax,3 mov [mem2], eax
因为都是使用了一个寄存器导致了这些指令无法并行执行, 实际上CPU可以检查到后, 直接下面的eax更换为一个临时寄存器. 使这些指令可以并行
分支预测技术 在流水线的执行流程中, 有一个指令队列, 提前预取一些指令. 但是如果执行的指令是跳转指令,比如说jmp就需要清空流水线.代价很大. 因此能不能对分支进行预测呢?
对于循环来说, 分支预测成功的概率很大. 分支预测是在CPU中的一个ETB实现的.
# 全局描述符表GDT和全局描述符表寄存器GDTR
# GDT
GDT(全局描述符表)存储着段的描述信息, 比如段的起始地址,段的大小等, 因此叫做全局描述符表. 一个描述表项占用8字节.
# GDTR
GDTR寄存器处于CPU内部, 在80386CPU中这个寄存器是48位的, d47~d16这32位保存的是全局描述符表的线性基地址(内存看做线性的依次为每一个内存单元编制), d15~d0位这16位保存的是全局描述符表的边界(相对于起始地址的偏移量, 数值上等于全局描述符大小-1)
因此GDT表项最大为64KB, 因为一个表项占8个字节, 则最大可以有8192个表项,实际上用不了这么多.
因为进入保护模式之前,就必须设置好GDT表, 因此我们需要设置好GDTR寄存器.
因为实模式最大寻址模式只有1MB, 因此只能将GDT表设置在0~FFFF的内存单元中.
实际上当我们进入到保护模式后,通过lgdt指令
可以重新设置GDT表的位置.
# 思考题: 当GDTR寄存器的数值如下, 求GDTR的物理地址和大小?
物理地址为: 0x00007E00 GDT大小为: 513字节(0x200 = 512, 512+1 = 513) 段描述表项个数: 513/8 = 64个
TIP
描述符表中偏移值最大为FFFF,即65535,即64KB-1, 这16位的值是边界值,而不是实际大小. 当边界为FFFF时,表示描述符大小为1MB, 因此按照每8字节一个表项,该16位寄存器的值应该是一个奇数?
# 描述符的分类
GDT表中,每一个描述符有不同的分类.
- 存储器的段描述符
- 系统的描述符:
- 系统的段描述符
- 系统的门描述符
如何区分描述符的分类 当通过一个段选择子,找到GDT表中的一个表项后:
- 通过d44位(S标志)如果该为位0,则为系统描述符,则后面的43~40位(TYPE),用来指示是门描述符还是系统的段描述符.
- 如果d44位(S标志)如果该位为1,则为内存段描述符, 后面的43~40位(TYPE),用来区分是数据段还是代码段.
# 存储器的段描述符格式
当类型为存储器的段描述符时, 8字节的表项的各个位的含义如下:
TIP
- d44位就是高16位的d12位, 而后面的d11~d8位就是TYPE
- X位的含义, x代表的是executable(可执行的).x=0,表示当前内存段是数据段. x=1则是代码段.
- A位的含义, 是否访问过, 该位是为了给软件/操作系统使用的, 段描述符刚被创建时,该位为0,即没有被访问过, 如果程序访问了这个段,该段就会被自动设置为1. 表示访问过. 操作系统在内存空间不够时,可以通过该位判断段访问的频率,将访问频率低的段存入磁盘等(虚拟内存页面置换算法.)
X=1时,后两位的含义
当X=1时,表示是代码段, 后面的两个标志位的含义是C和R.
- C: 是否是依从的, 为0时, 只有特权级别相同的段能够访问该段.为1时,只有比当前段特权级低的段能够访问.
- R: 是否是可以读取的, 一旦这个段是一个代码段,那么这个段的内容就不允许修改. 是否允许被其他的段通过类似
mov
等指令读取, 需要看R的值, 如果R为1,允许其他段读取,如果R为0则不允许其他的段读取. 如果r=0,其他段还是进行读操作,就会触发对应的段读取中断异常
X=0时,后两位的含义
当x=0时, 表示的是数据段,不可执行,后面两位的含义是E,W.
- E: 段的方向. 因为数据段和栈段都是存储数据的段. 但是他们的生长方向不同. E为0时表示从低地址向高地址生长,也就是普通的数据段. 如果E为1,表示是栈段. 栈生长方向是从高地址向低地址生长.(代码段默认向上扩展,因此没有这一位)
- W: 段是否允许其他的段进行写入. w=1允许,w=0禁止. 如果w=0,其他段还是进行写入操作,就会触发对应的段写入中断异常
段界限 段界限一共20位, 段界限保存的是 段大小-1, 它里面的值加上1,才是该段的大小. 如果段界限为0,不代表当前段大小为0, 而是为1(0+1).
在向上扩展(增长)的段中(x=1 或者 x=0且e=0), 如果访问的内存单元超过了段界限, 就会触发访问异常中断. 比如说mov [bx],0x1234
, 如果bx的数值大于段界限的值就会触发中断.
在向下扩展的段中(x=0且e=1), 这个段不一定是栈段, 但是通常是栈段.(比如说声明了段,但是以栈的方式去使用). 段界限的值越小表示这个栈段越大. 因为栈指针esp(sp),每次入栈元素,都会将减去一个值, push 指令执行后,会检查栈顶指针的值,如果小于等于界限的值,则会产生异常中断.
段界限,粒度位G 如果该位为0,表示段界限的值是以字节为单位,如果该位为1,则表示段界限的值以4KB为单位.
如果G为1(段界限单位为4k), 段界限值为0,那么段界限实际的值为:实际段界限= 0 * 0x1000 + 0xFFF
如果该段是向上增长的(从低地址向高低值生长),则段可以访问的内存单元范围是:0~0xFFF
.
如果该段是向下增长的(从高地质向低地址生长),则段可以访问的内存单元范围是:0xFFFF/0xFFFFFFFF ~ 0xFFF
, 是从0xFFFF
开始还是从0xFFFF_FFFF
开始取决于D/B位
段存在位P(Segment Present) 标识这个段是否真实在内存中存在,在内存稀少的情况下,会出现将使用不频繁的内存段存入到磁盘中. 此时这个段就再内存中不存在了, 应该将p置为0. 当程序访问p为0的段时,会触发中断,通常这个中断例程是操作系统编写的.在这个例程的处理逻辑是将指定的段从磁盘中加载到内存.
我的理解:
TIP
并不是一个段地址对应着一个段描述符表项, 也就是说同一个段地址可以有多个段描述表项.
比如说程序A和程序B的段选择子分别是0和1. 但是0和1对应的表项中的两个段描述中指向的都是一个物理真实的段. 也就是说明有一个程序的段被放入到磁盘中了. 当这个程序去访问自己的段时,它的存在位P为0. 但是并不是说这个段描述符中记录的这个段地址对应的段没有被使用, 它被另一个程序使用了,并且他段描述符对应的存在位p为1.
每个程序最大可申请4GB的内存空间. 如果自己申请了4GB空间,一部分存在物理内存中,一部分在磁盘中.
64位代码段标志 L(Long mode) 该标志标识是否处于64位模式, 是保留给64位CPU使用的,32位CPU此位将一直是0.
操作尺寸/栈上部边界位 D/B
如果E=0(不可执行),这个段是一个数据段: 比如之前说的向下扩展的栈段, 那么这这一位的含义是B, 他表示栈的上界是0xFFFF
还是0xFFFF_FFFF
如果E=1(可执行),这个段是一个代码段. 则这一位的含义的是D. 他表示CPU执行该段的代码时的默认操作尺寸. 这将影响CPU对于机器指令的译码结果. 相同的指令码,在不同的操作位数下会被看做不同的指令.
AVL位 (Available 可使用的) 该位保留给操作系统使用, 并没有明确的含义, 具体实现的功能可以由操作系统去指定.
DPL 访问特权级
当一个段想要访问另一个段时, 自己的段的特权级必须高于访问目标的特权级.
该标志2bit, 则一共有4个特权级别:0,1,2,3
. 特权级别0最高, 特权级别3最低.
# 进入保护模式的步骤
- 想好GDT的位置, 准备一个6字节的内存单元去存储这个GDTR的信息
;在代码段中找到位置定义, 通常是在程序代码的最后. gdt_size dw 0 gdt_base dd 0x00007e00 ;GDT的物理地址
- 提取GDTR中的逻辑段地址, 将其转化16字节对齐的段地址, 然后将段地址送入ds
;计算GDT所在的逻辑段地址 mov ax, [cs: gdt_base + 0x7c00] ;低16位 mov dx, [cs: gdt_base + 0x7c00 + 0x02] ;高16位 mov bx, 16 div bx mov ds, ax ;令DS指向该段以进行操作 mov bx, dx ;段内起始偏移地址
- 创建系统必要的0#描述符,它是空描述符,是处理器的要求
;创建0#描述符,它是空描述符,这是处理器的要求 mov dword [bx+0x00],0x00 mov dword [bx+0x04],0x00
TIP
一个描述符表项占用8个字节.
(可选)如果想要在进入保护模式后,在屏幕上显示文字也可以将文本模式的32kb显存空间作为一个段, 给它设置一个段描述符.
;创建#1描述符,保护模式下的数据段描述符(文本模式下的显示缓冲区) mov dword [bx+0x08],0x8000ffff mov dword [bx+0x0c],0x0040920b
上图展示了这9个字节的作用.
- 首先我们设置了段基地址为:
0x000B8000
, 即显存开始地址. - 设置了段界限为
0x0FFFF
,值为64kb-1, 实际上的段大小为值加1,即64KB.TIP
显存的地址实际上只有
0xB8000~0xBFFFF
即32KB, 但是这里分配了64KB - 设置G标志,粒度为0,即段界限的单位为字节.
- 设置D/B,操作尺寸为32位
- 设置L(Long mode)为0,即不是64位的处理器.
- 设置AVL(保留位)为0
- 设置P(段存在位)为1,表示这个段在内存空间中存在.
- 设置DPL为00,即0,最高特权级
- 设置S=1,即为内存的段描述符.
- 设置X为0,即为存放数据的段
- 设置E为0,W为1,即生长方向为向上(低地址向高地址),且运行写入.
- 设置A为0,即没有访问过(初始化,一个段描述符创建时,该段都没有访问过.) 该断描述符在内存中的存储:
TIP
可以看到低32个字节(双字),存储在内存的低地址处. 而这32个字节中的低16字节,存储在内存的低地址处,高16字节存储在内存的高16字节处.
高32个字节(双字),存储在内存中的高地址处. 同理:而这32个字节中的低16字节,存储在内存的低地址处,高16字节存储在内存的高16字节处.
即存储时,如果将这8个字节看做立即数, 发现还是按照高位在高地址,低位在低地址.
- 首先我们设置了段基地址为:
设置全局段描述符表的界限 GDTR是一个48位,即6字节的寄存器. 低16位是该表的界限(表大小=界限值+1). 高32位是GDT表的物理起始地址.
因为我们设置了空描述符,和显存映射的描述符, 一共两个表项16个字节, 则界限值为15.
;初始化描述符表寄存器GDTR mov word [cs: gdt_size+0x7c00],15 ;描述符表的界限(总数减一)
使用
lgdt
指令,加载指定内存单元的6个字节到GDTR寄存器.;我们在gdt_size+0x7c00处开始的6个字节作为GDTR的值. lgdt [cs: gdt_size+0x7c00]
打开第21根地址线即A20 为什么要打开第21根地址线A20? x86架构的新处理器工作在实模式时, 依然需要兼容能在8086上运行的的程序. 一些8086的程序通过使用内存回绕机制来访问内存. 在x86的实模式下访问的内存空间大小为1MB 如果我们访问目标地址
0xFFFF:0x0010
,对应的物理地址为0x100000H
,由于在8086中 有20根地址线, 则最高位1被丢弃, 回到了0x0000:0x0000
的地址. 这就是内存回绕.还记的之前说过的HWA(高端内存区域)吗? 也就是从
0x100000~0x10FFEF
之间的内存 (0x10FFEFh = 0xFFFF:0xFFFF
). 8086的程序员, 使用了这种方式实模式的寻址方式去寻址0x100000~0x10FFEFh
这段区域时,因为只有20根地址线,导致内存回绕的方式和去编程. 但是从80286(24根地址线)以及后续处理器都有更多的地址线. 如果还是工作在实模式下,发现地址线的个数实际上是可以访问到HWA区域的, 这会导致原本使用内存回绕的程序执行出错.因此,为了解决这个问题, 我们可以始终拉低第20根地址线.方式有两种:
- 在键盘控制器的0x60端口的最高位引脚, 与第20根地址线进行与门, 结果作为第20根地址线的真实结果. 因为键盘控制器是一个可以编程的器件, 因此可以向此端口送入某个二进制数,使与门结果为0, 因此实现了拉低20号地址线的作用.
- 随着时间流逝,键盘控制器是一个慢的io设备,每次写入地址,还需要判断该控制器是否准备好,准备好了才能写入. 因此后来不光使用键盘控制器,还专门在南桥(ICH)芯片中通过一个寄存器的位,连接到20号地址线. 实现拉低的作用.
打开A20的代码
in al,0x92 ;南桥芯片内的端口 or al,0000_0010B ;二进制的立即数, d2位设置为1 out 0x92,al ;打开A20
TIP
现代计算机中, 第20号地址线,默认开启.因此不需要执行开启的代码.
关闭中断 因为即将进入保护模式, 而保护模式不在使用原本的中断向量表,因为实模式下的中断例程处理程序不一定与保护模式兼容, 因此需要重新建立一套中断向量表. 在重新建立中断向量表期间,不允许有中断的发生.
cli
设置CR0(控制寄存器0)的d0位(PE,Protect Mode Enable)为1,正式进入保护模式
mov eax,cr0 ;先将cr0的值送入eax,cr0是一个32位寄存器,因此使用32位的寄存器. or eax,1 ;d0位设置为1 mov cr0,eax ;设置PE位
TIP
至此,正式进入保护模式.
# 完整代码
;设置堆栈段和栈指针
mov ax, cs
mov ss, ax
mov sp, 0x7c00
;计算GDT所在的逻辑段地址
mov ax, [cs: gdt_base + 0x7c00] ;低16位
mov dx, [cs: gdt_base + 0x7c00 + 0x02] ;高16位
mov bx, 16
div bx
mov ds, ax ;令DS指向该段以进行操作
mov bx, dx ;段内起始偏移地址
;创建0#描述符,它是空描述符,这是处理器的要求
mov dword [bx+0x00],0x00
mov dword [bx+0x04],0x00
;创建#1描述符,保护模式下的数据段描述符(文本模式下的显示缓冲区)
mov dword [bx+0x08],0x8000ffff
mov dword [bx+0x0c],0x0040920b
;初始化描述符表寄存器GDTR
mov word [cs: gdt_size+0x7c00],15 ;描述符表的界限(总字节数减一)
lgdt [cs: gdt_size+0x7c00]
in al,0x92 ;南桥芯片内的端口
or al,0000_0010B ;二进制的立即数, d2位设置为1
out 0x92,al ;打开A20
cli ;保护模式下中断机制尚未建立,应
;禁止中断
mov eax,cr0
or eax,1
mov cr0,eax ;设置PE位
;以下进入保护模式... ...
mov cx,00000000000_01_000B ;加载数据段选择子(0x08)
mov ds,cx
;以下在屏幕上显示"Protect mode OK."
mov byte [0x00],'P'
mov byte [0x02],'r'
mov byte [0x04],'o'
mov byte [0x06],'t'
mov byte [0x08],'e'
mov byte [0x0a],'c'
mov byte [0x0c],'t'
mov byte [0x0e],' '
mov byte [0x10],'m'
mov byte [0x12],'o'
mov byte [0x14],'d'
mov byte [0x16],'e'
mov byte [0x18],' '
mov byte [0x1a],'O'
mov byte [0x1c],'K'
mov byte [0x1e],'.'
hlt ;已经禁止中断,将不会被唤醒
;-------------------------------------------------------------------------------
gdt_size dw 0
gdt_base dd 0x00007e00 ;GDT的物理地址
times 510-($-$$) db 0
db 0x55,0xaa
# 补充说明
# 32位CPU加电开机后,各个段描述符缓存器的值.
除了CS寄存器的段描述符的高32位为0xff0093ff
,其他的都为0x00009300
. 低32位都为0x0000ffff
.
其中0093
的含义如下:
即所有的段寄存器都是16位操作尺寸(D为0). 为什么代码段寄存器对应的缓存器中X=0
呢? 按理说X=0
代表时数据段.
TIP
段类型检测只存在于保护模式下, 且检查时机是将段选择子送入段寄存器, 之后找到对应的段描述符后, 检查段描述符的类型是否为可执行. 如果可执行才会将描述符送入CS的描述符高速缓存器. 正常的访问段的内存单元是不会触发段类型检查的.
通过设置CR0寄存器最后最低位PE为1,进入保护模式时, 通过bochs调试会发现CS段描述符寄存器没有发生改变, 其地址值是进入保护模式前的0x00000000
, 界限是0x0000ffff
. 但是其X=0,即表示这不是一个代码段(即使它真是一个代码段). 但是发现可以继续执行指令. 因此我认为段类型的检查时机,应该是处于保护模式下,且段寄存器改变,尝试修改段描述符高速缓存器的时候. 直接进入保护模式时,是不会检查段描述符高速缓存器里面的段类型的.
进入保护模式时,发现CS段描述符中D/B位为0,即16位. 但是32位的处理器虽然可以选择使用16位的操作尺寸来解释指令, 但是如果想要发挥CPU的全部性能,还是应该进入一个一个D/B位为1. 即操作尺寸为32位的代码段.
# 32位处理器在实模式下的内存访问
实模式, 意思是处理器使用自己真实的寄存器位数, 比如说32位处理器, 在实模式下依然可以使用自己32位的寄存器 而不是说只能使用16位的寄存器.
实模式下,无论配备了多少内存,处理器只能访问1M字节左右,32根地址线,要想访问全部字节,就需要在保护模式下访问. 但是某些特殊情况下实模式下是可以访问到1M内存以外的内存单元的, 8086之后的处理器,因为引入了描述符高速缓存器, 因此不管是保护模式还是实模式,寻址的本质都是 将描述符高速缓存器中的32位地址与段内偏移值(8位,16位,32位...)的值相加, 最终结果作为实际的访问地址.
如果我们使用32位的寄存器比如 ESI,EDI,EBP,EBX, 通过使用32位的段内偏移 那么这是否意味着我们可以在实模式下访问完整的4GB空间呢?
在某种情况下,是可以的, 但是我们需要意识到,描述符高速缓存器中,不仅仅包含了地址, 还包含了界限值,没错,在实模式下也受到界限值(不是保护模式下段描述符的专利~)的限制. 在机器刚加电时,所有段寄存器的高速描述符缓存器的20位的界限值的低16位的界限值被全部置为1,即0x0FFFF
,并且粒度G
标志位是0,即界限值单位为字节. 因此界限值限制了段内偏移的值范围为0x00000~0x0FFFF
,即2的16次方,64KB. 如果超出这个界限值,就会被处理器阻止,触发中断
这就是实模式下, 可以使用32位的段内偏移,但却依然只能使用1MB字节左右内存空间的本质原因.
一个段偏移量是否会受到处理器阻止,不仅要看段内偏移的值,还需要看指令的操作数尺寸
比如说ebx的值为0x0000FFFF
, 如果执行的指令是add al,[ebx]
, 因为操作尺寸为1个字节,因此访问是允许的.
但是如果说执行指令是mov eax,[ebx]
,操作数的尺寸是4个字节, 因此实际需要访问的内存空间超出了3个字节. 因此是不被允许的.
mov eax,[ebx] ;操作数尺寸为4个字节.
add al,[ebx] ;操作数尺寸为1个字节
实模式下访问4gb内存的一个例外情况
也是在机器刚开始加电的时候, 代码段的描述符高速缓存器的地址值被设置为0xffff0000
,也就是高16位被设置为1.界限值被设置为0x0FFFF
. 此时的EIP的值是
0x0000fff0
. 因此EIP的数值并没有超过界限值. 因此实际访问的物理单元地址是0xfffffff0
,这是一个大于1MB, 又在4G内存命名空间之内一个地址.是BIOS的ROM芯片映射到的内存位置. 是一条远跳转指令. jmp far f000:e05b
,这条指令执行后会重设段描述符高速缓存器为的地址为0x000f0000
.
TIP
为什么要访问到4g的最后16字节处? 访问的是真实的物理内存吗? 现在的处理器都会把BIOS的ROM映射到内存命名空间中最后64kb处的地方(包括1MB的最后64kb处). 访问的是ROM, 而不是真实的物理内存.
一种解锁实模式4GB内存的方法
进入保护模式, 通过设置GDT表中的描述符, 间接的将各个段描述符缓存寄存器的界限值设置为0xFFFF_FFFF
(栈段可以设置为0), 然后退出保护模式,回到实模式. 此时因为界限值发生的改变.因此可以使用32位的段内偏移. 访问4GB内存.
# 从80386开始新增的两个段寄存器
新增了两个段寄存器FS和GS. 这两个段寄存器在实模式下也可以使用, 此时FS和GS都是16位的. 当来到保护模式时,这两个段寄存器也得到了扩展,变成了32位的.
# BIOS为了检查完整的物理内存会进入保护模式
BIOS因为要检查所有的硬件,检查内存的完整大小, 它需要进入保护模式, 因此在刚执行BIOS代码之前, GDTR寄存器的默认地址值为0,默认界限为0xFFFF.
在进入保护模式访问完所有的内存后,又会回到实模式. 当执行到引导扇区的程序即0x0000:0x7c00
处, 此时调试查看GDTR的值发现是BIOS之前设置过的值.
# 32位CPU复位执行的命令
当CPU加电开机,或者按下复位键. 通过bochs调试模式发现. 此时描述符高速缓存器中地址部分的值是0xFFFF0000
, 而CS段寄存器的值是0xF000
,IP寄存器的值是0xFFF0
, 我们发现刚开机时, 缓存器的地址的低16位值并没有根CS段寄存器对应起来. 实际上我们访问的第一条指令的地址是0xFFFFFFF0
,即缓存器的值加上IP寄存器的值, 这是一个32位的地址值. 指向了4G内存的倒数第16个字节的指令jmp far f000:e05b
, 一条远跳转指令. 而在8086CPU工作在实模式时, 这条指令的地址是在0xFFFF0
的位置. 实际上为了保证32位CPU工作在实模式下时,对8086CPU的程序的兼容, 会在1MB内存对应的地方映射ROM, 这个地方的ROM是一个影子. 即内存空间中,两个地方都有ROM对应的指令和代码.
当执行了jmp far f000:e05b
指令时, 会改变CS寄存器的值, 因此也会修改高速描述符寄存器的值.
# 80286的16位保护模式
80286内部的寄存器个数和位数与8086保持一致, 但是80286的地址加法器的地址线数达到了24根. 这允许它最大访问的内存空间是16MB.
从80286开始就引入了保护模式和描述符高速缓存器. 80286工作在实模式下时,也只能能否1MB字节多一点(HWA)的内存大小,但是可以将段地址*16的结果直接保存在描述符高速缓存器,方便下一次使用.
要想访问完整的16MB内存空间,80286也需要进入保护模式. 进入保护模式后, 也就是使用段选择子的机制去访问内存. 段描述符的大小依然是8字节, 但是高16位中的高8位保留不用(留给80386使用). 从里面获取24位的段地址, 然后与段内偏移相加得到最终的地址. 但是实际上由于指针寄存器si,di,以及bx,bp的位数都是16位的, 因此偏移大小最大也是16位的, 因此一个段最大只能是64kb 但是段的基地址是可以变的, 80286就是靠着这种方式访问16MB的内存空间. 因此80286的保护模式也叫做16位的保护模式.
获取段描述符: 这张图上没有画上去GDTR寄存器, 实际上80286也是有GDTR寄存器的.
# 实模式和保护模式的区别
实模式下是使用处理器自己真实的寄存器的位数. 32位处理器可以正常使用自己的32位寄存器,进行32位的指令运算等等. 本质区别是实模式下的寻址方式和保护模式的寻址方式不同, 以及寻址的范围不同.
# 保护模式寻址
在80386中有32根地址线,因此最大寻址空间为4GB. 只有进入了保护模式,才能使用4GB的内存空间.进入保护模式后,内存的寻址方式发生了改变.
内存中会保存一个描述符表(GDT, 全局描述符表).里面的表项记录了程序的段的描述信息,比如起始地址(32bit),段的大小(32bit).
进入保护模式后, 段寄存器CS,DS,ES,SS,FS,GS
的存储的值不在看做段的基地址,而是段选择子.
# 段选择子格式.
- d15~d3位为描述符索引,一共13位,2的13次方为8192. 值为0时,表示第一个段描述符,值为1时表示第二个段描述符.
:::
- 最大表示8192个索引, 这个数值正好是段描述符表(GDT)能记录的段描述符个数.GDT表的界限最大是
0xFFFF
,加1就是大小,即64KB 每个表项8字节, 因此 1MB/8 = 8192. :::
- 最大表示8192个索引, 这个数值正好是段描述符表(GDT)能记录的段描述符个数.GDT表的界限最大是
- d2位(TI,Table Indicator 表指示器), 实际上不只是GDT(全局描述符表)包含了段描述符, 还有一个LDT(本地描述符表)记录的段描述符.
TIP
当d2位为0时, 表示这是一个GDT的选择子, 因此会去GDT表获取段描述符信息. 如果d2为1则表示这是一个本地描述符表. 会去本地描述符表获取段描述符信息.
- d1~d0(RPL): 当前段的特权级.想要访问描述符索引对应的段,需要比较当前段特权级是否小于目标段DPL,访问条件是
RPL < DPL
TIP
在保护模式下,一个用户程序想要被执行,就需要给它重定向段选择子(实模式则是重定向段寄存器,其实操作的都是段寄存器.), 在操作系统的加载器在为这个用户程序 重定向时,给它的段选择子的这两位设置的值,就决定了它的权限.
特权级的值越小,权限越高.
# 段寄存器的描述符高速缓存器.
保护模式下, 给每一个段寄存器添加了一个对于程序员来说是透明的描述符高速缓存器. 访问段的内存单元时会从缓存器中获取访问的段的信息。
获取段描述符到描述符缓存器的过程如下:
当段选择子被设置给段寄存器后, 处理器会将段选择子中的索引部分*8(会判断是否超出界限.如果超出会触发中断),然后获取段选择的TI标志, 判断访问的是GDT表,还是LDT表. 如果TI为0,即访问GDT表. 那么就将乘以8后的段选择子的结果再与 GDTR寄存器的起始地址相加. 结果就是对应的段描述符所在内存单元. 从这个地方取出8个字节即一个描述符表项,送入到相应的段寄存器的描述符高速缓存器
TIP
段寄存器的描述符高速缓存器, 不仅仅可以在保护模式下使用, 也会在实模式下使用. 在实模式工作时, 段寄存器的段地址*16即左移4位, 然后将结果的高位补0,扩展到32位保存在描述符高速缓存器的地址部分. 每次访问该段的内存单元时,不再进行段地址的乘16,而是直接从段描述符中取出地址部分的低20位,然后与端内偏移相加.
举个例子: 需要注意的是这里的地址加法器,如果开启了第20根地址线, 访问的就是HWA的部分. 如果第20根地址线是关闭的,那么这根地址线的值强制为0.
# 保护模式具体的寻址过程。
设置好了段寄存器中段选择子的值后,该段的描述符高速缓存器就已经存储了对应的段描述符. 将段内偏移与段描述符中的起始地址相加即可得到对应内存单元的物理地址, 然后就对应大小的数据取出即可
# 指令操作数操作尺寸
为了设计和扩展指令系统, 32位处理器将操作尺寸定义(支持)为两种: 16位操作尺寸和32位操作尺寸。
16位操作尺寸允许8位或者16位的操作数, 以及16位有效地址。
32位操作尺寸允许8位或者32位的操作数, 以及32位有效地址。
TIP
但是,在同一时刻,处理器只能够按照一种操作尺寸工作. 要么选择16位操作尺寸, 要么选择32位操作尺寸,这叫做处理器的默认操作尺寸. 8086默认的操作尺寸为16位。
# 相同机器码但是不同的指令
通过查询Inter的机器指令手册,可以发现相同的机器指令竟然对应着多个不同的指令.
注意看被蓝框包括的部分. 以mov ax,dx
和mov eax,edx
这两条指令为例, 他们的原始机器指令都是D9D0
. 但是它们的操作尺寸不同
因此, 我们必须让CPU区分这这两条不同的指令. 因此引入了指令前缀
, 66
和67
就是指令前缀. 他们的大小为1个字节.
66
: 反转指令的数据操作尺寸67
: 反转指令的地址操作尺寸.
从图中可以看到,当默认操作尺寸为16时,89D0
这个指令默认是操作的是16位的数据. 如果想要它操作的是32位的数据,即执行mov eax,edx
这个指令
我们就需要在89D0
前面加上指令前缀66
,反转它的数据操作尺寸大小, 因为32位CPU只支持16/32位的操作数尺寸, 如果当前的操作数尺寸是16位,那么反转后
就是32位.
对于mov [bx+di],dh
和mov [ecx],si
这两条指令,他们的原始机器指令都是8931
.
但是如果想要执行的指令是mov [ecx],si
,则需要添加指令前缀67
,反转地址操作数尺寸. 因为16位操作模式下mov [bx+di],dh
,
bx+di
的数值被当做是一个16位的值. 因此加上67
后反转了地址操作尺寸为32位, 因此指令就变为了mov [ecx],si
.
TIP
不管多少位的X86CPU, 操作尺寸为8位的指令对应的机器指令始终是唯一的. 因此8位操作尺寸的指令,是不需要使用指令前缀的.
mov al,dl
指令, 不管CPU的默认操作尺寸是16位还是32位. 该指令操作尺寸永远是8位数据.
当CPU的默认操作尺寸为32位时, 如图是机器指令对应的指令: 可以发现与默认尺寸为16位时的指令不同, 原本需要加上指令前缀的指令,不需要加上指令前缀, 原本不需要加上指令前缀的指令加上了指令前缀.
# NASM的伪指令bits
NASM默认认为CPU的默认操作尺寸为16位, 因此对于指令mov ax,dx
,他会直接编译成D9D0
. 而对于mov eax,edx
指令,则编译成66D9D0
.
如果CPU的默认操作尺寸是32位的. 那么如果不加上bits 32
伪指令.则通过NASM编译mov ax,dx
指令时,生成的D9D0
机器码, 实际上对应的指令是mov eax,edx
.
因此按照代码块的bits不同, 也就是分为了32位代码块和16位代码块.
mov ax,dx
mov eax
::: 这么设计的原因是为了,让具有更大操作尺寸的CPU, 兼容操作尺寸小的CPU. 16位的程序,只要让32位CPU的默认操作尺寸为16位, 此时就能兼容16位的程序. :::
# 特殊情况
如下指令在16位操作尺寸下和32位操作尺寸下
mov ds,ax
mov ds,eax
在Inter的指令集中, 可以看到mov ds,ax
和 mov ds,eax
的机器码是一致的. 实际上这两条指令的含义也是一样的. 都是将eax,ax中低16位的数值送入段寄存器.
因此不管这两条指令是否加上指令前缀, 都会得到正确的结果.
如果加上了指令前缀, CPU执行这条指令时也不会出错. 但会多出一个字节.CPU执行时多一个时钟周期(浪费).
目前已知的汇编器中,NASM是一个新潮的汇编器, 不管在16操作尺寸下,还是32位操作尺寸下, 这两个指令的机器码都是8ED8 但是其他的汇编器, 有可能直接会在指令前面加上指令前缀符. 即使指令的执行结果没有问题, 但是加上指令前缀后会多一个字节.CPU执行时多一个时钟周期(浪费).
也可以自己避免这个问题. 在16位操作尺寸下, 就写16位的指令比如mov ds,ax
. 在32位操作尺寸下,就使用32位的操作尺寸指令mov ds,eax
. 这样不管汇编器是否先进. 都可以翻译出简洁正确的指令.
TIP
- 类似的同类的指令, 向其他的段寄存器设置值时, 比如
mov ss,eax
,mov es,eax
等指令, 也是如此. [bits 32]
与bits 32
的作用是一样的,方括号可加可不加
# 16位的指令操作数尺寸.
mov al, cl
中, 操作尺寸是8位的,
8086支持8位和16位操作尺寸, 而偏移值默认尺寸为16位,因此在8086下mov dl,[0x2e]
,方括号的数值都是16位的.
# 32位的指令操作数尺寸
# 充分发挥32位CPU性能.进入32位操作尺寸的代码段.
在进入保护模式后, 为了充分发挥32位CPU的性能, 应该使用bits 32
伪指令,将进入保护模式的代码后面的代码编译成32位操作尺寸的.
需要注意的是, 进入保护模式前的代码是16位操作尺寸的. 进入保护模式后的代码是32位操作尺寸的.
TIP
通过bochs调试代码可以发现, 在执行jmp 0000000000010_0_00B:flush
指令之前, 代码段描述高速缓存器中记录的还是进入保护模式之前的段描述信息.
其E=0. 且D=0. 如果我们此时使用反汇编u
命令发现, 后面指令是按照16位操作尺寸翻译的. 发现都是错误的.
当我们执行之后, 段寄存器被修改为选择子, 段描述符高速缓存器的段描述符, 变为GDT表中选择子对应的描述符. 它的E=1(代码段,可执行).
D=1(操作尺寸为32位). 再通过u
进行反汇编, 发现后面的指令是32位的操作尺寸翻译的, 是正确的指令.
# 修改段寄存器时的保护
进入保护模式后, 对于内存访问的保护措施说明
- 段选择子检查(是否超出GDT表界限):
修改段寄存器后. 会根据段选择子去查找对应的段描述符. 但是要求是索引号没有超过GDT表的界限.
TIP
如果段选择子超出界限. 触发中断异常
- 对取出的段描述符进行检查:
不同的段寄存器的描述高速缓存器,只能存放不同类型的段描述符,如下表:
- CS段寄存器的高速描述符缓存器, 只能存放 X=1的段,即代码段.
- DS,ES,FS,GS段的高速描述符缓存器能够存放所有的数据段, 以及可读的代码段.
- SS段寄存器的高速描述符缓存器只能存放可以读写的数据段.
TIP
如果放入了非法的段, 则会引发中断异常.
- 存在位P的检查. 如果访问的段在内存中不存在(P=0),也会触发中断异常. 该中断的中断处理程序通常由操作系统编写. 实现从磁盘中读取对应的段,加载到内存. 然后将P置为1.
# 特殊情况说明
在建立GDT表时,会设置第一个段描述符为全0的空描述符. 对于 ES,ES,FS和GS的段选择器, 可以向其加载数值为0的选择子.
mov eax,0
mov ds,eax
mov es,eax
mov fs,eax
mov gs,eax
尽管在加载的时候不会有任何的问题. 但是在访问内存时:
mov [ebx],ax
mov [es:ebx],ax
mov [fs:ebx],ax
mov [gs:ebx],ax
这样的指令,就会出现问题,导致一个异常中断. 这是一个特殊的设计,处理器用它保证系统安全.
而对于CS和SS的选择器来说,不允许向其传送为0的选择子.
# 段访问时的检查.
# jmp指令的检查
如下案例:
mov eax,cr0
or eax,1
mov cr0,eax
;以下进入保护模式...
jmp 0x0010:flush
首先jmp指令将选择子0x0010, 进行GDT表界限检查, 如果检查通过则取出描述符, 将描述符中的E位检查是否为1(可执行). 然后检查偏移量flush.
对于flush的检查,我与老师的解释有冲突.:
- 老师说: flush的值要与进入保护模式之前的CS段描述缓存寄存器中已有的段界限值进行比较, 进入保护模式之前值为
0x0FFFF
. - 我认为的是: flush的值要与选择子对应的描述符中的界限值比较, 这里选择子为0x0010(0000 0000 00010_000), 即选择的是第三个描述符.
然后与第三个描述符中的界限值进行比较. 这里的界限值为
0x001ff
,因为该段的描述符粒度G为0, 则界限值单位为字节,即511(界限值+1 = 512字节.) 只有flush的值小于等于511时,才会被允许.
TIP
我更加相信我自己的理解是对的. 因为如果按照老师的理解. 那么段内偏移检查就总是上一个段描述符的界限.
# 向上扩展的段的访问检查
每一个段都有自己的段界限, 以及对应的粒度G.
- 如果G=1, 实际使用的段界限=描述符中的界限值*0x1000+0xFFF
- 如果G=0, 实际使用的段界限=描述符中的界限值
向上扩展的段, 有代码段和E=0的数据段
代码段的检查: 每次取出指令时的检查
满足条件: EIP+指令长度-1 <= 实际使用的段界限
.
从公式中可以看出. 并不是说指令寄存器的值(段内偏移)小于段界限就行了, 还需要考虑该指令的长度. 如果指令的一半处于段界限,另一半在段界限之外.也会触发异常中断.
E=0的数据段的检查: 每次读写/内存的检查
满足条件: 操作数有效大小+操作数大小-1 <= 实际使用的段界限
当段是一个数据段时,并且作为一个栈段使用时
D/B的含义是B. (当段作为一个代码段时,D/B的含义是D,即操作尺寸.)
- 当B=0时,表示我们操作栈时使用的是sp.
- 当B=1时,表示我们操作栈时使用的是ESP.
隐式的栈操作指令有
push,pop,call,ret,iret
当向上扩展的数据段实际上作为栈段(栈段是一个特殊的数据段)使用时.
- 需要段描述符的D/B的值是0,还是1选择对sp还是esp进行值的设置.
- 需要根据段的实际界限值,设置sp或者esp的大小. 比如说段界限为
0x007ff
,G粒度为0(字节). 即实际的段界限为0x007ff字节. 栈顶指针寄存器的设置值应该是实际界限值大小+1. 这里就应该设置esp的值为0x800
.
一个例子: 向上扩展的数据段作为栈段:
对选择合适的栈顶指针寄存器,以及设置一个合适的值.
压入数据:push 0x076b076f ;字符'o','k'以及显示属性.
TIP
在32位处理器中,支持直接压入一个立即数.
- 首先我们这个段的基地址=0x00006c00
- 实际使用的段界限为=0x07FF
- 描述符的B位=1,即使用ESP
- 描述符的X=0, E=0, 表示向上扩展.
当压入数据时, 进行条件检查:
首先操作数基地址为: ESP = 0x800 -4 = 07FC
操作数大小为:4
则 07FC + 4 - 1 = 07FF
, 而07FF
<= 实际段界限(07FF)
. 因此条件成立, 访问通过.
当段是一个普通数据段时
以pop dword [0x0b8000]
举例
栈段的B位为1,因此出栈4个字节.到数据段的指定位置.
TIP
首先ds的值为0x0001(0000 0000 0000 1_000)
, 即选择了第二个段描述符.
该描述符的信息如下:
- 基地址为=0x00000000
- 段界限为=0xFFFFF
- 粒度G=1(单位为4k)
- 实际使用的段界限=0xFFFFF * 0x1000 + 0xFFF = 0xFFFF_FFFF (4G - 1)
- X=0,E=0 向上扩展的数据段.
访问条件还是操作数地址 + 操作数大小 -1 <= 实际段界限
- 操作数地址=0x0b8000
- 操作数大小为4字节
因此
0xb8000 + 4 -1 = 0xb8003
<=0xFFFF_FFFF
. 因此允许访问.
将出栈的4个字节保存在段基地址0x0000_0000
+0xb8000
= 0x000b_8000
位置.
然后将ESP = ESP + 4.(push是先减,然后取数,pop是先取数,然后再减.)
这里需要对ESP的值进行检查工作, 保证ESP加4后的值 在界限值范围之内. 如果超出了界限值, 表示越界了. 触发指令中断异常.
# 向下扩展的段的段内检查
检查条件: 操作数的有效地址(操作数地址 - 操作数大小) > 实际的段界限.
使用如下段作为例子:
TIP
X=0: 数据段
段基地址: 0x00007c00
.
段界限: 0xFFFFE
G=1: 粒度为1, 段界限单位为4kb
E=1: 向下扩展.
B=1: 使用的是ESP.且ESP的变化上限为0xFFFF_FFFF
首先对向下扩展的段具有的特性进行说明:
- 根据检查条件可以发现:
有效的操作地址
范围是:实际段界限 ~ FFFF/FFFF_FFFF
, 即段内偏移量的最小值是实际使用的段界限+1 - 如果描述符的B位是0, 则段内偏移量的最大值固定为FFFF. 用做栈段时, 使用SP.
- 如果描述符的B位为1, 则段内偏移量的最大值固定为FFFF_FFFF, 用作栈段时, 使用ESP
- 如果段界限是0时, 则当前段具有最大的尺寸. 即允许的偏移量的范围是最大的
例子:
TIP
如果越界, bochs会重启.
# 内存的线性回绕特性.
根据段基值以及段界限的不同, 有可能会出现内存的线性回绕. 以32位处理的线性回绕进行说明. 32位处理器 最终物理地址为 32位基值+32位段内偏移.
如果这个基值是0,那么段内偏移32位也足够访问完整的4GB内存空间.
如果这个基值是0x2000_0000
时,如果实际界限值也没有限制我们的访问. 那么32位的段内偏移也可以访问到完整的4GB内存空间. 只是会出现内存线性回绕的现象.
如下图:
左侧为线性地址. 右侧为段内偏移值. 当以0x2000_0000
作为基值时, 右侧的值就是0000_0000
# 通过段的别名描述符,实现段的共用和共享.
现在有这么个问题: 有一部分数据存在代码段中. 需要对代码段的数据进行修改. 但是因为代码段的段描述符中x=1,表示代码段, 一旦是代码段,就是只读的.
我们不能通过 mov [cs:xxx],0
的方式去修改代码段的这部分数据.
这个时候我们可以准备一个数据段, 将这个数据段的段范围包含这个代码段对应的数据部分. 只要我们能够操作这个数据即可.
为了方便的操作, 通常会设置这个数据段的地址与这个代码段的地址相同, 这样就可以用同样的段内偏移+段基值
来访问数据部分, 并且进行操作.
这个数据段的描述符则叫做别名描述符
# cpuid
指令
我们编写的程序需要依赖于CPU的功能. 新的CPU通常提供了许多新的功能,能够加快某些程序的运行.
因此我们在执行程序时,需要判断CPU是否支持某些功能. 因此在80486后的处理器支持cpuid
指令.
该指令要求我们将功能号,放入到eax中, 并且将对应的功能号的结果放入EAX,EBX,ECX,EDX等寄存器. 比如说功能号0, 它的作用是返回该CPU支持的最大功能号, 以及处理器的品牌
但是有些CPU也不支持cpuid的指令, EFLAGS
标志寄存器的中d21位(ID),标识该CPU是否支持获取CPU信息的指令
# 条件传送指令簇: CMOVxx
在编写一些功能时,如果出现分支, 我们通常习惯使用jmp
指令来跳转. 但是jmp指令有一个缺点就是, 会清空指令流水线的指令. 降低效率.
为了解决这个问题.后代的CPU 引入了条件传送指令, 可以根据各种条件来决定是否传送.
比如如下需求: 如果 eax 不等于 edx, 就将edx的值设置给eax:
cmp eax,edx ; 比较eax和edx
jz next ;跳转到标号出执行
mov eax,edx
next:
;后面的处理语句
jz指令会清空指令流水.
通过条件传送指令cmovne eax,edx
, 作用是如果不相等就将edx的值设置给eax.
cmp eax,edx
cmovne eax,edx
next:
;后面的处理语句
# 指令簇
类似于jmp指令, 根据不同的条件, 具有不同的条件传送指令.
# 使用条件
不是所有的CPU都支持条件传送指令簇, 如果想要知道是否支持.
可以通过cpuid指令, 传入功能号为1mov eax,1
, 然后查看结果中edx寄存器的d15位是否为1, 为1则支持,为0则不支持.
CMOVxx r , r/m
指令要求:
- 源操作数和目的操作数的宽度必须相同.
- 目的操作数, 只能是寄存器, ,且必须是16,32,64位.
条件传送指令簇, 不影响标志寄存器.
# 内存对齐
当我们存储数据时, 一般以数据的大小的偶数来存储. 比如说: 存储1个字节的数据, 我们就应该存储在地址为0,1,2,3,4的地址,即任意地址. 存储2个字节的数据, 我们就应该存储在地址为0,2,4,6,8的地址, 2的倍数地址 存储4个字节的数据, 我们就应该将其存储在地址为0,4,8,12等 4的倍数的地址. 存储8个字节的数据, 我们就应该将其存储在地址为0,8,16等 8的倍数的地址.
为什么呢?
因为内存这个硬件,它存储数据的单位就是它的位宽. 比如说一个内存条的位宽为64bit(8字节)
那么它可以从8字节对齐的地址开始读取或写入8个字节的数据. 也就是说内存条只能从8字节对齐的地方读写数据.
地址为:0,8,16,24...
如果我们将一个8字节的数据存储在了内存编制为11(第12个内存单元)的位置. 当CPU需要从内存编制为11的位置读取数据时. 首先11落在了第一个8字节大小的区域. 因此内存会读取从0开始的8字节数据, 然后CPU需要继续读取下一个8字节的数据. 也就是从内存编号8位置开始再读取一次. 将两次的数据整合, 取出内存编号为11开始的8个字节单元返回.
如果这个数据事先就存储在8字节倍数的地址, 那么CPU在读取时, 只需要读取一次就可以了. 这就是内存对齐的意义.
不同位宽的内存条,受到的影响. 位宽越大的内存条, 再读/写没有对齐的数据时, 收到的影响越小.
- 64bit位宽的内存条, 一次能够读取8字节的数据, 即使一个4字节的数据, 在8字节空间中没有对齐存储在0以及4的位置. 该位宽的内存条, 也仅仅需要读取一次. 而如果是
- 32bit位宽的内存条, 一次能够读取4字节的数据. 如果一个4字节的数据, 一半存储在了一个4字节单元.另一个存储在了另一个4字节单元. 那么就需要读取两次.
- 双通道的内存条, 通常采用低位交叉编制. 每个通道依然是64bit, 但是CPU能分别从两个通道读取数据. 这种方式受到没有内存对齐的影响,相比单通道64bit的内存条更小.
不过还是建议在编程时, 保证数据的字节对齐, 这样能避免性能降低问题, 以及某些原因引起的错误.
TIP
磁盘也有4kb对齐的说法, 本质原因是一样的. 一下摘自知乎:
windows读写是按簇,磁盘读写则是按扇区,如果一个分区的起始扇区为一个4K扇区的第2个虚拟扇区,也就是4K没有对齐,那么对于簇大小为4K的分区,每一个簇都会被割裂在两个不同的4K扇区里,那么当windows操作一个完整的4K簇的文件时,磁头就要对两个4K扇区进行操作,当存在大量4K左右小文件的时候,操作速度就会较低。而正确的4K对齐就会提升读写速度,尤其是小文件的读写速度。
意思就是, 一个4k簇的文件, 如果没有4kb对齐, 就会分散在两个物理扇区中, 需要分别读取两次扇区. (早期的磁盘中物理扇区的大小是512字节, 而现代磁盘的扇区大小是4096(4k)字节, 为了兼容老的软件, 一个物理扇区被虚拟成8个逻辑扇区.)
# 串比较指令.
为了比较两个字符串是否相同. 如果我们使用cmp来比较一个字符串, 那么我们在进行一次比较后, 需要手动的增加比较的索引(比如说si,di加1).
为了更加的方便我们可以使用串比较指令:
串比较指令, 有不同的比较大小, 根据自己比较的字符串的长度, 来选择一个合适的串比较指令,提高比较的效率.
但是串比较指令, 执行一次只能比较一个字节/字/双字/四字, 实际上需要比较的两个字符串通常比较长, 需要比较多次. 因此我们需要搭配重复指令.
但是使用的重复指令不是rep
, 因为我们需要根据每次比较的结果,来判断是否需要继续比较. 因此我们使用的是条件比较指令.
在进行串比较时, 还应该设置字符串的方向. 通过设置DF标志寄存器
因此总结, 进行串比较时,应该进行如下的步骤:
# 指令pushad
,popad
,xlat
- pushad: 压入所有双字寄存器
- popad: 出栈所有双字寄存器, 除了ESP.
- xlat 方便的查表指令, 自动以BX/EBX 为索引,去查表的指令字节. 然后将该字节设置给AL DS的选择子,所执行的段,存储着转换表.
# push指令说明
# 直接压入立即数.
需要注意的是,实际上是不能直接压入8位的立即数的. 因此在压入8位立即数时, 会根据CPU当前的指令操作尺寸, 来对这个立即数进行符号位扩展(压入前是负数,则高位补1) 比如说16位操作尺寸, 则这个立即数就会被扩展成16位的. 并且栈顶指针取决于当时代码段描述符的B位的值.
# 压入段寄存器
压入段寄存器时, 也会根据当前CPU的操作尺寸对段寄存器进行扩展,当操作尺寸为32位时, 进行扩展,补0.
# 出栈段寄存器
# LDT表和TSS段的概念
运行的程序是一个进程(任务). 而对于每一个进程,它所使用的描述符表项保存在LDT中,而系统内核使用的描述符表项, 保存在GDT中.
LDT, 局部描述符表. 每一个进程都有一个LDT表. 保存着这个进程所使用的段的信息 TSS段. 任务状态段. 每个进程都有这样的一个段. 保存进程的状态信息.
# 多任务
为了实现多任务. 通常需要对多任务进行管理. 因此引入一个数据结构, PCB(进程控制块)链表, 里面记录了一个进程的一些信息.比如程序的LDT表的基地址.LDT表的段界限. 程序基地址等等.
因此,内核在加载任务时,首先需要为新的任务申请一个PCB空间. 然后创建PCB, 将PCB链接在PCB链表中. 然后对新任务的PCB的内容进行初始化.
# LDT表的选择子
LDT表, 不存在空描述符. 即所有的描述符都是真实有效的. 新进程/任务在加载时, 会将自己的段生成描述符, 然后添加到LDT表(而不是之前学习时,加载到GDT表.) GDT表的空描述符是无效的.
当将某个选择子设置给段寄存器时, 为了区分这个选择子是访问GDT还是LDT的, 通常在选择的d2位来指定. 如果d2位为1,则是去访问LDT表. 如果是0则是访问GDT表.
# LDT段描述符格式
上面我们讲过. 段描述符的分类:
- 内存的段描述符: s=1
- 系统描述符: s=0
- 系统的段描述符: GDT,LDT,TSS
- 们描述符
GDT表和LDT表, 本质上也是保存在某个内存中段中的. 但是因为GDT表, 整个系统只存在一个. 因此不需要专门为它创建段描述, 而是直接存储在GDTR寄存器. 而LDT表, 是每一个任务/进程一个. 包含多个. 因此对于每一个LDT表, 都需要一个段描述符. 保存在GDT表中. 同理TSS段, 也是每一个任务/进程一个. 需要一个段描述符进行保存.
在GDT表中, 段描述符分为系统的描述符和段描述符. 通过s位进行区分. 当s=1时,为存储器的段描述符. 而TYPE字段用来进一步的区分是代码段还是数据段.
当s=0时,为系统描述符. 且TYPE字段用来指定系统的段的类型或者门的类型. 当TYPE值为(0010)时表示这是一个LDT表描述符 当TYPE值为(10B1)时, 表示这是一个TSS段描述符. 其中B的意思是busy(忙碌). 表示处理器正在执行这个任务.这个任务是忙碌的(任务是不可重入的. 多任务环境时, 切换任务时, 不能从自己切换到自己. ). 当TSS刚创建时B位清0. 任务执行时设置为1.
对于系统的段描述符来说, D/B位(操作尺寸)和L(Long mode)标志位对该段没有意义. 因此固定为0
# TR寄存器和LDTR寄存器
LDTR寄存器: 一个16位的寄存器, 存储的是LDT选择子. 该寄存器时刻保存着当前执行的用户程序的LDT表所在的GDT的选择子 TR寄存器: 一个16位的寄存器. 存储的是TSS选择子. 该寄存器时刻保存着当前执行的用户程序的TSS段所在GDT中的选择子
它们类似于段寄存器. 都隐式含有一个描述符高速缓存器. 存储着对应选择子所对应的段描述符. 保存着该描述符的32位基地址, 段界限. 段属性.
# ltr
和lldt
指令
ltr r/m16
: 该指令, 将一个16位寄存器的值或者某个内存单元开始16位大小的值, 送入TR寄存器.lldt r/m16
: 该指令, 将一个16位寄存器的值或者某个内存单元开始16位大小的值, 送入LDTR寄存器.
# TR寄存器和LDTR寄存器的工作流程.
TR寄存器: 当我们将选择子送入TR寄存器(通过ltr指令), 即TR寄存器的值发生变化后, 就会自动将TR的值作为选择子, 去GDT表中, 找到对应的TSS描述符, 然后将描述符送入TR寄存器的的隐式高速描述符缓存器.
同理LDTR寄存器也是如此: 当我们通过lldt r/m16
指令修改了LDTR的值后, 就会自动将LDRT寄存器的值作为选择子. 去GDTR表中1, 找到对应的LDT描述符, 然后将描述符送入到LDTR的隐式高速描述符缓存器
当用户程序工作时, 自己断用户程序的各种段寄存器的选择子的TI位为1, 表示段选择子对应的段描述符存储在LDT表中, 因此, 会从LDRT寄存器对应的高速描述符缓存器中, 取出LDT表的32位线性基地址. 然后找到LDT表中对应的段描述符. 将其送入段的高速描述符缓存器.
# 特权级保护和特权指令.
在多任务系统下, 内核是一个公共部分. 一个应用程序不应该影响内核以及其他的应用程序的正常执行.
因此引入了特权级保护, 每一个段有各自的特权级别:
级别数值越小, 则级别越高.
同时某些指令有关于系统, 能够改变计算机的执行状态, 这些指令叫做特权指令, 特权指令只能在特定的级别中的代码段执行. 例如: lgdt, lldt, ltr , 控制寄存器的传送指令. hlt(停机指令)等等.
我们说某个程序的特权级别为3, 一般指的是该程序的所有代码段的特权级都为3. 因此这个程序的所有段的特权级别应该也全为3. 因为代码段需要访问数据段, 栈段, 其特权级别值一定是需要比代码段大的. 这样代码段,才能去访问.
# 当前特权级的概念
当前特权级 Current Privilege Level: CPL 此时此刻, CPU正在执行的代码段的特权级. 也可以说是当前执行程序的特权级.
在CPU中的CS段寄存器中, 保存着当前执行的代码段的选择子. 选择子的d1和d2位构成的值, 就是当前的段的特权级. 也就是CPL.
特殊情况说明:
在刚进入保护模式前, 实模式下, 可以看作为以特权级0的模式下执行.
因为此时的代码段和CS描述符缓存器还保存着进入保护模式前的的值. 进入保护模式后,再通过一条jmp far xxxx:xxxx
指令, 来刷新CS段寄存器和高速描述符缓存器
# DPL 目标特权级.
DPL保存在段描述符的DPL标志位.
一般来说段描述符中的DPL就是, 当前段的CPL, 当我们在为应用程序创建描述符时, 返回一个段选择子, 通常来说段选择子的值与该段的短描述符保持一致.从代码段访问一个数据段时, 通常需要CPL<=DPL
当访问目标段是一个代码段时, 通常需要CPL==DPL, 也就是说控制转移指令(jmp,call,retf,iret), 通常发生在两个相同特权级的代码段之间.而不允许从一个高特权的段跳转到一个低特权的段去执行.因为, 高特权的段(特权级别为0),通常认为比低特权的段(特权级别为3)更加可靠.稳定.无论任何时候, 都不允许通过jmp指令, 从高特权的段跳转到低特权的段去执行.
# 如何从低特权的段转移到高特权的段?
- 将高特权(比如特权级别为0)的段设置为一个依从代码段. 即C位为1. 此时检查条件就由CPL==DPL 变为 CPL >= DPL. 允许低特权的程序转移到高特权的程序去执行. 但是需要注意的是, 即使转移到高特权的段去执行, 但是当前特权级不会发生改变,依然保持原本转移前的特权级.
- 通过调用门
# 门描述符之调用门
描述符不仅仅可以描述一个内存的段, 还可以描述一个例程(中断例程)或者子程序. 如果描述的是一个例程或者子程序, 那么就称这个描述符为一个调用门.
调用门这个描述符的格式与寻常的段描述符格式不同:
调用门的检查: 当我们通过JMP指令或者CALl指令, 调用调用门描述的子程序/例程时, 首先当前代码段的CPL的值必须 小于 调用门的描述符的DPL, 也就是说当前代码段的特权级别需要大于门描述符的特权级别, 这样才有权限.
然后就是当前代码段的特权级值必须 大于 目标代码段描述符的DPL. 即当前代码段的描述符的特权级别必须比目标代码段的特权级别小(即低特权级别的段, 去访问高特权级别段,可以通过调用门.)
需要注意的是: 通过JMP指令, 去执行调用门的子程序时, 不会改变当前特权级别, 会以当前特权级别来执行子程序. 而通过CALL指令, 去执行调用门的子程序时, 会将当前特权界别换成, 目标代码段的描述符的特权级别.
# 特权级检查的典型时机
- 执行特权指令: ltr, lldt
- 修改段寄存器: retf, jmp far(远跳转), call far(远调用), pop 段寄存器, 用mov指令向段寄存器传送段选择子.
在对内存单元进行访问时, 并不会进行检查. 但是不是说对于内存不进行保护. 而是将检查的时机放在了修改段寄存器的时候, 比如当修改数据段寄存器ds时, 如果选择子中的CPL 不满足 访问目标数据段的条件. CPL <= DPL. 如果不满足这个条件时, 在修改段寄存器时就会报错.
# 用户程序代码通过调用门调用内核例程.
我们有时候需要去在用户程序代码中去调用内核提供的例程. 因为是从低特权级到高特权级. 因此我们使用调用门的方式。
能否调用内核例程的特权级检查:
例程代码段DPL <= 用户程序代码段的CPL <= 调用门DPL
如果内核例程中需要修改其他的数据段. 以硬盘读写例程为例, 用户程序需要传递一个被写入的段的选择子. 这个选择子可能是自己用户程序的数据段. 也可能是内核数据段. 为了防止不破坏内核程序, 因此需要进行检查.
但是处理器它只会根据当前代码段的CPL来检查是否能够访问目标数据段. 当用户程序通过调用门, 执行内核例程时(如果通过call). 那么CPL就会变成内核的CPL 也就是0, 如果是访问内核数据段(DPL = 0), 硬件检查是可以通过的. CPL <= DPL.
因此仅仅依靠硬件的检查是不够的. 还需要例程的编写者, 判断例程真正的调用者的权限. 来修改选择子的特权级.
因此例程真正调用者的特权级, 叫做RPL(请求特权级.), RPL的值其实就是用户程序代码段的特权级.
例程编写者, 需要用RPL覆盖掉用户程序传递的选择子的特权级.
然后在访问数据段时, 要求 RPL <= DPL.
TIP
因为用户程序CPL 大于等于 例程的DPL, 因此例程的特权级实际上是比例程低的. 因此只需要检查RPL 是否小于等于DPL.即可. 因为如果RPL满足条件. 则例程CPL <= DPL 也一定能够满足条件. 同理, 如果RPL不满足条件. 那么即使例程CPL满足条件. 也无济于事.