如何指示内联ASM参数所指向的内存可以被使用?

17

考虑以下小函数:

void foo(int* iptr) {
    iptr[10] = 1;
    __asm__ volatile ("nop"::"r"(iptr):);
    iptr[10] = 2;
}

使用gcc编译,编译结果如下:

foo:
        nop
        mov     DWORD PTR [rdi+40], 2
        ret

需要特别注意的是,iptr 的第一次写入 iptr[10] = 1 没有发生:内联汇编 nop 是函数中的第一件事情,只有在 ASM 调用之后才出现最终的写入 2。显然,编译器决定仅需提供 iptr 值本身的最新版本,而不需要指向的内存。

我可以通过使用 memory 修饰符来告诉编译器,内存必须与更新一致,像这样:

void foo(int* iptr) {
    iptr[10] = 1;
    __asm__ volatile ("nop"::"r"(iptr):"memory");
    iptr[10] = 2;
}

这将导致预期的代码:

foo:
        mov     DWORD PTR [rdi+40], 1
        nop
        mov     DWORD PTR [rdi+40], 2
        ret

然而,这个条件太强了,因为它告诉编译器需要写入所有的内存。例如,在下面的函数中:

void foo2(int* iptr, long* lptr) {
    iptr[10] = 1;
    lptr[20] = 100;
    __asm__ volatile ("nop"::"r"(iptr):);
    iptr[10] = 2;
    lptr[20] = 200;
}

期望的行为是让编译器优化掉对 lptr[20] 的第一次写入,但不是对 iptr[10] 的第一次写入。使用 "memory" 选项不能实现这一点,因为它意味着两个写入都必须发生:

foo2:
        mov     DWORD PTR [rdi+40], 1
        mov     QWORD PTR [rsi+160], 100 ; lptr[10] written unecessarily
        nop
        mov     DWORD PTR [rdi+40], 2
        mov     QWORD PTR [rsi+160], 200
        ret

有没有一种方法可以告诉gcc扩展的asm语法编译器,输入到asm中包括指针和它所指向的任何内容?


1
@HadiBrais - 是的,但这只是=符号的一个副作用,意味着汇编可能会改变指针的值,因此gcc必须进行两次写操作(因为它们可能写入不同的位置)。然而,这并不意味着gcc在调用内联汇编之前必须执行写操作(尽管在这种情况下确实如此),因此它在一般情况下不起作用(您可以构造一个类似的示例,其中它失败)。 - BeeOnRope
2
另外,你不想要 "+r" 吗?使用 "=r" 甚至不需要编译器将指针值传递到汇编中,我认为是这样的? - BeeOnRope
1
你想从链接的重复答案中获取“具体来说,一个 m(*(const float (*)[]) fptr))会告诉编译器整个数组对象是一个任意长度的输入”。 - Jester
1
这是正确的。但如果答案有帮助,那么我想将其标记为重复也是可以的。未来可能会发现你的问题的访问者将被指向正确的方向。或者您提出其他解决方案吗?我们可以要求Peter在此处发布答案,以便他获得积分。 - Jester
1
@Jester:我认为我们可以使用一个独立的规范问答来讨论这个话题。我一直计划着写一个简单的例子,展示不需要的死存储消除。将其放入我的循环数组答案中只是一个权宜之计。我们需要一个标题总结问题、问题主体演示问题以及回答显示当前最佳实践虚拟内存输入或输出操作数和转换为数组语法的问题。这对于链接以向人们展示问题存在是有好处的。 - Peter Cordes
显示剩余3条评论
1个回答

23
这是正确的; 请求将指针作为输入到内联汇编并不意味着指向的内存也是输入或输出或两者兼而有之。使用寄存器输入和寄存器输出,对于所有gcc来说,您的asm只是通过屏蔽低位对齐指针,或向其添加常量。 (在这种情况下,您希望它优化掉死存储器。)
简单选项是asm volatile和"memory" clobber1。
更窄的、更具体的方式是同时使用一个“虚拟”内存操作数和寄存器中的指针。您的asm模板不引用此操作数(除非在asm注释中查看编译器选择了什么)。 它告诉编译器您实际读取、写入或读写的内存位置。
虚拟内存输入: "m" (*(const int (*)[]) iptr)
或输出: "=m" (*(int (*)[]) iptr). 当然也可以使用相同语法的"+m"
该语法将其转换为指向数组的指针并进行取消引用,因此实际输入是C数组。(如果您实际上有一个数组而不是指针,则不需要任何转换并且可以将其请求为内存操作数。)
如果使用[]未指定大小,则告诉GCC相对于该指针访问的任何内存都是输入、输出或双向操作数。如果使用[10][some_variable],则告诉编译器具体的大小。在运行时变量大小的情况下,实际上gcc会错过优化,即iptr [size + 1]不是输入的一部分。 GCC记录了这个,并支持它。我认为如果数组元素类型与指针相同,或者可能是char,那么这不是严格别名违规。

(from the GCC manual)
An x86 example where the string memory argument is of unknown length.

   asm("repne scasb"
    : "=c" (count), "+D" (p)
    : "m" (*(const char (*)[]) p), "0" (-1), "a" (0));
如果您可以避免在指针输入操作数上使用早期破坏,虚拟内存输入操作数通常会选择使用相同寄存器的简单寻址模式。但是,如果您确实为asm循环使用了早期破坏以保证严格正确性,则有时虚拟操作数会使gcc在内存操作数的基地址上浪费指令(和额外的寄存器)。请检查编译器的asm输出。

背景:

这是内联汇编示例中普遍存在但经常被忽略的错误,因为汇编代码被包装在不内联到任何调用者的函数中,诱使编译器重新排序存储以进行合并和死存储消除。

GNU C 内联汇编语法是围绕描述一个单一指令而设计的。意图是使用“m”或“=m”操作数约束告诉编译器有关内存输入或内存输出,并选择寻址模式。

在内联汇编中编写整个循环需要小心确保编译器真正知道发生了什么(或使用 asm volatile 加上 memory 破坏),否则在更改周围代码或启用允许跨文件内联的链接时优化时,会出现破损的风险。

另请参见Looping over arrays with inline assembly,以将 asm 语句用作循环主体,仍然在 C 中执行循环逻辑。使用实际(非虚拟)“m”和“=m”操作数,编译器可以通过使用其选择的寻址模式中的位移来展开循环。


注1:使用"memory" clobber可以让编译器将asm视为非内联函数调用(可能读取或写入除逃逸分析已证明未逃逸的局部变量之外的任何内存)。逃逸分析包括asm语句本身的输入操作数,以及任何早期调用可能存储指针的全局或静态变量。因此,通常在具有"memory" clobber的asm语句周围不必溢出/重新加载本地循环计数器。 asm volatile是必要的,即使其输出操作数未使用(因为您需要发生写入内存的未声明副作用)也不能被优化掉。
或者对于仅由asm读取的内存,如果同一输入缓冲区包含不同的输入数据,则需要再次运行asm。没有volatile,asm语句可能会被从循环中CSEd掉。("memory" clobber不会使优化器在考虑是否需要运行asm语句时将所有内存都视为输入。)
没有输出操作数的asm隐式地是volatile,但最好将其明确化。 (GCC手册有关于asm volatile的章节。)

例如,asm("... sum an array ..." : "=r"(sum) : "r"(pointer), "r"(end_pointer) : "memory")具有输出操作数,因此不是隐式易失性。如果您像下面这样使用它

 arr[5] = 1;
 total += asm_sum(arr, len);
 memcpy(arr, foo, len);
 total += asm_sum(arr, len);

没有使用{{volatile}},第二个{{asm_sum}}可能会被优化掉,假设相同的{{asm}}和相同的输入操作数(指针和长度)将产生相同的输出。对于任何不是其显式输入操作数的纯函数的{{asm}},都需要使用{{volatile}}。如果它没有被优化掉,那么{{"memory"}} clobber将具有需要内存同步的期望效果。

2
等我有时间的时候,我会用Godbolt的例子来扩展这个内容。 - Peter Cordes
如果您可以避免在指针输入操作数上使用早期破坏约束,那么虚拟内存输入操作数通常会选择使用相同寄存器的简单寻址模式。有没有相关证明链接?我不认为GCC文档中提到了这一点。 - yugr
@yugr:文档不会自己充斥着对当前优化器行为的建议。但事实是,GCC的优化器在对asm语句进行CSE时非常擅长,包括地址模式和“r”输入。在没有早期破坏的情况下,这也适用于“+r”的输入端。简单情况下(尝试使用-O2或3),它会可靠地发生。是的,我一直打算回到这个答案并添加一个例子。很难通过例子证明它在启用优化时几乎所有情况下都会可靠发生,但是从经验上来看确实如此。 - Peter Cordes
1
@PeterCordes 引用讨论此功能的开发者博客或GCC邮件列表中的线程即可。请注意,GCC中的相关代码并不是微不足道的,例如仅在寄存器类被认为“小”(无论特定目标选择什么意思)时才合并寄存器。 - yugr
1
更新:clang不支持未指定大小的数组,例如"m" (*(const char (*)[]) p)(不完整类型的解引用)。不确定是否使用固定的巨大大小,如[PTRDIFF_MAX],是否是一个好主意。 - Peter Cordes
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接