《CSAPP》笔记——信息表示、指令、处理器、性能优化、储存层次

文章目录

传送门

此系列文章分为三篇,本文对应CSAPP的第一卷:程序结构与执行,目标是让读者理解程序与硬件的交互作用。

第一卷:程序结构与执行——信息表示、指令、处理器、性能优化、储存层次
第二卷:在系统上运行程序——链接、异常控制流、虚拟内存
第三卷:程序间的交流与通信——系统级IO、网络编程、并发编程

概览

理念

计算机科班同学最应该学习的课程:计算机体系结构。

这门课的内容为,如何把硬件(处理器、内存、磁盘驱动器、网络基础设施)和软件(操作系统、编译器、库、网络协议)组合起来来支持应用程序的执行,以及程序员如何利用这些特征让程序更高效。

这门课看起来和应用没有关系,但是应用是运行在系统上的,其不可避免地会受到系统的影响,比如出明明你的程序从逻辑上来说一点问题都没有,但是就是会出bug,卡顿之类的,这个时候如果没有计算机体系结构知识,就会对这些问题无能为力,这种感觉是很难受的。

从更长远的角度看,不论是开发岗还是算法岗,学习体系结构知识可以帮助你学习编译器、操作系统、网络、计算机体系结构、嵌入式系统、存储系统知识,让你学的更快,学新框架,新技术的时候一眼就可以看出来原理,因为思想都是互通的。开发岗学了体系知识,就不容易被优化,更容易变成架构师。算法岗/研究人员学了,学习框架,配置环境,优化,加速的时候就更得心应手。

简言之,这门课不会帮助你更好的写代码,但是会让你深刻地认识到计算机体系中各个部件的构成,配合,可能出现的问题,以及如何优化,可以让你在面对各种问题的时候都可以从容解决。

本文脱胎于《深入理解计算机系统》,这本教材是卡耐基梅隆大学的教材,写的貌似是可以的,就是看翻译给不给力了,如果翻译不太好应当考虑读英文版。另外,理论上这门课应该在大二开,但是考虑到在北理工大一基本没学计算机知识,所以北理工大三开或许和卡耐基梅隆的大二开没啥区别。

五个基本事实

数据表示与计算:int不是整数,float不是实数

如果学过数据的具体表示,就能明白int和float都是有范围的,既然有范围,就不是数学意义上的数了,只能说int和float是计算机用二进制表示出来的,有限的数字。

学过底层数据表示以后,就可以理解第一张图的溢出,以及第二张图的浮点数截断问题。

在这里插入图片描述

在这里插入图片描述
计算机计算的原理是加法器,如果学过数字逻辑,就知道加法器的电路做法,基于加法器,产生乘法,减法,除法。

因为计算机计算的原理+数字储存的有限性,造成了计算机中的数字系统仅仅是对现实世界的一种模拟,这种模拟毕竟不是现实世界,就会与现实有一点差距,这一点差距平时无关痛痒,但是如果工作内容与这些误差有关,那就需要明确地学习。

机器级原理:你必须懂汇编语言

其实现在的年代,你基本用不上汇编,或者说绝大部分人不会用汇编去写程序,编译器比你做的更好。

但是汇编语言是对机器码(0101)的直接封装,理解汇编语言是理解机器级执行模式的关键,你可以从机器级别了解程序的运行原理,理解程序效率的影响因素,可以个性化地进行性能优化。
所以那一小部分用汇编的人,都是操作系统的设计者,开发语言的设计者,一般来说都是顶级工程师。

还有就是,学计算机的大概都有对信息安全的兴趣,空闲时间做个自娱自乐的黑客也是有趣的事情。

储存器很重要

首先是内存RAM。
虽然在程序中没有规定内存的使用范围,但是内存实际上,在物理上是有限制的,所以编程的时候必然涉及到对内存的分配和管理。

其次是各种储存器,Cache之类的。
不同的储存器性能差距很大,会显著影响程序性能,根据储存系统的特点调整程序可以极大地优化程序性能。

关于内存引用错误,这种错误一般出现在C语言级别的语言中,因为C语言为了追求性能,将系统暴露给了程序员,不做任何的内存保护。内存引用错误一般都很隐晦,很难被找出来,经典错误:

  1. 数组引用超界 Out of bounds array references
  2. 不合法的指针值 Invalid pointer values
  3. 分配和释放内存滥用 Abuses of malloc/free

那数组引用越界举例,引用越界可以导致本不属于数组的数据被数组数据覆盖。进而引发储存的错误,偏偏还不会报错,只会在运行的时候被程序员注意到,怎么这个数就错了呢?

拿着个图举例,结构体里有a数组和一个浮点数b。a数组占据2个地址,浮点数也占2个地址,因为在结构体里,所以是紧挨的。

在这里插入图片描述
如果给b赋值3.14,这时给a[0],a[1]赋值都不会影响b,但是给a[2]赋值,给a[3]赋值,就会导致越界,把b占据的内存覆盖一部分,如果进一步越界,可能就会触及到某些不知名空间,导致程序崩溃。
在这里插入图片描述

以上只是给出了一种错误情况与其影响,其实还可能有各种错误:

  1. 破坏不知名对象,甚至是程序,系统
  2. 产生一个延迟影响,当时看不出来,过一段时间才出现

要避免这种错误,如下:

  1. 采用Java、Ruby、Python、ML等编程,这些语言都对内存管理的功能进行了封装,但是相应的,还没有比C语言更高效的。
  2. 要么就是用C语言死磕到底,理解可能会发生什么相互影响 Understand what possible interactions may occur
  3. 使用或开发工具来检测引用错误(例如Valgrind) Use or develop tools to detect referencing errors (e.g. Valgrind)

性能:不仅仅是渐进复杂度

性能的影响因素是很多的,从上到下都有,算法,数据表示,过程,循环,以及系统,底层。需要注意的是,其实常数也是有很大影响的,只不过在渐进计数法中忽略了罢了。
所以要精准预测性能是不可能的,比如不同的写代码方式就可以导致10被性能差距。

要想实现对性能的大幅度优化,理解系统是必不可少的,你需要理解:

  1. 程序是如何编译和执行的 How programs compiled and executed
  2. 如何测量程序性能和识别瓶颈 How to measure program performance and identify bottlenecks
  3. 如何改进性能同时不破坏代码的模块性和通用性 How to improve performance without destroying code modularity and generality

比如下面这个图,从逻辑上讲,这两个代码是一模一样的,但是就是切换了一下内外循环,就会导致20倍的速度差距,如果不明白底层原理,不明白内存的读写,是无法看懂为什么的。

在这里插入图片描述

计算机系统的高级功能

计算机除了计算,还可以执行多种功能:

  1. I/O
  2. 并发操作
  3. 网络通信
  4. 跨平台兼容

课程内容

课程以计算机体系为核心,采取程序员视角(应用者),而非设计者视角,适合加深理解,虽然配一些Lab,但是真正需要动手开发的并不是很多,难度也不会很高。

在这里插入图片描述

下面是课程不同章节的内容与作业:

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

计算机系统漫游

本章跟踪hello world程序的生命周期,对系统进行一个全流程的简单解析。

首先看一眼注册表。Windows系统有注册表,注册表是按照文件目录结构组织的,很多人不知道注册表有什么用,其实就如他的名字一样,凡是你用过的软硬件设备,凡是经计算机管理的软硬件,都要被记录在注册表中。

具体说,HKEY_LOCAL_MACHINE/SYSTEM/CurrentControlSet/Enum里包含了系统控制的各种设备,包括USB,DISPLAY显示器等等,里面记录了这些设备的信息。

可以说,注册表里记录的信息就是物理硬件的抽象。所谓抽象,就是用一些数值表示物理硬件,就如同用一个公式表示物理规律一样。

在这里插入图片描述

注册表这里图一乐就好,言归正传。
程序的生命周期是从写代码开始,直到系统调用完成,退出程序。

信息就是位+上下文

所有程序都会有源代码,即源文件。
源文件就是我们平常说的代码,这些源文件记录了程序整体的逻辑,虽然不能直接执行,但是是执行的源头。

#include <stdio.h>

int main()
{
    printf("hello, world\n");
    return 0;
}

你看到的程序是一个一个的文本,一个一个的字符,但是在计算机实际的储存中,很多机器是用ASCII码存的,一个字节储存一个字符:

可以看到,其中不仅仅有代码,还有换行,空格等字符。

在这里插入图片描述

进一步讲,计算机储存所有数据都是用0101的二进制来实现的。

这里就可以解释文本文件和二进制文件的区别了,实际上,他们的底层存储都是二进制码,但是文本文件的二进制码都是ASCII编码,而其他文件有的就不是ASCII编码,或者说人家就不是用来表示文本的,所以这些就叫二进制文件。

这里还会有一个疑问,相同的01序列,有可能表示两个不同的东西,是如何区分这一串二进制码表示哪个数据对象的呢?你怎么知道这个文件是二进制文件,还是文本文件呢?

那就是上下文。具体是如何的,可以暂时理解为类似于前后缀的东西,比如0101010和10101001,开头为0就代表文本,开头是1就是二进制(这个例子是我的假想)

程序被其他程序翻译成不同的格式

C语言源代码是程序的高级表示,是人能读懂的,要想机器执行,就要转换成机器可以读懂的格式,即机器语言指令。将这些指令按照某种规格打包,就形成了可执行文件(比如windows的exe)。当然,这个可执行文件仍然是二进制磁盘文件。

具体过程由四步组成,这四步共同构成了编译系统。这四步每一步都把文件进行转化,生成一个中间文件,后缀不同,直到最终生成可执行文件。
在这里插入图片描述

  1. 预处理阶段。将C语言中所有预处理指令都处理了,生成.i文件,这个文件相当于一份完整的C语言源代码。C语言中,带#符号的指令都是预处理器指令,比如#include就是导入指令,这个预处理指令把对应的文件直接复制粘贴到对应位置。还有其他指令,比如#define 这个是进行宏替换。
  2. 编译阶段。编译器.i文件中的C语言转化成汇编语言,生成.s。汇编语言是对所有机器统一的,但是编程语言不是。对于不同的平台,不同的语言,可以使用不同的编译器把源代码转换成统一的汇编语言,比如C和Fortran的Hello world程序写法不同,但是汇编代码一样。
  3. 汇编阶段。将汇编语言转化成机器指令。生成.o目标文件
  4. 链接阶段。会便于这里的机器指令还不完善,因为#include只是告诉你去哪里找printf函数,但是printf函数具体怎么用机器语言执行,你是不知道的。在指定的地方,存在着预编译好的printf.o的文件,所以要把printf.o文件拼接到前面的.o文件中,补全.o文件,补全后一打包就成了可执行文件。

这一系列流程,现在都已经被整合,封装的很完善了,比如你在vs里写代码,直接F5就可以运行,不需要你去手动调用编译系统,他一下都执行完了。那我们为什么还要理解编译系统呢?

  1. 优化程序性能。就比如前面那个内外循环,虽然编译器可以为我们优化,但是优化的程度毕竟是有限的,更多时候需要我们思考怎么做才能让编译器优化到最好,换句话说,现在我们是将军,要指挥士兵。
  2. 理解链接错误。刚学C语言时,经常会写一大堆bug,最尴尬的是连编译都过不了,这个时候看报错就可能会有一些诸如“未解析的引用”,之类的错误,此时就需要链接相关的知识了。
  3. 避免安全漏洞。大部分网络安全漏洞出现在缓冲区溢出上,缓冲区是底层知识。

处理器读取并解释储存在他内存中的指令

此时已经有一个exe文件,里面都是二进制的指令信息,而这个exe是放在磁盘的。
在了解后续流程之前,需要先了解一点微机知识。

系统的硬件组成

这个知识我不会细说了,因为都在我的另一个文章里写了:

汇编语言与接口技术笔记

我这里只是简单的复述一下,再补充一点关于CPU指令的知识:

  1. 总线。总线是在主板上各个部件之间通信的通道,这个通道的宽度是固定bit数,比如32(4字节),64(8字节),不同的硬件有不同的宽度,但是宽度都称作字长,而一个字长的数据块就是一个“字”。
  2. I/O设备。磁盘严格意义上来说是算外部设备的,所以要通过通道加载到内存里。
  3. 主存。一般叫内存,是一种临时储存设备,这是缺点,优点就是速度快。从物理上来说,他是由一系列DRAM芯片组成的,DRAM可以实现动态随机存取,这就是他的速度来源,逻辑上把这些DRAM合并为一条字节数组,每一个字节都有地址。
  4. 处理器。负责计算,解释,执行指令。处理器内部也有储存设备,叫寄存器,很小,但是速度是和CPU计算同频的,没有更快的了。CPU的核心是一个叫程序计数器(PC)的储存器,PC的任务就是指向某条机器语言指令(PC存了个指针)。
  5. 从通电开始,CPU就在不断执行PC指向的指令。这时你可能会问,PC怎么更新,不更新就永远在重复执行一个指令了。更新的原理也很简单,就是PC指向的指令,不仅仅有当前指令需要做的01码,还有与PC地址切换相关的01码,在执行完当前指令相关的步骤后,这个指令还会令PC指向下一条指令,这样就无穷无尽,直到断电为止。
  6. 说到指令,指令就是一些关于主存,寄存器,计算,逻辑相关的01码,指令的数量并不多,有的也就100-200,加起来叫指令集。
  7. 具体说,指令可以执行加载(内存到寄存器),储存(寄存器到内存),操作(把两个寄存器的内容放到ALU上计算,并放回到指定寄存器上),跳转(从指令中抽取一个字,更新PC)
  8. 现代指令集已经很复杂了,所以把指令集的逻辑架构和实际实现分开了,逻辑上,指令集架构只需要描述一条指令可以干什么,理解为接口定义,而实际上,微体系结构描述了指令集如何实现

在这里插入图片描述

Hello World的执行过程

首先读入./hello指令(这是个加载指令)。
这个指令告诉CPU我要执行hello程序,告诉内存去磁盘的某个地方找程序加载 。

读入的顺序为:输入设备——桥芯片——寄存器——内存。
我是比较奇怪为什么不直接放到内存里?但是可能这两个之间没有直接通路,又或者CPU还需要处理一下。
实际上是要处理一下。

在这里插入图片描述

之后加载。
就是把磁盘中的二进制可执行程序(比如exe文件)以及可执行程序需要用到的数据,加载到内存中,开始执行。
这一步是磁盘直接到主存的,是通过DMA技术实现的。

在这里插入图片描述

最后执行。
执行的时候,CPU从内存中读取指令执行,同时不断更新PC,跳转指令。
这一步的输入是内存,输出有很多,比如内存,显示器(比如printf函数)

这里提一点,显示器在显示内容之前,要先把内容加载到显存之内。

在这里插入图片描述

高速缓存与储存器层次

在程序执行中,底层会执行大量的数据传输工作,那么数据传输,储存速度就制约了系统的性能。
再加上CPU越来越快,数据储存传输速度也就显得越发慢了。

在现有技术背景下,因为大容量的必然就慢(想一想CSDN写文章的时候,文章越长,就越卡),而想要提高速度,成本就又会提升,快速材料很贵。所以自然而然就产生了分级储存+缓存的想法。就比如CSDN写文章,长文章不直接写,而是先写到缓存文章中,然后粘贴到主文章去。

总而言之,外部和主板有差距,主板和CPU也有差距,且越是计算要求高的部位,越是频繁存取的部位,对容量的要求反而就没那么高,所以从外向内,容量越来越小,但是速度是越来越快。

  1. CPU里面的寄存器和CPU同频
  2. CPU里的Cache L1高速缓存,以及与CPU以特殊方式相连的L2高速缓存稍微慢一些,这L1-3的Cache采取SRAM技术实现,相比于DRAM(对应主存),S代表Static,比Dynamic更快。
  3. 更外面就是主存
  4. 之后是磁盘,也就是外行经常说的“内存”,比如512G的电脑,就是指磁盘,实际上严格意义上来说应该叫外存。
  5. 最后就是云计算储存资源,从本地到云要走网络,更慢。

在这里插入图片描述

操作系统管理硬件

前面执行hello程序其实是在另一个程序中的,即shell程序。
shell本身也是个程序,但shell,以及hello其实都不直接和硬件打交道,在应用程序与硬件之间还插了一个操作系统,操作系统定义了软件与硬件之间的接口,防止一些物理性的破坏,比如烧了机器等等。

在这里插入图片描述

在应用程序看来,他是接触不到处理器和主存以及I/O设备的,这些都被操作系统用另一种概念包装起来了,或者说提供给用户(软件)一种视图。

  1. 一切I/O都看做是文件,对文件的读写就是I/O的传输
  2. 把涉及到数据传输的部件:主存+I/O都封装成虚拟内存,总是就是都可以存数据
  3. 把一个程序要用到的所有的硬件接口都选择性地包装在进程中,一方面方便用户使用,同时也会限制用户做一些有害于硬件的操作。

在这里插入图片描述
之后就对这些抽象视图进行解释

进程

现代操作系统把一个又一个任务包装到一个又一个进程中,比如hello就是一个任务,也是一个进程。

进程可以同时有多个,这就是并发。就像你可以同时听音乐+写文章。

虽然看起来是同时运行的,但是那么多任务(至少100个),处理器却只有那么几个(比如我现在的8核电脑),也就是说一个CPU上会同时有好几个任务。但是实际上CPU是单线程的,CPU不能同时做两个任务,那为什么CPU看起来是并发的呢?只能有一种解释了:不同的任务在CPU上交错执行,比如先执行A的一步,再切到B执行一步,再切回A。

进程交错切换的技术叫上下文切换技术

上下文储存了程序执行的状态。这很好理解,就像游戏里的存档,没有上下文,你切回来的时候怎么恢复原来的状态?操作系统每一次切换进程的时候,都是先保存当前上下文(存档),然后恢复新进程的上下文(读档)

在这里插入图片描述

线程

把进程切分,就变成了线程。

进程可以理解为,不同的人干不同的任务。而线程,是一群人干一个任务。具体技术比较麻烦,后面会讲。

虚拟储存器

内存是一个重要的地方,如果任何程序都可以随意访问内存,那很容易造成内存区域信息的损坏。

所以现代操作系统是不允许用户进程直接访问物理内存的。这就要进行内存虚拟化。

对于每一个进程,都会在物理内存上划分出一段小区域作为进程的虚拟内存。对于每一个进程来说,他们看到的虚拟内存都是一模一样的(每个进程都觉得自己独占了内存),这个空间称作虚拟内存空间,但是在操作系统眼里,各个进程占据的虚拟内存到物理内存是有一个映射的。

下图给出Linux虚拟内存空间的安排方式。

在这里插入图片描述

  1. 上图地址,从下往上增加,最下是0,最上方是内核虚拟内存。即,虚拟内存空间是有上下限的。
  2. 程序代码,程序用到的数据。这一段内存是固定的
  3. 堆。程序的临时内存,堆可以动态伸缩,所以堆的左右是有一些不被占用的内存的。
  4. 共享库。存放共享库的地方,比如stdio这种标准函数。
  5. 用户栈。负责程序执行时的各种函数调用。栈也可以动态伸缩,但是上边是有上限的,所以有爆栈这种问题。
  6. 内核虚拟内存。这一部分只能通过内核调用,我的猜测是该区域记录了一些该进程相关的内核信息。

文件

文件本质上就是二进制序列。

从广泛的意义和实际的使用来说,所有IO设备,所有涉及到传输,储存的,都可以看做文件,包括磁盘、键盘、显示器,甚至网络都可以看做文件。

网络通信

到此为止,系统还只是一个孤立的个体,现在有了网络,系统之间互相连接,构成一个更大的系统。

网络的本质就是二进制串在不同主机之间的传输,其中介是网络适配器与数据传输线缆。

对一台主机来说,从磁盘到内存与从网络适配器到内存并没有什么区别,都是IO,这就是文件的好处。

基于网络,产生了云计算技术。实际上云计算并不仅仅是计算,云计算本质上有两种理解方式:

  1. 多进程通过网络通信形成的并发。
  2. 多台主机以网络介质,互相连接形成一个统一的集群主体。

重要概念

下面这几个概念会贯彻全书。

Amdahl定律(系统性能计算)

现代程序运行在一个大的系统上,因此系统的各个部分都可以影响程序性能。

Amdahl定律,评估了改进一个部分性能对总体性能的改善程度。

直观来看,该部分对性能贡献比例越大(该部分执行时间占用总执行时间比例大),或者对该部分性能改进越大,S值就越大。

在这里插入图片描述

α \alpha α值基本不会改变,而k值是可以改的,k越大,性能改进越大,S值越大。

比如一个部分为0.6的重要性,即 α = 0.6 \alpha=0.6 α=0.6,假设该部分提升了3倍,那么 S = 1.67 S=1.67 S=1.67。很明显,S永远比k小。

事实上,k对S的改进是有上限的,假设k是正无穷,S也只会提升到原来的 1 1 − α \dfrac{1}{1-\alpha} 1α1倍。所以要想对一个系统进行提升,不应该局限于一个部分的提升。

在这里插入图片描述

并发和并行(Concurrency and Parallelism)

  1. 并发:同时(concurrency)+多个活动。这是个通用的概念
  2. 并行:用并发技术使系统更快,更多的指平行(parallelism)

一般产业界是混起来的,但是英文区分的比较明白,考试的时候可能也会区分的明确一些。

线程级并发

一个CPU上可以实现进程级并发。

一个进程一般是有很多步骤的,包括计算+I/O

在最开始,当一个进程占据CPU的时候,程序还是顺序执行的。假如一个程序要先计算再储存,那么在程序计算的时候,IO还是空闲的。(此时因为CPU被占据,其他进程还在休眠)

如果可以把一个程序拆开,这就是线程。可以想到,一个程序拆了是很不容易的,但是至少可以把计算和IO分离,计算作为主线程,IO作为子线程。

在进程+线程的背景下,执行程序就变成了一个进程对应一群人。进程之间的切换就是一群人之间的切换。在一个进程占据CPU的时候,一群人同时做一种事情,但是内部还有具体分工,计算的计算,IO的IO,这就是线程。再回归进程级别,进程切换也可以理解为一些线程切换到另一些线程。

在一个核中,可以有多个进程,进程内部有线程,就变成了以下的形式。
但是,只要你是一个核,那你就只能是并行(parallel)。

在这里插入图片描述
多核出现以后,伴随超线程,才能真正实现并发(concurrent),这种并发是吧一个进程内部的线程拆开去放到不同的核上同时执行。

注意,这不是简单的拆开,是真正地同时,所以配套的各种东西都要有。

指令级并行

CPU是顺序执行的,但是并不代表一次不能执行多条指令。

CPU一次性执行多条指令也算一种并行。

在指令集并行出现之前,一般来说一个指令需要好多个时钟周期,但是有了指令集并发之后,从宏观来说,一条指令可能只需要一个时钟周期就执行完了。(微观来说,虽然一个人干的慢,但是同时有3个人干,即指令集并发,效果上就相当于一个人有了原来三倍的速度,即效果上加速一条指令的速度)

是否还能加速呢?当加速到一条指令都不需要一个时钟周期就可以执行完的时候,就叫做超标量处理器了。

SMID并行

单指令,多数据流并行。

这个技术通常在GPU上有,所以GPU的并行能力是很强的。

例题

在这里插入图片描述
A,进程切换实际上是通过操作系统,以及系统中断完成的。
B,进程的PCB都是由操作系统管理的

在这里插入图片描述
A,注意断句,主语是进程,而不是进程和线程
C,因为共享数据,所以效率肯定高
D,虚拟空间

在这里插入图片描述
这道题的并发和并行区分的特别明显

A,应该是对的(但是这道题默认为错),只能实现并行,不能并发。事实上,只要不是多核,就不能实现真正意义上的并发。
C,SMID并行多出现在GPU上,主要提高了多媒体数据的执行速度
D,多核是真正的并发,至于经典二字不需要抠字眼

抽象

抽象是计算机中最重要的概念。

抽象是一个很广泛的概念,和我们中国的抽象还不是特别一样。计算机中的抽象更多的是一种封装,把底层细节包装起来,封装了以后再暴露接口(视图)。

之所以重要,是因为从最基本的01,不断抽象,形成现在的计算机世界,其中工作量之大,单纯用01实现是不可能的,只有逐层封装抽象,才能让系统开发更有效率。

从底层01到计算机系统应用有几层抽象:

  1. 指令集体系结构提供实际处理器硬件的抽象 the instruction set architecture provides an abstraction of the actual processor hardware.
  2. 操作系统提供三个抽象:文件作为I/O设备抽象、虚存作为程序内存的抽象、进程作为运行程序的抽象 OS provides three abstractions: files as an abstraction of I/O devices, virtual memory as an abstraction of program memory, and processes as an abstraction of a running program.
  3. 新抽象:虚拟机提供整个计算机的抽象,包括OS、处理器和程序 a new one: the virtual machine, providing an abstraction of the entire computer, including the operating system, the processor, and the programs.

响应时间与吞吐量

  1. 响应时间。完成任务消耗的时间
  2. 吞吐量。吞吐量根据场景不同具有不同的意义,大致理解为执行速度,比如CPU吞吐可以理解为单位时间完成进程/事务的数量,网络传输吞吐可能就是2G/s这种。

吞吐速度的提升,本质上就是性能的提升,速度的提升。

吞吐速度上去了,响应时间自然就快了,也就不卡了。

执行时间探究

性能与相对性能

衡量程序性能一般用时间的倒数。很朴素。

相对性能就是性能之比,就是运行时间的反比。

在这里插入图片描述

测量执行时间

那问题来了,时间怎么测量?

简单用time类去测是不准确的。因为time类是软件部分,在软件执行之前还有系统硬件的各种操作,并且进程/线程之间也是有互斥的,time进程(线程)甚至可能被搁置,休眠。

实际上,程序经历的时间包含了很多方面,计算系统时间是一个复杂的工作。

在这里插入图片描述

CPU时间

CPU时间是最规则的,就是时钟。

因为时钟频率是固定的,直接用时钟周期数/频率就是任务消耗在CPU上的时间。

所以性能改进可以减少时钟周期数量,也可以提高CPU频率。

在这里插入图片描述

这不是一个解方程问题。
时钟频率=时钟数量/时钟时间
A时钟频率=1个单位的时钟周期/10秒
B时钟频率=1.2个单位的时钟周期/6秒
所以B时钟频率/A时钟频率=2,所以B的频率就是 2 × 2 G H z 2\times 2GHz 2×2GHz

前面说时钟周期数/频率,那么问题来了,时钟周期数怎么算?

时钟周期数=指令数×CPI

这个CPI其实是一个平均值,因为一个指令集里面的指令与指令需要消耗的时钟周期是不同的。

所以要么减少指令数,要么就减少CPI,即加快指令处理速度。

在这里插入图片描述
A的周期小,但是单指令消耗周期多,B的周期大,但是单指令消耗周期小。

比较AB的速度,可以直接比执行一条指令消耗的时间=CPI×时钟周期

进一步了解CPI。

CPI是一个平均值,但是加权平均其实是更加精确的,比如一个任务里某个指令执行的出现率很高,就应该给他高的权值。权值=该指令出现次数/所有指令的次数总和

下图给出两个不同的任务,分别计算器CPI。

在这里插入图片描述

最后进行总结:

CPU时间=每个程序的指令数 X 每条指令的时钟周期数 X 每个时钟周期的时间
本质上就是这三个在影响,从写代码,到编译,到指令集架构,到CPU硬件时钟周期,都可以影响CPU时间。
在这里插入图片描述

MIPS与性能度量

Millions of Instructions Per Second。

因为现在性能都比较强,所以用百万为单位计数。

M I P S = C l o c k R a t e P C I × 1 0 6 MIPS=\dfrac{Clock Rate}{PCI\times 10^6} MIPS=PCI×106ClockRate

时钟频率除以PCI可以计算出一秒钟执行的指令数,然后除以 1 0 6 10^6 106就是一秒钟的百万级指令数。

但是MIPS涉及到PCI,所以不同程序算出来还是不一样的。

信息表示与处理

10进制一方面因为10个手指比较自然,再加上10的n次方写起来就多个0,比较好写,所以10进制就比较广泛。

但是在现实世界,其实正反两面存在的更多,也就是所谓的阴阳。二进制的稳定性,简单性,可靠性有利于机器的实现,所以计算机采用二进制。而单独的bit位表示能力有限,但是组合起来,就可以对现实世界进行编码,配合解码手段,就可以把现实世界的信息存入计算机,之后再从计算机中显示出来。

数字表示有三种主要方式,无符号是最简单的,之后用补码表示有符号数,最后使用浮点数模拟实数。

因为位数有限,所以数都是可能溢出的,又因为浮点数只是在模拟,所以会有小数误差。

为了保证程序的正确运行+可移植性+安全性,学习数据表示是有必要的。但是实际上并没有太重要,所以可以挑重点学。

信息储存

出于效率考虑(其实有一篇论文),计算机使用8bit作为基本储存单位,即Byte。

储存空间逻辑上可以看做是很长的Byte数组,每一个Byte都有地址,所有可能的地址构成虚拟地址空间。之所以是虚拟,是为了防止程序破坏物理设备,而对储存空间进行整合。

C语言指针存的就是虚拟地址的值,虽然C语言指针绑定了类型,但是实际上生成的机器代码不包含类型信息,而是已经把类型转换成了关于访问空间长度的机器代码。

进制表示与转换

二进制,十进制,讲过。

十六进制是二进制的简化,可以表现出类似于10进制的计算便利性。给你一长串二进制相加,其实转成16进制算的更快。

字数据大小

每台计算机都有一个字长,表明指针数据的标准大小。比如现在的64位系统,指针就是8字节的,32位位对应4字节。同时,这个位也是寻址的位宽,比如32位对应 2 32 = 4 G 2^{32}=4G 232=4G的寻址空间,而扩展到64位的机器寻址空间大到可怕,有16EB,已经超出了我的理解范围。

同一个c语言程序,用32位模式和64位模式编译出的程序大不相同。比如long,在32位程序中是4字节,64位程序是8字节。为了避免这种模糊性,干脆就出了固定长度的整形, int32_t,int64_t。

具体的支持可移植性的机制还有很多。

寻址与字节顺序

虽然字节是按照顺序排列的,但是有从高到低和从低到高之分,分为大端法和小端法,Intel基本都是小端法,即数据的低字节存在低地址。现在没有统一的理论,所以这两种排列法都有。

对于一台机器,程序员基本是不知道顺序的。但是在网络环境下,机器与机器之间互相发送信息就会受到这种影响,如果是大端机器给小端机器发东西,如果不加任何处理,bit位就会反序,失去意义。这就是网络传输协议出现的背景,发送机器先把机器内代码转换成协议支持的格式,然后到另一台机器上,再转换成另一台机器支持的格式。

不过有的时候还是可以直接接触到的,比如在读机器指令,汇编代码那一级别的时候,使用反汇编器可以生成代码。生成的指令比如是 43 Ob 20 00这样的,实际上却是0x 00 20 0b 43。这是因为程序默认从低地址读到高地址,所以先写出来的是低字节,而人习惯于先读高字节。请注意,不是完全反过来,仅仅是子节反过来,子节内部是正常的。

还有一种接触到字节顺序的情况,是使用强制类型转换或者union类型的时候。一个指针的值代表空间的基址,类型代表其访问空间的大小,通过强制转换类型,可以更改一个指针默认访问的空间大小。

首先新建一个int=12345,然后建一个浮点数也是12345,最后取int的地址存到*int中。
分别在不同的系统上逐字节用以下程序打印16进制,得到下面的图

void show_bytes(byte_pointer start, size_t len) { 
	size_t i;
	for (i = O; i < len; i++){
	printf ("%.2x", start[i]); 
	printf("\n"); 
	}

在这里插入图片描述
可以看到,Sun是反的,这是因为他是大端机器。
其他类型,不同系统基本一致。而指针就不一样了,这是因为不同系统的内存安排不同,况且运行两次程序,内存也不会一样,需要注意Linux64系统的指针是64位的。

还有就是在进行强制转换后,int和float的储存发生了极大地改变,这就涉及到浮点数储存机制了。

表示字符串

字符串实际上是字符数组,结尾用\0来表示。

"12345"就是31 32 33 34 35 00

以上只是ASCII,为了表示世界上的文字,出现了Unicode,统一用4字节,但是这样又太占用空间,于是出现了UTF-8这种根据频率不同采用不同长度的编码。关键是UTF-8还兼容ASCII。

Java中用Unicode表示字符串。C也有Unicode库。

表示代码

同一串简单的代码在不同机器上编译后会有大不相同的结果:

Linux 32 55 89 e5 Sb 45 Oc 03 45 08 c9 c3
Windows 55 89 e5 Sb 45 Oc 03 45 08 5d c3
Sun 81 c3 eO 08 90 02 00 09
Linux 64 55 48 89 e5 89 7d fc 89 75 f8 03 45 fc c9 c3

所以可执行文件几乎是不可移植的,因为指令本身就不能移植,所以可移植性一般考虑源代码级别。比如C语言。

比特级操作

布尔代数

表示成二进制以后,如何运算呢,这就是数字逻辑,布尔代数的内容了。如何把逻辑转换成二进制数字,二进制数字换成逻辑,这就是数字逻辑的本质,这个本质被香农进行了总结,形成了信息论。

具体是什么原则,会在数字逻辑中学到,这里不再赘述。这里举一个运算的例子。比如一个异或门。在门的一端通电,中间架上异或门,如果不满足条件,另一边是0,如果满足条件,就是1,这个结构就相当于进行判断是否满足异或条件,01的转换就是计算的本质。

在程序中,有位运算,比如这四个就是经典的按位运算。

在这里插入图片描述
位还有更多玩法,比如位向量,位向量多用于对集合元素进行编码。给定一个大集合,通过1和0可以表示子集中有哪些元素。计算机的运算,是先把现实世界的东西建模(编码)成01,然后用01的方式去计算,比如两个集合的运算:比如集合并转化成了按位并,这就很有意思,通过编码把计算方式都改变了,不需要比集合,只需要比编码以后的东西就好了。
这让我想到了电路分析基础里面的相量法,只要把正弦量转化成相量,计算的过程就会简单很多,然后再把结果转回去,这和计算机的处理思路是一样的。正所谓,大道至简,道不可言。

在这里插入图片描述
又比如颜色,给出三原色,然后进行组合,形成了各种颜色的数据表示,之后用数据来计算,而不是用颜色直接计算。
这种例子还有很多,比如很多信号会中断程序运行,通过位向量进行掩码就可以进行选择性的屏蔽,计网中的子网掩码也是这种用途。

在这里插入图片描述

C语言中的位级运算

前面的与或非是最基本的位运算,还有一种就是异或:^(考试会着重考)

虽然数量不多,但是玩法倒是很花。计算的时候,先把16进制换成2进制,算完再弄回去。

下面展示了使用异或实现两数交换的操作(不需要第三个数),不使用第三个数纯粹就是个装逼的写法,并没有性能优势。第二张图,在reverse_array函数里,条件应该改成<,否则会出现自己和自己异或成0的情况。

在这里插入图片描述
在这里插入图片描述
这里详细给一个掩码的例子。
在掩码中,1用于取位,0则是屏蔽。

  1. & 0xFF就是保留低8位,其他位置0
  2. ^~0xFF是保留低8位,其他取反(高位是1,与1异或就是取反,低位是0,与0异或就是不变)
  3. | 0xFF是低8位置1,其他不变

掩码操作在C语言中有函数:

  1. 位设置:bis(int x,int m)=x|m
  2. 位清除:bic(int x,int m)=x&~m

C语言中的逻辑运算

逻辑运算是 || && !,与位级运算的区别在于,这是把一个数看做整体的,结果只要是非0就是True。

另一个区别是,逻辑运算有提前终止机制。如果逻辑运算的第一个参数就可以确定结果,就不会再求第二个参数,比如a&&5/a,如果a是0,结果就直接是0了,不会再去求5/a,p&&p++同理,如果p是Null,就不会执行p操作

C语言中的移位运算

>>和<<

左移位在空位补0,但是右移位分两种,逻辑右移是补0,算数右移是补最高位,这样可以保证最高位不变。
一般来说,有符号数都是算术右移,无符号数是逻辑右移。

在这里插入图片描述
容易出错的地方是,算术运算符的优先级高于移位运算

经典例题——bitCount(重要)

基于移位操作,可以写bitCount函数:

int bitCount(int x) {
	//x=5   b0101
    int m1 = 0x11 | (0x11 << 8); //这一步虽然实际没变,但是已经把m1扩展成了16位
    int mask = m1 | (m1 << 16); //再次扩位是0x00110000还是0x00000011?,在不同电脑上会结果不同吗?
    int s = x & mask;
    s += x>>1 & mask; 
    s += x>>2 & mask;
    s += x>>3 & mask;
    /* Now combine high and low order sums */
    s = s + (s >> 16);
    /* Low order 16 bits now consists of 4 sums.
       Split into two groups and sum */
    mask = 0xF | (0xF << 8);
    s = (s & mask) + ((s >> 4) & mask);
    return (s + (s>>8)) & 0x3F;
}

整数

整数编码表示

最开始是原码,常用于无符号数。
在这里插入图片描述

在有符号数中,采用补码。(反码已经不用了)
正数,最高位为0,所以第一项为0,第二项就是原码值
负数,最高位为1,所以第二项绝对不是原码值。

在这里插入图片描述
这个图形象地告诉我们,补码的最高位是负权。


原码和补码很好转换
整数补码就是原码,负数补码为其正数的原码全部取反后+1
那补码转回原码,同样是要看最高位是0还是1,0就不动,1就-1取反。
在这里插入图片描述
关于补码的范围,其实补码的范围和原码是一样的,只不过基本以0为中心分布了。比如原码是0-255,那补码就是-128-127,其实范围长度是不变的,这表明本质上8位二进制的一对一编码极限就是256个长度。

在补码中没有-0,所以负数比正数多1个范围。下图给出一些特殊的数。
0和-1的差距是最大的,最大和最小是反的。

在这里插入图片描述
在这里插入图片描述

下表清晰地给出负数补码的变化规律。可以看到,因为负数的负权重为1,所以正权重为0的时候负的程度最大,即1000,随着正权部分逐渐增大,负数的绝对值反而在逐渐缩小为-1。

在这里插入图片描述

类型转换

整数类型转换中,位是不变的,仅仅是解释不同,不保证值相等。因为正数解释规则相同,所以结果一样,但在负数情况下,会有不同的解释,即大数变负数,负数变大数。

本质上,这是因为这两种表达方式的范围不同,所以无法进行一对一的转换。但是反过来说,若是能进行一对一的转换,那也没必要用两种方式表示了

在这里插入图片描述
在这里插入图片描述
在高级语言中,比如java,干脆舍弃了unsigned。

类型转换有强制类型转换和隐式转换:

  1. 强制转换在编译时就已经转换了,是从位表示上就已经变了
  2. 隐式转换是在运行的时候才会转换,一定是解释型转换。这种转换尽量自己把控,否则可能会出现问题。比如一个同时具有unsigned和signed数的计算式,会统一转换成unsigned,那负数就会变成大正数,有奇怪的结果。

所以,尽量不要让你的计算中同时出现两种不同的编码。,即使有的编译器会做出一定的处理,比如下一节的扩位操作,但是仍然可能出现意想不到的情况。在自己写编译器的时候,可以考虑扩位操作来兼容一些异常情况。

在这里插入图片描述

在这里插入图片描述

扩展和截断

扩展

无符号数扩位很简单。
有符号数扩位比较奇特:

在这里插入图片描述
乍一看有点匪夷所思,不会影响数值么?但是计算一下就会发现值不会变。
正数很好理解,负数不变就有意思了。因为扩展的位都是1,所以负号保留,而且其他的1位,可以看做是从原码的0变化过来的,所以从实际值来说,相当于其对应的正数位在高位扩0,即绝对值不变,这个巧合真的很奇妙。

在这里插入图片描述

扩位的操作,直接进行类型转换就可以。

在这里插入图片描述

截断

扩展的时候,可以保证数值不变。但是截断就不一定了,如果数太大(比如70000)截断成16位,必然会出问题。

截断的方法和扩展不是逆过程,因为没有意义,所以直接取低位就行了,前面的所有,包括符号位,全部直接丢弃,用剩下的最高位作为符号位。

在这里插入图片描述

这里给出一些例子,
无符号数截断还行
正有符号数,如果超出阶段范围,就会溢出,比如01000,截断后变成1000,成了负数
负有符号数,必然会抽出范围,结果大概率都会变化,比如10001,截断后变成0001,比如11111,截断后成了1111

为什么不保留符号位呢?因为保留了也没意义。
01000,保留符号位就变成了0000,还是变了。
10001,保留符号位就变成了1001,还是变了。

既然一定会变,那干脆就直接截断算了。
本质上来说,截断就是在缩小编码空间,是破坏编码的行为,本身就容易出事,既然怎么搞都会出事,那干脆选择效率最高的。

在这里插入图片描述
为了实现上述的效果,计算机采用 m o d   2 k mod \ 2^k mod 2k来保留后k位。
无符号数直接取mod,有符号数是先转换成无符号数(比特位没变,只是解释变了)再mod运算。

各种算数的底层实现(加法,补码非,乘法,移位)

溢出破坏原理举例
加法

加法的底层实现是数字逻辑中的全加器阵列,无论是无符号数还是有符号数,都是直接把底层的二进制码放到全加器中相加。如果有溢出,就忽略,直接截断。截断的数学意义就是 m o d   2 w mod \ 2^w mod 2w

因为无符号和有符号的解释方式不同,所以截断后的效果也略有差距,至于为什么会出现不同的效果,那就是群,环,mod这些离散数学理论了,总之要明白,截断后的效果不是偶然,是必然。

无符号加法

结果如果溢出,就会成为 ( a + b ) m o d 2 w (a+b)mod 2^w (a+b)mod2w,w为最大位数。

mod是截断取余,取后w位,而前k-w位通过求商得到: s = ( a + b ) / 2 w s=(a+b)/2^w s=(a+b)/2w即溢出部分

在这里插入图片描述

这个图可视化了溢出过程,整体增加的方向是正方形的对角线方向,溢出后直接变回0,因为这是mod的特征。

在这里插入图片描述

补码加法

补码加法和原码加法一样。相当于先解释成无符号数求和后再解释成有符号数。

唯一的不同在于溢出的效果不同。因为符号位的存在,如果只是符号位溢出,不会影响结果,甚至符号位溢出本身就在考虑之中,你保留了溢出的位反而结果会出问题,截断了就奇妙的正常了。比如下面两个负数相加,符号位必然溢出。

下图演示了一个假溢出(实际上的计算结果没有溢出,只是两个负数的符号位溢出):

在这里插入图片描述

有符号的真正溢出(超出范围)非常复杂,正规的范围应该是正方体内的一个中点六边形,可以向负数方向溢出和正数方向溢出,溢出后补偿一个 2 w 2^w 2w,使得最后结果保持在有符号数范围内。

在这里插入图片描述
在这里插入图片描述

乘法

乘法比加法更容易溢出。精度很难保证,所以有高精度计算软件。

乘法的极限范围,粗略地说,是2w,所以乘法中间结果用2w来储存,先用数字逻辑实现正常的乘法

无符号乘法

算法同样是 ( u ⋅ v ) m o d 2 w (u\cdot v)mod 2^w (uv)mod2w,因为真实结果最多2w位,所以采用2w位作为中间计算结果,最终结果直接mod截断。

有符号乘法
代码安全示例:XDR库

核心在于有符号数和无符号数的乘法,以及malloc分配空间的溢出

移位实现乘除

无论是有符号还是无符号,无论是左移还是右移,直接移位+截断丢弃的原则是不变的,比如负数左移后的符号是不不确定的。

所以这几种移位的区别就在于如何补位以及效果了。

在这里插入图片描述

左移实现2的整数次幂乘

无论是有符号数还是无符号数,左移以后都是补0。

所以对负数使用左移是没有什么意义的,对于正数和无符号数使用左移,只要不溢出,都可以实现乘2的整数次幂的效果。

进一步地,复杂的乘法可能也会通过位移和加操作来实现。

右移实现除,以及修正右移

对于右移,有符号和无符号有区别。无符号与正数都是逻辑右移,补0。

对于负数,因为采取算术右移,补1,反而不同于负数左移,是有意义的。

右移还需要注意的一点是小数问题,正数右移是直接截断移出的小数部分,呈现趋0截断;而负数在直接截断溢出部分后,呈现出向下取整的特点,比如-56.1会变成-57。这一点就很迷惑,但是可以肯定的是,这和浮点数没关系,我猜测是负数补码过程中+1导致的。

在这里插入图片描述
在这里插入图片描述

因为负数补码移位后结果向下取整,所以采用修正的移位操作, ( x + 2 k − 1 ) / 2 k (x+2^k-1)/2^k (x+2k1)/2k,即先 + 2 k − 1 +2^k-1 +2k1,再进行移位。
效果就是:变成趋零截断,相当于给原来的结果+1,所以-590.8125就会先截断,再+1,最后变成-590。

在这里插入图片描述

通过移位实现常量乘除

移位实现幂次方乘积只适用于小部分情况,而更多的是常量乘积。在计算机中,这个也是会被编译器优化成移位与+的操作的,下面以12举例:

首先12=(1+2)×4,先用1+2实现3x,然后3x左移2位相当于乘以4。总的来说,编译器乘以常量相当于

在这里插入图片描述

无符号数,是逻辑右移。

在这里插入图片描述

重点在于补码负数除法,这里展示了修正移位机制:
可以看到,是先 + 2 3 − 1 +2^3-1 +231,然后再算数移位的。

在这里插入图片描述

补码非:求补与递增

对有符号数,求非是通过补码+1实现的:

在这里插入图片描述

原理比较简单,因为原数与按位取反后,加起来和每一位都是1,即补码-1。可得结论: x +   x = − 1 x+~x=-1 x+ x=1,所以 − x =   x + 1 -x=~x+1 x= x+1

在这里插入图片描述

有两个特殊例子,因为TMin按位取反后+1会导致符号位溢出,结果求补码非以后还是自己。
而0,补码非以后也是自己。
TMax还是正常的。

在这里插入图片描述

浮点数

二进制小数

同二进制整数,同样是按权展开。

在这里插入图片描述

但是这种方法,表示的空间是有限的,离散的。仅仅能表示 x / 2 k x/2^k x/2k形式的数,其他的都是近似,最终会变成无限循环小数(这是因为在乘2取整过程中,如果原来的数不能被2整除,那乘二取整就总会有余数,形成循环节)。

在这里插入图片描述

IEEE 754计算机浮点标准

因为随着小数点位置的不同,相同的二进制码会有不同的解释,小数的表示也是百花齐放,所以IEEE就指定了IEEE 754标准。

总的来说,好的浮点数标准,应该有足够的精度,且可以适应各种舍入,溢出情况。

浮点表达

从形式上来说,这是一种科学计数法表示。S确定整体的正负,E有8位,本身也可以表示负的指数。

在这里插入图片描述

从具体实现来看,exp和frac表示E和M。但是绝对不等同,而且根据情况不同还会有不同的解释,具体请看IEEE 标准

在这里插入图片描述

精度

精度由位数决定,最常用的是32位,但是很明显,32位的尾数只有23位,如果从10进制转到2进制时,数字长度太长,超过23位,就会损失精度。所以出现了双精度,在exp(11)和frac(52)上都有扩展

所以在与32位int转换的时候,不一定完全等价,且不说有效二进制位就不够,浮点exp能表示的范围也和int不同(详见CSAPP-datalab : floatFloat2Int函数)

在这里插入图片描述

规格数

如果exp是非全0,以及非全1,总的来说就是正常的数,就都是规格化表示。

在规格化情况下:

  1. 尾数部分必然是1.xxx,所以大可把1省去,用frac表示小数部分,最后+1表示M,这样可以说是凭空增加了1位。
  2. 阶码是移码表示,本身是无符号数,换算成带符号的要偏置一下。实际的阶码=E-127,实际的阶码可以取到-127-128(这一点和补码稍微有些差距,补码是-128-127)

这里给出一个从实数到单精度的计算过程,你也可以倒着算回去:
在这里插入图片描述

从极小规格数到非规格数再到0(特殊非规格数)

当exp全0,这时E不是-127,而是-127+1,E=-126。

浮点数极其趋近于0,那这个时候frac默认补1就没有意义了,此时前导1就变成了前导0,因为此时我们要以最大的精度表示趋近于0的数字。

当exp为 0000 0001,此时E=exp-127=-126,和exp为 0000 0000时一样。这就有趣了,既然极小规格数和非规格数的E是一样的,那这两个又有什么区别呢?即前导1和前导0的区别。本质上说,非规格数是把E中最后一位能表示的信息转移到了frac位,让frac位有了更强的表达能力,这就是所谓的精度提升。具体来说,我没有去细究的想法,就此略过。

从当非规格数(exp=0)的frac=0,此时表示0,随S的不同而表示+0,-0

提问:exp不为0且不全1(规格数)的时候frac=0,表示的是0吗?

不是,因为exp=0的时候,前导1变成0了,但是exp不为0的时候,frac是1.xxxx,不可能表示0。所以,0一定是非规格数。

最后,非规格数与exp=1的极小规格数这个区间,是罕见的均匀分布。因为E是固定为-126的,所以尾数部分就决定了实际的值。从极小规格数的1.1111 1111 1111 1111 1111 111到1.0000 0000 0000 0000 0000 000到最大非规格数的0.1111 1111 1111 1111 1111 111到0.0000 0000 0000 0000 0000 000,是连续的,间距稳定的,很神奇。

特殊值总结

exp全1,当frac全为0,则E=exp-127=128

此时指数是最大的,所以表示Inf

exp全1,但是frac不全0,比Inf都大,显然不合理,所以就表示Nan(Not a number——不是数,一般溢出以后是这个表达)

exp全0,当frac不为0,则表示E=-126,前导0为0,即M为0.xxxxx的极小数。

exp全0,当frac全0,就变成了0。根据符号位为0或1,就有+0 -0之分(这里强调,浮点数不是补码)。从这一点看,浮点数+0和补码0是一样的,浮点数-0和补码最小数一样。

在这里插入图片描述

范围可视化与值的表达

exp全1,frac非0,表示Nan
exp全1,frac为0,表示Inf
exp介于全1全0之间,frac任意,表示规格数,在正数部分最小为frac全0,exp为1的时候,此时frac为1(仅有前导1),最大为exp为1111 1110的时候,frac全1的时候。
exp全0,frac任意,表示非规格数,在非负数部分,最小为frac全0,代表0,最大为frac全1,前导为0。

从大到小,浮点数在数轴上顺序排列,而且从极小规格数到非规格数不会发生重叠,这一个优秀的结果是非常令人意外的。

在这里插入图片描述

我们用6位的IEEE格式检验一下,就会发现,整体分布是外部稀疏,内部稠密的,逐渐变密集,而再往内部走,间距就稳定了,极小规格与非规格是区间连续+间距稳定的

在这里插入图片描述

在这里插入图片描述

示例

这是一个规范化数。

在这里插入图片描述

exp全0,是非规范化数,E=-126,frac前导为0

在这里插入图片描述

在这里插入图片描述

舍入,加法和乘法

舍入

舍入有四种:

在这里插入图片描述

在整数中,是采用趋零截断的,但是浮点数为了保证精度,是采用偶数舍入的。因为趋零截断可能导致较大偏差,不如1.9,截断后变成1,很明显不合理,所以浮点数采用偶数截断。

偶数舍入更像是四舍五入,即偏向哪一方就变成哪一方,如果正好在中间,就把结果变成偶数。

具体来说,就是让截断后的数和截断前的数的绝对值差距最小:

在这里插入图片描述
在这道题中,要舍入到第二位小数,那就要比较剩下的小数与中间数的大小了。中间数就是100,如果大于100,就向上进位,小于100,就直接截断,等于100,就要让结果为偶数。

第三个例子,是10.11,因为最后一位是1,所以偶数舍入要进位,最终变成11.00

第四个例子,是10.10,最后一位是0,所以偶数舍入不进位,变成10.10,无论是11.00还是10.10,都是偶数。

加法

浮点运算

  1. 统一阶码为第一个操作数的阶码E1。
  2. 有符号数frac对齐相加
  3. 修正。最起码要进行舍入,而且这个时候frac可能不在范围内,还有一些其他问题。
    在这里插入图片描述

举例:

注意这里舍入了,丢弃部分为中间值,所以结果变为偶数。

在这里插入图片描述

从数学性质上来说,浮点数在结合上是不精确的,因为溢出与舍入。

在这里插入图片描述

浮点乘法

在这里插入图片描述

C语言浮点数

C语言浮点数基本符合IEEE标准,提供float(23),double(52)两种数据格式。

其核心在于转换。

在这里插入图片描述
浮点数比较容易出问题,这里举出一些例子:

在这里插入图片描述

程序的机器级表示

汇编是机器代码的文本表示,虽然现在基本不用写汇编了,但是能看懂还是有必要的。

基础

程序最后转化为指令,指令属于指令集,运行在CPU上。
指令集是对机器码的封装,其中是一些常用的硬件操作,与硬件高度耦合,如果硬件稍微不同,指令集就不能正常运行。所以每次出厂,指令集都是直接烧录在CPU里的。

Intel处理器和架构演变

指令集

谈到处理器不得不提到x86。因为指令集和硬件,以及用对应指令集写的软件是严格匹配的,所以谈到x86,可以泛指这一系列对应概念。

指令集有很多种,主要分为CISC和RISC。CISC比较复杂,种类多,功耗大,但是Intel已经占领了市场,所以目前还是以CISC为主。

x86指令集属于Intel,是32位的。在64方面,AMD扩展到了x86_64,与此同时Intel的IA 64夭折,最后大家都用x86_64了,所谓的Intel 64就是这个。x86性能很强,是市面上主流的架构,其主要是CISC,引入了一些RISC。

ARM和MIPS也是指令集,只不过主要是RISC,引入部分CISC,其性能差x86很多,但是ARM功耗低,成本低,MIPS是学院派产物,纯计算能力很强,授权门槛也很低。中国的龙芯用的是MIPS。

在这里插入图片描述

x86演进

在这里插入图片描述

汇编基础(重点,需要自己看书)

汇编介于软硬件之间。

在这里插入图片描述

汇编视图
  1. 指令集架构(ISA,Instruction set architecture)。包括指令集以及其对应的硬件架构。指令集必须运行在确定的架构上。比如x86指令集只能在x86CPU上,实际上都是烧录的,你自己也搞不了。
  2. 机器代码是二进制码,汇编是指令(封装二进制机器码)的文本形式。

在这里插入图片描述

汇编的内容无非就是3类,数据(实际上都是整形数,只不过解释不同),地址(以整形数据形式储存),指令。

在这里插入图片描述

地址一般是定长的,64位机器就是8字节
指令一般是不定长,越频繁的指令越短,加速指令处理速度

在这里插入图片描述

汇编基础操作
  1. 传送数据(内存与寄存器之间,寄存器之间)
  2. 计算(寄存器中的数运算)
  3. 传递控制(跳转,条件,间接分支)

数据传送

基础写法

详见汇编,这里和汇编写法上的区别在于,这里是源操作数先写,目标操作数后写。

操作数写法(注意这里的源操作数和目标操作数位置):

  1. 立即数。$前缀 比如$0x80
  2. 寄存器,%前缀,比如%rax
  3. 内存,(数值),比如(%rax),($0x80)

在这里插入图片描述

指令有后缀q,指令后缀很多时候可写可不写,写了就是显式声明,不写也可以通过大小判断出来。

通用写法

下面这种写法一般是数组,对应汇编中的比例变址写法。 基址+数组首地址+索引×元素大小(注意,段寄存器这里还没有指定)

在这里插入图片描述
在这里插入图片描述

定长与扩展

移动的时候可以指定移动大小(貌似也可以通过mov自动确定大小,但是有时候mov无法确定大小)

在这里插入图片描述

下面给出一些等长例子

在这里插入图片描述

既然有等长,那也一定有短的移动到长的空间的情况,这时就要补位。补位方式由零扩展和符号扩展。零扩展类似于逻辑移位,前面补0,不同长度的传送扩展需要用零扩展movz(zero)指令。还有符号扩展movs(sign)指令,类似于算数移位,前面补符号位。

在这里插入图片描述在这里插入图片描述
在这里插入图片描述

下面给出例子:

在这里插入图片描述

坑点

生成4字节数字的指令会把高位4字节置零。这是一个很古怪的规定,是为了扩展与兼容设定的。

所以movzbl,在扩展到4字节后会把高4字节清零。显然我们不想要这个副作用,但是必须要考虑到这种情况。

算数和逻辑运算

地址计算leaq

首先介绍一个特殊的算数指令leaq。这个指令其实是用来计算地址的,可以将我们前面写的那种通用地址转化成实际地址,赋值给目标寄存器。但是leaq本质上还是个算数指令,可以利用他来做算术运算,因为有三个操作数,所以也比较方便。

q代表8字节,64位机统一用q。

在这里插入图片描述
在这里插入图片描述

算术指令

下面给出的指令是真正的算数指令。注意:

  1. 汇编不关注你是有符号还是无符号数,在他看来只有0和1,有无符号数的运算实现由汇编程序设计者实现。
  2. 下面的计算,都是dest=dest op src,注意顺序

这些指令都是英文缩写,其全拼为: add,subtract,multiply有两种,imul是有符号数,mul是无符号数,sal(shift arithmetic left),sar(shift arithmetic right),shr(shift right),xor,and,or,increase,decrease,negative,not
在这里插入图片描述在这里插入图片描述

下图给出一些例子,左右是对照的。其中大量使用了leaq这个特殊指令。

在这里插入图片描述

目前还没有发现二元操作里两个操作数都是内存中的情况,大概计算和mov是一样的,不允许双内存操作数。

在这里插入图片描述

特殊的运算

以下这些计算都是用单操作数写法实现双操作数,是比较落后的写法。首先要赋给rax基础值,之后再用一个操作数与其作用,最后赋给默认的寄存器(一般是高rdx:低rax)。

图中的写法比较特别,R:R代表两个寄存器拼起来。R[%rax]表示rax的寄存器值。第一个代表,S和rax乘,分两段赋给rdx:rax

在这里插入图片描述

下图给出例子,例子一:把rsi值赋给rax,用rax和rdx无符号相乘,高位赋给rdx,低位留在rax,最后两步把寄存器数挪到内存。例子二:把rdx值赋给r8,rdi赋给rax,cqto将rax的符号位扩到rdx上,用rdx:rax(扩位后rax)作为被除数,除以rsi,商存在rax,余数存在rdx,最后将寄存器数挪到内存中。

在这里插入图片描述

C、汇编、机器代码

正向工程

从c语言源文件,到二进制文件的过程比较多,包括编译,汇编,链接,但是只需要一个命令:gcc 1.c 2.c -o target

还有一些参数,比如-Og代表基本优化,-O1就是进一步优化。
-o代表生成可执行文件,-c代表生成目标代码文件,-S代表生成汇编代码。

gcc -O1 -o main main.c //生成目标文件,指定名字,使用O1优化级别

gcc -Og -c main.c //生成.o文件

gcc -Og -S main.c //生成汇编文件

在这里插入图片描述

虽然编译出来的汇编语言是下面这种模样,但是实际上我们只需要看中间一部分即可,其他以.开头的都是伪指令。

在这里插入图片描述
在这里插入图片描述

最后形成的二进制文件,以指令为单位。一条指令由指令地址:指令内容组成。之所以需要指令地址,这是因为指令长度不确定,有长有短。就像下面这个指令,是中等长度,48代表movq,89代表rax,03应该是rbx计算出的内存偏移。至于程序的内存段,由程序运行初期,操作系统指定。

在这里插入图片描述

逆向工程

反汇编是逆向工程,可以是二进制到汇编,也可以是汇编到源代码。

在C语言编译,汇编,链接成二进制程序的过程中,需要保持语义一致:虽然写法可以不一样,但是要表达的意图要一样。即执行的结果要一样(过程是否一定要一样不一定)。汇编过程尚不能保证语义完美一致,反汇编更是难。比如,一个函数可以有多个参数,但是寄存器有限,所以很多时候参数不存在寄存器中。这种变化给反汇编带来难度。

反汇编的经典工具是objdump

在这里插入图片描述
在这里插入图片描述

另一个反汇编工具是gdb

在这里插入图片描述

控制

在这里插入图片描述

可以看到,.L1 .L2都是标号,类似于flag,je和jmp类似于goto。汇编底层通过条件+跳转实现控制流。

条件码:控制基础

条件码含义

程序执行的信息有临时数据(一些通用寄存器),栈顶位置rsp,代码指针rip,以及最近状态,最近状态存在标志寄存器中,更多含义详见汇编。SF和ZF用户判断正负,CF和OF用于判断溢出(无符号/有符号),这两组互相配合,可以判断各种结果情况。

在这里插入图片描述

最近状态实际上是所有进程共享的,但是因为同一时间只有一个进程,所以可以看成独享。

在这里插入图片描述

条件码修改

条件码不可以直接修改,但是可以通过运算来修改,可以说,条件码变化是运算的副作用。

一般的计算都会改变条件码,只有leaq之类的特殊计算不改变。还有一些命令,只会影响条件码,比如cmp命令和test命令,虽然结果不会存,但是会改变条件码,可以利用这个特性进行调节。

记住,des-src,不管是正写还是反写,都是des做第一个操作数的。(这里的des指广义上的des)

在这里插入图片描述
在这里插入图片描述

条件码读取

下面给出条件码读取的指令:

指令都是单操作数,将条件码进行逻辑组合后的位信息赋给操作数的最低位,其他位不影响。其他位可以自己进行movz之类的操作去清零。

因为条件码是组合后赋值的,所以可以蕴含多种信息,这一位可以用来直接进行一般的条件判断。

在这里插入图片描述

下为判断大小的例子,使用setg(greater),因为只改变目标空间,比如setne %al,只会设置一个字节,剩下部分不影响。又比如setne %ax,会设置两个字节。

如果要用32位,就要用0扩展把左边24位清零(但是机器会出问题,把高4字节也清零了,这是指令的问题,不是人的问题)。

注意cmp是比des:src,所以cmp要和实际c语言代码写法反一下。

在这里插入图片描述

条件分支

跳转

一般来说,都是先规定一个.label,然后我们jmp .label即可跳转过去。跳转实际上就是在切换PC指针。

  1. 直接跳转。给标签是直接跳转,以标签位置作为跳转目标。
  2. 间接跳转。这是通过寻址方法,找出跳转目标去跳转。
jmp *%rax  ;寄存器寻址方法,用寄存器本身的值当做目标地址
jmp *(%rax)  ;间接寻址方法,用寄存器的值去内存中寻址,从内存中读出目标地址

再来具体探讨一下跳转指令的编码,这有利于后面学链接操作,以及理解反汇编器的输出。

从汇编课可知,SHORT和NEAR跳转都是用偏移量跳转,而间接跳转以及FAR都是直接给出目标地址,这分别对应了PC相对编码与绝对编码:

拿书中的例子举例什么是PC相对编码。

在这里插入图片描述

反汇编中,第一个jmp后面的注释是jmp 8,对应L2标签,第二个jmp注释为5,对应L3标签。但是这个注释是反汇编器给你的,实际上你看代码,会发现jmp 8实际上是03,jmp 5实际上是f8。这两个与标签有什么关系呢?

0x8-0x5(jmp的下一条地址)=0x3
0x5-0xd(jmp的下一条地址)=-8=0xf8

PC相对编码=目标地址-jmp指令的下一条指令地址
那么跳转的时候,只需要用PC值(jmp的下一条指令地址)+PC相对编码=目标指令地址。

问题来了,PC值不应该指向当前指令吗?这就是jmp指令的特别指出,jmp指令的时候PC是要对应下一条指令的,这可以追溯到以前的实现。

在这里插入图片描述

jmpX条件跳转

jmp是无条件的,其他各自有各自的含义,根据条件码的组合进行跳转。含义实际上和前面的setX的X部分是一致的。

在这里插入图片描述

举例(if x>y):

首先把xy的寄存器反写,代表x:y,之后cmp命令更新条件码。之后对else设置jle跳转(小于等于),否则就顺序执行。最后把rax的值return。

不过,这个ret并不是真的return,仅仅代表程序结束。因为结果已经存rax里了,实际上所谓return只不过是让外面承接的变量直接读取rax即可。

现在,C语言中的控制流(比如if else)已经可以被模块化地转换成汇编代码了。

在这里插入图片描述

在C语言代码和汇编代码之间,可以用GOTO写法来做过渡。GOTO写法告诉你汇编代码大概长什么样,尤其是跳转点的位置在哪里。GOTO写法可以理解为汇编的伪代码。

在这里插入图片描述

cmovX条件传送

随着时代发展,条件控制和MOV被结合在了一起:

条件传送和mov的区别,除了加了条件,还规定了SRC只能是寄存器,且不支持单字节。这是因为一般条件传送都是先计算再传送,都计算了,自然默认你是寄存器里面的了。

在这里插入图片描述

这样可以减少指令数量,符合现代CPU性能特性,提高CPU效率,以及编译效率。

之所以用cmov效率更高,是因为现在的CPU都是流水线工艺。一条指令的处理分为不同阶段,这样就可以提高利用效率(前一条指令在执行阶段2,后一条指令执行阶段1,流水线行动)

这样就要求指令序列一定是连续的,一定要让CPU流水线中充满了指令。但是你如果要走条件分支,你就不能确定后面要用什么代码。即使现在已经有分支预测逻辑可以实现90%的预测准确率,但是一旦预测错误,就会浪费15-30个时钟周期去重新调指令。

如果使用cmov,就相当于把分支结构变成了顺序结构,让性能更加稳定。

下图中,先把两个结果都计算出来,最后使用cmov命令。
但是很明显也有缺点,就是计算量变大,需要的储存空间也大了(比如多用了寄存器),而且有风险,毕竟多一次计算,Flag就会被改变,而且还有很多意想不到的副作用。

所以,这种优化通常在控制程序中使用,而不是计算程序。

在这里插入图片描述

在这里插入图片描述

  1. 用自己和自己异或,清零,此时ZF=1
  2. 0-1,变成负数,SF=1,从效果上来看,假设看做无符号数,此时就产生进位,所以CF=1。由此可见,CF和OF不管你是有符号还是无符号,反正CF就当你是无符号,OF就当你是有符号。
  3. -1和2比较,结果是负的,SF=1,但是最高位不变,所以CF和OF都是0
  4. setl,此时SF=1,所以setl %al,将al设置为0x01(00000001)
  5. 最后进行零扩展,mov不是计算,所以不影响标志位。

在这里插入图片描述

循环

do while

在这里插入图片描述

shrq的结果会影响条件码。jne:非0,即移位后非零就跳转回开头。

在这里插入图片描述

while

while只是对do while做一个修改:首先跳转到test步骤,之后和do while一样。

在这里插入图片描述

另一种就是通用的版本,逻辑上更符合while。初始进行条件判断,更加安全。

在这里插入图片描述
在这里插入图片描述

for

for可以理解为,在while循环外,加一个init,在循环末尾加最后的操作。之后再把这两个操作叠加到while的汇编块中。

在这里插入图片描述

下图为带入口条件测试的while,但是这个入口测试可以优化。因为你的初始值已经明确了,所以在编译的时候就可以计算出逻辑值,根据逻辑值进行优化。而不需要等到运行的时候。

在这里插入图片描述

switch语句

跳转表

switch语句需要考虑一些特殊情况,比如:

  1. 多标签
  2. 又比如没加break的语句
  3. 条件不连续(1235)

在这里插入图片描述

因为情况复杂,顺序不定,而且目标代码块很多,所以采用二级跳转结构。以跳转表jtab作为中介。

在这里插入图片描述

以下图为例。L4就是跳转表首地址,通过rdi在跳转表中索引到代码块地址,最终是要跳到这个地址的。

注意区分.L4(,%rsi,8)和*.L4(,%rsi,8),前者是指向跳转表描述符的指针,是描述符的地址。但是真正代码块地址是描述符的值,所以用*取出描述符内容。

在这里插入图片描述

构建跳转表过程:

不管你case里面的值顺序如何,反正代码块在代码区是连续排列的。而在跳转表中,则会把可能的条件都遍历一次,按照条件值将跳转目标排列出来。

在这里插入图片描述

至于用条件值在跳表中寻找目标,则直接用偏移量即可。*.L4(,%rdi,8),其中.L4是跳表基址,然后一个跳表描述符是8字节,计算出目标地址后,用*取值jmp到代码块即可。

注意,前面还会进行大小匹配,通过cmp以及条件跳转jmp,防止case索引超出跳转表。

一些case的分析

正常情况下,是case 3这种,通过3在跳转表里间接找到对应的块.L9即可。之后break就ret。你会发现一个特殊的地方,明明w=1是在switch外面赋初值的,但是却在case 3的开始赋值。这说明编译器存在一种机制,会把switch前赋初值命令转移到每个case的开头,并且为这个开头加一个标签(比如下面的L9)。这种机制我也不明白为什么,但是就确实存在。

可能会问,case2的L5开头为什么没有,这是因为case2是直接赋值,w有没有初值都无所谓,我猜测是被优化掉了。

回归case2。按照我们前面的推测,case2中没有加break,理应和case3连起来执行,但是因为前面特殊的优化机制,L9这一块需要跳过,所以在case2的末尾加一个jmp .L6,跳过这个初始化。

在这里插入图片描述

在这里插入图片描述

如果5是空的,那么编译器就不会为5创建标签,而是把5,6都对应到.L7。
这里看到L7前面也有w赋初值,这印证了我前面说的,编译器会把外面赋初值的语句转移到每个需要用w初值的分支开头。

在这里插入图片描述

从二进制代码中找出跳转表

看命令,cmp+ja是对case范围的判断。下一步jmpq就是跳转表。所以0x4007f0就是跳转表地址。在汇编中,所有的标签都会被转化成二进制地址。

在这里插入图片描述

在这里插入图片描述

过程

所谓过程,基本就可以理解为函数,传入一组参数,返回一个结果。在不同的语言中有不同的叫法,比如函数function,方法method,子例程subroutine,处理函数handler等等。

函数调用过程需要使用栈,学过数据结构的都知道,栈的特性是保存路径,或者说保存过去状态。push保存路径,pop操作可以实现回退,这是函数调用的基础。

实现一个过程需要三个机制:

  1. 传递控制。简单说就是转移到函数代码,还要转移回来
  2. 传递数据。传入参数,得到返回值
  3. 分配和释放内存。函数有局部变量,需要用栈分配空间,返回的时候还需要释放内存。

栈结构

下图给出一个程序的逻辑内存空间。

在这里插入图片描述

首先是分布问题。栈在一端,代码在另一端,这是因为栈要不断地push,所以尽量给他足够的伸缩空间。
另一个是方向问题,栈顶是朝着低地址方向走的。每次push,栈顶地址会减小,之后写入内存的时候,正好也是从低地址开始写入的。所以这种奇怪的方向是因为机器是小端法表示才采用的。

比如,pushq就是-8,pushb就是-1

在这里插入图片描述

下图给出pushq的例子。

在这里插入图片描述

注意,pushq的 src可以是内存/寄存器,但是pop必须到寄存器中(因为你pop出来肯定是要马上用的,寄存器是不二选择)

而且,pop仅仅是修改指针,然后赋值到寄存器中,至于原来的内存中的数,不做修改,自生自灭(被后来者覆盖)。

在这里插入图片描述

调用规则

前面只是大概给出了栈的生长方向,这里给出真正的栈帧。所谓栈帧,就是一种约定俗成的结构,因为程序是以函数为单位的,所以一个函数对应栈上的一片区域就很合理,虽然大小不同,但是分区以及结构是基本不变的。这样可以提高系统效率。(虽然很多函数简单到没必要构造栈帧)

假设P函数调用Q,那么P会先在栈上传入7-n个参数。之所以是7开始,是因为前6个可以通过寄存器传。构造完参数后,把返回地址压入栈就可以跳转到目标函数代码了。

跳转到目标函数代码,就是进入了一个新的函数,要构造新栈帧。第一步是先把P函数的寄存器保存了,因为Q也要用寄存器,为了防止破坏掉P函数的寄存器,就得先保存。然后就是给局部变量开一点空间,最后还有一个参数构造区,这个区域用于进一步调用其他函数

在这里插入图片描述

传递控制

所谓控制,就是控制下一步读取哪个指令,进一步说,就是修改ip指针。
ip不可以赋值,但是会在call的时候被系统修改。

在这里插入图片描述

在这里插入图片描述

call的时候,ip指向目标函数首地址,与此同时,将return地址(call地址的下一个地址)push进去。

在这里插入图片描述

ret的时候,把栈里的地址pop到ip里去。

在这里插入图片描述

传递数据

传参用寄存器,返回用rax,其他参数(太大的,或者太多的,从7-n号参数)用栈动态储存。

复杂的调用需要用栈来实现,比如递归,大量传参,大量返回。我们数据结构里学的栈,只能push,pop,不能直接访问内部数据,不必担心,这里的栈可以随意访问栈内元素,只不过增加和删除只能用push,pop,而读取是不限制的,可以用rsp+8读取内部数据。

6个寄存器参数的顺序和栈的构造顺序下图给出,都是约定俗成的。

在这里插入图片描述

例子:

下图中,mult2函数的a,b参数是通过寄存器(rdi,rsi)传入的。所谓的传入也只是在函数外给寄存器赋值,然后再函数内用这个寄存器的值。

在ret之前,rax里已经有要返回的值了,所谓的返回,就是提前在函数内把rax设成要返回的值,在函数外直接用rax即可。

到目前为止,还没有涉及到栈,但是据我所知,复杂的传参(比如参数超过寄存器数量)和返回(返回一个大结果)都是通过栈实现的,毕竟寄存器就那么点,有时候传入和返回的东西可不是寄存器那点空间能容得下的。

在这里插入图片描述

给出一个简单的例子,说明如何通过栈传参。

栈传参从本质上来说,是把参数内容存到栈里,然后给出栈顶指针传入函数即可实现参数传递。在被调用的函数里,直接用rsp+偏移。

举个例子,假设指定后两个参数通过栈传递。传入一个short,一个int,一个double,共三个参数。此时会把int和double压栈,然后把rsp传入函数。在被调用函数里,用rsp就对应double,rsp+8对应int,rsp+12对应short。

拿书里的例子看:

在这里插入图片描述

可以看到,534和1057都是通过栈传入的。

在这里插入图片描述

下面的例子和我这个想法略有不同,他是把int(参数的实体)放到栈里,然后用leaq命令计算出地址,赋值给rdi寄存器,通过这个寄存器把一个参数传进去。我上面的思路是通过一个指针传一堆参数,这个思路是一个寄存器(存有指针)对应一个参数。

这种一个参数对应一个寄存器(存指针)的思路,在面对特别多参数的情况下就没办法了,实际上更多的是书中的例子。

在这里插入图片描述
在这里插入图片描述

管理局部数据

局部变量有一些是用寄存器,另外一些是通过栈储存的。

寄存器局部变量

因为寄存器是共享的,所以存在寄存器里的局部变量在函数调用的时候一定要保护起来,这就是寄存器保护。回顾栈帧,在进行栈上局部变量构造之前,要先保存寄存器,这些寄存器里存的就是调用者的局部变量。

保护寄存器局部变量要分为调用者和被调用者两方面,先说被调用者保护。下图给出需要被调用者保护的寄存器:

假设P调用Q。

这些寄存器,一进入Q代码段,Q就会把这些寄存器放栈帧,调用结束的时候,Q再把栈帧里的寄存器恢复。这样,P在调用前后,不用担心这些寄存器被Q修改。

虽然保护的是P的寄存器,但是做出这个保护行为的是Q,所以叫做被调用者保护。

在这里插入图片描述

相比于被调用者保护寄存器,调用者保护寄存器是得不到保护的。这些寄存器往往用作传参,就是要被被调用者修改的。

P要想保护这些寄存器里的值,P函数就要先把这些寄存器的值挪到被调用者保护寄存器中,这样就不会被修改了。因为这个过程是P做的,所以叫调用者保护。

在这里插入图片描述

给出教材的例子:

在这里插入图片描述

假设M调用P,P调用Q。

2,3行:一进入P函数,P就把rbp和rbx压栈了,这就是P作为被调用者对寄存器的保护。可见调用与被调用是相对的。

4,5行:P函数作为调用者,把rdi移动到了rbp,这是在做调用者保护,挪到rbp中,x可以作为局部变量,不受Q函数影响。

6行:传参

8,9行:把rax存到rbx,又是在做调用者保护。此时u也是局部变量,不可以被Q函数破坏。

13 14行:P函数把M的寄存器恢复,返回到M函数去。

在这里插入图片描述

再给出一个例子:

1-7行都是被调用者保护。

9-14,都是用寄存器保存局部变量

15-18,寄存器不够了,把局部变量放到栈上

在这里插入图片描述

栈上的局部变量

总的来说,有如下情况需要局部变量存栈:

  1. 寄存器不够了
  2. 取地址&
  3. 局部变量是数组/结构,实际上也是地址

看到这里,建议再回到开始看一眼栈帧的具体结构。这里直接给例子,这个例子综合了传参,局部变量:

在这里插入图片描述
在这里插入图片描述

函数开了32长度的栈帧,因为要用4个局部变量,且都要用到地址,所以直接存在栈中。
前6个参数都通过寄存器传,78两个参数,即使已经在栈里了,也要送到应该去的参数构造区,因为这是栈帧的规定,便于整体的实现。

注意,下面这个栈帧里面有一些空的,这是为了对齐,后面会说对齐。

在这里插入图片描述

递归过程

递归过程中,尤其注意的是要保护局部变量+返回值累加计算,下面给出例子:

在这里插入图片描述

n是局部变量,所以每次都要存在rbx中,充分利用被调用者保护。

在递归的过程中,先向下延伸,直到到达递归终点。从终点的eax=1开始,eax被n不断地回退,累乘,最后得到结果。rax没有进行任何保护,被所有递归过程都操作过一次。

在这里插入图片描述

数据

数组

基本概念

数组,这一块简略的说即可,C语言教的差不多了。曾经我学指针时最离谱的时候,是一个程序全用指针写,各种星号套着用,叠加用,现在已经懒得去写数组的基本知识了。

数组本质上来说就是指针,其指向一片连续区域。需要区分的概念无非就是指针数组和数组指针。指针数组,就是一个数组全是指针。数组指针,就是指向一个数组的指针,声明的时候会规定指向数组的大小。

数组指针和普通指针有何区别?这涉及到指针的本质,指针其实是一个地址+地址的大小信息。即使是指向同一个地址的数组指针与普通指针,也不是一个东西,因为他们指向的空间大小不同。

所以,一维数组其实就是一个普通指针,二维数组就是一个数组指针。

#include<stdio.h>

int main(void) 
{
	int M[2][3]={1,2,3,4,5,6};
	
	int* p_int=(*(M+1)+2);//指向4字节空间,相当于&M[1][2]
	int m12=M[1][2];
	
	int (*p_list)[3]=M; //指向12字节空间,这个空间是长度为3的int数组
	
	
	printf("%d %d\n",*p_int,m12);
	printf("%d %d\n",*(*(M+1)+2),p_list[1][2]); //M和p_list基本是等同的
}

至于嵌套数组,只需要知道行优先原则即可。

取地址与取值

这里主要是说一下汇编与数组操作,尤其是mov和leaq的区分:

rdx储存了一维数组的头指针,本身是地址。

movl (%rdx) 是取内存中的值
leaq 4(%rdx) 是计算内存的地址

在这里插入图片描述

关于嵌套数组的取值,要经过比较复杂的伸缩计算,为什么不用imulq?因为这三条的代价更低:

在这里插入图片描述

定长数组

定长数组就是我们平时的数组。

这种数组结构清晰,便于优化:

比如如果在循环中,如果每次都要用 M [ i ] [ j ] M[i][j] M[i][j]这种方式取值,则每次都要进行计算。但是可以把伸缩计算变成指针移动,就可以大大减少指令代价。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

变长数组

变长数组并不是python中list这种变长的结构。而是在运行过程中确定大小的数组。

举个例子

int fix[2][3];//定长
int m,n;
scanf("%d %d",&m,&n);
int flex[m][n];//变长,如果编译器版本不够会报错

定长和变长的本质区别在于,能否在编译阶段与汇编阶段,就确定数组的尺寸。很显然,变长是无法确定的。这会带来什么影响呢?通过索引的取值代价不同了。

在这里插入图片描述

定长数组,数组的伸缩量(一行元素的长度)是确定的,可以在编译与汇编阶段,就把取值代码优化成leaq的组合。

但是变长数组,伸缩量不能确定,只能用imulq操作去计算伸缩量,一旦用了imulq,计算代价就会很大。
在这里插入图片描述

虽然变长数组的取值代价变大,但是在循环中还是可以优化。可以看到,虽然免不了imulq操作,但是只需要在循环外计算一次即可,循环内还是指针移动。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

异质的数据结构

结构

结构储存在栈中,是一种异质的数据结构。储存方式和数组类似,连续储存。

结构成员的选择,实际上就是加偏移量,结构的定义其实就是在规定偏移量的多少,仅此而已。

在这里插入图片描述
在这里插入图片描述

联合

联合可以让成员公用空间。一般来说,联合还要搭配结构使用,结构里包一个标签,表明此时的联合代表什么,然后联合储存具体的值:

在字段很多的情况下,可以节省很多空间。

在这里插入图片描述

联合还有一个特殊的用法,就是查看位模式。

如果你把一个浮点数转成int,他的位模式就会被改变,而值保持不变。但是如果是一个联合,位模式始终不变。

在这里插入图片描述

数据对齐

数据对齐:数据地址必须是数据大小K的倍数。这种要求简化了物理实现。

在这里插入图片描述
比如:

在这里插入图片描述
在这里插入图片描述

此外,结构的末尾可能还要补充空间,防止结构数组出现问题。(结构容易产生特殊长度的类型)

在这里插入图片描述

浮点数

TODO

实践

TODO

处理器体系结构

在这里插入图片描述

ISA(Instruction Set Architecture)

ISA:Instruction Set Architecture

指令集体系架构,提供了机器语言的语义抽象。
ISA沟通了编译和CPU硬件。

在这里插入图片描述

Y86简化架构

x86比较复杂,这里用Y86架构(x86的简化)讲课,举例,但是仅限于理论。
这一节大概理解就行了,不用背,仅供教学。

在这里插入图片描述

在这里插入图片描述

stat,代表CPU执行状态,1为正常,234为三种异常值

在这里插入图片描述

Y86指令编码

在这里插入图片描述

在这里插入图片描述

  1. 采用小端法储存
  2. 指令最长10字节(不一定10字节),通过第一个字节就可以确定指令的具体类型,也就确定了指令的长度了。
  3. 黄色区域是0字节。高4位决定了指令的类别,具体细分通过低4位进一步区分,比如cmov指令,的高4位fn。
  4. 粉色区域是1字节,高4位和低4位分别表示两个寄存器。需要注意的是,mrmovq和rmmovq,在写指令的时候,会切换位置,但是编码以后,一定是register在高4位,memory对应寄存器在低4位。可能会有疑问,固定用字节编码两个寄存器,没有内存寻址吗?是有的,用寄存器间接寻址,且寻址模式只支持一个偏移量。
    在这里插入图片描述
    在这里插入图片描述
Y86具体指令

Op类指令,只有加减,没有乘。剩下的是与或。

在这里插入图片描述

传送类指令:

rr之间直接送(图中有点小问题,rA,rB的编码位置没给出来)

其余涉及到立即数或者地址,都写在高字节区域。

在这里插入图片描述

举例,看第三个,50是mrmovq,15,1代表rcx(register),5代表rbp(内存间接寻址),后面的f4 ff ff ···实际上是-12(注意这里是小端法储存,后面那些ff都是高字节)

在这里插入图片描述
在这里插入图片描述

跳转与子程序调用指令:

这里使用直接地址编码,而不是PC相对编码。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

栈操作:

x86一致。

在这里插入图片描述

在这里插入图片描述

Y86程序示例——数组寻址

Y86因为指令少,尤其是没有比例变址寻址法,所以写数组十分困难:

两种解决方法,要么就写源代码的时候就按照Y86格式写。直接在C语言里就把指针移动写出来。
更优秀的方案是做一个Y86的编译器。所以,编译器的设计者要同时考虑指令集架构与高级语言。

总的来说,指令集太过简单就会导致汇编更加困难,所以就需要权衡,进行合理的封装,充分但不冗余。

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

反汇编与指令集

假设给定一个二进制串,有一个问题:如何知道串中的哪几个二进制值是一个指令?如何知道一个指令有多长?

答案在于指令集本身。指令集有一个特征,就是只要知道code字节,就可以确定一条指令的长度,分区。知道长度后就可以确定下一条指令的的code字节,依次类推。

只要序列的第一个字节是指令的第一个字节,就一定能确定所有指令的内容。但是问题在于,有时候第一个字节不是指令的code,这就给反汇编带来了难度。

RISC与CISC

ISA主要由两种,一种RISC,另一种CISC。对应有两种CPU,指令集与CPU硬件紧密关联。

但是,即使是一种指令集架构,CPU也有很多版本区分。

在这里插入图片描述

我们用的Y86是CISC和RISC的结合体,实际上现在也没有纯粹的CISC和RISC指令集了,都要取长补短的。

在这里插入图片描述

RISC功能有限,体积小。适用于单片机之类的小机器。值得一提是,python可以编译出RISC架构的指令,可以送到GPU,单片机上跑。

在这里插入图片描述

RISC的思维就是:简单且固定。这样就可以让指令集效率提高,执行时间减小。缺点就是没有那么灵活。

注意:

  1. RISC编码定长,寻址模式简单,执行快
  2. RISC指令较少
  3. RISC只用寄存器传参(但是寄存器更多)
  4. RISC没有条件码,用寄存器存放test指令结果

在这里插入图片描述

CISC用栈实现过程链接,是因为CISC内存够大,而RISC的传参只能用寄存器来回倒腾。

在这里插入图片描述

最后看一眼MIPS,可见寄存器数量更多,功能也更多。

在这里插入图片描述

逻辑硬件设计

强电,本质在于能量,弱电,本质在于信息。微电子,数字逻辑都是典型的弱点学科。

二进制,通过阴阳两极来使得电路稳定,进而产生了数字逻辑电路设计。
注意,注意,注意,0只不过是低电位,而不是不给电。

在这里插入图片描述

最基础的门是与或非,实际上是与非,或非,非。通过门电路的组合,可以表达出各种复杂的布尔函数。此处略写,默认学过数字逻辑。

从时间维度来说,输入其实是连续的,正常情况下是稳定不变的。那么用变化作为信号成本就比较低,这就是时序逻辑的核心,这也就是触发器大行其道的原因。

需要注意的是,电路中是有延迟的,虽然很短,但是有时候会有比较重要的影响。

组合逻辑

组合逻辑实际上就是用逻辑门组合出一个布尔函数。

从具体设计来说,先要有布尔函数,再写出初始的真值表,进行逻辑优化(比如卡诺图),最后做出来一个布尔逻辑模块。

高级的设计,是用布尔逻辑模块进行进一步的组合。

在这里插入图片描述

这是一个简单的例子,展现了如何通过组合逻辑实现布尔函数,可以看到,布尔函数的写法与硬件逻辑是一一对应的,或者说布尔表达式就是硬件的符号描述。这种符号描述语言就是HCL

下面是一个同或函数。同1同0都是1。

在这里插入图片描述在这里插入图片描述

下图展示了,如何通过已有的模块进一步组合。下图用64个位相等模块组合出一个字相等模块。

在这里插入图片描述

给定一个模块,可以根据输入输出的对应,写出一个HCL case表达式。

在这里插入图片描述

到CPU中,ALU单元如下图。4个一组,用4路选择器进行选择,然后给输入,最后得到一个输出,同时将标志位改变。

回顾Y86,add指令是60,6代表op,0其实是多路选择器的选择码。

在这里插入图片描述

时序逻辑

组合逻辑是输入后,经过短暂延迟马上输出,而时序逻辑的核心在于储存。

稳态原理

从物理电路来说,下面这种构造方法,在0和1的状态下是稳定的,或者说0和1是吸引子。这种吸引子就是记忆,储存的基础。当然,这里是通电情况的储存,如果是断电,寄存器的值就没了,如果要储存,就需要转化为磁(磁盘的原理)

在这里插入图片描述

锁存器、触发器

根据稳态原理,最开始出现的是锁存器:
在不给置位信号的状态下(都是0),仍然是有输出的。
给定置0/置1信号,会修改输出。即使给定置位信号后,又变成双0,输出仍然可以保持。

在这里插入图片描述

基于基础锁存器,后面不断演变,出来了D-锁存器,是最常用的锁存器。
通过时钟信号,发挥使能作用。
C=0的时候,无论d怎么变,输出都不会变(储存状态)
C=1的时候允许置位,或者说输出与d保持一致(略有延迟)

在这里插入图片描述
C从0到1,此时d和Q不同,于是会有一个同步。
之后C=1的时候,d与Q保持同步
最后C=0,d不论怎么变,Q都不变

在这里插入图片描述

锁存器还有一个问题,就是C=1的时候会让输入与输出保持同步,我们前面说信号传递应该在变化之中,而不是状态之上,所以应当是C从0变1的时候才装入d值。

基于D锁存器与SR锁存器,产生了触发器,当触发的时候才会装入d值。

从效果上来说,以前的输出在时间上是与输入同步的,现在的输出在时间上是与时钟逻辑同步的。

在这里插入图片描述

寄存器与RAM(寄存器文件)

寄存器是触发器的大成。
一系列触发器以及一个时钟组合为一个寄存器,一个寄存器可以保存一个字(这个是计算机内部的字长)(不是我们前面说的程序寄存器,那个比较复杂)。
在时钟触发的时候,将值装入寄存器。

在这里插入图片描述

RAM(Random Access Memory),这其实才是真正的寄存器,更应该称之为寄存器文件。

读写都需要提供值接口与地址接口。

关于地址接口,在Y86背景下(寄存器长度固定,没有rax,eax,ax,al之分),地址应该是寄存器的编码(0-0xF),寻址机制是多路选择器,通过4位地址进行多路选择。

在这里插入图片描述

先说写操作,就是经典的时钟装载,每一个时钟触发都会将值装入寄存器。

再说读。单从寄存器角度来说,读取是不受时钟控制的,类似于组合逻辑。因为获取触发器的输出不需要时钟的同意。但是呢,你读出来是要用的,虽然读本身不受时钟限制,但是后面的操作还是以时钟为单位的,所以从另一种意义上来说,读也是受时钟限制的。

在这里插入图片描述6

ALU累加逻辑

ALU计算模块实际上是一个状态机。以加法举例,先看一下结构:

  1. 红色的是寄存器,被clock控制
  2. ALU单元,这里默认多路选择码=0,对应add。ALU接受来自于外部的一个输入,以及寄存器的输出。这是为了累加。
  3. 多路选择器,0对应ALU输出,1对应直接输入,通过LOAD值进行选择。LOAD为1,代表把输入装载到寄存器中,LOAD=0,代表把ALU输出装载到寄存器中。

走一下下面的时序图:

  1. 每一个clock,都要更新寄存器,也就是刷新输出
  2. 刚开始LOAD=1,此时clock触发,把x0装载到寄存器中。
  3. 后面LOAD=0,此时clock再触发,就是把ALU输出装载到寄存器中。
  4. ALU将输入与输出(LOAD=0时为前一次ALU的求和结果)再次求和。得到(x0)+x1。
  5. ALU将输入与输出(LOAD=0时为前一次ALU的求和结果)再次求和。得到(x0+x1)+x2。
  6. LOAD=1,此时clock触发,则将x3装载到寄存器中。后面又是新的一轮累加

从效果上来说:

  1. LOAD=1,那么就是ALU=In
  2. LOAD=0,那么每次每次的操作都是ALU+=In

在这里插入图片描述

HDL

硬件描述语言,比如Verilog,这里的HDL更加简单:

在这里插入图片描述
在这里插入图片描述

顺序处理

  1. 取指,单纯的取出指令
  2. 译码,将指令的编码翻译,比如把rA和rB翻译出来,去对应寄存器寻找操作数。
  3. 执行,把数送到ALU算,可以算值或者地址
  4. 内存,读写内存
  5. 写回,把ALU新计算出来的寄存器值或者内存中的值送到寄存器中(这也可以理解,为什么内存和内存不能直接传了),同时把PC换成下一个指令地址。

处理器无限循环这5个阶段,只要指令正确,且有电。如果出现异常(stat为三个异常值),就会进入异常处理模式(我们这里简单处理,直接终止)

在这里插入图片描述
这个总图比较有用,常回来看看。

在这里插入图片描述

上面的硬件看起来很复杂,但是实际上是成本最低的方法,在硬件上就是要尽可能地共用,因为硬件成本是要远大于软件成本的。在实现共用的前提下,如何把不同的指令放到同一个硬件框架中,就是更宏观的问题了。

在这里插入图片描述

从这个图可以看出,不同指令的执行流程是统一的,只不过不同指令在不同阶段的执行情况不同,有的指令甚至在一些阶段不执行。

再给一个具体的例子,14是指令的地址。rdx和rbx分别是9,21。

在这里插入图片描述

再举两个例子。valC是立即数,代表地址的偏移量。
不论是r到m还是m到r,rB永远代表Memory地址,所以valE=valB+valC,为计算出的内存地址。

下图有点小问题,从内存中取出的值应该是valM,原书写成valE了。

push过程的具体执行其实不是先改变rsp,再放值的,而是先计算出valE这个中间值,在访存阶段就用valE放入目标,最后才把valE写到rsp的。

在这里插入图片描述

看看跳转指令。call和ret逻辑上是反的。

call先取rsp地址值为valB,然后把valB-8,valE是新的栈顶地址,之后把valP(下一条指令地址)压入栈,最后把新的栈顶地址写回rsp。

ret过程,因为ret在逻辑上是要先取值再改指针的,但是流水线是不允许改变执行流程的,所以一次性把赋给了valA和valB,valA负责取值,valB负责计算新的地址。

在这里插入图片描述

分阶段解析

取指

取指比较简单,就是从PC指向的空间取指令。PC指针是上一次操作的结果规定的。
PC在最初是会初始化的,之后就可以不停歇地运行了。

在这里插入图片描述

观察硬件图:

  1. 首先通过PC获取指令(长度根据icode确定)
  2. Split部件把第一个字节(指令字节),icode和ifun分离出去。
  3. 通过icode计算出附加信息
  4. Align部件根据附加信息,把操作数对齐,变成rA,rB,valC(不一定都有)。
  5. 最后,通过附加信息,PC increment模块计算出相邻指令地址valP=当前地址+指令长度

由此,取指阶段生成了icode,ifun,rA,rB,valC,valP。

在这里插入图片描述

指令可以产生一些附加信息:
比如,Need regids怎么算呢?其实就是判断icode是否是那些需要寄存器的指令:

在这里插入图片描述

在这里插入图片描述

译码

图比较复杂,从下往上解释:

  1. rA可以生成dstM和srcA,一个是写端口,一个是读端口,对应的是同一个寄存器。rB同理,也可以生成一个寄存器的读写端口,为dstE,srcB。
  2. 具体控制读写,由icode控制,icode生成了对这4种读写的选择,比如我选择从A读,B写(至于怎么生成的,大概类似于卡诺图的布尔函数,但是要更复杂)。
  3. 通过寄存器指定(rA,rB)与操作指定(icode),就可以取出valA和valB。或者是把valM和valE写入寄存器。

这里补充一点,valM来自于memory,valE来自于ALU计算结果。

在这里插入图片描述

看下图,将icode解码后,就可以产生黄色框里的逻辑,至于操作数从哪来,到哪去,就是valA,valB,valE,valM的事情了。

注意,即使是从寄存器到寄存器传送,也需要走一趟ALU或者内存,总之不可以直接传。

看最后的ret指令,先把栈顶指针送到valA中,然后valA经过ALU计算,得到内存中的返回地址,最后再回写到PC指针。

在这里插入图片描述

还需要注意的是,decode后,有的绿色框(逻辑)是空的,代表不需要进行写回操作。

在这里插入图片描述

执行

从下往上:

  1. valA和valB都是rA,rB计算后,从寄存器取出来的值,valC是你输入的立即数。三选二,不一定会都有。
  2. ifun决定ALU的计算模式
  3. icode控制哪些数据要送进去计算。
  4. ALU有两个前置选择器,ALUA和ALUB先进行初步选择,之后才计算,送到ALU后输出valE。
  5. 除了输出valE,还会输出CC(3bit),根据CC可以进行cond跳转/传送。

在这里插入图片描述

mov,要走一趟ALU,啥都不做,就是+0,再回写。

在这里插入图片描述
在这里插入图片描述

内存

从下往上:

  1. valA和valE共同影响地址。valP影响数据
  2. icode控制是读还是写,而且还会控制写入内容,是用valA还是valE,还是valP?
  3. 多通道影响stat,代表了指令的执行是正确还是错误的。

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

回写

对于寄存器的回写,前面译码阶段已经接触到了。

回写还要更新PC,PC的更新受很多因素影响:

  1. icode
  2. condition,比如条件跳转
  3. valC:输入的立即数。比如 jmp label
  4. valM:内存的输出。比如内存的间接跳转
  5. valP:从指令中计算出的值

在这里插入图片描述

在这里插入图片描述

顺序操作(SEQ)

从宏观上来说,一个指令的总体过程其实就是两个阶段:

  1. 组合逻辑计算,自动计算,但是会有固有的时延
  2. 状态的储存,装载。通过时钟触发,装载过程也会有一点时延

在这里插入图片描述在这里插入图片描述

在这种逻辑下,其实一条指令只需要一个时钟周期即可:触发寄存器装载,寄存器装载了新值后通过组合逻辑计算新的输出。

这个时候,你就理解了时钟的本质了,时钟控制着寄存器装载,所以一定要给装载和计算留出足够时间,否则没等计算完,你就又要重新装载,必然出错。从硬件实现上来说,时钟随便加快的,之所以时钟频率上不去,其实是受到架构中的延迟限制的。

SEQ指令模式,使用恰当的指令编码方式,将每一条指令的处理流程规范化,便于逻辑设计,这是其优点。
SEQ模式的缺点在于,一条指令执行完,另一条指令才能从头开始。因为一条指令的不同阶段实际上是在用不同的资源,在取指阶段,其他4个阶段的资源(内存,寄存器)都处于静息状态,自然造成了资源的浪费。
这样会带来一个副作用,即时钟周期没办法短,必须等组合逻辑全部计算完毕才能进行新的触发,这导致人们没办法加快时钟周期。

SEQ+,或者说是pipline模式,就是对这种缺点的改进,将使用不同资源的不同阶段拆分。比如指令1取指完毕,开始译码的时候,指令2开始取指,指令1译码完毕开始计算的时候,指令2开始译码,指令3开始取指。

说白了,就是不让一个指令占着茅坑不拉屎。

这样,虽然还是串行,但是充分利用了不同的资源,从效果上来说,可以极大地提高吞吐量。

流水线(pipline)

流水线原理

流水线将指令不同阶段拆分。问题来了,怎么把组合逻辑拆分呢?

直接拆+用寄存器隔开即可。

给定一个组合逻辑,这是最初的顺序执行,装载需要20ps,组合逻辑计算需要300ps。
在这里插入图片描述

现在把组合逻辑拆3块,中间用3个寄存器隔开。
你会看到,单条指令的时延变长了一些,但是整体吞吐量却增加了将近3倍,因为资源的利用率也几乎提高了3倍。

比如指令1取指完毕,开始译码的时候,指令2开始取指,指令1译码完毕开始计算的时候,指令2开始译码,指令3开始取指。从效果上来说,有点像是并行。但是请注意,这不是并行,本质上仍然是串行。

在这里插入图片描述

同时处理并不代表并行(仍然是有先后的,是阶段性的先后)

在这里插入图片描述

吞吐量计算

最简单的办法:吞吐量= 1 时钟周期 = 1000 时钟周期 G I P S \dfrac{1}{时钟周期}=\dfrac{1000}{时钟周期} GIPS 时钟周期1=时钟周期1000GIPS

这个比较抽象,如何理解呢?
其实,吞吐量= 1 延迟时间 × 同时执行的指令数 \dfrac{1}{延迟时间}\times 同时执行的指令数 延迟时间1×同时执行的指令数,延迟=同时执行的指令数×时钟周期。所以稍微转换一下就是上面的公式。

限制

非统一时延

理想情况下,我们应当是把组合逻辑的时延均匀地切分。然而呢,指令和指令之间本身消耗的时间就不同,而且有的硬件单元不能进一步切分,所以均匀切分就更难了。

当时延不统一的时候,时钟周期就会被那个耗时最久的组合逻辑所限制。在处理这个组合逻辑的时候(下图150ps),前面那个50ps的组合逻辑就必须多等100ps(150-50),浪费了资源。

在这里插入图片描述

寄存器开销

无论你如何切分组合逻辑,永远都是一个组合逻辑后跟一个寄存器。

但是呢,组合逻辑越切越细,时延越来越小,而单个寄存器的时延却是固定的,这就造成了组合逻辑时延:寄存器时延的值越来越小。这意味着,随着切割的加深,寄存器在一个阶段中消耗的时间越来越多,甚至超过了计算组合逻辑本身。

所以,流水线级别不能太高,也不能太低。

在这里插入图片描述

反馈与数据冒险

在这里插入图片描述

假设一条指令,要依赖于上一条指令的结果。那么流水线就会出问题,因为第一条指令还没处理完,你第二条指令就要用,这肯定会出问题。

在这里插入图片描述
在这里插入图片描述

解决这种问题的方式,我最开始想到的是交错执行。比如第一条指令执行的时候,你流水线先安排第3,4条指令,等第1条指令执行完再把第2条指令塞进来。

但是这种有个问题,很不好控制,指令顺序怎么排列呢?不过我记得现在CPU指令就是交错执行的,或许是有这种机制的,只是我们Y86这里是不用的。

最简单的逻辑还是加空跳,直接把流水线操作退化,在指令1取指后的两个时钟里,不引入新的指令。这样就变成了顺序操作。

数据预测

这其实是数据冒险的一种特殊情况,而且是无时无刻不在发生的情况:

当前的PC如何切换到下一个PC?这个受很多因素影响,但是可以肯定的是,一开始肯定是难以确定的,最保险的方法就是走完一整条指令,之后用各种结果计算出新的PC。但是这和流水线冲突了。

为了适应流水线,PC计算采用了多阶段预测方式,要用到不同阶段的信息,预测肯定也是会出错的,所以还有对应的错误恢复机制。

SEQ+与PIPE-硬件架构

对原来的SEQ硬件架构中的PC值预测模块做出修改,就可以得到SEQ+。

根据我们前面的推断,PC指针采用多级预测的方法计算,而不同于顺序执行的直接得出下一个PC。

在这里插入图片描述

在SEQ+中插入流水线寄存器,就可以得到PIPE-架构。硬件图比较复杂,先给出命名规则:

  1. S_field:在s阶段,寄存器中的值
  2. s_field:在s阶段,计算出的field值(用于载入下一个寄存器)

下图给出更详细的寄存器值,可以看到,stat和icode都是逐级保存的。很合理,icode代表了指令本身,负责生成各种控制信息,绝对不能丢。反过来,像rA,rB这种,用过一次就可以丢弃了。

总的来说吧,基本没什么变化,仅仅是用流水线寄存器进行分割,从这些寄存器的名字来看,寄存器充当的是一个阶段的输入。有了5个流水线寄存器,就可以一次性保存5个指令的状态了。

在这里插入图片描述

前面是前馈路径,现在说下反馈路径。反馈就是回写

  1. ALU将valE返回到寄存器,内存将valM返回寄存器
  2. 其他返回都是返回到PC预测模块去了,可以看到,PC预测模块中有大量的返回,来支持预测。

反馈和前馈有可能出现冲突,比如寄存器的读写,就是很典型的数据冒险。
又比如PC指针预测,也是数据冒险的一种情况。

在这里插入图片描述

PC预测

先思考一下什么指令会决定下一个PC:

  1. 不带跳转,PC=valP,保证正确
  2. 直接跳转,jmp/call,PC=valC,保证正确
  3. jxx(条件转移),根据上一条指令的条件码(执行阶段)而定,不能确定,需要在valC和valP之间选择。预测错了也会从采取措施补救。
  4. ret,无法预测,要在ret指令访存阶段确定,完全由栈中的返回地址决定,所以就干脆不预测了。

在这里插入图片描述
说白了,真正需要预测的也就是条件分支了。PC预测分为两个阶段:

  1. 预测:在这张图里,predict PC模块会先在valP和valC中选择(我们的策略比较简单,只要有valC,就选valC),送到F_predPC中储存起来。
  2. 选择:Select PC真正选择PC指针,有如下选择
    • F_predPC。使用预测值,这是多数情况
    • M_valA,从图中来看,这个值实际上就是valP。什么意思呢?我们的策略默认预测选择分支(跳转),但是有一些情况他最后没有跳转,所以就会进行错误修正,这就是M_valA的意义。
    • W_valM,这个值是访存阶段取出的栈顶地址,送到了W_valM中准备回写PC的。这种处理专为ret定制,ret必须等到valM计算出来才能选择。

流水线冒险

冒险的分类与原因

流水线冒险分为数据冒险和控制冒险:

  1. 数据冒险:下一条指令用这一条指令的数据结果
  2. 控制冒险:下一条指令要确定PC值

下图,通过加三个nop指令避免了数据冒险,对于数据冒险,需要保证当前指令在Decode阶段之前,前面的指令已经完成Write,或者说,要让前一条指令的Write与当前指令的Fetch重合,进一步说,就是要让当前指令的前三条指令不能修改当前指令操作数。

在这里插入图片描述

上面只是说了寄存器的数据冒险,但是实际上可能出现数据冲突的地方有很多,我们一一列举:

  1. 寄存器。很有可能冒险,寄存器到寄存器有冒险,内存到寄存器有冒险。
  2. PC。冒险是必然的
  3. 内存。不同于寄存器,寄存器读写发生的阶段不同,但是内存的读写都发生在访存阶段,所以当前指令读内存的时候,前一条指令已经写完内存了,准确的说,已经到了回写阶段。所以,内存到内存不会发生冒险。
  4. 条件码寄存器。cmov会在执行阶段读取Cond,jxx会在访存阶段,读取条件码寄存器(用于判断预测是否正确),而条件码会在执行阶段更新,所以这个和内存一样,不可能发生冒险。
  5. 状态寄存器。略

总的来说,我们只需要关注数据冒险,控制冒险,以及正确处理异常即可。至于内存和条件码,不可能发生冒险。

根据冒险发生的原理,我们会有一些针对性的方法:

暂停:延后读取

暂停的写法是bubble,空指令是nop。这两个有所不同:

  1. nop看起来像一条完整的指令,只不过啥也不干。
  2. bubble实际上是在流水线的一个位置上插入一个空气泡,后来的指令的被阻塞,前面的指令被推进。

以这个图举例,在执行阶段,加了bubble以后,本来addlq指令要从D到E了,结果被阻塞。halt也被阻塞在F阶段。而前面的指令(两条nop),仍然保持原来的推进节奏。

在这里插入图片描述

什么时候应该插入bubble呢?

当一条指令在D阶段,要进入E阶段了。此时系统会检查前面的三条指令中,是否有冲突指令,如果有,就插入一个bubble。下图中,插入三个bubble,直到时钟7,前三条指令中已经没有冲突指令了。

下图中,三个bubble是分三次检查并插入的,每次只会检查一次,插入一次。

在这里插入图片描述

虽然这种机制,实现起来比较简单,但是会严重拖慢时钟运行,因为数据冒险太容易发生了,老是延迟不太好。

转发:提前传输

鉴于bubble策略效率太低,我们要换个策略。

拿rrmov举例,其实不一定要等指令执行完才能获取到结果,在这条指令里,e_valE其实已经是计算出来的结果了,也就是说,下一条指令在D阶段是可以直接用前一条指令E阶段的结果的,不一定要等他执行完。(这一过程需要反馈路径,后面会说)

下图中,在addq指令的D阶段,虽然0x00a处的irmovq指令尚处于W阶段,但是也可以运行,因为直接用W_valE,虽然没回写完毕,但是可以提前转发。

在这里插入图片描述

我们看一种极限的情况,本来应该冲突的指令直接连在一起,中间不加任何bubble。这也是可以运行的。

注意,用e_valE当做valA或者valB是完全没问题的,译码阶段的要求是在下一个时钟周期之前计算出valA和valB,而在下一个时钟周期之前,e_valE已经计算完毕,通过数据转发旁路直接传到valA和valB的计算模块中了,这个过程是组合逻辑,所以在下一个时钟周期之前是可以完成的。

也就是说,在使用转发技术时,寄存器与寄存器之间的数据冒险是可以被完全解决的,不需要加任何bubble。

在这里插入图片描述

从硬件上来说,这种机制需要引入旁路(bypassing)

下图是引入旁路的PIPE架构,数据源可以是:

  1. ALU计算结果:e_valE,M_valE,W_valE(这三个值内涵一样,只不过是不同时期的)
  2. 内存读取值:m_valM,W_valM

同时,还要引入选择模块。毕竟,那么多数据来源,有转发回来的,还有走正常路径从寄存器取出的值,你到底要用哪个也是个问题。也就是下图中哪个FwdB和Sel+FwdA模块。

在这里插入图片描述

加载/使用数据冒险:解决内存到寄存器的冲突

上面说,寄存器与寄存器之间的冲突可以通过转发彻底解决,但是从内存到寄存器之间的冲突无法通过转发解决。

原因在于,内存取值,最早也要等到M阶段才能算出,即m_valM,所以还得多等一个时钟周期,也就是在转发的基础上再加一个bubble。

(补充,我刚开始还怀疑0x014和0x028之间会不会出现冲突,后来想起来,内存与内存是不会冲突的)

在这里插入图片描述

避免控制冒险

控制冒险和PC预测紧密相关。回顾PC预测的过程,对于普通指令和直接跳转,PC预测都是100%准确的,只有条件转移和ret会出现控制冒险问题。

ret比较简单,直接在ret后的F阶段连续插入三个bubble即可。(注意,这里是在F阶段插入)

在这里插入图片描述
在这里插入图片描述

再说说条件转移。条件转移,我们采用的策略是默认跳转,那么必然会出错,出错了怎么恢复呢?

在这里插入图片描述

恢复过程需要具体讨论。0x002指令在F阶段取指,之后在D和E阶段根据PC预测策略选择了两个跳转后的指令。

注意此时0x002指令在E阶段,这个阶段就可以读取条件码了,也就是说这个阶段就可以判断出跳转是否正确了。进一步说,截止E阶段0x002指令知道跳转结果,要么就是正确执行,要么就是引入两条错误的指令,注意,要么是0,要么就是2,不会有其他数字。

既然只有两条指令,那么这两条指令一条在F,一条在D阶段,总之都不会影响条件码和其他状态,此时正是铲除错误指令的大好时机。

具体如何铲除呢?
继引入两条错误指令后,下一个周期的时候,0x016本应该进入E,0x020本应该进入D阶段,但是我分别在E和D阶段将这两条指令用bubble直接覆盖掉(不阻塞前面的)。然后将PC指针修改,用正确的PC指针取出正确指令0x00b,将F填充。

由此,通过两个bubble将E和D阶段的错误指令剔除,同时在F阶段引入正确指令,处理器成功恢复了分支预测错误。

在这里插入图片描述

异常处理

异常可以从外部而来,也可以从内部来,内部异常如下:

  1. halt指令
  2. 非法指令
  3. 非法地址,包括非法指令地址,非法数据地址。

理想情况下,我们希望处理器能够在发现异常指令的时候,指令前的所有指令都已完成,指令后的指令不可以再修改处理器状态(条件码)。我们设计异常处理的目标正是这样的。

异常处理还有一些细节问题待解决,要解决这些问题需要从流水线架构下手:

  1. 异常优先级。流水线中有5条指令,如何确定优先级?直接选最深的指令,因为我们不能让一条错误的指令执行完毕,越深越容易造成破坏
  2. 分支预测错误的处理。分支预测错误的处理已经在避免控制冒险中说完了。
  3. 后来的指令可能会修改条件码。这和我们的目标冲突。所以需要在异常指令的M阶段禁止E阶段更新条件码,需要在异常指令的W阶段禁止M阶段修改内存。

为了实现以上的功能,硬件架构需要在每一个流水线寄存器中加一个stat状态码字段,这标志着流水线中5条指令各自的异常状态。控制逻辑根据异常状态而定制(后面会讲)。最终可以实现如下特性:

  1. stat存放在流水线寄存器中,负责给控制逻辑提供输入信号
  2. 发现异常后进制后面的指令修改程序可见状态(cnd和内存)
  3. 异常前面的指令都会执行完,当异常指令执行到写回阶段时,就停止程序执行,此时流水线的异常状态就是这条异常指令的stat。

PIPE 各阶段的实现

这一部分主要讲解一些与SEQ不同的细节

取指

首先是predPC作为PC预测。
之后Select PC从三个值里选一个作为PC指针。

与SEQ不同之处在于,对指令异常的检测被拆分为了两个阶段:

  1. 取指阶段。检测非法指令,halt指令
  2. 访存阶段。检测非法数据地址

在这里插入图片描述

译码和写回

图中的核心结构就是Sel+Fwd A和Fwd B。

这两个块都是从若干数据源中选择一个出来作为valA/valB。你要考虑到是使用转发源呢,还是使用寄存器读出的值呢。如果采用转发源,不同的转发源优先级如何确定呢?这些问题比较复杂,就此略过。

注意,valA还有一个特殊数据来源:valP,为什么valA能和valP合并?这是因为,在后续过程中,valA和valP只能存在一个。

  1. 假设这条指令后面的计算需要valP(call jmp),那就不需要寄存器值了,而是用valC。
  2. 假设这条指令需要从寄存器读valA,那后面的计算肯定就不需要valP。

在这里插入图片描述

执行阶段

执行阶段,与SEQ的不同在于加了Set CC控制模块。这一点请回顾前面的异常处理,假设在M阶段发现异常,那么就要屏蔽E阶段对cond的修改。所以,W_stat和m_stat将状态信息回传到E阶段,如果发生异常,就可以通过逻辑屏蔽对cond的修改。

在这里插入图片描述

流水线控制逻辑

我们前面只是说了宏观上的控制逻辑,比如什么时候加气泡,但是没有说如何检测到该不该加气泡,以及怎么加气泡。在这里都要具体的讲一下。

特殊情况的处理
  1. 加载/使用冒险。首先要发现冒险的情况,其次要保持F,D不变,然后在E阶段插入气泡

  2. ret指令。在D阶段插入气泡,持续插入三个时钟。在这期间,取指阶段在不断地取出错误指令,但是因为D阶段在不断加气泡,所以错误指令被阻断。直到三个时钟后取出正确指令。
    在这里插入图片描述

  3. 分支预测错误发生。在D,E阶段加入气泡清理错误指令,F阶段取出正确指令。

  4. 异常指令。
    要满足ISA期望的行为,需要在异常指令到达M阶段的时候,先禁止E阶段修改条件码,然后向M阶段插入气泡禁止访问内存,最后等异常指令执行到W阶段就停止流水线。
    在下图中,0x00c指令由异常,在M阶段屏蔽E,且在M阶段插入气泡,后面的指令都被阻断。最后异常指令执行到W阶段停止。
    在这里插入图片描述

如何发现特殊情况

注意时序:在当前阶段发现异常,设置异常信号为1,在下一个时钟周期处理异常。

至于如何发现异常,就是组合逻辑:

以ret举例,D_icode为ret的时候,代表ret指令执行到了D阶段,此时把D阶段的气泡信号设置为1,则下一个时钟周期,D阶段会出现一个气泡,而ret指令转移到了E阶段。

也就是说,ret指令在D,E,M阶段被发现,但是气泡是在ret指令的E,M,W阶段加入的。

在这里插入图片描述
在这里插入图片描述

暂停与气泡原理

根据我们上一部分的描述,气泡和暂停都是异常处理的具体行为,需要一个异常信号来控制。

异常信号分为暂停和气泡,都是施加在流水线寄存器上的,有四种情况:

  1. 都是0。下一个时钟周期正常加载
  2. 暂停=1,气泡=0。下一个时钟周期输出保持不变
  3. 气泡=1,暂停=0。下一个时钟周期输出为nop(空指令)
  4. 都是1,非法的。

这也印证了我们前面的结论:异常信号在当前阶段设置,下一阶段执行。

在这里插入图片描述

暂停与气泡的本质就是对下一个时钟内,流水线寄存器行为的改变。理解了这一层以后,再看异常处理的逻辑就简单很多:

ret指令会让F暂停,D产生气泡。(其实F暂停不暂停都无所谓,反正D有气泡F也无法传输下去)

在这里插入图片描述

控制条件的组合

单一控制条件是很好检测的,但是如果两种情况都有,就不太好搞了。幸好,大多数异常情况是冲突的,只有两种特殊情况:

  1. 组合A。检测到预测错误的同时,ret指令正在Decode阶段。此时应该以分支异常修复为主,ret指令是预测错误的指令。
  2. 组合B。E阶段指令目标是将内存值加载到rsp寄存器,D阶段是ret,需要rsp寄存器的值,这就造成了加载使用异常,要优先处理加载使用异常。

也就是说,当ret1和两种异常形成组合的时候,ret的优先级是较低的。

在这里插入图片描述

控制逻辑的实现

最后的最后,说一下如何具体实现控制逻辑,或者说如何从硬件上检测到异常情况。

从图中来看,控制逻辑实际上就是个组合逻辑,通过当前状态产生一个异常信号。之后在下一个阶段通过异常信号修改流水线寄存器的行为。

在这里插入图片描述

具体的组合逻辑怎么写,给出stall和bubble的原理,下面的控制逻辑告诉我们在什么条件下会产生stall和bubble信号:

在这里插入图片描述
在这里插入图片描述

性能分析

流水线的性能取决于你加了多少bubble,或者说你为了执行 C i C_i Ci条指令,插入了多少个bubble,插入越多,你的效率就越低。

C P I = C i + C b C i = 1 + C b C i CPI=\dfrac{C_i+C_b}{C_i}=1+\dfrac{C_b}{C_i} CPI=CiCi+Cb=1+CiCb

其中, C b C i \dfrac{C_b}{C_i} CiCb相当于C条指令里,平均每条指令需要插入的气泡数量。这个值可以分解为3项,因为气泡来源有三个:

C P I = 1 + l p + m p + r p CPI=1+lp+mp+rp CPI=1+lp+mp+rp

  1. p:penalty,惩罚。
  2. l:load。加载,使用冒险,需要插入1气泡
  3. m:mispredict branch。预测错误,需要插入2气泡
  4. r:return。返回,需要插入3气泡

最后给出一个计算例子:

指令频率代表指令在所有指令中出现的概率。条件频率代表出现了这条指令的情况下,出现bubble情况的概率。

在这里插入图片描述
在这里插入图片描述

性能优化

在这里插入图片描述

概览

影响性能最重要的是渐进复杂度,但是渐进复杂度的优化只是算法层面的,其他方面还有很多优化的空间,可以实现几十倍的常量级别加速,所以常量倍的加速也很重要。

性能优化除了要明确算法,还要了解系统,这才能在代码层面进行更深度的优化。好在我们一般不用干这个,编译器为我们进行了优化:

  1. 寄存器分配
  2. 指令调度
  3. 死代码删除
  4. 解决小规模性能问题

编译器的优化受到诸多限制,所以不会在渐进复杂度层面进行优化,只能优化一些常量级别的效率:

  1. 不能改变程序行为,而且相当保守
  2. 不支持过程之间的优化
  3. 无法预判运行时的输入

当算法和编译器都优化完以后,下面这两个问题就只能靠程序员自己优化了,这也是我们学系统知识的意义之一:

  1. 内存别名
  2. 过程副作用

再往底层走,现在的CPU都是超标量CPU,如何写出好的程序,充分利用CPU的超标量特性,就需要去理解CPU架构,做指令级并行优化:

  1. 压榨串行CPE:循环展开
  2. 压榨并行CPE
    • K×K循环展开(包括L×K展开)
    • K×1a重结合

表示程序性能

一般来说,大规模程序都要批量处理类似的数据,比如数组中的每个元素。CPE(Cycles Per Element)即处理每个元素需要消耗的平均时钟周期数。时钟周期又和指令挂钩,所以CPE可以理解为每个元素要几个指令去处理。

计算CPE是通过最小二乘法算的,给定一种算法,用不同长度的list去测试消耗的时钟周期数,每个结果都可以画一个点在图上,最后用最小二乘拟合一条直线,斜率就是CPE。

图像中,处理一个list总消耗的时间T = CPE*n + k,k是固定消耗时间,比如代码初始化,与list无关。

在这里插入图片描述

基准程序

本章中会有各种优化技巧,这些技巧最后都会被应用到一个基准程序上,与此对应的还有一个基准数据。

数据结构是很简单的,一个头结点,其有len和指向data的指针。而data是另一个地方的长度为len的数组。

typedef long data_t;//每个元素都是long
typedef struct {
long len; 
data_t *data; 
} vec_rec, *vec_ptr;

在这里插入图片描述

基准程序是对数组进行遍历累乘操作。

#define IDENT 1
#define OP *
void combine1(vec_ptr v, data_t *dest) 
{ 
	long i; 
	*dest = IDENT; 
	for (i = O; i < vec_length(v); i++)
	{ 
		data_t val; 
		get_vec_element(v, i, &val); 
		*dest = *dest OP val; 
	} 
} 

这个基准程序的效率很差,对不同类型数据和不同操作方法,不同级别优化进行测试,得到下图的CPE。后面,我们会给出一系列优化方法,提升基准程序的性能。

在这里插入图片描述

这里给出完整的,带有时间输出的测试代码

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

#define OP +
#define SCALE 100000
#define REPEAT 10000

//数据定义
typedef long data_t;//每个元素都是long
typedef struct {
	long len; 
	data_t *data; 
} vec_rec, *vec_ptr;

//辅助函数
long vec_length(vec_ptr v)
{
	return v->len;
}

void get_vec_element(vec_ptr v,long i,data_t* val)
{
	if(i<0||i>=v->len)
	{
		exit(0);
	}
	
	*val=v->data[i];
}

void combine1(vec_ptr v, data_t *dest) 
{ 
	long i; 
	*dest = 0; //初始化清0
	for (i = 0; i < vec_length(v); i++)
	{ 
		data_t val; 
		get_vec_element(v, i, &val); 
		*dest = *dest OP val; 
	} 
} 

int main(void)
{
	//构建数据
	data_t data[SCALE];
	vec_rec vector={SCALE,data};
	vec_ptr ptr =&vector;
	
	//填充
	for(long i=0;i<vec_length(ptr);i++)
	{
		ptr->data[i]=i+1;
	}
	
	printf("准备数据:数据规模%d\n开始测试,重复次数%d\n",SCALE,REPEAT);
	
	//合并,测试时间
	
	clock_t start,end;
	data_t res;
	
	start=clock();
	for(long i=0;i<REPEAT;i++) //重复核心代码
	{
		combine1(ptr,&res);//核心代码
		//printf("第%ld次结果:%ld\n",i+1,res);
	}
	end=clock();
	printf("结果:%ld\n耗时:%f\n",res,((double)end-start)/CLOCKS_PER_SEC);
	
	return 0;
	
}

常见优化技术

代码外提(机器无关优化)

代码外提就是把循环中反复计算,但是其实只需要计算一次的东西提到循环外面。

基准程序的循环判断条件在每次循环中都要计算一次,而计算一个list的长度成本肯定是比较大的,尤其是像strlen()这种,时间复杂度更是O(n)。虽然成本很大,但是每次的结果又都是一样的,所以不如直接提到外面。

//改进前
for (i = O; i < vec_length(v); i++)
{ 
	data_t val; 
	get_vec_element(v, i, &val); 
	*dest = *dest OP val; 
} 
//改进后
int len=vae_length(v);
for (i = O; i < len; i++)
{ 
	data_t val; 
	get_vec_element(v, i, &val); 
	*dest = *dest OP val; 
} 

再举个例子:

在这里插入图片描述

这个图中,你会发现循环里面有一个计算,每次的结果都是一样的,那干脆就把那个重复算的代码提出去即可。也就是,虽然你的源代码是左边的,但是你生成的目标代码却是右边代码的逻辑。但无论如何,语义是不变的。

将重复使用的部分外提,可以提高利用率,提高重用率。

可以看到,任何机器都可以做到代码外提,因为他只需要一个寄存器。

计算强度削弱(机器依赖优化)

计算强度削弱,就是将成本大的指令替换为若干低成本指令。

比如乘法的代价就很大,所以通通优化成shift和add操作。不过需要注意的是,你需要比较直接乘和转换指令后的代价,有时候转换后的代价还更大了,所以这个是依赖于机器的,更准确的说是依赖于指令集的。

又比如下面这个代码,每一次循环都要计算一次n*i,但是你仔细一看,这和每次都给他累加一个n没区别,所以自然是把乘替换成了加。

在这里插入图片描述

公共子表达式删除

在这里插入图片描述

看下面的代码,可以看到,sum由四个部分组成。每个部分都需要进行计算,但是你会发现方括号里计算的公式中存在类似的写法,那么左边的代码其实可以写成右边的写法。

这一步由编译器去检测,转换,提取出的inj=i*n+j就是公共子表达式。

在这里插入图片描述

优化的困难

过程调用和内存别名,编译器很难优化,所以这两个通常手动优化。

过程间调用

我们前面说过代码外提,下面这个程序就是代码外提的应用场景。乍一看,只有一个循环,很多人想当然地就以为是O(n)的复杂度。

在这里插入图片描述

但是实际上,那个strlen(s)会在每次循环中执行一次,而strlen的时间复杂度也是O(n),所以代码的时间复杂度实际上是 O ( n 2 ) O(n^2) O(n2)

如何解决这个问题呢?只需要将strlen提到外面就行。因为我们知道,每次循环,strlen的结果都是一样的。进行了下图的优化后,时间复杂度就回到了O(n)。

在这里插入图片描述

但是,你有没有发现一个问题,我们做这个移动的前提,是我们程序员本身就知道结果是不变的,但机器不知道啊,编译器不确定啊。在程序中,函数(过程调用)都是看做黑盒子的,过程调用中,被调用者和调用者之间会产生相互作用,这是很复杂的,我们思考两种情况:

  1. 过程修改全局变量。假如一个过程会修改一次全局变量,如果放在循环判断条件,那么就会多次修改,反之,放在外面就只会修改一次,这其中差距就大的很了。
  2. 过程返回值变化。strlen返回值不变我们才敢拿出来的,如果过程调用需要用到全局变量,全局变量一变,就算传入的参数不变,返回值也有可能变,所以编译器是无法确定返回值是否变化的。

综上,编译器能力有限,所以干脆将所有过程调用都看成黑盒,涉及到过程调用的优化很少做:

  1. -O1可以对单个文件内优化,但是不优化过程调用
  2. 过程调用只能程序员优化,需要手动移动代码,你必须改变源代码写法,这里就考验程序员功底了。

内存别名

看例子:

程序给定一个矩阵a,数组b。其中,矩阵a是以数组形式编写的,访问的时候要计算一下伸缩。
程序将a矩阵的第i行求和赋值给b[i]

下面的汇编代码中,rsi指向b数组,rdi指向a数组。每次小循环都会先从内存读b,然后计算完以后再写入b,这就生成了大量的内存读写指令,浪费很严重。

在这里插入图片描述

如果我们制造一个局部变量(寄存器),那么就可以大大减少取内存和写内存的循环:
下面程序中,加了val变量以后,生成的代码就好很多。

在这里插入图片描述

内存别名的本质就是:从内存中取中间值,还要把中间值写回内存。经常出现在源操作数要取内存,目标操作数要写内存的表达式中。

解决内存别名的方法就是:用局部变量(寄存器)当中间值,放到循环中去。

指令级并行挖掘

现在我们的优化还比较高级,对硬件的依赖很小,如果要进一步提高性能,就要充分考虑到硬件,即现代CPU架构。以前的流水线,CPI最低是1,但是从奔腾开始就都是超标量处理器了,CPI可以小于1。

举个例子,我们前面学的是,CPU一条一条取指令,但是实际上现代CPU比这复杂多了,一次要取多条指令,同时求值,这就是指令级并行。指令级并行比较复杂,虽然并行,但是表现出严格的顺序。

正因为可以并行执行指令,所以可以针对底层进行并行优化挖掘,大致思路如下:

  1. 通过k×1循环展开压榨串行效率
  2. 压榨并行效率,二选一。
    • k×k循环展开优化(L×K也可)
    • k×1a重结合

我们最终的目标是学会上面这些并行优化,但是在此之前,先讲一下现代CPU结构。

现代CPU结构

整体操作

从现在开始,我们说的指令已经不仅仅是Y86简化指令了,而是引入了X86里的很多复杂指令,这些复杂指令可以在现代CPU上快速执行。

比如,addq在Y86里只能是寄存器之间加(因为内存部件在计算部件的后面),但是X86就可以在内存和寄存器之间加,这就涉及到现代CPU架构了。

现代CPU除了可以执行复杂指令,还可以一个时钟周期执行多个操作,而且是乱序的。虽然计算的结果是乱序的,但是其结果生效是顺序的,有对应机制去保证,所以可以放心地通过乱序并行来加速。下图为现代CPU简图:

在这里插入图片描述
看着挺复杂,别慌,其实还是PIPE的变种,慢慢看。

整体可以分为两部分,上面是指令控制单元ICU(集成了带分支预测取指,译码部件),下面是指令执行单元EU(集成了运算,内存部件)。

指令执行的整体流程是,如此在流水线中循环,接下来就逐个进行讲解:

  1. ICU负责整体规划,预测取指译码
  2. EU就负责算结果
  3. ICU的退役单元负责验收结果,是原来的回写部件的升级

CPU并不直接和内存通信,而是通过cache,因此取指阶段是从指令cache取指令的,涉及到分支预测,会直接取出要跳转的目标指令,这就是投机执行机制。

指令译码部件也针对复杂指令做了升级。对任何指令,译码部件会将其拆分成若干个微指令,简单指令就还是一个微指令,但是复杂指令就会变成多个微指令,比如一个addq %rax,8(%rdx)会拆成三个操作:

  1. 取内存
  2. 相加
  3. 写内存

这些微指令会根据类别送到对应的EU的功能单元中,不同的功能单元可以并行地处理微操作,所以一个复杂的相加操作在现在的CPU中只需要一个周期就完成了。

EU的功能单元种类丰富,随着现在集成度提高,功能单元的数量也就更多了,一个功能单元可以支持的计算种类也增加了,这些都提高了CPU的计算能力瓶颈。

EU中读写内存通过加载和储存单元实现,已知CPU不直接和内存通信,所以CPU读写内存的时候,还是和cache进行第一步的通信。

ICU中的退役部件负责结果验收。在计算过程中可能发生多种错误:

  1. EU并行执行的缺点是乱序,这会导致顺序错误
  2. 取值部件的投机执行机制,不管预测的对不对,只取指令交给EU执行,这也会导致分支预测错误。
  3. 为了并行加速,EU中不同功能单元之间会有更精细的数据传送,通过前面图中那个总的操作结果线控制(寄存器重命名机制),这也可能出问题

但是没关系,EU计算的结果都是暂存在ICU中的,ICU会保持指令顺序一致,还会验证分支预测是否正确,如果不正确就丢弃错误结果,重新让取指部件取不跳转的那条指令。

功能单元的性能
  1. 延迟。从头到尾完成一个计算指令要的周期数
  2. 发射时间。两个连续的同类型运算之间的最小间隔周期。发射时间可以小于延迟,这要归功于流水线技术,如果发射时间=1,那就是完全流水线化的,否则当前指令就会阻塞后面的指令。现代CPU流水线没有PIPE那么死板了,比如一个浮点加法分成三个周期,这三个周期的结果依次传递:
    • 处理数值
    • 小数相加
    • 结果舍入
  3. 容量。执行该计算的功能单元数量。

从加法到除法,越来越复杂,所以延迟越来越大。其中,乘法仍然能保持完全流水线化,但是除法成本远高于乘法,发射时间至少要3个周期。

在这里插入图片描述

表达发射时间更常用最大吞吐量:

  1. 单个功能单元的最大吞吐量=发射时间倒数
  2. 一个处理器中,容量为C的某种功能单元的最大吞吐量=C×单元最大吞吐量

后面的优化有两个方向:

  1. 将CPE压榨到延迟。假设是一条一条地执行计算指令,那么串行执行指令的极限CPE就是延迟本身。
  2. 将CPE压榨到CPU的最大吞吐量的倒数。因为有多个功能单元,所以理论上一个时钟周期可以执行多条指令,所以还可以继续压缩CPE,比完全流水线化还要快,逼近吞吐量的倒数,即CPE的吞吐量界限。

以整数加法举例,一个时钟周期要取内存,计算,写内存。有两个取内存部件,四个计算部件。如果是串行完全流水线化执行,那就是CPE=1,逼近延迟。但是我们可以同时执行两个整数加法,因为有两个取内存部件,CPE=0.5。为什么不是同时执行4个呢?因为木桶效应,内存部件的吞吐量是加法运算的短板,再怎么折腾,也不可能超出硬件的极限。

看一下我们前面通过高级优化优化到极限代码,基本都逼近了延迟界限。而加法和延迟界限差一些是因为一些额外的开销占比比较大。后面要做的各种操作,其实是为了压缩CPE到吞吐量界限。

在这里插入图片描述

处理器操作的抽象模型

为了分析性能,优化程序,本节引入现代处理器操作的抽象模型。拿combine4循环中的操作举例:

acc=acc*data[i]

这个循环变成汇编代码如下,三条指令,先执行,之后移动指针,比较循环结束条件:

在这里插入图片描述

我们将每一次循环的汇编代码变成下面的数据流图:

  1. 白色的部分,rax,rdx,xmm0都是寄存器,上面是初始状态,下面是本次循环完毕的状态
  2. 蓝色是计算部分,EU功能单元进行计算,再将结果写回,直线箭头记录了数据流动
  3. 弧线箭头代表同一个时钟周期内,不同功能单元的数据传递

因为用的部件各不相同,所以这几条指令在现代CPU中是并行的。

在这里插入图片描述

图很复杂,所以要简化。简化前要将寄存器分类:

  1. 只读:只读,不写,比如储存末尾指针的寄存器rax。
  2. 只写:与只读相反
  3. 局部:循环内部会修改和使用,但是前后两次循环不相关,比如条件码寄存器。
  4. 循环:后一次循环需要读取前一次循环的结果,存在依赖关系,是制约程序速度的关键。比如rdx,xmm0两个寄存器。

重新排列后,只保留循环寄存器,数据流图就会很清晰地反映出循环之间的依赖关系。假设其他操作执行的都比较快,那么制约性能的就是剩下的这三个并行操作中的某一个了。

在这里插入图片描述
将循环的链条拼起来,load是1周期,add是1周期,浮点乘法是5周期,每次循环都必须得把三个操作都完成,才能继续下一次循环,所以mul操作限制了整体的运行速度,add和load部件必须等待mul部件,这就造成了浪费。所以,不进行并行优化的CPE只能逼近延迟L。

走得最慢的一条路就是关键路径,长为L×n,这个概念和图论里的一致。

在这里插入图片描述

压榨串行度:k×1循环展开

在进行并行优化之前,应当先把串行性能压榨到极致,即把整数加法的CPE压缩到1。之所以加法的CPE达不到1,是因为循环中不仅仅有计算,还有条件判断之类的事情,这些会消耗时间,所以要尽量减少计算无关的操作。

一个最简单的方法就是减少循环次数,即k×1循环展开。减少了循环次数后,一次循环中计算的比例就被大大提高,效率也就高了。这个方法后续还可以用来提高并行度,是优化的基础。

还是以合并代码举例,此时OP为+。如果一次循环就执行两次加法,那是不是可以把循环次数变成 n 2 \dfrac{n}{2} 2n。以此类推,如果可以一次循环执行k次加法,是不是可以把循环次数变成 n k \dfrac{n}{k} kn

在这里插入图片描述

当然,因为无法整除,所以总有剩下的一点无法被执行完,这就需要提前结束展开的循环,进入另一个处理剩余元素的普通循环中。提前结束的公式为:i<n-k+1。

在这里插入图片描述

使用k×1循环展开,只能让CPE逼近延迟界限,接下来通过数据流图讲解原因:

先给出汇编代码的关键部分:

在这里插入图片描述

两条vmulsd指令用到了同一个寄存器,所以并不能并行执行。变成抽象图以后,可以看到,左边的关键路径上进行mul操作,右边的路径给mul操作提供load操作。很显然,虽然迭代次数少了,但是因为mul操作是串行的,所以关键路径上还是有n个mul操作。这就是循环展开不能突破延迟界限的原因,它压根就没有用到并行。

在这里插入图片描述

提高并行性:改进循环展开

k×k循环展开

前面说到,之所以不能展开,是因为这两个vmuls指令用到了同一个寄存器,这就是两条指令之间的数据相关。如果他们各自分配一个I寄存器,就可以打破数据相关,并行执行了。改进如下,用两个变量就会生成两个寄存器(xmm0,xmm1):

在这里插入图片描述

变成流程图,可以看到,两路展开生成了两条关键路径,每条上只有一半的mul操作。

在这里插入图片描述

真正优化了关键路径后,CPE突破了延迟界限,除了整数加法,其他三个都达到了延迟界限的一半,这与我们前面分析的一致,缩短了一半的关键路径可以加速一倍。

在这里插入图片描述

理论上,有k路展开,就应该有k个寄存器,让每一路都单独占用一个寄存器。当k不断增加,让关键路径不断缩短,就可以和非关键路径的长度相等,此时CPE也就逼近了吞吐量界限。

需要注意的是,k×k路展开有两个限制:

  1. 计算必须可结合。k×k路展开实际上是把一个累乘分为k部分,这就是结合律。逻辑上来说,加乘都有结合律,但是从计算机角度来说,浮点数没有严格的结合律(溢出,大数加小数)。好在,现实中没有这么刁钻的情况,基本都是能用的。
  2. 寄存器溢出。k路展开要k个寄存器,如果寄存器不够,就会把局部变量开到栈上,内存的读写会严重拖慢速度,反而会导致某一条关键路径的速度变得更慢,CPE不降反增。
k×1a重结合

基础的循环展开中,是上面那行代码,我们这里利用结合律,先让从内存中取出的两个数执行mul,之后马上将结果给rax,再执行一次mul。

在这里插入图片描述
变成数据流图如下:
乍一看,这不还是串行吗,先mul一次,然后再把结果送到另一个mul模块中。这是你还没有理解为什么基础循环展开中的两个mul操作只能串行。在基础循环展开中,两个mul指令,全都需要读rax,还要写rax,无法构成一个链条,但是这个重结合就不一样了,第一次mul操作的不需要读取rax,也不用写rax,只是数据依赖,但是不存在冲突,所以可以在一个时钟周期内将结果发给第二次mul操作。

在这里插入图片描述
结果也验证了我们想法的正确性,也可以突破延迟界限,和k×k循环展开效果相当,当k增加的时候,CPE会逼近吞吐量界限。当然,编译器不会轻易做重结合,就算做了,效果也不一定好,这东西还是有点复杂的,k×k才是更加稳定的。

在这里插入图片描述

这道例题告诉我们为什么重结合不好做,下面是三次循环展开,r为用于累乘的变量。

  1. 做这种题应该直接画数据流图,从上往下的时间顺序对应多层括号的从内到外。
  2. 画好图以后就数关键路径即可,关键路径是一个循环寄存器从开始到结束的最长路径。

在这里插入图片描述
在这里插入图片描述

L×K循环展开(重结合联想,夹带私货)

前面说过,寄存器可能会溢出,如何兼顾最大化压榨串行效率的时候,还不会导致寄存器溢出呢?少用点寄存器呗。

L×K循环展开介于L×1次循环展开与L×L次循环展开,比如,你会发现,我们一次行展开了4路,但是实际上只用了两个寄存器。只要L是K的整数倍,你可以实施不完全的循环展开,虽然是4次展开,但是实际上只是两次展开的效果,提升一倍速度:

a=(a*d[i])*d[i+1]
b=(b*d[i+2])*d[i+3]

还可以压榨,你会发现,我开了两个寄存器,但是寄存器内部的计算是可以重结合的,这就很有趣了,照下面这么写,速度还能再提一倍:

a=a*(d[i]*d[i+1])
b=b*(d[i+2]*d[i+3])

L×K次循环展开与重结合一起使用,可以用更少的寄存器实现相同的效果。

提高并行性:使用AVX指令集

复杂的指令里,有些指令可以对向量直接进行处理,具有更高的并行度,广泛用于显卡中,其发展历程如下:

  1. SIMD(Single Instruction Multiple Data):单指令多数据技术
  2. SEE(Streaming SIMD Extensions):流SIMD扩展
  3. AVX(Advanced Vector Extension):高级向量扩展

这些技术的根本在于SIMD模型,我们从SIMD的向量寄存器说起。

前面我们讲过,K×K路循环展开可以用寄存器来加速,于是就有人想到了,干脆专门为向量的并行计算提供多路寄存器,这就是向量寄存器,名字为%ymm0到%ymm15,总共16个,每个寄存器有32字节,可以放8个4字节(32位)数,或者4个8字节(64位)数,可以是整数也可以是浮点数。

普通的标量指令的源操作数或者目标操作数只能是一个寄存器,而AVX指令的源操作数和目标操作数可以是向量寄存器,而一个向量寄存器相当于4/8个标量。比如下面的指令:

vmulps (%rcx) , %ymm0 , %ymm1

这个指令,就是从内存中读8个值,并行执行8路乘法,最后将结果保存到目标的向量寄存器中。这个操作,相当于自动执行了8×8循环展开,可以提升到原来的8倍速。

下图给出各种组合的性能提升,可以看到,向量吞吐量界限很低,这是因为其运算单元足够,向量寄存器也足以支持并行运算,对于32位的元素,相当于8路展开,比标量提升8倍,64位元素则是提4倍。由此也可以看出,现在的显卡的图形计算能力有多么强。

在这里插入图片描述
具体应用中,GCC支持AVX指令的编译,比直接手写AVX汇编更方便。

总结

限制因素

寄存器溢出

回顾前面的K×K循环展开,如果寄存器不够,则某条关键路径的局部变量就会开在栈上,反而会造成内存别名问题,使得性能恶化。

条件分支

因为流水线的限制,在信息不完整的前提下是不存在完美的条件分支预测的,取值模块不知道往哪走,就要进行选择。

  1. 基本的方法是投机选择,即先选择目标指令。但是这种一旦错误,惩罚会很严重。
  2. 更加智能的有一些自适应硬件算法,可以进行一定的预判,提高准确率
  3. 还有一种思路类似于条件传送,我同时取目标指令和不跳转的指令,都进行计算,最后舍弃掉一个就行。

理解内存性能

这是下一章内容,看书了解即可。

本章的所有技术罗列

总的来说,循环是最容易出问题的地方,也是优化的重点。

  1. 算法层。这一层优化渐进复杂度,后面的部分都是常数级别。
  2. 常规层。
    • 代码外提
    • 计算强度削弱
    • 公共子表达式删除
  3. 编译器无法优化的层次。
    • 过程调用,尤其是循环判断条件中的过程调用
    • 内存别名
  4. 指令集并行挖掘
    • 逼近延迟界限:k×1循环展开
    • k×k循环展开
    • 重结合
    • L×K与重结合的综合优化
  5. 其他优化
    • 分支预测
    • 编写Cache友好代码# 储存层次
      上一节说到,对程序性能影响很大的还有内存,cache,这一章从储存层次开始,逐步过渡到cache。

储存层次

上一节说到,对程序性能影响很大的还有内存,cache,这一章从储存层次开始,逐步过渡到cache。

储存技术与趋势

储存材料

下图中,RAM属于易失性内存,断电就会丢失信息,ROM整体属于非易失性内存,断电也可以保存很久。

抛去双极型RAM,我们来对比一下两种MOS型RAM的区别:

  1. SRAM(静态RAM)是双稳态电路,成本高,抗干扰能力强,是数字器件
  2. DRAM(动态RAM)是电容电路,成本低,抗干扰能力弱,是模拟器件,因为容易出错,所以要搭配校验模块使用。

总的来说,除了成本以外,SRAM在各个方面全都占有优势,因此我们的cache和寄存器都是SRAM做的,内存是用DRAM做的。

在这里插入图片描述

再来谈一下ROM,ROM是非易失性内存,这是因为其信息是靠内部的结构储存的:

  1. ROM。总称
  2. MROM:只能初始化,常用于BIOS以及各种硬件固件
  3. PROM:可编程一次的ROM。这个编程类似于FPGA,是通过熔断内部部分线路实现的,所以是非易失性,且只能编程一次。
  4. EPROM:可擦可编程,能多搞几次,但是次数很少。
  5. FLASH:闪存,现在主流的电可擦除ROM,虽然还有擦写上限,但是次数多达10w,是现在固态硬盘的材料。

从更广泛的非易失性储存来说,磁盘(机械硬盘)也是非易失的,其内部使用磁结构来储存,每次写入的时候,都会通过电磁作用改变内部磁极排列,实现永久修改。

总线与内存读写

计算机中的信息都是通过总线传输的,总线有三种,可以分别传地址,数据,控制。现在的总线技术是地址和数据共用一种总线,通过总线上的某位区分是地址还是数据,而控制总线单独有一条,做什么由控制线决定。

以读内存举个例子:

在这里插入图片描述

  1. CPU:发出读取命令,数据总线:地址A,控制总线:“读”的控制信号
  2. 内存:收到读取信号,从总线上取出地址,将对应区域的内容放到总线上,此时数据总线:x的值,控制总线:“可以读取”的控制信号
  3. CPU:收到可以读取的信号,从总线上读出内容,拷贝到寄存器中。

写也是类似:

  1. CPU:数据总线:地址A,控制总线:“准备写”。此时内存读取了地址,等待数据到来
  2. CPU:数据总线:数据x,控制总线:“写入”。
  3. 内存:将数据总线上x取出,写到目标地址

我的解释并不完全准确,但大致是这个流程,数据总线和控制总线配合实现复杂的功能,读写都分为三个阶段完成。

磁盘(机械硬盘)

磁盘比内存慢,但是是非易失性储存,且可以恢复。

磁盘结构

磁盘结构是老生常谈了,磁盘由盘片(platter)组成,盘片上有两面(surface),一个盘面上有若干磁道(tracks),磁道上有若干扇区(sector)。

在这里插入图片描述

在实际使用中,因为磁头不能跨盘片移动,所以有多少个盘面,就有多少个磁头,共进退,n个磁头旋转一周是一个柱面。于是实际常常是以<柱面,磁头,扇区>来编排的,这就是CHS编址方式。

磁盘容量

计算出扇区数量,乘以扇区大小即可,具体计算千奇百怪,因为编址方式就各有各的,所以记住核心,灵活变通。

在这里插入图片描述

磁盘读写流程

注意磁盘是逆时针旋转,许多风扇也是逆时针转的,这个符合螺丝的反扣,不会越转越松。

需要进行LBA到CHS转换。在计算机内部,磁盘块是用整数编址的(LBA编址),但是物理上磁盘是CHS结构,所以需要专门的磁盘控制器(disk conotroller)将逻辑磁盘块转化为CHS编址,然后进行读写。

读取:

  1. CPU发起读取命令,将磁盘逻辑块号,目标内存地址发给disk controller
  2. 磁盘控制器解析地址,之后通过DMA将数据读到内存。此时CPU区干别的去了
  3. DMA传完以后,给CPU发一个中断信息,告诉CPU传输完毕。

在这里插入图片描述

磁盘读写时间
  1. 寻道时间。切换磁道时间
  2. 旋转延迟。从刚进入目标磁道开始,等目标扇区转过来的时间。
  3. 数据读取。磁头从目标扇区开始,转到结尾的时间。

不同题目给的假设都不一样,有的给具体事件,有的给平均时间,只需要理解这三个含义即可,到时候给什么假设就按什么计算即可。还有几个注意点:

  1. 访问延迟主要受到寻道时间影响,其次是旋转延迟,所以现在磁盘都标记了多少转,转的越快,读取越快。
  2. 读扇区的第一个bit最耗时,其他很快。因为要做很多准备,准备做好了以后剩下的就顺次读就行
  3. 磁盘比DRAM慢2500倍,比SRAM慢4w倍,差距极大。

固态硬盘(SSD)

注意区分,机械硬盘是磁盘,固态硬盘是闪存盘,两者都属于硬盘,是外存。

SSD整体上以块组织,块内部有页。数据以页为单位读取,但是要写入一页就必须把一整块都擦了。

在这里插入图片描述

SSD性能上普遍优于磁盘:

  1. 速度快,没有机械运动拖累,更节能,也更坚固,摔了也不容易坏,磁盘摔了可能把螺丝都蹦出来了。
  2. 读取快,写入慢(因为要擦一整块)

只有两个即将被改进的缺点:

  1. 成本高。500块可以买4T的磁盘,但是只能买1T的固态。现在SSD成本已经越来越小了。
  2. 有擦写上限,使用寿命更短。现在可以通过一些技术缓解磨损,使用寿命已经超出了普通用户的需求,所以现在SSD是主流。

访存局部性原理与Cache

现在有一个趋势叫CPU-内存差距:CPU速度越来越快,内存容量提升很快,但是速度跟不上。下图中,DRAM访问时间的下降并没有很快,而CPU降得特别快,差距被拉大。

在这里插入图片描述

现在的技术利用局域性消除差距。其实时间局部性也就那样,空间局部性是最关键的。

  1. 时间局域性。两次访问更倾向于同一个地方
  2. 空间局域性。两次访问更倾向于离得不远的地方

在这里插入图片描述

局部性在数据和指令上都有体现,举两个例子。对sum而言,其有时间局部性,对a的迭代访问而言,其有空间局部性。

在这里插入图片描述
局部性有什么用呢?假如我同一时刻,只使用一部分数据,那么我是不是可以用一个更小更快的空间去储存这些数据,这就是cache原理。cache和下层之间通过数据块传输,有时候也叫“以字为单位”,这个字是一种特殊说法。

如果cache中有目标数据,则hit,否则就是miss,miss有三种类型:

  1. 冷启动miss。刚开始cache是空的,或者遍历后没找到
  2. 冲突miss。要找的块不在cache中,cache中的位置被占用,此时就要替换。之所以会出现冲突miss,是因为多个下层块被映射到同一个cache位置了,具体映射机制后面会讲。
  3. 容量丢失。工作集的块超过了cache容量(有点迷,不常见)

在这里插入图片描述

计算机分层储存,上层比下层更小,更快。所以,从广义角度看,每一层都是下一层的cache。因此层与层之间的数据交换也是类似的,cache与内存的交换,内存与硬盘的交换,机制类似。

在这里插入图片描述

下图是具体的数值:

  1. TLB常用于访问内存,比cache还要高级(其实就是全相联cache)。
  2. 寄存器是由编译器管理的。这是因为,高级语言编译成汇编的过程都是由编译器控制的

在这里插入图片描述

Cache结构

通用cache结构

首先明白,cache的地址和内存的地址是一致的,位数都是m。CPU给一个逻辑地址,先去cache里找,找不到再去内存中找,所以在内存地址和cache地址之间必然有一种映射,在cache里通过内存地址找目标的过程,就是在利用这个映射关系。

映射关系有三种:

  1. 直接映射
  2. 组相联
  3. 全相联

但无论是哪种映射,其都按照一个通用的格式编址,只是位数有所变化。接下来看一下通用编址方法。下图给出一个(S,E,B,m)的cache的通用组织:

  1. 一个cache有S组
  2. 一组有E行
  3. 一行里面有
    • 有效位
    • 标记位
    • B字节的块。

在这里插入图片描述

m位内存编址(也是cache编址)和上面的似乎有点矛盾,在图中,标记位似乎是在组里的行编号,但是却排在组索引的更高位。其实,标记位有更加深刻的含义,他只是可以用于区分行,但并不是行的编号。具体原因比较复杂,但是你就只需要知道,标记位有更宏观的意义,应该放在高位就行了。

  1. 高t位,是标记位。
  2. 中s位,是组索引。
  3. 低b位,是块内偏移。

在这里插入图片描述

现在,有一个m位的内存地址,有一个cache,如何确定m位地址指向的内存区域,已经在cache中有拷贝呢?

  1. 组选择。首先用组索引锁定组号。
  2. 行匹配。之后遍历组内行,每一行都用标记位去匹配行里的标记位,只要相同,就说明cache中有一份拷贝
  3. 字抽取。在这一份拷贝中,用偏移在块中找到起始字节的位置,然后读取出一定长度的数据。

由此也可以看出,标记位并不是编号。如果标记位是行编号的话,也就不需要去遍历了。之所以用这种看似更麻烦的方法,还是有其意义在内的,后面会慢慢挖掘。

最后给出一份参数表,巩固一下概念。之后就要开始介绍不同类型的组织方式了。

在这里插入图片描述

直接映射

直接映射高速缓存,每组只有一行,E=1。这是最好理解的模式。
在这里插入图片描述
CPU用m位地址取内存的时候,cache在其中做缓存,机制如下:

  1. 如果cache中有这个块,则直接抽取
  2. 如果cache中没有,则先从内存中调块到cache,再从cache中抽取
组选择

索引是二进制编码,从0开始。

在这里插入图片描述

行匹配/行替换/字抽取

因为只有一行,所以不需要遍历目标组中的所有行,直接比就行。

  1. 有效位=0,说明没有数据,冷启动miss
  2. 有效位=1,标记位匹配(cache行中的标记位=m位地址中的标记位),则证明有,进行字抽取。
    • m位地址中的b位偏移也是二进制编码,代表了目标内容在块中的起始字节,从0开始。
  3. 有效位=1,标记位不匹配,则发生冲突miss,触发行替换。行替换也很简单,直接换掉一行就可以
    • cache行中标记位换成m位地址中的标记位
    • 从内存中读取一个块的内容,一次性装入cache的行中。(此处利用了空间局部性,虽然只需要一个字的内容,但是我们一次性读入一个块的内容)

在这里插入图片描述

实例分析

到现在为止,还有一个迷惑的点就是,内存和cache的映射到底是怎么样的?这个时候就举个实例。我们用的cache描述如下,E=1,是直接相联映射:

在这里插入图片描述
一个块里有两个字节,总共4块,则一个cache可以装8个字节。我们给出16个字节的内存,肯定是装不下的,我们用这些数据探索一下为什么会发生冲突,以及标志位凭什么能用于区分不同行。

可以看到,其实在直接相联映射中用索引位和偏移位(总共3位)就可以对应到8字节(cache的所有内存),那此时的标记位,就可以理解为凌驾于cache之上的编码,标记位每增加1,则代表内存地址跨越了一个cache的总长度。因此,块0和块4,块5和块1都会被映射到同样的cache组,因为一组只有一行,所以会冲突。

在这里插入图片描述
接下来就来手工模拟一个过程,加深理解:

在这里插入图片描述

  1. 第一次是冷启动miss。
  2. 第二次也是冷启动miss
  3. 第三次hit

在这里插入图片描述
4. 第4次,地址8的t位和地址0的t位不同,标记位冲突,所以直接替换。
5. 第5次,又一次冲突。这种换来换去的就是抖动。
在这里插入图片描述

抖动情况与解决策略

在直接相联映射中,只要把地址,去掉偏移位,对cache中块数量取模,结果就是标记位,标记位相同的就会冲突。

在这里插入图片描述

给定上述代码,如果一个高速缓存的大小为32字节,那么就会频繁出现抖动。因为x和y之间恰好差了一个cache的空间,导致相同下标的元素,其组索引恰好相同。

在这里插入图片描述
解决方法也很简单,让两个数组之间的地址差1.5个cache即可,这样就组索引就不会冲突了。具体做,就是定义数据的时候让x的长度多一些,尾部增加4个空元素就可以把y顶到后面去了。

在这里插入图片描述

为什么用中间s位做组索引

这个问题初期是困惑我的,现在基本想明白了。问题的根源在于,cache空间是小于内存的,所以要截取低位来对cache进行编址,而标记位不是用来编址的,所以留在高位用作匹配。

我们前面的4位编址中,只需要截取后3位就可以完整地将cache的所有位置编址。如果用将高位掺杂进来,就会导致编址空间不连续,下图给出了解释。(下图标的不是特别明白,其实下面的索引都是去掉了最后的偏移位的简化形式。)

如果用中间位索引,那么理论上cache内可以保存内存中连续的元素,塞满cache。但是用高位索引,cache内的元素就是有步长的,不能连续对应内存中的连续空间。

在这里插入图片描述

E路组相联

前面的直接相联,只要地址差cache的整数倍,就一定会冲突,如何降低冲突概率?那就让标志位增加,吞并一部分的组索引。组索引减少以后,E就会增加,即 1 < E < C B 1<E<\frac{C}{B} 1<E<BC。两边都是极限情况,左边对应直接相联,右边对应全相联映射,本节讨论中间情况。

在这里插入图片描述

组选择/字选择

与上面一致,直接用二进制索引。

行匹配

因为一组里不止一行,所以不可以直接匹配,而是要进行遍历,判断逻辑更加复杂(关键这是硬件)。
没找到就是冷启动miss,找到但冲突就是冲突miss。

在这里插入图片描述

行替换

一组中有多行,当发生行替换时,高速缓存从内存中拿到一块数据,该替换哪一行呢?

  1. 空行直接填
  2. 没有空行,这就需要用调度策略,与内存缺页中断时的调度策略一样。
    • LFU(Frequency)。替换这段时间内最不常使用的一行。
    • LRU(Recent)。替换这段时间内没被访问过的一行。

虽然调度策略也有开销,但相比于一次甚至多次miss来说,这个开销很小。

全相联映射

全相联映射就是只有1组,此时没有组索引,全被标记位吞并了。

此时,全相联映射还可以以另一种方式理解:每一对标记位与其对应的cache行可以构成一张索引表。下图中M就是标记位,标记位在索引表中进行标记位遍历匹配,匹配到以后,就可以确定对应的cache行。这种理解方式和页表,TLB快表都是一致的,其实,TBL就是全相联映射cache,挂在CPU上。

全相联映射下有两个特征:

  1. 不存在冲突miss,只有冷启动miss。
  2. 遍历的成本可能比较大
    在这里插入图片描述

写入cache的方式

分多种情况。

  1. miss。直接把写的内容写到内存。写完后有的计算机会把数据块调入cache。
  2. hit。
    • 直写式。写cache的同时,写内存。缺点在于,写完内存之前,cache不能用,会拖慢写入速度。
    • 回写式。实际上,没必要频繁写内存,只需要在被置换出去前写一次内存就够了,为此,需要一个dirty位,1代表cache的数据被写过(修改过),则这一行被交换出去的时候需要先写一次内存,把cache的修改同步到内存。如果没修改过,那就不用同步内存。回写式比较快,但是机制比较复杂。

现在用回写式较多,因为硬件集成度提高允许更复杂更高效的系统。

cache映射的总结

三种映射的区别

三种映射有什么区别呢?从直接相联到E路组相联到全相联,随着标记位吞并组索引,两个趋势:

  1. 标记位增加,更不容易发生冲突
  2. E增加,遍历一组需要的成本增加,行匹配成本增加

具体情况,要根据规模来看:

  1. 如果cache规模小,行匹配成本可以忽略,那么就用全相联
  2. 如果特别大,本来就不容易冲突,就可以用直接相联
  3. E路组相联是折中。
计算机中的3级cache

cache可以分为data-cache和Instruction-cache。分开可以提速。由此可以引出3级cache(数字是特例,根据CPU不同而改变):

  1. L1(32K):每个内核都有d-cache和i-cache
  2. L2(256K):d-cache和i-cache公用一个L2-cache
  3. L3(8M):所有核的L2-cache公用一个L3-cache。

从上往下走,容量增大,速度减少。
在这里插入图片描述

在这里插入图片描述

cache性能的指标与硬件参数

有若干指标衡量cache性能:

  1. 不命中率。不命中数量/引用数量。
  2. 命中率。
  3. 命中时间。命中了需要花的时间,比较短
  4. 不命中处罚。注意,这个是不命中情况下额外的时间,所以不命中时间=不命中处罚+命中时间

需要注意,访问时间其实主要受到miss影响,一旦miss,访问时间将会增加很多,所以我们用不命中率去评估cache性能。下图给出了具体的计算,按100个cycle去计算,则1%的不命中率就是3%的两倍

在这里插入图片描述

硬件参数基本都是需要折中的:

  1. 高速缓存大小。太大了可以提高hit率,但是查找速度慢。
  2. 块大小。块大了可以一次性加载更多内容,提高空间局部性,但是一旦miss,替换的成本也很大
  3. 相联度。高相联度便利成本高,低相联度容易冲突
  4. 写策略。现在都用回写了。

Cache性能

编写cache友好代码

  1. 关注最重要的部分:内层循环
  2. 减少内层循环的miss率:
    • 重复访问(时间局部性)
    • 连续访问(空间局部性)

储存器山

为了测试cache的性能,有一个基准函数。函数使用4×4路循环展开进行大数组的求和。最后得到评价指标。

在这里插入图片描述
读吞吐率:读带宽。

用不同的步长和数组大小,测试吞吐率,可以得到下面的图。有重点:

  1. 沿着size轴。性能会有阶梯式变化,说明一旦调用了更低阶的cache,性能就会断崖式下跌。这代表时间局部性。
  2. 沿着stride轴。
    • 刚开始性能会平缓变化,这代表,随着stride增加,发生miss的几率在平稳增加。
    • 最后吞吐量保持不变。这是因为,当stride步长对应的内存跨度超过块的大小了,那么每次读取新元素,上一次加载进来的一个块里肯定没有这个元素,这意味着必须要再次加载。也就是说,每一次循环都有惩罚,那也就无所谓stride有多长了,所以是常数时间。
  3. 当stride=1的时候,即使size增加,仍然有一段距离吞吐量不变,这是因为激进预取策略。硬件检测到你正在遍历,就会试图提前取出你下一次要遍历的内容,于是就不会出现miss现象。因此,我们鼓励程序员写步长=1的循环,访问连续内存有利于硬件的激进预取。
  4. cache是主要影响因素,可以到1个数量级,stride是次要的,8倍。

在这里插入图片描述

循环变换:提升空间局部性

在这里插入图片描述

对这个问题,根据ijk顺序可以有6种搭配,我们最关注的是内层循环,所以在内层循环一样的前提下,代码是等价的。

在这里插入图片描述

我们做出如下假设,可以得到下面的测试表:

  1. 每个数组都是double型数组
  2. cache块大小为32B,即一个块可以放4个double数字
  3. 数组大小n特别大,不需要考虑特殊情况。

在这里插入图片描述

为什么会有上面的测试结果呢?我们看一下内循环就够了:

  1. AB:sum+=A[i][k]*B[k][j],最内层k递增,所以A矩阵是顺次访问的,B矩阵是跨行访问的,所以A每4次(遍历完一个块)就miss一次,B矩阵每次都会miss。因为内循环没用到C,所以C的miss=0
    在这里插入图片描述

  2. AC:C[i][j]+=A[i][k]*r,最内层i递增,C跨行,所以miss=1,A跨行,miss=1。B不在内循环,miss=0。
    在这里插入图片描述

  3. BC:C[i][j]+=r*B[k][j],最内层j递增,C和B都是顺次访问,miss=0.25,A不在内循环,所以miss=0。
    在这里插入图片描述

矩阵分块:提高时间局部性

分块灵感

我们前面只是让数据尽可能顺次访问,但是还存在丢掉再读回来的情况,这就说明时间局部性还没有压榨尽。理论上,极致的时间局部性是你用完一片内存后,从cache替换出去以后就再也不会用了。矩阵分块就是这种技术。

我们以最开始的ijk程序作为基准去分析,探讨一下ijk的抖动问题。以C[0][0]举例,需要A的第一行和B的第一列。假设cache的一个块可以放8个数据。那么A加载进来的8个数据会全部读取完毕。而B加载进来的数据,一个块中有8个,但是实际上只会用到1个。剩下的7个,在求C[0][1]的时候,还会被加载进来,但是仍然只会使用一个数据。也就是说,存在一个数据被多次加载进来却没有被使用的现象。

解决方案也很简单,既然我B矩阵一次性会加载进来8个数,有7个不用,那我就把那7个用了就行了。为此,需要把矩阵分块。注意,矩阵分块不等同于cache中的块

请添加图片描述
在这里插入图片描述
分块后的矩阵,宏观上有i,j,k的块遍历。在块内部,进行小的矩阵乘法。当cache可以一次性容纳3个块的时候(C一个块,A一个块,B一个块),我就可以在cache中完成小块的矩阵积,加载进来的数据全部被使用一次,不会浪费。

分块性能计算

我们来从具体的数据上分析一下。已知一个cache块可以放8个值。

先算一下不分块的ijk:

A矩阵是顺次的,所以每隔8个数就会miss一次,而B矩阵是跨行的,1个数就miss一次,计算一个C[i][j],对应n组数据的使用,则一个C值要有 n × ( 1 8 + 1 ) n\times (\frac{1}{8}+1) n×(81+1)。C是方阵,所以总共要 n 3 × 9 8 n^3\times \frac{9}{8} n3×89次miss。

再算一下分块的ijk情况

设一个块长宽都是B个元素,则一个块的大小为 B 2 B^2 B2个元素,一个方向上有 n B \frac{n}{B} Bn个块。假设cache里可以同时装得下3个小块,保证分块求矩阵积的时候不会出现抖动现象,即 C > 3 B 2 C>3B^2 C>3B2

在计算C中一个小块的时候,A中或者B中的一个块,内部都可以看做是顺次的,所以一个块有 B 2 8 \frac{B^2}{8} 8B2次miss,那么AB各出一个就是 B 2 4 \frac{B^2}{4} 4B2次。计算一个C小块,需要 n B \frac{n}{B} Bn组小块,所以计算C中的一个小块,总共有 n B × B 2 4 = n B 4 \frac{n}{B}\times \frac{B^2}{4}=\frac{nB}{4} Bn×4B2=4nB次miss。

C中总共需要计算 ( n B ) 2 (\frac{n}{B})^2 (Bn)2个小块,所以总共有 n 3 4 B \frac{n^3}{4B} 4Bn3次miss。

从公式中可以看出,B越大,效果越好。但是要注意,cache要能同时装下3个块才行 C > 3 B 2 C>3B^2 C>3B2

分块总结

两个指标:

  1. 不分块: 9 8 × n 3 \frac{9}{8}\times n^3 89×n3
  2. 分块: 1 4 B × n 3 \frac{1}{4B}\times n^3 4B1×n3。注意cache要同时装下3个块。

之所以可以提高时间局部性,是因为计算量 2 n 3 2n^3 2n3本身就远大于数据规模 3 n 2 3n^2 3n2,也就是说总有数据是要重复使用的,我们只需要找到重复使用的方法即可。

提高cache性能总结

利用好cache,性能又可以提个数量级。

  1. 集中注意力到内循环,尤其是有访存的内循环。
  2. 空间局部性:尽量使数据以步长1顺次访问,减少miss,甚至激发激进预取策略,消除miss。
  3. 时间局部性:一旦取出一个数据,就要多次使用,最好彻底用完后丢掉,保证再也不用(防止抖动)。
  • 5
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

亦梦亦醒乐逍遥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值