深入Linux内核架构(一)简介和概述

学术用操作系统相对简单而且可获得源代码;商用操作系统虽然性能不同但是其源代码受到良好的保护;

Linux结合了UNIX操作系统合理可靠和基础牢固的概念以及强大的创新,同时由于学术性操作系统缺乏解决问题的能力,所以具有强大的吸引力;

本书要求读者有牢固的C语言程序设计基础。因为内核使用了C语言的许多高级技巧,尤其是GNU C编译器的许多专门特性;内核源代码是特别为使用GNU C编译器进行编译而设计的;

本章将概述内核所涉及的各种领域,并在后续章节中对相应的子系统进行长篇阐述之前,这里先行说明其基本关系;

本书基于2.6.24版本的内核,发布于2008年1月末;

1.1 内核的任务

在纯技术层面上,内核是硬件与软件之间的一个中间层。其作用是将应用程序的请求传递给硬件, 并充当底层驱动程序,对系统中的各种设备和组件进行寻址;

从应用程序的角度来看,内核是计算机硬件的高度抽象,它屏蔽了具体的工作细节;

从共享资源的角度来看,内核负责分配管理可用资源(包括CPU时间、磁盘空间、网络连接等),将它们分配到各个系统进程;

从系统调用的角度来看,内核是提供给应用程序向系统发送请求的命令库;

1.2 实现策略

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

微内核(Microkernel):只有最基本的功能直接由中央内核(即微内核)实现。所有其他的功能都委托给一些独立进程,这些进程通过明确定义的通信接口与中央内核通信,好处在于结构清晰,动态可扩展以及支持运行时切换等,但在各个进程之间支持复杂通信需要额外的CPU时间;

宏内核(Monolithic kernel):与微内核相反,宏内核是构建系统内核的传统方法;内核的全部代码,包括所有子系统(如内存管理、文件系统、设备驱动程序)都打包到一个文件中,内核中的每个函数都可以访问内核中所有其他部分,同时支持模块的热插拔和动态装载;

深入Linux内核架构(一)简介和概述 - 图1

1.3 内核的组成部分

本节概述了内核的各个组成部分,以及我们将在后续章节中详细研究的各个领域;

1.3.1 进程、进程切换、调度

每个进程都在CPU的虚拟内存中分配地址空间,各个进程的地址空间是完全独立的,进程之间并不会意识到彼此的存在;

系统中同时真正在运行的进程数目最多不超过CPU数目,内核会按照短的时间间隔在不同的进程之间切换;

(1) 内核借助于CPU的帮助,负责进程切换:撤销进程的CPU资源之前保存进程所有与状态相关的要素,并将进程置于空闲状态,重新激活进程时,则将保存的状态原样恢复;

(2)内核还需要负责进程调度:确定如何在进程之间共享CPU时间,确定哪个进程运行多长时间;

1.3.2 UNIX进程

Linux采用树形结构对进程进行管理,每个进程都依赖于一个父进程。内核启动init程序作为第一 个进程,该进程负责进一步的系统初始化操作,它进程树的根,所有进程都直接或间接起源自该进程;

UNIX操作系统中有两种创建新进程的机制:分别是forkexec

(1)fork可以创建当前进程的一个副本,父进程和子进程只有PID不同。Linux采用写时复制(copy on write)技术将内存复制操作延迟到父进程或子进程向某内存页面写入数据之前,在只读访问的情况下父进程和子进程可以共用同一内存页;

(2)exec将新程序加载到当前进程的内存中并执行。旧程序的内存页将刷出,其内容将替换为新的数据,然后开始执行新程序。

1.3.2.1 线程

Linux采用clone方法创建线程。其工作方式类似于fork,但启用了精确的检查以区分资源与父进程共享或线程独立创建。

1.3.2.2 命名空间

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

1.3.3 地址空间与特权级别

内存区域是通过指针寻址,因此CPU的字长决定了所能管理的地址空间最大长度。对32位系统是2³²B=4GB,对64位处理器是2⁶⁴B。

地址空间的最大长度与实际可用的物理内存数量无关,因此被称为虚拟地址空间。

Linux将虚拟地址空间划分为两个部分,分别称为内核空间和用户空间:系统中每个用户进程都有自身的虚拟地址范围,从0到TASK_SIZE。用户空间之上的区域(从 TASK_SIZE到2³²或2⁶⁴)保留给内核专用而用户进程不能访问。

1.3.3.1 特权级别

内核把虚拟地址空间划分为两个部分,保护各个系统进程使之彼此隔离。所有的现代CPU都提供了几种特权级别,进程可以驻留在某一特权级别,每个特权级别都有各种限制;

Linux只使用两种不同的状态:核心态和用户态。两种状态的关键差别在于对内核空间的访问,即在用户态禁止访问内核空间。 用户进程不能操作或读取内核空间中的数据,也无法执行内核空间中的代码。这是内核的专用领域。 这种机制可防止进程无意间修改彼此的数据而造成相互干扰。

从用户状态到核心态的切换通过系统调用的特定转换手段完成,且系统调用的执行因具体系统而不同。如果普通进程想要执行任何影响整个系统的操作(例如操作输入/输出装置),则只能借助于系统调用向内核发出请求。内核首先检查进程是否允许执行相应的操作,然后代表进程执行所需的操作,最后再返回到用户状态。

除了代表用户程序执行代码之外,内核还可以由异步硬件中断激活,然后在中断上下文中运行。 与在进程上下文中运行的主要区别是,在中断上下文中运行不能访问虚拟地址空间中的用户空间部分。因为中断可能随机发生,中断发生时可能是任一用户进程处于活动状态,由于该进程基本上与中断的原因无关,因此内核无权访问当前用户空间的内容。

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

ps命令的输出中很容易识别内核线程,其名称都置于方括号内;

1.3.3.2 虚拟和物理地址空间

内核和CPU必须考虑如何将实际可用的物理内存映射到虚拟地址空间的区域:可取的方法是用页表来为物理地址分配虚拟地址。虚拟地址关系到进程的用户空间和内核空间, 而物理地址则用来寻址实际可用的内存;

物理内存页经常称作页帧,而则专指虚拟地址空间中的页;

两个虚拟地址空间中的页(虽然在不同的位置)可以映射到同一物理内存页。内核负责将虚拟地址空间映射到物理地址空间,因此它可以决定哪些内存区域在进程之间共享,哪些不共享;

并非虚拟地址空间的所有页都映射到某个页帧:这可能是因为页没有使用,或者是数据尚不需要使用而没有载入内存中。还可能是页已经换出到硬盘,将在需要时再换回内存。

1.3.4 页表

用来将虚拟地址空间映射到物理地址空间的数据结构称为页表;

因为虚拟地址空间的大部分区域都没有使用,也没有关联到页帧,那么就可以使用功能相同但内存用量少得多的模型:多级分页

为减少页表的大小并容许忽略不需要的区域,计算机体系结构的设计会将虚拟地址划分为多个部分:示例将虚拟地址划分为4部分,这样就需要一个三级的页表。大多数体系结构都是这样的做法,但有一些采用了四级的页表,而Linux也采用了四级页表。为简化场景这里会一直用三级页表阐述;

PGD(Page Global Directory):虚拟地址的第一部分称为全局页目录,用于索引进程中的一 个数组(每个进程有且仅有一个),该数组是所谓的全局页目录或PGDPGD的数组项指向另一些数组的起始地址,这些数组称为中间页目录(Page Middle Directory,PMD);

PMD(Page Middle Directory):在通过PGD中的数组项找到对应的PMD之后,则使用PMD 来索引PMDPMD的数组项也是指针,指向下一级数组,称为页表或页目录;

PTE(Page Table Entry):称为页表数组,用作页表的索引。虚拟内存页和页帧之间的映射就此完成,因为页表的数组项是指向页帧的。

Offset:虚拟地址最后的一部分称为偏移量。它指定了页内部的一个字节位置。归根结底,每个地址都指向地址空间中唯一定义的某个字节;

页表的特色在于对虚拟地址空间中不需要的区域,不必创建中间页目录或页表,因此多级页表节省了大量内存。 当然该方法也有一个缺点就是每次访问内存时,必须逐级访问多个数组才能将虚拟地址转换为物理地址。CPU试图用下面两种方法加速该过程:

(1)CPU中有一个专门的部分称为MMU(Memory Management Unit),该单元优化了内存访问操作;

(2)地址转换中出现最频繁的那些地址,保存到称为地址转换后备缓冲器(Translation Lookaside Buffer,TLB)的CPU高速缓存中。无需访问内存中的页表即可从高速缓存直接获得地址数据,因而大大加速了地址转换;

1.3.4.1 与CPU的交互

IA-32体系结构在将虚拟地址映射到物理地址时,只使用了两级页表。而64位体系结构(Alpha、Sparc64、IA - 64)地址空间比较大,需要三级或四级的页表,内核与体系结构无关的部分总是假定使用四级页表。 对于只支持二级或三级页表的CPU来说,内核中体系结构相关的代码必须通过空页表对缺少的页表进行仿真。因此内存管理代码剩余部分的实现是与CPU无关的。

1.3.4.2 内存映射

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

1.3.5 物理内存的分配

在内核分配内存时,必须记录页帧的已分配或空闲状态,以免两个进程使用同样的内存区域。由于内存分配和释放非常频繁,内核还必须保证相关操作尽快完成。内核可以只分配完整的页帧,而将内存划分为更小的部分的工作,则委托给用户空间中的标准库。标准库将来源于内核的页帧拆分为小的区域,并为进程分配内存;

1.3.5.1 伙伴系统

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

系统中的空闲内存块总是两两分组,每组中的两个内存块称作伙伴。伙伴的分配可以是彼此独立的。但如果两个伙伴都是空闲的,内核会将其合并为一个更大的内存块,作为下一层次上某个内存块的伙伴;

1.3.5.2 slab缓存

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

(1)对频繁使用的对象,内核定义了只包含了所需类型对象实例的缓存。每次需要某种对象时,可以从对应的缓存快速分配(使用后释放到缓存)。slab缓存自动维护与伙伴系统的交互,在缓存用尽 时会请求新的页帧;

(2) 对通常情况下小内存块的分配,内核针对不同大小的对象定义了一组slab缓存,可以像用户空间编程一样,用相同的函数访问这些缓存。不同之处是这些函数都增加了前缀k,表明是与内核相关联的:kmallockfree

1.3.5.3 页面交换和页面回收

页面交换通过利用磁盘空间作为扩展内存,从而增大了可用的内存。在内核需要更多内存时,不经常使用的页可以写入硬盘。如果再需要访问相关数据,内核会将相应的页切换回内存。通过缺页异常机制,这种切换操作对应用程序是透明的。换出的页可以通过特别的页表项标识。在进程试图访问此类页帧时,CPU则启动一个可以被内核截取的缺页异常。此时内核可以将硬盘上的数据切换到内存中。接下来用户进程可以恢复运行。由于进程无法感知到缺页异常,所以页的换入和换出对进程是完全不可见的。

页面回收用于将内存映射被修改的内容与底层的块设备同步,为此有时也简称为数据回写。数据刷出后,内核即可将页帧用于其他用途(类似于页面交换)。内核的数据结构包含与此相关的所有信息,当再次需要该数据时,可根据相关信息从硬盘找到相应数据并加载;

1.3.6 计时

内核必须能够测量时间以及不同时间点的时差,进程调度就会用到该功能;jiffies是一个合适的时间坐标。名为jiffies_64jiffies(分别是64位和32位)的全局变量,会按恒定的时间间隔递增。每种计算机底层体系结构都提供了一些执行周期性操作的手段,通常的形式是定时器中断。对前述的两个全局变量的更新可使用底层体系结构提供的各种定时器机制执行。

1.3.7 系统调用

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

进程管理:创建新进程,查询信息,调试;

信号:发送信号,定时器以及相关处理机制;

文件:创建、打开和关闭文件,从文件读取和向文件写入,查询信息和状态;

目录和文件系统:创建、删除和重命名目录,查询信息,链接,变更目录;

保护机制:读取和变更UID/GID,命名空间的处理;

定时器函数:定时器函数和统计信息;

所有这些函数都对内核提出了要求:它们不能以普通的用户库形式实现,因为需要特别的保护机制来保证系统稳定性或安全不受危及。此外许多调用依赖内核内部的结构或函数来得到所需的数据或结果,这也导致了无法在用户空间实现。在发出系统调用时,处理器必须改变特权级别,从用户状态切换到核心态。Linux对此没有标准化的做法,因为每个硬件平台都提供了特定的机制。但它们的共同点就是:用户进程要从用户状态切换到核心态,并将系统关键任务委派给内核执行,系统调用是必由之路。

1.3.8 设备驱动程序、块设备和字符设备

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

外设可分为以下两类:

(1)字符设备:提供连续的数据流,应用程序可以顺序读取,通常不支持随机存取。此类设备支持按字节/字符来读写数据;

(2)块设备:应用程序可以随机访问设备数据,自行确定读取数据的位置。硬盘是典型的块设备,应用程序可以寻址磁盘上的任何位置并由此读取数据。此外数据的读写只能以块(通常是512B)的倍数进行。与字符设备不同,块设备并不支持基于字符的寻址。

1.3.9 网络

网卡也可以通过设备驱动程序控制,但在内核中属于特殊状况,因为网卡不能利用设备文件访问。 原因在于在网络通信期间,数据打包到了各种协议层中。在接收到数据时,内核必须针对各协议层的处理,对数据进行拆包与分析,然后才能将有效数据传递给应用程序。在发送数据时,内核必须首先根据各个协议层的要求打包数据然后才能发送。 为支持通过文件接口处理网络连接(按照应用程序的观点),Linux使用了源于BSD的套接字抽象。 套接字可以看作应用程序、文件接口、内核的网络实现之间的代理;

1.3.10 文件系统

文件系统使用目录结构组织存储的数据,并将其他元信息(例如所有者、访问权限等)与实际数据关联起来;Linux支持许多不同的文件系统:标准的Ext2Ext3文件系统、ReiserFSXFSVFAT(为兼容DOS)等;

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

1.3.11 模块和热插拔

模块用于在运行时动态地向内核添加功能,如设备驱动程序、文件系统、网络协议等,实际上内核的任何子系统几乎都可以模块化,这也消除了宏内核与微内核相比一个重要的不利之处;

1.3.12 缓存

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

1.3.13 链表处理

内核提供的标准链表可用于将任何类型的数据结构彼此链接起来,它不是类型安全的。加入链表的数据结构必须包含一个类型为list_head的成员,其中包含了正向和反向指针;

内核建立了一个循环链表:这种链表的第一个和最后一个元素都能达到O(1)的访问时间, 也就是说不管链表的大小如何,访问这两个元素花费的时间是一个常数;

1.3.14 对象管理和引用计数

内核采用一般性的方法来管理内核对象,也为内核不同部分管理的对象提供了一致的视图;

一般性的内核对象机制可用于执行下列对象操作:

引用计数;

管理对象链表(集合);

集合加锁;

将对象属性导出到用户空间(通过sysfs文件系统);

1.3.14.1 一般性的内核对象

下列数据结构将嵌入其他数据结构中,用作内核对象的基础:

[->kobject.h]

  1. struct kobject {
  2. const char * k_name;
  3. struct kref kref;
  4. struct list_head entry;
  5. struct kobject * parent;
  6. struct kset * kset;
  7. struct kobj_type * ktype;
  8. struct sysfs_dirent * sd;
  9. }

kobject不是通过指针与其他数据结构连接起来,而必须直接嵌入。这样通过管理kobject即达到了对包含kobject对象的管理。由于kobject结构会嵌入到内核的许多数据结构中,开发者需要注意保持该结构较小。向该数据结构添加一个新成员,则会导致许多其他数据结构的大小增加;

1.3.14.2 对象集合

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

[->kobject.h]

  1. struct kset {
  2. struct kobj_type * ktype;
  3. struct list_head list;
  4. ...
  5. struct kobject kobj;
  6. struct kset_uevent_ops * uevent_ops;
  7. }

kset是内核对象应用的第一个例子。由于管理集合的结构只能是内核对象,因此它可以通过struct kobject管理。kset中嵌入的kobject的实例kobj与集合中包含的各个kobject无关,只是用来管理kset对象本身。

另一个结构用于描述内核对象的共同特性,其定义如下:

  1. struct kobj_type {
  2. ...
  3. struct sysfs_ops *sysfs_ops;
  4. struct attribute **default_attrs;
  5. };

kobj_type与内核对象的集合没什么关系,kset已经提供了集合功能。该结构提供了与 sysfs文件系统的接口。如果多个对象通过该文件系统导出类似的信息,则可以简化使多个对象共享同一个ktype来提供所需的方法;

1.3.14.3 引用计数

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

[->kref.h]

  1. struct kref {
  2. atomic_t refcount;
  3. };

该数据结构只提供了一个一般性的原子引用计数。原子在这里意味着对该变量的加1和减1操作在多处理器系统上也是安全的;

1.3.15 数据类型

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

1.3.15.1 类型定义

内核使用typedef来定义各种数据类型,以避免依赖于体系结构相关的特性,比如各个处理器上标准数据类型的位长可能都不相同。

1.3.15.2 字节序

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

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

1.3.15.3 per-cpu变量

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

1.3.15.4 访问用户空间

源代码中的多处指针都标记为__user,该标识符对用户空间程序设计是未知的。内核使用该记号来标识指向用户地址空间中区域的指针,在没有进一步预防措施的情况下,不能轻易访问这些指针指向的区域。这是因为内存是通过页表映射到虚拟地址空间的用户空间部分的,而不是由物理内存直接映射的。因此内核需要确保指针所指向的页帧确实存在于物理内存中。通过显式标记,可以支持利用自动检查工具(sparse)来确认实际上遵守了必要的条件。