为什么 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 后的标志位结果,因此,如果后续代码依赖那些标志,就不能直接替换。
使用时可以按以下顺序判断:
- 先用条件跳转表达完整语义。
- 若只是选择少量寄存器值,检查是否适合
CMOVcc。 - 确认提前计算不会引入额外异常或副作用。
- 有性能需求时,在目标处理器和真实数据分布上测量。
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 的指令形式不足以得出结论。
备注:内容仅供参考。
