目录
1、概述
2、核心抽象及设计选型
2.1. 对进程和内核的抽象
2.2. 对进程地址空间的抽象
2.3. 支持可重入可抢占的内核
2.4. 放松管控与努力回收
2.5. 单块结构内核+动态加载模块
2.6. 为系统中的一切活动打拍子
2.7. 一切皆文件的理念
3、Linux整体架构模块说明
3.1. 内存管理子系统
3.2. 调度子系统
3.3. VFS虚拟文件子系统
3.4. 中断和异常体系
3.5. 磁盘高速缓存
3.6. 内存回收子系统
3.7. 对进程的抽象与管理
3.8. 进程地址空间
3.9. I/O体系结构和设备驱动程序模型
3.10. 网络子系统
4、总结
前言:Linux变得如此成功与流行,其在设计选型上的取舍是至关重要的,概括来说包括如下重要方面:单块大内核+动态加载模块;进程/内核模式设定,以轻量级进程作为基本的执行上下文;侧重基于分页方式构建进程的虚拟地址空间;支持内核的可重入可抢占;在资源(尤其是内存)使用分配时放松管控,并在必要时努力回收;通过时钟的周期性中断为系统中的一切活动打拍子,由此提供了分时抢占式调度的基础;一切皆文件的理念,以及构建于该理念之上的对外围IO设备的支持。这些设计选型使得Linux的内核更加精简,更加快速和稳定,并易于扩展和维护。我们接下来就要详细的分析一下Linux内核的核心抽象及设计选型、其整体架构及各个子系统模块的介绍。
操作系统(Operating System)是一种系统软件,其负责控制和管理整个计算机系统的硬件和软件资源,合理地组织调度计算机的工作和资源的分配,并为用户和其他软件提供方便的接口和环境。当前流行的操作系统包括:Windows、Unix及其众多的发行版和衍生版————其中最著名的就是Linux和MacOS了,另外还有SVR4、BSD以及Solaris等等。
从技术角度来说,Linux是一个真正的Unix内核,但它并不是一个完全的Unix操作系统,这是因为它不包含全部的Unix应用程序,诸如文件系统实用程序、窗口系统及图形化桌面、系统管理员命令、文本编辑程序、编译程序等等。各不同的Linux发行版会视自己的情况选择部分或全部补充这些应用程序。
Linux内核遵循IEEE POSIX(基于Unix的可移植操作系统)标准,其包括了现代Unix操作系统的全部特点,诸如虚拟存储、虚拟文件系统、轻量级进程、Unix信号量、进程间通信、支持对称多处理器(Symmetric Multiprocessor,SMP)系统等。
Linux的内核相较于其他商用Unix内核而言非常精小且紧凑,其主要目标是执行效率,这就不难理解其沿用Unix单块结构内核的设计选择了,Linus大神为此还跟Tanenbaum吵过一场著名的架。另外,Linus舍弃了很多商用系统中有可能会降低性能的设计选择,如STREAMS I/O。
任何计算机系统都应该包含一个被称为操作系统的基本程序集合,其中最重要的部分就是操作系统内核(kernel)。当操作系统启动时,内核被装入到RAM中,内核中包含了系统运行所必不可少的很多核心过程(procedure)。内核为系统中所有事情提供了主要功能,并决定高层软件的很多特性。上述定义理解起来还是有点太过抽象,那么操作系统内核到底需要做些什么呢?归根结底,一个操作系统需要完成的两个最主要目标是:
为了较好的完成这些任务,每种类型的操作系统根据侧重点不同都有其构建之上的一些核心抽象及设计选择。如下是一些对Linux内核整体实现至关重要的核心抽象及设计选择。
进程是Linux所提供的一种基本抽象,从内核的观点看,进程的目的就是担当分配系统资源(CPU时间,内存等)的实体。
一些操作系统允许所有的用户程序都直接与硬件部分进行交互(典型的例子是MS-DOS)。与此相反,Linux把与计算机物理组织相关的所有低层次细节都对用户运行的程序隐藏起来。当程序想使用硬件资源时,必须向操作系统发出一个请求。内核对这个请求进行评估,如果允许使用这个资源,那么,内核代表应用程序与相关的硬件部分进行交互。为了实施这种机制,现代操作系统依靠特殊的硬件特性来禁止用户程序直接与低层硬件部分进行交互,或者禁止直接访问任意的物理地址。特别是,硬件为CPU引入了至少两种不同的执行模式:用户程序的非特权模式和内核的特权模式。Linux把它们分别称为用户态(User Mode)和内核态(Kernel Mode)。
Linux的进程/内核模式假定:请求内核服务的进程使用所谓系统调用(system call)的特殊编程机制。每个系统调用都设置了一组识别进程请求的参数,然后执行与硬件相关的CPU指令完成从用户态到内核态的转换。只要进程发出系统调用,硬件就会把特权模式由用户态变成内核态,然后进程以非常有限的目的开始一个内核过程的执行。这样,操作系统在进程的执行上下文中起作用,以满足进程的请求。
换句话说,Linux以轻量级进程作为基本的执行上下文,而并不是什么专门的内核线程(Linux中内核线程的用处很特定且有限)。进程可以在用户态和内核态之间切换,当进程切换到内核态以后才可以执行一些需要特权的行为(例如与硬件部分交互)。虽然权限变了,但是执行该动作的主体仍为当前进程,而内核的主体只是一些等待被切换为内核态的轻量级进程调用的例程。
Linux以非常有限的方式使用分段,分段可以给每一个进程分配不同的线性地址空间,而分页可以把同一线性地址空间映射到不同的物理空间。与分段相比,Linux更喜欢使用分页方式,因此所有进程具有相同的线性地址空间,然后通过分页机制将各自的线性地址映射到不同的物理内存页框。
Linux的这种对进程地址空间的抽象以及侧重使用分页的方式,允许一系列的“好事”得以发生,概略描述如下:
内核控制路径(kernel control path)表示内核处理系统调用、异常或中断所执行的指令序列。在最简单的情况下,CPU从第一条指令到最后一条指令顺序地执行内核控制路径。而可重入及可抢占就是为了打破这种顺序执行内核控制路径的方式,从而允许CPU交错的执行内核控制路径。
内核可重入指的是:每个中断或异常都会引起一个内核控制路径,或者说代表当前进程在内核态执行单独的指令序列。例如:当I/O设备发出一个中断时,相应的内核控制路径的第一部分指令就是那些把寄存器的内容保存在内核堆栈的指令,而最后一部分指令就是恢复寄存器内容并让CPU返回到用户态的那些指令。内核控制路径可以任意嵌套;一个中断处理程序可以被另一个中断处理程序“中断”,因此引起内核控制路径的嵌套执行。其结果是,对中断进行处理的内核控制路径,其最后一部分指令并不总能使当前进程返回到用户态:如果嵌套深度大于1,这些指令将执行上次被打断的内核控制路径,此时的CPU依然运行在内核态。
内核可抢占指的是:如果进程正执行内核函数时----即它在内核态运行时,允许发生内核切换,那么这个内核就是抢占的。使内核可抢占的目的是减少用户态进程的分派延迟(dispatch latency),即从进程变为可执行状态到它实际开始运行之间的时间间隔。内核抢占对及时执行需要实时调度的任务(如:硬件控制器、环境监视器、电影播放器等等)是非常有好处的,因为它降低了这种进程被另一个运行在内核态的进程延迟的风险。
概括来说,当下述事件之一发生时,CPU会交错执行内核控制路径:
允许交错执行内核控制路径使得系统实现变得更复杂了,那么其能带来什么好处呢?Linux基于以下两个主要原因,选择支持交错执行内核控制路径:
总结一下就是:Linux通过支持可重入、可抢占的内核,结合对延迟函数(软中断和tasklets)的使用,几乎同时地改善了系统的响应时间、对外围设备的处理效率、以及整体任务操作的吞吐量。
Linux中有一点很有意思,在为用户态进程与内核分配动态内存时,所作的检查是马马虎虎的。比如,对单个用户所创建进程的RAM使用总量并不作严格检查(信号数据结构中维护的对进程的资源限制只针对单个进程);对内核使用的许多磁盘高速缓存和内存高速缓存大小也同样不作限制。
减少控制是一种设计选择,这使内核以最好的可行方式使用RAM。当系统负载较低时,RAM的大部分由磁盘高速缓存占用,正在运行的进程并不需要占据很多RAM。但是,当系统负载增加时,RAM的大部分则会被进程页占用,高速缓存会相应缩小而给后来的进程让出空间。
资源申请时放松管控,并在资源变得紧张之前努力回收,这其实是一种更加深奥且实现起来也更加复杂的设计思路,其核心目的在于最大可能的对资源进行有效使用。我们在有些系统的资源控制体系里也看到了类似的思路,例如分布式计算引擎presto对其资源组使用资源的控制,就采用了在申请资源时放松管控,如有必要则尽力尝试回收;允许某资源组在短期超额使用资源,但在一个较长的期限内,将实际资源的使用平滑到设定的限额内的思路。这是一种实现起来较复杂的思路,但是在全局层面上,在一个拉长的观测期限内,其对于资源的使用效率不是简单做严格管控的实现方案可比的。
大部分Unix内核是单块结构:每一个内核层都被集成到整个内核程序中,并代表当前进程在内核态下运行。单块大内核确实带来了复杂性,但其性能也不是微内核架构的实现可以相提并论的。为了达到微内核理论上的很多优点而又不影响性能,Linux内核提供了模块(module)。模块是一个目标文件,其代码可以在运行时链接到内核或从内核解除链接。与微内核操作系统的外层组件不同,模块不是作为一个特殊的进程执行的。相反,与任何其他静态链接的内核函数一样,它代表当前进程在内核态下执行。
基于定时中断为系统中的一切活动打拍子。Linux很多的行为都是基于周期性的定时中断发生的,说周期性的定时中断是Linux内核的心跳脉搏也不为过。
Linux在启动时会给PC的第一个PIT进行编程,使它以(大约)1000Hz的频率向IRQ0发出时钟中断,即每1ms产生一次时钟中断。这个时间间隔叫做一个节拍(tick),它的长度以纳秒为单位存放在tick_nsec变量中。在PC上,tick_nsec被初始化为999848ns。节拍为系统中的所有活动打拍子。
Linux会在定时中断处理程序中执行如下行为:
另外,了解Linux的实现细节就会知道,在中断处理程序退出时,会触发一系列可能的行为:例如检查并执行软中断、检查并传递信号、检查是否执行调度及进程切换等等,其是Linux一系列核心功能得以发生的基础机制。
在Linux中,将一切视为文件来进行管理,文件是由字节序列构成的信息载体。根据这一点,可以把I/O设备当作设备文件(device file)这种所谓的特殊文件来处理;因此,与磁盘上的普通文件进行交互所用的同一系统调用可直接用于I/O设备。例如,用同一write()系统调用既可以向普通文件中写入数据,也可以通过向/dev/lp0设备文件中写入数据从而把数据发往打印机。另外,特殊文件系统可以为系统程序员和管理员提供一种容易的方式来操作内核的数据结构并实现操作系统的特殊特征(例如我们熟悉的/proc文件系统)。
为了屏蔽不同文件系统的差异,并对内部提供统一的模型,Linux引入了虚拟文件系统VFS的概念。虚拟文件系统的作用是把表示很多不同种类文件系统的共同信息放入内核;其中有一个字段或函数来维护Linux所支持的所有实际文件系统提供的任何操作。对所调用的每个读、写或其他函数,内核都能把它们替换成支持本地Linux文件系统、NTFS文件系统,或者文件所在的任何其他文件系统的实际函数。其扩展性表现在能为各种文件系统提供一个通用的spi接口供其自行实现。
虽然设备文件也在系统的目录树中,但是它们和普通文件以及目录文件有根本的不同。当进程访问普通文件时,它会通过文件系统访问磁盘分区中的一些数据块;而在进程访问设备文件时,它只要驱动硬件设备就可以了。例如,进程可以访问一个设备文件以从连接到计算机的温度计读取房间的温度。为应用程序隐藏设备文件与普通文件之间的差异正是VFS的责任。
为了做到这点,VFS在设备文件打开时改变其缺省文件操作;因此,可以把设备文件的每个系统调用都转换成与设备相关的函数的调用,而不是对主文件系统相应函数的调用。与设备相关的函数对硬件设备进行操作以完成进程所请求的操作。
Linux是一个复杂的单块系统,其内部的核心模块和组件环环相扣共同对外支持了一个操作系统内核应该提供的功能,在学习Linux内核时,想要了解一个模块的机制往往需要先搞清楚其他若干模块的机制,这无疑让Linux内核的学习曲线非常陡峭,但我们还是应该立志攀上这座高峰。学习任何一个复杂系统的前提是首先从概貌轮廓上对其进行直观的了解,然后才能够分别研究各个具体模块。因此,在此尝试对Linux内核的整体结构进行一个概括绘制,如下图所示:
由于整体涉及的内容非常庞大,难免会有些省略或无法展开的地方,后续在分别介绍各个模块的时候再展开描述其细节。大体上划分,Linux内核涉及到的核心子模块包括内存管理子系统、调度子系统、VFS虚拟文件子系统、中断和异常体系、I/O体系结构、网络子系统、页面高速缓存及内存回收子系统。另外单纯的从静态上划分模块似乎并不能涵盖Linux内核的全部意义。在动态的概念上,Linux将一个个的用户程序的执行过程、对各种资源的争用及分配过程抽象为进程的概念加以维护和管理。因此可以从单个进程的角度来观察,Linux是如何对其进行组织的、如何维护其执行上下文、虚拟内存地址空间、执行调度等;其生命周期如何管理,如何支持信号、系统调用,以及进程间是如何通信的等等。因此,我们可以从两个不同角度来观察Linux内核:
--> 从内核整体角度观察:
--> 从进程的角度来观察:
接下来对涉及到的各个子系统做一个概要说明,后续会写一系列的文章来分别对其做详细的讲解。
Linux将RAM划分为两部分,其中若干兆字节专门用于存放内核映像(也就是内核代码和内核静态数据结构),RAM的其余部分称为动态内存,这不仅是进程所需的宝贵资源,也是内核本身所需的宝贵资源,这部分RAM会被用在以下三种可能的方面:
实际上,任何一个操作系统内核的核心都是内存管理。对于Linux而言,其内存管理是一个庞大的系统,涉及到很多个层面。在最底层,其基于硬件提供的分段与分页机制将物理内存划分为页框的概念进行管理;在此基础上将物理内存抽象为内存节点(支持NUMA)、内存管理区,为其实现了内核内存分配器(KMA)子系统,KMA子系统被用于满足系统中所有部分对实际物理内存的请求;然后,Linux基于自己对硬件分段和分页的使用(重点是分页),将进程和内核使用的内存空间抽象为虚拟地址空间,通过进程页表和内核页表将逻辑地址映射到实际申请到的物理内存上,并在用户虚拟地址空间的基础上实现了请求调页和写时复制机制等优化。最后,由于存在着高速缓存不断的将磁盘页数据缓存在RAM中,其并不会主动释放自己抓取的缓存页,因此迟早物理内存资源会被消耗殆尽,所以需要一种可以释放内存的机制。页框回收机制(PFRA)就是用来做这个的。这其中主要涉及到的四个子系统:内核内存分配器(KMA)子系统、进程虚拟内存子系统、高速缓存子系统、页框回收子系统(PFRA),其中的每一个都是设计相当精巧的复杂子系统,所以在此我们尽量将其区分开来描述,后续分别进行详细介绍。本节主要涉及到内核内存分配器(KMA)子系统。
内核内存分配器(Kernel Memory Allocator, KMA)是一个子系统,它试图满足系统中所有部分对物理内存的请求。其中一些请求来自内核其他子系统,它们需要一些内核使用的内存,还有一些请求来自于用户程序的系统调用,用来增加用户进程的地址空间。一个好的KMA应该具有下列特点:
Linux支持非一致内存访问(Non-Uniform Memory Access, NUMA)模型,在这种模型中,给定CPU对不同内存单元的访问时间可能不一样。系统的物理内存被划分为几个节点(node)。在一个单独的节点内,任一给定CPU访问页面所需的时间都是相同的。然而,对不同的CPU,这个时间可能就不同。对每个CPU而言,内核都试图把耗时节点的访问次数减到最少,这就要小心地选择CPU最常引用的内核数据结构的存放位置。因此Linux内核把物理内存划分为不同的内存节点,每个节点中的物理内存又可以分为几个管理区(Zone)。
对物理内存的请求分为两种类型:第一种是以页框为基本单位的大块内存申请与释放;第二种是对小内存区(小于一个页的内存单元,例如几十或几百个字节)的申请与释放。Linux的KMA对大块内存的申请与释放采用了伙伴系统算法;而对小块内存的申请与释放采用了Slab分配算法。
--> 对于连续页框组的内存分配请求
对连续页框组的内存分配是通过一种被称作分区页框分配器的内核子系统来完成的,它与物理内存及内存节点的拓扑关系及其主要组成如下图所示:
每个内存节点都有自己单独的分区页框分配器。其中,名为“管理区分配器”部分接收动态内存分配与释放的请求。在请求分配内存的情况下, 该部分会搜索一个能满足所请求的一组连续页框内存的管理区。在每个管理区内部,页框被名为“伙伴系统”的部分来处理。另外,为了达到更好的系统性能,一小部分页框被保留在高速缓存中用于快速地满足对于单个页框的分配请求。
--> 对于小块内存的分配
对于小块内存的分配是通过slab分配器来支持的。其组成如下图所示:
slab分配器把对象分组放进对象高速缓存中。每个对象高速缓存都是同种类型对象的一种“储备”,例如:用于分配文件对象的filp高速缓存、用于分配目录项对象的dentry_cache高速缓存、以及用于分配缓冲区页中的缓冲区首部对象的bh_cachep高速缓存等等。包含对象高速缓存的主内存区被划分为多个slab,每个slab由一个或多个连续的页框组成,这些页框中既包含已分配的对象,也包含空闲的对象。
当slab分配器创建新的slab时,它会依靠分区页框分配器来获得一组连续的空闲页框。
一般来说,CPU的个数总是有限的,因而只有少数几个进程能同时执行。操作系统中叫做调度程序(scheduler)的部分决定哪个进程当前能执行。调度器的任务是分配CPU运算资源,并权衡效率和公平性。调度算法必须实现几个互相冲突的目标:进程响应时间尽可能快,后台作业的吞吐量尽可能高,尽可能避免进程的饥饿现象,低优先级和高优先级进程的需要尽可能调和等等。决定什么时候以怎样的方式选择一个新进程运行的这组规则就是所谓的调度策略(scheduling policy)。当我们讨论对进程的调度时,一般会把进程分类为“I/O受限(I/O-bound)”和“CPU受限(CPU-bound)”两种。前者频繁地使用I/O设备,并花费很多时间等待I/O操作的完成;而后者则需要使用大量的CPU时间以进行计算。
在多用户系统中,操作系统需要记录下每个进程占有的CPU时间,并周期性地激活调度程序。因此,支持可抢占式内核,对一个系统的调度延时具有重要意义。在Linux2.6之前,一个进程进入内核态后,别的进程无法抢占,只能等其完成或退出内核态时才能抢占,这会带来严重的延时问题,Linux2.6开始支持内核态抢占。在Linux中,如果进程进入TASK_RUNNING状态,内核检查它的动态优先级是否大于当前正运行进程的优先级。如果是,则当前进程的执行被中断,并调用调度程序选择另一个进程运行(通常是刚刚变为可运行状态的那个更高优先级的进程)。当然,进程在它的时间片到期时也可以被抢占。此时,当前进程thread_info结构中的TIF_NEED_RESCHED标志被设置,以便时钟中断处理程序终止时调度程序被调用。被抢占的进程并没有被挂起,因为它还处于TASK_RUNNING状态,只不过不再使用CPU。
分时机制依赖于定时中断,因此对进程本身是透明的,不需要在程序中插入额外的代码来保证CPU分时。这也是Linux内核模块之间紧密耦合的一个典型代表,中断和定时机制直接涉入了很多其他模块。
我们可以把运行中的进程视为三种类型:
调度的公平性在于有区分度的公平,多媒体任务和数值计算任务对延时和限定性的完成时间的敏感度显然是不同的。为此,POSIX规定了操作系统必须实现以下调度策略(scheduling policies), 以针对上述任务进行区分调度:
这两个调度策略定义了对实时任务,即对延时和限定性的完成时间高敏感度的任务。前者提供FIFO语义,相同优先级的任务先到先服务,高优先级的任务可以抢占低优先级的任务;后者提供Round-Robin语义,采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,同样,高优先级的任务可以抢占低优先级的任务。不同要求的实时任务可以根据需要用 sched_setscheduler() API 设置策略。
此调度策略包含除上述实时进程之外的其他进程,亦称普通进程。采用分时策略,根据动态优先级(可用 nice() API设置)分配CPU运算资源。注意:这类进程比上述两类实时进程优先级低,换言之,在有实时进程存在时,实时进程优先调度。
实时进程的调度器比较简单且行为明确;而普通进程的调度器,则历经了一系列的演进,其中最为经典的就是O(1)调度器和CFS完全公平调度器。
--> O(1) 调度器
2.6时代开始支持。顾名思义,此调度器为O(1)时间复杂度。该调度器修正之前的O(n)时间复杂度调度器,以解决进程过多时的选择性能问题。为每一个动态优先级维护队列,从而能在常数时间内选举下一个进程来执行。这是一个通过使数据结构更复杂来改善性能的典型例子:调度程序的操作效率的确更高了,但运行队列的链表却为此而被拆分成140个不同的队列!在这种算法中,进程的优先级是动态的。调度程序跟踪进程正在做什么,并周期性地调整它们的优先级。在这种方式下,在较长的时间间隔内没有使用CPU的进程,通过动态地增加它们的优先级来提升它们。相应地,对于已经在CPU上运行了较长时间的进程,通过减少它们的优先级来处罚它们。
--> CSF调度器
其核心思想是完全公平性,即平等地看待所有普通进程,通过它们自身的行为将彼此区分开来,从而指导调度器进行下一个执行进程的选举。具体说来,此算法基于一个理想模型。想像你有一台具有无限个相同计算力CPU的机器,那么很容易做到完全公平:每个CPU上跑一个进程即可。但是,现实的机器CPU个数是有限的,超过CPU个数的进程数不可能完全同时运行。因此,算法为每个进程维护一个理想的运行时间,及实际的运行时间,这两个时间差值大的,说明受到了不公平待遇,更应得到执行。这种算法可以自然而然的区分交互式进程和批量式进程:交互式进程大部分时间在睡眠,因此它的实际运行时间很少,而理想运行时间是随着时间的前进而增加的,所以这两个时间的差值会变大。与之相反,批量式进程大部分时间在运行,它的实际运行时间和理想运行时间的差距就较小。因此,这两种进程就自然地被区分开来了。
Linux为其文件管理体系建立了一棵根目录为“/”的树。根目录包含在根文件系统中,在Linux中这个根文件系统通常就是Ext3或Ext4类型。其他所有的文件系统都可以被“安装”在根文件系统的子目录中。
虚拟文件系统的作用是把表示很多不同种类文件系统的共同信息放入内核;其中有一个字段或函数用来维护Linux所支持的所有实际文件系统提供的任何操作。对所调用的每读、写或其他函数,内核都能把它们替换成支持本地Linux文件系统、NTFS文件系统,或者文件所在的任何其他文件系统的实际函数。其扩展性表现在能为各种文件系统提供一个通用的spi接口。
VFS是应用程序和具体文件系统之间的一层。不过,在某些情况下,一个文件操作可能由VFS本身去执行,无需调用低层函数。例如,当某个进程关闭一个打开的文件时,并不需要涉及磁盘上的相应文件,因此VFS只需释放对应的文件对象。类似地,当系统调用lseek()修改一个文件指针,而这个文件指针是打开文件与进程交互所涉及的一个属性时,VFS就只需修改对应的文件对象,而不必访问磁盘上的文件,因此,无需调用具体文件系统的函数。从某种意义上说,可以把VFS看成“通用”文件系统,它在必要时依赖某种具体文件系统。
每个文件系统都实现了其自己的文件操作集合,执行诸如读写文件这样的操作。当内核将一个索引节点从磁盘装入内存时,就会把指向这些文件操作的指针放在file_operations结构中,而该结构的地址存放在索引节点对象的i_fop字段中。当进程打开这个文件时,VFS就用存放在索引节点中的地址初始化新文件对象的f_op字段,使得文件操作的后续调用能够使用这些函数。如果需要,VFS随后也可以通过在f_op字段存放一个新值而修改文件操作的集合。
VFS所隐含的主要思想在于引入了一个通用的文件模型(common file model),这个模型能够表示所有支持的文件系统。该模型严格反映传统Unix文件系统提供的文件模型。这并不奇怪,因为Linux希望以最小的额外开销运行它的本地文件系统。不过,要实现每个具体的文件系统,必须将其物理组织结构转换为虚拟文件系统的通用文件模型。
通用文件模型由下列对象类型组成:
VFS把每个目录看作由若干子目录和文件组成的一个普通文件。然后,一旦目录项被装入内存,VFS就把它转换成基于dentry结构的一个目录项对象。对于进程查找的路径名中的每个分量,内核都为其创建一个目录项对象;目录项对象将每个分量与其对应的索引节点相联系。例如,在查找路径名/tmp/test时,内核为根目录“/”创建一个目录项对象,为根目录下的tmp项创建一个第二级目录项对象,为/tmp目录下的test项创建一个第三级目录项对象。注意,目录项对象在磁盘上并没有对应的映射,因此在dentry结构中不包含指出该对象已被修改的字段。
Linux内核不会对一个特定的函数进行硬编码来执行诸如read()或ioctl()这样的操作,而是对每个操作都必须使用一个指针,指向要访问的具体文件系统的适当函数(典型的面向对象及spi接口的概念)。我们在后面会看到,文件在内核内存中是由一个file数据结构来表示的。这种数据结构中包含一个称为f_op的字段,该字段中包含一个指向专门针对特定类型(如Ext2)文件的函数指针。简而言之,内核负责把一组合适的指针分配给与每个打开文件相关的file变量,然后负责调用针对每个具体文件系统的函数(由f_op字段指向)。因此,在Linux中可以将一切视为文件来进行管理。当网络和磁盘文件系统能够使用户处理存放在内核之外的信息时,特殊文件系统可以为系统程序员和管理员提供一种容易的方式来操作内核的数据结构并实现操作系统的特殊特征。另外,可以把I/O设备当作设备文件(device file)这种所谓的特殊文件来处理;因此,与磁盘上的普通文件进行交互所用的同一系统调用可直接用于I/O设备。例如,用同一write()系统调用既可以向普通文件中写入数据,也可以通过向/dev/lp0设备文件中写入数据从而把数据发往打印机。
虽然设备文件也在系统的目录树中,但是它们和普通文件以及目录文件有根本的不同。当进程访问普通文件时,它会通过文件系统访问磁盘分区中的一些数据块;而在进程访问设备文件时,它只要驱动硬件设备就可以了。例如,进程可以访问一个设备文件以从连接到计算机的温度计读取房间的温度。为应用程序隐藏设备文件与普通文件之间的差异正是VFS的责任。
为了做到这点,VFS在设备文件打开时改变其缺省文件操作;因此,可以把设备文件的每个系统调用都转换成与设备相关的函数的调用,而不是对主文件系统相应函数的调用。与设备相关的函数对硬件设备进行操作以完成进程的请求。
中断(interrupt)通常被定义为一个事件,该事件改变处理器执行的指令顺序。这样的事件与CPU芯片内外部硬件电路产生的电信号相对应。中断通常分为同步(synchronous)中断和异步(asynchronous)中断:
在Intel微处理器手册中,把同步和异步中断分别称为异常(exception)和中断(interrupt)。中断是由间隔定时器和I/O设备产生的。例如,用户的一次鼠标点击或者网络包到达网卡都会引起一个中断。另一方面,异常是由程序的错误产生的,或者是由内核必须处理的异常条件产生的。对于异常而言,在前一种情况下,内核通过发送一个信号来进行处理;在后一种情况下,内核会执行恢复异常需要的所有步骤,例如缺页,或对内核服务的一个请求(通过一条int或sysenter指令)。
中断提供了一种特殊的方式,使处理器转而去运行正常控制流之外的代码。当一个中断信号到达时,CPU必须停止它当前正在做的事情,并且切换到一个新的活动。为了做到这一点,就要在内核态堆栈保存程序计数器的当前值(即eip和cs寄存器的内容),并把与中断类型相关的一个地址放进程序计数器————cs是代码段寄存器,eip是程序计数器,两者共同决定了当前要执行指令的地址。
中断处理是由内核执行的最敏感的任务之一,因为它必须满足下列约束:
中断处理与进程切换有一个明显的差异:由中断或异常处理程序执行的代码不是一个进程。更确切的说,它是一个内核控制路径,以中断发生时正在运行进程的身份来执行。作为一个内核控制路径,中断处理程序比一个进程要“轻”(light)(中断的上下文很少,建立或终止中断处理需要的时间很少)。本质上说,中断或异常是在当前正在执行的进程上下文中以内核态执行的一段代码,也即是内核控制路径,并不涉及进程切换。系统调用可以视为其一种特殊形式,其被作为异常中的陷阱门来处理。
每个中断或异常都会引起一个内核控制路径,或者说代表当前进程在内核态执行单独的指令序列。例如:当I/O设备发出一个中断时,相应的内核控制路径的第一部分指令就是那些把寄存器的内容保存在内核堆栈的指令,而最后一部分指令就是恢复寄存器内容并让CPU返回到用户态的那些指令。内核控制路径可以任意嵌套;一个中断处理程序可以被另一个中断处理程序“中断”,因此引起内核控制路径的嵌套执行,如下图所示。其结果是,对中断进行处理的内核控制路径,其最后一部分指令并不总能使当前进程返回到用户态:如果嵌套深度大于1,这些指令将执行上次被打断的内核控制路径,此时的CPU依然运行在内核态。
基于以下两个主要原因,Linux交错执行内核控制路径:
每个能够发出中断请求的硬件设备控制器都有一条名为IRQ(Interrupt ReQuest)的输出线。所有现有的IRQ线(IRQ line)都与一个名为可编程中断控制器(Programmable Interrupt Controller, PIC)的硬件电路的输入引脚相连,可编程中断控制器执行下列动作:
a. 把接收到的引发信号转换成对应的向量。
b. 把这个向量存放在中断控制器的一个I/O端口,从而允许CPU通过数据总线读此向量。
c. 把引发信号发送到处理器的INTR引脚,即产生一个中断。
d. 等待,直到CPU通过把这个中断信号写进可编程中断控制器的一个I/O端口来确认它;当这种情况发生时,清INTR线。
中断处理涉及到的硬件及软件的结构体系如下图所示:
中断描述符表(Interrupt Descriptor Table, IDT)是一个系统表,它与每一个中断或异常向量相联系,每一个向量在表中有相应的中断或异常处理程序的入口地址。内核在允许中断发生前,必须适当地初始化IDT。IDT表中的每一项对应一个中断或异常向量,每个向量由8个字节组成。因此,最多需要256 * 8 = 2048字节来存放IDT。
idtr CPU寄存器使IDT可以位于内存的任何地方,它指定IDT的线性基地址及其限制(最大长度)。在允许中断之前,必须用lidt汇编指令初始化idtr。IDT包含三种类型的描述符,这些描述符是:
Linux利用中断门处理中断,利用陷阱门处理异常,利用任务门对“Double fault”异常(非预期的异常,说明内核发生了严重错误)进行处理。中断处理依赖于中断类型。需要重点关注的三种主要的中断类型如下:
不管引起中断的电路种类如何,所有的I/O中断处理程序都执行四个相同的基本操作:
每个进程的thread_info描述符与thread_union结构中的内核栈紧邻,而根据内核编译时的选项不同,thread_union结构可能占一个页框或两个页框。如果thread_union结构的大小为8KB,那么当前进程的内核栈被用于所有类型的内核控制路径:异常、中断和可延迟的函数。相反,如果thread_union结构的大小为4KB,内核就需要使用三种类型的内核栈:
--> 时钟中断
Linux给PC的第一个PIT进行编程,使它以(大约)1000Hz的频率向IRQ0发出时钟中断,即每1ms产生一次时钟中断。这个时间间隔叫做一个节拍(tick),它的长度以纳秒为单位存放在tick_nsec变量中。在PC上,tick_nsec被初始化为999848ns。节拍为系统中的所有活动打拍子。
如前所述,Linux内核会利用时钟中断周期性地执行如下行为:
另外,在中断处理程序退出时会引发一系列的行为:检查并执行软中断、检查信号、检查是否需要执行调度及进程切换等等,这是Linux一系列核心功能得以发生的前提。
--> 可延迟函数和工作队列
在由内核执行的几个任务之间有些不是紧急的:在必要情况下它们可以延迟一段时间。一个中断处理程序的几个中断服务例程之间是串行执行的,并且通常在一个中断的处理程序结束前,不应该再次出现这个中断。相反,可延迟中断可以在开中断的情况下执行。把可延迟中断从中断处理程序中抽出来有助于使内核保持较短的响应时间。这对于那些期望它们的中断能在几毫秒内得到处理的“急迫”应用来说是非常重要的。在Linux内核中,通过引入两种非紧迫、可中断内核函数来处理这种情况:可延迟函数(包括软中断与tasklets)以及放入工作队列中执行的函数。
软中断和tasklet有密切的关系,tasklet是在软中断机制之上实现的。tasklet是I/O驱动程序中实现可延迟函数的首选方法。tasklet建立在两个叫做HI_SOFTIRQ和TASKLET_SOFTIRQ的软中断之上。几个不同的tasklets可以与同一个软中断相关联,每个tasklet执行自己的函数。两个软中断之间没有真正的区别,只不过do_softirq()先执行HI_SOFTIRQ的tasklet,后执行TASKLET_SOFTIRQ的tasklet。
从Linux2.6开始引入了工作队列,用来代替任务队列。它们允许内核函数(非常像可延迟函数)被激活,而且稍后由一种叫做工作者线程(worker thread)的特殊内核线程来执行。
尽管可延迟函数和工作队列非常相似,但是它们的区别还是很大的。主要区别在于:可延迟函数运行在中断上下文中,而工作队列中的函数运行在进程上下文中(内核线程的进程上下文)。执行可阻塞函数(例如:需要访问磁盘数据块的函数)的唯一方式是在进程上下文中运行。因为,在中断上下文中不可能发生进程切换。可延迟函数和工作队列中的函数都不能访问进程的用户态地址空间。事实上,可延迟函数执行时并不能确定当前是哪个进程在运行。另一方面,工作队列中的函数是由内核线程来执行的,因此,根本不存在它要访问的用户态地址空间。
磁盘高速缓存是一种软件机制,它允许系统把通常存放在磁盘上的一些数据保留在RAM中,以便对那些数据的进一步访问不用再去访问磁盘,因而能尽快得到满足。
因为对同一磁盘数据的反复访问频繁发生,所以磁盘高速缓存对系统的性能至关重要。与磁盘交互的用户态进程很有可能反复请求读或写同一磁盘数据;此外,不同的进程可能也需要在不同的时间访问相同的磁盘数据。例如,你可以使用cp命令拷贝一个文本文件,然后调用你喜欢的编辑器修改它。为了满足你的请求,命令shell将创建两个不同的进程,它们在不同的时间访问同一个文件。
整体而言,Linux内核中的磁盘高速缓存包括如下几种:
其中,目录项高速缓存与索引节点高速缓存是在VFS虚拟文件系统中使用的数据结构,此处不再赘述。交换高速缓存是页框回收算法在执行交换(swap)时所依赖的一种以同步控制为主要目的的页框缓存机制,其底层实现技术基于页高速缓存。页高速缓存(page cache)是Linux内核所使用的主要磁盘高速缓存。在绝大多数情况下,内核在读写磁盘时都会引用页高速缓存。新页被追加到页高速缓存以满足用户态进程的读请求。如果页不在高速缓存中,新页就被加到高速缓存中,然后用从磁盘读出的数据填充它。如果内存有足够的空闲空间,就让页在高速缓存中长期保留,使其他进程再使用该页时不再访问磁盘。
同样,在把一页数据写到块设备之前,内核首先检查对应的页是否已经在高速缓存中;如果不在,就要先在其中增加一个新项,并用要写到磁盘中的数据填充该项。I/O数据的传送并不是马上开始,而是要延迟几秒之后才对磁盘进行更新,从而使进程有机会对要写入磁盘的数据做进一步的修改(通过执行延迟的写操作以提高性能)。
内核的代码和内核数据结构不必从磁盘读,也不必写入磁盘,因此,页高速缓存中的页可能是下面的类型:
考虑到页高速缓存的职责,内核设计者实现页高速缓存时主要需要满足下面两种需要:
如下图所示,页高速缓存的核心数据结构是address_space对象,它是一个嵌入在页所有者的索引节点对象中的数据结构。高速缓存中的许多页可能属于同一个所有者,从而可能被链接到同一个address_space对象。该对象还在所有者的页和对这些页的操作之间建立起链接关系。
address_space对象的关键字段是a_ops,它指向一个类型为address_space_operations的表,表中定义了对所有者的页进行处理的各种方法,这是用来区分对不同所有者的页执行不同处理逻辑的手段。其中比较最重要的方法包括readpage, writepage, prepare_write和commit_write等,在绝大多数情况下,这些方法把所有者的索引节点对象和访问物理设备的低级驱动程序联系起来。
Linux页高速缓存的整体结构如下图所示:
除了文件页缓存之外,Linux还支持以数据“块”为基本单位的块缓冲区。在较新的Linux内核中,会把这些块缓冲区存放在叫做“缓冲区页”的专门页中,而这些“缓冲区页”与普通文件页统一保存在页高速缓存中。缓冲区页会与被称作“缓冲区首部”的附加描述相关,其主要目的是快速确定页中的一个块在磁盘中的地址。实际上,页高速缓存内的页中的一大块数据在磁盘上的地址不一定是相邻的。其结构描述如下图所示:
内核不断用包含块设备数据的页填充页高速缓存。只要进程修改了数据,相应的页就被标记为脏页,即把它的PG_dirty标志置位。Linux允许把脏缓冲区写入块设备的操作延迟执行,因为这种策略可以显著地提高系统的性能。对高速缓存中的页的几次写操作可能只需对相应的磁盘块进行一次缓慢的物理更新就可以满足。此外,写操作没有读操作那么紧迫,因为进程通常是不会由于等待写的结果而挂起(进程实现写操作往往是异步的,因为本来就知道写比较慢),而大部分情况都因为等待读的结果而挂起。正是由于延迟写,使得任一物理块设备平均为读请求提供的服务将多于写请求。
由于延迟写策略,一个脏页可能直到最后一刻(即直到系统关闭时)都一直逗留在主存中。这样做虽然可以有效的提高性能,但它有两个主要的缺点:
因此,Linux必须以一定的策略将脏页刷新(写入)到磁盘,当前触发刷新的条件如下:
Linux会使用一组通用内核线程pdflush来系统地扫描页高速缓存以搜索要刷新的脏页,并保证所有的页不会“脏”太长的时间。
Linux在为用户态进程与内核分配动态内存时,所作的检查是马马虎虎的。例如,对单个用户所创建进程的RAM使用总量并不作严格检查;对内核使用的许多磁盘高速缓存和内存高速缓存大小也同样不作限制。减少控制是一种设计选择,这使内核以最好的可行方式使用可用的RAM:当系统负载较低时,RAM的大部分由磁盘高速缓存占用,这有助于对数据读写请求的响应时间;但是当系统负载开始增加,高速缓存就会缩小而给后来的进程让出空间,RAM的大部分则会由进程页占用。
我们在前面看到,内存及磁盘高速缓存抓取了那么多的页框但从未主动释放任何页框。如此设计是有其道理的,因为高速缓存系统并不知道进程是否(什么时候)会重新使用某些缓存的数据,因此不能确定高速缓存的哪些部分应该释放。此外,因为请求调页机制,只要用户态进程继续执行,它们就应该能获得页框;然而,请求调页并没有办法强制进程释放其不再使用的页框。
因此,迟早所有空闲内存将被分配给进程和高速缓存。Linux内核的页框回收算法(page frame reclaiming algorithm, PFRA)采取从用户态进程和内核高速缓存中“榨取”页框的方法补充伙伴系统的空闲块列表。
实际上,在真正用完所有空闲内存之前,就必须执行页框回收算法。否则,内核很可能陷入一种内存请求的僵局中,并导致系统崩溃。也就是说,要释放一个页框,内核就必须把页框的数据写入磁盘;但是,为了完成这一操作,内核却要请求另一个页框(例如,为I/O数据传输分配缓冲区首部)。因为不存在空闲页框,因此,就没有办法释放页框。因而,页框回收算法的目标之一就是保存最少的空闲页框池以便内核可以安全地从“内存紧缺”的情形中恢复过来。
如下是在设计PFRA算法时所遵循的几个总的原则:
因此,页框回收算法是几种启发式方法的混合:
PFRA用两种机制进行周期性回收:kswapd内核线程和cache_reap函数。前者调用shrink_zone()和shrink_slab()从LRU链表中回收页;后者则被周期性地调用以便从slab分配器中回收未用的slab。
尽管PFRA会尽量保留一定的空闲页框数,但虚拟内存子系统的压力还是可能变得很高,以至于所有可用内存都被耗尽。这很快会造成系统内的所有工作冻结。为满足一些紧迫请求,内核试图释放内存,但是无法成功,这是因为交换区已满且所有磁盘高速缓存已被压缩。因此,没有进程可以继续执行,也就没有进程会释放它所拥有的页框。为应对这种突发情况,PFRA使用所谓的内存不足(out of memory, OOM)删除程序,该程序选择系统中的一个进程,强行删除它并释放页框。OOM删除程序就像是外科大夫,为挽救一个人的生命而进行截肢。失去手脚当然是坏事,但这是不得已而为之。
--> 交换子系统
交换(swapping)用来为非映射页在磁盘上提供备份。有三类页必须由交换子系统处理:
就像请求调页,交换对于程序必须是透明的。换句话说,不需要在代码中嵌入与交换有关的特别指令。内核利用每个页表项中的Present标志来通知属于某个进程地址空间的页已被换出(页表项中Present标志被清零,但是其他高31位不全为0,则说明页已被换出)。在这个标志之外,Linux还利用页表项中的其他位存放换出页标识符(swapped-out page identifier)。该标识符用于编码换出页在磁盘上的位置。当缺页异常发生时,相应的异常处理程序可以检测到该页不在RAM中,然后调用函数从磁盘换入该缺页。
交换子系统的主要功能总结如下:
总之,交换是页框回收的一个最高级特性。如果我们要确保进程的所有页框都能被PFRA随意回收,而不仅仅是回收有磁盘映像的页,那么就必须使用交换。当然,你可以用swapoff命令关闭交换,但此时随着磁盘系统负载增加,很快就会发生磁盘系统瘫痪。
我们还可以看出,交换可以用来扩展内存地址空间,使之被用户态进程有效地使用。事实上,一个大交换区可允许内核运行几个大需求量的应用,它们的内存总需求量超过系统中安装的物理内存量。但是,就性能而言,基于交换区的RAM扩展肯定无法与RAM本身相比。进程对当前换出页的每一次访问,与对RAM中页的访问比起来,要慢几个数量级。简而言之,如果性能重要,那么交换仅仅作为最后一个方案;为了解决不断增长的计算需求增加RAM芯片的容量仍然是一个最好的方法。
进程是程序执行时的一个实例。你可以把它看作充分描述程序已经执行到何种程度的数据结构的汇集。进程类似于人类:它们被产生,有或多或少的生命,可以产生一个或多个子进程,最终都要死亡。
从内核的观点看,进程的目的就是担当分配系统资源(CPU时间,内存等)的实体。电脑从启动开始,CPU就要不停的执行,从内存中读取数据,执行计算,写入内存等等,至于执行的是什么进程什么代码,从硬件的角度来说不关心。这是作为硬件之上的创世者(同时也是运行于硬件这个模式之下的“应用”)操作系统要关心的事情。Linux内核对进程的维护工作包括:如何组织和维护进程(包括进程地址空间和执行上下文等)、进程间的关系、进程资源限制、进程的调度及切换、进程的创建和撤销、如何支持进程的信号及进程间通信等。
为了管理进程,内核必须对每个进程所做的事情进行清楚的描述。例如,内核必须知道进程的优先级,它是正在CPU上运行还是因为某些事件而被阻塞,给它分配了什么样的地址空间,允许它访问哪个文件等等。这正是进程描述符(process descriptor)的作用————进程描述符都是task_struct类型结构,它的字段包含了与一个进程相关的所有信息。如下图所示:
Linux使用轻量级进程(lightweight process)对多线程应用程序提供更好的支持。两个轻量级进程基本上可以共享一些资源,诸如地址空间、打开的文件等等。只要其中一个修改共享资源,另一个就立即可以查看这种修改。当然,当两个线程访问共享资源时必须同步它们自己。
进程链表把所有进程的描述符链接起来。进程链表的头是init_task描述符,它是所谓的0进程(process 0)或swapper进程的进程描述符。init_task的tasks.prev字段指向链表中最后插入的进程描述符的tasks字段。另外,程序创建的进程具有父/子关系。如果一个进程创建多个子进程时,则子进程之间具有兄弟关系。在进程描述符中引入几个字段来表示这些关系。进程0和进程1是由内核创建的;而进程1(init)是所有进程的祖先。Linux通过几个链表来维护进程之间的关系,如下图所示:
此外,Linux内核为处于阻塞状态的进程分别创建了专门的链表,叫做等待队列,它们会等待在不同的事件上。运行状态的进程会视具体的调度算法被组织在不同格式的运行队列中。例如,在O(1)调度算法中,运行状态的队列会被组织在CPU*140个运行队列中。
每个进程都有一组相关的资源限制,制定了进程能使用的系统资源数量(CPU、磁盘空间等),对当前进程的资源限制存放在current->signal->vlim字段(进程信号描述符中的一个字段)中。
进程切换只会发生在内核态。在执行进程切换之前,用户态进程使用的所有寄存器内容都已被保存在内核态堆栈上,包括ss和esp这对寄存器的内容。在每次进程切换时,被替换进程的硬件上下文必须保存在别处。不能像Intel原始设计的那样把它保存在TSS中。因此,每个进程描述符包含一个类型为thread_struct的thread字段,只要进程被切换出去,内核就把其硬件上下文保存在这个结构中。
当内核寻找一个新进程在CPU上运行时,必须只考虑可运行进程(即处在TASK_RUNNING状态的进程)。早先的Linux版本把所有的可运行进程都放在同一个叫做运行队列(runqueue)的链表中,由于维持链表中的进程按优先级排序开销过大,因此,早期的调度程序不得不为选择“最佳”可运行进程而扫描整个队列。后续为了改善调度的性能,提出的影响力比较大的算法为O(1)和CFS算法,参见上面对调度子系统的描述。
--> 进程执行上下文
尽管每个进程可以拥有属于自己的地址空间,但所有进程必须共享CPU寄存器。因此,在恢复一个进程的执行之前,内核必须确保每个寄存器装入了挂起进程时的值。另外,由于在Linux中TSS任务状态段是每CPU级别的,因此其起到的作用与寄存器类似,在每个进程恢复执行之前,同样需要用自己执行所需的一些上下文信息来填充它。
进程恢复执行前必须装入寄存器及TSS段的一组数据称为硬件上下文(hardware context)。硬件上下文是进程可执行上下文的一个子集,因为可执行上下文包含进程执行时需要的所有信息。在Linux中,进程硬件上下文的一部分存放在进程描述符持有的类型为thread_struct的thread字段中,而剩余部分存放在内核态堆栈中。
--> 信号
信号(signal)是很短的消息,可以被发送到一个进程或一组进程。发送给进程的唯一信息通常是一个数,以此来标识信号。在标准信号中,对参数、消息或者其他相随的信息没有给予关注。信号被用于在用户态进程间通信,内核也用信号通知进程系统所发生的事件。使用信号的两个主要目的是:
信号的一个重要特点是它们可以随时被发送给状态经常不可预知的进程。发送给非运行进程的信号必须由内核保存,直到进程恢复执行。阻塞一个信号(后面描述)要求信号的传递拖延,直到随后解除阻塞,这使得信号产生一段时间之后才能对其传递这一问题变得更加严重。因此,内核明确区分信号传递的两个不同阶段:
已经产生但还没有传递的信号称为挂起信号(pending signal)。任何时候,一个进程仅存在给定类型的一个挂起信号,同一进程同种类型的其他信号不被排队,只被简单地丢弃。但是,实时信号是不同的:同种类型的挂起信号可以有好几个。一般来说,信号可以保留不可预知的挂起时间。必须考虑下列因素:
尽管信号的表示比较直观,但内核的实现相当复杂。内核必须:
-> 目标进程没有被另一个进程跟踪(进程描述符中ptrace字段的PT_TRACED标志等于0)。
-> 信号没有被目标进程阻塞。
-> 信号被目标进程忽略(或者因为进程已显式地忽略了信号,或者因为进程没有改变信号的缺省操作且这个缺省操作就是“忽略”)。
内核用于实现信号的数据结构如下所述:
--> 系统调用
系统调用是通过软件中断向内核态发出一个明确的请求。当用户态的进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核函数。在80x86体系结构中,可以用两种不同的方式调用Linux的系统调用。两种方式的最终结果都是跳转到所谓系统调用处理程序(system call handler)的汇编语言函数。这两种方式分别为:
因为内核实现了很多不同的系统调用,因此进程必须传递一个名为系统调用号(system call number)的参数来识别所需的系统调用,eax寄存器就用作此目的。所有的系统调用都返回一个整数值。这些返回值与封装例程返回值的约定是不同的。在内核中,正数或0表示系统调用成功结束,而负数表示一个出错条件。在后一种情况下,这个值就是存放在errno变量中必须返回给应用程序的负出错码。内核没有设置或使用errno变量,而封装例程在系统调用返回之后设置这个变量。
系统调用处理程序与其他异常处理程序的结构类似,执行下列通用的步骤:
下图显示了调用系统调用的应用程序、相应的封装程序、系统调用处理程序及系统调用服务例程之间的关系。箭头表示函数之间的执行流。占位符“SYSCALL”和“SYSEXIT”是真正的汇编语言指令,它们分别把CPU从用户态切换到内核态和从内核态切换到用户态。
--> 进程间通信
通常,应用程序员有使用不同通信机制的各种需求,Linux提供的进程间通信的基本机制包括如下:
Linux提供了一种非常有用的抽象,叫虚拟内存(virtual memory)。虚拟内存作为一种逻辑层,处于应用程序的内存请求与硬件内存管理单元(MMU)之间。虚拟内存子系统的主要成分是虚拟地址空间(virtual address space)的概念。进程所用的一组内存地址不同于物理内存地址。当进程使用一个虚拟地址时,内核和MMU协同定位其在内存中的实际物理位置。现在的CPU包含了能自动把虚拟地址转换成物理地址的硬件电路。为了达到这个目标,可以把可用RAM划分成长度为4KB的页框(page frame),并且引入一组页表来指定虚拟地址与物理地址之间的对应关系。这些电路使得内存分配变得简单,因为一块连续的虚拟地址请求可以通过分配一组非连续的物理地址页框而得到满足。
虚拟内存可以带来很多用途和优点:
进程的地址空间(address space)由允许进程使用的全部线性地址组成。与进程地址空间有关的全部信息都包含在一个叫做内存描述符的数据结构中,其类型为mm_struct,由进程描述符的mm字段所指向。一个进程的地址空间被组织成很多互不交叠的线性区,每个线性区表示一个线性地址区间。进程所拥有的线性区从来不重叠,并且内核会尽力把新分配的线性区与紧邻的现存线性区进行合并。每个进程所看到的线性地址集合是不同的,一个进程所使用的地址与另外一个进程所使用的地址之间没有什么关系。线性区是由起始地址线性地址、长度和一些访问权限来描述的。为了效率起见,起始地址和线性区的长度都必须是4K的倍数、以便每个线性区所标识的数据可以完全填满分配给它的页框。
进程的线性区在运行过程中是会动态变化的,下面是进程获得新线性区的一些典型情况:
一个进程的地址空间、内存描述符及线性区链表之间的关系如下图所示:
出于性能考虑,Linux把内存描述符存放在叫做红黑树的数据结构中(后面介绍进程地址空间的专门文章中会详细介绍)。
另外,Linux采用了所谓请求调页(demand paging)的内存分配策略,其底层基于缺页异常处理机制。有了请求调页,进程可以在它的页还没有在内存时就开始执行。当进程访问一个不存在的页时,MMU产生一个异常;异常处理程序找到受影响的内存区,分配一个空闲的页,并用适当的数据把它初始化。请求调页可以极大的改善系统整体对于物理内存的使用情况,尤其是在结合了页框写时复制机制的情况下:一方面进程实际运行的过程并不会访问其地址空间中自己所申请的全部地址,而很可能只使用其中的一部分;另一方面对于大量只读的共享库,写时复制机制允许所有进程地址空间共享相同的物理页框。
因此,在请求调页机制下,当进程通过调用malloc()或brk()系统调用动态地请求内存时,内核仅仅修改进程的堆内存区的大小(相当于只是记录一下)。只有试图引用进程的虚拟内存地址而产生异常时,才给进程分配页框。
为了确保计算机能够正常工作,必须提供数据通路,让信息在连接到个人计算机的CPU、RAM和I/O设备之间流动。这些数据通路总称为总线,担当计算机内部主通信通道的作用。所有计算机都拥有一条系统总线,它连接大部分内部硬件设备。典型的情况是,一台计算机包括几种不同类型的总线,它们通过被称作“桥”的硬件设备连接在一起。两条高速总线用于在内存芯片上来回传递数据:前端总线将CPU连接到RAM控制器上,而后端总线将CPU直接连接到外部硬件的高速缓存上。主机上的桥将系统总线和前端总线连接在一起。下面这张图描述了现代处理器下PCI总线,内存总线和PCIe总线的整体拓扑关系:
CPU和I/O设备之间的数据通路通常称为I/O总线。每个I/O设备依次连接到I/O总线上,这种连接使用了包含3个元素的硬件组织层次:I/O端口、I/O接口和设备控制器。
系统设计者的主要目的是对I/O编程提供统一的方法,但又不能牺牲性能。为此,首先将每个设备的I/O端口组织成上图所示的一组专用寄存器。并在此基础上,提供了一些数据结构和辅助函数,它们为系统中所有的总线、设备以及设备驱动程序提供了一个统一的视图:设备驱动程序模型。与VFS的通用文件模型类似,设备驱动程序模型的引入旨在为内核屏蔽掉具体设备驱动程序的不同点,以一种通用的方式来处理形形色色的总线及设备。
设备驱动程序模型的核心数据结构叫做kobject,它与sysfs文件系统自然地绑定在一起:每个kobject对应于sysfs文件系统中的一个目录。kobject会被嵌入一个叫做“容器”的更大对象中,此处的“容器”便是被用于描述设备驱动程序模型中组件的对象,包括总线、设备以及驱动程序的描述符;例如,第一个IDE磁盘的第一个分区描述符对应于/sys/block/hda/hda1目录。设备驱动程序模型的结构如下图所示:
将一个kobject对象嵌入“容器”中允许内核:
Linux内核的早期版本为设备驱动程序的开发者提供微不足道的基本功能:分配动态内存,保留I/O地址范围或中断请求(IRQ),激活一个中断服务例程来响应设备的中断。现在的情形大不一样,诸如PCI/PCI-e这样的总线类型对硬件设备的内部设计提出了强烈的要求(类似于spi接口定义);因此,新的硬件设备即使类型不同但也有相似的功能。对这种设备的驱动程序需要特别关注:
设备驱动程序是内核例程的集合,它使得硬件设备能够响应控制设备的编程接口,而该接口是一组规范的VFS函数集(open, read, lseek, ioctl等等,一切皆文件的理念)。这些函数的实际实现由设备驱动程序全权负责。由于每个设备都有一个唯一的I/O控制器,因此就有唯一的命令和唯一的状态信息,所以大部分I/O设备都有自己的驱动程序。
设备驱动程序的种类有很多。它们在对用户态应用程序提供支持的级别上有很大的不同,也对来自硬件设备的数据采集有不同的缓冲策略。这些选择极大地影响了设备驱动程序的内部结构。
设备驱动程序并不仅仅是实现了设备文件操作的几个函数,其还需要包括对如下的几个必要行为的实现逻辑:
设备驱动程序最典型的两大类包括:字符设备驱动程序和块设备驱动程序。其中对块设备驱动程序的读写访问是内核工作中的要点和难点,其性能对操作系统至关重要,因此,Linux为块设备驱动程序专门引入了通用块层及IO调度程序层等,以优化对于块设备的访问。此外,页高速缓存的引入,主要也是为了改善对于块设备的访问性能。
网络子系统是Linux操作系统的核心组件之一,负责处理计算机网络相关的所有任务。它提供了一组API和协议,使Linux能够与各种网络设备、协议和服务进行通信。该子系统的主要组成部分包括:
Linux网络子系统的实现需要屏蔽协议、硬件、平台(API)的差异,因而其采用分层结构。在网络分层模型里,整个协议栈被分成了物理层、链路层、网络层,传输层和应用层。物理层对应的是网卡和网线,应用层对应的是我们常见的Nginx,FTP等等各种应用。Linux内核实现的是链路层、网络层和传输层这三层。
在Linux内核实现中,链路层协议靠网卡驱动来实现,网络层和传输层由内核协议栈实现。内核对更上层的应用层提供socket接口来供用户进程访问。整个网络子系统基于网络分层模型如下图所示。
在网络子系统中,为应用程序提供的协议无关接口是由socket层来实现的,其提供一组通用功能,以支持各种不同的协议。socket库会通过系统调用来实际访问内核。网络协议层为socket层提供proto协议接口并实现其具体细节,其抽象出一组通用spi函数供底层网络设备驱动程序实现。设备驱动与特定的网卡设备相关,约定具体的协议细节,并做特定的具体实现。
Linux操作系统是一个构建精巧,环环相扣的庞大单块系统,设计紧凑、复杂,且非常高效。它的很多设计理念、采用的数据结构以及算法会被各种类型各种层次的软件框架竞相参考学习采纳,最直接和类似的诸如作为虚拟机的jvm,其中的很多设计理念都直接借鉴了Linux;另外作为分布式计算引擎的presto,其整体的设计中也有非常多神似Linux内核的选型,因为虚拟机和数据库内核在某种程度上也可以被认为是一个“针对特定方面的操作系统”,如有机会笔者希望能专门写一些文件进行介绍和类比。
接下来,我们将沿着上述的整体架构图进行一番游览,逐个介绍其中涉及的各个子系统,深入其设计与实现的细节中,希望可以和大家一起彻底深入的了解作为计算机系统万物之源的操作系统内核————Linux!