初识计算机组成原理-指令和运算篇

3,021 阅读1小时+

计算机的硬件组成

undefined

现代计算机的基本组成部分其实主要由三部分组成:CPU,内存,主板。

你撰写的程序,打开的任何PC端应用。都要加载到内存中才能运行,存放在内存中的程序及其数据需要被CPU读取,CPU计算完之后还要把对应的数据写回到内存。主板的作用就是承载二者,因为他们不能互相嵌入到对方中。

主板上的芯片组总线解决了CPU及内存之间如何通信的问题。芯片组控制了数据传输的流转,也就是数据从哪里到哪里的问题。总线则是实际数据传输的高速公路。

具体流程:CPU读取内存中的二进制指令,然后译码,通过控制信号操作对应的运算原件以及存储单元进行操作。

冯诺依曼体系结构

我们现在所使用的机器既叫图灵机也叫冯诺依曼机,两者是不同的计算机抽象。冯诺依曼侧重于硬件抽象,图灵机则侧重于计算抽象。

冯诺依曼体系结构也叫存储程序计算机,起源于冯诺依曼发表的一片文章即“第一份草案”,文中描述了他心目中的一台计算机应该是什么样。即它是“可存储”,“可编程”的计算机,并且还其中还说了一台计算机应该有哪些部分组成,即:运算器,控制器,存储器,输入设备,输出设备五部分。

他认为现代计算机应该是一个“可编程”的计算机,是一个“可存储”的计算机。即也叫存储程序计算机。因为过去的计算机电路是焊死在电路板的,如同现在的计算器,如果要做其他的操作,那么就需要重新焊接电路,它是不可编程的计算机。再后来的计算机是“插拔式”的计算机,需要用什么程序必须得插入该程序的组件才可即类似我们玩的游戏机。这样程序无法保存在计算机上,每次使用的时候还需要插拔的方式才可以。它是不可存储的计算机。

如图即为冯诺依曼体系结构:

undefined

图灵机

图灵是一位数学家,他对于计算机的设想,并没有考虑计算机的硬件基础,而是只考虑了计算机在数学计算模型上的可行性。即只思考了作为一个“计算机”,他应该做的工作,和怎么工作。也是因为在那个年代,计算机原本的作用就就是帮助数学家进行运算的一个工具产物。图灵只思考了计算机的计算模型,及计算机所谓的“计算”的理论逻辑的实现方法。

图灵机可以看做由一条两端可无限延长的带子,它有一个读写头,以及一组控制读写头工作的命令组成,是一种抽象的计算模型,即将人们使用纸笔进行的数学运算由一个虚拟的机器代替。

它证明了通用计算理论,肯定了计算机实现的可能性。同时它给出了计算机应有的主要架构,引入了读写,算法,程序语言的概念。极大的突破了过去的计算机的设计理念。

其实图灵机本质上是状态机,计算机理论模型,冯诺依曼体系则更像是图灵机的具体物理实现。包括运算,控制,存储,输入,输出五个部分。冯诺依曼体系相对之前的计算机最大的创新在于程序和数据的存储。从此实现机器的内部编程。图灵机的纸带应对冯诺依曼体系中的存储,读写头对应输入输出规则及(读取一个符号后,做了什么)运算,纸带怎么移动则对应着控制。理论上图灵机可以模拟人类的所有计算过程,无论复杂与否。

undefined

我认为,图灵机就是一个抽象的“思维实验”,而冯·诺依曼机就是对应着这个“思维实验”的“物理实现”。如果我们把“图灵机”当成“灵魂”,代表计算机最抽象的本质,那么“冯诺伊曼机”就是“肉体”,代表了计算机最具体的本质。这两者之间颇有理论物理学家和实验物理学家的合作关系的意思,可谓是一个问题的两面。

芯片组,总线

芯片组

芯片组是主板的核心组成部分,联系CPU及其他周边设备的运作。过去主板上最重要的芯片组就是南北桥芯片组。

南北桥芯片组->主板上的两个主要芯片组,靠上方的叫北桥,下方的叫南桥。

  • 北桥负责与CPU通信,并且链接高速设备(内存,显卡),并且与(I/O操作)南桥通信。
  • 南桥负责与低速设备(硬盘/外部IO设备,USB等设备)通信,并且与北桥通信。

总线

主板的芯片组和总线解决了CPU和内存通信的问题(北桥),芯片组控制数据传输的流转(从哪来,到哪儿去),总线则是实际数据传输的高速公路。

CPU

指令集,微架构

又称指令集体系,是计算机体系结构中与程序设计有关的部分包含了基本数据类型,指令集,寄存器,寻址模式,存储体系,中断,异常处理以及外部I/O。指令集架构包含一系列的opcode即操作码(机器语言),以及由特定处理器执行的基本命令。

不同的处理器“家族”——例如Intel IA-32和x86-64、IBM/Freescale Power和ARM处理器家族——有不同的指令集架构。

指令集体系与微架构(一套用于执行指令集的微处理器设计方法)不同。使用不同微架构的计算机可以共享一种指令集。 例如,Intel的Pentium和AMD的AMD Athlon,两者几乎采用相同版本的x86指令集体系,但是两者在内部的硬件设计上有本质的区别。

一些虚拟机支持基于Smalltalk,Java虚拟机,微软的公共语言运行时虚拟机所生成的字节码,他们的指令集体系将bytecode(字节码)从作为一般手段的代码路径翻译成本地的机器语言,并通过解译执行并不常用的代码路径,全美达以相同的方式开发了基于x86指令体系的VLIW处理器。

指令集是所有机器指令的集合,汇编可以认为是指令集的抽象,为了方便开发人员使用。

指令集的优劣设计:对微处理器而言可以视为有两种指令集。第一种是复杂指令集,拥有许多不同的指令。在1970年代,许多机构,像是IBM,发现有许多指令是不需要的。结果就产生了精简指令集,它所包含的指令就比较少。精简的指令集可以提供比较高的速度,使处理器的尺寸缩小,以及较少的电力损耗。然而,比较复杂的指令集较容易使工作更完善,存储器及缓存的效率较高,以及较为简单的代码。

一些指令集保留了一个或多个的opcode,以运行系统调用或软件中断。

指令集的分类:

复杂指令集计算机包含许多应用程序中很少使用的特定指令,由此产生的缺陷是指令长度不固定。精简指令集计算机通过只执行在程序中经常使用的指令来简化处理器的结构,而特殊操作则以子程序的方式实现,它们的特殊使用通过处理器额外的执行时间来弥补。理论上的重要类型还包括最小指令集计算机与单指令集计算机,但都未用作商业处理器。另外一种派生类型是超长指令字,处理器接受许多经过编码的指令并通过检索提取出一个指令字并执行。

X86指令集和ARM指令集区别例子:

A要坐车去B

  • arm:准备四个轮子和一个座位 > 把A放在座位上 > 座位放在四个轮子上 > 去B
  • x86: 仓库找车 > 坐车 > 去B

同样的事情,虽然结果是一样把A送到了目的地,但是arm没有仓库,什么都要造,x86是有一堆仓库,随便用,差异在于arm过程中消耗少,因为就个车壳轮子,而x86那了车出来,但是车上的灯光,空调,其余的空座位是本次执行中不需要的东西,也一起弄进去了,所以占用资源比arm大,但是做复杂的事因为x86啥都有,所以比arm开发更简单,统一标准的库也能让效率更高

指令集就是 add move这些汇编命令对应的二进制命令串,微架构就是,在硬件层面这些二进制命令串是如何实现功能的,其实就是集成电路

参考:

Intel,AMD

  • Intel, AMD 的 CPU,大多是为 CISC 结构的 CPU,因为他们主营 PC 端。
  • ARM 则属于 RISC 结构的 CPU。

CISC 是复杂指令集CPU,指令较长,分成几个微指令去执行,开发程序比较容易(指令多的缘故)。通常一个指令需要好几个 Machine Cycle 才能执行。RISC 是精简指令集CPU,指令较短,如果内部的 pipe line 做得好,可以使得指令的译码与数据的处理较快,使得执行效率高。通常一个指令只要一个 Machine Cycle 就能够执行

近年来某些 CISC CPU,如 Intel 的 Pentium-Pro、AMD 的K5、K6实际上是改进了的CISC,也可以做到一个 Machine Cycle 能执行一个指令。ARM 本身不做CPU,只设计CPU架构,并授权给其他厂商做ARM架构的CPU,比如高通、联发科等我们熟知的CPU公司都是用ARM架构。

由於 ARM 的结构一开始就以省电为主,所以一般来说,执行速度没有 Intel 或是 AMD 的 CPU 快。但是在手持式装置的世界,ARM 的省电加上以授权而非自制的联合军团方式,打败了所有的人,成为王者,现在进而慢慢的进入其他的领域。

参考:

CPU与GPU

CPU即中央处理器,GPU即图形处理器,现在的电脑,大部分GPU都集成在了CPU中也叫集成显卡,后来原本的GPU即属于北桥的内存控制器等作为一支独立的芯片封装到了CPU基板上。所以后来的及其的主板上没有南北桥之分了,只剩下了PCH芯片即过去的南桥。当然如果你的PC机要运行一些大型游戏,或者有一些对GPU要求较高的工作的话,也可以配置独立的GPU卡到主板上。

过去:

  • CPU — 北桥 — 内存
  • CPU — 北桥 — 显卡
  • CPU — 北桥 — 南桥 - 硬盘
  • CPU — 北桥 — 南桥 - 网卡
  • CPU — 北桥 — 南桥 - 外部IO设备

CPU的好坏决定 -> 主频高,缓存大,核心数多。CPU一般安装在主板的CPU插槽中。

数据通路:其实就是连接了整个运算器与控制器,方便我们程序的运转和计算,并最终组成了CPU。

CPU一般被叫做超大规模集成电路,由一个个晶体管组合而成,CPU的计算过程其实就是让晶体管中的“开关”信号不断的去“打开”和“关闭”,来组合完成各种运算和功能,这里的“打开”及“关闭”操作的快慢就是由CPU主频来影响。

控制器:一条条指令执行的控制过程,就是由计算机五大组件之一的控制器来控制的。

CPU 的核数线程数

要看一台PC机的具体CPU核数以及线程数可以通过任务管理器界面看到,也可以通过计算机右键属性的设备管理器中看到(仅能看到线程数)。或者通过如下命令看到

wmic
cpu get *
-----------对应属性
NumberOfCores
NumberLogicProcessors

CPU 主频

CPU 的主频即内核工作的时钟频率,通常所说的CPU是多少兆赫的,这里所谓的兆赫就是描述的CPU主频,CPU型号后面跟着的2.4 GHZ即主频的数字描述。

主频并不直接代表CPU的运算速度,所以也会有CPU主频高但是CPU的运算速度慢的情况,主频仅是CPU性能表现的一方面。

CPU 线程和 Java 线程的关系

Java 中的所有线程均在JVM进程中,CPU调度的是进程中的线程。

CPU线程数和Java线程数并没有直接关系,CPU采用分片机制执行线程,给每个线程划分很小的时间颗粒去执行,但是真正的项目中,一个程序要做很多的的操作,读写磁盘、数据逻辑处理、出于业务需求必要的休眠等等操作,当程序正在执行的线程进入到I/O操作的时候,线程随之进入阻塞状态,此时CPU会做上下文切换,以便处理其他线程的任务;当I/O操作完成后,CPU会收到一个来自硬盘的中断信号,并进入中断处理例程,手头正在执行的线程则可能因此被打断,回到 ready 队列。而先前因 I/O 而阻塞等待的线程随着 I/O 的完成也再次回到 就绪队列,这时 CPU 在进行线程调度的时候则可能会选择它来执行。

进程与线程

线程是操作系统最小的调度单位,进程则是操作系统资源分配的对小单位。

进程:进程是操作系统分配资源的基本单位,每个进程拥有虚拟后的独立的内存空间,存储空间,CPU资源。各种PC端应用均是一个独立的进程。

线程:是CPU调度的基本单位,同意进程的各个线程共享进程内部的资源,线程间的通讯远小于进程间的。因为(各个线程共享进程内部的资源)。所以在多线程并发的情况下,需要额外关注对于共享资源的保护问题,尤其是全局变量。

超线程

Intel的超线程技术,目的是为了更充分地利用一个单核CPU的资源。CPU在执行一条机器指令时,并不会完全地利用所有的CPU资源,而且实际上,是有大量资源被闲置着的。超线程技术允许两个线程同时不冲突地使用CPU中的资源。比如一条整数运算指令只会用到整数运算单元,此时浮点运算单元就空闲了,若使用了超线程技术,且另一个线程刚好此时要执行一个浮点运算指令,CPU就允许属于两个不同线程的整数运算指令和浮点运算指令同时执行,这是真的并行。我不了解其它的硬件多线程技术是怎么样的,但单就超线程技术而言,它是可以实现真正的并行的。但这也并不意味着两个线程在同一个CPU中一直都可以并行执行,只是恰好碰到两个线程当前要执行的指令不使用相同的CPU资源时才可以真正地并行执行

本质上是一个物理核在跑一个线程时,同时利用闲置的运算单元管跑其他指令,这样就可以提升效能。

性能和功耗

对于计算机而言:性能 即响应时间的倒数。有两个指标来进行恒定

  • 响应时间:即任务的执行时间,让计算机处理的更快。
  • 吞吐率:即带宽,让计算机处理的更多。

其中吞吐率的提升有很多种方式,多核处理器就是其中一种。即我们现在处理器都是6核8核等,同一时间可以使CPU执行多个任务,相应的响应时间变短也同样可以增加计算机的吞吐率,但现在CPU在响应时间上的提升却遇到了瓶颈,“摩尔定律”有点儿不再适用了。

性能 = 1 / 响应时间。响应时间越短,性能的数值就越大

理论上程序运行时间 = 程序在用户态运行指令的时间 + 程序在内核态运行指令的时间。

但受线程调度的影响,CPU在同一时间会有很多的Task在执行,不是只执行特定程序的指令,并且同一台计算机可能CPU满载执行,也能会降频执行。并且程序运行时间也会受到相应的主板和内存的影响。如下图:

程序的 CPU 执行时间 = CPU 时钟周期数 × 单位时钟周期时间。

比如Intel Core - i7 - 7700HQ 2.8GHZ,这里的2.8GHZ粗浅理解即CPU在一秒里可以执行的简单指令数是2.8G条。准确说即CPU的一个“钟表”能够识别出来的最小时间间隔。

计算机的计时单位:CPU 时钟

时钟周期时间: 在CPU内部,和我们戴的电子石英表类似,有一个叫晶体振荡器的东西简称“晶振”,晶振的每一次“滴答”即电子石英表的时钟周期时间(晶振时间)。在2.8GHZ主频的CPU上,这个时钟周期时间就是1/2.8GHZ。CPU就是按照这个“时钟”提示的时间来进行自己的操作,主频越高意味着这个表走的越快,CPU也就“被逼”着走的也快,CPU越快散热压力当然也越大。

CPU时钟周期数 = 指令数 * 指令的平均时钟周期数(CPI)。

这里说了指令的平均时钟周期数,所以我们就知道不同的指令执行时间是不同的,即所花费的时钟周期数是不同的,具体到计算机,乘法的时钟周期数就要多于加法。不过现代的CPU通过流水线技术可以使得单个命令的执行需要的CPU时钟周期数更少了。

一个程序包含多条语句,一条语句可能对应多条指令,一条CPU指令可能需要多个CPU时钟周期才能完成。

程序的CPU执行时间: 指令数 * 指令的平均时钟周期数(CPI) * 时钟周期时间

由上面的公式我们知道,如果想要减少程序的CPU执行时间的话那么就要从以上三点着手。但是指令数是由不同编译器所决定的,时钟周期时间则是由CPU主频的高低来决定的,而每条指令的平均时钟周期数我们则可以通过流水线技术来优化。

摩尔定律:其中摩尔定律就一直不断在提升计算机主频,英特尔创始人之一 戈登丶摩尔 曾说:当价格不变时,集成电路上可容纳的元器件的数目每隔18-24个月便会增加一倍,性能也将提升一倍。

由以上分析可知,CPU主频的提升可以有效的提升计算机的性能,但需要注意的是由于机器功耗的影响, 并非计算机的主频越高,计算机的性能就越强,主频的提高意味着CPU的功耗散热也一并提高了,但CPU本身的散热程度可能并跟不上这样大幅度散热的提升,这就制约了CPU主频不能一味的提升,过高的主频也发挥不出他应有的性能,甚至可能更低。

CPU功耗 ~= 1/2 * 负载电容 * 电压的平方 * 开关平率 * 晶体管数量

可以有效提升CPU性能的方式:

制程:

纳米制程,以14nm为例,其制程是指在芯片中,线最小可以做到14纳米的尺寸,缩小晶体管可以减少耗电量(晶体管一定的单位面积中),同时可以提升信号量在电路间的传输速度,缩小制程后,晶体管之间的电容也会更低,从而提升他们之间的开关频率。可知功耗与电容成正比,所以传输速度更快,还更省电。

要想提升计算机性能,从响应时间上已经很难突破了,所以后来开发人员针对计算机的吞吐率进行了着手。即通过超线程的多核手段来提升计算机的性能,也是通过并行提高性能的一种手段。

阿姆达尔定律:

并行优化,并不是所有的问题都可以通过并行去优化。

  • 条件1:需要进行的计算本身可以进行分解成几个并行的任务,如乘法可以分解成多个加法。
  • 条件2:需要能够分解的计算确保最后可以合并在一起。
  • 条件3:“汇总”阶段无法再并行优化,只能单步执行。

优化后的执行时间 = 受优化影响的执行时间/加速倍数(并行处理数) + 不受影响的执行时间。

计算机性能的提升方式,在“摩尔定律”和“并行计算”之外,还有加速大概率事件我通过流水线提高性能通过预测提高性能的方法

编译器

汇编器是一种工具程序,用于将汇编语言源程序转换为机器语言。机器语言是一种数字语言,专门设计成能被计算机处理器(CPU)理解。所有 x86 处理器都理解共同的机器语言。

汇编语言包含用短助记符如 ADD、MOV、SUB 和 CALL 书写的语句。汇编语言与机器语言是一对一的关系:每一条汇编语言指令对应一条机器语言指令。这就意味着不同型号的处理器如果所使用的机器语言(指令集)不同的话,那么他们的汇编语言也绝不相同。

高级语言如 Python、C++ 和 Java与汇编语言和机器语言的关系是一对多。比如,C++的一条语句就会扩展为多条汇编指令或机器指令。一种语言,如果它的源程序能够在各种各样的计算机系统中进行编译和运行,那么这种语言被称为是可移植的。

汇编语言不是可移植的,因为它是为特定处理器系列设计的。目前广泛使用的有多种不同的汇编语言,每一种都基于一个处理器系列。 对于一些广为人知的处理器系列如 Motorola 68×00、x86、SUN Sparc、Vax 和 IBM-370,汇编语言指令会直接与该计算机体系结构相匹配,或者在执行时用一种被称为微代码解释器的处理器内置程序来进行转换。

要让一段C语言程序在一个 Linux操作系统上跑起来,我们需要把整个程序翻译成一个汇编语言的程序,这个过程我们一般叫编译成汇编代码。针对汇编代码,我们可以再用汇编器翻译成机器码。这些机器码由“0”和“1”组成的机器语言表示。对于C语言也有编译器可以直接由C语言编译为机器语言直接执行。这一条条机器码,就是一条条的计算机指令的组成,类似我们汉字常用的不过4,5千,总共也才1W+,就能帮助我们完成几十万,上百万的小说及文集的编写。这样一串串的16进制数字,就是我们CPU能够真正认识的计算机指令。为了读起来方便,我们一般把对应的二进制数,用 16 进制表示。

解释型语言,是通过解释器在程序运行的时候逐句翻译,而Java这样使用虚拟机的语言,则是由虚拟机对编译出来的字节码进行解释成机器码来最终执行。


我们日常用的 Intel CPU,有 2000 条左右的 CPU 指令。常见的指令可以分成五大类:

  • 第一类是算术类指令。我们的加减乘除,在 CPU 层面,都会变成一条条算术类指令。
  • 第二类是数据传输类指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。
  • 第三类是逻辑类指令。逻辑上的与或非,都是这一类指令。
  • 第四类是条件分支类指令。日常我们写的“if/else”,其实都是条件分支类指令。
  • 最后一类是无条件跳转指令。写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时候,其实就是发起了一个无条件跳转指令。

来看一段汇编代码:

#include <time.h>
#include <stdlib.h>


int main()
{
  srand(time(NULL));
  int r = rand() % 2;
  int a = 10;
  if (r == 0)
  {
    a = 1;
  } else {
    a = 2;
  } 
==================
    if (r == 0)
  3b:   83 7d fc 00             cmp    DWORD PTR [rbp-0x4],0x0
  3f:   75 09                   jne    4a <main+0x4a>
    {
        a = 1;
  41:   c7 45 f8 01 00 00 00    mov    DWORD PTR [rbp-0x8],0x1
  48:   eb 07                   jmp    51 <main+0x51>
    }
    else
    {
        a = 2;
  4a:   c7 45 f8 02 00 00 00    mov    DWORD PTR [rbp-0x8],0x2
  51:   b8 00 00 00 00          mov    eax,0x0
    } 

机器语言,汇编语言,编译器:

过去编写程序是通过纸带打孔的方式,那么就只能通过“0,1”机器码来进行程序的编写,后来发展出了汇编语言,汇编语言是一种更接近人类语言的语言,用汇编器可以将汇编语言转为机器语言。汇编器则相当于翻译机的存在,可以根据具体的汇编指令转为计算机能识别的二进制码,即各CPU开发商提供的机器码,因为汇编代码和各CPU所能识别的机器码是一一对应的。

我们知道当下所流行的各种汇编语言都是与处理器所一对一的,即当初汇编语言的设计人员在编写汇编语言的时候是通过CPU开发商提供的指令集手册来对应开发定义的汇编语言,而各种型号不同的CPU所独有的指令集则是烧录到了CPU中。

  • C语言:C语言 -> 编译器编译 -> 汇编语言 -> 汇编器 -> 机器码 || C语言 -> 编译器编译 -> 机器码
  • Java:Java语言 -> 编译器编译 -> 字节码 -> JVM -> 机器码

参考:

  1. c.biancheng.net/view/450.ht…
  2. www.zhihu.com/question/38…
  3. zhuanlan.zhihu.com/p/53336801
  4. www.zhihu.com/question/39…
  5. blog.csdn.net/zaassd/arti…
  6. blog.csdn.net/u013678930/…

寄存器

可以先读:

内存、寄存器和存储器的区别

基本概念:

  • RAM(random access memory)即随机访问内存,这种存储器在断电时将丢失其存储内容,故主要用于存储短时间使用的程序。
  • ROM(Read-Only Memory)即只读内存,是一种只能读出事先所存数据的固态半导体存储器。

寄存器

寄存器是中央处理器内的组成部分。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和地址。在中央处理器的控制部件中,包含的寄存器有指令寄存器(IR)程序计数器(PC)

  1. 寄存器是和CPU一起的,只能存少量的信息,但是存取速度特别快;
  2. 存储器是指的是硬盘,U盘,软盘,光盘之类的外存储工具,速度最慢;
  3. 内存指的是内存条,由于一半的硬盘读取速度很慢,所以用先将硬盘里面的东西读取到内存条里面,然后在给CPU进行处理,这样是为了加快系统的运行速度;

各类存储器按照到cpu距离由近到远(访存速度由高到低)排列分别是寄存器,缓存(一,二,三级),主存,辅存。

其他优质解答:

寄存器的种类

逻辑上,我们可以认为,CPU 其实就是由一堆寄存器组成的。而寄存器就是 CPU 内部,由多个触发器或者锁存器组成的简单电路。

N 个触发器或者锁存器,就可以组成一个 N 位(Bit)的寄存器,能够保存 N 位的数据。比方说,我们用的 64 位 Intel 服务器,寄存器就是 64 位的。

一个 CPU 里面会有很多种不同功能的寄存器。其中有三个比较特殊的

  • PC 寄存器,也叫指令地址寄存器。用来存放下一条需要执行的计算机指令的内存地址。
  • 指令寄存器,用来存放当前正在执行的指令。
  • 条件码寄存器,用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。

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

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

而有些特殊指令,比如 J 类指令,也就是跳转指令,会修改 PC 寄存器里面的地址值。这样,下一条要执行的指令就不是从内存里面顺序加载的了。事实上,这些跳转指令的存在,也是我们可以在写程序的时候,使用 if…else 条件语句和 while/for 循环语句的原因。

CPU 中寄存器的数量并不多,一般使用的 Intel i7 CPU 只有 16 个 64 位寄存器,也就是16个8字节寄存器。

位运算

计算机中的数在内存中都是以二进制形式进行存储的,用位运算就是直接对整数在内存中的二进制位进行操作,因此其执行效率非常高,在程序中尽量使用位运算进行操作,这会大大提高程序的性能。当然可读性才是首要保证的目标。

位操作符

  • & 与运算 两个位都是 1 时,结果才为 1,否则为 0
1 0 0 1 1 
&
1 1 0 0 1 
------------------------------
1 0 0 0 1
  • |或运算 两个位都是 0 时,结果才为 0,否则为 1
1 0 0 1 1 
| 
1 1 0 0 1 
------------------------------
1 1 0 1 1
  • ^ 异或运算,两个位相同则为 0,不同则为 1
1 0 0 1 1 
^
1 1 0 0 1 
-----------------------------
0 1 0 1 0
  • ~ 取反运算,0 则变为 1,1 则变为 0
~ 1 0 0 1 1 
-----------------------------
  0 1 1 0 0
  • << 左移运算,向左进行移位操作,高位丢弃,低位补 0
int a = 8;
a << 3;
移位前:0000 0000 0000 0000 0000 0000 0000 1000
移位后:0000 0000 0000 0000 0000 0000 0100 0000
  • >> 右移运算,向右进行移位操作,对无符号数,高位补 0,对于有符号数,高位补符号位
unsigned int a = 8;
a >> 3;
移位前:0000 0000 0000 0000 0000 0000 0000 1000
移位后:0000 0000 0000 0000 0000 0000 0000 0001

int a = -8;
a >> 3;
移位前:1111 1111 1111 1111 1111 1111 1111 1000
移位后:1111 1111 1111 1111 1111 1111 1111 1111

在真实的程序里,压栈的不只有函数调用完成后的返回地址。比如函数A在调用B的时候,需要传输一些参数数据,这些参数数据在寄存器不够用的时候也会被压入栈中。整个函数 A 所占用的所有内存空间,就是函数 A 的栈帧

实际的程序栈布局,顶和底与我们的乒乓球桶相比是倒过来的。底在最上面,顶在最下面,这样的布局是因为栈底的内存地址是在一开始就固定的(内存地址偏大的那一边)。而一层层压栈之后,栈顶元素地址的内存地址是在逐渐变小而不是变大。

触发的StackOverFlow常见触发方式:函数的递归调用,在栈中声明一个非常占内存的变量(巨大数组)。

程序运行常见的优化方案:

把一个实际调用的函数产生的指令,直接插入到调用该函数的位置,来替换对应的函数调用指令。这种方案在如果被调用的函数中没有调用其他函数的情况下,还是可行的。这是一个常见的编译器进行自动优化的场景,叫函数内联。

这里编译器优化的具体痛点并非简单的少了一些指令的执行,而是函数频繁进出栈所花费时间的开销,因为相对于寄存器来说,内存是十分慢的。所以让CPU反复操作内存的话,开销还是很大的。所以上述文本着重提示了被调用函数没有调用其他函数的情况下,因为如果有调用的话,一是寄存器容量可能开销不够,二是还是有操作主存的瓶颈在。

代码演示:

#include <stdio.h>
int static add(int a, int b)
{
    return a+b;
}

int main()
{
    int x = 5;
    int y = 10;
    int u = add(x, y);
}

对应上面函数 add 的汇编代码,main 函数调用 add 函数时,add 函数入口在 0~1 行,add 函数结束之后在 12~13 行。

我们在调用第 34 行的 call 指令时,会把当前的 PC 寄存器里的下一条指令的地址压栈,保留函数调用结束后要执行的指令地址。而 add 函数的第 0 行,push rbp 这个指令,就是在进行压栈。这里的 rbp 又叫栈帧指针(Frame Pointer),是一个存放了当前栈帧位置的寄存器。push rbp 就把之前调用函数,也就是 main 函数的栈帧的栈底地址,压到栈中。

接着,第 1 行的一条命令 mov rbp, rsp 里,则是把 rsp 这个栈帧指针的值复制到 rbp 里,而 rsp(记录着add函数对应栈帧的入口位置) 始终会指向栈顶。这个命令意味着,rbp(实际变动指令) 这个栈帧指针指向的地址,变成当前最新的栈顶,也就是 add 函数的栈帧的栈底地址了。

而在函数 add 执行完成之后,又会分别调用第 12 行的 pop rbp 来将当前的栈顶出栈,这部分操作维护好了我们整个栈帧。然后,我们可以调用第 13 行的 ret 指令,这时候同时要把 call 调用的时候压入的 PC 寄存器里的下一条指令出栈,更新到 PC 寄存器中,将程序的控制权返回到出栈后的栈顶。

编译、链接和装载:拆解程序执行

C 语言的文件在编译后会生成以.o为尾缀的汇编语言文件,如 add_lib.o 以及 link_example.o 并不是一个可执行文件而是目标文件。只有通过链接器把多个目标文件以及调用的各种函数库链接起来,我们才能得到一个可执行文件。

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

  • 第一部分:由编译、汇编以及链接三个阶段组成。在这三个阶段完成之后,我们就生成了一个可执行文件。
  • 第二部分,我们通过装载器把可执行文件装载到内存中。CPU 从内存中读取指令和数据,来开始真正执行程序。

由上我们可以得知程序最终是通过装载器加载程序及数据到内存然后变成指令和数据的,所以其实我们生成的可执行代码也并不仅仅是一条条的指令。

ELF 格式

在Linux下,可执行文件和目标文件所使用的都是一种叫ELF的文件格式,这里面不仅存放了编译成的汇编指令,还保留了很多别的数据。

如函数名称addmain 等,以及定义的全局可以访问的变量名称,都存放在ELF格式文件里。这些名字和它们对应的地址,在 ELF 文件里面,存储在一个叫作符号表的位置里。符号表相当于一个地址簿,把名字和地址关联了起来。

  • 重定位表:发生在链接前,该文件中引用的多个函数的地址还不明确,由于各函数文件可能写在不同文件中,编译后分别为目标文件,除非经过连接器链接后才能形成一个完整的可执行文件,所以在初期把不确定的函数地址记录在这里,链接后进行更改。

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

所以一些文件即便是在同一计算机同一CPU上的不同操作系统上可能会出现一个可执行而一个不可执行的情况。根本原因在于不同OS的装载器所对应的能解析的文件格式也是不同的。Linux的装载器只能装载EFL的文件格式,而Windows是PE的。

PS:如果是编译型的高级语言大都是先编译成汇编语言,再汇编成机器码执行的。也有通过解释器,或者虚拟机,转换成实际的机器码指令执行的。

这里Java实现跨平台的机制则是:Java是通过实现不同平台上的虚拟机,然后即时翻译javac生成的中间代码来做到跨平台的。跨平台的工作被虚拟机开发人员来解决了(如同汇编)。各平台的Java虚拟机通过连接器链接对应的目标文件后,可以根据所在OS生成对应格式的可执行文件。来让装载器成功装载。

装载器需要满足两个要求。

由上可知,由于连接器的作用,装载器只需要把对应的指令和数据加载到内存里面来,让 CPU 去执行即可。但装载器还需满足两个要求:

  • 第一,可执行程序加载后占用的内存空间应该是连续的。因为处理器在执行指令的时候,程序计数器是顺序地一条一条指令执行下去,所以就意味着这些指令应该连续的存储在一起。
  • 第二,我们需要同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置。 虽然编译出来的指令里已经有了对应的各种各样的内存地址,但是实际加载的时候,我们其实没有办法确保,这个程序一定加载在哪一段内存地址上。因为我们现在的计算机通常会同时运行很多个程序,可能你想要的内存地址已经被其他加载了的程序占用了。

解决办法:

分段: 在内存中划分一段连续的内存空间,分配给装载的程序,把连续的内存空间和指令指向的内存地址进行映射。

其中指令里用到的内存地址叫作虚拟内存地址,实际内存硬件里面的物理空间叫做物理内存地址。程序员只需要关心虚拟内存地址就行了。所以我们只需要维护虚拟内存到物理内存的映射关系的起始地址和对应的空间大小就可以了。

问题:内存碎片

解决办法:

内存交换。即先把内存中某个程序所占用的内存写到硬盘上,然后再从硬盘上读回内存中,只不过读回来的时候要紧贴上一个应用所占用内存空间的后面,形成连续的内存占用。

问题:性能瓶颈,内存碎片和内存交换的空间太大,硬盘的读写速度太慢

解决办法:

内存分页。原理是少出现一些内存碎片。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点。和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小。而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。 这样一个连续并且尺寸固定的内存空间,就是页。一般页远小于程序大小只有几KB。

在内存分页的情况下,即使内存空间不够,只需要让现有的、正在运行的其他程序,通过内存交换释放出一些内存的页出来,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,让整个机器被内存交换的过程给卡住。

分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。

虚拟内存是指一段地址,但是没有加载到物理内存里的时候其实就是放在硬盘上。

虚拟内存,内存交换,内存分页三者结合下,其实运行一个应用程序需要用的必要内存是很少的,也是为什么我们优先的内存可以运行比我们内存大很多的应用的原因。

JVM也是一个可执行程序,同其他程序一样依赖于操作系统的内存管理和装载程序,它可以按自己的方式去规划它自身的内存空间给就Java程序使用,而无需考虑怎么映射到物理内存这些。这是承载他的操作系统需要做的事情,每个应用程序都有固定使用的内存空间的限度

链接可以分动、静,共享运行省内存

用连接器进行代码合并的时候,是静态链接的方式,相应的,也有对应的动态链接。当程序在进行装载的时候同一份代码如果多个程序都静态连接了一遍那么内存中将会有多分同样的代码占用内存,这对内存耗费也是非常大的。在动态链接的过程中,我们想要“链接”的,不是存储在硬盘上的目标文件代码,而是加载到内存中的共享库。

既然是共享代码,那么内存中只要装载一份即可。在程序链接的时候我们链接到该共享库的内存地址即可,不同系统下,共享库的文件尾缀不同。Windows是.dll,Linux下是.so

共享库文件代码要求:

编译出来的共享库文件的指令代码,是地址无关的。 原因是不同程序如果都用同一份共享代码库的话,不同程序该代码的虚拟地址是不同的,虽然物理地址上是相同的,但是对于该共享代码库的虚拟地址和物理地址的映射就无法维护了。

其中利用重定位表的代码就是与地址相关的代码。利用重定位表的代码在程序链接的时候,就把函数调用后要跳转访问的地址确定下来了,这意味着,如果这个函数加载到一个不同的内存地址,跳转就会失败。

对于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的。

相对地址: 动态代码库中的数据和指令的虚拟地址都是通过相对地址的方式互相访问的。各种指令中使用到的内存地址,给出的不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址。因为整个共享库是放在一段连续的虚拟内存地址中的,无论装载到哪一段地址,不同指令之间的相对地址都是不变的。

需要注意的是:虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。

全局偏移表(GOT): GOT表位于共享库的数据段里。所以使用动态链接的各个程序在共享库中生成各自的GOT,每个程序的GOT都不同。 而 GOT 表里的数据,则是在加载一个个共享库的时候写进去的。

所以如果当前运行程序的共享库指令需要用到外部的变量和函数地址的话,都会查询 GOT,来找到当前运行程序的虚拟内存地址。

不同的进程,调用同样的共享库,各自 GOT 里面指向最终加载的动态链接库里面的虚拟内存地址是不同的(因为各应用程序调用该函数的虚拟内存地址是不同的)。

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

像动态链接这样通过修改“地址数据”来进行间接跳转,去调用一开始不能确定位置代码的思路,在Java中,类似多态的实现。

如下代码:

public class DynamicCode {// 动态代码库
   
   private HashMap<String, Object> data; // 各类私有的数据部分 - 其中有一项是GOT
   
   public static void main(strs[] args) {// 公用代码部分
   
   }
}

二进制编码

二进制 -> 十进制: 把从右到左的第 N 位,乘上一个 2 的 N 次方,然后加起来。N 从 0 开始记位。

示例:

0011
=====
0×2^3 + 0×2^2 + 1×2^1 + 1×2^0

十进制 -> 二进制: 短除法。也就是,把十进制数除以 2 的余数,作为最右边的一位。然后用商继续除以 2,把对应的余数紧靠着刚才余数的右侧,这样递归迭代,直到商为 0 就可以了。然后余数序列从下到上组成的序列就是该整数的二进制表示。

原码,反码,补码

首先需要明白:在计算机中,数字都是用补码来存储的,而对于补码的表示方式一个字节(8bit)的数字,规定1000 0000就是-128。而且对于正数而言,反码,补码是其原码本身,负数通过补码的方式在计算机中存储

二进制负数的补码,等于该负数取反码再加1,也等于其正数按位取反再加1

原码: 0001 在原码中就表示为 +1。而 1001 最左侧的第一位是 1,所以它就表示 -1。这个其实就是整数的原码表示法。

原码表示法的问题

  • 0 有两种表示方法:-0(1000) 及 +0(0000) - 补码解决
  • +1(0001) 和 -1(1001) 相加不为 0 的情况。(1010 为 -2) - 反码解决

反码: 为了解决“正负相加不等于0”的问题,在“原码”的基础上,人们发明了“反码”。“反码”表示方式是用来处理负数的,符号位置不变,其余位置相反

undefined

这样正负两数相加不为0的情况就解决了

反码表示法的问题:

  • 但目前0还存在两种表示方法。

补码:同样是针对"负数"做处理的,从原来"反码"的基础上 +1。在补一位1的时候,要丢掉最高位(比如1111)。

undefined

这样就解决了+0和-0同时存在的问题,另外"正负数相加等于0"的问题,同样得到满足。同时还多了一位数 -8。

用原码的话,一个字节可以表示的范围是:-127 ~ 127,用补码的话表示的范围是:-128 ~ 127

二进制负数的补码,等于该负数取反码再加1,也等于其正数按位取反再加1。

正数的反码是其本身,负数的反码是在其原码的基础上, 符号位不变,其余各个位取反。

重点:

说了那么多,只是描述一下三者的区别及由来。因为我们从一开始就说了,计算机中是按补码来存储数据的,所以我们只要想办法快速搞清楚一个计算机中的二进制数的十进制是多少。

我们仍然通过最左侧第一位的0和1,来判断这个数的正负。但是,我们不再把这一位当成单独的符号位,在剩下几位计算出的十进制前加上正负号而是在计算整个二进制值的时候,在左侧最高位前面加个负号

比如,一个 4 位的二进制补码数值 1011,转换成十进制,就是 -1×2^3 + 0×2^2 + 1×2^1 + 1×2^0 = -5。如果最高位是 1,这个数必然是负数;最高位是 0,必然是正数。并且,只有 0000 表示 0,1000 在这样的情况下表示 -8。一个 4 位的二进制数,可以表示从 -8 到 7 这 16 个数,不会浪费一位。

需要注意的是:Java中~表示按位取反,但是按位取反并不是反码。

参考:

字符串的编码

ASCII(American Standard Code for Interchange,美国信息交换标准代码: 最早计算机只需要使用英文字符,加上数字和一些特殊符号,然后用8位的二进制,就能表示我们日常需要的所有字符了,这个就是ASCII码。

undefined

ASCII 码就好比一个字典,用 8 位二进制中的 128 个不同的数,映射到 128 个不同的字符里。比如,小写字母 a 在 ASCII 里面,就是第 97 个,也就是二进制的 0110 0001,对应的十六进制表示就是 61。而大写字母 A,就是第 65 个,也就是二进制的 0100 0001,对应的十六进制表示就是 41。

需要注意的是:

在 ASCII 码里面,数字 9 不再像整数表示法里一样,用 0000 1001 来表示,因为它在 ASCII 字符集中站位靠后。而是用 0011 1001 来表示,是第 57 位。字符串 “15” 也不是用 0000 1111 这 8 位来表示,而是变成两个字符 1 和 5 连续放在一起,也就是 0011 0001 和 0011 0101,需要用两个 8 位来表示。 两个 8 位的原因是,因为 4 位最高只能表示到(-8 - 7)。

我们可以看到,最大的 32 位整数,就是 2147483647。如果用整数表示法,只需要 32 位就能表示了。但是如果用字符串来表示,一共有 10 个字符,每个字符用 8 位的话,需要整整 80 位。比起整数表示法,要多占很多空间。所以这也是为什么我们在存储数据的时候要通过二进制序列化的方式来存储。

Unicode: 和 ASCII 一样是一个字符集,包含了 150 种语言的 14 万个不同的字符。

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

undefined

同样的文本,采用不同的编码存储下来。如果另外一个程序,用一种不同的编码方式来进行解码和展示,就会出现乱码。

需要注意的是,如果我们程序中使用了一些或者说存储了一些不常用的古老字符集,那么可能 Unicode 字符集中并不存在这样的字符,那么Unicode 会统一把这些字符记录为 U+FFFD 这个编码。如果用 UTF-8 的格式存储下来,就是\xef\xbf\xbf。

参考:

电路

继电器,门电路

计算机不用十进制而用二进制原因如下:

电磁关系及继电器的由来可参考继电器

电信号在传递的时候,由于电线过长会导致电阻过大此时对电压要求会变大或者说用电器会出现无响应的状态。所以在进行远距离信息传递的时候为了避免电路过长这种情况,发明了继电器(电驿)。继电器可以方便我们的电信号进行传导,或者根据需要组成我们想要的“与”,“或”,“非”等的逻辑电路。

基本门电路的表示:

  • “与”电路的话相当于我们在电路上串联两个开关,当两个开关都打开,电路才接通。
  • “或”相当于我们在输入端通两条电路到输出端,任意一条电路是打开状态,那么到输出端的电路都是联通的。
  • “非”相当于从开关默认关掉,只有通电有了磁场之后打开,换成默认是打开通电的,只有通电之后才关闭

这三种基本逻辑电路实现起来都比较简单,如果要做复杂的工作的话则需要更多的逻辑电路通过分层,组合的方式来实现。

undefined

结论:我们通过电路的“开”和“关”,来表示“1”和“0”。就像晶体管在不同的情况下,表现为导电的“1”和绝缘的“0”的状态。

这些门电路,是我们创建 CPU 和内存的基本逻辑单元。我们的各种对于计算机二进制的“0”和“1”的操作,其实就是来自于门电路,叫作组合逻辑电路。

所谓门电路在数字电路中,所谓“门”就是只能实现基本逻辑关系的电路,即基本组成单位。最基本的逻辑关系是与,或,非,最基本的逻辑门是与门,或门和非门。如下是最基本的门电路,其他复杂的门电路都是由这些门电路组合而成。他们是构成现代计算机硬件的“积木”。

加法器

半加器

如图通过一个异或门计算出个位,通过一个与门计算出是否进位,就可以通过电路算出了一个一位数的加法。于是,后来就把这两个门电路进行打包,叫他为半加器。

全加器

半加器只能解决个位的运算,二,四,八位的输入情况与个位的并不一样。因为二位除了一个加数和被加数之外,还需要加上来自个位的进位信号,一共需要三个数进行相加,才能得到结果。解决办法即通过两个半加器和一个或门就能组合成一个全加器。

W:为二位的值。有了全加器理论上两个8位数的加法运算就可以实现了。

有了全加器来实现各位的加法之后,更多位的加法计算只需要更多个全加器串联起来即可。如图8 位加法器可以由 8 个全加器串联而成。

可以看到的是,个位和其他高位不同,个位只需要一个半加器即可。而最高位即最左侧的一位表示的是我们的加法是否溢出了。整个电路中有这样一个信号来表示我们所做的加法运算是否溢出了,可以给到硬件层面的其它标志位中,来让计算机知晓这样算溢出了,以便得到计算机硬件层面的支持。

算术逻辑单元(ALU):是中央处理器的执行单元,是所有中央处理器的核心组成部分,由与门和或门构成的算术逻辑单元,主要功能是进行二进制的算术运算,如加减乘数(不包括整数除法)。

乘法器

13 * 9 = 117 的二进制竖式表:

如图从人为计算角度看,实际计算机层面的乘法是由位移加法组合而成。因为是二进制乘法,所以乘数的各位和被乘数的乘积不是全部为0就是把被乘数复制一份下来。需要注意的是乘数的每位进行一次乘积运算之后,下一次的运算结果就需要向高位移动一位。

乘法器的简单实现:根据乘数从个位一直到高位通过一个门电路来控制每次位移的输出信号,来判断和被乘数的结果是全部为0输出还是把被乘数复制一份输出,并将结果累加到统一某个寄存器上即可。如图:

具体:先拿乘数最右侧的个位乘以被乘数,然后把结果存入到寄存器中,然后,把被乘数左移一位,把乘数右移一位,仍然用乘数的个位去乘以被乘数,然后把结果加到刚才的寄存器上。反复重复这一步骤,直到两者分别不能再左移和右移位置。这样,乘数和被乘数其实仅仅需要简单的加法器(每位乘法及位移后的结果的累加),一个可以支持其左移一位的电路和一个右移一位的电路,以及一个开关(判断乘数的每位和被乘数乘积的结果是复制还是0)就能完成整个乘法。 如图所示

这里的控制测试,其实就是通过一个时钟信号,来控制左移、右移以及重新计算乘法和加法的时机。

但由之前的加法器可以得知,乘法器所谓的位移+加法,每一位的操作并不是独立的,乘数的高位在进行乘法运算之前任然需要低位的运算结果(移位)才可以。比如运算数是 4 位数,就要等待 4 组“位移 + 加法”的操作。

如果要优化整个乘法器的运算,可以看到影响执行速度的原因有如下几点:

  1. 每组位移+加法运算都具有强关联及先后关系。
  2. 控制测试进行每次进行一次位移及加法所需要等待的时钟频率。(最大)
  3. 每组乘法结果通过加法器在寄存器上进行结果累加的时候受门延时影响。

解决办法:

电路进行展开:首先针对第一点,我们上面所看到的竖列图分析出所谓的每组位移+加法的强关联关系及先后关系是因为我们人分析,但其实对于计算机的电路而言,当相加的两个数是确定的,那高位是否会进位其实也是确定的。也就是说,对于计算机的电路而言,高位和地位可以同时出结果,电路是天然并行的,也就不存在所谓的强关联关系。同时对应的第三点的门延时也就只有一组加法进行运算的门延时存在了,即3T的门延时。并且移位需要等待的时钟频率也不需要了。

其实可以看到乘法器的实现方式共有两种:

  1. RISC:即精简的电路,较少的门电路和寄存器,但相对的需要更长的门延迟和时钟周期来计算一个指令。
  2. CISC:比较复杂的电路,更短的门延迟和时钟周期来计算一个复杂的指令。

这之间的权衡,其实就是计算机体系结构中 RISC 和 CISC 的经典战争

定点数,浮点数

定点数

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

比如:0001 1001 就是 19,而不是 25。

undefined

用二进制来表示十进制的编码方式,叫作BCD 编码。

缺点:能表示数值太小,原本32位的数值表示方法,能表示的数值的最大值是42亿。用BCD编码的话最大只能表示到100w,一般超市常用。

浮点数

zhuanlan.zhihu.com/p/75581822

可以看到如果都像定点数那样表示,那么无疑会出现较大的实数无法表示的情况。就需要用到科学计数法的表示方法,其中浮点数的科学计数法的表示有一个 IEEE 754的标准,它定义了两个基本的格式。 一个是用 32 比特表示单精度的浮点数,也就是我们常常说的 float 或者 float32 类型。另外一个是用 64 比特表示双精度的浮点数,也就是我们平时说的 double 或者 float64 类型。

根据国际标准IEEE 754,任意一个二进制浮点数V可以表示成下面的函数形式:

  • 符号:(-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。
  • 尾数:M表示有效数字,大于等于1,小于2。
  • 阶码:2^E表示指数位。

例如:

  • 十进制的6.0,写成二进制是110.0,相当于1.10×2^2。那么,按照上面V的格式,可以得出s=0,M=1.10,E=2。
  • 十进制的-5.0,写成二进制是-101.0,相当于-1.01×2^2。那么,s=1,M=1.01,E=2。

IEEE 754规定,对于32位的浮点数,最高的1位是符号位s,接着的8位是指数E,剩下的23位为有效数字M。 对于64位的浮点数,最高的1位是符号位S,接着的11位是指数E,剩下的52位为有效数字M。

因为E为8位,所以它的取值范围为0~255。由于科学计数法中的E是可以出现负数的(为了表示更小的实数,无限接近与0的数),所以IEEE 754规定,E的真实值必须再加上一个中间数,对于8位的E,这个中间数是127(1-254,0和255是标志位);对于11位的E,这个中间数是1023。

比如,2^10的E是10,所以保存成32位浮点数时,必须保存成10+127=137,即10001001。

然后,指数E还可以再分成三种情况:

  1. E不全为0或不全为1。这时,浮点数就采用上面的规则表示,即指数E的计算值(10001001 = 137)减去127(或1023),得到真实值,再将有效数字M前加上第一位的1。
  2. E全为0。这时,浮点数的指数E等于1-127(或者1-1023),有效数字M不再加上第一位的1,而是还原为0.xxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字。
  3. E全为1。这时,如果有效数字M全为0,表示±无穷大(正负取决于符号位s);如果有效数字M不全为0,表示这个数不是一个数(NaN)。

在这样的浮点数表示下,不考虑符号的话,浮点数能够表示的最小的数和最大的数,差不多是 1.17 * 10^-383.40 * 10^38,表示的数值范围就大很多了。此时f为23个0,e为-126 和 f为23个1,e为127。

对于浮点数的整数部分十进制转二进制是如上公式,小数部分则与整数部分并不相同。

0.1001 这样一个二进制小数,换算成 10 进制就是

1*2^-1 + 0*2^-2 + 1*2^-3 + 0*2^-4` = 0.5625

小数的二进制表示方法和整数不同,比如9.1中的 9 可以表示为 1001,而作为小数的 0.1 则和整数转化成二进制除2的做法不同,小数的方式就是乘以 2,然后看看是否超过 1。如果超过 1,我们就记下 1,并把结果减去 1,进一步循环操作。在这里,我们就会看到,0.1 其实变成了一个无限循环的二进制小数。0.0++0011++0011这里的“0011”会无限循环下去。如图:

然后,我们把整数部分和小数部分拼接在一起,9.1 这个十进制数就变成了 1001.000110011...这样的二进制表示了。由于浮点数是用二进制科学计数法来进行表示的,所以这个数就变成了1.001000110011...*2^3。对应到浮点数二进制的科学计数法表达式中,这里的符号位 s = 0,对应的有效位 f=001000110011...。因为有效为f最长只有23位,于是f=00100011001100110011 ==001==。最后的一个“0011”循环中的最后一个“1”会被截断掉。对应的指数为为3,需要加上127,则二进制应该是130的表示,即10000010。 - 0.3也是如此表示。

最终可以得到9.1的二进制表示:0 10000010 0010001100110011==001==。

反向过来,这个二进制的值表示成10进制的话,实际准确的值则是 9.09999942779541015625。

模拟地址:IEEE-754 Floating Point Converter

由上小数部分的二进制转化可知:0.1~0.9 这 9 个数,其中只有 0.5 能够被精确地表示成二进制的浮点数。而其他的都只是一个近似的表达。

参考:

浮点数的加法原理

先对齐、再计算。

两个浮点数的指数位可能是不一样的,所以只要把两个的指数位,变成一样的,然后只去计算有效位的加法就好了。

可以看到浮点数的加法过程,其中指数位较小的数,需要在有效位进行右移,在右移的过程中,最右侧的有效位就被丢弃掉了。这会导致对应的指数位较小的数,在加法发生之前,就丢失精度。两个相加数的指数位差的越大,位移的位数越大,可能丢失的精度也就越大。32 位浮点数的有效位长度一共只有 23 位,如果两个数的指数位差出 23 位,较小的数右移 24 位之后,所有的有效位就都丢失了。这也就意味着,虽然浮点数可以表示上到3.40*10^38,下到 1.17×10^-38这样的数值范围。但是在实际计算的时候,只要两个数,差出2^24,也就是差不多 1600 万倍,那这两个数相加之后,结果完全不会变化。

如下代码:

public class FloatPrecision {
  public static void main(String[] args) {
    float a = 20000000.0f;
    float b = 1.0f;
    float c = a + b;
    System.out.println("c is " + c);
    float d = c - a;
    System.out.println("d is " + d);
  }
}
=====
c is 2.0E7
d is 0.0