保护模式下的80386及其编程02:机器状态和存储寻址

目录

1 寄存器

1.1 通用寄存器

1.2 处理器控制寄存器

1.2.1 指令指针寄存器EIP

1.2.2 处理器状态和控制标志寄存器EFLAGS

1.3 段寄存器

2 内存寻址机制

2.1 基于段的内存寻址

2.2 段地址的构成

2.2.1 概述

2.2.2 隐式与默认引用

2.2.3 显式指定

2.3 偏移地址的构成

2.3.1 基地址

2.3.2 变址

2.3.3 比例因子

2.3.4 偏移量

2.4 程序栈

2.4.1 栈的基本操作

2.4.2 子程序与栈

2.5 指针数据类型

2.5.1 指针类型

2.5.2 指针数据的使用

2.6 程序分段策略

2.6.1 多段模式

2.6.2 单段模式

3 指令编码简介

3.1 前缀字节

3.2 操作码字节

3.3 MODRM操作数指定符

3.4 地址位移

3.5 立即常数

4 IO空间简介


1 寄存器

在80386中有3类寄存器

1. 通用寄存器

2. 状态控制寄存器

3. 段寄存器

具体寄存去如下,

说明:在80386中,指令的操作数可以存储在如下位置,

① 寄存器

② 内存

③ IO设备寄存器

④ 编码在指令中的立即数

需要注意的是,虽然内存为存储操作数提供了巨大的空间,但是访问内存比访问寄存器和使用立即数要慢得多

1.1 通用寄存器

1. 80386共有8个32位的通用处理器

2. 通用处理器可用于

① 算术和逻辑运算

② 寻址时形成内存地址

3. 通用寄存器的低16位可以作为16位的寄存器单独访问,从而为执行8086和80286代码提供了兼容的寄存器组

访问这些低16位的寄存器,不会影响32位寄存器的高16位

4. AX / BX / CX / DX寄存器还可以作为2个8位寄存器访问,如果这些8位寄存器被访问,通用寄存器的其他位不受影响

说明:对通用寄存器的隐式使用

有些指令(e.g. 串操作指令和双精度乘法和除法操作指令)必须要从固定的寄存器中获取操作数,这就是对通用寄存器的隐式使用

以串操作指令为例,

① 使用ECX存储串长度

② 使用ESI存储源地址

③ 使用EDI存储目的地址

1.2 处理器控制寄存器

1.2.1 指令指针寄存器EIP

1. EIP寄存器为32位,用于指向处理器将要执行的下一条指令地址

2. 80386每次取值后,会自动使EIP增加这条指令的长度

3. 控制转移指令可以改变EIP寄存器的值,从而控制执行流

4. 16位的IP寄存器包含在EIP寄存器的低16位,用于执行8086和80286代码

1.2.2 处理器状态和控制标志寄存器EFLAGS

1. 32位的EFLAGS寄存器包含有若干个状态标志位控制标志位

2. 算术操作之后,处理器会自动设置适当的状态标志位,后续程序通过测试这些状态位,可以检查特定的条件

3. 程序可以通过设置控制标志位,控制80386的某些功能,其中有些设置有特权级要求

4. EFLAGS寄存器中的保留位,必须按图示的方式设置,这样才能保证当Intel未来决定使用这些标志位时的软件兼容性

5. 修改EFLAGS寄存器中的位,可以遵循读-修改-写的模式,这样可以保证只修改要修改的位,其余的位不会改变,即

① 先将EFLAGS寄存器的值存储起来(e.g. 通过PUSHF指令)

② 对存储的EFLAGS值进行修改

③ 将修改后的值加载到EFLAGS寄存器中(e.g. 通过POPF指令)

当然,还有一些指令可以直接设置指定的标志位,比如STI / CLI指令可以直接设置IF位

1.2.2.1 状态标志位

状态标志位一般由算术和逻辑指令来设置,具体标志位含义如下,

标志位

含义

CF(Carry Flag)

进位标志位

  1. 如果算术运算超出最高有效位产生进位或借位,CF位为1;否则为0
  1. CF标志位是无符号算术运算的溢出条件,可用于支持多精度的算术运算

PF(Parity Flag)

奇偶标志位

  1. PF位指示计算结果低8的奇偶性
  1. 如果计算结果的低8位中1的个数为偶数,则PF位为1;否则为0。这里设置的方式为奇的奇偶性,因为计算结果低8位和PF位一起,1的个数为奇数

AF(Auxiliary carry Flag)

辅助进位标志位

  1. 如果计算时bit [3]向前一位有进位或借位,AF位为1;否则为0
  1. AF位用于BCD算术运算中

ZF(Zero Flag)

零标志位

如果计算结果为0,ZF位为1;否则为0

SF(Sign Flag)

符号标志位

  1. SF位被置位计算结果的最高有效位
  1. 如果使用补码表示有符号数,最高有效位为符号位,表示有符号数的正负

OF(Overflow Flag)

溢出标志位

如果计算结果超出指定位数的补码所能表示的范围,则OF位被置位

1.2.2.2 状态标志位在计算中的作用

通过状态标志位,处理器使用一套指令就可以对无符号整数、有符号整数和BCD码进行计算。下面以CMP指令为例,说明如何使用一套指令完成对各种数据类型的计算

CMP指令从一个数中减去另一个数,并根据计算结果设置状态标志位,从而进行两个数的比较。假设进行8位数的比较,参与比较的两个数为0x85和0x49,指令序列如下,

mov al, 0x85

mov bl, 0x49

cmp al, bl

单纯从二进制角度理解,计算结果如下,

0b10000101 - 0b01001001 = 0b00111100

1. 以无符号整数方式理解

① 最高有效位没有发生借位,所以CF = 0

② 以无符号整数方式理解,进行的计算如下,

133 - 73 = 60

后续指令可以通过CF位判断两个无符号整数的大小关系,由于CF位为0,所以al > bl

2. 以有符号整数方式理解

① 从有符号整数的角度,一个负数减去一个正数,结果为一个正数,可见发生有符号数溢出,所以OF = 1

② 计算结果的最高位有效位为0,所以SF = 0

③ 以有符号数方式理解,进行的计算如下,可见-196已经超出8位有符号数所能表示的范围

-123 - 73 = 60

后续指令可以通过(SF xor OF)的值判断两个有符号正数的大小关系,由于两个标志位异或计算的结果为1,所以SF位和OF位一定是不同的,因此al < bl

3. 以BCD码方式理解

① 这里使用的是压缩BCD码,因此低4位进行的是(5 - 9)的BCD码运算

② 由于计算过程中低4位发生借位,所以AF = 1

后续指令可以通过AF位判断低4BCD码的大小关系

说明1:处理器通过指令和状态标志位,对操作数同时从无符号数、有符号数和BCD码的角度进行了运算。操作数在程序中的具体含义,是由程序员负责解释的

说明2:有符号数大小关系判断分析

有符号数的大小判断主要依据状态标志位中的SF位和OF位,列表分析如下,这里理解的关键是,SF位只是在逻辑上表示计算结果的正负,并不代表实际结果的正负,因为运算可能溢出

SF

OF

说明

0

0

SF = 0:逻辑上计算结果为正数或者0

OF = 0:没有发生溢出

所以实际结果与逻辑结果一致,计算结果为正数或者0,即有符号数大于或等于

1

1

SF = 1:逻辑上计算结果为负数

OF = 0:发生溢出

所以实际结果与逻辑结果相反,计算结果为正数或者0,即有符号数大于或等于

0

1

SF = 0:逻辑上计算结果为正数或者0

OF = 1:发生溢出

所以实际结果与逻辑结果相反,计算结果为负数,即有符号数小于

1

0

SF = 1:逻辑上计算结果为负数

OF = 0:没有发生溢出

所以实际结果与逻辑结果一致,计算结果为负数,即有符号数小于

从上表中可以看出,

① 当SF = OF时,也就是(SF xor OF)的计算结果为0时,表示有符号数大于或等于

② 当SF != OF时,也就是(SF xor OF)的计算结果为1时,表示有符号数小于

说明3:状态标志位设置与操作数长度的关系

① CF / ZF / SF / OF状态标志位的设置取决于操作数的长度,例如,

  • 进行32位数据运算时,根据计算结果的bit [31]设置SF位,根据32位的结果是否为0设置ZF位,根据bit [31]是否发生进位或借位设置CF位,根据有符号数计算结果是否超过32位表示范围设置OF位
  • 进行16位数据运算时,根据计算结果的bit [15]设置SF位,根据16位的结果是否为0设置ZF位,根据bit [15]是否发生进位或借位设置CF位,根据有符号数计算结果是否超过16位表示范围设置OF位

② PF / AF状态标志位的设置与操作数的长度无关

  • PF位总是根据计算结果低8位的奇偶性进行设置
  • AF总是根据计算过程中bit [3]是否发生进位或借位进行设置

1.2.2.3 控制标志位

控制标志位可以由程序来设置以控制80386处理器的运行,具体标志位含义如下,

标志位

功能

TF(Trap Flag)

追踪标志位

  1. 用于控制产生单步中断来支持程序的调试
  1. 当TF = 1,每条指令执行结束时,都将产生单步中断

IF(Interrupt-enable Flag)

中断允许标志位

  1. 用于控制处理器响应INTR引脚发来的外部中断信号
  1. 当IF = 1,响应外部中断;当IF = 0,忽略外部中断

DF(Direction Flag)

方向标志位

  1. 用于控制串操作指令在每步之后地址的发展方向
  1. 当DF = 0,每步之后变址寄存器递增,也就是地址递增

当DF = 1,每步之后变址寄存器递减,也就是地址递减

IOPL(IO Privilege Level)

IO特权级字段

  1. IOPL是一个2 bit位宽的字段,所以可以有0 ~ 3共4种状态,正好对应4个特权级
  1. IOPL字段指定了要进行IO操作所需的特权级,

如果CPL <= IOPL,则可以执行IO指令

如果CPL > IOPL,则不可以执行IO指令,执行后会产生通用保护异常

NT(Nested Task)

嵌套任务标志位

  1. 用于控制iret指令的执行行为
  1. 如果NT = 0,则执行常规的中断返回操作

如果NT = 1,则执行任务切换操作(与TSS段等相关,详见后续笔记)

RF(Restart Flag)

重启标志位

  1. 用于控制处理器对调试异常(指令断点)的响应
  1. 当RF = 0,则指令断点产生调试异常

当RF = 1,则禁用指令断点产生调试异常

VM(Virtual Machine)

虚拟8086标志位

  1. 用于控制处理器的工作模式
  1. 当VM = 1,则处理器在虚拟的8086模式下工作

当VM = 0,则处理器在一般的保护模式下工作

说明:不同特权级对控制标志位的操作

① 运行在任何特权级下的程序都可以置位或清除TF / DF / NT / RF控制标志位

只有在特权级0下执行的程序才能改变IOPL / VM控制标志位

只有具有IO权限的程序才能改变IF控制标志位

1.3 段寄存器

1. 80386有6个16位的段寄存器

2. 段寄存器用于寻址内存段

3. 在保护模式下,要在一个给定的段中访问数据,程序需要先在适当的段寄存器中装载该段的段选择子

4. 因为有6个段寄存器,所以一个程序最多可以同时访问6个内存段

2 内存寻址机制

2.1 基于段的内存寻址

1. 80386基于段进行内存寻址,他将内存空间划分为一个或多个叫做段的线性区域

2. 在分段机制下,一个内存地址由两部分组成,

段地址:用于标识所容纳的段,在保护模式下,是一个16位的段选择子

偏移地址:用于标识在该段内以字节为单位的32位偏移量,由于偏移量是32位,所以一个段的最大长度是4GB

对于每个对内存的引用,都必须指定段地址和偏移地址

3. 在本系列笔记中使用如下方式表示内存地址,

段地址 : [偏移地址]

① 段地址可以是段名或段寄存器

② 偏移地址包括在方括号中,以数或者寄存器的方式给出,而且可以用表达式表示,详见下文

说明:上述通过段地址 : [偏移地址]给出的内存地址称为逻辑地址,是一个二维地址。后续笔记会说明如何将这个二维地址重定位到一维的线性地址空间

其实这种方式是很容易想到的,因为段是一个线性区域,而通过段地址选择了一个段。那么将这个段的起始线性地址与偏移地址相加,就得到了相应的线性地址

下面就分别说明逻辑地址中的段地址和偏移地址如何构成

2.2 段地址的构成

2.2.1 概述

1. 每个内存引用,都需要指定段寄存器

2. 段寄存器可以显式、隐式或默认地指定

2.2.2 隐式与默认引用

1. 代码段的引用总是使用CS段寄存器,结合EIP寄存器,CS : [EIP]指向下一条要执行的指令、

2. 栈段的引用总是使用SS段寄存器,结合ESP寄存器,SS : [ESP]指向当前栈顶

3. 引用除栈段之外的数据段默认使用DS段寄存器

4. 某些字串处理指令总是使用ES段寄存器作为目标操作数的段寄存器

说明:当前代码段与当前栈段

① 当前CS段寄存器指向的代码段称为当前代码段,由于所有代码段的引用都使用CS段寄存器,所以任何时候只有一个代码段是可以寻址的

② 出于类似的原因,当前栈段也只能有一个,由SS段寄存器寻址

③ 但是一个程序可以同时使用多个数据段(非栈段数据段)

2.2.3 显式指定

1. 在寻址一般的数据段时,可以显式指定使用DS / ES / FS / GS段寄存器(DS段寄存器为默认选项)

2. 相较于使用默认的DS段寄存器,显式指定使用ES / FS / GS段寄存器的指令会增加一个字节的开销(后文将会看到,需要增加一个字节的段超越前缀)

说明:指定段寄存器的策略

根据上述特征,可以使用DS段寄存器寻址最经常引用的数据段,而使用ES / FS / GS段寄存器寻址不常引用的数据段

2.3 偏移地址的构成

引用内存操作数的每条指令规定了计算偏移地址的方法,这种方法就叫做指令的寻址方式。80386的寻址方式总则如下,

偏移地址 = 基地址 + (变址 * 比例因子) + 偏移量

下面说明各部分构成,

2.3.1 基地址

1. 基地址存储在基址寄存器中

2. 8个通用寄存器中的任意一个可以用作基址寄存器

3. 偏移地址中可以不包含基地址部分

2.3.2 变址

1. 变址存储在变址寄存器中

2. 除ESP寄存器之外的通用寄存器中的任意一个可以用作变址寄存器

3. 偏移地址中可以不包括变址部分

2.3.3 比例因子

1. 如果指定了变址寄存器,包含在该寄存器中的值再加到偏移地址之前,可以乘以比例因子进行换算

2. 比例因子可以是1 / 2 / 4 / 8

2.3.4 偏移量

1. 可以指定8位或32位的常数偏移量

2. 偏移地址中可以不包括偏移量

说明1:基址寄存器对应的默认段寄存器

对于数据索引,其默认的段寄存器取决于所选择的基址寄存器(所选的变址寄存器对此没有影响)

② 如果基址寄存器为ESP或EBP,默认使用的段寄存器为SS

③ 对于其他的基址寄存器,包括没有基址寄存器的情况,默认使用的段寄存器为DS

说明2:80386之所以提供复杂的寻址方式,是为了直接支持高级语言中数据结构的寻址需要,比如访问数组或结构体中成员的需要

2.4 程序栈

2.4.1 栈的基本操作

1. 在80386中,使用满减栈,并且每个栈元素为4B,SS : [ESP]指向当前栈顶

2. 栈的基本操作为PUSH和POP指令,分别实现元素的压栈和出栈

3. 如果栈是对齐的,即ESP寄存器中总是包含4的倍数,则栈上的操作数可以很快被引用

2.4.2 子程序与栈

1. 80386通过栈实现了有效的子程序调用和返回机制,其中,

① CALL指令将返回地址(CALL指令下一条指令的地址)压入栈中,并且跳转到子程序执行

② RET指令将返回地址从栈中弹出,实现子程序的返回

很显然,这要求在子程序中保持栈平衡,否则子程序无法正确返回

2. 程序栈也可用于向子程序传递参数,以及存储子程序的局部变量,一般步骤如下,

① 将参数压入程序栈

② 调用CALL指令将子程序的返回地址压入栈中,并跳转到子程序执行

③ 从ESP寄存器中减去一个常数,在栈上为局部变量流出空间

说明1:上述传递参数和局部变量均通过栈来传递和使用,但是访问内存属于慢速操作。因此可以将开头的少数几个参数和最经常使用的局部变量存储在通用寄存器中,这样可以加快程序的执行速度

这点在实际工作中,主要是依赖C编译器的函数调用规则,以及编译器的实现水平

说明2:在子程序中如何索引参数和局部变量

① 按上文说明的步骤,在压入参数、CALL指令跳转以及为局部变量预留栈空间之后,栈的布局如下图所示。可见通过ESP寄存器就可以实现对参数和局部变量的索引

② 但是在执行子程序时,子程序的压栈 / 出栈操作均会导致ESP寄存器值变化,要想继续正确索引,就需要正确追踪ESP寄存器的变化

为了简化这种操作,引入了EBP寄存器,EBP寄存器可以作为一个稳定的基址寄存器指向当前子程序的栈,这样参数和局部变量就可以按相对于EBP寄存器固定的偏移量来寻址

下面给出一张通过EBP寄存器索引参数和局部变量的示意图,详细内容可参考庖丁解牛Linux内核分析01:操作系统工作原理基础 chapter 3.3.2

使用EBP寄存器还带来了一个额外的好处,就是可以通过EBP寄存器回溯函数调用栈。在引入了EBP寄存器之后,程序栈就细化为了函数调用栈帧的堆叠,ESP和EBP寄存器共同维护了当前函数的栈帧

③ 80386为了支持上述特性,才将EBP作为基址寄存器时的默认段寄存器设置为SS,这样可以指令中1B的段超越前缀开销

2.5 指针数据类型

2.5.1 指针类型

指针数据类型即包含有各种数据的地址的一个值,根据上文说明的逻辑地址构成(段选择子 + 偏移地址),指针也分为2种类型,

1. 48位全指针

① 包含段选择子和偏移地址,其中偏移地址在低32位,段选择子在高16位

② 当要用指针来索引不同段中的数据时,需要使用48位全指针

2. 32位指针

① 只包含地址的偏移部分

② 只有当要索引的数据存储在同一个段时,才能使用32位指针

2.5.2 指针数据的使用

使用指针数据就是将指针加载到寄存器中进行寻址操作

1. 如果使用48位全指针,则需要将段选择子部分加载到段寄存器中,将偏移地址部分加载到通用寄存器中

2. 如果使用32位指针,则只需要将偏移地址部分加载到通用寄存器中

说明1:后续笔记中将看到,80386指令集中提供了LDS / LES / LFS / LGS指令,这些指令可以在一条指令中将48位全指针加载到段寄存器和指定的通用寄存器中

以Linux 0.11内核head.S文件中的startup_32函数为例,通过lss指令将stack_start处存储的48位全指针加载到SS段寄存器和指定的ESP寄存器,从而实现栈的设置

说明2:在存储消耗和执行时间上,32位指针比48指针更有效。因此,如果一个程序将所有的数据和栈都存储在同一个段中,则可以都使用32位指针,这个就是平坦模型的理论基础。关于平坦模型的详细内容,可参考X86汇编语言从实模式到保护模式20:平坦模型

此处"存储在同一个段中"的实现方法如下,

① 划分出一段线性地址空间作为一个段,并且设置并登记该段的段描述符

② DS / SS / ES段寄存器均加载这个段的段选择子,从而指向同一个段

2.6 程序分段策略

2.6.1 多段模式

1. 多段模式将一个应用程序的程序单元分配存储在多个段中,以上图为例,程序1和程序2分别有自己的代码段、数据段和栈段,同时两个程序还有一个共享的数据段

2. 多段模式在重定位、共享和保护各个程序单元方面有最大的灵活性;但是需要使用48位全指针来索引内存

3. 对于共享的数据段,两个应用程序可以构造不同的段描述符来描述他,从而使得不同应用程序对共享段有不同的权限(e.g. 对某个程序设置为只读)

2.6.2 单段模式

1. 单段模式中每个应用程序只使用2个段来存储,

① 一个包含应用程序的所有代码

② 一个包含应用程序的所有数据和栈

2. 因为每个应用程序只有一个数据段,因此应用程序可以使用32位指针来索引内存;但是无法像多段模式那样共享数据段

说明1:关于平坦模型

平坦模型在单段模式的基础上更进一步,将程序的所有代码段和数据段都存储在一个段中。由于平坦模型一般都是配合分页机制使用,所以此时分段机制就仅仅是实现对线性地址空间的划分了

② 时刻要记住,段本质上是内存空间中的一段线性区域。而线性区域的含义,就是索引这段区域使用的是一维地址(在80386中通过将二维的逻辑地址映射而来)

③ 在80386 + Linux的环境中,平坦模型就是将所有代码段和数据段都存储在0 ~ 4GB的线性区域中,也就是同一个0 ~ 4GB的段中

④ 需要注意的是,对于这个0 ~ 4GB的线性区域(也就是段),可以构造多个段描述符进行描述,以使其具有不同的属性(e.g. 可执行、可读可写等),供不同的程序单元使用(e.g. 程序的代码段就需要具有可执行权限)

只是所有程序单元通过各自的段描述符,最终映射到的线性区域,都是这个0 ~ 4GB的段

说明2:实际使用哪种分段策略需要在多段模式的灵活与单段模式的简单之间进行权衡,至少在Linux操作系统中,最后胜出的是简单,他使用平坦模型"绕过"了分段机制

3 指令编码简介

80386指令的通用格式如下,其中最左边的字段存储在较低的内存地址处

3.1 前缀字节

1. 前缀字节用来指定不常用的指令参数,这样可以使常规情况下的指令更为紧凑

2. 使用指令前缀的一个例子,是使用段超越前缀(e.g. mov ax, es:[0x100])。段超越前缀的使用场景,是在默认的段寄存器不合适时为内存寻址指定段寄存器

3. 其他的指令前缀还包括LOCK和RET前缀

3.2 操作码字节

1. 每条指令至少有1B的操作码

2. 某些指令需要2B的操作码

3.3 MODRM操作数指定符

1. 操作码决定了指令中是否存在MODRM操作数指定符

2. MODRM操作数指定符指令了两个操作数或一个操作数加上额外的操作码位

3.4 地址位移

1. MODRM字段中中的MOD子字段表示有无地址位移量以及位移量的长度

2. 只有内存操作数才存在地址位移量

3.5 立即常数

1. 操作码决定了指令中是否存在立即常数以及他的长度

2. 立即常数最大可达4B

4 IO空间简介

1. IO空间是80386中独立于内存空间的一个存储空间,他用来存储IO设备的控制通道

2. IO空间是不分段的,他是一个64KB2^16)的一维地址空间

3. IO空间也是按字节来编址的,IO通道可以是1 / 2 / 4B长度,对于多字节通道也是使用小端模式

4. 80386通过专门的IO指令(IN / OUT指令)来访问IO空间

5. 对IO空间也有保护机制,这种保护机制允许分别保护IO空间中的每个字节,但是与内存的保护机制是完全分开的。该保护机制通过TSS段中的IO映射基地址和EFLAGS中的IOPL字段实现,详见后续笔记

猜你喜欢

转载自blog.csdn.net/chenchengwudi/article/details/124293169