2

堆栈对齐在 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
4

2 回答 2

7

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

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

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

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

...你需要减去多少?

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

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

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

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

于 2020-11-07T16:05:03.780 回答
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 告诉内存控制器使用与基于 BYTE 的地址 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 中,但在通用处理器的许多其他用例中仍然可能并且可能)我还没有看到该规则适用于那里,因为您将看到编译器执行小于对齐大小的推送或弹出,然后使用其他推送或弹出或减法或加法进行调整。如果在这些之间发生中断,则处理程序将看到未对齐的堆栈。

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

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

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

于 2020-11-07T16:00:38.947 回答