操作系统-硬件结构和内存管理

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

操作系统---图解系统(小林coding)_图解操作系统-CSDN博客

20 张图揭开「内存管理」的迷雾,瞬间豁然开朗_20张图揭开内存-CSDN博客

1  CPU是如何执行程序的

冯诺依曼模型

内存

我们的程序和数据都是存储在内存,存储的区域是线性的。

数据存储的单位是一个二进制单位(bit),即0或1。 最小的存储单位是字节(byte),1字节等于8位。

内存地址从0开始编号,自增排列,最后一个地址为内存总字节数-1,与数组类似,内存读写任何一个数据的速度都是一样的。

中央处理器CPU

常见的CPU有32和64位CPU,这个位数指的是CPU的位宽。32位CPU一次可以处理4字节,64位CPU一次可以处理8字节。

CPU位宽越大,可以计算的数值就越大。

寄存器

CPU中的寄存器主要作用是存储计算时的数据CPU访问数据时先访问寄存器,再访问内存

常见的寄存器种类:

通用寄存器:存放需要进行运算的数据
程序计数器:存储CPU要执行的下一条指令所在的内存地址
指令寄存器:存放程序计数器指向的指令


总线

总线用于CPU 和内存以及其它设备之间的通信

地址总线:用于指定CPU将要操作的内存地址
数据总线:用于读写内存的数据
控制总线:用于接收和发送信号
CPU读写内存时,一般经过两个总线:首先通过地址总线来指定内存的地址,再通过数据总线来传输数据。

输入输出设备

输入设备向计算机输入数据,计算机通过计算后,把数据输出给输出设备。

线路位宽与CPU位宽

数据通过操作电压,低电压0,高电压1,进而实现通过线路传输。

一位一位传输的方式为串行,下一个bit必须等待上一个bit传输完成才能进行传输。与之相对的并行传输即一次传输多个bit,需要增加线路位宽

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

CPU位宽最好不要小于线路位宽。

如果计算的数额不超过32位数字的情况下,32位和64位CPU之间是没有什么区别的,只有当计算超过32位数字的情况下,64位的优势才能体现出来。

**程序执行流程:**一个程序执行时,CPU会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器中执行,然后根据指令长度自增,开始顺序读取下一条指令。

**CPU的指令周期:**CPU从程序计数器读取指令、到执行,再到下一条指令,这个过程会会不断循环直到程序执行结束,这个过程就叫做CPU的指令周期

内存中有两个存放指令和数据的区域,分别是正文段和数据段:

**指令的编码:**编译器在编译程序时构造指令的过程。

**指令的解码:**CPU执行程序时会解析指令的过程。

CPU通常使用流水线的方式来执行指令,分为四个阶段,这四个阶段称为指令周期

  • Fetch(取得指令):CPU通过程序计数器读取对应内存地址的指令
  • Decode(指令译码):CPU对指令进行解码
  • Execute(执行指令):CPU执行指令
  • Store(数据回写):CPU将计算结果存回寄存器或将寄存器的值存入内存

指令从功能角度划分为5大类:

  • 数据传输类型指令
  • 运算类型指令
  • 跳转型指令
  • 信号类型指令
  • 闲置类型指令


指令的执行速度

CPU的硬件参数GHz指的是时钟频率为1G,代表着1秒就会产生1G次数的脉冲信号,每一次脉冲信号高低电平的转换就是一个周期,称为时钟周期。

程序的CPU执行时间 = CPU时钟周期数 x 时钟周期时间 = 指令数 x CPI x 时钟周期时间

  • 时钟周期时间就是CPU主频,1/1G
  • CPU时钟周期数 = 指令数 x 每条指令的平均时钟周期数(CPI)

64位CPU相比于32位CPU的优势:

  • 64位CPU可以一次计算超过32位的数字,而32位CPU如果要计算超过32位的数字,要分多步骤进行计算,效率不高,但是大部分应用程序很少会计算大数字。所以,只有计算大数字时,64位CPU的优势才能体现出来,否则和32位CPU的计算性能相差不大。
  • 64位CPU可以寻址更大的内存空间为2^64,32位CPU的最大寻址地址是4G。


64位和32位软件,这里面的位指的是指令的位宽:

  • 如果32位指令在64位的机器上执行,需要一套兼容机制,就可以运行。但64位的指令在32位的机器上执行就会比较困难,因为32位的寄存器存不下64位的指令。
  • 操作系统也是一种程序,常见的32位和64位操作系统指的是操作系统中指令的位数,比如64位操作系统就不能装在32位机器上。

硬件的32和64位指的是CPU的位宽,软件的32和64位指的是指令的位宽。

2 存储器金字塔

2.1 存储器的层次结构:

  • **寄存器:**最靠近CPU的控制单元和逻辑计算单元的存储器。速度最快,价格最贵。32位CPU中大多数寄存器可以存储4个字节,64位CPU中大多数寄存器可以存储8个字节。

  • CPU Cache:使用一种SRAM(静态随机存储器)的芯片,有电数据则存在,断电数据就会丢失。

  • L1高速缓存:访问速度几乎与内存器一样快,通常只需要2-4个时钟周期;指令和数据在L1是分开存放的,L1缓存通常分为指令缓存与数据缓存
  • L2高速缓存:位置比L1高速缓存远,大小比L1大,访问速度更慢,通常为10-20个时钟周期。
  • L3高速缓存:通常是多个CPU核心共用的,位置比L2高速缓存还要远,大小更大,访问速度更慢,通常为20-60个时钟周期。

  • 内存:内存所使用的DRAM(动态随机存取存储器)芯片;相比于SRAM,DRAM的密度更高,功耗更低,容量更大,造价更便宜。访问速度为200-300个时钟周期。
  • SSD/HDD硬盘:SSD就是固体硬盘,断电后数据依然存在,访问速度比内存慢10-1000倍。HDD是机械硬盘,通过物理读写的方式来访问数据,访问速度比内存慢10w倍。

 

 每个存储器之和相邻的一层存储器打交道,并且存储设备为了追求更快的速度,所需的材料成本必然也越高,因此,CPU内部的寄存器、L1、L2、L3 Cache只好使用较小的容量,而内存、硬盘则可使用较大的容量,这就是我们所说的存储器层次结构。

2.2 缓存体系

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


3 内存管理 

3.1 虚拟内存

单片机是没有操作系统的,所以每次写完代码,都需要借助工具把程序烧录进去,这样程序才能跑起来。

另外,单片机的 CPU 是直接操作内存的「物理地址」

在这种情况下,要想在内存中同时运行两个程序是不可能的。如果第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。


操作系统是如何解决这个问题呢?

这里关键的问题是这两个程序都引用了绝对物理地址,而这正是我们最需要避免的。

我们可以把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」,人人都有,大家自己玩自己的地址就行,互不干涉。但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。

操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。

如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。

这里就引出了两种地址的概念:

  • 程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
  • 实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)

操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:

操作系统是如何管理虚拟地址与物理地址之间的关系?

主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的,我们先来看看内存分段。


3.2 内存分段

程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。

分段机制下,虚拟地址和物理地址是如何映射的?

分段机制下的虚拟地址由两部分组成,段选择子段内偏移量 

  • 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
  • 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

在上面,知道了虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:

如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。

分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:

  • 第一个就是内存碎片的问题。
  • 第二个就是内存交换的效率低的问题。

接下来,说说为什么会有这两个问题。

我们先来看看,分段为什么会产生内存碎片的问题?

我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中:

游戏占用了 512MB 内存
浏览器占用了 128MB 内存
音乐占用了 256 MB 内存。
这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。

如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。

 

这里的内存碎片的问题共有两处地方:

  • 外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
  • 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;

针对上面两种内存碎片的问题,解决的方式会有所不同。

解决外部内存碎片的问题就是内存交换

可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。

这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换

再来看看,分段为什么会导致内存交换效率低的问题?

对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。

因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。

所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿

为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页

3.3 内存分页


分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。

要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页(Paging)。

分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB

3.3.1 简单页表

虚拟地址与物理地址之间通过页表来映射,如下图:

页表实际上存储在 CPU 的内存管理单元 (MMU) 中,于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。

而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行

分页是怎么解决分段的内存碎片、内存交换效率低的问题?

由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。

如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高

 

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

分页机制下,虚拟地址和物理地址是如何映射的?

在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。

总结一下,对于一个内存地址转换,其实就是这样三个步骤:

  • 把虚拟内存地址,切分成页号和偏移量;
  • 根据页号,从页表里面,查询对应的物理页号;
  • 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。

下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图:

这看起来似乎没什么毛病,但是放到实际中操作系统,这种简单的分页是肯定是会有问题的。

简单的分页有什么缺陷吗?

有空间上的缺陷。

因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。

在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。

这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。

那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。

3.3.2 多级页表


要解决上面的问题,就需要采用的是一种叫作多级页表(Multi-Level Page Table)的解决方案。

在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB 的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。

我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。如下图所示:

分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?

当然如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。

其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的局部性原理么?

局部性原理是指CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。

  • 时间局部性。如果程序中的某条指令被执行,它可能在不久的将来再次被执行;同样,如果某个数据被访问,它可能在不久的将来再次被访问。这种特性通常是由于程序中存在大量的循环操作。
  • 空间局部性。一旦程序访问了某个存储单元,它可能在不久的将来访问其附近的存储单元。这是因为程序通常是以顺序或循环的方式执行,且数据往往以矩阵或数组的形式存储。

每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。

如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?

那么为什么不分级的页表就做不到这样节约内存呢?

我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。

对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:

  • 全局页目录项 PGD(Page Global Directory);
  • 上层页目录项 PUD(Page Upper Directory);
  • 中间页目录项 PMD(Page Middle Directory);
  • 页表项 PTE(Page Table Entry);

3.3.3 TLB 页表缓存

多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。

程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域

我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。

在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。

有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。

TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。

3.4 段页式内存管理

内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理

 

段页式内存管理实现的方式:

  • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
  • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;

这样,地址结构就由段号、段内页号和页内位移三部分组成。

用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:

段页式地址变换中要得到物理地址须经过三次内存访问:

  • 第一次访问段表,得到页表起始地址;
  • 第二次访问页表,得到物理页号;
  • 第三次将物理页号与页内位移组合,得到物理地址。

可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。

3.5 Linux 内存管理


那么,Linux 操作系统采用了哪种方式来管理内存呢?

在回答这个问题前,我们得先看看 Intel 处理器的发展历史。

早期 Intel 的处理器从 80286 开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。因此,在不久以后的 80386 中就实现了对页式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理。

但是这个 80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的地址上再加上一层地址映射

由于此时由段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。

这里说明下逻辑地址和线性地址:

  • 程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;
  • 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;

逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。

了解完 Intel 处理器的发展历史后,我们再来说说 Linux 采用了什么方式管理内存?

Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。

这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。

但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走。

Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护

我们再来瞧一瞧,Linux 的虚拟地址空间是如何分布的?

在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:

通过这里可以看出:

  • 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
  • 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

再来说说,内核空间与用户空间的区别:

  • 进程在用户态时,只能访问用户空间内存;
  • 只有进入内核态后,才可以访问内核空间的内存;

虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存

接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。

我们看看用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系:

通过这张图你可以看到,用户空间内存,从低到高分别是 7 种不同的内存段:

  • 程序文件段,包括二进制可执行代码;
  • 已初始化数据段,包括静态常量;
  • 未初始化数据段,包括未初始化的静态变量;
  • 堆段,包括动态分配的内存,从低地址开始向上增长;
  • 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关);
  • 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;

在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。


3.6 内存管理总结

为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套虚拟地址空间,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。

每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。

那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。

那么对于虚拟地址与物理地址的映射关系,可以有分段和分页的方式,同时两者结合都是可以的。

内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致内存碎片和内存交换效率低的问题

于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB。由于分了页后,就不会产生细小的内存碎片。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率

再来,为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。

Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护

另外,Linxu 系统中虚拟空间分布可分为用户态和内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。

4 如何写出让CPU跑得更快的代码


在之前的学习中,我们知道了为了弥补CPU和内存之间的性能差异,就在CPU和内存之间引入了CPU Cache,它通常分为大小不等的三级缓存即L1、L2、L3 Cache。而在L1 Cache中,数据和指令是分开缓存的,即数据缓存和指令缓存

程序在执行时,会先将内存中的数据加载到共享的L3 Cache中,再加载到每个CPU核心独有的L2 Cache,最后进入到最快的L1 Cache,之后才会被CPU读取。


CPU Cache 的数据结构和读取过程

CPU Cache的数据是从内存中读取来的,它是以一小块一小块的方式读取数据的,不是按照单个数组元素来读取数据。在CPU Cache中,这样一小块一小块的数据称之为Cache Line(缓存块)。

CPU读取数据时,无论数据是否放到Cache中,CPU都会先访问Cache,只有当Cache中找不到数据时,才会去访问内存,并把内存中的数据读到Cache中,CPU再从 CPU Cache中读取数据。

内存地址映射到CPU Cache地址有很多种策略,最简单的是直接映射Cache,我们可以通过直接映射来看CPU Cache的数据结构和访问逻辑。它把一个内存的访问地址拆分为组标记、CPU Line索引、偏移量这三种信息,CPU根据这些信息在CPU Cache中找到缓存的数据。对于CPU Cache中的数据结构,则是由索引、有效位、组标记、数据块组成


要想写出让CPU跑得更快的代码就需要写出缓存命中率高的代码,而CPU L1 Cache分为数据缓存和指令缓存,因此需要分别提高它们的缓存命中率:

  • 数据缓存方面:我们在遍历数据时,应该按照内存布局的顺序操作,这是因为CPU Cache是根据CPU Cache Line批量操作数据的,所以顺序操作连续内存数据时,性能能够得到明显的提升。
  • 指令缓存方面:有规律的条件分支语句能够让CPU的分支预测器发挥作用,进一步提高执行的效率。

对于多核CPU系统,线程可能在不同的CPU核心来回切换,这样各个核心的缓存命中率就会降低,所以想提高缓存命中率,可以考虑把线程绑定到某一个CPU核心

5 CPU缓存一致性


首先,我们先来了解CPU Cache的数据写入方式–写直达和写回,然后探索缓存一致性问题,最后了解总线嗅探和MESI协议。

随着时间的推移,CPU和内存之间的访问性能相差越来越大,所以在CPU内部嵌入了CPU Cache(高速缓存),CPU Cache通常分为L1 Cache、L2 Cache、L13Cache,离CPU越近的级别越高,访问速度更快,容量更小。在多核心的CPU中,每个CPU核心都有自己的L1 Cache、L2 Cache,而L3 Cache是所有核心共享的。

CPU Cache是由很多个Cache Line组成的,Cache Line是CPU从内存读取数据的基本单位,而Cache Line是CPU从内存读取数据的基本单位 Line是由各种标志(Tag)和数据块(Data Block)组成。

数据不仅有读操作,还有写操作。那么在数据写入CPU Cache后,内存与Cache中的数据将会不同,所以在这种情况下我们要将Cache和内存中的数据进行同步,但何时进行同步即进行写回,这是一个问题。下满是两种针对数据写回的方法:

写直达: 把数据同时写入内存和Cache中,这是保持内存和Cache一致性最简单的方式。

在数据写入时都会先进行数据是否在CPU Cache中的判断,再进行写入操作:数据若是已经在Cache中了,则先将数据更新到Cache中,再写入到内存中;数据若是不在Cache中,就直接把数据更新到内存中。

这个方式的写法简单直接,但因为每次都会进行判断所以会花费大量时间影响性能

写回: 当发生写操作时,新的数据仅仅被写入Cache Block中,只有当修改过的Cache Block被替换时才需要写到内存中。

减少了数据写回内存的频率,节约了时间,提高了系统性能。

  • 发生写操作时,若数据已经在CPU Cache中,则把数据更新到CPU Cache中,同时标记CPU Cache里的这个Cache Block为脏(Dirty)的,这个脏的标记是指在这个时候,我们CPU Cache中的Cache Block的数据和内存是不一致的,这种情况下不用把数据写到内存中。
  • 发生写操作时,若数据不在CPU Cache中,则定位数据对应的Cache Block,检查此时这个Cache Block是否被标记为脏的,若是脏的,则将Cache Block中的数据直接写回到内存;若不是脏的,则将当前数据写入到此Cache Block,然后将这个Cache Block标记为脏的。
  • 写回方法在数据写入Cache时,只有在缓存不命中,同时数据对应的Cache中的Cache Block为脏标记的情况下才会将数据写到内存中,在缓存命中的情况下,则写入Cache并标记为脏,不写到内存中。


缓存一致性问题

缓存一致性的问题简单的理解就是在多核CPU里,两个核心同时运行两个线程操作同一个数据,第一个核心通过写回执行了改变该数据的操作,把此时改变了的数据值写入到L1/L2 Cache中,然后将其对应的Block标记为脏的。这个时候的数据是没有被同步到内存中的,此时第二个核心尝试从内存中读取该数据(没有被改变的数据),那么读到的数据与预期是不符的,两个核心的缓存不一致,进而会导致其他执行操作的错误,这就是缓存一致性问题。

为了解决缓存一致性问题,我们需要通过一种机制来同步不同核心里面的缓存数据,而要实现这一机制则需要满足这两个条件:写传播和事务的串形化

写传播: 某个CPU里的Cache数据更新时,必须要传播到其他核心的Cache。

事务的串形化某个CPU核心里对数据的操作顺序必须在其他CPU核心看起来顺序是一样的。即假设一个含有4个核心的CPU,这4个核心都操作一个变量,那么当某一个核心对数据进行了更改,其他核心读取到的操作顺序是一样的。A核心对一个变量进行了加操作,同时B核心对这个变量进行了减操作,那么剩余的C、D核心通过写传播收到的数据更改操作顺序不一致,C是先加后减,D是先减后加,那么各个Cache中的数据值是不一致的,把C、D收到数据操作顺序变为一致的过程就是事务的串形化。

为了实现事务的串形化则要做到这两点

  • CPU核心对于Cache中数据的操作,需要同步给其他CPU核心
  • 要引入**“锁”**的概念,如果两个CPU核心里有相同数据的Cache,那么对于这个Cache数据的更新,只有拿到了“锁”才能进行相应的数据更新。

写传播的原则就是当某个CPU核心更新了Cache中的数据,要把该事件广播通知到其他核心。最常见的实现方式就是总线嗅探

总线嗅探很简单,CPU需要时刻监听总线上的一切活动,但是不管别的核心的Cache是否缓存相同的数据都需要发出一个广播事件,这也会加重总线的负载

需要注意的是,总线嗅探只是保证了某个CPU核心的Cache更新数据这个事件能被其他CPU核心知道,但并不能保证事务的串形化


MESI协议

MESI协议基于总线嗅探机制实现了事务串形化,也用状态机制降低了总线带宽压力,实现了CPU缓存一致性。

M:Modified 已修改 即脏标记,代表该Cache Block上的数据已经被更新过,但还没有写到内存中。

I: Invalidated 已失效 表示这个Cache Block里的数据已经失效,不可以读取该状态的数据。

E: Exclusive 独占 该状态下数据只存储在一个CPU核心的Cache中,其他CPU核心的Cache没有该数据。向独占的Cache写入数据不需要通知其他CPU核心,自由写入,不存在缓存一致性问题。

S: Shared 共享 该状态表示该数据在多个CPU核心的Cache中都存在,当我们在进行数据更新时不能直接进行修改,要先对其他CPU核心进行广播一个请求,要求先把其他核心的Cache中对应的Cache Line标记为无效状态再进行数据的更新。

处于已修改或独占状态的Cache Line 修改更新数据不需要发送广播给其他CPU核心。

整个MESI的状态可以用一个有限状态机来表示它的状态流转。MESI是已修改、独占、共享、已实现这四个状态的英文缩写组合,整个MESI状态的变更是根据本地CPU核心的请求,或者来自其他CPU核心通过总线传输过来的请求,从而构造一个流动的状态机。


6  CPU是如何执行任务的

现代CPU的架构图

对于数据数据的读取,CPU会加载数组里面的连续多个数据到Cache中,因此我们应该按照物理内存地址分布的顺序去访问元素,这样访问数组元素时Cache的命中率就会很高,于是减少了从内存读取数据的频率,进而提高程序的性能。但是在我们不使用数组,使用单独变量时,则会有Cache伪共享的问题,Cache伪共享问题是一个性能杀手,我们应该规避这个问题。

多个线程同时读写同一个Cache Line的不同变量时,导致CPU Cache失效的现象叫做伪共享(False Sharing)。

伪共享(false sharing),并发编程无声的性能杀手-CSDN博客

                           

对于多个线程共享的热点数据,即经常进行修改的数据,应该避免这些数据处于同一个Cache Line中,否则就会出现伪共享问题,影响系统性能。

避免伪共享的方法

避免伪共享实际上是用空间换时间的思想,浪费一部分Cache空间从而换取性能的提升。

在Linux 内核中使用__cacheline_algined_in_smp宏定义解决伪共享问题。

#ifdef CONFIG_SMP
#define __cacheline_algined_in_smp __cacheline_algined
#else
#define __cacheline_algined_in_smp
#endif

 

CPU是如何执行任务的

了解完CPU是如何读取数据之后,我们来了解CPU是根据什么来选择当前要执行的线程的。

在Linux系统中根据任务的优先级以及响应要求,主要分为两种,其中优先级的数值越小,优先级越高:

  • **实时任务:**对系统的响应时间要求很高,也就是要尽可能快的执行实时任务,优先级在0~99范围内的就是实时任务。
  • **普通任务:**对于响应时间没有很高的要求,优先级在100~139范围内的都是普通任务级别。

由于任务有优先级之分,Linux系统为了保障高优先级的任务能够尽可能早的执行,于是分为了三种调度类:

DeaddlineRealtime调度类是应用于实时任务的,这两个调度类的调度策略合起来有三种:

  • SCHED_DEADLINE: 是按照deadline进行调度的,距离当前时间点最近的deadline任务会被优先调度。
  • SCHED_FIFO: 对于相同优先级的任务,按先来先服务的原则,但是优先级更高的任务可以抢占低优先级的任务,即高优先级的任务可以“插队”。
  • SCHED_RR: 对于相同优先级的任务,轮流着运行,每个任务都有一定的时间片,当用完时间片的任务会被放到队列尾部,以保证相同优先级任务的公平性,但是高优先级的任务依然可以“插队”。

Fair调度类应用于普通任务,都是由CFS调度器管理的,分为两种调度策略:

  • SCHED_NORMAL: 普通任务使用的调度策略。
  • SCHED_BATCH: 后台任务的调度策略,不和终端进行交互,因此在不影响其他需要交互的任务可以适当降低它的优先级。

这三种调度类的优先级是递减的即:Deadline > Realltime > Fair

实时任务总是会比普通任务先执行。


完全公平调度

我们平日里遇到的基本是普通任务,对于普通任务来说,公平性最重要,在Linux中,实现了一个基于CFS的调度算法,即完全公平调度(Completely Fair Scheduling)。

这个算法的理念是想让分配给每个任务的CPU时间是一样的,于是它为每个任务安排了一个虚拟运行时间vruntime,如果一个任务在运行,其运行的越久,该任务的vruntime就会越大,而没有运行的任务的vruntime是不变的。那么,在CFS算法调度时,会优先选择vruntime少的任务,以保证每个任务的公平性。

 

系统中需要运行的多线程一般都会大于CPU核心,这样就会导致线程排队等待CPU,这就会产生一定的延时,如果我们的任务对延时容忍度很低,则可以通过一些人为手段干预Linux的默认调度策略和优先级。


 

7 软中断


中断: 是系统用来响应硬件设备请求的一种机制,操作系统收到硬件的中断请求,会打断正在执行的进程,然后调用内核中的中断处理程序来响应请求。中断可以提高系统的并发处理能力

注意: 中断处理程序,要尽可能快的执行完,这样可以减少对正常进程运行调度地影响。中断处理程序在响应中断时,可能还会临时关闭中断,也就是说当前中断处理程序没有被执行完之前,系统中其他的中断请求都无法被响应,也就是说中断可能会丢失,所以中断处理程序要短且快


软中断

Linux系统为了解决中断处理程序执行过长和中断丢失的问题将中断过程分为了两个阶段,分别是上半部分和下半部分:

  • 上半部分: 用来快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间密切的事情。也称之为硬中断,主要负责耗时短的工作,特点是快速执行。
  • 下半部分:用来处理上半部未完成的工作,一般以“内核线程”的方式运行。也称之为软中断,耗时较长,特点是延迟执行

查看硬中断运行情况命令:$ cat /proc/interrupts

查看软中断运行情况命令:$ cat /proc/softirqs

每个CPU都有各自的软中断内核程序,可以使用ps命令查看内核线程,名字在中括号里的都是内核线程。

如果在使用top命令时发现CPU在软中断上的使用频率较高,而且使用率最高的进程也是软中断ksoftirqd时,可以认为系统的开销被软中断占据了。这时我们可以分析是哪种软中断类型导致的,一般都是网络接收软中断导致的,可以使用sar命令查看是哪个网卡有网络包接收,再用topdump抓网络包,做进一步分析,若该网络包的源头是非法地址则考虑防火墙增加规则,不是则考虑硬件升级。


8 为什么0.1+0.2不等于0.3


为什么负数要用补码表示?

主要是为了统一和正数的加减法操作一样,以统一的方式运算。

十进制小数是怎样转成二进制的?

十进制正数是除2取余法,十进制小数是乘2取整法。

计算机是怎样存小数的?

计算机是以浮点数的形式存储小数的,大多数计算机都是以IEEE 754标准定义的浮点数格式,包含三个部分:

  • 符号位:表示数的正负,0为正数,1为负数。
  • 指数位:小数点在数据中的位置,它可以为正也可以为负,指数位长度越长数值的表达范围越大。
  • 尾数位:小数点右侧的数字,即小数部分。位数精度决定了这个数的精度。

用32位来表示的浮点数称为单精度浮点数,即float变量;用64位来表示的浮点数称为双精度浮点数,也就是double变量。

为什么0.1+0.2不等于0.3

因为0.1和0.2这两个数字用二进制表示会是一个一直循环的二进制数,对于计算机而言无法精确表达,会造成精度损失,二者相加只能是一个近似数。

  • 32
    点赞
  • 43
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值