操作系统

146 阅读18分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第10天,点击查看活动详情

1.1 CPU是如何执行程序的?

有这么几个问题:

  • a = 1 + 2 这条指令是怎么被CPU运行起来的?
  • 软件32位和64位的区别?32位操作系统可以运行在64位系统上吗?64位可以运行在32位上吗?原因又是什么?
  • CPU分为32位和64位,64位优势在哪呢,64位性能一定比32位好吗?

本章大纲

  • 图灵机的工作方式
  • 冯诺依曼模型
  • 线路位宽与CPU位宽
  • 程序执行的基本过程
  • a = 1 + 2 执行具体过程

图灵机的工作方式

当我们不太清楚一个事物为什么是这样子的时候,我们应该去看看它的历史。它在时间潮流中的变化决定它现在出现在你面前的模样。

图灵机是很早就提出的抽象计算模型,下面是它的大概模样

图灵机的基本组成如下:

  • 有一条纸带,由一个一个的格子组成,纸带好比内存,格子中的数据就像内存中的数据。
  • 有一个读写头,读写头可以读取纸带上的任意格子的字符
  • 头上有一些部件,比如存储单元、控制单元、以及运算单元。分别用于存储数据,识别字符以及控制程序流程,执行运算指令

冯诺依曼模型

冯诺依曼遵循图灵机的设计提出了在现在依旧符合的冯诺依曼模型

定义了计算机基本机构: 算器、控制器、存储器、输入、输出设备。这五个部分被称为冯诺依曼模型

运算器、控制器是在中央处理器里的,存储器就是我们常见的内存,输入输出设备就是计算机外接的设备。

存储器和输入输出设备要是与中央处理器打交道的话,离不开总线。

下面介绍 内存、中央处理器、总线、输入输出设备

内存

内存这一个存储区域是线性的。存储数据的基本单位是字节(byte),1字节==8位(bit)。每一个字节对应一个内存地址。

内存地址是从0开始的,自增,最后一个地址为总大小 - 1,和数组差不多性质。所以内存读写任何一个数据的速度是一样的

中央处理器

即CPU,有32位和64位,区别在于一次能计算多少字节数据。

  • 32位 -- 4个字节
  • 64位 -- 8个字节

这么设计是为了计算更大的数值。

CPU内部还有常见的如寄存器、控制单元、和逻辑运算单元。

为什么已经有存储器内存存储了,还需要寄存器来存储?

因为内存离CPU远,计算速度比较慢

常见的寄存器种类:

  • 通用寄存器:用来存放需要进行运算的数据
  • 程序计数器:用来存放CPU要执行的下一条指令的内存地址
  • 指令寄存器:用来存放程序计数器存放的指令,指令本身

总线

CPU和内存以及其他设备的通信

分类:

  • 地址总线:用于CPU要操作的内存地址
  • 数据总线:用于读写内存的数据
  • 控制总线:用于发送和接受信号,比如中断、设备复位等信号

读写内存时

  • 地址总线指定内存地址
  • 控制总线控制读写命令
  • 数据总线传输数据

输出输出设备

外界与CPU交互,用到了控制总线


线路位宽与CPU位宽

数据通过线路传输是通过操作电压完成,低电压是0,高电压是1。

一位一位的传输就是串行方式。想一次多传输就应该增加线路。

为了避免低效率的串行传输方式,线路的位宽最好一次就能访问到所有的内存地址。

CPU要操作内存地址就需要地址总线:

  • 如果地址总线只有一根,能够表示的只有0 和 1所以能够操作的内存地址最大为2,不是同时操作的地址。
  • 两条就是 00 01 10 11 四个地址,

即2 ^ 根数

那么要操作4g内存,就需要32条总线,2 ^ 32 = 4 G

上面的是线路位宽,下面是CPU位宽

CPU的位宽不应该小于线路位宽,因为32位的CPU一次只能操作32位的地址总线和数据总线

32位如何计算64位大小的数字?

拆分成高低位,然后先算地位,再算进位,再算高位。

而64位CPU就能一次性计算。

所以如果32位和64位一起计算32位可能没什么差别,但是当计算64位时64位才会有优势。

另外,32位CPU最大只能操作4GB内存条,装了8g也没有用。64位则是2^64

程序执行的基本过程

  • 第一步,CPU根据程序计数器获取到存储指令的内存地址,然后控制单元操作地址总线访问内存地址,通知内存准备数据,准备好数据后,通过数据总线将指令内容传给CPU,CPU收到后存入到指令寄存器
  • CPU分析指令寄存器中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给逻辑运算单元运算,如果是存储类型的指令,就交给控制单元执行。
  • 第三步 ,CPU执行完指令后,程序计数器自增,表示指向下一条指令,大小由CPU位宽决定,比如32位CPU,一条指令大小就是4字节,需要4个地址存放,程序计数器就自增4

这个往复过程就是CPU的指令周期

a = 1 + 2执行具体过程

首先CPU是不认识 a = 1 + 2的,所以要想跑起程序就需要把整个程序翻译成汇编语言,整个过程就叫汇编。

针对汇编代码,需要汇编器翻译成机器码,由01组成,这个就是计算机指令,CPU能够认识。

a = 1 + 2 在 32位CPU的执行过程:

程序编译过程中,编译器通过分析代码,能够发现1 和 2 是数据,内存会有个叫数据段的区域来存放这些数据。

如图

  • 数据 1 被存放到 0x100 位置;
  • 数据 2 被存放到 0x104 位置;

存放指令的地方叫正文段

存放数据的地方叫数据段

编译器会把a = 1 + 2 翻译成4条指令存放到正文段。

  • 0x200 load 将数据1 装入 R0寄存器
  • 0x204 load 将数据2 装入 R1寄存器
  • 0x208 add 将R0 和 R1 相机,并把结果放到寄存器R2
  • 0x20c store 将寄存器R2的数据存回0x108地址就是变量a的地址

再提一句就是,32位CPU一条指令4个字节,也就是4个内存地址

指令

指令其实指的是机器码,但是为了方便理解,这里说的是汇编代码。CPU通过解析机器码来知道指令的内容。

不同的CPU有不同的指令集,也对应不同的机器码,下面是最简单的MIPS指令集,来了解机器码是如何生成的。

MIPS 的指令是一个 32 位的整数,高 6 位代表着操作码,表示这条指令是一条什么样的指令,剩下的 26 位不同指令类型所表示的内容也就不相同,主要有三种类型R、I 和 J。

  • R指令,用在算术和逻辑操作,里面有读取和写入数据的寄存器地址。如果是逻辑位移后面还有唯一操作的位移量,最后的功能码则是前面的操作码不够的时候,扩展操作码来表示对应的具体指令的。
  • I指令,用在数据传输、条件分支等。没有位移量、功能码、和第三个寄存器。三部分合并成一个地址值或一个常数
  • J指令用于跳转,后面26位全表示跳转后的地址

下面举例:add指令将寄存器R0和R1的数据相加,并把结果放入到R2,翻译成机器码

加和运算add指令属于R指令类型

编译器在编译程序的时候会构造指令,CPU执行时会解析指令。指令的编码与解码。

在此基础上,现代大多是CPU都是使用流水线的方式来执行指令。

四个阶段的具体含义?

  1. 通过程序计数器读取响应内存地址的指令 Fetch 取得指令
  2. 对指令进行解码 Decode 指令译码
  3. 执行指令 Execution 执行指令
  4. 将计算结果放入寄存器或者内存,Store 数据回写

上面4个步骤叫做指令周期。

不同阶段由不同计算机组件完成:

  • 指令都是存储在存储器中,通过程序计数器和指令寄存器取指令的过程都是由控制器操作的。
  • 译码--控制器
  • 执行时,算术由算术逻辑单元负责,跳转啥的由控制器负责

指令的类型

从功能出发,和上面不要混肴,上面有一个是指令集,是具体的体现:

  • 数据传输类型 store/load 寄存器与内存 mov 内存与内存
  • 运算类型 加减乘除
  • 跳转类型 if-else swit-case 函数调用
  • 信号类型 trap 中断
  • 闲置类型 nop CPU空转

指令的执行速度

CPU的硬件参数GHZ ,1GHZ表示1秒能够产生1G次脉冲信

号,每一次脉冲信号高低电平的转换就是一个时钟周期。

CPU在一个时钟周期内,只能完成一次最基本的动作,时钟频率越高时钟周期越短,工作速度越快。

一个时钟周期能够执行一条指令吗?

不能。大多数指令都不能,而且不同的指令需要的时钟周期也不一样。乘法比加法多。

如何让程序跑的更快?

消耗的CPU时间少,说明程序是快的,对于程序的CPU执行时间可以拆解成CPU时钟周期数和时钟周期时间的乘积

时钟周期时间即主频。主频越高说明运行速度越快。比如2.4GHZCPU,时钟周期时间就是 1/2.4G

但是主频一般来说是相对固定的,所以我们应该去减少CPU时钟周期数。

CPU时钟周期数 = 指令数 * 每天指令的平均时钟周期数CPI

那对于如何让程序跑的更快这个问题的答案就出来了?

  • 指令数,可以靠编译器,同样的代码在不同的编译器中编译出来的指令不一样
  • 每条指令的平均时钟周期数CPI:大多数CPU已使用流水线技术,让一条指令所需要的CPU时钟周期数尽可能的少。
  • 时钟周期时间:超频技术,打开超频意味着把CPU内部时钟调快了。代价是热量和崩溃。

很多厂商为了跑分而跑分,基本都是在这三个方面入手的哦,特别是超频这一块

总结

1.2 磁盘比内存缓慢几万倍?

\

存储器的层次结构是怎么样的呢?

理解存储器的层次结构的过程可以当作在图书馆看书。我们的大脑好比CPU,我们的运算速度很快,但是我们每次能处理的信息很少,就好像寄存器,容量很小但是很快。L1,L2,L3缓存就好像是短期记忆和长期记忆,访问时没有那么快,需要一点时间回忆一下。如果我们需要的东西,我们根本不记得,我们就需要去翻书,桌上触手可及的书就好像内存,每次翻阅都是在重新记到脑子里去,而图书馆书架上的书就好像硬盘,我们每次都需要先找到,然后开始看书,然后记到脑子里。

对于存储器,运行速度越快,成本越高,所以为啥寄存器的容量比较小,磁盘的内存比较大。另外,一个比较好的CPU的价格大于内存条+磁盘的价格。

接下来详细的了解一下

各个存储器的细节:

寄存器

  • 32 位 CPU 中大多数寄存器可以存储 4 个字节;
  • 64 位 CPU 中大多数寄存器可以存储 8 个字节。

寄存器的访问速度非常快,一般是半个CPU时钟周期,CPU时钟周期和CPU主频息息相关。

2 GHz 主频的 CPU,那么它的时钟周期就是 1/2G,也就是 0.5ns(纳秒)。

CPU cahce

CPU缓存使用的是SRAM(Static Random-Access Memory静态随机存储芯片)芯片

SRAM之所以叫做静态存储器的,是因为只要有电,数据就可以一直保持,断电就会丢失。

在 SRAM 里面,一个 bit 的数据,通常需要 6 个晶体管,所以 SRAM 的存储密度不高,同样的物理空间下,能存储的数据是有限的,不过也因为 SRAM 的电路简单,所以访问速度非常快。

L1cache

$ cat /sys/devices/system/cpu/cpu0/cache/index0/size 查看 

index0 , 1 , 2 , 3 分别是 数据缓存,指令缓存,L2, L3

内存

DRAM随机存储芯片。动态是因为数据被存储在电容中,电容会不断漏电,需要定时刷新才能保证数据不被丢失。

硬盘

SSD固态硬盘 HDD机械硬盘

断电后不会丢失

总结

可以发现,不同的存储器之间性能差距很大,构造存储器分级很有意义,分级的目的是要构造缓存体系

CPU怎么知道要访问的内存数据是否在CPU cache中

1.3如何写出让CPU跑得更快的程序

在上面的基础上知道了层级结构之后,我们还需要了解一下CPU cache 的数据结构和读取过程是怎么样的,才能根据此得出如何让程序在CPU层面跑的更快。

L1cache分为数据缓存和指令缓存,L1cache 和 L2cache都是每个CPU核心独有的,L3是多个CPU核心共享的。

CPU是怎么读数据的?

CPU cache的结构

由多个cache line组成,同时cache line也是CPU读取内存的基本单位。

cache line 由各种标志tag + 数据块Data Block组成

\

CPU读取内存数据时,不是一个元素一个元素的读,而是一块一块,每一块就是cache line 缓存块

查看linux缓存块大小的指令 如下

cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size

举个例子,一个数组有100个元素,每个元素4个字节,CPU会顺序读取16个元素到cpu cache,下次就直接读cache就好了,能够提高性能。

在CPU中,不管数据存不存在缓存中,都会先访问cache,只有当cache找不到数据,才会去读取内存。

那CPU怎么知道访问的内存数据在不在缓存中呢,如果在又怎么找呢?

直接映射Cache Direct Mapped Cache 说起,

前面提到,CPU访问内存的时候读取的是一块块的cache line大小的数据,这在内存中叫做内存块(Block)。直接映射Cache就是把内存块的地址始终映射在一个CPU cache line地址。实现方式就是“取模运算”。

举个例子,内存共被划分为 32 个内存块,CPU Cache 共有 8 个 CPU Cache Line,假设 CPU 想要访问第 15 号内存块,如果 15 号内存块中的数据已经缓存在 CPU Cache Line 中的话,则是一定映射在 7 号 CPU Cache Line 中,因为 15 % 8 的值是 7。

这个时候就会产生多个内存块同时映射在一个内存块上。

因此为了区别不同的内存块,在对应的CPU cache line中,会存储一个tag(组标记) ,存储记录当前cacheline对应的内存块,以此来区别不同的内存块

除了组标记tag以外,还有两个信息:

  • 数据块(从内存中来的)
  • 有效位,有效位是0则CPU直接访问内存重新加载数据

CPU读取cpu cache line 的时候并不是读取整个数据块,而是读取所需要的一个数据片段, 这样的数据叫做字(word),怎么在cache line中找到这个字呢?通过偏移量(offset)

因此一个内存的访问地址是 组标记 + cache line 索引 + 偏移量

cpu通过这个能够在CPU cache里面找到缓存的数据。

所以CPU cache中的数据结构就是 索引 + 有效位 + 组标记 + 数据块

如果内存中的数据已经在CPU cache中了,那么CPU访问内存地址会经历以下4个步骤:

  1. 根据内存访问地址的索引,取模计算计算CPU cache的索引
  2. 拿到索引,根据有效位判断是否有效,无效则访问内存,有效则继续
  3. 对比内存地址中的组标记和cache line 中的组标记,如果不是就直接访问内存。如果是的话就往下执行
  4. 根据偏移量获取到相应的字

提高缓存命中率就能提高代码运行速度

我们知道L1cache分为 数据缓存和指令缓存

提高数据缓存命中,需要知道数据在缓存中的存储方式。比如,数组,二维数组等等。

提高指令缓存命中,CPU 的分支预测器。对于 if 条件语句,意味着此时至少可以选择跳转到两段不同的指令执行,也就是 if 还是 else 中的指令。那么,如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快

比如先遍历再排序快,还是先排序再遍历呢?

当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。

因此,先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中 if < 50 的次数会比较多,于是分支预测就会缓存 if 里的 array[i] = 0 指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了。

如果你肯定代码中的 if 中的表达式判断为 true 的概率比较高,我们可以使用显示分支预测工具,

如何提升多核 CPU 的缓存命中率?

在单核 CPU,虽然只能执行一个线程,但是操作系统给每个线程分配了一个时间片,时间片用完了,就调度下一个线程,于是各个线程就按时间片交替地占用 CPU,从宏观上看起来各个线程同时在执行。

而现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。

当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把线程绑定在某一个 CPU 核心上,这样性能可以得到非常可观的提升。

在 Linux 上提供了 sched_setaffinity 方法,来实现将线程绑定到某个 CPU 核心这一功能。

\