在 GCC 内联汇编中影响内存操作数寻址模式的早期破坏者的不正确行为的具体示例?

zzz*_*hhh 11 x86 assembly gcc register-allocation inline-assembly

以下摘自GCC 手册的 Extended Asm docs,关于使用asm关键字在 C 中嵌入汇编指令:

如果一个输出参数 ( a ) 允许寄存器约束而另一个输出参数 ( b ) 允许内存约束,则会出现同样的问题。GCC 生成的用于访问b 中的内存地址的代码 可以包含可能由a共享的寄存器,并且 GCC 将这些寄存器视为 asm 的输入。如上所述,GCC 假设在写入任何输出之前消耗此类输入寄存器。如果 asm 语句在使用 b 之前写入 a,则此假设可能会导致不正确的行为。与对寄存器约束结合“&”改性剂确保修改一个不影响通过引用的地址b. 否则,位置b,如果未定义 一个使用之前改性b

斜体句子表示如果 asm 语句a在使用b.

我无法弄清楚这种“不正确的行为”是如何发生的,所以我希望有一个具体的 asm 代码示例来演示“不正确的行为”,以便我可以深入理解这一段。

当两个这样的 asm 代码并行运行时,我可以察觉到问题,但上面的段落没有提到多处理场景。

如果我们只有一个单核的CPU,能否请您出示一个asm代码,可能会产生这样的错误行为,即修改a影响引用的地址,b使得其位置b未定义。

我唯一熟悉的汇编语言是 Intel x86 汇编,因此请让示例针对该平台。

Jes*_*ter 10

考虑以下示例:

extern int* foo();
int bar()
{
    int r;

    __asm__(
        "mov $0, %0 \n\t"
        "add %1, %0"
    : "=r" (r) : "m" (*foo()));

    return r;
}
Run Code Online (Sandbox Code Playgroud)

通常的调用约定将返回值放入eax寄存器。因此,编译器很有可能决定使用eax自始至终,以避免不必要的复制。生成的程序集可能如下所示:

extern int* foo();
int bar()
{
    int r;

    __asm__(
        "mov $0, %0 \n\t"
        "add %1, %0"
    : "=r" (r) : "m" (*foo()));

    return r;
}
Run Code Online (Sandbox Code Playgroud)

请注意,下一条指令之前的mov $0, %eaxeax尝试使用它来引用输入参数,因此此代码将崩溃。使用早期的 clobber,您会强制编译器选择不同的寄存器。就我而言,结果代码是:

        subl    $12, %esp
        call    foo
        mov $0, %eax
        add (%eax), %eax
        addl    $12, %esp
        ret
Run Code Online (Sandbox Code Playgroud)

编译器可以改为将结果移动foo()edx(或任何其他空闲寄存器)中,如下所示:

        subl    $12, %esp
        call    foo
        mov $0, %edx
        add (%eax), %edx
        addl    $12, %esp
        movl    %edx, %eax
        ret
Run Code Online (Sandbox Code Playgroud)

这个例子对输入参数使用了内存约束,但这个概念同样适用于输出。

  • 事实上,https://godbolt.org/z/x4WMY7dns 准确地显示了您正在谈论的损坏,并且“=&r”早期破坏输出避免了它。(使用适用于 x86-64 的 GCC11) (3认同)

Eri*_*hil 5

鉴于下面的代码,Apple Clang 11-O3使用(%rax)a%eaxfor b

void foo(int *a)
{
    __asm__(
            "nop    # a is %[a].\n"
            "nop    # b is %[b].\n"
            "nop    # c is %[c].\n"
            "nop    # d is %[d].\n"
            "nop    # e is %[e].\n"
            "nop    # f is %[f].\n"
            "nop    # g is %[g].\n"
            "nop    # h is %[h].\n"
            "nop    # i is %[i].\n"
            "nop    # j is %[j].\n"
            "nop    # k is %[k].\n"
            "nop    # l is %[l].\n"
            "nop    # m is %[m].\n"
            "nop    # n is %[n].\n"
            "nop    # o is %[o].\n"
        :
            [a] "=m" (a[ 0]),
            [b] "=r" (a[ 1]),
            [c] "=r" (a[ 2]),
            [d] "=r" (a[ 3]),
            [e] "=r" (a[ 4]),
            [f] "=r" (a[ 5]),
            [g] "=r" (a[ 6]),
            [h] "=r" (a[ 7]),
            [i] "=r" (a[ 8]),
            [j] "=r" (a[ 9]),
            [k] "=r" (a[10]),
            [l] "=r" (a[11]),
            [m] "=r" (a[12]),
            [n] "=r" (a[13]),
            [o] "=r" (a[14])
        );
}
Run Code Online (Sandbox Code Playgroud)

因此,如果将nop指令和注释替换为%[b]之前写入 的实际指令%[a],它们将破坏 所需的地址%[a]

  • 我想知道为什么 GCC 和 clang 如此不愿意重用注册表,甚至更喜欢保存/恢复像 RBX 这样的调用保留的注册表。事实证明,这只是因为在 asm 语句之后需要数组指针“int *a”,才能将寄存器的输出获取到“a[...]”。仅当您完全最大化了可以拥有的 reg 输出数量时,gcc 或 clang 才会选择 EDI 作为输出 https://godbolt.org/z/9eeWfezzo。(clang 在之后的代码中做了一件非常糟糕的工作,将它们“全部”溢出到堆栈而不是仅 1 个,以便为重新加载指针腾出空间,然后一次复制 4B。) (3认同)
  • 同一 Godbolt 链接中的“bar()”仅返回输出的 2 之和,而不存储到 asm 语句之后的“a[]”;那么 clang 会很乐意选择 EDI 作为输出,然后再选择 R8D..R15D 中的任何一个,只有 5 个寄存器输出。(不过在此之前 GCC 仍然选择 R8D。) (2认同)
  • 顺便说一句,你使用了两次“[h]”。GCC 和主线 clang,[两个错误](https://godbolt.org/z/YvW69zzPe);我认为 Apple Clang 确实如此,那是一个复制粘贴故障。另外,为了方便 Godbolt 使用,我经常执行“nop # comment”,这样该行就不会被过滤掉。(GCC 进行纯文本替换,因此它甚至不必是有效的 asm,这与 clang 的内置汇编器不同) (2认同)
  • @PeterCordes:回复“我认为 Apple Clang 也这样做”:事实并非如此! (2认同)