前言
- 在第一期中我简单介绍了一下各种工具的使用,这一期我们将通过实践,亲自上手揭示C++中算数运算符的神秘面纱。
算数运算
运算符 | 含义 |
---|---|
+ | 加法运算,对左右两边的操作数进行加法运算,并返回运算结果 |
- | 减法运算,对左右两边的操作数进行减法运算,并返回运算结果 |
* | 乘法运算,对左右两边的操作数进行乘法运算,并返回运算结果 |
/ | 除法运算,对左右两边的操作数进行除法运算,并返回运算结果 |
% | 除法运算,对左右两边的操作数进行除法运算,并返回运算结果 |
i++ | 自增运算,让变量 i 自增 1 ,并返回自增前的结果 |
i– | 自减运算,让变量 i 自减 1,并返回自减前的结果 |
++i | 自增运算,让变量 i 自增 1,并返回自增后的结果 |
–i | 自减运算,让变量 i 自减 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; }
-
右边生成了如下的汇编代码(只用看
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
-
先来看看汇编的第 7 行:
mov DWORD PTR [rbp-0x8],0x2 ; int b = 1+1;
- 有没有发现,编译器生成的汇编指令并没有生成加法指令来计算两个整数字面量的加法,说明编译器对此是有优化的。
-
接下来观察汇编的 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
选项可以生成程序的汇编文件,我们可以在此基础上修改对应的汇编文件,最后将它编译成二进制文件,看看内否编译通过,以及能否运行。
-
在 Linux 系统中编写下面的C程序(
main.c
)void func(){ int a = 0; int b = 2; a = a + b; } int main(){ func(); return 0; }
-
使用下面的命令生成对应的
intel
风格汇编文件main.s
gcc -g -S main.c -masm=intel -o main.s
-
生成的汇编文件相对较大,我只截取包含
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
-
将 24 、25 行的汇编代码改成下面的样子:
add DWORD PTR -8[rbp],DWORD PTR -4[rbp]
-
然后使用下面的命令将汇编代码编译成可执行文件:
gcc main.s -o main
- 出现了下面的报错:
-
通过修改汇编代码可以发现,这样的想法无法实现,至于为什么,我没有找到特别有说服力的答案,大多数说法是为了控制指令的长度。如果有朋友知道比较有说服力的答案欢迎在评论区分享。
-
乘法
-
将以下代码输入 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; }
-
生成的汇编代码如下(只需要看
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
-
首先来看汇编代码的第 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 优化等观点。有些观点有争议,客观参考。
- 其中有这是因为操作数的大小不同,也有说
-
-
接着看 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)
- 编译器实际上将
11
×
3
11 \times 3
11×3 优化成了
(
(
4
+
1
)
×
2
+
1
)
×
3
((4+1) \times 2 + 1) \times 3
((4+1)×2+1)×3 。编译器用位移运算和加法运算替换了乘法运算,为什么?
除法与模运算
- 除法和模运算实际上是一体的,对一个数进行除法运算,获得的商就是除法结果,获得的余数就是模运算结果。下面的例子也说明了这个问题,同时我们还将遇到通用寄存器
ax
和dx
的特殊用法
-
打开 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; }
-
生成了如下的汇编指令(只看
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
-
首先来验证我们开始时的想法:除法运算和模运算实际上是一体的 。分别观察
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
。没错,这里就是通用寄存器ax
和dx
的特殊用法:ax
:在除法运算开始之间存储被除数,并且在除法运算结束之后商会被存储在它里面dx
:可用于在除法运算结束之后存储余数,它还被用于存储被除数的符号位扩展
-
另外我们还能发现,在指令
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 00000000
和ax
=11101011 01100011
,则dx:ax
=00001100 00000000 11101011 01100011
)-
除数被除数,商和余数的存储形式:
被除数 除数 商 余数 AX reg/mem8 AL AH DX:AX reg/mem16 AX DX EDX:EAX reg/mem32 EAX EDX -
商和余数的大小不会超过被除数长度的一半,为了保证除法结束后的商和余数宽度仍然和原来存储被除数的寄存器一样,所以需要对被除数进行扩展。
-
有符号
指令 全称 说明 cbw convert byte to word 将AL的符号位扩展到AH cwd convert word to doubleword 将AX的符号位扩展到DX cdq convert 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);
-
在 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; }
-
我们只看
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
-
可以看到,如果我们不考虑自增运算符的返回值,那么它们二者生成的指令是完全一样的。如果考虑到返回值
i++
生成的指令要比++i
,不过它们实现自增的指令不一样,而且i++
生成的指令中由一个lea edx, [rax+0x1]
这个指令是专门用于做地址计算的,它表示将[rax+0x1]
中地址加法的结果赋值给edx
。与lea
和add
谁的速度更快,可以参考这篇帖子 https://stackoverflow.com/a/6328441/20203824。lea
要比add
更快,但是lea
不能直接写入内存,这就是为什么在不考虑返回值时i++
和++i
生成的指令都是使用add
而不是lea
。lea
更快但是int ipp = i++;
对应的汇编中要多一条mov
指令,因此还是必须通过编程+统计来判断谁更快。-
分别编写下面的程序
-
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; }
-
-
使用 gcc 命令编译程序
gcc -g valpp.c -o valpp gcc -g ppval.c -o ppval
-
使用
objdump
工具查看反汇编代码,并检查函数func
内有关自增运算的部分是否符合预期,保证编译器没有做一些奇怪的优化-
objdump -d ./valpp -M intel
- 可以看到符合预期,使用的是
lea
指令
- 可以看到符合预期,使用的是
-
objdump -d ./ppval -M intel
- 可以看到符合预期,使用的是
add
指令
- 可以看到符合预期,使用的是
-
-
使用
time
命令分别运行程序并计时。time ./valpp
time ./ppval
-
明显
++i
更快一些,分析可能是mov
指令的代价要比add
大
-
-
另外如果你还深入了解过汇编,应该还知道有一个指令
inc
(increasing)可以实现自增 1 。这里没有使用inc
而是使用add
的原因是add
虽然不总是比inc
快,但在大多数境况下它们效率基本近似,甚至有时add
更快。参考这篇帖子 https://stackoverflow.com/a/13383496/20203824ADD
is not always faster thanINC
, 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. -
所以当不考虑返回值
i++
与++i
,i--
与--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
×
2
=
0.5
0.25 \times 2 = 0.5
0.25×2=0.5 向下取整得到
- 那么
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=e−Bias=e−2k−1+1
- 至于为什么要使用阶码可以参考 CSAPP 69 页的内容。
-
-
-
OK 接下来看看在汇编代码中浮点数是如何存储的(由于 Compiler Explorer 无法查看只读数据,因此我们下面通过 GCC 的
-S
选项生成包含数据段标签的汇编代码。)-
首先编写下面的代码(
main.c
)int main(){ float a = 0.5; return 0; }
-
使用以下命令编译处汇编代码
gcc main.c -S -masm=intel -o main.s
-
获得以下汇编代码
.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:
-
观察第 15 行,可以看到浮点数不像整数赋值那样,可以直接通过数字写入内存完成赋值。浮点数赋值需要先从某个地方将浮点数值读出来:
movss xmm0, DWORD PTR .LC0[rip] ; float a = 0.5;
xmm0
:属于xmm
系列寄存器,是 128 位的寄存器.LC0
:这个属于汇编的伪代码,表示一个内存地址,其中DWORD PTR .LC0[rip]
也是我们第一期所讲的 寄存器相对寻址 的一种, 和DWORD PTR [rip+.LC0]
含义一样,编译器会在后面将汇编转化为二进制代码的时候计算.LC0
对应的地址
-
可以看到 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=e−Bias=e−2k−1+1- E = 126 − 2 8 − 1 + 1 = 126 − 128 + 1 = − 1 E = 126 - 2^{8-1}+1 = 126 - 128 + 1 = -1 E=126−28−1+1=126−128+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×2−1=0.5 ,我们成功验证了IEEE 浮点数表示法
- 所以这和标签
.LC0
对应的1056964608
有什么关系?你可以打开Windows 自带的计算器,将这个值转化成十六进制。发现了吗,得出来的结果和IEEE 754 浮点数在线工具生成的十六进制数一模一样。
- 你可能会一头雾水
-
好的现在你应该对对浮点数如何在计算机中表示,以及如何存储到内存中有了比较清晰的认识了吧。
-
有关浮点数运算的汇编指令
-
打开 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; }
-
查看对应的汇编(只看
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
-
可以看到不同于整数运算,这里单独引入了一套指令来处理浮点数运算
-
单精度浮点数
movss
addss
subss
divss
-
双精度浮点数
-
movsd
-
addsd
-
subsd
-
divsd
-
-
-
由于浮点数是没有取余运算,所以浮点数的除法中不需要使用两个寄存器来分别存储商和余数,也不需要对符号位进行扩展。
-
另外再看浮点数有关的自增运算:
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 2−2+2−5+2−6=0.296875 虽然我们的精度比不上计算机中实际存储的浮点数,但是这已经足够说明问题了:一些十进制小数转化成二进制小数后是无限循环的,而计算机注定无法存储下这所有的小数位,这将导致计算机会丢失一些精度。
-