控制逻辑:表达式和语句是如何协调程序运行的


表达式

表达式(expression)是由一系列运算符与操作数(operand)组成的一种语法结构。通常来说,表达式的求值(evaluation)过程,实际上就是根据运算符的优先级和结合性,来对表达式和它所包含的子表达式进行递归求值的过程。从编译的角度来看,这个过程中所涉及到的操作数的实际求值顺序会在语法分析阶段被确定,并体现在源码对应的抽象语法树(AST,Abstract Syntax Tree)上。

表达式提供了这样一种能力:能够让数据同时参与到多个操作符的不同计算过程中。而通过提供对运算符优先级与结合性的规则限制,表达式可以控制整个计算过程的有序进行。

语句

语句(statement)是用来描述程序的基本构建块。和表达式不同,语句是构成 C 程序的最大粒度单元,在它的内部,可以包含有简单或复杂的表达式结构,但也可以不包含任何内容。除此之外,语句在执行时不返回任何结果,但可能会产生副作用。

在 C 语言中,语句可以被分为复合语句、表达式语句、选择语句、迭代语句、跳转语句五种类型。但无论是哪种类型,语句都必须以分号结尾,并按从上到下的顺序依次执行。

其中,复合语句是指由花括号 “{}” 标记的一块区域。在这个区域中,我们可以放置声明(declaration)和语句,而最常见的一种复合语句便是函数体。在函数体内部,我们可以定义变量,并通过结合各类其他语句来实现函数的功能。而表达式语句则是直接由表达式外加一个分号组成的语句,比如函数调用语句或变量赋值语句。当然,表达式语句中的表达式也可以为空,这样就成为了仅由一个 “;” 组成的空语句。

int foo(int x, int y) {  // 复合语句;
  int sum = x + y; // 表达式语句;
  if (sum < 0) {  // 复合语句;
    sum = -sum;  // 表达式语句;
  }
  return sum;
}
选择语句

同其他大多数语言类似,在 C 语言中,选择语句主要是指由 if…else 和 switch…case 这两种语法结构组成的语句。首先来看 if…else 语句:

C语言ifelse

通过红框内的汇编代码可以看到,变量 v 的值被存放在栈内存中地址为 rbp 寄存器的值减去 4 的位置上。程序使用多个标签(如 .L2、.L3 等),分别划分不同分支对应的处理逻辑,而分支的判断过程则是由指令 cmp 与条件跳转指令 je 与 jne 共同完成的。汇编代码和 C 代码的整体逻辑基本是一一对应的关系。因此,在这种情况下,为了尽量保持程序的执行性能,你可以将命中几率较大的条件语句放在较前的位置上。

接着,我们再来看看 switch…case 语句。

C语言switchcase

首先汇编代码会通过 cmp 指令,判断寄存器 eax 中的值,即变量 v 的值是否大于 60。若判断成立,则直接将程序跳转到标签 .L2 处,并将数字 4 作为返回值;若条件不成立,程序将继续执行。接下来代码会基于变量 v 的值,来产生一个用于参与后续运算符的 “token” 值。这个值的生成步骤如下所示:

  1. 将寄存器 edx 的值设为 1;
  2. 将寄存器 ecx 的值设为变量 v 的值;
  3. 将寄存器 rdx 中的值左移 v 位(值被扩展为 64 位);
  4. 将此时寄存器 rdx 中的值移动到 rax 中留作待用。

接下来,程序完成了对变量 v 的值的第一次筛选过程。这个过程很好理解,如果将其中第一行指令 movabs 的立即数操作数 1154047404513689600 以 64 位二进制的形式展开,你会发现其中只有第 50 和 60 位被置位。

而第二行的 and 指令,会将这个超长的立即数与之前根据变量 v 的值进行移位而得来的 token 值进行“与”操作。若操作得到的结果不为 0,则表示 token 值的第 50 或 60 位肯定不为 0,即变量 v 的值为 50 或 60。否则,变量 v 的值则不符合该 case 语句的筛选条件。到这里,筛选的基本逻辑相信你已经清楚了。

不过,通过“位映射”的方式进行分支筛选,并不能完美地覆盖所有情况。比如,当 case 语句的筛选值过大,无法使用寄存器来进行映射时,默认优化条件下,编译器会将 switch…case 的实现“回退”到与 if…else 类似的方式。也就是说,使用 cmp 指令与条件跳转指令来进行分支的筛选与转移。当然,具体采用哪种实现策略依据编译器的不同而有所不同。

除了上面介绍的 if…else 与 switch…case 语句实现方式外,在高优化等级下,编译器还可能会采用一种名为“跳表”的方式,来实现这两种条件选择语句。

C语言高优化打开后的switchcase语句

跳表是一种用空间换时间的条件匹配策略。

首先,标注为红色的汇编代码将变量 v 的值减去了选择语句中最小匹配条件的值,这里也就是 10。然后,程序通过 cmp 与 ja 指令,判断变量 v 的值是否超过了选择语句中最大匹配条件与最小匹配条件之间的差值,这里也就是 30。若是,则程序直接跳转到标签 .LBB0_3 处,并返回数值 3。

否则,程序就会使用跳表来寻找变量 v 的值对应的正确分支。所谓跳表,即在一段连续内存中存放的,可用于辅助查找正确目标地址的地址信息。在上面这个例子中,跳表从标签 .LJTI0_0 处开始。在这段内存中,连续存放了筛选值 10 到 40 区间内,每一个整数值对应的正确分支处理地址。接下来的蓝色代码保存了当跳表第 0 项“命中”时,函数需要返回的值。

假设在调用函数 foo 时,传入变量 v 的值为 20。虚线框中的 jmp 指令在执行时,会根据 v 的值在跳表中找到它所对应的正确分支地址。由于这里 rdi 寄存器中的值为 10(20 - 10),因此正确的分支处理地址便是跳表中第十项对应的值。这里可以看到,在 .LJTI0_0 标签 +80 字节的位置(.quad 代表 8 字节数据)处,正对应着标签 .LBB0_4 的地址。而该标签的位置,正是变量 v 为值 20 时的正确分支处理地址。

除了上面提到的这些编译器在实现分支语句时使用的常用方式外,根据分支语句的具体情况,编译器还可能会采用某些针对特定形态代码的专用优化。而即使针对最“原始”的 cmp 加条件跳转语句组合这种实现方式,编译器也会根据 C 源代码的情况,适当使用“二分法”等优化策略,来加快条件的筛选过程。

总之,选择语句编译器通常会采用位映射法、跳表法、基于二分法的测试与条件跳转语句的方式来实现它们。

迭代语句

在 C 语言中,迭代语句主要包含 do…while、for、while 这三种基本语法形式。这些语句除了在执行细节上有些许差异外,其对应的汇编实现思路大同小异。这里我以 do…while 语句为例来讲解,具体代码如下所示:

C语言dowhile语句

可以看到,在真正对变量 v 进行条件判断之前,程序已经执行了一次 printf 函数,而这便是 do…while 语句相较于其他迭代语句的特点。迭代过程以 .L2 标签作为每次的起始点,每次迭代都遵循着“先执行循环体,再判断条件”的规则。条件的判断和执行转移流程则分别由指令 test 与 jne 负责进行。

即使是在高优化等级下,C 语言中的这三种基本迭代语句在机器层面的汇编实现方式也不会有较大的差异,但这也并不意味着你可以随意使用它们。至少对于 do…while 与 while 而言,它们在执行细节上存在着差异,如果不假思索地使用,很可能会给你的程序招致不必要且难以调试的 BUG。

跳转语句

C 语言中的跳转语句主要指那些可以改变程序执行流程的语法结构,它们主要包括以下四种类型:

  • break 语句;
  • continue 语句;
  • return 语句;
  • goto 语句。

在 C 代码中,用于控制程序执行逻辑的大部分语句,其背后都是通过条件跳转语句来实现的。编译器通过代码分析,可以找到程序中可能的“跳入点”与“跳出点”,并在机器指令层面通过 je 等条件跳转指令,来控制程序的执行流程在这些点之间进行转移。


Author: Andy Zhang
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source Andy Zhang !
评论
  TOC