操作系统结构学习笔记

365 阅读15分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第 10 天,点击查看活动详情

一、基本构成

  • 控制器、运算器、存储器、输入设备、输出设备,即冯诺依曼模型;
  • 控制单元、逻辑运算单元、寄存器都位于中央控制单元,即cpu内,存储器一般指的内存;

1,寄存器

  • 通用寄存器:存放需要进行运算的数据,比如需要进行相加运算的两个数据;
  • 程序计数器:存储CPU要执行下一条指令「所在的内存地址」,而不是存储了下一条要执行的指令,此时指令还在内存中;
  • 指令寄存器:存放程序计数器指向的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里;

2,CPU位宽

  • 32位cpu:一次可以计算32位的数据,即CPU的位宽;
  • 64位cpu:一次可以计算64位的数据,可以计算更大的数据;

3,总线

  • 总线是用于 CPU 和内存以及其他设备之间的通信,分为以下三种;
  • 地址总线:指定CPU将要操作的内存地址;
  • 数据总线:用于读写内存的数据;
  • 控制总线:用于发送和接收信号,比如中断、设备复位等信号;
  • CPU要读写内存数据的时候,先通过「地址总线」来指定内存的地址,然后通过「控制总线」控制是读或写命令,最后通过「数据总线」来传输数据;

4,线路位宽

  • 一条地址总线一次只能传输一个位数据,0或1;
  • 线路位宽指的是同时能够传输多少位的数据;
  • 线路位宽一般需要小于或等于CPU位宽;
  • 32位的CPU,32位的线宽,能够访问的最大内存地址为4G;
  • 64位的CPU,32位的线宽,一次性能够访问的最大内存地址为4G,4G以上的地址需要多次传输才能访问;

5,CPU执行过程

  • CPU读取「程序计数器」存储的内存地址,然后「控制单元」操作「地址总线」指定需要访问的内存地址;
  • 通知内存设备准备数据,数据准备好后通过「数据总线」将指令数据传给 CPU;
  • CPU收到内存传来的数据后,将这个指令数据存入到「指令寄存器」;
  • CPU分析「指令寄存器」中的指令,确定指令的类型和参数;
  • 如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行;
  • CPU执行完指令后,「程序计数器」的值自增,表示指向下一条指令;
  • 自增的大小,由CPU的位宽决定,32 位CPU,指令是4个字节,需要4个内存地址存放,因此「程序计数器」的值会自增4;

6,指令的类型

  • 数据传输类型、运算类型、跳转类型、信号类型、闲置类型;

7,时钟周期

  • 脉冲信号转换时间即为一个时钟周期,也等于1/xGHz;
  • 一个时钟周期内,CPU仅能完成一个最基本的动作,跟硬件相关,即基频越高,CPU运行越快;
  • 程序的CPU执行时间 = CPU时钟周期数 x 时钟周期时间;
  • CPU时钟周期数 =「指令数 x 每条指令的平均时钟周期数(Cycles Per Instruction,CPI)
  • 指令数与编译器相关,CPI可以通过pipeline优化;

二、存储器

  • 从上往下分为:寄存器、CPU缓存,内存,SSD/HDD;

1,寄存器

  • 容量最小,读写速度最快,半个时钟周期;
  • 主要分为:指令寄存器、程序计数器、通用寄存器;

2,CPU缓存

  • 分为L1、L2、L3三级高速缓存,容量递增、速度递减;
  • 其中L1、L2为每个CPU核心独有的,L3为不同CPU核心共享;
  • L1通常分为数据缓存和指令缓存,通过以下方式查看大小,L2,L3类似,但只有数据缓存:
    • 数据:cat /sys/devices/system/cpu/cpu0/cache/index0/size
    • 指令:cat /sys/devices/system/cpu/cpu0/cache/index1/size

3,CPU缓存结构

  • CPU Cache是由多个Cache Line组成的,Cache Line是CPU从内存读取数据的基本单位;
  • Cache Line由有效位、组标记、数据块组成;
    • 有效位:用来标记对应的 CPU Cache Line中的数据是否是有效的,如果有效位是0,无论CPU Cache Line 中是否有数据,CPU 都会直接访问内存,重新加载数据;
    • 组标记:记录当前CPU Cache Line中存储的数据对应的内存块,可以用来区分不同的内存块;
    • 数据块:存放具体的数据;
  • 一个内存地址中存放了组标记、索引、偏移量(通过该地址访问数据):
    • 组标记:用来对比Cache Line中的组标记,判断是否是我们需要访问的内存块
    • 索引:计算对应的 CPU Cache Line 的地址;
    • 偏移量:从CPU Cache Line 的数据块中,读取对应的字;
  • 查看L1 Cache 一次载入数据的大小:
    • cat /sys/devices/system/cpu/cpu0/index0/coherency_line_size (64字节);
    • 即一次性载入连续的64字节数据;
  • 提升数据缓存命中率:按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处;
  • 提升指令缓存命中率:如果分支预测可以预测到接下来要执行的指令,就可以「提前」把这些指令放在指令缓存中,CPU可以直接从Cache读取到指令,执行速度就会很快;
  • 多核CPU缓存命中率:L3 Cache是多核心之间共享的,但是L1和L2 Cache是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,可以把线程绑定在某一个CPU核心上,性能可以得到非常可观的提升;在 Linux 上提供了sched_setaffinity方法,来实现将线程绑定到某个CPU核心这一功能;

4,CPU缓存数据写入内存

  • 写直达:
    • 如果数据已经在缓存中,先将数据更新到缓存中,再写入到内存里面;
    • 如果数据没有在缓存中,就直接把数据更新到内存里面;
    • 写操作将会花费大量的时间,性能会受到很大的影响;
  • 写回:
    • 如果数据已经在缓存中,则把数据直接更新到缓存中,同时标记这个Cache Block为脏,此时不用把数据再写到内存里的;
    • 如果数据所对应的Cache Block里存放的是「别的内存地址的数据」的话,就要检查这个缓存块里的数据有没有被标记为脏:
      • 如果是脏的话,就要把这个Cache Block里的数据写回到内存,再把当前要写入的数据,先从内存读入到Cache Block里(因为一个数据块中的其他字节可能被其他核心的缓存更新过,先更新到缓存再修改能保证数据一致性),然后再把当前要写入的数据写入到Cache Block,最后也把它标记为脏;
      • 如果没有被标记为脏,则就直接将数据写入到这个Cache Block里,然后再把这个Cache Block标记为脏;
    • 当发生写操作时,新的数据仅仅被写入Cache Block里,只有当修改过的Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能;

5,缓存一致性问题

  • 问题:由于 L1/L2 Cache 是多个核心各自独有的,会带来多核心的缓存一致性问题;
  • 原因:采用写回的方式,某个核心对数据的修改可能只同步到L1/L2层,未同步到内存,导致其他核心拿到的数据不一致;
  • 解决方法:
    • 写传播:某个核心对数据的修改要传递到其他核心;
    • 事务串行化:某个核心对数据的操作顺序,必须在其他核心看起来顺序是一样的;

6,缓存一致性问题具体解决方式:

  • 1) 总线嗅探
    • CPU每时每刻监听总线上的一切活动,不管别的核心的Cache是否缓存相同的数据,都需要发出一个广播事件,会加重总线的负载,并不能保证事务串行化;
  • 2)MESI协议
    • 由4个状态单词的开头字母缩写表示,分别是:Modified已修改、Exclusive独占、Shared共享、Invalidated已失效;
    • 「已修改」:表示这个 Cache Block 里的数据已经被更新过,但是还没有写到内存里,即脏标记;
    • 「已失效」:表示这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据;
    • 「独占」:表示这个 Cache Block 里的数据是干净的,和内存里面的数据是一致性的,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据,数据可以自由写入,不需要通知其他核心,当其他核心从内存取该数据时,会转换为共享态;
    • 「共享」:表示这个 Cache Block 里的数据是干净的,和内存里面的数据是一致性的,修改数据时需要先向其他核心广播一个请求,将其他核心的Cache Line 标记为「无效」状态,再进行数据更新;

7,伪共享

  • 问题:因为多个线程同时读写同一个 Cache Line 的不同变量时,本来应该是独占状态的Cache Line逐步转换为共享状态,最后转变为失效状态,而导致 CPU Cache 失效的现象;
  • 解决方法:
    • 对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,否则就会出现为伪共享的问题;
    • 在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是用于解决伪共享的问题,使变量强制Cache Line对齐;
    • 也可以采用前置填充的方式使两个变量位于不同的Cache Line;

三、线程调度

  • 1,任务优先级以及响应要求,主要分为两种,其中优先级的数值越小,优先级越高:
    • 实时任务,对系统的响应时间要求很高,优先级在 0~99 范围内;
    • 普通任务,响应时间没有很高的要求,优先级在 100~139 范围内;
    • 调度类的优先级如下:Deadline > Realtime > Fair
  • 2,实时任务调度策略:
    • SCHED_DEADLINE:是按照 deadline 进行调度的,距离当前时间点最近的 deadline 的任务会被优先调度;
    • SCHED_FIFO:对于相同优先级的任务,按先来先服务的原则,优先级高的可以「插队」,更早执行;
    • SCHED_RR:对于相同优先级的任务,按照时间片轮流着运行,当用完时间片的任务会被放到队列尾部,但是高优先级的任务依然可以抢占低优先级的任务;
    • 使用的是deadline调度器和rt调度器;
  • 3,普通任务调度策略:
    • SCHED_NORMAL:普通任务使用的调度策略;
    • SCHED_BATCH:后台任务的调度策略,不和终端进行交互,在不影响其他需要交互的任务,可以适当降低它的优先级;
    • 采用的是CFS完全公平调度,任务队列采用红黑树描述;
  • 4,优先级调整
    • nice 的值能设置的范围是-20~19,值越低表明优先级越高,因此 -20 是最高优先级,19则是最低优先级,默认优先级是0;
    • 事实上,nice 值并不是表示优先级,而是表示优先级的修正数值,它与优先级(priority)的关系为:priority(new) = priority(old) + nice;
    • nice -n -5 /usr/bin/python:启动任务时指定优先级;
    • renice -5 -p pid:修改运行中的任务的优先级;
    • nice 调整的是普通任务的优先级,所以不管怎么缩小 nice 值,任务永远都是普通任务;

四、软中断

  • Linux 系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是「上半部和下半部分」:
    • 上半部直接处理硬件请求,也就是硬中断,主要是负责耗时短的工作,特点是快速执行;
    • 下半部是由内核触发,也就是软中断,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行;
    • 硬中断是会打断 CPU 正在执行的任务,然后立即执行中断处理程序,而软中断是以内核线程的方式执行,并且每一个 CPU 都对应一个软中断内核线程,名字通常为「ksoftirqd/CPU 编号」,比如 0 号 CPU 对应的软中断内核线程的名字是 ksoftirqd/0;、
    • 一些内核自定义事件也属于软中断,比如内核调度等、RCU 锁(内核里常用的一种锁)等;
    • cat /proc/softirqs:查看系统软中断运行情况;
    • cat /proc/interrupts:查看系统硬中断运行情况;
    • NET_RX 表示网络接收中断,NET_TX 表示网络发送中断、TIMER 表示定时中断、RCU 表示 RCU 锁中断、SCHED 表示内核调度中断;
    • watch -d cat /proc/softirqs 命令查看中断次数的变化速率;

五、二进制码

  • 负数表示
    • int类型是32位的,最高位是作为「符号标志位」,正数的符号位是 0,负数的符号位是 1,剩余的31位则表示二进制数据;
    • 负数在计算机中是以「补码」表示的,补码就是把正数的二进制全部取反再加1;
    • 如果负数不是使用补码的方式表示,在做基本加减法运算的时候,需要先判断是否为负数,而用了补码的表示方式,对于负数的加减法操作,实际上是和正数加减法操作一样的,可以直接运算;
  • 小数的十进制与二进制转换
    • 整数除二取余,小数乘二取整;
    • 小数点后的指数幂为负数;
    • 乘二取整会出现无限循环的情况,在有限的精度情况下,最大化接近原先值的二进制数,但会造成精度缺失的情况;
  • 小数的存储
    • 符号位:表示数字是正数还是负数,为 0 表示正数,为 1 表示负数;
    • 指数位:指定了小数点在数据中的位置,指数位的长度越长则数值的表达范围就越大;
    • 尾数位:小数点右侧的数字,也就是小数部分,尾数的长度决定了这个数的精度,如果要表示精度更高的小数,就要提高尾数位的长度;
    • double 的尾数部分是 52 位,float 的尾数部分是 23 位,因此在十进制中double的有效数字是1516 位,float的有效数字是 78 位,这些有效位是包含整数部分和小数部分;
    • double 的指数部分是 11 位,而 float 的指数位是 8 位,意味着 double 相比 float 能表示更大的数值范围;
    • 指数可以是负数或正数,而有符号整数的计算是比无符号整数麻烦的,所以在实际存储指数的时候,需要把指数转换成无符号整数,float 的指数偏移量是 127;

六、内核

  • 作为应用连接硬件设备的桥梁,让应用程序只需关心与内核交互,不用关心硬件的细节;
  • 内核的4个基本功能:
    • 管理进程、线程:决定哪个进程、线程使用 CPU,即进程调度;
    • 管理内存:决定内存的分配和回收,即内存管理;
    • 管理硬件设备:为进程与硬件设备之间提供通信能力,即硬件通信;
    • 提供系统调用:如果应用程序要运行更高权限运行的服务,那么就需要有系统调用,它是用户程序与操作系统之间的接口;
  • 内核具有很高的权限,可以控制 cpu、内存、硬盘等硬件,而应用程序具有的权限很小,因此大多数操作系统,把内存分成了两个区域:
    • 内核空间:这个内存空间只有内核程序可以访问;
    • 用户空间:这个内存空间专门给应用程序使用;
  • 内核架构:
    • 宏内核:宏内核的特征是系统内核的所有模块,比如进程调度、内存管理、文件系统、设备驱动等,都运行在内核态,Linux系统;
    • 微内核:微内核架构的内核只保留最基本的能力,比如进程调度、虚拟机内存、中断等,把一些应用放到了用户空间,比如驱动程序、文件系统等,服务与服务之间是隔离的,提高了操作系统的稳定性和可靠性,但会带来频繁的内核态切换,鸿蒙系统;
    • 混合内核:架构有点像微内核,内核里面会有一个最小版本的内核,其他模块会在这个基础上搭建,实现的时候会跟宏内核类似,也就是把整个内核做成一个完整的程序,大部分服务都在内核中,就像是宏内核的方式包裹着一个微内核,Windows;