操作系统接口
Q:什么是接口? A:连接两个东西,信号转换,屏蔽细节。
eg:命令行、图形界面
接口表现为函数调用,由系统提供,所以称为系统调用(system call)。
分类:(POSIX:IEEE指定的一个标准族)
- 任务管理(fork,execl)
- 文件系统(open,EACCES)
系统调用的实现
不能随意jmp,系统调用提供了一种进入内存的手段。
将内核程序和用户程序隔离,区分用户态和内核态:
- CS的最低两位当前特权级CPL:00内核态,11用户态
- DPL>=CPL时可以访问,DPL为表项中对应的段的特权级
硬件提供的主动进入内核方法:中断(进入内核的唯一方法)
因此,系统调用的核心为:
- 用户程序中包含一段包含 int 指令的代码
- 操作系统写中断处理,获取想调用程序的编号
- 操作系统根据编号执行相应代码
CPU管理的直观想法
CPU的工作原理:取址,执行
管理CPU最直观的方法:设置好PC的初值
处理一条IO命令的时间相当于很多计算指令的时间:多道程序,交替执行
一个CPU上交替执行多个程序:并发
怎么做到并发:
- 控制PC寄存器切换
- 记录切出去时进程的状态:每个进程有一个存放信息的结构–PCB
进程:进行中的程序,有开始有结束,走走停停,需要记录寄存器状态
多进程图像
多个进程使用CPU的图像,从启动开始到关机结束。
PCB:用来记录进程信息的数据结构
多进程如何组织:PCB+状态+队列
- 一些进程等待执行:就绪队列
- 一些进程等待某个事件:磁盘等待队列
多进程如何交替:队列操作+调度+切换(schedule( )完成切换)
schedule()
{
pNew=getNext(ReadyQueue);
switch_to(pCur,pNew);
}
进程调度:FIFO 先入先出;Priority 优先级。
会出现多个进程同时访问同一个内存位置的问题:要限制对内存的读写,通过映射表映射到不同的物理地址
多个进程如何合作:生产者-消费者实例
- 核心在于进程同步(合理的推进顺序)
- 生产者给counter上锁
- 消费者检查counter锁
- 生产者给counter开锁
- 消费者给counter上锁
用户级线程
进程=资源+指令执行序列
- 将资源和指令执行分开,线程切换就是只针对指令的切换
- 一个资源+多个指令执行序列
线程thread:保留了并发的优点,避免了进程切换代价
从一个栈到两个栈:使用TCB和Yield
内核级线程
和用户级相比,核心级有哪些不同:
- ThreadCreate时系统调用,内核管理TCB,内核负责切换线程
- 如何让切换成型?内核栈,TCB
- 执行的代码仍然在用户态,还要进行函数调用
- 一个栈到一套栈,两个栈到两套栈
用户栈和内核栈之间的关联:
返回时弹栈,跳回到发生中断前的位置。
开始内核中的切换:switch_to,通过TCP找到内核栈指针,通过ret切到某个内核程序,最后再用CS:IP切换到用户程序。
sys_read()
{
//启动磁盘读;将自己变成阻塞;找到next
switch_to(cur,next);
}
CPU调度策略
调度就是获得next,然后switch to next
调度需要折中,需要中和:
- 吞吐量和响应时间之间有矛盾;
- 前台任务(时间)和后台任务(周转时间)关注点不同;
- IO约束型任务和CPU约束型任务有各自的特点。
几种调度算法:
1.FCFS-先来先服务
- 平均周转时间:进入到离开的时间的平均
- 短的作业提前的话平均周转时间会变小→\rightarrow→SJF短作业优先
2.SJF-短作业优先
- 平均周转时间最小
- 响应时间该怎么办?→\rightarrow→RR按时间片轮转调度
3.RR-轮转调度
- 时间片大:响应时间太长
- 时间片小:吞吐量小
- 折中:时间片10-100ms,切换时间0.1-1ms(1%)
想要使用优先级,但会产生如下的问题:
- 如果一直有前台任务,后台任务就得不到执行,因此需要后台任务优先级动态升高
- 但后台任务一旦执行,前台任务的响应时间得不到保证,因此前后台都需要使用时间片,但这就又退化到了RR,体现不出SJF
因此引出如下的这个实际的schedule函数
Linux 0.11的schedule函数
求最大的counter;counter就代表了优先级,本身也作为时间片进行轮转调度。
找到最大的counter然后跳出去执行;
//没有找到最大counter,对所有任务的counter进行如下修改
for(p=$Last_Task;p>$First_Task;--p){
(*p)->counter = ((*p)->counter >> 1)+(*p)->priority;//当前counter除以2加上初值
}
counter作用的整理:
- counter保证了响应时间的界限;
- 经过IO后counter会变大,IO时间越长counter越大,照顾了前台进程;
- 后台进程按照counter轮转,近似于SJF;
- 每个进程只维护一个counter变量,简单高效。
进程同步与信号量
进程合作:多个进程共同完成一个任务
以生产者-消费者为实例:
//生产者进程
while(true){
while(counter == BUFFER_SIZE)
sleep();//缓存满,生产者停
buffer[in] = item;
in = (in+1) % BUFFER_SIZE;
counter++;
}
//消费者进程
while(true){
while(counter == 0)
sleep();//缓存空,消费者停
item = buffer[out];
out = (out+1) % BUFFER_SIZE;
counter--;
}
只发信号不能解决所有问题,如果两个生产者同时睡眠,第二个永远不会被唤醒。
因此需要使用信号量来记录更多详细的信息,来决定睡眠还是唤醒。
信号量开始工作:
- 缓冲区满,P1执行,P1 sleep
sem=-1 - P2执行,P2 sleep
sem=-2 - C执行1次循环, wakeup P1
sem=-1 - C再执行1次循环, wakeup P2
sem=0 - C再执行一次循环
sem=1 - P3执行
sem=0
定义信号量:Dijkstra提出
P在荷兰语中代表检查,而V代表增加。
struct semaphore{
int value;//记录资源个数
PCB *queue;//记录等待在该信号量上的进程
}
P(semaphore s);//消费资源
V(semaphore s);//产生资源
P(semaphore s){
s.value--;//消费一个资源
if(s.value < 0)
sleep(s.queue);//没有资源则等待
}
V(semaphore s){
s.value++;//生产一个资源
if(s.value <= 0)//说明有等待的进程,需要进行唤醒
wakeup(s.queue);
}
用信号量解决生产者-消费者问题:
int fd = open("buffer.txt");//用文件定义共享缓冲区
write(fd,0,sizeof(int));//写in
write(fd,0,sizeof(int));//写out
//信号量的定义和初始化
semaphore full = 0;
semaphore empty = BUFFER_SIZE;
semaphore mutex = 1;//互斥信号量,只能有一个人进入缓冲区写文件
Producer(item){
P(empty);//测试空闲缓冲区是不是0
P(mutex);
/*读入in,将item写入到in的位置*/
V(mutex);
V(full);
}
Consumer(){
P(full);//测试生产出的资源数量
P(mutex);
/*读入out,从文件中的out位置读出到item,打印item*/
V(mutex);
V(empty);
}
信号量临界区保护
共同修改信号量引出的问题:
- 竞争条件:和调度有关的共享数据语义错误
- 错误和调度顺序有关,难于发现和调试
解决竞争条件的直观想法:
- 在写共享变量时阻止其他进程访问它(上锁,开锁)
- 临界区:一次只允许一个进程进入的该进程的那一段代码
- 一个非常重要的工作:找出进程中的临界区代码
剩余区–进入区–临界区–退出区–剩余区
临界区代码的保护原则:
- 基本原则:互斥进入
- 有空让进
- 有限等待
轮换法:相当于值日
//进程P0
while(turn != 0);
/*临界区*/
turn = 1;
/*剩余区*/
//进程P1
while(turn != 1);
/*临界区*/
turn = 0;
/*剩余区*/
//能够满足互斥进入,但是不能满足有空让进原则
标记法:相当于留了便条
//进程P0
flag[0] = true;
while(flag[1]);
/*临界区*/
flag[0] = false;
/*剩余区*/
//两边同时留条的话会造成都认为对方会进入,结果没有人能进入
非对称标记:带名字的便条+一个人更加勤劳
进入临界区Peterson算法:结合了标记和轮转两种思想
//进程P0
flag[0] = true;
turn = 1;
while(flag[1] && turn == 1);
/*临界区*/
flag[0] = false;
/*剩余区*/
//满足互斥进入,有空让进,有限等待
多个进程:
1.面包店算法(软件方法)
- 每个进程获得一个序号,序号最小的进入
- 进程离开时序号为0,不为0的序号即为标记
- 很明显这个算法虽然可以,但是太复杂了
2.阻止调度(硬件方法)
- 多CPU(多核)时不好使
cli();//关中断
/*临界区*/
sti();//开中断
/*剩余区*/
3.临界区保护的硬件原子指令法(硬件方法)
- 中间不能被打断
boolean
TestAndSet(boolean &x)
{
//一次执行完毕
boolean rv = x;
x = true;
return rv;
}
while(TestAndSet(&lock));//查看锁是否被锁上
/*临界区*/
lock = false;
/*剩余区*/
死锁处理
再考虑生产者-消费者问题,如下所示调换信号量的位置:
Producer(item){
P(empty);//改为mutex
P(mutex);//改为empty
/*读入in,将item写入到in的位置*/
V(mutex);
V(full);
}
Consumer(){
P(full);//改为mutex
P(mutex);//改为full
/*读入out,从文件中的out位置读出到item,打印item*/
V(mutex);
V(empty);
}
生产者和消费者同时阻塞,互相等待对方持有的资源,称为死锁。
死锁的成因:
- 资源互斥使用,一旦别人占有无法使用
- 进程占有了一些资源,又不去释放,再去申请其他资源
- 各自占有资源并互相申请
死锁的四个必要条件:
- 互斥使用
- 不可抢占:资源只能自愿放弃
- 请求和保持:进程必须占有资源再去申请
- 循环等待:存在一个回路
死锁处理方法概述:
- 死锁预防:破坏死锁出现的条件
- 死锁避免:检查每个资源请求,如果会造成死锁就拒绝—银行家算法
- 死锁检测+恢复:检测到死锁时让一些进程回滚,让出资源
- 死锁忽略:重启
内存使用与分段
内存使用:将程序放到内存中,PC指向开始地址。
重定位:修改程序中的地址(是相对地址)
- 编译时完成重定位程序只能放在固定的位置
- 载入时重定位程序一旦载入内存就不能动了
重定位最合适的时机:运行时重定位
- 在运行每条指令时才完成重定位
- 每执行一条指令都要从逻辑地址算出物理地址
- 每个指令的基地址放在PCB里,执行指令的第一步先从PCB中取出这个基地址
- 物理地址 = base + offset
引入分段:是将整个程序一起载入内存中吗?
- 程序由若干段组成,每个段有各自的特点和用途,代码段只读,代码/数据段不会动态增长
- 用户可以独立考虑每个段
- 定位具体指令(数据):<段号,段内偏移>
不是将程序,而是将各段分别放入内存中。
进程段表:GDT表(操作系统对应的段表)+LDT表(每个进程自己的段表)
| 段号 | 基址 | 长度 | 保护 |
|---|---|---|---|
| 0 | 180K | 150K | R |
内存分区与分页
三个步骤:
- 将一个程序分成多个段落
- 在内存中找到空闲的区域
- 将程序读入内存
可变分区的管理–按需分配+再次申请:又有一个段提出内存请求,此时同时存在多个空闲段
- 首先适配:表查的最快
O(1) - 最佳适配:会割出很多外碎片
O(n) - 最差适配:会割出均匀的块
O(n)
引入分页:解决内存分区导致的内存效率问题
- 因为会产生大量的内存碎片;将空闲分区合并需要移动段(内存紧缩)
- 把面包切成片=把内存分成页,每4K分成一页
- 每个进程最多浪费一页,不用进行内存紧缩
页载入内存,此时要用到页表使程序运行起来:
- 页中的仍然是逻辑地址 Page(第几页)+Offset(页面尺寸)
- 由硬件MMU完成由逻辑地址找到物理地址
| 页号 | 页框号 | 保护 |
|---|---|---|
| 0 | 5 | R |
| 1 | 1 | R/W |
| 2 | 3 | R/W |
若此时逻辑地址为0x02|0x240,查找表内对应页框号为3,3*4K即为3左移12位,加上偏移就得到了物理地址为0x3240。
多级页表与快表
为了提高内存的空间利用率,页就应该小一点,但这样的话页表项增加,页表就大了。
实际上大部分的逻辑地址根本不会用到,很自然的想法:用到的逻辑页才有页表项
32位地址空间+4K页面+页号必须连续→\rightarrow→2202^{20}220个页表项→\rightarrow→大页表占用内存,造成浪费
Q:既要连续又要让页表占用内存少,怎么办? A:多级页表
逻辑地址:
| 10bits | 10bits | 12bits |
|---|---|---|
| 页目录号 | 页号 | Offset |
多级页表增加了访存的次数,尤其是64位系统:
- TLB是一组相联快速存储(使用硬件一步找到),是寄存器
| 有效 | 页号 | 修改 | 保护 | 页框号 |
|---|---|---|---|---|
| 1 | 140 | 0 | R | 56 |
| 1 | 20 | 1 | R/W | 23 |
| 0 | 19 | 0 | R/X | 29 |
根据逻辑地址的页号查找快表中的对应序号,如果TLB命中直接拿到对应的页框号;若TLB未命中则失效,查看多级页表,查看完成后将结果放入TLB中。
TLB命中时效率会很高,未命中时效率会降低。
TLB的条目数在64-1024之间:
- 程序的地址访问具有局部性
- 空间局部性
段页结合的实际内存管理
程序员希望用段,物理内存希望用页。
核心想法:先将用户程序的段映射到地址空间(虚拟内存),再将地址空间(虚拟内存)的段映射到实际的内存上,即完成了段页的结合。
cs:ip逻辑地址→\rightarrow→用户代码段→\rightarrow→虚拟地址(对用户是透明的)→\rightarrow→物理地址
段页同时存在时的重定位:
- 段号+偏移(cs:ip)(逻辑地址)→\rightarrow→页号+偏移(虚拟地址)→\rightarrow→物理页号+偏移(物理地址)
使用内存的换入换出来实现虚拟内存。
内存换入-请求调页
用户眼中的内存:
- 32位机器即4GB的(大而规整的)内存,用户可以随意使用,实际上就是用地址,就好像单独拥有4G内存(此时是虚拟内存)
- 这个“内存”如何映射到物理内存,用户全然不知
- 当物理内存的大小小于虚拟内存时:用换入、换出可以实现”大内存”
请求调页:
- 请求的时候才建立映射
- MMU查页表发现缺页,发起中断,进行页错误处理程序
- 从磁盘中把页面放入物理内存,重新执行指令
整个调入过程对用户透明,用户只会感觉运行慢了一点。
| 中断号 | 名称 | 说明 |
|---|---|---|
| 12 | Segment not Present | 描述符所指的段不存在 |
| 14 | Page fault | 页不在内存 |
内存换出
并不能总是获得新的页,内存是有限的,需要选择一页淘汰,换出到磁盘。
page = get_free_page();
bread_page(page, current->exectable->i_dev, nr);
1.FIFO页面置换
-
评价准则:缺页次数
-
置换方法不合适,可能造成刚换出又要换入的情况,缺页次数多。
2.MIN页面置换
-
选最远将使用的页淘汰,是最优方案;
-
但是MIN需要知道将来发生的事,理论可行但是实际无法使用。
3.LRU页面置换
-
用过去的历史预测将来:选择最近最长一段时间没有使用的页淘汰(最近最少使用);
-
LRU是公认的很好的页面置换算法,怎么实现?
-
LRU的准确实现:使用时间戳(time stamp)
- 每页维护一个时间戳,选择具有最小时间戳的页换出;
- 每次地址访问需要修改时间戳,需要维护一个全局时钟,需要找到最小值,实现的代价太大,故不可行。
-
LRU的准确实现:使用页码栈
- 维护一个页码栈,选择栈底页淘汰;
- 每次访问需要修改栈,实现代价仍然较大→\rightarrow→LRU的准确实现使用较少
-
LRU的近似实现:将时间计数更改为是和否
- 每次访问一页时,硬件自动设置该位;
- 选择淘汰页:扫描该位,是1时清零,并继续扫描;是0则淘汰该页(二次机会算法);
- 组织成循环队列较为合适;
- 二次机会算法也称为Clock算法。
Clock算法的分析和改造:
- 当缺页很少时,1很少有机会变成0,最终会导致R全为1,发生缺页时所有1变为0,然后将当前指向的页换出,退化为了FIFO算法;
- 发生以上问题的原因:记录了太长的历史信息,因此需要定时清除R位;
- 再加入一个扫描指针,用来清除R位,移动速度要快;
- 原来的指针用于选择淘汰页,移动速度慢;两指针一快一慢,因此称为Clock算法。
给一个进程分配多少页框(帧frame):
- 分配的多会导致请求调页导致的内存高效利用无效;
- 分配太少会导致程序数量到达一定量时CPU利用率急剧下降(颠簸);
I/O与显示器
让外设工作起来:
- CPU向控制器中的寄存器读写数据;
- 控制器完成真正的工作,并向CPU发送中断信号。
一段操纵外设的程序:操作系统为用户提供了统一的接口
int fd = open("/dev/xxx");
for(int i = 0; i < 10; i++){
write(fd,i,sizeof(int));
}
close(fd);
printf的整个过程:
- 库函数(printf)
- 系统调用(write)
- 字符设备接口(crw_table[])
- tty设备写(tty_write)
- 显示器写(con_write)
生磁盘的使用
磁盘的IO过程:控制器→\rightarrow→寻道→\rightarrow→旋转→\rightarrow→数据传输;
最直接的使用磁盘:
- 只要往控制器中写(使用out指令)柱面、磁头、扇区、缓存位置。
通过盘块号读写磁盘(一层抽象):
- 磁盘驱动负责从block计算出cyl、head、sec(CHS);
- 磁盘访问时间=写入控制器时间+寻道时间+旋转时间+传输时间
- block相邻的盘块可以快速读出,因此相邻的盘块尽量放在同一个磁道里;
- 由CHS得到的扇区号:C×\times×(Heads×\times×Sectors)+H×\times×Sectors+S
- 从扇区到盘块:每次读写1K:碎片0.5K;读写速度100K/秒;每次读写1M:碎片0.5M;读写速度约40M/秒。因此把连续的几个扇区认定为一个盘块,一次读写单位变大,虽然空间浪费变多但是读写速度变快。
多个进程通过队列使用磁盘(第二层抽象):
- 磁盘中断时由磁盘驱动从队列中取出block,换算成CHS后out输出;
- 需要考虑到调度算法。
1.FCFS磁盘调度算法:最公平、最直观的调度。
2.SSTF(Shortest-seek-time First)磁盘调度:短寻道优先;离中心较远的请求难以执行,存在饥饿问题。
3.SCAN磁盘调度(电梯算法):
- SSTF+中途不回折:每一个请求都有处理机会;
- SCAN+直接移到另一端:两端请求都能够很快处理。 ←\leftarrow←真实算法
生磁盘的使用整理:
- 进程“得到盘块号”,算出扇区号(sector);
- 用扇区号make req,使用电梯算法add_request;
- 进程sleep_on;
- 磁盘中断处理;
- do_hd_request算出CHS;
- hd_out调用outp(…)完成端口写。
从生磁盘到文件
引入文件,对磁盘使用的第三层抽象:
- 文件:建立字符流到盘块集合的映射关系;
- 连续结构来实现文件:处理起来快,但是不适合用于动态增长;
- 在文件的FCB中存放文件名、起始块以及块数。
链式结构也可以实现文件:
- 读写过程长,存取比较慢,但是动态变换比较快。
索引结构Index:
-
FCB中记录文件名与索引块;
-
读写速度和动态变换速度都不慢,现实中较为常用。
目录与文件系统
文件系统,抽象整个磁盘(第四层抽象)。
在其他计算机上:应用结构+存储的数据可以得到那棵文件树,找到文件,读取文件…
引入目录树:
- 将划分后的集合再次进行划分:k次划分后,每个集合中的文件数为O(logkN)O(\log_kN)O(logkN);
- 这一树状结构扩展性好、表示清晰,最常用;
- 引入目录,代表一个文件集合。
如何实现目录成为关键问题:
- 如何使用? 用
"/my/data/a"定位文件a;即为根据路径名"/my/data/a"得到a的FCB;
树状目录的完整实现:
- 磁盘:FCB数组+数据盘块集合;
- 目录项(字符串+一个号):文件名+对应的FCB的地址。
要使整个系统能自举,还需要存一些信息:
| 引导块 | 超级块 | i节点位图 | 盘块位图 | i节点 | 数据区 |
|---|---|---|---|---|---|
| 固定的 | 记录两个位图有多大等信息 | 哪些inode空闲,哪些被占用 | 表示哪些块是空闲的 | 根目录放在第一项 |
- 根目录放在inode数组的第一项;
- 通过读超级块,解析里面的内容,就可以找到根目录,进行其他的操作;
- mount就是读超级块的操作。
完成全部映射下的磁盘使用:
- 用户:读test.c 202-212个字节
- open(/xx/test.c):目录解析到/,读入/内容找到xx,再找到test.c的inode
- read(fd):根据找到的FCB和file中的202-212字节找盘块789
- 写入电梯队列:add_request(789)
- 磁盘中断:从队列中取出789,算出CHS
- 写磁盘控制器:outp(cyl,head,sector)
4554






