Linux系统学习笔记:文件I/O

时间:2022-05-28 11:12:42

Linux支持C语言中的标准I/O函数,同时它还提供了一套SUS标准的I/O库函数。和标准I/O不同,UNIX的I/O函数是不带缓冲的,即每个读写都调用内核中的一个系统调用。本篇总结UNIX的I/O并和标准I/O进行对比。

文件描述符

内核通过文件描述符引用打开的文件,它是一个非负整数。按惯例,shell中使用0与进程的标准输入关联,1与标准输出关联,2与标准错误输出关联。依照POSIX,这些幻数应替换为符号常量 STDIN_FILENO 、 STDOUT_FILENO 、STDERR_FILENO 以提高可读性 ,定义在 <unistd.h> 中。

Linux中打开文件 /dev/fd/n 等价于复制描述符n,还有 /dev/stdin 、 /dev/stdout 、 /dev/steerr 分别等价于/dev/fd/0 、 /dev/fd/1 、 /dev/fd/2 。

fd = open("/dev/fd/0", mode); 大多数系统忽略这个函数调用所指定的mode,而另外的一些系统则要求mode必须是所引用的文件(这里是标准输入)初始打开时所使用的打开模式的一个子集。因为上面的打开等效于 fd = dup(); ,所以描述符0和fd共享同一个文件表项。正是因为共享同一个文件表项,所以他们看到的文件状态标志(也就是打开模式)应该是一样的。

在编写文件操作的或者网络通信的软件时,初学者一般可能会遇到“Too many open files”的问题。这主要是因为文件描述符是系统的一个重要资源,虽然说系统内存有多少就可以打开多少的文件描述符,但是在实际实现过程中内核是会做相应的处理的,一般最大打开文件数会是系统内存的10%(以KB来计算)(称之为系统级限制),查看系统级别的最大打开文件数可以使用sysctl -a | grep fs.file-max命令查看。与此同时,内核为了不让某一个进程消耗掉所有的文件资源,其也会对单个进程最大打开文件数做默认值处理(称之为用户级限制),默认值一般是1024,使用ulimit -n命令可以查看。在Web服务器中,通过增大系统默认文件描述符的最大值来优化服务器是最常见的方式之一。

UNIX标准的I/O函数

UNIX标准的一组I/O函数主要包括 open 、 creat 、 close 、 lseek 、 read 、 write 。

使用 open 和 creat 打开文件

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h> /* 打开或创建文件
* @return 成功返回文件描述符,出错返回-1 */
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
/* 创建文件
* @return 成功返回为只写打开的文件描述符,出错返回-1 */
int creat(const char *pathname, mode_t mode);

参数说明:

pathname
要打开或创建的文件的名字。
flags

函数选项,包括:

  • O_RDONLY ,只读打开。
  • O_WRONLY ,只写打开。
  • O_RDWR ,读写打开。

以上三个选项必须有且只有一个。可选的选项还有:

  • O_APPEND ,每次写都追加在文件尾。
  • O_CREAT ,文件不存在时创建文件,需设置 mode 参数。
  • O_EXCL ,和 O_CREAT 同用,文件已存在则出错,文件不存在则创建文件,使测试和创建成为一个原子操作。
  • O_TRUNC ,文件存在且为写打开时,长度截短为0。
  • O_NOCTTY ,对于终端设备,不将该设备分配为此进程的控制终端。
  • O_NONBLOCK ,对于FIFO、块特殊文件、字符特殊文件,设为非阻塞模式。

还有三个同步相关的选项:

  • O_SYNC , write 等待物理I/O操作完成,包括文件属性的更新。
  • O_DSYNC , write 等待物理I/O操作完成,若写不影响读取(如文件大小没变化),不等待文件属性的更新。
  • O_RSYNC , O_SYNC 的同义词。

open 返回的文件描述符一定为最小可用描述符,有时用这一特性来在标准输入/输出/错误输出上打开文件。

creat 是一个历史遗留函数,用于以前没有 O_CREAT 选项的情况,它等价于:

open(pathname, O_WRONLY|O_CREAT|O_TRUNC, mode);

使用 close 关闭文件

#include <unistd.h>

/* 关闭打开的文件
* @return 成功返回0,出错返回-1 */
int close(int fd);

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

打开的文件有一个相关联的当前文件偏移量,通常为非负整数,表示从文件开始的字节数。读/写操作从当前文件偏移量处开始,使偏移量增加读写的字节数。默认打开文件偏移量为0,以 O_APPEND 打开偏移量为文件的字节数。可以用lseek 设置文件的偏移量。

lseek 获取或设置文件的偏移量

#include <sys/types.h>
#include <unistd.h> /* 设置文件的偏移量,设为whence指定的位置加offset
* @return 成功返回新的文件偏移量,出错返回-1 */
off_t lseek(int fd, off_t offset, int whence);

参数说明:

offset
要增加的偏移量。
whence
  • SEEK_SET ,相对文件开始处。
  • SEEK_CUR ,相对文件当前位置。
  • SEEK_END ,相对文件结尾处。

lseek 并不引起I/O操作,偏移量记录在内核中。

普通文件的偏移量必须是非负整数。偏移量可以大于文件的长度,这样之后的写会形成一个空洞,空洞不占存储,其中的字节被读为0。可以写一个这样的文件,用 od -c 命令验证一下,还可用 ls -ls 命令查看磁盘块占用情况。

用 read 函数读取文件的数据

#include <unistd.h>

/* 从打开的文件读数据,从当前偏移量开始,并将偏移量增加实际读取字节数
* @return 成功返回读到的字节数,已到文件尾返回0,出错返回-1 */
ssize_t read(int fd, void *buf, size_t count);

受文件大小、网络缓冲区、管道、FIFO的实际字节数的限制,实际读到的数据可能少于要读的字节数,信号中断也会造成这种情况。终端设备通常一次最多读一行,磁带等设备一次最多读一个记录。

用 write 函数向文件写入数据

#include <unistd.h>

/* 向打开的文件写数据,从当前偏移量开始,并将偏移量增加实际写入字节数
* @return 成功返回写入的字节数,出错返回-1 */
ssize_t write(int fd, const void *buf, size_t count);

对于普通文件,写操作从文件的当前偏移量开始。如果在打开文件时,指定了O_APPEND选项,则在每次写操作之前,将文件的偏移量设置在文件的当前结尾处。

#include <stdlib.h>
#include <unistd.h>
#include "error.h" #define BUFFSIZE 4096 int main(void)
{
int n;
char buf[BUFFSIZE]; while ((n = read(STDIN_FILENO, buf, BUFFSIZE)) > 0)
if (write(STDOUT_FILENO, buf, n) != n)
err_sys("write error");
if (n < 0)
err_sys("read error");
exit(0);
}

注意linux上不同缓冲区长度对读操作的时间的影响。

文件共享

UNIX支持不同进程共享打开的文件。内核使用三种数据结构表示打开的文件:

  • 打开文件描述符表:进程在进程表中都有一个记录项,包含一张打开文件的描述符表。每个描述符占一项,包含描述符标志和指向一个文件表项的指针。
  • 文件表项:内核维持一张所有打开文件的文件表,每个文件表项包含文件状态标志、当前文件偏移量、指向文件v节点表项的指针。
  • v结点表:每个打开文件有一个v节点表,每个v节点包含文件类型、操作函数指针和文件的i节点等。

Linux将v节点和i节点实现为独立于文件系统的i节点和依赖文件系统的i节点。

不同进程共享文件时,每个进程都有一个该文件的文件表项,指向同一个v节点表。多个文件描述符也可能指向同一个文件表项,如使用 dup 函数和 fork 后的父子进程

Linux系统学习笔记:文件I/O

整个系统由内核维护一张系统级的打开文件表,这张表由许多打开文件表项组成。如果假定第一个进程在文件描述符3上打开该文件,而另一个进程在文件描述符4上打开该文件。打开该文件的每一个进程都获得各自的一个文件表项,但对一个给定的文件只有一个v节点表项。之所以每个进程都获得自己的文件表项,是因为这可以使每个进程都有它自己的对该文件的当前偏移量。

dup函数后该进程的图示:

Linux系统学习笔记:文件I/O

在一个进程的打开文件描述符表中,两个文件描述符指向同一个文件表项。但是如果通过fork的父子进程,fork之后的结构是两个进程中大小相等的文件描述符(文件描述符不同,因为在不同的进程中;但是大小相同,因为是从父进程那里复制的)都指向同一个文件表项。

注意:

文件表项要存储在内核中,打开文件描述符表可以存放在用户空间(作为一个独立的对应于每一个进程的结构,可以换出),而非进程表中。这些表也可以用多种方式实现,不必一定是数组,例如,可以将它们实现为结构的链表。

上图中的文件表和v节点表都是在内核中维护的,访问这些表,不需要进行实际的I/O操作,不需要实际的访问磁盘的操作。

图中的fd标志,是文件描述符标志,具体的解释看这里:Linux系统学习笔记:文件描述符标志

原子操作

原子操作指由多步组成的操作,执行时要么全部执行,要么一步也不执行。

多个进程共享同一个文件,可能造成进程对文件的连续的操作被打乱,这就需要使操作成为原子操作。如 O_APPEND将到尾端和写入数据组成原子操作,还有 O_CREAT 和 O_EXCL 将检查文件是否存在和创建文件组成原子操作。

pread 和 pwrite 将定位和读/写组成原子操作。

#include <unistd.h>

/* 定位并读取数据
* @return 成功返回读到的字节数,已到文件尾返回0,出错返回-1 */
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
/* 定位并写入数据
* @return 成功返回写入的字节数,出错返回-1 */
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);

dup函数

dup 函数用来复制现有的文件描述符。

#include <unistd.h>

/* 复制文件描述符,返回的新描述符为当前可用的最小值
* @return 成功返回新文件描述符,出错返回-1 */
int dup(int oldfd);
/* 复制文件描述符,新描述符用newfd指定,若它已经打开,则先将其关闭,
* 若它等于oldfd,则不关闭,返回该值
* @return 成功返回新文件描述符,出错返回-1 */
int dup2(int oldfd, int newfd);

新文件描述符和原描述符共享同一个文件表项。

也可用 fcntl 复制描述符, dup 函数等价于:

fcntl(oldfd, F_DUPFD, 0);

文件延迟写

虚拟存储器总是采用写回策略,因此数据并不马上写入磁盘,称为延迟写。延迟写减少了磁盘读写次数,提高了性能,但是在系统发生故障时可能造成新数据丢失。

写回算法:

由于Cache的内容只是主存内容的一个子集,应当与主存内容保持一致,而CPU对Cache的写入更改了Cache的内容。为此,可选用写操作策略使Cache内容与主存内容保持一致。
1、写回法(Write-Back)
当CPU写Cache命中时,只修改Cache的内容,而不是立即写入主存;只有当此块被换出时才写回主存。
使用这种方法写Cache和写主存异步进行,显著减少了访问主存的次数,但是存在数据不一致的隐患。实现这种方法时,每个Cache块必须配置一个修改位,以反映此块是否被CPU修改过。
2、全写法(Write-Through)
当写 Cache命中时,Cache与主存同时发生写修改。
使用这种方法写Cache和写主存同步进行,因而较好地维护了Cache与主存的内容一致性。实现这种方法时,Cache中的每个块无需设置修改位以及相应的判断逻辑,但由于Cache对CPU向主存的写操作没有高速缓冲功能,从而降低了Cache的功效。
3、写一次法(Write-Once)
写一次法是基于写回法并结合全写法的写操作策略,写命中与写未命中的处理方法与写回法基本相同,只是第一次写命中时要同时写入主存,以便于维护系统全部Cache的一致性。

这里说的是主存和Cache之间的写算法,磁盘和主存的写算法是一样的。  

sync 等函数可以刷新块缓冲区,将数据写入磁盘。

#include <unistd.h>

/* 将所有修改过的块缓冲区排入写队列,不等待写磁盘结束 */
void sync(void);
/* 对指定文件刷新块缓冲区,等待写磁盘结束,更新文件属性
* @return 成功返回0,出错返回-1 */
int fsync(int fd);
/* 对指定文件刷新块缓冲区,等待写磁盘结束,不更新文件属性
* @return 成功返回0,出错返回-1 */
int fdatasync(int fd);

系统的 update 守护进程会周期调用 sync 函数。

fcntl函数

fcntl 可以改变已打开文件的性质。

#include <unistd.h>
#include <fcntl.h> /* 改变已打开文件的性质
* @return 成功依赖于cmd,出错返回-1 */
int fcntl(int fd, int cmd, ... /* arg */ );

参数说明:

cmd

根据 cmd 值的不同,有以下5种功能:

  1. F_DUPFD ,复制一个现有的描述符。新描述符为大于等于 arg 的最小可用值。
  2. F_GETFD 、 F_SETFD ,获取/设置文件描述符标记文件描述符标志只有 FD_CLOEXEC ,但一般使用0或1,分别代表 exec 时不关闭或关闭,0为默认值。
  3. F_GETFL 、 F_SETFL ,获取/设置文件状态标志。可获取的文件状态标志见 open 函数的 flags 可取值,可设置的文件状态标志不包括访问模式位和创建模式位。
  4. F_GETOWN 、 F_SETOWN ,获取/设置异步I/O所有权,即接收 SIGIO 和 SIGURG 信号的进程ID或进程组ID, arg 为正为进程ID, arg 为负为等于其绝对值的进程组ID。
  5. F_GETLK 、 F_SETLK 、 F_SETLKW ,获取/设置记录锁。

例:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include "error.h" int main(int argc, char *argv[])
{
int val; if (argc != 2)
err_quit("usage: a.out <descriptor#>");
if ((val = fcntl(atoi(argv[1]), F_GETFL, 0)) < 0)
err_sys("fcntl error for fd %d", atoi(argv[1]));
switch (val & O_ACCMODE) {
case O_RDONLY:
printf("read only");
break;
case O_WRONLY:
printf("write only");
break;
case O_RDWR:
printf("read write");
break;
default:
err_dump("unknown access mode");
}
if (val & O_APPEND)
printf(", append");
if (val & O_NONBLOCK)
printf(", nonblocking");
if (val & O_SYNC)
printf(", synchronous writes");
putchar('\n');
exit(0);
}

ioctl函数

ioctl 函数是个杂物箱,用来处理剩下的I/O操作,常用在终端I/O操作上。

#include <sys/ioctl.h>

/* 处理剩余的各种I/O操作
* @return 出错返回-1 */
int ioctl(int d, int request, ...);

标准I/O库

和UNIX的I/O函数相比,标准I/O函数移植性更好,同时它还处理了缓冲区分配等细节,更便于使用。在C语言篇已经列出了标准I/O库函数,这里只和UNIX的I/O函数进行比较。

标准I/O中,文件打开为流,使用指向 FILE 对象的文件指针来引用流。对标准输入、标准输出、标准错误输出预定义了 stdin 、 stdout 、 stderr 文件指针。

标准I/O库提供了自动缓冲管理,有三种类型:

  • 全缓冲。填满缓冲区后才进行实际I/O操作,通常对磁盘上的文件。
  • 行缓冲。在输入和输出中遇到换行符时执行I/O操作,通常对终端。缓冲区满时也执行I/O操作。
  • 无缓冲。标准I/O库不进行缓冲存储,通常对标准错误输出。

可以用 setbuf 或 setvbuf 更改缓冲类型,用 fflush 刷新流,数据被传送到内核。

可以用 fopen 、 freopen 打开流,POSIX.1还增加了 fdopen ,从现有文件描述符打开流。 fclose 关闭打开的流。

getc 、 fgetc 、 getchar 读取单个字符, ungetc 送回单个字符。 putc 、 fputc 、 putchar 写入单个字符。 fgets、 gets 提供读取一行的功能, fputs 、 puts 提供写入一行的功能,注意 gets 不推荐使用。

ferror 和 feof 用于判断出错还是到达文件结尾, clearerr 清除出错标志和文件结束标志。

fread 和 fwrite 用于二进制读写,比如读写数组和结构。

流定位可以使用 ftell 和 fseek 、 rewind 。SUS标准引入了 ftello 和 fseeko ,区别是用 off_t 代替了 long 类型。C标准中还有 fgetpos 和 fsetpos ,它们用 fpos_t 类型的对象保存文件位置。

标准I/O库还提供了格式化I/O函数,包括 printf 、 vprintf 、 scanf 、 vscanf 四个函数族14个函数。

可以用 tmpnam 和 tmpfile 创建临时文件,SUS标准分别对应增加了 tempnam 和 mkstemp 。

最后,可以用 fileno 通过流获得描述符,它也是POSIX.1的扩展。

标准I/O函数提供了更多丰富的功能。由于在内核和用户程序缓冲区之间增加了标准I/O缓冲,需要复制两次数据,因此它们比对应的UNIX的I/O函数执行要慢,但并不慢很多。

#include <stdio.h>
#include <stdlib.h>
#include "error.h" int main(void)
{
char buf[MAXLINE]; while (fgets(buf, MAXLINE, stdin) != NULL)
if (fputs(buf, stdout) == EOF)
err_sys("output error");
if (ferror(stdin))
err_sys("input error");
exit(0);
}