第2章 文件I/O
本章以及后续的3个章节将介绍文件相关的内容。UNIX系统主要是通过文件表示的,因此这些章节的探讨会涉及UNIX系统的核心。本章介绍了文件I/O的基本要素,详细阐述了最简单也是最常见的文件交互方式——系统调用。第3章基于标准C库描述标准I/O,第4章继续探讨更高级和专业的文件I/O接口。第8章以文件和目录操作为主题结束了文件相关的探讨。
在对文件进行读写操作之前,首先需要打开文件。内核会为每个进程维护一个打开文件的列表,该列表称为文件表(file table)。文件表是由一些非负整数进行索引,这些非负整数称为文件描述符(file descriptors,简称fds)。列表的每一项是一个打开文件的信息,包括指向该文件索引节点(inode)内存拷贝的指针以及关联的元数据,如文件位置指针和访问模式。用户空间和内核空间都使用文件描述符作为唯一cookies:打开文件会返回文件描述符,而后续操作(读写等)都把文件描述符作为基本参数。
文件描述符使用C语言的int类型表示。没有使用特殊类型来表示文件描述符显得有些怪异,但实际上,这种表示方式正是继承了UNIX传统。每个Linux进程可打开的文件数是有上限的。文件描述符的范围从0开始,到上限值减1。默认情况下,上限值为1 024,但是可以对它进行配置,最大为1 048 576。因为负数不是合法的文件描述符,所以当函数出错不能返回有效的文件描述符时,通常会返回-1。
按照惯例,每个进程都至少包含三个文件描述符:0、1和2,除非显式关闭这些描述符。文件描述符0表示标准输入(sdtin),1表示标准输出(stdout),2表示标准错误(stderr)。Linux C标准库没有直接引用这些整数,而是提供了三个宏,分别是:STDIN_FILENO, STDOUT_FILENO和STDERR_FILENO。一般而言,stdin是连接到终端的输入设备(通常是用户键盘),而stdout和stderr是终端的屏幕。用户可以重定向这些文件描述符,甚至可以通过管道把一个程序的输出作为另一个程序的输入。shell正是通过这种方式实现重定向和管道的。
值得注意的是,文件描述符并非局限于访问普通文件。实际上,文件描述符也可以访问设备文件、管道、快速用户空间互斥(futexes)[1]、先进先出缓冲区(FIFOs)和套接字(socket)。遵循一切皆文件的理念,几乎任何能够读写的东西都可以通过文件描述符来访问。
默认情况下,子进程会维护一份父进程的文件表副本。在该副本中,打开文件列表及其访问模式、当前文件位置以及其他元数据,都和父进程维护的文件表相同,但是存在一点区别:即当子进程关闭一个文件时,不会影响到父进程的文件表。虽然一般情况下子进程会自己持有一份文件表,但是子进程和父进程也可以共享文件表(类似于线程间共享),在第5章将对此进行更详细的介绍。
2.1 打开文件
最基本的文件访问方法是系统调用read()和write()。但是,在访问文件之前,必须先通过open()或creat()打开该文件。一旦完成文件读写,还应该调用系统调用close()关闭该文件。
2.1.1 系统调用open()
通过系统调用open(),可以打开文件并获取其文件描述符:
如果系统调用open()执行成功,会返回文件描述符,指向路径名name所指定的文件。文件位置即文件的起始位置(0),文件打开方式是根据参数flags值来确定的。
open()的flags参数
flags参数是由一个或多个标志位的按位或组合。它支持三种访问模式:O_RDONLY、O_WRONLY或O_RDWR,这三种模式分别表示以只读、只写或读写模式打开文件。
举个例子,以下代码以只读模式打开文件/home/kidd/madagascar:
不能对以只读模式打开的文件执行写操作,反之亦然。进程必须有足够的权限才能调用系统调用来打开文件。举个例子,假设用户对某个文件只有只读权限,该用户的进程只能以O_RDONLY模式打开文件,而不能以O_WRONLY或O_RDWR模式打开。
flags参数还可以和下面列出的这些值进行按位或运算,修改打开文件的行为:
O_APPEND
文件将以追加模式打开。也就是说,在每次写操作之前,将会更新文件位置指针,指向文件末尾。即使有另一个进程也在向该文件写数据,以追加模式打开的进程在最后一次写操作时,还是会更新文件位置指针,指向文件末尾(参见2.3.2小节)。
O_ASYNC
当指定的文件可读或可写时,会生成一个信号(默认是SIGIO)。O_ASYNC标志位只适用于FIFO、管道、socket和终端,不适用于普通文件。
O_CLOEXEC
在打开的文件上设置“执行时关闭”标志位。在执行新的进程时,文件会自动关闭。设置O_CLOEXEC标志位可以省去调用fcntl()来设置标志位,且避免出现竞争。Linux内核2.6.23以上的版本才提供该标志位。
O_CREAT
当参数name指定的文件不存在时,内核自动创建。如果文件已存在,除非指定了标志位O_EXCL,否则该标志位无效。
O_DIRECT
打开文件用于直接I/O(参见2.5节)。
O_DIRECTORY
如果参数name不是目录,open()调用会失败。该标志位被置位时,内部会调用opendir()。
O_EXCL
当和标志位O_CREAT一起使用时,如果参数name指定的文件已经存在,会导致open()调用失败。用于防止创建文件时出现竞争。如何没有和标志位O_CREAT一起使用,该标志位就没有任何含义。
O_LARGEFILE
文件偏移使用64位整数表示,可以支持大于2GB的文件。64位操作系统中打开文件时,默认使用该参数。
O_NOATIME+
在读文件时,不会更新该文件的最后访问时间。该标志位适用于备份、索引以及类似的读取系统上所有文件的程序,它可以避免为了更新每个文件的索引节点而导致的大量写操作。Linux内核2.6.8以上的版本才提供该标志位。
O_NOCTTY
如果给定的参数name指向终端设备(比如/dev/tty),它不会成为这个进程的控制终端,即使该进程当前没有控制终端。该标志位很少使用。
O_NOFOLLOW
如果参数name指向一个符号链接,open()调用会失败。正常情况下,指定该标志位会解析链接并打开目标文件。如果给定路径的子目录也是链接,open()调用还是会成功。举个例子,假设name值为/etc/ship/plank.txt,如果plank.txt是个符号链接, open()会失败;然而,如果etc或ship是符号链接,只要plank.txt不是符号链接,调用就会成功。
O_NONBLOCK
文件以非阻塞模式打开。不管是open()调用还是其他操作,都不会导致进程在I/O中阻塞(sleep)。这种情况只适用于FIFO。
O_SYNC
打开文件用于同步I/O。在数据从物理上写到磁盘之前,写操作都不会完成;普通的读操作已经是同步的,因此该标志位对读操作无效。POSIX还另外定义了两个标志位O_DSYNC和O_RSYNC,在Linux系统中,这些标志位和O_SYNC含义相同(参见2.4.3节)。
O_TRUNC
如果文件存在,且是普通文件,并且有写权限,该标志位会把文件长度截断为0。对于FIFO或终端设备,该标志位无效,对于其他文件类型,其行为是未定义。因为对文件执行截断操作,需要有写权限,所以如果文件是只读,指定标志位O_TRUNC,行为也是未定义的。
举个例子,以下代码会打开文件/home/teach/pearl,用于写操作。如果文件已经存在,该文件长度会被截断为0;由于没有指定标志位O_CREAT,如果文件不存在,该open调用会失败:
2.1.2 新建文件的所有者
确定新建文件的所有者很简单:文件所有者的uid即创建该文件的进程的有效uid。
确定新建文件的用户组则相对复杂些。默认情况下,使用创建进程的有效gid。System V是通过这种方式确定,Linux的很多行为都是以System V为模型,因此标准Linux也采用这种处理方式。
但是问题在于,BSD定义了自己的行为方式:新建文件的用户组会被设置成其父目录的gid。在Linux上可以通过挂载选项[2]实现这一点——在Linux上,如果文件的父目录设置了组ID(setgid)位,默认也是这种行为。虽然大多数Linux系统会采用System V行为(新建的文件使用创建进程的gid),但BSD行为(新建文件接收父目录的gid)也有存在的可能,这意味着对于那些对新建文件的所属组非常关注的代码,需要通过系统调用fchown()手动设置所属组(参见第8章)。
幸运的是,大部分时候不需要关心文件的所属组。
2.1.3 新建文件的权限
前面给出的两种open()系统调用方式都是合法的。除非创建了新文件,否则会忽略参数mode;如果给定O_CREAT参数,则需要该参数。在使用O_CREAT参数时如果没有提供参数mode,结果是未定义的,而且通常会很糟糕——所以千万不要忘记!
当创建文件时,参数mode提供了新建文件的权限。对于新建的文件,打开文件时不会检查权限,因此可以执行与权限相反的操作,比如以只读模式打开文件,却在打开后执行写操作。
参数mode是常见的UNIX权限位集合,比如八进制数0644(文件所有者可以读写,其他人只能读)。从技术层面看,POSIX是根据具体实现确定值,支持不同的UNIX系统设置自己想要的权限位。但是,每个UNIX系统对权限位的实现都采用了相同的方式。因此,虽然技术上不可移植,但在任何系统上指定0644或0700效果都是一样的。
为了弥补mode中比特位的不可移植性,POSIX引入了一组可以按位操作的常数,按位结果提供给参数mode:
S_IRWXU
文件所有者有读、写和执行的权限。
S_IRUSR
文件所有者有读权限。
S_IWUSR
文件所有者有写权限。
S_IXUSR
文件所有者有执行权限。
S_IRWXG
组用户有读、写和执行权限。
S_IRGRP
组用户有读权限。
S_IWGRP
组用户有写权限。
S_IXGRP
组用户有执行权限。
S_IRWXO
任何人都有读、写和执行的权限。
S_IROTH
任何人都有读权限。
S_IWOTH
任何人都有写权限。
S_IXOTH
任何人都有执行权限。
实际上,最终写入磁盘的权限位是由mode参数和用户的文件创建掩码(umask)执行按位与操作而得到。umask是进程级属性,通常是由login shell设置,通过调用umask()来修改,支持用户修改新创建的文件和目录的权限。在系统调用open()中,umask位要和参数mode取反。因此,umask 022和mode参数0666取反后,结果是0644。对于系统程序员,在设置权限时通常不需要考虑umask——umask是为了支持用户限制程序对于新建文件的权限设置。
举个例子,以下代码会对文件file执行写操作。如果文件不存在,假定umask值为022,文件在创建时指定权限为0644(虽然参数mode值为0664)。如果文件已存在,其长度会被截断为0:
为了代码可读性(以可移植性为代价,至少理论上如此),这段代码可以改写成如下,其效果完全相同:
2.1.4 creat()函数
OWRONLY | O_CREAT | O**TRUNC 的组合经常被使用,因而专门有个系统调用提供这个功能:
诚如你所看到的,函数名creat的确少了个e。UNIX之父Ken Thompson曾开玩笑说他在UNIX设计中感到最遗憾的就是漏掉了这个字母。
典型的creat()调用如下:
这段代码等效于:
在绝大多数Linux架构[3]中,creat()是个系统调用,虽然在用户空间也很容易实现:
这是一个历史遗留问题,因为之前open()函数只有两个参数,所以也设计了creat()函数。当前,为了向后兼容,仍然保留creat()这个系统调用。在新的体系结构中,creat()可以实现成调用open()的库函数调用,如上所示。
2.1.5 返回值和错误码
系统调用open()和creat()在成功时都会返回文件描述符。出错时,返回-1,并把errno设置成相应的错误值(第1章讨论了errno并列出了可能的错误值)。处理文件打开的错误并不复杂,一般来说,在打开文件之前没有什么操作,因此不太需要执行撤销。典型的处理方式是提示用户换个文件名或直接终止程序。