1.18. 浮点数单元
CPU中的FPU
1.18.1. IEEE 754
sign+significand+exponent
1.18.2. x86
一开始FPU和CPU是分开的,FWAIT用来转换CPU状态,等待FPU完成工作。FPU有一个栈,用来保存8个80比特的寄存器ST(0)……ST(7)。
1.18.3. ARM, MIPS, x86/x64 SIMD
ARM和MIPS不是栈,而是一些寄存器,在x86/x64的SIMD拓展也是。
1.18.4. C/C++
2种浮点类型,float(32bits)和double(64bits)。
1.18.5. 简单例子
return and printf a/3.15+b*4.1
x86
MSVC
fld QWORD PTR _a$[ebp]
fdiv QWORD PTR _real@40091eb851eb851f 即a/3.14
ST(0)=a, 用a/3.14放入ST(0)
ST(0)=b,先把ST(0)->ST(1)因为是栈
ST(0)=b*4.1 -> ST(0)
把ST(0)、ST(1)加起来->ST(0)
MSVC+OllyDbg
注意FPU的这个栈是循环的。
GCC
不一样的是,第一步ST(0)=3.14
fdivv [ebp+arg_0], 其中arg_0存放a
乘法也是,乘数与被乘数顺序与x86不同。
ARM:优化的Xcode(LLVM)(ARM模式)
VFP标准,没有栈,只有寄存器,D开头双精度,S开头单精度。
Thumb-2的代码是一样的。
ARM:优化的Keil(Thumb模式)
调用了一些库函数,模拟FPU,但其实是软件实现的。经济。
ARM64:优化的GCC
ldr d2, .LC25 ;3.14
fdiv d0, d0, d2 ;计算除法
ldr d2, .LC26 ;4.1
fwadd d0, d1, d2, d0 ;计算乘法和加法
ret
ARM64:非优化的GCC
没有必要地把值倒来倒去。
MIPS
最多可以支持4个coprocessor。也没有栈,用寄存器。
LWC1加载32位字到第一个coprocessor的寄存器。
DIV.D MUL.D ADD.D
1.18.6. 通过参数传递浮点数
一个简单的例子:
printf(“32.01 ^ 1.54 = %lf\n”, pow(32.01, 1.54));
x86
先给第一个变量分配空间,然后使用fld和fstp指令。这两个指令把变量在数据段和FPU栈之间进行移动。所以这两条语句实现了将32.01从数据段移到栈中。同样地,1.54也被移到栈中,然后调用了pow函数,函数返回值存放在ST(0)中。然后用fstp从ST(0)移动到本地栈,调用printf函数。
ARM和未优化的Xcode(LLVM)(Thumb-2模式)
64位的浮点数是用寄存器传递的,而不是栈。未优化的代码有点冗余。pow函数接收R0+R1为第一个参数,R2+R3为第二个参数。结果保存在R0+R1。pow函数的结果随后会被移动到D16中,然后再存到R1+R2里,printf就从这两个寄存器接受参数。
ARM和未优化的Keil(ARM模式)
还是用R0+R1存第一个参数,R2+R3存第二个参数。然后直接把pow函数结果放到了R3+R2,然后调用printf,不过这里没有使用D开头的寄存器,只使用了R开头的寄存器。
AMR64和优化的GCC
常数加载到D0和D1,这是pow的参数。结果放在D0中。然后会把D0直接传递给printf。实际上printf一般从X寄存器取整数类型值,从D寄存器取浮点数类型值。
MIPS
LUI指令将浮点数的32位放到
V
0
,
但
是
这
一
步
是
多
余
的
,
不
知
道
为
什
么
要
加
这
一
步
。
新
的
指
令
是
M
F
C
1
,
意
思
是
从
多
处
理
器
1
移
出
。
F
P
U
的
号
码
就
是
多
处
理
器
1
。
这
个
指
令
从
处
理
器
的
寄
存
器
中
移
出
数
据
到
C
P
U
寄
存
器
。
p
o
w
的
结
果
会
被
放
到
A3和$A2。printf就从这两个寄存器读取参数。
1.18.7. 比较例子
比较两个浮点数的大小。
x86
未优化的MSVC
比较的时候,b放在ST(0)中,a放在栈上。首先FLD将b加载到ST(0)中。FCOMP比较ST(0)和a的值,然后设置FPU状态字寄存器中的C3/C2/C0。这是一个16位的寄存器,保存FPU的状态。设置好位之后,FCOMP从栈上弹出变量,这就是与FCOM的不同点。不过这是老CPU的做法,现代CPU使用FCOMI/FCOMIP/FUCOMI/FUCOMIP指令,做同样的事,但修改的是ZF/PF/CF的flags。
FNSTSW指令将FPU的状态字寄存器中的内容复制给AX。C3/C2/C0放在14/10/8位置,与AX寄存器相同位置,都放在高地址部分AX-AH。
如果b>a,C3/C2/C0设置为0,0,0
a>b,0,0,1
a=b,1,0,0
结果出错,则是1,1,1
下面的指令test ah, 5,只考虑C0和C2位,其他的都被忽略了。然后会根据AX的值设置PF的值,根据PF值判断是否跳转。
如果条件跳转触发,FLD会将b的值加载到ST(0)。如果没有触发,就加载a的值。
注意C2主要是存放错误信息的位。
优化的MSVC2010
FCOM与FCOMP不一样,没有POP这一步,不更改FPU栈。与上一个例子不同,这里操作数是反的顺序。
如果b>a,C3/C2/C0设置为0,0,0
a>b,0,0,1
a=b,1,0,0
test ah, 65检查2个位,即C3和C0。然后FSTP ST(1)把值从ST(0)复制到ST(1),如果a>b就是a。
注意这个比较的两个数放在ST(0)和ST(1)中。
FSTP ST相当于pop这个FPU栈。
GCC
FUCOMPP与FCOM差不多,从栈中弹出两个变量。FPU可以处理非数字的值,如NaN。FCOM会触发异常,如果操作数是NaN。
SAHF将AH值存储到flags中。
fnstsw ax/sahf指令将C3/C2/C0移动到ZF, PF, CF。其实具体的操作与上面的都差不多。
如果a>b,CPU设置为ZF=0, PF=0, CF=0。
a
优化的GCC
与上面的差不多,除了在SAHF之后使用JA。JA是在CF和ZF都为0的情况下触发,其实与上面的结果一样,但是节省了大量的代码。
GCC:-03优化打开
FUCOMI比较ST(0)中的a值和ST(1)中的b值,然后在CPU设置一些FLAGS。FCMOVBE检查flags并将ST(1)的b值复制给ST(0),如果a
ARM
优化的Xcode(LLVM)(ARM模式)
输入值a和b分别放在D16和D17中。然后使用VCMPE指令进行比较。ARM有自己的flag寄存器,即FPSCR。VMRS将4位(N, Z, C, V)从处理器的状态字拷贝到通用状态寄存器APSR。
VMOVGT是D寄存器的指令,如果比较两个操作数,前者大于后者就执行(GT)。如果执行,a的值写入的D16(原本存在D17)。否则就是b在D16中。
VMOV准备好D16中的值,通过R0和R1返回值。
优化的Xcode(LLVM)(Thumb-2模式)
与之前的例子基本相同,但有一点不一样。我们已经知道ARM模式的很多指令可以通过条件谓词支持,但是Thumb模式中没有。IT GT指令表示if-then条件。ITE是if-then-else。
优化的Xcode(LLVM)(ARM模式)
基本相同,但是有很多冗余代码,因为a和b变量存在局部栈上。
优化的Keil(Thumb模式)
Keil不生成FPU指令,调用外部库来完成比较:_aeabi_cdrcmple。
ARM64
优化的GCC
有FPU指令。首先是FCMPE,比较D0和D1中的值,然后设置APSR标签(N, Z, C, V)。FCSEL根据条件不同将D0或D1的值赋值给D0。条件比较要根据APSR。
未优化的GCC
把参数存放在本地栈。然后从本地栈放到X0/X1,然后再放到D0/D1进行比较。本质上是使用了传统的BLE进行跳转,借用寄存器X0。最后把X0返回到D0进行printf的调用。
练习
试着手动优化上面的代码。
优化的GCC-float
把double换成float。这时没有用D寄存器,而是用了S寄存器。
MIPS
在FPU里设置条件,在CPU中检查。检查位放在FCCR中。C.LT.D比较两个值。LT是条件小于的意思。D表示数据类型为double。依据比较结果,FCC0是条件位被设置。
BC1T检查FCC0的位,然后跳到相关位置。T表示如果为True则跳转。因此存在BC1F。
1.18.8. 栈,计算器和逆向波兰注释
一些计算器使用reverse Polish notation。比如12,34的加法,先输入12和34,再输入+。因为计算器的实现就像栈一样。
1.18.9. x64
1.18.10. 练习