CPU乱序执行原理深度拆解:从保留站到重排序缓冲的完整流程

发布时间:2026/9/7 11:55:48
CPU乱序执行原理深度拆解:从保留站到重排序缓冲的完整流程 先问一个问题一条指令从内存里被取出来之后CPU 到底是按什么顺序执行它的如果你问没接触过体系结构的人他会说当然是按代码写好的顺序从头到尾跑。如果你问写过编译器的人他会说编译器在生成汇编时就重排过指令了。但真正决定最终执行次序的是现代高性能 CPU 内部那套乱序执行Out-of-Order Execution引擎——它根本不管你代码里写的先后关系只看一条铁律你的源操作数准备好了没有。这篇文章要拆的就是这套倒着干活的架构。我会讲清楚为什么要打乱指令顺序、打乱之后凭什么还能保证算出来的结果和顺序执行一模一样、以及这套机制从取指到提交的完整工作流程。它适合三类人看写性能敏感代码的开发者、正在学计算机组成原理的读者、以及纯粹好奇 CPU 内部到底在干什么的人。看完你至少能回答一个问题为什么有些 CPU 单核算得这么快而快的背后又付出了什么代价。1. 乱序执行的核心思路CPU 为什么非要倒着干活1.1 顺序执行的痛点CPU 其实一直在等人要理解乱序执行先得看顺序执行卡在哪。早期的 CPU 指令是一条一条按顺序走的取指、译码、执行、写回。看起来很干净但实际跑起来非常浪费。原因很简单——指令之间经常有依赖而依赖背后站着一个巨大的访存延迟。我举个例子。假设程序里有这么一段load r2, [r1] // 从内存地址 r1 读数据到 r2 mul r5, r2, r3 // 乘法需要 r2 add r6, r4, r2 // 加法也需要 r2第一条 load 如果发生缓存未命中可能需要几百个周期才能从内存把数据拿回来。第二条 mul 因为要用 r2只能干等。第三条 add 也要用 r2也只能干等。问题来了后面如果还有几十条指令压根不用 r2它们本来可以立刻算完但在顺序执行的流水线里它们全都堵在 load 后面谁也别想动。CPU 的算术单元、逻辑单元全部空转几十上百个周期就这么白白浪费掉。这就是顺序执行最大的痛点流水线的前端一直在取指令后端却经常因为一条慢指令全面停摆。本质上不是 CPU 算得不够快而是它在等人——等内存、等缓存、等一条慢吞吞的依赖链。1.2 倒着干活的本质让等的人先靠边让能干的人先干乱序执行的核心思想用一句话说就是把没有依赖关系的指令提前到前面去执行把需要等待的指令往后挪一挪。执行顺序被打乱了但从外部看最终结果必须和按顺序执行完全一致。你可以把它理解成一家餐厅的后厨。客人点单的顺序是A 桌要一份需要炖 40 分钟的汤B 桌要一份两分钟的炒青菜C 桌要一份十分钟的蒸鱼。如果严格按点单顺序做A 桌汤炖着的时候B 桌和 C 桌只能干瞪眼。聪明的后厨会把炒青菜和蒸鱼先做了汤在炖着也不耽误出菜速度。只要最后所有菜都端到正确的桌子上客人不会关心后厨是先炒了青菜还是先炖了汤。指令乱序执行也是同一个道理。CPU 内部有一条指令队列每条指令都在等待自己的食材——也就是源操作数。只要操作数都齐了哪怕这条指令在程序里排在很后面也可以先被送进执行单元去算反之就算你排在前面只要操作数没到你也只能蹲在队列里等着。1.3 乱序执行到底解决了什么问题往大了说乱序执行解决的是三件事隐藏访存延迟。现代 CPU 的性能瓶颈早就不是运算速度而是内存和缓存的速度差。乱序执行能在一次 cache miss 的几百个周期里把后面几百条独立指令全部提前算完相当于把等内存的时间用来做计算。提高单核指令级并行度ILP。单核 CPU 的并行度不靠多线程而靠单个线程内部指令之间的并行。乱序窗口越大能找到的并行指令就越多每个周期能退休的指令数IPC也更高。让执行单元尽量保持忙碌。现代 CPU 往往同时有多个整数单元、浮点单元、访存单元如果严格顺序执行绝大多数单元都在闲置。乱序调度能把不同类型的指令分散到不同单元里让后端资源满载。当然代价也很明显硬件复杂度直线上升、功耗猛增、面积变大。所以并不是所有 CPU 都采用乱序执行。嵌入式领域很多低功耗 CPU 到现在还是顺序执行因为省电、简单、面积小。乱序是高性能单核的标配烧钱方案。2. 核心组件拆解四个零件各管什么乱序执行不是魔法它由几个非常具体的硬件组件协同完成。想搞懂整条链路先得认识这四个核心部件。2.1 保留站和指令窗口等菜的排队区保留站Reservation Station是整个乱序调度的中枢。每条被译码的指令会在保留站里占一个位置同时记录三样关键信息这条指令要做什么运算、第一个源操作数就绪了没有、第二个源操作数就绪了没有。关键机制在于广播监听。执行单元一旦算出结果结果会被广播到一条公共数据总线CDB上。保留站里的每一条指令都会同时去看总线上这个结果是不是自己正在等的源操作数如果是就立刻把自己的源操作数填上。这个动作非常快所有保留站条目是同一条总线同时监听的不需要 CPU 一个个去比对。把所有等待中的指令放到一起看就构成了一个指令窗口。窗口越大能在同一时刻观察和调度的指令就越多越容易找到可以提前执行的指令。当然窗口越大硬件成本也越夸张这部分后面再细说。2.2 寄存器重命名给同名寄存器改名换姓程序员眼里只有一套寄存器比如 x86 里的 rax、rbx、rcx。但在乱序执行里同一个架构寄存器在不同时刻会被写入多个不同的值如果所有指令都去抢同一个物理寄存器就会产生大量本不该有的等待关系。解决办法是搞一个物理寄存器池比架构寄存器的数量多得多。硬件维护一张映射表动态地把架构寄存器映射到某个物理寄存器上。每次遇到一条要写 r1 的新指令就不再去用原来那个物理寄存器而是从池子里另取一块新的更新映射表。这个动作叫做寄存器重命名。它是解决数据冒险的重头戏具体怎么消掉假依赖第 4 章会详细展开。你只要先记住重命名让同一个名字在不同时间点上变成了不同的实物指令之间就不会因为名字相同而互相堵车。2.3 重排序缓冲ROB乱序干完活顺序交答卷既然执行顺序是乱的那最终怎么保证结果正确靠的就是重排序缓冲Reorder BufferROB。ROB 本质上是一块环形缓冲每条指令在进入保留站之前会先按程序顺序分配一个 ROB 条目。执行完的指令把结果写回到自己的 ROB 条目里但这一步不会直接修改架构寄存器。只有等到 ROB 里排在它前面的所有指令都提交了它才能正式退休commit把结果写进真正的架构寄存器或者真正写进内存。换句话说执行是乱序的但提交永远是按顺序的。这样做的价值非常巨大任何时刻处理器对外呈现的架构状态都能精确对应到程序顺序中的某一条指令执行完的状态。如果某条指令触发了异常CPU 可以精确地指出是第几条指令出的错然后放弃它之后所有乱序执行的结果回滚到异常点。这叫精确异常乱序执行能把这一点做得比想象中更干净。如果分支预测错了已经执行完但还没提交的指令直接整批作废即可因为它们的中间结果还没污染架构状态。2.4 分支预测提前赌一把赌错了就得重来乱序执行最怕的其实是分支。代码里 if、for、while 到处都是CPU 在拿到分支结果之前根本不知道下一条该取哪条指令。如果傻等着分支判断完流水线又得空转几十个周期。所以现代 CPU 前端都会有非常激进的分支预测器。它会根据历史执行记录提前猜一个跳转方向然后从猜的方向取指令、译码、甚至乱序执行。如果猜对了这些提前做的工完全免费等于把分支延迟给藏掉了如果猜错了已经进入流水线甚至已经算完的指令全部作废从正确的分支重新开始。这里有个连锁反应分支预测错误的代价在乱序 CPU 上比顺序 CPU 高得多。因为乱序 CPU 可能已经在错误路径上执行了一大堆指令这些指令占用了执行单元、占用了 ROB最后全部白干还白白烧了功耗。所以现代高性能 CPU 的分支预测器越做越复杂因为它直接影响乱序执行的效率上限。把这四个组件连起来走一遍流程就是取指 → 译码 → 寄存器重命名 → 分配 ROB → 放入保留站 → 乱序发射执行 → 写回 ROB → 按序提交。组件核心职责通俗类比保留站/指令窗口监控源操作数动态调度发射顺序后厨的备菜台谁的材料齐了谁先下锅寄存器重命名消除同名寄存器之间的假依赖同名客人很多给每个人发不同的桌号重排序缓冲 ROB乱序执行、按序提交保证架构状态正确外卖订单乱了没关系出餐口按订单编号排队出餐分支预测提前猜测跳转方向隐藏分支延迟导航提前猜你走哪条路走错了再重新规划3. 完整执行流程实操跟着一条指令走一遍理论讲完我们实际推演一次。假设编译器生成了下面这几条汇编指令I1: load r2, [r1] // 从内存地址 r1 加载数据到 r2 I2: mul r5, r2, r3 // r5 r2 * r3依赖 I1 I3: sub r8, r9, r10 // r8 r9 - r10完全独立 I4: add r11, r12, r13 // r11 r12 r13完全独立 I5: store [r14], r8 // 把 r8 写回内存依赖 I3假设 I1 访问内存发生了 cache miss需要 80 个周期才能拿到数据。在严格顺序执行的模型下I2 要等 r2I3、I4、I5 全都得排在 I2 后面等至少 80 个周期内后面的指令一个都跑不了。但在乱序 CPU 里推演过程完全不一样。3.1 前端取指、译码、按序分配编号CPU 先从指令缓存里把 I1 到 I5 取出来逐个译码并把它们翻译成内部微操作uop。在这个阶段每条指令被发到后端之前会做一次寄存器重命名。为了方便观察假设物理寄存器共有 p0 到 p31 三十二个。原始代码里的 r2、r5、r8 这些逻辑寄存器会通过映射表指向不同的物理寄存器。比如 I1 要写 r2硬件从池子里拿一个 p16 分配给 I1I2 要写 r5分配 p17I3 要写 r8分配 p18I4 要写 r11分配 p19。映射表同时更新以后谁读到 r2就知道要去读物理寄存器 p16。每条指令还会在 ROB 里按程序顺序占一个坑位。这里的顺序就是 I1、I2、I3、I4、I5铁打不动谁也别想插队。这个坑位用来决定提交顺序执行顺序可以乱但提交必须按这个次序来。分配完 ROB 和物理寄存器之后指令被派发dispatch到保留站里等待执行。3.2 保留站里发生了什么现在保留站里有五条指令每条指令的源操作数状态如下I1源操作数是内存地址 r1地址已经算好可以直接发起访存。I2源操作数 r2 还在等 I1 的结果来源物理寄存器 p16等待中。I3源操作数 r9、r10都已在寄存器堆里准备好了就绪。I4源操作数 r12、r13都已就绪。I5源操作数 r8来源物理寄存器 p18也就是 I3 的结果等待中。调度器Scheduler每个周期扫描保留站选那些所有源操作数都就绪的指令发射issue到对应的执行单元。I1 因为地址早就准备好了先被发射到访存单元发出读内存请求然后进入 80 个周期的等待。这是迫不得已的等待。紧接着I3 和 I4 被调度器扫描到它们没有任何依赖障碍立刻被发射到整数算术单元。它们几乎在下一个周期就计算完毕结果写回到自己的 ROB 条目里同时通过 CDB 广播出去——p18 的结果是 xx谁在等 p18 I5 立刻捕获到这个结果它的源操作数 r8 就绪了。I5 也被调度器发射到访存单元去执行存储操作。而 I2 还在等 p16也就是 I1 的 load 结果。它只能继续蹲在保留站里。3.3 慢速指令回来了快速指令已经悄悄干完等到第 80 个周期I1 的 load 结果终于从内存返回了。这个结果通过 CDB 广播I2 捕获到 r2 的值源操作数就绪立即被发射到乘法单元执行。I2 执行完结果写入 ROB 条目I1、I2 的前面已没有未提交指令了按顺序依次退休。I3、I4、I5 的 ROB 记录虽然早就标记为执行完成但它们必须等 I1、I2 先提交才能依次提交。最终提交顺序依然是 I1 → I2 → I3 → I4 → I5和程序原始顺序一模一样。对比一下总时间顺序执行I1 等待 80 周期I2 随后执行I3、I4、I5 全在 I2 之后排队整体完成至少 80 3 个周期以上而且这还是理想化情况。乱序执行I1 等待的 80 个周期里I3、I4、I5 已经执行完毕I1 回来后只需再执行 I2整体完成只需要 80 2 个周期左右。如果程序后面还有几十条和 I1 无关的指令乱序执行的收益会更大——那几十条指令全都在 load 等待期间偷偷算完了。这就是倒着干照样快的秘密它不是把慢指令变快了而是让其他指令不用陪着慢指令一起耗时间。4. 数据冒险与解决实战为什么乱序之后还能对很多人刚接触乱序执行时最担心一件事执行顺序都打乱了万一某条指令读写同一个寄存器顺序一变结果不就错了这个担心是对的乱序执行的正确性全部建立在解决数据冒险之上。数据冒险分三种解法各不相同。4.1 三类数据冒险逐个识别假设有两条指令 A 和 BA 在程序顺序上排在 B 前面它们可能产生三类冲突冒险类型全称冲突场景举例RAWRead After WriteB 要读 A 才写出的值顺序不能乱I1: mul r3, r1, r2I2: add r5, r3, r4WARWrite After ReadB 要写一个 A 正在读的寄存器I1: add r3, r4, r5I2: sub r4, r6, r7WAWWrite After WriteA、B 都要写同一个寄存器I1: mul r3, r1, r2I2: add r3, r8, r9RAW 是真依赖因为第二条指令确实要等第一条的结果这是运算逻辑本身决定的没法消掉。RAW 只能通过等待来解决乱序执行也得乖乖等着。WAR 和 WAW 则属于假依赖。它们不是因为数据真的需要传递而是因为两条指令碰巧用了同一个寄存器名字。比如 WAR 里A 要读 r4B 要写 r4其实 A 读的是旧值B 写的是新值只要把两个值放到不同的物理位置它们同时进行完全没问题。乱序执行的核心手段——寄存器重命名就是专门来消灭这两种假依赖的。4.2 寄存器重命名如何根治 WAR 和 WAW拿下面三条指令来演示I1: add r1, r2, r3 // 第一次写 r1 I2: sub r4, r1, r5 // 读 r1这是 RAW必须等 I1 I3: mul r1, r6, r7 // 第二次写 r1新的 r1如果不做重命名I2 要读 r1I3 要写 r1。顺序执行下没问题I2 先读旧 r1I3 再写新 r1。但乱序执行下硬件想提前执行 I3——它和 I1、I2 都没有真依赖——可是如果 I3 直接把 r1 覆盖了I2 再去读 r1 就读到了新值结果就错了。这就是 WAR 冒险卡住了调度器。加上寄存器重命名之后I1 写 r1硬件分配物理寄存器 p20映射表变成 r1 → p20。I2 读 r1译码时查映射表发现 r1 对应 p20于是它等待的操作数变成物理寄存器 p20 的结果跟 r1 这个名字彻底无关了。I3 写 r1硬件从物理寄存器池里另取一块 p21映射表更新为 r1 → p21。看I2 等的是 p20I3 写的是 p21。两者在不同物理位置上I3 提前执行根本不影响 I2。WAW 也一样I1 写 p20I3 写 p21两个物理寄存器互不干扰最后提交时按程序顺序决定架构 r1 的最终值是 p20 还是 p21——I3 在程序顺序上靠后所以提交时把 p21 作为 r1 的最终值p20 释放回池子。这就是重命名的精髓把逻辑名冲突转换为物理位置隔离假依赖就此消失。你会发现真正留下来的就只剩 RAW 真依赖了而 RAW 是程序逻辑里绕不开的等待谁来了都得等。4.3 访存指令的特殊处理Load/Store 队列与内存消歧寄存器冒险解决完还剩一类更麻烦的——内存冒险。寄存器只有一个名字体系内存地址可复杂多了两条指令访问的内存地址可能在运行时才能算出来。举例I1: store [r1], r2 // 往前地址 r1 写值 I2: load r3, [r4] // 从地址 r4 读值如果 r1 和 r4 指向同一个内存地址那么 I2 应该读到 I1 写入的新值这是 RAW 依赖。乱序执行时I2 可能被调度器提前发射了而 I1 还没来得及写内存于是 I2 读到了旧值这就错了。为了解决这个问题CPU 里有一组 Load/Store 队列专门跟踪每条访存指令的地址和执行状态。硬件会做内存消歧Memory Disambiguation在 load 指令被提交之前检查前面的 store 指令里有没有地址和它相同的如果有就先把 store 的数据传给 load或者干脆强制 load 等 store 执行完再读。这个机制比较隐蔽但它保证了乱序执行下访存指令之间依然语义正确。值得一提的是现代 x86 的强内存模型还会进一步限制 load 的重排程度所以你能在 CPU 实测数据里看到 load 指令的调度比算术指令保守得多。4.4 从 Tomasulo 到现代微架构的演进乱序执行的算法基础最早来自 IBM 工程师 Robert Tomasulo 在 1967 年提出的 Tomasulo 算法。它在 IBM System/360 Model 91 上实现核心思想就是保留站 公共数据总线 寄存器重命名。今天的乱序 CPU 虽然微架构五花八门但整体框架仍然是 Tomasulo 的思路。后来的变化主要集中在窗口大小和调度器设计上Intel 的 Core 架构ROB 在 100~500 多条不等具体取决于微架构代号近几代在 512 条左右。AMD 的 Zen 系列ROB 规模大概在 224~320 条之间配合多组整数调度器和浮点调度器工作。ARM 的高性能核Cortex-X 系列和 Apple 的 Firestorm 系也在维持非常庞大的乱序窗口Apple M 系列芯片的 ROB 能到 600 条以上调度器条目极多。窗口越大越能容忍长延迟事件。一个 500 条 ROB 的 CPU可以在一次高延迟访存等待期间最多容纳几百条独立指令在飞行。这就是为什么大乱序窗口的 CPU 在单线程整数性能上特别能打。问题类型解决办法后面的指令要读前面的结果RAW真依赖只能等靠保留站和 CDB 精确等待后面的指令要写前面的源寄存器WAR假依赖寄存器重命名隔离到不同物理寄存器两条指令写同一个寄存器WAW假依赖寄存器重命名提交时按序覆盖内存读写可能指向同一地址内存冒险Load/Store 队列 内存消歧5. 现实权衡与性能观察乱序执行没那么神也没那么玄5.1 乱序窗口越大越好吗理论上乱序窗口大到天上IPC 应该无限高。但现实有两堵墙挡着第一堵墙是依赖链。程序里真正可并行的独立指令是有限的如果一个程序本身就是一串强依赖的计算窗口再大也没用因为每条指令都得等上一条算完。这种程序叫延迟敏感型乱序执行对它帮助有限。第二堵墙是功耗和面积。保留站的每一项都需要比较器、标记位、数据缓存ROB 的每一项都要记录状态和结果物理寄存器池的端口数量直接决定每周期能重命名多少条指令。这些资源都是真金白银——晶体管烧钱漏电烧电。所以芯片设计者不会无限放大窗口而是在性能、功耗、面积之间找个平衡点。这些年还有一个趋势乱序窗口继续增大带来的性能收益越来越小边际效益递减非常明显。与其堆一个大窗口不如再加一条 SMT 线程去填满空闲槽位或者做大小核异构把大乱序核心留给重负载、把小核心留给轻负载。这也是为什么你在桌面 CPU 上看到的多核策略越来越复杂。5.2 乱序执行与超线程、多核的区别不少初学者会把乱序执行和超线程混在一起其实它们解决的问题完全不同。乱序执行是在一个线程内部找指令级并行。超线程SMT是让一个物理核心同时维护多个线程的上下文让这些线程共享同一套乱序执行硬件。线程 A 的指令在等内存时调度器可以拉线程 B 的指令来执行。它的核心是提升执行单元利用率并不是重新发明了一套乱序机制。多核则是每个物理核心各有一套独立的前端、乱序引擎、执行单元和缓存接口靠的是线程级并行。简单记乱序执行是一条流水线里找并行超线程是一条流水线里跑多个程序多核是多套流水线同时跑。现代 CPU 三者叠加使用这也是为什么单看会懵的原因。5.3 怎么在系统里观察到乱序执行的效果理论再多不如自己看一眼。Linux 下最方便的工具是 perf它能看 IPC每周期指令数这是衡量乱序执行收益最直观的指标。# 跑一个 5 秒的 CPU 压力测试统计指令数和周期数 perf stat -e instructions,cycles,ipc stress-ng --cpu 1 --timeout 5如果 IPC 能跑到 3 以上说明乱序执行和分支预测在拼命工作平均每个周期退休了好几条指令。如果 IPC 只有 0.5说明程序几乎每个周期都在等待很可能是一条长长的依赖链在作祟。你还可以自己做一个对照实验写一段大量独立乘法运算的程序再写一段串行累加的程序前者依赖编译器帮忙产生大量独立指令后者天然是一串依赖链。用 perf 统计你会发现前者的 IPC 明显高于后者。这个实验能直观地让你感受到乱序窗口救不了真依赖这句话。我自己踩过的坑也和依赖链有关。有一段时间调一个数值计算模块怎么优化都跑不上性能指标各种缓存优化做了个遍也没用。后来用 perf 看 IPC才发现隔三差五的跳转方向和一条频繁更新的循环计数器形成了强依赖链每次迭代都要等上一次迭代的结果。最后靠循环展开loop unrolling打破了依赖链IPC 一下子翻了一倍问题不了了之。这件事给我最大的教训是做性能优化时光想算法复杂度不够还得看指令之间的依赖结构乱序执行帮不了你打破逻辑上的先后次序。5.4 做性能优化时该怎么和乱序执行打交道了解了乱序执行之后写代码时你会多几个判断维度。不用手动乱序。千万不要因为 CPU 会乱序执行就在源码里乱调语句顺序。编译器在生成汇编时已经做了指令调度硬件在运行时还会再做一次动态调度你写得再乱也帮不上忙反而可能妨碍矢量化等优化。关注访存局部性就行。乱序执行能藏住首次 miss 的延迟但藏不住内存带宽的极限。如果你的程序频繁跨缓存行访问乱序窗口再大也没用最终还是得回到优化访问模式这条路上。依赖链是最大的敌人。在循环内部尽量让每轮迭代之间的指令相互独立。循环展开是打破循环进位依赖最直接的手段配合乱序执行IPC 会有肉眼可见的提升。分支密集代码要谨慎。如果分支预测频繁出错乱序窗口里塞满了错误路径的指令后面的提交流程全被污染。能用分支消除branchless处理的条件逻辑尽量改成算术运算。说到底乱序执行是一个高效的隐藏延迟机制它确实能让你在大多数场景下无感获得很高的单核性能但它的上限始终悬在程序本身的依赖结构之上。如果你正处于学习体系结构的阶段我强烈建议不要只看架构图一定要配合 perf、pmu-tools 这类性能分析工具去看真实程序跑出来的数据。纸上谈兵理解不了 CPU 的设计意图看到 IPC 从 0.8 变成 3.5 那一刻你对乱序执行的理解才算真正落地。