UNIX环境高级编程系列--文件I/O

发布者:有毒
发布于:2021-07-20 09:58

UNIX环境高级编程--文件I/O

一、引言

UNIX中可用的文件I/O函数:打开文件、读文件、写文件等,大部分文件I/O只需用到5个函数:open, read, write, lseek, close。本章描述的函数常被称为“不带缓冲的I/O”,这是指 read 和 write 都调用内核的一个系统调用。

 

本章还包括进程间资源共享时的原子操作,并讨论如何在多个进程间共享文件以及所涉及的内核有关数据结构。

 

此外,还包括 dup, fcntl, sync, fsync, ioctl 函数的相关说明。

二、文件描述符(File Descriptor)

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

 

惯例:UNIX系统中,shell文件会将fd与进程进行如下对应:

fd 含义 对应常量(定义在 <unistd.h> 文件)
0 标准输入 STDIN_FILENO
1 标准输出 STDOUT_FILENO
2 标准错误 STDERR_FILENO

三、函数 open 和 openat

1. 作用

打开或创建一个文件。

2. 定义

1
2
3
4
5
6
7
8
9
10
#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*/);
 
/*
Return :
成功,返回文件描述符
错误,返回-1
*/

3. 参数

1. path 参数

要打开或创建文件的名字

2. oflag 参数

关键可选项如下(可单独使用或进行“或”运算使用):

  1. O_RDONLY 只读打开 - 0

  2. O_WRONLY 只写打开 - 1

  3. O_RDWR 读、写打开 - 2

  4. O_EXEC 只执行打开

  5. O_SEARCH 只搜索打开(应用于目录),主要用于在目录打开时验证其搜索权限。

  6. O_APPEND 每次写时都追加到文件尾端。

  7. O_CREAT 文件不存在则进行创建。使用该选项时,open 函数需同时说明第三个参数 mode 以指定该新文件的访问权限位。

  8. O_EXCL 如果同时指定了 O_CREAT 但文件已存在,则报错。可以测试文件是否存在,如不存在,则创建文件,合并测试和创建文件变成一个原子操作(后续介绍)。

  9. O_SYNC 使每次 write 等待物理I/O操作完成,包括由该 write 引起的文件属性更新所需的I/O。

  10. O_TRUNC 如果文件存在,且为只读或读-写成功打开,则将其长度截断为0。

  11. O_DSYNC 使每次 write 等待物理I/O操作完成,但如果该写操作不影响读取刚写入的数据,则不需等待文件属性的更新。

  12. O_RSYNC 使每个以fd为参数进行的read操作等待,直到所有对文件同一部分挂起的写操作完成。

4. misc

由 open 和 openat 函数返回的fd一定是最小的未使用的描述符数值。一种常见的应用场景是:在标准输入、输出或错误上打开新文件。例如,应用程序先关闭标准输出(fd 1),然后打开另一个文件,在执行打开操作前就可以知道该文件一定会在fd 1上打开。

 

fd 参数将 open 和 openat 进行区分,有以下三种可能:

  1. path 参数指定的为绝对路径名时,fd 参数被忽略,open 和 openat 函数相同
  2. path 参数指定的为相对路径,fd is a file descriptor that specifies the starting location in the file system where the relative pathname is to be evaluated(此处使用英文原版描述更为准确,中文版翻译存在误差。)fd 通过打开相对路径名所在的目录获取。
  3. path 指定了相对路径, fd 参数具有特殊值 AT_FDCWD,此时路径名在当前工作目录中获取,两个函数操作类似。

openat 函数的出现主要是为了解决2个问题:

  • 让线程可以使用相对路径名打开目录中的文件,而不再只能打开当前工作目录。
  • 避免 time-of-check-to-time-of-use(TOCTTOU)错误(该错误的基本思想:如果有两个基于文件的函数调用,其中第二个调用依赖于第一个调用的结果,那么程序就是脆弱的,简言之,非原子操作。)

文件名和路径名截断

 

当文件名或路径名超过预定义的最大长度时,系统会对名字进行截断,保持长度到最大预定义的值。

 

如果最大值为14,而文件名恰好是14个字符,此时诸如 open、stat等无法确定该文件的原始名,因为函数无法判断该文件名是否发生过截断。

 

目前大多数的现代文件系统支持的最大文件名长度为255。

四、函数 creat

1. 作用

创建一个新文件

2. 定义

1
2
3
4
5
6
7
8
9
#include <fcntl.h>
 
int creat(const char *path, mode_t mode);
 
/*
Return:
成功:返回只写打开的fd
错误:返回-1
*/

3. misc

creat 函数等价于 open(path, O_WDONLY | O_CREAT | O_TRUNC, mode);.

 

creat 函数的不足之处是以只写方式打开创建的文件,如果要创建临时文件,并要写该文件,然后读该文件,就需要先调用 creat 、 close ,然后再调用 open,但是现在可以使用以下方式调用 open 实现:

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

五、函数close

1. 作用

关闭一个打开的文件

2. 定义

1
2
3
4
5
6
7
8
9
#include <unistd.h>
 
int close(int fd);
 
/*
Return:
成功:返回0
错误:返回-1
*/

3. misc

关闭一个文件时会释放该进程加在该文件上的所有记录锁(后续说明)。

 

需要特别注意的是:当一个进程终止时,内核会自动关闭它所有的打开文件。

六、函数 lseek

1. 作用

为一个打开文件设置文件偏移量

2. 定义

1
2
3
4
5
6
7
8
9
#include <unistd.h>
 
off_t lseek(int fd, off_t offset, int whence);
 
/*
Return:
成功:返回新的文件偏移量
错误:返回-1
*/

3. 参数

1. whence参数

可选值:SEEK_SET, SEEK_CUR, SEEK_END

2. offset参数

与 whence 参数相关:

whence 解释
SEEK_SET 将该文件的偏移量设置为距文件开始处 offset 个字节
SEEK_CUR 将该文件的偏移量设置为当前值加上 offset, offset 有符号,可为负
SEEK_END 将该文件的偏移量设置为文件长度加上 offset, offset 有符号,可为负

4. misc

确定打开文件的当前偏移量:

1
2
off_t currpos;
currpos = lseek(fd, 0, SEEK_CUR);

如果fd指向一个管道、FIFO或网络套接字,则返回-1,并将 errno 设置为 ESPIPE。

 

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

 

空洞文件

 

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

 

Show me your code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "apue.h" 
#include <fcntl.h>
 
char buf1[] = "abcdefghij";
char buf2[] = "ABCDEFGHIJ";
 
int main(void){
  int fd;
 
  if(fd = creat("fiel.hole", FILE_MODE) < 0// file offset = 0
    err_sys("creat error");
  if(write(fd, buf1, 10) != 10)                                // file offset = 10
    err_sys("write buf1 error");
  if(lseek(fd, 16384, SEEK_SET) == -1)                // file offset = 16384
    err_sys("lseek error"); 
  if(write(fd, buf2, 10) != 10)                                // file offset = 16394
    err_sys("write buf2 error");
 
  exit(0);
}

todo 补充运行截图

七、函数 read 和 write

1. 作用

read:从打开文件中读数据

 

write:向打开文件写数据

2. 定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <unistd.h>
 
ssize_t read(int fd, void *buf, size_t nbytes);
 
/*
Return:
成功:读取到的字节数,若已到文件末尾,返回0
错误:-1
*/
 
ssize_t write(int fd, void *buf, size_t nbytes);
/*
Return:
成功:写入的字节数
错误:-1
*/

3. misc

1. read

使实际读取的字节数少于要求读的字节数的情况:

  • 读取普通文件时,在读取到要求字节数之前已到达文件末尾。
  • 读取终端设备时,通常一次最多读一行
  • 读取网络时,网络中的缓冲机制可能造成返回值小于要求读取的字节数
  • 读取管道或FIFO时,如过管道包含的字节数少于要求的字节数,则只返回实际的字节数
  • 读取面向记录的设备(如磁带)时,一次最多返回一个记录

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

2. write

返回值通常与参数 nbytes 数值相同,常见出错是磁盘写满或超过了给定进程的文件长度限制。

 

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

八、函数 dup、dup2

1. 作用

复制一个现有的文件描述符

2. 定义

1
2
3
4
5
6
7
8
9
#include <unistd.h>
 
int dup(int fd);
int dup2(int fd, int fd2);
/*
Return:
成功:返回新的文件描述符
错误:返回-1
*/

3. misc

dup返回的新文件描述符一定是当前可用文件描述符中的最小数值。dup2可以由参数fd2指定新描述符的值。如果fd2已经打开,则先将其关闭,如果fd2等于fd,则dup2返回fd2,但是不关闭它。否则,fd2的FD_CLOEXEC文件描述符标志会被清除,这样fd2在进程调用exec时是打开状态。需要注意,函数返回的新文件描述符与参数fd共享同一个文件表项。

 

每个文件描述符都有它自己的一套文件描述符标志。

 

dup2是一个原子操作。

九、函数 sync、fsync、fdatasync

1. 作用

传统UNIX系统实现在内核中设有缓冲区高速缓存或页高速缓存,大多数磁盘I/O都通过缓冲区进行。当向文件写入数据时,内核现将数据复制到缓冲区中,然后排入队列,然后在合适的时机再写入磁盘。这种方式称为“延时写(delayed write)”。

 

为了保证磁盘上实际文件系统与缓冲区中内容的一致性,UNIX提供了这三个函数。

2. 定义

1
2
3
4
5
6
7
8
9
10
#include <unistd.h>
int fsync(int fd);
int fdatasync(int fd);
/*
Return:
成功:返回0
错误:返回-1
*/
 
void sync(void);

3. misc

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

 

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

 

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

十、函数 fcntl

1. 作用

改变已经打开的文件的属性

2. 定义

1
2
3
4
5
6
7
8
9
#include <fcntl.h>
 
int fcntl(int fd, int cmd, ... /* int arg */);
 
/*
Return:
成功:取决于cmd
错误:返回-1
*/

fcntl函数的5种功能:

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

3. misc

fcntl函数的重要性就在于在只知道打开文件的fd的情况下,就可以修改描述符的属性,在后续将对该函数进行深入讲解。

十一、函数 ioctl

1. 作用

处理文中已列函数处理不了的功能。

2. 定义

1
2
3
4
5
6
7
8
9
10
11
#include <unistd.h>  /*System V*/
 
#include <sys/ioctl.h> /*BSD and Linux*/
 
int ioctl(int fd, int request, ...);
 
/*
Return:
成功:返回其他值
错误:返回-1
*/

3. misc

ioctl函数属于杂物箱性质的函数,专门处理其他函数处理不来的I/O操作,终端I/O是使用ioctl函数最多的地方。

 

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

十二、文件共享和原子操作

文件共享基础

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

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

    • 文件描述符标志(close_on_exec,如下面的图3-7)
    • 指向一个文件表项的指针
  2. 内核为所有打开文件维持一张文件表,每个文件表项包括:

    • 文件状态标志(读、写、添写、同步和非阻塞等)
    • 当前文件偏移量
    • 指向该文件v节点表项的指针
  3. 每个打开文件(或设备)都有一个v节点(v-node)结构,包含了文件类型和对此文件进行各种操作函数的指针。大多数文件的v节点还会包含该文件的i节点(i-node,索引节点)。

    Linux没有使用v节点,只用了通用的i节点。

image-20210716104345422

 

上图显示了一个进程对应的3张表之间的关系,该进程有两个不同的打开文件,一个文件从标准输入打开,一个文件从标准输出打开。

 

如果是两个进程各自打开了同一文件,则关系如下图:

 

image-20210716105753195

 

从图上可以看到,每个进程都获得自己的文件表项,这可以使每个进程都有它自己的对该文件的当前偏移量。

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

Notes:文件描述标志文件状态标志在作用范围方面有所区别,前者只用于一个进程的一个描述符,后者则应用于指向该给定文件表项的任何进程中的所有描述符。

 

反复强调:每个进程都有自己的文件表项,其中也有它自己的当前文件偏移量。但是,当多个进程写同一文件时,有可能产生预想不到的结果。这就是后续的原子操作

原子操作

举个栗子:

 

有两个独立的进程A和B都对同一文件进行追加写操作。每个进程都已经打开了该文件,但是没有使用O_APPEND标志。此时的各数据结构之间的关系如图3-8所示。每个进程都有自己的文件表项,但是共享一个v节点表项。假设进程A调用了lseek,它将进程A的该文件当前偏移量设置为1500字节(当前文件尾端),然后内核切换进程,进程B运行。进程B执行lseek,也将其对该文件的当前偏移量设置为1500字节(当前文件尾端)。然后B调用write,他将B的该文件当前文件偏移量增加至1600。因为该文件长度已增加,所以内核将v节点中的当前文件长度更新为1600。然后,内核再次进行进程切换,使进程A恢复运行。当A调用write时,就从其当前文件偏移量(1500)处开始将数据写入文件。这样一来,进程B刚才写入到该文件中的数据就会被覆盖。

 

上述过程如下图所示:

 

image-20210716114911152

 

问题出在哪里呢?

  • “先定位到文件尾端,再写”,这个过程使用了两个分开的函数调用。、

如何解决?

  • 将上述操作合并成一个操作,使其对于其他进程而言称为一个原子操作。

原子操作, atomic operation,指不会被进程或线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何的上下文切换(context switch)。原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中一部分。原子性核心就是将整个操作视作一个整体。

 

回到上面的问题,UNIX系统为这样的操作提供了一个原子操作,即在打开文件时设置 O_APPEND标志,这使得内核在每次写操作之前,都将进程的当前偏移量设置到该文件的尾端处,于是在每次写之前就不再需要调用lseek。

 

原子操作的函数举例:

 

函数pread和pwrite:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t nbytes, off_t offset);
/*
Return:
成功:读到的字节数,若已到文件尾,返回0
出错:返回-1
*/
 
ssize_t pwrite(int fd, const void *buf, size_t nbytes, off_t offset);
/*
Return:
成功:返回已写的字节数
出错:返回-1
*/

调用pread相当于调用lseek后调用read,但是有区别:

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

调用pwrite相当于调用lseek后调用write,区别与上面类似。

十三、/dev/fd

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

 

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

 

也可以用/dev/fd作为路径名参数调用create函数,这与调用open时用O_CREATE作为第二个参数作用相同。

个人地址

Blog:https://www.v4ler1an.com
微信公众号:有毒的猫


声明:该文观点仅代表作者本人,转载请注明来自看雪