
计算机系统要素:Nand2tetris 部分章节学习
对数字逻辑设计、计算机组成原理、计算机体系结构、汇编、指令集架构、虚拟机、编译原理、操作系统的一个简单关系梳理。
本文很多内容如 Hack、Jack 等均为简化版的抽象,仅便于理解。
阅读本文之前,最好有以下基础:
数字逻辑设计
计算机组成原理
1. 数字逻辑设计
快速地复习一下那些早已淡忘的数电知识吧。
1.1 布尔逻辑:“计算单元”
1.1.1 布尔代数与逻辑门
逻辑代数常见运算
基本逻辑门:与非门 Nand 门
基本逻辑门:异或门 Xor 门
我们可以用以上的门电路进行组合设计一个复杂的组合逻辑电路芯片。
设计组合逻辑电路的方式:只要我们有真值表,我们就可以通过画卡诺图的方式得到逻辑表达式 F,再对逻辑表达式进行代数上的化简(化简规则这里不赘述)、转换(比如只用与非门实现),进而设计出我们的的逻辑门电路。(理解即可)
比如,我们要用与非门设计一个举重裁判表决电路。设举重比赛有3个裁判,一个主裁判和两个副裁判。只有当两个或两个以上裁判判明成功,并且其中有一个为主裁判时,表明成功的灯才亮。
输入变量:A为主裁判,B和C为副裁判; “1”---成功; “0”---失败。
输出变量:指示灯Y。 “1”---灯亮; “0”---灯灭。
1.1.2 从加法器到 ALU
实验室会有一些常用的组合逻辑电路,比如编码器、译码器、加法器、数据选择器、数值比较器等,我们在课上曾经学过:
8线-3线优先编码器 74LS148
二-十进制(BCD)优先编码器 74LS147
集成3线-8线译码器 74LS138
二-十进制译码器 74LS42
4选1数据选择器74LS153
集成双4选1数据选择器 74HC153
集成8选1数据选择器 74LS151
4位超前进位加法器 74LS283
集成4位数值比较器 74LS85
……
这些芯片有自己的真值表,由上述的逻辑门组成,对输入输出进行了一个抽象,极大方便了我们进行数字电路的设计。上过实验课的同学应该能够切身体会过。
这里我们着重介绍加法器。
半加器:两位数加法,真值表给出,可以使用逻辑门直接实现。
全加器:三位数加法(也可以说两位数带进位的加法),真值表给出,可以使用逻辑门直接实现。
加法器:n位数加法,使用全加器实现。可以使用串行进位加法器(传输延时长),也可以使用超前进位加法器(如 74LS283,传输延迟短)
算术逻辑单元 ALU:使用前面的加法器和另一些芯片,可以直接实现这个ALU芯片。
ALU 只实现较少的基本功能,对于其他扩展功能(如乘法、除法、浮点计算)等,我们用软件(如操作系统)实现。
1.2 时序逻辑:“记忆单元”
1.2.1 D 触发器(Data-Flip-Flop)
时钟(Clock) 在大多数计算机里,时间的流逝是用主时钟(master clock)来表示的,它提供连续的交变信号序列。其精确的硬件实现通常基于振荡器(oscillator),其在两个信号值0-1,或称“低电平-高电平(low-high,tick-tock)”之间交替变化。两个相邻的上升沿之间的时间间隔称为时钟的周期(cycle),每个时钟周期模塑一个离散时间单元。通过使用硬件电路,这个信号同时被传送到计算机平台的每个时序芯片中。
在设计计算机时钟时,时钟周期的长度要比 1 个比特在计算机系统中两个物理距离最长的芯片之间的传输时间稍长。
触发器(Flip-Flops) 计算机里最基本的时序单元是称为触发器的设备,它有几个种变体。在本节里我们使用称为数据触发器(Data Flip-Flop,DFF 或称 D 触发器)的变体,其接口包含 1 比特位输入和 1 比特位输出。另外,DFF 有个时钟输入,根据主时钟信号连续地交变。数据和时钟的输入使得 DFF 能够实现基于时间的行为 𝑜𝑢𝑡(𝑡)=𝑖𝑛(𝑡−1)out(t)=in(t−1),这里 in 和 out 是门的输入和输出值,t 是当前时钟周期。换句话说,DFF 简单地将前一个时间周期的输入值作为当前周期的输出。
D 触发器的相关原理需要用到 D 锁存器、RS 触发器等,也是通过基本逻辑门实现的,可以参考数字逻辑设计的PPT,这里不再赘述。
1.2.2 时序逻辑设计:寄存器与计数器
寄存器(Registers) 寄存器是具有记忆功能的设备,它能够“储存”或称“记忆”某一时刻的值,实现经典的存储行为 out(t)=in(t−1) 。在数字电路中,用来存放一组二进制数的电路称为寄存器。
一个 D触发器 可以存储一个二进制数(一个 bit),将他们组合起来成一个寄存器,可以存一组二进制数。
如 74LS194 ,它是一个4位移位寄存器,即可以串行移位置数,也可以并行置数。
此类寄存器的基本设计参数是它的宽度,即它所保存比特位的数量——比如,16、32或64。
此类寄存器的多位容量通常用字(word)来表示。
寄存器的位数其实就是机器字长。
计数器(Counter) 计数器是一种时序芯片,它的状态是整数,每经过一个时间周期,该整数就增加 1 个单位,执行函数 𝑜𝑢𝑡(𝑡)=𝑜𝑢𝑡(𝑡−1)+𝑐out(t)=out(t−1)+c,这里 𝑐c 就是 1。在数字电路中,能够记忆输入脉冲个数的电路称为计数器。
如 74LS161,它是一个4位同步计数器,异步清零(不受CP控制)、同步置数(受CP控制)
计数器在数字系统中担当非常重要的角色。比如,典型的 CPU 包括一个程序计数器(program counter),它的输出就是当前程序中下一步将要执行的指令地址。
同步时序逻辑问题的设计可以选用如下方法:
1.2.3 简单”RAM“
我们可以用上述的寄存器设计一个概念性的 ”RAM“。
一定要记住,实际的 RAM 不是由寄存器构成的,寄存器在 CPU 里面,这里只是概念性的设计!RAM有自己的工艺,一般1Bit由六MOS管构成
一旦具备了表示字的基本能力,就可以构建任意长度的存储块了。可以通过将很多寄存器堆叠起来形成随机存取RAM单元来实现。随机存取内存(RAM,Random Access Memory)这个概念由此得来:在RAM上能够随机访问被选择的字而不会受限于访问顺序。也就是说,我们要求内存中的任何字(无论具体物理位置在哪里)都能以相等的速度被直接访问。
这个规定可以通过以下方式实现。首先,根据其各自被存取的位置——也就是 𝑛 位 RAM 中每个记忆单元——分配一个唯一的地址(address,一个从 0 到 𝑛−1 之间的整数)。然后,构建一个由 𝑛-寄存器构成的阵列,再构建一个逻辑门,使得该逻辑门能够对给定的地址 𝑗j,找到地址 𝑗j 对应的记忆单元。要注意,由于记忆单元没有以物理实现的方式被标上地址,因此地址概念并没有在 RAM 设计中显式地表现出来,而我们在后面将会看到,当芯片配备了直接访问逻辑部件后,就能以逻辑方式实现寻址的概念。
总的来说,典型的 RAM 设备接收三种输入:数据输入、地址输入和加载位。地址指定了在当前时钟周期里哪一个 RAM 寄存器被访问。进行读操作时(load = 0),RAM 的输出立即发出被选中的记忆单元的值。在进行写操作(load = 1)时,被选择的记忆单元将在下一个时间周期内被赋予新输入值,从此 RAM 将开始发出该新输入值。
RAM 设备的基本设计参数是它的数据宽度(即每个字的宽度)和它的大小(RAM 中的字的数目)。现代计算机一般采用 32-位宽或者 64-位宽的 RAM。
1.2.4 程序计数器 PC
2. 计算机硬件架构
在 Nand2Tetris 这门课中,引入了一种硬件平台叫做 Hack,其机器语言称为 Hack 机器语言。
2.1 二进制机器语言
2.1.1 Hack 计算机
在第一章的数字逻辑设计中,我们可以用组合逻辑和时序逻辑设计各种各样的机器芯片组件如RAM、ALU、寄存器、程序计数器等。现在我们要做一个计算机叫做 Hack 计算机。而原来的二进制0、1指令现在被称作 Hack 机器语言。
Hack 是一个基于冯·诺伊曼架构的 16 位计算机,如下图,由以下组件组成:
CPU(含寄存器)
两个独立的内存模块:
指令内存(Instruction Memory)
数据内存(Data Memory)
两个内存映射 I/O 设备:
显示器
键盘
有关这些组件是如何组成的内容,我们在 2.2 进行讲述。
Hack 机器语言可以被看作是一种约定的形式,它利用处理器和寄存器来操控内存。
为了对 Hack 机器语言做一般性描述,我们只需要集中讨论三个主要的抽象体上:处理器(Processor)、内存(Memory)、一组寄存器(Registers)。
https://nand2tetris.github.io/web-ide/cpu
内存(memory)的概念是指“用来储存数据和指令的硬件设备”。从程序员的观点来看,所有的内存具有相同的结构:一个连续的固定宽度的单元序列,也称为字(word)或内存单元,每个内存单元都有一个唯一的地址(address)。因此,对于独立的字(word,代表一个数据项或是一个指令),可以通过提供它的地址来描述。
简便起见,在后面的内容中我们将会使用符号 Memory[address]
,RAM[address]
,或者 M[address]
来表示内存。
Hack 程序员需要了解两个不同的地址空间:
指令地址空间(如上图的 ROM)
数据地址空间(如上图的 RAM)
两个内存区都是 16 位宽,有 15 位地址空间(2 ^ 15 = 32K),这意味着两个内存可设的最大地址都是 32K 的 16-bit word。
CPU 仅能执行存储在指令内存中的程序。
指令内存 是只读设备,程序通过某种外部方法被加载到指令内存中。例如,可以在预先烧写了必要程序的 ROM 芯片中实现指令内存。要加载新程序,则要替换整个 ROM 芯片,跟玩游戏时需要在游戏控制台中替换游戏卡一样。为了模拟这个操作,Hack 平台的硬件仿真器必须提供一种方法,将某文本文件中用机器语言编写的程序加载到指令内存中去。
处理器,通常又称为中央处理器或 CPU(Central Processing Unit),是执行一组固定基本操作的设备。这些操作通常包括:
算术操作(如ALU)
逻辑操作
内存存取操作
控制操作(如程序计数器)
这些操作的对象是二进制数值,它们来自寄存器和指定的内存单元。类似的,操作的结果(处理器的输出)既可以存储在寄存器内,也可以存储在指定的内存单元。
寄存器,内存访问是相对较慢的操作,需要很长的指令格式(一个地址可能需要 32 位)。基于此原因,大多数处理器都配有一些寄存器,每个寄存器只存储 1 位。它紧挨着处理器,相当于处理器的一个高速本地内存,使得处理器能快速地操控数据和指令。寄存器使得程序员能够尽可能地使用内存访问命令,从而加速程序的执行。
Hack 程序员要接触两个称为 D 和 A 的 16 位寄存器。这些寄存器能够被算术和逻辑指令显式地操控,比如 A=D-1
或 D=!A
(这里“!”表示 16 位的 Not 操作)。
D 寄存器 仅用来储存数据值。
A 寄存器 既可作为数据寄存器也可作为地址寄存器。也就是说,根据指令的上下文含义,A 中的内容可以被解释为数值或数据存储器中的地址,或者作为指令存储器中的地址。
2.1.2 指令集:A-指令 与 C-指令
A-指令用于为 A 寄存器设置 15 位的值。
这里我们省去了汇编编译的过程,汇编编译在第三章讲到。本章中我们的重点在 Hack 机器语言。
Hack 汇编语言中,A 指令的格式为:
A-instruction: @value // value 是一个非负的十进制数,或者一个代表非负十进制数的符号。
Hack 机器语言中,A-指令的二进制表示如下:
0 VVV VVVV VVVV VVVV
例如,指令 @5
的二进制表示为:
0000 0000 0000 0101
这条指令使得计算机将二进制表示的 5 储存到 A 寄存器中。
A-指令主要有三种不同的用途:
将常数输入计算机:
在程序控制下,A-指令提供了唯一一种“将常数输入计算机”的方法。
为C-指令提供目标数据内存单元的地址:
通过将目标数据内存单元的地址放入 A 寄存器,A-指令为将对该内存单元进行操作的 C-指令提供了必要的条件。
为跳转指令提供目的地址:
通过将跳转的目的地址放入 A 寄存器,A-指令为执行跳转的 C-指令提供了条件。
C 指令是 Hack 平台程序的核心,几乎包含程序执行的所有操作。
Hack 汇编语言中,C 指令的格式为:
C-instruction: dest = comp; jump
其中,dest
或 jump
可以为空。如果 dest
为空,则省略“=”;如果 jump
为空,则省略“;”。
Hack 机器语言中,C 指令的二进制表示如下:
1 1 1 a c1 c2 c3 c4 c5 c6 d1 d2 d3 j1 j2 j3
前三位:第一位表示这是C指令,第2、3位没有使用。
comp(计算部分):接下来 1 + 6 位
comp 域控制 ALU,ALU 执行一组固定的函数集,对寄存器 D、A 和 M(M 代表 Memory[A])进行操作。comp 域由 1 个 a 位和 6 个 c 位组成,可以编码 128 个不同的函数,其中的 28 个函数在机器语言规范中列出。
dest(目的地部分):接下 3 位
dest 域指明计算结果的存储位置。dest 域由 3 位组成,决定将计算结果存入 A、D 或 M 中的哪个寄存器。
jump(跳转部分):
jump 域决定程序的下一步操作。jump 域由 3 位组成,根据 ALU 的输出值(由 comp 域计算得出)决定是否跳转到其他地址。
2.1.3 内存映像实现连接 I/O
Hack 平台能够连接两个外部设备:屏幕和键盘。这两个设备与计算机平台的交互都是通过内存映像(memory maps)实现的。这意味着在屏幕上描绘像素是通过将二进制值写入与屏幕相关的内存段来实现。同样,键盘的输入是通过读取与键盘相关的内存单元来实现的。物理 I/O 设备和它们对应的内存映像是通过连续的循环刷新来同步的。
Hack 计算机包括一个黑白屏幕,分辨率为 256×512 像素。屏幕的内容由 RAM 基地址为 16384(0x4000)的 8K 内存映射来表示。物理屏幕的每一行从屏幕的左上角开始,在 RAM 中用 32 个连续的 16 位字表示。因此,顶部第 r 行、左边第 c 列的像素映射到位置为 RAM[16384 + r*32 + c/16]
的字的 c%16
位(从 LSB 到 MSB)。为了读写屏幕上的一个像素,可以对 RAM 内存映射的相关位进行读写操作(1=黑,0=白)。
示例:在屏幕的左上角画一个黑点
@SCREEN // 将 A 寄存器的值置为内存映射区的映射到屏幕第一行的 16 个最左边的像素的内存字
M=1 // 将最左边的像素变黑
Hack 计算机与物理键盘之间通过 RAM 基地址为 24576(0x6000)的单字内存映像进行交互。只要在键盘上敲击一个键,其对应的 16 位 ASCII 码值就出现在 RAM[24576]
。没有击键时,该内存单元的值就为 0。除了常用的 ASCII 码之外,Hack 键盘还可以识别图 4.6 中列出的一些键。
屏幕:通过 RAM 内存映射来表示屏幕内容,每个像素对应一个二进制位。
键盘:通过 RAM 内存映射来表示键盘输入,按键时对应的 ASCII 码值会出现在指定的内存单元中。
2.1.4 使用汇编语言和机器语言执行程序命令
可以去看看 NAND2Tetris 的 Proj4:https://drive.google.com/file/d/1orGwC3o74vGv_rk-FDwoJGVvTxWGuQOC/view
再聊聊汇编语言与机器语言的区别:
1. Hack机器语言
定义:Hack机器语言是一种低级语言,直接由二进制代码组成,是计算机硬件能够直接理解和执行的语言。
特点:
由 16 位二进制指令组成。
每条指令对应一个特定的操作,例如算术运算、内存访问、跳转等。
直接与硬件交互,没有抽象层。
难以阅读和编写,因为它是纯二进制的。
2. Hack汇编语言
定义:Hack汇编语言是一种符号化的低级语言,是机器语言的符号表示形式。它使用助记符(如
ADD
、SUB
、JMP
等)来表示机器指令,而不是直接使用二进制代码。特点:
使用符号(如
A-指令
和C-指令
)来表示操作。比机器语言更易读和编写,但仍然非常接近硬件。
需要通过汇编器(Assembler)将汇编语言转换为机器语言,才能被计算机执行。
提供了一些抽象,例如符号地址(如
@sum
)和标签(如LOOP
),使得程序更易维护。
2.2 计算机组成原理
本部分我们从计算机各部件视角进行讨论,论述其功能与组成关系。
2.2.1 冯诺依曼结构
冯·诺依曼体系结构的基础是一个中央处理单元(CPU,Central Processing Unit),它与记忆设备(memory device)即内存进行交互,负责从输入设备(input device)接收数据,向输出设备(output device)发送数据。这个体系结构的核心是存储程序的概念:计算机内存不仅存储着要进行操作的数据,还存储着指示计算机运行的指令。
中央处理单元(CPU):
控制单元(CU):负责协调和控制计算机的操作,包括程序计数器(PC)、指令译码器(ID)等。
运算单元(ALU):执行算术和逻辑运算,包括加法器、逻辑运算器、移位器和求补器。
寄存器:用于临时存储数据和地址,分为数据寄存器、地址寄存器和标志寄存器。
存储器:
主存(内存):用于存储正在运行的程序和数据。
辅存(外存):属于 I/O 设备,如硬盘和光盘,用于长期存储数据和程序。
输入/输出(I/O)设备:
输入设备:如键盘、鼠标等,用于向计算机输入数据。
输出设备:如显示器、打印机等,用于从计算机输出数据。
总线(Bus):
连接CPU、存储器和I/O设备的通信路径,负责数据、地址和控制信号的传输。
下面让我们来详细了解这个体系结构。
2.2.2 存储器(Memory)
RAM(数据内存)
数据内存用于存储程序运行时的数据,支持读写操作。高级程序(如变量、数组、对象等)在翻译成机器语言后,会变成一连串的二进制数,存储在计算机的数据内存中。通过指定地址,可以对数据内存中的内存单元进行读操作或写操作。
Hack 的数据内存芯片具有典型的 RAM 设备接口。为了读取地址为 n 的内存单元中的内容,先将 n 放入内存的地址输入端口,然后从内存的输出端口得到结果。这是一个与时序无关的组合操作。为了将值 v 写入内存单元,先将 v 置于内存的数据输入端口,然后将地址 n 置于地址输入端口,并将内存的 load 比特位置为 1。这是一个时序操作,内存单元中的内容将在下一个时钟周期更新。
分类:
静态 RAM(SRAM):
用于片内 CPU 高速缓存(Cache)。
使用触发器存储数据,速度快但成本高。
读写操作通过地址译码器选择特定的存储单元。
动态 RAM(DRAM):
用于片外作为系统主存。
使用电容存储数据,速度较慢但成本低。
需要定期刷新以保持数据,包括集中刷新、分散刷新和异步刷新。
ROM(指令内存)
指令内存用于存储程序的指令,通常是只读的。高级命令在翻译成机器语言后,会变成一系列的二进制字,存储在计算机的指令内存中。CPU 在每一步操作中从指令内存中取出指令,解码并执行,然后计算下一条将要执行的指令。改变指令内存中的内容会完全改变计算机的操作。
Hack 的指令内存是只读的 ROM(Read-Only Memory),由 32K 可寻址的 16 位寄存器组成。指令内存中的指令格式遵守机器语言的规约。在一些计算机中,每个操作的规范及其操作数的代码用一个单一字长的指令表示;其他计算机则将这个规范分为几个单字表示。
分类:
拖模型 ROM(MROM):
用于存放计算机的基本程序和数据,如 BIOS ROM。
内容在制造时固定,用户无法修改。
可编程 ROM(PROM):
用户可以一次性编程的 ROM。
内容一旦写入后无法更改。
可擦除可编程 ROM(EPROM):
用户可以多次编程的 ROM,使用紫外线擦除。
适合需要频繁更新的应用。
可用电擦除的可编程 ROM(EEPROM):
用户可以多次编程的 ROM,使用电擦除。
适合需要快速更新的应用。
Flash Memory:
一种快速、可电擦除的非易失性存储器。
广泛用于存储设备中,如 USB 驱动器和固态硬盘。
2.2.3 CPU
CPU(中央处理器)是计算机体系的核心,负责执行已被加载到指令内存中的指令。这些指令告诉CPU执行不同的计算、对内存进行读/写操作,以及根据条件跳转去执行程序中的其他指令。CPU通过使用三个主要的硬件要素来执行这些任务:
算术逻辑单元(ALU):负责执行计算机中所有底层的算术操作和逻辑操作。典型的ALU可以执行的操作包括:将两个数相加、检查一个数是否为整数、在一个数据字(word)中进行位操作等。
寄存器(Registers):为了提高性能,CPU配备了一组高速寄存器,每个寄存器都能保存一个单独的字。这些寄存器用于暂存与运算相关的数据,避免频繁从内存中搬进搬出。
控制单元(Control Unit):负责解码指令,并向不同的硬件设备(如ALU、寄存器、内存)发送信号,指使它们如何执行指令。控制单元还决定下一步需要取出和执行哪一条指令。
CPU操作的循环
CPU操作可以被描述为一个重复的循环:
从内存中取一条指令(字)。
将其解码。
执行该指令。
取下一条指令,如此反复循环。
指令的执行过程可能包含以下子任务:
让ALU计算一些值。
控制内部寄存器。
从存储设备中读取一个字。
向存储设备中写入一个字。
在执行这些任务的过程中,CPU也会计算出下一步该读取并执行哪一条指令。
2.2.4 寄存器
内存访问的慢过程
内存访问是一个相对较慢的过程。当CPU被指示去取内存中地址j的内容时,以下过程会连续发生:
从CPU传到RAM。
RAM的直接访问逻辑(direct-access logic)选中地址为j的寄存器。
RAM[i]的内容传回到CPU。
相比之下,寄存器提供了更快的数据访问功能,因为它们位于CPU芯片内部,访问几乎可以瞬间完成。此外,寄存器数量远少于内存单元,因此机器语言指令可以使用短短的几个位来指定要操作的寄存器位置,从而使指令格式更短。
不同类型的寄存器
基于不同的目的,不同的CPU采用不同数量、不同类型的寄存器。在一些计算机体系结构中,每个寄存器可以有多种用途。常见的寄存器类型包括:
数据寄存器(Data Registers):为CPU提供短期记忆服务。例如,在计算(a-b)·c时,必须首先计算(a-b)的值并记住它。虽然这个结果可以暂时存储到内存中,但更好的办法是存储在CPU内部的数据寄存器中。
寻址寄存器(Addressing Registers):用于存储内存地址。在进行读写操作时,CPU需要访问内存中的数据。在某些情况下,内存地址作为当前指令的一部分给出;而在其他情况下,它依赖于前面指令的执行结果。对于后者,这个地址应存储在寻址寄存器中,以便在后续操作中作为存储单元的地址使用。
程序计数寄存器(Program Counter Register,PC):执行程序时,CPU必须知道下一条指令在指令内存中的地址。这个地址保存在程序计数器(PC)中。PC的内容被用作从指令内存中取指令的地址。在执行当前指令的过程中,CPU通过以下两种方式之一更新PC的内容:
如果当前指令不包含goto命令,PC增1,指向下一条指令。
如果当前指令包含goto n命令,则CPU将PC置为n。
2.2.5 I/O 设备
计算机使用一组输入输出(I/O)设备来与其外部环境进行交互。这些设备包括屏幕、键盘、扫描仪、网络接口卡、光驱等,还有一些复杂的嵌入式计算机,如集成在汽车、武器系统、医疗器械等机器中的计算机。
I/O 映像(Memory-Mapped I/O)
I/O 映像的基本思想是:创建 I/O 设备的二进制仿真,使其对于 CPU 而言,“看上去”就像普通的内存段。每个 I/O 设备在内存中都分配了独立的区域,作为它的“内存映像”。
输入设备(如键盘、鼠标):内存映像能连续不断地反映设备的物理状态。当外部事件作用于输入设备时(如按键或移动鼠标),某些特定的值会被写入它们对应的内存映像中。
输出设备(如屏幕、扬声器):内存映像连续地驱动设备的物理状态。例如,想要在屏幕上画图或播放一首歌曲,就将特定值写入它们对应的内存映像。
I/O 映像的实际应用
I/O 内存映像体系的实际应用非常有意义。CPU以及整个平台的设计可以完全不依赖于要与计算机进行交互的 I/O 设备,也不依赖于 I/O 设备的数量和种类。无论何时将新的 I/O 设备与计算机连接,所要做的只是为其分配一个新的内存映像并记录它的基地址(这些配置工作由操作系统完成)。这样一来,程序可以通过操控 I/O 内存映像中的比特数据来实现对相应物理 I/O 外设的操作。
标准规范的重要性
由于有大量可用的计算机平台和 I/O 设备,各类标准规范在计算机体系结构设计中起到了重要作用。这些标准确保了不同设备之间的兼容性,并简化了程序对 I/O 设备的访问。
2.3 汇编编译器
这段代码是一个简单的汇编器(Assembler),用于将Hack汇编语言(一种简单的汇编语言)转换为Hack机器语言(二进制代码)。Hack汇编语言是Nand2Tetris课程中使用的汇编语言,Hack机器语言是该课程中使用的虚拟计算机的机器语言。
主要功能
读取汇编文件:从指定的输入文件(
.asm
文件)中读取汇编代码。解析汇编指令:将汇编代码解析为不同的指令类型(A指令、C指令)。
转换为机器语言:将解析后的汇编指令转换为对应的二进制代码。
写入输出文件:将生成的二进制代码写入到指定的输出文件(
.hack
文件)中。
主要类和方法
Assembler
类
main
方法:程序的入口点,指定输入和输出文件路径,并调用run
方法。run
方法:负责读取输入文件、解析指令、转换为二进制代码并写入输出文件。
Parser
类
Parser
构造方法:初始化文件读取器。hasMoreCommands
方法:检查文件中是否还有更多指令。advance
方法:读取下一条指令,并去除注释和空白字符。commandType
方法:判断当前指令的类型(A指令、C指令或空指令)。symbol
方法:返回A指令的符号部分。dest
方法:返回C指令的dest
部分。comp
方法:返回C指令的comp
部分。jump
方法:返回C指令的jump
部分。
Code
类
dest
方法:将dest
助记符转换为对应的二进制码。comp
方法:将comp
助记符转换为对应的二进制码。jump
方法:将jump
助记符转换为对应的二进制码。
CommandType
枚举
A_COMMAND
:A指令类型。C_COMMAND
:C指令类型。EMPTY_COMMAND
:空指令类型。
代码流程
初始化:在
main
方法中,指定输入和输出文件路径,并调用run
方法。读取文件:使用
BufferedReader
读取输入文件中的每一行。解析指令:使用
Parser
类解析每一行指令,判断指令类型,并提取相应的部分(如dest
、comp
、jump
)。转换为二进制:根据指令类型,将指令转换为对应的二进制代码。
写入文件:使用
BufferedWriter
将生成的二进制代码写入输出文件。
示例
假设输入文件MaxL.asm
包含以下汇编代码:
asm
复制
@10
D=A
@20
D=D+A
生成的输出文件MaxL.hack
将包含以下二进制代码:
复制
0000000000001010
1110110000010000
0000000000010100
1110000010010000
package com.werun.werunjuly.common.nandlab6;
import java.io.*;
import java.util.Scanner;
public class Assembler {
private static Scanner scanner;
private static String currentCommand;
public static void main(String[] args) {
String inputFilePath = "D:\\code\\project\\Mar_NewWeRunJuly\\src\\main\\java\\com\\werun\\werunjuly\\common\\nandlab6\\MaxL.asm";
String outputFilePath = "D:\\code\\project\\Mar_NewWeRunJuly\\src\\main\\java\\com\\werun\\werunjuly\\common\\nandlab6\\MaxL.hack";
run(inputFilePath, outputFilePath);
}
public static void run(String inputFilePath, String outputFilePath) {
try (BufferedReader reader = new BufferedReader(new FileReader(inputFilePath)); BufferedWriter writer = new BufferedWriter(new FileWriter(outputFilePath))) {
Assembler.Parser parser = new Assembler.Parser(inputFilePath);
Assembler.Code code = new Assembler.Code();
while (parser.hasMoreCommands()) {
parser.advance();
Assembler.CommandType commandType = parser.commandType();
if (commandType == Assembler.CommandType.EMPTY_COMMAND) {
continue;
} else if (commandType == Assembler.CommandType.A_COMMAND) {
String binary = parser.symbol();
writer.write(binary);
writer.newLine();
} else if (commandType == Assembler.CommandType.C_COMMAND) {
String dest = parser.dest();
String comp = parser.comp();
String jump = parser.jump();
String binary = "111" + Assembler.Code.comp(comp) + Assembler.Code.dest(dest) + code.jump(jump);
writer.write(binary);
writer.newLine();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 封装对输入代码的访问操作
*/
static class Parser {
/**
* 打开文件流,为语法解析做准备
*
* @param path 文件路径
*/
public Parser(String path) throws FileNotFoundException {
scanner = new Scanner(new File(path));
}
/**
* 输入当中还有更多命令吗
*
* @return 有或没有
*/
public Boolean hasMoreCommands() throws IOException {
return scanner.hasNextLine();
}
/**
* 从输入中读取下一条命令
*/
public void advance() {
currentCommand = scanner.nextLine();
// 去掉注释
currentCommand = currentCommand.split("//")[0];
// 移除空白字符
currentCommand = currentCommand.trim();
}
/**
* 返回当前命令的类型
*
* @return 当前命令的类型
*/
public CommandType commandType() {
if (currentCommand.isEmpty()) {
return CommandType.EMPTY_COMMAND;
} else if (currentCommand.charAt(0) == '@') {
return CommandType.A_COMMAND;
} else return CommandType.C_COMMAND;
}
/**
* 返回十进制值
*
* @return 十进制值
*/
public String symbol() throws Exception {
if (commandType() == CommandType.A_COMMAND) {
return currentCommand.substring(1);
} else {
throw new Exception("只有A指令可以调用symbol");
}
}
/**
* 返回当前 C-指令的 dest 助记符
*
* @return 当前 C-指令的 dest 助记符
*/
public String dest() throws Exception {
if (commandType() == CommandType.C_COMMAND) {
if (!currentCommand.contains("=")) return "";
return currentCommand.substring(0, currentCommand.indexOf("="));
} else {
throw new Exception("只有C指令可以调用dest");
}
}
/**
* 返回当前 C-指令的 comp 助记符
*
* @return 当前 C-指令的 dest 助记符
*/
public String comp() throws Exception {
if (commandType() == CommandType.C_COMMAND) {
int beginIndex = currentCommand.indexOf("=") + 1;
int endIndex = currentCommand.indexOf(";");
if (endIndex == -1) endIndex = currentCommand.length();
return currentCommand.substring(beginIndex, endIndex);
} else {
throw new Exception("只有C指令可以调用comp");
}
}
/**
* 返回当前 C-指令的 jump 助记符
*
* @return 当前 C-指令的 dest 助记符
*/
public String jump() throws Exception {
if (commandType() == CommandType.C_COMMAND) {
if (!currentCommand.contains(";")) return "";
return currentCommand.substring(currentCommand.indexOf(";") + 1);
} else {
throw new Exception("只有C指令可以调用comp");
}
}
}
/**
* 将 Hack 汇编语言助记符翻译成二进制码
*/
static class Code {
/**
* 返回 dest 助记符的二进制码
*
* @param input 助记符
* @return 二进制码
*/
public static String dest(String input) {
switch (input) {
case "":
case "null":
return "000";
case "M":
return "001";
case "D":
return "010";
case "MD":
return "011";
case "A":
return "100";
case "AM":
return "101";
case "AD":
return "110";
case "AMD":
return "111";
default:
throw new IllegalArgumentException("Invalid dest value: " + input);
}
}
/**
* 返回 comp 助记符的二进制码
*
* @param input 助记符
* @return 二进制码
*/
public static String comp(String input) {
switch (input) {
case "0":
return "0101010";
case "1":
return "0111111";
case "-1":
return "0111010";
case "D":
return "0001100";
case "A":
return "0110000";
case "!D":
return "0001101";
case "!A":
return "0110001";
case "-D":
return "0001111";
case "-A":
return "0110011";
case "D+1":
return "0011111";
case "A+1":
return "0110111";
case "D-1":
return "0001110";
case "A-1":
return "0110010";
case "D+A":
return "0000010";
case "D-A":
return "0010011";
case "A-D":
return "0000111";
case "D&A":
return "0000000";
case "D|A":
return "0010101";
case "M":
return "1110000";
case "!M":
return "1110001";
case "-M":
return "1110011";
case "M+1":
return "1110111";
case "M-1":
return "1110010";
case "D+M":
return "1000010";
case "D-M":
return "1010011";
case "M-D":
return "1000111";
case "D&M":
return "1000000";
case "D|M":
return "1010101";
default:
throw new IllegalArgumentException("Invalid comp value: " + input);
}
}
/**
* 返回 jump 助记符的二进制码
*
* @param input 助记符
* @return 二进制码
*/
public String jump(String input) {
// 根据输入的jump值,返回对应的三位二进制字符串
switch (input) {
case "":
case "null":
return "000";
case "JGT":
return "001";
case "JEQ":
return "010";
case "JGE":
return "011";
case "JLT":
return "100";
case "JNE":
return "101";
case "JLE":
return "110";
case "JMP":
return "111";
default:
throw new IllegalArgumentException("Invalid jump value: " + input);
}
}
}
enum CommandType {
A_COMMAND, C_COMMAND, EMPTY_COMMAND,
}
}
3. 编译器后端:虚拟机
3.1 VM 虚拟机
在NAND2Tetris课程中,VM(虚拟机)代码是如何转换为Hack汇编代码的过程涉及多个步骤。这个过程通常由一个称为“VM翻译器”的工具来完成。以下是这个过程的概述:
3.1.1 VM代码的结构
VM代码是用一种高级的虚拟机语言编写的,它抽象了底层的硬件操作。VM代码通常包括以下几类指令:
算术和逻辑指令:如
add
,sub
,neg
,eq
,gt
,lt
,and
,or
,not
等。内存访问指令:如
push
,pop
,用于在虚拟机的栈和内存段之间传输数据。程序控制指令:如
goto
,if-goto
,label
,call
,return
,用于控制程序的执行流程。
3.2.2 VM翻译器的工作原理
VM翻译器的主要任务是将VM代码翻译成Hack汇编代码。Hack汇编代码是直接在Hack计算机上执行的低级代码。VM翻译器的工作可以分为以下几个步骤:
解析VM指令
VM翻译器首先解析VM代码,将其分解为不同的指令类型。每条指令都会被识别并分类为算术逻辑指令、内存访问指令或程序控制指令。
生成Hack汇编代码
根据不同的指令类型,VM翻译器会生成相应的Hack汇编代码。以下是一些常见的转换示例:
算术和逻辑指令:
例如,add
指令会被翻译为Hack汇编代码,执行栈顶两个元素的加法操作。// VM指令: add @SP AM=M-1 // SP-- D=M // D = *SP A=A-1 // A = SP-1 M=M+D // *SP-1 = *SP-1 + D
内存访问指令:
例如,push constant 10
指令会将常量10压入栈中。// VM指令: push constant 10 @10 D=A // D = 10 @SP A=M M=D // *SP = D @SP M=M+1 // SP++
程序控制指令:
例如,label LOOP
指令会生成一个Hack汇编标签。// VM指令: label LOOP (LOOP)
处理多文件和函数调用
如果VM代码包含多个文件或函数调用,VM翻译器还需要处理符号表、函数调用栈、返回地址等。例如,call
指令会生成保存返回地址和函数调用环境的Hack汇编代码。
最终,VM翻译器会生成一个完整的Hack汇编文件,其中包含了所有VM指令的等效Hack汇编代码。这个汇编文件可以直接在Hack计算机上运行。
3.2 VM 指令到 Hack 汇编指令的转换
让我们详细讲解一下为什么 add
指令需要这样翻译为 Hack 汇编代码,以及每一步的作用。
3.2.1. add
指令的作用
add
指令的作用是将栈顶的两个元素相加,并将结果存回栈中。具体来说:
栈顶的第一个元素是
y
(栈顶元素)。栈顶的第二个元素是
x
(栈顶的下一个元素)。执行
x + y
,并将结果存回栈中,覆盖原来的x
。
3.2.2. Hack 汇编代码的实现
以下是 add
指令的 Hack 汇编代码:
assembly
复制
// VM指令: add
@SP
AM=M-1 // SP--
D=M // D = *SP
A=A-1 // A = SP-1
M=M+D // *SP-1 = *SP-1 + D
让我们逐行分析:
第1行:@SP
@SP
表示将栈指针(Stack Pointer)的地址加载到寄存器A
中。SP
是一个内存地址,指向当前栈顶的位置。
第2行:AM=M-1
AM=M-1
是 Hack 汇编中的一种简写形式,表示同时修改A
和M
的值。A=M-1
:将A
设置为SP - 1
,即栈顶的下一个位置。M=M-1
:将SP
的值减 1,即栈指针向下移动一位。
这一步的作用是:
将栈指针
SP
向下移动一位,指向栈顶的下一个元素。同时,将
A
设置为栈顶的下一个元素的地址。
第3行:D=M
D=M
表示将内存地址A
处的值(即栈顶的下一个元素y
)加载到寄存器D
中。此时,
D
中保存的是y
的值。
第4行:A=A-1
A=A-1
表示将A
的值减 1,指向栈顶的下一个元素的地址。此时,
A
指向的是栈顶的下一个元素x
的地址。
第5行:M=M+D
M=M+D
表示将内存地址A
处的值(即x
)与寄存器D
中的值(即y
)相加,并将结果存回内存地址A
处。此时,
x
被更新为x + y
,栈顶的下一个元素被覆盖为加法的结果。
3.2.3. 为什么要这么做?
栈的工作原理
栈是一种后进先出(LIFO)的数据结构。栈顶的元素是最后压入栈的元素。
在 Hack 计算机中,栈指针
SP
指向栈顶的下一个空闲位置。栈的操作(如
push
和pop
)通过修改SP
来实现。
add
指令的实现逻辑
add
指令需要取出栈顶的两个元素x
和y
,并将它们相加。由于栈顶的元素是
y
,栈顶的下一个元素是x
,因此需要先取出y
,再取出x
,然后执行加法。
具体步骤的解释
SP--
:栈指针
SP
向下移动一位,指向栈顶的下一个元素。这一步是为了访问栈顶的下一个元素
y
。
D=M
:将栈顶的下一个元素
y
加载到寄存器D
中。
A=A-1
:将
A
的值减 1,指向栈顶的下一个元素x
的地址。
M=M+D
:将
x
和y
相加,并将结果存回栈中,覆盖原来的x
。
为什么需要 A=A-1
?
A=A-1
是为了访问栈顶的下一个元素x
。由于栈指针
SP
已经指向了栈顶的下一个元素y
,因此需要将A
减 1,才能访问到x
。
为什么需要 D=M
?
D=M
是为了将栈顶的下一个元素y
加载到寄存器D
中,以便与x
相加。
3.2.4 总结
add
指令的 Hack 汇编代码实现了一个栈操作的加法:
首先,栈指针
SP
向下移动一位,指向栈顶的下一个元素y
。然后,将
y
加载到寄存器D
中。接着,将
A
减 1,指向栈顶的下一个元素x
。最后,将
x
和y
相加,并将结果存回栈中。
这种实现方式充分利用了 Hack 计算机的寄存器和内存操作,确保了栈操作的正确性和高效性。
3.3 VM 与 JVM 的相似之处
在NAND2Tetris课程中,虚拟机(VM)的设计和实现与Java虚拟机(JVM)有很多相似之处。虽然NAND2Tetris的VM是一个简化的虚拟机,而JVM是一个功能强大的工业级虚拟机,但它们在概念和设计上有很多共通点。以下是它们之间的关系和对比:
3.3.1 虚拟机的概念
NAND2Tetris VM:
NAND2Tetris课程中的虚拟机是一个简化的栈式虚拟机,用于在Hack计算机上运行高级程序。
它的设计目的是让学生理解虚拟机的基本原理,包括栈操作、内存管理、程序控制等。
VM指令集包括算术逻辑指令、内存访问指令和程序控制指令。
JVM(Java Virtual Machine):
JVM是Java语言的核心运行时环境,负责将Java字节码(Java编译器生成的中间代码)转换为机器码并执行。
JVM是一个功能强大的虚拟机,支持多线程、垃圾回收、动态类加载等功能。
JVM指令集(字节码)也包括算术逻辑指令、内存访问指令和程序控制指令。
3.3.2 指令集的相似性
NAND2Tetris VM和JVM的指令集在功能上有很强的相似性,尽管它们的实现和复杂度不同。
算术和逻辑指令
NAND2Tetris VM:
包括
add
,sub
,neg
,eq
,gt
,lt
,and
,or
,not
等指令。这些指令直接操作栈顶的元素,完成算术和逻辑运算。
JVM:
包括
iadd
,isub
,ineg
,if_icmpeq
,if_icmpgt
,if_icmplt
,iand
,ior
,inot
等指令。这些指令也操作栈顶的元素,完成算术和逻辑运算。
内存访问指令
NAND2Tetris VM:
包括
push
和pop
指令,用于在栈和内存段之间传输数据。内存段包括
local
,argument
,this
,that
,temp
,static
,pointer
等。
JVM:
包括
aload
,astore
,getfield
,putfield
,getstatic
,putstatic
等指令。这些指令用于从栈、局部变量表、对象字段等内存区域加载和存储数据。
程序控制指令
NAND2Tetris VM:
包括
goto
,if-goto
,label
,call
,return
等指令。这些指令用于控制程序的执行流程,支持函数调用和返回。
JVM:
包括
goto
,ifeq
,ifne
,iflt
,ifge
,ifgt
,if_icmpeq
,if_icmpne
,invokevirtual
,invokestatic
,return
等指令。这些指令用于控制程序的执行流程,支持方法调用和返回。
3.2.3 栈式虚拟机的共性
NAND2Tetris VM和JVM都是栈式虚拟机,这意味着它们的指令集设计基于栈的操作。
栈式虚拟机的特点
栈是核心数据结构:
算术逻辑运算和内存访问都通过栈来完成。
栈顶的元素是当前操作的操作数。
指令简洁:
由于操作数从栈中获取,指令不需要显式指定操作数,因此指令集可以非常简洁。
易于实现:
栈式虚拟机的实现相对简单,适合教学和学习。
NAND2Tetris VM和JVM的栈操作
NAND2Tetris VM:
栈用于存储函数调用的参数、局部变量和返回值。
栈操作通过
push
和pop
指令完成。
JVM:
栈用于存储方法调用的操作数栈和局部变量表。
栈操作通过
aload
,astore
,dup
,swap
等指令完成。
3.2.4 函数调用和返回
NAND2Tetris VM和JVM都支持函数调用和返回,但实现方式有所不同。
NAND2Tetris VM:
使用
call
和return
指令。call
指令保存当前函数的返回地址和调用环境(如SP
,LCL
,ARG
,THIS
,THAT
),然后跳转到目标函数。return
指令恢复调用环境,并将返回值传递给调用者。
JVM:
使用
invokevirtual
,invokestatic
,invokeinterface
,invokespecial
和return
指令。invokevirtual
用于调用实例方法,invokestatic
用于调用静态方法。return
指令用于从方法返回,并将返回值传递给调用者。
3.4 VM 与 JVM 的不同点比较
学习 NAND2Tetris 课程中的虚拟机(VM)设计和实现,虽然与 JVM 的复杂性相比非常基础,但它确实为理解 JVM 的内存管理、类加载机制、垃圾回收、JVM 调优等高级主题提供了重要的基础知识和思维框架。以下是具体帮助的分析:
3.4.1 对 JVM 内存管理的帮助
NAND2Tetris VM 的内存管理
NAND2Tetris VM 的内存管理非常简单,主要包括以下几个部分:
栈(Stack):用于存储函数调用的局部变量和操作数。
堆(Heap):用于动态分配内存(如对象)。
内存段(Segments):如
local
,argument
,this
,that
,temp
,static
等,用于模拟不同的内存区域。
JVM 的内存管理
JVM 的内存管理更加复杂,主要包括以下区域:
堆(Heap):用于存储对象实例和数组。
方法区(Method Area):用于存储类信息、常量、静态变量等。
栈(Stack):每个线程有自己的栈,用于存储局部变量和操作数栈。
程序计数器(PC Register):记录当前线程执行的字节码指令地址。
本地方法栈(Native Method Stack):用于执行本地方法(如 C/C++ 代码)。
帮助点
学习 NAND2Tetris VM 的内存管理可以帮助你理解 JVM 内存管理的基本概念:
栈和堆的区别。
内存段的划分和作用。
函数调用时栈帧的创建和销毁。
3.4.2 对 JVM 类加载机制的帮助
NAND2Tetris VM 的类加载
NAND2Tetris VM 没有类加载机制,因为它是一个非常简单的虚拟机,不支持面向对象编程。
JVM 的类加载机制
JVM 的类加载机制非常复杂,包括以下步骤:
加载(Loading):从文件系统或网络加载类文件。
链接(Linking):包括验证、准备和解析。
初始化(Initialization):执行类的静态初始化代码。
帮助点
虽然 NAND2Tetris VM 没有类加载机制,但它的模块化设计和函数调用机制可以帮助你理解:
如何将代码划分为模块(类似于类)。
如何实现模块之间的调用(类似于方法调用)。
这些基础知识为理解 JVM 的类加载机制提供了思维框架。
3.4.3 对 JVM 垃圾回收的帮助
NAND2Tetris VM 的垃圾回收
NAND2Tetris VM 没有垃圾回收机制,内存分配和释放需要手动管理。
JVM 的垃圾回收
JVM 的垃圾回收机制非常复杂,主要包括以下算法:
标记-清除(Mark-Sweep)。
复制算法(Copying)。
标记-整理(Mark-Compact)。
分代收集(Generational Collection)。
帮助点
学习 NAND2Tetris VM 可以帮助你理解:
内存分配和释放的基本原理。
为什么需要垃圾回收(手动管理内存容易导致内存泄漏)。
这些基础知识为理解 JVM 的垃圾回收机制提供了背景知识。
3.4.4 对 JVM 调优的帮助
NAND2Tetris VM 的调优
NAND2Tetris VM 非常简单,没有调优的需求。
JVM 的调优
JVM 调优是一个复杂的主题,主要包括以下方面:
堆内存调优:调整堆的大小和分代策略。
垃圾回收器选择:选择合适的垃圾回收器(如 G1, CMS, ZGC)。
线程调优:调整线程池大小和线程优先级。
JIT 编译器调优:调整 JIT 编译器的优化策略。
帮助点
学习 NAND2Tetris VM 可以帮助你理解:
虚拟机的基本工作原理(如栈、堆、指令执行)。
如何分析虚拟机的性能瓶颈。
这些基础知识为理解 JVM 调优提供了思维框架。
4. 编译器前端:编译原理(略)
该部分是高级语言到VM编译器的“字节码”的转化过程,分为两部分:语法分析、代码生成。
参考
计算机系统要素
何燕平《数字逻辑设计》
张策《计算机组成原理》
deepseek