如何从零开始造一台计算机

加法计算是计算机唯一要做的工作。

code_book

最近在读 Charles Petzold 的《编码——隐匿在计算机软硬件背后的语言》,英文书名是《Code:The Hidden Language of Computer Hardware and Software》。作者的文笔可谓是轻松风趣,第一页那句“众所周知,手电筒是为了让孩子们能够躲在被子下看书而发明的”就让我产生极大兴趣。读来发觉这本书实际上就是从最基本的电学开始讲起,一步步地揭开一台原始的计算机是如何诞生的这一神秘面纱。同时这本书也帮助我补充了不少数字电路和组成原理的知识,也算是补补基础了。下面是一些笔记。

编码与组合

《美国传统英语词典》对编码的其中一个定义是:由被赋予了一定主观意义(arbitrary meanings)的符号、字母以及单词所组成的系统,可用于传输需要 保密或简短 的信息。本质上,编码就是交流。从计算机的角度看,编码是一种人与机器传递信息的方式。莫尔斯电码就是一种经典的编码,用点(dot)和划(dash)的组合来对应不同的字母。例如,求救信号SOS就是一个易于记忆的莫尔斯电码(··· — ···)。

事实上,两个不同的事物,只要经过适当的组合,就可以表示所有类型的信息。而组合的长度,就决定了码字的数目。例如,长度为 1 的组合只能表示 2 种码字(单个点或者单个划),长度为 2 的组合能表示 4 种码字(点点、点划、划点、划划)。实践中我们发现,点和划的数目跟码字的数目的函数关系为 2 的幂次方,如下表:

点和划的数目 码字的数目
1 2
2 4 (2^2)
3 8 (2^3)
4 16 (2^4)
5 32 (2^5)
6 64(2^6)
n 2^n

优先码

路易斯·布莱叶(Louis
Braille)是19世纪法国的一个盲人,他改良的盲文实际上是一种6位的二进制码(6个点),从上表可以知道,6个点可以有64种可能的编排方式。布莱叶盲文中有很大一部分码字会根据上下文的不同有着双重身份,例如,某一个位置的点点上了,就代表数字标识状态,不点上,就切换回字母。这跟我们键盘的 Caps Lock 键很像,按下 Caps Lock 键,输入的就是大写字母,否则是小写。像这样的编码通常称作优先码(precedence codes) 或 换挡码(shift codes)

进制

在我们熟悉的十进制中,数字3572表示2个1 + 7个10 + 5个100 + 3个1000。而在八进制中数字3572表示2个1 + 7个8 + 5个64 + 3个512 。百位是个位和十位的乘的结果。在二进制中,位数从右到左,依次为1的个数,2的个数,4的个数,8的个数……例如,1101表示1个1 + 0个2 + 1个4 + 1个8,结果为十进制的13。之所以在计算机中要用二进制,是从电报机或者灯泡中得到的启示——开/关即可传递信息,再用它们的组合来编码。

电报机和继电器

在线路的一端采取一些措施,使线路的另一端发生某种变化,能够实现这种功能的机器就是一个简易的电报机。电磁铁是电报机的基础,在一根铁棒上面用细导线绕几百圈,然后给导线通电,这根铁棒就成了电磁铁。用一个开关和电池来给导线通电,导线一直铺设到远处,一旦通电,远处的铁棒就有了磁性,磁力把旁边的活动横杠拉动,发出“滴”的声音。这样的设备,就是一个电报机。

我们知道,导体越长,电阻越大。如果电报机两端的距离很远,电阻很大以至于电池不足以提供足够的电流。这时候就要用到继电器了,继电器用传进来的电流去驱动电磁铁,拉动金属杠,金属杠又作为一个新的开关,连接了新的电池和导线,传递到另一个远处。这样,我们就可以用多个继电器来延长电报机的传输距离。

code_jdq

继电器就是这样一种用于放大微弱信号来生成强信号的机器。继电器是需要供电的

布尔代数和逻辑表达式

布尔代数是19世纪英格兰数学家乔治·布尔提出的数学理论。传统的代数,操作数代表数字,算子(+、-、×、÷)表示如何运算,而在布尔代数的世界里,操作数是类(class),一个类就是一个事物的群体,也被称为集(set)。“+”表示并集(∪),“×”表示交集(∩)。如果用F表示雌猫,用W表示白猫,那么 F×W 表示白色的雌猫。为了方便,我们用大写英文 OR 来代替“+”,用 AND 代替“×”,用 NOT 代替 “1-”(从全集中去掉某些元素)。

如果我们用F表示雌猫,M表示雄猫,W表示白猫,B表示黑猫。现在我们想得到白色的雌猫,在备选项F、M、W、B的一些表达式中,F和W可以用 1 表示,而其他选项为 0 。这种表示大大简化了表达式。

有了布尔代数和逻辑表达式及其简化表示之后,我们就可以用灯泡、导线、开关来制造我们的AND、OR、NOT计算器了。这其实就是计算机的原型。

逻辑门

我们可以给由灯泡、导线、开关组成的电路加多几个支路和开关,以实现更加复杂的功能。比如,闭合两个开关灯泡才会亮的 AND 逻辑电路,闭合两个中的任意一个开关灯泡就会亮的 OR 逻辑电路,以及不闭合开关灯泡才会亮的 NOT 电路。

但是要实现更加复杂的功能,就得用到继电器了。继电器可以作为一个电流控制而非人工控制的开关,我们用不同的接法连接继电器,最终就能实现不同的效果。比如我们串联两个继电器,继电器A的电源开关打开,继电器B的电源开关也打开,然后在起始段通电,这样,电流从起始端到继电器A,再到继电器B,最后到灯泡,导致灯泡亮。而灯泡亮的必要条件是继电器A和继电器B的开关都打开,实际上这就是一个 AND 逻辑电路。

同样,我们可以换一种接法,实现 OR 或 NOT 逻辑电路。再把简单逻辑电路组合成复杂逻辑电路,实现各种各样的功能。

在数字电路中,有与门、或门、与非门、或非门四种逻辑门和反向器(NOT)。

  • 与门:两个输入端,一个输出端,当两个输入都闭合时,才满足输出
  • 或门:两个输入端,一个输出端,当任意一个输入闭合时,就满足输出
  • 与非门:相当于与门 + 反向器
  • 或非门:相当于或门 + 反向器

二进制加法器

如果想搭建一台计算机,首先要造出可以计算两数之和的器件。事实上,加法计算是计算机唯一要做的工作。只要我们实现了加法,就可以用加法来实现减法、乘法、除法等等

二进制数相加,当遇到1+1=10时,结果10的1表示进位位,10的0表示加法位。我们可以用与门来实现进位位的表示。将一个与非门和一个或门连接到共同的输入,再加一个与门,就可以用来实现加法位的表示,这种接法叫做异或门 XOR(奇数个1为1,偶数个1为0)。

现在,与门的结果表示进位位,异或门的结果表示加法位,我们将这两个逻辑门接入共同的输入。这就是一个半加器(Half Adder)。半加器的缺陷是,只能实现1位的二进制数相加,多于1位进位位并不会向左累积。

事实上,二进制数相加,是三个位的相加,输入A、输入B和进位输入。用两个半加器和一个或门就能实现,这种组合称为全加器(Full Adder)。把8个全加器串联起来,就能实现8位二进制数的加法了。最低有效位的一对数字相加所得出的进位输出,作为下一对数字的进位位,以此类推。

回归本质,全加器->半加器->逻辑门->继电器,这一切只不过是许多继电器的组合而已!尽管现在的计算机已经用晶体管代替继电器,但原理都是一样的。

如何实现减法

在十进制中,999-176=823,我们把 823 叫做 176 的补数,计算对9的补数不需要借位。但如果是 253-176=77 呢?个位 3-6 需要向十位借位,不方便机器计算,为了消灭借位,我们可以把上式改为 253 + (999 - 176) + 1 - 1000。但如果是 176-253=-77呢?改造后的式子176 + (999-256) = 922,922-999依然需要借位,但是我们有一个技巧,因为我们已经知道结果为负,直接把被减数和减数调换,999-922=77,再取负,得-77。简而言之,就是被减数与减数的补数之和,跟999相减。这种方法在二进制中非常好用,因为 在二进制中,求对1补数只需要将0变成1,将1变成0即可

在二进制中,有没有什么办法来表示负数呢?假如我们有一张银行卡,银行允许你透支500元,账户最多储存不超过500元,那涉及的数字是从-500到+499,在三位十进制中,我们的数字是从000-999,000-499的部分用来表示余额,那透支-500到-1部分,为什么不可以用正数来表示呢?也就是,用500表示-500,用501表示-499…用998表示-2,用999表示-1。这就构成了一个循环。

在二进制中,对2的补数表示如下:

二进制 十进制
1000 0000 -128
1000 0001 -127
1000 0010 -126
1000 0011 -125
1111 1101 -3
1111 1110 -2
1111 1111 -1
0000 0000 0
0000 0001 1
0111 1110 126
0111 1111 127

可以发现,最高位实际上就是符号位(sign bit),1表示负数,0表示正数。计算对2的补数,先计算对1的补数,再加1。这就是我们熟悉的逐位取反再加一

所以,计算124-127=-3,实际上只需要将-127和124等价的两个二进制数相加。-127用补码表示为10000001,124用补码表示为01111100,相加得11111101,对应上表就是-3。

补码的本质,就是用 0 - 255 来表示-128 - 127。

n 位加法器本身又可以当一个黑箱来使用:它有两个 n 位输入,一个进位输入,一个进位输出以及一个 n 位输出(加法运算的结果)。利用 2 的补码,减法很容易就可以实现,只需要一个由非门构成的求补器和一个表明符号的输入端。

如何计数

osc

用如上图所示的方法连接电磁铁和导线,可以发现,当开关闭合,电磁铁磁力把横杠向下拉,由于横杠断开,电路断开,电磁铁失去磁力,横杠又复原,随着横杠的复原,电路又被接通了,电磁铁又有了磁力把横杠向下拉……像这样,会不断以恒定的频率在连通、开路之间变化的电路称为振荡器(oscillator),又称为时钟(clock),通过振荡器计数也是一种记时方式

用两个或非门可以组装一种特殊的电路,接通开关A,灯泡亮,断开A灯泡依然亮着,此时接通开关B灯泡被熄灭,断开B灯泡依然熄灭。当两个开关都断开时,电路有两个稳定态,这类电路统称为触发器(Flip-Flop)。触发器的最大特定是它可以保持信息!它可以让电路“记住”之前发生了什么事情

最简单的触发器是R-S触发器(Reset-Set,复位/置为),用Q表示输出状态,置位就是把Q设为1,复位就是把Q设为0。除了R-S触发器之外,还有一种D触发器,D表示data(数据端输入),除了数据端输入之外还有一个保持位,保持位为1输出就是数据位输入值,保持位为0输出不再随着数据位变化而变化。跟这种触发器相同的电路就是所谓的电平触发D型锁存器,它表示电路锁存住一位数据并保持它,以便将来使用。也称为1位存储器。可以把8个1位锁存器的时钟输入端(保持位)连在一起,构成8位锁存器。加法器计算的结果,可以存放在锁存器里面。

还有一种触发器,只有当电平从0变化到1的瞬间数据端的输入才会影响输出。这种触发器叫边沿触发器(edge-triggered)。我们可以用振荡器作为时钟端的的输入,这样时钟端就不断地在0、1之间切换,从而在规律的时间里影响输出,从而实现计数功能。

将一个振荡器与边沿触发的触发器连到一起,输出结果与振荡器类似,但是频率减半,这是一个分频器。分频器可以继续连接下去,得到时钟频率四分之一、八分之一……的信号。通过控制计数频率,我们就得到了计数器。

字节与十六进制

在构造加法器、锁存器等组件时,为什么总喜欢用8位比特流而不是6位、7位或10位呢?其实没有什么特别的原因,只是因为使用8位时,一切工作都显得特别的方便。最早在1956年IBM公司就提出用一个字节(byte)表示8位比特(bit)。一个字节的取值范围从0000 00001111 1111,代表2^8,即 256 种不同的事物。世界上大部分书面语言的字符都少于 256 个,因此 1 个字节就足以保存不同的文本符号(例如ASCII码)。当然中文、日文等象形文字远远超过256个,然而世界上没有什么是 1 个字节解决不了的,如果有,那就 2 个。所以,2 个字节可以表示 65536 个不同的事物,足以覆盖绝大多数的汉字。

一个8位二进制数,比如10110110,这种表达方式虽然足够直观和自然,但是不够简洁。用十进制表示要经过一系列换算显然更不方便。八进制(最大值为111)3位一组,不太适合。十六进制(最大值1111)正好是两个半字节。1011是十六进制的B,0110是十六进制的6,用B6h表示10110110即直观又方便。

存储器

由一个反向器、两个与门、两个或非门构成的D型电平触发器,本质上也是一个1位锁存器。保持位(时钟输入)在这里叫做写操作端,写操作端置为1(之后再置为0),数据输入的结果会被写入到输出端中,这个过程我们叫做存储(stored)。把8个1位锁存器组织成8位锁存器,就可以存储8位二进制数。

然而,假如我们只有一个灯泡可以鉴别输出,对于8位二进制数我们要把灯泡依次连接在8位锁存器的每一个锁存器上面,才能知道每一位的输出是0还是1,这有点麻烦。我们不妨直接在输出端用一个灯泡,再在电路中用3个开关来表示 0 到 7 这 八个数(000-111),通过打开关来鉴别每一位的输出,例如,开关(选择端)为101的时候,输出D5锁存器的值,为001的时候,输出D1锁存器的值。这种用开关来选择应该输出哪个锁存器的值的装置叫做8-1选择器

还有一个问题,8个1位锁存器连接,数据输入端可以连在一起,但是写操作端却不可以,因为我们很可能要向每个锁存器依次写入数据。我们还是可以用3个开关,来表示我们要把哪个锁存器的写端置1,比如,开关为101的时候,将D5锁存器的写操作端置1,D5的数据端就会写入到输出端。这种装置跟选择器功能类似,作用相反,叫做3-8译码器

现在,我们有一个3-8译码器、1个8位锁存器(由8个1位锁存器组成)、1个8-1选择器组装起来的家伙。选择器利用 n 位信号(地址)选择总共 2^n 位的锁存器(数据)中哪一位被访问,这样的装置叫做随机访问存储器(Random Access Memory,RAM),或者叫读/写存储器(read/write memory)。之所以叫做随机访问存储器,是因为读写操作很自由,我们只需要改变地址及相关的输入,就可以从8个锁存器中读出或写入需要的数据。

RAM通过特殊的配置可形成RAM阵列(Array)。2个RAM共享写操作端和开关(也叫地址),就形成8×2的RAM阵列,表示可存储的二进制数依然是8个,但位宽位2位。或者,在RAM的输入加多1-2译码器,输出加多一个2-1选择器,变成16×1的RAM阵列,表示存储容量为16。

这样,我们就可以构造任意大小的RAM阵列了。注意,我们现在所构造的RAM,本质上里面都是一个个由电磁铁组成的逻辑门组件,如果断电,这些电磁铁都会失去磁性,然后金属片弹回原位,所有继电器都还原到未触发状态,RAM中存储的数据都会丢失,因此随机访问存储器(RAM)也叫做易失性(volatile)存储器,一旦断电数据就会丢失

自动操作

用8位加法器和8位锁存器,可以先输入一个数到加法器中,然后储存到锁存器里,通过开关往加法器输入第二个数,再将锁存器的值与加法器的数相加,不断循环这种操作,就可以计算多个数的和。用来累加多个数的锁存器称为累加器(accumulator)

但假如,我们不需要把100个数加在一起,而只是想分别计算100个数某几个之和然后分别存储。这时候可以用存储器RAM来读取数据和保存结果。当然,我们需要振荡器和16位计数器,来选取RAM里面哪个数(地址)该送往累加器里面,然后通过计算,把结果送回RAM里特定的地址。我们把这套装置叫做自动累加器

实际上我们希望自动累加器完成四件事:第一,把一个字节从存储器传送到累加器中,这个过程叫做加载(load);第二,把存储器中的另一个字节加(Add)到累加器的内容中去;第三,把累加器中的计算结果取出并存回存储器中(store);第四,需要一个方法让自动加法器停下来(Halt)。

既然我们有一些需要求和的数据,又有4种操作,为什么不用另一个RAM来存放4种操作,然后通过控制面板决定当前要执行哪种操作呢?于是我们加多一个RAM,用来存储操作码,如 10h 表示 Load, 11h表示 Store,20h表示 Add,FFh表示Halt。**这被称为指令码(instruction code)或者操作码(operation code)**。他们用来指示电路要执行哪种操作。通过对电路进行改进,我们可以实现更多的操作码,例如减法(Subtract)、进位加法(Add with Carry)、地址跳转(Jump)等。

事实上,没有必要使用两个RAM来分开数据和指令,用3字节长的指令格式,即存放指令本身,也存放操作数所在的存储地址。

计算机

能否控制重复操作或者循环(looping)是计算机(computer)和计算器(calculator)的区别。至此,我们可以说我们已经造出了一台原始的计算机,包含四个部分:处理器(processor)、存储器(memory)、至少一个输入设备(input)和输出设备(output)。处理器也叫做中央处理单元(central processing unit,CPU),处理器内部又分为算术逻辑单元(ALU)和程序计数器(PC)。在我们的例子中,ALU由8位反向器和8位加法器构成,只能加减运算,实际上更高级的ALU还能进行逻辑运算。PC由16位计数器构成。

后来,随着科技的发展,渐渐地用真空管替代了继电器,再之后随着半导体技术的崛起,晶体管又替代了真空管。人们才得以把这些逻辑门组件(加法器、累加器、选择器、译码器)集成在微小的电子电路里面,集成电路(IC)得以发展,后来演变成微处理器。微处理器是计算机的大脑

机器语言和汇编语言

操作码在计算机里面,也叫做机器码或者机器语言,表示可以被计算机处理器响应的代码。例如指令代码10h表示加载(Load),11h表示加法(Add),编写代码时,程序员可能难以记住这些代码的意思,因此我们用一些助记符来帮助我们辨认,如用 LOD 表示 Load,用 SUB 表示 Subtract,用 JMP 表示 Jump 等等。数据存放的位置也不用实际的地址,而是用标号(label)来指代,如 2000h 存放结果,我们用 RESULT 代替。于是,我们的程序就演变成这样:

1
2
3
4
5
6
7
8
BEGIN:  LOD A, [RESULT + 1]
ADD A, [NUM1 +1]
STO [RESULT + 1], A

NEG1: HLT

NUM1: 00h, A7h
RESULT: 00h, 00h

这就是汇编语言(assembly language)。

字符编码

现在,我们已经能用计算机表示数字并进行计算了。但如果要显示文本呢?业界常用ASCII码(美国信息交换标准码)来将十六进制编码映射为字符,ASCII码是7位二进制数,一般用十六进制表示,从00h ~ 7Fh。例如,30h表示字符0(不是数字0),41h表示字符A。ASCII码包括95个图形字符和33个控制字符,例如09h表示水平制表符,当计算机遇到 41 09 42 这样的ASCII码,会输出A B,中间的 tab 空格,就是控制字符09h的含义。

ASCII码也有不足的地方,例如无法统一各国的语言符号(尤其是像中文这样的象形文字)。这导致各国都有自己的一套编码系统,例如中国有国标GBK编码。1988年开始,几大著名计算机公司决定合作研究一套统一的编码系统,取名 Unicode,采用16位编码,每个字符 2 个字节,范围从 0000h ~ FFFFh,总共可以表示65536个不同的字符。

磁盘

之前,我们用RAM来存储信息,但是RAM会随着断电而丢失数据。最早人们是通过纸带打孔卡片的方式来保存永久信息。后来丹麦发明家波尔森发明了世界上第一块可用的磁介质存储器。这种设备记录和读取信息是利用电磁铁的磁头(head)来完成的,磁盘的表面被划分为许多同心圆,称为磁道(tracks),每个磁道又被划分为扇形的扇区(sectors),每个扇区可以存放一定数量的字节。微处理器不能直接从磁盘读取数据,而是要先将所需的数据载入内存中,然后才能对其进行访问。

时至今日,磁盘又被分为软盘和硬盘,硬盘又分为机械硬盘和固态硬盘,但固态硬盘的原理跟磁盘已经不是一回事了。


  • 参考资料:《编码——隐匿在计算机软硬件背后的语言》