上一章节讲到的是数据的移动、计算的底层代码表示,其中,每条汇编指令都是顺序执行的。考虑C语言中三种程序执行方式。
顺序、条件、循环。
本章简练介绍条件分支(if、switch)和循环(do-while,while,for)的机器级表示。
条件分支指代单条件分支。程序通过测定某些条件的成立与否,控制程序的走向。反映到C语言当中,即 if−else 语句。
除了整型寄存器之外,CPU还会跟踪一个条件码寄存器。某一个条件码只用一个bit来表示,代表某种状态。这些状态可以被测试从而决定是否更改程序的走向,从而达到条件分支的效果。
注意,这里标注了说是最近的操作,也就是说,只要进行了数据的计算,条件码都会发生改变(leal除外,因为它不仅仅是用地址进行计算)。
所以说,条件码会不断的发生变化。
当有了条件码的时候,计算机就可以根据条件码的状态执行比较和测试指令。
指令 | 基于 | 描述 |
---|---|---|
cmp S2,S1 | S1−S2 | 比较 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 指令集的作用类似于数据计算指令集,可以更改条件码的状态。那么当我们有了条件码的状态的时候,我们如何去访问或者获得条件码呢?
条件码不可以被直接访问,通常来讲有三种方法获取条件码。
对于第一种方法,不同的条件码的组合设定某个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 ,该条件码不仅表示相等,也表示是否为零。
前面已经介绍完了条件码,即介绍使用条件码以及 cmp、set 指令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
我们都知道每条汇编指令都是被编码成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语言提供的 do−while, while, for 循环。
然而,机器指令编码并没有“循环”的功能,CPU通过组合条件测试指令和jmp指令达到循环的功能。
大部分编译器产生的具有循环效用的指令都是基于 do−while 循环的。其他的循环模式会被转换为 do−while 后再进行编码。
do{
body-statement
}while(test-expr);
这是C语言的 do−while 循环的形式,编译器会将其转换为下列形式 :
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 循环c语言描述如下:
while(test-expr)
body-statement;
编译器会先将其转换为 do−while 循环的模式:
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:
同样效果的函数,使用的循环的方式不同,产生了不同的汇编指令。我们可以看出, do−while 的汇编编码比 while 的要少一些,具有更好的效率。
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:
当出现很多种情况从而控制程序不同的走向时,C语言中一般使用 switch 语句来代替多个 if−else 。
switch 不仅可读性更好,而且当了解了它的实现原理的时候就会发现 switch 在多重分支语句中可能更为高效。
例如 : 当 if−else 很多(一般来讲多余4个的时候)或者稀疏度少的时候,考虑 switch 更为高效。
稀疏度的概念可能比较模糊,可以看一下这篇博客
编译器借助一种称作跳转表( jump table )的数据结构实现 switch−case 语义。
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, 标记某段代码起始位置
......
我们一起分析一下书上的例子。
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是某段代码的首地址标示,汇编中常用如此