是什么让堆栈概念在嵌入式编程中有用?

电器工程 嵌入式 数据
2022-01-14 12:46:38

下面是堆栈概念的说明:

在此处输入图像描述

我已经读过很多次关于堆栈指针以及一些变量如何存储在堆栈上的 SRAM 中的内容。

许多教程解释了它是如何工作的,但没有解释为什么这个概念存在。

想象一下我是程序,我想把我的杯子、手表和帽子放在我的房间里。为什么我要把它们叠放在一起而不是放在我房间里随机的地方?

程序只要有地址就可以访问数据。但是如果堆栈指针将 x 放在顶部 y 上。那么它如何在不移除 x 的情况下达到 y 呢?所以对我来说,堆栈必须使事情(数据访问)变得更加困难。

你能否举一个非常简单的例子,用寄存器或汇编代码或 C 代码来说明这种堆叠概念的好处,即“后进先出”(LIFO)?

4个回答

结论

是的,您需要嵌入式编程中的堆栈。这是一个从长期经验中产生的好主意。

简史

我已经足够大,可以在不具备硬件堆栈支持的计算机上工作。(显然,如果您有某种间接内存引用的必要说明,那么您总是可以在软件中制造自己的堆栈。)所以也许我对这个话题有一些话要说。

HP 21xx 系列处理器系列就是一个很好的例子。过去,这个处理器系列通常被学校系统用于双处理器配置,作为管理和/或学生使用的分时系统。使用 HP 21xx 处理器系列的最后一个软件版本是HP 2000F 分时系统(他们切换到从“F”到“G”的 HP 21MX。)

该指令集不支持用于进行子程序调用的堆栈。相反,它用返回地址戳第一个内存位置,并在下一个地址开始执行例程。在子例程结束时,将有一个间接跳转指令,它将引用该第一个内存位置并在调用之后以这种方式跳转回调用者。

这是 BASIC 操作系统的一些实际代码(注意使用了大写字母——小写字母并不总是可用的):

STACH NOP
      STB LTEMP+8      SAVE B-REG.
      AND B177         CLEAR TOP BITS.
      LDB STABF        GET BUFFER POINTER.
      CLE,ERB
      SEZ,RSS
      ALF,SLA,ALF      CHARACTER ON LEFT.
      IOR 1,I          CHARACTER ON RIGHT.
      STA 1,I
      ISZ STABF        BUMP POINTER.
      LDA LTEMP+8      RESTORE OLD B-REG.INTO A.
      JMP STACH,I

最后一行是子程序返回指令。请注意,NOP 始终用作子程序的第一条指令。那是因为当子程序被调用时,它总是被“吹走”并被调用者的返回地址代替。

RSS 指令是一个修饰符,它“反转跳过意义”它所修改的任何内容。在这种情况下,“如果 Z=0,则跳过下一条指令”指令(SEZ。)因此 RSS 会反转该含义并将其更改为“如果 Z!=0,则跳过下一条指令”指令。

这是一个附近的例子,它实际上必须使用返回地址,因为它想要一个不同的例程直接返回到这个例程的调用者:

STAPR NOP
      LDA STAPR        SAVE RETURN ADDRESS.
      STA T35SP
      LDA T35B2        COMPUTE # OF CHARS.
      CMA,INA
      ADA STABF
      LDB T35B2        RESET BUFFER
      STB STABF         POINTER.
      LDB T35B1
      JMP T35SP+1      OUTPUT.

注意最后一条指令跳转到 T35SP+1。这是 T35SP 之后的一条指令。(T35SP 是另一个函数。)还要注意,这个例程必须首先复制它自己的返回地址,并将其填充到这个例程的第一个地址中,他们最终会跳转到。这允许他们跳转到的例程(它使用 JMP,I 指令返回)直接返回给它的调用者。没有堆栈,所以这就是当时的做法。

(CMA,INA 与 NEG A 相同。它只是表示对 A 进行补码,然后将 A 递增。您可以在一条指令中执行一个、另一个或两者。)

另请注意,汇编器不支持本地标签。所有标签都是完全全球化的。这意味着那些 HP 21xx 程序员必须不断地想出新的、稍加修改的名称以避免冲突。而且没有小写!

对。你说对了。那时的日子很艰难。

当然,为 32 个同时用户提供分时功能的完整 BASIC 解释器代码以及所有常见的超越函数、矩阵、包括矩阵求逆和矩阵行列式运算符在内的常见矩阵运算将完全适合 6k 字( 16 位字。)是的——整个事情。

现在问问自己,“我可以用只有 12k 字节的代码空间来做什么?”

继续,

这种安排的一个显而易见的问题是,您不能调用可能需要调用您当前所在的例程的任何其他例程,因为如果发生这种情况,您将丢失原始调用者的返回地址。而且你当然不能递归地调用自己!

因此,如果这是一个要求,您必须在例程中编写一堆代码,将地址复制到您创建的某个“软件堆栈” 。你必须在任何可能成为问题的地方都这样做。

它可能成为一种严重的疼痛。去过也做过。

在 60 年代,有一个关于有用机制的学习过程。

有一个逐步学习的过程,有时被称为第二系统综合症。或者,至少,在我当时的同龄人中。这就是第一个系统无法以各种方式达到必要目标的地方,并且强烈推动使用所学知识来创建一个更明亮的第二个系统。但是第二个系统的功能太过分了,因此,消费者抱怨他们找不到他们真正需要的主要东西,因为他们被埋在这么多不同的选择中。因此,经过一些功能调整,大约在第三次系统尝试时,设计“大约正确”。(我想这也可能是金发姑娘和三只熊的故事,最后一张床尝试的“恰到好处”。

在设计 PDP-11 时,它是一种“第二系统”方法。他们提供了拥有硬件支持堆栈的所有可能方式。没有什么像今天这样。没有什么太好的。你应该有机会看到指令集。调用其他例程的方式令人惊叹。您可以调用代码并将您的返回地址放在寄存器中。这对于快速协同程序非常有用。您可以调用代码并使用几乎任何其他寄存器作为返回地址的堆栈指针。不拘泥于一个。你想要两个硬件堆栈?没问题。三?那也没关系。每个寄存器可用于:寄存器寄存器间接自动递增指针自动递减指针自动递增指针间接自动递减指针间接索引指针索引指针间接

PDP-11 是堆栈天堂。生活似乎很好。

但随后……第三系统综合症发作了。现在,我们陷入了一种更“平衡”的指令集视图。

那好吧。

堆栈框架又名激活框架

堆栈不仅仅是用于保存返回地址的堆栈。对于从源语言编译代码和希望生活更轻松的汇编程序员来说,它也是一个重要的工具,而不是必须跟踪散落在房间里、随机位置的东西。

我很久以前画过这个:

在此处输入图像描述

上图,显示在较白的区域,是一个典型的激活框架

上图并未定义所有可能的变化。只有一个比较简单的。例如,Pascal 需要支持嵌套函数,并且这些嵌套例程必须能够访问其封闭代码体中的局部变量,因此编译的 Pascal 代码的激活框架可能比上面的要复杂一些。

调用者将参数压入“ [线程]堆栈”,然后进行调用,自动压入返回地址。请注意,在上述情况下,有一个“可选”返回值参数。这是因为有时返回值可能是一个向量,而不是可以轻松放入寄存器的东西。(当数据结果很容易拟合时,寄存器通常用作返回值——例如整数。)因此,如果合适的话,被调用的例程可以使用这个槽来填充向量返回值。但它是可选的,因为每种情况都决定是否需要它。

一旦被调用的例程开始,它做的第一件事(使用序言代码)是保存必须在调用之间保留的特殊寄存器。这些寄存器是调用例程正确假设在调用子例程时不会被破坏的寄存器。它们有时被称为“保留寄存器”,但我不知道今天的计算机科学家如何称呼它们。关键是如果被调用的子程序需要使用这些寄存器,那么它必须保存它们以便它可以将它们不受干扰地返回给调用者。

另外,还必须保留称为“帧指针”“激活帧指针”的不同寄存器。这只是一个添加的寄存器,由编译器(或汇编编码器)为这个特殊的激活帧目的保留。激活帧指针总是指向堆栈上的“当前帧”,它为当前执行的子例程提供对所有内容的访问它需要完成它的工作。

(上述保存的顺序并不是非常重要。无论选择如何,都有优点和缺点。)

在必要的序幕结束后,子程序开始处理它的事务。一旦结束,子程序现在需要恢复保存的寄存器,恢复激活帧指针,然后返回给它的调用者。结语代码。)

这允许子例程拥有自己的局部变量,即使面对递归(调用自身)也是如此。

为了清楚起见,让我们以两个未优化的 C 例程和旧的 16 位 x86 指令集来做一些示例。

认为:

int f1( int a ) {
    if ( a > 2 ) return a * f1( a-1 );
  return a;
}

int f2( int a ) {
  int r= a;
    if ( a > 2 ) r= a * f2( a-1 );
  return r;
}

我使用递归形式从提供的值计算阶乘。我不想关注上述代码的质量或清晰度。那不相关。我只想关注如何编译它,以及为什么。另外,由于我已经很久没有定期编写 x86 例程了,请原谅我的编码有任何“鸽子”。我可能会忘记语法细节。

       ; Caller expects the function result in AX.
f1:    push si               ; save SI because caller assumes it is preserved.
       push bp               ; save caller's activation frame pointer.
       mov  bp, sp           ; set up our own activation frame pointer.
       mov  ax, [bp+6]       ; fetch parameter value into AX.
                             ;    the +6 part is there to skip the saved BP, SI,
                             ;    and the caller's return address.
       cmp  ax, #2           ; compare it against 2.
       ble  t1               ; exit the routine if <= 2, AX is already set.
       mov  si, ax           ; save parameter value in SI where it is safe.
       dec  ax               ; subtract 1 from the given parameter value.
       push ax               ; push this value as a parameter to a call.
       call f1               ; now call ourselves to compute its factorial.
       imul si               ; multiply this result (in AX) by the saved parameter.
                             ;    the result will be in DX:AX, but we ignore DX.
t1:    pop  bp               ; restore caller's activation frame pointer.           
       pop  si               ; restore SI.
       ret                   ; result is already in AX so just return.


       ; Caller expects the function result in AX.
f2:    push bp               ; save caller's activation frame pointer.
       mov  bp, sp           ; set up our own activation frame pointer.
       sub  sp, #2           ; reserve 2 bytes for local variable 'r'.
       mov  ax, [bp+4]       ; fetch parameter value into AX.
                             ;    the +4 part is there to skip the saved BP
                             ;    and the caller's return address.
       mov  [bp-2], ax       ; set local variable 'r' to parameter 'a'.
       cmp  ax, #2           ; compare 'a' against 2.
       ble  t2               ; exit the routine if <= 2, result 'r' is already set.
       dec  ax               ; subtract 1 from the given parameter value.
       push ax               ; push this value as a parameter to a call.
       call f2               ; now call ourselves to compute its factorial.
       imul [bp-2]           ; multiply this result (in AX) by 'r'.
       mov  [bp-2], ax       ; save result in 'r' (ignore DX.)
t2:    mov  ax, [bp-2]       ; put 'r' into the function result register.
       pop  bp               ; restore caller's activation frame pointer.           
       ret                   ; just return.

为了保持与源代码的一致性,这些被故意保留为未优化。一个好的优化器会用上面的代码度过一个愉快的一天。

我想提供两种不同的情况:第一个突出显示调用者希望在调用过程中不会更改的寄存器,第二个突出显示如何使用堆栈分配局部变量。在第二种情况下,请注意分配是多么容易——只需从堆栈指针中减去一个数字。您可以减去一个更大的数字,仍然使用一条指令,以分配大量的局部变量存储。这真的“那么容易”。(另请注意,这些局部变量未初始化!)

如果你通过几个这样的例子,你会看到堆栈概念的许多好处。

我没有在图片中描述任何东西。有一个可选的异常处理程序指针。这在处理异常时非常有用。通常,当此例程不包含 Try-block 处理程序时,它可以为 NULL。但如果是这样,那么这个值将设置为将处理错误的代码。有一个称为“堆栈展开”的过程(回到我的时代),在一个没有处理程序的例程中发生的异常将慢慢地将堆栈展开回先前的调用者,直到找到安装了处理程序的调用者。这个动作“吹走”了带有错误处理程序的例程下所有被调用子例程的上下文。这是一个方便的设备但超出了我想在这里添加的上下文。

最后一点。将保留的寄存器和激活帧指针推入堆栈并从中恢复的顺序和方式不是一个重要问题。不同的人会在这里选择不同的方法。知道实际做出了什么选择很重要,因为它会影响在访问参数值或访问局部变量时相对于激活帧指针使用的偏移量。然而,激活帧指针用于所有这些访问,因为参数和局部变量都激活帧指针相关。相对于它的确切位置并不重要。但重要的是它们的相对位置是固定的并且编译器或汇编编码器知道要使用的偏移量,并且不需要执行一些运行时计算来计算它们。

结论?

是的,您需要嵌入式编程中的堆栈。这是一个从长期经验中产生的好主意。

(如果 HP 21xx 指令集没有实现 FeRAM 或磁芯存储器,您当然不希望它在您的 MCU 上实现——因为它需要可写的代码空间!)

当前执行的位置在子程序启动时存储到堆栈中,以便子程序结束时可以从堆栈中弹出。

这提供了一种在将上下文更改为另一个子例程之前存储程序正在执行的上下文的方法,甚至使子例程可以递归地调用自身,因此它在返回时只上升一个级别。如果一个子程序被多次调用,这很有用,它不会让它忘记它应该返回的位置。如果返回地址不是堆栈,那就是将返回地址存储在某个地方的问题。

因此,在子例程调用期间,您不需要访问在被调用的子例程之前执行的子例程的任何上下文。这个上下文既包括子程序的局部变量,也包括返回地址如何回到前一个子程序。

你给了我们一个类比,现在我再给你一个。

如果您在一家机构的门口迎接人们并从每个进入参观的人那里收集外套和帽子怎么办?如果您确定最后一个访问的人将是第一个离开的人,您可以将帽子和外套存放在类似堆叠的结构中。然后当每个人离开时,你会把他们的外套和帽子递给他们,然后以最小的麻烦和大惊小怪去堆中的下一个帽子和外套。这就是计算机程序中函数调用的工作方式(在某种程度上)。

函数可以按任何顺序调用其他函数,但每次发生函数调用时,堆栈都会增加一层。每次函数调用返回时,堆栈都会减少一级。

由于函数使用临时内存,并且调用它们的确切顺序不一定是预先确定的,因此堆栈是处理此问题的好方法。永久内存地址的想法对于没有函数调用机制的程序来说是可行的,但它会使递归函数调用变得更加困难。

堆栈概念在数据处理中经常出现,甚至高级程序有时也会使用花哨的堆栈数据结构(后进先出数据结构)。

想象一下我是程序,我想把我的杯子、手表和帽子放在我的房间里。为什么我要把它们叠放在一起而不是放在我房间里随机的地方?

你以错误的方式看待这个问题:想象你自己不是整个程序,而是一个可以放入各种程序中的子程序。

任何重要规模的程序通常都是由小的“组件”或软件例程以模块化方式构建的。小程序更容易测试,并且在如何组合它们以创建更大的程序和在程序之间重用方面提供了更大的灵活性。但是,要使其正常工作,重要的是这些例程不会相互干扰,即使一个人调用另一个例程也是如此。

它们可以干扰的一种方式是它们对内存位置的使用。如果你有两个不同的例程要在一个程序中组合使用,例程 A 使用内存位置 6 来存储它的一些数据,并调用也使用位置 6 的例程 B(即使只是在 B 完成之前作为临时的东西),例程 B 将覆盖例程 A 的数据,例程 A 将中断。

这可以通过仔细跟踪谁使用什么位置以及谁给谁打电话来在一定程度上避免这种情况,但是随着例程数量的增加,它们之间的潜在交互会成倍增加。因此,有一种方法可以最小化这些交互是很有用的,而堆栈通过使重入更容易来实现这一点。当例程需要临时保存某些内容时,它总是可以压入堆栈,然后将该值从堆栈中拉出,同时完全不受导致调用例程的任何代码放置在堆栈上的内容的影响,并且不受它调用的任何代码中堆栈使用的影响。(当然,所有例程都必须同意,在退出之前,它们总是会从堆栈中取出之前压入的任何东西。)

最简单的例子是想象你是一个需要使用 A 寄存器的例程,但是你在 A 寄存器中调用了一个(对你来说)你必须保留的随机值。(例如,如果您通过中断调用,则 A 寄存器将包含另一个例程正在处理的任何值,并且在另一个例程恢复时不得更改,否则它将中断。)在这种情况下,您可以将 A 寄存器压入堆栈,继续将其用于您自己的目的,然后在完成后将 A 寄存器从堆栈中拉出,恢复其原始值并完全按照您找到的方式离开堆栈。

如果您想要一个更详细的解决重入问题的堆栈示例,您还可以查看 Retrocomputing Stack Exchange 的这个答案

程序只要有地址就可以访问数据。但是如果堆栈指针将 x 放在顶部 y 上。那么它如何在不移除 x 的情况下达到 y 呢?所以对我来说,堆栈必须使事情(数据访问)变得更加困难。

嗯,它确实使访问“备份堆栈”变得更加困难。通常,当需要这样做时,堆栈上会有一系列指针,可让您逐个例程地返回,找到每个“堆栈帧”,以便您可以访问其数据。(因此,您可以查找您存储的帽子,调用您的例程存储的帽子,调用它的例程存储的不同帽子,等等。)这不是一个不寻常的技术,但会详细解释它可能超出了您正在寻找的答案的范围。