资讯详情

深入浅出计算机组成原理

计算机组成原理简单

基础篇

由计算机的基本硬件组成

image.png

首先,广泛。组成原理中有很多概念,每个概念的信息量也很大。例如,如果你想理解 CPU 算术逻辑单元(即算术逻辑单元) ALU)如何实现加法,需要涉及如何将整数表示为二进制,还需要了解这些表示背后的电路、逻辑门、CPU 时钟、触发器等知识。

第二,深度。组成原理中的许多概念是计算机学科的另一门核心课程。例如,计算机指令是如何从你写的 C、Java 这样的高级语言,变成计算机可以执行的机器码的?如果我们展开并深入讲解这个问题,就会变成《编译原理》这样一门核心课程。

第三,学习不能应用。学习是用来使用的,但由于课程本身的属性,许多人经常沉迷于概念和理论,不能与他们的日常发展工作联系起来,以解决工作中遇到的问题,所以,学习往往没有成就感,很难有动力坚持。

早年,要自己组装一台计算机,首先要有三件,CPU、内存和主板。

在这三个部分中,我们首先要说的是 CPU,它是计算机最重要的核心配件。你必须知道全名。它被称为中央处理器(Central Processing Unit)。为什么说 CPU 是最重要的吗?因为计算机的所有计算都是由 CPU 来做的。CPU 它也是整个计算机中最昂贵的部分之一。

第二个重要的配件是内存(Memory)。您编写的程序、打开的浏览器和操作游戏必须加载到内存中才能操作。程序读取的数据和计算结果也应放入内存中。内存越大,可以加载的东西就越多。

存储在内存中的程序和数据需要 CPU 读取,CPU 计算后,将数据写回内存。 CPU 不能直接插入内存,反之亦然。因此,它带来了最后一个大块——主板(Motherboard)。

主板是一种配件,有时有几十个甚至几百个插槽。 CPU 插在主板上,内存也插在主板上。。芯片组控制数据传输的流通,即数据从哪里到哪里。总线是实际数据传输的高速公路。因此,总线速度(Bus Speed)它决定了数据能传输多快。

有三件,只要配备电源,计算机几乎可以运行。但仍然缺乏各种输入(Input)/ 输出(Output)设备,我们常说 I/O 设备。如果你使用自己的个人电脑,显示器是必不可少的。只有有了显示器,我们才能看到计算机输出的各种图像和文本,这就是所谓的输出设备。

同样,鼠标和键盘也是必不可少的配件。这样,我就可以输入文本并写下这篇文章。它们被称为输入设备。

另一种非常特殊的设备是显卡(Graphics Card)。现在,无论是使用图形界面操作系统的计算机, Windows、Mac OS 还是 Linux,显卡是必不可少的。有人可能会说,我装机的时候没买显卡,电脑也能正常跑!那是因为现在的主板都有内置的显卡。假如你用电脑玩游戏,做图形渲染或跑深度学习应用,你大多需要买一张单独的显卡,插在主板上。显卡之所以特别,是因为除了显卡之外,还有显卡 CPU 另一个处理器,即 GPU(Graphics Processing Unit,图形处理器),GPU 也可以做各种计算工作。

鼠标、键盘和硬盘都插在主板上。作为外部 I/O 通过主板上的南桥设备(SouthBridge)控制和控制芯片组 CPU 通信之间。南桥芯片的名称非常直观,一方面,它在主板上的位置,通常在主板的南面。另一方面,它的作用就是作为“桥”,来连接鼠标、键盘以及硬盘这些外部设备和 CPU 通信。

有了南桥,自然对应着北桥。是的,以前的主板上通常有北桥芯片作为桥连接 CPU 与内存和显卡之间的通信。然而,随着时间的推移,主板上北桥芯片的工作已经转移到 CPU 所以你在主板上看不到北桥芯片。

手机里只有我们 SD 卡(Secure Digital Memory Card)这种类似硬盘功能的存储卡插槽没有内存插槽,CPU 插槽这些东西。是的,由于手机的大小,手机制造商选择了 CPU、内存、网络通信甚至相机芯片都包装在芯片上,然后嵌入到手机主板上。这种方式叫 SoC,也就是 System on a Chip(系统芯片)。

所有的计算机程序也可以抽象地从输入设备中读取输入信息,通过计算器和控制器执行存储在存储器中的程序,最终将结果输出到输出设备中。我们写的所有程序,无论是先进的还是低级的语言,都是基于这样一个抽象的框架。

  • 图灵机是一种思想模型(计算机的基本理论基础),是一个贫穷、结构性的问题。图灵认为,所有可以用算法解决的问题都可以用图灵机解决;
  • 冯诺依曼提出了存储程序的计算机设计理念,并参考图灵模型设计了历史上第一台电子计算机,即冯诺依曼机。

CPU

事实上,计算机的性能与我们的体力劳动非常相似,就像我们必须搬东西一样。我们需要有一个衡量计算机性能的标准。本标准主要有两个指标。

第一个是响应时间(Response time)或执行时间(Execution time)。想要提高响应时间的性能指标,可以理解为让计算机跑得更快。

二是吞吐率(Throughput)或者带宽(Bandwidth),想要提高这个指标,可以理解为让计算机移动更多。

在过去的几年里,流行的手机运行点软件是在手机上运行多个预设程序,然后根据运行所需的时间计算一个分数来评估手机的性能。而在行业,各大 CPU 服务器制造商组织了一个名称 SPEC(Standard Performance Evaluation Corporation)第三方机构专门用于指定各种跑分规则。

SPEC 提供的 CPU 基准测试程序就像 CPU 对于今年的高考,通过几十个不同的计算程序 CPU 性能给出最终评分。这些程序丰富多彩,包括编译器、解释器、视频压缩、人工智能国际象棋等,涵盖了应用场景的各个方面。如果您感兴趣,请单击此链接。

其次,。即使在同一台计算机上,CPU 也许满载运行或降频运行,降频运行时自然会花费更多的时间。

除了 CPU 此外,时间的性能指标也会受到其他相关硬件的影响,如主板和内存。因此,我们需要拆解我们能感知到的时间指标,并解决程序 CPU 执行时间变成 CPU 时钟周期数(CPU Cycles)和 时钟周期时间(Clock Cycle)的乘积。

让我们先了解什么是时钟周期时间。当你买电脑时,你必须注意它 CPU 主频。比如我手头的电脑就是 Intel Core-i7-7700HQ 2.8GHz,这里的 2.8GHz 是电脑的主频(Frequency/Clock Rate)。这个 2.8GHz,我们可以先粗浅地想,

如果你想更准确地描述,这个 2.8GHz 代表,我们 CPU 钟表能识别的最小时间间隔。就像我们挂在墙上的挂钟一样,每一秒都是滴答滴答,所以墙上挂钟能识别的最小时间单位是秒。

而在 CPU 内部类似于我们通常戴的电子石英表,有一个晶体振荡器(Oscillator Crystal)的东西,简称晶振。我们把晶振当作晶振。 CPU 使用内部电子表。

对于 CPU 时钟周期数,我们可以再做一个分解,把它变成“指令数×每个指令的平均时钟周期(Cycles Per Instruction,简称 CPI)。不同的指令需要什么 Cycles 是不同的,,自然慢。这样拆分后,我们的程序 CPU 执行时间可以成为这三个部分的乘积。

**程序的 CPU 执行时间 = 指令数 × CPI × Clock Cycle Time **

  • Clock Cycle Time 晶振时间,时钟周期
  • CPI,每个指令的平均时钟周期(Cycles Per Instruction,简称 CPI),加法和乘法对应一个接一个 CPU 指令,乘法所需 Cycles 比加法多

所以,要想解决性能问题,就要优化这三者。

  1. 时钟周期时间,就是计算机主频,这个取决于计算机硬件。我们所熟知的摩尔定律就一直在不停地提高我们计算机的主频。比如说,我最早使用的 80386 主频只 33MHz,现在手头的笔记本电脑就有 2.8GHz,在主频层面,就提升了将近 100 倍。
  2. 每条指令的平均时钟周期数 CPI,就是一条指令到底需要多少 CPU Cycle。在后面讲解 CPU 结构的时候,我们会看到,现代的 CPU 通过流水线技术(Pipeline),让一条指令需要的 CPU Cycle 尽可能地少。因此,对于 CPI 的优化,也是计算机组成和体系结构中的重要一环。
  3. 指令数,代表执行我们的程序到底需要多少条指令、用哪些指令。这个很多时候就把挑战交给了编译器。同样的代码,编译成计算机指令时候,就有各种不同的表示方式。

于是,从 1978 年 Intel 发布的 8086 CPU 开始,计算机的主频从 5MHz 开始,不断提升。1980 年代中期的 80386 能够跑到 40MHz,1989 年的 486 能够跑到 100MHz,直到 2000 年的奔腾 4 处理器,主频已经到达了 1.4GHz。而消费者也在这 20 年里养成了“看主频”买电脑的习惯。当时已经基本垄断了桌面 CPU 市场的 Intel 更是夸下了海口,表示,颇有一点“大力出奇迹”的意思。

然而,计算机科学界从来不相信“大力出奇迹”。奔腾 4 的 CPU 主频从来没有达到过 10GHz,最终它的主频上限定格在 3.8GHz。这还不是最糟的,更糟糕的事情是,大家发现,奔腾 4 的主频虽然高,但是它的实际性能却配不上同样的主频。想要用在笔记本上的奔腾 4 2.4GHz 处理器,其性能只和基于奔腾 3 架构的奔腾 M 1.6GHz 处理器差不多。

一个 3.8GHz 的奔腾 4 处理器,满载功率是 130 瓦。这个 130 瓦是什么概念呢?机场允许带上飞机的充电宝的容量上限是 100 瓦时。如果我们把这个 CPU 安在手机里面,不考虑屏幕内存之类的耗电,这个 CPU 满载运行 45 分钟,充电宝里面就没电了。而 iPhone X 使用 ARM 架构的 CPU,功率则只有 4.5 瓦左右。

我们的 CPU,一般都被叫作超大规模集成电路(Very-Large-Scale Integration,VLSI)。这些电路,实际上都是一个个晶体管组合而成的。CPU 在计算,其实就是让晶体管里面的“开关”不断地去“打开”和“关闭”,来组合完成各种运算和功能。

想要计算得快,一方面,我们要在 CPU 里,同样的面积里面,多放一些晶体管,;另一方面,我们要让晶体管“打开”和“关闭”得更快一点,。而这两者,都会增加功耗,带来耗电和散热的问题。

我们会在 CPU 上面抹硅脂、装风扇,乃至用上水冷或者其他更好的散热设备,就好像在工厂里面装风扇、空调,发冷饮一样。但是同样的空间下,装上风扇空调能够带来的散热效果也是有极限的。

因此,在 CPU 里面,能够放下的晶体管数量和晶体管的“开关”频率也都是有限的。一个 CPU 的功率,可以用这样一个公式来表示:

  • 增加晶体管可以增加硬件能够支持的指令数量,增加数字通路的位数,以及利用好电路天然的并行性,从硬件层面更快地实现特定的指令,所以增加晶体管也是常见的提升cpu性能的一种手段。
  • 电压的问题在于两个,一个是电压太低就会导致电路无法联通,因为不管用什么作为电路材料,都是有电阻的,所以没有办法无限制降低电压,另外一个是对于工艺的要求也变高了,成本也更贵啊。

那么,为了要提升性能,我们需要不断地增加晶体管数量。同样的面积下,。这个就是平时我们所说的提升“制程”。从 28nm 到 7nm,相当于晶体管本身变成了原来的 1/4 大小。这个就相当于我们在工厂里,同样的活儿,我们要找瘦小一点的工人,这样一个工厂里面就可以多一些人。我们还要提升主频,让开关的频率变快,也就是要找手脚更快的工人。

但是,功耗增加太多,就会导致 CPU 散热跟不上,这时,我们就需要降低电压。这里有一点非常关键,在整个功耗的公式里面,功耗和电压的平方是成正比的。这意味着电压下降到原来的 1/5,整个的功耗会变成原来的 1/25。

事实上,从 5MHz 主频的 8086 到 5GHz 主频的 Intel i9,CPU 的电压已经从 5V 左右下降到了 1V 左右。这也是为什么我们 CPU 的主频提升了 1000 倍,但是功耗只增长了 40 倍。比如说,我写这篇文章用的是 Surface Go,在这样的轻薄笔记本上,微软就是选择了把电压下降到 0.25V 的低电压 CPU,使得笔记本能有更长的续航时间。

这就引出了我们在进行性能优化中,常常用到的一个经验定律,。这个定律说的就是,对于一个程序进行优化之后,处理器并行运算之后效率提升的情况。具体可以用这样一个公式来表示:

在“摩尔定律”和“并行计算”之外,在整个计算机组成层面,还有这样几个原则性的性能提升方法。

  1. 加速大概率事件。最典型的就是,过去几年流行的深度学习,整个计算过程中,99% 都是向量和矩阵计算,于是,工程师们通过用 GPU 替代 CPU,大幅度提升了深度学习的模型训练过程。本来一个 CPU 需要跑几小时甚至几天的程序,GPU 只需要几分钟就好了。Google 更是不满足于 GPU 的性能,进一步地推出了 TPU。后面的文章,我也会为你讲解 GPU 和 TPU 的基本构造和原理。
  2. 通过流水线提高性能。现代的工厂里的生产线叫“流水线”。我们可以把装配 iPhone 这样的任务拆分成一个个细分的任务,让每个人都只需要处理一道工序,最大化整个工厂的生产效率。类似的,我们的 CPU 其实就是一个“运算工厂”。我们把 CPU 指令执行的过程进行拆分,细化运行,也是现代 CPU 在主频没有办法提升那么多的情况下,性能仍然可以得到提升的重要原因之一。我们在后面也会讲到,现代 CPU 里是如何通过流水线来提升性能的,以及反面的,过长的流水线会带来什么新的功耗和效率上的负面影响。
  3. 通过预测提高性能。通过预先猜测下一步该干什么,而不是等上一步运行的结果,提前进行运算,也是让程序跑得更快一点的办法。典型的例子就是在一个循环访问数组的时候,凭经验,你也会猜到下一步我们会访问数组的下一项。后面要讲的“分支和冒险”、“局部性原理”这些 CPU 和存储系统设计方法,其实都是在利用我们对于未来的“预测”,提前进行相应的操作,来提升我们的程序性能。

指令和运算

CPU指令

我们就从平时用的电脑、手机这些设备来说起。这些设备的 CPU 到底有哪些指令呢?这个还真有不少,我们日常用的 Intel CPU,有 2000 条左右的 CPU 指令,实在是太多了,所以我没法一一来给你讲解。不过一般来说,常见的指令可以分成五大类。

第一类是算术类指令。我们的加减乘除,在 CPU 层面,都会变成一条条算术类指令。

第二类是数据传输类指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。

第三类是逻辑类指令。逻辑上的与或非,都是这一类指令。

第四类是条件分支类指令。日常我们写的“if/else”,其实都是条件分支类指令。

最后一类是无条件跳转指令。写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时候,其实就是发起了一个无条件跳转指令。

你可能一下子记不住,或者对这些指令的含义还不能一下子掌握,这里我画了一个表格,给你举例子说明一下,帮你理解、记忆。

我们说过,不同的 CPU 有不同的指令集,也就对应着不同的汇编语言和不同的机器码。为了方便你快速理解这个机器码的计算方式,我们选用最简单的 MIPS 指令集,来看看机器码是如何生成的。

MIPS 是一组由 MIPS 技术公司在 80 年代中期设计出来的 CPU 指令集。就在最近,MIPS 公司把整个指令集和芯片架构都完全开源了。想要深入研究 CPU 和指令集的同学,我这里推荐一些资料,你可以自己了解下。

R 指令是一般用来做算术和逻辑操作,里面有读取和写入数据的寄存器的地址。如果是逻辑位移操作,后面还有位移操作的位移量,而最后的功能码,则是在前面的操作码不够的时候,扩展操作码表示对应的具体指令的。

I 指令,则通常是用在数据传输、条件分支,以及在运算的时候使用的并非变量还是常数的时候。这个时候,没有了位移量和操作码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或者一个常数。

J 指令就是一个跳转指令,高 6 位之外的 26 位都是一个跳转后的地址。

add $t0,$s2,$s1

我以一个简单的加法算术指令 add t0,s1, $s2, 为例,给你解释。为了方便,我们下面都用十进制来表示对应的代码。

对应的 MIPS 指令里 opcode 是 0,rs 代表第一个寄存器 s1 的地址是 17,rt 代表第二个寄存器 s2 的地址是 18,rd 代表目标的临时寄存器 t0 的地址,是 8。因为不是位移操作,所以位移量是 0。把这些数字拼在一起,就变成了一个 MIPS 的加法指令。

为了读起来方便,我们一般把对应的二进制数,用 16 进制表示出来。在这里,也就是 0X02324020。这个数字也就是这条指令对应的机器码。

回到开头我们说的打孔带。如果我们用打孔代表 1,没有打孔代表 0,用 4 行 8 列代表一条指令来打一个穿孔纸带,那么这条命令大概就长这样:

一个 CPU 里面会有很多种不同功能的寄存器。我这里给你介绍三种比较特殊的。

一个是 PC 寄存器(Program Counter Register),我们也叫指令地址寄存器(Instruction Address Register)。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。

第二个是指令寄存器(Instruction Register),用来存放当前正在执行的指令。

第三个是条件码寄存器(Status Register),用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。

除了这些特殊的寄存器,CPU 里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。我们通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。

实际上,一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。

$ gcc -g -c test.c
$ objdump -d -M intel -S test.o 

编译链接

GCC 预处理、编译、汇编、链接.

实际上,“C 语言代码 - 汇编代码 - 机器码” 这个过程,在我们的计算机上进行的时候是由两部分组成的。

第一个部分由编译(Compile)、汇编(Assemble)以及链接(Link)三个阶段组成。在这三个阶段完成之后,我们就生成了一个可执行文件。

第二部分,我们通过装载器(Loader)把可执行文件装载(Load)到内存中。CPU 从内存中读取指令和数据,来开始真正执行程序。

你会发现,可执行代码 dump 出来内容,和之前的目标代码长得差不多,但是长了很多。因为在 Linux 下,可执行文件和目标文件所使用的都是一种叫 ELF(Execuatable and Linkable File Format)的文件格式,中文名字叫可执行与可链接文件格式,这里面不仅存放了编译成的汇编指令,还保留了很多别的数据。

比如我们过去所有 objdump 出来的代码里,你都可以看到对应的函数名称,像 add、main 等等,乃至你自己定义的全局可以访问的变量名称,都存放在这个 ELF 格式文件里。这些名字和它们对应的地址,在 ELF 文件里面,**存储在一个叫作符号表(Symbols Table)**的位置里。符号表相当于一个地址簿,把名字和地址关联了起来。

ELF 文件格式把各种信息,分成一个一个的 Section 保存起来。ELF 有一个基本的文件头(File Header),用来表示这个文件的基本属性,比如是否是可执行文件,对应的 CPU、操作系统等等。除了这些基本属性之外,大部分程序还有这么一些 Section:

  1. 首先是.text Section,也叫作代码段或者指令段(Code Section),用来保存程序的代码和指令;
  2. 接着是.data Section,也叫作数据段(Data Section),用来保存程序里面设置好的初始化数据信息;
  3. 然后就是.rel.text Secion,叫作重定位表(Relocation Table)。重定位表里,保留的是当前的文件里面,哪些跳转地址其实是我们不知道的。比如上面的 link_example.o 里面,我们在 main 函数里面调用了 add 和 printf 这两个函数,但是在链接发生之前,我们并不知道该跳转到哪里,这些信息就会存储在重定位表里;
  4. 最后是.symtab Section,叫作符号表(Symbol Table)。符号表保留了我们所说的当前文件里面定义的函数名称和对应地址的地址簿。

链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。这也是为什么,可执行文件里面的函数调用的地址都是正确的。

我们上一节解决程序装载到内存的时候,讲了很多方法。说起来,最根本的问题其实就是内存空间不够用。如果我们能够让同样功能的代码,在不同的程序里面,不需要各占一份内存空间,那该有多好啊!就好比,现在马路上的共享单车,我们并不需要给每个人都造一辆自行车,只要马路上有这些单车,谁需要的时候,直接通过手机扫码,都可以解骑行。

这个思路就引入一种新的链接方法,叫作动态链接(Dynamic Link)。相应的,我们之前说的合并代码段的方法,就是静态链接(Static Link)。

在动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库(Shared Libraries)。顾名思义,这里的共享库重在“共享“这两个字。

这个加载到内存中的共享库会被很多个程序的指令调用到。在 Windows 下,这些共享库文件就是.dll 文件,也就是 Dynamic-Link Libary(DLL,动态链接库)。在 Linux 下,这些共享库文件就是.so 文件,也就是 Shared Object(一般我们也称之为动态链接库)。这两大操作系统下的文件名后缀,一个用了“动态链接”的意思,另一个用了“共享”的意思,正好覆盖了两方面的含义。

不过,要想要在程序运行的时候共享代码,也有一定的要求,就是这些机器码必须是“地址无关”的。也就是说,我们编译出来的共享库文件的指令代码,是地址无关码(Position-Independent Code)。换句话说就是,这段代码,无论加载在哪个内存地址,都能够正常执行。如果不是这样的代码,就是地址相关的代码。

对于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的。我们没办法、也不应该要求动态链接同一个共享库的不同程序,必须把这个共享库所使用的虚拟内存地址变成一致。如果这样的话,我们写的程序就必须明确地知道内部的内存地址分配。

那么问题来了,我们要怎么样才能做到,动态共享库编译出来的代码指令,都是地址无关码呢?

动态代码库内部的变量和函数调用都很容易解决,我们只需要使用相对地址(Relative Address)就好了。各种指令中使用到的内存地址,给出的不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址。因为整个共享库是放在一段连续的虚拟内存地址中的,无论装载到哪一段地址,不同指令之间的相对地址都是不变的。

要实现动态链接共享库,也并不困难,和前面的静态链接里的符号表和重定向表类似,还是和前面一样,我们还是拿出一小段代码来看一看。

首先,lib.h 定义了动态链接库的一个函数 show_me_the_money。

// lib.h
#ifndef LIB_H
#define LIB_H

void show_me_the_money(int money);

#endif

lib.c 包含了 lib.h 的实际实现。

// lib.c
#include <stdio.h>


void show_me_the_money(int money)
{ 
        
    printf("Show me USD %d from lib.c \n", money);
}

然后,show_me_poor.c 调用了 lib 里面的函数。

// show_me_poor.c
#include "lib.h"
int main()
{ 
        
    int money = 5;
    show_me_the_money(money);
}

最后,我们把 lib.c 编译成了一个动态链接库,也就是 .so 文件。

$ gcc lib.c -fPIC -shared -o lib.so
$ gcc -o show_me_poor show_me_poor.c ./lib.so

你可以看到,在编译的过程中,我们指定了一个 -fPIC 的参数。这个参数其实就是 Position Independent Code 的意思,也就是我们要把这个编译成一个地址无关代码。

然后,我们再通过 gcc 编译 show_me_poor 动态链接了 lib.so 的可执行文件。在这些操作都完成了之后,我们把 show_me_poor 这个文件通过 objdump 出来看一下。

$ objdump -d -M intel -S show_me_poor
    
……
0000000000400540 <show_me_the_money@plt-0x10>:
  400540:       ff 35 12 05 20 00       push   QWORD PTR [rip+0x200512]        # 600a58 <_GLOBAL_OFFSET_TABLE_+0x8>
  400546:       ff 25 14 05 20 00       jmp    QWORD PTR [rip+0x200514]        # 600a60 <_GLOBAL_OFFSET_TABLE_+0x10>
  40054c:       0f 1f 40 00             nop    DWORD PTR [rax+0x0]

0000000000400550 <show_me_the_money@plt>:
  400550:       ff 25 12 05 20 00       jmp    QWORD PTR [rip+0x200512]        # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>
  400556:       68 00 00 00 00          push   0x0
  40055b:       e9 e0 ff ff ff          jmp    400540 <_init+0x28>
……
0000000000400676 <main>:
  400676:       55                      push   rbp
  400677:       48 89 e5                mov    rbp,rsp
  40067a:       48 83 ec 10             sub    rsp,0x10
  40067e:       c7 45 fc 05 00 00 00    mov    DWORD PTR [rbp-0x4],0x5
  400685:       8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  400688:       89 c7                   mov    edi,eax
  40068a:       e8 c1 fe ff ff          call   400550 <show_me_the_money@plt>
  40068f:       c9                      leave  
  400690:       c3                      ret    
  400691:       66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
  400698:       00 00 00 
  40069b:       0f 1f 44 00 00          nop    DWORD PTR [rax+rax*1+0x0]
……

我们还是只关心整个可执行文件中的一小部分内容。你应该可以看到,在 main 函数调用 show_me_the_money 的函数的时候,对应的代码是这样的:

call   400550 <show_me_the_money@plt>

这里后面有一个 @plt 的关键字,代表了我们需要从 PLT,也就是程序链接表(Procedure Link Table)里面找要调用的函数。对应的地址呢,则是 400550 这个地址。

那当我们把目光挪到上面的 400550 这个地址,你又会看到里面进行了一次跳转,这个跳转指定的跳转地址,你可以在后面的注释里面可以看到,GLOBAL_OFFSET_TABLE+0x18。这里的 GLOBAL_OFFSET_TABLE,就是我接下来要说的全局偏移表。

  400550:       ff 25 12 05 20 00       jmp    QWORD PTR [rip+0x200512]        # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>
  

在动态链接对应的共享库,我们在共享库的 data section 里面,保存了一张全局偏移表(GOT,Global Offset Table)。虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。所有需要引用当前共享库外部的地址的指令,都会查询 GOT,来找到当前运行程序的虚拟内存里的对应位置。而 GOT 表里的数据,则是在我们加载一个个共享库的时候写进去的。

不同的进程,调用同样的 lib.so,各自 GOT 里面指向最终加载的动态链接库里面的虚拟内存地址是不同的。

这样,虽然不同的程序调用的同样的动态库,各自的内存地址是独立的,调用的又都是同一个动态库,但是不需要去修改动态库里面的代码所使用的地址,而是各个程序各自维护好自己的 GOT,能够找到对应的动态库就好了。

我们的 GOT 表位于共享库自己的数据段里。GOT 表在内存里和对应的代码段位置之间的偏移量,始终是确定的。这样,我们的共享库就是地址无关的代码,对应的各个程序只需要在物理内存里面加载同一份代码。而我们又要通过各个可执行程序在加载时,生成的各不相同的 GOT 表,来找到它需要调用到的外部变量和函数的地址。

这是一个典型的、不修改代码,而是通过修改“地址数据”来进行关联的办法。它有点像我们在 C 语言里面用函数指针来调用对应的函数,并不是通过预先已经确定好的函数名称来调用,而是利用当时它在内存里面的动态地址来调用。

字符集和字符编码

ASCII 码只表示了 128 个字符,一开始倒也堪用,毕竟计算机是在美国发明的。然而随着越来越多的不同国家的人都用上了计算机,想要表示譬如中文这样的文字,128 个字符显然是不太够用的。于是,计算机工程师们开始各显神通,给自己国家的语言创建了对应的字符集(Charset)和字符编码(Character Encoding)。

字符集,表示的可以是字符的一个集合。比如“中文”就是一个字符集,不过这样描述一个字符集并不准确。想要更精确一点,我们可以说,“第一版《新华字典》里面出现的所有汉字”,这是一个字符集。这样,我们才能明确知道,一个字符在不在这个集合里面。比如,我们日常说的 Unicode,其实就是一个字符集,包含了 150 种语言的 14 万个不同的字符。

而字符编码则是对于字符集里的这些字符,怎么一一用二进制表示出来的一个字典。我们上面说的 Unicode,就可以用 UTF-8、UTF-16,乃至 UTF-32 来进行编码,存储成二进制。所以,有了 Unicode,其实我们可以用不止 UTF-8 一种编码形式,我们也可以自己发明一套 GT-32 编码,比如就叫作 Geek Time 32 好了。只要别人知道这套编码规则,就可以正常传输、显示这段代码。

同样的文本,采用不同的编码存储下来。如果另外一个程序,用一种不同的编码方式来进行解码和展示,就会出现乱码。这就好像两个军队用密语通信,如果用错了密码本,那看到的消息就会不知所云。在中文世界里,最典型的就是“手持两把锟斤拷,口中疾呼烫烫烫”的典故。

电路与运算

可以说,电报是现代计算机的一个最简单的原型。它和我们现在使用的现代计算机有很多相似之处。我们通过电路的“开”和“关”,来表示“1”和“0”。就像晶体管在不同的情况下,表现为导电的“1”和绝缘的“0”的状态。

其实加法器就是想一个办法把这三排开关电路连起来

讲与、或、非门的时候,我们很容易就能和程序里面的“AND(通常是 & 符号)”“ OR(通常是 | 符号)”和“ NOT(通常是 ! 符号)”对应起来。可能你没有想过,为什么我们会需要“异或(XOR)”,这样一个在逻辑运算里面没有出现的形式,作为一个基本电路。其实,

算完个位的输出还不算完,输入的两位都是 11 的时候,我们还需要向更左侧的一位进行进位。那这个就对应一个与门,也就是有且只有在加数和被加数都是 1 的时候,我们的进位才会是 1。

所以,通过一个异或门计算出个位,通过一个与门计算出是否进位,我们就通过电路算出了一个一位数的加法。于是,我们把两个门电路打包,给它取一个名字,就叫作半加器(Half Adder)。

你肯定很奇怪,为什么我们给这样的电路组合,取名叫半加器(Half Adder)?莫非还有一个全加器(Full Adder)么?你猜得没错。半加器可以解决个位的加法问题,但是如果放到二位上来说,就不够用了。我们这里的竖式是个二进制的加法,所以如果从右往左数,第二列不是十位,我称之为“二位”。对应的再往左,就应该分别是四位、八位。

二位用一个半加器不能计算完成的原因也很简单。因为二位除了一个加数和被加数之外,还需要加上来自个位的进位信号,一共需要三个数进行相加,才能得到结果。但是我们目前用到的,无论是最简单的门电路,还是用两个门电路组合而成的半加器,输入都只能是两个 bit,也就是两个开关。那我们该怎么办呢?

实际上,解决方案也并不复杂。我们用两个半加器和一个或门,就能组合成一个全加器。第一个半加器,我们用和个位的加法一样的方式,得到是否进位 X 和对应的二个数加和后的结果 Y,这样两个输出。然后,我们把这个加和后的结果 Y,和个位数相加后输出的进位信息 U,再连接到一个半加器上,就会再拿到一个是否进位的信号 V 和对应的加和后的结果 W。

int add(int a,int b)
{ 
        
    int carry,add;
    do{ 
        
        add=a^b;
        carry=(a&b)<<1;
        a=add;
        b=carry;
    }while(carry!=0);
    return add;
}

有了全加器,我们要进行对应的两个 8 bit 数的加法就很容易了。我们只要把 8 个全加器串联起来就好了。个位的全加器的进位信号作为二位全加器的输入信号,二位全加器的进位信号再作为四位的全加器的进位信号。这样一层层串接八层,我们就得到了一个支持 8 位数加法的算术单元。如果要扩展到 16 位、32 位,乃至 64 位,都只需要多串联几个输入位和全加器就好了。

十进制中的 13 乘以 9,计算的结果应该是 117。我们通过转换成二进制,然后列竖式的办法,来看看整个计算的过程是怎样的。

在 13×9 这个例子里面,被乘数 13 表示成二进制是 1101,乘数 9 在二进制里面是 1001。最右边的个位是 1,所以个位乘以被乘数,就是把被乘数 1101 复制下来。因为二位和四位都是 0,所以乘以被乘数都是 0,那么保留下来的都是 0000。乘数的八位是 1,我们仍然需要把被乘数 1101 复制下来。不过这里和个位位置的单纯复制有一点小小的差别,那就是要把复制好的结果向左侧移三位,然后把四位单独进行乘法加位移的结果,再加起来,我们就得到了最终的计算结果。

对应到我们之前讲的数字电路和 ALU,你可以看到,最后一步的加法,我们可以用上一讲的加法器来实现。乘法因为只有“0”和“1”两种情况,所以可以做成输入输出都是 4 个开关,中间用 1 个开关,同时来控制这 8 个开关的方式,这就实现了二进制下的单位的乘法。

我们可以用一个开关来决定,下面的输出是完全复制输入,还是将输出全部设置为 0

至于位移也不麻烦,我们只要不是直接连线,把正对着的开关之间进行接通,而是斜着错开位置去接就好了。如果要左移一位,就错开一位接线;如果要左移两位,就错开两位接线。

把对应的线路错位连接,就可以起到位移的作用

这样,你会发现,我们并不需要引入任何新的、更复杂的电路,仍然用最基础的电路,只要用不同的接线方式,就能够实现一个“列竖式”的乘法。而且,因为二进制下,只有 0 和 1,也就是开关的开和闭这两种情况,所以我们的计算机也不需要去“背诵”九九乘法口诀表,不需要单独实现一个更复杂的电路,就能够实现乘法。

为了节约一点开关,也就是晶体管的数量。实际上,像 13×9 这样两个四位数的乘法,我们不需要把四次单位乘法的结果,用四组独立的开关单独都记录下来,然后再把这四个数加起来。因为这样做,需要很多组开关,如果我们计算一个 32 位的整数乘法,就要 32 组开关,太浪费晶体管了。如果我们顺序地来计算,只需要一组开关就好了。

我们先拿乘数最右侧的个位乘以被乘数,然后把结果写入用来存放计算结果的开关里面,然后,把被乘数左移一位,把乘数右移一位,仍然用乘数去乘以被乘数,然后把结果加到刚才的结果上。反复重复这一步骤,直到不能再左移和右移位置。这样,乘数和被乘数就像两列相向而驶的列车,仅仅需要简单的加法器、一个可以左移一位的电路和一个右移一位的电路,就能完成整个乘法。

乘法器硬件结构示意图

你看这里画的乘法器硬件结构示意图。这里的控制测试,其实就是通过一个时钟信号,来控制左移、右移以及重新计算乘法和加法的时机。我们还是以计算 13×9,也就是二进制的 1101×1001 来具体看。

你应该很容易就能发现,在这个乘法器的实现过程里,我们其实就是把乘法展开,变成了“加法 + 位移”来实现。我们用的是 4 位数,所以要进行 4 组“位移 + 加法”的操作。而且这 4 组操作还不能同时进行。因为下一组的加法要依赖上一组的加法后的计算结果,下一组的位移也要依赖上一组的位移的结果。这样,整个算法是“顺序”的,每一组加法或者位移的运算都需要一定的时间。

所以,最终这个乘法的计算速度,其实和我们要计算的数的位数有关。比如,这里的 4 位,就需要 4 次加法。而我们的现代 CPU 常常要用 32 位或者是 64 位来表示整数,那么对应就需要 32 次或者 64 次加法。比起 4 位数,要多花上 8 倍乃至 16 倍的时间。

换个我们在算法和数据结构中的术语来说就是,。这里的 N,就是乘法的数里面的位数。

前面顺序乘法器硬件的实现办法,就好像体育比赛里面的单败淘汰赛。只有一个擂台会存下最新的计算结果。每一场新的比赛就来一个新的选手,实现一次加法,实现完了剩下的还是原来那个守擂的,直到其余 31 个选手都上来比过一场。如果一场比赛需要一天,那么一共要比 31 场,也就是 31 天。

加速的办法,就是把比赛变成像世界杯足球赛那样的淘汰赛,32 个球队捉对厮杀,同时开赛。这样一天一下子就淘汰了 16 支队,也就是说,32 个数两两相加后,你可以得到 16 个结果。后面的比赛也是一样同时开赛捉对厮杀。只需要 5 天,也就是 O(log2N) 的时间,就能得到计算的结果。但是这种方式要求我们得有 16 个球场。因为在淘汰赛的第一轮,我们需要 16 场比赛同时进行。对应到我们 CPU 的硬件上,就是需要更多的晶体管开关,来放下中间计算结果。

通过并联更多的 ALU,加上更多的寄存器,我们也能加速乘法

上面我们说的并行加速的办法,看起来还是有点儿笨。我们回头来做一个抽象的思考。之所以我们的计算会慢,核心原因其实是“顺序”计算,也就是说,要等前面的计算结果完成之后,我们才能得到后面的计算结果。

最典型的例子就是我们上一讲讲的加法器。每一个全加器,都要等待上一个全加器,把对应的进入输入结果算出来,才能算下一位的输出。位数越多,越往高位走,等待前面的步骤就越多,这个等待的时间有个专门的名词,叫作门延迟(Gate Delay)。

每通过一个门电路,我们就要等待门电路的计算结果,就是一层的门电路延迟,我们一般给它取一个“T”作为符号。一个全加器,其实就已经有了 3T 的延迟(进位需要经过 3 个门电路)。而 4 位整数,最高位的计算需要等待前面三个全加器的进位结果,也就是要等 9T 的延迟。如果是 64 位整数,那就要变成 63×3=189T 的延迟。这可不是个小数字啊!

除了门延迟之外,还有一个问题就是。在上面的顺序乘法计算里面,如果我们想要用更少的电路,计算的中间结果需要保存在寄存器里面,然后等待下一个时钟周期的到来,控制测试信号才能进行下一次移位和加法,这个延迟比上面的门延迟更可观。

那么,我们有什么办法可以解决这个问题呢?实际上,在我们进行加法的时候,如果相加的两个数是确定的,那高位是否会进位其实也是确定的。对于我们人来说,我们本身去做计算都是顺序执行的,所以要一步一步计算进位。但是,计算机是连结的各种线路。我们不用让计算机模拟人脑的思考方式,来连结线路。

那怎么才能把线路连结得复杂一点,让高位和低位的计算同时出结果呢?怎样才能让高位不需要等待低位的进位结果,而是把低位的所有输入信号都放进来,直接计算出高位的计算结果和进位结果呢?

我们只要把进位部分的电路完全展开就好了。我们的半加器到全加器,再到加法器,都是用最基础的门电路组合而成的。门电路的计算逻辑,可以像我们做数学里面的多项式乘法一样完全展开。在展开之后呢,我们可以把原来需要较少的,但是有较多层前后计算依赖关系的门电路,展开成需要较多的,但是依赖关系更少的门电路。

我在这里画了一个示意图,展示了一下我们加法器。如果我们完全展开电路,高位的进位和计算结果,可以和低位的计算结果同时获得。这个的核心原因是电路是天然并行的,一个输入信号,可以同时传播到所有接通的线路当中。

C4 是前 4 位的计算结果是否进位的门电路表示

如果一个 4 位整数最高位是否进位,展开门电路图,你会发现,我们只需要 3T 的延迟就可以拿到是否进位的计算结果。而对于 64 位的整数,也不会增加门延迟,只是从上往下复制这个电路,接入更多的信号而已。看到没?我们通过把电路变复杂,就解决了延迟的问题。

这个优化,本质上是利用了电路天然的并行性。电路只要接通,输入的信号自动传播到了所有接通的线路里面,这其实也是硬件和软件最大的不同。

无论是这里把对应的门电路逻辑进行完全展开以减少门延迟,还是上面的乘法通过并行计算多个位的乘法,都是把我们完成一个计算的电路变复杂了。而电路变复杂了,也就意味着晶体管变多了。

之前很多同学在我们讨论计算机的性能问题的时候,都提到,为什么晶体管的数量增加可以优化计算机的计算性能。实际上,这里的门电路展开和上面的并行计算乘法都是很好的例子。

你可以在 Linux 下打开 Python 的命令行 Console,也可以在 Chrome 浏览器里面通过开发者工具,打开浏览器里的 Console,在里面输入“0.3 + 0.6”,然后看看你会得到一个什么样的结果。

>>> 0.3 + 0.6
0.8999999999999999

不知道你有没有大吃一惊,这么简单的一个加法,无论是在 Python 还是在 JavaScript 里面,算出来的结果居然不是准确的 0.9,而是 0.8999999999999999 这么个结果。这是为什么呢?

有一个很直观的想法,就是我们用 4 个比特来表示 0~9 的整数,那么 32 个比特就可以表示 8 个这样的整数。然后我们把最右边的 2 个 0~9 的整数,当成小数部分;把左边 6 个 0~9 的整数,当成整数部分。这样,我们就可以用 32 个比特,来表示从 0 到 999999.99 这样 1 亿个实数了。

这种用二进制来表示十进制的编码方式,叫作BCD 编码(Binary-Coded Decimal)。其实它的运用非常广泛,最常用的是在超市、银行这样需要用小数记录金额的情况里。在超市里面,我们的小数最多也就到分。这样的表示方式,比较直观清楚,也满足了小数部分的计算。

不过,这样的表示方式也有几个缺点。

第一,这样的表示方式有点“浪费”。本来 32 个比特我们可以表示 40 亿个不同的数,但是在 BCD 编码下,只能表示 1 亿个数,如果我们要精确到分的话,那么能够表示的最大金额也就是到 100 万。如果我们的货币单位是人民币或者美元还好,如果我们的货币单位变成了津巴布韦币,这个数量就不太够用了。

第二,这样的表示方式没办法同时表示很大的数字和很小的数字。我们在写程序的时候,实数的用途可能是多种多样的。有时候我们想要表示商品的金额,关心的是 9.99 这样小的数字;有时候,我们又要进行物理学的运算,需要表示光速,也就是 3×108 这样很大的数字。那么,我们有没有一个办法,既能够表示很小的数,又能表示很大的数呢?

答案当然是有的,就是你可能经常听说过的浮点数(Floating Point),也就是 float 类型。

在计算机里,我们也可以用一样的办法,用科学计数法来表示实数。浮点数的科学计数法的表示,有一个 IEEE 的标准,它定义了两个基本的格式。一个是用 32 比特表示单精度的浮点数,也就是我们常常说的 float 或者 float32 类型。另外一个是用 64 比特表示双精度的浮点数,也就是我们平时说的 double 或者 float64 类型。

双精度类型和单精度类型差不多,这里,我们来看单精度类型,双精度你自然也就明白了。

第一部分是一个符号位,用来表示是正数还是负数。我们一般用 s 来表示。在浮点数里,我们不像正数分符号数还是无符号数,所有的浮点数都是有符号的。

接下来是一个 8 个比特组成的指数位。我们一般用 e 来表示。8 个比特能够表示的整数空间,就是 0~255。我们在这里用 1~254 映射到 -126~127 这 254 个有正有负的数上。因为我们的浮点数,不仅仅想要表示很大的数,还希望能够表示很小的数,所以指数位也会有负数。

我们可以做一个简单的实验,用一个循环相加 2000 万个 1.0f,最终的结果会是 1600 万左右,而不是 2000 万。这是因为,加到 1600 万之后的加法因为精度丢失都没有了。这个代码比起上面的使用 2000 万来加 1.0 更具有现实意义。

public class FloatPrecision { 
        
  public static void main(String[] args)  

标签: 矩阵等压式绝缘水冷电阻器安硅x电容集成电路1515解码器的耦合电容可以去掉吗主板薄膜电阻封装074k7l电阻

锐单商城拥有海量元器件数据手册IC替代型号,打造 电子元器件IC百科大全!

锐单商城 - 一站式电子元器件采购平台