浮点寄存器栈的栈顶指针记录在状态寄存器的Bit11 ~ Bit13。共3位合计8个数值,分别为000 ~ 111,也就是0 ~ 7,指示当前的栈顶位置在哪个浮点寄存器上。假如为010,则浮点寄存器栈顶位于FPR2。执行一条FLD,

相当于1)

执行一条PUSH指令,状态寄存器的 Bit11 ~ Bit13减1,变成了001,这时TOP指针指向FPR1。

2)再把值写入ST(0)

  条件码(C0~C3):x87的条件码作用相当于x86中的Eflags寄存器的条件码的作用。
    栈顶指针(TOP):栈顶指针000~111是x87浮点寄存器的索引值。共8个值,分别指示FPR0~FPR7,在汇编语法层,ST(0)表示栈顶

 

《浮点仿真库的实现与VC6浮点库反汇编分析》

之六 x87 FPU编程

 

 

x87 FPU编程
前面几章都是讲述计算原理,在CPU支持的基本整型的基础建立了几个类型模板,支持通用用的 (也即没有长度或精度限制的)整型计算、定点数计算、浮点数计算、基本函数计算,实际上是实现了个C++仿真库。这些内容虽然对理解计算原理非常重要,但 除了CGUINT可能在密码领域有点价值之外,其他的几个模板应用价值均不大。
目前,支持IEEE标准的浮点硬件已经普及。通用CPU均已内置了浮点单元,最大可支持扩展双精度类型,足以满足一般计算的需要。特殊环境 下的开发(例如嵌入式系统)即使没有浮点硬件,编译器也可能提供基本的仿真库。毕竟,浮点运算已经是C/C++不可或缺的一部分。而且,在编译器中实现一 两种浮点类型(例如单精度类型和双精度类型),技术难度和工作量并不大(本书的代码就是一个证明),FORTRAN早在几十年前就是这么做的。因此,在实 际的开发工作中,重要的不是自己实现已经实现的东西,而且如何更好地控制浮点硬件,从而得到更稳定、更高效的代码。
自然,正如CPU一样,每一个浮点硬件系统都是不一样的,且不提运算过程的处理细节,即使是编程 模型、指令集也会有很大的区别,因此讲述所有的浮点硬件是不现实的。不过,它们一般都遵循IEEE标准,这意味着在逻辑层面,它们是大同小异的,因此讲述 所有的浮点硬件也是没有必要的。
INTEL的x86系列的CPU是目前 PC的主流,它内置的浮点处理单元x87 FPU(早年是x87数学协处理器)因而也成了最常用的浮点硬件。本章就以x87 FPU为例讲述浮点硬件编程,而后面的几章将反汇编分析建立在x87 FPU之上的VC6浮点库,从而得到计算编程系统的完整印象。
1 x87 FPU简史
早在16位的8086年代,8087就作为8086的数学协处理器出现。当时的8087是一个与8086分离的芯片,它使用的浮点指令是专 用的浮点指令,但浮点指令与其他8086指令混合在一起执行。后来,在80486中,x87 FPU出现了,取代了原先的协处理器,这是x87 FPU在IA-32体系中的首次出现。
x87 FPU延续了协处理器的浮点指令集。
2 编程环境
x87 FPU有专用的指令和配套的寄存器。它的指令是x87浮点指令,配套的寄存器有:8个80位的数据寄存器、1个16位的控制寄存器、1个16位的状态寄存 器、1个16位的标志寄存器、1个最后指令地址寄存器、1个最后操作数寄存器、1个操作码寄存器。其中,最后3个寄存器用于最后一次操作的信息记录,主要 用于异常处理。
编程主要是围绕数据寄存器堆栈、状态寄存器和控制寄存器进 行的,其他部分则多用于调试支持。
2.1 数据寄存器(Data Register)
FPU有8个80位 的寄存器(编号0-7),使用扩展双精度格式存储操作数。当内存中的数据载入数据寄存器时,如果数据格式不是扩展双精度格式,则在载入过程中进行格式转 换。
8个寄存器组成一个循环堆栈,栈顶纪录保存于状态寄存器中,相当于堆 栈指针。每次压栈(FLD指令载入数据),堆栈指针就减1,在0-7之间循环。代码并不直接使用这个指针操作这些寄存器,而是使用ST(0)~ST(7) 表示。ST(0)指栈顶,即状态寄存器中栈顶指针指示的那个寄存器。
假设 当前状态寄存器中的栈顶指针是N(即编号N的寄存器位于栈顶),则ST(i)对应的寄存器编号是:
N+i mod 8
例如初始化时状态寄存器是0,FLD指令装载了一个数据以后,指针减小1是7,则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指令存在资源共享问题,不可同时使用。
2.2 状态寄存器(Status Register)
x87 FPU有一个16位寄存器显示当前的状态:空闲标志(1位)、条件判断标志(4位)、数据寄存器堆栈指针(3位)、异常标志(6位)、全局错误标志(1 位)、堆栈错误标志(1位)。
一般在一个操作完成以后,通过检测状态寄存 器控制流程。在一般编程中,异常标志和条件标志是最重要的。其他部分仅在出现错误时才需要关注,详情清参阅文献[2]
 
(1)指令
状态寄存器是只读的,因此与它有关的指令只有一条(但有同步和不同步两个版本,参见3节),即 FSTSW。这条指令将状态寄存器中的内容传送至AX寄存器,位次序不变。由于状态寄存器共16位,因此正好填满AX。通过检测AX的对应位,即可得到状 态控制器的相关信息,例如前面检测条件位C0的代码:
FSTSW                        ;传送至AX
AND     EAX,100H            ;测试C0=1?
JNZ     MYNEXT2             ;ST0 < ST1
 
(2)堆栈指 针
位11至位13是堆栈指针(共3位),其值在0和7之间循环。
与常规x86指令使用寄存器模式不同,浮点指令使用堆栈模式(更确切地说,是寄存器模式和堆栈 模式的混合),即一般的浮点指令从栈顶取操作数,结果也存储于栈顶,例如FADDP指令就是如此,它的操作数来自ST(0)和ST(1),结果存储于 ST(0)。
一般情形下,用户只需保证堆栈不出现错误(例如溢出),无需 特别在意它的指针。但在特殊情形下,移动堆栈指针可能有利于提高效率。例如正在进行一个复杂的计算,中间结果占据了多个数据寄存器:
 
data1
 
data2
 
data3
 
data4
TOP->
data5
 
data6
 
data7
 
data8
假 设当前的堆栈指针位于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)异常位
第 6章已经提及IEEE定义了5种异常,每种异常均对应一个位,共5个位。除此而外,还有一个弱规范数异常,当操作数中出现弱规范数时,这个异常位就会被设 置(但扩展双精度格式例外)。
在这些硬件基础上,可以建立两种异常处理模 式:正常模式和安静模式。
正常的异常处理是硬件触发异常,被内核(操作系 统代码)捕获,然后由内核传递至用户层,交由指定的异常代码处理。这种处理模式的特点是,当一切正常、没有异常触发时,异常处理代码不会被激活,如同不存 在一样,因此任务代码执行效率较高。但是一旦触发了异常,内核代码和用户代码均被激活,处理代码的执行效率较低,系统受到较大干扰。
安静模式就是屏蔽一切异常,但在任务代码执行过程中,在一些关键处(例如结果出来时)进行异常 检测。如果检测到异常就进行异常处理,否则继续执行任务代码。这种模式即使在异常出现时也不会激活内核代码,对系统干扰较小,异常处理代码的效率也高。但 是,大量的检测代码即使在没有异常时也需要运行(否则怎么知道是否发生异常了呢),从而导致任务代码效率低下。
两种模式各有优缺点,选择的关键在于异常被触发的频率。如果频率较低,那么正常模式有优势;反 之,安静模式有优势。幸运的是,在浮点运算中,这两种模式你都可以根据情形选用。Windows标准的异常处理模式就是正常模式,常见的try- except块和try-catch块就是这种模式在C/C++中的对应物。与此同时,VC6的浮点数学库却使用安静模式处理数学函数内部可能出现的异 常,例如如果想处理asin()可能出现的定义域错误(非法操作异常的一种情形),那么只需提供一个_matherr()即可。不过这种模式仅用于VC6 的数学函数库,对普通的浮点代码无效(VC6浮点库没有提供异常检测函数)。
 
(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指令是P6系列才引入IA-32体系的,先前的CPU不支持。因此,如果需要考虑兼容性,FCOM指令是唯一的选择。C0,C1,C2,C3的 意义参见附录B指令说明部分。
另一个需要特别注意的细节是FCOMI指令 的特性,它只设置EFLAGS寄存器的ZF、CF和PF,没有设置SF和OF,因此FCOMI指令的结果类似无符号整型的结果,这意味着紧跟的分支指令应 该使用JA/JAE/JE/JBE/JB,而不要使用JG/JGE/JE/JLE/JL。否则,结果会出错。
2.3 控制寄存器(Control Register)
在浮点运算中,需要控制的一般就是计算精度、舍入模式和异常,因此控制寄存器非常简洁。
唯一比猜测多出的是无穷控制位(Infinity Control),这是287数学协处理器的遗迹(我始终没有弄明白,287数学协处理器怎么用这个位),在IA-32体系中已没有用处。
编写控制寄存器的操作代码需要非常小心,最常见的错误是在一个域时无意中改变了另一个域,例如 下列代码:
controls = EM_PRECISION;        // 屏蔽精度异常
__asm FLDCW controls             // 设置
这段代码的目的是屏蔽精度异常,但实际上它也同时清除了对其他异常的屏蔽以及精度设置和舍入模式 设置。更为麻烦的是,改变精度设置和舍入模式导致的计算精度不够或错误是很难察觉的。避免这种错误的基本方法就是对控制寄存器操作提供标准函数,不允许其 他代码操作控制寄存器。
本节给出的代码仅供参考,更为通用的代码将在第 10章VC6浮点函数库中给出。
 
(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)控 制使用的精度:
精度
PC
IEEE单精度24位
00B
保留
01B
IEEE双精度53位
10B
IEEE扩展双精度64位
11B
同样可以定 义常量:
#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控制舍入模式的选择:
舍入模式
RC
最近舍入
00B
向-∞舍入
01B
向+∞舍入
10B
向0舍入(截断舍入)
11B
同样可以定 义常量:
#define RC_MASK                 0xC00
#define RC_NEAREST              0x000
#define RC_DOWN                 0x400
#define RC_UP                   0x800
#define RC_TRUNCATE            0xC00
使用最近舍入模式的设置代码:
unsigned short controls;
__asm FSTCW controls             // 获 取控制寄存器内容
controls &= ~RC_MASK;             // RC位清零即是最近舍入
__asm FLDCW controls             // 设置
2.4 其他寄存器
除 了控制寄存器和状态寄存器,x87 FPU还有几个寄存器,这些寄存器的主要作用在于内部使用或调试而非一般编程的接口。
 
(1) 标志寄存器(Tag Register)
在操作数据寄存器堆栈时,如果寄 存器为空(即没有初始化过),那么一般浮点指令将出错;如果寄存器已经有数据,那么FLD指令将出错。这么规定的目的是在出现误操作时通过触发异常(堆栈 溢出)报警,避免计算错误,提高系统的稳定性。但是,仅仅根据数据寄存器的内容(即数据本身)无法判断该寄存器是否为空,于是就出现了标志寄存器。
标记是否为空只需1位,但是每个标记使用了2位,这样就有4种可能的标记:
标记
说明
00
一般有限数
01
0
10
特殊值,例如非法格式、NaN、±∞、弱 规范数
11
空,即没有初始化过
可见,虽然标记的最初目的是用 于标记是否为空,但兼顾了优化计算的目的。除了空之外的3个标记显然是按计算特性划分的。一般有限数需要进行计算,0可以快速处理,而特殊值不能参与计 算,只能特殊处理。
 
(2)调试寄存器
为 了进一步支持调试,x87 FPU还实现了最后指令地址寄存器、最后操作数地址寄存器和操作码寄存器。
浮点计算过程中,当异常被触发时,调试最需要知道的自然是哪儿触发了这个异常的以及在干什么时触 发的。最后指令地址寄存器给出异常指令的地址,如果这条指令使用内存操作数,那么最后操作数地址寄存器给出操作数的内存地址。
所给出的地址是48位全局地址,即选择子:偏移形式,例如CS:EIP或DS:ESI。相关资料 可以参阅文献[2]或《386保护模式编程》。
这些寄存器最可能的用户是 调试器,一般用户不大可能编写指令级异常处理程序,即使是调试错误也是在调试器的支持下进行的,因此知道即可,无需关心细节。
3 x87 FPU指令
3.1 指令简介
绝大部分浮点指令都是同步指 令,即当指令返回时,功能已经完成,但有部分控制指令除了同步指令外,还有一个非同步指令的形式,例如状态寄存器存储指令FSTSW就有一个非同步指令 FNSTSW。
其实,同步指令实际上是两条指令,例如FSTSW指令实际 上是:
FWAIT
FNSTSW
在 编程的其他领域,有时也有非同步代码(一般称异步代码,例如Wini32 API的WriteFile接口就有异步调用方式),使用它们的主要目的是使更多的硬件设备并行,从而提高效率,但在x87 FPU编程中,使用非同步指令的主要目的是避免异常检查。也就是说,同步指令在功能完成即将返回之前会进行异常检查(即检查状态寄存器的异常位),如果发 现有异常位被设置且控制寄存器对应的异常屏蔽位没有设置,那么将触发异常,而非同步指令不做这种检查,因此即使有异常发生而且没有被屏蔽,也不会触发异 常。
3.2 指令分类
x87 FPU指令集大约有近100条指令,下面对这些指令作简要介绍,更进一步的资料请参阅附录A浮点指令说明或文献[2]。
 
(1)数据传 输指令
这些指令类似x86指令集中的MOV指令,它们将数据在数据寄存器 之间、数据寄存器和内存之间进行传输。数据格式随指令不同,可能是浮点数,也可能是整数或BCD码。当源操作数和目的操作数的数据格式不一致时,还会进行 数据格式转换。某些指令(例如FCMOVcc指令)有兼容性问题,P6系列以前的CPU不支持。
FLD/FILD/FBLD
将内存或数据寄存器中的数据载入数据堆栈
FST/FSTP/FIST/FISTP/FBSTP
将ST(0)中的数据保存到指定的内存或数据寄存器
FXCH
交换两个数据寄存器中的内容
FCMOVcc
满足条件cc则将指定的数据寄存器拷贝到ST(0)
 
(2)常量装载指令
浮点运算中常用到一些常量,例如1.0和π, x87 FPU内置了这些数据方便使用。由于数据寄存器使用扩展双精度格式,因此这些内置数据的精度至少达到了64位的精度。
FLD1
载入+1.0
FLDZ
载入+0.0
FLDPI
载入π
FLDL2E
载入log 2e
FLDLN2
载入log e2,即ln2
FLDL2T
载入log 210
FLDLG2
载入log 102
 
(3)基本算术指令
常说的四则运算及其他一些相关指令。不过,不对称的双目运算符(例如减法和除法)引入了反向运算,这些指令完成同样的功能,但参数的位置正 好颠倒了。引入这些指令的目的显然是减少数据寄存器操作,方便使用。
FADD/FADDP/FIADD
加法
FSUB/FSUBP/FISUB
减法
FSUBR/FSUBRP/FISUBR
反向减法
FMUL/FMULP/FIMUL
乘法
FDIV/FDIVP/FIDIV
除法
FDIVR/FDIVRP/FIDIVR
反向除法
FPREM/FPREM1
计算部分余数,由于取整不同有两种。
FABS
计算绝对值
FCHS
改变符号,即计算相反数
FRNDINT
取整运算
FSCALE
将操作数放大2的幂次方,例如y×2 n
FSQRT
平方根运算
FXTRACT
将浮点数分解为指数和尾数
 
(4)比较指令
比 较两个数的大小,即常说的逻辑运算。
FCOM/FCOMP/FCOMPP
比较大小,结果在C0、C2和C3中
FUCOM/FUCOMP/FUCOMPP
比较大小,结果在C0、C2和C3中
FICOM/FICOMP
整数比较大小,结果在C0、C2和C3中
FCOMI/FCOMIP
比较大小,结果在EFLAGS中
FUCOMI/FUCOMIP
比较大小,结果在EFLAGS中
FTST
与0.0比较,结果在C0、C2和C3中
FXAM
判别数据类型,结果在C0、C2和C3中
 
(5)超越函 数指令
计算一些常见的超越函数值,主要是三角函数、反三角函数和对数函 数。某些函数可能有操作数大小限制(例如Sine函数),即使定义域是整个实数域,需要特别注意。
FSIN
Sine函数
FCOS
Cosine函数
FSINCOS
一次完成Sine函数和Cosine函数的计算
FPTAN
部分正切函数
FPATAN
部分反正切函数
F2XM1
计算2 x-1
FYL2X
计算ylog 2x
FYL2XP1
计算ylog 2(x+1)
 
(6)控制指令
这 些指令控制x87 FPU的各个方面,例如读写控制寄存器、读状态寄存器等。
FINCSTP/FDECSTP
增减堆栈指针
FFREE
清空数据寄存器
FINIT/FNINIT
初始化x87 FPU
FCLEX/FNCLEX
清除异常标志
FSTCW/FNSTCW
读控制寄存器
FLDCW
写控制寄存器
FSTENV/FNSTENV
保存x87 FPU执行环境,不包括数据寄存器
FLDENV
载入x87 FPU执行环境,不包括数据寄存器
FSAVE/FNSAVE
保存x87 FPU信息,包括数据寄存器
FRSTOR
载入x87 FPU信息,包括数据寄存器
FSTSW/FNSTSW
读状态寄存器
FWAIT
等待
FNOP
空操作
3.3 指令使用
(1) 设备初始化
初始化硬件只有一条指令FINIT,执行FINIT指令之 后,x87 FPU硬件复位。控制寄存器值是0x037F,即所有异常被屏蔽、最近舍入、64位运算精度。状态寄存器清零,即没有异常、堆栈指针是0。标志寄存器是 0xFFFF,即8个数据寄存器中全空,没有有效数据,但实际上,8个数据寄存器的内容没有改变。FINIT指令在复位硬件前会检查状态寄存器中的异常状 态纪录,如果有异常发生且控制寄存器没有屏蔽该异常则触发该异常,FNINIT指令完成与FINIT一样的初始化功能,但不进行异常检测。
 
(2)系统 设置
FINIT指令实际上也作了缺省设置,不过,它的设置可能不符合你的 需要。x87 FPU的设置一般有三个方面:异常处理、计算精度和舍入模式,实际上就是控制寄存器的设置。具体操作参见2.3节和10.1节。
状态寄存器和标志寄存器是只读的,一般不会改写,但可以通过特殊指令改写(FSAVE和 FRSTOR指令)。
 
(3)计算
计算 过程中需要特别注意的是浮点堆栈。由于浮点堆栈不用于程序控制,出了问题以后不会立即表现出来,即使表现出来也不一定会导致程序崩溃,因此相当隐秘。在 C/C++等高级语言编程过程中,编译器处理了这个问题,但在汇编语言中需要自己处理这个问题。
在代码编写过程中,要清楚自己使用的数据寄存器内容及个数,一旦不需要就及时清空。数据寄存器只 有8个,因此除非做好了详细计划,一般不要使用它们长期存储数据。减少寄存器使用个数的关键在于挑选合适的指令并且安排好指令序列,尽量减少中间结果的个 数。例如实现a+b+c的代码可以是:
FLD a
FLD b
FLd c
FADDP ST(1), ST(0)
FADDP ST(1), ST(0)
最多使用了3个数据寄存器,换个形式:
FLD a
FADD b
FADD c
只使用了1个数据寄存器。
 
(4)设备信息的维护
现代操作系统多是多用户系统,有多任务(进程或线程)调度功能,用户任务经常被切换。假设任务A 正在进行浮点运算,x87 FPU正在执行一个指令序列,此时发生了线程切换,线程B取代线程A获得了对x87 FPU的控制,线程B自然也可能使用x87 FPU,那么显然线程A的中间结果(正在数据寄存器堆栈中)和设置将被破坏。当线程A再度获得对x87 FPU的控制时,此时的x87 FPU已是线程B使用时的状态,线程A的计算将出现错误。同样,线程B也是一团糟。这个问题实际上是个简单的共享信息问题,解决之道就是在任务切换时进行 信息的保存和恢复,从而使各任务的信息互不干扰。x87 FPU为了支持这一功能,提供了两条指令,即FSAVE指令和FRSTOR指令。
当任务被挂起时,操作系统任务调度模块使用FSAVE指令将x87 FPU保存于任务数据块中。当任务被恢复时,使用FRSTOR指令将FSAVE指令保存的数据载入x87 FPU。如此,计算将不受任务切换的影响。
如果你想自己处理浮点异常,那么就需要熟悉FSAVE指令保存的信息格式,因为在 Windows中,当异常发生时,内核正是通过FSAVE指令将x87 FPU的信息保存起来传递给异常处理代码。当然,在这个层次处理异常通常是操作系统和浮点库的任务。除非你想创建一个浮点库并且该浮点库完善到自己进行异 常处理,否则你不需要熟悉FSAVE指令和FRSTOR指令。
4 代码示例
为测试方便,本节代码使用了VC6的嵌入式汇编,但使用了naked函数修饰符,尽量避免嵌入 式汇编的特征。由于VC6 CRT的初始化代码在程序启动时就已经初始化了x87 FPU以支持double类型,而本节代码也使用双精度类型,因此无需再初始化x87 FPU,相关的设置也被忽略。实际上,本节只需注意x87 FPU的指令和寄存器的使用即可。
数组求和FloatSum()演示基本 的浮点操作,冒泡排序FloatSort()演示浮点比较操作,Sine函数表FloatSin()演示堆栈管理。所有函数均使用C/C++缺省的 __cdecl调用约定,因此返回时无需清理堆栈。
为了尽量减少代码,所 有函数都没有进行输入值检测等工作,实际代码应该做这项工作以提高稳定性。
4.1 数组求和
数组求和是非常简单的常用代 码:
double FloatSum( double* pd, int count )
{
    double sum = 0.0;
    for( int i = 0; i<count; i++ )
        sum += pd[i];
    return sum;
}
将上述逻辑转 换为汇编代码,除了常规的循环控制之外,只使用了FLD和FADD两条浮点指令以及ST(0)寄存器。由于VC6将ST(0)作为浮点返回值,因此浮点堆 栈指针问题也很简单,几乎无需注意。代码如下:
__declspec(naked) double FloatSum( double* pd, int count )
{
    __asm
    {
        PUSH   ESI
        FLDZ                      // ST0清 零
        MOV    ESI, [ESP+8]     // pd
        MOV    ECX, [ESP+12]    // count
 
    MYLOOP:
        JECXZ MYEXIT   
         FADD  QWORD PTR [ESI]     // 相加
        DEC   ECX                    // 计数器
        ADD   ESI, 8                // 地址递增
        JMP    MYLOOP
 
    MYEXIT:
        POP    ESI
        RET                         // 结 果在ST0中返回
    }
}
4.2 冒泡排序
这个程序将给定的数组按从小到大的次序排列,逻辑要复杂一些,C代码是:
void FloatSort( double* pd, int count )
{
    int bContinue = 1, i, k;
    double temp;
 
    k = count;
    while( bContinue )
    {
        k--;
        bContinue = 0;
        for( i=0; i<k; i++ )
        {
            if( pd[i] > pd[i+1] )
            {
                temp = pd[i];
                pd[i] = pd[i+1];
                pd[i+1] = temp;
                bContinue = 1;
            }
        }
    }
}
转换为汇编 后,复杂的是双重循环,浮点运算其实很简单:
__declspec(naked) void FloatSort( double* pd, int count )
{
    __asm
    {
        PUSH   ESI
        MOV    EDX, [ESP+12]        // count
        MOV    ECX, 1                // bContinue = 1
    MYLOOP1:
        JECXZ  MYEXIT
        MOV    ESI, [ESP+8]
        XOR    ECX, ECX              // bContinue = 0
 
        DEC    EDX                    // k
        XOR    EAX, EAX              //
    MYLOOP2:
        CMP    EAX, EDX              // i<k?
        JAE    MYLOOP1
        INC    EAX
 
        // 载入比较,结果在EFLAGS中
        FLD    QWORD PTR[ESI]       // pd[i]>pd[i+1]?
        FLD    QWORD PTR[ESI+8]
        FCOMI  ST(0), ST(1)
        JB     MYNEXT2
 
        FSTP   ST(0)                 // 无须交换,恢复堆栈
        FSTP   ST(0)
        ADD    ESI, 8
        JMP    MYLOOP2
 
    MYNEXT2:
        FSTP   QWORD PTR[ESI]        // 需要交换
        FSTP   QWORD PTR[ESI+8]
        MOV    ECX, 1
        ADD    ESI, 8
        JMP    MYLOOP2
 
    MYEXIT:
        POP    ESI
        RET
    }
}
4.3 Sin函数表
从逻辑上看,这也是一个简单的循环计算:
void FloatSin( double* pd, int count )
{
    double step = MATH_PI*2.0/(count-1), data = 0.0;
    for( int i=0; i<count; i++ )
    {
        pd[i] = sin( data );
        data += step;
    }
}
转换为汇编代 码时,初始化要复杂一些,而且使用了多个浮点寄存器(最多时同时使用3个),需要注意浮点堆栈管理。
__declspec(naked) void FloatSin( double* pd, int count )
{
    __asm
    {
        PUSH   EDI
        MOV    EDI, [ESP+8]
        MOV    ECX, [ESP+12]
 
        // 计算步长
        FLDPI                          // 通过载入两个π相加得到2π
        FLDPI
        FADDP ST(1), ST(0)          // ST0是2π
        DEC    DWORD PTR[ESP+12]    // count-1
        FILD   DWORD PTR[ESP+12]
        FDIVP  ST(1), ST(0)          // ST0中是2π /(count-1)
 
        // 起始 值
        FLDZ                           // ST0是起始点,步长变成了ST1
 
    MYLOOP:
        JECXZ    MYEXIT
 
        FLD    ST(0)                  // FSIN会破环ST0,需要复制一份
        FSIN                           // 计算结果在ST0中
        FSTP    QWORD PTR[EDI]      // 存 储结果,注意堆栈
        FADD    ST(0), ST(1)         // ST0中是下一个计算点
 
        DEC   ECX
        ADD   EDI, 8
        JMP    MYLOOP
 
    MYEXIT:
        POP    EDI
        FSTP   ST(0)                 // ST0和ST1用于起始点和步长,需要弹出
        FSTP   ST(0)
        RET
    }
}
另外有一个 小细节需要注意,就是在Intel手册中,下列指令:
FADDP ST(1), ST(0)
FDIVP ST(1), ST(0)
有精简格式:
FADDP
FDIVP
但 VC6似乎不支持在嵌入式汇编中使用这种形式,会提示操作数错误。