汇编中精妙的流程控制
今天一天挺废啊,百无聊赖啊,唉,也不想学习,看了一天的电视了,不过好在我还是想看看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
上面已经提到,在进行算术与逻辑操作时,条件码寄存器的值可能随之改变.这里介绍两个比较特殊的测试指令,他们不改变普通寄存器或者存储器的值,只是为了设置条件条件码寄存器的值.这算是唯二两个可以主动设置条件码寄存器的指令,他们分别是cmp以及test指令.
cmp是compare的意思,他有两个操作数,比如
cmp S2 S1
举个简单的案例,对于
cmpl %edx,%eax
testl %eax ,%eax
testl %eax ,%eax
cmpl &0,%eax
对于
testl %eax,%eax
cmpl $0,%eax
对于普通寄存器来讲,使用的时候一般直接读取它的值,而对于条件码寄存器来说,则不一定非要读取它的值才能使用.对于条件码寄存器来说,有三种使用方式,都可以让它发挥作用.
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指令后面加上12种组合,就成了表中的12个指令,这是不是很想数据格式的后缀呢(他们还是有严格区别的).
举个简单的案例,就算是对set指令做一个简单的介绍.对于setae %al指令来说,%al是%eax寄存器中的最后一个字节,这个指令的含义是,将~CF的值设置到%eax寄存器的最后一个字节.
这个指令是我们程序实现流程跳转的关键指令,他可以直接将程序跳转到指令的位置,又或者根据条件码寄存器的组合进行条件跳转.这个指令比较符合我们的思维逻辑,咱们先把指令表里出来,然后再做针对性的理解.
看一看出来,出了两个jmp指令之外,其余指令均是由j与条件码的组合组成的,因此除了第一个jmp直接跳转指令以及第二个jmp间接跳转指令之外,剩下的12个都是跳转指令,它们基于条件码寄存器的组合进行跳转.这些指令并没啥难度,咱们就不做介绍了.
总的来说,跳转指令的地址编码一般有两种,第一种是基于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指令和set指令很相似,同样有12种,也就是加上12种条件码寄存器的组合即可,看一下指令表格:
对于条件传送指令执行的时钟周期数,有一种简单的计算方式,用于阐述最优周期数,最差周期数以及随机周期数的关系,有兴趣的自己去查,为啥我这里没说这个公式呢?谁说明这不是重点.总的来说,条件传送指令相当于一个if/else的赋值判断,一般情况下,条件传送指令的性能高于if/else的赋值操作.
万事有例外,要不然万一这个词咋来的,因为条件传送指令将对两个两个表达式都求值,因此如果两个表达式计算量都很大的时候,那么条件传送指令的性能可能不如if/else的分支判断了.既然是万一,说明这种情况很少,所以条件传送指令还是很有用的,只是并不是所有的处理器都支持条件传送指令,这依赖于处理器以及编译器的编译方式.
条件传送指令最大的缺点便是可能会引起意料之外的错误,比如:
int cread(int *xp) { return (xp?*xp:0); }
猛地一看,这段代码是没问题,不过如果使用条件传送指令趋势线这段代码的话,将可能会引起空指针引用的错误.因为条件发送指令会先对两个表达式进行计算,也就是说无论无论xp是否有值,都将计算*xp这个表达式,因此当xo为空指针的时候,则会产生错误.由此可见,条件传送指令也不是哪里都能用的,通常情况下,编译器会帮我们尽力处理这种情况.
这一章的难度就在于条件码寄存器的组合,如果不了解,多拿出一点时间来搞明白,否则的话,下面出现的指令也就只能是知其然不知其所以然.