汇编耻辱殿堂
文章摘要
Assembly Hall of Shame(汇编耻辱殿堂)是安全研究者 Christopher Domas(网名 xoreaxeaxeax,sandsifter、cantor.dust、movfuscator 的作者)的一个研究项目,它把常规做法整个反了过来。
README 开门见山:指令延迟分析通常关注性能优化——让代码跑得越快越好。这个项目走相反方向:寻找单条指令性能的绝对下限。也就是说,这是一份排行榜,比的是谁能让一条指令跑得最慢。
规则设计得相当讲究,这也是这个项目不流于恶搞的原因:
- 指令可以使用任何必要的准备工作,但只有单条指令计入分数。
- 被陷入/模拟/虚拟化的指令只能计时陷入本身,不能计时处理程序。
- 指令必须不可中断——
rep movs、pause之类一律取消资格。 - 时间按 CPU 基频归一化。
- 所有平台必须是出厂原始配置,不允许硬件改装。
当前 x86 冠军的成绩是 198,002,498,236 个周期,也就是 62 秒——一条指令。
夺冠策略非常精妙:用 fxrstor64 从 PCIe fabric 中一块高延迟 MMIO 区域加载 512 字节的 FPU/MMX/XMM 状态,同时在这条加载在途时把整个 fabric 饿死——一队「锤子核心」用紧凑的 4 字节读循环猛砸另一个高延迟 MMIO 寄存器,用非投递(non-posted)事务把 PCIe 根复合体和端点都灌满,于是 CPU 0 那条 512 字节的 fxrstor64 就必须排在所有这些争用流量后面。参赛硬件是 AMD Ryzen 7 5800H。
整个排行榜读下来就是一部微架构病理学教材,从倒数第一路爬到冠军,每一级都对应一种不同的慢速机制:
- 第 27 名
nop,1 个周期,0 纳秒——「nop什么也不做,排行榜相应地由它开启。」 - 第 26 名
nop16,20 个周期——「普通nop太短了,但要怎么让『什么都不做』变得更久?试试超长的 nop。」(7 个 data16 前缀) - 接下来是一串微码(microcode)路径:
idiv用 128 位被除数配小除数把商顶到符号扩展上限之上,走完除法器微码最长的路径(77 周期);enter $0, $31用最大嵌套深度 31 强制 30 次显示指针加载和压栈(112 周期);fldl/fsin/fadd/fdiv全都靠非正规数(denormal/subnormal)触发浮点微码辅助(133 / 257 / 677 / 883 周期)。 - 第 16 名 split lock(865 周期):把
lock前缀的操作数对齐到跨越缓存行边界,迫使 CPU 断言外部总线锁,而不是走快速的 MESI 缓存一致性路径。 - 第 19 名
mfence(326 周期):先用movnti向 16 条不同缓存行写入,把所有写合并的 line-fill buffer 灌满,逼mfence在退休前把整条 LFB 写路径排空到 uncore。 - 第 13 名
rdrand(5,579 周期):在紧密循环里执行,把硬件熵池抽干得比它补充还快,后续调用只能等熵源恢复。 - 第 12 名
wrmsr(34,304 周期):用作者自己的 nightshyft 项目找出高延迟 MSR,Zen 上的 MCG_CTL 是赢家——可能是跨硬件单元对 MCA 错误 bank 做微码静默与同步,其中一些可能在片外,需要 fabric 级通信而非简单的本地寄存器写。 - 第 10 名
rdmsr(161,602 周期,202 微秒):VIA 在 0x133 有一个未文档化的寄存器,响应时间高得离谱。「不知道它是干嘛的。」参赛硬件是 VIA Eden 800MHz。 - 第 9 名
wbinvd(1,616,480 周期):把 L1/L2/L3 全部装满脏行,强制整个层级回写到 DRAM。 - 第 8 名
in(12,524,415 周期,3.9 毫秒):瞄准映射到 ACPI PM 块的 I/O 端口,一次非对齐 4 字节读被解码成多次非投递加载。 - 第 7 到第 3 名是同一个思路的逐级放大:用作者的 mmiotic 工具在 PCIe fabric 里找出高延迟的「死区」,命中一个未知的 GPU 寄存器,然后不断增大单次访问宽度——
mov4 字节(444M 周期)、movq8 字节(888M)、vmovdqu xmm16 字节(1.77G)、vmovdqu ymm32 字节(3.55G),最后是 32 字节非对齐读拿到 9 次 dword 寄存器访问(4.45G 周期,1.39 秒)。README 对这几条的注释一路递进地好笑:「严格来说这不被允许,但就是能用」→「仍然不被允许,但就是能用」→「比对齐版本更加不被允许」。 - 还有一条留了 TODO 的未来展望:Sapphire Rapids 上用 AMX 扩展状态配合同样的 MMIO 手法跑
xrstor64——xsave状态区是 8KB 而非 512 字节,16 倍大小,估算 1,000,000,000,000 个周期。
这项研究不是纯粹的玩票。 README 的「荣誉提名」直接点明了实用价值:那条违反规范的非对齐 ymm0 加载(从停滞的 GPU 寄存器强制发出非投递 dword 事务)被用来攻破系统管理模式(SMM)的基础设计,成果发布在作者的另一个项目 smiiiiiiiiiiiiiiii 里。换句话说,一条能跑几百毫秒到几秒的指令,是打破 SMI 时序假设的武器。
ARM 和 RISC-V 排行榜目前都是 T.B.D.。
HN 评论精华
这条帖子拿到 434 分。讨论的真实走向相当分散:一部分人在硬核补充微架构知识,一部分人顺着「计算机为什么还是这么慢」跑题跑得很远,还有一大段是关于 nop 到底做不做事的极限抬杠。
对作者其人的补充(本帖信息密度最高的部分)
- TomatoCo 列举了作者的其他作品:一个只发射
mov指令的编译器(movfuscator),以及一个故意扰乱控制流的编译器——反汇编时常见调试器画出的控制流图会呈现骷髅头或威胁性图案(repsych)。 - inigyou 补充:他还暴力枚举了整个操作码空间来寻找未文档化指令(sandsifter)。
- vanderZwan 再补一条:他也是原始的 ..cantor.dust.. 二进制可视化工具的作者,「这个工具我从没直接用过,但 Chris 在 2012 年对它的演讲仍是我看过最酷的技术演讲之一」。
- spoocecow 一句感慨:「哇,很高兴看到 Chris Domas 又活跃起来了!」markus_zhang 顺势猜测:「这是不是意味着 Domas 准备好开始下一场冒险了?」
- Telaneo 给了本帖对作者最贴切的评价:「Domas 蹂躏 x86 的方式让我搞不清自己该敬佩还是该恶心。我想还是敬佩吧,然后去恶心 Intel(和 AMD?)。」
技术性质疑与补充
- IshKebab 提出了本帖最实质的批评:「用 MMIO 是作弊,而且让结果变得很无聊。如果只允许用主存,结果会有意思得多。」这条批评切中要害——排行榜前七名基本都是同一个 MMIO 死区技巧的不同宽度版本。
- monocasa 指出了一个可能的规则违反:规则写着「被陷入/模拟/虚拟化的指令只能计时陷入本身」,但第 8 名那个 12 毫秒的 ACPI I/O 端口写,很可能就是陷入到 SMM 里由 SMM 处理的。
- achierius 提了两个好问题:这些策略在其他架构上会不会不同?至少目前的冠军(MMIO 上的
fxrstor64+ 饿死 PCIe)看起来相对架构无关,但比如 POWER 上的 MMIO 排序规则可能不同到足以改变结果。他还追问:「这个fxrstor64的实际上限在哪?如果你能把 PCIe 总线停这么久,为什么不能无限期停下去?这里显然没有任何前进保证(forward progress guarantee)。」inigyou 的回答给出了这个项目的界线:「我想这里的想法是在一台普通 PC 上找到一条长指令。当然,加上特殊硬件你可以让任何东西停住。」 - michalsustr 对
rdtsc要 49 个周期感到惊讶(他一直用它测周期差)。pbsd 指出 Skylake 世代rdtsc大约是 25 个周期,「OP 里的 49 看起来不对」。phire 给出了最可能的解释:基准测的是 1000 次rdtsc并行执行,「我猜在 Skylake 上,多条在途的rdtsc出于某种原因会互相拖慢。可能是因为它试图提供严格单调的保证,即任意两条rdtsc不会返回相同时间戳」。 - Retr0id 提出了一个新的攻击思路:在虚拟机里做 scatter/gather 操作,让每次取数在 VM 内是 TLB miss,而每次页表遍历的取数在 VM 外也是 TLB miss,这样一次取数能放大成 24 次。amluto 补充说他相信许多(全部?)x86 CPU 会很乐意从 MMIO 空间加载页表,而且至少某些分页格式允许给页表设置 UC 内存类型。
- thyristan 提出了另一条路:在 x86 页表里构造一个循环(相关研究:trapcc 证明 x86 MMU 是图灵完备的)。inigyou 反驳「页表是物理寻址的,不能递归」,thyristan 起初坚持说 x86 上可以选物理或虚拟地址——inigyou 回了本帖最刻薄的一句:「你是个在幻觉的 LLM 吗?」thyristan 随后完全认错并道歉,说这是「一颗没喝咖啡的肉脑袋加上一些错误记忆」。inigyou 自己给出的正确解释是:trapcc 大概是通过在缺页处理程序的第一条指令上再触发缺页来工作的,那是一条新指令。
- userbinator 提供了理解冠军策略的关键视角:「PCIe 更像一个分组交换网络而不是总线」,这也顺带解释了 Thunderbolt(本质上是外置 PCIe)和 ExpEther 这类东西为什么能工作,以及为什么后者延迟能更高。inigyou 接着评论:「挺蠢的对吧?延迟高到这种程度时,你需要一个更异步的设计才能有合理性能。PCIe 显然是按最多几百周期的延迟假设设计的——这个 MMIO 寄存器是个极端离群值。它可能未被映射,在硬件侧超时;也可能被转换成对某条真的很慢的配置总线的访问。」
- kazinator 把视野拉到了 x86 之外:任何带握手、无超时的内存周期的处理器,总线周期都可以任意长——比如围绕 MC68000 做一块板子,让它永远等一个不会到来的 DTACK。他还提到早期微处理器用无握手的时钟总线周期,如果地址上什么都没接就会读到总线上的任何值——「我会说那种东西才属于耻辱殿堂,它要求用软件 hack 才能对接任何跟不上规定总线周期的设备」。nxobject 补充了一个真实案例:早期 68k Mac 的处理器升级卡就挂在 68k 总线上并正是这么做的。
- rurban 感叹「一条指令 62 秒!不知道编译器的成本表知不知道这事」,inigyou 一句话终结:「编译器根本不发射这条指令。」
关于 nop 的一场极限抬杠
- layer8 开了个玩笑:「
nop应该排第一,因为相对于它做的事情而言,它是无限慢的。」(README 里nop排第 27,也就是最后一名。) - jooops1 接话:「它把 rip 加了一。」fluoridation 反驳:「不,那是解码器干的。它确实什么也不做。」由此展开了一场跨越十层嵌套的争论。loeg 认为解码器是 NOP 的实现细节;fluoridation 反问:如果说 NOP 会把 IP 加一,那我们是不是也该说 ADD「在 dst 里存 src 和 dst 的和,并且把 IP 加上指令长度」?
- phire 给出了最专业的收尾:按规范可以说它把 RIP 加一,但典型硬件实现是从 icache 取下 16–32 字节、对齐后塞进一堆并行解码器(每个都尝试从某一字节开始解码一条 x86 指令),下一周期前 1–6 条不重叠的有效指令进入队列做进一步解码——「在任何时点 RIP 都没有被加一。甚至不存在一个物理的 RIP 寄存器可以去加;CPU 正在并行『执行』几十甚至几百个 RIP。」
- amluto 补上了最后一层精确性:NOP 和所有非错误、非控制转移指令一样,把 RIP 加上指令长度,而这个长度可能是也可能不是 1——带前缀的 NOP 仍然是 NOP。
- bonzini 纠正了一段流传的错误知识:JoeAltmaier 说曾有多个 NOP(
XCHG BX,BX等)后来被拿去做新指令类的前缀,bonzini 指出没有一个是这样的——XCHG AX,AX特殊是因为XCHG AX,reg有单字节编码;对方大概是把两件事搞混了:POP CS因为设计有缺陷后来变成了前缀,以及某些操作码是「保留的 NOP」(保留但不产生 #UD),用于将来可能定义的指令同时保证向后兼容,比如新的 prefetch 指令、MPX 边界检查指令。 - EvanAnderson 记得看过 8086 微码反汇编的说法:编码为
XCHG AX,AX的 NOP 实际上确实跑了 XCHG 微码,用一个内部暂存寄存器做交换。 - codeshaunted 的总结:「我从这个图表里看到的结论是,我们应该所有事都用
nop指令。」bee_rider:「最好的代码是没有代码。不过nop可以排第二。」inigyou 收尾:「指令不清楚。我设置了 NX 位来确保没有代码,然后拿到了一个通用保护错。」
跑题最远的一条支线:计算机为什么还是慢
- metadat 起头:「考虑到 1 毫秒里能执行多少条指令,计算机每过几年还是会明显变慢,这事挺疯狂的。可耻,甚至。那条关于程序员把所有算力浪费在抽象上的定律叫什么来着?」(mwigdahl 回答:Wirth 定律;inigyou 补了个双关梗,另有人提到 Andy and Bill’s Law。)
- AceJohnny2 给出了最有解释力的分析:计算性能有延迟和吞吐两面。业界为提升吞吐做了海量工作,但这经常以延迟为代价,因为提升吞吐的一大手段就是批处理——用它来摊薄单任务开销。他引用了 Dan Luu 2017 年那份著名的「计算机延迟」表格(测量按键到屏幕可见变化的时间),其中 Apple IIe 的延迟比跑 Windows 的联想 X1 Carbon 低 5 倍。他补了一句很公道的话:从某个角度看,考虑到涉及的所有子系统(USB、中断分发、输入层、窗口系统、双缓冲……),联想那个例子其实是了不起的工程成就。
- 中间还岔出一整段关于人类能感知多少延迟的讨论:m463 引用 Jakob Nielsen 的经典三档(0.1 秒感觉即时、1 秒思维流不被打断、10 秒是注意力上限),citelao 则补充说人类能感知的延迟远比这小得多——打字时至少部分人能分辨 50ms 和 100ms,60Hz 和 120Hz 可以可靠区分,而在有参照的情况下(比如触屏拖拽)人能分辨到 1ms 和 10ms 的差别。
- 还有一小段变成了 Windows 应用吐槽(HappyPanacea:「新版记事本是个耻辱」;adamrezich 详细控诉新版画图把他几十年的肌肉记忆先搞坏、再修好、再搞坏一次)。
最后一条八卦
- darksim905 怀疑「首页上有两个这个作者的东西,看着像作者本人在刷屏」。john_strinlai 做了个漂亮的澄清:两条是不同的人提交的,两个账号都有一年以上历史和不错的 karma,「我认为都不是作者本人。另一个提交者大概是读了这一条,去看了 GitHub,发现了别的好东西然后也发了(我差点也这么干,不过我把它加书签了)」。
</content>