Unix编程学习笔记-文件IO

风尘

文章目录

  1. 1. 文件IO
  2. 2. 文件描述符
  3. 3. 函数open和openat
  4. 4. 函数creat
  5. 5. 函数close
  6. 6. 函数lseek
  7. 7. 函数read
  8. 8. 函数write
  9. 9. 原子操作
    1. 9.1. 函数pread和pwrite
  10. 10. 函数dup和dup2
  11. 11. 函数sync、fsync和fdatasync
  12. 12. 函数fcntl
  13. 13. 函数ioctl
  14. 14. /dev/fd

文件IO

不带缓冲的IO,其中不带缓冲指的是每一个readwrite都调用内核中的一个系统调用。不带缓冲的IO不是ISO C的组成部分,但是,它们是POXSIX.1SUS的组成部分。

文件描述符

文件描述符是一个非负整数,当打开一个现有文件或创建一个新文件时,内核向该进程返回一个文件描述符。当读写一个文件时,使用open或creat返回的文件描述符标识该文件,将其作为参数传送给read或write。所有打开的文件都通过文件描述符引用。

通常,UNIX 系统 shell 把文件描述符 0 与进程的标准输入关联,文件描述符 1 与标准输出关联,文件描述符 2 与标准错误关联。可以把它们替换成符号常量STDIN_FILENOSTDOUT_FILENOSTDERR_FILENO以提高可读性。这些常量定义在头文件<unistd.h>中。

函数openopenat

1
2
3
4
#include <fcntl.h>

int open(const char *path, int oflag, ... /* mode_t mode */);
int openat(int fd, const char *path, int oflag, ... /* mode_t mode */);

path参数是要打开或创建文件的路径和文件名
oflag参数用来说明此函数的多个选项(必须指定下列常量中一个且只能一个)

  • O_RDONLY 只读打开
  • O_WRONLY 只写打开
  • O_RDWR 读写打开
  • O_EXEC 只执行打开
  • O_SEARCH 只搜索打开(应用于目录)
    用于在目录打开时验证它的搜索权限。对目录的文件描述符的后续操作就不需要再次检查对该目录的搜索权限。此常量支持系统较少。
    其他可选常量
  • O_APPEND 每次写时都追加到文件的尾端。
  • O_CLOEXEC 把 FD_CLOEXEC 常量设置为文件描述符标志。
  • O_CREAT 若此文件不存在则创建它。使用此选项时,oppen 和 openat 函数需同时说明最后一个参数 mode,用mode指定该新文件的访问权限。
  • O_DIRECTORY 如果 path 引用的不是目录,则出错。
  • O_EXCL 如果同时指定了 O_CREAT,而文件已经存,则出错。用此可以测试一个文件是否存在,如果不存在,则创建此文件,这使测试和创建成为一个原子操作。
  • O_NOCTTY 如 path 引用的是终端设备,则不将该设备分配为此进程的控制终端。
  • O_NOFOLLOW 如果 path 引用的是一个符号链接,则出错。
  • O_NONBLOCK 如果 path 引用的是一个FIFO、一个块特殊文件或一个字符特殊文件,则此选项为文件的本次打开操作和后续的 I/O 操作设置非阻塞方式。
  • O_SYNC 使每次 write 等待物理 I/O 操作完成,包括由该 write 操作引起的文件属性更新所需的 I/O_TRUNC,于是也就不再需要单独的creat函数。
  • O_TRUNC 如果此文件存在,而且为只写或读-写成功打开,则将其长度截断0。
  • O_TTY_INIT 如果打开一个还未打开的终端设备,设置非标准 termios 参数值,使其符合 Single UNIX Specification。
  • O_DSYNC 使每次 write 要等待物理 I/O 操作完成,但是如果该写操作并不影响读取刚写入的数据,则不需要等待文件属性被更新。
  • O_RSYNC 使第一个以文件描述符作为参数进行的 read 操作等待,直至所有对文件同一部分挂起的写操作完成。

fd参数把两个函数区分开,共有3种可能性:
(1) path 参数指定的是绝对路径名,在这种情况下,fd参数被忽略,openat 函数就相当于open 函数。
(2) path 参数指定的是相对路径名,fd参数指出了相对路径在文件系统中的开始地址。fd参数是通过打开相对路径名所在的目录来获取。
(3) path 参数指定了相对路径名,fd参数具有特殊值AT_FDCWD。在这种情况下路径名在当前工作目录中获取,openat 函数在操作上与open 函数类似。


openat 函数是POSIX.1最新版本中新增的一类函数之一,希望解决两个问题:
(1) 让纯种可以使用相对路径名打开目录中的文件,而不再只能打开当前工作目录。
(2) 可以避免time-of-check-to-time-of-use(TOCTTOU)错误。


TOCTTOU错误是:如果有两个基于文件的函数调用,其中第二个调用依赖于第一个调用结果,那么程序是脆弱的。因为两个调用并不是原子操作,在两个函数调用之间文件可能改变了,这样也就造成了第一个调用的结果就不再有效,使得程序最终的结果是错误的。

  • 文件名和路径截断

    如果NAME_MAX是14,而试图创建一个文件名包含15个字符的新文件,早期的System V版本允许这种使用方法,但问题将文件名截断为14个字符,而且不给出任何信息,而BSD类的系统则返回出错状态,并将errno设置为ENAMETOOLONG。无声无息的截断文件名会引起无法确定该文件的原始名以及无法判断该文件名是否被截断过等问题。

    POSIX.1中,常量_POSIX_NO_TRUNC决定是要截断过长的文件名或路径名,还是返回一个出错。不同文件系统的类型,此值可以变化。可以用fpathconfpathconf来查询目录具体支持何种行为。

    _POSIX_NO_TRUNC有效,则在整个路径名超过PATH_MAX或路径名中的任一文件名超过NAME_MAX时,出错返回,并将errno设置为ENAMETOOLONG

    大多数的现代文件系统支持文件名的最大长度可以为255.因为文件名通常比这个限制要短,因此对大多数应用程序来说这个限制还未出现什么问题。

函数creat

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

1
2
3
4
#include <fcntl.h>

// 返回值:若成功,返回为只写打开的文件描述符;若出错,返回-1
int creat(const char *path, mode_t mode);

此函数等效于:

1
open(path, O_WRONLY | O_CREAT | O_TRUNC, mode);

在早期的 UNIX 系统版本中,open 的第二个参数只能是0、1或2。无法打开一个尚未存在的文件,因此需要另一个系统调用creat以创建新文件.现在,open 函数提供了选项 O_CREAT 和 O_TRUNC,于是也就不再需要单独的creat函数。
creat 的一个不足之处是它以只写方式打开所创建的文件,在提供 open 的新版本之前,如果要创建一个临时文件,并要先写该文件,然后又读该文件,则必须先调用creat、close, 然后再调用open。现在则可如下直接调用open实现。

1
open(path, O_RDWR | O_CREAT | O_TRUNC, mode);

函数close

close函数可以关闭一个打开的文件:

1
2
3
4
#include <unistd.h>

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

关闭一个文件时还会释放该进程加在该文件上的所有记录锁。当一个进程终止时,内核自动关闭它所有的打开文件,而不用显式地用 close 关闭打开文件。

函数lseek

每个打开文件都有一个与其相关联的当前文件偏移量,通常是一个非负整数。系统默认情况,当打开一个文件时,除非指定O_APPEND选项,否则该偏移量被设置为0
调用lseek函数可以显式地设置打开文件的偏移量:

1
2
3
4
#include <unistd.h>

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

offset 参数的解释与参数whence的值有关:

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

对于普通文件,其偏移量必须是非负值,但某些设备允许负的偏移量。如果文件描述符指向的是一个管道、FIFO或网络套接字,则 lseek 返回-1,并将 errno 设置为 ESPIPE。

因为偏移量可能是负值,所以在比较 lseek 的返回值时,不要比较它是否小于0,而要比较它是否等于-1。

代码 test.c
1
2
3
4
5
6
7
8
9
10
#include "common/apue.h"

main(int argc, char *argv[]) {
if (lseek(STDIN_FILENO, 0, SEEK_CUR) == -1) // 与-1比较
printf("cannot seek\n");
else
printf("seek OK\n");

exit(0);
}
交互测试
1
2
3
4
5
6
$ ./test.o < /etc/passwd // 普通文件
seek OK
$ cat < /etc/passwd | ./test.o // 管道返回-1
cannot seek
$ ./test.o < /var/spool/cron/FIFO // FIFO返回-1
cannot seek

lseek 仅将当前的文件偏移量记录在内核中,它并不引起任何 I/O 操作,该偏移量用于下一个读或写操作。
文件偏移量可以大于文件的当前长度,在这种情况下,对该文件的下一次写将加长该文件,并在文件中构成一个空洞,文件中没有被写过的字节都被读为0。文件中的空洞并不占用磁盘存储区。

函数read

调用 read 函数可以从打开文件中读取数据。

1
2
3
4
#include <unistd.h>

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

函数write

调用 write 函数向打开文件写数据。

1
2
3
4
#include <unistd.h>

// 返回值:若成功,返回已写的字节数;若出错,返回-1
ssize_t write(int fd, const void *buf, size_t nbytes);

原子操作

场景:假定有两个独立的进程A和B都对同一文件进行追加写操作。每个进程都已打开该文件,但未使用O_APPEND标志。此时,每个进程都有它自己的文件表项,但是共享一个 v 节点表项如下图:

当进程 A 调用了 lseek 函数将该文件当前进程领衔量设置为 1500 字节(当前文件尾端处)。然后内核切换到进程 B 执行 lseek 函数,也将其对该文件的当前偏移量设置为 1500 字节(当前文件尾端处)。然后 B 调用 write 函数,将 B 的该文件当前文件偏移量增加至 1600。因为该文件的长度已经增加了,所以内核将 v 节点中的的当前文件长度更新为 1600。然后,内核切换到里程 A 调用 write 函数,就从其当前文件偏移量(1500)处开始将数据写入到文件。此时覆盖了进程 B 写入到该文件中的数据。


上面场景中问题出在逻辑操作“先定位到文件尾端,然后写”,它使用了两个分开的函数调用。可以通过使用两个操作对其他进程而言成为一个原子操作的方法解决问题。
UNIX 系统为这样的操作提供了一种原子操作方法,即在打开文件时设置O_APPEND标志。这样使得内核在每次写操作之前,都将进程的当前偏移量设置到该文件的尾端处,于是在每次写之前就不再需要调用 lseek 函数。

函数preadpwrite

这两个函数是SUS扩展允许原子性定位并执行 I/O 的扩展函数。

1
2
3
4
5
6
7
#include <unistd.h>

// 返回值:读到的字节数,若已到文件尾,返回0;若出错,返回-1
ssize_t pread(int fd, void *buf, size_t nbytes, off_t offset);

// 返回值:若成功,返回已写的字节数;若出错,返回-1
ssize_t pwrite(int fd, const void *buf, size_t nbytes,off_t offset);

调用 pread 相当于调用 lseek 后调用 read,但是 pread 又与这种顺序调用有下列重要区别:

  • 调用 pread 时,无法中断其定位和读操作。
  • 不更新当前文件偏移量

调用 pwrite 也是类似的区别。

函数dupdup2

两个函数都可用来复制一个现有的文件描述符。

1
2
3
4
5
#include <unistd.h>

int dup(int fd);
int dup2(int fd, int fd2);
// 两函数的返回值:若成功,返回新的文件描述符;若出错,返回-1

dup 函数返回的新文件描述符一定是当前可用文件描述符中的最小数值。
dup2 函数参数 fd 表示已经存在的打开的文件描述符,fd2 表示指定新的文件描述符,如果 fd2 等于 fd,直接返回;如果 fd2 存在并且打开,则先关闭(close)后重新打开;如果 fd2 不存在或者没有打开则打开 fd2,并且指向 fd 所指向的文件。

dup2重定向代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "common/apue.h"
#include <errno.h>

main(int argc, char *argv[]) {
int fd, refd;
char *buf = "dup2 stdout!\n";

fd = open("/Users/windus/iwork/code/c_c++/algorithms_in_c/unix_test.txt", O_RDWR | O_CREAT, 0644);

if (fd != -1) {
// 重定向标准输出到目标文件
refd = dup2(fd, fileno(stdout));

if (refd != -1) {
printf("refd值:%d", refd);
write(refd, buf, strlen(buf));
close(fd);
}
} else {
printf("errno=%d\n", errno);
}
exit(0);
}

查看 unix_test.txt 文件内容如下:

1
2
3
$ cat unix_test.txt
dup2 stdout
refd值:1 # 从printf打印的信息也被写入进来

从结果可以看出,由于标准输出文件描述符被复制为目标文件描述符,把标准输出重定向到目标文件。所以,原本应该到标准输出(stdout)的信息,也写入到了文件中。

函数syncfsyncfdatasync

传统的 UNIX 系统实现在内核中设有缓冲区高速缓存或页高速缓存,大多数磁盘 I/O 都通过缓冲区进行。当向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。这种方式被称为延迟写(delayed write)。当内核需要征用缓冲区来存放其他磁盘块数据时,它会把所有延迟写数据块写入磁盘。
为保证磁盘上实际文件系统与缓冲区中内容的一致性,UNIX 系统提供了syncfsyncfdatasync三个函数。

1
2
3
4
5
6
7
#include <unistd.h>

int fsync(int fd);
int fdatasync(int fd);
// 返回值:成功返回0;出错返回-1

void sync(void);

sync 只是将所有修改过的块缓冲区排入写队列,然后就返回,并不等待实际写磁盘操作结束。通常,称为 update 的系统守护进程周期性地调用(一般每隔30秒) sync 函数。这就保证了定期冲洗(flush)内核的块缓冲区。命令 sync(1)也调用 sync 函数。

fsync 函数只对由文件描述符 fd 指定的一个文件起作用,并且等待写磁盘操作结束才返回。fsync 可用于数据库这样需要确保修改过的块立即写到磁盘的应用程序。

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

函数fcntl

此函数可以修改已经打开文件的属性。

1
2
3
4
#include <fcntl.h>

// 返回值:成功,则依赖于cmd;出错返回-1
int fcntl(int fd, int cmd, ... /* int arg */);

cmd参数选项:
(1) 复制一个已有的描述符

  • F_DUPFD 复制文件描述符 fd。新文件打桩符作为函数值返回。它是尚未打开的各描述符中大于或等于第3个参数值(取为整型值)中各值的最小值。新描述第二符与 fd 共享同一文件表项。但是,有它自己的在套文件描述符标志,其 FD_CLOEXEC 文件描述符标志被清除(表示该描述符在 exec 时仍保持有效)。
  • F_DUPFD_CLOEXEC 复制文件描述符,设置与新描述符关联的 FD_CLOEXEC 文件描述符标志的值,返回新文件描述符。

(2) 获取 / 设置文件描述符标志

  • F_GETFD 对应于 fd 的文件描述符标志作为函数值返回。当前只定义了一个文件描述符标志 FD_CLOEXEC。

  • F_SETFD 对 fd 设置文件描述符标志。新标志值按第3个参数(取为整型值)设置。

    很多现有与文件描述符标志有关的程序并不使用常量 FD_CLOEXEC,而是将此标志设置为0(系统默认,在 exec 时不关闭)或1(在 exec 时关闭)。

(3) 获取 / 设置文件状态标志

  • F_GETFL 对应于 fd 的文件状态标志作为函数值返回。即 open / openat 函数 oflag 参数中的常量所对应的状态。
  • F_SETFL 将文件状态标志设置为第3个参数的值(取为整型值)。可以更改的标志有:O_APPENDO_NONBLOCKO_SYNCO_DSYNCO_RSYNCO_FSYNCO_ASYNC

(4) 获取 / 设置异步I/O所有权

  • F_GETOWN 获取当前接收 SIGIO 和 SIGURG 信号的进程 ID 或进程组 ID。
  • F_SETOWN 设置接收 SIGIO 和 SIGURG 信号的进程 ID 或进程组 ID。正的 arg 指定一个进程 ID,负的 arg 表示等于 arg 绝对值的一个进程组 ID。

(5) 获取 / 设置记录锁

  • F_GETLK
  • F_SETLK
  • F_SETLKW

函数ioctl

/dev/fd

较新的系统都提供名为 /dev/fd 的目录,其目录项是名为0、1、2的文件。打开文件 /dev/fd/n 等效于复制描述符 n(假定描述符 n 是打开的)。

1
2
3
4
// 大多数系统忽略它所指定的mode,另外一些系统则要求 mode 必须是所引用的文件初始打开时所使用的打开模式的一个子集
fd = open("/dev/fd/0", mode);
// 等效于
fd = dup(0);

由上可知描述符 0 和 fd 共享同一文件表项。若描述符 0 先前被打开为只读,那么也只能对 fd 进行读操作,即使设置其他模式,仍然无法改变。

1
2
// 0 描述符为只读,所以对于 O_RDWR 模式的 fd 也不能进行写操作
fd = open("/dev/fd/0", O_RDWR);

Linux实现中的 /dev/fd 是个例外。它把文件描述符映射成指向底层物理文件的符号链接。如打开 /dev/fd/0 时,事实上正在打开与标准输入关联的文件,因此返回的新文件描述符的模式与 /dev/fd 文件描述符的模式其实并不相关。

可以使用 /dev/fd 作为路径名参数调用 creat,这与调用 O_CREAT 作为第2个参数作用相同。

在 Linux 上必须非常小心,因为 Linux 实现使用指向实际文件的符号链接,在 /dev/fd 文件上使用 creat 会导致底层文件被截断。

像 mac 等一些系统提供路径名 /dev/stdin、/dev/stdout 和 /dev/stderr,这些等效于 /dev/fd/0、/dev/fd/1、/dev/fd/2。

/dev/fd 文件主要由 shell 使用,它允许使用路径名作为调用参数,如 cat 命令:

1
2
3
4
5
$ echo 'abc' | cat -
abc
# 等效于
$ echo 'abc' | cat /dev/fd/0
abc