绪论
Unix文件系统概述
文件
Unix文件是以字节序列组成的信息载体,文件被组织在一个树结构的命名空间中。除叶节点外,树的所有节点表示目录名。目录节点包含其下的文件及目录的所有信息。
Unix的进程都有一个当前目录,当前目录属于进程执行上下文(execution context),标识进程所在的当前目录。进程使用路径名(pathname)标识某个特定文件,路径名由斜杠及一系列指向文件的目录名交替组成。若路径名的第一个字符为’/‘,则此路径为绝对路径;若第一项为目录名或文件名,则此路径为相对路径,.
标识当前目录、..
标识父目录。
软链接 & 硬链接
使用ln
命令为由路径P1
标识的文件创建一个名为P2
的硬链接1
$> ln P1 P2
硬链接有2个限制:
- 不能为目录创建硬链接
- 只能在同一文件系统中创建硬链接
软链接又可称之为符号链接。路径名可指向位于任意文件系统的任意文件或目录,甚至可指向一个不存在的文件(硬链接只能对已存在的文件进行创建)。同样使用ln
命令创建软链接
创建一个名为P2
的软链接并指向路径名P1
,任何对P2
的操作都会自动转换到P1
1
$> ln -s P1 P2
硬链接是有着与原文件相同inode号仅文件名不同的文件,而软链接是拥有属于自己的inode号的,软链接的数据块内容存放着指向原文件的路径。
文件类型
Unix文件类型包括如下:
- 普通文件
- 目录
- 软链接(符号链接)
- 块设备文件
- 字符设备文件
- 管道、命名管道
- 套接字(socket)
文件描述符(fd) & 索引节点(inode)
除设备文件和特殊文件系统文件外,Unix文件都由字符序列组成。Unix对文件的内容和描述文件的信息加以区分,文件内容不包含控制信息(文件长度、文件结束符等)。
文件系统处理文件所需的控制信息都包含在索引节点(inode)中。每个文件都有自己的索引节点,文件系统用索引节点标识文件。
索引节点(inode)包含如下属性:
- 文件类型
- 与文件相关的硬链接个数
- 以字节为单位的文件长度
- 设备标识符
- 文件系统中标识文件的索引节点号
- 文件拥有者ID
- 文件的组ID
- 时间戳(改变时间、最后访问时间、最后修改时间)
- 访问权限及文件模式
访问权限
文件的潜在用户分三类:
- 文件拥有者(
u
ser) - 同组用户,不包括拥有者(
g
roup) - 所有剩下的用户(
o
ther)
访问权限有三种:读、写和可执行。因此文件访问权限的组合有九种不同的二进制标记。除此之外,还有三种附加标记——suid
、sgid
和sticky
用于定义文件模式。
suid
:进程执行文件时保持文件拥有者的UID。sgid
:进程执行文件时保持用户组的GID。sticky
:向内核发出请求,当程序结束后依然将其保存在内存中(此标志已过时)。
Unix内核概述
进程/内核模式
CPU可运行在用户态下,也可运行在内核态下。尽管CPU会拥有不同的执行状态,但标准的Unix内核仅用到用户态和内核态。
一个程序在执行时,大部分时间处于用户态下,只有需要内核所提供的服务时才切换到内核态,程序在用户态下不能直接访问内核数据结构和内核程序。当内核满足用户程序请求后,内核会让程序重新回到用户态。
请求内核服务的进程使用系统调用(system call)的特殊编程机制完成从用户态到内核态的转换(系统调用只是其中的一种方式)。每个系统调用都设置一组识别进程请求的参数,进程调用后执行与硬件相关的CPU指令。
除用户进程外,Unix系统还有几个内核线程(kernel thread)的特权进程,特权进程有如下特点:
- 以内核态运行在内核地址空间中
- 不与用户直接交互、不需要终端设备
- 在系统启动时创建,一直处于活跃状态直到系统关闭
进程实现
为了内核能管理进程,每个进程由进程描述符(process descriptor)表示,进程描述符包含有关进程当前状态的信息。
当内核需要暂停一个正在执行中的进程时,会把相关处理器寄存器的内容保存到进程描述符中,这些寄存器包括:
- 程序计数器(PC)和栈指针(SP)寄存器
- 通用寄存器
- 浮点寄存器
- 包含CPU状态信息的处理器控制寄存器
- 用来跟踪进程对RAM访问的内存管理寄存器
当内核需要恢复一个进程时,会用进程描述符中合适的字段来装载寄存器。
当进程不在CPU上执行时,那正在等待事件。内核可区分多种等待状态,这些等待状态由进程描述符队列实现,每个队列对应一组正在等待某事件的进程。
可重入内核
所有Unix内核都是可重入的(reentrant),可重入意味着多个进程可同时在内核态下执行。
提供可重入的其中一种方式是编写可重入函数,可重入函数只能修改局部变量而不能修改全局变量。对于非重入函数,可利用锁机制保证一次只有一个进程执行一个非重入函数。
进程即使处于内核态,当一个硬件中断产生时,可重入内核也能将正在执行的进程挂起。产生硬件中断的设备控制器能快速的收到内核的应答,设备控制器在CPU处理中断时能执行其他任务,这样能提高设备控制器的吞吐量。
为了说明可重入性对内核组织的影响。引入内核控制路径(kernel control path)概念,内核控制路径表示内核处理系统调用、异常或中断所执行的指令序列。
最简单的情况下,CPU从第一条指令到最后一条指令顺序执行内核控制路径。但当以下事件之一发生时,CPU交错执行内核控制路径:
- 用户态下的进程调用一个系统调用,相应的内核控制路径确定这个请求无法立即满足。于是,内核控制路径调用调度程序选择一个新的进程执行。因此发生进程切换。第一个内核控制路径没完成,CPU便开始执行其他的内核控制路径。此情况下,两条控制路径代表两个不同的进程在执行。
- 当正在执行一个内核控制路径时,CPU检测到一个异常,第一个控制路径被挂起而CPU开始执行合适的过程。
- 当CPU正在运行一个启用了中断的内核控制路径时,一个硬件中断产生。第一个内核控制路径还未执行完,CPU开始执行另一个内核控制路径来处理中断,中断处理完后第一个内核控制路径恢复。
- 在支持抢占式调度的内核中,若一个更高优先级的进程进入调度队列,则CPU会中断第一个执行控制路径,优先响应更高优先级的进程。
进程地址空间
每个进程都运行在属于自己的私有地址空间中,地址空间的最大长度和物理内存无关,所以称之为虚拟地址空间。Linux将地址空间根据用户态和内核态不同分为用户空间和内核空间。用户态下进程的地址空间在用户空间中,涉及私有栈、数据区和代码区,但进入内核态则使用内核空间的数据区和代码区。
虽说进程访问的是私有地址空间,但进程间也能共享部分地址空间,比如在实现进程间通信时,其中一种方式称为共享内存,是通过共享部分地址空间从而实现进程间通信的。
Linux的mmap()
系统调用允许块设备的文件映射到进程的地址空间中,若一个文件由多个进程共享,那么这些进程的地址空间都包含该文件的内存映射。
进程间通信
目前Linux系统中进程间通信主要的方式有以下几种:
- 管道(pipe)和命名管道(FIFO)
- 信号(signal)
- 消息队列
- 共享内存
- 套接字(socket)
进程管理
Linux系统是通过调用fork()
创建新进程的。fork()
会创建一个与父进程完全相同的子进程,子进程被创建后继续执行fork()
后面的代码。为了效率考虑Linux系统使用Copy-On-Write机制,简单的说就是fork()
后的父子进程都使用相同的物理内存区域,子进程的代码区、数据区和堆栈区等都指向父进程(父子进程的虚拟地址空间是不同的),只有当父或子进程需要进行写入时才为子进程分配数据区、堆栈区(代码区(exec()
))并复制父进程的内容给子进程。
- 僵尸进程(zombie process)
Linux中是通过父进程调用fork()
创建出新的子进程,当子进程执行完毕调用exit()
结束自己释放绝大多数资源后,子进程并非立即就在系统中消失而是会留下部分信息(PID
、退出状态
、CPU使用时间
等)并在进程列表中保留位置(即占用一个PID),直到父进程调用wait()/waitpid()
收集完子进程残留的信息后才会使子进程彻底从系统中消失。子进程调用exit()
到父进程调用wait()/waitpid()
之间所处于的状态称其为僵尸进程。
僵尸进程是不能通过kill
命令让其消失的,因为僵尸进程已经exit()
退出了只是还残留信息等待被父进程收集而已。假若父进程中没有写wait()/waitpid()
操作或父进程忙于执行其他任务无法执行wait()/waitpid()
,那么系统中可能会出现很多僵尸进程(僵尸进程占用PID,系统PID资源也是有限的)。其中一个办法是之间杀死父进程,让子进程变成孤儿进程由init进程
接管并收集残留信息使僵尸进程消失。 - 进程组(process group) & 登录会话(login session)
- 进程组(process group)
Linux操作系统引入进程组(process group)是为了能对更方便的管理多个进程,若将信号(signal)发送给一个进程组则该进程组的所有进程都会收到该信号。
每个进程组会有一个进程组组长(process group leader),组长进程(group leader)的PID
会成为进程组ID(process group ID,PGID
)。新创建的进程会被加入到其父进程的进程组中。
进程组(process group)的存在和组长进程(group leader)是否存在无关,只要进程组(process group)中有一个进程存在则进程组(process group)就存在。只有当进程组(process group)内所有进程都退出进程组(process group)才会消失。 - 登录会话(login session)
Linux系统也引入了登录会话(login session),一个或多个进程组(process group)组成一个登录会话(login session)。会话由其中的某个进程建立,该进程称为会话首进程(session leader)。会话首进程(session leader)的PID
成为会话IDsession ID, SID
。若会话首进程(session leader)退出,内核将发送SIGHUP
信号给前台进程组。
一个登录会话(login session)可让多个进程组(process group)处于活动状态,但只能有一个进程组(process group)处于前台(foreground),其他进程组都是后台(background)。前台进程组可以访问终端,后台进程组试图访问终端时会收到SIGTTIN
/SIGTTOUT
信号。可使用bg
和fg
将一个进程组放到后台或前台。
- 进程组(process group)
内存管理
虚拟内存
在内存管理上,Linux系统引入虚拟内存(virtual memory)这个抽象的逻辑层。虚拟内存(virtual memory)是处于应用程序内存申请和硬件内存管理单元(Memory Management Unit,MMU
)之间。
虚拟内存(virtual memory)的用途和优点:- 支持多进程并行执行
- 程序所需内存大于实际物理内存是也可运行(Linux有
overcommit
机制) - 程序只有部分代码载入内存时进程可执行程序
- 允许每个进程访问可用物理内存的子集
- 进程可以共享函数库或程序的一个单独内存映象
- 程序是可重定位的,可以把程序放在物理内存的任何地方
- 程序员可以编写机器无关代码,无需关系物理内存的组织结构
虚拟内存子系统中最重要的概念是虚拟地址空间(virtual address space),进程所使用的内存地址并非物理内存地址而是虚拟地址。虚拟地址由内核和MMU
协同定位出其在内存中的物理地址。
现在的CPU有能自动把虚拟地址转换成物理地址的硬件电路,使得可以将RAM
划分成长度为4K
或8K
的页框(page frame)并引入一组页表来指定虚拟地址和物理地址的关系。
随机访问存储器RAM
RAM基本别分为两部分:- 若干兆节用于存放内核映象,主要是内核代码和内核静态数据结构。
- 剩余部分全由虚拟内存子系统处理。主要用于三个方面:
- 满足内核对缓冲区、描述符及其他动态内核数据结构的请求
- 满足进程对一般内存区的请求及对文件内存映射的请求
- 借助于高速缓存从磁盘及其他缓冲设备获得较好的性能
虚拟内存子系统另外一个需要处理的问题是内存碎片,因为即使存在可用内存,但如果不能作为一个连续的块使用时,申请内存会失败的。
内核内存分配器KMA
内核内存分配器(Kernel Memory Allocator,KMA
)是一个子系统,用于满足系统所有对内存的请求。KMA需要有如下特点:- 速度快,性能高
- 必须把内存的浪费减少到最少
- 必须努力减轻内存碎片问题
- 必须能与其他内存管理子系统合作
根据不同的算法有多种不同的KMA,Linux的KMA使用了Slab分配算法。
进程虚拟地址空间处理
进程的虚拟地址空间包括进程能使用的所有虚拟内存地址。内核常用一组内存区描述符描述进程虚拟地址空间。
当进程使用exec()
系统调用执行程序时,内核分配给进程的虚拟地址空间由以下内存区组成:- 程序的可执行代码(代码区)
- 程序的初始化数据(数据区)
- 程序的未初始化数据(数据区)
- 初始程序栈(用户态栈)
- 所需共享库的可执行代码和数据
- 堆(由程序动态申请的内存)
Linux系统都是采用请求调页(demand paging)的内存分配策略。进程可在其所需内存页没加载进内存前开始执行,当进程访问页不存在时,MMU
产生一个异常,异常处理程序定位到受影响的内存区,分配空闲页并用适当的数据页进行初始化。有了请求调页(demand paging)内存分配策略,当进行使用malloc()
系统调用动态申请内存时,内核仅需要修改堆内存区大小即可。
高速缓存
所谓高速缓存,就是将内存作为磁盘或其他设备的缓存。将从磁盘读入的内容缓存在内存中以便再次使用时能快速提供数据,不用重新从磁盘再加载进内存。对于写入磁盘的数据,会先直接在内存中修改,内核会将变更的数据标记为dirty
并周期性地将这些dirty
数据写回磁盘,避免重复低效的I/O操作。
内存寻址
《深入理解Linux内核》内存寻址这章节是以80x86
微处理器为主来讲解芯片级别的内存寻址的。
内存地址
80x86
微处理器将内存区分为三种不同的地址:
- 逻辑地址(logical address)
- 包含在机器语言指令中用来指定一个操作数或一条指令的地址。
- 每个逻辑地址(logical address)由一个段(segment)和偏移量(offset/displacement)组成。
- 线性地址(linear address)/虚拟地址(virtual address)
32
位无符号整数。最大可表达4GB
的地址。(32位系统中单进程寻址最大4G的原因)- 通常用十六进制表示。
0x00000000
~0xffffffff
- 物理地址(physical address)
- 用于内存芯片级内存单元寻址。
- 物理地址(physical address)和从微处理器的地址引脚发送到内存总线上的电信号相对应。
- 由
32
或64
位无符号整数表示。
内存控制单元(MMU)通过分段单元(segmentation unit)硬件电路将逻辑地址(logical address)转换成虚拟地址(virtual address),再通过分页单元(paging unit)硬件电路将虚拟地址(virtual address)转换物理地址(physical address)。1
逻辑地址(logical address)——>[分段单元(segmentation unit)]——>虚拟地址(virtual address)——>[分页单元(paging unit)]——>物理地址(physical address)
分段
从80286
模型开始,Intel微处理器以2
种不同方式执行地址转换:
- 实模式(real mode):之所以存在实模式是为了维持处理器与早期模型兼容并让操作系统自举
- 保护模式(protected mode):目前主要的地址转换模式,接下来主要讨论的内容
段选择符 & 段寄存器
逻辑地址 = 段标识符 + 段内偏移量
- 段标识符:
16
位长的字段,称段选择符(Segment Selector),这16
位长的段选择符结构如下15
~3
:索引号3
~1
:TL——表指示器1
~0
:RPL——请求者特权级
- 偏移量:
32
位长的字段
为能快速找到段选择符(Segment Selector),处理器提供段寄存器,段寄存器的唯一目的就是存放段选择符。总共有6个段寄存器:cs、ss、ds、es、fs和gs,其中3
个段寄存器有专门用途:
- cs:代码段寄存器,指向包含程序指令的段。cs寄存器有个非常重要的功能:含有一个
2
位长的字段,用以指明CPU当前特权级,0
表示最高优先级,3
表示最低优先级。Linux只使用0
和3
分别称之为内核态和用户态。 - ss:栈段寄存器,指向包含程序栈的段
- ds:数据段寄存器,指向包含静态数据或全局数据段
段描述符
段描述符(Segment Descriptor)是用于描述段的特征的。
每个段都由一个8
字节的段描述符(Segment Descriptor)表示,段描述符(Segment Descriptor)存放在全局描述符表(Global Descriptor Table,GDT)或局部描述符表(Local Descriptor Table,LDT)中。
通常只定义一个GDT,若进程除存放在GDT中的段外仍需要新段,则创建属于自己的LDT来存放。
GDT在主存中的地址和大小存在gdtr控制寄存器中,当前正被使用的LDT地址和大小放在ldtr控制寄存器中。