Lecture 1: 课程概述与自底向上方法论;LC-3 机器模型复习 (Course Overview and the Bottom-Up Philosophy; LC-3 Review)

目录 · ← l0 · l2 →

Lecture 1: 课程概述与自底向上方法论;LC-3 机器模型复习 (Course Overview and the Bottom-Up Philosophy; LC-3 Review)

概述

ECE 220 要回答的核心问题是:从 ECE 120 中”用比特和门电路搭出来”的那台机器出发,怎样一步步走到能够写出 C 程序, 并且清楚地知道每一行 C 在机器里究竟发生了什么。本讲引入贯穿全课的两条主线:自底向上 (bottom-up) 的七层抽象链 (bits → gates → microarchitecture → ISA → assembly → C → algorithms),以及 LC-3 机器模型(内存、8 个通用寄存器、 指令格式、可寻址性、内存映射)。这套模型是后面一切的地基:第 2 讲的内存映射 I/O 直接落在本讲画出的内存图最顶端, 第 3 讲的调用约定落在”寄存器约定”上,第 4 讲的栈帧落在”栈区”上,而 C 的变量作用域、数组、指针与函数调用, 也都会用同一套语言反复解释。

核心概念与底层机制图解

  • 自底向上方法论 (Bottom-Up Philosophy):把”从问题到程序”的过程理解为逐层构建——每一层只使用下一层已经存在的机制。
    • 直观解释:像盖楼而不是像堆沙。ECE 120 给了你砖(门电路)和一层楼(ISA);ECE 220 要在这层楼上盖第二层(汇编), 再盖第三层(C)。没有下面两层,上面写的一切都是悬空的。
    • 底层机制图解:课程把数字系统分成七层,颜色标注每层通常用什么语言描述:

      问题 / 任务        Problems / Tasks      ← 人类语言、理论
      算法               Algorithms
      机器 / 指令集架构  Machine / ISA         ← ECE 220 从这一层开始
      微架构             Microarchitecture
      电路               Circuits              ← ECE 120 在这里造出计算机
      器件               Devices
      ------------------------------------------------
      ECE 120: 从 bits 和 gates 造出机器;ECE 220: 站在 ISA 上走向 C;CS 374: 算法理论
      

      与机器码/汇编的对应关系在于:向上走一步,代价是失去一层确定性。写 C 时你不指定寄存器, 但编译器必须把它变成明确的 LC-3 指令;本课要求你能够手工完成这个翻译(教学目标 1)。

    • 作用域与存储期:这条方法论决定了”作用域”在本课中的含义——一个名字(变量名、标号、函数名)的可见范围, 总是由它所处的那一层决定:汇编里的标号受 .ORIG 与文件范围限制,C 里的标识符受块作用域与文件作用域限制, 而它们最终都映射为某个地址或某个寄存器偏移。
  • “计算机很笨” (Computers Are Dumb):处理器只会反复执行取指—译码—执行 (fetch-decode-execute) 循环,它对”这些比特是什么意思”完全没有概念;所有含义都是人或编译器赋予的约定
    • 直观解释:把 "41,962""41321""9874" 三个字符串按 ASCII 排序,计算机给出的大小顺序与人的直觉相反: 逗号 x2C 小于字符 '3' (x33),所以 "41,962" 排在 "41321" 前面。计算机没有错,它只是严格按你写的规则做
    • 底层机制图解:字符串在 LC-3 内存里就是”从某个地址开始、连续存放 ASCII 码、以 NUL (x0000) 结束”的一段字, 而”字符串”这个值就是它的起始地址:

      地址      内容(bit)   含义
      x4012     x0031      '1'   ← 字符串 "19" 由地址 x4012 表示
      x4013     x0039      '9'
      x4014     x0000      NUL   ← 读到 0 就知道字符串结束
      x7196     x0032      '2'   ← 字符串 "23" 由地址 x7196 表示
      x7197     x0033      '3'
      x7198     x0000      NUL
      
      若 LC-3 执行:  R1 ← x4012
                     R2 ← x7196
                     R3 ← R1 + R2
      则 R3 = xB1A8  —— 这是一个"指向字符串 "23" 之后第四个字的地址",
      而不是 19 + 23 = 42。M[xB1A8] 里的内容与本题毫无关系。
      

      对应的机器码就是一条 ADD R3,R1,R20001 011 001 000010):ALU 只做二进制加法, 既不检查溢出,也不问这两个数是不是地址。

    • 作用域与存储期:这段内存里的字节是数据段的存储期 = 整个程序运行期(静态存储期); 而”字符串”这个名字在 C 里可能只是一个指针变量(automatic storage duration), 指针本身随栈帧消失,指向的内容却可能仍然存在——这是后面”悬垂指针”的根本原因。
  • 系统化分解 (Systematic Decomposition):给定一个用人类语言描述的任务,反复拆分为更简单的子任务,直到每个子任务只需要几条指令(或几条 C 语句)就能表达。
    • 直观解释:把”做一顿饭”拆成”买菜 / 洗菜 / 炒菜 / 盛盘”,每一项继续拆到”打开冰箱门”这个粒度。
    • 底层机制图解:拆分的结果最终只归结为三种构造 (construct),它们各自映射到内存中的指令序列:

      顺序            条件                          迭代
      子任务1         test condition ──FALSE──→ else  ┌──→ 子任务
      子任务2              ↓ TRUE        ↓            │      ↓
      子任务3         then 子任务    ──→ 汇合 ←──      └─ test condition(TRUE 则回去)
      
      内存中的样子(条件构造):
          x3000  …生成条件的指令(如 ADD R0,R0,#0 设置 N/Z/P)…
          x3001  BRn  ELSE        ; 0000 100 …(条件为假时跳走)
          x3002  …then 子任务的指令…    x3003  BRnzp JOIN
          x3004  ELSE …else 子任务的指令…    x3005  JOIN …
      

      BR 的机器码形如 0000 nzp PCoffset9:只改 PC,不改任何寄存器,这正是”流程图能画进内存”的唯一手段。

    • 作用域与存储期:三种构造在 C 里对应顺序语句、if/else、循环语句;循环体是块作用域, 在块内声明的 automatic 变量每次进入块都会重新获得存储(生命周期只有一次迭代那么长)。
  • 良好设计 (Good Design):软件的好坏没有单一指标——指令条数、内存用量、运行时间、能耗、正确性全都算数;课程给出两条可操作的指导原则:(1) 更简单(可行)的方案;(2) 易读、易测。
    • 直观解释:先画流程图、先写注释,再写代码;能复用就复用,每复制一次代码,就复制了一份 bug
    • 底层机制图解:设计原则最终体现为可测量的机器代价。例如同一个”求数组和”的任务:

      方案 A(简单直接)                 方案 B("聪明"但难读)
      LOOP  LDR R3,R1,#0                 ;被完全展开的 5 条 LDR/ADD
            ADD R0,R0,R3                 ;无循环、无分支
            ADD R1,R1,#1                 ;指令数 15,内存 15 字
            ADD R2,R2,#-1                ;跑得快,但数组长度一变就要重写
            BRp LOOP                     ;无法测试"长度 0"的情形
      ;指令数 5,内存 5 字
      ;长度改变只需改 R2 的初值
      

      课程强烈建议先用查表 (look-up table) 与循环来表达重复,而不是把代码抄 N 遍。

    • 作用域与存储期:这条原则在 C 里表现为:函数的职责边界要清晰(接口 = 参数 + 返回值 + 副作用), 这样每个函数才能独立测试;用 static 限制文件作用域,可以避免全局状态被意外修改。
  • LC-3 机器模型 (Machine Model):LC-3 是一台 16 位、字可寻址 (word-addressable) 的冯·诺依曼机。
    • 直观解释:内存是一排编号的抽屉,每个抽屉里放一个 16 位的字;”地址”就是抽屉编号。
    • 底层机制图解:核心参数必须背下来:

      字长 (word size)          16 bit
      地址宽度                  16 bit  → 2^16 = 65,536 个地址
      可寻址空间                65,536 words = 128 KiB
      可寻址单位 (addressability) 1 word(不是 1 byte!)
      通用寄存器                8 个:R0..R7,每个 16 bit
      条件码 (condition codes)  N / Z / P(每次写寄存器都会被更新)
      PC                       16 bit,指向下一条要取的指令
      

      与 C 的关键差异:C 的 char *p; p + 1 前进 1 字节,而 LC-3 的 ADD R1,R1,#1 前进 1 个字(2 字节)。 这就是同一个”指针加一”在两层的不同含义。

    • 作用域与存储期:8 个寄存器的”生命周期”是整个程序运行期,但它们的内容归属由第 3 讲的调用约定决定: R0–R3 是 caller-saved(子程序可以随便改),R4–R7 承担全局数据指针、帧指针、栈指针、返回地址。
  • 指令格式 (Instruction Formats) 与可寻址性:16 位指令中高 4 位是操作码 (opcode),其余 12 位按格式划分为寄存器号和立即数/偏移。
    • 直观解释:16 个比特要同时说明”做什么”和”对谁做”,所以必须精打细算;这就是为什么偏移量的位宽会限制跳转范围。
    • 底层机制图解:三种基本格式(REGI 型、IMM 型、JMP 型):

      bits  15 14 13 12 \| 11 10  9 \|  8  7  6 \|  5  4  3 \| 2  1  0
      -------------------------------------------------------------
      ADD (reg)   0001  \|   DR     \|  SR1     \|  0  0  0 \|  SR2       ; DR ← SR1 + SR2
      ADD (imm5)  0001  \|   DR     \|  SR1     \|  1       \| imm5       ; DR ← SR1 + SEXT(imm5)
      LDR         0110  \|   DR     \|  BaseR   \|        offset6            ; DR ← M[BaseR+SEXT(off6)]
      STR         0111  \|   SR     \|  BaseR   \|        offset6            ; M[BaseR+SEXT(off6)] ← SR
      BR          0000  \|  n z p  \|        PCoffset9                     ; if (cc 命中) PC ← PC + SEXT(off9)
      JSR         0100  \|  1      \|        PCoffset11                    ; R7 ← PC; PC ← PC + SEXT(off11)
      JSRR        0100  \|  0  0 0 \| BaseR \| 0 0 0 0 0 0                    ; R7 ← PC; PC ← BaseR
      LEA         1110  \|   DR    \|        PCoffset9                     ; DR ← PC + SEXT(off9)(取地址)
      

      立即数位宽直接决定能力边界:imm5 只能表示 −16..15,所以”把 15 放进 R1”必须用 AND R1,R1,#0 + ADD R1,R1,#15 两条指令(立即数不够用,就先清零再加);offset6 只能表示 −32..31, 所以访问远处的数据要用 LEA 先算出基址,再配合 LDR/STR

    • 作用域与存储期:立即数偏移是编译期常量,它的”作用域”只在这一条指令内;而基址寄存器的内容是运行期值, 生命周期由程序员管理。
  • LC-3 内存映射 (Memory Map):整个 64K 字地址空间被约定划分为系统空间、代码、全局数据、堆、栈,以及最顶端的设备寄存器。
    • 直观解释:像一座城市的规划图:市中心(低地址)是政府机关(陷阱向量表)与操作系统,中间是居民区(你的程序与数据), 最北边(高地址)是海关与港口(I/O 设备寄存器)。
    • 底层机制图解

      高地址  xFFFF ┌──────────────────────────────┐
                    │ 设备寄存器 (memory-mapped I/O)│  xFE00 KBSR  xFE02 KBDR
             xFE00  ├──────────────────────────────┤  xFE04 DSR   xFE06 DDR
                    │ 栈 stack ↓ 向低地址增长        │  ← R6 栈指针;R5 帧指针
                    │        ...   (空闲区)        │
                    │        ↑ 向高地址增长          │
                    ├──────────────────────────────┤
                    │ 堆 heap(动态分配 malloc)     │
                    ├──────────────────────────────┤
                    │ 全局数据 global data          │  ← R4 全局数据指针(x4000 起)
             x4000  ├──────────────────────────────┤
                    │ 代码 code(.ORIG x3000)       │  ← PC 从 x3000 开始
             x3000  ├──────────────────────────────┤
                    │ 操作系统 / 监督程序栈 (system) │
             x0200  ├──────────────────────────────┤
                    │ 中断向量表 (interrupt vectors)│
             x0100  ├──────────────────────────────┤
                    │ 陷阱向量表 (trap vectors)      │  x0020..x0025 存放 TRAP 入口地址
      低地址  x0000 └──────────────────────────────┘
      

      堆与栈相向增长malloc 从下往上要空间,函数调用从上往下压栈。两边一旦相遇,就是”内存耗尽”。 这也是”栈溢出 (stack overflow)”在系统层面的真实含义。

    • 作用域与存储期:这张图就是 C 的存储期分类的物理来源—— 代码/全局数据 = static storage duration(整个程序期),栈 = automatic storage duration(进入块时创建、离开块时销毁), 堆 = allocated storage duration(从 mallocfree,由程序员负责)。
  • ECE 120 如何喂养后面每一个 C 概念:本课的每一个 C 主题都能追溯到 ECE 120 的一个底层机制。
    • 直观解释:ECE 120 教会你”机器能做什么”,ECE 220 教你”用机器能懂的方式表达想法”。
    • 底层机制图解:对应表如下——

      ECE 120 的底层机制                 ECE 220 / C 中的概念
      --------------------------------  --------------------------------------------
      二进制补码表示                     int / unsigned / 溢出 / 类型转换
      ASCII 与 NUL 结尾约定              字符串 (char*)、strlen、字符串常量
      8 个寄存器 + 条件码                表达式求值、控制流、局部变量的寄存器分配
      内存映射 I/O                       printf / scanf 的底层对应物
      JSR/RET + R7                      函数调用、返回地址、递归
      栈与 R6 / R4 全局数据指针           automatic 变量、栈帧、递归深度、static 变量
      内存映射中的 heap                  malloc / free / 动态数据结构
      

      教学目标 2(作用域与存储)、3(调用约定)、4(数组与指针)、6(动态分配) 全部是这张表的直接延伸。

    • 作用域与存储期:这张表本身就是”作用域与存储期”的分类标准:看到一个新变量, 先问”它在哪个区、活了多久”,答案决定它能不能被别的函数看到、能不能安全地被返回。

代码示例与底层机制分析

示例 1:一个完整的 LC-3 程序——用三种构造求数组元素之和

代码 (LC-3 assembly):

; SUM5 -- 把数组里 5 个字相加,结果放在 R0
; 寄存器用途表
;   R0 : 累加和(输出)
;   R1 : 指向当前数组元素的指针
;   R2 : 剩余元素计数
;   R3 : 当前元素(临时)

        .ORIG x3000
        LEA R1,ARRAY        ; x3000: R1 ← 数组首地址(顺序构造)
        AND R0,R0,#0        ; x3001: R0 ← 0
        AND R2,R2,#0        ; x3002: R2 ← 0
        ADD R2,R2,#5        ; x3003: R2 ← 5(立即数只有 5 位,所以先清零再加)
LOOP    LDR R3,R1,#0        ; x3004: R3 ← M[R1]      ← 迭代构造从这里开始
        ADD R0,R0,R3        ; x3005: sum ← sum + 元素
        ADD R1,R1,#1        ; x3006: 指针前进一个字
        ADD R2,R2,#-1       ; x3007: 计数减一
        BRp LOOP            ; x3008: 还有元素就回去(条件构造:只在 P 时跳)
        HALT                ; x3009: 停机
ARRAY   .FILL #10           ; x300A: 10
        .FILL #20           ; x300B: 20
        .FILL #30           ; x300C: 30
        .FILL #40           ; x300D: 40
        .FILL #50           ; x300E: 50
        .END

【代码做什么?】

  1. LEA R1,ARRAY:把标号 ARRAY 的地址(不是内容)装进 R1,此处 R1 = x300A
  2. 三条 AND/ADD 指令把 R0(和)清零、把 R2(计数)设置为 5;因为 imm5 只能到 15,这里虽然放得下 5,但同样两条指令的习惯写法在后面放 15 时是必需的。
  3. 进入 LOOPLDR R3,R1,#0 取出当前元素,ADD R0,R0,R3 累加。
  4. ADD R1,R1,#1 让指针指向下一个ADD R2,R2,#-1 让计数减一。
  5. BRp LOOP:若刚才的 ADD 使结果为正(P=1)就跳回;等于 0 时 P=0、Z=1,不跳,落到 HALT
  6. HALT 停机。

【底层机制透视】

  • LC-3 没有”数组”这个类型,只有连续的内存字和”用指针加偏移去访问”的机制。数组名 ARRAY 在汇编里就是地址常量。
  • 循环的循环变量、边界判断全部由程序员手工维护;BRp 只看条件码,而条件码是上一条写寄存器的指令留下的,所以 ADD R2,R2,#-1BRp LOOP 必须紧挨着写——中间插入任何写寄存器的指令都会破坏判断。
  • printf 这类”高级”操作在这里完全不存在:输出要靠第 2 讲的 STI/TRAP,所以本程序只能把结果留在 R0 里供调试器观察。

【内存布局图解】

地址      内容        含义
x3000     1110 001 000001001   LEA  R1,ARRAY   (PC+1+9 = x300A)
x3001     0101 000 000 1 00000 AND  R0,R0,#0
x3002     0101 010 010 1 00000 AND  R2,R2,#0
x3003     0001 010 010 1 00101 ADD  R2,R2,#5
x3004     0110 011 001 000000  LDR  R3,R1,#0    ← LOOP
x3008     0000 001 111111011   BRp  LOOP       (PC+1-5 = x3004)
x3009     1111 0000 0010 0101 HALT
x300A     0000 0000 0000 1010 10      ← ARRAY(连续 5 个字)
x300E     0000 0000 0011 0010 50      ← 最后一个元素

【与汇编的对应】(逐条手算执行结果)

时刻R0R1R2R3说明
LEA R1,ARRAYx0000x300Ax0000指针就位
ADD R2,R2,#5x0000x300A#5计数 = 5,P=1
第 1 次循环末#10x300B#4#10P=1 → 跳回
第 2 次循环末#30x300C#3#20P=1 → 跳回
第 3 次循环末#60x300D#2#30P=1 → 跳回
第 4 次循环末#100x300E#1#40P=1 → 跳回
第 5 次循环末#150x300F#0#50Z=1, P=0 → 不跳,落到 HALT

R0 = x0096 = 150,正是 10+20+30+40+50。注意 R1 结束时指向 x300F(数组之后的第一个字), 这已经”越界”了一个字——把指针留在越界位置是常见隐患,若紧接着去 LDR 就会读到垃圾。

示例 2:C 版本的”计算机很笨”——把地址当数字相加

代码 (C):

/* ECE 220 -- Lecture 1 example: "computers are dumb".
 *
 * The LC-3 executes R3 <- R1 + R2 with no idea what the bits mean.
 * Here we reproduce, in C, the slide example in which a student adds the
 * *addresses* of the strings "19" and "23" and gets a third address.
 */
#include <stdio.h>
#include <stdint.h>
#include <string.h>

int main(void)
{
    int32_t addr1 = 0x4012;   /* address of the string "19" */
    int32_t addr2 = 0x7196;   /* address of the string "23" */
    int32_t sum   = addr1 + addr2;

    uint32_t bits = 0x3F800000u;   /* the 32 bits of the IEEE-754 float 1.0f */
    float    as_float;
    int32_t  as_int;

    /* copy the same 32 bits into a float and into an int32_t */
    memcpy(&as_float, &bits, sizeof(as_float));
    memcpy(&as_int,   &bits, sizeof(as_int));

    printf("R1 = x%04X   (address of \"19\")\n", (unsigned)(addr1 & 0xFFFF));
    printf("R2 = x%04X   (address of \"23\")\n", (unsigned)(addr2 & 0xFFFF));
    printf("R3 = x%04X   (R1 + R2: an address, NOT 19 + 23)\n",
           (unsigned)(sum & 0xFFFF));
    printf("M[x%04X] is one word past the NUL of \"23\" -- meaningless here\n",
           (unsigned)(sum & 0xFFFF));

    printf("\n");
    printf("the same 32 bits x%08X are:\n", (unsigned)bits);
    printf("  an IEEE-754 float -> %f\n", (double)as_float);
    printf("  a signed int32_t  -> %d\n", (int)as_int);
    printf("sizeof(int32_t) = %d bytes, sizeof(float) = %d bytes\n",
           (int)sizeof(int32_t), (int)sizeof(float));
    return 0;
}

编译与运行(本机 gcc 12.2.0):

$ gcc -g -std=c99 -Wall -Werror ece220_l01_dumb.c -o ece220_l01_dumb
$ ./ece220_l01_dumb
R1 = x4012   (address of "19")
R2 = x7196   (address of "23")
R3 = xB1A8   (R1 + R2: an address, NOT 19 + 23)
M[xB1A8] is one word past the NUL of "23" -- meaningless here

the same 32 bits x3F800000 are:
  an IEEE-754 float -> 1.000000
  a signed int32_t  -> 1065353216
sizeof(int32_t) = 4 bytes, sizeof(float) = 4 bytes

【代码做什么?】

  1. 用两个 int32_t 保存两份”地址”,与幻灯片上 LC-3 的 R1、R2 完全对应。
  2. sum = addr1 + addr2 就是那条 ADD R3,R1,R2;结果 x4012 + x7196 = xB1A8(16 位截断后),与 LC-3 的结果逐位相同
  3. 打印 M[xB1A8] 的说明:它落在 "23" 的 NUL 之后的第四个字节处,没有任何含义。
  4. 后半段演示”同一串比特,不同类型解读不同”:x3F800000 当作浮点是 1.000000,当作有符号整数是 1065353216
  5. 打印 sizeof 说明”类型”的唯一实际作用之一就是告诉编译器一次运算动多少字节

【底层机制透视】

  • memcpy(&as_float, &bits, 4) 做的是纯粹的比特复制,不进行任何数值转换:这正是”机器只搬比特”的 C 级证据。 用 as_float = (float)bits; 则是数值转换,结果是 1065353216.0f,语义完全不同——这是 C 新手最常见的混淆点。
  • (unsigned)(addr1 & 0xFFFF) 里的 & 0xFFFF 模拟了 LC-3 的 16 位寄存器宽度:LC-3 的 ADD 天然丢弃第 16 位以上的进位。 C 的 int32_t 不会自动截断,所以这里必须显式模仿。
  • 在真正的机器上,"19""23" 是编译器放进只读数据段的两个字符数组,程序里出现的是它们的地址(指针)。 "19" + "23" 这种写法在 C 里甚至无法编译通过——两个指针不能相加——但 LC-3 没有类型,所以它能”顺利”执行并给你一个垃圾地址。

【内存布局图解】

        .rodata(只读数据,相当于 LC-3 的代码/数据区)
        +--------+--------+--------+
        |  '1'   |  '9'   | NUL    |   "19"   起始地址 0x4020(示例)
        +--------+--------+--------+
        |  '2'   |  '3'   | NUL    |   "23"   起始地址 0x4024
        +--------+--------+--------+

        栈帧(automatic storage duration)
        +------------------+  0x7ffd...
        |  addr1 = 0x4012  |
        +------------------+
        |  addr2 = 0x7196  |
        +------------------+
        |  sum   = 0xB1A8  |   ← 一个"指向垃圾"的地址
        +------------------+
        |  bits  = 0x3F800000
        +------------------+
        |  as_float        |   ← 与 bits 相同的 32 位,按 IEEE-754 解读
        +------------------+
        |  as_int          |   ← 与 bits 相同的 32 位,按补码解读
        +------------------+

【与汇编的对应】

; C: int32_t addr1 = 0x4012;  int32_t addr2 = 0x7196;  int32_t sum = addr1 + addr2;
; 汇编里变量存在内存(或寄存器)里,先搬到寄存器再运算
        LD   R1,ADDR1       ; R1 ← M[ADDR1]  = x4012
        LD   R2,ADDR2       ; R2 ← M[ADDR2]  = x7196
        ADD  R3,R1,R2       ; R3 ← xB1A8(第 16 位以上被丢弃)

; C: memcpy(&as_float, &bits, 4) —— 4 个字节的纯粹复制,不做任何数值转换
; LC-3 是字可寻址的,所以"4 字节"就是两个字的搬运(LDR/STR 各两次)

ADDR1   .FILL x4012
ADDR2   .FILL x7196

示例 3:内存映射在真实程序中的样子

代码 (C):

/* ECE 220 -- Lecture 1 example: the memory map, seen from C. */
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

static int32_t global_initialized = 0x220;   /* global data area  */
static int32_t global_zero;                  /* zero-filled (.bss) */

int main(void)
{
    int32_t  automatic = 1;          /* stack (automatic storage)  */
    static int32_t static_local = 2; /* still global data area     */
    int32_t *heap = malloc(4 * sizeof(int32_t));  /* heap          */
    uintptr_t a_heap;
    uintptr_t a_stack;
    uintptr_t a_global;

    if (heap == NULL) {
        printf("malloc failed\n");
        return 1;
    }
    heap[0] = 0x220;

    a_global = (uintptr_t)&global_initialized;
    a_heap   = (uintptr_t)heap;
    a_stack  = (uintptr_t)&automatic;

    printf("&main              = %p   (code)\n", (void *)&main);
    printf("&global_initialized= %p   (global data)\n",
           (void *)&global_initialized);
    printf("&global_zero       = %p   (global data, zero-filled)\n",
           (void *)&global_zero);
    printf("&static_local      = %p   (global data, static duration)\n",
           (void *)&static_local);
    printf("heap (malloc)      = %p   (heap)\n", (void *)heap);
    printf("&automatic         = %p   (stack)\n", (void *)&automatic);

    printf("\n");
    printf("heap   is %lu bytes above global data\n",
           (unsigned long)(a_heap - a_global));
    printf("stack  is %lu bytes above heap\n",
           (unsigned long)(a_stack - a_heap));
    printf("global_zero-static_local = %ld (static locals sit with globals)\n",
           (long)((intptr_t)&static_local - (intptr_t)&global_zero));
    printf("on this machine the stack is at the %s addresses\n",
           (a_stack > a_heap) ? "HIGHER" : "lower");

    free(heap);
    return 0;
}

编译与运行:

$ gcc -g -std=c99 -Wall -Werror ece220_l01_memmap.c -o ece220_l01_memmap
$ ./ece220_l01_memmap
&main              = 0x401166   (code)
&global_initialized= 0x404050   (global data)
&global_zero       = 0x40405c   (global data, zero-filled)
&static_local      = 0x404054   (global data, static duration)
heap (malloc)      = 0xcd42a0   (heap)
&automatic         = 0x7ffd355f679c   (stack)

heap   is 9241168 bytes above global data
stack  is 140725485446396 bytes above heap
global_zero-static_local = -8 (static locals sit with globals)
on this machine the stack is at the HIGHER addresses

heap&automatic 的数值以及两个”相距多少字节”每次运行都不同——本机开了 ASLR;上面是一次真实运行的输出, 但相对次序global_zero - static_local = -8 这条结论恒定不变。)

【代码做什么?】

  1. 在五个不同的位置各声明一个对象:代码(函数)、全局数据、零初始化数据、静态局部变量、自动变量、堆。
  2. %p 打印各自的地址,注意地址是运行期决定的(本机开启了 ASLR,每次运行都会变),但相对次序永远不变
  3. 计算并打印相对距离,验证”代码 < 全局数据 < 堆 < 栈”这个次序与 LC-3 内存图一致。
  4. global_zero - static_local == -8 说明:static 局部变量并不在栈上,它与全局变量住在一起。
  5. free(heap) 归还堆空间,体现堆的存储期由程序员显式控制。

【底层机制透视】

  • 编译产物分成若干段 (section).text(代码)、.rodata(字符串常量)、.data(有初值的全局/静态变量)、 .bss(初值为 0 的全局/静态变量)。global_initialized.dataglobal_zero.bss—— 它们在内存图上相邻,但只有 .data 的内容需要存进可执行文件(.bss 只记录大小,运行时清零)。
  • &static_local 落在全局数据区,正是”静态存储期 (static storage duration)”在物理上的表现: 它在程序启动前就存在、程序结束才消失,与函数调用无关。
  • 栈地址比堆高得多、且两者相距极远,是因为栈从高地址往下长、堆从低地址往上长,在中间留着大块未映射的空隙。 这与 LC-3 的内存图完全同构;LC-3 只是把地址空间缩小到 64K 个字。
  • 把函数指针转成 void * 打印属于实现定义行为(POSIX 保证可行),此程序在 x86-64 gcc 上稳定输出 0x401166 一类的低地址。

【内存布局图解】

低地址  0x401166  ┌──────────────┐  .text        &main(代码)
                  │     ...      │
        0x404050  ├──────────────┤  .data        global_initialized = 0x220
        0x404054  ├──────────────┤  .data        static_local = 2(静态存储期!)
        0x40405c  ├──────────────┤  .bss         global_zero(运行时清零)
                  │     ...      │
                  │    (空隙)   │  ← 堆向上长、栈向下长,在这中间相遇
                  │     ...      │
        0xcd42a0  ├──────────────┤  heap         malloc 返回的 16 字节
                  │     ...      │
高地址  0x7ffd... ├──────────────┤  stack       &automatic(automatic 存储期)
                  └──────────────┘

【与汇编的对应】

; LC-3 里同样的五个位置
;   代码        —— 从 x3000 开始,PC 在这里跑
;   全局数据    —— R4 指向这里(本课约定 x4000 起)
;   堆          —— 由 malloc 的实现(MP 中的 LC-3 分配器)用 R4 之上的空间管理
;   栈          —— R6 指向栈顶,向低地址增长;R5 是当前帧指针

        LEA  R4,GLOBAL_START   ; 初始化全局数据指针(程序启动代码负责)
        ; C 的 static int32_t static_local = 2; 编译成:
        LD   R0,STATIC_LOCAL   ; 直接从全局数据区取,不用 R5/R6
        ; C 的 int32_t automatic = 1; 编译成:
        ADD  R6,R6,#-1         ; 在栈帧里要一个槽
        AND  R0,R0,#0
        ADD  R0,R0,#1
        STR  R0,R5,#-1         ; 局部变量放在 R5-1

GLOBAL_START .FILL #0
STATIC_LOCAL .FILL #2

常见错误与调试技巧

  • 把地址当数值参与运算:像 "19" + "23" 那样把指针或地址直接相加,得到 xB1A8 这类垃圾地址。 现象是访问越界、结果莫名其妙。调试:在 C 中用 printf("%p\n", (void *)p); 确认拿到的确实是地址而不是数值; 在 LC-3 中用 lc3simdump 命令查看该地址处的内容,再用 list 对照它属于哪个数据块。
  • 忘记 LC-3 是”字可寻址”:以为 ADD R1,R1,#1 前进一个字节。现象是遍历 char 数组时每次跳过 2 字节而漏掉一半元素。 调试:在 C 中用 printf("%td\n", (char *)p - (char *)q) 观察真实字节差;记住 LC-3 上一律是”字”。
  • 用错寄存器破坏了调用约定:把 R4/R5/R6/R7 当临时寄存器乱用(例如用 R6 存循环计数)。 现象是返回后崩溃、RET 跳到错误位置。调试:在每条子程序开头写寄存器用途表注释; 在 gdb 中用 info registerslayout regs 观察 rsp/rbp(对应 R6/R5)是否被破坏。
  • BR 与条件码之间插入了别的指令ADD R2,R2,#-1 之后本应紧跟 BRp,中间插了 LDR 就按 LDR 的结果分支。 现象是循环次数差 1 或死循环。调试:在 lc3sim 里对循环回边设置断点(break LOOP), 每次停下用 print R2printpsr 看 N/Z/P,逐次核对是否与手算一致。
  • 偏移量超出位宽限制LDRoffset6 只有 −32..31,BRPCoffset9 只有 −256..255,JSRPCoffset11 只有 ±1024。 现象是汇编器报 “offset out of range”。调试:用 LEA 先算基址再用 LDR/STR 间接访问; 远距离跳转改用 JSRR(配合 LEA R7,label 一类技巧)或拆成两跳。
  • 误以为内存有”类型”:把 x3F800000 当成整数却期望它是 1.0。现象是打印出 1065353216调试gdbx/4xb &bits 看字节、p *(float *)&bitsp *(int32_t *)&bits 解读同一地址。
  • 忘记 HALT:程序”跑完”却没停机,PC 继续执行到 .FILL 的数据区,把数据当指令执行,仿真器行为完全不可解释。 调试lc3sim 中单步 step 并开 list 观察 PC 是否越过最后一条指令;课程 MP 明确惩罚”执行数据”。

关键要点

  • ECE 220 是自底向上的课程:每一个 C 概念都必须能追溯到 ECE 120 的底层机制(补码、ASCII、寄存器、内存映射、栈、调用约定)。 学习本课时,看到任何新概念都要问一句”它在机器里对应什么”。
  • 计算机只做取指—译码—执行,所有含义都来自约定。”字符串是地址 + NUL”、”栈向低地址增长”、 “R6 是栈指针”都是约定;约定不遵守,机器照跑不误,但结果毫无意义。
  • LC-3 的核心参数必须背熟:16 位字长、16 位地址、65536 个字、字可寻址、8 个通用寄存器、N/Z/P 条件码。 立即数位宽(imm5 = 5 位、offset6 = 6 位、PCoffset9 = 9 位)决定了哪些操作需要拆成多条指令。
  • 内存映射的五段(系统空间 / 代码 / 全局数据 / 堆 / 栈)不是硬件规定,而是软件约定 + 硬件配合的产物; 堆向上、栈向下相向增长,这就是 C 中 static / automatic / allocated 三种存储期的物理来源。
  • 良好设计的可操作版本:先画图、先写注释、避免复制代码、把功能切开以便单独测试——这些习惯在汇编阶段看起来”多余”, 到了 C、数据结构与递归阶段会决定你是”能debug”还是”被bug埋掉”。

思考题(带答案)

问题 1:LC-3 的地址是 16 位、可寻址单位是一个字(16 位)。请回答:(a) 总共有多少个可寻址的字? (b) 如果换成字节可寻址(如真实机器),同一个 16 位地址最多能覆盖多少字节? (c) 为什么 ADD R1,R1,#1 在遍历 int32_t 数组时在 C 里要写成 p + 1 而不是 p + 2

答案:(a) 2^16 = 65,536 个字,即 128 KiB。(b) 16 位地址最多寻址 65,536 个字节(64 KiB)—— 这就是为什么”字可寻址”的 LC-3 空间看起来比同地址宽度的字节寻址机器大:字长变大,地址仍只数”格子”。 (c) 因为 C 的指针算术按所指类型的大小缩放int32_t *p; p + 1 意味着”下一个 int32_t“, 编译器生成的是 地址 + 4(x86-64)或 地址 + 2(LC-3 上一个 int32_t 占两个字)。 LC-3 汇编里没有类型,ADD R1,R1,#1 就是加一个,所以它等价于 C 中”下一个 16 位单元”。

问题 2:下面这段代码在 LC-3 上会打印出什么?为什么它与”人以为的答案”不同?

        .ORIG x3000
        LEA R0,A
        PUTS
        HALT
A       .STRINGZ "41,962"
B       .STRINGZ "41321"
        .END

答案:只打印 41,962。若要比较两个字符串(幻灯片中的例子),LC-3 会逐字比较 ASCII 值: , = x2C 小于 '3' = x33,因此按 ASCII 顺序 "41,962" 排在 "41321" 之前,而人按数值认为 41321 < 41962。机器没有错——它执行的是”按字符编码逐位比较”这个约定; “字符串转成数值再比较”必须由程序显式完成(这也是 MP2/MP3 中要自己写转换代码的原因)。

问题 3:为什么”每复制一次代码就复制了一份 bug”?请用本讲的内存图解释代码复用(子程序)在机器层面的收益。

答案:复制出来的代码在内存里是另一份独立的指令序列,它与原版没有任何共享: 修 bug 时只改一处,另一处照旧出错;更糟的是两份副本会逐渐”漂移”,变成两套行为不同的语义。 子程序把指令序列放在内存中的一个位置,所有调用者用 JSR 跳过去、用 RET 跳回来,于是内存占用从 O(调用点数 × 代码长度) 降到 O(代码长度),修改也只有一处。代价是必须约定好”怎么传参、怎么返回、 哪些寄存器会被改”——这就是第 3 讲的调用约定。