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,则 EIP 将不准确,即使只有 4 GiB 或更少的 RAM 也是如此)。
- R8–15
- 这些是 64 位的新增寄存器。它们被计数为上面的寄存器是 0 到 7 号寄存器(包含),而不是 1 到 8 号。
R8–R15 可以被访问为 8 位、16 位或 32 位寄存器。以 R8 为例,对应于这些宽度的名称分别是 R8B、R8W 和 R8D。64 位版本的 x86 还允许直接访问 RSP、RBP、RSI、RDI 的低字节。例如,可以使用 SPL 访问 RSP 的低字节。无法直接访问这些寄存器的第 8–15 位,就像 AH 允许访问 AX 一样。
64 位 x86 包括 SSE2(32 位 x86 的扩展),它为特定指令提供 128 位寄存器。自 2011 年以来制造的大多数 CPU 也具有 AVX,这是一个进一步的扩展,它将这些寄存器的长度扩展到 256 位。一些 CPU 还具有 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 mebibyte 的内存。这意味着所有寄存器本身都不足以利用地址总线的整个宽度,留下了 4 位未用,将可用地址的数量缩小了 16 倍(1024 KiB / 64 KiB = 16)。
问题是:如何通过 16 位寄存器引用 20 位地址空间?为了解决这个问题,英特尔的工程师提出了段寄存器 CS(代码段)、DS(数据段)、ES(扩展段)和 SS(堆栈段)。要从 20 位地址转换,首先将其除以 16,并将商放在段寄存器中,并将余数放在偏移寄存器中。这表示为 CS:IP(这意味着,CS 是段,IP 是偏移量)。同样,当写入地址 SS:SP 时,意味着 SS 是段,SP 是偏移量。
这也适用于反向操作。如果一个人要创建 20 位地址,而不是从 20 位地址转换,则可以将段寄存器的 16 位值放在地址总线上,但将其左移 4 次(因此实际上将寄存器乘以 16),然后将另一个寄存器的未修改的偏移量添加到总线上的值,从而创建一个完整的 20 位地址。
如果 CS = 258C,IP = 001216,那么 CS:IP 将指向一个 20 位地址,相当于“CS × 16 + IP”,即
258C × 1016 + 001216 = 258C0 + 001216 = 258D2(记住:16 十进制 = 1016)。
20 位地址称为绝对(或线性)地址,Segment:Offset 表示法(CS:IP)称为分段地址。这种分离是必要的,因为寄存器本身无法保存需要超过 16 位编码的值。在 32 位或 64 位处理器上以保护模式编程时,寄存器足够大,可以完全填充地址总线,从而消除了分段地址 - 只有线性/逻辑地址通常在这种“平面寻址”模式中使用,尽管为了向后兼容,仍然支持Segment:Offset 体系结构。
需要注意的是,物理地址与分段地址之间不存在一一映射关系;对于任何物理地址,都可能存在多个分段地址。例如:考虑分段表示 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 具有保护模式,在该模式下,其所有 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 位地址可以覆盖高达 4 GiB 的内存。这意味着我们不需要在 32 位处理器中使用偏移地址。相反,我们使用所谓的“扁平寻址”方案,其中寄存器中的地址直接指向物理内存位置。段寄存器用于定义不同的段,这样程序就不会尝试执行堆栈部分,也不会意外地尝试在数据部分执行堆栈操作。
如前所述,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,你可以访问向上延伸至 1 MiB 边界的 32 KiB,然后访问再向上的 32 KiB,最终会绕回到最底部的 32 KiB。
现在,这些非常棒的程序员忽视了一个事实,那就是将会制造出具有更多地址线的处理器。(注意:比尔·盖茨被认为说过“谁会需要超过 640 KB 的内存?”,这些程序员可能也持有类似的想法。) 1982 年,仅仅在 8086 推出两年后,英特尔发布了具有 24 条地址线的 80286 处理器。虽然从理论上来说,它与传统的 8086 程序向后兼容,因为它也支持实模式,但许多 8086 程序不能正常工作,因为它们依赖于越界地址绕回到较低的内存段。因此,为了兼容性,IBM 工程师将 A20 地址线 (8086 具有 A0 - A19 线) 通过键盘控制器进行路由,并提供了一种机制来启用/禁用 A20 兼容模式。现在如果你想知道为什么是键盘控制器,答案是它有一个未使用的引脚。由于 80286 将被宣传为与 8086 完全兼容 (甚至还没有推出很久),所以如果 80286 不能实现完全的 bug-for-bug 兼容性,升级后的客户将非常生气,这样为 8086 设计的代码在 80286 上也能正常运行,只是速度更快。