From Nand to Tetris 里的 Project 6 (用 java 实现)

58 阅读7分钟

背景

让我们按照 From Nand to Tetris 里 Project 6: Assembler 的要求,来设计一个 Assembler (汇编器) 。点击下图红色箭头所示位置,可以看到详细的描述。

image.png

《计算机系统要素 (第2版)》 一书的第 6 章也有相关描述。

正文

《计算机系统要素 (第2版)》 一书的第 798081 页有如下描述 image.png

image.png

image.png

《计算机系统要素 (第2版)》 中第 6 章的内容,对我完成 Project 6 颇有帮助(但在本文中限于篇幅,不便一一截图)。如果读者朋友在实现 Project 6 遇到问题的话,可以参考

  1. 《计算机系统要素 (第2版)》 中第 6 章的内容
  2. From Nand to Tetris 里的相关描述

后者的位置如下图红色箭头所示 image.png

我写的 java 代码

我在 Project 6 中,一共写了下列 5 class

  1. Parser
  2. Code
  3. Assembler
  4. SymbolTable
  5. InstructionType

它们都在 Assembler.java 中,Assembler.java 的具体内容如下

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Stream;

public class Assembler {
    private final String filename;
    private final Parser parser;
    private final Code code;
    private final SymbolTable symbolTable;
    private final List<String> hackCodeLines;

    public Assembler(String filename) throws Exception {
        this.filename = filename;
        parser = new Parser(filename);
        code = new Code();
        symbolTable = new SymbolTable();
        hackCodeLines = new ArrayList<>();
    }

    /**
     * Scan the first round to handle labels (i.e. {@link InstructionType#L_INSTRUCTION})
     */
    private void scanFirstRound() {
        int actualLineNum = 0;
        while (parser.hasMoreLines()) {
            parser.advance();

            if (parser.instructionType() == InstructionType.L_INSTRUCTION) {
                symbolTable.addEntry(parser.symbol(), actualLineNum);
            } else {
                actualLineNum++;
            }
        }
    }

    private void scanSecondRound() {
        parser.reset();

        while (parser.hasMoreLines()) {
            parser.advance();

            switch (parser.instructionType()) {
                case A_INSTRUCTION -> hackCodeLines.add(translateAType());
                case C_INSTRUCTION -> hackCodeLines.add(translateCType());
                case L_INSTRUCTION -> {
                    // just do nothing
                }
            }
        }
    }

    private int getAddress(String symbol) {
        if (!symbolTable.contains(symbol)) {
            symbolTable.addEntry(symbol, symbolTable.consumeAvailableAddress());
        }
        return symbolTable.getAddress(symbol);
    }

    private boolean isConstant(String symbol) {
        char firstChar = symbol.charAt(0);
        return firstChar >= '0' && firstChar <= '9';
    }

    private String translateAType() {
        String symbol = parser.symbol();
        if (isConstant(symbol)) {
            return translateAType(Integer.parseInt(symbol));
        }
        int address = getAddress(symbol);
        return translateAType(address);
    }

    private String translateAType(int value) {
        String result = Integer.toBinaryString(value);
        int lengthDiff = 16 - result.length();
        return "0".repeat(lengthDiff) + result;
    }

    private String translateCType() {
        String comp = code.comp(parser.comp());
        String dest = code.dest(parser.dest());
        String jump = code.jump(parser.jump());

        return String.format("111%s%s%s", comp, dest, jump);
    }

    public List<String> translate() {
        scanFirstRound();
        scanSecondRound();

        return hackCodeLines;
    }

    private Path buildOutputPath() {
        var path = Paths.get(filename);

        var asmFilename = path.toFile().getName();
        var hackFilename = asmFilename.substring(0, asmFilename.length() - ".asm".length()) + ".hack";

        var directory = path.toAbsolutePath().toFile().getParent();
        return Paths.get(directory, hackFilename);
    }

    public static void main(String[] args) throws Exception {
        var assembler = new Assembler(args[0]);

        List<String> hackCodeLines = assembler.translate();
        String allHackCode = String.join(System.lineSeparator(), hackCodeLines);

        var outputPath = assembler.buildOutputPath();
        Files.writeString(outputPath, allHackCode);
    }
}

class Parser {
    private final List<String> lines;
    private int nextLineNum;
    private final int totalLineNum;
    private String currentLine;
    private InstructionType currentType;

    public Parser(String fileName) throws Exception {
        try (var rawLines = Files.lines(Paths.get(fileName))) {
            this.lines = preProcess(rawLines);
        }
        nextLineNum = 0;
        totalLineNum = lines.size();
        currentLine = null;
        currentType = null;
    }

    public void reset() {
        nextLineNum = 0;
        currentLine = null;
        currentType = null;
    }

    public boolean hasMoreLines() {
        return nextLineNum < totalLineNum;
    }

    /**
     * Pre-process
     * 1. Skip empty lines and comment lines
     * 2. Trim each line
     */
    private List<String> preProcess(Stream<String> rawLines) {
        return rawLines.map(String::trim)
                .filter(line -> !line.isEmpty())
                .filter(line -> !line.startsWith("//"))
                .toList();
    }

    public void advance() {
        if (!hasMoreLines()) {
            throw new IllegalStateException("The file is already exhausted");
        }
        currentLine = lines.get(nextLineNum++);
        currentType = instructionType();
    }

    public InstructionType instructionType() {
        if (currentLine.startsWith("@")) {
            return InstructionType.A_INSTRUCTION;
        }
        if (currentLine.startsWith("(")) {
            return InstructionType.L_INSTRUCTION;
        }
        return InstructionType.C_INSTRUCTION;
    }

    public String symbol() {
        if (!EnumSet.of(InstructionType.A_INSTRUCTION, InstructionType.L_INSTRUCTION).contains(currentType)) {
            throw new IllegalStateException("The current instruction belongs to neither A type nor L type!");
        }
        if (currentLine.startsWith("(")) {
            return currentLine.substring(1, currentLine.length() - 1).trim();
        }
        if (currentLine.startsWith("@")) {
            return currentLine.substring(1);
        }
        throw new IllegalArgumentException("Unexpected line: " + currentLine);
    }

    public String dest() {
        if (currentType != InstructionType.C_INSTRUCTION) {
            throw new IllegalStateException("The current instruction doesn't belong to C type!");
        }
        int index = currentLine.indexOf("=");
        if (index >= 0) {
            return currentLine.substring(0, index);
        }
        return null;
    }

    public String comp() {
        if (currentType != InstructionType.C_INSTRUCTION) {
            throw new IllegalStateException("The current instruction doesn't belong to C type!");
        }

        int index1 = currentLine.indexOf("=");
        int index2 = currentLine.indexOf(";");
        if (index1 < 0 && index2 < 0) {
            return currentLine;
        }
        if (index1 < 0) {
            return currentLine.substring(0, index2);
        }
        if (index2 < 0) {
            return currentLine.substring(index1 + 1);
        }
        return currentLine.substring(index1 + 1, index2);
    }

    public String jump() {
        if (currentType != InstructionType.C_INSTRUCTION) {
            throw new IllegalStateException("The current instruction doesn't belong to C type!");
        }

        int index = currentLine.indexOf(";");
        if (index >= 0) {
            return currentLine.substring(index + 1);
        }
        return null;
    }
}

class Code {
    public String dest(String rawDest) {
        if (rawDest == null) {
            return "000";
        }

        char[] cs = new char[]{'0', '0', '0'};
        for (char item : rawDest.toCharArray()) {
            switch (item) {
                case 'A' -> cs[0] = '1';
                case 'D' -> cs[1] = '1';
                case 'M' -> cs[2] = '1';
                default -> throw new IllegalArgumentException("Unexpected dest: " + rawDest);
            }
        }

        return new String(cs);
    }

    public String comp(String rawComp) {
        int a = (rawComp.contains("M")) ? 1 : 0;
        return a + switch (rawComp) {
            case "0" -> "101010";
            case "1" -> "111111";
            case "-1" -> "111010";
            case "D" -> "001100";
            case "A", "M" -> "110000";
            case "!D" -> "001101";
            case "!A", "!M" -> "110001";
            case "-D" -> "001111";
            case "-A", "-M" -> "110011";
            case "D+1" -> "011111";
            case "A+1", "M+1" -> "110111";
            case "D-1" -> "001110";
            case "A-1", "M-1" -> "110010";
            case "D+A", "D+M" -> "000010";
            case "D-A", "D-M" -> "010011";
            case "A-D", "M-D" -> "000111";
            case "D&A", "D&M" -> "000000";
            case "D|A", "D|M" -> "010101";
            default -> throw new IllegalArgumentException("Unexpected comp: " + rawComp);
        };
    }

    public String jump(String rawJump) {
        if (rawJump == null) {
            return "000";
        }

        return switch (rawJump) {
            case "JGT" -> "001";
            case "JEQ" -> "010";
            case "JGE" -> "011";
            case "JLT" -> "100";
            case "JNE" -> "101";
            case "JLE" -> "110";
            case "JMP" -> "111";
            default -> throw new IllegalArgumentException("Unexpected jump: " + rawJump);
        };
    }
}

class SymbolTable {

    private final Map<String, Integer> table;

    private int nextAvailableAddress;

    public SymbolTable() {
        table = new HashMap<>();
        init();
    }

    /**
     * Put pre-defined entries into the symbol table
     */
    private void init() {
        for (int i = 0; i < 16; i++) {
            addEntry("R" + i, i);
        }

        addEntry("SP", 0);
        addEntry("LCL", 1);
        addEntry("ARG", 2);
        addEntry("THIS", 3);
        addEntry("THAT", 4);
        addEntry("SCREEN", 16384);
        addEntry("KBD", 24576);

        nextAvailableAddress = 16;
    }

    public void addEntry(String symbol, int address) {
        if (table.containsKey(symbol)) {
            String message = String.format("Symbol table already contains symbol: %s!", symbol);
            throw new IllegalArgumentException(message);
        }
        table.put(symbol, address);
    }

    public int consumeAvailableAddress() {
        return nextAvailableAddress++;
    }

    public boolean contains(String symbol) {
        return table.containsKey(symbol);
    }

    public int getAddress(String symbol) {
        return table.get(symbol);
    }
}

enum InstructionType {
    A_INSTRUCTION,
    C_INSTRUCTION,
    L_INSTRUCTION
}

1. Parser

Parser (分析器)的整体设计参考了 《计算机系统要素 (第2版)》 一书第 7778 页的相关描述 ⬇️

image.png

image.png

Parser 中各个方法的简介如下表所示

方法简介
构造函数 Parser(String)
reset()重置 Parser,以便进行第二遍扫描
hasMoreLines()判断输入是否还有更多行
preProcess(Stream<String>)对输入进行预处理
1. 将其中的空白行和注释行移除
2. 进行 trim 操作
advance()从输入中读取下一条指令,并将其设置为当前指令
instructionType()返回当前指令的类型
symbol()1. 如果当前指令是 (xxx)(xxx),则返回符号 xxxxxx
2.如果当前指令是 @xxx@xxx,则将该符号或十进制数 xxxxxx(作为字符串)返回。
dest()返回当前 C 指令的 dest 部分
comp()返回当前 C 指令的 comp 部分
jump()返回当前 C 指令的 jump 部分

image.png

2. Code

Code 模块的整体设计参考了 《计算机系统要素 (第2版)》 一书第 7879 页的相关描述 ⬇️

image.png

image.png

Code 中各个方法的简介如下表所示

方法简介
dest(String)返回 dest 助记符的二进制编码
comp(String)返回 comp 助记符的二进制编码
jump(String)返回 comp 助记符的二进制编码
image.png

3. Assembler

Assembler (汇编器)的整体设计参考了 《计算机系统要素 (第2版)》 一书第 79 页的相关描述 ⬇️

image.png

Assembler 中各个方法的简介如下表所示

方法简介
构造函数 Assembler(String)
scanFirstRound()进行第一遍扫描。在这一轮扫描中,会处理 label
scanSecondRound()进行第二遍扫描。
getAddress(String)获取 symbol 对应的 address
isConstant(String)判断输入是否是常量(即 @xxx@xxx 指令中的 xxxxxx 是否为常量)
translateAType()A 类型指令翻译为 hack 指令
translateAType(int)A 类型指令翻译为 hack 指令
translateCType()C 类型指令翻译为 hack 指令
translate()asm 指令全部翻译为 hack 指令
buildOutputPath()生成和输出文件对应的 Path
main(String[])main 方法,Assembler 的入口

image.png

4. SymbolTable

SymbolTable (符号表)的整体设计参考了 《计算机系统要素 (第2版)》 一书第 79 页的相关描述 ⬇️

image.png

SymbolTable 中各个方法的简介如下表所示

方法简介
构造函数 SymbolTable()
init()将预定义的 entry 保存在 SymbolTable
addEntry(String, int)SymbolTable 中添加一个 symboladdresssymbol\to address 条目
consumeAvailableAddress()返回当前可用的最小可用地址,并令最小可用地址加 1
contains(String)判断 symbol 是否在 SymbolTable
getAddress(String)返回与 symbol 相关联的地址

image.png

5. InstructionType

InstructionType 里定义了指令的类型 ⬇️

image.png

有了 Assembler.java 文件后,就可以对 asm 程序进行汇编操作了。

验证

Add.asm

前往 nand2tetris.github.io/web-ide/asm ,点击 Load file 按钮

image.png

选择 projects/06 中的 Add.asm

image.png

点击 Translate all 按钮

image.png

之后就能看到对应的 hack 代码 ⬇️

image.png

Add.asm 进行 download 操作,然后将其移动到和 java 文件相同的目录,再执行如下命令

javac Assembler.java
java Assembler Add.asm

之后应该会生成 Add.hack 文件。 在 Compare code 面板中,可以选择本地文件 image.png

image.png

选择本地的 Add.hack 文件之后,再点击 Compare,之后应该可以看到 Comparison successful 的提示 ⬇️ image.png

Max.asm

操作步骤和 Add.asm 类似,这里就不赘述了,下图是对比结果 ⬇️

image.png

MaxL.asm

操作步骤和 Add.asm 类似,下图是对比结果 ⬇️

image.png

Pong.asm

操作步骤和 Add.asm 类似,但是 Pong.asm 很长,可以在 Translate all 之前,将 Execution speed 按钮移动到最右侧 ⬇️

image.png

下图是最终的对比结果 ⬇️

image.png

PongL.asm

操作步骤和 Add.asm 类似,但是 PongL.asm 很长,可以在 Translate all 之前,将 Execution speed 按钮移动到最右侧 ⬇️

image.png

下图是最终的对比结果 ⬇️

image.png

Rect.asm

操作步骤和 Add.asm 类似,下图是对比结果 ⬇️

image.png

RectL.asm

操作步骤和 Add.asm 类似,下图是对比结果 ⬇️

image.png