Lecture 1: 课程概述与自底向上方法论;LC-3 机器模型复习 (Course Overview and the Bottom-Up Philosophy; LC-3 Review)
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,R2(0001 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(从
malloc到free,由程序员负责)。
- 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
【代码做什么?】
LEA R1,ARRAY:把标号 ARRAY 的地址(不是内容)装进 R1,此处R1 = x300A。- 三条
AND/ADD指令把R0(和)清零、把R2(计数)设置为 5;因为imm5只能到 15,这里虽然放得下 5,但同样两条指令的习惯写法在后面放 15 时是必需的。 - 进入
LOOP:LDR R3,R1,#0取出当前元素,ADD R0,R0,R3累加。 ADD R1,R1,#1让指针指向下一个字;ADD R2,R2,#-1让计数减一。BRp LOOP:若刚才的ADD使结果为正(P=1)就跳回;等于 0 时 P=0、Z=1,不跳,落到HALT。HALT停机。
【底层机制透视】
- LC-3 没有”数组”这个类型,只有连续的内存字和”用指针加偏移去访问”的机制。数组名
ARRAY在汇编里就是地址常量。 - 循环的循环变量、边界判断全部由程序员手工维护;
BRp只看条件码,而条件码是上一条写寄存器的指令留下的,所以ADD R2,R2,#-1与BRp 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 ← 最后一个元素
【与汇编的对应】(逐条手算执行结果)
| 时刻 | R0 | R1 | R2 | R3 | 说明 |
|---|---|---|---|---|---|
LEA R1,ARRAY 后 | x0000 | x300A | x0000 | — | 指针就位 |
ADD R2,R2,#5 后 | x0000 | x300A | #5 | — | 计数 = 5,P=1 |
| 第 1 次循环末 | #10 | x300B | #4 | #10 | P=1 → 跳回 |
| 第 2 次循环末 | #30 | x300C | #3 | #20 | P=1 → 跳回 |
| 第 3 次循环末 | #60 | x300D | #2 | #30 | P=1 → 跳回 |
| 第 4 次循环末 | #100 | x300E | #1 | #40 | P=1 → 跳回 |
| 第 5 次循环末 | #150 | x300F | #0 | #50 | Z=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
【代码做什么?】
- 用两个
int32_t保存两份”地址”,与幻灯片上 LC-3 的 R1、R2 完全对应。 sum = addr1 + addr2就是那条ADD R3,R1,R2;结果x4012 + x7196 = xB1A8(16 位截断后),与 LC-3 的结果逐位相同。- 打印
M[xB1A8]的说明:它落在"23"的 NUL 之后的第四个字节处,没有任何含义。 - 后半段演示”同一串比特,不同类型解读不同”:
x3F800000当作浮点是1.000000,当作有符号整数是1065353216。 - 打印
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 这条结论恒定不变。)
【代码做什么?】
- 在五个不同的位置各声明一个对象:代码(函数)、全局数据、零初始化数据、静态局部变量、自动变量、堆。
- 用
%p打印各自的地址,注意地址是运行期决定的(本机开启了 ASLR,每次运行都会变),但相对次序永远不变。 - 计算并打印相对距离,验证”代码 < 全局数据 < 堆 < 栈”这个次序与 LC-3 内存图一致。
global_zero - static_local == -8说明:static局部变量并不在栈上,它与全局变量住在一起。free(heap)归还堆空间,体现堆的存储期由程序员显式控制。
【底层机制透视】
- 编译产物分成若干段 (section):
.text(代码)、.rodata(字符串常量)、.data(有初值的全局/静态变量)、.bss(初值为 0 的全局/静态变量)。global_initialized在.data,global_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 中用lc3sim的dump命令查看该地址处的内容,再用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 registers或layout regs观察rsp/rbp(对应 R6/R5)是否被破坏。 BR与条件码之间插入了别的指令:ADD R2,R2,#-1之后本应紧跟BRp,中间插了LDR就按LDR的结果分支。 现象是循环次数差 1 或死循环。调试:在lc3sim里对循环回边设置断点(break LOOP), 每次停下用print R2与printpsr看 N/Z/P,逐次核对是否与手算一致。- 偏移量超出位宽限制:
LDR的offset6只有 −32..31,BR的PCoffset9只有 −256..255,JSR的PCoffset11只有 ±1024。 现象是汇编器报 “offset out of range”。调试:用LEA先算基址再用LDR/STR间接访问; 远距离跳转改用JSRR(配合LEA R7,label一类技巧)或拆成两跳。 - 误以为内存有”类型”:把
x3F800000当成整数却期望它是1.0。现象是打印出1065353216。 调试:gdb中x/4xb &bits看字节、p *(float *)&bits与p *(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 讲的调用约定。
