zoukankan      html  css  js  c++  java
  • 深入理解计算机系统(3.6)---汇编中精妙的流程控制(重要)(难度较高)

    引言

     

      最近LZ有些略忙,因此这一章拖的时间有点久,不知道有没有猿友在跟着看呢,LZ觉得应该几乎没有吧。毕竟这实在是一本乍一看十分枯燥的书,不过随着慢慢的深入,不知道有没有猿友慢慢找到了一点感觉呢。

      本章我们来看一个特别有趣的内容,就是汇编级别的语言,如何利用寄存器实现if/for/while这些高级语言的流程控制,LZ只能说这实在是十分神奇。在没有接触这部分内容的时候,LZ打死也没有想到,原来平时的流程控制是这样实现的。接下来,各位猿友就和LZ一起见证这奇迹吧。

     

    条件码寄存器

     

      这个子标题在之前就提到过,条件码寄存器与普通的寄存器不同,它们都是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时,则会产生错误。由此可见,条件传送指令也不是哪都能用的,通常情况下,编译器会帮我们尽力处理这种错误。

     

    文章小结

      

      本章内容较多,不过这已经是LZ将书中的内容压缩后的体积,总的来说,这一章的难点就在于条件码寄存器的组合。如果各位猿友理解不了这一部分,一定要下功夫搞定它,否则的话,下面出现的指令也只能是知其然而不知其所以然了。本次内容就到此为止了,下一章也是非常重要的一章,有关于程序当中过程(方法)的实现方式。

  • 相关阅读:
    poj 2112
    写给学大计python的同学的查错方法
    [Win32 API学习] Edit,ListBox的用法,字体的设置
    [Win32 API] FindFirstFile()和FindNextFile()
    初探C++ win32 api——Hello world!
    [学习笔记]舞蹈链(DLX)(C++指针版)
    2020科大回归记
    SCOI2019AFO记
    [学习笔记]动态动态规划/动态DP/DDP
    BZOJ5343[CTSC2018]混合果汁(二分答案+主席树)
  • 原文地址:https://www.cnblogs.com/zuoxiaolong/p/computer18.html
Copyright © 2011-2022 走看看