写代码这么多年我见过太多人在 x86 汇编里栽在同一个地方明明CMP指令已经把两个数比完了标志位也摆在那里结果想拿这个比较结果做逻辑运算时却只能笨拙地跳转、分支、再跳回来。直到你把SETcc这一族条件置位指令弄明白才会意识到——原来标志位可以直接变成 0 或 1 的整数根本不需要打断控制流。这篇内容适合正在读汇编、写编译器后端、做逆向分析、或者只是好奇xor eax, eax; cmp ...; setl al为什么天天出现在反汇编窗口里的朋友。我会从 EFLAGS 标志位的基本功讲起把无符号和有符号比较的判定逻辑拆开再带上几个能直接抄的实战代码和经典踩坑现场。1. 为什么需要 SETxx标志位是“状态”不是“数值”1.1 CMP/TEST 只改标志不写结果先看一个基础问题CMP EAX, EBX执行完以后EAX 和 EBX 的值变了吗没有。CMP本质上就是做一次减法EAX - EBX但减法结果直接被丢弃唯一留下的痕迹就是 EFLAGS 寄存器里那几位标志位。TEST同理它做按位与也丢弃结果只更新标志。这就是 x86 的一个设计特点比较操作把“结论”存在了状态寄存器里而不是放在通用寄存器里。状态标志是隐式的、瞬时的你没法直接把它赋值给某个 C 语言变量。所以当你写下int r (a b);这行 C 代码时编译器必须想办法把标志位翻译成一个真正的整数 0 或 1。最直接的做法是跳转:cmp edi, esi jge .Lfalse mov eax, 1 ret .Lfalse: xor eax, eax ret这当然可行但它把线性代码切成了两个基本块。如果这个比较结果要参与后续算术运算而分支又很难预测流水线会被打乱。1.2 从 Jcc 到 SETcc控制流与数据流的转换SETcc的作用是把标志位的状态“捕获”成一个字节。它本身不做任何跳转不改变执行顺序只是根据条件码决定目标字节是写成 1 还是 0。这就把一个控制流问题转变成了纯粹的数据流问题布尔值终于可以像普通整数一样参与加减、移位、掩码运算。x86 里的条件码在跳转指令和置位指令之间是高度统一的。JE/JZ和SETE/SETZ关心的都是 ZFJB/SETB关心的都是 CFJGE/SETGE关心的都是 SF 与 OF 的关系。区别只在于Jcc 改变 EIPSETcc 改变目标字节。1.3 编译器眼中的布尔值无处不在的 setcc 模式如果你用 objdump 看过 GCC 或 Clang 开启优化后的输出一定经常遇到下面这种三连xor eax, eax cmp edi, esi setl al三行指令完成了一整个“无分支比较返回布尔值”。第一行先把 EAX 清零保证 AL 之外的高位是干净的第二行做比较设置标志位第三行setl al根据“有符号小于”的条件把 AL 置成 1 或保持 0。这样生成的代码没有跳转、没有分支预测失败的风险所有 CPU 前端流水线都保持线性执行在比较结果不可预测的场景下远比跳转方案稳定。理解 SETcc本质上就是理解编译器为什么这么喜欢它。2. EFLAGS 标志位速查先读懂 ZF、CF、SF、OF在讨论SETcc之前必须把 EFLAGS 里那几个关键位彻底搞清楚。很多人背指令表时只知道“SETL 是有符号小于”但遇到实际数据就懵了因为他不清楚 SF 和 OF 为什么要一起看。2.1 状态寄存器里的 8 个关键位x86 的状态标志位分布在 EFLAGS/RFLAGS 中真正影响 SETcc 条件判断的主要有以下这些标志位名称含义典型触发场景CF进位标志无符号运算产生进位或借位移位时移出的位ADD最高位进位、CMP无符号借位PF奇偶标志结果低 8 位中 1 的个数为偶数时为 1很少用主要用于串口/BCD校验ZF零标志结果为零时为 1CMP两数相等、TEST为零SF符号标志结果的最高位为 1 时为 1有符号视角为负算术结果最高位为 1OF溢出标志有符号运算结果超出可表示范围时为 1正加正得负、负减正得正等还有 AF 辅助进位标志主要给 BCD 指令用日常SETcc几乎碰不到可以先略过。2.2 CF 与 OF无符号和有符号的“溢出”不是一回事这是最容易混淆的一对我见过很多人在这个地方翻车。CF 站在无符号视角。8 位无符号数的范围是 0 到 255当0xFF 0x01时结果变成 0x100超出了 8 位能容纳的上限那个多出来的 1 就被放进 CF。所以ADD之后 CF1 代表无符号加法有进位CMP之后 CF1 代表无符号减法的被减数小于减数发生了借位。OF 站在有符号视角。8 位有符号数的范围是 -128 到 127当0x7F 0x01时按数学期望是 128但 128 超出了有符号范围结果被解释成 -128符号完全反了这时候 OF1。反过来两个负数相加得到正数也一样会把 OF 置 1。举个具体例子你就明白了。设AL 0x80BL 0x01执行CMP AL, BL无符号视角0x80 是 128128 - 1 127没有借位CF0。有符号视角0x80 是 -128(-128) - 1 -129超过了 8 位有符号范围结果截断后变成 127符号翻转所以 OF1。同一个减法CF 和 OF 给出完全不同的结论这不是矛盾而是它们分别属于两套解释体系。2.3 TEST 与 CMP 到底改了什么TEST执行按位与所以它永远不产生进位执行后 CF 和 OF 一定被清 0SF 反映按位与的最高位结果ZF 反映按位与结果是否为零PF 反映结果的奇偶性。CMP执行减法CF、OF、SF、ZF、PF 都会被合理更新。记住一个常识普通MOV指令不影响标志位LEA也不影响但绝大多数算术逻辑指令ADD、SUB、AND、OR、XOR、INC、DEC、SHL等都会影响。这意味着你不能在CMP之后随便插入一条算术指令否则之前的比较结果就作废了。这一点在后面避坑部分还会展开。3. SETxx 完整指令家族条件码分组与判定逻辑3.1 按标志位直接取值Z/S/O/P 四组有些SETcc指令非常直观它们只读取某一个标志位逻辑上就是“把这一个布尔值搬运到字节里”。SETE / SETZZF1 时为 1。常用于判断相等、判断结果为零。SETNE / SETNZZF0 时为 1是SETE的反义。SETSSF1 时为 1即结果为负。SETNSSF0 时为 1。SETOOF1 时为 1即有符号溢出。SETNOOF0 时为 1。SETP / SETPEPF1 时为 1低 8 位 1 的个数为偶数。SETNP / SETPOPF0 时为 1。这四组没什么需要推导的地方读标志位、写目标字节仅此而已。真正需要动脑筋的是下面两组比较类条件码。3.2 无符号比较组CF 告诉你“谁大谁小”无符号数没有符号位大小关系完全由借位情况和相等情况决定。CMP A, B执行后A BA 减 B 会产生借位CF1。A B没有借位CF0。A B差值为 0ZF1。A B要么相等要么产生了借位即 ZF1 或 CF1。A B既不相等也没有借位即 ZF0 且 CF0。对应到指令表就是下面这批指令判定条件含义SETB / SETC / SETNAECF1无符号低于 (Below)SETAE / SETNB / SETNCCF0无符号高于等于 (Above or Equal)SETBE / SETNACF1 或 ZF1无符号低于等于SETA / SETNBECF0 且 ZF0无符号高于记忆技巧B 是 BelowA 是 Above。Below 就是“在下面”对应小于Above 就是“在上面”对应大于。带 E 的是加上 Equal等于。3.3 有符号比较组SF 和 OF 的组合判定有符号数的大小关系不能只看 CF因为 CF 只反映无符号的借位。比如0x80和0x01做减法CF0 表示无符号视角下 128 大于 1但站在有符号视角0x80是 -128它明明小于 1。所以有符号比较必须绕道 SF 和 OF。核心结论是CMP A, B之后若SF ! OF则 A 有符号小于 B。为什么当 OF0 时减法没有发生有符号溢出结果符号就是真实符号。如果 SF1说明差为负即 A B如果 SF0说明差非负即 A B。当 OF1 时减法发生了溢出结果符号被“翻转”了。也就是说真实差值为负但结果看起来为正或者真实差值为正但结果看起来为负。此时判断 A B 的条件反而变成 SF0。无论哪种情况A B都等价于SF ! OF。这是一条非常优美的规律也解释了为什么SETL的条件码视图是“SF 异或 OF”的风格。完整的有符号比较组如下指令判定条件含义SETL / SETNGESF ! OF有符号小于 (Less)SETGE / SETNLSF OF有符号大于等于 (Greater or Equal)SETLE / SETNGZF1 或 SF ! OF有符号小于等于SETG / SETNLEZF0 且 SF OF有符号大于如果你把这两张表对比看会发现无符号和有符号只是把 CF 换成了 SF/OF 的组合其他结构完全对称。3.4 与 Jcc 的对照表同一套条件码两种用途SETcc和Jcc共享同一套条件码定义。我平时查指令时最常用的就是这张对照表SETccJcc条件含义SETE / SETZJE / JZZF1相等 / 结果为零SETNE / SETNZJNE / JNZZF0不相等SETBJB / JCCF1无符号小于SETAEJAE / JNCCF0无符号大于等于SETBEJBECF1 或 ZF1无符号小于等于SETAJACF0 且 ZF0无符号大于SETLJLSF ! OF有符号小于SETGEJGESF OF有符号大于等于SETLEJLEZF1 或 SF ! OF有符号小于等于SETGJGZF0 且 SF OF有符号大于SETSJSSF1结果为负SETOJOOF1有符号溢出SETPJPPF1低8位偶校验看完这张表你会发现只要你学过 JccSETcc 的语义就已经会了一半剩下的就是习惯“把条件写到字节里”这个动作。补充一个编码层面的细节机器码层面SETcc的 Opcode 是0F 90到0F 9F条件后缀直接编码在低位顺序和条件码编号完全一致。如果你在做反汇编器或者手工看二进制发现0F 9D就是SETGE / SETNL0F 9C就是SETL / SETNGE不需要每次都翻手册。4. 实战从 C 语言条件到 SETcc 汇编的完整链路4.1 最简单的例子return a b假设你在 x86-64 Linux 环境下有这样一个函数int is_less(int a, int b) { return a b; }GCC 开启优化后可能生成如下代码System V 调用约定下a 在 edib 在 esi返回值放 eaxis_less: xor eax, eax ; 把 eax 清零顺便保证高位干净 cmp edi, esi ; 计算 edi - esi修改标志位 setl al ; al (SF ! OF) ? 1 : 0 ret注意xor eax, eax必须放在cmp之前因为xor本身会改变标志位。如果把xor放在cmp之后等于把刚算好的 ZF、SF、OF 全部清掉了setl al会依赖错误的标志状态。编译器这样编排不是随意的。同样如果改成return a b;末尾那条指令会变成setle al改成return a b;则会变成sete al。这套对应关系练习几遍就熟了。4.2 把标志位“存起来”进位捕获与多精度算术SETcc最有价值的功能之一是把标志位转成整数保存下来供后续逻辑使用。典型场景是 32 位平台上做 64 位加法。低 32 位相加后产生的进位在 CF 里但你不能直接拿 CF 去参与 C 语言逻辑于是先把它抓出来mov eax, [a_low] add eax, [b_low] ; eax 低32位之和CF 是否产生进位 mov ecx, 0 setc cl ; ecx 0 或 1保存进位 mov edx, [a_high] adc edx, [b_high] ; edx 高32位之和 CF这里setc cl扮演了“快照”角色。虽然下面的adc也会读 CF但如果你需要同时保留进位给后续数组、或者要返回进位标记先把 CF 变成整数是必要的。相比之下LAHF虽然也能把标志位送回 AH但位的位置很别扭不如setc直观。4.3 branchless 编程用 setcc 构造掩码实现无分支最大值SETcc生成的是 0 或 1而 0 或 1 本身不是很好用的掩码。这时候配合NEG把它变成全 0 或全 1就能派上大用场。看一个无分支求最大值的例子; int max(int a, int b)a 在 edib 在 esi xor eax, eax cmp edi, esi setg al ; al (edi esi) ? 1 : 0 neg eax ; eax 0 或 0xFFFFFFFF mov ecx, eax and ecx, edi ; 如果 edi esi保留 edi否则清零 not eax and eax, esi ; 如果 edi esi清零否则保留 esi or eax, ecx ; 合并得到 max ret整个流程没有一条条件跳转指令。setg把比较结果变成 1neg把它扩展成全 1 掩码随后两个and分别从两个源里各取所需最后or拼接。这类代码在哈希表探测、状态机索引、饱和度运算里非常常见。编译器在遇到一些布尔表达式时也会自己生成类似的掩码逻辑。如果你想要的是“选一个”而不是“算最大值”也可以换用CMOVcc但CMOVcc适合在两个现成值之间选择SETcc 掩码的组合适合把比较结果本身当作数值参与运算两者分工不同。5. 五个高频踩坑现场SETcc 使用的边界与陷阱5.1 坑一只写低 8 位留下的高位残留SETcc的目标操作数只能是 8 位寄存器或字节内存这是指令格式的硬性限制。很多人写完setl al以为整个 EAX 变成了 0 或 1其实 AL 之外的位原封不动。看这个错误示范mov eax, 0x12345678 cmp edi, esi setl al ; 此时 eax 0x123456??并不是 0x00000001修复方法有两种。第一种是提前清零xor eax, eax cmp edi, esi setl al第二种是事后扩展cmp edi, esi setl al movzx eax, al两种都正确但第一种在部分旧微架构上更友好因为它从根源上避免了“先写部分寄存器、再读完整寄存器”的依赖问题。编译器输出里两种风格都存在遇到哪个都别意外。5.2 坑二CMP 和 SETcc 之间插入了“不干净”的指令标志位是瞬时的很多指令会无意中修改它。看这段代码cmp edi, esi inc ecx ; 这里破坏了 ZF、SF、OF虽然它不改 CF setl al ; 此时比较结果已经丢了就算用的是一条看起来和比较无关的指令只要它属于算术逻辑类就可能覆盖标志位。有一批指令不影响标志位比如MOV、LEA、PUSH、POP、JMP但ADD、SUB、INC、DEC、AND、OR、XOR都会改写标志。别以为INC和DEC不影响 CF 就安全它们对 ZF、SF、OF 的影响足以毁掉一次SETL判断。最稳妥的习惯是让CMP/TEST和SETcc紧挨着中间不插入任何可能影响标志的指令。如果必须在两者之间处理数据先把标志位保存出去再用。5.3 坑三无符号和有符号条件混用同一个比较无符号和有符号可能得到完全相反的结论。设eax 0x80000000ebx 0x00000001执行cmp eax, ebxsetb al无符号视角下2147483648 1没有借位CF0所以 AL0。setl al有符号视角下-2147483648 1溢出使得 SF0、OF1SF ! OF所以 AL1。你写代码前必须明确数据语义这两个数到底该按无符号解释还是有符号解释数组下标、地址运算、位掩码通常按无符号业务逻辑里的整数比较通常按有符号。混用条件码是逆向上经常看到的逻辑反演原因。5.4 坑四操作数顺序对比较结果的影响CMP A, B计算的是 A 减 B它表达的含义是“A 与 B 比较”。如果你要判断if (x y)应该写cmp x, y; setl al表示“x 小于 y 为真”。如果写反了变成cmp y, x; setl al得到的其实是y x也就是x y。这种错误在简单场景下通常肉眼能看出来但在复杂表达式拆分后很容易被忽略。一个通用口诀CMP 第一操作数, 第二操作数条件判断的语义就是“第一操作数 条件 第二操作数”。cmp x, y; jg等价于if (x y)和SETcc完全一致。5.5 坑五旧微架构上的部分寄存器停顿从 Pentium 时代到较早的 Core 架构x86 CPU 在“写 8 位寄存器 AL然后读完整寄存器 EAX”时可能会产生额外停顿因为它无法高效跟踪 AL 与 EAX 高位的合并关系。这类问题被称为部分寄存器停顿partial register stall。现代 CPU 大多通过寄存器重命名缓解了这个问题但在兼容性和低功耗微架构上它仍然可能咬你一口。我写高性能代码的习惯仍然是要么xor eax, eax提前清要么movzx eax, al显式扩展。这样写不仅安全而且语义一目了然。6. 性能与替代方案用 SETcc 写出更快的分支消除代码6.1 SETcc 在现代 CPU 上的指令成本在较新的主流微架构上绝大多数SETcc指令都被解码为单个 µop延迟大约 1 到 2 个周期吞吐非常可观。加上前面的CMP和后面的MOVZX整个“比较并把结果转成整数”的序列大概只有三到四条指令流水线完全线性没有任何分支预测器参与。对比传统分支版本一条Jcc如果预测正确开销很小但如果预测失败代价往往是 15 到 20 个周期以上的流水线清空。所以当比较结果接近五五开、无法预测时SETcc的无分支方案几乎总是更优。这也是编译器倾向于把它用在“返回布尔值”函数里的原因。6.2 寄存器清零的两种常见姿势先 XOR 还是后 MOVZX前面提到过把 AL 扩展成完整 EAX 有两种常见写法; 姿势 A先清后置 xor eax, eax cmp edi, esi setl al ; 姿势 B后扩展 cmp edi, esi setl al movzx eax, al两者的指令数差不多。姿势 A 在寄存器重命名资源上更省因为 CPU 从一开始就认为 EAX 是一个完整定义姿势 B 的结构更清晰适合在已经有现成寄存器值时使用。我建议跟随编译器惯例简单的布尔函数优先用姿势 A需要保留旧值时用姿势 B。不要在cmp之后插xor那会破坏标志位。6.3 从 SETcc 到 SBB 技巧另一种把标志位传播为整数的方法如果你看过老手写的极致短代码可能撞见过利用SBB传播 CF 的方法。比如把无符号比较a b转成 0/1xor eax, eax cmp edi, esi sbb eax, eax ; CF1 时 eax -1CF0 时 eax 0 inc eax ; CF1 时 eax 0CF0 时 eax 1原理是SBB计算dst dst - src - CF。当src是同一个寄存器时结果就是-CFCF1 得到 0xFFFFFFFFCF0 得到 0。这个技巧可以生成 32 位掩码避免setcc movzx的两步操作。但我不建议在日常代码里刻意卖弄它。现代编译器基本不会用这种写法因为setae已经足够快而且语义更直白。了解它的意义在于让你理解“CF 可以被算术指令消费”这件事这对调试和分析手写汇编很有帮助。6.4 什么情况下不该用 SETcc分支预测仍有价值分支消除听起来很美好但它不是银弹。如果分支具有极强的偏向性比如循环里 99% 的情况都不满足条件现代分支预测器的正确率能轻松超过 99%此时一个预测正确的条件跳转几乎不花时间。强行改成SETcc反而多算了多条算术指令白白增加指令数。我在优化代码时会先问自己这个条件的分布是怎样的如果是随机数据、哈希值、外部输入SETcc无分支是稳妥选择如果是边界检查、特殊错误分支、循环结束判断保留分支通常更高效。不要拿着setcc到处替换所有分支性能决策要靠数据说话不是靠信仰。有经验的工程师拿到一段陌生汇编时往往先扫一眼有没有setcc出现——它意味着这段代码在把比较结果当作数值使用。反过来如果你是自己写汇编掌握好SETcc的语义和边界你的代码风格会干净一个档次也更容易被编译器优化到同一水平。我个人的习惯是凡是需要布尔结果的地方先写无分支的setcc版本再用实际数据跑一轮 profile多数时候它能赢偶尔输给预测器但至少你手里多了一把可用的刀。
阅读完成 · 觉得有帮助?