[隐藏]

一、文件描述符

文件描述符是一个非负整数,用于标识一个文件。当打开或创建一个文件时,内核向进程返回一个文件描述符。

按照惯例,Unix系统的应用程序使用文件描述符0与标准输入关联,1和2分别与标准输出和标准错误输出关联。POSIX标准定义了符号常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO分别代替0,1,2(在程序中使用符号常量而不是魔数是一个好的习惯)。它们定义在<unistd.h>中。

文件描述符的范围是0-OPEN_MAX,默认情况下,Linux允许每个进程打开1024个文件。

二、打开/关闭文件

#include <fcntl.h>

int open(const char *pathname, int oflag, … /* mode_t mode */);

返回值:若成功则返回文件描述符,若出错则返回-1

#include <unistd.h>

int close(int filedes);

返回值:若成功则返回0,若出错则返回-1

open用于打开或创建一个文件。只有创建文件的时候才使用第三个参数。

pathname 是要打开或创建文件的名字;oflag参数是此函数的选项,可由下列常量进行或运算组成(O_表示的就是or的意思):

O_RDONLY 只读打开
O_WRONLY 只写打开
O_RDWR 读写打开

这三个常量必须且只能指定一个。下列常量则可任选0个或多个:

O_APPEND 每次写时都追加到文件末尾
O_CREAT 若文件不存在则创建它。使用此选项时,需要第三个参数,指定该新文件的访问权限位
O_EXCL 如果同时指定了O_CREAT,而文件已经存在,则会报错。用此可以测试一个文件是否存在,如果不存在,则创建此文件,这使测试和创建两者成为一个原子操作
O_TRUNC 如果此文件存在,而且为只写或读写方式打开,则将其长度截短为0
O_NOCTTY 如果pathname指的是终端设备,则不将该设备分配作为此进程的控制终端
O_NONBLOCK 如果pathname指的是一个FIFO、一个块特殊文件或一个字符特殊文件,则此选项为文件的本次打开操作和后续的I/O操作设置非阻塞模式
O_DSYNC 使每次write等待物理I/O操作完成,但是如果写操作并不影响读取刚写入的数据,则不等待文件属性被更新
O_RSYNC 使每一个以文件描述符作为参数的read操作等待,直至任何对文件同一部分进行的未决写操作都完成
O_SYNC 使每次write都等到物理I/O操作完成,包括由write操作引起的文件属性更新所需的I/O

为了创建一个新文件,我们可以这样调用open:

open(pathname, O_WRONLY | O_CREAT | O_O_TRUNC, mode);

也可以调用creat函数来创建文件:

#include <fcntl.h>

int creat(const char *pathname, mode_t mode);

返回值:若成功则返回为只写打开的文件描述符,若出错则返回-1

注意,creat以只写方式打开所创建的文件。

创建文件时需要指定访问权限位,那么什么是访问权限位呢?

所有文件类型(目录文件、字符设备文件等)都有访问权限。每个文件有9个访问权限位,可将它们分为三类:

S_IRUSR

S_IWUSR

S_IXUSR

所有者-读

所有者-写

所有者-执行

S_IRGRP

S_IWGRP

S_IXGRP

组-读

组-写

组-执行

S_IROTH

S_IWOTH

S_IXOTH

其他-读

其他-写

其他-执行

这些权限位定义在<sys/stat.h>中

需要注意的是,对目录具有读权限不代表可以读该目录下的文件,目录的读权限表示允许我们获得该目录中所有文件的列表。要读该目录下的文件,必须具有该目录的执行权限。

例如,为了打开文件/usr/include/stdio.h,需要对目录/、/usr、/usr/include具有执行权限。

为了在一个目录中创建一个新文件,则必须对该目录具有写权限和执行权限。

由open返回的文件描述符一定是最小的未用描述符数值。也就是说,在一个进程中打开的第一个文件的文件描述符一定是3。另一方面,如果你在之前关闭了标准输入(文件描述符0),然后打开一个文件,那么这个文件的会在文件描述符0上打开。

调用close函数可以关闭一个打开的文件。

当一个进程终止时,内核会自动关闭它所有打开的文件。重复关闭一个文件或关闭一个没有打开的文件都是可以的。

三、文件读写

#include <unistd.h>

ssize_t read(int filedes, void *buf,  size_t nbytes);

返回值:若成功则返回读到的字节数,若已到文件结尾则返回0,若出错则返回-1

#include <unistd.h>

ssize_t write(int filedes, const void *buf, size_t nbytes);

返回值:若成功则返回已写的字节数,若出错则返回-1

#include <unistd.h>

off_t lseek(int filedes, off_t offset, int whence);

返回值:若成功则返回新的文件偏移量,若出错则返回-1

要明白这几个函数的用法,需要先了解“当前文件偏移量”的概念。内核为每个打开的文件维护了一个文件偏移量,它通常是一个非负数,用以度量从文件开始处计算的字节数。默认情况下,打开一个文件时,除非指定O_APPEND选项,否则该偏移量被设置为0。

调用read函数从文件的当前偏移量开始读nbytes字节的数据,如果读取成功,则返回实际读到的字节数,并把当前偏移量增加实际读到的字节数。

这里之所以说“实际读到的字节数”,是因为在多种情况下实际读到的字节数会少于要求读的字节数:

  • 读普通文件时,在读到要求字节数之前已到达了文件末尾。例如,若在到达文件末尾之前还有30个字节,而要求读100个字节,则read返回30,下次调用read时,它将返回0表示已到文件末尾。
  • 当从终端设备读时,通常一次最多读一行。
  • 当从网络读时,网络中的缓冲机制可能造成返回值小于所要求的字节数。
  • 当从管道或FIFO读时,如若管道包含的字节少于所需的数量,那么read将只返回实际可用的字节数。
  • 当从某些面向记录的设备(如磁带)读时,一次最多返回一个记录。
  • 当某一信号造成中断,而已经读了部分数据量时。

调用write函数从当前偏移量开始写如nbytes字节数据。如果打开文件时指定了O_APPEND选项(即以添加模式打开),则每次写操作都将数据写到文件结尾处。

通常情况下,如果磁盘已满,或者超过了一个给定进程的文件长度限制,write操作会失败。否则,其返回值等于nbytes

我们也可以调用lseek函数显式设置文件的当前偏移量。

参数 offset 的含义与参数 whence 有关:

whence offset的含义
SEEK_SET 将偏移量设置为距文件开始offset字节处
SEEK_CUR 将偏移量设置为当前值加offset,offset可正可负
SEEK_END 将偏移量设置为文件长度加offset,offset可正可负

因为offset可能是负值,所以在判断lseek是否出错时应当测试返回值是否是-1,而不是测试它是否小于0。

如果whence是SEEK_END并且offset是正值会咋样呢?也就是说,文件偏移量大于了文件的长度。

在这种情况下,对文件的写操作将加长文件,并在文件中形成一个空洞,这样的空洞并不要求存储在磁盘上。读空洞的值会读出0。

四、文件共享与原子操作

首先需要介绍一下Linux内核用于I/O的数据结构。

对于每一个进程,内核中都有一个struct task_struct结构的实例与之对应,该结构保存了与进程有关的所有信息,当然也包括进程已打开文件的信息,即下图中的struct files_struct。(当然,下图忽略了大量细节)

图1

files_struct 中有一个指针数组,每个指针指向一个文件项(file),其中的path中有一个目录项(dentry),而dentry结构的主要用途就是建立文件名和相关的inode之间的关联。每一个文件都对应一个inode来保存关于文件的信息。

如果两个独立的进程各自打开了同一个文件,则系统会利用dentry缓存机制,使内部数据有下图的结构:

图2

可以看到,虽然打开了同一个文件,但它们有各自的file实例,所以文件的当前偏移量并不会相互干扰。但对于一个给定的文件只有一个inode项(dentry只是用来加速文件的查找操作,与具体文件属性和内容并无关系,正如前面所说,它提供的是文件名和inode的关联)。

现在考虑这样一种情况。假设有两个独立的进程A和B都对同一文件进行添加操作(比如,两个进程同时想往某一日志文件中写入日志),但未指定O_APPEND选项。此时,它们的数据结构关系如图2所示。

假定进程A调用lseek将A的当前偏移量设置为100字节处(文件末尾),然后内核切换进程使B运行,B同样调用lseek将B的当前偏移量设置为100,接着B调用write进行写操作,使文件增加至200字节。然后A恢复运行并调用write时,就将从100处进行写操作,这样就覆盖了之前B写入的数据。

问题出现在“定位,写”操作使用了两个函数调用,而任何一个需要多函数调用的操作都不可能是原子操作。也就是说,在两个函数调用之间,内核可能会临时挂起该进程。

这时就需要调用下面的函数使这种操作成为原子操作:

#include <unistd.h>

ssize_t pread(int filedes, void *buf, size_t nbytes, off_t offset);

返回值:读到的字节数,若已到文件结尾则返回0,若出错则返回-1

ssize_t pwrite(int filedes, const void *buf, size_t nbytes, off_t offset);

返回值:若成功则返回已写的字节数,若出错则返回-1

两个进程同时创建一个文件也会出现类似的情况,这时只要在调用open函数时同时指定O_CREAT和O_EXCL选项就可以了。

我们可以调用下面两个函数手动复制一个现存的文件描述符:

#include <unistd.h>

int dup(int filedes);

int dup2(int filedes, int filedes2);

两函数的返回值:若成功则返回新的文件描述符,若出错则返回-1

由dup返回的新的文件描述符一定是当前可用文件描述符的最小值。

用dup2则可以用filedes2指定新的文件描述符。如果filedes2已经打开,则先将其关闭,如果filedes等于filedes2,则dup2返回filedes2而不会关闭它。

这些函数返回的新文件描述符与参数filedes共享一个file实例,所以它们共享同一当前文件偏移量等属性。

五、强制刷入

传统的unix实现在内核中设有缓冲区,当将数据写到文件上时,通常该数据先由内核复制到缓存中,如果该缓存尚未写满,则并不将其排入输出队列,而是等待其写满或者当内核需要重用该缓存以便存放其他磁盘块数据时,再将该缓存排入输出队列,然后待其到达队首时,才进行实际的I / O操作。这种输出方式被称之为延迟写(delayed write)。

延迟写减少了磁盘读写次数,但是却降低了文件内容的更新速度,使得欲写到文件中的数据在一段时间内并没有写到磁盘上。当系统发生故障时,这种延迟可能造成文件更新内容的丢失。为了保证磁盘上实际文件系统与缓存中内容的一致性,unix系统提供了sync、fsync和fdatasync三个函数。

#include <unistd.h>

int fsync(int filedes);

int fdatasync(int filedes);

返回值:若成功返回0,若出错则返回-1

void sync(void);

sync只是将所有修改过的块的缓存排入写队列,然后就返回,它并不等待实际I / O操作结束。

fsync只引用单个文件(由文件描述符filedes指定),它等待写操作结束,然后返回。

fdatasync 类似于fsync,但它只影响文件的数据部分。而除数据外,fsync还会同步更新文件的属性。