1 一切硬件的基础:逻辑门
逻辑门是搭建计算机的基础元件,主要用于完成逻辑运算。逻辑运算又称为布尔运算,无论是输入还是输出,都只有0和1,用来表示两种对立的逻辑状态。用来执行与、或、非这三种最基本逻辑运算的元件称为与门、或门、非门。使用这三种基本的逻辑门,就可以实现所有的逻辑运算,进而构造一整套的计算。
计算机的本质就是上述提到的与门、或门、非门等各种门。木头、水泵、塑料、卡子,只要能够完成基本逻辑门的功能,任何东西都能够做成计算机。目前除了现代电脑以外,市面上几乎没有其他计算机系统,其实是因为除了工业集成电路技术,尚没有别的更好的技术,能够将上述逻辑门以千万级的数量储存在一个几厘米见方的芯片里面,从而实现商业化的规模生产和应用。未来随着纳米技术和分子生物技术的进步,一定会有别的形式的商业级计算机出现。
2 一切运算的基础:加法
或许你会怀疑上述简单的逻辑门能够做什么事情,接下来我们将会看到,通过组合,逻辑门就能实现基本的计算机功能。
与我们平常支持0到9的十进制计算不同。因为整个计算机系统只有0和1两个数,所以这样的计算机系统只能够支持0和1的二进制计算,在计算机系统里面,所有的计算都需要转换成二进制。
为了实现上述计算功能,需要首先实现半加器,通过半加器实现全加器,再通过三个全加器的连接,就能够形成支持上述计算的一个三位加法器了。
2.1 半加器(Half Adder)
对于给定的输入A和B(它们都只能是0和1),通过一个或门,两个与门,一个非门的组合,可以对两个位进行加法并形成进位。
2.2 全加器(Full Adder)
通过两个半加器和一个或门的组合,形成一个全加器。与半加器相比,全加器在输入上多了一个接收的进位,可能把从低位进位而来的数据纳入到计算中,将从低位计算产生的进位也加在一起。
2.3 三位加法器
通过三个全加器的组合,就形成了一个三位加法器。
以此类推,为了实现对n位二进制数据的加法,需要使用n个全加器芯片,并且依次把进位传到下一个全加器。同理,我们可以通过任意位的加法器来实现对于较长二进制数的计算。尽管我们只介绍了加法运算的实现,实际上数学家已经证明,加法是实现所有数学运算的基础。有了加法器,原则上就能通过它们搭建任何其他计算,像乘法、除法、平方、开方、三角函数、对数函数等。而伟大的计算机科学家图灵在一百年前就已经指明,这些简单运算足以支撑任何信息处理过程。
如果需要实现上述加法器,最直接的方法是购买相应逻辑门级别的晶体管电子元件亲自动手焊接实现。
随着设计功能的复杂化,通过手动连接实现将会面对大量的晶体管和少量的复杂连线,因此人们发明了FPGA(Field-Programmable Gate Array),它提供了大量的基础逻辑元件,这些元件封装在一个小的芯片里面,可能看成是一个计算芯片的半成品。设计人员可以在软件中以类似于编程的方式设计逻辑元件的连接,并将其写入到专门的FPGA开发板中,从而实现相关的运算。
3 让计算过程自动起来:机器指令
事实上,人天生就是懒惰的,刚刚介绍的机器虽然能够解决基本计算的问题,但是说实在的,确实非常不好用。比如现在需要做一个连续加的操作,假设我们希望先把三个数字加在一起,然后把另外两个数字加在一起,最后再把另外三个数字加在一起。如果使用前面的机器,我们需要把这些数字都写在纸上,然后按照二进制的格式一个个地输入进去,并根据计算结果显示的情况把数据抄下来,然后再进行计算。在这个过程中,需要不断地把数据操作过程在计算机外记录下来,那么有没有办法让计算过程自动进行呢?答案是肯定的。
首先,我们需要一个叫做内存的东西,它能够把数据存储在计算机里面,并且能够保持一定的时间。可以把内存理解为一个一个的小房间,每个小房间都有一个门牌号,这就是地址,地址表示的是数据存储的位置。内存的主要作用就是能够对数据进行存储、读取和修改。关于内存的实现,除了上述提到的基本逻辑门的组合(组合逻辑)以外,还需要加上触发器设计(涉及时序逻辑)实现。
下图是一个在内存中计算求和的过程。为了表示方便,我们已经把里面关于二进制的表述都换成了我们较为熟悉的十进制,实际上在计算机里面存储的都是二进制。在这里,每一个格子表示一个内存地址单元,里面存放的是相应的数据,左边是这些内存单元的地址编号,基本上所有的地址编号都是从0开始的。
我们需要进行四种操作:读取、加、保存、停止。
操作 | 编码 |
---|---|
Load(读取) | 10 |
Store(保存) | 11 |
Add(加) | 20 |
Halt(停止) | 99 |
这样编码只是为了方便,并没有特别的原因。通过相应的转换以后,上述的相应计算操作即可编码成下图所示的操作过程,存入在以1000开始的内存地址中。
但是,实际上这样的编码序列还是无法自动运行,因为前面的每个操作都需要指定操作数据地址,因此,假设我们规定每个操作命令加上操作数据的地址为三个内存单元,并命名为指令,那么整个计算过程的编码如下图所示:
这样计算机就可以根据存储在内存中的指令一条条地往下执行直到遇到停机指令,这样就可以让整个计算过程自动执行,从而让计算机根据写好的指令完成我们想要的计算。
上述四个基本指令只是用于这样的连续累加所涉及的一些操作的示意,真正通用的计算机在进行运算时,需要设计更多的硬件来实现相应更多的指令。一个计算机系统支持的全部指令称为指令集,在对计算机进行设计时,有两种基本的设计思路,一种是设计精简的指令集,复杂的计算通过编程实现。比如可以设计只支持加减运算的指令集,那么对于乘法的实现,就可以通过在软件中不断地用加法来实现。这种芯片设计简单,适用范围广泛。另一种是设计复杂的指令集,如直接通过硬件来实现乘法,可能实现更快的运算速度,同时也增加了硬件设计的复杂性和成本。
在实际的硬件设计时,由于在计算过程中经常会对一些常用的数进行操作,于是专门设计了一种叫作寄存器的东西(如在上面的操作中,加法器计算的结果我们默认保存在加法器,实际上一般CPU计算完的结果都保存在寄存器中),专门用于对需要中转的数据进行暂存,类似于平常运算过程中用到的可擦写的草稿纸。
Intel 8080于1974年4月发布,作为英特尔早期发布的处理器,它集成了6000只晶体管,除了上述提到的加减运算和数据复制以外,还支持存数、取数等更多指令。这款CPU用在了1975年风靡美国的最早的个人计算机牛郎星8800上面。在这台机器上,操作是通过一些开关来扳动输入的,计算的结果是通过指示灯显示出来的。当然在今天看来,这实在是太简陋了,但是它的后续作品8086、80286、80386、80486等持续进行了改进,开创了英特尔X86电脑系列的辉煌时代。
4 写点能让人理解的东西:编程语言
到目前为止,通过基本的逻辑门设计和相应的运算指令的实现,一台计算机的硬件部分就已经设计完毕了。如前所述真正的计算机在运行的时候,是通过逐条读取存入在内存中的相应指令然后进行各种计算和操作实现的。类似10 0000和20 0001的被机器所识别并运行的机器指令或操作指令,会被编码成方便人类理解的助记形式如Load 0000和Add 0001。这就是汇编语言。
以某种假想的汇编语言为例,来看一个从1到100累加求和的计算过程。前面的数字表示语句序列,#号后面表示解释说明。
1 | mov @100 ,R0 | # 将100存入到内存R0单元,用于计数 |
---|---|---|
2 | mov @0 ,A | # 累加计算结果,初始值设置为0 |
3 | mov @1 ,R1 | # 用于增加计算 |
4 | Loop: | # 表示以下部分循环执行 |
5 | add A,R1 | # 将A的值和R1中的值相加后存入A |
6 | inc R1 | # R1中的数增加1 |
7 | dec R0 | # R0中的数减少1 |
8 | jgz R0, Loop | # 判断如果R0中的值大于0,则转到Loop处运行 |
9 | jmp $end | # 转到End |
10 | End | # 程序结束停止,最终的计算结果存在A中 |
虽然这样的程序写起来已经比直接的机器语言要方便很多,但还是不够方便,因此需要提供高级编程语言让用户使用。对于上述的汇编语言实现的功能,现在绝大多数的高级编程语言(如C语言)实现起来应该是这样的:
i=1,sum=0,count-100 | # 计数器设为100,累加计算结果设为0 |
---|---|
while(count-->0){ | # 计数器大于0的时候,计数器减1并循环执行{}中的内容 |
sum=sum I; | # 每次将sum值与i的值相加,结果存在sum中 |
i ;} | # i的值增加1 |
为了在一台计算机上实现上述功能,我们需要能够实现语言之间转换的编译器。编译器指的是能够将一种源语言翻译成另一种目标语言的程序。在上述计算机中,我们需要实现两个编译器,一个将高级语言编译成汇编语言,另一个将汇编语言编译成机器语言,如下图所示:
编译器的实现是一个较为复杂的过程。一般首先对源语言程序进行扫描,将其中的一些关键字符和存储数据的变量进行相应的转换和处理,将源语言的相应操作对应到目标语言上去。在实际的编译过程中,需要进行多次反复处理才能够生成最终的目标语言。
以上面这段简单的程序为例,为了实现把这段语言转换成汇编语言的过程,主要包括词法分析、语法分析、语义分析、目标代码生成几个阶段。
4.1 词法分析
主要是把源代码里面所有的字符串全部读进来,然后进行扫描和分解,把常量、变量名、运算符、关键字等标识出来。
4.2 语法分析
此阶段主要是在词法分析的基础上将识别出来的单词序列按照该语言的语法要素识别出相应的语法单位。
4.3 语义分析
语义分析的主要作用是判断整个源程序代码里面是否有错误,如有C语言中对于变量是否已经声明、语句是否以分号结束、运算的对象是否合理等进行整体审查。
4.4 目标代码生成
将源代码转换成目标代码的过程是最重要也是最复杂的阶段。如上例所示,将i=1;sum=0,count=100;语句中的三个赋值表达式转换成了三条Mov汇编指令,存在三个寄存器中,然后把While语句的范围转换成loop和end之间的代码,sum=sum i;转换成add A,R1, i 转换成inc R1, count--转换成dec R0,而while(count-->0)则转换成jgz R0,Loop。
从汇编语言转换到机器指令的基本过程也差不多,而且这个过程往往比高级语言转换到汇编语言要简单。因为在设计CPU时,人们对于相应的操作基本上已经给出了相应的操作码。
其实对于编程语言来说,语言的关键字符、书写形式等构成的是语言的语法,但语言的强大与否并不在于语法,而在于提供的相应操作函数的数量,一般语言提供的大量相关函数称为类库。在实现自己的编程语言时,除了需要实现语言的编译器以外,更多的是需要提供强大的、适用的函数的类库。如前面的语言,如果提供一个叫sum的累积求和函数,只需要一行语句sum(1,100)就可以实现从1到100的加法计算功能。由于不同的语言设计目的不同,函数库侧重不同。因此不同的语言适用于不同的功能。
5 灵魂和守护者:操作系统
前面已经讲述了从逻辑门到编程语言的整个过程,但是不知道你有没有注意到,从开始到现在,所有的例子都只提到了加减法。对于一台真正的计算机,哪怕能够算出宇宙尽头毁灭的时刻,对于大多数人来说,也不如能够玩个植物大战僵尸或者看个美国大片有用,所以,我们的计算机能够做的可不仅仅只是算算数。
作为一套计算机系统,除了最核心的计算单元CPU以外,还需要通过操作系统将其和存储器、输入、输出设备连接在一起,才能够形成完整可用的计算机系统。
5.1 输出
为了使从1到100的计算结果能够显示在计算机屏幕上,我们需要在内存中留出特定的区域存放用于显示的内容,在CPU通过指令的运行把数据存放在特定的内存位置上以后,操作系统负责不断地将这些特定区域的内容在屏幕上显示出来。在这个过程中,要适应不同的分辨率,计算在显示器上输出的位置。为此,操作系统需要适应不同的显示设备,根据不同的设备运行不同的驱动程序。
5.2 输出
同样,操作系统需要接收键盘的输入,在键盘发生了按键动作时,需要得到触发的通知,将按键的电信号转换为相应的字符,并不断将接收到的字符存在指定内存区域,供计算机中运行的程序使用。
在程序员进行高级语言编程时,我们希望通过诸如printf("100")、getchar之类的命令就能够实现输出和输入的功能,操作系统负责实现具体的细节功能。
在简单的计算机模型中,操作系统主要负责的功能有两点:一是封装对于底层的硬件实现,二是提供更多的函数支持更多的功能,如提供drawline之类的函数支持在屏幕上实现划线的操作。因此,这个意义上的操作系统与前面提到的语言的类库之间的界线并不是特别明显。现在主流的操作系统Windows、Unix和Linux,由于设置了不同程序对于硬件的访问权限和优先级的控制,这个界面切分得很清楚,基本上在高级语言层面是不允许直接访问底层硬件的。
前面从如何通过基本的与、或、非逻辑门开始构造计算机的硬件用以实现相应的指令集,以及在与指令集完全对应的机器语言上通过汇编语言进而到高级语言来编写计算程序,说明了构造一台计算机制主要过程。在整个系统的构造过程中,最后一个环节就是操作系统,操作系统是用来衔接计算机的硬件系统和软件系统的,使一台计算机对于用户来说真正可以使用。