操作系统学习笔记(1)—— 架构与硬件

Posted by 皮皮潘 on 04-05,2022

操作系统概述

操作系统主要由两个职责:对硬件进行管理和抽象,为应用提供服务并进行管理,即使一个应用希望并且也实现了直接操纵硬件也需要操作系统的支持,因为操作系统除了硬件还提供了应用的管理能力(加载,启动,调度,销毁等)

广义的操作系统可以进一步分为操作系统内核和操作系统框架两层:内核负责对硬件资源进行抽象与管理、为框架提供基础的系统服务;框架则基于内核提供的服务进一步为不同的应用领域提供编程接口与运行环境

操作系统接口分为三种:1. 系统调用接口:系统内核提供的接口,用户态应用需要通过system call然后trap进入内核态才可以调用 2. POSIX:对于不同操作系统的系统调用进行了标准化封装,从而保证同一个应用程序在不同操作系统上的可移植性,包括:glibc,应用程序只需要调用libc提供的接口就可以实现对操作系统功能的调用(libc处理好了底层的汇编与system call)3. 领域应用接口:在POSIX或系统调用基础上进一步封装的面向不同领域的领域应用接口(由系统框架提供)

ISA(Instruction Set Architecture,指令集架构)主要包括3个重要的组成部分:1. ISC(Instruction Set Computer,指令集计算机),又细分为两种,复杂的CISC(x86)和精简的RISC(ARM) 2. 特权级:用户态(应用程序)运行在EL0,内核态(操作系统)运行在EL1,CPU提供了三种特权级之间切换的方式:系统调用、指令触发异常、外设的中断,前两者为同步切换,后一种为异步切换,不管是哪种切换,CPU都会在切换时保存好应用程序的执行状态(PC,SP,异常原因等)而操作系统则会在异常处理函数执行时保存应用程序的上下文(通用寄存器)3. 寄存器以及每个寄存器的作用

操作系统架构

在简要架构(DOS)中,应用程序与操作系统都放置在同一个地址空间中,并且彼此都处在内核态中,这虽然带来了应用程序性能的提升,但是由于没有用户态和内核态的分离,因此很容易用于种用户应用程序的bug而整体崩溃

在宏内核架构中,所有必要的操作系统功能都在内核态实现,具备直接操作硬件的能力,从而降低了这些必要操作系统功能间交互的成本(无需从用户态陷入内核态),但是可扩展性,可靠性和安全性不足

在微内核架构中,许多操作系统功能,如:文件系统,存储设备驱动等都运行在用户态,拥有特权的内核态仅仅负责最基本的功能:1. 调度管理 2. 内存管理 3. 进程通信 4. 权限管理。应用程序和文件系统等通过进程间通信进行交互,通过将大部分系统功能放在用户态使得它们的崩溃不会影响到操作系统最核心的功能,但是这也导致了多次的用户态和内核态的转换

在外核架构中,内核仅仅提供了硬件资源的访问和抽象能力,而真正的使用通过专门为程序应用的定制化的libOS实现,不同的应用所使用的libOS不同,这对于特定领域的性能优化带来了极大的提升,但是也带来了极高的门槛以及libOS之间代码的冗余

一个简单的操作系统架构如下图所示:
1769_1.png
可以发现,这个操作系统内核没有任何设备驱动程序,甚至没有文件系统和网络组件,内核所实现的功能很少。这吸取了微内核的优势,内核小出问题的可能性就少,扩展性就越强。

同时把文件系统、网络组件、其它功能组件作为虚拟设备交由设备管理,比如需要文件系统时就写一个文件系统虚拟设备的驱动,完成文件系统的功能,需要网络时就开发一个网络虚拟设备的驱动,完成网络功能。这些驱动一旦被装载,就是内核的一部分了,并不是像微内核一样作为服务进程运行,这又吸取了宏内核的优势,代码高度耦合,性能强劲。

CPU工作模式

CPU 的工作模式共有实模式、保护模式、长模式三种模式,现介绍如下:

实模式

实模式又称实地址模式,实,即真实,这个真实分为两个方面,一个方面是运行真实的指令,对指令的动作不作区分,直接执行指令的真实功能,另一方面是发往内存的地址是真实的,对任何地址不加限制地发往内存,另外在实模式下的内存访问采用了分段机制:分段机制通过段基址+地址相对位移的方式进行内存地址的访问,从而解决16位寄存器无法访址1MB内存地址的问题

保护模式

保护模式相比于实模式,增加了一些控制寄存器并扩展通用寄存器的位宽,通过给CR0置位可以开启保护模式,保护模式相较于实模式而言,CPU会基于硬件逻辑数字电路通过内存中在OS启动时定义的全局段描述符表(GDT)对于运行的指令以及访问的内存进行权限判断,其中权限判断是设置了从R0到R3的特权级,R0 可以执行所有指令,R1、R2、R3 依次递减,它们只能执行上一级指令数量的子集,并在段描述符中加入了DPL字段代表段的权限级别,另外在中断描述符中也加入了DPL以及相应的权限检查

长模式

长模式相较于保护模式则将寄存器扩充到了64位,同时具有了能处理 64 位数据和寻址 64 位的内存地址空间的能力,另外长模式弱化段模式管理,只保留了权限级别的检查,忽略了段基址和段长度,而地址的检查则交给了 MMU 其中页表项的权限位,因此在使用长模式的的同时需要给CR3置位开启页表模式。

总结

  1. 实模式:早期 CPU 是为了支持单道程序运行而实现的,单道程序能掌控计算机所有的资源,早期的软件规模不大,内存资源也很少,所以实模式极其简单,仅支持 16 位地址空间,分段的内存模型,对指令不加限制地运行,对内存没有保护隔离作用。
  2. 保护模式:随着多道程序的出现,就需要操作系统了。内存需求量不断增加,所以 CPU 实现了保护模式以支持这些需求。保护模式包含特权级,对指令及其访问的资源进行控制,对内存段与段之间的访问进行严格检查,没有权限的绝不放行,对中断的响应也要进行严格的权限检查,扩展了 CPU 寄存器位宽,使之能够寻址 32 位的内存地址空间和处理 32 位的数据,从而 CPU 的性能大大提高。
  3. 长模式:又名 AMD64 模式,最早由 AMD 公司制定。由于软件对 CPU 性能需求永无止境,所以长模式在保护模式的基础上,把寄存器扩展到 64 位同时增加了一些寄存器,使 CPU 具有了能处理 64 位数据和寻址 64 位的内存地址空间的能力。

内存

为了满足多道程序同时运行时满足内存的隔离和保护需求,计算机科学家提出了虚拟地址的概念,但是虚拟地址必须转换成物理地址,这样程序才能正常执行。要转换就必须要转换机构,它相当于一个函数:p=f(v),输入虚拟地址 v,输出物理地址 p。那么要怎么实现这个函数呢?用软件方式实现太低效,用硬件实现没有灵活性,最终就用了软硬件结合的方式实现,它就是 硬件MMU(内存管理单元)模块 + 软件页表。MMU 可以接受软件给出的地址对应关系数据,进行地址转换。

只有在保护模式和长模式下才可以开启分页模型,但是由于保护模式的内存模型是分段模型,它并不适合于 MMU 的分页模型,所以我们要使用保护模式的平坦模式(段基址就是0,段长度就是4G),这样就绕过了分段模型。这个平坦模型和长模式下忽略段基址和段长度是异曲同工的。地址产生的过程如下所示:
1771_1.png

保护模式的分页模型

保护模式的分页模型有4KB和4MB两种大小。

在4KB分页模式下,32 位虚拟地址被分为三个位段:页目录索引、页表索引、页内偏移:第一级页目录,其中包含 1024 个条目 ,每个条目指向一个页表,每个页表中有 1024 个条目。其中一个条目就指向一个物理页,每个物理页 4KB。这正好是 4GB 地址空间。如下图所示:
1773_1.png
这里需要注意的是,每一个虚拟地址中的划分的每一段记录的都是一个索引,而CR3指向的是页目录的物理地址,再次强调:正是通过CR3才能找到实际的页目录,虚拟地址中的分段仅仅是页目录或者页表中的索引或者偏移量,而页表和页目录都是一块实际的、4KB大小的物理页。在实际运行过程中MMU会先根据CR3把页目录(4KB)拿出来,然后根据页目录索引找到对应的页表的物理地址,再把对应的页表(4KB)拿出来,然后根据页表索引找到对应的物理页的物理地址,再把对应的物理页拿出来,最后根据页内偏移找到那1Byte的数据。

CR3、页目录项和页表项的格式分别如下:
1775_1.png
1777_1.png
1779_1.png
可以看到,CR3、页目录项、页表项都是 4 字节 32 位,1024 个项正好是 4KB(一个页),因此它们的地址始终是 4KB 对齐的。由于每个页面的大小是4KB,因此对于32位的一行而言只需要花20位描述页面的物理基地址就行了(对于页目录和页表页内偏移无关紧要),另外低 12 位另作它用,形成了页面的相关属性,如是否存在(触发缺页异常)、是否可读可写、是用户页还是内核页、是否已写入、是否已访问等

4MB分页模式其实和4KB分页模式没有太大区别,CR3 还是 32 位的寄存器,只不过不再指向顶级页目录了,而是指向一个 4KB 大小的页表,另外页表项还是 4 字节 32 位,但只需要用高 10 位来保存物理页面的基地址就可以

长模式的分页模型

如果开启了长模式,则必须同时开启分页模式,因为长模式弱化了分段模型,而分段模型也确实有很多不足,不适应现在操作系统和应用软件的发展。同时,长模式也扩展了 CPU 的位宽,使得 CPU 能使用 64 位的超大内存地址空间。所以,长模式下的虚拟地址必须等于线性地址且为 64 位。长模式下的分页大小通常也有两种,4KB 大小的页和 2MB 大小的页

在4KB分页模式下,64 位虚拟地址被分为 6 个位段,分别是:保留位段,顶级页目录索引、页目录指针索引、页目录索引、页表索引、页内偏移,顶级页目录、页目录指针、页目录、页表各占有 4KB 大小,其中各有 512 个条目,每个条目 8 字节 64 位大小,如下图所示:
1781_1.png

其中 CR3 变成了 64 位的 CPU 的寄存器,它指向一个顶级页目录,里面的顶级页目项指向页目录指针,依次类推,具体细节和保护模式下的分页模型没太大区别只是多了几个中间目录页表罢了,另外每个条目也从32位变成了64位,这里就不再赘述了,需要注意的是虚拟地址48到63这16位是保留位,因为x86 CPU 并没有实现全 64 位的地址总线,而是只实现了 48 位,但是 CPU 的寄存器却是 64 位的,因此实际寻址只有48位,但寻址空间也有256T大大超过现在内存的大小了

MMU地址转化失败

在页表项中的数据为空,用户程序访问了超级管理者的页面,向只读页面中写入数据时都会导致 MMU 地址转换失败,MMU 地址转换失败了之后既不会放行,也不会Reset,而是通过MMU的逻辑电路执行如下操作:
1.MMU 停止转换地址。
2.MMU 把转换失败的虚拟地址写入 CPU 的 CR2 寄存器。
3.MMU 触发 CPU 的 14 号中断,使 CPU 停止执行当前指令。
4.CPU 开始执行 14 号中断的处理代码,代码会检查原因,处理好页表数据返回。5.CPU 中断返回继续执行 MMU 地址转换失败时的指令。
具体的细节会在后面的内存管理博文中给出

开启MMU

  1. 使 CPU 进入保护模式或者长模式。
  2. 准备好页表数据,这包含顶级页目录,中间层页目录,页表,假定我们已经编写了代码,在物理内存中生成了这些数据。
  3. 把顶级页目录的物理内存地址赋值给 CR3 寄存器。
mov eax, PAGE_TLB_BADR ;页表物理地址
mov cr3, eax
  1. 设置 CPU 的 CR0 的 PE 位为 1,这样就开启了 MMU:
mov eax, cr0
bts eax, 0 ;CR0.PE =1
bts eax, 31 ;CR0.P = 1
mov cr0, eax

Cache

为了缓解内存和CPU之间的速度差距以及大名鼎鼎的局部性原理,计算机硬件引入了Cache作为CPU和内存之间的中间层

Cache工作原理

Cache 主要由高速的静态储存器、地址转换模块和 Cache 行替换模块组成,Cache大致的逻辑工作流程如下:

  1. CPU 发出的地址由 Cache 的地址转换模块分成 3 段:组号,行号,行内偏移。
  2. Cache 会根据组号、行号查找高速静态储存器中对应的行。如果找到即命中,用行内偏移读取并返回数据给 CPU,否则就分配一个新行并访问内存,把内存中对应的数据加载到 Cache 行并返回给 CPU。写入操作则比较直接,分为回写和直通写,回写是写入对应的 Cache 行就结束了,直通写则是在写入 Cache 行的同时写入内存。
  3. 如果没有新行了,就要进入行替换逻辑,即找出一个 Cache 行写回内存,腾出空间,替换行有相关的算法,替换算法是为了让替换的代价最小化。例如,找出一个没有修改的 Cache 行,这样就不用把它其中的数据回写到内存中了,还有找出存在时间最久远的那个 Cache 行,因为它大概率不会再访问了。

以上这些逻辑都由 Cache 硬件独立实现,软件不用做任何工作,对软件是透明的。

其中为了快速定位物理地址在缓存中的位置,物理地址往往被划分为3个部分

|- - tag - -| |- - set - - | | - - offset - - |

其中set所占的位数根据缓存有多少组决定,比如:256组则对应长度为8个bit,offset所占的位数则由缓存每行存储多少Byte决定,比如:64Byte则对应长度为6个bit,剩余的则为tag所占的位数,考虑到空间局部性所以该设计将set比tag放在更低位,这样一大块连续的内存(1024Byte)会依次缓存在每一个set中,而不是在同一个set的不同路中然后被反复覆盖。在具体的缓存寻址时,先会计算出对应的set从而快速定位到对应的n行(n路组相联,缓总大小存大小=每行缓存的大小(常常是64byte)* set组数量 * n路),然后对于这n行依次判断tag是否相同来决定是否是同一个物理地址,然后在判断isValid,最后读取Offset对应的缓存数据

Cache带来的问题

Cache 虽然带来性能方面的提升,但同时也给和硬件和软件开发带来了问题,那就是数据一致性问题,首先来看一下x86 CPU的Cache结构图:
1785_1.png
这是一颗最简单的双核心 CPU,它有三级 Cache,第一级 Cache 是指令和数据分开的,第二级 Cache 是独立于 CPU 核心的,第三级 Cache 是所有 CPU 核心共享的

而Cache的一致性问题,主要包括三个方面:

  1. 一个 CPU 核心中的指令 Cache 和数据 Cache 的一致性问题。
  2. 多个 CPU 核心各自的 2 级 Cache 的一致性问题。
  3. CPU 的 3 级 Cache 与设备内存,如 DMA、网卡帧储存,显存之间的一致性问题。这里我们不需要关注这个问题。

为了解决上述的问题,计算机科学家们引入了Cache的MESI协议,MESI 协议定义了 4 种基本状态:M、E、S、I,即修改(Modified)、独占(Exclusive)、共享(Shared)和无效(Invalid),四种状态的解释如下:

  1. M 修改(Modified):当前 Cache 的内容有效,数据已经被修改而且与内存中的数据不一致,数据只在当前 Cache 里存在。比如说,内存里面 X=5,而 CPU 核心 1 的 Cache 中 X=2,Cache 与内存不一致,CPU 核心 2 中没有 X。
  2. E 独占(Exclusive):当前 Cache 中的内容有效,数据与内存中的数据一致,数据只在当前 Cache 里存在;类似 RAM 里面 X=5,同样 CPU 核心 1 的 Cache 中 X=5(Cache 和内存中的数据一致),CPU 核心 2 中没有 X。
  3. S 共享(Shared):当前 Cache 中的内容有效,Cache 中的数据与内存中的数据一致,数据在多个 CPU 核心中的 Cache 里面存在。例如在 CPU 核心 1、CPU 核心 2 里面 Cache 中的 X=5,而内存中也是 X=5 保持一致。
  4. 无效(Invalid):当前 Cache 无效。前面 Cache 中没有数据的那些,都属于这个情况。

最后还要说一下 Cache 硬件,它会监控所有 CPU 上 Cache 的操作(这里需要插一嘴,硬件的监控与软件的监控不同,软件的监控需要通过具体的进程去循环然后读取信号,而硬件的监控直接通过逻辑电路的特性实现,当有不同信号输入时逻辑电路自然而然地会做出对应的操作),根据相应的操作使得 Cache 里的数据行在上面这些状态之间切换。Cache 硬件通过这些状态的变化,就能安全地控制各 Cache 间、各 Cache 与内存之间的数据一致性了。

开启Cache

x86 CPU 上默认是关闭 Cache 的,需要在 CPU 初始化时将其开启。在 x86 CPU 上开启 Cache 非常简单,只需要将 CR0 寄存器中 CD、NW 位同时清 0 即可。CD=1 时表示 Cache 关闭,NW=1 时 CPU 不维护内存数据一致性。所以 CD=0、NW=0 的组合才是开启 Cache 的正确方法。开启 Cache 只需要用四行汇编代码,代码如下:

mov eax, cr0
;开启 CACHE    
btr eax,29 ;CR0.NW=0
btr eax,30  ;CR0.CD=0
mov cr0, eax

参考

  1. 《操作系统实战45讲》
  2. 《现代操作系统:原理与实现》