GCC 代码似乎违反了内联汇编规则,但专家认为并非如此

GCC code that seems to break inline assembly rules but an expert believes otherwise

我曾与一位 专家 打过交道,据称他的编码技能比我自己高明得多,他对内联汇编的理解比我以往任何时候都好得多。

其中一项声明是,只要操作数显示为输入约束,您就不需要将其列为破坏者或指定寄存器可能已被内联汇编修改。当其他人试图就以这种方式有效编码的 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) already tells the compiler c is clobbered

我在 专家的 自己的操作系统中发现 an example,他们在其中使用相同的设计模式编写相似的代码。他们将 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;
}

antibuffer0antibuffer1framebuffer都是内存中的缓冲区,被视为uint32_t的数组。 framebuffer 是实际的视频内存 (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都是这段代码修改的。 RDIRSI 隐式增加 LODSDSTOSD .其余的用

    显式修改
        "add rbx, 4;"
        "dec rcx;"
    

    None 这些寄存器被列为 input/output 也没有被列为输出操作数。我认为需要修改这些约束以通知编译器这些寄存器可能已经 modified/clobbered。唯一被列为损坏且我认为正确的寄存器是 RAX。我的理解正确吗?我的感觉是 RDI, RSI, RBX, RCX 应该是 input/output 约束(使用 + 修饰符)。即使有人试图争辩说 64 位系统 V ABI 调用约定将保存它们(假设恕我直言,编写此类代码的方式很差)RBX 是一个非易失性寄存器,它将更改此代码。

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

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

  4. 如果假设必须在此处使用内联汇编(假设),那么此函数的无错误 GCC 内联汇编代码会是什么样子?这个功能是不是很好,我只是不明白 GCC 的扩展内联汇编的基础知识,就像 expert 那样?


脚注

你在所有方面都是正确的,这段代码对编译器来说充满了谎言,可能会咬你一口。 例如具有不同的周围代码,或不同的编译器版本/选项(特别是 link-time 优化以启用 cross-file 内联)。

swap_vbufs 甚至看起来都不是很有效,我怀疑 gcc 可以用纯 C 版本做同样或更好的事情。 https://gcc.gnu.org/wiki/DontUseInlineAsmstosd 在 Intel 上是 3 微指令,比常规的 mov-store + add rdi,4 差。并且使 add rdi,4 成为无条件的将避免需要那个 else 块,它在(希望如此)没有 MMIO 存储到视频 RAM 的快速路径上放置一个额外的 jmp,因为缓冲区是相等的。

lodsd 在 Haswell 和更新版本上只有 2 微指令,所以如果您不关心 IvyBridge 或更旧的版本,那没关系。

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


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

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

"r" 或任何 specific-register 约束,如 "D" 意味着这是一个 read-only 输入,编译器可以期望在之后找到不受干扰的值。没有"input I want to destroy"约束;你必须用一个虚拟输出或变量来合成它。

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

来自 GCC 手册:Extended asm Input Operands

Do not modify the contents of input-only operands (except for inputs tied to outputs). The compiler assumes that on exit from the asm statement these operands contain the same values as they had before executing the statement. It is not possible to use clobbers to inform the compiler that the values in these inputs are changing.

rax 破坏使得使用 "a" 作为输入是错误的;破坏和操作数不能重叠。)


示例 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 compiler explorer

请注意,gcc 的 addl 使用 edi 作为 in,即使内联汇编使用该寄存器作为输入 。 (因此中断,因为这个有问题的内联 asm 修改了寄存器)。在这种情况下它恰好持有 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 也是如此。这是一个故意破坏输入的人为示例。


如果函数 不是 内联并且不在 a​​sm 语句之后使用输入变量,您通常可以逃避对编译器的欺骗,只要它是 call-clobbered注册.

人们经常会发现编写了不安全代码的人恰好在他们使用它的上下文中工作。他们也并不少见地相信只需使用一个编译器在该上下文中对其进行测试version/options足以验证其安全性或正确性。

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

如果 gcc 假设 asm 语句总是破坏它们的输入,那将是一个蹩脚的错过优化。事实上,内联 asm 使用的相同约束(我认为)用于教 gcc 关于 ISA 的内部 machine-description 文件。 (所以被破坏的输入对 code-gen 来说会很糟糕)。

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


a potential bug that the compiler hasn't been told that memory that these pointers are pointing at has been read and or modified.

也对。寄存器输入操作数 而不是 意味着 pointed-to 内存也是输入操作数。在不能内联的函数中,这实际上不会引起问题,但是只要启用 link-time 优化,cross-file 内联和 inter-procedural 优化就成为可能。

现有 Informing clang that inline assembly reads a particular region of memory unanswered question. This Godbolt link 显示了一些可以揭示此问题的方法,例如

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

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

数组很好,因为它表明即使 "m"(*arr) 对指针也不起作用只有一个实际的 array。该输入操作数只会告诉编译器 arr[0] 是一个输入,而不是 arr[2]。如果这是您所有的 asm 读取,那是一件好事,因为它不会阻止其他部分的优化。

对于那个 memset 示例,要正确声明 pointed-to 内存是一个输出操作数,将指针转换为 pointer-to-array 并取消引用它,以告诉 gcc 整个内存范围是操作数。 *(char (*)[count])pointer。 (您可以将 [] 留空以指定通过此指针访问的 arbitrary-length 内存区域。)

// 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 注释让我们看看编译器是如何分配它的。我们可以看到编译器使用 AT&T 语法选择 (%rdi),因此它愿意使用也是 input/output 操作数的寄存器。

在输出上有一个 early-clobber 时,它可能想使用另一个寄存器,但如果没有它,我们不需要付出任何代价来获得正确性。

使用不 return 指针的 void 函数(或内联到不使用 return 值的函数后),它没有在让 rep stosb 销毁它之前将指针 arg 复制到任何地方。