C++冷知识第【二】期 算数运算底层长什么样

前言

  • 在第一期中我简单介绍了一下各种工具的使用,这一期我们将通过实践,亲自上手揭示C++中算数运算符的神秘面纱。

算数运算

运算符含义
+加法运算,对左右两边的操作数进行加法运算,并返回运算结果
-减法运算,对左右两边的操作数进行减法运算,并返回运算结果
*乘法运算,对左右两边的操作数进行乘法运算,并返回运算结果
/除法运算,对左右两边的操作数进行除法运算,并返回运算结果
%除法运算,对左右两边的操作数进行除法运算,并返回运算结果
i++自增运算,让变量 i 自增 1 ,并返回自增前的结果
i–自减运算,让变量 i 自减 1,并返回自减前的结果
++i自增运算,让变量 i 自增 1,并返回自增后的结果
–i自减运算,让变量 i 自减 1,并返回自减后的结果

上手实践

  • 下面的实践中将会带大家,底层的算数运算是什么样子的

加法与减法

  1. 打开 Compiler Explorer,在左侧选择C++并输入以下的代码,右侧选择编译器 x86-64 gcc 11.4 :

    void func(){
        int a = 1;
        int b = 1+1;
        int c = 0;
        c = 1+1;
        c = c+b;
        c = c+2;
        int d = a+1;
        int e = 1+a;
        int f = a+b;
    
        c = a-b;
        d = 1-a;
    }
    
    
    int main(){
        func();
        return 0;
    }
    
  2. 右边生成了如下的汇编代码(只用看 func 部分,为了方便区分,我为生成的汇编代码加了注释,并且分块进行展示)

    func():
     push   rbp
     mov    rbp,rsp
     
     mov    DWORD PTR [rbp-0x4],0x1			; int a = 1;
     
     mov    DWORD PTR [rbp-0x8],0x2			; int b = 1+1;
     
     mov    DWORD PTR [rbp-0xc],0x0			; int c = 0;
     
     mov    DWORD PTR [rbp-0xc],0x2			; c = 1+1;
     
     mov    eax,DWORD PTR [rbp-0x8]			; c = c+b;
     add    DWORD PTR [rbp-0xc],eax
     
     add    DWORD PTR [rbp-0xc],0x2			; c = c+2;
     
     mov    eax,DWORD PTR [rbp-0x4]			; int d = a+1;
     add    eax,0x1
     mov    DWORD PTR [rbp-0x10],eax
     
     mov    eax,DWORD PTR [rbp-0x4]			; int e = 1+a;
     add    eax,0x1
     mov    DWORD PTR [rbp-0x14],eax
     
     mov    edx,DWORD PTR [rbp-0x4]			; int f = a+b;
     mov    eax,DWORD PTR [rbp-0x8]
     add    eax,edx
     mov    DWORD PTR [rbp-0x18],eax
     
     mov    eax,DWORD PTR [rbp-0x4]			; c = a-b;
     sub    eax,DWORD PTR [rbp-0x8]
     mov    DWORD PTR [rbp-0xc],eax
     
     mov    eax,0x1							; d = 1-a;
     sub    eax,DWORD PTR [rbp-0x4]
     mov    DWORD PTR [rbp-0x10],eax
    
     nop
     pop    rbp
     ret
    
  3. 先来看看汇编的第 7 行:

    mov    DWORD PTR [rbp-0x8],0x2			; int b = 1+1;
    
    • 有没有发现,编译器生成的汇编指令并没有生成加法指令来计算两个整数字面量的加法,说明编译器对此是有优化的。
  4. 接下来观察汇编的 13 ~ 14 行:

     mov    eax,DWORD PTR [rbp-0x8]			; c = c+b;
     add    DWORD PTR [rbp-0xc],eax
    
    • 这里编译器选择将数据从内存中导出然后再运算,最后再将结果放回内存中。这样看上去多此一举,那能否直接将结果加到内存上呢?就像下面这样

       add    DWORD PTR [rbp-0xc],DWORD PTR [rbp-0x8]	
      
    • 可以通过下面的方法进行验证:

      • gcc-S 选项可以生成程序的汇编文件,我们可以在此基础上修改对应的汇编文件,最后将它编译成二进制文件,看看内否编译通过,以及能否运行。
      1. 在 Linux 系统中编写下面的C程序(main.c

        void func(){
            int a = 0;
            int b = 2;
            a = a + b;
        }
        
        int main(){
            func();
            return 0;
        }
        
      2. 使用下面的命令生成对应的 intel 风格汇编文件 main.s

        gcc -g -S main.c -masm=intel -o main.s
        
      3. 生成的汇编文件相对较大,我只截取包含func的前几行:

            .file   "main.c"
            .intel_syntax noprefix
            .text
        .Ltext0:
            .file 0 "/root/test/seg" "main.c"
            .globl  func
            .type   func, @function
        func:
        .LFB0:
            .file 1 "main.c"
            .loc 1 1 12
            .cfi_startproc
            endbr64
            push    rbp
            .cfi_def_cfa_offset 16
            .cfi_offset 6, -16
            mov rbp, rsp
            .cfi_def_cfa_register 6
            .loc 1 2 9
            mov DWORD PTR -8[rbp], 0
            .loc 1 3 9
            mov DWORD PTR -4[rbp], 2
            .loc 1 4 7
            mov eax, DWORD PTR -4[rbp]				; 这里是我们需要修改的
            add DWORD PTR -8[rbp], eax
            .loc 1 5 1
            nop
            pop rbp
            .cfi_def_cfa 7, 8
            ret
            .cfi_endproc
        
      4. 将 24 、25 行的汇编代码改成下面的样子:

        add DWORD PTR -8[rbp],DWORD PTR -4[rbp]
        
      5. 然后使用下面的命令将汇编代码编译成可执行文件:

        gcc main.s -o main
        
        • 出现了下面的报错:

        修改汇编报错

      6. 通过修改汇编代码可以发现,这样的想法无法实现,至于为什么,我没有找到特别有说服力的答案,大多数说法是为了控制指令的长度。如果有朋友知道比较有说服力的答案欢迎在评论区分享。

乘法

  1. 将以下代码输入 Compiler Explorer

    void func(){
        int a = 2;
        int b = 3;
        unsigned int c = 10;
        unsigned int d = 8;
        unsigned int e = c * d;
        a = b * a;
        a = 3 * 2;
        a = 11 * b;
    
    }
    
    
    int main(){
        func();
        return 0;
    }
    
  2. 生成的汇编代码如下(只需要看 func 部分即可)

    func():
     push   rbp
     mov    rbp,rsp
     mov    DWORD PTR [rbp-0x4],0x2				; int a = 2;
     mov    DWORD PTR [rbp-0x8],0x3				; int b = 3;
     
     mov    DWORD PTR [rbp-0xc],0xa				; unsigned int c = 10;
     mov    DWORD PTR [rbp-0x10],0x8			; unsigned int d = 8;
     
     mov    eax,DWORD PTR [rbp-0xc]				; unsigned int e = c * d;
     imul   eax,DWORD PTR [rbp-0x10]
     mov    DWORD PTR [rbp-0x14],eax
     
     mov    eax,DWORD PTR [rbp-0x4]				; a = b * a;
     imul   eax,DWORD PTR [rbp-0x8]
     mov    DWORD PTR [rbp-0x4],eax
     
     mov    DWORD PTR [rbp-0x4],0x6				; a = 3 * 2;   编译器对字面量运算做了一定的优化
     
     mov    edx,DWORD PTR [rbp-0x8]				; a = 11 * b;
     mov    eax,edx
     shl    eax,0x2
     add    eax,edx
     add    eax,eax
     add    eax,edx
     mov    DWORD PTR [rbp-0x4],eax
     
     nop
     pop    rbp
     ret
    
  3. 首先来看汇编代码的第 14 ~ 16 行:

     mov    eax,DWORD PTR [rbp-0x4]				; a = b * a;
     imul   eax,DWORD PTR [rbp-0x8]
     mov    DWORD PTR [rbp-0x4],eax
    
    • 可以看到这里使用到了一个新的指令 imul ,它表示整数乘法 integer multiplying。其用法与加减法指令类似。不过事实上还有另一种乘法指令 mul 用于无符号整数的乘法。看第 7 ~ 12 行的汇编代码:

       mov    DWORD PTR [rbp-0xc],0xa				; unsigned int c = 10;
       mov    DWORD PTR [rbp-0x10],0x8			; unsigned int d = 8;
       
       mov    eax,DWORD PTR [rbp-0xc]				; unsigned int e = c * d;
       imul   eax,DWORD PTR [rbp-0x10]
       mov    DWORD PTR [rbp-0x14],eax
      
    • 你会发现,貌似GCC编译器并没有按照我们所想的那样使用 mul 来进行无符号整数运算。我在 Stack Overflow 上找到一片帖子说明了这种情况(c - GCC and the Multiply Instruction - Stack Overflow

      • 其中有这是因为操作数的大小不同,也有说 imul 便于CPU 优化等观点。有些观点有争议,客观参考。
  4. 接着看 20 ~ 26 行,可以发现生成的汇编代码有些抽象,我对这些代码打了注释:

    mov    edx,DWORD PTR [rbp-0x8]   ; 将 [rbp-0x8] 地址中的值(即变量 b 的值,3)移动到 edx 寄存器中
    mov    eax,edx                   ; 将 edx 寄存器中的值(3)复制到 eax 寄存器中
    shl    eax,0x2                   ; 将 eax 寄存器中的值(3)左移两位(相当于乘以 4),此时 eax = 12
    add    eax,edx                   ; 将 edx 寄存器中的值(3)加到 eax 寄存器中,此时 eax = 15
    add    eax,eax                   ; 将 eax 寄存器中的值(15)加倍,此时 eax = 30
    add    eax,edx                   ; 将 edx 寄存器中的值(3)再次加到 eax 寄存器中,此时 eax = 33
    mov    DWORD PTR [rbp-0x4],eax   ; 将 eax 寄存器中的值(33)存储到 [rbp-0x4] 地址中(即变量 a 的位置)
    
    • 编译器实际上将 11 × 3 11 \times 3 11×3 优化成了 ( ( 4 + 1 ) × 2 + 1 ) × 3 ((4+1) \times 2 + 1) \times 3 ((4+1)×2+1)×3 。编译器用位移运算和加法运算替换了乘法运算,为什么?
      • 位移运算和加减法运算与乘法运算相比代价较小,移位指令的周期更短效率更高。
      • PS:左位移运算相当于对 2 的乘法,比如本例中的数字 3 对应的二进制数是 11 ,将它的二进制数左移 2 位,对应的二进制数变成了 1100 即十进制的 12 相当于乘以 4 ( 2 2 2^2 22)。
      • 参考 32位编译器整型乘除法优化 - 简书 (jianshu.com)

除法与模运算

  • 除法和模运算实际上是一体的,对一个数进行除法运算,获得的商就是除法结果,获得的余数就是模运算结果。下面的例子也说明了这个问题,同时我们还将遇到通用寄存器 axdx 的特殊用法
  1. 打开 Compiler Explorer 输入以下代码:

    void func(){
        int a = 10;
        int b = 2;
        int c = a / b;
        c = a % b;
       	unsigned int d = 10;
        unsigned int e = 2;
        unsigned int f = d / e;
    }
    
    
    int main(){
        func();
        return 0;
    }
    
  2. 生成了如下的汇编指令(只看 func 部分)

    func():
     push   rbp
     mov    rbp,rsp
     mov    DWORD PTR [rbp-0x4],0xa
     mov    DWORD PTR [rbp-0x8],0x2
     
     mov    eax,DWORD PTR [rbp-0x4]					; int c = a / b;
     cdq
     idiv   DWORD PTR [rbp-0x8]
     mov    DWORD PTR [rbp-0xc],eax
     
     mov    eax,DWORD PTR [rbp-0x4]					; c = a % b;
     cdq
     idiv   DWORD PTR [rbp-0x8]
     mov    DWORD PTR [rbp-0xc],edx
     
     mov    DWORD PTR [rbp-0x10],0xa
     mov    DWORD PTR [rbp-0x14],0x2
     
     mov    eax,DWORD PTR [rbp-0x10]				; unsigned int f = d / e;
     mov    edx,0x0
     div    DWORD PTR [rbp-0x14]
     mov    DWORD PTR [rbp-0x18],eax
     
     nop
     pop    rbp
     ret
    
  3. 首先来验证我们开始时的想法:除法运算和模运算实际上是一体的 。分别观察 int c = a / b;c = a % b; 你可以发现二者生成的指令基本一模一样,它们都使用到了整数除法运算 idiv 。不同点在于第 10 和 第 15 行:int c = a / b; 的汇编代码最终是将寄存器 eax 的值存储到变量 c 的内存中,而 c = a % b; 的汇编代码最终是将寄存器 edx 的值存储到变量 c 的内存中。另外你有没有发现 idiv 指令中只有一个操作数 DWORD PTR [rbp-0x8] 它对应的内存地址是变量 b ,而它没有直接指出被除数 c 。没错,这里就是通用寄存器 axdx 的特殊用法:

    • ax :在除法运算开始之间存储被除数,并且在除法运算结束之后商会被存储在它里面
    • dx :可用于在除法运算结束之后存储余数,它还被用于存储被除数的符号位扩展
  4. 另外我们还能发现,在指令 idiv 之前还有一个指令 cdq 。通过查询资料可以得知x86汇编_DIV / IDIV除法指令_笔记53_x86 div指令-CSDN博客

    • 我们先来补充一个知识点:计算机中如何存储负数?你应该听说过计算机中采用 补码 表示法来表示整数(以下示例参考 原码、反码、补码 详解! (qq.com) ):

      • 原码 :原码就是符号位加上真值的绝对值:

        • [+1]原= 0000 0001

          [-1] 原= 1000 0001

      • 反码 :符号位不变,对原码的其它位按位取反,0 变成 1,1 变成 0

        • [+1] = [0000 0001]原= [0000 0001]反

          [-1] = [1000 0001]原= [1111 1110]反

      • 补码 :正数补码等价于原码,负数补码是在反码的基础上加 1

        • [+1] = [0000 0001]原= [0000 0001]反= [0000 0001]补

          [-1] = [1000 0001]原= [1111 1110]反= [1111 1111] 补

    • 为了验证计算机中存储负数使用的是补码,可以在Compiler Explorer 中编写下面的程序

      • void func(){
            int a = -1;
        }
        
        
        int main(){
            func();
            return 0;
        }
        
      • func():
         push   rbp
         mov    rbp,rsp
         mov    DWORD PTR [rbp-0x4],0xffffffff				; int a = -1;
         nop
         pop    rbp
         ret
        
      • 可以看到 -1 在汇编中表示为 0xffffffff ,因为 int 变量的大小为 4 个字节,而一个字节需要两个十六进制数表示,已知 -1 的原码为 10000000 00000000 00000000 00000001 。按照计算负数补码的方法,符号位不变:

        • 其它位按位取反:11111111 11111111 11111111 11111110
        • 加上一:11111111 11111111 11111111 11111111
        • 转换成十六进制:0xffffffff
    • 和乘法一样,汇编中除法也分成有符号除法和无符号除法:(补充:通用寄存器 ax 等大小为 16 二进制位,而为了兼容性考虑,它可以拆成两个 8 个二进制位大小的寄存器,高八位为 ah 低八位 为 al 。下面的表示中用 : 表示将两个寄存器的值前后连接起来,比如 dx = 00001100 00000000ax=11101011 01100011 ,则 dx:ax = 00001100 00000000 11101011 01100011

      • 除数被除数,商和余数的存储形式:

        被除数除数余数
        AXreg/mem8ALAH
        DX:AXreg/mem16AXDX
        EDX:EAXreg/mem32EAXEDX
      • 商和余数的大小不会超过被除数长度的一半,为了保证除法结束后的商和余数宽度仍然和原来存储被除数的寄存器一样,所以需要对被除数进行扩展。

      • 有符号

        指令全称说明
        cbwconvert byte to word将AL的符号位扩展到AH
        cwdconvert word to doubleword将AX的符号位扩展到DX
        cdqconvert doubleword to quadword将EAX的符号位扩展到EDX
        • 之前在讲解补码的时候可以知道,补码中会包含一个符号位,而这个符号为处于最高位,当我们在更宽的寄存器或内存位置中存储一个较窄的整数时,我们需要确保这个整数的符号在扩展过程中不会改变。否则,如果我们只是简单地在高位添加0(对于正数)或1(对于负数),我们可能会得到一个完全不同的数。

          例如,假设我们有一个8位的整数-1,在二进制补码中表示为0xFF(即所有位都是1)。如果我们只是简单地在前面添加两个0来得到一个10位的数,我们会得到0x00FF,这实际上代表的是正数255,而不是-1。这显然是不正确的。

      • 无符号

        • 顾名思义不考虑符号位,那么操作数永远是正数,而正数的符号位为 0,这也是为什么我们例子中的无符号除法中 edx 要被置为 0。

           mov    eax,DWORD PTR [rbp-0x10]				; unsigned int f = d / e;
           mov    edx,0x0
           div    DWORD PTR [rbp-0x14]
           mov    DWORD PTR [rbp-0x18],eax
          

    自增与自减

    • 你应该听说过 ++i 的效率会比 i++ 更高,因为 i++ 需要保存自增运算之前的值,事实真是如此吗?下面哪个循环更快?
    for(int i = 0;i<10000;i++);
    
    for(int i = 0;i<10000;++i);
    
    1. 在 Compiler Explorer 中输入以下的代码

      void func(){
          int i = 0;
          int ipp = i++;
          int ppi = ++i;
      
          int j = 0;
          i++;
          ++j;
          
          int iss = i--;
          int ssi = --i;
      
          i--;
          --j;
      }
      
      int main(){
          func();
          return 0;
      }
      
    2. 我们只看 func 部分的汇编代码

      func():
       push   rbp
       mov    rbp,rsp
       
       mov    DWORD PTR [rbp-0x4],0x0
       
       mov    eax,DWORD PTR [rbp-0x4]			; int ipp = i++;
       lea    edx,[rax+0x1]
       mov    DWORD PTR [rbp-0x4],edx
       mov    DWORD PTR [rbp-0x8],eax
       
       add    DWORD PTR [rbp-0x4],0x1			; int ppi = ++i;
       mov    eax,DWORD PTR [rbp-0x4]
       mov    DWORD PTR [rbp-0xc],eax
       
       mov    DWORD PTR [rbp-0x10],0x0
       
       add    DWORD PTR [rbp-0x4],0x1			; i++;
       add    DWORD PTR [rbp-0x10],0x1		; ++j;
       
       mov    eax,DWORD PTR [rbp-0x4]			; int iss = i--;
       lea    edx,[rax-0x1]
       mov    DWORD PTR [rbp-0x4],edx
       mov    DWORD PTR [rbp-0x14],eax
       
       sub    DWORD PTR [rbp-0x4],0x1			; int ssi = --i;
       mov    eax,DWORD PTR [rbp-0x4]
       mov    DWORD PTR [rbp-0x18],eax
       
       sub    DWORD PTR [rbp-0x4],0x1			; i--;
       sub    DWORD PTR [rbp-0x10],0x1		; --i;
       
       nop
       pop    rbp
       ret
      
    3. 可以看到,如果我们不考虑自增运算符的返回值,那么它们二者生成的指令是完全一样的。如果考虑到返回值 i++ 生成的指令要比 ++i ,不过它们实现自增的指令不一样,而且 i++ 生成的指令中由一个 lea edx, [rax+0x1] 这个指令是专门用于做地址计算的,它表示将[rax+0x1] 中地址加法的结果赋值给 edx 。与 leaadd 谁的速度更快,可以参考这篇帖子 https://stackoverflow.com/a/6328441/20203824lea 要比 add 更快,但是 lea 不能直接写入内存,这就是为什么在不考虑返回值时 i++++i 生成的指令都是使用 add 而不是 lealea 更快但是 int ipp = i++; 对应的汇编中要多一条 mov 指令,因此还是必须通过编程+统计来判断谁更快。

      1. 分别编写下面的程序

        • valpp.c

          void func(){
              int val = 0;
              int ans = 0;
              for(int i = 0;i<1000000;++i){
                  ans = val++;
              }
          }
          
          int main(){
              func();
              return 0;
          }
          
        • ppval.c

          void func(){
              int val = 0;
              int ans = 0;
              for(int i = 0;i<1000000;++i){
                  ans = ++val;
              }
          }
          
          int main(){
              func();
              return 0;
          }
          
      2. 使用 gcc 命令编译程序

        gcc -g valpp.c -o valpp
        
        gcc -g ppval.c -o ppval
        
      3. 使用 objdump 工具查看反汇编代码,并检查函数 func 内有关自增运算的部分是否符合预期,保证编译器没有做一些奇怪的优化

        • objdump -d ./valpp -M intel
          

          valpp反汇编

          • 可以看到符合预期,使用的是 lea 指令
        • objdump -d ./ppval -M intel
          

          ppval反汇编

          • 可以看到符合预期,使用的是 add 指令
      4. 使用 time 命令分别运行程序并计时。

        time ./valpp
        

        运行valpp

        time ./ppval
        

        运行ppval

      5. 明显 ++i 更快一些,分析可能是 mov 指令的代价要比 add

    4. 另外如果你还深入了解过汇编,应该还知道有一个指令 inc (increasing)可以实现自增 1 。这里没有使用 inc 而是使用 add 的原因是 add 虽然不总是比 inc 快,但在大多数境况下它们效率基本近似,甚至有时 add 更快。参考这篇帖子 https://stackoverflow.com/a/13383496/20203824

      ADD is not always faster than INC, but it is almost always at least as fast (there are a few corner cases on certain older micro-architectures, but they are exceedingly rare), and sometimes significantly faster.

    5. 所以当不考虑返回值 i++++ii----i 一样快!。开头所说的循环谁更快,想必你心中应该有了答案。

浮点数运算?

  • 我们上面讨论的运算都是基于整数的运算,事实上计算机中浮点数的存储和整数的存储是不同的,针对浮点数的存储相对比较复杂。

计算机如何存储浮点数

  • 计算机中的所有数据都是以二进制存储的,将十进制的整数转化成二进制可以通过 整除 2 取余数,直到余数为0 的办法得到对应的二进制整数。而对于浮点数来说,我们需要通过乘以 2 向下取整,直到乘积等于整数 1 的方式得到对应的二进制浮点数。以 0.25 为例:
    • 0.25 × 2 = 0.5 0.25 \times 2 = 0.5 0.25×2=0.5 向下取整得到 0
    • 0.5 × 2 = 1 0.5 \times 2 = 1 0.5×2=1 向下取整得到 1
  • 那么 0.25 对应的二进制小数为 0.01
IEEE 754 浮点数表示法
  • 虽然可以将十进制小数转化成二进制小数,但是这个小数点貌似没有办法用我们之前的通用寄存器来存储。而且如果一个浮点数包含整数部分又该怎么办。

    以下内容摘自 CSAPP

    ​ 直到20世纪80年代,每个计算机制造商都设计了自己的表示浮点数的规则,以及对浮点数执行运算的细节。另外,他们常常不会太多地关注运算的精确性,而把实现的速度和简便性看得比数字精确性更重要。
    ​ 大约在1985年,这些情况随着IEEE标准754的推出而改变了,这是一个仔细制订的表示浮点数及其运算的标准。这项工作是从1976年Intel发起8087的设计开始的,8087是一种为8086处理器提供浮点支持的芯片。他们雇佣了William Kahan,加州大学伯克利分校的一位教授,作为帮助设计未来处理器浮点标准的顾问。他们支持Kahan加入一个IEEE资助的制订上业标准的委员会。这个委员会最终采纳了一个非常接近于Kahan为Intel设计的标准。**目前,实际上所有的计算机都支持这个后来被称为IEEE浮点的标准。**这大大改善了科学应用程序在不同机器上的可移植性。

    • IEEE 浮点表示法以 V = ( − 1 ) s × M × 2 E V = (-1)^s \times M \times 2^E V=(1)s×M×2E 来表示浮点数,类似我们常见的科学计数法(十进制)

      • s s s :表示符号位,和整数一样,正数的符号位为 0 ,负数的符号位为 1

      • M M M :是一个二进制小数,大于等于 1 且小于 2 或者大于等于 0 且小于 1 ,类似科学计数法的尾数。一般会隐藏掉 1 只保存剩下的纯小数部分。

      • E E E :表示指数,而事实上在 IEEE 754 中并没有直接存储指数,而是引入了一个阶码的概念:

        • 阶码 e e e :就是实际上在内存中存储的表示指数的部分
        • 偏置 B i a s Bias Bias :定义为 $2^{k-1} - 1 $ ,其中 k k k 为正整数,表示阶码所占长度
          • **半精度(16位)浮点数阶码长度为 5 **
          • 单精度(32位)浮点数阶码长度为 8
          • 双精度(64位)浮点数阶码长度为 11
        • 这样对应的指数 E E E 可以表示为 : E = e − B i a s = e − 2 k − 1 + 1 E = e - Bias = e - 2^{k-1}+1 E=eBias=e2k1+1

        IEEE754

        • 至于为什么要使用阶码可以参考 CSAPP 69 页的内容。
  • OK 接下来看看在汇编代码中浮点数是如何存储的(由于 Compiler Explorer 无法查看只读数据,因此我们下面通过 GCC 的 -S 选项生成包含数据段标签的汇编代码。)

    1. 首先编写下面的代码(main.c

      int main(){
          float a = 0.5;
          return 0;
      }
      
    2. 使用以下命令编译处汇编代码

      gcc main.c -S -masm=intel -o main.s
      
    3. 获得以下汇编代码

              .file   "main.c"
              .intel_syntax noprefix
              .text
              .globl  main
              .type   main, @function
      main:
      .LFB0:
              .cfi_startproc
              endbr64
              push    rbp
              .cfi_def_cfa_offset 16
              .cfi_offset 6, -16
              mov     rbp, rsp
              .cfi_def_cfa_register 6
              movss   xmm0, DWORD PTR .LC0[rip]			; float a = 0.5;
              movss   DWORD PTR -4[rbp], xmm0
              mov     eax, 0
              pop     rbp
              .cfi_def_cfa 7, 8
              ret
              .cfi_endproc
      .LFE0:
              .size   main, .-main
              .section        .rodata
              .align 4
      .LC0:
              .long   1056964608
              .ident  "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
              .section        .note.GNU-stack,"",@progbits
              .section        .note.gnu.property,"a"
              .align 8
              .long   1f - 0f
              .long   4f - 1f
              .long   5
      0:
              .string "GNU"
      1:
              .align 8
              .long   0xc0000002
              .long   3f - 2f
      2:
              .long   0x3
      3:
              .align 8
      4:
      
    4. 观察第 15 行,可以看到浮点数不像整数赋值那样,可以直接通过数字写入内存完成赋值。浮点数赋值需要先从某个地方将浮点数值读出来:

      movss   xmm0, DWORD PTR .LC0[rip]			; float a = 0.5;
      
      • xmm0 :属于 xmm 系列寄存器,是 128 位的寄存器
      • .LC0 :这个属于汇编的伪代码,表示一个内存地址,其中 DWORD PTR .LC0[rip] 也是我们第一期所讲的 寄存器相对寻址 的一种, 和 DWORD PTR [rip+.LC0] 含义一样,编译器会在后面将汇编转化为二进制代码的时候计算 .LC0 对应的地址
    5. 可以看到 15 行读取浮点数的内存位置处于标签 .LC0 处,我们来看 .LC0 对应地址指向的是什么内容:

      .LC0:
              .long   1056964608
      
      • 你可能会一头雾水 1056964608 是什么。还记得我们之前说的 IEEE 浮点数表示法吗。我们先试着将需要赋值的浮点数 0.5 转化成 IEEE 表示法(可以用这个网站来在线计算 0.5 的IEEE 表示法 IEEE 754 浮点数 - 在线工具 (toolhelper.cn)
      Decimal (exact)  0.5
      Binary           0 01111110 00000000000000000000000
      Hexadecimal      3F000000
      
      • 二进制表示下 0 01111110 00000000000000000000000 对应的符号位是 0 表示对应的小数为正数 s = 0 s = 0 s=0
      • 第二部分 01111110 是阶码对应的十进制为 126 ,并且存储的是单精度的小数,阶码长度 k = 8 k = 8 k=8 按照定义可以得出指数 E = e − B i a s = e − 2 k − 1 + 1 E = e - Bias = e - 2^{k-1}+1 E=eBias=e2k1+1
        • E = 126 − 2 8 − 1 + 1 = 126 − 128 + 1 = − 1 E = 126 - 2^{8-1}+1 = 126 - 128 + 1 = -1 E=126281+1=126128+1=1
      • 第三部分表示尾数部分,并且有一个隐含的整数 1 ,故加上这个隐含的 1 可以读出位数部分 M = 1.0 M = 1.0 M=1.0
      • 最后按照公式 V = ( − 1 ) s × M × 2 E V = (-1)^s \times M \times 2^E V=(1)s×M×2E 可以得出 V = ( − 1 ) 0 × 1.0 × 2 − 1 = 0.5 V = (-1)^0 \times 1.0 \times 2^{-1} = 0.5 V=(1)0×1.0×21=0.5 ,我们成功验证了IEEE 浮点数表示法
      • 所以这和标签 .LC0 对应的 1056964608 有什么关系?你可以打开Windows 自带的计算器,将这个值转化成十六进制。发现了吗,得出来的结果和IEEE 754 浮点数在线工具生成的十六进制数一模一样。
    6. 好的现在你应该对对浮点数如何在计算机中表示,以及如何存储到内存中有了比较清晰的认识了吧。

有关浮点数运算的汇编指令

  1. 打开 Compiler Explorer 输入以下代码

    void func(){
        float a = 0.5;
        float b = 0.5;
        float c = a + b;
        c = a - b;
        c = a * b;
        c = a / b;
    
        double d = 0.5;
        double e = 0.5;
        double f = d + e;
        f = d - e;
        f = d * e;
        f = d / e;
        
        f = e++;
        f = ++e;
    }
    
    int main(){
        func();
        return 0;
    }
    
  2. 查看对应的汇编(只看 func 部分)

    func():
     push   rbp
     mov    rbp,rsp
     movss  xmm0,DWORD PTR [rip+0x0]        # c <func()+0xc>    ; float a = 0.5;
        R_X86_64_PC32 .rodata-0x4
     movss  DWORD PTR [rbp-0x4],xmm0
     
     movss  xmm0,DWORD PTR [rip+0x0]        # 19 <func()+0x19>	; float b = 0.5;
        R_X86_64_PC32 .rodata-0x4
     movss  DWORD PTR [rbp-0x8],xmm0
     
     movss  xmm0,DWORD PTR [rbp-0x4]		; float c = a + b;
     addss  xmm0,DWORD PTR [rbp-0x8]
     movss  DWORD PTR [rbp-0xc],xmm0		
     
     movss  xmm0,DWORD PTR [rbp-0x4]		; c = a - b;
     subss  xmm0,DWORD PTR [rbp-0x8]
     movss  DWORD PTR [rbp-0xc],xmm0
     
     movss  xmm0,DWORD PTR [rbp-0x4]		; c = a * b;
     mulss  xmm0,DWORD PTR [rbp-0x8]
     movss  DWORD PTR [rbp-0xc],xmm0
     
     movss  xmm0,DWORD PTR [rbp-0x4]		; c = a / b;	
     divss  xmm0,DWORD PTR [rbp-0x8]
     movss  DWORD PTR [rbp-0xc],xmm0
     
     movsd  xmm0,QWORD PTR [rip+0x0]        # 62 <func()+0x62> ; double d = 0.5;
        R_X86_64_PC32 .rodata+0x4			; .rodata+0x4 对应的是 0.5 的IEEE 表示
     movsd  QWORD PTR [rbp-0x18],xmm0
     
     movsd  xmm0,QWORD PTR [rip+0x0]        # 6f <func()+0x6f> ; double e = 0.5;
        R_X86_64_PC32 .rodata+0x4
     movsd  QWORD PTR [rbp-0x20],xmm0
     
     movsd  xmm0,QWORD PTR [rbp-0x18]		; double f = d + e;
     addsd  xmm0,QWORD PTR [rbp-0x20]
     movsd  QWORD PTR [rbp-0x28],xmm0
     
     movsd  xmm0,QWORD PTR [rbp-0x18]		; f = d - e;
     subsd  xmm0,QWORD PTR [rbp-0x20]
     movsd  QWORD PTR [rbp-0x28],xmm0
     
     movsd  xmm0,QWORD PTR [rbp-0x18]		; f = d * e;
     mulsd  xmm0,QWORD PTR [rbp-0x20]
     movsd  QWORD PTR [rbp-0x28],xmm0
     
     movsd  xmm0,QWORD PTR [rbp-0x18]		; f = d / e;
     divsd  xmm0,QWORD PTR [rbp-0x20]
     movsd  QWORD PTR [rbp-0x28],xmm0
     
     movsd  xmm0,QWORD PTR [rbp-0x20]		; f = e++;
     movsd  xmm1,QWORD PTR [rip+0x0]        # bd <func()+0xbd>
        R_X86_64_PC32 .rodata+0xc
     addsd  xmm1,xmm0
     movsd  QWORD PTR [rbp-0x20],xmm1		; xmm1 对应加 1 后的值,最后保存到 e 中
     movsd  QWORD PTR [rbp-0x28],xmm0		; xmm0 对应加 1 之前的值,最后保存到 f 中
     
     movsd  xmm1,QWORD PTR [rbp-0x20]		; f = ++e;
     movsd  xmm0,QWORD PTR [rip+0x0]        # d8 <func()+0xd8>
        R_X86_64_PC32 .rodata+0xc			; .rodata+0xc 对应的是 1.0 的IEEE 表示
     addsd  xmm0,xmm1
     movsd  QWORD PTR [rbp-0x20],xmm0		; xmm0 对应加 1 后的值,保存到 e 中
     movsd  xmm0,QWORD PTR [rbp-0x20]		; 从 e 中将数字导出给 xmm0,这里很明显编译器没有选择更好的做法
     movsd  QWORD PTR [rbp-0x28],xmm0		; 再将xmm0 写入变量 f
     
     nop
     pop    rbp
     ret
    
  3. 可以看到不同于整数运算,这里单独引入了一套指令来处理浮点数运算

    • 单精度浮点数

      • movss
      • addss
      • subss
      • divss
    • 双精度浮点数

      • movsd

      • addsd

      • subsd

      • divsd

  4. 由于浮点数是没有取余运算,所以浮点数的除法中不需要使用两个寄存器来分别存储商和余数,也不需要对符号位进行扩展。

  5. 另外再看浮点数有关的自增运算:

     movsd  xmm0,QWORD PTR [rbp-0x20]		; f = e++;
     movsd  xmm1,QWORD PTR [rip+0x0]        # bd <func()+0xbd>
        R_X86_64_PC32 .rodata+0xc
     addsd  xmm1,xmm0
     movsd  QWORD PTR [rbp-0x20],xmm1		; xmm1 对应加 1 后的值,最后保存到 e 中
     movsd  QWORD PTR [rbp-0x28],xmm0		; xmm0 对应加 1 之前的值,最后保存到 f 中
     
     movsd  xmm1,QWORD PTR [rbp-0x20]		; f = ++e;
     movsd  xmm0,QWORD PTR [rip+0x0]        # d8 <func()+0xd8>
        R_X86_64_PC32 .rodata+0xc			; .rodata+0xc 对应的是 1.0 的IEEE 表示
     addsd  xmm0,xmm1
     movsd  QWORD PTR [rbp-0x20],xmm0		; xmm0 对应加 1 后的值,保存到 e 中
     movsd  xmm0,QWORD PTR [rbp-0x20]		; 从 e 中将数字导出给 xmm0,这里很明显编译器没有选择更好的做法
     movsd  QWORD PTR [rbp-0x28],xmm0		; 再将xmm0 写入变量 f
    
    • 由于没有相关的替换指令,所以也不存在优化的问题。不过反直觉的是浮点数的 ++e 相比 e++ 多出来了一条指令,不过仔细观察这条指令实际上没有必要存在 (如果你觉得可能有影响,可以在结尾加上一句输出,然后通过 gcc 生成汇编代码后删除13 行对应的汇编,然后比较删除和没删除的输出结果)。至于原因,可能与编译器内部的算法有关,由于篇幅原因这里就不做过多的研究了,知道原因的同学欢迎在评论区分享一下。

浮点数运算可能带来的问题

精度!

  • 首先来看下面的这个例子,你觉的这个程序会输出什么:

    #include <iostream>
    using namespace std;
    int main(){
        double a = 0.1;
        double b = 0.2;
        cout<<std::boolalpha <<(a+b == 0.3)<<endl;
    
        return 0;
    }
    

浮点数问题

  • 你应该或多或少听说过这个例子,浮点数 0.1 + 0.2 != 0.3 确实让人费解。但是别忘了,计算机可看不懂十进制加法,它只能进行二进制加法,那么可能是在进制转换之间出现了问题。

  • 我前面提到,十进制小数转二进制小数的方法是 乘以 2 向下取整,直到乘积等于整数 1

    • 先将 0.1 转化成二进制小数:

      0.1 * 2 = 0.2 --> 0
      0.2 * 2 = 0.4 --> 0
      0.4 * 2 = 0.8 --> 0
      0.8 * 2 = 1.6 --> 1
      0.6 * 2 = 1.2 --> 1
      0.2 * 2 = 0.4 --> 0
      .... 
      
      • 最终的结果是 0.0 0011 0011 .... 是一个无限循环小数
    • 再将 0.2 转化成二进制小数:

      0.2 * 2 = 0.4 --> 0
      0.2 * 2 = 0.8 --> 0
      0.8 * 2 = 1.6 --> 1
      0.6 * 2 = 1.2 --> 1
      0.2 * 2 = 0.4 --> 0
      ...
      
      • 最终的结果是 0.0011 0011 .... 也是一个无限循环小数
    • 然后我们基于二进制进行加法运算,由于它们都是无限循环小数,我们取小数点后 8 位进行运算

      0.00011001
      0.00110011
      0.01001100
      
    • 2 − 2 + 2 − 5 + 2 − 6 = 0.296875 2^{-2}+2^{-5}+2^{-6} = 0.296875 22+25+26=0.296875 虽然我们的精度比不上计算机中实际存储的浮点数,但是这已经足够说明问题了:一些十进制小数转化成二进制小数后是无限循环的,而计算机注定无法存储下这所有的小数位,这将导致计算机会丢失一些精度。

  • 32
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

学艺不精的Антон

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值