Linux内核设计与实现 Robert Love

第一章 Linux内核简介

  • 通常一个内核由负责响应中断的中断服务程序,负责管理多个进程从而分享处理器时间的调度程序,负责管理进程地址空间的内存管理程序和网络、进程间通信等系统服务程序共同组成。

  • 每个处理器在任何指定时间点上的活动必然概况为下列三者之一:

    • 运行于用户空间,执行用户进程
    • 运行于内核空间,处于进程上下文,代表某个特定的进程执行
    • 运行于内核空间,处于中断上下文,与任何进程无关,处理某个特定中断。
    • 例如,当CPU空闲时,内核就运行一个空进程,处于进程上下文,但是运行在内核空间。
  • 单内核:把它从整体上作为一个单独的大进程来实现,同时也运行在一个单独的地址空间上。这样的内核以单个静态二进制文件的形式存放于磁盘上。所有的内核服务都在这样一个大内核地址空间上运行。内核之间的通信是微不足道的,因为大家都运行在内核态,统一地址空间上:内核可以直接调用函数。单内核具有简单和性能高的特点。

  • 微内核:功能被划分为多个独立的过程,每个过程叫做一个服务器。理想情况下,只有强烈请求特权服务的服务器才运行在特权模式下,其他服务器都运行在用户空间。不过,所有的服务器都保持独立并运行在各自的地址空间上。因此,就不可能像单模块内核那样直接调用函数,而是通过消息传递处理微内核通信:系统采用进程间通信IPC机制,因此,各个服务器之间通过IPC机制互通消息,互换“服务”。服务器的各自独立有效地避免了一个服务器的失效祸及另一个。同样,模块化的系统运行一个服务器为了另一个服务器而换出。

  • Windows NT内核和Mach(Mac OS X的组成部分)是微内核的典型实例。

  • Linux是一个单内核,也就是说,Linux内核运行在单独的内核地址空间上。Linux也汲取了微内核的精华:其引以为豪的是模块化设计、抢占式内核、支持内核线程以及动态装载内核模块的能力。

第二章 从内核出发

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

第三章 进程管理

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

  • 内核调度的对象是线程,而不是进程。

  • 内核把进程的列表存放在叫做任务队列(task list)的双向循环链表中。链表中的每一项都是类型为task_struct,称为进程描述符。

  • 进程描述符包含的数据能完整地描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号。进程的状态,还有其他更多的信息。

  • Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色的目的。通过预先分配和重复使用task_struct,可以避免动态分配和释放所带来的的资源消耗。在2.6以前的内核中,各个进程的task_struct存放在它们内核栈的尾端(栈最低的内存地址)。

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

  • 进程状态:

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

  • 进程创建

    • 写时拷贝(copy-on-write):内核并不复制整个地址空间,而是让父进程和子进程共享同一拷贝。
    • fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。
    • vfork():除了不拷贝父进程的页表项外,vfork()系统调用和fork()的功能相同。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,知道子进程退出或执行exec()。子进程不能向地址空间写入。
    clone(SIGCHLD,0)  // fork 实现
    clone(CLONE_VFORK | CLONE_VM | SIGCHLD,0) // vfork实现
    
  • 线程创建:线程创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源

clone(CLONE_VM |CLONE_FS |CLONE_FILES |CLONE_SIGHAND,0)
  • 内核线程:独立运行在内核空间的标准进程。内核线程没有独立的地址空间(指向地址空间的mm为NULL)。它只在内核空间运行,可以被调度,也可以被抢占。内核线程只能由其他内核线程创建。
  • 进程终结:通过调用exit()系统调用(C语言编译器会在main()函数的返回点后面放置调用exit()的代码)。
    • 进程终结大部分依靠do_exit()来完成。
    • 执行完do_exit()之后,进程不可运行(实际上也没有地址空间让它运行)并处于EXIT_ZOMBIE退出状态,它占用的所有内存就是内核栈、thread_info结构和task_struct结构。此时进程存在的唯一目的就是向它的父进程提供信息。父进程检索到信息后,或者通知内核那是无关的信息后,由进程所持有的剩余内存被释放掉,归还给系统使用。
    • 进程终结时所需的清理工作和进程描述符的删除分开执行。这样做可以让系统有办法在子进程终结之后仍能获取它的信息。
    • wait()一族函数通过唯一的一个系统调用wait4()来实现的。它的标准动作是挂起调用它的进程,直到一个子进程退出,此时函数会返回该子进程的PID。此时,调用该函数时提供的指针会包含子函数退出时的退出代码。当最终需要释放进程描述符时,release_task()会被调用。
  • 孤儿进程:如果父进程在进程之前退出,需要为子进程找到一个父进程。解决办法是给子进程在当前线程组里找到一个线程作为父亲,如果不行,就让init做它们的父进程。

第四章 进程调度

  • 进程调度程序可看做在运行态进程之间分配有限的处理器时间资源的内核子系统。
  • CFS:完全公平调度算法是一个针对普通进程的调度器类,在Linux中称为SCHED_NORMAL(POSIX中称为SCHED_OTHER)
  • 进程优先级
    • nice值:[-20,19],默认值0,越大的nice值意味着更低的优先级——nice意味着你对系统其他进程更“优待”。在Linux中,nice值代表时间片的比例。
    • 实时优先级:[0,99],越高的实时优先级意味着进程优先级越高。任何实时进程的优先级都高于普通进程
    ps -eo state,uid,pid,ppid,rtprio,time,comm
    # rtprio对应实时优先级,其中“-”说明它不是实时进程
    
  • 时间片:表示进程在被抢占前所能持续运行的时间。时间片太短会增大进程切换带来的处理器耗时,太长会导致系统对交互的响应表现欠佳。
    • Linux的CFS调度器并没有直接分配时间片到进程,它是将处理器的使用比划分给了进程。进程所获得的处理器时间其实是和系统负载密切相关的。这一比例进一步还会受到nice值的影响,nice作为权重将调整进程所使用的处理器时间比。nice值高,低权重,丧失一部分处理器时间比,nice低,高权重,抢得更多的处理器使用比。
    • 在Linux中使用CFS调度器,其抢占时机取决于新的可运行程序消耗了多少处理器使用比。如果消耗的使用比比当前进程小,则新进程立即投入运行,抢占当前进程。否则推迟运行。
  • 任何进程所获得的的处理器时间是由它自己和其他所有可运行进程nice值的相对差值决定的。nice值对时间片的作用不再是算术加权,而是几何加权。任何nice值对应的绝对时间不再是一个绝对值,而是处理器的使用比。CFS称为公平调度器是因为它确保给每个进程公平的处理器使用比。
  • 调度器实现
    • 时间记账:CFS不再有时间片的概念,但是它也必须维护每个进程运行的时间记账,因为它需要确保每个进程只有公平分配给它的处理器时间内运行。vruntime变量存放进程的虚拟运行时间,该运行时间(花在运行上的时间和)的计算是经过了所有可运行进程总数的标准化。
    • 进程选择:当CFS需要选择下一个运行进程时,它会挑一个具有最小 vruntime 的进程。
    • 调度器入口:schedule(),寻找一个最高优先级的调度类。
    • 睡眠和唤醒
      • 休眠通过等待队列进行处理
      • 唤醒操作通过wake_up()进行,它会唤醒指定等待队列上的所有进程
  • 上下文切换:context_switch()负责,每当一个新的进程被选出来准备投入运行的时候,schedule()就会调用该函数。它完成两项基本工作
    • 调用switch_mm(),负责把虚拟内存从上一个进程切换到新进程中
    • 调用switch_to,负责从上一个进程的处理器状态切换到新进程的处理器状态。这包括保存、恢复栈信息和寄存器信息
  • 内核调用schedule()的时机:内核提供了一个need_resched标志来表明需要重新执行一次调度。
    • 当进程被抢占时,scheduler_tick()会设置这个标志
    • 当一个优先级高的进程进入可执行状态的时候,try_to_wake_up()也会设置这个标志,内核检查该标志确认其被设置,调用schedule()切换到一个新进程。
    • 在返回用户空间以及从中断返回的时候,内核也会检查need_resched标志
  • 用户抢占:内核在返回用户空间的时候,如果need_resched标志被设置,会导致schedule()被调用,此时就会发生用户抢占。
    • 从系统调用返回用户空间时
    • 从中断处理程序返回用户空间时
  • 内核抢占:只要重新调度是安全的,内核就可以在任何时间抢占正在执行的任务。进程的thread_info引入preempt_count计数器,使用锁数值加1,释放锁数值减1。当设置了need_resched和preempt_count为0,调度程序会被调用
    • 中断处理程序正在执行,且返回内核空间之前
    • 内核代码再一次具有可抢占性的时候
    • 如果内核中的任务显式地调用schedule()
    • 如果内核中的任务阻塞(这同样也会导致调用schedule())
  • 实时调度策略:SCHED_FIFO和SCHED_RR
    • SCHED_FIFO实现了一种简单的、先入先出的调度算法:它不使用时间片。处于可运行状态的SCHED_FIFO级的进程会比任何SCHED_NORMAL级的进程先得到调度。一旦一个SCHED_FIFO级进程处于可执行状态,就会一直执行,知道它自己被阻塞或显式地释放处理器为止。只有更高优先级的SCHED_FIFO和SCHED_RR任务才能抢占SCHED_FIFO任务。
    • SCHED_RR与SCHED_FIFO大致相同,只是SCHED_RR级的进程在耗尽事先分配给它的时间后就不能再执行了。
  • 放弃处理器时间:Linux通过sched_yield()系统调用,提供一种让进程显式地将处理器时间让给其他等待执行进程的机制。它是通过将进程从活动队列(因为进程正在执行,所以它肯定位于此队列)移到过期队列中实现的。由此产生的效果不仅抢占了该进程并将其放入优先级队列的最后面,还将其放入过期队列中——这样能确保在一段时间内它都不会再被执行了。

第五章 系统调用

  • 在Linux中,系统调用是用户空间访问内核的唯一手段;除异常和陷入外,它们是内核唯一的合法入口。
  • 系统调用通过一个系统赋予的系统调用号来关联。系统调用号一旦分配就不能做任何变更,否则编译好的应用程序就会崩溃。
  • 应用程序通过软中断的方式通知内核,需要执行一个系统调用,切换到内核态,这样内核就可以代表应用程序在内核空间执行系统调用。
  • 通知内核的软中断机制:通过引发一个异常促使系统切换到内核态去执行异常处理程序。此时的异常处理程序就是系统调用处理程序。在x86系统上预定义的软中断是中断号128,通过int $0x80 指令触发该中断。这条指令会触发一个异常导致系统切换到内核态并执行第128号异常处理程序,而该程序正是系统调用处理程序system_call()。x86还提供了一条sysenter的指令,这条指令提供了更快、更专业的陷入内核执行系统调用的方式。
  • 系统调用号通过eax寄存器传递给内核。参数传递采用寄存器ebx ecx edx esi edi五个寄存器,如果参数大于等于6个,此时用一个单独的寄存器存放指向这些参数在用户空间地址的指针。
  • 内核在执行系统调用的时候处于进程上下文。
    • current指针指向当前任务,即引发系统调用的那个进程。
    • 在进程上下文中,内核可以休眠(如系统调用阻塞或显示调用schedule())并且可以抢占。能够休眠说明系统调用可以使用绝大多数内核提供的功能。能抢占表明,像用户空间内的进程一样,当前的进程同样可以被其他进程抢占。因为新的进程可以使用相同的系统调用,所以必须保证系统调用是可重入的。
    • 当系统调用返回的时候,控制权任然在system_call()中,它最终会负责切换到用户空间,并让用户进程继续执行下去。

第六章 内核数据结构

  • 链表
  • 队列
  • 映射
  • 二叉树:红黑树

第七章 中断和中断处理

  • 中断(由硬件产生的异步中断):从物理学的角度来看,中断是一种电信号,由硬件设备生成,并直接送入中断处理器的输入引脚中——中断控制器是一个简单的电子芯片,由硬件设备组成,其作用是将多路的中断管线,采用复用技术只通过一个和处理器连接的管线与处理器通信。当接收到一个中断后,中断控制器会给处理器发送一个电子信号。处理器一检测到此信号,便中断自己当前工作转而处理中断。此后,处理器会通知操作系统已经产生中断,这样,操作系统就可以对这个中断进行适当的处理了。

  • 不同的设备对应的中断不同,而每个中断都通过一个唯一的数字标志。这些中断值通常被称为中断请求(IRQ)线。每个IRQ线都会被关联一个数量值——例如,在经典的PC机上,IRQ 0 是时钟中断,而IRQ 1 是键盘中断。中断可以是动态分配的,重点是特定的中断总是与特定的设备关联,内核知道这些信息。

  • 异常:异常和中断不同,它产生时必须考虑与处理器时钟同步。实际上,异常也常常称为同步中断。在处理器执行到由于编程失误而导致的错误指令(如被0除)的时候,或者在执行期间出现特殊情况(如缺页),必须靠内核来处理的时候,处理器就会产生一个异常。

  • 中断处理程序:在响应一个特定中断时,内核执行的函数,也叫做中断服务例程(ISR)。

    • 产生中断的每个设备都有一个相应的中断处理程序,是它设备驱动程序(driver)的一部分——设备驱动程序是用于对设备进行管理的内核代码。
    • 中断处理程序运行于中断上下文中,在该上下文中执行代码不可阻塞。
  • 中断处理一般切为两部分。中断处理程序是上半部(top half)——接收到一个中断,它就立即开始执行,但只做有严格时限的工作,例如对接收的中断进行应答或复位硬件,这些工作都是在所有中断被禁止的情况下完成的。能够被允许稍后完成的工作会推迟到下半部(bottom half)去。此后,在合适的时机,下半部会被开中断执行。

  • 中断注册方法

    • request_irq():在给定的中断线上注册一给定的中断处理程序
    • free_irq():如果在给定的中断线上没有中断】处理程序,则注销响应的处理程序,并禁用其中中断线。
  • Linux中的中断处理程序是无需重入的。当一个给定的中断处理程序正在执行时,相应的中断线在所有处理器上都会被屏蔽掉,以防止在同一中断线上接收另一个新的中断。通常情况下,所有其他中断都是打开的,所以这些不同中断线上的其他中断都能被处理,但当前中断线总是被禁止的。由此可以看出,同一个中断处理程序绝对不会被同时调用以处理嵌套的中断。

  • 以网卡中断为例,在没有设置SMP IRQ affinity时, 所有网卡中断都关联到CPU0, 这导致了CPU0负载过高,而无法有效快速的处理网络数据包,导致了瓶颈。 通过SMP IRQ affinity, 把网卡多个中断分配到多个CPU上,可以分散CPU压力,提高数据处理速度。在支持网卡多队列的网卡上,不同队列关联不同中断,不同的中断绑定到不同的CPU提升性能。

  • 共享的中断处理程序:内核接收到一个中断后,它将依次调用在该中断线上注册的每一个处理程序。因此,一个处理程序必须知道它是否应该为这个中断负责。如果与它相关的设备并没有产生中断,那么处理程序应该立即退出。这需要硬件设备提供状态寄存器,以便中断处理程序进行检查。

  • 新的linux内核不再支持中断嵌套,中断发生后,一般硬件会自动屏蔽CPU对中断的响应,而软件层面上,直到IRQ HANDLER做完,才会重新开启中断。

  • 中断控制:控制中断系统的原因归根到底是需要提供同步,通过禁止中断,可以确保某个中断处理程序不会抢占当前代码。此外禁止中断还可以禁止内核抢占。

    • 禁止当前处理器上的本地中断:在发出中断的处理器上,它们将禁止和激活中断的传递
    • 禁止某条中断线:禁止给定中断向系统中所有处理器的传递

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

  • 软中断:是一组静态定义的下半部接口,有32个,可以在所有处理器上同时执行——即使两个类型相同也可以。
  • tasket:是基于软中断实现的。两个不同类型的tasklet可以在不同的处理器上同时执行,但类型相同的tasklet不能同时执行。
  • 内核定时器把操作推迟到某个确定的时间段之后执行,但是软中断和tasklet等下半部机制不能指定在确定时间后执行。
  • 一个软中断不会抢占另外一个软中断。实际上,唯一可以抢占软中断的是中断处理程序。
  • 一个注册的软中断必须在被标记后才会执行,这被称为触发软中断(raising the softirq)。通常,中断处理程序会在返回前标记它的软中断,使其在稍后被执行。待处理的软中断在下列地方会被检查和执行:
    • 从一个硬件中断代码处返回时
    • 在ksoftirqd内核线程中
    • 在那些显示检查和执行待处理的软中断的代码中,如网络子系统中
  • 软中断在do_softirq()中执行
  • 软中断处理程序执行的时候,运行响应中断,但它自己不能休眠(不能使用信号量或者其他什么阻塞式函数),在一个处理程序运行的时候,当前处理器上的软中断被禁止。但其他处理器仍可以执行别的软中断
  • 触发软中断:raise_softirq()函数可以将一个软中断设置为挂起状态,让它在下次调用do_softirq()函数时投入运行。举个栗子,网络子系统可能会调用:raise_softirq(NET_TX_SOFTIRQ);这会触发NET_TX_SOFTIRQ软中断。它的处理程序net_tx_action()就会在内核下一次执行软中断时投入使用。干函数在触发一个软中断之前先要禁止中断,触发后再恢复原来的状态。
  • 注册软中断:open_softirq()
  • tasklet有两类软中断代表:HI_SOFTIRQ和TASKLET_SOFTIRQ。HI_SOFTIRQ优先级更高,其他一样。
  • tasklet由tasklet_schedule()和tasklet_hi_schedule()函数进行调度,它接受一个tasklet_struct结构的指针作为参数。tasklet_action()和tasklet_in_action()就是tasklet处理的核心。
  • ksoftirqd:每个处理器都有一组辅助处理软中断(和tasklet)的内核线程。
  • 工作队列(work queue)是另外一种将工作推后执行的形式,交给一个内核线程去执行——这个下半部分总是会在进程中执行的。这样工作队列执行的代码能占尽进程上下文的所有优势。最重要的是工作队列允许重新调度甚至是睡眠。

第十章 内核同步方法

  • 原子操作:保证指令以原子的方式执行——执行过程不被打断
  • 自旋锁:CPU忙等待,适用于执行很快的任务
  • 信号量:睡眠锁,如果有一个任务试图获取一个不可用(已经被占用)的信号量时,信号量会将其推进一个等待队列,然后让其睡眠。当持有的信号量可用(被释放)后,处于等待队列中的那个任务被唤醒,并获得该信号量。
  • mutex:
  • 完成变量(completion variable):如果在内核中一个任务需要发出信号通知另一个任务发生了某个特定事件,利用完成变量是使两个任务得以同步的简单方法。
  • 顺序锁(seq):用于读写共享数据。实现依靠一个序列计数器。当有疑义的数据被写入时,会得到一个锁,并且序号值会增加。在读取数据之前和之后,序号值都会被读取。如果读取的序号值相同,说明在读操作进行的过程中没有被写操作打断过。此外,如果读取的值是偶数,那么表明写操作没有发生(要明白因为锁的初值是0,所以写锁会使值成奇数,释放的时候变成偶数)。
  • 屏障barrier:指令顺序执行。

第十一章 定时器和时间管理

  • 内核必须在硬件的帮助下才能计算和管理时间。硬件为内核提供了一个系统定时器用以计算流逝的时间,该时钟在内核中可看成是一个电子时间资源,比如数字时钟或处理器频率等。
  • 节拍率:HZ
  • 高HZ的优势:
    • 内核定时器能够以更高的频率和更高的准确度运行
    • 依赖定时器执行的系统调用,比如poll()和select(),能够以更高的精度运行。
    • 对诸如资源消耗和系统运行时间等的测量会有更精细的解析度
    • 提高进程抢占的准确度
  • 高HZ的劣势:时钟中断频率越高,系统负担越重。因为处理器必须花时间来执行时钟中断处理程序,所以节拍率越高,中断处理程序占用的处理器时间越多。这样不但减少了处理器处理其他工作的时间,而且会更频繁地打乱处理器高速缓存并增加耗电。
  • jiffies:记录自系统启动以来产生的节拍的总数。启动时,内核将该变量初始化为0,此后,每次时钟中断处理程序就会增加该变量的值。
  • 实时时钟RTC是用来持久存放系统时间的设备,即便系统关闭后,它也可以靠主板上的微型电池提供的电力保持系统的计时。当系统启动时,内核通过读取RTC来初始化墙上时间,该时间存放在xtime变量中,这是实时时钟的最主要作用。
  • 系统定时器:提供一种周期性触发中断机制
  • 时钟中断处理程序:可划分为两部分
    • 体系结构相关部分,作为系统定时器的中断处理程序而注册到内核中,以便在产生时钟中断时,它能够相应地运行,主要完成以下工作。
      • 获得xtime_lock锁,以便对访问jiffies_64和墙上时间xtime进行保护
      • 需要时应答或重新设置系统时钟
      • 周期性地使用墙上时间更新实时时钟
      • 调用体系结构无关的时钟例程:tick_periodic()
    • 体系无关例程tick_periodic()主要工作:
      • 给jiffies_64变量增加1
      • 更新资源消耗的统计值,比如当前进程所消耗的系统时间和用户时间
      • 执行已经到期的动态定时器
      • 执行scheduler_tick()函数:负责减少当前运行进程的时间片计数值并且在需要时设置need_resched标志,在SMP机器中,还要负责平衡每个处理器上的运行队列。
      • 更新墙上时间,该时间存放在xtime变量中
      • 计算平均负载值
  • 实际时间(墙上时间):xtime,存放自1970年1月1日(UTC)以来经过的时间,秒和纳秒
  • 内核在时钟中断发生后执行定时器,定时器作为软中断在下半部上下文中执行。
  • 一般来说,定时器在超时后马上就会执行,但是也可能推迟到下一次时钟节拍时才能执行,所以不能用定时器来实现任何硬实时任务。
  • 因为定时器与当前执行代码是异步的,因此就有可能存在潜在的竞争条件。
  • 延迟执行:
    • 忙等待
    • 短延迟:udelay()函数依靠执行数次循环达到延迟效果,而mdelay()又是通过udelay()函数实现的。因为内核知道处理器在一秒内能执行多少次循环,所以udelay()函数仅仅需要根据指定延迟时间在1秒中占的比例,就能决定需要进行多少次循环即可达到要求的推迟时间。
    • schedule_timeout():该方法会让需要延迟执行的任务睡眠到指定的延迟时间耗尽后再重新执行

第十二章 内存管理

  • 内核使用struct page 结构表示系统的每个物理页。
  • 页分配:以页为单位分配内存
    • alloc_pages 分配连续的内存页,大小为(1<<order),即2的整数倍,返回第一个页page的指针
    • alloc_page 分配一页
    • __get_free_pages:分配连续的页,和alloc_pages作用相同,只是返回的是第一个页的逻辑地址
    • free_pages free_page 释放页,以逻辑地址为参数
    • __free_pages 释放页,以page为参数
  • kmalloc():用来获取以字节为单位的一块内核连续内存。kfree 释放内存
  • vmalloc():分配连续的内存虚拟地址是连续的,而物理地址则无需连续。vfree
  • slab分配器扮演了通用数据结构缓存层的角色。slab层把不同的对象划分为所谓的高速缓存组,其中每个高速缓存组都存放不同类型的对象。每种对象类型对应一个高速缓存。如,一个高速缓存用于存放进程描述符,而另一个高速缓存存放索引节点对象。
  • kmalloc()接口建立在slab层之上,使用了一组通用高速缓存。
  • 高速缓存被划分为slab。slab由一个或多个物理上连续的页组成,每个高速缓存可以有多个slab组成。每个slab都包含一些对象成员(被缓存的数据结构)。
  • slab分配器接口
    • 高速缓存创建:kmem_cache_create
    • 撤销高速缓存:kmem_cache_destory
    • 获取对象:kmem_cache_alloc
    • 释放对象:kmem_cache_free
  • 内核栈:固定的一页或者两页的栈。
  • 中断栈:为每个进程提供一个用于中断处理程序的栈,有了这个,中断处理程序不用再和被中断进程共享一个内核栈,它们可以使用自己的栈了。

第十三章 虚拟文件系统

  • 虚拟文件系统VFS,作为内核子系统,为用户空间程序提供了文件和文件系统相关的接口。
  • 从本质上讲文件系统是特殊的数据分层存储结构,它包含文件、目录和相关的控制信息。
  • 在Unix中,目录属于普通文件,它列出包含在其中的所有文件。由于VFS把目录当做文件对待,所以可以对目录执行和文件相同的操作。
  • Unix系统把文件的相关信息和文件本身加以区分。文件的相关信息,有时也称为文件的元数据,被存储在一个单独的数据结构中,该结构被称为索引节点inode。
  • 文件系统的控制信息存储在超级块中,超级块是一种包含了文件系统信息的数据结构。
  • VFS中有四个主要的对象类型,它们分别是:
    • 超级块对象,它代表一个具体的已安装文件系统
      • 各种文件系统都必须实现超级块对象,该对象用于存储特定文件系统的信息,通常对于存在磁盘特定扇区中的文件系统超级块或文件系统控制块。对于并非基于磁盘的文件系统(如基于内存的sysfs),它们会在使用现场创建超级块并将其保存在内存中。
    • 索引节点对象,它代表一个具体文件
      • 包含了内核在操作文件或目录时需要的全部信息。
      • 对于Unix风格的文件系统来说,这些信息可以从磁盘索引节点直接读入。
      • 索引节点对象由inode 结构体表示。
      • 一个索引节点代表文件系统中(但是索引节点仅当文件被访问时,才在内存中创建)的一个文件,它也可以是设备或管道这样的特殊文件
    • 目录项对象,它代表一个目录项,是路径的一个组成部分。dentry
      • 在路径中(包括普通文件在内),每一个部分都是目录项对象。
      • 目录项对象有三种状态
        • 被使用:一个被使用的目录项对应一个有效的索引节点并且表明该对象存在一个或多个使用者。不能被丢弃
        • 未被使用:一个未被使用的目录项对应一个有效的索引节点,但是应指明VFS当前并未使用它。该目录项对象仍然指向一个有效对象,而且被保留在缓存中以便需要时再使用它。如果需要回收内存,可以撤销未使用的目录项
        • 负状态:一个负状态的目录项没有对应的有效索引节点,因为索引节点已被删除了,或者路径不再正确了,但是目录项仍然要保留,以便快速解析以后的路径查询。可以撤销
      • 目录项对象释放后也可以保存在slab对象缓存中去,此时,任何VFS或文件系统代码都没有指向该目录项对象的有效引用。
      • 目录项缓存:内核将目录项对象缓存在目录项高速缓存dcache中,由下列三个主要部分组成:
        • "被使用的"目录项链表
        • "最近被使用的"双向链表:含有未被使用的和负状态的目录项对象。由于该链表总是在头部插入目录项,所以链头节点的数据总比链尾的数据要新
        • 散列表和相应的散列函数用来快速地将给定路径解析为相关目录项对象。
    • 文件对象,它代表由进程打开的文件
      • 文件对象是已打开的文件在内存中的表示。该对象(不是物理文件)由相应的open()系统调用创建,由close()系统调用撤销,所有这些文件相关的调用实际上都是文件操作表中定义的方法。
      • 由于多个进程可以打开和操作同一个文件,所以同一个文件也可能存在多个对应的文件对象
      • 类似于目录项对象,文件对象实际上没有对应的磁盘数据
    • 注意,因为VFS将目录作为一个文件来处理,所以不存在目录对象。
  • 用来描述各种特定文件系统类型:file_system_type
  • 用来描述一个安装文件系统的实例:vfsmount
  • 进程相关的数据结构:
    • file_struct:由进程描述符中files目录项指向。所有单个进程相关信息(如打开的文件即文件描述符)都包含在其中
    • fs_struct:该结构由进程描述符的fs域指向,它包含文件系统和进程相关的信息
    • namespace结构体:由进程描述符的mm_namespace指向

第十四章 块I/O层

  • 扇区——设备最小寻址单元,别名"硬扇区"或"设备块"
  • 块——文件系统最小寻址单元,有时称为"文件块"或"I/O块"。
  • 块包含一个或多个扇区,但大小不能超过一个页面,所以一个页可以容纳一个或多个内存中的块。
  • 当一个块被调入内存(在读入后或等待写出时),它要存储在一个缓冲区中。每个缓冲区与一个块对应,它相当于磁盘块在内存中的表示。
  • 缓冲区头包含一个缓冲区的控制信息(如块属于哪一个块设备,块对应哪个缓冲区等)。缓冲区头的目的在于描述磁盘块和物理内存缓冲区(在特定页面的字节序列)之间的映射关系。
  • bio结构体:代表了正在现场(活动的)以片段(segment)链表形式组织的块I/O操作。一个片段是一小块连续的内存缓冲区,不需要保证单个缓冲区连续。通过以片段来描述缓冲区,即使一个缓冲区分散在内存的多个位置上,bio结构体也能对内核保证I/O操作的执行。像这样的向量I/O就是所谓的聚散I/O。
  • 每一个块I/O请求都通过一个bio结构体表示。每个请求包含一个或多个块,这些块存储在bio_vec结构体数组中。这些结构体描述了每个片段在物理页中的实际位置,并且像向量一样被组织在一起。I/O操作的第一个片段由b_io_vec结构体所指向,其他的片段在后依次放置,共有bi_vcnt个片段。当块I/O开始执行请求、需要各个片段时,bx_idx域会不断更新,从而总指向当前片段。
  • 请求队列:块设备将它们挂起的块I/O请求保存在请求队列中。
  • I/O调度程序:在内核中负责提交I/O请求的子系统。
  • I/O调度程序通过两种方法减少磁盘寻址时间:
    • 合并:将两个或多个请求结合成一个新请求。请求合并之后只需要传递一条寻址命令,就可以访问到请求合并前必须多次寻址才能访问完的磁盘区域了,因此合并请求能减少系统开销和磁盘寻址次数
    • 排序:通过将请求按磁盘上扇区的排列顺序有序排列的目的不仅为了缩短单独一次请求的寻址时间,更重要的优化在于,通过保持磁盘头以直线方向移动,缩短了所有请求的磁盘寻址时间。
  • 最终期限(deadline)I/O调度程序:每个请求都有一个超时时间。默认读请求超时为500ms,写请求为5s。
    • 除了排序队列,读和写分别对应两个按请求提交次序的读FIFO队列和写FIFO队列中。
    • 对应普通操作来说,最后期限I/O调度程序将请求从排序队列头部取下,再推入到派发队列中,派发队列然后将请求提交给磁盘驱动,从而保证了最小化请求寻址。
    • 如果在写FIFO队列头,或在读FIFO队列头的请求超时,那么最后期限I/O调度便从FIFO队列提取请求进行服务。依靠这种方法,最后期限I/O调度程序试图保证不会发生有请求在明显超期的情况下认不能得到服务。
  • 预测I/O调度程序(as):和最终期限调度类似,但是添加了预测启发能力。Linux默认调度程序
    • 两个最后期限队列和两个排序队列;I/O调度程序在读和写请求之间交互扫描排序队列,不过更倾向于读请求。扫描基本上是连续的,除非有某个请求超时。读请求的缺省超时时间是125ms,写请求的缺省超时时间是250ms。但是,该算法还遵循一些附加的启发式准则:
    • 有些情况下,算法可能在排序队列当前位置之后选择一个请求,从而强制磁头从后搜索。这种情况通常发生在这个请求之后的搜索距离小于在排序队列当前位置之后对该请求搜索距离的一半时。
    • 算法统计系统中每个进程触发的I/O操作的种类。当刚刚调度了由某个进程p发出的一个读请求之后,算法马上检查排序队列中的下一个请求是否来自同一个进程p。如果是,立即调度下一个请求。否则,查看关于该进程p的统计信息:如果确定进程p可能很快发出另一个读请求,那么就延迟一小段时间(缺省大约为7ms)。因此,算法预测进程p发出的读请求与刚被调度的请求在磁盘上可能是“近邻”。
  • 完全公正的排队I/O调度程序CFQ
    • 把I/O请求放入特定的队列中,这种队列是根据引起I/O请求的进程组织的
    • 以时间片轮转调度队列,从每个队列中选取请求数,然后进行下一轮调度。这就在进程级提供了公平
  • 空操作的I/O调度程序(Noop):不进行排序,当一个新的请求提交到队列时,就把它与任一相邻的请求合并

第十五章 进程地址空间

  • 内核除了管理本身的内存外,还必须管理用户空间中进程的内存。我们称这个内存为进程地址空间,也就是系统中每个用户空间进程所看到的的内存。
  • 可被进程访问合法虚拟地址空间称为内存区域(memory areas)。通过内核,进程可以给自己的地址空间动态地添加或减少内存区域。
  • 内存区域包含各种内存对象
    • 代码段(text section):可执行文件代码的内存映射
    • 数据段(data section):可执行文件的已初始化全局变量的内存映射
    • bss段:包含未初始化全局变量,也就是bss段的零页的内存映射
    • 用于进程用户空间栈的零页的内存映射
    • 每一个诸如C库或动态连接程序等共享库的代码段、数据段和bss也会被载入进程的地址空间
    • 任何内存映射文件
    • 任何共享内存段
    • 任何匿名的内存页,如malloc()分配的内存
  • 内核使用内存描述符结构体表示进程的地址空间,该结构包含了和进程地址空间有关的全部信息。使用mm_struct结构体表示
  • 在进程的进程描述符中,mm域存放着该进程使用的内存描述符,所以current->mm便指向当前进程的内存描述符。
  • 内核线程没有进程地址空间,也没有相关的内存描述符。当一个进程被调度时,该进程的mm域指向的地址空间被装载到内存,进程描述符中的active_mm域会被更新,指向新的地址空间。内核线程没有地址空间,所以mm域为NULL。于是,当一个内核线程被调度时,内核发现它的mm域为NULL,就会保留前一个进程的地址空间,随后内核更新内核线程对应的进程描述符中的active_mm域,使其指向前一个进程的内存描述符。所以在需要时,内核线程便可以使用前一个进程的页表。因为内核线程不访问用户空间的内存,所以它们仅仅使用地址空间中和内核内存相关的信息,这些信息的含义和普通进程完全相同。
  • 虚拟内存区域VMA:描述了指定地址空间内连续区间的一个独立内存范围。内核将每个内存区域作为一个单独的内存对象管理,每个内存区域都拥有一致的属性,比如访问权限等,另外,相应的操作也一致。
  • VMA中的vm_mm域指向相关的唯一mm_struct结构体,所以即使两个独立进程将同一个文件映射到各自的地址空间,它们分别都会有一个vm_area_struct结构体标志自己的内存区域;反过来,如果两个线程共享一个地址空间,那么它们页同时共享其中的所有vm_area_struct结构体。
  • 可以通过内存描述符中的mmap和mm_rb域之一访问内存区域。这两个域各自独立地指向与内存描述符相关的全体内存区域对象。mmap域使用单链表连接所有的内存区域对象,mm_rb域使用红黑树。
  • 可以使用pmap [pid]或者cat /proc/[pid]/maps 来显示该进程地址空间中的全部内存区域。
  • 内核使用do_mmap()函数创建一个新的线性地址空间。但是如果创建的地址区间和一个已经存在的地址空间相邻,并且具有相同的访问权限的话,两个区间合并为一个。do_mmap()函数都会将一个地址区间加入到进程的地址空间中——无论是扩展已存在的内存区域还是创建一个新的区域。创建的映射没有和文件相关,叫做匿名映射;如果指定文件名和偏移量,那么该映射称为文件映射。系统调用为mmap()
  • do_munmap()从特定的进程地址空间中删除指定地址空间。系统调用munmap()给用户空间程序提供了一种从自身地址空间中删除指定地址区间的方法,和mmap()相反。
  • 页表:虚拟地址到物理地址的转换,硬件完成。应用程序操作的对象是虚拟内存,而处理器直接操作的却是物理内存。
  • TLB作为一个将虚拟地址映射到物理地址的硬件缓存,当请求访问一个虚拟地址时,处理器将首先检查TLB中是否缓存了该虚拟地址到物理地址的映射,如果在缓存中直接命中,物理地址立即返回;否则,就需要再通过页表搜索需要的物理地址。

第十六章 页高速缓存和页回写

  • 页高速缓存cache是Linux内核实现磁盘缓存。它主要减少对磁盘的I/O操作。使用页高速缓存的原因:磁盘比内存慢了几个数量级;局部性原理:数据一旦被访问,短期内会再次被访问
  • 写缓存缓存策略
    • 不缓存(nowrite):高速缓存不去缓存任何写操作。当对一个缓存的数据片进行写时,将直接跳过缓存,写到磁盘,同时也使缓存中的数据失效。该策略很少使用
    • 写透缓存(write-through cache):写操作自动更新内存缓存,同时也更新磁盘文件。这种策略对于保持缓存一致性很有好处——缓存数据时刻和后备存储保持同步,所以不需要让缓存失效,同时它的实现也最简单
    • 会写(copy-write或write-behind):写操作直接写到缓存,后端存储不会直接更新,而是将页高速缓存中被写入的页面标记为"脏",并且加入到脏页链表中。然后由一个进程(回写进程)周期性将脏页链表中的页回写到磁盘,从而让磁盘中的数据和内存中最终一致。
  • Linux的缓存回收是通过选择干净页(不脏)进行简单替换。如果缓存中没有足够的干净页面,内核将强制地进行回写操作,以腾出更多的干净可用页。
  • LRU最近最少使用:最新访问的页加入尾端,淘汰头结点页
  • Linux实现了一个修改过的LRU,也称为双链策略:活跃链表和非活跃链表,位于活跃链表上的页面被认为是"热"的且不会被换出,而在非活跃链表上的页则是可以被换出的。两个链表可以相互转换,活跃页链表页过多就转移一部分到非活跃页链表
  • Linux采用address_space结构体管理页高速缓存项和页I/O操作。每一个文件只能有一个address_space数据结构,但是可以有多个虚拟地址
  • linux写操作通常步骤
    • 在页高速缓存中搜索需要的页。如果页不在高速缓存中,那么内核在高速缓存中新分配一空闲项
    • 内核创建一个写请求;
    • 接着数据从用户空间拷贝到内核缓冲;
    • 最后写入磁盘
  • 每个address_space对象都有唯一的基树:只要指定文件偏移量,就可以在基树中迅速检索到页高速缓存中希望的页。
  • 脏页写回磁盘的时机:
    • 当内存空闲低于一个特定的阈值,内核必须将脏页写回磁盘以便释放内存,因为只有干净内存才可以被回收。当内存干净后,内核就可以从缓存清理数据,然后收缩缓存,最终释放出更多的内存。
    • 当脏页在内存中驻留时间超过一个特定的阈值是,内核必须将超时的脏页写回磁盘,以确保脏页不会无限期地驻留在内存中。
    • 当用户进程调用sync()和fsync()系统调用时,内核会按要求执行回写操作。
  • 内核通过flusher线程执行上述三种工作
  • 2
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Linux内核设计实现(原书第3版)》是一本专门介绍Linux内核设计实现的经典书籍。这本书由作者Robert Love撰写,详细解析了Linux内核的各个部分和组件。它是一本面向开发者和系统管理员的实践指南,深入讲解了内核原理和实践,对于想要深入了解Linux内核的人来说是一本不可或缺的参考资料。 本书主要分为八个部分。第一部分介绍了Linux内核的起源、发展和架构;第二部分详细讲解了进程管理和调度;第三部分深入剖析了内存管理;第四部分探讨了文件系统和存储管理;第五部分介绍了输入输出子系统;第六部分阐述了网络协议栈;第七部分涉及设备驱动程序;最后一部分介绍了内核模块。 该书内容丰富全面,讲解深入浅出,既可以作为学习内核原理的参考书,也可以作为实践指南。每个部分都有相应的代码示例和实例描述,帮助读者更好地理解和掌握Linux内核。同时,作者还提供了一些实用的技巧和调试方法,对于解决实际问题和优化性能非常有帮助。 总之,《Linux内核设计实现(原书第3版)》是一本非常实用和全面的Linux内核参考书籍,它不仅可以帮助读者了解内核设计实现原理,还能指导读者解决实际问题和优化系统性能。无论是想要深入了解内核原理的开发者,还是需要解决实际问题的系统管理员,都可以从这本书中获得丰富的知识和经验。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值