Nand2Tetris——从与非门到俄罗斯方块,硬件部分总结

0 阅读17分钟

课程资料

逻辑门的抽象与封装

基础逻辑门的搭建

  • 第一层抽象和封装是逻辑门的搭建,对于有限位数(一位或两位)的二进制数字进行运算,它们的运算方式和结果都是有限的,根据这些运算方式我们可以创建出各种逻辑门(And、Or、Not)

  • 当然,它们本身就是 Nand 的抽象和封装

  • Nand 门(输入都为 1 时输出 0,否则输出 1):

07 硬件部分完成总结-1789783766327.png

  • 用Nand 搭建 Not 门

07 硬件部分完成总结-1789783793157.png

  • 用 Nand 搭建的 And 门

07 硬件部分完成总结-1789783821082.png

  • 用 Nand 搭建的 Or 门

07 硬件部分完成总结-1789783958372.png

  • 在Nand门的基础上我们得到了And、Or、Not三个基础逻辑门,现在我们对此进行抽象和封装,不再管它们是如何用Nand门实现的

  • 我们以And、Or、Not三种逻辑门为基础,搭建更高级的逻辑门

更高级的逻辑门搭建

  • Xor 异或门(相同为 0、不同为 1)

07 硬件部分完成总结-1789784062364.png

  • Mux 门(控制器控制输出端选择哪个输入)

07 硬件部分完成总结-1789784141702.png

  • DMux 门(控制器控制输入走向哪个输出)

07 硬件部分完成总结-1789784236763.png

  • 在此基础上,通过更多的输入输出以及控制位以及更复杂的电路,我们可以搭建出 8 位 16 位或更高位数的上述逻辑门...

  • 这里我们不再赘述,我们再次进行抽象和封装,不再管Xor、Mux和DMux门的实现细节

加法器的搭建

  • 以Xor、Mux、DMux为基础,我们根据二进制数计算得到的真值表,搭建半加器(两位二进制数相加)和全加器(多位二进制数相加)

  • 半加器的真值表

02 构建第一个算术逻辑单元(ALU)-1787916676249.png

  • 我们可以发现,sum 在 a b 相同时为 1,不同时为 0,所以 sum = a xor b;carry 则是只有在 a 和 b 都为 1 时才为 1,所以 carry = a and b

  • 所以我们可以搭建出半加器

07 硬件部分完成总结-1789785163731.png

  • 全加器的真值表(以三个值为例)

02 构建第一个算术逻辑单元(ALU)-1787965823765.png

  • 通过真值表,我们可以写出逻辑表达式

  • Sum = (Not a and Not b and c)or (Not a and b and Not c)or (a and Not b and Not c) or (a and b and c)= a xor b xor c

  • Carry = (Not a and b and c)or (a and not b and c) or (a and b and not c)or(a and b and c)= (a and b) or (a and c) or (b and c)

  • 通过表达式,我们可以完成全加器的搭建

07 硬件部分完成总结-1789785193339.png

算数逻辑单元的搭建

  • 有了加法器,我们可以搭建出算数逻辑单元,也就是ALU

07 硬件部分完成总结-1789785242856.png

  • 算数逻辑单元通过控制位来对输入进行对应的运算,得到输出,本质是两种输入的所有输出可能由不同控制位表示

07 硬件部分完成总结-1789785336789.png

  • 通过表格,我们可以写出对应的HDL代码(本质是通过逻辑门和加法器得到两个输入的所有可能输出)
    // Put you code here:
    Mux16(a = x, b = false, sel = zx, out = xzx);
    // 处理第一个控制位zx
    Not16(in = xzx, out = xnot);
    Mux16(a = xzx, b = xnot, sel = nx, out = xnx);
    // 处理第二个控制位nx
    Mux16(a = y, b = false, sel = zy, out = yzy);
    // 处理第三个控制位zy
    Not16(in = yzy, out = ynot);
    Mux16(a = yzy, b = ynot, sel = ny, out = yny);
    // 处理第四个控制位ny
    And16(a = xnx, b = yny, out = fAnd);
    Add16(a = xnx, b = yny, out = fAdd);
    Mux16(a = fAnd, b = fAdd, sel = f, out = outf);
    // 处理第五个控制位f
    Not16(in = outf, out = notoutf);
    Mux16(a = outf, b = notoutf, sel = no, out = out, out[15] = ng, out[0..7] = outlow, out[8..15] = outhigh);
    // 处理第六个控制位no,同时通过最高位判断正负,得到输出ng,并准备处理zr
    Or8Way(in = outlow, out = or1);
    Or8Way(in = outhigh, out = or2);
    Or(a = or1, b = or2, out = nonzero);
    // 16位全为 0 out才是0
    Not(in = nonzero, out = zr);
    // out为0 则zr是1,out是1,zr就是0

内存和程序计数器

  • 通过Mux门和D触发器,我们可以搭建一个存储二进制数字的装置,也就是内存

07 硬件部分完成总结-1789785387174.png

  • 内存的原理是当 load 为 0,我们一直输出上一秒的输入,当 load 为 1 时,我们才输出新的输入——in,in 对应的实际是 Mux 门的 b 输入端

  • 为了让算数逻辑单元按照给定的顺序逐个计算,我们需要搭建一个用来计数的程序计数器PC(对输入值进行加1或重置)

07 硬件部分完成总结-1789787818416.png

  • 程序计数器的底层构造 (是否重置(计数器变为 0),是否将计数器的值变成输入值,是否将计数器的值自加)

07 硬件部分完成总结-1789788577151.png

  • 当我们完成了基础的内存和计数器后,我们再次进行抽象和封装,不再管它的具体实现

CPU和更高级别的内存搭建

  • 通过一位寄存器 + 多位 Mux 门和多位 DMux 门,我们可以做到寻址 + 输入输出

  • 也就可以构造更高位的内存

07 硬件部分完成总结-1789785696256.png

  • DMux 通过 load 位寻址,改变对应寄存器的 load 位;Mux 通过 load 位寻址,输出对应寄存器的输出

07 硬件部分完成总结-1789787683868.png

整个CPU 架构图(将输入指令通过控制位进行执行并将结果进行输出或保存)

07 硬件部分完成总结-1789788716727.png

  • CPU的HDL代码如下(本质是通过对输入的二进制码指令进行拆解,来进行对应的计算和跳转)
    // Put your code here:
    // 1. 控制信号解码 (Instruction Decoding & Control Logic)
    // =================================================================
    // 判定指令类型:A 指令 (instruction[15] == 0) 或 C 指令 (instruction[15] == 1)
    Not(in=instruction[15], out=isA);
    Not(in=isA, out=isC);

    // A 寄存器加载条件:是 A 指令,或 C 指令要求写 A 寄存器 (d1位 = instruction[5])
    And(a=isC, b=instruction[5], out=isCWriteA);
    Or(a=isA, b=isCWriteA, out=loadA);

    // D 寄存器加载条件:C 指令且 d2位 = 1 (instruction[4])
    And(a=isC, b=instruction[4], out=loadD);

    // Memory 写使能条件:C 指令且 d3位 = 1 (instruction[3])
    And(a=isC, b=instruction[3], out=writeM);

    // =================================================================
    // 2. A 寄存器及其数据选择器 (A Register & Input Mux)
    // =================================================================
    // 若为 A 指令,将 instruction 送入 A;若为 C 指令写 A,将 ALU 计算结果送入 A
    Mux16(a=instruction, b=aluOut, sel=isCWriteA, out=inA);

    // A 寄存器输出:同时接入 ALU 输入选择器、PC 以及芯片外接的 addressM 端口
    ARegister(in=inA, load=loadA, out=aOut, out[0..14]=addressM);

    // =================================================================
    // 3. D 寄存器 (D Register)
    // =================================================================
    DRegister(in=aluOut, load=loadD, out=dOut);

    // =================================================================
    // 4. ALU 核心计算单元及输入选择 (ALU & Mux)
    // =================================================================
    // 由 a 位 (instruction[12]) 决定 ALU 的 Y 输入来自 A 寄存器 (0) 还是内存 inM (1)
    Mux16(a=aOut, b=inM, sel=instruction[12], out=aluY);

    // ALU 计算:直接由 c1~c6 (instruction[6..11]) 驱动
    ALU(
        x=dOut,
        y=aluY,
        zx=instruction[11],
        nx=instruction[10],
        zy=instruction[9],
        ny=instruction[8],
        f=instruction[7],
        no=instruction[6],
        out=aluOut,
        out=outM,
        zr=zr,
        ng=ng
    );

    // =================================================================
    // 5. 跳转控制逻辑与程序计数器 (Jump Logic & PC)
    // =================================================================
    // 计算 ALU 结果是否为正数: pos = (非零 AND 非负)
    Not(in=zr, out=notZr);
    Not(in=ng, out=notNg);
    And(a=notZr, b=notNg, out=pos);

    // 比对三位跳转标志位 (j1, j2, j3)
    And(a=instruction[2], b=ng,  out=passLT); // j1: 小于 0
    And(a=instruction[1], b=zr,  out=passEQ); // j2: 等于 0
    And(a=instruction[0], b=pos, out=passGT); // j3: 大于 0

    // 汇总条件:三者满足其一即认为需跳转
    Or(a=passLT, b=passEQ, out=passLE);
    Or(a=passLE, b=passGT, out=jumpCond);

    // 严苛限定:必须是 C 指令且满足跳转条件才允许 PC 载入 A 寄存器的地址
    And(a=isC, b=jumpCond, out=loadPC);

    // 程序计数器:未触发 load 且 reset=0 时,默认保持 inc=true 自增
    PC(in=aOut, load=loadPC, inc=true, reset=reset, out[0..14]=pc);

机器语言到二进制

由于人类无法通过二进制数字和计算机互动,所以我们需要设定一种语言来方便编写程序,同时创建一套规则将这种语言翻译成计算机能够理解的二进制指令

  • 这门课的汇编语言也就是我们所说的规则如下

    • 大小写敏感:所有指令、预定义符号、标签和变量均严格区分大小写
    • 空白字符:行首、行尾空格、Tab 制表符以及空行在汇编时会被直接忽略
    • 注释规则:只支持单行注释 //。双斜杠之后直到行尾的所有字符均被视作注释
    • 字符集限制:合法符号(标签与变量)只能由 字母(A-Z, a-z)、数字(0-9)、下划线(_)、点(.)、美元符号($)和冒号(:) 组成,且不能以数字开头
  • 具体指令

    • A 指令:@value@symbol

      • 作用:将15 位非负整数或符号对应的内存地址加载到寄存器 A 中(后面用到 M,那么整数就同时代表了内存地址)
      • 同时隐式地将内存引用 M 锁定为 RAM[value]
    • C 指令:dest = comp ; jump

      • 作用:将符合 ALU 运算的表达式(comp)的值赋值给目标寄存器(dest)
      • Jump 代表跳转条件,若省略则代表不跳转
  • 二进制编码结构

-   1 1 1 a c1 c2 c3 c4 c5 c6 d1 d2 d3 j1 j2 j3
-   前三位 111 代表着这条指令是 C 指令

-   第四位 a 和 c 1 - c 6 决定了计算方式,也就是前面提到的 ALU 的控制位,
-   当计算涉及当前寄存器 M 时,a 必须为 1,当计算只涉及寄存器 A 和寄存器 D 时,a 为 0

-   计算方式 comp 对应表:

    -   |汇编语法 (a=0)|二进制码 (a c1​c2​c3​c4​c5​c6​)
    -   | `0`| `0 101010`
    -   |`1`|`0 111111`
    -   |`-1`|`0 111010`
    -   |`D`|`0 001100`
    -   |**`A`**|**`0 110000`**
    -   |**`M`**|**`1 110000`**
    -   |`!D`|`0 001101`
    -   |`!A`|`0 110001`
    -   |`!M`|`1 110001`
    -   |`-D`|`0 001111`
    -   |`-A`|`0 110011`
    -   | `-M` | `1 110011`
    -   |`D+1`|`0 011111`
    -   |`A+1`|`0 110111`
    -   |`M+1`|`1 110111`|
    -   |`D-1`|`0 001110`
    -   |`A-1`|`0 110010`
    -   |`M-1`|`1 110010`|
    -   |`D+A`|`0 000010`
    -   |`D+M`|`1 000010`|
    -   |`D-A`|`0 010011`
    -   |`D-M`|`1 010011`|
    -   |`A-D`|`0 000111`
    -   |`M-D`|`1 000111`|
    -   |`D&A`|`0 000000`
    -   |`D&M`|`1 000000`|
    -   |`D|A`|`0 010101`
    -   |`D|M`|`1 010101`|

-   `d1 d2 d3` 代表着目标寄存器,000 代表无,001 代表目前寄存器,010 代表 D 寄存器,100 代表寄存器 A,101 代表同时写入当前寄存器和寄存器 A...
-    也就是说第一位代表 A 寄存器,第二位代表 D 寄存器,末位代表当前寄存器 M

-   `j1 j2 j3` 代表着跳转方式,根据 ALU 的输出来进行跳转,j 1 代表小于 0,j 2 代表等于 0,j 3 代表大于 0,111 代表无条件跳转
  • 我们直接通过例子来看这条规则的运用
@2 
// A指令,将非负整数2加载到寄存器A中 
D=A 
// C指令,把寄存器A中的值给寄存器D 
@3 
// A指令,将非负整数3加载到寄存器A中 
D=D+A 
// C指令,将寄存器D中的值和寄存器A中的值相加,并给寄存器D 
@0 
// A指令,将非负整数0加载到寄存器A中 
M=D 
// 由于用到了M,也就是当前内存地址,也就是0,RAM[0],将寄存器D中的值给RAM[0]
  • 那么这段代码的作用就是计算两个数(2 和 3)的和,并将和存放到 RAM[0]中

  • 我们可以尝试把上面的语句编译成二进制代码

@2
// A指令,将非负整数2加载到寄存器A中
// 由于是A指令,所以首位为0,后面跟上15位代表整数2的二进制补码(正数的补码是它本身)
// 0 000000000000010
D=A
// C指令,把寄存器A中的值给寄存器D
// 由于是C指令,所以前三位固定为111,没用到M和跳转,所以a和j1 j2 j3都为0,目标寄存器是D,所以d1 d2 d3为010,参考计算表,comp代表寄存器A,也就是110000,所以我们可以得出二进制码
// 111 0 110000 010 000
@3
// A指令,将非负整数3加载到寄存器A中
// 0 000000000000011
D=D+A
// C指令,将寄存器D中的值和寄存器A中的值相加,并给寄存器D
// 同样,j1 j2 j3都为0,d1 d2 d3 为 010,comp代表D + A也就是000010
// 111 0 000010 010 000
@0
// A指令,将非负整数0加载到寄存器A中
// 0 000000000000000
M=D
// 由于用到了M,也就是当前内存地址,也就是0,RAM[0],将寄存器D中的值给RAM[0]
// comp中没有M,所以a依然为0,目标为M,那么d1 d2 d3就是001,没有跳转 j1 j2 j3都为0,comp是D,也就是001100
// 111 1 001100 001 000 
  • 我们可以用利用目前的计算表和规则写出一个简单的编译器,以 python 为例
import sys

dest = {"M": "001", "D": "010", "MD": "011", "A": "100", "AM": "101", "AD": "110", "AMD": "111"}
# 目标寄存器对应二进制代码
comp = {
    "0": "0101010",
    "1": "0111111",
    "-1": "0111010",
    "D": "0001100",
    "A": "0110000",
    "!D": "0001101",
    "!A": "0110001",
    "-D": "0001111",
    "-A": "0110011",
    "D+1": "0011111",
    "A+1": "0110111",
    "D-1": "0001110",
    "A-1": "0110010",
    "D+A": "0000010",
    "D-A": "0010011",
    "A-D": "0000111",
    "D&A": "0000000",
    "D|A": "0010101",
    "M": "1110000",
    "!M": "1110001",
    "-M": "1110011",
    "M+1": "1110111",
    "M-1": "1110010",
    "D+M": "1000010",
    "D-M": "1010011",
    "M-D": "1000111",
    "D&M": "1000000",
    "D|M": "1010101",
}
# comp计算指令对应二进制代码
jump = {
    "": "000",
    "JGT": "001",
    "JEQ": "010",
    "JGE": "011",
    "JLT": "100",
    "JNE": "101",
    "JLE": "110",
    "JMP": "111",
}
# 跳转指令对应二进制代码
def assemble(filename):
    origin_text = []
    with open(filename, "r") as f:
        for line in f.readlines():
            if line[0:2] != "//" and line[0] not in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]:
                origin_text.append(line.strip())
            # 跳过两端空格、注释和数字开头的行
    return origin_text

origin_text = assemble(sys.argv[1])
# 获取原始代码
result = []
for line in origin_text:
    if line[0] == "@":
        # A指令
        result.append("0" + bin(int(line[1:]))[2:].zfill(15))
        # 将整数转换成15位二进制
    else:
        # C指令
        dest_mnemonic = line.split("=")[0].split(";")[0]
        comp_mnemonic = line.split("=")[1].split(";")[0]
        jump_mnemonic = line.split("=")[1].split(";")[1] if ";" in line else ""
        dest_binary = dest[dest_mnemonic]
        comp_binary = comp[comp_mnemonic]
        jump_binary = jump[jump_mnemonic]
        result.append("111" + comp_binary + dest_binary + jump_binary)

with open ("result.txt", "a") as f:
    for line in result:
        f.write(str(line) + "\n")
  • 当然,这个简单的编译程序只对应了最基础的部分,为了完成跳转和标签等功能,编译代码会更复杂
  • 优化后的完整编译程序代码如下
import sys

class SymbolTable:
    """管理预定义符号、标签及变量与 RAM/ROM 地址的映射"""
    def __init__(self):
        # 1. 预定义符号 (R0-R15, I/O 映射及系统指针)
        self.table = {f"R{i}": i for i in range(16)}
        self.table.update({
            "SP": 0, "LCL": 1, "ARG": 2, "THIS": 3, "THAT": 4,
            "SCREEN": 16384, "KBD": 24576
        })
        self.ram_address = 16  # 自定义变量从 RAM[16] 开始分配

    def add_entry(self, symbol: str, address: int):
        self.table[symbol] = address

    def contains(self, symbol: str) -> bool:
        return symbol in self.table

    def get_address(self, symbol: str) -> int:
        """读取符号地址,若为全新变量则在 RAM[16+] 动态分配"""
        if not self.contains(symbol):
            self.add_entry(symbol, self.ram_address)
            self.ram_address += 1
        return self.table[symbol]


class Code:
    """将汇编指令的各个文本字段翻译为二进制编码"""
    DEST_MAP = {
        "": "000", "M": "001", "D": "010", "MD": "011",
        "A": "100", "AM": "101", "AD": "110", "AMD": "111"
    }
    JUMP_MAP = {
        "": "000", "JGT": "001", "JEQ": "010", "JGE": "011",
        "JLT": "100", "JNE": "101", "JLE": "110", "JMP": "111"
    }
    COMP_MAP = {
        "0": "0101010", "1": "0111111", "-1": "0111010",
        "D": "0001100", "A": "0110000", "!D": "0001101", "!A": "0110001",
        "-D": "0001111", "-A": "0110011", "D+1": "0011111", "A+1": "0110111",
        "D-1": "0001110", "A-1": "0110010", "D+A": "0000010", "D-A": "0010011",
        "A-D": "0000111", "D&A": "0000000", "D|A": "0010101",
        "M": "1110000", "!M": "1110001", "-M": "1110011", "M+1": "1110111",
        "M-1": "1110010", "D+M": "1000010", "D-M": "1010011", "M-D": "1000111",
        "D&M": "1000000", "D|M": "1010101"
    }

    @classmethod
    def dest(cls, mnemonic: str) -> str: return cls.DEST_MAP.get(mnemonic, "000")
    @classmethod
    def jump(cls, mnemonic: str) -> str: return cls.JUMP_MAP.get(mnemonic, "000")
    @classmethod
    def comp(cls, mnemonic: str) -> str: return cls.COMP_MAP.get(mnemonic, "0000000")


class Parser:
    """清理源码格式并拆解当前指令的各个组成部分"""
    def __init__(self, filepath: str):
        with open(filepath, 'r') as f:
            # 清理整行注释、行尾注释及无用空白字符
            self.lines = [
                line.split('//')[0].strip().replace(" ", "")
                for line in f if line.split('//')[0].strip()
            ]
        self.cursor = -1

    def has_more_commands(self) -> bool:
        return self.cursor + 1 < len(self.lines)

    def advance(self):
        self.cursor += 1
        self.current = self.lines[self.cursor]

    def command_type(self) -> str:
        if self.current.startswith('@'): return 'A_COMMAND'
        if self.current.startswith('(') and self.current.endswith(')'): return 'L_COMMAND'
        return 'C_COMMAND'

    def symbol(self) -> str:
        if self.command_type() == 'A_COMMAND': return self.current[1:]
        if self.command_type() == 'L_COMMAND': return self.current[1:-1]

    def dest(self) -> str:
        return self.current.split('=')[0] if '=' in self.current else ''

    def comp(self) -> str:
        comp_part = self.current.split('=')[1] if '=' in self.current else self.current
        return comp_part.split(';')[0]

    def jump(self) -> str:
        return self.current.split(';')[1] if ';' in self.current else ''

    def reset(self):
        self.cursor = -1


def assemble(asm_filepath: str) -> str:
    """双趟扫描驱动核心函数"""
    symbol_table = SymbolTable()
    parser = Parser(asm_filepath)

    # ================= 第一趟扫描:绑定标签 (L_COMMAND) 与 ROM 行号 =================
    rom_address = 0
    while parser.has_more_commands():
        parser.advance()
        if parser.command_type() == 'L_COMMAND':
            symbol_table.add_entry(parser.symbol(), rom_address)
        else:
            rom_address += 1  # 只有真正的指令才占用 ROM 空间

    # ================= 第二趟扫描:翻译指令并解析变量/数值 =================
    parser.reset()
    binary_instructions = []

    while parser.has_more_commands():
        parser.advance()
        cmd_type = parser.command_type()

        if cmd_type == 'A_COMMAND':
            sym = parser.symbol()
            # 判断是纯数值字面量还是符号
            val = int(sym) if sym.isdigit() else symbol_table.get_address(sym)
            binary_instructions.append(f"0{val:015b}")

        elif cmd_type == 'C_COMMAND':
            c_bin = Code.comp(parser.comp())
            d_bin = Code.dest(parser.dest())
            j_bin = Code.jump(parser.jump())
            binary_instructions.append(f"111{c_bin}{d_bin}{j_bin}")

        # L_COMMAND 在第二趟扫描中直接跳过

    return "\n".join(binary_instructions)

# 运行示例
if __name__ == "__main__":
    if len(sys.argv) > 1:
        hack_code = assemble(sys.argv[1])
        print(hack_code)
  • 现实中的汇编代码远比课程中的要复杂的多,但其核心概念是不变的,我们只需要知道它是用来将代码转换成二进制指令的规则即可

总结

  • 虽然课程中具体的搭建方式和现实中略有出入,但核心思想是相同的。所有复杂的计算和构造都是从最简单的逻辑和元件一步步抽象封装而成,所有计算机的操作从本质上来说就是二进制数字的运算