作为一名计算机专业的学生,对底层的东西都会比较感情兴趣。寒假看了《操作系统真象还原》这本书,作者写的很好,感觉不做下笔记的话对不起作者。软件是靠硬件运行的,软件能实现什么功能,很大程度上取决于硬件提供了哪些支持。理解好底层的东西,对学习新的概念和事物也有很大帮助。操作系统是非常复杂的体系结构,下面对最近学的内容进行梳理,方便以后查阅。内容有点多,大多摘自《操作系统真象还原》,感兴趣的慢慢看吧。
计算机的启动全过程:
计算机是一种分层工作的机器,各个模块完成自己的工作,然后交给下一层。loader将储存在硬盘中的操作系统的复制到内存中,跳到内存中操作系统的开始地址,操作系统的内核首先被载入内存,其中包含操作系统写好的各种数据结构,为操作系统做好准备,至此,计算机启动完毕。
中央处理器(CPU,Central Processing Unit)
CPU大体上分为控制单元,运算单元,存储单元三部分。
控制单元:大体上由指令寄存器IR(Instruction Register),指令译码器ID(Instruction Decoder),操作控制器OC(Operation Controller)组成。指令指针寄存器IP指向内存下一条待执行指令的地址,控制单元根据IP指向,将内存中的指令逐个装载到指令寄存器中,然后指令译码器根据一套规定好的指令分析出操作数,操作码之类的内容。
存储单元:CPU内部的L1、L2缓存及寄存器,待处理的数据就存放在这些存储单元中。基本采用SRAM存储器(静态RAM,不需要刷新电路也可以保存数据).
运算单元:负责算术运算,逻辑运算,只是单纯的执行部件。
CPU工作原理
控制单元要取下一条待运行的指令,该指令的地址在程序计数器 PC 中,在 x86CPU 上,程序计数器就是 CS: ip。读取 ip 寄存器后,将此地址送上地址总线,CPU 根据此地址便得到了指令,并将其存入到指令寄存器中。这时候,指令译码器根据指令格式检查指令寄存器中的指令,先确定操作码是什么,再检查操作数类型,若是在内存中,就将相应操作数从内存中取回放入自己的存储单元,若操作数是在寄存器中就直接用了,免了取操作数这一过程。 操作码有了,操作数也齐了,操作控制器给运算单元下令,于是运算单元便真正开始执行指令了。ip寄存器的值被加上当前指令的大小,于是ip又指向了下一条指令的地址。
内存分段:
内存是随机读写的,访问任何地址,只需要把地址送入地址总线即可。CPU的分段机制是从8086开始的,由于各种原因,CPU的宽度都是16位的,其存储的数字范围为2^16=65536字节=64KB。那时候的内存,再小也有1MB,所以地址总线是20位的,2^20=1MB。然而,通过寄存器显然只能送出16位的地址。为了解决这个问题,cpu采用了一种方法:内存分段。通过两个16位的地址,一个称为段地址,一个称为偏移地址,采用段地址*16+偏移地址的方法将其送上地址总线,从而实现1MB的寻址。假如想要访问物理内存0xC04,段基址和段内偏移的组合可以是:0xC01:0x03,0xC02:0x02,0xC00:0x04。本质上,内存并没有被分段,从CPU的视角来看,内存是分段的。在高级语言中,程序分段这种工作由编译器在编译阶段完成CPU中,CS(代码段),DS(数据段),SS(栈段)等寄存器都是指向程序中划分的段地址。
实模式
最初的计算机,用户具有至高无上的权利,利用底层语言我们可以对计算机每一个地方进行操作,计算机对我们来说就是全裸的。在这种环境下,新手程序员很可能不小心破坏计算机中的一些重要数据,甚至把操作系统毁掉。最大可用内存只有1MB,也不能够同时运行多个程序。由于分段机制,访问超过64KB的内存区域就要切换段基址,这些都是实模式下的缺点。为了解决这些问题,厂商在286架构导入保护模式。
保护模式
32位的CPU具有保护模式和实模式两种。保护模式运行在32位环境。32位CPU,地址总线和数据总线也变为32位,其寻址空间多达2^32=4GB。原来的16位寄存器也拓展为32位寄存器,可以直接处理32位数据。经过extend后的寄存器,统一在名字前面加e,如eax,ebx,eip等。
GDT(全局描述符表)
在保护模式中,为了兼容以前的模式,CPU仍然是分段访问。然而在保护模式下,CPU对段的访问进行了约束。段描述符被用来描述内存段,里面存储着内存段的类型(系统/数据),是否可执行/写/读,以及所拥有的特权级,是否存在内存中等一大堆信息,就像是内存段的身份证。一个段描述符用来定义一个内存段,这些描述符放在GDT(全局描述符表)里面,每个元素都是8字节的描述符。
选择子
保护模式下,段寄存器被当做选择子,16位的选择子,它的高13位作为索引部分,确定段描述符的位置,其低位存储请求特权级。通过选择子索引到段描述符后,会检查段寄存器的用途和段类型是否匹配,如果匹配,CPU自动从段描述符中取出段基址,这样再加上段内偏移地址,便组成段基址:段内偏移地址的形式。
虚拟地址
CPU允许在GDT注册的段不在内存中存在。如果描述符中P位为1,表示该段在内存中存在。访问过该段后,CPU自动将A位置1。当内存不足时,操作系统可以找出使用频率最低的段将其换出到硬盘。如果该段被访问而又不存在内存中,会抛出异常,执行中断处理程序,将段换入内存。保护模式下,打开分页机制后,段部件输出的线性地址不再等同于物理地址,而是虚拟地址。需要通过查页表来获取物理地址。
一级页表
在CPU打开分页机制的情况下,4GB空间被分成1M(20位)个4KB(12位)。每一个4KB被称为页,页是地址空间的计量单位,所有的页被放在页表里。在32位保护模式下,虚拟地址的高20位可以用来寻址页表,低12位可以用来在该物理页中寻址。
二级页表
为了不要一次性将全部页表项建好,需要动态创建页表项。二级页表将1M个标准页平均放置1K个页表中。每个页表中包含有1K个页表项,页表项是4字节大小,页表包含1K个页表项,故页表大小为4KB。由页目录表存储这些页表,每个页表的物理地址在页目录表中以页目录项(Page Directory Entry ,PDE)的形式存储。PDE可容纳1024个页表,1个页表可容纳1024个物理页,1个物理页映射4KB物理内存,1024*1024*4KB=4GB,达到32位地址空间最大容量。在二级页表中,32位虚拟地址拆成高10位,中间10位,低12位,高10位用于在页目录项中定位PDE,PDE中有页表物理页地址。找到页表后,由中间10位定位到页表中的某一页。找到具体页后,由最低12位作为页表偏移量,得到最终转换的物理地址。这一过程由页部件自动完成。
TLB(Translation Lookaside Buffer)
虚拟地址到物理地址的转换步骤略多,转换速度较慢,处理器准备了一个高速缓存,用来存放虚拟地址到物理地址的准换,这个调整缓存就是TLB(Translation Lookaside Buffer),俗称快表。有了TLB,CPU在寻址之前会用虚拟地址的高20位作为索引来查找TLB中的相关条目,命中则返回,不命中则查询后更新TLB。
特权级
特权级按照权力大小分为0,1,2,3级,0级特权能力最大。操作系统内核处于0级,系统程序位于1和2级,一般是虚拟机、驱动程序等。3级特权是用户程序,权力最弱。需要注意的是,特权级只能由低往高(如从3到0)。计算机的特权级的标签体现在DPL,CPL,RPL三个字段中。
RPL(Request Privilege Level):请求特权级,处于选择子的低2位
CPL(Current Privilege Level):是当前进程的权限级别,是当前正在执行的代码所在的段的特权级,存在于cs寄存器的低两位。对CS 和SS 来说,选择子的RPL=当前段的CPL)
DPL(Descriptor Privilege Level):表示门或者段的特权级,存储在门或者段的描述符的DPL 字段中。
访问数据段:访问者的权限大于等于该DPL表示的最低权限。
访问代码段:访问者的权限等于该DPL表示的最低权限。(只能平级访问,低特权级能做的事,高特权级代码也能做,正常情况下CPU没有理由自降等级去做某事,唯一一种处理器会从高特权级降到低特权级运行的情况,处理器从中断处理程序返回到用户态)
转移后的目标代码段的DPL会成为将来处理器的当前CPL,当处理器从一个特权级的代码段转移到另一个特权级的代码段上执行时,由于两个代码段的特权级不一样,处理器的CPL发生了变化。如int,call等使CS和EIP的值改变。
一致性代码段
执行高特权级代码段上的代码,又不提升特权级,可以利用一致性代码段。在段描述符中C为1时则表示该段是一致性代码段。一致性代码段即如果自己是转移后的目标段,自己的DPL要大于等于转移前的CPL,即数值上CPL>=DPL,转移后的特权级与转移前的低特权级一致,并不会将CPL用该目标端段的DPL替换。
处理器通过门结构才能由低特权级转移到高特权级。除了段描述符,还有一种门描述符,它描述一段程序,进入这种神奇的门,处理器便能转移到更高的特权级上。分别是任务门,调用门,中断门,陷阱门。现代操作系统很少用调用门跟任务门。
中断门:以 int 指令主动发中断的形式实现从低特权级向高特权级转移,Linux系统调用便使用此中断门实现。
陷阱门:供调试器使用,支持应用程序的调试。
使用门结构进入高特权级是CPU硬件电路里写好的规则。门的门槛是访问者特权级的下限,CPL必须比门描述符的DPL低,即数值上CPL<=DPL,否则连门都进不去。门的门框是访问者权限的上限,门所包含的目标程序段的DPL要大于等于CPL,即数值上CPL>=DPL,进门以后,处理器以目标代码段DPL为当前特权级CPL。可以把门类比为蹦床。我们的特权级要大于蹦床(即描述符)才能跳到蹦床上,利用蹦床,我们可以达到比原来更高的高度(特权级提升),但高度不能比原来的低(即CPL>=DPL),否则也没有意义(如果特权级比原来低,那我们就变成了从高特权级到低特权级,不符合门结构使用规则)。
特权级检查的实质
CPL<=DPL&&RPL<=DPL,工程师给CPU设置的规则。为什么需要RPL呢?CPL是我当前的特权级,DPL是我要访问的目标的特权级,使用这两个不就可以判断了吗?如果说,只使用DPL与CPL的话,会有这样一种情况发生:用户利用门进入到了0特权级,通知操作系统从硬盘读数据写入某地址,如果用户传入的参数中存在有地址是0特权级内核空间,由于0特权级是至高无上的,所以处理器是有这种资格的,说不定会破坏内核数据段。内核程序只是代替用户程序来拿数据的,资源的真正请求者是用户程序。RPL(Request Privilege Level)完美的解决这个问题。在请求特权级为DPL的资源时,需要数值上CPL>=DPL&&RPL<=DPL。当用户申请系统调用,如果提交了选择子作为参数,选择子的RPL会被操作系统修改为用户进程的CPL,此时RPL代表真正访问者的CPL。
中断
操作系统是中断驱动的,没有中断,操作系统什么都做不了。操作系统由事件驱动,而这个事件就是以中断的形式通知操作系统。所谓的中断,即CPU暂停正在进行的程序,转而去执行处理发出中断信号的事件的程序,执行完毕后再继续执行之前的程序,这一过程称为中断处理。
中断分为外部中断和内部中断。
外部中断:来自CPU外部的中断,比如网卡收到来自网络的数据包,这时网卡会通知CPU,CPU得到通知后将数据拷贝到内核缓冲区。CPU有两条接收中断的信号线:INTR和NMI。
从INTR引脚收到的中断为可屏蔽中断,可以随时处理,不影响系统运行,CPU可以不理会。如硬盘,网卡等发出的中断。
从NMI收到的中断为不可屏蔽中断,它表示系统发生致命错误,屏蔽不了,即将宕机。出现不可屏蔽中断基本可以认为用软件解决不了,多数属于硬件问题。
CPU收到中断后,相应的中断向量号也会通过上述信号线传入CPU,CPU再通过中断向量表(实模式下)或者中断描述符表(保护模式下)检索对应的中断处理程序去执行。
内部中断:分为软中断和异常。
软中断:软件主动引起的中断,并非内部错误。软件可通过 int 8位立即数 指令进行系统调用。
异常:CPU无法识别指令,或者除0错误等运行时错误。有的异常可以修复,称为故障。有的异常被称为陷阱,int3指令引发此类异常,中断处理程序返回后将执行导致异常指令的下一条指令。最严重的异常类型称为终止,操作系统为了自保,只能将此程序从进程表中去除,通常是硬件错误或者某些系统数据结构错误。
中断描述符表(IDT,Interrupt Descriptor Table)
在保护模式下存在。表中不仅仅有中断描述符,还可以有任务门描述符和陷阱门描述符,所以IDT中的描述符又称为门。CPU通过中断描述符表寄存器IDTR(Interrupt Descriptor Table Register)找到IDT的基地址。通过lidt指令加载IDTR.
中断处理过程处理及特权级保护
进入中断要把eflags寄存器中的NT位跟TF位置0,TF 即Trap Flag,不允许中断处理程序单步执行。
NT位即Nest Task Flag,任务嵌套标志位,当NT位为1时,说明当前程序是被嵌套执行的,它会从TSS中“上一个任务TSS的指针”去执行该任务。如果NT位为0,表示当前是中断处理环境,于是执行正常的中断退出流程。特权级保护参考之前特权级检查的实质。
可编程中断控制器PIC(Programmable Interrupt Controller)
专用的中断代理芯片,位于主板上的南桥芯片。它负责负责对接收到的中断信号进行中断屏蔽,维护一个队列,进行优先级判定,然后将最优先的中断信号和中断向量号发送给CPU。OS开发者通过编程的方式操纵PIC。
时钟CP
分为内部时钟和外部时钟。
内部时钟
是一种工作节拍,由晶体振荡器震荡产生,简称晶振,它位于主板上。内部时钟由处理器固件决定,无法更改。CPU中的CP是最快的。
外部时钟
是指CPU与外部设备之间通信时采用的一种时序。计算机中每个芯片都有各自的时钟CP,他们各自独立,当他们通信时,怎么让他们既保持各自的内部时钟不变,又能够同步通信呢?外部设备相对于CPU来说是很慢的,在通信时,为了防止他们之间发生冲突,我们必须同步时钟。计算机使用一种叫定时计数器的硬件实现这一功能。
定时计数器
如常见的可编程定时计数器有Intel 8253,它采用倒计时的方式。基本原理是可以通过设定一个初值n,定时器有自己的时钟信号CLK,每收到一次脉冲信号。减法计数器就会将计数值-1,递减为0时,此定时器的输出引脚才从低电平变为高电平。此信号可以接在中断代理芯片的中断引脚上,所以此信号可以用来向处理器发出中断。它有6种工作方式,分别是计数中断方式,硬件可重出发单稳方式,比率发生器(分频器),方波发生器,软件触发选通,硬件触发选通。
进程与线程:
在任务调度器眼里,只有执行流是调度单元,而线程与进程则是我们的执行流。任务调度器公平地调度各种执行流,形成了并发。我们平时写的程序如果没有创建线程的话,就属于单线程进程。进程里面至少有一个线程。举个例子,社团举办一个活动,需要把任务分配给各部门(进程),而各部门的部长又把任务细分,分给他的干事(线程),干事可多可少,但至少得有一个人才行,这样部门才有存在的必要。否则没有人来干活,部门也就不存在。
有了线程之后,进程得到了提速。假如原先任务调度器有进程A和进程B,进程A没有创建线程的话,调度一圈进程A执行1次。如果进程A显示创建了3个线程,由于这3个线程都是为进程A服务,那么调度一圈后,进程A相当于被处理器执行了4次,而进程B只执行了1次。CPU的利用率大大提高。线程另一个作用是避免阻塞,假设我们有一个等待用户输入的操作,我们为它单独创建线程,这样此进程的另一线程还能继续运行做其他事,相当于给进程提速。
线程是什么?线程是具有能动性,执行力,独立的代码块。
进程是什么?进程=线程+资源。
程序控制块PCB(Process Control Block)
操作系统为每一个进程提供了一个程序控制块PCB(Process Control Block),用来记录进程相关信息,比如进程状态,PID,优先级等,每个进程都有一个PCB,所有PCB在一张表中维护,即进程表。PCB没有具体的格式,取决于操作系统的功能复杂度。
同步机制——锁
进程里的所有线程共享自己的地址空间,也即是说线程可以访问同一进程里其他线程的数据。假如线程A读取了某对象的实例数据data并打算对data进行修改,而线程B在线程A修改数据之前,读取了数据,并用这个数据来进行其他操作,这时的数据被称为脏数据,使用脏数据进行的操作有可能是不正确的。线程A对data的读取,修改,写入这三个步骤必须像原子一样不可分割,也就是说具有原子性,才能防止脏数据的产生。在程序中,对共享变量的使用一般遵循一定的模式,即读取、修改和写入三步。之前碰到的问题是,这三步执行中可能线程执行切换,造成非原子操作。利用同步机制——锁,我们可以把这三步变成一个原子操作。其实现方式有多种:其中一种是通过在原子性代码的前面关闭中断,阻止其他线程的调度,在原子性代码的后面打开中断,允许其他线程的调度。进程切换和线程切换其实是由系统的时钟中断引发的,关了中断就不会发生进程或线程切换,起到加锁的作用。目前CPU本身实现 将这三步 合起来 形成一个原子操作,无需线程锁机制干预,常见的指令是CAS(compare and set)和TAS(test and set),是一种加锁的原子操作指令。如果是多核,硬件还提供了锁内存总线的机制,确保同时只有一个核使用test and set指令。
信号量
是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。
信号量的实现包括两个微操作,这两个微操作必须是原子操作。
Up:将信号量的值加1,唤醒在此信号量上等待的线程。
Down:判断信号量是否大于0,若大于0,则将信号量减1,若等于0,则阻塞自己,以在此信号量上等待。
在二元信号量(初始值为1)中,大致流程如下:
线程A进入临界区之前先通过down操作获得锁,信号量的值变为0。
线程B进入临界区,通过down操作,发现信号量为0,阻塞自己,在此信号量上等待。
线程A 从临界区出来后释放锁,信号量变为1,之后将线程B唤醒。
线程B获得锁,进入临界区。
键盘输入原理
键盘是个独立的设备,在它内部有个叫作键盘编码器的芯片,通常是 Intel 8048 或兼容芯片,每当键盘上发生按键操作,它就向键盘控制器报告哪个键被按下,按键是否弹起。
键盘控制器在主机内部的主板上,通常是 Intel 8042 或兼容芯片,,也就是键盘的 IO 接口,它的作用是接收来自键盘编码器的按键信息,将其解码后保存,然后向中断代理发中断,之后处理器执行相应的中断处理程序读入 8042 处理保存过的按键信息。键盘中断处理程序由程序员负责编写。 一个键的状态要么是按下,要么是弹起,因此一个键便有两个编码,按键被按下时的编码叫通码,也就是表示按键上的触点接通了内部电路,使硬件产生了一个码,故通码也称为 makecode。按键在被按住不 松手时会持续产生相同的码,直到按键被松开时才终止,因此按键被松开弹起时产生的编码叫断码,也就是 电路被断开了,不再持续产生码了,故断码也称为 breakcode 。一个键的扫描码是由通码和断码组成的。程序员利用扫描码,编写出每个键的输出显示以及组合键的产生的显示效果。
TSS(Task State Segment)
任务状态段,用来表示任务。TSS有三组栈,SS0,esp0,SS1,esp1,SS2,esp2,是用来由低特权级进入高特权级时用的,CPU并不会主动更新栈指针,除非人为地进行改写,否则三组栈指针将一成不变。TSS也有自己的描述符,需要定义在GDT中。
每个任务都有不同的TSS,在CPU眼里,任务切换的实质就是TR寄存器指向不同的TSS。然而Linux系统并未这么做,它为每个CPU创建了一个TSS,各个CPU上的所有任务共享同一个TSS,各CPU的TR寄存器保存各CPU的TSS,用ltr指令加载TSS后,TR寄存器永远指向同一个TSS,之后再也不会重新加载TSS,在进程切换时,只需要把TSS中的SS0及esp0更新为新任务的内核栈的段地址及栈指针。Linux系统只用到了3特权级的用户态和0特权级的内核态。CPU从用户态到内核态时,CPU自动从当前TSS获取SS0和esp0作为0特权级的栈,然后Linux执行一系列的push指令将任务的状态保存在0特权级栈中。这样实现的好处是任务切换的开销变小了。
文件系统
inode
硬盘是低速设备,其读写单位是扇区,为了避免频繁访问硬盘,操作系统往往等积累了一定数据量才一次性访问硬盘,这足够大小的数据就是块,它是扇区大小的整数倍。块是文件系统的读写单位。UNIX文件系统比较先进,文件中的块依然可以分散到不连续的零散空间中,它将文件以索引结构来组织,避免了访问某一数据块需要从头把其前面所有数据块遍历一次的缺点。文件系统为每一个文件的所有块建立了一个索引表,访问任意一个块,只需要从索引表获取块地址就可以了。包含此索引表的索引结构成为inode,即index node。一个文件必须对应一个inode,有多少文件就有多少inode。
间接块索引表
为了防止索引表变得过于庞大,每个索引表包含15个索引项,前12个索引项对应前12个块,若文件大于12块,则建立新的块索引表,容纳256个块的地址。新的块索引表的地址存储在第13个索引项中,叫做一级间接块索引表。可以储存12+256.如果仍然不够用,可以建立二级间接块索引表(地址储存在第14个索引项中),在其中储存一级块索引表,可以储存12+256+256*256个块。再不够,可以建立三级间接块索引表(地址储存在第15个索引项中),文件可达12+256+256*256+256*256*256个块。在inode结构中,还储存了权限,时间,文件拥有者等信息。
目录:
文件肯定要位于哪个目录中,因此文件名应该存储在与目录相关的地方。在Linux中,目录也是文件,它是包含文件的文件,所以它也用inode来表示。
目录项:
由于在inode中并不包含文件名,我们需要一种映射关系,把文件名映射到对应的inode,这就是目录项的作用。它包含inode编号,文件名,文件类型(是目录还普通文件)。
超级块:
在为分区创建文件系统时创建的文件系统元信息的“配置文件”,含有魔数,数据块数量,inode数量,分区起始扇区地址,空闲块位图地址,空闲块位图大小,inode位图地址,inode位图大小,inode数组地址,inode数组大小,根目录地址,根目录大小,空闲块起始地址……
文件系统梳理
①每个文件都有自己单独的inode, inode 是文件实体数据块在文件系统上的元信息。
②所有文件的 inode 集中管理,形成 inode 数组,每个 inode 的编号就是在该 inode 数组中的下标。
③inode 中的前 12 个直接数据块指针和后 3 个间接块索引表用于指向文件的数据块实体。
④文件系统中并不存在具体称为“目录”的数据结构,同样也没有称为“普通文件”的数据结构,统 一用同一种 inode 表示。inode 表示的文件是普通文件,还是目录文件,取决于 inode 所指向数据块中的实际内容是什么,即数据块中的内容要么是普通文件本身的数据,要么是目录中的目录项。
⑤目录项仅存在于 inode指向的数据块中,有目录项的数据块就是目录,目录项所属的 inode 指向的所有数据块便是目录。
⑥目录项中记录的是文件名、文件 inode 的编号和文件类型,目录项起到的作用有两个, 一是粘合 文件名及 inode ,使文件名和 inode 关联绑定,二是标识此 inode 所指向的数据块中的数据类型(比如是普 通文件,还是目录,当然还有更多的类型)。
⑦ inode 是文件的“实质”,但它并不能直接引用,必须通过文件名找到文件名所在的目录项,然后 从该目录项中获得 inode 的编号,然后用此编号到 inode 数组中去找相关的 inode ,最终找到文件的数据块。
查找任意文件时,都直接到根目录的数据块中找相关的目录项,然后递归查找,最终可以找到任意子目录中的文件。