|
| 1 | +--- |
| 2 | +slug: x86-instruction |
| 3 | +title: 由 00 引发的 x86 指令格式探究 |
| 4 | +authors: [wjc133] |
| 5 | +tags: [x86] |
| 6 | +--- |
| 7 | + |
| 8 | +本文主要用于梳理 x86 的指令的基本格式。 |
| 9 | + |
| 10 | +<!-- truncate --> |
| 11 | + |
| 12 | + |
| 13 | + |
| 14 | +下午快下班的时候,晓东来问:上面有一些 00 的行,这些行的作用或者含义是什么? |
| 15 | + |
| 16 | +问了大模型,有说是为了内存对齐的。以我浅薄的汇编知识来看,指令应该是一条接一条的才对,应该不存在对齐一说。且对齐都是在一个数据区块存储到最后有空缺的时候才会对齐,莫名其妙在中间插入 0 做对齐,会浪费大量的空间,也达不到对齐的效果。 |
| 17 | + |
| 18 | +仔细观察`mov %r8,0x78(%rsp)`和`mov %r9,0x80(%rsp)`两句所对应的机器码,为什么前一句不用对齐,后一句就要对齐?这就更说不过去了。 |
| 19 | + |
| 20 | +于是就对该问题做了更详细的探究。 |
| 21 | + |
| 22 | +## 指令的组成结构 |
| 23 | + |
| 24 | + |
| 25 | +一条 x86 指令通常由以下部分组成,其总长度取决于每个部分的存在和大小: |
| 26 | + |
| 27 | +1. **指令前缀(Optional Prefixes)**:0 到 4 个字节,用于修改指令的行为,如: |
| 28 | + - LOCK 前缀(用于原子操作) |
| 29 | + - 操作数大小覆盖前缀(如强制在 16 位模式下使用 32 位操作数) |
| 30 | + - 地址大小覆盖前缀 |
| 31 | + - 重复前缀(用于字符串操作,如 REP) |
| 32 | +2. **操作码(Opcode)**:1 到 3 个字节 |
| 33 | + - 这是指令的核心部分,定义了 CPU 需要执行什么操作(如 add, mov, jmp)。 |
| 34 | +3. **ModR/M字节(Optional)**:1 个字节 |
| 35 | + - 用于指定操作数的寻址模式。它告诉指令操作数是寄存器还是内存,以及使用哪种计算内存地址的方式。 |
| 36 | +4. **SIB 字节(Optional)**:1 个字节 |
| 37 | + - Scale, Index, Base。当寻址模式比较复杂时(例如[eax + ebx*4 + 10]),需要这个字节来进一步说明。 |
| 38 | +5. **位移量(Displacement)**:0, 1, 2, 或 4 个字节 |
| 39 | + - 一个直接包含在指令中的常数偏移值,用于内存寻址(如[array + 10]中的 10)。 |
| 40 | +6. **立即数(Immediate)**:0, 1, 2, 或 4 个字节 |
| 41 | + - 一个直接包含在指令中的常数操作数(如 mov eax, 123 中的 123)。 |
| 42 | + |
| 43 | +从上面的组成中也可以看出,并不是所有指令都是看到 opcode 就知道有多长的定长指令,还有很多指令是变长指令。 |
| 44 | + |
| 45 | +当指令中出现内存操作对象的时候,就需要在操作码后面附加一个字节来进行补充说明,这个字节被称为 ModR/M。你可以理解 ModR/M 决定了我们常说的寻址方式。 |
| 46 | + |
| 47 | +## ModR/M字节 |
| 48 | +该字节的 8 个位被分成了三部分: |
| 49 | + |
| 50 | + |
| 51 | + |
| 52 | +Reg/Opcode(第3、4、5位,共3个字节)描述指令中的 G 部分,即寄存器 |
| 53 | + |
| 54 | +| 寄存器宽度 | 000 | 001 | 010 | 011 | 100 | 101 | 110 | 111 | |
| 55 | +| :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | |
| 56 | +| 32 | EAX | ECX | EDX | EBX | ESP | EBP | ESI | EDI | |
| 57 | +| 8 | AL | CL | DL | BL | AH | CH | DH | BH | |
| 58 | + |
| 59 | + |
| 60 | + |
| 61 | + |
| 62 | +Mod(第 6、7 位) 和 R/M(第 0、1、2 位) 共同描述指令中的 E 部分,即寄存器/内存 |
| 63 | + |
| 64 | + |
| 65 | + |
| 66 | +上图其实已经说的很清楚了,mod 一共占用 2 个 bit,最多 4 种取值,每种取值对应的**Effective Address**是不一样的。 |
| 67 | + |
| 68 | ++ `mod = 00`:无位移量的内存寻址(例如 `[eax]`) |
| 69 | + - 有一个例外:如果 `r/m = 101`,则表示纯 32 位位移寻址(例如 `[0x12345678]`),此时需要 4 字节位移量。 |
| 70 | ++ `mod = 01`:存在一个 **1 字节** 位移量的内存寻址(例如 `[eax + 0x12]`) |
| 71 | ++ `mod = 10`:存在一个 **4 字节** 位移量的内存寻址(例如 `[eax + 0x12345678]`) |
| 72 | ++ `mod = 11`:寄存器直接寻址(例如 `eax`),而不是内存寻址。 |
| 73 | + |
| 74 | +为什么除了 1 字节,就是 4 字节的表示呢?因为 1 字节已经覆盖了大部分常见的“访问结构体字段”、“栈帧局部变量”等场景,这些偏移量通常很小。用 1 字节编码可以**极大地节省代码尺寸**,这是 x86 设计的一个核心目标(高代码密度)。 |
| 75 | + |
| 76 | +其他情况下就可以使用 mod = 10 来“兜底”了。 |
| 77 | + |
| 78 | +:::warning |
| 79 | +在 64 位模式下,增加了一个 REX 前缀。REX 前缀会直接影响 ModR/M 的表现。 |
| 80 | +::: |
| 81 | + |
| 82 | +## REX 前缀 |
| 83 | + |
| 84 | + |
| 85 | +在 64 位模式下,许多指令需要一个 **REX 前缀** 来启用 64 位操作数和访问新的寄存器(R8-R15)。REX 是一个 1 字节的前缀,其格式为 `0100_wrbx`,其中每位都有特定功能: |
| 86 | + |
| 87 | ++ **W bit (Bit 3)**: 操作数宽度。`W=1`** 表示 64 位操作数**(例如 `mov rax, rbx`),`W=0` 表示默认操作数大小(通常是 32 位,如 `mov eax, ebx`)。 |
| 88 | ++ **R bit (Bit 2)**: 扩展 `ModR/M.reg` 字段的最高位。 |
| 89 | ++ **B bit (Bit 0)**: 扩展 `ModR/M.r/m` 字段或 `SIB` 字段中 `base` 或 `index` 的最高位。 |
| 90 | ++ **X bit (Bit 1)**: 扩展 `SIB.index` 字段的最高位。 |
| 91 | + |
| 92 | +REX 前缀的存在直接改变了 ModR/M 和 SIB 字节的解释方式。 |
| 93 | + |
| 94 | + |
| 95 | + |
| 96 | +在 64 位模式下,ModR/M 主要有以下不同: |
| 97 | + |
| 98 | +### 寄存器编码的扩展 |
| 99 | +在 32 位模式下,ModR/M 的 3 位 `reg` 和 `r/m` 字段只能编码 8 个寄存器(如 EAX, ECX, EDX, EBX, EBP, ESI, EDI, ESP)。 |
| 100 | + |
| 101 | +在 64 位模式下,REX.R 和 REX.B 位充当了 `reg` 和 `r/m` 字段的第四位(最高位)。这使得可以寻址的寄存器数量翻倍,从 8 个增加到 16 个。 |
| 102 | + |
| 103 | +例如:指令 `mov r8, r9` 的编码需要 REX 前缀。 |
| 104 | + |
| 105 | ++ `reg` 字段编码源操作数 `r9`。 |
| 106 | ++ `r/m` 字段编码目标操作数 `r8`。 |
| 107 | ++ 因为涉及 R8-R15,所以需要 `REX.B` 和 `REX.R` 位来扩展这两个字段。其机器码为 `4D 8B C1`。 |
| 108 | + - `4D`:REX 前缀 (`0100`),其中 `W=0`? (这里需要看具体操作),但关键是 `R=1` 和 `B=1`。 |
| 109 | + - `8B`: `mov` 的操作码。 |
| 110 | + - `C1`:ModR/M 字节。假设`reg`=000 (由 REX.R=1 扩展为 1000, 即 R9),r/m=001 (由 REX.B=1 扩展为 1001, 即 R8)。 |
| 111 | + |
| 112 | +### 对 EBP/RBP 和 ESP/RSP 寻址的特殊处理 |
| 113 | +在 32 位模式下,`r/m=101` 且 `mod=00` 是一个特例,表示纯 32 位位移寻址(如 `[0x12345678]`),而不是 `[ebp]`。 |
| 114 | + |
| 115 | +在 64 位模式下,**这个特例被取消了**。`mod=00` 且 `r/m=101` 现在表示 `[rbp]`(或者更准确地说,是 `[R13]`,因为 RBP 的编码被 R13 继承)。要表示纯 64 位位移寻址(如 `[0x12345678]`),需要使用 SIB 字节的特殊形式。 |
| 116 | + |
| 117 | +这简化了 64 位模式下的编码规则。 |
| 118 | + |
| 119 | +### 立即数和位移量的默认大小 |
| 120 | +在 64 位模式下,当操作数大小是 64 位时(REX.W=1),**立即数和位移量通常仍然是 32 位的**。32 位的位移量在符号扩展后足以满足绝大多数寻址需求,同时保持了代码的紧凑性。 |
| 121 | + |
| 122 | + |
| 123 | + |
| 124 | +例如,指令 `mov rax, [rbx + 0x12345678]` 使用 4 字节的位移量 `78 56 34 12`,而不是 8 字节。CPU 会在计算地址时自动将这个 32 位值符号扩展为 64 位。 |
| 125 | + |
| 126 | +### RIP 相对寻址 |
| 127 | +最后,64 位模式引入了一个极其重要的新寻址模式:**RIP 相对寻址**(如 `[rip + 0x1234]`)。 |
| 128 | + |
| 129 | +这是一种非常高效和有利于位置无关代码(PIC)的寻址方式。它使用 `mod=00` 和 `r/m=101` 的组合进行编码,但后跟一个 32 位的位移量。CPU 会将当前指令指针(RIP)的值加上这个位移量来计算有效地址。 |
| 130 | + |
| 131 | +## 案例分析 |
| 132 | +具体来分析一下上面的案例。 |
| 133 | + |
| 134 | +`mov %r8, 0x78(%rsp)` |
| 135 | + |
| 136 | ++ **位移量值**:`0x78` (十进制 120) |
| 137 | ++ **分析**:`120` 在 8 位有符号数的范围之内 (`-128` 到 `+127`)。 |
| 138 | ++ **编码**:这种情况下,`mod` 字段可以被设置为 `01`。这个模式的含义是:**存在一个 1 字节的位移量**。指令格式为:`[基址寄存器 + 1字节位移]`。 |
| 139 | ++ **机器码**:`4c 89 44 24 78` |
| 140 | + - `4c`:REX 前缀(指定 64 位操作和扩展寄存器) |
| 141 | + - `89`: `mov` 的操作码 |
| 142 | + - `24`:ModR/M 字节。其 `mod` 部分就是 `01`,表示后续有 1 字节位移。`r/m` 部分指定了 `%rsp` 作为基址寄存器。 |
| 143 | + - `78`: **这就是 1 字节的位移量本身** `0x78`。不需要也不应该有任何填充。 |
| 144 | + |
| 145 | + |
| 146 | + |
| 147 | +`mov %r9, 0x80(%rsp)` |
| 148 | + |
| 149 | ++ **位移量值**:`0x80` (十进制 128) |
| 150 | ++ **分析**:`128` **超出了** 8 位有符号数的范围(8 位有符号数的最大值是 127)。因此,不能使用上面的紧凑模式。 |
| 151 | ++ **编码**:这种情况下,`mod` 字段**必须**被设置为 `10`。这个模式的含义是:**存在一个 4 字节的位移量**。指令格式为:`[基址寄存器 + 4字节位移]`。 |
| 152 | ++ **机器码**:`4c 89 8c 24 80 00 00 00` |
| 153 | + - `4c 89 8c 24`:操作码和 ModR/M 字节。这里的 `mod` 部分是 `10`,表明后面跟随着 4 字节位移。 |
| 154 | + - `80 00 00 00`:这就是 **4 字节的位移量**。在 x86 的小端序中,值 `0x00000080` 被存储为 `80 00 00 00`。后面的三个 `00` 不是填充,而是这个 32 位数字的高位字节,是指令不可或缺的一部分。 |
| 155 | + |
| 156 | +## 总结 |
| 157 | +好,最后我们再来总结一下这个问题。 |
| 158 | + |
| 159 | +首先,折行的这个 00 就**是指令的一部分**,是有意义的。 |
| 160 | + |
| 161 | +为什么有的有 00,有的没有? |
| 162 | + |
| 163 | +分两种情况: |
| 164 | + |
| 165 | +第一种情况,`mov %r9, 0x80(%rsp)`是因为 0x80 超出了 8 位整数的表示范围,所以 mod=10,必须用 4 个字节来表示其偏移量。 |
| 166 | + |
| 167 | +第二种情况,`movl $0x0, 0x3c(%rsp)`是因为 0x0 是一个要存入到内存中的立即数,它不是用于寻址的,自然也就没有变长一说,必须完完整整的用 4 个字节表示。 |
| 168 | + |
| 169 | + |
| 170 | + |
0 commit comments