系统调用
POSIX API和系统调用
从编程者的观点看,API和系统调用之间的差别是没有关系的:唯一相关的事情就是函数名、参数类型及返回代码的含义。然而,从内核设计者的观点看,这种差别确实有关系,因为系统调用属于内核,而用户态的库函数不属于内核。
大部分封装返回一个整数,其值的含义依赖于相应的系统调用。返回值-1通常表示内核不能满足进程的请求。每个出错码都定义为一个常量宏。POSIX标准制定了很多出错码的宏名。
系统调用处理程序及服务例程
当用户态的进程调用一个系统调用时,CPU切换到内核态并开始执行一个内核态函数。在80x86体系结构中,可以用两种不同的方式调用linux的系统调用。两种方式的最终结果都是跳转到所谓系统调用处理程序的汇编语言函数。
因为内核实现了很多不同的系统调用,因此进程必须传递一个名为系统调用号的参数来识别所需的系统调用,eax寄存器就用作此目的。
所有的系统调用都返回一个整数值。这些返回值与封装例程返回值的约定是不同的。在内核中,正数或0表示系统调用成功结束,而负数表示一个出错条件。在后一种情况下,这个值就是存放在error变量中必须返回给应用程序的负出错码。
系统调用处理程序与其他异常处理程序的结构类似,执行下列操作:
- 在内核态栈保存大多数寄存器的内容
- 调用名为系统调用服务例程
- 退出系统调用处理程序:用保存在内核栈中的值加载寄存器,CPU从内核态切换回到用户态
xyz()
系统调用对应的服务例程的名字通常是sys_xyz()
。不过也有一些例外。为了把系统调用号与相应的服务例程关联起来,内核利用了一个系统调用分派表(dispatch table)。这个表存放在sys_call_table
数组中,有NR_syscalls
个表项:第n个表项包含系统调用号为n的服务例程的地址。
NR_syscalls
宏只是对可实现的系统调用最大个数的静态限制,并不表示实际已实现的系统调用个数。实际上,分派表中的任意一个表项也可以包含sys_ni_syscall()
函数的地址,这个函数是“未实现”系统调用的服务例程,它仅仅返回出错码-ENOSYS。
进入和退出系统调用
本地应用可以通过两种不同的方式调用系统调用:
- 执行
int $0x80
汇编语言执行。在Linux内核的老版本中,这是从用户态切换到内核态的唯一方式。 - 执行
sysenter
汇编语言执行。
同样,内核可以通过两种方式从系统调用退出,从而使CPU切换回到用户态:
- 执行
iret
汇编语言指令。 - 执行
sysexit
汇编语言指令。
但是,支持进入内核的两种不同方式并不像看起来那么简单,因为:
- 内核必须即支持只使用
int $0x80
指令的旧函数库,同时支持也可以使用sysenter
指令的新函数库。 - 使用
sysenter
指令的标准库必须能处理仅支持int $0x80
指令的旧内核。 - 内核和标准库必须既能运行在不包含
sysenter
指令的旧处理器上,也能运行在包含它的新处理器上。
通过int $0x80指令发出系统调用
向量128(十六进制0x80)对应于内核入口点。在内核初始化期间调用的函数trap_init()
,用下面的方式建立对应于向量128的中断描述符表表项:1
set_system_gate(0x80, &system_call);
该调用把下列值存入这个门描述符的相应字段:
Segmet Selector
:内核代码段__KERNEL_CS
的段选择符。Offset
:指向system_call()
系统调用处理程序的指针。Type
:置为15。表示这个异常是一个陷阱,相应的处理程序不禁止可屏蔽中断。DPL
(描述符特权级):置为3。这就允许用户态进程调用这个异常处理程序。
因此,当用户态进程发出int $0x80
指令时,CPU切换到内核态并开始从地址system_call
处开始执行指令。
system_call函数
system_call
函数首先把系统调用号和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈中,不包括由控制单元已自动保存的eflags、cs、eip、ss和esp寄存器。“I/O中断处理”讨论的SAVB_ALL
宏也在ds和es中装入内核数据段的段选择符:1
2
3
4
5system_call:
pushl %eax
SAVE_ALL
movl $0xffffe000, %ebx
andl %esp, %ebx
随后,这个函数在ebx中存放当前进程的thread_info
数据结构的地址,通过获得内核栈指针并把它取整到4KB或8KB的倍数完成的。
接下来system_call
检査thread_info
结构flag
字段的TIF_SYSCALL_TRACE
和TIF_SYSCALL_AUDIT
标志之一是否被设1,是否有某一调试程序正在跟踪执行程序对系统调用的调用。如果是,system_call()
两次调do_syscall_trace()
:在这个系统调用服务例程执行之前,在其之后。这个函数停止current
,因此允许调试进程收集关于current
的信息。
然后对用户态进程传递来的系统调用号有效性检査,如果大于等于系统调用分派表中的表项数,系统调用处理程序就终止。1
2
3
4
5cmpl $NR_syscalls, %rax
jb nobadsys
movl $(-ENOSYS), 24(%esp)
jmp resume_userspace
nobadsys:
如果系统调用号无效,就把-ENOSYS存在栈中曾保存eax的单元(从当前栈顶开始偏移量为24的单元),跳到resume_userspace
。当进程恢复它在用户态的执行时,会在eax中发现一个负返回码,最后调用与eax中所包含的系统调用号对应的特定服务例程:1
call *sys_call_table(0, %eax, 4)
分派表中的每表项占4字节,首先把系统调用号乘以4,再加sys_call_table
分派表的起始地址,从这个地址单元获取指向服务例程的指针,内核就找到了要调用的服务例程。
从系统调用退出
当系统调用服务例程结束时,system_call()
函数从eax获得它的返回值,并把这个返回值存放在eax寄存器的那个栈单元上:movl %eax, 24(%esp)
。因此用户态进程将在eax中找到系统调用的返回码。
然后,system_call()
函数关闭本地中断并检查当前进程的thread_info
结构中的标志。1
2
3
4cli
movl 8(%ebp), %ecx
testw $oxffff, %cx
je restore_all
如果所有的标志都没有被设置,函数就跳转到restore_all
标记处的代码恢复保存在内核栈中的寄存器的值,并执行iret汇编指令以重新开始执行用户态进程。
只要有任何一个标志被设置,那么就要在返回用户态之前完成一些工作。如果TIF_SYSCALL_TRACE
标志被设置,system_call()
函数就第二次调用do_syscall_trace()
函数,然后跳转到resume_userspace
标记处。否则,如果TIF_SYSCALL_TRACE
标志没有被设置,函数就调转到work_pending
标记处。在resume_userspace
和work_pending
标记处的代码检查重新调度请求,虚拟8086模式,挂起信号和单步执行,最终调转到restore_all
标记处以恢复用户态进程的执行。
通过sysenter指令发出系统调用
在Intel文档中被称为快速系统调用的sysenter
指令,提供了一种从用户态到内核态的快速切换方法。
sysenter指令
汇编语言指令sysenter使用三种特殊的寄存器,它们必须装入下述信息:
SYSENTER_CS_MSR
:内核代码段的段选择符。SYSENTER_EIP_MSR
:内核入口点的线性地址。SYSENTER_ESP_MSR
:内核堆栈指针。
执行sysenter指令时,CPU控制单元:
- 把
SYSENTER_CS_MSR
的内核拷贝到cs。 - 把
SYSENTER_EIP_MSR
的内容拷贝到eip。 - 把
SYSENTER_ESP_MSR
的内容拷贝到esp。 - 把
SYSENTER_CS_MSR
加8的值装入ss。
因此CPU切换到内核态并开始执行内核入口点的第一条指令。
在内核初始化期间,一旦系统中的每个CPU执行函数enable_sep_cpu()
,三个特定于模型的寄存器就由该函数初始化了。enable_sep_cpu()
函数执行以下步骤:
- 把内核代码(
__KERNEL_CS
)的段选择符写入SYSENTER_CS_MSR
寄存器。 - 把下面要说明的函数
sysenter_enry()
的线性地址写入SYSENTER_CS_EIP
寄存器。 - 计算本地TSS末端的线性地址,并把这个值写入
SYSENTER_CS_ESP
寄存器。
系统调用开始的时候,内核栈是空的,因此 esp 寄存器应该执行 4KB 或 8KB 内存区域的末端,该内存区域包括内核堆栈和当前进程的描述符。因为用户态的封装例程不知道该内存区域的地址,因此不能正确设置该寄存器。但必须在切换到内核态之前设置该寄存器的值,因此,内核初始化该寄存器,以便为本地 CPU 的任务状态段编址。每次进程切换时,内核把当前进程的内核栈指针保存到本地 TSS 的 esp0 字段。这样,系统调用处理程序读 esp 寄存器,计算本地 TSS 的 esp0 字段的地址,然后把正确的内核堆栈指针装入 esp 寄存器。
vsyscall页
只要 CPU 和 Linux 内核都支持 sysenter 指令,标准库 libc 中的封装函数就可以使用它。
本质上,在初始化阶段,sysenter_setup()
建立一个称为vsyscall
页的页框,它包括一个小的 EFL 共享对象(一个很小的 EFL 动态链接库)。
当进程发出execve()
系统调用而开始执行一个 EFL 程序时,vsyscall
页中的代码就会自动链接到进程的地址空间。vsyscall
页中的代码使用最有用的指令发出系统调用。
sysenter_setup()
为vsyscall
页分配一个新页框,并将它的物理地址与FIX_VSYSCALL
固定映射的线性地址相关联。然后把预先定义好的多个 EFL 共享对象拷贝到该页中:
如果 CPU 不支持sysenter
,sysenter_setup()
建立一个包括下列代码的vsyscall
页:1
2
3__kernel_vsyscall:
int $0x80
ret
否则,如果 CPU 的确支持sysenter
,sysenter_setup()
建立一个包括下列代码的vsyscall
页:1
2
3
4
5
6__kernel_vsyscall:
pushl %ecx
push %edx
push %ebp
movl %esp, %ebp
sysenter
当标准库中的封装例程必须调用系统调用时,都调用__kernel_vsyscall()
。
如果老版本的Linux内核不支持sysenter
指令,内核不建立vsyscall
页,而且函数__kernel_vsyscall()
不会被链接到用户态进程的地址空间。新的标准库识别出这种状况后,简单地执行int $0x80
调用系统调用。
进入系统调用
当用sysenter
指令发出系统调用时,依次执行下述步骤:
- 标准库中的封装例程把系统调用号装入
eax
寄存器,并调用__kernel_vsyscall()
。 __kernel_vsyscall()
把ebp
、edx
和ecx
的内容保存到用户态堆栈中,把用户栈指针拷贝到ebp
中,然后执行sysenter
指令。- CPU从用户态切换到内核态,内核态开始执行
sysenter_entry()
(由SYSENTER_EIP_MSR
寄存器指向)。 sysenter_entry()
汇编指令执行下述步骤:- 建立内核堆栈指针:
movl -508(%esp), %esp
- 开始时,esp 寄存器指向本地 TSS 的第一个位置,本地 TSS 的大小为 512 字节。因此,
sysenter
指令把本地 TSS 中的偏移量为 4 处的字段的内容(即 esp0 字段的内容)装入 esp。 - esp0 字段总是存放当前进程的内核堆栈指针。
- 开始时,esp 寄存器指向本地 TSS 的第一个位置,本地 TSS 的大小为 512 字节。因此,
- 打开本地中断:
sti
- 把用户数据段的段选择符、当前用户栈指针、eflags 寄存器、用户代码段的段选择符及从系统调用退出时要指向的指令的地址保存到内核堆栈:
pushl $(__USER_DS)
pushl %ebp
pushfl
pushl $(__USER_CS)
pushl $SYSENTER_RETURN
- 把由封装例程传递的寄存器的值恢复到 ebp 中:
movl (%ebp), %ebp
- 该指令完成恢复的工作,因为
__kernel_vsyscall()
把ebp的原始值存入用户态堆栈中,并随后把用户堆栈指针的当前值装入 ebp 中。
- 该指令完成恢复的工作,因为
- 通过执行一系列指令调用系统调用处理程序,这些指令与
system_call
标记处开始的指令是一样的。
- 建立内核堆栈指针:
退出系统调用
当系统调用服务例程结束时,sysenter_entry()
函数本质上执行与system_call()
函数系统的操作。首先,它从eax获得系统调用服务例程的返回码,并将返回码存入内核栈中保存用户态eax寄存器值的位置。然后,函数禁止本地中断,并检查current
的thread_info
结构中的标志。
sysexit指令
sysexit
是与sysenter
配对的汇编语言指令:它允许从内核态快速切换到用户态。执行这条指令时,CPU控制单元执行下述步骤:
- 把
SYSENTER_CS_MSR
寄存器中的值加16所得到的结果加载到cs寄存器。 - 把
edx
寄存器的内容拷贝到eip
寄存器。 - 把
SYSENTER_CS_MSR
寄存器中的值加24所得到的结果加载到ss寄存器。 - 把
ecx
寄存器的内容拷贝到esp
寄存器。
因为SYSENTER_CS_MSR
寄存器加载的是内核代码的段选择符,cs寄存器加载的是用户代码的段选择符,而ss寄存器加载的是用户数据段的段选择符。结果,CPU从内核态切换到用户态,并开始执行其地址存在edx中的那条指令。
SYSENTER_RETURN的代码
SYSENTER_RETURN
标记处的代码存放在vsyscall
页中,当通过sysenter
进入的系统调用被iret
或sysexit
指令终止时,该页框中的代码被执行。
该代码恢复保存在用户态堆栈中的ebp、edx和ecx寄存器的原始内容,并把控制权返回给标准库中的封装例程:1
2
3
4
5SYSENTER_RETURN:
popl %ebp
popl %edx
popl %ecx
ret
参数传递
系统调用的输入/输出参数可能是:
- 实际的值
- 用户态进程地址空间的变量
- 指向用户态函数的指针的数据结构地址
因为system_call()
和sysenter_entry()
是 Linux 中所有系统调用的公共入口点,因此每个系统调用至少有一个参数,即通过eax
寄存器传递进来的系统调用号。
普通C函数的参数传递时通过把参数值写入活动的程序栈(用户态栈或内核态栈)实现的。而系统调用是一种横跨用户和内核的特殊函数,所以既不能使用用户态栈也不能使用内核态栈。在发出系统调用前,系统调用的参数被写入 CPU 寄存器,然后再调用系统调用服务例程前,内核再把存放在 CPU 中的参数拷贝到内核态堆栈中,因为系统调用服务例程是普通的 C 函数。
为什么内核不直接把参数从用户态的栈拷贝到内核态的栈?
- 同时操作两个栈比较复杂。
- 寄存器的使用使得系统调用服务处理程序的结构与其他异常处理程序结构类似。
- 使用寄存器传递参数时,必须满足两个条件:
用寄存器传递参数必须满足:
- 每个参数的长度不能超过寄存器的长度,即 32 位。
- 参数的个数不能超过 6 个(除 eax 中传递的系统调用号),因为寄存器数量有限。
- 当确实存在多于 6 个参数的系统调用时,用一个单独的寄存器指向进程地址空间中这些参数值所在的一个内存区。
用于存放系统调用号和系统调用参数的寄存器是:eax(系统调用号)、ebx、ecx、edx、esi、edi 及 ebp。system_call()
和sysenter_entry()
使用SAVE_ALL
宏将这些寄存器的值保存在内核态堆栈中。
因此,当系统调用服务例程转到内核态堆栈时,就会找到system_call()
或sysenter_entry()
的返回地址,紧接着时存放在ebx
中的参数(系统调用的第一个参数),存放在ecx
中的参数等。这种栈结构与普通函数调用的栈结构完全相同,因此,系统调用服务例程很容易通过使用C语言结构引用它的参数。
有时候,服务例程需要知道在发出系统调用前 CPU 寄存器的内容。类型为pt_regs
的参数允许服务例程访问由SAVE_ALL
宏保存在内核态堆栈中的值:1
int sys_fork(struct pt_regs regs)
服务例程的返回值必须写入 eax 寄存器。这在执行return n
指令时由 C 编译程序自动完成。
验证参数
有一种检查对所有的系统调用都是通用的。只要有一个参数指定的是地址,那么内核必须检查它是否在这个进程的地址空间内。检查方式:
- 验证这个线性地址是否属于进程的地址空间
- 仅仅验证该线性地址是否小于
PAGE_OFFSET
(即没有落在留给内核的线性地址区间内)。
这是一种非常错略的检查,真正的检查推迟到分页单元将线性地址转换为物理地址时。该粗略的检查确保了进程地址空间和内核地址空间都不被非法访问。
对系统调用所传递地址的检测是通过access_ok()
宏实现的,它有两个分别为addr
和size
的参数。该宏检查addr
到addr+size-1
之间的地址区间:1
2
3
4
5
6
7int access_ok(const void *addr, unsigned long size)
{
unsigned long a = (unsigned long)addr;
if(a + size < a || a + size > current_thread_info()->addr_limit.seg)
return 0;
return 1;
}
验证addr + size
是否大于2^32-1
,因为 gcc 编译器用 32 位数表示无符号长整数和类型,所以等价于对溢出条件进行检查,检查addr+size
是否超过current
的thread_info
结构的addr_limit.seg
存放的值。普通进程通常存放PAGE_OFFSET
,内核线程为0xffffffff
。可通过get_fs
和set_fs
宏动态修改addr_limit.seg
。
访问进程地址空间
get_user()
和put_user()
宏可方便系统调用服务例程读写进程地址空间的数据。
get_user()
宏从一个地址读取 1、2 或 4 个连续字节。put_user()
宏把 1、2 或 4 个连续字节的内容写入一个地址。
参数:
- 要传送的值
x
- 一个变量
ptr
,决定还有多少字节要传送
在get_user(x, ptr)
中,由ptr
指向的变量大小使该函数展开为__get_user_1()
、__get_user_2()
或__get_user_4()
汇编语言函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16__get_user_2:
addl $1, %eax
jc bad_get_user
movl $0xffffe000, %edx
andl %esp, %edx
cmpl 24(%edx), %eax
jae bad_get_user
2: movzwl -1(%eax), %edx
xorl %eax, %eax
ret
bad_get_user:
xorl %edx, %edx
movl $-EFAULT, %eax
ret
eax
寄存器包含要读取的第一个字节的地址ptr
,前 6 个指令所执行的检查与access_ok()
宏相同,即确保要读取的两个字节的地址小于 4GB 并小于current
进程的addr_limit.seg
字段,这个字段位于current
的thread_info
结构中便宜为24处,出现在cmpl
指令的第一个操作数处。
如果地址有效,执行movzwl
指令,把要读的数据存到edx
寄存器的两个低字节,两个高字节置 0,然后在eax
中设置返回码0并终止。如果地址无效,清edx
并将eax
置为-EFAULT。
put_user(x, ptr)
宏类似于get_user
,但把值x
写入以地址ptr
为起始地址的进程地址空间。根据x
的大小,使用__put_user_asm()
宏,或__put_user_u64()
宏,成功写入则在eax
寄存器中返回0,否则返回 -EFAULT。
表中列出了内核态下用来访问进程地址空间的另外几个函数或宏。首部没有下划线的函数或宏要用额外的时间对所请求的线性地址区间进行有效检查,而有下划线的则会跳过检查。
动态地址检查:修正代码
access_ok()
宏仅对以参数传入的线性地址空间进行粗略检查,保证用户态进程不会试图侵扰内核地址空间。但线性地址仍然可能不属于进程地址空间,这时,内核使用该地址时,会发生缺页异常。
缺页异常处理程序区分在内核态引起缺页异常的四种情况,并进行相应处理:
- 内核试图访问属于进程地址空间的页,但是,相应的页框可能不存在,或内核试图写一个只读页。此时,处理程序必须分配和初始化一个新的页框(请求调页、写时复制)。
- 内核试图访问属于内核地址空间的页,但是,相应的页表项还没有初始化(处理非连续内存区访问)。此时,内核必须在当前进程页表中适当建立一些表项。
- 某一个内核函数包含编程错误,导致函数运行时引起异常;或者,可能由于瞬时的硬件错误引起异常。此时,处理程序必须执行一个内核漏洞(处理地址空间以外的错误地址)。
- 系统调用服务例程试图读写一个内存区,该内存区的地址以系统调用参数传入,但不属于进程的地址空间。
异常表
只有少数的函数和宏访问进程的地址空间;因此,如果异常是由一个无效的参数引起的,那么引起异常的指令一定包含在其中一个函数或展开的宏中。对用户空间寻址的指令非常少。因此,可把访问进程地址空间的每条内核指令的地址放到一个叫异常表的结构中。
当内核态发生缺页异常时,do_page_fault()
处理程序检查异常表:
- 如果表中包含产生异常的指令地址,则该错误就是由非法的系统调用参数引起的,
- 否则,就是由某一严重的 bug 引起的。
Linux 定义了几个异常表。主要的异常表在建立内核程序映像时,由 C 编译器自动生成。它存放在内核代码段的__ex_table
节,起始地址和终止地址由 C 编译器产生的两个符号__start__ex_table
和__stop__ex_table
标识。
此外,每个动态装载的内核模块都包含自己的局部异常表。该表在建立模块映像时,由 C 编译器自动产生,在把模块插入到运行中的内核时,该表被装入内存。
每个异常表的表项是一个exception_table_entry
结构,有两个字段:
insn
,访问进程地址空间的指令的线性地址。fixup
,存放在insn
单元中的指令所触发的缺页异常发生时,fixup
就是要调用的汇编语言代码地址。
修正代码由几条汇编指令组成,用以解决由缺页异常所引起的问题。修正通常由插入的一个指令序列组成,该指令序列强制服务例程向用户态进程返回一个出错码。这些指令通常在访问进程地址空间的同一函数或宏中定义;由 C 编译器把它们放置在内核代码段的一个叫.fixup
的独立部分。
search_exception_tables()
在所有异常表中查找一个指定地址:若该地址在某一个表中,则返回指向相应exception_table_entry
结构的指针;否则,返回 NULL。因此,缺页处理程序do_page_fault()
执行:1
2
3
4
5if(fixup = search_exception_tables(regs->eip))
{
regs->eip = fixup->fixup;
return 1;
}regs->eip
字段包含异常发生时保存到内核态栈eip
寄存器中的值。如果eip
寄存器中的该值在某个异常表中,do_page_fault()
把regs->eip
保存的值替换为search_exception_tables()
的返回地址。缺页处理程序终止,被中断的程序恢复运行。
生成异常表和修正代码
GNU 汇编程序伪指令.section
允许程序员指定可执行文件的哪部分包含紧接着要执行的代码。可执行文件包含一个代码段,该代码段可能被划分为节。下边的代码在异常表中加入一个表项:1
2
3.section __ex_table, "a"
.long faulty_instruction_address, fixup_code_address
.previous.previous
伪指令强制汇编程序把紧接着的代码插入到遇到上一个.section
伪指令时激活的节。
前边讨论过的__get_user_1()
、__get_user_2()
或__get_user_4()
函数,访问进程地址空间的指令用1、2、3标记。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25__get_user_1:
[...]
1: movzbl (%eax), %edx
[...]
__get_user_2:
[...]
2: movzwl -1(%eax), %edx
[...]
__get_user_4:
[...]
3: movl -3(%eax), %edx
[...]
bad_get_user:
xorl %edx, %edx
movl $-EFAULT, %eax
ret
.section __ex_table, "a"
.long 1b, bad_get_user
.long 2b, bad_get_user
.long 3b, bad_get_user
.previous
每个异常表项由两个标号组成,第一个是标号,前缀 “b” 表示”向后的”,标号出现在程序的前一行。修正代码对这三个函数是公用的,被标记为bad_get_user
,如果缺页异常是由标号 1、2 或 3 处的指令产生的,则修正代码就执行。bad_get_user
修正代码给发出系统调用的进程只简单地返回一个出错码 -EFAULT。
其他作用于用户态地址空间的内核函数也使用修正代码技术。比如strlen_user(string)
宏,返回系统调用中string
参数的长度,string
以null结尾;出错时返回 0。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22strlen_user(string):
mvol $0, %eax
movl $0x7fffffff, %ecx
movl %ecx, %ebx
movl string, %edi
0: repne; scabsb
subl %ecx, %ebx
movl %ebx, %eax
1:
.section .fixup, "ax"
2: xorl %eax, %eax
jmp 1b
.previous
; 在 __ex_table 中增加一个表项
; 内容包括 repne; scasb 指令的地址和相应的修正代码的地址
.section __ex_table, "a"
.long 0b, 2b
.previous
ecx
和ebx
寄存器的初始值设置为 0x7fffffff,表示用户态地址空间字符串的最大长度。repne; scabsb
循环扫描由edi
指向的字符串,在eax
中查找值为0的字符(字符串的结尾标志 \0)。因为每一次循环scasb
都将ecx
减1,所以eax
中最后存放字符串长度。修正代码被插入到.fixup
节。ax
属性指定该节必须加载到内存且包含可执行代码。如果缺页异常是由标号为 0 的指令引起,就执行修正代码,它只简单地把eax
置为 0,因此强制该宏返回一个出错码 0 而不是字符串长度,然后跳转到标号 1,即宏之后的相应指令。
第二个.section
指令在__ex_table
中增加一个表项,内容包括repne; scsab
指令地址和相应的修正代码地址。
内核封装例程
系统调用也可以被内核线程调用,内核线程不能使用库函数。为了简化相应的封装例程的声明,Linux 定义了 7 个从_syscall0
到_syscall6
的一组宏。
每个宏名字中的数字 0~6 对应着系统调用所用的参数个数(系统调用号除外)。也可以用这些宏来声明没有包含在 libc 标准库中的封装例程。然而,不能用这些宏来为超过 6 个参数(系统调用号除外)的系统调用或返回非标准值的系统调用封装例程。
每个宏严格地需要 2+2*n 个参数,n 是系统调用的参数个数。前两个参数指明返回值类型和名字;后面的每一对附加参数指明参数的类型和名字。以fork()
系统调用为例,其封装例程可以通过如下语句产生:1
_syscall0(int, fork)
而write()
系统调用的封装例程可通过如下语句产生:1
_syscall3(int, write, int, fd, const char *, buf, unsigned int, count)
展开如下:1
2
3
4
5
6
7
8
9
10
11
12
13int write(int fd, const char *buf, usninged int count)
{
long __res;
adm("int $0x80",
: "0" (__NR_write), "b" ((long)fd),
"c" ((long)buf), "d" ((long)count));
if((unsigned long)__res >= (unsigned long)-129)
{
errno = -__res;
__res = -1;
}
return (int)__res;
}
__NR_write
宏来自_syscall3
的第二个参数;它可展开成 write() 的系统调用号,当编译前面的函数时,产生如下汇编代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14write:
pushl ebx ; 将 ebx 入栈
movl 8(%esp), %ebx ; 第一个参数放入 ebx
movl 12(%esp), %ecx ; 第二个参数放入 ecx
mvol 16(%esp), %edx ; 第三个参数放入 edx
movll $4, %eax ; __NR_write 放入 eax
int $0x80 ; 调用系统调用
cmpl $-125, %eax ; 检测返回码
jbe .L1 ; 如果无错则跳转
negl %eax ; 求 eax 的补码
movl %eax, errno ; 结果放入 errno
movl -1, %eax ; eax 置为 -1
.L1: popl %ebx ; 从堆栈弹出 ebx
ret ; 返回调用程序
如果 eax 中的返回值在 -1~-129 之间,则被解释为出错码,在 errno 中存放 -eax 的值并返回 -1;否则,返回 eax 中的值。
信号
信号的作用
信号是很短的消息,可以被发送到一个进程或一组进程。发送给进程的唯一信息通常是一个数,来标识信号。前缀为SIG
的一组宏标识信号。如,当一个进程引用无效的内存时,SIGSEGV
宏产生发送给进程的信号标识符。
使用信号的两个目的:
- 让进程知道已经发生了一个特定的事件。
- 强迫进程执行自己代码中的信号处理程序。
POSIX 标准还引入了实时信号,编码范围为 32~64。不同于常规信号,它们必须排队,以便发送的多个信号都能被接收到。而同种类型的常规信号并不排队:如果一个常规信号被连续发送多次,则只有其中一个发送到接收进程。Linux 内核不使用实时信号,但通过几个特定的系统调用实现了 POSIX 标准。
许多系统调用允许程序员发送信号并决定他们的进程如何响应接收的信号。
信号的一个重要特点是它们可以随时被发送给状态经常不可预知的进程。发送给非运行进程的信号必须由内核保存,直到进程恢复执行。阻塞一个信号会拖延信号的传递,直到阻塞解除。因此,内核区分信号传递的两个不同阶段:
- 信号产生。内核更新目标进程的数据结构,以表示一个新信号已经被发送。
- 信号传递。内核强迫目标进程通过以下方式对信号做出反应:或改变目标进程的执行状态,或开始执行一个特定的信号处理程序,或两者都是。
每个产生的信号之多被传递一次。信号是可消费资源:一旦已经传递出去,进程描述符中有关该信号的所有信息都被取消。
已经产生但还没有传递的信号被称为挂起信号。任何时候,一个进程仅保存特定类型的一个挂起信号,同一进程同种类型的其他信号不被排队,只被简单地丢弃。但对于实时信号,同种类型的挂起信号可以有好几个。
一般,信号可以保留不可预知的挂起时间,必须考虑下列因素:
- 信号通常只被当前正在运行的进程(current)传递。
- 给定类型的信号可以由进程选择性地阻塞。此时,在取消阻塞前进程将不接受该信号。
- 当进程执行一个信号处理程序的函数时,通常“屏蔽”相应的信号,即自动阻塞该信号直到处理程序结束。
因此,所处理的信号另一次出现不能中断信号处理程序,所以信号处理函数不必是可重入的。
信号的内核实现比较复杂,内核必须:
- 记住每个进程阻塞哪些信号。
- 当从内核态切换到用户态时,对任何一个进程都要检查是否有一个信号已经到达。这几乎在每个定时中断时都发生。
- 确定是否可忽略该信号。发生在下列条件都满足时:
- 目标进程没有被另一个进程跟踪(进程描述符中
ptrace
字段的PT_PTRACED
的标志等于 0)。 - 信号没有被目标进程阻塞。
- 信号被目标进程忽略。
- 目标进程没有被另一个进程跟踪(进程描述符中
- 处理这样的信号,即信号可能在进程运行期的任意时刻请求把进程切换到一个信号处理函数,并在这个函数返回后恢复原来执行的上下文。
- 此外,还需考虑兼容性。
传递信号之前所执行的操作
进程以三种方式对一个信号做出应答:
- 显式地忽略信号。
- 执行与信号相关的缺省操作。由内核预定义的缺省操作取决于信号的类型:
- Terminate,进程被终止
- Dump,进程被终止,如果可能,创建包含进程执行上下文的核心转储文件,该文件可用于调试。
- Ignor,信号被忽略。
- Stop,进程被停止,即把进程设置为
TASK_STOPPED
状态。 - Continue,如果进程被停止,就把它设置为
TASK_RUNNING
状态。
- 通过调用相应的信号处理函数捕获信号。
对一个信号的阻塞和忽略是不同的:
- 只要信号被阻塞,就不被传递;只有在信号解除阻塞后才传递。
- 而一个被忽略的信号总是被传递,只是没有进一步的操作。
SIGKILL
和SIGSTOP
信号不可被显式忽略、捕获或阻塞,因此,通常必须执行它们的缺省操作。因此,SIGKILL
和SIGSTOP
分别允许具有适当特权的用户终止、停止任何进程,不管程序执行时采取怎样的防御措施。
如果某个信号的传递导致内核杀死一个进程,那么该信号对进程就是致命的。致命的信号包括:
- SIGKILL 信号
- 缺省操作为 Terminate 的每个信号
- 不被进程捕获的信号对于该进程是致命的
如果一个被进程捕获的信号,对应的信号处理函数终止了该进程,那么该信号就不是致命的,因为进程自己选择了终止,而不是被内核杀死。
POSIX 信号和多线程应用
POSXI 1003.1 标准对多线程应用的信号处理有一些严格的要求:
- 信号处理程序必须在多线程应用的所有线程之间共享;不过,每个线程必须有自己的挂起信号掩码和阻塞信号掩码。
- POSIX库函数
kill()
和sigqueue()
必须向所有的多线程应用而不是某个特殊的线程发送信号。所有由内核产生的信号同样如此。 - 每个发送给多线程应用的信号仅传送给一个线程,这个线程是由内核在从不阻塞该信号的线程中随意选择出来的。
- 如果向多线程应用发送了一个致命的信号,那么内核将被杀死该应用的所有线程,而不仅仅是杀死接收信号的那个线程。
为遵循 POSIX 标准,Linux 内核把多线程应用实现为一组属于同一个线程组的轻量级进程。
如果一个挂起信号被发送给了某个特定进程,那么该信号是私有的;如果被发送给了整个线程组,它就是共享的。
与信号相关的数据结构
内核使用几个从处理器描述符可存取的数据结构:
与信号处理相关的进程描述符中的字段:
进程描述符中的blocked
字段存放进程当前所屏蔽的信号。它是一个sigset_t
位数组,每种信号类型对应一个元素:1
2
3
4typedef struct
{
unsigned long sig[2];
}sigset_t;
每个无符号长整数由 32 位组成,所以信号最大数是64,信号的编号对应于sigset_t
位数组中相应位的下标 + 1。1 ~ 31 之间的编号对应于常规信号,32 ~ 64之间的编号对应于实时信号。
信号描述符和信号处理程序描述符
进程描述符的signal
字段指向信号描述符—一个signal_struct
类型的结构,用来跟踪共享挂起信号。信号描述符还包括与信号处理关系不密切的一些字段,如
rlim
,每进程的资源限制数组pgrp
,进程的组领头进程 PIDsession
,进程的会话领头进程 PID
信号描述符被属于同一线程组的所有进程共享,即被调用clone()
系统调用(设置CLONE_SIGHAND
标志)创建的所有进程共享,因此,对属于同一线程组的每个进程而言,信号描述符中的字段必须都是相同的。
每个进程还有信号处理程序描述符,是一个sighand_struct
类型的结构,用来描述每个信号必须如何被线程组处理。
调用clone()
时设置CLONE_SIGHAND
标志,信号处理程序描述符就可以被几个进程共享。描述符的count
字段表示共享该结构的进程个数。在一个POSIX的多线程应用中,线程组中的所有轻量级进程都应该用相同的信号描述符和信号处理程序描述符。
sigaction
信号的特性存放在k_sigaction
结构中,既包含对用户态进程所隐藏的特性,也包含sigaction
结构。字段:
sa_handler
,指定执行操作的类型。它的值可以是指向信号处理程序的一个指针,SIG_EFL
,或SIG_IGN
。sa_flags
,标志集,指定必须怎样处理信号。sa_mask
,类型为sigset_t
的变量,指定当运行信号处理程序时要屏蔽的信号。
挂起信号队列
为了跟踪当前的挂起信号是什么,内核把两个挂起信号队列与每个进程关联:
- 共享挂起信号队列,位于信号描述符的
shared_pending
字段,存放这个线程组的挂起信号。 - 私有挂起信号队列,位于进程描述符的
pending
字段,存放特定进程的挂起信号。
挂起信号队列由sigpending
数据结构组成,定义如下:1
2
3
4struct singpengding {
struct list_head list;
sigset_t signal; // 指定挂起信号的位掩码
}signal
字段是指定挂起信号的位掩码,而list
字段是包含sigqueue
的双向链表的头,sigqueue
的字段如表:
siginfo_t
是一个128字节的数据结构,存放有关出现特定信号的信息,包含下列字段:
si_signo
,信号编号si_errno
,引起信号产生的指令的出错码,没有错误则为 0si_code
,发送信号者的代码
_sifields
,依赖于信号类型的信息的联合体,相对于SIGKILL
信号,siginfo_t
在这里记录发送者进程的PID和UID。
在信号数据结构上的操作
下面的set
是指向sigset_t
类型变量的一个指针,nsig
是信号的编号,mask
是无符号长整数的位掩码。
sigemptyset(set)
和sigfillset(set)
:把set
中的位分别置为 0 或 1。sigaddset(set, nsig)
和sigdelset(set, nsig)
:把nsig
信号在set
中对应的位分别置为 1 或 0。sigaddset()
简化为:set->sig[(nsig-1) / 32] |= 1UL << ((nsig - 1) % 32);
,sigdelset()
简化为:set->sig[(nsig-1) / 32] |= ~(1UL << ((nsig - 1) % 32));
sigaddsetmask(set, mask)
和sigdelsetmask(set, mask)
:把mask
中的位在set
中对应的所有位分别设置为 1 或 0。仅用于编号为 1~32 之间的信号,可分别简化为:set->sig[0] |= mask;
,set->sig[0] |= ~mask;
sigismember(set, nsig)
:返回nsig
信号在set
中对应的值。可简化为:return 1 & (set->sig[(nsig - 1) / 32] >> ((nsig - 1) % 32));
sigmask(nsig)
:产生nsig
信号的位索引。如果内核需要设置、清除或测试一个特定信号在sigset_t
类型变量中对应的位,可通过该宏得到合适的位。sigandsets(d, s1, s2)
、sigoresets(d, s1, s2)
和signandsets(d, s1, s2)
:在sigset_t
类型的s1和s2变量之间分别执行逻辑“与”、逻辑“或”即逻辑“与非”。结果保存在d指向的sigset_t
类型的变量中。sigtestsetmask(set, mask)
:如果mask
在set
中对应的任意一位被设置,就返回 1;否则返回 0,只用于编号为 1 ~ 31。siginitset(set, mask)
:把mask
中的位初始化为 1 ~ 32 之间的信号在set
中对应的低位,并把 33 ~ 63 之间信号的对应位清 0。siginitsetinv(set, mask)
:用mask
中位的补码初始化 1 ~ 32 间的信号在sigset_t
类型的变量中对应的低位,并把 33 ~ 63 之间信号的对应位置位。signal_pending(p)
:如果*p
进程描述符所表示的进程有非阻塞的挂起信号,就返回 1,否则返回 0。通过检查进程的TIF_SIGPENDING
标志实现。recalc_sigpending_tsk(t)
和recalc_sigpending()
:第一个函数检查是*t
进程描述符表示的进程有挂起信号(t->pending->signa
),还是进程所属的线程组有挂起的信号(t->signal->shared_pending->signal
),然后把t->thread_info->flags
的TIF_SIGPENDING
标志置位。第二个函数等价于recalc_sigpending_tsk(current)
。rm_from_queue(mask, q)
:从挂起信号队列q
中删除与mask
位掩码相对应的挂起信号。flush_sigqueue(q)
:从挂起信号队列q
中删除所有的挂起信号。flush_signals(t)
:删除发送给*t
进程描述符所表示的进程的所有信号。通过清除t->thread_info->flags
中的TIF_SIGPENDING
标志,并在t->pending
和t->signal->shared_pending
队列上两次调用flush_sigqueue()
实现。
产生信号
当发送给进程或整个线程组一个信号时,该信号可能来自内核,也可能来自另一个进程。内核通过对表中的某个函数进行调用而产生信号:
表中的所有函数在结束时都会调用specific_send_sig_info()
。
一个信号被发往整个线程组时,这个信号可来自内核,也可能来自另一个进程,内核对表中的函数进行调用来产生这类信号:
表中的所有函数在结束时都会调用group_send_sig_info()
。
specific_send_sig_info()
向指定进程发送信号,参数:
sig
,信号编号。info
,或者是siginfo_t
表的地址,或者是三个特殊值中的一个:- 0:信号由用户态进程发送。
- 1:信号由内核发送。
- 2:由内核发送的
SIGSTOP
或SIGKILL
信号。
t
:指向目标进程描述符的指针。
必须在关本地中断和已经获得t->sighand->siglock
自旋锁的情况下调用该函数,执行下列步骤:
- 检查进程是否忽略信号,如果是就返回 0(不产生信号)。以下三个条件都满足时,信号被忽略:
- 进程没有被跟踪(
t->ptrace
中的PT_PTRACED
标志被清 0) - 信号没有被阻塞(
sigismember(&t->blocked, sig)
返回 0) - 或者显示地忽略信号(
t->sighand->action[sig-1].sa_handler == SIG_IGN
),或者隐含地忽略信号(sa_handler == SIGDFL
,且信号是SIGCONT
、SIGCHLD
、SIGWINCH
或SIGURG
)
- 进程没有被跟踪(
- 如果信号是非实时的(sig < 32),且在进程的私有挂起信号队列上已经有另外一个相同的挂起信号(
sigismember(&t->pending.signal, sig)
返回 1),什么都不需要做,返回0。 - 调用
send_signal(sig, info, t, &t->pending)
把信号添加到进程的挂起信号集合中。 - 如果
send_signal()
成功结束,且信号不被阻塞(sigismember(&t->blocked,sig)
返回0),signal_wake_up()
通知进程有新的挂起信号,随后,该函数执行下述步骤: - 把
t->thread_info->flags
中的TIF_SIGPENDING
标志置位。 - 如果进程处于
TASK_INTERRUPTILE
或TASK_STOPPED
状态,且信号是SIGKILL
,try_to_wake_up()
唤醒进程。 - 如果
try_to_wake_up()
返回 0,说明进程已经是可运行的:检查进程是否已经在另外一个 CPU 上运行,如果是就像那个 CPU 发送一个处理器间中断,以强制当前进程的重新调度。
因为从调度函数返回时,每个进程都检查是否存在挂起信号,因此,处理器间中断保证了目标进程能很快注意到新的挂起信号。 - 返回 1(成功产生信号)。
send_signal()
在挂起信号队列中插入一个新元素。参数:
- 信号编号
sig
siginfo_t
数据结构的地址info
- 目标进程描述符的地址
t
- 挂起信号队列的地址
signals
执行下列步骤:
- 如果
info == 2
,该信号就是SIGKILL
或SIGSTOP
,且已经由内核通过force_sig_specific()
产生:跳到第 9 步,内核立即强制执行与这些信号相关的操作,因此函数不用把信号添加到挂起信号队列中。 - 如果进程拥有者的挂起信号的数量(
t->user->sigpending
)小于当前进程的资源限制(t->signal->rlim[RLIMT_SIGPENDING].rlim_cur
),就为新出现的信号分配sigqueue
数据结构:q = kmeme_cache_alloc(sigqueue_cachep, GFP_ATOMIC);
- 如果进程拥有者的挂起信号的数量太多,或上一步的内存分配失败,就跳转到到第 9 步。
- 递增拥有者挂起信号的数量(
t->user->sigpending
)和t->user
所指向的每用户数据结构的引用计数器。 - 在挂起信号队列
signals
中增加sigqueue
数据机构:list_add_tail(&q->list, &signals->list);
在
sigqueue
数据结构中填充表siginfo_t
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18if((unsigned long)info == 0)
{
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_USER;
q->info._sifields._kill._pid = current->pid;
q->info._sifields._kill._uid = current->uid;
}
else if((unsigned long)info == 1)
{
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_KERNEL;
q->info._sifields._kill._pid = 0;
q->info._sifiields._kill._uid = 0;
}
else
copy_siginfo(&q->info, info); // 复制由调用者传递的 siginfo_t 表把队列位掩码中与信号相应的位置 1:
sigaddset(&signals->signal, sig);
- 返回 0:说明信号已经被成功追加到挂起信号队列中。
此时,不再向信号挂起队列中增加元素,因为已经有太多的挂起信号,或已经没有可以分给
sigqueue
数据结构的空闲空间,或者信号已经由内核强制立即发送。如果信号是实时的,并已经通过内核函数发送给队列排队,则send_signal()
返回错误代码 -EAGIN:1
2if(sig >= 32 && info && (unsigned long)info != 1 && info->si_code != SI_USER)
return -EAGIN;设置队列的位掩码中与信号相关的位:
sigaddset(&signals->signal, sig);
- 返回 0:即使信号没有被追加到队列中,挂起信号掩码中相应的位也被设置。
即使在挂起队列中没有空间存放相应的挂起信号,让目标进程能接收信号也很重要。假设一个进程正在消耗过多内存,内核必须保证即使没有空闲内存,kill()
也能成功执行。
group_send_sig_info()
向整个线程组发送信号。参数:
- 信号编号
sig
siginfo_t
表的地址info
- 进程描述符的地址
p
执行下列步骤:
- 检查 sig 是否正确
1
2if(sig < 0 || sig > 64)
return -EINVAL; - 如果信号是由用户态进程发送的,则确定是否允许该操作。如果不允许用户态进程发送信号,返回 -EPERM。下列条件至少有有一个成立,信号才可被传递:
- 发送进程的拥有者拥有适当的权限(通常意味着通过系统管理员发布信号)。
- 信号为
SIGCONT
且目标进程与发送进程处于同一个注册会话中。 - 两个进程属于同一个用户。
- 如果参数
sig == 0
,不产生任何信号,立即返回。因为0是无效的信号编码,说明发送进程没有向目标线程组发送信号的特权。如果目标进程正在被杀死(通过检查它的信号处理程序描述符是否被释放得知),那么函数也返回。 - 获取
p->sighand->siglock
自旋锁并关闭本地中断。 handle_stop_signal()
检查信号的某些类型,这些类型可能使目标线程组的其他挂起信号无效。- 如果线程组正在被杀死(信号描述符的
flags
字段的SIGNAL_GROUP_EXIT
标志被设置),则返回。 - 如果
sig
是SIGSTOP
、SIGTSTP
、SIGTTIN
或SIGTTOU
信号,rm_from_queue()
从共享挂起信号队列p->signal->shared_pending
和线程组所有成员的私有信号队列中删除SIGCONT
信号。 - 如果
sig
是SIGCONT
信号,rm_from_queue()
从共享挂起信号队列p->signal->shared_pending
中删除所有的SIGSTOP
、SIGTSTP
、SIGTTIN
和SIGTTOU
信号,然后从属于线程组的进程的私有挂起信号队列中删除上述信号,并唤醒进程:1
2
3
4
5
6
7
8rm_from_queue(0x003c0000, &p->signal->shared_pending);
t = p;
do
{
rm_from_queue(0x003c0000, &t->pending);
try_to_wake_up(t, TASK_STOPPED, 0);
t = next_thread(t); // 返回线程组中不同轻量级进程的描述符地址
}while(t != p);
- 如果线程组正在被杀死(信号描述符的
- 检查线程组是否忽略信号,如果是就返回 0 值(成功)。如果前一节“信号的作用”中提到的忽略信号的三个条件都满足,就忽略信号。
- 如果信号是非实时的,并且在线程组的共享挂起信号队列中已经有另外一个相同的信号,就什么都不做,返回 0 值(成功)。
1
2if(sit < 32 && sigismember(&p->signal->shared_pending.signal, sig))
return 0; send_signal()
把信号添加到共享挂起信号队列中。如果返回非 0 的错误码,终止并返回相同值。__group_complete_signal()
唤醒线程组中的一个轻量级进程。- 释放
p->sighand->siglock
自旋锁并打开本地中断。 - 返回 0(成功)。
__group_complete_signal()
扫描线程组中的进程,查找能接收新信号的进程。满足下述所有条件的进程可能被选中:
- 进程不阻塞信号。
- 进程的状态不是
EXIT_ZOMBIE
、EXIT_DEAD
、TASK_TRACED
或TASK_STOPPED
。 - 进程没有正在被杀死,即它的
PF_EXITING
标志没有置位。 - 进程或者当前正在 CPU 上运行,或者它的
TIF_SIGPENDING
标志还没有设置。
线程组可能有很多满足上述条件的进程,函数按照下面的规则选中其中一个进程:
- 如果 p 标识的进程(
group_send_sig_info()
的参数传递的描述符地址)满足所有的优先准则,函数就选择该进程。 - 否则,函数通过扫描线程组的成员搜索一个适当的进程,搜索从接收线程组最后一个信号的进程(
p->siganl->curr_target
)开始。
如果__group_complete_signal()
成功找到一个适当的进程,就开始向被选中的进程传递信号。先检查信号是否是致命的,如果是,通过向线程组中的所有轻量级进程发送SIGKILL
信号杀死整个线程组。否则,调用signal_wake_up()
通知被选中的进程:有新的挂起信号。
传递信号
如何确保进程的挂起信号得到处理内核所执行的操作。
在运行进程恢复用户态下的执行前,内核会检查进程TIF_SIGPENDING
标志的值。每当内核处理完一个中断或异常时,就检查是否存在挂起信号。
为了处理非阻塞的挂起信号,内核调用do_signal()
。参数:
regs
,栈区的地址,当前进程在用户态下寄存器的内容存放在这个栈中。oldset
,变量的地址,假设函数把阻塞信号的位掩码数组存放在这个变量中。不需要保存位掩码数组时,置为 NULL。
通常只在 CPU 要返回到用户态时才调用do_signal()
。因此,如果中断处理程序调用do_signal()
,该函数立即返回。1
2if((regs->xcs & 3) != 3)
return 1;
如果oldset
参数为NULL,就用current->blocked
字段的地址对它初始化:1
2if(!oldset)
oldset = ¤t->blocked;
do_signal()
的核心是重复调用dequeue_signal()
,直到私有挂起信号队列和共享挂起信号队列中都没有非阻塞的挂起信号为止。
dequeue_signal()
的返回码存放在signr
局部变量中,值为:
- 0,所有挂起的信号已全部被处理,且
do_signal()
可以结束。 - 非 0,挂起的信号正等待被处理,且
do_signal()
处理了当前信号后又调用了dequeue_signal()
。
dequeue_signal()
首先考虑私有信号队列中的所有信号,并从最低编号的挂起信号开始。然后考虑共享队列中的信号。它更新数据结构以标识信号不再是挂起的,并返回它的编号。这就涉及清current->pending.signal
或current->signal->shared_pending.signal
中对应的位,并调用recalc_sigpending()
更新TIF_SIGPEDING
标志的值。
do_signal()
处理每个挂起的信号,并将其编号通过dequeue_signal()
返回:
- 首先,检查
current
接收进程是否正受其他一些进程的监控,如果是,调用do_notify_parent_cldtop()
和schedule()
让监控进程知道进程的信号处理。 - 然后,把要处理信号的
k_sigaction
数据结构的地址赋给局部变量ka
:ka = ¤t->sig->action[signr-1];
- 根据
ka
的内容可以执行三种操作:忽略信号、执行缺省操作或执行信号处理程序。 - 如果显式忽略被传递的信号,
do_signal()
仅仅继续执行循环,接着考虑另一个挂起信号:if(ka->sa.sa_handler == SIG_IGN) continue;
执行信号的缺省操作
如果ka->sa.sa_handler == SIG_DFL
,do_signal()
就必须执行信号的缺省操作。但当接收进程是init
时,该信号被丢弃:1
2if(current->pid == 1)
continue;
如果接收进程是其他进程,对缺省操作是 Ignore 的信号进行简单处理:1
2if(signr == SIGCONT || signr == SIGCHLD || signr == SIGWINCH || signr == SIGURG)
continue;
缺省操作是 Stop 的信号可能停止线程组中的所有进程。
因此,do_singal()
把进程的状态都设置为TASK_STOPPED
,并随后调用schedule()
:1
2
3
4
5
6if(signr == SIGTOP || signr == SIGTSTP || signr == SIGTTIN || signr = SIGTTOU)
{
if(signr != SIGSTOP && is_orphaned_pgrp(current->signal->pgrp))
continue;
do_signal_stop(signr);
}
SIGSTOP
与其他信号的差异比较微妙,SIGSTOP
总是停止线程组而其他信号只停止不在“孤儿进程组”中的线程组。POSIX 标准规定,只要进程组中有一个进程有父进程,即便父进程处于不同的进程组中,但在同一个会话中,那么该进程组不是孤儿进程组。因此,如果父进程死亡,但启动该进程的用户仍登录在线,那么该进程组就不是一个孤儿进程组。
do_signal_stop()
检查current
是否是线程组中第一个被停止的进程,如果是,激活“组停止”:本质上,将信号描述符中的group_stop_count
字段设为正值,并唤醒线程组中的所有进程。组中的所有进程都检查该字段以确认正在进行“组停止”,然后把进程的状态设置为TASK_STOPPED
,并调用schedule()
,如果线程组领头进程的父进程没有设置SIGCHLD
的SA_NOCLDSTOP
标志,还需要向它发送SIGCHLD
信号。
缺省操作为 Dump 的信号可以在进程的工作目录中创建一个“转储”文件,该关文件列出进程地址空间和 CPU 寄存器的全部内容。do_signal()
创建了转储文件后,就杀死该线程组。
剩余 18 个信号的缺省操作为Terminate,仅仅杀死线程组。为了杀死整个线程组,调用do_group_exit()
执行彻底的“组退出”过程。
捕获信号
如果信号有一个专门的处理程序,do_signal()
就执行它。通过调用handle_signal()
进行:1
2
3
4handle_signal(signr, &info, &ka, oldset, regs);
if(ka->sa.sa_flags & SA_ONESHOT)
ka->sa.sa_handler = SIG_DFL;
return 1;
如果所接收信号的SA_ONESHOT
标志被置位,就必须重新设置它的缺省操作,以便同一信号的再次出现不会再次触发信号处理程序的执行。do_signal()
处理了一个单独的信号后,直到下一次调用do_signal()
时才考虑其他挂起的信号,确保了实时信号将以适当的顺序得到处理。
执行一个信号处理程序复杂性一:在用户态和内核态之间切换时,需要谨慎地处理栈中的内容。
handle_signal()
运行在内核态,而信号处理程序运行在用户态,当前进程恢复“正常”执行前,必须首先执行用户态的信号处理程序。此外,当内核打算恢复进程的正常执行时,内核态堆栈不再包含被中断程序的硬件上下文,因为每当从内核态向用户态转换时,内核态堆栈都被清空。
执行一个信号处理程序复杂性二:可以调用系统调用。这种情况下,执行了系统调用的服务例程后,控制权必须返回到信号处理程序,而不是被中断程序的正常代码流。
Linux 所采用的解决方法是,把保存在内核态堆栈中的硬件上下文拷贝到当前进程的用户态堆栈中。用户态堆栈也以同样方式修改:即当信号处理程序终止时,自动调用sigreturn()
把这个硬件上下文拷贝回内核态堆栈中,并恢复用户态堆栈中原来的内容。
图11-2说明了有关捕获一个信号的函数的执行流:
- 一个非阻塞的信号发送一个进程。
- 中断或异常发生时,进程切换到内核态。
- 内核执行
do_signal()
,该函数依次处理信号(handle_signal()
)和建立用户态堆栈(setup_frame()
或setup_rt_frame()
)。 - 进程返回到用户态,因为信号处理程序的起始地址被强制放进程序计数器,因此开始执行信号处理程序。
- 处理程序终止时,
setup_frame()
或setup_rt_frame()
放在用户态堆栈中的返回代码被执行。该代码调用sig_return()
或rt_sigreturn()
系统调用,相应的服务例程把正常程序的用户态堆栈硬件上下文拷贝到内核态堆栈,并把用户态堆栈恢复到它原来的样子(restore_sigcontext()
)。 - 普通进程恢复执行。
下面详细讨论该种方案。
建立帧
为建立进程的用户态堆栈,handle_signal()
调用setup_frame()
或setup_rt_frame()
。为了在这两个函数之间进行选择,内核检查与信号相关的sigaction
表sa_flags
字段的SA_SIGINFO
标志。
setup_frame()
参数:
sig
,信号编号ka
,与信号相关的k_sigaction
表的地址oldset
,阻塞信号的位掩码数组的地址regs
,用户态寄存器的内容保存在内核态堆栈区的地址
setup_frame()
把帧推入用户态堆栈中,该帧含有处理信号所需的信息,并确保正确返回到handle_signal()
。一个帧就是包含下列字段的sigframe
表:
pretcode
:信号处理函数的返回地址,它指向__kernel_sigreturn
标记处的代码sig
:信号编号sc
:类型为sigcontext
的结构,它包含正好切换到内核态前用户态进程的硬件上下文,还包含进程被阻塞的常规信号的位数组fpstate
:类型为_fpstate
的结构,用来存放用户态进程的浮点寄存器内容extramask
:被阻塞的实时信号的位数组retcode
:发出sigreturn()
系统调用的8字节代码
setup_frame()
执行步骤:
- 调用
get_sigframe()
计算帧的第一个内存单元,通常在用户态堆栈中:- 因为栈朝低地址方向延伸,所以通过把当前栈顶的地址减去它的大小,使其结果与 8 的倍数对齐,就获得了帧的起始地址
1 | (rets->esp - sizeof(struct sigframe)) & 0xfffffff8 |
- 用
access_ok
宏对返回地址进行验证。- 如果地址有效,
setup_frame()
反复调用__put_user()
填充帧的所有字段。 - 帧的
pretcode
初始化为&__kernel_sigreturn
,一些粘合代码的地址存放在vsyscall
页中。 - 修改内核态堆栈的
regs
区,保证了当current
恢复在用户态的执行时,控制权将传递给信号处理程序。
- 如果地址有效,
1 | regs->esp = (unsigned long)frame; // 而 esp 指向已推进用户态堆栈顶的帧的第一个内存单元 |
setup_frame()
把保存在内核态堆栈的段寄存器内容重新设置成它们的缺省值,现在,信号处理程序所需的信息就在用户态堆栈的顶部。
setup_rt_frame()
与setup_frame()
非常相似,但它把用户态堆栈存放在一个扩展帧中(rt_sigframe
数据结构中),该帧包含了与信号相关的siginfo_t
表的内容。此外,该函数设置pretcode
字段以使它执行vsyscall
页中的__kernel_rt_sigreturn
代码。
检查信号标志
建立用户态堆栈后,handle_signal()
检查与信号相关的标志值。如果信号没有设置SA_NODEFER
标志,在sigaction
表中sa_mask
字段对应的信号就必须在信号处理程序执行期间被阻塞1
2
3
4
5
6
7
8
9if(!(ka->sa.sa_flags & SA_NODEFER))
{
spin_lock_irq(¤t->sighand->siglock);
sigorsets(¤t->blocked, ¤t->blocked, &ka->sa.sa_mask);
sigaddset(¤t->blocked, sig); // sig 为信号编号
recalc_sigpending(curent);
spin_unlock_irq(¤t->sighand->siglock);
}
recalc_sigpending()
检查进程是否有非阻塞的挂起信号,并因此设置它的TIF_SIGPENDING
标志。然后,返回到do_signal()
,do_signal()
也立即返回。
开始执行信号处理程序
do_signal()
返回时,当前进程恢复它在用户态的执行。由于setup_frame()
的准备,eip
寄存器执行信号处理程序的第一条指令,而esp
指向已推进用户态堆栈顶的帧的第一个内存单元。因此,信号处理程序被执行。
终止信号处理程序
信号处理程序结束时,返回栈顶地址,该地址指向帧的pretcode
字段所引用的vsyscall
页中的代码:1
2
3
4__kernel_sigreturn:
popl %eax
movl $__NR_sigreturn, %eax
int $0x80
信号编号(即帧的sig
字段)被从栈中丢弃,然后调用sigreturn()
。
sys_sigreturn()
函数:
计算类型为
pt_regs
的regs
的地址,pt_regs
包含用户态进程的硬件上下文。根据存放在esp
字段中的地址,导出并检查帧在用户态堆栈内的地址:1
2
3
4
5
6frame = (struct sigframe *)(regs.esp - 8);
if(verify_area(VERIFY_READ, frame, sizeof(*frame))
{
force_sig(SIGSEGV, current);
return 0;
}把调用信号处理程序前所阻塞的信号的位数组从帧的
sc
字段拷贝到current
的blocked
字段。结果,为信号处理函数的执行而屏蔽的所有信号解除阻塞。- 调用
recalc_sigpending()
。 - 把来自帧的
sc
字段的进程硬件上下文拷贝到内核态堆栈中,并从用户态堆栈中删除帧,这两个任务通过调用restore_sigcontext()
完成。 rt_sigqueueinfo()
需要与信号相关的siginfo_t
表。- 扩展帧的
pretcode
指向vsyscall
页中的__kernel_rt_sigturn
代码,它调用rt_sigreturn()
,相应的sys_rt_sigreturn()
服务例程把来自扩展帧的进程硬件上下文拷贝到内核态堆栈,并通过从用户态堆栈删除扩展帧以恢复用户态堆栈原来的内容。
系统调用的重新执行
内核不总是能立即满足系统调用发出的请求,这时,把发出系统调用的进程置为TASK_INTERRUPTIBLE
或TASK_UNINTERRUPTIBLE
状态。
如果进程处于TASK_INTERRUPTIBLE
状态,并且某个进程向它发送了一个信号,则内核不完成系统调用就把进程置成TASK_RUNNING
状态。当切换回用户态时信号被传递给进程。这时,系统调用服务例程没有完成,但返回EINTR
、ERESTARTNOHAND
、ERESTART_RESTARTBLOCK
、ERESTARTSYS
或ERESTARTNOINTR
错误码。
实际上,用户态进程获得的唯一错误码是 EINTR,表示系统调用还没有执行完。内核内部使用剩余的错误码来指定信号处理程序结束后是否自动重新执行系统调用。
表中列出了与未完成的系统调用相关的出错码以及这些出错码对信号三种可能的操作产生的影响。
Terminate
,不会自动重新执行系统调用:进程在int $0x80
或sysenter
指令紧接着的那条指令将恢复它在用户态的执行,这时eax
寄存器包含的值为 -EINTR。Reexecute
,内核强迫用户态进程把系统调用号重新装入eax
寄存器,并重新执行int $0x80
或sysenter
指令。进程意识不到这种重新执行,出错码也不传递给进程。Depends
,只有被传递信号的SA_RESTART
标志被设置,才重新执行系统调用;否则,系统调用以 -EINTR 出错码结束。
传递信号时,内核在试图重新执行一个系统调用前,必须确定进程确实发出过该系统调用。regs
硬件上下文的orig_eax
字段起该作用。中断或异常处理程序开始时初始化该字段:
- 中断,与中断相关的
IRQ
号减去 256 0x80
或sysenter
,系统调用号- 其他异常,-1
因此,orig_eax
字段中的非负数意味着信号已经唤醒了在系统调用上睡眠的TASK_INTERRUPTIBLE
进程。服务例程认识到系统调用曾被中断,并返回前面提到的某个错误码。
重新执行被未捕获信号中断的系统调用
如果信号被显式忽略,或者它的缺省操作被强制执行,do_signal()
就分析系统调用的出错码,并确定是否重新自动执行未完成的系统调用。如果必须重新开始执行系统调用,do_signal()
就修改regs
硬件上下文,以便在进程返回用户态时,eip
指向int $0x80
或sysenter
指令,且eax
包含系统调用号:1
2
3
4
5
6
7
8
9
10
11
12
13
14if(regs->orig_eax >= 0)
{
if(regs->eax == -ERESTARTNOHAND || regs->eax == -ERESTARTSYS || regs->eax == -ERESTARTNOINTR)
{
regs->eax = regs->orig_eax;
regs->eip -= 2;
}
if(regs->eax == -ERESTART_RESTARTBLOCK)
{
regs->eax = __NR_restart_syscall;
regs->eip -= 2;
}
}
把系统调用服务例程的返回码赋给regs->eax
,int $0x80
和sysreturn
的长度都是两个字节,eip
减 2 后,指向引起系统调用的指令。ERESTART_RESTARTBLOCK
错误码是特殊的,因为eax
寄存器存放了restart_syscall()
的系统调用号,因此,用户态进程不会重新指向被信号中断的同一系统调用,该错误码仅用于与时间相关的系统调用,重新指向这些系统调用时,应该调整它们的用户态参数。
为所捕获的信号重新执行系统调用
如果信号被捕获,那么handle_signal()
可能分析出错码,也可能分析sigaction
表的SA_RESTART
标志,来决定是否必须重新执行未完成的系统调用。如果系统调用必须被重新开始执行,handle_signal()
就与do_signal()
完全一样继续执行;否则,向用户态进程返回一个出错码 -EINTR。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19if(regs->orig_eax >= 0)
{
switch(regs->eax)
{
case -ERESTART_RESTARTBLOCK:
case -ERESTARTNOHAND:
regs->eax = -EINTR;
break;
case -ERESTARTSYS:
if(!(ka->sa.sa_flags & SA_RESTART))
{
regs->eax = -EINTR;
brea;
}
case -ERESTARTNOINTR:
regs->eax = regs->orig_eax;
regs->eip -= 2;
}
}
与信号处理相关的系统调用
kill
kill(pid, sig)
向普通进程或多线程应用发送信号,其服务例程是sys_kill()
。pid
参数的含义取决于它的值:
pid > 0
,把sig
信号发送到PID
等于pid
的进程所属的线程组。pid = 0
,把sig
信号发送到与调用进程同组的进程的所有线程组。pid = -1
,把信号发送到所有进程,除了swapper
(PID = 0),init
(PID = 1)和current
。pid < -1
,把信号发送到进程组 -pid 中进程的所有线程组。
sys_kill()
为信号建立最小的siginfo_t
表,然后调用kill_something_info()
:1
2
3
4
5
6info.si_signo = sig;
info.si_errno = 0;
info.si_code = SI_USER;
info._sifields._kill._pid = current->tgid;
info._sifields._kill._uid = current->uid;
return kill_something_info(sig, &info, pid);
kill_something_info
还依次调用kill_proc_info()
(通过group_send_sig_info()
向一个单独的线程组发送信号),或调用kill_pg_info()
(扫描目标进程组的所有进程,并为目标进程组中的所有进程调用send_sig_info()
),或为系统中的所有进程反复调用group_send_sig_info()
(如果pid
等于 -1)。
kill()
能发送任何信号,包括 32 ~ 64 间的实时信号,但不能确保一个新的元素加入到目标进程的挂起信号队列,因此,挂起信号的多个实例可能被丢失。实时信号应当通过rt_sigqueueinfo()
进程发送。
tkill() 和 tgkill()
向线程组中的指定进程发送信号。
tkill()
的两个参数:
PID
,信号接收进程的pid
sig
,信号编号
sys_tkill()
服务例程为siginfo
表赋值、获取进程描述符地址、进行许可性检查,并调用specific_send_sig_info()
发送信号。
tgkill()
还需要第三个参数:
tgid
,信号接收进程组所在线程组的线程组 ID
sys_tgkill()
服务例程执行的操作与sys_tkill()
一样,但还需要检查信号接收进程是否确实属于线程组tgid
。该附加的检查解决了向一个正在被杀死的进程发送消息时出现的竞争条件的问题:
- 如果另外一个多线程应用正以足够快的速度创建轻量级级进程,信号就可能被传递给一个错误的进程。
- 因为线程组 ID 在多线程应用的整个生存期中是不会改变的。
改变信号的操作
sigaction(sig, act, oact)
允许用户为信号指定一个操作。如果没有自定义的信号操作,则执行与传递的信号相关的缺省操作。
sys_sigaction()
服务例程作用于两个参数:
sig
,信号编号act
,类型为old_sigaction
的act
表(表示新的操作)oact
,可选的输出参数,获得与信号相关的以前的操作。
函数首先检查act
地址的有效性。用*act
的字段填充类型为k_sigaction
的new_ka
局部变量的sa_handler
、sa_flags
和sa_mask
字段:1
2
3
4__get_user(new_ka.sa.sa_handler, &act->sa_handler);
__get_user(new_ka.sa.sa_flags, &act->sa_flags);
__get_user(mask, &act->sa_mask);
siginitset(&new_ka.sa.sa_mask, mask);
调用do_sigaction()
把新的new_ka
表拷贝到current->sig->action
的sig-1
位置的表项中(没有 0 信号):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20k = ¤t->sig->action[sig-1];
if(act)
{
*k = *act;
sigdelsetmask(&k->sa.sa_mask, sigmask(SIGKILL) | sigmask(SITSTOP));
if(k->sa.sa_handler == SIG_IGN || (k->sa.sa_handler == SIG_DFL &&
(sig == SIGCONT || sig == SIGCHLD || sig == SIGWINCH || sig == SIGURG)))
{
rm_from_queue(sigmask(sig), ¤t->signal->shared_pendig);
t = current;
do
{
rm_from_queue(sigmask(sig), ¤t->pending);
recalc_sigpending_tsk(t);
t = next_thread(t);
}while(t != current);
}
}
信号处理程序从不屏蔽SIGKILL
和SIGSTOP
。POSIX 标准规定,当缺省操作是Ignore
时,把信号操作设置为SIG_IGN
或SIG_DFL
将引起同类型的任意挂起信号被丢弃。
sigaction()
还允许用户初始化表sigaction
的sa_flags
字段。
原来的 System V Unix 变体提供了signal()
系统调用,Linux提供了sys_signal()
服务例程:1
2
3
4new_sa.sa_handler = handler;
new_sa.sa_flags = SA_ONESHOT | SA_NOMASL.
ret = do_sigaction(sig, &new_sa, &old_sa);
return ret ? ret : (unsigned long)old_sa.sa.sa_handler;
检查挂起的阻塞信号
sigpending()
允许进程检查信号被阻塞时已经产生的那些信号。服务例程sys_sigpending()
只作用于一个参数set
,即用户变量的地址,必须将位数组拷贝到该变量中:1
2
3sigorsets(&pending, ¤t->pending.signal, ¤t->signal->shared_pending.signal);
sigandsets(&pending, ¤t->blocked, &pending);
copy_to_user(set, &pending, 1);
修改阻塞信号的集合
sigprocmask()
允许进程修改阻塞信号的集合,只应用于常规信号(非实时信号)。sys_sigprocmask()
服务例程作用于三个参数:
oset
,进程地址空间的一个指针,执行存放以前位掩码的一个位数组。set
,进程地址空间的一个指针,执行包含新位掩码的位数组。how
,一个标志,可采取如下值:SIG_BLOCK
,*set
位掩码数组,指定必须加到阻塞信号的位掩码数组中的信号。SIG_UNBLOCK
,*set
位掩码数组,指定必须从阻塞信号的位掩码数组中删除的信号。SIG_SETMASK
,*set
位掩码数组,指定阻塞信号新的位掩码数组。
sys_sigprocmask()
调用copy_from_user()
把set
参数拷贝到局部变量new_set
中,把current
标准阻塞信号的位掩码数组拷贝到old_set
局部变量中,然后根据how
标志进行相应操作。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19if(copy_from_user(&new_set, set, sizeof(*set)))
return -EFAULT;
new_set &= ~(sigmask(SIGKILL) | sigmask(SIGSTOP));
old_set = current->blocked.sig[0];
if(how == SIG_BLOCK)
sigaddsetmask(¤t->blocked, new_set);
else if(how == SIG_UNBLOCK)
sigdelsetmask(¤t->blocked, new_set);
else if(how == SIG_SETMASK)
current->blocked.sig[0] = new_set;
else
return -EINVAL;
recalc_sigpending(current);
if(oset && copy_to_user(oset, &old_set, sizeof(*oset)))
return -EFAULT;
return 0;
挂起进程
sigsuspend()
把进程置为TASK_ITERRUPTIBLE
状态,这发生在把mask
参数指向的位掩码数组所指定的标准信号阻塞后。只有当一个非忽略、非阻塞的信号发送到进程后,进程才被唤醒。sys_sigsuspend()
服务例程:1
2
3
4
5
6
7
8
9
10
11
12mask&= ~(sigmask(SIGKILL) | sigmask(SIGSTOP));
saveset = current->blocked;
siginitset(¤t->blocked, mask);
recalc_sigpending(current);
regs->eax = -EINTR;
while(1)
{
current->state = TASK_INTERRUPTIBLE;
schedule();
if(do_signal(regs, &saveset))
return -EINTR;
}
schedule()
选择另一个进程运行,当发出sigsuspend()
的进程又开始执行时,sys_sigsuspend()
调用do_signal()
传递唤醒了该进程的信号,返回值为 1 时,不忽略该信号,因此返回 -EINTR 出错码后终止。
实时信号的系统调用
实时信号的几个系统调用(rt_sigaction()
、rt_sigpending()
、rt_sigprocmask()
即rt_sigsuspend()
)与前面的描述类似。
rt_sigqueueinfo()
:发送一个实时信号以便把它加入到目标进程的共享信号队列中。一般通过标准库函数sigqueue()
调用rt_sigqueueinfo()
。
rt_sigtimedwait()
:把阻塞的挂起信号从队列中删除而不传递它,并向调用者返回信号编号;如果没有阻塞的信号挂起,就把当前进程挂起一个固定的时间间隔。一般通过标准库函数sigwaitinfo()
和sigtimedwait()
调用rt_sigtimedwait()
。
虚拟文件系统
在Linux下可以安装挂载很多格式的文件系统,之所以能实现就是通过虚拟文件系统这一中间系统层,虚拟文件系统所隐含的思想是把表示很多不同种类文件系统的共同信息放入内核;其中有一个字段或函数来支持Linux所支持的所有实际文件系统所提供的任何操作。
虚拟文件系统的作用
虚拟文件系统(Virtual Filesystem)也可以称之为虚拟文件系统转换(Virtual Filesystem Switch,VFS),是一个内核软件层,用来处理与Unix标准文件系统相关的所有系统调用。
VFS支持的文件系统可以划分为三种主要类型:
- 磁盘文件系统:这些文件系统管理在本地磁盘分区中可用的存储空间或者其他可以起到磁盘作用的设备(比如一个USB闪存)。
- VFS支持的基于磁盘的某些著名文件系统还有:
- Linux使用的文件系统,如广泛使用的第二扩展文件系统(Ext2),新近的第三扩展文件系统(Third Extended Filesystem, Ext3)及Reiser文件系统(ReiserFS)。
- Unix家族的文件系统。
- 微软公司的文件系统,如MS-DOS、VFAT及NTFS。
- ISO9660 CD-ROM文件系统(以前的High Sierra文件系统)和通用磁盘格式(UDF)的DVD文件系统。
- 其他有专利权的文件系统。HPFS,HFS,AFFS,ADFS
- 起源于非Linux系统的其他日志文件系统,如IBM的JFS和SGI的XFS。
- VFS支持的基于磁盘的某些著名文件系统还有:
- 网络文件系统:这些文件系统允许轻易地访问属于其他网络计算机的文件系统所包含的文件。
- 虚拟文件系统所支持的一些著名的网络文件系统有:NFS、Coda、AFS(Andrew文件系统)、CIFS以及NCP。
- 特殊文件系统:这些文件系统不管理本地或者远程磁盘空间。/proc文件系统是特殊文件系统的一个典型范例。
Unix的目录建立了一颗根目录为“/”的树。根目录包含在根文件系统(root filesystem)中,在Linux中这个根文件系统通常就是Ext2或Ext3类型。其他所有的文件系统都可以被安装在根文件系统的子目录中。
通用文件模型
VFS引入一个通用文件模型,能够表示所有支持的文件系统。由下列对象类型组成:
- 超级块对象(superblock object):存放已安装文件系统有关信息。对基于磁盘的文件系统,这类对象通常对应于存放在磁盘上的文件系统控制块(filesystem control block)。
- 索引节点对象(inode object):存放关于具体文件的一般信息。对基于磁盘的文件系统,这类对象通常对应于存放在磁盘上的文件控制块(file control block)。每个索引节点对象都有一个索引节点号,这个节点号唯一地标识文件系统中的文件。
- 文件对象(file object):存放打开文件与进程之间进行交互的有关信息。这类信息仅当进程访问文件期间存在于内核内存中。
- 目录项对象(dentry object):存放目录项(也就是文件的特定名称)与对应文件进行链接的有关信息。每个磁盘文件系统都以自己特有的方式将该类信息存在磁盘上。
下图是一个简单的实例,说明进程怎样与文件交互。三个不同的进程打开同一个文件,其中两个进程使用同一个硬链接。每个进程使用自己的文件对象,但只需要两个目录项对象,每个硬链接对应一个目录项对象。
VFS除了能为所有文件系统的实现提供一个通用接口外,还具有另一个与系统性能相关的重要作用。最近最常用的目录项被放在目录项高速缓存(dentry cache)的磁盘高速缓存中,以加速从文件路径名到最后一个路径分量的索引节点的转换过程。磁盘高速缓存属于软件机制,它允许内核将原本存在磁盘上的某些信息保存在RAM中,以便对这些数据的进一步访问能快速进行,而不必慢慢访问磁盘本身。
注意,磁盘高速缓存不同于硬件高速缓存或内存高速缓存,后两者都与磁盘或其他设备无关。硬件高速缓存是一个快速静态RAM,它加快了直接对慢速动态RAM的请求。内存高速缓存是一种软件机制,引入它是为了绕过内核内存分配器。
除了目录项高速缓存和索引节点高速缓存之外,Linux还使用其他磁盘高速缓存。其中最重要的一种就是所谓的页高速缓存。
VFS所处理的系统调用
可以把VFS看成通用文件系统,它在必要时依赖某种具体文件系统。
VFS的数据结构
每个VFS对象都存放在一个适当的数据结构中,其中包括对象的属性和指向对象方法表的指针。内核可以动态地修改对象的方法,因此可以为对象建立专用的行为。
超级块对象
超级块对象由super_block
结构组成:
所有超级块对象都以双向循环链表的形式链接在一起。链表中第一个元素用super_blocks
变量来表示,而超级块对象的s_list
字段存放指向链表相邻元素的指针。sb_lock
自旋锁保护链表免受多处理器系统上的同时访问。s_fs_info
指向属于具体文件系统的超级块信息。为了效率,由s_fs_info
字段所指向的数据被复制到内存,任何基于磁盘的文件系统都需要访问更改自己的磁盘分配位图,以便分配释放磁盘块,VFS支持文件系统对内存超级块的s_fs_info
进行操作,无需访问磁盘。这需要引入一个s_dirt
表示该超级块是否是脏的,磁盘上的数据是否需要更新。
与超级块关联的方法就是所谓的超级块操作。这些操作是由数据结构super_operations
来描述的,该结构的起始地址存放在超级块的s_op
字段中。
每个具体的文件系统都自定义超级块操作。当VFS需要调用其中一个操作,比如read_inode()
,执行下列操作:1
sb->s_op->read_inode(inode);
超级块操作实现了一些高级操作:
struct inode *(*alloc_inode)(struct super_block *sb);
:为索引节点对象分配空间,包括具体文件系统的数据所需要的空间。void (*destroy_inode)(struct inode *);
:撤销索引节点对象,包括具体文件系统的数据。void (*read_inode) (struct inode *);
:用磁盘上的数据填充以参数传递过来的索引节点对象的字段,索引节点对象的i_ino
字段标识从磁盘上要读取的具体文件系统的索引节点。void (*dirty_inode) (struct inode *);
:当索引节点标记为修改(脏)时调用。int (*write_inode) (struct inode *, int);
:用通过传递参数指定的索引节点对象的内容更新一个文件系统的索引节点。索引节点对象的i_ino
字段标识所涉及磁盘上文件系统的索引节点。flag
参数表示I/O操作是否应当同步。void (*put_inode) (struct inode *);
:释放索引节点时调用(减少该节点引用计数器值)以执行具体文件系统操作。void (*drop_inode) (struct inode *);
:在即将撤消索引节点时调用——也就是说, 当最后一个用户释放该索引节点时;实现该方法的文件系统通常使用generic_drop_inode()
函数。该函数从VFS数据结构中移走对索引节点的每一个引用, 如果索引节点不再出现在任何目录中,则调用超级块方法delete_inode
将它从文件系统中删除。void (*delete_inode) (struct inode *);
:在必须撤消索引节点时调用。删除内存中的VFS索引节点和磁盘上的文件数据及元数据。void (*put_super) (struct super_block *);
:释放通过传递的参数指定的超级块对象(因为相应的文件系统被卸载)。void (*write_super) (struct super_block *);
:用指定对象的内容更新文件系统的超级块。int (*sync_fs)(struct super_block *sb, int wait);
:在清除文件系统来更新磁盘上的具体文件系统数据结构时调用(由日志文件系统使用)。void (*write_super_lockfs) (struct super_block *);
:阻塞对文件系统的修改并用指定对象的内容更新超级块。当文件系统被冻结时调用该方法,例如,由逻辑卷管理器驱动程序(LVM)调用。void (*unlockfs) (struct super_block *);
:取消由write_super_lockfs()
超级块方法实现的对文件系统更新的阻塞。int (*statfs) (struct dentry *, struct kstatfs *);
:将文件系统的统计信息返回,填写在buf缓冲区中。int (*remount_fs) (struct super_block *, int *, char *);
:用新的选项重新安装文件系统(当某个安装选项必须被修改时被调用)。void (*clear_inode) (struct inode *);
:当撤消磁盘索引节点执行具体文件系统操作时调用。void (*umount_begin) (struct vfsmount *, int);
:中断一个安装操作,因为相应的卸载操作已经开始(只在网络文件系统中使用)。int (*show_options)(struct seq_file *, struct vfsmount *);
:用来显示特定文件系统的选项。int (*show_stats)(struct seq_file *, struct vfsmount *);
:用来显示特定文件系统的状态。ssize_t (*quota_read)(struct super_block *, int, char *, size_t, loff_t);
:限额系统使用该方法从文件中读取数据,该文件详细说明了所在文件系统的限制。ssize_t (*quota_write)(struct super_block *, int, const char *, size_t, loff_t);
:限额系统使用该方法将数据写入文件中,该文件详细说明了所在文件系统的限制。
索引节点对象
文件系统处理文件所需要的所有信息都放在一个名为索引节点的数据结构中。文件名可以随时更改,但是索引节点对文件是唯一的,并且随文件的存在而存在。内存中的索引节点对象由一个inode
数据结构组成,字段如图。
每个索引节点对象都会复制磁盘索引节点包含的一些数据,比如分配给文件的磁盘块数。如果i_state
字段的值等于I_DIRTY_SYNC
、I_DIRTY_DATASYNC
或I_DIRTY_PAGES
,那么该索引节点就是“脏”的,也就是说,对应的磁盘索引节点必须被更新。I_DIRTY
宏可以用来立即检查这三个标志的值。i_state
字段的其他值有I_LOCK
(涉及的索引节点对象处于I/O传送中)、I_FREEING
(索引节点对象正在被释放)、I_CLEAR
(索引节点对象的内容不再有意义)以及I_NEW
(索引节点对象已经分配但还没有用从磁盘索引节点读取来的数据填充)。
每个索引节点对象总是出现在下列双向循环链表的某个链表中(所有情况下,指向相邻元素的指针存放在i_list字段中):
- 有效未使用的索引节点链表,典型的如那些镜像有效的磁盘索引节点,且当前未被任何进程使用。这些索引节点不为脏,且它们的
i_count
字段置为0。链表中的首元素和尾元素是由变量inode_unused
的next
字段和prev
字段分别指向的。这个链表用作磁盘高速缓存。 - 正在使用的索引节点链表,也就是那些镜像有效的磁盘索引节点,且当前被某些进程使用。这些索引节点不为脏,但它们的
i_count
字段为正数。链表中的首元素和尾元素是由变量inode_in_use
引用的。 - 脏索引节点的链表。链表中的首元素和尾元素是由相应超级块对象的
s_dirty
字段引用的。
此外,每个索引节点对象也包含在每文件系统(per filesystem)的双向循环链表中,链表的头存放在超级块对象的s_inodes
字段中;索引节点对象的i_sb_list
字段存放了指向链表相邻元素的指针。
最后,索引节点对象也存放在一个称为inode_hashtable
的散列表中。散列表加快了对索引节点对象的搜索,前提是系统内核要知道索引节点号及文件所在文件系统对应的超级块对象的地址。由于散列技术可能引发冲突,所以索引节点对象包含一个i_hash
字段,该字段中包含向前和向后的两个指针,分别指向散列到同一地址的前一个索引节点和后一个索引节点;该字段因此创建了由这些索引节点组成的一个双向链表。
与索引节点对象关联的方法也叫索引节点操作。它们由inode_operations
结构来描述,该结构的地址存放在i_op
字段中:
int (*create) (struct inode *,struct dentry *,int, struct nameidata *);
:在某一目录下,为与目录项对象相关的普通文件创建一个新的磁盘索引节点。struct dentry * (*lookup) (struct inode *,struct dentry *, struct nameidata *);
:为包含在一个目录项对象中的文件名对应的索引节点查找目录。int (*link) (struct dentry *,struct inode *,struct dentry *new_dentry);
:创建一个新的名为new_dentry的硬链接,它指向dir目录下名为old_dentry的文件。int (*unlink) (struct inode *,struct dentry *);
:从一个目录中删除目录项对象所指定文件的硬链接。int (*symlink) (struct inode *,struct dentry *,const char *);
:在某个目录下,为与目录项对象相关的符号链接创建一个新的索引节点。int (*mkdir) (struct inode *,struct dentry *,int);
:在某个目录下,为与目录项对象相关的目录创建一个新的索引节点。int (*rmdir) (struct inode *,struct dentry *);
:从一个目录删除子目录,子目录的名称包含在目录项对象中。int (*mknod) (struct inode *,struct dentry *,int,dev_t);
:在某个目录中,为与目录项对象相关的特定文件创建一个新的磁盘索引节点。其中参数mode和rdev分别表示文件的类型和设备的主次设备号。int (*rename) (struct inode *old_dir, struct dentry *old_entry, struct inode *new_dir, struct dentry *new_dentry);
:将old_dir
目录下由old_entry
标识的文件移到new_dir
目录下。新文件名包含在new_dentry
指向的目录项对象中。int (*readlink) (struct dentry, char *buffer, int len);
:将目录项所指定的符号链接中对应的文件路径名拷贝到buffer
所指定的用户态内存区。void * (*follow_link) (struct inode *, struct nameidata *);
:解析索引节点对象所指定的符号链接;如果该符号链接是一个相对路径名,则从第二个参数所指定的目录开始进行查找。void (*put_link) (struct dentry *, struct nameidata *);
:释放由follow_link
方法分配的用于解析符号链接的所有临时数据结构。void (*truncate) (struct inode *);
:修改与索引节点相关的文件长度。在调用该方法之前,必须将inode
对象的i_size
字段设置为需要的新长度值。int (*permission) (struct inode *, int, struct nameidata *);
:检查是否允许对与索引节点所指的文件进行指定模式的访问。int (*setattr) (struct dentry *, struct iattr *);
:在触及索引节点属性后通知一个“修改事件”。int (*getattr) (struct vfsmount *mnt, struct dentry *, struct kstat *);
:由一些文件系统用于读取索引节点属性。int (*setxattr) (struct dentry *, const char *,const void *,size_t,int);
:为索引节点设置“扩展属性”(扩展属性存放在任何索引节点之外的磁盘块中)。ssize_t (*getxattr) (struct dentry *, const char *, void *, size_t);
:获取索引节点的扩展属性。ssize_t (*listxattr) (struct dentry *, char *, size_t);
:获取扩展属性名称的整个链表。int (*removexattr) (struct dentry *, const char *);
:删除索引节点的扩展属性。
文件对象
文件对象描述进程怎样与一个打开的文件进行交互。交互对象是在文件被打开时创建的,由一个file
结构组成。注意,文件对象在磁盘上没有对应的映像,因此file结构中没有设置“脏”字段来表示文件对象是否已被修改。
存放在文件对象中的主要信息是文件指针,即文件中当前的位置,下一个操作将在该位置发生。由于几个进程可能同时访问同一文件,因此文件指针必须存放在文件对象而不是索引节点对象中。
文件对象通过一个名为filp
的slab高速缓存分配,filp
描述符地址存放在filp_cachep
变量中。由于分配的文件对象数目是有限的,因此files_stat
变量在其max_files
字段中指定了可分配文件对象的最大数目,也就是系统可同时访问的最大文件数。
在使用文件对象包含在由具体文件系统的超级块所确立的几个链表中。每个超级块对象把文件对象链表的头存放在s_files
字段中;因此,属于不同文件系统的文件对象就包含在不同的链表中。链表中分别指向前一个元素和后一个元素的指针都存放在文件对象的f_list
字段中。files_lock
自旋锁保护超级块的s_files
链表免受多处理器系统上的同时访问。
文件对象的f_count
字段是一个引用计数器:它记录使用文件对象的进程数(记住,以CLONE_FILES
标志创建的轻量级进程共享打开文件表,因此它们可以使用相同的文件对象)。当内核本身使用该文件对象时也要增加计数器的值—例如,把对象插入链表中或发出dup()
系统调用时。
当VFS代表进程必须打开一个文件时,它调用get_empty_filp()
函数来分配一个新的文件对象。该函数调用kmem_cache_alloc()
从filp高速缓存中获得一个空闲的文件对像,然后初始化这个对象的字段,如下所示:1
2
3
4
5
6
7
8
9f = kmem_cache_alloc(filp_cachep, GFP_KERNEL);
percpu_counter_inc(&nr_files);
memset(f, 0, sizeof(*f));
tsk = current;
INIT_LIST_HEAD(&f->f_u.fu_list);
atomic_set(&f->f_count, 1);
rwlock_init(&f->f_owner.lock);
f->f_uid = tsk->fsuid;
f->f_gid = tsk->fsgid;
每个文件系统都有其自己的文件操作集合,执行诸如读写文件这样的操作。当内核将一个索引节点从磁盘装入内存时,就会把指向这些文件操作的指针存放在file_operations
结构中,而该结构的地址存放在该索引节点对象的i_fop
字段中。当进程打开这个文件时,VFS就用存放在索引节点中的这个地址初始化新文件对象的f_op
字段,使得对文件操作的后续调用能够使用这些函数。如果需要,VFS随后也可以通过在f_op
字段存放一个新值而修改文件操作的集合。
下面的列表描述了文件的操作:
struct module *owner;
:指向一个模块的拥有者,该字段主要应用于那些有模块产生的文件系统loff_t (*llseek) (file, offset, origin);
:更新文件指针。ssize_t (*read) (file, buf, count, offset);
:从文件的*offset
处开始读出count
个字节;然后增加*offset
的值(一般与文件指针对应)。ssize_t (*aio_read) (req, buf, len, pos);
:启动一个异步I/O操作,从文件的pos
处开始读出len
个字节的数据并将它们放入buf
中(引入它是为了支持io_submit()
系统调用)。ssize_t (*write) (file, buf, count, offset);
:从文件的*offset
处开始写入count
个字节,然后增加*offset
的值(一般与文件指针对应)。ssize_t (*aio_write) (req, buf, len, pos);
:启动一个异步I/O操作,从buf
中取len
个字节写入文件pos
处。int (*readdir) (dir, dirent, filldir);
:返回一个目录的下一个目录项,返回值存人参数dirent
;参数filldir
存放一个辅助函数的地址,该函数可以提取目录项的各个字段。unsigned int (*poll) (file, poll_table);
:检查是否在一个文件上有操作发生,如果没有则睡眠,直到该文件上有操作发生。int (*ioctl) (inode, file, cmd, args);
:向一个基本硬件设备发送命令。该方法只适用于设备文件。long (*unlocked_ioctl) (file, cmd, args);
:与ioctl
方法类似,但是它不用获得大内核锁。我们认为所有的设备驱动程序和文件系统都将使用这个新方法而不是loctl
方法。long (*compat_ioctl) (file, cmd, args);
:64位的内核使用该方法执行32位的系统调用ioctl()
。int (*mmap) (file, vm_area_struct);
:执行文件的内存映射,并将映射放入进程的地址空间。int (*open) (inode, file);
:通过创建一个新的文件对象而打开一个文件,并把它链接到相应的索引节点对象。int (*flush) (file);
:当打开文件的引用被关闭时调用该方法。该方法的实际用途取决于文件系统。int (*release) (inode, file);
:释放文件对象。当打开文件的最后一个引用被关闭时(即文件对象f_count字段的值变为0时)调用该方法。int (*fsync) (file, dentry, datasync);
:将文件所缓存的全部数据写入磁盘。int (*aio_fsync) (req, datasync);
:启动一次异步I/O刷新操作。int (*fasync) (fd, file, on);
:通过信号来启用或禁止I/O事件通告。int (*lock) (file, cmd, file_lock);
:对file文件申请一个锁。ssize_t (*readv) (file, vector, count, offset);
:从文件中读字节,并把结果放入vector
描述的缓冲区中;缓冲区的个数由count
指定。ssize_t (*writev) (file, vector, count, offset);
:把vector描述的缓冲区中的字节写人文件;缓冲区的个数由count
指定。ssize_t (*sendfile) (in_file, count, offset, read_actor_t, out_file);
:把数据从in_file
传送到out_file
(引人它是为了支持sendfile()
系统调用)。ssize_t (*sendpage) (file, page, offset, size, pointer, fill);
:把数据从文件传送到页高速缓存的页;这个低层方法由sendfile()
和用于套接字的网络代码使用。unsigned long (*get_unmapped_area)(file, addr, len, offset, flag);
:获得一个未用的地址范围来映射文件。int (*check_flags)(flags);
:当设置文件的状态标志(F_SETFL
命令)时,fcntl()
系统调用的服务例程调用该方法执行附加的检查。当前只适用于NFS网络文件系统。int (*dir_notify)(file, arg);
:当建立一个目录更改通告(F_NOTIFY
命令)时,由fcntl()
系统调用的服务例程调用该方法。当前只适用于CIFS(Common Internet File system,公用互联网文件系统)网络文件系统。int (*flock) (file, falg, file_lock);
:用于定制flock()
系统调用的行为。官方Linux文件系统不使用该方法。*/
目录项对象
VFS把每个目录看作由若干子目录和文件组成的一个普通文件。然而目录项不同,一旦目录项被读入内存,VFS就把它转换成基于dentry结构的一个目录项对象。对于进程查找的路径名中的每个分量,内核都为其创建一个目录项对象;目录项对象将每个分量与其对应的索引节点相联系。
请注意,目录项对象在磁盘上并没有对应的映像,因此在dentry结构中不包含指出该对象已被修改的字段。目录项对象存放在名为dentry_cache
的slab分配器高速缓存中。因此,目录项对象的创建和删除是通过调用kmem_cache_alloc()
和kmem_cache_free()
实现的。
每个目录项对象可以处于以下四种状态之一:
- 空闲状态(free):处于该状态的目录项对象不包括有效的信息,且还没有被VFS使用。对应的内存区由slab分配器进行处理。
- 未使用状态(unused):处于该状态的目录项对象当前还没有被内核使用。该对象的引用计数器
d_count
的值为0,但其d_inode
字段仍然指向关联的索引节点。该目录项对象包含有效的信息,但为了在必要时回收内存,它的内容可能被丢弃。 - 正在使用状态(in use):处于该状态的目录项对象当前正在被内核使用。该对象的引用计数器
d_count
的值为正数,其d_inode
字段指向关联的索引节点对象。该目录项对象包含有效的信息,并且不能被丢弃。 - 负状态(negative):与目录项关联的索引节点不复存在,那是因为相应的磁盘索引节点已被删除,或者因为目录项对象是通过解析一个不存在文件的路径名创建的。目录项对象的
d_inode
字段被置为NULL,但该对象仍然被保存在目录项高速缓存中,以便后续对同一文件目录名的查找操作能够快速完成。术语“负状态”容易使人误解,因为根本不涉及任何负值。
与目录项对象关联的方法称为目录项操作。这些方法由dentry_operations
结构加以描述,该结构的地址存放在目录项对象的d_op
字段中。尽管一些文件系统定义了它们自己的目录项方法,但是这些字段通常为NULL,而VFS使用缺省函数代替这些方法:
int (*d_revalidate)(struct dentry *, struct nameidata *);
:在把目录项对象转换为一个文件路径名之前,判定该目录项对象是否仍然有效。缺省的VFS函数什么也不做,而网络文件系统可以指定自己的函数。int (*d_hash) (struct dentry *, struct qstr *);
:生成一个散列值;这是用于目录项散列表的、特定干具体文件系统的散列函数。参数dentry标识包含路径分量的目录。参数name指向一个结构,该结构包含要查找的路径名分量以及由散列函数生成的散列值。int (*d_compare) (struct dentry *, struct qstr *, struct qstr *);
:比较两个文件名。name1应该属于dir所指的目录。缺省的VFS函数是常用的字符串匹配函数。不过,每个文件系统可用自己的方式实现这一方法。例如,MS.DOS文件系统不区分大写和小写字母。int (*d_delete)(struct dentry *);
:当对目录项对象的最后一个引用被删除(d_count
变为“0”)时,调用该方法。缺省的VFS函数什么也不做。void (*d_release)(struct dentry *);
:当要释放一个目录项对象时(放入slab分配器),调用该方法。缺省的VFS函数什么也不做。void (*d_iput)(struct dentry *, struct inode *);
:当一个目录项对象变为“负”状态(即丢弃它的索引节点)时,调用该方法。缺省的VFS函数调用iput()
释放索引节点对象。
};
目录项高速缓存
为了最大限度地提高处理同一个文件需要被反复访问的这些目录项对象的效率,Linux使用目录项高速缓存,它由两种类型的数据结构组成:
- 一个处于正在使用、未使用或负状态的目录项对象的集合。
- 一个散列表,从中能够快速获取与给定的文件名和目录名对应的目录项对象。同样,如果访问的对象不在目录项高速缓存中,则散列表函数返回一个空值。
目录项高速缓存的作用还相当于索引节点高速缓存(inode cache)的控制器。在内核内存中,并不丢弃与未用目录项相关的索引节点,这是由于目录项高速缓存仍在使用它们。因此,这些索引节点对象保存在RAM中,并能够借助相应的目录项快速引用它们。
所有“未使用”目录项对象都存放在一个最近最少使用(Least Recently used, LRU)的双向链表中,该链表按照插入的时间顺序。换句话说,最后释放的目录项对象放在链表的首部,所以最近最少使用的目录项对象总是靠近链表的尾部。一旦目录项高速缓存的空间开始变小,内核就从链表的尾部删除元素,使得最近最常使用的对象得以保留。
LRU链表的首元素和尾元素地址存放在list_head
类型的dentry_unused
变量的next
和prev
字段中。目录项对象的d_lru
字段包含指向链表中相邻目录项的指针。
每个正在使用的目录项对象都被插入一个双向链表中,该链表由相应索引节点对象的i_dentry
字段指向。目录项对象的d_alias
字段存放链表中相邻元素的地址。指向相应文件的最后一个硬链接被删除之后,一个正在使用的目录项可能会变成负状态。这时该目录项对象被移动到未使用目录项对象组成的LRU链表中。这些对象就逐渐被释放。
与进程相关的文件
每个进程都有它自己当前的工作目录和它自己的根目录。这仅仅是内核用来表示进程与文件系统相互作用所必须维护的数据中的两个例子。类型为fs_struct
的整个数据结构就用于此目的,且每个进程描述符的fs
字段就指向进程的fs_struct
结构。
第二个表表示进程当前打开的文件,表的地址存放于进程描述符的files
字段,类型为files_struct
。
fd
字段指向文件对象的指针数组。该数组的长度存放在max_fds
字段中。通常,fd
字段指向files_struct
结构的fd_array
字段,该字段包括32个文件对象指针。如果进程打开的文件数目多余32,内核就分配一个新的、更大的文件指针数组,并将其地址存放在fd
字段中,内核同时也更新max_fds
字段的值。
对于在fd数组中有元素的每个文件来说,数组的索引就是文件描述符(file descriptor)。通常,数组的第一个元素(索引为0)是进程的标准输入文件,数组的第二个元素(索引为1)是进程的标准输出文件,数组的第三个元素(索引为2)是进程的标准错误文件。两个文件描述符可以指向同一个文件。进程不能使用多于NR_OPEN
个文件描述符。内核也在进程描述符的signal->rlim[RLIMIT_NOFILE]
结构上强制动态限制文件描述符的最大数。
open_fds
字段最初包含open_fds_init
的地址,open_fds_init
表示当前已打开文件的文件描述符的位图。max_fdset
存放位图中的位数。当内核开始使用一个文件对象时,内核提供fget()
函数。这个函数接收文件描述符fd
作为参数,返回在current->files->fd[fd]
中的地址,即对应文件对象的地址,如果没有任何文件与fd对应,则返回NULL。
当内核控制路径完成对文件对象的使用时,调用内核提供的fput()
函数。该函数将文件对象的地址作为参数,并递减文件对象引用计数器f_count
的值,另外,如果这个域变为0,该函数就调用文件操作的release方法(如果已定义),释放相应的目录项对象,并递减对应索引节点对象的i_write域的值(如果该文件是写打开),最后,将该文件对象从“正在使用”链表移到“未使用”链表。
文件系统类型
文件系统注册——也就是通常在系统初始化期间并且在使用文件系统类型之前必须执行的基本操作。一旦文件系统被注册,其特定的函数对内核就是可用的,因此文件系统类型可以安装在系统的目录树上。
特殊文件系统
特殊文件系统为系统程序员和管理员提供一种容易的方式来操作内核的数据结构并实现系统的特殊特征。常用特殊文件系统如表,有几个文件系统没有固定的安装点,可以自由安装使用。一些文件系统不是用于与用户交互,所以没有安装点。
特殊文件系统不限于物理设备。然而,内核给每个安装的特殊文件系统分配一个虚拟的块设备,让其主设备号是0,而次设备号具有任意值(每个特定文件系统具有不同的值)。set_anon_super()
函数用于初始化特殊文件系统的超级块;该函数本质上获得一个未使用的次设备号dev
,然后用主设备号0和次设备号dev
设置新超级块的s_dev
字段。而另一个kill_anon_super()
函数移走特殊文件系统的超级块。unnamed_dev_ida
变量包含一个辅助结构(记录当前在用的次设备号)的指针。
文件系统类型注册
文件系统的源代码实际上要么包含在内核映像中,要么作为一个模块被动态装入。VFS必须对代码目前已经在内核中的所有文件系统的类型进行跟踪,这是通过进行文件系统类型注册实现的。注册的文件系统用file_system_type
类型表示。
所有文件系统类型的对象都插入到一个单向链表中。由变量file_systems
指向链表的第一个元素,而结构中的next
字段指向链表的下一个元素。file_systems_lock
读/写自旋锁保护整个链表免受同时访问。fs_supers
表示给定类型的已安装文件系统所对应的超级块链表的头。链表元素的向后和向前链接存放在超级块对象的s_instances
字段中。get_sb
指向依赖于文件系统类型的函数,该函数分配一个新的超级块对象并初始化。kill_sb
指向删除超级块的函数。fs_flags
存放几个标志:
在系统初始化期间,调用register_filesystem()
函数来注册编译时指定的每个文件系统;该函数把相应的file_system_type
对象插入到文件系统类型的链表中。当实现了文件系统的模块被装入时,也要调用register_filesystem()
函数。在这种情况下,当该模块被卸载时,对应的文件系统也可以被注销(调用unregister_filesystem()
函数)。
get_fs_type()
函数扫描已注册的文件系统链表以查找文件系统类型的name
字段,并返回指向相应的file_system_type
对象的指针。
文件系统处理
就像每个传统的Unix系统一样,Linux也使用系统的根文件系统(system’s root filesystem):它由内核在引导阶段直接安装,并拥有系统初始化脚本以及最基本的系统程序。
其他文件系统要么由初始化脚本安装,要么由用户直接安装在已安装文件系统的目录上。作为一个目录树,每个文件系统都拥有自己的根目录(root directory)。安装文件系统的这个目录称之为安装点(mount point)。已安装文件系统属于安装点目录的一个子文件系统。例如,/proc
虚拟文件系统是系统的根文件系统的孩子(且系统的根文件系统是/proc
的父亲)。已安装文件系统的根目录隐藏了父文件系统的安装点目录原来的内容,而且父文件系统的整个子树位于安装点之下。
命名空间
在传统的Unix系统中,只有一个已安装文件系统树:从系统的根文件系统开始,每个进程通过指定合适的路径名可以访问已安装文件系统中的任何文件。从这个方面考虑,Linux2.6更加的精确:每个进程可拥有自己的已安装文件系统树——叫做进程的命令空间(namespace)。
通常大多数进程共享同一个命名空间,即位于系统的根文件系统且被init进程使用的已安装文件系统树。不过,如果clone()
系统调用以CLONE_NEWNS
标志创建一个新进程,那么进程将获取一个新的命名空间。这个新的命名空间随后由子进程继承(如果父进程没有以CLONE_NEWNS
标志创建这些子进程)。
当进程安装或卸载一个文件系统时,仅修改它的命名空间。因此,所做的修改对共享同一命名空间的所有进程都是可见的,并且也只对它们可见。进程甚至可通过使用Linux特有的pivot_root()
系统调用来改变它的命名空间的根文件系统。
进程的命名空间由进程描述符的namespace
字段指向的namespace
结构描述。root
表示已安装文件系统。
文件系统安装
当一个文件系统被安装了n次时,它的根目录可通过n个安装点访问。不管一个文件系统被安装了多少次,都仅有一个超级块对象。
安装的文件系统形成一个层次:一个文件系统的安装点可能成为第二个文件系统的目录,而第二个文件系统又安装在第三个文件系统之上。
把多个安装堆叠在一个单独的安装上也是可能的。尽管已经使用先前安装下的文件和目录的进程可以继续使用,但在同一安装点上的新安装隐藏前一个安装的文件系统。当最顶层的安装被删除时,下一层的安装再一次变为可见的。
对每个安装操作,内核必须在内存中保存安装点和安装标志,以及要安装文件系统与其他已安装文件系统之间的关系。每个描述符是一个具有vfsmount
类型的数据结构。
vfsmount
结构会被保存在几个双向循环链表中:
- 由父文件系统
vfsmount
描述符的地址和挂载点目录的目录项对象的地址索引的散列表。散列表存放在mount_hashtable
数组(fs/namespace.c
,static struct list_head *mount_hashtable __read_mostly;
)中,其大小取决于系统中RAM的容量。表中每一项是具有同一散列值的所有描述符形成的双向循环链表的头。描述符的mnt_hash
字段包含指向链表中相邻元素的指针。 - 对于每一个命名空间,所有属于此命名空间的已挂载文件系统描述符形成了一个双向链表。
namespace
结构的list
字段存放链表的头,vfsmount
描述符的mnt_list
字段包含链表中指向相邻元素的指针。 - 对于每一个已安装的文件系统,所有已安装的子文件系统形成了一个双向循环链表。链表的头存放在
vfsmount
描述符的mnt_mounts
字段;子描述符的mnt_child
字段存放指向链表中相邻元素的指针。
vfsmount_lock
自旋锁(fs/namespace.c
, __cacheline_aligned_in_smp DEFINE_SPINLOCK(vfsmount_lock);
)保护vfsmount
对象的链表免受同时访问。
描述符的mnt_flags
字段存放几个标志的值,用以指定如何处理已安装文件系统中的某些种类的文件。这些标志可通过mount
命令的选项进行设置。所有的标志定义如下:
MNT_NOSUID
:在已安装的文件系统中禁止setuid
和setgid
标志MNT_NODEV
:在已安装的文件系统中禁止访问设备文件MNT_NOEXEC
:在已安装文件系统中禁止程序执行
内核提供了几个用以处理vfsmount
描述符的函数:
struct vfsmount *alloc_vfsmnt(const char *name)
:分配和初始化一个vfsmount
描述符void free_vfsmnt(struct vfsmount *mnt)
:释放由mnt
指向的vfsmount
描述符struct vfsmount *lookup_mnt(struct path *path)
:在散列表中查找一个vfsmount
并返回它的地址
安装普通文件系统
mount()
系统调用被用来安装一个普通文件系统;它的服务例程sys_mount()
作用于以下参数:
- 文件系统所在的设备文件的路径名,或者如果不需要的话就为NULL。
- 文件系统被安装其上的某个目录的目录路径名(安装点)。
- 文件系统的类型,必须是已注册文件系统的名字。
- 安装标志(见表格)。
- 指向一个与文件系统相关的数据结构的指针(也许为NULL)。
sys_mount()
函数把参数拷贝到临时内核缓冲区,获取大内核锁,并调用do_mount()
函数。一旦do_mount()
返回,释放大内核锁并释放临时缓冲区。
do_mount()步骤:
- 检查安装标志
MS_NOSUID
,MS_NODEV
或MS_NOEXEC
,并在vfsmount
对象中设置相应标志 - 调用
path_lookup()
查找安装点路径名 - 检查安装标志:
- 如果
MS_REMOUNT
,其目的是改变超级块对象s_flags
的安装标志,以及已安装文件系统对象mnt_flags
的安装文件系统标志。do_remount()
执行这些改变; - 否则检查
MS_BIND
,用户要求在系统目录树的另一个安装点上的文件或目录能够可见 - 否则检查
MS_MOVE
,用户要求改变安装点,do_move_mount()
- 否则,
do_new_mount()
,当用户安装一个特殊文件系统或存放在磁盘分区中的普通文件系统时触发该函数。它调用do_kern_mount()
。它出击时机的安装操作并返回一个新安装文件系统描述符的地址。然后do_new_mount()
调用do_add_mount()
,后者执行:- 获得当前进程的写信号量
namespace->sem
- 验证在安装点上最近安装的文件系统是否仍指向当前的namespace,不是释放sem,返回错误码
- 如果要安装的文件系统已经被安装在由系统调用的参数所指定的安装点上,或该安装点是一个符号链接,则释放sem,返回错误码
- 初始化
do_kernel_mount()
分配的新的安装文件系统对象的mnt_flags
字段的标志 - 调用
graft_tree()
把新安装的文件系统对象插入到namespace
链表、散列表以及父文件系统的子链表中 - 释放sem,返回。
- 获得当前进程的写信号量
- 如果
- 调用
path_release()
终止安装点路径名查找,并返回0.
do_kern_mount()函数:
安装操作的核心是do_kern_mount()
,他检查文件系统类型以决定安装操作是如何完成的。参数:
fstype
:要安装文件系统类型名flags
:安装标志name
:存放文件系统的块设备路径名data
:指向传递给文件系统read_super
方法的附加数据指针
该函数通过下列操作实现安装:
- 调用
get_fs_type()
在文件系统列表中搜索并确定放在fstype
参数中的名字的位置;返回局部变量type
中对应的file_system_type
描述符的位置 - 调用
alloc_vfsmnt()
分配vfsmount
描述符,并给mnt
局部变量赋值 - 调用
type->get_sb()
分配并初始化一个新的超级块 - 用新的超级块给
mnt->mnt-sb
赋值 - 将
mnt->mnt_root
字段初始化为与文件系统根目录对应的目录项对象的地址,存在于super_block
中的s_root
字段中,并增加该目录项对象引用计数器值。 - 用
mnt
中的值初始化mnt->mnt_parent
字段, - 用
current->namespace
初始化mnt->mnt_namespace
- 释放超级块的读写信号量
s_unmont
- 返回已安装文件系统对象的地址
mnt
分配超级块对象
文件系统对象的get_sb
方法由单行函数实现的。
get_sb_bdev()
VFS函数分配并初始化一个新的适合于磁盘文件系统的超级块;get_sb_pseudo()
针对没有安装点的特殊文件系统如pipefs;get_sb_single()
针对具有唯一安装点的特殊文件系统如sysfs;get_sb_nodev()
针对可以安装多次的特殊文件系统如tmpfs
get_sb_bdev()
步骤:
- 调用
open_bdev_excl()
打开设备文件名为dev_name
的块设备 - 调用
sget()
搜索文件系统的超级块链表(type->fs_supers
)。如果找到一个与块设备相关的超级块,则返回它的地址;否则分配并初始化一个新的超级块,将其插入到文件系统链表和超级块全局链表中,并返回其地址 - 如果不是新的超级块,则跳6
- 把参数
flags
中的值拷贝到超级块的s_flags
中,并将s_id
、s_old_blocksize
以及s_blocksize
字段置为块设备的合适值 - 调用以来文件系统的函数访问磁盘上超级块的信息,并填充新超级块对象的其他字段
- 返回新超级块对象的地址
安装根文件系统
安装根文件系统是系统初始化的关键部分。当编译内核时,或者向最初的启动装入程序传递一个合适的“root”选项时,根文件系统可以被指定为/dev
目录下的一个设备文件。类似地,根文件系统的安装标志存放在root_mountflags
变量中。用户可以指定这些标志,或者通过对已编译的内核映像使用rdev
外部程序,或者向最初的启动装入程序传递一个合适的rootflags
选项来达到。
安装根文件系统分为两个阶段:
- 内核安装特殊rootfs文件系统,该文件系统仅提供一个作为初始安装点的空目录。
- 内核在空目录上安装实际的根文件系统。
rootfs文件系统允许内核容易地改变实际根文件系统。实际上,在大多数情况下,系统初始化是内核会逐个地安装和卸载几个根文件系统。
阶段1:安装rootfs文件系统
第一阶段由init_rootfs()
和init_mount_tree()
函数完成,他们在系统初始化过程中执行。
init_rootfs()
函数注册特殊文件系统类型rootfs:1
2
3
4
5struct file_system_type rootfs_fs_type = {
.name = "rootfs";
.get_sb = rootfs_get_sb;
.kill_sb = kill_litter_super;
};
init_mount_tree()
执行如下操作:
- 调用
do_kern_mount()
函数,把字符串”rootfs”作为文件系统类型和设备名参数传递给它,把该函数返回的新安装文件系统描述符的地址保存在mnt
局部变量中。如前面所说,do_kern_mount()
调用get_sb
,也就是get_fs_type()
。get_sb_nodev()
则执行操作:- 调用
sget()
函数分配新的超级块,传递set_anon_super()
函数的地址作为参数,用合适的方式设置超级块的s_dev
字段,主设备号为0,次设备号不同于其他已安装的特殊文件系统的次设备号。 - 将
flags
参数的值拷贝到超级块的s_flags
字段中 - 调用
ramfs_fill_super()
函数分配索引节点对象和对应的目录项对象,并填充超级块字段值。 - 返回新超级块的地址
- 为进程0的已挂载文件系统命名空间分配一个
namespace
对象,并将它插入到由do_kern_mount()
函数返回的已安装文件系统描述符中 - 将系统中其他每个进程的
namespace
字段设置为namespace
对象的地址;同时初始化引用计数器namespace->count
- 将进程0的根目录和当前工作目录设置为根文件系统。
安装实际根文件系统
根文件系统安装操作的第二阶段是由内核在系统初始化即将结束时进行的。根据内核被编译时所选择的选项,和内核装入程序所传递的启动选项,可以有几种方法安装实际根文件系统。为了简单起见,我们只考虑磁盘文件系统的情况,它的设备文件名已通过“root”启动参数传递给内核。同时我们假定除了 rootfs文件系统外,没有使用其他初始特殊文件系统。
prepare_namespace()
函数执行如下操作:
- 将
root_device_name
变量置为从启动参数“root”中获取的设备文件名。同样把ROOT_DEV
变量置为同一设备文件的主设备号和次设备号 - 调用
mount_root()
函数,依次执行如下操作:- 调用
sys_mknod()
在rootfs初始化根文件系统中创建设备文件/dev/root,其主次设备号与存放在ROOT_DEV
中的一样。 - 分配一个缓冲区并用文件系统类型名链表填充它。该链表要么通过启动参数“rootfstype”传送给内核,要么通过扫描文件系统类型单向链表中的元素建立
- 扫描上一步建立的文件系统类型名链表,对每个名字,调用
sys_mount()
试图在根设备上安装给定的文件系统类型。 - 调用
sys_chdir("/root")
改变进程的当前目录
- 调用
- 移动rootfs文件系统根目录上的已安装文件系统的安装点
sys_mount(".", "/", NULL, MS_MOVE, NULL)
卸载文件系统
umount()
系统调用用来卸载一个文件系统。相应的sys_umount()
服务例程作用于两个参数:文件名(多是安装点目录或是块设备文件名)和一组标志。
- 调用
path_lookup()
查找安装点的路径名,将结果存放在nameidata
类型的局部变量nd
中; - 如果查找的最终目录不是文件系统的安装点,则设置
retval
返回码为-EINVAL并跳到第6步。这种检查是通过验证nd->mnt->mnt_root
(它包含由nd.dentry
指向的目录项对象地址)进行的。 - 如果要卸载的文件系统还没有安装在命名空间中,则设置
retval
返回码为-EINVAL并跳到第6步。这种检查是通过在nd->mnt
上调用check_mnt()
函数进行的。 - 如果用户不具有卸载文件系统的特权,则设置
retval
返回码为-EPERM并跳到第6步。 - 调用
do_umount()
,传递给它的参数为nd.mnt
(已安装文件系统对象)和flags
(一组标志)。该函数执行下列操作:- 从已安装文件系统对象的
mnt_sb
字段检索超级块对象sb
的地址。 - 如果用户要求强制卸载操作,则调用
umount_begin
超级块操作中断任何正在进行的安装操作。 - 如果要卸载的文件系统是根文件系统,且用户并不要求真正地把它卸载下来则调用
do_remount_sb()
重新安装根文件系统为只读并终止。 - 为进行写操作而获取当前进程的
namespace->sem
读/写信号量和vfsmount_lock
自旋锁。 - 如果已安装文件系统不包含任何子安装文件系统的安装点,或者用户要求强制卸载文件系统,则调用
umount_tree()
卸载文件系统(及其所有子文件系统) - 释放
vfsmount_lock
自旋锁和当前进程的namespace->sem
读/写信号量。
- 从已安装文件系统对象的
- 减少相应文件系统根目录的目录项对象和已安装文件系统描述符的引用计数器值;这些计数器值由
path_lookup()
增加。 - 返回
retval
的值。
路径名查找
当进程必须识别一个文件时,就把它的文件路径名传递给某个VFS系统调用,如open()
、mkdir()
、rename()
或stat()
。
执行这一任务的标准过程就是分析路径名并把它拆分成一个文件名序列除了最后一个文件名以外,所有的文件名都必定是目录。
如果路径名的第一个字符是“/”,那么这个路径名是绝对路径,因此从current->fs->root
(进程的根目录)所标识的目录开始搜索。否则,路径名是相对路径,因此从current->fs->pwd
(进程的当前目录)所标识的目录开始搜索。
在对初始目录的索引节点进行处理的过程中,代码要检查与第一个名字匹配的目录项,以获得相应的索引节点。然后,从磁盘读出包含那个索引节点的目录文件,并检查与第二个名字匹配的目录项,以获得相应的索引节点。对于包含在路径中的每个名字,这个过程反复执行。
目录项高速缓存极大地加速了这一过程,因为它把最近最常使用的目录项对象保留在内存中。正如我们以前看到的,每个这样的对象使特定目录中的一个文件名与它相应的索引节点相联系,因此在很多情况下,路径名的分析可以避免从磁盘读取中间目录。
但是,事情并不像看起来那么简单,因为必须考虑如下的Unix和VFS文件系统的特点:
- 对每个目录的访问权必须进行检查,以验证是否允许进程读取这一目录的内容。
- 文件名可能是与任意一个路径名对应的符号链接;在这种情况下,分析必须扩展到那个路径名的所有分量。
- 符号链接可能导致循环引用;内核必须考虑这个可能性,并能在出现这种情况时将循环终止。
- 文件名可能是一个已安装文件系统的安装点。这种情况必须检测到,这样,查找操作必须延伸到新的文件系统。
- 路径名查找应该在发出系统调用的进程的命名空间中完成。由具有不同命名空间的两个进程使用的相同路径名,可能指定了不同的文件。
路径名查找是由path_lookup()
函数执行的,它接收三个参数:
name
:指向要解析的文件路径名的指针。flags
:标志的值,表示将会怎样访问查找的文件。nd
:nameidata
数据结构的地址,这个结构存放了查找操作的结果。
path_lookup
返回时,nd
指向的nameidata
结构用与路径名查找操作有关的数据来填充/
dentry
和mnt
字段分别指向所解析的最后一个路径分量的目录项对象和已安装文件系统对象。这两个字段“描述”由给定路径名表示的文件。
由于path_lookup()
函数返回的nameidata
结构中的目录项对象和已安装文件系统对象代表了查找操作的结果,因此在path_lookup()
的调用者完成使用查找结果之前,这个两个对象都不能被释放。因此,path_lookup()
增加这两个对象引用计数器的值。如果调用者想释放这些对象,则调用path_release()
函数,传递给它的参数就是nameidata
结构的地址。
flags
字段存放查找操作中使用的某些标志的值,这些标志中的大部分可由调用者在path_lookup()
的flags
参数中进行设置:
path_lookup()
执行下列步骤:
- 首先,如下初始化nd参数的某些字段:
nd->last_type = LAST_ROOT;
- nd->flags = flags;
- nd->depth = 0;
- 为进行读操作而获取当前进程的
current->fs->lock
读写信号量 - 如果路径名的第一个字符是“/”,那么查找操作必须从当前根目录开始:获取相应已安装文件对象(
current->fs->rootmnt
)和目录项对象(current->fs->root
)的地址,增加引用计数器的值,并把它们的地址分别存放在nd->mnt
和nd->dentry
中。 - 否则,如果路径名的第一个字符不是“/”,则查找操作必须从当前工作目录开始:获得相应已安装文件系统对象(
current->fs->mt
)和目录项对象(current->fs->pwd
)的地址,增加引用计数器的值,并把它们的地址分别存放在nd->mnt
和nd->dentry
中。 - 释放当前进程的
current->fs->lock
读写信号量 - 把当前进程描述符中的
total_link_count
字段置为0 - 调用
link_path_walk()
函数处理真正进行的查找操作:retval = link_path_walk(name, nd);
我们现在准备描述路径名查找操作的核心,也就是link_path_walk()
函数。它接收的参数为要解析的路径名指针name
和拥有目录项信息和安装文件系统信息的nameidata
数据结构的地址nd
。
标准路径名查找
当LOOKUP_PARENT
标志被清零时,link_path_walk()
执行下列步骤:
- 用
nd->flags
和nd->dentry->d_inode
分别初始化lookup_flags
和inode
局部变量。 - 跳过路径名第一个分量前的任何斜杠(/)。
- 如果剩余的路径名为空,则返回0。在
nameidata
数据结构中,dentry
和mnt
字段指向原路径名最后一个所解析分量对应的对象。 - 如果
nd
描述符中的depth
字段的值为正(大于0),则把lookup_flags
局部变量置为LOOKUP_FOLLOW
标志(这个跟符号链接查找相关)。 - 执行一个循环,把
name
参数中传递的路径名分解为分量(中间的“/”被当做文件名分隔符对待);对于每个找到的分量,该函数:- 从
nd->dentry->d_inode
检索最近一个所解析分量的索引节点对象的地址 - 检查存放到索引节点中的最近那个所解析分量的许可权是否允许执行(在Unix中,只有目录是可执行的,它才可以被遍历)。执行
exec_permission_lite()
函数,该函数检查存放在索引节点i_mode
字段的访问模式和运行进程的特权。在两种情况中,如果最近所解析分量不允许执行,那么link_path_walk()
跳出循环并返回一个错误码: - 考虑要解析的下一个分量。从它的名字,函数为目录项高速缓存散列表计算一个32位的散列值
- 如果“/”终止了要解析的分量名,则跳过“/”之后的任何尾部“/”
- 如果要解析的分量是原路径名中的最后一个分量,则跳到第6步
- 如果分量名是一个“.”(单个圆点),则继续下一个分量(“.”指的是当前目录,因此,这个点在目录内没有什么效果)
- 如果分量名是“..”(两个圆点),则尝试回到父目录:
- 如果最近解析的目录是进程的根目录(
nd->dentry
等于curren->fs->root
,而nd->mnt
等于current->fs->rootmnt
),那么再向上追踪是不允许的:在最近解析的分量上调用follow_mount()
(见下面),继续下一个分量。 - 如果最近解析的目录是
nd->mnt
文件系统的根目录(nd->dentry
等于nd->mnt->mnt_root
),并且这个文件系统也没有被安装在其他文件系统之上(nd->mnt
等于nd->mnt->mnt_parent
),那么nd->mnt
文件系统通常就是命名空间的根文件系统:在这种情况下,再向上追踪是不可能的,因此在最近解析的分量上调用follow_mount()
(参见下面),继续下一个分量。 - 如果最近解析的目录是
nd->mnt
文件系统的根目录,而这个文件系统被安装在其他文件系统之上,那么就需要文件系统交换。因此,把nd->dentry
置为nd->mnt->mnt_mountpoint
,且把nd->mnt
置为nd->mnt->mnt_parent
,然后重新开始第5g步(回想一下,几个文件系统可以安装在同一个安装点上)。 - 如果最近解析的目录不是已安装文件系统的根目录,那么必须回到父目录:把
nd->dentry
置为nd->dentry->d_parent
,在父目录上调用follow_mount()
,继续下一个分量。 follow_mount()
函数检查nd->dentry
是否是某文件系统的安装点(nd->dentry->d_mounted
的值大于0);如果是,则调用lookup_mnt()
搜索目录项高速缓存中已安装文件系统的根目录,并把nd->dentry
和nd->mnt
更新为相应已安装文件系统的安装点和安装系统对象的地址;然后重复整个操作(几个文件系统可以安装在同一个安装点上)。从本质上说,由于进程可能从某个文件系统的目录开始路径名的查找,而该目录被另一个安装在其父目录上的文件系统所隐藏,那么当需要回到父目录时,则调用follow_mount()
函数。
- 如果最近解析的目录是进程的根目录(
- 分量名既不是“.”,也不是“..”,因此函数必须在目录项高速缓存中查找它。如果低级文件系统有一个自定义的
d_hash
目录项方法,则调用它来修改已在第5b步计算出的散列值。 - 把
nd->flags
中LOOKUP_CONTINUE
对应的位置位,这表示还有下一个分量要分析 - 调用
do_lookup()
,得到与给定的父目录(nd->dentry
)和文件名(要解析的路径名分量&next
结果参数)相关的目录项对象,存放在结果参数next中:err = do_lookup(nd, &this, &next, atomic);
。该函数本质上首先调用__d_lookup()
在目录项高速缓存中搜索分量的目录项对象。如果没有找到这样的目录项对象,则调用real_lookup()
。而real_lookup()
执行索引节点的lookup
方法从磁盘读取目录,创建一个新的目录项对象并把它插入到目录项高速缓存中,然后创建一个新的索引节点对象并把它插入到索引节点高速缓存中。在这一步结束时,next
局部变量中的dentry
和mnt
字段将分别指向这次循环要解析的分量名的目录项对象和已安装文件系统对象。 - 调用
follow_mount()
检查刚解析的分量(next.dentry
)是否指向某个文件系统安装点的一个目录(next.dentry->d_mounted
值大于0)。follow_mount()
更新next.dentry
和next.mnt
的值,以使它们指向由这个路径名分量所表示的目录上安装的最上层文件系统的目录项对象和已安装文件系统对象 - 检查刚解析的分量是否指向一个符号链接(
next.dentry->d_mode
具有一个自定义的follow_link
方法)。 - 检查刚解析的分量是否指向一个目录(
next.dentry->d_inode
具有一个自定义的lookup
方法)。 - 把
nd->dentry
和nd->mnt
分别置为next.dentry
和next.mnt
,然后继续路径名的下一个分量
- 从
- 除了最后一个分量,原路径名的所有分量都被解析,即循环体的
qstr
结构的this
指向最后一个分量(/test.conf
),name
内部变量已经是NULL了,nd
指向的dentry
对应this
的前一个分量(/system-configure-ext2/test.conf
),此时goto last_component;
如果跳出循环的话,那么清除nd->flags
中的LOOKUP_CONTINUE
标志 - 如果路径名尾部有一个“/”,则把
lookup_flags
局部变量中LOOKUP_FOLLOW
和LOOKUP_DIRECTORY
标志对应的位置位,以强制后边的函数来解释最后一个作为目录名的分量。 - 检查
lookup_flags
变量中LOOKUP_PARENT
标志的值。下面假定这个标志被置为0,并把相反的情况推迟到lookup_parent
。 - 如果最后一个分量名是“.”(单个圆点),则终止执行并返回值0(无错误)。在
nd
指向的nameidata
数据结构中,dentry
和mnt
字段指向路径名中倒数第二个分量对应的对象(/system-configure-ext2/test.conf
,任何分量“.”在路径名中没有效果)。 - 如果最后一个分量名是“..”(两个圆点),则尝试回到父目录:
- 如果最后解析的目录是进程的根目录(
nd->dentry
等于current->fs->root
,nd->mnt
等于current->fs->rootmnt
),则在倒数第二个分量上调用follow_mount()
,终止执行并返回值0(无错误)。nd->dentry
和nd->mnt
指向路径名的倒数第二个分量对应的对象,也就是进程的根目录。 - 如果最后解析的目录是
nd->mnt
文件系统的根目录(nd->dentry
等于nd->mnt->mnt_root
),并且该文件系统没有被安装在另一个文件系统之上(nd->mnt
等于nd->mnt->mnt_parent
),那么再向上搜索是不可能的,因此在倒数第二个分量上调用follow_mount()
,终止执行并返回值0(无错误)。 - 如果最后解析的目录是
nd->mnt
文件系统的根目录,并且该文件系统被安装在其他文件系统之上,那么把nd->dentry
和nd->mnt
分别置为nd->mnt->mnt_mountpoint
和nd->mnt_mnt_parent
,然后重新执行第10步。 - 如果最后解析的目录不是已安装文件系统的根目录,则把
nd->dentry
置为nd->dentry->d_parent
,在父目录上调用follow_mount()
,终止执行并返回值0(无错误)。nd->dentry
和nd->mnt
指向前一个分量(即路径名倒数第二个分量)对应的对象。
- 如果最后解析的目录是进程的根目录(
- 路径名的最后分量名既不是“.”也不是“..”,因此,必须用
do_lookup
在高速缓存中查找它。如果低级文件系统有自定义的d_hash
目录项方法,则该函数调用它来修改在第5c步已经计算出的散列值。 - 调用
do_lookup()
得到与父目录和文件名相关的目录项对象。这一步结束时,next
局部变量存放的是指向最后分量名对应的目录项和已安装文件系统描述符的指针。 - 调用
follow_mount()
检查最后一个分两名是不是一个文件系统的安装点,如果是,则把next
局部变量更新为最上层已安装文件系统根目录对应的目录项对象和已安装文件系统对象的地址。 - 检查在
lookup_flags
中是否设置了LOOKUP_FOLLOW
标志,且索引节点对象next.dentry->d_inode
是否有一个自定义的follow_link
方法。如果是,分量就是一个必须进行解释的符号链接。 - 要解析的分量不是一个符号链接或符号链接不该被解释。把
nd->mnt和nd->dentry
字段分别置为next.mnt
和next.dentry
的值。最后的目录项对象就是整个查找操作的结果 - 检查
nd->dentry->d_inode
是否为NULL。这发生在没有索引节点与目录项对象关联时,通常是因为路径名指向一个不存在的文件。在这种情况下,返回一个错误码-ENOENT。 - 路径名的最后一个分量有一个关联的索引节点。如果在
lookup_flags
中设置了LOOKUP_DIRECTORY
标志,则检查索引节点是否有一个自定义的lookup
方法,也就是说它是一个目录。如果没有,则返回一个错误码-ENOTDIR。 - 返回值0(无错误)。
nd->dentry
和nd->mnt
指向路径名的最后分量。
父路径名查找
在很多情况下,查找操作应当取回最后分量的前一个分量的目录项对象。当查找操作必须解析的是包含路径名最后一个分量的目录而不是最后一个分量本身时,就使用LOOKUP_PARENT
标志。
当LOOKUP_PARENT
标志被设置时,link_path_walk()
函数也在nameidata
数据结构中建立last
和last_type
字段。last
字段存放路径名中的最后一个分量名。last_type字段标识最后一个分量的类型;可以把它置为如下所示的值之一:
LAST_NORM
:最后一个分量是普通文件名LAST_ROOT
:最后一个分量是“/”(也就是整个路径名为“/”)LAST_DOT
:最后一个分量是“.”LAST_DOTDOT
:最后一个分量是“..”LAST_BIND
:最后一个分量是链接到特殊文件系统的符号链接
当整个路径名的查找操作开始时,LAST_ROOT
标志是由path_lookup()
设置的缺省值。如果路径名正好是“/”,则内核不改变last_type
字段的初始值。
last_type
字段的其他值在LOOKUP_PARENT
标志置位时由link_path_walk()
设置;在这种情况下,函数执行直到第8步。不过,从第8步往后,路径名中最后一个分量的查找操作是不同的:
- 把
nd->last
置为最后一个分两名 - 把
nd->last_type
初始化为LAST_NORM
- 如果最后一个分量名为“.”(一个圆点),则把nd->last_type置为LAST_DOT。
- 如果最后一个分量名为“..”(两个圆点),则把nd->last_type置为LAST_DOTDOT。
- 通过返回值0(无错误)终止。
你可以看到,最后一个分量根本就没有被解释。因此,当函数终止时,nameidata
数据结构的dentry
和mnt
字段指向最后一个分量所在目录对应的对象。
符号链接的查找
路径名可以包含符号链接,且必须由内核来解析。内核必须执行两个不同的查找操作:
- 第一个操作解析/foo/bar:当内一核发现bar是一个符号链接名时,就必须提取它的内容并把它解释为另一个路径名;
- 第二个路径名操作从第一个操作所达到的目录开始,继续到符号链接路径名的最后一个分量被解析。
- 接下来,原来的查找操作从第二个操作所达到的目录项恢复,且有了原目录名中紧随符号链接的分量。
假定一个符号链接指向自己,解析含有这样符号链接的路径名可能导致无休止的递归调用流,这又依次引发内核栈的溢出。当前进程的描述符中的link_count
字段用来避免这种问题:每次递归执行前增加这个字段的值,执行之后减少其值。如果该字段的值达到6,整个循环操作就以错误码结束。因此,符号链接嵌套的层数不超过5。
另外,当前进程的描述符中的total_link_count
字段记录在原查找操作中有多少符号链接(甚至非嵌套的)被跟踪。如果这个计数器的值到40,则查找操作中止。没有这个计数器,怀有恶意的用户就可能创建一个病态的路径名,让其中包含很多连续的符号链接,使内核在无休止的查找操作中冻结。
这就是代码基本工作的方式:一旦link_path_walk()
函数检索到与路径名分量相关的录项对象,就检查相应的索引节点对象是否有自定义的follow_link
方法。如果是,索引节点就是一个符号链接,在原路径名的查找操作进行之前就必须先对这个符号链接进行解释。
在这种情况下,link_path_walk()
函数调用do_follow_link()
,前者传递给后者的参数为符号链接目录项对象的地址dentry
和nameidata
数据结构的地址nd
:
1 | static inline int do_follow_link(struct path *path, struct nameidata *nd) |
do_follow_link()
依次执行下列步骤:
- 检查
current->link_count
小于MAX_NESTED_LINKS
,一般来说是5;否则,返回错误码-ELOOP。 - 检查
current->total_link_count
小于40;否则,返回错误码-ELOOP。 - 如果当前进程需要,则调用
cond_resched()
进行进程交换(设置当前进程描述符thread_info
中的TIF_NEED_RESCHED
标志)。 - 递增
current->link_count
、current->total_link_count
和nd->depth
的值。 - 更新与要解析的符号链接关联的索引节点的访问时间。
- 调用与具体文件系统相关的函数来实现
follow_link
方法,给它传递的参数为dentry
和nd
。它读取存放在符号链接索引节点中的路径名,并把这个路径名保存在nd->saved_names
数组的合适项中。 - 通过
__do_follow_link
调用__vfs_follow_link()
函数,给它传递的参数为地址nd
和nd->saved_names
数组中路径名的地址: - 如果定义了索引节点对象的
put_link
方法,就执行它,释放由follow_link
方法分配的临时数据结构。 - 减少
current->link_count
和nd->depth
字段的值。 - 返回由
__vfs_follow_link()
函数返回的错误码(0表示无错误):
1 | static __always_inline int __do_follow_link(struct path *path, struct nameidata *nd) |
1 | static __always_inline int __vfs_follow_link(struct nameidata *nd, const char *link) |
__vfs_follow_link()
函数本质上依次执行下列操作:
- 检查符号链接路径名的第一个字符是否是“/”:在这种情况下,已经找到一个绝对路径名,因此没有必要在内存中保留前一个路径的任何信息。如果是,对
nameidata
数据结构调用path_release()
,因此释放由前一个查找步骤产生的对象;然后,设置nameidata
数据结构的dentry
和mnt
字段,以使它们指向当前进程的根目录。 - 调用
link_path_walk()
解析符号链的路径名,传递给它的参数为路径名和nd。 - 返回从
link_path_walk()
取回的值。
当do_follow_link()
最后终止时,它把局部变量next
的dentry
字段设置为目录项对象的地址,而这个地址由符号链接传递给原先就执行的link_path_walk()
。link_path_walk()
函数然后进行下一步。
VFS系统调用的实现
open()系统调用
open()
系统调用的服务例程为sys_open()
函数,该函数接收的参数为:
- 要打开文件的路径名
filename
- 访问模式的一些标志
flags
- 该文件被创建所需要的许可权位掩码
mode
如果该系统调用成功,就返回一个文件描述符,也就是指向文件对象的指针数组current->files->fd
中分配给新文件的索引;否则,返回-1。
下面列出了open()系统调用的所有标志:
O_RDONLY
:为读而打开O_WRONLY
:为写而打开O_RDWR
:为读和写而打开O_CREAT
:如果文件不存在,就创建它O_EXCL
:对于O_CREAT标志,如果文件已经存在,则失败O_NOCTTY
:从不把文件看作控制终端O_TRUNC
:截断文件(删除所有现有的内容)O_APPEND
:总是在文件末尾写O_NONBLOCK
:没有系统调用在文件上阻塞O_NDELAY
:与O_NONBLOCK相同O_SYNC
:同步写(阻塞,直到物理写终止)FASYNC
:通过信号发出I/O事件通告O_DIRECT
:直接I/O传送(不使用缓存)O_LARGEFILE
:大型文件(长度大于2GB)O_DIRECTORY
:如果文件不是一个目录,则失败O_NOFOLLOW
:不解释路径名中尾部的符号链接O_NOATIME
:不更新索引节点的上次访问时间
下面来描述一下sys_open()
函数的操作。它执行如下操作:
- 调用
getname()
从进程地址空间读取该文件的路径名,想查看细节请看博文“文件系统安装预备知识”。 - 调用
get_unused_fd()
在current->files->fd
中查找一个空的位置。相应的索引(新文件描述符)存放在fd局部变量中。 - 调用
do_filp_open()
函数,传递给它的参数为路径名、访问模式标志以及许可权位掩码。- 把访问模式标志拷贝到
namei_flags
标志中,但是,用特殊的格式对访问模式标志。O_RDONLY
、O_WRONLY
和O_RDWR
进行编码:如果文件访问需要读特权,那么只设置namei_flags
标志的下标为0的位(最低位);类似地,如果文件访问需要写特权,就只设置下标为1的位。注意,不可能在open()
系统调用中不指定文件访问的读或写特权;不过,这种情况在涉及符号链接的路径名查找中则是有意义的。 - 调用
open_namei()
,传递给它的参数为dfd
(AT_FDCWD
)、路径名、修改的访问模式标志以及局部nameidata
数据结构的地址。open_namei
,这个函数执行以下流程:- 如果访问模式标志中没有设置
O_CREAT
,则不设置LOOKUP_PARENT
标志而设置LOOKUP_OPEN
标志后开始查找操作。此外,只有O_NOFOLLOW
被清零,才设置LOOKUP_FOLLOW
标志,而只有设置了O_DIRECTORY
标志,才设置LOOKUP_DIRECTORY
标志。 - 如果在访问模式标志中设置了
O_CREAT
,则以LOOKUP_PARENT
、LOOKUP_OPEN
和LOOKUP_CREATE
标志的设置开始查找操作。一旦path_lookup()
函数成功返回,则检查请求的文件是否已存在。如果不存在,则调用父索引节点的create
方法分配一个新的磁盘索引节点。 open_namei()
函数也在查找操作确定的文件上执行几个安全检查。例如,该函数检查与已找到的目录项对象关联的索引节点是否存在、它是否是一个普通文件,以及是否允许当前进程根据访问模式标志访问它。如果文件也是为写打开的,则该函数检查文件是否被其他进程加锁。
- 如果访问模式标志中没有设置
- 调用
dentry_open()
函数,传递给它的参数为访问模式标志、目录项对象的地址以及由查找操作确定的已安装文件系统对象:- 根据传递给
open()
系统调用的访问模式标志初始化文件对象的f_flags
和f_mode
字段。 - 根据作为参数传递来的目录项对象的地址和已安装文件系统对象的地址初始化文件对象的
f_fentry
和f_vfsmnt
字段。 - 重点步骤:把f_op字段设置为相应索引节点对象
i_fop
字段的内容。这就为进一步的文件操作建立起所有的方法。 - 把文件对象插入到文件系统超级块的
s_files
字段所指向的打开文件的链表。 - 如果文件操作的
open
方法被定义,则调用它。 - 调用
file_ra_state_init()
初始化预读的数据结构(参见第十六章)。 - 如果
O_DIRECT
标志被设置,则检查直接I/O操作是否可以作用于文件(参见第十六章)。 - 返回文件对象的地址。
- 根据传递给
- 返回文件对象的地址
- 把访问模式标志拷贝到
- 回到
do_sys_open
,把current->files->fd[fd]
置为由dentry_open()
返回的文件对象的地址: - 返回
fd
1 | int get_unused_fd(void) |
1 | static struct file *do_filp_open(int dfd, const char *filename, int flags, |
1 | static struct file *__dentry_open(struct dentry *dentry, struct vfsmount *mnt, |
1 | void fastcall fd_install(unsigned int fd, struct file * file) |
read()和write()系统调用
read()
和write()
系统调用非常相似。它们都需要三个参数:
- 一个文件描述符
fd
- 一个内存区的地址
buf
(该缓冲区包含要传送的数据) - 一个数
count
指定应该传送多少字节
read()
把数据从文件传送到缓冲区,而write()
执行相反的操作。两个系统调用都返回所成功传送的字节数,或者发送一个错误条件的信号并返回-1。返回值小于count
并不意味着发生了错误。即使请求的字节没有都被传送,也总是允许内核终止系统调用,因此用户应用程序必须检查返回值并重新发出系统调用(如果必要)。
一般会有这几种典型情况下返回小于count的值:当从管道或终端设备读取时,当读到文件的末尾时,或者当系统调用被信号中断时。文件结束条件(EOF)很容易从read()
的空返回值中判断出来。这个条件不会与因信号引起的异常终止混淆在一起,因为如果读取数据之前read()
被一个信号中断,则发生一个错误。
读或写操作总是发生在由当前文件指针所指定的文件偏移处(文件对象的f_pos字段)。两个系统调用都通过把所传送的字节数加到文件指针上而更新文件指针。
简而言之,sys_read()
(read()
的服务例程)和sys_write()
(write()
)的服务例程)几乎都执行相同的步骤:
- 调用
fget_light()
从fd
获取当前进程相应文件对象的地址file
: - 如果
file->f_mode
中的标志不允许所请求的访问(读或写操作),则返回一个错误码-EBADF。 - 如果文件对象没有
read()
或aio_read()
(write()
或aio_write()
)文件操作,则返回一个错误码-EINVAL。 - 调用
access_ok()
粗略地检查buf和count参数(参见博文“文件系统安装预备知识”)。 - 调用
rw_verify_area()
对要访问的文件部分检查是否有冲突的强制锁。如果有,则返回一个错误码,如果该锁已经被F_SETLKW
命令请求,那么就挂起当前进程。 - 调用
file->f_op->read
或file->f_op->write
方法(如果已定义)来传送数据;否则,调用file->f_op->aio_read
或file->f_op->aio_write
方法。所有这些方法都返回实际传送的字节数。另一方面的作用是,文件指针被适当地更新。 - 调用
fput_light()
释放文件对象。 - 返回实际传送的字节数。
1 | struct file fastcall *fget_light(unsigned int fd, int *fput_needed) |
close()系统调用
close()
系统调用接收的参数为要关闭文件的文件描述符fd
。sys_close()
服务例程执行下列操作:
- 获得存放在
current->files->fd[fd]
中的文件对象file
的地址;如果它为NULL,则返回一个出错码。 - 把
current->files->fd[fd]
置为NULL。释放文件描述符fd
,这是通过清除current->files
中的open_fds
和close_on_exec
字段的相应位来进行的。 - 调用
file_close()
,该函数执行下列操作:- 调用文件操作的
flush
方法(如果已定义)。 - 释放文件上的任何强制锁。
- 调用
fput()
释放文件对象。
- 调用文件操作的
- 返回0或一个出错码。出错码可由
flush
方法或文件中的前一个写操作错误产生
文件加锁
Unix提供了一种允许进程对一个文件区进行加锁的机制,以使同时访问可以很容易的被避免。POSIX标准规定了基于fcntl()
系统调用的文件加锁机制。这可以对文件的任意一部分加锁或对整个文件加锁。这种锁并不把不知道加锁的其他进程关到外边,只有在访问文件之前其他进程合作检查锁的存在时,锁才起作用。因此POSIX的锁称为劝告锁。传统的BSD变体通过flock()
系统调用实现劝告锁,这个调用不允许进程对文件的一个区字段进行加锁,而只能对整个文件加锁。
不管是劝告锁还是强制锁,它们都可以使用共享读锁和独占写锁。在文件的某个区字段上,可以有任意多个进程进行读,但在同一个时刻只能有一个进程进行写。当其他进程对同一个文件都有自己的读锁时,就不可能获得一个写锁。
Linux文件加锁
Linux支持所有的文件加锁方式:劝告锁和强制锁,以及fcntl()
、flock()
和lockf()
系统调用。不过,lockf()
系统调用仅仅是一个标准的库函数。flock()
系统调用不管MS_MANDLOCK
安装标志如何设置,只产生劝告锁。这是任何类Unix操作系统所期望的系统调用行为。在Linux中,增加了一种特殊的flock()
强制锁,以允许对专有的网络文件系统的实现提供适当的支持。这就是所谓的共享模式强制锁;当这个锁被设置时,其他任何进程都不能打开与锁访问模式冲突的文件。
在Linux中还引人了另一种基于fcntl()
的强制锁,叫做租借锁。当一个进程试图打开由租借锁保护的文件时,它照样被阻塞。然而,拥有锁的进程接收到一个信号。一旦该进程得到通知,它应当首先更新文件,以使文件的内容保持一致,然后释放锁。如果拥有者不在预定的时间间隔内这么做,则租借锁由内核自动删除,且允许阻塞的进程继续执行。
进程可以采用以下两种方式获得或释放一个文件劝告锁:
- 发出
flock()
系统调用。传递给它的两个参数为文件描述符fd
和指定锁操作的命令。该锁应用于整个文件。 - 使用
fcntl()
系统调用。传递给它的三个参数为文件描述符fd
、指定锁操作的命令以及指向flock
结构的指针。flock
结构中的几个字段允许进程指定要加锁的文件部分。因此进程可以在同一文件的不同部分保持几个锁。
fcntl()
和flock()
系统调用可以在同一文件上同时使用,但是通过fcntl()
加锁的文件看起来与通过flock()
加锁的文件不一样,反之亦然。这样当应用程序使用一种依赖于某个库的锁,而该库同时使用另一种类型的锁时,可以避免发生死锁。
处理强制文件锁会更复杂:
- 安装文件系统时强制锁是必需的,可使用
mount
命令的-o mand
选项在mount()
系统调用中设置MS_MANDLOCK
标志,缺省操作是不使用强制锁。 - 通过设置文件的
set-group
位和清除group-execute
许可权位将他们标记为强制锁的候选者。 - 使用
fcntl()
系统调用获得或释放一个文件锁
处理租借锁:调用具有F_SETLEASE
或F_GETLEASE
命令的系统调用fcntl()
就足够了。使用另一个带有F_SETSIG
命令的fcntl()
可以改变传送给租借锁进程拥有者的信号类型。
文件锁的数据结构
在 Linux 内核中,所有类型的文件锁都是由数据结构file_lock
来描述的:
指向磁盘上相同文件的所有lock_file
结构会被链接成一个单链表,索引节点结构中的i_flock
字段会指向该单链表结构的首元素,fl_next
用于指向该链表中的下一个元素;因为锁被挂起的进程会被插入到由阻塞锁file_lock
结构的fl_wait
指向的等待队列中。所有的活动锁被链接在全局文件锁链表中,该表的首元素存放在file_lock_list
中。所有的阻塞锁被链接在阻塞链表中,该表的首元素存放在block_list
中。fl_link
字段指向这两个列表其中一个。
内核必须跟踪所有与给定活动锁关联的阻塞锁。
FL_FLOCK锁
FL_FLOCK锁总是与一个文件相关联,因此由一个打开该文件的今晨来维护。当一个锁被请求或允许时,内核就把进程保持在同一个文件对象上的任何其他锁都替换掉。这只发生在进程想把一个已经拥有的读锁改变为一个写锁,或把一个写锁改变为读锁。
flock()
系统调用允许进程在打开文件上申请或删除劝告锁。它作用于两个参数:要加锁文件的文件描述符fd
和指定锁操作的参数cmd
。如果cmd
参数为LOCK_SH
,则请求一个共享的锁,为LOCK_EX
则请求一个互斥的锁,为LOCK_UN
则释放一个锁。
当sys_flock()
例程被调用时,执行下列步骤:
- 检查
fd
是否是一个有效的文件描述符,如果不是,就返回一个错误码。否则获得相应文件对象flip
的地址。 - 检查进程在打开文件上是否有读和/或写权限;如果没有,就返回一个错误码。
- 获得一个新的
file_lock
对象锁并用适当的锁操作初始化它:根据参数cmd
的值设置fl_type
字段,把fl_file
字段设为文件对象filp
的地址,fl_flags
字段设为FL_FLOCK
,fl_pid
字段设为current->tgid
,并把fl_end
字段设为-1,这表示对整个文件(而不是文件的一部分)加锁的事实。 - 如果参数
cmd
不包含LOCK_NB
位,则把FL_SLEEP
标志加入fl_flags
- 如果文件具有一个
flock
文件操作,则调用它,传递给它的参数为文件对象指针filp
、一个标志(F_SETLKW
或F_SETLK
,取决于LOCK_NB
位的值)以及新的file_lock
对象锁的地址。 - 否则,如果没有定义
flock
文件操作(通常情况下),则调用flock_lock_file_wait()
试图执行请求的锁操作。传递给它的两个参数为:文件对象指针filp
和在第3步创建的新的file_lock
对象的地址lock
。 - 如果上一步中还没有把
file_lock
描述符插入活动或阻塞链表中,则释放它。 - 返回成功
flock_lock_file_wait()
函数执行下列循环操作:
- 调用
flock_lock_file()
,传递给它的参数为文件对象指针filp
和新的file_lock
对象锁的地址lock
。这个函数依次执行下列操作:- 搜索
filp->f_dentry->d_inode->i_flock
指向的链表。如果在同一文件对象中找到FL_FLOCK
锁,则检查它的类型(LOCK_SH
或LOCK_EX
):如果该锁的类型与新锁相同,则返回0(什么也没有做)。否则,从索引节点锁链表和全局文件锁链表中删除这个file_lock
元素,唤醒fl_block
链表中在该锁的等待队列的所有进程,并释放file_lock
结构 - 如果进程正在执行开锁(LOCK_UN),则什么事情都不需要做:该锁已不存在或已被释放,因此返回0。
- 如果已经找到同一个文件对象的
FL_FLOCK
锁——表明进程想把一个已经拥有的读锁改变为一个写锁(反之亦然),那么调用cond_resched()
给予其他更高优先级进程(特别是先前在原文件锁上阻塞的任何进程)一个运行的机会。 - 再次搜索索引节点锁链表以验证现有的
FL_FLOCK
锁并不与所请求的锁冲突。在索引节点链表中,肯定没有FL_FLOCK
写锁,此外,如果进程正在请求一个写锁,那么根本就没有FL_FLOCK
锁。 - 如果不存在冲突锁,则把新的
file_lock
结构插入索引节点锁链表和全局文件锁链表中,然后返回0(成功)。 - 发现一个冲突锁:如果
fl_flags
字段中FL_SLEEP
对应的标志位置位,则把新锁(waiter
锁)插入到blocker
锁循环链表和全局阻塞链表中。 - 返回一个错误码-EAGAINO
- 搜索
- 检查
flock_lock_file()
的返回码:- 如果返回码为0(没有冲突迹象),则返回0(成功)。
- 不相容的情况。如果
fl_flags
字段中的FL_SLEEP
标志被清除,就释放file_lock
锁描述符,并返回一个错误码-EAGAIN。 - 否则,不相容但进程能够睡眠的情况:调用
wait_event_interruptible()
把当前进程插入到lock->fl_wait
等待队列中并挂起它。当进程被唤醒时(正好在释放blocker
锁后),跳转到第1步再次执行这个操作。
FL_POSIX锁
FL_POSIX
锁总是与一个进程和一个索引节点相关联。当进程死亡或一个文件描述符被关闭时(即使该进程对同一文件打开了两次或复制了一个文件描述符),这种锁会被自动地释放。此外,FL_POSIX
锁绝不会被子进程通过fork()
继承。
当使用fcntl()
系统调用对文件加锁时,该系统调用作用于三个参数:
- 要加锁文件的文件描述符
fd
- 指向锁操作的参数
cmd
- 指向存放在用户态进程地址空间中的
flock
sys_fcntl()
执行的操作取决于在cmd
参数中所设置的标志值。
F_GETLK
:确定由flock
结构描述的锁是否与另一个进程已获得的某个FL_POSIX
锁互相冲突。冲突时用现有锁的有关信息重写flock
结构F_SETLK
:设置由flock
结构描述的锁,如果不能获得该锁,就返回错误码F_SETLKW
:设置由flock
结构描述的锁,如果不能获得该锁,则阻塞系统调用,直至该锁可以获取F_GETLK64
,F_SETLK64
,F_SETLKW64
:使用的时flock64
而不是flock
sys_fcntl()
服务例程首先获取与参数fd
对应的文件对象,然后调用fcntl_getlk()
或fcntl_setlk()
函数(这取决于传递的参数:F_GETLK
表示前一个函数,F_SETLK
或F_SETLKW
表示后一个函数)。我们仅仅考虑第二种情况。
fcntl_setlk()
函数作用于三个参数:
- 指向文件对象的指针
filp
cmd
命令(F_SETLK
或F_SETLKW
)- 指向
flock
数据结构的指针
该函数执行下列操作:
- 读取局部变量中的参数
fl
所指向的flock
结构。 - 检查这个锁是否应该是一个强制锁,且文件是否有一个共享内存映射。在肯定的情况下,该函数拒绝创建锁并返回-EAGAIN出错码,说明文件正在被另一个进程访问。
- 根据用户
flock
结构的内容和存放在文件索引节点中的文件大小,初始化一个新的file_lock
结构。 - 如果命令
cmd
为F_SETLKW
,则该函数把file_lock
结构的fl_flags
字段设为FL_SLEEP
标志对应的位置位。 - 如果
flock
结构中的l_type
字段为F_RDLCK
,则检查是否允许进程从文件读取;类似地,如果l_type
为F_WRLCK
,则检查是否允许进程写入文件。如果都不是,则返回一个出错码。 - 调用文件操作的
lock
方法(如果已定义)。对于磁盘文件系统,通常不定义该方法。 - 调用
__posix_lock_file()
函数,传递给它的参数为文件的索引节点对象地址以及file_lock
对象地址。该函数依次执行下列操作:- 对于索引节点的锁链表中的每个
FL_POSIX
锁,调用posix_locks_conflict()
。该函数检查这个锁是否与所请求的锁互相冲突。从本质上说,在索引节点的链表中,必定没有用于同一区的FL_POSIX
写锁,并且,如果进程正在请求一个写锁,那么同一个区字段也可能根本没有FL_POSIX
锁。但是,同一个进程所拥有的锁从不会冲突;这就允许进程改变它已经拥有的锁的特性。 - 如果找到一个冲突锁,则检查是否以
F_SETLKW
标志调用fcntl()
。如果是,当前进程应当被挂起:在这种情况下,调用posix_locks_deadlock()
来检查在等待FL_POSIX
锁的进程之间没有产生死锁条件,然后把新锁(waiter锁)插入到冲突锁(blocker锁)blocker
链表和阻塞链表中,最后返回一个出错码。否则,如果以F_SETLK
标志调用fcntl()
,则返回出错码。 - 只要索引节点的锁链表中不包含冲突的锁,就检查把文件区重叠起来的当前进程的所有
FL_POSIX
锁,当前进程按需要对文件区中相邻的区字段进行锁定、组合及拆分。 - 把新的
file_lock
结构插入到全局锁链表和索引节点链表中。 - 返回值0(成功)。
- 对于索引节点的锁链表中的每个
- 检查
__posix_lock_file()
的返回码:- 如果返回码为0(没有冲突迹象),则返回0(成功)。
- 不相容的情况。如果
fl_flags
字段的FL_SLEEP
标志被清除,就释放新的file_lock
描述符,并返回一个错误码-EAGAIN。 - 否则如果不相容但进程能够睡眠时,调用
wait_event_interruptible()
把当前进程插入到lock->fl_wait
等待队列中并挂起它。当进程被唤醒时(正好在释放blocker锁后),跳转到第7步再次执行这个操作。