组装中的“对齐堆栈”是什么意思?

Fay*_*ure 1 assembly x86-64 calling-convention memory-alignment stack-pointer

堆栈对齐在 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
Run Code Online (Sandbox Code Playgroud)

Bre*_*dan 7

你什么时候需要在函数调用之前对齐堆栈......?

当您调用的函数需要对齐的堆栈时,您需要对齐堆栈。

用其他语言(例如 C)编写的函数,以及用汇编编写但旨在从其他语言调用的函数,将遵守某种调用约定(其中包括的不仅仅是堆栈对齐 - 参数如何通过,参数在哪里,诸如“红色区域”等);对于 64 位 80x86,2 常见调用约定期望堆栈与 16 字节边界对齐。

在“纯程序集”项目中,您正在为程序集调用者调用以程序集编写的函数;程序员可以自由地做他们喜欢的任何事情(例如,任何对性能最有利的事情),而不必关心降低性能的其他语言的限制/限制(调用约定)。在这种情况下,您可能根本不需要对齐堆栈(但如果您正在处理 AVX-512,一个函数可能希望堆栈对齐到 64 字节,如果您正在处理 AVX2,一个函数可能希望堆栈对齐到 32 个字节,和..)。

......你需要减去多少?

如果您不知道堆栈是否足够对齐;然后对齐堆栈通常使用 AND 完成(例如,可能and rsp,0xFFFFFFFFFFFFFFF0将堆栈对齐到 16 字节边界)。这也意味着您需要将旧的堆栈指针存储在某处以便您可以恢复它;这通常意味着还有 4 条指令(push rbp,mov rbp,rsp在对齐之前,然后mov rsp,rbp和pop rbp稍后恢复)。

然而; 如果你知道你的调用者为你对齐了堆栈(并且你调用的函数想要相同或更少的对齐),那么你可以通过跟踪你在堆栈上压入多少来计算要减去多少额外的。例如,如果您的调用者将堆栈对齐到 32 字节,并且您将四个 64 位(8 字节)值call压入堆栈,并且一条指令将压入另一个 64 位值(返回地址);那么它总共是 5*8 = 40 个字节;所以你知道如果你想对齐到 16 个字节,你需要再减去 8 个字节来得到总共 48 个字节,或者如果你想对齐到 32 个字节,那么减去另外 24 个字节来得到总共 64 个字节。这也避免了保存原始堆栈指针的需要(您可以添加以后减去的任何内容),因此可以节省 4 条指令。

当然(对于“纯汇编”),您会查看您调用的所有函数的要求,然后选择最坏的情况并将堆栈对齐一次(并避免多次以不同的方式对齐堆栈,每个函数调用一次); 您可能会说“我的函数要求堆栈与我调用的函数的最坏情况对齐”以确保您可以计算减去多少(并避免更昂贵的“AND with ...”方法) . 然而(对于“纯汇编”)这会给你的调用者带来负担(谁可能会给他们的调用者带来负担,谁可能......)所以它会使性能变得更糟(调用链中的所有祖先都必须这样做额外的工作,这样你就可以避免更少的工作)。换句话说; 用于“纯组装”;

这也是编译器将对齐放在其调用约定中的部分原因 - 所需的“在大多数情况下不太可能是最佳的”标准对齐使编译器更容易。


old*_*mer 5

寻址通常是基于字节的。一个唯一的地址指向一个字节(它可以是一个字或双字等中的第一个字节,但引用到该地址)。

对于任何编号系统,最低有效位都是以 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,但我们通常不直接访问 dram)。

如果我想在小端机器中保存 16 位 0xAABB 以寻址 0x1001,那么 0x1001 将获得 0xBB,而 0x1002 将获得 0xAA。如果我有一个 32 位数据总线和一个 32 位内存在它的远端,那么如果我为此设计了总线,我可以移动这 16 位,通过将 0xXXAABBXX 写入地址 0x1000 并使用字节通道掩码 0b0110 告诉内存控制器使用与基于字节的地址 0x1000 相关联的 32 位内存,以及总线上的字节通道掩码告诉控制器只保存中间两个字节,外面两个无关紧要。

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

现在堆栈对齐规则不会阻止写入时的读取-修改-写入。对于发生较大传输的情况,有机会提高性能,例如,如果总线是 32 位,而内存是 64 位传输到地址 0x1000,那么基于总线设计,这看起来像一个带有两个的长度。总线握手发生然后两个背靠背时钟数据移动,而不是握手和一个宽度的数据总线进行较小的传输。因此,如果内存为 32 位宽,那么您会在那里获得收益,那么它是两次写入,而没有对缓存中的 sram 进行读取-修改-写入。很干净,想避免读-修改-写。

现在随着事情的发展和硬件和工具需要堆栈对齐,这样做一段时间。

根据指令集,很明显,您在这里问的是 x86,但作为程序员,您有时可以选择说在堆栈上压入一个字节,然后对其进行调整以对齐它。或者,如果您正在为局部变量腾出空间,则取决于指令集(如果堆栈指针的通用性足以对其进行数学运算),您可以简单地进行减法运算,因此 sub sp,#8 与推入两个相同将 32 位项目放入堆栈只是为两个 32 位项目腾出空间。

如果规则是 32 位对齐并且您压入一个字节,那么您需要将堆栈指针调整 3,以使堆栈指针的总变化为 4 个字节(32 位)的倍数。

你怎么知道有多少,你只需计算一下。如果是 16 字节对齐并且您推送 4,那么您需要再推送 12 或将堆栈指针调整 12。

这里的关键是,如果每个人都同意保持堆栈对齐,那么您实际上不必查看堆栈指针的低位,您只需在调用其他内容之前跟踪您正在推送和弹出的内容。

如果堆栈与中断处理程序共享(不是在您当前运行操作系统的 x86 中真正共享,但在通用处理器的许多其他用例中仍然可能并且可能)我还没有看到这条规则在那里适用,因为您将看到编译器执行小于对齐大小的推送或弹出,然后使用其他推送或弹出或减法或加法进行调整。如果在这些之间发生中断,处理程序将看到未对齐的堆栈。

某些架构会在未对齐访问时出错,这是保持堆栈对齐的另一个原因。

如果您的代码没有弄乱堆栈,那么您就不需要弄乱堆栈(指针)。仅当您通过在堆栈上分配空间(堆栈指针上的压入或数学运算)来使用代码中的堆栈时,您是否需要关心并且您需要知道与此代码链接并符合的编译器约定那。如果这都是汇编语言而没有编译器,那么你自己决定约定,基本上在处理器本身的限制内做任何你想做的事情。

从您的标题问题来看,它与汇编无关,也与机器代码无关。它与您的代码及其功能有关。汇编语言只是一种语言,您可以在其中传达您想要调整堆栈指针的程度,指令不关心或不知道任何此类事情,它采用提供的常量并将其用于寄存器。汇编是为数不多的允许您在堆栈指针寄存器上进行数学运算的其中之一,因此存在这种联系。但是对齐和组装没有关系。