ARM:为什么在函数调用时需要推送/弹出两个寄存器?

16

我知道在函数调用开始时需要将链接寄存器压入堆栈,并在返回之前将该值弹出至程序计数器,以使执行能够从函数调用之前的位置继续。

但我不明白为什么大多数人会通过添加额外的寄存器来实现这一点。例如:

push {ip, lr}
...
pop {ip, pc}

例如,这是一段ARM汇编中的Hello World代码,由官方ARM博客提供:
.syntax unified

    @ --------------------------------
    .global main
main:
    @ Stack the return address (lr) in addition to a dummy register (ip) to
    @ keep the stack 8-byte aligned.
    push    {ip, lr}

    @ Load the argument and perform the call. This is like 'printf("...")' in C.
    ldr     r0, =message
    bl      printf

    @ Exit from 'main'. This is like 'return 0' in C.
    mov     r0, #0      @ Return 0.
    @ Pop the dummy ip to reverse our alignment fix, and pop the original lr
    @ value directly into pc — the Program Counter — to return.
    pop     {ip, pc}

    @ --------------------------------
    @ Data for the printf calls. The GNU assembler's ".asciz" directive
    @ automatically adds a NULL character termination.
message:
    .asciz  "Hello, world.\n"

问题1: 为什么要使用所谓的“虚拟寄存器”?为什么不直接使用push{lr}和pop{pc}?他们说这是为了保持堆栈8字节对齐,但堆栈不是4字节对齐吗?

问题2: "ip"寄存器是什么(即r7还是其他什么)?


我链接了一个ARM的博客文章,他们推荐使用这个双寄存器模式。请去看一下,里面有一些代码。 - Daniel Scocco
啊,所以这个链接回答了你的问题。你可以自己发布这个答案并关闭这个问题。 - old_timer
请参考下面Mike的回答,这和64位总线有关,如果保持对齐,即使你来回移动32个以上的位,速度也是相同或更快的,如果不对齐,则需要2到3个额外的内存事务。一个64位对齐的push或pop(2个寄存器)是一个内存事务,一个64位不对齐的push或pop是两个内存事务。一个128位对齐的pop是1个内存事务(长度为2),一个128位不对齐的pop是3个内存事务,1个32位,1个64位和1个32位。期望编译器始终对齐(并希望引导程序也这样做)。 - old_timer
比其他人已经指出的更简单的答案是“因为ARM这样说”。 ARM EABI规定8字节堆栈对齐,因此编译器现在生成代码以维护此对齐(好吧,我至少看到了一个问题)。 - old_timer
这是两个更近期的重复问题:为什么ARM gcc在函数开始时将寄存器r3和lr推入堆栈?使用arm-linux-gnueabi-gcc编译时,为什么堆栈指针向下移动4个字节而不是堆栈帧大小? 我认为这里的答案已经充分覆盖了所有内容。 - Peter Cordes
显示剩余4条评论
3个回答

8

8字节对齐是符合AAPCS标准的对象之间互操作的要求。

ARM在此问题上有一条建议性注释:

ARM® 架构ABI建议注释 - 进入AAPCS兼容函数时SP必须8字节对齐

文章提到了使用8字节对齐的两个原因

  • 对齐故障或不可预测的行为。 (与硬件/架构相关的原因 - LDRD / STRD可能会在除ARMv7之外的架构上导致对齐故障或显示不可预测的行为)

  • 应用程序失败。 (编译器 - 运行时假设差异,他们举了va_startva_arg作为例子)

当然,这都是关于公共接口的,如果您正在创建一个没有额外链接的静态可执行文件,您可以将堆栈对齐到4个字节。


值得一提的是:存储2个寄存器的用例非常普遍,在armv8中,已经取消了pushpop指令,而专门引入了stpldp指令来代替。详情请参考:https://dev59.com/Tobca4cB1Zd3GeqPbuRu - Ciro Santilli OurBigBook.com

7
“Dummy register”是什么原因?为什么不直接使用push{lr}和pop{pc}?他们说这是为了保持栈的8字节对齐,但是栈不是4字节对齐吗?
栈只需要4字节对齐;但是如果数据总线宽度为64位(正如许多现代ARM处理器),将其保持在8字节对齐会更加有效。例如,如果您调用一个需要堆叠两个寄存器的函数,则可以通过单个64位写入而不是两个32位写入来完成。
更新:显然这不仅仅是为了效率;这是官方过程调用标准的要求,如评论中所述。
如果目标是旧的32位ARM,则额外的堆叠寄存器可能会轻微降低性能。
“ip”寄存器是哪个(即r7还是其他)?
是“r12”。例如,请参见过程调用标准使用的完整寄存器别名的此处

2
这个答案是误导性和危险的。8字节对齐是所有EABI兼容代码的要求,不在所有外部边界上保持它可能会导致运行时故障 - 更糟糕的是,在某些处理器上执行某些编译器版本构建时,可能会导致运行时故障。 - unixsmurf
3
只是回应 @unixsmurf 的回答。 AAPCS 的 5.2.1.2 规定了“SP mod 8 = 0。堆栈必须是双字对齐的。”对于公共接口,您真的希望始终遵循这一点,除非您知道自己在做什么。ARM 还有一篇关于8 字节堆栈对齐的知识文章 - John Szakmeister
2
@unixsmurf:抱歉,我的过程调用标准知识有点过时了;我没有意识到现在需要8字节对齐。我想我最好停止回答有关ARM的问题了。我已经更新了答案以反映这一点;希望现在可以接受,但不幸的是,只要答案被接受,我就无法删除它。 - Mike Seymour
很久以前(OABI)曾经不需要这样做。随着ARMv4和strd的出现,有一个要求是将事物对齐到8个字节。https://gcc.gnu.org/bugzilla/show_bug.cgi?id=43518并不总是要求堆栈对齐到8个字节。然而,任何现代系统都会有这个要求。如果您只使用汇编语言编码,则无需遵循ABI(除非系统中的其他内容需要)。将该函数标记为不可跟踪。 - artless noise

4

由于您希望在执行函数后存储并恢复它们,因此需要进行以下操作。 在函数入口处,保存 iplr 寄存器(称为 prolog)。 完成函数后,分配两者(epilog):

pc <- lr

ip <- old_ip

EDIT

寄存器r12也被称为IP,用作过程内调用的临时寄存器,详情请参见此处

约定规定被调用的函数可以更改ip、r0-r3,因此您必须根据调用约定恢复它们。

EDIT2: 为什么我们可能希望在ARM上将堆栈对齐到8个字节

如果堆栈不是八字节对齐的,则使用LDRD和STRD(加载和存储双字)可能会引起对齐故障,具体取决于目标和配置。

请注意,X86上也有相同的问题,在Mac OS上有16字节对齐


我知道它可以这样做。我的问题是为什么大多数人在push/pop时使用两个寄存器。为什么不简单地使用push {lr}和pop {pc}呢? - Daniel Scocco
由于该语言允许您推送{寄存器列表},并且是一条汇编指令,假设您想要存储r0-r15,则可以在32位代码长度或15 * 32位代码长度中执行,哪个更好?http://en.wikipedia.org/wiki/KISS_principle - 0x90
你没有理解我的问题。我重新编辑了它,请查看一下。 - Daniel Scocco
7
ARM EABI规范要求堆栈保持64位对齐,否则无法在堆栈上使用ldr/strd。此外,我所见过的大多数实现都能够在相同的时间内执行64位宽度的内存访问,如果地址是64位对齐的话。在这种情况下添加ip(或任何其他寄存器)只是为了避免代码显式进行对齐(通过add和sub)。如果代码仅push/pop lr/pc,则printf的堆栈将不再对齐,并且调用ldr时可能会崩溃。 - Nico Erfurth
2
@Masta79:为什么不把你的评论作为答案添加呢?这是正确的解释,现有的解释都不完整。 - unixsmurf
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接