2.2 浮点数类型

计算机也需要运算和存储数学中的实数。在计算机的发展过程中,曾产生过多种存储实数的方式,有的现在已经很少使用了。不管如何存储,都可以将其划分为定点实数存储方式和浮点实数存储方式两种。所谓定点实数,就是约定整数位和小数位的长度,比如用4字节存储实数,我们可以约定两个高字节存放整数部分,两个低字节存储小数部分。

这样做的好处是计算效率高,缺点也显而易见:存储不灵活,比如我们想存储65536.5,由于整数的表达范围超过了2字节,就无法用定点实数存储方式了。对应地,也有浮点实数存储方式,道理很简单,就是用一部分二进制位存放小数点的位置信息,我们可以称之为“指数域”,其他的数据位用来存储没有小数点时的数据和符号,我们可以称之为“数据域”“符号域”。在访问时取得指数域,与数据域运算后得到真值,如67.625,利用浮点实数存储方式,数据域可以记录为67625,小数点的位置可以记为10的-3次方,对该数进行访问时计算一下即可。

浮点实数存储方式的优缺点和定点实数存储方式的正好相反。在80286之前,程序员常常为实数的计算伤脑筋,而后来出现的浮点协处理器,可以协助主处理器分担浮点运算,程序员计算实数的效率因此得到提升,于是浮点实数存储方式也就普及开来,成为现在主流的实数存储方式。但是,在一些条件恶劣的嵌入式开发场合,仍可看到定点实数的存储和使用。

在C/C++中,使用浮点方式存储实数,用两种数据类型来保存浮点数:float(单精度)、double(双精度)。float在内存中占4字节,double在内存中占8字节。由于占用空间大,double可描述的精度更高。这两种数据类型在内存中同样以十六进制方式存储,但与整型类型有所不同。

整型类型是将十进制转换成二进制保存在内存中,以十六进制方式显示。浮点类型并不是将一个浮点小数直接转换成二进制数保存,而是将浮点小数转换成的二进制码重新编码,再进行存储。C/C++的浮点数是有符号的。

在C/C++中,将浮点数强制转换为整数时,不会采用数学上四舍五入的方式,而是舍弃掉小数部分(第4章会提到的“向零取整”),不会进位。

浮点数的操作不会用到通用寄存器,而是会使用浮点协处理器的浮点寄存器,专门对浮点数进行运算处理。

2.2.1 浮点数的编码方式

浮点数编码转换采用的是IEEE规定的编码标准,float和double这两种类型数据的转换原理相同,但由于表示的范围不一样,编码方式有些许区别。IEEE规定的浮点数编码会将一个浮点数转换为二进制数。以科学记数法划分,将浮点数拆分为3部分:符号、指数、尾数。

1. float类型的IEEE编码

float类型在内存中占4字节(32位)。最高位用于表示符号,在剩余的31位中,从左向右取8位表示指数,其余表示尾数,如图2-2所示。

图2-2 float类型的二进制表示说明

在进行二进制转换前,需要对单精度浮点数进行科学记数法转换。例如,将float类型的12.25f转换为IEEE编码,须将12.25f转换成对应的二进制数1100.01,整数部分为1100,小数部分为01;小数点向左移动,每移动1次,指数加1,移动到除符号位的最高位为1处,停止移动,这里移动3次。对12.25f进行科学记数法转换后二进制部分为1.10001,指数部分为3。在IEEE编码中,由于在二进制情况下,最高位始终为1,为一个恒定值,故将其忽略不计。这里是一个正数,所以符号位添加0。

12.25f经IEEE转换后各位如下。

  • 符号位:0。
  • 指数位:十进制3+127=130,转换为二进制为10000010。
  • 尾数位:10001 000000000000000000(当不足23位时,低位补0填充)。

由于尾数位中最高位1是固定值,故忽略不计,只要在转换回十进制数时加1即可。为什么指数位要加127呢?这是因为指数可能出现负数,十进制数127可表示为二进制数01111111,IEEE编码方式规定,当指数小于0111111时为一个负数,反之为正数,因此01111111为0。

将示例中转换后的符号位、指数位和尾数位按二进制拼接在一起,就成为一个完整的IEEE浮点编码:01000001010001000000000000000000。转换成十六进制数为0x41440000,内存中以小尾方式进行排列,故为00 00 44 41,分析结果如图2-3所示。

图2-3 单精度浮点数12.25f转换为IEEE编码

上面演示了符号位为正、指数位也为正的情况。那么什么情况下指数位可以为负呢?根据科学记数法,小数点向整数部分移动时,指数做加法。相反,小数点向小数部分移动时,指数需要以0起始做减法。浮点数-0.125f转换IEEE编码后,将会是一个符号位为1、指数部分为负的小数。-0.125f经转换后二进制部分为0.001,用科学记数法表示为1.0,指数为-3。

-0.125fIEEE转换后各位的情况如下。

  • 符号位:1。
  • 指数位:十进制127+(-3),转换为二进制是01111100,如果不足8位,则高位补0。
  • 尾数位:00000000000000000000000。

-0.125f转换后的IEEE编码二进制拼接为10111110000000000000000000000000。转换成十六进制数为0xBE000000,内存中显示为00 00 00 BE,分析结果如图2-4所示。

图2-4 单精度浮点数-0.125f转换为IEEE编码

上面的两个浮点数小数部分转换为二进制时都是有穷的,如果小数部分转换为二进制时得到一个无穷值,则会根据尾数部分的长度舍弃多余的部分。如单精度浮点数1.3f,小数部分转换为二进制就会产生无穷值,依次转换为0.3、0.6、1.2、0.4、0.8、1.6、1.2、0.4、0.8……转换后得到的二进制数为1.01001100110011001100110,到第23位终止,尾数部分无法保存更大的值。

1.3f经IEEE转换后各位的情况如下。

  • 符号位:0。
  • 指数位:十进制0+127,转换二进制01111111。
  • 尾数位:01001100110011001100110。

1.3f转换后的IEEE编码二进制拼接为00111111101001100110011001100110。转换成十六进制数为0x3FA66666,在内存中显示为66 66 A6 3F。由于在转换二进制过程中产生了无穷值,舍弃了部分位数,所以进行IEEE编码转换后得到的是一个近似值,存在一定的误差。再次将这个IEEE编码值转换成十进制小数,得到的值为1.2516582,四舍五入保留一位小数之后为1.3。这就解释了为什么C++在比较浮点数值是否为0时,要做一个区间比较而不是直接进行等值比较。正确浮点数比较的代码见代码清单2-1。

代码清单2-1 正确浮点数比较

float f1 = 0.0001f; // 精确范围
if (f2 >= -f1 && f2 <= f1)
{
    // f1等于0
}

2. double类型的IEEE编码

前文讲解了单精度浮点类型的IEEE编码。double类型和float类型大同小异,只是double类型表示的范围更大,占用空间更多,是float类型所占空间的两倍。当然,精准度也会更高。

double类型占8字节的内存空间,同样,最高位也用于表示符号,指数位占11位,剩余52位表示位数。

在float类型中,指数位范围用8位表示,加127后用于判断指数符号。在double类型中,由于扩大了精度,因此指数范围使用11位正数表示,加1023后可用于指数符号判断。

double类型的IEEE编码转换过程与float类型一样,读者可根据float类型的转换流程来转换double类型,此处不再赘述。

2.2.2 基本的浮点数指令

前面介绍了浮点数的编码方式,下面我们来学习浮点数指令。浮点数的操作指令与普通数据类型不同,浮点数操作是通过浮点寄存器实现的,而普通数据类型使用的是通用寄存器,它们分别使用两套不同的指令。

在早期CPU中,浮点寄存器是通过栈结构实现的,由ST(0)~ST(7)共8个栈空间组成,每个浮点寄存器占8字节。每次使用浮点寄存器都是率先使用ST(0),而不能越过ST(0)直接使用ST(1)。浮点寄存器的使用就是压栈、出栈的过程。当ST(0)中存在数据时,执行压栈操作后,ST(0)中的数据将装入ST(1)中,如无出栈操作,将按顺序向下压栈,直到将浮点寄存器占满为止。常用浮点数指令的介绍如表2-1所示,其中,IN表示操作数入栈,OUT表示操作数出栈。

表2-1 常用浮点数指令表

其他运算指令和普通指令类似,只须在前面加F即可,如FSUB和FSUBP等。

在使用浮点指令时,都要先利用ST(0)进行运算。当ST(0)中有值时,便会将ST(0)中的数据顺序向下存放到ST(1)中,然后再将数据放入ST(0)。如果再次操作ST(0),则会先将ST(1)中的数据放入ST(2),然后将ST(0)中的数据放入ST(1),最后将新的数据存放到ST(0)。以此类推,在8个浮点寄存器都有值的情况下继续向ST(0)中的存放数据,这时会丢弃ST(7)中的数据信息。

1997年开始,Intel和AMD都引入了媒体指令(MMX),这些指令允许多个操作并行,允许对多个不同的数据并行执行同一操作。近年来,这些扩展有了长足的发展。名字经过了一系列的修改,从MMX到SSE(流SIMD扩展),以及最新的AVX(高级向量扩展)。每一代都有一些不同的版本。每个扩展都用来管理寄存器中的数据,这些寄存器在MMX中被称为MM寄存器,在SSE中被称为XMM寄存器,在AVX中被称为YMM寄存器。MM寄存器是64位的,XMM是128位的,而YMM是256位的。每个YMM寄存器可以存放8个32位值或4个64位值,可以是整数,也可以是浮点数。YMM寄存器一共有16个(YMM0~YMM15),而XMM是YMM的低128位。常用SSE浮点数指令的介绍如表2-2所示。

表2-2 常用SSE浮点数指令表

下面通过一个简单的示例介绍各指令的使用流程,帮助读者熟悉浮点指令的使用方法,如代码清单2-2所示。

代码清单2-2 Debug版float指令练习

// C+源码
#include <stdio.h>
int main(int argc, char* argv[]) {
  float f = (float)argc;
  printf("%f", f);
  argc = (int)f;
  printf("%d", argc);
  return 0;
}
//x86_vs对应汇编代码讲解
00401000  push    ebp
00401001  mov     ebp, esp
00401003  push    ecx
00401004  cvtsi2ss xmm0, dword ptr [ebp+8]
00401009  movss   [ebp-4], xmm0                         ;f = (float)argc;
0040100E  cvtss2sd xmm0, dword ptr [ebp-4];xmm0=(double)f
00401013  sub     esp, 8
00401016  movsd   qword ptr [esp], xmm0                 ;参数2 xmm0入栈
0040101B  push    offset asc_412160                     ;参数1 "%f"入栈
00401020  call    sub_401090                            ;调用printf函数
00401025  add     esp, 0Ch                              ;平衡栈
00401028  cvttss2si eax, dword ptr [ebp-4]
0040102D  mov     [ebp+8], eax ;argc=(int)f
00401030  mov     ecx, [ebp+8]
00401033  push    ecx                                   ;参数2 argc入栈
00401034  push    offset aD                             ;参数1 "%d"入栈
00401039  call    sub_401090                            ;调用printf函数
0040103E  add     esp, 8                                ;平衡栈
00401041  xor     eax, eax
00401043  mov     esp, ebp
00401045  pop     ebp
00401046  retn

//x86_gcc对应汇编代码讲解
00401510  push    ebp
00401511  mov     ebp, esp
00401513  and     esp, 0FFFFFFF0h                       ;栈对齐
00401516  sub     esp, 30h
00401519  call    ___main                               ;调用初始化函数
0040151E  fild    [ebp+8]                               ;argc转换双精度数入栈
00401521  fstp    dword ptr [esp+2Ch]                   ;f=(float)argc
00401525  fld     dword ptr [esp+2Ch]
00401529  fstp    qword ptr [esp+4]                     ;参数2 (double)f入栈
0040152D  mov     dword ptr [esp], offset asc_404000    ;参数1 "%f"入栈
00401534  call    _printf                               ;调用printf函数
00401539  fld     dword ptr [esp+2Ch]                   ;f入栈st(0)
0040153D  fnstcw  word ptr [esp+1Eh]
00401541  movzx   eax, word ptr [esp+1Eh]
00401546  or      ah, 0Ch
00401549  mov     [esp+1Ch], ax
0040154E  fldcw   word ptr [esp+1Ch]                    ;浮点异常检查代码
00401552  fistp   [ebp+8] ;argc=(int)f
00401555  fldcw   word ptr [esp+1Eh]
00401559  mov     eax, [ebp+8]
0040155C  mov     [esp+4], eax                          ;参数2 argc入栈
00401560  mov     dword ptr [esp], offset aD            ;参数1 "%d"入栈
00401567  call    _printf                               ;调用printf函数
0040156C  mov     eax, 0
00401571  leave
00401572  retn

//x86_clang对应汇编代码讲解
00401000  push    ebp
00401001  mov     ebp, esp
00401003  sub     esp, 24h
00401006  mov     eax, [ebp+0Ch]                        ;eax=argv
00401009  mov     ecx, [ebp+8]                          ;ecx=argc
0040100C  mov     dword ptr [ebp-4], 0
00401013  mov     edx, [ebp+8]                          ;edx=argc
00401016  cvtsi2ss xmm0, edx                            ;xmm0=(int)argc
0040101A  movss   dword ptr [ebp-8], xmm0               ;f=(int)argc
0040101F  movss   xmm0, dword ptr [ebp-8]
00401024  cvtss2sd xmm0, xmm0                           ;xmm0=(double)f
00401028  lea     edx, asc_412160                       ;edx="%f"
0040102E  mov     [esp], edx                            ;参数1 "%f"入栈
00401031  movsd   qword ptr [esp+4], xmm0               ;参数2 xmm0入栈
00401037  mov     [ebp-0Ch], eax
0040103A  mov     [ebp-10h], ecx
0040103D  call    sub_401070                            ;调用printf函数
00401042  cvttss2si ecx, dword ptr [ebp-8]
00401047  mov     [ebp+8], ecx                          ;argc=(int)f
0040104A  mov     ecx, [ebp+8]
0040104D  lea     edx, aD ;edx="%d"
00401053  mov     [esp], edx                            ;参数1 "%d"入栈
00401056  mov     [esp+4], ecx                          ;参数2 argc入栈
0040105A  mov     [ebp-14h], eax
0040105D  call    sub_401070                            ;调用printf函数
00401062  xor     ecx, ecx
00401064  mov     [ebp-18h], eax
00401067  mov     eax, ecx
00401069  add     esp, 24h
0040106C  pop     ebp
0040106D  retn
//x64_vs对应汇编代码讲解
0000000140001000  mov     [rsp+10h], rdx
0000000140001005  mov     [rsp+8], ecx
0000000140001009  sub     rsp, 38h
000000014000100D  cvtsi2ss xmm0, dword ptr [rsp+40h]    ;xmm0=(float)argc
0000000140001013  movss   dword ptr [rsp+20h], xmm0     ;f=(float)argc
0000000140001019  cvtss2sd xmm0, dword ptr [rsp+20h]    ;xmm0=(double)f
000000014000101F  movaps  xmm1, xmm0
0000000140001022  movq    rdx, xmm1                     ;参数2 (double)f
0000000140001027  lea     rcx, asc_1400122C0            ;参数1 "%f"
000000014000102E  call    sub_1400010C0                 ;调用printf函数
0000000140001033  cvttss2si eax, dword ptr [rsp+20h]    ;eax=(int)f
0000000140001039  mov     [rsp+40h], eax                ;argc=(int)f
000000014000103D  mov     edx, [rsp+40h]                ;参数2 argc
0000000140001041  lea     rcx, aD                       ;参数1 "%d"
0000000140001048  call    sub_1400010C0                 ;调用printf函数
000000014000104D  xor     eax, eax
000000014000104F  add     rsp, 38h
0000000140001053  retn;
//x64_gcc对应汇编代码讲解
0000000000401550  push    rbp
0000000000401551  mov     rbp, rsp
0000000000401554  sub     rsp, 30h
0000000000401558  mov     [rbp+10h], ecx
000000000040155B  mov     [rbp+18h], rdx
000000000040155F  call    __main                        ;调用初始化函数
0000000000401564  cvtsi2ss xmm0, dword ptr [rbp+10h]
0000000000401569  movss   dword ptr [rbp-4], xmm0       ;f=(float)argc
000000000040156E  cvtss2sd xmm0, dword ptr [rbp-4]      ;xmm0=(double)f
0000000000401573  movq    rax, xmm0
0000000000401578  mov     rdx, rax
000000000040157B  movq    xmm1, rdx
0000000000401580  mov     rdx, rax                      ;参数2 (double)f
0000000000401583  lea     rcx, Format                   ;参数1 "%f"
000000000040158A  call    printf                        ;调用printf函数
000000000040158F  movss   xmm0, dword ptr [rbp-4]
0000000000401594  cvttss2si eax, xmm0
0000000000401598  mov     [rbp+10h], eax                ;argc=(int)f
000000000040159B  mov     edx, [rbp+10h]                ;参数1 argc
000000000040159E  lea     rcx, aD                       ;参数2 "%d"
00000000004015A5  call    printf                        ;调用printf函数
00000000004015AA  mov     eax, 0
00000000004015AF  add     rsp, 30h
00000000004015B3  pop     rbp
00000000004015B4  retn
//x64_clang对应汇编代码讲解
0000000140001000  sub     rsp, 48h
0000000140001004  mov     dword ptr [rsp+44h], 0
000000014000100C  mov     [rsp+38h], rdx                ;保存argv
0000000140001011  mov     [rsp+34h], ecx                ;保存argc
0000000140001015  mov     ecx, [rsp+34h]
0000000140001019  cvtsi2ss xmm0, ecx
000000014000101D  movss   dword ptr [rsp+30h], xmm0     ;f=(int)argc
0000000140001023  movss   xmm0, dword ptr [rsp+30h]
0000000140001029  cvtss2sd xmm0, xmm0                   ;xmm0=(double)f
000000014000102D  lea     rcx, asc_1400122C0            ;参数1 "%f"
0000000140001034  movaps  xmm1, xmm0
0000000140001037  movq    rdx, xmm0                     ;参数2 (double)f
000000014000103C  call    sub_140001070                 ;调用printf函数
0000000140001041  cvttss2si r8d, dword ptr [rsp+30h]
0000000140001048  mov     [rsp+34h], r8d                ;argc=(int)f
000000014000104D  mov     edx, [rsp+34h]                ;参数2 argc
0000000140001051  lea     rcx, aD                       ;参数1 "%d"
0000000140001058  mov     [rsp+2Ch], eax
000000014000105C  call    sub_140001070                 ;调用printf函数
0000000140001061  xor     edx, edx
0000000140001063  mov     [rsp+28h], eax
0000000140001067  mov     eax, edx
0000000140001069  add     rsp, 48h
000000014000106D  retn

代码清单2-2通过浮点数与整数、整数与浮点数间的互相转换演示了数据传送类型的浮点指令的使用方法。从示例中可以发现,float类型的浮点数虽然占4字节,但是使用浮点栈将以8字节方式进行处理,而使用媒体寄存器则以4字节处理。当浮点数作为参数时,并不能直接压栈,PUSH指令只能传入4字节数据到栈中,这样会丢失4字节数据。这就是使用printf函数以整数方式输出浮点数时会产生错误的原因。printf以整数方式输出时,将对应参数作为4字节数据长度,按补码方式解释,而真正压入的参数为浮点类型时,却是8字节长度,需要按浮点编码方式解释。

浮点数作为返回值的情况也是如此,在32位程序中使用浮点栈st(0)作为返回值同样需要传递8字节数据,64位程序中使用媒体寄存器xmm0作为返回值只需要传递4字节,如代码清单2-3所示。

代码清单2-3 浮点数作为返回值

// C++源码
#include <stdio.h>
float getFloat() {
  return 12.25f;
}

int main(int argc, char* argv[]) {
  float f = getFloat();
  return 0;
}
//x86_vs对应汇编代码讲解
00401010  push    ebp
00401011  mov     ebp, es
00401013  push    ecx                 ;参数1 argc入栈
00401014  call    sub_401000          ;调用getFloat()函数
00401019  fstp    dword ptr [ebp-4]   ;f=getFloat()将st(0)的双精度数转换为单精度数
0040101C  xor     eax, eax
0040101E  mov     esp, ebp
00401020  pop     ebp
00401021  retn

00401000  push    ebp
00401001  mov     ebp, esp
00401003  fld     ds:flt_40D150       ;将返回值入栈到st(0)中,单精度数转换为双精度数入栈
00401009  pop     ebp
0040100A  retn                        ;getFloat()函数返回

//x86_gcc对应汇编代码讲解
00401517  push    ebp
00401518  mov     ebp, esp
0040151A  and     esp, 0FFFFFFF0h     ;栈对齐
0040151D  sub     esp, 10h
00401520  call    ___main             ;调用初始化函数
00401525  call    __Z8getFloatv       ;调用getFloat()函数
0040152A  fstp    dword ptr [esp+0Ch] ;f=getFloat()将st(0)的双精度数转换为单精度数
0040152E  mov     eax, 0
00401533  leave
00401534  retn
00401510  fld     ds:flt_404000       ;将返回值入栈到st(0)中,单精度数转换为双精度数入栈
00401516  retn                        ;getFloat()函数返回

//x86_clang对应汇编代码讲解
00401010  push    ebp
00401011  mov     ebp, esp
00401013  sub     esp, 14h
00401016  mov     eax, [ebp+0Ch]      ;eax=argv
00401019  mov     ecx, [ebp+8]        ;ecx=argc
0040101C  mov     dword ptr [ebp-4], 0
00401023  mov     [ebp-10h], eax
00401026  mov     [ebp-14h], ecx
00401029  call    sub_401000          ;调用getFloat()函数
0040102E  fstp    dword ptr [ebp-0Ch] ;f=getFloat()将st(0)的双精度数转换为单精度数
00401031  movss   xmm0, dword ptr [ebp-0Ch]
00401036  xor     eax, eax
00401038  movss   dword ptr [ebp-8], xmm0
0040103D  add     esp, 14h
00401040  pop     ebp
00401041  retn

00401000  push    ebp
00401001  mov     ebp, esp
00401003  fld     ds:flt_40D150       ;将返回值入栈到st(0)中,单精度数转换为双精度数入栈
00401009  pop     ebp
0040100A  retn                        ;getFloat()函数返回

//x64_vs对应汇编代码讲解
0000000140001010  mov     [rsp+10h], rdx
0000000140001015  mov     [rsp+8], ecx
0000000140001019  sub     rsp, 38h
000000014000101D  call    sub_140001000 ;调用getFloat()函数
0000000140001022  movss   dword ptr [rsp+20h], xmm0;f=getFloat()从xmm0获取返回值
0000000140001028  xor     eax, eax
000000014000102A  add     rsp, 38h
000000014000102E  retn

0000000140001000  movss   xmm0, cs:dword_14000D2C0;xmm0=12.25f
0000000140001008  retn                  ;getFloat()函数返回

//x64_gcc对应汇编代码讲解
0000000000401559  push    rbp
000000000040155A  mov     rbp, rsp
000000000040155D  sub     rsp, 30h
0000000000401561  mov     [rbp+10h], ecx
0000000000401564  mov     [rbp+18h], rdx
0000000000401568  call    __main      ;调用初始化函数
000000000040156D  call    _Z8getFloatv ;调用getFloat()函数
0000000000401572  movd    eax, xmm0
0000000000401576  mov     [rbp-4], eax ;f=getFloat()从xmm0获取返回值
0000000000401579  mov     eax, 0
000000000040157E  add     rsp, 30h
0000000000401582  pop     rbp
0000000000401583  retn

0000000000401550  movss   xmm0, cs:dword_404000;xmm0=12.25f
0000000000401558  retn                ;getFloat()函数返回

//x64_clang对应汇编代码讲解
0000000140001010  sub     rsp, 38h
0000000140001014  mov     dword ptr [rsp+34h], 0
000000014000101C  mov     [rsp+28h], rdx
0000000140001021  mov     [rsp+24h], ecx
0000000140001025  call    sub_140001000 ; 调用getFloat()函数
000000014000102A  xor     eax, eax
000000014000102C  movss   dword ptr [rsp+20h], xmm0;f=getFloat()从xmm0获取返回值
0000000140001032  add     rsp, 38h

0000000140001036  retn

0000000140001000  movss   xmm0, cs:dword_14000D2C0;xmm0=12.25f
0000000140001008  retn                  ;getFloat()函数返回