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

Mic*_*tch 6 x86 gcc x86-64 inline-assembly osdev

我与一位专家合作,据称他的编码技能比我自己高得多,而我比我更清楚内联汇编。

其中一项主张是,只要操作数作为输入约束出现,就不需要将其列出为破坏者,也不必指定该寄存器已被内联汇编程序潜在地修改。谈话是在其他人试图通过memset这种有效编码的实现寻求帮助时发生的:

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

当我在不告知编译器的情况下评论破坏寄存器的问题时,专家的主张是告诉我们:

“ c”(计数)已经告诉编译器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;
}
Run Code Online (Sandbox Code Playgroud)

antibuffer0antibuffer1framebuffer将内存中的所有缓冲区都视为的数组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;
Run Code Online (Sandbox Code Playgroud)

我对此类代码的问题和疑虑

作为对内联汇编的一个明显的新手,他对这个主题有一个明显的天真理解,我想知道我的明显未受过教育的信念,该代码可能是非常多虫的,是否正确。我想知道这些担忧是否有好处:

  1. RDIRSIRBXRCX均被此代码修改。RDIRSI隐式增加LODSDSTOSD。其余的被显式修改

        "add rbx, 4;"
        "dec rcx;"
    
    Run Code Online (Sandbox Code Playgroud)

    这些寄存器均未列为输入/输出,也未列为输出操作数。我认为需要修改这些约束条件,以告知编译器这些寄存器可能已被修改/填充。我认为是正确的唯一列出的内容是RAX。我的理解正确吗?我的感觉是RDIRSIRBXRCX应该是输入/输出约束(使用+修饰符)。即使有人试图争辩说64位System V ABI调用约定将保存它们(假设IMHO编写这种代码的方式很差),RBX是一种非易失性寄存器,它将在此代码中更改。

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

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

  4. 如果有人假设必须在这里使用内联汇编(假设),此功能的无错误GCC内联汇编代码会是什么样?此功能是否还不错,而且我不像专家那样不了解GCC扩展内联汇编的基础?


脚注

  • 1swap_vbufs功能和相关的变量声明已按原样复制,未经版权持有者的许可,在合理使用的情况下出于评论较大作品的目的。

Pet*_*des 7

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

swap_vbufs甚至看起来效率都不高,我怀疑gcc在纯C版本中会做得更好或更出色。 https://gcc.gnu.org/wiki/DontUseInlineAsmstosd在Intel上为3 oups,比常规mov商店+ 差add rdi,4。并且add rdi,4将其设置为无条件将避免对该else块的需求,该块会jmp在(希望)快速路径上增加额外的空间,因为缓冲区相等,因此没有MMIO存储到视频RAM。

lodsd在Haswell及更高版本上只有2 oups,所以如果您不关心IvyBridge或更旧的版本就可以)。

在内核代码中,我想他们会避免使用SSE2,即使它是x86-64的基准,否则您可能想要使用它。对于普通的内存目标,您只需memcpy使用rep movsd或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语句时,这些操作数包含的值与执行该语句之前的值相同。无法使用Clobber通知编译器这些输入中的值正在更改。

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;
}
Run Code Online (Sandbox Code Playgroud)

Godbolt编译器资源管理器编译

请注意,即使内联asm将该寄存器用作输入,gcc仍addl使用ediforin。(并因此中断,因为此越野车内联汇编修改了寄存器)。它恰好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     
Run Code Online (Sandbox Code Playgroud)

我们通过告诉编译器相同的输入寄存器也是输出来解决此问题,因此不再依赖于该寄存器。(或使用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;
}
Run Code Online (Sandbox Code Playgroud)
# 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
Run Code Online (Sandbox Code Playgroud)

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


如果函数没有内联并且在asm语句之后不使用输入变量,则只要它是调用中断寄存器,通常就不必对编译器撒谎。

很少有人发现编写过不安全代码的人恰好在他们使用它的上下文中工作。对于他们来说,相信在一个上下文中仅使用一个编译器版本/选项进行测试就足以满足要求的人也很罕见。验证其安全性或正确性。

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

如果gcc假设asm语句总是破坏其输入,那将是一个糟糕的错过优化。实际上,在内部机器描述文件中(我认为)使用了内联asm使用的相同约束,这些约束向gcc讲述了ISA。(因此,销毁的输入对于代码生成而言将是可怕的)。

GNU C内联汇编的整个设计都基于包装一条指令,这就是为什么即使对于输出的早期讲义也不是默认的。如果在内联汇编中编写了多个指令或循环,则必须在必要时手动执行此操作。


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

没错 寄存器输入操作数并意味着被指向的存储器也是一个输入操作数。在无法内联的函数中,这实际上不会引起问题,但是,一旦启用链接时优化,就可以进行跨文件内联和过程间优化。

有一个现有的通知容器,内联汇编会读取内存中特定区域的未解决问题。此Godbolt链接显示了一些解决此问题的方法,例如

   arr[2] = 1;
   asm(...);
   arr[2] = 0;
Run Code Online (Sandbox Code Playgroud)

如果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;
}
Run Code Online (Sandbox Code Playgroud)

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

在输出端出现早期错误的情况下,它可能希望使用另一个寄存器,但是如果没有这个寄存器,则不需要花费任何时间来获得正确性。

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