您的位置:首页 > 其它

汇编中精妙的流程控制

2015-08-19 19:15 357 查看

汇编中精妙的流程控制

前言

今天一天挺废啊,百无聊赖啊,唉,也不想学习,看了一天的电视了,不过好在我还是想看看OS中的东西,这次咱们一起来看看一个特别有趣的内容,就是汇编级的语言,如何利用寄存器实现if/for/while这些高级语言的流程控制,这一点十分神奇.保证你绝对想不到在汇编中是这样实现平时的流程控制的.





正文

条件码寄存器

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



书中列出了四种常用的寄存器,他们的名字与作用分别如下所述

名字

作用

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.可以有条件的传送数据

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





条件码寄存器的组合(重难点)

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



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



比如我们最容易理解的je指令,它代表的是”相等则跳转”.

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



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

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条件码寄存器的首个字母(纯属个人意淫).这里代表的组合是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指令族的图标,结合上面的条件码组合来看,很显的很简单.






不知道大家注意到了没,将set指令后面加上12种组合,就成了表中的12个指令,这是不是很想数据格式的后缀呢(他们还是有严格区别的).

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



条件跳转指令:jmp指令

这个指令是我们程序实现流程跳转的关键指令,他可以直接将程序跳转到指令的位置,又或者根据条件码寄存器的组合进行条件跳转.这个指令比较符合我们的思维逻辑,咱们先把指令表里出来,然后再做针对性的理解.






看一看出来,出了两个jmp指令之外,其余指令均是由j与条件码的组合组成的,因此除了第一个jmp直接跳转指令以及第二个jmp间接跳转指令之外,剩下的12个都是跳转指令,它们基于条件码寄存器的组合进行跳转.这些指令并没啥难度,咱们就不做介绍了.

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





基于PC的偏移量寻址

相信大部分都听过这样的说法,PC(程序计数器)会一直指向程序的下一条指令,因此这里所说的PC的相对位置,则是指跳转指令会附带一个偏移量,而这个偏移量与PC值的和则刚好指向跳转的位置.为了理解起来简单,举个例子,有着米一段代码,获得两个数中最小值的方法:

int min(int a,int b){
    if( a < b ){
        return a;
    }else{
        return b;
    }
}


我们将其命名为jmp.c,同样适用
GCC -O1 -S jmp.c


参数去编译它,然适用
cat jmp.c


这条命令查看编译后的结果,将会得到以下汇编代码:

.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偏移量记录跳转地址,接下来,咱们一起看一下这个偏移量寻址的方式.我们可以适应-O1好-c编译jmp.c,并使用objdump加-d参数去查看jmp.o,就会得到下面的反汇编代码:

jmp.o:     file format elf32-i386
 
 
Disassembly of section .text:
<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,还是继续看红色标注的地方:



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



流程控制展示

上面我们已经搞清楚了jmp指令族的一些常规内容,接下来,我们使用一个综合的程序,来看一下jmp指令族如何实现if/else,for,while,do/while以及switch语句.这部分内容相对比较简单,咱们就不详细介绍了,最难的部分是上面的内容,我反正还是不明白.

下面是一个C语言的程序,没什么目的,主要是用来测试的.

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来编译这段代码,接下来我们查看一下生成的汇编代码.为了方便期间,咱就直接汇编代码中,大家看的时候对照看看,体会一下这些流程控制是如何实现的.

.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




上面已经给出了详细的注释,详细个位在注释的帮助下,肯定很容易的就看出流程控制的实现,它们全是由跳转指令实现的,其实如果理解了跳转指令,像if/else,for等等这些流程控制,对各位来说只是一个小case了.





条件传送指令:cmov指令

这是最后一种条件只领了,大家坚持住.cmov叫做条件传送指令,顾名思义,条件传送指令的意思是在满足条件的时候进行传送的指令,也就是cmov指令.cmov指令和set指令很相似,同样有12种,也就是加上12种条件码寄存器的组合即可,看一下指令表格:



对于条件传送指令执行的时钟周期数,有一种简单的计算方式,用于阐述最优周期数,最差周期数以及随机周期数的关系,有兴趣的自己去查,为啥我这里没说这个公式呢?谁说明这不是重点.总的来说,条件传送指令相当于一个if/else的赋值判断,一般情况下,条件传送指令的性能高于if/else的赋值操作.

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



条件传送指令最大的缺点便是可能会引起意料之外的错误,比如:

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


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



小小的结一下

这一章的难度就在于条件码寄存器的组合,如果不了解,多拿出一点时间来搞明白,否则的话,下面出现的指令也就只能是知其然不知其所以然.
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: