【问题标题】:Locking register usage for a certain section of code [closed]锁定某段代码的寄存器使用[关闭]
【发布时间】:2016-07-12 04:17:05
【问题描述】:

让我们考虑使用 C 代码编写的情况。当编译器遇到函数调用时,我的理解是它做了以下事情:

  • 所有寄存器压入堆栈
  • 跳转到新功能,在里面做点什么
  • 将旧上下文从堆栈中弹出回寄存器。

现在,一些处理器有 1 个工作寄存器,有的 32 个,还有的更多。我最关心的是更多的寄存器。如果我的处理器有 32 个寄存器,编译器将需要发出 32 个 push 和 pop 指令,就像函数调用的基本开销一样。如果我可以在函数中以一些编译灵活性 [1] 换取更少的推送和弹出指令,那就太好了。也就是说,我想要一种可以告诉编译器“对于函数foo(),只使用4个寄存器。这意味着编译器在跳转到foo()之前只需要push/pop 4个寄存器。

我意识到在现代 PC 上担心这很愚蠢,但我更多地考虑的是低速嵌入式系统,您可能会非常快速地处理中断,或者一遍又一遍地调用一个简单的函数。我也意识到这可能很快成为一个依赖于架构的特性。使用“Source Source -> Dest”指令集(如 ARM)的处理器,而不是累加器(如 Freescale/NXP HC08)可能对我们允许函数使用的寄存器数量有一些下限。

我确实知道编译器使用内联小函数等技巧来提高速度,而且我意识到我可以通知大多数编译器不要生成推送/弹出代码,而只是自己在汇编中手动编写代码,但我的问题集中在指导编译器从“C-Land”执行此操作。

我的问题是,是否有允许这样做的编译器?这对于优化编译器是否必要(他们已经这样做了)?

[1] 编译灵活性:通过减少编译器在函数体中可用的寄存器数量,限制了它的灵活性,并且它可能需要更多地利用堆栈,因为它不能只使用另一个寄存器.

【问题讨论】:

  • 你的理解是错误的。没有理由推送所有寄存器,他们当然不会这样做。有些寄存器是输入,有些是输出,有些从未使用过,因此没有理由保存它们。查看调用约定,您将看到它是如何完成的。编译器当然知道它正在使用哪些寄存器,并且可以在需要时保存它们的状态。
  • @SamiKuhmonen 哦,好的,谢谢。
  • 太宽泛了。这涉及 CPU、API、编译器优化、操作系统/库、链接器等。如果您需要特定的寄存器使用,您很可能还需要特定的代码结构,这意味着 Assembler(可能是内联的)。此外,一些编译器已经允许保留全局寄存器,但这通常比在代码中接受更多的保存/恢复指令更糟糕。这是从 MCU 的角度来看的,而不是(只是)更大的铁杆。

标签: c assembly compiler-optimization cpu-architecture


【解决方案1】:

当谈到编译器、寄存器和函数调用时,您通常可以认为寄存器属于以下三类之一:“不干涉”、易失性和非易失性。

“放手”类别是那些编译器通常不会使用的类别,除非您明确告诉它(例如使用内联汇编)。这些可能包括调试寄存器和其他特殊用途的寄存器。该列表因平台而异。

易失性(或临时/调用破坏/调用者保存)寄存器集是函数可以在不需要保存的情况下使用的寄存器。也就是说,调用者明白在函数调用之后这些寄存器的内容可能不同。因此,如果调用者在其想要保留的那些寄存器中有任何数据,则它必须在调用之前保存该数据,然后再将其恢复。在 32 位 x86 平台上,这些易​​失性寄存器(有时称为暂存寄存器)通常是 EAX、ECX 和 EDX。

非易失性(或调用保留或被调用者保存)寄存器集是函数在使用它们之前必须保存并在返回之前恢复其原始值的寄存器。如果调用函数使用它们,它们只需要保存/恢复。在 32 位 x86 平台上,这些通常是剩余的通用寄存器:EBX、ESI、EDI、ESP、EBP。

希望这会有所帮助。


(我只是想添加一个小例子,但很快就被带走了。如果这个问题没有结束,我会添加我自己的答案,但我将把这个长部分留在这里,因为我认为它很有趣。如果您不想在答案中使用它,请压缩或完全编辑它——彼得)

举一个更具体的例子,SysV x86-64 ABI 是经过精心设计的(参数在寄存器中传递,调用保留与临时/参数 regs 之间取得了良好的平衡)。 标签 wiki 中还有一些其他链接,解释了 ABI / 调用约定的全部内容。

考虑一个无法内联的函数调用的简单示例(因为定义不可用):

int foo(int);

int bar(int a) {
  return 5 * foo(a+2) + foo (a) ;
}

It compiles (on godbolt with gcc 5.3 for x86-64 with -O3 到以下地址:

   ## gcc output
   # AMD64 SysV ABI: first arg in e/rdi, return value in e/rax
   # the call-preserved regs used are: rbp and rbx
   # the scratch regs used are: rdx.  (arg-passing / return regs are not call-preserved)
    push    rbp             # save a call-preserved reg
    mov     ebp, edi        # stash `a` in a call-preserved reg
    push    rbx             # save another call-preserved reg
    lea     edi, [rdi+2]    # edi=a+2 as an arg for foo.  `add edi, 2`  would also work, but they're both 3 bytes and little perf difference
    sub     rsp, 8          # align the stack to a 16B boundary (the two pushes are 8B each, and call pushes an 8B return address, so another 8B is needed)
    call    foo             # eax=foo(a+2)
    mov     edi, ebp        # edi=a as an arg for foo
    mov     ebx, eax        # stash foo(a+2) in ebx
    call    foo             # eax=foo(a)
    lea     edx, [rbx+rbx*4] # edx = 5*foo(a+2), using the call-preserved register
    add     rsp, 8          # undo the stack offset
    add     eax, edx        # the add between the to function-call results

    pop     rbx             # restore the call-preserved regs we saved earlier
    pop     rbp
    ret                     # return value in eax

像往常一样,编译器可以做得更好:与其将foo(a+2) 存储在ebx 中以在对foo 的第二次调用中幸存,它可以使用一条指令(lea ebx, [rax+rax*4]) 存储5*foo(a+2)。此外,只需要一个调用保留寄存器,因为在第二个call 之后我们不需要a。这将删除一个 push/pop 对,以及 sub rsp,8 / add rsp,8 对。 (gcc bug report already filed for this missed optimization)

    ## Hand-optimized implementation (still ABI-compliant):
    push    rbx             # save a call-preserved reg; also aligns the stack

    lea     ebx, [rdi+2]    # stash ebx=a+2
    call    foo             # eax=foo(a)
    mov     edi, ebx        # edi=a+2 as an arg for foo
    mov     ebx, eax        # stash foo(a) in ebx, replacing `a+2` which we don't need anymore
    call    foo             # eax=foo(a+2)
    lea     eax, [rax+rax*4] #eax=5*foo(a+2)
    add     eax, ebx        # eax=5*foo(a+2) + foo(a)

    pop     rbx             # restore the call-preserved regs we saved earlier
    ret                     # return value in eax

请注意,在此版本中,对 foo(a) 的调用发生在 foo(a+2) 之前。它在开始时保存了一条指令(因为我们可以将 arg 原封不动地传递给第一次调用 foo),但后来删除了潜在的保存(因为现在必须在第二次调用之后发生乘以 5,并且不能与移入呼叫保留寄存器结合使用)。

如果是5*foo(a) + foo(a+2),我可以去掉额外的mov。使用我写的表达式,我不能在每种情况下都将算术与数据移动(使用lea)结合起来。或者我需要在第一个 call 之前保存 a 并单独执行 add edi,2

【讨论】:

  • volatile 寄存器实际上毫无用处,因为寄存器通常是一种“局部/临时变量”。 OP 谈论 CPU 寄存器,而不是外围寄存器。请注意,您的示例不是 C 中的 volatile 的含义。临时寄存器是一个临时(未命名)变量。
  • @Olaf:对“易失性”一词的使用进行了很好的观察。此外,您指出的暂存寄存器与易失性寄存器并不完全相同。然而,由于易失性寄存器通常(但不限于)用于临时存储值,它们有时仍被称为暂存寄存器并且远非无用。
  • 请不要在 C 上下文中使用术语“易失性”,因为您在其他上下文中使用它。含义是非常不同的。正如我试图指出的那样,您基本上区分了本地寄存器和全局寄存器,这与 volatile 无关,并且不应在任何其他上下文中使用 C 中的术语。您为 x86 列出的全局寄存器实际上与您的第一个类别相同:手。
  • @Olaf 术语易失性和非易失性寄存器有时用于学术著作。然而,大多数人使用调用者保存和被调用者保存的寄存器。我同意这个术语在 C 中是令人困惑的,因为 volatile register 是合法的。 @Sparky 没有放手寄存器之类的东西。事实上,没有第三类。此外,此答案纯粹是提供信息,实际上并未尝试回答问题。
  • @HadiBrais - 确实如此。信息性的。该问题假定必须首先纠正一个不正确的模型。我很懒惰,没有走得更远。我提到的“放手”类别在技术上是非易失性集合的一部分。话虽如此,将其建模为非官方的第三类仍然有价值——除非通过汇编明确告知,否则编译器不会使用这些寄存器。在 x86 上,这将包括 CRx 寄存器以及其他寄存器。尽管它们可能不是通用寄存器,但它们仍然是寄存器。
【解决方案2】:

将所有寄存器压入堆栈

没有。在优化代码中的绝大多数函数调用中,只有一小部分寄存器被压入堆栈。

我最关心的是更多的寄存器。

您是否有任何实验证据支持这种担忧?这是性能瓶颈吗?

我可以用函数中的一些编译灵活性 [1] 换取更少 推送和弹出指令。

现代编译器使用复杂的过程间寄存器分配。通过限制寄存器的数量,您很可能会降低性能。

我意识到在现代 PC 上担心这很愚蠢,但我是 更多地考虑您可能在的低速嵌入式系统 非常快速地服务中断,或者调用一个简单的函数 及以上。

这是非常模糊的。您必须显示“简单”功能、所有调用站点并指定编译器和目标嵌入式系统。您首先需要测量性能(与手写汇编代码相比)以确定这是否是一个问题。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-01-30
    相关资源
    最近更新 更多