【问题标题】:What if there is no return statement in a CALLed block of code in assembly programs如果在汇编程序的 CALLed 代码块中没有 return 语句怎么办
【发布时间】:2020-08-18 11:07:57
【问题描述】:

如果我说“呼叫”而不是跳转会发生什么?由于没有写return语句,控制权只是传递到下面的下一行,还是在调用后仍然返回到该行?

start:
     mov $0, %eax
     jmp two
one:
     mov $1, %eax
two:
     cmp %eax, $1
     call one
     mov $10, %eax

【问题讨论】:

标签: assembly x86


【解决方案1】:

CPU 总是执行内存中的下一条指令,除非分支指令将执行发送到其他地方。

标签没有宽度,对执行没有任何影响。它们只是允许您从其他地方引用此地址。 执行只是通过标签,如果你不避免的话,甚至在你的代码末尾。

如果您熟悉 C 或其他具有 goto (example) 的语言,则用于标记位置的标签可以 goto 与 asm 标签完全相同,jmp / jcc 的工作方式与 gotoif(EFLAGS_condition) goto 完全相同。但是 asm 没有函数的特殊语法;你必须自己实现这个高级概念。

如果您在代码块末尾省略ret,则执行将继续执行并将接下来的任何内容解码为指令。 (如果这是 asm 源文件中的最后一个函数,则可能是 What would happen if a system executes a part of the file that is zero-padded?,或者执行落入最终返回的某个 CRT 启动函数。)

(在这种情况下,您可以说您正在谈论的块不是一个函数,只是一个函数的一部分,除非它是一个错误并且 retjmp 是打算。)


您可以(也许应该)在调试器中自己尝试一下。单步执行该代码并观察 RSP 和 RIP 变化。 asm 的好处是 CPU 的总状态(不包括内存内容)不是很大,因此可以在调试器窗口中查看整个架构状态。 (好吧,至少是与用户空间整数代码相关的有趣部分,因此不包括只有操作系统可以调整的特定于模型的寄存器,并且不包括 FPU 和向量寄存器。)


callret 不是“特殊”(即 CPU 不会“记住”它位于“函数”中)。

它们只是按照手册中的说明进行操作,您可以正确使用它们来实现函数调用和返回。 (例如,确保在 ret 运行时堆栈指针指向返回地址。)还取决于您是否让调用约定正确,以及所有这些东西。 (请参阅 标签 wiki。)

jmp 的标签与您call 的标签也没有什么特别之处。汇编器只是将字节汇编到输出文件中,并记住您放置标签标记的位置。它并不像 C 编译器那样真正“了解”函数。您可以将标签放在您想要的任何位置,并且不会影响机器码字节。

使用.globl one 指令会告诉汇编器在符号表中放置一个条目,以便链接器可以看到它。这将允许您定义一个可从其他文件使用的标签,甚至可从 C 调用。但这只是目标文件中的元数据,仍然没有在指令之间放置任何内容。

标签只是您可以在 asm 中用于实现“函数”(也称为过程或子例程)的高级概念的机制的一部分:标签供调用者调用,以及最终以一种或另一种方式跳转回调用者传递的返回地址的代码。但并非每个标签都是函数的开始。有些只是循环的顶部,或函数内条件分支的其他目标。


如果您模拟call 与返回地址的等效push 然后是jmp,您的代码将以完全相同的方式运行。

one:
     mov   $1, %eax
     # missing ret  so we fall through
two:
     cmp   %eax, $1
     # call one               # emulate it instead with push+jmp
     pushl  $.Lreturn_address
     jmp   one
.Lreturn_address:
     mov   $10, %eax
     # fall off into whatever comes next, if it ever reaches here.

请注意,此序列仅适用于非 PIC 代码,因为绝对返回地址已编码到 push imm32 指令中。在具有可用备用寄存器的 64 位代码中,您可以使用 RIP-relative lea 将返回地址放入寄存器并在跳转前将其推送。


另请注意,虽然 CPU 在架构上不会“记住”过去的 CALL 指令,但通过假设 call/ret 对将匹配,以及 use a return-address predictor 以避免对 ret 的错误预测,实际实现运行得更快。

为什么 RET 很难预测?因为它是间接跳转到存储在内存中的地址!它相当于pop %internal_tmp / jmp *%internal_tmp,所以如果你有一个备用寄存器来破坏它,你可以用这种方式模拟它(例如,在大多数调用约定中,rcx 不保留调用,也不用于返回值)。或者,如果您有一个红色区域,因此堆栈指针下方的值仍然可以安全地免受异步破坏(通过信号处理程序或其他方式),您可以 add $8, %rsp / jmp *-8(%rsp)

显然,对于实际使用,您应该只使用ret,因为这是最有效的方法。我只是想指出它使用多个更简单的指令做了什么。不多也不少。


请注意,函数可以以尾调用而不是ret结束:

(see this on Godbolt)

int ext_func(int a);  // something that the optimizer can't inline

int foo(int a) {
  return ext_func(a+a);
}
# asm output from clang:

foo:
    add     edi, edi
    jmp     ext_func                # TAILCALL

ext_func 末尾的ret 将返回到foo调用者foo 可以使用此优化,因为它不需要对返回值进行任何修改或进行任何其他清理。

在 SystemV x86-64 调用约定中,第一个整数 arg 位于 edi 中。所以这个函数用 a+a 替换它,然后跳转到 ext_func 的开头。在进入ext_func 时,一切都处于正确状态,就像运行call ext_func 时一样。堆栈指针指向返回地址,而 args 是它们应该在的位置。

尾部调用优化可以在 register-args 调用约定中比在堆栈上传递 args 的 32 位调用约定中更频繁地完成。你经常会遇到问题,因为你想要尾调用的函数比当前函数需要更多的参数,所以没有空间将我们自己的参数重写为函数的参数。 (并且编译器不倾向于创建修改其自己的参数的代码,即使 ABI 非常清楚函数拥有保存其参数的堆栈空间并且可以根据需要破坏它。)

在被调用者清理堆栈的调用约定中(使用ret 8 或在返回地址后弹出另外 8 个字节的东西),您只能尾调用一个采用完全相同数量的 arg 字节的函数。

【讨论】:

  • 相反(我认为)是使 x86 代码地址独立的构造:call $+5; pop eax。这是一个“什么都不做”的调用,即它也永远不会返回,因为你偷了它的返回地址。恰好在您的自己的地址附近(加或减几个字节)。
  • @Cody:感谢您对此和其他一些最新答案的清理编辑:)
  • 是的,call +0 / pop eax 是 clang 用于 32 位 PIC 代码的。 64 位代码可以只使用 RIP-relative LEA。您会期望不匹配的调用(没有 ret)会弄乱现代 CPU 中的返回地址预测器,但它们是 rel32=0 的特殊情况。 blog.stuffedcow.net/2018/04/ras-microbenchmarks/#call0 / Reading program counter directly。 (删除了我之前假设这会是一个问题的回复。)
【解决方案2】:

你的直觉是正确的:在函数返回后,控件只是传递到下面的下一行。

在您的情况下,在 call one 之后,您的函数将跳转到 mov $1, %eax,然后继续向下到 cmp %eax, $1 并最终进入无限循环,就像您将再次 call one 一样。

除了无限循环之外,您的函数最终将超出其内存限制,因为call 命令将当前rip(指令指针)写入堆栈。最终,你会溢出堆栈。

【讨论】:

    猜你喜欢
    • 2021-05-02
    • 1970-01-01
    • 2011-07-12
    • 2014-12-15
    • 2019-06-02
    • 1970-01-01
    • 2019-09-17
    • 1970-01-01
    相关资源
    最近更新 更多