基本上可以通过三种方法来完成此操作。我会一次一个地回顾它们。
一种方法基本上就是您在问题中所描述的:进行比较,然后分支到分别实现两种可能性的代码。例如:
cmp [a], 13 ; compare 'a' to 13, setting flags like subtraction
jge GreaterThanOrEqual ; jump if 'a' >= 13, otherwise fall through
mov eax, [x * 0 * sizeof(x[0])] ; EAX = x[0]
jmp Next ; EAX now loaded with value, so do unconditional jump
GreaterThanOrEqual:
mov eax, [x * 1 * sizeof(x[0])] ; EAX = x[1]
; EAX now loaded with value; fall through
Next:
mov [y], eax ; store value of EAX in 'y'
通常,编译器会尝试在寄存器中保留更多值,但这应该让您了解基本逻辑。它进行比较,然后分支到读取/加载的指令x[1]
或落入读取/加载的指令x[0]
。然后,它转移到一条指令,将该值存储到y
.
您应该能够看到,由于需要所有分支,因此效率相对较低。因此,优化编译器不会生成这样的代码,尤其在简单的情况下,你有一个基本的三元表达式:
(a >= 13) ? 1 : 0
or even:
(a >= 13) ? 125 : -8
有一些位操作技巧可用于进行此比较并获取相应的整数without曾经不得不做一个分支。
这给我们带来了第二种方法,即使用SETcc
操作说明。这cc
部分代表“条件代码”,所有条件代码与条件跳转指令的条件代码相同。 (事实上,您可以将所有条件跳转指令写为Jcc
。) 例如,jge
表示“大于或等于则跳转”;相似地,setge
意思是“如果大于或等于则设置”。简单的。
诀窍是关于SETcc
是它设置了一个字节大小的寄存器,这基本上意味着AL
, CL
, DL
, or BL
(还有更多选择;您could设置这些寄存器之一的高字节,和/或在 64 位长模式下有更多选项,但这些是操作数的基本选择)。
以下是实现此策略的代码示例:
xor edx, edx ; clear EDX
cmp [a], 13 ; compare 'a' to 13, setting flags like subtraction
setge dl ; set DL to 1 if greater-than-or-equal, or 0 otherwise
mov eax, [x * edx * sizeof(x[0])]
mov [y], eax
很酷,对吧?支行被淘汰。需要的0或1直接加载到DL
,然后将其用作负载的一部分(MOV
操作说明)。
这里唯一有点令人困惑的是你需要知道DL
是全32位的低字节EDX
登记。这就是为什么我们需要预先清除完整的EDX
, since setge dl
只影响低字节,但我们想要完整的EDX
为 0 或 1。事实证明,对整个寄存器进行预清零是在所有处理器上执行此操作的最佳方法 https://stackoverflow.com/questions/33666617/what-is-the-best-way-to-set-a-register-to-zero-in-x86-assembly-xor-mov-or-and,但还有其他方法,例如使用MOVZX
after the SETcc
操作说明。链接的答案对此进行了非常详细的说明,因此我不会在这里进行详细说明。关键点就在于SETcc
仅设置寄存器的低字节,但后续指令需要entire32位寄存器要有值,所以需要消除高字节中的垃圾。
不管怎样,当你编写类似的代码时,编译器 99% 的情况下都会生成这样的代码y = x[a >= 13]
. The SETcc
指令为您提供了一种根据一个或多个标志的状态设置字节的方法,就像您可以在标志上进行分支一样。这基本上就是您所想到的允许直接访问标志的指令。
这实现了以下逻辑
(a >= 13) ? 1 : 0
但如果你想做怎么办
(a >= 13) ? 125 : -8
就像我之前提到的?嗯,你还是用SETcc
指令,但之后您会进行一些花哨的位调整,以将结果 0 或 1 “修复”为您实际想要的值。例如:
xor edx, edx
cmp [a], 13
setge dl
dec edx
and dl, 123
add edx, 125
; do whatever with EDX
这适用于几乎任何二进制选择(两个可能的值,取决于条件),并且优化编译器足够聪明来解决这个问题。仍然是无分支代码;很酷。
还有第三种方法可以实现这一点,但它在概念上与我们刚才讨论的第二种方法非常相似。它使用一个有条件的移动指令,这只是基于标志状态进行无分支设置的另一种方法。条件移动指令是CMOVcc
, where cc
再次引用“条件代码”,与前面的示例完全相同。这CMOVcc
该指令大约在 1995 年随 Pentium Pro 引入,此后一直存在于所有处理器中(不是 Pentium MMX,而是 Pentium II 及更高版本),因此基本上是您今天看到的所有处理器。
代码非常相似,只是顾名思义,它是一个条件移动,因此需要更多的初步设置。具体来说,您需要将候选值加载到寄存器中,以便您可以选择正确的值:
xor edx, edx ; EDX = 0
mov eax, 1 ; EAX = 1
cmp [a], 13 ; compare 'a' to 13 and set flags
cmovge edx, eax ; EDX = (a >= 13 ? EAX : EDX)
mov eax, [x * edx * sizeof(x[0])]
mov [y], eax
请注意,移动EAX
into EDX
is 有条件的- 仅当标志指示条件时才会发生ge
(大于或等于)。因此,它可以计算出基本的 C 三元运算符,如指令右侧的注释中所述。如果标志表明ge
, then EAX
被移入EDX
。否则,什么都不会移动,并且EDX
保持其原始值。
请注意,尽管某些编译器(特别是英特尔的编译器,称为 ICC)更喜欢CMOV
指示结束SET
指令,这与我们之前看到的之前的实现相比没有任何优势SETGE
。事实上,这确实是次优的。
When CMOV
真正有用的是允许您消除获取除旧的 0 或 1 以外的值所需的位操作代码。例如:
mov edx, -8 ; EDX = -8
mov eax, 125 ; EAX = 125
cmp [a], 13 ; compare 'a' to 13 and set flags
cmovge edx, eax ; EDX = (a >= 13 ? EAX : EDX)
; do whatever with EDX
现在指令更少了,因为正确的值被直接移入EDX
寄存器,而不是设置 0 或 1,然后必须将其操作为我们想要的值。因此编译器将使用CMOV
指令(当针对支持它们的处理器时,如前所述)来实现更复杂的逻辑,例如
(a >= 13) ? 125 : -8
即使他们could使用其他方法之一来完成它们。你也need当条件两侧的操作数不是编译时常量(即它们是寄存器中的值,仅在运行时已知)时,条件移动。
这有帮助吗? :-)
我试图查看 gcc 为该代码示例吐出的内容,但不可能从它抛出的所有额外垃圾中找出逻辑。
是的。我有一些提示给你:
-
将您的代码缩减为很简单功能是only做你想学习的事情。您需要将输入作为参数(以便优化器不能简单地折叠常量),并且您需要返回函数的输出。例如:
int Foo(int a)
{
return a >= 13;
}
返回一个bool
也会在这里工作。如果您使用条件运算符返回 0 或 1 以外的值,则需要返回int
, 当然。
不管怎样,现在你可以看到exactly编译器生成什么汇编指令来实现这一点,没有任何其他噪音。确保您已启用优化;查看调试代码并没有什么指导意义very noisy.
-
确保您要求 GCC 使用 Intel/MASM 格式生成汇编列表,即much比它的默认格式 GAS/AT&T 语法更容易阅读(至少在我看来)。上面的所有汇编代码示例都是使用 Intel 语法编写的。所需的咒语是:
gcc -S -masm=intel MyFile.c
where -S
生成输入源代码文件的汇编列表,并且-masm=intel
将程序集列表语法格式切换为 Intel 样式。
-
使用像这样的好工具Godbolt 编译器资源管理器 https://gcc.godbolt.org,它可以自动执行所有这些操作,从而大大缩短周转时间。另一个好处是,它对汇编指令进行颜色编码,以与原始源代码中的 C 代码行相匹配。
这是您用来研究此内容的示例 https://gcc.godbolt.org/#g:!((g:!((g:!((h:codeEditor,i:(j:1,source:'bool+Boolean(int+a)%0A%7B%0A++++return+a+%3E%3D+13%3B%0A%7D%0A%0Aint+OneOrZero(int+a)%0A%7B%0A++++return+a+%3E%3D+13%3B%0A%7D%0A%0Aint+ComplexTernary(int+a)%0A%7B%0A++++return+a+%3E%3D+13+%3F+125+:+-8%3B%0A%7D%0A'),l:'5',n:'0',o:'C%2B%2B+source+%231',t:'0')),header:(),k:25.109860278204515,l:'4',m:100,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:g71,filters:(___0:(),b:'0',commentOnly:'0',directives:'0',intel:'0',jquery:'3.2.1',length:1,prevObject:(___0:(),length:1,prevObject:(___0:(jQuery3210076433803653344511:(display:'')),length:1))),options:'-m32+-O3+-march%3Dhaswell',source:1),l:'5',n:'0',o:'x86-64+gcc+7.1+(Editor+%231,+Compiler+%231)',t:'0')),header:(),k:32.88792518846509,l:'4',m:100,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:g71,filters:(___0:(),b:'0',commentOnly:'0',directives:'0',intel:'0',jquery:'3.2.1',length:1,prevObject:(___0:(),length:1,prevObject:(___0:(jQuery3210076433803653344511:(display:'')),length:1))),options:'-m32+-O3+-march%3Dpentium',source:1),l:'5',n:'0',o:'x86-64+gcc+7.1+(Editor+%231,+Compiler+%232)',t:'0')),k:42.00221453333042,l:'4',n:'0',o:'',s:0,t:'0')),l:'2',n:'0',o:'',t:'0')),version:4。原始来源位于最左侧。中间窗格显示了现代处理器的 GCC 7.1 汇编输出,它支持CMOV
指示。最右边的窗格显示了 GCC 7.1 对于一个非常旧的处理器的汇编输出,它确实not支持CMOV
指示。很酷,对吧?您可以轻松操作编译器开关并观察输出如何变化。例如,如果你这样做-m64
(64 位)而不是-m32
(32位),然后你会看到参数是在寄存器中传递的(EDI
),而不是在堆栈上传递并必须作为函数中的第一条指令加载到寄存器中。