HUJ-nans2tetrics-笔记-全-
HUJ nans2tetrics 笔记(全)
001:课程概述 🚀

在本课程中,我们将一起踏上构建完整现代计算机系统的旅程。我们将从底层硬件出发,逐步构建虚拟机、编译器与操作系统,最终实现用高级语言编写程序的能力。
课程全景图
上一节我们介绍了课程的整体目标,本节中我们来看看实现这一目标的完整路径。
我们的旅程始于最底层的“裸机”硬件,它仅能处理0和1两种信号。旅程的终点则是一位高级程序员,他怀揣着开发游戏、搜索引擎等复杂软件的想法。那么,如何连接抽象的编程思想与只能处理0和1的硬件呢?这正是本课程要解决的核心问题。
我们通过几个定义明确的阶段来搭建这座桥梁,每个阶段都包含一个或多个编程项目。整个旅程分为两个独立的课程。
第一部分回顾
在Coursera上称为“从零开始构建现代计算机”的第一部分课程中,我们从最基本的逻辑门——Nand门出发。通过六个硬件构建项目,我们逐步搭建了称为Hack的计算机平台。我们构建了基本逻辑门、内存系统、CPU乃至完整的Hack芯片组,最终组装成经典的冯·诺依曼硬件架构,并为该计算机编写了汇编器。
至此,我们拥有了一个完整的计算机——Hack。
第二部分启程
现在,第二部分课程(即本课程)正式开始。我们将Hack计算机平台视为一个抽象层。我们不再关心CPU如何构建、寄存器如何由底层触发器构成等细节。对我们而言,Hack是一个可以通过其接口(即机器语言)进行交互的黑盒。
然而,更便捷的方式是从上至下地理解本课程。让我们将注意力转向屏幕左侧,聚焦于编写高级程序的过程。这与你用Java、Python或C#编程时的体验类似:你只关注高级编程任务本身,而不关心计算机的其他部分。
在本课程中,我们将引入一种称为Jack的高级语言。它是Java、Python、C#等语言的简化版本。如果你了解上述任何一种语言,大约一小时就能学会Jack,并用它编写出色的程序。
以下是往届学生用Jack编写的一些程序示例:
- 打字教学游戏:一个互动性强的打字练习游戏。
- 太空入侵者:经典的射击游戏。
- 推箱子:源自日本的著名益智游戏。
- 弹跳球动画:一个精美的动画演示。
- 俄罗斯方块:学生们开发了各种版本。
此外,这里还有一个我编写的程序,它创建并操作二维点,进行各种代数运算。让我们看看它的Jack代码:
class Point {
// 实现二维点功能
...
}
class Main {
// 使用Point类的服务来创建和操作屏幕上的点
...
}
这段代码看起来与Java或Python非常相似。在本课程的第三周,你们将亲自用Jack编写类似的程序,以熟悉这门语言,为我们后续为其开发编译器做好准备。
核心构建模块
上一节我们体验了用Jack编程,本节中我们来看看如何让Jack程序在硬件上运行起来。这需要一系列系统软件的支撑。
编译器
众所周知,执行程序的第一步是编译。我们的编译器(类似于Java、C#的编译器)不会直接将源代码翻译成机器语言,而是先翻译成一种称为VM代码的中间语言,这种语言设计用于在抽象的虚拟机上运行。
例如,一条创建新点对象的Jack命令:
let p1 = Point.new(1, 2);
会被编译器翻译成四条VM命令。这个过程类似于Java生成字节码或.NET生成IL。
你们将在本课程的第四、五周,在我们的指导下编写这个编译器。
虚拟机与翻译器
然而,VM代码本身也是一个抽象层,它需要进一步翻译成机器语言。我们将使用的机器语言是Hack计算机的原生代码,即Hack汇编语言。
因此,我们需要一个VM翻译器。它接收VM命令(例如一个典型的栈操作命令push 1),并将其翻译成大约七到八条机器级指令。生成这些指令后,我们就可以将其加载到计算机中执行。
你们将在课程的前两周开发这个VM翻译器,其原理与Java的JVM和微软的CLR非常相似。本课程的所有内容都遵循现代软件工程范式,让你们能够深入理解这些工具背后的核心思想。
操作系统
目前还缺少关键的一环:操作系统。现代高级语言编程离不开操作系统服务的支持,例如处理图形、动画、读取键盘输入、管理内存中的对象等。我们不可能要求高级程序员亲自处理所有这些底层细节。
因此,在课程的第六周,我们将在指导下开发一个基本的操作系统。
课程方法论与安排
面对如此庞大的知识体系,如何在六到七周内掌握并构建这些核心系统呢?我们的“秘密武器”是独特的课程方法论:
- 提供全部必要知识:我们将教授构建本课程所有系统所需的计算机科学知识。除了基本的编程技能,你们无需自带任何先验知识。我们将提供所有必要的算法、数据结构和编程技巧。
- 提供强大的软件工具:在要求你们实现某个系统(如虚拟机)之前,我们会提供相应的模拟器(如VM模拟器),让你们可以通过动画演示来玩转和深入理解该系统,明确实际项目中需要完成的任务。
- 清晰的架构与规范:我和Noam教授将扮演系统架构师的角色,你们则是软件开发者。我们将提供详尽、明确的API、蓝图和设计文档,确保你们能准确无误地理解任务要求。
- 全面的测试支持:我们将提供大量的测试程序、测试文件和测试脚本。你们可以对自己编写的代码进行高频率的单元测试(例如每编写10-15行代码就测试一次),从而逐步建立信心,稳步推进开发。
- 乐趣与成就感:从零开始构建计算机的过程激动人心,你们将从中获得巨大的乐趣和成就感。迄今为止,已有成千上万的学习者完成了这门课程,他们绝大多数都成为了“快乐的露营者”。
本课程将持续七周,每周包含:
- 约3小时的视频讲座。
- 一个需要实现的编程项目。
- 一章可选的参考书阅读(教材由Noam和我编写,MIT出版社出版)。
每周的工作量大约在10到20小时之间,具体取决于当周内容。请注意,这里的“周”是弹性的。你们可以按照自己的节奏学习,如果一周需要10天或14天来完成,完全没有问题。你们是自己学习之旅的船长,我们只提供指南。最重要的是完成项目并掌握背后的思想与技术。
先修要求与课程收获
本课程的先修要求非常基础:
- 具备典型的计算机科学导论课程所教授的编程能力。
- 熟悉Java或Python。
- 掌握面向对象编程的基本技术与技能。
如果具备这些基础知识,就欢迎你加入本课程。虽然建议先学习第一部分,但并非必需。两部分课程相互独立,你甚至可以先学习第二部分。
最后,我想列出本课程将涵盖的部分主题,其广度令人惊叹:
- 高级语言设计与实现
- 编译器构造
- 虚拟机设计与实现
- 操作系统服务(内存管理、I/O驱动、数学函数库等)
- 软件工程最佳实践(模块化、抽象、测试)
这份清单的激动人心之处在于,对于其中的每一个概念,我们都不会仅仅停留在理论讲解。你们将在构建系统的实践过程中,亲手实现并深刻理解它们。
总而言之,通过本课程的学习,你将:
- 成为一名更加成熟、开明和自信的软件开发者。你将驱散对编译器、虚拟机、程序与操作系统如何交互等问题的疑虑。
- 接触到应用计算机科学中最重要的一系列思想与技术。
- 在智力上成为更富有的人。你将连接到人类所建造的最重要机器(至少在当代是如此)的灵魂深处,而这正是“从零开始构建现代计算机”系列课程的全部意义。
感谢你耐心听完课程概述。接下来,我们将讨论“项目零”。

本节课中我们一起学习了“从零开始构建现代计算机”第二部分课程的整体蓝图、从Jack高级语言到Hack硬件之间的完整软件栈构建路径(编译器、虚拟机、操作系统)、独特的实践驱动教学方法论,以及学习本课程所需的先备知识和你将收获的宝贵技能与深刻理解。旅程即将开始,你准备好了吗?
002:项目0概述 🎬

在本节课中,我们将学习课程的第一个项目——项目0。这个项目非常简单,主要目的是帮助你熟悉课程所需的软件环境,并掌握提交作业的基本流程。
概述
项目0包含两个主要任务。首先,你需要下载并安装课程所需的Nand2Tetris软件套件。其次,你需要按照指示,将一个特定的文件压缩并提交到课程网站。完成这些步骤后,你就可以正式开始后续的学习了。
任务一:下载软件套件
首先,你需要将Nand2Tetris软件下载到你的个人电脑上。
以下是具体步骤:
- 访问网站
www.nand2tetris.org。 - 在网站上找到“软件”页面。
- 在软件页面中,找到并点击指定的下载链接。
- 点击后,一个名为
nand2tetris.zip的文件将开始下载。
下载完成后,请解压这个ZIP文件。解压后会得到一个名为 nand2tetris 的文件夹。我们建议你将此文件夹放在桌面上,以便在整个课程中方便使用。
探索软件文件夹结构
现在,让我们来看看这个文件夹里有什么。打开 nand2tetris 文件夹,你会发现它主要包含两个子文件夹:tools 和 projects。
tools文件夹:包含一系列有用的软件工具,这些工具将帮助你完成Nand2Tetris第一部分和第二部分的课程。projects文件夹:包含14个子目录,编号从00到13。这些目录对应课程的所有项目。
在本课程(Nand2Tetris Part 2)中,我们只会用到其中一部分工具和项目。请注意,项目4(Project 4)的轮廓是虚线,这表示它在第二部分中是一个可选项目,我们将在后续单元中详细介绍。
重要提示:即使你已经学习过Nand2Tetris第一部分,我们仍然强烈建议你重新下载软件套件。因为我们会持续更新软件,重新下载可以确保你使用的是最新版本。
任务二:提交文件
上一节我们介绍了如何下载和设置软件环境,本节中我们来看看如何提交作业文件。
假设你已经成功下载、解压并查看了 nand2tetris 文件夹,接下来需要完成以下操作:
- 在
nand2tetris文件夹内,找到名为file.txt的文件。 - 我们要求你直接提交这个文件,不做任何修改。这样做的目的是让你熟悉提交文件的简单流程。
以下是具体操作步骤:
- 将
file.txt文件压缩成一个ZIP文件,并命名为project0.zip。- 代码示例(在命令行中):
zip project0.zip file.txt
- 代码示例(在命令行中):
- 将这个
project0.zip文件提交到Coursera课程网站。请按照网站上关于“如何提交项目0”的书面说明进行操作。
提交后,你将收到我们确认收到文件的反馈。之后,我们就可以正式开始本课程的实际项目了。
总结

本节课中我们一起学习了项目0的完整流程。我们首先下载并设置了Nand2Tetris软件套件,然后通过压缩和提交一个简单的文本文件,熟悉了课程作业的提交机制。完成这些准备工作后,在接下来的单元中,我们将开始深入探讨机器语言的相关知识。
003:机器语言入门 🖥️

在本单元中,我们将介绍机器语言的基础知识。这是硬件与软件交汇的关键接口,也是后续构建计算机虚拟机的必要基础。
课程概述
欢迎来到《从零开始构建现代计算机》第二部分的第0单元。本单元将探讨应用计算机科学中一个至关重要的接口——机器语言。这是硬件与软件握手的地方,我们将通过这个接口来理解和使用硬件平台。
课程结构全景
以下是本课程第二部分将要学习的各个模块,我们从模块0开始。
- 模块0:机器语言基础(本单元)
- 模块1:虚拟机架构与命令
- 模块2:虚拟机翻译器的实现
为何需要学习机器语言?
为了解释硬件与软件之间这种精密的协作,请看下面的图示。

你会注意到,图中有一段用“虚拟机语言”编写的VM代码。目前你无需理解这段代码,我们将在后续介绍。关键在于,我们将讨论如何将用这种语言编写的程序翻译成机器语言(即汇编语言)。
具体来说,虚拟机翻译器(VM Translator)是一个将由你们在指导下用Java或Python等语言编写的程序。这个程序将以VM代码作为输入,逐行解析并生成对应的汇编代码。例如,它会从 push 1 生成你在此处看到的汇编代码,然后继续处理 push 2 并生成对应的代码段,依此类推。
由此可见,要完成这个任务,你必须能够用汇编语言生成和编写代码。
模块0的目标
在模块1和模块2中,我们将完成两件事:
- 讨论虚拟机架构、抽象及其命令(即VM语言)。
- 通过实现一个虚拟机翻译器来实际构建这台机器。
为了实现第二点,我们必须再次生成汇编代码。因此,模块0的目的就是解释汇编语言中的低级编程概念,并让你编写一两个程序以获得这项基础能力。没有这项能力,你将无法编写自己的虚拟机翻译器。
为了达成这个目标,我们将提供九个教学单元,最终导向一个被称为“项目4”的实践任务。
请注意,所有这些单元都是我们在《从零开始构建现代计算机》第一部分中相同单元的原样复制。在第一部分中,这些单元构成了模块4的核心内容,因此这个项目也被称为项目4。
学习路径指南
考虑到这一点,观看本视频的学习者可能属于以下三类情况之一。
以下是针对不同背景学习者的具体建议:
- 近期完成第一部分的学习者:如果你刚学完第一部分,对机器语言掌握良好,并渴望开始第二部分的学习,我们建议你只复习本单元目录中的第8单元。同时,强烈建议你复习并可以考虑重新提交第9单元的项目4(这里的编号可能有些令人困惑)。这是一个可选项目,但请确保你记得如何用Hack汇编语言编写程序。
- 过去完成第一部分的学习者:如果你在较早之前完成了第一部分的学习,现在关于机器语言的知识可能有些生疏。你需要“除锈”。最好的方法是复习此处列出的这个单元子集。我们同样强烈建议你再次完成并提交项目4。不过,在第二部分中,这个项目仍是可选的。
- 未学习第一部分,直接开始第二部分的学习者:如果你决定直接开始第二部分的学习,这完全可以。但如果你做出了这个选择,我们坚持要求你认真学习此处列出的所有单元。特别是,你必须完成项目4。如果你未能获得必要的低级编程知识,将无法继续进行第二部分的学习。因此,由你来负责补齐这些先决知识。
单元总结
本单元到此结束。请根据你的低级编程专业水平,将自己定位到上述三条学习路径之一。决定好你需要从这些剩余单元中学习什么内容。
完成之后,我将在模块1中与你再见,届时我们将正式开始构建我们的虚拟机。


004:机器语言概述 🖥️

在本节课中,我们将从用户视角审视我们即将构建的计算机,了解其规格与能力。我们将探讨计算机如何通过单一硬件执行多种任务,并深入理解机器语言的核心概念及其作用。
计算机的通用性
上一节我们介绍了ALU和内存层次结构。本节中,我们来看看计算机的通用性原理。
大多数机器功能单一,例如洗衣机只能洗衣。然而,计算机却能执行多种任务,如文字处理、游戏、通信和视频播放。单一硬件如何实现如此多样的功能?
其核心理念是通用性。艾伦·图灵在20世纪初的理论模型中首次形式化了这一概念。他定义了图灵机模型来捕捉计算的本质,并发现了一个惊人现象:尽管存在许多不同的图灵机,但存在一个通用图灵机,在给定正确程序的情况下,可以模拟所有其他图灵机。这是“一机多用”理念的首次体现。
冯·诺依曼将这一理念付诸实践,设计了首个通用计算架构。其架构的核心是存储程序计算机的概念,即程序(软件)可以存储在硬件中,硬件根据加载的软件执行不同的操作和功能。正是这一原理,使得我们今天使用的单一计算机能够执行无数不同的任务。
硬件与软件的协作
现在,让我们更仔细地看看这是如何运作的。
计算机内部包含中央处理器和内存。它读取输入并产生输出。关键在于,内存中存储的程序会告诉硬件该做什么。硬件是固定的,但程序(软件)可以改变。随着软件的改变,计算机执行的功能也随之改变。
那么,静态的单一硬件如何根据程序执行多种任务?程序由一系列指令组成,每条指令都用二进制编码。硬件只需逐条执行指令。不同的指令集、不同的指令序列,将导致硬件产生完全不同的结果。
整个指令序列,即整个程序,共同构成了程序的功能。该功能完全取决于所编写的程序本身。
机器语言的要素
接下来,我们看看需要仔细研究的几个要素,这些要素共同促使硬件按程序要求工作。
以下是构成机器语言控制的三个基本要素:
- 指令规范:程序由指令序列构成。指令具体告诉计算机做什么。
- 执行流控制:我们需要知道在任何给定时刻执行哪条指令。通常顺序执行,但有时需要改变顺序(例如实现循环或条件分支)。软件需要能够控制硬件的操作流程。
- 操作数指定:即使硬件知道要执行加法,软件也必须告诉硬件从何处获取待相加的两个值,以及将结果存放在何处。
每个机器语言、每种硬件允许软件进行控制的方式,都需要对这三方面进行规范。我们稍后将讨论每种可能性的选项。
机器语言与高级语言
一旦定义了机器语言,它就规定了硬件允许软件执行的操作。机器语言规范本身就是一种编程语言,但它是一种对硬件方便、对人类却极不友好的语言。
因此,现实中人类程序员几乎从不直接使用机器语言编程,而是使用高级编程语言。高级语言专为人类设计,便于轻松表达意图。然后,被称为编译器的自动程序会将高级语言代码翻译成机器语言软件,从而在运行时告诉硬件该做什么。
通常,我们不必过于担心机器语言,因为人类程序员并不直接使用它。然而,从计算机的角度看,最终运行的始终是机器语言程序,任何其他程序都以某种方式被翻译成这种机器语言形式,这才是真正指挥硬件的东西。
本课程中与机器语言的交互
尽管人们通常不直接用机器语言编写程序,但在本课程中,我们处理的是运行机器的硬件以及直接在其上操作的软件。为了理解如何构建计算机,我们需要学习机器语言,因此将不得不直接处理机器语言。
在某些其他情况下,人们也会直接处理机器语言,例如需要为特定任务编写高度优化代码时。在这些情况以及我们的课程中,我们需要让机器语言的使用稍微容易一些。通常有两种方法可以简化机器语言的工作:
- 助记符表示:机器语言指令始终是比特序列,但这很难阅读和理解。实际上,指令编码的内容通常是我们能理解的。例如,一段比特序列可能编码“加法”操作。这时,使用助记符形式(如
ADD)比记一串0 1 1 1 0更方便。同样,指令中编码寄存器或内存位置的部分也可以用R2或类似符号表示。这相当于讨论比特序列,只是对人类更友好。最初,我们可以简单地认为这些助记符形式并不实际存在,只是我们谈论真实比特的一种方式。 - 汇编器与符号:更进一步,我们可以允许用户以这种合成形式(即汇编语言)编写代码,然后通过一个称为汇编器的程序轻松地将其翻译成比特。这是第二种解读方式。在本课程第六周,你将实际构建这样一个汇编器。
此外,为了让我们(以及少数需要直接使用机器语言的情况)编写程序更容易,我们还可以免费获得另一个特性:符号。很多时候,在机器语言程序中,我们需要访问内存位置。例如,可能有一条指令告诉计算机“给内存位置129加1”。对程序员来说,具体是位置129并不重要,重要的是那里存储了一个“索引”值。这个值本可以存放在位置250或其他任何位置。关键点是,每次使用这个值时,都访问同一个内存位置。
因此,我们可以允许用户为该内存位置使用一个符号名称,例如称之为 index。然后让我们的汇编器(它已经提供了便利)自动将这个符号 index 翻译成某个具体的、固定的内存地址(例如129)。这样,我们就能以对人类更直观的方式编写程序,而实际上,每当看到 index,它都意味着同一个固定的内存位置。
总结

本节课中,我们一起学习了机器语言的概述。我们探讨了计算机通用性的理念,理解了硬件如何通过执行不同的软件程序来实现多样功能。我们分析了机器语言控制硬件的三个核心要素:指令规范、执行流控制和操作数指定。我们还区分了机器语言与高级编程语言的角色,并介绍了在本课程中简化机器语言编程的两种方法:使用助记符和符号。有了这些背景知识和视角,在接下来的单元中,我们将具体讨论机器语言的构成要素,并最终介绍本课程将要构建的计算机——Hack计算机——的特定机器语言。
005:机器语言要素 🧠

在本单元中,我们将探讨所有机器语言共有的基本要素。这为我们理解下一单元将要学习的 Hack 机器语言提供了必要的背景知识,因为 Hack 语言中的大多数元素,都以某种形式存在于其他机器语言中。我们首先对机器语言的要素进行一个概括性的描述,这个描述非常基础,省略了许多更复杂的特性,但足以帮助我们理解 Hack 机器语言。
机器语言的核心作用
上一节我们讨论了机器语言的通用概念及其如何控制计算机。本节中,我们来看看机器语言的具体构成要素。
首先需要记住,机器语言是计算机科学中最重要的接口。它是硬件与软件之间的桥梁,是软件控制硬件的具体方式。
这种语言需要规定硬件执行哪些操作、从哪里获取操作数据、如何控制操作流程等。原则上,这个接口通常与硬件的实际实现一一对应。硬件被设计成能直接支持其向上层软件提供的功能类型。当然,有时为了提供更好的功能(例如让编译器编写者更轻松),硬件实现可能会与机器语言稍有分离,但这不是我们讨论的重点。从基本原理出发,机器语言精确地规定了硬件能为我们做什么。
设计权衡:成本与性能
当我们实际设计一种机器语言时,基本考量是成本与性能的权衡。我们希望机器语言提供的操作越复杂、支持的数据类型越庞大或越高级,实际构建它的成本就越高。这里的成本包括硅片面积和硬件执行所需的时间等。在我们的课程中,我们总是倾向于最简单的权衡,展示最简单的概念,而不太担心实际性能。但在任何真实的机器中,驱动机器语言设计的核心正是这种成本与性能的权衡。
操作类型
每种机器语言都定义了一组操作,这些操作可分为几类。
以下是常见的操作类别:
- 算术运算:例如两个数的加法、减法,可能还包括乘法或除法。
- 逻辑运算:例如对两个位(或两个字)进行按位与(AND)操作。
- 流程控制操作:这些指令告诉硬件何时在程序中跳转。
不同的机器语言定义了不同的操作集合,它们在丰富程度上可能有所不同。例如,有些机器可能将除法作为基本操作提供,而其他机器可能因为硅片成本太高而选择不提供,转而由软件来实现该功能。
数据类型
比操作类型更重要的一个问题是:硬件能原生访问哪些数据类型?当然,处理8位数和处理64位数有很大区别。如果你的软件程序确实需要进行64位值的算术运算,那么能在一个操作中完成64位值加法的硬件,至少比需要通过一系列8位值加法来实现64位值加法的硬件快八倍。
类似地,一些计算机还可以提供更丰富的数据类型。例如,你的硬件可能直接支持浮点运算(处理非整数的实数),并提供加法、乘法、除法等基本操作。如果你要进行科学计算,处理浮点数或实数而非仅仅是整数,这样的机器当然会快得多。
数据寻址:核心挑战
接下来这个问题可能更重要,尽管更微妙一些:我们如何决定对哪些数据进行操作?硬件如何允许我们指定要处理的数据值?
我们面临的基本问题是,我们要处理的数据驻留在内存中,而访问内存是一项开销很大的操作。这至少体现在两个相关的方面。首先,如果你有一个大内存,指定要操作哪部分内存需要很多位来表示,因为你需要在一个非常大的内存中给出一个地址。如果我只想说“把最后两个数相加”,我将无法直接做到,因为我必须指定两个非常大的地址来告诉硬件要操作什么。
其次,与CPU本身或算术运算本身的速度相比,从大内存中访问一个值需要相对较长的时间。
解决方案:内存层次结构
处理这两个问题的方法,即在无需承担指定大地址和从“远处”(就时间而言)获取信息的成本的情况下,让我们能良好控制所处理数据类型的方法,就是所谓的内存层次结构。这早在冯·诺依曼建造第一台计算机时就已经构想出来了。
基本思想是,我们不是只有一个大内存块,而是有一系列越来越大的内存。最小的内存(寄存器)将非常容易访问。首先,因为数量很少,我们不需要指定大的地址空间。其次,因为数量很少,我们可以非常快速地从中获取信息。然后会有稍大一些的内存(通常称为缓存),以及更大的内存(有时称为主内存),甚至可能还有更大的内存(如磁盘)。每远离算术单元一步,内存就变得更大,访问它变得更困难(无论是需要更宽的地址,还是需要等待更长时间才能获取值),但那里存储的信息也更多。
不同级别的内存层次结构处理方式各不相同。我们现在要讨论的是最小的内存——通常真正位于CPU内部的寄存器——的处理方式。
寄存器:CPU内部的快速存储
几乎每个CPU都有少量非常小的内存单元,称为寄存器,它们真正位于CPU内部。它们的类型和功能是机器语言的一部分,关键在于,由于数量极少,寻址它们只需要很少的位,并且获取信息的速度极快。它们由可用的最快技术构建,并且已经在CPU内部,因此从那里获取信息没有任何延迟。
那么,我们有哪些类型的寄存器呢?
寄存器的用途
以下是寄存器的主要用途:
- 存储数据:我们可以将数字放入其中,并执行诸如“将寄存器一加到寄存器二”的操作。在这种情况下,寄存器一的内容将被加到寄存器二的内容上(如果这是我们机器语言中该操作的含义)。一旦CPU内部有了少量寄存器,我们就可以非常快速地进行大量操作。
- 存储地址:我们有时也可以将主内存中的一个地址放入这些寄存器之一。这允许我们指定要访问的较大内存的哪个部分,用于我们想要访问的操作。例如,如果我们有一个操作如“将寄存器一存储到由名为A的寄存器指定的内存地址”,那么当硬件执行此操作时,数字77将被写入主内存。这可能比CPU内部操作花费更多时间,但重要的是,我们写入信息的地址实际上是由A寄存器给出的。
寻址模式
现在我们有了这些寄存器,可以回到最初的问题:我们如何决定对哪些数据进行操作?我们如何告诉计算机一个操作(比如一个简单的加法操作)应该对什么进行操作?这里有一系列不同的可能性。
以下是四种常见的可能性(有时称为寻址模式),有些计算机还有其他可能性:
- 寄存器寻址:例如,我们可以说
add R1, R2。这意味着加法操作在两个寄存器上进行。 - 直接内存寻址:我们可以有一个操作说
add R1, [200]。在这种情况下,我们告诉计算机直接寻址,不仅指定了寄存器一,还指定了指令内部给出的内存地址200。 - 间接寻址:这是我们之前使用地址寄存器(A寄存器)的例子。我们访问的内存地址不是作为指令的一部分指定的,而是已经写在CPU内部先前加载了正确值的地址寄存器中。
- 立即数寻址:我们实际上在指令内部有一个值。例如,我们可以说
add R1, #73,其中73是一个常量,是指令的一部分。
所有这些都是在每条指令中告诉计算机对哪些数据进行操作的不同方式。
输入与输出处理
在讨论从哪里获取数据以及将数据放到哪里时,我们可能还会附带讨论一下在大多数机器语言中如何处理输入和输出。众所周知,有大量的输入和输出设备,如打印机、屏幕、各种传感器、键盘、鼠标等。
访问这些输入/输出设备的一种方法是,将控制它们的寄存器作为内存的一部分连接起来。例如,我们可能有一个鼠标,其连接方式是,每当用户移动鼠标时,最后一次移动就被写入某种寄存器,而该寄存器可以通过计算机在内存的某个特定地址访问。这使我们能够像访问内存本身一样访问输入和输出。当然,实际处理它的软件(通常是操作系统中的驱动程序)必须确切知道输入/输出设备连接到哪些地址,以及如何与之通信(读写位置的值具体代表什么含义)。
流程控制
我们要讨论的最后一个要素是所谓的流程控制。我们如何告诉硬件接下来执行哪条指令?通常这很简单。通常,如果我现在执行的是指令73,下一条指令将是74。这是正常情况。但有些情况下,我们当然需要改变控制流,而不是一条接一条地顺序执行指令。
改变流程的第一个原因是,有时我们需要跳转到另一个位置,例如执行循环,或者因为现在是时候跳转到程序的另一部分。这有时被称为无条件跳转。它的一个主要用途实际上是实现循环。假设我们想对值1、2、3、4、5、6等依次执行某些操作。我们的做法是,用某种寄存器保存这些值,每次我们想给R1加1(假设这是我们选择用来保存这个值的寄存器),然后我们用这个新值做任何需要做的事情。接下来我们想对R1的下一个值做同样的事情。我们这样做的方式是告诉计算机:“在执行完示例中的指令156之后,不要继续执行指令157,而是跳回指令102。”指令102基本上就是给R1加1,给我们R1的下一个值,然后继续用新值做之前做的事情。这允许你实现一个循环。机器语言总是有某种能力让软件告诉硬件再次执行某些操作、返回或跳转到不同方向。
注意,实际地址101、102和156并不那么重要。真正重要的是,当我们跳转到地址102时,它需要是我们实际意指的地址。因此,我们可以用符号化的方式来做这件事,给重要位置起个名字。例如,给位置102起名叫 loop,然后说 jump loop。这并没有真正改变什么,当我们在机器语言中用比特表示时是完全一样的,但对人类来说查看起来更方便。我们将在描述程序时采用这种方式。
还有其他情况我们需要处理流程控制,这时我们需要进行所谓的条件跳转。在某些情况下,我们想跳转到另一个位置,而在其他情况下,根据我们执行的上一条指令、某个寄存器的值或其他条件,我们只想继续执行下一条指令。这被称为条件跳转。例如,假设我想计算一个数的绝对值。如果这个数是正数,我只想对这个数进行一些操作。但如果这个数是负数,我想先把它变成正数,然后处理它的正数版本。我们可以通过条件跳转来实现。例如,jump-greater-than 指令意味着“如果R1大于0,我想让你跳转到标签 con(这是我编的一个名字),否则就继续”。这意味着如果我们有一个正数,我们将不会执行下一条指令(减法指令)。但如果我们有一个负数,我们将直接继续执行减法指令,该指令实际上对R1取反,使其变为正值。现在,在两种情况下,我们都继续执行相同的指令序列,并且无论如何,R1现在都是一个正值。这是另一个例子,说明我们有时需要条件跳转,而机器语言总是有某种机制来指示硬件执行这种条件跳转。
总结


至此,我们完成了对机器语言提供指令类型的一个非常快速、高度概括和基础的概述。现在,我们已经准备好实际讨论我们的计算机——Hack机器语言了。
006:Hack计算机与机器语言 🖥️

概述
在本节课中,我们将学习Hack计算机平台及其机器语言。我们将了解Hack计算机的基本构成,并详细探讨其机器语言指令的两种类型:A指令和C指令。通过理解这些核心概念,你将能够编写控制这台16位计算机运行的程序。
Hack计算机平台概览
在之前的两个单元中,我们讨论了机器语言的一般概念。从本单元开始,我们将具体探讨Hack机器语言,这是我们将用来操作Hack计算机的本地代码。
我们不打算过多讨论Hack计算机本身,因为后续会花一周甚至更多时间来讲解。但为了理解机器语言,我们需要对它的运行平台有一个概览。正如Noam之前描述的,计算机设计和机器语言设计是相辅相成的,两者之间存在某种对偶性,理解其中一个有助于理解另一个。
以下是你将从下周开始构建的计算机的概览。实际上,你一直在构建这台计算机的各个部分,下周我们将把这些部分组装起来。这台计算机主要由三个核心元素构成:
- 16位架构:这是一台16位计算机,意味着所有信息都以16位为基本单位进行处理。无论是存储、检索还是移动数据,都以16位块为单位。这是本计算机中信息的原子单位。
- 数据内存:这是一个16位值的序列,每个值存储在一个内存寄存器中。为了方便,这些寄存器被编号,我们可以将其视为寄存器0、寄存器1,一直到数据内存中拥有的所有寄存器。
- 指令内存:这是一个独立的内存空间,同样是一个16位值的序列。
- 中央处理器:这是一个能够处理16位值的设备,主要依靠其内部的算术逻辑单元。
- 总线:用于在不同部件间移动数据的通道。我们有连接CPU和数据内存的数据总线,以及将指令从指令内存移动到CPU的指令总线。为了简化图示,我们没有画出地址总线。你可以将总线想象成拥有16条车道的高速公路,负责在各个部件间传输16位数据块。
如何控制计算机?
我们通过软件来控制这台计算机。在这个机器层面,软件由机器语言构成,或者说,是我们用机器语言编写的程序。
在设计这台计算机及其机器语言时,我们决定创建两类指令,分别称为A指令和C指令。与这台机器中的所有事物一样,每条指令都表示为一个16位的数字。因此,一个Hack程序本质上就是一系列用Hack机器语言编写的16位数字指令序列。
在讨论控制流程之前,需要介绍平台上的另一个元素:复位按钮。
以下是让这台计算机为我执行有用任务的基本流程:
- 我编写一个程序,该程序是一组16位数字。
- 我通过某种方式(稍后讨论)将这些16位数字放入内存。
- 我按下复位按钮。
- 程序开始运行。
这就是让计算机执行任务的基本用户指令。至于计算机会做什么,完全取决于你编写的程序。它可能开始播放音乐、显示视频剪辑,或者计算一百万个数字的平均值等等。
Hack计算机的关键寄存器
在深入讨论机器语言之前,我们需要了解Hack计算机识别的三个关键寄存器:
- D寄存器:存放一个16位值,代表一个数据。
- A寄存器:同样存放一个16位值,可以代表一个数据值或一个内存地址(稍后详述)。
- 选中的内存寄存器:用字母 M 表示。无论数据内存中有多少个寄存器(例如20亿个),在任意给定时刻,只有一个寄存器被“选中”并处于活动状态。我们可以忽略所有其他不相关的寄存器,只关注当前选中的这个,并将其称为 M。这是在Hack机器语言规范中使用的一个约定。
Hack机器语言语法
现在,我们已经具备了理解语言语法所需的基本背景知识。
A指令
A指令的语法是:@value
其中,value 可以是一个非负十进制常数,或者一个指向此类常数的符号(符号稍后讨论)。
与其花太多时间讨论形式语义,不如看一个例子。@21 是什么意思?
执行 @21 时会发生两件事:
- A寄存器被设置为21。
- 数据内存中地址为21的寄存器(RAM[21])成为当前选中的寄存器(即M)。
我们可以看到,A寄存器有一个非常重要的副作用:一旦你将A寄存器设置为一个特定值(如21),它会自动从数据内存中选择一个特定的寄存器,该寄存器就成为当前选中的寄存器,也就是上一张幻灯片中我们称为 M 的那个。
以下是实际使用这条指令的例子。假设我们想将RAM[100]设置为-1,该怎么做?
首先,我们必须选择要操作的内存寄存器,通过执行 @100 来实现。
一旦执行了这条指令,副作用生效,M现在指向内存单元中编号为100的寄存器。然后我们就可以说 M=-1。
这就是A指令的用法。在对内存进行操作之前,我们总是需要它。在我们对内存做任何事情之前,必须先选择一个寄存器。因此,我们总是需要使用A指令来寻址内存,这也是它被称为“A”(Addressing,寻址)指令的原因。
C指令
C指令是语言的核心,大部分操作都在这里进行。这条指令的语法包含三个不同的字段,我们称之为目的地、计算和跳转指令。
它的工作原理如下:
首先,我们计算某个值。
然后,我们可以做两件事中的一件:
- 将计算结果存储到某个目的地。
- 或者,利用这个计算结果来决定是否要跳转到程序中的另一条指令。
这是C指令的基本整体语义。现在让我们更深入地了解每个字段的细节。
计算字段可以是这里列出的任何计算。我们看到一组有时被称为助记符或符号的东西,它们代表某种操作。
我们可以计算值0、1、-1,可以计算D寄存器的当前值,可以计算D-1、D+A、D&A等等。在助记符中出现 A 的地方,我们也可以用 M 来替换。因此,使用计算字段我们可以做许多不同的事情。
目的地字段有8种可能的目标,从“null”(空)开始,表示我们根本不想存储计算结果。
我们可以将计算结果存储在当前选中的RAM寄存器(M) 中,或者存储在D寄存器中。有趣的是,我们还可以同时将结果存储在M和D中。这是Hack机器语言一个相当强大的特性:我们可以将计算结果同时存储在多个容器中。程序员可以根据他/她想做的事情,自由使用这8种可能性中的任何一种。
跳转指令字段需要一点时间来适应。它的工作原理如下:
我们有8种可能的条件,这些条件总是将计算结果与0进行比较。
同样,与其赘述,不如稍后通过例子来说明,这样会更清晰。
实例解析
让我们通过几个例子来巩固理解。
例1:将D寄存器设置为-1。
查看语言规范,我看到“-1”是我可以计算的值之一,而“D”是合法的目的地之一。因此,我可以直接写 D=-1。跳转指令是可选的,所以我不需要指定它。任务完成。
例2:将RAM[300]设置为D寄存器的值减1。
再次查看语言规范,但在那之前,实际上我必须先选择要操作的内存寄存器。每当我想要访问内存时,都必须使用一条A指令来选择感兴趣的寄存器。所以我先写 @300。
然后查看规范,我发现“D-1”是CPU可以为我计算的值之一,而“M”是合法的目的地之一。因此,我只需要写 M=D-1。这样我就将寄存器300设置为了D-1。如你所见,执行这个特定操作需要两条指令。每当我需要对内存进行操作时,首先必须使用一条A指令来选择要操作的寄存器。
例3:实现条件跳转。
我如何告诉计算机跳转去执行一条特定的指令?假设我想要使用的条件是:如果 D-1 等于0,则跳转到执行位于RAM[56]的指令。这类似于一个终止循环的条件:我在循环中做某事,每次检查 D-1 的值,当它达到0时,我想跳转到其他指令。
那么,我该怎么做呢?再次强调,每当我处理内存时,首先要做的是使用一条A指令来指定我想要操作的寄存器地址,所以我写 @56 来选择地址56。
然后查看语言规范,我看到“D-1”是可能的计算之一,并且我还有一个“JEQ”,意思是“如果计算值等于0则跳转”,这正是我想做的。我想在 D-1 等于0时跳转。所以我计算 D-1 并说 JEQ。除了“等于”,我还可以使用“大于”、“小于”、“不等于”等条件。
另外请注意,我还有一个无条件跳转。如果我只想无条件跳转到地址56,而不检查任何条件,我只需要说 JMP。实际上,为了更精确,这种语言的语法要求我写 0;JMP。这只是一种约定,所以如果我想进行无条件跳转,我就写 0;JMP,这将导致无条件跳转。
顺便说一下,我在这里所说的一切在教材和网站上都有完整描述,所以你实际上不需要记住任何细节,因为以后可以随时查阅。
总结
本节课我们一起学习了Hack计算机平台及其机器语言。我们了解了Hack计算机的基本架构,包括其16位特性、数据内存、指令内存、CPU和总线。我们重点探讨了两种核心指令:用于内存寻址的A指令和用于计算、存储及控制流程的C指令。通过实例,我们看到了如何组合这些指令来完成设置寄存器值、操作内存以及实现条件跳转等基本任务。

在下一个单元中,我们将更具体地讨论这种语言,因为我们将描述它的两种不同形式:到目前为止我们看到的是其符号化形式,但我们也可以用二进制代码来编写所有这些指令。了解这两种形式如何相互关联会非常有趣,我们将在下个单元中进行探讨。
007:Hack语言规范 🖥️

在本单元中,我们将完成对Hack机器语言的描述和规范说明。
概述
我们将学习Hack机器语言的完整规范,包括其两种表示形式:符号化表示和二进制表示。理解这两种形式及其映射关系,是后续编写和运行程序的基础。
机器语言的整体框架
上一节我们介绍了计算机硬件平台的基本构成。现在,我们来看看其软件层面的核心——机器语言。
整体背景是,我们有一个由指令内存、CPU和数据内存组成的硬件平台。我们有一套机器语言,由A指令和C指令构成。我们在上一个单元描述了这些指令。一个Hack程序就是一系列这样的指令组合在一起,我们一次执行一条指令。这就是整体情况。
事实上,对于任何机器语言,你都可以用两种不同的风格或语言来编写程序。你可以使用助记符和友好的符号进行符号化编写,正如我们在幻灯片左下方看到的那样。或者,你也可以使用约定好的二进制代码来编写。
如果你用符号编写程序,就需要有人将这些程序从符号翻译成二进制代码。一旦程序以二进制代码的形式被指定,你就可以将这些代码加载到计算机中并实际执行。
我们将用整整一周的时间来讨论这个翻译过程,以及一个非常特殊的程序——汇编器。因此,我不会花太多时间讨论翻译过程,但我想让大家知道,在构建这台计算机时,这是一个必须面对的挑战。
A指令的语法规范
首先,我们来详细看看A指令的语法。
符号化语法
A指令的符号化语法我们之前已经见过:@value。这个值可以是一个最大为 2^15 - 1 的数字。你可能想知道这个数字从何而来,稍后就会看到。或者,它也可以是一个指向该数字的符号,关于符号的讨论我们将推迟到后面的单元。例如:@21。
二进制语法
以下是同一条指令的二进制形式。我们以一个特殊的代码 0 开始,它告诉计算机这是一条A指令。然后,我们指定与符号指令中相同的值,但使用二进制代码来指定。这样我们就得到了像这里展示的例子。
再次说明,开头的 0 有时被称为操作码。然后是15位,代表我们想要加载到A寄存器中的值。在这个例子中,二进制数 0000000000010101 等于十进制的 21。
C指令的语法规范
上一节我们介绍了A指令,本节中我们来看看C指令的规范。
符号化语法
C指令的符号化定义非常用户友好。我们有一个可以存储到特定目的地的计算,以及一个可选的跳转指令。这就是符号表达的巨大优势。
二进制语法
如果我们想用二进制表达它,就必须约定一些代码。在设计这种语言时,我们已经完成了这项工作。以下是同一符号指令的16位规范。
第一位是操作码,它告诉计算机这是一条C指令。我们只有两种类型的指令:A指令和C指令,这就是为什么我们只需要一位来表示操作码,即 0 或 1。因此,操作码 1 表示这是一条C指令。接着是两位我们未使用的位,按照惯例我们将其设为 1。
接下来的七位共同指定了要执行的计算。这些是稍后将发送给ALU的控制位,告诉ALU它必须执行哪种计算。再接下来的三位代表目的地。最后三位代表我们使用“jump”这个词符号化调用的跳转条件。
这就是指令在二进制形式下的不同字段。
符号与二进制的映射关系
现在我们已经了解了两种指令的格式,接下来让我们讨论C指令的符号表达与二进制表达之间的映射关系。
计算字段映射
以下是计算字段的映射表。
以下是关联符号化计算助记符与其二进制等价物的表格。左侧是符号,右侧是二进制代码。表格底部还有一个 a 位。
例如,假设计算是 D+1。符号上,我们希望计算机计算 D+1。我们查表,在表格中间某处找到 D+1,并看到它列在 a=0 的列中。因此我们知道 a 位应为 0。然后我们查看表格中该行的其余部分,看到 c 位应为 0111111。就是这样。这就是我们用二进制表示 D+1 操作的方式。只需查表即可获得。
目的地字段映射
目的地字段的映射原理非常相似。
我们有一个映射表,左侧给出符号助记符,下一列是它们的二进制代码等价物。由于有8种不同的可能目的地,这些代码非常方便地从 000 排到 111。
再次说明,如果有人给你一个特定的目的地,比如 M,你可以在表中查找,并立即看到它对应于二进制代码 011。这就是在需要时从符号翻译成二进制代码的方法。
跳转字段映射
最后,让我们关注跳转字段。
其概念与目的地字段几乎相同。左侧是助记符,下一列有八种不同的二进制组合。同样非常方便,我们有八种不同的跳转条件,因此其二进制等价物也方便地从 000 排到 111。
完整的C指令规范
基本上,这总结了符号和二进制代码之间的映射关系。如果你想把它全部整合起来,我们可以在一张幻灯片中完成。这是C指令的完整规范,展示了其符号形式和二进制形式的所有细节。如果你必须编写一个计算机程序来将一种语言翻译成另一种语言,你可以开始看到如何使用这个逻辑来编写这个程序。顺便说一下,这是我们将在本课程最后一周要做的事情。
Hack程序示例
现在我们已经理解了具体指令在二进制和符号形式下的样子,让我们继续讨论Hack程序的整体概念。
以下是一个Hack程序的示例。在课程的这个阶段,你不需要理解这个程序,我们只是给你一个程序外观的初步概览,我们可以做一些快速的观察。
首先,一个Hack程序是一系列Hack指令。这个程序是使用符号指令编写的。允许使用空白,你可以在任何你认为能提高程序可读性的地方插入空行。欢迎使用注释,并且可以随意使用。
最后,我想说这不是编写Hack代码的好方法。有更好的方法来编写代码,使用更少的数字和更多的符号,这是我们将在课程后面再次做的事情。现在,我只是想让你看一个典型Hack程序的例子。
如果我们想在计算机中运行这个程序,首先必须将其翻译成二进制代码。因此,我们需要一个人类汇编员或一个计算机程序来进行从一种形式到另一种形式的翻译。一旦程序以二进制代码表达,我们就可以将其加载到计算机中并执行它。程序将有望做一些有用的事情。如果没有,我们可以返回去调试程序,重新编译或重新汇编,重新运行,等等,直到我们满意为止。
总结

本节课中我们一起学习了Hack机器语言的完整规范。这是讨论Hack机器语言的最后一个单元。在下一个单元中,我们将讨论如何使用这种语言来控制连接到Hack计算机的输入输出设备。
008:处理输入与输出 🖥️⌨️

在本单元中,我们将学习如何使用机器语言来控制和操作计算机的输入输出设备。我们将重点介绍两种核心设备:显示器和键盘,并解释如何通过操作内存中的比特位来与这些设备进行交互。
上一节我们介绍了计算机的基本架构,本节中我们将为其扩展两种重要的IO设备。
屏幕内存映射 🖼️
为了控制显示器,我们需要理解一个核心概念:屏幕内存映射。屏幕内存映射是数据存储器(RAM)中的一个特定区域。物理显示单元会持续不断地从该内存映射区域刷新其显示内容,每秒发生多次。
因此,如果我想在屏幕上显示内容,我只需要操作内存映射中的相应比特位。在下一个刷新周期,我对内存所做的更改就会反映在屏幕上。
以下是屏幕内存映射的工作原理:
- 物理屏幕抽象:Hack计算机的显示器被抽象为一个256行、512列的矩阵。每个行列交点称为一个像素。这是一个黑白屏幕,每个像素只能是开启(黑色)或关闭(白色)状态。
- 内存映射结构:屏幕内存映射由8K个16位字(word)组成。每个字对应屏幕上的16个连续像素。整个映射恰好对应256 * 512 = 131,072个像素。
- 像素寻址:要操作屏幕上特定行(
row)和列(col)的像素,需要找到对应的内存地址和比特位。计算步骤如下:- 计算目标像素所在的内存字地址:
address = 32 * row + col / 16(col / 16为整数除法,舍去余数)。 - 从内存中读取该地址的16位字。
- 在该字中,定位目标比特位:
bit_position = col % 16(取col除以16的余数,范围0-15)。 - 将该比特位设为0或1。
- 将修改后的16位字写回内存。
- 计算目标像素所在的内存字地址:
在Hack架构中,屏幕内存映射由一个名为Screen的芯片实现。当我们将此芯片集成到完整的计算机内存中时,其基地址是16384。因此,在完整系统中访问屏幕内存的绝对地址为:16384 + (32 * row + col / 16)。
键盘内存映射 ⌨️
与屏幕类似,键盘也通过一个内存映射区域与计算机连接。这个映射比屏幕简单得多。
以下是键盘内存映射的工作原理:
- 物理连接抽象:物理键盘通过一个名为
Keyboard的16位寄存器芯片连接到计算机。该芯片代表键盘在计算机架构内部的状态。 - 扫描码:当按下键盘上的一个键时,该键对应的扫描码(一个预先约定好的数值)会通过连接线传输并出现在键盘内存映射(即
Keyboard寄存器)中。松开按键时,寄存器值恢复为0。 - 查询按键:要检测当前按下了哪个键,程序只需读取
Keyboard芯片的输出值。如果值为0,表示没有按键被按下;如果值为非零,则该值就是被按下键的扫描码,通过查表即可知道是哪个键。
在完整的Hack计算机内存地址空间中,键盘内存映射位于地址24576。因此,通过读取内存地址24576的值,即可获取键盘输入状态。
硬件模拟器演示 💻
为了更直观地理解,我们可以在硬件模拟器中观察这些芯片的工作方式。
屏幕芯片演示
- 加载内置的
Screen.hdl芯片。这是一个内存芯片,具有地址输入、数据输入、数据输出和加载位。 - 其GUI侧边会模拟一个256x512像素的物理屏幕。
- 例如,要将第3行的前16个像素涂黑:
- 计算地址:
32 * 3 = 96。 - 在地址输入中写入96,在数据输入中写入-1(其二进制补码表示是16个1),并断言加载位。
- 点击“时钟”按钮执行写入操作。你会看到内存地址96的值变为-1,同时模拟屏幕上第3行开头出现了16个黑点。
- 计算地址:
键盘芯片演示
- 加载内置的
Keyboard.hdl芯片。这是一个只读芯片,只有一个输出引脚。 - 点击模拟器界面上的“启用键盘”按钮,将芯片连接到你的物理键盘。
- 按下键盘上的任意键(例如‘G’、空格、方向键),芯片的输出引脚会实时显示该键对应的扫描码(如71、32、133等)。
- 松开按键,输出值恢复为0。

本节课中我们一起学习了计算机输入输出的基本原理。我们了解到,在底层硬件层面,IO设备是通过特定的内存映射区域来控制的。对于屏幕,我们通过向屏幕内存映射的特定比特位写入0或1来控制像素的亮暗;对于键盘,我们通过读取键盘内存映射寄存器中的扫描码来获取用户的按键输入。这些操作虽然基础,但却是所有高级图形和交互功能的基石。在课程的第二部分,我们将基于这些硬件知识,构建更高级的软件层和IO库。
009:底层编程第一部分

在本单元中,我们将开始学习如何使用Hack机器语言进行底层编程。我们将重点介绍寄存器和内存的基本操作,这是所有底层程序的基础。
概述
现在你已经熟悉了Hack编程语言,是时候看看我们能用这种语言做什么了。在接下来的三个单元中,我们将讨论底层编程这个广泛的主题,特别是Hack机器语言的编程。在开始之前,我们先来概述一下Hack语言中的两种指令。
我们有两种指令:
- A指令:用于将A寄存器设置为一个特定的值。
- C指令:用于做三件不同的事情:计算一个表达式、将计算结果存储到某个目的地,以及根据计算结果决定是否跳转到程序的其他区域执行(即执行
goto操作)。
这种语言是符号化的,由助记符组成。但计算机只理解0和1,因此在执行任何用符号化Hack机器语言编写的程序之前,我们必须先将其翻译成二进制代码。这个翻译工作通常由一个叫做汇编器的程序来完成。
汇编器接收一个用符号语言编写的文件,并将其翻译成另一个文件,这个文件同样用符号表示,但这些符号只有0和1。生成二进制目标代码后,我们就可以将这个文件加载到Hack计算机中并最终执行预期的程序。
这是Hack汇编器。在本课程的第六周,我们实际上会构建这样一个汇编器。但现在,你只需要知道如果你需要使用它,它是可用的。
将符号程序翻译成二进制代码的另一个选项是使用提供的CPU模拟器。你可以将任何用符号化Hack语言编写的程序直接加载到CPU模拟器中。CPU模拟器是一个Java程序,它有一个图标允许你将文件加载到指令内存中。当你将这些文件加载到指令内存时,CPU模拟器会实时地将程序翻译成机器语言。一旦完成,你就可以在CPU模拟器中执行程序。因此,CPU模拟器是一个用于调试和模拟执行Hack程序的非常友好的工具。我们建议,如果你想尝试本小节中看到的代码片段,请使用提供的CPU模拟器。
那么,当我们谈论底层编程时,我们指的是什么呢?令人惊讶的是,我们指的是你在任何编程语言中都会遇到的一整套问题,比如处理内存和变量、分支、迭代、指针、输入和输出。这些惯用法在任何编程语言中都广泛使用,Hack机器语言也不例外。在本单元中,我们将开始讨论寄存器和内存。在后续单元中,我们将讨论其他稍微更高级的主题。
寄存器与内存操作
寄存器和内存是底层程序的基础,也是你经常需要操作的对象。我们主要操作两种位于CPU内部的标准寄存器:D和A。
- D寄存器(数据寄存器)可以保存一个16位的值。
- A寄存器可以保存一个数据值或一个地址,具体取决于程序员想要如何使用这个寄存器。
- 最后,我们使用字母M(助记符)来指代当前选中的内存寄存器。
A寄存器有一个很好的副作用:当你给它加载一个特定值时,它最终会从RAM中选择一个寄存器。这个寄存器在我们的语言中被称为M。因此,我们可以执行类似M = -1或M = 0等操作。
这些就是我们将要操作的主要寄存器。以下是一些我们可以用这些寄存器实际完成的操作示例。
以下是几个基本操作的代码示例:
-
将数字10存入D寄存器:没有直接的方法可以做到这一点。我们需要间接操作:先将A寄存器设为10,然后将A的值赋给D。
@10 D=A -
将D寄存器的值加1:这可以直接用一条指令完成,因为C指令允许我们计算
D+1的值,然后将结果存回D。D=D+1 -
将D设为RAM[17]的值:要访问特定的内存寄存器,首先必须寻址(选中)该内存。我们先执行
@17,然后设置D为所选寄存器的值。@17 D=M -
将D的值存入RAM[17]:操作与上一条相反。
@17 M=D -
将RAM[17]设为10:这可以通过组合之前的操作来完成。首先获取常数10,然后将其存入RAM[17]。
@10 D=A @17 M=D -
将RAM[3]设为RAM[5]:同样可以使用类似的操作组合。
@5 D=M @3 M=D
你可以暂停视频,仔细查看这六个代码示例,并确认它们完成了预期的功能。
一个完整的程序示例
现在,让我们将这些内存和寄存器操作放到一个实际的程序上下文中。
这里有一个程序,旨在将两个数字相加并将结果存储在内存中的某个位置。我们假设在用户运行此程序之前,用户已将两个值(例如5和7)放入RAM[0]和RAM[1]中。运行此程序后,程序将这两个数字(本例中为5和7)相加,并将结果存储在RAM[2]中。如果一切正常,RAM[2]将包含12。选择RAM[0]、RAM[1]和RAM[2]只是任意的决定,我们决定使用RAM[0]和RAM[1]作为程序的输入,RAM[2]作为程序的输出。
我们是如何做到的呢?你可能已经猜到,我们使用了与之前非常相似的操作。
// 将两个数字相加
// 使用说明:将想要相加的值放入RAM[0]和RAM[1]
// 结果:RAM[2] = RAM[0] + RAM[1]
@0
D=M // D = RAM[0]
@1
D=D+M // D = D + RAM[1]
@2
M=D // RAM[2] = D
如果我们运行这个程序,希望它能计算出RAM[0]和RAM[1]的和。
在继续之前,我想提醒你,Hack语言中的指令有隐含的行号。你在编写程序时看不到它们,但这些数字潜藏在后台。当你翻译这个程序并将其加载到内存中时,有一些有趣的观察需要做。首先,空白行会被忽略。真正起作用的只有那些有行号的“真实”指令。此外,重要的是要记住,我们在指令内存中看到的是这个程序的符号化视图。这样的程序本身不能在计算机上执行,因为计算机除了0和1之外无法处理其他符号。但如果你使用我们的CPU模拟器,你可以告诉模拟器以二进制形式显示指令内存的内容。如果你这样做,你会看到程序以这种形式呈现。在这种状态下,程序实际上可以在计算机上执行。然而,通常来说,当程序以符号形式编写时,思考、讨论和调试程序要容易得多。因此,我们建议你在尝试理解程序在做什么时,以符号形式进行。只有当你准备运行它时,如果你愿意,可以看一下二进制代码。但解码二进制代码在做什么是非常困难的。我们很幸运拥有所有这些符号,使我们的程序更具可读性。
考虑到这一点,我想调用CPU模拟器,并演示这个程序是如何实际执行的。
这个演示的目的是说明如何用Hack符号语言编写一个小程序,然后在CPU模拟器上运行它。
(演示过程:编写程序、保存、加载到模拟器、设置RAM[0]=5和RAM[1]=7、逐步执行指令、观察D寄存器和RAM[2]的变化,最终RAM[2]变为12,程序成功运行。)
程序的正确终止
看过我们刚刚进行的演示,以及在这个演示结束时遇到的问题后,让我们看看如何正确地终止一个程序。提醒一下,我们遇到的问题是指令流变得有些失控。基本上,我们告诉计算机执行每一条指令,一切都很顺利,但随后所谓的“空指令”开始起作用,计算机基本上就失控了。
如果我是一个黑客,一个坏的黑客,看到这种执行模式,我可能会说,也许我可以写一些恶意程序,把它放在这个内存下游的某个地方,然后让用户天真地运行他的程序。用户会愉快地运行他的程序,程序会做它应该做的事情,但随后在不知情的情况下,计算机会继续执行,然后“砰”的一声,我的程序就开始工作,开始做一些坏事,比如删除用户电脑上的随机文件。
那么,我们该如何避免这个潜在的问题呢?顺便说一下,这种特定的攻击被称为空操作滑行。空操作代表空指令或无操作码。我们从指令6开始往后的内容就是空指令。一个坏的黑客可以利用这些指令将控制流“滑行”到他控制的内存区域,然后就可能发生坏事。我们该如何避免这个问题?一般来说,我们该如何正确地终止一个程序?
有一件事你必须明白:计算机永远不会静止不动。它们总是在做些什么。即使你不碰键盘,后台也有许多进程在运行。在Hack计算机中,因为我们不希望计算机做疯狂的事情,我们不妨让计算机做一些我们控制的事情。所以,我们可以做的是用一个无限循环来结束程序。
我们可以添加两条命令,比如@6,然后在第7行,跳转到第6行。这样我们就有了一个无限循环,一切都在控制之中,因为这是我们有意让程序做的事情。因此,作为一个最佳实践建议,我们建议你用无限循环结束你的每一个程序。
@END
(END)
0;JMP // 无限循环,程序在此终止
语言的内置符号
在本单元结束之前,我想谈谈我们尚未讨论的该语言的另一个特性。通过说明这一点,我将基本完成Hack机器语言的规范说明。该语言具有几个内置符号,如下所示。
首先,我们有一组16个所谓的虚拟寄存器,或者更准确地说,我们有一组16个标签,我们像使用虚拟寄存器一样使用它们。这些标签的范围是从R0到R15。
约定是这样的:每当汇编器或翻译器看到像R3这样的标签时,它会用数字3替换它。仅此而已。
你可能会问,为什么我们需要这些花哨的标签?这里有一个例子可以说明这些标签如何发挥作用。
这是一个非常简单的代码片段,我们将RAM[5]设置为15。
// 版本 A:直接使用数字地址
@15
D=A
@5
M=D
// 版本 B:使用虚拟寄存器标签
@15
D=A
@R5
M=D
让我们仔细阅读这里发生了什么。在第一对指令中,我们将A寄存器用作数据寄存器,将数字15放入A,然后将其移动到D。在第二对指令中,我们做了一些非常不同的事情:我们使用@5命令来寻址内存,选择内存寄存器5号,然后执行M=D。
这段代码有些令人困扰。首先,我们做了两件非常不同的事情,却使用了完全相同的语法@数字。我认为这有点令人不安。另一个有点麻烦的问题是,当你读一条单独的A指令时,在看到下一条指令之前,你无法知道程序员想做什么。
那么,我们如何让这件事更具可读性呢?我们的建议是:每当你想寻址内存中前16个寄存器之一时,使用标签约定。这样,任何阅读你程序的人(包括你自己)都能确切知道你想做什么。因为机器语言程序很难阅读和理解,所以我们必须尽一切努力使其更具可读性,以便你在一周或两周后再次看到你的程序时,能知道程序在做什么。所以在右边看到的第二个例子中,与左边的相比只有一个很小的区别,但它更具可读性,并且程序将有效地做完全相同的事情,因为一旦被翻译,R5将变成5,然后我们又回到了之前的状态。这就是我们推荐使用这些所谓虚拟寄存器的动机。
有一件事你必须注意:像许多其他编程语言一样,Hack是区分大小写的。大写的R5与小写的r5是不同的。我们建议你记住这个观察结果,因为如果你忘记了它,可能会导致各种非常奇特的错误。将来,如果你的程序没有语法问题,但你不知道这个程序在做什么,很可能是因为你拼错了某个符号。所以,当你运行自己的符号程序时,请把这一点记在心里。
语言中还有哪些其他符号呢?我们还有SCREEN和KEYBOARD,我们在之前的一个单元中讨论过,它们代表屏幕和键盘内存映射的基地址。
我们还有一组六个额外的符号,我们在本课程中并不真正使用。让我总结一下Hack语言中的内置或预定义符号:我们有16个虚拟寄存器,有两个代表输入/输出映射基地址的符号,还有一组六个符号。这些符号供那些想在Hack平台上编写虚拟机和高级语言编译器的人使用。这实际上是我们本课程第二部分(称为Nand2Tetris Part 2)中要做的事情,但在本课程中我们不会使用这六个寄存器。
总结
在本单元中,我们一起学习了Hack底层编程的基础,重点是寄存器和内存的操作。我们了解了A指令和C指令的基本用途,学习了如何通过直接或间接的方式操作D、A寄存器和RAM。我们通过一个完整的加法程序示例,演示了如何编写、加载和运行一个简单的Hack程序。我们还讨论了程序正确终止的重要性,并介绍了使用无限循环作为标准做法。最后,我们介绍了Hack语言的内置符号,特别是虚拟寄存器(R0-R15),它们能显著提高代码的可读性。记住,Hack语言区分大小写,这是避免错误的关键点。

下一单元,我们将探讨分支、变量和迭代,进一步扩展我们的底层编程技能。
010:底层编程第二部分

📖 概述
在本单元中,我们将学习底层编程中的三个核心概念:分支、变量和迭代。这些概念是高级编程语言中常见结构的基础,我们将了解如何在机器语言层面实现它们。
🔀 分支
上一单元我们介绍了如何在内存中使用寄存器,这是底层机器语言进行一切操作的基础。本节中,我们来看看更接近高级程序员思维的概念——分支。
分支是计算机的一项基本能力,它让计算机能够评估一个布尔条件或表达式,并根据其结果决定是跳转到程序的另一个部分执行,还是继续执行下一条指令。
任何编程语言都有各种分支机制。例如,在Java或Python中,有 if-else、while、repeat、switch 等结构。在机器语言中,我们通常只有一种分支机制,称为 Go to。
以下是一个具体示例。在这个程序中,我们希望检查某个数据值,判断它是否为正数。按照约定,该值存放在 RAM[0] 中。程序执行完毕后,如果 RAM[0] 为正数,则在 RAM[1] 中存入 1,否则存入 0。
这本质上是一个 if-else 结构。遗憾的是,在底层编程中,我们无法直接表达这种结构。底层编程更加精简和原始,因此我们需要通过一系列指令来实现它。
以下是实现该功能的一段代码:
// 检查 R0 是否大于 0,如果是,设置 R1 为 1
@R0
D=M
@8
D;JGT
@R1
M=0
@10
0;JMP
@R1
M=1
@10
0;JMP
在指令 8 和 9 中,我们处理了将 R1 设置为 1 的操作。在指令 2 和 3 中,我们检查了数据输入(现在存放在 D 寄存器中)的值,并据此决定是否跳转到指令 8。
如果不确定这段代码的工作原理,建议暂停视频,在脑海中或纸上模拟运行,以理解分支在此处是如何工作的。
现在,作为实验,我们移除行号和注释,只留下代码本身。你会发现,剩下的代码非常难以阅读和理解。这是一个典型的“神秘代码”例子,尽管它能完美运行,但缺乏可读性。
这正是引用 Donald Knuth 名言的好时机:“与其想象我们程序员的主要任务是指导计算机做什么,不如集中精力向人类——我们的同行程序员——解释我们想让计算机做什么。” 这一点至关重要,因为如果我们的程序不能自我说明,我们将无法修复和扩展它们。
那么,如何让这个程序更具可读性呢?幸运的是,汇编语言有一个非常好的特性,叫做 符号引用。
🏷️ 符号引用与标签
让我们看看如何改进。我引入了一个自己创建的标签,并决定称之为 POSITIVE,因为它大致描述了我想要做的事情。这个 POSITIVE 标签在代码中出现了两次。
首先,我声明了这个标签。我在这里的意思是:这里有一段代码,我希望从程序的其他地方跳转过来,我将这个代码位置称为 POSITIVE。
在设计 Hack 汇编语言时,我们决定通过将标签写在圆括号内来声明标签。因此,在 Hack 汇编语言中,声明标签的语法是 (LABEL_NAME)。
使用标签时,我们不再说 @8,而是说 @POSITIVE。汇编器(翻译器)的任务就是将这些标签翻译成具体的数字。
翻译遵循一个通用规则:@LABEL 会被翻译成 @n,其中 n 是标签声明之后的下一条指令的编号。
请记住,指令有隐式的编号。当我们把这段代码加载到指令内存时,会发生以下情况:
首先,标签声明本身不会被翻译成机器码。它们被忽略,不生成任何代码。
其次,对标签的引用会被替换为对该标签声明之后的下一条指令编号的引用。
因此,@POSITIVE 变成了 @8(因为 8 是 POSITIVE 标签后的指令编号),@END 变成了 @10。
一旦我们理解了这一点,就可以在程序中自由地发明和使用任意多的标签,并用它们来实现分支逻辑。
📦 变量
处理完分支后(显然我们还会再次涉及它,因为任何非平凡的程序都离不开分支),让我们继续讨论另一个抽象概念:变量。
变量是一个具有名称和值的容器的抽象。在高级语言中,我们有不同类型的数据和变量。然而,在机器语言的底层,至少在 Hack 机器语言中,我们只有一种变量类型:16 位的值。这个抽象可以通过使用数据内存中的一个单独寄存器来实现。如果我们要在程序中创建变量,就使用一个单独的寄存器来表示每一个变量。
以下是一个变量应用的例子。在这个程序中,我想交换 RAM[0] 和 RAM[1] 的值。我使用了一个经典的编程惯用法,它在几乎所有基础编程课程中都会出现:创建一个变量(通常称为 temp,表示它是一个临时变量),然后进行一些操作,最终完成交换。
问题在于,如何在目前还没有变量概念的机器语言中实现它?这是一个引入变量的好机会。
以下是我决定如何实现它的代码。让我们只关注程序中的红色指令。在第一条红色指令中,我声明了一个名为 temp 的变量,然后立即使用它。
@temp 这个神秘命令的含义是什么?请注意,与标签和引用不同,我们并没有一个对应的叫做 temp 的标签。那么,当我们这样写 @temp 时,我们是什么意思呢?
我们的意思是向计算机提出以下请求:请到内存单元中,找到一个可用的内存寄存器(假设是第 N 号寄存器),并从现在开始用它来代表我称之为变量 temp 的东西。
因此,从现在开始,每当你看到符号指令 @temp,我请求你将其视为我们已经知道如何处理的 @N 指令。
这就是变量抽象的实现方式。
让我们观察一下,当你翻译这个程序并将其加载到指令内存时会发生什么。
首先,你会看到代码中原来的 @temp 变成了 @16。因为 @temp 在程序中出现了两次,所以我们有两个 @16 指令的出现。
这是如何发生的呢?编写汇编器或实现此翻译器的人,是根据我们作为语言设计者制定的“契约”来工作的。
这个契约有两条规则:
- 对一个没有对应标签声明的符号的引用,被视为对一个变量的引用。我们假设程序员想要创建一个变量。
- 变量从 RAM 地址 16 开始分配。
在这个特定例子中,我们只有一个变量,所以它最终被分配到 RAM[16]。如果程序中有更多变量,我们将使用 RAM[17]、RAM[18] 等地址。实际上,你可以使用任意多的变量。在 Hack 语言中,你实际上可以使用数千个变量,但通常程序员只会用到半打或一打左右。
关于变量的这一小节结束时,如果你看这段代码,你会发现这个程序相对容易阅读和调试。我们有一个符号标签和一个符号变量 temp,与之前使用实际数字表示地址的程序相比,理解这里发生了什么要容易得多。
这个程序还有另一个更微妙但非常好的优点:它是所谓的 可重定位代码。
我可以把这个程序加载到内存中,不一定是从地址 0 开始。我可以把它放在内存中任何我想要的地方,只要我记得这个程序使用的基础地址。这一点极其重要,因为你知道,当你在个人电脑上工作时,通常同时运行着多个程序,这意味着多个程序被加载到主内存中。一旦我们使用符号引用仔细编写这些程序,就不必担心它们将位于内存中的哪个位置。我们可以编写一个叫做“加载器”的程序来处理这个技术细节。
所以,符号化程序是好的。
🔁 迭代
接下来我想讨论的编程惯用法是 迭代。
例如,假设我们想计算级数 1 + 2 + 3 + ... + n 的和,其中 n 是给定的输入。我们该怎么做?
通常,我们会使用一个累加器变量来累积级数的运行总和。你把这个变量设为 0,然后 0+1=1,1+2=3,3+3=6,依此类推,直到达到 n。
如果你必须用底层语言编写这个程序,最好从一些面向底层的伪代码开始。以下是我所做的。
一旦你确信这段伪代码确实能实现所需的功能,我们就可以继续在纸上将这段伪代码翻译成符号机器语言。
让我们从程序的第一部分开始,在这里我们声明并初始化三个变量 n、i 和 sum。
首先,请注意我伪代码中使用的变量名与符号语言中的变量名之间存在一一映射关系,这很好。
我们有这三个变量。假设我只翻译程序的这一部分并将其加载到指令内存中,我将得到:@n 被翻译成 @16,@i 被翻译成 @17,@sum 被翻译成 @18。这并不奇怪,因为它遵循了我们之前讨论的契约:变量从地址 16 开始分配到连续的 RAM 地址。
让我们记住这一点,并完成将伪代码翻译成机器语言的工作。这个程序的细节并不是特别重要(当然,如果你真的要写这个程序,它们很重要),但对于我想在本单元强调的内容来说并不重要。不过,我仍然希望你再次暂停视频,并确信自己理解这段代码在做什么。
顺便说一下,当你编写这样的程序时,如果我把它作为作业交给你,我强烈建议你先用伪代码写出来。
然后,我建议你调试你的伪代码,并说服自己伪代码确实能工作。一旦你确信它能工作,你就可以将编写机器代码的任务简化为仅仅从伪代码翻译成机器语言。你看,这样做要容易得多。你看着像 n = R0 这样的伪代码命令,然后写一组实现相同功能的机器语言指令;或者看看伪代码中循环指令后面的语句,比如 if i > n goto STOP,只专注于翻译这一条指令要容易得多。
这是编写底层代码并确信其能工作的一种方法。
验证代码能工作的第二种方法同样重要:一旦你的程序写好了,你必须模拟它。我们建议你在纸上使用一种叫做 跟踪表 的东西来模拟它。
跟踪表是一张纸,你在上面写下程序中所有主要“参与者”或你感兴趣的主要变量的名称,然后在表中记录这些变量在整个程序执行过程中的值。这些是迭代 0 时这些变量的值。然后你遍历循环,找出第一次迭代、第二次迭代、第三次迭代等之后发生了什么。你这样做了三到四次迭代,试图说服自己程序确实在工作。
💡 最佳实践建议
最后,这是我们编写 Hack 机器语言程序的最佳实践建议:
- 设计:首先,你必须使用某种伪代码来设计程序。你可以遵循我们在上一张幻灯片中使用的伪代码示例。
- 翻译:其次,你必须将你的伪代码用 Hack 汇编语言重新表达出来。
- 测试:最后,我们建议你使用某种跟踪表在纸上测试最终的程序。
请注意,我是在没有接触计算机的情况下完成所有这些事情的,一切都是在纸上完成的(实际上我可能用了文本编辑器,但文本编辑器对我来说就像使用一张纸)。在你真正在计算机上运行程序之前,确信程序能正常工作是非常重要的。
📝 总结
在本单元中,我们一起学习了底层编程中的三个核心概念:分支、变量和迭代。我们了解了如何使用 Go to 指令和标签实现分支逻辑,如何通过分配内存地址来创建和使用变量,以及如何通过循环和累加实现迭代操作。我们还探讨了使用符号引用和伪代码来编写更清晰、可维护且可重定位的代码的最佳实践。

在下一个单元中,我们将讨论指针和输入/输出,这将是我们 Hack 底层编程部分的结尾。
011:底层编程第三部分 🖥️

在本单元中,我们将完成对Hack汇编语言编程的概览。上一节我们介绍了寄存器操作、分支、变量和迭代。本节中,我们将探讨如何处理指针,以及如何编写与输入/输出设备交互的程序。
指针处理
让我们从一个指针发挥作用的例子开始。考虑以下这段许多人在高级语言中写过的简单代码:
for (int i = 0; i < n; i++) {
arr[i] = -1;
}
这段代码将数组 arr 的前 n 个元素设置为 -1。在高级语言中这很简单,但在机器语言层面,我们需要更努力地实现这个功能。
首先需要认识到,数组的概念在翻译到机器语言时会“丢失”。对于机器语言而言,数组只是内存中的一个段,我们知道这个段的基地址和数组长度。
假设数组从地址 100 开始,我们想操作其前 10 个元素。在数据内存中,它看起来是这样的:
arr: 存储数组的基地址(例如100)。n: 存储要操作的元素数量(例如10)。- 目标是将内存地址
100到109的内容设置为-1。
以下是实现此功能的Hack汇编程序:
// 初始化变量
@100
D=A
@arr
M=D // arr = 100 (数组基地址)
@10
D=A
@n
M=D // n = 10
@i
M=0 // i = 0
(LOOP)
// 检查循环终止条件:if i == n goto END
@i
D=M
@n
D=D-M
@END
D;JEQ
// 核心操作:arr[i] = -1
@arr
D=M // D = arr (基地址)
@i
A=D+M // A = arr + i (计算目标地址)
M=-1 // RAM[A] = -1
// 递增索引 i,准备下一次迭代
@i
M=M+1
// 跳回循环开始
@LOOP
0;JMP
(END)
@END
0;JMP // 无限循环,程序结束
程序的核心在于 @arr 和 @i 这两行。我们首先将基地址 arr 加载到 D 寄存器,然后加上当前索引 i 的值,并将结果(目标内存地址)存入 A 寄存器。随后,M=-1 指令就会将 -1 写入 A 寄存器所指向的内存位置。
像 arr 和 i 这样存储内存地址的变量,在任何语言中都被称为指针。在Hack中,使用指针访问内存的典型逻辑是:通过一些指令(如 A = 某个值)将地址寄存器设置为某个内存寄存器的值,通常还会进行指针运算来计算要操作的目标地址。
输入/输出处理
接下来,我们看看指针操作如何应用于编写与屏幕、键盘等外围设备交互的代码。
首先回顾一下,Hack计算机有两个标准的I/O设备:一个显示单元和一个键盘。它们分别映射到RAM中的两个特定区域:
- 屏幕内存映射: 一块8K的内存区域,对应物理屏幕上约13,000个像素。
- 键盘内存映射: 一个单独的内存地址,对应键盘的输入。
我们无需记住具体的数字地址,因为汇编器提供了预定义的标签(如 SCREEN 和 KEYBOARD)来表示这些区域的基地址。
屏幕操作示例:绘制矩形
一个经典的图形编程“Hello World”是在屏幕左上角绘制一个实心矩形。假设矩形的宽度固定为16像素,高度由用户存储在 RAM[0] 中的值决定(例如 50)。
程序的目标是:根据 RAM[0] 的值,在屏幕顶部绘制相应行数的16像素宽的黑条。
在深入代码之前,理解其工作原理至关重要:我们无法直接操纵物理屏幕,但可以通过写入屏幕内存映射来间接控制它。内存映射与物理像素之间存在固定对应关系。
以下是绘制矩形的伪代码:
n = RAM[0] // 用户指定的矩形高度(行数)
addr = SCREEN // 屏幕内存映射的基地址
i = 0
while (i < n):
RAM[addr] = -1 // -1的二进制是16个1,表示16个黑色像素
addr = addr + 32 // 移动到下一行的起始地址(每行32个字)
i = i + 1
为什么地址增量是32?因为屏幕内存映射使用连续的32个字(每个字16位)来表示物理屏幕的一整行(512像素)。要绘制下一行的前16个像素,就需要跳过这32个字。
将上述伪代码翻译成Hack汇编语言,其核心部分与之前处理指针的示例非常相似:计算目标地址(SCREEN + i*32),将其加载到 A 寄存器,然后向该内存位置写入 -1。通过循环执行此操作,即可在屏幕上绘制出矩形。
键盘操作
与键盘交互则相对直接。键盘的状态被映射到内存中的一个特定地址(RAM[24576],或使用标签 KEYBOARD)。
要检测用户当前按下了哪个键,只需读取该内存地址的内容:
@KEYBOARD
D=M // D寄存器中现在包含了当前按键的扫描码
如果 D 的值为 0,表示键盘空闲,没有按键被按下。否则,D 中的值就是当前被按下键的扫描码。
需要注意的是,在底层进行编程时,我们通常只能获取当前瞬间的按键状态。要处理更复杂的输入(如输入字符串、等待回车键等),逻辑会变得繁琐,这正是在高级语言中处理此类任务更为方便的原因,编译器会帮我们处理这些底层细节。
关于编译的思考
通过本单元及之前的示例,我们已经非常接近“编译”的核心概念。我们展示了如何将高级语言代码(如 arr[i] = -1 或绘制矩形的循环)翻译成底层的机器语言。
编译器正是完成这项转换工作的工具。它允许程序员在高级语言的抽象层面进行思考和工作,而无需操心所有底层实现的细节。然而,正如我们所看到的,要在底层实现这些功能,必须在低级语言的约束下进行大量细致的工作。
在本课程的第二部分(Nand2Tetris项目第二部分),我们将亲手编写这样一个编译器。它将能够把用高级面向对象语言(类似于你日常使用的编程语言)编写的程序,翻译成Hack汇编语言等低级语言。在这个过程中,我们还会用到虚拟机和宿主操作系统的服务。如果你想深入了解这些有趣的抽象层次,欢迎继续学习本课程的第二部分。
总结与感悟
本节课中,我们一起学习了Hack汇编语言编程的最后两个核心概念:指针和输入/输出处理。
- 指针: 我们学习了如何通过内存地址(指针)来间接访问和操作数据,特别是如何通过基地址加偏移量的方式访问数组元素。
- I/O编程: 我们了解了Hack计算机的屏幕和键盘如何通过内存映射与程序交互,并编写了绘制矩形和读取按键的示例代码。
至此,我们已经完成了对底层编程核心要素的概览:寄存器操作、分支、变量、迭代、指针以及I/O。
最后,我想分享一点感悟。底层编程直接与机器对话,看似非常“低级”且简约。Hack语言只有两种基本指令,却能表达任何你能想到的程序逻辑——任何高级语言程序都能被翻译成功能等价的Hack程序。这种在极度简约中蕴含的无限表达能力,是深刻、精妙且充满智力挑战的。
这让我想起一句话:“简单的人为复杂的事物所震撼,而深刻的人为简单的事物所震撼。” 当简单的事物拥有非凡的表达力时,它便显得尤为震撼。Hack语言正是这样一个令人印象深刻的例子。

我们的Hack编程概览到此结束。在下一个单元,你将亲自上阵编写程序,我们也会提供一些让这个过程更轻松的建议。
012:项目4概述 🖥️

在本节课中,我们将学习项目4的具体内容。项目4要求我们编写两个低级别的程序,直接操作Hack计算机的硬件。我们将通过这两个程序,为下周构建完整的Hack计算机硬件平台做好准备。
上一节我们介绍了Hack汇编语言和硬件平台的基本概念,本节中我们来看看项目4的具体任务。
项目4的第一个程序:乘法(Mult)
第一个程序名为 Mult。其目标是计算内存中 R0 和 R1 两个寄存器的乘积,并将结果存入 R2。
以下是该程序在CPU模拟器中执行结束时的截图示例。可以看到,RAM[0] 的值为6,RAM[1] 的值为7,而 RAM[2] 的结果为42,这正是预期的乘积。

程序执行流程如下:用户将程序加载到指令内存后,还需在 RAM[0] 和 RAM[1] 中存入两个数字。点击运行按钮后,程序便会开始计算这两个数的乘积。
在模拟器屏幕的右侧,我们提供了一个测试脚本。该脚本会用我们预设的多组数字对来测试你的程序。你可以查看这个脚本,了解我们将对你的程序进行哪些测试。需要说明的是,模拟器中的屏幕是一个多功能设备,有时作为物理屏幕显示,有时则用于展示测试脚本等信息。
那么,如何编写这样的程序呢?关键在于,Hack机器语言本身没有乘法指令,只有加法和减法指令。因此,我们必须使用加法和减法来实现乘法运算。
实现提示是:你需要使用一个循环来逐步累加,从而计算出结果。顺便一提,在本课程的后续部分(Nand2Tetris第二部分),你将有机会开发一个操作系统,其中会包含一个数学库,提供包括高效乘法在内的各种数学运算。但在当前项目中,我们只要求你实现一个能完成乘法功能的程序,不要求使用特别高效的算法。
项目4的第二个程序:填充屏幕(Fill)
第二个程序是一个简单的交互式程序,其功能如下:
- 程序持续监听键盘输入。
- 只要用户没有按下任何键,屏幕保持原状。
- 一旦用户按下键盘上的任意键,程序会将整个屏幕涂黑。
- 当用户松开按键时,屏幕会恢复为空白(全白)。
- 这个过程可以无限重复:按下变黑,松开变白。
这个程序实际上包含两个挑战:一是探测键盘状态(是否有按键被按下),二是控制屏幕的填充(全黑或全白)。填充操作的本质是向屏幕内存映射区域写入特定的值。
这个练习的简化之处在于,我们操作的是整个屏幕内存映射,而不是单个像素。我们采用一种“散弹枪”式的方法,要么打开所有像素(变黑),要么关闭所有像素(变白)。因此,从整体上看,这个程序并不算非常复杂,但它很有趣,能让你了解如何使用标准的Hack机器语言代码来控制外围设备。
以下是通用的实现策略:
- 监听键盘:持续检查键盘内存映射地址的值。
- 填充屏幕:根据键盘状态,编写代码将整个屏幕内存映射区域填充为黑色或白色像素。
- 使用循环和指针:为了填充整个屏幕区域,我们需要寻址内存映射中的每一个寄存器。这可以通过一个使用指针的循环来实现,其方式与之前单元中讨论的指针操作非常相似。你已经掌握了实现这个程序所需的所有工具。
在CPU模拟器中测试此程序时,请确保选择了“无动画”选项。我们无法为这个交互式程序提供一个自动测试脚本,因此你需要手动测试:按下和松开键盘按键,观察屏幕是否相应地变黑或变白。你也可以发挥创意,尝试不同的填充方式,例如按列填充或创造出某种图案,但这会更具挑战性。
程序开发流程与最佳实践
现在,我们来了解一下使用Hack机器语言开发程序的通用流程和最佳实践。
开发周期
开发Hack机器语言程序的过程非常简单:
- 编写:使用任何文本编辑器编写程序,并将其保存为扩展名为
.asm的文件(通常以大写字母开头命名,如Mult.asm)。 - 翻译与加载:将
.asm文件加载到CPU模拟器中。模拟器会自动将其翻译成二进制代码。 - 运行与调试:在模拟器中运行程序,检查结果。
- 如果满意,即可提交。
- 如果不满意(在最初的调试迭代中很可能如此),则需要查找并修复错误。
- 迭代:回到文本编辑器修改程序,保存后重新加载到模拟器中,再次测试。建议同时打开文本编辑器和CPU模拟器两个窗口,以便快速切换。但请记住,每次修改后都必须重新加载程序。
关于错误诊断:CPU模拟器的错误诊断功能比较基础。如果程序存在语法错误,模拟器会拒绝加载并给出一个包含错误行号的提示信息。修复所有语法错误后,更困难的运行时错误才会出现,这通常需要通过观察程序执行过程来发现和修复。
编程建议与技巧
编写机器语言程序与编写高级语言程序遵循相同的原则。我们期望你的程序简短、高效、优雅且具有自描述性。
以下是一些非常重要的技术建议,能让你的编程工作更轻松:
1. 使用符号变量和标签
这是编写清晰程序的关键。避免在代码中直接使用数字地址,而应使用有意义的标签(用于跳转)和变量名(用于存储数据)。
2. 使用合理的命名
为标签和变量起一个易于理解的名字,例如 LOOP、END、i、sum、count,而不是像 G531% 这样晦涩难懂的名称。
3. 遵循命名约定(建议)
虽然汇编器不强制要求,但遵循以下约定能帮助你更好地区分变量和标签:
- 变量名使用小写字母(如
i,x,sum) - 标签名使用大写字母(如
LOOP,END,STOP)
这样,当你看到@sum时,如果sum是小写,你就知道它是一个变量;如果是大写,则代表跳转到名为SUM的标签。
4. 使用注释和缩进
像高级语言一样为你的代码添加注释,解释高级别的操作意图。同时,使用缩进来保持代码结构清晰美观。可以参考课程之前提供的示例代码风格。
5. 先编写伪代码
编写机器级代码总是具有挑战性的。一个有效的方法是先使用高级伪代码或你自创的类似高级语言的语法来设计程序逻辑。在纸上验证伪代码逻辑正确后,再将其翻译成机器语言,这样可以大大简化后续的调试过程。
所需资源
所有项目所需的文件都可以在Nand2Tetris官网的项目4页面找到。如果你在课程开始时已经下载了软件套件,那么在你的个人电脑上,projects/04 目录中已经包含了项目4所需的所有文件,无需额外下载。

本节课中我们一起学习了项目4的两个编程任务:实现乘法运算和创建交互式屏幕填充程序。我们还探讨了Hack机器语言的开发流程,并介绍了一系列编写清晰、高效代码的最佳实践。这些知识和技能将为我们下周构建完整的Hack计算机硬件平台打下坚实的基础。
013:前路展望 🚀

在本单元中,我们将回顾第一部分的成果,并展望第二部分的学习路线图。我们将探讨高级编程语言如何通过层层抽象,最终在底层硬件上运行,并明确学习第二部分所需的基础知识。
课程回顾与展望
如果一切按计划进行,那么你已经完成了机器语言入门。我们现在准备继续前进,讨论接下来的内容。
这是我们第一部分构建的计算机,现在开启第二部分的学习。
就像许多其他经典的计算机科学课程一样,我们将从熟悉的“Hello World”程序开始。这个程序用一种名为Jack的语言编写,这是一种基于对象的高级语言,我们将在本课程的第二部分中使用。
我们编写这个程序,以某种方式将其放入计算机,点击某个按钮,然后屏幕上就显示出“Hello World”。在大多数课程中,学生此时会继续开发一些更有趣的程序。但在本课程中,我们在此停下,并问自己一系列关于“表面之下”发生了什么的有趣问题。
深入思考程序执行
首先,看看程序执行这个概念,它简直像魔法一样。毕竟,程序只是我写在纸上或用文本编辑器写下的一堆静态字符。突然间,我可以将这个程序转化为一系列低级指令,使计算机真正执行某些操作。这非常迷人,但我们却习以为常。
计算机如何知道如何在屏幕上显示“Hello World”这些文字或图像?它如何知道显示一个“H”?更进一步,它如何知道在屏幕上显示一个像素?这些都是我们通常不会费心去思考的非常有趣的问题。
再看看像 class、function、do、while 这样的关键字。当我们写下这些词时,我们确切地知道想让计算机做什么,但计算机如何理解如何处理它们?它如何解析我们的代码并理解这些词的位置?即使它理解找到了 while 并且 while 应该表示一个循环,计算机又如何知道如何执行这个循环?
再看看像 print、screen 和 printLine 这样的命令。看起来这些命令调用了存在于其他类中的方法或函数,对吧?一个名为 Output 的类,它可能不在这里,而是某个类库或操作系统的一部分。那么,计算机如何知道如何将控制权转移给这些方法?然后在它们完成任务后,又如何将控制权返回给我们的程序?
所有这些问题以及数百个类似的问题都极其有趣和重要,然而高级程序员却不会停下来思考它们。为什么他们可以处于这种“无知的幸福”状态?答案是,高级程序员将他们的高级语言视为一种抽象。就他们而言,他们编写的程序也是抽象。
能够向系统的用户隐藏低级技术细节,这是一个极其重要的原则,不仅在计算机科学中,在任何大规模的科学或技术项目中都是如此。因为高级程序员有足够多自己的问题需要解决。应用程序编程本身就是一个足够有趣且充满挑战的工作领域,如果我们期望高级程序员还要一直思考如何在屏幕上渲染像素、解析程序等等,他们会发疯的,无法专注于自己的工作。因此,他们不必考虑底层细节实际上是一种优点。
然而,必须有人去思考这些问题。至少,如果你有求知欲,你应该问自己:是什么让这些抽象得以运作?答案是,让它们运作的是诸如汇编器、虚拟机、编译器和操作系统这样的东西。弥合这个鸿沟,理解这些东西如何工作,正是本课程第二部分的核心内容。
学习底层知识的意义
有些人可能会说,既然为了成为一名应用程序员,我不必担心这些事情,那我为什么还要担心呢?答案是,确实,你可以在不理解编译器和操作系统内部原理的情况下,编写出非常优秀的高级程序。然而,如果你这样做,你将停留在某种有限且受限制的能力水平上。
但是,如果你花时间去深入了解这些奇妙的软件层是如何工作的,你将成为一个能力更强、更成熟的软件开发人员。😊 不仅如此,从智力上讲,你也会成为一个更丰富的人。因为要开发虚拟机、编译器和操作系统,你必须学习如何使用一些极其优美的算法、数据结构、编程技术以及各种能极大提升你能力的东西,这不一定是为了理解低级细节,而是作为一个软件开发人员、思考者,当然也作为一个应用计算机科学家。
课程路线图概览
好的,考虑到这一点,以下是我们旅程的主要站点。😊
最后一个站点是高级语言。我想用本单元来讨论我们旅程的终点,即在结束时,我们将能够用Jack语言编写程序,它在许多方面与Java非常相似。我想给你一个例子,看看这些程序会是什么样子。
我随意选择了一个在二维空间中处理点的小问题。我们在这里看到的是点P1和P2。点P3是P1和P2的向量加法,看起来我们对P1和P3之间的距离感兴趣。我们如何使用Jack进行这样的操作呢?
事实证明,已经有人编写了一个名为 Point 的类,它包含了各种服务,对于执行此类2D操作非常有用。这个人不仅给了我这个类,还给了我这个类的API,这样我就可以使用这个API编写一个小程序,执行你在图片上看到的内容。以下是这个程序。
我打开文本编辑器,写下这些代码行:我定义了一个新类,称之为 Main;定义了一个新函数,也叫做 main;然后我声明了三个名为 p1、p2、p3 的对象变量,这些变量的类型是 Point。接着,我继续构造 p1 和 p2。我知道怎么做,因为我有API,所以我阅读构造函数的签名,并基于此初始化和构造这两个点,将它们赋值给对象变量 p1 和 p2。然后我必须将 p1 和 p2 相加,我查看API,有一个 plus 方法,所以我根据其签名使用它,构造了一个新点 p3。
然后为了调试目的,我在屏幕上打印这个点。如果这个程序现在运行(虽然它还不能),我希望能在屏幕上看到 (4,6)。接着我打印一行,并计算 p1 和 p3 之间的笛卡尔距离,然后打印它。我在同一条语句中完成。再次,如果这被执行,我希望能看到数字 5,然后我返回。
好的,我使用 Point 类作为一个抽象,并做了一些恰好符合我期望的操作。我认为这个小例子向你展示了基于对象编程的巨大力量,因为用大约10行代码,我就完成了一些非常有用的工作。当然,前提是有人费心为我开发了 Point 类。
探究底层实现
因此,接下来我想深入研究一下 Point 类。让我们开始吧。
当我们打开 Point 的实现时,首先看到的是类级别的声明。这个类首先声明每个 Point 类型的对象由两个数字 x 和 y 来表征,它们是这个点的坐标。然后有一个类级别的静态变量叫做 pointCount,用于存储到目前为止设计或构造了多少个 Point 对象。
接下来是构造函数。签名与我们在API中看到的一样,这是构造函数的主体。这里没有什么神秘的。如果你熟悉Java或Python,它看起来非常相似。唯一的区别是,构造函数以一个显式的 return 语句结束,这在Java和Python等语言中是隐式的。在这个显式语句中,它返回刚刚创建的对象的地址,这个地址直接进入调用代码中的 p1 和 p2,所以一切按预期工作。
让我们继续探究这个类。这是我们之前看到的,有一些访问器我没有在API中列出。这是 plus 方法的定义,如果你愿意,可以稍后查看代数运算,并确信它确实执行了向量加法。
我们还有这个笛卡尔距离方法,它使用了勾股定理。然后我们有 print 方法,就是这样。
这里我想提出一个重要观点,所以请看着我并注意代码。重点是,没有必要过分深入 Point 类,因为本单元的目的不是教你Jack,也不是教你基于对象的编程,目的只是让你感受一下Jack中基于对象编程是什么样子,并让你感觉它与你可能在其他编程语言、其他计算机科学入门课程中所做的非常相似。所以,对于那些有编程经验的人来说,Jack是一种相当简单、朴实的语言。
从高级代码到可执行代码
好的,回顾一下,我们这里有两个类:Main.jack 与 Point.jack 交互以执行一些操作。提醒一下,我们现在位于旅程的最后一个站点。接下来我想探讨的问题是:我们如何将这些高级代码一路向下转化为可以在我们裸机硬件上实际运行的可执行代码?
我们是这样做的。我们取这两个类,Main 和 Point,把它们放在我们计算机上的某个目录里,可以任意命名。我决定称之为 Points(复数)。然后我们将Jack编译器应用于整个目录。结果,我们将得到两个新文件,它们具有相同的前缀名、相同的类名,但扩展名是 .vm 而不是 .jack。这两个文件将与我们的源代码位于同一目录。顺便说一下,如果你习惯用Java,这正是你用Java所做的,因为你从两个扩展名为 .java 的Java类开始,应用Java编译器后,你会得到用字节码编写的两个类,扩展名将是 .class 而不是 .vm,这只是一个小小的语法细节。
然后,你可能不知道,但接下来你将应用一个叫做VM翻译器的东西。在Java中,这是通过 java 命令本身完成的。在我们的世界里,VM翻译器将把VM代码翻译成一个包含许多Hack汇编指令的文件。然后我们可以取这个 .asm 文件,如你所见,把它交给汇编器。汇编器最终会从中产生另一个扩展名为 .hack 的文件。然后我们可以把这个文件放入我们在第一部分构建的计算机中,运行它,瞧!我们将在面前的屏幕上看到我们程序的输出闪烁。
这就是将一个高级程序通过层层处理,直到变成可以实际执行的东西的过程。在本课程第二部分中,我们不仅会多次做这些事情,实际上还会自己构建所有这些工具。
课程计划与预备知识
好的,再次重申,这是课程计划。我们从模块0开始,在这个模块中,我们要求你熟悉Hack汇编语言,这非常重要。一旦完成,我们将进入接下来的两个模块,在那里我们将构建一个虚拟机和VM翻译器。
然后,我们将跳到旅程的终点,要求你用Jack编写一些非平凡的程序。显然,我们会教你很多关于如何使用Jack语言的知识,但不会太多,因为Jack是自解释的。然后你将使用这种语言来开发一些交互式游戏或其他简单程序。
一旦你理解了语言本身,熟悉了语言本身,我们将为Jack语言编写一个编译器。我们将使用像Java或Python这样的语言来编写这个编译器,一种你感觉舒适的语言。最后,在课程的最后一个模块,我们将开发操作系统,为Jack提供各种重要的扩展,并弥合高级编程与裸机硬件之间的各种鸿沟。
在我们开始第二部分的旅程之前,我想说一些免责声明,并和你一起做一个小小的自我检查。
首先,我想说明,第二部分比第一部分要求高得多。我认为它和第一部分一样有趣,但你必须编写许多程序,做很多项目,所以你必须做好适当的心理和时间承诺,来学习这门严谨且富有挑战性的课程,尽管它会很有趣,我认为你会非常享受,但仍然会有很多工作。
在本单元中,我向你展示了几个使用Jack语言进行高级编程的例子。我想问你以下问题,并且请你真诚地在内心深处回答:看到这些编程示例,你感到舒适吗?你对诸如对象、构造函数、引用变量以及在Java或C#等语言中应用 new 操作感到舒适吗?
如果答案是肯定的,如果你理解类、方法和构造函数等是什么,那么你已经准备好学习这门课程。如果答案是否定的,那么我恳请你不要学习这门课程,而是去学习一些计算机科学入门课程,使用像Java或Python这样体面的基于对象的语言,学习高级编程的方方面面,然后再回来完成本课程的第二部分。当然,你可以享受讲座,至少我希望是其中一些,而不做任何项目,但这将错过本课程的目的。所以再次强调,如果你感觉舒适,很好;如果不,你需要一些准备。
我的下一个问题是:在本单元中,我们也看到了不少低级编程的例子。所以我的问题是:你对于使用Hack汇编语言进行低级编程感到舒适吗?你理解 A=M 是什么吗?你知道 A 是什么,M 是什么,这个操作的含义是什么吗?如果答案是否定的,那么我要求你回到机器语言入门,根据我的指导学习你需要的内容,并完成项目4。完成这个项目,提交它,获得评分,然后回来继续学习第二部分。
如果对这两个问题的回答都是响亮的“是”,那么欢迎加入,你已经准备好学习第二部分,我们可以继续前进,真正开始课程。在下一个单元,我们将讨论一些编译方面的内容,然后开始实际构建我们的虚拟机。
总结

在本单元中,我们一起回顾了课程第一部分的成果,并展望了第二部分的学习蓝图。我们探讨了高级编程语言背后的抽象层,理解了从Jack高级语言代码到最终在Hack硬件上运行所需的完整工具链,包括编译器、虚拟机、VM翻译器、汇编器等。最后,我们明确了学习第二部分所需的高级编程(如面向对象)和低级编程(Hack汇编)基础,为接下来的深入学习做好了准备。🚀
014:程序编译预览 🚀

在本节课中,我们将预览整个高级语言编译过程,并理解“虚拟机”这一核心概念如何将复杂的编译任务分解为两个更简单的步骤。
概述
我们已经掌握了硬件知识,并学会了用Hack汇编语言编写程序。现在,我们将开始编写编译器的旅程。这是一个庞大的任务,因此我们将从路线图的最右侧开始,首先构建一个虚拟机翻译器。但在深入细节之前,让我们先预览一下旅程的终点:将一个高级语言程序编译并运行在计算机上的完整过程。
编译挑战与解决方案
上一单元中,我们看到了一个操作二维空间点的程序。这段代码在计算机上运行时,屏幕上会显示出通过向量代数和勾股定理等计算得出的动态点。这令人惊叹:我们如何从左侧的高级语言程序,最终得到能在计算机上运行的东西?
首先,你需要一台计算机。如果你完成了《从零开始构建现代计算机》第一部分,你已经构建了这样一台计算机。但即使有了计算机,你仍然需要将高级语言一路翻译成该计算机的机器语言。这正是第二部分课程的核心内容。
那么,我们如何做到这一点?答案是需要一个编译器。我们需要将(例如)Jack语言翻译成Hack机器语言,或任何你想到的其他机器语言。因此,我们需要编写这个编译器。
多平台兼容性问题
这里存在一个主要问题:世界由许多不同的计算机组成。你希望你的高级程序能在许多不同的平台上运行,例如笔记本电脑、台式机、平板电脑、智能手机、数字手表等。问题是,这些设备使用不同的处理器,而不同的处理器拥有不同的机器语言。因此,仅仅编写一个编译器是不够的,你必须为每个不同的处理器开发一个编译器。对于软件开发者而言,这意味着需要将程序翻译到多个目标平台,并维护代码的多个版本,这是一个巨大的痛点。
两阶段编译的解决方案
很高兴地告诉你,存在一种替代方案,其口号是“一次编写,到处运行”,而不是“一次编写,到处调试”。这种方法的最佳范例可能是Java。
Java并不直接一路编译到机器语言。相反,它采用了一种称为两阶段编译的方法。
- 第一阶段(顶层):Java编译器将Java程序翻译成一种中间代码。在Java世界中,它被称为字节码,但通常我们称之为虚拟机代码。这种虚拟机代码被设计运行在一个称为虚拟机的抽象构件上。虚拟机不是真实的计算机,而是一台想象中的计算机。
- 第二阶段(底层):为了实际执行这些字节码,我们必须将其进一步翻译成机器语言,即在某个真实的冯·诺依曼机器或硬件设备上实现它。为此,我们需要为目标设备配备一个虚拟机实现。在Java世界中,这被称为Java虚拟机实现。JVM实现是一个程序,它接收字节码,并最终将其翻译成目标平台的机器代码。我们需要为每一个想要运行虚拟机程序的平台准备这样一个翻译器。
两阶段编译的优势
高级语言和低级语言之间的翻译鸿沟是巨大的。通过引入一个中间层,我们将这个非常复杂的过程解耦为两个独立的子过程:编译器和虚拟机翻译器(或称虚拟机实现)。其中每一个翻译器的复杂度都远低于之前看到的、从顶层直接编译到底层(例如C++编译器)的单一编译器。
我们将一个非常复杂的任务分解为两个更简单的任务,这总是可取的。事实上,Java不应独占两阶段编译思想的功劳,因为这个想法和实践已有约30年历史。如果你仔细想想,它甚至(信不信由你)有近90年的历史,我将在本模块末尾对此进行说明。
Jack语言的编译路径
Jack是本课程使用的类Java语言。我们将在两个不同的平台上执行Jack程序:一个平台是你自己的个人电脑;另一个平台是我们在《从零开始构建现代计算机》第一部分中构建的Hack硬件。如果你没有学习第一部分,也无需担心,你不需要实际的Hack计算机,因为本课程的软件套件包含一个在你的个人电脑上模拟Hack计算机的模拟器。
那么,我们如何弥合Jack语言与这两个目标平台之间的差距呢?我们将采用与Java非常相似的方法。
我们将编写一个编译器,将Jack程序编译成我们称之为虚拟机代码的中间形式。然后,我们将为你的个人电脑配备一个称为虚拟机模拟器的软件,它知道如何获取虚拟机程序并在你的笔记本电脑或任何你使用的机器上实际执行它。
与此同时,我们还将编写一个实质性的程序——一个虚拟机翻译器,它接收虚拟机代码并将其翻译成Hack平台的机器语言。这实际上将是我们在本模块中要达到的顶峰,但这需要经过几个单元的学习才能达到这个水平。
总结与展望
总而言之,在本模块及下一个模块中,我们将编写虚拟机翻译器。我们将首先理解什么是虚拟机,然后实现它。接着,在第4和第5模块,我们将编写编译器,以完成整个编译过程的图景。
我想提醒你,虚拟机是一个抽象,是一个想象中的构件,它是我们称之为虚拟化的最重要计算机科学理论和实践思想的范例。没有大规模地使用虚拟化概念,你无法进行云计算、通信网络和现代编程语言开发。
我能想到有一个人会很高兴看到我们在这里所做的事情,那就是艾伦·图灵,计算机科学领域最杰出的先驱之一。大约90年前,图灵写了一篇开创性的论文,描述了一台能够抽象执行计算机程序的抽象计算机。他还描述了一种被称为通用机器(现在称为通用图灵机)的东西,它可以模拟任何其他机器的操作。通过思考一台执行另一台机器的机器,或者一个执行和理解另一个程序的程序(即一个将另一个程序视为数据的程序),图灵真正将计算机科学提升到了一个全新的复杂高度。
这种“关于思考的思考”能力,是智能的标志。在计算机科学中,我们可以编写分析、运行、以各种方式管理其他程序的程序,这种对推理进行推理的能力,正是计算机科学成为一个如此复杂和强大领域的原因。
我们没有太多时间深入探讨,因为这是另一门课程的内容。说到这里,我想起了图灵一句非常精彩的话:“我们只能看到前方很短的距离,但我们可以看到那里有许多事情需要去做。”我认为这是本课程一个非常好的口号。
那么,在这个模块中,我们将专注于虚拟机,并从两个独立的视角进行学习:首先,思考这台机器能为我们做什么,这是机器的抽象视图;然后,讨论如何实际构建它、实现它并使其工作。
本模块将介绍许多精彩内容:
- 我们将讨论编译。
- 我们将以非常直观和深入的方式理解虚拟化。
- 我们将大量讨论虚拟机抽象。通过这样做,你将理解Java的JVM和C#的虚拟机等是如何工作的。
- 我们将学习如何使用栈,这是计算机科学理论和实践中非常重要的数据结构。
- 我们将实际构建虚拟机,实现它。在此过程中,我们将使用指针,并进行大量的编程实践。

很多乐趣即将到来,请耐心跟随,让我们进入下一个单元。
015:栈 🧱

在本节中,我们将开始探索虚拟机抽象,并首先讨论抽象虚拟机架构中最重要的元素——栈。
上一节我们介绍了课程的整体框架,本节中我们来看看虚拟机抽象的核心组成部分。
虚拟机设计的平衡艺术
我们正在开发一个两阶段的编译过程,其核心是运行虚拟机代码的虚拟机。假设我们有完全的自由来设计这个位于翻译过程中间的虚拟机语言,我们应该如何设计它?我们会创造出什么样的语言?
我们需要在两个相互冲突的目标之间找到平衡。一方面,我们希望虚拟机语言足够高级,这样高级语言与虚拟机语言之间的“距离”就相对较小,因此翻译的挑战相对可控,生成的虚拟机编译器也会是一个相对简单优雅的程序。另一方面,我们希望虚拟机代码和虚拟机语言足够低级,这样它与最终目标语言(低级语言)之间的“距离”也应该是可控且足够小的,以便虚拟机翻译器或虚拟机实现也能成为一个相对简单优雅的程序。
计算机科学多年的研究和实践表明,一种能在两个目标之间取得良好平衡的架构被称为栈机器。栈机器是一种抽象,它包含一个架构、一个栈以及一组我们可以应用于此架构的操作。接下来,我们将描述这个架构和这组命令。
栈的直观理解
以下是理解栈的一种方式。
我们可以将栈想象成一叠盘子。它当然有一个顶部。我们可以设想一个游戏,其中只允许两种操作。出于历史原因,其中一种操作称为 push。这个操作允许我们将一个盘子放到栈的顶部。pop 操作则允许我们从栈中移除顶部的盘子。当我们执行这些操作时,顶部指针会相应移动以反映我们刚刚所做的操作。顶部指针总是指向栈中最顶部盘子上方的一个位置。
在普通的盘子堆栈中,你可以拿起几个盘子,然后在中间放入另一个盘子。但在这个栈中,这是不允许的。你只能从顶部访问栈。
计算机科学中的栈
在计算机科学中,栈看起来有些不同,但理念完全相同。栈包含一些值(而不是盘子)。这些值,我们可以将它们视为整数,但通常这些整数可以代表任何东西——字符串、布尔值等等。为了简化,我们先将其视为整数。我们有一个称为栈指针的东西,它指向下一个值将被推入的位置。
这个栈存在于一个更大的上下文中,称为虚拟机。虚拟机还配备了一个常规的内存段,它非常类似于我们在课程第一部分构建的 RAM。你可以基本上将其视为一个数组,这个数组允许直接访问内存空间中的每个元素,这与栈只能通过栈指针访问形成对比。
栈的基本操作
那么,我们可以用这个架构做什么呢?
假设我们执行 push X。我们假设可以随意标记 RAM 中的不同位置。如果 X 包含数字 7,那么我们将看到数字 7 被添加到栈的顶部,而内存中没有任何变化。
我们可以做的另一件事是 pop Y,这将影响两个独立的子操作。一个子操作将从栈中移除顶部值(例如 3)。第二个操作将把这个值 3 存储到位置 Y 中。因此,无论位置 Y 之前是什么,都会被覆盖。
这些是我们用来在栈和内存之间移动数据的两个基本操作。
算术与逻辑命令
除此之外,我们还将拥有一整套算术命令。例如,如果我们说 add,将会发生以下事情:我们将从栈中弹出两个最顶部的值,在“一旁”将它们相加,然后将结果推回栈上。因此,结果将是 10。请注意,加法操作数(本例中的 3 和 7)已被加法的结果(10)所取代。
我们可以用另一个名为 neg 的命令做类似的事情,它将取反最顶部的值。它不会简单地取反,而是首先弹出顶部值,在“一旁”取反它,然后将结果推回栈上。
一般来说,如果你想在栈上应用某个函数,你必须做三件不同的事情:首先,你必须从栈中弹出所需数量的操作数;然后,你在一个单独的过程中对这些操作数计算函数;最后,你将结果推回栈上。我说“你”,但所有这些事情都是“自动”发生的,这是抽象的一部分。因此,抽象本身支持所有这些操作。你不必担心它们。你只需要说 add、neg,这些事情就会发生。
以类似的方式,我们也可以在栈上应用各种布尔操作。在这个例子中,如果我们说 eq,我们将隐式地弹出两个顶部值(它们恰好相等)。我们会问:它们相等吗?5 等于 5 是真的吗?答案是“是”,所以我们将 true 推入栈。然后,如果我们愿意,我们可以说 or。我们将弹出两个最顶部的值,栈将变空。在“一旁”对它们进行或运算,得到 true,然后将 true 推回栈上。
命令从何而来?
至此,你可能会问自己:这些命令从何而来?push、pop、eq、neg、or……谁给了我们这些命令?实际上,我没有义务回答这个问题。我可以要求你忘记外部世界,只关注虚拟机抽象本身,它本身就是一个足够有吸引力、优雅且强大的架构,是一个很好的抽象。我们完全可以沉浸其中,而不必担心这个抽象之外存在什么。
这种“无知的幸福”有一定的好处,因为我们可以 100% 地专注于我们现在正在做的事情。一些伟大的思想家正是这样做的,他们能够将自己与任何其他事物隔离开来,只专注于当前的工作。
但是,在本课程中,像你这样聪明的学生选修这门课是因为他们想要理解全局。所以,问“这从何而来?”、“我们将从这里走向何方?”等问题是这门课程的 DNA。因此,我允许自己冒险走出虚拟机的背景,简单谈谈栈算术的大局。
这些命令从何而来?它们来自我们尚未见过的编译器。如果你从一个高级语句开始,比如 x = 17 + 19,我们将在课程后期构建的编译器会将其翻译成:push 17、push 19、add、pop x。执行所有这些操作的结果将把 17 加 19 的值放入 x。
这里有一个有趣的抽象与实现的相互作用。首先,高级语言当然是一个抽象。它并不真实存在,可以通过将其翻译成在栈机器上运行的代码来实现。但栈机器也是一个抽象。而这个抽象是由其他东西实现的。请继续关注,因为理解这个抽象并实现它(我们将在本模块稍后部分进行)是整个模块的要旨。
虚拟机全貌
那么,这就是虚拟机的完整面貌:一个由四类命令操作的栈机器——算术逻辑命令、内存段命令、分支命令和函数命令。接下来,我们将更详细地讨论算术和逻辑命令。
以下是一个例子。我们从这个高级代码开始,编译器会将其翻译成这个虚拟机代码。这个翻译是如何发生的,在课程的现阶段你不必担心,因为我们将在两三个模块后再来讨论它。
现在,我们确实需要关心如何执行这段代码,因为这完全属于虚拟机抽象的范畴。我们从一个空栈开始,并假设我们有一个包含 x 和 y 变量的内存段。然后我们说 push 2,栈变为 2。接着我们 push x,x 的值被推入栈。我们 sub 这两个值,得到结果。然后我们 push y,栈增长。接着我们 push 9,栈继续增长。然后我们将两个最顶部的值 add,得到 21。然后我们再做一次 add,得到 -4 + 21 = 16。最后,我们将结果 pop 到 d,我们得到了一个包含此计算值的新变量 d。
我们在这里看到的是一个典型的栈机器命令序列,通过执行这些命令来获得某个有用的值。
我们也可以用逻辑命令做非常类似的事情。以下是一个例子。在代码窗格的顶部,我们看到一个逻辑表达式或布尔表达式,它具有真值。为了计算这个表达式,编译器将生成你在这里看到的代码。再次从一个空栈和一些关于 X 和 Y 的假设开始,我们可以一个接一个地执行这些命令中的每一个。最终我们将得到你在这里看到的模拟过程。如果你想,也许可以在这里暂停视频,逐步执行并说服自己理解这里从一个转换到另一个发生了什么。所以,请这样做。
命令总结
回顾我们到目前为止所做的,以下是我们的算术逻辑命令:我们有三个算术命令(add、sub、neg),它们总是对栈中最顶部的值进行操作;然后我们有三个逻辑比较命令(eq、gt、lt),它们也对栈中的两个最顶部值进行操作;最后,我们有三个经典的逻辑连接词(or、and、not),它们也对栈的两个最顶部值进行操作。
这看起来可能不多,对吧?看起来是一个非常精简的命令集。但这里有一个有趣的观察:任何你能想到的、用任何编程语言编写的算术和逻辑操作,总是可以简化为使用这些命令并在栈机器上执行的子集操作,而栈机器最终将计算出你在高级语言中想要计算的值。这展示了我们正在构建的这个非常简单的虚拟机所具有的潜在能力。
总结
本节课中我们一起学习了虚拟机抽象的核心——栈。我们探讨了栈机器的设计理念,理解了 push 和 pop 两种基本数据移动操作,并详细介绍了算术与逻辑命令(如 add、sub、eq、and 等)在栈上的执行过程。我们还初步了解了这些虚拟机命令将由未来的编译器从高级代码生成。这个看似精简的命令集,实际上具备了实现任何复杂运算的潜力,为后续构建完整的虚拟机翻译器奠定了基础。

下一节,我们将继续探讨虚拟机的另一类重要命令:内存段命令。
016:内存段 🧠

在本单元中,我们将学习虚拟机语言中的内存段以及相关的内存段命令。我们将了解为什么需要多个内存段,以及如何使用 push 和 pop 命令来操作它们。
概述
到目前为止,我们的虚拟机只与一个单一的内存段(栈)进行交互。然而,高级语言程序中的变量具有不同的角色(例如,静态变量、局部变量、参数等)。为了在虚拟机层面保留这些语义,我们需要引入内存段的概念。本单元将详细介绍内存段的作用、种类以及如何通过 push 和 pop 命令来操作它们。
从高级代码到虚拟机代码
让我们从一个高级语言代码片段开始。假设我们有如下代码:
c = s1 + y;
其中,s1 是一个静态变量,y 是一个参数,c 是一个局部变量。编译器需要将此代码翻译成虚拟机代码。翻译过程会丢失变量名信息,但会保留变量的角色,这是通过映射到不同的内存段来实现的。
上一节我们介绍了算术逻辑命令,本节中我们来看看内存段命令如何帮助我们在虚拟机中保留变量的语义。
内存段的作用
在高级语言中,不同的变量扮演不同的角色。为了在虚拟机中保留这些角色,我们引入了多个命名的内存段,例如 argument、local、static 等。编译器可以将高级语言中的变量映射到这些段上。
例如,对于上面的代码,编译器会生成如下虚拟机命令:
push static 0
push argument 1
add
pop local 2
这里,static 0 对应 s1,argument 1 对应 y,local 2 对应 c。变量名虽然丢失了,但变量的语义角色通过内存段得以保留。
虚拟机内存段命令
我们的虚拟机语言现在扩展为包含多个内存段。所有 push 和 pop 命令现在都需要指定要操作的内存段名称以及该段内的索引。
以下是内存段命令的通用格式:
push segment i:将segment段中索引为i的值压入栈顶。pop segment i:将栈顶的值弹出,并存入segment段中索引为i的位置。
其中,segment 可以是以下八种之一:argument、local、static、constant、this、that、pointer、temp。i 是一个非负整数。
需要注意的是,constant 段是一个特殊的虚拟段,它包含常数 0, 1, 2... 等。你可以从它 push 值,但不能向它 pop 值。
为什么需要八个内存段?
您可能会问,为什么需要这么多内存段?答案源于我们高级的、面向对象的源语言。在这种语言中,有各种语义实体:静态变量、局部变量、参数、当前对象(this)、数组(that)等等。这八个虚拟内存段为我们提供了一种机制,可以在虚拟机层面优雅地处理所有这些高级语义。
好消息是,在虚拟机抽象内部,所有这八个段的行为方式完全一致,都是通过 push segment i 和 pop segment i 命令来操作。
实践练习
为了巩固对内存段操作的理解,让我们来看一个简单的练习。
假设虚拟机当前状态如下:
argument段索引 1 的值为 12。static段索引 2 的值为 99。- 栈中有一些值。
我们的目标是通过虚拟机命令实现操作:static 2 = argument 1。执行后,static 2 的值应变为 12。
以下是实现此操作的步骤:
- 首先,我们需要将
argument 1的值放到栈上。 - 然后,将这个值从栈顶存入
static 2。
因此,所需的虚拟机命令是:
push argument 1
pop static 2
这两条命令通过栈作为中介,完成了从一个内存段到另一个内存段的值传递,并且保持了栈的最终状态不变。
总结
在本单元中,我们一起学习了虚拟机抽象中的内存段。我们了解到,为了保留高级语言变量的语义角色,虚拟机需要支持多个内存段,如 argument、local、static、constant 等。我们学习了如何使用格式为 push segment i 和 pop segment i 的命令来操作这些段,并通过一个练习了解了如何通过栈来在内存段之间传递数据。

至此,我们已经介绍了虚拟机语言中的算术逻辑命令和内存段命令。在下一个单元,我们将开始探讨如何实际实现这些命令,让它们在真实的计算机上运行起来。
017:栈

在本节课中,我们将学习如何将抽象的虚拟机栈概念,在真实的计算机内存中实现。我们将从回顾栈的抽象操作开始,然后学习指针操作的核心概念,最后将 push constant 命令翻译成具体的机器指令。
栈抽象回顾
上一节我们介绍了虚拟机的抽象模型。现在,我们来看看如何实现它。首先,回顾一下栈的抽象操作。我们从一个任意的栈状态开始,目标是执行 static 2 equals argument 1 这样的虚拟机命令,以达到最终的栈状态。
需要明确的是,之前讨论的栈、内存段等都是抽象的,并不真实存在。为了在真实的计算机上执行这些操作,我们必须将它们映射到物理内存(RAM)上。
指针操作速成课
为了实现抽象,我们将使用一种称为“指针操作”的技术。以下是一个简明的介绍。
假设我们有一个典型的RAM。我们决定用符号 P 和 Q 来象征性地指代前两个RAM位置(Ram[0] 和 Ram[1])。
考虑以下伪汇编语句:
D = *P
这里的规则是:*P 表示 P 所指向的内存位置。* 符号表示我们将 P 视为一个指针。
如果没有 *,D = P 会将 Ram[0] 的值(例如257)赋给 D。但有了 *,我们进行的是间接寻址操作:我们查看 Ram[0] 的值(257),将其视为一个地址,然后取出该地址(Ram[257])的值(例如23),最后存入 D 寄存器。因此 D 变为 23。
要在 Hack 汇编语言中实现此操作,代码如下:
@P // 等同于 @0,将地址0(P的地址)加载到A寄存器
A=M // 将 Ram[0] 的值(257)加载到A寄存器,现在A=257,并选中 Ram[257]
D=M // 将当前选中的 Ram[257] 的值(23)存入 D 寄存器
以下是另外两个在后续实现中会频繁使用的操作对:
示例1:指针递减后取值
P-- // P = P - 1,即 Ram[0] 的值减1(例如从257变为256)
D = *P // 取出 P 新指向的地址(Ram[256])的值(例如19)给 D
示例2:指针解引用赋值后递增
*Q = 9 // 将 Q 指向的地址(Ram[1024])的值设为 9
Q++ // Q = Q + 1,即 Ram[1] 的值加1(从1024变为1025)
请注意,以上是高级伪代码。要实际运行,必须用目标计算机(如Hack)的汇编指令集重写每一条指令。
在物理内存中实现栈
现在,我们来看看如何在真实计算机上实现栈抽象。
我们做出两个重要假设:
- 栈指针(SP) 将存储在 Ram[0] 中。
- 栈区 将从地址 256 开始。
假设初始抽象栈包含 [12, 5]。在物理内存中,它们将被映射到从地址256开始的位置:Ram[256]=12,Ram[257]=5。栈指针(SP,即Ram[0])始终指向下一个可用位置,因此其值为 258。
执行 push constant 17 后,抽象栈变为 [12, 5, 17]。在物理内存中,数字 17 被放入 Ram[258](即之前SP指向的位置),然后栈指针递增为 259,指向新的下一个可用位置。
实现 push constant 17 的逻辑可以用两条伪指令描述:
*SP = 17:将值17存入栈指针所指向的地址。SP++:栈指针加一,指向下一个可用位置。
翻译为 Hack 汇编代码
接下来,我们将上述逻辑翻译成具体的 Hack 汇编代码。
以下是 push constant 17 的实现:
// 将常数17存入D寄存器
@17
D=A
// *SP = D (将D的值存入SP指向的地址)
@SP // A寄存器 = 0 (SP的地址)
A=M // A寄存器 = Ram[0] (SP的值,即栈顶地址)
M=D // Ram[A] = D (将17存入栈顶)
// SP++ (栈指针加一)
@SP
M=M+1
这段代码需要仔细理解:
- 前两行(
@17和D=A)中,A寄存器被用作数据寄存器,目的是将常数17移入D寄存器。此时A寄存器选择Ram[17]的副作用被忽略。 - 中间三行(
@SP,A=M,M=D)中,A寄存器被用作经典的地址寄存器。我们通过指针(SP)精心设置A的值,然后精确地将D的值存入A所指向的RAM位置(M)。
重要提示:如果你对这段代码的逻辑感到困惑,请务必回顾之前的指针操作示例,并确保你已完成并理解课程项目0。理解Hack汇编中间接寻址是学习后续内容的基础。
通用命令的实现
上面的例子中数字17并无特殊性。因此,我们得到的是通用命令 push constant i(其中 i 是任意非负整数)的实现模式。
通用命令 push constant i 的语义可描述为:
*SP = iSP++
我们的任务就是编写一个 VM 翻译器 程序(可以用Java、Python等语言编写)。这个程序接收像 push constant i 这样的虚拟机命令流作为输入,并将其翻译(或称为“重新表达”)为实现其语义的多条汇编指令。
翻译器的工作就是从左(VM命令)到右(汇编指令),将高级抽象转化为机器可执行的低级代码。每条VM命令都会生成若干条汇编命令。
本节总结
本节课中,我们一起学习了虚拟机实现的第一步:栈的实现。
- 我们回顾了栈的抽象概念与物理实现的区别。
- 我们学习了指针操作这一核心概念,特别是间接寻址。
- 我们详细分析了如何将
push constant这条虚拟机命令,通过指针操作,映射到物理内存,并最终翻译成具体的 Hack 汇编代码。 - 我们明确了 VM 翻译器 的角色:它是一个将VM命令流转换为机器语言指令的程序。

在接下来的单元中,我们将讨论如何实现之前介绍过的所有其他抽象命令。
018:内存段

在本单元中,我们将继续探讨虚拟机的实现,重点关注内存段的实现方式。
概述
上一单元我们开始讨论虚拟机的实现,并介绍了栈架构的实现。本节中,我们将深入探讨如何实现虚拟机规范中定义的各个内存段。
内存段的统一抽象
首先需要明确,所有内存段的访问方式都是统一的。我们使用完全相同的语法来寻址任何段中的任何位置:push 或 pop,后跟段名和一个非负整数索引。例如:push constant 17、pop local 2 等。
那么,如何在宿主计算机上实现这些内存段呢?我们将循序渐进,从 local 段开始。
实现 Local 段
local 段使用我们一直使用的统一语法访问:push/pop segment_name index。以下是一个简单的栈机示例,展示了这两个数据结构。
在宿主 RAM 上,栈的实现方式与之前单元相同:我们约定栈从地址 256 开始,并使用一个称为 SP 的指针来记录栈中下一个可用位置的地址。我们决定将 SP 映射到宿主内存的 RAM[0]。
至于 local 段,原则上可以放置在 RAM 的任何位置。我们不关心 local 段存储在哪里,只要记住这个内存块的基地址即可。如图所示,我们决定将 local 段的基地址存储在一个名为 LCL 的指针中,并且 LCL 将引用 RAM[1]。
在示例中,local 段恰好始于一个任意的地址(例如 1015),LCL 包含相同的地址。请注意,我们使用了两个指针,但方式截然不同:栈指针指向栈中下一个可用位置,而 LCL 指向 local 段的基地址。
以下是使用此实现来处理 pop 命令的方式。
假设执行 pop local 2 命令,SP 已更新以反映弹出操作。原本位于栈顶的值 5,现在被存储到 local[2] 中,其地址是 1017。
如何知道 local[2] 的地址是 1017?很简单:基地址是 1015(此信息始终存储在 LCL 中),加上偏移量或索引 2,通过这两个值的相加,就得到了存储弹出值的目标地址。
这样,我们就可以概括这个翻译过程:取基地址,加上偏移量。
另一个值得注意的点是地址 257。它包含值 5,但这个值已“失效”。因为栈指针现在指向 256,所以下一次 push 操作很可能会覆盖这个 5。这在栈管理中很典型:栈中可能存在各种“垃圾”数据,但指针精确地指示了栈的哪些部分正在使用,哪些部分可以被回收。
那么,如何翻译 pop 操作呢?我们可以使用以下伪汇编代码来实现。
首先,计算目标地址(本例中为 LCL + 2),并将其存储到某个临时变量(如果需要)。
然后,递减栈指针。
接着,取出栈指针当前指向的值,并将其放入 ADDR 所指向的地址。
这就是我们想要实现的语义。如何在汇编中实现它?这是你需要完成的任务。你需要使用 Hack 语言、A 命令、C 命令等来翻译此类指令。一旦你解决了这个小难题,你就知道如何始终实现 pop local i 命令。
实际上,数字 2 并没有什么特殊之处。我可以用 i 替换 2,那么一切都会像之前一样工作。我不再执行 LCL + 2,而是执行 LCL + i。这样我就完全概括了这个翻译过程,知道如何处理 pop local i。
以下是 pop local i 命令的通用翻译。右侧显示的正是上一张幻灯片中的内容。push local i 命令的实现方式非常相似,基本对称:像之前一样计算目标地址,然后使用两条伪指令执行推送操作。同样,为了实际实现,你的 VM 翻译器必须生成使用 Hack 语言实现此伪代码的汇编指令。
扩展实现:四个关键段
现在,我想扩展这个图景,一起讨论 local、argument、this 和 that 这四个段。扩展视角并记住这些段在更宏大的项目中的来源和目的总是有帮助的。
考虑一个在运行时运行的典型方法(例如 Java 中的方法)。这个方法通常有局部变量、参数变量,通常会处理某个当前对象(它也是一组所谓的成员变量),并且该方法也可能处理某个具有数据值条目的数组。
当我们将此逻辑转换到虚拟机世界时,必须保留所有这些语义。我们通过 local、argument 内存段以及另外两个称为 this 和 that 的段来实现这一点。this 段存储当前对象的成员变量或字段值,that 段存储当前方法可能处理的数组的值。基本上,我们在 VM 级别使用这四个段来捕获这种语义。
那么,如何实现这四个内存段呢?首先,请记住在抽象层面上,我们使用它们的方式完全相同:执行 push 和 pop,后跟段名和索引。仅此而已。它们以统一的方式被访问。我很高兴地告诉大家,我们也以完全相同的方式实现它们。
除了始终可用的栈指针外,我们现在还将使用四个指针,分别称为 LCL、ARG、THIS 和 THAT。这些指针中的每一个都将完全执行上一张幻灯片中 LCL 指针所做的操作:它们的实现方式完全相同,每个都保存其相关内存段的基地址。
因此,push 和 pop 命令的实现也将完全相同。我们将使用与 local 段相同的语义,只是不说 local,而说段指针名称,其中段指针对应于我们在 VM 代码中正在处理的内存段。
现在,你可能会问自己一个好问题:等等,这些段到底会位于 RAM 的什么地方?答案是:我们不在乎。因为它们会被放在某个地方,我们所要做的就是记住这些动态分配的基地址。在课程后面,我们会看到决定将这些段放在内存的何处是一个非常有趣的问题,我们将使用各种巧妙的算法来解决这个问题,这些算法将成为操作系统和编译器的一部分,共同决定如何将这些段映射到整个 RAM 上。
实现 Constant 段
接下来要讨论的段称为 constant。常量的背景是:当编译器在你的高级源程序中遇到常量时,它会通过生成使用常量内存段的 VM 命令来处理这些常量。因此,它将通过诸如 push constant something 或 pop constant something 等栈操作来重新表达。
如何实现这些操作呢?以下是通用的 push constant i 命令。没有 pop constant 命令,因为将某些内容存储到这个常量中没有任何意义。这个常量的实现是微不足道的,因为它是一个真正虚拟的段,并不真实存在。因此,每当我们看到 push constant i 命令时,我们只需生成提供指定常量的低级代码:执行 *SP = i,然后递增栈指针。
这就是对常量段的处理。
实现 Static 段
接下来要处理的是 static 段。这个段的背景是什么?它从何而来?处理你高级代码的编译器可能会遇到一些静态变量,这些是所谓的类级别变量,在你的类中每个方法都可以访问。显然,我们必须以不同于处理局部变量或参数变量的方式来对待这些变量。因此,我们决定在虚拟机抽象中使用一个称为 static 的专用段来表示源代码中的静态变量。因此,源代码中对静态变量的每个操作都将在 VM 级别被重新表达为 push static i 或 pop static i 操作。
以下是我们实现静态变量的方式。我们从一个使用静态变量的 VM 代码示例开始,特别是在 pop 指令的上下文中。
请记住静态变量的特点。与局部变量和参数变量(对每个 VM 函数是唯一的)不同,静态变量处于更高的作用域级别。因此,你程序中的所有 VM 函数都应该看到相同的静态变量。由于有这个功能要求,将静态变量存储在某个全局空间中是有意义的,实际上也是必需的,这样所有从这些 VM 函数生成的代码都将看到相同的静态变量。
因此,我们决定采用的方法是:让 VM 翻译器将某个 VM 文件中对 static i 的每个引用,翻译成汇编引用 f.i。这个标签的前缀是文件名,i 是取自 VM 命令的索引(例如 static 5 对应 i=5,static 2 对应 i=2)。如果我们遵循这个约定(稍后会详细解释),那么 VM 翻译器将把 pop static 5 翻译成:首先有一些执行出栈操作的代码(此处省略),栈顶值将进入 D 寄存器,然后我们必须将其放入我们在汇编级别的 static 5 的化身中。我们如何做到这一点?我们生成一个名为 f.5 的标签,然后执行 M=D。另一个命令 pop static 2 的处理方式完全相同,只是将 5 替换为 2。
为什么这能实际工作?请记住,按照约定,Hack 汇编器会将这些引用映射到 RAM[16]、RAM[17],一直到 RAM[255](如果你的程序中有这么多静态变量的话,但这不太可能,典型的计算机程序通常只有几个静态程序变量)。因此,这些引用最终将被映射到你在 RAM 上看到的蓝色区域,而这正是我们想要的。因为这个蓝色区域在栈之外,并且你程序中运行的所有函数都可以查看这个 RAM 区域并看到完全相同的东西——静态变量。
通过使用这个技巧,我们基本上利用了 Hack 汇编器的一个独特约定:将符号变量映射到从位置 16 开始的 RAM 上。因此,你的静态段的条目将按照它们在 VM 代码中出现的顺序,依次映射到 RAM[16]、RAM[17] 等。这将导致一个稍微特殊的情况:在本例中,static 5 将映射到 RAM[16],static 2 将映射到 RAM[17](假设中间没有声明和使用更多的静态变量)。所以存在一一映射,但索引值的顺序与 RAM 条目的顺序并不相同,只是代码中出现的顺序决定了每个变量将被映射到 RAM 的哪个位置。
这就是我们实现静态段的方式。它有点复杂,但很有效。我想对此实现做一个总体观察:到目前为止,在我们讨论静态变量之前,我在本模块中所说的一切都是完全与平台无关的。毕竟,每台计算机都有 RAM,而我所说的关于实现 local、argument 等的内容,并没有对底层平台做出任何假设,除了存在栈这个非常安全的假设(因为每台计算机都有栈)。只有在实现静态变量时,我们才打破了这种平台无关的优雅,实际上利用了 Hack 汇编器独有的特性。重申一下,到目前为止,我所说的一切都可以在任何计算机上实现,不一定非要在 Hack 平台上。然而,如果你想在非 Hack 计算机上实现我们的虚拟机,你必须找到其他技巧来实现静态段。
实现 Temp 段
接下来要讨论的段是 temp。为什么我们需要一个临时段?事实是,当编译器生成代码,将你的 Jack 程序翻译成 VM 代码时,有时必须使用一些临时变量。因此,我们决定分配一个称为 temp 的八位置段,在需要时容纳和服务这些变量。
以下是我们的做法。push 和 pop 与之前完全相同,是统一的。但 temp 将是一个固定的八位置内存段,我们决定将此段映射到 RAM 位置 5 到 12。总共有八个值,因此 push 和 pop 的翻译将考虑这个基地址 5。所以,这几乎与我们之前的翻译相同,只是基地址现在是 5,而不是某个段指针。
这就是我们实现 temp 段的方式。
实现 Pointer 段
我们必须处理的最后一个段称为 pointer。pointer 段有点晦涩,因为在你开始编写编译器(这是我们在课程后期要做的事情)之前,你无法真正理解为什么需要它。所以现在,我只是将 pointer 段解释为某种近乎任意的游戏规则,根据我的解释,你将拥有在实际目标机器上实现它的所有信息。
首先,这个段从何而来?事实证明,当编译器翻译一个方法时,它必须记住 this 和 that 段的基地址。这些段代表了当前对象和该方法可能正在处理的当前数组。因此,我们决定将 this 和 that 的基地址保存在这个我们决定称为 pointer 的段中。
以下是游戏规则:
pointer 是一个固定的内存段。它只有两个条目:0 和 1。因此,这里唯一适用的 push 和 pop 命令是 push/pop pointer 0 和 push/pop pointer 1,任何其他整数在 pointer 上下文中都是无效的。
那么,我们用这些命令做什么?这只是命令的语法。每当 VM 代码试图访问索引 0 时,它应该导致访问 this;每当它想访问索引 1 时,它应该导致访问 that。因此,实现应该通过提供 this 和 that 来服务这些 push 和 pop 命令。
考虑到这一点,让我谈谈我们实际上是如何实现这个的。
push pointer 0 命令应实现如下:你想创建实现抽象指令 *SP=THIS; SP++ 的汇编代码。
如果 VM 命令是 push pointer 1,你做完全相同的事情,但使用 THAT 代替 THIS。
pop pointer 的处理方式对称地相似。
我想提醒你,这里看到的汇编代码是伪代码,你必须使用 Hack 汇编来实现这个伪代码。
这就是我们处理 pointer 段的方式。我必须承认,这个段以及我们使用和实现它的方式可能听起来有些奇特和奇怪,但你不必担心,因为一旦我们讨论代码生成,开始编写 Jack 编译器时,它就会变得完全合理。现在,让我们只是努力创建我在这里描述的功能。
总结

在本单元结束之前,我想回顾并总结一下我们所做的事情。基本上,我们正在实现 VM 语言,它包括算术/逻辑命令、内存访问命令、分支命令和函数命令。到目前为止,我们已经实现了前两类命令,剩下的两类命令将在课程的下一个模块中处理。在进入下一个模块之前,我们在这里还有一些工作要做。我将在下一单元与你见面,届时我们将讨论 VM 模拟器。
019:虚拟机模拟器 🖥️

在本单元中,我们将学习虚拟机模拟器。这是一个功能多样且非常有用的工具,将在本课程的多个阶段发挥作用,从当前模块开始。
概述
首先,让我们将虚拟机模拟器置于我们正在进行的整体工作框架中。我们的总体目标是将用高级语言(例如Jack语言)编写的程序翻译成机器语言。为了实现这一目标,我们采用两阶段编译模型:第一阶段将高级语言编译成虚拟机代码;第二阶段,我们需要在目标平台上执行这些虚拟机代码。
一种方法是将虚拟机代码翻译成机器语言,然后将二进制代码加载到目标平台(在本课程中即Hack计算机)上执行。此外,还有一种快捷方式,允许您直接运行虚拟机代码,这可以通过虚拟机模拟器实现。虚拟机模拟器是一个高级程序,在我们的案例中是用Java编写的,您可以在任何计算机(包括您自己的个人电脑)上运行它。如果您已经下载了Nand2Tetris软件套件,那么您的电脑上就已经有了我们将在本单元讨论的虚拟机模拟器。
虚拟机模拟器的用途
人们使用虚拟机模拟器有多种目的。
首先,它提供了执行已编译Jack程序最自然、最简单的方式。即使程序包含各种视觉效果,如弹跳球、玩俄罗斯方块等,您也将在虚拟机模拟器环境中获得所有这些功能。
其次,您可以使用提供的测试脚本系统地测试程序,这也是该工具的一个优点。
此外,人们还使用虚拟机模拟器来理解虚拟机抽象。如果您想单步执行一个虚拟机程序,并观察每条push和pop命令等对虚拟内存段和堆栈的影响,虚拟机模拟器提供了一个非常友好的环境来实现这一点。
最后,虚拟机模拟器还能让您一窥虚拟机抽象是如何在宿主平台上实际实现的,随着本单元的深入,这一点将变得更加清晰。
虚拟机模拟器界面介绍
这就是虚拟机模拟器的完整界面。
在左侧,我们看到一些虚拟机代码被加载到虚拟机模拟器环境中,这些代码目前正在运行。我们还看到一组控件,允许我们控制代码的执行,例如单步执行、快进、调整代码执行速度等。
在这里,我们看到程序生成的输出,在本例中是一些弹跳球。虚拟机模拟器的设计使得这个显示输出的窗格是一个多功能窗口,它可以作为用户查看各种文件(如脚本生成的输出文件、脚本文件、比较文件等)的工具,我们将在后续过程中说明所有这些功能。
然后,我们可以查看堆栈,观察它如何响应各种push和pop命令而变化。最后,我们还可以查看虚拟内存段,观察它们如何受到虚拟机程序的影响。
虚拟机模拟器还有一些其他的控件和功能,我将在后面讨论。
工具使用示例
以下是我们通常使用此工具所做工作的一个示例。
我们取一段代码,将其加载到虚拟机模拟器中。当代码执行时,我们需要关注几件事情。
首先,我们将虚拟机模拟器视为一种动手实践的实验室。我们可以查看代码,观察它如何影响堆栈和虚拟内存段。通过这样做,我们将更好地理解虚拟机抽象是如何工作的。
我们还可以查看虚拟机模拟器GUI的右侧。如果我们查看这个区域,我们将看到虚拟机抽象是如何在宿主平台(宿主计算机的RAM)上实际实现的。
因此,我们获得了抽象及其实现的双重视图。这非常有指导意义且功能强大,因为您可以在两个不同的关注层面上真正看到虚拟机是如何工作的。
测试脚本的概念
接下来我想讨论的概念是测试脚本,这是您在本模块中需要完成的项目7的核心。
以下是一个测试脚本。它通常是一个由我们提供的纯文本文件。
测试脚本中的第一项通常是一个load命令,它指示模拟器将一个虚拟机文件加载到模拟器环境中,以便我们可以执行和测试它。
这些虚拟机文件通常由我们提供,或者您也可以编写自己的文件。但如果您想这样做,必须使用外部文本编辑器,并以类似“文件名.vm”的格式保存。然后,您可以使用像这样的load命令将其加载到模拟器环境中。您也可以交互式地加载它,但这里我们讨论的是以批处理方式完成所有事情的测试脚本概念。
测试脚本中的下一个命令通常定义一个输出文件,测试脚本将在程序执行期间向该文件写入一些值。目前,我们有一个空的输出文件。
接着,提供的测试脚本通常还会加载一个由我们提供的比较文件。比较文件看起来各不相同,通常它会列出几个选定容器(如RAM或内存段中的选定区域)的名称。因此,您会看到容器的名称以及脚本期望在程序执行期间看到的当前值或值。
接下来我们将看到的是一些输出格式化命令。好消息是,您并不需要完全理解这些测试脚本的所有语法,因为我们提供了所有这些用我们称之为TDL(测试描述语言)编写的测试脚本。这实际上不是课程的一部分,我们不需要深入探讨,我们在其他地方已经有足够的挑战了。
再次强调,我们只需要理解正在发生的事情的精神即可。
我们在测试脚本中看到的下一件事是一个循环,它指示模拟器一次执行一条虚拟机命令。在本例中,对于basic test.vm文件,我们有25条指令,因此25次迭代足以执行此代码。
最后,在这个特定的测试脚本中,我们看到在脚本末尾只有一个输出命令。当模拟器执行此命令时,它将把脚本序言中输出列表命令定义的所有变量打印到输出文件中。
规则是这样的:如果生成的输出文件与提供的比较文件相同,我们就说测试成功,模拟器将给您一个积极的消息,表明测试已成功完成。然而,如果存在任何差异,模拟将停止,您将收到错误消息。

这就是使用测试脚本的基本思想。
项目7中的特殊考虑

事实证明,由于我们将虚拟机翻译器的开发分为两个阶段(项目7和项目8),在项目7中,有一些缺失的元素需要处理。
具体来说,我们在项目7中运行的程序并不是真正的程序。您可以将它们视为一系列指令。为了使这些序列成为程序,缺少的是我称之为“函数与返回封装”的部分。
在项目8中,函数和返回命令将导致虚拟机实现初始化方法或函数期望使用的各种内存段(局部变量段、参数段、this、that等)。因此,在项目7中,这些内存段没有在任何地方初始化,因此必须有人负责这些初始化工作,我们使用测试脚本来完成。
正如您所见,我们编写了一组命令(再次强调,这些测试脚本是由我们提供给您的),我们添加了一些命令来手动初始化虚拟内存段,并将它们锚定在RAM中的选定区域。一旦我们在测试脚本中执行了这个设置阶段,虚拟机代码就能正常运行了。
虚拟机模拟器实战演示
接下来,我想在项目7的背景下更详细地演示虚拟机模拟器的实际工作,因为在这个特定项目中,您需要处理多个虚拟机文件,因此我认为看看这个工具在您必须自己完成的项目背景下如何工作是很有意义的。
以下是我的Nand2Tetris文件夹,让我进入projects目录,在projects中选择Project 7。
在这些文件夹中,我将选择memory access。在memory access中,让我们进入basic test。我看到这里有四个文件。第一个是比较文件,第二个是测试脚本,第三个是虚拟机代码,第四个是另一个测试脚本。这很有趣,为什么我们这里有两个测试脚本?我稍后会解释这一点。
让我启动我的虚拟机模拟器,并加载我们面前的虚拟机文件。
我进入basic test,看到一个basic test.vm文件。我加载它。我们看到有24条命令,实际上包括第0条命令是25条。让我们通过单步执行代码来开始执行。
第一条命令是push constant 10,确实我们看到堆栈中有10。看起来不错。
下一条是pop local 0。执行它。哎呀,我收到一个错误消息“out of segment space”。问题是,在这个特定的虚拟机文件中,代码开头没有函数声明命令,因此,没有人告诉模拟器需要为虚拟内存段(静态、局部、参数等)分配内存空间,所以我们遇到了这个问题。
解决这个将内存段锚定在RAM中的挑战的快速简便方法是使用测试脚本。因此,我们总是建议加载测试脚本,而不是直接加载虚拟机代码(如果有的话)。让我们这样做。我们这里有两个测试脚本,我们将选择这个VME.tst文件。
加载它。看起来什么都没发生。这是因为GUI中有一个小问题。如果您点击这里的空格,您将看到完整的测试脚本。现在我们可以开始执行它了。我将像之前一样单步执行。
点击“Run”。我们看到测试脚本中的几条命令被执行了。确实,模拟器总是执行测试脚本中直到分号的所有命令。逗号被视为一个微步骤,分号被视为一个步骤的结束。因此,当我们执行单步时,我们告诉模拟器执行前四条命令。
第一条命令是load basic test.vm,所以模拟器基本上重新加载了已经存在于程序窗格中的文件。这很好,因为我们可能想在中间进行一些调试,所以最好总是从新鲜状态开始。
然后,测试脚本定义了一个输出文件,使用了某个比较文件等等。所有这些并不是非常重要,因为我们不期望您深入研究测试脚本代码的细节。您只需要理解正在发生的事情的精神即可。
让我再次点击单步执行。我们看到我们执行了所有这些命令和第一个虚拟机步骤。确实,我们执行了push constant 10,并且看到堆栈中包含10。很好。
执行另一条命令。我们看到我们执行的命令是pop local 0,并且确实,local 0现在包含10。
让我执行push constant 21,push constant 22,pop argument 2。确实,我们看到argument 2现在包含22。很好。
执行push constant 36,pop this 6。我们看到this 6现在包含36。
执行push constant 42,push constant 45,堆栈包含42和45。到目前为止,一切似乎进展顺利。
此时,如果我愿意,我还可以查看宿主平台,看看我目前所做的如何反映在底层宿主平台(即Hack计算机)上。因此,我将把注意力转向宿主RAM。
我看到堆栈指针是258。这是合理的,因为根据Hack平台上的标准虚拟机映射,堆栈应该从256开始。确实,我们在测试脚本中有一条命令说“set sp 256”。所以堆栈指针被初始化为256,现在堆栈中有两个值,因此堆栈指针应该是256加2,即258。但为了确认,我也可以向下滚动RAM。256应该不远。确实,在这里。看,我现在看到了堆栈是如何在宿主RAM中实现的。它就在这里:42、45,堆栈指针指向258。
就堆栈而言,一切看起来都很好。
我可以探索其他一些事情。例如,看看参数段存储在哪里很有趣。Arg是400。让我使用这里的双筒望远镜控件,输入400。这将带我进入RAM中的地址400。我看到这里的内容是0、21、22,这与我们在这里看到的相同。因此,在左侧,我们有参数段的抽象表示。在右侧,我们看到了这个抽象结构是如何在宿主平台上实际实现的一瞥。
回到代码,我可以继续单步执行,但这变得有点繁琐,所以我也可以快进。如果这还不够快,我还可以使用这个滑块让它更快。
当我们快进时,我们本质上执行了一系列单步事件。在每一个单步中,我们执行测试脚本命令直到下一个分号。基本上,我们进入了这里的循环,并执行了25次迭代,25次迭代就足够了,因为在这个特定程序中我们只有25条指令。
现在,我们如何知道程序确实做了它应该做的事情?通常,计算机程序被设计来执行非常具体的任务,比如在屏幕上弹跳球、运行俄罗斯方块游戏或判断一个给定数字是否为质数,因此在许多情况下测试这些程序相对直接。
但是,在这个特定程序以及项目7的其他程序中,有些代码相当晦涩,因为它没有考虑某些全局目的,而是为了测试(或者我应该说单元测试)堆栈和一些内存段上的特定虚拟机操作而编写的。因此,如果您想在虚拟机级别测试这个程序,您可以像我们之前在这个演示中那样交互式地单独跟踪每条指令,或者您可以(可以说)让提供的测试脚本自行执行测试。
为了做到这一点,您运行测试脚本直到它结束。然后,如果您收到“End of script: comparison ended successfully”这样的好消息,您可以得出结论,程序已经通过了规定的测试。
这里的逻辑是,当您执行脚本文件时,每当模拟器在脚本中遇到输出命令,它就会将此输出的结果与比较文件中的当前行进行比较。如果输出行和比较文件行在脚本执行过程中始终一致,那么在最后,您将收到这个积极的“comparison ended successfully”消息。如果中途出现任何差异,模拟将停止,您将收到错误消息。
如果您想深入了解此过程的细节,可以转到这个控件,例如,查看此特定脚本生成的输出文件。在这个脚本中,我们只在脚本末尾有一个输出命令。确实,我们只有一行输出。这一行显示了一些从内存中选定的字的值,这些值共同可以帮助您确认程序已经完成了它应该做的事情。
因此,我们可以查看输出。我们也可以查看比较文件,我们知道比较文件将与输出相同,否则我们就不会收到这个漂亮的程序完成消息。再次强调,这是测试程序的标准方式。

总结

我们的虚拟机模拟器演示到此结束。模拟器软件中有一些额外的功能和特性我决定不演示,因为我们在当前阶段不需要它们。当我们开始进行项目8(我们Nand2Tetris旅程中的下一个项目)时,我们将需要它们。
总而言之,虚拟机模拟器有几个目的:首先,它允许我们运行和测试高级程序或已编译的Jack程序;其次,它是一个动手实践的实验室,允许我们深入研究和理解虚拟机抽象及其实现。

我希望您喜欢这个关于虚拟机模拟器的基本介绍。在下一个单元中,我们将讨论虚拟机在Hack平台上的实现。
020:Hack平台上的虚拟机实现 💻

在本单元中,我们将讨论如何在一个特定的硬件平台——Hack计算机上,实现我们迄今为止介绍的虚拟机抽象。
概述
首先,我们来回顾一下什么是虚拟机翻译器。虚拟机翻译器是一个用某种高级语言(如Java或Python)编写的程序。该程序的输入是另一个用虚拟机代码(如push等命令)编写的程序。然后,它会解析这个文件,将每条命令分解成各种词法元素(例如push、constant和整数2),接着利用这些词法元素,使用目标平台(在我们的案例中是Hack)的汇编命令来重新表达这些命令。
请注意,右侧是输出文件。我建议你在开发这个翻译器时,每当翻译一条特定命令,就写一条注释来标明这条命令。这将有助于你后续调试虚拟机翻译器的输出。自然地,每条在此上下文中被视为高级语言的虚拟机命令,都将被翻译成若干条汇编命令,正如我们在之前单元讨论翻译时所看到的那样。
如果你想编写一个能将任何给定的法语文本翻译成西班牙语的通用程序,首先了解法语和西班牙语这两种语言会非常有帮助。同样地,如果你想编写虚拟机翻译器,你必须同时精通源语言和目标语言,并且还需要了解如何在宿主计算机上放置各种虚拟数据结构(如栈和段),以及如何将高级命令翻译成汇编命令。
源语言与目标语言
基于以上考虑,我们需要处理的源语言是虚拟机语言,它包括算术逻辑命令、内存访问命令、分支命令和函数命令。但在本课程的项目7中,我们将只关注算术逻辑和内存访问命令。在下一个项目(项目8,也是下一个模块的核心)中,我们将完成整个图景,处理分支和函数命令。
我们翻译的目标将是符号化的Hack汇编语言。我们在模块0中学习过这种语言,它主要由两种命令组成:A命令和C命令。这就是将你的虚拟机程序翻译成汇编语言所需的全部。
标准虚拟机映射
我们需要了解的另一个概念是目标平台上的“标准虚拟机映射”。让我先展示标准映射,然后讨论为什么我们需要它。
基本上,我们需要做两个决定:如何将虚拟机的数据结构映射到宿主计算机的RAM上,以及如何在宿主语言中表达这些命令。
标准映射为你提供了关于我们建议你如何实现这种映射的各种信息或约定。试想一下,如果我让你实现一个虚拟机翻译器,原则上你可以自由地做任何你想做的事。我可以告诉你,这是RAM,随你怎么用。然而,提出一个特定的标准映射是方便且惯常的做法,这将确保两件事:首先,如果你同意将各个段放在我们建议的RAM位置,那么你将遵循普遍接受的标准,这些标准也被运行在同一台计算机上的其他软件开发工具所接受。如果每个人都遵守相同的规则,这些程序就不会在内存中发生冲突。其次,如果你承诺在RAM上采用某种特定的映射,那么我们就可以编写各种也做出类似假设的测试程序。通过这样做,测试软件的团队可以与实际开发软件的团队分开,这通常是为了进行客观测试而推荐的。
这就是标准映射的目的。
Hack计算机上的标准映射
以下是虚拟机在Hack计算机上的标准映射。我重复了我们在之前单元讨论过的内容,但我想把它们放在一张幻灯片上,以便你一目了然。
我们使用前5个字来存储段指针。接下来的8个字用作临时段的实现。然后我们有三个通用寄存器。接着是大约250或240个静态变量。从这之后一直到2K的所有剩余内存,将用作存放虚拟机栈的地方。
我们之前提到的所有这些段,都将按照本幻灯片中展示的逻辑映射到这片RAM上。具体细节你可以自行查看。
实现映射所需的符号
如果我们想实现这种映射,那么在编写虚拟机翻译器时,我们必须使用某些约定的符号来生成实现虚拟机代码的Hack汇编代码。
具体来说:
- 我们将使用
SP作为一个特殊标签,代表栈指针。 - 我们将使用
LCL、ARG、THIS、THAT作为相应内存段的段指针。 - 当我们需要使用一些通用寄存器时,我们将使用
R13、R14、R15。 - 最后,如果你还记得,我们在之前的单元中描述了如何使用特殊生成的标签来表示静态变量。
文件名.运行索引这种格式将代表具有相同运行索引的静态变量。
因此,你的虚拟机翻译器在生成汇编代码时,必须使用这些符号。你需要负责编写能生成这些标签的代码,并明智地使用它们,以实现我们刚刚描述的映射。
最后,我想提一下,在我们将要实现剩余虚拟机命令的项目8中,我们将对这个映射进行一些扩展,并添加一些新的约定。
总结

本节课中,我们一起学习了如何将虚拟机翻译器或虚拟机映射到宿主RAM和宿主计算机上。现在,我们终于可以开始讨论如何实际使用高级语言来构建这个程序了。
021:虚拟机翻译器实现方案 💻

在本节课中,我们将学习如何实际构建一个虚拟机翻译器。我们将讨论其整体架构、核心模块的设计以及实现的具体步骤。
概述
虚拟机翻译器是一个程序,它读取包含虚拟机命令的文本文件,并生成包含汇编命令的另一个文本文件。其核心任务是将高级的虚拟机指令逐行翻译成底层的汇编代码。
虚拟机翻译器的工作原理
上一节我们介绍了虚拟机翻译器的概念,本节中我们来看看它的具体工作流程。
虚拟机翻译器的工作流程如下:
- 读取输入的
.vm文件。 - 逐行解析文件中的虚拟机命令。
- 根据命令类型(如算术运算、内存访问)生成对应的汇编代码。
- 将生成的汇编代码写入输出的
.asm文件。
例如,对于 push constant 17 这条命令,翻译器会将其解析为词法元素,并生成类似 @17、D=A 等汇编指令。接着,它会继续处理下一条命令,如 push local 2,并生成实现该栈操作目标的汇编代码。这个过程会持续到输入文件结束。
如何使用虚拟机翻译器
假设我们已经实现了一个虚拟机翻译器,并希望用它来翻译一个之前编写的程序(无论是测试程序还是实际应用)。操作步骤如下:
- 打开终端窗口(或在Windows中使用命令提示符)。
- 运行虚拟机翻译器程序。例如,如果使用Java编写,则通过Java运行时系统调用它,并将源程序文件名作为参数传入。
java VmTranslator MyProgram.vm - 翻译完成后,在当前目录下会生成一个与输入文件同名但扩展名为
.asm的新文件。这个文件包含了由源虚拟机代码翻译而来的汇编代码。
之后,我们可以使用纯文本编辑器查看这个 .asm 文件,以验证生成的汇编代码是否有效。
构建虚拟机翻译器的架构
现在我们已经了解了虚拟机翻译器的用途,接下来讨论如何构建它。具体实现取决于你选择的编程语言(如Java或Python)。我们推荐采用一种由三个独立模块组成的软件架构。
以下是这三个核心模块:
- 主类 (Main Class / VM Translator):这是程序的入口点。它接收一个
.vm文件名作为输入,并生成对应的.asm文件。 - 解析器类 (Parser Class):负责处理输入文件。它读取虚拟机命令,将其分解为独立的组成部分(如命令类型、参数),并忽略空白字符和注释。
- 代码写入器类 (CodeWriter Class):负责生成汇编代码。它根据解析器提供的信息,编写实现特定虚拟机命令的汇编指令。
主类 VM Translator 的运行逻辑如下:
- 构造一个
Parser对象来处理输入文件。 - 构造一个
CodeWriter对象来处理输出代码的生成。 - 遍历输入文件的每一行(每条虚拟机命令)。
- 对于每一行,使用
Parser的功能进行解析。 - 将解析得到的信息传递给
CodeWriter,以生成相应的汇编代码。 - 将所有生成的汇编代码整合并写入输出文件。
在实现这个主类时,你需要掌握所用编程语言中处理文本文件的方法(例如,在Java中如何打开、读取、写入文件)。这是完成本项目的基础。
模块API详解
以下是 Parser 和 CodeWriter 这两个核心模块的应用程序接口(API)设计。
解析器 (Parser) API
解析器模块负责解析单个VM文件。它读取VM命令,将其分解为各个组成部分,并将信息传递给代码写入器。在此过程中,它会忽略源文件中的所有空白和注释。
其核心方法包括:
- 构造函数 Parser(String fileName):打开输入文件/流以进行处理。
- boolean hasMoreCommands():检查输入中是否还有更多命令。
- void advance():从输入中读取下一条命令,使其成为当前命令。仅当
hasMoreCommands()为真时才调用此方法。 - CommandType commandType():返回当前命令的类型。它是一个枚举常量,如
C_ARITHMETIC、C_PUSH、C_POP等。 - String arg1():返回当前命令的第一个参数。对于算术命令,返回操作命令本身(如 “add”)。对于其他命令类型,返回内存段(如 “local”)。
- int arg2():返回当前命令的第二个参数(一个整数)。仅对
C_PUSH、C_POP、C_FUNCTION、C_CALL命令有效。
代码写入器 (CodeWriter) API
这个类/模块负责根据解析后的命令生成汇编代码。
其核心方法包括:
- 构造函数 CodeWriter(String fileName):打开输出文件/流并准备写入。
- void writeArithmetic(String command):编写实现给定算术命令的汇编代码。这是主要的工作部分之一,需要为不同的算术/逻辑命令生成对应的汇编指令。
- void writePushPop(CommandType command, String segment, int index):编写实现
push或pop命令的汇编代码。这是另一个核心方法,需要根据内存段(如local、argument、static等)和索引生成相应的内存访问指令。 - void close():关闭输出文件。
这个API构成了基础虚拟机翻译器的核心。在下一个处理完整VM语言(包括分支和函数命令)的项目中,还会向此API添加更多例程。
我们在此提供的是一个推荐的架构。你可以在自己的代码中添加更多的私有方法(不对外暴露),以使代码更易于管理、更优雅、更可读。
总结

本节课中我们一起学习了虚拟机翻译器的实现方案。我们描述了能够处理算术逻辑和内存访问命令的虚拟机翻译器的实现架构。剩下的工作是在下一个项目中完善这个翻译器,以同时处理分支命令和函数命令。接下来的模块将具体描述如何完成并提交该项目。
022:构建虚拟机翻译器第一部分 🧱

在本节课中,我们将学习如何构建虚拟机翻译器的第一部分。我们将了解项目的整体目标、开发计划、测试方法以及所需工具。通过本教程,你将能够编写一个程序,将虚拟机代码中的算术逻辑和内存访问命令翻译成机器语言。
概述
在项目7中,我们的目标是构建一个虚拟机翻译器的初始部分。这个翻译器能够将用虚拟机语言编写的程序,特别是其中的算术逻辑和内存访问命令,翻译成可以在我们之前构建的硬件平台上运行的机器语言。
整体架构与项目背景
上一节我们介绍了项目的总体目标。现在,让我们回顾一下构建现代计算机的完整旅程。
整个旅程始于一个高层次的想法,例如开发一个游戏。然后,我们通过多个构建阶段,最终得到一个能在真实计算机上运行的程序。我们将这个旅程分为两个部分:第一部分构建了硬件平台(即主机),第二部分则构建了运行在主机之上的复杂软件层次结构。
这个软件层次结构使我们能够使用像Jack、Java或Python这样的高级语言编写程序,然后将它们一路翻译成机器语言,生成可以在第一部分构建的平台上执行的代码。
我们将这个旅程划分为六个独立的项目,编号从项目7到项目12。此外,还有一个可选的项目0,它是本课程第一个模块的重点。
在本模块中,我们将专注于项目7。在这个项目中,我们将构建一个完整虚拟机翻译器的第一阶段。项目0中介绍的一些工具也将在项目7中发挥作用。
虚拟机代码是用虚拟机语言编写的。虚拟机语言包含四类命令。在项目7中,我们只关注前两类命令。
我们将开发一个基本的虚拟机翻译器,它能够将包含算术逻辑和内存访问命令的虚拟机程序翻译成机器语言。在下一个项目(项目8)中,我们将处理虚拟机语言的其余部分,完成完整虚拟机翻译器的构建。
项目目标与示例
以下是项目7的具体目标示例。
假设有一个存储在某个.vm文件中的虚拟机程序,它实际上是一系列虚拟机命令。项目7的目标是使用Java或Python等语言编写一个名为“虚拟机翻译器”的程序。这个程序会读取这个输入文件,并将其翻译成汇编代码。
例如,这是第一个虚拟机命令的翻译结果。然后,翻译器会继续处理输入文件,为每个虚拟机命令生成对应的汇编代码,并输出到一个.asm文件中。
请注意,建议你的虚拟机翻译器在输出时总是附带一条注释,说明它正在翻译哪个命令。这样做会使调试变得更容易,因为当你阅读生成的汇编文件时,可以清楚地看到是哪条命令生成了哪段汇编代码。
测试翻译结果
那么,如何测试翻译是否正确呢?如何确认虚拟机翻译器生成了可读的机器级代码?
你可以将生成的代码加载到目标平台(在我们的案例中是Hack计算机)上运行,并验证代码是否按照源代码的意图执行。
回顾整体架构,我们可以再次将代码加载到机器层面并运行。如果你没有学习过第一部分课程,或者即使你学习过,也有另一种测试方法:我们可以使用提供的CPU模拟器来运行Hack程序。CPU模拟器可以在任何计算机(包括你自己的个人电脑)上运行,其行为与实际硬件平台完全一致。
如果你下载了Nand2Tetris软件套件,你就可以使用这个程序。我们在项目0中已经使用过它,这也是为什么我将项目0列为项目7的一部分。我们可以使用CPU模拟器来测试生成的代码是否按预期工作。
详细开发计划
接下来,让我们详细了解一下开发计划,并解释在每个步骤中需要做什么。
首先,我们提供了五个虚拟机程序。每个程序都旨在测试你在项目7中构建的虚拟机翻译器的某些特定功能。每个程序都存储在一个单独的目录中,因此有五个目录,目录名与程序名相同。这里有一个名为BasicTest的目录示例。
在这个BasicTest目录中,最重要的文件是虚拟机程序本身,也就是我们需要翻译的程序。其他文件则用于其他目的,我稍后会解释。
你的任务是编写一个符合我们在第1.7单元中描述的“虚拟机到Hack”标准映射的翻译器。你必须使用这个虚拟机翻译器来翻译我们在这个项目中提供的五个虚拟机文件中的每一个。对于每一次翻译,你都需要生成一个汇编程序。然后,当你在虚拟机模拟器上执行生成的汇编程序时,你的代码生成的输出文件必须与提供的比较文件相同。如果满足这个条件,你就通过了测试,可以继续翻译下一个虚拟机程序,并重复相同的流程。
以下是测试这五个程序时,我建议你遵循的具体步骤。
首先,有一个可选阶段。我建议你在这个阶段使用虚拟机模拟器运行这个虚拟机程序。我建议你这样做是为了熟悉虚拟机程序,并理解它的设计目的。
虽然这与本项目没有直接关系,因为本项目是将程序翻译成汇编,而虚拟机翻译器并不关心程序在做什么,它只是将其视为一个文件到文件的翻译任务。然而,如果你花几分钟时间运行这个程序,它将为后续的翻译任务提供很多有用的背景信息。
一旦你理解了虚拟机程序的功能,接下来你需要调用你编写的虚拟机翻译器,将其翻译成一个汇编程序,并存储在一个单独的.asm文件中。
然后,我建议你进行下一步:将生成的.asm文件加载到某个文本编辑器中,并确认文件看起来没问题。毕竟,这个文件是你的虚拟机翻译器生成的,你需要确保它生成了一些可读的虚拟机命令。如果有任何问题,你必须返回修改虚拟机翻译器,并重复这个过程。
完成视觉检查后,你可以将.asm文件加载到CPU模拟器中,并继续执行和检查结果。
不幸的是,最后一个阶段(阶段4)不会成功。这是因为源代码以及你的翻译器从中生成的汇编代码都期望使用栈和虚拟内存段。而源代码中没有任何内容实际初始化主机平台上的这些虚拟段和栈。
因此,你必须将提供的测试脚本加载到CPU模拟器中,执行它,并检查结果。如果有问题,你可以返回阶段2修复你的虚拟机翻译器。如果你使用测试脚本而不是汇编文件,测试脚本将负责所有必需的初始化,然后翻译后的代码就能在CPU模拟器上正确执行。
项目所需工具与资源
在本项目中,你需要使用各种工具和资源。首先,你需要测试程序和比较文件,它们可以在你计算机上的项目目录中找到。
你必须使用提供的虚拟机模拟器来试验提供的虚拟机程序。你需要使用你正在开发的虚拟机翻译器将这些虚拟机程序翻译成汇编。
然后,为了测试生成的代码,你需要使用提供的CPU模拟器,正如我们刚才解释的那样。为了开发虚拟机翻译器,你需要一些资源,如高级语言(Java、Python等)。我们建议你查看Nand2Tetris网站上提供的一些教程,如虚拟机模拟器教程、CPU模拟器教程。你也可以参考《计算机系统要素》一书的第7章。
这是一个复杂的项目,但你有所有这些工具来帮助你成功完成它。
测试挑战与解决方案
现在,我们需要解决一些测试挑战,我之前已经描述过,但我想再次总结一下。
我们提供的虚拟机文件中的代码缺少我称之为“函数返回封装”的部分。因此,这段代码没有初始化整个平台上的栈和虚拟机段。这些缺失的部分将在项目8中补充完成。目前,我们使用一个测试脚本来手动填补这些空白。
测试脚本以通常的前导命令开始,我们不需要完全理解这些命令,这同样不属于本课程的核心内容。然后是一组命令,用于将虚拟内存段和栈锚定到整个RAM上。接着是一个循环,实际执行生成的机器级代码。
我们看到,我们使用这些命令来手动完成项目8在下一个模块中将要自动完成的工作。

工具使用演示

接下来,我想通过实际演示你在项目7中必须使用的工具,来回顾和梳理到目前为止所讲的一切。这将在下一个视频片段中完成。
本演示的目的是逐步讲解你在进行项目7时必须遵循的步骤。
这是我的Nand2Tetris文件夹。让我们进入项目7。在项目7中,我们进入MemoryAccess目录,再进入BasicTest目录。让我调整窗口大小以便更好地聚焦。
我们看到这里有四个文件:一个比较文件、一个为CPU模拟器设计的测试脚本、我们需要翻译成汇编的虚拟机代码,以及另一个为虚拟机模拟器设计的测试脚本。
这个过程的核心是BasicTest.vm。这是我们需要翻译的程序。让我们看一下它。
我们看到这里有一组虚拟机命令,旨在对栈和虚拟段(如constant、local、argument、this、that等)执行各种操作。我们面临的挑战是将这段虚拟机代码翻译成汇编代码,然后使用CPU模拟器运行汇编代码,并确认生成的代码确实执行了虚拟机代码应该执行的操作。
因此,在我们实际进行这个翻译和测试过程之前,先尝试运行一下源代码并理解其设计目的是有意义的。
让我打开我的虚拟机模拟器,并将BasicTest.vm脚本加载进去。正如我之前所说,我认为在我的某个演示中,输出显示有一个小故障。所以有时你需要点击输出面板才能看到或显示其内容。
这就是测试脚本,我们可以开始执行它。让我们单步执行测试脚本。
我们看到发生了几件事。首先,BasicTest.vm文件被加载到模拟器中。我们还看到测试脚本创建了一个名为BasicTest.out的新输出文件。我怀疑在这个阶段,输出文件是空的,因为代码的执行还没有真正开始。
然后,我确实可以查看它。让我们这样做。我们看到输出文件包含一些RAM位置的标签,但没有与之关联的值。没有值是因为代码还没有运行。
现在,让我们选择快速执行页面,然后快速执行脚本。
在这个阶段,我可以坐下来观察发生了什么。这相当困难,因为脚本运行得相当快。
但现在我可以进入虚拟段和主机RAM,探索这个程序发生了什么。我并不想深入探讨这一点,因为我相信我们在第1.6单元已经对虚拟机模拟器进行了相当详细的演示,事实上,我们正是在这个相同程序的背景下演示了虚拟机模拟器。所以这里没有必要重复这些内容。
相反,我现在将最小化虚拟机模拟器,转向项目的下一步:将虚拟机测试文件翻译成汇编。
我想提醒你,项目7的核心是编写一个虚拟机翻译器。我的虚拟机翻译器是用Java编写的。为了使用它,我将打开我的终端窗口,输入java VMtranslator。这是我的翻译器程序的名字。然后我将提供一个参数,即我要翻译的文件名,也就是BasicTest.vm。
输入这个命令。如你所见,一个新文件已经创建,这个文件是BasicTest.asm,用Hack汇编语言编写。
现在,查看一下这个文件的内容可能很有趣。让我们这样做。
这就是我的虚拟机翻译器生成和创建的汇编文件。让我在这个文件中向下滚动。我看到它总共包含大约200行Hack汇编命令。
现在我提醒自己,原始的虚拟机测试文件大约包含25行代码,所以翻译比率大约是1:8。换句话说,每个虚拟机命令在翻译后平均生成大约8条Hack汇编命令。很可能你的虚拟机翻译器写得比我的更好,因此它可能生成更紧凑的汇编代码,并达到更好的翻译比率。
接下来我想做的是测试生成的汇编代码实际上是正确的。换句话说,我想确认这段代码执行了源代码应该执行的操作。为了做到这一点,我将调用我的CPU模拟器。
这是我的CPU模拟器,我将把提供的BasicTest.tst脚本文件加载进去。就是这个。像往常一样,我将点击我的输出面板以显示其内容。
让我们看看。我将单步执行第一批脚本命令。作为响应,CPU模拟器已经将由我的虚拟机翻译器生成的汇编程序加载到模拟器环境中。就在这里。
现在我可以执行这个程序,看看会发生什么。让我们点击快速执行,现在我可以坐下来观看由我的程序生成的代码执行。我看到执行在继续,因为代码中没有告诉它停止的内容。
在某个时刻,就在这里,执行停止了。执行停止是因为提供的测试脚本被编程为执行600个时钟周期。因此,在某个时刻,它强制程序停止。
那么,我怎么知道程序运行成功了呢?首先,我收到了这条非常好的消息,它告诉我我已经履行了合同。翻译后的汇编代码通过了提供的测试脚本所要求的测试。

如果你不确定如何使用CPU模拟器,你可以回到模块0,查找一个名为“低级编程第一部分”的单元,在该单元中某处,你会找到一个CPU模拟器演示,欢迎你查看。如果你愿意,你也可以访问Nand2Tetris网站,在那里你会找到一个CPU模拟器教程。

总结
这就是演示,我们向你展示了在进行项目7时必须经历的各种步骤。
回顾一下,在本项目中,以及在本模块中,我们开始开发一个虚拟机翻译器。在项目7中,我们只关注算术逻辑命令和内存访问命令。因此,我们开发了一个程序,能够将这些命令翻译成在主机平台上执行相同操作的机器级指令。
在下一个模块和项目8中,我们将通过扩展虚拟机翻译器以处理分支命令以及函数和返回命令,来完成虚拟机翻译器的构建。通过这样做,我们将拥有一个完整的虚拟机翻译器,它将作为我们Jack编译器的后端,使我们能够编写高级程序,并将它们一路翻译成机器语言。


这基本上是模块1操作部分的结束。在最后一个单元中,我们将讨论关于虚拟机一般概念的一些观点和观察。
023:视角 🧭

在本单元中,我们将回顾第一模块的学习内容,并从更广阔的视角审视虚拟机的概念。我们将探讨其历史背景、与现实世界技术(如Java虚拟机)的联系,并讨论在课程项目中暂时忽略但实际至关重要的因素,例如效率。
上一节我们完成了虚拟机翻译器的构建。本节中,我们将通过回答几个常见问题,来深入理解虚拟机概念的背景与意义。
以下是本单元将探讨的几个核心问题。
- 虚拟机是一个相当古老的想法。它有多古老?
- 我们的虚拟机与Java虚拟机(JVM)有多接近?
- 关于效率问题,我们需要注意什么?
虚拟机是一个相当古老的想法。它有多古老?
个人计算机时代在20世纪70年代末全面兴起。当时,苹果和IBM公司推出的个人电脑迅速流行起来。这些早期的PC几乎没有任何预装软件。
同期,一种名为Pascal的编程语言在大型机和微型计算机上相当流行。人们开始为新的IBM和苹果PC开发Pascal编译器。然而,这些机器使用不同的处理器、芯片组和操作系统,因此必须开发不同的Pascal编译器。
当时的软件开发者若想让其软件(如会计软件或电脑游戏)在所有PC上运行,就必须使用不同的编译器,并维护和支持软件的多个版本。这导致了诸多问题。IBM和苹果是竞争对手,它们无意帮助开发者将软件移植到对方的机器上。
为了在基础设施层面应对这一跨平台挑战,一些研究者和实践者开始构思并规范一种早期的虚拟机架构和语言,称为P-code。与我们的VM语言类似,P-code基于栈机抽象。
为了实现这一抽象,P-code语言在IBM和苹果的机器上分别得到了实现。一个实现将P-code程序翻译成IBMPC所用CPU的Intel x86指令集,另一个实现则将相同的P-code程序翻译成苹果电脑当时使用的Motorola 68000指令集。
这与我们自己的VM语言和VM翻译器的概念非常相似。基本思想是创建一个酷的处理抽象——一台虚拟机,然后在任意多的硬件平台上实现它。在我们的课程中,我们在Hack平台上实现了它。
随后,其他人开发了能生成P-code而非机器语言的Pascal编译器。于是,同一个Pascal程序首次能够不加修改地在IBM和苹果机器上运行,每台机器使用其平台特定的P-code实现。当然,整个方案基于一个假设:用户的计算机需安装合适的、基于客户端的P-code实现。当时,这些P-code实现通过互联网广泛且免费地分发,因此整个方案运行良好。
从历史上看,这或许是高级语言的概念在一个由不同硬件平台和不同机器语言组成的世界中,首次充分发挥其潜力。你可以看到,抽象的虚拟机概念在这个跨平台愿景中扮演了多么核心和 overarching 的角色。
具体来说,VM语言或VM架构提供了一个中间架构或中间层,它将目标硬件的许多复杂细节对高级语言隐藏起来。当我们在本课程后续的一些模块中着手开发编译器时,这一点将变得至关重要。
我们的虚拟机与Java虚拟机(JVM)有多接近?
Java的虚拟机同样基于一种中间语言,在Java中称为字节码。显然,我们的VM语言和字节码之间存在不少技术差异。
例如,我们的语言只识别一种数据类型:16位整数。而Java字节码指令设计用于操作整数、浮点数、双精度数等多种其他数据类型,在这方面比我们的VM语言丰富得多。此外,Java字节码提供了我们的VM语言所缺乏的各种高级抽象(如乘法和除法)以及低级命令(如位运算等),因此具有更强的表达能力。
然而,在其最本质的要素上,这两种语言非常相似。两种语言都是基于栈的,都使用push和pop操作,并且都通过虚拟内存段而非符号变量来访问内存。
追溯Java语言建立在两层编译模型(即使用虚拟机作为中间层)上的历史动机可能很有趣。Java由一家名为Sun Microsystems的强大公司的员工在20世纪90年代中期开发。当时的商业和办公应用世界完全由微软公司主导。作为微软的主要竞争对手,Sun希望通过主导其他一切来脱颖而出。
为了实现这一主导地位,Sun决定创建一种新的编程语言。用这种语言编写的程序可以通过互联网传输,并在任何可能的移动设备和数字设备上执行,无论是PC、数字电视、冰箱、洗衣机、汽车系统还是烤面包机。Sun希望连接一切,并希望一切都连接到Sun。因此,他们决定将这一愿景建立在一个通用的VM架构上,该架构可以相对容易地在任何给定的硬件平台和数字设备上实现。
如果这听起来与P-code非常相似,那么你完全正确。这是相同的问题——跨平台兼容性,以及相同的VM解决方案思路,只是规模更大、更具世界级的雄心。其基本思想(至今仍然盛行)是:Java编译器首先生成字节码,然后这些字节码可以通过互联网部署到任何连接的设备。一旦到达目的地,客户端将有一个JVM或VM实现,通过将VM代码翻译成设备的机器语言来执行代码,就像我们用自己的VM翻译器所做的那样。
回到90年代中期,Sun的这些崇高想法在历史上是超前的,Java的起步有些缓慢。但随后发生了完全意想不到的事情:手机出现了。世界迅速被众多使用不同处理器、芯片组和操作系统的手机型号所淹没。于是,Sun关于在不同设备上运行相同软件的Java愿景突然变得极具现实意义,并且发生在一个新的领域——手机。这个领域是Sun、微软或英特尔都未曾预料到的。
因此,在大约延迟了10年之后,Java突然成为了在正确时间出现的正确事物。难怪它也迅速成为为移动设备开发应用程序的首选语言,直到现在,Java仍然是Android领域最流行的编程语言。
这一切都表明,如果你提出了一个优雅而简洁的想法(在我们的案例中,是一个抽象的、硬件无关的虚拟机),那么,在某个时刻,总会有人(可能是你,也可能是其他人)将这个想法拾起,并将其转化为极其有用和实用的技术。一个好的想法自有其生命力,无可阻挡。
关于效率问题,我们需要注意什么?
在本模块中,我们的约定是开发一个VM翻译器,而不要求生成的机器代码紧凑或高效。显然,这是一个非常严重的疏忽,因为必须认识到,VM翻译器是一个关键任务程序,它以某种方式存在于大多数PC、平板电脑和手机的核心。它可能被称为JVM、KVM、ART或CLR,但归根结底,它是一个VM翻译器。因此,如果你的VM翻译器生成低效且冗长的代码,那么运行在其上的所有应用程序都会变得迟缓,用户会感到沮丧。
在现实中,VM翻译器的开发者会非常努力地生成尽可能紧凑和高效的低级代码,而这一点在Nand2Tetris项目2中,我们至今完全忽略了。
此外,我想指出,我们在本课程中精心使用的栈架构并非虚拟机的必要组成部分。例如,如今安装在Android设备上的JVM使用另一种称为寄存器机的抽象架构。这种寄存器机可能不如我们自己的栈架构优雅和美观,但可以说,它为移动设备的处理器生成了更优化的代码。在效率方面,关键任务软件层的开发者非常务实,他们会使用任何工作得更好、更快的东西。

本节课中,我们一起学习了虚拟机概念的历史渊源,了解了它与Java虚拟机(JVM)的异同,并认识到在实际开发中,代码生成效率是虚拟机实现不可忽视的关键因素。这些视角帮助我们更好地理解抽象计算模型在连接软件与硬件、实现跨平台兼容性方面的核心价值。我们将在下一个模块中继续完善我们的虚拟机抽象与实现。
024:程序控制 🎮

在本节课中,我们将学习程序控制的核心概念,包括分支和函数调用。这些概念使得程序能够根据条件改变执行路径,并支持代码的模块化与重用。
上一模块我们处理了算术逻辑命令和内存段命令。这些命令提供了有限的编程视角,程序执行路径是预先确定且线性的。本节中,我们将探讨程序控制的概念,通过if命令、goto命令和函数调用命令,程序的执行流将能够根据条件进行转向。
从示例开始
以下是一个高中数学中常见的数学表达式:
B² - 4AC
同一个表达式可以用高级语言表示如下:
B * B - 4 * A * C
高级语言的表达方式几乎与实际数学表达式一样直观。这正是高级语言的优势所在,它允许程序员以接近人类思维的方式编写程序。
这个表达式在数学的某些分支中应用广泛。因此,我们可以将其重构为一个函数,以便在程序中多次使用:
def disc(A, B, C):
return B * B - 4 * A * C
我们称这个函数为disc(判别式),这是该表达式在数学中的名称。
抽象与扩展
从编程理论的角度看,square root、power和disc都是抽象结构。它们并非基础语言的一部分。无论是Java还是Python,基础语言的语法和命令集都是有限的。然而,程序员可以根据需要发明新的命令,这非常强大。每当需要新功能时,就可以创建它,就像我们刚才创建disc函数一样。
随之而来的问题是:这些函数的实现由谁负责?这种做法的美妙之处在于,实现细节与使用细节是分离的。我可以创建新的抽象,并假设在某个时刻会有人实现它们。这个“某人”可能是我自己(在之后的时间),也可能是团队中的其他成员。关键在于,我可以在函数实现之前就开始使用它们,甚至可以假设它们会返回某个值来进行程序调试。这是一种非常优雅的实践方式。
核心观点是:基础语言可以按需无限扩展。基础语言本身非常有限,但通过这些扩展,语言可以变得无比复杂。赋予程序员扩展语言功能的能力,这或许是高级语言乃至整个编程领域中最伟大的思想之一。
程序控制的另一个方面:分支
除了函数,程序控制的另一个重要方面是分支。以计算判别式为例,出于某些数学原因,我们可能需要先检查a是否不等于0,然后才进行计算,否则执行其他操作。此处的逻辑本身并不重要。
如果我将这段代码交给编译器,编译器会生成类似以下的虚拟机(VM)代码:
if a != 0 goto COMPUTE
goto ELSE
label COMPUTE
...
label ELSE
...
编译器如何实现这种转换,将在第四和第五模块中详细讨论,目前无需担心。但观察生成的代码,我们会发现一系列之前未见过的命令。
这些新命令主要分为两类:
- 分支命令:包括
goto、if-goto和label。 - 函数命令:包括
call、function和return。
本模块的学习目标
本模块中,我们将从两个不同视角探讨这些命令:
- 理解抽象:学习如何在虚拟机语言中进行分支、函数调用和返回。这对于大多数学习者可能是全新的,因为我们需要了解在虚拟机层面实现这些功能的规则。
- 学习实现:学习如何实际实现这些功能,使其真正工作并产生效果。
抽象和实现是两个截然不同但又紧密相关的方面。
关键知识点
在学习过程中,我们将掌握一些非常重要的知识点:
- 再次深入学习分支和函数命令的抽象与实现,理解它们的工作原理。
- 我们将花大量时间剖析函数调用与返回的“舞蹈”,这是一个在幕后发生的复杂而迷人的过程。
- 在此过程中,计算机需要为参与“舞蹈”的主要角色——即相互调用和返回的函数——动态分配内存。
- 我们将继续讨论栈处理,并且会比之前更加深入。
- 我们显然还需要处理指针。
- 最后,我们将完成一个非常有趣的编程作业:完整的虚拟机实现。
总而言之,本单元结束后,你将完全理解函数如何安全地相互调用和返回,并完成一个极具收获感的编程作业。
接下来,让我们进入下一节,详细讨论分支。🚀

本节课中,我们一起学习了程序控制的基本概念,包括通过函数实现代码抽象与扩展,以及通过分支命令改变程序执行流。我们明确了本模块将从抽象和实现两个角度,深入学习分支与函数调用的机制,为后续构建完整的虚拟机打下基础。
025:分支结构 🧭

在本节课中,我们将要学习虚拟机语言中的分支结构。分支结构允许程序改变其线性的执行流程,是实现条件判断和循环等复杂逻辑的基础。
在之前的模块中,我们处理了算术逻辑命令和内存段命令。现在,我们开始处理分支命令。
分支结构概述 🔄
分支是一个相对容易理解的概念。在没有分支的情况下,我们的程序是完全线性的,每个命令都在前一个命令之后顺序执行。而有了分支,我们可以在程序的控制流中引入各种非线性路径。程序可以线性执行,然后突然遇到一个 goto 指令,跳转到其他指令继续执行。goto 指令可以向前跳转,也可以向后跳转。通过向前和向后的跳转,我们就能实现循环。因此,分支结构为我们提供了极大的灵活性。
任何使用过 if 语句或 while 语句的人,都体验过分支的概念,只是可能没有直接称之为“分支”。
在底层,我们有两种类型的分支:无条件分支和条件分支。接下来,我们将花几分钟时间讨论这两种分支,并从示例开始。
一个高级语言示例 📝
以下是一个高级语言函数的示例,它通过重复加法来计算两个给定值 X 和 Y 的乘积。
function multiply(x, y) {
sum = x
n = 1
while (n <= y) {
sum = sum + x
n = n + 1
}
return sum
}
我们将 X 放入一个名为 sum 的容器中,然后将 sum 累加 X 共 Y 次。为了计数 Y 次,我们使用另一个名为 n 的变量,它从1开始。这段代码非常直观。
需要指出的是,这种乘法实现方式非常朴素且低效。在后续课程中,我们将展示效率高得多的乘法实现。
编译为虚拟机代码 ⚙️
当我们把这个高级代码编译成虚拟机代码时,编译器会生成你在右侧看到的代码。编译器所做的是将这个 while 循环用 goto 命令重写。为了实现这种重写,编译器还必须创建并生成一些标签,并将它们插入到正确的位置。一个标签放在 while 循环开始的地方,另一个标签放在 while 循环结束的地方。编译器知道如何完成这一切。
正如在前一单元所说,目前你无需担心编译器如何实现这种“魔法”,因为我们将在课程后期深入探讨。现在,我们只需关注虚拟机代码,看看这些 goto 命令是如何被使用的。
无条件分支命令:goto 🚀
让我们聚焦于 goto 命令。在上面的代码中,我们看到了几个 goto 的例子。其语法非常简单:
goto LABEL
你期望计算机跳转去执行紧跟在 LABEL 标签后的那条指令。例如,如果你说 goto LOOP,那么下一条将被执行的命令就是 push n,因为那是紧跟在标签 LOOP 之后的命令。
条件分支命令:if-goto ⚖️
条件分支更有趣,因为它依赖于对某个条件真值的评估。回到高级代码,我们看到那里有一个条件:while (n <= y)。在生成的虚拟机代码中,我们看到了命令 if-goto END_LOOP。
需要注意的是,每当我们要使用条件 goto 命令时,我们必须首先将一个描述该条件的表达式压入堆栈。这个表达式必须在执行 if-goto 之前被求值,否则我们将无法知道条件是否满足。
规则是:先压入表达式,当程序运行时,该表达式将被求值,一个特定的真值将保留在堆栈顶部。然后,基于这个真值,if-goto 命令将知道跳转是否应该实际发生。
在这个具体例子中,高级代码的条件是 n <= y。在生成的代码中,我们看到在 if-goto 命令之前有 push n、push y 和 gt 命令。这三个虚拟机命令组合在一起,在虚拟机语言中实现了与 n <= y 相同的逻辑。一旦计算机求值了 push n、push y 和 gt,就会得到一个真值,该值将决定条件跳转是否应该发生。
标签命令:label 🏷️
除了 goto 和 if-goto,我们还有 label 命令。没有它,我们就无法生成所有这些标签。label 命令的语法同样简单:
label LABEL_NAME
它只是标记了代码中的一个位置,供分支命令跳转使用。
实现与翻译 🛠️
这三个分支命令(goto、if-goto、label)的实现方式是:当虚拟机翻译器被要求实现或“实现”这些命令时,它必须生成能在汇编语言中产生相同跳转操作的汇编代码。
如果你按照课程模块0的要求复习了汇编语言,你应该知道这个实现应该极其简单。因为汇编语言也有 goto 命令,其执行风格和方式与虚拟机语言的 goto 命令几乎相同。因此,在这种情况下,将虚拟机分支命令翻译成汇编分支命令是一个相当简单的练习,我们期望你能独立完成。
总结 📚
本节课中,我们一起学习了虚拟机语言中的分支结构。
- 我们首先了解了分支的概念,它使程序能够非线性执行。
- 我们通过一个乘法函数的例子,看到了高级循环结构如何被编译成基于
goto的虚拟机代码。 - 我们详细介绍了三种分支命令:
goto LABEL:实现无条件跳转。if-goto LABEL:根据栈顶的真值(非零为真)决定是否跳转,使用前需将条件表达式的结果压栈。label LABEL_NAME:在代码中定义一个标签,作为跳转的目标。
- 最后,我们了解到将这些虚拟机分支命令翻译成汇编语言是直接且简单的,因为汇编层面存在类似的分支指令。

在之前的模块中,我们处理了算术和内存访问命令。在当前单元,我们处理了这些分支命令。现在,我们已经准备好开始学习本模块的真正核心:函数命令。这将是我们下一单元的内容。
026:函数抽象 🧩

在本单元中,我们将开始讨论函数,并从函数的抽象视角入手。这意味着,如果你是一名虚拟机程序员,你将如何在代码中使用函数。
概述
众所周知,高级语言可以通过子程序、函数、过程、方法等来扩展功能。你可能好奇这些名称的来源。实际上,不同的编程语言使用不同的术语来描述我们将要讨论的“函数”。我们无需深究这一点,因为每个人都理解函数是什么:它就像一个黑盒,你给它一些参数,按下按钮,函数执行某些操作并返回一个值。这就是函数。同样,由于历史原因,不同语言用不同的名称来称呼这些函数。
现在,转到我们的虚拟机语言。我们已经处理了之前看到的所有命令,现在终于要开始讨论如何使用以及如何实现函数的概念了。
函数的作用示例
高级代码可能会包含类似 sqrt(x - 17 + x * 5) 的表达式。当编译器翻译这段代码时,会产生以下伪虚拟机代码。
首先,我们必须计算括号内的表达式。具体做法是:push x,push 17,subtract。这将得到 x - 17 的结果。接着,push x,push 5,然后执行一个你尚未见过的操作:call Math.multiply。
Math.multiply 是一个函数,它位于名为 Math 的类中,该类是我们虚拟机所依托的操作系统的一部分。因此,我们的虚拟机被允许与这个操作系统交互。由于乘法不是虚拟机语言的内置操作,我们很幸运地拥有一个知道如何计算两个数字乘积的 multiply 方法。我们利用了这个函数的服务。
multiply 将对 x 和 5 进行操作。根据规则,它将用 x * 5 的结果替换栈上的 x 和 5。然后,我可以将乘法的结果与 x 的值相加。接着,我可以调用 square root 函数,它同样是宿主操作系统 Math 库的一部分,依此类推。
虚拟机语言的操作类型
我们可以看到,虚拟机语言具有两种类型的操作符。首先是语言内置的固定操作,如 add、subtract 等,但这类操作的数量有限。如果你想使用其他操作,要么使用别人已经写好的函数(如 Math.multiply 和 Math.sqrt),要么可以编写自己的函数。
在这方面,你拥有极大的自由,可以随心所欲地创建和使用任意数量的函数。从这个意义上说,虚拟机语言是无限的,你可以随意扩展它,这非常了不起。
一个重要观察
应用像 subtract 这样的原始操作符,与应用像 Math.multiply 或 Math.sqrt 这样的抽象函数,具有完全相同的外观和感觉。
在这两种情况下,游戏规则如下,理解这一点非常重要:
如果你想调用一个期望接收三个参数的函数,那么你需要这样做:
- 将这三个参数压入栈。
- 调用该函数。
- 然后,一些“魔法”会发生。
- 函数的结果将替换你之前压入栈的这三个值。你压入的参数值会消失,取而代之的是该函数的返回值。
如果你按照这个逻辑分析之前的例子,会发现它完全合理。我建议你现在暂停视频,拿出一张白纸,画一个空栈,然后逐步执行每条命令,观察栈如何增长和收缩,直到最终得到正确的计算结果。你可以假设 x 的值以使过程更简单,但这并不重要。重要的是理解:调用函数和调用内置命令具有完全相同的外观和感觉。这是语言中一个非常优雅的特性。
如何在虚拟机语言中定义函数?
以下是一个高级程序中的函数源代码视图。我们选择重新审视之前模块中使用的乘法函数,这是我们自己的乘法实现。我们将绕过操作系统的 Math.multiply 方法,转而使用这个,因为我想让你看到代码。重申一个与当前讨论完全无关的评论:这个 multiply 实现非常朴素且低效,而 Math.multiply 是一个非常复杂且高效的操作,我们将在本课程最后一个模块开发操作系统时讨论它。
编译器开始工作,将这个高级代码翻译成伪虚拟机代码。你在右侧看到的是最终将实际生成的伪虚拟机代码。我展示它只是为了方便讨论示例,但如你所知,虚拟机程序中没有符号变量,所以真实情况显示在右侧。
这里有一个技术性的观察:函数定义语法以关键字 function 开始,然后是函数名 mult,接着是一个整数(0、1、2 或其他非负整数)。这个整数告知实现,该函数将使用多少个局部变量。因此,function mult 2 意味着这里开始一个名为 mult 的函数,它将使用两个局部变量。为什么需要提及局部变量的数量,我们将在下一个单元(而非本单元)理解。
代码示例与运行时模拟
我在这里重复了之前看到的 mult 函数的虚拟机版本,并添加了行号以便参考。我还想引入一些展示如何使用这个 mult 函数的代码。
以下是一个名为 main 的函数,它在某个时刻包含了命令 call mult 2。注意语法 call mult 2。这个语法意味着:我将调用一个函数,我想调用的函数名是 mult,随后的数字告知在发起此调用之前有多少个参数被压入了栈。
注意虚拟机程序员在这里做了什么:程序员压入了常数 8,然后压入了常数 5,接着调用了 mult。因此,虚拟机实现知道,我们不仅需要调用 mult 函数,还必须将参数 8 和 5 传递给它,因为我们被告知程序员压入了两个参数到栈上。
在函数调用术语中,调用函数有时被称为“调用者”,被调用函数有时被称为“被调用者”。我将在后续示例中交替使用这些术语。
接下来,我想通过一个运行时模拟来具体说明这段代码是如何实际执行的。
让我们从 main 函数的视角开始。我将在几个关键点暂停这个想象的模拟。
执行完第 3 行后会发生什么?让我们看看第 1、2、3 行的代码:我压入了 3、8 和 5。因此,栈将包含 3、8 和 5。然后我们开始执行命令 call mult 2。
这是一个相当戏剧性的转折,因为 main 函数说我现在必须调用另一个函数。因此,我们必须将 main 函数挂起,转去执行这个名为 mult 的其他函数。
以下是 mult 函数的视图。让我们看看执行完第 0 行后会发生什么。首先,我得到一个空栈(这里的“我”指代当前的 mult 函数)。其次,我得到一个参数段,注意这个参数段包含了调用者压入的那两个确切值。我不仅能看见这些值,还知道如何称呼它们:在我的世界里,它们被称为 argument 0 和 argument 1。第三,我得到两个初始化为 0 的局部变量。为什么是两个?因为看函数声明命令:function mult 2,所以虚拟机实现为我准备了一个名为 local 的段,其中有两个初始化为 0 的局部变量。
继续,执行完第 7 行后会发生什么?让我们逐步执行从 0 到 7 的所有命令。我们执行 push constant 0,将 0 压栈,然后 pop local 0。这不太有趣,因为 local 0 之前就是 0,所以它保持为 0。接着,我们将 1 压栈并弹出到 local 1,所以局部变量段将变成 0 和 1。然后有一个标签 loop。接着,将 local 1 压栈(实际上在此之前,参数段保持不变,因为我们没有动它)。现在回到第 6 条命令,我们将 local 1 压栈,然后将 argument 1 压栈。我们将得到数字 1 和 5。
然后我们进入整个循环,计算两个给定数字(即参数 8 和 5)的乘积,得到 40(相信我,我检查过这段代码)。根据上一张幻灯片所示的代码,乘积被累加到 local 0 中。
那么执行完第 20 行后会发生什么?现在我们已经在这个计算的末尾。local 0 包含乘积 40。local 1 包含我们在高级代码中称为 n 的值(你需要回到上一张幻灯片查看)。当 n 超过第二个参数(即 5)时我们停止。所以 local 1 将是 6,这就是我们知道停止代码的方式。现在栈包含 40,因为我们在第 20 条指令执行了 push local 0,这条指令获取 local 0 的值并将其放入栈中。
我们这样做是因为我们想将这个值返回给调用者。确实,下一条命令是 return。虚拟机实现知道如何处理 return。具体来说,当我们说 return 时,虚拟机实现会获取被调用者栈顶的值,并执行以下技巧:它取出这个值,并将其放入调用者的栈上,替换之前压入的参数。
现在我们回到 main 函数,mult 函数已经消失,其所有内存资源都已被回收(这个过程我们尚未看到)。回到 main 的视图,我得到了这个结果:我压入了 8 和 5,调用了 mult,得到了 40,这正是我期望得到的。然后在 main 的第 5 行,我将这两个数字相加,得到 43。确实非常棒。
注意 main 的抽象视图与实际发生的情况之间的区别:main 压入两个参数,说“相乘”,然后得到了结果,接着继续计算,仿佛什么都没发生。但实际上,幕后发生了很多事情。
实现视角:如何让抽象指令实际工作?
我们在这里看到的代码与之前完全相同。现在我想从实现的视角来看:为了让这个“魔法”生效,我们必须做些什么?让我们从 call 开始,故事就从这里开始。
调用函数是我们正在处理的一个重大事件。在运行时,实现必须为每个函数调用执行以下操作:
以下是实现必须为每次函数调用执行的操作:
- 传递参数:必须以某种方式将参数(也称为实参)从调用者传递给被调用者。我们必须完成这个“握手”。
- 确定返回地址:因为想想看,我们现在要开始一段新的冒险。当这段冒险完成时,我们必须准确地返回到调用者代码中的下一条指令。所以我们必须以某种方式记住这个地址,即记住返回的位置。
- 保存返回地址:当我们说“记住”时,它没有实际意义,我们必须将其保存在内存中的某个地方。
- 保存调用者的状态:状态包括调用者的工作栈、内存段等所有内容。必须保存这一切,以便当我们返回到调用者代码时,能够重新创建
main的私有世界,并像什么都没发生一样继续执行。 - 跳转执行被调用函数:只有在保存了调用者状态(包括返回地址)之后,我们才能允许自己跳转去执行被调用函数。
所有这一切都必须发生,才能实现和服务一个 call 命令。
那么 return 呢?return 也相当复杂。因为作为实现,每当需要执行 return 命令时,我必须做以下事情:
以下是实现必须为每次 return 执行的操作:
- 返回计算值:必须将被调用函数计算出的值返回给调用者。这里有一个隐含的假设,我现在将其明确:要求被调用函数在返回前总是压入一个值。这是我们游戏规则的一部分:在
return之前,必须将一个值压入栈,这就是返回值。因此实现知道栈顶的值必须是返回值,它知道必须以某种方式将这个值传回给调用者。 - 处理返回值与参数:实际上,实现需要做的是:取出这个返回值,并将其压入调用者的栈顶。但在压入之前,它必须移除参数,正如我之前解释的那样。这甚至比听起来更复杂。
- 回收内存资源:在完成返回值处理后,因为实现是一个“好公民”,它必须回收被调用函数使用的内存段、内存资源、栈等所有“东西”,因为被调用函数现在已经无关紧要了。
- 恢复调用者世界:然后,我们必须恢复或重新创建调用者的世界,包括其保存的工作栈和内存段。
- 跳转返回地址:只有完成这些之后,我们才能跳转到调用者代码中的返回地址,并像什么都没发生一样继续执行。
这就是实现的视角。在接下来的三个单元中,我们将真正让这个实现工作起来。
总结

在本节课中,我们一起学习了函数的抽象概念及其在虚拟机语言中的应用。我们探讨了函数如何作为黑盒操作,接收参数并返回结果。通过具体示例,我们观察了函数调用在虚拟机代码中的表现,包括参数传递、局部变量初始化以及返回值处理。我们还从实现者的角度,剖析了支持函数调用和返回所需的关键步骤,如保存状态、确定返回地址和回收资源。理解这些抽象概念是后续实际实现虚拟机函数处理机制的基础。
027:函数调用与返回实现预览 🧠

在本节课中,我们将要学习函数调用与返回协议是如何在虚拟机层面实现的。这是一个复杂但非常精妙的过程,我们将通过预览、模拟和具体实现三个步骤来深入理解。本节作为预览,将介绍核心概念和整体框架。
函数执行与调用链
上一节我们介绍了虚拟机的基本操作,本节中我们来看看如何管理多个函数的执行。一个计算机程序通常由多个函数(或方法)组成。在程序运行的任何时刻,只有一小部分函数处于活动状态。例如,函数 P 调用 bar,bar 调用 sqrt,sqrt 又可能调用其他函数。我们将这一系列正在执行的函数称为 调用链。
从实现的角度看,链上的每个函数都有自己的 状态。这个状态包括函数的 工作栈 和 内存段(如局部变量段、参数段等)。当函数开始执行时,我们需要为其创建这个状态。当函数调用另一个函数时,必须保存当前函数的状态,以便被调用函数返回后能恢复执行。当函数返回时,其状态可以被回收,以释放内存。
状态管理与栈数据结构
那么,我们如何保存调用链上所有函数的状态呢?关键在于观察到一个模式:函数调用遵循 后进先出 的原则。只有当前正在执行的函数(调用链末端的函数)可以返回。其他函数都在等待它返回。调用链会像这样增长和缩短:
P 调用 bar -> bar 调用 sqrt -> sqrt 返回 -> bar 返回 -> P 继续...
这种模式是否让你想起了什么?没错,这正是 栈 的行为!因此,我们可以利用栈数据结构来保存和恢复所有函数的状态。接下来,我们将说明如何巧妙地使用栈来实现这一点。
函数调用过程详解
让我们通过一个具体例子来理解:计算 17 和 212 的乘积。
从调用者的抽象视角看,过程很简单:
- 将参数
17和212压入栈。 - 执行
call Mult2 2(2表示参数数量)。 - 栈顶的结果就是乘积。
然而,虚拟机实现需要处理大量幕后工作。以下是详细步骤:
1. 处理 call 命令
当虚拟机遇到 call functionName nArgs 命令时,它知道已有 nArgs 个参数被压入栈。此时,虚拟机需要:
- 设置参数指针:将
ARG段指针指向栈中参数区的基地址。 - 保存调用者状态(帧):调用者的状态,即其 帧,包括:
- 返回地址(调用结束后应返回的代码位置)。
- 关键内存段的基地址:
LCL、ARG、THIS、THAT。 - (注意:
CONSTANT、TEMP、POINTER、STATIC段无需保存,它们不属于单个函数的私有世界)。
- 将这些帧信息(返回地址和段指针)压入栈。
- 跳转执行:最后,跳转到被调用函数
functionName的代码开始执行。
2. 被调用函数初始化
被调用函数代码的第一条命令通常是 function functionName nVars,声明其需要的局部变量数量 nVars。虚拟机需要:
- 初始化局部变量段:在栈上分配
nVars个位置,并将其初始化为0。 - 设置局部变量指针:将
LCL段指针指向这个新区块的基地址。 - 此后,函数可以开始执行,使用自己的局部变量段并操作自己的工作栈。
3. 处理 return 命令
当被调用函数准备返回时:
- 它将返回值压入栈顶。
- 执行
return命令。
虚拟机处理 return 命令的步骤是:
- 传递返回值:将栈顶的返回值复制到调用者栈帧中
ARG[0]的位置。这样,返回值就替换了原来的第一个参数,符合“用函数返回值替换其参数”的抽象效果。 - 恢复调用者状态:
- 从栈中弹出并恢复调用者保存的
THAT、THIS、ARG、LCL段指针。 - 将栈指针
SP设置到返回值之后的位置(即ARG[0]之后),这实际上是调用者工作栈的顶部。
- 从栈中弹出并恢复调用者保存的
- 清除被调用者栈帧:被调用函数的局部变量和工作栈区域现在已不再需要。
- 跳转返回:最后,跳转回之前保存的返回地址,调用者从其暂停处继续执行。
全局栈与函数块
通过上述机制,整个程序运行时的所有信息都保存在一个 全局栈 中。这个栈不仅包含当前函数的数据,还包含整个调用链上所有函数的“世界”。
我们可以将全局栈划分为多个 块,每个块代表一个函数的活动记录(激活记录)。对于当前正在运行的函数,其块包含:
- 参数段(由调用者设置)。
- 被保存的调用者帧信息(对当前函数透明)。
- 局部变量段。
- 自己的工作栈。
全局栈则按顺序包含了调用链上所有函数的块。这种设计非常精妙:同一个栈数据结构,既用于执行程序的所有运算和逻辑操作,也用于支撑整个程序运行所需的幕后管理机制。
总结与展望
本节课中我们一起学习了函数调用与返回协议的实现预览。
- 抽象视角:调用函数非常简单:推送参数,执行
call,获取结果。 - 实现视角:幕后需要大量复杂工作来管理状态、保存帧、初始化内存、传递返回值并恢复现场。这就像魔法背后的大量工程。
正如亚瑟·C·克拉克所说:“任何足够先进的技术都与魔法无异。” 从抽象层面看,函数调用就像魔法;而从实现层面看,则是大量精巧工作的结果。

在接下来的两个单元中,我们将继续深入探讨这些幕后工作,具体模拟这一过程并完成函数调用与返回协议的具体实现。
028:函数调用与返回运行时模拟 🧠

在本节课中,我们将通过一个具体的例子——递归计算阶乘,来模拟函数调用与返回协议在程序运行时的具体过程。我们将看到高级语言代码如何被编译成虚拟机指令,以及这些指令在栈上如何执行,从而理解函数调用背后的机制。
上一节我们介绍了函数调用与返回协议的基本概念,本节中我们来看看这个协议在实际程序运行中是如何工作的。
编译时:从高级语言到虚拟机代码
为了理解运行时行为,我们首先需要知道编译器如何将高级语言代码(如C或Java)翻译成虚拟机指令。我们以计算 factorial(3) 为例。
以下是阶乘函数的源代码:
int factorial(int n) {
if (n == 1) {
return 1;
} else {
return n * factorial(n - 1);
}
}
编译器会将其编译成以下虚拟机代码。我们首先编译主函数 main,它需要计算 factorial(3)。
以下是 main 函数对应的虚拟机代码:
push constant 3
call factorial 1
return
接下来,我们编译 factorial 函数本身。
以下是 factorial 函数对应的虚拟机代码:
function factorial 0
push argument 0
push constant 1
eq
if-goto BASE_CASE
push argument 0
push argument 0
push constant 1
sub
call factorial 1
call mult 2
return
label BASE_CASE
push constant 1
return
代码解析:
function factorial 0声明函数并指明有0个局部变量。push argument 0和push constant 1将参数n和常数1压入栈。eq指令比较这两个值是否相等,结果(真或假)会留在栈顶。if-goto BASE_CASE如果比较结果为真(即n==1),则跳转到标签BASE_CASE处执行,这是递归的基本情况。- 如果不相等,则执行
else部分:再次将n压栈,然后计算n-1并压栈,接着递归调用factorial,最后调用乘法函数mult计算n * factorial(n-1)。 - 标签
BASE_CASE标记了基本情况的开始:将返回值1压栈,然后返回。
值得注意的是,这种两阶段编译(从高级语言到虚拟机代码)非常优雅。源程序可能只有10条语句,而目标虚拟机代码可能只有20条左右,这使得编译过程可控且高效。我们将在后续的第4和第5单元详细讲解编译器本身的工作原理。
运行时:栈上的执行模拟
现在,让我们进入核心部分,模拟上述虚拟机代码在运行时栈上的执行过程。程序从 main 函数开始。
第一步:main 函数调用 factorial(3)
main函数开始执行,它被告知有0个局部变量。- 栈初始为空。
push constant 3指令将常数3压入栈。 - 接着执行
call factorial 1。这条指令告知系统:已有一个参数(即数字3)被压入栈,可以将其作为argument 0引用。 - 处理
call指令时,运行时系统需要保存当前函数(main)的帧状态(包括返回地址)到栈上。返回地址指向call指令之后的下一条指令(即return)。 - 保存完毕后,程序跳转到
factorial函数的代码开始执行。
第二步:执行 factorial(3) 的实例
- 进入
factorial函数,它被告知有0个局部变量,因此栈帧无需为局部变量分配空间。 - 执行
push argument 0(值为3)和push constant 1,然后执行eq比较。由于3 != 1,比较结果为假,if-goto跳转不会发生。eq指令会消耗栈顶的两个值(3和1),因此这几条指令对栈的净影响为零。 - 程序继续执行
else部分:push argument 0:再次将3压栈。push argument 0和push constant 1以及sub:计算3-1,得到2并压栈。现在栈上有两个值:3(第一个乘数)和2(即将递归调用的参数)。call factorial 1:再次调用factorial,并告知有一个参数(值2)在栈上。
第三步:递归调用 factorial(2) 和 factorial(1)
- 处理
call factorial 1指令时,系统保存当前factorial函数实例的帧状态和返回地址(指向乘法调用call mult 2的位置),然后跳转。 - 类似地执行
factorial(2)。比较2 == 1为假,继续else部分,计算2-1=1,然后call factorial 1。 - 进入
factorial(1)的实例。比较1 == 1为真,因此if-goto发生跳转,直接来到BASE_CASE标签处。 - 在基本情况中,执行
push constant 1,将返回值1压栈,然后遇到return指令。
第四步:处理返回(Return Protocol)
这是模拟中第一次遇到 return 指令,协议开始发挥作用。
- 获取返回地址:系统从保存的帧信息中弹出返回地址(对应
factorial(1)调用后的位置)。 - 复制返回值:系统知道栈顶的值(我们刚刚压入的1)就是函数的返回值。它将该值复制到专用的返回值寄存器或位置(例如
ARG[0]所指向的调用者栈帧中的特定位置)。 - 清理栈帧:当前函数实例的栈帧(包括局部变量、保存的状态等)被回收,栈指针调整。
- 跳转:程序计数器跳转到之前获取的返回地址,继续执行。
第五步:回溯与计算
- 程序返回到
factorial(2)实例中call factorial 1之后的位置,即准备执行call mult 2。 call mult 2指令调用乘法函数,计算2 * 1(factorial(2)中的n是2,递归调用factorial(1)的返回值是1)。我们省略乘法函数内部的详细栈操作,直接记录结果2被放回栈顶,取代原来的两个操作数。factorial(2)紧接着执行return。重复返回协议:获取返回地址(指向factorial(3)实例中的call mult 2),复制返回值2,清理栈帧,跳转。- 程序返回到
factorial(3)实例,执行call mult 2,计算3 * 2,得到结果6。 - 最后,
factorial(3)执行return,将返回值6复制回main函数栈帧的指定位置,清理栈帧,并跳转回main函数中call factorial 1之后的位置。 main函数获得返回值6,然后执行自己的return指令(或进行其他操作)。
总结与展望
本节课中我们一起学习了函数调用与返回协议的运行时模拟。我们追踪了一个递归阶乘函数 factorial(3) 的完整执行过程,从 main 函数的调用开始,经过多次递归调用、栈帧的保存与增长,到触发基本情况后,再通过返回协议一步步回溯、计算并清理栈帧,最终将结果6返回给调用者。
这个过程清晰地展示了:
- 栈的增长与收缩:每次函数调用都会压入新的帧,每次返回都会弹出该帧。
- 返回协议的关键步骤:获取地址、复制返回值、清理帧、跳转。
- 递归的实现机制:正是通过这种标准的调用/返回协议和栈管理,递归才得以实现。

尽管背后的栈操作非常复杂,但对于高级语言程序员来说,这一切都是透明的。他们只需简单地写下 factorial(3),就能得到结果6。在下一单元,我们将最终动手用代码实现这个函数调用与返回协议。你会发现,实现它的代码本身可能出乎意料地简洁明了。
029:函数调用与返回实现 🧩

在本节课中,我们将学习如何实现虚拟机(VM)语言中的函数调用与返回机制。我们将从调用者、被调用者和实现者三个视角,详细解析函数调用的协议,并了解如何将VM命令翻译成汇编代码。
调用者视角 📞
上一节我们预览了函数调用的流程,本节中我们来看看调用者需要遵守的协议。
在调用另一个函数之前,调用者必须完成以下步骤:
- 推送参数:根据被调用函数的期望,推送相应数量的参数到堆栈上。
- 发起调用:使用
call functionName nArgs命令,其中nArgs是之前推送的参数数量。 - 处理返回值:当被调用函数返回后,调用者之前推送的参数将被一个返回值替换。这个返回值将位于调用者堆栈的顶部。
- 内存状态:函数返回后,除了
temp段未定义以及static段可能被修改外,调用者所有的内存段(local,argument,this,that)都应恢复到调用前的状态。
被调用者视角 🛠️
现在,我们切换到被调用者的视角,看看它在开始执行时需要满足哪些条件。
在被调用函数开始执行时,其运行环境由调用者设置完毕,具体包括:
- 参数段:已用调用者传递的参数值初始化。
- 局部变量段:已初始化,所有局部变量的初始值为0。
- 静态段:已设置为该函数所属VM文件(即原始类)的静态段。
- 指针与临时段:
this、that和temp指针在入口处未定义,函数若需使用,需自行设置合理值。 - 工作堆栈:为空。
- 返回值:在发出
return命令之前,被调用函数必须将一个值推送到堆栈顶部作为返回值。即使是void函数,也需要推送一个值(例如0),调用者可以选择忽略它。
实现者视角 ⚙️
了解了调用双方的协议后,我们来看看作为第三方的VM翻译器如何实现这些命令。VM翻译器的任务是将VM代码逐行翻译成目标平台的汇编代码(本课程中为Hack汇编)。
以下是VM翻译器处理关键命令的逻辑。
处理 call 命令
当VM翻译器遇到 call functionName nArgs 命令时,它需要生成汇编代码来保存当前函数状态、设置新函数的帧并跳转。
以下是需要生成的伪汇编逻辑:
- 生成并推送返回地址标签:创建一个唯一的返回地址标签(如
Foo$ret.1)并推入堆栈。 - 保存调用者状态:将调用者当前的
LCL、ARG、THIS、THAT指针值依次推入堆栈保存。 - 重定位
ARG指针:为被调用函数设置新的ARG指针。其位置应为:当前SP - nArgs - 5。 - 重定位
LCL指针:将LCL设置为当前SP的位置,作为新函数局部变量段的起点。 - 跳转到函数入口:生成
goto functionName指令。 - 注入返回地址标签:在生成的代码流中插入第1步创建的返回地址标签。
处理 function 命令
当VM翻译器遇到 function functionName nVars 命令时,它需要为函数设置执行环境。
以下是需要生成的伪汇编逻辑:
- 生成函数入口标签:在代码中生成标签
(functionName),作为函数的入口点。 - 初始化局部变量:根据
nVars参数,循环nVars次,生成push 0指令,将所有局部变量初始化为0。
处理 return 命令
当VM翻译器遇到 return 命令时,它需要将控制权交还给调用者,并传递返回值。
以下是需要生成的伪汇编逻辑:
- 保存帧尾地址:将当前
LCL的值存入一个临时变量(如endFrame),它指向当前帧的末尾。 - 获取返回地址:从
endFrame - 5的内存位置取出调用前保存的返回地址,存入另一个临时变量(如retAddr)。 - 放置返回值:将堆栈顶部的返回值弹出,并放置到调用者
ARG指针所指向的位置(即调用者视角下的第一个参数位置,现在用于接收返回值)。 - 恢复堆栈指针:将堆栈指针
SP设置为ARG + 1,指向返回值之后的位置。 - 恢复调用者状态:依次从堆栈中恢复调用者的
THAT、THIS、ARG、LCL指针值。它们的地址分别是endFrame-1,endFrame-2,endFrame-3,endFrame-4。 - 跳转返回:使用
goto retAddr指令跳转回调用者的返回地址继续执行。
总结与展望 🎯
本节课中我们一起学习了虚拟机语言中函数调用与返回的完整实现机制。
我们首先从调用者和被调用者的角度明确了双方必须遵守的协议。然后,我们从实现者(VM翻译器)的角度,详细剖析了如何处理 call、function、return 这三个关键命令。我们使用伪代码描述了保存状态、设置新帧、传递返回值以及恢复现场的核心逻辑。
重要的是,我们所描述的方案是与语言和平台无关的通用实现规范。它定义了如何在任何目标硬件上实现我们的VM语言。在本课程中,我们将在接下来的单元中,基于Hack计算机平台,将这些逻辑转化为具体的Hack汇编代码。

至此,我们已经完成了虚拟机语言绝大部分功能的实现。在下一个单元,我们将补充在Hack平台上实现这些功能的具体技术细节。
030:Hack平台上的虚拟机实现 🖥️

在本单元中,我们将讨论如何在Hack计算机平台上实现虚拟机翻译器。这是本模块的一个转折点,因为在此之前,我们并未限定具体的硬件平台,而是以通用术语描述了如何在任何给定平台上实现虚拟机。现在,我们将具体针对Hack计算机平台进行实现。
概述
首先,我们需要了解从高级语言到机器代码的完整翻译流程。这有助于我们理解虚拟机翻译器在整个软件栈中的位置和作用。
整体流程
上一节我们介绍了本单元的目标,本节中我们来看看从Jack程序到Hack汇编代码的完整翻译流程。
我们从一个Jack程序开始。该程序存储在一个或多个.jack文件中,这些文件位于一个特定的目录(或文件夹)中,我们称此目录为MyProgram。接着,我们将Jack编译器应用于整个目录。Jack编译器将生成两个新的.vm文件(例如Foo.vm和Bar.vm)。源程序中的每个方法、构造函数和函数都将被翻译成目标VM代码中的一个VM函数。
这个过程目前无需我们担心,它将在后续构建编译器时详细讨论。但从现在起,VM代码的进一步翻译就是我们的任务了,这正是虚拟机翻译器的职责所在。
虚拟机翻译器将执行一个关键步骤:它将把所有独立的VM函数整合并转换成一个单一的汇编文件。请注意,在从Jack翻译到VM时,我们丢失了构造函数、方法和函数的概念,因为一切都变成了函数。在从VM翻译到汇编时,我们又丢失了函数的概念,现在只剩下一个长长的汇编指令流。我们必须使用汇编语言来捕获源VM代码的语义。
这个虚拟机翻译器正是我们在当前及前一个项目中正在开发的程序。
平台约定与启动
既然我们决定在Hack平台上实现,就必须遵守一些特定的约定。第一个约定是关于系统启动的,即计算机如何知道并执行我们的程序。
以下是需要遵循的假设和约定:
首先,必须有一个VM编程约定:在任何VM程序中,必须有一个文件名为Main.vm,并且该文件中必须有一个名为main的VM函数。Jack编译器会自动处理这一点,因为在Jack语言层面也有类似要求(需要一个名为Main的类,其中包含一个名为main的函数)。
其次,在虚拟机翻译器的实现中,当虚拟机实现开始运行或被重置时,它必须开始执行一个无参数的操作系统函数Sys.init。
因此,VM实现必须从以下代码开始:
call Sys.init
Sys.init函数已在底层实现好,它会去调用Main.main,然后进入一个无限循环。这样,程序员编写的程序就能开始运行。
最后,我们需要遵循硬件平台约定,即所谓的“引导代码”。我们必须确保以下代码被存储在目标计算机指令内存的地址0处。
因为Hack计算机(以及世界上大多数计算机)被设计为从指令内存地址0开始执行程序。计算机启动后做的第一件事就是将SP设置为256,从而确定堆栈的起始地址,然后调用Sys.init。这两件事必须用机器语言(汇编)完成。
此后,虚拟机翻译器将知道如何处理每一条VM命令,一切将顺利运行。
内存映射标准
为了生成符合上述所有解释的代码,我们需要在Hack平台上提出一个标准的内存映射方案。我们在上一个模块中解释了为什么需要标准映射,以下是迄今为止我们所做工作的总结。
我们使用Hack的RAM,并按照以下方案存储所有指针(如SP、LCL、ARG、THIS、THAT):
SP-> RAM[0]LCL-> RAM[1]ARG-> RAM[2]THIS-> RAM[3]THAT-> RAM[4]
现在,让我们在Hack计算机整个RAM的背景下看待这个映射。Hack的RAM总共包含约32,768个字。我们一直讨论的顶部2K字(地址0-2047)是上图中左侧蓝色的部分,用于堆栈、静态变量等。
RAM中还有其他区域,如堆(Heap)、内存映射I/O和一些未使用空间。就虚拟机实现而言,我们只关心RAM顶部的这2K空间,其他区域将用于虚拟机实现管辖范围之外的其他目的。
特殊符号约定
最后,我们需要记录下虚拟机翻译器应使用的所有特殊符号。
首先,在上一个模块中,我们决定使用以下特殊符号来代表指针:
SP,LCL,ARG,THIS,THAT
为了实现在本模块中描述的所有功能,我们还需要使用以下几类特殊符号来生成标签(例如,用于实现函数调用、返回和流程控制)。以下是课程配套书籍中的截图,详细列出了这些约定:
(此处应插入关于$ReturnAddress、$FunctionName等标签生成约定的描述,但根据原文,这部分非常技术性且不展开讲解,因此教程中仅提示读者可自行查阅相关资料。)
现在,任务就是编写虚拟机翻译器,生成实现VM语言所需的所有代码。在下一个单元,我们将描述如何使用如Java或Python这样的实现语言来实际编写这个翻译器。
总结

本节课中,我们一起学习了在Hack平台上实现虚拟机翻译器的关键知识。我们了解了从Jack程序到最终汇编代码的完整翻译流程,掌握了系统启动所必须遵循的硬件与软件约定(如Main.vm、main函数、Sys.init调用和引导代码)。我们还回顾了Hack平台的标准内存映射,明确了虚拟机实现所关心的内存区域。最后,我们知道了实现过程中需要遵循的特殊符号命名约定。这些内容为接下来动手实现虚拟机翻译器奠定了坚实的基础。
031:虚拟机翻译器建议实现方案 🖥️➡️💻

在本节课中,我们将学习如何实现一个完整的虚拟机翻译器。这个翻译器的任务是将虚拟机代码转换为目标平台的汇编代码。我们将基于之前项目中的基础翻译器进行扩展,使其能够处理更复杂的命令和多个源文件。
上一节我们介绍了虚拟机翻译器的基本概念和架构,本节中我们来看看具体的实现方案。
概述
虚拟机翻译器是一个程序,它接收虚拟机代码作为输入,并将其翻译成等价的汇编代码。翻译过程是逐行进行的:它读取第一条命令(例如 push constant 17),生成实现相同语义的汇编代码,然后继续处理下一条命令(如 goto loop),依此类推,直到处理完输入源文件中的所有命令。
我们之前已经编写了一个基础的虚拟机翻译器。在本项目中,我们将扩展这个基础翻译器,使其成为最终的、功能完整的虚拟机翻译器。本质上,我们需要在之前项目编写的程序上增加更多功能。
需要记住的是,我们的源语言是虚拟机语言,而生成的目标代码是 Hack 汇编代码。在整个过程中,我们必须遵守上一单元中描述的那些特殊符号的使用规范。
翻译器架构与流程
以下是描述虚拟机翻译器工作流程的示意图:

在之前的项目中,你编写的翻译器只能处理单个虚拟机文件。而在本项目的一个主要扩展目标,就是让翻译器能够处理多个虚拟机文件。因此,你编写的虚拟机翻译器应该能够处理一个包含一个或多个虚拟机文件的目录(如上图所示)。无论如何,它最终都会生成一个单独的汇编文件,其中包含所有被翻译函数的代码序列。
我在这里概述的建议实现方案,包含的模块与上一个项目完全相同:主模块、代码写入器和解析器。让我们依次描述每一个模块。
模块详解
主模块
主模块接收一个字符串作为输入,这个字符串可以代表一个单独的文件,也可以代表一个目录(或称文件夹)的名称,该目录中可能包含一个或多个虚拟机文件。输入是这两种情况之一。如果你使用 Java 或 Python 这类语言,它们本身具备判断一个给定字符串是文件还是目录的能力。
程序的输出应该是一个文件名以 .asm 结尾的文件,其中包含我们将要生成的汇编代码。
主程序将执行以下过程:
- 首先,它将构造一个代码写入器对象,用于处理输出文件(此时输出文件还是空的)。
- 然后,它会判断输入是文件还是目录。
- 如果输入代表单个文件,它将构造一个解析器来处理这个输入文件。
- 如果输入是一个目录,它将对该目录中的每一个虚拟机文件执行上述操作。
- 接着,它将进入一个循环,遍历输入文件中的每一条命令,对其进行解析,并将词法元素交给代码写入器以生成汇编代码。
如你所见,这并非对上一个项目所做的复杂扩展。
解析器
解析器像往常一样,负责处理或解析单个虚拟机文件。它读取虚拟机命令,将其分解为词法元素,并在此过程中移除所有的空白和注释。这个解析器与我们在上一个项目中开发的解析器完全相同,我们不需要修改它。
然而,我们需要确保解析器也能处理本模块引入的新命令,即分支命令和函数命令。如果你遵循了上一个项目的要求,你的解析器应该已经能够处理这些命令。但如果出于某种原因你还没有实现,现在是时候将它们包含进来了。
代码写入器
代码写入器是需要进行扩展的主要部分。我们在上一个项目中开发的基础代码写入器的 API 如下图所示:

我们需要向这个 API 添加一些额外的功能。以下是需要添加的方法:
setFileName(String fileName):此方法用于通知代码写入器,一个新的虚拟机文件处理即将开始。我们将把该文件的名称作为参数传入。writeInit():此方法负责写入引导代码,这些代码将使虚拟机能够按照虚拟机在 Hack 平台上的标准映射开始工作。writeLabel(String label),writeGoto(String label),writeIf(String label):这三个方法生成汇编代码,分别负责处理虚拟机命令label、goto和if-goto。writeFunction(String functionName, int numLocals),writeCall(String functionName, int numArgs),writeReturn():这三个方法生成汇编代码,分别知道如何处理相应的虚拟机命令function、call和return。
一旦你完成了这些,你将拥有一个功能完整的虚拟机翻译器,并且实现了一个虚拟机。
实现挑战
编写这个虚拟机翻译器最具挑战性的部分,就是编写上述最后六个方法(特别是与函数调用相关的三个)。writeLabel、writeGoto 和 writeIf 这三个方法相对简单,因为生成实现 if 和 goto 的汇编代码并不复杂。
然而,我们必须为 function、call 和 return 生成汇编代码。在实现这部分时,你必须遵循我们在前几个单元中解释的所有逻辑,以构建那个令人惊叹的“堆栈大教堂”。这个堆栈需要上下移动,以实现函数调用和返回协议。

总结
本节课中我们一起学习了最终版虚拟机翻译器的建议实现方案。我们回顾了翻译器的整体工作流程,并详细介绍了主模块、解析器和代码写入器这三个核心组件的职责与扩展点。关键点在于扩展代码写入器,使其能够处理引导代码、程序流控制命令以及完整的函数调用协议。实现函数调用相关的部分是本次项目的核心挑战,需要仔细应用之前所学的堆栈操作和内存映射知识。


本模块到此结束。在下一个模块中,我们将讨论项目材料,并提供一些关于如何实际构建这个虚拟机翻译器的建议。
032:构建虚拟机翻译器第二部分

概述
在本节课中,我们将动手完成虚拟机翻译器的构建。这是本课程的第二个项目,但编号为项目8,以延续《计算机系统要素》第一部分的项目编号体系。我们的目标是构建一个能将虚拟机语言程序翻译成更低级的Hack汇编语言程序的翻译器。这个庞大的开发任务被分为两个独立项目:你已经完成的项目7和我们现在要构建的项目8。每个项目负责处理虚拟机命令的不同子集。本项目的挑战是实现分支和函数命令。
项目8的测试方法
为了测试你的虚拟机翻译器,我们将提供一系列虚拟机程序。你需要使用你的翻译器翻译每一个程序,生成用Hack汇编代码编写的低级程序。然后,我们可以在目标平台上执行这段代码,或者使用更友好的CPU模拟器直接运行汇编代码。如果程序执行结果与虚拟机代码的原始意图一致,则测试通过。我们提供了详细的测试脚本和对比文件,测试过程管理完善,你只需专注于编写功能正确的虚拟机翻译器。
测试程序集
在你的计算机上,如果你已经下载了Nand2Tetris软件套件,可以在项目8文件夹中找到两个目录:ProgramFlow 和 FunctionCalls。每个目录都包含几个子目录。
程序流测试
以下是程序流测试的步骤简介:
-
基本循环测试 (
BasicLoop)
首先,我们建议你启动虚拟机模拟器,使用提供的BasicLoopVME.tst测试脚本加载虚拟机程序并稍作运行。这有助于理解程序意图。接着,使用你的翻译器将其翻译成默认名为BasicLoop.asm的汇编文件。最后,在CPU模拟器中加载并运行BasicLoop.tst脚本,该脚本将执行生成的代码,并将其输出与提供的对比文件进行比较。BasicLoop.vm程序旨在将数字1到argument 0累加,并将结果压入堆栈。其核心逻辑是一个简单的循环,用于测试你的翻译器处理label和if-goto命令的能力。 -
斐波那契数列测试 (
FibonacciSeries)
接下来,我们看看斐波那契数列测试文件。该程序旨在计算斐波那契数列的前argument 0个元素,并将这些值存入从argument 1指定地址开始的RAM中。同样,有一个测试脚本FibonacciSeries.tst会初始化参数并运行翻译后的代码。此程序涉及操作
pointer和that等虚拟内存段,其具体语义目前无需深究。重要的是,它用于测试你的翻译器处理label、goto、if-goto以及许多其他虚拟机命令的能力。
函数调用测试
完成分支命令的测试后,我们现在转向测试处理函数的能力,这通常在实现翻译器时更具挑战性。
-
简单函数测试 (
SimpleFunction)
SimpleFunction.vm程序执行一个简单且无实际意义的计算并返回结果。其测试脚本会初始化内存段并执行代码。这个测试旨在检验你的翻译器处理function和return命令的能力。这是一个相对简单的测试,因为不涉及函数调用。 -
斐波那契元素测试 (
FibonacciElement)
在FibonacciElement目录中,我们首次看到同一个目录下有多个虚拟机文件(.vm)。根据约定,当目录中有多个虚拟机文件时,你的翻译器需要翻译整个目录,并生成一个名为FibonacciElement.asm的单一输出文件。Main.vm包含一个名为Fibonacci的函数,它递归地计算斐波那契数列的第n个元素。Sys.vm包含一个名为Sys.init的函数。按照约定,虚拟机实现启动时应调用Sys.init。在这个测试中,Sys.init被用来调用测试函数(例如,用参数4调用Main.Fibonacci),然后进入无限循环。
这个测试综合检验了
function、return、call命令的处理,以及虚拟机翻译器是否正确初始化和处理内存段(特别是堆栈指针)并调用Sys.init。 -
嵌套调用测试 (
NestedCall)
我们的同事Mark Armbrust指出,在SimpleFunction和FibonacciElement测试之间存在一个过大的测试跨度。因此,他开发了NestedCall测试来填补这一空白。如果你的SimpleFunction测试通过但FibonacciElement失败,建议使用此测试来更好地定位翻译器中的问题。该测试附有Mark编写的详细HTML文档。 -
静态变量测试 (
StaticsTest)
最后一个测试是静态变量测试。同样,它包含多个虚拟机文件,需要翻译整个目录。Class1.vm包含Class1.set和Class1.get函数,操作其静态变量static 0和static 1。Class2.vm包含逻辑几乎相同的Class2.set和Class2.get函数。Sys.vm中的Sys.init依次调用Class1.set、Class2.set、Class1.get和Class2.get。
这个测试旨在确保不同文件的静态段被正确且独立地处理。执行后,堆栈上应得到结果
-2和8(分别对应6-8和23-15)。
所需工具与资源
你需要以下工具和资源来完成项目8:
- 测试程序与对比文件:位于项目目录中。
- 参考书籍:书中第8章以紧凑的形式涵盖了本模块的所有内容。
- 项目7用到的资源:与项目7所需的工具和资源完全相同。
总结

本节课我们一起学习了项目8的具体内容。我们概述了测试虚拟机翻译器的方法,并逐一介绍了用于测试分支命令(BasicLoop, FibonacciSeries)和函数命令(SimpleFunction, FibonacciElement, NestedCall, StaticsTest)的各个程序集及其测试目标。通过完成这些测试,你将最终构建出一个功能完整的虚拟机翻译器。祝你在项目8中顺利成功,我们下个单元再见。
033:视角 🧭

在本单元中,我们将探讨虚拟机(VM)语言的实际应用、效率权衡、安全性优势,以及抽象与实现分离的核心价值。我们将通过解答课程中常见的几个问题来展开讨论。
概述
在本节课中,我们将学习虚拟机模型在现实世界中的重要性。我们将探讨VM语言的实用性、其与C++等语言在效率上的对比、它如何提供更强的安全性,以及抽象层带来的巨大好处。
VM语言的实用性
上一节我们介绍了虚拟机翻译器的构建。本节中我们来看看VM语言本身的实际效用。
VM语言出人意料地强大。为了体会这一点,你可以回顾本模块中展示的一些Jack应用程序。比较这些应用的Jack高级代码与J编译器将其翻译成VM语言后生成的VM代码。
如果你进行行数统计,你会发现平均每行高级Jack代码会生成大约四行编译后的VM代码。这非常了不起。即使我们尚未深入讨论编译原理,J编译器生成的VM代码的紧凑性和可读性也应给你留下深刻印象。
那么问题来了:这个编译器是如何做到生成如此少的代码的?
答案是:这是一个两级编译器。因此,编译器可以放心地假设存在另一个代理——即VM实现——来处理从VM层到机器语言的所有翻译任务。这正是我们在前两个模块中构建的VM翻译器。
实际上,从宏观角度看,我们刚刚完成了整个编译模型的第二个后端部分。好消息是,当我们在第4和第5模块中编写编译器的其余部分时,与编写必须将C++直接翻译到机器语言(没有中间层)的C++编译器相比,我们的工作量将少得多。
因此,你在前两个模块付出的辛勤工作,将在课程后期开发编译模型的前端部分(我们简称为“编译器”)时得到巨大回报。这个前端部分负责生成我们在本模块和前一个模块中讨论和实现的VM代码。
总而言之,虚拟机是一个极其强大且实用的概念。它是Java架构、C#架构中非常重要的一环。事实上,如今微软也为C++提供了两级编译器,但请注意,这样做会损失很多效率,而这正是使用C++的首要优势。
效率权衡
接下来,我们看看一个直接相关的问题:效率。为什么C++比Java更高效?
显然,任何两级翻译模型都会带来额外的开销,并导致生成更多行、效率更低的机器代码。如果你不信,可以尝试先将英语翻译成西班牙语,再从西班牙语翻译成法语,与直接英译法相比,第二种方法会更繁琐,结果也更冗长。
那么,哪个更理想?是一个最终生成1000行机器代码的两级Java程序,还是一个只用300行机器代码完成相同任务、运行速度快三倍的等效C++程序?
务实的答案是:当今的计算机速度惊人。因此,在许多重要应用中,300%的效率下降并不明显。当然,在实时信号处理和嵌入式系统等领域,任何效率提升都至关重要,对于这些应用,像C和C++这样的语言是不可或缺的,也比Java更适用。
但在其他众多领域,Java、C#和Python等语言所提供的优雅性和安全性,足以弥补效率上的损失,而这种损失在许多应用中几乎察觉不到。
安全性优势
让我们继续看第三个问题:安全性。为什么Java比C++更安全?这与我们的VM架构有什么关系?
你可能知道,VM代码有时被称为托管代码。我认为这个术语最早由微软引入。同样,你可能知道,.NET框架的核心是一个名为CLR(公共语言运行时)的虚拟机,它相当于Java的JVM。因此,C#编译器生成VM代码(在微软世界中称为IL,即中间语言),这种IL代码与字节码类似,设计为由某个CLR实现进一步处理。
为了区分由CLR处理的代码(如C#)和直接在目标硬件平台上运行的代码(如C++),微软将前者称为托管代码,后者称为非托管代码。“托管”这个概念不仅限于微软,任何使用VM技术的人都会用到它。
“托管”这个概念源于一个观察:当代码在诸如CLR、JVM或我们自己的VM翻译器这样的虚拟机环境中运行时,除了盲目执行代码外,我们还可以做各种有用的事情。例如,我们可以检查VM代码以寻找安全漏洞。与分析更为复杂和晦涩的机器语言程序相比,分析VM程序的语义要容易得多。
我们不要忘记,VM代码必须由某个虚拟机实现来处理。因此,我们可以设计这个实现,以创建所谓的沙箱效应。其思想是,在运行时,执行中的程序永远无法越出这个沙箱,去操纵它们不被允许操作的东西(比如宿主机的内存)。在一个软件经常在你不知情的情况下从互联网下载到你的电脑或手机的世界里,这一点极其重要,而且这些软件来源不明。
因此,从这个角度看,运行在你PC或手机上的VM实现,不仅可以被视为一种赋能技术,也可以被视为一层安全保护,保护你的设备免受恶意代码侵害。这是VM模型的另一个非常重要的优势。
抽象与实现的分离
最后,我想以对抽象与实现分离这一优点的总体观察来结束本模块,这也是我们Nand to Tetris课程中几乎每个单元和模块都强调的一点。
回想一下,VM函数使用诸如 push argument 2、pop local 1、push static 7 等命令来访问其内存段。这样做时,VM函数完全不知道这些值在主机内存中是如何实际表示的,也不知道在函数调用和返回协议中它们是如何被保存和恢复的。
换句话说,VM代码将内存段视为抽象,而完全不关心这个抽象是如何被VM实现具体实现的。
这种抽象与实现的完全分离意味着许多重要的事情,其中之一是:生成VM代码的编译器开发者完全不必操心这些VM代码最终将如何运行,也完全不必操心主机平台。对他们来说,主机平台就是一台虚拟机。这非常重要,因为编译器开发者有足够多自己的问题需要担心,在接下来三个模块中开发我们自己的编译器时,你将很快意识到这一点。
但是,请振作起来。你已经完成了为一个基于对象的高级编程语言开发编译器的前半部分。这非常了不起!这是一个巨大的成就,你应该为自己感到骄傲。
总结
本节课中,我们一起学习了:
- VM语言的强大实用性,它得益于两级编译模型,使得高级语言编译器的工作得以简化。
- 效率与优雅的权衡,认识到在当今硬件条件下,VM模型带来的效率损失在许多场景下是可接受的,而其带来的开发优势显著。
- 托管代码的安全性,VM实现通过代码检查和沙箱机制,为系统提供了重要的安全保护层。
- 抽象与实现分离的核心价值,它允许编译器开发者忽略底层细节,专注于语言本身的翻译,这是软件工程中的一个基本原则。

带着这份自豪感,我们结束了模块2的学习。我们将在模块3中再次相见,届时我们将介绍我们的高级语言。
034:Jack语言概览 🧱

在本节课中,我们将要学习高级编程语言的基础知识,并重点介绍本课程将使用的Jack语言。我们将了解Jack语言的基本结构、语法特点以及它在整个课程项目中的核心作用。
上一节我们介绍了计算机硬件与底层软件,本节中我们来看看如何从零开始构建一个高级编程语言及其编译器。
课程全景与议程
这是课程接近尾声的开始。我们看到,在接下来的模块4和模块5中,我们将构建Jack编译器。在随后的模块中,我们将在Hack计算机平台上构建一个操作系统。因此,现在开始学习Jack语言非常有意义,因为Jack语言将在我们后续的所有工作中扮演主导角色。我们将编写一个Jack编译器,而伴随Jack的操作系统也将用Jack语言本身编写。这类似于伴随Unix的操作系统是用C编程语言编写的一样。
Jack语言初览
基于以上背景,让我们再次回顾一下在课程介绍模块中看到的一些Jack代码。现在无需阅读或尝试理解代码,我们将在本模块中展示更多示例。目前,我只想通过观察这段代码指出:Jack是一种简单的、类Java的语言。如果你写过Java程序,一切看起来都会有些熟悉。它有一些特殊的语法,我们稍后会解释。它是一种基于对象的语言,不支持继承。它是一种多用途语言,可以用来设计你想到的任何应用程序,尤其适合编写简单的交互式应用,如俄罗斯方块、贪吃蛇、太空入侵者等。并且,它可以在大约一小时内学会(也应该能很快“忘掉”),这一点极其重要,因为Jack语言本身不是目的,而是我们学习如何编写编译器和操作系统的重要工具。
本模块的学习要点
本模块的学习要点很多。首先,你将学习高级语言是如何设计和实现的。我们将学习在幕后如何处理基本类型和类类型,如何创建、表示和销毁对象。这将让你深入了解各种可能的错误,如栈溢出和内存泄漏等。我们将学习如何处理字符串和集合(如数组和列表)。我们还将学习高级语言如何与主机操作系统交互,以及关于高级语言的更多知识。此外,你将持续发展你的编程技能,使用Jack语言编写一个非平凡的程序,并继续讨论贯穿Nand2Tetris课程的、极其重要的“抽象-实现”原则。
通过示例学习Jack
Jack在很大程度上是一种不言自明的语言。因此,我认为介绍它的最佳方式是看一些示例。我们将浏览四个示例,在每个示例中,我将用它来介绍语言的一些特性。让我们从“Hello World”开始。
示例一:Hello World
“Hello World”是我们之前见过的一个程序。如果执行它,我们会在屏幕上得到这段非常有想象力的文本:“Hello world”。但现在我想指出的是,在Jack中,我们有三种注释:
- API注释:可以被像Javadoc这样的外部工具处理。我们也可以编写类似的JackDoc工具。
- 块注释:由
/*和*/这两个分隔符界定。 - 行内注释:用于内部文档。
此外,我们还有空白字符。我们可以在代码中使用任意多的空格字符。我们利用这种空白字符(编译器会忽略它)来进行缩进。缩进当然非常重要,它能帮助我们理解自己和其他程序员的代码。
示例二:计算平均值
接下来,让我们看另一个更有趣的Jack程序示例。这个程序想要读取一组数字并计算它们的平均值。为了演示这个程序,我们需要一个输出设备。以下是代码的要点。
我们首先声明一组变量:一个名为 a 的数组变量和三个整数变量。然后,我们询问用户想要输入多少个数字。假设用户说3。基于这个信息,我们构造一个大小为3的数组,将某个变量设置为0,然后进入一个循环。在循环中,我们持续提示用户输入数字。假设用户输入数字12、8和5。25的平均值是8点几,当我们打印平均值时,我们得到结果8,这是合理的,因为在Jack中(或者说在这个程序中,以及Jack语言通常处理数字时),我们只处理整数数据类型。
我们在这里看到的是一个过程或处理的例子。这里没有什么特别有趣的,它与你过去使用过的任何其他语言非常相似。但我们仍然可以做一些重要的观察。
以下是关于Jack程序结构的关键观察:
- 程序结构:一个Jack程序或应用被定义为一个或多个类的集合。其中一个类必须名为
Main。在这个Main类中,必须至少有一个函数,且该函数必须名为main(小写m)。这个Main.main函数是Jack应用程序的入口点。 - 控制结构:Jack语言提供了控制结构,如
if、while和do。显然,当你想做有意义的事情时,必须使用这些结构。 - 数组处理:在Jack中,数组是作为数组类的实例实现的,该类是主机操作系统的一部分。有趣的是,Jack数组是无类型的,因此同一个数组可以包含你能想到的任何类型的任何值。这有点特别,但我们将在后续以各种有趣的方式使用它。
- 操作系统调用:我们看到了一些操作系统调用和方法,如
Keyboard.readInt和Output.printScreen等。这些方法属于主机操作系统,我们可以在Jack代码中随意使用它们。 - 数据类型:如果我们关注这三行代码,我们看到有基本数据类型,如本程序中出现的
int,在其他程序中你还会看到更多数据类型,如char和boolean。我们还有一些类类型,比如我之前提到的Array类,这在Jack程序中也是一个合法的类型。在Jack程序中,你经常会看到来自操作系统的类类型,但程序员可以自由地发明任意多的类和类类型。从这个意义上说,Jack具有无限的数据类型定义能力。
总结

本节课中我们一起学习了Jack语言的概览。在本单元中,我们看到了两个非常简单的Jack编程示例。实际上,我们看到的是一个面向对象的语言如何被用来编写没有对象、没有面向对象特性,只是非常简单、普通的顺序处理代码。下一个示例将开始探索基于对象的编程所能实现的极限,这将是下一单元的内容。
035:基于对象的编程

在本节课中,我们将要学习基于对象的编程。我们将通过一个具体的例子——实现一个分数类——来探讨如何设计和使用对象,并了解对象在内存中的表示方式。
概述
上一节我们介绍了简单的过程式编程。本节中,我们将讨论基于对象的编程。我们将创建一个名为 Fraction 的类,它允许我们以高精度进行分数运算。通过这个例子,我们将学习对象的构造、方法调用和销毁。
对象设计:分数类 API
首先,我们需要设计一个分数类的应用程序接口。这个API定义了分数对象能提供的服务。
以下是分数类的基本功能框架:
class Fraction {
// 构造函数:创建一个新的分数
constructor Fraction new(int a, int b)
// 访问器:获取分子
method int getNumerator()
// 访问器:获取分母
method int getDenominator()
// 方法:将当前分数与另一个分数相加
method Fraction plus(Fraction other)
// 方法:释放该分数对象占用的内存资源
method void dispose()
// 方法:在屏幕上打印当前分数
method void print()
}
当然,一个完整的分数类还可以包含减法、乘法、除法、求倒数等功能。但本课程的重点是语言设计与实现,而非编程本身,因此我们仅展示核心概念。
客户端代码示例
了解了API后,我们来看看如何使用它。客户端代码存在于另一个独立的Jack类中。
以下是一个客户端程序示例,它计算 2/3 与 1/5 的和:
class Main {
function void main() {
var Fraction a, b, c; // 声明三个Fraction类型的引用变量
let a = Fraction.new(2, 3); // 创建分数 2/3
let b = Fraction.new(1, 5); // 创建分数 1/5
let c = a.plus(b); // 将两个分数相加
do c.print(); // 打印结果:13/15
do c.dispose();
do a.dispose();
do b.dispose();
return;
}
}
执行此程序后,我们将在屏幕上得到正确的结果 13/15。
抽象与实现分离原则
这里我们再次遇到了本课程中反复强调的抽象-实现分离原则。分数类的使用者完全不需要知道分数在内部是如何实现的。他们只需将分数视为一个黑盒,并通过其公开的API来使用服务。
然而,总有人需要实现这个抽象。接下来,我们就从应用开发者切换到类实现者的角色,看看如何构建 Fraction 类。
实现分数类
设计一个类时,首先要确定需要存储哪些数据。对于分数,我们需要两个整数:分子和分母。
字段与访问器
我们在类中定义两个整型字段来存储这些数据,它们类似于Java的成员变量或C#的属性。
class Fraction {
field int numerator; // 分子
field int denominator; // 分母
...
}
在Jack语言中,从类外部访问字段值的唯一方式是通过访问器方法。这是一种良好的编程实践,在Jack中更是必须的。
以下是两个访问器方法的实现:
// 返回当前分数的分子
method int getNumerator() {
return numerator;
}
// 返回当前分数的分母
method int getDenominator() {
return denominator;
}
构造函数
构造函数用于创建新的对象。它接收两个参数,用于初始化分子和分母,并自动对分数进行约分。
// 构造函数:创建新分数并约分
constructor Fraction new(int x, int y) {
let numerator = x;
let denominator = y;
do reduce(); // 调用约分方法
return this; // 返回新创建对象的基地址
}
关键字 this 是对当前对象的标准引用,在内部它代表对象在内存中的基地址。Jack的构造函数必须显式返回 this。
约分方法与GCD函数
reduce 方法是一个典型的操作当前对象字段的方法。它通过计算分子和分母的最大公约数来约分分数。
// 方法:约分当前分数
method void reduce() {
var int g;
let g = Fraction.gcd(numerator, denominator); // 调用GCD函数
if (g > 1) {
let numerator = numerator / g;
let denominator = denominator / g;
}
return;
}
gcd 函数是一个静态函数,它不操作特定对象,类似于Java中的静态方法。这里实现了经典的欧几里得算法。
// 函数:计算两个整数的最大公约数(欧几里得算法)
function int gcd(int a, int b) {
var int r;
while (~(b = 0)) {
let r = a - (b * (a / b)); // 求余运算
let a = b;
let b = r;
}
return a;
}
加法方法
plus 方法将当前分数与另一个分数相加,基于小学数学原理,并返回一个新的分数对象。
// 方法:将当前分数与另一个分数相加
method Fraction plus(Fraction other) {
var int sumNum, sumDen;
let sumNum = (numerator * other.getDenominator()) + (other.getNumerator() * denominator);
let sumDen = denominator * other.getDenominator();
return Fraction.new(sumNum, sumDen); // 创建并返回新的分数对象
}
打印与销毁方法
print 方法以“分子/分母”的格式在屏幕上打印当前分数。
dispose 方法至关重要,因为Jack语言没有垃圾回收机制。它通过调用宿主操作系统的例程来释放对象占用的内存,是良好编程习惯的体现。
// 方法:打印当前分数
method void print() {
do Output.printInt(numerator);
do Output.printString("/");
do Output.printInt(denominator);
return;
}
// 方法:释放当前对象占用的内存
method void dispose() {
do Memory.deAlloc(this); // 调用操作系统函数,传入对象基地址
return;
}
对象在内存中的表示
程序员通常将对象可视化为包含其字段的块,并通过引用(变量名)来访问它们。例如,创建分数 2/3 和 1/5 后,心理模型如下:
a -> [ numerator: 2, denominator: 3 ]
b -> [ numerator: 1, denominator: 5 ]
然而,在系统底层,对象是在内存中实现的。RAM的一部分被划定为堆,用于存储对象和数组。变量 a 和 b 本身存储在栈上,其值是对象在堆中内存块的基地址(指针)。
例如:
- 变量
a的值可能是一个地址,如15087。访问该地址,会找到存储分子2的内存字,下一个字存储分母3。 - 变量
b存储另一个地址,指向代表分数1/5的内存块。
这种表示的建立依赖于编译器与宿主操作系统的协作。当编译构造函数时,编译器会插入对操作系统内存分配例程的调用。同样,dispose 方法会调用操作系统的内存释放例程。这些底层机制将在课程后续部分详细讨论。
总结
本节课中我们一起学习了基于对象的编程。我们通过实现一个完整的 Fraction 类,深入探讨了以下核心概念:
- 如何设计类的API。
- 如何编写客户端代码来使用对象。
- 抽象与实现分离的重要原则。
- 类的具体实现,包括字段、访问器、构造函数、方法和函数。
- Jack语言中
this关键字的意义和构造函数的显式返回要求。 - 对象在内存中的真实表示方式,以及栈和堆的区别。
- 在没有垃圾回收机制的语言中,显式内存管理(
dispose方法)的必要性。

这个简洁的分数类示例展示了基于对象编程的强大表现力。在接下来的单元中,我们将看到更多优雅的面向对象编程范例,并继续深入探讨支撑这些范例的语言设计和基础设施。
036:列表处理

在本节课中,我们将学习如何使用面向对象编程来创建和操作一种称为“列表”的复合数据结构。列表是计算机科学中一个非常重要的抽象概念,它由一系列称为“原子”的基本元素组成。我们将学习如何构建列表、如何顺序处理列表以及如何递归地处理列表。
什么是列表?
上一节我们介绍了处理“原子对象”的程序。原子对象,例如一个分数,是不可再分的基本单元。
然而,我们也可以使用面向对象编程来操作和创建由多个原子组成的集合对象。最通用的集合类型就是列表,它在理论和应用计算机科学中都是一个非常重要的抽象。因此,我们将用整个单元来讨论列表处理。
那么,什么是列表?本质上,列表要么是原子 null(可以看作是空集),要么是一个原子后跟一个列表。我们使用以下符号来表示列表:
List = null | (atom, List)
以下是几个列表的例子:
null(空列表)(5, null)(3, (5, null))
一直写这么多括号很繁琐。因此,我们使用约定的缩写符号来描述列表。但在我们心中要记住,我们实际上指的是由“一个原子后跟一个列表”这种结构组成的实体。所以,这是一个高度递归的数据结构。
原子可以是任何东西:可以是整数(如上面的例子)、字符串、数组、其他对象,甚至可以是列表本身。
现在,程序员倾向于使用这里的隐喻来思考列表。这同样是一个由原子链接在一起的集合,因此有时也被称为链表。但请注意,这整个东西是一个对象。在这个例子中,我们有一个指针变量 V 指向这个对象。当然,问题在于我们如何创建和操作这样的集合对象。这就是我们将在本单元中学习的内容。
列表类的抽象接口
以下是设计用于操作列表的类的抽象接口:
class List {
// 构造函数:创建新列表
constructor List new(int car, List cdr) {...}
// 方法:获取列表的第一个原子(头部)
method int car() {...}
// 方法:获取列表的剩余部分(尾部)
method List cdr() {...}
// 方法:打印列表中的所有原子
method void print() {...}
// 方法:回收列表占用的所有内存资源
method void dispose() {...}
}
我们使用术语 car 和 cdr(由于历史原因)来描述列表的原子(头部)和尾部(它本身也是一个列表)。然后我们有一个 print 方法,它遍历列表并打印每一个原子。我们还有一个 dispose 方法,用于回收列表持有的所有内存资源。因为一个列表可能包含数百万个原子,当我们不再需要这个列表时,回收其资源是有意义的。
显然,在实际应用中,你可能希望为这个抽象添加更多功能,例如在列表中间插入或删除原子、合并列表等。但本课程的重点不在于列表处理本身,因此我们只关注核心操作。我们将使用 Jack 语言来实现它。
客户端代码示例
以下是使用列表的客户端代码示例,它演示了我们希望对列表进行的三种典型操作:创建、处理(打印)和销毁。
var List v;
let v = List.new(5, null); // 创建包含 5 的列表
let v = List.new(3, v); // 在头部添加 3
let v = List.new(2, v); // 在头部添加 2
do v.print(); // 输出:2 3 5
do v.dispose(); // 回收列表内存
return;
这段代码首先创建了列表 (2, (3, (5, null))),然后打印它(输出“2 3 5”),最后销毁它。所有其他列表操作都可以看作是这三种基本操作的衍生。接下来,我们将逐一详细解释。
构建列表
让我们从创建列表开始。上面的客户端代码包含了三次对列表构造函数的调用。现在让我们看看如何编写这个 List 类。
首先,我们需要定义列表包含的数据。一个列表由一个数据(原子)和一个指向下一个列表的指针组成。因此,我们定义两个字段:
class List {
field int data; // 原子数据(本例中为整数)
field List next; // 指向尾部(下一个列表节点)的指针
// ... 方法定义
}
构造函数 new 会向内存系统(操作系统)请求足够的内存来存放这个两字块(data 和 next),然后返回这个内存块的地址。
现在,让我们模拟这段代码是如何运行的,这将帮助我们理解列表在内存中是如何构建和表示的。
- 首先,声明指针变量
v。作为一个局部变量,它被初始化为 0(即null)。 - 第一次调用
List.new(5, null)。构造函数分配内存,存储数据5和指针null,并返回该内存块的地址。v现在指向这个新创建的对象。 - 第二次调用
List.new(3, v)。构造函数分配新内存,存储数据3和指针(指向第一步创建的对象)。v被更新为指向这个新对象。旧的v值(指向包含5的节点)现在由新节点的next字段引用。 - 第三次调用
List.new(2, v)过程类似,最终构建出完整的链表。
需要指出的是,这种构建列表的代码也可以用更简洁的逻辑来完成。此外,这只是构建列表的一种方式。我们可以设计其他构造函数,例如接收一个数组并从中创建列表,这完全取决于API设计者的决定。
顺序处理列表
构建好列表后,接下来我们看看如何遍历列表,这有时被称为列表的顺序处理。我们将通过 print 方法来演示。
当运行 v.print() 时,我们期望在屏幕上看到列表的内容。以下是 print 方法的主体:
method void print() {
var List current;
let current = this; // `this` 指向调用此方法的列表对象
while (~(current = null)) {
do Output.printInt(current.car());
do Output.printChar(32); // 打印空格
let current = current.cdr();
}
return;
}
在面向对象编程中,当你在一个对象上调用方法(如 v.print())时,该对象会作为隐式参数 this 传递给方法。因此,在 print 方法内部,this 指向列表的头部。
- 方法首先创建一个局部指针
current,并将其设置为this。现在有两个指针(this和current)指向列表头部。 - 进入
while循环。只要current不是null,就执行循环体。 - 在循环体内,打印当前节点的数据(
current.car()),然后打印一个空格。 - 将
current指针移动到下一个节点(current.cdr())。 - 重复步骤3和4,直到
current变为null,此时循环结束,方法返回。
模拟这个过程:current 依次指向包含 2、3、5 的节点,并打印它们,最后遇到 null 时退出。方法返回后,其局部变量(包括 this 和 current)被回收,而客户端变量 v 仍然指向原来的列表头部,列表本身没有被修改。
这里有两个重要的点:
print方法虽然深入列表内部进行遍历,但对客户端来说,它只是请求“打印列表”,完成后列表完好无损地返回。这体现了抽象与实现的 interplay:抽象很简单,实现可能不简单,但客户端无需关心。- 打印本身并不重要,它只是一个示例,代表了任何需要遍历列表并对每个元素执行某些操作的方法(例如,将每个值增加10%)。
递归处理列表
最后,我们来看看列表的递归处理,以 dispose 方法为例。dispose 的目的是回收列表及其所有节点占用的内存。
method void dispose() {
if (~(this.next = null)) {
do this.next.dispose();
}
do Memory.deAlloc(this);
return;
}
请注意,这是一个递归方法。因为列表本身是递归数据结构(一个节点指向另一个列表),所以用递归来处理它非常自然。
客户端调用 v.dispose() 后,控制权转移到 dispose 方法,this 指向列表头部。
- 方法首先检查
this.next(尾部)是否为null。 - 如果不为
null,则递归调用this.next.dispose()。在递归调用中,this指向下一个节点。 - 递归会一直进行,直到到达最后一个节点(其
next为null)。 - 对于最后一个节点,由于
next是null,跳过递归调用,直接执行Memory.deAlloc(this)。这个函数(我们之前见过)回收this指向的内存块。 - 递归返回上一层(倒数第二个节点),执行它的
Memory.deAlloc(this),回收其内存。 - 以此类推,直到最初的调用返回,整个列表的所有节点都被回收。此时,客户端变量
v仍然存在,但它指向的内存已被释放,列表不复存在。
这是一个尾递归的清晰示例。然而,需要注意的是,递归在处理非常大的列表时可能效率低下,甚至导致栈溢出。因此,在实践中,可能会使用迭代等更实用的方式来销毁列表。但这里的递归版本很好地展示了利用数据结构递归特性的优雅处理方式。
列表在内存中的表示
在本课程中,非常重要的是再次“打开引擎盖”,看看列表在内存中是如何实现的。
客户端看到的高级视图是链接在一起的节点。而在内存内部,它是这样存储的:
- 变量
v存储一个地址(例如 567)。 - 在地址 567 处,我们找到值
2和一个指向下一个节点的指针(例如 14017)。 - 在地址 14017 处,我们找到值
3和一个指针(例如 3108)。 - 在地址 3108 处,我们找到值
5和指针null。
这就是列表在内存中的存储方式。我们对这些地址没有控制权,它们完全由内存分配器决定。实际上,当你调用 new 时,构造函数会在内存的某个地方创建对象,我们只关心指向其基地址的指针。因此,列表节点可能分散在内存的不同位置,这完全没有问题,因为内存是直接访问的。
那么,是谁实现了这个魔法?如果你只满足于从高级视角思考编程,那么简单的答案是:构造函数以某种方式处理了它。但如果你有兴趣深入表面(正如我们本课程所做的),那么你应该知道:当编译器翻译高级程序员编写的构造函数代码时,它会向代码流中插入对操作系统的调用。这些调用将激活一些例程,这些例程知道如何为新的对象找到可用的内存并将其分配给它。在本课程的后续模块中,当我们构建编译器和操作系统时,你将很高兴地看到所有这些功能是如何以非常优雅和有效的方式实现的。
总结
本节课中,我们一起学习了列表处理的核心概念:
- 构建列表:我们了解了列表的递归定义,并使用构造函数和指针逐步构建链表结构。
- 顺序处理列表:我们通过
print方法,学习了如何使用循环和指针遍历列表中的每一个元素。 - 递归处理列表:我们通过
dispose方法,展示了如何利用列表自身的递归结构,通过递归调用来处理整个列表。 - 内存表示:我们探讨了链表在物理内存中是如何通过指针链接离散的节点来实现的。

列表是复杂数据结构的基石,理解其创建、遍历和销毁过程,对于掌握更高级的编程概念至关重要。
037:语法 🧩

在本节课中,我们将学习Jack编程语言的语法规范。我们将通过之前见过的编程示例,从纯语法的角度来解析代码的构成元素。
上一节我们介绍了Jack语言的整体框架,本节中我们来看看其具体的语法构成。我们将以平均计算程序为例,其代码本身在本单元中仅作为语法分析的素材。

空白与注释
首先,我们来看代码中的空白部分,包括注释和空格。在Jack语言中,注释有三种类型,我们在之前的单元中已经讨论过,这里为了完整性再次列出其规则。
以下是Jack语言中注释的规则:
- 行内注释:以
//开头,直到行尾。 - 多行注释:以
/*开头,以*/结尾。 - API文档注释:以
/**开头,以*/结尾,通常用于生成文档。
关键字
接下来,我们讨论关键字。在上述示例代码中,我们看到了多个关键字在起作用。Jack语言总共约有20个关键字,它们可以分为不同的类别。相比之下,Java语言有大约60到70个关键字,因此在数量级上,两者都属于比较简单的语言。
以下是Jack语言的关键字分类:
- 标准关键字:例如
class、function、void、while。 - 非标准关键字:例如
var、let、do。这些关键字是为了简化编译器的编写而特意引入的,你将在课程的下一个模块中体会到这一点。
符号
每个Jack程序(或其他任何语言的程序)都包含许多符号。这些符号用于不同的目的。
以下是符号的主要用途:
- 分组符号:例如
{、}、(、)、[、]。 - 其他用途符号:例如
;、,、.、+、-、*、/、&、|、<、>、=、~。
常量
Jack语言有能力描述常量。在我们的示例中,可以看到两种常量。
以下是Jack语言支持的常量类型:
- 整数常量:例如
5、12。 - 字符串常量:例如
"The average is: "。 - 布尔常量:
true和false。 - 空常量:
null。
标识符
最后,我们来看标识符。标识符实际上是任何程序的主体部分。在这个例子中,大部分代码都是由标识符构成的。😊
标识符是程序员在创建变量、类和方法时发明的名称。这些名称需要遵循特定的命名规则。需要再次指出的是,Jack和Java本身都是非常简单的语言。这些语言之所以强大(尤其是Java),在于程序员可以自由地创建属于自己的完整世界,这个世界通常由类、方法和API组成。程序员编写的大部分代码,实际上就是他们在设计这些API时所创造的“小语言”。我们在示例中看到的 Keyboard.readInt() 等就是如此。所有这些“小语言”都由程序员发明的标识符构成,因此想出好的标识符名称至关重要,它能使你的代码更具可读性。
总结
本节课中,我们一起学习了Jack编程语言的语法元素,包括空白与注释、关键字、符号、常量和标识符。在下一单元,我们将讨论Jack语言中可用的数据类型。


《从零开始构建现代计算机2》:单元3-5:Jack语言规范:数据类型 🧱

在本节中,我们将学习Jack编程语言支持的数据类型。我们将了解其基本类型、类类型,以及该语言独特且强大的类型转换规则。
在上一单元,我们讨论了Jack语言的语法。本节中,我们来看看Jack程序允许创建和操作哪些类型的数据。照例,我们先从一个例子开始。
这是一段Jack代码,其具体功能并不重要。让我们聚焦于这三条语句,程序员在其中创建了一个名为 a 的数组、一个名为 length 的整数变量,以及另外两个名为 I 和 sum 的整数变量。
由此我们看到,Jack语言首先有三种基本类型:
int数据类型:可保存0到32767范围内的值。boolean类型:值为true或false。char类型:本质上是使用Unicode(或ASCII)字符集表示字符的数字。在本课程使用的字符子集中,两者是相同的。
你可能会问,如果 int 值只能是非负数,如何在Jack中表示负数?你可以在值前加上负号 -,但这并非一个常量,而是一个表达式,我们对紧随其后的非负值应用了负号运算符。因此,从程序员的角度看,使用负数与使用正数一样简单,但其实现稍微复杂一些。
接下来,我们还有类类型。例如,在这段代码中,我们看到变量 a 的类型是 Array。类类型要么来自Jack操作系统(如 String 和 Array),要么是用户创建的。这里的“用户”指的是编写Jack的程序员,他们可以设计诸如 Fraction、List、Stack 等类,并自由创建这些数据类型的变量。
因此,与其他基于对象的高级语言类似,Jack同时拥有基本类型和类类型。
接下来,我想演示Jack中可能相当“狂野”的类型转换。
首先,字符和整数可以根据需要随意相互转换。这与其他语言中的做法很相似。
- 如果有一个名为
c的字符变量,我可以写c = 65;,其中65是一个整数值,这将根据ASCII码给我字符'A'的表示。 - 同样,我可以创建一个名为
s的字符串,用双引号将大写字母A赋值给s。如果我想将其转换为字符,则必须使用操作系统函数charAt,通过调用s.charAt(0)来实现。这有点繁琐,因为理想情况下我们可能希望直接写c = "A";,但Jack语言不支持这种写法。这可能会给需要进行大量字符串和字符处理的人带来一些不便,但它会使编译器的编写变得更容易。当我们开发编译器时,会感谢这些让步。
其次,整数可以赋值给引用变量,此时它被视为一个内存地址。考虑这段代码:我们创建了一个名为 arr 的数组,然后写 arr = 5000;。这看起来有点疯狂,因为 arr 本应是一个数组。然而,Jack语言支持这种赋值。如果你这样做,基本上是告诉系统数组 arr 应该从基地址5000开始。因此,如果你之后写 arr[100] = 17;,编译器生成的代码将使RAM位置 5000 + 100(即5100)的值变为17。如你所见,这种转换相当奇特和狂野,如果使用不当可能造成混乱,因为它允许程序员控制RAM并随意操作。然而,这些技巧在我们后续开发操作系统时将再次被证明极其有用。
此外,对象可以轻松转换为数组,反之亦然。例如:
- 我创建一个
Fraction类型的对象x。回想一下,Fraction对象有两个字段:numerator(分子)和denominator(分母)。 - 我创建一个名为
arr的数组,将其构造为长度为2。 - 然后我写
arr[0] = 2;和arr[1] = 5;。 - 接着我做了一件奇怪的事:
x = arr;。现在,x(一个Fraction对象)指向了arr数组。 - 完成此操作后,我可以写
x.print();。这将调用Fraction类的print方法作用于x。即使x指向一个数组,它也会将此数组视为一个Fraction,从而将其打印为2/5。
这再次是一种相当狂野的类型转换,但完全合理,因为在内部,数组和对象的处理方式几乎相同。这就是为什么在Java和C#等语言中,你可以轻松地将对象序列化为数组等等。在Jack中,这种关系更加明确和清晰。
本节课中,我们一起学习了Jack语言的数据类型。总结来说,我们可以得出结论:Jack拥有一套相当基础的基本类型集(只有三种,而Java有八种)。我们本可以轻松添加更多,但从教学价值考虑认为没有必要。
同时,Jack语言也是弱类型的。事实上,它非常弱类型。任何变量都可以赋值给任何其他变量,而不管其类型如何。一方面,这听起来像是一场混乱;另一方面,它提供了一些非常有趣的“黑客”可能性,我们后续肯定会加以利用。我们本可以轻易施加各种限制,不允许进行某些操作,但我们决定不为此烦恼。

这就是Jack的类型系统。在下一单元,我们将转向讨论类和方法。
039:类 🧱

在本节课中,我们将要学习Jack语言规范中的核心概念——类。我们将了解类的基本结构、两种主要用途,以及Jack语言如何通过操作系统库来支持高级编程。
类的结构
上一节我们介绍了Jack语言的基本元素,本节中我们来看看其最基础的编译单元——类。类在Jack语言中是最基本和核心的编译单元,这与Java、C#、C++等语言类似。
每个以TheClass命名的类都存储在一个独立的.jack文件中,并且该文件与应用程序中的所有其他类文件分开编译。我们要求类名必须与文件名相同,并且类名和文件名的首字符必须是大写字母。
以下是Jack语言中一个类的通用骨架:
class ClassName {
// 字段变量声明(可选)
field type varName;
// 静态变量声明(可选)
static type varName;
// 子程序声明(构造函数、方法、函数)
constructor ClassName(...) { ... }
method returnType methodName(...) { ... }
function returnType functionName(...) { ... }
}
它以一个类名开始。接着是字段变量声明,这是可选的,但如果存在,它们会出现在这里。然后是另一组零个或多个静态变量声明。最后,是所谓的“子程序”声明,这里指的是构造函数、方法和函数。它们通常构成了类内容的主体,但语法上要求字段和静态变量(如果存在)必须出现在子程序声明之前。
类的两种主要用途
了解了类的结构后,我们来看看类的语义和主要用途。类有多种不同的用途,软件架构和软件工程课程中描述了所谓的设计模式和不同形态的类。在我看来,类主要有两大类。
1. 提供功能的类
第一大类是提供功能的类。这类类的一个好例子是我们的操作系统Math类。正如你从其API中看到的,它只是一个常用数学函数的库。任何人都可以随意使用这些函数。这个库提供了这些函数的实现,并且希望是高效的实现。这类类的特殊之处在于它们只包含函数,没有字段、构造函数、方法,显然也没有对象。因此,这类类本质上是包含函数的库或模块。
2. 表示实体或对象的类
另一大类(其中包含更多子类别)是表示我们有时称为“实体”或“对象”的类。我们已经见过的例子有Fraction、List、String。这些类中的每一个都旨在表示该类的实例,即根据类的能力创建和操作的对象。如果一个类至少有一个方法,那么它必然是一个表示实体的类。通常,这样的类也会包含字段和方法,以表示实体的数据和允许对这些实体进行的操作。
最后,表示实体或对象的类也可以有函数。但如果程序员知道自己在做什么,那么这些函数最好只用于内部辅助目的。这些在Java中有时被称为“私有静态方法”。例如,如果你编写一个操作对象的方法,并且在这个过程中需要进行一些在其他几个方法中重复出现的、冗长复杂的计算,你可能希望将这个计算提取出来,放到类中的另一个函数里,并且只让类中的其他子程序使用它。这是完全可以的。你可以为内部目的创建这样的辅助函数。
然而,作为一个与本课程无关的一般性建议,你应该记住不要将这些辅助函数暴露给外部世界,即暴露给你这个旨在表示对象的特定类之外的其他类。如果你确实想创建一些与该类对象无关、并且可能对其他客户端也有帮助的功能,你应该把这些函数放到别处,放到单独的类中,而不是放在这个类里。
所以,一般性建议是:不要将库功能和对象表示功能混合在同一个类中,你应该将它们分开。再次强调,这些是一般的软件工程主张,与本课程没有直接关系。
一个设计示例
为了说明这个原则,我将展示一个实际上违反了该原则的例子。这个例子就是我们之前在课程中看到的Fraction类。
如果你看这个类,你会在这张幻灯片上看到一个构造函数、一个名为reduce的方法和一个名为gcd的函数。这个方法做了我刚才描述的事情:它计算两个数字(恰好是分子和分母)的最大公约数,然后使用结果达到某个目的。
那么,它有什么问题呢?首先,在语法和Jack语言的有效性等方面,一切都很好。编译器会很乐意翻译这个程序,并且它会完美运行。但从美学设计、实用角度来看,这是一个糟糕的解决方案,因为gcd是一个非常通用的函数,它在分数上下文之外也有很多用处。换句话说,其他处理各种其他事情(例如质数)的类也会受益于能够访问这个gcd函数。因此,重构这段gcd代码并将其放到一个单独的类中是很有意义的。这个类很可能就是我们操作系统的Math类,或者你自己的数学类,它是一个数学函数库。
所以,这里有一个例子,正好是我之前描述的两类类:Fraction表示一个对象,而Math是一个数学操作库,gcd是其中之一。这是一个好得多的解决方案。它有很多好处,其中之一是Fraction类将更紧凑、更专注、更切中要点,它只处理分数,不处理其他东西。而Math类现在扩展了额外的功能,现在许多其他客户端都可以使用gcd函数,而这个函数之前被埋在这个Fraction类中。
面向对象语言非常强大,具有强大的表达能力。但在未经良好训练的程序员手中,它们可能导致难以扩展和维护的、糟糕的程序。这些程序员不遵循这些最佳实践建议。所有这一切真的与Nand2Tetris无关,这只是一个广告,旨在建议你,如果你想成为一个严肃的开发者,你必须学习一些软件架构课程,或者至少一门关于软件设计、设计模式之类的课程,这些课程会教你如何正确地设计软件。
Jack应用程序与操作系统库
继续前进,正如我们之前在课程中解释过的,一个Jack应用程序是一个或多个(希望是设计良好的)Jack类的集合,其中必须有一个名为Main的类。这个类应该至少有一个名为main的函数,Main.main成为整个应用程序的入口点。我在这里重复这一点只是为了完整性,因为我们之前讨论过。
最后,我想谈谈Jack附带的支持库,它类似于Java附带的支持库,至少在原则上是相似的。
这是我们一直使用的同一个代码示例。如果我仔细看这段代码,我会发现它有许多对操作系统例程的调用。这在Jack以及其他与围绕它们的某些操作系统层紧密协作的高级面向对象语言中是非常自然的。
操作系统的目的是多方面的。首先,它旨在弥合高级编程与主机硬件及外围设备(如键盘、屏幕、鼠标、扫描仪、打印机、摄像头、麦克风等)之间的各种差距。确实,你在这个代码示例中看到,如果你想从键盘读取一些东西,你有一个操作系统例程来做这件事:Keyboard.readInt()。相信我,编写这个例程很痛苦,因为你必须深入键盘,读取扫描码,将你点击的键转换为整数和字符串等等。当我们在这门课程后期开发操作系统时,你会发现这并不简单。但我再次承诺,我们将以最优雅的方式来完成,所以不会那么可怕。
但对于高级程序员来说,他或她可能根本不在乎所有这些可怕的细节,因为操作系统很好地、优雅地提供了这个抽象。操作系统的另一个目的是提供广泛使用的函数(例如平方根)的高效实现。最后,我们还希望操作系统能高效地表示常用的抽象数据类型,如数组、字符串、列表等。
在实现方面,伴随Jack的操作系统由八个类组成,我们将在下一张幻灯片中看到它们。再次强调,整个概念在精神上(我强调,是在精神上)与Java标准类库相似。你知道,我们的类库和Java类库之间的唯一区别是,我们的库包含8个类,而截至Java 8的Java标准类库,我认为包含超过4000个类。这是因为Java是一种工业级语言,所以你必须支持许多东西,如网络、文件、加密、压缩、多线程以及许多其他东西。当然,我们本可以用Jack做同样的事情,但我们没有必要,因为这不在课程范围内。但它们在某种意义上相似,即Jack标准库是完全开放的,我们可以以任何我们想要的方式扩展它,如果我们有精力构建一些高级课程,我们可能会这样做。
正如所承诺的,这里是支持Jack的八个操作系统类的描述。如果你想阅读完整的操作系统API,欢迎查阅Nand2Tetris书籍和网站,API在那里提供。
总结

本节课中我们一起学习了Jack语言中“类”的概念。我们了解了类的基本骨架结构,探讨了类的两种主要用途:作为功能库和作为实体/对象的表示。我们还通过一个Fraction类的例子,学习了良好的软件设计原则,即分离关注点。最后,我们介绍了Jack应用程序的结构以及其操作系统支持库的作用,它类似于Java的标准库,但规模更小,旨在为硬件交互和常用功能提供抽象。下一单元,我们将通过讨论方法来完成Jack语言的规范。
040:方法 🧩

在本单元中,我们将全面探讨Jack语言中“方法”相关的所有规范。这包括变量、子程序调用、数组、字符串等核心概念。我们将以尽可能清晰的方式,逐一解析这些构成Jack程序的基本元素。
概述
上一节我们介绍了类的基本概念,并提到类以子程序声明结尾。本节中,我们将深入探讨这些子程序,即方法(包括构造函数、方法和函数)的详细规范,以及与之相关的变量、表达式、语句等语言要素。
子程序:构造函数、方法与函数
Jack语言使用“子程序”这一统称来描述三种类型的方法:构造函数、方法和函数。它们目的不同,但声明语法完全相同。
- 构造函数:用于创建新对象。
- 方法:用于操作当前对象(
this)。 - 函数:类似于C语言中的函数,不操作特定对象,仅提供计算服务。
所有子程序都必须有返回类型,并且必须返回一个值。即使是void类型的子程序,也需要显式地使用return;语句,这相当于return null;的简写。在其他语言中,编译器可能会隐式地处理这一点,但在Jack中需要程序员显式写出。
构造函数的特殊规则
构造函数是享有特殊规则的方法,专门用于创建对象。
- 一个类可以有0个、1个或多个构造函数。
- 构造函数的常用名称是
new。如果有多个构造函数,可以使用更具描述性的名称,如newOne、newTwo。 - 构造函数的返回类型必须是其所属类的类名。
- 构造函数必须返回一个值,且该值必须是该类的一个对象。
变量类型
Jack程序中有四种变量,以下是它们的简要说明:
- 静态变量:类级别的变量,类中的所有子程序都可以访问。
- 字段变量:同样是类级别的变量,类中的所有方法和构造函数可以访问,但函数不能访问(因为函数不理解对象的概念)。
- 局部变量:在子程序内部声明和使用的变量。
- 参数变量:本质上与局部变量相同,但其值可以从外部传入,用于向类内传递参数。
所有变量都必须先声明类型,然后才能使用。Jack是一种弱类型语言,这意味着在大多数情况下,可以将一个变量赋值给另一个变量,而无需过多考虑它们的类型是否严格匹配。
语句
Jack语言提供了五种基本语句,以下是它们的用途:
let:用于赋值。if:用于条件判断。while:用于循环。do:用于调用一个方法(仅为了其副作用,不关心返回值)。return:用于从子程序中返回。
表达式
表达式是构成程序计算的基本单元。一个Jack表达式可以是以下任何一种形式:
- 一个常量(如
17)。 - 一个变量名或关键字
this。 - 一个数组索引访问(如
temperature[15])。 - 一个子程序调用(如
Math.sqrt(17))。 - 一个带一元运算符(
-或~)的表达式(如-sqrt(17))。 - 两个表达式通过二元运算符(
+,-,*,/,&,|,<,>,=)连接(如x + 17)。 - 一个放在括号内的表达式(如
(x + 17) * y)。
子程序调用
子程序调用的语法在不同语言中可能有所不同,但Jack遵循通用规则:调用时提供的参数数量和类型必须与被调用子程序签名中定义的完全匹配。理解这一点对于正确使用API至关重要。
字符串
字符串在Jack中是对象,它们是操作系统String类的实例。以下是字符串使用的示例:
var String s;
var char c;
let s = "hello world"; // 编译器会将此“语法糖”转换为底层的构造和赋值代码
let c = s.charAt(0); // 从字符串中提取单个字符
字符串可以通过new String显式构造并逐个添加字符,但更常见且方便的方式是直接使用双引号赋值。这种简化写法被称为“语法糖”,它隐藏了底层的复杂性。要了解所有字符串操作,请查阅操作系统中的String API。
数组
数组在Jack中也是对象,它们是操作系统Array类的实例。Jack的数组是弱类型的,并且始终是一维的。以下是一个示例:
var Array arr;
var String helloWorld;
let helloWorld = "hello world";
let arr = Array.new(4);
let arr[0] = 12; // 整数
let arr[1] = false; // 布尔值
let arr[2] = 314/100; // 分数
let arr[3] = helloWorld; // 字符串
如示例所示,同一个数组的不同元素可以包含完全不同的数据类型。如果需要多维数组,可以通过“数组的数组”来实现。
Jack语言的特性总结
在开始用Jack编写程序(如本模块的最终项目)之前,需要了解其一些独特特性:
- 赋值前缀:所有赋值语句都必须以
let关键字开头。忘记使用会导致编译错误。 - 方法调用前缀:如果调用一个方法仅为了其副作用(不关心返回值),必须使用
do关键字。如果方法返回值并用于赋值,则使用let。 - 语句体括号:即使语句体内只有一条语句,也必须用花括号
{}括起来。例如:if (x) { return a; }。这是一种良好的编程实践。 - 显式返回:所有子程序都必须以
return语句结束。在其他语言中,编译器可能隐式添加,但在Jack中必须显式写出。 - 无运算符优先级:Jack语言没有定义运算符的优先级。表达式
2 + 3 * 4可能被计算为20(从左到右),而不是14。要确保运算顺序,必须使用括号,例如2 + (3 * 4)。 - 弱类型系统:Jack是一种比工业级语言弱得多的类型语言。这赋予了程序员通过指针直接操控底层硬件的强大能力(在课程中非常有用),但同时也极不安全,可能导致程序崩溃。在本课程中,这被视为一个理想特性,因为它将整个软硬件系统变成了一个开放的实验室。
总结

本节课中,我们一起学习了Jack语言中关于方法的完整规范。我们详细探讨了构造函数、方法和函数的区别,了解了四种变量类型、五种基本语句以及表达式的构成规则。我们还特别介绍了字符串和数组的使用方式,并总结了Jack语言的一些关键特性,如必须使用let和do前缀、语句体必须加括号、没有运算符优先级等。掌握这些规范是接下来使用Jack语言进行编程和项目开发的基础。下一单元,我们将讨论在Hack平台上编写Jack程序和应用的艺术与实践。
041:使用Jack语言和操作系统开发应用 🚀

在本节课中,我们将学习如何使用Jack语言及其配套的操作系统来开发各种应用程序。我们将从开发流程、输入输出处理、操作系统提供的核心类库等方面进行介绍,并给出一些实用的开发建议。
开发流程 📝
上一节我们介绍了Jack语言的基本规范。本节中,我们来看看如何将Jack语言用于实际应用开发。
开发一个Jack应用程序,我们建议遵循以下步骤:
- 创建项目目录:新建一个目录,并以你的应用名称命名,例如
Tetris。 - 编写Jack代码:使用标准文本编辑器编写
.jack类文件,并将它们放入之前创建的目录中。如果编辑器支持Jack语法高亮,使用它会更加方便。 - 编译程序:使用提供的Jack编译器编译你的程序。你可以编译单个
.jack文件,也可以直接编译整个目录。编译器会为每个.jack文件生成对应的.vm虚拟机文件。 - 执行程序:将包含
.vm文件的整个目录加载到虚拟机模拟器中,然后运行你的程序。
以下是虚拟机模拟器运行某个程序的截图示例,这也是我们推荐用于执行你编写的程序的环境。

处理输入与输出 ⌨️🖥️
应用程序如何与用户交互?这取决于你想开发什么类型的应用。
文本应用
如果你开发的是处理简单字符和字符串的文本应用,那么你应该将屏幕(虚拟机模拟器的屏幕或Hack计算机的通用屏幕)视为一个由 23行、每行 64个字符 组成的黑白网格。
使用Jack操作系统提供的 Output 类可以方便地处理文本输出。这个类提供了一系列功能明确的函数。
以下是 Output 类的API示例:
class Output {
function void printChar(char c) { ... }
function void printString(String s) { ... }
function void printInt(int i) { ... }
function void println() { ... }
// ... 其他方法
}
图形应用
如果你想开发像《乒乓球》、《太空入侵者》或《贪吃蛇》这样的图形应用,那么你应该将同一个物理屏幕视为一个由 256行、每行 512个像素 组成的黑白网格。
对于图形生成,我们推荐使用操作系统提供的 Screen 类,当然你也可以创建自己的图形库(这将在本模块后续单元讨论)。
以下是 Screen 类的API示例:
class Screen {
function void clearScreen() { ... }
function void setColor(boolean b) { ... } // true=黑色, false=白色
function void drawPixel(int x, int y) { ... }
function void drawLine(int x1, int y1, int x2, int y2) { ... }
function void drawRectangle(int x1, int y1, int x2, int y2) { ... }
function void drawCircle(int x, int y, int r) { ... }
}
setColor 函数用于设置一个全局颜色(黑色或白色),所有后续的绘图操作都将使用这个颜色,直到下一次调用 setColor。通过交替使用黑色和白色,可以创造出擦除或动画效果。
处理用户输入
Hack计算机的标准输入设备是一个标准键盘。为了处理来自键盘的输入,操作系统提供了一个 Keyboard 类。
以下是 Keyboard 类的API示例:
class Keyboard {
function char keyPressed() { ... }
function char readChar() { ... }
function String readLine(String message) { ... }
function int readInt(String message) { ... }
}
keyPressed 方法会持续返回用户当前按下的键的扫描码。只要手指按住某个键(例如左箭头键),keyPressed 就会返回对应的数字(如130)。一旦松开手指,它将返回0。一种基本的用户交互方式就是持续采样 keyPressed 的返回值。
Hack计算机识别的字符集包括方向键(上、下、左、右),这些键在各类交互式游戏中非常有用。
Jack操作系统类库概览 📚
就像Java拥有标准库一样,Jack语言也配套了一个操作系统类库。不过,Java标准库有数千个类,而Jack的类库目前只有8个核心类。
以下是这些类的简要介绍:
- Math:数学库。提供一些你可能需要的数学函数,例如
Math.multiply和Math.divide。注意,在常规Jack编程中,你可以直接使用*和/运算符,这两个函数主要用于编译器开发等场景。 - String:字符串类。提供了字符串抽象,包括构造函数、一系列操作字符串的方法,以及一些特殊字符常量(如退格键、双引号、换行符)。
- Array:数组类。在Jack程序中创建数组,需要调用
Array.new(size)。之后可以通过array[index]的语法访问数组元素。不再需要数组时,建议调用Array.dispose()释放。 - Memory:内存类。允许Jack程序直接访问宿主机的RAM,通过
peek(读)和poke(写)方法实现。使用这些函数需要格外小心,因为它们可能访问到不应触碰的内存地址(如栈或指针区域)。此外,还提供了alloc和deAlloc函数来创建和释放内存块。 - Sys:系统工具类。提供三个实用函数:
halt(停止程序)、error(输出错误信息)和wait(等待指定毫秒数)。wait函数在制作动画时非常有用,可以通过它在循环中添加延迟来控制动画速度。 - Output:文本输出类,前文已介绍。
- Screen:图形输出类,前文已介绍。
- Keyboard:键盘输入类,前文已介绍。
学习与开发建议 💡
了解Jack编程的最佳方式是研究现有的Jack程序。本模块中我们已经展示了许多示例,如分数计算器和链表。此外,我们还提供了一个名为 Square 的简单交互式游戏程序(包含三个类),以及一个更复杂的、面向对象的多类应用程序 Pong 的完整代码。
以下是一些开发你自己应用程序的指南:
- 观摩现有程序:访问课程网站,运行并研究提供的应用程序(如
Square和Pong),理解其用户体验、局限性和Jack的输入输出风格。 - 精心规划:仔细规划你的应用。明确用户能做什么、程序提示什么,并设计实现方案,包括需要哪些类。
- 尽早考虑测试:在开发早期就思考你的测试策略。
- 实现、测试、享受:完成实现,进行测试,并享受你的劳动成果。
以下是一些技术提示:
- 使用文本编辑器编写程序。
- 熟悉操作系统提供的类库。
- 如需图形更流畅,可以使用后续单元讨论的一些优化技巧。
- 像注释Java、Python程序一样为你的代码添加文档。
- 使用提供的Jack编译器编译代码,并使用虚拟机模拟器执行。
- 在虚拟机模拟器中,记得使用滑块控制执行速度,并利用各种动画控制选项。如果不确定,请查看课程网站上的虚拟机模拟器教程。
总结 ✨
本节课中,我们一起学习了使用Jack语言和操作系统开发应用程序的全过程。我们了解了从创建目录、编写代码、编译到执行的完整开发流程。重点介绍了如何处理文本与图形输出,以及如何通过 Keyboard 类接收用户输入。我们还概览了Jack操作系统提供的8个核心类库(Math, String, Array, Memory, Sys, Output, Screen, Keyboard)及其基本用途。最后,我们获得了一些关于学习现有代码、规划应用以及进行开发实践的实用建议。接下来,我们将通过分析一个由多个类组成的完整Jack应用程序代码来加深理解。


042:方块舞 🎮

概述
在本单元中,我们将通过一个名为“方块舞”的完整Jack应用程序示例,学习如何将面向对象设计原则与操作系统提供的输入/输出服务相结合,来开发交互式应用。我们将先观察程序的运行效果,然后深入分析其Jack代码。
程序演示与功能说明
上一单元我们讨论了开发Jack应用程序的一些通用准则,本节中我们来看看一个完整Jack应用程序的实际示例。
我们选择展示的应用程序称为“方块舞”。通过这个特定程序,我们旨在阐释Jack中的面向对象设计概念,并展示一种利用主机操作系统提供的各种输入/输出服务来开发用户交互的方法。
关于面向对象设计需要说明的是,这并非本课程的核心内容。本课程的重点不是编程,因此我们假设您已具备一些面向对象设计的基础知识。如果不熟悉,可以参考课程内外的示例来获得设计自己应用程序的思路。
首先,我们快速演示一下方块舞游戏。
再次欢迎来到虚拟机模拟器环境。我已经加载了方块程序,现在可以执行它。执行时,代码内部发生了很多操作,但屏幕上没有显示任何内容。让我停止执行并解释一下原因。
基本上,每次启动程序时,操作系统和程序本身都需要进行初始化。这需要数千个时钟周期才能在屏幕上显示内容。我们可以继续执行程序,最终会在屏幕上看到输出。但如果你想跳过所有这些初始化步骤,我们需要将程序流程切换到“无动画”模式。在模拟器上下文中,“无动画”意味着不展示代码内部行为的动画。现在,我们“倒回”代码,将程序计数器设置为零,然后重新开始执行。
立即,我们看到屏幕左上角出现了一个黑色方块。这是默认方块,宽高均为30像素。请注意,我现在正用一根手指按下右箭头键。确实,你看到方块开始移动,直到碰到墙壁。接着,我按下下箭头、左箭头、上箭头,方块对我的键盘事件做出了响应。我可以连续按几个箭头键,方块会相应移动。
我还可以点击X键。每次点击X,方块的底边和高度都会增加2个像素。现在移动这个方块,看起来它移动得更慢了,但这只是因为方块变大了,这是一种视觉偏差。我也可以让它变小。如果点击Z键,每次点击方块都会变小。由于没有使用任何图形优化,视觉效果并不精细。在某个时刻,方块会变得非常小,甚至可能完全收缩消失,但实际上我看到这里仍然有一个小方块幸存下来,显然程序就是这样写的。我可以再次增大它,使其大致恢复正常大小,如此反复。以上就是我们这个小方块程序的演示。
回顾一下刚刚看到的演示:当你启动方块舞游戏时,首先看到的是一个位于屏幕左上角的黑色方块。方块不会移动,它会停留在原地,直到用户进行操作。特别是,如果用户点击箭头键,方块就会开始在屏幕上移动。因此,用户可以使用这四个键来控制移动。用户还可以使方块变大或变小。最后,用户可以退出游戏。这里没有什么新内容,我刚才讨论的一切都在演示中展示了。但在深入分析代码时,我们必须牢记这些点,因为代码的设计就是为了实现所有这些动作。
程序架构与设计模式
那么,我们程序的架构是怎样的?我们决定围绕三个独立且自包含的Jack类文件来构建它。
以下是构成我们程序的三个独立类:
- Square类:提供方块对象。使用这个类,你可以创建一个图形方块,并在屏幕上操纵和移动它。
- SquareGame类:控制用户操作,并通过在一个持续循环中移动方块来响应用户操作。
- Main类:相当简单,用于初始化、设置舞台并启动方块游戏。
总的来说,这个设计遵循了有时被称为MVC的著名设计模式。该模式建议使用一些类来处理你想要操纵的对象数据,使用另一个或多个类来控制游戏和视图功能。确实,我们大致就是这样做的。Square类对应所谓的“模型”,SquareGame是游戏控制器。我们使用一组独立的方法,以及操作系统来实际进行绘制并提供游戏的视图功能。
Square类详解
接下来,我想逐步探索这三个类中的每一个,让我们从Square类开始。
以下是Square类的API:
- 构造函数:
constructor Square new(int x, int y, int size)。创建一个新方块,将其定位在给定的坐标,并以给定的尺寸创建。尺寸是方块边长的像素值。 - 析构方法:
method void dispose()。处理掉当前方块。 - 绘制方法:
method void draw()。在当前位置和尺寸绘制方块。 - 擦除方法:
method void erase()。同样绘制方块,但使用背景色,因此实际上是擦除方块。 - 改变大小方法:
method void incSize()和method void decSize()。执行其名称所暗示的操作。 - 移动方法:
method void moveUp(),method void moveDown(),method void moveLeft(),method void moveRight()。每个方法都执行其广告的功能。
一切都非常直观。正如你所见,这就是方块对象,世界上的其他类都可以使用它,当然也包括我们应用程序中的类。
让我们打开代码,回到这个Square类。以下是表征当前对象的字段:首先,它有x和y坐标(提醒一下,屏幕左上角被认为是(0,0)),然后它有一个以像素为单位的尺寸。
接下来是构造函数,以下是构造函数的代码:我们将x和y设置为给定值,将size设置为给定值,然后调用draw。一旦调用draw,你将看到方块定位在当前坐标。假设调用此构造函数的人会以(0,0)坐标开始调用,这意味着你将看到方块锚定在屏幕的左上角。绘制后,我们返回this,这是每个Jack构造函数中的标准做法,我们必须将新创建对象的基地址返回给调用代码,正如我们在其他示例中看到的那样。
继续看,dispose方法是标准的,没有什么特别之处,我们以前见过这样的方法。
然后我们有draw方法,它实际上获取这个逻辑方块并最终向用户显示它。我们如何做到呢?我们将颜色设置为true(意味着选择黑色),然后使用操作系统的drawRectangle函数来绘制一个长度和高度相同的矩形,因此我们实际上是使用标准的drawRectangle例程来绘制一个正方形。erase方法做完全相同的事情,但使用背景色(false被解释为白色)。
接着是incSize方法。incSize方法简单地擦除当前方块的图像,增加方块的尺寸,然后重新绘制它。这样,你将看到一个比之前稍大的方块。注意其中的条件判断,该方法只是确保增加操作不会导致方块溢出并弄乱屏幕。
然后我们有一个对称的decSize例程,无需讨论。
以上就是我们如何增大和减小方块尺寸。
那么,我们如何向上移动方块呢?想一想,方块位于屏幕上的某个位置,我们需要将其向上移动两个像素。最简单的方法(不一定是最有效的方法)是:使用背景色在当前位置绘制当前方块,这将有效地从用户视野中消除它。然后,我们可以改变方块的y坐标,切换回前景色黑色,并在新位置重新绘制它。这正是我们在moveUp方法实现中所做的。如果你愿意,可以暂停并查看这段代码。一旦我们这样做了,其余的移动方法(moveDown、moveLeft、moveRight)都非常相似,遵循相同的技巧,因此无需讨论它们。
好的,以上就是Square类的全部内容。
SquareGame类详解
现在,我们继续讨论实际控制游戏的SquareGame类。
首先,每个SquareGame类都需要一个方块。因此,SquareGame类的一个属性是一个Square类型的对象。然后,方块还有一个方向,这个方向由用户控制。由于这个方向在代码的许多地方都会用到,我们决定使用一些常量来编码五种可能的方向,从0开始,表示方块静止。这就是每个SquareGame的两个属性。
然后我们有一个构造函数,它创建一个新游戏。以下是构造函数的代码:我们首先构造一个新方块,将其构造在屏幕的左上角,并默认决定方块的生命起始尺寸为30像素。然后我们将方向设置为0,这意味着在开始时,我们希望方块在角落里保持不动,直到用户决定开始点击键盘上的某些键。然后,像构造函数中通常做的那样,我们返回this。
dispose方法是标准的,但在处理我们的对象之前,我们确保也处理掉我们创建的方块,我们作为负责任的公民行事。
让我们看看moveSquare例程。moveSquare是一个设计用来移动方块的例程。我不太确定它是否属于这个类,我们也可以把它放在Square类中。所以这是一个判断问题。这个例程可以放在这里或那里,我们决定把它放在这里。那么我们如何做呢?如果方向是1,我们想向上移动方块;如果方向是2,我们想向下移动它,依此类推。移动方块后,我们调用Sys.wait(5),因为我们想稍微延迟一下动画,然后返回。这就是moveSquare例程。
然后我们来到SquareGame类最重要的例程,叫做run。这是实际运行整个表演的例程。现在,让我们看看它实际上是如何做到的。
我们选择以下方式来实现:首先,我们声明一个名为key的变量,它包含用户最后按下的键的扫描码。如果用户释放了这个键,它将是0。这就是key的约定。
然后我们使用一个名为exit的布尔变量来决定何时退出游戏,并将其初始化为false,因为游戏刚刚开始。
接着我们进入一个while循环,条件是not exit。稍后你会看到,只要用户没有按Q退出,我们想做什么呢?想一想,我们启动了游戏,方块位于屏幕的左上角。假设需要一些时间(几纳秒、几秒或几小时),用户才会决定做些什么。那么我们如何知道用户实际上已经开始使用键盘呢?我们必须采样键盘,必须使用key来找出用户在做什么。
因此,从现在开始直到本单元结束,你将看到我们处理键盘事件或键盘效果的典型方式。
只要key是0,就意味着用户还没有触碰键盘。我们做什么?我们采样键盘,并将用户输入的扫描码(或0)放入key。然后我们移动方块。我们移动方块是因为游戏的规则是:方块总是在移动,直到碰到墙壁。在开始时,方向是0,所以我们以方向0移动方块,这意味着方块并没有真正移动。但在许多次这样的按键之后(因为我们在这里的循环中运行),只要我们还在这个循环内,我们就知道用户还没有触碰键盘。但是一旦用户触碰了键盘,我们将被拉出这个while循环,因为key将不再是0。最终,我们有了一个非零的方向。
因此,如果key恰好是81(这是Q的扫描码),我们将exit设置为true。如果key是Z,我们减小方块。如果key是88(X),我们增大方块。然后,如果key包含箭头键之一的扫描码,我们将方块的方向属性设置为相应的约定常量。然后我们进入另一个循环。
这个循环控制用户实际按住他或她的手指的时间。想一想,我们到达这段代码是因为用户点击了键,对吧?所以用户按住手指,因此key不为0。但在某个时刻,用户会抬起他或她的手指。因此,只要键被按住(只要key不为0),我们希望持续监控或采样键盘(使用Keyboard.keyPressed())。在某个时刻,用户会抬起他或她的手指,key将变为零。此时,我们将退出这个循环。只要我们还在这个循环内,我们就移动方块,因为再次强调,我们希望方块持续移动,因为这是我们选择实现的游戏规则。
在某个时刻,我们将退出控制用户操作的内层while循环,回到外层while循环,该循环询问:我们还在玩游戏吗?只要exit为false,我们就想继续玩游戏。在某个时刻,用户会点击Q,exit将变为true,那时我们也将退出这个外层循环。然后run方法将返回,这将是游戏的结束。
以上就是SquareGame类。
Main类详解
继续,我们需要探索的最后一个类相当简单,就是Main类。Main类基本上初始化事物并启动游戏。它按照Jack的惯例,使用一个main函数来完成:我们创建一个新的game对象,调用这个game对象的run方法。然后,我刚才描述的方法开始执行。一旦这个run方法终止,我们处理game对象并返回。就是这样。
总结
以上就是对方块舞应用程序的完整描述,我希望它能帮助您理解在Jack中开发交互式应用程序的一些技巧和科学原理。
回顾一下,我们向您展示这个示例,是因为我们希望您能欣赏和理解Jack应用程序所具备的用户界面风格。我们希望向您展示,您必须广泛使用操作系统,就像您在Java、Python和其他高级语言中编写程序时广泛使用操作系统一样。
我们还希望阐释一些基本的面向对象设计。我们没有展示我们的测试策略,但我们希望强调这也是您必须关注的事情。您必须能够对所做的所有事情进行单元测试,就像我们在本课程的任何其他项目中所做的那样。
至此,本单元结束。在下一单元中,我们将讨论如何处理一些比在屏幕上移动方块更具挑战性的图形需求。

我们下个单元再见。
043:图形优化 🎨

在本单元中,我们将学习如何通过绕过操作系统,使用自定义代码来实现高性能图形渲染。我们将探讨精灵(Sprite)的概念,并学习如何高效地在屏幕上绘制不规则图形。
概述
上一单元我们介绍了方块舞应用程序,它涉及非常简单的图形图像——一个正方形。在许多应用程序中,我们需要使用更复杂的图形,例如不规则的多边形。当需要创建高性能图形时,我们可以使用一些技巧,这些技巧将在本单元中展开说明。
精灵与挑战
我们看到了方块舞,你可能也有机会查看太空侵略者应用程序。如果你仔细观察构成太空侵略者的图像,你会看到这里各种生物,比如外星人、射击者和子弹。再看另一个应用程序,比如“Band”,如果你关注这个应用程序的图形构建块,你会发现这个迷宫是由一些基本图像的重复图案组成的。总的来说,所有这些图像通常被称为精灵。
精灵是一个二维位图,通常作为较大场景中的单一图像或重复图像。作为应用程序开发者,我们需要关注两件事:首先,如何快速绘制精灵,这在精灵是不规则形状、不适合标准绘图例程时并不明显;其次,如何以平滑且用户可接受的方式移动这些精灵。
实现图形的两种方法
有两种基本方法可以实现图形:一种是使用主机操作系统的标准方法;另一种是,当操作系统的能力达到极限时,你可能需要开发自己的自定义代码。本单元的主要内容就是关于如何绕过操作系统,利用自己的技能进行图形处理。
Hack计算机的图形基础
你最终在Jack中创建的图形将在Hack计算机上执行。Hack计算机配备了一个32K的RAM。从项目0中你可能还记得,计算机的组织方式使得有一个8K的地址空间专门用于屏幕内存映射,还有一个单字内存映射专门用于键盘。我们可以将屏幕和键盘连接到这两个内存映射上。
计算机还具有一个持续的刷新周期,该周期根据屏幕内存映射的内容刷新物理屏幕。因此,如果你想在屏幕上绘制某些内容,你必须写入Hack RAM。
深入了解屏幕
让我们更仔细地看看屏幕,因为这是本单元的重点——Hack屏幕上的图形。屏幕是一个由256行、每行512像素组成的网格。如果我想在屏幕上的某个位置绘制三个像素,那么我必须在屏幕内存映射中的某个地址写入数字7(即二进制的111)。我选择了一个任意的地址919003,它可能对应于我们在屏幕上看到的内容。
如果我们想看到更多像素,比如16个像素,那么我们必须去同一个地址并写入数字-1,在二进制中就是16个1。因此,每当你想在屏幕上渲染某些内容时,唯一的方法就是在程序控制下向RAM、向屏幕内存映射中写入内容。如果你用机器语言编写,可以直接操作;如果用Jack编写,则必须使用Jack命令。
使用Jack命令操作图形
那么,我们如何使用Jack命令来实现呢?首先,我们很幸运有一个名为Memory的类,它是我们操作系统的一部分。在Memory类中有两个函数叫做Peek和Poke,它们使我能够做到我刚才描述的事情。我可以使用Peek和Poke来选择内存中的特定地址,要么获取存储在该地址的数字值,要么通过使用Poke来更改这个值。
以下是一些例子:如果我写代码let x = peek(19003),x将被设置为7,这是二进制111的十进制值。如果我说poke(19003, -1),那么这个字将不再是7,它会变成-1,即二进制中的16个1。因此,我可以使用这两个操作来控制RAM。这是一种方法。
使用操作系统绘制图形
我也可以使用操作系统来绘制像素。例如,如果我想绘制你在这里看到的这个矩形,它由两行、每行三个像素组成。那么有几种方法可以做到,我可以使用Screen类的操作系统功能。我们有drawPixel、drawLine、drawRectangle。首先,我们必须决定或假设所需矩形的位置。让我们决定这些是这个图像左上角的坐标。
我们可以做的事情就是简单地绘制构成这个图像的所有像素。我们有六个像素,一次绘制一个。我们还可以使用drawLine例程来绘制两条各三个像素的线。我也可以使用drawRectangle例程来绘制这个图像。只要提供我指定的参数,就会绘制出相同的图像。因此,这里的任何一种技术都会做同样的事情。
处理不规则图形
绘制像这样的规则图形很容易,不是吗?那么看起来像这样的图像呢?这是一个不规则的图像,我们没有专门设计用来绘制类似内容的例程。即使我们有这样的例程,我也可以提出另一个你无法绘制的例子,等等。因此,在某些时候,我们必须处理不规则图形。
处理这些图形的一种方法是简单地绘制所有像素。我可以找出所有这些像素的坐标,并为每个像素逐一绘制。在这个特定的例子中,我最终将使用75次不同的像素绘制操作。当你考虑效率时,你不应该只从高级别开始思考,但在某些时候,你应该始终考虑低级别,即在实际的主机平台上绘制这个图像到底需要多长时间。
事实证明,在低级别上,这些像素中的每一个都需要执行你在这里看到的所有操作,没有一个操作是简单的。我已经用伪代码写出来了,操作系统将必须实现所有这些。在某些时候,我们还得把它翻译成机器语言等等。当你看着这一批命令时,如果你担心效率,那么我脑海中浮现的唯一两个词就是“哦,天哪”。
因为我们这里至少有大约40个机器操作或机器周期。因此,这个操作的效率是40乘以75,大约是3000次机器操作。为了在屏幕上绘制这个小精灵,这工作量很大。想想动画,你必须在循环中绘制这个图像成千上万次。
自定义绘制:更高效的方法
所以,一定有更好的方法,确实有更好的方法,我们称之为自定义绘制。正如你可能已经想到的,我们可以做的一件事是看看这里的图像,并意识到我们实际上是在看一堆数字,对吧?代表这些像素的二进制数字。
我们可以通过简单地写入一堆数字(这些是十进制数字)到RAM中精心选择的地址来在屏幕上渲染这个图像,然后我们会得到完全相同的效果。为了写入这些数字,事实证明需要16次Poke操作。因此,效率将是16次Poke操作。现在,Poke是一个高度优化的操作,因为它是一个16位操作,这对主机平台来说非常自然。因此,我相信它可以用4个机器操作来实现。所以我们有4乘以16,即64次操作,与我们之前的3000次相比,效率有了显著提高。
精灵的寻址方案
我们可以继续讨论自定义绘制。现在,我希望你注意,我们从不孤立地绘制这些精灵。它们总是位于主机屏幕的某个特定位置,所以我们不要忘记我们必须决定这些值的精确地址,这就是我想在接下来几分钟里讨论的寻址方案。
首先,回想一下,我们在屏幕上看到的是Hack计算机上8K屏幕内存映射的持续渲染,它一直在刷新。因此,让我们只关注内存映射,并将内存映射的地址从0重命名为8K。同时,让我们记住精灵左上角的坐标。我已经在内存映射中填充了一些位,以说明当我们在屏幕上看到这个精灵时,内存中实际看到的内容。
屏幕的第一行,即512位或像素,由内存映射的前32个字表示,它们都是零,因为那里没有绘制任何内容。这里我们看到代表下一行或下一个512像素的32个字,然后,在某个时刻,我们会到达精灵实际开始的地址,我们看到我们有一堆零,然后是一堆一,还有几个零。这些是代表头像顶部发际线的像素位。这些“一”是站在他或她头顶的头发。
接下来的32个字代表图像中的下一行像素。正如你所看到的,我们有一堆零,然后有两个一,更多零,两个一,更多零。这些“一”是头像头部的边缘,头像在这里有头发,而中间没有头发的地方就是零。如此继续下去。在某个时刻,我们到达了头像或这个位图的底行。在这里,我们看到这个人物鞋子的底部轮廓,中间有一个零像素将两只鞋子分开。
因此,你在这里看到的是右侧二维屏幕与构成主机平台内存的16位字向量之间的映射关系。
使用位图编辑器简化流程
因此,考虑到这一点,如果我们想绘制这个位图,我们必须执行我之前说的,进行16次精心选择地址的Poke操作。一切都始于某个常量ADDR。ADDR是我们想要在内存中绘制的第一个字。如果我想将其转换为绝对寻址,那么我必须将屏幕内存映射在整个RAM中的基地址(即16384)加到ADDR上。然后我还必须加上屏幕上的位置,即我想开始绘制这个头像的位置对应的计算位置。一旦我将这两个常量相加,我将得到最终会在屏幕上这个确切位置绘制头像的16次操作。
你可能会同意,所有这些计算相当烦人和繁琐。因此,当你意识到我们提供了一个为你自动完成所有这些工作的工具时,你会非常高兴。我们有一个位图编辑器,由Golan Perrahi(参加本课程的学生之一)提供。这是一个非常不错的小型JavaScript应用程序,可供你使用。
你需要做的就是调用这个应用程序,它将在你的浏览器中开始运行,你会看到一个16x16的网格。然后你可以使用鼠标来打开和关闭单个像素,接着点击屏幕中间的按钮,应用程序将自动生成所有必要的Poke命令,以在Hack屏幕上渲染你选择的图像。
因此,从这时起,你需要做的就是从浏览器复制这段代码,并将其粘贴到你的Jack代码中,瞧!你就有了自定义代码,可以直接控制你的图形,并绕过操作系统。
最佳实践建议
现在,如果你对主机平台的细节感到有些困惑,欢迎你再次查看项目0,我们在其中讨论了Hack计算机以及输入输出操作。但也许你不必在这上面花太多时间,因为这个小工具真的为你完成了大部分工作。当然,如果你想获得更多背景知识,欢迎你去做。
我想以一些关于使用高性能图形的最佳实践建议来结束。我的最佳建议是,在许多情况下,你根本不需要使用它。如果你只想使用一些简单的图形,你应该使用标准的操作系统服务。如果你想做一些更高级的事情,你也可以从标准的操作系统服务开始,只有当某些东西不能令你满意时,你才可以选择使用我们在本单元中所做的那些高级技巧。
特别是,如果你想进行高性能的图形动画,你可以做我们在这里展示的事情。如果你愿意接受16x16像素的精灵,欢迎你使用位图编辑器。有了所有这些,你现在可以自由地开发高级应用程序了,希望你想出的游戏或任何你想做的事情都会很酷、很令人兴奋。
总结


在本单元中,我们讨论了高性能图形。在下一个单元中,我们将最终讨论项目9,这是你必须编写的Jack程序,作为本模块的顶点项目。
044:高级语言视角 🧐


在本节课中,我们将探讨高级语言,特别是Jack语言的设计理念、特点及其与主流语言的差异。我们将通过回答几个常见问题,来理解为何Jack语言被设计成现在的样子,以及这种设计背后的教学目的。
Jack语言与典型面向对象语言的差异
上一节我们介绍了高级语言的基本概念,本节中我们来看看Jack语言与Java、C#、C++等典型面向对象语言有何不同。
Jack是一个小巧的语言,它包含了过程式编程所必需的核心特性,例如变量、循环、数组和子程序。此外,它也允许构造和操作对象。
然而,Jack也存在一些明显的局限性:
- 它的类型系统非常原始,仅包含三种基本类型。
- 它只有两种控制结构:
if和while。 - 作为一种基于对象的语言,它不支持继承,而继承是面向对象编程最受推崇的特性之一。
总而言之,Jack是一个不错的小语言,但仍有诸多不足。你可能会问,为什么我们不把这些缺失的特性加入到Jack语言中呢?
答案是,与我们在课程中设计的许多其他系统和语言一样,我们的目标并非创造功能强大的工具,而是只暴露足够的功能,以便大家能够抓住工具(在本例中是高级语言)的本质。从这个极简且高度聚焦的视角来看,任何非绝对必要的额外功能都并非益处,反而可能是一种负担。我们非常赞同法国作家安东尼·德·圣-埃克苏佩里的观点:完美不在于无以复加,而在于无可删减。
什么是弱类型编程语言?
了解了Jack语言的整体定位后,我们来看看它在类型系统上的一个关键设计选择。
强类型语言对于如何声明和使用变量及数据类型非常严格。首先,你必须在正式声明变量后才能使用它,这很有道理。一旦声明,对于如何向该变量赋值与其声明数据类型不符的值,存在诸多限制。这种操作有时被称为类型转换。在强类型语言中,类型转换受到语言及其编译器的严格监管。这些限制很重要,因为它们能防止各种可能的损害。例如,如果你将 3.14 赋值给一个 int 类型的变量,你会丢失 0.14 的小数部分,这种数据丢失可能并非本意。因此,强类型策略很有意义,程序员应该感谢语言在这方面的严格把关。
然而,这一切在Jack中都不存在。在Jack中,你可以将任何数据类型的值赋给任何其他类型的变量,而语言从不抱怨。
那么,作为语言设计者,我们为何允许这种“转换无政府状态”呢?原因如下:
- 正如上一个问题所述,我们希望语言规范保持极简。而规范类型转换会带来很多麻烦。
- 宽松的类型转换可以被巧妙地利用,以实现各种高级编程技巧,从而让我们能够控制底层硬件平台。这在课程后期我们着手开发操作系统时将变得非常方便。这个操作系统将用Jack编写,就像Unix用C编写一样。你将发现,Jack的弱类型特性将允许我们构思各种巧妙的“黑客”技巧,没有这些技巧,我们将无法使用高级语言(或至少是Jack)来编写操作系统。
顺便一提,这也是操作系统通常用C语言开发的原因之一。并非C语言是弱类型的(事实上C是强类型语言),而是它提供了各种机制,允许程序员在有意时故意弱化类型系统。这种自由的类型转换在编写操作系统、嵌入式系统以及通常靠近硬件层运行的软件时非常有用。
Jack的语法约定:let 与 do
接下来,我们探讨Jack语言中两个独特的语法约定。
Jack有一些语法约定,例如 let 和 do,这在其他现代编程语言中并不常见(尽管在一些古老的语言中存在)。为什么要在语言中引入这些语法约定呢?
确实,在Jack中,如果你想给变量赋值,你必须写 let x = 10;,而不是简单的 x = 10;。如果你想调用一个子程序,你必须写 do foo();,而不是像在Java或Python中那样直接写 foo();。
引入这些前缀标记只有一个目的:允许开发优雅、简单且极简的Jack编译器,正如你们将在下两个模块中所做的那样。编译器最基础的操作之一称为语法分析。语法分析是分析我们试图编译的程序的语法结构的行为。事实证明,当你尝试对任何语言的源代码进行语法分析时,如果给定代码的第一个标记能揭示或表明我们正在处理的是哪种语句(是 while、if、let、do、return、函数声明还是其他),这将非常有帮助。
因此,尽管 let 和 do 在编写Jack代码和应用程序时有点麻烦,但当你在课程后期(实际上是下一个模块)必须编写Jack编译器的语法分析器时,你会对这些前缀标记感激不尽。
关于Jack操作系统的说明
最后,我们来谈谈Jack操作系统。正如你所知,基本的Jack语言通过Jack操作系统的完整功能得到了显著增强。这个操作系统被打包为Jack语言的标准类库。作为一个类库,它可以被随意扩展。这种模块化和开放式的架构在现代语言(如Java、Python和C++)中非常典型。
以Java为例。我记得大约20年前,我帮助在IDC Herzliya(我现在任教的地方)建立了一个新的计算机科学学院。我们当时做出的一个决定是采用Java作为学生学习计算机科学的第一门编程语言。这在当时是一个相当大胆的赌注,因为1996年时Java还非常新,并且只带有一个非常小的类库。自那时起,Java当然已经成为一门强大的语言,如今Java类库包含了超过4000个类。然而,在这二十年功能和特性惊人增长的过程中,基本的Java语言却几乎保持不变。
我希望我已经向你们传达了这种软件架构的美妙和实用性。作为语言设计者,你指定了一种简单、优雅、紧凑的语言。然后,利用类和子程序的抽象,你允许人们使用这种语言以无限种方式扩展它本身。在我看来,这种简单性与无限可扩展性的结合,或许是现代编程语言最显著的特征。
总结
本节课中我们一起学习了:
- Jack语言的定位:它是一个为教学目的设计的极简语言,包含了核心编程概念,但省略了继承等高级特性以聚焦本质。
- 弱类型系统:Jack采用弱类型,允许灵活的类型转换,这简化了语言设计,并为底层系统编程(如操作系统开发)提供了便利。
- 独特的语法:
let和do等前缀标记旨在简化编译器的语法分析器实现。 - 可扩展的架构:通过标准类库(操作系统)的形式,Jack语言具备了强大的可扩展能力,这体现了现代语言“核心简洁,外围丰富”的设计哲学。

关于高级语言,当然还有更多可以探讨的内容,但时间有限,本模块到此结束。在下一个模块中,我们将从语法分析开始,着手编写我们自己的编译器。
045:语法分析

在本节课中,我们将要学习编译器的构建,特别是语法分析器的开发。我们将从宏观视角了解编译过程,并深入探讨语法分析的第一步:词法分析。
概述
我们想要将高级语言程序翻译成机器码,以便执行它们并获得计算结果。程序通常由多个类组成,这没有问题。我们的目标是将这些代码编译成机器语言,然后执行可执行代码以获得预期结果。
如果一切正常,屏幕上显示的结果将是程序设计所要产生的。
编译过程
正如课程之前多次提到的,在Java、C#以及Jack等现代编程语言中,存在一个两级编译过程。
- 程序首先被编译成某种中间代码。在Java中称为字节码,在C#中称为IL(中间语言),而在Jack中我们简单地称之为VM代码。
- 然后,另一个过程会获取VM代码,并将其翻译成最终的目标机器语言。
VM翻译器是一个已解决的问题,我们在前两个模块中已经实现了它。因此,剩下的工作就是编写编译器,即编写将高级语言翻译成VM代码的程序。这将是我们在本模块(模块4)和下一个模块(模块5)中要做的事情。
开发路线图
我们想要构建一个编译器。Nor和我决定将这个重要的整体开发工作分成两个独立的模块。
- 在当前的模块4中,我们将开发一个语法分析器。
- 在下一个模块5中,我们将为其添加一个代码生成器模块。
现在有了两个不同的模块,一个有趣的问题出现了:我们如何独立于第二个模块来测试第一个模块?为此,我们决定让语法分析器输出一个用XML编写的文件(稍后会描述)。通过查看这个文件,你将能够确信并验证语法分析器确实“理解”了输入代码,并为下一个项目生成代码做好了必要的准备。
生成这个XML输出文件的工作将在项目10中完成,该项目将在本模块的最后一个单元中描述。因此,目前我们在本模块中要做的是开发分词器和解析器,它们共同构成了语法分析器。
本模块有许多重要的知识点,包括分词、语法、解析等。你可以在列表末尾继续阅读。
学习动机
我猜你们中有些人会问自己:为什么我必须学习所有这些知识?我又不打算以编写编译器为生,我将成为一名应用程序员,做其他事情。我可能永远都不需要写一个编译器。
我同意你的看法,你们中只有少数人会在职业生涯中需要编写编译器。然而,通过理解编译器是如何构建的,并能够自己编写一个,你将成为一个更加成熟和有能力的高级程序员。
此外,你必须理解,在当今的经济环境中,我们必须处理大量具有预定结构化格式的文件。这些文件来自生物信息学、遗传代码,一直到以特定方式构建的金融信息等应用领域。通信网络产生的文件也具有预定的结构。如今的应用程序员必须常规地处理这些文件,将它们拆分、从一种格式转换为另一种格式、合并它们,并执行许多涉及大数据和分析数据文件、数据挖掘等任务。
在每一项如今普遍存在的任务中,你可以用两种不同的方式来完成。
- 一种是糟糕的方式,即在不了解正确做法的情况下开始编写程序。你的程序可能完成你需要做的事情,但代码会写得非常糟糕,难以维护和扩展。
- 或者,你可以学习计算机科学中所有这些经典技术,它们让你不仅能以优雅、美观和令人满意的方式完成工作,还能创造出对你和他人来说都易于使用的代码。
这就是学习如何编写编译器的动机。
模块路线图
以下是本模块的整体路线图,我们要做的第一件事是专注于编写一个分词器。
这将在编译理论与实践的一个更大背景下完成,即词法分析。

总结

本节课我们一起学习了编译器构建的宏观过程,特别是两级编译的概念。我们明确了本模块(模块4)的目标是开发语法分析器,并了解了将其分为分词器和解析器两部分的原因。我们还探讨了学习编译器构建对于提升编程能力和处理结构化数据文件的重要性。接下来,我们将深入词法分析,开始构建分词器。
046:词法分析

在本节课中,我们将要学习词法分析。词法分析是编译器前端的关键步骤,它负责将原始的字符流转换为有意义的“词法单元”或“标记”流,为后续的语法分析做好准备。
上一节我们介绍了语法分析器的整体结构,它由词法分析器和语法分析器两个主要模块组成。本节中,我们来看看其中的第一个模块——词法分析器。
什么是词法分析?
词法分析,通常由一个称为“词法分析器”或“标记器”的程序模块完成。它的核心任务是将输入的、仅由字符组成的原始数据流,转换成一个由有意义的“标记”组成的流。这里的“有意义”是针对我们正在分析的语言(例如Jack语言)而言的。
一旦我们得到了这个标记流,就可以将其交给编译器的后续阶段(如语法分析器)。从这一刻起,我们可以完全忘记原始的输入文件。这非常有用,因为原始文件包含了许多对编译器无关的“噪音”,例如空格、制表符、换行符和注释。因此,词法分析是对文件进行的一种简单而重要的预处理,为编译过程奠定了良好的基础。
什么是标记?
一个标记,是在目标语言中有意义的一个字符串或字符序列。不同的编程语言对标记有不同的定义。
例如,考虑语句 x++。在C语言中,这个语句很有意义,因为它可以被分解为两个有意义的标记:x 和 ++。然而,如果将完全相同的输入交给一个Jack语言的标记器,它虽然也能将其分解为标记(得到三个标记:x、+ 和 +),但在后续的编译过程中,语法分析器会报错,因为在Jack语言中并没有 ++ 这个运算符。
因此,如果你要编写一个标记器,必须获得一份明确的语言规范文档,其中清晰定义了该语言中哪些字符序列构成合法的标记。
Jack语言的标记类别
在Jack语言中,标记被明确地分为以下五类:
以下是Jack语言中定义的标记类别:
- 关键字:例如
class、method、if、while等,约有20个。 - 符号:例如
+、-、*、/、=、{、}、;等。 - 整型常量:范围在
0到32767之间的数字。 - 字符串常量:由一对双引号
"包围的任何字符序列。 - 标识符:用于命名变量、方法、类等,由字母、数字和下划线组成,且不以数字开头。
每一类标记都有清晰、无歧义的定义。基于这份词法定义,我们就可以编写程序来实现标记器。
标记器的功能与使用
标记器通常被实现为一个类或模块。它会提供一系列有用的服务,允许我们将输入视为一个标记流来操作。
以下是标记器提供的主要服务:
- 判断:是否还有更多标记需要处理?
- 获取:获取下一个标记。
- 查询:当前标记的类型是什么?其值是什么?
下面是一个使用这些服务的程序示例。程序 TokenizeTest 会构造一个标记器对象,然后处理输入文件。
// 伪代码示例
tokenizer = new JackTokenizer(inputFile);
while (tokenizer.hasMoreTokens()) {
tokenizer.advance(); // 前进到下一个标记
tokenType = tokenizer.tokenType(); // 获取当前标记类型
tokenValue = tokenizer.getValue(); // 获取当前标记值
// 输出:<tokenType> tokenValue </tokenType>
outputFile.writeLine("<" + tokenType + "> " + tokenValue + " </" + tokenType + ">");
}
对于输入文件中的每一个标记,程序会将其输出,并用标签包围以说明其类型。这样生成的结果不仅列出了程序中的所有标记,也清晰地标明了它们的类型。这个测试并非随意举例,它实际上正是后续我们的语法分析器使用标记器的方式。这里生成的信息对编译器至关重要,而标记器让我们能够轻松地获取它。
请注意,完成此过程后,我们就不再需要源代码文件了。因为从现在开始,标记器对象将成为编译器的输入源。
总结
本节课中我们一起学习了词法分析。我们了解到,标记器是语法分析器中的第一个模块,它负责根据语言规范将字符流分组为有意义的标记(如关键字、符号、常量等)。现在我们已经知道了如何将字符分组为标记,接下来就可以继续讨论语法分析了。


但在深入语法分析之前,我们需要先了解一些关于“文法”的知识,这将是下一单元的主题。
047:文法 🧩

在本节课中,我们将要学习文法的概念。文法定义了如何将有效的单词(即词法单元)组合成有意义的句子或程序语句。理解文法是进行语法分析的基础。
在上一单元,我们讨论了词法分析,并建立了将程序视为一系列词法单元的概念能力。现在,你可能会同意,仅仅拥有一组有效的词法单元,并不一定意味着我们拥有一个有效的文本或程序。例如,在英语中,考虑以下一组词法单元:“Red drives a he”。这毫无意义,尽管每个词法单元本身都是合法的英语单词。然而,它们的顺序不正确。如果说“He drives a red car”,一切就变得清晰明了。因此,不仅词法单元本身重要,它们的顺序也至关重要。
定义词法单元如何合法组合的规则集合,就称为文法。从技术上讲,文法是一组规则,每条规则定义了一种模式,指示如何将词法单元串联起来,以构成底层语言中有意义的句子或语句。
文法示例:英语子集
以下是一个英语语言的子集文法示例。在这个文法中,我们有句子。每个句子由一个名词短语后跟一个动词短语构成。
- 句子 → 名词短语 动词短语
- 名词短语 → 限定词? 名词
- 动词短语 → 动词 名词短语
- 名词 →
dog|school|dinner|Dina| ... - 动词 →
went|ate|said| ... - 限定词 →
the|my|a| ...
这个文法具有递归性。这个子集足够丰富,可以接受诸如“Dina went to school”、“She said”、“The dog ate my homework”等输入。例如,分析“The dog ate my homework”:
- “The”是限定词,“dog”是名词,它们构成一个名词短语。
- “ate”是动词,“my homework”是另一个名词短语,它们构成一个动词短语。
- 因此,我们有一个名词短语后跟一个动词短语,根据此文法,这是一个合法的句子。
文法的规则类型
观察这个文法,可以发现它由一组规则构成。这些规则可以分为两大类:
- 终结符规则:规则的右侧仅由常量(即具体的词法单元)构成。
- 非终结符规则:规则的右侧包含其他规则的名称,也可能包含常量。
一个文法就是由终结符规则和非终结符规则组成的集合。这个观察在我们后续的工作中会很有用。
聚焦Jack语言文法
现在,让我们将注意力转向Jack语言的文法。本课程不是关于一般计算语言学,而是关于编译。因此,我们关注Jack程序的文法。
一个Jack程序由一系列语句构成。我们聚焦于Jack语言的一个子集,其中包含三种可能的语句:if、while和let。以下是该子集的文法定义:
statements→statement*statement→ifStatement|whileStatement|letStatementifStatement→if(expression){statements}whileStatement→while(expression){statements}letStatement→letvarName=expression;expression→term(opterm)?term→varName|constantvarName→ 一个不以数字开头的字符串constant→ 任意十进制数字op→+|-|*|/|&|||<|>|=
以上构成了Jack语言一个子集的完整文法。现在我们可以开始考虑各种输入,并判断它们是否符合此文法。
文法分析示例
以下是几个输入示例及其是否符合文法的分析:
-
输入:
let x = 100;- 分析:以
let开头,符合letStatement模式。x是有效的varName。接着是=符号。100是constant,因此是term,进而构成expression。最后是;。所有部分都匹配letStatement规则。结论:接受。
- 分析:以
-
输入:
let x = x + 1;- 分析:
let x =部分同上。x + 1中,x是term,+是op,1是constant(即term),因此x + 1匹配expression规则(termopterm)。结论:接受。
- 分析:
-
输入:
while ( n < limit )- 分析:以
while开头,符合whileStatement模式。接着是(。n < limit可以分析为expression。接着是)。然而,whileStatement规则要求后面必须有{statements},但输入在此处结束。结论:拒绝(语法错误)。
- 分析:以
-
输入:
if (key = 81) { let exit = true; do Output.printString("Bye"); }- 分析:这是一个
ifStatement。括号内的key = 81是expression。花括号{}内包含两个statement(一个letStatement和一个do语句,虽然do不在我们当前子集但原理相同),这符合statements(零个或多个statement)的定义。结论:接受。
- 分析:这是一个
-
输入:
let x = 5 * (y + 3);- 分析:你可以尝试自行分析。它同样符合
letStatement的规则,因为5 * (y + 3)是一个合法的expression。结论:接受。
- 分析:你可以尝试自行分析。它同样符合
语法分析的作用
语法分析就是判断给定输入是否符合特定文法的艺术与科学。请注意,在这个确认过程中,我们同时也揭示了输入的全部语法结构,因为我们分析了输入如何对应规则的各个组成部分。通过这样做,我们构建了输入底层的语法结构树。
顺便一提,我们的大脑正是以某种神秘的方式(尽管越来越可被理解)在做类似的事情。当我们听到一个句子时,我们会尝试将其与我们大脑中固有的、通过一生语言学习形成的模式进行匹配。我们的大脑拥有这种理解句子的卓越能力,即使句子不完全符合语法。如果没有这种能力,世界上所有的诗人都会失业。但是,计算机程序和编译器没有这种卓越的能力。当你处理编译器时,输入必须完美匹配文法。如果有任何不符,编译器就会报错并提示“语法错误,你必须修复你的程序”。
本节课中我们一起学习了文法的基本概念,包括其定义、规则类型(终结符与非终结符),并通过英语和Jack语言的例子了解了如何使用文法来判断输入的有效性。语法分析是编译过程中的关键步骤,它确保程序在结构上是正确的。

下一单元,我们将讨论语法分析树的概念,这是一种我们用来描述输入语法结构的数据结构。
048:语法分析树 📚

在本节课中,我们将学习语法分析树的概念。语法分析树是一种用于表示输入文本(无论是自然语言还是编程语言)语法结构的树形图。我们将通过对比英语和Jack编程语言的例子,来理解其工作原理和表现形式。
语法分析树的概念
上一节我们介绍了语法的概念。本节中我们来看看如何用结构化的方式表示一个符合语法的句子的形态。
语法分析树是一种在计算语言学中用于记录输入文本完整语法结构的方法。它本质上是一种经典的计算机科学数据结构——树。树是一种递归结构,它从一个根节点开始,然后分支成子树,依此类推。
输入的语法结构同样是递归的。具体来说,如果我们自上而下地观察这个形态,它会记录一个事实:我们有一个句子,这个句子由诸如名词短语和动词短语等子树描述。而这些子树本身又由更低层级的子树构成,我们可以持续扩展这棵树,直到到达树的边界。
树的边界有时被称为叶子节点或终结节点。在词法分析的语境下,终结节点就是构成输入的实际标记。因此,语法结构是位于输入之上的一种上层结构,用于描述其语言形态。
从英语到Jack语言
以上是关于像英语这样的语言的故事,当然,这是一个非常简单的例子。现在让我们转向Jack语言。
在Jack语言中,我们有一套不同的语法,以及对应编程语言(即Jack语言)中某些语句子集的不同类型的输入。
以下是这个特定输入对应的语法分析树示例。
我们可以看到,这里再次得到了一棵树。虽然我使用了曲线而非直线,但实际上,这与之前的概念完全相同。
语法分析树的表示形式
我想请大家注意,语法分析树的概念是一个抽象的产物,它位于所有这些漂亮图表之上。而我在这里绘制的图表,只是描述这个语法分析树的一种方式。
为了说明这一点,让我们聚焦于此处看到的语法分析树的某个子集,放大观察这个小部分。请注意,这个子树描述或对应于输入 count + 1。
我想向大家展示描述这同一个语法分析树的另一种方式,如下所示。
这里我们看到的是一种叫做XML的格式。XML格式是一种公认的描述结构化数据的方式。基本上,我们使用标记标签来描述数据的结构或构成数据的项的结构。
在这个例子中,我们使用了一些尚未定义的约定,但我们可以大致猜测其含义。我们记录了这样一个事实:这里有一个表达式,该表达式由一个项、后跟一个符号、再跟另一个项组成。而这些项本身也由更低层级的结构构成:第一个项由一个标识符构成,第二个项由一个整型常量构成,依此类推。
因此,如果我愿意,可以选择使用这里展示的格式,即某种约定的XML文本来创建我的语法分析树。
重要观察与总结
在结束之前,我想提出两个重要的观察。
首先,我们在这里看到的是对应于输入 count + 1 的XML文件。可以想象,如果我们需要为一个完整的Jack程序创建语法分析树,最终将得到一个庞大的XML文件,而这完全没有问题。
其次,我尚未提及实际构建这个文件的过程。我们如何做到呢?从输入开始,到最终得到这个结构良好的输出,其背后的逻辑是什么?这个逻辑正是下一单元即将讨论的主题:语法分析逻辑。

本节课中我们一起学习了语法分析树的概念。我们了解到它是一种表示句子语法结构的树形图,具有递归特性,并且可以用图表或XML等结构化格式来描述。它为下一阶段理解语法分析的具体逻辑奠定了基础。
049:解析器逻辑 🧩

在本单元中,我们将探讨解析器(Parser)的逻辑。我们将了解如何根据给定的语法规则,将输入的源代码(如Jack语言代码)转换成一个结构化的、描述其语法成分的树状表示(例如XML格式)。
在上一单元,我们讨论了语法分析树(Parse Tree),并看到了英语句子和Jack代码的解析示例。我们指出,既可以用图示来表示语法树,也可以用一种约定的格式(如XML)来描述输入的结构。那么,核心问题自然是:我们如何从一个原始的输入开始,最终得到这样一个结构清晰、描述输入语法结构的输出呢?
解析器的实现逻辑
为了解答这个问题,我们直接来看实现解析器将要用到的代码逻辑。在本模块后续部分,我们将开发一个名为“解析器”的程序,实际上这个模块的名称将是“编译引擎”(Compilation Engine)。
这个编译引擎将由一系列方法构成。几乎对应语法中的每一个非终结符规则,我们都会有一个方法。例如,语法中有一个名为 statements 的规则,相应地我们就有一个 compileStatements 方法;有一个 ifStatement 规则,就有一个 compileIfStatement 方法,依此类推。
这些方法负责解析输入中对应特定规则的部分。虽然有些规则没有专门的方法,但它们会在其他规则的上下文中被隐式处理,这个细节我们稍后会讨论。
方法的工作原理
为了说明,让我们聚焦于其中一个方法:compileWhileStatement,它对应 whileStatement 规则。我们来看看这个子程序在实践中如何工作。
总的来说,这些方法的编写逻辑直接来源于语法规则。具体做法如下:
- 遵循规则的右侧:我们严格按照规则右侧描述的“模式”来编写代码。
- 处理终结符:如果规则右侧指定了一个终结符(Token,如
while、(),我们就检查输入中的当前Token是否匹配。如果匹配,就将其记录到输出文件中,然后让输入前进到下一个Token。 - 处理非终结符:如果规则右侧指定了一个非终结符规则(如
expression),我们就调用专门处理这个非终结符的对应方法(如compileExpression)。
可以看到,整个过程是高度递归的。我们不断递归调用,直到耗尽输入中的所有Token,这样就完成了对给定输入的解析。
解析过程模拟
让我们通过一个具体的 while 循环示例,来模拟解析器的完整工作流程。假设输入是 while (count < 10) { let count = count + 1; }。
以下是解析步骤的详细模拟:
- 启动:我们启动词法分析器(Tokenizer),它负责将输入分解为Token序列。第一个Token是
while。 - 调用
compileWhileStatement:由于第一个Token是while,我们调用compileWhileStatement方法。该方法开始运行,并在输出中写入开始标签<whileStatement>。 - 处理
while:规则说第一个应该是whileToken。检查当前Token,确实是while。将其记录到输出(如<keyword> while </keyword>),然后让输入前进。 - 处理
(:规则说下一个应该是(。检查当前Token,确实是(。记录并前进。 - 处理
expression:规则说下一个应该是expression。我们调用compileExpression方法。compileExpression查看其规则,发现应以一个term开始。于是调用compileTerm。compileTerm查看其规则,发现当前Tokencount是一个varName。记录count并前进。- 控制权回到
compileExpression。它检查规则,发现后面可能有一个可选的op和另一个term。当前Token是<,是一个操作符。记录<并前进。 - 再次调用
compileTerm来处理下一个term。当前Token10是一个constant。记录10并前进。 compileTerm结束,compileExpression也结束。
- 处理
):控制权回到compileWhileStatement。规则说下一个应该是)。检查当前Token,确实是)。记录并前进。 - 处理
{:规则说下一个应该是{。检查当前Token,确实是{。记录并前进。 - 处理
statements:规则说下一个应该是statements。我们调用compileStatements方法。compileStatements查看其规则,寻找一个statement。当前Token是let,表明是一个letStatement。于是调用compileLetStatement。compileLetStatement按照其规则依次处理let、varName(count)、=、expression(count + 1)、;。每处理一个Token就记录并前进。compileLetStatement结束,compileStatements发现没有更多statement,也结束。
- 处理
}:控制权回到compileWhileStatement。规则说下一个应该是}。检查当前Token,确实是}。记录并前进。 - 结束:没有更多Token需要处理。
compileWhileStatement方法结束,并在输出中写入结束标签</whileStatement>。至此,解析完成,我们得到了描述输入结构的XML文件。
代码结构示例
现在,让我们更具体地看看 compileWhileStatement 方法的代码结构。开发者会以语法规则为指导来编写解析器代码。
以下是 compileWhileStatement 方法的一个伪代码示例:
// 伪代码示例
public void compileWhileStatement() {
// 写入开始标签
output.write("<whileStatement>");
// 1. 处理 'while' 关键字
eat("while"); // 期望并消耗掉 "while" 这个Token
// 2. 处理 '('
eat("(");
// 3. 处理表达式
compileExpression();
// 4. 处理 ')'
eat(")");
// 5. 处理 '{'
eat("{");
// 6. 处理语句序列
compileStatements();
// 7. 处理 '}'
eat("}");
// 写入结束标签
output.write("</whileStatement>");
}
// 一个私有的辅助方法
private void eat(String expectedToken) {
if (currentToken.equals(expectedToken)) {
// 将当前Token写入输出(带适当标签)
output.writeToken(currentToken);
// 前进到下一个Token
advance();
} else {
// 如果不匹配,抛出语法错误
throw new SyntaxError("Expected: " + expectedToken + ", but found: " + currentToken);
}
}
如你所见,每个 compileXxx 方法的代码都严格遵循对应 xxx 规则的右侧描述。每个方法都负责推进并处理属于该规则的所有Token。通过这些方法的递归调用,我们就能完成对整个输入的解析。
关于语法的理论说明
最后,我们以一些关于所用语法类型的理论说明来结束本单元。
首先,引入 LL 文法 的概念。LL文法是一种可以被递归下降解析算法(即我们正在使用的这种算法)无回溯地解析的文法。这意味着这种文法非常“友好”:一旦你开始解析某部分,就永远不需要回头。当你判定当前是一个 while 语句还是一个 if 语句时,你不必撤销进度、重新分析。这个特性使得解析器的实现相对简单。
其次,是 LL(k) 的概念。一个LL(k)解析器在确定使用哪条规则之前,最多需要向前查看 k 个Token。幸运的是,我们目前看到的Jack语法是 LL(1) 的。这意味着,只要我手中有一个特定的Token(如 while、let、do),我就能立即知道应该调用哪条规则,无需回溯。
这与自然语言(如英语)形成鲜明对比。英语不是LL(1)。例如,听到单词“Lift”时,你无法确定其语法角色。它可能是一个动词(如“Lift me”),也可能是一个形容词(如“Lift operator”)。你可能需要向前查看多个单词,甚至可能需要回溯,才能确定正确的语法结构。人类大脑能以惊人的速度完成这种解析,但用计算机程序实现则极其困难,因为英语可能是类似LL(5)或LL(6)的文法。而大多数编程语言被设计成LL(1),这使得解析它们比解析自然语言要容易得多。
总结

在本单元中,我们一起学习了解析器的核心逻辑。我们了解到,解析器通过一系列与语法规则对应的方法,以递归下降的方式工作。每个方法负责处理输入中符合特定规则的部分,通过检查、记录Token和递归调用其他方法,最终将线性的Token序列转换为结构化的语法树表示。我们还了解到,像Jack这样的编程语言通常采用LL(1)文法,这使得其解析过程可以高效、无回溯地进行,大大简化了解析器的实现。在接下来的部分,我们将具体审视Jack语言的完整语法规则。
050:Jack文法 🧩

在本单元中,我们将深入探讨Jack编程语言的文法。我们将学习如何形式化地描述Jack语言的结构,并理解这些文法规则如何指导我们后续构建语法分析器。
文法符号定义 📝
上一节我们介绍了文法和语法分析的一般概念。本节中,我们来看看Jack文法所使用的具体符号表示法。
以下是描述Jack文法时使用的符号规则:
- 字面量:用黑色字体和引号表示,例如
"class"。这表示输入中必须原样出现的特定词法单元。 - 元语言:所有非字面量的绿色部分,是我们为描述Jack文法而发明的描述符。
- 分组:使用圆括号
()对词法单元进行分组。 - 选择:使用竖线
|表示在多个选项中选择一个。 - 可选:使用问号
?表示某个元素出现零次或一次。 - 重复:使用星号
*表示某个元素出现零次或多次。
这些简单的规则足以描述编程语言的结构。
Jack文法全貌 🗺️
掌握了符号规则后,我们现在可以完整地展示Jack文法。它看起来可能有些复杂,但这只是因为我们将整个语言压缩到了一张幻灯片上。实际上,整个文法非常简洁,分为四个主要部分。
1. 词法元素
Jack程序由以下基本的词法单元(或称为“原子”)构成:
- 关键字:例如
class,constructor,function,method,field,static,var,int,char,boolean,void,true,false,null,this,let,do,if,else,while,return。 - 符号:例如
{,},(,),[,],.,,,;,+,-,*,/,&,|,<,>,=,~。 - 整数常量:例如
123,0,4567。 - 字符串常量:例如
"Hello, World!"。 - 标识符:由字母、数字和下划线组成的序列,且不以数字开头,例如
myVariable,_temp,Screen12。
2. 程序结构
一个Jack程序由一个或多个类组成,每个类被单独存储、处理和编译。
以下是类的结构定义:
class ::= ‘class’ className ‘{‘ classVarDec* subroutineDec* ‘}’
class是关键字。className是一个标识符。{和}是必须的符号。classVarDec*表示零个或多个类变量声明。subroutineDec*表示零个或多个子程序声明。
类变量声明的规则如下:
classVarDec ::= (‘static’ | ‘field’) type varName ( ‘,’ varName )* ‘;’
例如,static int x, y, z; 符合此规则。
类型定义如下:
type ::= ‘int’ | ‘char’ | ‘boolean’ | className
Jack只有三种基本类型(int, char, boolean),也支持类名作为类型。
子程序声明的规则如下:
subroutineDec ::= (‘constructor’ | ‘function’ | ‘method’) (‘void’ | type) subroutineName ‘(‘ parameterList ‘)’ subroutineBody
子程序可以是构造函数、函数或方法,可以返回void或某种类型。
参数列表是可选的:
parameterList ::= ((type varName) ( ‘,’ type varName )*)?
? 表示整个参数列表可能出现零次或一次。
3. 语句
Jack程序包含多种语句,以下是语句的总体和具体定义:
statements ::= statement*
statement ::= letStatement | ifStatement | whileStatement | doStatement | returnStatement
以下是各类语句的规则:
- 赋值语句:
letStatement ::= ‘let’ varName ( ‘[‘ expression ‘]’ )? ‘=’ expression ‘;’ - 条件语句:
ifStatement ::= ‘if’ ‘(‘ expression ‘)’ ‘{‘ statements ‘}’ ( ‘else’ ‘{‘ statements ‘}’ )? - 循环语句:
whileStatement ::= ‘while’ ‘(‘ expression ‘)’ ‘{‘ statements ‘}’ - 过程调用语句:
doStatement ::= ‘do’ subroutineCall ‘;’ - 返回语句:
returnStatement ::= ‘return’ expression? ‘;’
4. 表达式
表达式是文法中相对复杂的部分,其定义如下:
expression ::= term (op term)*
表达式由一个term(项)开头,后面可以跟零个或多个由运算符op连接的term。
使表达式变得复杂的是term规则本身:
term ::= integerConstant | stringConstant | keywordConstant | varName | varName ‘[‘ expression ‘]’ | subroutineCall | ‘(‘ expression ‘)’ | unaryOp term
term可以是整数常量、字符串常量、关键字常量、变量名、数组访问、子程序调用、括号表达式或一元运算表达式。
处理表达式的挑战在于,当遇到一个标识符(变量名)时,它可能代表多种情况(简单变量、数组元素、子程序调用等)。为了确定具体是哪一种,语法分析器需要“向前看”下一个词法单元。这是Jack语言中唯一需要LL(2)分析能力的地方,其他部分都是LL(1)的。
文法与语法分析器的关系 🔗
我们给出完整的Jack文法,是因为它将作为我们编写语法分析器的“配方”。
语法分析器将由一组编译例程构成,几乎对应文法中的每一个非终结符规则。每个分析例程将根据对应规则的右侧部分来编写,负责生成所需的XML输出,并在此过程中调用其他分析例程。这体现了文法与依其运作的语法分析器之间紧密的关系。
总结 📚

本节课中我们一起学习了Jack编程语言的完整文法。我们了解了用于描述文法的符号系统,并将文法分解为四个主要部分:词法元素、程序结构、语句和表达式。我们特别注意到,表达式中term规则的处理相对复杂,因为遇到标识符时需要“向前看”一个词法单元来确定其具体角色。理解这些文法是后续动手构建Jack语法分析器的关键基础。在下一个单元,我们将开始讨论如何编写这个分析器本身。
051:Jack分析器输出规范

在本单元中,我们将详细说明Jack语法分析器(或称解析器)应输出的具体内容。我们将了解如何通过生成结构化的XML输出来验证分析器对源代码的语法理解,而无需等待代码生成器的开发。
概述
在之前的单元中,我们泛泛地讨论了语法树和解析器逻辑。本单元将具体说明Jack分析器应输出的内容。我们正在开发一个编译器,但当前模块仅关注语法分析器。一个有趣的问题是:如何独立地对语法分析器进行单元测试?如何在不生成代码的情况下,确认分析器正确理解了源代码?答案是:我们可以将代码生成器的开发推迟到后续阶段。目前,我们可以要求分析器以预定的方式输出源代码的结构化表示,具体来说,就是生成XML代码。
终端规则的输出处理
首先,我们来看如何处理符合语法中终端规则的输入。根据Jack语法,终端元素包括关键字、符号、整数常量、字符串常量或标识符。
以下是处理此类输入的方法:
- 对于关键字(如
method),输出格式为:<keyword> method </keyword>。 - 对于符号、整数常量等,只需用相应的XML标签将其包裹并输出即可。
处理终端规则的输出非常简单直接。
非终端规则的输出处理(主要部分)
接下来,我们讨论非终端规则。非终端规则可分为两个子集。首先,我们看构成语法中绝大部分规则的那个子集。
当输入符合此类规则时,处理方法如下:
- 在输出开始处写入该非终端规则的名称作为开始标签。
- 然后,递归地输出构成该规则主体的所有内容。
- 最后,写入结束标签。
例如,对于输入 return x;:
- 分析器识别出这是一个
returnStatement,输出<returnStatement>。 - 根据语法规则,
returnStatement应以关键字return开始。分析器输出<keyword> return </keyword>。 - 接着应有一个
expression。分析器递归处理,发现表达式由term构成,而term在这里是标识符x。因此输出<identifier> x </identifier>。 - 最后输出
</returnStatement>。
分析器必须递归地发出构成规则主体的所有输出,这些输出本身可能是嵌套的,因此解析器必须是一个递归程序。
非终端规则的输出处理(浅层规则)
现在,我们来看第二个非终端规则子集,我有时称之为浅层规则或从语法角度看“不那么有趣”的规则。
处理符合此类规则的输入时,分析器不会生成任何相关的XML标记。让我举例说明。
让我们关注描述 let 语句和 varName 的这部分Jack语法。varName 就是一条我们决定不在XML输出中包含的非终端规则。
例如,处理输入 let x = 17;:
- 分析器记录这是一个
letStatement,输出<letStatement>。 - 记录
let关键字,输出<keyword> let </keyword>。 - 根据规则,接下来应有一个
varName。但是,查看输出示例,我们不会输出<varName>标签。 我们跳过了varName规则,直接深入处理其右侧的标识符x,输出<identifier> x </identifier>。
再次注意,varName 规则不生成任何标记。我们决定这样做,是因为此处列出的一些规则(如 varName)在结构上非常简单(例如,一个规则的名称只是另一个规则主体的名称)。我们允许自己跳过解析这些简单规则,以避免如果不采取这些快捷方式可能产生的一些冗余输出。
总结

本节课我们一起学习了Jack语法分析器应输出的具体规范。我们了解到,分析器通过生成反映Jack语法结构的XML输出来证明其对源代码的理解。对于终端规则,直接输出带标签的内容;对于主要的非终端规则,递归输出其完整结构;而对于一些浅层规则,则选择跳过其标记以简化输出。在单元4.5中,我们已从宏观上讨论了分析器生成输出的方法(即为每个规则配备一个处理方法),下一个单元将提供Jack分析器的参考实现。结合这两个单元,你将获得编写自己的Jack分析器所需的全部信息。
052:Jack分析器建议实现方案 🛠️

在本节课中,我们将学习如何构建一个Jack语法分析器。我们将讨论其核心模块:Jack分词器、编译引擎以及驱动它们的主程序Jack分析器。
概述
到目前为止,我们以Jack语言为例,讨论了语法分析的一般概念。在本单元及下一单元,我们将实际动手构建一个Jack分析器。我们的最终目标是开发一个编译器,并决定将编译器的开发分为两个独立项目。在当前项目中,我们开发语法分析器;在下一个项目(模块)中,我们将开发代码生成器。
为了独立于系统其他部分对语法分析器进行单元测试,我们决定让语法分析器生成一个XML代码文件。通过检查这个文件,我们可以判断语法分析器是否正常工作。关于“正常工作”的具体定义,我们将在下一单元详细讨论项目10时给出。
我们建议的语法分析器实现将包含三个模块:一个Jack分词器模块、一个编译引擎以及一个名为Jack分析器的主程序。本单元我们将讨论每个模块,并给出其API,以便你能够按照我们的建议实现自己的分析器。
Jack分析器的使用方式
首先,让我们从最终如何使用Jack分析器开始说明。假设这个程序是用Java编写的,那么在命令行中,我们会输入程序名 JackAnalyzer,并提供一个输入参数。
输入参数可以是以下两种之一:
- 一个
.jack文件(例如MyProgram.jack)。 - 一个包含零个或多个
.jack文件的目录名。
输出结果如下:
- 如果输入是单个
.jack文件,输出将是一个同名的.xml文件。 - 如果输入是一个目录名,将为该目录中的每个
.jack文件生成一个对应的.xml文件,并保存在同一目录下。
这就是我们的Jack分析器期望的行为。
模块架构与工作流程
Jack分析器将使用Jack分词器和编译引擎的服务。分词器和编译引擎都需要你根据Jack语法和本模块之前给出的所有指导原则来开发。
Jack语法的第一部分描述了语言的词法元素(即标记)。语言共有五类标记:关键字、符号、整型常量、字符串常量和标识符。Jack分词器将负责处理这些标记。
语法的其余部分将由编译引擎处理。
因此,从现在到本单元结束,我们将讨论这两个模块:首先介绍如何开发分词器,然后介绍如何开发编译引擎。
Jack分词器模块 🔤
分词器在编译引擎的上下文中运行。编译引擎使用分词器的服务。分词器封装了输入文件,使我们无需再关心原始文件本身。对我们而言,分词器就是输入源。我们可以让它前进到下一个标记,询问当前标记的类型等信息。
最终,分词器将是一个软件模块,包含一个构造函数和一系列方法。
编写分词器意味着编写一个类(如果用Java、C#或C++等语言),该类包含这些方法。它使用输入文件作为输入,逐个读取字符并将其分组为标记。
其中最具挑战性的方法是 advance,因为它负责将字符分组为标记,并判断标记的类型。这并不十分复杂,但需要考虑一些特殊情况。例如,一连串没有空格的字符并不一定代表一个标记,可能包含多个标记(如 getX() 包含三个标记:getX、( 和 ))。分词器必须足够智能来区分它们。
实现逻辑是:从第一个字符开始构建标记,当遇到不属于当前标记类型的字符时(例如,标识符后遇到符号 (),就结束当前标记的构建,并开始下一个标记。我们可以通过查询语法定义中的合法字符集来判断。
Jack分词器API
以下是Jack分词器的应用程序接口:
- 构造函数
JackTokenizer(inputFile)- 打开输入文件/流并准备进行标记化。
boolean hasMoreTokens()- 判断输入中是否还有更多标记。
void advance()- 从输入中获取下一个标记,并将其设为当前标记。只有在
hasMoreTokens()为真时才能调用此方法。
- 从输入中获取下一个标记,并将其设为当前标记。只有在
tokenType tokenType()- 返回当前标记的类型。
tokenType是以下常量之一:KEYWORD,SYMBOL,IDENTIFIER,INT_CONST,STRING_CONST。
- 返回当前标记的类型。
keyword keyword()- 返回当前标记的关键字。仅当
tokenType()为KEYWORD时调用。
- 返回当前标记的关键字。仅当
char symbol()- 返回当前标记的字符。仅当
tokenType()为SYMBOL时调用。
- 返回当前标记的字符。仅当
string identifier()- 返回当前标记的标识符。仅当
tokenType()为IDENTIFIER时调用。
- 返回当前标记的标识符。仅当
int intVal()- 返回当前标记的整数值。仅当
tokenType()为INT_CONST时调用。
- 返回当前标记的整数值。仅当
string stringVal()- 返回当前标记的字符串值(不含双引号)。仅当
tokenType()为STRING_CONST时调用。
- 返回当前标记的字符串值(不含双引号)。仅当
实现这些方法相对简单。你可以自由使用编程语言提供的字符串处理工具,如正则表达式。
编译引擎模块 ⚙️
与分词器类似,编译引擎也以语法为指导进行设计。简而言之,我们将开发一个名为 CompilationEngine 的类或模块。
编译引擎从Jack分词器获取输入,因此我们无需再关心原始输入文件。它可以愉快地询问当前标记、在输入中前进等。
它将有一个构造函数和一系列 compileXxx 例程,几乎对应语法中的每一个 xxx 规则。每个例程负责处理对应规则右侧的所有标记。
我们在第4.5单元专门模拟了编译引擎的逻辑和操作。如果你需要更多关于如何实现编译引擎的指导,强烈建议回顾第4.5单元。
编译引擎API
以下是编译引擎的应用程序接口:
- 构造函数
CompilationEngine(inputTokenizer, outputFile)- 创建一个新的编译引擎,并指定其输入(分词器)和输出文件/流。
void compileClass()- 编译一个完整的类。
void compileClassVarDec()- 编译一个静态变量声明或字段声明。
void compileSubroutine()- 编译一个完整的方法、函数或构造函数。
void compileParameterList()- 编译一个(可能为空的)参数列表,不包括括号
()。
- 编译一个(可能为空的)参数列表,不包括括号
void compileVarDec()- 编译一个
var声明。
- 编译一个
void compileStatements()- 编译一系列语句,不包含花括号
{}。
- 编译一系列语句,不包含花括号
void compileDo()- 编译一个
do语句。
- 编译一个
void compileLet()- 编译一个
let语句。
- 编译一个
void compileWhile()- 编译一个
while语句。
- 编译一个
void compileReturn()- 编译一个
return语句。
- 编译一个
void compileIf()- 编译一个
if语句,可能包含else分支。
- 编译一个
void compileExpression()- 编译一个表达式。
void compileTerm()- 编译一个项。如果当前标记是标识符,此例程必须区分变量、数组元素还是子程序调用。
void compileExpressionList()- 编译一个(可能为空的)逗号分隔的表达式列表,不包含括号
()。
- 编译一个(可能为空的)逗号分隔的表达式列表,不包含括号
关于未覆盖的语法规则
Jack语法中有一些规则没有对应的独立 compile 方法。语法大约有21条非终结符规则,而上面的API只有15个编译方法。缺少的6条规则(如 statement、subroutineCall 等)的逻辑,由调用它们的上层方法处理。
我们之所以不直接为这些规则创建方法,是因为它们在整体语法中的作用相对次要或浅显。例如,statements 规则由 compileStatements() 方法处理,该方法使用循环处理零个或多个 statement 实例,并根据每个语句的第一个标记来决定调用 compileIf、compileWhile 等具体方法。因此,没有单独的 compileStatement 方法。
Jack分析器主程序 🚀
Jack分析器模块相对简单,因为它只是通过使用我们之前讨论过的、更具挑战性的分词器和编译引擎的服务来驱动整个流程。
Jack分析器是我们程序的最顶层模块。它接收一个 .jack 文件名或一个包含多个 .jack 文件的目录名。
对于每个输入文件,它执行以下流程:
- 根据输入文件名(
.jack)创建一个Jack分词器对象。 - 创建一个输出文件,其名称与输入文件相同,但扩展名为
.xml,并准备写入。 - 创建并使用一个编译引擎,将输入(Jack分词器)编译到输出文件。
这就是Jack分析器的全部设计工作。
总结
本节课我们一起学习了Jack语法分析器的建议实现方案。我们详细介绍了三个核心组件:
- Jack分词器:负责读取源代码并将其分解为有意义的标记(如关键字、标识符、符号等)。
- 编译引擎:根据Jack语法规则,接收分词器产生的标记流,并生成结构化的XML输出,反映程序的语法层次。
- Jack分析器:作为主程序,负责处理文件输入/输出,并协调分词器与编译引擎的工作。


在下一单元,我们将实际动手,使用Java或Python等编程语言来构建这个分析器。
053:项目10 - 构建语法分析器 🛠️

在本节课中,我们将动手实践,开始开发我们一直讨论的语法分析器。
概述
我们正在开发一个Jack编译器,本模块的重点是编译器的语法分析器部分。我们面临的挑战是将此模块与系统的其余部分分开进行单元测试。为此,在项目10中,我们决定让语法分析器生成XML代码。通过查看此XML代码,无论是通过肉眼分析还是使用其他技术,我们都能判断语法分析器是否真正理解了源代码。这就是我们需要开发的程序。
项目要求
我们将在Jack语言的背景下进行开发。要求是开发一个语法分析器,能够翻译项目10目录中提供的所有Jack程序。对于每个这样的Jack文件,分析器应生成一个单独的XML输出文件,反映该Jack文件的语法结构,并且此XML文件应与我们提供的对比文件完全相同。
以下是验证输出的方法:
- 测试程序和对比文件位于您计算机的项目目录中。
- 您可以使用我们提供的名为
TextComparer的实用程序,将您的输出与我们的对比文件进行比较。TextComparer是一个比较两个文本文件的工具,它会忽略空白字符的差异。 - 另一种检查XML输出是否合理的方法是将其加载到浏览器(如Chrome或Firefox)或能够处理编程语言的文本编辑器中。这些工具可以帮您查看生成的XML结构,并判断其是否组织良好。
您需要选择一种编程语言来创建语法分析器,本课程目前接受Java和Python。此外,建议您查阅教材,其中有一整章专门介绍语法分析,您可以找到一些有用的信息。
实现计划
首先,我们将开发一个分词器,这是一个定义明确且相对简单的任务。然后,我们将继续开发编译引擎。我们使用“编译引擎”这个术语来描述一个使用我们之前开发的分词器服务的语法分析器。
我们将分两个阶段开发这个编译引擎:首先构建一个相对简单的基础程序,然后将其扩展为一个功能完整的语法分析器。
开发分词器
让我们从分词器开始。我们以一些源代码开始,最终希望得到类似右侧的XML输出。
现在,我们在这里右侧看到的XML示例与之前单元中看到的略有不同。以下是区别:
- 我们决定用名为
<tokens>和</tokens>的开始和结束标签包装整个XML文件。这样做是因为,如果您要将此文件加载到浏览器中以获得美观的结构化视图,浏览器期望看到一个定义良好、有开始和结束标签的XML文件。 - 您还会注意到,字符串常量(如源代码中的单词“negative”)在XML中显示时没有双引号。这是正确的处理方式。您的语法分析器不应输出双引号,而应原样输出字符串常量。
- 最后,您会注意到某些特殊字符(如
<、>、"、&和%)以特殊方式处理。由于它们在HTML中有特殊含义,为了避免浏览器解析错误,我们使用替代字符集(例如用<代表<)。使用此约定后,当您将文件加载到浏览器中时,就能正确看到这些字符。
那么,如何构建这个分词器呢?我们建议您遵循单元48中描述的API。请记住,本模块中的所有内容(分词器和语法分析器)本质上都是文本处理挑战。因此,您可以自由使用编程语言提供的所有字符串处理和正则表达式功能。
开发语法分析器
上一节我们介绍了分词器,本节中我们来看看如何开发语法分析器(或称整体Jack分析器)。这里有一个Jack代码示例,我们需要将其分析或翻译成右侧所示的XML。
我们如何做到这一点呢?我们在这个模块中已经讨论过,但基本上我们建议您从编写一个基础编译引擎开始,它能处理除表达式之外的所有内容。因为表达式处理起来有些棘手,所以我们倾向于单独处理它们。之后,我们再添加对表达式的处理。为了支持这种分阶段开发策略,我们将提供测试文件,使您能够分别对每个步骤进行单元测试。
以下是您的语法分析器在项目10中应处理的测试文件示例。我将突出显示此Jack文件中的表达式。如您所见,每个程序都包含许多表达式。
我们将为您提供此文件的两个版本:第一个是完整测试版本。但我们还提供了另一个版本,称为无表达式版本。在此版本中,我们将每个表达式替换为此方法作用域内的一个变量名。例如,我们不写 y + size < 254,而只写 x。这里x有意义,因为它恰好是此类中的一个字段,因此可以被所有方法全局识别。同样,我们不写 size = size + 2 这个复杂表达式,而是替换为 size = size。
我们的任务是创建这些文件,您无需关心我们是如何做到的。但结果是,这个文件比原始文件更简单,因为它没有复杂的表达式。因此,它适合测试一个能处理除表达式之外所有内容的语法分析器。这就是这些文件的目的。
基本上,当您查看这些程序的无表达式版本时,您会注意到它们在语义上没有意义,甚至有些奇怪。但从语法上讲,它们是完全有效的,因此它们达到了测试目的。我们很高兴地使用它们来测试语法分析器的基础版本。一旦您的语法分析器可以处理这些无表达式文件,您就可以继续完成也能处理表达式的完整版本。
处理表达式
这就是构建语法分析器的总体计划。现在,我想在本单元结束时谈谈如何处理表达式。
回顾Jack语法,在语法分析方面有问题的部分是这里突出显示的term规则。问题是,当当前标记是一个变量名(或某个标识符)时,它可能是一个像x这样的变量名,也可能是一个像x[18]这样的数组条目,或者是一个像x.doSomething()这样的子程序调用。解决我们处于哪种可能性的唯一方法是向前查看下一个标记。我们必须保存当前标记,然后向前查看下一个标记。一旦我们掌握了这两个标记,我们就拥有了决定如何处理它以及从中生成何种XML所需的所有信息。
好的,这是您在开发完整版语法分析器时必须注意的一个细节。
那么,子程序调用呢?看起来子程序调用在语法中是一个单独的规则,确实如此。但由于各种原因,我们决定子程序调用不由单独的编译方法处理,而是作为处理term的一部分来处理子程序调用规则的右侧逻辑。当您开发语法分析器时,您会发现这个建议会使代码更容易编写。因此,不会有compileSubroutineCall方法,子程序调用逻辑将作为处理term的一部分来处理。
总结
本节课中我们一起学习了构建Jack编译器语法分析器的路线图。我们正在开发一个Jack编译器,在本模块中我们开发了编译器的语法分析器。在下一个模块中,我们将开发一个代码生成器,两者结合将实现Jack语言编译器的完整功能。


这就是我们讨论如何处理项目10的单元,接下来是模块4的总结展望单元。
054:视角与展望

在本单元中,我们将回顾第一部分(语法分析与解析)的内容,并回答一些常见问题,为进入下一模块的代码生成部分做好准备。
概述
在本节课中,我们将探讨编译器构建中关于错误处理、技术通用性以及自动化工具使用的几个关键视角。我们将了解为何在本课程中做出某些设计选择,并展望这些技术在其他领域的应用。
错误处理与诊断
上一节我们介绍了语法分析的核心流程。本节中,我们来看看一个现实问题:错误处理。
在真实的编译器中,除了翻译程序,发现并报告错误同样重要。然而,在我们的Jack编译器中,我们决定完全回避错误诊断。我们做了一个简化的假设:需要编译的源代码是无错误的。
如果我们要移除这个简化假设,该如何处理?在某些情况下,捕获错误是简单的。例如,根据Jack语法,let关键字后必须跟一个标识符(如 let x = ...)。当解析器处理到let这个token时,它应预期下一个token是标识符。如果下一个token不是标识符,就可以生成错误信息并终止编译。
然而,通常编译器会更友好。它们不会在发现第一个错误时就立即停止,而是会尝试“控制”当前错误造成的损害,并继续捕获和报告代码中可能存在的其他错误。同时,一个优秀的编译器会尽力精确定位错误在源代码中的确切位置(这个位置可能与编译器检测到错误的位置不同)。
因此,错误处理是一门复杂的艺术,需要精密的诊断方法和复杂的代码,这些内容在本课程中均未涉及。此外,如果我们希望处理错误,就必须保留原始源代码,以便用各种错误信息和警告信息来标注它。这些细节在开发一个功能完整的编译器时非常重要。但在本课程中,我们决定构建一个最小化的编译器,并将错误诊断视为一个可以随时添加到基础编译器中的可选功能。
技术的通用性
接下来,我们探讨一下在本模块中学到的技术是否适用于其他编程语言。
解析器不仅对解析程序有用,对解析任何基于语法的文本也很有用,这在许多应用程序员的职业生涯中很常见。从生物信息学到电子邮件再到金融服务,有大量应用需要处理、分析和呈现结构化文本。我们在本模块中学到的技术可以直接应用于这些场景。
具体来说,在整个模块中,我们涵盖并开发了语法分析的两个最重要元素:词法分析和语法解析。因此,在这方面,你现在对进行语法分析的核心要点有了扎实的实践理解。
然而,与我们熟悉的Java、C++等工业级编程语言相比,我们处理的Jack语言被刻意设计得非常简单。它既没有运算符优先级,也没有继承等许多复杂特性。这种有限的设计显然是故意的,因为我们的目标是能够使用简单而优雅的解析算法来开发一个Jack编译器。
我们讨论的解析算法是自顶向下的。它尽可能少地读取token,并试图尽早确定正在解析的是哪种语言结构。采用这种自顶向下贪婪策略的解析器非常适合语法简单的语言。
当编译器开发者需要解析语法更复杂的语言时,他们通常会使用自底向上的解析,这与我们的方法有很大不同。这种策略自底向上开始,首先构建解析树的终端叶子节点,并将判断当前处理的是哪种语言结构的决定推迟到解析过程的后期阶段。这样,它们可以同时考虑多种处理可能性。但为了实现这一点,这些算法必须使用回溯,导致解析逻辑比我们在本模块中使用的简单自顶向下解析更难开发。
本课程高度聚焦,形式语言和编译中的许多主题都超出了其范围。如果你对编译和计算语言学感兴趣,那么现在我们已经为你打下了坚实的基础,你可以考虑选修编译课程或购买相关书籍进行深入学习。
为何不使用自动化工具?
以下是关于为何在本课程中手动构建词法分析器和解析器,而非使用自动化工具的说明。
Lex和Yacc是两个来自Unix世界的软件工具,如今已有许多不同名称和风格的版本,并用多种编程语言实现。
Lex代表“词法分析器生成器”,指能够自动生成词法分析代码的工具。Yacc代表“又一个编译器编译器”,指能够自动生成语法解析代码的工具。
它们通常一起使用。这些工具的出现是因为许多优秀的程序员都“懒惰”(褒义),他们寻求自动化一切可以自动化的东西。你可能已经注意到,我们在整个模块中概述的解析逻辑(作为开发解析器的“配方”)是高度结构化的。我们建议为Jack语法中的每个产生式规则,在语法分析器中开发一个对应的解析方法,所有这些方法共同构成了分析器。
事实证明,这种开发策略如此结构化,以至于确实可以推广到其他语法并实现自动化。换句话说,如果你有一个定义良好的语言(或某种文件格式)的语法,并且可以用某种约定的机器可读格式来指定它,那么你确实可以编写一个程序,将该语法规范作为输入,并自动生成用C、Java等语言编写的语法分析器。当然,你可能需要进入生成的代码中进行各种修改,但大部分逻辑都可以由Lex和Yacc这类工具自动生成。
那么,回到问题本身:为什么我们坚持要从零开始开发词法分析器和解析器?事实上,我们本可以使用这些自动化工具让事情变得更简单。答案在于本课程的精神——“从第一性原理出发构建”。使用像Lex和Yacc这样的黑盒工具违背了这种精神,因此我们决定不使用它们。
总结

本节课中我们一起学习了编译器构建第一部分的收尾视角。我们探讨了错误处理的重要性与复杂性,了解了自顶向下解析技术的适用场景与局限,并明白了本课程选择手动实现而非依赖自动化工具(如Lex和Yacc)的教学初衷。这为我们进入下一个模块——代码生成——做好了准备。
055:代码生成概述 🧠

在本节课中,我们将要学习编译器的代码生成组件。我们将探讨如何将高级语言程序转换为虚拟机代码,并理解这一过程背后的核心原理。
课程概述
大多数高级程序员倾向于将编译器视为理所当然的工具。他们编写程序,将编译器应用于源代码,然后观察代码如何被翻译成机器语言。他们看不到实际的过程,只看到结果。实际上,他们甚至看不到结果,他们只是执行它,然后继续调试源代码。
这种无知的幸福状态是可以接受的。然而,将程序从高级语言翻译成机器代码无异于一种魔法。如果你不想理解这种魔法,那么你将错失两个重要的好处。首先,你将无法欣赏编写编译器时所涉及的算法和技术的美丽与智慧。其次,你将失去成为一个更称职的高级程序员的机会,因为在编写编译器的过程中,你将获得许多非常重要的能力,这些能力远远超出了为高级语言编写编译器的狭隘范畴。
编译器整体路线图
我们的目标是编写一个编译器,将Jack语言的程序翻译成机器语言。在本课程中,我们决定编写一个两层编译器,就像Java和C#的编译器一样。因此,我们不是直接翻译成机器代码,而是先将Jack翻译成VM代码,然后通过一个VM翻译器进一步翻译成机器语言。
好消息是,VM翻译器已经在之前的两个模块中完成了。所以现在,我们“只需要”编写一个将高级语言翻译成VM代码的编译器。
我们决定将这个编译器的构建分为两个定义明确的模块:语法分析器和代码生成器。语法分析器是我们在之前的模块中已经开发的部分。为了测试它,我们决定让语法分析器输出XML代码而不是VM代码。这个练习的目的是验证分析器能够理解源代码,理解命令的语法结构,并能通过生成可读的XML代码来展示这种理解。
在本项目中,XML代码不再相关。因为在本模块中,我们将专注于代码生成器,它将翻译成VM代码。因此,我们将专注于分析器和代码生成器模块。我们将重写分析器中负责生成代码的某些部分,并编写更多的代码生成能力。最终结果将是一个能够从Jack程序一直翻译到VM代码的编译器。
因此,我们的目标是开发一个全功能的编译器,或者更准确地说,是将我们之前编写的编译器扩展为一个全功能的编译器。我们将把之前拥有的编译引擎从生成被动的XML代码,转变为生成可执行的VM代码。这就是我们的目标,也是我们将要采取的方法。
编译过程的简化观察
当我们谈论程序编译时,我们指的是一个高级的、面向对象的语言——Jack语言。以下是一个你之前见过的代码段示例,它旨在操作二维空间中的一些点。如果你编译并执行这段代码,你最终会在屏幕上看到一些漂亮的结果。正如我们之前讨论的,为了使这段代码正常工作,它必须与另一个提供点功能的类文件(即Point类文件)进行交互。
当你着手开发像编译器这样复杂的东西时,尽可能地简化问题总是有帮助的。因此,我将提出一些简化的观察。
第一个观察是:每个类文件都是单独编译的。
每个Jack类文件,就像每个Java类文件一样,都是一个独立且自包含的编译单元。因此,编译多类程序的整体任务被简化为一次编译一个类的更有限的任务。
第二个观察是:类内部的编译可以进一步分解。
以下是一个Point类的示例。这里列出了一些类级别的声明语句,然后是Point类的所有方法或子程序。由于空间有限,我没有列出所有子程序的代码,只列出了它们的签名,除了一个名为distance的子程序,我展示了其完整代码。
类由两个主要模块组成。首先,我们有一些定义类和一些类级别变量的前导代码。然后,我们有零个或多个子程序代码声明。在这个类中,这里是类级别的代码,这里是一个特定子程序的代码示例。
因此,简化的假设是:编译可以再次被视为两个独立的过程。首先编译我们在顶部看到的类级别代码,然后一次编译一个子程序。在很大程度上,这两个编译任务是相对独立和自包含的。因此,为一个多类程序编写编译器这一整体且艰巨的任务,再次被简化为一次编译一个子程序。
因此,事情再次被局部化,我们将采用一种非常模块化的策略,并且像往常一样,我们将一步一步地进行。
编译子程序面临的挑战
当你编译子程序时,你会看到变量、表达式、if、while等语句,你还可以看到对象,显然,你也会看到数组。因此,我们将要处理的编译挑战就是屏幕上看到的这些内容。这些挑战也构成了本模块后续单元的内容目录,每个单元将依次处理其中一个挑战。
处理这些挑战的任务是:使用VM命令生成能够捕捉变量、表达式等语义的VM代码。这不是一项简单的任务,因为当今的高级语言(包括Jack)非常复杂。它们提供了所有这些花哨的控制结构、对象和数组。然而,目标语言——VM语言——却非常简单和有限。它只有push、pop命令和少数其他命令,仅此而已。
因此,我们面临着弥合高级语言和VM代码之间差距的挑战。我们必须以某种方式,使用非常有限的VM语言来表达你在这里看到的非常丰富的语义。但正如你将看到的,我们将以一种令人惊讶的优雅且相对简单的方式来完成它。
本模块的学习收获
在结束本模块之前,我想列出你将从中获得的一些经验教训。
首先,你将学习编程语言的工作原理。
你将学习如何实现一个简单的过程式语言,如C。因为你将知道如何处理变量、表达式、控制流。你还将学习如何在低级层面处理数组和对象。通过这样做,你将再次对行业工具有非常深入的理解。
其次,你将学习一些通用的技术,这些技术可以应用于许多其他问题和应用。
你将加强对解析的理解(这是我们在上一个模块中广泛讨论的内容),你将理解递归编译是如何工作的,你将学习如何生成代码,如何创建和使用符号表,并且你将开始学习如何巧妙地管理内存(这将在我们讨论本课程最后一个模块的操作系统时再次涉及)。
所有这些好处都将在当前模块中出现。
总结
在本节课中,我们一起学习了编译器代码生成组件的概述。我们探讨了编译器的整体路线图,理解了将高级Jack语言编译为VM代码的两层架构。我们通过将编译任务分解为独立的类编译和子程序编译来简化问题。我们还明确了在编译子程序时将面临的核心挑战:即用简单有限的VM语言去表达复杂的高级语言语义,如变量、表达式、控制流、对象和数组。最后,我们展望了学习本模块将带来的收获,包括深入理解编程语言的工作原理和掌握一系列通用的软件工程技术。

接下来的单元,我们将从处理变量开始,逐一攻克这些挑战。
056:处理变量 🧩

在本节课中,我们将学习编译器如何识别和处理高级语言中的变量。我们将重点介绍符号表的概念及其在代码生成过程中的关键作用。
概述
编译器在将高级语言代码(如Jack语言)转换为虚拟机(VM)代码时,必须能够识别和处理程序中出现的所有变量。这包括确定每个变量的类型、种类(如字段、静态、局部、参数)和作用域。为了实现这一点,编译器使用一种称为“符号表”的数据结构来存储和管理所有变量的信息。
上一节我们介绍了编译器的整体架构,本节中我们来看看如何处理变量这一核心任务。
变量与代码生成
让我们从一个高级语言表达式开始。例如,表达式 sum + x * rate 包含了三个变量:sum、x 和 rate。
代码生成器的任务是将这个表达式翻译成VM指令流。一个可能的翻译结果如下:
push sum
push x
push rate
multiply
add
这些指令会计算表达式的值,并将结果置于栈顶。然而,VM语言本身没有“sum”、“x”、“rate”这样的符号变量,它只有像 local、argument、this、that 这样的虚拟内存段。
因此,为了完成翻译,编译器必须知道每个符号变量对应哪个虚拟内存段及其索引。这需要关于每个变量的详细信息。
变量的属性
在Jack语言中,每个变量都有四个关键属性:
- 名称:一个标识符。
- 类型:可以是三种基本类型(
int、char、boolean)之一,也可以是程序中定义的任何类名。 - 种类:指明变量在程序中的角色,有四种可能:
field(字段)、static(静态)、local(局部)、argument(参数)。 - 作用域:变量被识别的代码区域。在Jack中只有两种作用域:类级(在整个类中有效)和子程序级(仅在当前子程序中有效)。
为了管理这些信息,编译器使用符号表。
符号表示例
考虑以下Jack代码片段:
class Point {
field int x, y; // 类级变量:字段
static int pointCount; // 类级变量:静态
method int distance(Point other) { // 子程序
var int dx, dy; // 子程序级变量:局部
let dx = x - other.getX();
...
}
}
以下是管理这些变量所需的符号表。
类级符号表(Point类):
| 名称 | 类型 | 种类 | 编号 |
|---|---|---|---|
| x | int | field | 0 |
| y | int | field | 1 |
| pointCount | int | static | 0 |
子程序级符号表(distance方法):
| 名称 | 类型 | 种类 | 编号 |
|---|---|---|---|
| this | Point | argument | 0 |
| other | Point | argument | 1 |
| dx | int | local | 0 |
| dy | int | local | 1 |
请注意,对于方法(method),符号表的第一个条目总是隐式的 this 参数,它代表当前对象,类型是所属的类名(此处为Point),并且总是作为argument 0传递。
符号表的管理与使用
编译器在分析源代码时,会创建和维护这些符号表。
当遇到变量声明时(如 field int x; 或 var int dx;),编译器会:
- 提取变量的名称、类型和种类。
- 根据变量的种类,将其信息添加到相应的符号表中(类级或子程序级)。
- 不生成任何VM代码,仅更新符号表。
当在表达式或语句中使用变量时(如 x - other.getX()),编译器需要生成访问该变量的VM代码(例如 push this 0)。为此,它执行变量查找:
- 首先在当前子程序的符号表中查找变量名。
- 如果未找到,则在类级符号表中查找。
- 如果仍未找到,则报错(变量未定义)。
例如,在表达式 dx = x - other.getX() 中:
dx是局部变量,会在子程序符号表中立即找到。x是字段变量,不会在子程序符号表中找到,但会在类级符号表中找到。
扩展到更复杂的语言特性
我们讨论的符号表机制可以轻松扩展到处理更复杂的语言特性。
更多的变量类型和种类:例如,Java有8种基本数据类型。我们的符号表只需在“类型”列中容纳这些类型即可,机制不变。
嵌套作用域:像Java这样的语言允许在代码块(由花括号 {} 定义)内创建新的作用域。这可以通过符号表链表来处理。
- 链表头部是当前最内层作用域的符号表。
- 查找变量时,从链表头部开始,依次在每个符号表中查找,直到找到或遍历完整个链表(最后是类级符号表)。
- 这种结构自然地实现了“内层作用域遮蔽外层作用域同名变量”的规则。
虽然在Jack语言中只有两级作用域,无需链表,但了解这种通用机制对理解编译器设计很有帮助。
总结
本节课中我们一起学习了编译器处理变量的核心机制。我们了解到,通过构建和维护类级与子程序级的符号表,编译器可以记录每个变量的名称、类型、种类和编号。在生成代码时,通过查找这些符号表,编译器能将高级语言中的符号变量准确映射到虚拟机语言的虚拟内存段指令上。这种基于符号表的方法,是连接高级语言抽象与底层虚拟机执行的关键桥梁。


掌握了变量的处理方法后,我们就可以继续前进,探讨编译器如何分析和翻译更复杂的程序结构,例如表达式和语句。
057:处理表达式 🧮

在本节课中,我们将详细探讨编译器如何处理表达式。我们将学习如何将高级编程语言中的表达式(使用中缀表示法)系统地转换为虚拟机(VM)的栈机器代码(使用后缀表示法)。
从语法到代码生成
上一单元我们讨论了变量的处理。这些变量通常出现在表达式的上下文中。当时我们简要提及了将这些表达式转换为虚拟机代码的过程。在本单元中,我们将深入探讨这一转换的细节。
首先,让我们回顾一下Jack语言中定义表达式的语法子集。根据课程配套书籍中的语法定义,一个表达式可以是一个项,或者一个项后跟一个运算符和另一个项。
例如:
- 5 是一个整数常量,根据语法,它是一个项,因此也是一个表达式。
- x 是一个变量名,同样是一个项,因此也是一个表达式。
- x + 5 符合“项 + 运算符 + 项”的模式,因此也是一个合法的表达式。
我们可以构造出无限多、高度复杂的表达式。作为编译器编写者,我们面临的问题是:如何系统地将这些多样化的表达式(从简单的 5 或 x 到复杂的组合)映射到虚拟机指令上?
表达式表示法:中缀、前缀与后缀
要理解转换过程,我们需要引入解析树的概念。以简单表达式 A * B + C 为例,其解析树描述了表达式的语义结构。
我们通常将表达式写成 A * B + C 的形式,这种表示法称为中缀表示法,因为运算符位于两个操作数之间。这种表示法对人类来说很直观,因为我们在小学就被这样教导。
然而,这种表示法并非唯一。我们也可以使用前缀表示法(例如 + * A B C),将运算符放在操作数之前。它也被称为函数式表示法,因为函数调用就是这种形式(函数名在前,参数在后)。
更重要的是,我们还有后缀表示法(例如 A B * C +),它先列出操作数,然后列出要应用的运算符。后缀表示法与我们的栈机器语言密切相关,因为虚拟机语言本身就是后缀形式的。例如,要计算 A + B,我们执行 push A, push B, add。
我们的源语言(如Jack、Java、Python)使用中缀表示法,而目标虚拟机语言使用后缀表示法。因此,编译器的核心任务之一就是将中缀表达式转换为后缀表达式。
转换策略:通过解析树生成代码
一种直观的转换方法是:
- 从源代码(中缀)生成解析树。
- 按照特定顺序(如深度优先遍历)访问解析树的每个节点。
- 在访问过程中,生成对应的虚拟机代码。
例如,对于表达式 x + 2 * y,其解析树根节点是 +,左子树是 x,右子树是 *(其左子树是 2,右子树是 y)。深度优先遍历会依次访问 x、2、y、*、+,从而生成代码:push x, push 2, push y, call Math.multiply, add。
然而,为整个程序构建完整的解析树可能非常庞大和低效。幸运的是,存在一种替代方法,可以在不显式构建整个解析树的情况下,动态生成代码。
递归代码生成算法
我们将使用一个优雅的递归算法 codeWrite 来动态生成代码。该算法接收一个表达式并生成对应的虚拟机代码。其核心逻辑如下:
以下是该算法的伪代码描述:
function codeWrite(expression):
if expression is a number (e.g., 5):
output "push constant 5"
else if expression is a variable (e.g., x):
// 根据符号表确定变量段
output "push [segment] [index]"
else if expression is of form "expr1 op expr2" (e.g., x + y):
codeWrite(expr1)
codeWrite(expr2)
output VM command for `op` (e.g., "add")
else if expression is of form "op expr" (unary, e.g., -z):
codeWrite(expr)
output VM command for unary `op` (e.g., "neg")
else if expression is a function call (e.g., f(2, y, -z)):
codeWrite(argument1) // e.g., 2
codeWrite(argument2) // e.g., y
codeWrite(argument3) // e.g., -z
output "call f [argumentCount]"
这个算法不仅捕获了输入表达式的语义,还确保了在运行时,表达式的计算结果会被放置在栈顶。
从语法分析到代码生成
在之前的项目中,我们编写的是语法分析器,它接收类似 let x = a + b - c; 的源代码,并输出描述其结构的XML标签。例如,它会将表达式扁平化地标记为 <term>a</term><symbol>+</symbol><term>b</term><symbol>-</symbol><term>c</term>。
在当前项目中,我们不再生成XML。相反,我们利用语法分析器识别出的结构,直接调用类似 codeWrite 的算法来生成可执行的虚拟机代码。虽然编译器程序的整体API和结构仍然适用,但生成XML的部分需要被替换为生成虚拟机指令的逻辑。
运算符优先级与括号
现在,让我们关注一个重要的细节:运算符优先级。考虑表达式 a + b * c。根据我们上面的递归算法(严格从左到右处理),可能会生成先计算 a + b,再与 c 相乘的代码。然而,在标准数学中,乘法 (*) 的优先级高于加法 (+),因此正确的结果应该是先计算 b * c,再与 a 相加。
这里有一个关键点:Jack语言规范本身没有定义运算符优先级。这意味着,对于没有括号的表达式 a + b * c,编译器生成上述两种代码版本中的任何一种在语言层面都是“正确”的。运算符优先级的处理方式成为了编译器实现的一部分,由编译器作者决定。
那么,如何让程序员表达他们想要的运算顺序呢?答案是通过括号。括号是Jack语言的一部分,编译器必须尊重括号所指定的优先级。对于表达式 a + (b * c),编译器必须生成先计算 b * c 的代码。我们的 codeWrite 算法在处理括号时,会自然地优先处理括号内的子表达式,从而生成符合数学直觉的正确代码。

本节课中,我们一起学习了表达式处理的完整流程。我们了解了中缀、前缀和后缀表示法的区别,以及它们与栈机器的关系。我们掌握了一个强大的递归算法,能够将任何复杂度的中缀表达式动态转换为正确的虚拟机后缀代码。最后,我们讨论了Jack语言中运算符优先级的特殊性,并明确了括号在控制运算顺序中的关键作用。这些知识为我们接下来处理程序的控制流结构打下了坚实的基础。
058:处理控制流 🚦

在本节课中,我们将要学习如何将高级语言中的控制流语句(如 if 和 while)翻译成虚拟机语言。这是编译器构建过程中的一个核心挑战。
概述
控制流语句决定了程序的执行路径。在 Jack 语言中,主要有五种语句:let、while、if、return 和 do。其中,while 和 if 语句的翻译最具挑战性,因为它们涉及条件判断和代码跳转。我们将重点探讨如何利用虚拟机语言中的三个分支命令——label、goto 和 if-goto——来实现这些控制流结构。
处理 if 语句
上一节我们介绍了控制流翻译的总体挑战,本节中我们来看看如何具体处理 if 语句。
if 语句在 Jack 语言中的通用结构如下:
if (expression) {
// statements 1
} else {
// statements 2
}
为了生成代码,我们首先在概念上将其转换为一个流程图。关键步骤是将条件表达式的结果取反。这样,代码生成会变得更简单、更紧凑。
以下是生成虚拟机代码的步骤:
- 生成计算表达式值的代码,结果被推入栈顶。
- 使用
if-goto命令:如果栈顶值(表达式结果)为true(非零),则跳转到标签L1。 - 使用
goto命令:无条件跳转到标签L2。 - 在代码流中插入标签
L1,后面跟随else分支的语句代码。 - 在代码流中插入标签
L2,后面跟随if分支结束后的代码。
编译器会自动生成这些唯一的标签(如 L1、L2),确保整个翻译过程是确定性的。
处理 while 语句
理解了 if 语句的翻译后,我们再来看看 while 循环的处理。它在某种程度上比 if 更简单。
while 语句的通用结构是:
while (expression) {
// statements
}
我们同样可以为其构建一个流程图:首先对表达式求值并取反,如果结果为 true,则跳出循环;如果为 false,则进入循环体执行语句,执行完毕后跳回开头重新评估表达式。
生成的虚拟机代码如下:
- 插入一个标签,例如
L1,作为循环的入口点。 - 生成计算表达式值的代码。
- 对结果取反,使用
if-goto L2:如果结果为true,则跳转到L2(循环结束)。 - 生成循环体内语句的代码。
- 使用
goto L1跳回循环开始处,重新评估条件。 - 插入标签
L2,标记循环结束后的位置。
同样,所有标签都由编译器自动生成和管理。
处理复杂情况
我们已经掌握了单个 if 和 while 语句的翻译。然而,实际程序要复杂得多,我们需要考虑两个额外的细节。
1. 生成唯一标签
一个程序通常包含多个控制流语句。编译器必须确保为每个语句生成的标签是全局唯一的,避免跳转目标冲突。这可以通过使用递增的计数器(如 L1, L2, L3...)或结合作用域信息来轻松实现。
2. 支持嵌套结构
控制流语句可以相互嵌套,例如 while 循环内部包含 if 语句,if 语句内部又包含另一个 while 循环,形成“望远镜”式的代码结构。
幸运的是,我们之前设计的编译器采用了高度递归的编译策略。这意味着编译一个语句时,如果其内部包含其他语句,编译器会自然地递归调用自身来处理它们。因此,嵌套结构的支持是内置的,无需额外处理。
能力总结与反思
到目前为止,我们已经掌握了编译一个简单过程式语言所需的核心技术:
- 处理变量:在模块的第一个单元中已介绍。
- 处理表达式:通过代码生成器算法实现。
- 处理控制流:即本节课所学内容。
综合这些能力,我们已经可以为类似 C 语言子集的简单语言编写一个功能完整的编译器。这是一个值得骄傲的成就。
然而,我们也需要认识到以下几点:
- 我们开发的编译器只翻译到虚拟机语言这一中间层,而非直接到机器语言。这缩短了编译器需要跨越的“距离”。
- 编译过程之所以如此优雅和易于管理,很大程度上得益于我们之前建立的虚拟机抽象层。编译器编写者只需生成
push、pop、label、goto等虚拟机命令,而无需关心它们底层是如何实现的,因为这些已在之前的项目和模块中完成。
这充分体现了模块化软件工程的巨大优势:通过分层和抽象,复杂系统的构建变得可控、清晰且高效。
总结

本节课中我们一起学习了如何将高级语言中的 if 和 while 控制流语句翻译成虚拟机代码。我们掌握了其通用的代码生成模式,并讨论了如何处理多语句和嵌套语句的复杂情况。至此,我们已具备了构建一个简单编译器所需的主要技术组件。在下一单元,我们将进入一个更有趣的领域:学习如何处理面向对象代码中对象的创建与操作。
059:底层实现 🖥️

在本节课中,我们将学习编译器如何生成用于处理对象的代码。在深入讨论代码本身之前,我们先了解一些使用虚拟机命令进行数据处理的底层细节。
概述
上一节我们介绍了高级编程与底层实现之间的鸿沟。本节中,我们来看看如何利用虚拟机架构来支持对象和数组数据的表示与访问。
从高级抽象到底层实现
当你编写高级面向对象程序时,可以奢侈地思考各种抽象概念,例如二维点对象。这些对象在像Jack或Java这类对程序员友好的语言中实现,允许你像数学家一样思考这些点及其代数运算。
然而,如果被迫使用中间虚拟机语言编写相同代码,你将无法享受对象和方法的便利。相反,你将面对一个简化的虚拟机世界观,它只包含八个虚拟内存段。所有高级操作都必须通过这些虚拟内存段以及push、pop等虚拟机命令来完成。
最后,如果被迫使用机器语言完成所有工作,限制会更多。对象和数组的概念将完全失去意义,你将直接操作RAM。
幸运的是,我们有编译器来自动处理所有这些麻烦,生成必要的底层和中间代码。编译器的目的就是弥合这些鸿沟,让我们能够专注于高级编程,而由编译器负责在更简单、更原始的语言中表达相同语义的所有细节。
在本课程中,我们已经处理过虚拟机翻译器。你在项目7和8中编写了虚拟机翻译器,因此现在我们可以只专注于编译阶段。
虚拟机代码基础
考虑到这一点,我们必须编写虚拟机代码,或者更准确地说,我们必须编写一个能生成虚拟机代码的编译器。
让我提醒你,在虚拟机程序中,如果我们想操作这八个内存段,必须使用push和pop命令,并且必须指定操作哪个段以及段内的哪个条目。例如:
push local 3
pop static 2
最终,我们在虚拟机级别所做的一切都将简化为对RAM的操作。虚拟机实现通过一种特定的方式看待RAM来处理这种映射。
首先,RAM的前五个字被用作非常重要的指针,它们保存着:
- 栈指针的当前值
- 局部段的基础地址
- 参数段的基础地址
this指针that指针
所有这些段都属于当前正在运行的虚拟机函数。此外,虚拟机实现还会在RAM上分配一个特定区域来保存全局栈。这个栈不仅保存当前运行函数的运行栈,还保存调用链上等待当前函数终止的所有函数的运行栈和内存段。
支持局部变量和参数变量
以下是关于这种架构如何支持局部变量和参数变量概念的说明。
局部变量和参数变量存储在栈上,并由虚拟机实现管理。就虚拟机代码而言,我们可以分别使用local和argument段来访问它们。虚拟机实现通过LCL和ARG这两个指针在栈上记录这些段的位置。例如,虚拟机实现将局部段放在栈的某个区域,并将该段的基础地址记录在LCL指针中,对参数段也做同样处理。
表示对象和数组数据
接下来,我们看看如何使用相同的架构来表示对象和数组数据。
这里的情况类似但稍微复杂一些。首先,我们使用RAM上一个完全不同的区域,称为堆。在堆上,我们记录当前程序要操作的所有对象和数组的数据。
在一个面向对象的应用程序中,你可能拥有许多对象和数组来实现计算机游戏、金融应用或其他任何程序。你可能需要同时管理数十、数百、数千甚至数百万个对象。
因此,与局部段和参数段(每个只有一个)不同,我们可能在堆上有许多对象。在使用this段访问对象之前,我们必须以某种方式告诉系统“this”指的是哪个对象。
this和that位置的实现首先通过使用this和that指针来处理,就像我们对局部变量和参数所做的那样。然而,在使用this和that这两个段之前,我们必须先使用pointer段来告诉系统我们希望将this和that指向RAM上的哪个位置。
你可能会问,为什么我们必须以这种方式使用pointer段?这是由架构设计决定的,我们需要提供一种机制来将this和that锚定到代码需要操作的特定对象和数组上,为此我们使用了这个虚拟的pointer段。
一个具体示例
我敢打赌,我刚才说的一些话听起来有点晦涩难懂。我认为下面的例子将澄清一切。
假设我们希望访问RAM地址8000、8001、8002等处的字,因为它们代表一个特定的对象或数组,或者存储了特定对象或数组的数据。我们该怎么做呢?
我想向你展示实现它所需的命令序列。对于每个命令,我将讨论命令本身以及虚拟机实现对该命令的处理。
首先,我们必须将this段锚定到所需的地址(8000)。我们通过以下命令实现:
push constant 8000
pop pointer 0
响应这两个命令,虚拟机实现会将this指针设置为8000,从而得到我们想要的效果。顺便说一句,如果你完成了项目7和8,你应该不会对此感到惊讶,因为你实际上已经实现了这些虚拟机命令的具体实现。
一旦你将this指针设置为特定地址,this段就可以被使用,就好像它镜像或锚定在RAM的8000地址上一样。
因此,从现在开始,给定我们有了这个所需的映射,我可以愉快地使用像push this 0这样的命令。push this 0会做什么?它会获取当前位于RAM[8000]的值,并将其压入栈中。而pop this 0则会从栈中取出一个值,并将其放入RAM[8000]。
我们使用this段来做到这一点。我们可以对this的任何其他索引做完全相同的事情,例如this 1、this 2、this 3、this i。为了访问第i个字,我们将8000和i相加,并利用这里的抽象来精确指向RAM中我们想要的字。
请注意,虚拟机程序并不知道所有这些段在RAM上的具体位置。它只使用this段,而虚拟机实现在后台处理所有其他细节。
访问RAM的具体步骤
这里有一个具体的例子。假设你想将RAM[8000]设置为17。我们该怎么做?
以下是实现步骤:
- 锚定指针:首先,我们需要将
this指针指向地址8000。push constant 8000 pop pointer 0 - 设置值:然后,我们将值17压入栈,并弹出到
this段的第0个位置。
这将在虚拟的push constant 17 pop this 0this段中放入数字17,实际上它会将其放入RAM位置8000。
我刚才在这里展示的技术是使用虚拟机命令访问RAM中特定位置的通用方法。这就是我们处理对象数据的方式,也是我们以非常类似的方式处理数组数据的方法(我们将在课程后面讨论)。
总结
本节课中,我们一起学习了虚拟机层面处理对象和数组数据的底层机制。
- 对象数据通过虚拟机命令使用
this段访问。 - 数组数据通过虚拟机命令使用
that段访问。 - 在这两种情况下,我们都需要先编写一些虚拟机代码,将
this和that(或其中之一)锚定到RAM中的所需位置。我们使用虚拟的pointer段来实现这一点,它只有两个条目(0和1),0用于正确设置this段,1用于设置that段。

有了这些关于使用虚拟机命令进行底层数据处理的背景知识,我们现在具备了继续讨论如何编写处理对象构造的虚拟机代码所需的基础。
060:构造 🏗️

在本节课中,我们将学习如何编写虚拟机(VM)代码来处理对象的构造过程。我们将从调用者的视角和构造函数本身的视角,分别探讨如何生成相应的VM指令。
概述
对象构造是一个两阶段的过程,涉及编译时和运行时。编译时,编译器处理变量声明并更新符号表。运行时,构造函数被调用,在堆上分配内存并初始化对象。我们将学习如何为这两个阶段生成正确的VM代码。
从调用者视角编译对象构造
上一节我们介绍了如何访问堆上的特定区域。本节中,我们来看看如何从调用者的角度编译创建对象的代码。
当高级语言(如Java)中声明一个对象变量时,例如 var point P1;,编译器不会生成任何VM指令。它只会在当前子程序的符号表中记录这个变量,并将其在栈上对应的位置初始化为0。
当调用构造函数时,例如 P1 = Point.new(2, 3);,编译器需要生成调用代码。这个过程与调用普通子程序类似。
以下是处理此类调用的步骤:
- 将构造函数的参数(例如2和3)压入栈。
- 使用
call Point.new指令调用构造函数。 - 构造函数执行完毕后,会将其新创建对象的基地址作为返回值留在栈顶。
- 调用者代码使用
pop指令将这个地址存入之前为P1变量分配的位置(例如pop local 0)。
这样,P1 变量就指向了堆上新创建的对象。
从构造函数视角编译对象构造
现在,我们知道了如何生成调用构造函数的代码。接下来,我们探讨如何编译构造函数本身的VM代码。
构造函数的主要任务有两个:
- 在堆上为新对象分配内存空间。
- 初始化新对象的字段。
为了访问和操作对象的字段,我们需要使用 this 虚拟段。在构造函数开始执行时,this 段尚未指向有效的对象内存。
以下是编译一个构造函数(例如 constructor Point new(int x, int y))的步骤:
-
分配内存:编译器首先查询类的符号表,确定对象需要多少字(word)的内存(例如,
Point类需要2个字存储x和y)。然后,它生成代码调用操作系统的Memory.alloc函数来申请一块连续的空闲内存。- 代码示例:
push constant 2后跟call Memory.alloc 1。alloc函数会将分配的内存块的基地址返回到栈顶。
- 代码示例:
-
设置
this指针:将alloc返回的基地址弹出到pointer 0。这样,this段就被锚定到了新创建的对象内存起始处。- 公式/代码:
pop pointer 0
- 公式/代码:
-
初始化字段:现在可以通过
this段来访问对象字段。将传入的参数(如x和y)赋值给对象的对应字段。- 代码示例:
push argument 0然后pop this 0(设置x);push argument 1然后pop this 1(设置y)。
- 代码示例:
-
返回对象引用:根据语言规范,构造函数必须返回新对象的引用。由于
pointer 0正持有这个基地址,只需将其压入栈并返回即可。- 代码示例:
push pointer 0后跟return。
- 代码示例:
完成这些步骤后,控制权返回给调用者,调用者如前所述,将返回的地址存入目标变量。
总结
本节课中我们一起学习了对象构造的完整编译过程。
- 从调用者视角,构造被编译为:压入参数、调用构造函数、接收返回的对象地址并存储。
- 从构造函数视角,构造被编译为:使用
Memory.alloc分配内存、用返回地址设置this指针、初始化对象字段、最后返回this指针的值。

理解这个两阶段的协作过程(调用者准备、构造函数执行并返回结果)是处理面向对象编程中对象生命周期的关键。掌握了对象构造的编译方法后,我们就可以继续学习如何操作已构造好的对象。
061:操作

在本节课中,我们将学习如何编译面向对象代码中的方法调用和方法本身。我们将看到,编译器如何将高级的、面向对象的语法(如 p1.distance(p2))转换为底层的、过程式的虚拟机指令。
概述
上一节我们介绍了对象的构造,本节中我们来看看如何操作对象。对象操作主要涉及两方面:首先,我们需要知道如何编译调用方法的客户端代码;其次,我们需要讨论如何编译方法本身的代码。
方法调用的编译
当我们在高级语言中调用一个对象的方法时,例如 p1.distance(p2),其语法遵循面向对象哲学:先指定要操作的对象,再指定方法名,最后提供参数。然而,底层的虚拟机语言并不理解对象。因此,编译器的核心挑战是将这种面向对象的表达方式转换为过程式的表达方式。
以下是通用的转换技术:
- 将方法调用所操作的对象作为第一个隐式参数压入栈。
- 将方法调用中显式提供的所有参数压入栈。
- 调用对应的方法。
以 p1.distance(p2) 为例,其编译后的核心逻辑是:
push p1 // 将对象 p1(即其基地址)作为第一个参数
push p2 // 将显式参数 p2 作为第二个参数
call Point.distance // 调用方法
这里“推送对象”实际上是指推送该对象在内存中的基地址,该地址存储在对应的变量(如 p1)中。
方法本身的编译
现在,让我们将注意力转向方法本身的编译,以 Point 类中的 distance 方法为例。方法被设计为操作当前对象(在大多数语言中用 this 关键字表示)。为了访问当前对象的字段(如 x, y),我们需要正确地设置 this 指针。
以下是编译一个方法(如 distance)的关键步骤:
- 建立符号表:编译方法声明和局部变量声明时,不生成实际代码,而是创建子程序级的符号表,记录
this(作为参数0)和所有参数、局部变量。 - 锚定 this 指针:生成代码,将传入的当前对象的地址(即参数0)设置到
pointer 0,从而让this段指向正确的内存位置。push argument 0 pop pointer 0 - 编译方法体:根据符号表,使用
this 0,this 1等访问对象字段,使用argument 1,local 0等访问参数和局部变量,并生成对应的运算指令。 - 返回值处理:方法结束时,必须通过
return命令返回一个值。对于有返回值的方法,返回值应位于栈顶。
处理 Void 方法
Void 方法(不返回值的方法)的编译与普通方法类似,但有一个重要区别:根据虚拟机规范,所有被调用的子程序都必须返回一个值。
因此,编译 void 方法时:
- 在方法结尾,我们需要推送一个虚拟值(例如
push constant 0),然后执行return。// 在 void 方法(如 print)的结尾 push constant 0 // 推送一个虚拟返回值 return - 在调用方,编译器必须生成额外的指令来清理这个无用的返回值,以保持栈的整洁。
call Point.print // 调用 void 方法 pop temp 0 // 丢弃无用的返回值
这是一个约定:void 方法返回一个虚拟值,而调用方负责将其从栈中弹出。
总结

本节课中我们一起学习了对象操作的核心编译技术。我们了解到,编译器通过将对象作为隐式首参数传递,将面向对象的方法调用桥接到了过程式的底层。在编译方法时,关键步骤是锚定 this 指针以访问对象字段。此外,无论是普通方法还是 void 方法,都必须遵守底层的返回值约定。掌握了这些,你就理解了计算机在幕后如何处理对象,为我们接下来学习数组的处理打下了基础。
062:处理数组 🧩

在本单元中,我们将学习编译器如何为数组的声明和操作生成代码。我们将从数组的构造开始,然后深入探讨如何访问和操作数组元素。
数组构造 🏗️
上一节我们介绍了对象处理,本节中我们来看看数组的构造。首先,我们回顾一下RAM的布局。
假设高级Jack程序员写下 var Array arr; 来声明一个名为 arr 的数组。编译器对此的响应非常直接。
以下是编译器处理此声明和后续构造的步骤:
- 处理声明语句:
var Array arr;这条语句本身不生成任何代码。编译器唯一要做的是更新符号表,添加一行记录,表明有一个名为arr、类型为Array的变量(例如,存储在局部变量0的位置)。 - 处理构造语句:当程序员通过
let arr = Array.new(n);构造数组时,编译器将其视为一次普通的子程序调用。我们已经知道如何为方法调用生成代码(例如,压入参数n,然后调用Array.new)。因此,这里没有新的代码生成逻辑。操作系统(后续课程会讲解)会与编译器协作,在堆中分配足够空间,并将该内存块的基地址存入变量arr(即局部变量0)中。
数组操作基础:this 与 that 指针 🧭
在深入数组操作之前,我们需要回顾VM架构中的两个关键虚拟段:this 和 that。
this 和 that 是两个特殊的指针段,它们允许我们动态地指向RAM中的任意地址,从而间接操作内存。它们的基地址分别存储在RAM位置3 (THIS) 和 4 (THAT) 中。
为了将 this 段锚定到特定地址(例如8058),我们需要使用 pointer 虚拟段。pointer 段只有两个条目:0代表 this,1代表 that。
操作步骤如下:
- 将目标地址(如8058)压入栈。
- 执行
pop pointer 0。VM实现会将此地址存入THIS指针,从而将this段对齐到地址8058。 - 此后,操作
this段(如push this 0)实际上就是在操作地址8058处的内存。
对 that 段(使用 pop pointer 1)的操作逻辑完全相同。在VM翻译器的实现约定中,我们通常用 this 来访问当前对象的字段,用 that 来访问当前数组的元素。
访问数组元素 🔍
现在,我们运用 that 指针来学习如何访问数组元素。假设数组 arr 的基地址已存储在局部变量0中,我们想将数字17存入 arr[2](即第三个元素)。
生成VM代码的思路如下:
- 计算目标地址:将数组基地址(
arr)和索引(2)压栈,然后相加。结果就是arr[2]在RAM中的物理地址。 - 对齐
that段:将上一步计算出的地址通过pop pointer 1存入THAT指针。这样,that段就被对齐到了目标元素的位置。 - 赋值:将值17压栈,然后执行
pop that 0。这会将17存入that段的第0个位置,也就是我们想要设置的arr[2]。
对应的VM代码序列是:
push local 0 // 压入数组基地址 (arr)
push constant 2 // 压入索引 2
add // 计算 arr + 2 的地址
pop pointer 1 // 将该地址存入 THAT 指针,对齐 that 段
push constant 17// 压入要存储的值
pop that 0 // 将值存入 that 0,即 arr[2]
这里有两个重要观察:
- 我们总是操作
that 0,而不像对象字段那样使用this 0,this 1等。因为对于数组,每次访问不同索引时,我们都会重新对齐that段。 - 这段VM代码完全在虚拟的、符号化的层面运行,它不知道数组具体在RAM的哪个位置。所有物理地址的计算和映射都由底层的VM翻译器处理。这使得代码安全且与具体硬件平台无关,是实现可移植性的关键。
处理通用数组赋值语句 🛠️
上一节我们看了一个简单的例子,本节中我们来看看如何处理更通用的数组赋值语句:arr[expression1] = expression2。一个直接的生成策略可能如下:
- 计算
arr + expression1的地址并存入pointer 1。 - 计算
expression2的值。 - 将结果值弹出到
that 0。
然而,这种方法存在缺陷。考虑语句 a[i] = b[j]。如果按上述步骤:
- 计算
a+i地址并存入pointer 1。 - 计算
b+j地址。但计算b+j本身可能涉及复杂的表达式,其中可能包含对pointer 1的写入操作,这会覆盖第一步中存储的a[i]的地址,导致错误。
因此,我们需要一个更健壮的方案。以下是解决 a[i] = b[j] 的正确VM代码生成步骤:
- 计算左值地址并暂存:计算
a + i的地址,但先不存入pointer 1,而是留在栈顶。 - 计算右值:计算
b + j的地址,然后通过pop pointer 1对齐that段到b[j]。接着,使用push that 0和pop temp 0将b[j]的值存入临时变量。 - 取回左值地址并赋值:此时,栈顶仍保留着
a[i]的地址。将其通过pop pointer 1对齐that段到a[i]。最后,将临时变量中的值 (temp 0) 压栈并pop that 0,完成赋值。
这个模式可以推广到通用的 arr[exp1] = exp2 语句。以下是代码生成模板:
// 1. 计算左值地址 (arr + exp1) 并留在栈顶
push arr // 压入数组基地址
[生成计算 exp1 的代码] // 结果在栈顶
add // 栈顶现在是 arr[exp1] 的地址
// 2. 计算右值 (exp2) 并存入临时变量
[生成计算 exp2 的代码] // 结果在栈顶
pop pointer 1 // 将对齐 that 段到 exp2 结果指向的地址?(这里需要修正)
push that 0 // 取出该地址的值
pop temp 0 // 存入临时变量
// 3. 为左值地址对齐 that 段并赋值
pop pointer 1 // 将栈顶的 (arr+exp1) 地址存入 THAT 指针
push temp 0 // 压入右值
pop that 0 // 赋值给 arr[exp1]
重要修正:上述模板步骤2中,exp2 可能本身就是一个数组访问(如 b[j]),因此它计算出的就是一个地址。我们不应该直接 pop pointer 1 然后 push that 0,因为 exp2 可能就是一个简单值。正确的通用步骤是:
- 计算左地址(
arr+exp1),结果留在栈顶。 - 计算右表达式(
exp2)的值,结果压栈。 - 将右值弹出到临时变量(如
temp 0)。 - 将左地址弹出到
pointer 1以对齐that段。 - 将临时变量中的右值压栈,并弹出到
that 0。
这样,无论 exp2 是简单值、变量、还是另一个数组访问,都能正确处理。
总结 📚
本节课中我们一起学习了编译器如何处理数组。
- 我们了解到数组的声明只更新符号表,而构造则是通过普通的
new方法调用实现。 - 我们回顾了
this和that指针的机制,它们是间接操作内存的关键。 - 我们深入探讨了如何通过计算基地址加偏移、并使用
that指针来访问和赋值数组元素。 - 最后,我们分析并解决了在通用数组赋值语句
arr[exp1] = exp2中可能出现的地址覆盖问题,引入了使用临时变量的健壮代码生成模式。

掌握这些知识后,我们的编译器就具备了处理复杂数据结构——数组的能力。下一单元,我们将改变主题,讨论虚拟机标准映射的相关内容。
063:虚拟机上的标准映射 🗺️

在本单元中,我们将讨论一组约定。建议J语言编译器的开发者遵循这些约定。我们将这组约定统称为“虚拟机上的标准映射”。
概述
首先,让我们回顾一下整体架构。我们正在开发一个编译器,这个编译器是两层的。首先,我们将代码从Jack高级语言编译到虚拟机代码,然后再将虚拟机代码编译到目标平台。第二部分已在模块1和2中完成,而本模块及前一个模块则负责填补Jack语言与虚拟机之间的鸿沟。因此,出于实际目的,我们可以忽略虚拟机代码以下的所有细节,专注于将Jack程序翻译成虚拟机代码。
现在思考一下,当你编写Jack、Java或C++程序时,你会创建各种实体,例如类、函数、构造函数、方法等。然而,当你需要将这些实体翻译到虚拟机层面时,结果看起来会完全不同。在虚拟机层面,我们不知道什么是对象,也不知道什么是构造函数。我们只有栈和虚拟内存段,必须设法将每一个高级构造映射到虚拟机的现实上。如何实现这一点,我们已在本模块中讨论过。现在,我想总结并整合所有从高级语言映射到虚拟机层面时必须遵循的规则。我们通过一份称为“虚拟机平台标准映射”的文档或标准集来实现这一点。
顺便提一下,如果你想为其他语言(如Java、C++等)创建编译器,那么对于每一种语言和编译器,都需要一份不同的标准映射文档,因为每种语言都有不同的构造,尽管许多地方非常相似。在本课程中,我们专注于Jack语言,因此我们将讨论如何将Jack映射到虚拟机。
文件与子程序映射约定 📁
一个Jack程序由一个或多个类组成,每个类有唯一的名称。每一个类文件都将被翻译成一个具有相同文件名的虚拟机文件。在此过程中,源代码中的每一个子程序都将被翻译成虚拟机层面的一个函数。
因此,每个子程序对应一个函数,每个文件对应一个虚拟机文件。
请注意,一个拥有k个参数的Jack构造函数或函数,将被编译成一个同样操作k个参数的虚拟机函数。在示例中,我们看到new是一个构造函数,在Jack层面有一个参数,在虚拟机层面也将有一个参数。同样的情况也适用于函数buzz。
那么方法呢?方法的情况稍微复杂一些。一个拥有k个参数的Jack方法,将被编译成一个操作k+1个参数的虚拟机函数。在这个例子中,bar方法在Jack层面只有一个参数x,但在虚拟机层面将有两个参数。我们稍后会详细阐述这一点,实际上我们在本模块早期讨论对象操作时已经提到过。
观察图表,你会发现一个有趣的现象:子程序的类型(无论是构造函数、方法还是函数)在翻译过程中“丢失”了。因为在到达虚拟机层面时,所有内容都将被映射为函数。因此,我们必须通过某些约定来捕获构造函数、函数和方法的语义,例如刚才讨论的“方法比源代码多一个参数”的规则。关于这一点,我们后面还有更多要说的。
变量映射 🧮
在Jack语言中,我们有四种变量:局部变量、参数变量、静态变量和字段变量。每一种都有特定的处理方式。
- 局部变量:某个Jack子程序的局部变量被映射到虚拟内存段
local。例如,如果x和y是你的子程序中声明的头两个局部变量,那么x将被映射到local 0,y将被映射到local 1。后续的变量将依次映射到local 2、local 3等。 - 参数变量:处理方式完全相同。第一个参数映射到
argument 0,第二个映射到argument 1,依此类推。 - 静态变量:被映射到与当前编译文件关联的虚拟内存段
static。映射方式类似:第一个静态变量映射到static 0,第二个映射到static 1,依此类推。 - 字段变量:当前对象的字段变量处理如下。首先,我们必须假设
pointer 0已经指向了这个对象(当前子程序生成的代码会完成这件事)。如果已经完成,那么这个对象的第i个字段在虚拟机层面就映射到this i。例如,如果x是一个属性或字段,它将映射到this 0;如果y也是一个属性,它将映射到this 1,依此类推。
数组处理 📊
对于数组,我们提出以下约定。如果你试图访问任何数组条目arr[i],你应该这样做:
- 首先,将
pointer 1设置为该条目的地址,即arr + i。 - 完成这个初始“锚定”后,你就可以通过访问
that 0来简单地访问该条目。
无论i是18还是312,你总是将pointer 1指向它,然后访问that 0。我们发现这是在编译器中处理数组最简单的方法。
编译子程序的约定 🔧
在Jack中,我们有三种子程序:方法、构造函数和函数。让我们分别讨论每一种。
编译方法
编译Jack方法时,编译器必须确保生成的代码首先将this虚拟内存段的基地址设置为argument 0。这是因为我们有一个非常重要的约定:方法的调用者会在调用该方法之前,将方法要操作的对象地址作为第一个参数压入栈。因此,当方法开始运行时,它需要操作当前对象。我们通过将this设置为argument 0的内容来实现这个抽象。一旦完成,我们就可以如前所述,将this 0、this 1、this 2等作为当前对象字段的占位符来引用,这非常方便且合理。
编译构造函数
编译构造函数时,我们首先必须在内存中为要创建的对象分配空间。完成之后,我们将这个新内存块的基地址设置为this段。通过这样做,我们再次建立了对该对象的访问,然后就可以使用this 0、this 1、this 2等来执行构造函数可能想做的任何事情。例如,构造函数通常喜欢将对象字段设置为各种初始值,现在它们可以做到了,因为我们已经建立了对新构造对象的访问。
在返回之前,请记住,构造函数的虚拟机代码还必须记得将新构造对象的基地址返回给调用者。考虑到这一点,我不确定我们是否必须在标准映射中特别说明这一点,因为在Jack层面,我们已经要求构造函数必须总是返回this。由于在Jack层面有这个要求,编译器会自动生成处理return this的代码,因此我们也不必在这里提及。不过,在这里提及这个重要约定也无妨。
编译无返回值函数/方法
无返回值函数和方法在Jack层面不返回值。然而,在虚拟机层面,它们必须返回一个值,因为这是虚拟机函数的要求。既然必须返回点什么,我们决定,如果遵循我们的标准映射,我们要求返回constant 0。顺便提一下,无返回值子程序的调用者必须记住它调用的是一个无返回值子程序,因此当子程序返回时,它要做的第一件事就是丢弃栈顶元素(即包含constant 0的那个值)。我们接下来会讨论如何处理子程序调用。
处理子程序调用的约定 📞
当我们编译一个典型的子程序调用(如subroutineName(arg1, arg2, ...))时,调用者必须将参数压入栈,然后调用子程序。即执行push arg1、push arg2等,然后call subName。
如果这个子程序是一个方法,那么调用必须记住,它要做的第一件事是压入一个对方法要操作的对象的引用,然后才继续压入arg1、arg2等,并调用该方法。你看,一切是如何连接起来的:一旦你这样做了,从被调用方法的角度看,当前对象的地址就成为了我们之前讨论的argument 0的值。
至于调用无返回值子程序,它在Jack中不返回值,但在虚拟机层面会返回一个我们约定为constant 0的虚拟值。因此,在子程序返回后,子程序调用的编译代码必须弹出并忽略这个返回值。
处理常量 🔢
Jack中有三个常量:null、false和true。以下是它们的处理方式:
null和false映射到constant 0。true映射到constant 1。
对于 -1,我们可以简单地生成先压入1再执行neg命令的代码,这将有效地将-1置于栈顶。
类与子程序 🏗️
基本的Jack操作系统被实现为一组八个虚拟机类,名称如Math.vm、Memory.vm等。我们将在课程的下一个(也是最后一个)模块6中详细讨论并实现这些类。所有操作系统类文件必须与编译器生成的虚拟机文件位于同一目录中。因此,在目录层面,你的应用程序文件和操作系统文件没有区别,它们都被视为一个大的虚拟机文件集合。因此,你的代码中的任何虚拟机函数都可以调用这些操作系统类中的任何虚拟机函数。
特殊操作系统服务 ⚙️
显然,如果你编写Jack编译器,必须记住以下几点:
- 乘法命令:必须通过调用
Math.multiply来处理。 - 除法命令:必须生成调用
Math.divide的代码。 - 字符串常量:使用构造函数
String.new和所需字符串的长度来创建。 - 字符串赋值:例如
x = "NAND",这个由四个字符组成的字符串,通过四次调用String.appendChar来处理。编译器必须再次提供这种抽象。 - 对象构造:需要在RAM中为新对象分配空间。因此,在构造函数的某个地方,我们必须调用
Memory.alloc。所需的大小将根据我们要创建的对象中的字段数量推导出来。因为在Jack中一切都是16位的,所以字段数量与容纳此对象所需的RAM字数或RAM位置数量之间存在一一对应的关系。 - 对象回收:使用操作系统函数
Memory.deAlloc来处理。
以上就是Jack编译器开发者在编写针对我们虚拟机的编译器时,需要牢记在心的所有内容。
总结


在本单元中,我们一起学习了Jack语言到虚拟机的“标准映射”。我们详细探讨了如何将Jack程序中的文件、子程序(方法、构造函数、函数)、各类变量(局部、参数、静态、字段)、数组以及常量映射到虚拟机层面的对应表示。我们还了解了编译不同类型子程序时必须遵循的特定约定,以及如何处理子程序调用和利用操作系统提供的特殊服务。这些约定是构建一个正确、高效的Jack编译器的基石。在下一个单元,我们将讨论如何实际构建这个编译器,以及我们推荐遵循的API结构。
064:编译器完整实现建议方案 🛠️

在本单元中,我们将讨论如何实际构建编译器。我们将基于之前单元中介绍的标准VM映射约定,逐步讲解编译器的整体架构和实现模块。
概述
在之前的项目10中,我们编写了一个生成XML代码的语法分析器。现在,我们需要将这个软件改造为一个生成VM代码的程序,即我们的Jack编译器。我们将分步进行,并基于五个独立的模块来构建编译器的软件架构。
编译器整体架构
以下是编译器的整体架构和路线图。编译器将包含五个主要模块:Jack编译器(主程序)、Jack分词器、符号表、VM写入器和编译引擎。本单元我们将重点讨论符号表和VM写入器。
Jack编译器类
Jack编译器是驱动整个编译过程的主程序。它的用户级功能如下:如果输入是一个.jack文件,编译器将生成一个同名的.vm文件;如果输入是一个目录名,编译器将为该目录中的每个.jack文件生成一个对应的.vm文件。
对于每个源.jack文件,编译器会创建一个Jack分词器对象来处理输入,并创建一个输出的.vm文件。然后,编译器使用符号表、编译引擎和VM写入器来生成VM代码,并将其写入输出文件。
Jack分词器
Jack分词器模块在项目10中已经开发完成,我们可以直接使用,无需为完整规模的编译器进行修改。
符号表
符号表是一个新模块,用于跟踪程序中的各种符号或变量。在Jack语言中,主要有类级别的变量(字段和静态变量)和子程序级别的变量(参数和局部变量)。
符号表示例
考虑以下Point类的示例:
class Point {
static int pointCount;
field int x, y;
method int distance(Point other) {
var int dx, dy;
// ... 方法体
}
}
- 类级别符号表:跟踪
x、y(字段)和pointCount(静态变量)。这些变量在整个类中可见。 - 子程序级别符号表:跟踪
other(参数)和dx、dy(局部变量)。这些变量仅在distance方法内可见。
编译器最多只需要两个符号表:一个类级别符号表和一个子程序级别符号表。每当开始编译一个新类时,类级别符号表被初始化;每当开始编译当前类中的一个新子程序时,子程序级别符号表被重置。
符号表API
符号表类(例如用Java实现)应提供以下方法:
- 构造函数:
SymbolTable()- 创建一个新的空符号表。 - 开始子程序:
startSubroutine()- 重置子程序级别符号表,开始一个新的子程序作用域。 - 定义符号:
define(String name, String type, String kind)- 向当前作用域的符号表添加一个新符号。kind可以是STATIC、FIELD、ARG或VAR。 - 变量计数:
varCount(String kind)- 返回当前作用域中已定义的指定kind的变量数量。 - 查询方法:
kindOf(String name)- 返回指定名称符号的kind。typeOf(String name)- 返回指定名称符号的type。indexOf(String name)- 返回指定名称符号在其作用域和种类内的索引号(从0开始)。
实现建议
符号表抽象结构可以使用经典的数据结构哈希表来实现。我们可以使用一个哈希表表示类作用域,另一个哈希表表示子程序作用域。开始编译新的子程序时,后一个哈希表被清空重置。
一个重要提示:在编译无错误的Jack代码时,任何在子程序符号表和类符号表中都找不到的符号,必定代表一个子程序名或类名。这个提示在编写编译器时非常有用。
VM写入器
VM写入器的任务是生成VM代码并将其写入输出的.vm文件。
VM写入器API
VM写入器类应提供以下方法:
- 构造函数:
VMWriter(String outputFile)- 创建一个新的输出文件/准备写入。 - 写入Push命令:
writePush(String segment, int index)- 生成一条push segment indexVM命令。 - 写入Pop命令:
writePop(String segment, int index)- 生成一条pop segment indexVM命令。 - 写入算术命令:
writeArithmetic(String command)- 生成一条算术/逻辑VM命令,如add、sub、eq等。 - 写入标签:
writeLabel(String label)- 生成一条label labelVM命令。 - 写入Goto命令:
writeGoto(String label)- 生成一条goto labelVM命令。 - 写入If-Goto命令:
writeIf(String label)- 生成一条if-goto labelVM命令。 - 写入Call命令:
writeCall(String name, int nArgs)- 生成一条call name nArgsVM命令。 - 写入Function命令:
writeFunction(String name, int nLocals)- 生成一条function name nLocalsVM命令。 - 写入Return命令:
writeReturn()- 生成一条returnVM命令。 - 关闭:
close()- 关闭输出文件。
这个模块相对简单,但从软件工程角度看很重要,因为它封装了所有与生成编译器输出相关的活动。
编译引擎
编译引擎从Jack分词器获取输入,并利用VM写入器将输出写入文件。它被组织为一系列compileXxx()例程,其中Xxx是Jack语言中的一个语法元素(例如compileClass()、compileSubroutine()、compileWhile()等,大约有15个)。
这些例程之间的约定是:每个compileXxx()例程应该从输入中读取Xxx构造,将输入恰好推进到Xxx之后,并发出实现Xxx语义的VM代码。如果该元素是表达式的一部分,那么发出的VM代码应计算其值并将其置于VM栈顶。
编译引擎的API与项目10中编写语法分析器时使用的API完全相同。然而,在本项目中,我们需要将这个生成XML代码的API,改造为生成可执行VM代码的API。
总结

在本单元中,我们一起学习了完整Jack编译器的建议实现方案。我们回顾了编译器的整体架构,并详细介绍了其核心模块:Jack编译器(主驱动)、Jack分词器(已就绪)、符号表(用于管理变量作用域和属性)、VM写入器(用于生成VM代码)以及编译引擎(负责语法分析和代码生成的核心逻辑)。下一单元(项目11概述)将提供逐步指南,教你如何实际执行和测试从生成XML代码到生成可执行VM代码的转变。
065:构建编译器

在本项目中,我们将完成编译器的构建。我们将把项目 10 中构建的语法分析器扩展和转变为一个功能完整的编译器。
概述
在项目 10 中,我们构建了一个语法分析器。在本项目中,我们将分两个独立的阶段,将这个语法分析器转变为一个完整的编译器。首先,我们将为其添加符号表处理能力,以理解源代码中标识符的语义。其次,我们将在此基础上实现代码生成功能,最终输出可执行的 VM 代码。
符号表处理
上一节我们介绍了项目的整体目标,本节中我们来看看第一阶段:符号表处理。
目前,语法分析器将所有标识符(如变量名、子程序名)统一标记为 identifier。为了更精确地理解源代码,我们需要分析每个标识符的语义角色。
我们将扩展语法分析器,使其在 XML 输出中包含以下信息:
- 标识符类别:是
var(局部变量)、argument、static、field、class还是subroutine。 - 索引编号:如果标识符属于
var、argument、static或field类别,还需输出其在该类别中的序号(例如,argument 0、argument 1)。 - 定义或使用:标识该标识符是在声明中被定义(如在
var语句中),还是在表达式等上下文中被使用。
以下是实现此功能的方法:
首先,你需要根据之前单元中给出的 API 规范,用 Java 或 Python 等语言实现符号表。
接着,为了测试符号表,我们建议你扩展项目 10 中已构建的语法分析器,为其添加上文所述的标识符处理能力。你可以设计自己的 XML 标签格式来输出这些信息,并通过运行项目 10 的测试程序来验证其正确性。这部分测试仅供你自己验证,无需提交。
代码生成
在成功为语法分析器添加符号表功能后,源代码的语法和符号语义都已被充分理解。接下来,我们将进入编译的最后阶段:代码生成。
我们将通过一系列测试程序来分阶段开发和测试你的编译器。以下是测试流程:
- 使用你正在开发的编译器编译包含测试程序的目录。
- 检查编译器生成的 VM 代码。如果发现问题,返回修改编译器。
- 将生成的 VM 文件目录加载到 VM 模拟器中并执行。
- 检查运行结果是否符合预期。如果不符合,则修复编译器。
需要特别注意的是,我们提供的测试程序本身是完美无误的。因此,任何错误都意味着是你的编译器存在缺陷,需要你来修复。
以下是六个测试程序的说明,请按顺序完成:
测试程序 1:Seven
此程序测试编译器处理简单算术表达式、do 语句和 return 语句的能力。程序计算 1 + 2 * 3。成功编译运行后,屏幕左上角应显示数字 7。
生成的 VM 代码中可能包含类似 pop temp 0 和 push constant 0 的指令。前者用于在 do 语句上下文调用方法后,丢弃其返回值。后者用于在 void 方法返回时,按约定推送一个返回值(此处为 0)到栈上。
测试程序 2:ConvertToBin
此程序测试编译器处理表达式(不含数组和方法调用)以及 if、while、do、let、return 这五种语句的能力。程序将十进制数转换为二进制表示。
测试时,输入值(如 171)需预先放入 RAM[8000],程序运行后,转换得到的 16 位二进制结果将存入 RAM[8001] 至 RAM[8016]。
测试技巧:
- 使用 VM 模拟器的望远镜图标快速定位 RAM 地址。
- 程序运行后会修改 RAM,因此重新运行前无需(也不应)重置代码。
- 在非“无动画”模式下才能修改 RAM 值。
- 程序执行完毕后,需点击“停止”按钮才能查看 RAM 的最终状态。
测试程序 3:Square(Square Dance)
此程序测试编译器处理构造函数、方法以及包含方法调用的表达式的能力。该目录包含 Square.jack、SquareGame.jack 和 Main.jack 文件。
测试程序 4:Average
此程序测试编译器处理数组和字符串的能力。它是一个单文件程序,计算一组数字的平均值。
测试程序 5:Pong
这是一个完整的面向对象交互式游戏,用于测试编译器处理完整面向对象应用的能力,包括对象和静态变量的处理。目录包含 Ball.jack、Bat.jack、PongGame.jack 和 Main.jack 文件。成功编译后,可在 VM 模拟器中运行游戏,可能需要调整执行速度滑块以便操作。
测试程序 6:ComplexArrays
此程序测试编译器处理包含复杂索引表达式的数组操作的能力。程序运行后,会输出“期望结果”和“实际结果”,两者一致则表明编译正确。
总结


本节课中,我们一起学习了如何分阶段完成编译器的构建。我们首先通过实现符号表来增强语法分析器的语义分析能力,然后按照特定顺序,通过六个逐步复杂的测试程序来开发和验证代码生成功能。最终,你将拥有一个能够将 Jack 高级语言编译成可执行 VM 代码的完整编译器。
066:视角 🧠

在本节课中,我们将探讨代码生成单元的一些常见问题,包括Jack语言的简化特性、将其扩展为更复杂语言的挑战,以及编译器优化的概念。
Jack语言的简化特性
上一节我们介绍了代码生成的基本原理,本节中我们来看看Jack语言为何相对简单。Jack语言的设计包含了几项关键简化,这使得为其编写编译器的工作量大大减少。
以下是Jack语言的主要简化之处:
- 缺乏类型系统:所有数据值都是16位,任何类型的值都可以赋值给任何其他类型的变量。这允许编译器几乎完全避开处理类型问题带来的麻烦。
- 不支持继承:所有方法调用都可以在编译时静态处理。而在支持继承的语言(如Java)中,编译器必须在运行时解析方法调用,这被称为动态绑定或后期绑定。
- 没有公共字段:从类外部访问字段的唯一方式是通过程序员必须编写的访问器方法。
所有这些简化使得为Jack语言进行代码生成,比工业级语言的编译器要简单得多。
扩展Jack语言的挑战
了解了Jack语言的简化之处后,本节我们来看看将其扩展为更复杂语言(如Java或Python)的难度。一些扩展需要大量的代码生成工作,而另一些则相对直接。
以下是扩展Jack语言可能涉及的方面:
- 需要大量工作的扩展:支持继承、多态和动态绑定需要大量的编译工作。
- 相对直接的扩展:
- 添加
for循环和switch等控制结构。 - 在现有三种类型之外添加更多数据类型。
- 允许将字符常量直接赋值给
char类型变量(目前Jack程序员必须使用字符串函数,这很繁琐)。
- 添加
总的来说,扩展一门语言需要两项独立的活动:
- 语言设计者必须谨慎地扩展语言的语法。
- 编译器编写者必须扩展语法分析器和代码生成器以适应这些变化。
好消息是,大多数这些扩展是彼此独立的,可以通过对现有语法分析器和代码生成器进行相对局部和简单的修改来实现。
编译器优化的意义
在讨论了语言扩展之后,我们转向另一个核心话题:编译器优化。编译器本身的效率并不重要,重要的是它能生成高效且优化的低级代码。
例如,考虑Java中的高级语句 x++。
- 一个简单的编译器可能将其翻译成
push x,push 1,add,pop x这样的虚拟机命令,最终可能产生约50行机器码。 - 一个优化的编译器则会识别出这是一个简单的变量递增操作,并直接将其翻译成两条机器指令:
@X后跟M=M+1(在Hack平台上)。
生成尽可能紧凑、使用最少时钟周期和硬件资源的代码,是代码生成和编译器一个非常吸引人的特性。我们在本模块开发的虚拟机翻译器并未进行此类优化。
尽管如此,我们开发的代码生成器虽然未经优化,但仍然代表了一项复杂且实质性的工程成就。完成它的开发后,你应该为自己的成就感到自豪——你刚刚完成了一个面向对象的、类Java语言的编译器。
总结
本节课中我们一起学习了:
- Jack语言通过缺乏类型系统、不支持继承和没有公共字段等特性实现了简化。
- 将Jack扩展为更复杂的语言,有些方面(如支持继承)挑战巨大,而有些方面(如添加新控制结构)则相对直接。
- 编译器优化的核心目标是生成高效的低级代码,而非优化编译器自身,这是我们未来可以探索的方向。

本模块(模块5)到此结束。在下一个模块中,我们将使用Jack语言开发一个操作系统。
067:操作系统概述 🖥️

在本节课中,我们将要学习操作系统的核心概念、其必要性以及我们将在本模块中构建的Jack操作系统的蓝图。
课程概述
欢迎来到模块6,这是Nand2Tetris第二部分的最后一个核心模块。我们将讨论关于操作系统的众多不同方面。这个模块让我想起在印度餐厅享用的一顿丰盛晚餐,我们将品尝到许多色彩和风味各异的小菜,相信你会非常享受。
回顾我们的课程全景图,我们现在正处于操作系统模块的位置。没有操作系统在后台提供支持,就无法编写高级程序。操作系统的目标是弥合高级编程与程序赖以执行的底层硬件之间的诸多鸿沟。弥合这些鸿沟并描述这个操作系统,正是本模块的全部内容。
为什么需要操作系统?
世界上存在许多操作系统,这里展示了一些广为人知的系统图标。此外,还有一个不那么知名但我们将在本模块中开发的操作系统——Jack操作系统。
那么,为什么我们首先需要操作系统呢?请看这段用Jack语言编写的高级代码。你会发现,这段代码中多次调用了本类中不存在的方法。这些方法很可能存在于其他类中,例如Keyboard、Output、Math。这些类实际上属于Jack语言附带的标准类库。
这不是我们发明的。每一种现代高级语言都配备了一个支持它的标准库,Java、C#、Python、C++ 等都是如此。因此,当你开发一种高级语言时,该语言的用户会期望获得一个支持它的标准库。
操作系统的视角
从高级程序员的角度来思考操作系统,可以将其视为属于某个标准库的一组类。高级程序员期望获得各种数学计算功能的实现,例如平方根。他们期望获得数据结构方面的支持,如字符串、栈、数组等。
程序员期望能够以高级、抽象和便捷的方式与输入设备(如键盘、鼠标,甚至麦克风)进行交互。同样,程序员也期望能够使用一些便捷的API和接口在屏幕上输出文本或图形。因此,作为操作系统的开发者,我们有义务支持所有这些必要的抽象。
系统服务
此外,操作系统还应提供各种面向系统的服务,这些服务可能比我们刚才讨论的更偏向底层。
- 内存管理:我们必须能够管理主机内存(这里指的是RAM)。否则,当高级程序员想要创建新对象和新数组时,我们将无法帮助编译器完成。
- 存储管理:如果我们有海量存储设备(如硬盘、闪存等),我们也必须管理这些存储。为此,我们还需要一些文件系统抽象,至少需要支持文件,最好还能支持文件夹和子文件夹等。这些都是人们通常认为理所当然的东西,但显然是需要有人去实现的抽象。
- 设备驱动:我们需要开发各种驱动程序,这些程序用于在高级程序和低级外围设备(包括键盘、屏幕、鼠标以及绘图仪、打印机等各种你想连接到计算机的设备)之间进行协调。显然,你需要能够与这台计算机对话,因此必须有人实现诸如命令行界面和窗口系统等功能。
- 多任务处理:如今,很多人都希望同时做很多事情,例如写邮件、聊天、写博客、发评论、发布各种消息。为了做到这一点,我们需要为计算机配备多任务处理能力,使其能够同时运行多个程序。
- 网络与安全:如今没有人是一座孤岛,我们期望计算机能够通过网络与世界上的其他计算机交互。为此,我们必须有软件来协调和控制这些通信。一旦有了通信,我们还必须考虑安全性。
因此,我们必须支持所有这些服务,而承担这一重任的“某人”就是操作系统。由此可见,编写操作系统是一项庞大的工程。
Jack操作系统蓝图
在本课程中,我们不会开发一个功能齐全的操作系统,但会开发一个足够有趣且具有挑战性的系统。它将首先包括你在左侧看到的所有内容,即所有语言扩展。我们还将开发系统服务中的内存管理部分,并在一定程度上开发一些I/O设备驱动程序。其他所有内容将作为可选项目留待未来工作,作为我们Nand2Tetris课程的后续。
以下是我们的Jack操作系统的全貌,它由八个类组成,让我简要介绍一下每一个:
- Math类:提供各种数学运算。Java的Math类有大约60或70个数学函数(尽管许多函数是重载的,所以实际数量更少)。我们提供这个选择,显然如果需要可以添加更多。
- Memory类:这个类很经典,因为它包含任何操作系统中都存在的一些基本操作:
peek、poke、alloc、deAlloc。这些词现在可能没有意义,但很快你就会明白它们的重要性。 - Screen类:提供一个允许你在屏幕上绘制图形的类,可以画线、圆、一些多边形。我们将广泛使用它,实际上我们在之前的一些项目中已经使用过了。
- Output类:我们将广泛使用另一个类
Output,以便在屏幕上输出文本。 - Keyboard类:通过这个类,我们可以与使用键盘的用户进行交互。
- String类:字符串处理类,与Java的String类和C#中使用的类非常相似。我们将开发所有这些功能。
- Array类:这在Jack中有些独特,因为在Jack中,数组不像在其他一些语言中是语言的原始部分。相反,我们决定使用操作系统来实现数组。通过这样做,数组变得与对象非常相似。实际上,Java和C#中也是如此,但我们没有时间深入探讨。这对高级程序员是隐藏的。
- Sys类:包含一些有用的服务。
这就是我们的操作系统。要知道,有些人就是以开发操作系统为生的。
理论与实践
这些人基本上分为两类。首先是理论家,他们是算法和数据结构的专家。他们根据我们这里提到的需求,设计出各种巧妙的算法和数据结构,旨在高效地实现这些需求。然后是另一类人,他们将这些伟大的想法付诸实践,用C++、C等语言编写程序来实现这些数据结构和算法。
在本课程中,我们将同时采用这两种视角。在本模块的后续单元中,每个单元都将以一些理论讨论开始,探讨在算法和数据结构方面需要什么。然后,单元的后半部分将实际使用Jack语言实现这些想法。因此,Jack操作系统是用Jack语言编写的,就像Unix操作系统是用C语言编写的一样。我们还需要一些引导能力,这也将是本模块的一部分。
本模块的收获
本模块有许多值得学习的要点,这里只列出了其中几个。我们将深入学习应用计算机科学中这些超级重要的兴趣领域,并且将从高层次和低层次两个角度去学习。在本模块结束时,你将对这些内容有深入骨髓的理解。
我们将如何做到这一点呢?这个模块是一个绝佳的机会,让我和Noam可以向你们介绍一些用于管理资源、在屏幕上绘制图形等的优美算法。为了实现这些算法,我们还将向你们介绍巧妙编程的艺术,并给你们一些关于如何控制围绕软件的宿主平台的想法。当然,我们还将提供大量关于如何使用我们可用的所有工具来实际构建它的实现技巧。这就是我们的计划。
关于效率的说明
在结束本单元之前,我想简单谈谈效率问题。到目前为止,在本课程中,我们允许自己在效率方面有些“马虎”。我们不太担心效率,只希望事情能运行起来,不在乎它们是快是慢。但当涉及到开发操作系统时,效率至关重要。因为我们开发的算法将服务于在这些算法之上所做的一切。如果某个服务运行得不够快,那么所有运行在该服务之上的应用程序都会变得迟缓。反之,如果你能在底层优化某些东西,那么所有使用它的人都会视你为大英雄。因此,在操作系统层面,我们不能再对效率问题一无所知,我们必须非常聪明地设计程序的运行方式。这就是我们从下一个单元开始将要学习的内容。
课程总结


本节课中我们一起学习了操作系统的核心作用,它作为高级程序与底层硬件之间的桥梁,提供了必要的抽象和服务。我们概述了Jack操作系统的八个核心组件,并理解了开发操作系统需要兼顾算法理论与工程实践。最后,我们认识到在操作系统开发中,效率是至关重要的考量因素。
068:效率问题 ⚙️

在本单元中,我们将探讨操作系统设计中的一个核心议题:效率。我们将以乘法运算为例,分析不同算法的效率差异,并理解为何在底层系统软件中,高效的实现至关重要。
概述 📋
正如上一单元所述,我们决定以效率问题的讨论来开启操作系统模块。效率之所以重要,是因为操作系统中的基础服务(例如数学运算)将被无数应用程序乃至操作系统自身的其他部分频繁调用。因此,位于软件层次结构底层的服务,其效率越高,对上层服务的支持就越稳固。以乘法、除法、平方根等运算为例,它们必须足够高效。
乘法运算的客户端视角 👁️
在Jack编程中,你可以通过 x * y 这样的语法进行乘法运算。编译器足够智能,能够理解星号(*)代表调用底层操作系统 Math 类中的 multiply 方法,并自动将 419 * 5003 转换为 Math.multiply(419, 5003)。因此,无论使用哪种语法,最终都需要 Math 类来提供乘法功能。
算法一:重复加法
以下是第一种可能的乘法实现方案,它基于“重复加法”的思想:将 x 累加 y 次。
注意:从本单元开始,为了专注于算法本身而非语法细节,我们将使用类似Python的缩进来表示代码块,而不再使用Jack语言的花括号和分号。
def multiply(x, y):
sum = 0
while y > 0:
sum = sum + x
y = y - 1
return sum
算法分析
该算法的运行时间完全由 y 的值主导。循环将执行 y 次。在计算机科学中,我们通常从最保守的角度评估算法,即考虑最坏情况。
假设 n 是我们可能需要相乘的 y 的最大值。那么,在最坏情况下,该算法将进行 n 次迭代。每次迭代大约需要执行10次机器级操作(具体数字可能因编译等因素而变化)。因此,总操作数约为 10 * n。无论常数是10、20还是40,起主导作用的都是 n,所以该算法的运行时间与 n 成正比,即 O(n)。
如果 n 是一个非常大的数,这个算法的运行速度会非常慢。
算法二:基于二进制位运算的乘法
现在,让我们考虑第二种解决方案,它基于我们在小学学过的竖式乘法原理,但采用了更适合计算机实现的二进制形式。
例如,计算 27(二进制 11011)乘以 9(二进制 1001):
- 将乘数
x(11011)写下。 - 根据乘数
y(1001)的每一个二进制位(从最低位到最高位),将x左移相应的位数后写下。若该位为1,则此行的值有效;若为0,则此行值为0。 - 将所有有效行的结果相加,得到最终乘积。
一个更系统化的算法描述如下:
def multiply_fast(x, y):
sum = 0
shifted_x = x
W = 16 # 假设字长为16位(在Jack中)
for i in range(0, W):
if (y & 1) == 1: # 检查y的最低位是否为1
sum = sum + shifted_x
shifted_x = shifted_x + shifted_x # 左移一位(相当于乘以2)
y = y >> 1 # 将y右移一位,检查下一个比特位
return sum
算法分析
这个算法有一个非常优良的特性:它的运行时间不依赖于输入数字 n 的大小,而是依赖于数字的二进制表示位数 W。
- 在Jack中,
W=16。 - 在现代计算机中,
W可能是 64 或 128。 - 表示一个数字
n所需的位数大约是 log₂(n)。
因此,该算法的运行时间与 W 成正比,即 O(W) 或等价于 O(log n)。W 是一个固定的常数(如16、64),所以无论 n 是百万、十亿还是万亿,算法都只进行固定次数的循环(如16次、64次)。
此外,该算法只涉及加法和移位这两种非常基础且高效的操作。移位操作在二进制中非常简单(例如,左移一位等价于该数自己加自己)。
对数级运行时间的威力 💪
为了理解 O(log n) 的效率有多高,让我们看一些具体例子。
下表对比了线性时间 O(n)(假设系数为10)和对数时间 O(log n) 在不同输入规模 n 下的运行时间(迭代次数):
| 输入大小 (n) | O(n) ~ 10n | O(log n) ~ 10 * log₂(n) |
|---|---|---|
| 1,024 | 10,240 | 100 |
| 1,048,576 | 10,485,760 | 200 |
| 1,073,741,824 | 10,737,418,240 | 300 |
从图表上看,随着 n 的增长,线性函数(红色)急速上升,而对数函数(蓝色)则缓慢增长,渐近有界。
对数运行时间的一个关键特性是:当输入规模 n 翻倍时,算法所需的迭代次数仅增加 1 个单位。这是一个非常卓越的特性,具有深远的实际意义。
现实世界的例子:网络搜索
你们都在使用搜索引擎在互联网上查找信息。所有搜索引擎的核心都使用了各种版本的二分查找算法,该算法的运行时间就是 O(log n)。
这意味着什么?
假设互联网上有 10亿 个可搜索项,找到一个特定项需要大约 300 次迭代(假设常数因子为10)。
- 如果明天互联网规模翻倍到20亿项,找到一项需要 310 次迭代。
- 如果规模再次翻倍到40亿项,则只需要 320 次迭代。
可以看到,即使互联网的规模爆炸式增长,搜索时间也仅以对数速度缓慢增加,永远不会变得过大。这生动地展示了对数级运行时间算法的强大威力。因此,在计算机科学中,无论是理论还是应用领域,设计出对数级运行时间的算法总是令人欣喜的。
总结 🎯
在本单元中,我们围绕效率这一主题展开讨论,并以乘法运算为例,比较了线性时间 O(n) 算法与对数时间 O(log n) 算法的巨大差异。我们了解到,对于操作系统这类底层、基础的服务,采用高效的算法(如基于位运算的乘法)至关重要,因为它会被上层大量调用,其效率直接影响整个系统的性能。对数级算法因其“输入翻倍,耗时仅增一”的卓越特性,在处理大规模数据时具有无可比拟的优势。

在下一单元,我们将以此为基础,继续探讨其他数学运算(如除法、平方根)的高效实现。
069:数学运算 🧮

在本节课中,我们将继续讨论数学运算,重点介绍除法与平方根的高效算法实现。我们将对比朴素算法与高效算法,并探讨在Jack操作系统中的具体实现细节。
上一节我们讨论了线性运行时间与对数运行时间的巨大差异,并以此为基础介绍了两种乘法算法。本节中,我们来看看除法与平方根运算。
除法运算 ➗
在Jack程序中,除法可以通过两种语法形式实现:
x = something / something- 直接调用
Math类的divide函数
第一种形式因其可读性更佳而被推荐,但编译器最终会将两者都转换为函数调用。
朴素除法算法
以下是实现除法的朴素方法:计算在余数严格小于除数y之前,能从被除数x中减去多少次y。
例如,计算20除以6:
- 20 - 6 = 14 (减1次)
- 14 - 6 = 8 (减2次)
- 8 - 6 = 2 (减3次)
- 2 < 6,停止。
结果为商3,余数2。
该算法的运行时间取决于x的大小。如果n是可表示或被要求相除的最大数字,则运行时间与n成正比。这不仅意味着运行时间可能很长,而且不可预测,在某些输入下算法可能需要运行数百万甚至数十亿次。
高效长除法算法
幸运的是,我们有一个基于长除法的更优算法。以175除以3为例:
该算法的核心思想是加速减法过程。我们不是一次减去一个3,而是寻找能一次性减去多个3的最大倍数。
- 首先确定:在100, 90, 80, ..., 10中,最大的
x使得3*x ≤ 175?答案是50(因为50*3=150)。 - 将50记录在结果中,并计算余数:175 - 150 = 25。
- 接着处理余数25:在9, 8, 7, ..., 1中,最大的
x使得3*x ≤ 25?答案是8(因为8*3=24)。 - 将8加到结果中(累计商为58),并计算新余数:25 - 24 = 1。
- 由于1 < 3,除法完成。最终结果为商58,余数1。
此算法的美妙之处在于,其运行时间与输入数字的位数成正比,即O(log n)。对于二进制数是log₂ n,对于十进制数是log₁₀ n。这是一个巨大的改进。
为了总结这两种算法,让我们快速回顾一下:
- 重复减法算法:运行时间取决于输入大小,可能非常巨大。
- 长除法算法:运行时间取决于输入位数,永远不会太大。
例如,对于一个天文数字,朴素算法的运行时间将是该数字本身的数量级,而长除法算法的运行时间仅为27(即该数字的位数)。这展示了长除法的极高效率。
递归除法算法
在我们的操作系统中,将使用另一种递归除法算法。该算法基于以下洞见:若想计算x / y,可以先计算(x/2) / y的结果,再根据奇偶性进行调整。
算法思路(伪代码表示):
function divide(x, y):
if (y > x) return 0
q = divide(x, 2*y)
if (x - 2*q*y < y)
return 2*q
else
return 2*q + 1
该算法的运行时间同样是O(log n),达到了此类算法的理论最优。其优点在于仅涉及加法和减法操作,因此无论在软件还是硬件中都能高效实现。这就是我们要求在Jack操作系统的Math类中实现的divide函数所使用的算法。
平方根运算 √
平方根函数具有两个吸引人的特性:
- 其逆运算(平方)很容易计算:
sqrt(x)的逆运算是x * x,而我们已经掌握了高效的乘法算法。 - 平方根函数是单调递增的。
基于这两个特性,我们可以使用二分查找算法来计算平方根。二分查找算法的运行时间也是对数级的,即O(log n)。
因此,我们将使用二分查找算法在操作系统中实现平方根运算。这同样是一个高效的算法。
内容回顾与实现说明 📝
我们讨论了乘法、除法和平方根的高效算法。其余函数(如绝对值、最小值、最大值)较为简单,相信你能轻松处理。
接下来,讨论一些重要的实现注意事项,因为数学运算总存在一些必须处理的潜在问题。
乘法实现要点
以下是C语言风格的乘法算法伪代码,我们需要处理三个问题:
- 如何处理有符号数:如果输入使用二进制补码表示,该算法无需特殊处理即可正常工作。
- 如何处理溢出:算法中可能发生溢出的地方(如
sum = sum + shiftedX)。该算法返回的结果是模2^16的正确值。这意味着它可能无法给出完整结果,但给出的位是正确的。通常我们不会遇到溢出,因此这个结果是可以接受的。 - 如何提取特定位:算法需要提取16位值
y的第i位。我们建议将此操作封装在一个名为bit的布尔函数中。
由于Jack语言不支持位运算,我们需要一个变通方案。我们建议创建一个静态数组,在计算机启动时一次性构建。该数组保存16个值:2^0, 2^1, ..., 2^15。
// 在Math类中声明静态数组
class Math {
static Array twoToTheI;
...
}
可以使用Math.init()来构建并填充这个数组。一旦有了这个数组,就可以轻松地实现bit函数。重申一下,这个数组仅在操作系统启动时构建一次,这是操作系统的典型做法——在初始化阶段构建各种将在整个执行过程中使用的数据结构。
除法实现要点
对于除法算法,需注意:
- 有符号数处理:建议先计算输入绝对值的除法,然后根据需要设置结果的正负号。共有四种情况(正/正,正/负,负/正,负/负),可以轻松处理。
- 溢出处理:在递归计算中,
y可能溢出(因为我们会将其乘以2)。我们建议监控y的值。对于一个增长的二进制补码正数,当其变为负数时,就发生了溢出。因此,我们可以在算法条件中添加判断:如果y > x或y < 0,则返回0。这足以处理溢出。
平方根实现要点
对于平方根算法,同样存在潜在溢出点(例如在计算中点值的平方时)。我们建议在算法的条件判断中添加额外的子句,以妥善处理溢出。
这些就是这三个函数的实现要点。至此,我们完成了Math类的实现,因为其余函数确实很简单。
总结 🎯
本节课中我们一起学习了:
- 除法运算:对比了运行时间为O(n)的朴素重复减法算法与运行时间为O(log n)的高效长除法算法。并介绍了将在Jack操作系统中使用的递归除法算法。
- 平方根运算:利用其单调性和易求逆的特性,采用运行时间为O(log n)的二分查找算法实现。
- 关键实现细节:包括有符号数处理、溢出检测,以及在缺乏位运算支持的Jack语言中,通过预计算2的幂次数组来高效提取特定位的技巧。

我们已经完成了第一个操作系统类——Math类的实现。在下一单元,我们将讨论内存管理。
070:内存访问 🧠

在本节课中,我们将学习操作系统如何管理内存,特别是如何通过两个基础操作——peek和poke——来实现对内存的直接访问。我们将从理解需求开始,探讨解决方案,并最终了解如何在Jack语言中实现这些功能。
内存管理的需求与解决方案
上一节我们介绍了操作系统在程序与硬件之间扮演的桥梁角色。本节中,我们来看看内存访问的具体需求。
程序运行在宿主计算机的RAM上。然而,高级语言(如Jack)编写的程序并不直接操作RAM,而是通过变量、对象和数组等抽象概念来间接访问。同样,应用程序需要从键盘读取输入或向屏幕输出内容,而无需关心这些操作在硬件层面的具体实现。
操作系统的作用,就是提供一座桥梁,连接高级程序与底层硬件。屏幕和键盘等设备正是通过内存映射技术实现的,这些映射区域也位于RAM中。因此,所有操作最终都归结为对RAM中特定比特位的读写。
核心内存操作:Peek与Poke
为了实现对RAM的直接访问,操作系统提供了底层的peek和poke服务。基于这些基础原语,操作系统可以构建出更高级的功能(如读取和打印),从而为高级程序员提供他们期望使用的抽象接口。
以下是这两个核心操作的定义:
peek:此函数旨在返回RAM中指定地址的值。- 公式/代码描述:
value = Memory.peek(address)
- 公式/代码描述:
poke:此函数接收一个地址和一个值,并将该值设置到RAM的对应地址中。- 公式/代码描述:
Memory.poke(address, value)
- 公式/代码描述:
例如,假设地址19003中存储着数字7。
- 执行代码
let x = Memory.peek(19003);后,变量x的值将变为7。 - 执行代码
Memory.poke(19003, -1);后,该地址的内容将变为二进制补码表示的-1(即16个1)。
在Jack中实现内存访问
现在我们已经理解了peek和poke的功能,本节中我们来看看如何在Jack语言中实现它们。关键在于如何让Jack程序能够访问整个RAM空间。
首先,让我们考虑一个直观但行不通的方案。
一个行不通的方案
一个天真的想法是:在内存类中创建一个名为Ram的数组,其大小与整个RAM(32768个单元)相同。
// 这是一个行不通的方案
class Memory {
static Array Ram;
...
}
这个方法会失败,原因有二:
- Jack编译器会尝试在堆中分配这个巨大的数组,但堆空间不足以容纳它。
- 即使能分配,通过这种方式创建的数组也无法访问屏幕、键盘等硬件内存映射区域。
可行的实现方案
虽然上述方案失败了,但它引导我们找到了正确的解决方案。以下是可行的实现方法。
我们在Memory类中创建一个静态数组Ram,但关键在于其初始化方式。某些操作系统类拥有不向用户公开的init函数,用于内部初始化和簿记工作。我们可以在Memory.init函数中执行以下特殊赋值:
class Memory {
static Array Ram;
function void init() {
let Ram = 0; // 关键步骤:将数组变量指向地址0
}
...
}
这个语句 let Ram = 0; 看起来很奇怪,因为它将数组当作一个普通变量来赋值。然而,这恰恰是我们需要的技巧。由于Jack是一种弱类型语言,编译器允许这样的操作。
这个技巧之所以有效,是因为它将数组Ram的基地址设置为0。此后,在程序中任何地方,当我们执行 Ram[address] = value 时,实际上访问的是RAM中地址为 0 + address 的位置。这样,我们就通过高级语言获得了一个通往整个RAM的“后门”。
当然,这种强大的能力也意味着责任。系统程序员(如操作系统开发者)必须清楚自己在做什么,因为错误的操作可能会破坏栈、堆等关键内存区域。
实现Peek与Poke
有了Ram数组这个强大的工具,实现peek和poke函数就变得非常简单。它们本质上只是一两条语句的封装,你可以尝试自己思考如何实现。
总结与展望
本节课中,我们一起学习了操作系统内存管理的基础。我们了解了程序为何需要操作系统作为与RAM硬件交互的桥梁,并重点探讨了实现直接内存访问的两个核心底层操作:peek(读取内存)和poke(写入内存)。更重要的是,我们学习了一个在Jack语言中访问整个RAM空间的巧妙技巧——通过初始化将数组指向地址0。

现在,我们已经为Memory类打下了基础。掌握了peek和poke之后,我们就可以在下一单元继续讨论更高级的内存管理功能:alloc(内存分配)和deAlloc(内存释放)。
071:堆管理 🧠

在本节课中,我们将要学习操作系统如何管理计算机的主内存资源,特别是“堆”这一部分。我们将探讨两个核心操作:alloc(分配内存)和deAlloc(回收内存),并了解如何通过一个称为“空闲链表”的数据结构来高效地管理堆内存。
堆管理的需求
上一节我们介绍了操作系统如何通过peek和poke操作来访问内存。本节中我们来看看操作系统如何管理程序动态请求的内存区域——堆。
在程序运行时,高级语言(如Jack)编写的程序会创建许多对象和数组。每个对象和数组在内存中都有一个对应的数据块,而指向这些数据块的指针通常存储在栈中。存放所有这些对象和数组数据的内存区域,在计算机科学中被称为堆。
挑战在于如何高效地管理堆内存资源。具体来说,我们需要知道:
- 如何在被请求时分配内存。
- 如何回收不再需要的对象和数组所占用的内存。
我们将通过操作系统的一个名为Memory的类来实现这些功能。它主要包含两个函数:
alloc(size):接收需要分配的内存块大小作为参数,在RAM中找到一块合适大小的内存,并返回该内存块的起始地址给调用者。deAlloc(object):接收一个对象或数组的地址,回收该对象或数组在RAM中占用的空间。
高级程序员的视角
在深入操作系统实现之前,让我们先从一个高级程序员(如Jack程序员)的角度看看发生了什么。
当程序员声明一个对象变量(例如 Point p)时,系统会在栈上分配一个变量p并将其初始化为null(即0)。当程序员通过构造函数(例如 let p = Point.new())实际创建对象时,会发生以下事情:
- 构造函数内部会隐含地调用
alloc(2)向操作系统请求分配2个字(word)的内存(假设Point对象有两个字段x和y)。 - 操作系统找到一块可用的内存(例如地址8012),将其分配给这个新对象。
- 构造函数将这块内存的基地址(8012)存入变量
p中,从而建立链接。
当对象不再需要时,程序员应显式调用 p.dispose()。dispose方法内部会调用 deAlloc(p),通知操作系统回收该对象占用的内存。
注意:像Java/C#这类语言拥有垃圾回收器,能自动跟踪对象引用并在引用数为零时自动回收内存。但Jack语言不包含此功能,因此需要程序员手动管理内存的回收。
堆管理的基本实现思路
现在,让我们向下迈进一步,探讨在操作系统层面如何实现堆管理。我们将介绍两种方法:一种简单但低效,另一种更复杂但高效。
简单方法:顺序分配
一个最简单的堆管理方法是维护一个指针(例如 free),它始终指向堆中下一块可用内存的起始地址。
- 初始化:
free = HeapBase(堆的起始地址,在Hack计算机中是2048)。 - 分配 (
alloc(size)):block = free(记录要返回的块地址)free = free + size(将空闲指针向后移动size个字)return block(返回分配的内存块地址)
- 回收 (
deAlloc(block)):什么都不做。
代码描述:
// 伪代码示意
function alloc(size) {
block = free;
free = free + size;
return block;
}
function deAlloc(block) {
// 什么也不做
}
这种方法的问题显而易见:它只分配,不回收,最终会导致内存耗尽。它只适用于程序运行时间很短或创建对象极少的情况。
高效方法:使用空闲链表
一个更现实的解决方案是使用一个空闲链表来跟踪所有当前可用的内存段。
链表中的每个“段”具有以下结构:
- 两个开销字(Overhead Words):
size:本段中数据区的大小(以字为单位)。next:指向链表中下一个空闲段的指针。
- 数据区:紧随其后的、实际可用来存储对象或数组数据的连续内存空间。
公式描述一个内存段:
[段地址] -> [size | next | data...]
初始时,整个堆是一个大的空闲段,链表只有一个节点。
以下是核心操作流程:
内存分配 (alloc(size))
当请求分配大小为 size 的内存时,操作系统需要搜索空闲链表,找到一个足够大的段。
寻找策略:
- 首次适应:从链表头开始,找到第一个大小 >=
size + 2的段(+2是用于存放size和next的开销)。这是一种贪心算法,速度较快。 - 最佳适应:搜索整个链表,找到大小 >=
size + 2且最小的段。这有助于减少内存碎片。
找到合适段后,从中“切出”一块大小为 size + 2 的内存(包含开销)。剩余部分(如果有)形成一个新的、更小的空闲段,需要更新链表。最后,返回被切出块的数据区起始地址(即块地址 + 2)给调用者。
内存回收 (deAlloc(block))
当回收一个对象时,我们获得的是其数据区的起始地址。我们需要将其对应的整个内存块(包括开销)重新链接到空闲链表中。一个简单的实现是将其追加到链表的末尾。
内存碎片整理
随着频繁的分配和回收,空闲链表会变得“碎片化”,即包含许多小的、不连续的空闲段。这可能导致无法分配较大的对象,即使总空闲空间足够。
碎片整理算法会周期性地遍历空闲链表,将物理地址相邻的空闲段合并成更大的段。这是一个高级功能,在本课程的操作系统实现中是可选的扩展项目。
实现技巧与内存布局
在Hack操作系统的Memory类中实现上述想法,需要一些具体的技巧。
我们可以在Memory类中声明一个静态数组heap,但它并不是通过Array.new创建的真正数组。我们只是借用“数组”这个概念,将其起始地址设置为堆的基地址(2048),然后将其当作一块大的、可自由读写的内存区域来管理我们的空闲链表。
初始化 (init):
- 设置
heap的基地址为2048。 - 初始化空闲链表:让链表头指向
2048。 - 在第一个“段”(地址2048)处,设置
size = 16384 - 2048(整个堆的大小),next = 0(链表结束标志)。
访问段信息:
- 对于一个位于地址
addr的段:- 其
size存储在heap[addr]。 - 其
next指针存储在heap[addr+1]。 - 其数据区从
heap[addr+2]开始。
- 其
通过操作这个“伪数组”heap,我们可以实现alloc、deAlloc以及可选的defrag函数。
总结
本节课中我们一起学习了操作系统堆管理的核心概念。我们了解到:
- 堆是用于动态分配对象和数组内存的区域。
- 堆管理通过
Memory类的alloc和deAlloc函数实现。 - 一种高效的实现方式是使用空闲链表来跟踪可用内存段。
- 分配时可采用“首次适应”或“最佳适应”策略在链表中寻找合适内存块。
- 回收时将内存块简单地链接回空闲链表。
- 内存碎片是长期运行程序的挑战,可以通过碎片整理算法来缓解。

现在,你已经理解了现代操作系统中内存管理的基础原理。在接下来的单元中,我们将探索另一个核心主题:图形系统。
072:图形处理 🖥️

在本单元中,我们将学习图形处理的基本概念,并重点介绍如何实现Jack操作系统中用于处理图形操作的Screen类。我们将探讨一个图形库,该库提供了在屏幕上绘制图形和像素的几种基本操作。
概述
首先,我们将介绍图形的基本概念,特别是两种主要的图形表示技术:位图和矢量图形。接着,我们将深入探讨如何在我们的操作系统中实现最基础的图形操作——绘制单个像素。
图形表示技术
上一节我们介绍了本单元的学习目标,本节中我们来看看两种核心的图形表示技术:位图和矢量图形。
请看这两幅看起来相同的图片,它们都是唐老鸭试图理解计算机工作原理的图案。如果我们移除计算机并开始放大图像,一个显著的差异就会显现出来:右侧的图像放大后效果不佳,而左侧的图像则可以完美缩放。
这种差异源于两种不同的图形存储技术。左侧的图像使用矢量图形管理,而右侧的图像是位图的一个例子。接下来,我们将讨论每种技术的优点,并特别关注矢量图形。
位图 (Bitmap)
位图是一种直接的图形存储方式。它将屏幕上的每个像素映射为一个二进制值。
以下是绘制一个杯子的屏幕部分示例。在Jack语言(或Hack计算机)中,我们只有黑白两色(尽管可以轻松添加颜色)。杯子通过打开和关闭像素来绘制。
对应的位图文件会存储这个图像。对于每个白色像素,存储0;对于每个黑色像素,存储1。这样就得到了一系列数字,它们共同建立了图形与二进制数字之间的一一对应关系。
核心概念:位图存储的是每个像素的颜色值。
像素颜色值 = 0 (白) 或 1 (黑)
图像数据 = [像素1值, 像素2值, 像素3值, ...]
矢量图形 (Vector Graphics)
矢量图形存储的不是像素数据,而是一系列描述如何绘制图形的指令。
同样是那个杯子,矢量图形文件可能包含以下指令:首先,从坐标(30)到(110)画一条线,形成杯子的顶部。然后,利用一个大矩形,从(31)到(95)画一个矩形,这样一条指令就描述了大约35个像素。接着,继续画几条线来完成杯子的绘制。
核心概念:矢量图形存储的是绘制命令。
绘制命令列表 = [
drawLine(30, 110),
drawRectangle(31, 95),
...
]
技术对比
以下是两种技术的主要优缺点:
- 文件大小:矢量图形文件通常更小,因为它存储的是指令而非每个像素的数据。
- 缩放性:矢量图形可以完美缩放。只需按比例调整指令中的坐标参数,即可在任何分辨率下获得清晰的图像。位图缩放则会出现锯齿,需要复杂的插值算法,效果往往不理想。
- 适用性:在拥有多种屏幕尺寸(手机、相机、笔记本电脑、平板电脑)的世界里,矢量图形的可缩放性极其重要。此外,矢量图形可以随时转换为位图。
总而言之,矢量图形是一项非常重要的发明,我们的操作系统也将使用它。
基础绘图操作
在了解了图形表示的基础后,本节我们将聚焦于实现图形操作所需的三个基本原语。
我们将要探索的三个基本绘图操作是:
- 绘制单个像素
- 绘制一条线
- 绘制一个圆
我们将从绘制像素开始。需要说明的是,绘制像素是位图和矢量图形都需要的操作,它是最基础、最原始的操作。在此基础上,我们可以创建位图或矢量图形。
绘制像素的实现原理
现在,让我们深入了解绘制单个像素是如何在硬件和软件层面实现的。
首先需要回顾,在构建Hack计算机时,我们分配了一段特定的内存区域来表示屏幕。我们取用了8K(8192个)寄存器,称之为屏幕内存映射。
约定如下:如果我们要在屏幕上绘制像素,只需在内存映射中打开或关闭某些特定位,图像就会“自动”显示在屏幕上。其工作原理是,硬件实现了一个称为屏幕刷新的机制。每秒多次,计算机会自动例行刷新,读取内存映射中的所有位,并将其提交到物理屏幕上对应的像素点。
例如,假设我们想在屏幕上绘制坐标为(450, 200)的像素。在Jack中,我们可能会使用这样的命令:Screen.drawPixel(450, 200)。这个操作将由操作系统,特别是Screen类来实现。
当drawPixel例程被调用以绘制这个特定像素时,它会进行以下计算:
- 确定需要操作的内存字(word)的地址。
- 确定在该16位字中,需要操作的是哪一位(bit)。
屏幕是二维空间(宽512像素,高256像素),而RAM是一维的16位字数组。两者之间的映射关系是直接的。
通过数学计算,我们可以找到操作字地址的公式:
wordAddress = baseAddress + (y * 32) + (x / 16)
其中,baseAddress是屏幕内存映射的基地址,x和y是像素坐标。
然后,我们执行以下步骤:
- 从RAM中该地址读取当前的16位值。
- 将该值的第
(x % 16)位设置为当前颜色(1或0)。 - 将修改后的16位值写回RAM的同一地址。
你可能会问,为什么需要先读取再写入,而不是直接操作特定位?原因有二:
- 计算机必须以固定大小的数据块(如16位或64位)进行操作,无法直接操作单个位。因此,必须读写整个字来修改其中的一个位。
- 该字中可能已经绘制了其他相邻像素。通过先读取整个字,然后只修改目标位而不影响其他位,我们可以安全地更新像素,避免破坏周围的图像。
通常,我们会使用位操作(如OR运算)来设置特定位,然后将结果写回内存。
核心操作流程:
// 1. 计算目标字地址
address = Screen.baseAddress + (y * 32) + (x / 16)
// 2. 读取当前字的值
currentWord = Memory.peek(address)
// 3. 计算掩码并设置特定位
mask = 1 << (x % 16)
newWord = currentWord | mask // 假设是“画黑点”(置1),清空则用AND操作
// 4. 将新值写回内存
Memory.poke(address, newWord)
值得注意的是,Screen类中的drawPixel子程序会调用Memory类中的peek和poke子程序。这种操作系统模块间的相互依赖关系是非常典型的。
总结

本节课中我们一起学习了图形处理的基础。我们比较了位图和矢量图形两种技术,了解了矢量图形在文件大小和可缩放性方面的优势。我们深入探讨了在Hack计算机平台上绘制单个像素的实现原理,包括屏幕内存映射的概念、像素坐标到内存地址的换算,以及通过读取-修改-写入流程来安全更新像素值的方法。在下一单元,我们将在此基础上学习如何绘制一条线。
073:线段绘制 🖌️

在本节课中,我们将学习如何绘制线段。上一节我们介绍了矢量图形和位图图形,并讨论了矢量图形的优点。我们决定实现三个图形基元:绘制单个像素、绘制线段以及绘制圆形。本节我们将从绘制像素出发,学习如何使用绘制像素功能来绘制线段,以及如何利用绘制线段功能来绘制圆形。
线段绘制的动机
矢量图形的一个优点是,所有图形都由简单的代数语句控制。例如,我们可以将一组多边形分解为若干子图像,每个子图像都使用矢量图形绘制。以绘制鸟喙为例,我们可以通过连续绘制多条线段来构成这个多边形。
图像绘制是一系列线段绘制操作的集合。因此,线段绘制的速度至关重要。如果绘制速度慢,整个图形渲染过程就会变得迟缓,无法实现流畅的动画或视频播放。所以,我们需要尽可能快地绘制这些线段。
线段绘制算法详解
让我们通过一个示例网格来深入探讨线段绘制的细节。首先,我们做一个简化假设:只关注向东北方向延伸的线段。
假设我们要绘制从点 (x1, y1) 到点 (x2, y2) 的线段。由于我们只能控制像素的开关,因此只能通过近似的方式来绘制这条线段。在每次迭代中,我们只能选择向右或向上移动一个像素,以避免在线上产生空洞。
以下是绘制线段的基本算法:
- 我们使用变量
A和B来分别记录当前已向右和向上移动的像素数。初始时,A = 0,B = 0。 - 在每次迭代中,我们绘制像素
(x + A, y + B)。 - 然后,我们决定是向右移动(
A增加 1)还是向上移动(B增加 1)。 - 循环条件为:只要
A < dx且B < dy,就继续绘制。
决定向右还是向上的关键在于比较当前路径的斜率与目标线段的斜率。具体来说,我们比较 B/A 与 dy/dx 的大小。如果 B/A > dy/dx,说明当前路径过于陡峭,应向右移动以降低斜率;否则,应向上移动以提高斜率。
然而,这个比较涉及除法运算,计算成本较高。为了优化,我们可以进行如下转换:
比较 B/A > dy/dx 等价于比较 A * dy < B * dx。我们引入变量 diff = A * dy - B * dx。初始时 diff = 0。判断条件 B/A > dy/dx 就变成了判断 diff < 0。
当 A 增加 1 时,diff 增加 dy;当 B 增加 1 时,diff 减少 dx。这样,整个算法就简化为只包含加法和减法的操作,极大地提高了效率。
优化后的算法伪代码如下:
初始化 A = 0, B = 0, diff = 0
当 A < dx 且 B < dy 时,循环:
绘制像素 (x + A, y + B)
如果 diff < 0:
A = A + 1
diff = diff + dy
否则:
B = B + 1
diff = diff - dx
这个算法的运行时间仅取决于需要绘制的像素数量,是目前最高效的线段绘制算法之一。
使用线段绘制圆形
现在我们已经知道如何使用绘制像素来绘制线段,接下来看看如何利用线段绘制功能来绘制圆形。
要绘制一个圆形,我们需要至少三个输入参数:圆心坐标 (x, y) 和半径 r。在像素化的计算机屏幕上,我们无法绘制完美的圆形,只能通过近似来实现。
一种方法是绘制一个实心圆。我们可以通过绘制一系列水平线段来填充圆形区域。具体来说,对于从 -r 到 r 的每一个垂直偏移量 dy,我们计算对应的水平线段的起点和终点 x 坐标,然后绘制这条线段。
对于每个 dy,线段的 y 坐标都是 y + dy。起点和终点的 x 坐标可以通过勾股定理计算得出:
x1 = x - sqrt(r^2 - dy^2)
x2 = x + sqrt(r^2 - dy^2)
然后,我们调用绘制线段函数,绘制从 (x1, y+dy) 到 (x2, y+dy) 的线段。对所有 dy 执行此操作后,就得到了一个实心圆。
如果只需要绘制圆形的轮廓,我们可以修改算法:对于每个 dy,不绘制整条线段,而是只绘制两端的像素,即分别绘制像素 (x1, y+dy) 和 (x2, y+dy)。
实现注意事项
到目前为止,我们讨论的都是理想化的算法。在实际实现时,需要考虑一些细节。
对于 drawPixel 函数,它需要使用 peek 和 poke 操作来读写内存。此外,需要操作 16 位值中的特定位,这可以通过位运算来实现。
对于 drawLine 函数,需要进行几处修改:
- 我们的算法基于原点在左下角的坐标系,但计算机图形学中屏幕原点通常在左上角。因此需要调整算法以适应左上角原点。
- 算法只处理了向东北方向的线段。实际上,线段可能有八个方向(东、南、西、北、东北、东南、西北、西南)。需要扩展算法以处理所有情况。
- 水平和垂直线段可以作为特例处理,因为它们的绘制可以更高效,而这类线段在绘制矩形等图形时非常常见。
对于 drawCircle 函数,需要注意计算中的溢出问题。如果限制半径 r 不大于 181,通常可以避免溢出。这使得图形库在一定程度上依赖于硬件屏幕的大小。
屏幕类的其他函数相对简单,相信你可以运用自己的判断力来实现它们。
总结

本节课我们一起学习了计算机图形学中的核心绘制功能。我们首先探讨了高效的线段绘制算法,该算法通过巧妙的数学转换,将耗时的除法运算优化为快速的加减法运算。接着,我们学习了如何利用线段绘制功能来构建实心圆和圆形轮廓。最后,我们讨论了在实际编码实现这些功能时需要注意的细节,如坐标系转换、方向处理以及性能优化。掌握这些基础图形的绘制方法,是构建更复杂图形应用的关键第一步。
074:处理文本输出 📝

在本单元中,我们将学习计算机如何处理文本输出。计算机屏幕通常有两种工作模式:图形模式和文本模式。我们将重点介绍文本模式,以及如何通过操作系统中的 Output 类在屏幕上绘制字符。
屏幕的两种模式
上一节我们介绍了计算机屏幕的基本概念。本节中我们来看看它的两种工作模式。
计算机屏幕至少有两种处理模式。首先,程序可以以图形模式看待屏幕。在这种模式下,程序将屏幕视为一个由 256 行、每行 512 个像素组成的黑白网格。通过 Screen 类提供的各种抽象,我们可以绘制各种图形,实现面向图形的输出。
然而,在许多情况下,我们也需要开发文本应用程序。这些应用程序需要绘制字母、数字等。为此,我们提供了另一种操作模式,称为文本模式。在这种模式下,我们可以将屏幕视为一个由 23 行、每行 64 个字符组成的黑白网格。为了以这种方式使用屏幕,我们不使用 Screen 类,而是使用一个完全不同的类,称为 Output。因此,Screen 和 Output 这两个类提供了两种不同的方式来思考和操作屏幕。
请注意,就像 Screen 类实现了绘制圆形和矩形等抽象一样,Output 类也以非常类似的方式实现了绘制字母 K、Q、1、7 等抽象。字母 K 的图像本身就是一个抽象,需要有人努力才能在屏幕上实际绘制出这个图像。这正是本单元要讨论的内容。
字符集与字体
既然我们要处理文本输出,首先需要确定要绘制哪些字符。
我们的 Hack Jack 平台支持并识别许多不同规格的字符,其中之一就是这个字符集。计算机支持并识别所有这些字符,其中一些是可打印的,另一些是不可打印的。所有可打印字符都必须有相应的位图图像来在屏幕上显示它们。不可打印字符传达一些非常重要的信息,但它们没有视觉效果。当然,换行符和退格符除外,它们有视觉效果,但不会在屏幕上显示任何特定字符。
在这些表格中我们看到的是 ASCII 码或 Unicode(两者是相同的),以及这个字符的子集。在左侧,我们看到字符的名称或我们将在屏幕上看到该字符的方式。
以下是一个典型的文本输出示例。我们看到屏幕由一个网格来表征,X 轴从 0 到 63,Y 轴从 0 到 22。然而,我们必须记住,实际的物理屏幕仍然是 256 x 512 像素,这在我们的计算机内存中使用一个 8K 16 位字的块来表示。
因此,我们面临着一个底层挑战:如何使用一个 256 x 512 像素的网格来实现一个为文本应用程序设计的 23 x 64 字符网格。因为最终我们必须理解,我们将要绘制图片,例如构成单词 “AN” 的字母 A 和 N。这些是三个不同的位图图形,我们必须使用像素来绘制。我们应该怎么做呢?
让我们仔细看看这部分输出。让我们再靠近一点看。现在让我们真正靠近。我们看到这里的东西被称为字体。
Hack 字体在设计上,使用一个固定的 11 像素高、8 像素宽的区域来表示字符集中的每个字符。我们称这个区域为“帧”。如果你看这些帧,你会发现帧的右侧包含两个空列用于字符间距,底部包含一行用于行间距。现在你可能看起来底部有两行空行,但实际上,请记住我们有像 G 和大写 Q 这样有向下延伸部分的字母,所以我们必须多使用一行来处理这些字符。这里显示的四个字符恰好没有这些延伸部分,所以你在这里看不到,但我们稍后会看到。
这就是 Hack 字体。我们花了相当多的精力来设计它,因为我们必须绘制这些位图,为每个字符决定使用哪个图像。这个字体不是非常漂亮,但绝对可用。事实上,你定义的字体越多,你就越接近像花哨的文字处理器那样的东西。
所以,每种字体实际上是一整套图像,决定了如何在计算机屏幕上绘制每个字符。
字体的实现
现在,我们应该如何实现这个字体呢?坏消息是,这需要大量工作。好消息是,我们已经为你完成了,我们不期望你自己去做,因为这是重复性工作,我们不希望你做重复性工作。
我们使用接下来要探讨的 Output 类来实现这个字体。
以下是这个 Output 类。如你所见,我们首先定义一个名为 characterMaps 的静态数组。这个数组将保存我们字符集中每个成员的所有位图,总共 127 个字符。请注意,它必须是静态的,因为我们希望这个类中的每个方法都能出于各种目的使用这个字体。所以我们把它放在类级别,这样每个人都可以使用它。
我们编写了一个私有方法(私有函数)。在 Jack 语言中,我们没有私有和公有的指定,所以对我来说,私有函数是我不向外界公开的函数。外界甚至不知道这个函数存在,但我需要它,并且我需要它来一次性创建这些位图。
我首先定义数组 characterMaps。然后我使用一个方法,另一个名为 create 的私有函数,它实际上决定了为这些字符中的每一个需要写入的数字。
看一下代码块中的第一条语句,字母 ‘a’。你会注意到 create 函数的第一个参数是 97,这恰好是小写字母 ‘a’ 的 ASCII 码。然后我们有 11 个数字,这些数字决定了在位图的每一行需要绘制哪些数字。如果你看字母 ‘a’ 的图像,你会看到顶部三行是空的,确实它以三个零开始。下一行,字母 ‘a’ 的顶部,看起来像 0,1,1,1,然后是一些零。我们必须将其反转。所以实际上我们这里有的是 1,1,1,0,而 1,1,1,0 我认为是 14,确实我们下一个参数就是 14,依此类推。
这就是我们定义 ‘A’ 位图的方式,然后我们定义 ‘B’、‘C’ 的位图,以及字母表中的所有其他字母,然后我们处理数字,然后处理所有特殊字符。就是这样。如果你下载了 Nand2Tetris 软件套件并查看代码,你会看到我们实际上努力创建了这个字体。
剩下需要描述的是我们在这里使用的 create 函数。这就是 create 函数。它接受 12 个参数。第一个参数是数组的索引,顺便说一下,这个索引也恰好是 ASCII 码。然后是 11 个数字。
这个函数定义了另一个名为 map 的数组,这是实际字母的数组。之前的数组是保存所有位图的数组。这是一个特定的位图,所以我们定义一个名为 map 的数组,创建时有 11 个条目,对应字符帧中的每个数字。然后我们设置 charMaps[index] 为 map。本质上,我们正在实现一个二维数组。charMaps 是保存所有位图的静态数组。然后我们用实际的数字 A、B、C、D 等填充我们的小 map 数组,然后返回。
我们对字符集中的每个字符执行此操作 127 次。好消息是,我们只做一次。我们不必每次绘制字符时都这样做。当我们打开计算机,当我们重置时,我们也将重置操作系统,当操作系统重置时,它也会重置这个 Output 类,而 Output 类将运行一个 init 方法来创建这个字体。一旦这个字体被创建,我们就可以开始使用它,通过一些我还没有向你展示的方法,这些方法在你的课程材料中有详细记录。
光标管理
在处理文本时,我们必须担心的另一件事是所谓的“光标”。如果你看这里的例子,光标就是这个红色矩形显示的地方。我的意思是,当你在屏幕上看到这个输出时,实际上看不到红色矩形,我只是在幻灯片中使用它来向你展示我在说什么。所以光标是这个帧,11 像素深,8 像素宽,显示下一个字符将被写入的位置,如果以及当被要求写入下一个字符时。
光标这个概念既有逻辑上的表现,也有物理上的表现。首先,逻辑概念是我真的必须知道光标在哪里。我作为开发 Output 类的人,因为如果要求我写入一个字符,我必须知道把它放在屏幕上的什么位置,所以我必须做一些内部记录来记住光标的位置。显然,每次我绘制一个新字符时,光标的位置都会受到影响。
此外,我可能想友好地向用户显示光标在屏幕上的位置,以便她或他知道当她触摸键盘时会发生什么。当她触摸键盘时,她想知道在屏幕上的哪个位置会看到她将要触摸的下一个字母或按键。
所以,我可能想创建一些闪烁效果,或者可能只想放一个下划线或一个小箭头,这是一个设计决策,完全独立于光标逻辑状态的概念。我认为在我们的 Hack Jack 平台中,我们决定根本不显示光标。所以用户需要自己弄清楚光标在哪里。但我可能记错了,我不记得确切的细节了。这并不太重要。
那么,我们应该如何管理光标呢?我现在谈论的是逻辑光标。如果要求显示换行符,那么我们必须将光标移动到输出的下一行。如果要求显示退格符,我们必须将光标向左移动一列。如果要求显示任何其他字符,那么我们必须显示该字符并将光标向右移动一列。这些就是游戏规则。
Output 类 API
考虑到所有这些,我们现在可以仔细看看 Output 类。我们只会在 API 层面进行。
首先,我们有 init 方法。init 方法是将会调用我之前看到的创建字体的函数的方法。这只会做一次。
然后我们有几个函数,在我们想要使用文本的某些情况下(虽然不是每次)会为我们服务。我们有一个 moveCursor 函数,可以将光标放在屏幕上的任何位置。当我说 I J 时,我指的是光标将要到达的位置。
然后我们有一个 printChar 方法,它知道如何显示单个字符并相应地移动光标。我们有一个 printString,可以打印任何合理长度的字符串。我们有一个 printInt,它与 printString 非常相似,但它知道如何清晰地打印整数数字。
我们还有一个 printLine,基本上将光标移动到下一行。我们有一个我之前描述过的 backSpace。这些就是 Output 类的函数。鉴于我给你的所有信息,并且鉴于我们已经为你实现了 Hack 字体,所以你不必为此烦恼,因此实现这些函数肯定是你自己可以搞定的。
总结

本节课中我们一起学习了计算机如何处理文本输出。我们介绍了屏幕的图形和文本两种模式,重点探讨了在文本模式下,如何使用 Output 类在屏幕上绘制字符。我们了解了字符集、Hack 字体的结构及其实现方式,包括如何通过位图定义每个字符。我们还讨论了光标在逻辑和物理上的概念及其管理规则。最后,我们预览了 Output 类的主要 API 方法,为接下来的键盘输入处理学习做好了准备。
075:输入处理 🖥️⌨️

在本单元中,我们将探讨操作系统的输入能力,特别是介绍一个名为 Keyboard 的类,它负责管理计算机与物理键盘之间的所有交互。
键盘内存映射
上一节我们介绍了操作系统的基本架构,本节中我们来看看输入设备是如何在HEC平台上处理的。这曾在Nand2Tetris项目的第一部分以及本课程的模块0中提及。
按照惯例,我们在RAM中分配特定区域来代表屏幕。同样,我们在RAM中分配一个16位的寄存器来代表键盘。这个16位的表示足以代表Unicode字符集中的任何可能字符,因此足以处理键盘输入。这被称为键盘内存映射,它本质上是一个单独的RAM寄存器。
下图展示了这个概念:

右侧是一个键盘,与你当前使用的键盘大致相同。左侧是我们之前讨论的那个单独的RAM寄存器,按照惯例我们也称之为“键盘”。
以下是硬件实现的约定:当你在物理键盘上按下某个键时,键盘寄存器会立即、瞬时地显示该按键的扫描码(或Unicode/ASCII码)的二进制值。在HEC计算机可识别的字符子集内,这些编码是相同的。当你松开手指,没有按键被按下时,键盘寄存器被重置为0。因此,键盘寄存器通常为0,因为大多数时间无人触碰键盘。在我们触碰键盘的少数情况下,该寄存器会反映出一个非零值。
这个约定由硬件实现,我们无需担心。然而,我们将在操作系统的 Keyboard 类中构建的所有功能,都将建立在这个简单的基础之上。
工作原理示例
让我举例说明其工作原理。假设我按下 K 键。只要我的手指按住 K 键,我将在键盘寄存器中看到代表 K 的扫描码值,在ASCII中恰好是 75。一旦我抬起手指,键盘未被触碰,我将看到默认值 0。
再次按下代表数字 4 的键。数字 4 的ASCII码是 52,这将是键盘寄存器中的值。抬起手指,看到 0。按下空格键,只要按住,看到 32(代表空格的ASCII值)。抬起手指,看到 0。按下上箭头键,其ASCII码是 131,将在键盘寄存器中看到这个数字。抬起后,再次看到 0。你应该明白了。
HEC字符集
了解了字符的概念后,我们继续讨论 Keyboard 类。以下是HEC计算机识别的完整字符集。这个表格被分成几列以便阅读。左列是字符的约定名称,右列是代表该字符的整数值代码。所有这些字符共同构成了HEC Jack字符集。
Keyboard类API
我们选择基于四个子程序来构建这个类。以下是它们的简要介绍,我们随后将深入探讨并仔细实现每一个。
keyPressed: 设计用于捕获键盘按键按下的事件。readChar: 设计用于从键盘读取一个字符的事件。readLine: 读取一个字符串,直到遇到回车符。readInt: 功能类似,但将读取的内容解释为整数。
这些共同构成了我们键盘适配器或 Keyboard 类的应用程序接口(API)。现在,让我们开始实现这个类。
实现 keyPressed
keyPressed 的实现相当简单。如果键盘有键被按下,则返回该键的扫描码,否则返回 0。你或许能猜到如何实现它,我们稍后在讨论实现说明时会详细说明。这是操作系统中最基本的输入函数。
请注意,这是一种实时监听函数。换句话说,当你调用它时,你得到的是调用该方法那一瞬间键盘上当前被按下的键。因此,它操作在“一瞬间”。你调用这个方法,它返回一个值,仅此而已。这是你能想到的最实时的函数。
实现 readChar
readChar 则完全不同。readChar 函数具有持久性;它返回用户最后按下的键。这与 keyPressed 有很大区别,我来解释原因。
keyPressed 是实时操作。然而,假设我写了一个程序,在屏幕上显示“请按任意键”的字符串,我告诉用户按一个键。现在,我必须等待并查看用户会做什么。这就是 readChar 的功能。
首先,用户可能不会立即配合。他们看到提示后可能决定去喝杯咖啡。十分钟后他们回来,还没有按键,然后突然按下一个键。我有些夸张,但即使是真实的按键,一次按键也可能需要几毫秒,不同用户的反应时间不同。所以,在某个时刻,用户会按下键。其次,我们也不知道用户愿意将手指放在键盘上多久,可能几秒钟,也可能立即抬起。
因此,我们看到 readChar 必须克服或处理两种不确定性:用户响应并按下键需要多长时间,以及用户愿意抬起手指需要多长时间。我们如何应对?我们必须消除这些不确定性。
首先,我们显示光标(如果希望用户体验良好),向用户展示他们按键的反馈将出现在屏幕的哪个位置。然后,我们必须等待直到有键被按下。最自然的方法是使用这个看起来有些奇怪的 while 循环:
while (keyPressed() == 0) {
// 什么也不做
}
这个循环的所有动作实际上都在条件判断中。这里的条件非常活跃,因为它为了其效果而调用了 keyPressed 函数,并且在一个可能是无限的循环中调用(如果用户不按任何键)。因此,这个循环本质上相当于对键盘进行采样。我们持续采样键盘,直到有事情发生。当有事情发生时,keyPressed 将不再为 0,于是我们跳出这个 while 循环。
然后,我们终于可以捕获用户按下的键,将其存入一个变量(我称之为 c)。现在记住,手指是按下的,我们不知道会按多久。因此,我们将等待直到键被释放,使用一个非常相似的循环:
while (keyPressed() != 0) {
// 什么也不做
}
只要手指按下,我们就什么也不做。一旦手指抬起,keyPressed 将再次变为 0。然后,我们最终可以给用户一些关于他/她按了什么的反馈,并将 c 返回给调用者。
我希望你能够理解 readChar 和 keyPressed 之间的区别,并明白我们需要 readChar 来克服这些我们无法控制的(程序层面的)不确定性。
实现 readLine
接下来要讨论的方法是读取字符串。回想一下,通常当我们要求用户输入某些内容,如“请输入您的姓名”或“请输入您的年龄”时,我们期望用户输入多个键,而不是一个,比如名字“Peter”或数字“53”等。通常,我们期望用户通过按回车键(有时称为返回键)来表示输入完成。
我们必须编写一些逻辑来处理这种用户行为,这就是操作系统键盘API中 readLine 的功能。
我们开始构建一个字符串(即用户正在输入的字符串),初始化为空。然后,我们将进入一个循环,根据用户提交的击键来增长这个字符串。
首先,我们读取一个字符。注意这个抽象的巧妙之处:我们说 readChar,但我们知道我们已经完全处理了手指按下和抬起的所有过程,这一切都被 readChar 封装了,我们已经处理好了。
然后,我们查看这个字符并检查它是什么。如果字符是换行符(即回车键的ASCII码),那么我们想要显示换行并返回字符串。显示换行不是我们的事,这是由输出类完成的,因此我们必须调用显示换行符的输出方法(你可以查看输出API了解如何操作),这将使光标移动到下一行。到那时,我们知道字符串 str 已经输入完毕,因此我们直接返回它。这就是过程的结束。
另一种可能是退格键。如果用户输入了退格键,那么我们知道最后输入的字符应该被擦除,因此我们从 str 中移除最后一个字符。然后,我们也想确认用户按了退格键,通过调用 output.backSpace() 函数来实现。这个函数(我们在上一单元讨论过)知道如何将光标向后移动一个位置,但这不是我们的事,输出类会为我们处理。
这些是输入字符的两种极端可能性。因此,如果既不是回车也不是退格,那么大概我们有一个真正的字符需要处理,我们取这个字符并将其附加到 str 的末尾。
这就是这里的逻辑。我们持续这样做,注意跳出这个循环的唯一方式是遇到换行符,这正是我们想要的。当这种情况发生时,我们返回 str。这就是从键盘读取行的函数。
实现说明
描述了这些函数的逻辑后,我想给你一些关于如何在HEC平台上实现它们的提示。
从 keyPressed 开始,它可以很容易地使用 Memory.peek 函数实现。如果你记得,我们有一个名为 peek 的函数,允许你获取RAM中任何寄存器的值并返回它。这些寄存器中恰好有一个保存着键盘的当前内容,你可以从这里入手,我相信你知道(或将会知道)如何实现它。
readChar 呢?readChar 只需实现我在上一张幻灯片中展示的伪代码。同样,readLine 你也可以实现其伪代码。
最后是 readInt。readInt 是一个与 readLine 非常相似的函数,只是我们期望用户只输入数字。当我们读取这些数字时,我们从中构建一个整数。同样,这是你应该能够根据我们之前给出的指导方针自己编写的逻辑。
这些是编写 Keyboard 类所需的所有必要实现说明。
总结
在本节课中,我们一起学习了操作系统的输入处理机制。我们首先了解了键盘在内存中的映射原理,即通过一个特定的RAM寄存器来实时反映按键状态。接着,我们探讨了 Keyboard 类的四个核心API函数:用于瞬时检测按键的 keyPressed,用于等待并读取单个字符的 readChar,用于读取整行字符串的 readLine,以及用于读取整数的 readInt。我们详细分析了 readChar 和 readLine 的实现逻辑,它们通过循环等待和状态判断来克服用户输入时间的不确定性。最后,我们给出了在HEC平台上实现这些函数的关键提示。至此,我们已经完成了操作系统中所有核心类的介绍,为后续的字符串处理等主题做好了准备。


076:字符串处理 🧵

在本单元中,我们将学习如何在Jack操作系统中处理字符串。字符串是编程中用于表示文本数据的基本结构。我们将通过一个名为String的类来了解其创建、操作和转换。
概述
Jack操作系统使用一个名为String的类来处理字符串。这个类提供了一系列方法,允许我们创建字符串、获取其长度、访问和修改特定位置的字符,以及在字符串和整数之间进行转换。理解这些操作对于构建更复杂的程序至关重要。
String类API
以下是String类的主要方法,它们构成了我们与字符串交互的接口:
new: 构造函数,用于创建一个具有给定最大长度的新空字符串。dispose: 析构函数,用于销毁字符串对象并回收其内存资源。length: 访问器,返回当前字符串的实际长度。charAt: 返回当前字符串中给定索引位置的字符。setCharAt: 将当前字符串中给定索引位置的字符设置为指定的字符。appendChar: 将指定字符追加到当前字符串的末尾。eraseLastChar: 删除当前字符串的最后一个字符。intValue: 假设当前字符串的字符都是数字,则返回其对应的整数值。setInt: 将给定的整数值转换为字符串表示形式。newLine: 返回换行符的ASCII码值。backSpace: 返回退格符的ASCII码值。doubleQuote: 返回双引号的ASCII码值。
这个API的设计借鉴了其他面向对象语言中字符串类的常见功能,旨在提供直观且强大的字符串操作能力。
客户端视角
为了理解这些方法如何被使用,让我们从客户端程序的角度来看几个例子。
以下是创建和操作字符串的客户端代码示例:
var String s;
var int x;
let s = String.new(6); // 创建一个最大长度为6的空字符串
do s.appendChar(97); // 追加字符‘a’ (ASCII 97)
do s.appendChar(98); // 追加字符‘b’ (ASCII 98)
do s.appendChar(99); // 追加字符‘c’ (ASCII 99)
do s.appendChar(100); // 追加字符‘d’ (ASCII 100)
let x = s.length(); // 此时 x = 4
另一个例子展示了整数与字符串之间的转换:
var String numStr;
let numStr = String.new(5);
do numStr.setInt(314); // 将整数314转换为字符串“314”
let x = numStr.intValue(); // 此时 x = 314
let x = x * 2; // 此时 x = 628,展示了数值计算与字符串的不同
关键点在于区分整数值(如二进制表示的314)和字符串(如由字符‘3’、‘1’、‘4’的ASCII码组成的序列)。
核心算法:整数与字符串互转
setInt和intValue方法是实现中最具挑战性的部分。下面我们来看看它们背后的算法。
整数转字符串算法
这个算法的目标是将一个整数(如123)转换为其字符表示形式(“123”)。
算法步骤(递归实现):
- 获取输入整数的最后一位数字:
lastDigit = value % 10。 - 将该数字转换为对应的ASCII字符。
- 如果
value < 10,则递归结束,返回该字符。 - 否则,将输入整数缩短:
remainingValue = value / 10。 - 递归调用算法处理
remainingValue,并将当前步骤得到的字符追加到递归结果的末尾。
通过递归,我们从最低位到最高位依次处理每一位数字,并在递归返回时拼接成完整的字符串。
字符串转整数算法
这个算法的目标是将一个数字字符串(如“123”)转换回整数值(123)。
算法步骤(迭代实现):
- 初始化结果
result = 0。 - 从左到右遍历字符串中的每个字符。
- 对于每个字符:
- 将其ASCII码转换为对应的整数值
digit(例如,字符‘1’的ASCII码49转换为数字1)。 - 更新结果:
result = result * 10 + digit。
- 将其ASCII码转换为对应的整数值
- 遍历结束后,
result即为最终的整数值。
这个过程模拟了我们将数字字符串“读”成一个整数的过程。
实现要点
了解了算法之后,我们来看看如何在Jack中实现String类。
内部表示:
一个String对象可以包含两个字段:
- 一个数组:用于存储字符串中每个字符的ASCII码值。
- 一个整数:表示字符串当前的实际长度(而非创建时指定的最大长度)。
方法实现思路:
- 构造函数 (
new):根据指定最大长度分配数组,并将长度字段初始化为0。 - 基本操作 (
length,charAt,setCharAt,appendChar,eraseLastChar):通过对内部数组和长度字段进行操作即可实现。 intValue和setInt:分别实现上述的“字符串转整数”和“整数转字符串”算法。newLine,backSpace,doubleQuote:这些是简单的函数,直接返回对应字符的固定ASCII码值(例如128、129、34)。
总结

在本单元中,我们一起学习了Jack操作系统中的字符串处理。我们首先介绍了String类的API,了解了如何创建、修改和查询字符串。接着,我们从客户端代码的角度观察了字符串的实际使用。然后,我们深入探讨了setInt和intValue这两个方法背后的核心算法,即整数与字符串之间的相互转换。最后,我们概述了String类的实现要点,包括其内部数据表示和各个方法的实现思路。掌握字符串处理是进行更复杂编程的基础。下一单元,我们将探讨数组处理的实现。
077:数组处理 🧮

在本节课中,我们将学习操作系统中的数组处理。更准确地说,我们将探讨数组在内存中的表示方式,以及操作系统如何创建和销毁数组。我们将重点关注数组类的实现,其API非常简单,仅包含两个子程序。
数组类概述
上一节我们介绍了操作系统的基本结构,本节中我们来看看数组类。数组类的API设计得非常简洁,它只包含两个核心子程序。
以下是数组类的两个主要功能:
new:创建一个指定大小的新数组。dispose:销毁调用该方法的数组。
从客户端视角看数组操作
为了更好地理解这些功能,我们先从一个客户端的角度来观察。客户端代码会创建数组并进行操作。
以下是一个典型的客户端代码示例:
// 创建两个数组引用
Array a, b;
// 构造一个大小为3的数组,并让a指向它
let a = Array.new(3);
let a[2] = 77;
// 构造另一个数组,并让b指向它
let b = Array.new(1);
let b[1] = a[2] - 100;
// 在某个时刻,销毁数组a
a.dispose();
这段代码展示了数组的创建、赋值和销毁过程。值得注意的是,像 b[1] = a[2] - 100 这样的数组元素操作,其处理权在于编译器,而非操作系统。我们之前已经用整个单元讨论了编译器如何生成此类操作的代码。
因此,操作系统(至少在Jack语言中)只需要关心两件事:创建数组和销毁数组。这主要涉及数组在内存中的表示。
实现细节:为何使用函数而非构造器
现在,让我们深入探讨如何实现这些功能。首先,请注意 new 子程序被设计为一个函数,而不是一个构造器。这是有原因的。
如果 new 是一个构造器,那么编译器在编译这个类时,会去查询该类的符号表,以确定需要为 Array 类型的对象分配多少内存空间(即计算其字段的总大小)。然而,数组对象本身并没有字段。因此,使用构造器会导致不必要的符号表访问和处理,甚至可能引发问题。
为了避免这种情况,我们选择使用一个普通的函数。当编译器编译一个函数时,它不会去查询符号表或做任何特殊处理。但我们必须记住,由于这不是构造器,系统不会自动为我们调用内存分配器。因此,我们需要在函数中显式地调用 Memory.alloc。
new 函数的实现非常简单,甚至有些微不足道。它的核心就是调用 Memory.alloc 来申请一块指定大小的连续内存空间,然后将这块新内存区域的首地址返回给调用者。之后,调用者(通常是编译器生成的代码)就可以开始向这个数组中填充值了。
实现细节:销毁数组
至于销毁数组的 dispose 函数,其实现同样直接。它也是一个非常简单的函数,主要任务就是调用内存管理模块中的 Memory.deAlloc 方法。Memory.deAlloc 会施展它的“魔法”,回收被该数组占用的内存区域,以便后续重新利用。
总结与过渡
本节课中,我们一起学习了操作系统中的数组处理。我们了解到,数组类的核心功能是管理数组在内存中的生命周期——通过 new 函数创建内存空间,并通过 dispose 函数回收内存空间。具体的数组元素访问和运算则由编译器负责。
至此,关于数组类的讨论就结束了。我们的操作系统构建之旅也接近尾声,只剩下最后一个类需要完成。

在下一单元,我们将讨论 Sys类,完成整个J操作系统的设计。
078:Sys类 🖥️

在本节课中,我们将学习如何实现操作系统的最后一个类——Sys类。Sys类包含几个关键方法,它们负责系统的初始化、控制以及错误处理,是连接硬件、虚拟机、操作系统和用户应用程序的桥梁。
系统初始化与引导程序 🔄
上一节我们介绍了操作系统模块的整体结构,本节中我们来看看Sys类的核心功能。首先,我们需要理解“引导程序”的概念。引导程序是指在计算机通电或重置后,将基本软件(尤其是操作系统)加载到内存中的过程。随后,操作系统负责按需加载其他软件。
在Jack平台上,实现引导需要满足四个不同层面的约定:
- 硬件约定:计算机重置时,执行总是从ROM地址0处的指令开始。
- 虚拟机约定:VM翻译器在翻译程序时,必须在ROM顶部(地址0)放置两条机器语言指令:
SP=256和call Sys.init。 - Jack语言约定:每个Jack应用程序必须包含一个名为
Main的类,且该类中必须有一个名为main的方法,这是应用程序的入口点。 - 操作系统约定:操作系统必须提供一个
Sys.init函数。
当所有这些约定都被满足时,引导过程得以完成:硬件从ROM 0启动,执行VM设置指令并调用Sys.init。Sys.init函数随后初始化操作系统,并调用Main.main来启动用户应用程序。
Sys.init 函数详解 ⚙️
Sys.init是Sys类中最关键的函数,尽管它对用户是隐藏的。它的主要职责是初始化操作系统,然后启动用户程序。
以下是Sys.init函数需要完成的两个主要步骤:
- 初始化操作系统组件:遍历所有包含
init函数的OS类(例如Math、Memory),并逐一调用它们的init方法。这确保了操作系统各服务在应用程序使用前已准备就绪。 - 启动用户程序:调用
Main.main()方法。从此,控制权便交给了用户编写的高级应用程序。
其他实用方法 🛠️
除了init,Sys类还提供了一些必要的实用方法。
halt 方法
halt方法用于停止计算机的当前运行。一种简单的实现方式是让程序进入一个无限循环,从而制造出计算机已停止的假象。
代码示例:
function void halt() {
while (true) { }
}
wait 方法
wait方法用于实现延时,这在开发交互式程序时非常有用。它接收一个以毫秒为单位的时长参数,并让程序等待相应的时间。
实现wait的关键是使用一个循环,并通过调整循环内的“延迟因子”来控制总耗时。这个因子是平台相关的:在较快的计算机上需要更大的值来减慢循环,在较慢的计算机上则需要较小的值。
实现思路:
- 使用一个循环。
- 在循环体内进行空操作或简单计算以消耗时间。
- 通过实验(例如用秒表或手机计时)校准循环次数,使其符合指定的毫秒数要求。
error 方法
error方法是一个简单的内务处理工具,它接收一个错误代码,并将其清晰地打印到屏幕上。其实现非常直接,此处不再赘述。
总结 📝
本节课中我们一起学习了操作系统的最后一个类——Sys类。我们深入探讨了系统引导的完整链条,它需要硬件、虚拟机、编程语言和操作系统共同遵守约定才能实现。我们详细分析了Sys.init函数如何初始化OS并启动用户程序。此外,我们还了解了halt、wait和error这些实用方法的作用与实现思路。

正如T.S. Eliot的诗句所言:“我们不应停止探索,而所有探索的终点,都将回到起点,并第一次真正认识此地。” 通过这门课程,你们深入探索了计算机与操作系统的内部奥秘。当完成这些探索后,你们将对内存分配、图形绘制、数据转换等概念有全新的、透彻的理解,因为它们不再是神秘的黑箱,而是你们亲手构建过的系统的一部分。在接下来的单元中,我们将亲手实现这一切。
079:构建操作系统 🖥️

在本项目中,我们将从零开始构建一个完整的操作系统。这是课程的最后一个项目,我们将实现一个由八个不同类组成的操作系统,每个类负责特定的功能,如内存管理、屏幕管理和键盘管理等。
概述
操作系统本质上是一组应用程序接口,它定义了操作系统能为应用程序开发者提供的服务。在本项目中,我们不仅会学习这些API,还将亲手实现它们。
操作系统的结构
操作系统由八个不同的类组成,每个类都设计用于提供特定的功能。例如,Screen 类负责屏幕管理,Memory 类负责内存管理等。
以下是操作系统的主要组成部分:
Memory:内存管理Array:数组操作Math:数学运算String:字符串处理Output:输出控制Screen:屏幕绘制Keyboard:键盘输入Sys:系统调用
实现策略:逆向工程
上一节我们介绍了操作系统的结构,本节中我们来看看如何实现它。我们将采用一种称为“逆向工程”的开发策略。
逆向工程是一种重要的软件开发技术。假设我们要实现一个现有的操作系统,它由多个可执行模块组成,但我们没有源代码。我们可以采取以下步骤:
- 专注于实现其中一个模块。
- 在开发过程中,调用其他模块的功能时,使用系统提供的可执行版本来支持。
- 逐个模块地重复此过程,最终完成整个系统的开发。
这正是像GNU和Linux这样的操作系统最初的开发方式。开发者从一个已知的可执行系统(Unix)开始,逐步替换并实现自己的版本。
以Screen类为例
让我们以 Screen 类为例,具体说明开发流程。Screen 类是操作系统的八个类之一。
开发步骤
我们将获得一个名为 Screen.jack 的存根文件。这个文件包含了 Screen 类中所有子程序(包括对应用程序开发者不可见的内部子程序)的签名。我们的任务就是使用Jack语言实现这些子程序的具体功能。
// Screen.jack 存根文件示例
class Screen {
function void init() { ... }
function void clearScreen() { ... }
function void drawPixel(int x, int y) { ... }
function void drawLine(int x1, int y1, int x2, int y2) { ... }
// ... 其他方法
}
测试方法
为了测试我们实现的 Screen 类,我们将使用一个名为 Main.jack 的测试文件。这个文件会调用 Screen 类中的各种方法。
测试流程如下:
- 将我们编写的
Screen.jack和提供的Main.jack放在同一个目录。 - 使用Jack编译器编译该目录,生成对应的VM文件(
Screen.vm和Main.vm)。 - 在VM模拟器中运行这个目录。
VM模拟器内置了操作系统的Java实现。但是,如果它发现了用户实现的VM函数(例如我们编写的 drawRectangle 或 drawLine),它会优先使用我们的实现。这样,我们就为逆向工程创造了理想的环境:我们的代码会运行,而它调用的其他OS服务则由内置版本提供。
运行测试后,如果我们的实现正确,屏幕上会显示一幅简单的图画,证明 Screen 类工作正常。
完成所有类
对于操作系统中的八个类,其中五个类(如 Screen)将完全采用上述技术进行开发和测试:提供存根文件和测试文件。
其余三个类的测试方式略有不同,会使用Jack测试脚本和比较文件。所有具体细节都可在课程网站的Project 12页面找到。
这些类可以按任意顺序开发,因为在VM模拟器中,你总是可以使用其他七个类的内置实现来支持你正在开发的那个类。
最终测试
完成所有八个类的实现后,需要进行最终测试。这个测试将在一个实际应用程序(例如“Pong”游戏)的上下文中,运行我们开发的操作系统。
测试步骤在项目说明中有详细描述。一旦通过了这个测试,我们就可以认为你的操作系统已经成功构建,并满意地完成了项目12。
总结

本节课中我们一起学习了如何从零开始构建一个操作系统。我们了解了操作系统的API结构,掌握了通过逆向工程策略逐步实现各个模块的方法,并以 Screen 类为例详细说明了开发、编译和测试的完整流程。最终,我们通过在一个完整应用程序中运行来验证整个操作系统的功能。
080:视角 🧭

在本节课中,我们将回顾并总结我们构建的J操作系统,并将其与真实的工业级操作系统进行比较,探讨其设计理念、功能、效率以及未来展望。
概述
我们已经完成了操作系统的构建,这是Nand2Tetris项目最后一个模块的最后一个单元。本节将从宏观视角审视我们的J操作系统,分析它与常规操作系统的核心差异,并评估其设计效率与意义。
J操作系统与常规操作系统的差异
上一节我们完成了操作系统的构建,本节中我们来看看J操作系统与真实世界中的操作系统有何主要区别。
J操作系统提供的功能与工业级系统相比非常有限。例如,我们的操作系统既不支持多线程,也不支持多进程处理。而典型操作系统的核心正是为了处理这些任务而设计的。
同时,我们巧妙地避免了构建文件系统。这得益于Hack平台没有磁盘这一简单事实。显然,如果需要管理海量存储,文件系统是一个绝对必要的抽象层,而操作系统通常支持这种抽象。
以下是其他一些我们未支持的功能:
- 用户通常期望通过命令行界面或窗口系统与计算机交互,而我们的操作系统不包含这些功能。
- 我们还不支持许多其他服务,例如安全性和通信功能。
尽管如此,我们的操作系统仍然弥合了底层硬件与应用程序之间的重要鸿沟。它向应用程序员隐藏了大量复杂、技术性和底层的细节。总体而言,它以相当优雅和清晰的方式完成了这项基础工作。从这个角度看,我们的操作系统为任何想了解设计和实现一个简单操作系统所需知识的人,提供了一个非常好的起点。
开放性与权限机制
了解了功能差异后,我们来看看J操作系统为何如此开放,允许程序员进行任何操作。
首先,这完全符合Nand2Tetris的精神,即一切开放,用户应将硬件和软件系统视为开放的实验室。
然而在现实中,大多数计算机的操作系统代码被视为特权代码。访问操作系统服务和功能需要一个比我们系统中简单的函数调用更复杂的权限机制。此外,在大多数系统中,操作系统函数在一种特殊的保护模式下执行,这种模式还为它们分配了额外的硬件资源,例如时钟周期。
但在我们的Hack平台上,用户级代码和操作系统代码在完全相同的执行级别上运行。与真实的计算机系统相比,这几乎是闻所未闻的。但这再次鼓励了实验和参与的精神。
效率评估
接下来,我们来探讨一个非常重要的问题:J操作系统在效率方面表现如何?
我认为与此问题最相关的两个领域是数学运算和图形处理。在这两个领域,我们都做了相当不错的工作。
首先,我们为乘法和除法提供的算法是高效的。我们的实现方式与其他计算机的主要区别在于,在其他计算机中,这些算法通常在硬件中实现,而不是在软件中。然而,两种实现方法都基于完全相同的算法思想和见解。
具体来说,我们介绍的乘法和除法算法的运行时间是 O(n),与 n 成正比,其中 n 是必须处理的比特数。更准确地说,运行时间是 O(n) 次加法操作。由于将两个 n 位二进制数相加也需要 O(n) 次比特操作,因此这些算法中任何一个的总运行时间最终都是 n² 或 O(n²) 次比特操作。事实证明,O(n²) 的效率并不差。当然,存在运行时间渐近快于 O(n²) 的乘除法算法,但这些算法只有在需要乘除非常大的数字时才开始显现优势,而在我们的案例中,这并不是真正的问题。
那么图形处理呢?我们提供的画线算法相当高效。然而在大多数系统中,这些图形基元通常由软件和特殊的图形加速硬件组合实现。这些就是所谓的GPU,它们的作用是将中央处理器从执行复杂的高性能图形任务(如绘制3D图像和渲染平滑曲面,这些需要大量计算能力)中解放出来。
在Hack平台中,CPU和其他专用处理器之间没有这种责任分离,所有工作都由CPU独自完成。
总结与展望
总而言之,操作系统是一个巨大的研究和实践领域,尤其是在计算日益分布式、开放化、可移植化,同时面临多种风险的世界中。因此,我们可以预期操作系统的技术水平将出现显著的进步。我衷心希望你们中的一些人将在这些发展中扮演重要角色。

在本节课中,我们一起学习了J操作系统与工业级系统的核心差异,理解了其开放设计的理念,评估了其在数学运算和图形处理方面的效率,并展望了操作系统领域的未来发展方向。
081:更多探索方向 🚀

在本单元中,我们将回顾已构建完成的计算机系统,并探讨如何对其进行优化与扩展。我们将学习优秀设计的重要性,并审视一系列可能的改进方向,包括硬件加速、指令集扩展以及编译器优化等。
课程概述
恭喜你,我们已经从零开始构建完成了一套现代通用计算机系统,包括硬件和软件。既然任务已经完成,我们可以回顾并问自己:如何才能让这台计算机变得更好?如何让它更快、更有用、更通用?这正是我们将在本模块(模块7)中要做的事情。
我们将提出几个扩展和优化的想法,并且我敢打赌你们也能想出类似的想法。这些想法中的每一个都可以成为一个后续的 Nand2Tetris 扩展项目的基础。
优秀设计的重要性
为了开展每一个这样的项目,你必须首先从所谓的“设计”开始。一个好的设计是每一个成功的硬件和软件实现项目的关键。
到目前为止,在本课程中,Nand2Tetris 的设计者是 Noam 和我。我们提供了所有的设计文档,包括所有的 API、标准实现契约、测试程序等。你们扮演了系统开发者的角色,根据我们的设计进行工作。Noam 和我确实非常努力地设计出了架构良好、API 清晰的方案。我们这样做是因为我们相信,实现设计良好的系统是学习(或至少开始学习)如何成为一名系统设计师(也称为系统架构师)的最佳方式。这就是我们要求你们实现所有这些设计的原因。如果你想成为一名诗人,你最好从阅读一些好诗开始。
那么,是什么让一个好的设计变得优秀呢?我们可以从显而易见的东西开始:模块化、简洁性、优雅性、清晰性、美观性。在课程中,我们并没有明确地讨论所有这些事情,因为空谈无益。相反,我们要求你们实现我们的设计,并希望通过这样做,你们能对什么是模块化、简洁、优雅、清晰和美观建立起一种直观的欣赏。
一旦你把这些都做好了,那么下一个设计目标几乎会自动实现。这个目标,可能是任何硬件和软件项目或系统最重要的优点,就是能够以最小的麻烦来修改和扩展系统。这一点非常相关,因为这就是我们想在本模块中做的事情:讨论如何优化和扩展 Hack 系统。令人欣慰的是,我们即将修改的这个系统设计良好。这令人欣慰,因为设计良好的系统自然就适合进行此类修改。因此,我们将能够以一种可管理和可预测的方式完成所有事情。
优化方向探索
上一节我们讨论了优秀设计是系统可扩展性的基础。本节中,我们来看看一些具体的优化和扩展方向。
硬件加速:乘法与除法
目前,Hack 的 ALU 只能进行加法和减法。因此,即使是乘以 2 这样的简单操作也需要调用操作系统函数,这非常低效。当你考虑如何在各种可能的优化项目之间分配精力时,你应该始终考虑影响,即如何获得最大的效益。乘法和除法在计算机程序中频繁发生,因此显然值得尝试优化它们。
让我们专注于如何优化乘法,因为除法的情况非常相似。在单元 6.2(以及书中第12章),我们介绍了一种高效的按位乘法算法。我们当时用 Jack 语言实现了这个算法。现在,如果你想加速乘法,我们可以采用完全相同的算法,但不是将其作为用 Jack 编写的操作系统函数来实现,而是作为一个用 HDL 编写或指定的硬件芯片来实现。
一旦你有了这样一个经过充分测试的芯片,你就可以将其集成到 Hack 的 ALU 中。但这还需要扩展 Hack 的指令集,因为你必须决定一些二进制乘法操作码,否则你将无法告诉 ALU 进行乘法运算。如果你扩展了二进制机器语言,那么你也必须扩展汇编语言,需要想出一些符号助记符来表示乘法。这反过来又要求你修改我们在课程第一部分构建的汇编器。你看,这就是设计的全部内容。你必须决定要做什么,然后你必须实际去做。
因此,将乘法重构到硬件中,可以说,需要进行一次非常漂亮的跨层“手术”,因为它贯穿并影响了我们计算机架构的多个层次。由于这些层次高度模块化且规范明确,每一个修改都可以单独进行和单元测试。
硬件加速:位移操作
我们的 ALU 中缺少的另一个功能是位移操作。位移可以真正加速诸如乘除法等操作,以及处理屏幕上像素的低级图形操作。同样地,如果你想在硬件中实现位移,诀窍在于设计新的芯片和新的指令,以在硬件级别执行左移和右移操作。
一旦我们拥有了这些硬件能力,我们就应该考虑修改我们的翻译器(编译器/VM翻译器)来巧妙地利用它们。例如,当编译器需要编写代码来乘以或除以 2 的幂次方数时,它可以通过生成使用位移操作而不是标准乘法的代码来实现。你看,这就是人们谈论优化编译器时的一个例子。
顺便说一下,在本模块中,当我们说“编译器”时,我们指的是编译器和 VM 翻译器两者。因此,你可能需要修改编译器或 VM 翻译器,或者两者都修改。在任何一个优化项目中,这都是你需要自己弄清楚的事情。
优化 VM 翻译器
VM 翻译器实际上可以通过许多不同的方式进行优化。因为目前它只是简单地将命令从 VM 代码翻译成汇编代码,而没有尝试生成紧凑的代码。因此,即使是最简单的操作,如加 1 或减 1,也会生成好几条汇编指令,而实际上可以用更少的指令完成。所以,这可能是另一个优化项目的例子。
扩展新功能
以上我们探讨了优化现有功能的方向。那么,关于创建新功能呢?例如,为我们的计算机添加大容量存储和网络访问功能如何?这将是我们在下一单元讨论的内容。
总结

在本节课中,我们一起学习了在完成基础计算机构建后,如何思考系统的优化与扩展。我们认识到优秀的设计是实现可扩展性的关键。我们探讨了多个具体的优化方向,包括将乘除法、位移操作等关键功能从软件迁移到硬件实现,以及随之而来的指令集、汇编器和编译器/VM翻译器的协同修改。我们还提到了优化 VM 翻译器代码生成效率的可能性。最后,我们预告了下一单元将探讨为系统添加全新功能(如存储和网络)的设想。这些探索方向为你将所学知识应用于实际项目提供了清晰的路径。
082:更多探索方向 🚀

在本节中,我们将探讨如何为计算机添加大容量存储和网络访问功能,并了解实现这些扩展所需的具体步骤和规划方法。
概述
我们将讨论如何将硬盘和网络接口卡等设备集成到计算机架构中。这涉及硬件修改、操作系统开发以及新芯片的设计与集成。
添加大容量存储 💾
上一节我们介绍了计算机的基本架构,本节中我们来看看如何为其添加大容量存储功能。
我们可以假设已经拥有一个内置的硬盘芯片,例如一个能够持久存储大量比特的闪存单元。利用这个新功能,一个自然的应用是保存和加载Hack程序。
任何此类新功能的添加都需要修改整体架构中的多个层面。
以下是需要修改的主要方面:
- 修改Hack硬件:允许程序驻留在可写的内存空间,而非当前只读的内存区域。
- 指定简单的文件系统:开发一个操作系统类,实现如
加载文件和存储文件等抽象操作。 - 引入命令行界面:此时,引入某种命令外壳或终端窗口将使用户能够访问和操作他们的文件。
正如所见,一项改动往往会引发一系列新的需求。
连接至互联网 🌐
现在,让我们看看如何将Hack计算机连接到互联网。
与处理磁盘类似,我们假设拥有一个内置的网络接口芯片,可以将其集成到硬件模拟器中。这个芯片的设计应使其一端与Hack硬件交互,另一端与您实际PC上的某个套接字交互。
这与当前已实现的键盘芯片的运作方式非常相似,因为键盘芯片与您PC上的实际物理键盘交互。网络接口卡也需要能够处理来自互联网(而非键盘)的比特流。
为了管理这个新设备,我们需要编写一个新的操作系统类,它知道如何使用某种标准通信协议在不同位置之间移动比特。
一旦具备了这些能力,我们就可以开发各种与这个芯片通信的Jack程序,作为通往互联网的网关。例如,使用Jack开发一个基于HTTP的简单网页浏览器会非常酷。
设计与集成内置芯片 🔧
我们讨论的所有扩展,其核心都是将外围设备添加到基础硬件中。所有这些扩展都要求首先设计和集成一个新的内置芯片,就像我们刚才讨论的磁盘和网络接口芯片一样。
那么,如何构建这样的内置芯片呢?
在开发Nand2Tetris模拟器时,我们设计了一个简单且文档完善的接口,用于添加所谓的“内置芯片”。得益于这个接口,模拟的Hack硬件平台是开放且可扩展的。
如果您想构建一个新的内置芯片(例如硬盘,或者如果您对音乐感兴趣,可以构建一个扬声器),那么您应该首先从指定和开发一个独立的Java类开始,该类实现您希望在这个新设备中使用的底层功能。
如果您想了解更多关于如何开发此类Java芯片的信息,可以从Nand2Tetris网站下载模拟器软件并阅读相关文档。我们的代码是开源的(纯Java),欢迎您按需修改和扩展。
项目规划与实施 📝
有一点不言而喻,但我还是要强调一下:我们讨论的任何优化和扩展项目都需要非常仔细、务实和现实的规划。
以下是实施步骤:
- 明确功能目标:首先清晰阐述您希望通过这个新设备或新扩展交付的确切功能。目标不要定得太高,专注于绝对必要的部分,忽略其余。
- 识别需修改的子系统:确定现有架构中(包括硬件和软件)所有需要修改的子系统与API。
- 指定新API并编写测试:指定您将开发的新功能所需的API,并编写一些存根程序来测试这些新功能。
- 开始实际实现:只有在完成所有这些准备工作之后,才应开始着手交付实际实现。
同样需要强调的是,在实现大容量存储、网络接口或任何其他您感兴趣的设备的简化版本之前,您必须先在互联网上阅读相关资料,然后再次确定您的项目绝对需要的最小功能子集,否则您可能会迷失方向。
分享与展望 🤝
最后,我想谈谈分享。如果您构思了一个很棒的项目,并希望与其他学习者分享,我们很乐意知晓。您不一定要与世界分享所有细节,可以只描述您的项目想法、通用方法、规范或任何您想分享的内容,这样其他人就可以从您的设计中受益并构建这个项目,就像我们要求您在本课程中构建我们的设计一样。
在下一个单元,我们将讨论更多在Jack语言层面的扩展以及高级语言的一般概念。我们还将讨论Nand2Tetris课程论坛中经常出现的一个话题:如何物理地构建Hack计算机(或任何其他计算机),而不仅仅是通过模拟。我们将使用一种名为FPGA的卓越硬件技术来实现这一点。
总结

本节课中,我们一起学习了如何为计算机架构添加大容量存储和网络功能。我们探讨了从硬件修改、操作系统开发到内置芯片设计与集成的完整流程,并强调了务实规划和分步实施的重要性。这些扩展为计算机打开了通往更广阔应用世界的大门。
083:更多探索方向 🚀


在本单元中,我们将继续探讨如何优化和扩展Hack计算机的硬件与软件,并介绍一些可供深入探索的实践方向。
上一节我们讨论了Hack计算机软硬件的一些优化思路。本节中,我们来看看更多具体的、可供探索的扩展与实现方案。
软件层面的扩展:Jack语言 🖥️
首先,我们可以从Jack高级语言入手进行改进。在课程第3模块的“视角”单元中,我们曾讨论过Jack语言的一些潜在优化方向。
以下是几个可以改进Jack语言的方面:
- 语法优化:使其对开发者更友好。
- 引入新命令:例如添加
for循环和switch选择语句。 - 扩展类型系统:增强语言的数据类型支持。
- 添加继承机制:为面向对象编程提供更强大的支持。
实现这些改进需要完成一系列修改。首先,必须修改语言的语法规范,例如在文法中定义 for 和 switch 等新命令。随后,需要相应地修改编译器的语法分析器和代码生成器,以处理这些新特性并生成实现其功能的机器码。
这些修改的复杂度各不相同。例如,添加 for 和 switch 语句相对简单。而实现真正的继承机制则更具挑战性,它不仅要求修改编译器,还需要修改底层的虚拟机。
另一个非常有趣且富有挑战性的方向是,实现一门全新的高级语言。这可以是一门现有语言,也可以是你自己设计的新语言。例如,开发一个Scheme语言的解释器,就是一个非常棒的项目。
硬件层面的实现:从虚拟到物理 🔧
关于软件扩展的讨论就到这里,让我们回到底层的硬件。许多学习者常问的一个问题是:如何构建Hack计算机的物理实体?将Hack计算机真正“刻入硅片”需要做些什么?
实现物理构建主要有两种基本途径。
第一种相对“简单”的路径是,在现有设备上模拟Hack/Jack平台。例如,你可以在手机或个人电脑上模拟我们的虚拟机。这需要编写一个VM翻译器,将VM程序翻译成宿主设备(如手机或PC处理器)的指令集。
第二种更“硬核”的途径是,使用真实的硬件从头开始构建一切。要实现这个目标,你需要完成以下三件事:
- 学习硬件描述语言:掌握一种工业级硬件描述语言(如VHDL或Verilog)的子集。
- 重写芯片设计:使用该语言,重写我们在Nand2Tetris课程中编写的所有HDL程序。完成后,你将得到Hack计算机芯片组所需的30多个芯片的VHDL/Verilog实现。
- 部署到可编程硬件平台:将上述HDL程序部署到一个可编程硬件平台上。
例如,现场可编程门阵列(FPGA)板就是这样一种平台。FPGA技术功能强大、应用广泛且价格亲民,一块不错的FPGA板价格远低于100美元。实际上,你甚至可以不花一分钱就在FPGA上构建这台计算机:就像我们在Nand2Tetris中使用基于软件的硬件模拟器一样,你可以在个人电脑上使用免费的、基于软件的FPGA模拟器来完成所有工作,获得的经验同样宝贵。
需要指出的是,上述描述听起来可能过于简单。实际上,FPGA实现项目颇具挑战性。其中一个原因是,它要求你在FPGA中实现所有我们曾视为理所当然的内置设备,例如内存、屏幕和键盘驱动芯片。这些在课程中作为内置芯片提供的部件,都需要在FPGA中自行设计和实现,这既有趣又充满挑战。
好消息是,我们的同事Denny Sidner博士——一位极具天赋的教师和工程师——已经完成了所有这些工作。目前,我们正与Denny合作开发一门新课程,专门教授如何使用真实硬件构建Hack计算机(乃至任何其他计算机)。在未来的某个时刻,我们将推出这门新课程(或许可以称之为Nand2Tetris第三部分),届时你将学习如何用“原子”而非“比特”来构建计算机。
总结 📚
本节课中,我们一起探讨了扩展Hack计算机软件(如改进Jack语言或实现新语言)与硬件(如在FPGA上进行物理实现)的多种可能方向。正如你所见,关于计算机构建的探索远未结束,仍有广阔的空间等待我们去发现和实践。敬请期待未来的更多内容!
084:更多探索方向 🚀

在本节课中,我们将为这门精彩的课程画上一个句号,并探讨课程结束后的学习方向与资源。我们将回顾课程的核心贡献者,并了解如何持续获取最新的项目与工具信息。
课程结束与致谢
朋友们,很遗憾,每一段伟大的旅程都有终点。在此结束之际,我想说两件事。
首先,正如我在上一个单元提到的,关于“Nand to Tetris”项目,我们远未说出最终结论。
因此,如果你想与我们保持联系,并希望了解新课程、酷炫工具、新项目和激动人心的未来活动,欢迎访问Nand to Tetris网站并加入邮件列表。操作非常简单。
幕后功臣:诺姆·尼桑教授
接下来我想谈谈的是我那神秘的联合讲师——诺姆·尼桑教授,他巧妙地避开了本课程的所有讲座。
事实上,当我们开始制作“Nand to Tetris”的在线版本时,我们决定由我——西蒙——负责准备和讲授课程讲座。
但这种单人表演让我处于一个相当不安的境地,因为它可能造成一种错误的印象,即我是“Nand to Tetris”背后的主要人物。
但事实上,你应该知道,本课程中大多数伟大的想法都来自诺姆·尼桑教授杰出的头脑。
诺姆决定在整个课程中保持幕后,这让我想起了一个关于牛顿和莱布尼茨的可爱轶事。这两位伟大的数学家大约300年前分别在德国和英格兰生活和工作。
故事始于莱布尼茨偶然遇到了一个他无法解决的微积分难题。由于他对这个问题毫无头绪,他将其发表在某科学期刊上,并向同行数学家发起挑战,看谁能解决它。他还在脚注中写道,他认为需要大约六个月才能找到解决方案。
但当牛顿在期刊上看到这个问题时,他在晚饭后大约一小时就解决了它。他无法相信莱布尼茨认为这个问题如此困难,因此牛顿怀疑这是某种恶作剧或诡计。他随后将解决方案匿名发送给了莱布尼茨。
现在,让我们把场景从伦敦移到德国汉诺威,想象一下戈特弗里德·威廉·莱布尼茨教授正在处理他的邮件。突然,他找到了对他那个著名的、所谓超级难题的简短而优雅的解决方案,而解决方案的作者并未在任何地方提及。
根据这个故事,莱布尼茨看了一眼解决方案,说道:“我凭爪子就能认出狮子。”换句话说,解决方案上没有名字,但它带有牛顿天才不可磨灭的印记。
现在,你可能会问,这与“Nand to Tetris”有什么关系?在本课程中,我们非常高兴能与大家分享许多优美的架构、算法、API和编程技术。尽管诺姆·尼桑教授在所有这些讲解中身体缺席,但我相信你们都能“凭爪子认出狮子”。
总结与展望
我们希望你们喜欢“Nand to Tetris”课程。我们很感激有机会与大家分享应用计算机科学的美丽与严谨。再次强调,让我们通过“Nand to Tetris”邮件列表保持联系。

就此,再见。
001:引言 🚀

在本节课中,我们将了解《从零开始构建现代计算机》这门课程的整体目标与结构。我们将一起探索如何从零开始,一步步构建出一台功能完整的通用计算机。
大家好,欢迎来到《从零开始构建现代计算机》。我是 Shimon Shoen,我是 IDC Herzliya 的计算机科学教授,也是耶路撒冷希伯来大学的客座教授。
大家好,我是 Norni Sun,耶路撒冷希伯来大学的计算机科学教授,同时也是微软的研究员。我们将共同教授这门课程。
在本单元中,我们首先要做的,是向大家展示这门课程的整体蓝图。
在这门课程中,我们将从零开始,构建一台完整的、通用的、可工作的计算机,包括硬件和软件。
我们将这个宏大的工程分为两门独立的课程。在第一门课程中,我们将构建计算机的硬件部分,这门课程将持续七周。
在后续将提供的第二门课程中,我们将完成整个蓝图,构建运行在你于第一门课程中构建的计算机之上的软件层次结构。
这就是我们在接下来七周里要做的事情:我们将专注于构建计算机的硬件部分,我们称之为 Hack。
这将是一段精彩的旅程,包含 7周、6个项目、1台计算机,并且无需任何预备知识。我们假设你没有任何计算机科学或工程学的基础知识。课程本身将提供你所需的一切。
以上便是我们在《从零开始构建现代计算机》中的第一个单元。接下来,我们将展望前方的道路,更详细地描述我们在这门课程中将要完成的任务。

本节课中,我们一起学习了这门课程的核心目标:从零开始构建一台名为 Hack 的完整计算机。我们了解到,整个学习过程分为硬件和软件两大部分,并且课程设计为无需任何先验知识即可入门。
002:课程路线图 🗺️

在本节课中,我们将要学习本课程的核心思想工具——抽象,并了解我们将如何通过层层构建的方式,从最基础的逻辑门开始,最终完成一台功能完整的计算机。
概述:从“是什么”到“如何做”
我们通常从一门编程课开始学习计算机科学。在第一节课上,你可能会看到一个非常简单的程序,例如打印“Hello World”。你学习它的语法和命令,但程序背后的复杂机制——比如字母如何在屏幕上显示,或者代码如何被计算机理解——通常被隐藏起来,无需你操心。
这种“隐藏”并非教学的缺陷,而是计算机科学最强大的工具:抽象。你只需要关心某个组件做什么(它的接口或规范),而无需关心它如何做(它的实现细节)。这让你能专注于当前的任务,而将底层复杂性交给其他层级处理。
抽象:计算机科学的基石
上一节我们提到了“抽象”这个概念,本节中我们来看看它是如何运作的。
分离关注点
抽象的核心在于分离关注点。一旦我们构建好一个组件(例如一个“蓝色盒子”),我们就可以忘记其内部复杂的实现细节,只记住它简洁的接口(它能做什么)。然后,我们可以基于这个接口去构建更高级的组件(例如一个“绿色盒子”)。
这个过程可以重复多次,形成多层抽象。每一层都相对简单,但层层叠加后,我们就能得到一个极其复杂的系统。
以下是抽象分层构建的示意图:
[ 紫色盒子 ] <- 基于橙色盒子的接口构建
|
[ 橙色盒子 ] <- 基于绿色盒子的接口构建
|
[ 绿色盒子 ] <- 基于蓝色盒子的接口构建
|
[ 蓝色盒子 ] <- 基础实现
课程中的抽象层级
这正是本课程将采用的方法。我们将从非常基础的简单逻辑门开始,逐步构建更复杂的芯片,最终组装成中央处理器(CPU)和完整的计算机。
在课程的第二部分,我们将在这些硬件抽象之上,继续构建更复杂的软件层级。
课程结构:分步构建
基于抽象的思想,本课程的结构设计如下:
- 每周一个抽象层级:每周,我们将专注于实现一个特定的抽象层级。
- 利用已知,构建新知:我们将已完成的底层视为已知且可靠的“黑盒”,只使用其接口,并在此基础上实现新的、更高层级的抽象。
- 测试与验证:每周都会提供测试套件,确保你构建的组件工作正常,为下一周的工作打下坚实基础。
通过这种方式,经过数周的学习,我们将从名为 Nand门 的简单逻辑门开始,最终构建出一台能够运行复杂程序(例如本课程得名的“俄罗斯方块”游戏)的完整计算机。
课程路线图详解
现在,让我们具体看看课程的两个主要部分。
第一部分:硬件构建(7周)
在第一部分,我们将构建计算机的整个硬件平台。
- 起点:从最简单的Nand逻辑门开始。
- 终点:构建出一台能够运行汇编语言程序的计算机。
- 过程:分为七个清晰的步骤,每周完成一个步骤,最终得到一个可工作的硬件平台。
第二部分:软件构建
在第二部分,课程名“从Nand到俄罗斯方块”将得到完整体现。
- 起点:基于第一部分构建好的Hack计算机。
- 目标:在其上逐步添加软件层级,包括编译器、操作系统等。
- 终点:最终我们将能够用高级编程语言(如本课程设计的Jack语言)编写应用程序,并在我们亲手构建的计算机上运行,例如“俄罗斯方块”游戏。
注:本课程的第二部分目前尚未在Coursera平台上线。如果你在完成第一部分后迫不及待想继续,可以参考我们的配套书籍,其中包含了全部内容。
总结
本节课中我们一起学习了计算机科学的核心思想——抽象,以及本课程如何利用多层抽象的分步构建方法。
我们了解到,通过每周专注于一个层级,将已完成的底层视为可靠工具,并在此基础上构建新知,我们就能在14周内,从最微小的Nand门开始,亲手搭建出一台功能完整的现代计算机,从而彻底理解计算机的工作原理,揭开其神秘面纱。

在接下来的两个单元中,我们将更详细地探讨硬件和软件部分的具体路线图。之后,我们将正式启程,开始第一步的构建。
003:03_01_04_单元-0-2-从NAND到Hack 🧱➡️💻

在本节课中,我们将要学习如何从最基础的逻辑门开始,一步步构建出一台名为“Hack”的通用计算机。我们将了解整个课程的结构、使用的工具以及最终的目标。
在上一单元中,Noam 概述了从 Nand 到 Tetris 的整个旅程。在本单元中,我们将聚焦于本课程的核心任务:构建一台名为“Hack”的计算机。
下图以非常概括的方式描绘了这台计算机,它仅包含了其主要组件。它将拥有 ROM、CPU、RAM 以及许多其他芯片。一旦我们构建好这台计算机,我们将把它连接到一个标准键盘和一个显示单元。此时,你就可以开始执行程序,并享受你亲手构建的计算机了。

可以运行哪些程序呢?任何你能想到的程序。你可以编写一个玩《Pong》的程序,或者玩《太空侵略者》、《推箱子》的程序,当然,还有《俄罗斯方块》。这些实际上是之前选修本课程的学生们编写过的程序示例。
这就是本课程的总体蓝图。我基本上是在重复 Noam 之前说过的话。我们从想要编写的程序的大致想法开始。我们编写程序,编译它,进一步将其翻译成机器语言。我们将代码加载到计算机中。计算机将使用我们构建的所有芯片,这些芯片基于基本的逻辑门,而整个系统的底层是硬件本身。
本质上,我们所做的是在一个硬件平台之上构建了一个软件层次结构。正如 Noam 解释的那样,我们决定将这项事业分为两个不同的部分。第一部分称为“从 Nand 到 Tetris 第一部分”,第二部分稍后提供,称为“从 Nand 到 Tetris 第二部分”。在本课程中,我们将只专注于硬件部分,从硬件本身、电子学和逻辑门的最低层级开始,自底向上地进行构建。
因此,我们现在处于应用计算机科学中一切事物的最低层级。这实际上不是计算机科学,而是电气工程、固态物理学等等,涉及许多我和 Noam 都不太了解的知识。因此,我们将对这个硬件进行抽象,转而专注于我们能想到的最基本的逻辑门,它被称为 Nand。
Nand 是一个我可以在 10 秒内描述清楚的东西,我将在后续的某个单元中介绍。但现在,让我们假设它只是一个基本的逻辑门。我们拿这个 Nand 门,并使用一种称为组合逻辑的技术,从中构建出一整套基本的逻辑门,例如 And、Or 等等。
然后,我们将使用这些门,并运用组合逻辑和时序逻辑(这是一种不同的设计技术,需要考虑时间和时钟),从中构建出诸如寄存器、RAM 单元和 CPU 等组件。
接着,我们将利用构建出的这套芯片组,设计出一个完整的计算机体系结构,称为 Hack。
现在,为了能够在这台机器上编写并方便地执行程序,我们还需要引入一个汇编器,并为 Hack 机器语言开发一个汇编器。
我介绍了很多听起来可能非常陌生的概念。不用担心,随着课程的进行,一切都会得到解释。现在,你们中的许多人可能在想,我们究竟要如何实际构建所有这些芯片。
事实证明,如今的硬件工程师并不徒手做任何事情。他们使用计算机来开发计算机。具体来说,他们使用一种称为硬件模拟器的工具来设计、测试和调试他们想要构建的硬件。这也正是你们在本课程中将要做的。
我们在这里看到的是我们的硬件模拟器的截图。这是一个软件,你可以从我们的网站免费下载,然后安装到你的电脑上。你将使用你的电脑和我们提供的软件来完成本课程的所有项目。
让我们举一个例子来说明我们将如何实际操作。这里是一个 Xor 芯片的例子,它是我们将在本课程中构建的大约 30 个不同芯片之一。你在这里看到的是 Xor 如何操作的抽象描述。
本质上,你将接受这个抽象描述,思考它,并结合我们将提供的各种提示和指导,想出一个逻辑图,使你能够使用之前构建的更低层级的门来构建 Xor。
然后,你将采用这个逻辑图,并使用我们将教给你的一种称为硬件描述语言的语言来具体描述它。结果将是一个称为 HDL 程序的东西。你将把这个 HDL 程序与我们提供的一些测试脚本结合起来,然后使用我之前描述的硬件模拟器来调试、测试并完善你的 HDL 程序。
这就是我们为本课程中要构建的每一个芯片所要做的事情。最终的结果将是 Hack 计算机。我们将把这个旅程分为六个不同的项目。
以下是每个项目的简要介绍:
- 项目一:基本逻辑门。在第一周,我们将构建一些基本的逻辑门,总共 15 个,如幻灯片所示。
- 项目二:算术逻辑单元。在第二周,我们将构建一个算术逻辑单元,它是我们稍后构建的 CPU 的核心部件。
- 项目三:内存系统。在第三周,我们将构建内存系统,从寄存器开始,一直到 RAM 和 ROM 单元。
- 项目四:机器语言编程。在第五周我们构建计算机之前,我们将在第四周用 Hack 机器语言编写一些程序,以便了解这台计算机将能做什么。
- 项目五:计算机体系结构。在第五周,我们将利用迄今为止构建的所有芯片组,设计一台实际的计算机。
- 项目六:汇编器。在课程的最后一周,我们将为 Hack 机器语言引入一个汇编器,并以两种不同的方式实际开发它,一种面向有编程背景的人,另一种面向没有编程背景的人。
这些就是你在本课程中将要完成的所有项目。你努力的成果将是一台 Hack 计算机,一台可以运行你想到的任何程序的通用计算机,无论是俄罗斯方块还是其他任何东西。
现在,我相信你们中的许多人想知道,学习这门课程需要具备什么知识。答案是,我们假设你没有任何计算机科学、工程学或数学方面的先验知识。构建计算机和参加本课程所需的所有必要知识都将在课程本身中提供。这是一门自包含的课程。你将在七周激动人心的项目中学到很多东西。
本节课中我们一起学习了从 Nand 门到 Hack 计算机的构建旅程。在下一个单元中,我们将概述从 Hack 计算机到俄罗斯方块的旅程。


004:04_01_05_单元-0-3-从Hack到俄罗斯方块 🎮

在本节课中,我们将了解本课程第一部分的最终目标,并展望第二部分(尚未正式开设)的宏伟蓝图。我们将看到如何从一个简单的逻辑门开始,最终构建出一台能够运行复杂程序(如俄罗斯方块)的完整计算机系统。
课程第一部分回顾 🧱
上一节我们介绍了课程的基本结构。现在,我们来具体看看第一部分的终点。
在课程的第一部分,你将从非常简单的逻辑门开始。正如你所听到的,你将最终构建出一个可以运行任何程序的、可工作的计算机系统。例如,本课程名称中的“俄罗斯方块”就可以在这台计算机上运行。
课程第二部分展望 🔮
那么,第一部分之后还剩下什么?我们将在第二部分做什么呢?
核心问题在于,在我们于本课程中构建完成的计算机上,人们会进行何种编程。在这台计算机上,你也会构建一种可以在其上运行的语言。但这是一种非常低级的汇编语言。如果你查看汇编语言,它看起来并不友好。它肯定不是你在一对一编程入门课程中开始使用的那种语言。它是一种非常低级、非常不方便的语言。
大多数程序员真正希望使用的是高级语言,就像下面这个例子。这是编程入门课程中非常典型的代码。
// 一个典型的高级语言代码示例
class Main {
function void main() {
do Output.printString("Hello World");
do Output.println();
return;
}
}
在这种高级语言中包含了许多特性:丰富的语言结构、循环、数据类型、方法、抽象等等。这些特性在本课程结束时介绍的汇编语言中并不存在。
此外,高级语言还包含许多高级操作。例如,数学运算、输入和输出操作。这些是基本的,也是任何想要编写程序(比如打印“Hello World”并期望它在屏幕上显示)的程序员所期望的。
软件层级的构建 🏗️
事实上,在课程的第二部分,也就是我们处理软件层级的部分,这正是我们要实现的内容。
我们将介绍一种名为 Jack 的高级语言。并且,我们将使用多层抽象为它编写一个编译器。当然,因为构建编译器是一件复杂的事情。同样地,我们还将构建一个标准库,事实上,是一个迷你操作系统,它提供程序员期望的所有高级服务。
一旦你拥有了这两样东西(高级语言和操作系统),那么你基本上就站在了编程入门课程的起点。因为你拥有了一个可以正常使用的高级语言。现在,这有望弥合从计算机构建的基础知识,到作为一名入门计算机程序员所期望获得的编程语言、操作系统和标准库之间的所有鸿沟。
关于第二部分课程 📚
如前所述,Nand to Tetris 的第二部分尚未正式开设。我们计划在未来提供。但如果你等不及,它已经在我们的网站和课程用书中准备就绪。
下一单元预告 🚀
在下一个单元,我们将简要介绍第一个项目,或者实际上是第零个项目。我们只是想确保你已经准备就绪,找到了我们的网站,并且知道如何在 Coursera 上提交练习。一旦我们完成这些,下周我们就将真正开始构建计算机。

本节课总结:本节课我们一起回顾了课程第一部分的终极目标——构建一台能运行“俄罗斯方块”的计算机,并展望了第二部分的核心内容:在硬件之上构建软件层级,包括 Jack 高级语言及其编译器、标准库和迷你操作系统,从而搭建起从硬件到现代软件开发的完整桥梁。
005:布尔逻辑

概述
在本节课中,我们将要学习计算机科学的基础——布尔逻辑。我们将从最抽象的层面开始,了解计算机如何仅使用0和1这两种值,以及如何通过基本的逻辑运算来构建和处理信息。这是理解计算机内部工作原理的第一步。
布尔值:计算机的基石
计算机内部只处理0和1。这是因为使用两种明确的状态最为简单可靠。我们可以用多种方式称呼这对值:开/关、真/假、否/是,或0/1。在本单元中,我们将统一使用0和1。
基本逻辑运算
既然我们只有0和1,我们能对它们做什么呢?答案是进行逻辑运算。以下是三种最基本的运算。
与运算 (AND)
与运算接收两个输入,仅当两个输入都为1时,输出才为1。其真值表如下:
| X | Y | X AND Y |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
在逻辑表达式中,我们通常用 X ∧ Y 或 X && Y 表示。
或运算 (OR)
或运算接收两个输入,只要至少有一个输入为1,输出就为1。其真值表如下:
| X | Y | X OR Y |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
在逻辑表达式中,我们通常用 X ∨ Y 或 X || Y 表示。
非运算 (NOT)
非运算是一元运算,只接收一个输入,并输出其相反值。其真值表如下:
| X | NOT X |
|---|---|
| 0 | 1 |
| 1 | 0 |
在逻辑表达式中,我们通常用 ¬X 或 !X 表示。
组合逻辑表达式
上一节我们介绍了三种基本运算,本节中我们来看看如何像组合算术运算一样,将它们组合成更复杂的布尔表达式。
例如,计算表达式 NOT(0 OR (1 AND 1)):
- 先计算括号内的
1 AND 1,结果为1。 - 表达式变为
NOT(0 OR 1)。 - 计算
0 OR 1,结果为1。 - 表达式变为
NOT(1)。 - 最终结果为
0。
布尔函数与真值表
一旦我们掌握了如何对具体值进行布尔运算,就可以定义更一般的布尔函数。一个布尔函数接收若干个变量(如X, Y, Z)作为输入,并通过一个布尔表达式为每一组输入值产生一个输出。
例如,我们可以定义一个三输入函数:F(X, Y, Z) = (X AND Y) OR (NOT X AND Z)。
布尔值的一个巨大优势是:对于有限个输入变量,可能的输入组合也是有限的。因此,我们可以通过真值表来完整地描述一个函数。真值表列出了所有可能的输入组合及其对应的输出值。
以下是函数 F(X, Y, Z) = (X AND Y) OR (NOT X AND Z) 的真值表:
| X | Y | Z | F(X,Y,Z) |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 1 |
| 0 | 1 | 0 | 0 |
| 0 | 1 | 1 | 1 |
| 1 | 0 | 0 | 0 |
| 1 | 0 | 1 | 0 |
| 1 | 1 | 0 | 1 |
| 1 | 1 | 1 | 1 |
布尔表达式和真值表是描述同一布尔函数的两种完全等价的方式。
布尔代数定律
就像普通代数有交换律、结合律一样,布尔代数也有一系列恒等式或定律。这些定律可以帮助我们转换和简化布尔表达式。
以下是几个核心定律:
- 交换律:
X AND Y = Y AND XX OR Y = Y OR X
- 结合律:
(X AND Y) AND Z = X AND (Y AND Z)(X OR Y) OR Z = X OR (Y OR Z)
- 分配律:
X AND (Y OR Z) = (X AND Y) OR (X AND Z)X OR (Y AND Z) = (X OR Y) AND (X OR Z)
- 德摩根定律:
NOT(X AND Y) = (NOT X) OR (NOT Y)NOT(X OR Y) = (NOT X) AND (NOT Y)
- 幂等律:
X AND X = XX OR X = X
- 双重否定律:
NOT(NOT X) = X
所有这些定律都可以通过为等式两边构建真值表并验证其完全一致来证明。
表达式化简实例
现在,让我们运用这些定律来化简一个布尔表达式。假设我们有表达式:NOT( (NOT X) AND (NOT X OR Y) )。
化简步骤如下:
- 对子表达式
(NOT X OR Y)应用德摩根定律,得到(NOT(NOT X)) AND (NOT Y)。 - 根据双重否定律,
NOT(NOT X) = X。表达式变为NOT( (NOT X) AND (X AND NOT Y) )。 - 根据结合律调整与运算顺序:
NOT( (NOT X AND X) AND NOT Y )。 - 我们知道
NOT X AND X恒等于0(矛盾律)。表达式变为NOT( 0 AND NOT Y )。 0 AND 任何值 = 0。表达式简化为NOT(0)。- 最终结果为
1。
通过代数变换,我们将一个复杂表达式化简为了常量 1。另一种方法是直接为原表达式构建真值表,会发现所有输出行都是1,从而得出相同结论。
总结
本节课中我们一起学习了计算机理论的起点——布尔逻辑。我们认识了基本的布尔值(0和1)以及三种核心逻辑运算:与(AND)、或(OR)、非(NOT)。我们学会了如何组合它们形成布尔表达式和函数,并掌握了用真值表完整描述函数的方法。最后,我们介绍了布尔代数的一系列基本定律,并演示了如何利用它们进行表达式的转换与化简。

至此,我们完成了从纯逻辑角度操作0和1的学习。在下一单元,我们将保持这种理论视角,探讨一个关键问题:如何用这些基本运算来构造我们想要的任意布尔函数。这正是设计计算机硬件时必须掌握的技能。而再往后,在第三单元,我们将把视角切换到物理实现,探讨这些抽象的0和1信号如何在计算机内部的真实芯片和门电路中表达。
006:布尔函数综合

概述
在本节课中,我们将学习如何根据给定的真值表,综合出实现该布尔函数的逻辑表达式。这是计算机硬件设计中的核心技能,因为我们需要将高级功能描述转化为由基本逻辑门(如与、或、非)构成的电路。
从真值表到布尔表达式
上一节我们介绍了布尔函数、布尔值、布尔代数和布尔表达式。本节中我们来看看如何从更基本的操作来构造布尔函数。
我们已经知道两种表示布尔函数的方法:布尔表达式和真值表。我们也知道如何从表达式推导出真值表:对输入位的每一种可能取值计算表达式,然后填充真值表。
我们现在要做的是完全相反的过程。我们从一个函数的描述(例如给定的真值表)开始,目标是提出一个能计算相同布尔函数的公式。
为什么需要这样做?这正是我们设计计算机时必须做的事情。我们知道我们希望某个单元做什么,但我们必须用原始门、原始操作来实际构建它。
构造析取范式
让我们看看如何做到这一点。我们继续采用抽象的处理方式,试图找出从原始操作构造布尔函数的基本逻辑方法。稍后,当我们实际讨论构建时,我们将更加注重实践,逐步进行。目前,我们只想确保理解原理。
以下是从真值表构造布尔函数的标准方法,称为构造其析取范式公式。
我们逐行查看真值表,只关注输出值为1的行。例如,这里的第一行输出值为1。我们可以写一个表达式,使其仅在这一行取值为1。具体来说,由于在这一行中,x、y和z的值是0、0和0,如果我们看表达式 (¬x ∧ ¬y ∧ ¬z),这将是一个布尔函数,一个仅在此行取值为1的布尔函数。
现在我们有了一个布尔函数。我们对每个输出值为1的行做同样的事情,构造另一个布尔函数(另一个子句)。例如,这里有另一个输出值为1的第二行。这次,在这一行中y等于1,而x和z等于0。所以我们在这里写的子句是 (¬x ∧ y ∧ ¬z)。同样,这个表达式仅在此行完全取值为1,在其他所有地方取值为0。
我们对每一个输出值为1的可能行都这样做。现在我们有一堆不同的函数,每个函数仅在其对应的行取值为1,在所有其他行取值为0。
但我们想要的是一个单一的函数,一个单一的表达式,恰好只在所有这些行取值为1,在其他行取值为0。我们如何做到?这很简单。我们只需将它们或在一起。现在我们得到一个单一的表达式,一个单一的布尔函数,它恰好在我们为其构建子句的那些行取值为1,在其他所有地方取值为0。
现在,我们基本上已经将函数构造为一个仅使用与、非和或的布尔表达式。
当然,一旦有了这个表达式,我们可以开始以各种方式操作它。这是将函数写成表达式的一种方式。但如果你仔细观察,你会发现我们可以开始改变它的格式。例如,如果你看前两个子句,一个是 (¬x ∧ ¬y ∧ ¬z),另一个是 (¬x ∧ y ∧ ¬z)。注意,对于y我们有两种可能性,而x和z的值完全相同。因此,我们可以将这两个子句合并为一个子句,它不关心y,只关心 (¬x ∧ ¬z)。
所以我们得到一个稍微短一些的等价表达式。我们可以做更多的操作。我们暂不深入讨论,但实际上我们可以用许多不同的方式写出相同的表达式。有些会比其他的更短。有些在我们实际在计算机中实现时可能更高效。但关键是,从逻辑上讲,它们都是完全等价的。
你可能会想,如何实际找到与我们刚刚得到的公式等价的最短或最高效的公式?一般来说,这不是一个简单的问题。对人类来说不容易,也没有任何算法能高效地做到这一点。事实上,找到一个与给定表达式等价的最短表达式,甚至验证你给定的表达式是否只是一个常数0或1,这是一个NP完全难题。
布尔函数的完备性
更有趣的是,我此刻真正想关注的是,我们实际上证明了一个非常卓越的数学定理:任何布尔函数,无论有多少变量,无论布尔函数是什么,都可以用仅包含与、或和非操作的表达式来表示。
要理解这是多么卓越,只需想想整数和整数函数。并非每个整数函数都可以只用加法和乘法等算术运算来表示。事实上,大多数函数不能仅用算术运算表示。然而,由于我们在布尔代数中所处的有限世界,每个布尔函数都可以只用与、或和非来表示。
这正是赋予我们基本能力的原因,使我们能够仅用这些可能的门、仅用这些可能的操作与、或、非来实际构建计算机。
基本操作集的简化
但我们真的需要所有这些吗?这里有一个更好的定理:我们实际上并不需要或门。仅用与和非,我们就可以构造任何布尔函数。我们如何证明这一点?我们已经知道,如果我们有或,我们可以做任何事情。我们刚刚看到了这一点。现在我们需要证明的只是我们可以用与和非门实际计算一个或。但我们已经知道如何做到这一点。我们记得德摩根定律,它正好给出了一个仅使用非和与门的或运算公式。
所以现在我们有了一个更卓越的定理:我们只需要这两个基本操作就可以实际计算每一个布尔函数。
我们能更进一步减少吗?我们能放弃,比如说,与门吗?这没有意义,因为非只接受一个值并输出一个值,甚至不允许我们组合任何东西。
我们能放弃非门吗?不太可能,因为与有这样的特性:如果你只输入0,输出将总是0。而有些布尔函数,当你输入0时,输出是1,所以仅靠与本身是不够的。
但事实证明,还有另一种操作,仅靠它本身就足以实际计算一切。让我介绍一下与非函数。与非函数的真值表是:仅当两个输入都为1时输出0,其他所有可能性都输出1。从逻辑上讲,x NAND y 被定义为 ¬(x ∧ y)。
这个布尔函数有什么了不起的?好处在于,我们可以证明以下定理:如果你只有与非门,你已经可以计算每一个布尔函数,你已经可以用仅包含这些与非门的表达式来表示每一个布尔函数。
我们如何证明?我们知道,如果你能做非和与,你就能做一切。所以我们只需要展示如何用与非门做非,以及如何用与非门做与。
以下是如何做非。如果你看看当你把x同时输入到与非门的两个输入端时会发生什么,将其代入上一张幻灯片的真值表,你可以看到 ¬x 实际上由 x NAND x 表示。这是第一部分。
第二部分我们需要展示如何做与。 x ∧ y 结果是 ¬(x NAND y)。但我们如何得到非?我们刚刚看到你可以用与非本身做非。所以现在我们已经有效地减少了,我们不再使用非和与,而只使用与非门。
我们得到了我们惊人的定理:只要你有一个与非门,你就可以计算一切。这正是我们实际去构建计算机时将采用的方法。我们将给你一个基本的、原始的与非门操作,你将基本上只用这些基本的与非操作来构建整个计算机,构建所有要求你构建的复杂逻辑。
总结

本节课中,我们一起学习了如何从真值表综合出布尔表达式,特别是通过构造析取范式的方法。我们探讨了布尔函数表示的完备性,了解到仅使用与、或、非操作就足以表示任何布尔函数。更重要的是,我们学习了如何进一步简化基本操作集,最终发现仅与非一种门电路就具备功能完备性,可以构建出所有其他逻辑功能。这为后续从抽象逻辑转向实际硬件门电路设计奠定了理论基础。从下一节开始,我们将把视角从抽象的逻辑操作切换到实际构建计算机所用的门电路。
007:逻辑门


在本节课中,我们将要学习逻辑门的基本概念,包括其定义、类型以及接口与实现的区别。我们还将了解如何使用基本逻辑门构建更复杂的复合门。
概述
在本周的前两个单元中,我们讨论了布尔函数,但讨论大多是理论性的。从本单元开始,我们将探讨如何实际使用硬件来实现这些布尔函数。具体来说,我们将介绍一种称为门逻辑的通用技术,它大致上是一种使用逻辑门来实现布尔函数的技术。
什么是逻辑门?🚪
逻辑门是一个独立的芯片,或者说是一个非常简单的芯片或基本芯片,其设计目的是提供明确定义的功能,例如“与非”(NAND)功能、“或”(OR)功能等等。
基本逻辑门
以下是三个在每一个数字设计项目中都会出现的经典逻辑门:
- 与门(AND Gate):当它的两个输入都为1时,输出1;在其他情况下输出0。
- 或门(OR Gate):当它的任意一个输入为1时,输出1;在两个输入都为0时输出0。
- 非门(NOT Gate):作为一个转换器工作,将输入取反。
我们可以将这些门组合起来,以创建更复杂的功能,稍后将进行说明。
复合逻辑门
什么是复合逻辑门?复合逻辑门是由基本逻辑门和其他复合逻辑门构成的。简单来说,它是比基本门更复杂的门。在本课程中,我们将开发诸如多路复用器等复合门。显然,你们大多数人还不知道这些术语的含义,不用担心,在接下来的几周课程中,你们会熟悉并实际构建它们。
现在,让我们从本课程中使用的最基本的逻辑门——与非门(Nand) 开始。
以下是与非门的定义。这是我们用来描述与非门的标准图示。它有两个输入(A, B)和一个输出(out),所有值都是二进制的(0或1)。
这个门的功能描述是:如果两个输入都是1,则输出0;在任何其他情况下,输出1。 这里也有一个真值表来描述相同的功能规范。这些描述中的任何一个都可以。综合来看,我们这里得到的是与非门的一个抽象。我们没有说这个东西实际上是如何工作的,我们只是描述了我们可以期望它提供什么样的功能。
构建复合门示例
我们可以使用这些基本门来创建复合门。例如,这里有一个三路与非门,可以看作是简单两路与非门的扩展。构建它的一种方法是使用这个技巧:我们可以取一个与非门的输出,并将其馈送到另一个与非门的一个输入中。如果我们正确连接所有线路,这个门应该能提供所需的功能。
在实现周围的虚线矩形中,我们看到的是这个芯片接口的文档。用户位于虚线矩形之外,因此用户只看到三个输入和输出,如门描述的左侧所示。如果你想深入了解或打开这个黑盒,你必须查看虚线矩形内记录和完成的内容。
接口与实现
基于此,我想就接口和实现的概念说几句。
门的接口就是门的抽象。这是用户思考门应该做什么的方式。接口回答了“做什么”的问题。
同时,如果你想理解芯片是“如何”做它正在做的事情,你必须更深入一层。在这一层,黑盒被打开,你看到芯片实际上是如何构建的,或者你自己去构建它(如果你的工作是实际实现这个抽象的人)。
门的接口是唯一的。只有一种方式来描述门的功能。否则,如果有不止一种方式,要么是你描述得不好,要么是你让用户感到困惑,因为描述门应该做什么的方式应该是唯一且独特的。
同时,可能有几种不同的实现来实现相同的抽象。不同的实现可能更优雅、能耗更低、成本更低或更高等。所以,一个抽象,多种不同的实现。这在计算机科学中非常典型。每当你构建一个大型系统时,都会遇到这种二元性。
电路实现
我们讨论了门的接口和门级实现。现在,让我们谈谈被称为电路实现的东西。
如果需要,我可以使用像这样的硬连线电路来实现这些门。这里有一种特定的图形语言来描述正在发生的事情:当我们想要表示门输出1时,我们假设一个灯泡会亮起;当门输出0时,灯泡会熄灭。
如果我们看这个与门电路的实现,我们会发现,由于这个电路的架构,只有当两个继电器都闭合时,灯泡才会亮起。在任何其他情况下,灯泡都会熄灭。这正是表示与逻辑时我想要的效果。
那么或逻辑呢?同样,如果我必须构建一个或门的电路实现,我可以使用这个特定的架构来实现。在这里,只需要一个继电器闭合电路就能点亮灯泡。同样,这与期望的或电路抽象是一致的。
继续看,这是一个三路与非门的电路实现。我想你可以很容易地说服自己,它将提供所需的功能。
然而,我想提醒你,在本单元早些时候,我们还展示了这个与非门的实现(如果你还记得的话)。因此,我想就这两种设计硬件的不同方法说几句。
本课程的实现方法
我想首先说明,在本课程中,我们不涉及物理实现。因此,关于电路、晶体管、继电器等等的所有讨论,以及你在屏幕左上角看到的内容,都属于电气工程范畴,而不是计算机科学。那些使用电路和类似技术构建这些门的人(其中一些技术要先进和复杂得多)被称为电气工程师。我们在计算机科学中已经有足够多的问题需要担心,所以我们完全不会担心物理实现。
我们在课程中将要使用的所有设计,都将类似于我们在屏幕右下角所做的:我们将从与非门(以及“与”、“或”、“非”门)开始,取现有的逻辑门,并以某种巧妙的方式将它们组合在一起,以生成和产生所需的功能。
总结

本节课中,我们一起学习了逻辑门的初步介绍。在下一单元,我们将向你介绍一种语言,一种称为HDL(硬件描述语言)的编程语言。使用这种语言,你实际上可以构建像本单元中看到的逻辑门,以及未来更复杂的逻辑门和芯片。
008:硬件描述语言入门

在本节课中,我们将学习如何使用硬件描述语言来设计和实现逻辑门。我们将从理解逻辑门的行为描述开始,逐步深入到如何用HDL代码构建一个具体的异或门电路。
从抽象到实现
上一节我们讨论了如何使用逻辑门实现布尔函数。本节中,我们来看看如何实际构建和实现这些逻辑门,我们将使用一种称为硬件描述语言的形式化方法。
一旦你用HDL构建了一个逻辑门,你就可以模拟它、测试它,并最终在硬件中实现它。
定义门的行为
作为门的设计师,你首先要做的是明确所需门行为的完整描述。对于一个简单的异或门,我们需要一个真值表。
以下是异或门的真值表:
| A | B | Out |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
真值表与门电路图一起,提供了理解这个芯片功能所需的一切信息。我们在这里看到的有时也被称为芯片的接口。利用这些信息,你可以开始编写一个HDL文件。
编写HDL接口
一个HDL文件通常从一些自由格式的文档开始,描述门应该做什么。然后我们指定芯片的名称、输入和输出的名称。
这些信息通常是给定的,是芯片“契约”的一部分。你只需按照语法写出即可。接着,你会写下关键字 PARTS,这标志着程序段的开始,你将在这里描述芯片的实际设计。
一个异或门接口的HDL代码框架如下:
/**
* 异或门 (Xor)
* 如果两个输入不同,则输出1;否则输出0。
*/
CHIP Xor {
IN a, b;
OUT out;
PARTS:
// 实现部分
}
构建芯片:从接口到实现
当我们使用这样一个芯片时,我们迄今为止看到的部分被称为门的接口。请注意,这是使用异或门所需的全部信息。然而,如果要构建这个门,那就是另一回事了。现在我们必须打开这个“黑匣子”并实际设计它。
因此,在使用芯片时,我们总是戴着两顶不同的“帽子”。一顶是程序员,他使用芯片,为此我们只需要知道芯片接口。另一顶是芯片构建者,这就是我们现在要做的。
接下来,让我们讨论如何从零开始构建这个芯片。实际上,我们并非完全从零开始。我们可以假设我们已经构建了与门、或门和非门。如果我们已经构建了这些门,或者有人给了我们这些门供我们自由使用,我们就可以开始设计。
设计逻辑图
通过检查真值表,我们可以推导出异或功能可以描述如下:当 (A 且 非B) 或 (B 且 非A) 时,门输出1。这个布尔函数可以从真值表中综合出来。
一旦得出这个见解,下一步就是思考如何用我们已有的基本逻辑门来构建这个布尔函数,并绘制出相应的门逻辑图。
以下是构建异或门的一种可能逻辑图:
- 首先,我们绘制芯片图的边界。边界之外是用户对这个门的视图,即门接口。
- 信号A被复制并同时发送到两个不同的目的地:一个与门和一个非门。信号B也经历同样的处理。
- 我们使用一些现成的门:非门、与门和或门。使用现成的门时,我们必须使用其官方定义的输入和输出名称。
- 接下来,我们关注连接不同芯片部件的内部连线。每条这样的连线都必须被命名,我们需要为其起一个合理的、自描述的名称。
用HDL描述逻辑图
现在,我们可以继续使用HDL来实现这个图。我们回到之前创建的HDL存根文件。存根文件是一个部分HDL实现,通常只描述芯片接口,并带有“实现缺失”或“在此处输入代码”等语句。
现在,我们将专注于该文件的实现部分。基本上,我们开始一次一个芯片部件地描述门电路图。对于我们拥有的每个芯片部件,我们写一条HDL语句来描述该芯片及其所有连接。
以下是实现异或门的HDL代码示例:
PARTS:
// 计算 notA 和 notB
Not(in=a, out=notA);
Not(in=b, out=notB);
// 计算 A and notB
And(a=a, b=notB, out=aAndNotB);
// 计算 notA and B
And(a=notA, b=b, out=notAAndB);
// 最终输出: (A and notB) or (notA and B)
Or(a=aAndNotB, b=notAAndB, out=out);
这个HDL文件不过是我们看到的门电路图的文本描述。再次注意,我们区分了接口和实现。同时,芯片的接口是唯一的,而实现方式通常不唯一。例如,异或门可以用更少的逻辑门来实现。不同的实现可能在成本、部件数量、能耗等方面有所不同。
HDL语言的特点
我想借此机会对HDL做一些一般性的观察。为了方便参考,我将本单元早些时候看到的门电路图(右侧)和其HDL描述(左侧)放在一起。
关于HDL,我们可以说以下几点。首先,HDL中有一些问题与我们通常在其他编程语言中所做的非常相似:
- 我们必须关注HDL程序的良好文档。
- 我们必须为使用的芯片和架构内创建的连接起好的描述性名称。可读性非常重要。
- 我们使用缩进使代码看起来整洁美观。
此外,还有一些是HDL真正独特的地方:
- HDL是一种功能性或声明性语言。没有过程发生,没有程序执行。它只是门电路图的静态描述。我们假设某个解释器(在我们的案例中是硬件模拟器)会获取此描述并开始工作,但过程性部分不属于HDL代码本身。
- 由于HDL是功能性的,我们可以按任何顺序编写这些HDL语句。通常习惯从左到右描述你的图表,这也使代码更具可读性。
- 每次使用现成的门时,我们都承诺使用该门的接口,即其文档中规定的输入和输出名称。
在我们将在本课程中构建的Hack计算机里,我们约定:对于双输入芯片,几乎总是使用字母 A 和 B 作为输入;对于单输出芯片,使用 out 作为输出。因此,我们会看到许多像 a=a 和 out=out 这样的芯片连接。起初,这些约定可能看起来有点奇怪,但如果你思考一下并参考图表,你会发现这些连接很有意义,并且从编程的角度来看非常方便。
关于HDL的总结
我想以一些关于硬件描述语言的一般性评论作为结束。市面上有很多HDL,但据我所知,最流行的两种是VHDL和Verilog。它们用于大约90%的硬件设计项目,但还有许多其他HDL也可以使用。
我们自己的HDL在精神上与前面提到的工业级HDL(VHDL和Verilog)非常相似,但它是这些HDL的一个非常精简和简单的版本。因此,你可以在阅读教程大约一小时后掌握它,并开始编写自己的HDL代码。最重要的是,我们的HDL连同我们的硬件模拟器,为你提供了构建本课程描述的计算机(以及你可能想用所学知识构建的任何其他计算机)所需的一切。
如果你想了解更多关于HDL的信息,你应该查看教科书中的附录A,并阅读我们Nand2Tetris网站上的HDL生存指南。你可能还想学习硬件模拟器教程,了解如何实际读取HDL描述,并使用模拟器执行这些HDL背后的逻辑。

本节课中我们一起学习了硬件描述语言的基础知识。我们了解了如何从定义芯片接口开始,通过分析真值表设计逻辑电路,并最终使用HDL代码将设计文本化。我们还讨论了HDL作为声明性语言的特点以及良好的编程习惯。在下一单元中,我们将描述如何将你的HDL设计在硬件模拟器的环境中变为现实。
009:硬件仿真 🖥️

在本节课中,我们将学习如何使用硬件仿真器来验证我们编写的HDL代码是否正确。我们将了解交互式仿真和基于脚本的仿真两种方法,并学习如何利用测试脚本、输出文件和比较文件来系统化地测试芯片功能。
概述
上一节我们学习了如何使用HDL实现门逻辑。然而,我们编写的HDL代码并不能保证其正确性。我们无法确定设计的架构是否能实现预期的芯片功能。因此,在本节中,我们将学习如何通过硬件仿真来验证HDL程序是否实现了底层芯片的预期功能。
硬件仿真概览
以下是硬件仿真的大致流程。
我们有一个特定的HDL文件需要测试。我们可以将其加载到一个名为“硬件仿真器”的特殊程序中。我们已编写了此程序并在网站上提供,该程序使用Java编写,旨在模拟和测试HDL文件。
我们将HDL文件加载到程序中,可以交互式地测试该芯片的各种操作,这种模式称为交互式仿真。
或者,我们可以使用一种我们设计的特殊语言(称为测试语言)编写另一个文件。这种语言几分钟就能学会。我们可以编写一组预定的、可重复的测试。这样我们就不必进行交互式操作,可以提前规划如何系统地测试底层芯片。
我们将其写入所谓的测试脚本中,然后将两个独立的文件加载到仿真器中:HDL代码和测试脚本。接着,我们让仿真器开始工作,它会执行测试脚本中的每一步,并根据提供的脚本对HDL代码进行指定的测试。这种操作模式称为基于脚本的仿真。
最后,如果需要,我们可以将仿真输出记录到输出文件中,甚至可以将仿真结果与存储在另一个比较文件中的期望输出进行比较。
正如你所见,硬件仿真实践涉及许多新概念和技术。本节的目的是通过一个贯穿本节的逐步测试示例,使所有这些新信息具体化。
软件工具准备
在本节中,我们将介绍几个软件工具(实际上只有一个工具:硬件仿真器)。我们非常欢迎你暂停视频,在你自己的计算机上调用这个硬件仿真器,并确保我们演示的操作在你的机器上也能完成。
如果你在课程开始时下载了软件套件,那么硬件仿真器应该已安装在你的计算机上,你在讲座中看到的所有操作都可以自己完成。我们鼓励你尽可能多地动手尝试。
具体示例:Xor芯片
让我们开始看一些具体示例。
以下是我将在本节中使用的示例。这是一个描述我们在上一节讨论过的Xor芯片的HDL文件。代码本身你应该很熟悉,尽管理解本节内容并不需要完全理解它。
再次强调,我们编写了这段漂亮的代码,但这绝不意味着代码实际上是正确的。事实上,它很可能包含一些错误,比如语法错误、逻辑错误等等。那么我们如何测试它呢?
我们可以调用硬件仿真器,程序开始运行。我们将HDL文件加载到仿真器中,然后可以在A和B输入端输入0和1,并观察芯片内部的情况。
我们如何观察呢?我们必须告诉仿真器去评估芯片的逻辑。在我们告诉仿真器考虑我们输入的新值并评估芯片逻辑之前,仿真器不会做任何事情。如果我们这样做,仿真器会将这些值(我们输入的0和1或其他值)通过架构进行“管道传输”,最终会有结果从芯片的输出引脚输出。
此时,我们可以检查实际产生的输出。我们可以观察芯片的输出(在本例中是输出引脚out的值)。如果需要,我们还可以检查内部引脚(如notB、notA等)的值。仿真器为我们提供了所有这些强大的功能。
硬件仿真器界面详解
下图是硬件仿真器运行时的截图。仿真器包含或具有几个不同的窗格。
让我们分别讨论每一个窗格。
在屏幕的左下角,我们看到已加载到仿真器中的HDL代码。这是一个静态视图。我们无法在此操作或编辑代码。如果想编辑,必须使用外部文本编辑器修改HDL代码,然后重新加载。所以这个窗格只是让我们了解我们加载到仿真器中的具体内容。
在这里,我们可以与仿真器的输入引脚进行交互。我们可以点击它们并更改值。在这个特定示例中,我们有四种不同的0和1组合可能性(这是一个非常简单的芯片)。
一旦我们这样做了,我们可以点击这个看起来像计算器的图标。点击它,我们基本上就是告诉仿真器根据提供的输入评估芯片的逻辑。此时,仿真器会开始工作,花费一小段时间,然后会有结果输出。
然后我们可以评估芯片的输出(本例中只有一个名为out的输出)。同样,如果需要,我们也可以检查内部引脚的当前值。
总之,这个GUI为我们提供了进行Xor芯片(或任何其他芯片)动手交互式仿真所需的一切。
交互式仿真演示
现在,我想给你一个硬件仿真器实际操作的示例。让我们先演示一下,然后再回到讲座。
这就是硬件仿真器。为了使用它,我们首先需要将一个HDL程序加载到仿真器中。我们通过点击这里的图标来实现。
我们看到我们位于Nand2Tetris文件夹中。在这个文件夹内,我们选择projects,然后在projects中选择Project 0。
我们看到这里有一个HDL文件,让我们选择它。这是我们之前讨论过的Xor.hdl文件。
我们将芯片加载到仿真器中。现在,左下角的窗格显示了我刚刚加载的文件内容。我可以上下滚动。它只是只读显示,无法在仿真器内部更改代码。如果你想更改代码,必须使用外部文本编辑器,然后重新加载你修改并保存的文件。
假设我们对这个文件满意,我想实际模拟芯片逻辑。首先,我查看当前的输入值,我们看到Xor芯片的A和B输入默认值为0。所以,如果需要,我们可以继续将它们中的一个或两个更改为其他值。
让我们将A改为1,也将B改为1。注意,目前还没有任何变化。为了查看芯片对这些变化的响应,我必须重新评估芯片逻辑。我通过点击这里的计算器图标来实现。
点击后,我们看到输出变为0。确实,1 XOR 1的结果是0。如果我们想查看其他可能性,可以再次操作其中一个输入引脚,点击计算器,我们看到Xor现在输出1而不是0。
我们还可以做的另一件事是检查内部引脚的值,这为我们提供了另一个层次的检查,特别是在我们试图调试芯片并理解它为何在某些情况下行为异常时。
这只是使用仿真器时首先要做的事情的简要描述:加载芯片、操作输入引脚、检查输出和内部引脚。这是芯片调试和测试的基础。
最后提醒一下,如果你想更改芯片逻辑,必须使用外部文本编辑器编辑HDL文件,保存新文件,将其重新加载到仿真器中,并重新运行测试以测试新的芯片设计。
基于脚本的仿真
交互式仿真确实很好,但在某些时候可能会变得相当繁琐,特别是当你的设计中有很多错误,每次都需要重复进行同一组测试时。
考虑到这一点,我们很幸运地拥有了测试脚本的概念。
再次以Xor芯片为例(顺便说一下,我允许自己互换使用“芯片”和“门”这两个词,对我来说,门就是一个简单的芯片)。
这是一个为测试Xor门设计的测试脚本示例。让我们逐行浏览这个测试脚本。
测试脚本中的第一条命令指示仿真器将Xor.hdl文件加载到程序中。所以连这一步都自动处理了,我们不需要自己动手。测试脚本会为我们加载程序。顺便说一下,这在重复调试时非常重要,因为你如何使用外部文本编辑器修改门逻辑,然后点击保存,再回到仿真器。所以记住重新加载编辑后的芯片新版本很重要。这就是为什么我们在测试脚本中添加了这个命令。
一旦HDL加载到仿真器中,测试脚本就开始对芯片进行四次不同的测试。分号;代表一个特定测试的结束。
我们将芯片的输入值设置为0和0,评估芯片逻辑,并观察结果。然后我们进行下一个测试,依此类推,直到完成该芯片所有可能的输入组合。
这就是我们进行基于脚本的测试的方式。它有很多好处,首要的是我们不必费力地凭直觉做事,因为一切都是预先确定的。我们有一组可重复的测试,无论何时调试芯片(可能是两周或两个月后),我们都可以一遍又一遍地使用相同的测试。所以很高兴知道我们总是可以重复同一组测试。
总之,我们将尽量始终使用测试脚本。好消息是你真的不必担心如何编写测试脚本,因为我们将为你提供测试所需的所有测试脚本,以测试你将在HDL中设计的芯片。所以你专注于HDL,我们来负责测试你的工作。
记录仿真输出
我们可以做的另一件事是记录仿真的输出。具体来说,我们可以用类似以下命令来增强我们的基本测试脚本。
在测试开始时初始化时,我们可以指示仿真器创建一个输出文件(在本例中我们称之为Xor.out)。在测试过程中,在每个测试结束时,我们告诉仿真器将一组值输出到输出文件,这组值在脚本的前言中确定。
如你所见,脚本的第三行说输出列表是a、b和out。因此,每当仿真器看到output指令时,它就会将a、b和out的当前值写入输出文件。在仿真结束时,我们可以简单地检查输出文件的结果,并确信芯片确实按照预期运行。
事实上,在这个特定示例中,如果你检查仿真产生的输出文件,你会注意到它与Xor门的真值表完全相同。所以看起来这个门的行为是良好的。

脚本仿真演示
我们将展示如何使用硬件仿真器,通过测试脚本来测试HDL程序。
我们要做的第一件事是加载HDL程序到仿真器中。让我们选择之前使用的同一个Xor.hdl芯片。
然后,与其交互式地操作输入引脚,我们可以使用这里的图标打开一个测试脚本。我们看到在Project 0文件夹中有一个名为Xor.tst的测试脚本。让我们将其加载到仿真器环境中。
一旦加载,界面中的这个窗格会显示当前加载的测试脚本内容。我们看到它以几个命令开始,然后是实际的测试命令。下一个要执行的命令由一个黄色条高亮显示,我们可以称之为测试脚本的“光标”。
我们看到每个命令都以逗号或分号结尾。当我们点击播放图标时,仿真器将执行所有测试命令,直到遇到下一个分号。
现在,这个测试脚本中的前四个命令可以说是设置了仿真。让我们执行它们。我点击这里的图标,我们看到光标移动到了下一批命令。
这批命令说:将a设为0,将b设为0(这是该芯片的输入引脚),然后评估芯片逻辑,并将结果输出到输出文件。让我们执行这批命令。我们看到确实A和B输入现在是0,芯片的输出out是0,这似乎是正确的,因为我们知道0 XOR 0应该是0。
让我们执行下一批测试脚本命令。这批命令将a设为0,b设为1,评估芯片并输出结果。确实,我们看到0 XOR 1的结果是1,这似乎是正确的。
执行下一批命令,将a设为1,b设为0,得到结果1,这也似乎是正确的。然后继续,执行下一批也是最后一批命令,将A和B都设为1,我们看到Xor通过输出值0来响应,这再次似乎是正确的。
Xor门的测试脚本相对简单,因为它基本上只是执行了芯片真值表规定的所有可能输入组合。在更复杂的芯片中,这种穷举测试不太可行,因此测试脚本可能会更复杂。
当我们测试这个脚本时,脚本还将每个测试脚本批次的结果写入了输出文件。这是output命令的结果。
如果你想检查输出文件,我们可以通过转到这里的控件并选择output来实现。让我们这样做,一旦完成,我们看到输出文件确实看起来完全像Xor的真值表,这为我们提供了另一个迹象,表明芯片是按照规格运行的。

硬件仿真器简介
在我们继续这个逐步示例之前,我想暂停一两分钟,谈谈关于硬件仿真器的一般概念。
首先,硬件仿真器有很多种。有些是免费开源的,有些是专有的、非常复杂且昂贵的。在本课程中,我们使用一个简单的硬件仿真器,它同时为你提供了构建Hack计算机以及使用课程中描述的技术构建任何其他计算机所需的一切。
所以,现在你计算机上提供的硬件仿真器是一个工具,它再次为你提供了所需的一切。如果你想学习如何使用它,有几个地方可以查阅可用的文档。如果你访问Nand2Tetris网站,可以找到一个附录章节(我想是附录A和附录B),在我们的书中描述了如何使用硬件仿真器,这些章节也可以在网站上免费获取。另一件你应该做的事情是完成硬件仿真器教程,这是一套交互式幻灯片,解释了如何使用仿真器以及你在本讲座中看到的所有示例。
关于硬件仿真器的“广告时间”到此结束,我们回到正在进行的示例。
行为仿真与比较文件
让我们回顾一下到目前为止所做的工作。我们查看了一个记录Xor逻辑的特定HDL文件,我们向仿真器提供了一个设计用于测试此代码的测试脚本,我们还查看了生成的输出文件,并确信HDL文件实际上是正确的。
现在,查看输出文件并说“是的,它工作了”的这种特权,在现实生活的测试中很少可用。为什么?因为我们通常构建的芯片比Xor门复杂得多。没有办法查看一个ALU或CPU芯片的输出文件,然后说“是的,它工作正确”。我们必须以更系统和有计划的方式进行。那么有办法做到吗?当然有,这就是我们接下来要做的。
具体来说,我们可以使用另一个称为比较文件的工具进行仿真。这是一个比较文件的示例。它看起来完全像一个输出文件。事实上,在它之前的形态中,比较文件是一个行为良好的Xor芯片的输出文件。
有人(我们稍后会谈到这个人)正确地编写了一个Xor芯片,通过仿真器运行它,生成了一个输出文件,将其重命名为比较文件,然后交给我们,说:“现在你去构建Xor芯片,并将其与我做的进行比较。”
所以基本上,我们可以获取比较文件并将其加载到仿真器中,然后像之前一样开始进行仿真。现在,在仿真的每一步,当我们输出一组选定的引脚值时,仿真器会将输出的这组值与比较文件中的相应行进行比较。如果两行不一致,仿真器将抛出比较错误。
这使我们不仅能够记录芯片的输出,还能将其与期望的结果进行比较。
如何生成比较文件?
你可能会再次问自己,我们最初是如何生成这些比较文件的呢?我们可以把它当作一个谜,但没必要留下任何未解释的事情。以下是我们如何做到这一点。
我们使用所谓的行为仿真来实现。具体来说,考虑我们之前看到的Xor门,让我们再次假设这个实现是正确的。我们将其放入仿真器,连同脚本一起运行,生成结果,然后简单地将其重命名为Xor.cmp,它就成为该芯片的官方比较文件。
那么这张幻灯片有什么特别之处呢?我们不是已经说过这里的一切了吗?是的,我们说过。但有一个细节非常有趣。
在这个行为仿真的概念中,有趣的是芯片逻辑可以用设计者希望的任何方式实现。我们可以用Java或其他高级语言编写这个芯片逻辑。我们可以运行程序,生成输出文件,然后再次将其作为比较文件提供。
这种使用高级语言编写或实现门逻辑的技术,使你有能力在编写任何一行HDL代码之前就规划和测试你的硬件。这是一种相当复杂的工作方式。所以,我们可以在高级层面完成所有工作,只有当我们的机器的整体设计正常工作后,我们才开始用HDL逐个实现每个芯片。
硬件构建项目中的角色
这就是行为仿真的概念。我想继续谈谈我们实际上如何进行硬件构建项目。
任何硬件构建项目中的角色都由几个参与者组成,我想谈谈其中的两个。
首先,我们有系统架构师。系统架构师是被告知“去构建一个支持数码相机某种功能的芯片”或“去构建一个监控某种医疗设备的芯片”的人。架构师拥有芯片确切的用户级规格。
团队中的另一个成员是开发人员(可能不止一个开发人员或架构师)。他们必须一起设计这个芯片。他们如何做到呢?
系统架构师查看芯片的整体期望操作,然后他或她做出一些决定,如何将这种整体行为分解为一组更小的芯片(或更低级别的芯片)。然后,对于这些芯片中的每一个,架构师创建一个芯片API,其中包括芯片的名称、其输入和输出引脚的名称、一个测试脚本和一个比较文件。所有这些资源都由系统架构师准备(可能有人协助他或她的工作)。
最后,有了这些资源,开发人员就可以实际去构建芯片了。
所以,架构师可以进行行为仿真。他们可以做任何他们想做的事情来测试一切是否很好地结合在一起。在某个时候,开发人员实际上使用HDL来构建这个东西。
这再次是一个精心规划的模块化设计的例子,也是分而治之的另一个例子:将非常复杂的东西分解为一组更易于管理的、更小的模块,每个模块都可以独立创建和测试。
例如,Hack计算机。我本可以告诉你“去构建一台计算机”,实际上这就是我们在本课程中所做的,但我们把这个工作分解为构建大约30个不同的芯片,它们共同构成了Hack芯片组。
所以,我们(课程讲师)扮演系统架构师的角色,而你们则扮演实际硬件开发人员的角色。
开发人员的视角
那么,开发人员对特定芯片的视角是怎样的呢?当我们告诉你构建一个芯片时,你会得到以下内容:
- 一个存根文件,其中包含你需要构建的芯片的文档及其接口。
- 一个我们为你编写的测试脚本。
- 一个比较文件。
总之,你基本上得到了理解接口、理解芯片应该做什么以及我们将如何测试它所需的一切。
那么,你用所有这些资源做什么呢?你需要编写缺失的实现——即实际驱动整个过程的HDL代码。
这正是我们将在单元1.7中要做的事情。但在我们继续那个单元之前,我们还需要谈谈多比特总线,这是我们在HDL编程的一般描述中仍需填补的一个空白。
总结

在本节课中,我们一起学习了硬件仿真的核心概念与实践。我们了解了如何使用硬件仿真器对HDL代码进行交互式测试和基于脚本的自动化测试。我们认识了测试脚本、输出文件和比较文件在验证芯片功能中的重要作用,并理解了系统架构师与开发人员在硬件项目中的分工协作。这些工具和方法为我们后续实际构建复杂的数字逻辑芯片奠定了坚实的基础。
010:多比特总线 🚌

在本单元中,我们将探讨如何将多个比特作为一个整体来处理。虽然这个概念本身相对简单,但其中涉及一些技术细节,因此值得用一个简短的单元来专门讲解。
概述
在硬件设计中,我们经常需要同时操作多个比特。本单元将介绍如何将这些比特集合视为一个整体(称为“总线”)来操作,并学习在硬件描述语言中表示和操作总线的方法。
总线的基本概念
上一节我们介绍了比特和逻辑门,本节中我们来看看如何将多个比特组合起来使用。
在硬件设计中,将多个比特作为一个整体来操作非常方便。这允许我们在更高的抽象层次上思考,将一组比特视为一个单一的实体,而不是独立的比特。
由于我们将使用硬件描述语言来描述芯片,因此语言本身也需要支持这种操作。这种被一起操作、具有单一含义的比特集合,有时被称为“总线”。
总线操作示例:加法器
以下是总线应用的一个例子。当我们想要相加两个数字时(我们将在下周构建这样的芯片),每个二进制数都由一组比特(在我们的实现中是16位)表示,但我们希望将它们视为数字。
因此,我们的加法器芯片将被描述为具有两个16位的输入(A和B)和一个16位的输出(out)。实际上,这个芯片有32根输入线和16根输出线,但将其视为两个数字输入、一个数字输出更为方便。
在硬件描述语言中,该芯片的接口可以这样描述:
CHIP Add16 {
IN a[16], b[16];
OUT out[16];
// ... 内部实现部分省略
}
我们定义了输入A和B,并在方括号中指定了数字16,表示每个都是16位,类似于编程语言中数组的语法。
使用总线构建更复杂的芯片
一旦有了这种表示法,我们就可以在更高层次的芯片中操作这些数字实体。
假设我们需要构建一个将三个16位数字相加的芯片。逻辑上,我们知道可以先加前两个,再将结果与第三个相加。
以下是该芯片在HDL中的实现方式:
CHIP Add3Way {
IN first[16], second[16], third[16];
OUT out[16];
PARTS:
// 使用之前定义的 Add16 芯片
Add16(a=first, b=second, out=temp);
Add16(a=temp, b=third, out=out);
}
我们使用了两个内部的 Add16 芯片。第一个将 first 和 second 输入相加,结果存入一个16位的临时变量 temp。然后,我们将这个临时变量与 third 输入相加,得到最终输出。
访问总线中的单个比特
当然,我们也需要能够访问和操作总线中的单个比特,因为总线本质上就是一组比特的集合。
以下是一个例子:假设我们有一个芯片,输入是一个4比特的总线,输出是一个单比特,它是总线中所有比特的“与”运算结果。
以下是实现方法:
CHIP And4Way {
IN in[4];
OUT out;
PARTS:
And(a=in[0], b=in[1], out=t1);
And(a=t1, b=in[2], out=t2);
And(a=t2, b=in[3], out=out);
}
请注意这里的语法:当我们通过索引(如 in[0])引用总线引脚时,它表示我们正在谈论的特定比特。我们遵循了与大多数现代编程语言相同的约定:一个N比特的总线,其索引从0到N-1。
在本周的项目中,你将构建一批这样的芯片,它们接收总线中的多个比特,并将它们合并(如与、或运算)为单个值,我们称之为“多路”芯片。
并行比特操作:按位与
另一个例子展示了如何在总线内部并行操作比特,这是本周项目中常见的操作。
在这个例子中,我们有两个输入总线,我们想对它们进行按位“与”操作:取A和B的第一个比特相与,结果作为输出的第一个比特,依此类推。
实现方式如下:
CHIP BitwiseAnd {
IN a[4], b[4];
OUT out[4];
PARTS:
And(a=a[0], b=b[0], out=out[0]);
And(a=a[1], b=b[1], out=out[1]);
And(a=a[2], b=b[2], out=out[2]);
And(a=a[3], b=b[3], out=out[3]);
}
我们使用了四个“与”门,每个门对相应的一对比特执行操作,并产生输出的对应比特。注意,输出的四个比特是作为一个单一的总线 out[4] 定义的。
总线的组合与拆分
处理总线时,有时需要使用一些技术上的便利操作。例如,你可能希望将总线拆分为子总线,或者将多个子总线组合成一个总线。
第一个例子展示了如何将两个8比特总线组合成一个16比特总线:
// 假设我们有输入 LSB[8] (低有效字节) 和 MSB[8] (高有效字节)
// 我们希望将它们组合成 output[16]
output[0..7] = LSB;
output[8..15] = MSB;
请注意这里的语法:在方括号内使用 .. 表示法指定一个子范围,我们可以将总线连接到正确的子总线部分。我们可以在输入和输出上这样做,也可以用不同的方式拆分它们。
本课程HDL的特定约定
不同的硬件描述语言处理这些问题时有不同的语法约定。你可以在课程网站上找到我们使用的HDL的完整规范。
以下是本课程HDL中一些你可能觉得有用的特性:
- 允许子总线重叠:你可以提取比特0到5作为一个6比特总线输出,同时再提取比特3到7作为另一个总线输出。我们允许输出具有重叠部分的多个子总线。
- 内部总线宽度自动推导:内部总线或引脚的宽度完全由你连接到它的内容推导得出。你不需要指定内部引脚的总线宽度。
- 常量填充:如果你想用一连串的0或1填充一个总线,可以使用
true或false常量一次性完成。true:总线中的每个比特都被设置为1。false:总线中的每个比特都被设置为0。
总结

本节课中我们一起学习了如何处理多比特总线。我们了解了将多个比特作为一个实体(总线)来操作的概念优势,学习了在硬件描述语言中定义和操作总线的方法,包括访问单个比特、进行并行操作以及组合和拆分总线。我们还介绍了一些本课程硬件描述语言特有的实用约定。掌握这些概念后,我们现在已经为完成本周的项目做好了充分准备。
011:项目1概述 🧱

在本单元中,我们将概述项目1的目标与任务。你将学习如何利用给定的Nand门,逐步构建一系列更复杂的逻辑门,为后续构建计算机打下基础。
项目背景与目标
上一节我们熟悉了HDL和硬件模拟器,现在可以开始动手开发芯片了。本单元将提供设计项目1所需芯片的全部信息。
首先,让我们回顾本课程的核心精神。我们讲述的故事是:从一个给定的逻辑门(Nand)出发,逐步构建一台计算机。我们计划构建的计算机由大约30个不同的芯片组成,我们将一步一步地构建它们。在第一个项目中,我们从Nand门开始。你可以将其视为一个给定的公理或基础元件。使用Nand门,你需要构建以下门电路:Not、And、Or、Xor等。这就是项目1的任务。
你可能会问,为什么是这15个特定的门?答案有两个方面。首先,这些门中的每一个在几乎所有数字设备的构建中都被广泛使用。其次,更实际的原因是,这些门构成了构建我们的计算机所需的所有基本逻辑门。项目1为后续项目奠定了基础,因为在其他项目中,我们将使用这些芯片来构建更复杂、更有趣的功能。
项目1芯片分类
以下是项目1需要构建的芯片,我们将其分为三类以便更有条理地讨论:
- 基本逻辑门:广泛使用的门,如Not、And、Or等。
- 16位变体:某些逻辑门的16位版本。
- 多路与多路16位变体:前述门的多路及多路16位版本。
为了使这些新概念更易于理解,我们将从每一类中挑选一些门进行重点讲解。这足以让你掌握构建项目1中任何门所需的知识。
多路复用器与多路分配器
让我们从多路复用器和多路分配器开始。
多路复用器
多路复用器的工作方式如下。它有两个输入,我们记为A和B,还有一个从底部(在门电路图中)进入的选择输入。因此,共有三个输入:A、B和sel。
多路复用器的作用是:如果 sel = 0,则输出A;如果 sel = 1,则输出B。这就是多路复用器的期望行为。其真值表与上述描述一致。
一个两路多路复用器使我们能够从两个输入中选择一个输出。这个基本操作在数字设计项目和通信网络中被反复使用。
以下是一个展示Mux逻辑在构建“可编程门”中应用的例子。可编程门是一种可以根据我们的意愿以几种不同方式之一运行的门。这里有一个简单的可编程门,当选择位为0时,它作为And门运行;当选择位为1时,它作为Or门运行。
其真值表与上述描述一致。构建方法如下:使用两个门(一个And和一个Or,前提是你已经开发了它们),将A和B输入同时馈送到And和Or。然后使用一个多路复用器来决定是输出And的结果还是Or的结果。这样,只看到该门外部的用户就能得到他/她想要的可编程门。
以下是实现此特定架构的HDL代码。
那么,我们如何构建一个多路复用器呢?我们从系统架构师那里获得所有信息,同时获得一个存根文件。我们需要做的就是编写HDL代码。事实证明,我们可以使用三个门(And、Or、Not)以某种巧妙的方式连接来构建一个多路复用器。结果就是期望的Mux逻辑。你需要自己弄清楚如何进行连接以实现所需的多路复用器。
多路分配器
接下来,让我们谈谈多路分配器。
多路分配器看起来像是多路复用器的逆过程。它接收单个输入,并根据选择位,将该输入引导到A输出或B输出。它是一个将值分配到几个可能通道之一的分配器。所有其他通道输出0。因此,Dmux本质上是Mux的逆过程。同样,它将单个输入分配到几个可能的输出通道之一。
与多路复用器一样,多路分配也广泛用于数字设计项目和通信网络。
你将获得存根文件,并需要在HDL中实现其逻辑。
通信网络中的应用示例
我想举一个例子,说明多路复用和多路分配如何在通信网络的背景下发挥作用。这是构建网络时的典型情况:我们可能有多个通道进入,比如音乐频道或多部电影,我们希望通过单条通信线路发送。
这条单线可能长达5000公里,可能是一条水下线路或卫星线路。通过这条单线,我想发送多条消息。如何做到呢?如果一切都是数字化的,我可以在发送端放置一个Mux,并向Mux馈送一个持续的0、1、0、1序列(这可以使用振荡器完成)。由于Mux将接收到重复的0、1序列,它将在每个周期输出蓝色、红色、蓝色、红色。在接收端,我放置一个不同的振荡器,因此Dmux将根据其逻辑分配传入的输入,输出蓝色、红色、蓝色、红色等。
这里的逻辑,即Dmux和Mux逻辑结合在一起,使我能够在一条可能非常昂贵的单一通信线路上交织或交错多条消息,因此将其用于多条消息是值得的。顺便说一下,这个方案的另一个吸引人之处在于它可以是完全异步的,它们不必按照主时钟运行,编码和解码操作都可以单独完成。
16位变体示例:And16
我们讨论了Mux和Dmux,它们是你需要构建的基本逻辑门中最复杂的芯片。现在,让我们继续讨论你需要构建的16位芯片中的一个例子:And16。
And16看起来如下:有两个输入进入,它们都是16位总线,还有一个16位总线输出。基本上,我们需要实现的是一个16位的And操作。其逻辑是:仅当两个输入在各自索引位上都为1时,输出才为1。
这个例子中有一点需要注意:这里的逻辑计算不是顺序的。所有输出都是并行产生的。这也提示了你如何实际构建它。这是基本And门的直接扩展。你可以在HDL中阅读更多关于如何处理数据总线的信息。基本上,一旦你拿到存根文件,就可以使用一组两路And门来实现And16。
多路变体示例:Mux4Way16
最后,我想讨论多路变体的一个例子,这个概念实际上相当直接。
以Mux4Way16为例:它有四个输入,每个都是16位总线;有一个2位选择输入(因为需要两位来选择四种不同的可能性);我们有存根文件;我们可以使用几个Mux16门以及可能需要的其他一些门来构建这个东西。
项目1实施流程
那么,在项目1中我们具体需要做什么?我们需要仅从Nand门和先前设计好的门开始,实现所有这些门。一旦你构建了Not门,就可以使用它;一旦你构建了Not和And,就可以使用这两个门中的任何一个,依此类推。
我们如何实际构建每一个门呢?我们将提供一些构建材料,并以Xor为例。你从书籍或讲座中获得了门电路图;我们将提供这个HDL文件(你可以用文本编辑器打开它);我们还将提供一个测试脚本和一个比较文件。
我们希望你们遵守的规则是:当你运行你的Xor.hdl文件(即在你拿到这个存根文件并实现它、添加你自己的HDL之后),在提供的Xor测试脚本上运行结果文件时,你的输出文件应该与提供的比较文件相同。如果发生这种情况,你就按照我们的要求构建了该芯片。如果你的芯片产生了我们指定的预期行为,那么你就完成了这个芯片,可以继续开发下一个。
对于你需要开发的每一个芯片,我们都会提供存根文件、测试脚本和比较文件。
项目资源与最佳实践
项目1的资源在Nand2Tetris网站上列出。你可以查看不同的文件,但无需下载任何东西,因为如果你已经下载了课程软件套件,那么本项目所需的所有必要文件都已在你的projects/01目录中。你可以开始使用这些文件并构建项目1。
如果你需要一些额外资源,可以回顾第一周的内容来热身。还有HDL生存指南教程和问答论坛。你可以咨询这些资源中的任何一个,以获得信心并实际开始构建项目1。
在结束之前,我想谈谈我们所谓的Hack芯片组API。当你构建一个特定的门电路图时,你将使用各种芯片部件。问题是,你如何知道你想使用的每个门的输入和输出引脚名称?答案是,如果你使用的门是所谓的现成门,或者它们是Hack芯片组API的一部分,我们会提供关于如何使用它们的所有必要信息。具体来说,Nand2Tetris网站上有一份文档提供了Hack芯片组中所有芯片的API,这份文档实际上是HDL生存指南的一部分。因此,每当你 unsure 某个特定芯片的输入和输出引脚名称时,请查阅此文档。
我想在结束本单元之前说的另一件事是,你总是可以使用我们所谓的“内置芯片”。例如,假设你实现了一个名为Foo的芯片,并且想使用一个名为Bar的芯片部件。如果你之前没有编写过Bar,如何做到呢?这听起来有点不可能,但事实上是可能的。同样,如果你没有实现某些属于Hack芯片组的芯片,你仍然可以将它们用作芯片部件。你需要做的就是:如果我们给了你Bar的存根文件,你必须将此文件重命名为其他名称。例如,你可以将其从chipname.hdl重命名为chipname.hdl1。这样做之后,当你将Foo加载到硬件模拟器中时,模拟器将寻找与缺失芯片部件对应的HDL文件。如果找不到,它将默认使用缺失芯片的内置实现。
这给了我们极大的灵活性,因为这意味着原则上我们可以按任何顺序构建这些芯片,尽管我们建议你按照项目1中列出的给定顺序构建芯片。
以下是项目1及后续项目的一些通用最佳实践建议:
- 我们建议你按给定顺序构建芯片,因为它们是从简单到复杂指定的。
- 如果你没有实现某个东西,可以把它放在一边,稍后再回来处理。
- 如果需要,你可以设计一些辅助芯片(我们未指定的芯片),它们就像高级编程中的私有方法。然而,这确实没有必要,我们建议你不要这样做。没有必要发明任何新芯片,只需使用我们提供的芯片,你应该就能顺利完成这个项目。
- 总的来说,就像我们在计算机科学中常做的那样,力求使用尽可能少的部件,尝试创建一个优雅、可读性高且完成度高的实现。
常见问题与注意事项
现在,我想以关于项目1中常见问题和学生疑问的一些说明来结束本单元。
首先,你必须意识到,当你创建HDL程序时,必须使用一些外部文本编辑器,如Notepad++或你喜欢的任何编辑器。你不能使用我们的工具创建HDL代码,特别是不能在模拟器内部编写代码。再次强调,在外部编辑器中完成,保存HDL文件后,再将其加载到我们的模拟器中。
让一些学生困惑的下一点是:你不能在一个芯片的实现中将其自身用作芯片部件。HDL中没有递归。因此,你在实现一个新芯片时使用的所有芯片,必须是你之前实现的其他芯片,或者是始终可用的内置芯片。
当你在模拟器中运行HDL时,如果一切正常,你会在模拟器左下角看到一条消息,显示“simulation completed successfully”。如果出现问题,例如你的HDL代码存在语法问题,你将看到一条红色的错误消息。有些学生容易忽略这些消息,所以请注意模拟器左下角的信息。
在单元1.6中,我们解释了多比特总线的概念。我们想补充的是,这些总线是从右到左索引的。如果a是一个16位总线,那么a[0]代表最右边的位(有时称为最低有效位或LSB),a[15]代表最左边的位(也称为最高有效位或MSB)。这只是你在索引多比特总线时需要记住的一个约定。
最后,我们想再次强调,我们提供了各种资源供你在编写HDL代码时参考。特别是,书中有一个完整的附录专门介绍HDL编程,该附录也可在Nand2Tetris网站上在线获取。还有一份由Mark Armbrust编写的优秀文档,名为HDL生存指南。当你遇到困难需要建议时,这是你首先应该查阅的地方。
总结

本节课中,我们一起学习了项目1的概述。我们明确了项目的目标是从Nand门出发构建15个基本逻辑门,并将这些门分为三类进行了解释。我们重点讨论了多路复用器和多路分配器的原理、应用及实现思路,并举例说明了16位变体(And16)和多路变体(Mux4Way16)。最后,我们介绍了项目的实施流程、可用资源、最佳实践以及需要注意的常见问题,为动手完成项目1做好了充分准备。
012:视角与展望 🧠

在本单元中,我们将回顾第一周关于基本逻辑门的学习,并探讨一些由此引发的、关于硬件与软件系统构建的常见问题。我们将讨论逻辑门的替代起点、物理实现、硬件描述语言以及复杂芯片的设计方法。
上一节我们介绍了如何用与非门作为基础构建计算机。本节中,我们来看看一个相关的问题:是否可以从其他逻辑门开始构建计算机?
答案是肯定的。例如,你可以使用另一个基本门电路——或非门(NOR,即 NOT OR)——作为原子构件来构建整个计算机。同样,也可以很自然地以一套包含与门(AND)、或门(OR)和非门(NOT)的集合作为起点。这类似于几何学可以建立在不同的公理集合之上,每一种都可以作为推导出所有几何定理的出发点。然而,在硬件系统的物理实现中,与非门非常流行,因为在许多集成电路技术中,构建与非门的成本相对较低。
既然我们之前将逻辑门视为黑盒抽象,那么一个直接的问题是:如果真要构建一个与非门,该如何实现?
以下是实现与非门的一个简单示例,它基于 NMOS 技术:
Vdd (逻辑1) —— [弱电阻] —— 输出
|
[NMOS晶体管A] —— 输入A
|
[NMOS晶体管B] —— 输入B
|
Gnd (逻辑0)
其工作原理如下:
- 当输入 A 与 B 均为高电平(逻辑1)时,两个晶体管均导通,输出被强连接到地(Gnd),因此输出为低电平(逻辑0)。
- 在其他任何情况下(至少一个输入为低电平),对应的晶体管会断开,弱电阻将输出上拉至 Vdd,因此输出为高电平(逻辑1)。
这符合与非门(NAND)的真值表:仅当所有输入为1时,输出为0。不过,本课程的重点是计算机科学层面的抽象,我们并不关心具体的物理实现技术(如晶体管、电阻),我们只关心逻辑门接收真/假值并输出真/假值的功能。
欢迎从晶体管和电阻的世界回来。接下来,我们探讨硬件描述语言(HDL)。
问题是:我们在本周课程中使用的 HDL,与硬件工程师通常使用的真实 HDL 语言相比如何?
首先,我们的 HDL 是一种非常真实的语言,因为它可以用来设计和模拟计算机,这正是 HDL 的用途。同时,显然,像 Verilog 和 VHDL 这样的工业级 HDL 语言比我们的 HDL 要复杂和强大得多。
以下是工业级 HDL 的一些特点:
- 它们的语法通常是我们的 HDL 与类似 C 语言的混合体。
- 它们具有各种高级编程结构(如
for和while循环),这消除了编写大量重复 HDL 代码的需要。 - 和我们的语言一样,它们也能够建模和模拟时间和时钟的概念,没有这些概念,就无法构建我们后续用来制造内存和计数器等的时序逻辑。
这些语言非常强大,但同时也相当复杂,需要至少一个月左右的时间来掌握才能开始为本课程编写代码。因此,作为替代方案,我们决定设计一个非常简单的 HDL 方言或版本提供给大家。它拥有我们在本课程中构建计算机所需的全部功能,并且你可以在一个小时内学会它。
上一节我们讨论了用于描述硬件的语言。本节中,我们来思考一个设计问题:我们目前构建的芯片都相当简单。那么,如何设计包含数百个部件和连接的复杂芯片呢?
事实上,设计复杂电路并没有简单的通用方法。这是一个复杂的设计挑战,需要人类的智慧才能做好。
以下是设计复杂芯片时可能用到的一些方法和思路:
- 存在许多在数字电路构造课程中学习的技术,例如卡诺图(Karnaugh Maps),它可以优化具有少量输入的门电路。
- 有时可以使用各种工具,例如所谓的硅编译器(Silicon Compilers),你指定所需的功能,硅编译器内部已经包含了许多可以为你优化门电路的逻辑和算法。
- 然而,这些都不是完美的算法,它们都是启发式方法,因为这类通用问题是所谓的 NP 完全问题,你无法找到一个能完美解决它的计算机程序。
归根结底,在使用了所有这些工具和技术之后,你最终还是要运用计算机科学的常用工具:模块化和抽象。你将一个复杂问题分解成更简单的部分,这些更简单的部分更容易优化和构建。在运用了所有工具和技术之后,最终还是要回归到模块化和抽象的理念上来。
以上就是我们在第一周的第一个视角单元中选择重点讨论的问题。正如你所见,这种视角单元的形式是开放式的,可以提出无数的问题。我们再次强调,我们不希望深入到晶体管和电阻的层面,这属于电气工程的范畴。我们希望专注于计算机科学。当涉及到硬件技术等问题时,也有很多我们无法回答的问题。我们欢迎任何可能出现的疑问,你可以将这些问题发布在课程的问答论坛上。如果有其他同学对这些领域有所了解,也欢迎你前往本课程网站的问答论坛自行解答这些问题。
总结:在本节课中,我们一起学习了构建计算机的不同逻辑起点,窥探了逻辑门的物理实现(NMOS 与非门),比较了教学用 HDL 与工业级 HDL 的异同,并探讨了设计复杂芯片所面临的挑战及核心方法论——模块化与抽象。我们始终聚焦于计算机科学的抽象层面,为后续构建更复杂的计算机部件打下基础。
013:13_03_04_宣传视频


概述
在本节课中,我们将介绍《从零开始构建现代计算机》课程的第一部分。这是一门面向所有人的课程,即使你没有任何计算机科学或工程背景,也可以学习。课程将通过六个项目,引导你从最基础的逻辑门开始,逐步构建出一台完整的通用计算机。
课程介绍
大家好,我是希伯来大学和IC Herzliya的计算机科学教授Shimon Shoquikin。
我是耶路撒冷希伯来大学和微软研究院的计算机科学教授Noam Nisan。
我们非常高兴地向大家介绍《从零开始构建现代计算机》课程的第一部分。
任何人都可以学习这门课程,包括没有计算机科学或工程背景的人。
课程结构与项目
课程包含六个每周项目,每个项目专注于构建计算机硬件的一个不同模块。
在每个项目中,你需要构建一组芯片。对于每一个芯片,我们会提供完整的芯片规格说明,然后你需要利用之前已经构建好的芯片,来设计实现这个芯片的功能。
但请放心,你不需要进行任何焊接工作。相反,你将使用硬件模拟器来构建所有芯片,就像硬件工程师在实际工作中所做的那样。
项目内容详解
在课程的前三个项目中,我们将从一个最基本的逻辑门——Nand门开始。通过详细的项目材料,我们将指导你构建一个芯片组、一个中央处理器(CPU)和一个内存单元。
在接下来的两个项目中,你将把所有之前构建的芯片集成起来,组装成一台完整的通用计算机,我们将其命名为Hack计算机。
然后,我们会将这台计算机连接到键盘和屏幕上。接着,计算机就会开始运行。
在课程的最后一个项目中,我们将开发一个汇编器。这个工具允许我们使用符号命令而不是二进制代码来编写程序。这将使我们能够在Hack平台上运行各种酷炫的程序,比如俄罗斯方块,以及你能想到的几乎所有其他程序。
课程总结与要求
以上就是从Nand门到俄罗斯方块的六周旅程。课程每周大约需要5到10小时的学习时间,并且不要求任何计算机科学或工程背景。
学习本课程和构建计算机所需的所有知识都将在课程中提供。


所以,如果你希望理解计算机是如何工作的,以及它们是如何被设计出来的,那么就请注册课程,让我们一起构建一台计算机吧!


014:二进制数

概述
在本节课中,我们将要学习计算机如何仅使用0和1这两个值来表示更复杂的信息,特别是整数。我们将探讨二进制数的表示原理,学习如何在二进制和十进制之间进行转换,并理解使用固定位数表示数字时的范围限制。
二进制表示基础
上一周我们学习了如何在计算机中操作比特位(0和1)。但仅凭0和1,我们能做什么呢?我们当然可以用它们来表示更复杂的事物,这是让计算机执行有用任务的必要条件。
仅有两个值,我们能做什么?我们可以将多个比特组合在一起。两个比特组合,可以得到四种可能性(00, 01, 10, 11)。三个比特组合,可以得到八种可能性。一般来说,n个比特组合在一起,就能表示 2^n 种不同的可能性。这意味着我们可以用比特序列来表示任何我们想表示的事物,例如数字。这正是我们接下来要关注的重点:如何表示整数。
从十进制到二进制:理解位置系统
0和1的表示很简单。但表示数字2时,我们遇到了第一个问题,因为单个比特只有两种状态。因此,我们需要使用两个比特:10。数字3是11。要表示数字4,我们就需要三个比特:100。
那么,比特序列(二进制表示)是如何与整数值联系起来的呢?其背后的通用系统是什么?要理解这一点,我们需要回顾小学二年级学习的十进制系统。
当我们看到十进制数789时,数字7、8、9与数值789有什么关系?我们学过位置系统:最右边的数字是个位,接着是十位,然后是百位。因此,789实际上是 9 + 810 + 7100。一般来说,从右数第k位(从0开始计数)的权重是 10^k。
二进制数的情况完全相同,但更简单,因为我们只有0和1,而不是0到9的所有数字。因此,在二进制表示法中,不同位置的权重是2的幂:1, 2, 4, 8……以此类推。
例如,二进制数101的值是多少?我们知道最右边的比特(位)代表个位(20)。下一个比特`0`代表2的位(21)。最左边的比特1代表4的位(2^2)。所以,其值是 4 + 02 + 11 = 5。
通用转换公式
一般来说,对于任意比特序列,我们从右向左为比特编号,最右边的比特为 b0,下一个是 b1,依此类推,直到 bn(假设我们有n+1个比特)。
这个比特序列所表示的数值计算公式如下:
值 = b0 * 2^0 + b1 * 2^1 + b2 * 2^2 + ... + bn * 2^n
这就是我们将任何比特序列(数字的二进制表示)转换为其对应数值的方法。
比特数与表示范围
此时我们应该注意,如果我们用 k 个比特来表示数字,能表示的最大数字是多少?我们需要将从20到2(k-1)的所有权重相加(因为比特索引从0开始,最后一个比特的索引是k-1)。
这个和是 1 + 2 + 4 + ... + 2^(k-1)。这个等比数列的和等于 2^k - 1。因此,使用k个比特,我们能表示的数字范围是0到 2^k - 1。
计算机中的固定位数表示
到目前为止,我们假设可以使用任意长度的比特来表示数字。当然,如果要表示任意大的数字,就需要任意多的比特。但在计算机中,通常分配的是固定数量的比特,因此只能表示固定范围内的整数。
例如,如果我们只有8个比特(1个字节),能表示哪些数字?我们可以表示从00000000到11111111的所有序列。总共有 2^8 = 256 种可能性。最小的数字(对应00000000)是0,最大的数字(对应11111111)是255。
但实际情况并非完全如此。通常,我们希望能用这8个比特表示负数。我们将在下一单元详细讨论这个问题。一般来说,这256种可能性中,大约一半会被预留用来表示负数,因此我们只能使用0到127之间的数字来表示正数。
从十进制到二进制的转换
前面我们看到了如何将比特串转换为十进制数。现在,我们来做相反的事情:假设给定一个十进制数,例如87,如何将其表示为比特序列?如果我们要在十进制记法和计算机中实际表示的二进制数之间来回转换,这也是我们必须掌握的技能。
我们知道,从二进制得到十进制是通过对2的幂次求和。因此,我们可以反向操作:找出能放入数字87的最大2的幂次,即64。然后,在64的基础上,找出下一个能加上去且总和不超过87的2的幂次,即16。依此类推。
我们可以将数字87写成2的幂次之和:
87 = 64 + 16 + 4 + 2 + 1
从这个将十进制数表示为2的幂次之和的形式,我们可以直接得到二进制表示。方法如下:在求和式中出现的幂次所对应的比特位上,我们放置1;未出现的幂次对应的比特位上,我们放置0。
例如,最右边的比特位(对应20,即1)在求和式中存在,所以该位是`1`。从右数第三位(对应23,即8)在求和式中不存在,所以该位是0。按照这个规则,87的完整8位二进制表示是01010111(从高位到低位对应27到20,其中64=26,16=24,4=22,2=21,1=2^0)。
这基本上是将任何数字转换为二进制的一种通用方法。
总结
本节课我们一起学习了如何在二进制系统中表示整数。我们探讨了二进制位置系统的原理,学习了使用公式 Σ(bi * 2^i) 在二进制和十进制之间进行转换,并理解了使用固定数量比特(如8位)时所限制的数值表示范围。

在下一单元中,我们将实际讨论如何对这些以二进制表示的数字执行算术运算,特别是加法运算。掌握这一点后,我们将在第三单元回过头来讨论负数表示的问题。
015:二进制加法

概述
在本节课中,我们将学习如何在计算机中实现二进制数的加法。我们将从回顾二进制表示法开始,逐步理解并构建一个能够直接对二进制数进行加法的硬件电路。核心在于理解并实现半加器、全加器,最终将它们组合成一个完整的16位加法器。
二进制加法原理
上一节我们介绍了如何在计算机中使用二进制位来表示数字。现在,我们有了数字的表示方法,接下来自然要学习如何操作它们。
对于数字,我们通常希望进行加法、减法、乘法等运算。本单元我们将重点学习如何实现加法。一旦掌握了加法,我们几乎可以免费获得其他运算。例如,在理解了负数的表示(下个单元的内容)后,减法也能轻松实现。乘法和除法更为复杂,但幸运的是,我们可以将它们推迟到软件层面实现,而不必在硬件中构建专门的电路。
那么,当我们有两串二进制数时,如何将它们相加呢?
一种已知的方法是:先将它们转换为十进制数,进行我们从小就会的十进制加法,得到结果后再转换回二进制。这种方法虽然可行,但计算机并非如此工作。计算机不会先将二进制数转换为十进制再进行加法。因此,我们需要学习如何直接对二进制数进行加法运算。
我们该如何做呢?和往常一样,我们需要的一切知识其实在小学二年级就学过了。让我们回顾一下如何做十进制加法,例如 5783 + 某个数。
我们是这样做的:
- 从最右边的数字开始相加。
3 + 6 = 9,这很简单。- 接下来处理下一位:
8 + 5 = 13。我们不能在十位上直接写下13,因为13大于10。于是我们学会了那个重要而巧妙的技巧:写下3,并将1作为进位带到百位。 - 然后我们将进位
1加到7上,并继续这个过程。 - 处理完最左边的数字后,我们就得到了完整的结果。
我们将对二进制数做完全相同的事情,而且过程会简单得多。
以下是二进制加法的步骤:
- 取最右边的两个位相加,例如
1 + 0 = 1,直接写下结果。0 + 0 = 0也同样简单。 - 第一次遇到问题是在
1 + 1时,因为1 + 1 = 2,而一个二进制位无法表示2。于是我们采用同样的技巧:写下0,并将1作为进位带到下一位。 - 继续这个过程:我们需要将进位
1与下一位的两个数相加(例如1 + 0 + 1),结果又是2,所以我们写下0,并产生一个新的进位1。 - 再下一位可能是
1 + 1 + 1 = 3,我们写下1,并产生进位1。 - 如此反复,直到得到最终答案。
这就是二进制加法的全部原理。本单元的剩余部分将学习如何完全机械化地实现这个过程,用我们迄今所学的二进制运算来理解每一步操作。
溢出问题
在继续之前,我们需要先解决一个潜在问题:溢出。
假设我们很不走运,要相加的两个数的最高位都是1。问题在于,当我们将它们相加时,可能会产生一个需要进位到“字长”之外的进位位,但我们的字长是固定的,没有地方存放这个进位位。
那么,计算机会怎么做呢?通常会发出警告吗?答案很简单:在计算机系统中,通常什么也不做,我们直接忽略任何无法放入字长的进位位。
从数学角度来看,这意味着我们硬件中实现的加法并非真正的整数加法,因为我们无法处理超出字长范围的数字。实际上,我们实现的是模 2^n 的加法,其中 n 是字长。
换句话说,得到的结果是正确的,除非真实结果超过了 2^n。如果真实结果大于 2^n,硬件会自动减去 2^n(这基本上就是我们丢弃的进位所代表的值)。因此,任何使用计算机和软件的人都需要记住,如果结果超出了字长范围,你得到的将不是整数加法的真实结果,而是溢出被处理后的截断结果。
解决了溢出问题,让我们回到正题,看看如何实际构建这种加法器。
构建加法器:三步走
我们如何构建一个硬件,它接收两串二进制位作为输入,并输出代表这两个输入数之和的另一串二进制位呢?
我们将分三个简单的阶段来完成:
- 第一阶段:学习如何将两个位相加。
- 第二阶段:学习如何将三个位相加(包含一个进位输入)。
- 第三阶段:通过一个大的飞跃,实现任意位数的两个数相加。
这看起来进展缓慢,但请放心,第三步将让我们一次性完成任意位数的加法。
第一步:半加器
让我们看看在刚才描述的加法过程中,两个位相加的典型操作。例如,我们处理 1 + 1,得到了和为0,进位为1。
最重要的是注意到,当我们只处理这一个“位片”的操作时,这两个数的其他所有位都无关紧要。只要我们现在相加的是 1 + 1,并且到目前为止的进位是0,那么无论其他位是什么,我们都会进行完全相同的操作:在这两个1下面写下0,并产生一个进位1。
这告诉我们,现在这只是一个简单的二元运算:取两个位 A 和 B,产生两个仅依赖于它们的输出位。其中一个输出我们称为和,另一个称为进位。这是我们实现加法的第一步,它允许我们相加两个位。
这个操作很自然地可以被抽象为一个芯片。该芯片有两个输入 A 和 B,两个输出。对于输入的每一种组合,我们都确切知道输出应该是什么。这个芯片被称为半加器。
在实现上,半加器的逻辑可以用以下布尔表达式描述:
- 和 =
A XOR B - 进位 =
A AND B
实现这个芯片是本周练习的第一项任务。事实上,我们会提供描述该芯片接口的HDL代码,你只需要完成实现其内部逻辑。
至此,我们完成了加法之旅的第一步:相加两个位。
第二步:全加器
回想一下,我们在做两个位相加时,唯一的限制是“到这一点的进位为0”。但这并非普遍情况。更一般的情况是,可能有一个来自上一位的进位。
假设现在我们有一个额外的输入位 C,它表示来自前一步的进位,可以是0或1。现在,我们如何进行这个加法呢?我们知道需要将这三个数相加,仍然得到一个和与一个进位。
这样,我们又得到了一个布尔门电路,或者说一个芯片。它有三个输入 A、B、C,两个输出(和与进位)。输入有八种可能性,对于每一种,我们都能确切知道输出是什么。这个芯片被称为全加器。
同样,你可以去实现它。这构成了我们加法之旅的第二步。我们也会提供这个芯片的HDL描述,请继续实现它。
第三步:16位加法器
现在,我们准备好进行最后一步,完成所有工作:将两个完整的数字相加。
我们如何做到这一点呢?我们已经知道如何执行加法过程中的每一个单步操作,所以只需要重复执行这个单步操作即可。
让我们用颜色来标记不同的位以便说明(实际实现中当然没有颜色,只有不同的位):
- 从最右边的黄色位开始相加。由于此时还没有进位,这只是一个半加器操作。我们得到一个黄色的和与一个黄色的进位。
- 下一步,我们需要将黄色的进位与两个绿色的位相加。由此,我们得到一个绿色的和与一个绿色的进位。
- 接着,将绿色的进位与两个蓝色的位相加,以此类推。
每一个带颜色的步骤,本质上就是一个全加器。例如,将黄色进位、两个绿色位作为输入,输出绿色进位与绿色和,这正是一个全加器,而我们已经实现了它。
因此,要实现整个加法器,我们只需要将16个这样的全加器(或者15个全加器加1个最右边的半加器)以正确的方式连接在一起。这样你就得到了一个完整的加法器。
这就是我们要构建的16位加法器。它接受两个数字作为输入,每个数字是一个16位的总线,并输出一个16位的总线,代表这两个数以二进制补码形式表示的和。
同样,我们会提供这个加法器的HDL框架,你只需要去实现它。
总结与展望
本节课中,我们一起学习了如何在非常具体的意义上实现两个数的加法,即构建真正执行加法的芯片。
我们首先回顾了二进制加法的基本原理,它与我们熟悉的十进制加法类似,只是基数变为2。接着,我们探讨了加法中可能出现的溢出问题,并理解了硬件处理溢出的方式。
然后,我们通过三个步骤系统地构建了加法器:
- 半加器:用于相加两个位,产生和与进位。
- 全加器:用于相加三个位(包括一个进位输入),是构建多位加法器的核心模块。
- 16位加法器:通过串联多个全加器(和/或半加器),实现了对两个16位二进制数的加法。
下一单元,我们将回过头来解决上单元遗留的问题:如何表示负数。一旦完成,我们将能“免费”获得减法运算。

在掌握了加法和减法之后,我们将进入本周课程的高潮:构建一个完整的算术逻辑单元。事实证明,ALU中最巧妙的部分——加法——我们已经完成了。当然,我们还需要围绕它构建许多其他逻辑电路,这将是第四单元的内容。
016:负数表示

概述
在本节课中,我们将要学习如何在计算机中表示负数。到目前为止,我们只讨论了正整数的二进制表示和加法运算。然而,计算机必须能够处理负数。本节将介绍一种称为“二进制补码”的表示方法,它使我们能够使用已有的加法电路来处理负数,而无需设计新的硬件。
从正数到负数
上一节我们介绍了如何使用比特位表示和操作正整数。本节中我们来看看如何表示负数。
以4个比特位为例。我们知道4个比特可以表示16种不同的值。到目前为止,我们使用这4个比特来表示0到15之间的正整数。一般来说,对于 n 个比特,我们用它来表示 0 到 2^n - 1 之间的正整数。
为了表示负数,我们需要放弃这16种可能性中的一部分,用它们来表示负数。例如,可能用其中8个表示正数,另外8个表示负数。
符号位表示法
以下是实现负数表示的一种简单思路,有时也被使用。
这种方法的基本思想是使用最高有效位(最左边的比特)作为符号位。剩余的比特(n-1 个)用来表示实际的数值。
- 如果符号位是
0,则表示一个正数。 - 如果符号位是
1,则表示一个负数,其数值由剩余的比特决定。
在这种方案下,我们可以表示 0 到 7 的正数,以及 -0 到 -7 的负数。
然而,这种方法并不流行,因为它存在一些问题。最明显的问题是它存在“负零”(-0)这个概念。在数学中,0 等于 -0。在计算机中拥有两种零的表示方式既不优雅,也容易在硬件操作中引发麻烦,因为电路需要额外处理正负号。因此,这种方法已很少使用。
二进制补码表示法
人们现在普遍使用一种称为“二进制补码”的系统。它的核心思想非常简单。
如果你想表示一个负数 -x,你实际上表示的是正数 2^n - x。对于4个比特的情况,n=4,2^4 = 16。因此,-x 由 16 - x 这个正数来表示。
例如,要表示 -3,我们计算 16 - 3 = 13。因此,在二进制补码中,-3 由二进制数 1101(即十进制的13)来表示。
让我们看看在这种表示法下我们得到了什么。
- 正数范围:我们能够表示的正数比之前少了一半。具体来说,我们只能表示从
0到2^(n-1) - 1的数。对于4比特,就是0到7。 - 负数范围:我们能够表示从
-1到-2^(n-1)的负数。对于4比特,就是-1到-8。 - 零:
0的表示是唯一的。
这种表示法最巧妙的地方在于,我们几乎可以“免费”获得加法和减法等运算功能。
补码加法的魔力
接下来我们看看这意味着什么。假设我们想用之前为正数设计的加法电路来相加两个负数。
例如,计算 -2 + (-3)。
- 在4比特补码中,
-2表示为14(16-2)。 -3表示为13(16-3)。- 我们用标准二进制加法计算
14 + 13。
1110 (14)
+ 1101 (13)
-----------
11011 (27)
我们的加法电路会输出低4位 1011(即十进制的11),并丢弃最高位的进位(绿色部分,代表数值16)。
神奇之处在于:11 在4比特补码中代表的正是 -5 (16-11=5),而这恰好是 -2 + (-3) 的正确结果。
为什么会这样?因为我们的加法本质上是模 2^n 的加法(丢弃溢出位),而补码表示法本质上也是一种模 2^n 的表示。由于表示和运算遵循相同的模运算规则,它们完美契合。因此,为正数设计的硬件,无需任何修改,就能直接处理补码形式的负数加法。
求补与减法运算
我们可能还需要一个功能:给定一个数 x,求它的负数 -x(即求其补码)。
为什么需要这个?原因之一是,我们还没有看到执行减法的电路。一旦我们能计算 -x,减法问题就迎刃而解,因为 y - x 等价于 y + (-x)。而加法我们已经知道如何做了。
在二进制补码中求负数的数学技巧非常简单:
-x 在补码中等于 2^n - x。
我们可以将其重写为:-x = 1 + (2^n - 1 - x)。
这看起来可能很复杂,但它带来了一个巨大的好处:(2^n - 1) 这个数用二进制表示就是一连串的 1(例如,4比特下是 1111)。从一个全是 1 的数中减去 x 非常容易——你只需要将 x 的每一个比特取反(0变1,1变0)即可,完全不需要借位操作。
因此,求补码的步骤可以简化为:
- 按位取反:将输入
x的每一个比特翻转。 - 加1:将取反后的结果加上
1。
我们来看一个例子,如何将 4 转换为其补码形式 -4(4比特下)。
- 输入
x=0100(4)。 - 按位取反:得到
1011。 - 加1:
1011 + 1 = 1100。 - 结果
1100是十进制的12,在4比特补码中代表-4(16-12=4)。
加1这个操作本身也可以优化。从最右位开始,不断翻转比特(1变0并产生进位,0变1并停止),这是一个非常简单的硬件操作。
总结
本节课中我们一起学习了计算机中负数的表示方法。
我们首先看到简单的“符号位”表示法存在缺陷。然后,我们深入学习了二进制补码这种强大的表示方法。它的核心优势在于,我们无需为负数设计新的加法或减法硬件。已有的正整数加法电路,配合补码表示法,就能自动处理包含负数的运算。求一个数的补码(即求负)也可以通过简单的“按位取反再加1”操作来实现。

至此,我们已经掌握了表示和处理整数(包括正数和负数)所需的所有关键概念,为接下来设计计算机的算术逻辑单元(ALU)做好了准备。
017:算术逻辑单元 (ALU) 🧮

在本单元中,我们将探讨通用计算机中一个至关重要的组件——算术逻辑单元,简称 ALU。
概述
在本节课中,我们将学习算术逻辑单元 (ALU) 的基本概念、功能及其在计算机架构中的核心地位。我们将深入分析一个具体的 ALU 设计——Hack ALU,并通过实例理解其工作原理。
ALU 简介
1945年,伟大的数学家约翰·冯·诺依曼撰写了一篇开创性论文,其中描述了如何构建通用计算机。这后来被称为冯·诺依曼架构。在该架构图中,中央处理器 (CPU) 是关键部分,而 ALU 又是 CPU 内部的核心组件。
如果我们忽略其他细节,只关注 ALU,可以将其抽象为一个接收两个多比特输入(称为输入1和输入2)以及第三个决定计算功能的输入的组件。ALU 计算该功能并输出结果。
ALU 的功能
函数 F 是一系列预定义函数中的一个,这些函数共同定义了 ALU 的能力。其中一些是算术函数,另一些是逻辑函数。例如,ALU 通常执行整数加法、整数乘法、整数除法等算术运算,以及按位与、按位或等逻辑运算。
这里有一个有趣的问题:在设计 ALU 时,你希望在这个硬件设备中内置多少功能?这是一个经典的硬件与软件权衡问题。因为如果你选择不在硬件中实现某个功能,以后总可以通过软件来补充。例如,如果 ALU 不包含乘法或除法功能,那么在构建软件层时,可以通过软件来完成这些功能。
Hack ALU 介绍
到目前为止,我们讨论的内容适用于任何计算机上的 ALU。从现在起,我们将专注于一个具体的 ALU 示例,我们称之为 Hack ALU,因为它将用于我们构建的 Hack 计算机内部。
这是 Hack ALU 的整体接口图。如图所示,ALU 有两个 16 位数据输入,我们称之为 X 和 Y。它输出一个 16 位输出,我们称之为 out。
计算哪个函数由 6 个控制位决定,这些控制位有奇怪的名字,如 zx、nx 等。我们稍后会解释这些名字。基于这六个控制位,ALU 会计算以下 18 个我们感兴趣的函数之一。
我们称这些为“感兴趣的函数”,因为原则上 ALU 可以计算更多函数,但我们决定只关注这 18 个。其中一些函数非常简单,如常数 0、1、-1、X、Y 等,而另一些函数则更复杂和有趣,如 X + Y、X & Y 等。
如图所示,ALU 还会计算并输出两个 1 位的控制输出,称为 zr 和 ng。这两个控制位的作用及其名称的由来将在本单元后面变得清晰。
ALU 的功能规格
现在,让我们继续关注 ALU 的输出,以及导致 ALU 计算这些输出的控制位。它们通过一个真值表来驱动 ALU。
这个真值表提供了 ALU 完整的功能规格。也就是说,如果你想计算某个函数,可以在表的右侧查找它,然后读出对应于该函数的 0 和 1 序列,将这些 0 和 1 输入到控制位中,ALU 就会通过某种“黑魔法”计算出所需的函数。
ALU 工作原理演示
让我通过硬件模拟器来演示 ALU 的实际工作过程。我们启动模拟器,加载内置的 ALU 芯片。加载后,我们会在 HDL 窗口中看到这个芯片。同时,我们还会得到一个很好的图形用户界面。确实,我们模拟器中的一些内置芯片具有 GUI 副作用,可以帮助用户理解相应芯片内部的情况。这个图表是我们制作的,旨在帮助你跟踪 ALU 内部发生的情况。
我们开始测试。如图所示,我们将 ALU 的输入设置为两个值:30 和 20。同时,我们将控制位设置为 0, 0, 0, 1, 1, 1。如果你查看我之前展示的表格,你会发现这意味着指示 ALU 计算 Y - X。
接下来,我们需要告诉模拟器实际执行计算。我们通过点击计算器图标来实现。这会触发 ALU 根据给定输入评估芯片逻辑。然后我们可以检查输出。确实,我们看到 ALU 输出了预期的结果 -10,即 Y - X 的结果。至少在这个例子中,ALU 工作正常。
让我再举一个例子,展示 ALU 的逻辑计算能力。首先,我告诉模拟器恢复到使用布尔格式,而不是十进制格式,这样更容易将 0 和 1 输入到 ALU 的各个输入中。我们在这里这样做。我选取了两个任意的 16 位 0/1 值示例并输入。然后,我也输入了控制位值 0, 0, 0, 0, 0, 0,这恰好是指示计算 X & Y 的指令。再次查看真值表,你会看到这一行。确实,在我们点击计算器图标后,我们看到 ALU 实际上计算了按位与操作,结果是两个给定输入的按位与。到目前为止,至少在这两个例子中,ALU 似乎运行得相当好。
揭开 ALU 的“黑盒”
我们还没有说明 ALU 实际上是如何工作的。到目前为止的一切都像是魔法。现在是时候打开黑盒子,理解 ALU 是如何实现这种魔法的了。
再次强调,这是 ALU 的接口图。我想重点解释这六个控制位的名称和每个位的操作。
zx控制位:如果zx等于 1,我们要做的是将X输入设置为 0。无论X是什么值,我们都将其设为 0。nx控制位:如果nx等于 1,我们将X输入设置为not X(按位取反)。请注意,这两个操作是顺序发生的。例如,如果zx = 1且nx = 1,首先将X输入置零,然后对其取反,结果会得到全 1(1111...)。zy和ny控制位:对Y输入进行完全相同的操作,分别使用zy和ny指令。f控制位:如果f等于 1,我们计算X + Y。如果f等于 0,我们计算X & Y。注意,这里的X和Y是经过上述处理后的值。在进行这些计算之前,X和Y可能已经经历了我们之前讨论的那些操作(置零、取反或保持不变)。no控制位:如果no位等于 1,我们对刚刚计算出的结果输出进行取反。如果no等于 0,我们保持原样。
如果我们按顺序执行所有这些操作,那么最终输出的就是所需的函数。
验证真值表
现在你理解了这种语义,你可以查看表格并尝试说服自己。你可以证明这个表格能产生所需的结果。我将演示如何做到这一点。
让我们选取一个例子,看看 ALU 如何计算 not X。我在右侧查找 not X,看到了它,然后查找六个控制位的二进制值,并开始在纸上模拟 ALU 内部发生的情况。
为了做到这一点,我必须想出一些任意的 X 和 Y 示例。我编造了两个值,并使用 4 位而不是 16 位来减少繁琐。我任意选择了这两个示例 X 和 Y。然后我查看控制位:zx = 0,nx = 0,这意味着我们不触碰 X 输入,保持原样。
接着我们处理 Y 输入,我们看到 zy = 1,所以我们将 Y 输入置零,然后 ny = 1,所以我们对其取反,得到结果 1111。继续,f = 0。如果 f = 0,我们要计算 X & Y。所以我们计算 X & Y,得到 1100(按位与)。最后,no = 1,所以我们取反结果,得到 0011。看,0011 正是 not X。如果你看原始的 X,它是 1100,我们得到了 not X。因此,我们证明了真值表中的这一行确实如广告所说那样工作。
让我们再举一个例子,这将是最后一个例子。我们看看算术运算 Y - X。再次,我们看到二进制值,并开始模拟它们。同样,我们以两个任意的 X 和 Y 示例开始,我选择了 2 和 7。这是算术运算,所以用十进制和二进制来思考更容易。我们有 2 和 7。如果一切正常,我们应该得到结果 5,因为 Y - X(7 - 2)应该得到 5。
我们看到 zx = 0,nx = 0,所以我们不触碰 X 输入,它保持不变。继续,我们看到 zy = 0,所以我们不触碰 Y 输入。但 ny = 1,这意味着我们必须对其取反。所以 0111 变成了 1000。继续,我们看到 f = 1,所以我们计算加法。如果我们把 X 和 Y 加起来,得到 1010。no 也等于 1,所以我们取反结果,得到 0101,而 0101 代表 5,这正是我们想要的。再次,你看到 ALU 按照规格执行。
你们中的许多人可能仍然想知道这个魔法实际上是如何发生的。我们被告知要做减法,但实际上我们做了加法,却得到了我们期望的结果。我们不想深入探讨太多,但如果你回顾我们讨论二进制补码方法的单元,你也会理解这里的内部机制,以及为什么一切都能如预期般实现。
ALU 的状态输出位
正如我们在本单元前面所说,ALU 还会计算并输出两个 1 位的控制输出位,这些位称为 zr 和 ng。这些位的作用是说明 ALU 的主要输出(记为 out)的某些特性。具体来说:
- 如果
out等于 0,ALU 将zr设置为 1,否则zr变为 0。 - 如果
out是负数,则ng等于 1,否则ng等于 0。
你可能会问,为什么我们需要这两个位?当我们组装完整的计算机架构时,这两个位将扮演重要角色,届时这一点会变得清晰。
关于 Hack ALU 的总结
关于 Hack ALU,我想做几点总结。我希望我们已经让你相信,这是一个简单的概念。它非常优雅,而且出人意料地,实现起来也极其容易。
让我解释一下原因。如果你还记得我们如何使用这些控制位操作 ALU:在某些情况下,我们必须将 16 位值设置为零,这很容易。在其他情况下,我们必须将其设置为全 1,这也非常容易。在某些情况下,我们必须对输入值取反,我们以前做过,我想在项目 1 中,我们构建了一个正好执行 16 位取反的门。在其他一些情况下,我们必须计算加法或按位与,这两种计算已经由你在之前项目中设计的芯片处理好了。
总而言之,需要做的事情非常少。所有功能都以某种方式由你已经开发的现有芯片完成了。
总结
列奥纳多·达·芬奇是历史上最伟大的发明家之一。他说过:“简单是终极的复杂。”我希望我已经让你相信,Hack ALU 既简单,又相当复杂。
本节课我们一起学习了算术逻辑单元 (ALU) 的核心概念,重点分析了 Hack ALU 的设计、功能规格和工作原理。我们通过真值表和具体示例,理解了其六个控制位如何协同工作以执行各种算术和逻辑运算,并了解了其状态输出位 zr 和 ng 的作用。

这引出了下一个单元,在下一单元中,我们将亲自动手,实际构建一个这样的 ALU,以及一些其他芯片。
018:项目2概述 🧩

在本节课中,我们将学习项目2中需要构建的所有芯片,并提供一些实现指南。我们将从最简单的半加器开始,逐步构建更复杂的组合逻辑芯片,最终完成一个功能完整的算术逻辑单元。
上一节我们介绍了组合逻辑芯片的基本概念,本节中我们来看看项目2的具体任务。
项目2芯片概述
在项目2中,你需要构建一系列组合逻辑芯片。你可以使用在项目1中已经构建好的所有芯片作为基础模块。利用这些模块,你需要构建以下五个芯片,从半加器到ALU,构成一个从简单加法器到更复杂芯片的家族。
以下是需要构建的芯片列表:
- 半加器:最简单的加法器芯片。
- 全加器:能处理三个输入位的加法器。
- 16位加法器:具有工业级加法能力的芯片。
- 增量器:将输入值加1的专用芯片。
- 算术逻辑单元:项目中最核心、最复杂的芯片。
芯片详解与实现指南
半加器
半加器是加法器家族中最简单的芯片。它接收两个比特位作为输入,将它们相加,并输出这两个位的和以及可能产生的进位位。
其功能可以用以下真值表描述:
| 输入 a | 输入 b | 和 sum | 进位 carry |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 1 | 1 | 0 |
| 1 | 0 | 1 | 0 |
| 1 | 1 | 0 | 1 |
观察这个真值表,你会发现和与进位的输出列,与我们在项目1中已经构建的两种逻辑门的输出完全一致。因此,构建这个门电路相当简单:你只需要选取两个已经构建好的门电路,以某种方式连接它们,就能获得半加器的功能。这很有趣,因为我们用逻辑设备实现了一个语义上是加法操作的功能,这在数字设计中很常见。
全加器
全加器在功能上比半加器更强大一些。它能够对三个输入位进行求和,并输出这三个位的和以及进位。
以下是全加器的真值表:
| 输入 a | 输入 b | 输入 c | 和 sum | 进位 carry |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 1 | 0 |
| 0 | 1 | 0 | 1 | 0 |
| 0 | 1 | 1 | 0 | 1 |
| 1 | 0 | 0 | 1 | 0 |
| 1 | 0 | 1 | 0 | 1 |
| 1 | 1 | 0 | 0 | 1 |
| 1 | 1 | 1 | 1 | 1 |
通常,你可以使用两个半加器,再加上一些其他逻辑门作为“粘合剂”来组合这两个半加器的操作,从而构建一个全加器。这也是之前那个芯片被称为“半”加器的原因之一——因为它需要两个,再加上一些其他功能,才能实现一个“全”加器的功能。当然,这不是实现全加器的唯一方式,你可以采用任何有意义的HDL实现。
16位加法器
现在,我们开始构建具有工业级加法能力的芯片:16位加法器。思考一下,它可以很容易地由一系列全加器构建而成。
具体方法是:将16个全加器依次排列,并将一个加法器的进位位输出,连接到下一个更高有效位加法器的输入之一(通常是从右向左连接)。需要注意的是,根据芯片规范,最高有效位的进位位直接被忽略。
增量器
增量器是加法器的一个简单变体。它接收一个名为in的单一输入,将该输入值加1,然后输出结果。
其存根文件很直接。你可以使用已经构建好的芯片来构建这样一个增量器。在此提醒一下,在HDL中,你可以分别使用关键字false和true来表示单比特值0和1。
算术逻辑单元
最后,我们来到本项目中最有趣的芯片:ALU。我们在上一个单元已经详细讨论过ALU,但为了重申其主要功能,这里再次给出存根文件中的相关部分,它记录了ALU应该执行的操作。
所有操作都是我们之前使用已实现芯片完成过的,现在你需要将所有功能组合在一起,以交付所需的ALU功能。
两个有用的提示:
- 你可以使用一个16位加法器以及项目1中构建的各种芯片来构建这个ALU。
- 你可以用少于20行的HDL代码完成上述所有功能的构建,这再次体现了这个非平凡芯片的简洁与优雅。
资源与最佳实践
在资源方面,你需要访问Nand2Tetris网站获取项目2的完整描述。再次提醒,如果你已经下载了课程软件套件,在课程期间就无需下载其他任何东西。你的计算机上现在有一个名为projects/02的目录,其中包含了项目2所需的所有文件。我在项目1中描述的一些其他资源也与此项目相关。
最后是一些最佳实践建议,其中大部分重复了我在项目1中说过的话,因此不再赘述,但在开始项目之前你肯定需要仔细阅读。
对于项目2,我们有一些新的建议:最佳实践是不要使用你自己的HDL实现,而是使用其内置版本。这些内置版本在你使用我们的硬件模拟器时会自动可用。
让我重复并解释一下我们是如何做到的。如果你查看项目2的文件,你会发现它们只包含六个芯片(从半加器到ALU)。然而,我刚才告诉过你可以使用之前定义的芯片,如And、Or、Mux等。你之所以可以使用这些芯片,恰恰是因为它们没有被列出,也不在你的项目2目录中。因为模拟器的工作方式是:如果它试图评估一个芯片部件但在当前目录中找不到它,它会转而使用该芯片的内置版本。这种设置对我们的目的来说是理想的:你只需使用我们给你的芯片,并将任何之前定义的芯片用作部件,模拟器会在必要时自动调用其内置版本。


本节课中我们一起学习了项目2需要构建的所有芯片,包括半加器、全加器、16位加法器、增量器和ALU,并了解了实现它们的基本思路和最佳实践。下一单元,照例在每周结束时,将是“视角”单元,我们将为你提供更多关于组合逻辑以及ALU在其他计算机中应用的信息。
019:视角与展望 🧭

在本节课中,我们将回顾本周构建算术逻辑单元(ALU)的过程,并探讨一些在组合逻辑和ALU设计中常见的问题与思考。我们将讨论芯片设计的标准性、ALU功能的取舍、性能优化策略,以及项目开发中关于使用内置芯片的建议。
上一节我们完成了ALU的构建,本节中我们来看看围绕这个设计可能产生的一些疑问与深入视角。
芯片的标准性
以下是关于我们已构建芯片是否属于行业标准的问题。
- 大多数芯片是标准的:我们在课程中构建的大多数门电路,如半加器(Half Adder)、全加器(Full Adder)、加法器(Adder),以及上周构建的多路复用器(Multiplexor)、异或门(Xor Gate)等,都是完全标准的计算机组件。
- ALU是简化的特例:我们的ALU设计极其简单。本课程的核心原则是强调简洁性,以便将所有内容融入一门课程。因此,我们的ALU在实现上进行了大幅简化,这与通常的ALU相比有些独特。
ALU的功能取舍
既然我们的ALU如此简单,一个自然的问题是:为什么它不包含乘法和除法等更多运算?
- 硬件实现的可行性:确实,完全可以用HDL编写直接对二进制位进行乘除运算的芯片,并且存在非常优雅的算法来实现。
- 系统设计的层次划分:在构建计算机系统时,其整体功能是在硬件和运行在其上的操作系统之间进行划分的。设计者可以自由决定在每个层次中放入多少功能。
- Hack计算机的设计决策:对于本课程将继续构建的Hack计算机,我们决定(正如Noam之前解释的)让硬件保持极简。像乘除法这样的运算将被委托给运行在该计算机上的软件来处理。
- 对程序员的透明性:在本课程的第二部分(项目2),我们将设计一个操作系统。该操作系统将包含一个数学库(Math Library),提供包括乘除法在内的各种有用数学运算。最终,编写程序的程序员不会感受到差异,他们无需关心某个代数运算是由操作系统还是硬件完成的,这对高级程序员是完全透明的。
- 性能与成本的权衡:当然,这里存在权衡。通常,用硬件实现的操作运行速度更快,但设计成本更高,制造更复杂的硬件单元也更昂贵。因此,我们决定构建具有简单ALU的Hack计算机,并在后续构建操作系统时进行功能扩展,这是基于成本效益的权衡。
加法器的性能优化
我们希望说服你,我们设计的ALU确实简单。那么,它的实现是否高效呢?
我们构建的几乎所有组件都是完全高效的,几乎没有进一步优化的空间。但有一个组件存在重要的优化可能,那就是加法器(Adder)。让我们看看加法器的主要问题以及如何改进。
让我们回顾加法器的实现。加法器内部由一系列全加器(Full Adder)串联构成。
每个全加器接收来自加法器输入的某些位。关键点在于,它的一个输出——进位(Carry)——会传递到下一个全加器。同样,这个全加器的进位输出又会传递到再下一个全加器,依此类推。
这种链式连接正是我们希望优化的关键所在。
让我们看看信号从输入到最后一个全加器最终输出需要“走”多远,即必须经过多少个门电路。信号需要进入第一个全加器,经过几个门,然后传递到下一个全加器,再经过几个门,如此反复。假设每个全加器内部有3到4个门。对于一个32位的加法器,从输入到输出可能会有 32 bits * (3 or 4 gates) 的延迟。
在实际系统中运行这样的硬件时,必须考虑这种延迟,因为信号需要时间遍历所有全加器实现中的电容器来完成充放电等过程。拥有如此长的链被认为是不利的,因为它实际上增加了延迟。
那么,可以怎么做来改进呢?如果想缩短这条链,或许可以换一种方式计算进入高位全加器的进位,而不是通过这条长链。
实际上,观察这里计算的逻辑,存在更高效、延迟更低的方法。你可以采用所谓的超前进位(Carry Look-Ahead) 技术。独立于这条长链来计算进位。这可能会重复一些计算工作,但至少最小化了延迟。与其让进位通过长链计算,不如为每个全加器以延迟最小的可能方式单独计算进位。这就是超前进位,它能让你以更快的速率运行这个芯片,因为延迟更短。
项目开发建议:使用内置芯片
我们要关注的最后一个问题是:为什么在项目2中推荐使用内置芯片,而不是我们在项目1中实际构建的芯片?
首先,你完全可以使用自己构建的芯片,我们绝不禁止。使用自己构建的芯片非常有意义,因为这正是本课程的核心——我们正在构建一个由不同构建层次组成的复杂机器。当你构建某一层时,使用之前构建的层次是完全合理的。
然而,有充分的理由说明你应该使用我们提供的内置芯片,而不是你自己构建的芯片。
最重要的原因是我们可以称之为局部故障(Local Failures) 的概念。其思想是,如果你使用内置芯片作为部件,并且在当前项目中出现了问题,那么你可以保证这个问题可以归因于仅在当前项目中产生的错误和问题,而不是之前项目中的问题。
这有时也被称为单元测试(Unit Testing) 的概念,即独立于系统其他部分测试每个单元。单元测试的原则与其他非常重要的原则(如抽象和模块化)相辅相成。这些原则共同意味着,一旦你完成了某个模块的构建,就可以将其放在一边,在构建更复杂的功能时,只需使用该模块的接口或API,而不再担心其实现细节。
这确实是管理复杂项目的唯一方法。通过遵循这些我们认为极其重要的原则,我们才能够将这个“从第一性原理构建现代计算机”的超雄心项目,仅在七周内完成。
此外,我们应该承认,我们的模拟器效率并不高,尤其是在处理更复杂的项目时。如果你运行基于之前项目构建的芯片,我们的模拟器将不得不处理大量的门阵列,速度会非常慢。如果你只使用之前芯片的定义(即规格说明),那么我们的模拟器将运行得更快,也更易于使用。事实上,这是另一个非常重要的技术原因,解释了为什么我们希望使用内置芯片。这个问题将在课程后期,当我们构建内存系统和更复杂的功能时变得更加突出。因此,这确实是使用基于软件的内置芯片的另一个充分理由。
本节课中,我们一起回顾了ALU设计,探讨了芯片标准性、功能取舍、性能优化(特别是加法器的超前进位技术)以及项目管理中单元测试和使用内置芯片的重要性。这些视角有助于我们理解硬件-软件协同设计的权衡,并为后续构建更复杂的计算机系统做好准备。
020:时序逻辑 ⏱️

在本节课中,我们将学习计算机如何处理“时间”这一概念。在前两周,我们构建的组合逻辑电路是“瞬时”工作的,输入一旦确定,输出就立即确定。然而,真实的计算机需要在时间中运行,能够按顺序执行任务并记住过去的信息。本节将介绍如何将连续的物理时间离散化,并引入“时序逻辑”的基本思想。
为何需要时间?🤔
上一节我们介绍了组合逻辑,它忽略了时间因素。本节中我们来看看为什么计算机必须考虑时间。主要有两个原因:
- 硬件复用:我们希望同一块硬件能反复用于计算。例如,一个加法器不应该只使用一次,而应该能在每次需要时都执行加法运算。
- 记忆功能:计算过程需要记住中间结果。例如,计算100个数字的总和时,需要不断累加并记住当前的和。
此外,还有一个我们必须妥善处理的问题:物理延迟。真实的电子信号变化需要时间,我们不能要求计算机以超过其物理极限的速度运行。
离散化时间:时钟 ⏰
在物理世界中,时间是连续的。但在计算机科学中,我们将其离散化,以便于思考和设计。
我们引入一个时钟,它是一个以固定频率振荡的信号。时钟的每一个周期被视为一个离散的、整数的时间单位(如时间=1,时间=2)。
- 在每个时间单位内,我们视系统为静态的,输入和输出保持不变。
- 在时间单位之间,输入可以改变,输出则根据新的输入(瞬时)计算。
例如,一个非门(Not gate):
- 在时间1,输入为1,输出为0。
- 在时间2,输入变为0,输出随之变为1。
公式表示:对于组合逻辑,输出 out(t) 是输入 in(t) 的即时函数:out(t) = f(in(t))
处理物理延迟 🧹
在现实中,信号变化并非瞬间完成。从逻辑时间1到逻辑时间2,电压需要时间上升或下降,逻辑门本身也存在传播延迟。
我们的解决方案是:确保时钟周期足够长。
- 在每个时钟周期开始时,允许输入变化,系统进入一个不稳定的“灰色区域”。
- 只要时钟周期比所有电路稳定下来所需的最长时间更长,那么在周期结束时,所有信号都会达到正确、稳定、一致的状态。
- 这样,我们只需关注每个时钟周期结束时的“逻辑状态”,而可以忽略周期内短暂的过渡和不一致。这让我们能够安全地在离散的时间步长中思考问题。
组合逻辑 vs. 时序逻辑 🔄
基于离散时间视角,我们可以区分两种逻辑:
- 组合逻辑:输出仅取决于当前时刻的输入。
out(t) = f(in(t)) - 时序逻辑:输出取决于过去时刻的输入(或系统自身的过去状态)。
out(t) = f(in(t-1), state(t-1))
时序逻辑使我们能够“记住”信息。例如,一个比特的状态在时间2的值,可以依赖于它在时间1的值。
状态的概念 💾
这是时序逻辑带来的一个强大视角:我们可以让输入和输出共享相同的硬件线路(如一根导线或一组总线)。
这根线上承载的值被称为状态。
- 在时间1,状态值为
a。 - 在时间2,状态值变为某个函数
F(a)。 - 在时间3,状态值变为
F(F(a)),即对前一个状态值再次应用函数F。
代码描述(伪代码):
state = initial_value // 初始状态
for each clock cycle:
state = function(state) // 新状态是旧状态的函数
这使我们能够构建可以随时间改变和记忆信息的电路,这是实现计算(如循环累加)的基础。
总结 📝
本节课中我们一起学习了计算机中“时间”的处理方式:
- 我们通过时钟将连续时间离散化为整数时间单位。
- 通过设置足够长的时钟周期,我们将物理延迟问题“扫到地毯下”,只关注每个周期结束时的稳定状态。
- 我们区分了组合逻辑(瞬时映射)和时序逻辑(依赖过去状态)。
- 时序逻辑引入了状态的概念,使得电路能够记忆信息并随时间演变。

在接下来的单元中,我们将具体介绍能够实现这种时序操作的芯片。
021:触发器 🔄

概述
在本节课中,我们将学习计算机系统中用于处理时间和顺序逻辑的核心元件——触发器。我们将了解其基本概念、工作原理,并学习如何利用它来构建能够“记住”信息的电路。
在上一单元中,我们讨论了计算机系统如何处理时间和顺序逻辑。本节中,我们将具体探讨实现这种功能所需的实际元件——芯片。
顺序逻辑的核心需求
回顾上一单元,我们通过离散时间单位来处理时间。在每个时间单位 T,我们希望电路的输出能依赖于当前输入以及前一个时间单位 t-1 的值。为了实现这种功能,我们缺少一个能“记住”信息的基本元件。
我们需要一种元件,能将一个比特的信息从时间 t-1 传递到时间 T。到目前为止,我们拥有的组合逻辑可以在单个时间单位内进行任何操作,但跨时间单位传递信息的能力仍然缺失。
触发器的引入
在两个连续时间单位的转换点,这个新元件必须记住前一个时刻的比特值。这意味着它必须具有状态,能够记住“0”或“1”。在物理实现上,它必须能在两种不同的物理状态之间切换。
这种能够在两种不同状态之间翻转的元件,被称为触发器。关键在于,这种翻转是它内部“记住”的状态,而不仅仅是当前输入的函数。它在时间单位之间保持记忆。
时钟D触发器
本课程将使用的基本触发器称为时钟D触发器。它有一个输入 in 和一个输出 out,其基本功能是记住上一个时间单位的输入,并在下一个时间单位输出。
用公式描述其行为:
out(t) = in(t-1)
这意味着,在任何时间单位 T,D触发器的输出是上一个时间单位 T-1 的输入值。因此,输出信号看起来像是输入信号向右移动了一个时间单位。
这个D触发器将是我们构建本课程中所有顺序逻辑的唯一基础元件。
顺序芯片的标识
在D触发器的符号底部,我们能看到一个小三角形。这表示它是一个顺序芯片,其输出不仅依赖于当前输入,还依赖于芯片内部保持的先前状态。这与我们之前所有的组合芯片(输出仅依赖于当前输入)形成对比。
从逻辑抽象的角度,我们只需关心这种时间依赖性。虽然在物理实现上,它需要接入一个将连续时间划分为离散单位的时钟信号,但在我们的抽象层次上,我们只将其视为一个具有状态记忆能力的原语。
触发器的实现视角
在本课程中,我们将D触发器视为一个完全原始的操作,就像与非门一样,是构建一切的基础。我们不会深入探讨如何用与非门构建触发器(尽管这在硬件课程中很常见),因为我们认为将组合逻辑(瞬时发生)和顺序逻辑(具有状态)在概念上清晰分离,更有利于理解计算机的逻辑设计。
构建范式:记忆与组合
现在,既然我们可以用D触发器记住一个比特,我们就可以构建其他一切。事实上,构建计算机逻辑有一个通用范式:通过基本的D触发器阵列来记忆信息,然后使用我们在前两讲中构建的组合逻辑来操作这些信息。
具体来说,通常的做法是:
- 我们有一个D触发器阵列,构成系统中的所有内存。
- 它们的输出会与当前时间单位的新输入一起,馈入某个组合逻辑电路。
- 组合逻辑的结果将决定D触发器在下一个时间单位的新状态。
这是一种通用的构建方法,无论是内存还是计数器。例如,对于一个计数器,我们用这些触发器记住一个数字,组合逻辑则执行加一操作。这样,每个时间单位,新值都比旧值大一。
从触发器到比特寄存器
现在,让我们看看如何用D触发器构建第一个实际的芯片——一个能永久记住一个比特的比特寄存器。
比特寄存器芯片的接口定义如下:
- 输入:一个数据位
in,一个加载位load。 - 输出:一个数据位
out。
其逻辑是:
- 如果在时间
t-1,load位为1,那么在时间T,我们希望输出out(t)等于之前的输入in(t-1)。 - 否则(
load为0),我们希望输出保持为之前记住的值,即out(t) = out(t-1)。
如何用D触发器构建这样的芯片呢?核心思想是:我们需要一种方法,将我们希望记住的旧值反馈回去,除非我们要求加载新值。
这可以通过一个多路复用器来实现。以下是构建方法:
- 将外部输入
in连接到多路复用器的第一个输入。 - 将D触发器的输出
out(即当前记住的值)连接到多路复用器的第二个输入。 - 用
load信号作为多路复用器的选择位。 - 将多路复用器的输出连接到D触发器的输入。
其工作原理如下:
- 当
load=1时,多路复用器选择外部输入in传递给D触发器。在下一个时钟沿,这个新值将被存储并输出。 - 当
load=0时,多路复用器选择D触发器自身的输出(旧值)反馈回去。在下一个时钟沿,这个旧值被重新存储,从而实现了“保持”功能。
通过逐步跟踪信号,可以验证这个设计完全符合比特寄存器的功能要求:仅在 load 有效时更新存储的值,否则无限期保持原值。
总结
本节课中,我们一起学习了:
- 触发器的核心作用:作为顺序逻辑的基础,能够在离散时间单位之间“记住”比特信息。
- 时钟D触发器:其功能是
out(t) = in(t-1),它是我们构建更复杂顺序元件的基石。 - 构建范式:计算机逻辑通常结合D触发器阵列(用于记忆状态)和组合逻辑电路(用于操作状态)来构建。
- 比特寄存器:我们利用D触发器和多路复用器,构建了第一个实用的顺序芯片——一个可以按需加载并保持比特值的寄存器。


掌握了这一比特存储器,在接下来的单元中,我们将能够构建由大量比特组成的完整内存系统。🚀
022:存储单元 💾

在本节课中,我们将学习计算机架构中的核心组件——存储单元。我们将从基本的寄存器开始,逐步理解如何将它们组合成随机存取存储器(RAM),并探讨其读写操作的基本原理。
存储单元概述
上一节我们介绍了时序逻辑和时间的概念,现在我们可以开始构建计算机所需的存储单元了。
在开始之前,让我们回顾一下计算机架构。该架构中的一个主要组成部分被称为“内存”。在计算机硬件中,“内存”一词指代多种不同的事物。首先,有主内存,它实际上位于计算机内部并直接连接到计算机的主板上。主内存又分为几个不同的类别,其中最著名的是RAM,即随机存取存储器。
此外,还有像硬盘和U盘这样的辅助存储器。同时,内存还有易失性和非易失性之分。例如,当你拔掉计算机的电源插头时,RAM中的内容会丢失。而存储在磁盘和闪存等介质上的信息,即使在计算机未连接电源时也能保留。这就是它们的区别。
RAM用于存储程序操作的数据以及程序本身的指令。我们将在后续讨论整体计算机架构时,再深入探讨这种双重性。
最后,在开始本单元之前,我想说明的是,就像讨论其他事物一样,当我们谈论内存时,可以从物理角度(如何实际构建内存、使用何种技术)和逻辑组织角度来讨论。在本单元乃至整个课程中,我们始终关注逻辑层面的考量,并特别关注RAM单元,它再次成为计算机主内存中最重要的元素。
寄存器的基本概念
在讨论RAM之前,我们需要退一步,谈谈设计内存的基本构建模块。上一单元以描述单比特寄存器的工作原理结束。不难想象,你可以将多个这样的1位寄存器并排放置,从而创建一个16位数字的抽象,我们称这个抽象为“寄存器”。
寄存器的宽度通常是一个参数,我们称之为W。在某些计算机中是16位,在其他计算机中可能是32位或64位。事实证明,这并不是一个特别有趣的参数,因为本讲座中讨论的所有内容都适用于你可能遇到的任何内存宽度。但从现在开始,我将使用W=16,因为我们正在构建一台16位计算机。这样做不失一般性。
我想说的另一件事是,我将大量使用“寄存器状态”这个术语。事实上,这个术语在上一单元也已引入。“状态”是当前存储在寄存器内部的值。更准确地说,这是寄存器内部电路当前正在表达的值。我认为这是对正在发生的事情更准确的描述,它创造了一种存储的假象。
以下是寄存器的示意图。让我们从用户的角度来看这个设备。
我们如何读取这个寄存器的值?事实证明,我们只需要探测输出端即可。因为在任何给定时间点,输出端只是简单地发出寄存器的状态。所以,如果我们查看从输出端出来的值,我们就知道在这个特定的时间周期内寄存器中存储了什么。
现在,让我们谈谈如何向寄存器写入新值。假设我们希望寄存器从现在开始记住数字17。我们该怎么做?我们将输入设置为这个新值,比如17,然后我们“断言”加载位。当我说“断言加载位”时,我的意思是将加载位设置为1。以下是将会发生的情况:
寄存器的状态变为V。从下一个周期开始,寄存器的输出也将开始发出这个值V。因此,从下一个周期开始,寄存器将有效地存储新值17,并且它将一直存储这个值,直到我们决定以刚才描述的相同方式更改这个值。
寄存器芯片实例演示
现在,我们准备给你一个寄存器芯片的实际操作示例。为了做到这一点,我们将启动硬件模拟器,向你展示寄存器芯片的工作原理。
首先,我将加载一个内置的寄存器芯片。为此,我将选择Nand2Tetris文件夹,然后在其中找到工具。在工具中,我选择内置芯片。顺便说一下,我所做的一切你也可以在你的计算机上完成。当然,前提是你已经将Nand2Tetris套件下载到你的电脑上。
我选择内置芯片目录并打开它。我看到我有各种内置芯片可用,所有这些芯片都是Hack芯片组的一部分。这些芯片将在我们组装计算机架构时发挥作用。其中一个芯片叫做“D寄存器”。让我们将这个内置的D寄存器芯片加载到模拟器中。
如果我查看HDL代码,会发现它有点奇怪,因为它是一个内置芯片。你不用担心这个,因为你不需要在本课程中用纯HDL构建内置芯片。这里的代码说明这个芯片实际上是由一个名为DRegister的Java类实现的,它提供了16位寄存器的功能。
代码还说明这个芯片是时钟驱动的,因为它是一个寄存器芯片。因此它接收一个时钟输入。因为HDL包含了“clocked”这个神奇的关键词,我们看到模拟器的控制面板中打开了一个时钟图标。这个时钟用于模拟计算机内的时间进程,或者更准确地说,它可以用来模拟一系列周期。每次我点击这个时钟图标,我就向前移动一个周期阶段。所以,这是一个滴答声。滴答,滴答,滴答。我手动推进时钟。当然,我也可以编写一个包含类似“while true ticktock”命令的测试脚本,这将导致时钟永远滴答作响,或者我可以说“repeat 10000 ticktock”等等。但现在我们将手动完成所有操作,这通常是你首次构建和测试芯片时的做法。
因为这个芯片是由Java类实现的,我们为这些Java实现添加了各种很好的功能。例如,D寄存器有一个GUI副作用,显示该寄存器当前的状态或内容。目前恰好是0,因为这是默认值。让我们继续更改这个寄存器的内容为其他值。
具体来说,让我们将寄存器更改为17(我最喜欢的数字)。我将输入(一个16位值)更改为17。然后,我将时钟向前运行。我看到实际上似乎什么都没有发生。寄存器的内容仍然是0,寄存器的输出也是0。嗯,什么都没有发生是因为我忘记断言加载位了。
让我们转到加载位并将其设置为1。然后我们回到时钟,执行一个“tick”。我们看到寄存器的内容变成了17,这很好。但寄存器的输出仍然是0,这是因为寄存器需要一个完整的周期才能稳定下来并开始发出新状态。所以让我们执行一个“tock”,确实,一旦我移动到下一个周期,寄存器的输出也变成了17。从现在开始,寄存器处于稳定状态,状态是17,并且它将保持17,直到我决定将其更改为其他值。
现在,我可能想做的一件事是将加载位设置为0。这将作为一个安全措施,这样如果无意中有人更改了这个寄存器的值,在加载位被断言之前,什么都不会发生。所以,让我们继续将当前为17的寄存器值更改为,比如说,23(这是另一个我喜欢的数字)。
所以是23,我将加载位设置为1。我执行一个“tick”,看到现在寄存器处于一种不一致的状态,因为寄存器的状态是23,但输出仍然是17。再次地,我必须完成这个周期才能使寄存器进入稳定状态。所以我执行一个“tock”,现在寄存器是23,但寄存器的内部状态是23,同时寄存器本身也已经提交了这个新值,并发出值23。
现在我继续点击时钟,寄存器的值变成了23。这演示了寄存器的典型行为,让我们继续讲座。
构建内存单元
既然我们理解了内存的基本构建模块——寄存器的工作原理,我们就可以开始将它们堆叠在一起,构建实际的内存单元了。
这些单元的一般架构如下所示。实际上,我可以从抽象的角度来谈论它,即RAM抽象。我们习惯将内存视为一系列可寻址的寄存器,就好像每个寄存器都有一个地址,范围从0到n-1(对于一个包含n个寄存器的RAM设备而言)。
这里有一个非常重要的一点需要强调,它使得整个RAM概念更容易理解和掌握。那就是:无论这个RAM单元中有多少个寄存器(可能多达数百万个),在任何给定时间点,只有一个寄存器被选中,并且只有这个寄存器是“活跃”的。所有其他寄存器可以说都不参与游戏。因此,在任何给定时间点,我们必须说明我们想要操作哪个寄存器,我们想要读取哪个寄存器,或者我们想要更改哪个寄存器的值。
所以我们必须进行选择。我们必须确定这个寄存器的地址,我们使用一个称为“地址”的输入来实现。想一想,如果我们必须使用某种二进制代码从n个可能的寄存器中选择一个,我们需要多少位来创建这样的代码?事实证明,我们需要log₂(n)位。例如,如果我们有8个寄存器,我们需要3位,因为log₂(8) = 3。所以寄存器0将用代码000表示,寄存器7将用代码111表示,中间还有所有其他可能性。
因此,在这个图中,我们称为K的地址输入的长度等于log₂(n),即这个特定RAM设备中的寄存器数量。最后,我们有这个W,正如我之前所说,它并不是特别有趣。在我们的计算机中,W恰好是16,但如果你愿意,我们可以使W等于其他数字,比如64。从现在到本单元结束,我们所说的一切都将是相同的。所以不失一般性,W = 16。
最后,我希望大家记住,这是一个时序芯片,它依赖于时钟输入,这就是为什么我们在图中有一个小三角形。这意味着它具有时钟驱动的行为,其行为定义如下:如果我们用标签M代表所选寄存器的状态,那么如果load=1,M变为in,并且从下一个周期开始,该寄存器的输出也变为M。如果load不是1,该寄存器的输出将和以前完全一样。这就是这个RAM单元的基本功能。
RAM的读写操作
让我们谈谈如何使用这个RAM设备读写值。
要读取一个特定的寄存器,我们需要做两件事。首先,我们将RAM单元的地址设置为所需的寄存器编号或地址(例如寄存器5)。然后,我们探测RAM单元的输出。RAM单元的内部电路是这样构建的:输出总是发出所选寄存器的状态。因此,在这种情况下,它将发出寄存器i的状态。这就是我们如何从这个可能包含数百万个寄存器的RAM设备中读取一个寄存器。
关于写入呢?写入稍微复杂一些。如果我们想向寄存器5写入内容(使用相同的例子),我们将地址设置为5,将输入设置为17,然后断言加载位。以下是将会发生的情况:寄存器5的状态将变为17,并且从下一个周期开始,RAM将开始输出这个值。从现在起,直到另行通知,寄存器5将包含值17,直到我们再次使用完全相同的过程更改它。因此,我们知道如何从给定的RAM设备中读写单个寄存器。
RAM芯片实例演示
现在,我想给你一个RAM芯片的实际操作示例。我们将再次启动硬件模拟器,看看寄存器值如何随时间变化的一些例子。
在这个演示中,我们将使用硬件模拟器来说明RAM设备的操作。让我们打开内置芯片目录,寻找一些RAM设备。我们在Hack芯片组中有几个RAM设备。让我们选择一个Ram8。我加载这个芯片。
这是一个内置芯片。它附带了一个很好的GUI副作用,我们在右侧可以看到。我可以使用这个GUI将一些值放入RAM中。让我们开始吧。
我只是在一些寄存器中放入一些随机数字。我们可以让一些寄存器保持为0。我只是输入一些值,没什么特别的。就这样。
应该理解的是,我在这里所做的有点像是“作弊”。这不是你在低级层面上操作RAM设备的方式。所以,让我们继续以正确的方式来做这件事。
假设我们想要读取寄存器5。我如何读取寄存器5的内容?我转到RAM设备的地址输入,在那里放入数字5。似乎什么都没有发生。但是如果我运行时钟,我看到RAM设备的输出开始发出值8373,这恰好是所选寄存器(寄存器5)的内容。
如果我想读取另一个寄存器,比如寄存器1。我将地址更改为1,运行时钟,然后看到我得到了寄存器1的内容。
关于更改这些寄存器中的内容呢?假设我想将寄存器4的内容设置为12。我转到RAM的地址输入,将地址设置为4。我将加载位断言为1以启用写操作。我将输入值设置为期望的值,我们决定是12。然后我运行时钟。
在第一个“tick”中,我看到寄存器4的内部状态变成了12,但RAM设备的输出仍然是其他值。为了使RAM提交新值,我必须完成这个周期。所以我这样做,看到现在RAM设备也发出了所选寄存器(恰好是寄存器4)的值。
假设我想更改另一个寄存器,比如寄存器2。让我们将寄存器2设置为数字17。我转到地址输入,将其设置为2。我转到输入,将其更改为17。我运行时钟,看到寄存器2确实变成了17,并且RAM的输出现在也发出了这个值。
这演示了RAM单元如何操作。
本课程将构建的RAM芯片
在本课程中,我们将构建一个16位RAM芯片系列。它们都将具有完全相同的通用架构,但细节会略有不同。
以下是我们将要实际构建的芯片:
- Ram8:包含8个寄存器,地址位长度为log₂(8) = 3。
- Ram64:包含64个寄存器,地址位长度为6。
- Ram512:包含512个寄存器。
- Ram4K:包含4096个寄存器。
- Ram16K:包含16384个寄存器,这是我们最终为Hack计算机平台所需要的。
这些就是我们将要构建的5个芯片,这些芯片的构建将出人意料地简单,你将在下一单元看到。
一个合理的问题是:为什么我们需要这五个特定的芯片?答案很简单,因为这是我们构建Hack计算机所需要的。另一个我到现在为止一直回避的有趣问题是:为什么这个东西一开始被称为RAM?RAM代表随机存取存储器。
总结与展望
在本单元结束时,我想指出,也许我们没有给予这个芯片应有的尊重,因为在这个芯片上发生了一些真正了不起的事情。以下是实际发生的情况:
无论这个芯片的尺寸如何,无论它有8个寄存器还是800万个寄存器,我都可以以完全相同的访问时间从这个芯片中选择每一个寄存器并对其应用操作。这确实是非凡的。我所要做的就是将要选择的寄存器的地址输入到地址输入端,然后“砰”的一声,这个寄存器就被选中了,所有其他寄存器都被忽略了。当然,现实中并没有“砰”的一声,我只是为了戏剧效果。但这确实相当戏剧性,因为再次强调,如果我现在想选择寄存器5000,我所要做的就是输入5000,然后“砰”,这个寄存器就变得无关紧要,而寄存器5000变得活跃并开始“营业”。无论我有800万个寄存器还是80亿个寄存器,我都有这种基本能力,可以随机选择此配置中的任何寄存器,并以相同的访问时间读取或写入它。
所以这确实是一个非凡的功能。在本周晚些时候,你将实际使用HDL构建这个功能。但在我们这样做之前,我们还需要描述一个芯片,这个芯片实现计数操作,该芯片称为计数器(单数),这将是我们下一单元的主题。

在本节课中,我们一起学习了存储单元的基础知识。我们从最简单的寄存器开始,理解了其状态、读写操作和时钟驱动的行为。然后,我们探讨了如何将多个寄存器组合成RAM,并理解了其通过地址选择特定寄存器、以恒定时间进行随机存取的核心特性。最后,我们预览了本课程将要构建的一系列RAM芯片。这些知识为我们接下来构建实际的计算机内存系统奠定了坚实的基础。
023:计数器 🧮

在本节课中,我们将要学习本周需要构建的最后一个芯片——计数器。我们将了解它的作用、工作原理,并通过硬件模拟器演示其功能。
上一节我们介绍了寄存器的概念,本节中我们来看看一个特殊的寄存器——计数器。计数器在计算机中扮演着至关重要的角色,尤其是在控制程序执行顺序方面。
计数器的应用场景
为了理解计数器的用途,让我们假设一个场景:我家里有一个家用机器人,我想让它为我烘焙一些布朗尼蛋糕。为此,我需要编写一个包含大约50条指令的特定程序,详细告诉机器人如何操作。我会把这个“食谱”贴在厨房墙上,并在旁边放置一个计数器。这个计数器会输出一个数字,告诉机器人接下来需要执行哪条指令。程序指令从0到49编号,因此计数器从0开始。
计数器的工作流程如下:当机器人执行完一条指令后,在一个周期结束时,计数器会加1。然后,机器人查看计数器,得知需要执行下一条指令(例如指令1),并执行它。接着计数器变为2,机器人执行指令2,依此类推。
除了基本的递增功能,我还需要能够完全控制计数器。这意味着我需要能够随时将计数器设置为一个特定的值(例如17),而不管它当前显示的是什么数字(3、4、5等)。设置完成后,计数器会从17开始继续计数。
这种控制能力非常重要。例如,当机器人完成一批布朗尼的烘焙后,我想让它再烘焙一批。这时,我可以将计数器重置为0,机器人就会从头开始执行程序。另一个原因是,当机器人开始烘焙第二批时,烤箱可能已经预热好了。因此,我们需要跳过程序中那些关于打开烤箱的指令。程序的第一条指令可能是:“如果烤箱已工作,则跳转到指令11”,从而跳过所有与打开烤箱相关的指令。这就是为什么我们需要能够将计数器设置为特定值。
计数器的基本操作
综上所述,计数器需要支持三种基本操作:
以下是计数器需要支持的三种基本操作:
- 获取第一条指令:为此,我们需要将程序计数器设置为0。
- 获取下一条指令:这是默认操作。为此,我们需要能够将计数器的当前状态加1。
- 跳转到特定指令并执行:为此,我们需要能够将程序计数器设置为某个期望的值。
那么,什么是计数器?计数器是一个芯片,一个硬件设备,它实现了上述抽象概念,并支持这三种基本操作。
计数器的接口与功能
让我们更详细地讨论这个抽象概念。我们将使用本课程其他单元中使用的相同芯片图来描述程序计数器。
我们有一个程序计数器的黑盒描述。它有一个16位的输入(包含一个16位的值),一个16位的输出,以及三个控制位,分别称为 load、inc 和 reset(inc 代表递增)。
这个设备应该如何工作?以下是这个16位计数器所需操作的更正式描述:
以下是计数器在不同控制信号下的行为逻辑:
- 如果复位位 (
reset) 等于1(即被置位):在这种情况下,我希望计数器输出0。因此,在下一个时钟周期,计数器将输出数字0。 - 如果加载位 (
load) 被置位:在这种情况下,我希望将计数器设置为一个特定值。例如,如果我想让计数器跳转到数字17,我就在输入端口输入一个代表17的16位二进制值,并置位load位。这将绕过计数器的常规递增操作,并在下一个周期将计数器设置为数字17。 - 如果递增位 (
inc) 等于1(即被置位):在这种情况下,计数器的输出将是其当前状态加1。这是计数器的默认操作。 - 如果以上控制位均未被置位:计数器不执行任何操作,仅输出其当前状态。
这就是计数器的期望功能。你的任务是编写必要的HDL语句来实现这个功能。
如果你现在还不知道如何实现,请不要担心。我们将有一个完整的单元专门讲解项目3,在那个单元中,我们将讨论如何构建每个芯片(包括这个计数器芯片)的各种技巧和指南。
硬件模拟器演示
在继续讲解之前,我想通过我们的硬件模拟器给你演示一下计数器芯片的实际操作。接下来我们就进行演示。
首先,我们加载程序计数器的内置芯片。我们进入工具文件夹,找到内置芯片目录。在内置芯片中,我们搜索 ProgramCounter.hdl 文件。这就是内置芯片。我们看到它有一个图形界面,可以显示程序计数器的内容,这本质上是一个带有一些控制位的寄存器。
确实,我们看到它有一个16位输入、一个16位输出和三个名为 load、inc 和 reset 的控制位。
让我们向程序计数器中加载一些值。我们想输入数字23。我们必须运行时钟来提交这个值。但我们发现实际上什么也没发生。我们运行了时钟,但似乎没有任何变化。没有变化是因为我们忘记了置位 load 位。让我们置位 load 位,然后运行时钟。确实,我们看到程序计数器现在包含了23。
但它似乎并不计数,它似乎固定在了23。它固定不动是因为我们忘记了置位 inc 位。让我们这样做:我们置位 inc 位。但仍然,似乎什么也没发生。似乎没有变化是因为在每个周期中我们做了两件事:我们告诉寄存器(程序计数器)递增,但同时我们在每个周期都给它加载23,所以它保持在23。
如果你想让程序计数器最终开始计数,我们必须关闭 load 位,然后运行时钟。终于,看起来它开始正常工作了。程序计数器在每个周期递增1,这是程序计数器的经典操作。实际上,我们可以点击快进图标。这将使时钟无限循环运行,我们看到确实在每个周期,程序计数器都前进1。非常好。让我们停止时钟。
现在让我们复位程序计数器。我们将 reset 位设置为1,希望程序计数器会变为0。让我们运行时钟,确实我们看到在下一个周期,程序计数器变成了0。但它又不计数了,因为 reset 位仍然是1。所以我们必须关闭 reset 位,然后运行时钟。我们看到现在,程序计数器确实在每个周期都正常递增。
以上就是程序计数器的演示,让我们回到课程。
总结

本节课中我们一起学习了计数器的概念、功能及其在程序执行控制中的关键作用。我们了解到计数器需要支持复位、加载特定值和递增三种基本操作。我们还通过硬件模拟器直观地观察了计数器在这些控制信号下的行为。这是本周需要构建的最后一个芯片的描述,实际上也是本周所有芯片描述的结束。在下一个单元,我们将为你提供构建这些芯片以及如何提交项目3的各种技巧和指南。
024:项目3概述 🧠

在本节课中,我们将学习如何运用之前所学的存储器理论与实践知识,实际动手构建实现这些功能的芯片。
概述
项目3将基于项目1和项目2中已设计好的所有芯片,以及一个可供自由使用的基础D触发器。基于这些基础模块,我们需要构建一系列芯片。这些芯片在前面的单元中都以不同方式出现过,现在我们将亲手实现它们。
芯片构建任务
我们将要构建的芯片,除程序计数器外,其余构成了一个复杂度递增的时序芯片家族,从1位寄存器一直到包含约16K个寄存器的随机存取存储器。
以下是需要构建的芯片列表:
- 1位寄存器
- 16位寄存器
- RAM8:包含8个寄存器的RAM设备
- RAM64
- RAM512
- RAM4K
- RAM16K
- 程序计数器
从基础寄存器开始
上一节我们介绍了项目任务,本节中我们来看看如何从最基础的芯片开始构建。
1位寄存器
我们首先构建一个1位寄存器。其示意图和文档说明已在此处重复展示,我们之前讨论过它。其HDL存根文件并无特殊之处,实现方式在之前的单元中已有描述,你可以按照说明在HDL中构建此芯片。
16位寄存器
接下来,我们可以将一组这样的单比特寄存器组合起来,构建一个16位寄存器。其HDL代码框架已展示,你需要做的就是创建正确的连线,从而得到一个16位寄存器。
构建RAM设备
现在我们已经准备好继续前进,构建我们的第一个RAM设备——包含8个寄存器的RAM8芯片。
RAM8芯片
这是该芯片的API接口说明,这是描述其输入和输出引脚的存根文件。
现在,我们来看看如何实际构建这样一个RAM设备。这里有两个非常重要的提示:
- 你必须将输入值同时馈送到所有八个寄存器。你需要使用HDL语句将这个输入值分发到RAM设备中的所有寄存器。
- 然后,你需要使用一些多路复用器(Mux) 和解复用器(DMux) 逻辑,来选择你希望受特定读或写操作影响的确切寄存器。
简而言之,你将信息发送给所有寄存器,但只通过Mux和DMux功能选中其中一个。具体的实现细节留给你来完成,否则挑战性会不足。掌握了这些细节,你就具备了构建此RAM设备所需的一切。
构建更复杂的RAM芯片
我们已经实现了前三个芯片,现在准备在层次结构中构建更复杂的RAM64、RAM512等芯片。
我们的做法是:从刚刚讨论的RAM8芯片开始,将八个这样的RAM8芯片堆叠在一起,得到的结果就是我们称为RAM64的芯片。然后,我们可以取八个这样的RAM64芯片组合起来,得到层次结构中的下一个芯片——RAM512。我们可以再进行两个性质相似的步骤,最终得到最复杂的RAM芯片——我们称之为RAM16K的芯片。这基本上是一个递归上升的过程。
那么,具体如何操作呢?首先,我们注意到一个RAM设备可以通过将更小的RAM部件组合在一起来构建,这可以用HDL描述。我们可以将每个芯片的地址输入(图中未显示,但在背景中)视为一个由两个逻辑字段组成的二进制值:一个字段用于选择你想要操作的确切RAM部件,第二个字段用于选择该RAM部件内你想要通过读或写操作来影响的具体寄存器。结合这两个技巧,你就能构建任意长度的RAM设备,并使用Mux和DMux门来实现我刚才描述的这种分层寻址方案。
同样,这里的提示足以让你使用HDL独立完成实现。
构建程序计数器
这基本上完成了RAM设备的构建,我们接下来构建本项目中的最后一个芯片——程序计数器。这是一个完全不同的芯片。
程序计数器实际上就是一个计数器。它有一个输入值、一个输出值和三个控制位。这个计数器的整体行为有些复杂,我们在之前讨论计数器时已经讲过。这里我只强调关于它的四个最重要方面:
- 作为一个计数器,我们需要能够将其重置为零。
- 我们需要能够将其设置为其他值(如17或19等)。
- 我们需要能够告诉芯片开始计数,即在每个周期变为之前的值加一。
- 在某些情况下,我们可能希望告诉它停止计数并保持当前值。
你需要实现正确的功能逻辑,来影响这四种操作中的每一种。事实证明,你可以使用一个寄存器芯片、一个增量器芯片以及一些之前描述过的其他逻辑门来实现。
项目实践指南
综上所述,这就是你在项目3中需要完成的任务。和往常一样,你需要访问Nand to Tetris网站,在项目网页上找到许多额外的操作细节。
再次强调,如果你已经下载了课程软件套件,则无需额外下载任何东西。所有必要的文件都已在你计算机的 project/03 目录中,你拥有硬件模拟器,可以开始独立构建所有这些芯片。
和往常一样,必要时查阅这些资源是明智之举。此外,之前项目中给出的所有最佳实践建议在这里同样适用,你最好在开始构建项目前阅读一下。另外,和往常一样,你将需要使用在之前项目中实现的芯片,而最佳实践是忽略你自己的HDL实现,转而使用内置的实现。
最后,关于本项目,我想强调另一点:本项目的HDL文件存储在名为A和B的两个独立目录中,这个结构应保持不变。为什么这样做呢?如果你回想一下,我们的RAM设备是由更小的RAM部件构建的,而更小的RAM部件又由更小的RAM部件构建,如此反复,就像一个俄罗斯套娃系统。如果你将一个巨大的RAM芯片(比如RAM16K)放入硬件模拟器,模拟器会开始评估所有芯片部件,并以某种递归向下钻取的方式进行,一直深入到单比特寄存器。事实证明,这对于硬件模拟器(它只是一个普通的计算机程序)来说,可能需要处理太多内存或太多对象。因此,通过将这些RAM芯片放在两个独立的目录中,我们强制模拟器在某个点停止使用HDL文件,并开始使用这些内置芯片的Java实现,这将使模拟运行得更快、更顺畅。所以,请保持目录结构不变。
总结

本节课中,我们一起学习了项目3的完整概述。我们明确了需要构建的芯片家族,从基础的1位和16位寄存器,到通过分层和递归方式构建的复杂RAM设备(RAM8、RAM64、RAM512、RAM4K、RAM16K),最后是功能多样的程序计数器。我们还讨论了关键实现技巧(如使用Mux/DMux进行寻址)和重要的项目实践指南(如目录结构的意义)。现在,你已经准备好开始动手,将这些理论知识转化为实际运行的硬件了。
025:视角与展望 🧠

在本节课中,我们将回顾本周关于内存系统构建的核心内容,并解答一些常见问题。我们将探讨触发器(Flip-Flop)的内部原理、不同类型的内存设备,以及计算机架构中内存设计的权衡考量。
触发器是如何构建的?🔧
上一节我们介绍了如何使用触发器作为黑盒抽象来构建内存。本节中我们来看看触发器本身是如何从基本逻辑门构建的。
在许多计算机硬件课程中,学生会学习如何用基础的与非门(Nand gate)来构建触发器。我们的课程跳过了这部分,以专注于组合逻辑与时序逻辑的核心区别。然而,了解其基本原理是有益的。
核心思想是:如何让与非门记住信息。以下是基本方法:
取两个与非门,并将它们连接成一个环路。通常,我们不允许将组合逻辑门连接成环路(本课程的硬件模拟器也明确禁止此操作,这是出于教学考虑)。但在构建触发器时,这是一个特例。
以下是该电路的工作原理简述:
- 当两个输入均为1时,电路状态不确定。
- 若将其中一个输入暂时置为0,会导致环路中产生一个稳定的0和1的输出组合。
- 当该输入恢复为1后,环路会保持这个输出组合。
- 随后,若将另一个输入暂时置为0,环路状态会翻转,稳定在另一个输出组合上。
这种环路能够“记住”最后一个被置为0的输入线,并稳定在两种不同状态之一,因此得名“触发器”(Flip-Flop)。当然,要构建我们本周使用的D型触发器,还需要在其周围添加控制逻辑,并利用时钟信号来分隔不同的时钟周期。
内存设备的类型 🗃️
我们本周构建了一个称为RAM的内存设备。那么,这是计算机使用的唯一内存设备吗?答案是否定的。
计算机使用多种内存设备,其中RAM(随机存取存储器)确实是最重要的一种。RAM存储数据和指令,但它是一种易失性设备,依赖于外部电源。一旦断电,其内容就会丢失。
除了RAM,计算机通常还使用另一种称为ROM(只读存储器)的设备。ROM不仅是只读的,还是一种非易失性设备,这意味着它能长期保持内容,不依赖外部电源。ROM通常存储计算机启动时必须运行的程序(即引导程序)。
另一种你可能听说过的技术是闪存。它结合了RAM和ROM的优点:既可读写,又具有非易失性。
此外,还有一种常见的内存类型叫高速缓存。那么,什么是高速缓存?我们为什么需要它?
高速缓存与内存层级结构 ⚡
当实际构建计算机时,内存将是整个系统中成本较高的部分。通常,内存速度越快,价格越昂贵;容量越大,价格也越昂贵。
计算机架构师面临一个权衡:是将更多资金投入内存使其更大更快,还是选择更便宜的内存而将资金更多地投入处理单元?
一个常见的折中方案是:使用一个大容量、低成本(可能也较慢) 的主内存,同时配备一个非常小、昂贵、快速的内存(即高速缓存)。目标是确保处理器频繁使用的数据驻留在高速缓存中,而较少使用的数据则放在主内存中。
这样,你就能以低成本获得大容量,同时通过高速缓存获得接近快速内存的访问速度。正确地实现这一点需要精妙的架构设计。现代计算机通常拥有多级高速缓存层级,这些缓存越靠近处理器,速度越快、成本越高、容量也越小。这种设计能实现惊人的有效访问速度。
逻辑视角的统一性 🔍
从物理实现的角度看,我们有许多不同种类的内存单元。然而,从逻辑视角来看,所有这些不同的内存系统——RAM、ROM、高速缓存等——看起来都是一样的。
它们的行为都像一系列可寻址的寄存器,我们可以访问其中任何一个寄存器,并对其内容进行操作。因此,本周我们所讨论的所有关于内存访问和操作的概念,完全适用于本单元讨论的每一种内存类型。
总结 📚
本节课中我们一起学习了:
- 触发器可以通过将与非门连接成环路来构建基本记忆单元。
- 计算机使用多种内存设备,包括易失性的RAM、非易失性的ROM和闪存,以及用于提升性能的高速缓存。
- 内存设计需要在速度、容量和成本之间进行权衡,高速缓存层级是解决这一矛盾的关键架构。
- 尽管物理实现多样,但从程序员或逻辑设计的角度看,所有内存都表现为统一的可寻址寄存器阵列。
至此,我们完成了对内存系统构建的深入探讨与视角拓展。
026:机器语言概述 🖥️

在本节课中,我们将学习机器语言的基本概念。我们将探讨为什么单一的硬件能执行多种任务,理解机器语言的核心作用,并了解如何使其对人类程序员更友好。
概述
过去两周,我们构建了算术逻辑单元和内存层次结构。现在,我们已经处于可以将它们组合起来构建一台计算机的阶段。但我们决定将这项工作推迟到下周。本周,我们将从用户的角度,审视我们想要构建的计算机。我们将探讨能用这台计算机做什么,以及我们将要构建的计算机的具体规格。只有在深入了解并实践之后,我们才会在下周真正开始构建它。
那么,为什么需要这样做?因为计算机是一个复杂的实体,它能做很多事情。仔细想想,这确实令人惊叹。世界上大多数机器只做一件事,例如,你的洗衣机只洗衣服,它不能煮意大利面。然而,一台计算机,比如智能手机,可以做许多不同的事情:文字处理、玩游戏、与人交流、播放视频等等。同一台硬件如何能完成所有这些不同的任务?
通用性的核心思想
其基本思想是通用性。这个思想的起源是艾伦·图灵在19世纪末20世纪初提出的理论模型。他试图形式化计算机的概念,并定义了一个理论模型——图灵机,以捕捉“可计算”的本质。他随后注意到一个惊人的现象:尽管在这个模型中有许多不同的计算设备(即不同的图灵机),但其中存在一个单一的机器,在给定正确程序的情况下,可以模拟所有其他机器。这被称为通用图灵机。这是我们第一次拥有“一台机器可以扮演所有机器角色”这一惊人想法。
冯·诺依曼将这一想法付诸实践,构建了第一台通用计算设备的严肃架构。他架构的核心是存储程序计算机的概念:我们可以将程序(软件)放入硬件中,然后硬件就能根据我们放入的软件(不同的程序)来执行不同的操作和功能。通过这种方式,我们第一次拥有了能够执行多种功能、运行多种不同程序的真正计算机。这个秘密一直延续至今,以至于我们甚至没有注意到我们的单一机器能完成这么多不同的事情是多么了不起。
软件如何控制硬件
让我们更仔细地看看这是如何工作的。我们的计算机内部将有一个中央处理单元和一些内存。它会读取一些输入,产生一些输出。但在内存内部,将有一个程序来告诉计算机该做什么。硬件是固定的,但程序(软件)可以改变。随着软件的变化,计算机将执行不同的任务。
那么,单一的、静态的硬件如何根据程序执行多种任务呢?程序由一系列指令组成,每条指令都用二进制编码。我们的硬件只需逐条执行指令。不同的指令集、不同的指令序列将导致我们的硬件产生完全不同的结果。当我们把整个指令序列(即整个程序)放在一起时,我们就得到了程序的功能,这完全取决于所编写的程序本身。
机器语言的关键要素
现在,让我们看看需要更仔细研究的几个关键要素,这些要素共同促使硬件按照程序的意愿工作。
以下是构成机器语言指令的三个基本要素:
- 指令规范:我们的程序将由一系列指令组成。指令具体告诉计算机做什么?这是第一个要素。
- 执行顺序控制:我们如何知道在任何给定时间应该执行哪条指令?假设我们现在执行第74条指令,按理下一条应该是第75条。但有时我们需要改变顺序,例如为了实现循环或条件执行。这是软件需要能够控制硬件操作的第二个要素。
- 操作数指定:我们必须告诉硬件对什么进行操作。即使硬件知道现在需要将两个数字相加,软件也必须告诉硬件具体从哪里获取这两个值,以及将结果放在哪里。这是第三个要素。
每个机器语言,或者说硬件允许软件告诉它做什么的每种方式,都需要指定这三个方面。我们稍后会讨论每种要素的可能性和选项。
机器语言与高级语言
一旦我们有了这种机器语言,这就是硬件允许软件执行的内容。这种机器语言规范本身就是一种编程语言,但它是一种非常糟糕的编程语言。它对硬件非常方便,可以指定你想让硬件做的任何事情,但对人类来说却非常不方便。因此,在现实中,人类程序员几乎从不直接用机器语言编程,而是使用为人类设计的高级编程语言来轻松地编写他们想做的事情。然后,有称为编译器的自动程序,将高级语言翻译成机器语言,从而告诉硬件该做什么。
因此,我们通常不太担心机器语言,因为作为人类程序员,我们实际上并不使用它。然而,从计算机的角度来看,真正运行的始终只有机器语言程序。任何其他程序最终都以某种方式被翻译成这种机器语言形式,这才是确切告诉硬件该做什么的东西。
让机器语言更友好
尽管通常人们不直接用机器语言编程,但在本课程中,我们正在处理运行机器的硬件以及直接在其上运行的软件。为了理解如何构建计算机,我们需要学习机器语言,因此我们将不得不直接处理机器语言。此外,在某些情况下,例如为了针对特定任务获得高度优化的代码,人们也会直接处理机器语言。
在这些情况下,包括我们的课程,我们需要让使用机器语言变得稍微容易一些。通常有两种方法可以使机器语言编程更容易:
- 助记符:机器语言中的指令总是比特序列。这很糟糕,因为很难通过查看像
0111110这样的指令来理解它的含义。但实际上,这条指令在计算机中编码了某些我们可以理解的东西。例如,这个比特序列的第一部分可能编码了“加法”操作。在这种情况下,谈论这种指令时,使用其助记符形式会更方便,比如直接说ADD,而不是01110或任何代码。同样,指令的其他部分可能编码特定的寄存器(例如R2表示第二个寄存器),而不是对人类无意义的比特序列。因此,我们将使用像ADD R3, R2这样的形式来思考,这完全等同于谈论之前的比特序列,只是对我们来说更容易。 - 符号:很多时候,在机器语言程序中,我们需要访问内存位置。例如,可能有一条命令告诉计算机“给内存位置 129 加 1”。对于编写程序的程序员来说,具体是内存位置 129 并不重要。他真正想的是,我们将在那个内存位置存储某种索引。因此,对于程序员来说,这个值也可以存储在内存位置 250 或其他任何位置。重要的是,每次我使用这个值时,我都去同一个内存位置。在这种情况下,我们可以允许用户为该内存位置写一个符号名称,比如叫它
index。然后,让我们的汇编器(它已经提供了一些便利)自动将这个单词index翻译成某个空闲的内存位置(例如 129)。这使我们能够以对人类更简单的方式编写程序,而实际上,每当你看到index,它都意味着某个固定的内存位置。
总结

本节课中,我们一起学习了机器语言的基本概念。我们探讨了计算机通用性的核心思想,理解了软件如何通过指令序列控制固定的硬件。我们分析了机器语言的三个关键要素:指令规范、执行顺序控制和操作数指定。我们还了解了机器语言与高级语言的关系,以及如何通过助记符和符号让机器语言对人类程序员更友好。在接下来的单元中,我们将具体讨论机器语言的细节,以及本课程中我们将要构建的 Hack 计算机的特定机器语言。
027:机器语言要素 🧠

在本节中,我们将学习机器语言的基本构成要素。这些要素是所有机器语言共有的核心概念,理解它们将为我们后续学习Hack计算机的特定机器语言打下坚实基础。
机器语言:硬件与软件的接口
上一节我们介绍了机器语言的通用概念及其如何控制计算机。本节中,我们来看看所有机器语言中都会出现的基本要素。
机器语言是计算机科学中最重要的接口。它是硬件与软件之间的桥梁,是软件控制硬件的直接方式。这种语言需要规定硬件执行哪些操作、从哪里获取操作数据以及如何控制操作流程等。
通常,这个接口与实际的硬件实现几乎是一一对应的。硬件被设计成能直接支持其向上层软件提供的功能类型。当然,从基本原理出发,机器语言精确地规定了硬件能为我们做的事情。
设计权衡:成本与性能
当我们实际设计一种机器语言时,基本要素是成本与性能的权衡。我们希望机器语言提供的操作越复杂、支持的数据类型越庞大或越高级,实际构建它的成本就越高(包括硅片面积和硬件运行时间等)。在我们的课程中,我们总是倾向于最简单的权衡,展示最简单的概念,而不必担心真实性能。但在任何真实的机器中,驱动机器语言设计的核心正是这种成本与性能的权衡。
操作类型
每种机器语言都定义了一组操作,这些操作可以分为几类。
以下是常见的操作类别:
- 算术运算:例如两个数字的加法、减法,可能还包括乘法或除法。
- 逻辑运算:例如对两个位或两个字进行按位与(AND)操作。
- 流程控制操作:这些操作告诉硬件何时在程序中跳转。
不同的机器语言定义了不同的操作集合,它们在丰富程度上可能有所不同。例如,有些机器可能将除法作为基本操作提供,而其他机器可能因为硅片成本太高而不这样做,转而由软件来提供该功能。
数据类型
可能更重要的问题是:我们的硬件能原生访问哪些数据类型?处理8位数和64位数之间有巨大差异。如果你的软件程序确实需要进行64位值的算术运算,那么能在一个操作中完成64位值加法的硬件,至少比需要通过一系列8位值加法来实现64位值加法的硬件快八倍。
同样,一些计算机可以提供更丰富的数据类型。例如,你的硬件可能直接支持浮点运算,即处理非整数的实数,并将它们的加法、乘法、除法作为基本操作提供。对于需要进行科学计算(使用浮点数而非整数)的场景,这样的机器当然会快得多。
寻址模式:如何指定操作数据
下一个问题可能更重要,尽管稍微微妙一些:我们如何决定对哪些数据进行操作?硬件如何允许我们指定要处理的数据值类型?
基本问题在于,我们要处理的数据驻留在内存中,而访问内存是一项开销很大的操作。这至少体现在两个相关的方面:首先,如果你有一个大内存,指定要操作哪部分内存需要很多位来表示地址,这在指令中是一种浪费。其次,从大内存中访问一个值所需的时间,相对于CPU本身或算术运算的速度来说,相对较长。
处理这两个问题的方法,是在不承担指定大地址和从“远处”获取信息(就时间而言)的高昂成本的情况下,为我们提供对操作数据类型的良好控制。基本解决方案被称为内存层次结构,这早在冯·诺依曼建造第一台计算机时就已经构想出来了。
基本思想是,我们不是只有一个大内存块,而是有一系列越来越大的内存。最小的内存(通常称为寄存器)将非常容易访问,因为地址空间小且数量少,我们可以非常快速地从中获取信息。然后会有稍大一些的内存(通常称为缓存),以及更大的主内存,甚至可能还有更大的磁盘存储。每当我们离算术单元本身越远,内存就越大,访问它就越困难(无论是需要更宽的地址还是更长的等待时间),但那里存储的信息也越多。
不同层次的内存处理方式各不相同。我们现在要讨论的是最小的内存——通常真正位于CPU内部的寄存器——的处理方式。
寄存器:CPU内部的快速存储
几乎每个CPU都有少量非常小的内存,称为寄存器,它们真正位于CPU内部。它们的类型和功能是机器语言的一部分,主要特点是数量极少,因此寻址它们只需要很少的位数,并且从中获取信息的速度极快。它们是用最快的可用技术构建的,并且已经在CPU内部,因此从那里获取信息没有任何延迟。
我们有以下几种寄存器类型:
- 数据寄存器:我们可以将数字放入其中,并执行诸如“将寄存器一加到寄存器二”的操作。在这种情况下,寄存器一的内容将被加到寄存器二的内容上(如果这是我们机器语言中该操作的含义)。一旦我们在CPU内部有了少量寄存器,我们就可以非常快速地对少量内存进行大量操作。
- 地址寄存器:我们有时可以将一个主内存地址放入其中一个寄存器,这允许我们指定要访问的较大内存的哪一部分用于我们想要访问的操作。例如,如果我们有一个像“将寄存器一存储到由名为A的寄存器指定的内存地址”这样的操作,那么一旦硬件执行此操作,该数字(例如77)将被写入主内存。这可能比CPU内部操作花费更长的时间,但重点是我们写入信息的地址实际上是由A寄存器给出的。
寻址模式详解
现在我们有了这些寄存器,可以回到最初的问题:我们如何决定对哪些数据进行操作?我们如何告诉计算机,对于一个操作(比如一个简单的加法操作),它应该对什么进行操作?这里有一系列不同的可能性,有时被称为寻址模式。
以下是四种常见的可能性(有些计算机还有其他可能性):
- 寄存器寻址:我们只想处理这些寄存器。例如,我们可以说
add R1, R2。这当然意味着加法操作是在两个寄存器上进行的。 - 直接寻址:我们可以有一个操作说
add R1, M[200],在这种情况下,我们告诉计算机不仅要直接寻址寄存器一,还要寻址我们在命令中指定的内存地址200。 - 间接寻址:这是我们之前使用A寄存器的例子,我们访问的内存地址不是作为指令的一部分指定的,而是已经写在地址寄存器中,该寄存器之前已加载到CPU中并具有某个正确的值。
- 立即数寻址:我们实际上在指令内部有一个值。例如,我们可以说
add #73, R1,其中73是一个常量,是指令的一部分。
所有这些都是在每条指令中告诉计算机对哪些值、哪些数据进行操作的不同方式。
输入与输出处理
在讨论从哪里获取数据以及放到哪里时,我们可能还会附带讨论一下在大多数机器语言中如何处理输入和输出。众所周知,有大量的输入和输出设备,如打印机、屏幕、各种传感器、键盘、鼠标等。
实际访问这些输入/输出设备的一种方法是,将它们连接到控制它们的寄存器,作为你内存的一部分。例如,我们可能有一个鼠标,其连接方式是每当用户移动鼠标时,最后的移动信息就被写入某种寄存器,而该寄存器可以通过某个特定地址作为内存的一部分被计算机访问。这使我们能够像访问内存本身一样访问输入和输出。当然,实际处理它的软件(通常是操作系统中的驱动程序)必须确切地知道这些输入/输出设备连接到哪些地址,以及如何与它们通信(哪些位置的值真正意味着什么)。
流程控制
我们要讨论的最后一个要素是所谓的流程控制。我们如何告诉硬件接下来执行哪条指令?通常这很简单:如果我现在执行的是指令73,下一条指令将是74,这是正常情况。但有些情况下,我们当然需要改变控制流,而不是一条接一条地继续执行指令。
改变流程的第一个原因是,有时我们只需要跳转到另一个位置,例如执行循环或跳转到软件的另一部分。这有时被称为无条件跳转。它的一个主要用途实际上是执行循环。
假设我们想对值1、2、3、4、5、6等依次执行某些操作。我们的做法是让某种寄存器保存这些值。每次我们想给R1加1(假设这是我们选择用来保存该值的寄存器),然后我们用这个新值做任何需要做的事情。接下来我们想对R1的下一个值做同样的事情。我们这样做的方式是告诉计算机:“在执行完我们的例子中的指令156之后,不要继续执行指令157,而是跳回指令102”,该指令基本上给R1加1,给我们R1的下一个值,然后继续用新值做你之前做的任何事情。这允许你执行一个循环。
机器语言总是有某种能力让软件告诉硬件再次执行某些操作、返回或跳转到不同的方向。请注意,实际的地址101、102和156并不是那么重要。真正重要的是,当我们跳转到地址102时,我们需要跳转到我们实际意指的地址。因此,我们可以用符号化的方式来做这件事,如下所示:给重要的位置起个名字。例如,位置102,我给它起名叫 loop,然后我说 jump loop。这并没有真正改变任何东西,当我们在机器语言中用比特表示它时是完全一样的,但对人类来说查看起来更方便。所以我们将把这作为描述程序的一部分。
还有其他情况我们需要处理流程控制,这时我们需要进行所谓的条件跳转。在某些情况下,我们想跳转到另一个位置,而在其他情况下,根据我们执行的上一条指令、某个寄存器的值或其他条件,我们可能只想继续执行下一条指令。这被称为条件跳转。
例如,假设我想计算一个数的绝对值。如果这个数是正数,我只想对这个数进行一些操作。但如果这个数是负数,我想先把它变成正数,然后处理它的正数版本。我们可以通过条件跳转来实现:jump-greater-than,意思是如果R1大于0,我希望你跳转到标签 con(这只是我编的一个名字),否则就继续。这意味着如果我们有一个正数,那么我们将不会执行下一条指令(减法指令)。但如果我们有一个负数,我们将直接继续执行减法指令,该指令实际上获取R1并对其值取反,使其变为正数。现在,在两种情况下,我们都继续执行相同的指令序列,并且无论如何,我们现在已经有了一个正的R1值。这是另一个例子,说明我们有时需要条件跳转,而机器语言总是有某种装置或方式来实际指示硬件执行这种条件跳转。
总结

本节课中,我们一起学习了机器语言的基本构成要素。我们了解了机器语言作为硬件与软件接口的核心地位,探讨了设计时成本与性能的权衡。我们学习了机器语言提供的几种主要操作类型:算术运算、逻辑运算和流程控制操作,并认识了硬件原生支持的数据类型(如整数、浮点数)的重要性。我们深入探讨了内存层次结构的概念,特别是CPU内部的寄存器及其在快速数据访问中的作用。我们分析了多种寻址模式(寄存器、直接、间接、立即数),理解了它们如何指定操作数据。我们还简要了解了输入/输出设备如何通过内存映射进行访问,并学习了流程控制的关键机制,包括无条件跳转和条件跳转,它们使得循环和条件分支成为可能。现在,我们已经为接下来具体学习我们的Hack计算机的机器语言做好了准备。
028:Hack计算机与机器语言 🖥️

在本节课中,我们将学习Hack计算机的硬件平台概览及其机器语言的基本语法。我们将了解Hack计算机的核心组成部分,并学习如何使用其两种核心指令——A指令和C指令——来控制计算机。
计算机硬件平台概览
上一节我们介绍了机器语言的一般概念。本节中,我们来看看Hack计算机的硬件平台,因为机器语言的设计与硬件平台的设计是相辅相成的。
Hack计算机是一个16位计算机,这意味着所有信息都以16位为基本单位进行处理、存储和传输。它主要由以下三个核心元素构成:
- 数据内存:一个由16位值组成的序列,每个值存储在一个内存寄存器中。这些寄存器从0开始编号。
- 指令内存:一个独立的存储空间,同样存储着一系列16位值,即机器语言指令。
- 中央处理器:一个能够处理16位值的设备,其核心是算术逻辑单元。
这些组件通过总线连接,你可以将总线想象成拥有16条车道的高速公路,负责在各个部件间传输16位数据块。
如何控制计算机
我们通过软件来控制这台计算机,具体来说,就是使用Hack机器语言编写的程序。一个Hack程序本质上就是一系列用Hack机器语言编写的16位指令序列。
要让计算机执行有用的工作,我们需要遵循以下步骤:首先编写程序,然后将这些16位指令加载到指令内存中,最后按下复位按钮。一旦按下复位按钮,程序便开始运行。
关键硬件寄存器
在深入了解语言语法之前,我们需要认识Hack计算机识别的三个关键寄存器:
- D寄存器:用于存储一个代表数据的16位值。
- A寄存器:用于存储一个16位值,该值可以代表一个数据值,也可以代表一个内存地址。
- M寄存器:代表当前选中的数据内存寄存器。无论数据内存有多大,在任一时刻,只有一个寄存器被选中并可通过M进行访问。
Hack机器语言语法
现在我们已经具备了理解语言语法的基本背景。Hack机器语言包含两种指令:A指令和C指令。
A指令(寻址指令)
A指令的语法是 @value,其中 value 是一个非负十进制常数或指向该常数的符号。
A指令执行时会发生两件事:
- 将A寄存器设置为指定的值。
- 将数据内存中地址为该值的寄存器选为当前寄存器(即M)。
示例:假设我们想将内存地址100的寄存器值设为-1。
- 首先,使用A指令选择要操作的寄存器:
@100 - 然后,使用C指令(见下文)将-1存储到当前选中的寄存器M中:
M=-1
C指令(计算指令)
C指令是语言的核心,大部分操作都在这里进行。其语法包含三个字段:计算、目的地和跳转。
其基本逻辑是:首先进行某项计算,然后可以选择将计算结果存储到某个目的地,和/或根据计算结果决定是否跳转到程序的其他指令。
以下是各字段的详细说明:
计算字段
计算字段指定要执行的操作。以下是一些可用的计算操作示例:
0,1,-1D,A,MD+1,D-1,A+1,M-1D+A,D+M,D&A,D|M!D,!A,!M
目的地字段
目的地字段指定计算结果的存储位置。有8种可能的选择:
null:不存储结果。M:存储到当前选中的内存寄存器。D:存储到D寄存器。A:存储到A寄存器。MD:同时存储到M和D。AM:同时存储到A和M。AD:同时存储到A和D。AMD:同时存储到A、M和D。
跳转字段
跳转字段根据计算结果是否等于、大于或小于零来决定是否跳转。有8种跳转条件:
null:不跳转。JGT:如果计算结果 > 0,则跳转。JEQ:如果计算结果 = 0,则跳转。JGE:如果计算结果 >= 0,则跳转。JLT:如果计算结果 < 0,则跳转。JNE:如果计算结果 != 0,则跳转。JLE:如果计算结果 <= 0,则跳转。JMP:无条件跳转。
指令使用示例
让我们通过几个例子来巩固理解。
示例1:将D寄存器设置为-1
我们查看语言规范,发现-1是可计算的值,D是合法的目的地。因此,指令很简单:
D=-1
跳转字段是可选的,此处无需指定。
示例2:将内存地址300的寄存器值设为 D-1
首先,必须使用A指令选择要操作的内存寄存器:
@300
然后,查看规范,D-1是可计算的值,M是合法的目的地。因此,完整的操作需要两条指令:
@300
M=D-1
示例3:条件跳转
假设我们想实现:如果 D-1 的结果等于0,则跳转到地址56的指令执行(这类似于循环结束条件)。
首先,使用A指令指定目标地址:
@56
然后,计算 D-1,并检查结果是否为0(使用JEQ跳转条件):
D-1; JEQ
如果需要无条件跳转到地址56,则使用:
0; JMP
总结

本节课中,我们一起学习了Hack计算机的基本硬件架构及其机器语言。我们了解到Hack计算机是一个16位系统,由数据内存、指令内存和CPU组成。其机器语言包含两种核心指令:用于选择内存地址的A指令(@value),以及用于执行计算、存储结果和控制程序流程的C指令(dest=comp; jump)。掌握这些基本概念是后续进行编程和深入理解计算机工作原理的基础。在下一单元,我们将探讨这种语言的两种不同表现形式:符号形式和二进制形式。
029:Hack语言规范 🖥️

在本单元中,我们将完成对Hack机器语言的描述和规范说明。
概述
上一节我们介绍了Hack计算机的硬件平台和指令类型。本节中,我们将深入探讨Hack机器语言的完整规范,包括其符号化表示与二进制编码之间的映射关系,并了解一个完整的Hack程序是什么样子。
指令的两种表示形式
与所有机器语言一样,Hack程序可以用两种不同的“风味”或语言来编写。你可以使用助记符和友好的符号进行符号化编写,也可以使用约定的二进制代码来编写。
如果你用符号编写程序,就需要有人将这些程序从符号翻译成二进制代码。一旦程序以二进制代码指定,你就可以将此代码加载到计算机中并实际执行。
我们将用整整一周的时间来讨论这个翻译过程,以及一个被称为“汇编器”的特殊程序。因此,这里不会花太多时间讨论翻译过程,但需要知道,在构建这台计算机时,这是一个必须面对的挑战。
A指令的语法
以下是A指令的符号语法和二进制语法。
符号语法是我们之前见过的:@value。这个值可以是一个最大为 2^15 - 1 的数字,或者是一个引用该数字的符号(关于符号的讨论将推迟到后面的单元)。例如:@21。
以下是同一指令的二进制形式。我们以一个特殊的代码 0 开始,它告诉计算机这是一条A指令。然后,我们使用二进制代码指定与符号指令中相同的值。最终我们得到类似这样的示例:
0 000000000010101
第一个 0 有时被称为操作码。然后是代表我们想要加载到A寄存器中的值的15位。这里,000000000010101 恰好等于二进制的21。
C指令的语法
接下来是C指令。正如你所回忆的,C指令的符号定义非常用户友好:我们有一个可以存储到特定目的地的计算,以及一个可选的跳转指令。这是符号表达的巨大优势。
如果我们想用二进制表达它,就必须决定一些约定的代码。在设计这种语言时,我们已经完成了这项工作。以下是同一符号指令的16位规范:
1 1 1 a c1 c2 c3 c4 c5 c6 d1 d2 d3 j1 j2 j3
第一个 1 是操作码,它告诉计算机这是一条C指令。因为我们只有两种指令类型(A指令和C指令),所以只需要一位来表示操作码(0或1)。因此,操作码 1 表示这是C指令。
接着是两个未使用的位,按照惯例我们将其设为 1。
接下来的七位共同指定了要执行的计算。这些是稍后将发送给ALU的控制位,告诉ALU它必须执行哪种计算。
随后的三位代表目的地。
最后三位代表我们在符号上用 jump 一词表示的跳转条件。
这就是C指令二进制形式的各个字段。
符号到二进制的映射
现在让我们讨论C指令的符号表达式与其二进制表达式之间的映射关系。
计算字段
首先从计算字段开始。这里有一个表格,将符号计算助记符与其二进制等价物联系起来。
表格左侧是符号,右侧是二进制代码,底部还有一个 a 位。
例如,假设计算是 D+1。我们查表,在表格中间某处找到 D+1,并看到它列在 a=0 的列中。因此我们知道 a 位应为 0。然后我们查看表格中该行的其余部分,看到 c 位应为 011111。这就是我们如何用二进制表示 D+1 操作。你只需查表即可得到。
目的地字段
目的地字段的概念非常相似。我们有一个映射,左侧给出符号助记符,下一列给出其二进制代码等价物,它们非常方便地从 000 到 111,因为我们有8种可能的目的地。
同样,如果有人给你一个特定的目的地,比如 M,你可以在表格中查找它,并立即看到它对应于二进制代码 011。
跳转字段
最后,让我们关注跳转字段。概念与目的地字段几乎相同。左侧是助记符,下一列有8种不同的二进制组合。同样非常方便,我们有8种不同的跳转条件,因此它们的二进制等价物也恰好从 000 排列到 111。
这基本上总结了符号和二进制代码之间的映射关系。
完整的C指令规范
如果你想将其全部整合在一起,我们可以在一张幻灯片中完成。这是C指令的完整规范,既有符号版本,也有二进制版本。如果你必须编写一个计算机程序来从一种语言翻译到另一种语言,你可以开始看到如何使用此逻辑来编写该程序。顺便说一下,这是我们将在本课程最后一周要做的事情。
Hack程序概览
现在我们了解了具体指令在二进制和符号形式下的样子,让我们继续讨论Hack程序的整体概念。
以下是一个Hack程序的示例。在课程的这个阶段,你不需要理解这个程序。我们只是给你一个程序外观的初步概览,并可以做一些快速的观察。
首先,一个Hack程序是一系列Hack指令的序列。这个程序是使用符号指令编写的。
允许使用空白。如果你认为这能提高程序的可读性,可以在任何地方插入空行。
欢迎使用注释,并且可以随意使用。
最后,我想说这不是编写Hack代码的好方法。有更好的方法来编写代码,使用更少的数字和更多的符号,这是我们稍后将在课程中做的事情。现在,我只是想让你看到一个典型的Hack程序的示例。
如果我们想在计算机中运行这个程序,首先必须将其翻译成二进制代码。因此,我们需要一个人类汇编员或计算机程序来进行翻译。一旦程序以二进制代码表达,我们就可以将其加载到计算机中并执行。如果程序编写正确,它将有望做一些有用的事情;如果没有,我们可以返回,调试程序,重新编译或重新汇编,重新运行,等等,直到我们满意为止。
总结

本节课中,我们一起学习了Hack机器语言的完整规范。我们探讨了A指令和C指令的符号与二进制表示,以及它们之间的映射关系。我们还看到了一个Hack程序的基本结构。在下一个单元中,我们将讨论如何使用这种语言来控制连接到Hack计算机的输入/输出设备。
030:输入输出单元 🖥️⌨️

在本节课中,我们将学习如何使用机器语言来控制和操作计算机的输入输出设备。我们将重点介绍两种核心设备:显示器和键盘,并了解如何通过操作内存中的位来与它们进行交互。
屏幕内存映射 🖼️
上一节我们介绍了计算机的基本架构。本节中,我们来看看如何扩展这个架构以支持输入输出设备。首先,我们将探讨如何控制屏幕输出。
计算机通过一个称为屏幕内存映射的机制来控制物理显示器。这个内存映射是数据存储器(RAM)中的一个特定区域。物理显示单元会持续不断地从该内存映射的内容中刷新画面,每秒发生多次。
因此,如果程序员想要在屏幕上显示内容,只需操作内存映射中的某些位即可。在下一个刷新周期,内存中的更改就会反映在屏幕上。
屏幕的抽象模型
对于我们熟悉的计算机,显示器是一个二维矩阵。但在Hack计算机的架构中,显示单元被抽象为一个由256行、512列组成的表格。每个行列交叉点是一个像素。由于是黑白屏幕,每个像素只能是开启(黑色)或关闭(白色)状态。
我们通过屏幕内存映射来控制它。该映射由一系列16位的值(称为“字”)组成,总共有8K(8192)个这样的字。这是因为8K * 16 = 131,072,正好等于物理显示器上的像素总数(256行 * 512列 = 131,072像素)。因此,物理显示器上的每个像素都对应内存映射中的一个位。
像素与内存的映射关系
理解如何控制这些位的关键在于建立像素坐标(行、列)与内存地址及位偏移之间的映射关系。这有点复杂,因为屏幕是二维结构,而内存是一维的。
首先,需要记住内存访问总是以16位(一个字)为单位进行的。我们无法直接访问单个位。要操作一个像素,必须先找到包含该像素位的那个字,读取整个字,修改相应的位,然后再将整个字写回内存。
以下是映射规则:
- 内存映射中的前32个字对应屏幕的第0行(Row 0)。
- 接下来的32个字对应第1行,依此类推,直到第255行。
假设我们要操作屏幕上位于 (row, column) 的像素。计算对应内存地址和位偏移的公式如下:
-
计算目标字的内存地址:
address = 32 * row + column / 16
(注意:column / 16是整数除法,即取商舍余) -
计算目标字中的位偏移:
bit = column % 16
(即column除以16的余数,范围是0到15)
找到地址后,我们读取该处的16位字,将第 bit 位设置为1(开启像素)或0(关闭像素),然后将修改后的字写回原内存地址。
硬件实现与地址细节
在Hack架构中,我们使用一个名为 Screen 的芯片来实现屏幕内存映射。它是一个8K的内存芯片,可以读写数据。当我们构建完整的计算机时,这个 Screen 芯片将成为数据存储器的一部分。
这里有一个技术细节需要注意:当直接使用 Screen 芯片时,我们使用上述公式计算出的相对地址。但当 Screen 芯片作为整个数据存储器的一部分时,访问屏幕内存映射需要加上一个基地址。在Hack计算机中,这个基地址是 16384。
因此,在完整计算机中访问屏幕像素的绝对地址为:
absolute_address = 16384 + (32 * row + column / 16)
键盘内存映射 ⌨️
了解了屏幕输出后,我们再来看看如何获取键盘输入。
物理键盘通过电缆连接到计算机,并映射到RAM中的一个特定区域,称为键盘内存映射。与屏幕不同,表示键盘状态只需要一个16位的寄存器。
这个寄存器的工作原理如下:
- 当键盘上的一个键被按下时,该键的扫描码(一个预先约定好的数值)会通过电缆传输,并出现在键盘内存映射寄存器中。
- 当键被释放或没有键被按下时,该寄存器的值为 0。
因此,要检测用户按下了哪个键,程序只需持续读取键盘内存映射寄存器的值。如果值为0,表示没有按键;如果值为非零,则根据扫描码对照表即可确定是哪个键被按下。
在Hack计算机完整的地址空间中,键盘内存映射位于地址 24576 处。
扫描码示例
Hack计算机的键盘支持一个字符子集,每个键都有对应的扫描码。例如(具体数值可能因实现而异):
- 键
k的扫描码可能是 75 - 键
4的扫描码可能是 52 - 空格键的扫描码可能是 32
- 上箭头键的扫描码可能是 131
这类似于ASCII或Unicode编码的概念,用唯一的数字来代表每个字符或控制键。
总结 📝
本节课中,我们一起学习了计算机输入输出(I/O)的基本原理,特别是在底层使用机器语言进行操作的方法。
我们了解到,输出设备(如屏幕)是通过内存映射I/O来控制的。程序通过向屏幕内存映射的特定地址写入数据,来改变屏幕上对应像素的状态。我们学习了如何将二维的屏幕像素坐标转换为一维的内存地址和位偏移。
对于输入设备(如键盘),我们同样通过一个内存映射寄存器来获取状态。程序通过读取该寄存器的值(扫描码)来判断用户当前按下了哪个键。

这些知识揭示了高级编程语言中丰富的图形和输入库是如何在底层硬件基础上构建起来的。虽然直接操作位和内存映射对于现代编程来说非常繁琐,但理解这一底层机制对于掌握计算机系统的工作原理至关重要。在课程的第二部分,我们将探索更高级的软件层次,了解这些底层硬件抽象如何被操作系统和高级语言库封装和利用。
031:Hack编程第一部分

在本节课中,我们将学习如何使用Hack机器语言进行底层编程。我们将从寄存器和内存操作的基础知识开始,了解如何编写简单的程序,并学习如何正确地终止程序以避免潜在问题。
寄存器与内存操作
上一节我们介绍了Hack机器语言的基本指令。本节中,我们来看看如何利用这些指令来操作寄存器和内存。
Hack语言的核心操作围绕三个关键元素展开:A寄存器、D寄存器和内存。我们可以用以下方式表示它们:
- A: 地址寄存器,可存储数据或地址。
- D: 数据寄存器,存储16位数据值。
- M: 代表当前由A寄存器选中的内存单元(即
RAM[A])。
以下是几个基础操作的代码示例:
将常数10存入D寄存器
由于没有直接指令,我们需要间接操作:
@10
D=A
将D寄存器的值加1
这可以通过一条C指令直接完成:
D=D+1
将RAM[17]的值存入D寄存器
首先需要选中内存地址,然后读取:
@17
D=M
将D寄存器的值存入RAM[17]
操作与读取相反:
@17
M=D
将常数10存入RAM[17]
结合前几个操作:
@10
D=A
@17
M=D
将RAM[5]的值复制到RAM[3]
@5
D=M
@3
M=D
编写一个完整的程序
理解了基本操作后,我们可以将它们组合成一个有实际功能的程序。接下来,我们编写一个将两个数相加的程序。
假设我们要将存储在 RAM[0] 和 RAM[1] 中的两个数相加,并将结果存入 RAM[2]。程序逻辑如下:
- 读取
RAM[0]的值到D寄存器。 - 读取
RAM[1]的值并与D寄存器当前值相加。 - 将结果(D寄存器的值)存入
RAM[2]。
对应的Hack程序代码如下:
// 程序:两数相加
// 用法:将待相加的数存入 RAM[0] 和 RAM[1]
// 结果将出现在 RAM[2] 中
@0 // 选中 RAM[0]
D=M // D = RAM[0]
@1 // 选中 RAM[1]
D=D+M // D = D + RAM[1]
@2 // 选中 RAM[2]
M=D // RAM[2] = D
程序执行与潜在问题
在CPU模拟器中加载并运行上述程序,可以验证其功能。然而,运行结束后,程序计数器会继续向下移动,执行后续内存单元中的内容。这些内容可能是无意义的“空指令”,也可能被恶意利用,引发“空操作雪崩”攻击。
为了避免程序结束后失去控制,最佳实践是在程序末尾添加一个无限循环,主动控制程序流。例如:
(END)
@END
0;JMP
这段代码会形成一个无限循环,将程序计数器锁定在指定标签位置,从而安全地终止程序。
预定义符号与编程风格
Hack语言提供了一些预定义符号,使代码更易读写。
虚拟寄存器
为了方便引用前16个内存单元,可以使用符号 R0 到 R15。汇编器会自动将其转换为对应的地址。例如,@R5 等价于 @5。使用虚拟寄存器能使代码意图更清晰。
I/O映射基地址
SCREEN 和 KEYBOARD 分别代表屏幕和键盘内存映射的基地址。
其他符号
还有六个用于高级语言编译器和虚拟机实现的符号(如 SP, LCL 等),它们在本课程的第一部分不会用到。
注意事项
Hack语言对大小写敏感。R5 和 r5 是不同的符号,错误使用会导致难以排查的bug。
总结

本节课中我们一起学习了Hack机器语言编程的基础。我们掌握了如何使用A指令和C指令来操作寄存器和内存,编写并运行了一个简单的加法程序。我们还了解了在程序末尾使用无限循环来正确终止程序的重要性,并认识了利用虚拟寄存器等预定义符号来提升代码可读性的最佳实践。下一节,我们将探讨分支、变量和迭代等更复杂的编程概念。
032:Hack编程第二部分

在本节课中,我们将学习Hack汇编语言中更高级的编程概念,包括分支、变量和迭代。这些概念是构建复杂程序的基础,我们将通过具体的例子来理解它们是如何在底层实现的。
分支(Branching)
上一节我们介绍了如何在内存中操作寄存器,这是低级机器语言的基础。本节中我们来看看更接近高级程序员熟悉的编程概念,例如分支、变量和迭代。
让我们从分支开始。分支是计算机的一项基本能力,它告诉计算机评估一个特定的布尔条件或布尔表达式,并根据这个值决定是否要跳转并执行程序中的另一段代码,或者简单地继续执行程序中的下一条指令。
任何编程语言都有各种分支机制。例如,如果你用Java或Python编写程序,你会用到if-else、while、repeat、switch等结构。在机器语言中,我们通常只有一种分支机制,称为Goto。
以下是一个使用Goto的具体例子。在这个程序中,我们想检查某个数据值,并判断该值是否为正数。我们约定这个值存放在RAM[0]中。因此,使用此程序的用户有责任将一个值放入RAM[0]。当用户执行程序时,程序开始运行。执行结束时,程序将在RAM[1]中放入值1(如果RAM[0]为正数)或值0(其他情况)。这本质上是一个典型的if-else结构。
不幸的是,我们无法在低级编程中直接表达这种结构。低级编程更加精简和基础,因此我们必须努力实现它。以下是我编写的一些代码来完成这个操作,可能不是唯一的方法,但它是有效的。
// 检查R0是否大于0,如果是,设置R1为1
@R0
D=M
@8
D;JGT
@R1
M=0
@10
0;JMP
@R1
M=1
@10
0;JMP
我用红色高亮显示了实际执行“如果R0大于0则设置R1为1”计算的代码部分。让我们暂时忽略程序的其余部分。在第8和第9条指令中,我负责将R1设置为1。在第2和第3条指令中,我检查现在存放在D寄存器中的数据输入值,并根据这个值决定是否要跳转到第8条指令,在那里我将实际把R1设置为1。
如果你不确定这是如何工作的,我建议你暂停视频,在脑海中或用纸笔模拟运行这段代码,确保自己理解分支在这里是如何工作的。
现在,作为一个实验,让我去掉行号和注释,只盯着这段代码看。如果我也去掉前面的注释,我认为剩下的部分相当难以阅读。这是一个晦涩代码的例子,很难理解这段代码试图做什么。然而,这段代码将完美运行。这是一个无懈可击的程序。如果你将其翻译成二进制代码并加载到计算机中,它将完美工作。它将实现所需的功能,用户会非常满意,但程序本身不可读。
现在,这是引用Donald Knuth名言的最佳时机。他说,与其认为我们程序员的主要任务是指导计算机做什么,不如让我们专注于向人类(即其他程序员)解释我们想让计算机做什么。这一点非常重要,因为如果我们不具备这种能力,如果我们的程序不能自我说明,我们将无法修复和扩展它们。
那么,我们能做些什么来使这个程序更具可读性呢?幸运的是,汇编语言有一个非常好的特性,称为符号引用。
符号引用(Symbolic References)
让我们看看我在这里做了什么。我引入了一个自己创建的标签,并决定称之为POSITIVE,因为这是一个合理的标签选择,因为它大致描述了我试图做的事情。这个POSITIVE标签在代码中出现了两次。首先,我声明了这个标签。基本上,我在这里说的是:这里有一段代码,我想从程序的其他地方跳转到这里,我将称这个代码位置为POSITIVE。
当我们设计Hack汇编语言时,我们决定通过直接书写标签并将其括在圆括号中来声明标签。因此,这是在Hack汇编语言中声明标签的方式。而这是我们使用标签的方式。所以,我们不再说@8,而是说@POSITIVE。汇编器(翻译器)的工作就是将这些标签翻译成具体的数字。让我们观察一下这是如何发生的。
再次强调,一般规则是:@标签将被翻译成@n,其中n是标签声明之后的下一条指令的编号。请记住,指令有隐含的编号。当我将这段代码加载到指令内存时,会发生以下情况。
首先,进行翻译的人必须按照某种约定操作。例如,如果你编写汇编器(我们将在第6周做这件事),我们会告诉你语言做出了一些假设和约定,你必须根据这个约定来编写汇编器或执行翻译。首先,请注意标签声明本身不会被翻译。这里我们有两个标签声明:POSITIVE和END,一个在第8行之前,另一个在第10行之前。它们不会被翻译,不会生成任何代码,这有点令人惊讶。
那么,对标签的引用呢?对标签的引用肯定会被翻译。原来的@POSITIVE变成了@8,因为8是POSITIVE之后的下一条指令。原来的@END出于同样的原因变成了@10。
因此,我们约定的第二部分是:对标签的每个引用都被替换为对该标签声明之后的下一条指令编号的引用。
一旦我们记住这一点,我们就可以愉快地发明和使用任意多的标签,然后使用这些标签来实现分支的概念。
变量(Variables)
处理完分支后(显然我们还会回到这个话题,因为没有分支就无法编写任何非平凡的程序),让我们继续讨论另一个抽象概念:变量。
变量是一个具有名称和值的容器的抽象。在高级语言中,我们有不同类型的数据和变量。然而,在机器语言的底层,或者至少在Hack机器语言的底层,我们只有一种变量类型。我们只需要处理16位的值。这个抽象可以通过在数据内存中使用单个寄存器来实现。因此,如果我们想在程序中创建变量,我们使用单个寄存器来表示每一个变量。
以下是一个变量发挥作用的例子。在这个程序中,我想交换RAM[0]和RAM[1]的值。我使用了一个经典的编程惯用法来实现,这个惯用法几乎出现在所有基础编程课程中。我创建一个变量,通常我们称之为temp,以表示它是一个临时变量,我只为此目的需要它。然后我进行一些操作,最终实现所需的交换。
问题在于,如何在迄今为止还没有变量概念的机器语言中做到这一点?这是一个引入它们的好机会。以下是我决定如何做的方法,让我们只关注这个程序中的红色指令。特别是在第一条红色指令中,我声明了一个名为temp的变量,然后我立即开始使用它。
现在,这个神秘的命令@temp是什么意思?请注意,与标签和引用不同,我们没有一个名为T的对应标签。那么,当我们这样说@temp时,我们是什么意思呢?基本上,我们向计算机提出以下请求:请到内存单元中,找到一个可用的内存寄存器。让我们假设它是寄存器号N,并从现在开始用它来代表我更喜欢称之为变量temp的东西。
因此,从现在开始,每当你看到一个符号指令@temp,我请求你将其视为我们已经知道如何处理的@N指令。这就是我实现这个变量抽象的方式。
让我们观察一下当你翻译这个程序并将其加载到指令内存时会发生什么。第一个观察结果是,当你查看代码时,你会发现原来的@T变成了@16。因为@T在程序中出现了两次,所以我们有两个@16指令的出现。这是如何发生的呢?
编写汇编器或实现这个翻译器的人是根据我们作为语言设计者制定的约定来工作的。该约定有两条规则。首先,对没有相应标签声明的符号的引用被视为对变量的引用。我们假设程序员想要创建一个变量。第二条规则是,变量从地址16开始分配到RAM中。
在这个特定的例子中,我们只有一个变量,所以它最终被分配到RAM[16]。如果程序中有更多变量,我们将使用RAM[17]、RAM[18]等位置。实际上,你可以使用任意多的变量,我可以放心地说,因为通常程序只使用半打或一打左右的变量。在Hack语言中,你实际上可以使用数千个变量,但没有人真的会用到那么多变量。
关于变量这一小节的结尾,如果你看这段代码,你会发现这个程序相对容易阅读和调试。我们有一个符号标签和一个符号变量T,与之前我们使用实际数字表示地址时编写的程序相比,理解这里发生了什么要容易得多。
这个程序还有另一个非常微妙的优点,那就是这个程序是所谓的可重定位代码。我可以把这个程序加载到内存中,不一定是从地址0开始。我可以把它放在内存中任何我想要的地方,只要我记得我为这个程序使用的基础地址。这一点非常重要,因为正如你所知,当你在个人电脑上工作时,通常同时有几个程序在执行,这意味着有几个程序被加载到你的主内存中。一旦我们使用符号引用仔细编写这些程序,我们就不必担心它们将位于内存中的哪个位置。我们可以编写一个叫做“加载器”的东西来处理这个技术细节。
因此,符号程序是好的。
迭代(Iteration)
接下来我想讨论的编程惯用法是迭代。
例如,假设我们想计算级数1 + 2 + 3 + ... + n的和,其中n是某个给定的输入。我们该怎么做?通常,我们使用某个累加器变量来累积级数的运行总和。你将这个变量设置为0,然后0+1得到1,1+2得到3,3+3得到6,依此类推,直到达到n。
如果你必须在低级语言中编写这个程序,最好从一些面向低级的伪代码开始。这就是我在这里所做的。一旦你确信这个伪代码确实实现了所需的功能,我们就可以继续在纸上将这个伪代码翻译成符号机器语言。
让我们从这个程序的第一部分开始,我们声明并初始化三个变量n、i和sum。首先,请注意,我的伪代码中使用的变量名称与符号语言中的变量名称之间存在一一映射关系,这很好。我们有这三个变量。让我们假设只翻译程序的这一部分并将其加载到指令内存中,以下是我将得到的结果:@n将被翻译成@16,@i翻译成@17,@sum翻译成@18。这应该不会令人惊讶,因为它遵循了我们之前讨论的约定:变量从16开始分配到连续的RAM地址中。
让我们记住这一点,并完成将伪代码翻译成机器语言的工作。这个程序的细节并不是特别重要(当然,如果你实际编写这个程序,它们很重要),但对于我想在本节中强调的内容来说并不重要。但我确实希望你再次暂停视频,确保自己理解这段代码在做什么。
顺便说一下,当你编写这样的程序时,如果我给你这个程序作为作业,我强烈建议你先用伪代码编写。然后我建议你调试你的伪代码,并说服自己伪代码确实有效。一旦你确信它有效,你就可以将编写机器代码的任务简化为仅仅从伪代码翻译成机器语言。你看,这样做要容易得多。你看着像n = R0这样的伪代码命令,然后编写一组在机器语言中做同样事情的指令。或者看看伪代码中loop指令后面的指令,它说if i > n goto stop。只专注于翻译这条指令到机器语言要容易得多。这是编写低级代码并说服自己它有效的一种方法。
验证代码有效的第二种方法(同样重要)是,一旦你的程序编写完成,你必须模拟它,或者我们建议你在纸上使用所谓的跟踪表来模拟它。跟踪表是一张纸,你在上面写下程序中所有主要参与者或你感兴趣的主要变量的名称,然后在表中记录这些变量在整个程序执行过程中的值。这些是迭代0时这些感兴趣变量的值。然后你遍历循环,找出第一次迭代、第二次迭代、第三次迭代等之后发生的情况。你这样做三到四次迭代,试图说服自己程序确实在工作。
最佳实践总结
以下是编写Hack机器语言程序的最佳实践建议。
首先,你必须使用某种伪代码来设计程序,你可以遵循我们在上一张幻灯片中使用的伪代码示例。
其次,你必须获取你的目标代码,并使用Hack汇编语言重新表达它。
最后,我们建议你使用某种跟踪表在纸上测试最终的程序。请注意,我是在没有接触计算机的情况下完成所有这些事情的。一切都是在纸上完成的,尽管我可能使用了文本编辑器来编写程序,但对我来说,文本编辑器就像使用一张纸一样。在你真正在计算机上运行程序之前,说服自己程序正在工作是非常重要的。

本节课中我们一起学习了分支、变量和迭代。在下一单元中,我们将讨论指针和输入/输出,这将结束我们对低级Hack编程的讲解。
033:Hack编程第三部分

在本节课中,我们将要学习Hack编程的最后两个核心概念:指针以及如何操作输入/输出设备。我们将通过具体的例子,了解如何在机器语言层面处理内存地址和与屏幕、键盘进行交互。
指针
上一节我们介绍了变量和迭代,本节中我们来看看指针。指针是存储内存地址的变量。在高级语言中,数组的概念很直观,但在机器语言层面,数组被翻译为一段连续的内存区域,我们只知道其基地址和长度。
考虑以下高级语言代码片段,其目标是将数组arr的前n个元素设置为-1:
for (int i = 0; i < n; i++) {
arr[i] = -1;
}
在机器语言中,数组arr只是一个起始于地址100的内存段。我们需要通过指针运算来访问每个元素。以下是实现该逻辑的伪代码:
arr = 100
n = 10
i = 0
LOOP:
if i == n goto END
RAM[arr + i] = -1
i = i + 1
goto LOOP
END:
在Hack机器语言中,关键步骤是计算目标地址arr + i并将其存入A寄存器,然后操作该地址指向的内存单元。相关指令序列如下:
@arr
D=M
@i
A=D+M
M=-1
变量arr和i就是指针。访问指针指向内存的典型逻辑是:将地址寄存器A设置为某个内存寄存器(其中存储了地址值)与偏移量计算后的结果。
输入/输出操作
上一节我们介绍了指针,本节中我们来看看如何利用指针操作与输入/输出设备交互。Hack计算机有两个标准I/O设备:显示器和键盘,它们分别映射到RAM中的特定区域。
屏幕操作
屏幕被映射到一段称为“屏幕内存映射”的RAM区域。向该区域写入数据会立即反映在物理屏幕上。我们的任务是编写一个程序,在屏幕左上角绘制一个矩形,宽度固定为16像素,高度由用户通过RAM[0]指定。
以下是绘制矩形的伪代码逻辑:
addr = SCREEN
n = RAM[0]
i = 0
LOOP:
if i == n goto END
RAM[addr] = -1 // -1的二进制是16个1,代表16个黑色像素
addr = addr + 32 // 移动到下一行的起始地址(每行32个字)
i = i + 1
goto LOOP
END:
将上述伪代码翻译成Hack机器语言,核心部分再次用到了指针操作:计算当前行的起始地址addr,存入A寄存器,然后对该地址的内存赋值-1。这与此前操作数组的指针逻辑完全一致,只是操作的对象是屏幕内存映射区。
键盘操作
键盘被映射到RAM中一个特定的寄存器(地址24576,可用预定义符号KBD表示)。当用户按下某个键时,该键的扫描码会出现在这个寄存器中。
检测当前按键的伪代码如下:
@KBD
D=M
// 此时D寄存器中即为当前按键的扫描码。若D为0,则表示没有按键。
在底层编程中,我们只能获取当前瞬间的按键状态。构建更复杂的输入逻辑(如读取字符串)通常需要在高级语言中完成,然后编译成机器语言。
总结与展望
本节课中我们一起学习了Hack编程的最后两个主题:指针和I/O操作。我们了解到:
- 指针是存储地址的变量,通过
A=D+M等指令进行地址计算,从而间接访问内存。 - 屏幕通过内存映射进行控制,向特定内存区域写入数据即可绘制图形。
- 键盘状态通过读取特定内存地址(
KBD)来获取。
至此,我们已经完成了对Hack机器语言编程的全面概览,涵盖了寄存器操作、分支、变量、循环、指针和I/O。底层编程虽然繁琐,但因其极简而强大的表达能力,充满了智力挑战。
正如一句名言所说:“简单头脑为复杂事物所震撼,智慧头脑为简单事物所震撼。” Hack语言仅用两种指令和符号能力,就能表达任何程序逻辑,这本身就是一个令人满意的成就。

在接下来的单元中,你将有机会亲自编写Hack程序。而在本课程的第二部分(Nand2Tetris项目第二部分),我们将深入探索如何编写一个编译器,将高级语言程序自动翻译成这种底层机器语言,从而构建起完整的软件抽象层次。
034:项目4概述 🖥️


在本节课中,我们将概述课程项目4的内容。我们将了解需要编写的两个程序,并学习在Hack机器语言中进行程序开发的一般流程和最佳实践。
项目4简介
上一节我们介绍了Hack汇编语言和硬件平台。本节中,我们来看看项目4的具体任务。项目4的目标是让你亲手操作硬件,用近乎机器级别的底层语言进行编程。
项目4包含两个相对简单的程序:
- 第一个程序执行代数运算。
- 第二个程序与用户交互,操作屏幕和键盘。
程序一:乘法运算(Mult)
第一个程序名为 Mult,其目的是计算内存中 R0 和 R1 两个值的乘积。
以下是该程序在CPU模拟器中执行结束时的截图。在RAM顶部可以看到,RAM[0] 是6,RAM[1] 是7,而 RAM[2] 是42。这正是该程序的功能:将内存中前两个寄存器的值相乘,并将乘积存入 RAM[2]。
程序执行的假设是:用户将此程序加载到指令内存后,还需在 RAM[0] 和 RAM[1] 中放入两个数字,然后点击运行按钮。如果一切正常,程序将计算出这两个数字的乘积。
在屏幕右侧区域,我们提供了一个测试脚本。该脚本使用我们设计的多组数字来测试你的程序。你可以查看脚本以了解我们将对你的程序进行何种测试。
需要指出的是,在模拟器和CPU模拟器中,屏幕被用作一种多功能设备:有时它作为真实的物理屏幕,有时我们用它来显示测试脚本。
再次强调,在这个区域你会看到程序的输入(两个数字)和输出。现在的问题是:我们如何编写这样的程序?
回想一下,Hack机器语言没有乘法操作,我们只有加法和减法。因此,我们面临的挑战是使用加法和减法来表达乘法运算。这里不需要多说,只再给一个提示:你需要使用一个循环,并通过这个循环以某种方式计算结果。
顺便提一下,选择学习本课程第二部分(Nand to Tetris Part 2)的同学,还将经历开发操作系统的过程。我们的操作系统将提供一个包含各种数学运算的数学库,其中就包括乘法。操作系统将使用的乘法算法非常高效,但在这个练习中,我们不期望你做到那种程度。我们只期望你能以某种方式将两个数字相乘,你可以尝试尽可能高效地完成,但我们不要求你费尽心思去设计非常花哨的乘法算法。
程序二:交互式屏幕控制(Fill)
第二个任务是编写一个简单的交互式程序,执行以下操作。
这个程序监听键盘。只要用户不操作键盘,就不会发生任何事情。但是,一旦用户按下键盘上的任意一个键,程序就会将整个屏幕变黑。当用户松开按键时,屏幕又会变清晰。再次按下任意键,屏幕变黑;松开按键,屏幕变清晰。这就是该程序的功能。
程序会进入某种无限循环,始终监听键盘并做出相应动作。
实际上,这里有两个独立的挑战:
- 探测键盘,判断是否有按键被按下。
- 能够将屏幕变黑或变白(本质上是相同的操作,只是向内存映射写入不同的值)。
这个练习相对简单的一点在于,我们操作的是整个屏幕内存映射,因此不需要精确地选择特定像素来打开或关闭。我们只需采用一种“霰丨弹丨枪”式的方法:要么打开所有像素,要么关闭所有像素。从这个角度看,程序并不非常复杂,总的来说也不是一个特别复杂的程序,但它很有趣。它让你看到如何使用标准的Hack机器语言代码来控制外围设备。
以下是通用的实现策略:
- 监听键盘。
- 为了将屏幕变黑或变清晰,我们编写代码,用白色或黑色像素填充整个屏幕内存映射。
- 为了做到这一点,我们必须寻址内存映射中的每个寄存器。我们使用某种操作指针的循环来实现,这与之前讨论指针操作的单元中所做的工作非常相似。因此,你确实拥有实现这个特定程序所需的所有能力。
为了在CPU模拟器中测试这个程序,请确保选择了“无动画”选项。我们无法(或者说可以,但认为太复杂)提供一个测试脚本来测试这个程序,所以你只需像我演示的那样进行测试:按下按键,松开按键,并希望屏幕能相应地变黑或变白。
如果你愿意,也可以思考一些花哨的方式来使屏幕变黑或变白。例如,你可以不按行操作,而尝试按列操作,或者创建一些逐渐增长、旋转的图案等等。你可以发挥想象力,用任何你想要的方式来使屏幕变黑或变白,这实际上也是这个特定程序对你的期望。
程序开发流程
接下来,我想描述一下使用Hack机器语言进行程序开发的一般流程或周期。
首先,你的程序将驻留在普通的文本文件中,你可以使用任何文本编辑器编写。启动你喜欢的文本编辑器,编写程序,并使用 .asm 扩展名保存。按照惯例,我们给程序起的名字以大写字母开头,例如 Do.asm。然后,你将这个程序原样加载到CPU模拟器中。如前所述,CPU模拟器有一个很好的服务:当你加载程序时,它会自动将程序翻译成二进制代码。接下来,你可以在CPU模拟器上运行程序,并自问是否对结果满意。如果满意,你就完成了,可以将程序提交给我们。如果你对程序不满意(在最初的几次调试迭代中很可能如此),那么你就修复程序:查找错误并修正。修正错误的方法是回到文本编辑器,在编辑器中修改,再次保存程序,重新加载到CPU模拟器中,如此循环。因此,在屏幕上同时打开两个窗口非常方便:一边是文本编辑器,另一边是CPU模拟器,你可以轻松地在两者之间切换,但别忘了在修复后加载更新后的程序。
CPU模拟器的错误诊断功能相当原始。当你将程序加载到CPU模拟器时,如果程序存在语法错误,CPU模拟器会拒绝加载它,并发出一些晦涩的错误信息。它会提供一些非常重要的信息:出错的行号以及发现的第一个错误。然后你可以找到这一行并修复问题。一旦你解决了语法错误,真正的问题(即运行时错误)就开始了,这些错误更难检测。你通常需要通过观察程序的执行、发现某些部分工作不正常来检测它们,然后再次经历相同的循环:修复程序,直到它按你的期望工作。
这就是使用CPU模拟器和文本编辑器开发程序的一般机制。正如你所见,它相当简单和友好。
最佳实践与建议
最后,我想就注意事项和最佳实践说几句。
没有理由真的将机器语言程序视为某种奇怪的动物。它只是编程制品的又一个例子,所有适用于高级语言编程的原则在底层同样适用。我们期望你的程序简短(或不必要地冗长),它们应该简短、高效、优雅且自文档化。因此,我们期望你为程序添加文档,但要运用判断力,不要过度注释。我认为最好的做法就是参考我们提供的示例,并遵循类似的风格。通常,我们使用一些高级操作来描述:我们开始一个注释,写下类似“如果 I 大于 N,则跳转到...”的内容,然后写下实际实现此语义的六七条机器代码指令。
以下是一些非常重要的技术提示,它们将使你的工作更轻松:
- 必须使用符号变量和标签。否则,你的程序将变得极其复杂和晦涩。如果你想跳转到程序中的某个地方,请使用标签。如果你想重复存储某些内容,请创建一个变量名。写完程序后,检查一下,确保没有看到任何实际的地址,一切都应该是符号化的。这是编写良好的机器语言程序的一个重要优点。
- 为变量和标签使用合理的名称。与常规编程一样,当你创建变量和标签时,请使用有意义的名称。不要使用像
GU5,31%这样晦涩的名称,而应使用像loop、end、stop、positive、negative等名称,具体取决于你想做什么。尝试使用合理的标签名,同样,变量名也应使用像i、sum、count等好名字。 - 变量使用小写,标签使用大写。如果你回顾我们的程序示例,会发现所有标签(通常是像
END、STOP这样的词)都是大写的,而所有变量(如i、x、y等)都是小写的。遵循这个约定(顺便说一下,这不是汇编程序的要求)将使你很容易区分变量和标签。因此,当你看到一条A指令时,如果符号是小写的,你就知道它是一个变量;如果是大写的,你就知道它指的是一个名为该符号的标签。 - 使用缩进。再次遵循我们在之前单元中给出的示例,确保你的程序看起来美观且易于阅读。
- 始终建议从伪代码开始。编写机器级代码总是具有挑战性,如果你的生活会更轻松:首先用某种你可以为自己设计的高级语言构思或编写程序,或者再次参考我们的示例。一旦程序在伪代码中运行良好(这是你必须在纸上检查的东西),你就可以将其翻译成机器语言并继续调试过程。
所需资源
你可以在哪里找到所需的一切?和往常一样,访问 Nand to Tetris 网站,查看项目4。此项目所需的所有文件都在该网页中有描述。你不需要下载任何东西,因为如果你在课程开始时下载了软件套件,现在你的个人电脑上已经有一个名为 projects/04 的目录,该目录已经包含了你在项目4中必须使用和操作的所有文件。

本节课中,我们希望能为你提供一些关于如何编写项目4程序的提示。现在,我们将进入本周的最后一个单元,回顾我们在整个星期中所做的工作。
035:视角与展望 🧭

在本节课中,我们将探讨Hack机器语言与典型计算机(如个人电脑)机器语言的异同,并讨论其设计选择背后的原因。我们还将了解在实际开发中,程序员如何与机器语言互动。
Hack机器语言与典型机器语言的比较
上一节我们介绍了机器语言并编写了一些低层级的Hack汇编程序。一个常见的问题是:Hack机器语言与典型计算机(如个人电脑)的机器语言有何不同?
Hack机器语言非常简单,因为它被设计运行在一个非常简单的硬件平台之上。我们特意构建了这样一个架构简单的计算机,目的是为了能在六周的课程时间内实际构建它。因此,这台计算机虽然简单,但其功能足够强大,能够提供几乎所有必需的功能,具体原因稍后解释。
典型的机器语言比Hack指令集要丰富得多。它们提供更多的命令、更多的指令类型、更多的数据类型(如浮点数)以及更多的操作(例如乘法和除法)。然而,正如之前所说,无需担心,因为其他语言提供的所有这些高级功能都可以在软件层面、在更高的抽象层级上实现。这正是我们在本课程第二部分(Nand2Tetris项目2)中要做的事情:我们将构建一个操作系统,并引入当前机器语言不支持的各种操作。
指令格式与内存寻址
看起来在Hack语言中,每当你想做一些有意义的事情时,都需要两条机器语言指令:一条用于寻址内存,另一条用于对选定的内存寄存器进行操作。这是标准的做法吗?
并非如此。大多数机器语言比我们的更复杂、更强大,通常允许你在一条机器语言指令中同时指定操作符和操作数。例如,你可以说“我想进行加法运算,并且想使用这个内存位置”,并将所有这些信息打包到一条机器语言指令中。
在我们的Hack计算机中,我们只有16位的指令。在16位中,很难同时放入大量关于操作的信息和大量关于操作数的信息。特别是,你无法放入一个完整的内存地址以及关于你想执行何种操作的更多信息。因此,在我们的案例中,你必须将指定地址(这本身需要15位)和实际指定操作(这需要再多几位)的步骤分开。
在其他机器语言中,它们通常有更宽的机器指令,有时甚至是可变宽度的机器指令,这样它们就能将更多信息打包到一条机器指令中。话虽如此,从先前的命令中获取机器操作将要处理的部分地址的基本思想,并非罕见之事,许多机器语言确实具备这种特性。
Hack语法设计的选择
一个相关的问题是,Hack机器语言确实有一些奇怪或特殊的语法规则。你能谈谈语法选择的原因以及它与常规机器语言语法的区别吗?
与Java等高级语言不同,机器语言的设计目的不是为了让人感到愉悦,而是为了在硬件平台上运行。因此,机器语言的命令必须直接与算术逻辑单元、内存和寄存器打交道,并且必须极其高效,这也是机器语言如此简单的原因之一。
正如我们之前讨论的,Hack机器语言非常简单,因此我们也决定让它的语法比你在常规机器语言中看到的更简单一些。例如,如果你想计算加法并将结果存储在某个寄存器中,在Hack语言中你会说类似 D = D + M 的话。
而在典型的机器语言中,这类命令通常以操作符的说明开始。通常会有类似 ADD 的指令,同时附带一些地址,你可以在一条指令中同时指定地址、目标寄存器和操作符。因为在典型的机器语言中,你有32位或64位,有足够的空间将所有信息打包到一条命令中,而在Hack中我们只能这样做。
同样,如果你想将一个寄存器的内容放到另一个寄存器中,在Hack中你会说类似 D = M 的话,而在典型的机器语言中通常会说类似 LOAD M 的话。同样,你可以写一个地址来代替 M,并将所有这些信息打包到一条指令中。
实际开发中的机器语言
本视角单元的最后一个问题是:人们真的必须费心用机器语言编写程序吗?
答案是,人们并不真正用机器语言编写程序。相反,一些开发者有时会编写生成机器代码的程序,这些程序被称为编译器。编译器是一种程序,它接收用某种高级语言(如Java)编写的另一个程序,并将其翻译成二进制代码。因此,为了编写编译器,显然你必须理解编译器需要生成的机器语言。这正是我们在Nand2Tetris课程第二部分要做的事情,我们将实际为一个高级语言编写编译器,这个编译器将生成能在本课程构建的机器上运行的Hack代码。
不过,我之前所说的有一个例外。在某些应用中,特别是在实时系统或性能至关重要的应用中,程序员有时不得不查看机器语言代码的复杂细节,如果他们想优化自己的程序。他们不是用机器语言编写程序,而是通常用像C编程语言这样的语言编写。但是,一旦他们将程序翻译成机器语言,他们会查看代码,如果代码看起来非常混乱或不必要地冗长,他们可以返回去重写他们的高级代码。但在大多数情况下,大多数程序员只使用高级语言编写程序,仅此而已。
总结
本节课中,我们一起学习了Hack机器语言与典型机器语言在设计哲学、指令格式和语法上的关键区别。我们了解到Hack的简洁性源于其教学目的和硬件限制,而更丰富的功能可以通过高级软件(如操作系统和编译器)来实现。最后,我们探讨了在实际编程工作中,直接使用机器语言的情况非常罕见,但理解其原理对于进行底层优化和开发系统软件至关重要。
036:冯·诺依曼架构 🖥️

在本节课中,我们将学习计算机的核心设计思想——冯·诺依曼架构。我们将了解计算机如何通过统一的架构来运行任何程序,并深入探讨其内部主要组件(如CPU、内存)是如何通过数据、地址和控制信号协同工作的。
上周,我们描述了本课程要构建的计算机应该具备的功能。本周,我们将面对更艰巨的任务:实际实现它,构建一台能够执行我们之前承诺的所有功能的计算机。
让我们回顾一下我们所说的计算机将要完成的任务。最令人惊叹的一点是,一台计算机能够运行任何类型的程序或软件。这意味着它应该能够从软件获取指令并执行它们,从而得到一个非常灵活、无所不能的单一机器。这个思想在理论界被称为“通用图灵机”,而实际实现它的架构被称为“冯·诺依曼架构”,这正是我们今天要构建的。
上周,我们很高兴能拥有这样一个神奇的设备来执行我们的指令。本周,我们将付出代价,看看如何实现如此灵活的功能。我们已经看到了实现这一点的通用架构:我们将有一大块内存,用于两个目的。首先,存储计算中要使用的各种数据。但更有趣的是,内存将保存一个程序,即一系列将要被逐一执行的指令。
除此之外,我们还将拥有中央处理单元,它负责实际执行这些指令、运行它们并控制一切。
现在,让我们更仔细地看看这是如何实际发生的,中央处理单元是如何构建的,以及所有来回的控制是如何工作的。我们将以一般性的方式描述这个单元,稍后我们会更具体地讨论为本项目构建的计算机。
一般来说,我们的CPU将由两个主要部分组成。其中一个通常被称为算术逻辑单元,它实际上是一块能够进行数字加法、减法以及逻辑运算等操作的硬件。第二个元素是一组寄存器,即我们可以存储数据的地方,这些数据将用于后续的计算。这基本上就是CPU构建的核心元素。
如前所述,内存本身有两个部分:存储程序的部分和存储数据的部分。
为了理解所有这些部分如何协同工作,最好考虑数据的流动,即计算机内部需要传递哪些类型的信息。我们现在就要尝试这样做,描述计算机中传递的各种信息以及我们如何控制它们。
基本上,系统内通常传递三种类型的信息。其中一种是数据,当我们需要对数字进行加法运算时,这些数字需要从一个地方移动到另一个地方,从数据内存到寄存器,再到实际执行操作的算术逻辑单元,然后再返回。
我们需要控制的第二种信息被称为地址。我们现在正在执行哪条指令?我们现在需要访问内存中的哪块数据?这些都是地址。当然,还需要有一组电线来实际执行所有控制,告诉系统的每个部分在特定时刻该做什么,这被称为控制信号。有时,这些信息中的每一种都将通过一组电线(有时称为总线)来实现。因此,在一台典型的计算机中,我们将有一条数据总线、一条地址总线,有时控制位被称为控制总线,有时它们只被称为控制线。让我们看看计算机中的不同部分,确切了解它们接收和发出什么类型的信息。
让我们从算术逻辑单元开始。这是计算机概念上最简单、最清晰的部分。它基本上需要能够接收数字并对它们进行加法、减法以及一些逻辑运算。这很简单。我们只需要让来自数据总线的信息连接到ALU,然后将ALU的输出反馈回数据总线。当然,它还需要连接到数据总线上其他部分,如内存或寄存器。
ALU还需要连接到另一种信息,即控制总线。一方面,ALU需要知道每次执行什么类型的操作。因此,它必须从控制总线获取信息,以指定其执行的操作类型。另一方面,根据其执行的算术或逻辑运算的结果,它需要能够告诉系统的其他部分该做什么。例如,如果它发现某个数字大于零,这可以控制下一条指令的跳转。这种控制将通过控制位发生。因此,我们还需要从ALU获取一些信息,并将其反馈回来以控制系统的其余部分。
现在让我们考虑寄存器。寄存器在概念上也非常简单。我们在寄存器中存储中间结果,因此我们需要能够从数据总线将数据输入寄存器,然后从寄存器取出数据反馈回数据总线。当然,数据会从数据总线流向系统的其他部分,例如ALU。
因此,第一件事当然是,我们必须将所有寄存器连接到数据总线。
第二种信息是,有时,正如我们之前讨论的,一些寄存器用于指定地址。我们实现间接寻址或跳转到错误地址的方式,通常是将数字(地址)放入寄存器,然后由该寄存器指定我们要访问的位置。因此,我们还需要让寄存器连接到地址总线,地址总线又控制着内存。
这就是我们需要拥有的第二种信息。我们将有一些地址寄存器(专门用于地址或同时用于地址和数据),它们需要连接到地址总线。
我们需要讨论的最后一部分是内存。一方面,内存总是需要指定我们将要处理的内存地址。这当然是由地址总线指定的。这是关键所在。当然,一旦我们实际处理某个地址,我们需要能够读取它或写入它,从中获取信息或将信息放入其中。因此,内存单元的输入和输出当然必须连接到数据总线。
让我们更仔细地看看内部。内存有两个部分:数据内存和程序内存。我们来分别谈谈这两部分。
对于数据内存,它需要获取要操作的数据块的地址。然后,很简单,我们需要对它进行读写。
对于另一部分,即程序内存,我们需要将下一条程序指令的地址放入程序内存,因为这是我们获取程序指令的地方。我们需要能够将一个地址放入程序内存地址,然后从那里获取指令。我们从程序内存获取的指令可能包含数据,例如,它可能包含我们需要相加的数字等。但同样重要的是,程序指令告诉系统的其余部分该做什么。因此,我们需要能够从程序内存的数据输出中获取下一条指令的信息,并将其输入控制总线。这是我们需要在计算机内部建立的另一个重要连接。
到目前为止,我们讨论了计算机将要组成的所有不同部分,以及它们之间如何通过信息流动相互连接。
在下一单元中,我们将更仔细地查看硬件应该执行的基本循环:从程序内存获取指令,并使用系统的其余部分适当地执行它。这被称为“取指-执行周期”,将是下一单元的内容。

本节课中,我们一起学习了冯·诺依曼架构的基本原理。我们了解到,计算机通过统一的内存来存储程序和数据,并由CPU(包含ALU和寄存器)执行指令。数据、地址和控制信号通过总线在组件间流动,共同协作以执行复杂的任务。下一节,我们将深入探讨指令执行的核心循环——取指-执行周期。
037:取指执行周期 🔄

在本节课中,我们将要学习计算机最核心的工作流程:取指-执行周期。这是CPU不断重复的基本操作,理解它是理解计算机如何工作的关键。
概述
计算机的核心任务非常简单:它不断地获取一条指令,然后执行这条指令,如此循环往复。这个循环就是“取指-执行周期”。我们将深入探讨这两个阶段在硬件层面是如何实现的。
取指阶段
上一节我们介绍了计算机的基本概念,本节中我们来看看指令是如何被获取的。
要获取下一条需要执行的指令,首先需要知道它在哪里。指令存储在程序内存中,具体位置由程序计数器指定。因此,我们需要将下一条指令的地址放入程序内存的地址输入端,然后读取该地址的内容,从而得到需要执行的指令代码。
通常,下一条指令就是当前指令之后的那一条。例如,刚执行完第8条指令,下一条通常就是第9条。当然,有时程序会“跳转”去执行其他位置的指令。
以下是取指阶段在硬件层面的实现方式:
- 程序计数器:这是一个寄存器,用于存放下一条指令的地址。
- 地址输入:程序计数器的输出连接到程序内存的地址输入端。
- 指令输出:程序内存根据地址输出对应的指令代码。
这个过程就是取指-执行周期中的“取指”部分。
执行阶段
现在我们已经获取了指令代码,接下来需要执行它。
指令代码本身包含了所有需要执行的操作细节:应该进行何种计算、访问哪个寄存器、执行后是否需要跳转等等。这些信息通常以一种简单的方式编码在指令的不同比特位中,每个比特位控制着CPU的不同部分。
从硬件角度看,执行当前指令意味着:将从取指阶段得到的指令代码输入到CPU的控制总线。这个控制总线负责指挥一切:
- 它告诉算术逻辑单元进行何种运算(如加法、减法)。
- 它指定数据的来源(来自哪个寄存器或数据内存)。
- 它控制数据的写入位置。
简而言之,刚刚获取的指令代码精确地告诉系统的每个部分在执行周期中该做什么。当然,具体的实现细节(指令比特如何精确控制ALU、选择寄存器等)最好通过一个具体的计算机架构来理解,我们将在后续关于Hack计算机的单元中深入探讨。
一个关键问题:内存访问冲突
在深入细节之前,我们需要后退一步,审视一个被暂时忽略的关键问题:取指周期和执行周期之间存在冲突。
这是因为程序和数据都存放在内存中。在取指周期,我们需要从程序内存获取指令,因此需要将指令地址输入内存。而在执行周期,我们需要访问数据内存,因此需要将数据地址输入内存。由于我们只有一套内存系统,这就产生了冲突:我们该把指令地址还是数据地址输入到内存地址端?
解决方案:分时复用与哈佛架构
那么如何解决这个冲突呢?通常有两种方法。
第一种是分时复用。我们按顺序进行这两个操作。具体实现是使用一个多路复用器来选择输入到内存地址端的内容:
- 在取指周期,多路复用器选择程序计数器的输出(即下一条指令的地址)。
- 在执行周期,多路复用器选择数据地址。
同时,内存的输出会同时连接到需要指令的部分(如指令寄存器)和需要数据执行操作的部分。这里的关键是,我们需要一个指令寄存器来记住在取指阶段获取的指令,以便在执行阶段使用。
第二种方法是一个更简单的捷径:将两种内存分开。即使用两个独立的存储单元:一个作为程序内存,另一个作为数据内存。它们各自有自己的地址输入,因此我们无需像上述那样在两者之间切换。这种架构有时被称为哈佛架构,我们将其视为冯·诺依曼架构的一种变体,并因其简单性而采用。
总结
本节课中我们一起学习了计算机工作的核心循环——取指-执行周期。我们了解了:
- 取指阶段:CPU通过程序计数器从内存中读取下一条指令。
- 执行阶段:CPU解码并执行该指令,控制数据流和运算。
- 内存冲突与解决:由于指令和数据共享内存导致的访问冲突,可以通过分时复用(使用多路复用器和指令寄存器) 或采用独立的程序与数据内存(哈佛架构) 来解决。

至此,我们已经完成了对计算机通用架构的讨论。从下一个单元开始,我们将具体探讨我们的Hack计算机是如何精确构建的。
038:中央处理器 (CPU) 🧠

在本节课中,我们将学习计算机的核心——中央处理器(CPU)。我们将了解CPU的抽象功能、具体规格以及其内部架构的实现思路。CPU是计算机的“大脑”,负责执行指令并控制程序的流程。
CPU的抽象功能
中央处理器,或称CPU,是每个计算机硬件架构的核心。它是计算的枢纽,因为机器的所有计算都在这里发生。它也是控制中心,负责决定接下来应获取和执行哪条指令。
我们将讨论Hack CPU的规格以及如何实际构建它。和往常一样,从抽象概念开始总是有益的。
我们可以将Hack CPU视为一个执行两项非凡任务的“黑盒”:
- 它是一个16位处理器。给定一条用Hack语言编写的指令,CPU将执行该指令。
- 在执行当前指令的过程中,CPU还能以某种方式确定正在运行的程序中下一条应执行的指令是什么。
当然,我们讨论的是Hack计算机,因此假设所有程序都是用我们在第4周学习的Hack机器语言编写的。
CPU的详细规格
接下来,我们讨论这个设备的更详细规格。首先必须记住,CPU并非孤立工作,它连接到计算机内部的其他设备。具体来说,在Hack架构中,CPU同时连接到指令内存和数据内存。
考虑到这一点,以下是CPU的具体输入和输出连接。让我们从左到右、从上到下逐一审视。
首先,有三个来自不同源的输入:
inM:一个16位数据值,它是当前选中的数据内存寄存器的值。这是CPU将要操作的数据。instruction:一个16位输入,它是当前选中的指令内存寄存器的值。在任何给定时刻,指令内存中总有一个被选中的寄存器,数据内存中也总有一个被选中的寄存器,因此总有数据输入到CPU。reset:一个1位输入,我们将在本单元稍后讨论。
在右侧,我们看到CPU的输出:
outM:如果ALU想向数据内存写入内容,它必须指定三件事。第一是写入什么,这通过名为outM的输出端口发出。addressM:第二是写入到哪里,我们必须提供一个地址。这是下一个数据输出addressM的任务。writeM:第三个输出是一个加载位,用于在写入操作时启用数据内存。pc:一个极其重要的输出,我们称之为pc(原因稍后说明)。这个输出以某种方式保存着下一个需要获取和执行的指令的地址。
CPU的实现架构
我们如何实现这个“魔法”?如何让所有这些好事真正发生?以下是一种实现方式。这是我们刚刚描述的功能的提议实现。构建CPU的方法不止一种,但这是一个相当好的方法,因为它非常优雅、简单且相对容易实现。
在我们深入研究这个架构的实际细节之前,先看看它的接口。如果我们查看这个硬件图的接口,会发现它与我们之前看到的接口100%相同:有三个输入进来,四个输出出去。有了这个观察,我们现在可以深入研究实际架构的细节了。
在开始理解这里发生了什么之前,另一个重要的事情是,在这个架构中,我们有许多标有通用符号c的标签,我用来表示控制位的概念。为了使所有这些芯片协同工作,CPU的设计者必须确保不同的控制位到达正确的位置,这些多重信号共同作用将使CPU执行它应该执行的任务。
如果你观察这个架构,在最初一两分钟的困惑之后,你会意识到这里的一切都非常标准。你看到了在项目2中构建的ALU,看到了三个我们称为A、D和PC的寄存器(这些寄存器与你项目3中构建的芯片相同),还看到了两个多路复用器(我们在课程第1周的项目1中构建过)。因此,组装这个架构只是将你在本课程前几周已经构建好的芯片部件组合起来的问题。
处理A指令
在构建之前,我们必须描述这个架构实际做什么。在这样做时,我发现将这个架构想象成一个管弦乐队很有趣,一个由许多不同乐器协同工作以产生伟大交响乐的乐队,而这首交响乐就是当前程序的执行。就像一个常规的管弦乐队可以演奏许多不同的音乐作品一样,当前程序决定了它实际执行什么。
在接下来的内容中,我想聚焦于这个架构的不同部分。让我们从处理当前指令的部分开始。
放大这一部分,我们看到它主要由一个名为A的寄存器和一个通过多路复用器与该寄存器连接的指令输入组成。这里有一条A指令在当前周期(或某个周期)到达机器,这里也有同一条指令的符号助记符(更容易阅读,但显然计算机完全不在乎,这只是为了你我之间的交流)。
我们看到这条指令试图将值3001加载到A寄存器中。当然,所有操作都以二进制进行。为了执行这条指令,CPU必须做几件事。首先,它必须解码指令。它必须获取16位值,并将其分离为操作码和一个15位地址(或15位值)。顺便说一下,我们怎么知道它是A指令?我们可以查看操作码。操作码是0,根据Hack机器语言规范,这意味着它是一条A指令。所以CPU会说:“啊哈,我知道该怎么处理它。我必须取接下来的15位值,并将其放入A寄存器中。”请注意,这正是门电路图所做的:16位值通过寄存器进入A寄存器。
CPU做的另一件事(为了图面简洁,我们在此图中看不到,但这很简单)是:它获取A寄存器的输出,并通过我们称为addressM的输出端口将其发送到CPU外部。你可以查看计算机的整体架构图。这就是CPU处理A指令的方式。
处理C指令
接下来,我们将看看CPU如何处理C指令,毕竟在Hack机器语言中,我们有两种通用指令类型:A指令和C指令。因此,我们必须能够处理这两种指令中的任何一种。
C指令和A指令一样,由不同的字段组成。第一个字段是操作码,恰好是1。这就是CPU判断这是C指令的方式。除此之外,我们还有其他几个字段,如果我们查看同一条指令的符号表示,可以更清楚地看到它们。请注意,我使用颜色编码将比特位与符号助记符关联起来,但这些颜色对计算机来说显然毫无意义,它们只是我们在课程中更容易交流的一种方式。
那么CPU如何处理这样的指令呢?你可能已经猜到,首先要做的是解码指令。所以CPU获取这个16位值,并将其解码为四个不同的字段,这些字段稍后将用于启动和操作计算机架构内的不同元素。
到目前为止,我们讨论的是CPU如何处理A指令。但如果你看门电路图,你会发现同一个A寄存器不一定从指令输入获取数据,也可以从ALU输出获取数据。因此,我们必须决定:在某些情况下,我们希望A寄存器从指令获取输入;在其他情况下,我们希望A寄存器从ALU输出获取输入。你可能已经猜到我们如何做到这一点:我们知道在不同情况下,我们处理两种不同的指令。在某些情况下,它是操作码为0的A指令,此时我们希望输入来自指令。在其他情况下,它是操作码为1的C指令,此时我们希望以某种方式路由A寄存器的输入,使其来自ALU。因此,CPU设计者的工作是检查传入指令的操作码,并决定A寄存器将从哪个源获取其下一个内容。
ALU的操作
好了,这大致是CPU处理传入指令的方式。让我们回到整体架构,聚焦于我想讨论的下一个部分:ALU的操作,这是该架构中最复杂的部分。
现在我们看到处理ALU的部分,我们看到一条C指令进来,而不是之前看到的A指令。让我们记住,同一条指令由不同字段的比特位组成,每个字段都有不同的含义。我们必须记住的另一件事是,ALU是一个组合芯片。它总是在计算某些东西。总是有一些输入进来,一些输出出去。当你查看图表时,会看到ALU的输入来自两个不同的源:一个是D寄存器(D寄存器的当前值),另一个是A寄存器的值或所选内存寄存器的值。有一个多路复用器负责决定从何处获取值,而这个多路复用器的控制位是指令中的一个比特位。因此,CPU设计者必须再次注意获取这个决定ALU输入来源的比特位,然后ALU将获得正确的输入,或者说程序员希望它接收的输入。
这样,我们就将输入数据加载到ALU的输入引脚上,ALU开始工作。它怎么知道该做什么呢?ALU还有六个控制位,这些位合起来告诉或指定ALU我们要执行哪个操作。这些位取自指令,我在这里使用了一些颜色编码来帮助区分哪些位去哪里,但当然,就计算机而言,这些颜色毫无意义,我只是为了教学目的使用它们。因此,CPU设计者必须获取这六个绿色比特位(或更准确地说,是这些比特位的副本),并将它们一路路由到ALU的控制位。
现在,我们拥有了所有这些信息:输入数据、我们必须执行的操作,ALU实际上可以计算一些东西,然后输出结果。检查图表,我们看到ALU输出同时被馈送到三个不同的目的地:第一个目的地是D寄存器,第二个目的地是A寄存器(它通过一个多路复用器),第三个目的地可以说是芯片接口的外部。因此,同一个ALU输出被馈送到三个不同的地方。
于是我们有了这样的情况:同一个输出值敲着三扇不同的门。但敲了门并不一定意味着门会打开。我们必须决定,或者说程序员必须决定,哪扇门必须打开。这个决定由我想关注的下一个比特位字段做出,这些比特位在我们的行话中称为“目标位”。我们有三个目标位,这些位决定是否打开D寄存器、A寄存器和数据内存来接收ALU输出。例如,如果绿色比特位是0,0,0,那么ALU计算出的值虽然很好,但这个值将会丢失,计算机中没有任何“容器”会开门营业。如果我们将这些绿色比特位设置为111,那么所有这些容器将同时接收相同的输出。因此,我们可以像在第4周编写程序时那样(尽管我们是用汇编和符号方式做的)来操作这些比特位,我们可以用机器语言来做,再次,我们有一种方法来编程这个架构,使得ALU输出可以有选择地被该架构中的不同位置吸收。
控制逻辑与程序计数器
继续前进,我们必须记住的另一件事是,ALU还计算并输出两个控制输出,我想我们称之为zr和ng。这两个控制位记录了ALU输出是否为负或零,这两个输出在接下来要描述的CPU控制逻辑中起着关键作用。
但在我们讨论这个逻辑之前,我想按承诺说几句关于reset输入的话。我们看到它从左侧、幻灯片底部进入图表。当然,当你构建芯片时,所有这些上下左右都毫无意义,但在讨论门电路图时,用它来交流很有用。让我们说几句关于这个reset位的话。为了做到这一点,我们必须尝试设想如果我们真的着手构建Hack计算机会是什么样子。
它将是一个黑盒子。正如你所见,这个黑盒子配备了一个单一的按钮,这个按钮在我们的行话中称为reset。其想法如下:这个黑盒子,Hack计算机,已经加载了用Hack机器语言编写的某个程序。到目前为止,它什么都不做。程序在里面,但什么都没发生。好吧,如果你用手指按下这个reset按钮然后松开,将会发生的是计算机将开始运行当前程序。你观察从这个计算机出来的任何东西(几分钟后,你会看到我们可以给它连接一个屏幕和键盘),你看着程序的执行,在某个时刻,你决定重置计算机或再次运行同一个程序。你用手指按下reset按钮,然后松开,通过这样做,计算机将重新开始运行同一个程序。好的,这就是我们希望在CPU的控制逻辑中实现的行为,以及其他事情。
好了,现在我们理解了reset按钮在计算机外部是如何工作的,让我们回到CPU逻辑,探索CPU的控制逻辑。
如果没有一些赋予控制操作意义的指令,谈论控制是没有意义的。这里有一个C指令的例子,它是一个示意性指令,不是0和1,但我使用了一些助记符和颜色编码(就计算机而言,这些完全无意义)。我使用这些助记符和颜色是为了强调我们这里有不同字段的比特位,每个字段旨在整个计算机架构中做不同的事情。当我们谈论控制时,我希望你关注这三个我们称为“跳转位”的比特位。如果你还记得,如果三个跳转位都是0,它表示“不跳转”的情况;如果三个跳转位都是1,它表示“无条件跳转”;而跳转位中0和1的任何其他组合都表示“条件跳转”。我刚才说的是Hack机器语言规范的一部分,现在CPU的工作就是将这个规范变为现实,实现这个抽象指令。
那么我们怎么做呢?我们故事的主角是一个名为“程序计数器”的普通寄存器。它实际上是一个计数器,程序计数器在我们的行话中也称为PC。在接下来的内容中,我们想关注PC内部发生了什么。以下是一个典型程序计数器的期望操作:
首先,也是最重要的,我们希望程序计数器始终发出下一条需要执行的指令的地址。让我们记住这一点。
- 如果你想启动或重启计算机,那么在这种情况下,我们必须将
PC设置为0,因为我们希望执行程序中的第一条指令。所以PC=0很有意义。 - 否则,如果我们处于“不跳转”的情况(即当前指令的三个跳转位
J为0),那么我们希望程序计数器递增1步,这样我们下一条执行的指令将是第1条指令,然后是第2、3、4条,依此类推,只要我们没有跳转。 - 然而,如果当前指令的所有
J位都是1,那么我们有一个“无条件跳转”。在无条件跳转的情况下,我们希望将PC设置为A。因为如果程序员知道自己在做什么,那么程序员已经负责将我们想要跳转到的地址输入到了A寄存器中。所以如果我们做PC=A,程序计数器将发出下一条需要执行的指令的地址。 - 最后,如果只有部分
J位是1(即一个或两个J位是1),那么我们有一个“条件跳转”。如果我们有条件跳转,我们必须查看ALU输出,并决定这个跳转是否应该实际发生。这就是“条件”的全部意义。我们必须检查条件是真还是假。如果条件为真,那么我们想进行跳转;否则,我们想忽略这个跳转,只做PC++。
我希望你相信这个抽象指令是有道理的,这正是每个计算机中程序计数器必须工作的方式。剩下的问题是,我们如何实际实现它?答案已经在你面前的屏幕上了。逻辑架构使得PC将完全按照我们希望它做的那样工作。让我们说服自己情况确实如此。
让我们看看reset输入。记住,为了启动计算机,为了使计算机开始执行当前程序,我们按下reset按钮然后松开。所以如果reset位是1,我们希望将PC设置为0;否则,我们必须查看当前指令。
当前指令可能有也可能没有跳转位。给定跳转位的任何组合(从0,0,0到1,1,1,以及所有可能的八个三跳转位组合),给定这些组合中的每一个,我们必须查看跳转位,同时查看ALU输出,并决定我们是否真的想执行跳转。在幻灯片中,我将这个决定描述为一个函数,这个F函数是我完全凭空捏造的,在计算机架构中毫无意义,但我使用这个符号F试图说明我们同时查看这两条不同的数据,我们以某种方式组合这些信息,然后做出决定:我们想跳转吗?
我将这个决定的结果存储在符号load中。如果你看图表,你会发现这个load实际上是程序计数器的加载位。所以如果load最终是1,是的,我们想跳转。所以我们做PC=A(PC等于A寄存器的当前值,其中包含我们想要跳转到的地址);否则,我们做PC++。在所有操作结束时,当尘埃落定,我们将得到的是:PC总是发出下一条需要获取和执行的指令的地址。
总结
本节课中,我们一起学习了中央处理器(CPU)的核心概念。CPU是计算机的“大脑”和控制中心,负责执行指令并决定程序的执行流程。
我们首先了解了CPU的抽象功能:执行Hack机器语言指令,并在执行过程中确定下一条指令。接着,我们详细探讨了CPU的输入输出规格,包括数据输入inM、指令输入instruction、复位信号reset,以及输出到内存的数据outM、地址addressM、写使能writeM和下一条指令地址pc。
然后,我们深入研究了CPU的实现架构,它由ALU、A/D/PC寄存器以及多路复用器等我们已经熟悉的部件组成。我们分析了CPU如何处理A指令(加载立即数到A寄存器)和C指令(执行计算并可能存储结果)。重点剖析了ALU如何根据指令的控制位(zx, nx, zy, ny, f, no)和输入(来自D寄存器和A寄存器或内存inM)进行计算,以及如何通过目标位(d1, d2, d3)将结果写入D寄存器、A寄存器或数据内存。
最后,我们探讨了CPU的控制逻辑核心——程序计数器(PC)。PC根据当前指令的跳转位(j1, j2, j3)和ALU的输出状态(zr, ng),结合复位信号reset,来决定下一条指令的地址是重置为0、顺序递增(PC+1)、还是跳转到A寄存器指定的地址。这实现了程序中的顺序执行、无条件跳转和条件跳转。

这基本上完成了对CPU实现的描述,剩下要做的就是实际构建它。但在我们着手构建之前,必须记住,所有这些复杂性只是整个计算机架构中的一个元素。也就是说,它是该架构中最复杂、最有趣的元素。我们将在下一个单元之后构建它,因为在下一个单元中,我们将描述Hack计算机的整体架构,然后我们将亲自动手,实际构建CPU和整个Hack计算机。
039:Hack计算机实现 🖥️

在本节课中,我们将学习Hack计算机的整体硬件架构。我们将提供所有必要的信息,以便你能够实际动手构建它。
整体架构概述
上图展示了Hack计算机的整体硬件架构。我们注意到其中引入了两个新的输入/输出设备:一个屏幕显示单元,用于将计算机中的值以人类可理解的方式显示出来;以及一个键盘单元,允许我们与当前在计算机上运行的程序进行交互(前提是该程序接受输入)。这就是计算机的外观。
从用户或程序员的角度来看,我们可以将其视为一个能够运行Hack机器语言程序的“黑盒”。这一点很重要,因为如果我们编写一个将Java语言翻译成Hack机器语言的编译器,那么这台计算机就能运行Java程序。事实上,本课程的第二部分就会做类似的事情。因此,抽象地说,我们拥有一个通用计算机,可以运行任何能被翻译成Hack原生代码的程序。
以上是从上到下的描述。我们也可以自底向上地描述如何构建这台计算机。事实证明,我们可以使用本课程前面构建的一些芯片来搭建它,只需以巧妙的方式将它们组合起来,就能实现我们刚刚描述的抽象架构。
CPU:架构的核心
上一节我们介绍了整体架构,本节中我们来看看其核心部件——CPU。关于CPU的功能,我们在上一个单元已经详细讨论了约20分钟,因此这里只简要回顾它在整体硬件架构中的作用。
以下是我们在上一个单元见过的CPU接口。让我们结合三条可能的Hack指令来探索这个接口。CPU的所有操作都遵循Hack语言规范“契约”。
- 处理D和A寄存器:如果指令中包含助记符
D或A,CPU将操作其内部相应的D或A寄存器。 - 处理A指令:如果指令是A指令(例如
@100),CPU将把指令中的15位数据值(即X)放入A寄存器。 - 处理内存操作(M):
- 如果指令右侧提到助记符
M(例如D=M),CPU期望在其inM输入端口看到当前选定数据内存寄存器的值。 - 如果指令左侧将
M作为操作的目标之一(例如M=D),则outM输出应被存储到主内存中。这需要CPU提供三个信息:要存储的值、存储地址以及控制位writeM。
- 如果指令右侧提到助记符
接下来看看CPU如何处理跳转。假设一个典型场景:A寄存器已包含地址100,当前指令是D;JEQ(如果D等于0则跳转)。CPU逻辑会使用跳转位和ALU输出来决定是否跳转。
- 如果
reset输入为0(程序正常运行时的情况),且满足跳转条件,则程序计数器PC被设置为A寄存器的值;否则,PC递增。 - 如果
reset输入为1(用户按下了复位按钮),则PC被设置为0,程序从头开始运行。
更新后的PC值通过CPU的pc输出端口发出。以上就是CPU在整体计算机架构中需要遵循的操作契约。
数据内存:程序数据的家园
上一节我们探讨了CPU,本节中我们来看看数据内存。数据内存是存储程序所有数据的区域。
从程序员的角度看,可以方便地将数据内存划分为三个逻辑段:
- 数据段:地址
0到16383(16K),用于存储程序生成和操作的数据。 - 屏幕内存映射段:接下来的
8192(8K)内存,用于存储屏幕内存映射。如果你想在屏幕上绘制内容,就需要操作这个内存段中的位。 - 键盘内存映射段:内存中的最后一个寄存器(地址
24576),用于反映当前键盘上被按下的键。
以上是抽象描述。现在我们来讨论如何实际实现它。我们通过组合三个不同的子芯片(或芯片部件)来构建内存。
- RAM芯片:第一个段(数据段)就是一个标准的RAM芯片(例如
RAM16K)。我们在项目3中构建过它,直接接入即可。 - 屏幕芯片:第二个段是一个特殊的芯片,称为
Screen。它本质上也是一个RAM芯片,但有一个“副作用”:能自动刷新连接的显示单元。我们通过它来实现计算机的显示效果。 - 键盘芯片:第三个段是另一个特殊芯片,称为
Keyboard。它由一个连接到标准键盘的16位寄存器实现。- 当没有按键时,该寄存器输出
0。 - 当按下某个键时,该键的扫描码会被存储在这个寄存器中。例如,按下
K键,寄存器中会出现107的二进制值;按下空格键,会出现32。 - 要读取键盘,只需探测键盘寄存器的输出。在Hack内存的整体上下文中,这意味着读取内存地址
24576的内容。
- 当没有按键时,该寄存器输出
指令内存与程序加载
了解了CPU和数据内存后,我们回到整体计算机架构。现在需要实现Hack计算机系统的指令内存,我们使用一个称为ROM32K的芯片来实现。
假设我们有一台计算机放在桌上,旁边有一个用Hack机器语言编写的程序。要运行这个程序,首先需要将其加载到计算机中,具体是加载到ROM32K芯片里。然后按下复位按钮,程序就开始运行了。这是我们想要实现的抽象。
有多种方式可以实现程序加载:
- 使用可插拔ROM芯片:将机器代码“烧录”到ROM芯片中,然后将其插入计算机。这类似于一些游戏主机使用卡带的方式。
- 使用硬件模拟:这也是本课程将采用的方式。我们使用一个内置的ROM芯片,它允许我们通过模拟器将存储在文本文件中的程序加载进去。
在硬件模拟器中,ROM32K芯片的address输入接收来自程序计数器PC的地址,其out输出则提供该地址处存储的指令。这样,在每个时钟周期,PC指向下一条指令的地址,ROM32K则输出当前要执行的指令。
整合:构建完整的Hack计算机
现在,我们可以总结如何构建完整的计算机架构了。
以下是构建步骤:
- 连接CPU与指令内存:将
ROM32K的输出连接到CPU的instruction输入,为CPU提供当前指令。将CPU的pc输出连接到ROM32K的address输入,以获取下一条指令。 - 连接CPU与数据内存:将CPU的
outM、addressM和writeM输出连接到内存单元(即我们构建的RAM+Screen+Keyboard组合),以便向内存写入数据。同时,将内存单元的out输出连接回CPU的inM输入,因为许多指令需要读取内存而不仅仅是写入。 - 添加用户接口:最后,为架构添加整体接口,用户只能看到并与之交互的是:显示单元、键盘和复位按钮。
至此,我们就完成了Hack计算机的架构。它简洁、高效,没有冗余部分,完美地达到了设计目的。正如拉尔夫·瓦尔多·爱默生所说:“我们将美归于那些简单、没有多余部分、精确达到目的、与万物相关、是许多极端之中庸的事物。”希望你也认同Hack计算机符合这一美的标准。
总结

本节课中,我们一起学习了Hack计算机的整体硬件架构。我们详细探讨了其三个核心部件:遵循特定“契约”处理指令和跳转的CPU、由RAM、屏幕和键盘芯片组成的数据内存、以及用于存储和提供程序指令的ROM。最后,我们了解了如何将这些部件连接起来,形成一个可以运行任何Hack程序的完整计算机系统。在接下来的单元中,你将有机会用HDL代码实际构建这个简洁而优美的计算机。
040:项目5概述 🖥️

在本节课中,我们将学习如何将之前构建的所有芯片组合成一个完整的计算机系统。我们将回顾计算机架构,并详细介绍项目5中需要完成的任务:构建CPU、内存单元,并将它们集成为一台可工作的通用计算机。
课程回顾与项目目标
上一节我们讨论了计算机架构的合成。本节中,我们来看看项目5的具体实施。
我们正在构建一台计算机。这台计算机被实现为一个高级芯片。该芯片利用两个主要组件:一个CPU和一个内存单元。内存单元由多个RAM单元拼接而成,每个RAM单元本质上是一组寄存器。CPU也包含多个寄存器(如D寄存器、A寄存器)、一个程序计数器以及我们在项目2中构建的ALU。所有这些芯片最终都基于基础逻辑门。
从下往上看,在项目1中我们构建了基础逻辑门;在项目2中我们构建了一系列加法器,最终得到了ALU;在项目3中我们构建了一系列寄存器,最终得到了RAM单元;最后,在本项目中,我们将整合所有这些成果,构建一个完整的计算机系统。具体来说,我们需要实现CPU、内存单元,然后将这两个高级芯片合成为一个完整的、可工作的通用计算机。
构建CPU 🧠
以下是Hack计算机的抽象架构及其实现图。该架构基于一个CPU、一个数据内存单元和一个我们称为ROM的指令内存单元。
让我们从CPU开始。这是CPU的抽象结构,展示了其输入和输出单元。如果我们深入其实现,会得到之前课程中讨论过的图表。以下是一些关于如何构建这个相对复杂芯片的建议。
首先,仔细观察会发现,图中所有的芯片部件都是我们在之前项目中已经构建过的。因此,这基本上就是“从架子上取下这些芯片”,并以某种巧妙的方式将它们拼接起来。
那么具体怎么做呢?进一步检查图表会发现,这里有许多控制位,其中大部分来自当前指令。例如,这里有一条C指令,它由几个字段组成。你的任务就是解包这些控制位,并使用HDL将它们路由到架构中各个芯片部件的正确目的地。
如果你仔细操作,弄清楚每个控制位需要去哪里,你就能以逻辑方式将所有部件粘合起来,最终得到完整的CPU架构。实际上,这里我略过了一些细节,因为这不仅仅是发送控制位,你可能还需要添加一些逻辑来处理某些控制位,以达到预期效果。同时请注意,ALU也会输出一些控制位,这些控制位在整体架构中也扮演着重要角色。你需要编写一些HDL逻辑来获取这些控制位,将它们与指令中的其他信息结合,并以实现预期效果的方式组合起来。
我们有意没有给出具体的操作说明,只是使用了“C”这个统称标签,因为我们希望你自己思考并弄清楚如何将所有东西组合在一起。一旦完成,你就实现了CPU的整体架构。
我们为你提供了一个存根文件。该文件包含了CPU的文档、输入输出单元的名称。显然,你需要提供缺失的实现部分。
构建内存单元 💾
我们已经解决了CPU,接下来要讨论的是内存单元,即数据内存单元。该单元包含运行中的程序以及程序生成的数据、变量,在高级语言中还包括对象、数组等。
这是内存的抽象。它是一个具有地址输入和数据输出的单一地址空间,并具有刷新屏幕和探测用户键盘操作的附加效果。
Hack内存的整体地址空间由三个逻辑段组成。前16K内存专用于运行中的程序及其生成的数据;接下来的8K内存专用于屏幕内存映射;内存中的最后一个寄存器代表键盘内存映射。
我们如何构建它呢?我们需要使用之前构建过的芯片来实现这个抽象。以下是该架构的高级示意图。我们有三个基本芯片:项目3中构建的RAM芯片、一个内置的屏幕芯片(本质上就是一个常规的内存芯片,如果你之前实现过RAM,实现它并不复杂,但它有刷新物理屏幕的附加效果),以及一个名为Keyboard的简单寄存器,它保存当前按下键的扫描码。
你需要编写HDL代码,接收一个输入的地址(无论是什么地址),并将该地址引导到整体地址空间内的正确芯片部件。如果地址低于16K,你只需从RAM芯片部件中检索正确的寄存器。如果地址在16,384到24,575之间,你需要处理这个地址并将其路由到屏幕内存芯片的正确地址。最后,如果地址是24,576,你需要将其路由到键盘内存映射。这基本上就是你的HDL代码需要做的。如果你做到了,你就实现了将这些芯片部件粘合在一起并交付数据内存整体功能的“胶水”。
集成ROM与最终合成
我们已经解决了CPU和内存,剩下要做的就是弄清楚如何实现ROM。ROM实际上是RAM的一个更简单的版本,它是一个只读设备,因此比RAM芯片更容易构建。因此,我们决定不要求你构建它,而是将其作为内置芯片提供。我们提供内置版本的ROM还有另一个原因:我们赋予了这个内置芯片在硬件模拟器中加载程序的能力,这非常方便。你需要做的就是取用它并将其插入整体架构中。这可能是本项目中最简单的部分。
至此,我们已经构建了构成架构的所有三个组件,剩下的就是在HDL中实际实现你眼前看到的这个图表。你需要编写几行HDL语句,按照图表所示的方式将CPU连接到内存和ROM。
我们如何开始呢?我们从一个提供计算机API的存根文件开始,然后你编写几行HDL代码,基本上就是用HDL实现这个图表。
为了完成这个项目,和往常一样,你需要访问网站,阅读项目页面,获取关于所需文件的所有文档。所有这些文件都已经作为Nand2Tetris文件夹的一部分存在于你的计算机上,所以你已准备就绪,拥有最终完成计算机架构所需的一切。和往常一样,还有一些额外的资源供你使用。
如果你完成了这些,你将拥有一台可以运行的计算机,这将是本课程硬件部分的终点。

本节课中我们一起学习了如何将CPU、内存和ROM集成到完整的Hack计算机架构中。下一单元,我们将进入“展望”部分。
041:视角与展望 🖥️➡️🔮

在本节课中,我们将回顾第五周的核心内容,并探讨计算机架构的两种主要范式——冯·诺依曼架构与哈佛架构。我们将学习如何使用有限状态机来建模计算机行为,并了解如何将计算机连接到更多外围设备。
两种计算机架构
上一节我们介绍了如何将所有之前构建的芯片集成为一个完整的计算机系统。在本节中,我们来看看两种不同的计算机架构。
在第五周,我们讨论了两种不同的计算机架构:冯·诺依曼架构和哈佛架构。
以下是这两种架构的主要区别:
- 在我们的 Hack 计算机中,程序存储在独立的只读存储器中,数据则存储在可读写的独立内存中。这种将两种不同存储器分开的架构有时被称为哈佛架构。
- 在经典的冯·诺依曼架构中,程序和数据共享同一个内存。
正如我们在本周第二单元所解释的,采用经典方式有一个微小的复杂性:你需要在一个周期内单独获取指令(即访问程序指令),然后在下一个周期执行程序(这需要访问数据内存)。在我们的哈佛架构中,程序位于一个独立的内存单元中,因此我们可以在一个周期内完成这两项操作,这使得设计更简单。
现在,这种架构可能非常适合所谓的嵌入式计算机,即那些被预先编程并固化到 ROM 中、只执行单一任务的计算机。对于一个需要不断更改运行程序的通用计算机,你可能需要一个完整的冯·诺依曼架构。这两种架构之间的差异并不大,正如我们在本周第二单元所见,我们选择了最简单的一种,以保持我们整个方法的简洁性。
有限状态机建模
从哈佛架构转向冯·诺依曼架构的一个含义是,在冯·诺依曼架构中,我们期望计算机在不同的周期或时间点执行不同的操作。那么,在计算机科学中,是否有某种有组织的方式来建模计算机的这种不同行为?
绝对有。标准方法是通过有限状态机的形式化描述。它规定了计算机在不同时间步骤应该执行的不同操作,以及它应该如何从一个状态转移到另一个状态。
让我来演示一下。其核心思想是,对于机器可能处于的每一个状态,你用一个圆圈表示。不同圆圈(即机器的不同状态)之间有箭头连接,这些箭头告诉你从哪个状态转移到哪个其他状态,以及转移的原因。
在每个状态中,你可以写下机器在该情况下需要执行的操作类型。例如,在这个状态中,你可能想将程序计数器加一,或者让某个地址获取一个特定的值(例如,由地址 A 寻址的内存地址中的值),等等。在每个不同的状态中,你可以根据状态来编写这些信息。
这为你提供了一个非常清晰的图景:在每个可能的状态下需要做什么,以及在所有事情发生后,你将转移到哪种状态。例如,如果当前时钟周期改变,或者当前内存位置的值是 0,或者硬件中不同逻辑值的各种其他情况,你可能会从这里转移到那里。
一旦你以这种形式化和有限状态机的形式指定了这些,我们就可以将其转换为常规的寄存器和组合逻辑。诀窍就是简单地添加另一个称为“状态”的寄存器。
这个寄存器将编码我们当前处于哪个周期。在我们的情况下,需要两位来编码三个可能的状态。所以这里会有两位。
一旦我们有了这个状态寄存器,现在我们可以编写组合逻辑,基本上影响我们需要的一切。例如,如果这两位……这只是一个寄存器。它有两位输入和两位输出。但我们拥有的所有组合逻辑也可以将状态作为其输入的一部分,就像它接受程序计数器作为其输入一样,等等。
我们计算机的不同组合逻辑部分接受各种输入。现在它们也有了这个输入——状态。当然,它们可以根据不同的状态执行不同的操作,包括决定下一个状态将是什么,这同样是当前状态和计算机中所有其他硬件信号的函数。
因此,从这个形式化描述——有限状态机形式化描述——转换到实际实现是相当直接且完全技术性的。一旦你实际编写了所有代码,它就像你之前组织硬件一样。这是一种有组织的方式来设计在不同时间执行不同操作、并以有组织的方式在不同时间和不同状态之间移动的计算机。
连接更多外围设备
当我们教授课程的这个部分时,一个常见的问题是:Hack 计算机可以与键盘和屏幕交互。那么,除了键盘和屏幕之外,将计算机连接到更多外围设备需要什么?
确实,真实的计算机有许多外围单元,如屏幕、键盘、鼠标、麦克风、磁盘等等。而且这种架构是可扩展的。我们可以根据需要添加更多设备,问题确实是如何实现。
就像我们对屏幕和键盘所做的那样,我们可以为每个外围设备分配内存空间。当我们想要写入某些内容时,例如,当我们想在麦克风上发出声音或向磁盘写入内容时,我们可以向内存写入某些代码,这些代码稍后将被转换为实际操作这些外围设备的物理信号。
但是,当你添加多个这样的外围设备时,CPU 会变得极其过载,因为 CPU 不仅要运行你的程序,还要管理所有这些外围设备。因此,典型的方法是让 CPU 从所有这些麻烦中解脱出来,使用所谓的设备控制器。
通常,当你添加一个磁盘时,磁盘会配备一个设备控制器。这是一个专用硬件,它知道如何管理磁盘,知道如何将 CPU 想要执行的操作转换为磁盘的实际移动等等。类似的情况发生在每个特定的 I/O 设备上。
例如,以 Hack 平台上的屏幕为例,屏幕的管理方式非常简化。当你想打开或关闭一个像素时,你只需在内存中打开或关闭一个位,并假设在某个时刻,这种操作会被刷新或导致屏幕被刷新。所以 CPU 负责所有事情。如果你想画一条线,CPU 必须实际将所有点(所有这些位)写入内存,然后这条线会在某个时间点被绘制出来。
在真实的计算机中,屏幕配备有显卡或某种图形加速器。这是一个专用的计算机,可以在内部执行各种操作。因此,如果我们想从某个坐标到另一个坐标画一条线,我们可以简单地告诉控制器“去做吧”,控制器将完成计算哪些像素需要打开和关闭等所有必要的工作。
所以,再次强调,原则上我们可以根据需要添加许多 I/O 设备,这与我们对屏幕和键盘所做的非常相似,但其中涉及许多特定于所有这些不同设备的细节。
总结

本节课中我们一起学习了计算机架构的两种主要类型——冯·诺依曼架构与哈佛架构,理解了它们的关键区别。我们探讨了如何使用有限状态机这一形式化工具来清晰地建模和设计计算机在不同状态下的行为。最后,我们了解了计算机如何通过内存映射和设备控制器的方式与键盘、屏幕乃至更多外围设备进行交互,从而构建出功能更加强大和复杂的系统。
042:汇编语言与汇编器 🧩

在本节课中,我们将学习汇编器(Assembler)的基本概念和工作原理。汇编器是一个软件程序,负责将人类可读的汇编语言代码翻译成计算机可以直接执行的机器语言代码。我们将探讨其核心工作流程,包括如何处理指令、管理符号(如变量和标签),以及如何应对“前向引用”等复杂情况。
上周,我们构建了一台能够运行机器语言代码的计算机。两周前,我们实际上是用机器语言编程,但并非直接使用二进制代码,而是使用汇编语言和某种友好的语法。因此,中间存在一个缺口,这个缺口应由一个名为“汇编器”的简单软件程序来填补。
具体来说,这张幻灯片你已经看过两次了。两周前,你看到这张幻灯片时,关注的是左侧以及你编程时使用的实际汇编语言格式。那是你两周前的视角。上周,你的视角是右侧发生了什么,即机器语言,因为你必须构建一台能够实际执行右侧所见的0和1的计算机。
本周,我们的焦点和视角将放在中间。你如何从左到右?如何执行汇编操作?也就是说,汇编器是什么?它是一个将左侧的汇编语言代码翻译成右侧的机器语言代码的程序。
现在请注意,这是本课程中第一次涉及软件。这将是一个程序,它接收以左侧格式(即汇编语言)编写的输入文件,并生成以右侧格式(即0和1)编写的另一个文件,该文件可以直接在计算机中执行。所以这是软件,是你在本课程中第一次接触软件。在此之前一直是硬件。这确实是每一台计算机中的第一个软件层。当然,在其之上还有更多软件,但这是第一层。我们将其与硬件结合,以获得完整的图景。这样,我们就拥有了一台计算机,我们可以理解如何为其编程,并且理解编程并不意味着用0和1编程,而是用汇编语言编程,这仍然是一种非常、非常底层的格式,本质上等同于机器语言,但却是人类可以理解的东西。
那么,让我们看看这个汇编器将是什么样的软件。原则上,我们只创建了一台可以运行机器语言代码的计算机。因此,原则上,我们应该用唯一可以直接执行的语言来编写程序,即用0和1表示的机器语言代码,但这会相当烦人。
最好的思考方式是,我们不是在构建世界上第一台计算机,而是第二台。因此,让我们假设我们已经有一台可以运行某种高级语言的计算机。具体来说,我指的是你的计算机和你已经掌握的高级语言。
所以,我们将用那种语言编写汇编器,它将是一个在你的计算机上运行的软件程序。它将要生成的机器语言是针对Hack计算机的,即针对另一台计算机,你将在那台计算机上实际运行生成的程序。
因此,我们考虑的是一个交叉编译器。有时它被称为交叉编译器,因为它在一台计算机上运行,却生成针对另一台计算机的代码。这样,我们就避免了循环依赖问题,即不必用机器代码编写我们的汇编器,而是可以用已经在另一台计算机上实现的高级语言来编写它。
汇编器程序实际上是一个非常简单的程序。它执行以下基本循环,并一遍又一遍地重复。
它从输入中读取一条汇编语言命令。它基本上将其分解为各个部分,每个部分都可以按照我们语言规定的方式唯一地翻译成二进制。然后,我们获取不同部分的二进制代码,将它们组合在一起。我们就得到了与刚刚读取的汇编语言命令直接等价的机器代码。
我们输出它,然后继续处理下一条汇编语言命令。我们不断这样做,一条接一条地翻译命令,而无需记住历史上发生的任何事情。非常简单。
那么,让我们更仔细地看看这些不同的阶段,每个阶段。
我们如何读取下一条汇编语言命令?嗯,看起来我们只是从输入中读取下一行。这几乎正是我们要做的。唯一的其他困难是,我们可能需要跳过所谓的“空白”,例如注释。我们需要确保读取的是真正的下一条命令,而不是任何类型的注释或任何类型的空格或空行。
所以当我们从文件中读取时。我们有一个文件,假设其中有一条命令 load R1, 18。这只是一种虚构的汇编语言。在下一个单元中,我们将开始具体讨论Hack语言,但在这个单元中,我仍然专注于通用的汇编程序。
因此,假设我们假设的汇编语言有这种类型的命令 load R1, 18,我们期望这可能意味着将值18放入第一个寄存器,但当我们实际进行翻译时,我们不需要知道这一点。我们实际上想要读取该命令,并将其放入某种字符串变量或某种字符数组中,以便稍后处理。这基本上就是逐行从输入中读取所涉及的内容。
下一步是获取这个字符串并将其分解为不同的部分。所以当我们看它时,我们看到这个命令的不同部分是:load 是第一部分,R1 应该是第二部分,18 应该是第三部分。每个部分都有意义。空格和逗号等并不是命令中有趣的部分,而只是一些语法,帮助我们分解和理解命令中重要的部分。
所以接下来我们要做的基本上是理解语法,并将原始字符串分解为这三个不同的子字符串,即构成此命令的三个不同重要部分。这基本上涉及一些简单的字符串操作,直到我们得到不同的部分。
一旦我们有了这些不同的部分,我们就需要将每个部分翻译成机器语言,即其对应的二进制部分。我们如何做到这一点?这必须是机器语言和汇编语言规范的一部分。它基本上告诉我们每个命令的代码是什么。例如,对于 load 命令,我们可能会有一个表格,告诉我们每个命令的机器语言代码是什么。具体来说,我们可以查阅该表格,看看 load 命令的机器语言代码是什么。
输入的其他部分可能是数字或其他可以直接翻译成机器语言的东西,仅仅因为它们是数字。例如,数字18可以直接翻译成数字18的二进制表示。同样,这是语言规范的一部分。这是基本部分。在这个部分,我们需要准确理解汇编语言和机器语言之间的映射关系,这通常非常简单,完全由一组表格和一组规则指定,比如如何将整数放入二进制形式,仅此而已。
第三部分是,我们现在基本上有了输入每个部分的翻译,我们需要将它们组合在一起。通常只是某种连接。可能我们还需要根据规范添加一些其他位来填充和完成语言,因为有时命令的翻译并不能填满机器中所有可用的位,而其他位被指定为某个常量,比如0或1。现在,我们基本上得到了需要输出的二进制数,我们只需要将其打印到某种文件中。
我们如何精确地打印出来,将根据机器语言文件格式的规范来决定,可能是二进制格式,也可能只是字符0和1,这通常不是你通常会有的格式,等等。然后你只需要基本上将内存中的数字、0和1翻译成计算机实际可以执行的格式。
到目前为止,我们已经描述了汇编器的基本操作。现在还有一个额外的复杂性需要我们关注,那就是符号处理。你可能还记得,汇编语言为程序员提供的主要服务之一是能够使用符号而不是直接的数字。我们通常将它们用于两种不同类型的事情。
一种是程序中的标签,用于跳转到程序的某个特定部分,你给它一个名字,而不是硬编码地址。另一种是你想给变量一个名字,而不是总是引用其在内存中的确切地址。例如,我们可以编写(在大多数汇编语言中)jump loop,而 loop 将被自动翻译成某种地址。类似地,我们可以 load 到一个寄存器变量 weight。同样,weight 将是内存中的某个位置,汇编器需要找出是哪个位置,并基本上将此访问直接翻译为访问该内存位置。
因此,基本上,我们的汇编器必须做的是将每个这样的符号替换为其等效地址。它必须记住该地址在内存中的确切位置。类似地,它将用程序内的确切地址替换跳转指令中的标签 loop,并再次记住它在哪里。
这是如何发生的?将如何完成?嗯,它将需要维护某种表格,该表格基本上包含符号和实际地址之间的翻译信息。每当需要进行这种翻译时,它就必须实际查阅表格。它在汇编语言程序中看到 weight,就必须查阅表格,看看应该用什么正确的地址来替换它。一旦用地址替换了符号,它就可以像之前描述的那样继续。
现在让我们看看,我们如何维护这样的表格?我们如何将信息输入其中,以及如何从中查找信息?例如,假设现在我们的汇编器正在逐条读取命令,它遇到了这条包含变量 weight 的命令。
也许这个变量已经在表格中了。我们需要在表格中查找它。如果它已经在表格中,那么我们就确切知道如何翻译它。这是简单的部分。
但是,当我们第一次看到这个变量 weight 时会发生什么?嗯,我们查看它,在表格中查找,发现它不在那里。所以我们知道需要分配一个新的内存位置来保存这个变量。这是我们的汇编器必须做的事情之一。因此,它将实际找到下一个可用的内存位置,分配内存地址的确切定义应该是汇编语言规范的一部分,它将基本上为这个符号分配这个新内存。现在它有了这个符号-内存地址对,可以放入表格中,并从现在开始使用,包括现在。这就是我们如何将变量位置放入表格以及如何使用它们的基本方式。
我们拥有的另一种符号是标签。例如,我们有一段代码,其中包含一个标签 loop。
当我们的汇编器实际查看并读取这行 label loop 时,这意味着什么?它知道,嗯,这只是一个标签。这不是要执行的真正命令,但我必须记住,下次有人想跳转到这个符号 loop 时,我必须记住它确切在哪里。它在哪里?嗯,我必须记住,当前命令将被放入内存的什么地址。这将是 loop 所引用的地址。例如,如果我们的程序,如果这段当前程序被分配到内存位置671、672、673等程序内存之后。当我们看到这个标签时,我们的汇编器必须记住下一个程序是程序673。所以我需要记住,从现在开始,loop 总是引用位置673。这基本上是它需要做的事情。
然后,当我们实际到达使用这个标签、使用这个符号的地方时,我们已经知道,我们的表格中已经有了确切的正确地址。这就是关于标签的信息如何被放入表格并在需要时从中读取的基本方式。
现在,还有一个我们没有谈到的额外复杂性,那就是有时我们可以在标签被实际定义之前就跳转到它。这称为“前向引用”。例如,在程序中非常常见的是,我需要在标签 continue 实际出现在程序中之前就跳转到它。
如果是这种情况,当我第一次遇到使用标签的跳转指令时,该指令发生在我已经看到定义标签的 label 命令之前。我该如何处理,因为它还不在表格中?有两种处理方式。
一种方式,通常稍微复杂一些,是基本上记住我看到了一个还不知道其位置的标签,将其保存在一个辅助表格中。当我实际得到正确地址的定义时,再回头修复它。另一种选择,有时更容易,是我实际上分两次处理。在第一次处理中,我读取所有内容,只关注标签,并记住每个标签引用的位置。那是我实际为标签构建表格的时候。只有在第二次处理中,我才实际去将每个标签转换为其正确的代码,即其正确的地址,该地址现在已经存在于表格中,因为我在第一次处理时已经将其放入。这通常稍微容易一些,但你可以使用这两种可能性中的任何一种。
现在,我们完成了关于创建汇编器的一般过程、汇编器必须做的一般事情的讨论。在接下来的几个单元中,我们将开始具体讨论Hack机器的汇编器,并讨论如何实际构建它的不同部分。

在本节课中,我们一起学习了汇编器的核心概念和工作流程。我们了解到,汇编器是一个翻译程序,它逐条读取汇编指令,将其分解为组成部分,根据预定义的规则将各部分翻译成二进制代码,最后组合并输出为机器语言文件。我们还探讨了如何处理符号(变量和标签),包括通过维护符号表来管理内存地址分配和解析标签引用,并介绍了处理“前向引用”的两种策略。这些知识为我们接下来具体实现Hack汇编器奠定了坚实的基础。
043:Hack汇编语言

在本节课中,我们将学习如何构建一个Hack汇编语言的汇编器。我们将从理解汇编过程的基本逻辑开始,并探讨如何将符号化的汇编代码翻译成二进制机器码。
概述
上一节我们介绍了汇编过程及其通用逻辑。本节中,我们来看看如何实际构建一个能工作的汇编器,一个能将符号化的Hack汇编程序翻译成二进制代码的程序。
汇编器的任务
我们面临的核心任务是将用符号化Hack语言编写的源程序,翻译成等价的二进制代码程序。要开发这样一个通用的翻译器,我们需要掌握源语言和目标语言的语法规则。
翻译编程语言比翻译人类自然语言要简单得多,因为机器语言的结构非常简洁明了。
Hack语言构成
要翻译Hack语言,我们必须了解它的三个组成部分:A指令、C指令和符号。
A指令有其符号语法和对应的二进制语法。两者之间的转换规则相对直接。
C指令同样有符号语法和二进制语法,并且有一组表格描述了它们之间的映射关系。
符号是Hack语言的一个特性。程序员可以使用一些预定义的符号,也可以通过标签声明语句或A指令来声明和使用自定义的变量。
以上是对Hack机器语言的完整描述。基于此,我们应该能够开发出所需的汇编器。
处理程序中的不同元素
在一个实际的程序中,我们会遇到多种结构。以下是需要处理的主要部分:
- 空白:包括空行和注释。在Hack语言中,注释以双斜杠
//开始。 - 指令序列:一系列需要翻译的A指令和C指令。
- 符号:出现在标签声明命令或A指令引用中的符号。
因此,编写汇编器需要知道如何处理空白、指令和符号。
关于符号的处理策略
在典型的符号化机器语言程序中,符号的数量可能很多。处理它们是一个挑战。
一个有效的策略是暂时绕过这个“大山”,先处理其他部分。有时,从另一个角度(即在处理了其他部分之后)再来解决符号问题会更容易。我们将在后续阶段引入符号处理。
基于这个策略,我们目前只需要处理不包含任何符号的程序。这使得我们要编写的汇编器变得简单得多,成为一个“无符号汇编器”。剩下的挑战就是处理空白和指令。
处理空白
处理空白最简单、最合理的方式就是忽略它。
在源代码处理过程中,每当遇到注释或空行,我们只需丢弃该行中后续的所有内容,然后继续翻译剩余的代码。因此,空白被简单地忽略。
后续计划
在消除了空白之后,剩下的就是纯粹的指令流。接下来的挑战就是将这些A指令和C指令翻译成二进制代码。这将是下一节的重点。
以下是我们的整体计划:
- 首先,编写一个能处理无符号程序的基本汇编器(下一节内容)。
- 接着,开发处理符号的能力。
- 最后,利用这种能力,扩展我们之前编写的基本汇编器,使其成为一个能翻译任何给定Hack汇编程序的通用汇编器。
总结

本节课中,我们一起学习了构建Hack汇编器的整体思路。我们分析了Hack语言的构成,并制定了先处理无符号程序、再引入符号处理的分步策略。下一节,我们将开始探讨如何具体翻译指令。
044:指令处理 🖥️

在本节中,我们将学习如何将汇编语言中的单条指令(A指令和C指令)从符号代码翻译成二进制代码。我们将重点关注不包含符号的指令处理过程。
上一节我们介绍了如何处理空白字符和符号的延迟处理。本节中,我们来看看如何具体翻译A指令和C指令。
翻译A指令
A指令的语法规则如下:@value,其中value可以是一个十进制常数或一个符号(我们稍后处理符号)。其二进制形式的操作码(op-code)是0。
以下是翻译A指令的步骤:
- 如果
value是一个十进制常数,计算其二进制表示。 - 将这个二进制数填充为15位(在前面补零)。
- 在最前面加上操作码
0,形成一个16位的二进制指令。
例如,指令 @5 的二进制表示为 0000000000000101。
翻译C指令
C指令的符号形式包含三个字段:计算(comp)、目标(dest)和跳转(jump)。其二进制形式总是以三个1开头。
翻译C指令的过程涉及查表,将每个符号字段映射到对应的二进制位。以下是翻译步骤:
- 初始化一个二进制字符串,以
111开头。 - 处理
comp字段:根据其值(如D+1)查询对应的7位二进制码(包含a位和c1-c6位),并追加到字符串后。 - 处理
dest字段:根据其值(如MD)查询对应的3位二进制码,并追加到字符串后。 - 处理
jump字段:根据其值(如null)查询对应的3位二进制码,并追加到字符串后。
让我们通过一个例子来具体说明。假设我们有C指令 MD=D+1。
以下是翻译过程:
- 解析器将指令分解为三个字段:
dest = MDcomp = D+1jump = null
- 构建二进制字符串:
- 起始位:
111 comp字段D+1对应0011111dest字段MD对应011jump字段null对应000
- 起始位:
- 最终得到的16位二进制指令是:
1110011111011000
整体汇编逻辑
综上所述,对于一个只包含A指令和C指令(无符号)的Hack程序,汇编器的整体工作流程如下:
- 读取源程序文本文件。
- 逐行处理文件中的每条指令。
- 对于每一行:
- 如果是A指令:提取
value,将其转换为15位二进制数,并在前面加上0。 - 如果是C指令:将其分解为
comp、dest、jump三个字段。分别查询每个字段的二进制码,并与前缀111组合成16位指令。
- 如果是A指令:提取
- 将生成的每条16位二进制指令(作为由字符
0和1组成的字符串)写入一个新的输出文件。
请注意,汇编器生成的输出文件是一个文本文件,内容仅由字符0和1构成。当这个文件被加载到计算机中时,这些字符才被解释为真正的二进制位。

本节课中,我们一起学习了如何将不包含符号的A指令和C指令从汇编语言翻译成机器语言。我们掌握了A指令的简单转换规则,以及通过查表分解并翻译C指令三个字段的方法。然而,目前我们的汇编器还无法处理程序中出现的符号,这将是下一单元要解决的核心问题。
045:符号处理 🧩

在本节课中,我们将学习汇编器如何表示和处理符号。符号是汇编语言中用于指代内存地址或值的名称,它们使程序更易于编写和理解。我们将探讨符号的类型以及汇编器如何将它们转换为二进制代码。
概述
汇编器的主要任务之一是将符号化的汇编代码转换为纯二进制机器码。为了实现这一点,它必须识别和处理程序中出现的各种符号。上一节我们介绍了如何处理空白和指令,本节中我们来看看如何处理符号。
符号的类型
在Hack汇编程序中,符号主要分为三类。
以下是三类符号的详细说明:
- 变量符号:代表程序员希望存储某些值的内存位置,这些值通常在程序执行过程中会改变。程序员不关心这些变量的具体内存地址,只需使用如
@sum、@x这样的符号,由汇编器负责分配地址。 - 标签符号:代表
goto命令跳转的目标地址。它们通过(LOOP)这样的伪指令声明,本身不生成任何代码。 - 预定义符号:语言规范中预先定义好的符号,如虚拟寄存器
R0、R1,以及SCREEN、KEYBOARD等。总共有23个预定义符号。
符号的处理方法
接下来,我们将分别介绍如何处理这三类符号。
处理预定义符号
预定义符号的处理最为直接。根据Hack语言规范,每个预定义符号都对应一个固定的十进制数值。
当汇编器遇到形如 @R0 的A指令时,只需将符号 R0 替换为其对应的数值(例如0),然后将 @0 转换为二进制即可。这个过程我们在上一单元已经掌握。
处理标签符号
标签符号用于标记程序中的跳转目的地。它们通过 (LABEL_NAME) 的格式声明。
汇编器在第一次扫描(第一遍扫描)程序时,会忽略空白行和伪指令,只对实际会生成机器码的指令进行计数。当遇到 (LOOP) 这样的标签声明时,汇编器会将标签名 LOOP 与下一条指令的地址(行号)关联起来,并记录在符号表中。
例如,如果 (LOOP) 声明后的下一条指令是程序中的第4行,那么 LOOP 的值就是4。之后,当汇编器在第二遍扫描中遇到 @LOOP 指令时,它会从符号表中查找 LOOP 的值(4),将其替换为 @4,然后再将 @4 转换为二进制。
处理变量符号
变量符号为程序员提供了强大的抽象能力,允许他们按需创建和使用任意数量的变量。
根据Hack语言规范,任何在程序中出现的、既非预定义也未被标签声明语句声明的符号,都被视为变量。
汇编器在第二遍扫描中处理变量。当首次遇到一个变量符号(如 @sum)时,汇编器会从内存地址 16 开始,为其分配一个尚未使用的新地址,并将这个 符号-地址 对添加到符号表中。之后在程序中再次遇到同一个变量时,汇编器只需从符号表中查找其地址并替换即可。
变量地址从16开始分配是一个设计决策,并非完全随意,其缘由我们将在后续课程中探讨。
符号表:汇编器的核心工具
处理多种不同类型的符号是一项复杂的任务。为了简化这一过程,计算机科学家引入了符号表这一数据结构。
符号表是一个用于存储和查询 符号-值 对的简单而强大的工具。汇编器在开始翻译程序前,会先初始化一个空的符号表。
以下是构建和使用符号表的两遍扫描算法:
-
初始化与第一遍扫描:
- 首先,创建一个空符号表,并将所有23个预定义符号及其对应值填入表中。
- 接着,进行第一遍扫描。从头到尾读取源程序,忽略空白行,并对实际指令(非标签声明)进行计数。
- 当遇到标签声明(如
(LOOP))时,将当前指令计数器的值作为该标签的值,并将(LOOP, 地址)对添加到符号表中。此遍扫描完成后,符号表中包含了所有预定义符号和标签符号。
-
第二遍扫描与代码生成:
- 再次从头扫描源程序。
- 对于A指令:
- 如果
@后是数字,直接将其转换为二进制。 - 如果
@后是符号,则在符号表中查找。如果找到,用其值替换符号;如果未找到(说明是一个新变量),则从地址16开始为其分配一个新地址,添加到符号表,再用该地址替换符号。
- 如果
- 对于C指令,直接根据规则翻译为二进制。
- 将生成的每一条二进制指令写入输出文件。
符号表是汇编器在翻译过程中使用的辅助数据结构。一旦翻译完成,符号表就可以被丢弃。
总结

本节课中我们一起学习了汇编器处理符号的完整过程。我们了解了变量、标签和预定义符号这三种符号类型,并掌握了汇编器如何通过构建和使用符号表,在两次扫描程序的过程中,将所有符号解析为具体的二进制地址或数值。这套两遍扫描的算法是汇编器工作的核心逻辑。在接下来的单元中,我们将探讨如何使用Java或Python等编程语言,或者通过其他方式,实际构建这样一个汇编器。
046:开发Hack汇编器

概述
在本节课中,我们将学习如何为Hack计算机设计和实现一个汇编器。我们将探讨汇编器的核心架构,包括其关键组件以及它们如何协同工作,将汇编语言代码转换为机器语言。
汇编器架构概述
上一节我们介绍了汇编器的基本算法和逻辑。本节中,我们来看看如何将这些逻辑转化为一个实际的程序,并思考其架构设计。
汇编器是一个相对简单的程序。对于有丰富编程经验的同学,可以安全地跳过本单元,直接开始编写汇编器。对于编程经验稍少的同学,可以参考我们建议的合理架构和组件。这只是一个建议,你可以参考我们的推荐,然后选择自己最舒适的方式实现。
那么,在编写汇编器时,哪些类型的模块可能是有用的呢?有三种操作是明确需要完成的。
核心组件一:解析器
首先,我们需要一个解析器。它的任务是读取文件,识别其中的不同命令,并将它们分解成各个组成部分。
解析器组件只需要能够读取输入并将其分解为部分。它不需要理解命令的含义、如何翻译成机器语言,也不需要理解符号或其地址。它只需要理解输入语言的格式以及如何将其分解为不同的组件。
以下是解析器需要完成的具体任务列表:
- 打开文件:需要能够读取给定名称的文件。例如,如果用类实现,构造函数应能接受文件名并打开文件以供读取。
- 读取下一条命令:需要能够判断是否已到达文件末尾,并读取文件中的下一条命令(通常是一行字符串)。
- 解析命令:需要能够将读取到的命令分解为其组成部分。在我们的语言中,有A指令、C指令,以及定义标签的伪指令(如
(LABEL))。解析器需要识别这些类型。 - 提供组件访问:需要为程序的其他部分提供对命令各部分的便捷访问。例如,对于C指令,应能访问其目标部分、计算部分和跳转部分。对于A指令或标签,则应能访问其符号或数值字符串。
一个可能的接口设计如下(以类Java语言为例):
class Parser {
Parser(String fileName); // 构造函数
boolean hasMoreCommands(); // 是否还有更多命令
void advance(); // 读取下一条命令
CommandType commandType(); // 返回命令类型:A_COMMAND, C_COMMAND, L_COMMAND
String symbol(); // 返回A指令的符号或L指令的标签
String dest(); // 返回C指令的dest助记符
String comp(); // 返回C指令的comp助记符
String jump(); // 返回C指令的jump助记符
}
核心组件二:代码模块
接下来,我们需要一个负责翻译的代码模块。它的任务是将汇编命令的每个组成部分转换为实际的机器码(二进制代码)。
根据语言规范,汇编语言命令的每个部分在机器语言命令的位中都有对应的独立部分。代码模块的工作就是完成这种翻译。
同样,重要的是它不需要关心这些助记符是如何从输入行中获取的。它只需要接收一个简短的字符串(例如目标部分"D"或计算部分"M+1"),并将其翻译成对应的二进制代码。
翻译工作基于预定义的映射表完成:
- 目标部分 (
dest):根据映射表,将字符串(如"D","M","DM")翻译成3位二进制码。 - 跳转部分 (
jump):根据映射表,将字符串(如"JGT","JEQ","JMP")翻译成3位二进制码。 - 计算部分 (
comp):根据映射表,将字符串(如"0","1","D+M")翻译成7位二进制码(包含一个a位和六个c1-c6位)。
回忆一下,机器语言中C指令以111开头。因此,在拼接最终代码时,需要在前面加上这三个1。
翻译流程示例
现在,让我们看看如何结合使用解析器对象和代码对象来完成翻译。
- 解析器对象为我们提供对命令各部分的访问(例如,
comp、dest、jump)。 - 我们获取每个部分对应的字符串。
- 我们将这三个字符串分别交给代码对象,让它根据内部映射表进行翻译。
- 现在,我们得到了每个部分的机器码。要组合成完整的16位指令,我们只需将它们与左边的三个
1连接起来。
以下是一个简单的代码片段,展示了如何将汇编命令字符串翻译成二进制位字符串:
// 假设 parser 和 code 对象已初始化
String destCode = code.dest(parser.dest()); // 翻译 dest
String compCode = code.comp(parser.comp()); // 翻译 comp
String jumpCode = code.jump(parser.jump()); // 翻译 jump
String machineCode = "111" + compCode + destCode + jumpCode; // 拼接成完整指令
核心组件三:符号表
我们识别的第三个核心组件是符号表。它负责维护符号名称与其内存地址之间的关联。
同样,符号表不需要理解任何关于机器语言或汇编语言的知识,也不需要理解符号的含义。它唯一需要做的就是维护符号和内存地址之间的关联。
以下是符号表必须能够处理的操作列表:
- 创建新表:创建一个新的空符号表。
- 添加条目:向表中添加一个(符号,地址)对。
- 查询条目:在表中查找一个符号,判断其是否存在;如果存在,则返回其地址。
这些操作非常简单,大多数编程语言都已有现成的类(如哈希表HashMap或字典Dict)可以实现此功能,我们通常直接使用即可。
符号表使用逻辑
让我们看看在程序中如何使用这个符号表。
处理符号的基本逻辑如下:
- 首先,创建一个空符号表。
- 由于Hack汇编语言有一批预定义符号(如
R0、SCREEN、KBD等),我们程序的第一件事可能就是将这些预定义符号添加到表中。 - 然后,在读取和翻译程序的过程中,我们需要在遇到标签和变量时将它们添加到符号表中。
- 当我们在A指令中遇到一个符号时,就可以在符号表中查找它,直接获得一个数字地址,以便后续处理。
我们需要更仔细地看看如何将符号(标签和变量)放入表中。
对于标签 ((LABEL)):
- 当我们看到一个标签命令(括号形式)时,需要将该标签作为符号添加到符号表中。
- 与之关联的地址是下一条命令将要存放的内存地址。因此,我们需要维护一个当前地址计数器(或行号),它指示了程序中下一条指令的位置。
- 重要提示:由于可能存在对标签的前向引用(即在定义之前使用),我们可能需要进行第一遍扫描,在真正开始翻译代码之前,先将所有标签及其地址录入符号表。
对于变量(A指令中的符号):
- 当我们看到一个A指令,其中包含一个尚未识别的符号时,首先我们知道它不是标签(因为标签已在第一遍扫描中全部录入)。
- 如果它不在符号表中,则意味着我们遇到了一个新的变量名。
- 此时,我们需要为这个新变量分配下一个可用的内存地址。回忆一下,分配给变量的地址从
16开始,然后依次是17、18,依此类推。 - 因此,我们需要做的是:为这个新符号分配一个新地址,并将其录入符号表。
汇编器整体逻辑
到目前为止,我们已经描述了汇编器必须使用的三个基本组件。现在让我们看看如何使用它们,即汇编器的整体逻辑是什么。
-
初始化:
- 初始化解析器,开始打开输入文件。
- 初始化符号表为空,并填入所有预定义符号。
-
第一遍扫描(处理标签):
- 从头开始读取程序。
- 只关注标签定义(
(LABEL)形式),并将它们(标签名,当前地址)添加到符号表中。此过程不生成任何代码,只填充符号表。
-
第二遍扫描(翻译与处理变量):
- 重置解析器,重新从头开始读取程序。
- 逐条读取命令并进行翻译。
- 在此过程中,持续向符号表中添加新的变量符号(因为所有标签符号已在第一遍录入)。
- 主循环逻辑:
- 从输入中读取一条命令。
- 如果是A指令且包含符号,则查找符号表将其翻译为地址(若为新变量,则分配新地址并录入)。
- 如果是C指令,则将其拆分为三个部分,使用代码模块将每个部分翻译为二进制码,然后与
111拼接成完整的16位指令。 - 将得到的二进制代码输出到输出文件。
- 持续此过程,直到程序结束。
在拥有了之前描述的三个模块之后,剩下的就是一个简单的从左到右的代码流程。

总结
本节课中,我们一起学习了Hack汇编器的建议架构。我们详细探讨了三个核心组件:解析器(负责读取和分解命令)、代码模块(负责将助记符翻译为二进制码)以及符号表(负责管理符号与地址的映射)。我们还梳理了汇编器两遍扫描的整体工作流程:第一遍收集标签,第二遍进行翻译并处理变量。有了这些模块和清晰的逻辑,编写汇编器就变得直接明了。下一单元,我们将介绍完成该项目的具体机制,包括如何测试和提交。
047:项目6概述(编程选项)🚀

在本单元中,我们将介绍如何开发项目6,即实现一个汇编器。我们将详细说明项目的目标、推荐的软件架构、分阶段开发方法以及如何测试你的汇编器。
项目目标与契约 📝
我们需要开发一个名为 HackAs 的汇编器程序。该程序的功能是将用符号化Hack汇编语言编写的程序(源文件)翻译成可执行的Hack二进制代码。
我们假设输入的源程序是一个名为 Xxx.asm 的文本文件,且该文件没有语法错误。汇编器应生成一个名为 Xxx.hack 的输出文件。如果目标文件已存在,汇编器应覆盖它。
在命令行环境中,使用Java实现的汇编器调用方式如下:
java HackAssembler Xxx.asm
执行此命令后,汇编器将开始工作,读取 Xxx.asm 文件,并生成或覆盖 Xxx.hack 文件。
推荐的软件架构 🏗️
上一节我们介绍了项目的整体目标,本节中我们来看看实现它的推荐软件架构。我们建议将汇编器划分为四个独立的软件模块,每个模块都可以进行单元测试。
以下是四个核心模块:
- 解析器 (Parser):负责解析每一条汇编指令,将其拆解为底层字段(如指令类型、符号、计算字段、跳转字段等)。
- 代码模块 (Code):包含一组方法,用于将汇编指令中的助记符(如计算字段
D+M、跳转字段JGT)翻译成对应的二进制数值。 - 符号表模块 (SymbolTable):包含一组方法,用于创建和管理符号表。它负责处理标签(如
(LOOP))和变量符号,并将其解析为具体的内存地址。 - 主驱动模块 (Main):这是程序的入口点。它负责打开输入文件、创建输出文件,并通过调用上述三个模块的方法,驱动整个翻译流程。这个模块通常以汇编器命名,例如
HackAssembler。
分阶段开发策略 🔄
面对一个复杂的软件开发项目,分阶段进行是明智的选择。我们将采用“分而治之”的策略,先构建核心功能,再逐步完善。
以下是建议的开发阶段:
- 基础汇编器:首先实现一个能处理不包含任何符号(即没有标签和变量符号)的Hack汇编程序的汇编器。完成此阶段后,应进行独立测试。
- 符号表模块:独立开发并测试符号表模块,确保其能正确创建、添加和查询符号与地址的映射关系。
- 完整汇编器:将前两个阶段的工作整合起来,实现能够翻译包含符号的任意Hack汇编程序的完整汇编器。
测试程序介绍 🧪
为了帮助你进行单元测试和集成测试,我们提供了七个测试程序。上一节我们讨论了开发策略,本节中我们来看看这些具体的测试资源。
以下是提供的测试程序及其用途:
- Add.asm:一个非常简单的程序,仅包含几条A指令和C指令,没有符号。用于验证你的汇编器能正确处理空白字符和基本指令。
- Max.asm / MaxL.asm:一个计算两个数值最大值的程序。
MaxL.asm是其无符号版本(所有符号已被替换为具体数值),Max.asm是完整符号版本。你应在基础汇编器阶段测试MaxL.asm,在完整汇编器阶段测试Max.asm。 - Rect.asm / RectL.asm:一个在屏幕上绘制矩形的程序。同样提供有符号和无符号两个版本,测试策略与Max程序相同。
- Pong.asm / PongL.asm:这是一个复杂的“乒乓球”游戏程序。它最初是用名为Jack的高级语言编写,然后通过编译器(和中间的虚拟机)自动翻译成Hack汇编代码。因此,这个程序包含了大量自动生成的、看似“神秘”的标签和符号(如
Sys.init,Math.multiply)。Pong.asm是完整的符号化版本,PongL.asm是无符号版本。成功翻译并运行此程序,是对你汇编器工业级强度的终极考验。
如何测试你的汇编器 ✅
开发完成后,验证汇编器是否正确工作至关重要。以下是几种测试方法:
以下是三种主要的测试选项:
- 使用硬件模拟器:用你的汇编器生成
.hack二进制文件,然后将其加载到课程提供的硬件模拟器中的Hack计算机芯片上运行,观察程序行为是否符合预期。 - 使用CPU模拟器:这是一种更用户友好的方式。将生成的
.hack文件加载到CPU模拟器中运行和调试。 - 与参考汇编器对比(推荐):我们提供了一个已经过验证的汇编器(
Assembler)。你可以用它来翻译同一个.asm源文件,然后将生成的.hack文件与你自己的汇编器生成的文件进行逐行比较。如果两者完全一致,则证明你的汇编器工作正确。
课程网站提供了参考汇编器的图形化工具。你可以在其中加载源文件(.asm)和你的输出文件(作为比较文件),工具会自动进行比对并给出结果。
资源与总结 🎯
本项目所需的所有资源(测试程序、参考汇编器、CPU模拟器、教程、建议的API等)都已包含在你课程开始时下载的软件套件中,位于 projects/06 目录下。你也可以在 nand2tetris.org 网站上找到相关描述和额外资料。

本节课中我们一起学习了:项目6(汇编器)的完整实现蓝图。我们明确了程序契约,理解了四模块软件架构的优势,掌握了分阶段开发的实践方法,熟悉了用于验证的测试程序套件,并学会了通过对比参考输出来确保汇编器正确性的高效测试策略。现在,你可以根据这些指南,开始动手构建你自己的Hack汇编器了。
048:项目6概述(非编程选项)📝

在本节课中,我们将学习如何在不编写程序的情况下,完成汇编器项目的核心任务。我们将通过手动翻译的方式,将汇编语言程序转换为机器语言程序。
上一节我们介绍了如何编写一个汇编器程序。现在,我们来看看为没有编程经验的学习者提供的替代方案。
项目目标
本项目的目标与编写汇编器程序的目标完全相同:输入一个汇编语言文件,输出一个机器语言文件,后者是前者的精确翻译。唯一的区别在于,程序员会编写一个程序来自动完成这个翻译,而您需要手动完成。
您需要获取左侧格式的文件,并生成一个在右侧格式上与之等效的文件。
具体任务说明
以下是具体任务说明:
- 我们将在课程网站上提供几个用汇编语言编写的简短程序。
- 每个程序文件的后缀是
.asm,表示这是一个汇编语言程序。 - 对于每个名为
XXX.asm的文件,您需要创建一个名为XXX.hack的新文件。 .hack文件应包含与原始.asm文件等价的二进制机器码。
如何创建文件
您需要使用文本编辑器(例如记事本或WordPad)来创建和编辑 .hack 文件。这些工具允许您输入字符并将其保存为文件。
翻译方法概述
我们已经在本周详细解释了汇编器程序应该做什么。您可以模拟程序的行为,手动完成翻译,而无需知道如何编程。
为了使任务更简单,建议将整个过程分为两个阶段。
第一阶段:处理无符号程序
第一阶段是简单情况,即处理不包含任何符号的汇编语言程序。我们提供的练习程序中就有一个这样的例子。
以下是处理无符号程序的步骤:
- 从翻译一个没有任何符号的程序开始。
- 翻译过程是逐行进行的。
- 对于每一行,将其分解为各个部分,并按照第6.3单元讲解的规则进行翻译。
- 您需要查阅与程序相同的对照表(如指令表),手动完成翻译。
第二阶段:处理含符号程序
第二阶段更具一般性,也更困难,即处理包含符号的程序。正如我们所说,第一阶段是消除符号。一旦消除了符号,您就知道如何继续了。
以下是消除符号的步骤:
- 回顾第6.4单元,其中详细解释了Hack汇编语言中符号的含义、种类以及处理规则。
- 符号主要分为三类:变量、程序标签和一些预定义符号。
- 翻译时仍需逐行进行,但需要识别和处理不同类型的符号。
对于不同类型的行,处理方式如下:
- 不含符号的指令:例如
M=1,无需翻译,直接复制即可。注意,左侧的注释可以忽略。 - 变量:需要根据规则为变量分配内存地址。例如,第一个变量总是分配在地址16。之后每次看到同一个变量名,都必须将其翻译为同一个地址。
- 标签:首先需要确定标签在程序中的地址(即标签后第一条指令所在的内存位置)。然后,在程序中所有引用该标签的地方,用这个地址数值替换标签符号。标签声明本身(如
(LOOP))不占用内存位置。
如何验证翻译结果
完成翻译后,您可以通过以下几种方式验证结果是否正确。
以下是验证翻译结果的几种方法:
- 使用CPU和硬件模拟器:将您设计的CPU加载到硬件模拟器中,然后加载您刚刚创建的二进制程序文件,观察它是否能正确运行。
- 使用Hack计算机模拟器:我们提供了另一个工具,可以直接模拟Hack计算机,您无需加载自己的CPU设计,即可在该模拟器中运行您的程序。
- 使用我们提供的汇编器工具:我们的汇编器工具不仅可以自动、正确地翻译任何程序,还可以在您进行手动翻译时,将您的翻译结果与工具自动生成的结果进行比较,从而验证您的翻译是否正确。
工具使用示例
我们提供的汇编器工具运行界面截图展示了这一比较过程。左侧是汇编语言程序,右侧是您提供的翻译文件,中间是我们的汇编器自动生成的机器语言文件。如果两边的输出一致,则说明您的翻译是正确的。


本节课中,我们一起学习了如何通过手动翻译的方式完成汇编器项目。我们明确了项目目标,将翻译过程分解为处理无符号程序和处理含符号程序两个阶段,并介绍了验证翻译结果的几种方法。这为非编程背景的学习者提供了完成课程核心实践内容的有效途径。
049:视角与展望 🧭

在本单元中,我们将回顾第六周(也是本课程的最后一周)的学习内容,并探讨与汇编语言、计算机构建过程相关的一些深层问题与未来展望。
上一节我们完成了汇编器的构建,本节中我们将从更宏观的视角审视我们的工作,并回答一些常见问题。
如何改进符号语言? 🛠️
一个自然的问题是:能否在不改变底层二进制代码或机器语言的前提下,改进Hack符号语言,使其对程序员更友好?
答案是肯定的。我们可以在符号层之上引入一个抽象层,即宏汇编器和宏命令的概念。
以下是实现更友好编程的两种方式示例:
-
宏命令:例如,我们可能希望有一条像
D=M[100]这样的命令,表示将内存地址100的内容加载到D寄存器。标准的Hack语言没有这条命令。我们可以将其“翻译”成两条有效的Hack命令:@100和D=M。每当程序员写下这条宏命令时,汇编器就应生成对应的两条机器指令。 -
跳转指令:类似地,我们可能希望直接使用像
JMP LOOP这样的指令。这同样可以被翻译成@LOOP和0;JMP两条标准指令。
为了实现这些功能,我们需要扩展汇编器,使其能够识别这些宏命令并将其转换为对应的多条机器语言指令。
汇编器在现实中的应用 🏢
学生常问:在学校之外,我还会用到汇编器吗?
这个问题的答案与我们之前在第四周关于“是否会用到机器语言”的答案完全相同:非常罕见。
大多数时候,人们使用高级语言编程,无需关心机器语言。只有在性能至关重要的特殊情况下,程序员才会关注机器语言,此时他们会使用汇编语言,因而需要汇编器。
通常,他们可能只将程序中需要极致优化的一小部分用汇编语言编写,其余部分仍使用高级语言。例如,C编译器允许在C代码中嵌入汇编指令。因此,汇编器通常是编译器的一部分,用于处理特定的优化点。除此之外,你可能完全不需要关心汇编器或汇编语言编程。
第一个汇编器是如何编写的? 🧐
这是一个引人深思的问题。本课程中,我们享有使用其他计算机(用Java、Python等高级语言)来构建Hack计算机和编写汇编器的巨大便利。那么历史上,第一台计算机设计时,没有这种便利,第一个汇编器是如何编写的呢?
需要强调的是,这个概念上只适用于第一次编写汇编器的情况。现实中,人们通常利用已有的计算机和高级语言来为新计算机编写基础工具。
但从概念上探讨第一台计算机:你可以先用高级语言设计一个汇编器,然后手动地将这个汇编器程序,一条指令接一条指令地,翻译成目标计算机的机器语言。这个过程极其耗时、繁琐,但概念上只需要做一次。一旦完成,你就拥有了一个能在自己计算机上运行的机器语言版汇编器。此后,你就可以用它来编译更复杂的程序,包括用它自身的新版本(自举)。
课程总结与展望 🎉
至此,我们已经完成了计算机的构建。六周前我们从与非门开始,逐步构建了各种芯片、ALU、CPU、内存系统,并将它们组装成一台功能完整的计算机。我们还为其添加了汇编器,从而能够用一种相对友好的语言来编程这台计算机。现在,这台计算机可以运行你能想到的任何程序,包括《俄罗斯方块》。
然而,正如Noam指出的,本课程旨在揭开计算机的神秘面纱,虽然我们现在理解了硬件的工作原理,但计算机之上存在的各种软件层(操作系统、高级编程语言等)对许多人来说仍然神秘。我们使用的计算机离我们编写的小汇编器程序还很遥远。
幸运的是,这个“问题”已经得到解决——本课程拥有完整的第二部分!
在课程的第二部分,我们将从本课程构建的硬件出发,为其添加完整的软件层次结构。我们将以同样的自底向上方式,解释如何构建编译器、操作系统等软件层,最终实现一个非常接近现代面向对象编程语言的高级语言。届时,你将能够用这种语言编写《俄罗斯方块》等任何程序,并看到它在自己构建的硬件上运行。
你可以通过两种方式继续学习:
- 获取相关书籍,按照每周一个项目的方式,自行开发计算机的软件层次结构。
- 等待《从零开始构建现代计算机》第二部分在Coursera平台上线,像学习本课程一样完成这尚未完成的第二部分。
课程到此即将结束。感谢你投入时间和精力学习本课程,希望你能享受这段旅程。我们非常期待在《从零开始构建现代计算机》的第二部分与你再见!
再见! 👋

浙公网安备 33010602011771号