CSAPP–第三章-程序的机器级表示(下下)

Aki 发布于 2022-10-29 311 次阅读


【CSAPP-深入理解计算机系统】3-7. 过程(函数调用)_哔哩哔哩_bilibili

【CSAPP-深入理解计算机系统】3-6.跳转指令与循环_哔哩哔哩_bilibili

跳转指令、

接下来看下跳转指令相关代码及汇编指令:

long absdiff_se(long x, long y)
{
    long result;
    if (x < y)
    {
        result = y - x;
    }
    else
    {
        result = x - y;
    }
    return result;
}

汇编后得到下面指令:

_Z10absdiff_sell:
    cmpq    %rsi, %rdi
    jge     .L2
    movq    %rsi, %rax
    subq    %rdi, %rax
    ret
.L2:
    movq    %rdi, %rax
    subq    %rsi, %rax
    ret

通过cmp指令首先设置x<y对应的标志寄存器的符号标志(SF)和溢出标志(OF),然后跳转指令进行相应的位运算来判断其布尔值的真假,以此来判断是否发生跳转至.L2处,位运算计算方法如下图:

只不过,相比于代码中的x < y,汇编指令通过jge判断x是否大于等于y,ge是greater >和equal =的缩写。对于代码中的if-else语句,当满足条件时,程序洽着一条执行路径执行,当不满足条件时,就走另外一条路径。这种机制比较简单和通用,但是在现代处理器上,它的执行效率可能会比较低。针对这种情况,有一种替代的策略,就是使用数据的条件转移来代替控制的条件转移。还是针对两个数差的绝对值问题,给出了另外一种实现方式,我们既要计算y-x的值,也要计算x-y的值,分别用两个变量来记录结果,然后再判断x与y的大小,根据测试情况来判断是否更新返回值。这两种写法看上去差别不大,但第二种效率更高。具体如下所示:

上图c前面这几条指令都是普通的数据传送和减法操作。cmovge是根据条件码的某种组合来进行有条件的传送数据,当满足规定的条件时,将寄存器rdx内的数据复制到寄存器rax内。在这个例子中,只有当x大于等于y时,才会执行这一条指令。

更多指令如下所示:

为什么基于条件传送的代码会比基于跳转指令的代码效率高呢?这里涉及到现代处理器通过流水线来获得高性能。当遇到条件跳转时,处理器会根据分支预测器来猜测每条跳转指令是否执行,当发生错误预测时,会浪费大量的时间,导致程序性能严重下降。

循环、

汇编指令中并没有专门用于循环的指令,循环的实现是通过跳转和条件判断实现的。

对比一下forwhile的汇编代码:

可以发现除了这一句跳转指令不同,其他部分都是一致的。这两个汇编代码是采用-Og选项产生的。综上所述,三种形式的循环语句都是通过条件测试和跳转指令来实现。以上则是三种循环的示例说明。

swich语句、

对于上面的代码,汇编代码如下:

cmpq指令设置状态寄存器,ja指令判断n是否超过6,超过的话跳转至default对应的L8程序段,case0、case6可通过跳转表访问不同分支。代码跳转表声明为一个长度为7的数组,每个元素都是一个指向代码位置的指针,具体关系如下图所示:

在这个例子中,程序使用跳转表来处理多重分支,甚至当switch有上百种情况时,虽然跳转表的长度会增加,但是程序的执行只需要一次跳转也能处理复杂分支的情况,与使用一组很长的if-else相比,使用跳转表的优点是执行switch语句的时间与case的数量是无关的。因此在处理多重分支的时,与一组很长的if-else相比,switch的执行效率要高。

程序调用过程相关知识、

为了方便讨论,以C语言代码函数调用为例,假设函数P调用函数Q,函数Q执行完毕后返回函数P,这一系列操作包括图中一个或多个机制:

C语言过程调用机制的关键特性在于使用栈数据结构提供FIFO内存管理原则,在过程P调用过程Q的例子中,可以看到当Q在执行时,P以及所有在向上追溯到P的调用链中的过程都被暂时挂起。当Q运行时,它只需要为局部变量分配新的存储空间,或设置到另一个过程的调用。另一方面,当Q返回时,任何它所分配的局部存储空间都可被释放。因此,程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。当P调用Q时,控制和数据信息添加到栈尾。当P返回时,这些信息会被释放掉。

函数P调用函数Q时,会把返回地址压入栈中,该地址指明了当函数Q执行结束 返回时要从函数P的哪个位置继续执行。这个返回地址的压栈操作并不是由指令push来执行的,而是由函数调用call来实现的。具体以multstore代码为例我们可以查看返回地址细节:

编译并使用命令objdump进行反汇编,查看其具体调用情况:

上图可知中4005a9: e8 26 00 00 00 callq 4005d4 <multstore>这一行,指令call不仅要将函数multstore的第一条指令的地址写入到程序指令寄存器rip中,以此实现函数调用,同时还要将返回地址压入栈中。

当函数multstore调用完毕后,指令ret从栈中返回地址弹出,写入程序指令寄存器rip中:

函数返回,继续执行上面反汇编代码中main函数第7行相关的操作。以上整个过程就是函数调用与返回所涉及的操作。那么函数调用的参数传递是如何实现的呢?在一开始我们知道,函数传递参数分别通过6个寄存器可以实现,但是如果传递的参数大于6个呢?超出的参数就会通过压栈来实现存储。

代码中函数有8个参数,包括字节数不同的整数以及不同类型的指针,参数1到参数6是通过寄存器来传递,参数7和参数8是通过栈来传递。

这里补充说明:
通过栈来传递参数时,所有数据的大小都是向8的倍数对齐,虽然变量a4只占一个字节,但是仍然为其分配了8个字节的存储空间。由于返回地址占用了栈顶的位置,所以这两个参数距离栈顶指针的距离分别为816

栈局部存储

当代码中对一个局部变量使用地址运算符时,我们需要在栈上为这个局部变量开辟 相应的存储空间,接下来我们看一个与地址运算符相关的例子。

函数caller定义了两个局部变量arg1arg2,函数swap的功能是交换这两个变量的值,最后返回二者之和。我们通过分析函数caller的汇编代码来看一下地址运算符的处理方式:

subq $16, %rsp第一条减法指令将栈顶指针减去16,它表示的含义是在栈上分配16个字节的空间,以此来保存两个局部变量的值。

我们再来看一个较为复杂的栈上存放局部变量的例子,代码如下:

根据上面的C代码,我们来画一下这个函数的栈帧。根据变量的类型可知x18个字节,x24个字节,x3占两个字节,x4占一个字节,因此,这四个变量在栈帧中的空间分配如图所示。

由于上面call_proc代码中第6行调用的函数proc需要8个参数,前六个参数保存在寄存器上,因此参数7和参数8需要通过栈帧来传递。注意,传递的参数需要8个字节对齐,而局部变量是不需要对齐的。