【book】linux内核设计与实现

目录

1.进程

2.系统调用

3.中断处理

4.描述一下缺页中断

5.signal:

6.内核态与用户态的切换

7.定时器和时间管理

8.文件系统


《linux内核设计与实现》学习笔记

1.进程

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

在32位机器上,一个task_struct约为1.7KB,其中包含的数据能完整地描述一个正在执行的程序:它打开的文件、进程的地址空间、挂起的信号、进程的状态等

                                

进程的常见状态之间的转化:

                                     

以下文字来自进程的D和S状态

Linux进程状态:S (TASK_INTERRUPTIBLE),可中断的睡眠状态。

处于这个状态的进程因为等待某某事件的发生(比如等待socket连接、等待信号量),而被挂起。这些进程的task_struct结构被放入对应事件的等待队列中。当这些事件发生时(由外部中断触发、或由其他进程触发),对应的等待队列中的一个或多个进程将被唤醒。

通过ps命令我们会看到,一般情况下,进程列表中的绝大多数进程都处于TASK_INTERRUPTIBLE状态(除非机器的负载很高)。毕竟CPU就这么一两个,进程动辄几十上百个,如果不是绝大多数进程都在睡眠,CPU又怎么响应得过来。

Linux进程状态:D (TASK_UNINTERRUPTIBLE),不可中断的睡眠状态。

与TASK_INTERRUPTIBLE状态类似,进程处于睡眠状态,但是此刻进程是不可中断的。不可中断,指的并不是CPU不响应外部硬件的中断,而是指进程不响应异步信号。
绝大多数情况下,进程处在睡眠状态时,总是应该能够响应异步信号的。否则你将惊奇的发现,kill -9竟然杀不死一个正在睡眠的进程了!于是我们也很好理解,为什么ps命令看到的进程几乎不会出现TASK_UNINTERRUPTIBLE状态,而总是TASK_INTERRUPTIBLE状态。

而TASK_UNINTERRUPTIBLE状态存在的意义就在于,内核的某些处理流程是不能被打断的。如果响应异步信号,程序的执行流程中就会被插入一段用于处理异步信号的流程(这个插入的流程可能只存在于内核态,也可能延伸到用户态),于是原有的流程就被中断了。(参见《linux内核异步中断浅析》)
在进程对某些硬件进行操作时(比如进程调用read系统调用对某个设备文件进行读操作,而read系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互),可能需要使用TASK_UNINTERRUPTIBLE状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。这种情况下的TASK_UNINTERRUPTIBLE状态总是非常短暂的,通过ps命令基本上不可能捕捉到。

linux系统中也存在容易捕捉的TASK_UNINTERRUPTIBLE状态。执行vfork系统调用后,父进程将进入TASK_UNINTERRUPTIBLE状态,直到子进程调用exit或exec(参见《神奇的vfork》)。

进程为什么会被置于uninterruptible sleep状态呢?处于uninterruptiblesleep状态的进程通常是在等待IO,比如磁盘IO,网络IO,其他外设IO,如果进程正在等待的IO在较长的时间内都没有响应,那么就很会不幸地被ps看到了,同时也就意味着很有可能有IO出了问题,可能是外设本身出了故障,也可能是比如挂载的远程文件系统已经不可访问了(由down掉的NFS服务器引起的D状态)。

正是因为得不到IO的相应,进程才进入了uninterruptible sleep状态,所以要想使进程从uninterruptiblesleep状态恢复,就得使进程等待的IO恢复,比如如果是因为从远程挂载的NFS卷不可访问导致进程进入uninterruptiblesleep状态的,那么可以通过恢复该NFS卷的连接来使进程的IO请求得到满足。

D状态,往往是由于 I/O 资源得不到满足,而引发等待,在内核源码 fs/proc/array.c 里,其文字定义为“ "D (disk sleep)", /* 2 */ ”(由此可知 D 原是Disk的打头字母),对应着 include/linux/sched.h 里的“ #define TASK_UNINTERRUPTIBLE 2 ”。举个例子,当 NFS 服务端关闭之时,若未事先 umount 相关目录,在 NFS 客户端执行 df 就会挂住整个登录会话,按 Ctrl+C 、Ctrl+Z 都无济于事。断开连接再登录,执行 ps axf 则看到刚才的 df 进程状态位已变成了 D ,kill -9 无法杀灭。正确的处理方式,是马上恢复 NFS 服务端,再度提供服务,刚才挂起的 df 进程发现了其苦苦等待的资源,便完成任务,自动消亡。若 NFS 服务端无法恢复服务,在 reboot 之前也应将 /etc/mtab 里的相关 NFS mount 项删除,以免 reboot 过程例行调用 netfs stop 时再次发生等待资源,导致系统重启过程挂起。

 

抢占和上下文切换:

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

    1.调用声明在<asm/mmu_context.h>中的switch_mm(),该函数负责把虚拟内存从上一个进程映射切换到新进程中。

    2.调用声明在<asm/system.h>中的switch_to(),该函数负责从上一个进程的处理器状态切换到新进程的处理器状态,这包括保存、恢复栈信息和寄存器信息,还有其他任何与体系结构相关的状态信息,都必须以每个进程为对象进行管理和保存。

                                    

进程的地址空间:

    linux是一个基于虚拟内存的操作系统,内核表示进程空间用(mm_struct),内核表示该空间中的内存区域用(结构体vm_area_struct),内核创建和撤销这些内存区域分别用mmap()和munmap()。

    地址空间中内一个vm_area_struct[每次mmap()创建地址空间都会新建一个,或是与现有的一个合并起来公用一个vm_area_struct]都被内存描述符mm_struct中的mmap[单链表串起来]和mm_rb[红黑树管理]来管理。

 

一个二级页表的例子:

假设采用X86机器下的2级分页表的形式管理地址转换,

MMU 将虚拟地址转换成物理地址的方式是,32位机器上的虚拟地址中,取虚拟地址的 22~31bits 表示页目录的下标,获得页目录项定位到页表,再取 12~21bits 表示页表的下标,获得页表项定位到页,最后取 0~11bits 表示页内偏移。页目录项和页表项的下标分别用 10bits 表示,刚好最大 1024 项,页内偏移用 12bits 表示,刚好 4KB

                             

 

2.系统调用

    现代操作系统中,内核提供了用户进程与内核进行交互的一组接口,这些接口让应用程序受限地访问硬件设备,提供了创建新进程并与已有进程进行通信的机制,也提供了申请操作系统其他资源的能力。这样做的目的是为了保证系统稳定可靠,避免应用程序恣意忘形。

与内核通信

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

1. 他为用户空间提供了一种硬件的抽象接口。如要读写文件的时候,应用程序可以不管磁盘类型和介质,甚至不用管文件所在的文件系统到底是哪种类型。

2.系统调用保护了系统的稳定和安全。作为硬件设备和应用程序之间的中间人,内核可以基于权限、用户类型和其他一些规则对需要进行的访问进行裁决。这样可以避免应用程序不正确地使用硬件设备、窃取其他进程的资源,或做出其他危害系统的事情。

系统调用和API、c库函数

                                

     一个API定义了一组应用程序使用的编程接口。他们可以实现成一个系统调用,也可以通过调用多个系统调用来实现,而完全不使用任何系统调用也不存在问题。实际上,API可以在不同的操作系统上实现,给应用程序提供完全相同的接口,而他们本身在这些系统上的实现却可能不同。

     从程序员的角度看,系统调用无关紧要,他们只需要跟API打交道就可以了。相反,内核只跟系统调用打交道;库函数及应用程序是怎么使用系统调用,不是内核所关心的。

 

系统调用过程:

    先介绍一个概念:系统调用号:在linux中,每个系统调用被赋予了一个系统调用号。这样,通过这个独一无二的号就可以关联系统调用。当用户空间的进程执行一个系统调用的时候,这个系统调用号就用来指明到底是要执行哪个系统调用;进程不会提及系统调用的名称。内核记录了系统调用表中所有已注册过的系统调用列表,存储在sys_call_table中。

    用户程序无法直接执行内核代码。他们不能直接调用内核空间中的函数,因为内核主流在受保护的地址空间上。

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

      通知内核的机制是靠软中断来实现的:通过引发一个异常来促使系统切换到内核态去执行异常处理程序。此时的异常处理程序实际上就是系统调用处理程序。在x86系统上预定义的软中断是中断号128,通过int $0x80指令触发该中断(推测:cpu执行这条指令后跳转到异常向量表)。这条指令会触发一个异常导致系统切换到内核态并执行第128号异常处理程序,而该程序正是系统调用处理程序:名为system_call()。    最近x86处理器增加了一条叫做sysenter的指令,与int中断指令相比,这条指令提供更快、更专业的陷入内核执行系统调用的方式。

指定恰当的系统调用:    

    因为所有的系统调用陷入内核的方式都一样,所以仅仅是陷入内核空间是不够的。因此必须把系统调用号一并传给内核。在x86上,系统调用号是通过eax寄存器传递给内核的。在陷入内核之前,用户空间就把相应的系统调用号放入eax中,这样系统调用处理程序一旦运行,就可以从eax中得到数据。其他体系结构上的实现也类似。

    系统调用的参数传递:在x86-32系统上,ebx、ecx、edx、esi和edi按照顺序存放前5个参数。需要六个或以上参数的情况不多见,若有,则用一个单独的寄存器存放指向所有参数在用户空间地址的指针。

     给用户空间的返回值也通过寄存器传递,在x86上,他存放在eax寄存器中。

 

在用户空间中直接访问系统调用:

    linux本身提供了一组宏,用于直接对系统调用进行访问,他会设置好寄存器并调用陷入指令。这些宏是_syscalln(),其中n的范围从0到6,代表需要传递给系统调用的参数个数。

              

 

3.中断处理

详见:https://www.cnblogs.com/edver/p/7260696.html

中断概念

    中断是指在CPU正常运行期间,由于内外部事件或由程序预先安排的事件引起的CPU暂时停止正在运行的程序,转而为该内部或外部事件或预先安排的事件服务的程序中去,服务完毕后再返回去继续运行被暂时中断的程序。Linux中通常分为外部中断(又叫硬件中断)和内部中断(又叫异常)。

    在实地址模式中,CPU把内存中从0开始的1KB空间作为一个中断向量表。表中的每一项占4个字节。但是在保护模式中,有这4个字节的表项构成的中断向量表不满足实际需求,于是根据反映模式切换的信息和偏移量的足够使得中断向量表的表项由8个字节组成,而中断向量表也叫做了中断描述符表(IDT)。在CPU中增加了一个用来描述中断描述符表寄存器(IDTR),用来保存中断描述符表的起始地址。

中断号与中断向量

       I/O设备把中断信号发送给中断控制器(8259A)时与之相关联的是一个中断号,当中断控制器把中断信号发送给CPU时与之关联的是一个中断向量。换个角度分析就是中断号是从中断控制器层面划分,中断向量是从CPU层面划分,所以中断号与中断向量之间存在一对一映射关系。在Intel X86中最大支持256种中断,从0到255开始编号,这个8位的编号就是中断向量。其中将0到31保留用于异常处理和不可屏蔽中断。

中断描述符表(IDT)初始化

中断描述符表初始化需要经过两个过程:

       第一个过程在内核引导过程。由两个步骤组成,首先给分配IDT分配2KB空间(256中断向量,每个向量由8bit组成)并初始化;然后把IDT起始地址存储到IDTR寄存器中。
       第二个过程内核在初始化自身的start_kernal函数中使用trap_init初始化系统保留中断向量,使用init_IRQ完成其余中断向量初始化。
 

中断类型

      同步中断由CPU本身产生,又称为内部中断。这里同步是指中断请求信号与代码指令之间的同步执行,在一条指令执行完毕后,CPU才能进行中断,不能在执行期间。所以也称为异常(exception)。

      异步中断是由外部硬件设备产生,又称为外部中断,与同步中断相反,异步中断可在任何时间产生,包括指令执行期间,所以也被称为中断(interrupt)。

异常又可分为可屏蔽中断(Maskable interrupt)和非屏蔽中断(Nomaskable interrupt)。而中断可分为故障(fault)、陷阱(trap)、终止(abort)三类。

从广义上讲,中断又可分为四类:中断、故障、陷阱、终止。这些类别之间的异同点请参考 表 1。

中断或异常处理

      中断处理过程:设备产生中断,并通过中断线将中断信号送往中断控制器,如果中断没有被屏蔽则会到达CPU的INTR引脚,CPU立即停止当前工作,根据获得中断向量号从IDT中找出门描述符,并执行相关中断程序。

      异常处理过程:异常是由CPU内部发生所以不会通过中断控制器,CPU直接根据中断向量号从IDT中找出门描述符,并执行相关中断程序。

      中断控制器处理主要有5个步骤:1.中断请求 2.中断响应 3.优先级比较 4.提交中断向量 5.中断结束。这里不再赘述5个步骤的具体流程。

      CPU处理流程主要有6个步骤:1.确定中断或异常的中断向量 2.通过IDTR寄存器找到IDT 3.特权检查 4.特权级发生变化,进行堆栈切换 5.如果是异常将异常代码压入堆栈,如果是中断则关闭可屏蔽中断 6.进入中断或异常服务程序执行。这里不再赘述6个步骤的具体流程。
 

 

 

中断请求实现

上下半部机制

  我们期望让中断处理程序运行得快,并想让它完成的工作量多,这两个目标相互制约,如何解决--上下半部机制。

  我们把中断处理切为两半。中断处理程序是上半部——接受中断,他就立即开始执行,但只有做严格时限的工作。能够被允许稍后完成的工作会推迟到下半部去,此后,在合适的时机,下半部会被开中端执行。上半部简单快速,执行时禁止一些或者全部中断。

  下半部稍后执行,而且执行期间可以响应所有的中断。这种设计可以使系统处于中断屏蔽状态的时间尽可能的短,以此来提高系统的响应能力。上半部只有中断处理程序机制,而下半部的实现有软中断实现,tasklet实现和工作队列实现。

  我们用网卡来解释一下这两半。当网卡接受到数据包时,通知内核,触发中断,所谓的上半部就是,及时读取数据包到内存,防止因为延迟导致丢失,这是很急迫的工作。读到内存后,对这些数据的处理不再紧迫,此时内核可以去执行中断前运行的程序,而对网络数据包的处理则交给下半部处理。

         

上下半部划分原则

  1) 如果一个任务对时间非常敏感,将其放在中断处理程序中执行;

  2) 如果一个任务和硬件有关,将其放在中断处理程序中执行;

  3) 如果一个任务要保证不被其他中断打断,将其放在中断处理程序中执行;

  4) 其他所有任务,考虑放置在下半部执行。

                            

下半部实现机制之软中断

  软中断作为下半部机制的代表,是随着SMP(share memory processor)的出现应运而生的,它也是tasklet实现的基础(tasklet实际上只是在软中断的基础上添加了一定的机制)。软中断一般是“可延迟函数”的总称,有时候也包括了tasklet(请读者在遇到的时候根据上下文推断是否包含tasklet)。它的出现就是因为要满足上面所提出的上半部和下半部的区别,使得对时间不敏感的任务延后执行,软中断执行中断处理程序留给它去完成的剩余任务,而且可以在多个CPU上并行执行,使得总的系统效率可以更高。它的特性包括:

  a)产生后并不是马上可以执行,必须要等待内核的调度才能执行。软中断不能被自己打断,只能被硬件中断打断(上半部)。

  b)可以并发运行在多个CPU上(即使同一类型的也可以)。所以软中断必须设计为可重入的函数(允许多个CPU同时操作),因此也需要使用自旋锁来保护其数据结构。

下半部实现机制之tasklet

  tasklet是通过软中断实现的,所以它本身也是软中断。

  软中断用轮询的方式处理。假如正好是最后一种中断,则必须循环完所有的中断类型,才能最终执行对应的处理函数。显然当年开发人员为了保证轮询的效率,于是限制中断个数为32个。

  为了提高中断处理数量,顺道改进处理效率,于是产生了tasklet机制。

  Tasklet采用无差别的队列机制,有中断时才执行,免去了循环查表之苦。Tasklet作为一种新机制,显然可以承担更多的优点。正好这时候SMP越来越火了,因此又在tasklet中加入了SMP机制,保证同种中断只能在一个cpu上执行。在软中断时代,显然没有这种考虑。因此同一种软中断可以在两个cpu上同时执行,很可能造成冲突。

  总结下tasklet的优点:

  (1)无类型数量限制;

  (2)效率高,无需循环查表;

  (3)支持SMP机制;

  它的特性如下:

  1)一种特定类型的tasklet只能运行在一个CPU上,不能并行,只能串行执行。

  2)多个不同类型的tasklet可以并行在多个CPU上。

  3)软中断是静态分配的,在内核编译好之后,就不能改变。但tasklet就灵活许多,可以在运行时改变(比如添加模块时)。

下半部实现机制之工作队列(work queue)

  上面我们介绍的可延迟函数运行在中断上下文中(软中断的一个检查点就是do_IRQ退出的时候),于是导致了一些问题:软中断不能睡眠、不能阻塞,说明它们不可挂起。由于中断上下文出于内核态,没有进程切换,所以如果软中断一旦睡眠或者阻塞,将无法退出这种状态,导致内核会整个僵死。但可阻塞函数不能用在中断上下文中实现,必须要运行在进程上下文中,例如访问磁盘数据块的函数。因此,可阻塞函数不能用软中断来实现。但是它们往往又具有可延迟的特性。而且由于是串行执行,因此只要有一个处理时间较长,则会导致其他中断响应的延迟。为了完成这些不可能完成的任务,于是出现了工作队列,它能够在不同的进程间切换,以完成不同的工作。

  如果推后执行的任务需要睡眠,那么就选择工作队列,如果不需要睡眠,那么就选择软中断或tasklet。工作队列能运行在进程上下文,它将工作托付给一个内核线程。工作队列说白了就是一组内核线程,作为中断守护线程来使用。多个中断可以放在一个线程中,也可以每个中断分配一个线程。我们用结构体workqueue_struct表示工作者线程,工作者线程是用内核线程实现的。而工作者线程是如何执行被推后的工作——有这样一个链表,它由结构体work_struct组成,而这个work_struct则描述了一个工作,一旦这个工作被执行完,相应的work_struct对象就从链表上移去,当链表上不再有对象时,工作者线程就会继续休眠。因为工作队列是线程,所以我们可以使用所有可以在线程中使用的方法。

Linux软中断和工作队列的作用是什么

  Linux中的软中断和工作队列是中断上下部机制中的下半部实现机制。

  1.软中断一般是“可延迟函数”的总称,它不能睡眠,不能阻塞,它处于中断上下文,不能进程切换,软中断不能被自己打断,只能被硬件中断打断(上半部),可以并发的运行在多个CPU上。所以软中断必须设计成可重入的函数,因此也需要自旋锁来保护其数据结构。

  2.工作队列中的函数处在进程上下文中,它可以睡眠,也能被阻塞,能够在不同的进程间切换,以完成不同的工作。

可延迟函数和工作队列都不能访问用户的进程空间,可延时函数在执行时不可能有任何正在运行的进程,工作队列的函数由内核进程执行,他不能访问用户空间地址。

 

4.描述一下缺页中断

什么是缺页中断?

    进程线性地址空间里的页面不必常驻内存(一个可执行文件可能很大,放在磁盘上,一次只将其中一部分读进内存,当他要访问剩余内容时,会产生缺页中断,这时候再去从磁盘上换进来),在执行一条指令时,如果发现他要访问的页(虚拟地址的页)没有在物理内存中(即存在位为0),那么停止该指令的执行,并产生一个页不存在的异常,对应的故障处理程序可通过从外存加载该页的方法来排除故障,之后,原先引起的异常的指令就可以继续执行,而不再产生异常。

查看进程发生缺页中断的次数

ps -o majflt,minflt -C program查看

majflt和minflt表示一个进程自启动以来所发生的缺页中断的次数;

产生缺页中断的几种情况

1、当内存管理单元(MMU)中确实没有创建虚拟物理页映射关系,并且在该虚拟地址之后再没有当前进程的线性区(vma)的时候,可以肯定这是一个编码错误,这将杀掉该进程;

2、当MMU中确实没有创建虚拟页物理页映射关系,并且在该虚拟地址之后存在当前进程的线性区vma的时候,这很可能是缺页中断,并且可能是栈溢出导致的缺页中断;

3、当使用malloc/mmap等希望访问物理空间的库函数/系统调用后,由于linux并未真正给新创建的vma映射物理页,此时若先进行写操作,将和2产生缺页中断的情况一样;若先进行读操作虽然也会产生缺页异常,将被映射给默认的零页,等再进行写操作时,仍会产生缺页中断,这次必须分配1物理页了,进入写时复制的流程;

4、当使用fork等系统调用创建子进程时,子进程不论有无自己的vma_struct,它的vma都有对于物理页的映射,但它们共同映射的这些物理页属性为只读,即linux并未给子进程真正分配物理页,当父子进程任何一方要写相应物理页时,导致缺页中断的写时复制;

 

缺页中断的处理过程

当进程执行过程中发生缺页中断时,需要进行页面换入,步骤如下:

<1> 首先硬件会陷入内核(MMU通过中断线发出IRQ),在堆栈中保存程序计数器(保存当前指令位置)。大多数机器将当前指令的各种状态信息保存在CPU中特殊的寄存器中。

<2>启动一个汇编代码例程保存通用寄存器及其它易失性信息,以免被操作系统破坏。这个例程将操作系统作为一个函数来调用。

(在页面换入换出的过程中可能会发生上下文换行,导致破坏当前程序计数器及通用寄存器中本进程的信息)

<3>当操作系统发现是一个页面中断时,查找出来发生页面中断的虚拟页面(进程地址空间中的页面)。这个虚拟页面的信息通常会保存在一个硬件寄存器中,如果没有的话,操作系统必须检索程序计数器,取出这条指令,用软件分析该指令,通过分析找出发生页面中断的虚拟页面。

<4>检查虚拟地址的有效性及安全保护位。如果发生保护错误,则杀死该进程

<5>操作系统查找一个空闲的页框(物理内存中的页面),如果没有空闲页框则需要通过页面置换算法(如LRU)找到一个需要换出的页框。

<6>如果找的页框中的内容被修改了(脏页),则需要将修改的内容保存到磁盘上,此时会引起一个写磁盘调用,发生上下文切换(在等待磁盘写的过程中让其它进程运行)。

(注:此时需要将页框置为状态,以防页框被其它进程抢占掉)

<7>页框干净后,操作系统根据虚拟地址对应磁盘上的位置,将保持在磁盘上的页面内容复制到“干净”的页框中,此时会引起一个读磁盘调用,发生上下文切换。

<8>当磁盘中的页面内容全部装入页框后,向操作系统发送一个中断。操作系统更新内存中的页表项,将虚拟页面映射的页框号更新为写入的页框,并将页框标记为正常状态。

<9>恢复缺页中断发生前的状态,将程序指令器(程序计数器)重新指向引起缺页中断的指令。

<10>调度引起页面中断的进程,操作系统返回汇编代码例程。

<11>汇编代码例程恢复现场,将之前保存在通用寄存器中的信息恢复。

其实缺页中断的过程涉及了用户态和内核态之间的切换,虚拟地址和物理之间的转换(这个转换过程需要使用MMU和TLB),同时涉及了内核态到用户态的转换。

 

5.signal:

最近面试中被问到了Linux的signal机制,从而引发了我对Linux中signal机制的思考。

  • 中断:内核收
  • 信号:内核给进程发

信号的本质是软件层次上对中断的一种模拟(软中断)。它是一种异步通信的处理机制,事实上,进程并不知道信号何时到来。

    操作系统给进程发送信号,本质上是给进程的task_struct中写入数据,修改相应的task_struct字段,进程在合适的时间去处理所接受的信号。

    内核给一个进程发送软中断信号的方法,是在进程所在的进程表项的信号域设置对应于该信号的位,而存储这32位信号的空间恰巧需要4个字节,因此采用位图存储是最好不过的。bit位的位置表示对应信号的编号,用0来表示未接受到信号,1表示接收到信号。

我们模拟一下这样的场景:

(1)用户输入一个命令,在shell下启动一个前台进程;

(2)用户按下Ctrl+c,通过键盘输入产生一个硬件中断;

(3)如果CPU当前正在运行此进程的代码,则该进程的用户空间的代码将暂停执行,CPU从用户态切换至内核态处理中断;

(4)终端驱动程序将Ctrl+c解释为一个SIGINT信号,记在该进程的task_struct中;

(5)当某个时刻从内核返回至该进程的用户空间代码继续执行之前,首先处理task_struct中记录的信号;SIGINT信号的默认处理动作为终止进程,所以直接终止进程而不再返回到它的用户空间代码;

 

Signal机制在Linux中是一个非常常用的进程间通信机制,很多人在使用的时候不会考虑该机制是具体如何实现的。signal机制可以被理解成进程的软中断,因此,在实时性方面还是相对比较高的。Linux中signal机制的模型可以采用下图进行描述。

 

 

                               

                                        

信号是异步的,一个进程不可能等待信号的到来,也不知道信号会到来,那么,进程是如何发现和接受信号呢?实际上,信号的接收不是由用户进程来完成的,而是由内核代理。当一个进程P2向另一个进程P1发送信号后,内核接受到信号,并将其放在P1的信号队列当中(置位)。当P1再次陷入内核态时并要切换回用户态时,会检查信号队列,并根据相应的信号调取相应的信号处理函数。

 

       每个进程都会采用一个进程控制块对其进行描述,进程控制块中设计了一个signal的位图信息,其中的每位与具体的signal相对应,这与中断机制是保持一致的。当系统中一个进程A通过signal系统调用向进程B发送signal时,设置进程B的对应signal位图,类似于触发了signal对应中断。发送signal只是“中断”触发的一个过程,具体执行会在两个阶段发生:

1、  system call返回。进程B由于调用了system call后,从内核返回用户态时需要检查他拥有的signal位图信息表,此时是一个执行点。

2、  中断返回。进程被系统中断打断之后,系统将CPU交给进程时,需要检查即将执行进程所拥有的signal位图信息表,此时也是一个执行点。

    综上所述,signal的执行点可以理解成从内核态返回用户态时,在返回时,如果发现待执行进程存在被触发的signal,那么在离开内核态之后(也就是将CPU切换到用户模式),执行用户进程为该signal绑定的signal处理函数,从这一点上看,signal处理函数是在用户进程上下文中执行的。当执行完signal处理函数之后,再返回到内核,然后再切换回用户进程被中断或者system call(软中断或者指令陷阱)打断的地方。(之所以要再进入内核,是为了再处理新来的信号或者其他工作)

       Signal机制实现的比较灵活,用户进程由于中断或者system call陷入内核之后,将断点信息都保存到了堆栈中,在内核返回用户态时,如果存在被触发的signal,那么直接将待执行的signal处理函数push到堆栈中,在CPU切换到用户模式之后,直接pop堆栈就可以执行signal处理函数并且返回到用户进程了。Signal处理函数应用了进程上下文,并且应用实际的中断模拟了进程的软中断过程。

处理过程:(摘录)

    程序运行在用户态时->进程由于系统调用或中断进入内核->转向用户态执行信号处理函数->信号处理函数完毕后进入内核->返回用户态继续执行程序

    首先程序执行在用户态,在进程陷入内核并从内核返回的前夕,会去检查有没有信号没有被处理,如果有且没有被阻塞就会调用相应的信号处理程序去处理。首先,内核在用户栈上创建一个层,该层中将返回地址设置成信号处理函数的地址,这样,从内核返回用户态时,就会执行这个信号处理函数。当信号处理函数执行完,会再次进入内核,主要是检测有没有信号没有处理,以及恢复原先程序中断执行点,恢复内核栈等工作,这样,当从内核返回后便返回到原先程序执行的地方了。

 

处理僵尸进程的过程(推测): 子进程退出(exit等系统调用)、内核给父进程发送SIGCLD信号(修改其task_struct内未决信号集)、父进程触发异常(软中断)陷入内核、切换回用户空间时检查进程task_struct中的信号位图表(未决信号集),发现有注册SIGCLD的处理函数、将此处理函数push进栈、切换到用户空间继续执行栈顶函数帧,即信号处理函数。

 

 

一个注意点:

SIGSEGV 很有可能是栈溢出引起的,如果在默认的栈上运行很有可能会破坏程序运行的现场,无法获取到正确的上下文。因此,我们应该开辟一块新的空间作为运行信号处理函数的栈。只要在注册时,将sa_flags 设置为SA_ONSTACK 即可。如下:

#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
//参数
signum:可以是除了SIGKILL 和SIGSTOP 外的所有信号量
act:act 如果不是null,则设置当前为新的信号处理函数。如果oldact 是非Null则保存老的处理函数到该指针。

struct sigaction {
//sa_handler 和sa_sigaction 只能存一,sa_flags 设置为SA_SIGINFO 则为后者
    void (*sa_handler)(int);
    void (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);//非用户使用
};
//参数
sa_handler: sa_handler 可以注册默认的SIG_DFL 或者SIG_IGN 忽略,也可以注册只有一个signum 为参数的处理函数。
sa_mask: 设置屏蔽信号量,该信号处理函数时,其他信号量应该被屏蔽。如果SA_NODEFER 被设置则无效。
sa_flags: 修改信号量的处理行为。
SA_ONSTACK: 在sigalstack(2)提供的栈上面运行。如果被选的栈不可用,则在默认的栈上运行。该参数只有在建立信号量处理函数时有用。
SA_SIGINFO: 使用sa_sigaction 而不是,sa_handler。该函数可以设置和查询指定信号量的处理函数。sigaction(SIGSEGV, NULL, &older_handler_tmp)可以获取到老的信号处理函数,sigaction(SIGSEGV, &new_handler, NULL)注册new_handler 为SIGSEGV 函数。

额外栈空间的创建如下:

//创建时,可以先查看是否已经有创建sigal 栈空间。如果没有,或者创建的大小太小,则需要创建一块足够大的栈空间。
stack_t old_stack,new_stack;
if (sigaltstack(NULL, &old_stack) == -1 || !old_stack.ss_sp ||old_stack.ss_size < SIGSTKSZ) {
    new_stack.ss_sp = calloc(1, SIGSTKSZ);
    new_stack.ss_size = SIGSTKSZ;
    if (sys_sigaltstack(&new_stack, NULL) == -1) {
        free(new_stack.ss_sp);
        return;
    }
}

注册信号处理函数

void SignalHandler(int sig, siginfo_t* info, void* uc){
......
}
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sigemptyset(&sa.sa_mask);
sigaddset(&sa.sa_mask,[signal_value]);//将所有注册的信号均加入信号集,不然sigaction()中的参数内容可能是未定义的。
sa.sa_sigaction = SignalHandler;
sa.sa_flags = SA_ONSTACK | SA_SIGINFO;//使用sa_sigaction和其他栈上运行
sigaction([signal_value], &sa, NULL);

这里是为了解决进程崩溃时栈溢出导致信号处理失败的情况,还有一种解决方式就是用另一个进程去ptrace他,就可以接管他的信号了

 

 

 

 

6.内核态与用户态的切换

原文https://www.cnblogs.com/lirong21/p/4213028.html

  • 特权级

    熟悉Unix/Linux系统的人都知道,fork的工作实际上是以系统调用的方式完成相应功能的,具体的工作是由sys_fork负责实施。其实无论是不是Unix或者Linux,对于任何操作系统来说,创建一个新的进程都是属于核心功能,因为它要做很多底层细致地工作,消耗系统的物理资源,比如分配物理内存,从父进程拷贝相关信息,拷贝设置页目录页表等等,这些显然不能随便让哪个程序就能去做,于是就自然引出特权级别的概念,显然,最关键性的权力必须由高特权级的程序来执行,这样才可以做到集中管理,减少有限资源的访问和使用冲突。

  • 用户态和内核态

    现在我们从特权级的角度来理解用户态和内核态就比较好理解了,当程序运行在3级特权级上时,就可以称之为运行在用户态,因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;反之,当程序运行在0级特权级上时,就可以称之为运行在内核态。

虽然用户态下和内核态下工作的程序有很多差别,但最重要的差别就在于特权级的不同,即权力的不同。运行在用户态下的程序不能直接访问操作系统内核数据结构和程序,比如上面例子中的testfork()就不能直接调用sys_fork(),因为前者是工作在用户态,属于用户态程序,而sys_fork()是工作在内核态,属于内核态程序。

当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态,比如testfork()最初运行在用户态进程下,当它调用fork()最终触发sys_fork()的执行时,就切换到了内核态。

  •  用户态和内核态的转换

a. 系统调用

    这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,比如前例中fork()实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断。

b. 异常

    当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。

c. 外围设备的中断

    当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

     这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。

 

  • 具体的切换操作

从触发方式上看,可以认为存在前述3种不同的类型,但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一致的,没有任何区别,都相当于执行了一个中断响应的过程,因为系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本上也是一致的,关于它们的具体区别这里不再赘述。关于中断处理机制的细节和步骤这里也不做过多分析,涉及到由用户态切换到内核态的步骤主要包括:

[1] 从当前进程的描述符task_struct中提取其内核栈的ss0及esp0信息(一个内核栈可能被多个进程共用)。

[2] 使用ss0和esp0指向的内核栈将当前进程的cs,eip,eflags,ss,esp信息保存起来,这个过程也完成了由用户栈到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。

[3] 将先前由中断向量检索得到的中断处理程序的cs,eip信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。

 

 

  • 内核栈的概念

    内核栈:Linux中每个进程有两个栈,分别用于用户态和内核态的进程执行,其中的内核栈就是用于内核态的堆栈,它和进程的task_struct结构,更具体的是thread_info结构一起放在两个连续的页框大小的空间内。

在内核源代码中使用C语言定义了一个联合结构方便地表示一个进程的thread_info和内核栈:

此结构在3.3内核版本中的定义在include/linux/sched.h文件的第2106行:

2016  union thread_union {
2017          struct thread_info thread_info;
2018          unsigned long stack[THREAD_SIZE/sizeof(long)];
2019     };        

thread_info结构的定义如下:

3.3内核 /arch/x86/include/asm/thread_info.h文件第26行:

 26   struct thread_info {
 27         struct task_struct      *task;          /* main task structure */
 28         struct exec_domain      *exec_domain;   /* execution domain */
 29         __u32                   flags;          /* low level flags */
 30         __u32                   status;         /* thread synchronous flags */
 31         __u32                   cpu;            /* current CPU */
 32         int                     preempt_count;  /* 0 => preemptable,
 33                                                    <0 => BUG */
 34         mm_segment_t            addr_limit;
 35         struct restart_block    restart_block;
 36         void __user             *sysenter_return;
 37 #ifdef CONFIG_X86_32
 38         unsigned long           previous_esp;   /* ESP of the previous stack in
 39                                                    case of nested (IRQ) stacks
 40                                                 */
 41         __u8                    supervisor_stack[0];
 42 #endif
 43         unsigned int            sig_on_uaccess_error:1;
 44         unsigned int            uaccess_err:1;  /* uaccess failed */
 45 };

 在X86中调用int指令型系统调用后会把用户栈的%esp的值及相关寄存器压入内核栈中,系统调用通过iret指令返回,在返回之前会从内核栈弹出用户栈的%esp和寄存器的状态,然后进行恢复。所以在进入内核态之前要保存进程的上下文,中断结束后恢复进程上下文,那靠的就是内核栈。

  这里有个细节问题,就是要想在内核栈保存用户态的esp,eip等寄存器的值,首先得知道内核栈的栈指针,那在进入内核态之前,通过什么才能获得内核栈的栈指针呢?答案是:TSS

  • TSS

X86体系结构中包括了一个特殊的段类型:任务状态段(TSS),用它来存放硬件上下文。TSS反映了CPU上的当前进程的特权级。

linux为每一个cpu提供一个tss段,并且在tr寄存器中保存该段。

在从用户态切换到内核态时,可以通过获取TSS段中的esp0来获取当前进程的内核栈 栈顶指针,从而可以保存用户态的cs,esp,eip等上下文。
注:linux中之所以为每一个cpu提供一个tss段,而不是为每个进程提供一个tss段,主要原因是tr寄存器永远指向它,在任务切换的时刻不必切换tr寄存器,从而减小开销。(进程切换时,cpu不变,但是进程会变,如果tss与进程绑定,那么进程切换时还要保存tss段的上下文)

下面我们看下在X86体系中Linux内核对TSS的具体实现:

内核代码中TSS结构的定义:3.3内核中:/arch/x86/include/asm/processor.h文件的第248行处:

248   struct tss_struct {
249         /*
250          * The hardware state:
251          */
252         struct x86_hw_tss       x86_tss;
253 
254         /*
255          * The extra 1 is there because the CPU will access an
256          * additional byte beyond the end of the IO permission
257          * bitmap. The extra byte must be all 1 bits, and must
258          * be within the limit.
259          */
260         unsigned long           io_bitmap[IO_BITMAP_LONGS + 1];
261 
262         /*
263          * .. and then another 0x100 bytes for the emergency kernel stack:
264          */
265         unsigned long           stack[64];
266 
267 } ____cacheline_aligned;    

其中主要的内容是:

硬件状态结构 :       x86_hw_tss

IO权位图 :    io_bitmap

备用内核栈:    stack

linux的tss段中只使用esp0和iomap等字段,并且不用它的其他字段来保存寄存器,在一个用户进程被中断进入内核态的时候,从tss中的硬件状态结构中取出esp0(即内核栈栈顶指针),然后切到esp0,其它的寄存器则保存在esp0指的内核栈上而不保存在tss中。

879 #define INIT_TSS  {                                                       
880         .x86_tss = {                                                      
881                 .sp0 = sizeof(init_stack) + (long)&init_stack, 
882                 .ss0 = __KERNEL_DS,                            
883                 .ss1 = __KERNEL_CS,                            
884                 .io_bitmap_base = INVALID_IO_BITMAP_OFFSET,               
885          },                                                               
886         .io_bitmap = { [0 ... IO_BITMAP_LONGS] = ~0 },       
887 }

分别把内核栈栈顶指针、内核代码段、内核数据段赋值给TSS中的相应项。从而进程从用户态切换到内核态时,可以从TSS段中获取内核栈栈顶指针,进而保存进程上下文到内核栈中。

总结:有了上面的一些准备,现总结在进程从用户态到内核态切换过程中,Linux主要做的事:

1:读取tr寄存器,访问TSS段

2:从TSS段中的sp0获取进程内核栈的栈顶指针

3:  由控制单元在内核栈中保存当前eflags,cs,ss,eip,esp寄存器的值。

4:由SAVE_ALL保存其寄存器的值到内核栈

5:把内核代码选择符写入CS寄存器,内核栈指针写入ESP寄存器,把内核入口点的线性地址写入EIP寄存器

此时,CPU已经切换到内核态,根据EIP中的值开始执行内核入口点的第一条指令(中断处理等)

 

7.定时器和时间管理

    周期性产生的事件(比如每10ms一次)都是由系统定时器驱动的。系统定时器是一种可编程的硬件芯片,它能以固定频率(可调节的HZ)产生中断。该中断就是所谓的定时器中断,它对应的中断处理程序负责更新系统时间,也负责执行需要周期性运行的任务。

Linux提供定时器机制,可以指定在未来的某个时刻发生某个事件,定时器的结构如下:

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

    

    软件意义上的定时器最终依赖硬件定时器来实现, 内核在时钟中断发生后检测各定时器是否到期 , 到期后的定时器处理函数将作为软中断在底半部执行 。实质上,时钟中断处理程序会唤起TIMER_SOFTIRQ软中断 ,运行当前处理器上到期的所有定时器

  总结起来还是软中断的处理流程:

  • a.注册软中断处理函数
  • b.添加timer_list到某个链表
  • c.触发软中断处理函数
  • d.调用软中断处理函数   ->遍历执行时间到达的timer_list中的定时器处理函数

如何使用:

//定义
struct timer_list my_timer;
//初始化
void init_timer (struct timer_list *timer);
//将定时器加到定时器队列中
void add_timer(struct timer_list *timer);
//修改定时器的到期时间
int mod_timer(struct timer_list *timer, unsigned long expires);
//将定时器删除
int del_timer(struct timer_list * timer);

//实例
struct timer_list my_timer;
init_timer(&my_timer);
my_timer.expires = jiffies + delay;    //注意单位是节拍
my_timer.data = 0;
my_timer.function = my_function;

add_timer(&my_timer);

mod_timer(&my_timer,jiffies+new_delay);

del_timer(&my_timer);    

 

    虽然所有定时器都以链表形式存放在一起,但是让内核经常为了寻找超时定时器而遍历整个链表是不明智的。同样,将链表以超时时间进行排序也是很不明智的做法,因为这样一来在链表中插入和删除定时器都会很费时。

    为了提高检索效率,内核将定时器按它们的超时时间划分为5组,当定时器超时时间相近时,定时器将随组一起下移。采用分组定时器的方法可以在执行软中断的多数情况下,确保内核尽可能减少搜索超时定时器所带来的负担。因此定时器管理代码是非常高效的(类似轮盘)。

实际使用中:

1.如果不要求很精确的话,用 alarm() 和 signal() 就够了:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void sigalrm_fn(int sig)
{
    printf("alarm!\n");
    alarm(2);
    return;
}
int main(void)
{
    signal(SIGALRM,signalrm_fn);
    alarm(2);
    while(1)
    pause();
}

2.实现精度较高的定时功能的话,就要使用setitimer函数。

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/time.h>

void test_func()
{
    static count = 0;

    printf("count is %d\n", count++);
}

void init_sigaction()
{
    struct sigaction act;
          
    act.sa_handler = test_func; //设置处理信号的函数
    act.sa_flags  = 0;

    sigemptyset(&act.sa_mask);
    sigaction(SIGPROF, &act, NULL);//时间到发送SIGROF信号
}

void init_time()
{
    struct itimerval val;
         
    val.it_value.tv_sec = 3; //3秒后启用定时器  秒
    val.it_value.tv_usec = 0; // 微妙

    val.it_interval.tv_sec=3;//定时器间隔为3s
    val.it_interval.tv_usec = 0; //

/*如果it_interval设置为0则定时器只执行一次

* val.it_interval.tv_sec=0

*;val.it_interval.tv_sec=0

*/

    setitimer(ITIMER_PROF, &val, NULL);
}

int main(int argc, char **argv)
{

    init_sigaction();
    init_time();

    while(1);

    return 0;
}

3.使用select()

能精确到1us,目前精确定时的最流行方案。通过使用select(),来设置定时器;原理利用select()方法的第5个参数,第一个参数设置为0,三个文件描述符集都设置为NULL,第5个参数为时间结构体,代码如下:

#include <sys/time.h>
#include <sys/select.h>
#include <time.h>
#include <stdio.h>

/*seconds: the seconds; mseconds: the micro seconds*/
void setTimer(int seconds, int mseconds)
{
        struct timeval temp;

        temp.tv_sec = seconds;
        temp.tv_usec = mseconds;

        select(0, NULL, NULL, NULL, &temp);
        printf("timer\n");

        return ;
}

int main()
{
        int i;

        for(i = 0 ; i < 100; i++)
                setTimer(1, 0);

        return 0;
}

 结果是,每隔1s打印一次,打印100次。

select定时器是阻塞的,在等待时间到来之前什么都不做。要定时可以考虑再开一个线程来做。

4.基于时间轮的定时器简单实现,在实际项目中,一个常用的做法是新起一个线程,专门管理定时器,定时来源使用rtc、select等比较精确的来源,定时器超时后向主要的work线程发消息即可,或者使用timefd接口

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>

#define TIME_WHEEL_SIZE 8

typedef void (*func)(int data);

struct timer_node {
    struct timer_node *next;
    int rotation;
    func proc;
    int data;
};

struct timer_wheel {
    struct timer_node *slot[TIME_WHEEL_SIZE];
    int current;
};

struct timer_wheel timer = {{0}, 0};

void tick(int signo)
{
    // 使用二级指针删进行单链表的删除
    struct timer_node **cur = &timer.slot[timer.current];
    while (*cur) {
        struct timer_node *curr = *cur;
        if (curr->rotation > 0) {
            curr->rotation--;
            cur = &curr->next;
        } else {
            curr->proc(curr->data);
            *cur = curr->next;
            free(curr);
        }
    }
    timer.current = (timer.current + 1) % TIME_WHEEL_SIZE;
    alarm(1);
}

void add_timer(int len, func action)
{
    int pos = (len + timer.current) % TIME_WHEEL_SIZE;
    struct timer_node *node = malloc(sizeof(struct timer_node));

    // 插入到对应格子的链表头部即可, O(1)复杂度
    node->next = timer.slot[pos];
    timer.slot[pos] = node;
    node->rotation = len / TIME_WHEEL_SIZE;
    node->data = 0;
    node->proc = action;
}

 // test case1: 1s循环定时器
int g_sec = 0;
void do_time1(int data)
{
    printf("timer %s, %d\n", __FUNCTION__, g_sec++);
    add_timer(1, do_time1);
}

// test case2: 2s单次定时器
void do_time2(int data)
{
    printf("timer %s\n", __FUNCTION__);
}

// test case3: 9s循环定时器
void do_time9(int data)
{
    printf("timer %s\n", __FUNCTION__);
    add_timer(9, do_time9);
}

int main()
{
    signal(SIGALRM, tick);
    alarm(1); // 1s的周期心跳

    // test
    add_timer(1, do_time1);
    add_timer(2, do_time2);
    add_timer(9, do_time9);

    while(1) pause();
    return 0;
}

 

 

8.文件系统

Linux虚拟文件系统

主要参考:https://www.cnblogs.com/feng9exe/p/8383950.html

1."虚拟"二字主要有两层含义: 

1, 在同一个目录结构中, 可以挂载着若干种不同的文件系统. VFS隐藏了它们的实现细节, 为使用者提供统一的接口;

2, 目录结构本身并不是绝对的, 每个进程可能会看到不一样的目录结构. 目录结构是由"地址空间(namespace)"来描述的, 不同的进程可能拥有不同的namespace, 不同的namespace可能有着不同的目录结构(因为它们可能挂载了不同的文件系统)。

 

    “一切皆是文件”是 Unix/Linux 的基本哲学之一。不仅普通的文件,目录、字符设备、块设备、 套接字等在 Unix/Linux 中都是以文件被对待;它们虽然类型不同,但是对其提供的却是同一套操作界面。

    虚拟文件系统(Virtual File System, 简称 VFS), 是 Linux 内核中的一个软件层,用于给用户空间的程序提供文件系统接口;同时,它也提供了内核中的一个 抽象功能,允许不同的文件系统共存。系统中所有的文件系统不但依赖 VFS 共存,而且也依靠 VFS 协同工作。

    为了能够支持各种实际文件系统,VFS 定义了所有文件系统都支持的基本的、概念上的接口和数据 结构;同时实际文件系统也提供 VFS 所期望的抽象接口和数据结构,将自身的诸如文件、目录等概念在形式 上与VFS的定义保持一致。换句话说,一个实际的文件系统想要被 Linux 支持,就必须提供一个符合VFS标准 的接口,才能与 VFS 协同工作。实际文件系统在统一的接口和数据结构下隐藏了具体的实现细节,所以在VFS 层和内核的其他部分看来,所有文件系统都是相同的。下图显示了VFS在内核中与实际的文件系统的协同关系。

2 VFS数据结构

2.1 一些基本概念

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

文件 一组在逻辑上具有完整意义的信息项的系列。在Linux中,除了普通文件,其他诸如目录、设备、套接字等 也以文件被对待。总之,“一切皆文件”。

目录 目录好比一个文件夹,用来容纳相关文件。因为目录可以包含子目录,所以目录是可以层层嵌套,形成 文件路径。在Linux中,目录也是以一种特殊文件被对待的,所以用于文件的操作同样也可以用在目录上。

目录项 在一个文件路径中,路径中的每一部分都被称为目录项;如路径/home/source/helloworld.c中,目录 /, home, source和文件 helloworld.c都是一个目录项。

索引节点 用于存储文件的元数据的一个数据结构。文件的元数据,也就是文件的相关信息,和文件本身是两个不同的概念。它包含的是诸如文件的大小、拥有者、创建时间、磁盘位置等和文件相关的信息。(linux将文件的相关信息文件本身这两个概念区分)

超级块 用于存储文件系统的控制信息的数据结构。描述文件系统的状态、文件系统类型、大小、区块数、索引节 点数等,存放于磁盘的特定扇区中。

如上的几个概念在磁盘中的位置关系如图所示。

2.2 VFS数据结构

VFS依靠四个主要的数据结构和一些辅助的数据结构来描述其结构信息,这些数据结构表现得就像是对象; 每个主要对象中都包含由操作函数表构成的操作对象,这些操作对象描述了内核针对这几个主要的对象可以进行的操作。

2.2.1 超级块对象

    存储一个已安装的文件系统的控制信息,代表一个已安装的文件系统;每次一个实际的文件系统被安装时, 内核会从磁盘的特定位置读取一些控制信息来填充内存中的超级块对象。一个安装实例和一个超级块对象一一对应。 超级块通过其结构中的一个域s_type记录它所属的文件系统类型。

根据第三部分追踪源代码的需要,以下是对该超级块结构的部分相关成员域的描述,(如下同):

1. 超级块

struct super_block { //超级块数据结构
        struct list_head s_list;                /*指向超级块链表的指针*/
        ……
        struct file_system_type  *s_type;       /*文件系统类型*/
       struct super_operations  *s_op;         /*超级块方法*/
        ……
        struct list_head         s_instances;   /*该类型文件系统*/
        ……
};

struct super_operations { //超级块方法
        ……
        //该函数在给定的超级块下创建并初始化一个新的索引节点对象
        struct inode *(*alloc_inode)(struct super_block *sb);
       ……
        //该函数从磁盘上读取索引节点,并动态填充内存中对应的索引节点对象的剩余部分
        void (*read_inode) (struct inode *);
       ……
};

2.2.2 索引节点对象

    索引节点对象存储了文件的相关信息,代表了存储设备上的一个实际的物理文件。当一个 文件首次被访问时,内核会在内存中组装相应的索引节点对象,以便向内核提供对一个文件进行操 作时所必需的全部信息;这些信息一部分存储在磁盘特定位置,另外一部分是在加载时动态填充的。
2. 索引节点

struct inode {//索引节点结构
      ……
      struct inode_operations  *i_op;     /*索引节点操作表*/
     struct file_operations   *i_fop;  /*该索引节点对应文件的文件操作集*/
     struct super_block       *i_sb;     /*相关的超级块*/
     ……
};

struct inode_operations { //索引节点方法
     ……
     //该函数为dentry对象所对应的文件创建一个新的索引节点,主要是由open()系统调用来调用
     int (*create) (struct inode *,struct dentry *,int, struct nameidata *);

     //在特定目录中寻找dentry对象所对应的索引节点
     struct dentry * (*lookup) (struct inode *,struct dentry *, struct nameidata *);
     ……
};

2.2.3 目录项对象

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

struct dentry {//目录项结构
     ……
     struct inode *d_inode;           /*相关的索引节点*/
    struct dentry *d_parent;         /*父目录的目录项对象*/
    struct qstr d_name;              /*目录项的名字*/
    ……
     struct list_head d_subdirs;      /*子目录*/
    ……
     struct dentry_operations *d_op;  /*目录项操作表*/
    struct super_block *d_sb;        /*文件超级块*/
    ……
};

struct dentry_operations {
    //判断目录项是否有效;
    int (*d_revalidate)(struct dentry *, struct nameidata *);
    //为目录项生成散列值;
    int (*d_hash) (struct dentry *, struct qstr *);
    ……
};

2.2.4 文件对象

    文件对象是已打开的文件在内存中的表示,主要用于建立进程和磁盘上的文件的对应关系。它由sys_open() 现场创建,由sys_close()销毁。文件对象和物理文件的关系有点像进程和程序的关系一样。当我们站在用户空间来看 待VFS,我们像是只需与文件对象打交道,而无须关心超级块,索引节点或目录项。因为多个进程可以同时打开和操作 同一个文件,所以同一个文件也可能存在多个对应的文件对象。文件对象仅仅在进程观点上代表已经打开的文件,它 反过来指向目录项对象(反过来指向索引节点)。一个文件对应的文件对象可能不是惟一的,但是其对应的索引节点和 目录项对象无疑是惟一的。
4. 文件对象

struct file {
    ……
     struct list_head        f_list;        /*文件对象链表*/
    struct dentry          *f_dentry;       /*相关目录项对象*/
    struct vfsmount        *f_vfsmnt;       /*相关的安装文件系统*/
    struct file_operations *f_op;           /*文件操作表*/
    ……
};

struct file_operations {
    ……
    //文件读操作
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ……
    //文件写操作
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    ……
    int (*readdir) (struct file *, void *, filldir_t);
    ……
    //文件打开操作
    int (*open) (struct inode *, struct file *);
    ……
};

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值