内核源码解读-概述

对Kernel源码进行review,内核(https://www.kernel.org/)大版本为6.x。
ref.:
[1]. Operating Systems 2¶
[2]. Mauerer W. Professional Linux kernel architecture[M]. John Wiley & Sons, 2010.
中文版:《深入Linux内核架构》

因为《深入Linux内核架构》这本书中所引用的内核代码版本过老,书中的内容也因此比较过时了,所以这里结合这本书对最新版本的Kernel源码进行review,按照这本书的思路加深内核原理的理解。

概述

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

之所以要通过内核“转达”应用程序对硬件发送的请求,是因为可以通过内核对相关的硬件细节进行抽象。这样应用程序与硬件本身没有直接连续,而只与内核有联系,内核是应用程序所知道的层次结构的最底层。

另一方面,如果同时有若干个程序在并发运行,那么内核将会被视为资源管理程序,此时内核将会负责将共享的资源分配给每个程序,同时保证系统的完整性。

另一种研究内核的视角是将内核视为库,其提供了一组面向系统的命令。通常, 系统调用用于向计算机发送请求。借助于C标准库,系统调用对于应用程序就像是普通函数一样,其调用方式与其他函数相同。

内核的组成部分大致可以用下图表示:

image

不过内核各个组成部分还有较为复杂的交互行为无法在上图中表现出来。

内核的功能组成

内核的主要功能大致分为以下几个方面:

  1. 进程
  2. 内存
  3. 网络
  4. 驱动
  5. 文件系统

而这个系列博客将会关注于内存、进程、驱动与文件系统上的内核原理理解。

进程管理

UNIX操作系统下运行的应用程序、服务器及其他程序都称为进程。每个进程都在CPU的虚拟内存中分配地址空间。各个进程的地址空间是完全独立的,因此进程并不会意识到彼此的存在。从进程的角度来看,它会认为自己是系统中唯一的进程,这就是所谓的多任务(Multi-task)系统。

然而实际上系统内运行的任务不会超过CPU的数量,那么就需要CPU资源在多个任务之间共享,这样就带来了两个问题:

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

在多处理器系统上,许多线程启动时指定了CPU,并限制只能在某个特定的CPU上运行,利用taskset工具指定进程运行的CPU。

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

systemd─┬─2*[agetty]
        ├─cron
        ├─dbus-daemon
        ├─init-systemd(Ub─┬─SessionLeader───Relay(266)─┬─cpptools-srv───7*[{cpptools-srv}]
        │                 │                            └─sh───sh───sh───node─┬─node───12*[{node}]
        │                 │                                                  ├─node─┬─cpptools───22*[{cpptools}]
        │                 │                                                  │      └─11*[{node}]
        │                 │                                                  └─10*[{node}]
        │                 ├─SessionLeader───Relay(299)───node───6*[{node}]
        │                 ├─SessionLeader───Relay(314)───node───6*[{node}]
        │                 ├─SessionLeader───Relay(471)───sh───sh───node─┬─sh
        │                 │                                             └─6*[{node}]
        │                 ├─SessionLeader───Relay(1000)───bash───pstree
        │                 ├─init───{init}
        │                 ├─login───bash
        │                 └─{init-systemd(Ub}
        ├─networkd-dispat
        ├─rsyslogd───3*[{rsyslogd}]
        ├─systemd───(sd-pam)
        ├─systemd-journal
        ├─systemd-logind
        ├─systemd-resolve
        ├─systemd-udevd
        └─unattended-upgr───{unattended-upgr}

在Ubuntu 15.04及后续版本中都使用systemd进程作为init程序。

这种树形结构的扩展方式与新进程的创建方式密切相关。
Unix操作系统中有两种创建新进程的机制:

  1. fork: 创建当前进程的一个副本,父进程和子进程只有PID(进程ID)不同。

Linux使用了一种众所周知的技术来使fork操作更高效,该技术称为写时复制( copy on write),主要的原理是将内存复制操作延迟到父进程或子进程向某内存页面写入数据之前,在只读访问的情况下父进程和子进程可以共用同一内存页。

  1. exec: 将一个新程序加载到当前进程的内存中并执行。

线程:进程可以看作一个正在执行的程序,而线程则是与主程序并行运行的程序函数或例程。

该特性是有用的,例如在浏览器需要并行加载若干图像时。通常浏览器只好执行几次fork和exec调用,以此创建若干并行的进程实例。这些进程负责加载图像,并使用某种通信机制将接收的数据提供给主程序。在使用线程时,这种情况更容易处理一些。浏览器定义了一个例程来加载图像,可以将例程作为线程启动,使用参数不同的多个线程即可。

Linux使用clone方法创建线程。其工作方式类似于fork,但启用了精确的检查,以确认哪些资源与父进程共享、哪些资源为线程独立创建。

命名空间

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

传统的Linux(与一般的UNIX操作系统)使用许多全局量,例如进程ID。系统中的每个进程都有一个唯一标识符(ID),用户(或其他进程)可使用ID来访问进程,例如向进程发一个信号。

启用命名空间之后,以前的全局资源现在具有不同分组。每个命名空间可以包含一个特定的PID集合,或可以提供文件系统的不同视图,在某个命名空间中挂载的卷不会传播到其他命名空间中。

命名空间的特性主要应用于虚拟机中,通过称为容器的命名空间来建立系统的多个视图。从容器内部看来这是一个完整的Linux系统, 而且与其他容器没有交互。

地址空间与特权级别

地址空间的最大长度与实际可用的物理内存数量无关,因此被称为虚拟地址空间。使用该术语的另一个理由是,从系统中每个进程的角度来看,地址空间中只有自身一个进程,而无法感知到其他进程的存在。

虚拟地址空间进行如下划分:

这种划分与可用的内存数量无关。由于地址空间虚拟化的结果, 每个用户进程都认为自身有3 GiB内存。

尽管英特尔处理器区分4种特权级别,但Linux只使用两种不同的状态:核心态和用户状态。两种状态的关键差别在于对高于TASK_SIZE的内存区域的访问。

image

有两种方法能够从用户态切换到核心态:

  1. 系统调用:如果普通进程想要执行任何影响整个系统的操作(例如操作输入/输出装置),则只能借助于系统调用向内核发出请求。内核首先检查进程是否允许执行想要的操作,然后代表进程执行所需的操作,接下来返回到用户状态。
  2. 内核由异步硬件中断激活,然后在中断上下文中运行。与在进程上下文中运行的主要区别是,在中断上下文中运行不能访问虚拟地址空间中的用户空间部分。

这两种方式对虚拟地址空间的访问情况如下图所示:

image

除了普通进程,系统中还有内核线程在运行。

内核线程也不与任何特定的用户空间进程相关联,因此也无权处理用户空间。

与在中断上下文运转的内核相比,内核线程可以进入睡眠状态,也可以像系统中的普通进程一样被调度器跟踪。

内核线程类似于Windows系统中的服务(services),可以进入睡眠状态,被调用时唤醒。

内存管理

在内存管理中首先需要解决的问题是因此内核和CPU必须考虑如何将实际可用的物理内存映射到虚拟地址空间的区域。

物理内存页经常称作页帧(frame)。相比之下, 页(page)则专指虚拟地址空间中的页。

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

实现两个地址空间的关联最容易的方法是使用数组,对虚拟地址空间中的每一页,都分配一个数组项。但是这样存在一个问题,比如在IA-32体系架构使用4 KiB页,在虚拟地址空间为4 GiB的情况下,则需要包含100万项的数组。

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

image

上图各组成部分将会在后续的内容进行深入的讨论。

多级分页的方法也有一个缺点:每次访问内存时,必须逐级访问多个数组才能将虚拟地址转换为物理地址。

CPU试图用下面两种方法加速该过程。

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

物理内存的分配

在内核分配内存时,必须记录页帧的已分配或空闲状态,以免两个进程使用同样的内存区域。

由于内存分配和释放非常频繁,内核还必须保证相关操作尽快完成。内核可以只分配完整的页帧。将内存划分为更小的部分的工作,则委托给用户空间中的标准库。标准库将来源于内核的页帧拆分为小的区域,并为进程分配内存。

为了提升内存分配的效率,内核实现了下面三种机制:

  1. 伙伴系统
    内核中很多时候要求分配连续页,而伙伴系统主要是为快速检测内存中的连续区域。系统中的空闲内存块总是两两分组,每组中的两个内存块称作伙伴。伙伴的分配可以是彼此独立的。但如果两个伙伴都是空闲的,内核会将其合并为一个更大的内存块,作为下一层次上某个内存块的伙伴。
    内核对所有大小相同的伙伴( 1、 2、 4、 8、 16或其他数目的页),都放置到同一个列表中管理。各有8页的一对伙伴也在相应的列表中。
    当系统长时间运行时,频繁的分配和释放页帧可能导致一种情况:系统中有若干页帧是空闲的,但却散布在物理地址空间的各处。伙伴系统可以在某种程度上减少这种效应,但无法完全消除。

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

  1. slab缓存
    内核本身经常需要比完整页帧小得多的内存块。但是由于内核无法调用标准库进行更细致的内存分配,所以必须在伙伴系统基础上自行定义额外的内存管理层,将伙伴系统提供的页划分为更小的部分。
    该方法不仅可以分配内存,还为频繁使用的小对象实现了一个一般性的缓存,即slab缓存。有两种方法分配内存:
    1. 通常情况:内核针对不同大小的对象定义了一组slab缓存,可以像用户空间编程一样,用相同的函数访问这些缓存。即:kmallockfree
    2. 频繁使用的小对象:内核定义了只包含了所需类型实例的缓存,每次需要某种对象时,可以从对应的缓存快速分配(使用后释放到缓存),如下图所示:

image

  1. 页面交换与回收
    内核还实现了一种页面交换机制,用于将不常用的内存页暂存到磁盘中。当再需要访问相关数据时,内核会将相应的内存页切换回到内存。
    换出的页可以通过特别的页表项标识,在进程试图访问此类页帧时, CPU则启动一个可以被内核截取的缺页异常。此时内核可以将硬盘上的数据切换到内存中。接下来用户进程可以恢复运行。由于进程无法感知到缺页异常,所以页的换入和换出对进程是完全不可见的。
    页面回收机制则是用于将内存映射被修改的内容与底层的块设备同步,有时也被简称为数据回写。

设备驱动程序

按照经典的UNIX箴言“万物皆文件”(everything is a file),对外设的访问可利用/dev目录下的设备文件来完成,程序对设备的处理完全类似于常规的文件。

设备驱动程序的任务在于支持应用程序经由设备文件与设备通信。

外设通常分为两类:

  1. 字符设备:提供连续的数据流,应用程序可以顺序读取,通常不支持随机存取。相反,此类设备支持按字节/字符来读写数据。
  2. 块设备:应用程序可以随机访问设备数据,程序可自行确定读取数据的位置。硬盘是典型的块设备,应用程序可以寻址磁盘上的任何位置,并由此读取数据。

模块和热插拔

模块用于再运行时动态地向内核添加功能,实际上内核的任何子系统几乎都可以模块化,当然,模块也可以再运行时从内核卸载。

模块在本质上不过是普通的程序,只是在内核空间而不是用户空间执行而已。模块必须提供某些代码段在模块初始化(和终止)时执行,以便向内核注册和注销模块。

热插拔:某些总线(例如,USB和FireWire)允许在系统运行时连接设备,而无需系统重启。在系统检测到新设备时,通过加载对应的模块,可以将必要的驱动程序自动添加到内核中。

网络

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

为支持通过文件接口处理网络连接(按照应用程序的观点),Linux使用了源于BSD的套接字抽象。

文件系统

Linux支持许多不同的文件系统:标准的Ext2和Ext3文件系统、ReiserFS、XFS、VFAT(为兼容DOS),还有很多其他文件系统。

由于不同的文件系统所基于的概念抽象差异很大,因此内核必须提供一个新的额外的软件层,将各种底层文件系统的具体特性与应用层隔离,该软件层称为VFS(Virtual File System),该软件层既是向下的接口,即所有文件系统都必须实现该接口,也是向上的接口(用户进程通过系统调用最终能够访问文件系统的功能),如下图所示:

上图中页缓存用于将低速的块设备中的的内容读取到内存中暂存,从而提高块设备的访问性能。

内核中的链表

内核代码中存在大量的双向链表的使用,而这些链表的处理方法都被整合在include/linux/list.h文件下,链表结构的定义在include/linux/types.h文件中,如下:

struct list_head {
	struct list_head *next, *prev;
};

加入链表的数据结构必须包含一个类型为list_head的成员,如下:

struct task_struct {
...
	struct list_head run_list;
...
};

这样能够通过"run_list"维护一个"task_struct"类型的链表。

在链表结构中包含了正向和反向的指针,而链表的起点同样是"list_head"的实例,可以通过"LIST_HEAD(list_name)"进行初始化。

#define LIST_HEAD_INIT(name) { &(name), &(name) }

#define LIST_HEAD(name) \
	struct list_head name = LIST_HEAD_INIT(name)

其中宏命令LIST_HEAD_INIT用于初始化"list_head",将其"prev"成员以及"next"成员都初始化为其自身的地址。

还有一些常用的链表处理方法,这里进行简单的review。

  • list_add(new, head) 以及list_add_tail(new, head)

list_add()方法用于在链表的头节点之后插入一个新的节点,该方法可以用于实现栈。

与相对的,list_add_tail()方法用于在尾节点后面插入一个新的节点,该方法可以用于实现队列。

代码如下:

/*
 * Insert a new entry between two known consecutive entries.
 *
 * This is only for internal list manipulation where we know
 * the prev/next entries already!
 */
static inline void __list_add(struct list_head *new,
			      struct list_head *prev,
			      struct list_head *next)
{
	if (!__list_add_valid(new, prev, next))
		return;

	next->prev = new;
	new->next = next;
	new->prev = prev;
	WRITE_ONCE(prev->next, new);
}

/**
 * list_add - add a new entry
 * @new: new entry to be added
 * @head: list head to add it after
 *
 * Insert a new entry after the specified head.
 * This is good for implementing stacks.
 */
static inline void list_add(struct list_head *new, struct list_head *head)
{
	__list_add(new, head, head->next);
}

/**
 * list_add_tail - add a new entry
 * @new: new entry to be added
 * @head: list head to add it before
 *
 * Insert a new entry before the specified head.
 * This is useful for implementing queues.
 */
static inline void list_add_tail(struct list_head *new, struct list_head *head)
{
	__list_add(new, head->prev, head);
}

上面的代码中__list_add_valid()方法在配置了CONFIG_LIST_HARDENED时有意义,其作用在于确保不会发生链表污染,也就是链表中出现连续几个指向同一个实例的节点。

宏命令"WRITE_ONCE"定义在include/asm_generic/rwonce.h文件中:

#define __WRITE_ONCE(x, val)						\
do {									\
	*(volatile typeof(x) *)&(x) = (val);				\
} while (0)

#define WRITE_ONCE(x, val)						\
do {									\
	compiletime_assert_rwonce_type(x);				\
	__WRITE_ONCE(x, val);						\
} while (0)

用于确保写入操作只会执行一次,也就是不会被编译器优化写入顺序或者在微架构重排序。

  • list_del(entry)

该方法用于从链表中删除参数"entry"指定的节点。
代码如下:

/*
 * Delete a list entry by making the prev/next entries
 * point to each other.
 *
 * This is only for internal list manipulation where we know
 * the prev/next entries already!
 */
static inline void __list_del(struct list_head * prev, struct list_head * next)
{
	next->prev = prev;
	WRITE_ONCE(prev->next, next);
}

static inline void __list_del_entry(struct list_head *entry)
{
	if (!__list_del_entry_valid(entry))
		return;

	__list_del(entry->prev, entry->next);
}

/**
 * list_del - deletes entry from list.
 * @entry: the element to delete from the list.
 * Note: list_empty() on entry does not return true after this, the entry is
 * in an undefined state.
 */
static inline void list_del(struct list_head *entry)
{
	__list_del_entry(entry);
	entry->next = LIST_POISON1;
	entry->prev = LIST_POISON2;
}

其中宏命令"LIST_POSITION1"以及"LIST_POSITION2"定义在include/linux/position.h文件中,对该地址的方法将会导致页错误(page faults),这样能够确保没人会访问未初始化的链表节点。

  • list_empty(head)

该方法用于检测链表是否为空链表。
代码如下:

/**
 * list_empty - tests whether a list is empty
 * @head: the list to test.
 */
static inline int list_empty(const struct list_head *head)
{
	return READ_ONCE(head->next) == head;
}

其中"READ_ONCE"宏定义于include/asm_generic/rwonce.h文件中:

/*
 * Use __READ_ONCE() instead of READ_ONCE() if you do not require any
 * atomicity. Note that this may result in tears!
 */
#ifndef __READ_ONCE
#define __READ_ONCE(x)	(*(const volatile __unqual_scalar_typeof(x) *)&(x))
#endif

#define READ_ONCE(x)							\
({									\
	compiletime_assert_rwonce_type(x);				\
	__READ_ONCE(x);							\
})

用于执行具有原子性的读操作。

  • list_splice(list, head)以及list_splice_tail(list, head)

list_splice()方法用于合并两个链表,合并后的链表依然能够保持栈的顺序访问。与之相对的,而list_splice_tail()合并后的链表将会保持队列的顺序进行访问。
代码如下:

static inline void __list_splice(const struct list_head *list,
				 struct list_head *prev,
				 struct list_head *next)
{
	struct list_head *first = list->next;
	struct list_head *last = list->prev;

	first->prev = prev;
	prev->next = first;

	last->next = next;
	next->prev = last;
}

/**
 * list_splice - join two lists, this is designed for stacks
 * @list: the new list to add.
 * @head: the place to add it in the first list.
 */
static inline void list_splice(const struct list_head *list,
				struct list_head *head)
{
	if (!list_empty(list))
		__list_splice(list, head, head->next);
}

/**
 * list_splice_tail - join two lists, each list being a queue
 * @list: the new list to add.
 * @head: the place to add it in the first list.
 */
static inline void list_splice_tail(struct list_head *list,
				struct list_head *head)
{
	if (!list_empty(list))
		__list_splice(list, head->prev, head);
}

其代码设计逻辑与list_add()方法异曲同工。

  • list_entry(ptr, type, member)

该方法用于从链表中返回对应元素,其中ptr为list_head类型指针,指向要返回节点;type表示返回类型;member表示链表名称,注意到member是声明在type类型的结构体内的。
代码如下:

/**
 * list_entry - get the struct for this entry
 * @ptr:	the &struct list_head pointer.
 * @type:	the type of the struct this is embedded in.
 * @member:	the name of the list_head within the struct.
 */
#define list_entry(ptr, type, member) \
	container_of(ptr, type, member)

其中container_of宏定义在include/linux/containerof.h文件中:

/**
 * container_of - cast a member of a structure out to the containing structure
 * @ptr:	the pointer to the member.
 * @type:	the type of the container struct this is embedded in.
 * @member:	the name of the member within the struct.
 *
 * WARNING: any const qualifier of @ptr is lost.
 */
#define container_of(ptr, type, member) ({				\
	void *__mptr = (void *)(ptr);					\
	static_assert(__same_type(*(ptr), ((type *)0)->member) ||	\
		      __same_type(*(ptr), void),			\
		      "pointer type mismatch in container_of()");	\
	((type *)(__mptr - offsetof(type, member))); })

这个宏命令看起来比较复杂,但是如果忽略中间的静态断言(static_assert)那部分,那么整体的逻辑看起来就比较简单了:通过结构体成员的地址获取到结构体实例的地址,使用ptr指针中保存的地址减去type结构体中member的偏移量,就能得到一个type类型的结构体实例。

如果在链表中查找task_struct的实例,则需要下列示例调用:struct task_struct = list_entry(ptr, struct task_struct, run_list),用于返回与参数ptr对应的task_struct实例。

  • list_for_each(pos, head)

该方法用于从头遍历链表中的所有元素,pos表示遍历链表时的当前位置,而head参数用于指定链表的表头。
代码如下:

/**
 * list_for_each	-	iterate over a list
 * @pos:	the &struct list_head to use as a loop cursor.
 * @head:	the head for your list.
 */
#define list_for_each(pos, head) \
	for (pos = (head)->next; !list_is_head(pos, (head)); pos = pos->next)

该方法相对来说比较简单,不过在使用前需要声明一个list_head类型的临时指针,如下:

struct list_head *pos;
list_for_each(p, &list) {
	/* do something */
}

该方法通常结合list_entry()方法使用。

由于篇幅受限,所以这里只能列举几种最常见的内核链表方法,这些方法还有许多变体都定义在include/linux/list.h文件中。

posted @ 2024-12-17 15:06  2hYan9  阅读(52)  评论(0编辑  收藏  举报