UNIX环境高级编程(三)——文件I/O

paw5zx Lv4

文件描述符

文件描述符是一个非负整数,范围是0~OPEN_MAX-1,用于表征一个已打开的文件。内核用它来标识进程正在访问的文件。

当进程创建时,操作系统内核默认为它打开了3个文件描述符,它们都链接向终端:

  • 0:标准输入
  • 1:标准输出
  • 2:标准错误

一般我们应使用STDIN_FILENOSTDOUT_FILENOSTDERR_FILENO来替代这三个幻数,从而提高可读性。这三个常量在<unistd.h>中定义。

每个进程的标准输入、标准输出和标准错误是独立的,每个进程拥有自己的标准输入输出错误。

Paw5zx注:

文件描述符是进程级别的资源标识符,由各个进程独立管理。

Q:如果没有终端(如以守护进程方式启动的服务),且代码中没有显式重定向,此时在进程中向标准输出写入数据会怎样?

open和openat函数

openopenat函数用于打开或创建一个文件

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

// 成功返回文件描述符;失败返回-1
int open(const char* path, int oflag,.../*mode_t mode*/);
int openat(int fd, const char*path, int oflag,.../*mode_t mode */);

参数:

  • path:要打开或创建的文件的名字
  • oflag:用于指定文件状态标志,标志决定了文件打开的模式和行为。用下列一个或多个常量进行”或”运算构成oflag参数
    • O_RDONLY:只读打开
    • O_WRONLY:只写打开
    • O_RDWR:读、写打开
    • O_EXEC:只执行打开
    • O_SEARCH:只搜索打开(应用于目录)。不常见,且一般Linux系统通常不实现O_SEARCH标志。
      上述五个访问模式标志必须指定且只能指定一个
      下列常量是可选的:
    • O_APPEND:每次写时都追加到文件的尾端
    • O_CLOEXEC:将FD_CLOEXEC常量设置为文件描述符标志
    • O_CREAT:若文件不存在则创建。在使用此选项时,需要同时说明参数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
    • O_TRUNC: 如果此文件存在,且为O_WRONLY或者O_RDWR成功打开,则将其长度截断为0
    • O_RSYNC:使每一个以文件描述符作为参数的read操作等待,直到所有对文件同一部分挂起的写操作都完成
    • O_DSYNC:每次write等待物理I/O完成,但不包括由write操作引起的文件属性更新所需的I/O
  • mode:文件访问权限,在<sys/stat.h>中定义
    • S_IRUSR:用户读
    • S_IWUSR:用户写
    • S_IXUSR:用户执行
    • S_IRGRP:组读
    • S_IWGRP:组写
    • S_IXGRP:组执行
    • S_IROTH:其他读
    • S_IWOTH:其他写
    • S_IXOTH:其他执行

对于openat函数:

  • 如果path指定的是绝对路径名,则fd被忽略。openat等价于open
  • 如果path指定的是相对路径名,fd是有效的并指向一个目录。那么path将被解释为相对于这个目录的路径。
  • 如果path指定的是相对路径名,而fd是常量AT_FDCWD,则path被解释为相对于当前工作目录。被打开文件将在当前工作目录中查找。

Paw5zx注:

虽然逻辑上看起来是先判断path是绝对路径还是相对路径,但实际上在处理openat()调用时,首先是确定fd的状态,因为它决定了如何处理path。如果fd无效,且path不是绝对路径,通常会导致错误。

creat函数

creat函数用于创建一个新文件

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

// 成功返回以只写方式打开的文件描述符;失败返回-1
int creat(const char*path, mode_t mode);

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

参数:类似open函数

注意

  • 此函数以只写方式打开文件,如需读取该文件,需要先关闭,然后以读方式打开文件
  • 若文件已存在则将文件截断为0

Paw5zx注:

早期open函数不支持创建新文件,因此提供了creat函数。现在open提供了O_CRAETO_TRUNC,也就不需要单独的creat函数了。

close函数

close函数用于关闭一个已打开的文件

1
2
3
4
#include<unistd.h>

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

参数:

  • fd:待关闭文件的文件描述符

注意:

  • 进程关闭一个文件会释放该进程加在该文件上的所有记录锁。
  • 当一个进程终止时,内核会自动关闭进程所有的打开的文件。

文件偏移量

文件偏移量通常是一个非负整数,用于度量从文件开始处计算的字节数。通常读写操作都从当前文件偏移量处开始。注意:

  • 当打开一个文件时,除非指定O_APPEND选项,否则文件偏移量被设置为0
  • 如果文件描述符指向的是一个管道、FIFO、或者网络套接字,则无法设定文件偏移量,lseek将返回-1 ,并且将errno被设置为ESPIPE
  • 对于普通文件,其当前文件偏移量必须是非负整数。但是某些设备可以允许负的偏移量
  • 当前文件偏移量可以大于文件的当前长度。此时对该文件的下一次写操作将加长该文件,并且在文件中构成一个空洞。空洞中的内容位于文件中但是没有被写过,其字节被读取时都被读为0。这个空洞在文件中占位,但不占用磁盘空间

lseek函数

lseek函数用于显式地为已打开文件设置偏移量

1
2
3
4
#include<unistd.h>

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

参数:

  • fd:已打开文件的文件描述符
  • offset
    • 如果whenceSEEK_SET,则将该文件的偏移量设置为距离文件开始处offset个字节
    • 如果whenceSEEK_CUR,则将该文件的偏移量设置为当前值加上offset个字节,offset可正,可负
    • 如果whenceSEEK_END,则将该文件的偏移量设置为文件长度加上offset个字节,offset可正,可负
  • whence:必须是SEEK_SETSEEK_CURSEEK_END三个常量之一

注意:

  • 由于某些设备可以允许负的偏移量,因此对于lseek,不能判断它返回值是否小于0,而是要根据是否等于-1来判断是否出错。
  • lseek并不会引起任何I/O操作,lseek仅仅将当前文件的偏移量记录在内核中。

read函数

read函数用于从已打开文件中读数据

1
2
3
4
#include<unistd.h>

// 成功返回实际读到的字节数;若读取时当前偏移量已位于尾端,则返回0;失败返回-1
ssize_t read(int fd,void *buf,size_t nbytes);

参数:

  • fd:已打开文件的文件描述符
  • buf:存放读取内容的缓冲区的地址(由程序员手动分配)
  • nbytes:期望读到的字节数

读操作从文件的当前偏移量开始,在成功返回之前,文件的当前偏移量会增加实际读到的字节数。

有多种情况可能导致实际读到的字节数少于期望读到的字节数:

  • 读普通文件时,在读到期望字节数之前到达了文件尾端。例如,到达文件尾端之前有30字节,期望读100字节,则read返回30,再次调用会返回0
  • 当从终端设备读时,通常一次最多读取一行(终端默认是行缓冲的)
  • 当从网络读时,网络中的缓存机制可能造成返回值小于期望读到的字节数
  • 当从管道或者FIFO读时,若管道包含的字节少于所需的数量,则read只返回实际可用的字节数
  • 当从某些面向记录的设备(如磁带)中读取时,一次最多返回一条记录
  • 当一个信号造成中断,而已读了部分数据时

write函数

write函数用于向已打开文件写数据

1
2
3
4
#include<unistd.h>

// 成功返回一些的字节数;失败返回-1
ssize_t write(int fd,const void *buf,size_t nbytes);

参数:

  • fd:已打开文件的文件描述符
  • buf:存放待写入内容的缓冲区的地址(由程序员手动分配)
  • nbytes:期望写入的字节数

write的返回值通常都是与nbytes相同。否则表示出错。write出错的一个常见原因是磁盘写满,或者超过了一个给定进行的文件长度限制。

对于普通文件,写操作从文件的当前偏移量处开始。如果打开文件时指定了O_APPEND选项,则每次写操作之前,都会将文件偏移量设置在文件的当前结尾处。在一次成功写之后,该文件偏移量增加实际写的字节数。

文件共享

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

  • 每个进程在进程表中都有一个记录项,记录项中包含一个打开文件描述符表,可将其视为矢量,每个文件描述符占用一项。与每个文件描述符相关联的是:
    • 文件描述符标志
    • 指向一个文件表项的指针
  • 内核为所有打开的文件维持一张文件表。每个文件表项包含:
    • 文件状态标志(读、写、添写、同步和阻塞等)
    • 当前文件偏移量
    • 指向该文件v节点表项的指针
  • 每个打开的文件(或设备)都有一个v节点(vnode)结构。v节点
    • 包含文件类型和对此文件进行各种操作函数的指针。
    • 对于大多数文件,v节点还包含了该文件的i节点(inode)

Paw5zx注:

Linux没有使用vnode,而是使用了通用inode结构。

Linux文件系统分析请见:

下图显示了一个进程对应的三张表之间的关系。该进程有两个不同的打开文件:一个文件从标准输入打开(fd为0),另一个从标准输出打开(fd为1)

现在讨论两个独立的进程各自打开同一个文件的情况。假设进程A在文件描述符3上打开这个文件,进程B在文件描述符2上打开这个文件。则有下图所示的关系

进程A和B都有自己的文件表项,这是因为这样可以使每个进程都有它自己的对该文件的当前偏移量。

现在再对前面的一些操作做进一步说明:

  • 进程每次write之后,文件表项中当前文件偏移量即增加所写入的字节数。若这导致当前文件偏移量超过当前文件长度,则修改i节点的当前文件长度,设为当前文件偏移量
  • O_APPEND标志打开一个文件,则相应标志也设置到文件表项的文件状态标志中。每次对具有追加写标志的文件执行写操作时,文件表项中的当前文件偏移量首先被置为i节点中的文件长度。这就使得每次写入的数据都追加到文件的当前尾端处
  • 若用lseek定位到文件当前的尾端,则文件表项的当前文件偏移量设置为i节点中的当前文件长度(注意,这与用O_APPEND标志打开文件是不同的,详见原子操作
  • lseek函数只修改文件表项中的当前文件偏移量,不进行任何I/O操作
    对齐
    注意:
  • 可能有多个文件描述符指向同一文件表项,请见dup函数
  • 文件描述符标志和文件状态标志是不同的,前者针对单个进程的单个文件描述符,后者则应用于所有持有该文件的任何进程的所有描述符

原子操作

当一个进程要将数据追加到文件尾端时,早期UNIX不支持O_APPEND标志,所以需要

1
2
3
4
if (lseek(fd, OL, 2) < 0)       /*① position to EOF*/
err_sys("lseek error");
if (write(fd, buf, 100) != 100) /*② and write*/
err_sys("write error");

但是对于多个进程,如进程A和B同时打开同一个文件,每个进程有自己的文件表项,但是它们共享一个v节点表项。假设A进行了步骤①,文件当前偏移量被设置为1500,此时内核切换进程。进程B进行步骤①也将当前偏移量设置为1500,然后进行步骤②写入100字节,此时文件当前偏移量为1600。然后内核又切换到进程A,A进行步骤②,但是A是在其当前文件偏移量1500处写入数据,这样就覆盖了进程B写入的数据。

究其原因就是步骤①和②是两个函数调用,不是原子操作。UNIX系统的解决方案就是提供了一种原子操作的方法,即设置O_APPEND标志,使得内核在每次写操作前都会将当前偏移量设置到文件末尾,这样在调用write之前就不需要调用lseek了。

此外还有原子定位读写操作:

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

// 返回值同read
ssize_t pread(int fd,void*buf,size_t nbytes,off_t offset);
// 返回值同write
ssize_t pwrite(int fd,const void*buf,size_t nbytes,off_t offset);

参数:

  • fd:打开的文件描述符
  • buf:读出数据存放的缓冲区/写到文件的数据的缓冲区
  • nbytes:期望读出/写入文件的字节数
  • offset:指定的文件偏移量,从此值处开始读/写

注意:

  • preadpwrite不更新当前文件偏移量

dup和dup2函数

dupdup2函数用于复制一个现有的文件描述符

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

// 成功返回新文件描述符,失败返回-1
int dup(int fd);
int dup2(int fd,int fd2);

参数:

  • fd:待复制的文件描述符
  • fd2:由用户指定的新文件描述符

对于dup,返回的新文件描述符一定是当前可用的文件描述符中最小的
对于dup2,可以使用fd2指定新描述符的值;

  • 如果fd2已经是被打开的文件描述符且不等于fd,则先将其关闭,然后再打开(注意关闭再打开是一个原子操作)
  • 如果fd2等于fd,则直接返回fd2,而不作任何操作

任何情况下,返回的文件描述符都与参数fd共享同一个文件表项(因此文件状态标志以及文件偏移量都会共享)。

dupdup2不会复制文件描述符标志

sync、fsync和fdatasync函数

UNIX操作系统在内核中设有缓冲区高速缓存或也高速缓存,大多数磁盘I/O都通过缓冲区进行。当我们向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。这种方式称为延迟写(delayed write)。

  • 当内核需要重用缓冲区来存放其他数据时,它会把所有延迟写的数据块写入磁盘
  • 用户也可以调用下列函数来显式的将延迟写的数据块写入磁盘
1
2
3
4
5
6
7
#include<unistd.h>

// 无返回值
void sync(void);
// 成功返回0;失败返回-1
int fsync(int fd);
int fdatasync(int fd);

参数:

  • fd:指定的打开的文件描述符

区别:

  • sync:将所有修改过的块缓冲区排入写队列,然后返回,它并不等待时机写磁盘结束。(update系统守护进程会周期性调用sync函数;命令sync也会调用sync函数)
  • fsync:只对由fd指定的单个文件起作用,等待写磁盘操作结束才返回
  • fdatasync:类似fsync,但是它只影响文件的数据部分。fsync还会同步更新文件的属性

fcntl函数

fcntl函数可以改变已打开文件的属性

1
2
3
#include<fcntl.h>
// 成功,返回值依赖cmd;失败返回-1
int fcntl(int fd,int cmd,.../* int arg */);

参数:

  • fd:已打开文件的描述符
  • cmd
    • F_DUPFD:复制文件描述符fd。新文件描述符作为函数值返回。返回值是尚未打开的文件描述符中大于或等于arg的最小值。新文件描述符与fd共享同一个文件表项,但是新描述符有自己的一套文件描述符标志,其中FD_CLOEXEC文件描述符标志不会被设置。
    • F_DUPFD_CLOEXEC:类似FD_CLOEXEC,只不过,无论原文件描述符是否设置FD_CLOEXEC,新文件描述符的FD_CLOEXEC都会被设置。
    • F_GETFD:获取文件描述符标志。函数返回文件描述符fd的文件描述符标志。
    • F_SETFD:设置fd的文件描述符标志为arg,也可以用于清除文件描述符标志(如arg为~FD_CLOEXEC),注意要先F_GETFD,然后在返回值的基础上设置新标志值,不能直接调用F_SETFD设置,这样会清除之前设置的其他标志
    • F_GETFL:获取文件描述符的状态标志。特别的,如何要获取文件访问模式标志,则需要将函数返回值与上O_ACCMODE,其余状态标志则不需要
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      int fd_flags = fcntl(fd, F_GETFL);
      if (-1 == fd_flags)
      {
      // 错误处理
      }
      else
      {
      // 检查访问模式标志
      int acc_mode = fd_flags & O_ACCMODE;
      if (O_RDONLY == acc_mode)
      {
      }
      else if (O_WRONLY == acc_mode)
      {
      }

      // 检查其他标志
      if (fd_flags & O_NONBLOCK)
      {
      }
      if (fd_flags & O_APPEND)
      {
      }
      }
    • F_SETFL:设置fd的文件状态标志为arg。不允许改变文件的访问模式标志,可以更改的标志是:O_APPENDO_NONBLOCKO_SYNCO_DSYNCO_RSYNCF_ASYNCO_ASYNC。类似F_SETFD,要在F_GETFL返回值基础上设置新标志值。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      int flags = fcntl(fd, F_GETFL, 0);  // 获取当前的标志
      if (flags == -1)
      {
      // 错误处理
      }
      else
      {
      flags |= O_NONBLOCK; // 添加非阻塞标志
      flags |= O_APPEND; // 添加追加模式标志
      if (fcntl(fd, F_SETFL, flags) == -1)
      {
      // 错误处理
      }
      }
    • F_GETOWN:获取当前接收SIGIOSIGURG信号的进程ID或者进程组ID,返回正数则为进程ID,负数则其绝对值为进程组ID
    • F_SETOWN:设置当前接收SIGIOSIGURG信号的进程ID或者进程组ID为arg。若arg是正值,则设定进程ID;若arg是个负值,则设定进程组ID
    • F_GETLKF_SETLKF_SETLKW:获取/设置文件记录锁
  • arg:依赖于具体命令

ioctl函数

ioctl函数用于设备特定的输入/输出操作,它提供了一种操作设备文件(通常是设备驱动程序所代表的文件)的方法,这个操作无法使用上述系统调用(如readwriteopenclose等)实现。

1
2
3
4
5
#include <unistd.h>     /* System V */
#include <sys/ioctl.h> /* BSD and Linux */

// 成功返回其他值;失败返回-1
int ioctl(int fd, int request, ...);

参数:

  • fd:已打开文件的描述符
  • request:特定操作的编号,这个编号定义了ioctl要执行的具体任务。这些任务可以是从设备读取配置、向设备发送命令,或是执行设备的启动、停止、重置等操作。
  • ...:取决于请求码,通常是指向数据的指针或值

/dev/fd

/dev/fd是一个目录,该目录下是名为0、1、2等的文件。打开文件/dev/fd/n等效于复制描述符(假定描述符n是打开的)

对于fd=open("/dev/fd/0", mode)

  • fd和文件描述符0共享同一个文件表项
  • 大多数系统忽略mode参数,即使用已经打开的文件描述符的访问模式;另一些系统则要求mode必须是已经打开的文件描述符访问模式的子集
    1
    2
    // 若文件描述符0先前以只读打开,那么即使下述调用成功,我们也不能对fd进行写操作
    fd = open("/dev/fd/0", ORDWR);
  • 也可以将/dev/fd/n作为参数调用creat,这与调用open时用O_CREAT作为参数效果相同

Paw5zx注:

Linux系统中/dev/fd是个例外,它把文件描述符映射为指向底层物理文件的符号链接。例如当打开/dev/fd/0时,实际上是在尝试以新的模式直接打开与标准输入关联的文件。因此,即便原始的文件描述符(比如标准输入)是以某种特定的模式打开的(比如只读),Linux中的open("/dev/fd/0", ORDWR)允许你尝试以不同的模式重新打开它。

此外,由于上述原因,当将/dev/fd/n作为参数传入creat时,对应的文件会被截断,从而丢失所有已存在的数据。

参考文章

1.《APUE_notes》

  • 标题: UNIX环境高级编程(三)——文件I/O
  • 作者: paw5zx
  • 创建于 : 2025-01-09 00:23:24
  • 更新于 : 2025-02-09 09:50:22
  • 链接: https://paw5zx.github.io/APUE-note-3/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论