文章目录

  • 概览
    • 理念
    • 五个基本事实
      • 数据表示与计算:int不是整数,float不是实数
      • 机器级原理:你必须懂汇编语言
      • 储存器很重要
      • 性能:不仅仅是渐进复杂度
      • 计算机系统的高级功能
    • 课程内容
  • 计算机系统漫游
    • 信息就是位+上下文
    • 程序被其他程序翻译成不同的格式
    • 处理器读取并解释储存在他内存中的指令
      • 系统的硬件组成
      • Hello World的执行过程
    • 高速缓存与储存器层次
    • 操作系统管理硬件
      • 进程
      • 线程
      • 虚拟储存器
      • 文件
    • 网络通信
    • 重要概念
      • Amdahl定律(系统性能计算)
      • 并发和并行(Concurrency and Parallelism)
        • 线程级并发
        • 指令级并行
        • SMID并行
        • 例题
      • 抽象
      • 响应时间与吞吐量
      • 执行时间探究
        • 性能与相对性能
        • 测量执行时间
        • CPU时间
        • MIPS与性能度量
  • 信息表示与处理
    • 信息储存
      • 进制表示与转换
      • 字数据大小
      • 寻址与字节顺序
      • 表示字符串
      • 表示代码
    • 比特级操作
      • 布尔代数
      • C语言中的位级运算
      • C语言中的逻辑运算
      • C语言中的移位运算
      • 经典例题——bitCount(重要)
    • 整数
      • 整数编码表示
      • 类型转换
      • 扩展和截断
        • 扩展
        • 截断
      • 各种算数的底层实现(加法,补码非,乘法,移位)
        • 溢出破坏原理举例
        • 加法
          • 无符号加法
        • 补码加法
        • 乘法
          • 无符号乘法
          • 有符号乘法
          • 代码安全示例:XDR库
        • 移位实现乘除
          • 左移实现2的整数次幂乘
          • 右移实现除,以及修正右移
          • 通过移位实现常量乘除
        • 补码非:求补与递增
    • 浮点数
      • 二进制小数
      • IEEE 754计算机浮点标准
        • 浮点表达
        • 精度
        • 规格数
        • 从极小规格数到非规格数再到0(特殊非规格数)
        • 特殊值总结
        • 范围可视化与值的表达
        • 示例
      • 舍入,加法和乘法
        • 舍入
        • 加法
        • 浮点乘法
      • C语言浮点数
  • 程序的机器级表示
    • 基础
      • Intel处理器和架构演变
        • 指令集
        • x86演进
      • 汇编基础(重点,需要自己看书)
        • 汇编视图
        • 汇编基础操作
      • 数据传送
        • 基础写法
        • 通用写法
        • 定长与扩展
        • 坑点
      • 算数和逻辑运算
        • 地址计算leaq
        • 算术指令
        • 特殊的运算
      • C、汇编、机器代码
        • 正向工程
        • 逆向工程
    • 控制
      • 条件码:控制基础
        • 条件码含义
        • 条件码修改
        • 条件码读取
      • 条件分支
        • 跳转
        • jmpX条件跳转
        • cmovX条件传送
      • 循环
        • do while
        • while
        • for
      • switch语句
        • 跳转表
        • 一些case的分析
        • 从二进制代码中找出跳转表
    • 过程
      • 栈结构
      • 调用规则
        • 传递控制
        • 传递数据
        • 管理局部数据
          • 寄存器局部变量
          • 栈上的局部变量
      • 递归过程
    • 数据
      • 数组
        • 基本概念
        • 取地址与取值
        • 定长数组
        • 变长数组
      • 异质的数据结构
        • 结构
        • 联合
      • 数据对齐
      • 浮点数
    • 实践
  • 处理器体系结构
    • ISA(Instruction Set Architecture)
      • Y86简化架构
        • Y86指令编码
        • Y86具体指令
        • Y86程序示例——数组寻址
        • 反汇编与指令集
      • RISC与CISC
    • 逻辑硬件设计
      • 组合逻辑
      • 时序逻辑
        • 稳态原理
        • 锁存器、触发器
        • 寄存器与RAM(寄存器文件)
        • ALU累加逻辑
      • HDL
    • 顺序处理
      • 分阶段解析
        • 取指
        • 译码
        • 执行
        • 内存
        • 回写
      • 顺序操作(SEQ)
    • 流水线(pipline)
      • 流水线原理
        • 吞吐量计算
      • 限制
        • 非统一时延
        • 寄存器开销
        • 反馈与数据冒险
        • 数据预测
      • SEQ+与PIPE-硬件架构
      • PC预测
      • 流水线冒险
        • 冒险的分类与原因
        • 暂停:延后读取
        • 转发:提前传输
        • 加载/使用数据冒险:解决内存到寄存器的冲突
        • 避免控制冒险
      • 异常处理
      • PIPE 各阶段的实现
        • 取指
        • 译码和写回
        • 执行阶段
      • 流水线控制逻辑
        • 特殊情况的处理
        • 如何发现特殊情况
        • 暂停与气泡原理
        • 控制条件的组合
        • 控制逻辑的实现
      • 性能分析

概览

理念

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

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

这门课看起来和应用没有关系,但是应用是运行在系统上的,其不可避免地会受到系统的影响,比如出明明你的程序从逻辑上来说一点问题都没有,但是就是会出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.67S=1.67S=1.67。很明显,S永远比k小。

事实上,k对S的改进是有上限的,假设k是正无穷,S也只会提升到原来的11−α\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×2GHz2\times 2GHz2×2GHz

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

时钟周期数=指令数×CPI

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

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


A的周期小,但是单指令消耗周期多,B的周期大,但是单指令消耗周期小。

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

进一步了解CPI。

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

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

最后进行总结:

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

MIPS与性能度量

Millions of Instructions Per Second。

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

MIPS=ClockRatePCI×106MIPS=\dfrac{Clock Rate}{PCI\times 10^6}MIPS=PCI×106ClockRate​

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

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

信息表示与处理

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

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

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

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

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

信息储存

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

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

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

进制表示与转换

二进制,十进制,讲过。

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

字数据大小

每台计算机都有一个字长,表明指针数据的标准大小。比如现在的64位系统,指针就是8字节的,32位位对应4字节。同时,这个位也是寻址的位宽,比如32位对应232=4G2^{32}=4G232=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   b0101int 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,还是变了。

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


为了实现上述的效果,计算机采用mod2kmod \ 2^kmod 2k来保留后k位。
无符号数直接取mod,有符号数是先转换成无符号数(比特位没变,只是解释变了)再mod运算。

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

溢出破坏原理举例

加法

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

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

无符号加法

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

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

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

补码加法

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

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

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

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


乘法

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

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

无符号乘法

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

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

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

移位实现乘除

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

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

左移实现2的整数次幂乘

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

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

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

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

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

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

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


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

通过移位实现常量乘除

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

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

无符号数,是逻辑右移。

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

补码非:求补与递增

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

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

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

浮点数

二进制小数

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

但是这种方法,表示的空间是有限的,离散的。仅仅能表示x/2kx/2^kx/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时钟周期GIPS\dfrac{1}{时钟周期}=\dfrac{1000}{时钟周期} GIPS时钟周期1​=时钟周期1000​GIPS

这个比较抽象,如何理解呢?
其实,吞吐量=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,或者说你为了执行CiC_iCi​条指令,插入了多少个bubble,插入越多,你的效率就越低。

CPI=Ci+CbCi=1+CbCiCPI=\dfrac{C_i+C_b}{C_i}=1+\dfrac{C_b}{C_i}CPI=Ci​Ci​+Cb​​=1+Ci​Cb​​

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

CPI=1+lp+mp+rpCPI=1+lp+mp+rpCPI=1+lp+mp+rp

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

最后给出一个计算例子:

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


计算机系统导论(持续更新)相关推荐

  1. 计算机系统导论第九章,计算机系统导论 -- 读书笔记 -- 第三章 程序的机器级表示 (持续更新)...

    计算机系统导论 -- 读书笔记 -- 第三章 程序的机器级表示 (持续更新) 第三章 程序的机器级表示 3.1 历史观点 3.2 程序编码 1. 命令行 (1)编译 Linux> gcc -Og ...

  2. 2013计算机系统导论,【精选】2013计算机系统导论-期末考卷-发布.pdf

    [精选]2013计算机系统导论-期末考卷-发布 北京大学信息科学技术学院考试试卷 考试科目: 计算机系统导论 姓名: 学号: 考试时间: 2014 年 1 月 7 日任课教师: 题号 一 二 三 四 ...

  3. 微型计算机 持续更新,2020年南京邮电大学810《微机原理及应用》硕士研究生入学考试大纲...

    国各省市院校2020年硕士研究生考试大纲汇总(持续更新中)>>> 2020年国硕士研究生入学考试命题标准大纲已于7月8日正式公布,接下来国各研招院校将陆续发布2020考研专业课大纲. ...

  4. 本专栏所有力扣题目的目录链接, 刷算法题目的顺序(由易到难/面试频率)/注意点/技巧, 以及思维导图源文件问题(持续更新中)

    这篇文章为本专栏所有力扣题目提供目录链接, 更加方便读者根据题型或面试频率进行阅读, 此外也会介绍我在刷题过程中总结的刷算法题目的顺序/注意点/技巧, 最后说下文中出现的思维导图源文件的问题 和 打卡 ...

  5. 我的读书清单(持续更新)

    我的读书清单(持续更新) 2017-05-31 <一千零一夜>2006(四五年级) <中华上下五千年>2008(初一) <鲁滨孙漂流记>2008(初二) <钢 ...

  6. ArcGIS10从入门到精通系列实验图文教程(附配套实验数据持续更新)

    文章目录 1. 专栏简介 2. 专栏地址 3. 专栏目录 1. 专栏简介 本教程<ArcGIS从入门到精通系列实验教程>内容包括:ArcGIS平台简介.ArcGIS应用基础.空间数据的采集 ...

  7. 操作系统面试题(史上最全、持续更新)

    尼恩面试宝典专题40:操作系统面试题(史上最全.持续更新) 本文版本说明:V28 <尼恩面试宝典>升级规划为: 后续基本上,每一个月,都会发布一次,最新版本,可以联系构师尼恩获取, 发送 ...

  8. 明翰计算机基础知识V0.4(持续更新)

    明翰计算机基础知识V0.4(持续更新) 文章目录 @[toc] 前言 计算机硬件 `中央处理器(CPU)` CPU功能 CPU构成 `CPU缓存` L1 Cache(一级缓存) L2 Cache(二级 ...

  9. 动态规划题目汇总(持续更新)

    动态规划题目汇总(持续更新) 目录 动态规划题目汇总(持续更新) 1. 钢条切割 问题描述 解法 2. 矩阵链乘法 问题描述 解法 3. 最长公共子序列 问题描述 解法 4. 无重复字符的最长子串 问 ...

  10. 持续更新:免费的IT学习资源分享

    itshare 持续更新,免费的IT计算机学习资源分享,包括IT书籍,社区,课程,解决方案,牛人等等 也欢迎各位兄弟反馈和提供资源,我们就是要Share! github(一手资源):https://g ...

最新文章

  1. 【Python】百度首页GIF动画的爬虫
  2. 网络编程学习笔记(辅助数据)
  3. boost::hana::type_c用法的测试程序
  4. Win8装SQL2008需要离线安装 .Net3.5
  5. mysql基础命令大全
  6. 解决Nginx与mysql勾结的错误
  7. html:(28):后代选择器和通用选择器
  8. 6年20多篇重磅论文,27岁浙大女博导太飒了~
  9. HTML字体小于12谷歌不兼容,Chrome谷歌浏览器下不支持css字体小于12px的解决办法...
  10. 索引法则--字符串不加单引号会导致索引失效
  11. html 无组件上传图片,无组件上传图片到数据库中,最完整解决方案
  12. jQuery实现一个简单的选项卡效果
  13. 利用计算机教学的体会,教师计算机教学学习体会
  14. H5互动游戏营销方案策划
  15. 城通网盘仿蓝奏网盘源码 附带视频教程
  16. 安全系统工程徐志胜电子版_安全系统工程-第3版
  17. JPEG转换成TIFF
  18. 自己动手搭建一个简单的基于Hadoop的离线分析系统之一——网络爬虫
  19. unsw计算机科学的挂科率,恐怖挂科率创新高!UNSW期中惊现大面积挂科,商科一课程Fail率接近60%,朋友圈一篇哀嚎!...
  20. Illustrator 教程:如何在 Illustrator 中创建无缝平铺图案?

热门文章

  1. 你要如何衡量你的人生
  2. 2022年智慧城市行业概括及现状
  3. 简单的C语言代码实现快速排序
  4. nmap扫描主机存活情况
  5. 米思齐——简易呼吸灯
  6. Excel如何从身份证号码中提取性别
  7. 中仪股份管道机器人_中仪股份 X5-HT 管道CCTV检测机器人
  8. 实施整体变更控制-管理过程
  9. Qt Creator 使用教程
  10. Python | 范德蒙矩阵