深入理解计算机操作系统(四)

阅读经典——《深入理解计算机系统》03

  1. 复合型类型转换的内在原理
  2. 局部变量一定进内存?
  3. 奇葩的加载有效地址指令leal
  4. if...else和三元运算符

复合型类型转换的内在原理

上一篇文章的最后,我们讲解了复合型类型转换,比如从shortunsigned相当于分两步,先从short转换到int,再从int转换到unsigned。也就是说,先扩大范围,再改变符号性质。而实际上呢,在机器级程序中这个转换真的分两步吗?

答案是,只需要一步,下面的指令就足以完成从shortunsigned的转换。假设转换前变量存储在寄存器%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进制数。

如果思考得更加深入,也许会提出另一个问题:我觉得复合型类型转换的另一条路径——从shortunsigned 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 .L2jmp .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语言程序员来说,写哪个都一样。

哈哈,看到这里是不是有点失望了?虽然理解了这么多实现原理却并没有什么用。不过理解程序的本质总是好的,万一哪天编译器耍小脾气不给我们优化了,我们还可以自己动手搞一套,艺多不压身嘛。

参考资料



作者:金戈大王
链接:https://www.jianshu.com/p/eaa85130b8e4
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。
posted @ 2018-07-23 14:45  小时候挺菜  阅读(817)  评论(0编辑  收藏  举报