为何GCC与Clang在两个分支均生成pop指令而非仅一次?
pop指令? GCC和Clang均会编译如下C代码:
bool pred(); void f(); void g(); void h() { if (pred()) { f(); } else { g(); } }
生成类似如下的汇编(Clang -Os输出,-O3结果一致):
h(): push rax call pred()@PLT test al, al je .LBB0_2 pop rax jmp f()@PLT .LBB0_2: pop rax jmp g()@PLT
有人提出疑问:为什么编译器要在两个分支都生成pop指令,而非在je指令前只生成一次?
比如可以采用类似下面的手写汇编方案,将栈弹出到其他易失寄存器(避免破坏pred()的返回值),或者用add rsp, 8(不过现代CPU中这可能需要栈同步微操作,未必更快):
# 编译器可采用的手写示例代码 h(): push rax # 对齐栈 call pred()@PLT pop rcx # 清理栈,恢复栈指针至初始状态 test al, al je .LBB0_2 jmp f()@PLT # 尾调用f .LBB0_2: jmp g()@PLT # 尾调用g
核心原因解析
代码大小优化的实际需求
pop rax是1字节的短指令,而pop rcx是2字节,add rsp, 8是3字节。在-Os(优先优化代码大小)的场景下,两个分支各放一个pop rax总共仅占2字节,而手写方案里的pop rcx就占2字节,整体代码长度并没有优势,完全符合编译器针对代码大小优化的目标。栈状态一致性与分支预测容错
x86-64架构中,分支预测失败时CPU会回滚指令流水线。如果把pop放在分支判断前,一旦预测错误,栈指针已经被修改,回滚后可能导致后续栈操作出现异常。而在每个分支内单独执行pop,无论分支是否命中,栈操作都在分支路径内完成,流水线回滚时栈状态始终一致,不会引发错误。调用约定与栈对齐的兼容性
x86-64 System V调用约定要求调用函数时栈指针必须是16字节对齐。push rax是为了在调用pred()前完成栈对齐(call指令会压入8字节返回地址,push rax后栈指针刚好是16的倍数)。如果在分支判断前就恢复栈指针,后续尾调用f()/g()虽然不需要栈对齐,但编译器的优化逻辑更倾向于保持路径的对称性,避免引入额外的状态依赖。寄存器资源的无意义消耗
手写方案中使用pop rcx会占用rcx寄存器,虽然rcx是易失寄存器不需要调用者保存,但编译器会尽量避免无意义的寄存器占用——在这个简单函数里看似没问题,但在更复杂的函数中,保留空闲寄存器能给后续优化留出更多空间,编译器的通用优化逻辑不会为了这个场景打破常规。
内容的提问来源于stack exchange,提问作者Bernardo Sulzbach

