linux内核笔记(一)概念
概念
操作系统
整个系统中负责完成最基本功能和系统管理的那些部分。应该包括:内核、设备驱动、启动引导、shell、文件管理和系统功能
内核
技术层面上,内核是硬件与软件之间的中间层,其作用是将应用程序的请求传递给硬件,并充当底层驱动程序,对系统中的各种设备和组件进行寻址。系统其他部分必须依靠内核提供的服务(管理设备、分配系统资源)。一般包含中断服务程序、管理多进程任务的调度程序、管理进程内存地址空间的内存管理程序、进程通信的系统服务。
进程与线程
Linux采用了层次进程系统,每个进程都有一个父进程。内核启动init程序作为第一个进程,负责后面的系统初始化和操作。因此,init是进程树的根,所有进程都直接或间接来自该进程。有两种创建新进程的机制,分别是fork 和exec。
- fork
- 可以创建当前进程的一个副本,父进程和子进程只有PID不同。Linux使用了写时复制 (copy on write)使操作更高效,原理是将内存复制操作延迟到父进程或子进程向某内存页面写入数据之前,在只读访问的情况下父进程和子进程可以共用同一内存页。
- exec
- 将一个新程序加载到当前进程的内存中并执行。旧程序的内存页将刷出,其内容将替换为新的数据。然后开始执行新程序。
进程切换与调度
系统中同时真正运行的进程数不超过CPU数目,因此内核会在不同进程间切换,这样就产生了同时处理多进程的假象,但存在两个问题。
- 内核每个进程觉得CPU一直可用。通过在撤销进程的CPU资源前保存所有状态,然后将其置为空闲状态,再在重新激活时恢复状态,从而实现进程切换。
- 内核需要决定如何在现有进程之间共享CPU时间,确保主要进程获得更多CPU时间,而次要进程获得较少。这个过程称为调度。
线程
本质上一个进程可能由若干线程组成,这些线程共享同样的数据和资源,但可能执行程序中不同的代码路径。Linux用clone 方法创建线程。其工作方式类似于fork ,但启用了精确的检查,以确认哪些资源与父进程共享、哪些资源为线程独立创建。这种细粒度的资源分配扩展了一般的线程概念,在一定程度上允许线程与进程之间的连续转换。
命名空间
传统的Linux系统使用许多全局量,如进程ID。每个进程都有一个唯一的ID,用于访问进程或向其发送信号。启用命名空间后,全局资源被分组。每个命名空间可以有特定的PID集合,或提供文件系统的不同视图。挂载到一个命名空间的卷不会传播到其他命名空间。
虚拟地址空间与特权级别
由于内存区域是通过指针寻址,因此CPU的字长决定了所能管理的地址空间的最大长度。对32位系统,是$2^{32}$ B=4GiB,对更现代的64位处理器,可以管理$2^{64}B$。
64位计算机的情况可能更复杂,因为它们在实际管理自身巨大的理论虚拟地址空间时,倾向于使用小于64的位数。实际使用的位数一般小于64位,如42位或47位。因此,地址空间中实际可寻址的部分小于理论长度。但无论如何,该值仍然大于计算机上实际可能的内存数量,因此是完全够用的。这种做法的一个优点是,与寻址完整的虚拟地址空间相比,管理有效地址空间所需的位数较少,因此CPU可以节省一些工作量。这样,虚拟地址空间会包含一些不可寻址的漏洞。
Linux将虚拟地址空间划分为两个部分,分别称为内核空间
和用户空间
。系统中每个用户进程都有自身的虚拟地址范围,从0到TASK_SIZE
。用户空间之上的区域(从TASK_SIZE 到$2^{32}$ 或$2^{64}$ )保留给内核专用,用户进程不能访问。
内核把虚拟地址空间划分为两个部分,因此能够保护各个系统进程,使之彼此隔离。所有的现代CPU都提供了几种特权级别,进程可以驻留在某一特权级别。每个特权级别都有各种限制,例如对执行某些汇编语言指令或访问虚拟地址空间某一特定部分的限制。
系统调用 从用户状态到核心态的切换通过
系统调用
的特定转换手段完成。如果普通进程想要执行任何影响整个系统的操作(例如操作输入/输出装置),内核首先检查进程是否允许 执行想要的操作,然后代表进程执行所需的操作,接下来返回到用户状态。
CPU大多数时间都在执行用户空间中的代码。当应用程序执行系统调用时,则切换到核心态,内核将完成其请求。在此期间,内核可以访问虚拟地址空间的用户部分。在系统调用完成之后,CPU切换回用户状态。硬件中断也会使CPU切换到核心态,这种情况下内核不能访问用户空间。
在ps 命令的输出中很容易识别内核线程,其名称都置于方括号内。在多处理器系统上,许多线程启动时指定了CPU,并限制只能在某个特定的CPU上运行。从内核线程名称之后的斜线和CPU编号可以看到这一点。
1
2
3
4
5
6
7
8
9
10
11
12
13
ps fax
PID TTY STAT TIME COMMAND
2 ? S 0:25 [kthreadd]
3 ? I< 0:00 \_ [rcu_gp]
4 ? I< 0:00 \_ [rcu_par_gp]
5 ? I< 0:00 \_ [slub_flushwq]
6 ? I< 0:00 \_ [netns]
18 ? S 0:00 \_ [cpuhp/0]
19 ? S 0:00 \_ [cpuhp/1]
20 ? S 0:00 \_ [idle_inject/1]
21 ? S 1:37 \_ [migration/1]
22 ? S 5:08 \_ [ksoftirqd/1]
虚拟地址与物理地址
虚拟地址空间比系统中可用的物理内存要大,而每个进程都有自身的虚拟地址空间,因此内核和CPU必须考虑如何将实际可用的物理内存映射到虚拟地址空间的区域。
页表
来为物理地址分配虚拟地址。虚拟地址关系到进程的用户空间和内核空间,而物理地址则用来寻址实际可用的内存。 物理内存页经常称作页帧
。页
则专指虚拟地址空间中的页。
两个虚拟地址空间中的页(虽然在不同的位置)可以映射到同一物理内存页。由于内核负责将虚拟地址空间映射到物理地址空间,因此可以决定哪些内存区域在进程之间共享,哪些不共享。
页表
实现两个地址空间的关联最容易的方法是使用数组,对虚拟地址空间中的每一页,都分配一个数组项。该数组项指向与之关联的页帧,但这样会将未使用的大部分地址空间也占用内存映射(系统所有内存都要用来保存页表),因此是不切实际的。
为减少页表的大小并容许忽略不需要的区域,计算机体系结构的设计会将虚拟地址划分为多个部分。
页表的一个特色在于,对虚拟地址空间中不需要的区域,不必创建中间页目录或页表。与前述使用单个数组的方法相比,多级页表节省了大量内存。该方法也有一个缺点。每次访问内存时,必须逐级访问多个数组才能将虚拟地址转换为物理地址。CPU试图用下面两种方法加速该过程。
CPU中有一个专门的部分称为MMU(Memory Management Unit,内存管理单元 ),该单元优化了内存访问操作。
地址转换中出现最频繁的那些地址,保存到称为地址转换后备缓冲器 (Translation Lookaside Buffer,TLB)的CPU高速缓存中。无需访问内存中的页表即可从高速缓存直接获得地址数据,因而加速了地址转换。
在许多体系结构中高速缓存的运转是透明的,但某些体系结构则需要内核专门处理。
- 对于只支持二级或三级页表的CPU来说,内核中体系结构相关的 代码必须通过空页表对缺少的页表进行仿真。因此,内存管理代码剩余部分的实现是与CPU无关的。
内存映射
。映射可以将任意来源的数据传输到进程的虚拟地址空间中。作为映射目标的地址空间区域,可以像普通内存那样用通常的方法访问。但任何修改都会自动传输到原数据源。这样就可以使用相同的函数来处理完全不同的目标对象。例如,文件的内容可以映射到内存中。处理只需读取相应的内存即可访问文件内容,或向内存写入数据来修改文件的内容。内核将保证任何修改都会自动同步到文件中。内核在实现设备驱动程序时直接使用了内存映射。外设的输入/输出可以映射到虚拟地址空间的区域中。对相关内存区域的读写会由系统重定向到设备,因而大大简化了驱动程序的实现。
物理内存的分配
在内核分配内存时,必须记录页帧的已分配或空闲状态,以免两个进程使用同样的内存区域。由于内存分配和释放非常频繁,内核还必须保证相关操作尽快完成。内核可以只分配完整的页帧。将内存划分为更小的部分的工作,则委托给用户空间中的标准库。标准库将来源于内核的页帧拆分为小的区域,并为进程分配内存。
- 伙伴系统
- 系统中的空闲内存块总是两两分组,每组中的两个内存块称作伙伴。伙伴的分配可以是彼此独立的。但如果两个伙伴都是空闲的,内核会将其合并为一个更大的内存块,作为下一层次上某个内存块的伙伴。
- slab缓存
- 将内存划分为不同的缓存,每个缓存用于存储特定类型的对象或数据结构。每次需要某种对象时,可以从对应的缓存快速分配(使用后释放到缓存)。slab缓存自动维护与伙伴系统的交互,在缓存用尽时会请求新的页帧。对通常情况下小内存块的分配,内核针对不同大小的对象定义了一组slab缓存,可以像用户空间编程一样,用相同的函数访问这些缓存。不同之处是这些函数都增加了前缀k ,表明是与内核相关联的:
kmalloc
和kfree
。
- 页面交换与回收
- 利用磁盘空间作为扩展内存,增大了可用的内存。在内核需要更多内存时,不经常使用的页可以写入硬盘。如果再需要访问相关数据,内核会将相应的页切换回内存。换出的页可以通过特别的页表项标识。在进程试图访问此类页帧时,CPU则启动一个可以被内核截取的缺页异常。此时内核可以将硬盘上的数据切换到内存中。接下来用户进程可以恢复运行。由于进程无法感知到缺页异常,所以页的换入和换出对进程是完全不可见的。
计时
jiffies
是一个内核的时间坐标。每种计算机底层体系结构都提供了一些执行周期性操作的手段,通常的形式是定时器中断。
jiffies 递增的频率同体系结构有关,取决于内核中一个主要的常数HZ 。该常数的值通常介于100和1 000中间。换言之,jiffies 的值每秒递增的次数在100至1 000次之间。
计时的周期是可以动态 改变的。在没有或无需频繁的周期性操作的情况下,周期性地产生定时器中断是没有意义的,这会阻止处理器降低耗电进入睡眠状态。动态改变计时周期对于供电受限的系统是很有用的,例如笔记本电脑和嵌入式系统。
系统调用
系统调用是用户进程与内核交互的经典方法。POSIX标准定义了许多系统调用,以及这些系统调用在所有遵从POSIX的系统包括Linux上的语义。传统的系统调用按不同类别分组:
进程管理 :创建新进程,查询信息,调试。
信号 :发送信号,定时器以及相关处理机制。
文件 :创建、打开和关闭文件,从文件读取和向文件写入,查询信息和状态。
目录和文件系统 :创建、删除和重命名目录,查询信息,链接,变更目录。
保护机制 :读取和变更UID/GID,命名空间的处理。
定时器函数 :定时器函数和统计信息。
设备驱动程序、块设备和字符设备
设备驱动程序用于与系统连接的输入/输出装置通信,如硬盘、软驱、各种接口、声卡等。按照经典的UNIX箴言万物皆文件
(everything is a file),对外设的访问可利用/dev
目录下的设备文件来完成,程序对设备的处理完全类似于常规的文件。设备驱动程序的任务在于支持应用程序经由设备文件与设备通信,使得能够按适当的方式在设备上读取/写入数据。
外设可分为以下两类:
字符设备 :提供连续的数据流,应用程序可以顺序读取,通常不支持随机存取。相反,此类设备支持按字节/字符来读写数据。举例来说,调制解调器是典型的字符设备。
块设备 :应用程序可以随机访问设备数据,程序可自行确定读取数据的位置。硬盘是典型的块设备。数据的读写只能以块(通常是512B)的倍数进行。与字符设备不同,块设备并不支持基于字符的寻址。
网络
网卡也可以通过设备驱动程序控制,但在内核中属于特殊状况,因为网卡不能利用设备文件访问。原因在于在网络通信期间,数据打包到了各种协议层中。在接收到数据时,内核必须针对各协议层的处理,对数据进行拆包与分析,然后才能将有效数据传递给应用程序。在发送数据时,内核必须首先根据各个协议层的要求打包数据,然后才能发送。
为支持通过文件接口处理网络连接,Linux使用了源于BSD的套接字抽象。套接字可以看作应用程序、文件接口、内核的网络实现之间的代理。
文件系统
热插拨模块
模块用于在运行时动态地向内核添加和卸载功能,如设备驱动程序、文件系统、网络协议等,实际上内核的任何子系统几乎都可以模块化。这消除了宏内核与微内核相比一个重要的不利之处。
模块本质上是在内核空间而不是用户空间执行的程序。可以像编译到内核中的代码一样,访问内核中所有的函数和数据。
模块特性使得内核可以支持种类繁多的设备,而内核自身的大小却不会发生膨胀。在检测到连接的硬件后,只需要加载必要的模块,多余的驱动序无需加入到内核。
缓存
内核使用缓存来改进系统性能。从低速的块设备读取的数据会暂时保持在内存中,即使数据在当时已经不再需要了。在应用程序下一次访问该数据时,它可以从访问速度较快的内存中读取,因而绕过了低速的块设备。由于内核是通过基于页的内存映射来实现访问块设备的,因此缓存也按页组织,也就是说整页都缓存起来,故称为页缓存(page cache)。