【问题标题】:Why push first decreases the stack pointer?为什么push先减少堆栈指针?
【发布时间】:2019-11-23 15:55:53
【问题描述】:

我试图了解堆栈在推入和拉出东西时是如何工作的,如果问题听起来很简单,对不起。

我想从超级基本的东西开始,比如 8 位内存(我知道这会过于简单,但让我们从简单的开始)

我设计堆栈的方式如下:

SP 最初会指向内存中的最高位置:0xFF

0xFF:   <- SP

当推送命令发出时,我会将val保存在SP指向的位置,然后减小SP。

0xFE:         <- SP
0xFF:  val

弹出命令会先增加 SP,然后将 SP 指向的值移动到寄存器中。

基本上我的 SP 指向堆栈中的第一个可用位置。

然而,这似乎不是它在实际系统中的实现方式。

查看组装手册中的推送指令:

Decrements the stack pointer and then stores the source operand on the top of the stack.

所以基本上 SP 指向的是最新存储的值。

我的问题是:通过首先减少堆栈指针,堆栈的顶部不是不可用吗?如果我们在保存数据之前先减少指针,我们如何将数据存储到堆栈的第一个位置?

有理由这样设计堆栈指针吗?

【问题讨论】:

  • 这两种类型都有实现,一些架构同时支持这两种类型。 “第一位置”的概念取决于堆栈类型。您不能写入初始堆栈指针的地址,但您不会将其称为“第一个位置”。或者换一种说法:给定一个预期的第一个位置,将堆栈指针设置为直接指向它之后,然后它就会工作。
  • 主要基于意见/过于宽泛。也就是说,考虑存储然后减量的非 x86 方法:第一个有用的数据元素将位于距 SP 的偏移量1 或更大处。执行先减后存储,有效堆栈值的索引从0 开始,就像对任何类型的基于数组/指针的存储的常规访问一样。恕我直言,这是一个有用的一致性。这也意味着您没有“浪费”偏移量,即0 的偏移量实际上是有意义的,而不是从没用过。
  • 先修改SP是一种保护。如果堆栈中的数据副本和 SP 更新之间出现中断,会发生什么情况?如果您先更改SP,则不会出现问题。
  • @AlainMerigot:这对于没有 push 指令的 ISA 来说是有意义的,您必须手动减少堆栈。但是,如果您确实有推送指令,则存储和减量将是原子 WRT。中断。 (中断在逻辑上总是发生在指令边界;部分进度被丢弃或中断等待指令完成。)我从未听说过像push 这样的简单指令在完成一半指令时可中断的ISA。因此,满堆栈与空堆栈主要是一种任意设计选择,但让 SP 指向数据可能很有用。
  • 当然,指令永远不会被打断。但是即使在带有push 指令的机器上,也可以使用一对 sp--/store 指令来压栈,这需要通过首先递减 sp 来防止中断。而且,虽然技术上没有严格要求,但使用推送或一对指令具有完全相同的机制是有意义的。

标签: assembly stack cpu-architecture callstack instruction-set


【解决方案1】:

递减堆栈指针,然后将源操作数存储在堆栈顶部。

有一些设计考虑(但请放心,我同意它是相对随意的,因为任何一个都可以工作):

首先,让我们举个例子,看看如果从一开始,一个 2 字节的字被压入堆栈会发生什么。

        0xFF:   <- SP

push.w val2

        0xFD:         <- SP
        0xFE:  val2(hi 8-bits)   # order depends on big/little endian
        0xFF:  val2(lo 8-bits)

值的 8 位指向 SP 指向的位置(第一个可用字节),而其他 8 位必须低于该地址(因为它们不能高于它,是吗?)。堆栈指针指向一个空闲字节,所以刚刚压入的值可以在 SP + 1 处访问。

虽然这可以工作,但替代方案似乎更合理:

刚刚推送的项目在位置 SP + 0。

请记住,加载比存储更常见,因此加载堆栈顶部的项目可能比存储它更频繁。在 SP + 0 访问堆栈顶部有利于加载,在一个支持无位移加载的架构中。 (与无人认领的空间相比,它也有利于认领空间。)


如果我们想到 SP + ?作为已声明和未声明的分界线,在声明的空间中包含 0 似乎更实际和自然。这是因为(在计算机中,与数学不同)零更像是正数之一,而不是负数——例如,考虑无符号数,它总是支持零(以及正值)。


我们还要注意,由于微架构的原因,内存读取比内存写入慢(读取通常在关键路径上,它限制了最大可能的时钟频率,而写入则不是)。因此,后增量弹出(加载)优于前增量弹出,因为后增量可以在并行硬件中进行加法(对数据存储器访问),而前增量弹出在地址总线和数据存储器读取操作的方式。 (当然,为了支持后增量弹出,我们需要先减量推送。)

【讨论】:

  • 是否存在pop 在地址生成中绕过加法器的 CPU,从而允许比预增量/空堆栈更低的负载使用延迟?我想在流水线 CPU 中,如果负载和弹出背靠背运行,这可能会导致与使用正常 reg+const 寻址模式的常规负载发生资源冲突。或者这会在像 Z80 这样的高度微编码的非流水线 CPU 中,也许微码可以在负载运行时更新堆栈指针?如果没有足够快地开始,负载将处于指令完成的关键路径上。
  • IDK 如果准确地说读取延迟会限制时钟频率。当我说关键路径时,我指的是微码在一个 流水线 CPU 中要执行多少步,而不是最长的流水线阶段的长度。更早开始读取可能会让pop 在 Z80 等 CPU 上以更少的微周期运行。 en.wikipedia.org/wiki/Zilog_Z80#Instruction_execution 显示了不同的指令如何花费更多或更少的 M 周期。 (我不确定每个 M 周期是否是固定数量的 T 周期。例如 this 在 T 状态中计数 perf)。
  • Z80 x 6502 raw performance 说 Z80 push 是 11 个周期,但它的 pop 运行 10 个周期,因为堆栈预先递减。
【解决方案2】:

为什么push先减少栈指针?

首先:堆栈指针的工作方式取决于 CPU 类型。

在6800上,先写入值,然后堆栈指针递减。

在 TMS320F28 上,值被写入,然后堆栈指针递增。

...堆栈的最顶端不是不可用吗?

请忘记“无法使用”这个词。正确的词应该是“正在使用”。

想想下面的 C 或 Java 程序:

int a, b;
a = someFunction();
someOtherFunction();
thirdFunction(a);

您想将someOtherFunction() 的返回值存储在一个变量中,如下所示:

int a, b;
a = someFunction();
a = someOtherFunction();
thirdFunction(a);

这不是一个好主意,因为变量 a 已经“使用中”了。然而,变量 b 仍然“可用”。

但是,这并不妨碍您覆盖变量 a

现在让我们回到堆栈指针并查看局部变量。查看局部变量(而不是push),我们可以更清楚地看到堆栈指针的实际作用:

当输入这样的函数时:

void someFunction(void)
{
    int x, y, z;
    ...
    y = 5;
}

...生成的汇编代码将首先将堆栈指针减 3(假设一个 int 需要一个内存位置)。

假设堆栈指针在进入函数之前的值为 0x73。这意味着内存位置 0...72 “未使用”,而内存位置 73...FF 处于“使用中”。

汇编代码会将堆栈指针的值更改为 0x70,并将变量 x 存储在地址 0x70,y 在地址 0x71 和 z 在地址 0x72。

堆栈指针的值为 0x70,这意味着内存位置 70...FF 现在“正在使用”。

应该清楚的是,内存位置 70...72 正在“使用中”,因为变量 xyz 存储在那里。

但是,这并不意味着无法访问(读取或写入)这些内存位置:指令y=5; 将写入内存位置 0x71。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2011-08-22
    • 2010-12-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2010-09-30
    • 2013-07-26
    相关资源
    最近更新 更多