Lecture 2: 内存映射 I/O 与 TRAP (Memory-Mapped I/O: Input from the Keyboard, Output to the Monitor; TRAPs)
Lecture 2: 内存映射 I/O 与 TRAP (Memory-Mapped I/O: Input from the Keyboard, Output to the Monitor; TRAPs)
概述
本讲解决一个在 ECE 120 里被回避的问题:处理器如何与外部世界交换数据;LC-3 的答案是 内存映射 I/O (memory-mapped I/O)——把设备寄存器放在内存地址空间的最高端,用普通 LDI/STI 访问, 再用状态位 (status bit) 做同步(握手)。为了避免每个程序都手写轮询循环,LC-3 提供了 TRAP 指令与陷阱向量表 (trap vector table),把 GETC/OUT/PUTS/IN/HALT 等系统服务做成”带特权切换的子程序调用”; 理解 TRAP 是理解 C 的 printf/scanf、系统调用 (system call) 与特权级 (privilege) 的第一步。 本讲的所有机制在第 3 讲会被重新解释为”子程序 + 调用约定”的一个特例。
核心概念与底层机制图解
- 内存映射 I/O (Memory-Mapped I/O):把 I/O 设备的寄存器编入内存地址空间,用装载/存储指令访问它们。
- 直观解释:给设备也发一个”门牌号”,只不过这些门牌号集中在城市最北边;你不需要新的动词(新指令), 只需要知道”这个门牌号后面不是仓库,而是海关窗口”。
底层机制图解:访问 I/O 寄存器只有两种方案——
方案 1:新增指令与独立地址空间(端口 I/O) IN R0, KBDR / OUT DDR, R0 ;x86 的 IN/OUT 属于这一类,现代 ISA 中很少见 方案 2:内存映射 I/O(LC-3 采用,大多数现代 ISA 也采用) LDI R0, KBDR_ADDR / STI R0, DDR_ADDR ;用普通的存储访问指令即可 LC-3 的地址分配:xFE00 ~ xFFFF 属于 I/O(占全部 128K 字的 1/128) xFE00 KBSR xFE02 KBDR xFE04 DSR xFE06 DDR xFE08 ~ xFFFF 预留给未来的设备为什么地址是隔一个用:LC-3 本身是字可寻址的,而 P&P 定义的 LC-3b 是字节可寻址的; 每对寄存器之间留一个地址,就能让同一份 I/O 地址映射在两种机器上通用。 硬件侧:地址控制逻辑 (address control logic) 根据 MAR 与
MIO.EN、R.W决定是选中内存芯片, 还是把 KBSR/KBDR/DSR 的值送上 MDR;读 KBSR 时MAR=xFE00, R.W=read, MIO.EN=1。- 作用域与存储期:这些”变量”作用域全局(任何有权限的代码都访问同一份设备状态), 存储期等于机器通电期——不随程序启动/结束创建或销毁,进程退出也不清零。这是”外设状态”与”程序变量”最根本的区别。
- KBSR / KBDR:键盘寄存器对:键盘这一侧用两个 16 位寄存器分别承载”状态”和”数据”。
- 直观解释:一个负责举手(状态位),一个负责说话(数据);举手之后才有人听你说话。
底层机制图解:
KBDR (xFE02) —— 键盘数据寄存器 15 ................ 8 \| 7 .......................... 0 未使用(读为 0) \| 扩展 ASCII (8 bit) 字符,如 'A' = x41 KBSR (xFE00) —— 键盘状态寄存器 15 \| 14 ............................................ 0 状态位 \| 未定义 (undefined),软件必须忽略 1 = ready(有一个按键等待读取) 0 = not ready 关键:读入 KBSR 时 KBSR[15] 正好落在 N 条件码上, 所以检查"是否 ready"只需要一条 BRzp / BRn。协议违规的后果(幻灯片明确列出):若在处理器读走上一个键之前又按了一个键,只有一个键能被保存,另一个丢失; 若在没有按键时读 KBDR,读到的比特没有意义。
- 作用域与存储期:KBSR/KBDR 的值完全由硬件维护;软件唯一的责任是遵守协议—— 先看状态、再取数据。数据寄存器是”一到就取走”的单槽缓冲,不是队列。
- DSR / DDR:显示器寄存器对:输出侧的同一套结构,方向相反。
- 直观解释:DDR 是”投递口”,DSR 是”投递口是否空闲”的指示灯。
底层机制图解:
DDR (xFE06) —— 显示器数据寄存器 bits 15 ......... 8 \| 7 ......................... 0 写入时填 0 \| 扩展 ASCII (8 bit) 字符 DSR (xFE04) —— 显示器状态寄存器 bits 15 \| 14 ........................................ 0 状态位 \| 未定义,软件必须忽略 1 = ready(可以接收下一个字符) 0 = not ready 时序(显示器初始为 ready): 处理器:写字符进 DDR → (写动作本身把状态位翻转成 0) 显示器:读走并显示该字符 显示器:把自己的 SYNC 位翻转 → 状态位回到 1 处理器:观察到 ready 才能写下一个字符- 作用域与存储期:与键盘完全相同——全局可见、硬件维护、不随程序生命周期变化。 因为别的代码可能刚用过显示器,任何 LC-3 输出代码都必须先等 ready 再写,不能假设初始状态。
- 同步与握手 (Synchronization / Handshaking):设备与处理器没有公共时钟,因此必须用握手协议显式同步。
- 直观解释:课堂上你举手(状态)老师才听你说话(数据);没有举手就说话,问题就丢了。
底层机制图解:
没有同步会怎样:你突然喊出问题 → 老师正在板书 → "嗯?什么?" → 问题已经丢了 硬件握手协议:生产者 1. 把数据放上线 2. 翻转自己的 SYNC("新数据")3. 等消费者翻转它的 SYNC 消费者 4. 读走数据 5. 翻转自己的 SYNC LC-3 把两个 SYNC 信号用 XOR 合成一个"状态位"(假设都从 0 开始): SYNC_producer XOR SYNC_consumer = 1 → ready;= 0 → not ready 因此"翻转"在状态位上表现为 0 → 1 → 0 的交替。处理器比外设快得多(人按键间隔约 100 毫秒,对 LC-3 是几千万个周期),所以轮询几乎总在空转。
- 作用域与存储期:握手状态跨越两个时钟域——生产者与消费者各自保存一位,组合后的状态位是唯一被软件看到的部分。
- 忙等 / 轮询 (Busy-Wait / Polling):在循环里反复读取状态寄存器,直到 ready 为止。
- 直观解释:站在门口反复问”好了吗?”——简单可靠,但把 CPU 时间全部浪费在等待上。
底层机制图解:LC-3 的典型轮询写法(一条
LDI+ 一条BRzp):POLL LDI R1,KBSR ; R1 ← M[M[KBSR]]:先取地址 xFE00,再间接读设备 BRzp POLL ; N=0(not ready)就继续等 LDI R0,KBDR ; N=1(ready),取走数据 KBSR .FILL xFE00 ; 注意:这个字的"内容"才是设备地址 xFE00- 作用域与存储期:轮询循环没有持久状态,其变量的生命周期就是循环本身;与之相对的中断 (interrupt) 方案 允许处理器去干别的事,代价是必须保存全部处理器状态(所有寄存器 + 条件码)并用
RTI返回。
- TRAP 指令与陷阱向量表 (TRAP Instruction and Trap Vector Table):用一个 8 位编号请求系统服务。
- 直观解释:TRAP 就像”呼叫总机报分机号”——不需要知道服务代码在哪个地址,只需要知道编号。
底层机制图解:
TRAP 的 RTL(与 JSR 的前半段完全相同): R7 ← PC ; 保存返回地址 PC ← M[ZEXT16(vec8)] ; 用 8 位编号查表得到服务程序入口 Trap Vector Table 位于 x0000 ~ x00FF,每个单元存放对应服务的**起始地址**: x0020 → x0400 (GETC) x0021 → x0450 (OUT) x0022 → x0480 (PUTS) x0023 → x04A0 (IN) x0024 → x0520 (PUTSP) x0025 → x04E0 (HALT) (vector = 指针 = 地址;其余单元留给未来的服务) LC-3 的六个服务:GETC 读一个字符到 R0[7:0](不回显);OUT 输出 R0[7:0]; PUTS 输出 R0 指向的 NUL 结尾字符串;IN 提示并读入一行、回显,返回 R0[7:0]; PUTSP 按"两字符打包成一字"输出字符串;HALT 停机。TRAP 就是一个子程序调用:返回地址放进 R7,服务程序以
RET(JMP R7) 结束, 所以”TRAP 会覆盖 R7”与JSR完全一样——这就是课程readnumsub.asm里ST R7,SAVE_R7存在的原因。 系统调用还是一种特殊形式的库:通常以特权级执行、预先装进机器(有时在 ROM 里)、按编号间接访问。 这正是”任何问题都可以用另一层间接解决”的实例:编号 → 向量表 → 实现。- 作用域与存储期:陷阱向量表是系统空间的一部分,由操作系统在启动时填写,应用程序只读不写; 向量表的间接性带来可升级性:操作系统换了实现,应用程序的
TRAP x21一行都不用改。
- PSR 与特权级 (Processor Status Register and Privilege):硬件用一位标记”当前代码是否有权碰硬件”。
- 直观解释:设备寄存器就像配电箱,只有持证的电工(操作系统)能开箱;普通程序必须”请人代劳”(TRAP)。
底层机制图解:
PSR (Processor Status Register) 的 bit 15 PSR[15] = 0 → privileged (特权态,可以做任何事) PSR[15] = 1 → unprivileged (用户态,必须依赖操作系统) TRAP x21 的执行过程(概念模型): 用户代码 陷阱服务程序 返回 TRAP x21 → R7 ← PC, PC ← M[x0021] → 执行服务代码(ends with RET) → 回到用户态 (特权级提升,服务程序可以访问 DDR/DSR)LC-3 的 TRAP 实际上并不切换特权级(幻灯片明确指出:”But not in the LC-3 ISA”), 但它展示了真实机器上系统调用的结构:编号 → 查表 → 特权代码 → 返回。
- 作用域与存储期:PSR 的生命周期是”当前正在执行的代码”;它决定一次
LDI/STI能否作用于 I/O 地址, 是”作用域”概念在硬件权限层面的对应物。库代码的存储期则是”链接进可执行文件后与程序同寿”(静态库) 或”由加载器在运行时映射”(动态库),而 TRAP 服务由操作系统提供,跨越所有进程。
代码示例与底层机制分析
示例 1:把 R0 按 16 位二进制打印出来(课程原版 printbinary.asm)
代码 (LC-3 assembly):
.ORIG x3000
LD R0,NUMBER
; print the value in R0 as a 16-bit binary number
; R0 holds the number (shifted left to find bits)
; R1 holds the bit number being printed (15 down to 0)
; R2 holds the ASCII character for that bit
; R3 holds x30 (ASCII '0') for convenience
AND R1,R1,#0 ; start with bit 15
ADD R1,R1,#15
LD R3,ZERO ; initialize R3
BITLOOP ADD R2,R3,#0 ; copy R3 to R2 ('0')
ADD R0,R0,#0 ; check bit value
BRzp ZEROBIT ; skip next instruction for 0 bit
ADD R2,R2,#1 ; print a 1 bit
ZEROBIT LDI R4,DSR ; wait for display to be ready
BRzp ZEROBIT
STI R2,DDR ; write ASCII character to display
ADD R0,R0,R0 ; shift R0 left to find next bit
ADD R1,R1,#-1 ; count down in bit index
BRzp BITLOOP ; print another until index < 0
HALT
NUMBER .FILL xABCD ; a number to print
ZERO .FILL x30 ; ASCII digit '0'
DSR .FILL xFE04 ; DSR address in LC-3
DDR .FILL xFE06 ; DDR address in LC-3
.END
【代码做什么?】
LD R0,NUMBER把待打印的数xABCD装入 R0;AND+ADD把位计数器 R1 置为 15;LD R3,ZERO把 ASCII'0'(x30) 装入 R3。BITLOOP:先把 R2 设为'0'(ADD R2,R3,#0就是复制 R3);ADD R0,R0,#0不改 R0 的值, 只为了刷新条件码:若 R0[15] = 1,则结果视为负数,N = 1。BRzp ZEROBIT:N = 0(最高位是 0)就跳过下一条,于是 R2 保持'0';否则执行ADD R2,R2,#1得到'1'。ZEROBIT处的轮询循环:LDI R4,DSR反复读显示器状态,BRzp ZEROBIT在”没 ready”时继续等; ready 后STI R2,DDR把字符写进数据寄存器(写动作本身让状态位变 0,显示器取走后变回 1)。ADD R0,R0,R0左移一位把下一位送到 bit 15,ADD R1,R1,#-1计数减一,BRzp BITLOOP在还有位时继续; 16 位打印完毕后HALT停机。
【底层机制透视】
- 为什么要用
LDI/STI而不是LD/ST:DSR/DDR这两个标号处存放的是设备地址本身 (xFE04/xFE06)。LDI R4,DSR的语义是”先读M[DSR]得到xFE04,再读M[xFE04]“,正好命中设备寄存器。 如果误用LD R4,DSR,只会把xFE04这个地址常量读进 R4,永远等不到 ready。 - 轮询循环的方向很容易搞反:状态位为 1 = ready 时 N = 1,所以”继续等”的条件是 N = 0, 应该写
BRzp(Z 或 P,即 N = 0)跳回;写成BRn就变成了”ready 时还在等、不 ready 时反而往下走”。 - 位选择用”左移”而不是”右移”:R0 左移后最高位不断被替换成下一位,配合
ADD R0,R0,#0就能用 N 条件码直接判断, 省掉了AND掩码。这是 LC-3 上最省指令的写法,因为 LC-3 没有”算术右移”也缺少位测试指令。 HALT本身也是一条 TRAP(TRAP x25);本程序里写HALT就是它的伪指令形式。
【内存布局图解】
地址 指令/数据 说明
x3000 LD R0,NUMBER (off=+14) 取要打印的数
x3003 LD R3,ZERO (off=+12)
x3004 ADD R2,R3,#0 ← BITLOOP
x3005 ADD R0,R0,#0 刷新 N/Z/P
x3006 BRzp ZEROBIT (off=+1)
x3007 ADD R2,R2,#1
x3008 LDI R4,DSR (off=+8) ← ZEROBIT:轮询状态寄存器
x3009 BRzp ZEROBIT (off=-2) 未 ready 就继续等
x300A STI R2,DDR (off=+7) 写数据寄存器
x300D BRzp BITLOOP (off=-10) x300E HALT
------- 以下为数据区 -------
x300F xABCD ← NUMBER x3010 x0030 ← ZERO (ASCII '0')
x3011 xFE04 ← DSR x3012 xFE06 ← DDR
设备侧(不在程序占用的地址范围里,而是硬件寄存器):
xFE00 KBSR[15]=状态 xFE02 KBDR[7:0]=按键 xFE04 DSR[15]=状态 xFE06 DDR[7:0]=待显示字符
【与汇编的对应】(手算执行结果,R0 初值 = xABCD = 1010 1011 1100 1101)
| 迭代 | R1(计数) | 移位前的 R0 | R0[15] | 打印 | 移位后的 R0 |
|---|---|---|---|---|---|
| 1 | 15 | xABCD | 1 | 1 | x579A |
| 2 | 14 | x579A | 0 | 0 | xAF34 |
| 3 | 13 | xAF34 | 1 | 1 | x5E68 |
| 4 | 12 | x5E68 | 0 | 0 | xBCD0 |
| 5 | 11 | xBCD0 | 1 | 1 | x79A0 |
| 8 | 8 | xE680 | 1 | 1 | xCD00 |
| 12 | 4 | x6800 | 0 | 0 | xD000 |
| 16 | 0 → −1 | x8000 | 1 | 1 | x0000 |
(第 5–16 次迭代依次打印 0 1 1 1 1 0 0 1 1 0 1,中间行从略。) 显示屏上最终得到 1010101111001101,正是 xABCD 的 16 位二进制写法; 循环结束后:R0 = x0000、R1 = xFFFF(−1)、R2 = x31(最后一个字符 '1')、R3 = x0030、R4 = xFE04。 注意 ADD R0,R0,R0 每次都会丢弃最高位(左移溢出),所以移位 16 次后 R0 必然归零。
示例 2:OUT 陷阱的真实实现(系统服务就是子程序)
代码 (LC-3 assembly,取自 lc3sim 的 OS 代码):
; OUT is TRAP x21. M[x0021] contains x0450, and listing x0450 gives:
TRAP_OUT
ST R1,TOUT_R1 ; 保存 R1:子程序不允许偷偷改变调用者的寄存器
TRAP_OUT_WAIT
LDI R1,OS_DSR ; 轮询显示器状态
BRzp TRAP_OUT_WAIT ; 未 ready 就等待
STI R0,OS_DDR ; 把 R0[7:0] 写进 DDR
LD R1,TOUT_R1 ; 恢复 R1
RET ; JMP R7 —— 与普通子程序完全一样
TOUT_R1 .BLKW 1
OS_DSR .FILL xFE04
OS_DDR .FILL xFE06
【代码做什么?】
- 进入服务程序时 R7 已由
TRAP指令设为返回地址(R7 ← PC),R0 中是要输出的字符。 ST R1,TOUT_R1保存 R1——服务程序要用 R1 做轮询,但调用者并不期望 R1 被改。- 轮询循环等到显示器 ready,然后
STI R0,OS_DDR输出字符。 - 恢复 R1,
RET返回。
【底层机制透视】
- 这段代码把第 3 讲的主题提前暴露出来:调用约定 (calling convention)。 它有输入(R0)、有副作用(写显示器)、对”其他寄存器”的所有权做了明确声明(R1 被保存/恢复)。 一份完整的调用接口说明必须写清这四件事。
- 服务程序不保存 R0:R0 是 caller-saved 的输入/输出寄存器,
OUT的语义就是”消费 R0 里的字符”。 向量表的间接性还让”服务入口地址”成为可替换的数据项:操作系统升级只改M[x0021],应用程序无需重新汇编。
【内存布局图解】
x0000 ┌──────────────────────────────────────────────┐
│ Trap Vector Table (256 words) │
x0020 │ x0020: x0400 (GETC) x0021: x0450 (OUT) ★ │
x0022 │ x0022: x0480 (PUTS) x0023: x04A0 (IN) │
x0024 │ x0024: x0520 (PUTSP) x0025: x04E0 (HALT) │
x00FF └──────────────────────────────────────────────┘
x0450 ┌──────────────────────────────────────────────┐
│ TRAP_OUT: ST R1,TOUT_R1 │
│ WAIT: LDI R1,OS_DSR / BRzp WAIT │
│ STI R0,OS_DDR / LD R1,TOUT_R1 / RET │
└──────────────────────────────────────────────┘
执行 TRAP x21 的三个时刻:
(1) 用户代码: PC = 调用点 → TRAP 令 R7 ← PC,PC ← M[x0021] = x0450
(2) 服务代码: 轮询 DSR、写 DDR (3) RET: PC ← R7,回到用户代码的下一条指令
【与汇编的对应】:C 的 putchar('A') 在真实系统上会走到 write(1, &c, 1) 系统调用, 其结构与此处完全同构——用户态把参数放进约定好的位置(寄存器/栈),执行一条陷入指令, 内核用编号(x86-64 上是 rax 中的系统调用号)查表分派,服务完成后返回用户态; 区别只有两点:真实机器真的切换特权级,并且用专门的返回指令(LC-3 的 RTI)。
示例 3:C 中的轮询输入(getchar 循环)
代码 (C):
/* ECE 220 -- Lecture 2 example: a polling (busy-wait) I/O loop in C.
*
* This is the C equivalent of the LC-3 idiom
* POLL LDI R1,KBSR / BRzp POLL (wait for KBSR[15] = ready)
* LDI R0,KBDR (then take the data)
* Replacing the status register with "did getchar() return EOF?" makes the
* synchronisation idea visible without any hardware.
*/
#include <stdio.h>
int main(void)
{
int ch;
int count = 0;
printf("echo> ");
/* poll the keyboard once per iteration; stop at the end of the line */
while ((ch = getchar()) != EOF) {
if (ch == '\n') {
break;
}
putchar(ch); /* "store R2 to DDR" */
count = count + 1;
}
putchar('\n');
printf("read %d characters before the linefeed\n", count);
return 0;
}
编译与运行:
$ gcc -g -std=c99 -Wall -Werror ece220_l02_poll.c -o ece220_l02_poll
$ printf 'Hi 42\nignored line\n' | ./ece220_l02_poll
echo> Hi 42
read 5 characters before the linefeed
【代码做什么?】
- 先打印提示
echo>(相当于PUTS)。 - 每次循环调用一次
getchar():这就是”读一次 KBDR”,只不过函数内部替我们处理了 KBSR 的等待。 - 读到换行符或 EOF(管道输入结束 / Ctrl-D)就结束循环。
- 每读到一个普通字符就
putchar(ch)回显(相当于STI R2,DDR)并让计数加一,最后打印统计结果。
【底层机制透视】
getchar()与 LC-3 的GETC(TRAP x20) 是同一层抽象:阻塞式读一个字符。 在终端上,标准库还会做行缓冲 (line buffering)——按键先攒在缓冲区里,直到按下回车才交给程序, 所以”轮询”发生在库/内核内部,而不是在每一行 C 代码里。putchar与 TRAP x21 (OUT) 对应;二者都不返回”设备是否 ready”,因为同步已经被库/内核吸收了。- 输入输出被抽象成字节流 (stream) 后,程序不再需要知道 DDR/DSR——这是”用一层间接换取可移植性”的又一次体现。 管道输入使
EOF在读完所有字节后出现,程序能确定性地结束;交互式终端上则需要 Ctrl-D。
【内存布局图解】
栈帧(main 的帧,automatic storage duration)
+--------------------+ 0x7ffd....
| ch (int) | ← 每次 getchar() 的返回值
| count (int) | ← 5(本例中)
| 调用现场(R7 等) | ← printf/putchar/getchar 都是函数调用
+--------------------+
库/内核侧(对应 LC-3 的设备寄存器)
stdin 缓冲区: 'H' 'i' ' ' '4' '2' '\n' ... ← 行缓冲(按下回车才交给程序)
终端设备(概念上): KBSR 状态位 / KBDR 数据 stdout 缓冲区: 'e' 'c' 'h' 'o' '>' ' '
【与汇编的对应】
; C: while ((ch = getchar()) != EOF) { ... }
POLL_IN
GETC ; TRAP x20:读一个字符到 R0[7:0](内部已完成 KBSR 轮询)
ADD R1,R0,#-10 ; 与 ASCII 换行符 x0A 比较
BRz DONE ; 读到换行 → 结束
; C: putchar(ch); R0 此时就是刚才读到的字符
OUT ; TRAP x21:输出 R0[7:0]
; C: count = count + 1;
ADD R2,R2,#1 ; R2 是计数器
BRnzp POLL_IN
DONE
; C: printf("read %d characters ...\n", count);
; 需要把 R2 中的二进制数转成十进制字符串,再逐字符 OUT
示例 4:格式化 I/O 与返回值(以及一个被 -Werror 拦下的错误)
代码 (C):
/* ECE 220 -- Lecture 2 example: formatted I/O and its return values. */
#include <stdio.h>
int main(void)
{
int a = 0;
int b = 0;
int converted;
int written;
printf("Enter two integers: ");
/* scanf returns the number of successful conversions */
converted = scanf("%d%d", &a, &b);
printf("scanf converted %d value(s)\n", converted);
if (converted != 2) {
printf("Bad input!\n");
return 1;
}
/* every character of the format is printed literally except %... */
written = printf("%d + %d = %d\n", a, b, a + b);
printf("printf returned %d (the number of characters written)\n", written);
return 0;
}
编译与运行:
$ gcc -g -std=c99 -Wall -Werror ece220_l02_fmtio.c -o ece220_l02_fmtio
$ printf '6 7\n' | ./ece220_l02_fmtio
Enter two integers: scanf converted 2 value(s)
6 + 7 = 13
printf returned 11 (the number of characters written)
【代码做什么?】
printf("Enter two integers: ")输出提示(PUTS的角色)。scanf("%d%d", &a, &b)读两个十进制整数:%d把 ASCII 数字序列转换成补码存进变量,&传的是地址。- 检查返回值
converted:scanf返回成功转换的个数(这里是 2),失败时返回 0 或 EOF(−1)。 幻灯片强调:检查scanf的返回值是判断用户输入是否合法的标准做法。 printf("%d + %d = %d\n", ...)打印结果并把返回值存下来(printf返回实际输出的字符数,这里是 11), 最后打印它,说明”函数调用是表达式,有值”。
【底层机制透视】
scanf必须拿到变量的地址:它要往调用者的栈帧里写数据。这正是 LC-3 里”用寄存器传指针”的对应物—— 被调用者无法直接访问调用者的局部变量,只能通过地址间接写入。printf("...%d...", a)的工作是”把二进制补码按十进制转成 ASCII 再输出”, 与示例 1 把二进制转成'0'/'1'是同一类算法,只是进制不同。这解释了为什么 C 里 “输出一个数”比 LC-3 里”输出一个数”省事:转换逻辑被搬进了库。- 两个函数都遵循调用约定:参数按约定位置传递,返回值放在约定的位置(在 LC-3 上是
R0)。 本课 MP2 之后自己写的十进制打印子程序,就是在实现printf("%d")的一小部分。
【内存布局图解】
main 的栈帧
+---------------------+ 0x7ffd...
| a = 6 | ← scanf 通过 &a 直接写入调用者的帧
| b = 7 |
| converted = 2 | ← scanf 的返回值
| written = 11 | ← printf 的返回值
+---------------------+
字符串常量(.rodata,静态存储期):"Enter two integers: " "%d%d"
【与汇编的对应】
; C: scanf("%d%d", &a, &b) —— 把地址压栈后调用(第 4 讲的帧布局)
LEA R0,FMT_DD ; 参数 1:格式串地址
ADD R6,R6,#-1 ; 压参(右到左:先压最后一个参数)
STR R0,R6,#0
LEA R0,B_ADDR ; &b 的地址(用 LEA 得到局部变量的地址)
ADD R6,R6,#-1
STR R0,R6,#0
JSR SCANF
LDR R0,R6,#0 ; 返回值 = 成功转换个数
ADD R6,R6,#4 ; 弹掉返回值与 3 个参数
; C: OUT (TRAP x21) 与 printf 的关系:printf 最终也只是一串字符输出
ADD R0,R2,#0 ; 把要打印的字符放进 R0
OUT ; TRAP x21
仅供演示、请勿模仿——类型不匹配的 printf(编译期被 -Werror 拦下):
#include <stdio.h>
int main(void)
{
/* UB:%d 期待 int 却给了 double;%f 期待 double 却给了 int */
printf("%d %f", 10.0, 17);
return 0;
}
用课程命令 gcc -g -std=c99 -Wall -Werror 编译时它根本不会通过,本机 gcc 12.2.0 的真实报错是:
error: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Werror=format=]
error: format '%f' expects argument of type 'double', but argument 3 has type 'int' [-Werror=format=]
cc1: all warnings being treated as errors
去掉 -Werror 后它能编译,本机运行输出 17 10.000000——但这是未定义行为,输出取决于 ABI 与寄存器分配, 换一台机器就会变(幻灯片给出的另一组结果 0 0.000000 同样”合法”)。 这正是课程要求 -Wall -Werror 的原因:让编译器替你在运行前抓住这类错误。
常见错误与调试技巧
- 不检查状态位就直接读数据:在 KBSR[15] = 0 时读 KBDR,读到的是没有意义的比特。 现象是随机字符、程序行为不可复现。调试:在
lc3sim里对轮询循环设置断点,用print R1查看状态位, 确认BRzp的方向(N = 0 = 未 ready 时才继续等);在 C 里检查scanf/getchar的返回值。 - 用
LD/ST而不是LDI/STI访问设备:DSR .FILL xFE04之后写LD R4,DSR只会拿到xFE04这个常量。 现象是轮询循环永远等不到 ready(或立刻通过)。调试:在lc3sim中用list查看该地址的内容, 再确认指令助记符是LDI/STI;print R4应显示xFE04而不是设备状态。 - 把
xFE00当作普通内存写入:例如用ST R0,ADDR(而不是STI)会覆盖”存放设备地址的那个字”,破坏程序数据。 现象是后续指令取到错误地址。调试:用dump xFE00 xFE10观察设备区,确认你把”地址常量”和”设备内容”分了清楚。 - 在子程序里用 TRAP 却忘了保存 R7:
TRAP与JSR一样会覆盖 R7,导致RET跳回错误位置或陷入死循环。 现象是子程序返回后行为错乱(课程readnumsub.asm正是为此写ST R7,SAVE_R7)。 调试:在每个用到 TRAP/JSR 的子程序出入口观察 R7;在gdb里用bt(backtrace)看调用栈是否异常展开。 printf/scanf的格式串与参数不匹配:%d配double、%f配int、少写&、 格式符个数与参数个数不符。现象是垃圾输出或段错误。调试:始终使用-Wall -Werror编译; 运行时用gdb看p a、x/2xb &a;scanf前打印&a确认传的是地址。- 忘记 TRAP 会改变 R0:
GETC、IN等以 R0 作为输出寄存器;若调用前把重要数据放在 R0,就会丢失。 现象是”某个值莫名其妙变成刚输入的字符”。调试:写寄存器用途表;用gdb的watch或lc3sim的断点逐条观察 R0 的变化。
关键要点
- LC-3 用内存映射 I/O:设备寄存器占据
xFE00–xFFFF,用LDI/STI访问;代价是这些地址不能再当普通内存用。 - 每个设备都是”状态寄存器 + 数据寄存器“的一对:
KBSR/KBDR、DSR/DDR; 状态位固定在 bit 15,读入后正好落在 N 条件码上,所以判断”是否 ready”只需一条BRzp/BRn。 - 同步只能靠握手协议:先查状态、再传数据;违反协议会丢字符或读到无意义比特。 处理器比人快几千万倍,因此轮询几乎总是在空转——这是后面引入中断的动机。
TRAP就是”查表的子程序调用“:R7 ← PC; PC ← M[ZEXT16(vec8)], 服务程序以RET结束;六个常用服务是x20 GETC / x21 OUT / x22 PUTS / x23 IN / x24 PUTSP / x25 HALT。 因为 R7 会被覆盖,任何用到 TRAP 的子程序都必须保存 R7。- TRAP 展示的是真实系统调用的结构(编号 → 向量表 → 特权代码 → 返回),并把”库”落到实处: 系统调用是一组按编号访问的、预装的、带特权的子程序。
思考题(带答案)
问题 1:为什么 LC-3 的键盘和显示器寄存器地址是 xFE00, xFE02, xFE04, xFE06 这样”隔一个”排列的? 如果 LC-3 是字节可寻址的机器,同样的 I/O 映射会带来什么便利?
答案:因为 P&P 为高年级定义了一个字节可寻址的变体 LC-3b。在一个字占两个字节的字节寻址机器上, “每两个字地址之间空一格”正好给出字节粒度的相邻映射,使同一份 I/O 地址常量在两种机器上都可用(本课只关心字寻址)。 便利之处在于设备寄存器可以按字节访问,外设协议(如 8 位 ASCII 数据)与内存布局能一一对应,不需要”读一整字再屏蔽高位”。
问题 2:下面这段 LC-3 代码想等待键盘输入,但它有 bug。请指出问题并给出正确写法。
LDI R1,KBSR
BRn WAIT ; "没准备好就继续等"
WAIT LDI R1,KBSR
BRn WAIT
LDI R0,KBDR
答案:BRn 的判断方向反了。KBSR[15] = 1 表示 ready,此时 N = 1; 所以”继续等待”的条件应该是 N = 0,即应写 BRzp WAIT(若还想排除 N=1 之外的情况可写 BRz)。 这段代码的效果恰好相反:没按键时(N=0)它会直接往下走去读 KBDR,读到无意义的比特; 而按键后(N=1)它反而卡在循环里不动。正确写法是 LDI R1,KBSR / BRzp POLL / LDI R0,KBDR, 其中 POLL 是 LDI R1,KBSR 那一行的标号。
问题 3:TRAP x21(OUT)与 JSR 调用一个打印字符的子程序,在机器层面有什么相同、什么不同? 为什么课程说 TRAP 是”带特权切换的子程序调用”?
答案:相同:两者都是 R7 ← PC 然后改变 PC;服务程序/子程序都以 RET (JMP R7) 结束; 都遵守”R7 是 caller-saved”这条约定,因此调用者若还要用 R7 必须自己保存。 不同:JSR 的目标地址由指令里的 PCoffset11(或 JSRR 的寄存器)直接给出,服务代码与调用者被链接进同一个程序; TRAP 的目标地址来自陷阱向量表 M[ZEXT16(vec8)],服务代码由操作系统预先装好,应用程序只提供 8 位编号。 在真实机器上,TRAP(系统调用)还会提升特权级——因为服务代码需要访问用户态代码无权触碰的硬件, 这正是 LC-3 中 PSR[15] 所代表的机制;LC-3 的 ISA 本身不实现这一切换,但结构已经完全具备, 这也是”用一层间接(向量表)换取可升级性”的经典例子。
