前言
已经学习了pwn一段时间 自从开始接触堆以后 就越发感觉计组原理的重要性 终有一日也是要自己读源码 自己挖洞的 所以从现在开始扎实基础知识
并且同时在刷c语言题的时候 遇到了Segmentation Fault的报错 打算弄懂 没想到这一下子就是扯出一整块知识
本篇的篇幅将会十分长 将从0开始写起 梳理和补缺知识点 并且由于是构建一个逻辑性的知识梳理 所以不会像wiki一样详细 可能大部分是一个体系的指导或者记录一些难懂的知识点
并且这种基础知识 不同于呆板的文科学习 如果只是背诵八股文 那么依然达不到用处
希望读者阅读完整篇后 能够形成自己的理解
并且 本文一部分是参照着<<小林图解计算机>>来写的 建议还是去看他的 能够梳理更清楚的框架
Linux内核1
你可以把linux内核理解为一个中介 其负责硬件和软件的通信
对应用程序来说 他有什么请求的时候就会将其通过内核传递给硬件 而内核又充当一个底层驱动程序 对设备和组件进行寻址 并且内核是应用程序逻辑上的最底层
同时 内核也相当于一个库 系统调用即是通过这个库实现 应用程序进行系统调用就像调用普通函数一样
如图所示 为GNU/linux的基本体系结构 蓝色部分是用户空间 下方是内核空间
内核空间的最上方是系统调用接口 下面依次是通用的内核代码和特殊的内核代码(略)
内核也可以拆分成两部分 微内核和宏内核
微内核又可以称为中央内核 内核的一些基本操作是基于其实现的 其他的功能是交给一些独立进程 这些进程通过特定的通信接口和内核通信
宏内核 你可以将其理解为一个整体 一个文件 其包含了内核的所有代码和五个子系统 在宏内核中 任意一个函数都可以访问到内核的任意地方
五个子系统分别是 进程调度 内存管理 虚拟文件系统 网络接口和进程间通信
其中比较重要的是进程调度和内存管理两个系统 这里拿出来说说 其他的可自行了解
进程调度:linux系统是一个动态的系统 怎么理解这句话 其通过不断变化的各种进程来适应不断变化的计算需求
也就是说 linux中的所有操作 你都可以将其视为一个进程
比如说 我接下来编译如下的二进制文件
|
按理来说应该会直接进行了系统调用 随后我们直接cat flag就能获取flag了是吧 但是运行之后发现
没有回显 这是因为关闭了文件描述符1和2 其对应着标准输出和标准错误 文件描述符相当于一个索引值 其代表的是一个进程 也就是说 在当前终端 输出(即与屏幕的通信接口)被关闭了 这个进程被中断了 那么这里可以用重定向(不解释)
回归正文 linux中的所有进程都源于一个父进程 即fork函数 先调用了这个函数 然后才有了其他的子进程
并且 每一个子进程都有其自己的PID(可以理解为名字) 比如我们可以用 ps -e来查看当前所有的进程
(进程数量过多 这里仅仅这是截取了开头的小部分)
不过这里的PID在进程结束以后 是可以被其他的进程获取的 即其是重复使用的
可见进程对于linux系统的重要性 所以 内核中的其他四个子系统都是依赖于进程调度的 通过其来挂起或者恢复进程
当一个进程需要用到其不能得到的资源时 他会调起其他的进程 而自己会进入睡眠状态 分为两种:可被打断的睡眠和不可被打断 区别在于字面意思 收到信号以后 前者就会恢复进程
当然了 进程的信息肯定不单单只有PID 其还包括进程的优先级、地址的空间等信息 这些内容都会存储在其(单个进程)的一个独立的数据结构中 这个结构称为进程控制块 进程管理也就是管理这些块
内存管理: 提起内存 那么牵扯到的东西就多了 小到cpu的缓存 大到虚拟内存 需要极大的篇幅来叙述 所以我们接下来将偏移正文很长时间 来从0朔源起内存这一个概念
内存
最早的图灵机也有内存的概念 如果其要计算1+1 那么就会将1 + 1这三个放上带子 随后由读写头读入到控制器中
这一点和现如今使用的I/O总线类似 其分为地址总线 数据总线 控制总线
数据总线将读写到的数据传输给控制总线(I/O总线不在这里展开)
这一做法主要是因为数据的读取速度远远慢于计算速度 并且这样可以减少读取数据的次数
做个直观的比喻 你掉落了100根牙签在地上,没有缓冲区的情况是
你弯腰捡完一根牙签就起身把他放回桌子上,接下来继续重复流程
而有了缓冲区以后,你弯腰捡完牙签,你会先把他放在手上,等手中的牙签数量足够多的时候,你才会起身放回桌面
cpu中的缓存概念也类似于上述
cpu拥有三级缓存 简称为 L1 L2 L3
每级缓存逐渐递增缓存量 而读取速度逐渐减少
L1和L2是每一个cpu核心所特有的 而L3通常是几个核心共用
这三级缓存采用的SRAM(静态存储器) 其特点是价格高昂 并且断电后数据就会消失 但是因为其优秀的读取速度 被广泛运用在cpu缓存中
这里额外提一嘴扩展 我们经常会听到cpu超频 这个说法还得从cpu如何执行指令说起
在很多非科班的编程语言培训和你第一节计算机导论课上都会提到 低级语言和高级语言 你只知道一个概念 就是计算机读不懂你写的程序 比如c语言
他需要经过编译 将其转化成计算器能读懂的机器码
这其中的过程可不是能简单用编译两个字来概括的
就比如用gcc来编译一个c语言程序举例
我们编写了一个输出hello world的程序 接着编译这个程序 他会经历四个阶段 : 预处理阶段 编译阶段 汇编阶段 链接阶段
预处理阶段: 这个阶段主要是将我们程序调用的头文件插入到程序文本中 接着由a.c得到a.i 文件扩展名发生了变化
编译阶段: 生成了一个汇编语言程序 文件扩展名变成了s 并且每条汇编语句还对应着一条低级机器语言
汇编阶段: 将文件翻译成机器语言 打包在一个可重定位的二进制文件中 后缀名变为o (这里的可重定位你可以理解为是为了接下来的链接)
链接阶段: 将文件进行静态链接或者动态链接 区别在于是否通过libc库调用函数(如果pwn学到了ret2libc 应该对这块的过程很了解 这里不过多赘述)
接着 这些机器指令被传给了计算机 他需要经历三个阶段 才能从接收到实现 : Fetch(取指)Decode(译码)Execute(执行指令)
具体的功能不过多赘述 不过从字面意思应该也能理解各步骤的作用
cpu实现这些步骤也是需要时间的 所花费的时间以时间周期为基本单位来衡量 而cpu的主频就是cpu运算速度的单位,cpu的运算速度单位有MHz、GHz,其中GHz=1024MHz
但是并不意味着主频越大 cpu的性能越好 这还要参考指令集 发热 等等因素
接着我们说回到内存 基础的我就懒得写了 毕竟好理解 网上资料也多 实在不行我的个人博客里也有提到内存地址之类的概念
为什么会有虚拟内存这个概念?当我们电脑的物理内存不够时 虚拟内存机制可以支持计算机在硬盘中划出一块空间来充当内存使用
并且多个程序可以共有同一块内存区 大大提高了计算机的运算性能和效率
虚拟内存的实现有三种方式 分页 分段 段页式
分页:
以下所叙述的 前提是没有开启虚拟存储机制(不赘述 自行了解)
接下来我们清楚两个主角 物理内存和虚拟内存
所谓的分页是将这二者划分成大小相同的”块” 前者称为页框 后者为页面
而虚拟内存是远远大于物理内存的 所以其分配到的”块”是不能都有对应的物理内存的
为什么这么说? 我们先得了解cpu的寻址是如何计算的 用32位cpu举例来说
这里引入一个概念 I/O总线
I/O总线
三种总线: 地址总线 数据总线 控制总线
这里只介绍第一种 地址总线
最底层的数据是通过高低电压来传输的
比如说想要传输5这个值 那么就需要三个电压 101 如果只有一条地址总线 那么需要传输三次
这样一位一位传输的方式,称为串行,下一个 bit 必须等待上一个 bit 传输完成才能进行传输
要想提高效率 就需要增加地址总线
但是也不能无脑增加 32位的cpu自身的位宽只有32 要想让他跨级传输64位甚至更多的地址位宽
这明显是不现实的 所以最好的情况下是cpu的位宽刚好和地址位宽一致 所以32位cpu能够寻址到的内存大小是4gb
ps: 这一块不懂的可以看一下下面这段 截取自《小林图解计算机》
- 如果地址总线只有 1 条,那每次只能表示 「0 或 1」这两种地址,所以 CPU 能操作的内存地址最大数量为 2(2^1)个(注意,不要理解成同时能操作 2 个内存地址);
- 如果地址总线有 2 条,那么能表示 00、01、10、11 这四种地址,所以 CPU 能操作的内存地址最大数量为 4(2^2)个。
说回到虚拟内存为什么比物理内存大得多
32位的情况下 物理内存最大支持4GB 但是虚拟内存是每个进程所独立拥有的 这样才能确保虚拟内存地址不冲突 而进程是不会只有一个的
所以这种情况下 势必会出现虚拟内存的页面对应不上物理内存的页框
这种问题称为缺页中断 出现这种情况时 系统会从物理内存中挑选一个使用最少的页框 将其内容存放回硬盘 这样就腾出了一个新的页面 接着重新映射到这个空闲的页
我们所提到的这些操作 是由MMU(内存管理单元)来实现的 包括映射和虚拟内存的分配
了解一下就好 扩展的话自己百度百度吧
接下里的内容就需要带脑子看了 虽然说一直都需要脑子 我们来介绍一下 虚拟内存的页面是如何索引到物理内存的
每一个虚拟地址都由两部分组成 页号和页内偏移 页号是页表的索引 页表中存储的是物理内存每页框的基地址 这个类似于libc基址 页内偏移加上页表中的基地址 就索引到了物理地址
然后页表实际上是存储在MMU中 这里的索引其实有点类似于ret2dl的知识点 虽然说这个github上有很多开源的工具可以做到脚本攻击 但是建议还是自己去阅读理解
但是这样的索引机制其实存在一个缺陷 假设32位的情况下 虚拟内存有4GB 假设一个页为4kb 那么就分成了一百万个页 就对应着一百万个页表 页表存储的是物理内存地址的基址 32位情况下需要4字节 这就需要4MB
你会觉得这样也很小 占用不到哪里去 但是你要知道计算机在运行时一般不会只有一个进程 如果你上面按照我说的查看了你虚拟机里的所有进程 你就会知道这个4MB会被乘以多少
为了解决这个问题 还有一种多项页表
还是按照上文的数据假设 对于一个进程来说 一共有4mb的一百万个页表项 如果这些页表继续进行页号的索引 即形成二级页表
二级页表仍然由页表项索引指向一级页表 分配方案为 1024个二级页表 每个页表中仍然有1024个页表项 这样可以索引到1,048,576个一级页表
但是你想 一级页表占用4kb 其进行了二级页表分页后 二级页表的大小是4MB 那这样不多了内存吗?
但是 你要知道不是每个进程都会占用4GB的虚拟内存 往往会剩余很多 我们一级页表是为了覆盖整个物理内存 才需要不管其有没有占用满 都需要一一对应上
但是二级页表不用这种担心 所以其实大部分情况下 二级页表是分配不到那么多的 所以实际上 增加二级页表机制以后
一个进程所需要的页表项空间为: 4KB+(二级页表占用率)x4MB 得到的值是小于原本的4MB的 这样子就成功节约了空间
下面为补充知识 个人认为可掌握可不掌握
上图是页表项的详细结构 其中 页框号就是页号 用来索引物理内存地址 相当重要
“在/不在”位的值是一个布尔类型 1时代表此页表项所指引的页框不在虚拟内存上 如果调用到了这个页表项 就会触发缺页中断
保护位字面意思 用来防止非法调用 上图是比较低级的页表项 好的页表项还会有三位 代表可读 可写 可执行
修改位 如果这个页表项所指引页面被修改了 那么其对应在硬盘上的值也必须被修改 如果没有 那么这个页面就可以直接被丢弃 差不多你可以理解为 你在玩游戏 你使用了一个道具 那么虚拟内存调用到这个的时候 就会把你硬盘中道具的
分段:
这一部分还是很简单的 至少对于分页来说
将虚拟内存分为了多个段落 并且每个段落的长度并不是相等的 这部分和glibc内存管理是一样的 这里就不重复说了 讲一点不一样的
和分页机制类似 分段机制下的虚拟内存地址也包括两个部分 段选择子和段内偏移量
段选择子是段表的索引 而段表中存储的值是这个段映射的物理内存地址的基地址
加上段内偏移量后 就是物理内存地址
但是不同于分页的是 段存在着很大的缺陷 就是内存碎片的问题
因为段所对应的物理内存并不会是连续的一块空间 这么说不好理解 举个例子
一共有1024mb的内存空间 浏览器占了512mb 视频播放器128mb 图片管理器占了256mb
这时候如果把视频播放器的进程终止了 按理来说应该还有128mb(原本空余剩下的)+128mb(视频播放器腾出来的)
但是我们仍然打不开一个256mb的进程
因为这里的256mb的内存并不是连续的 中间是断开的
这就出现了外部内存碎片
那么相对的 还有内部内存碎片 比如我们打开一个视频播放器 但是我们实际上使用的内存只有几十mb 但是这个进程占用的高达256mb
这就出现了内存的浪费
并且分段机制下的内存交换的效率还很低
什么是内存交换 当我们出现了外部内存碎片的情况下 解决办法就是将图片管理器的内存接到浏览器的后面
先把这一块内存写到硬盘上 再读取回来 就接在了浏览器的后面
但是由于这样的内存交换需要将整个进程和硬盘交互 但是我们前面已经讲过了 硬盘的读写速度是远远比不上内存的 所以会被效率造成严重的拖累
段页式:
字面意思 结合了分段和分页两种机制的一种虚拟内存分配方式
其将虚拟内存按照逻辑 分段后 再段中分页 然后和物理内存对应 这样的优点就是又有分段试的管理又有分页式的调用
缺点就是在调用的过程中要不断的查表 既有段表又有页表 增加了硬件成本
接下来详细介绍一下段页式内存映射的原理
虚拟内存这回由三部分构成 段号 段内页号 页内偏移
有了前面的基础 这回就很好理解了
内存先访问段表 然后访问页表 接着加上页内偏移 就得到了物理内存地址
到这里我们就已经普及完了内存的一些知识点 理解完这些你应该对内存差不多有了大概的概念 接着我们说回到linux内核的子系统之一内存管理
Linux内核2
32位计算机的情况下 linux的虚拟内存分为两个部分 一是3GB大小的用户空间 二是1GB大小的内核空间
内核空间是所有进程所共享的
Linux内核所需求的内存一般都是以字节为单位 所以对于使用分页机制来说 若直接分配一页内存 会造成内存浪费
于是有一个叫slab分配器的东西 来专门负责小内存分配
不过其依然是通过申请大内存 然后对自己申请来的内存进行细分管理
除了分配小内存外 其还有第二个作用 就是提供一个类似于堆块缓存区的东西
(给不是pwn手的简单介绍一下堆块缓存区bins 就是比如说申请了一个范围大小的chunk 将其释放以后 其不会回到原本的内存里面 会被放到大小对应的垃圾桶(bins) 如果下次调用小于或者等于(并不是都适用的 要分辨是哪种bins) 就会从bins中分配)
这样子的好处就是当内核频繁调用小内存时 可以快速分配对象 提高效率
不过总得用slab分配器的术语来描述一下:
slab维护着对象的缓存 内核中有些结构 初始化所花费的时间高于为其分配空间的时间 所以就会新增一个slab使用构造函数为其初始化
即使这个结构被释放了 他也会保持初始化状态 下次生成就不用初始化
如果你会一点汇编 你会发现 经常性的会出现地址跳转的指令 比如此时正在执行0x1000地址处的指令 现在要跳转到0x1100
你以为只是很简单的跳转? 其实不然 原理涉及到了slab的着色机制
我们前面已经说了 cpu有自己的三级缓存 在执行指令时 一级缓存是交互最快的地方 而缓存中调用地址是采取缓存行的形式
比如说此时缓存行一行是64字节 那么:
再假设此时读取的物理内存基址是0x1000 那么第0缓存行读取的地址范围就是0x1000~0x1040
但是这时候要调用0x1100地址处的指令呢 是不是就得将0x1000-0x1040物理内存地址的数据写回硬盘 然后载入0x1100-0x1140
但是这样子如果频繁调用的话 效率太低 造成大量的时间消耗 于是采取了slab着色 为0x1100带上偏移 这样子计算的时候就可以代入到第1缓存行 这样子就不用频繁交换
所以着色其实就是加上偏移 只不过不同对象的偏移不一样 颜色也不一样
ps:slab内容远远不及于此 但是我感觉深究下去也不是个头 并且现阶段没有必要了解那么深 所以点到为此
CPU
首先需要知道cpu是怎么执行程序的
CPU又称中央处理器 字面意思是计算机的核心 和人脑一样的地位 其内部包括许多零件 诸如寄存器 控制单元 逻辑运算单元等
其中寄存器负责管理运算数据的存储 以及指令地址和指令内容 分别由三种寄存器种类存储:
通用寄存器 程序计数器 指令寄存器
并且cpu拥有自己的缓存区 称为cache 分别有L1 L2 L3
其中L1缓存区分为指令缓存和数据缓存 二者大小一致
cpu对于cache的读写速度远远大于内存 而上述三个cache 随着数字的增大 缓存空间逐渐增加 读写速度逐渐降低
cpu可以拥有多个核心 而每个核心都有自己特有的L1 L2 cache,L3 cache是所有核心所共享的
cpu和计算机的交互是通过IO总线来实现的 IO总线分为 地址总线 控制总线 数据总线
当cpu读写数据的时候 先通过地址总线来确定目标的地址 再通过控制总线决定是写入还是写出 最后由数据总线传输数据
cpu是如何执行程序的呢?
如果你学过学校教的计算机组成原理 你应该知道冯诺依曼模型 cpu执行程序相当于一行一行执行代码 而执行代码又是将一行代码拆分成指令和数据
其中数据的总类也很多样 全局变量和局部变量 二者还可以延申出不同的数据类型 int double等
例如puts函数输出的字符串也是数据 其会保存在rodata段
指令实际上是一串二进制的机器码 每条指令都有各自的机器码 cpu通过解析机器码来了解指令需求
指令的机器码解析由cpu的指令集负责 而不同的cpu的指令集不同 也就对应不同的汇编语言和机器码
下面举例MIPS指令集来解析一下指令的机器码的构造
MIPS指令集一共有三种格式 每种格式的长度都为32位
前六位是操作码 用来表示该指令作用 后26位根据不同格式构造不同
R格式用于逻辑和算术运算
划分出来的六个区块大小分别为6 5 5 5 5 6位 如果操作码不够描述指令 函数码也可以用来描述
I格式用于数据传输和条件分支
J格式用于跳转地址 构成是三者中最简单的 除了操作码其余都是跳转的地址
举一个R类型的指令 比如add $0,$1,$2
目标寄存器为$0 第一个操作数是$1 第二个操作数是$2 add的操作码为0 函数码为32
所以指令编码后转化成的机器码为 000000 00001 00010 00000 00000 32
cpu解析指令后将其分为数据段和正文段 数据段用来存放变量等 正文段用来存放指令
cpu有一个专门用来存放指令地址的 称其为程序计数器 其存放的指令地址为下一个要执行的指令
cpu先根据程序计数器索引指令 随后通过控制单元操控地址总线访问对应地址 随后利用数据总线传输数据到对应寄存器
接着程序计数器自增 自增大小根据操作系统位数有关 为一个字长
随后程序根据指令类型判断 如果为计算类型的指令 交给逻辑运算单元 如果是存储类型交给控制单元
大部分cpu遵从的是四个固定步骤来执行指令 分别是取指令 指令译码 执行指令 数据回写
取指令(Fetch): 通过程序计数器读取指令地址
指令译码(Decode): 对指令进行解码
执行指令(Execution): cpu执行指令
数据回写(Store): 程序将数据写回对应寄存器或者内存
我们称上述四个步骤为指令周期
cpu执行每一个步骤的时间称为时钟周期 如果时钟频率越快 时钟周期就越短 cpu的执行速度也越快
时钟频率和cpu的主频相关 也就是我们常说的几GHZ
比如2.4GHZ 代表着一秒可以触发2.4G次的脉冲信号 每一次脉冲信号的高低电频转化就是一次时钟周期
另外 cpu为什么还存在原码 补码 反码
原码是对于计算机中二进制的一种表达方式 首位额外增加了符号位 正数是0 负数是1
比如10进制数字1用源码表示就是000000001
但是原码不能直接用于计算 比如两个十进制数字1+(-1)=0
如果整个计算过程用原码来表示的话就是
000000001 + 100000001 = 100000010
最后的结果是-2 明显计算错误 因此计算机用补码来表达负数
所谓补码就是把负数的原码全部取反再加上1
所以-1的补码就是 011111111
而反码就是原码向补码转化的过度值 也就是还没有加上1时的数值 比如-1的反码是011111110
接下来我们再来讲一下二进制是如何表示小数的
10.625这个数 在二进制中实际上是1010.101
小数点前面的是正数幂 后则是负数幂
但是你会发现也有很多数 比如0.1是不能用二进制表示完全的 这种时候就会产生偏差 如果将二进制的0.1转化回十进制
得到的值是近似于0.1的 0.0001100110…….
我们提到过的二进制数1010.101如果用规格化存储 得到是1.010101 x 2*3 其中010101称之为尾数 3为指数 用来规范小数点的位置
更多细节这里不扩展 感兴趣的可以去这个博客了解
2.7 为什么 0.1 + 0.2 不等于 0.3 ? | 小林coding (xiaolincoding.com)
接着我们来利用下面的这个程序进入cpu cache的存储内容
|
当cpu执行printf语句的时候 需要将a[0]的值读入cache 但是cpu并不能刚好读入单个数据或者是刚好适应不同调用情况的字节数据 其单次读写数据的单位是cache line 上文讲述slab着色器的时候也说到过了 这里复述的详细一点
假设此cpu的cache line是64字节 那么当其需要a[0]数据的时候 会把a[0]后面的数据也一并读入 直到满足64字节
第一块64字节的数据会被放置为cache line 0 当需要更多字节的数据的时候 就会依法炮制 放置在cache line1 如此递增
假设cache一共有8行 而内存被分为了32个块 这也就意味着必然会有两块以上的内存共用一块cache line
其遵守的是取模运算 比如说第15个内存块 实际上对应的是第7个cache line
而cpu为了避免搞混单个cache line中的不同内存块 在对应的cache line中还存储了tag标记 相当于slab着色器
除了tag标记外 cache line中还存储着两个重要信息 一个是有效位 一个是偏移量
有效位为0时 不管cache中是否有需要的内存数据 仍然会去内存中读取
偏移量的存在则是因为cpu所需的内存数据并不一定都是刚好一个cache line 就比如说上面的程序
所需的仅仅是cache line中的一个内存片段 也就需要偏移量来界定一个范围
cache line是为了避免频繁的cpu访问内存带来的读写效率降低
但是其存在着一个缺陷 也就是意味着一块cache line中可能并不全是所需要的数据 出现了缓存命中率的问题 其又分为数据缓存命中率和指令缓存命中率
先说数据缓存命中率吧 来看下面的一个程序
|
你觉得哪一个for循环执行所消耗的时间会更加短 是第一个? 那为什么呢
其调用的数据在内存地址中都是连续的 也就意味着单次的cache line写入就可以做到覆盖所有要调用的数据
而第二个for循环调用的数据是间断的 需要花费更多的时间在cpu和内存的交互中
当我们需要遍历数组这种情况时 最好选择连续的内存空间 可以有效提高程序的运行时间和数据缓存命中率
而指令缓存命中率是什么呢?
还是先看一下下面的程序
|
当数组a进行了赋值后 步入if判断分支 请问是先对数组a中的数据进行一个排序后再判断所消耗的时间快还是直接进行判断
回答这个问题之前我们首先要了解 cpu拥有一个分支预测器 如果cpu能够预测到接下来是步入if语句的哪个分支 就会提前把指令(在这里也就是a[i]=0)放入到cache中 于是执行速度就会加快
那么显然 先进行排序再步入if判断语句就可以提高判断后赋值的效率
当我们在实际编写程序的时候 如果你可以确保哪一个步入哪一个分支的概率较高 你可以使用likely和unlikely两种宏
|
当括号中的判断式为true的概率大时 就使用likely宏
与此同时 我们前面提高过 每个cpu核心都有自己的L1 L2 cache 数据会被优先放到这两个cache中存放 方便cpu调用
但是 如果是单核cpu的话 只能执行一个线程 但是系统给多个需要被执行的线程分配时间片 每个线程执行一段时间后 就执行另外一个线程 所以看起来就好像所有的线程都在同时执行一样
但是现在的cpu基本都是多核心的 这也就意味着线程可能会在多个核心中执行 这就导致了缓存命中率的问题
上述所提到的cache和内存的数据交互 只是cache单方面写入内存的数据 如果cpu要执行b = a+1的操作呢?
那么就需要将cache的数据b写回到内存 这一操作是怎么实现的 下面我们就来讲两种写入数据的办法 分别是写直达和写回
写直达:
这一种办法是最简单粗暴的 也就是连同cache和内存 一起写入数据
流程为 先判断cache中是否有需要被写入的数据 如果有 就先写入cache 再写入内存
如果没有 就直接写入内存
这种办法的逻辑简单 比较浪费cpu的执行时间 因为每次写入都需要访问内存
写回:
写回办法有效解决了前者出现的问题
当要进行写操作的时候 先判断cache是否已经有对应数据了 如果有的话 则更新数据到cache中 并且把数据标记为脏的
如果cache中是其他内存地址的数据的话 先判断这个数据是不是脏的 如果是脏的 那么先将该数据写回到内存中
再从内存中将需要用到的数据写到cache 再覆写cache中的数据 并且标记为脏的 之所以这样做可以看
如果不是脏的话 就只要将需要的数据从内存中写入到cache 再覆写cache 最后标记为脏的
这样下来 就不需要每次写操作都需要访问内存
你可能会觉得 那这样就无法及时的写回内存 但是如果我们的缓存命中率高的话 就不需要频繁的与内存交互
我们一直提到缓存命中率 这是对于同一个核心而言的 如果一个线程由两个核心执行呢
一个计算式:
a=b=0 //1 |
如果核心A负责式子2和4 核心B负责式子3
接下来注意了 两个核心读入数据到cache中时 读入的都是初始值 对于两个核心来说 a,b最开始的值都是0
核心A执行完式子2了以后 按照上文的写回操作 并不会马上将数据写回到内存中
接下来核心A执行式子4的时候 其和核心B执行的式子3 并没有关联 对于核心A来说 变量a是1 变量b是0
a最后的值是1 但是对于上帝视角的我们来说 最后的结果应该是2 这就是核心A和B的信息差导致的后果 称这种问题为缓存一致性
为了解决这种问题的出现 就需要一种机制来协调不同核心间的cache
也就是当核心A将变量a赋值为了1时 需要通知其他核心的cache 将a同样重新赋值 相当于广播 这种方式称为总线嗅探
这种行为称为写传播 但是光这样还是不足以平衡不同核心之间的信息差 当核心A对于变量a多次赋值 第一次赋值为10 第二次赋值为20
假设核心B接收到的广播顺序是先20再10 那么显然最后变量a在两个核心中的值还是不同 也就需要统一对数据的操作顺序
这种行为称为事务的串行化 当接收到广播后 只有拥有<锁>才能对这个数据的赋值进行修改
要想同时实现事务串行化和写传播 并且优化每次写传播都需要对所有核心广播的问题
诞生了MESI协议 MESI协议包括了四种状态 分别是 已修改 独占 共享 已失效
已修改: 即我们前面提到的脏标记 意味着此时该数据在cache和内存中不一样
独占/共享:二者的定义是对于cpu核心而言的 即该数据是否为单个cpu核心所独占 如果处于独占状态 那么写入数据后就不需要写传播
如果处于共享状态 先经过写传播 将其他核心内的数据修改成无效状态 再重新写入数据
已失效: 代表该数据不能被调用
MESI协议的共享机制存在着伪共享的问题
此时核心A和核心B分别负责线程A(a += 1) 线程B(b += 1)
而变量a和b相邻 位于同个cache line范围中 所以此时核心A和核心B的cache line拥有同样的变量 变成共享状态
此时 核心A开始执行线程A 发现其处于共享状态 于是通过总线将核心B的cache line修改为已失效状态 随后进程A结束后 核心A的cache line变化已修改状态
核心B开始执行线程B 发现cache line处于失效状态 于是将拥有和其一样变量的核心A的cache line写回到内存 再从内存中重新读入cache line 随后执行完线程B以后 将新cache line设置为已修改状态 而此时核心A的cache line处于已失效状态
如果核心A和B不断重复各自的线程 实际上就是不断重复上述的操作 频繁的和内存进行交互 cache line的用处微乎其微 这就是伪共享问题
想要解决这一问题 linux内核提供了__cacheline_aligned_in_smp的宏定义
对于该定义 仅使用于多核cpu 下面举例一个程序
|
此时变量a和b在内存地址上是相邻的 如果将变量b用上__cacheline_aligned_in_smp宏定义
|
此时b的地址就被设置为cache line的对齐地址
但是这种方法实际上是浪费了一部分cache line的空间 来换取效率的提升
接下来我们来讲一下进程和线程的一点知识 为了接下里要讲的软中断做铺垫
每一个进程都有自己独立的内存空间 一个进程可以拥有多个线程 在windows系统中 可以使用任务管理器来查看当前的进程
其中 一个进程可以拥有多个线程
在linux系统中 线程可以被视为一个轻量化的进程 也就是说线程也是被作为一个进程看待
而在JVM(java为了实现跨平台而创建的一个假想计算机)中的线程 拥有自己的程序计数器 虚拟机栈 本地方法栈
这里不额外扩展 感兴趣的可以自行了解
进程是操作系统资源分配的基本单位 而线程是处理器任务调度和执行的基本单位
每一个线程都有自己的独立运行栈和程序计数器 并且共享代码和数据空间
线程也不能自己独立执行 必须依附于应用程序 也就是进程中
当有一个线程崩溃掉 整个进程都会崩溃掉
相比之 进程拥有独立的代码和数据空间 进程之间的切换会消耗挺大的开支
一个进程崩溃后 在保护模式下不会对其他进程造成影响
接下来我们来讲软中断 首先要明白什么是中断
举一个例子 在日常生活中 你正在打原神 这时候你接到了你的外卖电话 由于你的学习福建师范大专偷外卖的人很多 你不马上下楼拿就会不见 所以你立刻中断了打原神的操作 下楼去拿外卖
其中 打原神就是一个进程 拿外卖又是一个进程 从打原神向拿外卖的一个执行转化 就是中断
对于计算机来说 中断是一个异步的事件处理机制 可以有效于提高系统的并发处理能力
负责相应中断请求的是中断处理程序 其在相应中断请求的时候 可能还会临时关闭中断 这是什么意思呢
还是接着上面的例子 当我们下楼拿外卖的时候 原神突然发放福利1w原石 但是需要玩家动手领取 这时候你下楼去拿外卖了 也就无法相应这个福利 你就和原石擦肩而过
本来计算机需要相应两次中断 一次拿外卖 一次领福利 但是由于中断处理程序还在执行第一次中断 无法及时相应第二次中断 就丢失了一次中断
那么软中断是什么呢? 其讲一次中断请求分为了两个部分 第一部分用于快速响应中断 直接处理硬件请求 为硬中断
第二部分用于处理第一部分还没有完成的事情 通常是时间较长的事情 为软中断
如何理解呢? 举个例子
A要和你聊事情 约你在一个地方见面 没有分为软中断和硬中断的话 A会和你一直保持通话 期间如果其他人要找你就没有办法响应
如果有软中断的话 就是和A迅速约定好见面地点和时间 然后挂断电话 等待其他人给你打电话 随后你可以面对面询问A遇到什么问题