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

📅 2026/7/13 9:47:34
现代C:控制逻辑:表达式和语句是如何协调程序运行的?
引言上一讲我主要介绍了编译器是如何使用机器指令来实现各类 C 运算符的。在应用程序的构建过程中运算符仅作为“计算单元”为程序提供了基本的“原子”计算能力。而数据如何同时使用多种不同的运算符以及按照怎样的逻辑来在不同位置上“流动”这一切都是由表达式和语句进行控制的。这一讲就让我们来看看 C 语言中用来描述程序运行逻辑的这两种控制单元“背后的故事”。表达式表达式expression是由一系列运算符与操作数operand组成的一种语法结构。其中操作数是参与运算符计算的独立单元也即运算符所操作的对象。操作数可以是一个简单的字面量值比如数字 2、字符串 “Hello, world!”也可以是另一组复杂的表达式。举个例子在表达式 (1 2) * 3 4 / 5 中乘法运算符 “*” 所对应的两个操作数分别是字面量数值 3和子表达式 (1 2)。通常来说表达式的求值evaluation过程需要依据所涉及运算符的优先级和结合性的不同而按一定顺序进行。我们一起来看看上面提到的 (1 2) * 3 4 / 5 这个表达式的计算流程。首先需要根据表达式中运算符优先级的不同来决定最先进行哪一部分运算。运算符的优先级很好理解由于乘法运算符 “*” 与除法运算符 “/” 的优先级高于加法运算符 “”因此在计算整个表达式的值时需要首先对由这两个运算符组成的子表达式进行求值。当从上一步中“筛选出”的待计算运算符多于 1 个时我们就需要再判断运算符的结合性来决定优先计算哪一个。因为乘法运算符和除法运算符均具有左结合性因此由左侧乘法运算符构成的子表达式需要被优先求值。当我们以这个表达式为视角进行观察时参与表达式计算的操作数分别为子表达式 (1 2)以及字面量数值 3。这里我们需要分别对这两部分进行求值直至乘法运算符 “*” 两边的操作数可以直接参与计算为止。但需要注意的是C 标准中并未规定运算符两侧操作数的具体求值顺序因此具体方式由编译器选择。因此我们可以说对表达式的求值过程实际上就是根据运算符的优先级和结合性来对表达式和它所包含的子表达式进行递归求值的过程。从编译的角度来看这个过程中所涉及到的操作数的实际求值顺序会在语法分析阶段被确定并体现在源码对应的抽象语法树ASTAbstract Syntax Tree上。为了方便进一步观察我将这个表达式整合到了下面的 C 代码里并保存在文件 main.c 中int foo(void) { return (1 2) * 3 4 / 5; }然后借助 Clang 编译器提供的 “-ast-dump” 选项我们可以编译并打印出这段 C 代码对应的 AST 结构。完整的编译命令如下clang -Xclang -ast-dump -fsyntax-only main.c上面的命令执行完毕后部分输出结果如下图所示AST 作为用于表示源代码语法结构的一种树形数据结构语法分析器会将表达式中操作数的整体求值顺序映射到树的结构上。因此当我们以后序遍历LRD的方式遍历这棵树时便可以直接得到正确的表达式求值顺序。对于上面的 AST 来说由叶子结点组成的子树需要被最先求值因此我们首先可以得到括号内加法表达式的计算结果 3。然后该结果将作为叶子结点上的操作数参与乘法运算符的计算从而得到计算结果 9。接下来除法运算符所在的子表达式经过求值得到结果 0。最后该值再作为最后一个加法运算符的操作数与字面量值 9 相加进而得到整个表达式的最终计算结果 9。你可以看到表达式提供了这样一种能力能够让数据同时参与到多个操作符的不同计算过程中。而通过提供对运算符优先级与结合性的规则限制表达式可以控制整个计算过程的有序进行。语句语句statement是用来描述程序的基本构建块。和表达式不同语句是构成 C 程序的最大粒度单元在它的内部可以包含有简单或复杂的表达式结构但也可以不包含任何内容。除此之外语句在执行时不返回任何结果但可能会产生副作用。在 C 语言中语句可以被分为复合语句、表达式语句、选择语句、迭代语句、跳转语句五种类型。但无论是哪种类型语句都必须以分号结尾并按从上到下的顺序依次执行。其中复合语句是指由花括号 “{}” 标记的一块区域。在这个区域中我们可以放置声明declaration和语句而最常见的一种复合语句便是函数体。在函数体内部我们可以定义变量并通过结合各类其他语句来实现函数的功能。而表达式语句则是直接由表达式外加一个分号组成的语句比如函数调用语句或变量赋值语句。当然表达式语句中的表达式也可以为空这样就成为了仅由一个 “;” 组成的空语句。在下面这段代码里我标注出了其中使用到的复合语句与表达式语句。你可以通过它们来加深对这两种语句的理解。int foo(int x, int y) { // 复合语句 int sum x y; // 表达式语句 if (sum 0) { // 复合语句 sum -sum; // 表达式语句 } return sum; }这两种类型的语句它们的具体结构依程序设计细节的不同而不同因此这里我们不再做更多的讨论。相对的在 C 语言中选择语句、迭代语句、跳转语句都有着它们相对应的特定语法结构。因此接下来我们重点看看这几类语句探究编译器是如何实现它们的。选择语句同其他大多数语言类似在 C 语言中选择语句主要是指由 if…else 和 switch…case 这两种语法结构组成的语句。它们的使用方式你应该很熟悉这里就不多讲了。让我们直接通过一个例子观察编译器在默认情况下是如何实现它们的。首先来看 if…else 语句如上图所示在左侧的 C 函数 foo 中我们使用 if…else 语句构建了一个简单的程序逻辑。if 语句会在每一个条件分支中检测函数参数 v 的值并根据匹配情况返回一个数值。若所有情况都没有命中则最后的 else 语句生效直接返回数值 4。相应的在右侧我们可以看到这个函数对应的汇编代码。在这里通过红框内的汇编代码可以看到变量 v 的值被存放在栈内存中地址为 rbp 寄存器的值减去 4 的位置上。程序使用多个标签如 .L2、.L3 等分别划分不同分支对应的处理逻辑而分支的判断过程则是由指令 cmp 与条件跳转指令 je 与 jne 共同完成的。汇编代码和 C 代码的整体逻辑基本是一一对应的关系。因此在这种情况下为了尽量保持程序的执行性能你可以将命中几率较大的条件语句放在较前的位置上。接着我们再来看看 switch…case 语句。这里我只列出了部分汇编代码但足够你理解编译器对 switch…case 语句的实现细节了。其中标注为红色的汇编代码会通过 cmp 指令判断寄存器 eax 中的值即变量 v 的值是否大于 60。若判断成立则直接将程序跳转到标签 .L2 处并将数字 4 作为返回值若条件不成立程序将继续执行。接下来蓝色部分的代码会基于变量 v 的值来产生一个用于参与后续运算符的 “token” 值。这个值的生成步骤如下所示将寄存器 edx 的值设为 1将寄存器 ecx 的值设为变量 v 的值将寄存器 rdx 中的值左移 v 位值被扩展为 64 位将此时寄存器 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 语句实现方式外在高优化等级下编译器还可能会采用一种名为“跳表”的方式来实现这两种条件选择语句。下面是用这种方式修改后的 switch…case 语句实现你可以先观察下并思考这种方式的实现思路。可以看到这里我们将 switch…case 语句中分支筛选的“跨度”进行了减小即将其中的最大分支匹配条件由 60 减小到了 40。跳表是一种用空间换时间的条件匹配策略让我们通过上图右侧的汇编代码来了解它的实现过程。首先标注为红色的汇编代码将变量 v 的值减去了选择语句中最小匹配条件的值这里也就是 10。然后程序通过 cmp 与 ja 指令判断变量 v 的值是否超过了选择语句中最大匹配条件与最小匹配条件之间的差值这里也就是 30。若是则程序直接跳转到标签 .LBB0_3 处并返回数值 3。否则程序就会使用跳表来寻找变量 v 的值对应的正确分支。所谓跳表即在一段连续内存中存放的可用于辅助查找正确目标地址的地址信息。在上面这个例子中跳表从标签 .LJTI0_0 处开始。在这段内存中连续存放了筛选值 10 到 40 区间内每一个整数值对应的正确分支处理地址。接下来的蓝色代码保存了当跳表第 0 项“命中”时函数需要返回的值。假设在调用函数 foo 时传入变量 v 的值为 20。虚线框中的 jmp 指令在执行时会根据 v 的值在跳表中找到它所对应的正确分支地址。由于这里 rdi 寄存器中的值为 1020 - 10因此正确的分支处理地址便是跳表中第十项对应的值。这里可以看到在 .LJTI0_0 标签 80 字节的位置.quad 代表 8 字节数据处正对应着标签 .LBB0_4 的地址。而该标签的位置正是变量 v 为值 20 时的正确分支处理地址。除了上面提到的这些编译器在实现分支语句时使用的常用方式外根据分支语句的具体情况编译器还可能会采用某些针对特定形态代码的专用优化。而即使针对最“原始”的 cmp 加条件跳转语句组合这种实现方式编译器也会根据 C 源代码的情况适当使用“二分法”等优化策略来加快条件的筛选过程。迭代语句在 C 语言中迭代语句主要包含 do…while、for、while 这三种基本语法形式。这些语句除了在执行细节上有些许差异外其对应的汇编实现思路大同小异。这里我以 do…while 语句为例来讲解具体代码如下所示可以看到在真正对变量 v 进行条件判断之前程序已经执行了一次 printf 函数而这便是 do…while 语句相较于其他迭代语句的特点。迭代过程以 .L2 标签作为每次的起始点每次迭代都遵循着“先执行循环体再判断条件”的规则。条件的判断和执行转移流程则分别由指令 test 与 jne 负责进行。即使是在高优化等级下C 语言中的这三种基本迭代语句在机器层面的汇编实现方式也不会有较大的差异但这也并不意味着你可以随意使用它们。至少对于 do…while 与 while 而言它们在执行细节上存在着差异如果不假思索地使用很可能会给你的程序招致不必要且难以调试的 BUG。跳转语句C 语言中的跳转语句主要指那些可以改变程序执行流程的语法结构它们主要包括以下四种类型break 语句continue 语句return 语句goto 语句。其中return 语句的执行细节涉及到了函数的调用与返回因此我会在 05 讲中为你详细介绍。而对于另外三种语句相信就算不参考实际代码对于它们的实现“套路”你也已经心中有数因为它们的基本功能均是改变程序的具体执行流程。在 C 代码中用于控制程序执行逻辑的大部分语句其背后都是通过条件跳转语句来实现的。编译器通过代码分析可以找到程序中可能的“跳入点”与“跳出点”并在机器指令层面通过 je 等条件跳转指令来控制程序的执行流程在这些点之间进行转移。也就是说只要理解了其他语句实现中对条件跳转指令的使用方式这里你就能融会贯通。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。今天我主要讲解了 C 语言中用于描述程序运行逻辑的两种“控制单元”即表达式和语句背后的实现细节。表达式作为一种表达计算的基本语法结构对它的求值过程需要根据参与运算符的结合性与优先级按一定顺序进行。而计算的具体顺序则会在语法分析阶段由编译器直接体现在对应的 AST 结构形态上。语句是程序的基本构建块通过不同种类语句的组合使用我们可以控制程序的执行逻辑。C 语言中的语句主要包括复合语句、表达式语句、选择语句、迭代语句以及跳转语句共五种。其中由于前两种语句的展现形式较为动态因此我着重讲解了语法结构和功能较为固定的后三种语句。选择语句包含 if…else 与 switch…case 两种类型编译器通常会采用位映射法、跳表法、基于二分法的测试与条件跳转语句的方式来实现它们。迭代语句则包含 while 语句、do…while 语句以及 for 语句编译器实现它们时通常也是采用基本的测试与条件跳转但三种迭代语句在执行细节上稍有差异。最后的跳转语句包含 return 语句、continue 语句、break 语句以及 goto 语句。它们作为可以改变程序执行顺序的语句大多也以类似的方式实现。