GCC 内联汇编:"g" 约束和参数大小

GCC inline assembly: "g" constraint and parameter size

背景

我知道用内联汇编解决以下问题不是一个好主意。我目前正在 linux 内核上作为 class 的一部分学习内联汇编,这是 class.

作业的一部分

设置

开头,下面是一段几乎正确的代码,但有段错误。它是一个函数,仅使用内联汇编将 src 的子字符串从索引 s_idx 开始并(唯一地)在索引 e_idx 结束到预分配的 dest 中。

static inline char *asm_sub_str(char *dest, char *src, int s_idx, int e_idx) {
  asm("addq %q2, %%rsi;"  /* Add start index to src (ptrs are 64-bit) */
      "subl %k2, %%ecx;"  /* Get length of substr as e - s (int is 32-bit) */
      "cld;"              /* Clear direction bit (force increment) */
      "rep movsb;"        /* Move %ecx bytes of str at %esi into str at %edi */
      : /* No Ouputs */
      : "S" (src), "D" (dest), "g" (s_idx), "c" (e_idx)
      : "cc", "memory"
      );

  return dest;
}

此代码的问题在于第二个输入参数的约束。当使用 gcc 的默认优化和 -ggdb 编译时,生成以下程序集:

Dump of assembler code for function asm_sub_str:
   0x00000000004008e6 <+0>:     push   %rbp
   0x00000000004008e7 <+1>:     mov    %rsp,%rbp
   0x00000000004008ea <+4>:     mov    %rdi,-0x8(%rbp)
   0x00000000004008ee <+8>:     mov    %rsi,-0x10(%rbp)
   0x00000000004008f2 <+12>:    mov    %edx,-0x14(%rbp)
   0x00000000004008f5 <+15>:    mov    %ecx,-0x18(%rbp)
   0x00000000004008f8 <+18>:    mov    -0x10(%rbp),%rax
   0x00000000004008fc <+22>:    mov    -0x8(%rbp),%rdx
   0x0000000000400900 <+26>:    mov    -0x18(%rbp),%ecx
   0x0000000000400903 <+29>:    mov    %rax,%rsi
   0x0000000000400906 <+32>:    mov    %rdx,%rdi
   0x0000000000400909 <+35>:    add    -0x14(%rbp),%rsi
   0x000000000040090d <+39>:    sub    -0x14(%rbp),%ecx
   0x0000000000400910 <+42>:    cld    
   0x0000000000400911 <+43>:    rep movsb %ds:(%rsi),%es:(%rdi)
   0x0000000000400913 <+45>:    mov    -0x8(%rbp),%rax
   0x0000000000400917 <+49>:    pop    %rbp
   0x0000000000400918 <+50>:    retq 

这与第二个输入参数的约束设置为 "m" 而不是 "g" 时生成的程序集相同,这让我相信编译器实际上选择了 "m" 约束。在使用 gdb 逐步执行这些指令时,我发现有问题的指令是 +35,它将起始偏移索引 s_idx 添加到 %rsi 中的 src 指针。问题当然是 s_idx 只是 32 位,静态上那个位置的 64 位整数的高 4 字节不一定为 0。在我的机器上,它实际上是非零的,并导致除了混淆 %rsi 的高 4 字节,这会导致指令 +43 中的段错误。

问题

当然上面的解决方案是将参数 2 的约束更改为 "r" 因此它被放置在它自己的 64 位寄存器中,其中前 4 个字节被正确置零并调用这是一天。相反,我的问题是,在这种情况下,当表达式 "%q2" 表示参数 2 的值时,为什么 gcc 将 "g" 约束解析为 "m" 而不是 "r"将用作 64 位值?

我不太了解 gcc 是如何解析内联汇编的,而且我知道在汇编中没有真正的输入感,但我认为 gcc 可以识别 s_idx 的有效隐式转换a long 当它在第一条内联指令中用作 64 位值时。 FWIW,如果我明确地将 "g" (s_idx) 更改为 "g" ((long) s_idx),则 gcc 会将 "g" 约束解析为 "r",因为 (long) s_idx 是一个临时值。我认为 gcc 也可以隐式地做到这一点?

but I would think that gcc could recognize the effectively implicit cast of s_idx to a long when it's used as a 64-bit value in the first inline instruction.

不,gcc编译周边代码时只看约束,根本不看asm模板字符串.填充 % 模板操作数的 gcc 部分与周围代码的寄存器分配和代码生成完全分开。

没有检查完整性或理解使用模板操作数的上下文。也许您有一个 16 位输入并希望使用 vmovd %k[input], %%xmm0 / vpbroadcastw %%xmm0, %%ymm0 将其复制到向量寄存器.高 16 位被忽略,因此您不希望 gcc 浪费时间为零或为您对其进行符号扩展。但是您肯定想使用 vmovd 而不是 vpinsrw [=20=], %[input], %%xmm0,因为那样会更多 uops 并且具有错误的依赖性。对于所有 gcc 知道或关心的人,您可以在 asm 注释行中使用操作数,例如 "# low word of input = %h2 \n.

GNU C 内联 asm 的设计使约束告诉编译器它需要知道的一切。因此,您需要手动将 s_idx 转换为 long.

您不需要为 ECX 转换输入,因为 sub 指令将隐式地将结果零扩展(到 RCX)。您的输入是带符号的类型,但大概您希望差异始终为正。

必须始终假定寄存器输入具有超出输入类型宽度的高垃圾。这类似于 x86-64 System V 调用约定中的函数 args 可以有 ,但(我假设)没有关于扩展到 32 位的不成文规则。 (请注意,在函数内联之后,您的 asm 语句的输入可能不是函数参数。您不想使用 __attribute__((noinline)),正如我所说,它无论如何也无济于事。)


leading me to believe the compiler is effectively choosing the "m" constraint.

是的,gcc -O0 将每个 C 语句之间的所有内容都溢出到内存中(因此如果在断点处停止,您可以使用调试器更改它)。因此,内存操作数是编译器最有效的选择。它需要加载指令才能将其放回寄存器。即值 asm 语句之前的内存中,在 -O0.

(clang 在多选项约束方面很糟糕,即使在 -O3 时也会选择内存,即使这意味着首先溢出,但 gcc 没有这个问题。)

当输入是数字常量时,

gcc -O0(和 clang)将对 g 约束使用立即数,例如"g" (1234)。在你的情况下,你得到:

    ...
    addq 34, %rsi; 
    subl 34, %ecx; 
    rep movsb
    ...

"g" ((long)s_idx) 这样的输入即使在 -O0 也会使用寄存器,就像 x+y 或任何其他临时结果(只要 s_idx 还没有long)。有趣的是,即使 (unsigned) 也会产生一个寄存器操作数,即使 intunsigned 的大小相同并且转换不需要任何指令。在这一点上,您可以确切地看到 gcc -O0 优化有多么少,因为您获得的结果更多地取决于 gcc 内部结构的设计方式,而不是什么有意义或有效。


编译启用优化 如果你想看到有趣的 asm。请参阅 ,尤其是 Matt Godbolt 的 CppCon2017 中关于查看编译器输出的 link。

虽然在不禁用优化的情况下检查 asm 很好,但对于内联 asm 也是如此;如果它只是寄存器,您可能没有意识到使用 q 覆盖的问题,尽管它 仍然是一个问题。检查它如何在 -O3 处内联到几个不同的调用者也很有用(特别是如果您使用一些编译时常量输入进行测试)。


您的代码严重损坏

除了上面讨论的高垃圾问题之外,您修改了输入操作数寄存器而不告诉编译器。

通过使其中一些 "+" read/write 输出来修复此问题意味着您的 asm 语句默认情况下不再是 volatile,因此如果未使用输出,编译器将优化它. (这包括函数内联之后,因此 return dest 对于独立版本来说足够了,但如果调用者忽略了 return 值,那么在内联之后就不够了。)

您确实使用了 "memory" 破坏器,因此编译器将假定您使用 read/write 内存。你可以告诉它你读取和写入的是哪个内存,这样它就可以更有效地优化你的副本。请参阅 get string length in inline GNU Assembler:您可以使用虚拟内存 input/output 约束,例如 "m" (*(const char (*)[]) src)

char *asm_sub_str_fancyconstraints(char *dest, char *src, int s_idx, int e_idx) {
  asm (
      "addq %[s_idx], %%rsi; \n\t"  /* Add start index to src (ptrs are 64-bit) */
      "subl %k[s_idx], %%ecx;          \n\t"  /* Get length of substr as e - s (int is 32-bit) */

      // the calling convention requires DF=0, and inline-asm can safely assume it, too
      // (it's widely done, including in the Linux kernel)
      //"cld;"              /* Clear direction bit (force increment) */

      "rep movsb;                \n\t"        /* Move %ecx bytes of str at %esi into str at %edi */
      : [src]"+&S" (src), [dest]"+D" (dest), [e_idx]"+c" (e_idx)
        , "=m" (*(char (*)[]) dest)     // dummy output: all of dest
      : [s_idx]"g" ((long long)s_idx)
        , "m" (*(const char (*)[]) src) // dummy input: tell the compiler we read all of src[0..infinity]
      : "cc"
      );

  return 0; // asm statement not optimized away, even without volatile,
            //  because of the memory output.
            // Just like dest++; could optimize away, but *dest = 0; couldn't.
}

格式:注意在每行末尾使用 \n\t 以提高可读性;否则 asm 指令都在一行上,仅由 ; 分隔。 (它会 assemble 很好,但如果你正在检查你的 asm 模板是如何工作的,那么它不是很容易阅读。)

这编译(使用 gcc -O3)为

asm_sub_str_fancyconstraints:
    movslq  %edx, %rdx        # from the (long long)s_idx
    xorl    %eax, %eax        # from the return 0, which I changed to test that it doesn't optimize away
    addq %rdx, %rsi; 
    subl %edx, %ecx;          # your code zero-extends (e_idx - s_idx)
    rep movsb;                

    ret

我放this + a couple other versions on the Godbolt compiler explorer with gcc + clang。一个更简单的版本修复了错误,但仍然使用 "memory" clobber + asm volatile 来获得正确性,比这个告诉编译器读取和写入内存的版本更多的编译时优化成本。


早期破坏:注意"+&S"约束:

如果出于某种奇怪的原因,编译器知道 src 地址和 s_idx 相等,它可以对两个输入使用相同的寄存器 (esi/rsi)。这将导致在 sub 中使用之前修改 s_idx。声明持有 src 的寄存器被提前破坏(在最后一次读取所有输入寄存器之前)将强制编译器选择不同的寄存器。

请参阅上面的 Godbolt link,了解在没有 & 早期破坏的情况下导致损坏的调用程序。 (但仅限于无意义的 src = (char*)s_idx;)。多指令 asm 语句通常需要早期破坏声明,以防止更现实的破坏可能性,因此请务必牢记这一点,并且只有在您确定任何只读输入都可以共享寄存器时才将其保留输出或 input/output 操作数。 (当然,使用特定寄存器约束会限制这种可能性。)

我省略了 ecxe_idx 的早期破坏声明,因为唯一的 "free" 参数是 s_idx,将它们放在同一个寄存器中将根据需要导致 sub same,samerep movsb 运行 0 次迭代。


当然,让编译器进行数学计算并简单地在正确的寄存器中请求 rep movsb 的输入会更有效率。特别是如果 e_idxs_idx 都是编译时常量,强制编译器 mov 立即数到寄存器然后减去另一个立即数是愚蠢的。

或者更好的是,根本不使用内联汇编。 (但是如果你真的想要 rep movsb 来测试它的性能,内联 asm 是一种方法。gcc 也有调整选项来控制 memcpy 内联的方式,如果有的话。)

没有建议您 https://gcc.gnu.org/wiki/DontUseInlineAsm 如果可以避免的话,内联 asm 答案是不完整的。