FPU是专门处理浮点数的运算单元,是CPU的一个组件。
在早期的计算机体系中,FPU位于CPU之外的单独的运算芯片上。
IEEE 754 标准规定了计算机程序设计环境中的二进制和十进制的浮点数的交换、算术格式以及方法。符合这种标准的浮点数由符号位、尾数(又称为有效数字、小数位)和指数位构成。
事先接触过stack machine[1]或Forth语言编程基础[2]的读者,理解本节内容的速度会比较快。
在80486处理器问世之前,FPU(与CPU位于不同的芯片)叫作协作(辅助)处理器。而且那个时候的FPU还不属于主板的标准配置;如果想要在主板上安装FPU,人们还得单独购买它。[3]
80486 DX之后的CPU处理器集成了FPU的功能。
若没有FWAIT指令和opcode以D8~DF开头的所谓的“ESC”字符指令(opcode以D8~DF开头),恐怕很少有人还会想起FPU属于独立运算单元的这段历史。FWAIT指令的作用是让CPU等待FPU运算结束,而ESC字符指令都在FPU上执行。
FPU自带一个由8个80位寄存器构成的循环栈。这些80位寄存器用以存储IEEE 754格式的浮点数据[4],通常叫作ST(0)~ST(7)寄存器。IDA和OllyDbg都把ST(0)显示为ST。也有不少教科书把ST(0)叫作“栈顶/Stack Top”寄存器。
在ARM和MIPS平台的概念里,FPU寄存器不构成栈结构,仅仅是一组寄存器。x86/64构架的SIMD 扩展(单指令多数据流扩展)也延承了这种理念。
标准C/C++语言支持两种浮点类型数据,即单精度32位浮点数据(float)和双精度64位浮点数据(double)。
GCC编译器还支持long double类型浮点,即80位增强精度的扩展浮点类型数据(extended precision)。不过MSVC编译器不支持这种类型的浮点数据。[5]
虽然单精度浮点(float)型数据和整数(int)型数据在32位系统里都是32位数据,但是它们的数据格式完全不一样。
本节围绕下述例子进行讲解:
#include <stdio.h>
double f (double a, double b)
{
return a/3.14 + b*4.1;
};
int main()
{
printf ("%f\n", f(1.2, 3.4));
};
MSVC
使用MSVC编译上述程序,可得到如下所示的指令。
指令清单17.1 MSVC 2010:f()
CONST SEGMENT
__real@4010666666666666 DQ 04010666666666666r ; 4.1
CONST ENDS
CONST SEGMENT
__real@40091eb851eb851f DQ 040091eb851eb851fr ; 3.14
CONST ENDS
_TEXT SEGMENT
_a$ = 8 ; size = 8
_b$ = 16 ; size = 8
_f PROC
push ebp
mov ebp, esp
fld QWORD PTR _a$[ebp]
; current stack state: ST(0) = _a
fdiv QWORD PTR __real@40091eb851eb851f
; current stack state: ST(0) = result of _a divided by 3.14
fld QWORD PTR _b$[ebp]
; current stack state: ST(0) = _b; ST(1) = result of _a divided by 3.14
fmul QWORD PTR __real@4010666666666666
; current stack state:
; ST(0) = result of _b * 4.1;
; ST(1) = result of _a divided by 3.14
faddp ST(1), ST(0)
; current stack state: ST(0) = result of addition
pop ebp
ret 0
_f ENDP
FLD指令从栈中读取8个字节,把这个值转换为FPU寄存器所需的80位数据格式,并存入ST(0)寄存器。
FDIV指令把ST(0)寄存器的值用作被除数,把参数__real@40091eb851eb851f(即3.14)的值当作除数,进行除法运算。因为汇编语法不支持含有小数点的浮点立即数,所以程序使用64位IEEE 754格式的16进制数040091eb851eb851fr表示3.14 。
在进行FDIV运算之后,ST(0)寄存器将保存商。
此外,FDIVP也是FPU的除法运算指令。FDIVP在进行ST(1)/ST(0)运算时,先把两个寄存器的值POP出来进行运算,再把商推送入(PUSH)FPU的栈(即ST(0)寄存器)。这相当于Forth语言[6]中的堆栈机[7]。
下一条FLD指令,把变量b的值推送到FPU的栈。
此时,ST(1)寄存器里是上次除法运算的商,ST(0)寄存器里是变量b的值。
接下来的FMUL指令做乘法运算。它用ST(0)寄存器里的值(即变量b),乘以参数__real @4010666666666666(即4.1),并将运算结果(积)存储到ST(0)寄存器。
最后一条运算指令FADDP计算栈内顶部两个值的和。它先把运算结果存储在ST(1)寄存器,再POP ST(0)。所以,运算表达式的运算结果存储在栈顶的ST(0)寄存器里。
根据有关规范,函数必须使用ST(0)寄存器存储浮点运算的返回结果。所以在FADDP指令之后,除了函数尾声的指令之外再无其他指令。
MSVC+OllyDbg
图17.1标记出了两对32位数据。这两对数据都是由main()函数传递过来的、以IEEE 754格式存储的双精度浮点数据。我们可看到首条FLD指令从栈内读取了1.2、然后把它推送到ST(0)。
图17.1 OllyDbg:执行首条 FLD 指令
在把64位IEEE 754格式的数据转换为FPU所用的80位浮点数据的过程中,会不可避免地存在误差。图中1.1999……所要表示的量,就是1.2的近似值。此后,EIP寄存器的值指向下一条指令FDIV。FDIV指令会从内存中读取双精度浮点常量。OllyDbg人性化地显示出了第二个参数的值—— 3.14。
继续执行FDIV指令。如图17.2所示,此时ST(0)寄存器存储着上一次运算的商0.382…
图17.2 OllyDbg:执行FDIV指令
执行第三条指令即FLD指令之后,ST(0)寄存器加载了数值3.4(3.39999……)。如图17.3所示。
图17.3 OllyDbg:执行第二个 FLD 指令
在3.4入栈的同时,先前运算出来的商被推送到ST(1)寄存器,而后EIP指针的值指向下一条指令FMUL。如同OllyDbg提示的那样,FMUL指令会从内存中读取因子4.1。
在执行FMUL指令之后,ST(0)寄存器将存储着乘法运算的积,如图17.4所示。
图17.4 OllyDbg:执行FMUL指令
然后运行FADDP指令,运算求得的和会被存储在ST(0)寄存器中。同时,指令会清空ST(1)寄存器。如图17.5所示。
图17.5 OllyDbg:执行FADDP指令
在FPU的运算指令结束之后,运算结果存储在ST(0)寄存器里。main()函数稍后会从这个寄存器提取运算结果。
值得注意的是ST(7)寄存器——它的值是13.93……这是为什么?
这不难理解。前文介绍过,FPU的寄存器构成了自己的栈结构。因为需要用硬件直接实现数据栈,所以栈结构也比较简单。在对FPU进行出入栈操作的时候,如果每次都要把所有7个寄存器的内容转移(或者说复制)到相邻的寄存器,那么开销会非常高。实际的FPU只有8个寄存器和1个栈顶指针(TOP)寄存器。栈顶指针寄存器专门记录“栈顶”寄存器的寄存器编号。在FPU进行数据入栈(PUSH)操作时,它首先令栈顶指针寄存器指向下一个寄存器,然后在那个寄存器里存储数据。出栈(POP)指令的过程相反。但是在进行出栈操作时,FPU不会清空原有寄存器(否则必定影响性能)。所以,在执行完程序的浮点运算指令后,FPU寄存器的状态就如图17.5所示。这种现象可以说是“FADDP指令把运算结果推送入栈,然后进行了出栈操作”,但是实际上这条指令把和存入寄存器后调整了栈顶指针寄存器的值。所以,确切地说,FPU的寄存器构成了循环缓冲区(circular buffer)。
GCC
使用GCC 4.4.1(启用 –O3选项)编译上述代码,生成的程序略有不同。
指令清单17.2 Optimizing GCC 4.4.1
ublic f
f proc near
arg_0 = qword ptr 8
arg_8 = qword ptr 10h
push ebp
fld ds:dbl_8048608 ; 3.14
; stack state now: ST(0) = 3.14
mov ebp, esp
fdivr [ebp+arg_0]
; stack state now: ST(0) = result of division
fld ds:dbl_8048610 ; 4.1
; stack state now: ST(0) = 4.1, ST(1) = result of division
fmul [ebp+arg_8]
; stack state now: ST(0) = result of multiplication, ST(1) = result of division
pop ebp
faddp st(1), st
; stack state now: ST(0) = result of addition
retn
f endp
第一处不同点,同时也是最显著的不同之处是:GCC把3.14送入FPU的栈(ST(0)寄存器),用作arg_0的除数。
FDIVR是Reverse Divide的缩写。FDIVR指令的除数和被除数,对应FDIV指令的被除数和除数,即位置相反。在乘法运算中,因子的位置不影响运算结果,所以没有FMULR指令。
FADDP指令从栈中POP出一个值进行加法运算,并用ST(0)存储和。
在ARM统一浮点运算标准之前,很多厂商都推出了各自的扩展指令以实现浮点运算。后来,VFP(Vector Floating Point)成为了行业的标准。
x86平台的FPU有自己的栈;但是ARM平台里没有栈结构,只能操作寄存器。
指令清单17.3 Optimizing Xcode 4.6.3 (LLVM) (ARM mode)
f
VLDR D16, =3.14
VMOV D17, R0, R1 ; load "a"
VMOV D18, R2, R3 ; load "b"
VDIV.F64 D16, D17, D16 ; a/3.14
VLDR D17, =4.1
VMUL.F64 D17, D18, D17 ; b*4.1
vVADD.F64 D16, D17, D16 ; +
VMOV R0, R1, D16
BX LR
dbl_2C98 DCFD 3.14 ; DATA XREF: f
dbl_2CA0 DCFD 4.1 ; DATA XREF: f+10
上述程序出现了D字头的寄存器。ARM平台有32个64位的D字头寄存器。这些寄存器即可用来存储(双精度)浮点数,又可用于单指令流多数据流运算/SIMD(ARM平台下这种运算叫NEON)。ARM平台还有32个S字头的寄存器。S字头寄存器用于处理单精度浮点数据。简单地说,S字头寄存器用于存储单精度浮点,而D字头寄存器用于处理双精度浮点。详细规格请参见附录B.3.3。
本例中的两个浮点都以IEEE 754的格式存储于内存之中。
望文生义,VLDR和VMOV指令就是操作D字头寄存器的LDR和MOV指令。这些V字头的指令和D字头的寄存器,不仅能够处理浮点类型数据,而且可以用于SIMD(NEON)运算。
即使涉及浮点运算,但是它还是ARM平台的程序,还会遵循ARM规范使用R字头寄存器传递参数。双精度浮点数据是64位数据,所以每传递一个双精度浮点数据就需要使用2个R字头寄存器。
“VMOV D17, R0, R1”指令从R0和R1寄存器读取64位数据的2个部分,并把最终数值存储在D17寄存器中。
“VMOV R0, R1, D16”与上述指令的作用相反。它把D16寄存器的值(64位)分解成两个32位数据,并分别存储于R0和R1寄存器。
后面出现的VDIV、VMUL、VADD指令都是浮点运算指令,不再介绍。
使用Xcode生成Thumb-2模式的代码,会跟这段程序相同。
f
PUSH {R3-R7,LR}
MOVS R7, R2
MOVS R4, R3
MOVS R5, R0
MOVS R6, R1
LDR R2, =0x66666666;4.1
LDR R3, =0x40106666
MOVS R0, R7
MOVS R1, R4
BL __aeabi_dmul
MOVS R7, R0
MOVS R4, R1
LDR R2, =0x51EB851F;3.14
LDR R3, =0x40091EB8
MOVS R0, R5
MOVS R1, R6
BL __aeabi_ddiv
MOVS R2, R7
MOVS R3, R4
BL __aeabi_dadd
POP {R3-R7,PC}
; 4.1 in IEEE 754 form:
dword_364 DCD 0x66666666 ; DATA XREF: f+A
dword_368 DCD 0x40106666 ; DATA XREF: f+C
; 3.14 in IEEE 754 form:
dword_36C DCD 0x51EB851F ; DATA XREF: f+1A
dword_370 DCD 0x40091EB8 ; DATA XREF: f+1C
Keil生成的Thumb模式程序不支持NEON运算和FPU浮点运算。Thumb模式程序使用两个通用的R字头寄存器传递双精度浮点型数据。因为不再使用FPU的专用指令,所以这类程序必须调用库函数(例如__aeabi_dmul, __aeabi_ddiv, __aeabi_dadd)“仿真”浮点运算。“仿真”意义上的模拟运算,其速度当然比不上FPU处理器的速度,但是聊胜于无。
在早年协作处理器还属于昂贵的奢侈品的时候,x86平台的浮点仿真运算的库函数曾经盛行一时。
在ARM系统里,FPU仿真处理叫作“软浮点/soft float”或者“armel”,而通过硬件实现的FPU指令叫作“硬浮点/hard float”或“armhf”。
这种程序十分短。
指令清单17.4 Optimizing GCC (Linaro) 4.9
f:
; D0 = a, D1 = b
ldr d2, .LC25 ; 3.14
; D2 = 3.14
fdiv d0, d0, d2
; D0 = D0/D2 = a/3.14
ldr d2, .LC26 ; 4.1
; D2 = 4.1
fmadd d0, d1, d2, d0
; D0 = D1*D2+D0 = b*4.1+a/3.14
ret
; constants in IEEE 754 format:
.LC25:
.word 1374389535 ; 3.14
.word 1074339512
.LC26:
.word 1717986918 ; 4.1
.word 1074816614
指令清单17.5 Non-optimizing GCC (Linaro) 4.9
f:
sub sp, sp, #16
str d0, [sp,8] ; save "a" in Register Save Area
str d1, [sp] ; save "b" in Register Save Area
ldr x1, [sp,8]
; X1 = a
ldr x0, .LC25
; X0 = 3.14
fmov d0, x1
fmov d1, x0
; D0 = a, D1 = 3.14
fdiv d0, d0, d1
; D0 = D0/D1 = a/3.14
fmov x1, d0
; X1 = a/3.14
ldr x2, [sp]
; X2 = b
ldr x0, .LC26
; X0 = 4.1
fmov d0, x2
; D0 = b
fmov d1, x0
; D1 = 4.1
fmul d0, d0, d1
; D0 = D0*D1 = b*4.1
fmov x0, d0
; X0 = D0 = b*4.1
fmov d0, x1
; D0 = a/3.14
fmov d1, x0
; D1 = X0 = b*4.1
fadd d0, d0, d1
; D0 = D0+D1 = a/3.14 + b*4.1
fmov x0,d0 ;\ redundant code
fmov d0,x0 ;/
add sp, sp, 16
ret
.LC25:
.word 1374389535 ; 3.14
.word 1074339512
.LC26:
.word 1717986918 ; 4.1
.word 1074816614
在没有启用优化功能的情况下,GCC生成的代码比较拖沓。在上述程序中,不仅出现了没有意义的数值交换指令,而且还出现了明显多余的指令(例如最后两条FMOV指令)。这可能是GCC 4.9在编译ARM64程序方面尚有不足。
值得注意的是,ARM64本身就具备64位寄存器,而D字头寄存器同样是64位寄存器。所以编译器可以调动通用寄存器GPR直接存储双精度浮点数,而不必非得使用本地栈来存储这种数据。毫无疑问,在32位CPU上,编译器无法使用这种寄存器分配方案。
建议读者用这个程序进行练习,在不使用FMADD之类的新指令的情况下,手动优化上述函数。
MIPS平台支持多个(4个及以下)协作处理器。第0个协作处理器专门用于调度其他的协作处理器,第1个协作处理器就是FPU。
与ARM平台的情形相似,MIPS的协作处理器不是堆栈机(stack machine),只是32个32位寄存器($F0~$F31)。有关FPU各寄存器的介绍,请参见附录C.1.2节。在处理64位双精度浮点数时,必须使用一对32位F字头寄存器。
指令清单17.6 Optimizing GCC 4.4.5 (IDA)
f:
; $f12-$f13=A
; $f14-$f15=B
lui $v0, (dword_C4 >> 16) ; ?
; load low 32-bit part of 3.14 constant to $f0:
lwc1 $f0, dword_BC
or $at, $zero ; load delay slot, NOP
; load high 32-bit part of 3.14 constant to $f1:
lwc1 $f1, $LC0
lui $v0, ($LC1 >> 16) ; ?
; A in $f12-$f13, 3.14 constant in $f0-$f1, do division:
div.d $f0, $f12, $f0
; $f0-$f1=A/3.14
; load low 32-bit part of 4.1 to $f2:
lwc1 $f2, dword_C4
or $at, $zero ; load delay slot, NOP
; load high 32-bit part of 4.1 to $f3:
lwc1 $f3, $LC1
or $at, $zero ; load delay slot, NOP
; B in $f14-$f15, 4.1 constant in $f2-$f3, do multiplication:
mul.d $f2, $f14, $f2
; $f2-$f3=B*4.1
jr $ra
; sum 64-bit parts and leave result in $f0-$f1:
add.d $f0, $f2 ; branch delay slot, NOP
.rodata.cst8:000000B8 $LC0: .word 0x40091EB8 # DATA XREF: f+C
.rodata.cst8:000000BC dword_BC: .word 0x51EB851F # DATA XREF: f+4
.rodata.cst8:000000C0 $LC1: .word 0x40106666 # DATA XREF: f+10
.rodata.cst8:000000C4 dword_C4: .word 0x66666666 # DATA XREF: f
需要介绍的指令有:
LWC1把一个32位Word数据传递给第一个协作处理器(Load Word to Coprocessor 1)。可见,指令中的1指代协作处理器的编号。成对出现的LWC1指令可能会被调试程序显示为伪指令LD。
DIV.D、MUL.D、ADD.D指令是双精度浮点数的除法、乘法和加法运算指令。其后缀“.D”表明数据类型是double/双精度浮点。顾名思义,后缀为“.S”的指令则是single/单精度浮点数据的运算指令。
文中用问号“?”标出的LUI指令应当没有实际意义,可能是编译器生成的异常指令。如果有读者知道其中奥秘,请发email给我。
本节围绕下述程序进行演示:
#include <math.h>
#include <stdio.h>
int main ()
{
printf ("32.01 ^ 1.54 = %lf\n", pow (32.01,1.54));
return 0;
}
使用MSVC 2010编译上述程序,可得到如下所示的指令。
指令清单17.7 MSVC 2010
CONST SEGMENT
__real@40400147ae147ae1 DQ 040400147ae147ae1r ; 32.01
__real@3ff8a3d70a3d70a4 DQ 03ff8a3d70a3d70a4r ; 1.54
CONST ENDS
_main PROC
push ebp
mov ebp, esp
sub esp, 8 ; 为第1个变量分配空间
fld QWORD PTR __real@3ff8a3d70a3d70a4
fstp QWORD PTR [esp]
sub esp, 8 ; 为第2个变量分配空间
fld QWORD PTR __real@40400147ae147ae1
fstp QWORD PTR [esp]
call _pow
add esp, 8 ;单个变量的返回地址
; 栈分配了8个字节的空间
; 运算结果存储于ST(0)寄存器
fstp QWORD PTR [esp] ;把ST(0)的值转移到栈,供printf()调用
push OFFSET $SG2651
call _printf
add esp, 12
xor eax, eax
pop ebp
ret 0
_main ENDP
FLD和FSTP指令是在数据段(SEGMENT)和FPU的栈间交换数据的指令。FLD把内存里的数据推送入FPU的栈,而FSTP则把FPU栈顶的数据复制到内存中。pow()函数是指数运算函数,它从FPU的栈内读取两个参数进行计算,并把运算结果(x的y次幂)存储在ST(0)寄存器里。之后,printf()函数先从内存栈中读取8个字节的数据,再以双精度浮点的形式进行输出。
此外,这个例子里还可以直接成对使用MOV指令把浮点数据从内存复制到FPU的栈里。内存本身就把浮点数据存储为IEEE 754的数据格式,而pow()函数所需的参数就是这个格式的数据,所以此处没有格式转换的必要。下一节的例子就会用到这个技巧。
_main
var_C = -0xC
PUSH {R7,LR}
MOV R7, SP
SUB SP, SP, #4
VLDR D16, =32.01
VMOV R0, R1, D16
VLDR D16, =1.54
VMOV R2, R3, D16
BLX _pow
VMOV D16, R0, R1
MOV R0, 0xFC1 ; "32.01 ^ 1.54 = %lf\n"
ADD R0, PC
VMOV R1, R2, D16
BLX _printf
MOVS R1, 0
STR R0, [SP,#0xC+var_C]
MOV R0, R1
ADD SP, SP, #4
POP {R7,PC}
dbl_2F90 DCFD 32.01 ; DATA XREF: _main+6
dbl_2F98 DCFD 1.54 ; DATA XREF: _main+E
前文介绍过,ARM系统可以在不借助D字头寄存器的情况下,通过一对R字头寄存器传递64位浮点数。但是由于我们没有启用编译器的优化选项,所以它还是用D字头寄存器传递浮点数。
从中可以看出,R0和R1寄存器给_pow函数传递了第一个参数,R2和R3寄存器给函数传递了第二个参数。函数把计算结果存储在R0和R1寄存器对。而后_pow的运算结果再通过D16寄存器传递给R1和R2寄存器,以此向printf()函数传递参数。
_main
STMFD SP!, {R4-R6,LR}
LDR R2, =0xA3D70A4 ;y
LDR R3, =0x3FF8A3D7
LDR R0, =0xAE147AE1 ;x
LDR R1, =0x40400147
BL pow
MOV R4, R0
MOV R2, R4
MOV R3, R1
ADR R0, a32_011_54Lf ; "32.01 ^ 1.54 = %lf\n"
BL __2printf
MOV R0, #0
LDMFD SP!, {R4-R6,PC}
y DCD 0xA3D70A4 ; DATA XREF: _main+4
dword_520 DCD 0x3FF8A3D7 ; DATA XREF: _main+8
; double x
x DCD 0xAE147AE1 ; DATA XREF: _main+C
dword_528 DCD 0x40400147 ; DATA XREF: _main+10
a32_011_54Lf DCB "32.01 ^ 1.54 = %lf",0xA,0
; DATA XREF: _main+24
在没有启用优化功能时,编译器只使用了R-字头寄存器对,没有使用D-字头寄存器。
指令清单17.8 Optimizing GCC (Linaro) 4.9
f:
stp x29, x30, [sp, -16]!
add x29, sp, 0
ldr d1, .LC1 ; load 1.54 into D1
ldr d0, .LC0 ; load 32.01 into D0
bl pow
; result of pow() in D0
adrp x0, .LC2
add x0, x0, :lo12:.LC2
bl printf
mov w0, 0
ldp x29, x30, [sp], 16
ret
.LC0:
; 32.01 in IEEE 754 format
.word -1374389535
.word 1077936455
.LC1:
; 1.54 in IEEE 754 format
.word 171798692
.word 1073259479
.LC2:
.string"32.01 ^ 1.54 = %lf\n"
启用优化功能之后,编译器使用D0和D1寄存器加载常量、继而传递给pow()函数。pow()函数的运算结果再由D0寄存器传递给printf()函数。因为printf()函数不仅可以通过X-字头寄存器获取整型数据和指针,而且还可以直接访问D-字头寄存器获取浮点数参数,所以在传递浮点数时不需要修改或转移数据。
指令清单17.9 Optimizing GCC 4.4.5 (IDA)
main:
var_10 = -0x10
var_4 = -4
; function prologue:
lui $gp, (dword_9C >> 16)
addiu $sp, -0x20
la $gp, (__gnu_local_gp & 0xFFFF)
sw $ra, 0x20+var_4($sp)
sw $gp, 0x20+var_10($sp)
lui $v0, (dword_A4 >> 16) ; ?
; load low 32-bit part of 32.01:
lwc1 $f12, dword_9C
; load address of pow() function:
lw $t9, (pow & 0xFFFF)($gp)
; load high 32-bit part of 32.01:
lwc1 $f13, $LC0
lui $v0, ($LC1 >> 16) ; ?
; load low 32-bit part of 1.54:
lwc1 $f14, dword_A4
or $at, $zero ; load delay slot, NOP
; load high 32-bit part of 1.54:
lwc1 $f15, $LC1
; call pow():
jalr $t9
or $at, $zero ; branch delay slot, NOP
lw $gp, 0x20+var_10($sp)
; copy result from $f0 and $f1 to $a3 and $a2:
mfc1 $a3, $f0
lw $t9, (printf & 0xFFFF)($gp)
mfc1 $a2, $f1
; call printf():
lui $a0, ($LC2 >> 16) # "32.01 ^ 1.54 = %lf\n"
jalr $t9
la $a0, ($LC2 & 0xFFFF) # "32.01 ^ 1.54 = %lf\n"
; function epilogue:
lw $ra, 0x20+var_4($sp)
; return 0:
move $v0, $zero
jr $ra
addiu $sp, 0x20
.rodata.str1.4:00000084 $LC2: .ascii "32.01 ^ 1.54 = %lf\n"<0>
; 32.01:
.rodata.cst8:00000098 $LC0: .word 0x40400147 # DATA XREF: main+20
.rodata.cst8:0000009C dword_9C: .word 0xAE147AE1 # DATA XREF: main
.rodata.cst8:0000009C # main+18
; 1.54:
.rodata.cst8:000000A0 $LC1: .word 0x3FF8A3D7 # DATA XREF: main+24
.rodata.cst8:000000A0 # main+30
.rodata.cst8:000000A4 dword_A4: .word 0xA3D70A4 # DATA XREF: main+14
这段程序的LUI指令将双精度浮点数的高16位复制到$V0寄存器。其中的(汇编宏>>16)是经IDA整理的伪代码,它的作用是对32位数据右移16位、以得到高16位数。LUI指令只能操作16位立即数。不过两个LWC1之前的LUI指令似乎没有意义,笔者给它们注释上了问号。如果哪位读者知晓其中玄机,还请联系作者本人。
MFC1是“Move From Coprocessor 1”的缩写。在MIPS系统上,第1号协作处理器是FPU。可见,这条指令首先读取协作处理器的寄存器的值,然后再把这个值复制到CPU通用寄存器GPR。不难看出,这条指令把pow()函数的运算结果复制到$A3和$A2寄存器里,然后printf()函数从这对寄存器里提取一对32位数据、再把它输出为64位双精度浮点数。
本节围绕下述程序进行演示:
#include <stdio.h>
double d_max (double a, double b)
{
if (a>b)
return a;
return b;
};
int main()
{
printf ("%f\n", d_max (1.2, 3.4));
printf ("%f\n", d_max (5.6, -4));
};
虽然这个函数很短,但是它的汇编代码并不那么简单。
Non-optimizing MSVC
指令清单17.10 Non-optimizing MSVC 2010
PUBLIC _d_max
_TEXT SEGMENT
_a$ = 8 ; size =8
_b$ = 16 ; size = 8
_d_max PROC
push ebp
mov ebp, esp
fld QWORD PTR _b$[ebp]
; current stack state: ST(0) = _b
; compare _b (ST(0)) and _a, and pop register
fcomp QWORD PTR _a$[ebp]
; stack is empty here
fnstsw ax
test ah, 5
jp SHORT $LN1@d_max
; we are here only if a>b
fld QWORD PTR _a$[ebp]
jmp SHORT $LN2@d_max
$LN1@d_max:
fld QWORD PTR _b$[ebp]
$LN2@d_max:
pop ebp
ret 0
_d_max ENDP
可见,FLD指令把汇编宏_b 加载到ST(0)寄存器。
FCOMP首先比较ST(0)与_a的值,然后根据比较的结果设置FPU状态字(寄存器)的C3/C2/C0位。FPU的状态字寄存器是一个16位寄存器,用于描述FPU的当前状态。
在设置好相应比特位之后,FCOMP指令还会从栈里抛出(POP)一个值。FCOM与FCOMP的功能十分相似。FCOM指令只根据数值比较的结果设置状态字,而不会再操作FPU的栈。
不幸的是,在Intel P6[8]之前问世的CPU上,条件转移指令不能根据C3/C2/C0状态位进行条件判断。考虑到那时候的FPU在物理上尚与CPU分离,所以这种不足在当时大概还算不上是缺陷。
自Intel P6问世之后,FCOMI/FCOMIP/FUCOMI/FUCOMIP指令不仅延续了先前各指令的功能,而且新增了设置CPU标志位ZF/PF/CF的功能。
FNSTSW指令把FPU状态寄存器的值复制到AX寄存器。C3/C2/C0标志位对应AX的第14/10/8位。复制数值并不会改变标志位(bit)的数权(位置)。标志位会集中在AX寄存器的高地址位区域——即AH寄存器里。
如果b>a,则C3、C2、C0寄存器的值会分别是0、0、0。
如果a>b,则寄存器的值会分别是0、0、1。
如果a=b,则寄存器的值会分别是1、0、0。
如果出现了错误(NaN或数据不兼容),则寄存器的值是1、1、1。
在FNSTSW指令把FPU状态寄存器的值复制到AX寄存器后,AX寄存器各个bit位与C0~C3寄存器的对应关系如下图所示。
若以AH寄存器的视角来看,C0~C3与各bit位的对应关系则是:
“test ah, 5”指令把ah的值(FPU标志位的加权求和值)和0101(二进制的5)做与(AND)运算,并设置标志位。影响test结果的只有第0比特位的C0标志位和第2比特位的C2标志位,因为其他的位都会被置零。
接下来,我们首先要介绍奇偶校验位PF(parity flag)。
PF标志位的作用是判定运算结果中的“1”的个数,如果“1”的个数为偶数,则PF的值为1,否则其值为0。
检验奇偶位通常用于判断处理过程是否出现故障,并不能判断这个数值是奇数还是偶数。FPU有四个条件标志位(C0到C3)。但是,必须把标志位的值组织起来、存放在标志位寄存器中,才能进行奇偶校验位的正确性验证。FPU标志位的用途各有不同:C0位是进位标志位CF,C2是奇偶校验位PF,C3是零标志位ZF。在使用FUCOM指令(FPU比较指令的通称)时,如果操作数里出现了不可比较的浮点值(非数值型内容NaN或其他无法被指令支持的格式),则C2会被设为1。
如果C0和C2都是0或都是1,则设PF标志为1并触发JP跳转(Jump on Parity)。前面对C3/C2/C0的取值进行了分类讨论,C2和C0的数值相同的情况分为b>a和 a=b这两种情况。因为test指令把ah的值与5进行“与”运算,所以C3的值无关紧要。
在此之后的指令就很简单了。如果触发了JP跳转,则FLD指令把变量_b的值复制到ST(0)寄存器,否则变量_a的值将会传递给ST(0)寄存器。
如果需要检测C2的状态
如果TEST指令遇到错误(NaN等情形),则C2标志位的值会被设置为1。不过我们的程序不检测这类错误。如果编程人员需要处理FPU的错误,他就不得不添加额外的错误检查指令。
使用OllyDbg调试本章例一(a=1.2,b=3.4)
使用OllyDbg打开编译好的程序,如图17.6所示。
图17.6 OllyDbg:执行第一条FLD指令
我们可以在数据栈中看到两对32位的值,它们是当前函数的两个参数:a=1.2, b=3.4。此时ST(0)寄存器已经读取了变量b的值(3.4)。下一步将执行FCOMP指令。OllyDbg会提示FCOMP的第二个参数,这个参数也在栈里。
执行FCOMP指令,如图17.7所示。
图17.7 OllyDbg:执行FCOMP指令
此时FPU的条件标志位都是零。刚才被POP的数值已经转移到ST(7)寄存器里了。本章已经在5.1节介绍过FPU的寄存器和数据栈,这里不再复述。
然后运行FNSTSW指令,如图17.8所示。
图17.8 OllyDbg:执行FNSTSW指令
可见AX寄存器的值是零。确实,FPU的所有标志位目前都是零。OllyDbg将FNSTSW识别为FSTSW指令,这两条指令是同一条指令。
接下来运行TEST指令,如图17.9所示。
图17.9 OllyDbg:执行Test指令
PF标志的值为1。这是因为0里面有偶数个1,所以PF是1。OllyDbg将JP识别为JPE指令,它们是同一个指令。在下一步里,程序会触发JP跳转。
如图17.10所示,程序会触发JPE跳转,ST(0)将读取变量b的值3.4。
图17.10 执行第二条FLD指令
此后函数结束。
调试本章例二(a=5.6, b=−4)
首先使用OllyDbg加载编译好的可执行程序,如图17.11所示。
图17.11 OllyDbg:执行第一条FLD指令
这个函数有两个参数,a是5.6、b是−4。此刻,参数b已经加载到ST(0)寄存器,即将执行FCOMP指令。OllyDbg会在栈里显示FCOMP的另一个参数。
执行FCOMP指令,如图17.12所示。
图17.12 OllyDbg:执行FCOMP指令
C0之外的FPU标志位都是0。
然后执行FNSTSW指令,如图17.13所示。
图17.13 OllyDbg:执行FNSTSW指令
此时AX寄存器的值是0x100。C0标志位是AX寄存器的第8位(从第零位开始数)。
接下来执行TEST指令。
如图17.14所示,PF的值为0。毕竟,把0x100转换为2进制数后,里面只有1个1,1是奇数。此后不会触发JPE跳转。
图17.14 OllyDbg:执行TEST指令
因为不会触发JPE跳转,所以FLD从a里取值,把5.6赋值给了ST(0)寄存器,如图17.15所示。
图17.15 OllyDbg:执行第二条FLD指令
Optimizing MSVC 2010
指令清单17.11 Optimizing MSVC 2010
_a$ = 8 ; size = 8
_b$ = 16 ; size = 8
_d_max PROC
fld QWORD PTR _b$[esp-4]
fld QWORD PTR _a$[esp-4]
; current stack state: ST(0) = _a, ST(1) = _b
fcom ST(1) ; compare _a and ST(1) = (_b)
fnstsw ax
test ah, 65 ; 00000041H
jne SHORT $LN5@d_max
; copy ST(0) to ST(1) and pop register,
; leave (_a) on top
fstp ST(1)
; current stack state: ST(0) = _a
ret 0
$LN5@d_max:
; copy ST(0) to ST(0) and pop register,
; leave (_b) on top
fstp ST(0)
; current stack state: ST(0) = _b
ret 0
_d_max ENDP
FCOM指令和前面用过的FCOMP指令略有不同,它不操作FPU栈。而且本例的操作数也和前文有区别,这里它是逆序的。所以,FCOM生成的条件标志位的涵义也与前例不同。
如果a>b,则C3、C2、C0位的值分别为0、0、0。
如果b>a,则对应数值为0、0、1。
如果a=b,则对应数值为1、0、0。
就是说,“test ah, 65”这条指令仅仅比较两个标志位——C3(第6位/bit)和C0(第0位/bit)。在a>b的情况下,两者都应为0:这种情况下,程序不会被触发JNE跳转,并会执行后面的FSTP ST(1)指令,把ST(0)的值复制到操作数中,然后从FPU栈里抛出一个值。换句话说,这条指令把ST(0)的值(即变量_a的值)复制到ST(1)寄存器;此后栈顶的2个值都是_a。然后,相当于POP出一个值来,使ST(0)寄存器的值为_a,函数随即结束。
在b>a或a==b的情况下,程序将触发条件转移指令JNE。从ST(0)取值、再赋值给ST(0)寄存器,相当于NOP操作没有实际意义。接着它从栈里POP出一个值,使ST(0)的值为先前ST(1)的值,也就是变量_b。然后结束本函数。大概是因为FPU的指令集里没有POP并舍弃栈顶值的指令,所以才会出现这样的汇报指令。
使用OllyDbg调试例一:a=1.2/b=3.4的程序
在执行两条FLD指令之后,情况如图17.16所示。
图17.16 OllyDbg:执行过两条FLD指令之后的情况
此后将执行FCOM指令。OllyDbg会显示ST(0)和ST(1)的值。
在执行FCOMP指令之后,C0为1,其他标志全部为0,如图17.17所示。
图17.17 OllyDbg:执行FCOM指令
在执行FNSTSW指令之后,AX=0x3100,如图17.18所示。
图17.18 OllyDbg:执行FNSTSW指令
然后运行TEST,如图17.19所示。
图17.19 OllyDbg:执行TEST指令
此刻ZF=0,即将触发条件转移指令。
在执行FSTP ST(即ST(0))的时候,FPU把1.2从栈里POP了出来,栈顶变为3.4,如图17.20所示。
图17.20 OllyDbg:执行FSTP指令
可见“FSTP ST”指令与“POP FPU栈”指令的作用相似。
使用OllyDbg调试例二:a=5.6/b=-4的程序
在执行两条FLD指令之后的情况如图17.21所示。
图17.21 OllyDbg:执行两条FLD指令
接下来将要运行FCOM指令,如图17.22所示。
图17.22 OllyDbg:执行FCOM指令
标志位寄存器都被置零。
执行过FNSTSW之后,AX=0x3000,如图17.23所示。
图17.23 OllyDbg:执行FNSTSW指令
此后执行TEST指令,如图17.24所示。
图17.24 OllyDbg:执行TEST指令
在执行TEST置零之后,ZF=1,不会触发条件转移指令。
如图17.25所示,在执行FSTP ST(1)的时候,FPU栈顶的值是5.6。
图17.25 OllyDbg:执行FSTP指令
可见,FSTP ST(1)指令不会操作FPU栈顶的值,而会清空ST(1)寄存器的值。
GCC 4.4.1
指令清单17.12 GCC 4.4.1
d_max proc near
b = qword ptr -10h
a = qword ptr -8
a_first_half = dword ptr 8
a_second_half = dword ptr 0Ch
b_first_half = dword ptr 10h
b_second_half = dword ptr 14h
push ebp
mov ebp, esp
sub esp, 10h
; put a and b to local stack:
mov eax, [ebp+a_first_half]
mov dword ptr [ebp+a], eax
mov eax, [ebp+a_second_half]
mov dword ptr [ebp+a+4], eax
mov eax, [ebp+b_first_half]
mov dword ptr [ebp+b], eax
mov eax, [ebp+b_second_half]
mov dword ptr [ebp+b+4], eax
; load a and b to FPU stack:
fld [ebp+a]
fld [ebp+b]
; current stack state: ST(0) - b; ST(1) - a
fxch st(1) ; this instruction swapping ST(1) and ST(0)
; current stack state: ST(0) - a; ST(1) – b
fucompp ; compare a and b and pop two values from stack, i.e., a and b
fnstsw ax ; store FPU status to AX
sahf ; load SF, ZF, AF, PF, and CF flags state from AH
setnbe al ; store 1 to AL if CF=0 and ZF=0
test al, al ; AL==0 ?
jz short loc_8048453 ; yes
fld [ebp+a]
jmp short locret_8048456
loc_8048453:
fld [ebp+b]
locret_8048456:
leave
retn
d_max endp
FUCOMPP与FCOM指令的功能相似。它的全称是“Floating-Point Unsigned Compare And Pop”,所以它还能够从FPU栈中把两个比较的数值POP出来。此外,它们处理非数——“not-a-number/NaN”[9]的方式也有所不同。
FPU能够处理特定类型的NaN,如无限大、除以0的结果等。NaN又分为Quiet NaN和Signaling NaN。对Quiet NaN进行操作可能不会出现问题,但是对Signaling NaN进行运算将会引发错误(异常处理)。
只要在FCOM的操作数中有NaN,该指令就会引发异常处理机制。而FUCOM仅在处理Signaling NaN(简称为SNaN)时才会报错。
下一条指令是标志位传送指令SAHF(Store AH into Flags)。这条指令与FPU无关。具体来说,它把AH寄存器的8个比特位以下列顺序传递到CPU的8位标志位里:
在前文的例子中,我们关注过FSNSTSW指令。它把标志位C3/C2/C0以下列顺序复制到AH寄存器的第6、2、0位里:
换而言之,成对使用FNSTSW AX /SAHF这两条指令,可以把FPU的C3/C2/C0标志位复制到CPU的ZF/PF/CF标志位。
现在回忆一下C3/C2/C0标志位的几种情况:
如果a>b,则C3/C2/C0依次为0、0、0。
如果a<b,则它们依次为0、0、1。
如果a=b,则它们依次为1、0、0。
即,在执行过FUCOMPP/FNSTSW/SAHF这组指令之后,CPU的标志位:
如果a>b,则ZF=0、PF=0、CF=0。
如果a<b,则ZF=0、PF=0、CF=1。
如果a=b,则ZF=1、PF=0、CF=0。
SETNBE可以根据CPU标志位和有关限定条件把AL寄存器设置为0或1。SETNBE只在CF和ZF寄存器都为0的情况下,设置AL为1,其他情况下设置AL=0。SETcc和Jcc[10]是孪生兄弟。不过SETcc的作用是按条件赋值(0或1),而Jcc的作用是按条件进行跳转。
在本例中,只有在a>b的情况下,CF和ZF标志位才同时为0 。
这种情况下AL将会被赋值为1,程序不会触发JZ跳转,函数返回值是_a;否则函数返回值是_b。
Optimizing GCC 4.4.1
经GCC 4.4.1(启用优化选项-O3)编译上述程序,可得到如下所示的汇编指令。
指令清单17.13 Optimizing GCC 4.4.1
public d_max
d_max proc near
arg_0 = qword ptr 8
arg_8 = qword ptr 10h
push ebp
mov ebp, esp
fld [ebp+arg_0] ; _a
fld [ebp+arg_8] ; _b
; stack state now: ST(0) = _b, ST(1) = _a
fxch st(1)
; stack state now: ST(0) = _a, ST(1) = _b
fucom st(1) ; compare _a and _b
fnstsw ax
sahf
ja short loc_8048448
; store ST(0) to ST(0) (idle operation), pop value at top of stack,
; leave _b at top
fstp st
jmp short loc_804844A
loc_8048448:
; store _a to ST(1), pop value at top of stack, leave _a at top
fstp st(1)
loc_804844A:
pop ebp
retn
d_max endp
优化编译的效果集中体现在SAHF指令之后的JA指令上。实际上,依据无符号类型数据的比较结果进行跳转的条件转移指令(JA/JAE, JB/ JBE, JE/JZ, JNA/ JNAE, JNB/ JNBE, JNE/JNZ),只检测CF和ZF标志位。
在执行FSTSW/FNSTSW指令后,C3/C2/C0标志位的值将传递给AH寄存器。AH与Cx的关系是:
在执行标志位传送指令SAHF(Store AH into Flags)后,AH寄存器的各比特位与CPU的8位标志位的对应关系就变成了:
对照上述两个图表可知在比较数值的一系列操作之后C3和C0标志位的值被传送到ZF和CF标志位,以供后续的条件转移指令调用。如果CF和ZF都为0,则JA跳转将会被触发。
很显然,FPU的C3/C2/C0状态位之所以占用寄存器的相应数权,是为了方便把FPU的标志位复制到CPU标志位上、以便进行条件判断。这多半是有意而为之。
GCC 4.8.1 –启用优化选项-O3
Intel P6 系列[11]的FPU指令组新增加了一组指令。这些指令是FUCOMI(比较操作数并设置主CPU的标志位)和FCMOVcc(相当于处理FPU寄存器的CMOVcc指令)。GCC的维护人员采用了全新的指令集设计GCC,显然他们决定不再支持P6以前的CPU(也就是奔腾时代以前的CPU)。
另外,自Intel P6系列CPU起,Intel CPU都整合了FPU。这使得FPU直接修改、检测CPU标志位成为可能。
经GCC 4.8.1优化编译后,可得到如下所示的指令。
指令清单17.14 Optimizing GCC 4.8.1
fld QWORD PTR [esp+4] ; load "a"
fld QWORD PTR [esp+12] ; load "b"
; ST0=b, ST1=a
fxch st(1)
; ST0=a, ST1=b
; compare "a" and "b"
fucomi st, st(1)
; move ST1 (b here) to ST0 if a<=b
; leave a in ST0 otherwise
fcmovbe st, st(1)
; discard value in ST1
fstp st(1)
ret
FXCH 指令把栈寄存器ST (1)的值与栈顶 ST (0)的值进行交换,并保留栈顶指针。实际上,如果交换前两条FLD指令、或者把FCMOVBE(BE代表below or equal)替换为FCMOVA(A代表above)指令,那么就可以不用FXCH指令了。或许是因为编译器的优化功能尚未到位。
其后,FUCOMI比较ST(0)(即变量a)和ST(1)的值(即变量b),并在CPU上设置标志位。接下来FCOMVBE指令检查这些标志位,并进行下述操作如果ST(0)ST(1),即a
b,就把ST(1)的值(此时是a)复制给ST(0)寄存器。条件不成立,就是a>b的情况,它将保持ST(0)的值不变。
最后一条FSTP指令将ST(0) 寄存器中的值复制到目标操作数ST(1),然后弹出寄存器堆栈。为了弹出寄存器堆栈,处理器将 ST(0) 寄存器标记为空,并调整硬件上的堆栈指针(TOP)、便之递增1。
使用GDB调试这个程序,可得到如下所示的指令。
指令清单17.15 Optimizing GCC 4.8.1 and GDB
1 dennis@ubuntuvm:~/polygon$ gcc -O3 d_max.c -o d_max -fno-inline
2 dennis@ubuntuvm:~/polygon$ gdb d_max
3 GNU gdb (GDB) 7.6.1-ubuntu
4 Copyright (C) 2013 Free Software Foundation, Inc.
5 License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
6 This is free software: you are free to change and redistribute it.
7 There is NO WARRANTY, to the extent permitted by law. Type "show copying"
8 and "show warranty" for details.
9 This GDB was configured as "i686-linux-gnu".
10 For bug reporting instructions, please see:
11 <http://www.gnu.org/software/gdb/bugs/>...
12 Reading symbols from /home/dennis/polygon/d_max...(no debugging symbols found)...done.
13 (gdb) b d_max
14 Breakpoint 1 at 0x80484a0
15 (gdb) run
16 Starting program: /home/dennis/polygon/d_max
17
18 Breakpoint 1, 0x080484a0 in d_max ()
19 (gdb) ni
20 0x080484a4 in d_max ()
21 (gdb) disas $eip
22 Dump of assembler code for function d_max:
23 0x080484a0 <+0>: fldl 0x4(%esp)
24 => 0x080484a4 <+4>: fldl 0xc(%esp)
25 0x080484a8 <+8>: fxch %st(1)
26 0x080484aa <+10>: fucomi %st(1),%st
27 0x080484ac <+12>: fcmovbe %st(1),%st
28 0x080484ae <+14>: fstp %st(1)
29 0x080484b0 <+16>: ret
30 End of assembler dump.
31 (gdb) ni
32 0x080484a8 in d_max ()
33 (gdb) info float
34 R7: Valid 0x3fff9999999999999800 +1.199999999999999956
35 =>R6: Valid 0x4000d999999999999800 +3.399999999999999911
36 R5: Empty 0x00000000000000000000
37 R4: Empty 0x00000000000000000000
38 R3: Empty 0x00000000000000000000
39 R2: Empty 0x00000000000000000000
40 R1: Empty 0x00000000000000000000
41 R0: Empty 0x00000000000000000000
42
43 Status Word: 0x3000
44 TOP: 6
45 Control Word: 0x037f IM DM ZM OM UM PM
46 PC: Extended Precision (64-bits)
47 RC: Round to nearest
48 Tag Word: 0x0fff
49 Instruction Pointer: 0x73:0x080484a4
50 Operand Pointer: 0x7b:0xbffff118
51 Opcode: 0x0000
52 (gdb) ni
53 0x080484aa in d_max ()
54 (gdb) info float
55 R7: Valid 0x4000d999999999999800 +3.399999999999999911
56 =>R6: Valid 0x3fff9999999999999800 +1.199999999999999956
57 R5: Empty 0x00000000000000000000
58 R4: Empty 0x00000000000000000000
59 R3: Empty 0x00000000000000000000
60 R2: Empty 0x00000000000000000000
61 R1: Empty 0x00000000000000000000
62 R0: Empty 0x00000000000000000000
63
64 Status Word: 0x3000
65 TOP: 6
66 Control Word: 0x037f IM DM ZM OM UM PM
67 PC: Extended Precision (64-bits)
68 RC: Round to nearest
69 Tag Word: 0x0fff
70 Instruction Pointer: 0x73:0x080484a8
71 Operand Pointer: 0x7b:0xbffff118
72 Opcode: 0x0000
73 (gdb) disas $eip
74 Dump of assembler code for function d_max:
75 0x080484a0 <+0>: fldl 0x4(%esp)
76 0x080484a4 <+4>: fldl 0xc(%esp)
77 0x080484a8 <+8>: fxch %st(1)
78 => 0x080484aa <+10>: fucomi %st(1),%st
79 0x080484ac <+12>: fcmovbe %st(1),%st
80 0x080484ae <+14>: fstp %st(1)
81 0x080484b0 <+16>: ret
82 End of assembler dump.
83 (gdb) ni
84 0x080484ac in d_max ()
85 (gdb) info registers
86 eax 0x1 1
87 ecx 0xbffff1c4 -1073745468
88 edx 0x8048340 134513472
89 ebx 0xb7fbf000 -1208225792
90 esp 0xbffff10c 0xbffff10c
91 ebp 0xbffff128 0xbffff128
92 esi 0x0 0
93 edi 0x0 0
94 eip 0x80484ac 0x80484ac
95 eflags 0x203 [ CF IF ]
96 cs 0x73 115
97 ss 0x7b 123
98 ds 0x7b 123
99 es 0x7b 123
100 fs 0x0 0
101 gs 0x33 51
102 (gdb) ni
103 0x080484ae in d_max ()
104 (gdb) info float
105 R7: Valid 0x4000d999999999999800 +3.399999999999999911
106 =>R6: Valid 0x4000d999999999999800 +3.399999999999999911
107 R5: Empty 0x00000000000000000000
108 R4: Empty 0x00000000000000000000
109 R3: Empty 0x00000000000000000000
110 R2: Empty 0x00000000000000000000
111 R1: Empty 0x00000000000000000000
112 R0: Empty 0x00000000000000000000
113
114 Status Word: 0x3000
115 TOP: 6
116 Control Word: 0x037f IM DM ZM OM UM PM
117 PC: Extended Precision (64-bits)
118 RC: Round to nearest
119 Tag Word: 0x0fff
120 Instruction Pointer: 0x73:0x080484ac
121 Operand Pointer: 0x7b:0xbffff118
122 Opcode: 0x0000
123 (gdb) disas $eip
124 Dump of assembler code for function d_max:
125 0x080484a0 <+0>: fldl 0x4(%esp)
126 0x080484a4 <+4>: fldl 0xc(%esp)
127 0x080484a8 <+8>: fxch %st(1)
128 0x080484aa <+10>: fucomi %st(1),%st
129 0x080484ac <+12>: fcmovbe %st(1),%st
130 => 0x080484ae <+14>: fstp %st(1)
131 0x080484b0 <+16>: ret
132 End of assembler dump.
133 (gdb) ni
134 0x080484b0 in d_max ()
135 (gdb) info float
136 =>R7: Valid 0x4000d999999999999800 +3.399999999999999911
137 R6: Empty 0x4000d999999999999800
138 R5: Empty 0x00000000000000000000
139 R4: Empty 0x00000000000000000000
140 R3: Empty 0x00000000000000000000
141 R2: Empty 0x00000000000000000000
142 R1: Empty 0x00000000000000000000
143 R0: Empty 0x00000000000000000000
144
145 Status Word: 0x3800
146 TOP: 7
147 Control Word: 0x037f IM DM ZM OM UM PM
148 PC: Extended Precision (64-bits)
149 RC: Round to nearest
150 Tag Word: 0x3fff
151 Instruction Pointer: 0x73:0x080484ae
152 Operand Pointer: 0x7b:0xbffff118
153 Opcode: 0x0000
154 (gdb) quit
155 A debugging session is active.
156
157 Inferior 1 [process 30194] will be killed.
158
159 Quit anyway? (y or n) y
160 dennis@ubuntuvm:~/polygon$
使用“ni”指令可以执行头两条FLD指令。
再使用第33行的指令检查FPU寄存器的状态。
前文(17.5.1节)介绍过,FPU寄存器属于循环缓冲区的逻辑构造,它实际上不是标准的栈结构。所以GDB不会把寄存器名称显示为助记符“ST(x)”,而是显示出FPU寄存器的内部名称,Rx。第35行所示的箭头表示该行的寄存器是当前的栈顶。您可从第44行的“Status Word/状态字”里找到栈顶寄存器的编号。本例中栈顶状态字为6,所以栈顶是6号内部寄存器。
在第54行处,FXCH指令交换了变量a和变量b的数值。
在执行过第83行的FUCOMI指令后,我们可在第95行看到CF为1。
第104行,FCMOVBE指令复制变量b的值。
第136行的FSTP指令会调整栈顶,也会弹出一个值。而后TOP的值变为7,FPU栈指针指向第7寄存器。
Optimizing Xcode 4.6.3 (LLVM) (ARM mode)
指令清单17.16 Optimizing Xcode 4.6.3 (LLVM) (ARM mode)
VMOV D16, R2, R3 ; b
VMOV D17, R0, R1 ; a
VCMPE.F64 D17, D16
VMRS APSR_nzcv, FPSCR
VMOVGT.F64 D16, D17 ; copy "a" to D16
VMOV R0, R1, D16
BX LR
这段程序的代码很简短。函数把输入变量存储到D17、D16寄存器之后,使用VCMPE指令比较这两个变量的值。与x86处理器相仿,ARM处理器也有自己的状态寄存器和标识寄存器、其协作处理器也存在相关的FPSCR[12]。
虽然ARM模式的指令集存在条件转移指令,但是它的条件转移指令都不能直接访问协作处理器的状态寄存器。这个特点和x86系统相同。所以,ARM平台也有专门的指令把协作处理器的4个标识位(N、Z、C、V)复制到通用状态寄存器的ASPR寄存器里,即VMRS指令。
VMOVGT是FPU上的MOVGT指令,在操作数大于另一个操作数时进行赋值操作。这个指令的后缀GT代表“Greater Than”。
如果触发了VMOVGT指令,则会把D17里变量a的值复制到D16寄存器里。
否则,D16寄存器将会保持原来的变量b的值。
倒数第二条指令VMOV的作用是制备返回值,它把D16寄存器里64位的值拆分为1对32的值,并分别存储于通用寄存器R0和R1里。
Optimizing Xcode 4.6.3 (LLVM) (Thumb-2 mode)
使用Xcode 4.6.3(开启优化选项)、以Thumb-2模式编译上述程序可得到如下所示的指令。
指令清单17.17 Optimizing Xcode 4.6.3 (LLVM) (Thumb-2 mode)
VMOV D16, R2, R3 ; b
VMOV D17, R0, R1 ; a
VCMPE.F64 D17, D16
VMRS APSR_nzcv, FPSCR
IT GT
VMOVGT.F64 D16, D17
VMOV R0, R1, D16
BX LR
Thumb-2模式的代码和ARM模式的程序大体相同。确实,很多ARM模式的指令都存在对应的依相应条件才会执行的衍生指令。
但是Thumb模式没有这种衍生的执行条件指令。Thumb模式的opcode只有16位。这个空间存储不下条件判断表达式所需的那4位的存储空间。
而扩充后的Thumb-2指令集则没有上述缺陷,它们可以封装Thumb模式欠缺的条件判断表达式。
在IDA显示的汇编指令清单里,我们可以看到Thumb-2模式的代码里也出现VMOVGT指令。
此处的实际指令是VMOV指令,IDA为其添加了-GT后缀。为了直观地体现前面那条指令“IT GT”的条件判断作用,IDA在此使用了伪指令。
IT指令与所谓的if-then语句存在明确的对应关系。在IT指令之后的指令(最多4条指令),相当于在then语句模块里的一组条件运行指令。在本例中“IT GT”的涵义是:如果前面比较的数值,第一个值“大于/Greater Than”第二个值,则执行后续模块的1条指令。
我们来看下“愤怒的小鸟(iOS版)”里的代码片段。
指令清单17.18 Angry Birds Classic
...
ITE NE
VMOVNE R2, R3, D16
VMOVEQ R2, R3, D17
BLX _objc_msgSend ; not prefixed
...
“ITE”是“if-then-else”的缩写。这个指令后有两条指令:第一条就是then模块,第二条就是else模块。
我们再从“愤怒的小鸟”里找段更为复杂的代码。
指令清单17.19 Angry Birds Classic
...
ITTTT EQ
MOVEQ R0, R4
ADDEQ SP, SP, #0x20
POPEQ.W {R8,R10}
POPEQ {R4-R7,PC}
BLX ___stack_chk_fail ; not prefixed
...
ITTTT里有4个T,代表then语句有4条指令。根据这个信息,IDA给后续的4条指令添加了EQ伪后缀。
确实有ITEEE EQ这种形式的指令,代表“if-then-else-else-else-else”。在解析到这条指令后,IDA会给其后的5条指令依次添加下述后缀:
-EQ
-NE
-NE
-NE
我们继续分析“愤怒的小鸟”里的其他程序片段。
指令清单17.20 Angry Birds Classic
...
CMP.W R0, #0xFFFFFFFF
ITTE LE
SUBLE.W R10, R0, #1
NEGLE R0, R0
MOVGT R10, R0
MOVS R6, #0 ; not prefixed
CBZ R0, loc_1E7E32 ; not prefixed
...
ITTELE表示如果“小于或等于/LE(Less or Equal)”的条件成立,则执行then模块的2条指令,否则(“大于”情况下)执行else模块的第3条指令。
虽然I-T-E类型的指令可以有多个T和多个E,但是编译器还没有聪明到按需分配所有排列组合的程序。以“愤怒的小鸟”(iOS经典版)为例,那个时候的编译器只能分配“IT,ITE,ITT,ITTE,ITTT,ITTTT”这几种判断语句。调查IDA生成的汇编指令清单就可以验证这点。在生成汇编指令清单文件的时候,启用相关选项IDA同步输出每条指令的4字节opcode。因为IT指令的高16位的opcode是0xBF,所以我们应当使用的Linux分析指令是:
cat AngryBirdsClassic.lst | grep " BF" | grep "IT" > results.lst
另外,如果您要使用ARM的汇编语言手工编写Thumb-2模式的应用程序,那么只要您在指令后面添加相应的条件判断后缀,编译器就会自动添加相应的IT指令验证相应标志位。
Non-optimizing Xcode 4.6.3 (LLVM) (ARM mode)
指令清单17.21 Non-optimizing Xcode 4.6.3 (LLVM) (ARM mode)
b = -0x20
a = -0x18
val_to_return = -0x10
saved_R7 = -4
STR R7, [SP,#saved_R7]!
MOV R7, SP
SUB SP, SP, #0x1C
BIC SP, SP, #7
VMOV D16, R2, R3
VMOV D17, R0, R1
VSTR D17, [SP,#0x20+a]
VSTR D16, [SP,#0x20+b]
VLDR D16, [SP,#0x20+a]
VLDR D17, [SP,#0x20+b]
VCMPE.F64 D16, D17
VMRS APSR_nzcv, FPSCR
BLE loc_2E08
VLDR D16, [SP,#0x20+a]
VSTR D16, [SP,#0x20+val_to_return]
B loc_2E10
loc_2E08
VLDR D16, [SP,#0x20+b]
VSTR D16, [SP,#0x20+val_to_return]
loc_2E10
VLDR D16, [SP,#0x20+val_to_return]
VMOV R0, R1, D16
MOV SP, R7
LDR R7, [SP+0x20+b],#4
BX LR
这段程序使用了栈结构来处理变量a和变量b,所以操作略微烦琐。其他方面很好理解。
Optimizing Keil 6/2013 (Thumb mode)
使用Keil 6/2013(开启优化选项)、以Thumb模式编译上述程序可得到如下所示的指令。
指令清单17.22 Optimizing Keil 6/2013 (Thumb mode)
PUSH {R3-R7,LR}
MOVS R4, R2
MOVS R5, R3
MOVS R6, R0
MOVS R7, R1
BL __aeabi_cdrcmple
BCS loc_1C0
MOVS R0, R6
MOVS R1, R7
POP {R3-R7,PC}
loc_1C0
MOVS R0, R4
MOVS R1, R5
POP {R3-R7,PC}
因为运行Thumb指令的硬件平台未必会有FPU硬件,所以Keil不会生成FPU的硬指令。因此在比较浮点数时,Keil没有直接使用FPU的比较指令,而是使用了额外的库函数__aeabi_cdrcmple进行仿真处理。
需要注意的是,本程序调用的仿真函数会在比较数值后保留CPU标志位,所以后面可以直接执行BCS(B-Carry Set,大于或等于的情况下触发B跳转)之类的条件执行指令。
Optimizing GCC (Linaro) 4.9
d_max:
; D0 - a, D1 - b
fcmpe d0, d1
fcsel d0, d0, d1, gt
; now result in D0
ret
ARM64处理器的FPU指令集,能够不通过FPSCR直接设置APSR。至少,在逻辑上FPU不再独立于主处理器。此处的FCMPE指令负责比较D0和D1寄存器中的值(即函数的第一、第二参数),并根据比较结果设置相应的APSR标识位(N、Z、C、V)。
FCSEL(Floating Conditional Select)首先判断条件GT(Greater Than)是否成立,然后会选择性地复制D0或D1的值到D0寄存器。需要强调的是,在进行条件判断的时候,这个指令依据APSR寄存器里的标识、而非FPSCR里的标识进行判断。相比早期的CPU而言,ARM64平台的这种“可直接访问APSR”的特性算得上是一种进步。
如果条件表达式(GT)为真,那么D0将复制D0的值(即不发生值变化)。如果该条件表达式不成立,则D0将复制D1寄存器的值。
Non-optimizing GCC (Linaro) 4.9
d_max:
; save input arguments in "Register Save Area"
sub sp, sp, #16
str d0, [sp,8]
str d1, [sp]
; reload values
ldr x1, [sp,8]
ldr x0, [sp]
fmov d0, x1
fmov d1, x0
; D0 - a, D1 - b
fcmpe d0, d1
ble .L76
; a>b; load D0 (a) into X0
ldr x0, [sp,8]
b .L74
.L76:
; a<=b; load D1 (b) into X0
ldr x0, [sp]
.L74:
; result in X0
fmov d0, x0
; result in D0
add sp, sp, 16
ret
在关闭优化编译功能之后,编译出来的程序很庞大。首先,函数把输入参数存储于局部数据栈(Register Save Area)。接着把这些值复制到X0/X1寄存器,再把它们复制到D0/D1中、使用FCMPE进行比较。虽然这种程序的效率不高,不过在非优化模式下,编译器就本来是这样生成程序的。FCMPE在进行比较之后设置相应的APSR标识。不过,我们可以看出编译器没有考虑使用更为方便的FCSEL指令。所以它使用了较为古老的方式进行编译:它在此次分配了BLE(Branch if Less than or Equal)指令。在a>b的情况下,变量a的值将传递到X0寄存器;否则,变量b的值将传递给X0寄存器。最终,X0的值传递给D0寄存器,成为函数的返回值。
请在不使用新指令(包括FCSEL指令)的前提下,优化本例的程序。
Optimizing GCC (Linaro) 4.9—float
在把参数的数据类型从double替换为float之后,再进行编译:
float f_max (float a, float b)
{
if (a>b)
return a;
return b;
};
f_max:
; S0 - a, S1 - b
fcmpe s0, s1
fcsel s0, s0, s1, gt
; now result in S0
ret
程序使用了S-字头的寄存器,而不再使用D-字头寄存器。这是因为S-字头寄存器的32位(即64位D字头寄存器的低32位)已经满足单精度浮点的存储需要了。
即使是当今最受欢迎的MIPS处理器,其协作处理器也只能设置一个条件标识位。供CPU访问。早期的MIPS处理器只有一个标识条件位(即FCC0),现已逐步扩展到了8个(即FCC7~FCC0)。这些条件标识位位于浮点条件码寄存器(FCCR)。
指令清单17.23 Optimizing GCC 4.4.5 (IDA)
d_max:
; set FPU condition bit if $f14<$f12 (b<a):
c.lt.d $f14, $f12
or $at, $zero ; NOP
; jump to locret_14 if condition bit is set
bc1t locret_14
; this instruction is always executed (set return value to "a"):
mov.d $f0, $f12 ; branch delay slot
; this instruction is executed only if branch was not taken (i.e., if b>=a)
; set return value to "b":
mov.d $f0, $f14
locret_14:
jr $ra
or $at, $zero ; branch delay slot, NOP
“C.LT.D”是比较两个数值的指令。在它的名称中,“LT”表示条件为“Less Than”,“D”表示其数据类型为double。它将根据比较的结果设置、或清除FCC0条件位。
“BC1T”检测FCC0位,如果该标识位被置位(值为1)则进行跳转。“T”是True的缩写,表示该指令的运行条件是“标识位被置位(True)”。实际上确实存在对应的BC1F指令,在判定条件为False的时候进行跳转。
无论上述条件转移指令是否发生跳转,它都决定了$F0的最终取值。
现在,我们可以理解部分旧式计算器采取逆波兰表示法[13]的道理了。例如,在计算“12+34”时,这种计算器要依次按下“12”、“34”和加号。这种计算器采用了堆栈机器(stack machine)的构造。逆波兰记法不需要括号来标识操作符的优先级。所以,在计算复杂表达式时,这种构造计算器的操作十分简单。
有关x86-64系统处理浮点数的方法,请参见本书第27章。
请去除17.7.1节所示例子中的FXCH指令,进行改写并进行测试。
请描述下述代码的功能。
指令清单17.24 Optimizing MSVC 2010
__real@4014000000000000 DQ 04014000000000000r ; 5
_a1$ = 8 ;size=8
_a2$ = 16 ;size=8
_a3$ = 24 ;size=8
_a4$ = 32 ;size=8
_a5$ = 40 ;size=8
_f PROC
fld QWORD PTR _a1$[esp-4]
fadd QWORD PTR _a2$[esp-4]
fadd QWORD PTR _a3$[esp-4]
fadd QWORD PTR _a4$[esp-4]
fadd QWORD PTR _a5$[esp-4]
fdiv QWORD PTR __real@4014000000000000
ret 0
_f ENDP
指令清单17.25 Non-optimizing Keil 6/2013 (Thumb mode/compiled for Cortex-R4F CPU)
f PROC
VADD.F64 d0,d0,d1
VMOV.F64 d1,#5.00000000
VADD.F64 d0,d0,d2
VADD.F64 d0,d0,d3
VADD.F64 d2,d0,d4
VDIV.F64 d0,d2,d1
BX lr
ENDP
指令清单17.26 Optimizing GCC 4.9 (ARM64)
f:
fadd d0, d0, d1
fmov d1, 5.0e+0
fadd d2, d0, d2
fadd d3, d2, d3
fadd d0, d3, d4
fdiv d0, d0, d1
ret
指令清单17.27 Optimizing GCC 4.4.5 (MIPS) (IDA)
f:
arg_10 = 0x10
arg_14 = 0x14
arg_18 = 0x18
arg_1C = 0x1C
arg_20 = 0x20
arg_24 = 0x24
lwc1 $f0, arg_14($sp)
add.d $f2, $f12, $f14
lwc1 $f1, arg_10($sp)
lui $v0, ($LC0 >> 16)
add.d $f0, $f2, $f0
lwc1 $f2, arg_1C($sp)
or $at, $zero
lwc1 $f3, arg_18($sp)
or $at, $zero
add.d $f0, $f2
lwc1 $f2, arg_24($sp)
or $at, $zero
lwc1 $f3, arg_20($sp)
or $at, $zero
add.d $f0, $f2
lwc1 $f2, dword_6C
or $at, $zero
lwc1 $f3, $LC0
jr $ra
div.d $f0, $f2
$LC0: .word 0x40140000 # DATA XREF: f+C
# f+44
dword_6C: .word 0 # DATA XREF: f+3C
[1] 中文叫“堆栈机”或“堆栈结构机”。请参见http://en.wikipedia.org/wiki/Stack_machine。
[2] 请参见http://en.wikipedia.org/wiki/Forth_%28programming_language%29。
[3] 当初,为了使没有FPU的32位计算机(例如80386\80486 SX)兼容其研发的DOOM游戏,John Carmack设计了一套“软”FPU运算系统。这种系统使用CPU寄存器的高16位地址存储整数部分、低16位地址存储浮点数值的小数部分,仅通过标准32位通用寄存器即可实现浮点运算。更多详情请参阅:https://en.wikipedia.org/wiki/Fixed-point_arithmetic。
[4] 如需详细了解IEEE 754格式规范,请参见http://en.wikipedia.org/wiki/IEEE_754-2008。
[5] 如需详细了解这三种不同精度的数据类型,请参见:
http://en.wikipedia.org/wiki/Single-precision_floating-point_format。
http://en.wikipedia.org/wiki/Double-precision_floating-point_format。
http://en.wikipedia.org/wiki/Extended_precision。
[6] https://en.wikipedia.org/wiki/Forth_(programming_language)。
[7] Stack machines,https://en.wikipedia.org/wiki/Stack_machine。
[8] Pentium Pro, Pentium II等CPU。
[9] http://en.wikipedia.org/wiki/NaN。
[10] cc即条件判断指令的通称,如AE、BE、E等。
[11] Pentium Pro, Pentium-II之后的CPU。
[12] ARM平台的Floating-Point Status and Control Register。
[13] Reverse Polish notation,请参见https://en.wikipedia.org/wiki/Reverse_Polish_ notation。