Lecture 2: 内存映射 I/O 与 TRAP (Memory-Mapped I/O: Input from the Keyboard, Output to the Monitor; TRAPs)

目录 · ← l1 · l3 →

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.ENR.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.asmST 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

【代码做什么?】

  1. LD R0,NUMBER 把待打印的数 xABCD 装入 R0;AND+ADD 把位计数器 R1 置为 15;LD R3,ZERO 把 ASCII '0' (x30) 装入 R3。
  2. BITLOOP:先把 R2 设为 '0'ADD R2,R3,#0 就是复制 R3);ADD R0,R0,#0 不改 R0 的值, 只为了刷新条件码:若 R0[15] = 1,则结果视为负数,N = 1。
  3. BRzp ZEROBIT:N = 0(最高位是 0)就跳过下一条,于是 R2 保持 '0';否则执行 ADD R2,R2,#1 得到 '1'
  4. ZEROBIT 处的轮询循环:LDI R4,DSR 反复读显示器状态,BRzp ZEROBIT 在”没 ready”时继续等; ready 后 STI R2,DDR 把字符写进数据寄存器(写动作本身让状态位变 0,显示器取走后变回 1)。
  5. ADD R0,R0,R0 左移一位把下一位送到 bit 15,ADD R1,R1,#-1 计数减一,BRzp BITLOOP 在还有位时继续; 16 位打印完毕后 HALT 停机。

【底层机制透视】

  • 为什么要用 LDI/STI 而不是 LD/STDSR/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(计数)移位前的 R0R0[15]打印移位后的 R0
115xABCD11x579A
214x579A00xAF34
313xAF3411x5E68
412x5E6800xBCD0
511xBCD011x79A0
88xE68011xCD00
124x680000xD000
160 → −1x800011x0000

(第 5–16 次迭代依次打印 0 1 1 1 1 0 0 1 1 0 1,中间行从略。) 显示屏上最终得到 1010101111001101,正是 xABCD 的 16 位二进制写法; 循环结束后:R0 = x0000R1 = xFFFF(−1)、R2 = x31(最后一个字符 '1')、R3 = x0030R4 = 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

【代码做什么?】

  1. 进入服务程序时 R7 已由 TRAP 指令设为返回地址(R7 ← PC),R0 中是要输出的字符。
  2. ST R1,TOUT_R1 保存 R1——服务程序要用 R1 做轮询,但调用者并不期望 R1 被改
  3. 轮询循环等到显示器 ready,然后 STI R0,OS_DDR 输出字符。
  4. 恢复 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

【代码做什么?】

  1. 先打印提示 echo> (相当于 PUTS)。
  2. 每次循环调用一次 getchar():这就是”读一次 KBDR”,只不过函数内部替我们处理了 KBSR 的等待。
  3. 读到换行符或 EOF(管道输入结束 / Ctrl-D)就结束循环。
  4. 每读到一个普通字符就 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)

【代码做什么?】

  1. printf("Enter two integers: ") 输出提示(PUTS 的角色)。
  2. scanf("%d%d", &a, &b) 读两个十进制整数:%d 把 ASCII 数字序列转换成补码存进变量,& 传的是地址。
  3. 检查返回值 convertedscanf 返回成功转换的个数(这里是 2),失败时返回 0 或 EOF(−1)。 幻灯片强调:检查 scanf 的返回值是判断用户输入是否合法的标准做法
  4. 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/STIprint R4 应显示 xFE04 而不是设备状态。
  • xFE00 当作普通内存写入:例如用 ST R0,ADDR(而不是 STI)会覆盖”存放设备地址的那个字”,破坏程序数据。 现象是后续指令取到错误地址。调试:用 dump xFE00 xFE10 观察设备区,确认你把”地址常量”和”设备内容”分了清楚。
  • 在子程序里用 TRAP 却忘了保存 R7TRAPJSR 一样会覆盖 R7,导致 RET 跳回错误位置或陷入死循环。 现象是子程序返回后行为错乱(课程 readnumsub.asm 正是为此写 ST R7,SAVE_R7)。 调试:在每个用到 TRAP/JSR 的子程序出入口观察 R7;在 gdb 里用 bt(backtrace)看调用栈是否异常展开。
  • printf/scanf 的格式串与参数不匹配%ddouble%fint、少写 &、 格式符个数与参数个数不符。现象是垃圾输出或段错误。调试:始终使用 -Wall -Werror 编译; 运行时用 gdbp ax/2xb &ascanf 前打印 &a 确认传的是地址。
  • 忘记 TRAP 会改变 R0GETCIN 等以 R0 作为输出寄存器;若调用前把重要数据放在 R0,就会丢失。 现象是”某个值莫名其妙变成刚输入的字符”。调试:写寄存器用途表;用 gdbwatchlc3sim 的断点逐条观察 R0 的变化。

关键要点

  • LC-3 用内存映射 I/O:设备寄存器占据 xFE00xFFFF,用 LDI/STI 访问;代价是这些地址不能再当普通内存用。
  • 每个设备都是”状态寄存器 + 数据寄存器“的一对:KBSR/KBDRDSR/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, 其中 POLLLDI R1,KBSR 那一行的标号。

问题 3TRAP 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 本身不实现这一切换,但结构已经完全具备, 这也是”用一层间接(向量表)换取可升级性”的经典例子。