【问题标题】:If I have an 8-bit value, is there any advantage to using an 8-bit register instead of say, 16, 32, or 64-bit?如果我有一个 8 位的值,那么使用 8 位寄存器而不是 16、32 或 64 位有什么好处?
【发布时间】:2018-11-30 18:09:25
【问题描述】:

我阅读的介绍性 x86 asm 文献似乎在所有实际场景中都坚持使用 32 位寄存器(eax、ebx 等),除了将 64 位寄存器演示为同样存在的东西。如果完全提到 16 位寄存器,则作为历史注释解释了为什么 32 位寄存器的名称前有一个“e”。编译器似乎对小于 32 位的寄存器同样不感兴趣。

考虑以下 C 代码:

int main(void) { return 511; }

虽然main 声称返回一个 int,但实际上 Linux 退出状态码是 8 位的,这意味着任何超过 255 的值都将是最低有效的 8 位,即。

hc027@HC027:~$ echo "int main(void) { return 511; }" > exit_gcc.c
hc027@HC027:~$ gcc exit_gcc.c 
hc027@HC027:~$ ./a.out 
hc027@HC027:~$ echo $?
255

所以我们看到只有int main(void)的返回值的前8位会被系统使用。 然而当我们向 GCC 询问同一个程序的汇编输出时,它会将返回值存储在 8 位寄存器中吗?一起来了解一下吧!

hc027@HC027:~$ cat exit_gcc.s
    .file   "exit_gcc.c"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $511, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609"
    .section    .note.GNU-stack,"",@progbits

不!它使用 %eax,一个非常多的 32 位寄存器!现在,GCC 比我聪明,也许 int main(void) 的返回值 用于其他不知道它的返回值在哪里的东西不会被截断为 8 个最低有效位(或者也许 C 标准规定它必须返回一个真实的、实际的 int,不管它的实际命运如何)

但不管我的具体例子的效果如何,问题仍然存在。据我所知,现代 x86 汇编程序员和编译器几乎都忽略了 32 位以下的寄存器。粗略的谷歌“何时使用 16 位寄存器 x86”没有返回相关答案。我很好奇:在 x86 CPU 中使用 8 位和 16 位寄存器有什么好处吗?

【问题讨论】:

  • 编写一个使用uint8_t 类型的程序。这会改变使用的寄存器吗?类型的域通常不会根据所使用的“是”而改变。
  • 也不要忘记启用优化,尤其是-Os,因为使用 8 位会产生更小的代码。
  • 所以,当我将类型更改为uint8_t 时,GCC 的反应只是将值压入堆栈并完全避开通用寄存器。 但是当我使用uint8_t -Os GCC 使用8位AL 寄存器!!
  • 当您写入小于 DWORD(32 位)的寄存器时,您将面临处理器性能不佳的风险,因为之后会出现 potential register stall。由于-O3 通常会针对速度进行优化,因此它会尽量避免部分寄存器停顿的情况。当使用-Os 时,它将针对大小进行优化。 mov $51, %al(2 个字节)的编码比mov $51, %eax(5 个字节)短,所以选择了它,更短的指令并不一定意味着更好的执行代码。
  • @MichaelPetch 我不知道部分寄存器停顿,谢谢!因此,总而言之, 使用部分寄存器的原因是为了稍微优化大小,而 使用它们的原因是部分寄存器停顿。优秀的!如果您想将其添加为答案,那将回答我的问题。谢谢!

标签: assembly x86


【解决方案1】:

所以,它并不一定是这样,这里有一些历史。尝试运行

    mov rax, -1 # 0xFFFFFFFFFFFFFFFF
    mov eax, 0
    print rax

在您最喜欢的 x86 桌面上(print 取决于您的环境/语言/其他)。您会注意到,即使rax 开始时全是 1,并且您认为您只清除了底部的 32 位,print 语句打印为零!写入eax 完全擦除rax。为什么?这是非常奇怪和不直观的行为。原因很简单:因为它更快。当您继续写信给eax 时,试图保持rax 的较高值是绝对痛苦的。

然而,英特尔/AMD 在最初决定转向 32 位时并没有意识到这一点,并犯了一个致命错误,导致 al/ah 永远成为历史遗物:当你写信给alah,另一个不会被破坏!这确实更直观,这在 16 位时代曾经是一个好主意,因为现在你有两倍多的寄存器,并且你有一个 32 位寄存器!但是,如今,随着寄存器数量的增加,我们不再需要更多的寄存器了。我们真正想要的是更快的寄存器,并推动更多 GHz。从这个角度来看,每次你写信给alah 时,处理器都需要保留另一半,这从根本上来说就是要贵得多。 (稍后解释原因)

理论说得够多了,让我们进行一些实际测试。每个测试用例测试了 3 次。这些测试是在 Intel Core i5-4278U CPU @ 2.60GHz

上运行的

仅 rax:1.067s、1.072s、1.097s

global _main
_main:
mov ecx, 1000000000
loop:
test ecx, ecx
jz exit
mov rax, 5
mov rax, 5
mov rax, 6
mov rax, 6
mov rax, 7
mov rax, 7
mov rax, 8
mov rax, 8
dec ecx
jmp loop
exit:
ret

只有 eax:1.072s、1.062s、1.060s

global _main
_main:
mov ecx, 1000000000
loop:
test ecx, ecx
jz exit
mov eax, 5
mov eax, 5
mov eax, 6
mov eax, 6
mov eax, 7
mov eax, 7
mov eax, 8
mov eax, 8
dec ecx
jmp loop
exit:
ret

只有啊:2.702s、2.748s、2.704s

global _main
_main:
mov ecx, 1000000000
loop:
test ecx, ecx
jz exit
mov ah, 5
mov ah, 5
mov ah, 6
mov ah, 6
mov ah, 7
mov ah, 7
mov ah, 8
mov ah, 8
dec ecx
jmp loop
exit:
ret

仅 ah/al:1.432s、1.457s、1.427s

global _main
_main:
mov ecx, 1000000000
loop:
test ecx, ecx
jz exit
mov ah, 5
mov al, 5
mov ah, 6
mov al, 6
mov ah, 7
mov al, 7
mov ah, 8
mov al, 8
dec ecx
jmp loop
exit:
ret

ah and al, 然后 eax: 1.117s, 1.084s, 1.082s

global _main
_main:
mov ecx, 1000000000
loop:
test ecx, ecx
jz exit
mov ah, 5
mov al, 5
mov eax, 6
mov al, 6
mov ah, 7
mov eax, 7
mov ah, 8
mov al, 8
dec ecx
jmp loop
exit:
ret

(请注意,这些测试与部分寄存器停顿无关,因为在写入 ah 后,我没有阅读 eax。参考主帖上的 cmets。)

从测试中可以看出,使用 al/ah 的速度要慢得多。使用 eax/rax 将其他时间吹出水面,并且 rax 和 eax 本身之间基本上没有性能差异。如前所述,原因是因为 eax/rax 直接覆盖了整个寄存器。但是,使用 ah 或 al 意味着需要维护另一半。


现在,如果您愿意,我们可以深入解释为什么每次使用时擦除寄存器更有效。从表面上看,这似乎并不重要,只需更新重要的部分,对吗?有什么大不了的?

嗯,现代 CPU 是智能的,它们会非常积极地并行化 CPU 知道不会相互干扰的操作,但前提是这种并行化实际上是可能的。例如,如果您将 eax 移动到 ebx,然后将 ebx 移动到 ecx,然后将 ecx 移动到 edx,那么 CPU 无法并行化它,并且运行速度会比平时慢。但是,如果您写入 eax、写入 ebx、写入 ecx 和写入 edx,那么 CPU 可以并行化所有这些操作,并且运行速度会比平时快得多!随意自行测试。

在内部,它的实现方式是立即开始执行和计算一条指令,即使早期的指令仍在执行中。但是,主要限制如下:

  • 如果较早的指令写入到某个寄存器 A,而当前指令 读取从某个寄存器 A,则当前指令 必须等待直到前面的指令全部完成,这就是导致这种减速的原因。

在我们的mov eax, 5 垃圾邮件测试中,大约需要 1 秒,CPU 可以积极地并行运行所有操作,因为无论如何都不会读取任何指令,它们都是只写的。它只需要确保最近的写入是寄存器在任何未来读取期间保存的值(这很容易,因为即使操作都发生在重叠的时间段内,最后开始的操作也会完成最后一个)。

mov ah, 5 垃圾邮件测试中,它比mov eax, 5 垃圾邮件测试慢了 2.7 倍,因为基本上没有简单的方法来并行化操作。每个操作都被标记为“从 eax 读取”,因为它依赖于 eax 的先前值,并且它也被标记为“写入 eax”,因为它修改了 eax 的值。如果一个操作必须从 eax 读取,它必须发生在 之前的操作完成写入 eax 之后。因此,并行化受到很大影响。

此外,如果您想自己尝试,您会注意到add eax, 5 垃圾邮件和add ah, 5 垃圾邮件都花费完全相同的时间(我的 CPU 上为 2.7 秒,与 mov ah, 5 完全相同!)。在这种情况下,add eax, 5 被标记为“从eax 读取”,并被标记为“写入eax”,因此它接收到与mov ah, 5 完全相同的减速,这也必须同时读取和写入eax!实际的 mov 与 add 无关紧要,逻辑门将在 ALU 的单个滴答中通过所需的操作立即将输入连接到输出。

所以,我希望能说明为什么 eax 的 64 位覆盖功能导致的时间比 ah 的保存系统快。


这里还有更多细节,为什么 ah/al 交换测试需要快得多的 1.43 秒?嗯,最有可能发生的事情是寄存器重命名有助于所有“mov ah, 5; mov al, 5”的写入。看起来 CPU 足够智能,可以拆分“ah”和“al”它们自己的完整 64 位寄存器,因为它们无论如何都使用“eax”寄存器的不同部分。因此,这允许并行进行一对连续的ah 然后al 操作,从而节省大量时间。如果“eax”被完整读取,CPU 需要将两个“al”与“ah”寄存器合并回一个寄存器,导致显着减速(稍后显示)。在早期的“mov ah, 5”-only 测试中,不可能将 eax 拆分为单独的寄存器,因为无论如何我们每次都使用“ah”。

而且,有趣的是,如果您查看 ah/al/eax 测试,您会发现它几乎与 eax 测试一样快!在这种情况下,我预测所有三个都有自己的寄存器,因此代码非常并行化。

当然,如前所述,当必须合并 ah/al 时,尝试在该循环中的任何位置读取 eax 会降低性能,这是一个示例:

时间:3.412s、3.390s、3.515s

global _main
_main:
mov ecx, 1000000000
loop:
test ecx, ecx
jz exit
mov ah, 5
mov al, 5
xor eax, 5
mov al, 6
mov ah, 8
xor eax, 5
mov al, 8
dec ecx
jmp loop
exit:
ret

但是,请注意,上面的测试没有适当的控制组,因为它使用 xor 而不是 mov(例如,如果只使用“xor”是为什么它很慢的原因)。所以,这里有一个测试来比较它:

时间:1.426s、1.424s、1.392s

global _main
_main:
mov ecx, 1000000000
loop:
test ecx, ecx
jz exit
mov ah, 5
mov al, 5
xor ah, 5
mov al, 6
mov ah, 8
xor ah, 5
mov al, 8
dec ecx
jmp loop
exit:
ret

上述测试非常积极地合并,导致可怕的 3.4 秒,实际上比任何其他测试都慢得多。但是,al/ah 测试将 al/ah 拆分为两个不同的寄存器,因此运行速度非常快,比仅使用 ah 更快,因为可以并行化连续的 ah/al 操作。因此,这是英特尔愿意做出的权衡。

如前所述,正如所见,您是否执行xor vs add vs mov 并不重要,上面的 ah/al 仍然需要 1.4 秒,直接按位/添加/移动全部用很少的逻辑门将输入连接到输出,你使用哪种操作并不重要(但是, mul 和 div 确实会更慢,这需要更严格的计算,因此需要几个微周期)。


过去的两个测试显示报告的部分寄存器停顿,老实说,我一开始甚至没有考虑过。我首先认为寄存器重命名将有助于缓解问题,他们似乎在 ah/al 混合和 ah/al/eax 混合中这样做。然而,用脏的 ah/al 值读取 eax 是很残酷的,因为处理器现在必须组合 ah/al 寄存器。看起来处理器制造商认为寄存器重命名部分寄存器仍然值得,这是有道理的,因为大多数使用 ah/al 的工作不涉及读取 eax,如果这是您的计划,您只需从 ah/al 读取。这样,与 ah/al 打交道的紧密循环会大有裨益,唯一的危害是下次使用 eax 时会打嗝(此时可能不再使用 ah/al)。

如果英特尔想要,而不是 ah/al 寄存器重命名优化给出 1.4 秒,正常 ah 是 2.7 秒,寄存器合并滥用需要 3.4 秒,英特尔可能不会关心寄存器重命名,所有这些测试都会完全相同的 2.7 秒。但是,英特尔很聪明,他们知道那里有很多代码会想要使用 ah 和 al,但是找到大量使用 al 和 ah 的代码并不常见,同时还一直从总 eax 中读取嗯。

总体而言,即使在没有部分寄存器停顿的情况下,写入 ah 仍然比写入 eax 慢得多,这是我试图解决的问题。

当然,结果可能会有所不同。其他处理器(很可能是非常旧的处理器)可能具有关闭一半总线的控制位,这将允许总线在需要时像 16 位或 8 位总线一样工作。这些控制位必须通过沿输入的逻辑门连接到寄存器,这会稍微减慢寄存器的任何和所有使用,因为现在在寄存器可以更新之前还要通过一个门。由于此类控制位在绝大多数情况下都会关闭(因为很少会混淆 8 位/16 位值),看起来英特尔决定不这样做(有充分的理由)。

【讨论】:

  • 请注意,我并不是说编译器应该始终在旧 CPU 上同时使用 AH 和 AL。对这些寄存器的写入可能无法在 P5 上相互配对。如果您可以使用 DWORD 寄存器在相同数量的指令中完成相同的事情,那么它在任何 CPU 上几乎都不会更糟,而且通常至少在某些 CPU 上稍微好一些。因此,总体而言,您建议尽可能使用 DWORD 寄存器并避免使用 AH。但是您的推理是虚假的,正如您声称 AH 在 所有 CPU 上天生就很慢。
  • @PeterCordes 嘿伙计,我并没有说它在所有 CPU 上都很慢。我已经记下结果可能会有所不同,并发布了我的 CPU 以便其他人可以比较。我已经提到这可以优化,不一定是真的。显然,对 Intel 的测试无法证明 AMD 的存在。我从来没有说你错了;你提出了许多正确的陈述。我所做的只是说,即使没有任何寄存器重命名,CPU 使用ah 肯定比eax 更难。 CPU 可以让它变慢,也可以使用晶体管和逻辑来解决问题。
  • 我解释了为什么xor eax, 5 很慢,正如您刚才评论的那样。我不明白你在这里试图反驳什么。我说错什么了吗?我说这是部分注册失速。不是部分寄存器停顿吗?它读取eax,并且必须合并过时的啊。这是部分寄存器停顿,所以我不知道你想说什么不准确。引用前面提到的链接,“部分寄存器停顿是当我们写入 32 位寄存器的一部分,然后从整个寄存器或更大部分读取时发生的问题。”。
  • 我完全忘记了你的回答到底说了什么,抱歉。实际上只有几件事是错误的:“所以在这十年中 eax 肯定更快”是不正确的:在没有重命名寄存器的有序 CPU 上,写入 AL 与写入 EAX 完全相同。对于像add eax, 5 这样无论如何都必须读取EAX 的RMW 操作,add al, 5 在Haswell 及更高版本以及AMD 上的成本完全相同。所以字节寄存器并不总是较慢,它们几乎永远不会更快。不过,在某些情况下,您可以通过使用指令来保存指令,以获得净速度增益。
  • @PeterCordes 是的,RMW 和 ah 和 eax 一样快,我从来没有说过。我在回答的第二段中特别提到的是写作。不读。问题是,写入“啊”需​​要不必要的读取。这就是我所说的。
【解决方案2】:

int8_tuint8_t 有两种实际用途。它可以节省内存,这一点很重要,不是因为主流计算机会用完,而是因为它可以让更多数据放入 CPU 的缓存中。而且有时您还需要准确地指定内存中的布局,例如设备驱动程序或数据包头。

指令本身并不快(正如 Nicholas Pipitone 的精彩回答所示),并且可能需要更多或更少的字节来编码。在某些情况下,您或许可以改进寄存器分配。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2014-11-18
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-07-12
    • 1970-01-01
    • 2021-04-16
    相关资源
    最近更新 更多