1. 文件控制操作:fcntl()

#include <fcntl.h>
int fcntl(int fd, int cmd, ...);
// return on success depends on cmd, or -1 on error

打开文件的状态标志

fcntl()的用途之一是针对一个打开的文件,获取或修改其访问模式和状态标志(这些值是通过指定 open()调用的 flag 参数来设置的)。要获取这些设置,应将 fcntl()的 cmd 参数设置为F_GETFL。

fcntl() 的 F_SETFL 命令来修改打开文件的某些状态标志。允许更改的标志有 O_APPEND、O_NONBLOCK、O_NOATIME、O_ASYNC 和 O_DIRECT。

使用 fcntl() 修改文件状态标志,尤其适用于如下场景:

  • 文件不是由调用程序打开的,所以程序也无法使用 open() 调用来控制文件的状态标志
  • 文件描述符的获取是通过 open() 之外的系统调用
    • 比如 pipe() 调用,该调用创建一个管道,并返回两个文件描述符分别对应管道的两端
    • 比如 socket() 调用,该调用创建一个套接字并返回指向该套接字的文件描述符

为了修改打开文件的状态标志,可以使用 fcntl () 的 F_GETFL 命令来获取当前标志的副本,然后修改需要变更的比特位,最后再次调用 fcntl () 函数的 F_SETFL 命令来更新此状态标志。因此,为了添加 O_APPEND 标志,可以编写如下代码:

int flags;
flags = fcntl(fd, F_GETFL);
if (flags == -1)
    errExit("fcntl");
flags |= O_APPEND;
if (fcntl(fd, F_SETFL, flags) == -1)
    errExit("fcntl");

2. 文件描述符与文件

文件描述符和打开的文件并不是一一对应的关系,多个文件描述符可以指向同一个打开文件,这些文件描述符可以在相同或不同的进程中打开。

  • 进程级的文件描述符表。每个进程有自己的打开文件的描述符表。其中每个条目都记录了单个文件描述符的相关信息,比如:
    • 控制文件描述符操作的标志。这是文件描述符自己的属性。
    • 对打开文件句柄的引用。
  • 系统级的打开文件表。针对所有打开的文件,内核有一个系统级的描述表,其中每个条目称为打开文件句柄,存储了与一个打开文件相关的全部信息,比如:
    • 当前文件偏移量(调用read和write时更新,或者使用lseek直接修改)。
    • 打开文件时所使用的状态标志(open的flags参数)。
    • 文件访问模式(如调用 open () 时所设置的只读模式、只写模式或读写模式)。
    • 与信号驱动 I/O 相关的设置。
    • 对该文件inode对象的引用。
  • inode表。文件系统,每个文件系统都会为其中所有文件建立一个inode表。其中条目为文件的inode信息,比如:
    • 文件类型(例如,常规文件、套接字)和访问权限。
    • 文件的各种属性,包括文件大小以及与不同类型操作相关的时间戳。

1

  • 在进程 A 中,文件描述符 1 和 20 都指向同一个打开的文件句柄(标号为 23)。这可能是通过调用 dup ()、dup2 () 或 fcntl () 而形成的。
  • 进程A的文件描述符2和进程B的文件描述符2都指向同一个打开的文件句柄(标号为73)。这种情形可能在调用 fork () 后出现(即,进程 A 与进程 B 之间是父子关系),或者当某进程通过UNIX 域套接字将一个打开的文件描述符传递给另一进程时,也会发生。
  • 此外,进程 A 的描述符 0 和进程 B 的描述符 3 分别指向不同的打开文件句柄,但这些句柄均指向 i-node 表中的相同条目(1976),换言之,指向同一文件。发生这种情况是因为每个进程各自对同一文件发起了 open () 调用。同一个进程两次打开同一文件,也会发生类似情况。

总结如下:

  • 两个不同的文件描述符,若指向同一打开文件句柄,将共享同一文件偏移量。因此,如果通过其中一个文件描述符来修改文件偏移量(由调用 read ()、write () 或 lseek () 所致),那么从另一文件描述符中也会观察到这一变化。无论这两个文件描述符分属于不同进程,还是同属于一个进程,情况都是如此。
  • 要获取和修改打开的文件标志(例如,O_APPEND、O_NONBLOCK 和 O_ASYNC),可执行 fcntl () 的 F_GETFL 和 F_SETFL 操作,其对作用域的约束与上一条颇为类似。
  • 相形之下,文件描述符标志(亦即,close-on-exec 标志)为进程和文件描述符所私有。对这一标志的修改将不会影响同一进程或不同进程中的其他文件描述符。

3. 复制文件描述符

shell指令,把myscript执行产生的标准输出和错误输出全部输出到results. log

$ ./myscript > results.log 2>&1

是重定向到标准输出没错,但此时标准输出已经定向到文件了,所以错误输出也到文件了

2>&1 :将标准错误输出转变化标准输出,可以将错误信息也输出到日志文件中

shell 通过复制文件描述符 21 实现了标准错误的重定向操作,因此文件描述符 2 与文件描述符 1 指向同一个打开文件句柄(类似于上节图中进程 A 的描述符 1 和 20 指向同一打开文件句柄的情况)。

请注意,要满足 shell 的这一要求,仅仅简单地打开 results.log 文件两次是远远不够的(第一次在描述符 1 上打开,第二次在描述符 2 上打开)

  • 首先两个文件描述符不能共享相同的文件偏移量指针,因此有可能导致相互覆盖彼此的输出。
  • 再者打开的文件不一定就是磁盘文件。

dup()

#include <unistd.h>
int dup(int oldfd);
// return (new) file descriptor on success, or -1 on error
newfd = dup(1);

在正常情况下,shell 已经代表程序打开了文件描述符 0、1 和 2,且没有其他描述符在用,dup () 调用会创建文件描述符 1 的副本,返回的文件描述符编号值为 3。

如果希望返回文件描述符 2,可以使用如下技术:

close(2);
newfd = dup(1);

dup2()

如果想进一步简化上述代码,同时总是能获得所期望的文件描述符,可以调用 dup2 ()。

#include <unistd.h>
int dup2(int oldfd, int newfd);
// returns (new) file descriptor on success, or -1 on error

如果由 newfd 参数所指定编号的文件描述符之前已经打开,那么 dup2 () 会首先将其关闭。

dup2()调用会默然忽略 newfd 关闭期间出现的任何错误。故此,编码时更为安全的做法是:在调用
dup2 () 之前,若 newfd 已经打开,则应显式调用 close () 将其关闭

上述代码可以简化为:

dup2(1, 2);
  • 若调用 dup2 () 成功,则将返回副本的文件描述符编号(即 newfd 参数指定的值)。
  • 如果 oldfd 并非有效的文件描述符,那么 dup2 () 调用将失败并返回错误 EBADF,且不关闭 newfd。
  • 如果 oldfd 有效,且与 newfd 值相等,那么 dup2 () 将什么也不做,不关闭 newfd,并将其作为调用结果返回。

fcntl()

fcntl () 的 F_DUPFD 操作是复制文件描述符的另一接口,更具灵活性

newfd = fcntl(oldfd, F_DUPFD, startfd);

该调用为 oldfd 创建一个副本,且将使用大于等于 startfd 的最小未用值作为描述符编号。该调用还能保证新描述符(newfd)编号落在特定的区间范围内。

dup2 () 和 fcntl () 二者返回的 errno 错误码存在一些差别

dup3()

文件描述符的正、副本之间共享同一打开文件句柄所含的文件偏移量和状态标志。然而,新文件描述符有其自己的一套文件描述符标志,且其 close-on-exec 标志(FD_CLOEXEC)总是处于关闭状态。

dup3()系统调用完成的工作与 dup2()相同,只是新增了一个附加参数 flag,这是一个可以修改系统调用行为的位掩码。

#define _GNU_SOURCE
#include <unistd.h>
int dup3(int oldfd, int newfd, int flags)
// returns (new) file descriptor on success, or -1 on error

目前,dup3 () 只支持一个标志 O_CLOEXEC,这将促使内核为新文件描述符设置 close-on-exec标志(FD_CLOEXEC)。

Linux 从 2.6.24 开始支持 fcntl () 用于复制文件描述符的附加命令:F_DUPFD_CLOEXEC。该标志不仅实现了与 F_DUPFD 相同的功能,还为新文件描述符设置 close-on-exec 标志。

4. 在文件特定偏移量处的I/O:pread() 和 pwrite()

系统调用 pread () 和 pwrite () 完成与 read () 和 write () 相类似的工作,只是前两者会在 offset 参数所指定的位置进行文件 I/O 操作,而非始于文件的当前偏移量处,且它们不会改变文件的当前偏移量。

#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
// returns number of bytes read, 0 on EOF, or -1 on error
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
// returns number of bytes written, or -1 on error

pread () 调用等同于将如下调用纳入同一原子操作:

off_t orig;

orig = lseek(fd, 0, SEEK_CUR); // Save current offset
lseek(fd, offset, SEEK_SET);
s = read(fd, buf, len);
lseek(fd, orig, SEEK_SET); // Restore original file offset

对 pread () 和 pwrite () 而言,fd 所指代的文件必须是可定位的(即允许对文件描述符执行lseek () 调用)。

如果需要反复执行 lseek (),并伴之以文件 I/O,那么 pread () 和 pwrite () 系统调用在某些情况下是具有性能优势的。

5. 分散输入和集中输出:readv() 和 writev()

#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
// returns number of bytes read, 0 on EOF, or -1 on error
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
// returns number of bytes written, or -1 on error

这些系统调用并非只对单个缓冲区进行读写操作,而是一次即可传输多个缓冲区的数据。数组 iov 定义了一组用来传输数据的缓冲区。整型数 iovcnt 则指定了 iov 的成员个数。iov 中的每个成员都是如下形式的数据结构。

struct iovec {
    void *iov_base; // start address of buffer
    size_t iov_len; // number of bytes to transfer to/from buffer
};

分散输入

readv () 系统调用实现了分散输入的功能:从文件描述符 fd 所指代的文件中读取一片连续的字节,然后将其散置(“分散放置”)于 iov 指定的缓冲区中。

原子性是 readv()的重要属性。从调用进程的角度来看,当调用 readv()时,内核在 fd 所指代的文件与用户内存之间一次性地完成了数据转移。

#include <sys/stat.h>
#include <sys/uio.h>
#include <fcntl.h>
#include "lib/tlpi_hdr.h"

#define STR_SIZE 100

int main(int argc, char *argv[])
{
    int fd;
    struct iovec iov[3];
    struct stat myStruct;   // First buffer
    int x;                  // Second buffer
    char str[STR_SIZE];     // Third buffer
    ssize_t numRead, totRequired;

    if (argc != 2 || strcmp(argv[1], "--help") == 0)
        usageErr("%s file\n", argv[0]);

    fd = open(argv[1], O_RDONLY);
    if (fd == -1)
        errExit("open");

    iov[0].iov_base = &myStruct;
    iov[0].iov_len = sizeof(struct stat);
    totRequired += iov[0].iov_len;

    iov[1].iov_base = &x;
    iov[1].iov_len = sizeof(x);
    totRequired += iov[1].iov_len;

    iov[2].iov_base = str;
    iov[2].iov_len = STR_SIZE;
    totRequired += iov[2].iov_len;

    numRead = readv(fd, iov, 3);
    if (numRead == -1)
        errExit("readv");

    if (numRead < totRequired)
        printf("Read fewer bytes than requested\n");

    printf("total bytes requested: %ld; bytes read: %ld\n",
            (long) totRequired, (long) numRead);

    exit(EXIT_SUCCESS);
}

集中输出

writev()系统调用实现了集中输出:将 iov 所指定的所有缓冲区中的数据拼接(“集中”)起来,然后以连续的字节序列写入文件描述符 fd 指代的文件中。

像 readv()调用一样,writev()调用也属于原子操作,即所有数据将一次性地从用户内存传输到 fd 指代的文件中。

如同 write () 调用,writev () 调用也可能存在部分写的问题。因此,必须检查 writev () 调用的返回值,以确定写入的字节数是否与要求相符。

readv () 调用和 writev () 调用的主要优势在于便捷。

在指定的文件偏移量处执行分散输入/集中输出

#define _BSD_SOURCE
#include <sys/uio.h>
ssize_t preadv(int fd, const struct iovec *iov, int iovcnt, off_t offset);
// Returns number of bytes read, 0 on EOF, or –1 on error
ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt, off_t offset);
// Returns number of bytes written, or –1 on error

6. 截断文件:truncate() 和 ftruncate() 系统调用

truncate() 和 ftruncate() 系统调用将文件大小设置为 length 参数指定的值

#include <unistd.h>
int truncate(const char *pathname, off_t length);
int ftruncate(int fd, off_t length);
// Both return 0 on success, or –1 on error

若文件当前长度大于参数 length,调用将丢弃超出部分,若小于参数 length,调用将在文件尾部添加一系列空字节或是一个文件空洞。

两个系统调用之间的差别在于如何指定操作文件:

  • truncate () 以路径名字符串来指定文件,并要求可访问该文件(可执行 x),且对文件拥有写权限。若文件名为符号链接,那么调用将对其进行解用。
  • 调用 ftruncate () 之前,需以可写方式打开操作文件,获取其文件描述符以指代该文件,该系统调用不会修改文件偏移量。
  • 若 ftruncate () 的 length 参数值超出文件的当前大小,SUSv3 允许两种行为:要么扩展该文件(如 Linux),要么返回错误。而符合 XSI 标准的系统则必须采取前一种行为。

truncate () 无需先以 open ()(或是一些其他方法)来获取文件描述符,却可修改文件内容,在系统调用中可谓独树一帜。

7. 非阻塞I/O

在打开文件时指定 O_NONBLOCK 标志,目的有二:

  • 若 open () 调用未能立即打开文件,则返回错误,而非陷入阻塞。有一种情况属于例外,调用 open () 操作 FIFO 可能会陷入阻塞。
  • 调用 open()成功后,后续的 I/O 操作也是非阻塞的。若 I/O 系统调用未能立即完成,则可能会只传输部分数据,或者系统调用失败,并返回 EAGAIN 或 EWOULDBLOCK 错误。具体返回何种错误将依赖于系统调用。Linux 系统与许多 UNIX 实现一样,将两个错误常量视为同义。

管道、FIFO、套接字、设备(比如终端、伪终端)都支持非阻塞模式。

因为无法通过 open () 来获取管道和套接字的文件描述符,所以要启用非阻塞标志,就必须使用 5.3 节所述 fcntl () 的F_SETFL 命令。

由于内核缓冲区保证了普通文件 I/O 不会陷入阻塞,故而打开普通文件时一般会忽略 O_NONBLOCK 标志。然而,当使用强制文件锁时,O_NONBLOCK标志对普通文件也是起作用的。

8. 大文件I/O

通常将存放文件偏移量的数据类型 off_t 实现为一个有符号的长整型。

应用程序可使用如下两种方式之一以获得 LFS 功能:

  • 使用支持大文件操作的备选 API。
  • 在编译应用程序时,将宏_FILE_OFFSET_BITS 的值定义为 64。

要获取 LFS 功能,推荐的作法是:在编译程序时,将宏_FILE_OFFSET_BITS 的值定义为64

  • 做法之一是利用 C 语言编译器的命令行选项:

    -cc -D_FILE_OFFFSET_BITS=64 prog.c
  • 另外一种方法,是在 C 语言的源文件中,在包含所有头文件之前添加如下宏定义,所有相关的 32 位函数和数据类型将自动转换为 64 位版本。

    #define _FILE_OFFFSET_BITS 64

若试图使用 32 位函数访问大文件(即在编译程序时,未将宏_FILE_OFFSET_BITS 的值设置为 64),调用可能会返回 EOVERFLOW 错误。例如,为获取大小超过 2G 文件的信息,若使用 stat 的 32 位版本时就会遇到这一错误。

9. 创建临时文件

mkstemp() 函数生成一个唯一文件名并打开该文件,返回一个可用于 I/O 调用的文件描述符

#include <stdlib.h>
int mkstemp(char *template);
// return file descriptor on success, or -1 on error

模板参数采用路径名形式,其中最后 6 个字符必须为 XXXXXX。这 6 个字符将被替换,以保证文件名的唯一性,且修改后的字符串将通过 template 参数传回。因为会对传入的 template参数进行修改,所以必须将其指定为字符数组,而非字符串常量。

文件拥有者对 mkstemp() 函数建立的文件拥有读写权限(其他用户则没有任何操作权限),且打开文件时使用了 O_EXCL 标志,以保证调用者以独占方式访问文件。

int fd;
char tmp[] = "/tmp/somestringXXXXXX";

fd = mkstemp(tmp);
if (fd == -1)
    errExit("mkstemp");
printf("filename is: %s\n", tmp);
unlink(tmp);    // name disappears immediately
// but the file is removed only afer close()

// I/O code

if (close(fd) == -1)
    errExit("close");

使用 tmpnam ()、tempnam () 和 mktemp () 函数也能生成唯一的文件名。然而,由于这会导致应用程序出现安全漏洞,应当避免使用这些函数。

tmpfile() 函数会创建一个名称唯一的临时文件,并以读写方式将其打开。

打开该文件时使用了 O_EXCL 标志,以防一个可能性极小的冲突,即另一个进程已经创建了一个同名文件。

#include <stdio.h>
FILE *tmpfile(void);

tmpfile () 函数执行成功,将返回一个文件流供 stdio 库函数使用。文件流关闭后将自动删除临时文件。为达到这一目的,tmpfile () 函数会在打开文件后,从内部立即调用 unlink () 来删除该文件名