s/360 程序集:如何实现调用栈

s/360 assembly: how to implement a call stack

我想编写一个函数(在 hlasm 中)调用自身和其他函数。

在 x86 或 z80(可能还有其他)上,您只需调用函数并在函数结束时调用 ret。然后处理器将存储和检索 return 地址。

指令集中有如下指令:

BAL reg,func

...将 return 地址存储在 reg 中,然后在最后你可以执行 BR reg 跳转到那个 return 地址。另一个问题是显然没有 push/pop 指令。

如果是自己程序中的子程序,那么BAS(Branch and Save)/BR是标准。如果子程序再调用另一个子程序,可以保存调用者的地址,以后再恢复:

          <some code>
          BAS R10,SUBRTN1        .Branch to subroutine
          <some mode code>
          .
          .
          .
*
SUBRTN1   DS  0H
          ST  R10,R10SAVE         .Save Return address
          BAS R10,SUBRTN2         .Call subroutine 2
          L   R10,R10SAVE         .Restore return address
          BR  R10                 .and return
*
SUBRTN2   DS  0H
          <some code>
          BR  R10                 .Return to caller

但是,这是针对 'internal' 子程序的,您必须注意子程序不会覆盖主程序使用的寄存器。

如果这有风险,那么您可能希望使用标准链接约定,其中一个程序调用另一个程序,调用程序为被调用程序提供保存区域以保存调用者寄存器等。

在 System/360 和后续操作系统中,执行此操作的基础结构是所谓的 可重入 编程的一部分。基本概念是 R13 指向的保存区域的存储是从操作系统获得的(想想 C 中的 malloc)。获取存储的系统宏调用在程序的最开始使用。同样,对 return 操作系统存储的系统宏调用在程序退出时进行编码

您没有提到您使用的是哪种操作系统。我将为这个示例代码做出以下假设:

  • 这适用于 z/OS 或以前的操作系统(OS/390、MVS、OS/VSn、OS/360);
  • 代码为AMODE 24或31,非访问寄存器模式(引入AMODE 64或ARs改变保存区域格式);
  • 这不是 运行 语言环境 (LE) 情况;
  • 已定义形式为 Rn 的寄存器等式;
  • 而且我没有计算评论的列数(因此它们可能会变成 72)。

这个骨架例程是可重入的,正因为如此,它也可以递归使用,正如您所提到的(需要注意的是,错误的代码重新调用自身最终会导致存储不足异常终止)。该代码需要一个基址寄存器,但我建议使用称为 "baseless" 的现代编码方法,其中代码本身不使用基址寄存器,因为它使用分支的相对和立即指令。 (您始终需要一个数据基址寄存器。)

WORKAREA  DSECT ,                    Reentrant work area (like C stack)
          DS    18F                  Save area
FIELD1    DS    F                    Some variable
FIELD2    DS    F                    Another variable
WORKLEN   EQU   *-WORKAREA           Length of reentrant work area

SUBRTN1   RSECT ,                    HLASM will perform reentrant checking
          STM   R14,R12,12(R13)      Save registers at entry
          LR    R12,R15              Set code base register
          USING SUBRTN1,R12          Establish code addressability
          LGHI  R0,WORKLEN           Get length of reentrant work area
          STORAGE OBTAIN,            Obtain reentrant work area                  X
                LENGTH=(0)           ..Length is in R0
          ST    R1,8(,R13)           Forward chain in prev save area
          ST    R13,4(,R1)           Backward chain in next save area
          L     R14,20(,R13)         Get R1 at entry (parameters)          
          LR    R13,R1               Set up new save area/reentrant workarea
          USING WORKAREA,R13         Establish work area addressability
          LM    R2,R3,0(R14)         Get addresses of parameters
          STM   R2,R3,FIELD1         Save parameter addresses for later
…
***    Logic goes here
…
          LR    R1,R13               Address to be released
          L     R13,4(,R13)          Address of prior save area
          LGHI  R0,WORKLEN           Length of storage to release 
          STORAGE RELEASE,           Release reentrant work area                 X
                ADDRESS=(1),         ..Address in R1                             X
                LENGTH=(0)           ..Length in R0
          LM    R14,R12,12(R13)      Restore registers
          OI    15(R13),X'01'        This bit on means this save area is inactive
          BR    R14                  Return to caller

这是一个非常基本的示例,并介绍了一些高级汇编器概念,例如 DSECT(虚拟部分),它描述了一个区域,但实际上并未在程序中分配存储空间。 RSECT 是汇编程序加强可重入性的一种方式,它会在程序试图修改自身时发出警告。 (还有一个汇编程序选项 RENT,但它适用于整个源代码;RSECT 仅适用于该部分。)

关于这个例子要记住的关键是你有两个基址寄存器,一个寻址代码,一个寻址数据。这与早期的 x86 架构相似,有代码段和数据段。在这种情况下,数据段也被用作堆栈段。

可重入程序是系统级 z/Architecture 编程的重要组成部分,因为它实际上是多任务环境所必需的。这是一项重要的技术,可以了解一个人是否会编写超出典型批处理程序的应用程序(一般意义上)。

如果您处于 LE 环境中(COBOL、PL/I、C/C++ 或汇编程序主程序),您应该使用 LE 提供的 CEEENTRY 和 CEETERM 宏。 LE 编译器生成的程序通常是可重入的(即使是带有 NORENT 选项的 COBOL)。确保在 CEEENTRY 宏上编写 MAIN=NO,否则会出现各种性能问题和可能的逻辑错误。 LE堆栈机制是我上面演示的技术的高级版本,最显着的是分配一个大的区域专门用于堆栈使用,这样后续调用就没有调用操作系统获取存储的开销。

如果你工作在z/VSE或z/VM环境,或者非IBMBS2000/OSD环境,我可以修改上面的例子。

如有问题请发表评论,我将更新此示例以更加清晰。

除了已经发布的内容之外,还有一些想法。

首先,任何经验丰富的 assembler 程序员都会考虑过子程序链接,并且会在多年后建立偏好。除了 "works" 之外,您还需要高效、简单且易于调试的东西。解决方案分为三个部分:

  1. 您的 save/restore 状态如何以及在哪里。
  2. 您如何定位和调用您的子程序并从中获取结果。
  3. 你需要在被调用的子程序中做什么。

保存状态

保存状态是一个复杂的问题。上面来自 "zarchasmpgmr" 的示例显示了使用链式保存区域的经典方法,如果您正在调用您无法控制的内容,您应该熟悉它的工作原理。然而,有时这并不是最好的方法,因为您在示例中看到的 "STORAGE OBTAIN" 是一个系统服务,在执行效率方面不是 "free"。换句话说,如果你调用很多子例程并且它们每个 acquire/free 工作区域,你会发现你的 assembler 语言例程比用 C 编写的同样的东西慢得多。

我个人的偏好是使用保存区域 "stack" 一个接一个地保存多个条目。当您调用子程序时,子程序将其寄存器保存在堆栈中并将堆栈指针 (R13) 递增到下一帧 - 快速、简单且不需要系统服务。它甚至很容易调试,因为整个调用堆栈通常是连续的。

那么堆栈中有什么 "frame"?至少,您需要非参数寄存器 (R2-R12),尽管为了调试我经常喜欢保存 R0-R15。同样为了调试,我喜欢保存时间戳之类的东西,甚至可能保存 "owns" 堆栈帧的子例程的名称。

事实证明,如果您只存储一个时间戳和所有 16 个寄存器,您需要的大约 80 个字节 - 我可能会这样声明:

WORKAREA DSECT , 
STACK    DS    10CL80         A 10-entry subroutine save stack 
         DS    80X'FF'        End of stack marker

您当然可以在堆栈帧中包含各种额外的东西。例如,您可能需要子例程的名称或类似名称,而不是时间戳。

在程序开始时,所有这些都设置一次,然后使用 R13 指向堆栈...在调用例程时,R13 在堆栈中上下浮动,因此它始终指向第一个可用的框架。

作为旁注,由于 R13 实际上指向一个区域,任何被调用的程序都可以使用它来保存其寄存器,这种类型的堆栈帧也可以在调用需要标准 OS 样式的例程时使用链接约定。在这种情况下,您在堆栈框架中看到的将类似于上面的标准链接,但在大多数情况下,您不必担心调用一些不喜欢您的私有堆栈框架想法的未知服务。

这种方法的唯一缺点是它对于深度递归不是很好,因为您通常分配固定数量的堆栈帧。如果你有深度递归,你需要检查堆栈的末尾,并有一些策略来增加堆栈,如果初始数量不够的话。

我会注意到,您可以以几乎相同的方式使用系统链接堆栈...您可以发出 "BRANCH AND STACK" (BAKR) 指令,并且在幕后,硬件正在维护一些东西与我上面列出的非常相似。唯一的问题是 BAKR 保存了更多的状态信息,所以它比我描述的要慢一些。

正在调用子程序

不管你如何设置状态保存,诀窍都是定位并跳转到子程序。作为其中的一部分,您需要考虑传递给被调用例程的任何参数——通常 R1 会包含(或指向)您想要的任何参数,但有时 R0 也很有用,尤其是当例程是某种东西时你控制的。

有几种方法可以编写实际的调用序列,但传统的方法是调用包含目标子程序地址的 R14 和包含 return 地址的 R15 的子程序。

小型、随意的程序与您可能必须在大型应用程序中执行的程序之间存在差异。您使用了 BAL Rx,subroutine 的示例 - 这可行,但是您调用的子例程需要在您正在进行的任何基址寄存器上可寻址,并且它还必须是同一编译单元的一部分(即, BAL Rx 中的标签 "subroutine",子程序需要在同一个源文件中)。

如果您在较大的应用程序中调用子例程,或者如果您想单独 assemble 您的子例程,可能创建一个对象模块库,每个子例程都有一个成员,您不能使用 BAL .同样,这里有很多解决方案,但我更喜欢使用 A 型或 V 型文字将子程序的地址加载到 R15 中,然后使用 BALR 而不是 BAL:

* 
* Call the subroutine
*
       LA   R1,some_parameter       R1  -> subroutine parameter
       L    R15,=A(Subrtn)          R15 -> subroutine address
       BALR R14,R15                 Call the subroutine

通过这种方法,子例程可以在任何地方……基址寄存器中需要的只是文字池(而 LTORG 允许您将其放在任何您喜欢的地方)。如果你想要子程序在完全不同的模块中,你需要做的就是将 =A() 更改为 =V()。

如果你想使用系统提供的链接栈,区别不大——使用BAKR 0,R​​15代替BALR指令。

子程序完成后,它return控制 BALR 之后的指令。通常,子例程的结果在 R15 中 returned,但有时也会使用 R0/R1。例如,如果我有一个为某物获取存储空间的子程序,我可能会使用 R15 来指示它是否成功(有点像 ERRNO),如果 R15 = 0,则 R0 将设置为长度,R1 将设置为对象的地址。

要点是,您可以以创造性的方式使用参数寄存器,通常 return 将结果存入几个寄存器比将结果存储在内存中的某个地方并 returning 更有效结果区的地址。

在调用的例程中

好的,假设您使用 BALR R14,R15 进入子程序。这给了我们:

  • R0/R1 将任何参数传递给子例程
  • R13 包含可用堆栈帧的地址
  • R14 将 return 地址返回给调用者
  • R15 有子程序本身的地址

您要做的是将寄存器保存在堆栈中,以便在您 return 给调用者时能够正确地恢复它们。如果您正在编写具有基址寄存器问题等的大型应用程序,您还需要在子例程内处理一些有关本地可寻址性的情况。将它们放在一起,您往往会得到类似这样的代码:

*
* Called subroutine
*
           PUSH  USING           Guarantees no "stale" USINGs here
           DROP  , 
Subrtn     CSECT ,               Allows subroutines in a different object      
           STCK  0(R13)          Saves the timestamp in the stack 
           STM   R0,R15,8(R13)   Save the caller's registers
           LA    R13,80(R13)     R13 -> next stack frame
           LR    Rx,R15          Setup a local base register
           USING Subrtn,Rx
           . . .        
           . . .                 (Subroutine does it's work here)
           . . .
SubExit    EQU   *               
*
* Return to the caller here R0/R1 have our result, R15 = return code
*
           SLR   R15,R15         (we'll assume a good return code)
           SHI   R13,80          Back up to prior stack frame
           LM    R2,R14,16(R13)  Re-load caller's R2/R14
           BR    R14             Return to the caller 
           LTORG ,               Force subroutine literal pool here

如果你需要处理大规模递归,你会添加一些指令来检查堆栈帧的结尾(以 X'FF' 开头的堆栈帧),然后有你喜欢的任何策略扩大堆栈。

如果您使用了 BRANCH AND STACK,return 会更简单一些 - 只需一个 PROGRAM RETURN 指令弹出堆栈帧和 returns 到任何地方堆栈框架已创建。

无论您做什么,程序 entry/exit 和子例程链接都可以写成 assembler 宏并在您的所有应用程序中反复重用。在我个人的技巧包中,你会发现很多这样的东西,所以当我需要写一些新东西时,我已经解决了所有这些细节问题。

一位发帖人指出了 LE assembler 宏(CEESTART 等),它们确实做了很多我正在描述的事情,但代价是一些额外的复杂性,但具有附加值可以自由调用 LE 运行时函数,就好像它们是您自己的子程序一样。关键是要尽可能少地花时间担心琐事,而尽可能多地花时间担心手头的实际任务。

显然 S/360(至少在 Z/OS 下)的通常约定很复杂,并且不使用标准调用堆栈。

但为了好玩,我会天真地回答一般 RISC 的问题(并且可能在 S/360 硬件上可能,无论它在 Z/OS 或 [=46= 下是否可行) ] S/390).


在 x86 上,push reg 等同于 sub rsp,8 / mov [rsp], reg(但不设置标志)。在 MIPS 或大多数其他 RISC 等 ISA 上,包括 S/360,您可以使用 2 条指令简单地模拟推送和弹出。

调用栈并不神奇; push 和 pop 只是窥孔优化,可以有效地 执行存储或加载以及递减或递增指针的常见操作。

理论上,如果您正在发明一种新的调用约定/ABI,您可以选择任何寄存器用作堆栈指针,除非硬件隐式使用某个寄存器(例如,用于中断处理程序)。


分支与link/间接分支再次类似于 RISC,并且是所有 RISC 的工作方式(MIPS、PowerPC、ARM 等)。在非叶函数中,简单地处理link 像任何其他调用保留寄存器一样注册,save/restore 它在堆栈上。

例如在函数条目上,为本地人保留足够的 space 并使用一个 sub 保存插槽,然后 ststm 您要保存的寄存器。在 returning 之前,重新加载任何必要的寄存器,包括 link 寄存器,然后你可以 return 正常。

查看 GCC 或类似 MIPS 的 ISA 的 clang 输出,它们也没有特定的堆栈指令(甚至对于中断处理程序也没有异步使用堆栈),因此实现调用堆栈 100% 是软件约定,不是要求ISA.