Skip to content

Latest commit

 

History

History
174 lines (106 loc) · 28.1 KB

program-program-program-05.md

File metadata and controls

174 lines (106 loc) · 28.1 KB

程序!程序!程序!—— 协同的奥义

       程序的运行离不开软硬件的支持,硬件能够运行软件并体现出些许智能,就在于其自身也是由一个个逻辑构件组成的,同时它与软件之间定义了明确的接口,这个接口就是机器语言(或指令)。我们开发程序使用的编程语言,最终都需要依靠编译器将其转换为对应环境的(机器语言)本地代码。机器语言构成的本地代码实际就是针对硬件的指令和数据,与网络协议一样,它们一定是有意义的,虽然是一堆01组成的代码,但它如同音乐一般,会有节奏的律动。

       CPU会按照约定取出指令进行执行,但计算机不止CPU一个构件,它还包括了内存、硬盘、显卡以及鼠标等,这么多构件需要相互协同,让使用者感觉到它们就像一个整体。接下来,笔者会尝试介绍计算机的主要组成部分,看看它们是如何配合完成工作的。

计算机的组成部分

       冯·诺伊曼在1945年6月提出了现代计算机的范式,该范式被称为“冯·诺伊曼结构”,按照这种结构建造的计算机也被称为通用或冯·诺伊曼计算机。冯·诺伊曼计算机主要由运算器、控制器、存储器,输入和输出设备共计5个部分组成,它的的特点是:程序以二进制代码的形式存放在存储器中,所有的指令都是由操作码和地址码组成,指令按照顺序执行,以运算器和控制器作为计算机的中心。上述这段内容在 **《计算机组成原理》**专业课中都会介绍,但那时懵懂的少年是无法领悟这么抽象的结构和致密的内涵,而当工作多年后,随着在系统结构知识上的不断求取和实践,再次回想这些话时,就会有很多感慨吧。

       冯·诺伊曼计算机对于存储器没有区分出内存和外存,而中央处理器所包括的寄存器也没有在结构中体现出来,不过这些都是对计算机运行效率优化而出现的构件,并不会对其运行的核心概念产生影响。冯·诺伊曼计算机的组成部分,如下图所示:

       如上图所示,CPU由运算器(ALU)、控制器(CU)和寄存器(Register)组成,它是整个计算机的核心。存储器分为内存和外存,前者是我们常说的内存(Memory),CPU会与它直接通信,外存则是偏向I/O存储的设备,比如:硬盘。输入和输出设备都属于外设,常见的输入设备,比如:鼠标和键盘,输出设备也有很多,比方说现在正在观看本文章的显示器就属于输出设备。

       这么多的设备构件需要协同起来,才能从输入获取问题,载入存储器,通过运算器和控制器进行计算,最终将问题的结果发送到输出设备上。这些设备的协同离不开总线,总线可以理解为公共线路,在公共线路上的信号都能被接入线路的设备所获取到,而总线分为三类:控制、数据和地址。

       控制总线原点在控制器,它是核心中的核心,负责访问内存获取指令,解释并执行指令,它也会驱动运算器进行运算,当然访问输入设备和输出设备就更不在话下了。数据总线的目的是实现设备构件之间进行数据传输,不是任意设备之间都可以自由的进行数据拷贝,比如:快速的CPU就只会同内存打交道,就这CPU还会嫌内存慢,不会同低速的输入和输出设备直接交互,因此数据基本都会在内存中进行中转。地址总线主要是面向CPU和内存的,内存是一段连续的空间,但是它有地址编号,根据地址编号就可以存入(或取出)数据,实际内存更像一个Map结构,其中键是内存地址,而值是二进制数据,因此控制器操作内存就需要向地址总线输入地址信息。

       接下来,我们详细看一下内存、磁盘、外设和CPU

内存如何保存数据

       CPU与内存联系是非常紧密的,现代计算机中,CPU到内存的访问延时在100纳秒以内,已经非常快了,虽然它们离CPU很近,毕竟CPU和内存之间是通过线缆来连接的,相对于CPU中的缓存而言,距离还是很远的。内存一般被称为RAMRandom Access Memory),它会分为两类:DRAMSRAM,两者的区别在于前者需要不断的刷新才能保存数据,后者工艺复杂成本高,不需要刷新就可以维护数据,同时访问效率更高,常被用于CPU中的缓存,普通消费者购买的内存条一般都是DRAM,它长得大概是这样的,如果不是,可能穿了信仰加成的眩光马甲,如下图所示:

       如上图所示,内存条上有黑色的内存颗粒,它们是用来存储数据的,而绿色PCB板上密密麻麻的布线以及金黄色的引脚感觉科技感十足,其中引脚一般称为金手指。布线过于复杂,这里不做展开,但可以看到布线连接的引脚们分为左右两个部分,这两个部分分别会连接到地址总线和数据总线。金手指上除了地址总线和数据总线的引脚,还有电源引脚和负责控制的引脚,从概念上来看,内存如下图所示:

       如上图所示,内存芯片与《程序!数据的表达》中介绍用于存储数据的芯片,二者引脚结构很相似,只不过是多了地址信号的一组引脚,毕竟内存不是被设计成只能存储一个值的存储器。数据写入内存时,首先会指定内存地址,这就好比指定了数组的下标或者Map结构中的键,然后再将(二进制)数据(按位)输入给数据引脚,最后给WRWrite)引脚通电(也可以说设为1),输入的数据就会被保存在(内存中的)相应地址中。数据读出内存时,也需要先指定内存地址,然后给RDRead)引脚通电,对应内存地址存储的数据会透过相应数据引脚,以电压的形式呈现。

       内存中存储的数据不存在数据类型的概念,以地址(或数字编号)的形式访问内存,存取的是一个固定位宽的值。这么一看,内存挺死板的呀!很难想象程序中五花八门功能都会存放在一个如同棋盘般规矩的空间中,不过话说回来,不以规矩,无以成方圆,数据类型不在内存中,在编程语言中定义,而它们又能很好与内存一起工作。假设一款内存芯片,它每个地址只能存储8位的二进制数据,使用C语言定义了几个不同的变量,如下图所示:

       如上图所示,byteshortint这三种数据类型占据了不同数量的地址空间,内存不会将任意类型的数据都放在一个地址中,而是会跨多个地址来存放数据。对于byte类型的数据,例如:byte x = 123;这款8位的内存,使用一个地址对应的内存空间即可存下,所以它(或变量x对应的值)只会占据1个地址。shortint类型的数据就占据多个地址,其数据自身的地址是连续的,以int类型的变量为例,int z = 123;其中z会占据4个地址,由于123这个值对于32位的int而言,处于低8位,所以这种(多字节)数据排列方式称为小端模式(Little Endian)。

       在一般的编程语言中,内存地址不会被直接操作,虽然能隐约感受到操纵的是地址(或引用),比如:Java语言中这行代码String s = “hello”;,对Java了解的同学都知道,s变量是一个引用,它指向一个字符串对象,字符串对象保存的有字符内容hello,而引用s就好像地址一样。这样看,Java语言不也是能够操作内存地址了吗?没错,Java根据引用(或地址)可以访问到对应的对象数据,但是它无法在地址上做运算,而这点C语言可以轻松做到。

       C语言中的指针,是一种变量,它表示存储数据的内存地址,比如:定义一个字符类型的指针 char *d;其中d的值是地址,而char类型表示从地址一次能够读写的字节数。C语言中,地址可以通过数值计算进行更改,因此指针可以到处乱指,而直接操作内存地址的特性过于过于灵活,稍不注意就会导致问题。

       接下来我们把内存条插到主板上,和CPU连起来,如下图所示:

       如上图所示,CPU通过地址、控制和数据总线同内存插槽相连,内存条插入内存插槽中,CPU通过地址总线向内存输入地址信息,通过数据总线设置或读取(内存地址对应的)数据信息,而CPU对内存操作的读写模式则由控制总线来传递。看到这里,是不是对主板和内存感觉亲切了许多呢?

磁盘如何存储数据

       如果只有CPU和内存,计算机一旦掉电,重启后,所有的数据将不复存在,因为它们是没有“记忆”的。外存的出现使得计算机能够将数据持久化的保存起来,当需要的时候,会被载入到内存,而只有读入到内存中的程序(或数据)才可以被运行(和使用)。外存的访问速度相比内存会慢很多,而程序需要从外存载入到内存才能运行,所以在CPU和内存配置差别不大的情况下,使用SSDSolid State Drive)作为外存的计算机,在启动(和运行)程序方面,要明显快于使用磁盘(HDDHard Disk Drive)的计算机。

       不管是SSD还是HDD,它们大都会使用一致的接口同CPU和内存相连,比如:SATAPCI接口,以HDD为例,它的结构如下图所示:

       如上图所示,HDD下方有数据和电源接口,HDD最显著的还是它那银色的盘片以及盘片上的悬臂,以及看不见的马达。只要稍微接触过计算机的同学,纵使没有看过HDD的结构图,也能猜出数据是存储在银色盘片上的,那么它是怎样保存数据的呢?其实就是通过在磁性盘片上,使用正向或反向的电压来使得盘片上的磁性粒子处于不同的排列形态,而在不同电压下形成的排列形态就可以代表01了。悬臂顶端的磁头可以施加电压来更改盘片上相应位置磁性粒子的排列形态,同时也可以感知它们的排列形态,这就使得悬臂通过磁头能够写入和读取到盘片上的数据。

       盘片是圆的,数据划分起来岂不是挺困难?如果盘片是正方形的,那横平竖直岂不来的简单?确实如此,当数据在被读取时,需要让悬臂处于数据对应盘片区域的上方,可以看到读取数据时需要给定数据以及数据对应的区域,该对应关系一般会被存储在HDD的缓存中。那读取数据时,盘片和悬臂都会动吗?是的,盘片通过马达驱动进行旋转,而悬臂会在多个同心圆上移动,因此,盘片还是圆形的好,便于旋转,而圆形的盘片会依据不同的半径被分为若干同心圆空间,称为磁道。操作系统会对磁道进行概念上的分割,形成多个扇区,如下图所示:

       如上图所示,虽然磁道由内到外,每个长度不一,但是操作系统通过概念上的划分,把它们都分拆成了固定大小的扇区,比如:一个扇区有512字节,而操作系统访问HDD时,都是以扇区作为单位来进行读写的。与访问内存相比,外存访问的特点就是容量大速度慢,除了持久化保存数据以外,外存还会被用作虚拟内存。所谓虚拟内存,是操作系统中的概念,它是把外存中的一部分作为假想的内存进行使用。操作系统为了实现虚拟内存,就需要把程序在内存中的实际内容和外存上的虚拟内存进行部分置换(Swap),而置换过程对于运行中的程序而言是透明的。

       虚拟内存的使用,使得每个程序都拥有适度宽裕的内存空间,这听起来不错,但实现起来还是有些麻烦的,该功能需要操作系统抽象出页(Page)的概念。程序全部载入内存后,如《程序!硬件的执念》中所介绍,程序的指令和数据会顺序的排列在内存中,如果我们将载入的程序看作是一本书,那书中的每一页都是程序的一部分,因为程序的执行是顺序的,所以运行起来就如同翻书一般,只不过遇到JMP类型的指令时,会在其中几页上翻来翻去而已。有了页的概念后,就可以将页放置在内存或虚拟内存中,并且以页为单位在二者之间进行置换,从外存将页载入内存称为Page In,反之,称为Page Out,该过程如下图所示:

       如上图所示,载入内存的页完成修改暂时不用后,会通过Page Out更新到虚拟内存中,当后续需要使用时会通过Page In重新载入到内存里。虚拟内存中一般有程序全量的页,因此借助虚拟内存能够解决物理内存不足的问题,但Page In/Out会伴随着与低速外存的交互,应用运行就会变得卡顿起来。在计算机机箱上一般会有一个硬盘灯,当磁盘数据读写时会闪烁,而我们在使用程序过程中,如果发现程序变得卡顿时,往往也会发现硬盘灯也在狂闪,其实此时虚拟内存正在不停的置换,如果使用SSD,置换的效率就会变得高效,程序的响应一般就会变得灵敏许多。

外设如何工作

       没有输入和输出设备的接入,计算机没有任何意义,因为它求解的问题来自于输入,对问题的解需要进行输出。输入/输出设备,也就是外设,包括了键鼠、显示器以及摄像头等,开发人员在键盘上输入字符,游戏玩家在FPS游戏中用鼠标移动准心瞄准敌人,其实都是在输入,对于计算机而言,它们都是一样的。

       输入/输出设备的种类繁多,它们与方方正正的CPU、内存和外存形成了鲜明的对比,但是它们还是通过数据交换的方式同计算机相连。在输入/输出设备上,一般都有用来交换计算机与这些外设数据的IC,这些IC被称为I/O控制器。显示器和键盘等外围设备都有各自专用的I/O控制器,有些常用的I/O控制器会被集成到主板上,这些不同的I/O控制器通过I/O地址来进行区分。I/O控制器包含了端口,端口好比内存空间,是设备面向使用的抽象。

       在汇编语言中,提供了INOUT两个助记符,用来同输入/输出设备进行交互。以IN为例,使用的方式是:IN 寄存器名, 端口号,它代表将端口号所对应I/O设备中的数据读取到指定寄存器中。OUTIN相反,使用方式是:OUT 端口号, 寄存器名,它表示将寄存器中的数据输出到对应端口。开发者使用C语言,调用printf(“hello”);,期望在终端输出字符串hello,这个过程就涉及到操作输出设备,当然源代码没有与具体的终端设备打交道,而是通过调用操作系统提供的API函数加以实现,该过程如下图所示:

       如上图所示,应用通过调用操作系统提供的API与外设进行交互,而API的实现会使用IN或者OUT指令同具体的设备进行通信。那输入/输出设备的访问速度怎么样呢?开发者在IDE里键入一个字符,显示器上会立刻出现对应的字符,感觉很快呀!分析一下这个过程,它涉及到输入和输出,一来一回能够在人们无法感知到延迟的情况下完成,这个速度不快吗?其实这个速度很慢,只是一来一回再快,也有若干毫秒,而在这段时间里,CPU可能已经完成了上亿次计算。

       CPU与外存不会直接交互,原因就是怕被拖慢,跟外设的关系也一样,面对比外存还慢的外设,且伴随有人类更加缓慢的操作,这就需要一种新的机制来进行交互,该机制称为中断请求(IRQInterrupted Request)机制,这是一种用来暂停当前程序,并跳转到其他程序的必要机制。暂停当前程序,跳转到其他程序,这个同输入/输出设备与CPU的通信有什么关系呢?答案是关系很大,因为程序在运行时CPU会不断的执行指令,压根不会去理会输入/输出设备中的数据,就好比为了让闪电侠帮忙,只好不断的打他手机一样。

       实施中断请求的是输入/输出设备中的I/O控制器,而负责实施中断处理程序的是CPU,该过程如下图所示:

       如上图所示,输入/输出设备会不断的发起中断,而不同设备发出的中断请求又不一样,但是请求需要包括:设备信息和I/O端口,起码能够让CPU知道是哪个设备发起了中断。CPU收到中断请求后,会在适当的时间响应中断,发现键盘设备缓冲区有输入的数据,那么将数据拷贝到内存中,接着程序继续运作。显示器作为输出设备也会并行的发起中断,当处理对应中断时,发现内存中有了新数据,就会将新数据输出到显示器进行显示,这样键盘输入的字符就能显示在显示器上了,可以看出,输入和输出是异步进行配合的,只不过中断与中断处理很快,人们无法感知而已。

       中断是针对程序的,运行中的程序需要暂停,让渡出CPU,而后又切换回来,这个代价其实挺高的。中断需要将当前CPU寄存器(以及栈)内容进行备份,然后将控制权移交给处理输入/输出设备的程序,这个程序一般是操作系统提供用来实现中断处理的,该程序执行完成后,会还原寄存器内容,使得原有程序得以正常运行。

离近一点看CPU

       冯·诺伊曼计算机中的CPU,通过地址、控制和数据总线与内存交互,与外存和外设(或输入/输出设备)通过控制和数据总线相连,可以看出,CPU对其他设备就是进行命令控制和存取数据的,这也就是为什么它被称为中央的原因吧。我们稍微离近一点看CPU,它可以分为四个部分,如下图所示:

       如上图所示,CPU由寄存器、控制器、运算器和时钟组成,二进制程序位于内存中,CPU会通过多个部件的协作,完成由内存中取出程序指令与数据,分析指令与计算,最终写回内存的流程。CPU部件之间的(主要)关系与作用如下表所示。

名称 作用 关系
控制器 分析指令并发出相应的控制信号 控制器访问内存,获取指令;控制器驱动运算器进行计算
运算器 负责对数据进行各类运算,主要是数学和逻辑计算 依据寄存器中的数值进行计算
寄存器 用于存放中间结果或其他信息的高速存储器 内存中的数据或指令读入到寄存器
时钟 产生电子脉冲信号,触发其他部件运转 周期性驱动控制器

       可以看出来,CPU冯·诺伊曼时代到现在有了长足的发展,但是其运行的基本逻辑没有变,只是做了更细的拆分。控制器是直面指令逻辑和发号施令的单元,而运算器和寄存器的分拆,这不就是计算与存储分离么?时钟会放大来自主板的时钟频率,宝岛称之为时脉,感觉更贴切一些,在一个时钟周期中,不同体质(或性能)的CPU会执行数量不等的指令,这也就是为什么现代CPU很多主频相近,但性能相差很大的原因。寄存器(Register)听起来就是做数据存储的,这不就是内存吗?其实它和内存差不多,不过具有专属的功能特性,并且是唯一被程序作为对象描述的CPU部件。

       例如:汇编代码 ADD eax, 5,其中eax就是累加寄存器,它用来保存加法前的两个参数,以及加法运算后的结果。寄存器的种类有很多,不同类型的CPU也不一样,IntelCPU提供的基本寄存器在《程序!硬件的执念》中有所介绍,接下来介绍几个常见的寄存器。

       运算器在进行加法运算时,需要存储加法的两个参数,然后保存运算后的值,这里会涉及到加法运算状态的保存,此时就需要累加寄存器的帮助。X86下,32位的累加寄存器名称为eax,而64位为rax。累加寄存器的操作示意图如下所示:

       如上图所示,指令 ADD eax, 5,表示将eax旧有的值加5,结果仍会保存在eax*寄存器中。该指令执行时会将两个参数放置到累加寄存器中,然后使用运算器进行加法运算,运算器计算后的结果保存在累加寄存器中。

       累加寄存器保存了加法运算后的结果,而与之相似的标志寄存器则存储了逻辑运算后的CPU状态,标志寄存器能够提供给控制器(上次)指令执行后的状态,便于其进行流程控制,比如:程序中的if条件判断,都离不开标志寄存器,它的操作示意图如下所示:

       如上图所示,当运行指令CMP eax, 100后,运算器会比对累加寄存器eax中的值与100的大小,这和C语言中的 if (eax == 100)一样,在C语言中 eax == 100会产生一个值,这个值可以表示是否相等,而它一般就存储在标志寄存器中。标志寄存器保存了逻辑判断的结果,使用一个(32位长的)二进制数值来表示比对结果。如图中所示,两个值比较大小和等于的结果都会分别按位存储,这样可以方便后续判断,比如:后续指令JLE short@4,表示如果比对的值是小于或等于,即第23位为真时,程序会跳转到short@4所标注的指令行。

       程序跳转到某一行指令,这在C语言编写的程序中是很常见的,但对于CPU而言,它怎么知道该执行哪一条指令呢?这就需要有一个寄存器始终保存CPU接下来需要运行指令的地址,该寄存器被称为程序计数器。之前看到的指令JLE short@4,在进行指令跳转时,就是将需要跳转到的指令地址设置到程序计数器中,这样CPU在获取指令时就能拿到需要跳转到的指令了。从内存中拿到指令后,还需要将指令保存在指令寄存器中,方便控制器能够分析与执行指令,毕竟从内存中拷贝过来的指令需要有一个地方保存呀!

       程序的指令跳转可以使用程序计数器以及指令寄存器来完成,而函数调用就离不开栈寄存器了,栈寄存器一般被记作esp,如果是64位,则为rsp。该寄存器始终保存指向栈顶的内存地址,方便进行栈的操作,关于栈寄存器在《程序!硬件的执念》中详细介绍过,这里不再赘述。

       寄存器中多多少少都会保存内存地址,而对于内存访问时,往往需要指定一个地址获取数据,或者给出指定地址后,做一下运算,例如:在给定地址基础上移动4个字节,然后再取出对应地址的内存数据,这就需要基址寄存器和变址寄存器提供的能力了,它们的操作示意图如下所示:

       如上图所示,使用基址寄存器,好比指定数组引用的地址,而变址寄存器就相当于下标,当二者给定时,就能运算出实际内存地址,CPU就可以依据实际内存地址去内存中取值了。

       上述介绍的寄存器只是寄存器中很少的一部分,但是它们分别围绕着运算、执行流程以及数据访问三个基础特性,让我们把这些寄存器分门别类的填回到CPU中,再离近一点观察CPU,这样是不是就有些感觉了?

       CPU、内存、外存和输入/输出设备,它们之间协同配合,有快入闪电的运算器,也有慢如蜗牛般的外设。这些纷繁复杂的设备通过控制、地址和数据总线连接起来,透过合理的调度,运转的天衣无缝,让人们觉得它们本来就是浑然一体的,甚是精妙!