文章目录
- 3.6 控制
- 3.6.1 条件码
- 3.6.2 访问条件码
- 3.6.3 跳转指令及其编码
- 3.6.4 翻译条件分支
- 3.6.5 循环
- do-while 循环
- while循环
- for循环
- 3.6.6 switch 语句
3.6 控制
截止目前,考虑了 访问数据和 操作数据 的方法。程序执行的另一个很重要的部分就是控制被执行操作的顺序。对 C 和汇编代码中的语句,默认的方式是顺序的控制流,按照语句或指令在程序中出现的顺序来执行。C中的某些程序结构,比如条件语句、循环语句和分支语句,允许控制按照非顺序方式进行,即根据程序数据的值来确定顺序。
汇编代码提供了实现非顺序控制流的较低层次的机制。基本操作是跳转到程序的另一部分,可能会视某些测试结果而定。编译器产生的指令序列是依赖于这些低层机制来实现 C 的控制结构。
下文的讲解中,会先谈到机器级机制,然后会给出如何用它们来实现 C 的各种控制结构。
3.6.1 条件码
除了整数寄存器,CPU 还包含一组单个位的条件码(condition code) 寄存器,它们描述了最近的算术或逻辑操作的属性。对这些寄存器的检测,将有助于执行条件分支指令。最有用的条件码是:
- CF:进位标志。最近的操作使最高位产生了进位,它可用来检查无符号操作数的溢出。
- ZF:零标志。最近的操作得出的结果为0。
- SF:符号标志。最近的操作得到的结果为负数。
- OF:溢出标志。最近的操作导致一个二进制补码溢出——正溢出或负溢出。
比如,用 addl
指令完成等价于 C 表达式 t = a + b
的功能,这里变量a、b 和 t 都是整型的。然后,会根据下面的表达式来设置条件码:
leal
指令不改变任何条件码,因为它是用来进行地址计算的。另一方面,图3.7中列出的所有指令都会设置条件码。对于逻辑操作,例如 xorl
,进位标志和溢出标志会设置成0。对于移位操作,进位标志将设置为最后一个被移出的位,而溢出标志设置为0。
除了图3.7 中的操作,下面的表给出了两个操作(有8、16 和 32 位形式),它们只设置条件码而不改变任何其他寄存器。
cmpb
、cmpw
和 cmpl
指令根据它们的两个操作数之差来设置条件码。在 GAS 格式中,操作数的顺序是相反的,使得代码有点难读。如果两个操作数相等,这些指令会将零标志设置为1,而其他的标志可以用来确定两个操作数之间的大小关系。
testb
、testw
和 testl
指令会根据它们的两个操作数的与(AND)来设置零标志和负数标志。通常两个操作数是一样的(例如,testl %eax,%eax
用来检查 %eax 是负数、零还是正数),或其中的一个操作数是用来指示哪些位应该被测试的掩码。
3.6.2 访问条件码
条件码通常不会直接读取,两种最常用的访问条件码的方式是根据条件码的某个组合,设置一个整数寄存器或是执行一条条件分支指令。
下图3.10中描述的是各种 set 指令根据条件码的各个组合,将一个字节设置为0或者1。目的操作数是八个单字节寄存器元素之一,或是存储一个字节的存储器位置。为了得到一个 32 位结果,必须对最高的 24 位清零。
一个 C 判定条件(如
a
<
b
a<b
a<b)的典型指令序列如下所示:
movzbl
指令用来清零三个高位字节。
某些底层的机器指令可能有多个名字,称之为 “同义名(synonym)”。比如说,“setg”(表示“设置大于”)和 “setnle”(表示“设置不小于等于”)指的就是同一条机器指令。编译器和反汇编器会随意决定使用哪个名字。
虽然所有的算术操作都会设置条件码,但是各个 set 命令的描述都适用于这样一种情况:执行比较指令,根据计算 t = a − b t = a -b t=a−b 设置条件码。例如,就 sete 来说,即“当相等时设置(Set when equal)” 指令。当 a = b a = b a=b 时,会得到 t = 0 t = 0 t=0,因此零标志置位就表示相等。
setl
测试有符号比较
类似地,考虑用 setl
,即 “当小于时设置(Set when less)” 指令,测试一个有符号比较。当
a
a
a 和
b
b
b 是用二进制补码表示时,对于
a
<
b
a<b
a<b,计算两者之差时,会有
a
−
b
<
0
a-b<0
a−b<0。
- 当没有溢出发生时,符号标志置位就表明 a < b a<b a<b。
- 当因为 a − b a-b a−b 是一个很大的正数,出现正溢出时,会得到 t < 0 t<0 t<0。
- 当因为 a − b a-b a−b 是一个很小的负数,出现负溢出时,得到 t > 0 t>0 t>0。
无论这两种情况中的哪一种,符号标志都表示的是真正的差的反。因此,溢出和符号位的异或测试的就是
a
<
b
a<b
a<b。其他的有符号比较测试是基于 SF ^ OF
和 ZF
的其他组合。
无符号比较的测试
对于无符号比较的测试,当无符号参数
a
a
a 和
b
b
b 的整数差是负数时,也就是当 (unsigned)a < (unsigned) b
时,cmpl
指令会设置进位标志。因此,这些测试使用的是进位标志和零标志的组合。
3.6.3 跳转指令及其编码
正常执行的情况下,指令按照它们出现的顺序一条一条地执行。跳转(jump)指令会导致执行切换到程序中一个全新的位置(如下图3.11所示)。这些跳转的目的地通常用一个标号(label)指明。
考虑下面这样的汇编代码序列:
指令 jmp .L1
会导致程序跳过 movl
指令,从 popl
指令开始继续执行。在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并使跳转目标(目的指令的地址)编码为跳转指令的一部分。
jmp 指令是无条件跳转。它可以是直接跳转,即跳转目标是作为指令的一部分编码的,也可以是间接跳转,即跳转目标是从寄存器或存储器位置中读出的。 汇编语言中,直接跳转是给出一个标号作为跳转目标的,例如,上面代码中的标号 “.L1
”。间接跳转的写法是 “*
” 后面跟一个操作数指示符,语法与 movl
指令使用的一样。看如下例子,指令
jmp *%eax
用寄存器 %eax 中的值作为跳转目标,而指令
jmp *(%eax)
以 %eax 中的值作为读地址,从存储器中读出跳转目标。
其他的跳转指令是根据条件码的某个组合,或者跳转,或者继续执行代码序列中下一条指令。请注意这些指令的名字和跳转条件与 set 指令是相匹配的。同 set 指令一样,一些底层的机器指令有多个名字。条件跳转只能是直接跳转。
在汇编代码中,跳转目标是用符号标号书写的。汇编器,以及后来的链接器,会产生跳转目标的适当编码。跳转指令有几种不同的编码,但是最常用的一些是 PC 相关的(PC-relative,PC = Program Counter)。也就是,它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为一、二或四个字节。第二种编码方法是给出“绝对”地址,用四个字节直接指定目标。汇编器和链接器会选择适当的跳转目的编码。
如下是一个与 PC 相关的寻址的例子,下面这个汇编代码的片段是编译 silly.c
文件所产生的。它包含两个跳转:第1行的 jle
指令前向跳转到更高的地址,而第8行的 jg
指令后向跳转到较低的地址。
注意,第2行是一条针对汇编器的命令(directive),它会使后面指令的地址从 16 的倍数处开始,而最多浪费 7 个字节。这条命令是为了使处理器能更优化地使用指令高速缓存存储器(instruction cache memory)。
汇编器产生的 “.o
” 格式的反汇编版本如下:
第2行的 lea 0x0(%esi),%esi
指令没有什么实际的效果。它是作为 6 个字节的空指令(nop
),使得下一条指令(第3行)的起始地址是 16 的倍数。
右边反汇编器产生的注释中,指令 1 的跳转目标明确指明为 0x1b
,指令7的是 0x10
。不过,观察指令的字节编码,会看到跳转指令 1 的目标编码(在第二个字节中)为 0x11(十进制17)。把它加上 0xa
(十进制10),也就是下一条指令的地址,就得到跳转目标地址0x1b(十进制27),也就是指令 8 的地址。
类似地,跳转指令7的目标用单字节、二进制补码表示编码为 0xf5(十进制-11)。将这个数加上 0x1b(十进制27),即指令 8 的地址,得到 0x10(十进制16),即指令3的地址。
正如这些例子说明的那样,当执行与 PC 相关的寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。 这种惯例可以追溯到早期的实现,当时,处理器会将更新程序计数器作为执行一条指令的第一步。
如下是链接后的程序反汇编的版本:
这些指令被重定位到不同的地址,但是第 1 行和第 7 行中跳转目标的编码并没有变。通过使用与 PC 相关的跳转目标编码,指令编码很简洁(只需要两个字节),而且目标代码可以不做改变就移到存储器中不同的位置。
3.6.4 翻译条件分支
C中的条件语句是用有条件和无条件跳转结合起来实现的。
如下给出了一个计算两数之差绝对值的函数的C代码:
原始的C代码
//原始的 C 代码
int absdiff(int x, int y)
{
if (x < y)
return y - x;
else
return x - y;
}
GCC 产生的汇编代码
movl 8(%ebp), %edx Get x
movl 12(%ebp), %eax Get y
cmpl %eax, %edx Compare x:y
jl .L3 If <, goto less
subl %eax, %edx Compute x-y
movl %edx, %eax Set as return value
jmp .L5 Goto done
.L3: less:
subl %edx, %eax Compute y-x as return value
.L5: done: Begin completion code
汇编代码实现首先比较两个操作数(第3行),设置条件码。如果比较的结果表明 x 小于 y,那么它就会跳转到计算 y-x 的代码块(第9行),否则就继续执行计算 x-y 的代码(第5行和第6行)。在这两种情况下,计算结果都存放在寄存器 %eax 中,到第 10 行结束,在此,它会执行栈完成代码(没有显示出来)。
C中的 if-else
语句的通用形式是这样的:
if (test-expr)
then-statement
else
else-statement
这里 test-expr
是一个整数表达式,它的取值为0(解释为“假”)或者为非0(解释为“真”)。两个分支语句中(then-statement 和 else-statement)只会执行一个。
对于这种通用形式,汇编实现通常会使用下面这种形式,这里,用C语法来描述控制流:
t = test-expr;
if (t)
goto true;
else-statement
goto done;
true:
then-statement
done:
也就是,汇编器为 then-statement 和 else-statement 产生各自的代码块,并插入条件和无条件分支,以保证能执行正确的代码块。
与汇编代码等价的 C 版本,更加紧密地遵循汇编代码的控制流。
int gotodiff(int x, int y)
{
int rval;
if (x < y)
goto less;
rval = x - y;
goto done;
less:
rval = y - x;
done:
return rval;
}
该代码中使用了C中的goto语句,这个语句类似于汇编代码中的无条件跳转。第6行的 goto less
语句会导致一个跳转,转移到底9行的标号less处,略过了第7行上的语句。请注意,通常认为使用 goto
语句是一种不好的编程风格,因为它会使代码难以阅读和调试。在讲解中使用 goto 语句,是为了构造描述汇编代码程序控制流的C程序。我们称这样的 C 程序为 “goto代码”。
3.6.5 循环
C提供了好几种循环结构,即while
、for
和 do-while
。汇编中没有相应的指令存在。作为替代,将条件测试和跳转组合起来实现循环的效果。有趣的是,大多数汇编器根据一个循环的 do-while
形式来产生循环代码,即使在实际程序中,这种形式用的相对较少。其他的循环会首先转换成 do-while
形式,然后编译成机器代码。
do-while 循环
do-while
语句的通用形式是这样的:
do
body-statement
while(test-expr);
循环的效果就是重复执行 body-statement,对 test-expr 求值,如果求值的结果为非零,就继续循环。注意,body-statement至少执行一次。
通常,do-while 的实现有下面这样的通用形式:
loop:
body-statement
t = test-expr;
if (t)
goto loop;
举个例子,Fibonacci 序列的递归定义:
F
1
=
1
F
2
=
1
F
n
=
F
n
−
1
+
F
n
−
2
,
n
≥
3
F_1 = 1 \\ F_2 = 1 \\ F_n = F_{n-1} + F_{n-2}, n \ge 3
F1=1F2=1Fn=Fn−1+Fn−2,n≥3
比如说,该序列的前 10 个元素是1、1、2、3、5、8、13、21、34和55。用 do-while
循环来实现,序列从
F
0
=
0
F_0 = 0
F0=0 和
F
1
=
1
F_1 = 1
F1=1 开始,而不是从
F
1
F_1
F1 和
F
2
F_2
F2 开始的。
int fib_dw(int n)
{
int i = 0;
int val = 0;
int nval = 1;
do {
int t = val + nval;
val = nval;
nval = t;
i++;
}while (i < n);
return val;
}
对应的汇编语言代码:
图中显示了实现这个循环的汇编代码,以及一张列出寄存器和程序值之间对应关系的表。在这个例子中,body-statement 是第 8 ~ 11 行,对
t
t
t、
v
a
l
val
val 和
n
v
a
l
nval
nval 赋值,并将
i
i
i 加1。这些功能是由汇编代码的第 2 ~ 5 行实现的。表达式
i
<
n
i<n
i<n 就是 test-expr
。第 6 行和第 7 行的跳转指令的测试条件实现了这个表达式。一旦退出循环,就会将
v
a
l
val
val 拷进寄存器 %eax,作为返回值(第 8 行)。
while循环
while语句的通用形式是这样的:
while(test-expr)
body-statement
它与 do-while 的不同之处在于对 test-expr 求值,在第一次执行 body-statement 之前,循环就可能中止了。直接翻译成使用 goto 语句的形式就是:
loop:
t = test-expr;
if (!t)
goto done;
body-statement
goto loop;
done:
这种翻译要求内循环,也就是执行次数最多的代码部分,里面有两条控制语句。相反,大多数 C 编译器将这段代码转换成 do-while 循环,用一个条件分支来在需要时省略循环体的第一次执行:
if(!test-expr)
goto done;
do
body-statement
while (test-expr);
done:
然后,这段代码可以转换成带 goto 语句的代码:
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if (t)
goto loop;
done:
作为一个例子,下图给出了一个用 while 循环实现的 Fibonacci 序列函数的代码。
注意,这次的递归从元素
F
1
(
v
a
l
)
F_1(val)
F1(val) 和
F
2
(
v
a
l
)
F_2(val)
F2(val) 开始。旁边的 C 函数 fib_w_goto
(b) 表明了这段代码是如何翻译成汇编的,而© 中的汇编代码非常接近于 fib_w_goto
中的 C 代码。
编译器进行了几个非常有趣的优化,可以在 goto
代码(b) 中看到。
- 首先,编译器不是使用变量 i i i 作为循环变量并且在每次重复时拿它与 n n n 做比较,而是引入了一个新的 n m i nmi nmi 循环变量,与原来的代码相比,它的值等于 n − i n-i n−i。这使得编译器只用三个寄存器作为循环变量,而不用四个。
- 其次,它将最原始的测试条件 ( i < n ) (i<n) (i<n) 优化成了 ( v a l < n ) (val<n) (val<n),因为 i i i 和 v a l val val 的初始值都是 1。这样一来,编译器就能完全消除变量 i i i 了。编译器常常利用变量的初始值来优化初始的测试,不过这使得解读汇编代码有点麻烦。
- 第三,为了循环的连续执行,要保证 i ≤ n i\le n i≤n,这样编译器就能假设 n m i nmi nmi 是非负的了。因此,它就能将 n m i ! = 0 nmi !=0 nmi!=0 而不是 n m i > = 0 nmi >=0 nmi>=0 作为循环条件来测试了。这样就在汇编代码中省略了一条指令。
for循环
for 循环的通用形式:
C语言标准说明,这样一个循环的行为与下面这段使用 while 循环的代码的行为是一样的:
即,程序首先会对初始表达式 init-expr
求值。然后进入循环,它会先对测试条件 test-expr 求值,如果测试结果为 “假” 就会退出,然后执行循环体 body-statement,最后对更新表达式 update-expr 求值。
这段代码编译后的形式是基于前面讲过的从 while 到 do-while 的转换的, 首先给出do-while 形式:
然后,将它转换成 goto 代码:
举个例子,如下给出了一个使用 for 循环的 Fibonacci 函数的实现:
将这段代码转换成 while 循环形式得到的代码与前文中图3.14给出的 fib_w
的代码一样。实际上,GCC 对两个函数产生的汇编代码就是一样的。
3.6.6 switch 语句
switch语句提供了根据一个整数索引值进行多重分支(multiway branching)的能力。在处理具有多种可能结果的测试时,这种语句特别有用。
它们不仅提高了 C 代码的可读性,而且通过使用一种称为 跳转表(jump table) 的数据结构使得实现更加高效。跳转表是一个数组,表项 i i i 是一个代码段的地址,这个代码段实现的是当开关索引值等于 i i i 时程序应该采取的动作。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。和使用一组很长的 if-else 语句相比,使用跳转表的优点是执行开关语句的时间与开关情况(switch cases)的数量无关。GCC 根据开关情况的数量和开关情况值的稀少程度(sparsity)来翻译开关语句。当开关情况数量比较多(如四个或更多),并且值的范围跨度比较小时,就会使用跳转表。
下面的 (a) 给出了一个 C switch 语句的示例。该例子有些特别的特征,包括情况标号(case lables)是不连续的(对于情况 101 和 105 是没有标号的),有些情况有多个标号(情况104 和 106),而有些情况则会落入其他情况(情况102),因为对应该情况的代码段没有以 break 语句结尾。
//(a) switch语句
int switch_eg(int x)
{
int result = x;
switch (x) {
case 100:
result *= 13;
break;
case 102:
result += 10;
/* Fall through */
case 103:
result += 11;
break;
case 104:
case 106:
result *= result;
break;
default:
result = 0;
}
return result;
}
//(b) 到扩展C的翻译
// 到扩展C的翻译给出了跳转表 jt 的结构,以及是如何访问它的。实际上C中是不允许这样的表和访问的。
/*Next line is not legal C */
code *jt[7] = {
loc_A, loc_def, loc_B, loc_C,
loc_D, loc_def, loc_D
};
int switch_eg_impl(int x)
{
unsigned xi = x - 100;
int result = x;
if (xi > 6)
goto loc_def;
/*Next goto is not legal C */
goto jt[xi];
loc_A: /* Case 100 */
result *= 13;
goto done;
loc_B: /*Case 102 */
result += 10;
/* Fall through */
loc_C: /*Case 103 */
result += 11;
goto done;
loc_D: /*Case 104, 106 */
result *= result;
goto done;
loc_def: /*Default case */
result = 0;
done:
return result;
}
下图3.16是编译 switch_eg
时产生的汇编代码。这段代码的行为用 C 的扩展形式来描述就是上面(b) 中的过程 switch_eg_impl
。我们说“扩展的” 是因为 C 本身并不提供支持这种跳转表所需的结构,因此我们的代码并不是合法的C。数组 jt 包含 7 个表项,每个都是一个代码块的地址。为此,我们扩展了C,增加了数据类型 code。
- 第1~4行建立起了跳转表的入口。为了保证当 x 的值小于 100 或 大于 106 时会执行 default 开关情况指定的计算,代码生成了一个等于 x-100 的无符号值 xi。对于介于 100 ~ 106 之间的 x 的值,xi 的值在 0 ~ 6 之间,因为 x-100 的负值会绕回成非常大的无符号数。因此,当 xi 大于 6 时,代码用 ja(无符号大于)指令来跳转到默认开关情况的代码。用 jt 来指向跳转表,代码会执行一个跳转,转移到表中表项 xi 处的地址。注意,这种形式的 goto 不是合法的 C 语句。
- 指令4实现的是到跳转表中某个表项的转移。因为是间接跳转,目标是从存储器中读出的。读的有效地址是由标号
.L10
指定的基地址加上变量 xi(放在寄存器 %eax 中)的伸缩值(伸缩因子值为4,因因为跳转表的每个表项都是 4 个字节)确定的。
在汇编代码中,跳转表是用下面这样的声明表示的,添加了一些注释:
这些声明表明,在叫做 “.rodata” (表示“只读数据”,“Read-Only Data”)的目标代码文件的段中,应该有一组 7 个 “长” 字(4 个字节),每个字的值都是与指定的汇编代码标号(例如,.L4
)相关的指令地址。标号 .L10
标志着这段分配的起始。与这个标号相对应的地址会作为间接跳转(指令4)的基地址。
在 switch_eg_impl
中(上文的(b) 代码),从标号 loc_A
开始,一直到 loc_D
和 loc_def
的代码块,实现了 switch 语句的五个不同的分支。可以观察到,当 x 超出 100 ~ 106 范围时(初始范围检查),或者当它等于101 或 105 时(根据跳转表),都会执行标号为 loc_def
的代码块。注意标号为 loc_B
的代码块是如何落入标号为 loc_C
的代码块的。