• 简体中文
  • Hack 指令集架构(ISA)

    ← 入门教程 · Hack 概览 · English · 汇编器内部原理 →

    本文只讲 Hack ISA 本身:软件能够看到什么状态、机器指令怎样编码,以及一条指令怎样改变状态。详细位图以默认 hack16 profile 为基线,profile 差异会显式标出。运行命令、断言和 .hack 元数据等工具规则见 Hack 包参考手册。

    ISA 到底是什么

    ISA(Instruction Set Architecture,指令集架构)是软件与处理器实现之间的契约。它规定:

    • 处理器有哪些对程序可见的寄存器和内存;
    • 每个机器字怎样解码;
    • 每条指令读取什么、计算什么、写回什么;
    • 程序计数器怎样前进或跳转。

    ISA 不规定加法器由多少个 NAND 门组成、信号需要多久稳定,也不规定流水线或缓存怎样实现。这些属于微架构或电路实现。只要两个处理器对相同机器状态和机器指令产生相同的架构结果,它们就可以实现同一个 ISA。

    本项目涉及的几个层次不要混淆:

    层次示例是否属于 Hack ISA
    Profile 机器指令hack16 为 16 位;hack32 为 32 位是
    标准 Hack 汇编@2、M=D、D;JGT是机器指令的文本表示
    标签与符号(LOOP)、@R0、@variable汇编器语法,不是 CPU 指令
    Hack+ 伪指令SET、JEQ target,label、HALT本项目的汇编便利语法,不属于 ISA
    测试指令.assert、.max_steps本项目的执行元数据,不进入 ROM
    Sail 模型共享 model/*.sail 加 profile 文件/project对 ISA 语义的可执行描述

    最终送给模型 CPU 的始终只有所选 profile 的 A 指令或 C 指令。命令默认选择 hack16,--profile hack32 选择扩展。

    体系结构概览

    两个 profile 都采用分离的指令存储和数据存储:程序从 ROM 取指,数据从 RAM 读写;这通常称为 Harvard 结构。它们共享 15 位 PC/地址空间,但机器字宽度不同。

    架构状态

    状态hack16hack32作用
    A16 位32 位地址/数据寄存器;M 和跳转使用低 15 位地址
    D16 位32 位通用数据寄存器;作为 ALU 的固定输入
    PC15 位15 位下一条 ROM 指令的地址
    RAM32768 × 16 位32768 × 32 位数据地址空间
    ROM32768 × 16 位32768 × 32 位程序机器字;由 driver 加载、模型取指

    汇编语言中的 M 不是独立寄存器,而是当前 A 地址指向的内存:

    M ≡ RAM[A[14:0]]

    标准汇编器符号 R0..R15 是 RAM[0]..RAM[15] 的别名,不是额外的架构寄存器。例如,@R3 把地址 3 装入 A,后续 D=M 读取的就是 RAM[3]。

    hack16 的 A 为 16 位,A 指令携带 15 位立即数;hack32 的 A 为 32 位,A 指令携带 31 位立即数。两个 profile 的 RAM 地址与跳转目标都只使用 A[14:0];hack32 的 A 高位仍参与 32 位 ALU 数据运算。

    Hack 平台的内存映射

    nand2tetris 的完整 Hack 平台通常把数据地址空间解释为:

    地址平台含义
    0..16383普通 RAM
    16384..24575屏幕位图(SCREEN = 16384)
    24576键盘寄存器(KBD = 24576)

    Sail 模型把全部 0..32767 地址实现为普通 RAM。指令寻址规则属于模型范围,屏幕刷新和键盘输入不属于模型范围。

    机器指令格式

    每个 profile 都只有 A、C 两类指令。下面位图展示 canonical 16 位 hack16 布局;hack32 在更宽的编码中复用同一组 C 字段。

    A 指令

    15              0
    ┌─┬───────────────┐
    │0│ vvvvvvvvvvvvvvv│
    └─┴───────────────┘

    标准汇编写作:

    @value

    执行语义:

    A  := zero_extend_16(value)
    PC := (PC + 1) mod 32768

    例如 @42 编码为:

    0000000000101010

    A 指令中的值可以是十进制数或符号,符号解析由汇编器完成。hack16 编码 0 加 15 位立即数;hack32 编码 0 加 31 位立即数。

    C 指令

    15  13 12  6 5  3 2  0
    ┌─────┬───────┬────┬────┐
    │ 111 │a cccccc│ddd │jjj │
    └─────┴───────┴────┴────┘

    标准汇编形式为:

    [dest=]comp[;jump]

    三个字段分别决定:

    • a + comp:ALU 计算什么;
    • dest:结果写入 A、D、M 中的哪些位置;
    • jump:是否把 PC 改为旧 A 指定的地址。

    dest 和 jump 可以省略,但 comp 必须存在。hack32 把上面的 16 位 C 布局放在机器字低半部,高半部固定为 0xFFFF,完整编码为 1111111111111111 111accccccdddjjj。

    comp:ALU 运算

    ALU 的固定输入 x 是 D。a=0 时 y=A,a=1 时 y=M=RAM[A]。

    下表列出汇编器接受的 canonical mnemonic。— 表示该组合没有规范汇编写法。门级 control ALU 对 cccccc 的全部 64 个值都有总函数语义,因此合法 C 机器字可以解码并执行汇编器从不生成的 control 值。

    cccccca=0a=1
    1010100—
    1111111—
    111010-1—
    001100D—
    110000AM
    001101!D—
    110001!A!M
    001111-D—
    110011-A-M
    011111D+1—
    110111A+1M+1
    001110D-1—
    110010A-1M-1
    000010D+AD+M
    010011D-AD-M
    000111A-DM-D
    000000D&AD&M
    010101`DA`

    位运算和算术结果按所选 profile 的机器字宽度截断:hack16 按模 2^16 回绕,hack32 按模 2^32 回绕。Hack 没有独立状态标志寄存器;跳转条件直接检查本次 ALU 输出是否为零以及最高位是否为一。

    dest:写回目标

    ddd 从高到低分别是 A、D、M 的写使能:

    ddd汇编写法写回位置
    000省略不写回
    001MRAM[old_A]
    010DD
    011MDM、D
    100AA
    101AMA、M
    110ADA、D
    111AMDA、M、D

    多个目标在架构上视为同时写回。尤其是 AM=... 或 AMD=...:虽然 A 会得到新值,M 仍写入指令开始时 old_A 指向的位置。

    jump:条件跳转

    跳转把 ALU 输出 out 当作所选 profile 宽度的二进制补码值判断:

    jjj汇编写法条件
    000省略不跳转
    001JGTout > 0
    010JEQout == 0
    011JGEout >= 0
    100JLTout < 0
    101JNEout != 0
    110JLEout <= 0
    111JMP无条件跳转

    条件成立时,目标是指令开始时 A 的低 15 位,而不是 ALU 输出,也不是写回后的新 A。

    一条指令如何执行

    可以把 Sail 中的 execute 理解为下面的架构伪代码。

    A 指令

    A  = zero_extend_to_word(value)
    PC = PC + 1

    C 指令

    old_A    = A
    y        = (a == 0) ? A : RAM[old_A[14:0]]
    out      = ALU(comp, D, y)
    next_PC  = (PC + 1) mod 32768
    
    if dest.A: A = out
    if dest.D: D = out
    if dest.M: RAM[old_A[14:0]] = out
    
    if jump_condition(jump, out):
        PC = old_A[14:0]
    else:
        PC = next_PC

    保存 old_A 是最关键的细节。例如:

    AM=D+1;JGT

    如果条件成立,这条指令会同时:

    1. 计算 D+1;
    2. 把结果写入 A;
    3. 把结果写入旧 A 指向的 RAM;
    4. 跳转到旧 A 指向的 ROM 地址。

    这正是共享 model/core.sail 在 C 指令路径先保留旧 A 的原因。

    标准汇编怎样变成机器码

    汇编器使用两遍处理:

    1. 解析源码,并先把 Hack+ 伪指令展开为标准 A/C 指令;
    2. 第一遍记录每个 (LABEL) 对应的 ROM 地址;标签自身不占 ROM;
    3. 第二遍解析 A 指令符号并编码每条机器指令;
    4. 未预定义的变量符号从 RAM 地址 16 开始依次分配。

    预定义符号包括:

    • R0..R15;
    • SP=0、LCL=1、ARG=2、THIS=3、THAT=4;
    • SCREEN=16384、KBD=24576。

    对于 hack16,A 指令编码为 0 加 15 位值,C 指令拼接为:

    111 + COMP[comp] + DEST[dest] + JUMP[jump]

    对于 hack32,A 指令编码为 0 加 31 位值;C 指令编码为 0xFFFF 加同一条 canonical 16 位 C 编码。

    例如:

    M=D

    对应 comp=D、dest=M、无跳转,因此机器码为:

    111 0001100 001 000
    1110001100001000

    Hack+ 如何降级为正式指令

    Hack+ 的展开在标签解析和机器码编码之前完成。展开后的每一行使用 canonical nand2tetris A/C 汇编语法,最终机器字则采用所选 profile 的编码。

    Hack+ 写法展开后的标准 Hack 指令主要副作用
    SET target, value@value / D=A / @target / M=D改写 A、D、RAM[target]
    MOV target, source@source / D=M / @target / M=D改写 A、D、RAM[target]
    CLR target@target / M=0改写 A、RAM[target]
    INC target@target / M=M+1改写 A、RAM[target]
    DEC target@target / M=M-1改写 A、RAM[target]
    ADD target, source@source / D=M / @target / M=D+MRAM[target] += RAM[source];改写 A、D
    SUB target, source@source / D=M / @target / M=M-DRAM[target] -= RAM[source];改写 A、D
    AND target, source@source / D=M / @target / M=D&M按位更新 RAM[target];改写 A、D
    OR target, source@source / D=M / @target / M=D|M按位更新 RAM[target];改写 A、D
    NEG target@target / M=-M改写 A、RAM[target]
    NOT target@target / M=!M改写 A、RAM[target]
    NOP0只推进 PC,不改变数据状态
    GOTO label@label / 0;JMP改写 A、跳转
    JNZ/JNE target, label@target / D=M / @label / D;JNE改写 A、D;非零时跳转
    JGT target, label@target / D=M / @label / D;JGT改写 A、D;大于零时跳转
    JEQ target, label@target / D=M / @label / D;JEQ改写 A、D;等于零时跳转
    JGE target, label@target / D=M / @label / D;JGE改写 A、D;大于等于零时跳转
    JLT target, label@target / D=M / @label / D;JLT改写 A、D;小于零时跳转
    JLE target, label@target / D=M / @label / D;JLE改写 A、D;小于等于零时跳转
    HALT私有标签 + @私有标签 / 0;JMP在所选 profile 中形成自循环

    几个容易忽略的点:

    • SET R0, R1 把符号 R1 的地址值 1写入 R0;复制 RAM[R1] 应使用 MOV R0, R1;
    • 二元内存运算采用目标在前的顺序:SUB R0, R1 表示 RAM[R0] -= RAM[R1];
    • MOV、二元内存运算和条件伪指令都会覆盖 D;
    • JNZ 与 JNE 等价,后者与正式 Hack 跳转助记符一致;
    • HALT 不是 Hack 指令。汇编器为每个 HALT 生成唯一的 __HACKPLUS_HALT_n 标签和两指令自循环,并把该 ROM 地址记录为执行元数据;本项目 executor 到达该地址时结束,而实现所选 profile 的机器会停留在自循环中。

    完整展开示例

    源码:

    SET R0, 6
    JEQ R0, DONE
    (DONE)
    HALT

    概念上的标准 Hack 汇编结果:

    @6
    D=A
    @R0
    M=D
    
    @R0
    D=M
    @DONE
    D;JEQ
    
    (DONE)
    (__HACKPLUS_HALT_0)
    @__HACKPLUS_HALT_0
    0;JMP

    随后第一遍汇编才计算 DONE 和私有 HALT 标签的 ROM 地址,第二遍再生成所选 profile 的机器字。因此,伪指令展开产生的额外指令会真实占用 ROM 地址,标签地址也以展开后的程序为准。

    这份 ISA 如何映射到 Sail

    当前模型把共享语义与 profile 位布局分开:

    Sail 路径责任
    model/core.sail共享 instruction/exception、total 64-control ALU、架构状态、fetch、带合法性检查的 decode、execute、step,以及编码时直接使用的 scattered encdec 声明
    model/profiles/hack16.sail / hack32.sail单一 profile 入口:定义位宽与合法性、include core.sail,再提供双向 A/C mapping clauses
    projects/hack16.sail_project / hack32.sail_project每个可构建 profile 的单文件源码闭包

    C 指令字段使用命名位向量类型,而不是匿名的 bit / bits(n) 位置。这使 decoded instruction、alu 和 should_jump 的接口能够直接表达字段角色,同时不改变原始编码域。

    decode_hack 先检查合法性,再调用 profile mapping。hack16 中最高位为 0 的机器字是 A 指令,只有 111 前缀是 C 指令,100、101、110 非法。hack32 中最高位为 0 的机器字是 A 指令;C 指令必须同时满足高 16 位为 0xFFFF、低半字以 111 开头,其他最高位为 1 的机器字全部非法。非法机器字会在进入 execute 前抛出 HackIllegalInstruction(word),既保留原始字,也不提交架构状态。

    C 前缀合法后,六位 ALU control、a、dest、jump 的所有组合都属于机器编码域。共享 alu 按所选机器字宽度定义全部 64 种 control 结果;汇编器则有意只生成上表的 canonical mnemonic 子集。汇编语言词表不能替代机器码合法性定义。

    兼容性边界

    hack16 是 canonical nand2tetris 基线。hack32 不是普通 nand2tetris .hack 格式:机器字为 32 位,A 立即数为 31 位,C 指令带额外的 0xFFFF 高半字。汇编 mnemonic 相同不代表二进制可互换;必须显式选择 hack32,并使用理解 profile manifest 的 loader。

    模型边界

    理解测试结果时要区分完整 Hack 平台与本可执行模型:

    • ROM 由所选 profile composition 声明;生成的 driver 初始化 profile 宽度的原始机器字,fetch_hack 和 hack_step 负责取指与分派;
    • RAM 没有 Screen、Keyboard 的设备副作用;
    • HALT、Hack+、.assert 和 .max_steps 都属于工具层,不属于 Hack ISA;
    • 模型描述架构状态转换,不模拟门延迟、时钟边沿细节或 nand2tetris HDL;
    • 模型通过 Sail C 后端执行,不声明已经完成形式化等价证明。

    对照源码学习

    1. 先看本文的 A/C 编码和执行伪代码; 打开一个 model/profiles/ 文件,沿 include 进入 model/core.sail,再回到 profile 的 mapping clauses;编码直接使用 encdec(instruction),执行路径依次阅读 fetch_hack → decode_hack → encdec(word) → execute → hack_step;
    2. 查看 programs/basic_alu.asm,对照标准汇编与 Hack+;
    3. 执行 pixi run just hack assemble basic_alu,查看 isa/hack/.build/hack16/asm/basic_alu/basic_alu.hack;加 --profile hack32 可查看 .build/hack32/asm/basic_alu/basic_alu.hack;
    4. 阅读 tests/sail/hack16/conformance.sail 与 tests/sail/hack32/conformance.sail,观察 profile 规则如何直接变成测试。

    规范来源


    下一步:选择一个扩展,进化你自己的 Hack。