操作系统
第一章 操作系统基础
1.1 揭开钢琴的盖子
-
存储程序思想(冯诺伊曼)
将程序和数据存放到计算机内部的存储器中,计算机在程序的控制下一步一步进行处理
将程序放到存储器(内存)中,然后用一个指针(PC/IP指针)指向它,计算机自动(前提是置好初值)取指执行,开始工作
-
计算机由五大部件组成
输入设备、输出设备、存储器、运算器、控制器
-
x86PC开机过程
- x86 PC刚开机时CPU处于实模式
- 开机时,CS=0xFFFF; IP=0x0000
- 寻址0xFFFF0(ROM BIOS映射区)
- 检查RAM,键盘,显示器,软硬磁盘
- 将磁盘0磁道0扇区(即为引导扇区,一个扇区512字节)读入0x7c00处
- 设置cs=0x07c0(起始左移四位+IP),ip=0x0000,即BIOS开始在这个地址(你自己写的)执行
-
引导扇区:启动设备的第一个扇区
1.2 操作系统启动
将磁盘上的操作系统读到内存里
-
setup.s :挪动操作系统,让操作系统放在0地址。获取硬件参数,建立数据结构,存储硬件信息,初始化
-
保护模式:寻址空间不够,从16位机切换到32位,需要进入保护模式
-
system模块开始执行
setup是进入保护模式,head是进入之后的初始化
main函数
main函数当中的init函数:将页设置位0
-
总结:通过boot读入操作系统,setup获得参数,启动保护模式进行初始化,head初始化idt,gdt,main初始化一堆硬件。将操作系统读入内存,完成初始化。
-
为什么读入内存?
读入内存以后操作系统才可以进行取指执行。
-
为什么要初始化?
管理计算机硬件的设备。操作系统是管理计算机硬件的软件系统。
-
1.3 操作系统接口
-
上层应用通过什么东西进入内核?
操作系统接口
-
进入内核的这个接口是什么?
接口表现为函数调用,又由系统提供,所以称为系统调用
-
系统调用
-
e.g用户如何使用计算机
-
命令行
一段程序而已,shell也是一段程序:即/bin/sh
-
图像按钮
消息处理机制:一个循环调用一个函数,从内核里把消息挨个取出来,每取出一个消息都会调用一个消息处理函数。
-
应用程序
-
1.4 系统调用的实现
-
应用程序不能随意进入内核,通过CPL(当前)/DPL限制,当前访问权限必须大于等于目标权限才可进入
-
应用程序进入内核的唯一方法:中断
-
e.g:printf实现系统调用
-
int 0x80
初始化时DPL=0。进入int 0x80的将DPL=3,故意让CPL=3的用户代码可以进入内核,一旦进入内核,CPL根据这里的8就设置为0,执行完指令后CPL又变为3。
-
中断处理程序:system_call
- _sys_call_table中。当进程需要访问一个虚拟地址时,操作系统会使用该进程的页表指针来查找相应的页表项,以确定虚拟地址对应的物理地址。
- 页表的两种类型:全局页表包含内核和操作系统所使用的地址空间和数据结构。它们由内核执行初始化并在系统整个生命周期期间使用。每个进程都有自己的页表,称为进程页表。进程页表存储在内核空间,为每个进程独立维护一个。每个进程拥有它自己的虚拟地址空间,所以每个进程都有自己独立的页表。
- CPU寄存器的作用
-
CR0(Control Register 0)控制寄存器0主要用于控制和开关CPU的工作模式、内存分页方式、缓存控制、数学协处理器等。其中具体作用包括:
- 启用/禁用内存分页机制;
- 启用/禁用虚拟8086模式(以支持DOS程序运行);
- 启用/禁用拓展类型的分页机制(PAE);
- 启用/禁用写保护;
- 控制页面级的Write Protect;
- 启用/禁用协处理器;
- 控制系统管理模式(SMM)等
-
CR2寄存器主要用于记录当前程序访问的地址
- 当程序试图访问不存在于未分配页面的地址时,CPU会把当前的页故障错误码(PFEC)和CR2中记录的地址传递给操作系统,通知其处理页故障中断。
-
CR3寄存器主要用于记录页表目录页表的物理地址
- 操作系统在进行内存管理时,会操作页目录表和页表。当操作系统更改页目录表或页表时,需要在CR3寄存器中设置表的物理地址,以便CPU正确地访问物理内存。
- 在用户和内核之间进行上下文切换时,需要切换CR3寄存器的值,以切换不同页表,以实现内核和用户内存空间分离。
-
第二章 进程与线程
2.1 CPU管理的直观想法
-
CPU的工作原理
-
CPU上电以后发生了什么?
自动的取指执行,给一个初始地址,自动累加
-
CPU怎么工作
设好PC初值,设置为一段程序的初始地址(每执行一条指令,PC自动加一)
-
CPU怎么管理
并发
-
-
单道程序使用CPU带来的问题:CPU利用率低
-
如何解决该问题:并发(同时出发,交替执行,只有一套资源)
-
运行起来的程序称为进程,那么进程之间如何进行切换呢?引入PCB控制块
2.2 多进程图像
-
多个进程使用CPU
-
如何使用CPU
让程序执行起来
-
如何充分利用CPU
启动多个程序,交替执行
-
启动了的程序就是进程,所以是多个进程推进,进程由两个部分组成,资源+执行序列。
操作系统只需要把这些进程记录好、要按照合理的次序推进(分配资源、进行调度)(PCB : Process Control Block )
-
-
多进程图像从启动开始到关机结束
-
多进程的组织:PCB+状态+队列
-
多进程如何进行交替:schedule
-
交替的三个部分:队列操作+调度+切换
-
进程需要放在内存当中,但是多个进程同时存在与内存会出现以下问题
-
解决方法:进程带动内存的使用,映射
-
交替执行时也会出现乱套:比如两个合作的进程
-
如何解决:核心在于进程同步(合理的推进顺序)
-
总结
2.3 用户级线程
-
将资源和指令执行分开,引出线程
-
e.g:加载网页
-
分析其中代码实现
TCB和栈相互配合
-
线程拥有独立的栈TCP,PC在栈中
2.4 内核级线程
-
和用户级相比,核心级线程有什么不同?
-
用户栈和内核栈之间的关联:中断进入内核栈,IRET返回用户栈
-
两个内核线程之间的切换
-
内核线程switch_to的五段论
-
用户级线程、核心级线程的对比
用户级线程一旦某个线程阻塞,所有线程都无法执行,核心级不影响。
2.5 创建内核级线程
-
怎么切换?
-
切换到5段论如何具体实现?
-
从某个中断(fork)开始引入五段论
-
五段论当中的中断入口
通过INT 0x80进入中断。判断进程是否阻塞,如果阻塞(0)调用schedule切换到子进程。判断进程counter(时间片)是否用完,用完的话调用schedule切换进程。执行reschedule,执行到右括号从栈中弹出ret_from_sys_call,进入中断出口。
-
五段论当中的中断出口和schedule
执行一堆pop,然后iret返回到int 0x80后面执行,schedule调度,找下一个进程,或者找下一个核心级线程。
-
五段论当中的switch_to
TSS(Task Struct Segment )任务段。ljmp 是长跳转指令,把当前cpu的所有寄存器放在当前TR的段中,指向TSS选择描述符,保存原TSS,使用新TSS。TSS实际就是TCB中的一个子段。
-
-
一个核心级线程创建需要做哪些事情
-
从sys_fork开始Create Thread
_copy_process 将用户栈中的项一样样拷贝下来给子进程。
-
_copy_process的细节: 创建栈
调用get_free_page()获得一页内存(4k),在内核中要用内核的代码,绝不能用malloc(用户态的代码,库函数)。返回的一个地址,强制转化,用来做PCB。初始化TSS,esp0是内核栈,内核栈地址偏移4k + 指针p(申请页的初始地址),指针esp0正好在顶上,下面PCB上面内核栈。0x10是内核数据段。esp是用户栈,ss和esp从哪儿来?从父进程的用户栈拷贝而来。所以父子进程内核栈不同,用户栈相同。
eip哪里来的,INT 0x80指令的下一个指令。
-
总结
从用户栈到内核栈,从内核栈到TCB,TCB用switch_to 完成TCB的切换,进而完成内核栈的切换,用iret在完成用户栈的切换。
-
schedule 和 switch_to
-
schedule
schedule是指操作系统内核中的调度器,它负责在多个进程之间分配CPU时间片,并在进程需要等待某些事件时进行上下文切换。调度器会评估各进程的优先级、已占用时间和等待队列长度等因素,决定下次调度哪个进程执行。
-
switch_to
switch_to则是指将CPU控制从当前执行的进程切换到指定的目标进程。当调度器决定切换到某个进程时,它就会调用switch_to函数,将当前进程的上下文(包括寄存器值和堆栈等信息)保存起来,然后将目标进程的上下文还原,让它开始执行。
因此,可以看出,schedule和switch_to的关系是调度器会使用switch_to函数进行进程间切换。schedule函数是操作系统调度器的核心,而switch_to是所有进程切换的基础函数。
-
2.6 CPU调度策略
- 选哪个好?
- 设计调度算法的几个指标
- 周转时间:从任务进入到任务结束
- 响应时间:从操作发生到相应
- 吞吐量:完成的任务量
- 总原则:系统专注于任务执行,又能合理调配任务
- 设计调度算法需要折中,综合考虑
- SJF:短作业优先
- RR:轮转调度
- 还有太多问题需要解决
- 我们怎么知道哪些是前台任务,哪些是后台?
- gcc就一点不需要交互?Ctrl + C 按键怎么工作?word就不会执行一段批处理吗?
- SJF中的短作业优先如何体现?如何判断作业的长度?
- 来实际看一个例子
- counter的作用:
- counter的作用:
2.7 进程同步与信号量
进程需要实现合理有序的向前推进,等待信号告诉你可以继续。
-
相关概念
- 进程同步:需要让进程“走走停停”来保证多进程合作的合理有序。
- 信号量:用来sleep 和 wakeup,具体表现为信号量负数时阻塞等待,正数是还剩余多少资源可用。
-
对于多个进程之间一个信号显然是不够的,这就需要引入信号量的概念
多个进程执行到一定程度的时候要等待,靠信号量等待,具体表现为信号量负数时阻塞等待,正数是还剩余多少资源可用。
信号量唤醒的是进程,v是唤醒,p是睡眠。在缓冲区的信号量上进行V操作,将会唤醒等待该信号量的进程,包括生产者或消费者。唤醒的具体进程取决于等待该信号量的进程类型和等待的时间顺序。顺带一提,是在PCB上操作,所以是p和v是系统调用,需要进入内核态。
-
用信号量解决生产者-消费者问题
2.8 信号量临界区保护
-
多个进程共同修改信号量时会引出许多问题,比如说竞争条件引发的错误
-
为避免这些错误引入临界区概念
临界区的原则
-
轮换法:满足互斥,但不满足有空让进
-
标记法:会造成无限等待
-
非对称标记法:需要一个进程更加“勤劳”,选择一个进程进入,另一个进程循环等待
-
Peterson算法
满足各种情况,但是只适用于两个进程。
-
为满足多个进程,引入面包店算法
-
以上都是基于软件层面的算法,以下是软硬件合作
-
开关中断:被调度:另一个进程只有被调度才能执行,才可能进入临界区。但是多核CPU不好使
-
硬件原子指令法:原子操作是指对于其他进程或线程是不可中断的,保证了操作的完整性和不可分割性。适用于多核CPU
-
总结:一旦临界区保护住了信号量,信号量在执行的过程中,语义一定会正确,根据语义,多个进程就可以在适当的时候实现“走走停停”,用临界区保护信号量,用信号量实现同步。
2.9 死锁处理
-
基本概念
多个进程由于互相等待对方持有的资源而造成的谁都无法执行的情况叫死锁,越来越多进程死锁造成cpu无法工作。
-
死锁的成因
- 资源互斥使用,一旦占有别人无法使用
- 进程占有了一些资源,又不释放,再去申请其他资源
- 各自占有的资源和互相申请的资源形成了环路等待
-
死锁的四个必要条件
- 互斥使用(Mutual exclusion):资源的固有特性,如道口
- 不可抢占(No preemption):资源只能自愿放弃,如车开走以后
- 请求和保持(Hold and wait):进程必须占有资源,再去申请
- 循环等待(Circular wait):在资源分配图中存在一个环路
-
死锁处理方法
- 死锁预防
- 破坏死锁出现的条件
- 死锁避免
- 检测每个资源请求,如果造成死锁就拒绝
- 死锁检测+恢复
- 检测到死锁出现时,让一些进程回滚,让出资源
- 死锁忽略
- 就好像没有出现死锁一样
- 死锁预防
-
举例
第三章 内存管理
3.1内存使用与分段
-
内存如何使用
- 内存使用:将程序放到内存中,PC指向开始地址
-
让程序进入内存
将程序放入物理内存,但是执行call 40 指令明显错误,应修改其逻辑地址(也叫相对地址,即40)。
-
重定位
将程序加载到内存时,逻辑地址加上基地址,进行重定位变为物理地址,PC即可取指执行。编译时更多用于嵌入式设备,载入时相对灵活。但是程序载入后还需要移动,一个重要的概念需要引出:**交换(swap)**充分利用内存
-
重定位最合适的时机-运行时重定位
基址变化的信息都放在PCB中,程序运行过程中,PCB一直存在,需要时取出即可。基址+偏移就是实际的物理地址
- 总结一下这个过程:
- 一个程序编译好,这时候程序当中的地址不需要做任何修改,接下来执行程序,创建进程,在内存中找一段空闲的内存,比方说1000,把基址1000赋给PCB,把程序放到基址为1000的空闲内存,上下文切换时,PCB当中的基址就变成了基址寄存器,每执行一条指令都要进行取址,地址翻译,翻译完就能找到该条指令实际的物理地址。
- 一个程序编译好,这时候程序当中的地址不需要做任何修改,接下来执行程序,创建进程,在内存中找一段空闲的内存,比方说1000,把基址1000赋给PCB,把程序放到基址为1000的空闲内存,上下文切换时,PCB当中的基址就变成了基址寄存器,每执行一条指令都要进行取址,地址翻译,翻译完就能找到该条指令实际的物理地址。
-
内存如何更好的使用
-
引入段的概念
-
不是将整个程序,是将各段分别放入内存
-
GDT + LDT
- GDT(操作系统的段表):是全局的描述符表,用于存储操作系统对所有进程共享的段的描述符,如内核代码段、内核数据段、用户代码段、用户数据段等。
- LDT(进程的段表):每个进程独立使用的本地段描述符表,用于存储该进程使用的段的描述符,如代码段、数据段、堆栈段等,它们与其他进程的LDT是隔离的,因此可以提供更好的安全性和隔离性。
-
在段表的基础上总结如何使用内存
- 仍然是一个程序去一段内存上,只不过现在程序分段了,各个段在内存中找到一段空闲的区域,将基址记录在LDT表中。一个程序载入到内存中,LDT表也初始化完成了,PCB获取基地址。(实际通过LDTR寄存器来获取当前进程的LDT表的地址,该寄存器通常存储在进程的TSS(任务状态段)中,而TSS又是保存在进程的PCB中的。)PC指针开始取指执行,地址翻译,翻译完就能找到该条指令实际的物理地址。
- 仍然是一个程序去一段内存上,只不过现在程序分段了,各个段在内存中找到一段空闲的区域,将基址记录在LDT表中。一个程序载入到内存中,LDT表也初始化完成了,PCB获取基地址。(实际通过LDTR寄存器来获取当前进程的LDT表的地址,该寄存器通常存储在进程的TSS(任务状态段)中,而TSS又是保存在进程的PCB中的。)PC指针开始取指执行,地址翻译,翻译完就能找到该条指令实际的物理地址。
-
3.2 内存分区与分页
-
内存怎么割呢?
- 固定分区与可变分区
-
核心数据结构
-
内存申请
-
内存释放
-
引出算法
首先适配均匀随机,查表快,分配的特别快;最佳适配会造成内存碎片化;最差适配,得到一些比较均匀的段。
-
-
为了解决内存分区导致的内存效率问题,引入分页
-
可变分区(段)造成的问题
-
从连续到离散
不再需要内存紧缩,浪费空间少,最多就一页,4k。
重定位时,MMU硬件自动计算,实际是逻辑地址右移12位,算出页号(逻辑),在页表cr3找到页框号(物理),组合成物理地址。
-
3.3 多级页表与快表
-
为了提高内存空间利用率,页应该小,但是页小了页表就大了?
- 第一种尝试,只存放用到的页
- 失败,这样会造成页表中的页号不连续,会导致查找效率非常低下,连续的情况只需要知道偏移,一步就能找到,而不连续就要比较,查找…
- 第二种尝试,多级页表,即页目录表(章)+ 页表(节)
- 成功
- 成功
- 第一种尝试,只存放用到的页
-
多级页表提高了空间效率,但牺牲了时间
-
多级页表增加了访存次数,尤其是64位系统,如何解决?
-
快表TLB
将访问过的数据记录,下次再次访问可以快速定位物理地址,如果未记录,再查找多级页表,再记录
-
-
TLB得以发挥作用的原因
- 命中率,命中率越高效率越高
- TLB越大越好,但TLB很贵,通常只有[64, 1024]
-
3.4 段页结合的实际内存管理
-
段、页同时存在:段面向用户/页面向硬件
通过虚拟内存将段、页结合起来。
-
各自重定位
先通过段号找到段表上的基址加偏移得到虚拟地址,通过虚拟地址算出页号,得到页号加上页内偏移找到物理地址。第一层地址翻译是基于段的,第二层地址翻译是基于页的。
-
-
一个实际的段、页式内存管理
-
将程序放入内存
- 在虚拟内存割出一段区域给程序的代码段、数据段(建立段表,段表存的是虚拟内存的基址)
- 将段分割成页,将每一页和物理内存的页框对应起来(建立页表)
-
重定位
-
代码解读
-
fork()
P是PCB,①分配虚存,②建立段表
-
建页表
-
from_dir()
-
最终形态
-
-
写时复制
-
3.5 内存换入-请求调页(Swap in)
-
用换入、换出实现“大内存”
- 请求调页
- 请求调页
-
一个实际系统的请求调页从缺页中断开始
-
page fault
-
do_no_page
申请一个空闲页,从磁盘上读入这一页,建立该页和虚拟地址的联系。
-
3.6 内存换出(Swap out)
-
get_free_page,并不能总是获得新的页,内存是有限的
-
需要选择一页淘汰,换出到磁盘,选择哪一页呢?
-
FIFO,最容易想到,但如果刚换入的页马上又要换出怎么办?
频繁换入换出,浪费资源。
-
有没有最优的淘汰方法?MIN
预知未来,不现实。
-
最优淘汰方法能不能实现,是否需要近似?LRU
程序都有局部性的特点,利用这个特点,选最近最长一段时间没有使用的页淘汰。
-
-
LRU的准确实现
- 时间戳:每页维护一个时间戳,每次地址访问都需要修改时间戳,需维护一个全局时钟,需找到最小值,实现代价较大,不可行。
- 页码栈:选栈底页淘汰,每次地址访问都需要修改栈(修改10次左右栈指针),实现代价仍然较大,用的很少。
-
LRU的近似实现
- SCR(Clock Algorithm)
- Clock算法在缺页很少的情况下,会退化成FIFO,再加一个指针!
- SCR(Clock Algorithm)
-
置换策略有了,那么该给进程分配多少页呢?
提一嘴,操作集可以算出程序局部性,一般动态调整分配页。
-
总结
第四章 设备驱动与文件系统
4.1 I/O与显示器
-
外设驱动三部曲
- 形成统一的文件视图
- 通过out指令向外设发命令
- 进行中断处理
- 形成统一的文件视图
-
一段操纵外设的程序
不论什么设备!怎么区分设备,文件名!
-
看个例子
-
open系统调用
首先把 dev/tty0 的设备信息读进来,write的时候找到读进来的inode,最终执行out
-
sys_write
-
tty_write(实现输出的核心函数)
这里用到了缓存,原因是cpu在内存中执行很快,输入到显示器很慢,折中,设置一个缓存区,通过中断实现。
-
继续向下调用,tty->write
-
printf过程
一级级向下,找设备分支。mov 相当于out。
-
4.2 键盘
-
如何使用键盘,从中断初始化开始
-
进入key_table
-
进入key_map
-
进入put_queue
跟显示器的差别其实read和write互换了
-
键盘的中断处理
-
-
总结
4.3 生磁盘的使用
-
怎么让磁盘工作起来
- 移动磁头到相应的磁道上
- 旋转磁盘到相应的扇区上
- 和内存缓冲进行读写
-
最直接的使用磁盘(同时也是最复杂的)
-
通过盘块号读写磁盘(一层抽象)
-
从扇区到盘块
用空间换时间
-
通过盘块号访问磁盘,既提高了效率又方便使用
-
-
多个进程通过队列使用磁盘(第二层抽象)
-
FCFS磁盘调度算法
-
SSTF磁盘调度(短作业优先)
如果不停的来中间磁道的请求,就一直无法执行183,为优化该问题改进算法引出SCAN
-
C-SCAN磁盘调度(电梯算法)
-
-
总结
4.3 从生磁盘到文件
-
如何从文件得到盘块号
建立一个从字符流到盘块的映射
-
引入文件,对磁盘使用的第三层抽象
-
映射
-
连续结构
读写比较快,不适合动态增长
-
链式结构
读写过程耗时比较长,但是适合动态增长
-
索引结构
即适合于增长,读写起来也不慢
-
-
实际操作系统一般采用多级索引。没有最好的映射方法,只有最合适的。
4.4 目录与文件系统
-
文件系统,抽象整个磁盘(第四层抽象)
将整个磁盘的盘块,最后抽象成这样一个目录树结构。
-
怎么完成这个映射?
-
引入目录树
-
如何实现目录?
目录中应该存放文件名(字符串)和对应的FCB的“地址”。
-
树状目录的完整实现
-
-
映射的完整形态