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