文件系统_文件IO

主要参考:http://doc.embedfire.com/linux/imx6/base/zh/latest/index.html

1 文件系统

​ 在 Linux 系统中有一个重要的概念:一切皆文件,它把一切资源都看作是文件,包括硬件设备,通常称为设备文件。

1.1 存储设备文件系统

​ 为了高效地存储和管理数据,文件系统在存储介质上建立了一种组织结构,这些结构包括操作系 统引导区、目录和文件,就如同图书馆给不同类的书籍进行类、编号,放在不同的书架上。不同的管理 理念引出了不同的文件系统标准,如 FAT32NTFSexFAText2/3/4 就是指不同类型的 标准,除此之外,还有专门针对 NAND 类型设备 的文件系统 jffs2yaffs2 等等。

​ 正是有了文件系统,计算机上的数据才能以文件的形式呈现给用户,下面简单介绍一下各种不同标准文件系统的特性:

  • FAT32 :兼容性好,STM32MCU 也可以通过 Fatfs 支持 FAT32 文件系统,大部分 SD 卡或 U 盘出厂 默认使用的就是 FAT32 文件系统。它的主要缺点是技术老旧,单个文件不能超过 4GB ,非日志型文件系统。

  • NTFS :单个文件最大支持 256TB 、支持长文件名、服务器文件管理权限等,而且NTFS是日志型 文件系统。但由于是日志型文件系统,会记录详细的读写操作,相对来说会加快 FLASH 存储器的损耗。文件系统的日志功能是指,它会把文件系统的操作记录在磁盘的某个分区,当系统发生故障时,能够 尽最大的努力保证数据的完整性。

  • exFAT :基于 FAT32 改进而来,专为 FLASH 介质的存储器设计(SD 卡、U 盘),空间浪费少。单个文件最大支持 16EB ,非日志文件系统。

  • ext2 :简单,文件少时性能较好,单个文件不能超过 2TB 。非日志文件系统。

  • ext3 :相对于 ext2 主要增加了支持日志功能。

  • ext4 :从 ext3 改进而来,ext3 实际是 ext4 的子集。它支持 1EB 的分区,单个文件最大支持 16TB ,支持无限的子目录数量,使用延迟分配策略优化了文件的数据块分配,允许自主控制是否使用日志的功能。

  • jffs2yaffs2jffs2yaffs2 是专为 FLASH 类型存储器设计的文件 系统,它们针对 FLASH 存储器的特性加入了擦写平衡和掉电保护等特性。由于 NorNAND FLASH 类 型存储器的存储块的擦写次数是有限的(通常为10万次),使用这些类型的文件系统可以减少对存储器的损耗。

  • ubifs :用于固态存储设备上,并与 LogFS 相互竞争,作为 jffs2 的后继文件系统之一。由于Nand Flash 容量的暴涨,yaffs2 等皆无法操控大的 Nand Flash 空间。ubifs 通过子系统 UBI 处理与 MTD device 之间的动作。与 jffs2 一样,ubifs 建构于 mtd 之上,因而与一般的块设备不兼容。

​ 总的来说,在 Linux 下,ext2 适用于 U 盘(但为了兼容,使用得比较多的还是 FAT32exFAT ),日常应用推 荐使用 ext4 ,而 ext3 使用的场景大概就只剩下对 ext4 格式的稳定性还有疑虑的用户了,但 ext4 从2008年就已结束 实验期,进入稳定版了,可以放心使用。

Linux 内核本身也支持 FAT32 文件系统,而使用 NTFS 格式则需要安装额外的工具如 ntfs-3g 。所以使用开发板出厂的 默认 Linux 系统时,把 FAT32 格式的 U 盘直接插入到开发板是可以自动挂载的,而 NTFS 格式的则不支持。主机上的 Ubuntu 对于 NTFSFAT32U 盘都能自动识别并挂载,因为 Ubuntu 发行版安装了相应的支持。目前微软已公开 exFAT 文件系统的标准,且已把它开源至 Linux,未来 Linux 可能 也默认支持 exFAT

​ 对于非常在意 FLASH 存储器损耗的场合,则可以考虑使用 jffs2yaffs2 等文件系统。

​ 在 Linux 下,可以通过如下命令查看系统当前存储设备使用的文件系统:

$ df -T
Filesystem     Type     1K-blocks      Used  Available Use% Mounted on
overlay        overlay  144053944  10239336  126474016   8% /
tmpfs          tmpfs        65536         0      65536   0% /dev
/dev/md126p4   ext4    3639352864 670319072 2784142248  20% /sdk
tmpfs          tmpfs     16442644         0   16442644   0% /proc/acpi

1.2 伪文件系统

​ 除了前面介绍的专门用于存储设备记录文件的文 件系统外,Linux 内核还提供了 procfssysfsdevfs 等伪文件系统。

​ 伪文件系统存在于内存中,通常不占用硬盘空间,它以文件的形式,向用户提供了访问系统内核数据的接口。用户和应用程序 可以通过访问这些数据接口,得到系统的信息,而且内核允许用户修改内核的某些参数。

1.2.1 procfs 文件系统

procfs“process filesystem“ 的缩写,所以它 也被称为进程文件系统,procfs 通常会自动挂载在根 目录下的 /proc 文件夹。procfs 为用户提供内核状态和进程信息的接口,功能相 当于 Windows 的任务管理器。

/proc 各个文件的作用:

名字功能
数字表示的是进程的 PID 号,系统中当前运行的每一个进程都有对应的一个目录,用于记录进程所有相关信息。
对于操作系统来说,一个应用程序就是一个进程
self是一个软链接,指向了当前进程的目录,通过访问 /proc/self/ 目录来获取当前进程的信息,就不用每次都获取 pid
thread-self是一个软链接,指向了当前线程,访问该文件,等价于访问“当前进程 pid/task/ 当前线程 tid ”`的内容。
一个进程,可以包含多个线程,但至少需要一个进程,这些线程共同支撑进程的运行。
version记录了当前运行的内核版本,通常可以使用命令 “uname –r”
cpuinfo记录系统中 CPU 的提供商和相关配置信息
modules记录了目前系统加载的模块信息
meminfo记录系统中内存的使用情况,free 命令会访问该文件,来获取系统内存的空闲和已使用的数量
filesystems记录内核支持的文件系统类型,通常 mount 一个设备时,如果没有指定文件系统并且它无法确定文件系统类型时,mount 会尝试包含在该文件中的文件系统,除了那些标有 “nodev” 的文件系统。
1.2.2 sysfs 文件系统

Linux 内核在 2.6 版本中引入了 sysfs 文件系统,sysfs 通常会自动挂载在根目录下的 sys 文件夹。sys 目录下的文 件/文件夹向用户提供了一些关于设备、内核模块、文件系统以及其他内核组件的信息,如子目录 block 中存放了所 有的块设备,而 bus 中存放了系统中所有的总线类型,有 i2cusbsdiopci 等。下图中的虚线表示软连接,可以看到所有跟设备 有关的文件或文件夹都链接到了 device 目录下,类似于将一个大类,根据某个特征分为了无数个种类,这样使得 /sys 文件夹的结构层次清晰明了。

在这里插入图片描述

/sys 各个文件的作用:

名字功能
block记录所有在系统中注册的块设备,这些文件都是符号链接,都指向了/sys/devices目录。
bus该目录包含了系统中所有的总线类型,每个文件夹都是以每个总线的类型来进行命名。
class包含了所有在系统中注册的设备类型,如块设备,声卡,网卡等。文件夹下的文件同样也是一些链接文件,指向了/sys/devices目录。
devices包含了系统中所有的设备,到跟设备有关的文件/文件夹,最终都会指向该文件夹。
module该目录记录了系统加载的所有内核模块,每个文件夹名以模块命名。
fs包含了系统中注册文件系统。

​ 概括来说,sysfs 文件系统是内核加载驱动时,根据系统上的设备和总线构成导出的分级目录,它是系统上设备的直观反应,每个设备在 sysfs 下都有唯一的对应目录,用户可以通过具体设备目录下的文件访问设备。

1.2.3 devfs 文件系统

​ 在 Linux 2.6 内核之前一直使用的是 devfs 文件系统管理设备,它通 常挂载于 /dev 目录下。devfs 中的每个文件都对应一个设备,用户也可以通过 /dev 目录下的文件访 问硬件。在 sysfs 出现之前,devfs 是在制作根文件系统的时候就已经固定的,这不太方便使用,而当代的 devfs 通常会在系统运行时使用名为udev 的工具根据 sysfs 目录生成 devfs 目录。

1.3 虚拟文件系统

​ 除了前面提到的存储器文件系统 FAT32ext4 ,伪文件系统 /proc/sys/dev 外,还有内存文件系统 ramfs ,网络文件系统 nfs 等等,不同的文件系统标准,需要使用不同的程序逻辑实现访问,对外提供的访问接口可能也稍有差异。但是我们在编写应用程序时,大都可以通过类似 fopenfreadfwriteC 标准库函数访问文件,这都是虚拟文件系统的功劳。

Linux 内核包含了文件管理子系统组件,它主要实现了虚 拟文件系统(Virtual File SystemVFS),虚拟文件系统屏蔽了各种硬件上的差异以及具体实现的细节,为所有的硬件设备提供统一的接口,从而达到设备无关性的 目的,同时文件管理系统还为应用层提供统一的 API 接口。

​ 在 Linux 下,一个与文件操作相关的应用程序结构如下图:

在这里插入图片描述

​ 总的来说,为了使不同的文件系统共存, Linux 内核在用户层与具体文件系统之前增加了虚拟文件系统中间层,它对复杂的系统进行抽象化,对用户提供了统 一的文件操作接口。无论是 ext2/3/4FAT32NTFS 存储的文件,还是 /proc/sys 提供 的信息还是硬件设备,无论内容是在本地还是网络上,都使用 一样的 openreadwrite 来访问,使得 “一切皆文件” 的理念被实现,这也正是软件中间层的魅力。

1.4 Linux 系统调用

​ 系统调用(System Call)是操作系统提供给用 户程序调用的一组“特殊”函数接口 API ,文件操作就是其中一种类型。实际 上,Linux 提供的系统调用包含以下内容:

  • 进程控制 :如 forkcloneexitsetpriority 等创建、中止、设置进程优先级的操作。
  • 文件系统控制 :如 openreadwrite 等对文件的打开、读取、写入操作。
  • 系统控制 :如 rebootstimeinit_module 等重启、调整系统时间、初始化模块的系统操作。
  • 内存管理 :如 mlockmremap 等内存页上锁重、映射虚拟内存操作。
  • 网络管理 :如 sethostnamegethostname 设置或获取本主机名操作。
  • socket 控制 :如 socketbindsend 等进行 TCPUDP 的网络通讯操作。
  • 用户管理 :如 setuidgetuid 等设置或获取用户 ID 的操作。
  • 进程间通信 :包含信号量、管道、共享内存等操作。

​ 从逻辑上来说,系统调用可被看成是一个 Linux 内核与用户空间程序交互的中间人,它把用户 进程的请求传达给内核,待内核把请求处理完毕后再将处理结果送回给用户空间。它的存在就是为了对用户空间与内核空间进行隔离,要求用户通过给定的方式访问系统资源,从而达到保护系统的目的。

2 标准 I/O

2.1 简介

​ 标准 I/O 库则是标准 C 库中用于文件 I/O 操作相关的一系列库函数的集合,通常标准 I/O 库函数相关的函数定义都在头文件 <stdio.h> 中,所以我们需要在程序源码中包含 <stdio.h> 头文件。

​ 标准 I/O 库函数是构建于文件 I/Oopen()read()write()lseek()close() 等)这些系统调用之上的,譬如标准 I/O 库函数 fopen() 就利用系统调用 open() 来执行打开文件的操作、fread() 利用系统调用 read() 来执行读文件操作、fwrite() 则利用系统调用 write() 来执行写文件操作等等。

​ 设计库函数是为了提供比底层系统调用更为方便、好用的调用接口,虽然标准 I/O 构建于文件 I/O 之上,但标准 I/O 却有它自己的优势,标准 I/O 和文件 I/O 的区别如下:

  • 虽然标准 I/O 和文件 I/O 都是 C 语言函数,但是标准 I/O 是标准 C 库函数,而文件 I/O 则是 Linux 系统调用;
  • 标准 I/O 是由文件 I/O 封装而来,标准 I/O 内部实际上是调用文件 I/O 来完成实际操作的;
  • 可移植性:标准 I/O 相比于文件 I/O 具有更好的可移植性,通常对于不同的操作系统,其内核向应用层提供的系统调用往往都是不同,譬如系统调用的定义、功能、参数列表、返回值等往往都是不一样的;而对于标准 I/O 来说,由于很多操作系统都实现了标准 I/O 库,标准 I/O 库在不同的操作系统之间其接口定义几乎是一样的,所以标准 I/O 在不同操作系统之间相比于文件 I/O 具有更好的可移植性。
  • 性能、效率:标准 I/O 库在用户空间维护了自己的 stdio 缓冲区,所以标准 I/O 是带有缓存的,而文件 I/O 在用户空间是不带有缓存的,所以在性能、效率上,标准 I/O 要优于文件 I/O

2.2 基础知识

2.2.1 FILE 指针

​ 对于标准 I/O 库函数来说,它们的操作是围绕 FILE 指针进行的,当使用标准 I/O 库函数打开或创建一个文件时,会返回一个指向 FILE 类型对象的指针,使用该 FILE 指针与被打开或创建的文件相关联,然后该 FILE 指针就用于后续的标准 I/O 操作(使用标准 I/O 库函数进行 I/O 操作),所以由此可知,FILE 指针的作用相当于文件描述符,只不过 FILE 指针用于标准 I/O 库函数中、而文件描述符则用于文件 I/O 系统调用中。

FILE 是一个结构体数据类型,它包含了标准 I/O 库函数为管理文件所需要的所有信息,包括用于实际 I/O 的文件描述符、指向文件缓冲区的指针、缓冲区的长度、当前缓冲区中的字节数以及出错标志等。FILE 数据结构定义在标准 I/O 库函数头文件 <stdio.h> 中。

2.2.2 标准输入、标准输出和标准错误

​ 标准输入设备指的就是计算机系统的标准的输入设备,通常指的是计算机所连接的键盘;而标准输出设备指的是计算机系统中用于输出标准信息的设备,通常指的是计算机所连接的显示器;标准错误设备则指的是计算机系统中用于显示错误信息的设备,通常也指的是显示器设备。

​ 用户通过标准输入设备与系统进行交互,进程将从标准输入(stdin)文件中得到输入数据,将正常输出数据(譬如程序中 printf 打印输出的字符串)输出到标准输出(stdout)文件,而将错误信息(譬如函数调用报错打印的信息)输出到标准错误(stderr)文件。

​ 标准输出文件和标准错误文件都对应终端的屏幕,而标准输入文件则对应于键盘。

​ 每个进程启动之后都会默认打开标准输入、标准输出以及标准错误,得到三个文件描述符,即 012,其中 0 代表标准输入、1 代表标准输出、2 代表标准错误;在应用编程中可以使用宏 STDIN_FILENOSTDOUT_FILENOSTDERR_FILENO 分别代表 012,这些宏定义在 unistd.h 头文件中:

/* Standard file descriptors. */
#define STDIN_FILENO 0		/* Standard input. */
#define STDOUT_FILENO1		/* Standard output. */
#define STDERR_FILENO 2		/* Standard error output. */

012 这三个是文件描述符,只能用于文件 I/Oread()write()等),那么在标准 I/O 中,自然是无法使用文件描述符来对文件进行 I/O 操作的,它们需要围绕 FILE 类型指针来进行,在 <stdio.h> 头文件中有相应的定义,如下:

/* struct _IO_FILE 结构体就是 FILE 结构体,使用了 typedef 进行了重命名 */
/* Standard streams. */
extern struct _IO_FILE *stdin;		/* Standard input stream. */
extern struct _IO_FILE *stdout;		/* Standard output stream. */
extern struct _IO_FILE *stderr;		/* Standard error output stream. */
/* C89/C99 say they're macros. Make them happy. */
#define stdin stdin
#define stdout stdout
#define stderr stderr

​ 所以,在标准 I/O 中,可以使用 stdinstdoutstderr 来表示标准输入、标准输出和标准错误。

2.3 文件操作函数

2.3.1 fopen()
#include <stdio.h>

/**
 * 功能:打开或创建文件。新建文件时无法手动指定文件的权限,但却有一个默认值666。
 *
 * @path:指向文件路径,可以是绝对路径、也可以是相对路径。
 * @mode:指定了对该文件的读写权限,是一个字符串。
 * 		    “r”:以只读方式打开,文件指针位于文件的开头。
 * 			“r+”:以读和写的方式打开,文件指针位于文件的开头。
 * 			“w”:以写的方式打开,不管原文件是否有内容都把原内容清空掉,文件指针位于文件的开头。
 * 			“w+”:同上,不过当文件不存在时,前面的“w”模式会返回错误,而此处的“w+”则会创建新文件。
 * 			“a”:以追加内容的方式打开,若文件不存在会创建新文件,文件指针位于文件的末尾。与“w+”的区别是它不会清空原文件的内容而是追加。
 * 			“a+”:以读和追加的方式打开,其它同上。
 *
 * 返回值:成功返回一个指向 FILE 类型对象的指针;失败则返回 NULL,并设置 errno 以指示错误原因。
 */
FILE *fopen(const char *path, const char *mode);
2.3.2 fread()
#include <stdio.h>

/**
 * 功能:读取文件数据。
 *
 * @ptr:指向用于存放读取数据的缓存区地址。
 * @size:每一个数据项的大小为 size 个字节。
 * @nmemb:指定了读取数据项的个数。
 * @stream:FILE 指针。
 *
 * 返回值:成功返回读取到的数据项的数目;如果发生错误或到达文件末尾,则返回的值将小于参数 nmemb。
 */
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
2.3.3 fwrite()
#include <stdio.h>

/**
 * 功能:向文件里写数据。
 *
 * @ptr:指向用于存放要写数据的缓存区地址。
 * @size:每一个数据项的大小为 size 个字节。
 * @nmemb:指定了写数据项的个数。
 * @stream:FILE 指针。
 *
 * 返回值:成功返回写入的数据项的数目;如果发生错误返回小于参数 nmemb(或者等于 0)。
 */
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
2.3.4 fcolse()
#include <stdio.h>

/**
 * 功能:关闭文件指针。
 *
 * @stream:FILE 指针。
 *
 * 返回值:成功后返回0。 失败返回 EOF 并设置 errno 以指示错误。
 */
int fclose(FILE *stream);
2.3.5 fseek()
#include <stdio.h>

/**
 * 功能:用于设置下一次读写函数操作的位置。
 *
 * @stream:FILE 指针。
 * @offset:指定位置。
 * @whence:定义了offset的意义,whence的可取值如下:
 * 			SEEK_SET:offset是一个绝对位置。
 * 			SEEK_END:offset是以文件尾为参考点的相对位置。
 * 			SEEK_CUR:offset是以当前位置为参考点的相对位置。
 *
 * 返回值:成功后返回0。 失败返回 -1 并设置 errno 以指示错误。
 */
int fseek(FILE *stream, long offset, int whence);
2.3.6 fflush()
#include <stdio.h>

/**
 * 功能:用于把尚未写到文件的内容立即写出。常用于确保前面操作的数据被写入到磁盘上。也不能完全保证,只是保证写入到了缓存。
 *
 * @stream:FILE 指针。
 *
 * 返回值:成功后返回0。 失败返回 EOF 并设置 errno 以指示错误。
 */
int fflush(FILE *stream);
2.3.7 实验分析
  • 源码

    #include <stdio.h>
    #include <string.h>
    
    //要写入的字符串
    const char buf[] = "filesystem_test:Hello World!\n";
    //文件描述符
    FILE *fp;
    char str[100];
    
    
    int main(void)
    {
       //创建一个文件
       fp = fopen("filesystem_test.txt", "w+");
       //正常返回文件指针
       //异常返回NULL
       if(NULL == fp){
          printf("Fail to Open File\n");
          return 0;
       }
       //将buf的内容写入文件
       //每次写入1个字节,总长度由strlen给出
       fwrite(buf, 1, strlen(buf), fp);
    
       //写入Embedfire
       //每次写入1个字节,总长度由strlen给出
       fwrite("Embedfire\n", 1, strlen("Embedfire\n"),fp);
    
       //把缓冲区的数据立即写入文件
       fflush(fp);
    
       //此时的文件位置指针位于文件的结尾处,使用fseek函数使文件指针回到文件头
       fseek(fp, 0, SEEK_SET);
    
       //从文件中读取内容到str中
       //每次读取100个字节,读取1次
       fread(str, 100, 1, fp);
    
       printf("File content:\n%s \n", str);
    
       fclose(fp);
    
       return 0;
    }
    
  • 运行

    # 编译运行
    $ gcc main.c -o main
    $ ./main
    File content:
    filesystem_test:Hello World!
    Embedfire
    

2.4 检查或复位状态

​ 调用 fread() 读取数据时,如果返回值小于参数 nmemb 所指定的值,表示发生了错误或者已经到了文件末尾(文件结束 end-of-file),但 fread() 无法具体确定是哪一种情况;在这种情况下,可以通过判断错误标志或 end-of-file 标志来确定具体的情况。

2.4.1 feof()
#include <stdio.h>

/**
 * 功能:测试参数 stream 所指文件的 end-of-file 标志。
 *
 * @stream:FILE 指针。
 *
 * 返回值:如果 end-of-file 标志被设置了,返回一个非零值,如果 end-of-file 标志没有被设置,则返回 0。
 */
int feof(FILE *stream);

/* Usage */
if (feof(file))
	/* 到达文件末尾 */
else
	/* 未到达文件末尾 */
2.4.2 ferror()
#include <stdio.h>

/**
 * 功能:测试参数 stream 所指文件的错误标志。
 *
 * @stream:FILE 指针。
 *
 * 返回值:如果错误标志被设置了,返回一个非零值,如果错误标志没有被设置,则返回 0。
 */
int ferror(FILE *stream);

/* Usage */
if (ferror(file))
	/* 发生错误 */
else
	/* 未发生错误 */
2.4.3 clearerr()
#include <stdio.h>

/**
 * 功能:清除end-of-file标志和错误标志,当调用 feof()或 ferror()校验这些标志后,通常需要清除这些标志,避免下次校验时使用到的是上一次设置的值,
 * 		此时可以手动调用 clearerr()函数清除标志。
 *
 * @stream:FILE 指针。
 *
 * 返回值:无。
 */
void clearerr(FILE *stream);
/*
 * 对于end-of-file标志,除了使用clearerr()显式清除之外,当调用fseek()成功时也会清除文件的end-of-file标志。
 */

2.5 格式化 I/O

​ 在前面编写的测试代码中,会经常使用到库函数 printf() 用于输出程序中的打印信息,printf() 函数可将格式化数据写入到标准输出,所以通常称为格式化输出。除了 printf() 之外,格式化输出还包括:fprintf()dprintf()sprintf()snprintf() 这 4 个库函数。

除了格式化输出之外,自然也有格式化输入,从标准输入中获取格式化数据,格式化输入包括:scanf()fscanf()sscanf() 这三个库函数。

2.5.1 格式化输出

C 库函数提供了 5 个格式化输出函数,包括:printf()fprintf()dprintf()sprintf()snprintf(),其函数定义如下所示:

#include <stdio.h>

/* 发送格式化输出到标准输出stdout */
int printf(const char *format, ...);

/* 将格式化数据写入到由FILE指针指定的文件中 */
int fprintf(FILE *stream, const char *format, ...); /* streams是FILE指针 */

/* 格式化数据写入到由文件描述符 fd 指定的文件中 */
int dprintf(int fd, const char *format, ...); /* fd文件描述符 */

/* 将格式化数据存储在由参数 buf 所指定的缓冲区中。会在字符串尾端自动加上一个字符串终止字符'\0' */
int sprintf(char *buf, const char *format, ...);
int snprintf(char *buf, size_t size, const char *format, ...) /* buf缓存区地址,size缓存区大小 */
2.5.1.1 格式控制字符串 format

​ 一般格式如下:

/**
 * flags:标志,可包含 0 个或多个标志;
 * width:输出最小宽度,表示转换后输出字符串的最小宽度;
 * precision:精度,前面有一个点号" . ";
 * length:长度修饰符;
 * type:转换类型,指定待转换数据的类型。只有%和 type 字段是必须的,其余都是可选的。
 */
%[flags][width][.precision][length]type
2.5.1.2 type 类型
格式字符对应的数据类型意义
dint以十进制形式输出带符号整数(正数不输出符号)
ounsigned int以八进制形式输出无符号整数(不输出前缀0)
x,Xunsigned int以十六进制形式输出无符号整数(不输出前缀Ox)
uunsigned int以十进制形式输出无符号整数
fdouble以小数形式输出单、双精度实数
e,Edouble以指数形式输出单、双精度实数
g,Gdouble以%f或%e中较短的输出宽度输出单、双精度实数
cchar输出单个字符
schar *输出字符串
pvoid *输出指针地址
luunsigned long32位无符号整数
lluunsigned long long64位无符号整数
2.5.2 格式化输入

C 库函数提供了 3 个格式化输入函数,包括:scanf()fscanf()sscanf(),其函数定义如下所示:

#include <stdio.h>

/* 用户输入(标准输入)的数据进行格式化转换 */
int scanf(const char *format, ...);

/* 从指定文件中读取数据,作为格式转换的输入数据 */
int fscanf(FILE *stream, const char *format, ...);

/* 从参数str所指向的字符串缓冲区中读取数据,作为格式转换的输入数据 */
int sscanf(const char *str, const char *format, ...);
2.5.2.1 格式控制字符串 format

​ 一般格式如下:

/**
 * width:最大字符宽度;
 * length:长度修饰符,与格式化输出函数的 format 参数中的 length 字段意义相同。
 * type:指定输入数据的类型。
 */
%[*][width][length]type
%[m][width][length]type

2.6 I/O 缓冲

2.6.1 文件 I/O 的内核缓冲

read()write() 系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区(kernel buffer cache)之间复制数据。譬如调用 write() 函数将 5 个字节数据从用户空间内存拷贝到内核空间的缓冲区中:

/* 写入 5 个字节数据 */
write(fd, "Hello", 5);

​ 调用 write() 后仅仅只是将这 5 个字节数据拷贝到了内核空间的缓冲区中,拷贝完成之后函数就返回了,在后面的某个时刻,内核会将其缓冲区中的数据写入(刷新)到磁盘设备中,所以由此可知,系统调用 write() 与磁盘操作并不是同步的,write() 函数并不会等待数据真正写入到磁盘之后再返回。如果在此期间,其它进程调用 read() 函数读取该文件的这几个字节数据,那么内核将自动从缓冲区中读取这几个字节数据返回给应用程序。

​ 与此同理,对于读文件而言亦是如此,内核会从磁盘设备中读取文件的数据并存储到内核的缓冲区中,当调用 read() 函数读取数据时,read() 调用将从内核缓冲区中读取数据,直至把缓冲区中的数据读完,这时,内核会将文件的下一段内容读入到内核缓冲区中进行缓存。

​ 我们把这个内核缓冲区就称为文件 I/O 的内核缓冲。

  • 这样的设计,目的是为了提高文件 I/O 的速度和效率,使得系统调用 read()write() 的操作更为快速,不需要等待磁盘操作(将数据写入到磁盘或从磁盘读取出数据),磁盘操作通常是比较缓慢的。

  • 同时这一设计也更为高效,减少了内核操作磁盘的次数。

​ 譬如线程 1 调用 write() 向文件写入数据 "abcd",线程 2 也调用 write() 向文件写入数据 "1234",这样的话,数据 "abcd""1234" 都被缓存在了内核的缓冲区中,在稍后内核会将它们一起写入到磁盘中,只发起一次磁盘操作请求;加入没有内核缓冲区,那么每一次调用 write(),内核就会执行一次磁盘操作。

​ 前面提到,当调用 write() 之后,内核稍后会将数据写入到磁盘设备中,具体是什么时间点写入到磁盘,这个其实是不确定的,由内核根据相应的存储算法自动判断。

​ 通过前面的介绍可知,文件 I/O 的内核缓冲区自然是越大越好,Linux 内核本身对内核缓冲区的大小没有固定上限。内核会分配尽可能多的内核来作为文件 I/O 的内核缓冲区,但受限于物理内存的总量,如果系统可用的物理内存越多,那自然对应的内核缓冲区也就越大,操作越大的文件也要依赖于更大空间的内核缓冲。

2.6.2 刷新文件 I/O 的内核缓冲区

​ 强制将文件 I/O 内核缓冲区中缓存的数据写入(刷新)到磁盘设备中,对于某些应用程序来说,可能是很有必要的,例如,应用程序在进行某操作之前,必须要确保前面步骤调用 write() 写入到文件的数据已经真正写入到了磁盘中,诸如一些数据库的日志进程。

2.6.2.1 控制文件 I/O 内核缓冲的系统调用

Linux 中提供了一些系统调用可用于控制文件 I/O 内核缓冲,包括系统调用 sync()syncfs()fsync() 以及 fdatasync()

#include <unistd.h>

/* 将参数 fd 所指文件的内容数据和元数据写入磁盘,只有在对磁盘设备的写入操作完成之后,fsync()函数才会返回 */
int fsync(int fd);

/* 仅将参数 fd 所指文件的内容数据写入磁盘,并不包括文件的元数据;同样,只有在对磁盘设备的写入操作完成之后,fdatasync()函数才会返回 */
int fdatasync(int fd);

/* 将所有文件 I/O 内核缓冲区中的文件内容数据和元数据全部更新到磁盘设备中,刷新所有文件 I/O 内核缓冲区 */
void sync(void);
2.6.2.2 控制文件 I/O 内核缓冲的标志

​ 调用 open() 函数时指定一些标志也可以影响到文件 I/O 内核缓冲,譬如 O_DSYNC 标志和 O_SYNC 标志:

/* 效果类似于在每个write()调用之后调用fdatasync()函数进行数据同步 */
fd = open(filepath, O_WRONLY | O_DSYNC);

/* 类似于在每个write()调用之后调用fsync()函数进行数据同步 */
fd = open(filepath, O_WRONLY | O_SYNC);
2.6.3 直接 I/O:绕过内核缓冲

​ 从 Linux 内核 2.4 版本开始,Linux 允许应用程序在执行文件 I/O 操作时绕过内核缓冲区,从用户空间直接将数据传递到文件或磁盘设备,把这种操作也称为直接 I/Odirect I/O)或裸 I/Oraw I/O)。

​ 在有些情况下,这种操作通常是很有必要的,例如,某应用程序的作用是测试磁盘设备的读写速率,那么在这种应用需要下,我们就需要保证 read/write 操作是直接访问磁盘设备,而不经过内核缓冲,如果不能得到这样的保证,必然会导致测试结果出现比较大的误差。

​ 然后,对于大多数应用程序而言,使用直接 I/O 可能会大大降低性能,这是因为为了提高 I/O 性能,内核针对文件 I/O 内核缓冲区做了不少的优化,譬如包括按顺序预读取、在成簇磁盘块上执行 I/O、允许访问同一文件的多个进程共享高速缓存的缓冲区。如果应用程序使用直接 I/O 方式,将无法享受到这些优化措施所带来的性能上的提升,直接 I/O 只在一些特定的需求场合,譬如磁盘速率测试工具、数据库系统等。

​ 我们可针对某一文件或块设备执行直接 I/O,要做到这一点,需要在调用 open() 函数打开文件时,指定 O_DIRECT 标志,该标志至 Linux 内核 2.4.10 版本开始生效,譬如:

fd = open(filepath, O_WRONLY | O_DIRECT);
2.6.3.1 直接 I/O 的对齐限制

​ 因为直接 I/O 涉及到对磁盘设备的直接访问,所以在执行直接 I/O 时,必须要遵守以下三个对齐限制要求:

​ ①、应用程序中用于存放数据的缓冲区,其内存起始地址必须以块大小的整数倍进行对齐;

​ ②、写文件时,文件的位置偏移量必须是块大小的整数倍;

​ ③、写入到文件的数据大小必须是块大小的整数倍。

​ 如果不满足以上任何一个要求,调用 write() 均为以错误返回 Invalid argument。以上所说的块大小指的是磁盘设备的物理块大小(block size),常见的块大小包括 512 字节、1024 字节、2048 以及 4096 字节,那我们如何确定磁盘分区的块大小呢?可以使用 tune2fs 命令进行查看,如下所示:

$ tune2fs -l /dev/sda1 | grep "Block size"
Block size:               4096
2.6.3.2 实验分析
  • 源码

    /**
     * 使用宏定义O_DIRECT,需要在程序中定义宏_GNU_SOURCE 不然提示 O_DIRECT 找不到
     * 效率更低,因为每次都会对磁盘进行读写
     */
    #define _GNU_SOURCE
    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <unistd.h>
    
    /* 定义一个用于存放数据的 buf,起始地址以 4096 字节进行对其 */
    static char buf[8192] __attribute((aligned (4096)));
    
    int main(void)
    {
        int fd;
        int count;
    
        /* 打开文件 */
        fd = open("./test_file", O_WRONLY | O_CREAT | O_TRUNC | O_DIRECT, 0664);
        if (0 > fd) {
            perror("open error");
            exit(-1);
        }
    
        /* 写文件 */
        count = 10000;
        while(count--) {
            if (4096 != write(fd, buf, 4096)) {
                perror("write error");
                exit(-1);
            }
        }
    
        /* 关闭文件退出程序 */
        close(fd);
        exit(0);
    }
    
  • 实验

    # 编译执行
    $ gcc direct.c -o direct
    $ time ./direct
    
    real    0m0.982s
    user    0m0.012s
    sys     0m0.280s
    
2.6.4 stdio 缓冲

​ 标准 I/Ofopenfreadfwritefclosefseek 等)是 C 语言标准库函数,而文件 I/Oopenreadwritecloselseek 等)是系统用,虽然标准 I/O 是在文件 I/O 基础上进行封装而实现(譬如 fopen 内部实际上调用了 openfread 内部调用了 read 等),但在效率、性能上标准 I/O 要优于文件 I/O,其原因在于标准 I/O 实现维护了自己的缓冲区,我们把这个缓冲区称为 stdio 缓冲区。

​ 前面提到了文件 I/O 内核缓冲,这是由内核维护的缓冲区,而标准 I/O 所维护的 stdio 缓冲是用户空间的缓冲区,当应用程序中通过标准 I/O 操作磁盘文件时,为了减少调用系统调用的次数,标准 I/O 函数会将用户写入或读取文件的数据缓存在 stdio 缓冲区,然后再一次性将 stdio 缓冲区中缓存的数据通过调用系统调用 I/O(文件 I/O)写入到文件 I/O 内核缓冲区或者拷贝到应用程序的 buf 中。

​ 通过这样的优化操作,当操作磁盘文件时,在用户空间缓存大块数据以减少调用系统调用的次数,使得效率、性能得到优化。使用标准 I/O 可以使编程者免于自行处理对数据的缓冲,无论是调用 write() 写入数据、还是调用 read() 读取数据。

2.6.4.1 对 stdio 缓冲进行设置

C 语言提供了一些库函数可用于对标准 I/Ostdio 缓冲区进行相关的一些设置,包括 setbuf()setbuffer() 以及 setvbuf()

#include <stdio.h>

/**
 * 功能:对文件的 stdio 缓冲区进行设置,譬如缓冲区的缓冲模式、缓冲区的大小、起始地址等。
 *
 * @stream:FILE 指针,用于指定对应的文件,每一个文件都可以设置它对应的 stdio 缓冲区。
 * @buf:如果参数 buf 不为 NULL,那么 buf 指向 size 大小的内存区域将作为该文件的 stdio 缓冲区,因为stdio 库会使用 buf 指向的缓冲区,
 * 		 所以应该以动态或静态的方式在堆中为该缓冲区分配一块空间,而不是分配在栈上的函数内的自动变量(局部变量)。
 * 		 如果 buf 等于 NULL,那么 stdio 库会自动分配一块空间作为该文件的 stdio 缓冲区(除非参数 mode 配置为非缓冲模式)。
 * @mode:参数 mode 用于指定缓冲区的缓冲类型,可取值如下:
 *  	 _IONBF:不对 I/O 进行缓冲(无缓冲)。意味着每个标准 I/O 函数将立即调用 write()或者 read(),并且忽略 buf 和 size 参数,
 *				 可以分别指定两个参数为 NULL 和 0。标准错误 stderr 默认属于这一种类型,从而保证错误信息能够立即输出。
 *		 _IOLBF:采用行缓冲 I/O。在这种情况下,当在输入或输出中遇到换行符"\n"时,标准 I/O 才会执行文件 I/O 操作。对于输出流,在输出一个换行符前将
 *				 数据缓存(除非缓冲区已经被填满),当输出换行符时,再将这一行数据通过文件I/O write()函数刷入到内核缓冲区中;对于输入流,每次读取一
 * 				 行数据。对于终端设备默认采用的就是行缓冲模式,譬如标准输入和标准输出。
 * 		 _IOFBF:采用全缓冲 I/O。在这种情况下,在填满 stdio 缓冲区后才进行文件 I/O 操作(read、write)。对于输出流,当 fwrite 写入文件的数据填
 * 				 满缓冲区时,才调用 write()将 stdio 缓冲区中的数据刷入内核缓冲区;对于输入流,每次读取 stdio 缓冲区大小个字节数据。默认普通磁盘上
 *				 的常规文件默认常用这种缓冲模式。
 * @size:指定缓冲区的大小。
 *
 * 返回值:成功返回 0,失败将返回一个非 0 值,并且会设置 errno 来指示错误原因。
 */
int setvbuf(FILE *stream, char *buf, int mode, size_t size);

/**
 * 相当于setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ);
 * BUFSIZ 定义于头文件<stdio.h>中,该值通常为 8192
 */
void setbuf(FILE *stream, char *buf);

/* 相当于setvbuf(stream, buf, buf ? _IOFBF : _IONBF, size); */
void setbuffer(FILE *stream, char *buf, size_t size);
2.6.4.2 标准输出 printf() 的行缓冲模式测试
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

/*
 * 执行会发现只会打印一次,标准输出默认采用的是行缓冲模式,printf()输出的字符串写入到了标准输出的 stdio 缓冲区中,只有输出换行符时才会将这一行数据刷
 * 入 到内核缓冲区,也就是写入标准输出文件(终端设备),因为第一个 printf()包含了换行符,所以已经刷入了内核缓冲区,而第二个 printf 并没有包含换行符,
 * 所以第二个 printf 输出的"Hello World!"还缓存在 stdio 缓冲区中,需要等待一个换行符才可输出到终端。
 */
int main(void)
{
#if 0 /* 把这段代码放开,就可以正常打印两次 */
    /* 将标准输出设置为无缓冲模式 */
    if (setvbuf(stdout, NULL, _IONBF, 0)) {
        perror("setvbuf error");
        exit(0);
    }
#endif

    printf("Hello World!\n");
    printf("Hello World!");

    for ( ; ; )
    	sleep(1);
}
2.6.4.3 刷新 stdio 缓冲区

​ 无论我们采取何种缓冲模式,在任何时候都可以使用库函数 fflush() 来强制刷新(将输出到 stdio 缓冲区中的数据写入到内核缓冲区,通过 write() 函数)stdio 缓冲区,该函数会刷新指定文件的 stdio 输出缓冲区。

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

/* 成功打印两次 */
int main(void)
{
    printf("Hello World!\n");
    printf("Hello World!");

    /* 刷新标准输出 stdio 缓冲区 */
    fflush(stdout);

    for ( ; ; )
    	sleep(1);
}
2.6.4.5 关闭文件时刷新 stdio 缓冲区
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

/* 成功打印两次 */
int main(void)
{
    printf("Hello World!\n");
    printf("Hello World!");

    fclose(stdout); //关闭标准输出

    for ( ; ; )
    	sleep(1);
}
2.6.4.6 程序退出时刷新 stdio 缓冲区
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

/* 成功打印两次 */
int main(void)
{
    printf("Hello World!\n");
    printf("Hello World!");
}

​ 刷新 stdio 缓冲区相关内容,最后进行一个总结:

​ ①、调用 fflush() 库函数可强制刷新指定文件的 stdio 缓冲区;

​ ②、调用 fclose() 关闭文件时会自动刷新文件的 stdio 缓冲区;

​ ③、程序退出时会自动刷新 stdio 缓冲区(注意区分不同的情况)。

2.7 文件描述符与 FILE 指针互转

​ 在应用程序中,在同一个文件上执行 I/O 操作时,还可以将文件 I/O(系统调用 I/O)与标准 I/O 混合使用,这个时候我们就需要将文件描述符和 FILE 指针对象之间进行转换,此时可以借助于库函数 fdopen()fileno() 来完成。

#include <stdio.h>

/**
 * 功能:将标准 I/O 中使用的 FILE 指针转换为文件 I/O 中所使用的文件描述符。
 *
 * @stream:FILE 指针。
 *
 * 返回值:成功返回文件描述符,失败将返回一个非 0 值,并且会设置 errno 来指示错误原因。
 */
int fileno(FILE *stream);

/**
 * 功能:将文件 I/O 中所使用的文件描述符转换为标准 I/O 中使用的 FILE 指针。
 *
 * @fd:文件描述符。
 * @mode:指定了对该文件的读写权限,是一个字符串。
 * 		    “r”:以只读方式打开,文件指针位于文件的开头。
 * 			“r+”:以读和写的方式打开,文件指针位于文件的开头。
 * 			“w”:以写的方式打开,不管原文件是否有内容都把原内容清空掉,文件指针位于文件的开头。
 * 			“w+”:同上,不过当文件不存在时,前面的“w”模式会返回错误,而此处的“w+”则会创建新文件。
 * 			“a”:以追加内容的方式打开,若文件不存在会创建新文件,文件指针位于文件的末尾。与“w+”的区别是它不会清空原文件的内容而是追加。
 * 			“a+”:以读和追加的方式打开,其它同上。
 *
 * 返回值:成功返回FILE指针,失败将返回一个非 0 值,并且会设置 errno 来指示错误原因。
 */
FILE *fdopen(int fd, const char *mode);

3 文件 IO

​ 文件 IO 操作相关系统调用,一个通用的 IO 模型通常包括打开文件、读写文件、关闭文件这些基本操作,主要涉及到 4 个函数:open()read()write() 以及 close()

3.1 常用函数

3.1.1 open()
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

/**
 * 功能:打开或者创建文件,并返回该文件对应的文件描述符。
 *
 * @pathname:指向文件路径,可以是绝对路径、也可以是相对路径。
 * @flags:指定文件的打开方式,常用参数如下。
 * 			O_RDONLY:以只读的方式打开文件,该参数与O_WRONLY和O_RDWR只能三选一
 * 			O_WRONLY:以只写的方式打开文件
 * 			O_RDWR:以读写的方式打开文件
 * 			O_CREAT:创建一个新文件
 * 			O_APPEND:将数据写入到当前文件的结尾处
 * 			O_TRUNC:如果pathname文件存在,则清除文件内容
 * @mode:当open函数的flag值设置为O_CREAT时,必须使用mode参数来设置文件与用户相关的权限。常用参数如下,参数之间可以使用“|”来组合。
 * 			S_IRUSR:用户拥有读权限
 * 			S_IWUSR:用户拥有写权限
 * 			S_IXUSR:用户拥有执行权限
 * 			S_IRWXU:用户拥有读、写、执行权限
 * 			S_IRGRP:当前用户组的其他用户拥有读权限
 * 			S_IWGRP:当前用户组的其他用户拥有写权限
 * 			S_IXGRP:当前用户组的其他用户拥有执行权限
 * 			S_IRWXG:当前用户组的其他用户拥有读、写、执行权限
 * 			S_IROTH:其他用户拥有读权限
 * 			S_IWOTH:其他用户拥有写权限
 * 			S_IXOTH:其他用户拥有执行权限
 * 			S_IROTH:其他用户拥有读、写、执行权限
 *
 * 返回值:成功将返回文件描述符,文件描述符是一个非负整数;失败将返回-1。
 */
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
3.1.2 read()
#include <unistd.h>

/**
 * 功能:从打开的文件中读取数据。
 *
 * @fd:文件描述符。
 * @buf:指定用于存储读取数据的缓冲区。
 * @count:指定需要读取的字节数。
 *
 * 返回值:如果读取成功将返回读取到的字节数,实际读取到的字节数可能会小于 count 参数指定的字节数,也有可能会为 0,譬如进行读操作时,当前文件位置偏移量
 * 		  已经到了文件末尾。实际读取到的字节数少于要求读取的字节数,譬如在到达文件末尾之前有 30 个字节数据,而要求读取 100 个字节,则 read 读取成功
 * 		  只能返回 30;而下一次再调用 read 读,它将返回 0(文件末尾)。
 * 		  如果读取失败返回-1。
 */
ssize_t read(int fd, void *buf, size_t count);
3.1.3 write()
#include <unistd.h>

/**
 * 功能:向打开的文件写入数据。
 *
 * @fd:文件描述符。
 * @buf:指定写入数据对应的缓冲区。
 * @count:指定写入的字节数。
 *
 * 返回值:如果成功将返回写入的字节数(0 表示未写入任何字节),如果此数字小于 count 参数,这不是错误,譬如磁盘空间已满,可能会发生这种情况;
 * 		  如果写入出错,则返回-1。
 */
ssize_t write(int fd, const void *buf, size_t count);
3.1.4 close()
#include <unistd.h>

/**
 * 功能:关闭一个已经打开的文件。
 *
 * @fd:文件描述符,需要关闭的文件所对应的文件描述符。
 *
 * 返回值:如果成功返回 0,如果失败则返回-1。
 */
int close(int fd);
3.1.5 lseek()
#include <sys/types.h>
#include <unistd.h>

/**
 * 功能:从打开的文件中读取数据。
 *
 * @fd:文件描述符。
 * @offset:偏移量,以字节为单位。
 * @whence:用于定义参数 offset 偏移量对应的参考值,该参数为下列其中一种(宏定义):
 * 				SEEK_SET:offset是一个绝对位置。
 * 				SEEK_END:offset是以文件尾为参考点的相对位置。
 * 				SEEK_CUR:offset是以当前位置为参考点的相对位置。
 *
 * 返回值:成功将返回从文件头部开始算起的位置偏移量(字节为单位),也就是当前的读写位置;发生错误将返回-1。
 */
off_t lseek(int fd, off_t offset, int whence);

3.2 示例

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

//文件描述符
int fd;
char str[100];


int main(void)
{
   //创建一个文件
   fd = open("testscript.sh", O_RDWR|O_CREAT|O_TRUNC, S_IRWXU);
   //文件描述符fd为非负整数
   if(fd < 0){
      printf("Fail to Open File\n");
      return 0;
   }
   //写入字符串pwd
   write(fd, "pwd\n", strlen("pwd\n"));

   //写入字符串ls
   write(fd, "ls\n", strlen("ls\n"));

   //此时的文件指针位于文件的结尾处,使用lseek函数使文件指针回到文件头
   lseek(fd, 0, SEEK_SET);

   //从文件中读取100个字节的内容到str中,该函数会返回实际读到的字节数
   read(fd, str, 100);

   printf("File content:\n%s \n", str);

   close(fd);

   return 0;
}
3.2.2 实验
# 编译执行
$ gcc main.c -o main
$ ./main
File content:
pwd
ls

3.3 如何决择

​ 既然 C 标准库和系统调用都能够操作文件,那么应该选择哪种操作呢?考虑的因素如下:

  • 使用系统调用会影响系统的性能。

    执行系统调用时,Linux 需要从用 户态切换至内核态,执行完毕再返回用户代码,所以减少系统调用能减少这方面的开销。如库函数写入数据的文件操作fwrite 最后也是执行了 write 系统 调用,如果是写少量数据的话,直接执行 write 可能会更高效,但如果是频繁的写入操作,由于 fwrite 的缓冲区可以减少调用 write 的次数,这种情况下使用 fwrite 能更节省时间。

  • 硬件本身会限制系统调用本身每次读写数据块的大小。如针对某种存 储设备的 write 函数每次可能必须写4kB的数据,那么当要写入的实际数据小于 4kB时,write 也只能按 4kB 写入,浪费了部分空间,而带缓冲区的 fwrite 函数面对这种情况,会尽量在满足数据长度要求时才执行系统调用,减少空间开销。

  • 由于库函数带缓冲区,使得我们无法清楚地知道它何时才会真正地把内容写入到硬件上,所以在需 要对硬件进行确定的控制时,我们更倾向于执行系统调用。

4 高级 I/O

4.1 非阻塞 I/O

​ 阻塞其实就是进入了休眠状态,交出了 CPU 控制权。譬如 wait()pause()sleep() 等函数都会进入阻塞。

​ 阻塞式 I/O 顾名思义就是对文件的 I/O 操作(读写操作)是阻塞式的,非阻塞式 I/O 同理就是对文件的 I/O 操作是非阻塞的。举个例子,譬如对于某些文件类型(读管道文件、网络设备文件和字符设备文件),当对文件进行读操作时,如果数据未准备好、文件当前无数据可读,那么读操作可能会使调用者阻塞,直到有数据可读时才会被唤醒,这就是阻塞式 I/O 常见的一种表现;如果是非阻塞式 I/O,即使没有数据可读,也不会被阻塞、而是会立马返回错误!

​ 普通文件的读写操作是不会阻塞的,不管读写多少个字节数据,read()write() 一定会在有限的时间内返回,所以普通文件一定是以非阻塞的方式进行 I/O操作,这是普通文件本质上决定的;但是对于某些文件类型,譬如上面所介绍的管道文件、设备文件等,它们既可以使用阻塞式 I/O 操作,也可以使用非阻塞式 I/O 进行操作。

4.1.1 阻塞 I/O 与非阻塞 I/O 读文件
  • 阻塞式 I/O 读取鼠标

    • 源码

      #include <stdio.h>
      #include <stdlib.h>
      #include <sys/types.h>
      #include <sys/stat.h>
      #include <fcntl.h>
      #include <unistd.h>
      #include <string.h>
      
      int main(void)
      {
          char buf[100];
          int fd, ret;
      
          /* 打开文件 */
          fd = open("/dev/input/event3", O_RDONLY);
          if (-1 == fd) {
              perror("open error");
              exit(-1);
          }
      
          /* 读文件 */
          memset(buf, 0, sizeof(buf));
          ret = read(fd, buf, sizeof(buf));
          if (0 > ret) {
              perror("read error");
              close(fd);
              exit(-1);
          }
      
          printf("成功读取<%d>个字节数据\n", ret);
      
          /* 关闭文件 */
          close(fd);
          exit(0);
      }
      
    • 实验

      执行程序之后,发现程序没有立即结束,而是一直占用了终端,没有输出信息,原因在于调用 read()之
      后进入了阻塞状态,因为当前鼠标没有数据可读;如果此时我们移动鼠标、或者按下鼠标上的任何一个按
      键,阻塞会结束,read()会成功读取到数据并返回
      
  • 非阻塞式 I/O 读取鼠标

    • 源码

      #include <stdio.h>
      #include <stdlib.h>
      #include <sys/types.h>
      #include <sys/stat.h>
      #include <fcntl.h>
      #include <unistd.h>
      #include <string.h>
      
      int main(void)
      {
          char buf[100];
          int fd, ret;
      
          /* 打开文件 */
          fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK);
          if (-1 == fd) {
              perror("open error");
              exit(-1);
          }
      
          /* 读文件 */
          memset(buf, 0, sizeof(buf));
          ret = read(fd, buf, sizeof(buf));
          if (0 > ret) {
              perror("read error");
              close(fd);
              exit(-1);
          }
      
          printf("成功读取<%d>个字节数据\n", ret);
      
          /* 关闭文件 */
          close(fd);
          exit(0);
      }
      
    • 实验

      执行程序之后,程序立马就结束了,并且调用 read()返回错误,提示信息为"Resource temporarily unavailable",意思就是说资源暂时不可用;原因在于调用 read()时,如果鼠标并没有移动或者被按下(没有发生输入事件),是没有数据可读,故而导致失败返回,这就是非阻塞 I/O。
      
    • 使用轮训方式不断地去读取,直到鼠标有数据可读,read() 将会成功返回

      #include <stdio.h>
      #include <stdlib.h>
      #include <sys/types.h>
      #include <sys/stat.h>
      #include <fcntl.h>
      #include <unistd.h>
      #include <string.h>
      
      int main(void)
      {
          char buf[100];
          int fd, ret;
      
          /* 打开文件 */
          fd = open("/dev/input/event3", O_RDONLY | O_NONBLOCK);
          if (-1 == fd) {
              perror("open error");
              exit(-1);
          }
      
          /* 读文件 */
          memset(buf, 0, sizeof(buf));
          for ( ; ; ) {
              ret = read(fd, buf, sizeof(buf));
              if (0 < ret) {
                  printf("成功读取<%d>个字节数据\n", ret);
                  close(fd);
                  exit(0);
              }
          }
      } 
      
4.1.2 阻塞 I/O 的优点与缺点

​ 当对文件进行读取操作时,如果文件当前无数据可读,那么阻塞式 I/O 会将调用者应用程序挂起、进入休眠阻塞状态,直到有数据可读时才会解除阻塞;而对于非阻塞 I/O,应用程序不会被挂起,而是会立即返回,它要么一直轮训等待,直到数据可读,要么直接放弃!

​ 所以阻塞式 I/O 的优点在于能够提升 CPU 的处理效率,当自身条件不满足时,进入阻塞状态,交出 CPU 资源,将 CPU 资源让给别人使用;而非阻塞式则是抓紧利用 CPU 资源,譬如不断地去轮训,这样就会导致该程序占用了非常高的 CPU 使用率!

4.2 I/O 多路复用

4.2.1 何为 I/O 多路复用

I/O 多路复用(IO multiplexing)它通过一种机制,可以监视多个文件描述符,一旦某个文件描述符(也就是某个文件)可以执行 I/O 操作时,能够通知应用程序进行相应的读写操作。I/O 多路复用技术是为了解决:在并发式 I/O 场景中进程或线程阻塞到某个 I/O 系统调用而出现的技术,使进程不阻塞于某个特定的 I/O 系统调用。

​ 由此可知,I/O 多路复用一般用于并发式的非阻塞 I/O,也就是多路非阻塞 I/O,譬如程序中既要读取鼠标、又要读取键盘,多路读取。

​ 我们可以采用两个功能几乎相同的系统调用来执行 I/O 多路复用操作,分别是系统调用 select()poll() 。这两个函数基本是一样的,细节特征上存在些许差别!

I/O 多路复用存在一个非常明显的特征:外部阻塞式,内部监视多路 I/O

4.2.2 select() 函数介绍
4.2.2.1 函数原型

​ 系统调用 select() 可用于执行 I/O 多路复用操作,调用 select() 会一直阻塞,直到某一个或多个文件描述符成为就绪态(可以读或写)。其函数原型如下所示:

#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

/**
 * 功能:IO多路复用。
 *
 * @nfds:通常表示最大文件描述符编号值加 1,考虑 readfds、writefds 以及 exceptfds这三个文件描述符集合,在 3 个描述符集中找出最大描述符编号值,
 * 		  然后加 1。
 * @readfds:是用来检测读是否就绪(是否可读)的文件描述符集合。
 * @writefds:是用来检测写是否就绪(是否可写)的文件描述符集合。
 * @exceptfds:是用来检测异常情况是否发生的文件描述符集合。
 * @timeout:用于设定 select()阻塞的时间上限,控制 select 的阻塞行为,可将timeout 参数设置为 NULL,表示 select()将会一直阻塞、直到某一个或多个
 * 			 文件描述符成为就绪态;也可将其指向一个 struct timeval 结构体对象,设置超时时间。
 *
 * 返回值:成功时,select()返回三个返回的描述符集中包含的文件描述符的数量(即 readfds、writefds、exceptfds 中设置的总位数),如果超时到期,则可能
 * 		  为零 在任何有趣的事情发生之前。 出错时返回-1,设置errno表示错误。
 */
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);


/* 将文件描述符 fd 从参数 set 所指向的集合中移除 */
void FD_CLR(int fd, fd_set *set);
/* 如果文件描述符 fd 是参数 set 所指向的集合中的成员,则 FD_ISSET()返回 true,否则返回 false */
int FD_ISSET(int fd, fd_set *set);
/* 将文件描述符 fd 添加到参数 set 所指向的集合中 */
void FD_SET(int fd, fd_set *set);
/* 将参数 set 所指向的集合初始化为空 */
void FD_ZERO(fd_set *set);
4.2.2.2 缺点

​ 单个进程可监视的 fd 数量被限制,即能监听端口的大小有限。一般来说这个数目和系统内存关系很大,具体数目可以 cat /proc/sys/fs/file-max 察看。32 位机默认是 1024 个。64 位机默认是 2048

​ 对 socket 进行扫描时是线性扫描,即采用轮询的方法,效率较低:当套接字比较多的时候,每次 select() 之后都要通过遍历 FD_SETSIZESocket 来完成调度,不管哪个 Socket是活跃的,都遍历一遍。这会浪费很多 CPU 时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是 epollkqueue 做的。

​ 需要维护一个用来存放大量 fd 的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。

4.2.2.3 使用示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/select.h>

#define MOUSE "/dev/input/event3"

int main(void)
{
    char buf[100];
    int fd, ret = 0, flag;
    fd_set rdfds;
    int loops = 5;

    /* 打开鼠标设备文件 */
    fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
    if (-1 == fd) {
        perror("open error");
        exit(-1);
    }

    /* 将键盘设置为非阻塞方式 */
    flag = fcntl(0, F_GETFL); 	//先获取原来的 flag
    flag |= O_NONBLOCK; 		//将 O_NONBLOCK 标准添加到 flag
    fcntl(0, F_SETFL, flag); 	//重新设置 flag

    /* 同时读取键盘和鼠标 */
    while (loops--) {
        FD_ZERO(&rdfds);
        FD_SET(0, &rdfds); 	//添加键盘,文件描述符是0
        FD_SET(fd, &rdfds); //添加鼠标,文件描述符是fd

        ret = select(fd + 1, &rdfds, NULL, NULL, NULL);
        if (0 > ret) {
            perror("select error");
            goto out;
        }
        else if (0 == ret) {
            fprintf(stderr, "select timeout.\n");
            continue;
        }

        /* 检查键盘是否为就绪态 */
        if(FD_ISSET(0, &rdfds)) {
            ret = read(0, buf, sizeof(buf));
            if (0 < ret)
                printf("键盘: 成功读取<%d>个字节数据\n", ret);
        }

        /* 检查鼠标是否为就绪态 */
        if(FD_ISSET(fd, &rdfds)) {
            ret = read(fd, buf, sizeof(buf));
            if (0 < ret)
        		printf("鼠标: 成功读取<%d>个字节数据\n", ret);
        }
    }

out:
    /* 关闭文件 */
    close(fd);
    exit(ret);
}
4.2.3 poll() 函数介绍
4.2.3.1 函数原型

​ 系统调用 poll()select() 函数很相似,但函数接口有所不同。在 select() 函数中,提供三个 fd_set 集合,在每个集合中添加我们关心的文件描述符;而在 poll() 函数中,则需要构造一个 struct pollfd 类型的数组,每个数组元素指定一个文件描述符以及我们对该文件描述符所关心的条件(数据可读、可写或异常情况)。poll() 函数原型如下所示:

#include <poll.h>

/**
 * 功能:IO多路复用。
 *
 * @fds:指向一个 struct pollfd 类型的数组,数组中的每个元素都会指定一个文件描述符以及我们对该文件描述符所关心的条件。
 * @nfds:参数 nfds 指定了 fds 数组中的元素个数,数据类型 nfds_t 实际为无符号整形。
 * @timeout:该参数与 select()函数的 timeout 参数相似,用于决定 poll()函数的阻塞行为。
 *			 	等于-1,则 poll()会一直阻塞(与 select()函数的 timeout 等于 NULL 相同),直到 fds数组中列出的文件描述符有一个达到就绪态或者捕
 * 					获到一个信号时返回。
 * 				等于 0,poll()不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态。
 * 				大于 0,则表示设置 poll()函数阻塞时间的上限值,意味着 poll()函数最多阻塞 timeout毫秒,直到 fds 数组中列出的文件描述符有一个达到
 *					就绪态或者捕获到一个信号为止。
 *
 * 返回值:返回 0 表示该调用在任意一个文件描述符成为就绪态之前就超时了。返回一个正整数表示有一个或多个文件描述符处于就绪态了,返回值表示 fds 数组中返回
 * 			的 revents变量不为 0 的 struct pollfd 对象的数量。返回-1 表示有错误发生,并且会设置 errno。
 */
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
4.2.3.2 缺点

poll 没有最大文件描述符数量的限制。 pollselect 同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

4.2.3.3 使用示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <poll.h>

#define MOUSE "/dev/input/event3"

int main(void)
{
    char buf[100];
    int fd, ret = 0, flag;
    int loops = 5;
    struct pollfd fds[2];

    /* 打开鼠标设备文件 */
    fd = open(MOUSE, O_RDONLY | O_NONBLOCK);
    if (-1 == fd) {
        perror("open error");
        exit(-1);
    }

    /* 将键盘设置为非阻塞方式 */
    flag = fcntl(0, F_GETFL); 	//先获取原来的 flag
    flag |= O_NONBLOCK; 		//将 O_NONBLOCK 标准添加到 flag
    fcntl(0, F_SETFL, flag); 	//重新设置 flag

	/* 同时读取键盘和鼠标 */
    fds[0].fd = 0;
    fds[0].events = POLLIN; //只关心数据可读
    fds[0].revents = 0;
    fds[1].fd = fd;
    fds[1].events = POLLIN; //只关心数据可读
    fds[1].revents = 0;

    while (loops--) {
        ret = poll(fds, 2, -1);
        if (0 > ret) {
            perror("poll error");
            goto out;
        }
        else if (0 == ret) {
            fprintf(stderr, "poll timeout.\n");
            continue;
        }

        /* 检查键盘是否为就绪态 */
        if(fds[0].revents & POLLIN) {
            ret = read(0, buf, sizeof(buf));
            if (0 < ret)
                printf("键盘: 成功读取<%d>个字节数据\n", ret);
        }

        /* 检查鼠标是否为就绪态 */
        if(fds[1].revents & POLLIN) {
            ret = read(fd, buf, sizeof(buf));
            if (0 < ret)
            	printf("鼠标: 成功读取<%d>个字节数据\n", ret);
        }
    }

out:
    /* 关闭文件 */
    close(fd);
    exit(ret);
}
4.2.4 epoll
4.2.4.1 函数原型

​ 其实相对于 selectpoll 来说,epoll 更加灵活,但是核心的原理都是当 socket 描述符就绪(可读、可写、出现异常),就会通知应用进程,告诉他哪个socket描述符就绪,只是通知处理的方式不同而已。

epoll 使用一个 epfdepoll 文件描述符)管理多个 socket 描述符,epoll 不限制 socket 描述符的个数, 将用户空间的 socket 描述符的事件存放到内核的一个事件表中 ,这样在用户空间和内核空间的 copy 只需一次。当 epoll 记录的 socket 产生就绪的时候,epoll 会通过 callback 的方式来激活这个fd,这样子在 epoll_wait 便可以收到通知,告知应用层哪个 socket 就绪了,这种通知的方式是可以直接得到那个 socket 就绪的,因此相比于 selectpoll,它不需要遍历 socket 列表,时间复杂度是 O(1),不会因为记录的 socket 增多而导致开销变大。

epollsocket 描述符的操作有两种模式: LTlevel trigger)和 ETedge trigger) 。LT 模式是默认模式,LT 模式与 ET 模式的区别如下:

  • LT 模式:即水平出发模式,当 epoll_wait 检测到 socket 描述符处于就绪时就通知应用程序,应用程序可以不立即处理它。下次调用 epoll_wait 时,还会再次产生通知。

  • ET 模式:即边缘触发模式,当 epoll_wait 检测到 socket 描述符处于就绪时就通知应用程序,应用程序 必须 立即处理它。如果不处理,下次调用epoll_wait 时,不会再次产生通知。

ET 模式在很大程度上减少了epoll 事件被重复触发的次数,因此效率要比 LT 模式高 。epoll 工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

#include <sys/epoll.h>


/**
 * 功能:创建一个epoll的 epfd 。当创建好epoll句柄后,它就是会占用一个fd值,必须调用close()关闭,否则可能导致fd被耗尽。
 *
 * @size:监听的数目。
 *
 * 返回值:成功时,这些系统调用返回一个非负文件描述符。 出错时,返回 -1,并设置 errno 以指示错误。
 */
int epoll_create(int size);

/**
 * 功能:控制某个epoll文件描述符上的事件,可以注册事件,修改事件,以及删除事件。
 *
 * @epfd:由epoll_create()函数返回的epoll文件描述符。
 * @op:操作的选项,目前有以下三个选项:
 * 			EPOLL_CTL_ADD:注册要监听的目标socket描述符fd到epoll句柄中。
 * 			EPOLL_CTL_MOD:修改epoll句柄已经注册的fd的监听事件。
 * 			EPOLL_CTL_DEL:从epoll句柄删除已经注册的socket描述符。
 * @fd:指定监听的描述符。
 * @event:event结构如下:
 *        typedef union epoll_data {
 *            void        *ptr;
 *            int          fd;
 *            uint32_t     u32;
 *            uint64_t     u64;
 *        } epoll_data_t;
 *
 *        struct epoll_event {
 *            uint32_t     events;      Epoll events
 *            epoll_data_t data;        User data variable
 *        };
 *
 * 		  events可以是以下几个宏的集合:
 *				EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)。
 *				EPOLLOUT:表示对应的文件描述符可以写。
 *				EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
 *				EPOLLERR:表示对应的文件描述符发生错误。
 *				EPOLLHUP:表示对应的文件描述符被挂断。
 *				EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
 *				EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
 *
 * 返回值:成功时,这些系统调用返回一个非负文件描述符。 出错时,返回 -1,并设置 errno 以指示错误。
 */
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

/**
 * 功能:等待监听的事件的发生,类似于调用select()函数。
 *
 * @epfd:由epoll_create()函数返回的epoll文件描述符。
 * @events:分配好的 epoll_event结构体数组。
 * @maxevents:告知内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的指定的size。
 * @timeout:超时时间。
 *
 * 返回值:成功时,返回需要处理的事件数目,如返回0表示已超时。如果返回–1,则表示出现错误,需要检查 errno错误码判断错误类型。
 */
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
4.2.4.2 epoll为什么更高效

​ 当我们调用 epoll_wait() 函数返回的不是实际的描述符,而是一个代表就绪描述符数量的值,这个时候需要去 epoll 指定的一个数组中依次取得相应数量的 socket 描述符即可,而不需要遍历扫描所有的 socket 描述符,因此这里的时间复杂度是 O(1)

​ 此外还使用了内存映射( mmap )技术,这样便彻底省掉了这些 socket 描述符在系统调用时拷贝的开销(因为从用户空间到内核空间需要拷贝操作)。mmap 将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换,不需要依赖拷贝,这样子内核可以直接看到 epoll 监听的描述符,效率极高。

​ 另一个本质的改进在于 epoll 采用基于事件的就绪通知方式。在 select/poll 中,进程只有在调用一定的方法后,内核才对所有监视的 socket 描述符进行扫描,而 epoll 事先通过 epoll_ctl() 来注册一个 socket 描述符,一旦检测到 epoll 管理的 socket 描述符就绪时,内核会采用类似 callback 的回调机制,迅速激活这个 socket 描述符,当进程调用 epoll_wait() 时便可以得到通知,也就是说 epoll 最大的优点就在于它只管就绪的描述符,而跟描述符的总数无关 。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值