0 文件描述符

对于内核而言,所有打开的文件都通过文件描述符引用。文件描述符是一个非负整数。当打开一个现有文件或者创建一个新文件时,内核项进程返回一个文件描述符。文件描述符的变化范围时0~OPEN_MAX

1 Linux文件I/O函数

Linux系统中的大多数文件I/O只需要用到5个函数:openreadwritelseekclose

int open(const char *path, int oflag, ...  );
int creat(const char *path, mode_t mode);
int close(int fildes);

ssize_t pread(int fildes, void *buf, size_t nbyte, off_t offset);
ssize_t read(int fildes, void *buf, size_t nbyte);

ssize_t pwrite(int fildes, const void *buf, size_t nbyte, off_t offset);
ssize_t write(int fildes, const void *buf, size_t nbyte);

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

int dup(int oldfd);
int dup2(int oldfd, int newfd);

1.1 open函数

open函数的oflag参数表示以何种方式打开/创建文件,其可选值(可以多个进行或运算)如下:

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

上面这三个常量中必须指定一个且只能指定一个。下面的常量则是可选的:

  • O_APPEND     每次写都追加到文件尾端。
  • O_CREAT       若文件不存在,则创建。创建时必须指定第三个参数mode,即该新文件的访问权限。
  • O_EXCL          如果同时指定了O_CREAT,那么如果文件已经存在,则会报错。用此来测试文件是否存在。
  • O_TRUNC       如果文件存在,而且为只写或者读写打开,则将其长度截为0.
  • O_NOCTTY     如果path指定的时终端设备,则不将该终端设备分配作为此进程的控制终端。
  • O_NONBLOCK  如果path指的是一个FIFO、一个块特殊文件或一个字符特殊文件,则此选项为文件的本次打开操作和后续的I/O操作设置非阻塞模式。

注意,由open返回的文件描述符(非负整数)一定是最小的未使用的描述符数值。

1.2 creat函数

creat函数等效于:open(path, O_WRONLY | O_CREAT | O_TRUNC, mod);

1.3 close函数

close关闭一个打开的文件,同时释放该进程加在该文件上的所有记录锁。当一个进程终止时,内核自动关闭它所有打开的文件。很多程序利用这一功能而不是显式的用close关闭打开文件。

1.4 lseek函数

每个打开的文件都有一个与其关联的“当前文件偏移量”(current file offset)。它通常是一个非负整数,用以度量从文件开始处计算的字节数。通常,读、写操作都是从当前文件偏移量处开始,并使偏移量增加所读写的字节数。按系统默认的情况,当打开一个文件时,除非指定O_APPEND选项,否则该偏移量被设为0.

但是,我们可以使用lseek显式的为一个打开的文件设置其偏移量。对参数offset的解释将与whence的值有关:

  • 若whence是SEEK_SET,则将该文件的偏移量设置为距文件开始处offset个字节。
  • 若whence是SEEK_CUR,则将该文件的偏移量设置为其当前值加offset,offset可为正或者负。
  • 若whence时SEEK_END,则将该文件的偏移量设置为当前文件长度加offset,offset可为正或负。

若lseek执行成功,则返回新的文件偏移量。所以,我们可以使用下列方式确定打开文件的当前偏移量:

offset_t    currpos;
currpos = lseek(fd, 0, SEEK_CUR);

这种方法也可以用来确定所涉及的文件是否可以设置偏移量。如果文件描述符引用的是一个管道、FIFO或网络套接字,则lseek返回-1,并将errno设置为ESPIPE。

关于文件偏移量,还有几个点需要注意:

(1)偏移量类型off_t的数据类型也限制了一个文件的最大长度。一般是带符号数据类型,所以文件最大长度会减小一半。例如,若off_t是32位的整型,则文件最大长度是231-1个字节(2TB)。当然,文件的最大大小也受具体文件系统的限制。

(2)lseek仅将当前的文件偏移量记录在内核中(文件表项),它并不引起任何I/O操作。然后,该偏移量用于下一个读或写操作。

(3)文件偏移量可以大于当前文件的长度,在这种情况下,对该文件的下一次写将加长该文件,并在文件中构成一个空洞。位于文件中但没写过的字节都为读成0.文件中的空洞并不要求在磁盘上面占用存储区。具体处理方式与文件系统实现有关。当定位到超出文件尾端之后写时,对于新写的数据需要分配磁盘块,但是对于原文件尾端和新开始写位置之间的部分则不需要分配磁盘块。

1.5 read/write函数

如果read成功,返回读到的字节数;如果到达文件结尾,返回0;若出错,返回-1.

如果write的返回值不等于nbytes,则表示出错。write出错的一个常见原因时盘已写满,或者超过了一个给定进程的文件长度限制。

1.6 dup/dup2函数

dup和dup2都用来复制一个现存的文件描述符。dup返回的新文件描述符一定是当前可用文件描述符的最小值。用dup2则可以用newfd参数指定新描述符的数值。如果newfd已经打开,则先将其关闭。如果newfd等于oldfd,则dup2返回newfd,而不是关闭它。

需要注意的是两个函数返回的新文件描述符与旧文件描述符共享同一个文件表项,所以他们共享同一文件状态标志(读、写、添加等)以及同一当前文件偏移量。

2 文件共享

UNIX系统支持在不同进程间共享打开的文件。内核使用了三种数据结构表示打开的文件,他们之间的关系决定了在文件共享方面一个进程对另外一个进程可能产生的影响。

(1)每个进程在进程表中都有一个记录项,记录项中包含有一个打开文件描述符表,可将其视为一个矢量,每个描述符占一项。与每个文件描述符相关联的是:

  • 文件描述符标志(close_on_exec)。
  • 指向文件表项的指针。

(2)内核为所有打开的文件维持一张文件表。每个文件表项包含:

  • 文件状态标志(读、写、添加、同步和非阻塞等)。
  • 当前文件偏移量。
  • 指向该文件v节点表项的指针。

(3)每个打开文件(或设备)都有一个v节点结构。v节点包含了文件类型和对此文件进行各种操作的函数指针。对于大多数文件,v节点还包含了该文件的i节点(i-node,索引节点)。这些信息都是在打开文件时从磁盘上面读入内存的,所以所有关于文件的信息都是快速可供使用的。需要注意的是,创建v节点结构的目的是对一个计算机系统上的多个文件系统类型提供支持。Linux没有使用v节点,而是使用了通用i节点。但是这个i节点对应于此处的v节点,并不是文件系统里面的i节点的概念。

下图显示了一个进程的三张表之间的关系。该进程由两个不同的打开文件:一个文件打开为标准输入,另一个打开为标准输出。

下图显示了两个独立进程各自打开了同一文件,我们假定第一个进程在文件描述符3上打开该文件,而另一个进程则在文件描述符4上打开该文件。打开该文件的每个进程都得到一个文件表项,但对一个给定的文件只有一个v节点表项。每个进程都有自己的文件表项的理由时:这种安排使每个进程都有它自己对该文件的当前偏移量。

3999251

需要注意的是,可能有多个文件描述符指向同一个文件表项,比如像下面这样,fd1和fd2共享同一个文件表项,fd1、fd2、fd3共享一个v节点表:

6597955973028210657

dup/dup2返回的新描述符和原来的描述符共享同一个文件表项;fork后,父子进程对于每一个打开文件的描述符共享同一个文件表项。但是open函数每次都会打开一个新的文件表项。

另外还需要注意的是进程表项中的文件描述符标志和文件表项中的文件状态标志在作用域方面的区别,前者只用于一个进程的一个描述符,而后者则适用于指向该文件表项的任何进程中的所有描述符。

有了这些数据结构的概念后,我们来看一下上面介绍的几个I/O函数的具体操作:

  • 在完成每个write后,在文件表项中的当前文件偏移量即增加所写的字节数。如果这使得当前文件偏移量超过了当前文件长度,则在i节点表项中国的当前文件长度被设置为当前文件偏移量(也就是该文件加长了)。
  • 如果用O_APPEND标志打开了一个文件,则相应的标志也被设置到文件表项的文件状态标志中。每次对这种具有添加写标志的文件执行写操作时,在文件表项中的当前文件偏移量首先被设置为i节点表项中的当前文件长度。这就使得每次写的数据都添加到文件的当前尾端处。
  • 若一个文件用lseek定位都文件当前的尾端,则文件表项中的当前文件偏移量被设置为i节点表项中的当前文件长度。
  • lseek函数只修改文件表项中的当前文件偏移量,没有进行任何I/O操作。

3. 缓冲相关概念

传统的UNIX实现在内核中设有缓冲区高速缓存或者页面高速缓存,大多数磁盘I/O都通过缓冲进行。当将数据写入文件时,内核通常是将该数据复制到其中一个缓冲区中,如果该缓冲区尚未写满,则并不将其排到输出队列,而是等待其写满或者当内核需要重用该缓冲区以便存放其他磁盘块数据时,再将缓冲排入到输出队列,然后待其到达队首时,才进行实际的I/O操作。这种输出方式被称为延迟写。这种技术减少了磁盘读写次数,但是却降低了文件内容的更新速度,使得欲写到文件中的数据在一段时间内并没有写到磁盘上。当系统发生故障时,这种延迟可能造成文件更新内容的丢失。为了保证磁盘上实际文件与缓冲区高速缓冲中内容的一致性,UNIX系统提供了sync、fsync、fdatasync三个函数:

void sync(void);
int fsync(int fildes);
int fdatasync(int fildes);
  • sync函数只是将所有修改过的块缓冲区排入写队列,然后就返回,它并不等待实际写磁盘结束。通常称为update的系统守护进程会周期性的调用sync函数。这就保证了定期冲洗内核的块缓冲区。
  • fsync函数只对由文件描述符fildes指定的单一文件起作用,并且等待写磁盘操作结束后返回。fsync可用于数据库这样的应用程序,这种应用程序需要确保将修改过的块立即写到磁盘上。
  • fdatasync函数类似于fsync,但他只影响文件的数据部分。而除数据外,fsync还会同步更新文件的属性。
    一般,我们称UNIX的这些函数为不带缓冲的I/O(标准C里面的标准I/O为带缓冲的I/O),这里的术语不带缓冲指的是每个read和write都调用内核中的一个系统调用。而不是说读写文件时没有缓冲机制,正如上面所说,内核里面提供了缓冲机制。所以这里说的不带缓冲以及标准C里面的标准I/O带缓冲都指的是用户级别的缓冲,而不是内核级别的。

当然,UNIX提供的I/O相关的函数还有fcntl和ioctl函数,这里先不介绍了,具体用法可以查看man文档。

Linux I/O和标准I/O有很多不同的地方,前者很多都是系统调用,而后者基本都是用前者封装的库函数。一个是内核空间的,一个是用户空间的。标准C I/O参见我的另外一篇文章《ANSI C中的标准I/O》。

本文总结自APUE。