CSAPP读书笔记——程序的机器级表示之条件跳转与循环

程序控制指令


上一章节讲到的是数据的移动、计算的底层代码表示,其中,每条汇编指令都是顺序执行的。考虑C语言中三种程序执行方式。

顺序、条件、循环。

本章简练介绍条件分支(if、switch)和循环(do-while,while,for)的机器级表示。


条件分支


条件分支指代单条件分支。程序通过测定某些条件的成立与否,控制程序的走向。反映到C语言当中,即 ifelse 语句。
除了整型寄存器之外,CPU还会跟踪一个条件码寄存器。某一个条件码只用一个bit来表示,代表某种状态。这些状态可以被测试从而决定是否更改程序的走向,从而达到条件分支的效果。

条件码


  • CF(carry flag) : 进位标志。最近的操作产生最有效位进位时,设置为true。可以用来检查无符号整数是否溢出。
  • ZF(zero flag) : 零标志。最近的操作产生的结果为0时,设置为true。
  • SF(sign flag) : 符号标志。最近的操作产生的结果为负数时,设置为true。
  • OF(overflow flag):溢出标记。(补码)最近的操作产生的结果导致溢出(正溢出或者负溢出)时,设置为true。显然用来检测有符号整数是否溢出。

注意,这里标注了说是最近的操作,也就是说,只要进行了数据的计算,条件码都会发生改变(leal除外,因为它不仅仅是用地址进行计算)。

所以说,条件码会不断的发生变化。


cmp指令集合


当有了条件码的时候,计算机就可以根据条件码的状态执行比较和测试指令

指令 基于 描述
cmp S2,S1 S1S2 比较 S1,S2
cmpb compare byte
cmpw compare word
cmpl compare double word
test S2,S1 S1 & S2 测试
testb test byte
testw test word
testl test double word
  • cmp 命令基于 sub 命令, sub 在上一章数据计算中已经解释过。不同于 sub , cmp 仅仅是将 s2,s1 中的数据拿出来做了计算,但是并没有改变 s2,s1 中的任意一个寄存器的状态,它们的值是始终保持不变的。
  • test 命令是测试,基于命令 and ,基于的意思即不会改变寄存器的状态。它的重要用途是检测某寄存器的数值是0、正数、负数,或者其中一个操作数当做掩码,指示那些位置应当被测试。

set指令集


cmp 指令集的作用类似于数据计算指令集,可以更改条件码的状态。那么当我们有了条件码的状态的时候,我们如何去访问或者获得条件码呢?
条件码不可以被直接访问,通常来讲有三种方法获取条件码。

  1. 根据条件码的组合将某bit设置为0,1。作为条件分支检测的方式,相当于间接访问了条件码。
  2. 利用条件码,我们可以跳转到程序的某个分支,也相当于间接访问条件码并且改变了控制流。
  3. 类似于2,我们可以通过条件检测有条件的传输数据流。

对于第一种方法,不同的条件码的组合设定某个bit为0或者1等,这样的操作cpu集成了 set 指令。

指令 同义指令 效果 设置条件
sete D setz ZF-> D 相等/零
setne D setnz ~ZF-> D 不等/非零
sets D SF-> D 负数
setns D ~SF-> D 非负数
setg D setnle ~(SF ^ OF) & ~ZF -> D 有符号数大于
setge D setnl ~(SF ^ OF) -> D 有符号数大于等于
setl D setnge SF ^ OF -> D 有符号数小于
setle D setg (SF ^ OF) or ZF -> D 有符号数小于等于
seta D setnbe ~CF & ~ZF -> D 无符号大于(a->above)
setae D setnb ~CF -> D 无符号大于等于
setb D setnae CF -> D 无符号小于(b->below)
setbe D setna CF or ZF -> D 无符号小于等于

一条set指令将8个bit的寄存器或者内存作为它的目的存储器,将该byte设定为0或者1。为了生成32位的结果,必须将其余24位设定为0。
下面的代码就是C语言计算 a<b 时产生的汇编代码。

/* a is in %edp, b is in %eax */

cmpl %eax,%edx  //compare a,b
setl %al        //set low order byte of %eax to 0 or 1
movzbl %al,%eax //32bit zero extends to ensure the result is 32bit

set 指令集通过对条件码的组合(取反,或,异或等操作得到的条件码表达式)明确如何设定目标存储器为0或者1,这是如何做到的?
我们举例子讨论一下即可明白。
首先, set 指令前边都会有一组 cmp 或者 test 指令。
假如说 cmp 指令,因为 cmp 指令基于 sub ,我们给出以下假设。

// 假设cmpl b,a
// t = a - b
// 考虑setg(a,b)均为补码表示的有符号整数

// 1.减法没有溢出 , OF = 0
// 当且仅当t = a - b > 0 时,表示a > b,结果t为正数 SF = 0

// 2.减法溢出 , OF = 1
// 正溢出 : t = a - b (小 - 大) > 0,此时 a < b ,SF = 0
// 负溢出 : t = a - b (大 - 小) < 0,此时 a > b ,SF = 1

//由上边的推算可知,如果setg为真 ,必须满足以下条件
//1.ZF = 0 不相等
//2.减法没有溢出时         SF = 0 && OF = 0
//3.减法溢出时必须为负溢出 SF = 1 && OF = 1

//结合以上三个条件可以得出,~(SF ^ OF) & ~ZF

其他的证明也比较容易,需要特别强调的是 ZF ,该条件码不仅表示相等,也表示是否为零


跳转指令


跳转指令的作用


前面已经介绍完了条件码,即介绍使用条件码以及 cmpset 指令cpu可以实现条件检测,根据条件检测的结果,我们可以控制程序的走向。
控制走向的功能,由跳转指令完成。

跳转指令会跳到某个标签的位置开始执行程序,跳转分为直接跳转和间接跳转。

指令 同义词 跳转条件 描述
jmp Label always true 直接跳转
jmp *operand always true 间接跳转
je Label jz ZF 相等/零
jne Label jnz ~ZF 不相等/非零
js Label SF 负数
jns Label ~SF 非负数
jg Label jnle ~(SF ^ OF) & ~ZF 有符号数大于
jge Label jnl ~(SF ^ OF) 有符号数大于等于
jl Label jnge SF ^ OF 有符号数小于
jle Label jng (SF ^ OF) or ZF 有符号数小于等于
ja Label jnbe ~CF & ~ZF 无符号数大于
jae Label jnb ~CF 无符号数大于等于
jb Label jnae CF 无符号数小于
jbe Label jna CF ZF

其中间接跳转是使用存储器 operand 中的值作为跳转目标,例如:

jmp *(%eax)
//使用寄存器%eax中的值作为跳转目标

直接跳转则是跳转到某标签位置,下述代码会直接跳过jmp之后的movl语句。
从表中可以看出,条件jmp语句的目标位置只能是直接给出的(Label)而不能间接给出。

movl $0,%eax  //set %eax to 0
jmp  L1       //goto L1
movl (%eax),%edx // null pointer dereference
L1:
    popl %edx

jump指令的编码以C语言实例


我们都知道每条汇编指令都是被编码成16进制数在 .o 文件中呈现(C-like语言),jmp指令也不例外。由于jmp指令是可以改变程序的走向进入不同的分支,出于某些特殊原因(在学习linker的时候会用到),我们会讨论一下jmp指令的编码问题。

jmp指令有很多种编码方法,其中最为常用的方式是PC-relative模式的。
下面给出CSAPP上的例子。

jle L1  //if <= goto L1
L2:
    movl  %edx,%eax
    sarl  %eax
    subl  %eax,%edx
    leal  (%edx,%edx,2),%edx
    testl %edx,%edx
    jg    L2   // if > goto L2
L1:
    mov   %edx,%eax

考虑上述代码片段在 .o 中的指令编码情况,我们可以得到:

         //指令地址 : 指令编码    //对应的汇编翻译
1.         8:  7e 0d       jle   0x17  //直接跳转的地址
2.         a:  89 d0       movl  %edx,%eax
3.         c:  d1 f8       sarl  %eax
4.         e:  29 c2       subl  %eax,%edx
5.        10:  8d 14 52    leal  (%edx,%edx,2),%edx
6.        13:  85 d2       testl %edx,%edx
7.        15:  7f f3       jg    0xa //直接跳转的地址
8.        17:  89 90       mov   %edx,%eax

观察第一个跳转指令 jle 的跳转地址为 0x17 ,也即第8行。
jle 的被编码为 0x0d ,它的下一条指令 movl 的地址为 0xa (即第2行开始),两者之和为 0x17 刚好为 jle 跳转到的指令地址。

这不是巧合,PC-relative即将跳转指令( jle )编码为跳转地址( 0x17 )和跳转指令下一条指令的地址(第二行 movl 的地址为 0xa )之差。

这里一定要分清
指令编码指令地址的关系!

jmp指令的编码 + 紧接着jmp指令的下一条指令的指令地址 = jmp的目标位置的指令地址

循环


循环是程序处理数据的得力帮手。像C语言提供的 dowhile, while, for  循环。
然而,机器指令编码并没有“循环”的功能,CPU通过组合条件测试指令和jmp指令达到循环的功能。


do-while循环


大部分编译器产生的具有循环效用的指令都是基于 dowhile 循环的。其他的循环模式会被转换为 dowhile 后再进行编码。

do{
    body-statement
}while(test-expr);

这是C语言的 dowhile 循环的形式,编译器会将其转换为下列形式 :

loop:
    body-statement
    t = test-expr;
    if(t)
        goto loop;

如此可见,循环不过是 goto 语句的语法糖罢了。

根据上述代码段,对于下述C语言函数

int fact_do(int n)
{
    int result = 1;
    do{
        result *= n;
        n -= 1;
    }while(n > 1);
    return result;
}

我们可以很容易的将其翻译为下述汇编指令

//arugument : n at %ebp + 8
//register  : n in %edx,result in %eax

movl 8(%ebp),%edx  //get n
movl $1,%eax       //result = 1
loop:
    imull %edx,%eax  // result *= n
    subl  $1,%edx    // n -= 1
    cmpl  $1,%edx    //if(n > 1)
    jg   loop 

while循环


while 循环c语言描述如下:

while(test-expr)
    body-statement;

编译器会先将其转换为 dowhile 循环的模式:

if(!test-expr)
    goto done;

do{
    body-statement
}while(test-expr);

done:
    end...

进而全部转为 goto 语句的形式:

if(!test-expr)
    goto done;
loop:
    body-statement
    if(test-expr)
        goto loop;
done:
    end....

还是阶乘的例子 :

int fact_do(int n)
{
    int result = 1;
    while(n > 1){
        result *= n;
        n -= 1;
    }
    return result;
}
//写作goto的形式
int fact_do(int n)
{
    int result = 1;
    if(n <= 1)
        goto done;
    loop:
        result *= n;
        n--;
        if(n > 1)
            goto loop;
    done:
        return result;
}

上述代码将会被翻译为如下汇编指令:

//arugument : n at %ebp + 8
//register  : n in %edx,result in %eax

movl 8(%ebp),%edx  //get n
movl $1,%eax       //result = 1
cmpl $1,%edx       // cmpare n and 1
jle  label1        //if <= goto done
label2:
    imull %edx,%eax  // result *= n
    subl  $1,%edx    // n -= 1
    cmpl  $1,%edx    //if(n > 1)
    jg    label2
label1:

同样效果的函数,使用的循环的方式不同,产生了不同的汇编指令。我们可以看出, dowhile 的汇编编码比 while 的要少一些,具有更好的效率。


for循环


for循环也将被转换为 while 循环进而转换为 if,goto 模式,C语言表述如下 :

for(init-expr ; test-expr ; update-expr)
    body-statement;
//相当于
init-expr
while(test-expr){
    body-statement
    update-expr
}
//进而转换为goto模式
init-expr
if(!test-expr)
    goto done;
loop:
    body-statement
    update-expr
    if(test-expr)
        goto loop;
done:
    end....

同样以阶乘函数为例:

int fact_forloop(int n)
{
    int result = 1;
    int i;
    for(i = 2; i <= n ; ++i)
        result *= i;
    return result;
}
//等价于下述goto模式
int fact_forloop(int n)
{
    int result = 1;
    int i = 2;

    if( !(i <= n ) )
        goto done;
    loop:
        result *= i;
        i++;
        if(i <= n)
            goto loop;
    done:
        return result;
}
//生成的相应汇编代码为
//arugument : n at %ebp + 8
//register  : n in %edx,result in %eax

movl 8(%ebp),%ecx  //get n
movl $2,%edx       //i = 2
movl $1,%eax       //result = 1
cmpl $1,%ecx       // cmpare n and 1
jle  label1        //if <= goto done
label2:
    imull %edx,%eax  // result *= i
    addl  $1,%edx    // i++
    cmpl  $edx,%ecx    //if(n >= i)
    jge    label2
label1:

多重分支语句(switch)


当出现很多种情况从而控制程序不同的走向时,C语言中一般使用 switch 语句来代替多个 ifelse
switch 不仅可读性更好,而且当了解了它的实现原理的时候就会发现 switch 在多重分支语句中可能更为高效。

例如 : 当 ifelse 很多(一般来讲多余4个的时候)或者稀疏度少的时候,考虑 switch 更为高效。

稀疏度的概念可能比较模糊,可以看一下这篇博客


实现原理


编译器借助一种称作跳转表( jump table )的数据结构实现 switchcase 语义。

jump table 实际上就是数组,不过它的下标 i 满特定的条件。

我们都知道每一段代码都存储在内存当中,所以每段代码都有起始地址, jump table[i] 恰好存储第 i 种情况下要执行的代码段的首地址。可以借助 goto 语句跳到该代码位置处执行代码。

void*  jumpTable[5] = {&&case1,&&case2,&&case3,&&case4,&&case5}

这是一个类似的跳转表的实现。 jumpTable[i] 存储一个 void 指针(因为代码块用一个地址指示,所以类型显然是一个指针),解引用它会得到一个二级指针case,再次将其解引用就得到将要执行的代码块在内存中的首地址。

PS:解释一下case为什么是二级指针。case本身是一个指针,它指示要执行的代码段的首地址(指针就可以理解为地址),因此case是指向指针的指针,二级指针。

当然,这个跳跃表我们是看不到的,不过在生成的 .o 目标代码中,会有相应的汇编编码,大致如下所示。

.section    .rodata//read only data
.align      4      //align address to multiple of 4
.L7://可以理解跳转表的首地址
    .long .L3       //some case,标记某段代码的起始位置
    .long .L2       //some case, 标记某段代码起始位置
    ......

C语言实例


我们一起分析一下书上的例子。

int switch_eg(int x,int n)
{
    int result = x;

    switch(n){
        case 100:
            result *= 13;
            break;
        case 102:
            result += 10;
            /*Fall through*/
        case 103:
            result *= 11;
            break;
        case 104://这里两条case执行的是用一段代码,“稀疏度”小
        case 106:
            result *= result;
            break;
        default:
            result = 0;
    }
    return result;
}
//上述代码的jump-table,goto版本
int switch_eg_impl(int x,int n)
{
    static void* jt[7] = {
            &&loc_A,&&loc_def,&&loc_B,
            &&loc_C,&&loc_D,&&loc_def,
            &&loc_D
    };

    unsigned index = n - 100;
    int result;

    if(index > 6)//不在跳转表的范围内
        goto loc_def;

    goto *jt[index];//得到一个指针,指向将要指向的代码段的地址
    loc_def://default
        result = 0;
        goto done;
    loc_C://case 103
        result = x;
        goto rest;
    loc_A://case 100
        result = x * 13;
        goto done;
    loc_B://case 102
        result = x + 10;
        /*Fall through*/
    rest://finish case 103
        result += 11;
        goto done;
    loc_D:
        result = x * x;
    done:
        return result;
}

其汇编关键片段如下:

//x at %ebp+8,n at %ebp+12
movl    8(%ebp),%edx    //get x
movl   12(%ebp),%eax    //get n
subl   &100,%eax        //n - 100,与跳转表的下标建立映射关系
cmpl   &6,%eax          //compare n,6
ja     .L2              //if >,goto loc_def
jmp    *.L7(,%eax,4)    //jmp *operand是间接跳转,.L7表示跳转表的首地址,4是每个地址的偏移量
.L2:
    movl  &0,%eax  //loc_def
    jmp   .L8      //goto done
.L5:
    movl  %edx,%eax //loc_C
    jmp   .L9
.L3:
    leal  (%edx,%edx,2),%eax   //result = x * 3
    leal  (%edx,%eax,4),%eax   //result = x+ 4 * result
    jmp   .L8
.L4:
    leal  10(%edx),%eax        //loc_B
.L9:
    addl  $11,%eax             //rest
    jmp   .L8
.L6:
    movl  %edx,%eax            //loc_D
    imull %edx,%eax
.L8:
    //return

//上述.Lx是某段代码的首地址标示,汇编中常用如此 

补充 : 条件传送指令


你可能感兴趣的:(CSAPP,Note)