PAYATHON 2026

为什么 x86 没有 ARM 那样的 IT / ITT 条件执行和 link-register?

技术老齐

结论

x86 没有 ARM IT / ITT 那样的通用条件执行块,也没有由常规调用指令写入的 link register,主要与既有的指令编码、调用机制和兼容性有关。这两项设计彼此独立,不能用来划分 RISC 与 CISC。

x86 用条件跳转控制一段代码是否执行,用 CMOVcc 等指令完成部分无分支选择;普通近调用的 CALL / RET 则通过栈保存和恢复返回地址。ARM 的方案也有各自的收益和成本,不能仅凭指令更少或不访问栈就判断性能更好。

先区分 ARM 的几种条件执行机制

问题中提到的机制主要属于 AArch32,不能直接推广到所有 ARM 指令集。

指令集或执行状态条件执行方式
A32,通常所说的 ARM state许多指令自身带条件码字段,无须先执行 IT
支持 IT 的 T32,也称 Thumb 指令集IT 为随后最多四条指令指定条件模式,具体指令和用法存在限制
A64,即 AArch64 使用的指令集没有 IT 块,也没有 A32 那样广泛的逐指令谓词字段;提供 CSEL 等条件选择指令

“ARM 每条指令的最高四位都是条件码”只能概括 A32 的常见编码,不能用来描述整个 ARM 指令集。

ITT EQ 表示随后两条指令都使用 EQ 条件,执行受条件标志控制。块内可以使用哪些指令、怎样使用,仍有约束。

为什么 x86 没有通用的 IT / ITT

现有编码没有预留统一的谓词字段

A32 的许多固定长度指令在编码设计时就包含了条件字段。x86 使用可变长度编码,已有大量操作码、前缀和寻址格式,没有一个能直接用作所有指令条件字段的位置。

x86 可以通过新编码引入条件执行机制,但需要定义:

  • 条件控制哪些指令,以及如何表示块边界。
  • 条件不成立时,如何处理内存访问、异常和标志位。
  • 中断、异常恢复及调试如何与块状态配合。

这些设计都有编码、硬件实现和工具链支持的成本。既有程序已经能用条件跳转表达同样的控制流,新增机制需要有足够的收益。

谓词执行也会消耗资源

即使条件不成立,指令通常仍需要经过取指、译码等处理,具体消耗取决于处理器实现。

对于按条件写寄存器的操作,还需要实现“条件成立时写入新值,否则保留旧值”的语义。这会引入对条件标志、候选结果和旧值的依赖,可能延长关键路径。

使用条件跳转时,处理器可以预测执行哪条路径。预测正确,就能避免执行另一条路径的大部分工作;预测错误,则需要付出恢复执行的代价。选择时可以考虑:

  • 很短、难以预测的条件选择,可能适合 CMOVcc。
  • 分支容易预测,或者分支中有较多工作时,通常更适合跳转。
  • 涉及访存、异常或其他副作用时,需要检查能否安全地提前计算结果。

A64 采用了条件选择等局部机制,可见 ARM 的不同设计也没有统一采用广泛谓词执行。

这些属于架构上的工程取舍。要确定某一代 x86 设计者当年的具体动机,还需要查阅对应的历史设计资料。

在 x86 上怎样实现简单的 ITT

如果要在条件成立时执行两条指令,用一次条件跳转控制整个代码段即可,无须为每条指令各配一次跳转。

下面使用 x86-64 Intel/NASM 风格语法:

; 当 rdi != 0 时:
;   rax += 1
;   rdx += 2

test    rdi, rdi
jz      .skip

add     rax, 1
add     rdx, 2

.skip:

JZ 根据 TEST 生成的标志决定是否跳过整个代码段。进入代码段后,即使第一条 ADD 改变了标志位,也不会影响第二条指令是否执行。

如果需要无分支实现,可以先计算候选结果,再分别选择:

; r8、r9 用作临时寄存器
; 这里只要求两个目标寄存器的更新效果相同

lea     r8, [rax + 1]
lea     r9, [rdx + 2]

test    rdi, rdi
cmovne  rax, r8
cmovne  rdx, r9

这个版本始终计算两个候选值,并占用两个临时寄存器。它没有复现前一个版本执行 ADD 后的标志位结果,因此,如果后续代码依赖那些标志,就不能直接替换。

使用时可以按以下顺序判断:

  1. 先用条件跳转表达完整语义。
  2. 若只是选择少量寄存器值,检查是否适合 CMOVcc。
  3. 确认提前计算不会引入额外异常或副作用。
  4. 有性能需求时,在目标处理器和真实数据分布上测量。

CMOVcc 使用内存源操作数时,不能把它当作安全的条件加载。即使条件不成立,也可能访问该内存并产生异常。例如,不能靠条件为假来保证 cmovne rax, [rcx] 不会因无效地址而出错。

普通 x86 IMUL 没有通用的条件执行形式。不同操作数数量的 IMUL,以及乘法后根据标志跳转,都不等于带谓词的乘法指令。

为什么 x86 用栈保存返回地址

普通 x86 近调用的架构语义是:

  • CALL 把下一条指令的地址压入栈,然后跳转。
  • RET 从栈中取出返回地址,恢复执行。

每次调用都会保存自己的返回地址,因此这种方式自然支持嵌套调用和递归。它也无须占用一个通用寄存器长期保存当前函数的返回地址,对早期寄存器较少的 x86 有实际好处。

ARM 的 link register 会先把返回地址保存在寄存器中。例如,AArch64 的 BL 将返回地址写入 X30。叶函数如果不需要栈上局部变量,也没有需要保存的寄存器,就可以完全不访问栈:

// AArch64:参数和结果都在 x0
add_one:
    add     x0, x0, #1
    ret                     // 默认使用 x30 中的返回地址

函数一旦通过 BL 调用其他函数,X30 就会被新的返回地址覆盖。若之后还要返回原调用者,就必须提前保存原返回地址。通常会将它保存在栈上,也可以在满足调用约定的前提下使用其他保存位置;尾调用等情况则可能省去这一步。LR 因而能减少部分调用的内存操作,但一般的嵌套调用仍需要保存返回地址。

x86 的 CALL / RET 已经与现有二进制程序、调用约定、调试器和异常展开机制配套。新增一种用寄存器保存返回地址的机制在技术上可行,但需要新的指令和软件约定,也无法直接改变旧程序的调用行为。

x86 叶函数也可以没有栈帧

在 System V AMD64 调用约定下,下面的叶函数使用 RDI 接收参数、用 RAX 返回结果:

add_one:
    lea     rax, [rdi + 1]
    ret

函数没有分配局部栈空间,也没有保存寄存器,因此无须建立栈帧。不过,调用者执行的 CALL 已经向栈中写入返回地址,RET 仍要读取它。“函数体没有显式栈操作”和“整个调用过程完全不访问栈”是两回事。

栈访问也不代表每次都要访问主存。返回地址通常位于缓存中的栈区域,现代处理器还常用专门的返回地址预测结构预测 RET 的目标。这种结构帮助预测控制流,架构规定的栈读写仍然需要执行。

编写代码时,应优先遵循目标平台的调用约定。条件跳转与 CMOVcc 如何选择、叶函数能否省去栈帧,都取决于具体代码和目标处理器,仅比较 ARM 与 x86 的指令形式不足以得出结论。

备注:内容仅供参考。