【CSAPP笔记】第三章 程序的机器级表示(2)——分支、循环、函数

156 阅读8分钟

3.6 控制

3.6.1 条件码(Condition Code)

  • CPU维护一组条件码寄存器,描述最近的算数或逻辑操作的属性,用于条件分支指令
  • 图3-10中的整数运算,除了leaq外,都有可能设置条件码
    • 比如:移位操作,进位标志CF设置为最后一个被移出的位,溢出标志OF设置为0
标志说明
CF进位标志。最近的操作最高位产生进位。用于检查无符号操作的溢出
ZF零标志。最近的操作得出结果0
SF符号标志。最近的操作的到的结果为负数
OF溢出标志。最近的操作导致一个补码溢出(正溢出或负溢出)
  • 还有两类指令CMPTEST只改变条件码,而不改变其它寄存器
    • CMP:与SUB行为一样,两个操作数相减,但不影响目的寄存器。如果两个数相等,ZF=1;如果不等,用SF、OF判断两数大小
    • TEST:与AND行为一样,但不影响目的寄存器。

【说明】操作数A < 操作数B,为什么等价于SF^OF == 1

  • 假设两个操作数的值分别是a和b,t=a-b
    • case1:a=10,b=20,则a<b,且t<0。未发生溢出,此时SF=1,OF=0,SF^OF=1
    • case2:a=20,b=10,则a>b,且t>0。未发生溢出,此时SF=0,OF=0,SF^OF=0
    • case3:a=-2,b=127,则a<b,且t=127>0。发生正溢出,此时SF=0,OF=1,SF^OF=1
    • case4:a=1,b=-128,则a>b,且t=-127<0。发生负溢出,此时SF=1,OF=1,SF^OF=0
    • 综上所述,SF^OF==1代表a<b。其它a<=b可表示为(SF^OF)|ZF == 1 (图3-14)

3.6.2 访问条件码

  • 使用set指令读取
    • sete %rax表示把ZF标志赋给rax
    • setl %rax表示把SF异或OF的结果赋值给rax

image-20221202065017920.png

【例】书中例子

int compare(int a, int b) {
    return (a < b);
}

【编译】

//int compare(int a, int b)
//a in %edi, b in %esi
compare:
        cmpl    %esi, %edi        //a-b的结果存放到条件码寄存器中
        setl    %al               //结果赋值给al寄存器
        movzbl  %al, %eax         //零扩展
        ret

3.6.3 跳转指令

  • 分无条件跳转(JMP)和有条件跳转(如:JEJL
    • 无条件跳转:操作数是标号,或者间接跳转到某个程序计数器PC处
    • 有条件跳转:处理判断和set一样

image-20221202072846864.png

【例】书中例子

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

【编译】gcc -Og -S absdiff.c

//int absdiff(int x, int y)
//x in %edi, y in %esi
absdiff:
        cmpl    %esi, %edi        //Compare x:y
        jge     .L2               //If x>=y, goto .L2
        subl    %edi, %esi
        movslq  %esi, %rax        //result = y-x
        ret
.L2:
        subl    %esi, %edi
        movslq  %edi, %rax        //result = x-y
        ret

3.6.4 跳转指令的编码

  • 跳转指令在通过汇编器、链接器后,将跳转目标的相对地址转为绝对地址

【例】书中例子branch.c

long branch(long x) {
    long result = x;
    while (result > 0)
        result >>= 1;
    return result;
}

int main() {
    branch(1);
    return 0;
}

【编译】gcc -Og -S branch.c

//long branch(long x)
//x in %rdi
branch:
        movq    %rdi, %rax
        jmp     .L2
.L3:
        sarq    %rax
.L2:
        testq   %rax, %rax
        jg      .L3
        rep ret

【反汇编】gcc -Og -c branch.c; objdump -d branch.o

0000000000000000 <branch>:
   0:   48 89 f8                mov    %rdi,%rax
   3:   eb 03                   jmp    8 <branch+0x8>
   5:   48 d1 f8                sar    %rax
   8:   48 85 c0                test   %rax,%rax
   b:   7f f8                   jg     5 <branch+0x5>
   d:   f3 c3                   repz retq

【反汇编】gcc -Og -o branch branch.c; objdump -d branch

00000000004004ed <branch>:
  4004ed:       48 89 f8                mov    %rdi,%rax
  4004f0:       eb 03                   jmp    4004f5 <branch+0x8>
  4004f2:       48 d1 f8                sar    %rax
  4004f5:       48 85 c0                test   %rax,%rax
  4004f8:       7f f8                   jg     4004f2 <branch+0x5>
  4004fa:       f3 c3                   repz retq

【注】可以看到经过链接器后,jmp的相对位置变为绝对位置,其余不变(这对理解汇编有好处)

【备注】rep ret:当ret指令通过跳转指令到达时,根据AMD的说法,处理器不能正确预测ret指令的目的。所以增加rep空操作,使得代码在AMD上运行更快。无视即可。

3.6.6 用条件传送来实现条件分支

【例】用条件传送来实现条件分支,提升性能

【编译】还是absdiff例子,gcc -O2 -S absdiff.c

//int absdiff(int x, int y)
//x in %edi, y in %esi
absdiff:
        movl    %esi, %edx        //edx = y
        movl    %edi, %eax        //eax = x
        subl    %edi, %edx        //edx = y-x
        subl    %esi, %eax        //eax = x-y
        cmpl    %esi, %edi
        cmovl   %edx, %eax        //If x-y<0, eax=y-x
        ret

【说明】

  1. 现代处理器采用了分支预测逻辑猜测跳转指令是否会执行。如果猜测正确,会显著提高性能;猜测错误,则会招致很严重的惩罚。
  2. 使用条件控制转移语句cmovl,可以提高代码的效率,这与现代处理器的流水线处理(pipelining)有关系。当然,并不是所有场景都适合于条件控制转移(书本148页)。条件传送指令有:

image-20221203163059663.png

3.6.7 循环

汇编语言中没有whilefor,而是通过条件测试和跳转指令实现循环

  • 书中讲述了C代码先转换为goto版本,再转为汇编的过程,包括如何优化。感觉没必要抠得很深。知道是用跳转实现的循环即可。

【例】do-while循环

image-20221203165723356.png

【例】while循环

image-20221203165840320.png

【例】for循环:类似,略

3.6.8 switch语句

C语言的switch语句,分支数量较多时,使用跳转表使得比if-else效率高(节省cmp,空间换时间)

【例】书中示例:定义了一个数组,每个case直接跳转到标号进行处理

image-20221203170450844.png

【编译】对应的汇编语句

image-20221203170823382.png

【声明】汇编代码中,跳转表用以下声明表示

image-20221203170905573.png

3.7 过程(函数调用)

3.7.1 运行时栈

  • 操作系统每个进程有自己的运行时栈。栈划分为栈帧,存放控制和数据信息等内容。当前正在执行函数的栈总是在栈顶
  • 假设有两个函数P()Q(),调用链:main()->...->P()->Q()运行时栈情况如图3-25所示:
    • 存在三个栈帧:较早的帧(比如main)、P的栈帧、Q的栈帧
    • 函数调用时,程序计数器PC指向Q的起始地址、P向Q提供函数实参、Q为局部变量分配空间
    • 函数返回时,程序计数器PC指向P调Q后一条指令地址、Q返回P一个值、Q释放局部变量存储空间
    • P的栈帧
      • 返回地址:P调用Q时,会将返回地址压入占中。Q返回时,P从那个地址继续向下执行
      • 参数传递:如果Q()形参小于等于6个参数(指针和整数),可以只用寄存器实现。如果形参超过6个,需要将参数存放到P的栈帧中。
    • Q的栈帧:保存寄存器的值、分配局部变量空间、为它调用的函数设置参数

image-20221203174935163.png

3.7.2 转移控制——CALL和RET

  • call:函数调用。等价于
    • 将函数执行完成后的下一条地址压入栈中
    • 跳转到函数的首地址执行
  • ret:函数调用返回。等价于
    • 从栈弹出地址,赋值给程序计数器%rip
    • 跳转到这个地址继续执行

【例】书中例子,main()->multstore()->mult2()

#include <stdio.h>

long mult2(long a, long b) {
    return a * b;
}
void multstore(long x, long y, long *dest) {
    long t = mult2(x, y);
    *dest = t;
}
int main() {
    long d;
    multstore(2, 3, &d);
    printf("2 * 3 --> %ld\n", d);
    return 0;
}

【反汇编】

000000000040055d <mult2>:
  40055d:       48 89 f8        mov    %rdi,%rax
  400560:       48 0f af c6     imul   %rsi,%rax
  400564:       c3              retq                         //pop %rip,rip=40056e,继续运行

0000000000400565 <multstore>:
  400565:       53              push   %rbx
  400566:       48 89 d3        mov    %rdx,%rbx
  400569:       e8 ef ff ff ff  callq  40055d <mult2>        //push 40056e,jmp 40055d
  40056e:       48 89 03        mov    %rax,(%rbx)
  400571:       5b              pop    %rbx
  400572:       c3              retq                         //pop %rip, rip=40058b,继续执行

0000000000400573 <main>:
......
  40057c:       be 03 00 00 00  mov    $0x3,%esi
  400581:       bf 02 00 00 00  mov    $0x2,%edi
  400586:       e8 da ff ff ff  callq  400565 <multstore>    //push 40058b, jmp 400565
  40058b:       48 8b 54 24 08  mov    0x8(%rsp),%rdx
......

3.7.3 数据传送(参数传递)

  • 函数参数<=6:使用通用寄存器传递函数参数。注意这里的寄存器有特殊规定。

image-20221204153248917.png

  • 函数参数>6:1~6使用寄存器传递,多于6的部分通过来传递。
  • 函数返回:通过%rax返回

image-20221204153639979.png

【注意】使用栈传参时,所有数据的大小按8字节对齐,这里a4虽然是char类型,在栈存储时占8字节空间

image-20221204154417432.png

3.7.4 栈上的局部存储(局部变量)

  • 比较简单的函数中,局部变量可以放在寄存器中。但是有些情况,局部数据必须存放在栈中:
    • Case 1:寄存器不足,不够存放所有的本地数据
    • Case 2:局部变量使用了取地址运算符'&',产生一个地址
    • Case 3:局部变量是数组或结构体

【例】书中例子

void proc(long a1, long *a1p, int a2, int *a2p, short a3, short *a3p, char a4, char *a4p);

long call_proc() {
    long x1 = 1;
    int x2 = 2;
    short x3 = 3;
    char x4 = 4;
    proc(x1, &x1, x2, &x2, x3, &x3, x4, &x4);
    return (x1+x2)*(x3-x4);
}

【编译】

call_proc:
        subq    $32, %rsp           //分配了32字节的栈帧
        movq    $1, 24(%rsp)        //x1:%rsp+24 ~ %rsp+31字节
        movl    $2, 20(%rsp)        //x2:20~23字节
        movw    $3, 18(%rsp)        //x3:18~19字节
        movb    $4, 17(%rsp)        //x4:17字节
        leaq    17(%rsp), %rax      //参数8:&x4
        movq    %rax, 8(%rsp)
        movl    $4, (%rsp)          //参数7:x4
        leaq    18(%rsp), %r9       //参数6:&x3
        movl    $3, %r8d            //参数5:x3
        leaq    20(%rsp), %rcx      //参数4:&x2
        movl    $2, %edx            //参数3:x2
        leaq    24(%rsp), %rsi      //参数2:&x1
        movl    $1, %edi            //参数1:x1
        call    proc                //call proc()
        movslq  20(%rsp), %rdx      //rdx = x2
        addq    24(%rsp), %rdx      //rdx = x1+x2
        movswl  18(%rsp), %eax      //eax = x3
        movsbl  17(%rsp), %ecx      //ecx = x4
        subl    %ecx, %eax          //eax = eax-ecx = x3-x4
        cltq                        //convert to long
        imulq   %rdx, %rax          //(x1+x2)*(x3-x4)
        addq    $32, %rsp           //释放32字节的局部变量
        ret

【说明】栈帧情况:这里出现Case 1和Case 2。注意:局部变量按字节对齐;传参按8字节对齐

image-20221204163012158.png

3.7.5 寄存器中的局部存储空间

  • 由于16个通用寄存器是共享的,所以函数调用时,被调用函数用到的寄存器可能会被覆盖,所以需要压栈保存。根据函数调用过程,寄存器分成两类:
    • 被调用者保存:%rbx、%rbp、%r12~%r15(可能需压栈)
    • 调用者保存:%rax、%rcx、%rdx、%rsi、%rdi、%r8~%r11(无需压栈)

【例】书中例子:这里由于P()中用到了%rbp%rbx,所以要将环境的%rbp%rbx压栈保存起来,最后函数返回的时候从栈恢复。注意压入顺序和弹出顺序是相反的(栈后进先出)。

image-20221204171335111.png

3.7.6 递归过程

  • 递归:函数调用自己本身。实现和调用其他函数时一样的

【例】计算阶乘,每递归一次都会有一个栈帧

image-20221204173107667.png

【注意】如果递归次数太多不建议用递归,效率低且容易栈溢出