当前位置: 首页 > 知识库问答 >
问题:

GCC代码似乎破坏了内联程序集规则,但专家认为不是这样

沈龙光
2023-03-14

我与一位专家进行了接触,他据称拥有比我更高的编码技能,他对内联汇编的理解比我更好。

其中一个声明是,只要操作数作为输入约束出现,您就不需要将其列为clobber或指定寄存器已被内联程序集潜在地修改。当其他人试图获得有关memset实现的帮助时,对话发生了,该实现是以以下方式有效编码的:

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

当我在没有告诉编译器的情况下评论这个问题时,专家的说法是告诉我们:

“c”(计数)已经告诉编译器c被重击了

我在专家自己的操作系统中找到了一个例子,他们用相同的设计模式编写类似的代码。它们使用Intel语法进行内联程序集。这个hobby操作系统代码在内核(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;
}
volatile uint32_t *framebuffer;
volatile uint32_t *antibuffer0;
volatile uint32_t *antibuffer1;

int vbe_width = 1024;
int vbe_height = 768;
int vbe_pitch;
    "add rbx, 4;"
    "dec rcx;"

这些寄存器都不列为输入/输出,也不列为输出操作数。我相信需要修改这些约束,以通知编译器这些寄存器可能已经被修改/clobbread了。唯一一个我认为是正确的注册表是Rax。我的理解正确吗?我的感觉是RDI、RSI、RBX和RCX应该是输入/输出约束(使用+修饰符)。即使有人试图辩称64位System V ABI调用约定将保存它们(假设编写此类代码的方式不佳),RBX是一个非易失性寄存器,在此代码中会发生变化。

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

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

如果假设在这里必须使用内联程序集(假设),对于这个函数,无bug的GCC内联程序集代码会是什么样子?这个函数是不是很好,而我只是不像专家那样理解GCC的扩展内联程序集的基础知识?

  • 1swap_vbufs函数和相关变量声明被逐字复制,而没有版权持有者根据合理使用的许可,用于对大量作品的注释。

共有1个答案

韶硕
2023-03-14

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

swap_vbufs看起来甚至不是很高效,我怀疑gcc在纯C版本中会做得同样或者更好。https://gcc.gnu.org/wiki/dontuseinlineasm。stosd在Intel上是3个UOP,比常规的mov-store+add rdi,4差。使添加rdi,4无条件将避免对else块的需要,该块将在(希望)快速路径上放置一个额外的JMP,因为在快速路径上没有MMIO存储到视频RAM中,因为缓冲区是相等的。

(lodsd在Haswell和更新版本上只有2个UOP,所以如果您不关心IvyBridge或更旧版本,这是可以的)。

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

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

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

“r”或任何特定寄存器约束(如“d”)意味着这是一个只读输入,编译器可以期望在之后不受干扰地找到该值。没有“输入我要破坏”的约束;你必须用一个虚拟输出或变量来合成它。

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

摘自GCC手册:ExtendedASM输入操作数:

(RAXclobber会导致使用“a”作为输入出错;clobber和操作数不能重叠。)

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编译器资源管理器上编译

请注意,GCC的addledi用于in,尽管内联asm将该寄存器用作输入。(因此中断,因为这个错误的内联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     
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

如果函数没有内联,也没有在asm语句之后使用输入变量,那么只要它是一个被调用破坏的寄存器,就可以逃过编译器的欺骗。

经常会发现有些人编写了不安全的代码,而这些代码恰好在他们使用的上下文中工作。他们也很少相信,在那种情况下,只需用一个编译器版本/选项来测试它就足以验证它的安全性或正确性。

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

这也是正确的。寄存器输入操作数并不意味着指向的存储器也是输入操作数。在一个不能内联的函数中,这实际上并不能引起问题,但只要您启用链接时优化,跨文件内联和过程间优化就成为可能。

有一个已存在的通知性冲突,即内联程序集读取某个特定的内存区域未回答的问题。这个Godbolt链接展示了一些你可以揭示这个问题的方法,例如。

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

如果gcc假设arr[2]不是asm的输入,而是arr地址本身,它将执行死区消除并删除=1赋值。(或者将其视为使用asm语句重新排序存储,然后将2个存储折叠到相同位置)。

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

对于memset示例,要正确声明指向的内存是输出操作数,请将指针强制转换为指向数组的指针并取消引用,告诉gcc整个内存范围都是操作数。*(char(*)[count])指针。(您可以将[]保留为空,以指定通过此指针访问的任意长度的内存区域。)

// 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),因此它愿意使用一个寄存器,它也是一个输入/输出操作数。

对于不返回指针的void函数(或者在内联到不使用返回值的函数中之后),在让rep stosb销毁指针参数之前,它不必在任何地方复制指针参数。

 类似资料:
  • 我正试图使Filepond工作,但CSS中的这一行似乎破坏了它-在ul选择器中。 我试着对页面的整个部分进行核化,直到Filepond起作用,将目标锁定在css上,最后在ul{}中找到前面提到的行。我可以把其他的东西都抹掉,只留下那条线,而文件孔仍然坏了,所以我肯定这是问题所在,但我不知道是怎么回事。 我尝试使用Chrome的检查器功能查看运行时页面源代码,但在那里找不到溢出。 然后我使用Note

  • 我的单元测试设置如下: 进口似乎是个问题,因为我试图使用桶: 在index.ts文件中,我正在执行以下操作:

  • 这里是一个虚拟的*z++=*x++**y++指令。请注意,x、y和z指针寄存器必须指定为输入/输出,因为asm会修改它们。 在第一个示例中,在输入操作数中列出和有什么意义?同一份文件指出: 特别是,如果不将输入操作数指定为输出操作数,就无法指定输入操作数被修改。

  • 我有一个简单的积垢项目称为过滤器。在这里,每个过滤器都被分配给一个带有外键的类别。我试图做的是循环遍历每个foregin键,以获得类别名称,而不是显示给用户的id。 我首先获取所有过滤器,并执行模具/转储以检查所有结果是否存在。 尝试将类别名称分配给正确的数组项时,出现以下错误: msgstr"间接修改App\Filter的重载元素没有效果" 所以为了检查发生了什么,我在foreach循环中死了/

  • 我正在构建一个ANTLR4语法来解析数据源中的字符串--类似于StringTemplate(如果不是非常相似的话),只是我不喜欢这种语法,所以我正在编写自己的语法(也只是为了好玩和学习,因为这是我第一次使用/ANTLR)。我的语法现在看起来像这样(这是从我的实际情况中简化出来的,但我已经验证了它是一个“好例子”,并且展示了我所问的相同的问题): 这个语法工作得很好,允许我执行替换,例如: 结果是: