深入理解Linux内核 第一章 绪论
Unix 文件系统概述
Unix的每个进程都有一个当前工作目录。
为标识一个特定的文件,进程使用路径名。如果路径名第一个字符是斜杠,那么这个路径是绝对路径,其起点是根目录;如果第一项是目录名或者文件名,那么这个路径就是相对路径,其起点是进程的当前目录。
硬链接的限制
1)不允许用户给目录创建硬链接,因为这可能把目录的树形结构变成环形结构。
2)只有在同一文件系统中的文件之间才能创建硬链接。此限制较大,因为现代Unix系统可能包含多种文件系统,这些文件系统位于不同的磁盘和/或分区,用户也无法知道他们之间的物理划分。
为了克服这些限制,引入了软链接(符号链接),符号链接是短文件,包含有另一个文件的任意一个路径名,可以指向位于任意一个文件系统的任意文件或者目录,甚至一个不存在的文件。
文件类型
Unix 文件可以是以下类型之一:
1)普通文件
2)目录
3)符号链接
4)面向块的设备文件
5)面向字符的设备文件
6)管道(pipe)和命名管道(FIFO)
7)套接字(socket)
文件描述符和索引节点(inode)
Unix对文件的内容(Data)和描述文件的信息(Description)给出了清楚的区分。
除了设备文件和特殊文件系统文件外,每个文件都由字符序列组成,文件内容不包含任何控制信息,如文件的长度或者文件结束符(EOF)。
描述文件的信息(Description)都存储在一个名为索引节点(inode)的数据结构中,每个文件都有自己的inode,文件系统用inode来标识文件。
inode在不同的Unix系统上实现有很大的差异,但是必须至少提供POSIX标准中指定的如下属性:
1)文件类型
2)与文件相关联的硬链接个数
3)以字节为单位的文件长度
4)设备标识符
5)在文件系统中标识文件的索引节点号(inode号)
6)文件拥有者的UID
7)文件的用户组ID
8)三个时间戳:inode状态改变时间,最后访问时间,最后修改时间
9)访问权限和文件模式
访问权限
三种用户:
文件所有者
同组用户,不包括所有者
所有其他用户
三种权限:读、写、执行
因此组合起来有9种访问权限
文件模式
即三种附加的标记:
suid 进程执行一个文件时通常保持进程拥有者的UID,如果设置了该标识位,进程就获得了该文件拥有者的UID(例如,sudo,x表示为s)。
sgid 如果设置该标志位,进程就获得了该文件用户组的ID。
sticky 设置了sticky标志位的可执行文件在程序执行结束后,依然将它保留在内存,普通文件该位会被忽略,如果是目录设置了该位,那么其他用户是不能删除该目录下的文件和目录的,即使其他用户拥有该目录的写权限(如/tmp目录,x表示为t)。
Unix内核概述
进程/内核模式
内核本身并不是一个进程,而是一个数据结构,可以理解为进程的管理者。
CPU可以运行在用户态下,也可以运行在内核态下。
当一个程序运行在用户态时,不能直接访问内核数据结构或者内核的程序,通过使用system call来访问内核。
进程是动态的实体,在系统内只有有限的生存期。创建、撤销及同步的任务都委托给内核中的一组例程来完成。
除用户进程之外,Unix系统还包括几个所谓内核线程(kernel thread)的特权进程(被赋予了特殊权限的的进程),有以下特点:
1)以内核态运行在内核地址空间
2)不与用户直接交互,因此不需要终端设备
3)通常在系统启动时创建,然后一直处于活跃状态直到系统关闭
Unix内核的工作内容:
1)系统调用
2)异常处理
3)中断处理
4)内核线程执行
进程实现
每个进程由一个进程描述符(process descriptor)表示,这个描述符包含有关进程当前状态的信息。
当内核暂停一个进程执行时,就把几个相关CPU寄存器的内容保存在进程描述符中,包括:
1)程序计数器(PC)和栈指针(SP)寄存器
2)通用寄存器
3)浮点寄存器
4)包含CPU状态信息的处理器控制寄存器(处理器状态字,processor status word)
5)用来跟踪进程对RAM访问的内存管理寄存器
当内核决定恢复执行一个进程时,用进程描述符中合适的字段来装载CPU寄存器。
当一个进程不在CPU上执行时,它正在等待某一事件,Unix内核可以区分很多等待状态,这些等待状态通常由进程描述符队列实现。每个(可能为空)队列对应一组等待特定事件的进程。
可重入内核
所有Unix内核都是可重入的,即若干个进程可以同时在内核态下执行。
内核控制路径(kernel control path)表示内核处理系统调用、异常、中断所执行的指令序列。
进程地址空间
每个进程运行在它的私有地址空间。
用户态下运行的进程涉及到私有栈、数据区、代码区。
内核态运行时,进程访问内核的数据区,代码区,但使用另外的私有栈。因为内核是可重入的,因此几个内核控制路径(每个都与不同的进程相关)可以轮流执行,这种情况下,每个内核控制路径都使用它自己的私有内核栈。
尽管每个进程都有自己的私有地址空间,但是进程之间也有共享部分地址空间,一些情况下是显示的指出(如mmap共享内存),另一些情况下,是由内核自动完成以节约内存(如相同程序的多个不同实例的指令只会在内存中装载一次)。
同步和临界区
可重入内核的实现需要利用同步机制,多个内核控制路径对于内核数据结构存在竞争。
非抢占式内核,大多数传统Unix内核是非抢占式的,进程在内核态时,不能被任意挂起,不能被另一个进程代替。在单处理器系统上效果可以,但是多处理器系统上,效率低下。(简单,已不可取)
禁止中断,单处理器系统上的另一种同步机制,进入一个临界区之前禁止所有硬件中断,离开时再重新启用中断。如果临界区较大,那么在一个相对较长的时间内持续禁止终端就可能使所有硬件活动处于冻结状态。(简单,已不可取)
信号量,可以看成是一个对象,由三个部分组成:
1)一个整数变量
2)一个等待进程的链表
3)两个原子方法:down()和up()
每个要保护的数据结构都有它自己的信号量,其初始值为1。如果要使用该数据结构,就对其相应的信号量执行down(),如果当前值不是负数,则允许访问这个数据结构,否则,将该内核控制路径的进程加入这个信号量的链表并阻塞该进程。当另一个进程在那个信号量上执行up方法时,允许信号链表上的一个进程继续执行,缺点,如果要修改数据结构所需时间较短,其检查,插入队列,挂起等过程比较耗时,效率低。
自旋锁,与信号量是非常相似的,但是没有等待进程的链表,与信号量对比,优势在于,如果要修改的数据结构所需时间较短,比信号量更高效。当一个进程发现锁被另外一个进程锁着时,它就不停的“旋转”,执行一个紧凑的循环指令直到锁打开(占有某个处理器)。因此,在单处理器环境下,自旋锁是无效的。
避免死锁,死锁情形会导致受影响的进程或者内核控制路径完全处于冻结状态。内核设计中,当所用内核信号量的数量较多时,死锁就成为一个突出的问题,很难保证内核控制器在各种可能方式下的交错执行不出现死锁状态。有几种操作系统(包括Linux)通过按照规定的顺序请求信号量来避免死锁。
信号
Unix信号(signal)提供把系统事件报告给进程的一种机制,每种事件都有自己的信号编号。
POSIX标准定义了大约20种不同的信号,其中包括两种是用户自定义的。一般来说,进程可以以两种不同的方式对接收到的信号做出反应:
1)忽略该信号
2)异步的执行一个指定过程(信号处理程序)
如果进程不指定选择何种方式,内核就根据信号的编号执行一个默认操作,有五种可能的默认操作:
1)终止进程
2)将执行上下文进程地址空间的内容写入一个文件(核心转储,core dump),并终止进程
3)忽略该信号
4)挂起进程
5)如果进程已经被暂停,则恢复它的执行
SIGKILL和SIGSTOP信号不能直接由进程处理,也不能由进程忽略。
进程间通信
Unix System V引入了在用户态下其他种类的进程间通信机制,被很多Unix内核采用:信号量,消息队列,共享内存。被统称为System V IPC。
内核把它们作为IPC资源来实现,与文件一样,IPC资源是持久不变的,进程创建者,进程拥有者,或者超级用户必须显示的释放这些资源。
IPC里的信号量与前面所述的信号量是不同的,IPC信号量只用在用户态进程中。
POSIX标准定义了一中基于消息队列的IPC机制,就是所谓的POSIX消息队列,与System V IPC 消息队列相似,但是提供的接口更简单。
注意要区分:IPC信号量与信号量,System V IPC与POSIX IPC
进程管理
最基本的系统调用
fork()系统调用用来创建一个新进程(写时复制Copy-On-Write,vfork的区别)
_exit()系统调用用来终止一个进程
exec()类系统调用用来装入一个新程序(exec系列的区别)
僵死进程
父进程可以通过wait4()系统调用进行等待,直到其中的一个子进程结束,并且返回其进程标识符。waitpid()类似,只不过针对某单一子进程。
在父进程发出wait4()调用之前,已经运行完成的子进程会处于僵死进程状态,内核会保存子进程的有关信息(就算子进程已经运行结束)。可以使用init进程来专门管理这些。
进程组和登陆会话
进程组(process group)是表示“作业(Job)”的抽象。
登陆会话(login session),非正式地说,包含在指定终端已经开始工作会话的那个进程的所有后代进程。
内存管理
虚拟内存(virtual memory),作为一种逻辑层,处于应用程序的内存请求与硬件内存管理单元(Memory Management Unit ,MMU)之间,有以下用途和优点:
1)若干个进程可以并发地执行
2)应用程序所需内存大于可用物理内存时也可以运行
3)程序只有部分代码装入内存时进程可以执行它
4)允许每个进程访问可用物理内存的子集
5)进程可以共享库函数或程序的一个单独内存映射
6)程序是可重定位的,也就是说,可以把程序放在物理内存的任何地方
7)程序员可以编写与机器无关的代码,因为不必担心有关物理内存的组织结构
当进程使用一个虚拟地址时,内核和MMU协同定位其在内存中的实际物理位置
随机访问存储器(RAM)所有的Unix操作系统都将RAM划分为两部分,其中若干MB专门用于存放内核映射(也就是内核代码和内核静态数据结构),其余部分通常由虚拟内存系统来处理,可能用在以下三种方面:
1)满足内核对缓冲区,描述符及其他动态内核数据的请求
2)满足进程对一般内存区的请求及对文件内存映射的请求
3)借助高速缓存从磁盘及其他缓冲设备获得较好的性能
由于RAM有限,因此在可用内存达到临界阈值时,可以调用页框(page-frame-reclaiming)回收算法释放其他内存。
内核内存分配器(Kernel Memory Allocator, KMA),是一个子系统,它试图满足系统中所有部分对内存的请求。请求可能开自其他子系统,也会来自用户程序,一个好的KMA应该具备以下特点:
1)必须快,最重要
2)必须把内存浪费减少到最少
3)必须努力减轻内存的碎片(fragmentation)问题
4)必须能与其他内存管理系统合作,以便借用和释放页框
已经提出的几种KMA:
1)资源图分配算法(allocator)
2)2的幂次方空闲链表
3)McKusick-Karels分配算法
4)伙伴(Buddy)系统
5)Mach的区域(Zone)分配算法
6)Dynix分配算法
7)Solaris的Slab分配算法
Linux的KMA在伙伴系统上采用Slab分配算法
进程虚拟地址空间处理,内核通常拥一组内存区描述符描述进程虚拟地址空间。所有现代Unix操作系统都采用请求调页(demand paging)的内存分配策略。当进程访问一个不存在的页时,MMU产生一个异常,异常处理程序找到受影响的内存区,分配一个空闲的页,并用适当的数据把它初始化。同理,进程调用malloc()或者brk()系统调用动态的请求内存时,内核仅仅修改进程的堆内存区的大小,只有试图引用进程的虚拟内存地址而发生异常时,才给进程真正分配页框。内核分配给一个进程的虚拟地址空间由以下内存区组成:
1)程序的可执行代码(.text)
2)程序的初始化数据(.data)
3)程序的未初始化数据(.bss)
4)初始程序栈(.stack)
5)所需共享库的可执行代码和数据
6)堆(.heap)
高速缓存,与RAM的访问时间相比,磁盘和其他块设备的访问太慢了。
设备驱动程序,内核通过设备驱动程序(device driver)与I/O设备交互。设备驱动程序包含在内核中,由控制一个或多个设备的数据结构和函数组成,这些设备包括硬盘、键盘、鼠标、监视器、网络接口及连接到SCSI总线上的设备。通过特定的接口,每个驱动程序与内核中的其余部分相互作用,这种方式有以下优点:
1)可以把特定设备的代码封装在特定的模块中
2)厂商可以在不了解内核源代码而只知道接口规范的情况下,就能增加新的设备
3)内核以统一的方式对待所有的设备,并通过相同的接口访问这些设备
4)可以把设备驱动程序写成模块,并动态地把它们装进内核而不需要重新启动系统,不再需要时,也可以动态的卸载下模块,以减少存储在RAM中的内核映像的大小。