手搓PC(Program Counter)?Nand2Tetris(计算机系统要素) Unit 3 学习笔记

手搓PC(Program Counter)?Nand2Tetris(计算机系统要素) Unit 3 学习笔记
Xiaozhi_z起初是在CS自学指南里找到的这门课(依据基本原理构建现代计算机:从与非门到俄罗斯方块),感觉还不错而且零基础,是一个比较好的入门课程。同时全英教学可以逆向让我捉襟见肘的英语水平稍微强一点,多理解一个单词就是胜利(
因为课程快过期了 就火速学习了一下
Unit 3.1 Sequential Logic(顺序逻辑)
之前的课程完全忽略了时间这个参数,我们假设计算是“瞬时”完成的(组合逻辑),所有输入都没有在运行中有改变之类的情况出现。
只是简单的从输入到输出的逻辑映射,但是现实情况是要考虑时间这个因素。
为什么需要时间:
- 硬件复用:我们想用同一套硬件反复执行相同的任务(比如多次加法运算,就像程序中的循环)。
- 记忆/状态:我们需要记住过去的计算结果(比如累加100个数字的总和,需要记住中间的总和)。(这里其实引申出时序逻辑函数的概念了 时序逻辑的核心:电路需要具有‘状态’(State)
如何处理物理时间的延迟:
其实就是相当于把一段很长的时间分割出很多块 块的长度是一样的 便于计算机理解时间这个概念(时钟是通过连续不断地发出时钟脉冲,来把时间切成一块一块的。
物理世界中的电信号变化不是瞬时的,存在延迟(比如电压缓慢上升),而且时间也可以被无限分割 例如秒 毫秒 之类的

为了简化设计,我们引入了一个时钟。时钟就像一个稳定的振荡器,将连续的物理时间切割成一系列离散的、整数的“时间单位”(比如时刻1、时刻2、时刻3)。

组合逻辑 and 时序逻辑:
- 组合逻辑(前两周的内容):在时刻 t 的输出,完全取决于时刻 t 的输入。它不记忆过去的信息。
- 时序逻辑(本周内容):在时刻 t 的输出,取决于过去时刻(比如 t-1)的输入。它能够记住之前的状态。
状态的概念:
有了时序逻辑,我们可以让电路的输入和输出是同一个物理位置。
这样,这个位置在时刻 t 的值(新状态)就取决于它在时刻 t-1 的值(旧状态)。
这样的电路就存在记忆功能 输出将不仅仅和输入有关联 还会与上一个状态有关联。

Unit 3.2 Flip Flops(触发器)
先回忆一下上一节 多了一个新的变量t 用于来更好的理解时间这个概念

组合逻辑的局限:之前的逻辑电路(如与门、或门)的输出只取决于当前的输入,它们没有“记忆”。但计算机需要在不同的时间单位之间传递信息。
延伸出一个新元件可以将信息从上一个时间单位(t-1)带到下一个时间单位(t) 这个元件必须拥有状态,能记住自己是0还是1。
D触发器
这个原件长这样
定义:这种能够在两个物理状态(0和1)之间切换并记住当前状态的元件,叫做触发器。

- 功能:它的功能非常简单——输出等于上一个时间单位的输入。
- 在时间t,它的输出就是时间t-1时的输入值。
- 可以理解为将信号向右平移了一个时间单位。
但是第一次输入得到的输出因为没有上一个时间的输入所以得到的是不确定的值
符号含义:在电路图中,D触发器底部的小三角形代表这是一个时序芯片,它的行为依赖于时间(时钟),而不仅仅是当前的输入。
触发器可以由
Nand门通过反馈回路构建,但在本课程中,我们将触发器视为一个基本的、不可再分的原始构件,就像之前的Nand门一样。这样做的目的是为了在概念上清晰地区分“瞬时完成的组合逻辑”和“依赖时间的时序逻辑”。

1位寄存器(1-Bit Register)
有了D触发器这个基础记忆单元,可以基于此构建1位寄存器。它能够无限期地记住一位信息,直到被明确要求改变。

1位寄存器需要两个输入和一个输出:
- 输入
in:想要存入的数据位(0或1) - 输入
load:加载控制信号 - 输出
out:当前存储的值
基本逻辑:
- 当
load = 1时:在下一个时间单位,寄存器加载并记住当前的in值 - 当
load = 0时:寄存器保持当前值不变,无论in如何变化

构建1位寄存器
已经知道了1位寄存器想要什么功能,现在来看看怎么用D触发器把它造出来。
D触发器只能记住一个时间单位,但我们想要的是”永久”记住。怎么办?
如果能把D触发器的输出接回它自己的输入,那它就会每个时间单位都记住同样的值——这不就是”保持”吗?
但是,当需要加载新值时,又要把新数据接入 这就产生了一个矛盾
D触发器的输入有两个可能的来源 新来的in和自己的旧out。怎么选?

这时候多路复用器的作用就体现出来了 在D触发器前加一个MUX多路复用器 就可以实现寄存器的功能啦
可以理解为多路复用器就是一个简单的二选一开关
把in和out分别接到MUX的两个输入端,用load信号控制选哪个:
load=1:选in(加载新值)load=0:选out(保持旧值)
MUX的输出再接回D触发器的输入,这样就构成了一个能“保持或更新”的1位寄存器。

Unit 3.3 Memory Units(存储单元)
先回忆一下冯诺依曼架构 里面就有Memory 我正好计组学到这里了 国内一般叫存储器

但是关于存储 或者叫“内存” 其实有好几个概念 例如现在很多人都问手机是多大内存 其实这里说的是硬盘大小 而不是RAM(运行内存)的意思
这里要讲的Memory是主存的意思 就是RAM
易失性 vs 非易失性:RAM在断电后会丢失数据(易失性);硬盘和闪存则不会(非易失性)。

RAM里面存储程序运行时所需要的数据以及指令

寄存器
寄存器是由多个1位寄存器(Bit Register)并排组成的
状态:指寄存器当前存储的值。
操作方式:
- 读:直接查看
out引脚,它会持续输出当前存储的状态。 - 写:将新值放在
in引脚上,并将load位设置为1。在下一个时钟周期,寄存器才会存储并输出这个新值。

寄存器的字长 也就是所谓的多少多少位计算机 现在基本都是x64 很少用32位的机器了

RAM
结构:RAM被抽象为一个可寻址的寄存器序列。

核心特性:随机存取。
- 无论RAM中有8个还是数百万个寄存器,访问(读/写)任意一个寄存器所需的时间都是相同的。这是RAM最重要的特点。
读写流程:
- 读:设置
address-> 读取out值。 - 写:设置
address-> 设置in值 -> 将load位设为1-> 等待一个时钟周期完成写入。
为了最终组装成Hack计算机,你需要构建以下5个内存芯片:
- RAM8:8个寄存器,3位地址。
- RAM64:64个寄存器,6位地址。
- RAM512:512个寄存器,9位地址。
- RAM4K:4096个寄存器,12位地址。
- RAM16K:16384个寄存器,14位地址。

Unit 3.4 Counters(计数器)
课程中讲的一个理解计数器的例子
想象一下你家有个烤布朗尼的机器人。你想让它烤布朗尼,得写个50条指令的菜谱贴在墙上。但问题来了:机器人怎么知道下一步该执行哪条指令?
这就是计数器要干的事。
在墙上菜谱旁边装个计数器,它吐出来的数字就是机器人要执行的指令编号。从0开始,执行完一条,计数器加1,指向下一条。就这么简单。
但光会加1还不够 万一我想让它从中间某条指令开始呢?比如第二次烤布朗尼时,烤箱已经是热的,得跳过“预热烤箱”那几条指令。这时候就得能强行把计数器设成17,然后从17接着数。
或者烤完一批想再烤一批,得能把计数器归零,从头开始。
所以计数器要支持三个基本操作:
- 取第一条指令:把计数器设成0(
reset) - 取下一条指令:当前值加1(
inc) - 取指定指令:把计数器设成某个特定值(
load)
PC芯片规格
输入:in[16](16位数据输入),load(加载控制),inc(加1控制),reset(复位控制)
输出:out[16](16位数据输出)
功能:时钟上升沿触发更新

逻辑运行顺序
- reset优先:只要reset=1,不管别的控制位是啥,下一周期输出必是0
- load次之:reset=0且load=1时,把in的值加载进去
- inc再次:reset=0、load=0、inc=1时,当前值加1
- none:三个控制位全0,就保持当前值不动
Unit 3.5 Project 3 Overview(项目 3 概述)
项目目标
利用项目 1 和项目 2 中构建的芯片(如 Mux、DMux、逻辑门)以及一个原始的数据触发器(DFF),来构建一系列计算机中常用的时序芯片(记忆芯片)。
需要构建的芯片
1 位寄存器(Bit):最基本的存储单元,可以存储 1 个比特(0 或 1)。

16 位寄存器(Register):由 16 个 1 位寄存器并联组成,用于存储一个 16 位的值。

RAM 系列(内存):通过递归和层次化构建的方式,逐步搭建更大的内存:
- RAM8:包含 8 个 16 位寄存器的内存。

- RAM64:由 8 个 RAM8 组成。
- RAM512:由 8 个 RAM64 组成。
- RAM4K :由 8 个 RAM512 组成。
- RAM16K:最终的 16K 内存。

程序计数器(PC):本质上是一个计数器,但在计算机架构中用于跟踪下一条指令的地址。

Programming Assignment: Project 3(编程作业: 项目 3)
1 | 准备提交时,将您编写的所有 *.hdl 文件打包成一个名为 project3.zip 的压缩文件(打包文件本身,不要放在任何子文件夹内),然后提交。您提交的次数不限,成绩将是您所有提交成绩的最大值,因此您不会因为再次提交而丢分。 |
分享一个好用的插件
这是偶然发现的一个Vscode的插件 可以将hdl可视化显示出来 写hdl的时候会直观很多
https://marketplace.visualstudio.com/items?itemName=PranavJain.nand2tetris-hdl-visualizer
这个是插件的效果 我拿ALU做的例子展示 这个插件的原理应该是一个http服务跑在8080 根据介绍使用就好啦 非常的直观了也是

Bit(1 位寄存器)

这个实际上之前就已经有逻辑图了

写出的hdl如下
1 | CHIP Bit { |
用插件可视化hdl如下

跑一下测试 发现没问题!

Register(16 位寄存器)
由 16 个 1 位寄存器并联组成,用于存储一个 16 位的值。

就是将16个Bit串起来就好啦 hdl如下
1 | CHIP Register { |
可视化如下

测试成功

RAM8

这个思路其实比较简单 之前学逆向的时候了解过类似的东西
其实这个就是三八译码器 实现从内存地址到数据的转换?用这个思路尝试写一下hdl
用三个地址线来访问是哪个寄存器就可以啦
用之前做过的两个芯片 一个是三八译码器的效果 另一个是用同样到address实现八选一输出16位结果
1 | CHIP RAM8 { |
将逻辑图表达出来就很明了了

最后也测试成功!

RAM64

其实从这时候开始 就变成套娃了 一个套一个 实现更大的内存
地址分解:
- 高3位(address[3..5]):选择哪一组RAM8(共8组)
- 低3位(address[0..2]):在选中的RAM8内选择哪个寄存器
hdl如下
1 | CHIP RAM64 { |
逻辑图如下

测试结果也没问题

RAM512
这个就没啥配图了 依旧是RAM64套娃 实现的逻辑一模一样 套娃就行 最高三位用来选择RAM64 其余的都给RAM8使用即可
1 | CHIP RAM512 { |
逻辑图如下 一直在套娃

最后测试也是成功!

RAM4K
一样的逻辑写hdl就ok啦
1 | CHIP RAM4K { |
逻辑图也是一样的 只是换了芯片

测试成功

RAM16K
和之前的区别不大 就是从三八译码器换成了二四译码器 hdl如下
1 | CHIP RAM16K { |
逻辑图如下

测试一下最终的效果

PC(程序计数器)
输入:in[16](16位数据输入),load(加载控制),inc(加1控制),reset(复位控制)
输出:out[16](16位数据输出)
功能:时钟上升沿触发更新

逻辑运行顺序
- reset优先:只要reset=1,不管别的控制位是啥,下一周期输出必是0
- load次之:reset=0且load=1时,把in的值加载进去
- inc再次:reset=0、load=0、inc=1时,当前值加1
- none:三个控制位全0,就保持当前值不动
逻辑很明确 就是一层一层去写 和上一次写的ALU的层级关系很像
我一开始想先一层一层写发现到那个加法器那块就有点思维乱了
1 | Mux16(a= in, b= false, sel= reset, out= notreset); |
然后我就想一开始先把增量器整上 然后再去将别的芯片相连接
关键点在于这个是时序逻辑电路要想到这个电路是可以循环连接的
这时候其实优先级可以反着来看 如下
- 第一级Mux (inc): 决定是保持当前值还是加1
- 第二级Mux (load): 在inc结果和输入in之间选择
- 第三级Mux (reset): 在load结果和0之间选择(优先级最高)
- Register: 在时钟上升沿存储选中的值
1 | CHIP PC { |
最后的逻辑图如下

跑测试也成功啦 可喜可贺!撒花

