跳转到内容

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 一样。

128 位、256 位和 512 位 (SSE/AVX)

[编辑 | 编辑源代码]

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 和 80186

[编辑 | 编辑源代码]

最初的 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 MiBFFF0 等于 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 上运行时速度会更快。

华夏公益教科书