操作系统是管理计算机硬件的一种大型软件,我们所有运行的日常软件都基于操作系统之上。操作系统本质上也是软件,也处处体现着软件设计的本质思想,比如:抽象,虚拟,中间层等。从其功能等几大部分来看,内存管理,进程管理,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的响应,来处理准备事务或读取结果等。
#define TRAPHANDLER_NOEC(name, num) \ .globl name; \ .type name, @function; \ .align 2; \ name: \ pushl $0; \ pushl $(num); \ jmp _alltraps
这是定义handler的宏汇编内容,因为irq是没有错误号,的所以push了0
_alltraps: pushl %ds pushl %es pushal movl $GD_KD, %eax movw %ax, %ds movw %ax, %es push %esp call trap
主要是保存现场,并调用trap函数
进程的切换,主要是由schedule通过算法得出下一个运行的进程,算法主要由Robin-Round,FIFO,Normal等。之后使用switch-to进行切换,在之前,会切换到内核栈,会由硬件push进cs, ip, ss, sp, eflag等寄存器值,之后由指令SAVE_ALL保存余下诸如通用寄存器等寄存器。随后该进程进入就绪队列,而新的进程进来,先进入其内核栈,将其保存的寄存器值pop到寄存器,即restore_all,然后硬件恢复保存的内容,最后切换至用户栈,进入用户态运行。
VFS又是一种虚拟与抽象,并且是介于用户系统调用与文件操作之间的中间层。关系图如下:
VFS是所有文件系统所面对的一种接口,它提供了最一般的组织形式,具体数据结构如下:
这里存在对文件的进一步抽象,即所有的设备都会用一个文件来代表。用操作文件的方法,实现对设备的操作,因为操作设备实际上就是对端口的编程,而这和对磁盘的操作如出一辙。
file的操作函数,需要驱动程序的进一步的解释,来让设备识别
因为redis比较注重性能,下面都以它举例。
在redis里,它会频繁地计算时间用于不同用途,比如10s一次更新LRU时钟,更新日志,计算服务器上线时间等,这需要获取系统的时间,那么必然要需要使用系统调用,如上所述,系统调用要触发中断,中断需要保护现场,完了还要恢复现场,需要耗费一些时间,但如果频繁的使用势必影响高性能服务器的性能。
为了不那么频繁地使用系统调用,对于一些对精度要求不高的功能,redis采用了服务器时间缓存的方法,即100ms获取一下系统时间,存在unixtime里,当其他功能需要使用时,直接读取unixtime,而不是使用系统调用。
redis采用了单线程模型,主要是考虑了线程切换的代价,它采用了epoll/kqueue等多路复用的方法,去监听多个socket,而不是一个socket创建一个线程。考虑到linux里没有线程,它是用进程模拟的,所以可以从进程切换角度来解释。由上面的论述可得知,进程在切换时,需要保护现场,切换三次栈,第一次是pre进程进入内核栈,之后切换至next进程的内核栈,最后进入next的用户栈,等等,开销很大。一个redis服务器在使用时,可能会面临巨量的socket连接,这种用线程的方式无疑会严重影响性能,即使使用线程池,也只是省去创建和撤销线程的开销,切换线程代价并没有省去。所以redis用单线程,会大大提高性能表现。