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

19 阅读4分钟

背景

让我们按照 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

我写的 kotlin 代码

说明:我是先用 java 代码完成了 Project 6,然后在 Intellij IDEA (Community Edition) 的帮助下把 java 代码转化成了对应的 kotlin 代码(我自己也对 kotlin 代码做了些调整)。java 版的介绍在 From Nand to Tetris 里的 Project 6 (用 java 实现) 一文中。本文和它大同小异。

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

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

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

import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
import kotlin.Array
import kotlin.Boolean
import kotlin.Exception
import kotlin.IllegalArgumentException
import kotlin.Int
import kotlin.Throws
import kotlin.charArrayOf
import kotlin.check
import kotlin.collections.ArrayList
import kotlin.collections.HashMap
import kotlin.collections.MutableList
import kotlin.collections.MutableMap
import kotlin.text.contains
import kotlin.text.indexOf
import kotlin.text.isEmpty
import kotlin.text.repeat
import kotlin.text.startsWith
import kotlin.text.substring
import kotlin.text.toCharArray
import kotlin.text.toInt
import kotlin.text.trim

class Assembler(private val filename: String) {
    private val parser: Parser = Parser(filename)
    private val code: Code = Code()
    private val symbolTable: SymbolTable = SymbolTable()
    private val hackCodeLines: MutableList<String> = ArrayList()

    /**
     * Scan the first round to handle labels (i.e. [InstructionType.L_INSTRUCTION])
     */
    private fun scanFirstRound() {
        var actualLineNum = 0
        while (parser.hasMoreLines()) {
            parser.advance()

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

    private fun scanSecondRound() {
        parser.reset()

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

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

    private fun getAddress(symbol: String): Int {
        if (!symbolTable.contains(symbol)) {
            symbolTable.addEntry(symbol, symbolTable.consumeAvailableAddress())
        }
        return symbolTable.getAddress(symbol)
    }

    private fun isConstant(symbol: String): Boolean = symbol[0] in '0'..'9'

    private fun translateAType(): String {
        val symbol = parser.symbol()
        if (isConstant(symbol)) {
            return translateAType(symbol.toInt())
        }
        return translateAType(getAddress(symbol))
    }

    private fun translateAType(value: Int): String {
        val result = Integer.toBinaryString(value)
        val lengthDiff = 16 - result.length
        return "${"0".repeat(lengthDiff)}$result"
    }

    private fun translateCType(): String {
        val comp = code.comp(parser.comp())
        val dest = code.dest(parser.dest())
        val jump = code.jump(parser.jump())

        return "111${comp}${dest}${jump}"
    }

    fun translate(): MutableList<String> {
        scanFirstRound()
        scanSecondRound()

        return hackCodeLines
    }

    private fun buildOutputPath(): Path {
        val path = Paths.get(filename)

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

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

    companion object {
        @Throws(Exception::class)
        @JvmStatic
        fun main(args: Array<String>) {
            val assembler = Assembler(args[0])

            val hackCode = assembler.translate().joinToString(System.lineSeparator())

            val outputPath = assembler.buildOutputPath()
            Files.writeString(outputPath, hackCode)
        }
    }
}

internal class Parser(fileName: String) {
    private val lines: List<String>
    private var nextLineNum: Int
    private val totalLineNum: Int
    private var currentLine: String?
    private var currentType: InstructionType?

    init {
        this.lines = preProcess(File(fileName).readLines())
        nextLineNum = 0
        totalLineNum = lines.size
        currentLine = null
        currentType = null
    }

    fun reset() {
        nextLineNum = 0
        currentLine = null
        currentType = null
    }

    fun hasMoreLines(): Boolean = nextLineNum < totalLineNum

    /**
     * Pre-process
     * 1. Skip empty lines and comment lines
     * 2. Trim each line
     */
    private fun preProcess(rawLines: List<String>): List<String> {
        return rawLines.map { obj -> obj.trim { it <= ' ' } }
            .filterNot { line -> line.isEmpty() }
            .filterNot { line -> line.startsWith("//") }
            .toList()
    }

    fun advance() {
        check(hasMoreLines()) { "The file is already exhausted" }
        currentLine = lines[nextLineNum++]
        currentType = instructionType()
    }

    fun instructionType(): InstructionType {
        if (currentLine!!.startsWith("@")) {
            return InstructionType.A_INSTRUCTION
        }
        if (currentLine!!.startsWith("(")) {
            return InstructionType.L_INSTRUCTION
        }
        return InstructionType.C_INSTRUCTION
    }

    fun symbol(): String {
        check(
            EnumSet.of(InstructionType.A_INSTRUCTION, InstructionType.L_INSTRUCTION)
                .contains(currentType)
        ) { "The current instruction belongs to neither A type nor L type!" }
        if (currentLine!!.startsWith("(")) {
            return currentLine!!.substring(1, currentLine!!.length - 1).trim { it <= ' ' }
        }
        if (currentLine!!.startsWith("@")) {
            return currentLine!!.substring(1)
        }
        throw IllegalArgumentException("Unexpected line: $currentLine")
    }

    fun dest(): String? {
        check(currentType == InstructionType.C_INSTRUCTION) { "The current instruction doesn't belong to C type!" }
        val index = currentLine!!.indexOf("=")
        if (index >= 0) {
            return currentLine!!.substring(0, index)
        }
        return null
    }

    fun comp(): String {
        check(currentType == InstructionType.C_INSTRUCTION) { "The current instruction doesn't belong to C type!" }

        val index1 = currentLine!!.indexOf("=")
        val 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)
    }

    fun jump(): String? {
        check(currentType == InstructionType.C_INSTRUCTION) { "The current instruction doesn't belong to C type!" }

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

internal class Code {
    fun dest(rawDest: String?): String {
        if (rawDest == null) {
            return "000"
        }

        val cs = charArrayOf('0', '0', '0')
        rawDest.toCharArray().forEach { item ->
            when (item) {
                'A' -> cs[0] = '1'
                'D' -> cs[1] = '1'
                'M' -> cs[2] = '1'
                else -> throw IllegalArgumentException("Unexpected dest: $rawDest")
            }
        }

        return String(cs)
    }

    fun comp(rawComp: String): String {
        val a = if (rawComp.contains("M")) 1 else 0
        return a.toString() + when (rawComp) {
            "0" -> "101010"
            "1" -> "111111"
            "-1" -> "111010"
            "D" -> "001100"
            "A", "M" -> "110000"
            "!D" -> "001101"
            "!A", "!M" -> "110001"
            "-D" -> "001111"
            "-A", "-M" -> "110011"
            "D+1" -> "011111"
            "A+1", "M+1" -> "110111"
            "D-1" -> "001110"
            "A-1", "M-1" -> "110010"
            "D+A", "D+M" -> "000010"
            "D-A", "D-M" -> "010011"
            "A-D", "M-D" -> "000111"
            "D&A", "D&M" -> "000000"
            "D|A", "D|M" -> "010101"
            else -> throw IllegalArgumentException("Unexpected comp: $rawComp")
        }
    }

    fun jump(rawJump: String?): String {
        if (rawJump == null) {
            return "000"
        }

        return when (rawJump) {
            "JGT" -> "001"
            "JEQ" -> "010"
            "JGE" -> "011"
            "JLT" -> "100"
            "JNE" -> "101"
            "JLE" -> "110"
            "JMP" -> "111"
            else -> throw IllegalArgumentException("Unexpected jump: $rawJump")
        }
    }
}

internal class SymbolTable {
    private val table: MutableMap<String, Int> = HashMap()

    private var nextAvailableAddress = 0

    /**
     * Put pre-defined entries into the symbol table
     */
    init {
        for (i in 0..15) {
            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
    }

    fun addEntry(symbol: String, address: Int) {
        if (table.containsKey(symbol)) {
            val message = "Symbol table already contains symbol: ${symbol}!"
            throw IllegalArgumentException(message)
        }
        table.put(symbol, address)
    }

    fun consumeAvailableAddress(): Int = nextAvailableAddress++

    fun contains(symbol: String): Boolean = table.containsKey(symbol)

    fun getAddress(symbol: String): Int = table[symbol]!!
}

internal enum class InstructionType {
    A_INSTRUCTION,
    C_INSTRUCTION,
    L_INSTRUCTION
}

1. Parser

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

image.png

image.png

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

方法简介
init 语句块
reset()重置 Parser,以便进行第二遍扫描
hasMoreLines()判断输入是否还有更多行
preProcess(List<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 中各个方法的简介如下表所示

方法简介
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(Array<String>) 方法main 方法,Assembler 的入口

image.png

4. SymbolTable

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

image.png

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.kt 文件后,就可以对 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 操作,然后将其移动到和 kotlin 文件相同的目录,再执行如下命令

kotlinc Assembler.kt
kotlin 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

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

image.png

PongL.asm

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

image.png

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

image.png

Rect.asm

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

image.png

RectL.asm

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

image.png