计算机组成原理笔记

脑图

计算机五大组成部分
控制器
运算器
存储器
输入设备
输出设备

指令与运算

指令跳转: 原来if…else就是goto

CPU是如何执行指令的
    一条条计算机指令执行起来非常复杂,好在CPU在软件层面上已经为我们做好了封装,写好的代码变成指令后,是一条一条顺序执行的
    寄存器
        一个CPU里有多种不同功能的寄存器
        特殊寄存器
            PC寄存器(Program Counter Register):我们也叫指令寄存器,顾名思义它就是用来存放下一条需要执行的指令的内存地址
            指令寄存器(Instruction Register):用来存放当前正在执行的指令
            条件码寄存器(Status Register): 用里面的一个一个标记位(Flag),存放cpu进行算数或逻辑计算的结果
        Cpu里还有很多用来存储数据和内存地址的寄存器,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器,有些寄存器既可以存放数据,又可以存放指针,我们就叫它通用寄存器
        ![](http://pic.aipp.vip/20210511021317.png)

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

        而有些特殊指令,比如J类指令,也就是跳转指令,会修改PC寄存器里的地址值,这样下一条要执行的指令就不再是内存里顺序加载。

    从if...else来看程序的执行和跳转
        cmp 指令比较了前后两个操作数的值,这里的 DWORD PTR 代表操作的数据类型是 32 位的整数,而[rbp-0x4]则是一个寄存器的地址。所以,第一个操作数就是从寄存器里拿到的变量 r 的值。第二个操作数 0x0 就是我们设定的常量 0 的 16 进制表示。cmp 指令的比较结果,会存入到条件码寄存器当中去。
        在这里,如果比较的结果是 True,也就是 r == 0,就把零标志条件码(对应的条件码是 ZF,Zero Flag)设置为 1。除了零标志之外,Intel 的 CPU 下还有进位标志(CF,Carry Flag)、符号标志(SF,Sign Flag)以及溢出标志(OF,Overflow Flag),用在不同的判断条件下。
        cmp 指令执行完成之后,PC 寄存器会自动自增,开始执行下一条 jne 的指令。
        跟着的 jne 指令,是 jump if not equal 的意思,它会查看对应的零标志位。如果为 0,会跳转到后面跟着的操作数 4a 的位置。这个 4a,对应这里汇编代码的行号,也就是上面设置的 else 条件里的第一条指令。当跳转发生的时候,PC 寄存器就不再是自增变成下一条指令的地址,而是被直接设置成这里的 4a 这个地址。这个时候,CPU 再把 4a 地址里的指令加载到指令寄存器中来执行。
        跳转到执行地址为 4a 的指令,实际是一条 mov 指令,第一个操作数和前面的 cmp 指令一样,是另一个 32 位整型的寄存器地址,以及对应的 2 的 16 进制值 0x2。mov 指令把 2 设置到对应的寄存器里去,相当于一个赋值操作。然后,PC 寄存器里的值继续自增,执行下一条 mov 指令。
        这条 mov 指令的第一个操作数 eax,代表累加寄存器,第二个操作数 0x0 则是 16 进制的 0 的表示。这条指令其实没有实际的作用,它的作用是一个占位符。我们回过头去看前面的 if 条件,如果满足的话,在赋值的 mov 指令执行完成之后,有一个 jmp 的无条件跳转指令。跳转的地址就是这一行的地址 51。我们的 main 函数没有设定返回值,而 mov eax, 0x0 其实就是给 main 函数生成了一个默认的为 0 的返回值到累加器里面。if 条件里面的内容执行完成之后也会跳转到这里,和 else 里的内容结束之后的位置是一样的
        ![](http://pic.aipp.vip/20210511021654.png)

    总结
        除通过PC寄存器自增的方式外,条件码寄存器会记录下当前执行指令的条件判断状态,然后通过跳转指令读取对应的条件码,修改PC寄存器内的下一条指令的地址,最终实现if...else以及for/while这样的程序控制流程

        在硬件层面实现goto,除本身需要保存下一条指令地址,以及当前正要执行指令的PC寄存器、指令寄存器外,我们只需要再增加一个条件码寄存器,来保留条件判断状态,这样三个寄存器就可以实现条件判断和循环重复执行代码的功能。

函数调用: 为什么会发生stack overflow
    rbp,又叫栈帧指针(Frame Pointer),是一个存放了当前栈帧位置的寄存器
    rsp(stack pointer),rsp始终指向栈顶

为什么会发生stack overflow

我们为什么需要程序栈
    rbp rsp pc 入参
    栈帧(stack frame)
如何构造一个stack overflow
如何利用函数内联进行性能优化
    收益
    代价
    叶子函数


参数入栈: 将函数参数从右向左依次压入栈中
返回地址入栈: 将PC寄存器下一条指令压入占中,供函数返回时继续执行
栈帧调整:
    a.保存当前栈帧状态值,rbp入栈
    b.将当前栈帧切换到新栈帧,mov rbp,rsp
    c.给新栈帧分配空间
调用结束,


参数入栈: 将函数参数从右向左依次压入栈中
call时,会将当前PC寄存器下一条指令地址压栈,保留函数调用结束后要执行的指令地址。
然后push rbp,将调用函数的栈帧的栈底地址压栈
接着mov rbp,rsp 将rsp这个栈指针的值复制给rbp,而rsp始终指向栈顶
调用结束后,pop rbp,这个操作恢复用于栈底地址
然后ret,将call调用时压入的pc寄存器下一条指令出栈,更新到PC寄存器中,将程序控制权返回到出栈后的栈顶

ELF和静态链接: 为什么程序无法同时在linux和windows下运行?

编译、链接和装载: 拆解程序执行
    C语言代码->汇编代码->机器代码
        两部分构成
            第一部分: 编译、汇编、链接 -> 可执行文件
            第二部分: 通过装载器(loader)将可执行文件(load)到内存中,cpu从内存中读取指令和数据,来真正执行程序
            ![](http://pic.aipp.vip/20210511172633.png)
ELF格式和链接: 理解链接过程
    ELF是linux下可执行文件和目标文件使用的文件格式(extension file format),中文名叫可执行与可链接文件格式,里面不仅存放了编译成的汇编指令,还保存了很多别的数据
    elf文件中的信息按照section保存
    ![](http://pic.aipp.vip/20210511173732.png)
    ELF有一个文件头(FILE HEADER),用来表示文件的基本属性,比如是否是可执行文件,对应的cpu、操作系统等,除这些外,大部分程序还有这么一些section
    1. .text Section也叫代码段,用来保存程序代码和置零
    2. .data Section也叫数据段,用来保存程序里设置好的初始化数据信息
    3. .rel.text Section 叫做重定位表,其中保存的是当前文件中哪些跳转地图其实是我们不知道的。
    4. 符号表

    链接器会扫描所有输入的目标文件,然后把所有符号表信息收集构成全局符号表,再根据重定位表,把不确定要跳转地址的diamante根据符号表存储的地址进行一次修正,最后把所有目标文件进行合并,编程最终的可执行代码
    ![](http://pic.aipp.vip/20210511173925.png)
为什么同样一个程序在linux下可以执行而在windows下不能执行,很重要一个原因是两个操作系统的可执行文件格式不一样,linux下是elf,windows下是PE,linux下的装载器只能解析ELF格式而不能解析PE格式。
如果我们有一个能够解析PE格式的装载器,我们就有可能在linux下运行windows程序,这就是wine

程序装载: “640K内存”真的不够用么?

动态链接: 程序内部的”共享单车”

二进制编码:“手持两把锟斤拷,口中疾呼烫烫烫”?

理解二进制的逢二进一

字符串的表示,从编码到数字
    不仅述职可以用二进制表示,字符乃至更多的信息都能用二进制表示,最典型的例子就是字符串

    二进制序列化
        最大32位证书是2147473647,如果用证书表示法只需要32位就能表示,但是如果用字符串表示一共有10个字符,每个字符用8位的话,需要整整80位, 比起整数表示法,要多占用很多空间。
        这也是为什么很多时候我们存储数据时要采用二进制序列化,而不是简单的把数据存储为JSON文本序列化数据,不管是整数或浮点数,采用二进制序列化会比存储文本节省不少空间。
        > ASCII 用8位二进制中的128个数映射表示128个不同的字符,比如小写字母a在ASCII中为97,也就是二进制的0110 0001
        > 文本序列化,常见如json、xml、serialize等
        > 二进制序列化,常见如msgpack、protobuf、thrift等

    字符集: 字符的一个集合,比如unicode就是一个字符集,包含150种语言的14万个不同的字符
    字符编码: 字符集里的这些字符--用二进制表示出来的一个字典,比如unicode字符集的UTF-8,UTF-16等编码

    只要建立起字符集和字符编码,并且得到大家的认同,我们就可以在计算机里表示字符信息了。

理解电路: 从电报机到门电路,我们如何做到”千里传音”

从信使到电报,我们怎么做到"千里传音"
理解继电器,给跑不动的信号续一秒
    为了能实现接力传输信号,在电路里,工程师早了一个叫做继电器(relay)的设备
    ![](http://pic.aipp.vip/20210513212740.png)

加法器: 如何像乐高一样搭电路

浮点数和定点数: 怎么用有限的Bit标识尽可能多的信息?

定点数
浮点数
    浮点数表示法 符号位s+指数位e+有效位数f
    浮点数无论是标识还是计算都是近似计算

浮点数的二进制转化

浮点数和定点数: 深入理解浮点数到底有什么用?

处理器

建立数据通路: 指令+运算=CPU

指令周期(instruction cycle),又称读取执行周期
    计算机每执行一条指令可以分解成几个步骤
    1.Fetch(取得指令): 从PC寄存器找到对应的指令地址,根据指令地址从内存中把具体指令加载到指令寄存器中,然后把PC寄存器自增,好在蔚来执行下一条指令
    2.Decode(指令译码),根据指令寄存器里的指令,解析成要进行什么操作,是R\I\J中的哪一种指令,具体要操作哪些寄存器、数据或内存地址
    3.Excute(执行指令),实际运行对应的R\I\J这些特定的指令,进行算数运算操作、数据传输或直接的地址跳转
    4.重复进行1~3的步骤
    这样的步骤是一个永不停歇的循环,这个循环就是指令周期
    ![](http://pic.aipp.vip/20210514032320.png)
    循环中,不同部分是由计算机不同组件完成的,取指令阶段,我们的指令是放在存储器里的,实际上,通过PC寄存器和指令寄存器去除指令的过程,是由控制器(control unit)操作的。指令解码过程,也是由控制器进行的。一旦执行到指令阶段,无论进行算数操作、逻辑操作的R执行还是进行数据传输、条件分支的I指令,都是由算数逻辑单元(ALU)操作的,也就是由运算器处理的。不过,如果是一个简单的无条件地址跳转,那么我们可以直接在控制器里完成,不需要用到运算器。
    ![](http://pic.aipp.vip/20210514032545.png)

    除了指令周期,在CPU里还会提到2个周期,一个叫machine cycle,机器周期或CPU周期,CPU内部操作速度很快,但是访问内存的速度却慢很多,每条指令都需要从内存加载而来,所以我们一般把从内存里读取一条指令的最短时间,称为CPU周期。

    对于一个指令周期来说,我们取出一条指令然后执行它,至少需要2个CPU周期。取出指令至少需要一个CPU周期,执行至少也需要一个CPU周期,复杂的指令则需要更多CPU周期

    还有一个是Clock cyccle,即时钟周期,一个CPU周期,通常会由几个时钟周期累计起来。
    ![](http://pic.aipp.vip/20210514032846.png)

    一个指令周期,包含多个CPU周期,而一个CPU周期包含多个时钟周期

建立数据通路
    数据通路就是我们的处理器单元,它通常由两类原件组成
        第一类叫操作原件,也叫组合逻辑原件,其实就是ALU。它们的功能就是在特定输入下,根据组合电路的逻辑生成特定的输出
        第二类叫存储原件,也叫状态原件,比如我们在计算过程中需要用到的寄存器(通用/状态),都是存储原件

        我们通过数据总线的方式,把他们连接起来,就可以完成数据的存储、处理和传输了,这就是所谓的建立数据通路了

    下面我们来说控制器,它的逻辑相对简单,可以把它看成只是机械地重复"fetch-decode-excute"循环中的前两步,然后把最后一步通过控制器产生的信号交给ALU处理
    ![](http://pic.aipp.vip/20210514033520.png)
CPU所需要的硬件电路

面向流水线的指令设计: 一心多用的现代CPU

愿得一人心,白首不相离: 单指令周期处理器
    单指令周期处理器: 一个周期内处理器正好能处理一条指令
        不过我们的时钟周期是固定的,但指令的复杂度是不同的,所以实际执行一条指令的时间是不同的(随着门电路层数增加、由于门延迟的存在,位数多、计算复杂的指令需要的执行时间会更长)

        不同指令执行时间不同,但是我们需要让所有指令都在一个时钟周期内完成,那就只好把时钟周期和执行时间最长的那个指令设成一样。
        ![](http://pic.aipp.vip/20210514034418.png)
        快速执行完成的指令,需要等待满一个时钟周期,才能执行下一条指令

        这种情况下,虽然CPI能够保持在1,但是我们的时钟频率却没法太高,因为太高的话,有些复杂指令没法在一个是时钟周期内运行完成,那么下一个时钟周期到来,开始执行下一条指令时,前一条指令的执行结果可能还没写到寄存器里,那么下一条指令读取的数据就是不正确的,导致出错
        ![](http://pic.aipp.vip/20210514034640.png)
        前一条指令的写入在后一条指令的读取之前

        这里会发现,这里和之前将时钟频率时说的不太一样,当时说一个CPU时钟周期可以认为是完成一条简单指令的时间,为什么到这里单指令周期处理器,反而变成了执行一条最复杂的指令的时间呢?
        这是因为无论是PC上使用的intel cpu还是手机上使用的ARM CPU,都不是单指令周期处理器,而是采用一种叫指令流水线的技术
        ![](http://pic.aipp.vip/20210514035013.png)

        这就好像我们的后端程序员不需要等待功能上线,就会从产品经理手中拿到下一个需求,开始开发API,这样的协作模式,就是我们所说的指令流水线,里面每一个独立的步骤,我们就称之为流水线阶段或流水线级(pipeline stage)

        这样一来我们就不用把时钟周期设置为单条指令执行的时间,而是拆分完成这样的一个一个小步骤需要的时间。

        如果我们把一个指令拆分为"取指令-指令译码-执行指令"这样三个部分,这就是一个三级流水线,如果我们进一步把执行指令拆分为ALU计算(指令执行)-内存访问-数据写回,那么它就会变成一个五级的流水线

        五级流水线就表示在一个时钟周期里,可以同时执行五条指令的不同阶段。这个时间,虽然执行一条指令的时钟周期变成了5,但是我们可以把CPU的主频提的更高了。我们不需要确保最复杂的那条指令在时钟周期里执行完成,而只要保障一个最复杂的流水线级的操作,在一个时钟周期内完成就好了

        虽然我们不能通过流水线来减少单条指令执行的"延时"这个性能指标,但是通过同时执行多条指令的不同阶段,我们提升了CPU的“吞吐率”,在外部看来,我们的CPU好像是“一心多用”,在同一时间,同时执行了5条不同指令的不同阶段。在CPU内部,其实就像生产线一样,不同分工的组件不断处理上游传下来的内容,而不需要等待单个商品生产完成后,再启动下一件商品的生产过程

超长流水线的性能瓶颈
    既然流水线可以增加吞吐量,为什么不把流水线做得更深呢?比如20级、40级?这里其实是有原因的,一个基本原因是,增加流水线深度,其实是有性能成本的

    我们用来同步时钟周期的不再是指令级别的,而是流水线阶段级别的,每一级流水线对应的输出,都要放到流水线寄存器(pipeline register)里,然后下一个时钟周期交给下一个流水线级去处理,所以每增加一级的流水线,就要多一级写入到流水线寄存器的操作。
    ![](http://pic.aipp.vip/20210514035957.png)       

    但是我们不断加深流水线,这些操作占整个指令的执行时间比例就会不断增加,最后我们的性能瓶颈就会出现在这些overhead上,如果我们指令执行有3纳秒,也就是3000皮秒,我们需要20级的流水线,那流水线寄存器写入就需要花费400皮秒,占超过10%,如果50级流水线就要多花费1纳秒在流水线寄存器上,占到25%,也就意味着,单纯增加流水线级数,不仅不能提升性能,反而会有更多的oerhead开销

    为了不浪费CPU性能,我们通过把指令的执行过程,切分成一个个流水线级来提升CPU吞吐,因为每一级的overhead,一味地增加流水线,并不能无限的提高性能

    一个CPU的时钟周期是完成一条最复杂(耗时最长)指令拆分后阶段执行时间

    小故事: 流水线级数可以将单个时钟周期的时间设的很短,这也变相地让CPU的主频提升的很快

    流水线级数并不能缩短单条指令的响应时间,但是可以增加在运行很多指令时的吞吐率,看这个例子
    一条整数加法需要200ps
    一条整数乘法,需要300ps
    一条浮点数乘法,需要600ps
    如果我们在单指令周期的CPU上运行,最复杂的指令是浮点数乘法,为600ps,那三条指令就都需要600ps,总执行时间为1800ps

    如果我们采用6级流水线CPU,每一个pipeline的stage都只需要100ps,那么这三个指令的执行过程中,在指令1的第一个100ps的stage结束后,第二条指令就开始执行了,第二条指令的第一个100ps的stage结束后,第三条指令就开始执行了,这种情况下,三条指令顺序执行所需总时间为800ps,那么在1800ps内,使用流水线的CPU比单指令周期的CPU就可以多执行一倍以上你的指令数

    同样时间内完成的指令数多了,也就是吞吐上升了
    ![](http://pic.aipp.vip/20210514041503.png)


​ 新的挑战: 冒险和分支预测
​ 为什么奔腾4会失败呢?
​ 1.功耗问题,提升流水线深度必须要和提升CPU主频同时进行,因为单个pipeline stage能够执行的功能变简单了,也就意味着单个时钟周期内能完成的事情变少了,所以只有提升时钟周期,CPU在指令的响应时间这个指标上才能保持和原来相同的性能。
​ 同时流水线深度增加,我么需要的电路数量变多了,也就是我们所使用的晶体管增多了。
​ 主频提升和晶体管数量增加都会增加CPU功耗
​ 2.流水线带来的性能提升在实际程序执行中,并不一定能用得到

int a = 10 + 5; // 指令1
int b = a 2; // 指令2
float c = b
1.0f; // 指令3
我们发现三条指令相互依赖,总完成时间是1100ps而不是800ps,这个依赖问题就是计算机组成所说的冒险(hazard)问题。这里只列举了数据层面的依赖,也就是数据冒险,实际应用中,还会有结构冒险、控制冒险等依赖问题

    对于这些冒险问题,我们也有乱序执行、分支预测等解决方案

    但是流水线越长,冒险的问题就越难解决

    总结
        流水线级数和其他级数一样,都讲究一个折中(trade-off),一个合理的流水线深度,会提升我们CPU执行计算机指令的吞吐,我们一般用IPC(instruction per cycle)来衡量CPU执行指令的效率,IPC其实就是CPI(cycle per instruction)的倒数,IPC=3对应着cpi=0.33

冒险和预测

任何一本讲解CPU流水线设计的教科书,都会提到流水线设计需要解决的三大冒险,分别是结构冒险、数据冒险以及控制冒险

结构冒险: 为什么工程师都喜欢用机械键盘?
    结构冒险本质是一个硬件层面的资源竞争问题,CPU在同一个时钟周期,同时在运行两条计算机指令的不同阶段,但是这两个不同的阶段,可能会用到相同的硬件电路。
    我们可以看到,第一条指令执行到访存(mem)阶段时,流水线第四条指令,在执行取指令操作。访存和取指令,都需要进行内存数据的读取,我们的内存,只有一个地址译码器作为地址输入,那就只能在一个时钟周期里读取一条数据,没法同时执行第1条读取内存数据和第4条指令的读取指令代码
    ![](http://pic.aipp.vip/20210514042924.png)
    类似的资源冲突,比如薄膜键盘的锁键问题,薄膜键盘并不是每个按键都有一根独立的线路,而是多个键公用一个线路,如果我们同一时间按下连个公用一个线路的按键,这两个按键的信号就没办法都传输出去

    机械键盘每个按键都有独立的传输线路,可以做到"全键无冲",这种资源冲突解决方案本质就是增加资源。同样的方案我们一样可以用在CPU的结构冒险中,对于访问内存数据和取指令的冲突,一个直观解决办法就是把我们的内存分为2个部分,让他们各有各的地址译码器,这两部分分为是存放指令的程序内存和存放数据的数据内存,这样把内存拆为两部分的解决方案,叫哈佛架构,不过我们今天使用的CPU仍然是冯诺依曼结构,
    ![](http://pic.aipp.vip/20210514043356.png)
    不过借鉴了哈佛结构的思路,现代CPU在cpu内部告诉缓存做了区分,把告诉缓存分为指令缓存(instruction cache)和数据缓存(data cache)两部分,两者的拆分使得我们的CPU在进行数据访问和取指令时,不会再发生资源冲突问题

    解决办法: 增加资源,通过添加指令缓存和数据缓存,让我们对于指令和数据的访问可以同时进行

数据冒险
    数据冒险其实是同时在执行的多个指令之间,有数据依赖问题,我们分为3类
    先写后读
    先读后写
    写后再写
        先写后读
            int main() {
            int a = 1;
            int b = 2;
            a = a + 2;
            b = a + 3;
            }
            先写后读的依赖关系,我们一般称为数据依赖
        先读后写

            int main() {
            int a = 1;
            int b = 2;
            a = b + a;
            b = a + b;
            }
            反依赖
        写后再写

            int main() {
            int a = 1;
            a = 2;
            }
            输出依赖


​ 通过流水下你停顿解决数据冒险
​ 流水线停顿的办法很容易理解,如果我们发现后面执行的指令,对前面执行的指令有数据层面的依赖关系,那最简单的方法就是“再等等”

​ 实践过程中,我们并不是让流水线停下来,而是在执行后面操作前插入一个NOP操作,也就是一个什么都不干的操作

冒险和预测: 流水线里的接力赛

首先回顾一下
对于结构冒险,对应的解决方案一是增加资源,通过添加指令缓存和数据缓存,让我们对于指令和数据的访问可以同时进行。这个办法帮CPU解决了取指令和访问数据之间的资源冲突
对于数据冒险,则是直接进行等待

对于流水线冒险问题,有一个更加精巧的解决方案,操作数前推

NOP操作和指令对齐
    MIPS体系结构下有R\I\J三类指令,以及五级流水线"取指令(IF)-指令译码(ID)-指令执行(EX)-内存访问(MEM)-数据写回(WB)"
    ![](http://pic.aipp.vip/20210515172609.png)
    ![](http://pic.aipp.vip/20210515172624.png)

    在MIPS体系结构下,不同类型的指令会在流水线的不同阶段进行不同操作
    ![](http://pic.aipp.vip/20210515172712.png)
    有些指令没有对应的流水线阶段,但我们并不能直接跳过阶段,不然不同指令间可能会发生结构冒险事件,产生资源竞争
    ![](http://pic.aipp.vip/20210515172821.png)
    在实际中,各个指令不需要的阶段会插入NOP操作,这样可以使后一条指令的每一个Stage一定不会和前一条指令同Stage在一个时钟周期执行,这样就避免了同一周期竞争相同资源产生结构冒险了。
    ![](http://pic.aipp.vip/20210515173039.png)

操作数前推
    插入过多的NOP操作意味着我们的CPU总是在空转,有没有什么办法能减少插入NOP操作呢?看下下面例子

    add $t0, $s2,$s1
    add $s2, $s1,$t0

    第一条指令,把s1和s2寄存器里数据相加存入到t0寄存器
    第二条指令,把s1和t0寄存器里数据相加存入s2寄存器

    因为后一条add依赖寄存器t0里的值,而t0里的值又来自前一条指令计算,所以第二条指令需要等的第一条指令数据写回阶段完成后,才能执行。于是就不得不通过流水线停顿来解决,我们要在第二条指令译码阶段后插入对应的NOP指令,知道前一条指令数据回写完毕才能继续执行
    这个方案虽然解决了数据问题,但是也浪费了2个时钟周期
    ![](http://pic.aipp.vip/20210515173538.png)

    这里如果我们第一条指令执行的结果能够直接传输给第二条指令的执行阶段作为输入,那我们的第二条指令就不用再从寄存器里把数据再单独读出来一次才执行代码,这样就可以减少两个NOP的插入
    ![](http://pic.aipp.vip/20210515174121.png)
    这样的解决方案,我们叫做操作数前推(operand forwarding)

    > forward其实应该理解为“转发”,这是这个技术的逻辑含义,也就是在第一条指令的执行结果直接转发给地第二天指令的ALU作为输入。另一个名字(bypassing)则是这个技术的硬件含义,为了实现这里的转发,我们在CPU硬件里需要再单独拉一根信号传输线路出来,使得ALU的计算结果能够重新回到ALU的输入里来,这样一条线路就是我们的旁路。

    > 有时虽然我们可以把操作数转发到下一条指令,但是下一条指令仍然需要停顿一个时钟周期
    比如先LOAD,再ADD。LOAD指令在访存阶段才能把数据读取出来,所以下一条指令的执行阶段,需要在方寸阶段完成后,才能进行。
    ![](http://pic.aipp.vip/20210515174602.png)

总结
    操作数前推就是通过硬件层面制造一条旁路,让一条指令的计算结果,可以直接传输给下一条指令,而不再需要"指令1写回寄存器,指令2读取寄存器"操作,这样的好处是后面的指令可以减少,甚至消除原本需要通过流水线停顿才能解决的数据冒险问题


​ 什么情况下会插入NOP?
​ 1.流水线对齐,对于不需要的阶段插入NOP代替
​ 2.流水线停顿,等待依赖数据资源

冒险和预测(三): CPU里的”线程池”

之前讲解了之前的三种技术,增加资源、停顿等待、操作数前推等,但依旧会有需要等待前面指令完成的情况,也就是采用流水线停顿的解决办法。
那么我们能不能让后面没有数据依赖的指令,在前面指令停顿的时候先执行呢?

填上空闲的NOP: 上菜的顺序不必是点菜的顺序

    a = b + c
    d = a * e
    x = y * z
    计算力x要等待a和d都计算完,这实现是没啥必要,我们完全可以在d计算等待a时,先把x的结果计算出来
    体现在流水线中,后面的指令不依赖前面的指令,那就不用等待前面的指令执行,它完全可以先执行
    ![](http://pic.aipp.vip/20210515180802.png)
    可以看到,第三条指令不依赖于前两条指令计算结果。
    这样的解决方案,在计算机组成里被成为乱序执行(out-of-order excution,OoOE)

CPU里的"线程池": 理解乱序执行
    ![](http://pic.aipp.vip/20210515181500.png)
    1.取指令和指令译码时,乱序执行的CPU同样是顺序地进行取指令和指令译码操作
    2.指令译码完成后就不同了,CPU不会直接进行指令译码,而是进行一次指令分发,把指令发送到保留站(reservation stations)。
    3.这些指令不会立即执行,而是等待它们所依赖的数据到齐后才会执行
    4.一旦指令依赖数据来齐了,指令就可以交给后面的功能单元(Fuction Unit,FU)来执行,其实就是ALU。
    5.指令执行完成后把结果存放到重排序缓冲区(re-order buffer,ROB)
    6.在重排序缓冲区中,CPU会按照指令的顺序,对指令计算结果重排序。只有排在前面的指令都已经完成了,才会提交指令,完成整个指令的运算结果
    7.实际的指令;‘计算结果,并不是直接写到内存或告诉缓存中,而是先写道存储缓冲区(store buffer),最终才会写入到告诉缓存和内存中

    乱序执行情况下,只有CPU内部指令的执行层面可能是乱序的,只要我们能在指令的译码阶段正确分析出指令的数据依赖关系,这个乱序就只会在互相没有影响的指令之间发生

总结
    本节介绍乱序执行这个解决流水线阻塞的技术方案。因为数据依赖关系和指令先后执行的顺序问题,很多时候,流水线不得不阻塞在特定指令上,即使后续别的指令并不依赖正字执行的指令和阻塞的指令,也不能继续执行
    而乱序执行,则是指令执行的阶段通过一个类似线程池的保留站,让系统自己去动态调度先执行那些指令,这个动态调度巧妙解决了流水线阻塞的问题。指令执行的先后顺序,不再和他们在程序中的顺序有关,我们只要保证不破坏数据依赖就好,CPU只要等到在指令结果的最终提交阶段,再通过重排序的方式,确保指令"实际上"是顺序执行的。

冒险和预测(三): 今天下雨了,明天还会下雨么

控制冒险
    在结构冒险和数据冒险中,所有流水线停顿操作都是从指令执行阶段开始,流水线的前两个阶段(取指令和指令译码)是不需要停顿的,CPU会在流水线里直接读取下一条指令,然后进行译码。
    取指令和译码不会遇到任何停顿,这是基于"所有指令代码都是顺序加载"假设前提下的表现,实际上一旦遇到if这样的条件分支或for/while循环,就会不成立。jmp后的那一条指令是否应该顺序加载执行,在流水线进行取指令时无法知晓,要等jmp指令执行完成后更新PC寄存器后,我们才能知道是否执行下一条置零,然是跳转到另一个内存地址去执行别的指令
    这种为了确保能取到正确指令,而不得不进行等待延迟的情况,就是控制冒险。
应对控制冒险的方式
    缩短分支延迟
        条件跳转指令=条件比较+跳转地址写入PC寄存器
        将条件判断、地址跳转都提前到指令译码阶段进行,不需要放在指令执行阶段。相应的需要在CPU里设计对应的旁路,在指令译码阶段就提供对应的判断比较电路。
        本质上和操作数前推解决方案类似,就是在硬件电路里把一些计算结果更早的反馈到流水线中
    分支预测
        静态分支预测
            最简单的分支预测技术,叫"假装分支不发生",顾明司仪就是仍然按照顺序把指令往下执行。这事一种静态预测技术,就好像猜硬币,你一直猜正面,会有50%的正确率。
            如果预测正确,我们会节省本来需要停顿下来去等待的时间;如果预测失败,那就把后面已取出指令已执行部分丢弃掉,这个丢弃操作在流水线里叫Zap或Flush。
        动态分支预测
            简单来说就是根据之前比较结果来预测
            举例: 通过今天天气来预测明天天气,如果今天下雨,那预测明天也下雨,今天晴天,明天也是晴天。
            这种我们叫一级分支预测,也叫1比特饱和计数,这个方法其实就是用一个比特记录当前分支情况,只用当前分支比较情况来预测下一次分支的比较情况
            同样的,也有2比特饱和计数等等

Superscalar和VLIW: 如何让CPU的吞吐率超过1?

程序的CPU执行时间=指令数 * CPI * Clock Cycle Time
CPI的倒数叫IPC(intruction per clock),也就是一个时钟周期里能执行的指令数,代表了CPU的吞吐率,这个指标理想情况下只能到1
![](http://pic.aipp.vip/20210517144611.png)

如何继续提升CPI呢?

多发射与超标量: 同一时间执行两条指令
    ![](http://pic.aipp.vip/20210517144656.png)
    多发射: 同一时间将多条指令发射到不同译码期或后续处理流程的流水线。
    超标量: 超标量(superscalar)是指在CPU中有一条以上的流水线,并且每时钟周期内可以完成一条以上的指令,这种设计就叫超标量技术。
    超标量技术使得CPU不仅在指令执行阶段是并行的,在取指令和指令译码时也是并行的。通过超标量技术可以使CPU的IPC突破1
    > 原本在一个时钟周期内,只能执行一个标量(scalar)的运算,在多发射情况下,我们可以突破这个限制,进行多次计算。
    > 在超标量的CPU中有很多并行的流水线
    ![](http://pic.aipp.vip/20210517144851.png)

SIMD: 如何加速矩阵乘法

超线程: intel多卖给你的那一倍CPU
    无论是多个CPU核心运行不同程序,还是单个CPU核心里切换运行不同线程的任务,在同一时间点,一个屋里的CPU核心只会运行一个线程的指令,所以其实我们并没有真正的做到指令的并行运算
    ![](http://pic.aipp.vip/20210517153305.png)
    超线程是把物理层面CPU核心伪装成两个逻辑层面的CPU核心。这个CPU会在硬件层面增加很多电路,使得在一个CPU核心内维护两个不同进程的指令的状态信息。
    比如,在一个物理CPU核心内有双份的PC寄存器、指令寄存器乃至条件码寄存器。在外部看来,似乎有两个逻辑层面的CPU在同时运行。超线程技术一般也备叫做同时多线程(simultaneous multi-threading,简称SMT)技术
    不过CPU其他组件上却不是双份的,不论是指令译码期还是ALU,一个CPU核心仍然只有一份。因为超线程并不是真的去同时运行两个指令,那就真的变成物理多核了。
    超线程的目的是在一个线程A的指令在流水线停顿时,让另一个线程去执行指令,因为这时候CPU的译码器和ALU就空出来了,那么另一个线程B就可以拿来干自己需要的事情
    这样CPU通过很小的代价就能实现"同时"运行多个线程的效果,通常我们只要在CPU核心的添加10%左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点

    不过我们并没有增加真的功能单元,所以超线程只在特定应用场景下效果比较好,一般是在各个线程等待时间比较长的应用场景,比如我们需要应对很多请求的数据库应用就很适合超线程,各个指令都需要等待访问内存数据,但是并不需要做太多计算 (简单来说比较适合IO密集型计算)
    ![](http://pic.aipp.vip/20210517154408.png)
    4核CPU 8逻辑核

SIMD: 如何加速矩阵乘法
    上面图中的instrcutions写到了MMX\SSE等,这些就是CPU支持的指令集
    由此引出一个提升CPU性能的技术方案--SIMD 单指令多数据流(single instrction multiple data)
    相对的有循环一步一步计算的方式,被称为SISD,也就是单指令单数据的处理方式
    如果你手头是一个多核CPU,那么它同时处理多个指令的方式可以叫做MIMD,多指令多数据(multiple instruction multiple data)
    为什么SIMD指令能那么快
        这是因为SIMD在获取数据和执行指令时都做到了并行
        1.在从内存里读取数据的时候,SIMD是一次性读取多个数据
        2.在数据读取到后,指令执行层面,SIMD也是可以并行执行的
        ![](http://pic.aipp.vip/20210517155803.png)

    所以对于那些在计算层面存在大量“数据并行”(data parallelism)的计算中,使用SIMD时一个很划算的办法。大量的数据并行,其实通常就是实践中的向量运算或矩阵运算。在实际程序开发中,过去通常是在进行图片、视频、音频的处理。最近几年通常用于各种机器学习算法的计算。

    典型的有intel的MMX指令集(matrix math extensions) 矩阵数学扩展


​ 总结
​ 这里讲解了超线程和SIMD两个CPU的“并行计算”方案。超线程其实是一个“线程级别并行”的解决方案。它通过让一个物理CPU核心,“装作”两个逻辑层面的CPU核心,使得CPU可以同时运行两个不同线程的指令
​ SIMD技术,则是一种“指令级并行”的加速方案,或者说它是一种“数据并行”的加速方案,在处理向量计算时,同一个向量的不同维度之间的计算是相互独立的。而我们的CPU里的寄存器又能放得下多条数据,于是我们可以一次性去处多条数据交给CPU并行计算

异常和中断: 程序出错了怎么办

这里我们来看看计算机是如何处理异常的
异常: 硬件、系统和应用的组合拳
    这里讲的异常时硬件、系统相关的“硬件异常”,比如加法器两个数相加产生算数溢出;或玩游戏按下键盘发送指令给CPU,CPU要执行一个现有流程之外的指令,这也是异常

    异常是软硬件一起的处理过程,异常的发生和捕获是在硬件层面完成,异常处理处理则是由软件来完成。

    计算机为每一种异常分配一个异常代码,其中I/O发出的信号的异常代码是由操作系统来分配的,也就是由软件来设定的。而像加法溢出这样的异常代码,则是由CPU预先分配好的,也就是由硬件来分配的。
    拿到异常代码后,CPU会触发异常处理流程,计算机内存里,会保留一个异常表(exception table),有地方把这个表叫做中断向量表,异常表存放的是不同异常代码对应的异常处理程序(exception handler)所在的地址。
    我们的CPU在拿到异常码后会先把当前程序执行现场保存到栈中,然后根据异常码查询,找到对应的异常处理程序,最后把后续指令执行的指挥权交给异常处理程序
    ![](http://pic.aipp.vip/20210517161224.png)
    检查异常-拿到异常码-保存现场-根据异常码查表处理
    ![](http://pic.aipp.vip/20210517161254.png)
异常分类: 中断、陷阱、故障和中止
    异常分类
        中断(interrupt): 程序执行到一半被打断,这个打断执行的信号来自CPU外部的I/O设备。你在键盘上按一个按键,就会对应触发一个相应的信号到达CPU,CPU里某个开关值发生变化,也就触发了一个中断类型的异常
        陷阱(trap): 程序员主动触发的异常,常见的陷阱是我们程序调用系统调用时,也就是从用户态切换至内核态的时候。
        故障(fault): 故障不是我们刻意触发的,比如加法计算发生溢出就是故障类型的异常。故障的特点是异常程序处理完后,仍然回来处理当前指令,而不是去执行程序的下一条指令(因为当前指令没有成功执行)
        中止(abort): CPU遇到故障但是无法恢复时,程序就不得不中止了
        ![](http://pic.aipp.vip/20210517162010.png)

异常的处理:上下文切换
    异常的处理流程: 保存现场-异常代码查询-异常处理程序调用
    切换到异常处理程序,相比函数调用要更复杂,原因有下面几点
    1.异常往往发生在程序运行预期外,所以除了程序压栈要做的事情外,我们还要把CPU内当前运行程序用到的所有寄存器,都放到栈中,最典型的就是条形码寄存器里的内容
    2.像陷阱这样的异常,涉及程序指令在用户态和内核态之间的切换。对应压栈的时候对应的数据是压到内核栈里,而不是程序栈里。
    3.像故障这种异常,在异常处理程序执行完后,从栈返回出来继续执行的是故障发生时的指令,而不是下一条指令

    对于异常这样的处理流程,不像是顺序执行指令间函数调用关系,而是像两个不同独立进程之间在CPU层面的切换,所以这个过程我们称之为上下文切换
    > linux内核中有软中断和硬中断的说法。比如网卡收包时,硬中断对应的概念是中断,即网卡利用信号“告知”CPU有包到来,CPU执行中断向量对应的处理程序,即收到的包拷贝到计算机的内存,然后“通知”软中断有任务需要处理,中断处理程序返回;软中断是一个内核级别的进程(线程),没有对应到本次课程的概念,用于处理硬中断余下的工作,比如网卡收的包需要向上送给协议栈处理。

CISC和RISC: 为什么手机芯片都是ARM

两者差别
![](http://pic.aipp.vip/20210517170855.png)
RISC-V: arm是闭源的,于是有了"CPU届的linux" RISC-V项目

GPU: 为什么玩游戏需要使用GPU?

三维图形渲染过程: 顶点处理-图元处理-栅格化-片段处理-像素操作,整条下来称之为"图形流水线"或"渲染管线"
![](http://pic.aipp.vip/20210517171714.png)

FPGA和ASIC: 计算机体系结构的黄金时代

FPGA是一个可通过编程来控制硬件电路的芯片。FPGA尝尝被我们用来进行芯片的设计和验证工作,也可以直接拿来当成专用的新品啊,替换掉CPU或GPU以节省成本
ASIC是针对特定使用场景设计出来的芯片,比如摄像头、音频、挖矿或深度学习,成功的产品有谷歌的TPU

解读TPU: 设计和拆解一块ASIC芯片

理解虚拟机: 你在云上拿到的计算机是什么样的

JIT: 把本来解释执行的指令,编译成主机可以直接运行的指令

存储与IO系统

存储器层次结构全景: 数据存储的大金字塔长什么样?

![](http://pic.aipp.vip/20210517193834.png)

局部性原理: 数据库性能跟不上,加个缓存就好了?

局部性原理
    时间局部性: 如果一个数据被访问,那么它在短时间内还会被再次访问
    空间局部性: 如果一个数据被访问,那么和它相邻的数据也很快被访问

高速缓存:

我们为什么需要高速缓存

理解内存

简单页表
    页号 偏移量
多级页表

理解内存(下): 解析TLB和内存保护

多级页表虽然节约了空间,却要花费更多事件去多次访问内存。于是我们再实际进行地址转换的MMU旁放上了TLB这个用于地址转换的缓存。
内存保护
    可执行空间保护
    地址空间布局随机化

总线:计算机内部的高速公路

降低复杂度: 总线的设计思路来源
    ![](http://pic.aipp.vip/20210519203000.png)
    这就是总线

理解总线: 三种线路和多总线架构
    CPU内部和告诉缓存通信的本地总线以及和外部I/O设备以及内存通信的前端总线

相关资料