CISC 与 RISC —— 现代 x86 的双面人生
你写的
add eax, [rsi+8]是一条 CISC 指令,但 CPU 内部跑的是一串 RISC 风格的 µop。x86 是 CISC 的壳、RISC 的芯——这个"壳"止于译码器。理解这条分界线,是看懂 µop 级流水线、perf 前端计数器、以及为什么"指令条数 ≠ µop 数"的前提。
一、CISC 与 RISC 的哲学分歧
这场架构之争可以追溯到 20 世纪 80 年代。两种设计哲学的出发点完全不同:
| 维度 | CISC(x86) | RISC(ARM / RISC-V) |
|---|---|---|
| 指令长度 | 变长 1~15 字节 | 定长 4 字节(ARM)/ 2 或 4(RISC-V) |
| 访存方式 | 任意 ALU 指令可访存:add eax, [rsi+8] | Load/Store 架构:必须 ldr → add 分开 |
| 寻址模式 | 丰富:[base + index*scale + offset] 单指令完成 | 简单:ldr x0, [x1, #8] 只有 base+offset |
| 架构寄存器数 | 16 个 GPR + 16/32 个 XMM/YMM/ZMM | 31 个 GPR + 32 个 FPR |
| 操作数格式 | 两操作数:add eax, ebx(eax 既是源又是目的) | 三操作数:add x0, x1, x2(dest ≠ src) |
| 硬件成本 | 译码器复杂度极高(预译码 + 4 路并行译码) | 译码器极简,省下的晶体管给寄存器和执行单元 |
一句话总结:CISC 把复杂性推给硬件指令集,希望一条指令做更多事;RISC 把复杂性推给编译器,让硬件保持简洁。两种哲学没有绝对优劣——但现代高性能 CPU 已经在内部趋同了。
二、现代 x86 的真实面貌:CISC 壳 + RISC 芯
从 Pentium Pro(1995)开始,Intel 就走上了这条路。今天所有 x86 高性能处理器(Intel Core / AMD Zen)全部沿用这个架构:
译码器之前:变长指令、复杂寻址、操作数既是源又是目的——全是 CISC 的特征。
译码器之后:定长 µop、三操作数、寄存器全部分配物理编号、一条 µop 只干一件事——全是 RISC 的特征。
译码器是 CPU 内部唯一的 CISC 部件,之后的所有流水线阶段都不关心这些 µop 是从 x86 来的还是 ARM 来的。
2.1 为什么现代 x86 一定这样设计
如果不做 µop 分解,直接在超标量乱序流水线上跑 CISC 指令,会遇到三个不可能绕过的死结:
死结一:指令宽度不可控。 CISC 指令长度 1~15 字节不等,预译码都不知道下一条指令的起点在哪里,超标量每个周期取多条指令的效率极低。拆成定长 µop 后,所有后续阶段的调度、发射、退休都以固定粒度进行。
死结二:操作数语义混乱。 add eax, [rsi+8] 在 x86 层面是"读内存→做加法→写回 eax"一条指令,但"读内存"和"做加法"走的是完全不同的硬件通路(LSU vs ALU)。不拆开就没法在保留站里分别调度这两步——二者执行延迟差 4~5 拍,混在一起是调度器的噩梦。
死结三:寄存器重命名无法工作。 add eax, ebx 的语义是 eax = eax + ebx,eax 既是源又是目的。重命名必须把源和目的分配到不同的物理寄存器(否则消不掉 WAW/WAR),但如果指令格式本身不给三操作数空间,RAT 就得靠额外的临时寄存器来拆——等于在 RAT 里重新做了一遍译码。不如直接在译码器里拆好。
三、译码器:CISC→µop 的边界到底发生了什么
3.1 译码流程
x86 指令流(变长,1~15 字节)
│
├─→ 预译码(Pre-decode):标出每条指令的边界
│
├─→ 指令队列(Instruction Queue)
│
├─→ 复杂译码器(Complex Decoder):处理多 µop 指令(1 条/周期)
└─→ 简单译码器 × 3~4(Simple Decoder):处理单 µop 指令(1 条/周期每个)
│
└─→ µop 队列(µop Queue)→ µop Cache(DSB)→ IDQ(Instruction Decode Queue)现代 Intel(Skylake 起)译码器布局:
- 4~6 路并行译码(1 复杂 + 3~5 简单)
- 单周期最多译出 4~6 条 µop(不是 4~6 条 x86 指令)
- 复杂指令(如
rep movsb、div)由 MS-ROM(微码序列器)生成 µop 流
3.2 典型指令的 µop 拆分
| x86 指令 | µop 数 | 拆分结果 | 说明 |
|---|---|---|---|
mov eax, ebx | 1 | MOV Pn, Pm | 纯寄存器搬移,单 µop,简单译码器处理 |
add eax, ebx | 1 | ADD Pn, Pm, Pk | 纯 ALU,单 µop,简单译码器 |
add eax, [rsi+8] | 2 | LD tmp, [rsi+8] + ADD eax, tmp | 访存+算数合一指令,拆成两条 µop |
mov [rbx], eax | 2 | STA [rbx] + STD eax | Store 拆成地址 µop + 数据 µop |
addsd xmm0, [rdi+rcx*8] | 2 | LD tmp, [rdi+...] + ADDSD xmm0, tmp | 浮点 Load-Op,走的还是 INT RS(LD)+ FP RS(ADDSD) |
rep movsb(大块内存拷贝) | N | 微码流(几十条 µop) | 由 MS-ROM 按模式展开,不在普通译码器里处理 |
3.3 µop Cache(DSB):跳过译码器
x86 的译码器功耗大、面积大,而且受限于 4~6 路——如果循环体 20 条 x86 指令、拆出来 35 条 µop,每次迭代译码都要花 6~9 个周期。
DSB(Decoded Stream Buffer,即 µop Cache) 就是解决方案:已经译好的 µop 直接缓存起来,循环时跳过译码阶段,每个周期最多往 IDQ 送 6 条 µop。
| 场景 | 译码路径 | 吞吐 |
|---|---|---|
| 代码首次执行 | x86 → 预译码 → 译码器 → µop Queue → IDQ(同时写入 DSB) | ≤ 4~6 µop/周期 |
| 循环命中 DSB | DSB → IDQ(跳过译码器) | 6 µop/周期,译码器空闲 |
| 循环命中 LSD | LSD → IDQ(连 DSB 也跳过了) | 环路最短,零前段开销 |
对性能分析来说,perf 的
idq.dsb_uops/idq.mite_uops分别统计 DSB 和传统译码路径来的 µop 数,前者高是好事(前端高效),后者高意味着译码器是瓶颈。
四、对 perf 分析的启示
4.1 指令数 ≠ µop 数
perf stat -e instructions,idq.mite_uops,idq.dsb_uops -- ./your_programinstructions是退休的 x86 指令数(程序员的视角)idq.mite_uops + idq.dsb_uops才是硬件真正执行的 µop 数(CPU 的视角)- 两者差值就是译码器拆分开销
典型场景:
- 指令全是
mov reg, reg/ 简单 ALU → µop 数 ≈ 指令数 - 大量
add eax, [mem]/push/pop等内存操作数指令 → µop 数可高达指令数的 1.5~2 倍 rep stosb/div等微码指令 → µop 数远超指令数(几十倍)
4.2 前端 bound = 译码器 bound?
perf 的 frontend_bound 可以进一步用 idq.mite_uops vs idq.dsb_uops 判断:
| 迹象 | 含义 | 解法 |
|---|---|---|
idq.mite_uops 占比高,IPC 低 | 译码器吞吐不够,代码路径长且复杂指令多 | 减少 load-op 合并(改用单纯 load + 单纯 ALU)、减小编译后的代码体积 |
idq.dsb_uops 占比高,IPC 还是低 | 前端 DSB 吞吐够,瓶颈在后端(执行单元/缓存) | 分析后端 stall 来源 |
| DSB miss 率高 | µop cache 被热代码反复踢出,代码体积过大 | 热路径做 inlining 权衡、用 likely/unlikely 把冷代码移到函数末尾 |
4.3 一条指令"便宜"还是"贵"——看 µop 数
| x86 指令 | µop 数 | 隐藏成本 | 性能评价 |
|---|---|---|---|
lea rax, [rdi+rsi*2+8] | 1 | 单 µop,但走 AGU 算地址 | 很便宜——lea 是免费的算术单元 |
add eax, ebx | 1 | 单 µop,ALU 一拍 | 最便宜 |
add eax, [rsi+8] | 2 | LD + ADD,两条 µop 之间有 RAW | 中等——别被一条 x86 指令迷惑 |
push rax | 2(隐式 sub rsp,8 + mov [rsp],rax) | STA + STD,还隐式更新 rsp | 比看起来贵 |
pop rax | 2 | LD + add rsp,8 | 同上 |
div | 几十 | 微码流,阻塞译码器 | 极贵——循环里能免则免 |
五、与其他文档的关系
| 文档 | 关系 |
|---|---|
| uop-pipeline-walkthrough | 本文说了"µop 长什么样、怎么来的",walkthrough 讲了"µop 在流水线里逐拍怎么跑" |
| pipelining-superscalar | 流水线的基础假设就是定长 µop——如果跑的是变长 CISC 指令,五级流水线无从搭建 |
| data-hazards | add eax, [rsi+8] 拆成 LD+ADD——这条 RAW 依赖在 x86 汇编层根本看不到,只在 µop 层显形 |
| register-renaming | 三操作数 µop 是重命名的前提——dest, src1, src2 的分离格式让 RAT 可以直接给 dest 和 src1 分不同的 PR |
| cpu-microarch-overview | 演进链导读:µop 架构是超标量乱序的底层前提 |
| i-cache | I-Cache 存的是 CISC 指令,DSB 存的是译好的 µop——两者的 miss 都是前端 stall |
一句话总结:x86 的"复杂"只在译码器之前存在——从 µop 开始,它和 ARM/RISC-V 跑的是同一套 RISC 理念的流水线。理解这个分界线,才能看懂为什么
add eax, [rsi+8]比mov eax, [rsi+8]; add eax, ebx多了一条隐式的 µop 级 RAW 依赖,以及为什么 perf 的 µop 计数器比指令计数器更接近硬件真相。