GCC 内联汇编从数组中读取值

GCC inline assembly read value from array

在学习 gcc 内联汇编时,我在玩内存访问。我正在尝试使用来自不同数组的值作为索引从数组中读取值。 两个数组都被初始化为某些东西。

初始化:

uint8_t* index = (uint8_t*)malloc(256);
memset(index, 33, 256);

uint8_t* data = (uint8_t*)malloc(256);
memset(data, 44, 256);

数组访问:

unsigned char read(void *index,void *data) {
        unsigned char value;

        asm __volatile__ (
        "  movzb (%1), %%edx\n"
        "  movzb (%2, %%edx), %%eax\n"
        : "=r" (value)
        : "c" (index), "c" (data)
        : "%eax", "%edx");

        return value;
    }

我是这样使用函数的:

unsigned char value = read(index, data);

现在我希望它是 return 44。但它实际上 return 给我一些随机值。我是从未初始化的内存中读取的吗?此外,我不确定如何告诉编译器它应该将 eax 中的值分配给变量 value

您告诉编译器您要将输出放入 %0,它可以为 "=r" 选择任何寄存器。但是您永远不会在模板中写 %0

当您可以使用 %0 作为临时文件时,您无缘无故地使用了两个临时文件。

像往常一样,您可以通过添加 # 0 = %0 之类的注释并查看编译器的 asm 输出来调试内联 asm。 (不是反汇编,只是 gcc -S 看看它填充了什么。例如 # 0 = %ecx。(你没有使用 early-clobber "=&r" 所以它可以选择相同的寄存器作为输入)。


此外,这还有 2 个其他错误:

  1. 不编译。在具有 "c" 约束的 ECX 中请求 2 个不同的操作数是行不通的,除非编译器可以在编译时证明它们具有相同的值,因此 %1%2 可以是相同的寄存器。 https://godbolt.org/z/LgR4xS

  2. 您取消引用指针输入而不告诉编译器您正在读取指向的内存。使用 "memory" 破坏器或虚拟内存操作数。

或者更好https://gcc.gnu.org/wiki/DontUseInlineAsm 因为它对这个没用;让 GCC 自行发出 movzb 负载。 unsigned char* 不受严格别名 UB 的影响,因此您可以安全地将任何指针投射到 unsigned char* 并取消引用它,甚至不必使用 memcpy 或其他 hack 来对抗更广泛的未对齐或类型双关访问的语言规则。

但如果您坚持使用内联汇编,请阅读手册和教程,链接位于 https://whosebug.com/tags/inline-assembly/info。在代码坚持使用内联 asm 之前,您不能只是将代码扔到墙上:您 必须 理解为什么您的代码是安全的,才能希望它是安全的。有很多方法可以使内联 asm 碰巧工作但实际上被破坏,或者等待与不同的周围代码一起破坏。


这是一个安全且不完全糟糕的版本(除了内联 asm 不可避免的优化失败部分)。您仍然需要为 both 加载一个 movzbl 加载,即使 return 值只有 8 位。 movzbl 是加载字节的自然有效方式,替换而不是合并完整寄存器的旧内容。

unsigned char read(void *index, void *data)
{
    uintptr_t value;
    asm (
        " movzb (%[idx]), %k[out] \n\t"
        " movzb (%[arr], %[out]), %k[out]\n"
        : [out] "=&r" (value)              // early-clobber output
        : [idx] "r" (index), [arr] "r" (data)
        : "memory"  // we deref some inputs as pointers
    );

    return value;
}

注意输出上的早期破坏:这会阻止 gcc 选择相同的输出寄存器作为输入之一。它在第一次加载时销毁 [idx] 寄存器是安全的,但我不知道如何在一个 asm 语句中告诉 GCC。您可以 将您的 asm 语句拆分为两个单独的语句,每个语句都有自己的输入和输出操作数,通过局部变量将第一个的输出连接到第二个的输入。这样一来,谁都不需要 early-clobber,因为它们只是包装单个指令,例如 GNU C 内联 asm 语法设计得很好。

Godbolt with test caller to see how it inlines / optimizes 调用两次时,使用 i386 clang 和 x86-64 gcc。例如在寄存器中请求 index 会强制执行 LEA,而不是让编译器看到 deref 并让它为 *index 选择寻址模式。此外,编译器在添加到 unsigned sum 时完成了额外的 movzbl %al, %eax,因为我们使用了窄 return 类型。

我使用了 uintptr_t value 所以它可以针对 32 位和 64 位 x86 进行编译。 让 asm 语句的输出比return 函数的值,如果它选择 AL 作为 8 位寄存器,这使我们不必使用像 movzbl (%1), %k0 这样的大小修饰符来让 GCC 打印 32 位寄存器名称(如 EAX)输出变量,例如。

为了 64 位模式的好处,我确实决定实际使用 %k[out]:我们想要 movzbl (%rdi), %eax,而不是 movzb (%rdi), %rax(浪费 REX 前缀)。

不过,你也可以将函数声明为returnunsigned intuintptr_t,这样编译器就知道它不必重做零-扩展。 OTOH 有时它可以帮助编译器知道值范围仅为 0..255。你可以告诉它你使用 if(retval>255) __builtin_unreachable() 或其他东西产生了一个正确的零扩展值。或者你可以 不使用内联 asm.

您不需要 asm volatile。 (假设你想让它在结果未使用时优化掉,或者为了常量输入而被提升到循环之外)。您只需要一个 "memory" 破坏器,这样如果它确实被使用,编译器就会知道它读取内存。

(A "memory" clobber 算作所有内存都是输入,所有内存都是输出。所以它不能 CSE,例如提升循环,因为据编译器所知调用可能会读取前一个写的东西。所以在实践中,"memory" 破坏与 asm volatile 一样糟糕。即使是两次连续调用此函数而不触及输入数组也会强制编译器发出两次指令。)

您可以使用虚拟内存输入操作数来避免这种情况,因此编译器知道此 asm 块不会修改 内存,只会读取它。但如果你真的关心效率,你不应该为此使用内联汇编。


但正如我所说,使用内联 asm 的理由为零:

这将在 100% 可移植和安全的 ISO C 中做完全相同的事情:

// safe from strict-aliasing violations 
// because  unsigned char* can alias anything
inline
unsigned char read(void *index, void *data) {
    unsigned idx = *(unsigned char*)index;
    unsigned char * dp = data;
    return dp[idx];
}

如果您坚持每次都进行访问并且没有被优化掉,您可以将一个或两个指针都指向 volatile unsigned char*

或者甚至 atomic<unsigned char> * 取决于您在做什么。 (这是一个 hack,在通常不是原子的对象上更喜欢 C++20 atomic_ref 而不是原子 load/store。)