x86 汇编/16 位、32 位和 64 位
在使用 x86 汇编时,重要的是要考虑 16 位、32 位和 64 位架构之间的差异。本页将讨论不同位宽架构之间的一些基本差异。
在 8086 和所有后续的 x86 处理器上发现的寄存器如下:AX、BX、CX、DX、SP、BP、SI、DI、CS、DS、SS、ES、IP 和 FLAGS。这些都是 16 位宽的。
在 DOS 和 32 位 Windows 上,您可以从 DOS shell 运行一个非常方便的程序,称为“debug.exe”,这对于学习 8086 非常有用。如果您使用的是 DOSBox 或 FreeDOS,则可以使用“debug.exe” 如 FreeDOS 提供的那样。
- AX、BX、CX、DX
- 这些通用寄存器也可以作为 8 位寄存器寻址。因此 AX = AH(高 8 位)和 AL(低 8 位)。
- SI、DI
- 这些寄存器通常用作数据空间的偏移量。默认情况下,SI 是从 DS 数据段偏移,DI 是从 ES 附加段偏移,但这两个或其中之一都可以被覆盖。
- SP
- 这是堆栈指针,偏移通常来自堆栈段 SS。数据被推送到堆栈以进行临时存储,并在再次需要时从堆栈弹出。
- BP
- 堆栈帧,通常被视为堆栈段 SS 的偏移量。子例程的参数通常在调用子例程时被推送到堆栈,并且在子例程开始时 BP 被设置为 SP 的值。然后可以使用 BP 从堆栈中找到参数,无论在此期间使用了多少堆栈。
- CS、DS、SS、ES
- 段指针。这些分别是当前代码段、数据段、堆栈段和附加段的内存偏移量。
- IP
- 指令指针。从代码段 CS 偏移,它指向当前正在执行的指令。
- FLAGS (F)
- 一些单比特标志,指示(或有时设置)处理器的当前状态。
随着芯片开始支持 32 位数据总线,寄存器也被扩展到 32 位。32 位寄存器的名称只是在 16 位名称前加上一个“E”。
- EAX、EBX、ECX、EDX、ESP、EBP、ESI、EDI
- 这些是上面显示的寄存器的 32 位版本。
- EIP
- IP 的 32 位版本。在 32 位系统上始终使用它而不是 IP。
- EFLAGS
- 16 位 FLAGS 寄存器的扩展版本。
64 位寄存器的名称与 32 位寄存器的名称相同,只是以“R”开头。
- RAX、RBX、RCX、RDX、RSP、RBP、RSI、RDI
- 这些是上面显示的寄存器的 64 位版本。
- RIP
- 这是完整的 64 位指令指针,应该使用它而不是 EIP(如果地址空间大于 4 GiB,这可能会发生,即使只有 4 GiB 或更少的 RAM)。
- R8–15
- 这些是 64 位的新增寄存器。它们被计数,就像上面的寄存器是第零到第七个寄存器,包括在内,而不是第一个到第八个。
R8–R15 可以作为 8 位、16 位或 32 位寄存器访问。以 R8 为例,对应于这些位宽的名称分别是 R8B、R8W 和 R8D。64 位版本的 x86 还允许直接访问 RSP、RBP、RSI、RDI 的低字节。例如,RSP 的低字节可以使用 SPL 访问。没有办法直接访问这些寄存器的第 8-15 位,就像 AH 允许 AX 一样。
64 位 x86 包括 SSE2(对 32 位 x86 的扩展),它为特定指令提供 128 位寄存器。自 2011 年以来制造的大多数 CPU 也有 AVX,这是进一步的扩展,将这些寄存器延长到 256 位。有些还具有 AVX-512,它将它们延长到 512 位,并添加了 16 个寄存器。
- XMM0~7
- SSE2 及更新版本。
- XMM8~15
- SSE3 及更新版本和 AMD(但不是 Intel)SSE2。
- YMM0~15
- AVX。每个 YMM 寄存器都包含相应的 XMM 寄存器作为其下半部分。
- ZMM0~15
- AVX-512F。每个 ZMM 寄存器都包含相应的 YMM 寄存器作为其下半部分。
- ZMM16~31
- AVX-512F。512 位寄存器,除非实现了 AVX-512VL,否则在较窄的模式下不可寻址。
- XMM16~31
- AVX-512VL。每个都是相应 ZMM 寄存器的下四分之一。
- YMM16~31
- AVX-512VL。每个都是相应 ZMM 寄存器的下半部分。
最初的 8086 只有 16 位大小的寄存器,实际上允许存储 [0 - (216 - 1)] 范围内的值(或更简单地说:它可以寻址最多 65536 个不同的字节,或 64 kibibytes) - 但地址总线(到内存控制器的连接,它接收地址,然后加载来自给定地址的内容,并将数据通过数据总线返回到 CPU)是 20 位大小,实际上允许寻址最多 1 兆字节的内存。这意味着所有寄存器本身都不足以利用地址总线的全部宽度,留下 4 位未使用,将可用地址的数量缩减了 16 倍(1024 KiB / 64 KiB = 16)。
问题在于:如何通过 16 位寄存器引用 20 位地址空间?为了解决这个问题,英特尔的工程师提出了段寄存器 CS(代码段)、DS(数据段)、ES(附加段)和 SS(堆栈段)。要从 20 位地址转换,首先要将它除以 16,并将商放在段寄存器中,并将余数放在偏移寄存器中。这表示为 CS:IP(这意味着 CS 是段,IP 是偏移量)。同样,当写入地址 SS:SP 时,它意味着 SS 是段,SP 是偏移量。
这也适用于相反的方式。如果一个人是,而不是从,创建 20 位地址,它将通过取段寄存器的 16 位值并将其放在地址总线上,但向左移动 4 次(因此实际上将寄存器乘以 16),然后通过将另一个寄存器的偏移量保持不变地添加到总线上的值,从而创建完整的 20 位地址。
如果 CS = 258C 且 IP = 001216,则 CS:IP 将指向一个等于“CS × 16 + IP”的 20 位地址,即
258C × 1016 + 001216 = 258C0 + 001216 = 258D2(记住:16 进制 = 1016)。
20 位地址被称为绝对地址(或线性地址),而段:偏移表示法(CS:IP)被称为分段地址。这种分离是必要的,因为寄存器本身无法保存需要超过 16 位编码的值。在 32 位或 64 位处理器上使用保护模式编程时,寄存器足够大,可以完全填充地址总线,从而消除了分段地址——在这种“扁平寻址”模式下,通常只使用线性/逻辑地址,尽管为了向后兼容,仍然支持段:偏移架构。
需要注意的是,物理地址和分段地址之间没有一一对应关系;对于任何物理地址,都有多个可能的分段地址。例如:考虑分段表示 B000:8000 和 B200:6000。经过计算,它们都映射到物理地址 B8000。
B000:8000 = B000 × 1016 + 800016 = B0000 + 800016 = B8000,以及
B200:6000 = B200 × 1016 + 600016 = B2000 + 600016 = B8000。
但是,使用适当的映射方案可以避免此问题:这种映射将线性变换应用于物理地址,为每个物理地址创建唯一的分段地址。要反转转换,只需反转映射 [f(x)]。
例如,如果段部分等于物理地址除以 1016,而偏移部分等于余数,那么只会生成一个分段地址。(任何偏移都不会大于 0F16。)物理地址 B8000 映射到 (B8000 / 1016):(B8000 mod 1016) 或 B800:0。这种分段表示法有一个特殊的名字:这种地址被称为“规范地址”。
CS:IP(代码段:指令指针)表示下一条要执行的指令将从中获取的物理内存的 20 位地址。类似地,SS:SP(堆栈段:堆栈指针)指向一个 20 位绝对地址,该地址将被视为堆栈顶端(8086 使用它来压入/弹出值)。
保护模式 (80286+)
[edit | edit source]尽管这看起来很丑陋,但它实际上是迈向后来芯片中使用的保护寻址方案的一步。80286 具有保护模式的操作,其中所有 24 个地址线都可用,允许寻址高达 16 MiB 的内存。在保护模式下,CS、DS、ES 和 SS 寄存器不是段,而是选择器,指向一个表,该表提供有关程序正在使用的物理内存块的信息。在这种模式下,指针值 CS:IP = 0010:2400 的使用方法如下:
CS 值 001016 是选择器表中的一个偏移量,指向特定的选择器。该选择器将具有一个 24 位值来指示内存块的起始位置,一个 16 位值来指示块的长度,以及标志来指定块是否可写、是否当前驻留在内存中以及其他信息。假设指向的内存块实际从 24 位地址 16440016 开始,那么引用的实际地址为 16440016 + 240016 = 16680016。如果选择器还包含关于块长度为 240016 字节的信息,则引用将指向该块后面的字节,这将导致异常:操作系统不应允许程序读取它不拥有的内存。如果块被标记为只读,则代码段内存应该是这样,以防止程序覆盖自身,尝试写入该地址同样会导致异常。
随着 CS 和 IP 在 386 中扩展到 32 位,这种方案变得不再必要;选择器指向物理地址 0000000016,32 位寄存器可以寻址高达 4 GiB 的内存。然而,选择器仍然被用来保护内存免受恶意程序的侵害。例如,如果 Windows 中的一个程序试图读取或写入它不拥有的内存,它将违反选择器设置的规则,触发异常,Windows 将显示“通用保护故障”消息将其关闭。
32 位寻址
[edit | edit source]32 位地址可以覆盖高达 4 GiB 的内存。这意味着我们不需要在 32 位处理器中使用偏移地址。相反,我们使用所谓的“扁平寻址”方案,其中寄存器中的地址直接指向物理内存位置。段寄存器用于定义不同的段,这样程序就不会尝试执行堆栈部分,也不会意外地在数据部分执行堆栈操作。
A20 门传奇
[edit | edit source]如前所述,8086 处理器有 20 条地址线(从 A0 到 A19),因此它可寻址的总内存为 1 MiB(或 2 的 20 次方)。但由于它只有 16 位寄存器,他们想出了段:偏移方案,否则使用单个 16 位寄存器,他们不可能访问超过 64 KiB(或 2 的 16 次方)的内存。因此,这使得程序可以访问整个 1 MiB 的内存。
但这种分段方案也带来了副作用。使用这种方案,你的代码不仅可以引用整个 1 MiB,实际上可以引用更多。让我们看看如何实现…
让我们记住如何从段:偏移表示法转换为线性 20 位表示法。
转换
段:偏移 = 段 × 16 + 偏移。
现在要查看可以寻址的最大内存量,让我们将段和偏移都填充到其最大值,然后将其值转换为 20 位绝对物理地址。
因此,段的最大值为 FFFF16,偏移的最大值为 FFFF16。
现在,让我们将 FFFF:FFFF 转换为其 20 位线性地址,记住 1610 在十六进制中表示为 10。
所以我们得到,FFFF:FFFF -> FFFF × 1016 + FFFF = FFFF0(1 MiB - 16 字节)+ FFFF(64 KiB)= FFFFF + FFF0 = 1 MiB + FFF0 字节。
- 注意:FFFFF 为十六进制,等于 1 MiB,FFF0 等于 64 KiB 减去 16 字节。
故事的寓意:从实模式下,程序实际上可以引用 (1 MiB + 64 KiB - 16) 字节的内存。
注意“引用”这个词的使用,而不是“访问”。程序可以引用这么多内存,但它是否可以访问,取决于实际存在的地址线数量。因此,对于 8086 来说,这绝对不可能,因为当程序引用 1 MiB 以上的内存时,放在地址线上的地址实际上超过了 20 位,导致地址发生环绕。
例如,如果代码引用 1 MiB,它将发生环绕,指向内存中的位置 0,类似地,1 MiB + 1 将发生环绕,指向地址 1(或 0000:0001)。
当时有一些超级时髦的程序员利用了代码中的这个特性,即地址发生环绕,使得他们的代码更快,而且字节更少。使用这种技术,他们可以访问 32 KiB 的顶部内存区域(即与 1 MiB 边界相邻的 32 KiB)和 32 KiB 的底部内存区域,而无需重新加载段寄存器!
你看,简单的数学,如果你在段:偏移表示法中使段保持不变,那么由于偏移是一个 16 位值,因此你可以遍历 64 KiB(或 2 的 16 次方)的内存区域。现在,如果你让你的段寄存器指向 1 MiB 标记下方的 32 KiB,你可以向上访问 32 KiB,以接触 1 MiB 边界,然后进一步访问 32 KiB,最终将环绕到最底部的 32 KiB。
这些超级时髦的程序员忽视了一个事实,那就是会创建具有更多地址线的处理器。(注意:比尔·盖茨被认为说过,“谁会需要超过 640 KB 的内存?”,这些程序员可能也是这样想的。)1982 年,在 8086 发布仅仅两年后,英特尔发布了具有 24 条地址线的 80286 处理器。尽管从理论上来说,它向后兼容旧的 8086 程序,因为它也支持实模式,但许多 8086 程序无法正常运行,因为它们依赖于越界地址环绕到较低的内存段。为了兼容性,IBM 工程师将 A20 地址线(8086 具有 A0 - A19 线)路由到键盘控制器,并提供了一种机制来启用/禁用 A20 兼容模式。如果你想知道为什么是键盘控制器,答案是它有一个未使用的引脚。由于 80286 将被市场宣传为与 8086 完全兼容(8086 还没有上市很久),升级后的客户会很愤怒,如果 80286 不完全兼容,以至于为 8086 设计的代码在 80286 上运行时速度会更快。