古语“画虎画皮难画骨”,是说画老虎时要画它的外表很容易,可要将老虎的气势画出来却很难。
对于现在的程序员来说,似乎也是这样子,可以写出整洁的代码,设计出优异的程序,但却不一定需要知道代码在编译之后的是如何运行的。
但是有时候,能了解表面背后的故事,知道一些程序编译过程,其实对写出一个正确的正确的程序会有很大的帮助,这起源自一段很简单的问题实现:两整数交换。
简单的两数交换不简单
最早学习到的实现方式,就是使用临时变量来保存一个数进行交换:
int main() { int a = 21; int b = 7; int tmp = a; a = b; b = tmp; return 0; }
后来不经意间又学习到了不用临时变量,只用算数运算符加减就可以进行交换的方式:
int main() { int a = 21; int b = 7; a = a+b; b = a-b; a = a-b; return 0; }
这个问题在思想上讲十分的巧妙,不过在计算机的世界里这并不是一个好的方法,考虑一点编译后的问题,我们就会碰到溢出的问题,导致得到错误的结果。
高手们又使用位运算的异或运算来消除临时变量的方法,还不用担心溢出的问题,于是我又学习了:
int main() { int a = 21; int b = 7; a ^= b; b ^= a; a ^= b; return 0; }
甚至有更简短地把三个异或运算写成一行的:
int main() { int a = 21; int b = 7; a ^= b ^=a ^= b; return 0; }
就像我在《这些没有可读性的代码,却又体现出程序员对语言的高度理解力》一文里想表达的,这种写法体现了书写者对运算符顺序的深刻理解,对异或运算符特殊性的充分了解。
两数交换方法的编译后的指令
虎皮画得很精美,根根毛发都细致描绘,但是没有看过老虎骨骼和肌肉,可能就感受不到万兽之王从骨子里散发出的那种无畏的霸气。
没有看过编译后的代码,我才会总不能理解为何那么简短优美还“节省空间”的代码怎么就没有成为一种标准写法呢?好像只是作为偶然拿出来炫给初学者们,让他们惊讶和眼睛一亮的把戏。
在网上搜索了一下有关这方面的问题,在stackoverflow上看到了这样的一段回复:
Using this XOR might actually be slower than the usual two value swap using a temp variable; the fact that you are writing through references suggests you are forcing the compiler to do 3 memory (ok, cache line) writes, whereas temp-style swamp should only do two memory write (any intelligent compiler will put the temp in the registers). Adding the conditional check surely makes it slower. So while this might be fun, it probably isn't a practical thing to do in a sort.
从这句话看出,似乎看出算数法和异或法虽然节省了一个微不足道的空间,但是却会浪费时间。效率上还不及临时变量法,更不要提其中出现的如溢出等隐蔽的问题了。
在学习了一点gcc指令的入门之后,我使用了
Ider$ gcc -S swap.c
来观察了一下从C文件编译出来的汇编指令的代码,才发现:虽然在C种交换都只用了三行,但是编译后的汇编指令行数却不尽相同。
对比三种方式的main的汇编指令:
(每一个注释表示指令所对应的C代码,一行C代码可能对应多个汇编指令,从注释行开始计算,到下一个注释行之前那行都属于那行C代码对应的指令)
Temp
_main: Leh_func_begin1: pushq %rbp Ltmp0: movq %rsp, %rbp Ltmp1: movl $21, -12(%rbp) ;;int a = 21 movl $7, -16(%rbp) ;;int b = 7 movl -12(%rbp), %eax ;;int tmp = a movl %eax, -20(%rbp) movl -16(%rbp), %eax ;;a = b movl %eax, -12(%rbp) movl -20(%rbp), %eax ;;b = tmp movl %eax, -16(%rbp) movl $0, -8(%rbp) ;;return 0 movl -8(%rbp), %eax movl %eax, -4(%rbp) movl -4(%rbp), %eax popq %rbp ret Leh_func_end1:
Arithmetic
_main: Leh_func_begin1: pushq %rbp Ltmp0: movq %rsp, %rbp Ltmp1: movl $21, -12(%rbp) ;;int a = 21 movl $7, -16(%rbp) ;;int b = 7 movl -12(%rbp), %eax ;;a = a+b movl -16(%rbp), %ecx addl %ecx, %eax movl %eax, -12(%rbp) movl -12(%rbp), %eax ;;b = a-b movl -16(%rbp), %ecx subl %ecx, %eax movl %eax, -16(%rbp) movl -12(%rbp), %eax ;;a = a-b movl -16(%rbp), %ecx subl %ecx, %eax movl %eax, -12(%rbp) movl $0, -8(%rbp) ;;return 0 movl -8(%rbp), %eax movl %eax, -4(%rbp) movl -4(%rbp), %eax popq %rbp ret Leh_func_end1:
XOR
_main: Leh_func_begin1: pushq %rbp Ltmp0: movq %rsp, %rbp Ltmp1: movl $21, -12(%rbp) ;;int a = 21 movl $7, -16(%rbp) ;;int b = 7 movl -12(%rbp), %eax ;;a ^= b movl -16(%rbp), %ecx xorl %ecx, %eax movl %eax, -12(%rbp) movl -16(%rbp), %eax ;;b ^=a movl -12(%rbp), %ecx xorl %ecx, %eax movl %eax, -16(%rbp) movl -12(%rbp), %eax ;;a ^= b movl -16(%rbp), %ecx xorl %ecx, %eax movl %eax, -12(%rbp) movl $0, -8(%rbp) ;;return 0 movl -8(%rbp), %eax movl %eax, -4(%rbp) movl -4(%rbp), %eax popq %rbp ret Leh_func_end1:
(对于一行的异或运算交换法,得到汇编指令跟三行的是一样的。)
从汇编指令的行数,可以看出正如stackoverflow上讲的一样,利用临时变量的代码要明显短于其它两个。
对于临时变量法,每次赋值只要读取一个变量的值到寄存器,然后再从寄存器写回到另一个变量中即可。
但是对于运算操作,每次都需要读取两个数据到寄存器种,再进行运算操作。之后把结果写回到变量中。
如果这些指令被优化
其实如果编译后的汇编指令不是每次都把结果写回到变量,而是讲数据保存在寄存器中,把三次运算都执行完后再写回的吧,指令行数就会减少很多。以下就是那幻想出来的方式:
_main: Leh_func_begin1: pushq %rbp Ltmp0: movq %rsp, %rbp Ltmp1: movl $21, -12(%rbp) ;;int a = 21 movl $7, -16(%rbp) ;;int b = 7 movl -12(%rbp), %eax ;;a ^= b ^= a ^= b movl -16(%rbp), %ecx xorl %ecx, %eax xorl %eax, %ecx xorl %ecx, %eax movl %eax, -12(%rbp) movl %eax, -16(%rbp) movl $0, -8(%rbp) ;;return 0 movl -8(%rbp), %eax movl %eax, -4(%rbp) movl -4(%rbp), %eax popq %rbp ret Leh_func_end1:
如果指令像上边那样执行,我们就明显减少了指令(可惜,即使是如此优化,指令行数还是要比使用临时变量的方式要多一行)。
不过,编译器并没有这么做,因为这样做破坏了编译中很重要的一个概念:序列点。再者这样实现很麻烦,不止要编译当前行,还要预测之后多行可能要做的操作才能决定是否缓存该数据在寄存器中。
而且如果运算法可以优化,那临时变量法也可以优化成:
_main: Leh_func_begin1: pushq %rbp Ltmp0: movq %rsp, %rbp Ltmp1: movl $21, -12(%rbp) ;;int a = 21 movl $7, -16(%rbp) ;;int b = 7 movl -12(%rbp), %eax ;;int tmp = a, a = b, b = a movl -16(%rbp), %ecx movl %ecx, -12(%rbp) movl %eax, -16(%rbp) movl $0, -8(%rbp) ;;return 0 movl -8(%rbp), %eax movl %eax, -4(%rbp) movl -4(%rbp), %eax popq %rbp ret Leh_func_end1:
用到两个寄存器,读取数据进来,然后写回到对方的变量中。临时变量也省了。
这样看来,要想写出高效又省空间的代码还是要用汇编语言哦。不过,我相信这并不能成为让大家都学习和使用汇编的理由,毕竟现在很多时候追求的不是执行效率,还是开发效率。
说回那个很炫的一行实现的异或交换法。其实已经违背了序列点的要求:在每个序列点,只能对变量进行一次修改。只是凑巧,在C中编译后让程序的得到了正确的结果,但是不是所有编译器都能那么好运的。
另外,在实际中,我还碰到了另一个“不凑巧”的情况:当把它应用在数组中的两个元素时,会得到错误的答案:
#include <stdio.h> int main() { int a[] = {21, 7}; a[0] ^= a[1] ^= a[0] ^= a[1]; printf("%d, %d", a[0], a[1]); return 0; } //output:: //0, 21
还是把这段代码转成的汇编指令(不包括printf那行),看看能不能找出其中的缘由
_main: Leh_func_begin1: pushq %rbp Ltmp0: movq %rsp, %rbp Ltmp1: movl _C.0.1462(%rip), %eax movl %eax, -16(%rbp) movl _C.0.1462+4(%rip), %eax movl %eax, -12(%rbp) movl -16(%rbp), %eax movl -12(%rbp), %ecx movl -16(%rbp), %edx movl -12(%rbp), %esi xorl %esi, %edx movl %edx, -16(%rbp) movl -16(%rbp), %edx xorl %edx, %ecx movl %ecx, -12(%rbp) movl -12(%rbp), %ecx xorl %ecx, %eax movl %eax, -16(%rbp) movl $0, -8(%rbp) movl -8(%rbp), %eax movl %eax, -4(%rbp) movl -4(%rbp), %eax popq %rbp ret Leh_func_end1: .section __TEXT,__literal8,8byte_literals .align 2 _C.0.1462: .long 21 .long 7
可以看出,编译对数组做了完全不同的处理。最关键的是,它一共用了4个寄存器,把每次要用到的数组中的变量,都预先读到了每个寄存器中,然后再用寄存器中不匹配的值进行运算。
a[0] ^= a[1] ^= a[0] ^= a[1];
就相当于:
a[0] = 21^7; a[1] = a[0]^7 = (21^7)^21 = 21; a[0] = a[1]^21 = 0; //the last line is supposed to be a[0] = a[1]^a[0] = 21^(21^7);
所以以后还是不要用这些看似很炫的方式来做两数交换了,还是老老实实用个临时变量,或者比如在用C++的话直接调用标准库里的swap函数(临时变量实现)吧,这样也省了担心回有不能遇见的问题出现。
最后只感叹一句:
虎皮虽然精美,但是虎骨才是支撑起这张表皮的精髓;
编码虽然重要,但是编译才是让程序能够运行的重心。
测试环境
系统:Mac OS X 10.7.3
编译器: GCC 4.2.1 (Based on Apple Inc. build 5658)