阅读经典——《深入理解计算机系统》03
- 复合型类型转换的内在原理
- 局部变量一定进内存?
- 奇葩的加载有效地址指令leal
- if...else和三元运算符
复合型类型转换的内在原理
上一篇文章的最后,我们讲解了复合型类型转换,比如从short
到unsigned
相当于分两步,先从short
转换到int
,再从int
转换到unsigned
。也就是说,先扩大范围,再改变符号性质。而实际上呢,在机器级程序中这个转换真的分两步吗?
答案是,只需要一步,下面的指令就足以完成从short
到unsigned
的转换。假设转换前变量存储在寄存器%ax
中,转换后的结果存储在%edx
指向的内存地址中。(书中采用AT&T格式的汇编语言,与Intel格式的区别见参考资料。)
movswl %ax, (%edx)
这是一个简单的移动指令,与最普通的mov
相比,后缀wl
表示源操作数(即左操作数)长度为w(即word,一个字),目标操作数(即右操作数)长度为l(即long,双字),s
表示从w扩展到l高位全部用符号位补充。仍然用上次的例子,源操作数为short s = -12345
,用二进制表示为
1100 1111 1100 0111
按照movswl
指令的规则将其扩展为4个字节32位
1111 1111 1111 1111 1100 1111 1100 0111
现在按照unsigned
类型将该数翻译为10进制,结果为4294954951
,完全正确。
可见,在机器级代码中很容易实现复合型类型转换,只需要一次移动操作。但是,问题的实质是什么?
寄存器或内存中的数据只有长度之分, 并没有符号类型之分。也就是说,从short
转换到int
其实仍然是上面的汇编代码,只不过同样的结果被识别为int
类型后会被翻译为不同的10进制数。
如果思考得更加深入,也许会提出另一个问题:我觉得复合型类型转换的另一条路径——从short
到unsigned short
再到unsigned
更加合理,有没有指令可以实现这种方式的复合型类型转换?
毋庸置疑,编译器的实现方式应该是和指令集无关的,所以如果想要改动编译器使其默认选择另一种转换路径的话,一定是可以实现的。就像下面这样
movzwl %ax, (%edx)
唯一的不同就是后缀的s
变成了z
,之前是按照符号位扩展,而现在是纯粹用0扩展。对于short s = -12345
1100 1111 1100 0111
执行上面的指令后,结果为
0000 0000 0000 0000 1100 1111 1100 0111
再按照unsigned
将这个二进制数翻译为10进制,就是53191
,对应了上一章的结果。
因此,两种路径都很容易实现,而编译器选择了最合乎情理的那一条。
局部变量一定进内存?
通常,局部变量会在运行时进入栈,我们在第一章中已经介绍过进程的虚拟地址空间的分配。
但是,如果函数非常短,以致于某些局部变量可以一直保存在寄存器中,以获得更短的执行时间,这时候,就不一定需要进内存了。
比如交换函数的代码
int exchange(int *xp, int y)
{
int x = *xp;
*xp = y;
return x;
}
编译为汇编代码如下
#xp at %ebp+8, y at %ebp+12
movl 8(%ebp), %edx #Get xp
#by copying to %eax below, x becomes the return value
movl (%edx), %eax #Get x at xp
movl 12(%ebp), %ecx #Get y
movl %ecx, (%edx) #Store y at xp
程序并没有为局部变量x
申请内存,而是一直保存在寄存器%eax
中,而且%eax
寄存器的值默认作为函数的返回值,程序效率很高。
奇葩的加载有效地址指令leal
IA32指令集(详细介绍见参考资料)中有这样一条加载有效地址指令leal
,用法为leal S, D
,效果是将S的地址存入D。可是这条指令往往用在计算乘法上,比如下面的例子
leal (%eax, %eax, 2), %eax
实现的功能相当于%eax = %eax * 3
。括号中是一种比例变址寻址,将第一个数加上第二个数和第三个数的乘积作为地址寻址,leal
的效果使源操作数正好是寻址得到的地址,然后将其赋值给%eax
寄存器。为什么用这种方式算乘法,而不是用乘法指令imul
呢?
这是因为Intel处理器有一个专门的地址运算单元,使得leal的执行不必经过ALU,而且只需要单个时钟周期。相比于imul
来说要快得多。因此,对于大部分乘数为小常数的情况,编译器都会使用leal
完成乘法操作。
if...else和三元运算符
这是一个有趣的话题,我们常常会问,if...else和三元运算符相比,哪个性能更好?
这就涉及到汇编层面的两个术语:条件跳转和条件转移。以一个计算两数差的绝对值函数为例:
int absdiff(int x, int y)
{
if (x < y)
return y-x;
else
return x-y;
}
编译后的汇编代码如下:
# x at %ebp+8, y at %ebp+12
movl 8(%ebp), %edx #Get x
movl 12(%ebp), %eax #Get y
cmpl %eax, %edx #Compare x:y
jge .L2 #if >= goto x_ge_y
subl %edx, %eax #Compute result = x-y
jmp .L3 #Goto done
.L2 #x_ge_y:
subl %eax, %edx #Compute result = x-y
movl %edx, %eax #Set result as return value
.L3: #done: Begin completion code
代码中做了两次条件跳转,分别是jge .L2
和jmp .L3
,当满足条件时跳转到指定的代码位置。
再来看三目运算符的代码:
int absdiff(int x, int y)
{
return x < y ? y-x : x-y;
}
编译后的汇编代码如下:
# x at %ebp+8, y at %ebp+12
movl 8(%ebp), %ecx #Get x
movl 12(%ebp), %edx #Get y
movl %edx, %ebx #Copy y
subl %ecx, %ebx #Compute y-x
movl %ecx, %eax #Copy x
subl %edx, %eax #Compute x-y and set as return value
cmpl %edx, %ecx #Compare x:y
cmovl %ebx, %eax #If <, replace return value with y-x
我们发现跳转指令没了,取而代之的是最后一条cmovl
指令,这就是我们所说的条件转移指令,后缀l
表示Less,当前一条指令cmpl
的结果为“小于”时,将%ebx
赋值给%eax
。
两段代码的区别:前者先根据条件跳转,跳转后再执行各个分支中的指令;而后者先把两个分支全部计算出结果,然后用一个条件转移指令给出结果。至于哪种方式性能更好,现在还不能下结论,我们需要了解一个处理器技术——分支预测。
现代处理器往往采用多级流水线技术同时操作多条指令,当预取指令遇到分支的时候,如果等待实际选择结果,将会白白浪费多个时钟周期。所以处理器会采用分支预测技术猜测程序将会选择哪个分支,然后令该分支的指令进入流水线。现代处理器一般能达到90%以上的预测成功率,因此,分支预测可以大大提高指令执行效率。但是,一旦预测失败,需要撤销已经执行的操作,这往往会额外消耗掉20~40个时钟周期的时间,导致程序执行效率下降。
回到我们之前的程序,采用指令跳转方式时就需要考虑分支预测带来的影响。如果该函数被调用多次而且参数值毫无规律,将会给分支预测带来巨大的困难,预测成功率很低,导致程序性能下降。在这种情况下,采用指令转移方式会好得多。
但是,指令转移并不是万能的,如果两个分支中执行了非常耗时的操作,比如调用了另一个复杂的函数。那么,由于指令转移方式需要预先执行完每个分支的操作,用时会增加一倍,性能反而不如指令跳转方式。因此,采用哪种方式需要考虑实际情况。
最后,回答刚开始提出的问题,if...else和三目运算符哪个性能更好?
请不要被上面举的两个例子误导,其实它们是完全一样的。考虑到编译器的优化(具体的编译器优化选项见参考资料),无论是if...else语句还是三目运算符,生成的汇编代码很可能是一致的。编译器会考虑前面提到的因素,自动选择采用指令跳转方式还是指令转移方式实现。所以,对于C语言程序员来说,写哪个都一样。
哈哈,看到这里是不是有点失望了?虽然理解了这么多实现原理却并没有什么用。不过理解程序的本质总是好的,万一哪天编译器耍小脾气不给我们优化了,我们还可以自己动手搞一套,艺多不压身嘛。
参考资料
- AT&T汇编格式与Intel汇编格式的比较 中道学友
- x86, x86-64, i386, IA32, IA64 区别 Jerry_Chen_FTD
- gcc- -O 优化选项 misiter
- 浅谈分支预测、流水线与条件转移 yangecnu