汇编中的流程控制

条件码寄存器

 

  这个子标题在之前就提到过,条件码寄存器与普通的寄存器不同,它们都是1位寄存器,换句话说,它们当中的值只有0和1。当有算术与逻辑操作发生时,这些条件码寄存器当中的值会相应的发生变化,这算是比较神奇的地方吧。

  书中列出了四种常用的寄存器,它们的名字与作用分别如下所述,以下是LZ的理解。

  CF:进位标志寄存器,它记录无符号操作的溢出,当溢出时会被设为1。

  ZF:零标志寄存器,当计算结果为0时将会被设为1。

  SF:符号标志寄存器,当计算结果为负数时会被设为1。

  OF:溢出标志寄存器,当计算结果导致了补码溢出时,会被设为1。

  从上面寄存器的简单说明可以看出,ZF和SF可以判断结果的符号,而CF和OF可以判断无符号和补码的溢出。而我们平时使用的高级程序语言,就仅仅靠这四个寄存器,就可以演化出千变万化的流程控制。这尤其要感谢GCC的作者,个人觉得高级语言的编译实在是一件特别伟大并且有难度的工作。

 

改变条件码寄存器的值

 

  通常情况下,条件码寄存器的值无法主动被改变,它们大多时候是被动改变,这算是条件码寄存器的特色。这其实理解起来并不困难,因为条件码寄存器是1位的,而我们的数据格式最低为b,也就是8位,因此你无法使用任何数据传送指令去传送一个单个位的值。

  几乎所有的算术与逻辑指令都会改变条件码寄存器的值,不过改变的前提是触发了条件码寄存器的条件。比如对于subl %edx,%eax这个减法指令,假设%edx和%eax寄存器的值都为0x10,则两者相减的结果为0,此时ZF寄存器将会被自动设为1。对于其它的指令运算,都是类似的,会根据结果的不同而设置不同的条件码寄存器。

  

特殊的测试指令

 

  上面已经提到,在进行算术与逻辑操作时,条件码寄存器的值可能随之改变。这里介绍两个比较特别的测试指令,它们不改变普通寄存器或者存储器的值,只是为了设置条件码寄存器的值。这算是唯二两个可以主动设置条件码寄存器的指令,它们分别是cmp以及test指令。

  cmp是compare的意思,它有两个操作数,比如cmp S2,S1,最终会基于S1-S2的值去设置条件码寄存器的值。而对于test来说是类似的,对于test S2,S1来说,它将基于S1&S2去设置条件码寄存器的值。另外需要一提的是,两者都需要加数据格式后缀,比如b、w、l这些后缀。

  举个简单的例子,对于cmpl %edx,%eax这个指令来讲,假设%edx的值为y,%eax的值为x。则当x=y时,ZF将会被置为1。当x<y时,SF将会被置为1。而当x>y时,ZF和SF将同时为0。对于test指令来讲,则相对特别一点,它经常用于判断一个数是正数、负数,或者是0。当test用来判断一个数的正负零时,两个操作数为同一个,也就是说testl %eax,%eax可以用来判断%eax寄存器当中的值是正数、负数还是0。因此testl %eax,%eax就相当于cmpl $0,%eax这个指令。

  对于testl %eax,%eax这个指令,或许有的猿友会比较容易蒙,想不明白它如何判断一个数到底是正是负还是零。其实这个道理是非常简单的,只是有时候会一时转不过来,当两个操作数相同时,则经过“与运算”以后还是它自身。此时系统会根据计算结果去设置条件码,而结果又是它自身,因此其实就相当于根据这个数的正负零去设置条件码,这样就可以判断出这个数的正负了。就像cmpl $0,%eax一样,在减去0之后,还是它自身,然后根据自身的正负零去设置条件码寄存器。

  

访问条件码寄存器

 

  对于普通寄存器来讲,使用的时候一般是直接读取它的值,而对于条件码寄存器来说,则不一定非要读取它的值才能使用。对于条件码寄存器来讲,有三种使用方式,都可以让它发挥作用。

  1、可以根据条件码寄存器的某个组合,将一个字节设置为0或1,其实这个就相当于读值。

  2、可以直接条件跳转到程序的某个其它的部分。

  3、可以有条件的传送数据。

  这里面第一种方式其实就是普通寄存器的用法,直接读取条件码寄存器的值,然后进行使用。对于第二和第三种来说,就不是这样了,它们不会显示的读取条件码寄存器的值,而是直接使用。

 

条件码寄存器的组合

 

  本章最难的地方,就在于如何将条件码寄存器的组合与条件联系起来。只要理解了这一点,那么条件码寄存器就算是基本掌握了,因为下面即将介绍的三种使用方式,都是基于这种组合去设计的。接下来LZ就一个一个的去介绍这些组合,以及它们为何会代表相应的条件。由于本文是LZ自主设计的,因此这里有必要对下面出现的格式进行一下简单的说明。

  首先要说明的一点是,对于所有的组合都基于a-b这样的前提,也就是说条件码寄存器的值是经过了一个减运算设置后的值。例如,对于【e->ZF】这样的形式,代表的意思是字母e作为后缀时,则以ZF的值为1视为条件成立。

  比如我们最容易理解的je指令,它代表的是“相等则跳转”。j是跳转的意思,e则是条件码的组合,代表英文equals,因为我们基于a-b去设置条件码寄存器,因此当ZF为1时,代表a等于b。因此ZF条件码寄存器就是相等的条件码组合,而je就代表相等则跳转,就像if(a==b){block}这样的代码所代表的意思。

  接下来,LZ将一一介绍这些组合,这些内容还是比较重要的,并且其中的某一些组合有一定的难度。

  1、e->ZF(相等):e是equals的意思。这里代表的组合是ZF,因为ZF在结果为0时设为1,即a-b=0,也就是说a==b。因此ZF代表的意义是相等。

  2、ne->~ZF(不相等):ne是not equals的意思。这里代表的组合是~ZF,也就是ZF做“非运算”,则很明显是不相等的意思。

  3、s->SF(负数):s这里没什么实际意义,因为负数的直译是negative number,首字母是n,这与not的首字母重复了,因此这里就取了SF条件码寄存器的首个字母(纯属LZ的猜测,无权威证明,不过LZ自我感觉应该八九不离十,0.0)。这里代表的组合是SF,因为SF在计算结果为负数时设为1,此时可以认为b为0,即a<0。因此这里是负数的意思。

  4、ns->~SF(非负数):与s相反,加上n则是not的意思,因此这里代表非负数。

  5、l->SF^OF(有符号的小于):l代表的是less。这里的组合是SF^OF,即对SF和OF做“异或运算”。“异或运算”的意思则是代表,SF和OF不能相等。那么有两种情况,当OF为0时,则代表没有溢出,此时SF必须为1,SF为1则代表结果为负。即a-b<0,也就是a<b,也就是小于的意思。当OF为1时,则代表产生了溢出,而此时SF必须为0,也就是说结果最后为正数,那么此时则是负溢出,也可以得到a-b<0,即a<b。综合前面两种情况,SF^OF则代表小于的意思。

  6、le->(SF^OF)|ZF(有符号的小于等于):le是less equals的意思。有了前面小于的基础,这里就很容易理解了。SF^OF代表小于,ZF代表等于,因此两者的“或运算”则代表小于等于。

  7、g->~(SF^OF)&~ZF(有符号的大于):g是greater的意思。这里的组合是~(SF^OF)&~ZF,相对来说就比较复杂了。不过有了前面的铺垫,这个也非常好理解。SF^OF代表小于,则~(SF^OF)代表大于等于,而~ZF代表不等于,将~(SF^OF)与~ZF取“与运算”,则代表大于等于且不等于,也就是大于。

  8、ge->~(SF^OF)(有符号的大于等于):ge是greater equals的意思。这个组合就不需要再解释了吧。

  9、b->CF(无符号的小于):b是below的意思。CF是无符号溢出标志,这里的意思是指如果a-b结果溢出了,则代表a是小于b的,即a<b。其实这个结论很显然,关键点就在于,无符号减法只有在减出负数的时候才可能溢出,也就是说只要结果溢出了,那么一定有a-b<0。因此这个结论就显而易见了。

  10、be->CF|ZF(无符号的小于等于):这里是below equals的意思。因此这里会与ZF计算“或运算”,字面上也很容易理解,即CF(小于)|(或)ZF(等于),也就是小于等于。

  11、a->~CF&~ZF(无符号的大于):a代表的是above。这个组合也是非常好理解的,CF代表小于,则~CF代表大于等于,~ZF代表不等于,因此~CF&~ZF则代表大于等于且不等于,即大于。

  12、ae->~CF(无符号的大于等于):ae是above equals的意思。至于这个组合的意义,相信也不需要解释了吧。

  以上则是几乎所有的条件码寄存器组合,如果你完全理解了上面的组合,那么接下来的一系列指令会非常简单。它们只是基于条件码的组合,进行设值、跳转、传送的操作而已。从形式上来讲,上面这些组合与数据格式中的b、w、l的用法非常相似。

 

条件设值指令:set指令

 

  set指令是将条件码组合的值,设置到指定的目的操作数,值得注意的是,set指令中的目的操作数,只能是单字节的寄存器或者存储器中单字节的位置。在书中有set指令族的图表,LZ这里直接贴出来,各位结合着上面的条件码组合来看,会显得非常简单。

  各位猿友注意到了吧,将set指令后面加上12种组合,就成了表中的12个指令,这是不是很像数据格式的后缀呢(不过要注意,它们是有严格区别的)。

  这里LZ举一个简单的例子,就算是对set指令做一个简单的介绍。对于setae %al指令来说,%al是%eax寄存器中的最后一个字节,这个指令的含义是,将~CF的值设置到%eax寄存器的最后一个字节。

 

条件跳转指令:jmp指令

 

  这个指令是我们程序实现流程控制的关键指令,它可以直接将程序跳转到指定的位置,又或者根据条件码寄存器的组合进行条件跳转。这个指令比较符合我们思维的惯性,接下来LZ将书中的指令表列出,然后再做针对性的解释。

  可以看到,除了两个jmp指令之外,其余指令均是由j与条件码的组合组成的,因此除了第一个jmp直接跳转指令以及第二个jmp间接跳转指令之外,剩下的12个都是条件跳转指令,它们基于条件码寄存器的组合进行跳转。这些指令并没有太大难度,因此LZ这里就不详细介绍了。

  总的来说,跳转指令的地址编码一般有两种,第一种是基于PC的,第二种则是绝对地址。基于PC(程序计数器)则是指给出一个偏移量,这个偏移量基于当前下一条指令的地址,也就是PC当中的值,这是一种最常用的方式。绝对地址则比较简单,它将直接给出存储器当中代码的位置。这里比较难理解的是基于PC的偏移量方式,因此LZ这里再稍微详细的介绍一下。

 

基于PC的偏移量寻址

 

  相信大部分都听说过这样的说法,PC(程序计数器)会一直指向程序的下一条指令,因此这里所说的PC的相对位置,则是指跳转指令会附带一个偏移量,而这个偏移量与PC值的和则刚好指向跳转的位置。为了理解起来简单,LZ这里举个简单的例子,我们考虑下面一段代码,这是一个非常简单的取两数最小值的方法。

复制代码
int min(int a,int b){
    if( a < b ){
        return a;
    }else{
        return b;
    }
}
复制代码

  我们将其命名为jmp.c,并使用-O1和-S参数去编译它,我们将会得到以下汇编代码。

复制代码
    .file    "jmp.c"
    .text
.globl min
    .type    min, @function
min:
    pushl    %ebp
    movl    %esp, %ebp
    movl    8(%ebp), %edx
    movl    12(%ebp), %eax
    cmpl    %edx, %eax
    jle    .L2
    movl    %edx, %eax
.L2:
    popl    %ebp
    ret
    .size    min, .-min
    .ident    "GCC: (Ubuntu 4.4.3-4ubuntu5.1) 4.4.3"
    .section    .note.GNU-stack,"",@progbits
复制代码

  如果各位猿友从前面一直看过来的话,那么理解上面这一段汇编代码会非常简单。其中a和b分别存储在栈顶+8和+12的位置,这里比较了b-a的值,如果b小于等于a则返回b,否则返回a,很明显,这里判断的是else的条件。可以看到,在汇编代码当中,jmp族指令会使用标签指示跳转地址,比如上面出现过的.L2。

  不过经过汇编器处理之后,标签将不会再存在,此时会使用上面所说的PC偏移量记录跳转地址。接下来,LZ就带各位看一下这个偏移量寻址的方式。我们可以使用-O1和-c编译jmp.c,并使用objdump加-d参数去查看jmp.o,这样会得到下面的反汇编代码。

复制代码
jmp.o:     file format elf32-i386


Disassembly of section .text:

00000000 <min>:
   0:    55                       push   %ebp
   1:    89 e5                    mov    %esp,%ebp
   3:    8b 55 08                 mov    0x8(%ebp),%edx
   6:    8b 45 0c                 mov    0xc(%ebp),%eax
   9:    39 d0                    cmp    %edx,%eax
   b:    7e 02                    jle    f <min+0xf>
   d:    89 d0                    mov    %edx,%eax
   f:    5d                       pop    %ebp
  10:    c3                       ret    
复制代码

  可以看到,这里面的指令序列与刚才的一模一样,因为我们采取了同样的优化等级-O1。值得注意的是,在第b行的指令jle中,跳转的偏移地址是f,其实这个地址是通过偏移量计算出来的,也就是下图中标红的两个位置相加得到的。

  可以看到,这两者加到一起刚好是f,值得注意的是,在真正的二进制序列当中,是不存在f这样的地址的(实际上,f同样是一个偏移量)。换句话说,7e代表jle指令,02则是指令的参数或者说操作数。为了证明这一点,我们可以使用hexdump加-C参数查看jmp.o,各位仔细看LZ标红的地方。

  这下比较清楚了吧,当碰到7e指令(即jle)时,会检查后面的偏移量,结果一看是02,于是在条件满足的前提下,会跳过两个字节执行接下来的指令,也就是5d(即pop指令)。我们不难计算出,5d的位置刚好是89(即mov指令)这个指令的位置加2,而89此时正是PC的值,因为PC指向程序的下一条指令位置,而89刚好就是下一条指令。

  

流程控制展示

 

  上面我们已经搞清楚了jmp指令族的一些常规内容,接下来,我们使用一个综合的程序,来看一下jmp指令族如何实现if/else、for、while、do/while以及switch语句。这部分内容相对比较简单,因此LZ不会详细介绍。其实困难的部分都在上面呢,能看到这里,说明你已经拿下了最难的部分。

  以下是LZ杜撰的一个C程序,请不要揣测它的含义,LZ很明确的告诉各位,这段代码毫无意义,具体的无意义代码如下。

复制代码
int jmp(int a,int b){
    int i;
    if( a == b ){
        return a;
    }
    for(i = 0;i < a;i++){
        a++;
    }
    do{
        b++;
    }
    while(b<a);
    while(a <= b){
        a++;
    }
    switch (a)
    {
      case 10:
         a = a + 10;
         break;
      case 20:
         a = a + 20;
         break;
      default:
         a = a + 30;
         break;
     } 
     return a+b;
}
复制代码

  为了保持汇编代码与C程序代码的一致性,我们使用-S来编译这段代码,接下来我们查看一下生成的汇编代码。为了方便起见,LZ会将注释直接写在汇编代码当中,各位猿友可以对照着看看,体会一下这些流程控制都是如何实现的。

复制代码
    .file    "jmp.c"
    .text
.globl jmp
    .type    jmp, @function
jmp:

    /* 栈帧建立 */
    pushl    %ebp//备份帧指针
    movl    %esp, %ebp//调整栈栈指针
    subl    $16, %esp//分配栈空间
    /* 栈帧建立 */
    
    /* if判断实现 */
    movl    8(%ebp), %eax//取a
    cmpl    12(%ebp), %eax//a和b比较
    jne    .L2//如果a和b不相等,跳到.L2,继续for循环
    movl    8(%ebp), %eax//如果a和b相等,则把a作为返回值
    jmp    .L3//跳到.L3结束方法
    /* if判断实现 */
    
    /* for循环实现 */
.L2:
    movl    $0, -4(%ebp)//将0赋给i
    jmp    .L4//跳到.L4进行条件判断
.L5:
    addl    $1, 8(%ebp)//a做自增
    addl    $1, -4(%ebp)//i做自增
.L4:
    movl    -4(%ebp), %eax//取i
    cmpl    8(%ebp), %eax//i和a比较
    jl    .L5//如果i小于a则回到.L5继续循环,否则往下进行do/while循环
    /* for循环实现 */
    
    /* do/while循环实现 */
.L6:
    addl    $1, 12(%ebp)//b做自增
    movl    12(%ebp), %eax//取b
    cmpl    8(%ebp), %eax//比较b和a
    jl    .L6//如果b小于a,则继续循环,否则往下进行while循环
    /* do/while循环实现 */
    
    /* while循环实现 */
    jmp    .L7//先跳到.L7,这是while与do/while的区别,先判断再执行block
.L8:
    addl    $1, 8(%ebp)//a做自增
.L7:
    movl    8(%ebp), %eax//取a
    cmpl    12(%ebp), %eax//比较a和b
    jle    .L8//如果a小于等于b,则跳到.L8继续循环,否则向下进行switch语句
    /* while循环实现 */
    
    /* switch语句实现 */
    movl    8(%ebp), %eax//取a
    cmpl    $10, %eax//比较a和10
    je    .L10//如果a等于10,跳到.L10进行a=a+10的操作
    cmpl    $20, %eax//比较a和20
    je    .L11//如果a等于20,跳到.L11进行a=a+20的操作
    jmp    .L14//如果a不等于10也不等于20,则跳到.L14进行a=a+30的操作
.L10:
    addl    $10, 8(%ebp)//a=a+10
    jmp    .L12//break
.L11:
    addl    $20, 8(%ebp)//a=a+20
    jmp    .L12//break
.L14:
    addl    $30, 8(%ebp)//a=a+30
.L12:
    movl    12(%ebp), %eax//取b
    movl    8(%ebp), %edx//取a
    leal    (%edx,%eax), %eax//计算a+b并作为返回值
    /* switch语句实现 */
    
    /* 栈帧完成 */
.L3:
    leave
    ret
    /* 栈帧完成 */
    
    .size    jmp, .-jmp
    .ident    "GCC: (Ubuntu 4.4.3-4ubuntu5.1) 4.4.3"
    .section    .note.GNU-stack,"",@progbits
复制代码

  上面LZ已经加了详细的注释,相信各位猿友在注释的帮助下,不难看出这些流程控制的实现,它们全部是由跳转指令实现的。其实如果理解了跳转指令,像if/else、for等等这些流程控制,对各位来说只是一个脑筋小转弯而已。

  

条件传送指令:cmov指令

 

  接下来我们来看最后一种条件指令,叫做条件传送指令。顾名思义,条件传送指令的意思就是在满足条件的时候进行传送的指令,也就是cmov指令。它与set指令十分相似,同样有12种,也就是加上12种条件码寄存器的组合即可,以下是一张书中的指令表格。

  对于条件传送指令执行的时钟周期数,书中给出了一个简单的计算方式,用于阐述最优周期数、最差周期数以及随机周期数的关系,有兴趣的猿友可以去看看,LZ这里就不多讨论了,这一点不是我们的重点。总的来说,条件传送指令相当于一个if/else的赋值判断,一般情况下,条件传送指令的性能高于if/else的赋值判断。

  不过事情总有例外,因为条件传送指令将对两个表达式都求值,因此如果两个表达式计算量很大时,那么条件传送指令的性能就可能不如if/else的分支判断了。不过总的来说,这种情况还是很少的,因此条件传送指令还是很有用的,只是并不是所有的处理器都支持条件传送指令,这依赖于处理器以及编译器的编译方式。

  条件传送指令最大的缺点便是可能引起意料之外的错误,由于LZ的linux无法模拟出条件传送指令,因此这里使用一个书上的例子简单说明一下,比如对于下面这一段代码。

int cread(int *xp){
    return (xp ? *xp : 0);
}

  猛地一看,这一段代码是没问题的,不过如果使用条件传送指令去实现这段代码的话,将可能引起空指针引用的错误。因为条件传送指令会先对两个表达式进行计算,也就是说无论xp是否有值,都将计算*xp这个表达式,因此当xp为空指针0时,则会产生错误。由此可见,条件传送指令也不是哪都能用的,通常情况下,编译器会帮我们尽力处理这种错误。

 

作者:zuoxiaolong(左潇龙)

出处:博客园左潇龙的技术博客--http://www.cnblogs.com/zuoxiaolong

posted @ 2018-09-11 19:58  小时候挺菜  阅读(857)  评论(0编辑  收藏  举报