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

文章目录

第1章 Linux内核简介

1.3操作系统和内核简介

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

在系统中运行的应用程序通过系统调用来与内核通信。当一个应用程序执行一条系统调用,我们说内核正在代其执行。如果进一步解释,在这种情况下,应用程序被称为通过系统调用在内核空间运行,而内核被称为运行于进程上下文中。这种交互关系——应用程序通过系统调用界面陷入内核——是应用程序完成其工作的基本行为方式。

内核还要负责管理系统的硬件设备。现有的几乎所有的体系结构,包括全部Linux支持的体系结构,都提供了中断机制。当硬件设备想和系统通信的时候,它首先要发出一个异步的中断信号去打断处理器的执行,继而打断内核的执行。中断通常对应着一个中断号,内核通过这个中断号查找相应的中断服务程序,并调用这个程序响应和处理中断。 举个例子,当你敲击键盘的时候,键盘控制器发送一个中断信号告知系统,键盘缓冲区有数据到来。内核注意到这个中断对应的中断号,调用相应的中断服务程序。该服务程序处理键盘数据然后通知键盘控制器可以继续输入数据了。为了保证同步,内核可以停用中止——既可以停止所有的中断也可以有选择地停止某个中断号对应的中断。许多操作系统的中断服务程序,包括Linux的,都不在进程上下文中执行。它们在一个与所有进程都无关的、专门的中断上下文中运行。之所以存在这样一个专门的执行环境,就是为了保证中断服务程序能够在第一时间响应和处理中断请求,然后快速地退出。

这些上下文代表着内核活动的范围。实际上我们可以将每个处理器在任何指定时间点上的活动必然概括为下列三者之一:
在这里插入图片描述

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

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

单内核和微内核

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

操作系统内核可以分为两大阵营:单内核和微内核(第三阵营是外内核,主要用在科研系统中)。
单内核是两大阵营中一种较为简单的设计,在1980年之前,所有的内核都设计成单内核。所谓单内核就是把它从整体上作为一个单独的大过程来实现,同时也运行在一个单独的地址空间上。因此,这样的内核通常以单个静态二进制文件的形式存放于磁盘中。所有内核服务都在这样的一个大内核地址空间上运行。内核之间的通信是微不足道的,因为大家都运行在内核态,并身处同一地址空间:内核可以直接调用函数,这与用户空间应用程序没有什么区别。这种模式的支持者认为单模块具有简单和性能高的特点。大多数Unix系统都设计为单模块。
另一方面,微内核并不作为一个单独的大过程来实现。相反,微内核的功能被划分为多个独立的过程,每个过程叫做一个服务器。理想情况下,只有强烈请求特权服务的服务器才运行在特权模式下,其他服务器都运行在用户空间。不过,所有的服务器都保持独立并运行在各自的地址空间上。因此,就不可能像单模块内核那样直接调用函数,而是通过消息传递处理微内核通信:系统采用了进程间通信(IPC)机制,因此,各个服务器之间通过IPC机制互通消息,互换“服务”,服务器的各自独立有效地避免了一个服务器的失效祸及另一个。同样,模块化的系统允许一个服务器为了另一个服务器而换出。
因为IPC机制的开销多于函数调用,又因为会涉及内核空间与用户空间的上下文切换,因此,消息传递需要一定的周期,而单内核中简单的函数调用没有这些开销。结果,所有实际应用的基于微内核的系统都让大部分或全部服务器位于内核,这样,就可以直接调用函数,消除频繁的上下文切换。WindowsNT内核(Windows XP,Windows Vista和Windows7等基于此)和Mach(Mac OS X的组成部分)是微内核的典型实例。不管是Windows NT还是Mac Os x,都在其新近版本中不让任何微内核服务器运行在用户空间,这违背了微内核设计的初衷。

Linux是一个单内核,也就是说,Linux内核运行在单独的内核地址空间上。不过,Linux汲取了微内核的精华:其引以为豪的是模块化设计、抢占式内核、支持内核线程以及动态装载内核模块的能力。不仅如此,Linux还避其微内核设计上性能损失的缺陷,让所有事情都运行在内核态,直接调用函数,无须消息传递。至今,Linux是模块化的、多线程的以及内核本身可调度的操作系统,实用主义再次占了上风。

1.5 Linux内核版本

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

2 - 主版本号

6 - 从版本号或副版本号

26 - 修订版本号

1 - 稳定版本号

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

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

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

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



第2章 从内核出发

2.2 内核源码树

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

2.4 内核开发的特点

1 无标准C库

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

2 使用GNU C,推荐用gcc 4.4或以后的版本来编译内核

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

2.1 内联函数

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

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

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

2.2 内联汇编

Linux的内核混合使用了C语言和汇编语言。在偏近体系结构的底层或对执行时间要求严格的地方,一般使用的是汇编语言。而内核其他部分的大部分代码是用C语言编写的。

2.3 分支声明

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

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

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

3 没有内存保护

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

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

4 不使用浮点数

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

5 内核栈容积小且固定

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

6 同步和并发

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

7 可移植性

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



第3章 进程管理

3.1 进程

进程就是执行中程序的实例,为程序提供上下文。进程上下文由程序正确运行所需的状态组成,包括:存放在内存中的程序的代码和数据、栈、程序计数器、环境变量、文件描述符集合等等。

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

3.2 进程描述符及任务结构

内核把进程的列表存放在叫做任务队列(task list)的双向循环链表中。链表中的每一项都是类型为task_struct、称为进程描述符(process descriptor)的结构,该结构定义在<linux/sched.h>文件中。进程描述符中包含一个具体进程的所有信息。
task_struct相对较大,在32位机器上,它大约有1.7KB。进程描述符中包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,进程的状态,还有其他更多信息。
在这里插入图片描述

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

3.2.1 分配进程描述符

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

在内核中,访问任务通常需要获得指向其task_struct的指针。实际上,内核中大部分处理进程的代码都是直接通过task_struct进行的。因此,通过current宏查找到当前正在运行进程的进程描述符的速度就显得尤为重要。

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

current_thread_info()->task;

3.2.3 进程状态

进程描述符中的state域描述了进程的当前状态(见图3-3)。系统中的每个进程都必然处于五种进程状态中的一种。该域的值也必为下列五种状态标志之一:
在这里插入图片描述
在这里插入图片描述

3.2.5 进程上下文

可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的地址空间执行。一般程序在用户空间执行。当一个程序调执行了系统调用(参见第5章)或者触发了某个异常,它就陷入了内核空间。此时,我们称内核“代表进程执行”并处于进程上下文中。在此上下文中current宏是有效的。除非在此间隙有更高优先级的进程需要执行并由调度器做出了相应调整,否则在内核退出的时候,程序恢复在用户空间会继续执行。
系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行——对内核的所有访问都必须通过这些接口。

3.2.6 进程家族树

Unix系统的进程之间存在一个明显的继承关系,在Linux系统中也是如此。所有的进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本(initscript)并执行其他的相关程序,最终完成系统启动的整个过程。
系统中的每个进程必有一个父进程,相应的,每个进程也可以拥有零个或多个子进程。拥有同一个父进程的所有进程被称为兄弟。进程间的关系存放在进程描述符中。每个task_struct都包含一个指向其父进程tast_struct、叫做parent的指针,还包含一个称为chilren的子进程链表。

3.3 进程创建

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

3.3.1 写时复制

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

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

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

3.3.2 fork()

在这里插入图片描述在这里插入图片描述
再回到dofork()函数,如果copy-process()函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程首先执行(但并非总是如此)。因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。

3.3.3 vfork()

除了不拷贝父进程的页表项外,vfork()系统调用和fork()的功能相同。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁
vfork()系统调用的实现是通过向clone()系统调用传递一个特殊标志来进行的。
在这里插入图片描述

3.4 线程在Linux中的实现

线程机制是现代编程技术中常用的一种抽象概念。该机制提供了在同一程序内共享内存地址空间运行的一组线程。这些线程还可以共享打开的文件和其他资源。线程机制支持并发程序设计技术,在多处理器系统上,它也能保证真正的井行处理。

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

上述线程机制的实现与Microsoft Windows或是Sun Solaris等操作系统的实现差异非常大。这些系统都在内核中提供了专门支持线程的机制(这些系统常常把线程称作轻量级进程)。“轻量级进程”这种叫法本身就概括了Linux在此处与其他系统的差异。在其他的系统中,相较于重量级的进程,线程被抽象成一种耗费较少资源,运行迅速的执行单元。而对于Linux来说,它只是一种进程间共享资源的手段Linux的进程本身就够轻量级了)。举个例子来说,假如我们有一个包含四个线程的进程,在提供专门线程支持的系统中,通常会有一个包含指向四个不同线程的指针的进程描述符。该描述符负责描述像地址空间、打开的文件这样的共享资源。线程本身再去描述它独占的资源。相反,Linux仅仅创建四个进程并分配四个普通的task_sturct结构。建立这四个进程时指定他们共享某些资源,这是相当高雅的做法。

3.4.1 创建线程

在这里插入图片描述在这里插入图片描述
在这里插入图片描述

3.4.2 内核线程

内核经常需要在后台执行一些操作。这种任务可以通过内核线程(kernel thread)完成独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被设置为NULL),它们只在内核空间运行,从来不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。
Linux确实会把一些任务交给内核线程去做,像fush和ksofirgd这些任务就是明显的例子。在装有Linux系统的机子上运行ps -ef命令,你可以看到内核线程,有很多!这些线程在系统启动时由另外一些内核线程创建。实际上,内核线程也只能由其他内核线程创建。内核是通过从kthreadd内核进程中衍生出所有新的内核线程来自动处理这一点的。
我们将在以后的内容中详细讨论具体的内核线程。

3.5 进程终结

当一个进程终结时,内核必须释放它所占有的资源并把这一不幸告知其父进程。

1)子进程的操作:
一般来说,进程的析构是自身引起的。它发生在进程调用exit)系统调用时,既可能显式地调用这个系统调用,也可能隐式地从某个程序的主函数返回(其实C语言编译器会在main函数的返回点后面放置调用exit()的代码)。当进程接受到它既不能处理也不能忽略的信号或异常时,它还可能被动地终结。不管进程是怎么终结的,该任务大部分都要靠doexit()(定义于kernelexit.c)来完成,它要做下面这些烦琐的工作:

在这里插入图片描述
在这里插入图片描述

3.5.1 删除进程描述符

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

3.5.2 孤儿进程造成的进退维谷

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

3.6 小结

在这里插入图片描述



第4章 进程调度

4.1 多任务

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

多任务系统可以划分为两类:非抢占式多任务抢占式多任务,像所有Unix的变体和许多其他现代操作系统一样,Linux提供了抢占式的多任务模式。在此模式下,由调度程序来决定什么时候停止一个进程的运行,以便其他进程能够得到执行机会。这个强制的挂起动作就叫做抢占(preemption)。

4.3 策略

4.3.1 I/O消耗型和处理器消耗型的进程

进程可以被分为I/O密集型(I/O消耗型)CPU密集型(处理器消耗型)。前者指进程的大部分时间用来提交I/O请求或是等待I/O请求。因此,这样的进程经常处于可运行状态,但通常都是运行短短的一会儿,因为它在等待更多的I/O请求时最后总会阻塞(这里所说的I/O是指任何类型的可阻塞资源,比如键盘输入,或者是网络I/O),举例来说,多数用户图形界面程序(GUI)都属于I/O密集型。
在这里插入图片描述

4.3.2 进程优先级

进程的优先级有2种度量方法,一种是nice值,一种是实时优先级。

  1. nice值的范围是-20~+19,值越大优先级越低,也就是说nice值为-20的进程优先级最大。

  2. 实时优先级的范围是0~99,与nice值的定义相反,实时优先级是值越大优先级越高。

    实时进程都是一些对响应时间要求比较高的进程,因此系统中有实时优先级高的进程处于运行队列的话,它们会抢占一般的进程的运行时间。

两种优先级互斥,每个进程只会有一种优先级,并且实时优先级的优先程度更高

4.3.3 时间片

有了优先级,可以决定谁先运行了。但是对于调度程序来说,并不是运行一次就结束了,还必须知道间隔多久进行下次调度。

于是就有了时间片的概念。时间片是一个数值,表示一个进程被抢占前能持续运行的时间。

也可以认为是进程在下次调度发生前运行的时间(除非进程主动放弃CPU,或者有实时进程来抢占CPU)。

时间片的大小设置并不简单,设大了,系统响应变慢(调度周期长);设小了,进程频繁切换带来的处理器消耗。默认的时间片一般是10ms。

4.4 Linux调度算法

Linux调度器是以模块方式提供的,这样做的目的是允许不同类型的进程可以有针对性地选择调度算法

这种模块化结构被称为调度器类(scheduler classes),它允许多种不同的可动态添加的调度算法并存,调度属于自己范畴的进程。每个调度器都有一个优先级,基础的调度器代码定义在kernel/sched.c文件中,它会按照优先级顺序遍历调度类,拥有一个可执行进程的最高优先级的调度器类胜出,去选择下面要执行的那一个程序。

完全公平调度(CFS) 是一个针对普通进程的调度类,在Linux中称为SCHED_NORMAL。该算法对于所有2.6.23以后的内核版本意义非凡。

CFS定义了一种新调度模型,它给cfs_rq(cfs的run queue)中的每一个进程都设置一个虚拟时钟-virtual runtime(vruntime)。如果一个进程得以执行,随着执行时间的不断增长,其vruntime也将不断增大,没有得到执行的进程vruntime将保持不变。

而调度器将会选择最小的vruntime那个进程来执行。这就是所谓的“完全公平”。不同优先级的进程其vruntime增长速度不同,优先级高的进程vruntime增长得慢,所以它可能得到更多的运行机会。

4.5 CFS算法实现

CFS的实现主要由四部分组成:

  • 时间记账
  • 进程选择
  • 调度器入口
  • 睡眠和唤醒

4.5.1 时间记账

所有的调度器都必须对进程运行时间做记账。多数Unix系统,正如我们前面所说,分配一个时间片给每一个进程。那么当每次系统时钟节拍发生时,时间片都会被减少一个节拍周期。当一个进程的时间片被减少到0时,它就会被另一个尚未减到0的时间片可运行进程抢占。

1.调度器实体结构
CFS不再有时间片的概念,但是它也必须维护每个进程运行的时间记账,因为它需要确保每个进程只在公平分配给它的处理器时间内运行。CFS使用调度器实体结构(定义在文件<linux/
sched.h>的struct_sched_entity中)来追踪进程运行记账:
在这里插入图片描述
2.虚拟实时

vruntime变量存放进程的虚拟运行时间,该运行时间(花在运行上的时间和)的计算是经过了所有可运行进程总数的标准化(或者说是被加权的)。CFS使用vruntime变量来记录一个程序到底运行了多长时间以及它还应该再运行多久。

4.5.2 进程选择

CFS调度算法的核心:选择具有最小vruntime的任务。
CFS使用红黑树来组织可运行进程队列,并利用其迅速找到最小vruntime值的进程。

1.挑选下一个任务
CFS调度器选取待运行的下一个进程,是所有进程中vruntime最小的那个,它对应的便是在树中最左侧的叶子节点。也就是说,你从树的根节点沿着左边的子节点向下找,一直找到叶子节点,你便找到了其vrunime值最小的那个进程。CFS的进程选择算法可简单总结为“运行tree树中最左边叶子节点所代表的那个进程”。更容易地做法是把最左叶子节点缓存起来。

2.向树中加入进程
向树中加入进程发生在进程变为可运行状态(被唤醒)或者是通过fork()调用第一次创建进程时

3.从树中删除进程
删除动作发生在**进程堵塞(变为不可运行态)或者终止时(结束运行)**时。

4.5.3 调度器入口

进程调度的主要入口点是函数schedule(),它定义在文件kernel/sched.c中。它正是内核其他部分用于调用进程调度器的入口:选择哪个进程可以运行,何时将其投入运行。Schedule()通常都需要和一个具体的调度类相关联,也就是说,它会找到一个最高优先级的调度类——后者需要有自己的可运行队列,然后问后者谁才是下一个该运行的进程。
该函数中唯一重要的事情是(要连这个都没有,那这个函数真是乏味得不用介绍啦),它会调用pick_next_task()(也定义在文件kernel/sched.c中),pick_next_task()会以优先级为序,从高到低,依次检查每一个调度类,并且从最高优先级的调度类中,选择最高优先级的进程。

4.5.4 睡眠和唤醒

休眠(被阻塞)的进程处于一个特殊的不可执行状态
进程休眠有多种原因,但肯定都是为了等待一些事件。事件可能是一段时间从文件I/O读更多数据,或者是某个硬件事件。一个进程还有可能在尝试获取一个已被占用的内核信号量时被迫进入休眠,休眠的一个常见原因就是文件I/O——如进程对一个文件执行了read()操作,而这需要从磁盘里读取。还有,进程在获取键盘输入的时候也需要等待。

无论哪种情况,内核的操作都相同:进程把自己标记成休眠状态,从可执行红黑树中移出,放入等待队列,然后调用schedule()选择和执行一个其他进程。唤醒的过程刚好相反:进程被设置为可执行状态,然后再从等待队列中移到可执行红黑树中。

1.等待队列
休眠通过等待队列进行处理。等待队列是由等待某些事件发生的进程组成的简单链表。
进程把自己放入等待队列中并设置成不可执行状态。当与等待队列相关的事件发生的时候,队列上的进程会被唤醒。

2.唤醒
唤醒操作通过函数wake_up()进行,它会唤醒指定的等待队列上的所有进程。它调用函数try_to_wake_up(),该函数负责将进程设置为TASK_RUNNING状态,调用enqueue_task()将此进程放入红黑树中,如果被唤醒的进程优先级比当前正在执行的进程的优先级高,还要设置need_resched标志。通常哪段代码促使等待条件达成,它就要负责随后调用wake_up()函数。举例来说,当磁盘数据到来时,VFS就要负责对等待队列调用wake_up(),以便唤醒队列中等待这些数据的进程。
关于休眠有一点需要注意,存在虚假唤醒。有时候进程被唤醒并不是因为它所等待的条件达成了,因此需要用一个循环处理来保证它等待的条件真正达成。图4-1描述了每个调度程序状态之间的关系。

虚假唤醒:当一个条件满足时,很多线程都被唤醒了,但是仅有只有其中部分是有用的唤醒,其它的唤醒都是无用功。比如生产者生产了一件货物,唤醒了所有消费者,但只有其中一个消费者能获得该货物。

在这里插入图片描述

4.6 抢占和上下文切换

上下文切换,也就是从一个可执行进程切换到另一个可执行进程,由定义在kernel/sched.c中的contextswitcho函数负责处理。每当一个新的进程被选出来准备投入运行的时候,schedule()就会调用该函数。它完成了两项基本的工作:

  1. 完成将虚拟内存从上一个进程映射切换到新进程中(switch_mm())。
  2. 将上一个进程的处理器状态切换到新的处理器状态(switch_to())。这包括保存、恢复栈信息和寄存器信息,以及其它任何体系结构信息。

内核提供了need_resched标志来表明是否需要重新执行一次调度。进程被抢占时scheduler_tick()会设置这个标志。

再从用户空间以及中断返回的时候,内核也会检查need_resched标志。如果已被设置,内核会在继续执行之前调用调度程序。

4.6.1 用户抢占

内核即将返回用户空间的时候,如果need_resched标志被设置,会导致schedule()被调用,此时就会发生用户抢占。在内核返回用户空间的时候,它知道自己是安全的,因为既然它可以继续去执行当前进程,那么它当然可以再去选择一个新的进程去执行。

用户抢占在以下情况时发生:

  • 从系统调用返回用户空间时。
  • 从中断处理程序返回用户空间时。

4.6.2 内核抢占

与其他大部分的Unix变体和其他大部分的操作系统不同,Linux完整地支持内核抢占在不支持内核抢占的内核中,内核代码可以一直执行,到它完成为止。也就是说,调度程序没有办法在一个内核级的任务正在执行的时候重新调度——内核中的各任务是以协作方式调度的,不具备抢占性。内核代码一直要执行到完成(返回用户空间)或明显的阻塞为止。

Linux 支持完整的内核抢占。只要没有持锁,内核就可以进行抢占。如果没有持有锁,正在执行的代码可以重新导入的,也就是可以抢占的。

每个进程中thread_info中存在preempt_count计数器。使用锁++,释放锁–-。当其为0时,内核可以抢占。调度程序开始被调用。

内核抢占会发生在:

  • 中断处理程序正在执行,且返回内核空间之前
  • 内核代码再一次具有可抢占性的时候
  • 如果内核中的任务显示地调用schedule()
  • 如果内核中的任务阻塞(这样同样也会导致调用schedule())

4.7 实时调度策略

Linux提供了两种实时调度策略:SCHED_FIFOSCHED_RR。而普通的、非实时的调度策略是SCHED_NORMAL

这些实时策略并不被完全公平调度器来管理,而是被一个特殊的实时调度器来管理。其定义在kernel/sched_rt.c中。

  • SCHED_FIFO实现了一种简单的、先入先出的调度算法:不使用时间片。总是按照FIST IN FIST OUT的规则进行寻找和运算。执行的进程会一直执行下去,直到它自己受到阻塞或显式地释放处理器为止,并不基于时间片。只有更高优先级的SCHED_FIFO或者SCHED_RR任务才能抢占其任务。其它进程都不行。
  • SCHED_RR:与SCHED_FIFO大体相同。不过SCHED_RR的进程在耗尽事先分配给它的时间后就不会继续执行了——带时间片的SCHED_FIFO任务(实时轮流调度算法)。

两种算法实现的都是静态优先级,内核不为实时进程计算动态优先级。

Linux中的实时调度算法提供了一种软实时工作方式。内核调度进程,尽力使得进程在它的限定时间前运行。实时优先级范围从0到MAX_RT_PRIO - 1。
默认情况下,MAX_RT_PRIO 为10——所以默认的实时优先级范围是从0到99。SCHED_NORMAL级进程的nice值共享了这个取值空间;它的取值范围是从MAX_RT_PRIO到(MAX_RT_PRIO +40),也就是说,在默认情况下,nice值从-20到+19直接对应的是从100到139的实时优先级范围。

4.8 与调度相关的系统调用

4.8.1 与调度策略和优先级相关的系统调用

nice函数可以将给定进程的静态优先级增加一个给定的量。只有超级用户才能在调用它时使用负值,从而提高进程的优先级。

4.8.2 与处理器绑定有关的系统调用

Linux调度程序提供强制的处理器绑定机制。它允许用户强制指定”进程必须在处理器上运行”。相关强制性操作存在于task_struct中的cpus_allowed这个掩码标志中。掩码标志的每一位对应操作系统的一个可用处理器。默认情况下所有位都被设置为可用。可以通过sched_setaffinity()函数进行设置。注意这里仅仅是一个建议,并不是完全按照这个设置来进行。内核处理线程时,当其为第一次创建时,继承父进程的相关代码,需要更改时,内核会采用移植线程把任务推送到合理的处理器上。对处理器的指定是由该进程描述符的cpus_allowed域设置的。

4.8.3 放弃处理器时间

Linux通过sched_yield()系统调用。让进程显式地将处理器时间让给其他等待执行进程的机制。它通过将进程从活动队列中(进程正在执行)移动到过期队列中实现的。确保在这一段时间内它都不会再执行了。

对于实时进程。他们只被移动到其优先级队列的最后面(不会放到过期队列之中)。

内核代码为了方便,可以直接调用yield(),先要确定给定进程确实处于可执行状态,然后调用sched_yield()。用户空间可以直接调用sched_yield()系统调用。



第5章 系统调用

5.1 与内核进行通信

系统调用在用户空间进程与硬件设备之间添加了一个中间层。该层的主要作用有3个:

  • 为用户空间提供了一种硬件的抽象接口
  • 系统调用保证的系统的安全和稳定
  • 每个程序都运行在虚拟系统中,防止进程危害系统

除开异常和陷入,系统调用是用户空间访问内核的唯一手段,也是内核唯一的合法入口。

5.2 API、POSIX和C库

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TjNDUFkG-1605788208173)(https://wangpengcheng.github.io/img/2019-10-09-21-04-42.png)]
Windows也提供了POSIX的兼容库。

5.3 系统调用

系统调用get_pid()在内核中被定义成sys_getpid()。这个是Linux中所有系统调用都应该遵守的命名规则。

5.3.1 系统调用号

在Linux中,每个系统调用被赋予一个系统调用号。这样,通过这个独一无二的号就可以关联系统调用。当用户空间的进程执行一个系统调用的时候,这个系统调用号就用来指明到底是要执行哪个系统调用;进程不会提及系统调用的名称。

系统调用号,一旦分配就不能再有任何变更,否则编译好的应用程序也会崩溃。即使系统调用被删除,它所占用的系统调用号也不允许被回收利用。内核记录了所有的系统调用,并存储在sys_call_table中。在x86_64中,它定义于arch/i386/kernel/syscall_64.c文件中。

5.3.2 系统调用的性能

Linux系统调用比其它许多操作系统执行都要快的多。Linux很短的上下文切换时间是一个重要的原因,进出内核的操作都变得简洁高效。每个系统调用处理程序和每个系统调用本身也都非常简洁。

5.4 系统调用处理程序

用户空间的程序无法直接执行内核代码。它们不能直接调用内核空间中的函数,因为内核驻留在受保护的地址空间上。如果进程可以直接在内核的地址空间上读写的话,系统的安全性和稳定性将不复存在。

所以,应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,希望系统切换到内核态,这样内核就可以代表应用程序在内核空间执行系统调用。

通知内核的机制是靠软中断实现的:通过引发一个异常来促使系统切换到内核态去执行异常处理程序。此时的异常处理程序实际上就是系统调用处理程序。

x86系统预定义的软中断号是128,通过int $0x80指令触发该中断。导致操作系统切换到内核态并执行128号异常处理程序。就是system_call()。它与体系结构紧密相关,在entry_64.s文件中用汇编语言编写。

5.4.1 指定恰当的系统调用

系统调用号通过eax寄存器传递给内核。在陷入内核之前,用户空间就把相应系统调用所对应的号放入eax中。这样来实现快速切换。

5.4.2 参数传递

对应的系统调用参数也被放在寄存器中,实现参数传递。在x86_32系统上,ebx、ecx、edx、esi和edi按照顺序存放前五个参数。需要六个或六个以上参数的情况不多见,此时应该用一个单独的寄存器存放指向所有这些参数在用户空间地址的指针
在这里插入图片描述
给用户空间的返回值也通过寄存器传递。在x86系统上,它存放在eax寄存器中。

5.5 系统调用的实现

难点在于系统调用的设计与实现。

5.5.1 实现系统调用

系统调用需要明确它的功能,输入和输出参数。设计出良好的接口。

5.5.2 参数验证

系统调用必须仔细检查它们所有的参数是否安全合法。尤其是指针,需要严格检查相关参数和数据。在接受一个用户空间的指针之前:

  • 指针指向的内存区域属于用户空间,进程不能让内核去读取内核空间的数据
  • 指针指向的内存区域在进程的地址空间中,进程决不能让内核去读其它进程的数据
  • 内存的读写和可执行,都应该被明确标记,绝对不能绕过内存访问限制。

内核提供了copy_to_user()copy_from_usr()来进行从内核到用户,以及从用户到内核的内存拷贝的操作。

silly_copy()系统调用拷贝内存,将系统内核作为中转站。复制和移动内存空间。代码如下:

SYSCALL_DEFINE3(
    silly_copy,
    unsigned long *,dst,
    unsigned long *,src,
    unsigned long leng
)
{
    //内存缓冲

    unsigned long buf;
    //将用户空间中的src拷贝进buf

    if(copy_from_usr(&buf,src,len))
        return -EFAULT;
    //将buf拷贝进用户地址空间中的dist

    if(copy_to_user(dst.&buf,len))
        return -EFAULT;
    // 返回拷贝的数量

    return len;
}

注意:copy_to_user()copy_from_usr()都有可能引起阻塞。当包含用户数据的页被置换到硬盘上时上述问题就会发生。

调用者可以使用capable()函数来检查是否有权限对指定的资源进行操作。

5.6 系统调用上下文

在第3章中曾经讨论过,内核在执行系统调用的时候处于进程上下文

在进程上下文中,内核可以休眠(当系统调用阻塞或者显式调用schedule()的时候)并且可以被抢占

在第7章中我们会看到,休眠的能力会给内核编程带来极大便利。在进程上下文中能够被抢占其实表明,像用户空间内的进程一样,当前的进程同样可以被其他进程抢占,因为新的进程可以使用相同的系统调用,所以必须小心,保证该系统调用是可重入的

5.6.1 绑定一个系统调用的最后步奏

编写完成一个系统调用之后,把它正式注册为一个系统调用,流程如下:

  • 在系统调用表的最后添加一个表项
  • 对于支持的各种体系结构,系统调用号都必须定义于<asm/unistd.h>中
  • 系统调用必须被放入/kernel下的相关文件中,被编译进内核映像

5.6.2 从用户空间访问系统调用

Linux设置了_syscalln()宏(n的范围是0-6,代表需要传递给系统调用的参数个数);直接对系统调用进行访问。

可以直接不依靠支持库,直接调用此系统调用的宏的形式为:

#define NR_open 5 
_syscall3(long ,open,const char *,filename,int ,flags,int ,mode)

每个宏有2+2*n个参数,第一个参数㐊系统调用的返回类型,第二个参数是系统调用的名称。NR_openasm/unistd.h中定义,是系统调用号;会被扩展成为内嵌汇编的C函数。

5.6.3 为什么不通过系统调用的方式实现

建立一个新的系统调用非常容易,但是不建议这么做。
在这里插入图片描述



第7章 中断和中断处理

7.1 中断

中断使得硬件得以发出通知给处理器。本质上是一种特殊的电信号。由硬件设备发送给处理器,处理器接受到之后,向操作系统反映中断的到来。再由操作系统负责处理。中断并不考虑处理器的时钟同步——中断随时可以产生。

设备发送一个中断电信号,输入中断控制器的输入引脚。中断控制器将多路中断管线采用复用计数通过一个管线直接与处理器通信;并发送一个电信号。处理器再通知操作系统。

不同设备中断不同,每一个设备都有唯一的第一个设备中断号(数字标志)。通常被称为中断请求(IRQ)线,每个IRQ线都会被关联一个数值量。一般IRQ 0是时钟中断,IRQ 1 是键盘中断。

异常

在操作系统中,讨论中断就不能不提及异常。异常与中断不同,它在产生时必须考虑与处理器时钟同步。实际上,异常也常常称为同步中断在处理器执行到由于编程失误而导致的错误指令(如被0除)的时候,或者是在执行期间出现特殊情况(如缺页),必须靠内核来处理的时候,处理器就会产生一个异常。因为许多处理器体系结构处理异常与处理中断的方式类似,因此,内核对它们的处理也很类似。本章对中断(由硬件产生的异步中断)的讨论,大部分也适合于异常(由处理器本身产生的同步中断)。
你已经熟悉一种异常:在第6章中你已看到,在x86体系结构上如何通过软中断实现系统调用,那就是陷入内核,然后引起一种特殊的异常——系统调用处理程序异常。你会看到,中断的工作方式与之类似,其差异只在于中断是由硬件而不是软件引起的

7.2 中断处理程序

在响应特别中断时,内核会执行一个函数,这个函数叫做中断处理程序或中断服务例程。

一个设备的中断处理程序是它设备驱动程序(driver)的一部分——设备驱动程序是用于对设备进行管理的内核代码

在Linux中,中断处理程序就是普普通通的C函数。只不过这些函数必须按照特定的类型声明,以便内核能够以标准的方式传递处理程序的信息,在其他方面,它们与一般的函数别无二致。中断处理程序与其他内核函数的真正区别在于,中断处理程序是被内核调用来响应中断的,而它们运行于我们称之为中断上下文的特殊上下文中(关于中断上下文,我们将在后面讨论)。

需要指出的是,中断上下文偶尔也称作原子上下文,因为正如我们看到的,该上下文中的执行代码不可阻塞

中断可能随时发生,因此中断处理程序也就随时可能执行。所以必须保证中断处理程序能够快速执行,这样才能保证尽可能快地恢复中断代码的执行。

7.3 上半部与下半部的对比

又想中断处理程序运行得快,又想中断处理程序完成的工作量多,这两个目的显然有所抵触。鉴于两个目的之间存在此消彼长的矛盾关系,所以我们一般把中断处理切为两个部分中断处理程序是上半部——接收到一个中断,它就立即开始执行,但只做有严格时限的工作,例如对接收的中断进行应答或复位硬件。能够被允许稍后完成的工作会推迟到下半部去。此后,在合适的时机,下半部会执行(中断开启)。

7.4 注册中断处理程序

中断处理程序是管理硬件的驱动程序的组成部分。每一设备都有相关的驱动程序,如果设备使用中断(大部分设备如此),那么相应的驱动程序就注册一个中断处理程序
驱动程序可以通过request_irq()函数注册一个中断处理程序(它被声明在文件<limuxintermptb中),并且激活给定的中断线,以处理中断:

int request_irq(unsigned int irq, //分配的中断号
                irq_handler_t handler,//实际中断处理程序的一个指针
                unsigned long flags,//中断处理标志
                chonst char *name,//中断相关设备的文本表示
                void *dev   //中断线共享信息
                )

中断处理标志参数flags可以为0,可以使下列一个或者多个标志的位掩码。其定义在文件<linux/interrupt.h>中。这些标志中最重要的是

  • IRQF_DISABLED–内核在处理中断处理程序本身期间,禁止其它所有中断。一般在希望快速执行的轻量级中断中设置。
  • IRQF_SAMPLE_RANDOM–设备产生的中断对内核熵池(entropy pool);有贡献。内核熵池会记录计算机中的各种事件,以提供正真的随机数。如果设备中断速率有影响或者可能受到外来攻击者(如联网设备的影响),那么就不要设置这个标志,以免造成熵池的非随机
  • IRQF_TIMER–为系统定时器的中断处理而准备的
  • IRQF_SHARED–可以在多个中断处理程序之间共享中断线。

dev参数可以用于共享中断线。当一个中断处理程序需要释放时,dev将提供唯一的标志信息(cookie),以便指定共享中断线中需要删除的程序。无序共享时就直接设置为NULL;内核每次调用中断处理程序时,都会把这个指针传递给它(主要包括程序的设备结构)。

注意:request_irq()函数可能会睡眠等待处理,因此不能在中断上下文或者其他不允许阻塞的代码中调用该函数;可能会引起阻塞

主要原因是,注册中断处理程序时会调用proc_mkdir()/proc/irq文件中创建一个与中断对应的项。它会调用proc_create()其中存在kmalloc()的调用。这个函数可能会睡眠。 下面是一个简单的示例:

if(request_irq(irqn,my_interrupt,IPQF_SHARED,"my_device",my_dev)){
    printk(KERN_ERR,"my_device:cannot register IRQ %d\n",irdn);
    return -EIO;
}

注册之后可以使用free_irq(unsigned int irq,void *dev)进行处理函数的注销。
在这里插入图片描述

7.5 编写中断处理程序

static irqreturn_t intr_handler(int irq,void *dev);其中irq基本没有什么作用。dev必须与中断处理程序注册时的参数dev一致,主要是设备信息,可能直接是一个设备信息结构体。可以用来区分共享同一中断处理程序的多个设备

函数返回类型irqreturn_t类型为IRQ_NONEIRQ_HANDLED。当检测到中断,但是不是注册函数设置的中断源时返回NONE。被正确调用时返回HANDLED

linux 中断处理程序无需可重入。当给定的中断处理程序正在执行时,中断线会被屏蔽掉,防止同一中断线上接收另外一个新的中断。通常情况下,所有其他的中断都是打开的,所以这些不同中断线上的其他中断都能被处理。

共享的中断处理程序

原来对于计算机设备比较少的时候,可能一个中断线好可以对应一个中断处理程序(非共享中断线),这时候参数4为NULL,没有任何用,**但随着计算机设备的增加,一个中断线号对应一个中断处理程序已经不太现实,**这个时候就使用了共享的中断线号,多个设备使用同一个中断线号,同一个中断设备线号的所有处理程序链接成一个链表,这样当在共享中断线号的方式下一个中断产生的时候,就要遍历其对应的处理程序链表,但这个中断是由使用同一个中断线号的多个设备中间的一个产生的,不可能链表里面的所有处理程序都调用一遍吧,呵呵,这个时候就该第四个参数派上用场了。

因此使用共享中断时,设备的关键结构体信息dev参数尤为重要。内核接受一个中断后,将依次调用该中断线上注册的每一个处理程序。因此,一个处理程序必须知道它是否应该为这个中断负责。如果与它相关的设备并没有产生中断,那么处理程序应该立即退出。这需要硬件设备提供状态寄存器(或类似机制),以便中断处理程序进行检查。毫无疑问,大多数硬件都提供这种功能。

7.6 中断上下文

当执行一个中断处理程序时,内核处于中断上下文(interrput context)中。
因为没有后备进程,所以中断上下文不可以睡眠,否则无法对它重新调度。

中断上下文具有较为严格的时间限制,因为它打断了其他代码。中断上下文中的代码应当迅速、简洁,尽量不要使用循环去处理繁重的工作。有一点非常重要,请永远牢记:中断处理程序打断了其他的代码(甚至可能是打断了在其他中断线上的另一中断处理程序)。正是因为这种异步执行的特性,所以所有的中断处理程序必须尽可能的迅速、简洁。尽量把工作从中断处理程序中分离出来,放在下半部来执行,因为下半部可以在更合适的时间运行。

中断处理程序拥有自己的栈,名为中断栈,每个处理器一页。

7.7 中断处理机制的实现

中断处理系统非常依赖体系结构,中断代码很多是由汇编所构成。

在这里插入图片描述

对于每条中断线,处理器都会跳到对应的一个唯一的位置。这样内核就可以知道所接受中断的IRQ号了。在栈中保存这个号,并存放当前寄存器的值;然后使用内核调用do_IRQ()。提取函数对应的参数。然后禁止这条线上的中断传递。然后调用handle_IRQ_event()方法来运行为这条中断线所安装的中断处理程序。

7.9 中断控制

控制中断系统的原因,归根到结低是需要提供同步。通过禁止中断确定某个中断处理程序不会抢占当前的代码。

7.9.1 禁止和激活中断

可以使用如下语句禁止当前处理器,随后激活他们:

local_irq_disable();
/* 禁止中断 */
local_irq_enable();

上述函数一般以汇编指令来实现。如果在禁止中断之前就已经进行了中断禁止。存在潜在的危险。为了保证中断的安全,禁止中断之前需要保存中断系统的状态。在激活中断时只需要将中断恢复到他们原来的状态。这些功能借助宏和堆栈的定义来进行实现。因此**不能使用全局的cli()**所有的中断同步必须结合使用本地中断控制和自旋锁。

7.9.2 禁止指定中断线

可以使用如下的函数禁止整个系统中一条特定的中断线:

/*等待当前中断处理程序执行完毕,禁止给定中断向系统中所有处理器的传递,只有当所有程序处理完成之后函数才能返回*/
void disable_irq(unsigned int irq);
/*不等待其它函数执行完毕,直接进行中断禁止*/
void disable_irq_nosync(unsigned int irq);
/*启用中断*/
void enable_irq(unsigned int irq);
/*等待一个特定的中断处理程序的退出。必须退出后函数才能返回*/
void synchronize_irq(unsigned int irq);

7.9.3 中断系统的状态

使用``中的irqs_disable()宏可以用来检查中断系统的状态,如果被禁止则返回非0;否则返回0;

``中定义的两个宏提供来进行内核的当前上下文的检查接口,它们是:

//内核处于任何类型的中断处理中,它返回0;表示正在执行中断处理程序(上边部分/下半部分)

in_interrupt();
//内核确实时在进行上半部分中断处理程序时,才返回0

in_irq();

在这里插入图片描述



第 8 章 下半部分和推后执行的工作

中断处理程序只完成的处理流程的上半部分。主要有以下局限:

  • 中断处理程序以异步方式执行,可能会打断其它重要代码。因此中断处理程序应该执行的越快越好
  • 当前中断处理程序正在执行时,与该中断同级的其它中断会被屏蔽 。禁止中断后硬件与操作系统无法通信。中断处理程序应该越快越好
  • 往往需要对硬件进行操作。有很高的时限要求
  • 中断处理程序不再进程的上下文中运行,所以它们不能阻塞。这限制了它们所做的事情

8.1 下半部

与硬件相关不大的所有部分应该尽量,交给下半部分来进行。

可以参照一下规则对上下程序进行划分:

  • 如果任务对时间非常敏感,将其放在中断处理程序中执行。
  • 如果任务硬件相关,放在中断处理程序中执行。
  • 如果任务要保证不被其它中断(特别是相同的中断)打断,将其放在中断处理程序中执行。
  • 其它所有任务需要放在下半部分进行执行。

为了处理下班部分内核开发者引入了软中断(softirqs)tasklet机制。
软中断是一组静态定义的下半部接口,有32个;可以在所有处理器上同时执行——即使两个类型完全相同也可以;
tasklet是一种基于软中断实现的灵活性强、动态创建的下半部实现机制。两个不同类型的tasklet可以在不同的处理器上同时执行,但类型相同的tasklet不能同时执行。tasklet其实是一种在性能和易用性之间寻求平衡的产物。对于大部分下半部处理来说,用tasklet就足够了,像网络这样对性能要求非常高的情况才需要使用软中断。

2.6 内核版本中内核实现了三种不同形式的下本部分实现机制:软中断、tasklets和工作队列。其中软中断和tasklets在中断上下文执行,工作队列在进程上下文执行因此软中断和tasklet无法睡眠,但工作队列可以

另外一个可以用于将工作推后执行的机制是内核定时器。不像本章到目前为止介绍到的所有这些机制,内核定时器把操作推迟到某个确定的时间段之后执行。

8.2 软中断

软中断相对使用较少。tasklet是一种更加常用的形式。tasklet也是由软中断来实现的。软中断代码在kernel/softirqs.c文件中。

8.2.1 软中断的实现

软中断管结构体定义在``中

struct softirq_action
{
        void    (*action)(struct softirq_action *);
};

kernel/softirq.c中定义了一个包含有32个该结构体的数组

static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;

软中断使用较少,大部分都是使用tasklet进行中断切换。

使用void softirq_handler(struct softirq_action *)可以定义软中断的具体执行函数。软中断可以被中断处理程序中断。

一个注册的软中断必须在标记之后才会执行。下面的情况中待处理的软中断会被检查和执行

  • 从一个硬件中断代码返回时
  • 在ksoftirqd内核线程中
  • 在那些显式检查和执行待处理的软中断的代码中,如网络子系统。

最终软中断在do_softirq()中执行。函数遍历每一个调用他们的处理程序。

asmlinkage __visible void do_softirq(void)
{
	__u32 pending;
	unsigned long flags;

	if (in_interrupt())
		return;

	local_irq_save(flags);
    /*获取软中断位图,即其中的种类*/
	pending = local_softirq_pending();
    //这里主要是循环遍历处理位图

	if (pending && !ksoftirqd_running(pending))
		do_softirq_own_stack();

	local_irq_restore(flags);
}

8.2.2 软中断的使用

软中断保留给系统中对时间要求最严格以及最重要的下半部使用。目前,只有两个子系统(网络和SCSI)直接使用软中断。此外,内核定时器和tasklet都是建立在软中断上的。如果你想加入一个新的软中断,首先应该问问自己为什么用tasklet实现不了。tasklet可以动态生成,由于它们对加锁的要求不高,所以使用起来也很方便,而且它们的性能也非常不错。当然,对于时间要求严格并能自己高效地完成加锁工作的应用,软中断会是正确的选择。

在编译期间,使用的一种静态声明软中断。从0开始的索引表示一种优先级。索引号小的软中断优先执行。新的软中断必须在这个索引中添加自己的数据。下面是tasklet的类型

使用如下函数进行软中断处理程序的注册

open_softirq(NET_TX_SOFTIRQ,net_tx_action);
open_softirq(NET_RX_SOFTIRQ,net_tx_action);

注意:因为软中断可以允许同时执行,因此其共享数据需要严格的锁来进行保护。因此适合在单处理器中的数据中进行。它允许在不同的多个处理器中进行任务,但是需要严格的锁控制。tasklet本质上也是软中断。不过不允许在多个处理器上同时运行。

使用raise_softirq()函数可以将一个软中断设置为挂起状态,等待do_softirq()函数的调用。

8.3 tasklet

它的接口更加简单,锁保护也更低。一般情况下推荐使用tasklet操作。

8.3.1 tasklet的实现

tasklet_struct 定义

struct tasklet_struct
{
        struct tasklet_struct *next;    /* 链表中的下一个tasklet */
        unsigned long state;            /* tasklet的状态 */
        atomic_t count;                 /* 引用计数器 */
        void (*func)(unsigned long);    /* tasklet处理函数 */
        unsigned long data;             /* 给tasklet处理函数的参数 */
};

state成员只能在0、TASK_STATE_SCHED(已经被调度,准备运行)和TASK_STATE_RUN(正在运行)之间取值。counte为0时tasklet才能被激活,并被设置为挂起状态,该tasklet才能够执行。

已调度的tasklet被操作系统存放在两个链表队列中;tasklet_vec(普通tasklet)和tasklet_hi_vec(高优先级的tasklet)。然后由tasklet_schedule()tasklet_hi_schedule()函数进行调度。前者使用TASKLET_SOFTIRQ后者使用HI_SOFTIRQ;下面是tasklet_schedule()函数的执行细节:

  1. 检查tasklet状态是否为TASKLET_STATE_SCHED。是表示已经被调度,函数立刻返回。
  2. 调用_tasklet_schedule()
  3. 保存中断状态,然后禁止本地中断。保证数据的稳定
  4. 将需要调度的tasklet添加到每个处理器的一个tasklet_vec链表或者task_hi_vec链表的表头上
  5. 唤起TASKLET_SOFTIRQ或者HI_SOFTIRQ软中断,这样在下一次调用do_softirq()时就会执行该tasklet。
  6. 恢复中断到原状态并返回。

一般最近一个中断返回时就是执行do_softirq()的最佳时机。TASKLET_SOFTIRQ和HI_SOFTIRQ已经被触发,do_softirq()会执行相应的软中断处理程序。关键在于tasklet_action()tasklet_hi_action()。它们的工作内容如下:

  1. 禁止中断,并为当前处理其检索tasklet_vec或tasklet_hi_vec链表。
  2. 将当前处理器上的链表设置为NULL,达到清空的效果
  3. 允许响应中断。没有必要再恢复它们回原状态,因为这段程序本身就是作为软中断处理程序被调用的,所以中断应该是被允许的。
  4. 循环遍历获得链表上的每一个待处理的tasklet
  5. 如果是多处理器气筒,通过检查TASKLET_STATE_SCHED来判断这个tasklet是否正在其它处理器上运行。如果正在运行就不要执行,跳到下一个。
  6. 如果当前tasklet没有执行;将其状态设置为TASKLET_STATE_RUN,这样别的处理器就不会再去执行它。
  7. 检查count值是否为0,确保tasklet没有被禁止。如果tasklet被禁止了,则跳到下一个挂起的tasklet去
  8. 当tasket引用计数为0,并且没有在其它地方执行;则对其进行处理。
  9. tasklet运行完毕,清除tasklet的state域的TASK_STATE_RUN 状态标志。
  10. 重复执行下一个tasklet,直到没有剩余的等待处理的tasklet

8.3.2 使用 tasklet

使用下面的宏可以静态的创建一个tasklet

//将tasklet的引用计数器设置为0;tasklet处于激活状态

DECLARE_TASKLET(name,func,data)
//引用计数器设置为1.将tasklet设置为禁止状态

DECLARE_TASKLET_DISABLE(name,func,data);

上面的DECLARE_TASKLET(my_tasklet,my_tasklet_handler,dev)等价于

struct tasklet_struct my_tasklet={NULL,0,ATOMIC_INIT(0),my_tasklet_handler,dev};
//也可以通过下面的方法来进行创建一个;但是是动态创建

tasklet_init(t,my_tasklet_handler.dev);

使用void tasklet_handler(unsigned long data)来进行tasklet中断任务的设置。

再使用tasklet_schedule(&my_tasklet)函数来传递tasklet并进行调度。

为了防止tasklet被其它核上的处理器调度,可以使用tasklet_disable(&my_tasklet)禁止某个指定的tasklet被执行。然后使用tasklet_enable(&my_tasklet)来激活和进行下一步操作。

ksoftirqd

每个处理器都有一组辅助处理软中断(和tasklet)的内核线程。当任务量巨大时,通过内核进程对这个进行辅助处理。

网络子系统中,软中断执行时可以重新触发自己以便再次得到执行。

由于软中断可以重新触发自己,因此当大量软中断出现的时候;内核会唤醒一组内核线程(nice值是19,保证其在最低的优先级上运行)来处理任务。每个处理器中都有这样一个线程名为ksoftirqd/n(n)对应每个内核的编号。一旦线程被初始化,则会循环等待软中断的出现并进处理。

8.4 工作队列

工作队列(work queue)是另外一种将工作推后执行的形式,它和我们前面讨论的所有其他形式都不相同。工作队列可以把工作推后,交由一个内核线程去执行——这个下半部总是会在进程上下文中执行。这样,通过工作队列执行的代码能占尽进程上下文的所有优势。最重要的就是工作队列允许重新调度甚至是睡眠

通常,在工作队列和软中断/tasklet中做出选择非常容易。如果推后执行的任务需要睡眠,那么就选择工作队列。如果推后执行的任务不需要睡眠,那么就选择软中断或tasklet,实际上,工作队列通常可以用内核线程替换。但是由于内核开发者们非常反对创建新的内核线程(在有些场合,使用这种冒失的方法可能会吃到苦头),所以我们也推荐使用工作队列。当然,这种接口也的确很容易使用。

8.4.1 工作队列的实现

工作队列子系统是一个用于创建内核线程的接口。通过它创建的进程负责执行由内核其它部分排到队列里的任务。这个被称为工作者线程(work thread)。工作队列主要是让你的驱动程序创建一个专门的工作者线程类处理需要推后的工作。工作队列子系统提供了一个缺省的工作者线程来处理这些工作。

缺省的工作者线程叫做events/n,这里n表示处理器的编号。每个cpu核中有一个对应的工作队列线程,接受工作总队列的队列并,加入到自己的CPU队列中。单处理器系统中只有一个这样的线程。

下面是线程队列的结构

struct workqueue_struct {
    struct cpu_workqueue_struct *cpu_wq; /* 指针数组,其每个元素为per-cpu的工作队列,用来指定其工作的处理器 */
    struct list_head list;/* 链表头节点,用来形成链表结构 */
    const char *name; /* 工作队列的名字 */
    int singlethread; /* 标记是否只创建一个工作者线程 */
    int freezeable;     /* Freeze threads during suspend */
    int rt;
#ifdef CONFIG_LOCKDEP
    struct lockdep_map lockdep_map;
#endif
};
//cpu工作队列的结构如下所示:

struct cpu_workqueue_struct {
    spinlock_t lock; /* 保护锁结构 */
    struct list_head worklist; /* 队头节点 */
    wait_queue_head_t more_work;
    struct work_struct *current_work;
    struct workqueue_struct *wq;  /* 关联工作队列结构 */
    struct task_struct *thread;   /* 关联线程 */
} ____cacheline_aligned;

所有的工作者线程是用普通的内核线程实现的,它们都要执行work_thread()函数,在初始化完成之后就会在一个死循环中开始休眠直到,有操作被插入到队列中,线程才会被唤醒。执行之后继续休眠。

其中的关键部分如下所示:

struct work_struct{
    atomic_long_t data;
    struct list_head entry;
    work_func_t func;
}

每个处理器上的工作队列链表都是由上述工作结构组成的。当一个工作者线程被唤醒时,它就会执行它链表上的所有工作。工作被执行完毕就将这个work_struct对象从链表中移除。不再有对象的时候就会继续休眠。关键流程如下

for(;;){
    //将自己设置为休眠状态(重设state状态);将自己加入到等待队列中

    prepare_to_wait(&cwq->more_work,&wait,TASK_INTERRUPTIBLE);
    //如果队里为空,调用schedule()持续等待

    if(list_empty(&cwq->worklist)){
        schedule();
    }
    //等待调度相关信号接收

    finish_wait(&cwq->more_work,&wait);
    //执行被推后的工作

    while(!list_empty(&cwq->worklist)){
        //工作队列指针

        struct work_struct *work;
        //工作函数
        
        work_func_t f;
        //空数据指针

        void *data;
        //获取队列中的工作指针

        work=list_entry(cwq->worklist.next,struct work_struct,entry);
        //获取当前工作需要执行的函数,并调用

        f=work->func;
        //初始化删除准备--将元素从队列中删除

        list_del_init(cwq->worklist.next);
        //重新设置工作的pending

        work_clear_pending(work);
        //重新执行

        f(work);

    }
}

8.4.2 使用工作队列

  1. 创建推后的工作
  • DECLARE_WORK(name,void (*func) (void*),void data):静态创建一个名为name,处理函数为func,参数为data的结构体
  • INIT_WOK(struct work_struct *work,void (*func) (void *),void *data):动态的初始化一个由work指向的工作,处理函数为func,参数为data
  1. 工作队列处理函数
  • void work_handler(void *data):工作队列处理函数。函数会运行在进程上下文中。允许相应中断,并不持有任何锁。函数可以睡眠但是不能访问用户空间–内核线程在用户空间没有相关的内存映射。由系统调用进入用户态时(即用户态返回),它才能访问用户空间。
  1. 对工作进行调度
  • void schedule_work(&work):work马上会被调度。
  • void schedule_delayed_work(&work,delay):延迟delay之后再进行执行.
  1. 刷新操作
  • void flush_scheduled_work(void):刷新指定工作队列函数,函数会一直等待,直到队列中所有对象都被执行之后在返回。等待过程中函数会进入休眠状态。因此只能在进程上下文中使用(进程上下文与中断上下文的理解)
  • int cancel_delayed_work(struct work_struct *work):取消任何与work_struct相关的挂起工作
  1. 创建新的工作队列
  • struct workqueue_struct *create_workqueue(const char *name): 创建自己的工作者线程。
  • int queue_work(struct workqueue_struct *wq,struct work_struct *work):为自己的工作线程创建任务
  • int queue_delayed_work(struct workqueue_struct *wq,struct work_struct *work,unsigned long delay):延迟调度
  • void flush_workqueue(struct work_struct *wq):对指定任务队列进行刷新

进程上下文与中断上下文的理解

上下文基本概念

上下文是从英文context翻译过来,指的是一种环境。相对于进程而言,就是进程执行时的环境;具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存信息等。

为什么会有上下文这种概念

内核空间和用户空间是现代操作系统的两种工作模式,内核模块运行在内核空间,而用户态应用程序运行在用户空间。它们代表不同的级别,而对系统资源具有不同的访问权限。内核模块运行在最高级别(内核态),这个级下所有的操作都受系统信任,而应用程序运行在较低级别(用户态)。在这个级别,处理器控制着对硬件的直接访问以及对内存的非授权访问。内核态和用户态有自己的内存映射,即自己的地址空间。

其中处理器总处于以下状态中的一种:
 内核态,运行于进程上下文,内核代表进程运行于内核空间;
 内核态,运行于中断上下文,内核代表硬件运行于内核空间;
 用户态,运行于用户空间。

系统的两种不同运行状态,才有了上下文的概念。用户空间的应用程序,如果想请求系统服务,比如操作某个物理设备,映射设备的地址到用户空间,必须通过系统调用来实现。(系统调用是操作系统提供给用户空间的接口函数)。

通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换,用户空间和内核空间具有不同的 地址映射,通用或专用的寄存器组,而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户 空间继续执行

进程上下文

所谓的进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈上的内容,当内核需要切换到另一个进程时,它 需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行

一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文
用户级上下文: 正文、数据、用户堆栈以及共享存储区;
寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。

当发生进程调度时,进行进程切换就是上下文切换(context switch)。操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。而系统调用进行的是模式切换(mode switch)。模式切换与进程切换比较起来,容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。

进程上下文主要是异常处理程序和内核线程内核之所以进入进程上下文是因为进程自身的一些工作需要在内核中做。例如,系统调用是为当前进程服务的,异常通常是处理进程导致的错误状态等。所以在进程上下文中引用current是有意义的。

中断上下文

硬件通过触发信号,向CPU发送中断信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核, 内核通过这些参数进行中断处理

所以,“中断上下文”就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境

内核进入中断上下文是因为中断信号而导致的 中断处理或软中断。而中断信号的发生是随机的,中断处理程序及软中断并不能事先预测发生中断时当前运行的是哪个进程,所以在中断上下文中引用current是可以的,但没有意义。

事实上,对于A进程希望等待的中断信号,可能在B进程执行期间发生。例如,A进程启动写磁盘操作,A进程睡眠后B进程在运行,当磁盘写完后磁盘中断信号打断的是B进程,在中断处理时会唤醒A进程。

进程上下文 VS 中断上下文

内核可以处于两种上下文:进程上下文和中断上下文。

在系统调用之后,用户应用程序进入内核空间,此后内核空间针对用户空间相应进程的代表就运行于进程上下文。

异步发生的中断会引发中断处理程序被调用,中断处理程序就运行于中断上下文。

中断上下文和进程上下文不可能同时发生。
运行于进程上下文的内核代码是可抢占的,但中断上下文则会一直运行至结束,不会被抢占。因此,内核会限制中断上下文的工作,不允许其执行睡眠一类的操作。

Q:为什么中断不能被抢占但是可以被其他中断中断?
A:中断被其他中断中断时只是嵌套中断,相当于函数嵌套,通过中断栈可以恢复,并不算被抢占。http://bbs.chinaunix.net/thread-3761040-1-1.html


8.5 下半部分机制的选择

  • 软中断:对时间要求严格,需要多处理器并发运行。比如网络子系统,在多个处理器上并发的运行,适合专注于性能的提升。
  • tasklet:不需要并发运行,如驱动程序。
  • 工作队列:需要将任务推后到进程上下文中完成。

在这里插入图片描述

8.6 在下半部分之间加锁

tasklet自己负责执行的序列化保障;两个相同类型的tasklet不允许同时执行,即使在不同的处理器上也不行。两个tasklet之间的同步(两个不同的tasklet共享同一数据时,需要正确使用锁机制)。

如果进程上下文和一个下半部分共享数据,在访问这些数据之前,你需要禁止下半部分的处理并得到锁的使用权;防止本地和SMP的保护并防止死锁的出现。

如果进程上下文和一个下半部分共享数据,在数据之前,你需要禁止中断并得到锁的使用权;防止本地和SMP的保护并防止死锁的出现。

8.7 禁止下半部分

为了保护共享数据的安全,一般是先得到一个锁再禁止下半部分(所有软中断和所有的tasklet)的处理。使用如下的函数来实现

在这里插入图片描述

函数通过为每个进程维护一个preempt_count为每个进程维护一个计数器。当计数器变为0时,下半部分才能够被处理。函数的核心处理流程如下:

/* 通过增加preempt_count禁止本地下半部分 */
void local_bh_disable(void){
    //获取当前的线程信息

    struct thread_info *t=current_thread_info();
    //将线程中的计数器+1
    t->preempt_count+=SOFTIRQ_OFFSET;
}
/* 减少preempt_count 如果该返回值为0,将导致自动激活下半部,执行挂起的下半部分 */

void local_bh_enable(void)
{
    struct thread_info *t=current_thread_info();
    /* 减少引用计数 */
    t->preempt_count-=SOFTIRQ_OFFSET;
    /* 检查preempt_count是否为0,另外是否有挂起的下半部分,如果都没有则执行待执行的下半部分 */
    if(unlikely(!t->preempt_count&& softirq_pending(smp_processor_id()))){
        do_softirq();
    }
}


第9章 内核同步介绍

9.1 临界区和竞态条件

  • 临界区:访问和共享数据操作的代码段。
  • 竞态条件:指两个执行线程处于同一个临界区中同时执行,这样命名是因为这里会存在线程竞争。(多个执行线程同时对共享资源进行操作,有可能产生错误)

9.2 加锁

9.2.1 造成并发执行的原因

用户空间之所以需要同步;是因为用户程序会被调度程序抢占和重新调度。因为单核系统中线程之间是相互交叉进行的,因此也可以称为伪并发进行。

内核中并发执行的原因:中断、软中断和tasklet、内核抢占、睡眠及用户空间的同步、对称多处理(多个处理器可以同时执行代码)。

对内核开发者来说,必须理解上述这些并发执行的原因,并且为它们事先做足准备工作。如果在一段内核代码操作某资源的时候系统产生了一个中断,而且该中断的处理程序还要访问这一资源,这就是一个bug;类似地,如果一段内核代码在访问一个共享资源期间可以被抢占,这也是一个bug;还有,如果内核代码在临界区里睡眠,那简直就是鼓掌欢迎竞态条件的到来。最后还要注意,两个处理器绝对不能同时访问同一共享数据。

在中断处理程序中能避免并发访问的安全代码称作中断安全代码(interrup-saft),在内核抢占时能避免并发访问的安全代码称为抢占安全代码(preempt-safe)。

9.2.2 了解要保护什么

执行线程的局部数据显然不需要保护。

到底什么数据需要加锁呢?大多数内核数据结构都需要加锁!有一条很好的经验可以帮助我们判断:如果有其他执行线程可以访问这些数据,那么就给这些数据加上某种形式的锁;如果任何其他什么东西都能看到它,那么就要锁住它

9.3 死锁

死锁的产生需要一定条件:多个线程各自(用锁)占用部分共享资源,同时等待被其他线程占用的资源,相互等待,但都无法释放,因此无法继续,便意味着死锁。

一个很好的死锁例子是四路交通堵塞问题。如果每一个停止的车都决心等待其他的车开动后自己再启动,那么就没有任何一辆车能启动,于是就造成了交通死锁的发生。

最常见的例子是有两个线程和两把锁,它们通常被叫做ABBA死锁。
在这里插入图片描述

避免死锁的规则

预防死锁的发生非常重要,虽然很难证明代码不会发生死锁,但是可以写出避免死锁的代码,一些简单的规则对避免死锁大有帮助:

  • 按顺序加锁。使用嵌套的锁时必须保证以相同的顺序获取锁,这样可以阻止致命拥抱类型的死锁。最好能记录下锁的顺序,以便其他人也能照此顺序使用
  • 防止发生饥饿。试问,这个代码的执行是否一定会结束?如果“张”不发生?“王”要一直等待下去吗?
  • 不要重复请求同一个锁。
  • 设计应力求简单-越复杂的加锁方案越有可能造成死锁。

最值得强调的是第一点,它最为重要。如果有两个或多个锁曾被嵌套使用,那么以后其他函数嵌套使用它们也必须按照前次的加锁顺序进行 只要多次嵌套地使用多个锁,就必须在每一次按照相同的顺序去获取它们。

9.4 争用与扩展性

锁的争用(lock contention),或简称争用,是指当锁正在被占用时,有其他线程试图获得该锁。说一个锁处于高度争用状态,就是指有多个其他线程在等待获得该锁。由于锁的作用是使程序以串行方式对资源进行访问,所以使用锁无疑会降低系统的性能。被高度争用(频繁被持有,或者长时间持有——两者都有就更糟糕)的锁会成为系统的瓶颈,严重降低系统性能

可扩展性/可伸缩性(scalability)是一种对软件系统计算处理能力的设计指标,高可伸缩性代表一种弹性,在系统扩展成长过程中,软件能够保证旺盛的生命力,通过很少的改动甚至只是硬件设备的添置,就能实现整个系统处理能力的线性增长。对于操作系统,我们在谈及可扩展性时就会和大量进程、大量处理器或是大量内存等联系起来。理想情况下,处理器的数量加倍应该会使系统处理性能翻倍,而实际上,这是不可能达到的。

加锁粒度用来描述加锁保护的数据规模。一个过粗的锁保护大块数据——比如,一个子系统用到的所有的数据结构;相反,一个过于精细的锁保护很小的一块数据——比如,一个大数据结构中的一个元素,在实际使用中,绝大多数锁的加锁范围都处于上述两种极端之间。

考虑一个链表,最初的加锁方案可能就是用一个锁来保护链表,后来发现,在拥有集群处理器机器上,当各个处理器需要频繁访问该链表的时候,只用单独一个锁却成了扩展性的瓶颈。为解决这个瓶颈,我们将原来加锁的整个链表变成为链表中的每一个节点都加入自己的锁,这样一来,如果要对节点进行读写,必须先得到这个结点对应的锁。将加锁粒度变细后,多处理器访问同一个结点时,只会争用一个锁。

锁加得过粗或过细,差别往往只在一线之间,当锁争用严重时,加锁太粗会降低可扩展性;而锁争用不明显时,加锁过细会加大系统开销,带来浪费,这两种情况都会造成系统性能下降。但要记住:设计初期加锁方案应该力求简单,仅当需要时再进一步细化加锁方案。精髓在于力求简单



第10章 内核同步方法

10.9 顺序锁

顺序锁简称seq锁。提供了简单的机制,用于读写共享数据。主要依靠一个序列计数器来实现。当有数据被写入时,会得到一个锁,并且顺序值会增加,在读取数据之前和之后,序列号都被读取。如果相同则过程没有被写操作打断过。如果是偶数就表明写操作没有发生(写操作会使值变成奇数,释放变成偶数)

使用方法如下:

/* 定义一个锁 */
seqlock_t mr_seq_lock=DEFINE_SEQLOCK(mr_seq_lock);
/* 写锁 */
write_seqlock(&mr_seq_lock);
write_sequnlock(&mr_seq_lock);

/* 读锁被操作 */
unsigned long seq;
do{
    seq=read_seqbegin(&mr_seq_lock);
    /* 读这里的数据 */

}while(read_seqretry(&mr_seq_lock,seq));

在多个读者和少数写者共享一把锁的时候,seq锁有助于提供一种非常轻量级和具有可扩展性的外观。但是seq锁对写者更有利。只要没有其他写者,写锁总是能够被成功获得。读者不会影响写锁,这点和读-写自旋锁及信号量一样。另外,挂起的写者会不断地使得读操作循环(前一个例子),直到不再有任何写者持有锁为止。

Seq锁在你遇到如下需求时将是最理想的选择:

  • 你的数据存在很多读者。
  • 你的数据写者很少。
  • 虽然写者很少,但是你希望写优先于读,而且不允许读者让写者饥饿。
  • 你的数据很简单,如简单结构,甚至是简单的整型–在某些场合,你是不能使用原子量的。


第11章 定时器和时间管理

1.1 内核中的时间概念

时间概念对计算机来说有些模糊,事实上内核必须在硬件的帮助下才能计算和管理时间。硬件为内核提供了一个系统定时器用以计算流逝的时间,该时钟在内核中可看成是一个电子时间资源,比如数字时钟或处理器频率等。系统定时器以某种频率自行触发(经常被称为击中(hitting)或射中(popping))时钟中断,该频率可以通过编程预定,称作节拍率(tick rate)。当时钟中断发生时,内核就通过一种特殊的中断处理程序对其进行处理
因为预编的节拍率对内核来说是可知的,所以内核知道连续两次时钟中断的间隔时间。这个间隔时间就称为节拍(tick),它等于节拍率分之一(1/(tick rate))秒。

1.2 节拍率:HZ

系统定时器频率(节拍率)是通过静态预处理定义的,也就是HZ(赫兹),在系统启动时按照HZ值对硬件进行设置。体系结构不同,HZ的值也不同,实际上,x86的HZ为100,一节拍为10ms。注意大多数体系结构的节拍率是可以调节的

11.2.1 理想的HZ值

使用高频会让时钟中断产生的更加频繁;会给整个系统带来如下好处:

  • 更高的时钟中断解析度;可以提高时间驱动时间的解析度
  • 提高了时间驱动事件的准确度
  • 执行时间有更高的运行精度

劣势:

  • 系统负担过重;处理器需要花费大量的时间来处理中断程序。
  • 打乱处理器高度缓存增加耗电。

可以在编译内核时,选择不同的时钟频率来指定不同的HZ值。也可以设置CONFIG_HZ配置选项让cpu根据动态时钟的周期来进行选择。

11.3 jiffies

全局变量jiffies用来记录自系统启动以来产生的节拍的总数。启动时该变量初始化为0。没次时钟中断处理程序就会增加该变量的值。jiffies一秒内增加的值为HZ;系统运行时间为jiffies/HZ。其定义在文件linux/jiffies.h中:extern unsigned long volatile jiffies;

11.3.1 jiffies的内部表示

为了避免jiffies的溢出;使用了64位的数来对其进行存储;使用jiffies_64变量的初值覆盖jiffies变量:

jiffies=jiffies_64;

一般访问代码只会读取jiffies_64 的低32位。可以通过get_jiffies_64()函数读取整个64位数值。

11.3.2 jiffies 的回绕

当其变量的存储值超过它的最大范围之后会发生溢出。继续增加会恢复到0。注意当使用jiffies为比较标准作为判断时,可能因为发生回绕;造成其值为0,发生错误的判断;内核提供了对应的宏来处理这种判断:time_aftertime_beforetime_after_eqtime_before_eq

11.3.3 用户空间和HZ

为了避免内核中的HZ值更改,造成用户空间程序的异常结果;使用USER_HZ来代表用户空间看到的HZ值。通过一定的公式来进行计算;函数jiffies_64_to_clock_t()将64位的单位从HZ转换为USER_HZ。

11.4 硬时钟和定时器

体系结构提供了两种设备进行计时——系统定时器和实时时钟。

11.4.1 实时时钟

实时时钟(RTC)是用来持久存放系统时间的设备即便系统关闭后,它也可以靠主板上的微型电池提供的电力保持系统的计时。在PC体系结构中,RTC和CMOS集成在一起,而且RTC的运行和BIOS的保存设置都是通过同一个电池供电的。

当系统启动时,内核通过读取RTC来初始化墙上时间,该时间存放在xtime变量中。虽然内核通常不会在系统启动后再读取xtime变量,但是有些体系结构(比如x86)会周期性地将当前时间值存回RTC中。尽管如此,实时时钟最主要的作用仍是在启动时初始化xtime变量。

11.4.2 系统定时器

系统定时器是内核定时机制中最为重要的角色——提供一种周期性触发中断机制。不同体系结构实现方法不同。
在x86体系结构中,主要采用可编程中断时钟(PIT),PIT在PC机器中普遍存在,而且从DOS时代,就开始以它作为时钟中断源了。内核在启动时对PIT进行编程初始化,使其能够以HZ/秒的频率产生时钟中断(中断O),虽然PIT设备很简单,功能也有限,但它却足以满足我们的需要。x86体系结构中的其他的时钟资源还包括本地APIC时钟和时间戳计数(TSC)等。

11.5 时钟中断处理程序

时钟中断处理程序:

  • 体系结构相关部分(硬件中断);主要内容如下:

    • 获得xtime_lock锁,保护时间戳访问
    • 应答或者重新设置系统时钟(需要时)
    • 周期性地使用墙上时间更新实时时钟
    • 调用体系结构无关的时钟例程:tick_periodic()
  • 体系结构无关部分(中断处理),使用

    tick_periodic()
    

    执行下面的更多工作

    • 给jiffies_64变量增加1
    • 更新资源消耗的统计值,比如当前进程所消耗的系统时间和用户时间
    • 执行已经到期的动态定时器(通过标记一个软中断去处理到期定时器)
    • 执行sheduler_tick()函数
    • 更新墙上时间,该时间存放在xtime变量中
    • 计算平均负载值

11.6 实际时间

当前实际时间(墙上时间)定义在文件kernel/time/timekeeping.cstruct timespec xtime; timespec定义在文件<linux/time.h>中,形式如下:

struct timespec{
  _kernel_time_t tv_sec  /* 秒 */
  long tv_nsec;           /* ns */
}

xtime.tv_sec以秒为单位,存放着(1970.1.1)UTC以来经过的所有时间。

用户获得墙上时间的主要接口是gettimeofday();调用sys_gettimeofday()接口,定义于kernel/time.c

11.7 定时器

定时器由结构timer_list表示,定义在文件``中

struct timer_list{
  struct list_head entry;           /* 定时器链表的入口 */
  unsigned long expires;            /* 以jiffies为单位的定时值 */
  void (*function)(unsigned long)   /* 定时器处理函数 */
  unsigned long data;               /* 传给处理函数的长整型参数 */
  struct tvec_t_base_s *base;       /* 定时器内部值,用户不要使用 */
}

定时器使用方法如下:

/* 创建定时器 */
struct timer_list my_timer;
/* 初始化内部定时器结构,分配内存 */
init_timer(&my_timer);
/* 预定义超时函数 */
void my_timer_function(unsigned long data);
/* 填充结构中需要的值 */
my_timer.expires=jiffies+delay /* 定时器超时时的节拍数 */
my_timer.data=0;                /* 给定时器处理函数传入0值 */
my_timer.function=my_function;   /* 定时器超时时函数的调用 */
/* 启动激活定时器 */
add_timer(&my_timer);
/* 更改定时器超时时间;设置新的定时器 */
mod_timer(&my_timer,jiffies+new_delay);
/* 删除定时器 */
del_timer(&my_timer);
/* 异步等待并删除定时器 */
del_timer_sync(&my_timer);

注意不要删除超时的定时器,它们会自动删除。del_timer()保证定时器不会被激活。但是对于多处理,需要使用del_timer_sync(&my_timer)来进行异步操作,等待其它核上的定时器的结束。

11.7.2 定时器竞态条件

因为定时器和当前执行代码的异步性,因此可能存在条件竞争;应该尽量使用del_timer_sync()取代del_timer()函数。

因为内核异步执行中断处理程序,所以应该重点保护定时器中断处理程序中的共享数据。

11.7.3 实现定时器

内核在时钟中断之后执行定时器,更新当前时间。定时器作为软中断在下半部上下文中执行。

void run_local_timers(void)
{
  hrtimer_run_queues();
  /* 执行定时器软中断 */
  raise_softirq(TIMER_SOFTIRQ);
  softlockup_tick();
}

执行中断,在当前处理器上运行所有的超时定时器。虽然所有定时器都以链表形式存放在一起,为了提高定时器的搜索效率,内核将定时器按照超时时间划分为五组。当定时器超时时间接近时,定时器将随组一起下移。保证了定时器管理代码的高效性。

11.8 延迟执行

内核提供了许多延迟方法处理各种延迟要求。

11.8.1 忙等待

最简单的延迟方法,仅仅在延迟时间是节拍的整数倍,或者精确度要求不高时才可以使用。本质上是在循环中不断旋转直到希望的时钟节拍数耗尽

/* 10个节拍 */
unsigned long timeout=jiffies+10;
while(time_before(jiffies,timeout))
  ;

这个方法会消耗CPU资源,效率比较低,当等待时间较长时,应该尽量让任务挂起,允许内核重新执行调度任务。

unsigned long timeout=jiffies+5*HZ;
while(time_before(jiffies,timeout)){
  /* 重新执行调度 */
  cond_resched();
}

volatile 使得jiffies每次都直接从主内存中货色,不是通过寄存器中的变量别名来访问。从而确保前面的循环能按照预期的方式执行。

11.8.2 短延迟

时间很短(比时钟节拍还短),并且对延迟的时间很精确。内核提供了下面单个延迟函数,定义在中。并使用jiffies

/* 指定微妙(us)延迟 */
void udelay(unsigned long usecs);
/* 指定纳秒延迟 */
void ndelay(unsigned long nsecs);
/* 指定毫秒延迟 */
void mdelay(unsigned long msecs);

mdelay间接使用ndelay,ndelay使用udelay();udelay依靠执行次数循环达到延迟效果(由内核判断1s内执行多少次循环)。udelay更多的是根据循环的比例进行设置。

BogoMIPS:bogus(伪的)和MIPS(每秒处理百万指令);主要记录处理器空闲时的速度。该值存放在loop_per_jiffy中,可以由/proc/cpuinfo进行读取

内核在启动时利用calibrate_delay()计算loop_per_jiffy值。udelay()应当只在小延迟中调用,因为快速机器上的大延迟可能导致溢出。尽量减少使用mdelay以避免忙等待,消耗CPU资源。

11.8.3 schedule_timeout()

让延迟执行的任务睡眠到指定的延迟时间耗尽后再重新运行。主要是重新调度,因此该方法也不能保证睡眠时间正好等于指定的延迟时间,只能尽量靠近过。指定时间到达后,内核唤醒被延迟的任务并将其重新放回运行队列,用法如下:

/* 将任务设置为可中断睡眠状态 */
set_current_state(TASK_INTERRUPTIBLE);
/* 小睡一会儿,“s”秒后唤醒 */

schedule_timeout(s*HZ);

注意:

  • 使用schedule_timeout任务之前,必须将其窗台设置为TASK_INTERRUPTIBLE(可接受信号)或者TASK_UNINTERRUPTIBLE(不可接收信号)。
  • schedule_timeout()需要调用调度程序,必须保证用时短,调用代码应该处于进程上下文中,并且不能持有锁。
  • 可以使用schedule_timeout()函数代替schedule()函数来,让任务等待某个特定事件的到来。


第12章 内存管理

在内核里分配内存可不像在其他地方分配内存那么容易。造成这种局面的因素很多。从根本上讲,是因为内核本身不能像用户空间那样奢侈地使用内存。本章讨论的是在内核之中获取内存的方法。

12.1 页

内核把物理页作为内存管理的基本单位。尽管处理器的最小可寻址单位通常为字(甚至字节),但是,内存管理单元(MMU,管理内存并把虚拟地址转换为物理地址的硬件)通常以页为单位进行处理。正因为如此,MMU以页(page)大小为单位来管理系统中的页表(这也是页表名的来由)。从虚拟内存的角度来看,页就是最小单位。

体系结构不同支持的页大小也不同,大多数32位体系结构支持4KB的页,64位支持8KB的页。4KB表示支持4KB大小页。内核使用struct page结构表示系统中的每个物理页,位于linux/mm_types.h中。主要结构如下:

struct page{
  /* 存放页的状态(是否为脏页)等 */
  unsigned long         flags;
  /* 页被引用的次数统计,为-1时表示内核中没有引用这一页 */
  atomic_t              _count;
  /* 页缓存引用数目 */
  atomic_t              _mapcount;
  /* 私有数据指向 */
  unsigned long         priavte;
  /* 指向页相关的地址空间 */
  struct address_space  *mapping;
  pgoff_t               index;
  struct list_head      lru;
  /* 指向页的虚拟地址 */
  void                  *virtual;
}

_count域存放页的引用计数–也就是这一页被引用了多少次。当计数值变为-1时,就说明当前内核并没有引用这一页,于是,在新的分配中就可以使用它。

virtual域是页的虚拟地址。通常情况下,它就是页在虚拟内存中的地址。高端内存,并不是永久地映射到内核地址空间上。这时virtual值为NULL。需要时必须动态映射这些页。

注意:

  • page是与物理页相关而不是与虚拟页相关;内核仅仅用这个数据结构来描述当前时刻在相关的物理页中存放的东西。
  • 系统中的每个物理页都要分配一个这样的结构体。

12.2 区

因为硬件的限制性,因此需要内核把页划分为不同的区(zone)。内核对具有相似特性的页来进行分组。Linux中主要处理的硬件缺陷内存寻址问题如下:

  • 只能用特定的内存地址来执行DMA(直接内存访问)
  • 一些体系结构的内存的物理寻址范围比虚拟寻址范围大的多。一些内存不能永久地映射到内核空间上。

针对上述问题,Linux主要使用了四种区:

  • ZONE_DMA–区上页能用来执行DMA操作
  • ZONE_DMA32–和ZONE_DMA相似,不过只能被32位设备访问。某些体系结构中该区比ZONE_DMA更大。
  • ZONE_NORMAL–包含能正常映射的页。
  • ZONE_HIGHMEM–包含“高端内存”,其中的页不能永久地映射到内核地址空间。

注意:不同体系结构上分区方式不同。

ZONE_HIGHMEM的工作方式也差不多。在32位x86系统上,其为高于869MB的所有物理内存。其它体系结构上,其ZONE_HIGHMEM为空。

在这里插入图片描述

内核又将3~4G的虚拟地址空间,划分为如下几个部分:

内核地址空间

896MB又可以细分为ZONE_DMA和ZONE_NORMAL区域.

  • 低端内存(ZONE_DMA):3G-3G+16M 用于DMA __pa线性映射
  • 普通内存(ZONE_NORMAL):3G+16M-3G+896M __pa线性映射 (若物理内存<896M,则分界点就在3G+实际内存)
  • 高端内存(ZONE_HIGHMEM):3G+896-4G 采用动态的分配方式

ZONE_DMA+ZONE_NORMAL属于直接映射区:虚拟地址=3G+物理地址 或 物理地址=虚拟地址-3G,从该区域分配内存不会触发页表操作来建立映射关系

ZONE_HIGHMEM属于动态映射区:128M虚拟地址空间可以动态映射到(X-896)M(其中X位物理内存大小)的物理内存,从该区域分配内存需要更新页表来建立映射关系,vmalloc就是从该区域申请内存,所以分配速度较慢

直接映射区的作用是为了保证能够申请到物理地址上连续的内存区域,因为动态映射区,会产生内存碎片,导致系统启动一段时间后,想要成功申请到大量的连续的物理内存,非常困难,但是动态映射区带来了很高的灵活性(比如动态建立映射,缺页时才去加载物理页)。

整体映射关系如下:

整体映射关系

Linux把系统的页划分为区,形成不同的内存池,这样就可以根据用途进行分配了。例如,ZONE_DMA内存池让内核有能力为DMA分配所需的内存。如果需要这样的内存,那么,内核就可以从ZONE_DMA中按照请求的数目取出页。注意,区的划分没有任何物理意义,这只不过是内核为了管理页而采取的一种逻辑上的分组。

不是所有的体系结构都定义了全部区,有些64位的体系结构,如Intel的x86-64体系结构可以映射和处理64位的内存空间,所以x86-64没有ZON_EHIGHMEM区,所有的物理内存都处于ZONE_DMA和ZONE_NORMAL区。

每个区都用strcut zone表示,在<linux/mmzone.h>中定义。

12.3 获得页

内核中存在请求内存符底层机制,并提供了对它进行访问的接口。以页为基本单位,进行保存。定义在linux/gfp.h中,其中的核心函数是:

/* 分配2^order个连续的物理页,并返回一个指针,指向第一个页的page结构体,如果出错就返回NULL */

struct page *alloc_pages(gfp_t gfp_mask,unsigned int order);
/* 将页的物理地址转换为逻辑地址,返回指向逻辑地址的指针 */
void *page_address(struct page *page);
/* 直接创建并返回第一个页的逻辑地址 */
unsigned long __get_free_pages(gfp_t gfp_mask,unsigned int order);
/* 获取单个页 */
struct page *alloc_page(gfp_t gfp_mask);
unsigned long __get_free_page()gfp_t gfp_mask);
/* 返回页内容全为0 */
unsigned long get_zeroed_page(unsigned int gfp_mask);

在这里插入图片描述

12.3.2 释放页

可以使用如下函数进行页的释放

void __free_pages(struct page *page,unsigned int order)
void free_pages(unsigned long addr,unsigned int order)
void free_page(unsigned long addr)

页的释放错误可能会引起内核的崩溃。

12.4 kmalloc()

其在<linxu/slab.h>中声明;可以获取以字节为党文的一块内核内存。

void *kmalloc(size_t size,gfp_t flags)

12.4.1 gfp_mask标志

页和内存的分配都使用了分配器标志,它有一下三类:

  1. 行为修饰符:内核应当如何分配所需的内存 行为修饰符类别 行为修饰符类别2 可以同时多个指定:ptr=kmalloc(size,__GFP_WAIT|__GFP_IO|GFP_FS)

  2. 区修饰符:从那儿进行内存分配。

    在这里插入图片描述

    注意:

    • __GFP_HIGHMEMZONE_HIGHMEM优先
    • 如果没有任何指定,就优先从ZONE_NORMAL进行分配。
    • 不能给__get_free_pages()或者kmalloc()指定ZONE_HIGHMEM.其返回的是逻辑地址不是page结构,只有alloc_pages()才能分配高端内存
  3. 类型标志:组合了行为修饰符和区修饰符。在这里插入图片描述
    类型标志修饰表
    总结:
    在这里插入图片描述

不能睡眠表示,即使没有足够的连续内存块可以使用,内核也可能无法释放出可用内存,因为内核不能让调用者休眠。因此GFP_ATOMIC分配成功的机会比较小。在中断处理程序、软中断和tasklet中使用较多。

12.4.2 kfree()

定义在<linux/slab.h>

void kfree(const void *ptr);

释放内存已经被释放或者不是由kmalloc分配的将会引起严重后果。注意使用避免内存泄露。

12.5 vmalloc()

vmalloc()函数的工作方式类似于kmalloc(),只不过vmalloc()分配的内存虚拟地址是连续的,而物理地址则无须连续。这也是用户空间分配函数的工作方式由malloc()返回的页在进程的虚拟地址空间内是连续的,但是,这并不保证它们在物理RAM中也是连续的。kmalloc()函数确保页在物理地址上是连续的(虚拟地址自然也是连续的),vmalloc()函数只确保页在虚拟地址空间内是连续的。它通过分配非连续的物理内存块,再“修正”页表,把内存映射到逻辑地址空间的连续区域中,就能做到这点。

vmallo()的性能较低,需要对物理地址进行一一映射。会导致比直接内存映射大得多的TLB抖动。使用示例

char* buf;
buf=vmalloc(16*PAGE_SIZE);/* get 16 pages */ 
if(!buf)
...

vfree(buf);

12.6 slab层

分配和释放数据结构是所有内核中最普遍的操作之一。为了便于数据的频繁分配和回收,编程人员常常会用到空闲链表。空闲链表包含可供使用的、已经分配好的数据结构块。当代码需要一个新的数据结构实例时,就可以从空闲链表中抓取一个,而不需要分配内存,再把数据放进去。以后,当不再需要这个数据结构的实例时,就把它放回空闲链表,而不是释放它。从这个意义上说,空闲链表相当于对象高速缓存-快速存储频繁使用的对象类型。

频繁的分配/释放内存必然导致系统性能的下降,所以有必要为频繁分配/释放的对象类型建立缓存。

Linux内核提供了slab层(也就是所谓的slab分配器),slab分配器扮演了通用数据结构缓存层的角色,即 linux中的高速缓存是用所谓 slab 层来实现的

整个slab层的原理如下:

  1. 可以在内存中建立各种对象的高速缓存(比如进程描述相关的结构 task_struct 的高速缓存)
  2. 除了针对特定对象的高速缓存以外,也有通用对象的高速缓存
  3. 每个高速缓存中包含多个 slab,slab用于管理缓存的对象
  4. slab中包含多个缓存的对象,物理上由一页或多个连续的页组成

高速缓存->slab->缓存对象之间的关系如下图:
在这里插入图片描述
当内核需要分配一个新的对象时,先从部分满的slab中进行分配,找不到就从空的slab中分配。如果没有就需要创建一个slab。

每个高速缓存都使用kmem_cache结构来表示。这个包含三个链表:slabs_full、slabs_partial和slab_empty;存放在kmem_list3(定义于mm/slab.c)结构中。其基本数据结构如下:

struct slab{
  /* 链表连接节点 */
  struct list_head list;
  /* slab着色的偏移量 */
  unsigned long colouroff;
  /* slab中的第一个对象 */
  void *s_mem;
  /* slab中已分配的对象数 */
  unsigned int inuse;
  /* 第一个空闲对象(如果有的话) */
  kmem_bufctl_t free;
}

slab分配器可以创建新的slab,通过static void *kmem_getpages (struct kmem_cache *cachep, gfp_t flags, int nodeid)(底层使用了__get_free_pages())。

接着,调用kmem_freepages()释放内存,而对给定的高速缓存页,kmem_freepages()最终调用的是free_pages(),当然,slab层的关键就是避免频繁分配和释放页。由此可知,slab层只有当给定的高速缓存部分中既没有满也没有空的slab时才会调用页分配函数。而只有在下列情况下才会调用释放函数:当可用内存变得紧缺时,系统试图释放出更多内存以供使用;或者当高速缓存显式地被撤销时。
slab层的管理是在每个高速缓存的基础上,通过提供给整个内核一个简单的接口来完成的。通过接口就可以创建和撤销新的高速缓存,并在高速缓存内分配和释放对象。高速缓存及其内slab的复杂管理完全通过slab层的内部机制来处理。当你创建了一个高速缓存后,slab层所起的作用就像一个专用的分配器,可以为具体的对象类型进行分配

12.6.2 slab分配器的接口

每个高速缓存通过kmem_cache结构来描述,这个结构中包含了对当前高速缓存各种属性信息的描述。所有的高速缓存通过双向链表组织在一起,形成 高速缓存链表cache_chain。

一个新的高速缓存通过一下函数创建

struct kmem_cache *kmem_cache_create(
                                    const char *name,/* 高速缓存名称 */
                                    size_t size, /* 高速缓存中每个元素的大小 */
                                    size_t align,/* slab内第一个对象的偏移,保证页内对齐 */
                                    unsigned long flags,/* 可选设置项,用来控制高速缓存的行为 */
                                    void (*ctor)(void *)/* 高速缓存构造函数,添加高速缓存时才被调用一般为NULL */
                                    )

flags可选参数如下:

  • SLAB_HWCACHE_ALIGN–命令slab层把一个slab内的所有对象按照高速缓存对齐。防止“错误的共享”。以浪费内存为待见,进行性能的提升。
  • SLAB_POISON–使用已知的值填充slab,有利于对为初始化内存的访问。
  • SLAB_RED_ZONE–slab层在已分配的内存周围插入“红色警戒区”以探测缓冲越界。
  • SLAB_PANIC–当分配失败时提醒slab层。
  • SLAB_CHACHE_DMA–表示命令slab层使用可以执行DMA的内存给每个slab分配空间。只有分配DMA对象并且必须驻留在ZONE_DMA区时才需要这个标志。

内核初始化期间,定义于kernel/fork.c的fork_init()中会创建高速缓存.

删除一个高速缓存

int kmem_cache_destroy(struct kmem_cache *cachep);

创建高速缓存后可以,通过下列函数获取对象:

  • void kmem_cache_alloc(struct kmem_cache *cachep,gfp_t flags),查找空闲页,没有slab就自己分配一个。
  • void kmem_cache_free(struct kmem_cache *cachep,void *objp):销毁对象,并将内存返回给高速缓存。

下面是一个使用示例:

/* 定义全局变量存放高速缓存指针 */

struct kmem_cache *task_struct_cachep;
/* 创建一个task_struct类型的高速缓存,存放在ARCH_MIN_TASKALIGH个字节偏移的地方 */
task_struct_cachep=kmem_cache_create("task_struct",
                                    sizeof(struct task_struct),
                                    ARCH_MIN_TASKALIGH,
                                    SLAB_PANIC|SLAB_NOTRACK,
                                    NULL
                                    );
/* 定义数据结构 */
struct task_struct *tsk;
/* 从高速缓存中获取对象 */
tsk=kmem_cache_alloc(task_struct_cachep,GFP_KERNEL);
if(!tsk) return NULL;
/* 销毁对象,并将内存返回给高速缓存 */
kmem_cache_free(task_struct_cachep,tsk);

12.7 在栈上的静态分配

内核在创建进程的时候,在创建task_struct的同时,会为进程创建相应的堆栈。每个进程会有两个栈,一个用户栈,存在于用户空间,一个内核栈,存在于内核空间

当进程在用户空间运行时,cpu堆栈指针寄存器里面的内容是用户堆栈地址,使用用户栈

当进程在内核空间运行时,cpu堆栈指针寄存器里面的内容是内核栈空间地址,使用内核栈。

当进程因为中断或者系统调用而陷入内核态之行时,进程所使用的堆栈也要从用户栈转到内核栈。 进程陷入内核态后,先把用户态堆栈的地址保存在内核栈之中,然后设置堆栈指针寄存器的内容为内核栈的地址,这样就完成了用户栈向内核栈的转换;当进程从内核态恢复到用户态之行时,在内核态之行的最后将保存在内核栈里面的用户栈的地址恢复到堆栈指针寄存器即可。这样就实现了内核栈和用户栈的互转。

那么,我们知道从内核转到用户态时用户栈的地址是在陷入内核的时候保存在内核栈里面的,但是在陷入内核的时候,我们是如何知道内核栈的地址的呢?

关键在进程从用户态转到内核态的时候,进程的内核栈总是空的。这是因为,当进程在用户态运行时,使用的是用户栈,当进程陷入到内核态时,内核栈保存进程在内核态运行的相关信心,但是一旦进程返回到用户态后,内核栈中保存的信息无效,会全部恢复,因此每次进程从用户态陷入内核的时候得到的内核栈都是空的。所以在进程陷入内核的时候,直接把内核栈的栈顶地址给堆栈指针寄存器就可以了

每个进程的内核栈大小既依赖于体系结构,也与编译选项有关。一般每个进程都用两页(32位8KB,64位16KB)的内核栈。

12.7.1 单页内核栈

内核栈可以是1页也可以是2页,取决于编译时的配置选项。

12.7.2 在栈上光明正大的工作

因为thread_info在栈末端,栈溢出是会损害这个数据,进行动态分配(new)是比较适用于大块内存的分配。动态分配内存是一种明智的选择。

12.8 高端内存的映射

高端内存的页不能永久地映射到内核地址空间上。因此,通过alloc_pages()函数以__GFP_HIGHMEM标志获得的页不可能有逻辑地址。

12.8.1 永久映射

使用void *kmap(struct page *page)函数映射给定的page到内核地址空间。如果是低端则会返回该页的虚拟地址;如果是高端则会建立一个永久映射,再返回地址。函数可以睡眠。可以使用void kunmap(struct page *page)解除对应的映射。

12.8.2 临时映射

kernel 可以原子地映射一个高端内存页到 kernel 中的保留映射集中的一个,此保留映射集也是专门用于中断上下文等不能睡眠的地方映射高端内存页的需要。

临时映射,不能睡眠,绝对不会阻塞;通过下列函数建立一个临时映射:

/* 建立临时映射 */
void *kmap_atomic(struct page *page,enum km_type type);
/* 同下列函数取消映射 */
void kunmap_atomic(void *kvaddr,enum km_type type);

12.9 每个CPU的分配

每个CPU上的数据,对于给定的处理器其数据是唯一的。每个CPU的数据存放在一个数组中。数组中的每一项对应着系统上一个存在的处理器。按照当前处理器号确定这个数组的当前元素。访问数据的方式如下:

int cpu;
/* 获取当前处理器,并禁止内核抢占 */
cpu=get_cpu();
/* 获取数据 */
my_percpu[cpu]++;
printk("my_percpu on cpu=%d is %lu\n",cpu,my_percpu(cpu));
/* 激活内核抢占 */
put_cpu();

12.10 新的每个CPU接口

操作系统创建percpu接口定义在linux/percpu.h中;声明了所有的接口操作例程,可以在文件mm/slab.casm/percpu.h中找到他们的定义。

12.10.1 编译时每个CPU数据

/* 定义每个CPU变量 */
DEFINE_PER_CPU(type,name);
/* 声明变量 */
DECLARE_PER_CPU(type,name);
/* 返回当前cpu的值并++ */
get_cpu_var(name)++;
/* 增加指定处理器上的数据 */
per_cpu(name,cpu)++;
/* 完成,重新激活内核抢占 */
put_cpu_var(name);

注意:per_cpu函数既不会禁止内核抢占,也不会提供任何形式上的锁。

12.10.2 运行时的每个CPU数据

相关原型定义在文件linux/percpu.h

/* 宏为,系统中的每个处理器分配一个指定类型对象的实例 */
void *allocate_perccpu(type);
void *__alloc_percpu(size_t size,size_t align);
/* 释放内存 */
void free_percpu(const void *);

下面是一个简单的使用

void *percpu_ptr;
unsigned long *foo;
percpu_ptr=alloc_precpu(unsigned long);
if(!ptr)
  /* 内存分配错误... */
foo=get_cpu_var(percpu_ptr);
/* 操作foo ... */

/* 重新释放锁 */
put_cpu_var(percpu_ptr);

12.11 使用每个CPU数据的原因

可以有效减少,数据锁定。方便处理器快速访问内存。

12.12 分配函数的选择

如果你需要连续的物理页,就可以使用某个低级页分配器或kmalloc()。这是内核中内存分配的常用方式,也是大多数情况下你自己应该使用的内存分配方式。回忆一下,传递给这些函数的两个最常用的标志是GFP_ATOMIC和GFP_KERNEL。GFP-ATOMIC表示进行不睡眠的高优先级分配,这是中断处理程序和其他不能睡眠的代码段的需要。对于可以睡眠的代码,(比如没有持自旋锁的进程上下文代码)则应该使用GFP_KERNEL获取所需的内存。这个标志表示如果有必要,分配时可以睡眠。

如果你想从高端内存进行分配,就使用alloc_pages(),alloc-pages()函数返回一个指向struct page结构的指针,而不是一个指向某个逻辑地址的指针。因为高端内存很可能并没有被映射,因此,访问它的唯一方式就是通过相应的struct page结构。为了获得真正的指针,应该调用kmap(),把高端内存映射到内核的逻辑地址空间。

如果你不需要物理上连续的页,而仅仅需要虚拟地址上连续的页,那么就使用vmalloc()(不过要记住vmalloc()相对kmalloc()来说,有一定的性能损失),vmalloc()函数分配的内存虚地址是连续的,但它本身并不保证物理上的连续。这与用户空间的分配非常类似,它也是把物理内存块映射到连续的逻辑地址空间上。

如果你要创建和撤销很多大的数据结构,那么考虑建立slab高速缓存。slab层会给每个处理器维持一个对象高速缓存(空闲链表),这种高速缓存会极大地提高对象分配和回收的性能。slab层不是频繁地分配和释放内存,而是为你把事先分配好的对象存放到高速缓存中。当你需要一块新的内存来存放数据结构时,slab层一般无须另外去分配内存,而只需要从高速缓存中得到一个对象就可以了。



第13章 虚拟文件系统

参考链接:

虚拟文件系统(有时也称作虚拟文件交换,更常见的是简称VFS)作为内核子系统,为用户空间程序提供了文件和文件系统相关的接口。系统中所有文件系统不但依赖VFS共存,而且也依靠VFS系统协同工作。通过虚拟文件系统,程序可以利用标准的Uinx系统调用对不同的文件系统,甚至不同介质上的文件系统进行读写操作,如图13-1所示。

在这里插入图片描述

13.1 通用文件系统接口

VFS使得用户可以直接使用open(),read()和write()这样的系统调用而无须考虑具体文件系统和实际物理介质。它把各种不同的文件系统抽象后采用统一的方式进行操作。

13.2 文件系统抽象层

之所以可以使用这种通用接口对所有类型的文件系统进行操作,是因为内核在它的底层文件系统接口上建立了一个抽象层。该抽象层使Linux能够支持各种文件系统,即便是它们在功能和行为上存在很大差别。为了支持多文件系统,VFS提供了一个通用文件系统模型,该模型囊括了任何文件系统的常用功能集和行为。当然,该模型偏重于Unix风格的文件系统。

VFS抽象层之所以能衔接各种各样的文件系统,是因为它定义了所有文件系统都支持的、基本的、概念上的接口和数据结构。同时实际文件系统也将自身的诸如“如何打开文件”,“目录是什么”等概念在形式上与VFS的定义保持一致。

在这里插入图片描述

13.3 Unix文件系统

Unix使用了四种和文件系统相关的传统抽象概念:文件、目录项、索引节点和挂载点(mount point)。

从本质上讲,文件系统是特殊的数据分层存储结构,它包含文件、目录和相关的控制信息。为了描述 这个结构,Linux引入了一些基本概念:

  • 文件 一组在逻辑上具有完整意义的信息项的系列。在Linux中,除了普通文件,其他诸如目录、设备、套接字等 也以文件被对待。总之,“一切皆文件”。
  • 目录 目录好比一个文件夹,用来容纳相关文件。因为目录可以包含子目录,所以目录是可以层层嵌套,形成 文件路径。在Linux中,目录也是以一种特殊文件被对待的,所以用于文件的操作同样也可以用在目录上。
  • 目录项 在一个文件路径中,路径中的每一部分都被称为目录项;如路径/home/source/helloworld.c中,目录 /, home, source和文件 helloworld.c都是一个目录项。
  • 索引节点 (index node,简称inode)用于存储文件的元数据的一个数据结构。文件的元数据,也就是文件的相关信息,和文件本身是两个不同 的概念。它包含的是诸如文件的大小、拥有者、创建时间、磁盘位置等和文件相关的信息。
  • 超级块 用于存储文件系统的控制信息的数据结构。描述文件系统的状态、文件系统类型、大小、区块数、索引节 点数等,存放于磁盘的特定扇区中。
  • 挂载点 在Unix中,文件系统被安装(挂载)在一个特定的安装点上,该安装点(挂载点)在全局层次结构 中被称作命名空间,所有的已安装文件系统都作为根文件系统树的枝叶出现在系统中。

如上的几个概念在磁盘中的位置关系如图4所示。
在这里插入图片描述
关于文件系统的三个易混淆的概念:

  • 创建 以某种方式格式化磁盘的过程就是在其之上建立一个文件系统的过程。创建文现系统时,会在磁盘的特定位置写入 关于该文件系统的控制信息。

  • 注册 向内核报到,声明自己能被内核支持。一般在编译内核的时侯注册;也可以加载模块的方式手动注册。注册过程实 际上是将表示各实际文件系统的数据结构struct file_system_type 实例化。

  • 安装 也就是我们熟悉的mount操作,将文件系统加入到Linux的根文件系统的目录树结构上;这样文件系统才能被访问。

13.4 VFS对象及其数据结构

VFS其实采用的是面向对象的设计思路,使用一组数据结构来代表通用文件对象。因为内核纯粹使用C代码实现,没有直接利用面向对象的语言,所以内核中的数据结构都使用C语言的结构体实现,而这些结构体包含数据的同时也包含操作这些数据的函数指针其中的操作函数由具体文件系统实现

VFS中有四个主要的对象类型,它们分别是:

  • 超级块对象,它代表一个具体的已安装文件系统。
  • 索引节点对象,它代表一个具体文件。
  • 目录项对象,它代表一个目录项,是路径的一个组成部分。
  • 文件对象,它代表由进程打开的文件。

每个主要对象中都包含一个操作对象,这些操作对象描述了内核针对主要对象可以使用的方法:

  • super_operations对象,其中包括内核针对特定文件系统所能调用的方法,比如write_inode()和sync_fs()等方法。
  • inode_operations对象,其中包括内核针对特定文件所能调用的方法,比如create()和link()
    等方法。
  • dentry_operations对象,其中包括内核针对特定目录项所能调用的方法,比如d_compare()和d_delete()等方法。
  • file_operations对象,其中包括进程针对已打开文件所能调用的方法,比如read()和write()等方法。

操作对象作为一个结构体指针来实现,此结构体中包含指向操作其父对象的函数指针。对于其中许多方法来说,可以继承使用VFS提供的通用函数,如果通用函数提供的基本功能无法满足需要,那么就必须使用实际文件系统的独有方法填充这些函数指针,使其指向文件系统实例。

VFS就是一个利用C代码来有效和简洁地实现OOP的例子

VFS使用了大量结构体对象,它所包括的对象远远多于上面提到的这几种主要对象。

13.5 超级块对象

各种文件系统都必须实现超级块(super_block)对象,该对象用于存储特定文件系统的信息,通常对应于存放在磁盘特定扇区中的文件系统超级块或文件系统控制块(所以称为超级块对象),对于并非基于磁盘的文件系统(如基于内存的文件系统,比如sysfs),它们会在使用现场创建超级块并将其保存到内存中。

描述整个文件系统的信息。其只存在于内存中。定义在include/fs/fs.h中。主要域含义如下:

struct super_block
{
    struct list_head s_list;                /*指向超级块链表的指针*/
    /************描述具体文件系统的整体信息的域*****************/
    kdev_t s_dev; /* 包含该具体文件系统的块设备标识符。例如,对于 /dev/hda1,其设备标识符为 0x301*/
    unsigned long s_blocksize; /*该具体文件系统中数据块的大小,以字节为单位 */
    unsigned char s_blocksize_bits; /*块大小的值占用的位数,例如,如果块大小为 1024 字节,则该值为 10*/
    unsigned long long s_maxbytes; /* 文件的最大长度 */
    unsigned long s_flags; /* 安装标志*/
    unsigned long s_magic; /*魔数,即该具体文件系统区别于其他文件系统的一个标志*/
    
    /**************用于管理超级块的域******************/
    struct list_head s_list; /*指向超级块链表的指针*/
    struct semaphore s_lock /*锁标志位,若置该位,则其他进程不能对该超级块操作*/
    struct rw_semaphore s_umount /*对超级块读写时进行同步*/
    unsigned char s_dirt; /*脏位,若置该位,表明该超级块已被修改*/
    struct dentry *s_root; /*指向该具体文件系统安装目录的目录项*/
    int s_count; /*对超级块的使用计数*/
    atomic_t s_active;
    struct list_head s_dirty; /*已修改的索引节点形成的链表 */
    struct list_head s_locked_inodes;/* 要进行同步的索引节点形成的链表*/
    struct list_head s_files
    
    /***********和具体文件系统相联系的域*************************/
    struct file_system_type *s_type; /*指向文件系统的 file_system_type 数据结构的指针 */
    struct super_operations *s_op; /*指向某个特定的具体文件系统的用于超级块操作的函数集合 */
    struct dquot_operations *dq_op; /* 指向某个特定的具体文件系统用于限额操作的函数集合 */
    u; /*一个共用体,其成员是各种文件系统的 fsname_sb_info 数据结构 */
    
};

在文件系统安装时,文件系统会调用该函数以便从磁盘读取文件系统超级块,并且将其信息填充到内存中的超级块对象中。

其对应的超级块操作如下:

struct super_operations {
    /* 给定的超级块下创建和初始化一个新的索引节点对象 */
   	struct inode *(*alloc_inode)(struct super_block *sb);
	/* 释放给定的索引节点 */
    void (*destroy_inode)(struct inode *);
    /* 当索引节点被修改时,执行函数更新日志文件系统 */
   	void (*dirty_inode) (struct inode *);
    /* 将给定的索引几点写入磁盘, wait表示是否需要同步操作*/
	int (*write_inode) (struct inode *, int wait);
    /* 最后一个索引节点的引用被释放后,VFS会调用该函数。进行删除节点 */
	void (*drop_inode) (struct inode *);
    /* 删除指定节点 */
	void (*delete_inode) (struct inode *);
	/* 卸载文件系统时,由VFS调用,用来释放超级块。调用者必须一直持有s_lock锁 */
    void (*put_super) (struct super_block *);
    /* 用给定的超级块更新磁盘上的超级快,VFS通过该函数对内存中的超级快和磁盘中的超级快进行同步,调用者必须一直持有s_lock锁 */
	void (*write_super) (struct super_block *);
    /* 文件系统的数据元与磁盘上的文件系统同步。wait指定是否同步 */
	int (*sync_fs)(struct super_block *sb, int wait);
    /* */
	int (*freeze_fs) (struct super_block *);
	int (*unfreeze_fs) (struct super_block *);
    /* 获取文件系统状态,指定文件系统相关的统计信息放在statfs中 */
	int (*statfs) (struct dentry *, struct kstatfs *);
    /* 指定新的安装选项重新安装文件系统时调用,调用者必须一直持有s_lock锁 */
	int (*remount_fs) (struct super_block *, int *, char *);
    /* 释放索引节点,并清空包含相关数据的所有页面 */
	void (*clear_inode) (struct inode *);
    /* 中断安装操作,该函数被网络文件系统使用,如NFS */
	void (*umount_begin) (struct super_block *);
 
	int (*show_options)(struct seq_file *, struct vfsmount *);
	int (*show_stats)(struct seq_file *, struct vfsmount *);
#ifdef CONFIG_QUOTA 	ssize_t (*quota_read)(struct super_block *, int, char *, size_t, loff_t);
	ssize_t (*quota_write)(struct super_block *, int, const char *, size_t, loff_t);
#endif 	int (*bdev_try_to_free_page)(struct super_block*, struct page*, gfp_t);
};

调用方式:sb->s_op->write_super(sb);sb是指向文件系统超级块的指针,从该指针进入超级块对应的函数表,并从中获得write_super()函数。相当于C++中的this指针。

13.7 索引节点对象

文件系统处理文件(包括特殊设备文件、管道等等)所需要的所有信息都放在称为索引节点的数据结构中,索引节点对于文件是唯一的。具体文件系统的索引节点是存储在磁盘上的,是一种静态结构,要使用它,必须调入内存,填写VFS的索引节点,因此,也称VFS索引节点为动态节点。

/* 
 * 索引节点结构中定义的字段非常多,
 * 这里只介绍一些重要的属性
 */
struct inode {
    struct hlist_node    i_hash;     /* 散列表,用于快速查找inode */
    struct list_head    i_list;        /* 索引节点链表 */
    struct list_head    i_sb_list;  /* 超级块链表超级块  */
    struct list_head    i_dentry;   /* 目录项链表 */
    unsigned long        i_ino;      /* 节点号 */
    atomic_t        i_count;        /* 引用计数 */
    unsigned int        i_nlink;    /* 硬链接数 */
    uid_t            i_uid;          /* 使用者id */
    gid_t            i_gid;          /* 使用组id */
    struct timespec        i_atime;    /* 最后访问时间 */
    struct timespec        i_mtime;    /* 最后修改时间 */
    struct timespec        i_ctime;    /* 最后改变时间 */
    const struct inode_operations    *i_op;  /* 索引节点操作函数 */
    const struct file_operations    *i_fop;    /* 缺省的索引节点操作 */
    struct super_block    *i_sb;              /* 相关的超级块 */
    struct address_space    *i_mapping;     /* 相关的地址映射 */
    struct address_space    i_data;         /* 设备地址映射 */
    union{
	    struct pipe_inode_info i_pipe; /*指向管道文件*/
	    struct block_device *i_bdev; /*指向块设备文件的指针*/
	    struct char_device *i_cdev; /*指向字符设备文件的指针*/
	};
    unsigned int        i_flags;            /* 文件系统标志 */
    void            *i_private;             /* fs 私有指针 */
};

其操作调用方式如下:i->i_op->truncate(i)

struct inode_operations {
    /* 创建一个新的索引节点 */
	int (*create) (struct inode *,struct dentry *,int, struct nameidata *);    
    /* 在特定目录中寻找索引节点,该索引节点要对应于denrty中给出的文件名 */
	struct dentry * (*lookup) (struct inode *,struct dentry *, struct nameidata *);        
    /* 创建一个硬链接,dentry参数指定硬链接名,链接对象是dir中的old_dir */
	int (*link) (struct dentry *,struct inode *,struct dentry *);
    /* 删除一个硬链接 */    
	int (*unlink) (struct inode *dir,struct dentry *dentry);        
	/* 创建一个软链接 */
    int (*symlink) (struct inode *dir,struct dentry *dentry,const char *sysname);
	/* 创建一个文件夹,最后为一个模式*/
    int (*mkdir) (struct inode *,struct dentry *,int mode);
	int (*rmdir) (struct inode *,struct dentry *);
	/* 创建特殊文件,文件在dir目录中,其目录项为dentry,关联的设备为rdev,mode设置权限 */
    int (*mknod) (struct inode *dir,struct dentry *dentry,int mode,dev_t rdev);
	/* 重命名 */
    int (*rename) (struct inode *, struct dentry *,
			struct inode *, struct dentry *);
	/* 拷贝数据到特定的缓冲buffer中。 */
    int (*readlink) (struct dentry *, char *,int buflen);
    /* 查找执行的索引节点存储在nameidata中 */
	void * (*follow_link) (struct dentry *, struct nameidata *);
    /* 清除查找后的结果 */
	void (*put_link) (struct dentry *, struct nameidata *, void *);
    /* 修改文件的大小 */
	void (*truncate) (struct inode *);
	/* 检查文件是否允许特定的访问模式 */
    int (*permission) (struct inode *, int);
    /* 通知发生了改变事件,一般被notify_change()调用 */
	int (*setattr) (struct dentry *, struct iattr *);
    /* 在通知索引节点需要从磁盘中更新时,VFS会调用该函数,扩展属性允许key/value这样的一对值与文件关联 */
	int (*getattr) (struct vfsmount *mnt, struct dentry *, struct kstat *);
	/* 给dentry指定的文件设置扩展属性。属性名为name,值为value */
    int (*setxattr) (struct dentry *, const char *,const void *,size_t,int);
    /* 向value中拷贝给定文件的扩展属性name对应的数值 */
	ssize_t (*getxattr) (struct dentry *, const char *, void * value, size_t);
	/* 将指定文件的所有属性拷贝到一个缓冲列表 */
    ssize_t (*listxattr) (struct dentry *, char *, size_t);
    /* 删除指定属性 */
	int (*removexattr) (struct dentry *, const char *);
	void (*truncate_range)(struct inode *, loff_t, loff_t);
	long (*fallocate)(struct inode *inode, int mode, loff_t offset,
			  loff_t len);
	int (*fiemap)(struct inode *, struct fiemap_extent_info *, u64 start,
		      u64 len);
};

13.9 目录项对象

引入目录项的概念主要是出于方便查找文件的目的(通过建立缓存)。一个路径的各个组成部分,不管是目录还是 普通的文件,都是一个目录项对象。如,在路径/home/source/test.c中,目录 /, home, source和文件 test.c都对应一个目录项对象。不同于前面的两个对象,目录项对象没有对应的磁盘数据结构,VFS在遍 历路径名的过程中现场将它们逐个地解析成目录项对象。

当系统访问一个具体文件时,会根据这个文件在磁盘上的目录在内存中创建一个dentry结构,它除了要存放文件目录信息之外,还要存放有关文件路径的一些动态信息。

之所以建立这样的一个文件目录的对应物,是为了同一个目录被再次访问时,其相关信息就可以直接由dentry获得,而不必再次重复访问磁盘。

struct dentry {
	atomic_t d_count;
	unsigned int d_flags;		/* 记录目录项被引用次数的计数器 */
	spinlock_t d_lock;		/* 目录项的标志 */
	int d_mounted;
	struct inode *d_inode;		/* 与文件名相对应的inode */
 
	struct hlist_node d_hash;	/* 目录项形成的散列表 */
	struct dentry *d_parent;	/* 指向父目录项的指针 */
	struct qstr d_name;        //包含文件名的结构
 
	struct list_head d_lru;		/* 已经没有用户使用的目录项的链表 */
	union {
		struct list_head d_child;	/* 父目录的子目录项形成的链表 */
	 	struct rcu_head d_rcu;
	} d_u;
	struct list_head d_subdirs;	/* i节点别名的链表 */
	struct list_head d_alias;	/* inode alias list */
	unsigned long d_time;		/* used by d_revalidate */
	const struct dentry_operations *d_op;        //指向dentry操作函数集的指针
	struct super_block *d_sb;	/* 目录树的超级块,即本目录项所在目录树的根 */
	void *d_fsdata;			/* 文件系统的特定数据 */
 
	unsigned char d_iname[DNAME_INLINE_LEN_MIN];	/* 短文件名 */
};

13.9.1 目录项状态

目录项对象有三种有效状态:被使用、未被使用和负状态(无效状态)。

  • 被使用:对应一个有效的索引节点,并且d_count为正值。基本不会被丢弃
  • 未被使用:对应有效的索引节点,但是d_count为0;可以丢弃
  • 负状态:没有对应的有效索引节点,因为索引节点已经被删除了。但是目录项仍然保留,以便快速解析以后的路径查询。

目录项对象释放后也可以保存到slab对象缓存中去。

13.9.2 目录项缓存

内核将目录项对象缓存在目录项缓存(dcache)中。
在这里插入图片描述
而dcache在一定意义上也提供对索引节点的缓存,也就是icache。和目录项对象相关的索引节点对象不会被释放,因为目录项会让相关索引节点的使用计数为正,这样就可以确保索引节点留在内存中。只要目录项被缓存,其相应的索引节点也就被缓存了。所以像前面的例子,只要路径名在缓存中找到了,那么相应的素引节点肯定也在内存中缓存着。

dentry操作如下:

struct dentry_operations {
    /* 判断目录项是否有效 */
	int (*d_revalidate)(struct dentry *, struct nameidata *);
	/* 生成一个散列值 */
    int (*d_hash) (struct dentry *, struct qstr *);
    /* 比较两个文件名 */
	int (*d_compare) (struct dentry *, struct qstr *, struct qstr *);
	/* 删除count为0的dentry */
    int (*d_delete)(struct dentry *);
    /* 释放一个dentry对象 */
	void (*d_release)(struct dentry *);
    /* 丢弃目录项对应的inode */
	void (*d_iput)(struct dentry *, struct inode *);
	char *(*d_dname)(struct dentry *, char *, int);
};

13.11 文件对象

文件对象表示进程已打开的文件

文件对象是已打开的文件在内存中的表示。因为多个进程可以同时打开和操作同一个文件,所以同一个文件也可能存在多个对应的文件对象。文件对象仅仅在进程观点上代表已打开文件,它反过来指向目录项对象(反过来指向索引节点),其实只有目录项对象才表示已打开的实际文件。虽然一个文件对应的文件对象不是唯一的,但对应的素引节点和目录项对象无疑是唯一的。

文件对象由file结构体表示:

struct file
{
    struct list_head f_list; /*所有打开的文件形成一个链表*/
    struct dentry *f_dentry; /*指向相关目录项的指针*/
    struct vfsmount *f_vfsmnt; /*指向 VFS 安装点的指针*/
    struct file_operations *f_op; /*指向文件操作表的指针*/
    mode_t f_mode; /*文件的打开模式*/
    loff_t f_pos; /*文件的当前位移量(文件指针)*/
    unsigned short f_flags; /*打开文件时所指定的标志*/
    unsigned short f_count; /*使用该结构的进程数*/
    unsigned long f_reada, f_ramax, f_raend, f_ralen, f_rawin;/*预读标志、要预读的最多页面数、上次预读后的文件指针、预读的字节数以及预读的页面数*/
    int f_owner; /* 通过信号进行异步 I/O 数据的传送*/
    unsigned int f_uid, f_gid; /*用户的 UID 和 GID*/
    int f_error; /*网络写操作的错误码*/
    unsigned long f_version; /*版本号*/
    void *private_data; /* tty 驱动程序所需 */
};

文件操作

struct file_operations {
    struct module *owner;
    /* 更新偏移纸指针,由系统调用lleek*(调用它) */
    loff_t (*llseek) (struct file *, loff_t, int;
    /* 从给定文件的offset偏移处读取conut字节的数据到buf中,同时更新文件指针,一般由read进行调用 */
    ssize_t (*read) (struct file *, char *, size_t, loff_t *;
    /* 从给定的buf中取出conut字节的数据,写入给定文件的offset偏移处,同时更新文件指针 */
    ssize_t (*write) (struct file *, const char *, size_t, loff_t *;
    /* 返回目录列表中的下一个目录,由系统调用readdir()调用它 */
    int*readdir) (struct file *, void *, filldir_t);
    /* 函数睡眠等待给定文件活动,由系统调用poll()调用它 */
    unsigned int*poll) (struct file *, struct poll_table_struct *;
    /* 用来 */
    int*ioctl) (struct inode *, struct file *, unsigned int, unsigned long;
    /* 将给定的文件映射到指定的地址空间上。由系统调用mmap()调用它 */
    int*mmap) (struct file *, struct vm_area_struct *;
    /* 创建一个新的文件对象,并将其和对应的索引节点对象关联起来 */
    int*open) (struct inode *, struct file *;
    /* 更新一个文件相关信息 */
    int*flush) (struct file *;
    /* 当文件最后一个引用被注销时,该函数会被VFS调用,具体功能由文件系统决定 */
    int*release) (struct inode *, struct file *;
    /* 将给定的所有缓存数据写回磁盘,由系统调用fsync()调用它 */
    int*fsync) (struct file *, struct dentry *, int datasync);
    /* 打开或关闭异步I/O的通告信号 */
    int*fasync) (int, struct file *, int;
    /* 给指定文件上锁 */
    int*lock) (struct file *, int, struct file_lock *;
    /* 从给定文件中读取数据,并将其写入由vector描述的count个缓冲中去,同时增加文件的偏移量。由系统调用readv()调用它 */
    ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *;
    /* 由给定vector描述的count个缓冲中的数据写入到由file指定的文件中去,同时减小文件的偏移量。由系统调用writev()调用它 */
    ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *;
    /* 从一个文件向另外一个文件发送数据 */
    ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int;
    unsigned long*get_unmapped_area)(struct file *, unsigned long, unsigned long,
    unsigned long, unsigned long;
};

13.13 和文件系统相关的数据结构

除了以上几种VPS基础对象外,内核还使用了另外一些标准数据结构来管理文件系统的其他相关数据。第一个对象是file_system_type,用来描述各种特定文件系统类型,比如ext3,ext4或UDF,被Linux支持的文件系统,都有且仅有一 个file_system_type结构而不管它有零个或多个实例被安装到系统中。
第二个结构体是vfsmount,用来描述一个安装文件系统的实例。每当一个文件系统被实际安装,就有一个vfsmount结构体被创建,这个结构体对应一个安装点。

//<linux/fs.h>
struct file_system_type {
        const char *name;                /*文件系统的名字*/
        struct subsystem subsys;         /*sysfs子系统对象*/
        int fs_flags;                    /*文件系统类型标志*/

        /*在文件系统被安装时,从磁盘中读取超级块,在内存中组装超级块对象*/
        struct super_block *(*get_sb) (struct file_system_type*, 
                                        int, const char*, void *);
        
        void (*kill_sb) (struct super_block *);  /*终止访问超级块*/            
        struct module *owner;                    /*文件系统模块*/
        struct file_system_type * next;          /*链表中的下一个文件系统类型*/
        struct list_head fs_supers;              /*具有同一种文件系统类型的超级块对象链表*/
};
//<linux/mount.h>
struct vfsmount
{
        struct list_head mnt_hash;               /*散列表*/
        struct vfsmount *mnt_parent;             /*父文件系统*/
        struct dentry *mnt_mountpoint;           /*安装点的目录项对象*/
        struct dentry *mnt_root;                 /*该文件系统的根目录项对象*/
        struct super_block *mnt_sb;              /*该文件系统的超级块*/
        struct list_head mnt_mounts;             /*子文件系统链表*/
        struct list_head mnt_child;              /*子文件系统链表*/
        atomic_t mnt_count;                      /*使用计数*/
        int mnt_flags;                           /*安装标志*/
        char *mnt_devname;                       /*设备文件名*/
        struct list_head mnt_list;               /*描述符链表*/
        struct list_head mnt_fslink;             /*具体文件系统的到期列表*/
        struct namespace *mnt_namespace;         /*相关的名字空间*/
};

13.14 和进程相关的数据结构

以上介绍的都是在内核角度看到的 VFS 各个结构,所以结构体中包含的属性非常多。

而从进程的角度来看的话,大多数时候并不需要那么多的属性,系统中每个进程都有自己的一组打开的文件。如:根文件系统、当前工作目录、安装点等。其中file_struct、fs_struct、namespace将VFS层和系统的进程紧密结合在一起。

  • struct files_struct :由进程描述符中的 files 目录项指向,所有与单个进程相关的信息(比如打开的文件和文件描述符)都包含在其中。
  • struct fs_struct :由进程描述符中的 fs 域指向,包含文件系统和进程相关的信息(如当前工作目录和根目录)。
  • struct mmt_namespace :由进程描述符中的 mmt_namespace 域指向。它使得每一个进程在系统中都看到唯一的安装文件系统——不仅是唯一的根目录,而且是唯一的文件系统层次结构。
struct files_struct {//打开的文件集
        atomic_t count;              /*结构的使用计数*/
        struct fdtable  *fdt;          /* 指向其它fd表的指针*/
        struct fdtable  fdtab;          /* 基fd表 */
        spinlock_t file_lock;           /* 单个文件的锁*/
        int         next_fd;            /* 缓存下一个可用的fd */
        struct embedded_fd_set  close_on_exec_init;   /* exec()时关闭的文件描述符链接 */
        struct embedded_fd_set  open_fds_init;   /* 打开的文件描述符链表 */
        struct file *fd_aray[NR_OPEN_DEFAULT];   /* 缺省的文件数组对象 */
        ……
 };

struct fs_struct {//建立进程与文件系统的关系
        int     users;   /* 用户数目 */
        rwlock_t lock;      /* 保护该结构体的锁 */
        int      umask;     /* 掩码 */
        int      int_exec; /* 当前正在执行的文件 */
        struct    path root; /* 根目录路径 */
        struct      path pwd; /*当前工作目录的路径 */
};

struct mmt_namespace{
    atomic_t    count;   /* 结构的使用计数 */
    struct vfsmount  *root;  /* 根目录的安装点对象 */
    struct list_head  list; /* 安装点链表 */
    wait_queue_head_t  poll;  /* 轮询的等待队列 */
    int                 event; /* 事件计数 */
}

默认情况下,所有的进程共享同样的命名空间。只有在进行clone()操作时使用CLONE_NEWS标志,才会给进程一个唯一的命名空间结构体的拷贝。大多数情况下,都是直接集成父进程的命名空间。

结构之间的关系

超级块是对一个文件系统的描述;索引节点是对一个文件物理属性的描述;目录项是对一个文件逻辑属性的描述。一个进程所处的位置是由fs_struct来描述的,一个进程(或用户)打开的文件是由file_struct来描述的,而整个系统所打开的文件是由file结构描述的。下图表示不同数据结构之间的关系:
在这里插入图片描述


第14章 块I/O层

系统中能够随机访(不需要按照顺序)访问固定大小的数据片(chunks)的硬件设备称为块设备。固定大小的数据片就称为块。块设备包括:硬盘、软盘驱动器和闪存等。

字符设备:按照有序字符流的方式有序访问的设备。如串口和键盘。

一般而言,块设备管理比字符设备难得多。这是因为字符设备仅仅需要控制一个位置-当前位置,而块设备访问的位置必须能够在介质的不同区间前后移动。所以事实上内核不必提供一个专门的子系统来管理字符设备,但是对块设备的管理却必须要有一个专门的提供服务的子系统:块I/O子系统。不仅仅是因为块设备的复杂性远远高于字符设备,更重要的原因是块设备对执行性能的要求很高,块I/O性能的优化空间很高。

14.1 剖析一个块设备

块设备中最小(物理)可寻址单元是扇区,一般扇区的大小为2的整数倍,常见的是512字节。也有小的如CD-ROM扇区是2KB大小。

块设备的最小逻辑可寻址单元是块。块是文件系统的抽象,只能基于块来访问文件系统。块一般是2的整数倍,不能超过一个页的长度。块 必须是扇区的整数倍,并且要小于页面大小。因此其通常大小是512字节、1KB或者4KB。

扇区——设备的最小寻址单元,有时会称作“硬扇区”或“设备块";同样的,块——文件系统的最小寻址单元,有时会称作“文件块”或"IO块"。
在这里插入图片描述
查看扇区和I/O块的方法:

[wangyubin@localhost]$ sudo fdisk -l

WARNING: GPT (GUID Partition Table) detected on '/dev/sda'! The util fdisk doesn't support GPT. Use GNU Parted.


Disk /dev/sda: 500.1 GB, 500107862016 bytes, 976773168 sectors
Units = sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 4096 bytes
I/O size (minimum/optimal): 4096 bytes / 4096 bytes
Disk identifier: 0x00000000

Sector size 就是扇区的值,I/O size就是 块的值。

14.2 缓冲区和缓冲区头

当一个块被调入内存时,它要存储在一个缓冲区中。每个缓冲区与一个块对应,它相当于是磁盘块在内存中的表示
由于内核在处理数据时需要一些相关的控制信息(比如共属于哪一个块设备,块对应于哪个缓冲区等),所以每一个缓冲区都有一个对应的描述符。该描述符用buffer_head 结构体表示,称作缓冲区头,在文件<linux/buffer head.h>中定义,它包含了内核操作缓冲区所需要的全部信息

struct buffer_head {
    unsigned long b_state;            /* 表示缓冲区状态 */
    struct buffer_head *b_this_page;/* 当前页中缓冲区 */
    struct page *b_page;            /* 当前缓冲区所在内存页 */

    sector_t b_blocknr;        /* 起始块号 */
    size_t b_size;            /* buffer在内存中的大小 */
    char *b_data;            /* 块映射在内存页中的数据 */

    struct block_device *b_bdev; /* 关联的块设备 */
    bh_end_io_t *b_end_io;        /* I/O完成方法 */
     void *b_private;             /* 保留的 I/O 完成方法 */
    struct list_head b_assoc_buffers;   /* 关联的其他缓冲区 */
    struct address_space *b_assoc_map;    /* 相关的地址空间 */
    atomic_t b_count;                    /* 引用计数 */
};

缓冲区头的目的在于描述磁盘块和物理内存缓冲区(在特定页面上的字节序列)之间的映射关系。2.6内核之前还是所有块I/O操作的容器,如今这部分功能已被更轻量的bio结构体代替。

14.3 bio结构体

目前内核中块I/O操作基本容器由bio结构体表示,它定义在文件<linux/bio.h>中。该结构体代表了正在现场的(活动的)以片段(segment)链表形式组织的块I/O操作。一个片段是一小块连续的内存缓冲区。使得进程可以通过片段来描述缓冲区;即使缓冲区分散在内存的多个位置上,bio结构体也能对内核保证I/O操作的执行。其关键结构描述如下:

/*
 * I/O 操作的主要单元,针对 I/O块和更低级的层 (ie drivers and
 * stacking drivers)
 */
struct bio {
    sector_t        bi_sector;    /* 磁盘上相关扇区 */
    struct bio        *bi_next;    /* 请求列表 */
    struct block_device    *bi_bdev; /* 相关的块设备 */
    unsigned long        bi_flags;    /* 状态和命令标志 */
    unsigned long        bi_rw;        /* 读还是写 */

    unsigned short        bi_vcnt;    /* bio_vecs的数目 */
    unsigned short        bi_idx;        /* bio_io_vect的当前索引 */

    /* Number of segments in this BIO after
     * physical address coalescing is performed.
     * 结合后的片段数目
     */
    unsigned int        bi_phys_segments;

    unsigned int        bi_size;    /* 剩余 I/O 计数 */

    /*
     * To keep track of the max segment size, we account for the
     * sizes of the first and last mergeable segments in this bio.
     * 第一个和最后一个可合并的段的大小
     */
    unsigned int        bi_seg_front_size;
    unsigned int        bi_seg_back_size;

    unsigned int        bi_max_vecs;    /* bio_vecs数目上限 */
    unsigned int        bi_comp_cpu;    /* 结束CPU */

    atomic_t        bi_cnt;        /* 使用计数 */
    struct bio_vec        *bi_io_vec;    /* bio_vec 链表 */
    bio_end_io_t        *bi_end_io; /* I/O 完成方法 */
    void            *bi_private;    /* bio结构体创建者的私有方法 */
#if defined(CONFIG_BLK_DEV_INTEGRITY)
    struct bio_integrity_payload *bi_integrity;  /* data integrity */
#endif
    bio_destructor_t    *bi_destructor;    /* bio撤销方法 */
    /*
     * We can inline a number of vecs at the end of the bio, to avoid
     * double allocations for a small number of bio_vecs. This member
     * MUST obviously be kept at the very end of the bio.
     * 内嵌在结构体末尾的 bio 向量,主要为了防止出现二次申请少量的 bio_vecs
     */
    struct bio_vec        bi_inline_vecs[0];
};

bio结构体的目的代表现场正在执行的I/O操作,结构体中的主要域都是用来管理信息的,其中关键是bi_io_vecsbi_vcntbi_idx。其关系如下:
在这里插入图片描述

14.3.1 I/O向量

bi_io_vecs指向一个bio_vec结构体数组,该结构体链表包含了一个特定I/O操作所需要使用到的所有的片段。每个bio_vec结构都是一个形式为<page,offset,len>的向量。描述了片段对应的物理页、块在物理页中的偏移位置、从给定偏移量开始的块长度。整个结构体数组表示了一个完整的缓冲区。bio_vec结构体定义在linux/bio.h文件中:

struct bio_vec{
    /* 指向这个缓冲区所驻留的物理页 */
    struct page     *bv_page;
    /* 这个缓冲区以字节为单位的大小 */
    unsigned int    bv_len;
    /* 缓冲区所驻留页中以字节为单位的偏移量 */
    unsigned int    bv_offset;
}

bi_vcnt用来描述bi_io_vec指向的数组中的片段数目。bi_idx域指向数组的当前索引。

每个I/O请求通过一个bio结构体描述,其中包含多个块(bio_vec).其操作的第一个片段由bi_io_vec结构体所指向,然后不断更新bi_idx直到达到bi_vcnt的最后一个片段。

bi_cnt域记录bio结构体使用计数,如果其值为0,就应该撤销该bio结构体。

以bio结构体代替了buffer_head结构体有以下好处:

  • 容易处理高端内存,它处理的是物理页而不是直接指针。
  • bio结构体可以代表普通页I/O,同时也可以代表直接I/O
  • 便于执行分散-集中(矢量化)块I/O操作,操作中的数据可取自多个物理页面
  • 轻量级,它仅仅是一个矢量数组。

14.4 请求队列

块设备将它们挂起的I/O请求保存在请求队列中,该队列由resues_queue结构体表示,包含一个双向请求链表以及相关控制信息。队列只要不为空,队列对应的块设备驱动程序就会从队列头获取请求,然后将其送入对应的块设备上去。每个请求可能由多个bio结构体组成。

注意:虽然磁盘上的块必须连续,但是在内存中这些块并不一定要连续。

14.5 I/O调度程序

为了优化寻址操作,内核会在提交前,先执行名为合并(将多个请求结合成为一个新请求)与排序(请求按照扇区增长的方向有序排序)的预操作。内核中负责提交I/O请求的子系统名为I/O调度程序。

I/O调度程序将磁盘I/O资源分配给系统中所有挂起的块I/O请求

14.5.1 I/O调度程序的工作

I/O调度程序的工作是管理块设备的请求队列。它决定队列中的请求排列顺序以及在什么时刻派发请求到块设备。这样做有利于减少磁盘寻址时间,从而提高全局吞吐量。注意,全局这个定语很重要,坦率地讲,一个I/O调度器可能为了提高系统整体性能,而对某些请求不公。

I/O调度程序通过两种方法减少磁盘寻址时间:合并与排序。

合并指将两个或多个请求结合成一个新请求。即将相邻扇区的多个请求合并为一个,因此合并请求显然能减少系统开销和磁盘寻址次数。

整个请求队列将按扇区增长方向有序排列。使所有请求按硬盘上扇区的排列顺序有序排列(尽可能的)的目的不仅是为了缩短单独一次请求的寻址时间,更重要的优化在于,通过保持磁盘头以直线方向移动缩短了所有请求的磁盘寻址时间。该排序算法类似于电梯调度——电梯不能随意地从一层跳到另一层,它应该向一个方向移动,当抵达了同一方向上的最后一层后,再掉头向另一个方向移动。出于这种相似性,所以I/O调度程序(或这种排序算法)称作电梯调度。

14.5.2 Linus电梯

在2.4版内核中,Linus电梯是默认的I/O调度程序。虽然后来在2.6版内核中它被另外两种调度程序取代了。

linus电梯调度主要是对I/O请求进行合并和排序。
总而言之,当一个请求加入到队列中时,有可能发生四种操作,它们依次是:

  1. 如果队列中已存在一个对相邻磁盘扇区操作的请求,那么新请求将和这个已经存在的请求合并成一个请求。
  2. 如果队列中存在一个驻留时间过长的请求,那么新请求将被插入到队列尾部,以防止其他旧的请求饥饿发生。
  3. 如果队列中以扇区方向为序存在合适的插入位置,那么新的请求将被插入到该位置,保证队列中的请求是以被访问磁盘物理位置为序进行排列的。
  4. 如果队列中不存在合适的请求插入位置,请求将被插入到队列尾部。

14.5.3 最终期限I/O调度程序

linus电梯调度主要考虑了系统的全局吞吐量,对于个别的I/O请求,还是有可能造成饥饿现象。

而且读写请求的响应时间要求也是不一样的,一般来说,写请求的响应时间要求不高,写请求可以和提交它的应用程序异步执行

但是读请求一般和提交它的应用程序同步执行,应用程序等获取到读的数据后才会接着往下执行。

因此在 linus 电梯调度程序中,还可能造成 写-饥饿-读(wirtes-starving-reads)这种特殊问题。

在最终期限I/O调度程序在,为了避免饥饿,每个请求都有一个超时时间(默认为读500ms,写为5s)。最终期限I/O调度请求会类似Linus电梯,也以磁盘物理位置为次序维护请求队列(排序队列)。此外,多出了以下操作:

  1. 根据新请求的类型,将其加入 读队列(read-FIFO) 或者写队列(wirte-FIFO) 的尾部(读写队列是按加入时间排序的,所以新请求都是加到尾部);
  2. 调度程序首先判断 读,写队列头的请求是否超时,如果超时,从读、写队列头取出请求,加入到派发队列(dispatch-FIFO);
  3. 如果没有超时请求,从 排序队列(order-FIFO)头取出一个请求加入到 派发队列(dispatch-FIFO);
  4. 派发队列(dispatch-FIFO)按顺序将请求提交到磁盘驱动,完成I/O操作。
    在这里插入图片描述注意:它并不能严格保证请求的响应时间,只是近似。

14.5.4 预测I/O调度程序

最终期限I/O调度算法优先考虑读请求的响应时间,但系统处于写操作繁重的状态时,会大大降低系统的吞吐量。

因为读请求的超时时间比较短,所以每次有读请求时,都会打断写请求,让磁盘寻址到读的位置,完成读操作后再回来继续写。

因此,预测I/O调度在最终期限的基础上增加了 预测启发 能力。有一个新请求加入到I/O请求队列时,预测I/O调度与最终期限I/O调度相比,多了以下操作:

  1. 新的读请求提交后,并不立即进行请求处理,而是有意等待片刻(默认是6ms);
  2. 等待期间如果有其他对磁盘相邻位置进行读操作的读请求加入,会立刻处理这些读请求;
  3. 等待期间如果没有其他读请求加入,那么等待时间相当于浪费掉;
  4. 等待时间结束后,继续执行以前剩下的请求。

预测I/O调度程序所能带来的优势取决于能否正确预测应用程序和文件系统的行为。这种预测依靠一系列的启发和统计工作。预测I/O调度程序跟踪并且统计每个应用程序块I/O操作的习惯行为,以便正确预测应用程序的未来行为。如果预测准确率足够高,那么预测调度程序便可以大大减少服务读请求所需的寻址开销,而且同时仍能满足请求所需要的系统响应时间要求。这样的话,预测IO调度程序既减少了读响应时间,又能减少寻址次数和时间。

它是Linux内核中缺省的I/O调度程序,对大多数工作负荷来说都执行良好,对服务器也是理想的。不过,在某些非常见而又有严格工作负荷的服务器(包括数据库挖掘服务器)上,这个调度程序执行的效果不好。

14.5.5 完全公正的排序I/O调度程序

完全公正排队(Complete Fair Queuing, CFQ)I/O调度 是为专有工作负荷设计的,它和之前提到的I/O调度有根本的不同。

完全公正排序调度(CFQ):将进入的I/O请求放入特定的队列中(每个进程一个队列)。每个队列中,刚进入的请求与相邻请求合并在一起,并行插入分类。然后以时间片轮转调度队列,从每个队列中选取请求数(默认4个),然后进行下一轮调度。

CFQ I/O调度在进程级提供了公平,一般用于多媒体,但在几乎所有工作负荷中都能很好地执行。

14.5.6 空操作的I/O调度程序

空操作(noop)I/O调度几乎不做什么事情,这也是它这样命名的原因。

空操作I/O调度只合并,当有新的请求到来时,把它与任一相邻的请求合并,FIFO。

空操作I/O调度主要用于闪存卡之类的块设备,这类设备没有磁头,没有“寻道”的负担。可以说,专为随机访问设备而设计

14.5.7 I/O调度程序的选择

2.6内核中内置了上面4种I/O调度,可以在启动时通过命令行选项 elevator=xxx 来启用任何一种。

elevator选项参数如下:

参数I/O调度程序
as预测
cfq完全公正排队
deadline最终期限
noop空操作


第15章 进程地址空间

15.1 地址空间

进程地址空间,由可寻址的虚拟内存组成,内核允许进程使用这种虚拟内存中的地址。每个进程都有一个32位或64位的平坦(flat)地址空间(地址空间范围是一个独立的连续空间)。一些操作系统提供了段地址空间(被分段拥有)。每个进程都有唯一的平坦地址空间。一个进程的地址空间与另外一个进程的地址空间即使有相同的内存地址,实际上也彼此不相干。

内存地址是一个给定的值,一般是一个范围。这些可访问的合法地址空间为内存区域(memory areas)。通过内核,进程可以给自己的地址空间动态的添加或者减少内存区域。

进程只能访问有效内存区域内的地址。如果一个进程以不正确的方式/非有效地址;内核会终止该进程。并返回“段错误的信息”。内存区域包含各种内存对象如下:

  • 代码段(text section):代码内存映射
  • 数据段(data section):已初始化全局变量的内存映射
  • bss段(bss/零页):未初始化的全局变量,页面中的信息全部为0
  • 用户空间栈(stack):用于用户进程的零页内存映射
  • 内存映射段:使用mmap()映射的任何内存段
  • c库或者动态链接程序等共享库的代码段、数据段
  • 共享内存段
  • 匿名的内存映射,如由malloc()分配的内存(小空间的分配会直接使用堆,大空间的分配使用的就是匿名内存映射)。

在这里插入图片描述注意:

  • 可执行文件和可执行程序是不同的。可执行文件由操作系统装载后才是可执行程序。
  • 装载过程中如果发现函数是动态链接库符号,则会将动态链接库中相关数据一起装载。静态链接库无需此过程。

15.2 内存描述符

内核使用内存描述符结构体表示进程的地址空间,该结构包含了和地址空间有关的全部信息。内存描述符由mm_struct结构体表示:

struct mm_struct {
    struct vm_area_struct * mmap;        /* [内存区域]链表 */
    struct rb_root mm_rb;               /* [内存区域]红黑树 */
    struct vm_area_struct * mmap_cache;    /* 最近一次访问的[内存区域] */
    unsigned long (*get_unmapped_area) (struct file *filp,
                unsigned long addr, unsigned long len,
                unsigned long pgoff, unsigned long flags);  /* 获取指定区间内一个还未映射的地址,出错时返回错误码 */
    void (*unmap_area) (struct mm_struct *mm, unsigned long addr);  /* 取消地址 addr 的映射 */
    unsigned long mmap_base;        /* 地址空间中可以用来映射的首地址 */
    unsigned long task_size;        /* 进程的虚拟地址空间大小 */
    unsigned long cached_hole_size;     /* 如果不空的话,就是 free_area_cache 后最大的空洞 */
    unsigned long free_area_cache;        /* 地址空间的第一个空洞 */
    pgd_t * pgd;                        /* 页全局目录 */
    atomic_t mm_users;            /* 使用地址空间的用户数 */
    atomic_t mm_count;            /* 实际使用地址空间的计数, (users count as 1) */
    int map_count;                /* [内存区域]个数 */
    struct rw_semaphore mmap_sem;   /* 内存区域信号量 */
    spinlock_t page_table_lock;        /* 页表锁 */

    struct list_head mmlist;        /* 所有地址空间形成的链表 */

    /* Special counters, in some configurations protected by the
     * page_table_lock, in other configurations by being atomic.
     */
    mm_counter_t _file_rss;
    mm_counter_t _anon_rss;

    unsigned long hiwater_rss;    /* High-watermark of RSS usage */
    unsigned long hiwater_vm;    /* High-water virtual memory usage */

    unsigned long total_vm, locked_vm, shared_vm, exec_vm;
    unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
    unsigned long start_code, end_code, start_data, end_data; /* 代码段,数据段的开始和结束地址 */
    unsigned long start_brk, brk, start_stack; /* 堆的首地址,尾地址,进程栈首地址 */
    unsigned long arg_start, arg_end, env_start, env_end; /* 命令行参数,环境变量首地址,尾地址 */

    unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */

    struct linux_binfmt *binfmt;

    cpumask_t cpu_vm_mask;

    /* Architecture-specific MM context */
    mm_context_t context;

    /* Swap token stuff */
    /*
     * Last value of global fault stamp as seen by this process.
     * In other words, this value gives an indication of how long
     * it has been since this task got the token.
     * Look at mm/thrash.c
     */
    unsigned int faultstamp;
    unsigned int token_priority;
    unsigned int last_interval;

    unsigned long flags; /* Must use atomic bitops to access the bits */

    struct core_state *core_state; /* coredumping support */
#ifdef CONFIG_AIO
    spinlock_t        ioctx_lock;
    struct hlist_head    ioctx_list;
#endif
#ifdef CONFIG_MM_OWNER
    /*
     * "owner" points to a task that is regarded as the canonical
     * user/owner of this mm. All of the following must be true in
     * order for it to be changed:
     *
     * current == mm->owner
     * current->mm != mm
     * new_owner->mm == mm
     * new_owner->alloc_lock is held
     */
    struct task_struct *owner;
#endif

#ifdef CONFIG_PROC_FS
    /* store ref to file /proc/<pid>/exe symlink points to */
    struct file *exe_file;
    unsigned long num_exe_file_vmas;
#endif
#ifdef CONFIG_MMU_NOTIFIER
    struct mmu_notifier_mm *mmu_notifier_mm;
#endif
};

mm_user记录正在使用该地址的进程(线程也算)数目。比如两个线程共享该地址空间,则其值为2。同时mm_count(主引用数目)也是1。mm_count为0表示没有引用了,该结构体就会被撤销。一般mm_users值为0(使用该地址空间的所有线程都退出)之后,mm_count才为0。当内存在一个地址空间上操作,并需要使用该地址相关的引用计数时内核便增加mm_count——mm_count存在的意义(区别主使用计数和使用该地址的进程数)

所有的mm_struct结构体都通过自身的mmlist域链接在一个双向链表中,该链表的首元素是init_mm内存描述符。操作该链表时,需要使用mmlist_lock锁来防止并发访问。

mmap 和 mm_rb 都是保存此 进程地址空间中所有的内存区域(VMA)的,前者是以链表形式存放,后者以红黑树形式存放。用2种数据结构组织同一种数据是为了便于对VMA进行高效的操作。链表便于遍历,红黑树便于搜索指定区域
在这里插入图片描述

15.2.1 分配内存描述符

task_struct中的mm指向内存分配描述符。fork()函数利用copy_mm()函数复制父进程的内存描述符。mm_struct结构体,实际是通过allocate_mm()宏从mm_cachep_slab缓存中分配得到的。通常每个进程唯一。在调用clone时设置CLONE_VM标志共享地址空间,就会生成线程,此时copy_mm()将mm域指向其父进程的内存描述符。

是否共享地址空间几乎是进程和Linux中所谓的线程间本质上的唯一区别。除此以外,Linux内核并不区别对待它们,线程对内核来说仅仅是一个共享特定资源的进程而已。

15.2.2 撤销内存描述符

参考 kernel/exit.c 中的 exit_mm() 函数

该函数会调用 mmput() 函数减少 mm_users 的值,

当 mm_users=0 时,调用 mmdropo() 函数, 减少 mm_count 的值,

如果 mm_count=0,那么调用 free_mm 宏,将 mm_struct 还给 slab高速缓存。

15.2.3 mm_struct与内核线程

内核线程,没有进程地址空间,没有相关的内存描述符,因此内核线程对应的进程描述符中mm域为空,没有用户上下文。当新的内核线程运行时,为了避免处理器周期向新地址空间进行切换,内核线程将直接使用前一个进程的内存描述符。(内核中共享内存)

当一个进程被调度时,该进程的mm指向的地址空间被装载到内存中,进程描述符中的active_mm域会被更新,指向新的地址空间,内核线程没有地址空间,mm为NULL,内核线程别调度时,内核发现它的mm为NULL。就会保留前一个进程的地址空间,随后跟新内核线程的active_mm域,使其指向前一个进程的内存描述符,使用前一个进程的页表;因为内核线程不访问用户空间的内存,它们仅仅使用地址空间中内核相关的信息,基本和普通内存相同。

15.3 虚拟内存区域

内存区域由vm_area_struct结构体描述。内存区域在Linux内核中常被称为虚拟内存区域(VMAS)。
vm_area_struct结构体描述了指定地址空间内连续区间上的一个独立内存范围。内核将每个内存区域作为一个单独内存对象进行管理。操作相同,只是指向的位置不同。每一个VMA就可以代表一段内存区域(比如内存映射文件或者进程用户空间栈)。

/* * This struct defines a memory VMM memory area. There is one of these * per VM-area/task. A VM area is any part of the process virtual memory * space that has a special rule for the page-fault handlers (ie a shared * library, the executable area etc). */
struct vm_area_struct {
	/* The first cache line has the info for VMA tree walking. */

	unsigned long vm_start;		/* 区间首地址 */
	unsigned long vm_end;		/* 区间尾部地址 */

	/* 前后链表指针, sorted by address */
	struct vm_area_struct *vm_next, *vm_prev;
    /* 数上该VMA的节点 */
	struct rb_node vm_rb;

	/* * Largest free memory gap in bytes to the left of this VMA. * Either between this VMA and vma->vm_prev, or between one of the * VMAs below us in the VMA rbtree and its ->vm_prev. This helps * get_unmapped_area find a free area of the right size. */
	unsigned long rb_subtree_gap;

	/* Second cache line starts here. */

	struct mm_struct *vm_mm;	/* 结构体所属的地址空间 */
	pgprot_t vm_page_prot;		/* VMA访问权限 */
	unsigned long vm_flags;		/* 标志 see mm.h. */

	/* * For areas with an address space and backing store, * linkage into the address_space->i_mmap interval tree. */
	struct {
		struct rb_node rb;
		unsigned long rb_subtree_last;
	} shared;

	/* * A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma * list, after a COW of one of the file pages. A MAP_SHARED vma * can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack * or brk vma (with NULL file) can only be in an anon_vma list. */
	struct list_head anon_vma_chain; /* Serialized by mmap_sem & * page_table_lock */
	struct anon_vma *anon_vma;	/* 匿名VMA对象,Serialized by page_table_lock */

	/* 指向结构体的相关操作表指针 */
	const struct vm_operations_struct *vm_ops;

	/* 存储中的文件偏移量 */
	unsigned long vm_pgoff;		/* Offset (within vm_file) in PAGE_SIZE units */
	struct file * vm_file;		/* 被映射的文件(可以为NULL). */
	void * vm_private_data;		/* was vm_pte (shared mem) */

#ifdef CONFIG_SWAP 	atomic_long_t swap_readahead_info;
#endif #ifndef CONFIG_MMU 	struct vm_region *vm_region;	/* NOMMU mapping region */
#endif #ifdef CONFIG_NUMA 	struct mempolicy *vm_policy;	/* NUMA policy for the VMA */
#endif 	struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;

每个VMA对其相关的mm-struc结构体来说都是唯一的。内存区域位置是[vm_start,vm_end]。(同一个地址空间内的不同内存区间不能重叠)

15.3.1 VMA标志

VMA标志是一种位标志,包含在vm_flags域内,标志了内存区域所包含的页面的行为和信息。和物理页的访问权限不同,VMA标志反映了内核处理页面所需要遵守的行为准则,而不是硬件要求。
在这里插入图片描述

15.3.2 VMA相关操作

vm_area_struct 结构体定义中有个 vm_ops 属性,其中定义了内核操作 VMA 的方法:

struct vm_operations_struct
{
    /* 将指定的内存区域加入到地址空间 */
    void (*open)(struct vm_area_struct *area);
    /* 将指定的内存区域从地址空间删除,该函数被调用 */
    void (*close)(struct vm_area_struct *area);
    /* 等没有出现在物理内存中的页面被访问时,该函数被页面故障处理调用 */
    *int (*fault) (struct vm_area_struct *,struct vm_fault*);
    /* 页面为只读是,该函数被页面故障处理调用 */
    *int page_mkwrite(struct vm_area_struct *area,struct vm_fault *vmf);
    /* get_user_pages()函数调用失败时,该函数被access_process_vm()调用 */
    *int access(struct vm_area_struct *vma,unsigned long address,void *buf,int len,int write)
};

15.3.4 实际使用中的内存域

可以使用/proc文件系统和pmap工具查看给定的进程内存空间和其中所含的内存区域。

程序如下:

int main(){
    return 0;
}

在这里插入图片描述
在这里插入图片描述
前三行分别对应C库中lic.so的代码段、数据段和bss段,接下来的两个行为可执行对象的代码段和数据段,再下来三个行为动态连接程序Id.so的代码段、数据段和bss段,最后一行是进程的栈。

注意:多个进程都链接同一个so动态库,代码段共享,数据段不共享(动态链接库被多个进程访问)。因此多进程调用相同的动态链接库,它的内存地址也是不同的。

windows中的dll中的全局变量在被读取是是共享变量,但是会写时复制,对应进程不同的数据,保证数据的独立性,这样即节省了资源又保证了数据的独立性。

在Linux中,载入的动态链接库实际上可以直接使用外部框架或者其他模块的全局数据,不同于Windows,动态链接库中的全局变量可能因为多次引用造成重复初始化

15.4 操作内存区域

对内存区域的查找和验证。

//寻找给定的内存地址属于哪一内存区域
struct vm_area_struct * find_vma(struct mm_struct * mm, unsigned long addr);
//返回第一个小于addr的VMA
struct vm_area_struct * find_vma_prev(struct mm_struct * mm, unsigned long addr,
                         struct vm_area_struct **pprev);

//返回第一个和指定地址区间相交的VMA
static inline struct vm_area_struct * find_vma_intersection(struct mm_struct * mm, unsigned long start_addr, unsigned long end_addr)
{
    struct vm_area_struct * vma = find_vma(mm,start_addr);

    if (vma && end_addr <= vma->vm_start)
        vma = NULL;
    return vma;
}

15.5 mmap()和do_mmap():创建地址区间

内核使用do_mmap()函数创建一个新的线性地址区间。如果创建的地址区间和一个已经存在的相邻,并且具有相同的访问权限,两个区间将合并为一个。如果不能合并就创建一个新的VMA了。其函数定义如下:

unsigned long do_mmap(
						struct file *file, /* 指定映射源文件 */
						unsigned long addr, /* 可选,指定搜索空闲区域的起始位置 */
						unsigned long len,
						unsigned long prot,
						unsigned long flag, 
						unsigned long offset /* 映射的文件偏移 */
					);

该函数映射由file指定的文件,具体映射的是文件中从偏移offset处开始,长度为len字节的范围内的数据。如果file参数是NULL并且offet参数也是0,那么就代表这次映射没有和文件相关,该情况称作匿名映射(anonymous mapping),如果指定了文件名和偏移量,那么该映射称为文件映射(fle-backed mapping)。

prot指定页面访问权限。
在这里插入图片描述
在这里插入图片描述
在用户空间可以通过mmap()系统调用获取内核函数do_mmap()的功能。mmap()系统调用定义如下:

void* mmap2(void* start,
            size_t length,
            int prot,
            int flags,
            int fd,
            off_t pgoff);

mmap2()时mmap()调用的第二种变种,使用页偏移作为最后一个参数。

15.6 mummap()和do_mummap()删除地址区间

do_mummap()函数从特定的进程地址空间中删除指定地址区间

int do_mummap(struct mm_struct *m,unsigned long start,size_t len);
int munmap(void *start,size_t length);

系统调用munmap()是对do_mummap()函数的一个简单的封装,给用户空间程序提供了一种从自身地址空间中删除指定地址区间的方法

asmlinkage long sys_munmmap(unsigned long addr,size_t len)
{
	int ret;
	struct mm_struct *mm;
	mm=current->mm;
	down_write(&mm->mmap_sem);
	ret=do_mummap(mm,addr,len);
	up_write(&mm->mmap_sem);
	return ret;
}

15.7 页表

地址空间中的地址都是虚拟内存中的地址,而CPU需要操作的是物理内存,所以需要一个将虚拟地址映射到物理地址的机制。

地址的转换工作需要通过查询页表才能完成,概括地讲,地址转换需要将虚拟地址分段,使每段虚拟地址都作为一个索引指向页表项,而页表项则指向下一级别的页表或者指向最终的物理页面。

在这里插入图片描述页表起始地址(1级页表起始物理地址)存放在CPU中的页表基址寄存器(pagetablebaseregister,PTBR)中。

linux中使用3级页面来完成虚拟地址到物理地址的转换。转换过程如下:
在这里插入图片描述
多数体系结构,实现了一个翻译后缓冲器(TLB,块表)。TLB作为一个将虚拟地址映射到物理地址的硬件缓存。当请求访问一个虚拟地址时,处理器将首先检查TLB中是否缓存了该虚拟地址到物理地址的映射,如果在缓存中直接命中,物理地址立刻返回;否则,就需要再通过页表搜索需要的物理地址。



第16章 页高速缓存和页回写

页高速缓存(cache)是Linux内核实现的磁盘缓存。主要用来减少对磁盘的I/O操作。通过将磁盘中的数据缓存在物理内存中,把对磁盘数据的访问变为对物理内存的访问。

16.1 缓存手段

页缓存是由内存中的物理页面组成的,其对应磁盘上的物理块。
页高速缓存大小能动态调整——通过占用空闲内存以扩张大小,也可以自我收缩以缓解内存使用压力。
我们称正被缓存的存储设备为后备存储,因为缓存背后的磁盘无疑才是所有缓存数据的归属。
当内核开始一个读操作(比如read())操作时,首先检查页是否为在内存中。如果在则直接读取,称为缓存命中。不在则调度块I/O读取数据。

16.1.1 写缓存手段

写缓存手段如下:

  • 不缓存(nowrite):不去缓存任何写操作;直接写到磁盘
  • 写透缓存(write-through cache):自动更新内存缓存;缓存数据时刻与后备存储保持同步。
  • 回写(Linux采用):写操作直接写入到缓存中,后端存储不会立刻更新;将高速缓存中的页标记成“脏页”,并将其添加到脏页链表中。然后由一个进程(回写进程)周期性将脏页链表中的页写回到磁盘。最后清理脏页标识。

16.1.2 缓存回收

Linux中的脏页回收策略是,通过选择干净页进行简单的替换。如果缓存中没有足够的干净页,内核将强制地进行回写操作,腾出尽可能多的页。页的回收策略方式如下:

  • 最近最少使用(LRU);
  • 双链策略(LRU/2):LRU链表的改进,维护两个链表:活跃链表和非活跃链表。活跃上的不会被换出,非活跃上的会被换出,基本上都是尾部加入头部移除。活跃链表数量超过非活跃链表时,将会将多余的头页面重新移回到非活跃链表中,以便能再被回收。更普遍的是n个链表,称为LRU/n。

16.2 Linux 页高速缓存

页高速缓存缓存的是内存页面。缓存中的页来自对正规文件、块设备文件和内存映射文件的读写。

16.2.1 address_space对象

Linux页高速缓存的目标是缓存任何基于页的对象,这包含各种类型的文件和各种类型的内存映射

页高速缓存可能包含了多个不连续的物理磁盘块。它可以通过扩展inode结构体支持I/O操作。为了更好的性能,其使用address_space结构体,该结构体与vm_are_struct的物理地址对等。文件只能有一个address_sapce可以有多个vm_area_struct,即虚拟地址可以有多个,但是物理内存只能有一份。其数据结构定义如下:

struct address_space {
	struct inode		*host;		/* 拥有节点 */
	struct radix_tree_root	page_tree;	/* 包含全部页面的radix树 */
	spinlock_t		tree_lock;	/* 保护页面的自旋锁 */
	unsigned long		nrpages;	/* 页面总数 */
	pgoff_t			writeback_index;/* writeback starts here */
	struct address_space_operations *a_ops;	/* methods */
	struct list_head	i_mmap;		/* 私有映射连败哦 */
	struct list_head	i_mmap_shared;	/* 共享map链表 */
	struct semaphore	i_shared_sem;	/* 保护所有的链表 */
	atomic_t		truncate_count;	/* 截断计数 */
	unsigned long		flags;		/* gfp_mask掩码和错误标志 */
	struct backing_dev_info *backing_dev_info; /* 预读信息等 */
	spinlock_t		private_lock;	/* 私有address_space锁 */
	struct list_head	private_list;	/* address_space链表 */
	struct address_space	*assoc_mapping;	/* 相关缓冲 */
};

其对应的操作如下:

struct address_space_operations {
	int (*writepage)(struct page *page, struct writeback_control *wbc);
	int (*readpage)(struct file *, struct page *);
	int (*sync_page)(struct page *);

	/* Write back some dirty pages from this mapping. */
	int (*writepages)(struct address_space *, struct writeback_control *);

	/* Set a page dirty */
	int (*set_page_dirty)(struct page *page);

	int (*readpages)(struct file *filp, struct address_space *mapping,
			struct list_head *pages, unsigned nr_pages);

	/*
	 * ext3 requires that a successful prepare_write() call be followed
	 * by a commit_write() call - they must be balanced
	 */
	int (*prepare_write)(struct file *, struct page *, unsigned, unsigned);
	int (*commit_write)(struct file *, struct page *, unsigned, unsigned);
	/* Unfortunately this kludge is needed for FIBMAP. Don't use it */
	sector_t (*bmap)(struct address_space *, sector_t);
	int (*invalidatepage) (struct page *, unsigned long);
	int (*releasepage) (struct page *, int);
	ssize_t (*direct_IO)(int, struct kiocb *, const struct iovec *iov,
			loff_t offset, unsigned long nr_segs);
};

16.2.3 基树

每个address_space对象都有一个唯一的基树(radix tree),基树通过文件偏移量来检索希望的页。页面高速缓存的搜索函数find_get_page()要调用函数radix_tree_lookup(),该函数会在指定基树中搜索指定页面。

16.2.5 以前的页散列表

2.6之前,系统通过维护一个全局散列表进行检索。存在种种缺点:如锁的争用情况严重,搜索范围大等。

16.3 缓冲区高速缓存

独立的磁盘块通过块I/O缓冲也要被存入页高速缓存。回忆一下第14章,一个缓冲是一个物理磁盘块在内存里的表示。缓冲的作用就是映射内存中的页面到磁盘块,这样一来页高速缓存在块I/O操作时也减少了磁盘访问,因为它缓存磁盘块和减少块I/O操作。这个缓存通常称为缓冲区高速缓存,虽然实现上它没有作为独立缓存,而是作为页高速缓存的一部分

块I/O操作一次操作一个单独的磁盘块。普遍的块I/O操作是读写i节点。内核提供了bread()函数实现从磁盘读一个块的底层操作。通过缓存,磁盘块映射到它们相关的内存页,并缓存到页高速缓存中。

16.4 flusher线程

以下三种情况发生时,脏页被写回磁盘:

  • 当空闲内存低于一个特定的阀值时;
  • 脏页在内存中驻留时间超过一个特定的阀值时;flusher线程后台例程会被周期性唤醒来执行这个操作;
  • 用户进程调用sync()和fsync()系统调用时;内核会按照要求执行回写动作。

内核中由一群内核线程(flusher线程)执行这三种工作。当空闲内存比阀值(dirty_background_ratio)还低时,内核便会调用flusher_thread()唤醒一个或者多个flusher线程,随后flusher线程进一步调用函数bdi_writeback_all()开始将脏页写回磁盘。

每个磁盘对应于一个flusher线程。

16.4.1 笔记本电脑模式

它是一种特殊的页回写策略:将硬盘的转动的机械行为最小化,允许硬盘尽可能长时间的停滞,以此延长电池供电。可以通过/proc/sys/vm/laptop_mode文件进行配置。向其中写入1开启。其会找准磁盘运转的时机,把所有其它的物理磁盘I/O、刷新脏页缓冲等统统写回到磁盘,来减少写磁盘要求的主动运行。



第19章 可移植性

19.3 字长和数据类型

能够由机器一次完成处理的数据称为字。人们说某个机器是多少“位”的时候,他们其实说的就是该机器的字长。

C语言定义的long类型总是对等于机器字长。
ANSI C标准规定,一个char的长度一定是1字节。

在这里插入图片描述

19.3.4 char型的符号问题

C标准表示char类型可以带符号也可以不带符号,由具体的编译器、处理器或由它们两者共同决定到底char是带符号还是不带符号。

大部分体系结构上,char默认是带符号的,它可以自-128到127之间取值。也有一些例外,比如ARM体系结构上,char就是不带符号的,它的取值范围是0~255。
比如, 最简单的例子:

/*
 * 某些体系结构中, char类型默认是带符号的, 那么下面 i 的值就为 -1
 * 某些体系结构中, char类型默认是不带符号的, 那么下面 i 的值就为 255, 与预期可能有差别!!!
 */
char i = -1;

避免上述问题的方法就是, 给char类型赋值时, 明确是否带符号, 如下:

signed char i = -1;  /* 明确 signed, i 的值在哪种体系结构中都是 -1 */
unsigned char i = 255;  /* 明确 unsigned, i 的值在哪种体系结构中都是 255 */

19.4 数据对齐

对齐是跟数据块在内存中的位置相关的话题。如果一个变量的内存地址正好是它长度(字节为单位)的整数倍,它就称作是自然对齐的。举例来说,对于一个32位类型的数据,如果它在内存中的地址刚好可以被4整除(也就最低两位为0),那它就是自然对齐的。也就是说,一个大小为 2 n 2^{n} 2n 字节的数据类型,它地址的最低有效位的后n位都应该为0。

19.4.1 避免对齐引发的问题

一个数据类型长度较小,它本来是对齐的,如果你用一个指针进行类型转换,并且转换后的类型长度较大,那么通过改指针进行数据访问时就会引发对齐问题。

通过指针转换类型时, 不要转换成更大的类型, 比如下面的代码有可能出错

/*
 * 下面的代码将一个变量从 char 类型转换为 unsigned long 类型, 
 * char 类型只占 1个字节, 它的地址不一定能被4整除, 转换为 4个字节或者8个字节的 usigned long之后,
 * 导致 unsigned long 出现数据不对齐的现象.
 */
char wolf[] = "Like a wolf";
char *p = &wolf[1];
unsigned long p1 = *(unsigned long*) p;

19.4.2 非标准类型的对齐

对于标准数据类型来说,它的地址只要是其长度的整数倍就对齐了。而非标准的(复合的)C数据类型按照下列原则对齐:

  • 对于数组, 按照基本数据类型进行对齐就行。(数组元素的存放在内存中是连续的, 第一个对齐了, 后面的都自动对齐了)
  • 对于联合体, 其长度最大的数据类型对齐就可以了。
  • 对于结构体, 保证结构体中每个元素能够正确对齐即可。

为了保证结构体每一个成员都能对齐,需要引入填补机制。

struct animal_struct
{
    char dog;                   /* 1个字节 */
    /* 此处填充了7个字节 */
    unsigned long cat;          /* 8个字节 */
    unsigned short pig;         /* 2个字节 */
    char fox;                   /* 1个字节 */
    /* 此处填充了5个字节 */   
};

规则如下:

  1. 结构体每个成员相对于结构体首地址的偏移量都是成员大小的整数倍,如有需要编译器会在成员之间加上填充字节;
  2. 结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。

通过调整结构体中元素顺序, 可以减少填充的字节数, 比如上述结构体如果定义成如下顺序:

struct animal_struct
{
    unsigned long cat;          /* 8个字节 */
    unsigned short pig;         /* 2个字节 */
    char dog;                   /* 1个字节 */
    char fox;                   /* 1个字节 */
    /* 此处填充了4个字节 */   
};

但是ANSI C明确规定不允许编译器改变结构体能不成员对象的次序。

19.5 字节顺序

字节顺序是指在一个字中各个字节的顺序。

如果高地址存放低位字节,则属于高位优先(大端模式),低地址存放低位字节,则为低位优先(小端模式)。

x86体系结构,使用的都是小端模式。

Linux两种模式都支持。

判断方法:

int x = 1;  /* 二进制 00000000 00000000 00000000 00000001 */

/* 
 * 内存地址方向:   高位  <--------------------> 低位
 * little-endian 表示: 00000000 00000000 00000000 00000001
 * big-endian 表示:    00000001 00000000 00000000 00000000
 */
if (*(char *) &x == 1)   /* 这句话把int型转为char型, 相当于只取了int型的最低8bit */
    /* little-endian */
else
    /* big-endian */


参考资料:

《Linux 内核设计与实现》

《Linux Kernel Development 3rd Edition》

https://wangpengcheng.github.io/tags/#%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F

https://www.cnblogs.com/wang_yb/p/3514730.html

  • 4
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值