第1章 简介和概述

235 阅读49分钟

1. 内核的任务

在纯技术层面上,内核是硬件与软件之间的一个中间层。其作用是将应用程序的请求传递给硬件, 并充当底层驱动程序,对系统中的各种设备和组件进行寻址。尽管如此,仍然可以从其他一些有趣的视角对内核进行研究。

  • 从应用程序的视角来看,内核可以被认为是一台增强的计算机,将计算机抽象到一个高层次上。例如,在内核寻址硬盘时,它必须确定使用哪个路径来从磁盘向内存复制数据,数据的位置,经由哪个路径向磁盘发送哪一条命令,等等。另一方面,应用程序只需发出传输数据 的命令。实际的工作如何完成与应用程序是不相干的,因为内核抽象了相关的细节。应用程 序与硬件本身没有联系,只与内核有联系,内核是应用程序所知道的层次结构中的最底层, 因此内核是一台增强的计算机。
  • 当若干程序在同一系统中并发运行时,也可以将内核视为资源管理程序。在这种情况下,内核负责将可用共享资源(包括CPU时间、磁盘空间、网络连接等)分配到各个系统进程,同时还需要保证系统的完整性。
  • 另一种研究内核的视角是将内核视为库,其提供了一组面向系统的命令。通常,系统调用用于向计算机发送请求。借助于C标准库,系统调用对于应用程序就像是普通函数一样,其调用方式与其他函数相同。

2. 实现策略

当前,在操作系统实现方面,有以下三种主要的范型。

2.1 微内核|Micro kernel (wiki

这种范型中,只有最基本的功能直接由中央内核(即微内核)实现。所有其他的功能都委托给一些独立进程,这些进程通过明确定义的通信接口与中心内核通信。例如,独立进程可能负责实现各种文件系统、内存管理等。(当然,与系统本身的通信需要用到最基本的内存管理功能,这是由微内核实现的。但系统调用层次上的处理则由外部的服务器进程实现。)

  • 优点:理论上,这是一种很 完美的方法,因为系统的各个部分彼此都很清楚地划分开来,同时也迫使程序员使用“清洁的”程序 设计技术。这种方法的其他好处包括:动态可扩展性和在运行时切换重要组件。
  • 缺点:由于在各个组件之间支持复杂通信需要额外的CPU时间,所以尽管微内核在各种研究领域早已经成为活跃主题,但在实用性方面进展甚微。

2.2 宏内核 | Monolithic kernel (wiki

与微内核相反,宏内核是构建系统内核的传统方法。在这种方法中,内核的全部代码,包括所有子系统(如内存管理、文件系统、设备驱动程序)都打包到一个可执行文件中。

宏内核被视作为运行在单一地址空间的单一的进程,内核提供的所有服务,都以特权模式,在这个大型的内核地址空间中运作,这个地址空间被称为内核态(kernel space)。它通常是以单一静态二进制文件的方式被存储在磁盘,或是缓冲存储器上,在开机之后被加载存储器中的内核态,开始运作。

  • 优点:在内核之中的通信成本很小,内核可以直接调用内核态内的函数,跟用户态的应用程序调用函数一样,因此它的性能很好。在1980年代之前,所有的操作系统都采用这个方式实现;即使到了现在,主要的操作系统也多采用这个方式。
  • 缺点:宏内核的移植性不佳,即使有的宏内核将其运作从整体性运作拆分成几个服务模块,并让各模块各自运作,其操作系统的代码依然是高度紧密的,很难修改成其他类型的操作系统架构。此外,所有的模块也都在同一块寻址空间内执行,倘若某个模块有错误、瑕疵(Bug),执行时就会损及整个操作系统运作

现在多数采行整块性架构设计的操作系统,如OpenVMSLinuxFreeBSD、以及Solaris等,都已经能在运作执行阶段中,以动态方式来加载(Load)、卸载(Unload)可执行的模块,不过这些模块是属于二进制代码的层次,或称映像层次,而非内核架构的层次。即使宏内核进行模块化转化,也不会与微内核或混合内核架构的内核产生区分上的混淆,因为微内核、混合内核的模块是属于系统架构的层次。

2.3 混合内核 |Hybrid kernel(wiki

混合核心的基本设计理念,是以微核心架构来设计操作系统核心,但在实现上则采用宏内核的做法。混合核心实质上是微核心,只不过它让一些微核结构执行在用户空间的代码执行在核心空间,这样让核心的执行效率更高些。这是一种妥协做法,设计者参考了微核心结构的系统执行速度不佳的理论。

大多数现代操作系统遵循这种设计范畴,是用微核心的概念来设计,但是实现上则采用宏内核的作法,结合宏内核(Monolithic kernel)与微核心(Micro kernel)两种设计方法,形成第三种分类。但是这种分类法受到一些批评,如林纳斯·托瓦兹认为这种分类只是一种市场营销手法,因为它的架构实现方式接近于宏内核。

最有名的混合核心为Windows NT核心与XNU

3. 内核的组成

本节概述了内核的各个组成部分,以及我们将在后续章节中详细研究的各个领域。尽管Linux是 整体式的宏内核,但其具有相当良好的结构。尽管如此,Linux内核各个组成部分之间的彼此交互是不可避免的。各部分会共享数据结构,而且与严格隔离的系统相比,各部分(因为性能原因)协同工作时需要更多的函数。

下图概述了组成完整Linux系统的各个层次,以及内核所包含的一些重要子系统。但要注意,各个子系统之间实际上会以各种方式进行交互,图中给出的只是其中一部分。

3.1 进程、进程切换、调度

传统上,UNIX操作系统下运行的应用程序、服务器及其他程序都称为进程。每个进程都在CPU 的虚拟内存中分配地址空间。各个进程的地址空间是完全独立的,因此进程并不会意识到彼此的存在。 从进程的角度来看,它会认为自己是系统中唯一的进程。如果进程想要彼此通信(例如交换数据), 那么必须使用特定的内核机制。

由于Linux是多任务系统,它支持(看上去)并发执行的若干进程。系统中同时真正在运行的进程数目最多不超过CPU数目,因此内核会按照短的时间间隔在不同的进程之间切换(用户是注意不到的),这样就造成了同时处理多进程的假象。这里有两个问题。

  • 进程上下文切换:内核借助于CPU的帮助,负责进程切换的技术细节。必须给各个进程造成一种错觉,即CPU总是可用的。通过在撤销进程的CPU资源之前保存进程所有与状态相关的要素,并将进程置于空闲状态,即可达到这一目的。在重新激活进程时,则将保存的状态原样恢复。进程之间的切换称之为进程切换。
  • 进程调度:内核还必须确定如何在现存进程之间共享CPU时间。重要进程得到的CPU时间多一点,次要进程得到的少一点。确定哪个进程运行多长时间的过程称为调度。

3.2 Unix进程

3.2.1 重量级进程(Unix进程)

Linux对进程采用了一种层次系统,每个进程都依赖于一个父进程。内核启动init程序作为第一个进程,该进程负责进一步的系统初始化操作,并显示登录提示符或图形登录界面(现在使用比较广泛)。因此init是进程树的根,所有进程都直接或间接起源自该进程,如下面的pstree程序的输出所示。其中init是一个树型结构的顶端,而树的分支不断向扩展。

该树型结构的扩展方式与新进程的创建方式密切相关。UNIX操作系统中有两种创建新进程的机制,分别是forkexec

  • fork : 可以创建当前进程的一个副本,父进程和子进程只有PID(进程ID)不同。在该系统调用执行之后,系统中有两个进程,都执行同样的操作。父进程内存的内容将被复制,至少从程序的角度来看是这样。Linux使用了一种众所周知的技术来使fork操作更高效,该技术称为写时复制(copy on write),主要的原理是将内存复制操作延迟到父进程或子进程向某内存页面写入数据之前,在只读访问的情况下父进程和子进程可以共用同一内存页。例如,使用fork的一种可能的情况是,用户打开另一个浏览器窗口。如果选中了对应的选项,浏览器将执行fork,复制其代码,接下来子进程中将启动适当的操作建立新窗口。
  • exec : 将一个新程序加载到当前进程的内存中并执行。旧程序的内存页将刷出,其内容将替换为新的数据。然后开始执行新程序。

3.2.2 轻量级进程(线程)

进程并不是内核支持的唯一一种程序执行形式。除了重量级进程(有时也称为UNIX进程)之外,还有一种形式是线程(有时也称为轻量级进程)。线程也已经出现相当长的一段时间,本质上一个进程可能由若干线程组成,这些线程共享同样的数据和资源,但可能执行程序中不同的代码路径。

线程 概念已经完全集成到许多现代编程语言中,例如C++/Java。简而言之,进程可以看作一个正在执行的程序,而线程则是与主程序并行运行的程序函数或例程。该特性是有用的,例如在浏览器需要并行加载若干图像时。通常浏览器只好执行几次fork和exec调用,以此创建若干并行的进程实例。这些进程负责加载图像,并使用某种通信机制将接收的数据提供给主程序。在使用线程时,这种情况更容易处理一些。浏览器定义了一个例程来加载图像,可以将例程作为线程启动,使用参数不同的多个线程即可。由于线程和主程序共享同样的地址空间,主程序自动就可以访问接收到的数据。因此除了为防止线程访问同一内存区而采取的互斥机制外,就不需要什么通信了。下图说明了有和没有线程的程序之间的差别

Linux用clone方法创建线程。其工作方式类似于fork,但启用了精确的检查,以确认哪些资源与父进程共享、哪些资源为线程独立创建。这种细粒度的资源分配扩展了一般的线程概念,在一定程度上允许线程与进程之间的连续转换。

3.2.3 命名空间

在内核2.6的开发期间,对命名空间的支持被集成到了许多子系统中。这使得不同的进程可以看到不同的系统视图。

  • 传统的Linux(与一般的UNIX操作系统)使用许多全局量,例如进程ID。系统中的每个进程都有一个唯一标识符(ID),用户(或其他进程)可使用ID来访问进程,例如向进程发一个信号。
  • 启用命名空间之后,以前的全局资源现在具有不同分组。每个命名空间可以包含一个特定的PID集合,或可以提供文件系统的不同视图,在某个命名空间中挂载的卷不会传播到其他命名空间中。

命名空间很有用处。举例来说,该特性对虚拟主机供应商是有益的。他们不必再为每个用户准备一台物理计算机,而是通过称为容器的命名空间来建立系统的多个视图。从容器内部看来这是一个完整的Linux系统,而且与其他容器没有交互。容器是彼此分离的。每个容器实例看起来就像是运行Linux的一台计算机,但事实上一台物理机器可以同时运转许多这样的容器实例。这有助于更有效地使用资源。与完全的虚拟化解决方案(如KVM)相比,计算机上只需要运行一个内核来管理所有的容器。

3.3 地址空间与特权级别

3.3.1 虚拟地址空间

  • 虚拟地址空间大小:由于内存区域是通过指针寻址,因此CPU的字长决定了所能管理的地址空间的最大长度。对32位系统(如IA-32、PPC、m68k),是2^32B=4GiB,对更现代的64位处理器(如Alpha、Sparc64、IA-64、AMD64),可以管理2^64B。地址空间的最大长度与实际可用的物理内存数量无关,因此被称为虚拟地址空间

  • 虚拟地址空间独占性:使用该术语的另一个理由是,从系统中每个进程的角度来看,地址空间中只有自身一个进程,而无法感知到其他进程的存在。应用程序无需关注其他程序的存在,好像计算机中只有一个进程一样。

  • 虚拟地址空间划分:Linux将虚拟地址空间划分为两个部分,分别称为内核空间用户空间,如下图所示:系统中每个用户进程都有自身的虚拟地址范围,从0到TASK_SIZE。用户空间之上的区域(从TASK_SIZE到232或264)保留给内核专用,用户进程不能访问。TASK_SIZE是一个特定于计算机体系结构的常数,把地址空间按给定比例划分为两部分。这种划分与可用的内存数量无关。由于地址空间虚拟化的结果,每个用户进程都认为自身有TASK_SIZE大小的内存。各个系统进程的用户空间是完全彼此分离的。而虚拟地址空间顶部的内核空间总是同样的,无论当前执行的是哪个进程。

    • 32位系统:例如在IA-32系统中,地址空间在3 GiB处划分,因此每个进程的虚拟地址空间是3 GiB。由于虚拟地址空间的总长度是4 GiB,所以内核空间有1 GiB可用。 尽管实际的数字依不同的计算机体系结构而不同,但一般概念都是相同的。
    • 64位系统:64位计算机的情况可能更复杂,因为它们在实际管理自身巨大的理论虚拟地址空间时,倾向于使用小于64的位数。实际使用的位数一般小于64位,如42位或47位。因此,地址空间中实际可寻址的部分小于理论长度。但无论如何,该值仍然大于计算机上实际可能的内存数量,因此是完全够用的。这种做法的一个优点是,与寻址完整的虚拟地址空间相比,管理有效地址空间所需的位数较少,因此CPU可以节省一些工作量。这样,虚拟地址空间会包含一些不可寻址的洞,所以上图的简单情况是不完全正确的。

3.3.2 特权级别

所有的现代CPU都提供了几种特权级别,进程可以驻留在某一特权级别。每个特权级别都有各种限制,例如对执行某些汇编语言指令或访问虚拟地址空间某一特定部分的限制。IA-32体系结构使用4种特权级别构成的系统,各级别可以看作是环。内环能够访问更多的功能,外环则较少,如下图所示:

尽管英特尔处理器区分4种特权级别,但Linux只使用两种不同的状态:核心态用户状态。两种状态的关键差别在于对高于TASK_SIZE的内存区域的访问。简而言之,在用户状态禁止访问内核空间。 用户进程不能操作或读取内核空间中的数据,也无法执行内核空间中的代码。这是内核的专用领域。这种机制可防止进程无意间修改彼此的数据而造成相互干扰。

  • 由系统调用进入内核态:从用户状态到核心态的切换通过系统调用的特定转换手段完成,且系统调用的执行因具体系统而 不同。如果普通进程想要执行任何影响整个系统的操作(例如操作输入/输出装置),则只能借助于系统调用向内核发出请求。内核首先检查进程是否允许执行想要的操作,然后代表进程执行所需的操作,接下来返回到用户状态。
  • 由异步硬件中断进入内核态:内核还可以由异步硬件中断激活,然后在中断上下文中运行。 与在进程上下文中运行的主要区别是,在中断上下文中运行不能访问虚拟地址空间中的用户空间部分。因为中断可能随机发生,中断发生时可能是任一用户进程处于活动状态,由于该进程基本上与中断的原因无关,因此内核无权访问当前用户空间的内容。在中断上下文中运行时,内核必须比正常情况更加谨慎,例如,不能进入睡眠状态。在编写中断处理程序时需要特别注意这些。

CPU大多数时间都在执行用户空间中的代码。当应用程序执行系统调用时,则切换到核心态,内核将完成其请求。在此期间,内核可以访问虚拟地址空间的用户部分。在系统调用完成之后,CPU切换回用户状态。硬件中断也会使CPU切换到核心态,这种情况下内核不能访问用户空间

3.3.3 内核线程

除了普通进程,系统中还有内核线程在运行。内核线程也不与任何特定的用户空间进程相关联,因此也无权处理用户空间。不过在其他许多方面,内核线程更像是普通的用户层应用程序。与在中断上下文运转的内核相比,内核线程可以进入睡眠状态,也可以像系统中的普通进程一样被调度器跟踪。

内核线程可用于各种用途:从内存和块设备之间的数据同步,到帮助调度器在CPU上分配进程。我们在本书中会经常遇到它们。

3.4 页表 | Page table(wiki

3.4.1 页、页帧

大多数情况下,单个虚拟地址空间就比系统中可用的物理内存要大。在每个进程都有自身的虚拟地址空间时,情况也不会有什么改善。因此内核和CPU必须考虑如何将实际可用的物理内存映射到虚拟地址空间的区域。可取的方法是用“页表” 来为物理地址分配虚拟地址。虚拟地址关系到进程的用户空间和内核空间,而物理地址则用来寻址实际可用的内存。

  • :进程的虚拟地址空间,都被内核划分为很多等长的部分。这些部分称之为“”。页则专指虚拟地址空间中的页。
  • 页帧:物理内存也划分为同样大小的页。物理内存页经常称作 “页帧”
  • 页大小:IA-32体系结构使用4 KiB大小的页。

上图箭头标明了虚拟地址空间中的页如何分配到物理内存页,两个进程的虚拟内存页存在以下三种情形:

  • 不同进程的同一虚拟地址实际上具有不同的含义: 进程A的虚拟内存页1映射到物理内存页4,而进程B的虚拟内存页1映射到物理内存页5。
  • 两个进程显式共享的页帧:进程A的页5和进程B的页1都指向物理页帧5。这种情况是可能的, 因为两个虚拟地址空间中的页(虽然在不同的位置)可以映射到同一物理内存页。由于内核负责将虚拟地址空间映射到物理地址空间,因此可以决定哪些内存区域在进程之间共享,哪些不共享。
  • 并非虚拟地址空间的所有页都映射到某个页帧:这可能是因为页没有使用,或者是数据尚不需要使用而没有载入内存中。还可能是页已经换出到硬盘,将在需要时再换回内存。

3.4.2 页表

用来将虚拟地址空间映射到物理地址空间的数据结构称为页表。这种数据结构的实现有多种形式

  • 数组:实现两个地址空间的关联最容易的方法是使用数组,对虚拟地址空间中的每一页,都分配一个数组项。该数组项指向与之关联的页帧,但有一个问题。例如,IA-32体系结构使用4 KiB页,在虚拟地址空间为4 GiB的前提下,则需要包含100万项的数组。在64位体系结构上,情况会更糟糕。每个进程都需要自身的页表,因此系统的所有内存都要用来保存页表,也就是说这个方法是不切实际的。

  • 多级分页:为减少页表的大小并容许忽略不需要的区域,计算机体系结构的设计会将 “虚拟地址” 划分为多个部分(具体在地址字的哪些位区域进行划分,可能依不同的体系结构而异,但这与现在我们讨论的内容不相关),如图所示:虚拟地址划分为4部分,这样就需要一个三级的页表。大多数体系结构都是这样的做法。但有一些采用了四级的页表,而Linux也采用了四级页表。为简化场景, 这里用三级页表阐述。

    • PGD: 虚拟地址的第一部分称为全局页目录(Page Global Directory,PGD)。PGD用于索引进程中的一个数组(每个进程有且仅有一个),该数组是所谓的全局页目录或PGD。PGD的数组项指向另一些数 组的起始地址,这些数组称为中间页目录(Page Middle Directory,PMD)
    • PMD: 虚拟地址中的第二个部分称为PMD,在通过PGD中的数组项找到对应的PMD之后,则使用PMD来索引PMD。PMD的数组项也是指针,指向下一级数组,称为页表或页目录。
    • PTE: 虚拟地址的第三个部分称为PTE(Page Table Entry,页表数组),用作页表的索引。虚拟内存页和页帧之间的映射就此完成,因为页表的数组项是指向页帧的。
    • Offset : 虚拟地址最后的一部分称为偏移量。它指定了页内部的一个字节位置。归根结底,每个地址都指向地址空间中唯一定义的某个字节。

页表的一个特色在于,对虚拟地址空间中不需要的区域,不必创建中间页目录或页表。与前述使用单个数组的方法相比,多级页表节省了大量内存。当然,该方法也有一个缺点。每次访问内存时,必须逐级访问多个数组才能将虚拟地址转换为物理地址。

3.4.3 转换过程

CPU试图用下面两种方法加速页表的地址过程:

  • CPU中有一个专门部分称为MMU(Memory Management Unit,内存管理单元),该单元优化了内存访问操作。
  • CPU的 内存管理单元MMU 存储最近用过的映射缓存,保存到称为地址转换后备缓冲器(Translation Lookaside Buffer,TLB)的CPU高速缓存中。无需访问内存中的页表即可从高速缓存直接获得地址数据,因而大大加速了地址转换。

完整的虚拟地址到物理地址转换过程如下图所示:

  • 首先搜索TLB。如果找到匹配(TLB命中),则返回物理地址并继续存储器访问。如果没有匹配(称为TLB未命中),则存储器管理单元或操作系统TLB未命中处理器通常会查找页表中的地址映射以查看是否存在映射(页面遍历)。

  • 如果页表查找成功,则将其写回TLB(这必须完成,因为硬件通过虚拟存储器系统中的TLB访问存储器),并且重启错误指令(这也可以并行发生)。此后续转换将找到TLB命中,并且内存访问将继续。

  • 如果页表查找失败,有两种原因:

    • 第一种,如果该地址没有可用的转换,这意味该虚拟地址的存储器访问是无效的。这通常是程序错误导致,操作系统需要处理这个问题。现代操作系统会发送一个段错误信号给出错程序。
    • 当物理内存中不存在这个页,也会引起分页表查找失败 (缺页异常,Page fault)。如果请求的页面被调出物理内存,给其他页腾出空间,会引起这个错误。这种情况下,页被分配到存储在介质上的辅助存储,例如硬盘。(这种辅助存储,或叫备用存储,如果是一个硬盘分区或者交换文件, 经常称之为交换分区,如果是文件,叫做分区文件或页文件)。当物理内存没满的时候,这是一个简单操作。页被写回物理内存,页表和转换备用缓冲会更新,指令重启。然而,当物理内存已满,一个或多个页要被调、为请求的页面腾出空间时候。页表需要更新,标识出那些在物理内存被调出的页,然后标识那些从硬盘调入物理内存的页。TLB也需要更新,包括去掉调出的页,重启指令。

3.5 内存映射

内存映射是一种重要的抽象手段。在内核中大量使用,也可以用于用户应用程序。映射方法可以将任意来源的数据传输到进程的虚拟地址空间中。作为映射目标的地址空间区域,可以像普通内存那样用通常的方法访问。但任何修改都会自动传输到原数据源。这样就可以使用相同的函数来处理完全不同的目标对象。例如,文件的内容可以映射到内存中。处理只需读取相应的内存即可访问文件内容,或向内存写入数据来修改文件的内容。内核将保证任何修改都会自动同步到文件中。

内核在实现设备驱动程序时直接使用了内存映射。外设的输入/输出可以映射到虚拟地址空间的区域中。对相关内存区域的读写会由系统重定向到设备,因而大大简化了驱动程序的实现。

3.6 物理内存分配

在内核分配内存时,必须记录页帧的已分配或空闲状态,以免两个进程使用同样的内存区域。由于内存分配和释放非常频繁,内核还必须保证相关操作尽快完成。

  • 内核可以只分配完整的页帧
  • 标准库将来源于内核的页帧拆分为小的区域,并为进程分配内存

3.6.1 伙伴系统

内核中很多时候要求分配连续页。为快速检测内存中的连续区域,内核采用了一种古老而历经检验的技术:伙伴系统。

  • 伙伴系统:内核将系统中的空闲内存块总是两两分组(意思是说空闲块的大小是2^n个物理页, 容易拆分成两个物理页数相等的内存块)。内核对所有大小相同的连续未分配内存块(1、2、4、8、16或其他数目的页),都放置到同一个列表中管理。

  • 内存分配过程

    • 如果系统现在需要8个页帧,则将16个页帧组成的块拆分为两个伙伴。其中一块用于满足应用程序的请求,而剩余的8个页帧则放置到对应8页大小内存块的列表中。
    • 如果下一个请求只需要2个连续页帧,则由8页组成的块会分裂成2个伙伴,每个包含4个页帧。其中一块放置回伙伴列表中,而另一个再次分裂成2个伙伴,每个包含2页。其中一个回到伙伴系统,另一个则传递给应用程序。
  • 内存释放过程:在应用程序释放内存时,内核可以直接检查地址,来判断是否能够创建一组伙伴,并合并为一个

    •   更大的内存块放回到伙伴列表中,这刚好是内存块分裂的逆过程。这提高了较大内存块可用的可能性。
  • 内存碎片的产生:在系统长期运行时,服务器运行几个星期乃至几个月是很正常的,许多桌面系统也趋向于长期开机运行,那么会发生称为碎片的内存管理问题。频繁的分配和释放页帧可能导致一种情况:系统中有若干页帧是空闲的,但却散布在物理地址空间的各处。换句话说,系统中缺乏连续页帧组成的较大的内存块,而从性能上考虑,却又很需要使用较大的连续内存块。通过伙伴系统可以在某种程度上减少这种效应,但无法完全消除。如果在大块的连续内存中间刚好有一个页帧分配出去,很显然这两块空闲的内存是无法合并的。

3.6.2 SLB缓存(SLB分配器)

内核本身经常需要比完整页帧小得多的内存块。由于内核无法使用标准库的函数,因而必须在伙伴系统基础上自行定义额外的内存管理层,将伙伴系统提供的页划分为更小的部分。该方法不仅可以分配内存,还为频繁使用的小对象实现了一个一般性的缓存——slab缓存。它可以用两种方法分配内存。

  • 对频繁使用的对象 内核定义了只包含了所需类型对象实例的缓存。每次需要某种对象时,可以从对应的缓存快速分配(使用后释放到缓存)。slab缓存自动维护与伙伴系统的交互,在缓存用尽时会请求新的页帧。
  • 对通常情况下小内存块的分配 内核针对不同大小的对象定义了一组slab缓存,可以像用户空间编程一样,用相同的函数访问这些缓存。不同之处是这些函数都增加了前缀k,表明是与内核相关联的:kmalloc和kfree。

虽然slab分配器在各种工作负荷下的性能都很好,但在真正规模庞大的超级计算机上使用时,出现了一些可伸缩性问题。另一方面,对真正微小的嵌入式系统来说,slab分配器的开销可能又太大了。 内核提供了slab分配器的两种备选方案,可用于在相应的场景下替换slab分配器并提供更好的性能。对内核的其他部分而言,这3种方案的接口相同,因而不必关注内核中实际编译进来的底层分配器是哪一个。由于slab分配仍然是内核的标准方法,因此我不会详细讨论备选方案。

3.7 页面交换 和 页面回收

  • 页面交换: 通过利用磁盘空间作为扩展内存,从而增大了可用的内存。在内核需要更多内存时,不经常使用的页可以写入硬盘。如果再需要访问相关数据,内核会将相应的页切换回内存。通过缺页异常机制,这种切换操作对应用程序是透明的。换出的页可以通过特别的页表项标识。在进程试图访问此类页帧时,CPU则启动一个可以被内核截取的 缺页异常 Page Fault。此时内核可以将硬盘上的数据切换到内存中。接下来用户进程可以恢复运行。由于进程无法感知到缺页异常,所以页的换入和换出对进程是完全不可见的。
  • 页面回收:用于将内存映射被修改的内容与底层的块设备同步,为此有时也简称为数据回写。数据刷出后,内核即可将页帧用于其他用途(类似于页面交换)。内核的数据结构包含了与此相关的所有信息,当再次需要该数据时,可根据相关信息从硬盘找到相应的数据并加载。

3.8 计时

内核必须能够测量时间以及不同时间点的时差,进程调度就会用到该功能。

  • jiffies全局变量:是一个合适的时间坐标,名为jiffies_64和jiffies(分别是64位和32位)的全局变量,会按恒定的时间间隔递增。每种计算机底层体系结构都提供了一些执行周期性操作的手段,通常的形式是定时器中断。对前述的两个全局变量的更新可使用底层体系结构提供的各种定时器机制执行。
  • jiffies递增的频率 同体系结构有关,取决于内核中一个主要的常数HZ。该常数的值通常介于100和1 000中间。换言之,jiffies的值每秒递增的次数在100至1 000次之间。 基于jiffies的计时相对粒度较粗,因为目前1 000 Hz已经算不上很高的频率了。在底层硬件能力允许的前提下,内核可使用高分辨率的定时器提供额外的计时手段,能够以纳秒级的精确度和分辨率来计量时间。
  • 计时的周期是可以动态改变的(省电) 在没有或无需频繁的周期性操作的情况下,周期性地产生定时器中断是没有意义的,这会阻止处理器降低耗电进入睡眠状态。动态改变计时周期对于供电受限的系统是很有用的,例如笔记本电脑和嵌入式系统。

3.9 系统调用

系统调用是用户进程与内核交互的经典方法。POSIX标准定义了许多系统调用,以及这些系统调用在所有遵从POSIX的系统包括Linux上的语义。传统的系统调用按不同类别分组,如下所示。

  • 进程管理:创建新进程,查询信息,调试。
  • 信号:发送信号,定时器以及相关处理机制。
  • 文件:创建、打开和关闭文件,从文件读取和向文件写入,查询信息和状态。
  • 目录和 文件系统:创建、删除和重命名目录,查询信息,链接,变更目录。
  • 保护机制:读取和变更UID/GID,命名空间的处理。
  • 定时器函数:定时器函数和统计信息。

在发出系统调用时,处理器必须改变特权级别,从用户状态切换到核心态。Linux对此没有标准化的做法,因为每个硬件平台都提供了特定的机制。有时候, 在同样的体系结构上也会根据处理器类型使用不同的方法实现。

  • 尽管Linux使用了一个专用软件中断在IA-32处理器上执行系统调用。
  • 而其他UNIX操作系统在IA-32上的软件仿真(iBCS仿真器)则采用了一种不同的方法来执行二进制程序(汇编语言爱好者会知道,是lcall7或lcall27调用门)。
  • IA-32 架构的现代处理器也提供了专用的汇编语句来执行系统调用。这在旧系统上是不可用的,因此无法用到所有计算机上。

对所有的处理器来说,一个共同点就是:用户进程要从用户状态切换到核心态,并将系统关键任务委派给内核执行,系统调用是必由之路。

3.10 设备驱动、块设备、字符设备

设备驱动程序

用于与系统连接的输入/输出装置通信,如硬盘、软驱、各种接口、声卡等。按照经典的UNIX箴言“万物皆文件”(everything is a file),对外设的访问可利用/dev目录下的设备文件来完成,程序对设备的处理完全类似于常规的文件。 设备驱动程序的任务在于支持应用程序经由设备文件与设备通信。换言之,使得能够按适当的方式在设备上读取/写入数据。

外设类型

  • 字符设备:提供连续的数据流,应用程序可以顺序读取,通常不支持随机存取。相反,此类设备支持按字节/字符来读写数据。举例来说,调制解调器是典型的字符设备
  • 块设备:应用程序可以随机访问设备数据,程序可自行确定读取数据的位置。硬盘是典型的块设备,应用程序可以寻址磁盘上的任何位置,并由此读取数据。此外,数据的读写只能以块(通常是512B)的倍数进行。与字符设备不同,块设备并不支持基于字符的寻址。

编写块设备的驱动程序比字符设备要复杂得多,因为内核为提高系统性能广泛地使用了缓存机制。

3.11 网络

网卡也可以通过设备驱动程序控制,但在内核中属于特殊状况,因为网卡不能利用设备文件访问。 原因在于在网络通信期间,数据打包到了各种协议层中。在接收到数据时,内核必须针对各协议层的处理,对数据进行拆包与分析,然后才能将有效数据传递给应用程序。在发送数据时,内核必须首先根据各个协议层的要求打包数据,然后才能发送。

为支持通过文件接口处理网络连接(按照应用程序的观点),Linux使用了源于BSD的套接字抽象。 套接字可以看作应用程序、文件接口、内核的网络实现之间的代理。

3.12 文件系统

  • 文件系统

Linux系统由数以千计乃至百万计的文件组成,其数据存储在硬盘或其他块设备(例如ZIP驱动、软驱、光盘等)。

  • 存储使用了层次式文件系统。文件系统使用 目录结构 ****组织存储的数据,并将其他元信息(例如所有者、访问权限等)与实际数据关联起来。

  • Linux支持许多不同的文件系统:标准的Ext2和Ext3文件系统、ReiserFS、XFS、VFAT(为兼容DOS),还有很多其他文件系统。不同文件系统所基于的概念抽象,在某种程度上可以说是南辕北辙。

    • Ext2基于inode 即它对每个文件都构造了一个单独的管理结构,称为inode,并存储到磁盘上。inode包含了文件所有的元信息,以及指向相关数据块的指针。目录可以表示为普通文件,其数据包括了指向目录下所有文件的inode的指针,因而层次结构得以建立。
    • ReiserFS基于树形结构: 相比之下,ReiserFS广泛应用了树形结构来提供同样的功能。
  • 虚拟 文件系统 ****VFS

内核必须提供一个额外的软件层,将各种底层文件系统的具体特性与应用层(和内核自身)隔离开来。该软件层称为VFS(Virtual Filesystem或Virtual Filesystem Switch,虚拟文件系统或虚拟文件系统交换器)。VFS既是向下的接口(所有文件系统都必须实现该接口),同时也是向上的接口(用户进程通过系统调用最终能够访问文件系统功能)。如图所示。

3.13 模块 和 热插拔

  • 模块的作用

    • 用于在运行时动态地向内核添加功能,如设备驱动程序、文件系统、网络协议等,实际上内 核的任何子系统几乎都可以模块化。这消除了宏内核与微内核相比一个重要的不利之处。 模块特性使得内核可以支持种类繁多的设备,而内核自身的大小却不会发生膨胀。在检测到连接的硬件后,只需要加载必要的模块,多余的驱动程序无需加入到内核。
    • 模块还可以在运行时从内核卸载,这在开发新的内核组件时很有用。
  • 模块的本质:模块在本质上不过是普通的程序,只是在内核空间而不是用户空间执行而已。

    • 模块必须提供某些代码段在模块初始化(和终止)时执行,以便向内核注册和注销模块。
    • 模块代码与普通 内核代码的权利(和义务)都是相同的,可以像编译到内核中的代码一样,访问内核中所有的函数和数据。
  • 对支持 热插拔 而言,模块是必需的:某些总线(例如,USB和FireWire)允许在系统运行时连接设备,而无需系统重启。在系统检测到新设备时,通过加载对应的模块,可以将必要的驱动程序自动添加到内核中。

3.14c 缓存

3.14.1 页缓存 |page cache(wiki)

内核使用缓存来改进系统性能。从低速的块设备读取的数据会暂时保持在内存中,即使数据在当时已经不再需要了。在应用程序下一次访问该数据时,它可以从访问速度较快的内存中读取,因而绕过了低速的块设备。由于内核是通过基于页的内存映射来实现访问块设备的,因此缓存也按页组织,也就是说整页都缓存起来,故称为页缓存(page cache)。

3.14.2 块缓存

块缓存用于缓存没有组织成页的数据,其重要性差得多。在传统的UNIX系统上,块缓存用作系 统的主缓存,而Linux很久以前也是这样。到如今,块缓存已经被页缓存取代了。

3.15 Linux中的重要数据结构

3.15.1 标准双链表 list_head

Linux内核程序中重复出现的一项任务是对双链表的处理。内核提供的标准链表可用于将任何类型的数据结构彼此链接起来。很明确,它不是类型安全的。 加入链表的数据结构必须包含一个类型为list_head的成员,其中包含了正向和反向指针。如果有若干链表涉及同一数据结构,这也是比较常见的情形,那么结构中就需要同样数目的list_head成员。

// 双向链表
struct list_head {
    struct list_head *next, *prev;
}

// 包含双向链表的对象
struct task_struct {
...
    struct list_head runlist;
...
}

链表的起点同样是list_head的实例,通常用LIST_HEAD(list_name)宏来声明并初始化。如图所示,内核建立了一个循环链表。这种链表的第一个和最后一个元素都能达到 (1)的访问时间,也就是说,不管链表的大小如何,访问这两个元素花费的时间是一个常数。

如果作为数据结构的成员,则struct list_head被称作链表元素。用作链表起点的元素被称作表头。有若干处理链表的标准函数:

  • list_add(new, head):用于现存的head元素之后,紧接着插入new元素。
  • list_add_tail(new, head):用于在head元素之前,紧接着插入new元素。如果指定head为表头,由于链表是循环的,那么new元素就插入到链表的末尾(该函数因此而得名)。
  • list_del(entry):从链表中删除一项。
  • list_empty(head):检测链表是否为空,也就是链表是否没有包含元素。
  • list_splice ( list, head):负责合并两个链表,把list插入到另一个现存链表的head元素之后。
  • list_entry(ptr, type, member):查找链表元素,ptr是指向数据结构中list_head成员实例的一个指针,type是该数据结构的类型, 而member则是数据结构中表示链表元素的成员名。如果在链表中查找task_struct的实例, 则需要下列示例调用:struct task_struct = list_entry(ptr, struct task_struct, run_list)。因为链表的实现不是类型安全的,所以需要显式指定类型。如果数据结构包含在多个链表中, 则必须指定所要查找的链表元素,才能找到正确的链表元素。
  • list_for_each(pos, head):用于遍历链表的所有元素。pos表示链表中的当前位置,而head指定了表头。

3.15.2 对象管理 kobject 和 引用计数

3.15.2.1 一般内核对象机制

内核中很多地方都需要跟踪记录C语言中结构的实例。尽管这些对象的用法大不相同,但各个不同子系统的某些操作非常类似,例如引用计数。这导致了代码复制。由于这是个糟糕的问题,因此在内核版本2.5的开发期间,内核采用了一般性的方法来管理内核对象。所引入的框架并不只是为了防止代码复制,同时也为内核不同部分管理的对象提供了一致的视图,在内核的许多部分可以有效地使用相关信息,如电源管理。

  • 一般性内核对象定义kobject

一般性的内核对象机制可用于执行下列对象操作: 引用计数、 管理对象链表(集合)、集合加锁、将对象属性导出到用户空间(通过sysfs文件系统)。

struct kobject {
    const char            *k_name;
    struct kref           kref;
    struct liset_head     entry;
    struct kobject        *parent;
    struct kset           *kset;
    struct kobj_type      *ktype;
    struct sysfs_dorent   *sd;
}

kobject与面向对象编程语言(像C++或Java)中的对象概念的相似性决不是巧合。kobject抽象实际上提供了在内核使用面向对象技术的可能性,而无需C++的所有额外机制(以及二进制代码大小的膨胀和额外开销)。各成员含义如下:

  • k_name:是对象的文本名称,可利用sysfs导出到用户空间。sysfs是一个虚拟文件系统,可以将系统的各种属性描述导出到用户空间。

  • sd:即用于支持内核对象与sysfs之间的关联

  • kref:类型为struct kref,用于简化引用计数的管理。

    •         struct kref {
                  atomic_t refcount;
              }
      
  • entry:是一个标准的链表元素,用于将若干kobject放置到一个链表中(在这种情况下称为集合)。

  • parent:是一个指向父对象的指针,可用于在kobject之间建立层次结构。

  • kset:将对象与其他对象放置到一个集合时,则需要kset。

  • ktype:提供了包含kobject的数据结构的更多详细信息。其中,最重要的是用于释放该数据结构资源的析构器函数。

    •   struct kobj_type {
            struct sysfs_ops       *sysfs_ops;
            struct attribute       **default_attrs;
        }
      
  • kobject处理方法

    • kobject_get, kobject_put :对kobject的引用计数器加1或减1
    • kobject_(un)register:从层次结构中注册或删除对象,对象被添加到父对象中现存的集合中,同时在sysfs文件系统中创建一个对应项。
    • kobject_init:初始化一个kobject,即将引用计数器设置为初始值,初始化对象的链表元素
    • kobject_add:初始化一个内核对象,并使之显示在sysfs中
    • kobject_cleanup:在不需要kobject(以及包含kobject的对象)时,释放分配的资源

3.15.2.2 对象集合

在很多情况下,必须将不同的内核对象归类到集合中,例如,所有字符设备集合,或所有基于PCI的设备集合。用到的数据结构定义如下:

struct kset {
    struct kobj_type        *type;
    struct list_head        list;
...
    struct kobject          kobj;
    struct kset_uevent_ops  *uevent_ops;
}

kset是内核对象kobject应用的第一个例子:由于管理集合的结构只能是内核对象,因此它可以通过先前讨论过的struct kobject管理。实际上kset中嵌入了一个kobject的实例kobj。它与集合中包含的各个kobject无关,只是用来管理kset对象本身。其他成员含义如下:

  • ktype:指向kset中各个内核对象公用的kobj_type结构。
  • list:是所有属于当前集合的内核对象的链表。
  • uevent_ops提供了若干函数指针,用于将集合的状态信息传递给用户层。该机制由驱动程序模型的核心使用,例如格式化一个信息,通知添加了新设备。
3.15.2.3 引用计数

引用计数用于检测内核中有多少地方使用了某个对象。每当内核的一个部分需要某个对象所包含的信息时,则对该对象的引用计数加1。如果不再需要相应的信息,则对该对象的引用计数减1。在计数下降到0后,内核知道不再需要该对象,所以此时从内存中释放该对象。内核提供了下列数据结构处理引用计数:

      struct kref {
          atomic_t refcount;
      }

该数据结构确实很简单,它只提供了一个一般性的原子引用计数。“原子”在这里意味着,对该变量的加1和减1操作在多处理器系统上也是安全的,多处理器系统中可能会有多个代码路径同时访问一个对象。

辅助方法kref_init、kref_get和kref_put用于对引用计数器进行初始化、加1、减1操作。初看起来似乎太简单了。不过这些函数仍然有助于避免过度的代码复制,因为引用计数和前述几个操作的使用遍及整个内核。

尽管这样操作引用计数器不会有并发问题,但并不意味着并发访问包含kref的数据结构是安全的!内核代码需要采取进一步措施,以保证多处理器同时访问数据结构不会引起任何问题。

3.15.3 数据类型

与用户层程序相比,内核对与数据类型有关的一些问题采取了不同的处理方法。

3.15.3.1 类型定义

内核使用typedef来定义各种数据类型,以避免依赖于体系结构相关的特性,比如,各个处理器上标准数据类型的位长可能都不见得相同。定义的类型名称如sector_t(用于指定块设备上的扇区编号)、pid_t(表示进程ID)等,这些都是由内核在特定于体系结构的代码中定义的,以确保相关类型的值落在适当的范围内。因为通常无需了解这些类型的定义是基于哪些基本的数据类型,这些实际上只不过是对非复合的标准数据类型用了个不同的名称而已。

在某些时候内核必须使用精确定义了位数的变量,例如,在需要向硬盘存储数据结构时。为允许数据在各种系统之间交换(例如,USB存储棒),无论数据在计算机内部如何表示,必须总是使用同样的外部格式。 为此内核定义了若干整数数据类型,不仅明确标明了是有符号数还是无符号数,而且还指定了相关类型的精确位数。例如,__s8和__u8分别是有符号(__s8)和无符号(__u8)的8位整数。__u16 和__s16、__u32和__s32、__u64和__s64的定义类似。

3.15.3.1.2 字节序

为表示数字,现代计算机采用大端序(big endian)或小端序(little endian)格式。该格式表示如何存储多字节数据类型。在大端序格式中,最高有效字节存储在最低地址,而随着地址升高,字节的权重降低。在小端序格式中,最低有效字节存储在最低地址,而随着地址升高,字节的权重也升高。

内核提供了各种函数和宏,可以在CPU使用的格式与特定的表示法之间转换。cpu_to_le64将64位数据类型转换为小端序格式,而le64_to_cpu所做的刚好相反(如果体系结构采用的字节序是小端序格式,这两个例程当然是空操作,否则必须相应地交换字节位置)。对64位、32位和16位的数据类型,所有的小端序、大端序之间的转换例程都是可用的。

3.15.3.1.3 per-cpu变量

普通的用户空间程序设计不会涉及的一个特殊事项就是所谓的per-cpu变量。它们是通过 DEFINE_PER_CPU(name, type)声明,其中name是变量名,而type是其数据类型(例如int[3]、struct hash等)。在单处理器系统上,这与常规的变量声明没有不同。在有若干CPU的SMP系统上,会为每个CPU分别创建变量的一个实例。用于某个特定CPU的实例可以通过get_cpu(name, cpu)获得,其中smp_processor_id()可以返回当前活动处理器的ID,用作前述的cpu参数。

采用per-cpu变量有下列好处:所需数据很可能存在于处理器的缓存中,因此可以更快速地访问。 如果在多处理器系统中使用可能被所有CPU同时访问的变量,可能会引发一些通信方面的问题,采用上述概念刚好绕过了这些问题

3.15.3.1.4 访问用户空间

Linux源代码中的多处指针都标记为__user,该标识符对用户空间程序设计是未知的。内核使用该记号来标识指向用户地址空间中区域的指针,在没有进一步预防措施的情况下,不能轻易访问这些指针指向的区域。这是因为内存是通过页表映射到虚拟地址空间的用户空间部分的,而不是由物理内存直接映射的。因此内核需要确保指针所指向的页帧确实存在于物理内存中。