前言
操作系统是管理计算机硬件的一种大型软件,我们所有运行的日常软件都基于操作系统之上。操作系统本质上也是软件,也处处体现着软件设计的本质思想,比如:抽象,虚拟,中间层等。从其功能等几大部分来看,内存管理,进程管理,I/O设备,文件管理等等,都具有抽象和虚拟特征,并且操作系统本身就是一大中间层,介于应用软件和硬件中间,平滑我们与硬件的联系。
内存管理
对接物理层面
操作系统的底层与主存对接,对主存空间按页划分,每一页放在比如pageinfo的struct里,这样我们有了所有内存物理页的信息。在这一层面,操作系统着重于对内存的使用与分配,目的是使得内存得以充分使用,减少外部碎片(内部碎片问题由分页机制解决,最大内部碎片不会超过一页)。
与现实使用之间的矛盾
如果直接在实模式下编程,直接面对物理空间的话,可能会面临没有足够大的连续内存块的问题,实际上多个小的内存块加起来是够用的,这就意味着我们需要离散地去使用。虽然这样也并不难实现,但是对用户来说,空间的使用丧失逻辑性,我们使用一个连续的数组,顺序遍历时,地址可能要跳着走,会造成使用的不方便。
同时,还会面临安全问题,因为直接使用物理地址,意味着我们可以使用任意一块地址,这可能是别的用户的,或别的进程的,甚至可能是内核的。而操作系统在管理这些内存时,是根据内存空间合理分配的一块空间,并不会根据你是什么进程/用户/等情况来分配的,即第 i 块内存可能是进程P1的,而i + 1块可能是进程P9的。所以操作系统不会在此处标记属于谁的,这管理太麻烦了,而且这样设计不够单一性。
段页机制和虚拟地址
虚拟地址的设计,便是符合抽象,虚拟的。它是对内存空间的地址做了逻辑化的展现,背后与物理页的关联交给其映射机制,硬件上由MMU处理。比如一个4G的地址空间大概如下:
/*
* Virtual memory map: Permissions
* kernel/user
*
* 4 Gig --------> +------------------------------+
* | | RW/--
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* : . :
* : . :
* : . :
* |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~| RW/--
* | | RW/--
* | Remapped Physical Memory | RW/--
* | | RW/--
* KERNBASE, ----> +------------------------------+ 0xf0000000 --+
* KSTACKTOP | CPU0's Kernel Stack | RW/-- KSTKSIZE |
* | - - - - - - - - - - - - - - -| |
* | Invalid Memory (*) | --/-- KSTKGAP |
* +------------------------------+ |
* | CPU1's Kernel Stack | RW/-- KSTKSIZE |
* | - - - - - - - - - - - - - - -| PTSIZE
* | Invalid Memory (*) | --/-- KSTKGAP |
* +------------------------------+ |
* : . : |
* : . : |
* MMIOLIM ------> +------------------------------+ 0xefc00000 --+
* | Memory-mapped I/O | RW/-- PTSIZE
* ULIM, MMIOBASE --> +------------------------------+ 0xef800000
* | Cur. Page Table (User R-) | R-/R- PTSIZE
* UVPT ----> +------------------------------+ 0xef400000
* | RO PAGES | R-/R- PTSIZE
* UPAGES ----> +------------------------------+ 0xef000000
* | RO ENVS | R-/R- PTSIZE
* UTOP,UENVS ------> +------------------------------+ 0xeec00000
* UXSTACKTOP -/ | User Exception Stack | RW/RW PGSIZE
* +------------------------------+ 0xeebff000
* | Empty Memory (*) | --/-- PGSIZE
* USTACKTOP ---> +------------------------------+ 0xeebfe000
* | Normal User Stack | RW/RW PGSIZE
* +------------------------------+ 0xeebfd000
* | |
* | |
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* . .
* . .
* . .
* |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
* | Program Data & Heap |
* UTEXT --------> +------------------------------+ 0x00800000
* PFTEMP -------> | Empty Memory (*) | PTSIZE
* | |
* UTEMP --------> +------------------------------+ 0x00400000 --+
* | Empty Memory (*) | |
* | - - - - - - - - - - - - - - -| |
* | User STAB Data (optional) | PTSIZE
* USTABDATA ----> +------------------------------+ 0x00200000 |
* | Empty Memory (*) | |
* 0 ------------> +------------------------------+ --+
*
从上至下大致为:内核区,cpu对应的内核栈,与用户公共访问的区域(mmio,页表,进程等),用户区域(栈,堆,data,bss,text等)
可见其特征是符合逻辑的,是线性的。整个空间是分成一段段的,每个段的起始地址,和段长度需要记录,这就用到了段机制。
从段表到页表,再到虚拟地址,最后到物理地址的过程
linux的可执行文件一般是ELF格式,其逻辑上是按照段的方式存储的,data段,text段等等,每个段前有一个结构存储其长度,和加载的位置,即虚拟地址:
struct Proghdr {
uint32_t p_type;
uint32_t p_offset;
uint32_t p_va;
uint32_t p_pa;
uint32_t p_filesz;
uint32_t p_memsz;
uint32_t p_flags;
uint32_t p_align;
};
这些段按照上述信息,能放入恰当的虚拟地址位置,并且会把段信息记录在LDT表中
在进程运行时,会把这些段的选择子放入寄存器中,这些寄存器会在LDT中找出段描述符,其中会有段的起始地址(虚拟地址),之后根据段偏移组合成数据的虚拟地址,再通过页表机制,找到其对应的物理地址。
段页机制的安全性
以上的虚拟地址都只是对一个进程有用的,每个进程的虚拟地址空间是不同的(除了内核的部分),这个虚拟地址只会映射到 程序刚加载进来时分配的内存。此时虚拟地址空间就像一个中间层,使得物理地址空间对用户不透明,用户根本不能知道其他用户/进程的物理地址。
同时,段之间是被隔离开了,访问一个内存时,都要经过一个段选择+段偏移的过程,这个由操作系统来完成,使得用户不会用错段。
虽然好像用户可能会通过修改虚拟地址,轻易地去访问内核区,但是这在保护模式下,内核区需特权级0,才能访问,用户是访问不到的。同时页表项pte的最后12位是flag位,标记了用户的访问权限的。
中断与异常
这里的中断指硬件中断,异常是process exception,由cpu发出的中断。
虽然多核可以实现真正意义上的并行,但是我们同时需要运行的进程太多,可能上几千个,靠硬件是远远不够的,所以需要并发,而并发的关键就是中断。
硬件中断的意义是:响应硬件中断的请求,尽快让其运行,因为硬件是与cpu并行的。
异常分为:故障,陷阱,错误。除了错误,会终止进程外,其余的本质上是,在正常的任务运行过程中,出现了需要暂时停下来去处理的事情。
对于并发:并发就是为了能够让任务A停一下,再让B做一会,这肯定是需要中断了,比如典型地是用时钟中断,当一个进程时间片用完,便去切换下一个进程。
中断的过程
我们的外设,会经常发出中断的请求,目的是为了让其得到cpu的响应,来处理准备事务或读取结果等。
- 外设的端口上会有存放irq请求,其会通过总线发出,当其没有被屏蔽,且CPU来查询时,中断请求会正式发出:
- 随后便会进入排队器,进入排队器的都是有资格争用cpu的,这时候根据中断优先级,来选出“胜者”:
- 随后排队器的结果输出到中断向量形成部件,生成向量地址:
- 之后会在IDT表中找到对应的表项,其中有段选择符,offset,在GDT表中找到对应的段,然后根据offset找到入口地址;
这个地址便是handler的地址,在idt init的时候,用SETGATE设置好了idt中的表项,里面便存了handler的selector。 - 随后的代码流程:
- handler汇编内容:
#define TRAPHANDLER_NOEC(name, num) \
.globl name; \
.type name, @function; \
.align 2; \
name: \
pushl $0; \
pushl $(num); \
jmp _alltraps
这是定义handler的宏汇编内容,因为irq是没有错误号,的所以push了0
- 之后跳去_alltraps:
_alltraps:
pushl %ds
pushl %es
pushal
movl $GD_KD, %eax
movw %ax, %ds
movw %ax, %es
push %esp
call trap
主要是保存现场,并调用trap函数
- trap:如果是陷入指令,这就是从用户态陷入内核态的地方,之后会调用trap_dispatch函数
- trap_dispatch:会根据传入的trapframe里的trapno(中断号),来选择不同的中断处理函数
- 异常与系统调用
异常的处理与上述雷同,只不过少了前期硬件处理阶段,但会传入错误号。
而系统调用,是先进入syscall中断函数后,根据eax寄存器里的系统调用号选择不同的系统调用函数
进程管理
进程的切换,主要是由schedule通过算法得出下一个运行的进程,算法主要由Robin-Round,FIFO,Normal等。之后使用switch-to进行切换,在之前,会切换到内核栈,会由硬件push进cs, ip, ss, sp, eflag等寄存器值,之后由指令SAVE_ALL保存余下诸如通用寄存器等寄存器。随后该进程进入就绪队列,而新的进程进来,先进入其内核栈,将其保存的寄存器值pop到寄存器,即restore_all,然后硬件恢复保存的内容,最后切换至用户栈,进入用户态运行。
文件管理
VFS又是一种虚拟与抽象,并且是介于用户系统调用与文件操作之间的中间层。关系图如下:
VFS是所有文件系统所面对的一种接口,它提供了最一般的组织形式,具体数据结构如下:
- 系统里会有一个系统文件打开表,里面是用双链表将file对象连接起来,file里有文件的打开次数,读写位置,dentryfile_operations等。其中file_operations对应具体的文件操作实现。
- dentry是一个目录项,它有文件最基本的信息,以及inode的指针
- inode存放数据的地址等信息,如果这个文件是个目录,那么这个数据是其子目录/文件的目录项信息
- 一个进程里会有一个文件打开表,会用fd_array存放dentry的指针,而对应的fd文件描述符即fd_array的下标
- fs_struct存放文件系统的信息,如根目录的dentry,当前目录的dentry
驱动设备程序
使用设备
这里存在对文件的进一步抽象,即所有的设备都会用一个文件来代表。用操作文件的方法,实现对设备的操作,因为操作设备实际上就是对端口的编程,而这和对磁盘的操作如出一辙。
驱动程序
file的操作函数,需要驱动程序的进一步的解释,来让设备识别
案例
因为redis比较注重性能,下面都以它举例。
在redis里,它会频繁地计算时间用于不同用途,比如10s一次更新LRU时钟,更新日志,计算服务器上线时间等,这需要获取系统的时间,那么必然要需要使用系统调用,如上所述,系统调用要触发中断,中断需要保护现场,完了还要恢复现场,需要耗费一些时间,但如果频繁的使用势必影响高性能服务器的性能。
为了不那么频繁地使用系统调用,对于一些对精度要求不高的功能,redis采用了服务器时间缓存的方法,即100ms获取一下系统时间,存在unixtime里,当其他功能需要使用时,直接读取unixtime,而不是使用系统调用。
redis采用了单线程模型,主要是考虑了线程切换的代价,它采用了epoll/kqueue等多路复用的方法,去监听多个socket,而不是一个socket创建一个线程。考虑到linux里没有线程,它是用进程模拟的,所以可以从进程切换角度来解释。由上面的论述可得知,进程在切换时,需要保护现场,切换三次栈,第一次是pre进程进入内核栈,之后切换至next进程的内核栈,最后进入next的用户栈,等等,开销很大。一个redis服务器在使用时,可能会面临巨量的socket连接,这种用线程的方式无疑会严重影响性能,即使使用线程池,也只是省去创建和撤销线程的开销,切换线程代价并没有省去。所以redis用单线程,会大大提高性能表现。