案例一:关于编译优化
请自写一段if- else简单分支程序,分别尝试对它进行不带优化、-O1优化和-O2优化,比较它们的机器级表达,并讨论优劣。
图一为不带优化、图二为O1优化、图三为O2优化、图四为原始C代码。
(1)
①不带优化
不带优化的代码很直接的对应了C代码的逻辑。首先设置栈帧,然后调用__isoc99_scanf函数两次来读取输入。然后使用cmp和jle指令来比较x和y的值,并根据结果执行减法操作。最后,它检查栈的完整性,返回结果。
优点是易于理解和调试,因为它直接反映了C代码的结构,而且非常容易理解,都是一些基础的操作。但是缺点是性能不是最优的,因为它包含了一些不必要的栈操作和检查,而这些操作可以使用一些更高效的指令来替代。
②O1优化
在O1优化级别下,编译器进行一些基本的优化了。比如,减少了栈帧的大小,并尝试重用栈上的空间来存储变量。此外,它还使用了一些更高效的指令来执行相同的操作,比如在这个例子中,编译器使用了lea指令来加载有效地址,这通常比直接使用mov指令更快;它还使用了cmovg指令来实现条件移动,这可以避免使用分支指令,从而提高性能。
③O2优化
在O2优化级别下,编译器会进行更强烈的优化。可能会重新排序指令,删除不必要的操作,并尝试将代码内联到调用者中。
(2)优劣讨论
不带优化:
优点: 便于理解和调试,直接反映了C代码的结构。
缺点: 性能可能不是最优的,包含很多不必要的栈操作和检查。
O1优化:
优点: 在保持代码结构相对清晰的同时提高了性能。使用了一些更高效的指令和技巧来减少不必要的操作。
缺点: 虽然比不带优化的代码更高效,但可能还没有达到最优性能。
O2优化:
优点: 通常能提供最优的性能。编译器会进行更强烈的优化,删除不必要的操作,并尝试将代码内联到调用者中。
缺点: 代码可能变得难以理解和调试,因为编译器会对原始C代码进行大量的转换和重排。
案例二:条件表达式的机器级表示
C中有多种方式实现分支,下面是用2种不同的方式实现分支的程序,这两种方式在C层面上是等价的。
方式一:用if-else实现
方式二:用条件表达式实现
请在Ubuntu环境下进行编译及反汇编,分析并回答:
1) 两种方式的机器级表达是否一致?(贴图展现)
2) 方式二(条件表达式实现分支)存在什么风险?有什么代价?
从提供的反汇编代码来看,两种方式的机器级表达在大体上是一致的(左图为if else,右图为三目运算符)。它们都包含了以下步骤:
将局部变量x和y的值加载到寄存器中、使用cmp指令比较x和y的值、根据比较结果使用条件跳转指令(jle)来执行不同的减法操作、将结果存储到寄存器或栈中,并准备返回。
方式二使用条件表达式来实现分支,这种方式在C语言层面上的语法确实很简洁,然而,在底层实现上,它并没有本质上不同于if-else语句,方式二在机器级表达上同样会生成条件跳转和减法操作等指令序列。
在风险方面,使用条件表达式实现分支本身并没有引入新的风险,因为它在语义上与if-else语句是等价的。但是我们可以发现,过度使用条件表达式可能会使代码难以阅读和维护,尤其是在复杂的逻辑表达式中,比如当条件冗长时,一条语句可能就占了几十个字长。此外,如果条件表达式的条件部分计算一些复杂的操作(如函数调用),那么这些操作可能会在每次条件判断时都被执行,导致性能大大降低。
在代价方面,使用条件表达式与if-else语句在性能上通常没有显著差别,因为编译器会将其转换为类似的机器代码,这一点我们由objdump反汇编代码也能看出。然而,如果编译器无法对条件表达式进行有效的优化,或者条件表达式的条件部分较为复杂,那么可能会产生稍微多一些的指令,也可能会稍微降低一些执行速度。
案例三:循环的机器级表示
在C语言中,我们可以用for、while、do-while和goto四种不同的方式实现循环程序,请自己设定一种循环情形,用以上四种方式分别写出C代码,并比较它们的机器级表达(均不带编译优化)是否一样?
在不带编译优化的情况下,在x86-64指令集架构,gcc 13.2编译器环境下,我设计了一个循环打印1~10的整数的C代码。根据汇编代码,我们可以发现,for和while两种方式的汇编代码是完全相同的,而这两种循环与do-while、goto有少许差异。
①for循环的汇编代码包含了初始化计数器、循环体执行、更新计数器和条件判断等步骤,先将计数器初始化为1,并在每次迭代后增加1。条件判断是检查计数器是否小于或等于10。如果是,则继续执行循环体;否则,退出循环。
②while循环的汇编代码与for循环的汇编代码完全相同。这是因为在这个特定例子中,for循环和while循环的逻辑结构是相同的:先检查条件,然后执行循环体,最后更新计数器。编译器在这种情况下选择了类似的实现方式。
③do-while循环的汇编代码在结构上略有不同。它首先执行循环体,然后更新计数器,最后检查条件。这与for和while循环先检查条件后执行循环体的顺序不同。然而,由于这个差异仅在于循环的开始和结束部分,所以整体上三种循环的汇编代码仍然非常相似。
④goto循环的汇编代码在循环条件和跳转指令上与其他三种循环有所不同。它使用了一个无条件跳转指令jmp来重复执行循环体,而不是像其他循环那样使用条件跳转指令jle。此外,goto循环的条件判断是检查计数器是否大于9(即小于或等于10的补码表示),如果是,则跳转到循环体的末尾并更新计数器;否则,退出循环。
案例四:多情形分支-负数的情况
以课堂上讲授的多情形分支Switch.c程序为例,分析以下编译结果:
情形值x为有符号数,为什么编译结果却使用了ja(用于无符号数)指令来判断情形,而不是用于有符号数的jg指令?
附Switch.c代码:
请修改一下switch.c代码,增加一种case为负数的情况(比如-1或-2),进行编译后再观察看看,请贴图说明。(再联想之前问的为何使用ja而非jg)
在负数较小情况下,编译器是会把负数增加到0或者整数,然后再加入跳转表中。举个例子,比如在switch(i)中,原先是case 1 2 3 4 5 6,修改为case -1 -2 3 4 5 6,范围由6变为8。因为有了-1、-2这两个负数的加入,而且因为这两个负数绝对值不是很(负数绝对值过大时会使用jl等跳转语句),所以会先把i先全都+2,补齐为1 0 5 6 7 8,全部转换成了正数(类似于数组下标),然后再与8进行cmp(因为最大值是8。原先是与6进行cmp),这样i就还是正数,可以使用ja,而不是jg。详细对比见下图(x86-64 gcc 13.2编译器处理结果):