Linux应用编程基础01-文件I/O

147 阅读42分钟

1. 应用编程概念

1.1 系统调用

系统调用(system call)其实是 Linux 内核提供给应用层的应用编程接口(API)

譬如打开磁盘中的文件、读 写文件、关闭文件以及控制其它硬件外设( open、write、 read、close等)

image-20231120101452203.png

内核提供了一系列的服务、资源、支持一系列功能,应用程序通过调用系统调用 API 函数来使用内核 提供的服务、资源以及各种各样的功能

操作系统下有两种不同的状态:内核态和用户态,应用程序运行在用户态、而内核则运行在内核态

1.2 库函数

库函数是 C 语言库函数,C 语言库是应用层使用的一套函数库,在 Linux 下,通常以动态(.so) 库文件的形式提供,存放在根文件系统/lib 目录下

C 语言库函数构建于系统调用之上,也就是说库函数中有些函数由系统调用封装而来( fopen 内部调用了系统调用 open()来帮它打开文件)

库函数和系统调用的区别:

  • 库函数是属于应用层,而系统调用是内核提供给应用层的编程接口,属于系统内核的一部分
  • 库函数运行在用户空间,调用系统调用会由用户空间(用户态)陷入到内核空间(内核态)
  • 库函数通常是有缓存的,而系统调用是无缓存的,所以在性能、效率上,库函数通常要优于系统调 用
  • 可移植性:库函数相比于系统调用具有更好的可移植性

1.3 标准 C 语言函数库

在 Linux系统下 ,使用的C语言库为GNU C 语言函数库( glibc),作为 Linux 下的标准 C 语言函数库

glibc库版本号:2.23

image-20231120102732669.png

2. 文件IO

文件 I/O 指的是对文件的输入/输出操作

2.1 基础

2.1.1 文件描述符

调用 open 函数会有一个返回值,在 open 函数执行成功的情况下,会返回一个非负整数,该返回值就是一个文件描述符(file descriptor),

对于 Linux 内核而言,所有打开的文件都会通过文件描述符进行索引。

一个进程可以打开多个文件,但是在 Linux 系统中,一个进程可以打开的文件数是有限制(打开的文件是需占用内存)

ulimit -n # 查看最大打开的文件数

所以对于一个进程来说,文件描述符是一种有限资源,文件描述符是从 0 开始分配的,譬如进程中第一个被打开的文件对应的文件描述符是 0、第二个文件是 1、第三个文件是 2、第 4 个文件是 3……最大值为 1023

但是当我们调用 open 函数打开文件的时候,分配的文件描述符一般都是从 3 开始, 0、1、2 这三个文件描述符已经默认被系统占用 了,分别分配给了系统标准输入(0)、标准输出(1)以及标准错误(2)

Linux 系统下,一切皆文件,也包括各种硬件设备,使用 open 函数打开任何文件成功情况下便会 返回对应的文件描述符 fd。每一个硬件设备都会对应于Linux 系统下的某一个文件,把这类文件称为设备文 件。所以设备文件对应的其实是某一硬件设备,应用程序通过对设备文件进行读写等操作、来使用、操控硬 件设备,譬如 LCD 显示器、串口、音频、键盘等。

标准输入一般对应的是键盘,可以理解为 0 便是打开键盘对应的设备文件时所得到的文件描述符;标 准输出一般指的是 LCD 显示器,可以理解为 1 便是打开 LCD 设备对应的设备文件时所得到的文件描述符; 而标准错误一般指的也是 LCD 显示器。

标准输入、标志输出、标志错误

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

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

#define STDIN_FILENO  0 /* Standard input. */
#define STDOUT_FILENO 1 /* Standard output. */
#define STDERR_FILENO 2 /* Standard error output. */

2.1.2 open

open函数用于打开文件,当然除了打开已经存在的文件之外还可以创建一个新的文件

函数原型

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
  • pathname:用于标识需要打开或创建的文件,可以包含路径信息,如果 pathname 是一个符号链接,会对其进行解引用
  • flags:调用 open 函数时需提供的标志,包括文件访问模式标志以及其它文件相关标志,这些标志使用宏定义进行描述,open 函数提供了非常多的标志
    • <image-20231120110301683.png
    • O_APPEND:调用 open 函数打开文件, 当每次使用write()函数对文件进行写操作时,都会自动把文件当前位置偏移量移动到文件末尾
    • O_TRUNC:调用 open 函数打开文件的时候会将文件原本的内容全部丢弃
  • mode:此参数用于指定新建文件的访问权限,只有当 flags 参数中包含 O_CREAT 或 O_TMPFILE(标志用于创建一个临时文件)标志时才有效。 mode 参数的类型是 mode_t,这是 一个 u32 无符号整形数据。
  • <image-20231120111321480.png
    • O表示其他用户的权限,G表示同组用户(group)的权限,U表示文件所属用户的权限,S表示文件的特殊权限,很少用
    • image-20231120111321480.png
  • 返回值:成功将返回文件描述符,文件描述符是一个非负整数;失败将返回-1

2.1.3 write

write 函数可向打开的文件写入数据

函数原型

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);
  • fd:文件描述符。关于文件描述符,需要将进行写操作的文件所对应的文件描述符传递给 write 函数。
  • buf:指定写入数据对应的缓冲区
  • count:指定写入的字节数
  • 返回值:如果成功将返回写入的字节数(0 表示未写入任何字节),如果此数字小于 count 参数,这不 是错误,譬如磁盘空间已满,可能会发生这种情况;如果写入出错,则返回-1

从文件的哪个位置开始进行读写操作?也就是 IO 操作所对应的位置偏移量,读写操作都是从文件的当前位置偏移量处开始,默认情况下偏移量一般是 0,也就是指向了文件起始位置, 当调用 read、write 函数读写操作完成之后,当前位置偏移量也会向后移动对应字节数,譬如当前位置偏移 量为 1000 个字节处,调用 write()写入或 read()读取 500 个字节之后,当前位置偏移量将会移动到 1500 个字 节处

2.1.4 read

read 函数可从打开的文件中读取数据

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
  • fd:文件描述符。与 write 函数的 fd 参数意义相同
  • buf:指定用于存储读取数据的缓冲区
  • count:指定需要读取的字节数
  • 返回值:如果读取成功将返回读取到的字节数,实际读取到的字节数可能会小于count 参数指定的字节 数,也有可能会为 0,譬如进行读操作时,当前文件位置偏移量已经到了文件末尾。实际读取到的字节数少于要求读取的字节数,譬如在到达文件末尾之前有 30 个字节数据,而要求读取 100 个字节,则 read 读取成 功只能返回 30;而下一次再调用 read 读,它将返回 0(文件末尾)。

2.1.5 close

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

#include <unistd.h>

int close(int fd);
  • fd:文件描述符,需要关闭的文件所对应的文件描述符
  • 返回值:如果成功返回 0,如果失败则返回-1

除了使用 close 函数显式关闭文件之外,在 Linux 系统中,当一个进程终止时,内核会自动关闭它打开 的所有文件,也就是说在我们的程序中打开了文件,如果程序终止退出时没有关闭打开的文件,那么内核会 自动将程序中打开的文件关闭

2.1.6 lseek

对于每个打开的文件,系统都会记录它的读写偏移量

当调用 read()或 write()函数对文件进行读写操作时,就会从当前读写位置 偏移量开始进行数据读写

lseek函数可以改变读写偏移量

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

off_t lseek(int fd, off_t offset, int whence);

fd:文件描述符

offset:偏移量,以字节为单位

whence:用于定义参数 offset 偏移量对应的参考值,该参数为下列其中一种(宏定义):

  • SEEK_SET:读写偏移量将指向 offset 字节位置处(从文件头部开始算);
  • SEEK_CUR:读写偏移量将指向当前位置偏移量 + offset 字节位置处,offset 可以为正、也可以为 负
  • SEEK_END:读写偏移量将指向文件末尾 + offset 字节位置处,同样 offset 可以为正、也可以为负

返回值:成功将返回从文件头部开始算起的位置偏移量(字节为单位),也就是当前的读写位置;发生错误将返回-1。

off_t off = lseek(fd, 0, SEEK_SET);	 	// 将读写位置移动到文件开头处:
off_t off = lseek(fd, 0, SEEK_END); 	// 将读写位置移动到文件末尾:
off_t off = lseek(fd, 100, SEEK_SET);	// 将读写位置移动到偏移文件开头 100 个字节处:
off_t off = lseek(fd, 0, SEEK_CUR);		// 获取当前读写位置偏移量:

练习

1、新建、读取文件

新建

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main()
{
    int fd;
    int ret;
    // 创建文件(test.txt 必须不存在)
    fd = open("./test.txt", O_WRONLY | O_CREAT | O_EXCL, 0644);
    if (fd == -1)
    {
        printf("open error!\n");
        return -1;
    }
    printf("open success!\n");

    // 写文件
    ret = write(fd, "Hello world!", 12);
    if (ret == -1)
    {
        printf("write error!\n");
        close(fd);
        return -1;
    }

    printf("write %d bytes success!\n", ret);
    close(fd);
    return 0;
}

读取

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main()
{
    int fd;
    int ret;
    char buffer[1024] = {0};
    // 打开文件
    fd = open("./test.txt", O_RDONLY);
    if (fd == -1)
    {
        printf("open error!\n");
        return -1;
    }
    printf("open success!\n");

    // 读取文件
    ret = read(fd, buffer, sizeof(buffer));
    if (ret == -1)
    {
        printf("read error!\n");
        close(fd);
        return -1;
    }

    printf("read %d bytes: %s!\n", ret, buffer);
    close(fd);
    return 0;
}

2、拷贝文件

copy.c

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

#define MAX_BUF_lEN 1024

int main(int argc, char *argv[])
{

    int fd1;
    int fd2;
    int ret;
    char buffer[MAX_BUF_lEN] = {0};

    // 打开src_file
    fd1 = open(argv[1], O_RDONLY);
    if (fd1 == -1)
    {
        printf("open %s error!\n", argv[1]);
        return -1;
    }
    // 新建dest_file
    fd2 = open(argv[2], O_WRONLY | O_CREAT | O_EXCL, 0644);
    if (fd2 == -1)
    {
        printf("open %s error!\n", argv[2]);
        return -1;
    }

    // 写入dest_file
    while ((ret = read(fd1, buffer, sizeof(buffer))) != 0)
    {
        if (ret == -1)
        {
            printf("write %s error!\n", argv[2]);
            close(fd1);
            close(fd2);
            return -1;
        }
        write(fd2, buffer, ret);
    }
    close(fd1);
    close(fd2);
    return 0;
}
gcc copy.c -o exec
./exec test.txt dest.txt # 把test.txt的文件内容复制到dest.txt

3、计算文件大小

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main(int argc, char *argv[])
{
    int fd;
    int size = 0;
    // 打开file
    fd = open(argv[1], O_RDONLY);
    if (fd == -1)
    {
        printf("open %s error!\n", argv[1]);
        return -1;
    }

    // 计算文件大小
    size = lseek(fd, 0, SEEK_END);// 返回从文件头部开始算起的位置偏移量(字节为单位),SEEK_END将读写位置移动到文件末尾:
    if (size == -1)
    {
        printf("lseek %s error!\n", argv[1]);
        return -1;
    }

    printf("%s : %d bytes.\n", argv[1], size);
    close(fd);
    return 0;
}

2.2 深入探究IO

2.2.1 文件管理

静态文件与 inode

文件在没有被打开的情况下一般都是存放在磁盘中,称为静态文件

文件储存在硬盘上,硬盘的最小存储单位叫做“扇区”(sector),每个扇区储存 512 字节(相当于 0.5KB), 操作系统读取硬盘的时候一次性连续读取多个扇区,即一次性读取一个“块”(block)(由多个扇区组成)。“块”是文件存取的最小单位。“块”的大小,最常见的是 4KB,即连续八个 sector 组成一个 block。

调用 open 函数是如何找到对应文件的数据存储“块”的呢?

磁盘在进行分区、格式化的时候会将其分为两个区域

  • 一个是数据区,用于存储文件中的数据
  • 另一个是 inode 区,用于存放 inode table(inode 表),inode table 中存放的是 inode 节点(每一个文件都对应一个 inode)

image-20231121135930149.png

使用ls -i可以查看每个文件对应的inode编号

image-20231121140059144.png

打开一个文件需要进行三部:

  1. 系统找到这个文件名所对应的 inode 编号
  2. 通过 inode 编号从 inode table 中找到对应的 inode 结构体
  3. 根据 inode 结构体中记录的信息,确定文件数据所在的 block,并读出数据。

动态文件

当调用 open 函数去打开文件的时候,内核会申请一段内存(一段缓冲区),将静态文件的数据内容从磁盘中读取到内存中进行管理、缓存(也把内存中的这份文件数据叫做动态文件、内核缓冲区)。打开文件后,对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作, 而并不是针对磁盘中存放的静态文件,数据的同步工作由内核完成

所以有以下下情况:

  • 打开一个大文件的时候会比较慢
  • 文档写了一半,没记得保存,此时电脑因为突然停电直接掉电关机了,当重启电脑后,打开编写的 文档,发现之前写的内容已经丢失。

使用动态内存的原因:

磁盘、硬盘、U 盘等存储设备基本都是 Flash 块设备,因为块设备硬件本身有读写限制等特征,块设备是以一块一块为单位进行读写的,一个字节的改动 也需要将该字节所在的 block 全部读取出来进行修改,修改完成之后再写入块设备中,所以导致对块设备的 读写操作非常不灵活

而内存可以按字节为单位来操作,而且可以随机操作任意地址数据, 所以对于操作系统来说,会先将磁盘中的静态文件读取到内存中进行缓存

2.2.2 返回错误处理与 errno

前面文件操作执行出错之后它们的返回值都是-1,无法准确找到错误原因。

操作系统会将这个错误所对应的编号赋值给 errno 变量,每一个进程(程序)都维护了自己的 errno 变量,它是程序中的全局变量,该变量用于 存储就近发生的函数执行错误编号

C 库函数 strerror(),该函数可以将对应的 errno 转换成适合我们查看的字符串信息

#include <string.h>
char *strerror(int errnum);

除了 strerror 函数之外,我们还可以使用 perror 函数来查看错误信息

#include <stdio.h>
void perror(const char *s);

对比strerror的优点:

  1. 不需要传入 errno
  2. 调用此函数会直接将错误提示字符串打印出来,而不是返回字符串
  3. 还可以在输出的错误提示字符串之前加入自己的打印信息

2.2.3 空洞文件

使用 lseek 可以修 改文件的当前读写位置偏移量,此函数不但可以改变位置偏移量,并且还允许文件偏移量超出文件长度

如有一个 test_file,该文件的大小是 4K,通过 lseek 系统调用将该文件的读写偏移量移动到偏移文件头部 6000 个字节处,并且向后继续写,中间的那部分空间并没有写入任何数据,所以形成了空洞,这部分区域就被称为文件空洞,那么相应的该文件也被称为空洞文件

image-20231121162846458.png

注意:文件空洞部分实际上并不会占用任何物理空间,直到在某个时刻对空洞部分进行写入数据时才会为它 分配对应的空间,但是空洞文件形成时,逻辑上该文件的大小是包含了空洞部分的大小的。

使用 ls 命令查看空洞文件大小是文件的逻辑大小,包括了空洞部分大小和真实数据部分大小

使用 du命令查看空洞文件时,查看到的大小是文件实际占用存储块的大小

应用:

空洞文件对多线程共同操作文件是及其有用的,有时候我们创建一个很大的文件,将文件分为多段,然后使用多线程来操作,每个线程负责其中一段数据的写入;

  • 在使用迅雷下载文件时,还未下载完成,就发现该文件已经占据了全部文件大小的空间,这也是空洞文件;下载时如果没有空洞文件,多线程下载时文件就只能从一个地方写入,这就不能发挥多线程的作用了;如果有了空洞文件,可以从不同的地址同时写入,就达到了多线程的优势;

  • 在创建虚拟机时,你给虚拟机分配了 100G 的磁盘空间,但其实系统安装完成之后,开始也不过只 用了 3、4G 的磁盘空间,如果一开始就把 100G 分配出去,资源是很大的浪费

2.2.4 重复打开文件

同时open一个文件多次:

  • 一个进程内多次 open 打开同一个文件,那么会得到多个不同的文件描述符 fd,都对应同一个文件
  • 通过任何一个文件描述符对文件进行 IO 操作都是可以的,但是需要注意是,调用 open 函数打开文件使用的是什么权限,则返回的文件描述符就拥有什么权限
  • 一个进程内多次 open 打开同一个文件,不同文件描述符所对应的读写位置偏移量是相互独立的
  • 一个进程内多次 open 打开同一个文件,在内存中并不会存在多份动态文件,都是在操作同一个动态文件
image-20231121171230128.png
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
    int fd1, fd2;
    fd1 = open("./test.txt", O_WRONLY | O_TRUNC | O_APPEND);
    if (fd1 == -1)
    {
        perror("open error");
        return -1;
    }

    fd2 = open("./test.txt", O_WRONLY | O_APPEND);
    if (fd2 == -1)
    {
        perror("open error");
        close(fd1);
        return -1;
    }

    write(fd1, "Hello ", 6);
    write(fd2, "World!", 6);

    close(fd1);
    close(fd2);

    return 0;
}
// test.txt : Hello World!,因为使用了O_APPEND,所以会接着写不会覆盖

2.2.5 文件描述符复制

文件描述符 fd 可以进行复制,复制成功之后可以得到一个新的文件描述符,使用新的文件描述符和旧的文件描述符都可以对文件进行 IO 操作

复制得到的文件描述符和旧的文件描述符指向同一个文件表,拥有相同的文件的读写权限、文件状态标志、文件偏移量等,“复制”的含义 实则是复制文件表

image-20231122193456899.png

dup函数

#include <unistd.h>
int dup(int oldfd);
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main()
{
    int fd1, fd2;
    char buffer[128] = {0};
    fd1 = open("./test.txt", O_RDWR | O_TRUNC);
    fd2 = dup(fd1);

    write(fd1, "Hello World!", 12);
    lseek(fd2, 0, SEEK_SET); // 读写标志位移到开头

    read(fd2, buffer, 12);

    printf("%s\n", buffer);

    close(fd1);
    close(fd2);

    return 0;
}

dup2函数

可以手动指定文件描述符,而不需要遵循文件描述符分配原则

#include <unistd.h>
int dup2(int oldfd, int newfd);

2.2.6 文件共享

文件共享指的是同一个文件(对应同一个 inode)被多个独立的读写体同时进行 IO 操作

譬如同一 个文件对应两个不同的文件描述符 fd1 和 fd2,当使用 fd1 对文件进行写操作之后,并没有关闭 fd1,而此时 使用 fd2 对文件再进行写操作,这其实就是一种文件共享。

三种实现文件共享方式:

1、同一个进程中多次调用 open 函数打开同一个文件

image-20231123200558579.png

2、不同进程中分别使用 open 函数打开同一个文件

image-20231123200623122.png

3、同一个进程中通过 dup(dup2)函数对文件描述符进行复制

image-20231123200803147.png

文件共享存在问题:

假设有两个独立的进程 A 和进程 B 都对同一个文件进行追加写操作(都调用open 函数打开该文件,但未使用 O_APPEND 标志),每个进程有自己独立的文件表(读写位置可以偏移量不同)

​ 假定此时进程 A 处于运行状态,B 未处 于等待运行状态,进程 A 调用了 lseek 函数,它将进程 A 的该文件当前位置偏移量设置为 1500 字节处(假 设这里是文件末尾),刚好此时进程 A 的时间片耗尽,然后内核切换到了进程 B,进程 B 执行 lseek 函数, 也将其对该文件的当前位置偏移量设置为 1500 个字节处(文件末尾)。然后进程 B 调用 write 函数,写入 了 100 个字节数据,那么此时在进程 B 中,该文件的当前位置偏移量已经移动到了 1600 字节处。B 进程时 间片耗尽,内核又切换到了进程 A,使进程 A 恢复运行,当进程 A 调用 write 函数时,是从进程 A 的该文 件当前位置偏移量(1500 字节处)开始写入,此时文件 1500 字节处已经不再是文件末尾了,如果还从 1500 字节处写入就会覆盖进程 B 刚才写入到该文件中的数据。

image-20231123211504142.png

2.2.7 原子操作

上述的问题出在逻辑操作“先定位到文件末尾,然后再写”,使用两个分开的函数调用,首先使用 lseek 将文件偏移量移动到末尾、然后使用 write 函数写入

所谓原子操作,多步操作组成的一个操作,原子操作要么一步也不执行,一旦执行,必须要执行完所有 步骤,不可能只执行所有步骤中的一个子集

使用原子操作能够规避上面情况的发生:

1、O_APPEND 实现原子操作

当 open 函数的 flags 参数中包含了 O_APPEND 标志,每次执行 write 写入 操作时都会将文件当前写位置偏移量移动到文件末尾,然后再写入数据,这里“移动当前写位置偏移量到文 件末尾、写入数据”这两个操作步骤就组成了一个原子操作

加入 O_APPEND 标志后,不管怎么写入数据 都会是从文件末尾写,这样就不会导致出现“进程 A 写入的数据覆盖了进程 B 写入的数据”这种情况

2、pread()和 pwrite()

pread()和 pwrite()都是系统调用,与 read()、write()函数的作用一样,用于读取和写入数据。区别在于, pread()和 pwrite()可用于实现原子操作

调用 pread 函数或 pwrite 函数可传入一个位置偏移量 offset 参数, 用于指定文件当前读或写的位置偏移量,所以调用 pread 相当于调用 lseek 后再调用 read

#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);

与read和write的区别是:不会更新文件表中的读写位置偏移量

3、O_EXCL

open 函数的 O_EXCL 标志,可以用 于测试一个文件是否存在,如果不存在则创建此文件,如果存在则返回错误,这使得测试和创建两者成为一 个原子操作

两个进程操作的时候也会出现问题:

image-20231123215715663.png

进程 A 和进程 B 都会创建出同一个文件,同一个文件被创建两次这是不允许的

通过使用 O_EXCL 标志,当 open 函数中同时指定了 O_EXCL 和 O_CREAT 标志,如果要打开的文件已经存在,则返回错误;如果指定的文件不存在,则创建

2.2.8 截断文件

对文件进行截断,将文件截断为参数 length 指定的字节长度

  • 如果文件目前的大小大于参数 length 所指定的大小,则多余的数据将被丢失
  • 如果文件目前的大小小于参数 length 所指定的大小,则将其进行扩展,对扩展部分进行读取将得到空字 节"\0"
#include <unistd.h>
#include <sys/types.h>


//调用成功返回 0,失败将返回-1
int truncate(const char *path, off_t length);
int ftruncate(int fd, off_t length); // 其中fd必须具有可写的条件

ftruncate()使用文件描述符 fd 来指定目标文件,而 truncate()则直接使用文件路径 path 来指定目标文件,其功能一样

2.2.9 fcntl

对一个fd执行一系列操作,譬如复制一个文件描述符、获取/设置文件描述符标志、获取/设置文件状态标志等,类似多功能文件描述符管理工具箱

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ...)

cmd:操作命令,此参数表示我们将要对 fd 进行什么操作,cmd 参数支持很多操作命令("man 2 fcntl"命令查看)

  • 复制文件描述符(cmd=F_DUPFD 或 cmd=F_DUPFD_CLOEXEC)
  • 获取/设置文件描述符标志(cmd=F_GETFD 或 cmd=F_SETFD)
  • 获取/设置文件状态标志(cmd=F_GETFL 或 cmd=F_SETFL)
  • 获取/设置异步 IO 所有权(cmd=F_GETOWN 或 cmd=F_SETOWN)
  • 获取/设置记录锁(cmd=F_GETLK 或 cmd=F_SETLK);

:fcntl 函数是一个可变参函数,第三个参数需要根据不同的 cmd 来传入对应的实参,配合 cmd 来使 用。

返回值:执行失败情况下,返回-1,并且会设置 errno;执行成功的情况下,其返回值与 cmd(操作命令)有关,譬如 cmd=F_DUPFD(复制文件描述符)将返回一个新的文件描述符、cmd=F_GETFD(获取文 件描述符标志)将返回文件描述符标志、cmd=F_GETFL(获取文件状态标志)将返回文件状态标志等

(1)复制文件描述符

使用的 cmd 包括 F_DUPFD 和 F_DUPFD_CLOEXEC

当 cmd=F_DUPFD 时,它的作用会根据 fd 复制出一个新的文件描述符

// 第三个参数用于指出新复制出的文件描述符是一个大于或等于该参数的可用文件描述符(没有使用的文件描述符);如果第三个参数等于一个已经存在的文件描述符,则取一个大于该参数的可用文件描述符
// 第三个参数是0,也就时指定复制得到的新文件描述符必须要大于或等于 0,但是因为 0~6 都已经被占用了,所以分配得到的 fd 就是 7;如果传入的第三个参数是 100,那么 fd2 就会等于 100
fd2 = fcntl(fd1, F_DUPFD, 0); 

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

  • cmd=F_GETFL可用于获取文件状态标志,不需要传入第三个参数,返回值成功表示获取到的文件状态标志
  • cmd=F_SETFL 可用于设置文件状态标志,需要传入第三个参数, 此参数表示需要设置的文件状态标志

这些标志指的就是在调用 open 函数时传入的 flags 标志,但是文件权限标志不能被设置,会被忽略。在 Linux 系统中,只有 O_APPEND、O_ASYNC、O_DIRECT、O_NOATIME 以及 O_NONBLOCK 这些标志可以被修改

flag = fcntl(fd, F_GETFL);
printf("flags: 0x%x\n", flag);
fcntl(fd, F_SETFL, flag | O_APPEND); // 设置文件状态标志,添加 O_APPEND 标志

ioctl 函数

认为是一个文件 IO 操作的杂物箱,可以处理的事情非常杂,一般用于操作特殊文件或硬件外设(譬如可以通过 ioctl 获取 LCD 相关信息等)

#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);

fd:文件描述符

request:此参数与具体要操作的对象有关,没有统一值,表示向文件描述符请求相应的操作

...:此函数是一个可变参函数,第三个参数需要根据 request 参数来决定,配合 request 来使用

返回值:成功返回 0,失败返回-1。

2.3.10 文件IO缓冲

文件 I/O 的内核缓冲

read()和 write()系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是在用户空间缓冲区和内核缓冲区(kernel buffer cache)之间复制数据

image-20231126195103323.png
write(fd, "Hello", 5); //写入 5 个字节数据
  • 调用 write()后只是将 5 个字节数据从从用户空间内存拷贝到了内核空间的缓冲区中, 在后面的某个时刻,内核会将其缓冲区中的数据写入到磁盘设备中,系统调用 write() 与磁盘操作并不是同步的,write()函数并不会等待数据真正写入到磁盘之后再返回
  • 内核会从磁盘设备中读取文件的数据并存储到内核的缓冲区中,read()函数读取数据时,将从内核缓冲区中读取数据

刷新内核缓冲区

强制将文件 I/O 内核缓冲区中缓存的数据写入(刷新)到磁盘设备中,对于某些应用程序来说,是很有必要的

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

fsync()

fsync()将参数 fd 所指文件的内容数据和元数据(文件大小、时间戳、权限等)写入磁盘,只有在对磁盘设备的写入操作完成 之后,fsync()函数才会返回

#include <unistd.h>
int fsync(int fd); // 函数调用成功将返回 0,失败返回-1 并设置 errno 以指示错误原因

fdatasync()

与 fsync()类似,不同之处在于 fdatasync()仅将参数 fd 所指文件的内容数据写入磁盘,并不包括文件的元数据;

#include <unistd.h>
int fdatasync(int fd);

sync()

sync()会将内核缓冲区中的所有文件内容数据和元数据全部更新到磁盘设备中,该函数没有参数、也无返回值,意味着它不是对某一个指定的文件进行数据更新,而是刷新所有文件 I/O 内核缓冲区。

#include <unistd.h>
void sync(void);

控制文件 I/O 内核缓冲的标志:open()函数时指定一些标志可以影响到文件 I/O 内核缓冲

  • O_DSYNC 标志:效果类似于在每个 write()调用之后调用 fdatasync()函数 进行数据同步

    • fd = open(filepath, O_WRONLY | O_DSYNC);
      
  • O_SYNC 标志:在每个 write()调用之后调用 fsync()函数进行数据同步

    • fd = open(filepath, O_WRONLY | O_SYNC);
      

在程序中频繁调用 fsync()、fdatasync()、sync()(或者调用 open 时指定 O_DSYNC 或 O_SYNC 标志) 对性能的影响极大,大部分的应用程序是没有这种需求的,所以在大部分应用程序当中基本不会使用到

2.3.11 直接I/O

从 Liux 内核 2.4 版本开始,允许应用程序在执行文件 I/O 操作时绕过内核缓冲区,从用户空间直接将数据传递到文件或磁盘设备,称为直接 I/O(direct I/O)

对于大多数应用程序而言,使用直接 I/O 可能会大大降低性能,这是因为为了提高 I/O 性能,内 核针对文件 I/O 内核缓冲区做了不少的优化,譬如包括按顺序预读取、在成簇磁盘块上执行 I/O、允许访问 同一文件的多个进程共享高速缓存的缓冲区。

直接 I/O 只在一些特定的需求场合,譬如磁盘速率测试工具、数据库系统等

在调用 open()函数打开文件时,指定 O_DIRECT 标志,就能进行直接I/O操作

fd = open(filepath, O_WRONLY | O_DIRECT);

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

  • 应用程序中用于存放数据的缓冲区,其内存起始地址必须以块大小的整数倍进行对齐
  • 写文件时,文件的位置偏移量必须是块大小的整数倍
  • 写入到文件的数据大小必须是块大小的整数倍。

磁盘设备的物理块大小(block size),常见的块大小包括 512 字节、1024 字节、2048 以及 4096 字节

使用指令查看:

tune2fs -l /dev/sda1 | grep "Block size" # Ubuntu 系统的根文件系统挂载在/dev/sda1 磁盘分区下
#define _GNU_SOURCE //使用宏定义 O_DIRECT 需要在程序中定义宏_GNU_SOURCE,不然提示 O_DIRECT 找不到

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

// 定义一个用于存放数据的 buf,在变量定义后加了__attribute((aligned (4096)))修饰,使其起始地址以 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);
}

2.3 标准I/O库

标准 I/O 虽然是对文件 I/O 进行了封装,标准 I/O 还会处理很多细节,譬如分配 stdio 缓冲区、以优化的块长度执行 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 库在用户空间维护了自己的 stdio 缓冲区,所以标准 I/O 是带有缓存的,而文件 I/O 在用户空间是不带有缓存的,所以在性能、效率上,标准 I/O 要优于文件 I/O。

2.3.1 FILE 指针

文件 I/O 函数open()、read()、write()、lseek()等都是围绕文件描述符fd进行的

标准 I/O 库函数来说,它们的操作是围绕 FILE 指针进行的,当使用标准 I/O 库函数打开或创建一个 文件时,会返回一个指向 FILE 类型对象的指针(FILE *),使用该 FILE 指针与被打开或创建的文件相关 联,然后该 FILE 指针就用于后续的标准 I/O 操作

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

标准输入、标准输出和标准错误在 stdio.h 头文件中有相应的定义,如下:

// struct _IO_FILE 结构体就是 FILE 结构体,使用了 typedef 进行了重命名
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. */

#define stdin stdin
#define stdout stdout
#define stderr stderr

在标准 I/O 中,可以使用 stdin、stdout、stderr 来表示标准输入、标准输出和标准错误

2.3.2 fopen fclose

在标准 I/O 中,我们将使用库函数 fopen()打开或创建文件

#include <stdio.h>
FILE *fopen(const char *path, const char *mode);

path:参数 path 指向文件路径,可以是绝对路径、也可以是相对路径

mode:参数 mode 指定了对该文件的读写权限,是一个字符串

  • image-20231125152338659.png

返回值:调用成功返回一个指向 FILE 类型对象的指针(FILE *),该指针与打开或创建的文件相关联, 后续的标准 I/O 操作将围绕 FILE 指针进行。如果失败则返回 NULL,并设置 errno 以指示错误原因。

调用 fopen()函数新建文件时无法手动指定文件的权限,默认值:0666

调用 fclose()库函数可以关闭一个由 fopen()打开的文件

#include <stdio.h>
int fclose(FILE *stream);

2.3.3 读文件和写文件

使用 fread()和 fwrite()库函数对文件进行读、写操 作了

#include <stdio.h>

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
  • fread
    • ptr:fread()将读取到的数据存放在参数 ptr 指向的缓冲区中
    • size:fread()从文件读取 nmemb 个数据项,每一个数据项的大小为 size 个字节,所以总共读取的数据大 小为 nmemb * size 个字节
    • nmemb:参数 nmemb 指定了读取数据项的个数
    • stream:FILE 指针
    • 返回值:调用成功时返回读取到的数据项的数目(数据项数目并不等于实际读取的字节数,除非参数 size 等于 1);如果发生错误或到达文件末尾,则 fread()返回的值将小于参数 nmemb,fread()不能区分文件结尾和错误,可以使用 ferror()或 feof() 函数来判断
  • fwrite
    • ptr:将参数 ptr 指向的缓冲区中的数据写入到文件中
    • size:参数 size 指定了每个数据项的字节大小,与 fread()函数的 size 参数意义相同
    • nmemb:参数 nmemb 指定了写入的数据项个数,与 fread()函数的 nmemb 参数意义相同
    • stream:FILE 指针
    • 返回值:调用成功时返回写入的数据项的数目(数据项数目并不等于实际写入的字节数,除非参数 size 等于 1);如果发生错误,则 fwrite()返回的值将小于参数 nmemb(或者等于 0)。
#include <stdio.h>

int main()
{
    FILE *fp = NULL;
    int ret;
    char buffer[128] = {0};

    fp = fopen("./test.txt", "w+");
    if (NULL == fp)
    {
        perror("fopen error");
        return -1;
    }

    ret = fwrite("Hello!", 1, 6, fp);
    if (ret < 6)
    {
        printf("fread error or end of file"); // 因为fread和fwrite在发生错误时,不会设置errno这个变量,不能使用perror函数
    }
    
	// 文件偏移量移到开头,用法和lseek一样唯一同的是返回值:成功返回 0;发生错误将返回-1,并且会设置 errno 以指示错误原因;
    ret = fseek(fp, 0, SEEK_SET); 
    if (ret == -1)
    {
        perror("fseek error");
        return -1;
    }

    ret = fread(buffer, 1, 11, fp);
    if (ret < 6)
    {
        printf("fread error or end of file");
    }

    printf("test.txt: %s\n", buffer);

    fclose(fp);

    return 0;
}

2.3.4 检查或复位状态

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

feof()

库函数feof()用于测试参数 stream 所指文件的 end-of-file 标志,如果到了文件末尾返回非零值,没有则返回0。

#include <stdio.h>
int feof(FILE *stream);

ferror()

库函数ferror()用于测试参数 stream 所指文件的错误标志,如果错误标志被设置了,则返回一非零值,没有则返回0。

#include <stdio.h>
int feof(FILE *stream);

clearerr()

clearerr()用于清除 end-of-file 标志和错误标志,当调用 feof()或 ferror()校验这些标志后,通常需 要清除这些标志,避免下次校验时使用到的是上一次设置的值

#include <stdio.h>
void clearerr(FILE *stream);

对于 end-of-file 标志,除了使用 clearerr()显式清除之外,当调用 fseek()成功时也会清除文件的 end-offile 标志。

#include <stdio.h>

int main()
{
    FILE *fp = NULL;
    int ret;
    char buffer[128] = {0};

    fp = fopen("./test.txt", "w+");
    if (NULL == fp)
    {
        perror("fopen error");
        return -1;
    }
    // 文件偏移量移到末尾
    if (fseek(fp, 0, SEEK_END) < 0)
    {
        perror("fseek error");
        fclose(fp);
        return -1;
    }
	
    if (fread(buffer, 1, 11, fp) < 6)
    {
        if (feof(fp))
            printf("end-of-file 标志被设置,已到文件末尾!\n");
        clearerr(fp); //清除标志
    }

    fclose(fp);

    return 0;
}

2.3.5 格式化输出

格式化输出包括:fprintf()、 dprintf()、sprintf()、snprintf()

#include <stdio.h>
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int dprintf(int fd, const char *format, ...);
int sprintf(char *buf, const char *format, ...);
int snprintf(char *buf, size_t size, const char *format, ...);

它们都有一个共同的字符串参数 format,称为格式控制字符串,用于指定后续的参数如何进行格式转换,所以才把这些函数称为格式化输出

  • printf()函数用于将格式化数据写入到标准输出
  • dprintf()和 fprintf()函数用于将格式化数据写入到指定的文件中,fprintf()使用 FILE 指针指定对应的文件、dprintf()则使用文件描述符 fd 指定对应的文件
  • sprintf()、snprintf()函数可将格式化的数据存储在用户指定的缓冲区 buf 中,snprintf()使用参数 size 显式的指定缓冲区的大小,防止缓冲区溢出

格式控制字符串 format

格式控制字符串由两部分组成:普通字符(非%字符)和转换说明

每个转换说明都是以%字符开头,其格式如下所示(使用[ ]括起来的部分是可选的):

%[flags][width][.precision][length]type
  • flags:标志,可包含 0 个或多个标志
  • width:输出最小宽度,表示转换后输出字符串的最小宽度
  • precision:精度,前面有一个点号" . "
  • length:长度修饰符,结合 type 字段以确定不同长度的数据类型
  • type:转换类型,指定待转换数据的类型

2.3.6 格式化输入

格式化输入包括:scanf()、 fscanf()、sscanf()

#include <stdio.h>

int scanf(const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);
int sscanf(const char *str, const char *format, ...);
  • scanf()函数可将用户输入(标准输入)的数据进行格式化转换
  • fscanf()函数从 FILE 指针指定文件中读 取数据,并将数据进行格式化转换
  • sscanf()函数从参数 str 所指向的字符串中读取数据,并将数据进行格式 化转换。

format

%[*][width][length]type
%[m][width][length]type

...

2.3.7 stdio缓冲

虽然标准 I/O 是在文件 I/O 基础上进行封装而实现(譬如 fopen 内部实际上调 用了 open、fread 内部调用了 read 等),但在效率、性能上标准 I/O 要优于文件 I/O,因为标准 I/O 实 现维护了自己的缓冲区,我们把这个缓冲区称为 stdio 缓冲区

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

设置stdio 缓冲

1、setvbuf():可以设置如缓冲区的缓冲模式、缓冲区的大小、起始地址等

#include <stdio.h>
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
  • stream:FILE 指针,用于指定对应的文件,每一个文件都可以设置它对应的 stdio 缓冲区
  • buf:如果参数 buf 不为 NULL,那么 buf 指向 size 大小的内存区域将作为该文件的 stdio 缓冲区,因为stdio 库会使用 buf 指向的缓冲区,所以应该以动态(分配在堆内存,譬如 malloc)或静态的方式在堆中为该缓冲区分配一块空间,而不是分配在栈上的函数内的自动变量(局部变量)。如果 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 来指示错误原因

当 stdio 缓冲区中的数据被刷入到内核缓冲区或被读取之后,这些数据就不会存在于缓冲区中了

2、setbuf():构建于setvbuf()之上,执行类似的任务

#include <stdio.h>
void setbuf(FILE *stream, char *buf);
  • setbuf()调用除了不返回函数结果(void)外,就相当于setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ);
  • 要么将 buf 设置为 NULL 以表示无缓冲,要么指向由调用者分配的 BUFSIZ 个字节大小的缓冲区 (BUFSIZ 定义于头文件中,该值通常为 8192)。

3、setbuffer():类似于 setbuf(),但允许调用者指定 buf 缓冲区的大小

#include <stdio.h>
void setbuffer(FILE *stream, char *buf, size_t size);
  • 相当于:setvbuf(stream, buf, buf ? _IOFBF : _IONBF, size);
#include <stdio.h>

int main()
{
    printf("Hello world!\n");
    printf("Hello world!");
    
    while(1);

    return 0;
}
/*
只有第一个 printf()打印的信息显示出来了,第二个并没有显示出来
标准输出默认采用的是行缓冲模式,printf()输出的字符串写入到了标准输出的 stdio 缓冲区中,只有输出换行符时(不考虑缓冲区填满的情况)才会将这一行数据刷入到内核缓冲区,也就是写入标准输出文件(终端设备)
第二个还缓存在 stdio 缓冲区中,需要等待一个换行符才可输出到终端

调用 scanf()函数进行阻塞,用户通过键盘输入数据,只有在按下回车键(换行符键)时程序才会接着往下执行,也是因为标准输入默认也是采用了行缓冲模式
*/
#include <stdio.h>

int main()
{
    // 标准输出变成无缓冲模式
    if (setvbuf(stdout, NULL, _IONBF, 0))
    {
        perror("setvbuf error");
        return -1;
    }

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

    while(1);

    return 0;
}

/* 两个都会输出了*/

刷新 stdio 缓冲区

可以使用库函数 fflush()来强制刷新stdio 缓冲区

#include <stdio.h>
int fflush(FILE *stream); // 如果该参数设置为 NULL,则表示刷新所有的 stdio 缓冲区

除了fflush外,譬如当fclose()关闭文件时、程序退出时也会强制刷新

printf("Hello World!\n");
printf("Hello World!");
fflush(stdout); //刷新标准输出 stdio 缓冲区,两个都会输出

缓冲小结:

image-20231127112644192.png

应用程序调用标准 I/O 库函数将用户数据写入到 stdio 缓冲区中,stdio 缓冲区是 由 stdio 库所维护的用户空间缓冲区。针对不同的缓冲模式,当满足条件时,stdio 库会调用文件 I/O(系统 调用 I/O)将 stdio 缓冲区中缓存的数据写入到内核缓冲区中,内核缓冲区位于内核空间。最终由内核向磁 盘设备发起读写操作,将内核缓冲区中的数据写入到磁盘