《Linux内核设计与实现》笔记

《Linux内核设计与实现》笔记

参考:《Linux内核设计与实现》读书笔记

第一章 Linux内核简介

1. 单内核和微内核

原理 优势 劣势
单内核 整体上作为一个单独的大过程来实现,整个内核都在一个大内核地址空间上运行。 1. 简单。 2. 高效:所有内核都在一个大的地址空间上,所以内核各个功能之间的调用和调用函数类似,几乎没有性能开销。 一个功能的崩溃会导致整个内核无法使用。
微内核 内核按功能被划分成多个独立的过程。每个过程独立的运行在自己的地址空间上。 1. 安全:内核的各种服务独立运行,一种服务挂了不会影响其他服务。 内核各个服务之间通过进程间通信互通消息,比较复杂且效率低。

因为 IPC 机制的开销多于函数调用,又因为会涉及内核空间与用户空间的上下文切换,因此,消息传递需要一定的周期,而单内核中简单的函数调用没有这些开销。

Linux的内核虽然是基于单内核的,运行在单独的内核地址空间上。但是经过这么多年的发展,也具备微内核的一些特征。(体现了Linux实用至上的原则)

主要有以下特征:

  1. 模块化设计,支持动态加载内核模块
  2. 支持对称多处理(SMP)
  3. 内核可以抢占(preemptive),允许内核运行的任务有优先执行的能力
  4. 支持内核线程,不区分线程和进程

2. 内核版本号

内核的版本号主要有四个数组组成。比如版本号:2.6.26.1 其中:

2 - 主版本号

6 - 从版本号或副版本号

26 - 修订版本号

1 - 稳定版本号

副版本号表示这个版本是稳定版(偶数)还是开发版(奇数),上面例子中的版本号是稳定版。

稳定的版本可用于企业级环境。

修订版本号的升级包括BUG修正,新的驱动以及新的特性的追加。

稳定版本号主要是一些关键性BUG的修改。

第二章 从内核出发

1. 获取内核源码

内核是开源的,所有获取源码特别方便,参照以下的网址,可以通过git或者直接下载压缩好的源码包。

http://www.kernel.org

2. 内核源码的结构

目录 说明
arch 特定体系结构的代码
block 块设备I/O层
crypo 加密API
Documentation 内核源码文档
drivers 设备驱动程序
firmware 使用某些驱动程序而需要的设备固件
fs VFS和各种文件系统
include 内核头文件
init 内核引导和初始化
ipc 进程间通信代码
kernel 像调度程序这样的核心子系统
lib 同样内核函数
mm 内存管理子系统和VM
net 网络子系统
samples 示例,示范代码
scripts 编译内核所用的脚本
security Linux 安全模块
sound 语音子系统
usr 早期用户空间代码(所谓的initramfs)
tools 在Linux开发中有用的工具
virt 虚拟化基础结构

3. 编译内核的方法

还未实际尝试过手动编译内核,只是用yum更新过内核。这部分等以后手动编译过再补上。

安装新的内核后,重启时会提示进入哪个内核。当多次安装新的内核后,启动列表会很长(因为有很多版本的内核),显得不是很方便。

下面介绍3种删除那些不用的内核的方法:(是如何安装的就选择相应的删除方法)

  • rpm 删除法

    rpm -qa | grep kernel* (查找所有linux内核版本)
    rpm -e kernel-(想要删除的版本)

  • yum 删除法

    yum remove kernel-(要删除的版本)

  • 手动删除

    删除/lib/modules/目录下不需要的内核库文件
    删除/usr/src/kernel/目录下不需要的内核源码
    删除/boot目录下启动的核心档案禾内核映像
    更改grub的配置,删除不需要的内核启动列表

4. 内核开发的特点

4.1 无标准C库

为了保证内核的小和高效,内核开发中不能使用C标准库,所以连最常用的printf函数也没有,但是还好有个printk函数来代替。

4.2 使用GNU C

因为使用GNU C,所有内核中常使用GNU C中的一些扩展:

  • 内联函数

    内联函数在编译时会在它被调用的地方展开,减少了函数调用的开销,性能较好。但是,频繁的使用内联函数也会使代码变长,从而在运行时占用更多的内存。

    所以内联函数使用时最好要满足以下几点:函数较小,会被反复调用,对程序的时间要求比较严格。

    内联函数示例:static inline void sample();

  • 内联汇编

    内联汇编用于偏近底层或对执行时间严格要求的地方。示例如下:

    unsigned int low, high;
    asm volatile("rdtsc" : "=a" (low), "=d" (high));
    /* low 和 high 分别包含64位时间戳的低32位和高32位 */
  • 分支声明

    如果能事先判断一个if语句时经常为真还是经常为假,那么可以用unlikely和likely来优化这段判断的代码。

    /* 如果error在绝大多数情况下为0(假) */
    if (unlikely(error)) {
        /* ... */
    }
    
    /* 如果success在绝大多数情况下不为0(真) */
    if (likely(success)) {
        /* ... */
    }

4.3 没有内存保护

因为内核是最低层的程序,所以如果内核访问的非法内存,那么整个系统都会挂掉!所以内核开发的风险比用户程序开发的风险要大。

内核中的内存是不分页的,每用一个字节的内存,物理内存就少一个字节。所以内核中使用内存一定要谨慎。

4.4 不使用浮点数

内核不能完美的支持浮点操作,使用浮点数时,需要人工保存和恢复浮点寄存器及其他一些繁琐的操作。

4.5 内核栈容积小且固定

内核栈的大小有编译内核时决定的,对于不用的体系结构,内核栈的大小虽然不一样,但都是固定的。

查看内核栈大小的方法:

ulimit -a | grep "stack size"

4.6 同步和并发

Linux是多用户的操作系统,所以必须处理好同步和并发操作,防止因竞争而出现死锁。

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

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

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

4.7 可移植性

Linux内核可用于不用的体现结构,支持多种硬件。所以开发时要时刻注意可移植性,尽量使用体系结构无关的代码。

第三章 进程管理

1. 进程

程序本身并不是进程,进程是处于执行期的程序以及相关的资源的总称。

  • 可能存在两个或多个不同的进程执行的是同一个程序。
  • 两个或两个以上并存的进程还可以共享许多诸如打开的文件、地址空间之类的资源。

进程和线程是程序运行时状态,是动态变化的,进程和线程的管理操作(比如,创建,销毁等)都由内核来实现的。

Linux中不严格区分进程和线程,对Linux而言线程不过是一种特殊的进程。

现代操作系统中,进程提供2种虚拟机制:虚拟处理器和虚拟内存

  • 虚拟处理器给进程一种假象,让这些进程觉得自己在独享处理器。
  • 虚拟内存让进程在分配和管理内存时觉得自己拥有整个系统的所有内存资源。

每个进程有独立的虚拟处理器和虚拟内存

在进程中的各个线程之间可以共享虚拟内存,但每个都拥有各自的虚拟处理器。

进程的创建与退出:

  1. 进程在创建它的时刻开始存活,在 Linux 系统中,这通常是调用 fork()系统的结果,该系统调用通过复制一个现有进程来创建一个全新的进程。调用 fork()的进程称为父进程,新产生的进程称为子进程。在该调用结束时,在返回点这个相同位置上,父进程恢复执行,子进程开始执行。 fork()系统调用从内核返回两次:一次回到父进程,另一次回到新产生的子进程。
  2. 创建新的进程都是为了立即执行新的、不同的程序,而接着调用 exec() 这组函数就可以创建新的地址空间,并把新的程序载入其中。在现代 Linux 内核中, fork()实际上是由clone()系统调用实现的。
  3. 最终,程序通过 exit()系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。进程退出执行后被设置为僵死状态,直到它的父进程调用 wait()或 waitpid()为止。

内核中进程的信息主要保存在task_struct中(include/linux/sched.h)

进程标识PID和线程标识TID对于同一个进程或线程来说都是相等的。

Linux中可以用ps命令查看所有进程的信息:

ps -eo pid,tid,ppid,comm

2. 进程描述符及任务结构

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

进程描述符中包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,进程的状态,等等。

<div align="center"> <img src="https://gitee.com//MrRen-sdhm/Images/raw/master/img/20200523212758.png" alt="image-20200523212748578" style="zoom:67%;" /> </div>

2.1 分配进程描述符

Linux 通过 slab 分配器分配 task_struct 结构,这样能达到对象复用和缓存着色 (cache coloring) 的目的。

在2.6版本之后用 slab 分配器动态生成 task_struct,所以只需在栈底(对于向下增长的栈来说)或栈顶(对于向上增长的栈来说)创建一个新的结构 struct thread_ info。

在 x86 上, struct thread_ info 在文件 <asm/tbread_info.b> 中定义如下:

struct thread_info { 
    struct task_struct *task; 
    struct exec_domain *exec_domain; 
    __u32 flags; 
    __u32 status; 
    __u32 cpu; 
    int preempt_count; 
    mm_segment_t addr_limit; 
    struct restart_block restart_block; 
    void *sysenter_return; 
    int uaccess err; 
} ; 

<img src="https://gitee.com//MrRen-sdhm/Images/raw/master/img/20200523213612.png" alt="image-20200523213604586" style="zoom:67%;" />

每个任务的 thread_info 结构在它的内核栈的尾端分配。结构中 task 域中存放的是指向该任务实际 task_struct 的指针。

2.2 进程描述符的存放

内核中大部分处理进程的代码都是直接通过 task_struct 进行的。因此,通过 current 宏查找到当前正在运行进程的进程描述符的速度就显得尤为重要。

硬件体系结构不同,该宏的实现也不同,它必须针对专门的硬件体系结构做处理。有的硬件体系结构可以拿出一个专门寄存器来存放指向当前进程 task_struct 的指针,用千加快访问速度。而有些像 x86 这样的体系结构(其寄存器并不富余),就只能在内核栈的尾端创建 thread_info 结构,通过计算偏移间接地查找 task_struct 结构。

2.3 进程状态

进程描述符中的 state 域描述了进程的当前状态 。系统中的每个进程都必然处于五种进程状态中的一种。

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

<img src="https://gitee.com//MrRen-sdhm/Images/raw/master/img/20200523211906.png" alt="image-20200523211853355" style="zoom:67%;" />

进程的各个状态之间的转化构成了进程的整个生命周期。

2.4 设置当前进程状态

内核经常需要调整某个进程的状态。这时最好使用 set_task_state(task, state) 函数:

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

该函数将指定的进程设置为指定的状态。必要的时候,它会设置内存屏障来强制其他处理器作重新排序。否则,它等价于:

task->state = state;

2.5 进程上下文

一般程序在用户空间执行。当一个程序执行了系统调用或者触发了某个异常,它就陷入了内核空间。此时,我们称内核「代表进程执行」并处于进程上下文中。在此上下文中 current 宏是有效的。除非在此间隙有更高优先级的进程需要执行并由调度器做出了相应调整,否则在内核退出的时候,程序恢复在用户空间会继续执行。

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

2.6 进程家族树

Linux 系统中进程之间存在一个明显的继承关系,所有的进程都是 PID 为 1 的 init 进程的后代内核在系统启动的最后阶段启动 init 进程

init 进程读取系统的初始化脚本 (initscript) 并执行其他的相关程序,最终完成系统启动的整个过程。

系统中的每个进程必有一个父进程,相应的,每个进程也可以拥有零个或多个子进程,每个 task_struct 都
包含一个指向其父进程 tast_struct 的 parent 指针,还包含一个称为 children 的子进程链表

init 进程的进程描述符是作为 init_task 静态分配的。

3. 进程的创建

Linux中创建进程与其他系统有个主要区别,Linux中创建进程分2步:fork() 和 exec(),而其他系统通常提供spawn() 函数创建进程并读入可执行文件,然后开始执行。

  • fork: 通过拷贝当前进程创建一个子进程
  • exec: 读取可执行文件,将其载入到内存中运行

    • exec() 在这里指所有 exec() 一族的函数。内核实现了 execve() 函数,在此基础上,还实现了 execlp()、 execle()、execv() 和 execvp()。

3.1 写时拷贝

传统 fork() 系统调用:直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。

Linux 的 fork() 使用写时拷贝 (copy-on-write) 页实现

  • 写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时井不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。
  • 只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享
  • 这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候才进行。在页根本不会被写入的情况下(举例来说,fork() 后立即调用 exec() 它们就无须复制了。

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

3.2 fork()

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

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

  1. 调用dup_task_struct() 为新进程分配内核栈、thread_info 和 task_struct 等,其中的内容与父进程相同。此时,子进程和父进程的描述符是完全相同的。
  2. check新进程(进程数目是否超出上限等)
  3. 清理新进程的信息(比如PID置0等),使之与父进程区别开。
  4. 新进程状态置为 TASK_UNINTERRUPTIBLE,以保证它不会投入运行。
  5. 调用 copy_ftags() 更新 task_struct 的 flags 成员。
  6. 调用 alloc_pid() 为新进程分配一个有效的 PID
  7. 根据clone()的参数标志,拷贝或共享相应的信息
  8. 做一些扫尾工作并返回指向新进程的指针

copy_process() 函数执行完成后,返回到do_fork() 函数。若copy_process() 函数成功返回,新创建的子进程被唤醒井让其投入运行。

内核有意选择子进程首先执行 。因为一般子进程都会马上调用 exec() 函数,这样可以避免写时拷贝的额外开销。

创建进程的fork()函数实际上最终是调用clone()函数。

创建线程和进程的步骤一样,只是最终传给clone()函数的参数不同。

比如,通过一个普通的fork来创建进程,相当于:clone(SIGCHLD, 0)

创建一个和父进程共享地址空间,文件系统资源,文件描述符和信号处理程序的进程,即一个线程:clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0)

在内核中创建的内核线程与普通的进程之间还有个主要区别在于:内核线程没有独立的地址空间,它们只能在内核空间运行。

这与之前提到的Linux内核是个单内核有关。

4. 进程的终止

和创建进程一样,终结一个进程同样有很多步骤:

子进程上的操作(do_exit)

  1. 设置task_struct中的标识成员设置为PF_EXITING
  2. 调用del_timer_sync()删除内核定时器, 确保没有定时器在排队和运行
  3. 调用exit_mm()释放进程占用的mm_struct
  4. 调用sem__exit(),使进程离开等待IPC信号的队列
  5. 调用exit_files()和exit_fs(),释放进程占用的文件描述符和文件系统资源
  6. 把task_struct的exit_code设置为进程的返回值
  7. 调用exit_notify()向父进程发送信号,并把自己的状态设为EXIT_ZOMBIE
  8. 切换到新进程继续执行

子进程进入EXIT_ZOMBIE之后,虽然永远不会被调度,关联的资源也释放掉了,但是它本身占用的内存还没有释放,
比如创建时分配的内核栈,task_struct结构等。这些由父进程来释放。

父进程上的操作(release_task)

父进程受到子进程发送的exit_notify()信号后,将该子进程的进程描述符和所有进程独享的资源全部删除。

从上面的步骤可以看出,必须要确保每个子进程都有父进程,如果父进程在子进程结束之前就已经结束了会怎么样呢?

子进程在调用exit_notify()时已经考虑到了这点。

如果子进程的父进程已经退出了,那么子进程在退出时,exit_notify()函数会先调用forget_original_parent(),然后再调用find_new_reaper()来寻找新的父进程。

find_new_reaper()函数先在当前线程组中找一个线程作为父亲,如果找不到,就让init做父进程。(init进程是在linux启动时就一直存在的)

阅读 205

推荐阅读