本节主要介绍不带缓冲的I/O(unbuffered I/O),不带缓冲指的是每个readwrite都会调用内核中的一个系统调用。

不带缓冲的I/O函数不是ISO C的标准函数,但是是符合POSIX的。

原子操作在多进程之间贡献文件变得相当重要。

主要涉及的函数有oepn, read, write, lseek, close; dup, fcntl, sync, fsync, ioctl

文件描述符

对于内核而言,所有打开的文件都通过文件描述符引用。

文件描述符是一个非负整数,取值范围是0~1024

$ulimit -n
1024

当进程要打开文件调用open函数时,会触发一次内核的系统调用,内核会向进程返回一个文件描述符

在符合POSIX.1的环境中,STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO分别表示文件描述符0,1,2。这些常量定义在<unistd.h>中。

常用函数

open()

#include <fcntl.h> // file control header file
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
// 成功返回最小的未用文件描述符数值,失败返回-1。

验证open函数返回的总是当前进程未使用的最小的文件描述符。

验证方法是关掉标准输入流,使用open函数打开文件,查看返回的文件描述符是否等于0,如下所示:

$ cat open_ret_test.c 
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main()
{
    //关闭标准输流
    close(STDIN_FILENO);
    //打开任意文件
    int fd = open("./fd_test", O_RDWR);
    if (fd != -1)
    {
        printf("fd=%d\n", fd);
    }
    else
    {
        perror("oepn failed");
    }
}

$ touch fd_test
$ ./open_ret_test 
fd=0

path参数是要打开或创建的文件的名称,oflag参数可用来说明此函数的多个选项。由下列一个或多个常量或运行之后构成oflag参数,常量定义在<fcntl.h>头文件中。

oflag 参数含义
O_RDONLY只读打开
O_WRONLY只写打开
O_RDWR读写打开
O_APPEND追加模式打开文件,移动文件指针偏移和写操作是一个原子操作
O_CLOEXECclose_on_exec, 将FD_CLOEXEC设置为文件描述符标志。也可以先打开文件,再使用fcntl函数设置,但存在并发风险。该标志的作用是在运行exec相关程序关闭设置了此标志的文件描述符,防止访问无权限的文件。大部分系统中没有此定义常量
O_CREAT文件不存在则创建文件,同时需要指定mode参数,mode的取值如下。
O_DIRECTORY打开目录,如果不是目录则返回-1
O_TRUNC文件存在,则截断为0
O_SYNCwrite时需要等待物理I/O完成,包括由该write引起的文件属性更新所需的I/O。
O_DSYNCwrite时需要等待物理I/O完成,但写入操作不影响读取的内容时,无需等待文件属性更新。 当文件用O_DSYNC标志打开,在重写现有部分内容时,文件时间属性不会同步变化。O_SYNC只要写入内容,数据和属性总是同步更新。
O_RSYNCread操作等待,直至所有对文件同一部分挂起的写操作完成。

下面验证O_CLOEXEC标志的作用,当有效用户变为普通用户时也能够读取打开的文件,因此存在漏洞,设置O_CLOEXEC则可以避免此问题。

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    //输出当前的有效用户名
    //保证当前用户为root
    printf("EUID=%d, UID=%d\n", geteuid(), getuid());
    int fd = open("/etc/shadow", O_RDONLY);
    if (fd != -1)
    {
        printf("/etc/shadow open successfully\n");
    }
    else
    {
        perror("open failed");
        return 0;
    }

    pid_t pid = fork();
    if (pid == 0)
    {
        //进入到子进程中
        //设置用户ID和有效用户ID
        seteuid(1000);
        setegid(1000);
        char buf[32] = {0};
        printf("EUID=%d, UID=%d\n", geteuid(), getuid());
        //从父进程中泄漏的文件描述符,可以读取文件
        while (read(fd, buf, sizeof(buf)) > 0)
        {
            printf("%s", buf);
        }
        close(fd);

        //普通用户无权限打开
        fd = open("/etc/shadow", O_RDONLY);
        if (fd != -1)
        {
            printf("/etc/shadow open successfully\n");
            close(fd);
        }
        else
        {
            perror("open failed");
        }
    }

    sleep(3);
    close(fd);
    return 0;
}

如果两个基于文件的函数调用,其中第二个调用依赖于第一个调用的结果,那么程序很脆弱。因为两个调用不是原子操作,在两个调用之间文件可能被改变。这就是TOCTTOU错误。

create()

#include <fcntl.h>

int creat(const char* path, mode_t mode);
// 成功返回文件描述符,失败返回-1。

等价于open(path, O_WRONLY|O_CREAT|O_TRUNC, mode)

缺点在于以只写方式打开文件。如果先写文件在读文件,则需要分别调用creat, close, open

代替方式open(path, O_RDWR | O_CREAT | O_TRUNC, mode)

close()

#include <unistd.h>

int close(int fd);
// 成功返回0,失败返回-1。

关闭文件会释放该进程加在该文件上的所有记录锁。

lseek()

#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);
成功返回文件偏移量,失败返回-1

每个打开的文件都有一个与之相关的值为非负整数的文件偏移量。

读写操作都是从文件偏移量处开始,并使偏移量增加所读写的字节数。

默认情况下,打开的文件的偏移量为0,除非指定O_APPEND选项。 lseek函数显示设置文件偏移量。

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

验证标准输入,管道是否可以设置偏移,示例如下:

#include <stdio.h>
#include <unistd.h>

int main()
{
    if (lseek(STDIN_FILENO, 0, SEEK_CUR) == -1)
    {
        perror("cannot seek");
    }
    else
    {
        printf("seek ok\n");
    }
    return 0;
}

结果如下:

$ ./lseek_test < /etc/passwd
seek ok
$ cat /etc/passwd | ./lseek_test 
cannot seek: Illegal seek

由此标准输入可以设置文件偏移,但是管道不可以。

当文件偏移量大于文件长度时,写入文件将会造成文件空洞,但文件空洞是否占用磁盘空间取决于文件系统。

下面代码将验证空洞文件是否会占用磁盘空间:

#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

int main()
{
    char buf1[] = "abcdefghij";
    char buf2[] = "ABCDEFGHIJ";

    int fd = open("file.hole", O_RDWR | O_CREAT | O_TRUNC,
        S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
    if (fd < 0)
    {
        perror("create file failed");
    }
    //写入文件
    if (write(fd, buf1, strlen(buf1)) != 10)
    {
        perror("write failed");
        return 0;
    }
    // offset now = 10;

    // lseek 制造空洞
    if (lseek(fd, 16384, SEEK_SET) == -1)
    {
        perror("lseek failed");
    }
    // offset now=16384

    if (write(fd, buf2, strlen(buf2)) != strlen(buf2))
    {
        perror("write failed");
        return 0;
    }
    // offset now=16394

    //创建非空洞文件
    fd = open("file.nohole", O_RDWR | O_CREAT | O_TRUNC,
        S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
    if (fd < 0)
    {
        perror("create file failed");
    }
    int i = 0;
    while (i < 1639)
    {
        if (write(fd, buf1, strlen(buf1)) != strlen(buf1))
        {
            perror("write failed");
        }
        i++;
    }

    write(fd, "abcd", strlen("abcd"));
    close(fd);
    return 0;
}

从测试结果中可以看出,无空洞的文件占用的磁盘空间为20,存在空洞的文件占用磁盘空间为8。如下所示:

$ ls -ls file.*
 8 -rw-rw---- 1 blduan blduan 16394 Feb 27 01:20 file.hole
20 -rw-rw---- 1 blduan blduan 16394 Feb 27 01:20 file.nohole

read()

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
// 返回值:读到的字节数,若已到文件尾,返回0;若出错,返回-1。

有许多情况,read实际读到的字节数小于要读取的字节数:

  1. 读普通文件时,在读到要求字节数之前到达文件尾。返回实际读到的字节数,下次在调用read时会返回0。
  2. 当从终端设备读取时,通常一次最多读一行。
  3. 当从网络读取时,网络中的缓冲机制可能会造成返回值小于要求读的字节数。
  4. 当从管道或FIFO读时,管道包含的字节少于所需的数量,read会返回实际读到的字节数。

write()

#include <unistd.h>
ssize_t write(int fd, const void* buf, size_t nbytes);
// 返回值:成功返回写入的字节数,失败返回-1。

常见的错误是磁盘已写满。

I/O效率

先说结论,read读取缓冲区和磁盘块大小一致时,所用时间最短。

文件共享和原子操作

Unix进程支持在不同进程间共享打开文件。

内核进程管理打开文件的方式

内核用于I/O的数据结构,一共有四个,分别是task_struct, files_struct, fdtable, file

进程的抽象概念是基于struct task_struct结构体,每个进程中都有一个管理该进程所有打开文件的结构体,即struct files_struct

// /usr/include/sched.h
struct task_struct{
    ...
    /* Open file information */
    struct files_struct;
    ...
}

// /usr/include/fdtable.h
/*
 * Open file table structure
 */
struct files_struct {
    // 读相关字段
    atomic_t count;
    bool resize_in_progress;
    wait_queue_head_t resize_wait;

    // 打开的文件管理结构
    struct fdtable __rcu *fdt;
    struct fdtable fdtab; //优化策略,默认情况下fdt指向fdtab,只有真正需要时才会动态分配

    // 写相关字段
    unsigned int next_fd;
    unsigned long close_on_exec_init[1];
    unsigned long open_fds_init[1];
    unsigned long full_fds_bits_init[1];
    struct file * fd_array[NR_OPEN_DEFAULT];
};

进程中管理打开文件的结构体files_struct是通过文件指针数组来管理打开的文件,分别是fd_array*fdt。这两种分别是静态数组动态数据

当打开文件数量小于NR_OPEN_DEFAULT=BITS_PER_LONG(值为系统中long型的位长)时,采用静态数据,这是出于性能考虑。

实际上,文件描述符指的就是文件指针数组fd_arrayfdt中的索引。

进程所有打开的文件构成的文件指针数组中的每一项都是一个file指针类型,其内容如下:

struct file {
    // ...
    struct path                     f_path;
    struct inode                    *f_inode;
    const struct file_operations    *f_op;

    atomic_long_t                    f_count;
    unsigned int                     f_flags;
    fmode_t                          f_mode;
    struct mutex                     f_pos_lock;
    loff_t                           f_pos;
    struct fown_struct               f_owner;
    // ...
}

f_path:标识文件名。

f_inde:i节点指针,文件系统中每个文件仅对应1个i节点。

f_pos:当前打开的文件的偏移。


Linux进程是通过这3层结构来对文件进行I/O。

即进程通过管理结构体files_struct中的文件描述符表fd_table或文件指针数组fd_array来保存打开的文件,并将索引返回作为文件描述符。文件描述表中的每一行都代表一个已打开的文件

对每个打开文件创建一个单独的文件表file 文件表中包含了打开文件的偏移以及该文件在文件系统的节点。

有了这些数据结构之后,以下操作就比较明确了。

  • 在每次完成write之后,在文件描述符表项file结构体中的当前文件偏移量会增加所写入的字节数,如果这个文件偏移量大于i节点中的文件长度,则i节点信息更新。
  • 如果使用O_APPEND打开文件,则相应标志也会被设置到file结构体中的文件状态标志中,每次对这种具有追加写标志的文件执行写操作时,都会先将文件偏移量设置为i节点中的文件长度。
  • lseek定位到文件末尾时,会修改file结构体中的文件偏移量为i节点中的文件长度。

文件共享

多个进程打开同一文件时,每个进程会创建各自的文件指针struct file,但是文件指针指向的i节点是相同的。 这样每个进程都会有各自的文件偏移量,这就是文件共享的内部机制

原子操作

原子操作指的是由多步组成的一个操作。

如果该原子操作要执行,则要么执行完所有步骤,要么一步也不执行,不能只执行步骤的一个子集。

例如追加数据到文件结尾,如果没有O_APPEND,则需要先使用lseek定位到文件尾端,再使用write写数据到文件。

这样的话如果多进程操作同一个文件时,在一个进程执行完lseek后,另一个进程已经调用完write写入数据了,这样当第一个进程再写入时就会覆盖第二个进程写入的数据。

功能函数

dup()和dup2()

#include <unistd.h>
int dup(int fd);
int dup2(int fd, int fd2);
// 成功返回新的文件描述符,失败返回-1。

dup函数返回的文件描述符一定是可用文件描述符中的最小数值。

dup2可以使用参数fd2指定新文件描述符的值。如果fd2已经打开,则先关闭。如果fd==fd2,则直接返回fd2,这样fd2FD_CLOEXEC标志就不会被清楚。

这些函数返回的新文件描述符和参数fd共享同一个文件表项,即同一个file结构体。

下面代码验证dup函数返回最小可用文件描述符

#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>

int main()
{
    // 0,1,2分别是标准输,标准输出,标准错误的文件描述符,并且进程默认是打开。
    // open函数会返回最小未用的文件描述符,如果打开正常,则fd1==3;
    int fd1 =
        open("./test", O_CREAT | O_RDWR, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
    if (fd1 != -1)
    {
        printf("%d\n", fd1);
    }
    else
    {
        perror("open failed");
        return 0;
    }
    int fd2 = open("./test1", O_CREAT | O_RDWR,
        S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);  // 如果正常打开,则fd2==4
    if (fd2 != -1)
    {
        printf("%d\n", fd2);
    }
    else
    {
        perror("open failed");
        return 0;
    }

    //现在关闭fd1,则文件描述符3为最小可用的。
    close(fd1);

    int fd3 = dup(STDIN_FILENO);
    if (fd3 != -1)
    {
        printf("%d\n", fd3);  //正常情况下 fd==3
    }
    else
    {
        perror("open failed");
        return 0;
    }

    close(fd2);
    close(fd3);
    return 0;
}

执行结果如下

$ ./dup_return_min_fd 
3
4
3

dup2函数将关闭fd2和复制fd作为一个原子操作。

sync(),fsync()和fdatasync()

Unix系统在内核实现中设有缓冲区高速缓存或页高速缓存。

当我们向文件中写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。这种方式叫做延迟写。

当内核需要重用缓冲区存放其他磁盘数据时,就需要将现有的延迟写的数据全部写入到磁盘中。

为了保存磁盘文件系统中的数据和缓冲区数据的一致性,系统提供了3个接口,如下:

#include <unistd.h>

int fsync(int fd);
int fdatasync(int fd);
// 成功返回0,失败返回-1

void sync(void);

区别如下:

函数名是否写入磁盘是否同步文件属性
sync否,仅排入队列
fsync
fdatasync

fcntl()

文件描述符标志

每个进程为打开的文件维护对应的文件描述符标志,当前的文件描述符标志仅有一个,即FD_CLOEXEC

当进程采用静态文件指针数组fd_array保存打开的文件时,文件管理结构体中的参数close_on_exec_init的对应位的值0或1代表着打开文件的FD_CLOEXEC的状态。静态数组的长度和close_on_exec_init的位长一致。

动态数组fdtable中有专门的参数unsigned long* close_on_exec来保存FD_CLOEXEC的状态。

文件状态标志

  1. 访问方式标志:O_RDONLY, O_WRONLY, O_RDWR, O_EXEC, O_SEARCH
  2. 打开时标志:O_CREAT, O_EXCL, O_TRUNC, O_NONBLOCK
  3. I/O操作方式标志:O_APPEND, O_ASYNC, O_SYNC, O_DSYNC, O_RSYNC

了解了文件描述符标志和文件状态标志的区别之后,下面才是fcntl函数的说明:

#include <fcntl.h>
int fcntl(int fd, int cmd, ...);
// 成功返回值依赖于cmd,失败返回值为-1。

fcntl函数有5个功能:

  • 复制一个已有的描述符(cmd=F_DUPFD/F_DUPFD_CLOEXEC)
  • 获取或设置文件描述符标志(cmd=F_GETFD/F_SETFD)
  • 获取或设置文件状态标志(cmd=F_GETFL/F_SETFL)
  • 获取或设置异步I/O所有权(cmd=F_GETOWN/F_SETOWN)
  • 获取或设置记录锁(cmd=F_GETLK,F_SETLK,F_SETLKW)

cmd=F_DUPED时,如果第3个参数是整形,那么返回新的文件描述符,新的文件描述符大于或等于第3个参数。

新的文件描述符和fd拥有共同的文件表项(file指针),但有其自己的文件描述符标志,其中FD_CLOEXEC描述符会被清除。

#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main()
{
    // fcntl会返回大于或等于第3个参数的新文件描述符
    // 会清除新文件描述符的FD_CLOEXEC标志
    int fd = fcntl(STDIN_FILENO, F_DUPFD, 5);  //正常情况下fd的值为5
    if (fd == -1)
    {
        perror("fcntl failed");
    }
    else
    {
        printf("%d\n", fd);
    }
    int fdFlags = fcntl(fd, F_GETFD);
    printf("%d\n", fdFlags);  // FD_CLOEXEC标志被清除,值为0
    close(fd);
    return 0;
}

结果如下:

$ ./fcntl_F_DUPFD_test 
5
0

cmd=F_DUPFD_CLOEXEC时,返回新的文件描述符,并设置与新文件描述符相关联的FD_CLOEXEC文件描述符标志,其他和F_DUPFD相同。

cmd=F_GETFDfd的文件描述符标志作为函数值返回。当前只定义了一个文件描述符标志FD_CLOEXEC

cmd=F_SETFD, 给fd的文件描述符设置标志,当前仅支持FD_CLOEXEC

#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>

int main()
{
    int fd = open("./test", O_RDWR | O_CREAT | O_TRUNC,
        S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);

    if (fd == -1)
    {
        perror("open failed");
    }
    else
    {
        printf("%d\n", fd);
    }

    int fdFlags = fcntl(fd, F_GETFD);
    printf("%d\n", fdFlags); // 默认未设置,值为0

    fcntl(fd, F_SETFD, FD_CLOEXEC);
    fdFlags = fcntl(fd, F_GETFD);
    printf("%d\n", fdFlags); // 已设置FD_CLOEXEC,值为1

    close(fd);
    return 0;
}

执行结果如下:

$ ./fcntl_F_GETFD_test 
3
0
1

cmd=F_GETFL获取对应于fd的文件状态标志。

cmd=F_SETFL对文件描述符fd设置文件状态标志。

#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>

int main()
{
    int fd = open("./test", O_RDWR | O_CREAT | O_TRUNC,
        S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
    if (fd == -1)
    {
        perror("open failed");
    }
    else
    {
        printf("%d\n", fd);
    }

    int flags = fcntl(fd, F_GETFL);
    if (flags < 0)
    {
        perror("fcntl failed");
    }
    switch (flags & O_ACCMODE)
    {
        case O_RDONLY:
            printf("read only\n");
            break;
        case O_WRONLY:
            printf("write only\n");
            break;
        case O_RDWR:
            printf("read and write\n");
            break;
        default:
            printf("unkown\n");
    }
    close(fd);
}

cmd=F_GETOWN获取当前接收SIGIOSIGURG信号的进程ID或进程组ID。

cmd=F_SETOWN设置当前接收SIGIOSIGURG信号的进程ID或进程组ID。

ioctl()

ioctl函数是I/O操作的杂物箱。

#include <sys/ioctl.h>

int ioctl(int fd, unsigned long request, ...);
// 出错返回-1

每个设备驱动程序可以定义它自己专用的ioctl命令,系统则为不同种类的设备提供通用的ioctl命令。

类别常量名头文件ioctl数
盘标号DIOxxx<sys/disklabel.h>4
文件I/OFIOxxx<sys/filio.h>14
磁带I/OMTIOxxx<sys/mtio.h>11
套接字I/OSIOxxx<sys/sockio.h>73
终端I/OTIOxxx<sys/ttycom.h>43