首页 > 解决方案 > GCC 代码似乎违反了内联汇编规则,但专家认为并非如此

问题描述

我与一位专家进行了接触,据称他的编码技能比我自己高得多,他比我以往任何时候都更了解内联汇编。

其中一种说法是,只要操作数作为输入约束出现,您就不需要将其列为 clobber 或指定寄存器可能已被内联汇编修改。当其他人试图获得以memset这种方式有效编码的实现的帮助时,对话开始了:

void *memset(void *dest, int value, size_t count)
{
    asm volatile  ("cld; rep stosb" :: "D"(dest), "c"(count), "a"(value));
    return dest;
}

当我在没有告诉编译器的情况下评论破坏寄存器的问题时,专家的说法是告诉我们:

"c"(count) 已经告诉编译器 c 被破坏了

我在专家自己的操作系统中找到了一个示例,他们使用相同的设计模式编写了类似的代码。他们使用 Intel 语法进行内联汇编。这个爱好操作系统代码在内核(ring0)上下文中运行。一个例子是这个缓冲区交换函数1

void swap_vbufs(void) {
    asm volatile (
        "1: "
        "lodsd;"
        "cmp eax, dword ptr ds:[rbx];"
        "jne 2f;"
        "add rdi, 4;"
        "jmp 3f;"
        "2: "
        "stosd;"
        "3: "
        "add rbx, 4;"
        "dec rcx;"
        "jnz 1b;"
        :
        : "S" (antibuffer0),
          "D" (framebuffer),
          "b" (antibuffer1),
          "c" ((vbe_pitch / sizeof(uint32_t)) * vbe_height)
        : "rax"
    );

    return;
}

antibuffer0, antibuffer1, 和framebuffer都是内存中的缓冲区,被视为 的数组uint32_tframebuffer是实际视频内存 (MMIO) antibuffer0antibuffer1是在内存中分配的缓冲区。

在调用此函数之前已正确设置全局变量。它们被声明为:

volatile uint32_t *framebuffer;
volatile uint32_t *antibuffer0;
volatile uint32_t *antibuffer1;

int vbe_width = 1024;
int vbe_height = 768;
int vbe_pitch;

我对这种代码的问题和疑虑

作为一个对内联汇编有明显天真理解的明显新手,我想知道我明显未受过教育的信念是否该代码可能非常错误是正确的。我想知道这些担忧是否有任何价值:

  1. RDIRSIRBXRCX都被此代码修改。RDIRSILODSDSTOSD隐式递增。其余的被显式修改为

        "add rbx, 4;"
        "dec rcx;"
    

    这些寄存器均未列为输入/输出,也未列为输出操作数。我相信需要修改这些约束以通知编译器这些寄存器可能已被修改/破坏。我认为正确的唯一被列为 clobbered 的寄存器是RAX。我的理解正确吗?我的感觉是RDIRSIRBXRCX应该是输入/输出约束(使用+修饰符)。即使有人试图争辩说 64 位 System V ABI 调用约定将保存它们(假设恕我直言编写此类代码的方式很糟糕),RBX是一个非易失性寄存器,它将在此代码中发生变化。

  2. 由于地址是通过寄存器传递的(而不是内存限制),我认为编译器没有被告知这些指针指向的内存已被读取和/或修改是一个潜在的错误。我的理解正确吗?

  3. RBXRCX是硬编码寄存器。允许编译器通过约束自动选择这些寄存器不是很有意义吗?

  4. 如果假设必须在这里(假设地)使用内联汇编,那么这个函数的无错误 GCC 内联汇编代码会是什么样子?这个功能是否正常,我只是不像专家那样理解 GCC 的扩展内联汇编的基础知识吗?


脚注

标签: gccx86x86-64inline-assemblyosdev

解决方案


你在所有方面都是正确的,这段代码对编译器来说充满了可能会咬你的谎言。 例如,使用不同的周围代码,或不同的编译器版本/选项(尤其是链接时优化以启用跨文件内联)。

swap_vbufs甚至看起来效率都不是很高,我怀疑 gcc 与纯 C 版本相同或更好。 https://gcc.gnu.org/wiki/DontUseInlineAsmstosd在 Intel 上是 3 uops,比普通的mov-store +差add rdi,4。并且add rdi,4无条件将避免需要该块,因为缓冲区是相等的,所以在没有 MMIO 存储到视频 RAM 的(希望)快速路径上增加了else一个额外的块。jmp

lodsd在 Haswell 和更新版本上只有 2 uops,所以如果你不关心 IvyBridge 或更老的版本也没关系)。

在内核代码中,我猜他们正在避免 SSE2,即使它是 x86-64 的基线,否则你可能想要使用它。对于普通的内存目标,您只需memcpy使用rep movsdor ERMSB rep movsb,但我想这里的重点是通过检查视频 RAM 的缓存副本来尽可能避免 MMIO 存储。尽管如此,无条件流式存储movnti可能是有效的,除非视频 RAM 映射为 UC(不可缓存)而不是 WC。


通过例如在同一函数中的内联 asm 语句之后再次使用相关的 C 变量,很容易构建在实践中确实会中断的示例。(或在内联 asm 的父函数中)。

您要销毁的输入通常必须使用匹配的虚拟输出或带有 C tmp var 的 RMW 输出来处理,而不仅仅是"r". 或"a"

"r"或任何特定的寄存器约束,例如"D"意味着这是一个只读输入,编译器可以期望在之后找到不受干扰的值。没有“我想销毁的输入”约束;您必须将其与虚拟输出或变量合成。

这一切都适用于支持 GNU C 内联 asm 语法的其他编译器(clang 和 ICC)。

来自 GCC 手册:扩展asm输入操作数

不要修改仅输入操作数的内容(绑定到输出的输入除外)。编译器假定从 asm 语句退出时,这些操作数包含与执行语句之前相同的值。不可能使用clobbers 来通知编译器这些输入中的值正在改变。

raxclobber 将其"a"用作输入是错误的;clobber 和操作数不能重叠。)


示例 1:寄存器输入操作数

int plain_C(int in) {   return (in+1) + in;  }

// buggy: modifies an input read-only operand
int bad_asm(int in) {
    int out;
    asm ("inc %%edi;\n\t mov %%edi, %0" : "=a"(out) : [in]"D"(in) );
    return out + in;
}

Godbolt 编译器资源管理器上编译

请注意 gccaddl使用edifor in,即使内联 asm 使用该寄存器作为 input。(因此中断,因为这个错误的内联汇编修改了寄存器)。在这种情况下它恰好成立in+1。我使用了 gcc9.1,但这不是新行为。

## gcc9.1 -O3 -fverbose-asm
bad(int):
        inc %edi;
         mov %edi, %eax         # out  (comment mentions out because I used %0)

        addl    %edi, %eax      # in, tmp86
        ret     

我们通过告诉编译器同一个输入寄存器也是一个输出来解决这个问题,所以它不能再指望那个了。(或通过使用auto tmp = in; asm("..." : "+r"(tmp));

int safe(int in) {
    int out;
    int dummy;
    asm ("inc %%edi;\n\t mov %%edi, %%eax"
     : "=a"(out),
       "=&D"(dummy)
     : [in]"1"(in)  // matching constraint, or "D" works.
    );
    return out + in;
}
# gcc9.1 again.
safe_asm(int):
        movl    %edi, %edx      # tmp89, in    compiler-generated save of in
          # start inline asm
        inc %edi;
         mov %edi, %eax
          # end inline asm
        addl    %edx, %eax      # in, tmp88
        ret

显然"lea 1(%%rdi), %0"可以通过不修改输入来避免这些问题,mov/也是如此inc。这是一个故意破坏输入的人为示例。


如果函数没有内联并且在 asm 语句之后没有使用输入变量,那么您通常可以对编译器撒谎,只要它是一个调用破坏寄存器。

发现有人编写了恰好在他们使用的上下文中工作的不安全代码的情况并不少见。他们相信在该上下文中使用一个编译器版本/选项简单地测试它就足以验证其安全性或正确性。

但这不是 asm 的工作方式。编译器相信您可以准确地描述 asm 的行为,并且只是在模板部分进行文本替换。

如果 gcc 假设 asm 语句总是破坏它们的输入,那将是一个糟糕的错过优化。事实上,内联 asm 使用的相同约束(我认为)用于向 gcc 教授有关 ISA 的内部机器描述文件。(所以破坏的输入对于代码生成来说是可怕的)。

GNU C 内联汇编的整个设计都是基于包装一条指令,这就是为什么即使是用于输出的 early-clobber 也不是默认设置的原因。如果需要,如果在内联汇编中编写多个指令或循环,则必须手动执行此操作。


编译器没有被告知这些指针指向的内存已被读取和/或修改的潜在错误。

这也是正确的。寄存器输入操作数并不意味着指向的内存也是输入操作数。在不能内联的函数中,这实际上不会导致问题,但是一旦启用链接时优化,跨文件内联和过程间优化就成为可能。

有一个现有的通知铿锵声,内联汇编读取特定的内存区域未回答的问题。此Godbolt 链接显示了您可以揭示此问题的一些方法,例如

   arr[2] = 1;
   asm(...);
   arr[2] = 0;

如果 gcc 假设arr[2]不是 asm 的输入,只是arr地址本身,它将执行死存储消除并删除=1分配。(或者将其视为使用 asm 语句重新排序商店,然后将 2 个商店折叠到同一位置)。

数组很好,因为它表明它甚至"m"(*arr)不适用于指针,只能用于实际的数组。该输入操作数只会告诉编译器这arr[0]是一个输入,仍然不是arr[2]。如果这就是你所有的 asm 读取,那是一件好事,因为它不会阻止其他部分的优化。

例如memset,要正确声明指向的内存是输出操作数,将指针转换为指向数组的指针并取消引用它,以告诉 gcc 整个内存范围是操作数。 *(char (*)[count])pointer. (您可以[]留空以指定通过此指针访问的任意长度的内存区域。)

// correct version written by @MichaelPetch.  
void *memset(void *dest, int value, size_t count)
{
  void *tmp = dest;
  asm ("rep stosb    # mem output is %2"
     : "+D"(tmp), "+c"(count),       // tell the compiler we modify the regs
       "=m"(*(char (*)[count])tmp)   // dummy memory output
     : "a"(value)                    // EAX actually is read-only
     : // no clobbers
  );
  return dest;
}

使用虚拟操作数包含一个 asm 注释可以让我们看到编译器是如何分配它的。我们可以看到编译器(%rdi)使用 AT&T 语法进行选择,因此它愿意使用同时也是输入/输出操作数的寄存器。

如果输出上有一个 early-clobber,它可能想要使用另一个寄存器,但没有它,我们不会花费任何成本来获得正确性。

对于void不返回指针的函数(或内联到不使用返回值的函数之后),它不必在rep stosb销毁指针之前将指针 arg 复制到任何地方。


推荐阅读