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

1. Linux内核简介

Unix的特点:

  • 仅有几百个系统调用,设计目标明确;
  • 所有东西都被当作文件对待,因此所有的数据和设备都可以通过同一套系统调用进行操作:open、write、read、lseek、close;
  • 内核和系统工具软件是通过C语言编写的,因此对各种硬件平台都有较好的移植性;
  • 进程创建方便:可通过fork()函数创建,而且方便的进程通信机制可以保证将这些进程组合在一起,去实现更复杂的任务。
  • 而Linux发源于Unix,更加自由。Linux的内核同样是该操作系统的核心;在Linux系统中运行的应用程序同样通过系统调用来与内核通信——我们可以说内核与进程通过系统调用进行交互;内核还负责管理系统的硬件设备,如通过异步的硬件中断机制以及中断号和中断处理程序保证中断的第一时间响应与快速退出;
  • 用户空间、内核空间、硬件三者之间的关系为:
  • 对一些不合理的内核设计进行了修改与优化,例如:Linux存在一些不需要MMU即可运行的特殊版本;相比于单内核(Unix。只有一个单独的内核地址空间,所有模块都在其中,可以直接调用函数),Linux系统汲取了微内核(可以理解为每个模块都单独运行,它们之间的通信开销较大)的优点,支持内核线程与动态装载内核模块,且所有事情运行在内核态,直接调用函数,无需消息传递 。Linux支持多处理机制(SMP);不区分线程与一般进程;具有设备类的面向对象设备模型,以及用户空间的设备文件系统(sysfs)

2. 从内核出发

编译内核:

  • 前缀位CONFIG_表示内核的配置功能,一般通过使用命令行跳转到内核配置页面,如 make config等命令;编译命令:make。随后将编译好的内核存储到相应设备中对应的位置,并在启动流程中通过bootloader启动内核。

内核开发的特点:

  • 头文件与日常应用开发的不同,如没有printf函数,取而代之的是printk,与前者不同的是,printk可以通过一个标志来指定优先级标志,从而决定在哪里显示这条系统消息。
  • 编译器一般采用gcc(GNU编译器的集合),内核代码中支持内联函数、内联汇编(C函数中嵌入汇编指令)、分支声明(根据指令如likely()对分支选择进行优化)
  • 没有内存保护机制。应用程序发生非法访问时,内核会发现此错误并发送SIGSEGV信号并结束此进程;内核中发生了非法访问内存时会导致oops,相比与应用程序会有较大的风险。
  • 使用浮点数计算时较复杂,较难完美支持。
  • 内核栈的容积小而且固定。一般来说是两页,即32位机是8K,64位机是16K。
  • 易产生竞争条件。如任务发生抢占;对称多处理器系统(SMP)时;中断到来时。
  • 可移植性很重要。保持字节序、64位对齐、不假定字长和页面长度等规定有助于提高移植性。

3. 进程管理

  • 进程:处于执行期间的程序以及相关资源的总称。
  • 进程的两种虚拟机制:虚拟处理器与虚拟内存。
  • 进程描述符(task_struct):其相对较大,其中包含的数据能完整地描述一个正在执行的程序:它打开的文件、进程的地址空间、挂起的信号、进程的状态等。
    在这里插入图片描述
  • 分配进程描述符:Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色(cache coloring),并只需在栈顶创建一个新的结构struct thread_info,该结构体的task域中存放着该任务实际task_struct的指针,如下图所示。
    在这里插入图片描述
  • 一个进程的唯一标识符:PID,默认最大值为32768,该值是系统中允许同时存在的进程的最大数目。内核通过current宏查找当前正在运行进程的进程描述符。不同的硬件体系,该宏的实现也不同,因此其查找速度也有不同。
  • 进程上下文:表示处理器从用户进入内核态时需要保存当前运行进程状态的一些环境。
  • 进程家族树:以init(PID为1)为祖先,包括父子进程、兄弟进程等的拓扑。
  • 进程的创建:fork(内部通过clone实现)->exec。前者fork用于拷贝当前进程创建一个子进程,子进程与父进程的区别仅在于PID和某些资源和统计量,后者exec负责读取可执行文件并将其载入地址空间开始运行。
  • fork创建子进程时的写时复制(copy-on-write)。内核此时不复制整个进程地址空间,而是让父子进程共享一个数据,只有在写入的时候,数据才会被复制,在此之前只是以只读方式共享。因此fork函数的实际开销就是复制父进程的页表(进程在内存映射中的地址信息)以及给子进程创建唯一的进程描述符。
  • 在Linux的内核中,进程与线程都是通过task_struct对象来描述的。也就是说只有在用户态的视角才对进程与线程进行区分,两者在内核态的区别是:同一个进程中的线程拥有自己的task_struct,但是共享一部分资源如:虚拟地址空间、文件系统信息、文件描述符、信号处理函数等。
  • 内核线程。内核线程只运行在内核空间,它和普通进程间的区别是内核线程没有独立的地址空间(指向地址空间的mm指针被设置为NULL)
  • 程序的退出:父进程wait->子进程exit。进程终结时所需的清理工作和进程描述符的删除被分开执行,wait函数等待接收子进程exit后发送来的PID,并通过release_task对进程描述符进行释放。
  • 因此针对异常的结束情况产生了两种不正常结束进程:1.僵死进程:子进程结束时父进程没有调用wait函数;2.孤儿进程:子进程结束时,父进程已经结束。Linux解决这个问题的思路是通过在当前线程组中找一个线程作为父亲,如果不行就让init作为他们的父进程。

4. 进程调度

  • 调度策略主要在进程响应速度与最大系统利用率(高吞吐率)的矛盾之间寻求平衡。
  • Linux调度器是以模块方式提供的,该模块化结构被称为调度器类。
  • Linux系统的完全公平调度(CFS)是一个针对普通进程的调度类,在该调度算法中,nice值作为权重将调整进程所使用的处理器时间使用比,每个进程都按其权重在全部可运行进程中所占比例的“时间片”来运行。
    在这里插入图片描述
  • CFS中,每个进程都有一个调度器实体结构sched_entity,嵌入在进程描述符task_struct中,其中vruntime虚拟实时变量存放着进程的虚拟运行时间,该变量其实是实际运行时间与施加进程权重后的综合值,它记录了一个程序运行了多久(加权值)以及它还应该再运行多久。CFS算法的核心就是通过红黑树选择具有最小vruntime值的进程。
  • 进程调度的入口函数:schedule。
  • 上下文切换:从一个可执行进程切换到另一个可执行进程。这时,schedule会调用context_switch,该函数完成了两项基本的工作:switch_mm:把虚拟内存从上一个进程映射切换到新进程中;switch_to:把处理器状态(保存、恢复栈以及寄存器信息等)从上一个处理器状态切换到新进程的处理器状态。
  • 每个进程都包含一个need_resched标志(因为current宏速度很快而且通常描述符都在高速缓存中,所以其访问速度比全局变量快),用来表明是否需要重新调度。
  • 用户抢占:发生在从系统调用返回到用户空间时、从中断处理程序返回用户空间时,通过在此时检查need_resched标志决定是否发生用户抢占。
  • 内核抢占(抢占内核):发生在中断处理程序正在执行,返回内核空间之前、内核任务阻塞、内核任务显式调用schedule、内核代码再一次具有可抢占性时。通过在此时检查need_resched标志以及preempt_count(锁计数器)决定是否发生内核抢占。
  • Linux的实时调度算法:SCHED_FIFO与SCHED_RR,这两个算法都是软实时工作方式,内核尽力但不保证满足所有进程的实时调度要求。

5. 系统调用

  • 系统调用是用户进程与内核进行交互的一组接口,它可以保证应用程序受限、安全的访问硬件设备,是除了异常和陷入外,内核唯一的合法入口,同时也是实现多任务与虚拟内存的必要条件之一。
  • 使用系统调用访问Linux内核的一般流程:应用程序 – > C 程序库 – > 系统级POSIX 标准API函数-- > 系统调用-- > 内核代码
  • 内核中系统调用的实现使用过SYSCALL_DEFINEX(系统调用名字)来实现的,X代表该系统调用的参数数量。
  • 系统调用号是唯一的与系统调用相关联的号,通过该号而不是名称可以指明执行哪个系统调用。
  • 系统调用通过软中断引发一个异常从而陷入内核中去执行相应的异常处理函数。在Linux内核X86体系中,系统调用对应的软中断号为128,通过int $0x80触发一个异常从而使内核切换到内核态并执行128号异常处理程序——system_call()。在陷入内核前,用户空间已经将对应的系统调用号放入了eax中。
  • 内核无论何时都不能轻率的接受来自用户空间的指针,因此内核提供了两个方法来完成必须的检查和内核空间的与用户空间数据的来回拷贝:copy_to_user和copy_from_user。
    当包含用户数据的页被置换到硬盘而不是物理内存的时候,这两个函数会发生阻塞。
  • 在进程上下文中,内核可以休眠也可以被抢占。能够休眠说明系统调用可以使用内核提供的绝大多数功能(七);能够被抢占说明当前的进程同样可以被其他进程抢占,所以系统调用应该可以重入。
  • 实现一个自己的系统调用一定要有合理的理由,并且考虑兼容性和未来维护性的问题(内核社区一般不维护额外的系统调用)。另外,传入参数验证,调用的性能也是需要考虑的。如果能用ioctl方式来实现内核访问(大部分多媒体相关的功能都通过该接口实现特殊的功能),则无需自制系统调用。

6. 中断和中断处理

  • 中断是为了避免CPU一直监控某个接口的数据,从而占用着CPU的使用率,提出的一种硬件发起的异步通知手段。与之相似的,异常则是一种同步通知手段,比如软中断就是异常的一种情况。
  • Linux中,中断处理一般被分为两个部分。中断处理程序是上半部(top half),负责接收中断并立即执行,一般是接收数据;下半部(bottom half)是允许稍后完成的工作,如处理和操作数据。
  • 注册中断处理程序。Linux驱动程序通过request_irq注册一个中断处理程序,并激活给定的中断线。如下图所示,参数irq表示要分配的唯一中断号;参数handler指针指向这个中断的实际中断处理程序;第五个参数dev共享中断线。当一个中断处理函数需要释放时,dev将提供一个标志信息(cookie)使得系统从该共享中断线中的众多中断处理函数中删除指定的那一个,如果该中断线是最后一个处理程序,则该中断线也被禁用;如果中断线不是共享的,则处理函数被删除的同时,这条中断线也被禁用。
    在这里插入图片描述
  • request_irq成功执行会返回0,返回0表示错误发生;此外,因为request_irq会调用kmalloc函数,因此可能会睡眠。所以不能在中断上下文或其他不允许阻塞的代码中调用该函数。
  • Linux的中断处理函数是不重入的。在中断处理函数的执行中,同一中短线在所有处理器上会被屏蔽,其他中断线都是打开的。即中断处理函数不会发生嵌套。
  • 当执行一个中断处理函数时,内核处于中断上下文。中断上下文不可睡眠,否则无法对他进行重新调用。
  • 中断处理函数的函数栈是可配置的。如果没有配置独立的中断函数栈,中断ISR将共享被中断进程的栈,一般情况中断处理函数都会配置1K大小的栈,所以需要节约使用,不要在ISR中定义太多局部数据。
  • 在Linux的数据同步中,一是提供锁的保护机制,从而防止其他处理的并发访问;二是通过禁止中断防止其他中断ISR的并发访问。

7. 下半部和推后执行的工作

  • 如果一个任务对时间敏感/和硬件相关/保证不被其他中断,就把它放到ISR中执行,其他任务则放到下半段执行。因此这种设计可以使得中断线被屏蔽的时间缩短,从而提高系统的响应能力。

  • 与上半部不同,下半部可以通过多种机制实现。目前仍在使用的有:软中断(系统调用中提到的准确的称其实是软件中断)、tasklet、workqueue、内核定时器(10)。其中内核定时器可以把下半部操作推迟到确定的时间段执行。

  • 软中断是在编译期间静态分配的。一个软中断由include/linux/interrupt.h中的structsoftirq_action对象描述。每个被系统注册的软中断在softirq_to_name[]数组中保留一个位置,最大支持32个软中断。

  • 触发软中断:一个软中断必须在被标记后才执行,中断处理程序会在返回前标记他的软中断,这样内核在执行完中断处理程序以后,马上就会执行软中断去处理剩下的任务。软中断被触发后,softirq_action.action()函数将会执行。

  • 一般在系统中对时间要求最严格以及最重要的下半部才使用软中断,如网络子系统和SCSI子系统。其他情况一般不使用软中断,因为它的使用较为复杂。

  • 软中断处理程序执行时,不允许休眠,允许响应中断(因此要做好预防,如屏蔽中断线以及加锁)。当前处理器上的软中断被禁止,但是其他处理器仍可以执行该软中断。因此任意共享数据都需要严格的锁保护,也因此大部分软中断处理程序都采取单处理器数据,这也是tasklet相比软中断更受青睐的原因。tasklet本质上也是软中断,但是他不允许同一处理程序的多个实例在多个处理器上同时运行。

  • tasklet是在运行时动态注册和注销的。基于软中断形成,其实由两类软中断代表HI_SOFTIRQ和TASKLET_SOFTIRQ。如下图所示。
    在这里插入图片描述

  • task由linux/interrupt.h中的task_struct结构构成,其中func是tasklet的处理函数。
    在这里插入图片描述

  • tasklet同软中断一样不能睡眠/阻塞、允许中断响应。不同之处在于,两个相同的tasklet实例即使有多个处理器绝不会同时执行。不过两个不同的tasklet实例是可以在两个处理器上同时执行的,因此必要时候记得加锁。

  • ksoftirqd/n是一组内核进程(线程),其中n是处理器的编号。当内核中出现了大量软中断(和tasklet)的时候,这些进程就会辅助他们。这些线程的优先级最低(nice为19)。

  • 工作队列是一种允许休眠和重新调度的中断处理函数后的任务执行机制,软中断、tasklet不允许睡眠,因而要在推后执行任务中睡眠时,应该使用工作队列。工作队列上的任务是通过创建worker thread内核线程来处理的,运行在进程上下文,不属于中断处理程序,所以是可以睡眠的。

  • 工作队列相关的内核API在 include/linux/workqueue.h中声明。在使用时定义DECLARE_WORK()与work_func_t函数。

  • 中断处理函数返回时,通过schedule_work()触发工作队列的调度。schedule_work()调度函数会唤醒相应处理器上的内核线程或者创建内核线程来处理工作队列上pending的任务。

  • 禁止下半部:在驱动程序中,为了保证共享数据的安全,一般是先得到一个锁然后再禁止下半部的处理(10)。

8. 内核同步介绍与方法

  • 同步(synchronization)用来在内核避免数据的并发和防止竞争。用户空间的并发:用户程序会被调度程序抢占和重新调用。单处理器其实是伪并发,支持对称多处理器的机器才存在真并发,即在临界区同时有两个进程执行。内核空间的并发:中断,软中断与tasklet,内核抢占,睡眠与用户空间的同步,对称多处理器。
  • 需要保护的数据:大多数内核数据结构。局部变量,动态分配的数据(在堆栈上)不需要加锁。
    加锁的副作用:如死锁,自死锁。如何避免死锁:按顺序枷锁,防止饥饿,不要重复请求一个锁,设计力求简单。
  • 加锁粒度:用来描述加锁保护的数据规模。
  • 原子操作:将读取和增加变量的行为包含在一个单步中执行。

自旋锁

  • 用在不可睡眠,短暂等待的请求进程。假设自旋锁已经被A线程持有,B线程如果试图获取自旋锁,不同于信号量,B线程不会阻塞休眠,而是会占用一个CPU,不停得循环-旋转-等待。
    自旋锁相关的API放在include/linux/spinlock.h文件中。具体实现结构与内核及操作系统有关。
  • 自旋锁不可递归调用,否则会造成死锁。
  • 中断、软中断可以使用自旋锁,但是在持有锁之后,需要禁止本地CPU中断(即禁止当前处理器上的中断请求),从而避免双重请求死锁。内核提供的禁止中断的同事请求锁的接口:spin_lock_irqsave()、spin_unlock_irqrestore()。
  • 读写自旋锁:在自旋锁的基础上,加上读写相关的场景。多个读者可以安全的并发获取同一个锁(递归获取也是安全的),而写具有互斥性,同一时间只有一个写者获取锁,否则将发生互斥。任务链表的存取模式类似读-写自旋锁。相关的API在include/linux/rwlock.h中。

互斥体

  • 信号量:在Linux中是一种睡眠锁。如果有一个任务试图获得一个不可用的信号量,信号量会将该任务推进一个等待队列,然后让其睡眠。适用于锁会被长时间持有的情况。只有在进程上下文才能获取信号量锁,中断上下文不能进行调度。
  • 在实际使用时,基本上用到的都是互斥信号量(技术count == 1的信号量)。
  • 类似于信号量,加入了读写场景,无法获取写锁时将被阻塞休眠。
  • 互斥体mutex:一种用于互斥的强制睡眠锁。不能递归上锁和解锁,不能在中断和下半部使用。而且只能通过官方API管理。
  • BLK大内核锁:一种全局自旋锁。是Linux初期的SMP过渡到细粒度加锁机制。

顺序锁

  • 有疑义的数据写入,会得到锁,并且会有计数器序列值增加。

  • 读取数据前后,与数据相关的计数器的序列值都会被读取。- 比较读取前后的序列值,如果读取前后序列值相同,证明读取过程未被打断。如果序列值是偶数,则表明没有写操作发生。

  • 顺序锁相关的API在include/linux/seqlock.h中.

  • 顺序锁大部分情况用在读写者相关的地方,一般读者多,写者少,写优先于读,且读者不能让写者饥饿。

  • 使用seq锁最具有说服力的是jiffies,该变量存储了Linux机器启动到当前的时间(9)

  • 禁止抢占:相关的API在include/linux/preempt.h中。用在某些不需要自旋锁,但是也要禁止抢占的场合。

  • 顺序和屏障:某些需要和机器硬件打交道的场合,代表外部硬件交互的变量,读写顺序要有保障,不能被打乱。

  • 编译器和处理器可能自作聪明地打乱命令的执行顺序。

  • 此时需要读/写屏障,保障屏障前后的顺序不会被打乱。

  • 相关的API实现与具体机器架构有关,一般放在asm/system.h中。

  • 通知链是Linux内核态中,模块之间,通过注册回调函数,互相异步通知的机制。其设计思路类似于设计模式中的订阅者-发布者(也叫观察者)模型。一旦发布者产生对应的事件,会依次调用通知链上notifier_block所注册的callback函数,通知所有感兴趣的订阅者。

9. 定时器和时间管理

  • 系统定时器:由一种可编程一件芯片驱动,能以固定频率产生数字时钟、中断、处理器频率。该频率被称为节拍率(tick rate),大量的内核函数的生命周期都离不开系统定时器来把握流逝时间的控制。
  • 动态定时器:一种用来指定推迟程序运行时间的工具。可以不断的创建和撤销,运行次数不受限。由结构timer_list表示,定义在文件<linux/timer.h>中。
    在这里插入图片描述
  • 内核在时钟中断发生后执行定时器,定时器作为软中在下半部上下文中执行。
  • 墙上时间:真实世界的真实时间。通过实时时钟(RTC)来持久存放系统时间,掉电后也可以保持系统计时。系统启动后,墙上时间存放在xtime变量中,读写xtime变量需要使用xtime_lock锁(顺序锁seqlock)。
  • 系统运行时间:自系统启动开始所经历的时间。
  • 节拍率:定义在<asm/param.h>中,大部分体系结构的默认时钟中断频率是100HZ。高频率的优势与劣势:优势:内核的定时器、系统调用等会有更高的准确度与精度;劣势:意味着时钟中断频率高,系统负担加重。时钟中断处理程序占用CPU增多,不但减少了处理器处理其他工作的时间,还会打乱处理器高速缓存并增加耗电。
  • Linux可以通过CONFIG_HZ配置“无节拍操作”的选项,使系统可以动态调度时钟中断。
  • 全局变量jiffies:用来记录自系统启动以来产生的节拍总数。jiffies一秒内增加的值就是HZ。jiffies由于历史原因,32位的jiffies变量可以有时间溢出问题,目前有64位的jiffies_64可以修复该问题。
  • Timeout延迟机制
  • 除了动态定时器timer, Linux内核timebefore(),udelay(), ndelay(), mdelay(),schedule_timeout()等一系列延迟等待的机制和API。

10. 内存管理

  • 虽然CPU是以字/字节作为最小可寻址单位,但是内存管理单元(MMU 管理内存并把虚拟地址转换成物理地址)是以页(page)为单位来处理的。大多数32位体系结构支持4KB的页;64位体系支持8KB的页。
  • 内核中用struct page来表示系统中的物理页,放在linux/mm_types.h中。如下图所示,每个物理页都分配这样的一个结构体。
    在这里插入图片描述
  • 其中flag用来存放页的状态,其中的每一位单独表示一种状态;_count域存放页的引用计数;virtual存放页的虚拟地址。
  • 由于存在硬件限制,内核不能对所有的页一视同仁,因此将内存中的页划分为不同的区。

在Linux/mmzone.h中对Linux中所主要使用的四种区进行了定义

  • ZONE_DMA,ZONE_DMA32,ZONE_NORMAL,ZONE_HIGHEM。其中ZONE_HIGHEM用于DMA操作;ZONE_HIGHEM为高端内存,为解决物理地址寻址范围远大于虚拟地址的情况,该区中的页不能永久地映射到内核地址空间。不允许同时从两个区进行分配。每个区用struct zone表示,在linux/mmzone.h文件中定义,如下所示。
    在这里插入图片描述
  • 其中,lock域是一个自旋锁用来防止结构被并发访问;watermark持有该区的最小值、最低和最高水位值;name域是一个以NULL结束的字符串以表示这个区的名字。
  • 获得与释放、TLB抖动。请求内存的底层接口中,一部分以页为单位分配内存,定义于linux/gfp.h中,如alloc_pages,get_zoned_page,相对应的释放页函数为free_pages等。为避免内核分配失败造成的前功尽弃,在程序开始时就先进行内存分配是很有意义的。
  • 更常见的,以字节为单位的分配来说,内核提供的函数是kmalloc,vmalloc等函数,定义在linux/slab(vmalloc).h中。其中kmalloc分配的内存区在物理上是连续的,而后者只保证虚拟地址连续,通过页表将需非连续的物理地址映射到连续的虚拟地址,从而会造成TLB(一种硬缓冲区,用来缓存内存中的映射关系)抖动。相对应的释放函数如kfree,vfree等,同样声明于linux/slab.h中。
  • 分配器标志gfp_mask。可以分为三类:行为修饰符表示内核如何分配所需内存(不能睡眠等);区修饰符:表示从哪个区分配内存;类型标志:将型维修师傅和区修饰符进行了组合。常见的有GFP_KERNEL(允许睡眠),GFP_ATOMIC(不允许睡眠)等。
  • slab管理:slab分配器用于频繁使用的通用数据结构缓存层,是一组空闲链表。
    在这里插入图片描述
  • slab的工作原理如下:为需要经常alloc/free的数据结构对象建立一个slab高速缓存数组,每一个进入高速缓存的对象实例作为一个节点。需要分配一个A类型的数据对象时,当如果A类型的slab高速缓存组有未用完的A对象的数据节点,就直接从缓存中获取A对象实例,重新初始化。如果slab高速缓存中没有现存的节点,再重新申请分配物理内存。
  • 同理,释放对象的时候,把失效的对象A作为数据节点放回A对象的slab高速缓存,以便需要时再从中获取。
    在这里插入图片描述
  • slab分配器提高了常用数据结构对象的内存分配效率,无需每次都申请物理内存再转换为虚拟内存,缓解了内存碎片化的问题。
  • 内核栈的分配。内核态中可以光明正大的使用函数局部变量从而使用内核栈的内存,没有太多诀窍,谨记节约使用。一般进程的内核栈空间是1-2页(一页是4K(32位)或8K(64位)),所以不要定义过大的静态数据结构,不要随意使用递归,内核栈溢出(stack overflow)悄无声息,没有提醒,会造成不可预知的错误。
  • 高端内存的映射。高端内存相关的API一般在include/linux/hignmem.h中,一般通过gfp_mask标志,用alloc_pages()获取高端内存的page描述符后,可以通过kmap()将高端内存映永久射到虚拟地址空间,但是允许使用kmap()映射的内存数量是有限的,不再需要映射时,应该用kunmap取消映射。当必须创建一个临时映射而当前上下文不允许睡眠时,内核提供了临时映射kmap_atomic和相对应的kunmap_atomic。
    关于虚拟地址和物理地址的问题:CPU通过三级页表(线性地址)从虚拟地址查找到物理地址
  • CPU使用虚拟地址查找页表,从而把虚拟地址转换为物理地址。
  • 一般Linux内核有三级页表,32位虚拟地址(如0x8ed20000)可以分为4个部分(域),4个部分都是由含义的分别代表各级页表与页帧的索引,可以通过这些索引依次从各级页表和页帧中找到该32位虚拟地址(0x8ed20000)所对应的真正的物理地址。32位虚拟每个域的含义和索引方法如图所示。
    在这里插入图片描述

32位虚拟地址分为4个域,每个域对应一张表的索引,最终查找到其物理地址

  • PGD(全局页目录) --> PMD(中间页目录) – > PTE(保存page entry的数组) – > 页帧(物理地址所在物理页) – > 加上物理页首地址+ offset就是物理地址

  • 在一个进程的 task_struct -> mm ->pgd指针指向Linux内核全局共享的pgd表,因而系统可以从这里开始,顺利成章地索引到进程虚拟地址对应的物理地址

  • 物理内存的页经常被称为页帧(page frame),Linux内核中经常出现的带有pfn的变量,pfn是指page frame number,即该页帧在页表(PTE)中索引号,即该页是PTE表中的第几页。

  • 内核为每个CPU分配了自己私有数据的API,定义在include/linux/percpu.h中。

  • 使用每个CPU数据的好处:减少了数据锁定;减少缓存失败。唯一要求就是禁止内核抢占。

11. 虚拟文件系统

  • 虚拟文件系统(VFS)作为内核的一个子系统。通过VFS,用户可以通过标准的Unix系统调用对不同的文件系统或不同介质的文件系统或设备驱动进行统一的读写操作,内核开发者只需要按照框架实现和扩展响应的文件系统和设备驱动。满足了面向对象的开闭原则
  • 用户空间-VFS-文件系统-物理介质
  • VFS中四种主要的对象:索引节点inode、目录对象、文件对象、超级块
  • 文件相关信息被存储在一个叫做索引节点(inode)的数据结构中;与文件系统控制相关的信息存储在超级块中
  • VFS的面向对象OOP:通过结构体对各种对象进行表示;通过函数指针对每个对象的操作函数。
  • 超级块对象(super_block)用于存储特定文件系统的信息,通常对应于放在磁盘特定扇区中的文件系统超级/控制块;基于非磁盘的文件系统(如基于内存的文件系统sysfs),则会在现场创建超级块并存在内存中。超级块对象通过alloc_super创建并初始化,超级块中的s_op是一个指向超级块的操作函数表,定义在linux/fs.h中。
  • 索引节点对象(inode)包含了内核在操作文件或目录时所需要的全部信息。一个索引节点代表文件系统中的一个文件,仅当文件被访问时在内存中创建。
  • 目录项对象(dentry)。在Linux中路径名的查找需要解析路径中的每一个组成部分,较费时。其中路径的每个组成部分都由一个索引节点对象表示。目录项对象没有对应的磁盘数据结构,VFS通过字符串形式的路径名现场创建它。同样没有是否被修改的表示,只有:被使用、未被使用、负状态三种有效状态。
  • 由于VFS遍历路径名并进行解析是一件非常费力的事情,所以内核将目录项对象缓存在**目录项缓存(dcache)**中。缓存内容主要包括三部分:被使用的目录项链表,最近被使用的双向链表,用于快速解析路径的散列表和相应的散列函数。举例,在一个目录下进行文件编译时,VFS首先在目录项缓存中搜索路径名,如果未找到VFS必须自己通过遍历文件系统为每个路径分量解析路径,解析完毕后,再将目录项对象加入dcache中。其实只要目录项被缓存,其相对应的索引节点也就被缓存了,即dcache在一定意义上也提供对索引节点的缓存(icache)。
  • 文件对象:表示进程已经打开的文件在内存中的表示。因为多个进程可以同时打开和操作同一个文件,所以一个文件可能对应多个文件对象。而对应的索引节点和目录项对象是唯一的。在文件对象的操作函数file_operations中,ioctl函数就是用来给设备发送命令参数对的。
  • 管理文件系统其他数据的相关数据结构
  • file_system_type,用来描述各种特定的文件系统类型以及功能和行为(如从磁盘中读取超级块);vfsmount,用来描述一个安装文件系统的实例。
  • 和进程相关的数据结构
  • 系统中每一个进程都有自己的一组打开的文件,如根文件系统、当前工作目录、安装节点等。将系统的进程与VFS层紧密联系在一起的三个数据结构:file_struct包含了所有与单个进程相关的信息(如打开的文件及其文件描述符);fs_struct包含了文件系统和进程相关的信息(如当前进程的工作目录pwd等);namespace由所有进程共享,是唯一的文件系统层次结构,只有在进行clone函数时使用CLONE_NEWS标志才会给新进程一个唯一的namespace结构体拷贝,因此大部分进行都继承其父进程的命名空间,即大多数系统上只有一个命名空间。

12. 块I/O层

  • 块设备与字符设备。块设备是指系统中能够随机访问固定大小数据片的硬件设备,如硬盘、软盘驱动器、光驱和闪存等;字符设备按照字符流的方式被有序访问,如串口、键盘等。由于管理块设备要比管理字符设备复杂,因此在内核中有一个专门提供服务的子系统对块设备和块设备的请求进行管理:I/O块。
  • 各个文件系统的最小逻辑可寻址单元是块;块设备中的物理最小可寻址和操作单元是扇区,扇区的大小一般是2的整数倍,最常见的是512字节。扇区也被叫做硬扇区或设备块,块也被叫做文件块或I/O块。
  • 缓冲区与缓冲区头。当一个块被调入内存时,要存储在一个缓冲区中,每一个缓冲区都有一个对应的描述符,用buffer_head来表示,称作缓冲区头,它包含了内核操作缓冲区所需要的全部信息,描述磁盘块和物理内存缓冲区之间的映射关系。
    在这里插入图片描述
  • (新内核容器)bio结构体:内核中块I/O操作的基本容器,代表了正在现场执行(活动)的以片段(segment)链表形式组织的块I/O操作。每一个块I/O请求都通过一个bio结构体表示。
  • 挂起的块I/O请求保存在请求队列reques_queue双向队列中。
  • 四种调度程序:预测、最终期限、完全公平、空操作。与将处理器资源分配给系统中的资源的进程调度有一些相似,IO调度通过将请求队列中挂起的请求进行合并与排序后让磁盘IO资源按调度情况分配给块IO设备。不同的是进程调度是将一个资源虚拟给多个对象;IO调度是虚拟块设备给多个磁盘资源,以保证磁盘寻址速度最快。
  • 合并与排序。通过合并请求,IO调度程序将多次请求的开销压缩成一次请求的开销,减少传递磁盘的寻址命令,从而减小系统开销和磁盘寻址次数。通过排序请求,将整个队列按扇区增长方向排序,磁盘位置相近的操作也紧邻执行。通过保持磁盘头以直线方向移动,缩短了所有请求的磁盘寻址时间。
  • 早期的合并排序IO调度算法:Linux电梯。已存在则合并,如果驻留时间过长则插入队尾;扇区方式有序则插入,不存在合适插入位置则插入到队列队尾。
  • 最终期限IO调度程序:为解决Linux电梯算法所带来的饥饿问题而提出。再最后期限IO调度中,每个请求都有一个超时时间,默认为500ms。该算法不严格保证请求的响应时间,但是通常情况下可以在请求超时时或超时前提交和执行,以防止饥饿现象发生。但是该调度方法在为降低读操作响应时间的同时降低了系统吞吐量。
  • 预测IO调度算法。基础仍然是最后IO调度,也实现了三个队列(加上了派发队列),并为每个请求设置了超时时间,主要区别是增加了预测启发,试图在进行IO操作期间,处理新到来的读请求所带来的寻址数量。
  • 完全公正的排队IO调度(CFQ)为专有工作负荷设计,以时间片轮转调度队列。
  • 空操作(Noop)IO调度。空操作,不排序也不寻址。

13. 进程地址空间

  • 即用户空间中进程的内存,由进程可寻址的虚拟内存组成。每个进程都有一个32位或64位的平坦(flat,即独立的连续区间)虚拟地址空间。
  • 线程:两个进程的地址空间有相同的内存地址,及共享地址空间。
  • 在4G的地址空间中,可被进程合法访问的地址空间称为内存区域(memory areas),通过进程可以给自己的地址空间动态的添加或减少内存区域。如果进程访问了非有效内存区域或以不正确的方式进行访问,内核会终止该进程,并返回“段错误”。
  • 内存区域中包含了各种内存对象如:可执行文件代码的内存映射:代码段(text station)、已初始化全局变量的内存映射:数据段(data section)、未初始化全局变量(bss)、进程用户空间栈、C库或共享库内使用到的对象等。
  • 内存描述符mm_struct:内核使用内存描述符结构体表示进程的地址空间。其中mmap(单独链表)和mm_rb结构体描述的对象都是该地址空间中的全部内存区域。
    在这里插入图片描述在这里插入图片描述
  • 与用户进程相对,内核线程没有进程地址空间,也没有相关的内存描述符——即没有用户上下文。当一个进程被调度时,该进程的mm域指向的地址空间被装载到内存,内存描述符active_mm会被更新,指向新的地址空间。当新内核线程运行时,内核线程将直接使用前一个进程的内存描述符,mm域也指向NULL。
  • 在Linux中内存区域(vm_area_struct)也常称作虚拟内存区域(virtual memory areas,VMAs) 在这里插入图片描述
  • VMA标志。一种为标志,定义在linux/mm.h,包含在vm_flags域中,标志了内存区域所包含的页面的行为和信息。VMA操作,vm_area_struct结构体中的vm_ops域指向了与指定内存区域相关的操作函数表(vm_opeartions_struct)。
    在这里插入图片描述
  • 操作内存区域:声明在linux/mm.h中。如
    (1)ind_vma:找到一个给定的内存地址属于哪一个内存区域。该函数检查mmap_cache,查看缓存的VMA是否包含了所需地址,如果不包含,该函数搜索红黑树。
    (2)find_vma_prev:与上函数类似,但是返回第一个小于addr的VMA。
    (3)find_vma_intersection:返回第一个和指定地址间相交的VMA。
  • 创建地址空间函数:mmap与do_mmap。内核使用do_mmap创建一个新的线性地址区间并加入到进程的地址空间中(而不一定是新的VMA),如果该地址空间与已存在的地址区间相邻且权限相近,将进行合并;如果不能合并则创建一个新的VMA。在用户空间可以通过mmap系统调用获取内核函数do_mmap的功能。
    在这里插入图片描述
  • 删除地址区间:mummap和do_mummap。
  • 三级查询页表:通过将虚拟地址分段,使每段的虚拟地址作为一个索引指向页表,而页表项则指向下一级别的页表或者最终的物理页面,查询过程如下图所示。每个进程都有自己的页表。但是搜索内存中的物理地址速度很有限,因此多数体系结构都实现了一个翻译后缓冲器(TLB)。当请求一个虚拟地址时,处理器首先检查TLB中是否缓存了该虚拟地址到物理地址的映射,如果直接命中则直接返回物理地址。否则就需要在通过页表搜索需要的物理地址。
  • 其实在内核中一直在对页表的管理进行不断的改进以提高效率,如从高端内存分配部分页表;以写时拷贝(copy-on-write)的方式共享页表。在这里插入图片描述

14. 页高速缓存(cache)和页回写

  • 页高速缓存(cache)通过把磁盘中的数据缓存到物理内存中,把对磁盘的访问变为对物理内存的访问,从而解决了磁盘和内存间的访问速度差,以及短时期内集中访问同一片数据(临时局部原理temporal locality)
  • 页高速缓存其实是内存中的物理页面组成,这块大小可以动态调整。
  • 发生读操作(如进程发起read系统调用)时,首先会检查数据是否在页高速缓存中,如果在则直接从内存中读取(缓存命中),否则称为缓存未命中,内核必须调度IO操作从磁盘去读取数据。系统按页缓存,不一定要将整个文件都缓存,也可以缓存几个文件的几页。

发生写操作(如write系统调用)时,缓存一般有三种策略

  • 1、不缓存(nowrite)就是说高速缓存不去缓存任何写操作,读的时候再从磁盘读取;
  • 2、写透缓存(write-through cache)将自动更新内存缓存,同时也更新磁盘文件;
  • 3、回写(Linux采用的)直接执行写操作到缓存中,后端存储(磁盘)不会立即更新,而是将该缓存页标记为“脏”,并加入脏页链表中,然后通过一个回写进程周期地将脏页链表中的页写回到磁盘。

缓存回收(缓存清除)策略:通过选择干净页或对最不可能使用的页面进行替换,理想的页面回收策略称为预测算法。

  • 1.最近最少使用(LRU)。根据每个页面的访问踪迹(访问时间为序的页链表),来回收最老时间戳的页面(回收排序链表头所指的页面)
  • 2.Linux实现的是双链策略。在LRU的基础上,维护两个链表:活跃链表和非活跃链表,前者中的页面不可被换出而后者可以。页面从尾部进入,从头部移除,两者需要维持平衡。这种算法被称为LRU/2,更普遍的是n个链表,称为LRU/n。
  • 页高速缓存中的相关对象
  • address_space结构体:用来管理缓存项和页IO操作。在这里插入图片描述
  • 其中i_mmap字段是一个优先搜索树,搜索范围包含了在address_space中所有共享和私有的映射页面。
  • 缓冲区高速缓存。
  • address_space操作。与VFS对象及其操作表关系类似,a_ops域指向地址空间中的操作函数表,由address_space_operations结构体表示。这些方法指针指向那些为指定缓存对象实现的页IO操作。
    在这里插入图片描述
  • 基树(radix tree)。用于高效检索二叉树结构,只要制定了文件偏移量就可以在基树中快速检索到希望的页,该结构体保存在page_tree结构体中。
  • 此外,独立的磁盘块通过IO缓冲也要被存入页高速缓存。
  • flusher线程。一群内核线程,用来执行数据更新时的脏页写回。

15. 设备与模块

  • Linux中,所有设备被分为以下三种类型:
  • 块设备(blkdev),可寻址;
  • 字符设备(cdev),不可寻址,仅能通过数据流的方式访问。
  • 网络设备(ethernet devices),不是文件->设备节点的访问方式,通过套接字API接口访问。
  • Linux提供一些其他的非通用设备如杂项设备(miscellaneous device),实质上是一个简化的字符设备。此外,还有一些用于给内核提供功能的虚拟设备,如内核随机数发生器(/dev/random),空设备(/dev/null),零设备(/dev/zero),满设备(/dev/full),内存设备(/dev/mem)等。
  • 内核可装载模块(module):构建模块、安装模块、产生模块依赖型、载入模块(insmod .ko文件)、管理配置选项、模块参数、导出符号表。
  • 统一设备模型(设备树设备总线驱动模型)
  • kobject,类似于面向对象语言中的对象类,用来描述设备模型的数据结构的基类。提供了诸如引用计数、名称和父指针等。
    在这里插入图片描述
  • kobject不单独使用,通常嵌入在其他结构体中。比如定义于Linux/cdev.h中的cdev结构体才真正用到kobj结构。
    在这里插入图片描述
  • ktype,用来表示一族kobject所具有的普遍特性。如下图所示,其中release指针指向在kobject引用计数减至0时要被调用的析构函数;sysfs_ops变量指向sysfs_ops结构体。
    在这里插入图片描述
  • kset。是kobject对象的集合体。不同点在于:具有相同ktype的kobject可以被分组到不同的kset- sysfs。为我们提供了kobject对象层次结构的视图,是一个处于内存中的虚拟文件系统,帮助用户能以一个简单文件系统的方式来观察系统中各种设备的拓扑结构。

16. 调试

利用printk、查看log、OOP消息、strace、内核内置的hacking选项、ioctl方法、/proc文件系统、kgdb等

17. 个人补充

需要加强理解的:inode、Linux链表模式、禁止内核抢占、如何按顺序加锁、进程栈

  • Linux中的链表
    在这里插入图片描述

传统链表中,无法对每个node中的数据类型和个数进行统一管理;
在这里插入图片描述

通过将链表节点放入数据中,链表的节点将独立于用户数据之外,便于实现链表的共同操作。

  • Linux中的四种栈:
    进程栈:每个进程3G用户空间中的栈区,用于存放局部变量、函数参数等。
    线程栈:一个进程中的多个线程在Linux看来就是共享内存地址的一些进程。其中主线程是在fork时(写时)复制的,并可以动态增长;而子线程通过mmap即确定大小。
    内核栈:进程陷入内核后的代码所使用的栈,通过slab分配器从缓存池中分配。
    中断栈:与内核栈类似。在X86架构上两者不共享;ARM架构则共享(当中断嵌套时会发生栈溢出)。
  • 1
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值