|
|||||||||||||||||||||||||||||||
x87 FPU有 专用的指令和配套的寄存器。它的指令是x87浮点指令,配套的寄存器有:8个80位的数据寄存器、1个16位的控制寄存器、1个16位的状态寄存器、1个16位的标志寄存器、1个最后指令地址寄存器、1个最后操作数寄存器、1个操作码寄存器。其中,最后3个寄存器用于最后一次操作的信息记录,主要用于异常处理。如图8-1所示。
图8-1 x87 FPU寄存器说明(摘自文献[2]) 编程主要是围绕数据寄存器堆栈、状态寄存器和控制寄存器进行的,其他部分则多用于调试支持。 8.2.1 数据寄存器x87 FPU有8个80位的数据寄存器(编号0~7),使用IEEE扩展双精度格式存储操作数,如图8-1所示。当内存中的数据载入数据寄存器 时,如果数据格式不是扩展双精度格式,则在载入过程中进行格式转换。输出过程也是如此。 8个数据寄存器组成一个循环堆栈,栈顶记录保 存于状态寄存器中,相当于堆栈指针。每次压栈(FLD指令载入数据),堆栈指针就减1,在0~7之间循环。代码并不直接使用这个指针操作这些寄存器,而是使用ST(0)~ST(7)表示。ST(0)指栈顶,即状态寄存器中栈顶指针指 示的那个寄存器。如图8-2所示。
图8-2 x87 FPU的数据寄存器(摘自文献[2]) 假设当前状态寄存器中的栈顶指针是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指令存在资源共享问题,不可同时使用。 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位),其值 在0和7之间循环。
一般情形下,用户只需保证堆栈不出现错误(例如溢出),无需特别在意它的指针。但在特殊情形下,移动堆栈指针可能有利于提高效率。例如正在进行 一个复杂的计算,中间结果占据了多个数据寄存器,如图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寄存器的ZF、CF和PF,没有设置SF和OF,因此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>
同样可以定义常量: #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>
同样可以定义常量: #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>
可见,虽然标记的最初目的是用于标记是否为空,但兼顾了优化计算的目的。除了空之外的3个标记显然是按计算特性划分的。一般有限数需要进行计算,0可以快速处理,而特殊值不能参与计算,只能 特殊处理。 (2)调试寄存器 为了进一步支持调试,x87 FPU还实现了最后指令地址寄存器、最后操作数地址寄存器和操作码寄存器。 浮点计算过程中,当异常被触发时,调试最需要知道的自然是哪儿触发了 这个异常以及在干什么时触发的。最后指令地址寄存器给出异常指令的地址,如果这条指令使用内存操作数,那么最后操作数地址寄存器给出操作数的内存地址。 所给出的地址是48位全局地址,即选择字:偏移形式,例如CS:EIP或DS:ESI。更详细的说明可以参阅文献[2]。 这些寄存器最可能的用户是调试器,一般用户不大可能编写指令级异常处理程序,即使是调试错误也是在调试器的支持下进行的,因此知道即 可,无需关心细节。 |