第8章 x87 FPU编程

8.2 编程环境
 
 
 

x87 FPU有 专用的指令和配套的寄存器。它的指令是x87浮点指令,配套的寄存器有:880位的数据寄存器、116位的控制寄存器、116位的状态寄存器、116位的标志寄存器、1个最后指令地址寄存器、1个最后操作数寄存器、1个操作码寄存器。其中,最后3个寄存器用于最后一次操作的信息记录,主要用于异常处理。如图8-1所示。

8-1  x87 FPU寄存器说明(摘自文献[2]

编程主要是围绕数据寄存器堆栈、状态寄存器和控制寄存器进行的,其他部分则多用于调试支持。

 8.2.1  数据寄存器

x87 FPU880位的数据寄存器(编号07),使用IEEE扩展双精度格式存储操作数,如图8-1所示。当内存中的数据载入数据寄存器 时,如果数据格式不是扩展双精度格式,则在载入过程中进行格式转换。输出过程也是如此。

8个数据寄存器组成一个循环堆栈,栈顶记录保 存于状态寄存器中,相当于堆栈指针。每次压栈(FLD指令载入数据),堆栈指针就减1,在07之间循环。代码并不直接使用这个指针操作这些寄存器,而是使用ST(0)ST(7)表示。ST(0)指栈顶,即状态寄存器中栈顶指针指 示的那个寄存器。如图8-2所示。

 

8-2  x87 FPU的数据寄存器(摘自文献[2]

假设当前状态寄存器中的栈顶指针是N(即编号N的寄存器位于栈顶),则ST(i)对应的寄存器编号是:

N+i mod 8

例如初始状态时,堆栈指针是0FLD指令装载了一个数据以后,指针减小17,则ST(0)即是寄存器7,而ST(1)对应的寄存器编号是:

7+1 mod 8 = 0

这些计算实际上是以CPU管理寄存器的方式看待这8个寄存器组成的堆栈,比较晦涩。还有一种方式不是从CPU角度理解,而是从指令角度(也就是编程 角度)理解,直接将ST(0) ST(7)看成一个堆栈,ST(0)是栈顶,每次压栈,数据向上移动一个寄存器。例如载入一个数据,这个数据在ST(0)中,再次载入一个数据,则当前数 据在ST(0)中, 而上次载入的数据在ST(1)中。这种理解方式相对简易。

x87 FPU的数据寄存器是相对独立的,不受过程调用的影响。在线程、进程切换时,操作系统会保护这些寄存器,因 此它们也不受线程、进程切换的影响。这意味着这些寄存器是个存放数据和传送参数的好地方,例如VC6就将ST(0)作为浮点返回值的存放场所。

绝大部分浮点指令都会影响数据寄存器,影响是多方面的:数据寄存器的内容、堆栈指针、标志寄存器等都 会改变,例如FSIN指令就改写ST(0),自然标志寄存器也随之改变,其影响类似伪码:

ST(0) = FSIN( ST(0) )

TAG(0)= class( ST(0) )

FINCSTP则仅改变指针,类似:

TOP = TOP + 1

当然,大部分指令的影响要比这些复杂,例如FADDP指令,在使用时需要仔细阅读指令说明。

有一个细节需要注意,即通常使用FLD指令载入数据进行计算,使用FSTP等数据输出结果,但有时不需要输出数据寄存器(例如逻辑比较操作),而只是清空时,不能使用FINCSTP指令。虽然FINCSTP指令修改堆栈指针,但标志寄 存器没有改变,因此堆栈并未清空。一个简单的方法是使用下列指令:

FSTP ST(0)

数据寄存器、堆栈、标志寄存器等之间是联动的,在一般情形下,用户只 需关注自己的数据处理逻辑,无需关注它们之间关系的细节,同时也应尽量避免干预(例如直接修改堆栈指针)。一个简单的指导原则就是将堆栈指针和标志寄存器 当作只读的,仅用于调试。

还有一点需要特别注意,那就是MMX的数据寄存器与x87 FPU的数据寄存器虽然名字不同, 实际上却是通过别名机制共用数据寄存器。这意味着,MMX指令和x87 FPU指令存在资源共享问题,不可同时使用。

 8.2.2  状态寄存器

x87 FPU有一个16位寄存器显示当前的状态:空闲标志(1位)、条件判断标志(4位)、数据寄存器堆栈指针(3位)、异常标志(6位)、全局错误标志(1位)、堆栈错误标志(1位)。如图8-3所示:

 

8-3  x87 FPU的状态寄存器(摘自文献[2]

一般在一个操作完成以后,通过检测状态寄存器控制流程。在一般编程中,异常标志和条件标志是最重要的。其他部分仅在出现错误时才需要关注,详情 请参阅文献[2]

1)指令

状态寄存器是只读的,因此与它有关的指令只有一条(但有同步和不同步两个版本,参见8.3节),即FSTSW。这条指令将状态寄存器中的内容传送至AX寄存器,位次序不变。由于状态寄存器共16位,因此正好填满AX。通过检测AX的对应位,即可得到状态控制器的相关信息,例如前面检测条件位C0的代码:

FSTSW                     ;传送至AX

AND    EAX,100h           ;测试C0=1?

JNZ    MYNEXT2           ;ST0 < ST1

2)堆栈指针

11至 位13是堆栈指针 (共3位),其值 在07之间循环。

<DIV><DIV align=center> 

</DIV></DIV>
与常规x86指令使用寄存器模式不同,浮点指令使用堆栈模式(更确切地说,是寄存器模式和堆栈模式的混合),即一 般的浮点指令从栈顶取操作数,结果也存储于栈顶,例如FADDP指令就是如此,它的操作数来自ST(0)ST(1),结果存储于ST(0)

一般情形下,用户只需保证堆栈不出现错误(例如溢出),无需特别在意它的指针。但在特殊情形下,移动堆栈指针可能有利于提高效率。例如正在进行 一个复杂的计算,中间结果占据了多个数据寄存器,如图8-4所示。

8-4  堆栈示意图

假设当前的堆栈指针位于data5处,而需要计算sin(data2),那么如何实现呢?常 规方法(不直接操作堆栈指针)代码如下:

FSTP  data5

FSTP  data4

FSTP  data3

FSIN  data2

FLD  data3

FLD  data4

FLD  data5

如果直接操作堆栈指针(使用FINCSTP指令),那么代码如下:

FINCSTP

FINCSTP

FINCSTP

FSIN

FDECSTP

FDECSTP

FDECSTP

可以看出,在复杂计算中,常规方法的代价就是在数据寄存器和内存之间 进行多次数据传送,降低了执行效率,而直接操作堆栈指针可以避免这些问题。当然,这个例子是编造的,实际代码可能没有这么极端,而且操作堆栈指针相当危险 (清空堆栈时很容易造成堆栈溢出)。除非迫不得已,一般不建议这么做。

3) 异常位

5章已经提及IEEE定义了5种浮点异常,每种异常均对应一个位,共5个位。除此而外,还有一个弱规范数异常,当操作数中出现弱规范数时,这个异常位就会被设置(但扩展 双精度格式例外)。

在这些硬件基础上,可以建立两种异常处理模 式:正常模式和安静模式。

正常的异常处理是硬件触发异常,被 内核(操作系统代码)捕获,然后由内核传递至用户层,交由指定的异常代码处理。这种处理模式的特点是:当一切正常、没有异常触发时,异常处理代码不会被激 活,如同不存在一样,因此任务代码执行效率较高;但是一旦触发了异常,内核代码和用户代码均被激活,处理代码的执行效率较低,系统受到较大干扰。

安静模式就是屏蔽一切异常,但在任务代码执行过 程中,在一些关键处(例如结果出来时)进行异常检测。如果检测到异常就进行异常处理,否则继续执行任务代码。这种模式即使在异常出现时也不会激活内核代 码,对系统干扰较小,异常处理代码的效率也高。但是,大量的检测代码即使在没有异常时也需要运行(否则不知道是否发生了异常),从而导致任务代码效率低 下。

两种模式各有优缺点,选择的关键在于异常被触发的频率。如果频率较低,那么正常模式有优势;反之,安 静模式有优势。幸运的是,在浮点运算中,这两种模式都可以根据情形选用。Windows标准的异常处理模式就是正常模式,常见的try-except块和try-catch块就是这种模式在C/C++中的对应物。与此同时,VC6的浮点数学库使用安静模式处理数学函数内部可能出现的异常,例如,如果想处理asin()可能出现的定义域错误(非法操 作异常的一种情形),那么只需提供一个_matherr()即可。不过这种模式仅用于VC6的数学函数库,对普通的浮点代码无效(VC6浮点库没有提供异常检测函数)。

关于异常处理以及使用参见第12章。

4) 条件位

状态寄存器有4个条件位,虽然最常见的用处是给出逻辑比较的结果,但这只是它们在逻辑比较指令中的作用,在别的指 令中,它们还有其他作用,例如在FXAM指令中它们给出操作数的类型、在FPREM指令中它们返回商的最低3个位。考虑到这些,更确切的名字应该是指示位。

按返回结果方式的不同,x87 FPU有两类逻辑比较指令:一类指令直接将结果设置到EFLAGS寄存器中,例如FCOM指令;一类则将结果设置在状态寄存 器的条件位中,例如FCOMI指令。两者在使用上也有差异,例如比较两个数的大小,使用FCOMI指令是:

FLD    QWORD PTR[ESI]

FLD    QWORD PTR[ESI+8]

FCOMI  ST(0), ST(1)       ;占用EFLAGS部分

JB      MYNEXT2

需要将两个数都载入寄存器,但检测结果比较方便。而FCOM指令正好相反:

FLD    QWORD PTR[ESI]

FCOM   QWORD PTR[ESI+8]

FSTSW                        ;占用AX

AND    EAX,100h            ;测试C0=1?

JNZ     MYNEXT2           ;ST0 < ST1

只需载入一个即可,但检测结果比较麻烦,而且会破环AX

我喜欢使用FCOMI指令(此时无需关心C0,C1,C2,C3的意义),但在VC6浮点库中没有见到FCOMI指令。这里有一个重要的原因,那就是FCOMI指令是Pentium 6系列才引入IA-32体系的,先前的CPU不支持。因此,如果需要考虑兼容性,FCOM指令是唯一的选择。C0,C1,C2,C3的意义参见附录B指令说明部分。

另一个需要特别注意的细节是FCOMI指令的特性,它只设置EFLAGS寄存器的ZFCFPF,没有设置SFOF,因此FCOMI指令的结果类似无符号整型的结果,这意味着紧跟的分支指令应该使用JA/JAE/JE/JBE/JB,而不要使 用JG/JGE/JE/JLE/JL。否则,结果会出错。

 8.2.3  控制寄存器

在浮点运算中,需要控制的一般就是计算精度、舍入模式和异常,因此控制寄存器非常简洁,如图8-4所示。

对应的异常大多在5.3中已经讲述,唯一没有提到的是无穷控制位(Infinity Control)对应的异常,这是80287数学协处理器的遗迹(我始终没有弄 明白,80287数 学协处理器怎么用这个位),在IA-32体系中已没有用处。

编写控制寄存器的操作代码需要非常小心,最常见的错误是在设置一个域时无意中改变了另一个域,例如下列代码:

controls = EM_PRECISION;        // 屏蔽精度异常

__asm FLDCW  controls          // 设置

这段代码的目的是屏蔽精度异常,但实际上它也同时清除了对其他异常的 屏蔽以及精度设置和舍入模式设置。更为麻烦的是,改变精度设置和舍入模式导致的计算精度不够或错误是很难察觉的。避免这种错误的基本方法就是对控制寄存器 操作提供标准函数,不允许其他代码操作控制寄存器。

 

8-5  x87 FPU的控制寄存器(摘自文献[2]

1) 指令

控制寄存器可以读写,有两条指令,即载入指令FLDCW和存储指令FSTCW,两者的操作数均是16位内存单元。代码示例如下:

unsigned short controls;

__asm FLDCW controls

__asm FSTCW controls

__asm FLDCW WORD PTR [ESP+4]

2)异常屏蔽位

控制寄存器的低6位用于控制6种异常的屏蔽,设置相应位即可屏蔽异常。为了方便操作,可以定义一组常量:

#define EM_MASK                     0x3F

#define EM_INVALID_OPERATION      0x01

#define EM_DENORMAL_OPERAND     0x02

#define EM_ZERO_DIVIDE              0x04

#define EM_OVERFLOW                0x08

#define EM_UNDERFLOW              0x10

#define EM_PRECISION                 0x20

那么屏蔽精度异常的嵌入式代码就是:

unsigned short controls;

__asm FSTCW  controls             // 获 取控制寄存器内容

controls |= EM_PRECISION;          // 屏蔽精度异常

__asm FLDCW  controls             // 设置

清除代码就是:

unsigned short controls;

__asm FSTCW  controls            // 获取控制寄存器内容

controls &= ~EM_PRECISION;       // 清除精度异常

__asm FLDCW  controls            // 设置

3)精度控制

x87 FPU支持三种IEEE类型,即单精度、双精度和扩展双精度,对应的就有三种计算精度,即24位、53位和64位。控制寄存器中的PC位(位8和位9)控制使用的精度,见表8-1

8-1  x87 FPU的 精度控制位

<DIV align=center>

精度

PC

IEEE单精度24

00B

保留

01B

IEEE双精度53

10B

IEEE扩展双精度64

11B

</DIV>

 

同样可以定义常量:

#define PC_MASK                   0x300

#define PC_FLOAT_24               0x000

#define PC_RESERVED               0x100

#define PC_DOUBLE_53             0x200

#define PC_EX_DOUBLE_64          0x300

使用扩展双精度的设置代码:

unsigned short controls;

__asm FSTCW  controls          // 获取控制 寄存器内容

controls &= ~PC_MASK;          // 清零,否则可能设置错误!

controls |= PC_EX_DOUBLE_64    // 64位精度

__asm FLDCW  controls          // 设置

4) 舍入模式

x87 FPU均支持4.1.3节曾讨论的4种舍入模式,控制寄存器的位10和位11控制舍入模式的选择见表8-2

8-2  x87 FPU的 舍入控制位

<DIV align=center>

舍入模式

RC

最近舍入

00B

-∞舍入

01B

+∞舍入

10B

0舍入(截断舍入)

11B

</DIV>

同样可以定义常量:

#define RC_MASK0                 0xC00

#define RC_NEAREST0              0x000

#define RC_DOWN0                0x400

#define RC_UP0                      0x800

#define RC_TRUNCATE0          0xC00

使用最近舍入模式的设置代码:

unsigned short controls;

__asm FSTCW  controls         // 获取控制寄 存器内容

controls &= ~RC_MASK;         // RC位清零即是最近舍入

__asm FLDCW  controls         // 设置

 8.2.4  其他寄存器

除了控制寄存器和状态寄存器,x87 FPU还有几个寄存器,这些寄存器的主要作用在于内部使用或调试,而非一般编程接口。

1)标志寄存器

在操作数据寄存器堆栈时,如果寄存器为空(即没有初始化过),那么一般浮点指令将出错;如果寄存器已经有数据,那么FLD指令将出错。这么规定的目的是在出现误 操作时通过触发异常(堆栈溢出)报警,避免计算错误,提高系统的稳定性。但是,仅仅根据数据寄存器的内容(即数据本身)无法判断该寄存器是否为空,于是就 出现了标志寄存器。

标记是否为空只需1位,但是每个标记使用了2位,这样就有4种可能的标记,见表8-3

8-3  x87 FPU的标志寄存器

<DIV align=center>

标记

说明

00

一般有限数

01

0

10

特殊值,例如非法格式、NaN、±∞、弱规范数

11

空,即没有初始化过

</DIV>

 

可见,虽然标记的最初目的是用于标记是否为空,但兼顾了优化计算的目的。除了空之外的3个标记显然是按计算特性划分的。一般有限数需要进行计算,0可以快速处理,而特殊值不能参与计算,只能 特殊处理。

2)调试寄存器

为了进一步支持调试,x87 FPU还实现了最后指令地址寄存器、最后操作数地址寄存器和操作码寄存器。

浮点计算过程中,当异常被触发时,调试最需要知道的自然是哪儿触发了 这个异常以及在干什么时触发的。最后指令地址寄存器给出异常指令的地址,如果这条指令使用内存操作数,那么最后操作数地址寄存器给出操作数的内存地址。

所给出的地址是48位全局地址,即选择字:偏移形式,例如CS:EIPDS:ESI。更详细的说明可以参阅文献[2]

这些寄存器最可能的用户是调试器,一般用户不大可能编写指令级异常处理程序,即使是调试错误也是在调试器的支持下进行的,因此知道即 可,无需关心细节。