Lecture 3: 子程序与调用约定 (Repeated Code: TRAPs, Subroutines and the Call Interface Specification)
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 ← BaseRPCoffset11是 11 位补码(−1024 .. +1023),基准是”取指后已加一的 PC”。以.ORIG x3000调用入口在x4000的子程序为例:JSR SUB从x3000出发时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
【代码做什么?】
- 调用者先把”必须活过调用”的
KEEP_ME装进 R1,然后装参数、JSR;JSR做两件事:R7 ← PC(返回地址),PC ← PRINT_BIN16(第 1 次R7 = x3003,第 2 次R7 = x3005)。 - 被调用者先保存 R7 与 R1–R3(它承诺不改 R1–R3),再进行计算。
- 循环 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)
【与汇编的对应】(逐条手算两次调用的关键状态)
| 时刻 | R0 | R1 | R2 | R3 | R7 | 说明 |
|---|---|---|---|---|---|---|
执行第 1 次 JSR 前 / 进入子程序后 | xABCD | #7 | ? | ? | x3003 | 参数就位;JSR 写入返回地址 |
ADD R1,R1,#15 后 | xABCD | #15 | — | x30 | x3003 | 计数 15,P=1 |
| 第 1 次循环迭代 | x579A | #14 | x31 | x30 | x3003 | 打印 1(xABCD 的 bit15);16 次后 x0000 / #−1 |
恢复 + RET 之后 | x0000 | #7 | 原值 | 原值 | x3003 | R1 被恢复(承诺兑现),PC ← x3003 |
| 第 2 次调用返回后 | x0000 | #7 | 原值 | 原值 | x3005 | PC ← 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)
【代码做什么?】
- 用五个文件作用域变量模拟 LC-3 的 R0、R1、R2、R3、R7,使”寄存器纪律”变成可打印的量。
main充当调用者:准备好 R0(参数)与 R2/R3(活数据),打印调用前状态,然后”JSR”。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 上是rdi、rsi…), 把 callee-saved 寄存器(rbx、rbp、r12–r15)压栈保存——结构一模一样,只是名字不同。
【内存布局图解】
全局数据区(模拟"寄存器文件") 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。)
【代码做什么?】
caller.c与lib.c各自单独编译成.o:编译caller.c时只看到两行声明,完全不知道实现; 编译lib.c时完全不知道谁会用这两个函数。- 链接器只做一件事:把
caller.o里对print_bin16的”未定义引用”接到lib.o里的同名符号上。 - 运行时
main调用read_number两次读入 43981 与 5,再三次调用print_bin16; 输出验证:43981 = xABCD→1010101111001101,5→0000000000000101, 乘积xABCD * 5 = x15B01截断到 16 位得x5B01→0101101100000001。
【底层机制透视】
- 这就是”为什么 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(包括OUT、PUTS),返回地址被覆盖,RET跳错地方。 现象是回到调用点附近的随机位置或死循环。调试:在子程序入口/出口打印或观察 R7;gdb中用bt看调用栈是否合理展开;lc3sim中对RET设断点,检查 R7 的值是否等于预期的返回地址。 - 破坏 callee-saved 寄存器:CIS 里承诺”不改 R1”却用了 R1 当临时,调用者的数据被悄悄改掉,现象是 “某个变量莫名其妙变了”。调试:
gdb中对可疑变量watch var,断下后bt看是谁改的; LC-3 中给子程序出口设断点,逐一print R1…print R4与调用前对比。 - 用
JSR调用太远的子程序:目标超过PCoffset11的 ±1024 范围,汇编器报 offset 超范围。调试: 改成”先LD R1,SUB_ADDR再JSRR R1“,确认SUB_ADDR .FILL SUB的地址正确;用lc3sim的list核对地址。 - 把
JSRR的方向记反:以为JSRR R1会把子程序地址写进 R7。实际上 R7 得到的是返回地址, R1 必须事先装有子程序入口地址。现象是 PC 跳到垃圾地址。调试:单步执行JSRR前后print R1与print R7。 - 调用者假设 R0–R3 在调用后不变:把循环计数放在 R2 里,调用一个”会改 R2”的子程序后计数被破坏, 现象是循环次数错误或死循环。调试:
gcc -S看编译器如何在调用前后保存/恢复寄存器;gdb里info registers对比。 - 接口文档缺失或不一致:写了”输入 R0”却在代码里从 R1 取参数,返回值放在 R0 却让调用者去读栈; 现象是”单独测都对、连起来就错”。调试:给每个子程序写完整 CIS 注释(四个部分); 用示例 3 的分开编译强制自己只依赖声明,用
nm ece220_l03_caller.o检查未定义符号都能在lib.o里找到。
关键要点
- 子程序解决”写一次、用多次”的问题,代价是必须约定接口;LC-3 的调用机制是
JSR(11 位偏移)与JSRR(寄存器地址), 两者都执行R7 ← PC。 - CIS 有四个部分:输入、输出、其它寄存器的所有权、副作用——缺任何一部分都无法安全协作。
- LC-3 调用约定:
R0–R3caller-saved(参数/返回值),R4全局数据指针,R5帧指针,R6栈指针,R7返回地址; R7 永远是 caller-saved,因为JSR/JSRR/TRAP必然覆盖它。 - 约定必须系统化:编译器只能机械生成代码,分开编译的目标文件之间只有”符号 + 约定”这一条纽带。
- 系统调用(TRAP)是”带特权的子程序”;中断处理程序要求所有寄存器都是 callee-saved(被中断的代码毫无准备)。
思考题(带答案)
问题 1:从 x3000 开始的程序要用 JSR 调用位于 x4000 的子程序,为什么汇编器会报错?请给出两种替代方案与代价。
答案:JSR 用 11 位补码偏移,基准是”取指后已加一的 PC”:调用点 x3000 处 PC = x3001, 到 x4000 需要 offset = 4095,远超 +1023,所以汇编器拒绝。替代方案: (1) JSRR:LD R1,SUB_ADDR + JSRR R1(SUB_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 保持不变(若确实要用必须保存并恢复); 副作用:无”——把所有权写清楚,而不是藏在实现说明里。
问题 3:JSR、TRAP、RTI 三者都会改变 PC 与 R7(或更多状态),各对应什么场景?为什么中断处理必须保存全部状态?
答案:JSR 是普通子程序调用(程序主动发起,只覆盖 R7);TRAP 是系统调用,同样是程序主动发起的 同步事件,语义与子程序调用一致(真实机器上还会提升特权级);RTI 是中断返回,用于异步事件。 中断发生时被中断的代码完全不知道这一刻会发生什么,因此处理程序必须保存所有寄存器(含 R7)与条件码 (在这个意义上”所有寄存器都是 callee-saved”),并用 RTI 一次性恢复状态;而陷阱是程序自己发出的请求, 调用点清楚”接下来会发生什么”,只需遵守普通调用约定。这正是”同步 vs 异步”在机器层的差别。
