Linux内核学习笔记

学习时间:2023年4月30日

学习来源:

  • Linux内核设计与实现 原书第三版
  • 深入理解Linux内核 原书第三版
  • Linux 2.6源码

1 Linux内核简介

Linux是类Unix(Unix-like)操作系统大家族中的一名成员。从20世纪90年代末开始,Linux这位相对较新的成员突然变得非常流行,并且跻身于那些知名的商用Unix操作系统之列,这些Unix系统包括:

  • AT&T公司(现在由SCO公司拥有)开发的(System V Release 4)SRV4
  • 加利福尼亚大学伯克利分校发布的4.4 BSD
  • DEC公司(现在属于HP)的Digital Unix
  • IBM公司的AIX
  • HP公司的HP-UX
  • Sun公司的Solaris
  • Apple公司的Mac OS X

除了Linux之外,还有一些其他的类Unix操作系统也是开放源代码的,如FreeBSD、NetBSD以及OpenBSD。

1.1 操作系统和内核

操作系统是指在整个系统中负责完成最基本功能和系统管理的那些部分。这些部分应该包括内核、设备驱动程序、启动引导程序、命令行Shell或者其他种类的用户界面、基本的文件管理工具和系统工具。

通常一个内核由负责响应中断的中断服务程序,负责管理多个进程从而分享处理器时间的调度程序,负责管理进程地址空间的内存管理程序和网络、进程间通信等系统服务程序共同组成。对于提供保护机制的现代系统来说,内核独立于普通应用程序,它一般处于系统态,拥有受保护的内存空间和访问硬件设备的所有权限。这种系统态和被保护起来的内存空间,统称为内核空间。相对的,应用程序在用户空间执行。它们只能看到允许它们使用的部分系统资源,并且只使用某些特定的系统功能,不能直接访问硬件,也不能访问内核划给别人的内存范围,还有其他一些使用限制。当内核运行的时候,系统以内核态进入内核空间执行。而执行一个普通用户程序时,系统将以用户态进入以用户空间执行。

在系统中运行的应用程序通过系统调用来与内核通信。应用程序通常调用库承数(比如C库函数)再由库函数通过系统调用界面,让内核代其完成各种不同任务。一些库调用提供了系统调用不具备的许多功能,在那些较为复杂的函数中,调用内核的操作通常只是整个工作的一个步骤而已。举个例子,拿 printf 函数来说,它提供了数据的缓存和格式化等操作,而调用write函数将数据写到控制台上只不过是其中的一个动作罢了。不过,也有一些库函数和系统调用就是一一对应的关系,比如,open库函数除了调用open系统调用之外,几乎什么也不做。当一个应用程序执行一条系统调用,我们说内核正在代其执行。如果进一步解释,在这种情况下,应用程序被称为通过系统调用在内核空间运行,而内核被称为运行于进程上下文中。这种交互关系——应用程序通过系统调用界面陷入内核——是应用程序完成其工作的基本行为方式。

image-20230501152430823

我们可以将每个处理器在任何指定时间点上的活动概括为下列三者之一:

  • 运行于用户空间,执行用户程序
  • 运行于内核空间,处于进程上下文,代表某个特定的进程执行。
  • 运行于内核空间,处于中断上下文,与任何进程无关,处理某个特定的中断。

以上所列几乎包括所有情况,即使边边角角的情况也不例外,例如,当CPU空闲时,内核就运行一个空进程,处于进程上下文,但运行于内核空间。

1.2 硬件依赖性

Linux试图在硬件无关的源代码与硬件相关的源代码之间保持清晰的界限。为了做到这点,在archinclude目录下包含了23个子目录,以对应Linux所支持的不同硬件平台。这些平台的标准名字如下(部分):

  • armarm26:基于ARM处理器的计算机(如PDA)和嵌入式设备
  • mips:基于MIPS微处理器的工作站
  • x86_64:基于AMD的64位微处理器的工作站

2 内核源码

2.1 获取源码

内核源码网站:https://kernel.org/,这里采用`2.6.34`进行讲解。

解压缩:

1
tar xvzf linux-2.6.34.tar.gz

2.2 内核源码树

源码树的根目录如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|---arch 特定体系结构的源码
|---block 块设备IO层
|---crypto 加密API
|---Documentation 内核源码文档
|---drivers 设备驱动程序
|---firmware 使用某些驱动程序而需要的设备固件
|---fs VFS和各种文件系统
|---include 内核头文件
|---init 内核引导和初始化
|---ipc 进程间通信代码
|---kernel 像调度程序这样的核心子系统
|---lib 通用内核函数
|---mm 内存管理子系统和VM
|---net 网络子系统
|---samples 示例,示范代码
|---scripts 编译内核所用的脚本
|---security Linux安全模块
|---sound 语音子系统
|---usr 早期用户空间代码
|---tools 在Linux开发中有用的工具
|---virt 虚拟化基础结构

2.3 内核开发的特点

相对于用户空间内应用程序的开发,内核开发有一些独特之处。尽管这些差异并不会使开发内核代码的难度超过开发用户代码,但它们依然有很大不同。

最重要的差异包括以下几种:

  • 内核编程时既不能访问C库也不能访问标准的C头文件。
  • 内核编程时必须使用GNUC。
  • 内核编程时缺乏像用户空间那样的内存保护机制。
  • 内核编程时难以执行浮点运算。
  • 内核给每个进程只有一个很小的定长堆栈。
  • 由于内核支持异步中断、抢占和SMP,因此必须时刻注意同步和并发。
  • 要考虑可移植性的重要性。

2.3.1 无libc库或标准头文件

与用户空间的应用程序不同,内核不能链接使用标准C函数库。

但是大部分常用的C库函数在内核中都已经得到了实现。比如操作字符串的函数组就位于lib/string.c文件中。只要包含<linux/string.h>头文件,就可以使用它们。

注意:谈及头文件时,都指的是组成内核源代码树的内核头文件。内核源代码文件不能包含外部头文件,就像它们不能用外部库一样。

基本的头文件位于内核源代码树顶级目录下的include目录中。例如,头文件<linux/inotify.h>对应内核源代码树的include/linux/inotify.h

2.3.2 GNU C

内核并不完全符合 ANSI C标准。实际上,只要有可能,内核开发者总是要用到gcc提供的许多语言的扩展部分。(gcc是多种GNU编译器的集合,它包含的C编译器既可以编译内核,也可以编译Linux系统上用C语言写的其他代码)

内核开发者使用的C语言涵盖了ISO C99标准和GNU C扩展特性。

2.3.3 没有内存保护机制

如果一个用户程序试图进行一次非法的内存访问,内核就会发现这个错误,发送 SIGSEGV 信号,并结束整个进程。然而,如果是内核自己非法访问了内存,那后果就很难控制了。

内核中发生的内存错误会导致 oops,这是内核中出现的最常见的一类错误。在内核中,不应该去做访问非法的内存地址,引用空指针之类的事情,否则它可能会死掉,却根本不告诉你一声——在内核里,风险常常会比外面大一些。

此外,内核中的内存都不分页。也就是说,你每用掉一个字节,物理内存就减少一个字节。所以,在你想往内核里加入什么新功能的时候,要记住这一点。

2.3.4 不要轻易在内核中使用浮点数

在用户空间的进程内进行浮点操作的时候,内核会完成从整数操作到浮点数操作的模式转换。在执行浮点指令时到底会做些什么,因体系结构不同,内核的选择也不同,但是,内核通常捕获陷阱并着手于整数到浮点方式的转变。

与用户空间进程不同,内核并不能完美地支持浮点操作,因为它本身不能陷入。在内核中使用浮点数时,除了要人工保存和恢复浮点寄存器,还有其他一些琐碎的事情要做。如果要直截了当地回答,那就是:别这么做了,除了一些极少的情况,不要在内核中使用浮点操作。

2.3.5 容积小而固定的栈

用户空间的程序可以从栈上分配大量的空间来存放变量,甚至巨大的结构体或者是包含数以千计的数据项的数组都没有问题。之所以可以这么做,是因为用户空间的栈本身比较大,而且还能动态地增长。

内核栈的准确大小随体系结构而变。在x86上,栈的大小在编译时配置,可以是4KB也可以是8KB。从历史上说,内核栈的大小是两页,这就意味着,32位机的内核栈是8KB,而64位机是16KB,这是固定不变的。每个处理器都有自己的栈。

2.3.6 同步和并发

内核很容易产生竞争条件。和单线程的用户空间程序不同,内核的许多特性都要求能够并发地访问共享数据,这就要求有同步机制以保证不出现竞争条件,特别是:

  • Linux是抢占多任务操作系统。内核的进程调度程序即是对进程进行调度和重新调度。内核必须和这些任务同步。
  • Linux内核支持对称多处理器系统(SMP)。所以,如果没有适当的保护,同时在两个或两个以上的处理器上执行的内核代码很可能会同时访问共享的同一个资源。
  • 中断是异步到来的,完全不顾及当前正在执行的代码。也就是说,如果不加以适当的保护,中断完全有可能在代码访问资源的时候到来,这样,中段处理程序就有可能访问同一资源。
  • Linux内核可以抢占。所以,如果不加以适当的保护,内核中一段正在执行的代码可能会被另外一段代码抢占,从而有可能导致几段代码同时访问相同的资源。

常用的解决竞争的办法是自旋锁和信号量

2.3.7 可移植的重要性

尽管用户空间的应用程序不太注意移植问题,然而Linux却是一个可移植的操作系统,并且要一直保持这种特点。也就是说,大部分C代码应该与体系结构无关,在许多不同体系结构的计算机上都能够编译和执行,因此,必须把与体系结构相关的代码从内核代码树的特定目录中适当地分离出来。

诸如保持字节序、64位对齐、不假定字长和页面长度等一系列准则都有助于移植性。

3 进程管理

3.1 进程的概念

进程就是处于执行期的程序或程序执行(目标码存放在某种存储介质上,例如内存)。

进程包含的内容:

  • 一段可执行程序代码(Unix称其为代码段)
  • 打开的文件
  • 挂起的信号
  • 内核内部数据
  • 处理器状态
  • 一个或多个具有内存映射的内存地址空间
  • 一个或多个执行线程
  • 存放全局变量的数据段

实际上,进程就是正在执行的程序代码的实时结果。

执行线程,简称线程(thread),是在进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。内核调度的对象是线程,而不是进程。在传统的Unix系统中,一个进程只包含一个线程,但现在的系统中,包含多个线程的多线程程序司空见惯。Linux系统的线程实现非常特别:它对线程和进程并不特别区分。对Linux而言,线程只不过是一种特殊的进程罢了。

在现代操作系统中,进程提供两种虚拟机制:虚拟处理器和虚拟内存。虽然实际上可能是许多进程正在分享一个处理器,但虚拟处理器给进程一种假象,让这些进程觉得自己在独享处理器。第4章将详细描述这种虚拟机制。而虚拟内存让进程在分配和管理内存时觉得自己拥有整个系统的所有内存资源。第12章将描述虚拟内存机制。有趣的是,注意在线程之间可以共享虚拟内存,但每个都拥有各自的虚拟处理器。

程序本身并不是进程,进程是处于执行期的程序以及相关的资源的总称。实际上,完全可能存在两个或多个不同的进程执行的是同一个程序。并且两个或两个以上并存的进程还可以共享许多诸如打开的文件、地址空间之类的资源。

进程在创建它的时刻开始存活。在Linux系统中,这通常是调用fork系统调用的结果,该系统调用通过复制一个现有进程来创建一个全新的进程。调用fork的进程称为父进程,新产生的进程称为子进程。在该调用结束时,在返回点这个相同位置上,父进程恢复执行,子进程开始执行。fork系统调用从内核返回两次:一次回到父进程,另一次回到新产生的子进程。

通常,创建新的进程都是为了立即执行新的、不同的程序,而接着调用exec这组函数就可以创建新的地址空间,并把新的程序载入其中。在现代Linux内核中,fork实际上是由clone系统调用实现的,后者将在后面讨论。

最终,程序通过exit系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。父进程可以通过wait系统调用查询子进程是否终结,这其实使得进程拥有了等待特定进程执行完毕的能力。进程退出执行后被设置为僵死状态,直到它的父进程调用waitwaitpid为止。

注意:进程的另一个名字是任务(task)。Linux内核通常把进程也叫做任务。

3.2 进程描述符和任务结构

3.2.1 概念

内核把进程的列表存放在叫做任务队列双向循环链表中。链表中的每一项都是类型为task_struct的被称为进程描述符的结构,该结构定义在<linux/sched.h>文件中。进程描述符中包含一个具体进程的所有信息。

1
2
3
struct task_struct {
// task_struct代码略...
};

task_struct相对较大,在32位机器上,它大约有1.7KB。但如果考虑到该结构内包含了内核管理一个进程所需的所有信息,那么它的大小也算相当小了。进程描述符中包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,进程的状态,还有其他更多信息(见图3-1)。

image-20230502163224477

包含的信息

在这里插入图片描述

image-20230515101350472

3.2.2 分配进程描述符

补充:内核栈

在每一个进程的生命周期中,经常会通过系统调用陷入内核。在执行系统调用陷入内核之后,这些内核代码所使用的栈并不是原先用户空间中的栈,而是一个内核空间的栈,这个称作进程的内核栈void *stack)。

每个task的栈分成用户栈和内核栈两部分,进程内核栈在kernel中的定义是:

1
2
3
4
5
// <linux/sched.h>
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};

每个task的内核栈大小为THREAD_SIZE,大小由不同的体系结构确定,在32位系统是8KB,64位系统里是16KB。在x86上:

1
2
#define THREAD_SIZE_ORDER	1
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER) // 8KB

Linux通过slab分配器分配task_struct进程描述符,这样能达到对象复用和缓存着色(cache coloring)的目的。在2.6以前的内核中,各个进程的task_struct存放在它们内核栈的尾端。这样做是为了让那些像x86那样寄存器较少的硬件体系结构只要通过栈指针就能计算出它的位置,而避免使用额外的寄存器专门记录。由于现在用slab分配器动态生成task_struct,所以只需在栈底(对于向下增长的栈来说)或栈顶(对于向上增长的栈来说)创建一个新的结构 struct thread_info (见图3-2)。

image-20230502163550400

x86上,struct thread_info在文件<asm/thread_info.h>中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct thread_info {
struct task_struct *task; /* 指向进程描述符的指针 */
struct exec_domain *exec_domain; /* execution domain */
__u32 flags; /* low level flags */
__u32 status; /* thread synchronous flags */
__u32 cpu; /* current CPU */
int preempt_count; /* 0 => preemptable, <0 => BUG */
mm_segment_t addr_limit;
struct restart_block restart_block;
void __user *sysenter_return;
#ifdef CONFIG_X86_32
unsigned long previous_esp; /* ESP of the previous stack in
case of nested (IRQ) stacks
*/
__u8 supervisor_stack[0];
#endif
int uaccess_err;
};

每个任务的thread_info结构在它的内核栈的尾端分配。结构中*task域中存放的是指向该任务实际task_struct的指针。Linux使用task_struct存储通用的信息,将体系结构相关的部分存储在thread_info中。

3.2.3 进程描述符的存放

内核通过一个唯一的进程标识值PID来标识每个进程。PID是一个数,表示为 pid_t隐含类型,实际上就是一个int类型。为了与老版本的 Unix 和 Linux 兼容,PID的最大值默认设置为32768(short int短整型的最大值),尽管这个值也可以增加到高达 400万(这受<linux/threads.h>中所定义PID最大值的限制)。内核把每个进程的PID存放在它们各自的进程描述符中。

1
2
3
4
5
struct task_struct {
// ...
pid_t pid;
// ...
};

这个最大值很重要,因为它实际上就是系统中允许同时存在的进程的最大数目。尽管32768 对于一般的桌面系统足够用了,但是大型服务器可能需要更多进程。

在内核中,访问任务通常需要获得指向其task_struct的指针。实际上,内核中大部分处理进程的代码都是直接通过task_struct进行的。因此,通过current宏查找到当前正在运行进程的进解描述符的速度就显得尤为重要。硬件体系结构不同,该宏的实现也不同,它必须针对专门的硬件体系结构做处理。有的硬件体系结构可以拿出一个专门寄存器来存放指向当前进程task_struct的指针,用于加快访问速度。而有些像x86这样的体系结构(其寄存器并不富余),就只能在内核栈的尾端创建thread_info结构,通过计算偏移间接地查找task_struct结构。

3.2.3 进程状态

进程描述符中的state域描述了进程的当前状态(见图3-3)。

1
2
3
4
5
struct task_struct {
// ...
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
// ...
};

image-20230502165308682

系统中的每个进程都必然处于五种进程状态中的一种。该域的值也必为下列五种状态标志之一:

  • TASK_RUNING(运行):进程是可执行的;它或者正在执行,或者在运行队列中等待执行。这是进程在用户空间中执行的唯一可能的状态;这种状态也可以应用到内核空间中正在执行的进程。
  • TASK_INTERRUPTIBLE(可中断):进程正在睡眠(也就是说它被阻塞),等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并随时准备投入运行。
  • TASK_UNINTERRUPTIBLE(不可中断):除了就算是接收到信号也不会被唤醒或准备投入运行外,这个状态与可打断状态相同。这个状态通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现。由于处于此状态的任务对信号不做响应,所以较之可中断状态,使用得较少。
  • __TASK_TRACED:被其他进程跟踪的进程,例如通过ptrace对调试程序进行跟踪
  • __TASK_STOPPED(停止):进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。此外,在调试期间接收到任何信号,都会使进程进入这种状态。

3.2.4 设置当前进程状态

内核经常需要调整某个进程的状态。这时最好使用set_task_state宏:

1
set_task_state(task, state); // 将任务task的状态设置为state

3.2.5 进程上下文

可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。一般程序在用户空间执行。当一个程序调执行了系统调用或者触发了某个异常,它就陷入了内核空间。此时,我们称内核代表进程执行并处于进程上下文中

系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行,即对内核的所有访问都必须通过这些接口。

3.2.6 进程家族树

系统中的每个进程必有一个父进程,相应的,每个进程也可以拥有零个或多个子进程。拥有同一个父进程的所有进程被称为兄弟。进程间的关系存放在进程描述符中。每个task_struct都包含一个指向其父进程task_struct、叫做parent的指针,还包含一个称为children的子进程链表。

1
2
3
4
5
6
struct task_struct {
// ...
struct task_struct *parent; /* recipient of SIGCHLD, wait4() reports */
// ...
struct list_head children; /* list of my children */
};

所以,对于当前进程,可以通过下面的代码获得其父进程的进程描述符:

1
struct task_struct *my_parent = current->parent;

同样,也可以按以下方式依次访问子进程:

1
2
3
4
5
struct task_struct *task;
struct list_head *list;
list_for_each(list, &current->children) {
task = list_entry(list, struct task_struct, sibling);
}

3.3 进程创建

Unix 的进程创建很特别。许多其他的操作系统都提供了产生(spawn)进程的机制,首先在新的地址空间里创建进程,读入可执行文件,最后开始执行。Unix采用了与众不同的实现方式,它把上述步骤分解到两个单独的函数中去执行:forkexec。首先,fork通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID(每个进程唯一)、PPID(父进程的进程号,子进程将其设置为被拷贝进程的PID)和某些资源和统计量(例如,挂起的信号,它没有必要被继承)。exec函数负责读取可执行文件并将其载入地址空间开始运行。把这两个函数组合起来使用的效果跟其他系统使用的单一函数的效果相似。

3.3.1 写时拷贝

传统的fork系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。Linux的fork使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。

只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候才进行。在页根本不会被写入的情况下它们就无须复制了。

fork的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。

3.3.2 fork

Linux通过clone系统调用实现fork。这个调用通过一系列的参数标志来指明父、子进程需要共享的资源。forkvfork__clone库函数都根据各自需要的参数标志去调用clone,然后由clone去调用do_fork函数。

do_fork完成了创建中的大部分工作,它的定义在kernel/fork.c文件中。该函数调用copy_process函数,然后让进程开始运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
* Ok, this is the main fork-routine.
*
* It copies the process, and if successful kick-starts
* it and waits for it to finish using the VM if required.
*/
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
struct pt_regs *regs,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr) {
// ...
p = copy_process(clone_flags, stack_start, regs, stack_size,
child_tidptr, NULL, trace);
// ...
}

copy_process函数完成的工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/*
* This creates a new process as a copy of the old one,
* but does not actually start it yet.
*
* It copies the registers, and all the appropriate
* parts of the process environment (as per the clone
* flags). The actual kick-off is left to the caller.
*/
static struct task_struct *copy_process(unsigned long clone_flags,
unsigned long stack_start,
struct pt_regs *regs,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace) {
struct task_struct *p;
// ...
// 1.为新进程创建一个内核栈、thread_info结构和task_struct
// 这些值与当前进程的值相同,此时,子进程和父进程的描述符完全相同
p = dup_task_struct(current);
// ...
// 2.确保用户所拥有的进程数没有超过给他分配的资源的限制
if (nr_threads >= max_threads)
goto bad_fork_cleanup_count;
// ...
// 3.子进程着手使自己与父进程区别开来
// 进程描述符内的许多成员都要被清0或设为初始值
// task_struct中的大多数数据都依然未被修改
p->did_exec = 0;
delayacct_tsk_init(p); /* Must remain after dup_task_struct() */
copy_flags(clone_flags, p);
INIT_LIST_HEAD(&p->children);
INIT_LIST_HEAD(&p->sibling);
rcu_copy_process(p);
p->vfork_done = NULL;
spin_lock_init(&p->alloc_lock);
init_sigpending(&p->pending);
// 略去该部分代码
// ...
// 4.将子进程的状态设置为TASK_UNINTERRUPTIBLE,以保证它不会被投入进行
// ...
// 5.为子进程分配一个有效的PID
pid = alloc_pid(p->nsproxy->pid_ns);
// ...
// 6.根据传递给 clone 的参数标志clone_flags,copy_process拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。
/* copy all the process information */
if ((retval = copy_semundo(clone_flags, p)))
goto bad_fork_cleanup_audit;
if ((retval = copy_files(clone_flags, p)))
goto bad_fork_cleanup_semundo;
if ((retval = copy_fs(clone_flags, p)))
goto bad_fork_cleanup_files;
if ((retval = copy_sighand(clone_flags, p)))
goto bad_fork_cleanup_fs;
if ((retval = copy_signal(clone_flags, p)))
goto bad_fork_cleanup_sighand;
if ((retval = copy_mm(clone_flags, p)))
goto bad_fork_cleanup_signal;
if ((retval = copy_namespaces(clone_flags, p)))
goto bad_fork_cleanup_mm;
if ((retval = copy_io(clone_flags, p)))
goto bad_fork_cleanup_namespaces;
// ...
// 7.最后,copy_process做扫尾工作并返回一个指向子进程的指针
// ...
return p;
}

再回到do_fork函数,如果copy_process函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程首先执行。因为一般子进程都会马上调用exec函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。

3.4 线程在Linux中的实现

Linux 实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux把所有的线程都当做进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,如地址空间)。

3.4.1 创建线程

线程的创建和普通进程的创建类似,只不过在调用clone的时候需要传递一些参数标志来指明需要共享的资源:

1
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

上面的代码产生的结果和调用fork差不多,只是父子俩共享地址空间、文件系统资源、文件描述符和信号处理程序(分别对应上面的四个宏)。换个说法就是,新建的进程和它的父进程就是流行的所谓线程。

fork的实现为:

1
clone(SIGHAND, 0);

vfork的实现为:

1
clone(CLONE_VFORK | CLONE_VM | SIGHAND, 0);

传递给clone的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。参数定义在<linux/sched.h>

1
2
3
4
5
6
7
8
9
10
11
/*
* cloning flags:
*/
#define CLONE_VM 0x00000100 /* 父子进程共享地址空间 */
#define CLONE_FS 0x00000200 /* 父子进程共享文件系统信息 */
#define CLONE_FILES 0x00000400 /* 父子进程共享打开的文件 */
#define CLONE_SIGHAND 0x00000800 /* 父子进程共享信号处理函数和被阻塞的信号 */
#define CLONE_VFORK 0x00004000 /* 调用vfork,所以父进程准备睡眠等待子进程将其唤醒 */
#define CLONE_PARENT 0x00008000 /* 指定子进程与父进程拥有同一个父进程 */
#define CLONE_IO 0x80000000 /* Clone io context */
// 其余略
标志 含义
CLONE_PARENT 创建的子进程的父进程是调用者的父进程,新进程与创建它的进程成了“兄弟”而不是“父子”
CLONE_FS 子进程与父进程共享相同的文件系统,包括root、当前目录、umask
CLONE_FILES 子进程与父进程共享相同的文件描述符(file descriptor)表
CLONE_NEWNS 在新的namespace启动子进程,namespace描述了进程的文件hierarchy
CLONE_SIGHAND 子进程与父进程共享相同的信号处理(signal handler)表
CLONE_VFORK 父进程被挂起,直至子进程释放虚拟内存资源
CLONE_VM 子进程与父进程运行于相同的内存空间
CLONE_PID 子进程在创建时PID与父进程一致
CLONE_THREAD Linux 2.4中增加以支持POSIX线程标准,子进程与父进程共享相同的线程群

3.4.2 内核线程

暂略

3.5 进程终结

当一个进程终结时,内核必须释放它所占有的资源并发送SIGCHLD信号给父进程。

一般来说,进程的析构是自身引起的。它发生在进程调用exit系统调用时,既可能显式地调用这个系统调用,也可能隐式地从某个程序的主函数返回。当进程接受到它既不能处理也不能忽略的信号或异常时,它还可能被动地终结。不管进程是怎么终结的,该任务大部分都要靠 do_exit来完成。

该函数定义于kernel/exit.c

1

至此,与进程相关联的所有资源都被释放掉了(假设该进程是这些资源的唯一使用者)。进程不可运行(实际上也没有地址空间让它运行)并处于EXIT_ZOMBIE退出状态。此时它占用的所有内存就是内核栈、thread_info结构和 tast_struct结构。此时进程存在的唯一目的就是向它的父进程提供信息。父进程检索到信息后,或者通知内核那是无关的信息后,由进程所持有的剩余内存被释放,归还给系统使用。

3.5.1 删除进程描述符

在调用了do_exit之后,尽管线程已经僵死不能再运行了,但是系统还保留了它的进程描述符。前面说过,这样做可以让系统有办法在子进程终结后仍能获得它的信息。因此,进程终结时所需的清理工作和进程描述符的删除被分开执行。在父进程获得已终结的子进程的信息后,或者通知内核它并不关注那些信息后,子进程的task_struct结构才被释放。

wait这一族函数都是通过唯一(但是很复杂)的一个系统调用wait4来实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供的指针会包含子函数退出时的退出代码。

当最终需要释放进程描述符时,exit.c/release_task会被调用,用以完成以下工作:

1
2
3
4
5
6
7
8
9
10
void release_task(struct task_struct * p) {
// __exit_signal()->_unhash_process()->detach_pid(),最终从pidhash上删除该进程,同时从任务列表(双向循环链表)中删除该进程
__exit_signal(p);

// 如果这个进程是线程组最后一个进程,并且领头进程已经死掉,那么release_task就要通知僵死的领头进程的父进程

// 调用put_task_struct释放进程内核栈和thread_info结构所占的页,并释放task_struct所占的slab高速缓存

// ...
}

3.5.2 处理孤儿进程

如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。前面的部分已经有所暗示,对于这个问题,解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程。在do exit中会调用exit_notify,该函数会调用forget_original_parent,而后者会调用find_new_reaper来执行寻父过程。

一旦系统为进程成功地找到和设置了新的父进程,就不会再有出现驻留僵死进程的危险了。init进程会例行调用wait来检查其子进程,清除所有与其相关的僵死进程。

4 进程调度

调度程序负责决定将哪个进程投入运行,何时运行以及运行多长时间。进程调度程序(常常简称调度程序)可看做在可运行态进程之间分配有限的处理器时间资源的内核子系统。调度程序是像Linux这样的多任务操作系统的基础。只有通过调度程序的合理调度,系统资源才能最大限度地发挥作用,多进程才会有并发执行的效果。

最大限度地利用处理器时间的原则是,只要有可以执行的讲程,那么就总会有进程正在执行。但是只要系统中可运行的进程的数目比处理器的个数多,就注定某一给定时刻会有一些进程不能执行。这些进程在等待运行。在一组处于可运行状态的进程中选择一个来执行,是调度程序所需完成的基本工作。

4.1 多任务

多任务操作系统就是能同时并发地交互执行多个进程的操作系统。在单处理器机器上,这会产生多个进程在同时运行的幻觉。在多处理器机器上,这会使多个进程在不同的处理机上真正同时、并行地运行。无论在单处理器或者多处理器机器上,多任务操作系统都能使多个进程处于堵塞或者睡眠状态,也就是说,实际上不被投入执行,直到工作确实就绪。这些任务尽管位于内存,但并不处于可运行状态。相反,这些进程利用内核阻塞自己,直到某一事件(键盘输入、网络数据、过一段时间等)发生。因此,现代Linux系统也许有100个进程在内存,但是只有一个处于可运行状态。

多任务系统可以划分为两类:非抢占式多任务和抢占式多任务。像所有Unix的变体和许多其他现代操作系统一样,Linux提供了抢占式的多任务模式。在此模式下,由调度程序来决定什么时候停止一个进程的运行,以便其他进程能够得到执行机会。这个强制的挂起动作就叫做抢占(preemption)。进程在被抢占之前能够运行的时间是预先设置好的,而且有一个专门的名字,叫进程的时间片(timeslice)。时间片实际上就是分配给每个可运行进程的处理器时间段。有效管理时间片能使调度程序从系统全局的角度做出调度决定,这样做还可以避免个别进程独占系统资源。当今众多现代操作系统对程序运行都采用了动态时间片计算的方式,并且引入了可配置的计算策略。

相反,在非抢占式多任务模式下,除非进程自己主动停止运行,否则它会一直执行。进程主动挂起自己的操作称为让步(yielding)。理想情况下,进程通常做出让步,以便让每个可运行进程享有足够的处理器时间。但这种机制有很多缺点:调度程序无法对每个进程该执行多长时间做出统一规定,所以进程独占的处理器时间可能超出用户的预料;此外,一个决不做出让步的悬挂进程就能使系统崩溃。

4.2 Linux的进程调度

在Linux 2.5开发系列的内核中,调度程序做了大手术。开始采用了一种叫做O(1)调度程序的新调度程序。

自2.6内核系统开发初期,开发人员为了提高对交互程序的调度性能引入了新的进程调度算法。其中最为著名的是反转楼梯最后期限调度算法(Rotating Staircase Deadline scheduler)(RSDL),该算法吸取了队列理论,将公平调度的概念引入了Linux调度程序。并且最终在2.6.23内核版本中替代了O(1)调度算法,它此刻被称为完全公平调度算法,或者简称CFS

4.3 策略

4.3.1 IO消耗型和处理器消耗型的进程

进程可以被分为I/O消耗型和处理器消耗型。

前者指进程的大部分时间用来提交I/O请求或是等待I/O请求。因此,这样的进程经常处于可运行状态,但通常都是运行短短的一会儿,因为它在等待更多的IO请求后时总会阻塞。

处理器耗费型进程把时间大多用在执行代码上。除非被抢占,否则它们通常都一直不停地运行,因为它们没有太多的I/O需求。

调度策略通常要在两个矛盾的目标中间寻找平衡:进程响应迅速(响应时间短)和最大系统利用率(高吞吐量)。为了满足上述需求,调度程序通常采用一套非常复杂的算法来决定最值得运行的进程投入运行,但是它往往并不保证低优先级进程会被公平对待。Unix系统的调度程序更倾向于I/O消耗型程序,以提供更好的程序响应速度。Linux为了保证交互式应用和桌面系统的性能,所以对进程的响应做了优化(缩短响应时间),更倾向于优先调度I/O消耗型进程。

4.3.2 进程优先级

4.3.3 时间片

4.3.4 调度策略的活动

4.4 Linux调度算法

4.5 Linux调度的实现

相关代码位于kernel/sched_fair.c中。

4.5.1 时间记账

5 系统调用

在现代操作系统中,内核提供了用户进程与内核进行交互的一组接口。这些接口让应用程序受限地访问硬件设备,提供了创建新进程并与已有进程进行通信的机制,也提供了申请操作系统其他资源的能力。这些接口在应用程序和内核之间扮演了使者的角色,应用程序发出各种请求,而内核负责满足这些请求(或者无法满足时返回一个错误)。

在Linux中,系统调用是用户空间访问内核的唯一手段;除异常和陷入外,他们是内核唯一的合法入口。

5.1 API、POSIX和C库

一般情况下,应用程序通过在用户空间实现的应用编程接口(API)而不是直接通过系统调用来编程。应用程序使用的这种编程接口实际上并不需要和内核提供的系统调用对应。一个API定义了一组应用程序使用的编程接口。它们可以实现成一个系统调用,也可以通过调用多个系统调用来实现,而完全不使用任何系统调用也不存在问题。实际上,API可以在各种不同的操作系统上实现,给应用程序提供完全相同的接口,而它们本身在这些系统上的实现却可能迥异。图5-1给出POSIX、API、C库以及系统调用之间的关系。

image-20230506161457587

在Unix世界中,最流行的应用编程接口是基于POSIX标准的

POSIX是说明API和系统调用之间关系的一个极好例子。在大多数Unix系统上,根据POSIX定义的API函数和系统调用之间有着直接关系。实际上,POSIX标准就是仿照早期Unix 系统的接口建立的。

Linux的系统调用像大多数Unix系统一样,作为C库的一部分提供。C库实现了Unix系统的主要API,包括标准C库函数和系统调用接口。所有的C程序都可以使用C库,而由于C语言本身的特点,其他语言也可以很方便地把它们封装起来使用。此外,C库提供了POSIX的绝大部分 API。

5.2 系统调用

要访问系统调用(syscall),通常通过C库中定义的函数调用来进行。

5.2.1 系统调用号

5.2.2 系统调用的性能

Linux系统调用比其他许多操作系统执行得要快。

  • Linux很短的上下文切换时间是一个重要原因,进出内核都被优化得简洁高效
  • 另外一个原因是系统调用处理程序和每个系统调用本身也都非常简洁。

5.3 系统调用处理程序

6 内存寻址

80x86微处理器为例。

6.1 内存地址

有三种不同的地址:

  • 逻辑地址

包含在机器语言指令中用来指定一个操作数或一条指令的地址。每一个逻辑地址都由一个偏移量组成,偏移量指明了从段开始的地方到实际地址之间的距离。

  • 线性地址(虚拟地址)

是一个32位无符号整数,可以用来表示高达4GB的地址。线性地址通常用十六进制数字表示,值的范围从0x000000000xffffffff

  • 物理地址

用于内存芯片级内存单元寻址。它们与从微处理器的地址引脚发送到内存总线上的电信号相对应。物理地址由32位或36位无符号整数表示。


物理地址与虚拟地址

问题:多个程序要并发执行时,那这些程序的数据同时放到内存,我们该如何区分哪些数据属于哪个程序。

img

6.2 硬件中的分段

从80286模型开始,Intel微处理器以两种不同的方式执行地址转换,这两种方式分别称为实模式和保护模式。实模式存在的主要原因是要维持处理器与早期模型兼容,并让操作系统自举。

6.2.1 段选择符和段寄存器

一个逻辑地址由两部分组成:一个段标识符(segment)和一个指定段内相对地址的偏移量(offset)。段标识符是一个16位长的字段,又称为段选择符,而偏移量是一个32位长的字段。

image-20230515102626117

为了快速方便地找到段选择符,处理器提供段寄存器,段寄存器的唯一目的是存放段选择符。这些段寄存器称为cs,ss,ds,es,fs和gs。6个寄存器中3个有专门的用途:

  • cs:代码段寄存器,指向包含程序指令的段
  • ss:栈段寄存器,指向包含当前程序栈的段。
  • ds:数据段寄存器,指向包含静态数据或者全局数据段
  • 其他3个段寄存器作一般用途,可以指向任意的数据段。

cs寄存器还有一个很重要的功能:它含有一个两位的字段,用以指明CPU的当前特权级(Current Privilege Level,CPL)。值为0代表最高优先级,而值为3代表最低优先级。Linux只用0级和3级,分别称之为内核态和用户态。

6.2.2 段描述符

每个段由一个8字节的段描述符表示,它描述了段的特征。段描述符放在全局描述符表(Global Descriptor Table,GDT)或局部描述符表(Local Descriptor Table,LDT)中。

通常只定义一个GDT,而每个进程除了存放在GDT中的段之外如果还需要创建附加的段,就可以有自己的LDT。GDT在主存中的地址和大小存放在gdtr控制寄存器中,当前正被使用的LDT地址和大小放在ldtr控制寄存器中。

6.2.3 快速访问段描述符

6.2.4 分段单元