提问人:tanvi 提问时间:12/18/2016 最后编辑:Peter Cordestanvi 更新时间:9/12/2021 访问量:4739
如果汇编程序中的 CALLed 代码块中没有 return 语句,该怎么办
What if there is no return statement in a CALLed block of code in assembly programs
问:
如果我说“呼叫”而不是跳转会怎样?由于没有编写 return 语句,控制权是直接传递到下面的下一行,还是在调用后仍然返回到该行?
start:
mov $0, %eax
jmp two
one:
mov $1, %eax
two:
cmp %eax, $1
call one
mov $10, %eax
答:
您的直觉是正确的:在函数返回后,控件会传递到下面的下一行。
在你的例子中,之后,你的函数将跳到,然后继续向下,并最终进入一个无限循环,就像你再次一样。call one
mov $1, %eax
cmp %eax, $1
call one
除了无限循环之外,您的函数最终将超越其内存限制,因为命令将当前(指令指针)写入堆栈。最终,您将溢出堆栈。call
rip
CPU 始终在内存中执行下一条指令,除非分支指令将执行发送到其他地方。
标签没有宽度,也不对执行有任何影响。他们只是允许您从其他地方引用此地址。执行只是通过标签,如果你不避免这一点,甚至在代码的末尾。
如果您熟悉 C 或其他语言,例如,您用来标记地点的标签可以与 asm 标签完全相同,并且/与 or 完全相同。但是 asm 没有特殊的函数语法;你必须自己实现这个高级概念。goto
goto
jmp
jcc
goto
if(EFLAGS_condition) goto
如果你省略了代码块的末尾,执行会继续执行,并将接下来的任何内容解码为指令。(如果系统执行文件的一部分是零填充的,会发生什么?如果这是 ASM 源文件中的最后一个函数,或者执行可能落入最终返回的某个 CRT 启动函数。ret
(在这种情况下,你可以说你所说的块不是一个函数,只是一个函数的一部分,除非它是一个错误和一个 or 是有意的。ret
jmp
您可以(也许应该)在调试器中自己尝试此操作。单步完成该代码,观察 RSP 和 RIP 的变化。asm 的好处是 CPU 的总状态(不包括内存内容)不是很大,因此可以在调试器窗口中观察整个架构状态。(好吧,至少是与用户空间整数代码相关的有趣部分,因此排除了只有操作系统可以调整的特定于模型的寄存器,并排除了 FPU 和矢量寄存器。
call
和 ret
并不“特殊”(即 CPU 不会“记住”它在“函数”中)。
它们只是完全按照手册所说的去做,由你来正确使用它们来实现函数调用和返回。(例如,确保堆栈指针在运行时指向返回地址。你也要确保调用约定正确,以及所有这些东西。(请参阅 x86 标记 wiki。ret
你去的标签和你去的标签也没有什么特别之处。汇编程序只是将字节组装到输出文件中,并记住标签标记的放置位置。它并不像 C 编译器那样真正“了解”函数。您可以将标签放在任何您想要的位置,并且不会影响机器代码字节。jmp
call
使用该指令将告诉汇编程序在符号表中放置一个条目,以便链接器可以看到它。这样一来,你就可以定义一个标签,这个标签可以从其他文件使用,甚至可以从C语言调用。但这只是目标文件中的元数据,仍然没有在指令之间放置任何内容。.globl one
标签只是 asm 中可用于实现“函数”(又名过程或子例程)的高级概念的机制的一部分:调用者调用的标签,以及最终会以一种或另一种方式跳回调用者传递的返回地址的代码。但并非每个标签都是函数的开始。有些只是循环的顶部,或者是函数中条件分支的其他目标。
如果您模拟调用
,则使用等效的返回地址推送
,然后是 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 代码中起作用,因为绝对返回地址被编码到推送 imm32
指令中。在具有备用寄存器的 64 位代码中,您可以使用 RIP 相对将返回地址输入寄存器,并在跳转之前推送该地址。lea
另请注意,虽然从架构上讲,CPU 不会“记住”过去的 CALL 指令,但通过假设 call/ret 对将被匹配,并使用返回地址预测器来避免对 ret 的错误预测,实际实现运行得更快。
为什么RET很难预测?因为它是间接跳转到存储在内存中的地址!它等价于 / ,因此,如果您有一个备用寄存器来 clobber,则可以以这种方式模拟它(例如,rcx 在大多数调用约定中不保留调用,并且不用于返回值)。或者,如果您有一个红色区域,因此堆栈指针下方的值仍然可以避免被异步破坏(通过信号处理程序或其他方式),则可以 / .pop %internal_tmp
jmp *%internal_tmp
add $8, %rsp
jmp *-8(%rsp)
显然,对于实际使用,您应该使用 ,因为这是最有效的方法。我只是想使用多个更简单的指令指出它的作用。仅此而已。ret
请注意,函数可以以 tail-call 而不是 ret
:
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
末尾将返回给 的调用方。 可以使用此优化,因为它不需要对返回值进行任何修改或执行任何其他清理。ret
ext_func
foo
foo
在 SystemV x86-64 调用约定中,第一个整数 arg 位于 中。所以这个函数用 a+a 替换它,然后跳到 的开头。在进入 时,一切都处于正确的状态,就像某些东西运行了一样。堆栈指针指向返回地址,而参数位于它们应该在的位置。edi
ext_func
ext_func
call ext_func
尾调用优化可以在寄存器参数调用约定中比在堆栈上传递参数的 32 位调用约定更频繁地完成。你经常会遇到这样的情况,因为你要尾随调用的函数比当前函数需要更多的参数,所以没有空间将我们自己的参数重写为函数的参数。(编译器不倾向于创建修改自己的参数的代码,尽管 ABI 非常清楚函数拥有保存其参数的堆栈空间,并且可以根据需要对其进行破坏。
在调用约定中,被调用方清理堆栈(在返回地址后再弹出 8 个字节),您只能尾随调用采用完全相同数量的 arg 字节的函数。ret 8
评论
call $+5; pop eax
call +0
pop eax
评论
call one
mov $1, eax
cmp %eax, $1
call one
ret
ret
mov $1, eax
one
mov $10, %eax