从0开始构建计算机

序言

很多人都听过计算机是由0、1以及运算组成的,或在计算机基础学习时候也都听说过冯诺依曼设计思想:基于二进制的存储和运算……我相信大多数人都会对此表示神奇和怀疑:“通过0和1竟然能实现这么强大的功能?”对此大家肯定有过或多或少的学习和了解。随着知识深度和广度的增加:编程语言、计算机原理(CPU/汇编)、编译原理、可计算理论等等,对于没学习数字逻辑电路的人来说,最低层的机制始终缺少了那么一环!那么其底层到底是如何从通过何种形式一步步构建为CPU乃至计算机的呢?真的是复杂、很难理解吗?

一个偶然的机会我在油管上看到一个牛人用面包板、基础元件逐步构建了一个可以编程、运行的计算机雏形,佩服的同时也打开了我的深入探索之心,在学习过程种时不时的就会有豁然开朗的感觉,很多的干货。随后我通过逻辑电路软件,尝试去绘制构建,定义指令集、构建CPU,最终成功的实现了可以运行的自动机!尽管受限于内存规模、指令集数量,功能还相对较弱,但也是五脏俱全,理论上扩展下就可以是图灵完备的。因此我将此整理出来,和大家一起分享这整个过程:从0开始,突破认知,打开新思路,徒手构建一个可以运行的8位计算机!这将会让大家对计算机、CPU的机制有深入理解,结合其他知识,打通从底层到高层的每个环节认知!

本文知识点每一节难度都不大,不需要很深的知识储备,中学的知识储备即可以看懂,不过需要静心研读。理解了本文的思想,你离冯诺依曼、图灵等大神就更近一步!另外,对很多事情的认知或许也会有些不一样的灵感!

作者:吴亚萌
时间:2022-04

一、基础知识

相信愿意读这篇文章的人对此都有深入的了解,这里给接触不多的朋友以简单的介绍,熟悉的小伙伴请直接跳过对应部分。

1. 二进制

1.1. 数的表示

组成数的字符集中只有2个符号:0和1,计算时每个位置满2进1
自然数的序列就是:0,1,10,11,100,101,110,111,1000,...
在其上亦可进行加、减、乘、除的运算,这里不做阐述。

1.2. 单位(位、字节)

二进制数中的每一个0或1符号叫做1“位”(bit),是最小单位。
8位组成1“字节”(byte),是最常用的单位。1字节可以用于表达1个英文字符。
当然还有更大的单位,如KB,MB,GB,TB,PB等,每级之间都说是倍(接近1000)。
每1位有2个状态,1字节(8位)就种状态。
之所以1字节选择8位,是因为1字节需要能够表示:英语字母(大小写)、数字、常用符号等,接近128个,这就是最基础的ASCII表,再加上一个扩展标记或预留也就是8位。

1.3. 负数的表示(补码)

一个n位的二进制数有状态,可以表示0 ~ 这些自然数。假如我们需要表示负数,可以怎么做呢?
以1字节为例,想象一下,把这256个符号按0 ~ 255顺时针排成时钟一样的圆环,顺时针移动1格则加1,逆时针移动1格减小1。因此从0开始逆时针旋转的一半部分用来表示负数!这就是补码的思想。
因为我们将较大的一半的符号记作负数,也就刚好最高位是1的数表示负数。
负数补码和其相反数关于0是对称的,不难得出他们的关系是:取反再加1。
在1字节下,最大的有符号整数是,对应十进制127;无符号最大值表示-1,加1刚好变为0(最高位进位溢出忽略);最小的数是,表示十进制的-128。
一般的,n位有符号二进制数可以表示的有符号整数范围是: ~ 。

2. 十六进制

二进制表示数时位数比较多,书写和表达不方便,因此常用十六进制来替代。
十六进制是选择0~9、A-F(大小写不区分)这16个符号来计数,以0x打头。
1个十六进制位刚好和4位二进制数对应,对应表如下:

十进制 二进制 十六进制 十进制 二进制 十六进制
0 0000 0 8 1000 8
1 0001 1 9 1001 9
2 0010 2 10 1010 A
3 0011 3 11 1011 B
4 0100 4 12 1100 C
5 0101 5 13 1101 D
6 0101 6 14 1110 E
7 0111 7 15 1111 F

十六进制、十进制、二进制相互转,通过1,2,4,8(权重)组合相加即可快速换算获得。
一个字节可以用2位16进制字符来表示。最基础的ASCII表设计时,也是按16进制划段:0x20对应字符空格,随后是常见符号;0x30对应字符'0',随后数字符号;0x40对应字符'A',随后是大写字母;0x60对应字符'a',随后是小写字母。

3. 布尔逻辑

布尔逻辑相信大家也再熟悉不过了,只有真True(用1表示)、假False(用0表示)两种状态。
其上的常见运算有“与”、“或”、“非”,我们用 &, |, ! 符号表示。
与运算:0&0=0, 0&1=1&0=0, 1&1=1
或运算:0|0=0, 0|1=1|0=1, 1|1=1
非运算:!0=1, !1=0

除了这3个运算,还有其他的衍生运算符,比如异或、与非、或非等。
异或运算,输入相同则为0,不同则为1,正好对应不进位加法
“与非”和“或非”是在“与”、“或”的输出上再取反。

3.1. 等价性

不难发现逻辑运算由“与”、“非”可以得到“或”,由“或”、“非”可以得到“与”:

4. 逻辑电路

在现实世界,我们利用电压的高低表示0和1,用物理电路来实现上述逻辑运算,简称逻辑门
逻辑门的实现大致上可以简单理解为:通过开关的串联、并联、短路方式来实现“与”、“或”、“非”。这里仅是为了方便大家理解,实际过程有差异,有兴趣的同学自行搜索。而晶体管就是基本的电子开关元件,也是芯片的主要组成。

在逻辑门电路中,“与非门”(或者“或非门”)这1个逻辑门是全能的! 因为将两个输入连接在一起就是“非门”,结合上章节的等价性可得。也正因如此,理论上芯片制造时的电路是可以只采用“与非门”来实现!

4.1. 逻辑门符号

“与、或、非”的逻辑门常用的符号如下:

与或非.png

“与非”、“或非”门的图形是在“与”、“或”门的输出段多一个圆孔,表示取反:

与非或非.png

4.2. 三态门(Tri-State)

在实际的电路中,其实除了低电压表示0、高电压表示1之外,还有一种 “高阻态(High Impendance)”,高阻态相当于断开状态。
在础元件中有“三态门(Tri-State)”组件,有输入、控制和输出端,其作用有点像“与门”的开关,区别在于:控制端输入0时,输出和输入是断开的;控制端输入1时,内部导通,输出等于输入。符号如下:

Tri-State_e.png

4.3. 总线元件(Bus)

在逻辑电路的设备连接中,1个设备的输出可以和多个下游设备连接,而任何设备的输入只能接收1个明确信号的输出,否则多个信号可能会冲突,出现错误。
为了能让多个源设备输出到相同的下游设备,需要对这些源设备不同时输出信号进行聚合,在逻辑电路中需要使用Bus元件,输入端可以是2个或者多个,这里Bus的输入端就需要同一时刻只能有一个有数据,其他都处于断开的高阻态(用三态门):

BUS-2.png

二、功能模块构建

根据冯诺依曼的计算机体系,计算机需要包含5大设备:输入、存储、运算、控制、输出。其中输入和输出比较简单,针对其余部分存储、运算、控制,我们将会在后面的章节逐步解锁。

1. 工具选择

为了做逻辑电路的学习和实践,我们需要使用相关软件支撑。经过我的对比,最终选出比较理想的工具 logic.ly (https://logic.ly/demo/),其优点是界面简洁、直观以及美观,布线可调整,最关键的是可以进行逻辑封装,制作和查看各种模块,进而构建复杂系统。同时该软件也支持在浏览器里使用,非常便捷!

该软件种的主要输入是开关,输出是灯泡
以最简单的直连图为例,输出等于输入,白色表示0,蓝色为1(另外灰色表示未连接状态,红色表示错误):

输入输出.gif

在logic.ly中三态门如下,Control端为0,输出线是“灰色”表示断开状态(灯泡也是微红灯丝)。

Tri-State.png

集成封装:工具中可以通过选中完整功能的电路图,通过“集成”按钮可以对模块进行封装,在此之前需要对输入(开关)、输出(灯泡)设置名称,封装后对应模块/芯片的引脚。封装后的模块可以被其他组件直接引用,该模块的名称可以修改,但是内部结构不可变更。

2. 常见结构

这些“与、或、非”逻辑门有什么作用呢?我们先从几个简单的结构说起,作为热身。

2.1. 与门开关

将“与门”的一个输入连接控制信号,另一个输入和输出作为通道。控制信号为0时,输出恒为0;控制信号为1时,输出等于输入。

2.2. 或门汇聚

“或门”常用于将多个信号汇聚(任意一个输入为1时就输出为1)。

2.3. 选择器(Selector)

利用“与、或、非”我们可以构造“选择器”,用于两个数据源时二选一输出,通过“选择开关”进行切换。思路是:1个非门将选择信号分离出一个反向信号,然后通过2个“与门”分别作筛选开关,将二个数据源二选其一,再利用“或门”将两路输出聚合,结构图如下:

select-1.png

当Switch为0是,输出等于A;Switch为1时,输出等于B。
将上图导出为Select-1模块:

select-1_chip.png

输出Q通过选择开关S控制来源:是S=0时,输出Q是从A获取;S=1时从B获取。
然后利用多个Select-1并联成多位的选择器,比如8位选择器封装后图形如下(看起来很大,功能确很简单):

select-8封装.png

3. 存储的基本结构

存储是一切的基础,因此我们首先需要设计具备存储的基础结构或者元件。
一般的简单连接(无环的树形结构)组网下,所有的输出是输入函数,所以是无法实现存储功能的!
因此需要考虑带环路、反馈(输出接到输入)的结构,我们从最简单的情况考虑:
采用1个“或门”的反馈电路如下图,可以接收1信号,存储内容从0变为1,但是一旦变为1后,系统则无法对输入做出反应,储存的内容将会不会改变。演示图如下:

OR回路.gif

“与门”有类似的作用,但是刚好相反,可以接收0信号,存储从1变为0。

3.1. AND-OR锁存器(AND-OR Latch)

从上面的2种回环结构图来看,“或门”可以存储1,“与门”可以存储0,因此考虑将两者结合起来:

AND_OR锁存器.png

通过Set输入1将存储变为1,随后Set变为0也不再影响存储的内容;
通过Reset输入1将存储变为0,随后Reset变为0也不再影响存储的内容。
如此,我们就实现了存储的基本单元,我们称之为锁存器(Latch),上图被称为AND-OR锁存器。

3.2. SR锁存器(SR Latch)

还有另外一种结构更简单、通过两个“或非”门组成的Set-Reset Latch。“或非”门相当于一个多输入的非门,只要有一个输入是1,输出则为0。两个“或非”门相互循环对接,起到了相互取反设置的作用:

SR锁存器.png

通过Set输入1将存储Q变为1;通过Reset将Q变为0;图中Q’和Q始终相反。
从图中可以看出,若在某时刻两个“或非”门的输出相同,且两个元件“完全一致”,则会出现两个元件同步不断取反震荡过程,直到Set或者Reset输入1。在logic.ly工具中,该结构也是多个基础元件的组成,在工具启动时或某些状态下还真会出现这种频闪的不稳定状态,此时需要对元件触发Reset进行重置。而在现实中因为设备不可能会完全一致,所以不会出现震荡的情况,通电后Q是随机的0或1。

3.3. D锁存器(D Latch)

在上述的锁存器基础上稍微扩展,实现对单一Data输入(0或1)的存储。为了控制输入时机,还需要有个Store开关,用于确认存储,此结构称为Data Latch,结构如下:

D-Latch.png

上图的右侧是采用AND-OR锁存器,使用SR锁存器亦可,并且大多应用中主要使用SR结构。此处为了降低logic.ly工具中“频闪”的不稳定概率,我们选择使用AND-OR结构。
左侧一半是一个典型的选择电路:1个非门将输入信号分离出一个反向信号,然后通过2个“与门”分别作为开关,筛选其中之一(另外经常下游还会有个“或门”将两路输出聚合)。
将此结构导出为D Latch模块(2个输入、1个输出)。导出的模块可直接用于其他逻辑电路图中,封装后的模块图形如下:

D-Latch封装.png

3.4. D触发器(D Flip-Flop)

上述的D锁存表面上看可以用于1位二进制的存储,然而在多系统交互中,为了能让多个输入的时机一致且可控,我们需要通过时钟信号0/1震荡的矩形波)统一协调控制。并且在实践中会发现,需要做成二级级联结构以实现“瞬时的读取”:由2个D锁存器串联组成,结构图如下,当外部时钟信号Clock为0时,Data信号立即存储在第1个锁存器中,并作为第2个锁存器的输入,当时钟信号变为1时,第2个锁存器将锁存器1中存储的数据也存储在自身中,此结构称为D触发器(Data Flip-Flop),为了能快速清空2个锁存器中内容,我们加入了Reset输入(首次看图理解时1个与门、2个或门是导通的,可以忽略),逻辑电路图如下:

Flip-Flop.png

D触发器存储过程需要一个时钟信号从0变为1触发,“触发器”因此得名。时钟信号改变时锁存器1中的内容是稳定的,最终两个锁存器中内容一致,且都是时钟信号即将改变那个时刻的Data值。
将此结构导出为D Flip-Flop模块,D为数据输入,>表示时钟信号,CLR表示重置清空。

D-Flip-Flop封装1.png

后续我们将会发现,Clock、Clear是许多模块的基础输入,除此之外还会有“允许输入Enable”、“允许输出Output Control”等。
在logic.ly中已经有了现成的D Flip-Flop模块,自带模块比我们创建的多出一个PRE’,用来重置Q’的,给恒定输入1即可。另外该组件的清除信号CLR’是Clear的反,使用时需要注意。在后续构建中可以选用自己构建的,或者工具自带的都可以。

D-Flip-Flop自带.png

3.5. T触发器(T Flip-Flop)

和D触发器类似的,我们可以设计一个根据时钟信号实现存储的内容0~1来回切换的元件,效果和生活中常见的“按钮开关”一样:按一次开,再按一次关闭。
在D触发器基础上调整,将数据输入D取消,增加允许切换开关EN:EN为1时,时钟信号触发动作,将输出的反Q’作为输入传入,从而实现内容0~1切换;EN为0时将Q作为输入,从而实现存储内容不变。这里不再绘制图像,大家可以自行尝试绘制,logic.ly工具中提供现成的模块(其中PRE’也是用来预置内容,不使用时给恒定1输入即可):

T-Flip-Flop原生.png

T触发器仅在时钟信号从0变为1时触发存储的Q内容将取反(Q和Q’互换)。

4. 寄存器(Register)

学过汇编语言的同学知道,中央处理器(CPU)中的基本单元就是寄存器就是存储固定位数二进制数的元件),整个CPU的运行都是依托于寄存器的操作,因此我们首先要构造寄存器。
上述的D Flip-Flop是具有“1位”存储功能的,通过并联4(或8)个即可做成4(或8)位寄存器。为了能够对其写入时机进行控制,我们还需要对寄存器增加“允许输入”开关。
我们使用上面封装的D Flip-Flop来构建1位寄存器,输入端我们增加允许输入Enable开关,采用典型的选择电路,控制后端D触发器的输入来来源:是在外部输入Data或者自身原本的结果(保持不变)。结构图如下:

Register-1N.png

将此封装为Register-1模块,然后通过4个Register-1并联并封装为Register-4:

Register-4.png

类似的,用2个Register-4并联封装为Register-8(这样比直接从8个Register-1组合连线数量要少)。
封装后模块如下图:布局中左侧8根输入,右侧8根输出,下侧3个控制输入(允许写入、时钟信号、重置清空)。

Register-8f封装.png

至此,我们构建了自己的8位寄存器,然后可以利用开关作输入,灯泡或者4-Bit Digit(显示0~F)作为输出,检测模块正确性。

5. 运算模块

有了寄存器我们就可以对数据进行的保存,下面开始考虑构建最基础的运算设备“加法器”。
现在想象一下二进制的加法过程,和我们十进制一样,从低位到高位逐个相加,因此需要实现2个二进制位加法,同时除第一位外,还需要考虑来自低位的进位,也就是3个二进制位相加,输出2位(当前位和进位)。

5.1. 半加器(Half Adder)

我们先来考虑第一位的相加,也就是2个输入,2个输出,只有如下4中情况:
0+0=0, 0+1=1, 1+0=1, 1+1=10
看结果的最低位,刚好和异或运算结果相同;而第二位上,当且仅当输入都是1时才为1,因此可以用如下逻辑电路实现2个1位二进制数的加法,该图被成为半加器,别被名字吓唬住了,其实就是实现的功能很简单:

Half_Adder.png

5.2. 全加器(Full Adder)

那对于第二以及以上的位置上相加,除了计算当前位置2个数相加,还需要再加上低位上的进位。
仔细想象这个过程,其实就是通过了2次上面的半加过程,就是将半加中的Q0和进位值再相加,新的进位和Q1取并即可(因为最大值就是1+1+1=11),这被成为全加器,逻辑电路图为:

Full_Adder1.png

其中HADD是半加器,便于理解。由于HADD也不复杂,直接用原始元件构建亦可。
将此全加器导出为ADD模块(3个输入,2个输出)。

5.3. 算术逻辑单元(ALU)

我们通过多个全加器串接,即实现多位的算术逻辑单元ALU(Arithmetic&logical Unit),比如4位的ALU-4构建图如下:

ADD-4.png

这里注意第一位我们并没有使用半加器,而是采用了全加器(多出了一个外部输入C0),一方面我们可以使得ALU-4可以进一步级联变成高位的累加器;另一方面,最低位的C0结合额外的1个输入可以直接实现减法运算!构建图如下:

ALU-8.png

当SUB标记为0时,B和0进行异或运算保持不变,2组ALU的输出是A+B;当SUB标记为1时,一排异或是对B进行了取反操作,同时又在最低C0上输入1,也就是对B取反后又加了1,这正好是-B的补码(原理见基础知识章节),ALU的输出结果就是A-B,非常的巧妙!对ALU-8封装后的模块如下(左侧为A,下侧为B以及SUB标记,右侧是A+/-B的结果输出,C8是溢出位):

ALU-8封装.png

5.4. 简单应用

有了上面的寄存器,可以用来存变量,又有了算术逻辑单元,那就可以用来组合有趣的逻辑电路了:取1个Register-4寄存器(考虑手工连线的数量),4个开关输入,分别记作A和B,将他们对接到ALU-4上,然后将ALU的输出再接到A的输入,给B赋值常数(比如1),通过数字显像管连接ALU的输出查看数值,通过工具自带的时钟驱动设备运行,即做成了一个循环计数器:

ACL-4样例.png

给B赋值=0xF=15时,可以起到循环递减的效果。

6. 内存模块(RAM)

上面的D触发器(D Flip-Flop)也可以作为内存的基本单元(解决方案不唯一),将D触发器横横竖竖排列方阵,通过输入的地址(解析器为方阵中坐标X行、Y列)对应到其中的1个单元,然后进行写入、读取操作。另外,为了能持续的集群扩展,还需要增加一个选择CS(通过“与门”作为筛选开关):CS为1时,此块被选中;CS位0时,此内存块未被选择。

内存模块中涉及到多个存储输出聚合,因此可以在基本存储单元加上“允许输出”控制,和“允许输入”略有不同,不允许输出时需要断开(高阻态),因此我们通过1位寄存器基础上增加个1个“三态门Tri-State”来实现,构图如下:

UNIT-1高级封装.png

将此封装为UNIT-1模块,将成为内存基本单元。

6.1. 地址解析(Address Decoder)

以4位地址为例(A0 ~ A3),需要映射位16选1的信号,首先将4位输入拉分支并取反,然后根据0~15的的二进制分别选取原输入或者反值,然后通过4输入的与门(所有输入都为1时才输出为1)判定。输入二进制数A,16个输出中仅有对应编号1个输出为1,其他15个为0,起到选择作用。比如10对应,那它被选择的条件就是A3 & !A2 & A1 & !A0。构图如下:

AddressDecoder.png

对于一个8位的地址的内存块,对应X、Y分别是4地址,也就是对应16*16=256字节。

6.2. 内存构建

本次实践,为了方便我们先实现4位地址(Y=1),也就是16字节的内存。
使用上面地址解析器以及上面基础单元,使用“与门”将选择的行与“允许输入”、“允许输出”连接,达到选择、控制单元的作用。每个存储单元的输出需要用Bus元件汇聚。
16个“1位”内存构图如下:

RAM-1-16N.png

注意:假若将允许输出放在Bus之后,会导致不允许输出时输出为0,因为:0 & 高阻态 = 0,这样达不到输出高阻的预期。
将上述模块封装为RAM 1-16(存储1位16个)。再使用8组RAM-1-16并联即可构成16字节容量的内存。

RAM-16N.png

将此封装为RAM 8-16,模块如下:

RAM-8-16封装.png

其中上侧输入A是内存地址,左侧D是数据输入,右侧Q为输出,下侧是选片CS、允许写入EN、>时钟、允许输出OC和CLR清空。在地址及数据引脚中,编号小的对应低地址。

7. 计数器(Counter)

有了内存设备,我们可以用于存储程序,而程序的自动执行,不可避免的需要使用计数器组件,因此我们需要构建二进制计数器。
考虑之前的T触发器,2个时钟周期时间触发2次存储变化,存储的值又变回原值,也就是说T触发器的输出Q’(或者Q)变化周期是原时钟周期的2倍,利用这个特性我们可以用来设计二进制计数器:将多个T触发器串联,每个触发器的输出Q’作为下一个触发器的时钟信号输入,利用周期是2倍的特性(权重)来实现二进制计数!

计数器.png

在允许计数Enable=1时(4个触发器的T输入都是1),时钟信号将触发计数器内所表达的二进制数增加1;Enable=0时,存储内容保持不变。

7.1. 计数器增强

上述的计数器只能自增或被重置清0,而为了更加的灵活的控制和使用,我们可以利用T触发器的PRE’/CLR’对其存储值进行直接修改,构件图如下:

ProgramCounter.png

由于基础元件T Flip-Flop中预置和清理都是反信号PRE’和CLR’,因此上图种用到了几个“与非”门,理解起来有些绕,实际原理并不复杂。
实现的功能就是:对于正常状态(Jump=0),允许计数Count Enable=1时,每个时钟周期,内部存储的值增加1;跳转模式(Jump=1)时,时钟信号触发外部输入数据直接存储。

8. 总线(Bus)

有了上面的基础元件(寄存器、运算器、内存)即可构建复杂组网,而这些元件之间的数据传输,两两连接不现实,因此我们通过“公共的导线”进行连接,我们称之为“总线”。

多个组件都的输出和输入都和“总线”相连,用于相互的数据传输。多设备可以从总线上获取数据,这通过直接建立多连接即可(总线的输出和需要的设备输入连接);多设备的输出也要通过总线(输出到总线上)传输给其他设备。

因为总线是多个设备公用的,因此同一时间只能有一个设备允许输出,其余的需要断开输出,因此每个输出连接在总线上的设备都要进行“允许输出”控制。

在现实的物理电路中,多个设备和总线直接导线相连即可,而在逻辑电路中多个设备输出不能直接相连,需要使用Bus元件。使用Bus元件将多个设备的输出进行聚合,有一样的约束“同一时间上游设备只能有1个设备有输出,其他设备处于断开(高阻态)”,否则将可能出现信号冲突错误。

8.1 缓冲器(Buffer)

对于上述我们创建的内存,因为已经有了“允许输出”控制,因此可以直接连接Bus元件,而对于没有输出控制的组件,就需要控制输出的组件,简称缓冲器(Buffer),可以通过多个三态门并联组成,4位的缓冲器结构如下:

Buffer4.png

8位也类似的,然后导出为Buffer模块,其中D为输入,Q为输出,EN为通断控制:

Buffer4封装.png

9. 总体结构

有了上面的基础组件,即可用于自动机的设备构建。想要设计一个计算机,此处涉及一些可计算理论中的自动机模型,限于篇幅不做深入的讨论,这里只做最简结构的介绍和实现。

一个简单的自动机模型是:首先有一个长长的纸带(对应这里的内存),然后有个读写头,可以的操作就是:移动读写头、读取、计算、写入操作。

9.1. 组成部分

从最简化角度考虑,构造一个自动机,需要的必要组件如下:

  1. 为了缓存当前操作数,最少需要2个通用寄存器,记作A、B;
  2. 一个逻辑运算单元ALU(arithmetic&logical unit)用于计算;
  3. 为了存储程序指令、计算中间结果等,需要内存RAM(random access memory);
  4. 为了访问内存数据,需要一个内存地址寄存器MAR(memory address register);
  5. 为了记录程序执行到第几行,需要一个程序计数器PC(program counter register);
  6. 用于记录控制指令,需要一个指令寄存器IR(instruction register);
  7. 将上述各组件连接在一起的总线(Bus);
  8. 输出设备:输出寄存器OUT;
  9. 输入设备:按钮若干,用于直接对内存进行写入进行编程。

其中指令可以设计为:指令代码+参数的形式,也可以称为:操作码+操作数,参数/操作数是可选的。
根据上述的组件的构成可以值知道各类型元件的控制信号有:

  1. 寄存器:允许输入、输出控制。
  2. 逻辑运算单元:运算切换(做减法)、结果输出、进位判断。
  3. 计数器(增强):允许计数、输出、数据输入(跳转)控制。

所有组件的上述控制,将会在1个时钟信号(从0变成1)时触发。

9.2. 架构图

结合前述组件的功能,将各组件进行连接,组成最简单的计算机架构图如下:

最简计算机架构1.png

其中:“蓝色”为通过总线传输数据,“绿色”为线路直连,“橙色”为各组件的控制信号。

图中各组件的控制信号含义:

  1. MI:内存地址寄存器MAR输入
  2. RI:内存RAM输入
  3. RO:内存RAM输出
  4. II:指令寄存器IR输入
  5. IO:指令寄存器IR输出
  6. CO:程序计数器PC输出
  7. CE:程序计数器PC计数
  8. J:程序计数器PC输入(跳转)
  9. AI:寄存器A允许输入
  10. AO:寄存器A输出
  11. BI:寄存器B输入
  12. BO:寄存器B输出
  13. SO:逻辑运算单元ALU结果输出
  14. SUB:逻辑运算单元ALU切换为减法
  15. CY:逻辑运算单元ALU结果发生进位
  16. OI:输出寄存器OUT输入

鉴于我们当前最小化的设计,内存是16字节,因此地址相关传输是4位的,涉及组件PC、MAR。
指令寄存器IR是8位,高4位对应指令代码,低4位为参数,参数常表示地址。
4位数据传输在图中是是窄箭头宽箭头表示8位的数据传输。

10. 逻辑控制器

上面的架构图仅是实现了各组件的连接,而为了让系统能够自动执行,还需要设备能按照我们设置的指令列表(也就是程序)进行逐条获取、解析、执行,程序通过内存模块存储,解析和执行就需要“逻辑控制器”来实现。

自动机的操作通过“指令”执行来完成,指令的执行又可以进一步分解为一系列的控制动作:从哪个组件输出就打开对应组件的允许输出,传输到哪个组件就打开对应组件的允许输入。为了对细化步骤控制,我们还需要引入一个步骤计数器(Step Counter),每个指令的每一步都对应特定的控制信号,这就是逻辑控制器实现的功能是:将“指令代码”+“指令步骤”映射为“控制信号”。

10.1. 指令分析

我们从最简单的应用开始,比如我们需要计算两个数x、y的和,然后通过输出,我们需要的作用有:

  1. 将x加载到寄存器A
  2. 将y加载到寄存器B
  3. 将ALU计算A+B的结果输出到A
  4. 将寄存器A中的内存输出到OUT寄存器用于显示

因此我们可以如此设计指令:

  1. LDA指令:加载内存某地址中数据到寄存器A,有1个地址参数,指令编号定为0x1;
  2. ADD指令:加载内存某地址中数据到寄存器B,有1个地址参数,并将ALU的输出(A+B)的结果输出给寄存器A,指令编号定位0x2;
  3. OUT指令:将寄存器A中的内容输出到OUT寄存器,无参数,指令编号定为0xe(0xf用于停机HLT)

下面我们会具体看每个指令、步骤对应的控制信号。因为程序是存储在内存中,因此首先要获取和解析要执行的内容,这也是所有指令的公共步骤,因此对于所有指令指令步骤中的前2步相同:

步骤 内容
T0 将程序计数器PC输出到内存地址寄存器MAR。
(初始时PC时0,也就是指向RAM的0行)
T1 将RAM内容(第一句代码)输出指令寄存器II中;
同时打开PC允许计数,用于下个时钟周期递增1

对于LDA指令(指令代码为0x1)来说:

步骤 内容
T2 将指令寄存器中的参数(低4位,也就是x的地址)输出到内存地址寄存器MAR
T3 将RAM中的内容(x)输出到寄存器A

对于ADD指令(指令代码为0x2)来说:

步骤 内容
T2 将指令寄存器中的参数(低4位,也就是y的地址)输出到内存地址寄存器MAR
T3 将RAM中的内容(y)输出到寄存器B
T4 将ALU的结果(A+B)输出到寄存器A

对于OUT指令(指令代码为0xe)来说:

步骤 内容
T3 将寄存器A输出到OUT寄存器;

整理成指令控制表为:

指令 指令代码 指令步骤 控制信号 数据流转
Fetch xxxx 000 CO,MI PC->MAR
Fetch xxxx 001 RO,II,CE RAM->IR
PC下个时钟周期加1
LDA 0001 010 IO,MI IR(参数部分)->MAR
LDA 0001 011 RO,AI RAM->A
ADD 0010 010 IO,MI IR(参数部分)->MAR
ADD 0010 011 RO,BI RAM->B
ADD 0010 100 SO,AO ALU->A
OUT 1110 010 AO,OI A->OUT

有了这个控制表,即可对逻辑寄存器进行实现了,输入有:指令,指令步骤,输出为所有的控制信号:

  1. 通过地址解析器接入解析指令,获得0~15的编号
  2. 通过地址解析器解析指令步骤,获得0~4的编号
  3. 将上面2个输入利用与门连接识别(映射),然后输出作为三态门的控制信号(因为需要多个信号使用Bus聚合),将三态门的输出连接到对应的控制即可。

10.2. 条件跳转

程序的执行需要分支判定或者循环,也就是对于自动机来说需要实现“跳转”功能,无条件跳转比较简单,直接将新地址赋值给PC寄存器即可;而有条件跳转就还需要外部的状态识别,这就需要我们引入“状态寄存器(Flag Register)”,用来存储外部状态标记。

这里我们先实现2个最基础的:

标记 含义
ZF(Zero Flag) A寄存器是否为0
CF(Carry Flag) ALU是否触发了进位

将各个标记增加到逻辑控制器的输入中,即可用于条件跳转指令。

10.3. 指令集设计

至此,我们可以自主设计指令集了!这里我们一条指令用1个字节,其中高4位为指令代码(最多16个),低4位为参数(常表示内存地址)。

指令设计如下表:

指令符号 指令代码 功能说明 格式说明
NOP 0x0 无操作,读取下一条指令 NOP无参数,补齐1字节
LDA 0x1 从内存指定地址读取1字节到寄存器A LDA addr(4位地址)
ADD 0x2 从内存指定地址读取1字节到寄存器B,
通过ALU计算A+B,并将结果赋值到寄存器A中
ADD addr(4位地址);此步骤可能触发ZF,CF标志
SUB 0x3 从内存指定地址读取1字节到寄存器B,
通过ALU计算A-B,并将结果赋值到寄存器A中
SUB addr(4位地址);此步骤可能触发ZF,CF标志
STA 0x4 将寄存器A中内容保存到指定地址 STA addr(4位地址)
LDI 0x5 加载4位的立即数value到寄存器A LDI value(4位立即数)
JMP 0x6 无条件跳转至指定地址 JMP addr(4位地址)
JZ 0x7 零值标志ZF为1时触发跳转到指定地址,否则继续执行下一条指令 JZ addr(4位地址)
JC 0x8 进位标志CF为1时触发跳转到指定地址,否则继续执行下一条指令 JC addr(4位地址)
... ... 其他为预留,无操作,读取下一条指令
OUT 0xe 将寄存器A中内容输出到输出设备,用于显示 OUT无参数,补齐1字节
HLT 0xf 停止设备的控制时钟,从而停止运行 HLT无参数,补齐1字节

根据上述指令代码表,扩展之前的指令步骤控制表,然后即可对逻辑控制器进行调整,最终构建图如下:

LogicCtrl.png

左侧是指令代码解析,左上步骤计数器,上部是控制信号。部分未连接的部分是预留功能。

中间组网从上到下是各个指令,从右向左是T0 ~ T4各个指令步骤。其中个别步骤多个指令都会出现,通过多输入“或门”直接复用,比如指令代码1 ~ 4的T2步骤,都是加载参数(操作数地址)到内存地址寄存器Instruction->MAR。
将上图导出成逻辑控制器模块:

LogicCtrl封装.png

其中下面数输入:OP为指令代码,S为指令步骤,*F为状态标志;上方一排为各控制信号输出。

10.4. 循环计数器

逻辑控制器的工作,需要额外的指令步骤计数器组件配合,鉴于当前的我们设计的指令最复杂的是5步,指令步骤只需要0~4循环即可。
循环计数通过计数器+地址解析器实现:在计数器输出5时反过来对计数器进行重置即可。

StepCounter.png

上图中Q是二进制计数,而最右侧是T0 ~ T4的指示灯。

逻辑控制器的控制信号输出需要早于其他组件的时钟信号,因此我们需要将“步骤计数器”的时钟比“执行动作”的时钟信号提前半个时钟周期,通过在全局时钟信号上增加一个非门取反作为步骤计数器的时钟即可实现!

三、组装自动机

按照前面的架构图将各组件进行连接,左下角添加步骤计数器和逻辑控制器,逻辑控制的输出和各组件控制输入相连,然后再根据需要做了些轻微的调整:

  1. 左上角增加时钟信号作为整体驱动。通过1个选择器用于“自动执行”(Start开关)或“手动单步执行”(Step按钮)切换;
  2. 停机HLT信号通过非门、与门与时钟输入相连用于控制运行;
  3. 在RAM的左侧增加“地址、数据输入”,通过“选择器”和原电路相连。Switch引脚和“模式开关(Switch Mode)”对接,控制系统“编程(Program)”还是“运行(Run)”模式。“编程”模式直接通过手工输入代码到内存;输入完毕后,切换到“运行”模式自动执行。
  4. 在总线Bus、内存地址MAR、指令寄存器高4位分别添加“数字显示器”用于查看实时的“总线传输”、“内存地址”、“指令代码”内容。
  5. 内存的Clear按钮和其他组件重置按钮Reset是分开的,防止在重置时录入的代码被清空。

1. 成品展示

整理线路后效果图如下,这就是我们可以编程、自动运行的自动机!

cmp2.png

除去内存的限制,深入的分析该自动机、指令集,其实它是“图灵完备”的,因此也可以称之为“计算机”!

1.1. 使用说明

  1. 打开工程文件后,需要执行logic.ly界面左下角的“Reset Simulation”,然后再“Start Simulation”,便于清空逻辑控制器中的元件状态。
  2. 在执行程序前单机自动机左下角的“Reset”按钮对系统中各组件清空(内存不会被清空)。
  3. 想要编程前通过单击一次左上角“Step”按钮使得步骤指示灯处于第2个灯(T1)上,此时总线上是RAM的内容,便于查看。
  4. 通过“Switch Mode”开关将系统切换“编程”模式。
  5. 编程模式下,在“Set Address”关闭时,地址可以通过点Next按钮自动加1,便于逐行输入代码;打开Set Address开关,可以直接手动输出地址,通过Goto按钮(和Next是同一个按钮)直接跳转到目标地址。
  6. 通过Data按钮录入代码,点击Store存储到RAM当前地址中。
  7. 代码输入完毕后,切换系统为“Run”模式,即可通过Step单步执行,或者Start自动执行,任何时候都可以随时切换。

1.2. 程序编写

想要使用系统,需要先将您的问题编写程序,并根据前面的指令代码表转成对应的二进制。
根据1.1中的说明将程序录入RAM中……程序代码是从RAM中0行开始的,因此高地址段可以用于存储变量。

2. 应用样例

样例1. 加减混合运算

比如我们要计算x+y-z,先将3个数字转成二进制(我们都用十六进制展示,和二进制也可以口算转换),然后分别存储内存的f、e、d地址,比如128+76-88,伪代码如下:

地址 伪代码 机器码 解释
0 LDA f 1f 加载内存地址f中内容到寄存器A
1 ADD e 2e 将内容地址为e中内容累加到A
2 SUB d 3d 将A减去内存地址为d中内容
3 OUT e0 输出寄存器A中结果
4 HLT f0 停机
...
d 88 58 z
e 76 4c y
f 128 80 x

预期结果:128+76-88=116=0x74,执行结果如下:

sample1.png

修改f、e、d中的值,然后点击Reset按钮,可以重新执行新的计算f+e-d的结果。

样例2. 计算2个数乘法

计算2个数x和y的乘法,将x和y分别存入f、e,我们用循环来实现,使用d累加器,每次累加y,循环x次。因此d初始要赋值0;给c赋值常量1,用于f的递减循环次数控制。比如要计算3*76,则代码如下:

地址 伪代码 机器码 解释
0 LDA f 1f 加载内存地址f中内容到寄存器A
1 SUB c 3c A减少1
2 JC 6 86 若A减1之前不为0,都会触发进位跳转
3 LDA d 1d 加载d中结果,用于输出
4 OUT e0 输出结果
5 HLT f0 停机
6 STA f 4f 将f-1保存回f
7 LDA d 1d 加载中间结果d
8 ADD e 2e 将e累加到d
9 STA d 4d 将d+e更新回d
a JMP 0 60 跳转会程序开始,进行下一个循环
...
c 1 01 常量c
d 0 00 累加中间值d,初始是0,中间过程是y的倍数
e 76 4c 存储y
f 3 03 存储x,执行过程会被递减修改

上述代码中地址为2的代码利用了溢出标记来识别减1之前寄存器A是否0,因为ALU做减1运算,相当于加0xff,当且仅当计算前A为0才不会触发进位
预期结果:3*76=228=0xe4,执行结果如下:

sample2.png

图中数字显示风格和之前不同是软件版本所致,此为2.0beta版本风格。

样例3. 斐波那契数列

斐波那契数列,又称兔子序列,前两项为0、1,从第3项开始数值等于前2项值和。
因此序列如下:0,1,1,2,3,5,8,13,21,34,89,144,233,……
编程实现该序列的动态展示,直到溢出(超过255)为止。

程序设计思路:通过2个地址e和f存储初始的0和1,计算e+f的和,并将结果更新到较早的地址,循环执行即可。

地址 伪代码 机器码 解释
0 LDA f 1f 加载内存地址f中内容到寄存器A
1 ADD e 2e 将内存地址e中内存累加到寄存器A
2 JC a 8a 对上面ADD指令做溢出判断,如果溢出则退出循环
3 OUT e0 输出当前的和(寄存器A)到显示设备
4 STA e 4e 将当前的和保存在地址e中
5 ADD f 2f 将内存地址为f
6 JC a 8a 对上面ADD指令做溢出判断,如果溢出则退出循环
7 OUT e0 输出当前的和(寄存器A)到显示设备
8 STA f 4f 将当前的和保存在地址f中
9 JMP 1 61 跳转到程序1行,进行下一个循环
a HLT f0 停机
...
e 0 00 初始第1项
f 1 01 初始第2项

预期结果:每一轮循环OUT端输出2次中间结果,最后第一个显示的数值是233=0xe9,实际运行效果一致:

sample3.png

结束语

上面的自动机,经过扩展位数、增加指令集、增加专用计算模块等,以及进一步的高层架构设计即可逐步拓展为强大的计算机,而现在我们也知道了其内部核心是只由“与非门”一个元件构成的,大道至简

众多简单的逻辑门通过不同的组网结构实现各种的功能:存储(记忆)、运算、逻辑控制等,其中回环网状的连接形成了记忆,树形的组网可以组成复杂的逻辑运算功能。

很自然的让我们联想到人或动物的,其内部也是主要由无数的神经元组成!每个神经元的功能也是相对是简单的,通过树突接收信号,神经元简单的判定然后决定是否将信号继续向下传递,通过众多神经元的组网连接,形成了记忆、逻辑思维、各种特定功能区等等,这二者是多么的相似!

也让我突然想起关于人类大脑的一个常识:儿童的记忆很好,随着年龄的增长,记忆会减弱,但逻辑思维会变强。儿童大脑的神经元网络交织繁多,像一张白纸,对应着记忆好,富有天马行空的想象力。随着年龄的增长,这种网状结构逐渐被“学习”的过程“雕琢”,形成相对稳定、更加高效的逻辑思维功能区……也和计算机的模型吻合。

当然这种相似只是的笼统的类比,比如在“存储/记忆”方面,并不是只有回环结构才可以,比如通过“电容”等类似有状态的组件亦可以实现,脑的真正的机制还需要科学研究去探索。

其他方面,比如脑的学习、认知的原理是什么,和当今热门的人工智能、机器学习是否也有相通之处呢?这些关于脑机制的都是我感兴趣的方面,有机会再单独撰文写下我的感悟。不管如何说,持续学习打开思路,让我们向着追寻的方向,更进一步!

文章撰写仓促,并受限于个人水平,未表达清楚或者表述不准确之处,尽请谅解!

你可能感兴趣的:(从0开始构建计算机)