Lecture 3: 子程序与调用约定 (Repeated Code: TRAPs, Subroutines and the Call Interface Specification)

目录 · ← l2 · l4 →

Lecture 3: 子程序与调用约定 (Repeated Code: TRAPs, Subroutines and the Call Interface Specification)

概述

当同一段代码需要在程序里用很多次时,”复制粘贴”会把 bug 也一起复制,于是本讲引入子程序 (subroutine): 把公共代码放在内存里的一个位置,用 JSR/JSRR 跳进去、用 RET (JMP R7) 跳回来。 真正困难的部分不是跳转,而是跳转前后的约定——参数怎么传、结果怎么回、哪些寄存器会被改, 这就是调用接口规范 (Call Interface Specification, CIS)。本讲把 LC-3 的调用约定 (R0–R3 caller-saved、R4 全局数据指针、R5 帧指针、R6 栈指针、R7 返回地址)讲清楚, 并说明为什么它必须系统化:编译器是程序,只能机械地生成代码,而且不同编译器生成的调用者与被调用者必须能互相配合; 下一讲把这个约定落实到栈帧上,C 的函数调用则是它的直接翻译。

核心概念与底层机制图解

  • 代码复用 (Code Reuse):一段公共代码只写一次、被多处调用。
    • 直观解释:不要每间教室都装发电机,而是建一个电厂拉线过去;代价是必须约定”电压、频率、接头”。
    • 底层机制图解:LC-3 里”调用”的最原始形式是两步——把返回地址放进寄存器,然后跳过去:

      顺序代码(复制粘贴)                  子程序(复用)
      x3000  …公共代码…  (第 1 份)        x3000  JSR READNUM   ; 调用点 1
      x3010  …公共代码…  (第 2 份)        x3010  JSR READNUM   ; 调用点 2
      x3020  …公共代码…  (第 3 份)        x3050  READNUM …公共代码…
      ;改一次要改三处;占用 3×长度                  RET          ; JMP R7
                                          ;改一次只改一处;占用 1×长度
      

      LC-3 有两个”调用”指令:JSR(把指令里的 11 位偏移加到 PC 上)和 JSRR(跳到寄存器里的地址); 两者都做同一件事:把返回地址保存到 R7

    • 作用域与存储期:子程序代码与全局数据一样具有 static storage duration;而”调用”产生的是运行期的活动 (activation),其局部数据只活一次调用那么长——这就是 C 中 automatic 变量的来源。
  • JSR / JSRR / RET 的机器编码 (Encoding) 与可达范围:决定一次调用能”跳多远”。
    • 直观解释JSR 像”往前走 N 步”(步数写死在指令里),JSRR 像”照着纸条上的地址走”(地址在寄存器里)。
    • 底层机制图解

      bits  15 14 13 12 \| 11 \| 10 ......... 0
      --------------------------------------------
      JSR       0100    \|  1 \|   PCoffset11        ; R7 ← PC; PC ← PC + SEXT(PCoffset11)
      JSRR      0100    \|  0 \| 0 0 BaseR 0 0 0 0 0 0 ; R7 ← PC; PC ← BaseR
      JMP(RET)  1100    \|  0 \| 0 0 BaseR 0 0 0 0 0 0 ; PC ← BaseR
      

      PCoffset1111 位补码(−1024 .. +1023),基准是”取指后已加一的 PC”。以 .ORIG x3000 调用入口在 x4000 的子程序为例:JSR SUBx3000 出发时 PC = x3001,需要 offset = x4000 − x3001 = 4095,超过 +1023汇编器报错;改用 JSRR:

          LD    R1,SUB_ADDR   ; R1 ← M[SUB_ADDR] = x4000(地址从内存里取)
          JSRR  R1            ; R7 ← x3001(返回地址!),PC ← x4000
      SUB_ADDR .FILL SUB      ; 一个字的数据,指向远处子程序的入口
      

      关键区别JSRR 里的寄存器放的是被调用者的地址,而 R7 收到的永远是返回地址——两者方向相反。

    • 作用域与存储期PCoffset11 是编译期常量(作用域仅这一条指令),JSRR 的 BaseR 是运行期值, 可以指向任何地址,灵活性换来了可读性的下降。
  • 调用接口规范 (Call Interface Specification, CIS):一套说明”调用者与被调用者如何合作”的契约,包含四个部分。
    • 直观解释:像两个陌生人合作搬家具——事先说好从哪个门进(输入)、放到哪里(输出)、 谁不碰对方的东西(寄存器所有权)、会不会顺手把墙刷了(副作用)。
    • 底层机制图解

      CIS 的四个部分
      1. 输入 (inputs)     :参数放在哪里?(LC-3 常用 R0..R3,或放在栈上)
      2. 输出 (outputs)    :结果放在哪里?(返回寄存器/栈顶)
      3. 其它寄存器的所有权 :哪些寄存器可能被改(caller-saved),哪些保证不变(callee-saved)
      4. 副作用 (side effects):改了哪些内存、做了哪些 I/O
      
      以课程的 READNUM(readnumsub.asm)为例:
          输入 : 键盘;输出 : R0 = 读到的二进制补码数
          保存 : R1、R2、R3、R7(内部 ST/LD 保存并恢复)
          副作用: 键盘输入、回显字符、可能打印错误信息
      

      课程 MP 要求每个子程序都写下寄存器用途表并把接口写成注释——”契约”必须可检查。

    • 作用域与存储期:CIS 决定了子程序作者的”作用域”边界:只有写进接口的东西调用者才可能依赖, 其它一切(内部临时变量、是否递归)都是可随时更换的实现细节。
  • 为什么必须”系统化” (Why Systematic):编译器是程序,只能按规则生成代码;而且不同来源的代码必须能互操作。
    • 直观解释:如果每个厂家都自定插头尺寸,电器就没法互通;调用约定就是编程世界的”插座标准”。
    • 底层机制图解

      调用者编译器必须知道:参数放哪里 / 调用前保存哪些寄存器 / 返回值从哪里取
      被调用者编译器必须知道:去哪里取参数 / 结果放哪里 / 哪些寄存器必须原样返回
                        └────────► 同一份 CIS ◄────────┘
      分开编译(separate compilation):caller.c --(gcc -c)--> caller.o ┐
                                        callee.c --(gcc -c)--> callee.o ┴--(ld)--> 可执行文件
      两个 .o 文件里只有符号名与约定,没有对方的源码
      

      C 的函数声明 (prototype) 是 CIS 在语言层的表达(参数个数、类型、返回类型), 但没有说明副作用与寄存器所有权——那是 ABI 规定的。

    • 作用域与存储期:分开编译意味着”作用域”跨文件——函数名与全局变量具有外部链接 (external linkage)static 则限制在文件内,正对应子程序的”私有实现细节”。
  • LC-3 调用约定 (Calling Convention):五个位置各有专职,八个寄存器都被分配了角色。
    • 直观解释:R0–R3 是”一次性便签”(用完就扔),R4–R7 是”常设机构”(职责固定,不能乱动)。
    • 底层机制图解

      +------+---------------------+--------------------------------------------------+  寄存器文件
      \| R0   | 参数 / 返回值 / I/O  | caller-saved:被调用者可以随意改                   |
      | R1   | 参数 / 返回值 / 临时  | caller-saved                                     |
      | R2   | 参数 / 返回值 / 临时  | caller-saved                                     |
      | R3   | 参数 / 返回值 / 临时  | caller-saved                                     |
      | R4   | 全局数据指针         | 约定要求保持:指向全局数据区(本课约定 x4000 起)    |
      | R5   | 帧指针 (frame ptr)   | 由帧机制恢复:指向当前栈帧(第 4 讲详解)            |
      | R6   | 栈指针 (stack ptr)   | 由帧机制恢复:指向栈顶(第 4 讲详解)               |
      | R7   | 返回地址             | **永远 caller-saved**:JSR/JSRR/TRAP 都会覆盖它    |
      +------+---------------------+--------------------------------------------------+
      

      R7 特殊在哪里:它不是”可能被改”而是”一定会被改”(JSR 的语义就是 R7 ← PC), 所以子程序无法为调用者保留 R7;调用者若在调用后还需要 R7,必须在调用前把它存到内存或栈里。 注意(课程 MT1 复习的原话):LC-3 在编译器层面没有 callee-saved 寄存器——”Callee-saved registers at the start and end of the function (none in LC-3)”; 上表”约定要求保持”只是说 R4–R6 的角色不随调用改变,具体保存/恢复仍必须由代码自己写出(第 4 讲的帧机制负责 R5/R6)。

    • 作用域与存储期:R4–R7 的”角色”在整个程序运行期不变,R0–R3 的值只保证到下一次调用为止——这就是”caller-saved”这个名字的含义。
  • 寄存器保存纪律 (Register Saving Discipline):谁负责保存,取决于寄存器是 caller-saved 还是 callee-saved。
    • 直观解释:借用规则只有两条——”易耗品你自己备份”(caller-saved),”借了家具要还回来”(callee-saved)。
    • 底层机制图解:ECE 220 的两种保存方式(第 4 讲会升级为压栈):

      方式 A:调用者保存(把 R0..R3 存进自己的内存槽)
          ST   R1,SAVE_R1        ; 调用前:子程序可能改 R1,先存起来
          JSR  SUB
          LD   R1,SAVE_R1        ; 调用后恢复;子程序完全不用管 R1
      方式 B:被调用者保存(子程序承诺不改某些寄存器)
          SUB  ST   R7,SUB_R7    ; JSR/TRAP 覆盖了 R7,先存起来
               ST   R2,SUB_R2 / LD R2,SUB_R2   ; 声明并兑现"R2 是 callee-saved"
               LD   R7,SUB_R7
               RET
      SAVE_R1  .BLKW 1           ; 注意:.BLKW 不初始化,内容是垃圾
      

      选择原则:用得多的寄存器让被调用者保存(如 R4–R7 这类”常设”寄存器), 只在少数调用点用到的让调用者保存,可以省掉大量无谓的存取。

    • 作用域与存储期:被保存的值具有”调用期间”的存储期;ECE 220 里它们存在固定的 .BLKW 槽,子程序一旦可重入或递归就会互相覆盖——所以第 4 讲必须把它们放进栈帧。
  • 特权、陷阱与库 (Privilege, Traps and Libraries):系统服务也是子程序,只是多一层保护。
    • 直观解释:普通程序像访客只能走前台(TRAP);操作系统像内部员工,可以进机房(I/O 寄存器)。
    • 底层机制图解

      保护的三层含义:1. 保护硬件(写错 BRz 可能让设备失效)2. 保护用户(进程不能互踩内存)
                      3. 保护系统(内核代码不能被随意改写)
      实现手段:特权级 (PSR[15]);0 = privileged,1 = unprivileged。
      TRAP = 子程序调用 + 特权切换(概念上):
        用户代码 --TRAP x21--> 陷阱向量表 --> 服务子程序(特权) --RET--> 用户代码
      陷阱 vs 中断:陷阱**同步**(程序主动请求),中断**异步**(设备主动发起);中断时被中断的代码毫无准备,
        因此**所有寄存器(包括 R7)都必须是 callee-saved**,由处理程序保存全部状态并用 RTI 返回。
      
    • 作用域与存储期:特权级决定了同一条 STI R0,DDR合法性;”库”隐藏实现、只暴露 CIS。
  • 与 C 的对应 (Connection to C):C 的函数 = 子程序 + 调用约定 + 类型信息。
    • 直观解释int32_t f(int32_t x); 是门面,门后面是 R0 放参数、JSR 进去、R0 取结果。
    • 底层机制图解

      C 概念                  对应的底层机制
      函数声明 (prototype)     CIS:参数/返回值的类型与个数(ABI 补充寄存器所有权)
      函数定义                 一段以 RET 结束的指令序列
      局部变量 (automatic)     被调用者的栈帧(第 4 讲)
      全局/static 变量        R4 指向的全局数据区
      return 语句             把值放进约定位置(LC-3 上是 R0 / 栈顶),再 RET
      递归                     每次调用都要有**独立的**帧(第 4 讲)
      

      教学目标 3 要求”理解栈抽象与调用约定在调用者与被调用者之间传递信息的作用”:本讲给出约定,下讲给出载体。

    • 作用域与存储期:C 的 scope 决定”名字能否被看到”、storage duration 决定”数据活多久”, 两者最终都由 CIS + 栈帧实现。

代码示例与底层机制分析

示例 1:写一个真正的子程序 PRINT_BIN16(含完整调用者与被调用者)

代码 (LC-3 assembly):

; ---------------------------------------------------------------
; PRINT_BIN16 -- 把 R0 的低 16 位按二进制打印到显示器
; 调用接口规范 (CIS)
;   输入: R0 = 要打印的值    输出: 无(写 16 个 ASCII '0'/'1' 到显示器)
;   改变: 无(R1/R2/R3 由子程序保存恢复;R0 返回时被破坏)
;   保存: R7(本子程序用 TRAP x21,TRAP 会覆盖 R7)
;   副作用: 向显示器输出 16 个字符
; 说明:本讲用固定 .BLKW 槽保存寄存器;第 4 讲改用栈帧。
; ---------------------------------------------------------------

        .ORIG x3000
        LD   R1,KEEP_ME      ; x3000: 调用者的活数据,必须活过两次调用
        LD   R0,VALUE_A      ; x3001: 参数 1
        JSR  PRINT_BIN16     ; x3002: 第一次调用
        LD   R0,VALUE_B      ; x3003: 参数 2
        JSR  PRINT_BIN16     ; x3004: 第二次调用
        HALT                 ; x3005

PRINT_BIN16                  ; x3006: 被调用者从这里开始
        ST   R7,PB_R7        ; x3006: TRAP 会覆盖 R7,先保存
        ST   R1,PB_R1        ; x3007: 保存 R1(本子程序承诺不改 R1)
        ST   R2,PB_R2        ; x3008: 保存 R2
        ST   R3,PB_R3        ; x3009: 保存 R3

        LD   R3,PB_ZERO      ; x300A: R3 ← x30 = ASCII '0'
        AND  R1,R1,#0        ; x300B: R1 ← 0
        ADD  R1,R1,#15       ; x300C: R1 ← 15(位下标,从最高位开始)

PB_LOOP ADD  R2,R3,#0        ; x300D: R2 ← '0'
        ADD  R0,R0,#0        ; x300E: 刷新 N/Z/P,用 N 检查 bit 15
        BRzp PB_SKIP         ; x300F: bit 15 是 0 就跳过
        ADD  R2,R2,#1        ; x3010: 否则 R2 ← '1'
PB_SKIP ADD  R0,R0,R0        ; x3011: 左移一位,把下一位送到 bit 15
        ST   R0,PB_R0        ; x3012: OUT 要用 R0,先把移位结果存起来
        ADD  R0,R2,#0        ; x3013: 把要打印的字符放进 R0
        OUT                  ; x3014: TRAP x21,输出 R0[7:0]
        LD   R0,PB_R0        ; x3015: 取回移位结果
        ADD  R1,R1,#-1       ; x3016: 位下标减一
        BRzp PB_LOOP         ; x3017: 还有位就继续

        LD   R3,PB_R3        ; x3018: 恢复调用者的寄存器
        LD   R2,PB_R2        ; x3019
        LD   R1,PB_R1        ; x301A
        LD   R7,PB_R7        ; x301B: 恢复返回地址
        RET                  ; x301C: JMP R7

; ---- 子程序的私有数据(.BLKW 不初始化,第一次调用前内容是垃圾)----
PB_R0   .BLKW 1              ; x301D
PB_R1   .BLKW 1              ; x301E
PB_R2   .BLKW 1              ; x301F
PB_R3   .BLKW 1              ; x3020
PB_R7   .BLKW 1              ; x3021
PB_ZERO .FILL x30            ; x3022: ASCII '0'
; ---- 调用者的数据 ----
KEEP_ME .FILL #7             ; x3023
VALUE_A .FILL xABCD          ; x3024
VALUE_B .FILL xBC3D          ; x3025
        .END

【代码做什么?】

  1. 调用者先把”必须活过调用”的 KEEP_ME 装进 R1,然后装参数、JSRJSR 做两件事:R7 ← PC(返回地址),PC ← PRINT_BIN16(第 1 次 R7 = x3003,第 2 次 R7 = x3005)。
  2. 被调用者先保存 R7 与 R1–R3(它承诺不改 R1–R3),再进行计算。
  3. 循环 16 次:用 ADD R0,R0,#0 把 bit 15 送进 N,选 '0''1',左移,输出;每次输出前把移位结果 存进 PB_R0,因为 OUT(TRAP x21)会消费 R0;循环结束后恢复 R3、R2、R1、R7,RET 回到调用点。

【底层机制透视】

  • R7 的保存位置:本讲把 R7 存进 .BLKW 槽,这在”不递归、不重入”时够用,但两次调用会互相覆盖同一槽; 递归时必须把保存区搬到栈帧里(第 4 讲)。同理 .BLKW 不初始化,第一次调用前内容是垃圾—— 本程序总是”先 ST 再 LD”,所以安全;若直接 LDR 去读它就会拿到随机值。
  • caller-saved 的实际后果:子程序结束时 R0 里是最后一次 OUT 的字符,调用者的原参数已经丢了; 这对本程序无害(每次都重新 LD R0,VALUE_x),但若调用者以为 R0 还是参数,就会写出难查的 bug。
  • 为什么 ST R0,PB_R0 是必需的OUT 需要 R0 持有字符,而 R0 同时承担”待打印数值”的角色—— 这是 LC-3 只有 8 个寄存器时的典型权衡:用一次内存访问换取一个寄存器

【内存布局图解】

地址      内容                        说明
x3000/x3001  LD R1,KEEP_ME / LD R0,VALUE_A  (off=+34)  调用者:装入活数据与参数
x3002     JSR PRINT_BIN16 (off=+3)    第一次调用的返回地址 = x3003
x3003/x3004  LD R0,VALUE_B / JSR (off=+1)  第二次调用的返回地址 = x3005
x3006     ST R7,PB_R7     (off=+26)   ← 被调用者从这里开始(x3006..x301C 为 16 条循环指令)
x301C     RET  (JMP R7)
x301D..x3021  PB_R0..PB_R7  .BLKW 1 ×5  子程序私有存储(第一次调用前是垃圾值)
x3022     PB_ZERO x0030
x3023..x3025  KEEP_ME #7 / VALUE_A xABCD / VALUE_B xBC3D   调用者的数据(code/global data 区)

两次调用期间 PB_R7 的变化:第 1 次 PB_R7 ← x3003(返回 x3003),第 2 次 PB_R7 ← x3005(返回 x3005)

【与汇编的对应】(逐条手算两次调用的关键状态)

时刻R0R1R2R3R7说明
执行第 1 次 JSR 前 / 进入子程序后xABCD#7??x3003参数就位;JSR 写入返回地址
ADD R1,R1,#15xABCD#15x30x3003计数 15,P=1
第 1 次循环迭代x579A#14x31x30x3003打印 1(xABCD 的 bit15);16 次后 x0000 / #−1
恢复 + RET 之后x0000#7原值原值x3003R1 被恢复(承诺兑现),PC ← x3003
第 2 次调用返回后x0000#7原值原值x3005PC ← x3005(HALT

两次调用在显示器上输出的字符串依次是 1010101111001101(= xABCD)与 1011110000111101(= xBC3D); R1 中的 KEEP_ME = 7 在两次调用后仍然等于 7,这就是”callee-saved”承诺的实际含义。

示例 2:C 里”看得见”的调用约定(把寄存器模拟成变量)

代码 (C):

/* ECE 220 -- Lecture 3 example: the call interface specification, in C.
 *
 * The five file-scope variables stand in for LC-3 registers so that we can
 * literally watch a callee clobber a caller-saved register.
 */
#include <stdio.h>
#include <stdint.h>

static int32_t R0;   /* input / return value : caller-saved */
static int32_t R1;   /* input                : caller-saved */
static int32_t R2;   /* temporary            : caller-saved */
static int32_t R3;   /* temporary            : CALLEE-saved */
static int32_t R7;   /* return address       : always caller-saved */

/* PRINT_BIN16
 *   input : R0 (value to print, low 16 bits)
 *   output: none (writes 16 ASCII digits and a linefeed to the display)
 *   changes      : R2 (caller-saved)
 *   preserves    : R3 (callee-saved, saved and restored here)
 *   side effects : writes to the display
 */
static void PRINT_BIN16(void)
{
    int32_t saved_R3 = R3;      /* the callee keeps its promise here */
    int32_t i;

    R3 = R0;                    /* work on a copy inside the callee   */
    for (i = 15; i >= 0; i = i - 1) {
        R2 = '0';               /* R2 is caller-saved: free to clobber */
        if (((R3 >> i) & 1) != 0) {
            R2 = R2 + 1;        /* '0' + 1 == '1' */
        }
        putchar(R2);            /* "STI R2,DDR" */
    }
    putchar('\n');
    R3 = saved_R3;              /* restore the callee-saved register */
    return;                     /* "RET" (JMP R7) */
}

int main(void)
{
    R0 = 0xABCD;    /* same value as NUMBER in printbinary.asm */
    R1 = 0;         /* the caller's live values ... */
    R2 = 42;
    R3 = 7;
    R7 = 0;

    printf("before the call : R2 = %d, R3 = %d\n", (int)R2, (int)R3);

    /* the caller sets up the inputs, then JSRs */
    printf("PRINT_BIN16(x%04X) = ", (unsigned)(R0 & 0xFFFF));
    PRINT_BIN16();

    printf("after the call  : R2 = %d (clobbered), R3 = %d (preserved)\n",
           (int)R2, (int)R3);
    return 0;
}

编译与运行:

$ gcc -g -std=c99 -Wall -Werror ece220_l03_printbin.c -o ece220_l03_printbin
$ ./ece220_l03_printbin
before the call : R2 = 42, R3 = 7
PRINT_BIN16(xABCD) = 1010101111001101
after the call  : R2 = 49 (clobbered), R3 = 7 (preserved)

【代码做什么?】

  1. 用五个文件作用域变量模拟 LC-3 的 R0、R1、R2、R3、R7,使”寄存器纪律”变成可打印的量。
  2. main 充当调用者:准备好 R0(参数)与 R2/R3(活数据),打印调用前状态,然后”JSR”。
  3. PRINT_BIN16 用 R3 做工作副本、用 R2 承载 '0'/'1',循环 16 次输出;返回前把 R3 恢复为入口值。 结果:R2 从 42 变成 49('1' 的 ASCII 码),R3 仍是 7——caller-saved 被改,callee-saved 保住

【底层机制透视】

  • 打印的 1010101111001101 与示例 1 中 LC-3 版 PRINT_BIN16 的输出逐位相同(算法都是”从 bit 15 开始、每次左移一位”); R2 = R2 + 1'0' (x30) 变成 '1' (x31),与 ADD R2,R2,#1 完全对应。
  • 在这个模拟里,”保存”表现为 C 的局部变量 saved_R3——它就是编译器在栈帧上开辟的槽,与 LC-3 的 .BLKW 槽同源。 真实的 C 编译器不会这样使用全局变量:它把参数放进寄存器(x86-64 上是 rdirsi…), 把 callee-saved 寄存器(rbxrbpr12r15)压栈保存——结构一模一样,只是名字不同。

【内存布局图解】

全局数据区(模拟"寄存器文件")        PRINT_BIN16 的栈帧(automatic 存储期)
+---------------+  0x404040 附近     +-------------------+  0x7ffd....(高地址)
| R0 = 0        | ← 参数/返回值       | 保存的返回地址      |
| R1 = 0        |                    +-------------------+
| R2 = 49       | ← 被改成 '1'        | saved_R3 = 7      | ← callee-saved 存这里
| R3 = 7        | ← 仍是 7(被恢复)   | i                 |
| R7 = 0        |                    +-------------------+  0x7ffd....(低地址)
+---------------+

【与汇编的对应】

; C: int32_t saved_R3 = R3;      —— 被调用者的第一件事:保存 callee-saved 寄存器
        ST   R3,PB_SAVE_R3      ; 用内存槽(第 4 讲改为压栈)
; C: R3 = R0;   R2 = '0';  if (bit) R2 = R2 + 1;
        ADD  R3,R0,#0           ; 在工作副本上做位运算
        LD   R4,ZERO_CH         ; R4 ← x30(ASCII '0'),充当常量寄存器
        ADD  R2,R4,#0           ; R2 ← '0'
        ADD  R3,R3,#0           ; 刷新 N/Z/P:bit 15 决定 N
        BRzp SKIP
        ADD  R2,R2,#1           ; R2 ← '1'
SKIP    ; ... 输出 R2(OUT),然后 ADD R3,R3,R3 左移、计数减一,循环 16 次 ...
ZERO_CH .FILL x30               ; ASCII '0'
; C: R3 = saved_R3;  return;
        LD   R3,PB_SAVE_R3      ; 兑现"不改 R3"的承诺
        RET                     ; JMP R7

示例 3:分开编译——”约定”是唯一的纽带

代码 1 (C, 调用者 ece220_l03_caller.c):

/* ECE 220 -- Lecture 3, part 1 of the separate-compilation demo: the CALLER.
 * It knows the callee only through the two declarations below. */
#include <stdio.h>
#include <stdint.h>

void    print_bin16(uint16_t value);   /* the call interface */
int32_t read_number(int32_t *out);

int main(void)
{
    int32_t a = 0;
    int32_t b = 0;
    int64_t product;
    int32_t conversions;

    printf("Enter two integers: ");
    conversions  = read_number(&a);
    conversions += read_number(&b);
    if (conversions != 2) {
        printf("Bad input!\n");
        return 1;
    }

    product = (int64_t)a * (int64_t)b;
    printf("a     = %6d = ", (int)a);
    print_bin16((uint16_t)a);
    printf("\nb     = %6d = ", (int)b);
    print_bin16((uint16_t)b);
    printf("\na * b = %6d = ", (int)(product & 0xFFFF));
    print_bin16((uint16_t)(product & 0xFFFF));
    printf("\n");
    return 0;
}

代码 2 (C, 被调用者 ece220_l03_lib.c):

/* ECE 220 -- Lecture 3, part 2 of the separate-compilation demo: the CALLEE.
 * Everything the caller may rely on is in the two comments below. */
#include <stdio.h>
#include <stdint.h>

/* input: value; output: none; writes 16 ASCII '0'/'1' and a linefeed */
void print_bin16(uint16_t value)
{
    int32_t i;

    for (i = 15; i >= 0; i = i - 1) {
        if (((value >> i) & 1u) != 0u) {
            putchar('1');
        } else {
            putchar('0');
        }
    }
    return;
}

/* input: out; output: number of successful conversions (like scanf) */
int32_t read_number(int32_t *out)
{
    int n;

    if (out == NULL) {
        return -1;
    }
    n = scanf("%d", out);
    return (int32_t)n;
}

分开编译与链接(本机真实命令与输出):

$ gcc -g -std=c99 -Wall -Werror -c ece220_l03_lib.c -o ece220_l03_lib.o
$ gcc -g -std=c99 -Wall -Werror -c ece220_l03_caller.c -o ece220_l03_caller.o
$ gcc -g -std=c99 -Wall -Werror ece220_l03_caller.o ece220_l03_lib.o -o ece220_l03_cis
$ printf '43981 5\n' | ./ece220_l03_cis
Enter two integers: a     =  43981 = 1010101111001101
b     =      5 = 0000000000000101
a * b =  23297 = 0101101100000001

(也可以一条命令完成:gcc -g -std=c99 -Wall -Werror ece220_l03_caller.c ece220_l03_lib.c -o ece220_l03_cis。)

【代码做什么?】

  1. caller.clib.c 各自单独编译成 .o:编译 caller.c 时只看到两行声明,完全不知道实现; 编译 lib.c 时完全不知道谁会用这两个函数。
  2. 链接器只做一件事:把 caller.o 里对 print_bin16 的”未定义引用”接到 lib.o 里的同名符号上。
  3. 运行时 main 调用 read_number 两次读入 43981 与 5,再三次调用 print_bin16; 输出验证:43981 = xABCD101010111100110150000000000000101, 乘积 xABCD * 5 = x15B01 截断到 16 位得 x5B010101101100000001

【底层机制透视】

  • 这就是”为什么 CIS 必须系统化”的物证:两个翻译单元之间没有共享的源码或上下文,唯一的沟通渠道是 符号名 + 调用约定;任何一方偷偷改变约定(例如把参数从 R0 改到 R2),编译器都不会报错,运行时才崩溃。
  • 链接器只检查符号名与存储类别,不检查参数个数/类型——写错声明时 -Wall -Werror 才是真正的守门人。
  • 编译期看到的”声明”与运行期发生的”寄存器/栈操作”是 CIS 的两层:文字形式物理形式(uint16_t)(product & 0xFFFF) 则显式表达了 LC-3 中 16 位寄存器的天然截断行为。

【内存布局图解】

     caller.o                            lib.o
+-----------------------+          +-----------------------+
| .text: main           |          | .text: print_bin16    |
|  U print_bin16  ──────┼──┐       |  T print_bin16        |
|  U read_number  ──────┼──┤       |  T read_number        |
+-----------------------+  │       +-----------------------+
                    └──────┴──────┘  ld(链接器):把符号引用接到定义上
                                  ↓
              ece220_l03_cis(两个 .text 段拼接,调用点填上真实地址)

【与汇编的对应】

; C: print_bin16((uint16_t)a);        —— 调用者的四个动作
        LDR  R0,R5,#0        ; ① 取出实参 a
        ADD  R6,R6,#-1       ; ② 压参数(第 4 讲的帧布局)
        STR  R0,R6,#0
        JSR  PRINT_BIN16     ; ③ 调用(R7 ← 返回地址)
        ADD  R6,R6,#2        ; ④ 弹掉返回值与参数
; 被调用者的入口:把自己承诺的寄存器存起来
PRINT_BIN16
        ST   R7,PB_R7        ; JSR/TRAP 会覆盖 R7,必须先保存
        ST   R3,PB_R3        ; R3 在 CIS 里声明为"不改",所以必须保存
        ...                  ; (R2 是 caller-saved,可以随便用)
        RET

常见错误与调试技巧

  • 忘记保存 R7:子程序里用了 JSR/TRAP(包括 OUTPUTS),返回地址被覆盖,RET 跳错地方。 现象是回到调用点附近的随机位置或死循环。调试:在子程序入口/出口打印或观察 R7; gdb 中用 bt 看调用栈是否合理展开;lc3sim 中对 RET 设断点,检查 R7 的值是否等于预期的返回地址。
  • 破坏 callee-saved 寄存器:CIS 里承诺”不改 R1”却用了 R1 当临时,调用者的数据被悄悄改掉,现象是 “某个变量莫名其妙变了”。调试gdb 中对可疑变量 watch var,断下后 bt 看是谁改的; LC-3 中给子程序出口设断点,逐一 print R1print R4 与调用前对比。
  • JSR 调用太远的子程序:目标超过 PCoffset11 的 ±1024 范围,汇编器报 offset 超范围。调试: 改成”先 LD R1,SUB_ADDRJSRR R1“,确认 SUB_ADDR .FILL SUB 的地址正确;用 lc3simlist 核对地址。
  • JSRR 的方向记反:以为 JSRR R1 会把子程序地址写进 R7。实际上 R7 得到的是返回地址, R1 必须事先装有子程序入口地址。现象是 PC 跳到垃圾地址。调试:单步执行 JSRR 前后 print R1print R7
  • 调用者假设 R0–R3 在调用后不变:把循环计数放在 R2 里,调用一个”会改 R2”的子程序后计数被破坏, 现象是循环次数错误或死循环。调试gcc -S 看编译器如何在调用前后保存/恢复寄存器;gdbinfo registers 对比。
  • 接口文档缺失或不一致:写了”输入 R0”却在代码里从 R1 取参数,返回值放在 R0 却让调用者去读栈; 现象是”单独测都对、连起来就错”。调试:给每个子程序写完整 CIS 注释(四个部分); 用示例 3 的分开编译强制自己只依赖声明,用 nm ece220_l03_caller.o 检查未定义符号都能在 lib.o 里找到。

关键要点

  • 子程序解决”写一次、用多次”的问题,代价是必须约定接口;LC-3 的调用机制是 JSR(11 位偏移)与 JSRR(寄存器地址), 两者都执行 R7 ← PC
  • CIS 有四个部分:输入、输出、其它寄存器的所有权、副作用——缺任何一部分都无法安全协作。
  • LC-3 调用约定:R0–R3 caller-saved(参数/返回值),R4 全局数据指针,R5 帧指针,R6 栈指针,R7 返回地址; R7 永远是 caller-saved,因为 JSR/JSRR/TRAP 必然覆盖它。
  • 约定必须系统化:编译器只能机械生成代码,分开编译的目标文件之间只有”符号 + 约定”这一条纽带。
  • 系统调用(TRAP)是”带特权的子程序”;中断处理程序要求所有寄存器都是 callee-saved(被中断的代码毫无准备)。

思考题(带答案)

问题 1:从 x3000 开始的程序要用 JSR 调用位于 x4000 的子程序,为什么汇编器会报错?请给出两种替代方案与代价。

答案JSR 用 11 位补码偏移,基准是”取指后已加一的 PC”:调用点 x3000PC = x3001, 到 x4000 需要 offset = 4095,远超 +1023,所以汇编器拒绝。替代方案: (1) JSRRLD R1,SUB_ADDR + JSRR R1SUB_ADDR .FILL SUB)——代价是多一条指令与一个字的数据,R1 被占用; (2) 中继跳转 (trampoline):在 JSR 可达范围内放一个”入口转发”子程序,它再用 JSRR 跳到远处—— 代价是多一次调用(R7 要在转发处保存)。LC-3 的 11 位偏移是”指令字只有 16 位”的必然结果。

问题 2:下面这段被调用者的 CIS 注释有什么问题?会给调用者带来什么后果?

/*  输入 : R0
 *  输出 : R0
 *  说明 : 内部会使用 R1..R4 作为临时寄存器
 */

答案:它只列出了输入/输出,却把”R1..R4 会被改”写成实现说明而不是所有权声明R4 在 LC-3 约定里是 全局数据指针(callee-saved),一旦被改,调用者后续所有通过 R4 访问全局变量的代码都会用到错误地址; R1–R3 虽是 caller-saved,调用者也必须知道”它们会被改”才能决定是否提前保存。正确写法是 “输入 R0;输出 R0;改变 R1/R2/R3(caller-saved,调用者若需要请自行保存);R4 保持不变(若确实要用必须保存并恢复); 副作用:无”——把所有权写清楚,而不是藏在实现说明里。

问题 3JSRTRAPRTI 三者都会改变 PC 与 R7(或更多状态),各对应什么场景?为什么中断处理必须保存全部状态?

答案JSR普通子程序调用(程序主动发起,只覆盖 R7);TRAP系统调用,同样是程序主动发起的 同步事件,语义与子程序调用一致(真实机器上还会提升特权级);RTI中断返回,用于异步事件。 中断发生时被中断的代码完全不知道这一刻会发生什么,因此处理程序必须保存所有寄存器(含 R7)与条件码 (在这个意义上”所有寄存器都是 callee-saved”),并用 RTI 一次性恢复状态;而陷阱是程序自己发出的请求, 调用点清楚”接下来会发生什么”,只需遵守普通调用约定。这正是”同步 vs 异步”在机器层的差别。