"对齐堆栈"在汇编语言中是什么意思?

4

在ASMx64中,栈对齐是如何工作的?在函数调用之前何时需要对齐堆栈,需要减去多少字节?

我不太理解它的目的。我知道有其他关于这个问题的帖子,但对我来说不够清晰。例如:

extern foo
global bar

section .text
bar:
  ;some code...
  sub  rsp, 8     ; Why 8 (I saw this on some posts) ? Can it be another value ? Why do we need to substract?
  call foo        ; Do we need to align stack everytime we call a function?
  add  rsp, 8
  ;some code...
  ret

2
也许你应该先学习一下“内存对齐”。 - Scott Hunter
1
也许你应该更详细地解释一下你读到了什么,以及哪些部分不太清楚。否则,你很可能只会得到重复的关闭投票,或者人们会重新写一些你已经读过的东西。 - Nate Eldredge
5
AMD64系统V ABI(以及Microsoft的64位ABI)要求这样的对齐方式。在调用符合ABI要求的函数时,栈应该对齐到16字节边界。当第一个指令进入函数时,由于call指令将返回地址压入栈中,栈会被错位8字节。为了使栈重新对齐到16字节边界,可以通过从RSP(栈指针)减去8或将一个64位寄存器推入栈中来实现。 - Michael Petch
@MichaelPetch 谢谢,所以“堆栈对齐”是指 rsp 的地址是 16 的倍数? - Fayeure
1
是的,如果 RSP 中的值可以被 16 整除,则堆栈在 16 字节边界上对齐。 - Michael Petch
3个回答

8

地址通常是以字节为单位。唯一的地址指向一个字节(可以是一个字或双字中的第一个字节等,但与该地址相关联)。

在任何进位制中,最不重要的数字保持基数的幂为0的值(即1)。接下来是基数的幂为1,再下一个是基数的幂为2。在十进制中,这是个位数、十位数和百位数。在二进制中是个位、二位、四位...。对齐意味着被均匀地分成几部分,也就是最不重要的数字是零。

你总是"对齐"在一个字节边界上,但在二进制中,16位对齐意味着最不重要的位是零,32位对齐需要两个零,以此类推。

0x1234 在16位和32位边界上都对齐,但不在64位边界上。
0x1235 不对齐(字节对齐实际上并不存在)。
0x1236 在16位边界上对齐。
0x1230 四个零,所以是16、32、64、128位而不是字节。2、4、8、16字节。

之所以要这样做,主要是出于性能方面的考虑,所有存储器都有固定的宽度以及数据总线,一旦实现了逻辑,你就不能神奇地添加或删除电线,因为存在着物理上的限制。你可以选择不使用其中的一些作为设计的一部分,但不能添加任何东西。

因此,尽管x86总线更宽,假设你有一个32位宽的数据总线以及一个32位宽的存储器(考虑缓存,但通常我们不直接访问DRAM),如果我想要将16位0xAABB保存到小端机器的地址0x1001,那么0x1001将获得0xBB,0x1002将获得0xAA。如果我在总线上设计了这个功能,那么如果在远端有一个32位数据总线和一个32位存储器,我可以通过向地址0x1000写入0xXXAABBXX并设置字节掩码为0b0110来移动这些16位,告诉内存控制器使用与基于字节的地址0x1000相关联的32位存储器,总线上的字节掩码告诉控制器只保存中间的两个字节,外面的两个字节是无关紧要的。

存储器通常是固定宽度的,所以所有事务都必须是全宽度的,它会读取32位,将其中间的16位修改为0xAABB,然后将32位写回。这当然是低效的。更糟糕的是,将0xAABB写入0x1003将需要两个总线事务,一个是地址0x1000处的0xBBXXXXXX,另一个是地址0x1004处的0xXXXXXXAA。这将在总线和内存上产生大量额外的周期。

现在,堆栈对齐规则不会阻止写操作中的读-修改-写。 对于大块数据传输,例如如果总线是32位,内存也是32位,并且您将64位数据传输到地址0x1000,则可以根据总线设计将其视为长度为2的单个传输。 总线握手发生,然后两个相邻的时钟数据移动,而不是对于较小的数据传输进行握手和总线数据宽度为一次写入两次。 因此,如果内存宽度为32位,则是两个写入,而不是带有读-修改-写的SRAM缓存。要避免读-修改-写。
现在,随着事物的发展和硬件和工具的需求,需要堆栈对齐。
根据指令集,显然这里问的是x86,但作为程序员,您有时可以选择将一个字节推送到堆栈上,然后调整它以使其对齐。 或者,如果您要为本地变量腾出空间,则可以根据指令集(如果堆栈指针足够通用,可以在其上进行数学计算),只需减去sub sp,#8,即推送两个32位项到堆栈上,仅为了腾出两个32位项的空间。
如果规则是32位对齐,而您推送了一个字节,则需要将堆栈指针调整3以使堆栈指针的总变化为4字节(32位)的倍数。
如何知道需要多少,请简单地计数。 如果它是16字节对齐,并且您推送了4个,则需要再推送12个或将堆栈指针进一步调整12个。
关键在于,如果所有人都同意保持堆栈对齐,则实际上无需查看堆栈指针的较低位,您只需在调用其他内容之前跟踪推送和弹出的内容即可。
如果堆栈与中断处理程序共享(当前的x86运行操作系统可能不会,但在许多其他通用处理器的用例中仍然可能),我没有看到该规则适用于其中,因为编译器会执行小于对齐大小的推送或弹出,然后使用其他推送或弹出或减法或加法进行调整。 如果在它们之间发生中断,则处理程序将看到不对齐的堆栈。
某些体系结构会在未对齐访问时出错,这是保持堆栈对齐的另一个原因。
如果您的代码不涉及堆栈,则不需要涉及堆栈(指针)。 只有在代码中使用堆栈通过在堆栈上分配空间(推送或堆栈指针上的数学运算)时,您需要关心并了解链接此代码的编译器的约定,并符合该约定。 如果这全部是汇编语言而没有编译器,则自己决定约定,并在处理器本身的限制范围内进行任何操作。
从你的标题问题来看,这与汇编语言或机器码没有任何关系。它涉及到你的代码和它的功能。汇编语言只是一种传达调整堆栈指针所需的数量的语言,指令并不知道或关心任何这样的事情,它使用提供的常量对寄存器进行操作。汇编语言是为数不多,如果不是唯一允许你对堆栈指针寄存器进行数学运算的语言,所以有这个联系。但是对齐和汇编语言之间没有关联。

8
在函数调用时需要对齐堆栈,如果被调用的函数期望对齐堆栈,则需要进行对齐。其他语言编写的函数(例如C语言)以及设计成从其他语言调用的汇编语言编写的函数都将符合某种调用约定(包括远不止对齐堆栈这一项——如何传递参数、参数在哪里、"红区"等),当使用64位80x86时,两种常见的呼叫约定都要求堆栈对齐到16字节边界。
在“纯汇编”项目中,您正在调用为汇编调用程序编写的函数,程序员可以自由地做任何他们喜欢的事情(例如为了性能而做的选择),而不关心其他语言对性能的限制和限制(调用约定)。这种情况下,您可能根本不需要对齐堆栈(但如果您处理AVX-512,则函数可能需要堆栈对齐到64字节,如果您处理AVX2,则函数可能需要堆栈对齐到32字节等等)。
如果您不知道堆栈是否对齐足够,请使用AND对其进行对齐(例如,可能使用and rsp,0xFFFFFFFFFFFFFFF0 将堆栈对齐到16字节边界)。这也意味着您需要在某处存储旧的堆栈指针以便稍后进行还原;这通常需要4个以上指令(在对齐之前push rbpmov rbp,rsp ,然后在稍后 mov rsp,rbp 和pop rbp来恢复堆栈)。
但是,如果您知道调用者为您对齐了堆栈(并且调用的函数希望具有相同或更少的对齐方式),则可以通过跟踪在堆栈上推送量来计算需要减去多少额外的字节。例如,如果您的调用者将堆栈对齐到32字节,并且您在堆栈上推送了四个64位(8字节)值,而一个call指令会在堆栈上再推送一个64位值(返回地址),则总共将使用5*8= 40字节;因此,您就知道,如果要对齐到16字节,则需要减去另外8个字节,使总计达到48字节,或者需要减去另外24个字节,使总计达到64字节,以便对齐到32字节。这还避免了保存原始堆栈指针的必要性(您可以稍后添加任何已减去的内容),因此可以节省4个指令。
当然(对于“纯汇编”),您需要查看调用的所有函数的要求,并选择最坏情况并将堆栈对齐一次(避免多次不同地对齐堆栈,每个调用的函数都需要对齐一次);并且您可能会说“我的函数需要将堆栈对齐到我调用的函数的最坏情况”,以确保您可以计算要减去多少(并避免更昂贵的“AND with ...”方法)。但是(对于“纯汇编”),这使得调用者肩负了负担(他们可能会让其调用者承担负担,谁又可能...),因此它可能会降低性能(调用链中的所有祖先都必须做额外的工作,以便您可以避免更少的工作)。换句话说,对于“纯汇编”,实现最高效率/性能需要大量的工作(确定何时应对齐堆栈,以及最小化确保必要时对齐堆栈的开销)。
这也是编译器在其调用约定中放置对齐方式的原因之一 - 必需的“在大多数情况下不太可能是最优的”标准对齐方式使编译器更容易。

如果只有少数函数需要超过8字节的堆栈对齐,您仍然可以选择仅保持8字节的堆栈对齐,并在这些函数需要对齐的本地数组时使用and rsp,-32或其他内容。 (它们还需要设置RBP作为帧指针,或者执行其他操作以使恢复旧的RSP成为可能。)在整个程序中采用您想要的最大对齐方式并保持该方式可能会变得更加昂贵,特别是如果那些需要更高对齐方式的函数很少被调用。 - Peter Cordes
1
Windows x64和x86-64 System V都选择保持16字节的堆栈对齐,这是非常好的选择,可以让XMM寄存器得到对齐的溢出/重载,并且可以更有效地自动向量化本地数组或单个对象上的遗留SSE循环。为什么x86-64 / AMD64 System V ABI要规定16字节的堆栈对齐?只需要1个虚拟推/弹(或sub/add)和每个堆栈帧最多8字节的浪费空间成本。部分效益是特定于编译器而不是手写代码的,例如保持alignof(long double),但16B很好。 - Peter Cordes
@PeterCordes:从我的角度来看,ABI的存在是一种失败的承认——古老的解决方案“没有足够的内存同时在内存中拥有编译器和整个程序”的丑陋后果,再加上50多年的工具未能现代化。这就是为什么我的答案将“纯汇编”(摆脱编译器的失败)与你必须忍受ABI的情况分开的原因。 - Brendan
1
当然,这是一个很好的观点。从这个角度来看,在共享库中的每个函数都应该有一种指示其调用约定的方法,静态库应该具有LTO字节码,而不是机器代码。但是,即使你在每个函数上完全自定义了调用约定,“纯汇编”中仍然会涉及到要在跨函数调用时维护多少堆栈对齐。在整个程序中维护大量对齐可能很困难。我只提出ABI来讨论它们选择16B作为纯汇编(而不仅仅是因为它们更严格的限制)可能是好的选择。 - Peter Cordes

2

我认为我知道为什么在调用之前会出现行sub rsp,8的情况(但我不是专家)-我以装修工为生。所以call指令实际上将执行两个指令。首先,它将返回地址推送到堆栈上,然后第二个它将跳转到函数。好吧,返回地址是8个字节,这将导致堆栈失去对齐。因此,在调用之前额外的sub rsp,8将在函数执行之前修复不对齐的问题。
然后 - 为了从函数返回,RET指令将从堆栈中弹出返回地址,然后jmp到该地址。因此,从函数返回时堆栈将再次不对齐,因此在调用之后的行将添加rsp,8以再次修正堆栈对齐。


调用约定要求在“call”之前和“ret”之后RSP%16 == 0。 (您需要在从父函数返回之前添加rsp,8(或更多,如果您保留了更多空间),将RSP指向返回地址。)因此,“ret”实际上再次对齐堆栈。但是,是的,大多数情况下都适用于两个函数都不分配任何额外空间的特殊情况。 - Peter Cordes
你的回答可以通过提供更多支持信息来改进。请编辑以添加进一步的细节,例如引用或文档,以便他人可以确认你的答案是正确的。您可以在帮助中心中找到有关如何编写良好答案的更多信息。 - Community

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