未定义操作码 ud0 和 ud1 怎么会带操作数?它们到底有多"未定义"?
摘要
Raymond Chen 解释了 x86 操作码 ud0 和 ud1 为何在"未定义"的情况下仍会表现为带有操作数——它们占据了未被赋予意义的解码槽位,但在触发非法操作码异常之前,仍会经过操作数解析流程。
<p>Gunnar Dalsnes 想知道<a href="https://devblogs.microsoft.com/oldnewthing/20260910-00/?p=112689/&commentid=144727#comment-144727">如果 ud0 和 ud1 是未定义的,它们怎么会有操作数</a>?"这是不是意味着它们并非完全未定义,只是没有文档记录,也没有完全实现?"</p>
<p>操作码 <code>ud0</code> 和 <code>ud1</code> 没有任何定义,但这与在架构层面被认定为"未定义指令"并不相同。它们处于一种半途状态:既没有被赋予含义,也没有被正式声明为无意义。</p>
<p>最初,这些字节序列会经过指令解码器,并且碰巧从几个缝隙中溜了过去,直到有人终于注意到:"等一下,我不知道该如何执行这条指令。"</p>
<p>查看 Pentium III 时代编码为 <code>00001111 11111xxx</code> 的指令,你就能看到这一点。</p>
<table class="cp3" style="border-collapse: collapse;" border="1" cellspacing="0" cellpadding="3">
<tbody>
<tr>
<th>位</th>
<th>字节</th>
<th>操作码</th>
<th>操作数 1</th>
<th>操作数 2</th>
<th>含义</th>
</tr>
<tr>
<td><tt>00001111 11111000</tt></td>
<td><tt>0F F8</tt></td>
<td><tt>PSUBB</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>减去打包字节</td>
</tr>
<tr>
<td><tt>00001111 11111001</tt></td>
<td><tt>0F F9</tt></td>
<td><tt>PSUBW</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>减去打包字</td>
</tr>
<tr>
<td><tt>00001111 11111010</tt></td>
<td><tt>0F FA</tt></td>
<td><tt>PSUBD</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>减去打包双字</td>
</tr>
<tr>
<td><tt>00001111 11111011</tt></td>
<td><tt>0F FB</tt></td>
<td colspan="4" align="center">未被赋予含义</td>
</tr>
<tr>
<td><tt>00001111 11111100</tt></td>
<td><tt>0F FC</tt></td>
<td><tt>PADDB</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>加打包字节</td>
</tr>
<tr>
<td><tt>00001111 11111101</tt></td>
<td><tt>0F FD</tt></td>
<td><tt>PADDW</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>加打包字</td>
</tr>
<tr>
<td><tt>00001111 11111110</tt></td>
<td><tt>0F FE</tt></td>
<td><tt>PADDD</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>加打包双字</td>
</tr>
<tr>
<td><tt>00001111 11111111</tt></td>
<td><tt>0F FF</tt></td>
<td colspan="4" align="center">未被赋予含义</td>
</tr>
</tbody>
</table>
<p>字节序列 <code>0F FB</code> 和 <code>0F FF</code> 尚未被赋予含义。你可以看出指令解码器如何走了捷径:"嗯,这一范围内的所有指令,或者至少是我在乎的所有指令,都接受一个 mm 寄存器和一个 mm/m64 操作数,所以我干脆省一些晶体管,统一用两个参数(mm、mm/m64)来解码它们。"然后在解码之后,它会用第 3 位来决定是将算术单元配置为加法还是减法,并用第 0 位和第 1 位来决定如何将这些位细分为饱和单元。</p>
<p>如果你给它一个 <code>0F FF</code>,解码器只会在最后一步才意识到:"糟了,我不知道该如何处理 <tt>11</tt> 这个位组合。我要触发非法操作码指令。"</p>
<p>非法操作码异常是在操作数解析<i>之后</i>才被触发的。</p>
<p>当这些空槽位开始被 SSE 指令填充时,你就能看到 <code>0F FF</code> 造成的麻烦。</p>
<table class="cp3" style="border-collapse: collapse;" border="1" cellspacing="0" cellpadding="3">
<tbody>
<tr>
<th>位</th>
<th>字节</th>
<th>操作码</th>
<th>操作数 1</th>
<th>操作数 2</th>
<th>含义</th>
</tr>
<tr>
<td><tt>00001111 11111000</tt></td>
<td><tt>0F F8</tt></td>
<td><tt>PSUBB</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>减去打包字节</td>
</tr>
<tr>
<td><tt>00001111 11111001</tt></td>
<td><tt>0F F9</tt></td>
<td><tt>PSUBW</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>减去打包字</td>
</tr>
<tr>
<td><tt>00001111 11111010</tt></td>
<td><tt>0F FA</tt></td>
<td><tt>PSUBD</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>减去打包双字</td>
</tr>
<tr style="background-color: color(from currentcolor srgb r g b /15%);">
<td><tt>00001111 11111011</tt></td>
<td><tt>0F FB</tt></td>
<td><tt>PSUBQ</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>减去打包四字</td>
</tr>
<tr>
<td><tt>00001111 11111100</tt></td>
<td><tt>0F FC</tt></td>
<td><tt>PADDB</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>加打包字节</td>
</tr>
<tr>
<td><tt>00001111 11111101</tt></td>
<td><tt>0F FD</tt></td>
<td><tt>PADDW</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>加打包字</td>
</tr>
<tr>
<td><tt>00001111 11111110</tt></td>
<td><tt>0F FE</tt></td>
<td><tt>PADDD</tt></td>
<td>mm</td>
<td>mm/m64</td>
<td>加打包双字</td>
</tr>
<tr style="background-color: color(from currentcolor srgb r g b /15%);">
<td><tt>00001111 11111111</tt></td>
<td><tt>0F FF</tt></td>
<td colspan="4" align="center">我想把 <tt>PADDQ</tt> 放在这里但做不到</td>
</tr>
</tbody>
</table>
<p>放置 <code>PADDQ</code> 指令的自然位置是 <code>0F FF</code>,但人们已经在使用 <code>0F FF</code>,并且期望它触发非法指令异常。如果把它变成一条合法指令,就会破坏那些程序,所以 Intel 不得不将 <code>PADDQ</code> 移到颇为别扭的位置 <code>0F D4</code>。</p>
<p><b>额外补充</b>:带操作数的未定义指令其实并不少见。例如,在 AArch64 上,<a title="The AArch64 processor (aka arm64), part 19: Miscellaneous instructions" href="https://devblogs.microsoft.com/oldnewthing/20220822-00/?p=107032">有一段 65,536 条指令的范围被永久保留为未定义</a>,因此 <code>udf</code> 指令接受一个 16 位立即数来指定你想要的无效操作码。PDP-10 将操作码 000 保留为永久非法指令,而它带有寄存器和内存地址作为参数。(因为<i>所有</i> PDP-10 指令都带有寄存器和内存地址作为参数。)</p>
<p>还有一些所谓的"非官方操作码",它们不属于指令集架构的一部分,但被人们逆向工程发现了稳定的行为并开始依赖。(6502 处理器在极客圈中就因经历过这种分析而<a title="How MOS 6502 Illegal Opcodes really work" href="https://www.pagetable.com/31">广为人知</a>。)<code>0F FF</code> 就是这样一个"非官方操作码",它太流行了,以至于 Intel 感到压力必须维持向后兼容,尽管它从未被架构文档记录或支持过。</p>
<p><b>额外的额外补充</b>:看起来助记符 <code>ud1</code> 是<a href="https://github.com/netwide-assembler/nasm/blob/nasm-0.98.x/CHANGES#L827-L831">由 nasm 汇编器引入的</a>:</p>
<pre>* 新增了以下新指令:SYSENTER、SYSEXIT、FXSAVE、
FXRSTOR、UD1、UD2(后两个是 Intel 保证永远不会使用的两个操作码;
其中一个在 Intel 文档中被记录为 UD2,另一个仅标记为 "Undefined Opcode"——
<span style="border: solid 1px currentcolor;">将其称为 UD1 似乎很有道理</span>。)
</pre>
<p>看起来很明显,<code>ud1</code> 也是 Intel 在内部给那条遗留指令取的名字。否则,就完全没有必要把新指令叫做 <code>ud2</code> 了!</p>
<p>本文《<a href="https://devblogs.microsoft.com/oldnewthing/20261007-00/?p=112759/">How can undefined opcodes <CODE>ud0</CODE> and <CODE>ud1</CODE> have parameters? How undefined were they?</a>》最初发表于<a href="https://devblogs.microsoft.com/oldnewthing">The Old New Thing</a>。</p>
相似文章
为什么x86的未定义指令叫做ud2?为什么是2?
本文解释了x86未定义指令'ud2'命名背后的历史和原因,追溯了其从非官方操作码到Intel官方实现的演变,这得益于Hyrum定律。
# 为什么编译器有时使用 `ud2`,有时使用 `int 3` 来标记不可达代码? ## 核心原因 编译器在遇到**不应该被执行的代码**(即"不可达代码"或"陷阱区域")时,会插入特定的机器指令来主动触发崩溃,而不是让它默默执行未定义行为。选择 `ud2` 还是 `int 3` 取决于多个因素。 --- ## 两种指令的区别 | 特性 | `ud2` | `int 3` | |------|-------|---------| | 含义 | 触发"非法指令"异常(#UD) | 触发断点异常(#BP) | | 异常类型 | SIGILL / EXCEPTION_ILLEGAL_INSTRUCTION | SIGTRAP / EXCEPTION_BREAKPOINT | | 字节长度 | 2 字节(`0x0F 0x0B`) | 1 字节(`0xCC`) | | 语义 | "这不是一条合法指令" | "这是一个软件断点" | --- ## 为什么选择 `ud2`? ### 1. 语义更准确 `ud2` 的含义是 **"非法指令"**——即 CPU 明确知道"这条指令根本不存在"。这最能表达编译器的意图: > "如果你执行到了这里,说明程序逻辑出了严重错误。" ### 2. GCC 的默认选择 GCC 在大多数情况下使用 `ud2` 来标记不可达路径,例如: - `__builtin_unreachable()` 的实现 - `switch` 语句中缺少 `default` 分支时 - `noreturn` 函数在末尾的填充 ### 3. 不会被误认为是有意设置的断点 调试器可能会对 `int 3` 做特殊处理(比如自动恢复执行),而 `ud2` 通常会直接被当作致命错误处理。 --- ## 为什么选择 `int 3`? ### 1. Clang/LLVM 的偏好 Clang 编译器更倾向于使用 `int 3`(通常是填充用的 `0xCC` 字节),原因是: - **单字节**:`0xCC` 只有 1 字节,可以更紧凑地填充对齐区域 - **历史惯例**:x86 平台上 `0xCC` 长期被用作填充字节(从 MS-DOS 时代开始) - **MSVC 风格**:Microsoft 的工具链传统上使用 `0xCC` 填充 ### 2. 填充对齐区域 在函数之间或代码段之间的**对齐填充(padding)**中,编译器会用 `int 3` 填充未使用的字节: ```asm func_end: nop ; 对齐填充 int3 ; 或者用 0xCC 填充到下一个对齐边界 next_func: push rbp ... ``` ### 3. 与调试器配合 `int 3` 正是软件断点的实现方式,所以在某些调试场景下,`int 3` 更容易被调试工具识别和处理。 --- ## 总结对比 ``` 编译器 不可达代码标记 填充字节 ───────────────────────────────────────────── GCC ud2 (0x0F 0x0B) nop / int3 Clang int3 (0xCC) int3 (0xCC) MSVC __debugbreak/ud2 0xCC ``` **简单来说:** - **`ud2`** = "这里不应该被到达,如果到了就是 bug" - **`int 3`** = "这里不应该被执行,同时方便调试器处理" 两者都是**安全的陷阱**,确保程序不会默默地继续执行未定义的行为,而是立即崩溃并产生一个可诊断的异常。选择哪一个更多是**编译器团队的偏好和历史传统**,而不是功能上的本质区别。
Raymond Chen 解释了为什么编译器在不可达代码中使用 `int3` 而非 `ud2`:`int3` 用于标记不应存在代码的填充区域(即无效的函数指针),而 `ud2` 用于标记违反语言层面未定义行为的路径。
明明有 int 6,为什么还需要 ud2?
Raymond Chen 解释了为什么 x86 需要专门的 ud2 指令,而不能直接用 int 6 来触发未定义指令异常:int 6 是陷阱(trap,报告下一条指令的地址),而 #UD 是故障(fault,报告出错指令的地址);而且操作系统会禁止用户态调用 int 6,以免混淆 #UD 处理程序。
Intel 8087 浮点芯片微码中的条件
对 Intel 8087 浮点协处理器微码中使用的条件测试的详细研究,是逆向工程工作的一部分,旨在理解其算法。
Vfmaddrnd231pd 是谁?
一篇博客调查了 Intel SDM 手册中记载、但在实际 CPU 上会触发 #UD 的幽灵指令 vfmaddrnd231pd 的来源之谜,推测 Intel 在最后一刻移除了该指令,却未同步更新文档。