【问题标题】:Why is this inline assembly not working with a separate asm volatile statement for each instruction?为什么这个内联汇编不能为每条指令使用单独的 asm volatile 语句?
【发布时间】:2012-02-12 01:13:37
【问题描述】:

对于以下代码:

long buf[64];

register long rrax asm ("rax");
register long rrbx asm ("rbx");
register long rrsi asm ("rsi");

rrax = 0x34;
rrbx = 0x39;

__asm__ __volatile__ ("movq $buf,%rsi");
__asm__ __volatile__ ("movq %rax, 0(%rsi);");
__asm__ __volatile__ ("movq %rbx, 8(%rsi);");

printf( "buf[0] = %lx, buf[1] = %lx!\n", buf[0], buf[1] );

我得到以下输出:

buf[0] = 0, buf[1] = 346161cbc0!

应该是这样的:

buf[0] = 34, buf[1] = 39!

任何想法为什么它不能正常工作,以及如何解决它?

【问题讨论】:

  • 为什么不使用 gdb 单步执行代码以便您可以查看发生了什么?

标签: c linux gcc assembly x86-64


【解决方案1】:

你破坏了内存但不告诉 GCC,所以 GCC 可以在程序集调用中缓存 buf 中的值。如果您想使用输入和输出,请告诉 GCC。

__asm__ (
    "movq %1, 0(%0)\n\t"
    "movq %2, 8(%0)"
    :                                /* Outputs (none) */
    : "r"(buf), "r"(rrax), "r"(rrbx) /* Inputs */
    : "memory");                     /* Clobbered */

您通常还希望让 GCC 处理大部分 mov、寄存器选择等——即使您明确限制寄存器(rrax 仍然是 %rax)让信息通过 GCC 流动,否则您会得到意外结果。

__volatile__ 错了。

__volatile__ 存在的原因是您可以保证编译器将您的代码准确地放置在它所在的位置...对于此代码,这是一个完全没有必要的保证。这对于实现内存屏障等高级功能是必要的,但如果您只是修改内存和寄存器,则几乎完全没有价值。

GCC 已经知道它不能在printf 之后移动这个程序集,因为printf 调用访问buf,而buf 可能会被程序集破坏。 GCC 已经知道它不能在rrax=0x39; 之前移动程序集,因为rax 是程序集代码的输入。那么__volatile__ 能带给你什么?什么都没有。

如果您的代码在没有__volatile__ 的情况下无法运行,那么代码中有一个错误应该修复,而不是仅仅添加__volatile__ 并希望这会使一切变得更好。 __volatile__ 关键字不是魔法,不应被视为魔法。

替代修复:

您的原始代码是否需要__volatile__?不需要。只需正确标记输入和破坏值即可。

/* The "S" constraint means %rsi, "b" means %rbx, and "a" means %rax
   The inputs and clobbered values are specified.  There is no output
   so that section is blank.  */
rsi = (long) buf;
__asm__ ("movq %%rax, 0(%%rsi)" : : "a"(rrax), "S"(rssi) : "memory");
__asm__ ("movq %%rbx, 0(%%rsi)" : : "b"(rrbx), "S"(rrsi) : "memory");

为什么__volatile__ 在这里对你没有帮助:

rrax = 0x34; /* Dead code */

GCC 完全有权完全删除上述行,因为上述问题中的代码声称它从不使用 rrax

一个更清晰的例子

long global;
void store_5(void)
{
    register long rax asm ("rax");
    rax = 5;
    __asm__ __volatile__ ("movq %%rax, (global)");
}

反汇编在-O0处或多或少如您所愿,

movl $5, %rax
movq %rax, (global)

但如果关闭优化,您可能会在组装方面相当草率。让我们试试-O2

movq %rax, (global)

哎呀! rax = 5;去哪儿了?这是死代码,因为函数中从未使用过%rax——至少就 GCC 所知。 GCC 不会窥视程序集的内部。当我们删除 __volatile__ 时会发生什么?

; empty

好吧,您可能认为__volatile__ 是在为您提供服务,让 GCC 不会丢弃您宝贵的程序集,但这只是掩盖了 GCC 认为您的程序集没有任何事情的事实。 GCC 认为你的程序集不接受任何输入,不产生输出,并且不破坏内存。你最好把它理顺:

long global;
void store_5(void)
{
    register long rax asm ("rax");
    rax = 5;
    __asm__ __volatile__ ("movq %%rax, (global)" : : : "memory");
}

现在我们得到以下输出:

movq %rax, (global)

更好。但是如果你告诉 GCC 输入,它将确保 %rax 首先正确初始化:

long global;
void store_5(void)
{
    register long rax asm ("rax");
    rax = 5;
    __asm__ ("movq %%rax, (global)" : : "a"(rax) : "memory");
}

经过优化的输出:

movl $5, %eax
movq %rax, (global)

正确!而且我们甚至不需要使用__volatile__

为什么__volatile__ 存在?

__volatile__ 的主要正确用途是,如果您的汇编代码除了输入、输出或破坏内存之外还执行其他操作。也许它与 GCC 不知道或影响 IO 的特殊寄存器混淆。您在 Linux 内核中经常看到它,但在用户空间中却经常被滥用。

__volatile__ 关键字非常诱人,因为我们 C 程序员经常喜欢认为我们已经几乎在使用汇编语言进行编程。不是。 C 编译器会进行大量数据流分析——因此您需要向编译器解释汇编代码的数据流。这样,编译器就可以安全地操作您的程序集块,就像它操作它生成的程序集一样。

如果您发现自己经常使用__volatile__,作为替代方案,您可以在汇编文件中编写整个函数或模块。

【讨论】:

  • asm 中的 volatile 是告诉编译器将代码准确地放置在它放置的位置。它不像 volatile 的变量。
  • @MetallicPriest:是的,这正是 volatile 的用途,这就是为什么这里没有必要。如果你不明白这一点,那么请阅读 GCC 内联汇编 HOWTO 从头到尾,因为跳过块没有帮助。
  • @FrankH.: 不,程序集如原始海报所述不需要__volatile__,它只需要标记正确的输入/输出操作数。 __volatile__ 关键字无法解决单处理器数据流问题。您不应该使用 __volatile__ 来替换正确的输入/输出操作数。
  • @FrankH.:我已经用一种“演练”更新了答案,说明为什么 __volatile__ 从一开始就没有必要。
  • @Dietrich Epp:我同意解决方案是指定适当/充分的约束。 __volatile__ 内联汇编是“你有一把锤子,螺丝变成钉子”的东西之一。它做它所说的而不是你可能的意思;-)
【解决方案2】:

编译器使用寄存器,它可能会覆盖您放入其中的值。

在这种情况下,编译器可能在rrbx 赋值之后和内联汇编部分之前使用rbx 寄存器。

一般来说,您不应该期望寄存器在内联汇编代码序列之后和之间保持它们的值。

【讨论】:

  • 但我在 printf 之前使用 rsi 作为指向 buf 的指针。没关系,如果 printf 使用它。 buf[0] 和 buf[1] 应该有正确的值,不是吗?即使我从 printf 中删除 rrsi,它仍然会打印相同的错误值。
  • @ugoren:如果您让 GCC 使用 asm 关键字分配寄存器,它将正确溢出并重新加载它们,以便在函数调用之间保存它们。
  • @DietrichEpp,编译器负责保存它使用的寄存器。如果内联汇编更改了寄存器,它对此一无所知,也不会注意保存它们。
  • @ugoren:如果程序集更改了寄存器,那么您有责任将这些寄存器放入内联程序集的损坏部分。 GCC 正确地溢出__asm__ 语句。没有这个,你将永远无法真正使用__asm__
  • @ugoren:是的,这正是我给出 clobber 很重要的原因(因此 GCC 可以将寄存器溢出 __asm__)。我并没有说 clobber 会阻止 C 破坏 asm 使用的寄存器。为此,您需要使用输入和输出操作数。
【解决方案3】:

有点题外话,但我想跟进一下 gcc 内联汇编。

__volatile__ 的(非)需求来自 GCC 优化 内联汇编这一事实。 GCC 检查汇编语句的副作用/先决条件,如果发现它们不存在,它可能会选择移动汇编指令,甚至决定删除它。 __volatile__ 所做的只是告诉编译器“停止关心并把它放在那里”。

这通常不是你真正想要的。

这就是需要约束的地方。名称被重载,实际上用于GCC内联汇编中的不同事物:

  • 约束指定asm() 块中使用的输入/输出操作数
  • 约束指定“clobber 列表”,其中详细说明了受asm() 影响的“状态”(寄存器、条件代码、内存)。
  • 约束指定操作数的类别(寄存器、地址、偏移量、常量……)
  • 约束声明汇编程序实体与 C/C++ 变量/表达式之间的关联/绑定

在许多情况下,开发人员滥用 __volatile__,因为他们注意到他们的代码要么被移动,要么在没有它的情况下消失。如果发生这种情况,这通常表明开发人员试图告诉 GCC 关于程序集的副作用/先决条件。例如,这个错误代码:

register int foo __asm__("rax") = 1234;
register int bar __adm__("rbx") = 4321;

asm("add %rax, %rbx");
printf("I'm expecting 'bar' to be 5555 it is: %d\n", bar);

它有几个错误:

  • 首先,它仅因 gcc 错误 (!) 而编译。通常,要在内联汇编中写入寄存器名称,需要双精度 %%,但在上面,如果您实际指定它们,则会出现编译器/汇编器错误,/tmp/ccYPmr3g.s:22: Error: bad register name '%%rax'
  • 其次,它不会告诉编译器您何时何地需要/使用这些变量。相反,它假定编译器从字面上尊重asm()。对于 Microsoft Visual C++ 可能是这样,但对于 gcc,情况并非如此

如果你编译它没有优化,它会创建:

0000000000400524 
: [ ... ] 400534: b8 d2 04 00 00 移动 $0x4d2,%eax 400539: bb e1 10 00 00 移动 $0x10e1,%ebx 40053e: 48 01 c3 添加 %rax,%rbx 400541: 48 89 大移动 %rbx,%rdx 400544: b8 5c 06 40 00 移动 $0x40065c,%eax 400549: 48 89 d6 移动 %rdx,%rsi 40054c: 48 89 c7 移动 %rax,%rdi 40054f: b8 00 00 00 00 移动 $0x0,%eax 400554:e8 d7 fe ff ff callq 400430 [...]
你可以找到你的add 指令,以及两个寄存器的初始化,它会打印出预期的结果。另一方面,如果您加速优化,则会发生其他事情:
0000000000400530 
: 400530: 48 83 ec 08 sub $0x8,%rsp 400534: 48 01 c3 添加 %rax,%rbx 400537: 是 e1 10 00 00 移动 $0x10e1,%esi 40053c: bf 3c 06 40 00 移动 $0x40063c,%edi 400541: 31 c0 xor %eax,%eax 400543:e8 e8 fe ff ff callq 400430 [ ... ]
您对两个“已使用”寄存器的初始化不再存在。编译器丢弃了它们,因为它看不到任何东西在使用它们,并且在保留汇编指令的同时,它把它放在之前对这两个变量的任何使用。它就在那里,但它什么也没做(幸运的是……如果rax / rbx 一直在使用,谁知道会发生什么……)。

原因是您实际上并没有告诉 GCC 程序集正在使用这些寄存器/这些操作数值。 这与volatile 但事实上您使用的是无约束的 asm() 表达式。

正确做到这一点的方法是通过约束,即你会使用:

int foo = 1234;
int bar = 4321;

asm("add %1, %0" : "+r"(bar) : "r"(foo));
printf("I'm expecting 'bar' to be 5555 it is: %d\n", bar);

这告诉编译器程序集:

  1. 在寄存器中有一个参数"+r"(...),这两个参数都需要在汇编语句之前初始化,并被汇编语句修改,并将变量bar与之关联。
  2. 在寄存器中有第二个参数"r"(...),需要在汇编语句之前初始化,并被视为只读/不被语句修改。在这里,将 foo 与此关联。

注意没有指定寄存器分配 - 编译器根据编译的变量/状态来选择。以上的(优化的)输出:

0000000000400530 
: 400530: 48 83 ec 08 sub $0x8,%rsp 400534: b8 d2 04 00 00 移动 $0x4d2,%eax 400539: 是 e1 10 00 00 移动 $0x10e1,%esi 40053e: bf 4c 06 40 00 移动 $0x40064c,%edi 400543: 01 c6 添加 %eax,%esi 400545: 31 c0 xor %eax,%eax 400547:e8 e4 fe ff ff callq 400430 [ ... ]
GCC 内联汇编约束几乎总是需要以某种形式或其他形式,但可以有多种可能的方式向编译器描述相同的要求;而不是上面的,你也可以写:
asm("add %1, %0" : "=r"(bar) : "r"(foo), "0"(bar));

这告诉 gcc:

  1. 语句有一个输出操作数,变量bar,在语句之后将在寄存器中找到,"=r"(...)
  2. 语句有一个输入操作数,变量foo,将被放入寄存器"r"(...)
  3. 操作数零也是一个输入操作数,用bar初始化

或者,又是一个替代方案:

asm("add %1, %0" : "+r"(bar) : "g"(foo));

告诉gcc:

  1. bla(打哈欠 - 和以前一样,bar 输入/输出)
  2. 该语句有一个输入操作数,变量foo,该语句不关心它是在寄存器中、内存中还是编译时常量(即"g"(...) 约束)

结果与前者不同:

0000000000400530 
: 400530: 48 83 ec 08 sub $0x8,%rsp 400534: bf 4c 06 40 00 移动 $0x40064c,%edi 400539: 31 c0 xor %eax,%eax 40053b: 是 e1 10 00 00 移动 $0x10e1,%esi 400540: 81 c6 d2 04 00 00 添加 $0x4d2,%esi 400546:e8 e5 fe ff ff callq 400430 [ ... ]
因为现在,GCC 实际上已经弄清楚了 foo 是一个编译时常量,并简单地将值嵌入 add 说明!那不是很整洁吗?

诚然,这很复杂,需要习惯。优点是让编译器选择哪些寄存器用于哪些操作数允许整体优化代码;例如,如果在宏和/或static inline 函数中使用内联汇编语句,则编译器可以根据调用上下文在代码的不同实例中选择不同的寄存器。或者,如果某个值在一个地方是编译时可评估/常量,但在另一个地方不是,编译器可以为它定制创建的程序集。

将 GCC 内联汇编约束视为一种“扩展函数原型”——它们告诉编译器参数/返回值的类型和位置,以及更多信息。如果您不指定这些约束,则您的内联汇编正在创建仅对全局变量/状态进行操作的函数的模拟——正如我们可能都同意的那样,这些函数很少会完全按照您的意图进行。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2014-06-19
    • 2011-04-17
    • 1970-01-01
    • 2010-09-21
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多