华拓科技网
您的当前位置:首页【Linux系统】Linux文件操作一

【Linux系统】Linux文件操作一

来源:华拓科技网






一、初步认识文件

1、文件操作的归类认知

  • 文件是文件属性(元数据)和文件内容的集合(文件 = 属性(元数据)+ 内容)
  • 对于 0KB 的空文件是占用磁盘空间的,系统中显示的文件大小,实际上是文件内容的大小,不包括文件属性
  • 所有的文件操作本质是文件内容操作和文件属性操作

2、系统角度

  • 对文件的操作本质是进程对文件的操作
  • 磁盘的管理者是操作系统
  • 文件的读写本质不是通过 C 语言 / C++ 的库函数来操作的(这些库函数只是为用户提供方便),而是通过文件相关的系统调用接口来实现的,而库函数本质上就是封装了系统调用,才有这些操作文件的功能

3、补充与小结

1、文件 = 内容 + 属性(因此一个没有内容的空文件,也是要占据存储的)

2、访问文件之前,都必须先打开该文件(只有打开后才能进行读写等访问操作)

3、文件没有打开之前,文件在磁盘,打开之后就加载到内存中(准确来说是在内存中创建该文件的属性结构体用于后续对该文件的操作)

4、用于打开文件的程序,只有在该程序执行成功后,才会真正打开该文件

  • 如 C语言中的 fopen 和 fclose 是对文件的打开和关闭操作的库函数,只有该程序语句执行成功,才会执行打开和关闭一个文件的操作。
  • 进程在访问文件,本质上是 CPU 执行进程内部代码时,运行到并执行成功文件操作相关的程序语句

5、打开和访问文件必须将文件加载到内存中

  • 文件由文件属性和文件内容组成,属性和内容将来都是可能会被 CPU 进行读写等操作的。因为文件存储在磁盘中,根据冯诺依曼体系,CPU 是不能和磁盘直接交互的,而是和内存进行交互,因此可得出结论:打开与操作文件,前提是文件一定要先被加载到内存中!

6、OS 是否需要加载到内存中的文件?

因果关系:因为文件存有自己独特的信息,因此需要被描述起来,用于管理和组织

文件加载到内存后,会存在一些信息需要被管理和组织:

  • 文件的 id 是多少
  • 文件何时被加载的
  • 文件何时需要被释放空间
  • 文件何时需要开辟空间,又在哪里开辟
  • ……

这些信息都要被管理起来,用于描述某个特定的文件,这就必然要对加载到内存中的文件进行描述

则说明文件也是需要 “先描述再组织“,

在内核中,内存中的被操作文件 = 文件的内核结构 + 文件内容

即前面讲过:文件 = 内存 + 属性

文件的内核结构 就存储 文件的属性等相关信息

文件内容 就是需要被加载进来的部分内容(一般不会将文件的所有内容一起加载到内存中)

二、C语言操作文件接口

目的是快速了解或复习,如何通过C语言库函数操作文件

1、fopen 函数

函数原型

FILE *fopen(const char *path, const char *mode);
  • path: 指向一个表示文件路径的字符串。
  • mode: 指向一个表示打开文件方式的字符串。

参数说明

  • path: 文件的路径(可以是相对路径或绝对路径)。

绝对路径:通过完整的路径逐层解析寻找目标文件

fopen("/file1/file2/file3/myfile", "w");

  • mode : 打开文件的方式。

    常见的模式包括:

    • "r": 只读方式打开文本文件。文件必须存在。(打开文件用于读)
    • "w": 只写方式打开文本文件。如果文件已存在则清空文件内容;如果文件不存在则尝试创建新文件。(打开文件用于写,在文件头部开始写)
    • "a": 追加方式打开文本文件。若文件存在,则在文件末尾添加内容;若文件不存在,则尝试创建新文件。(打开文件用于写,在文件末尾继续写)
    • "rb", "wb", "ab": 分别对应于二进制文件的只读、只写和追加方式。
    • "r+", "w+", "a+": 分别为更新(读写)方式打开文本文件。"r+"要求文件必须存在,而"w+"会创建新文件或覆盖现有文件,"a+"会在文件末尾添加内容。
    • "rb+", "wb+", "ab+": 类似上面的模式,但针对的是二进制文件。

下面详细解释一下某些选项的注意点

2、写选项:w

1、当文件存在,就打开该文件并写入

2、文件不存在,就创建该文件

3、该选项打开文件,从头开始写入内容,写入前会将原有文件清空

4、只是打开文件,进行 w 操作,但并没有真正写入内容,则会清空原有文件

演示一下

#include<stdio.h>

int main()
{
    FILE *fp = fopen("myfile", "w");
    if(!fp){
        printf("fopen error!\n");
    }
    
    // 不进行写操作,则清空该文件,相当于从头写入空数据,并覆盖原有文件
    
    fclose(fp);
    return 0;
}

简单来说:

写操作选项 fopen("w") 表示两个基础操作

1、清空文件;

2、写入文件;

追加操作选项 fopen("a") 表示一个基础操作

1、直接接着已有内容,写入文件;

Linux 的文件操作命令的对应:

echo >> test.txt    对应    fopen(test.txt, "a");  // 追加的形式写入
echo > test.txt     对应    fopen(test.txt, "w");  // w的形式写入

3、stdin & stdout & stder :初识

概念

这三个是我们学习C语言时,老师和教材中常提起的 ”标准IO流“,其实就是三个文件!

stdin :标准输入流,默认对应 键盘

stdout :标准输出流,默认对应 显示器

stderr:标准错误流,默认对应 显示器

注意点

1、这三个标准IO流文件在程序启动时,进程会默认打开

2、这三个标准IO流文件都是 FILE 文件类型



至于 FILE 文件类型具体是什么,后续章节会进一步讲解,这里暂且记住是一个文件类型即可

3、不同语言底层操作标准IO流的机制是一样的

标准IO流文件 在进程启动时就已经准备好,应用程序可以直接使用它们进行输入输出操作。不同编程语言提供了不同的接口来访问这些标准输入输出流,但底层机制是相同的

在底层一样,只是在上层的表现上有些区别罢了

  • 在C语言中,stdinstdoutstderr 分别对应标准输入、输出、错误流,可以通过标准库函数如 freadfwritefprintf 等来操作。
  • C++ 通过 std::cinstd::coutstd::cerr 分别对应标准输入、输出、错误流
  • 其他语言,比如Python,也有类似的概念,如 sys.stdinsys.stdoutsys.stderr

4、结构图示



scanf 函数:键盘输入的所有数据都是先存储于 ”键盘自己的文件 stdin 中“,scanf 函数获取所需数据就是从该 stdin 文件中读取数据。

printf 函数:该函数打印数据到显示器上,实际上是将待打印数据放到 标准输出流 stdout 这个文件中,系统检测到 stdout 这个文件中存有数据,就会立即显示到对应的显示器上

4、输出信息到显示器的几种方法(C语言)

#include <stdio.h>
#include <string.h>
int main()
{
    printf("hello printf\n");
    
    fputs("hello fputs\n", stdout);

    const char* str = "hello fwrite\n";
    fwrite(str, strlen(str), 1, stdout);
    
    fprintf(stdout, "hello fprintf\n");
    return 0;
}


三、系统文件 I/O

打开一个文件可以通过语言层面的库函数打开,这些库函数本质上需要通过系统层面的系统调用接口打开文件。

文件在被访问打开之前,存储在磁盘中,而磁盘是一种硬件设备,用户不能直接操作硬件,不能和硬件直接进行交互,中间需要通过操作系统做中间人,操作系统对下通过驱动程序操作和管理硬件,对上提供系统调用接口给用户用于间接操作底层硬件。

因此,我们所使用的C语言操作文件的库函数,本质底层封装对应的文件操作的系统调用

简单来说,操作文件需要通过系统调用,而库函数本身封装了系统调用,用户就可以通过库函数操作文件



前面学习了 fopen / fclose 等C语言操作文件的库函数,下面学习系统底层层面的系统调用



1、系统调用 open

由于篇幅,请点击跳转查看:



2、文件描述符 fd :第一节


系统调用 open 的返回值有什么用?

int open(const char* pathname, int flags, mode_t mode)


查看文档:可以看到,系统调用 open 的返回值为一个文件描述符




在正式认识 open 的返回值前,先认识一下这几个系统调用

  • 系统调用 read:从一个文件描述符 fd 中读取出内容放到 buff

    #include <unistd.h>
    ssize_t read(int fd, void *buff, size_t count);
    

因为使用系统调用 read,读取 count 数量的字符到 buff,很多时候被读取的内容不足 count 个,因此读取内容放到 buff 中, 还需要在结尾多加个 ‘\0’ 表示结束

char buff[1024];
ssize_t num = read(fd, buff, sizeof(buff)); //返回值:成功则返回实际读取的字节数
if(num > 0){
    buff[num] = '\0';
    printf("%s\n", buff);
}
else if(num < 0){
    perror("open");
    return 1;
}
  • 系统调用 write:向一个文件描述符 fd 中写入字符串 buff
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
  • 系统调用 close :关闭一个文件描述符 fd

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

注:系统调用 writeread 的返回值 ssize_t 是由标准 C 库定义的一种有符号整数类型。ssize_t 类型主要用于那些需要处理 I/O 操作返回值的函数。


文件描述符的认识:看上面几个系统调用操作文件描述符的方式,可以感觉到,文件描述符似乎是使用一个整数代表着当前操作的那个文件



系统调用的综合使用样例

我先 open 打开一个文件 log.txt ,通过系统调用 write 向文件内写入字符串,我们通过 read 从文件中读取出内容放到 buff 中,这里 read 前需要先将文件指针重置到文件开头, read 之后需要确保字符串以 null 结尾 buff[read_num] = '\0';

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

int main()
{
    // open 打开文件:
	// O_RDWR : 打开文件进行读和写操作
    // O_CREAT : 如果文件不存在,则创建文件
    // O_TRUNC : 如果文件已存在,则将其长度截断为 0 
    //           (这个宏表示清空当前文件,如果不写这个,则默认从文件头开始覆盖式写入字符)
    int fd = open("log.txt", O_RDWR | O_CREAT | O_TRUNC, 0666);
    if(fd < 0){
        perror("open");
        return 1;
    }
    
    // 打印文件描述符
    printf("%d\n", fd);
    
    // 往文件描述符里写入字符串
    char* message = "helloworld\n";
    write(fd, message, strlen(message));
    
    // 将文件指针重置到文件开头
    // read读取文件之前,一般都需要将文件指针重置回开头, 因为上一步刚刚write写文件,文件指针还在文件末尾
    // 如果write写完后,关闭文件,则文件指针会重置回开头,此时再打开文件read读取,就不会发生这种事了
    lseek(fd, 0, SEEK_SET);
    
    // 从一个文件描述符中读取出内容并打印
    char buff[1024];
    ssize_t read_num = read(fd, (void*)buff, 1024);
    
    // 确保字符串以 null 结尾
    (char*)buff; // 先强转回来
    buff[read_num] = '\0';
    
    // 打印读取出来的字符内容
    printf("%s\n", buff);
    
    close(fd);
	return 0;
}

该代码运行结果如下:



添加 open 的标志位 O_APPEND :表示追加写入

仅仅修改这句:

int fd = open("log.txt", O_RDWR | O_CREAT | O_APPEND, 0666);

该代码运行结果如下:



问题:关于系统调用 write

问题:对于系统调用 write 第三个参数表示写入的数据大小 int size ,我在使用时, strlen 为什么不用通过 + 1 来包含字符串 message 结尾的 \0 ?正常来说,拷贝和传递字符串需要将结尾的 \0 包含


write(fd, message, strlen(message));


代码演示:我们这里故意+1,看一下现象

// 为什么这段代码中, 不能给write函数中strlen+1,如果+1,则会将字符串末尾的 \0 包含,文件里面就会多出多余的
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include <unistd.h>
#include<string.h>

int main()
{
    int fd = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    
    
    // 往文件描述符里写入字符串
    char* message = "helloworld\n";
    write(fd, message, strlen(message)+1);

    
    close(fd);
    return 0;
}

代码生成一个 log1.txt,我们通过 vim 打开,发现该文件的内容居然多了个 ^@



什么是 ^@

在计算机科学和编程中,^@ 用来表示 ASCII 控制字符中的 NUL 字符(Null Character),即 ASCII 值为 0 的字符,即在 C 语言及其相关编程环境中用作字符串的终止符。


结论\0 是C语言中用于表示字符串的结尾,但不代表文件中也是这个意思,在文件中 \0 也是一个普通的字符,

对于文本文件来说,通常不希望包含额外的 \0 字符,只需要干干净净的存入正常字符即可

正确写法:strlen 不加 1

write(fd, message, strlen(message));

cat 命令打印该文件:为什么没有打印 ^@

cat 命令默认不会对文件中的特殊字符(如制表符、回车符等)进行特殊处理。它们会被直接输出到终端,终端会根据其自身的解释规则显示这些字符。

而终端通常不会直接显示 NUL 字符。大多数终端会忽略 NUL 字符,或者将其视为普通空白字符。



3、系统调用 和 库函数 的关系

上面的 fopen fclose fread fwrite 都是C标准库当中的函数,我们称之为库函数(libc)。

open close read write 都属于系统提供的接口,称之为系统调用接口

系统调用和语言层面的关系

C库函数:fopen / fread / fwrite / fappend

系统调用:open / read / write / append




这些函数其实功能是一一对应的,这反映出,这几个C语言的文件操作库函数,本质上就是封装了对应的 系统调用函数,这些系统调用函数使用的相关的标志位:如 O_TRUNCO_APPEND


// fopen
fopen("log.txt", "w")
{
    // fopen的写选项: 打开文件进行写、文件不存在就新建、写前清空文件(这些一一对应open函数的相关标志位)
    open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
}

//fread
fread(...)
{
    read(...)
}

//fwrite
fwrite(...)
{
    write(...)
}



4、文件描述符 fd :第二节

我们可以多创建几个文件,观察文件描述符的规律

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

int main()
{
    int fd1 = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd2 = open("log2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd3 = open("log3.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd4 = open("log4.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    
    
    // 打印文件描述符
    printf("fd1: %d\n", fd1);
    printf("fd2: %d\n", fd2);
    printf("fd3: %d\n", fd3);
    printf("fd4: %d\n", fd4);
    
    // 往文件描述符里写入字符串
    char* message = "helloworld\n";
    write(fd1, message, strlen(message));

    
    close(fd1);
	close(fd2);
	close(fd3);
	close(fd4);
    return 0;
}

该代码运行结果如下:


可以发现文件描述符的规律:是一种编号,随着文件的不断打开,文件标识符不断递增

但为什么编号是从 3 开始的呢???

这个和 stdin & stdout & stder 有关



stdin & stdout & stder:深入

前面讲解过的,进程启动时,默认启动了三个标准的输入输出流,他们各自对应着一个文件描述符

stdin :标准输入流,键盘,文件描述符为 0

stdout :标准输出流,显示器,文件描述符为 1

stderr:标准错误流,显示器,文件描述符为 2


这就说明了为什么编号是从 3 开始的,因为前 3 个文件描述符一开始就被三个标准的输入输出流占用了


实际上,这三个标准的输入输出流对应的都是一种文件,我们可以对其进行正常的文件操作:读和写



演示对这些输入输出流文件的读写文件操作:

(1)从输入流 stdin 读取:即从该文件读取,从键盘读取

系统调用 read 是从第一个参数文件描述符 fd 表示的文件中读取,下面直接将 fd 设置成 0,表示直接操作 输入流文件 stdin

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

int main()
{
    char buff[1024];
    ssize_t num = read(0, buff, sizeof(buff)); 
    if(num > 0){
        buff[num] = '\0';
        printf("%s\n", buff);
    }

    return 0;
}

如此就达到从键盘中读取数据的目的:这和 scanf 的功能相似



(2)向输出流 stdout 写入:即向该文件进行写入,向显示器写入

系统调用 write 是向第一个参数文件描述符 fd 表示的文件写入,下面直接将 fd 设置成 1,表示直接操作 输出流文件 stdout

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

int main()
{
    char* message = "helloworld\n";
    write(1, message, strlen(message));

    return 0;
}



5、文件结构体


由于篇幅,请点击跳转查看,本文讲解文件结构体和文件描述符表:



6、文件内核缓冲区

本文讲解:文件结构体struct file 中的文件内核缓冲区的概念和使用、缓冲区的意
义、自动刷新机制、记事本编辑器的运行原理




7、文件描述符 fd :第三节

(本节内容需要认识和理解 文件描述符表和文件内核缓冲区的概念及使用)


在前面文章中,我们认识了文件描述符表,可以知道文件描述符实际上就是该表的数组下标,而下面讲解文件描述符 fd 具体是如何分配给新打开的文件:

1、fd 的分配规则

进程打开文件,需要给文件分配新的 fdfd 的分配规则:最小且没有被占用的数组下标,即为可分配的 fd!

代码演示现象:我们先关闭文件描述符 0,即关闭输入流文件,然后开启 4 个文件并打印其文件描述符 fd

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

int main()
{
    // 关闭文件描述符 0
    close(0);
    
    // 打开 4 个新文件
    int fd1 = open("log1.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd2 = open("log2.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd3 = open("log3.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd4 = open("log4.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);

    printf("fd1: %d\n", fd1);
    printf("fd2: %d\n", fd2);
    printf("fd3: %d\n", fd3);
    printf("fd4: %d\n", fd4);

    close(fd1);
    close(fd2);
    close(fd3);
    close(fd4);

    return 0;
}

代码运行结果如下:可以发现有个 0,表示第一个新打开文件的文件描述符为 0

这说明,新打开的文件,将文件描述符 0 占用了

再次验证:将文件描述符 2 关闭,即关闭错误流文件

如结果所示,也是占用了 2 的位置

但如果关闭文件描述符 1 呢???


2、关闭文件描述符 fd = 1

可以发现,没有任何东西输出

文件描述符 1 表示的是文件输出流


下面使用图文演示的例子讲解其原因:

下图是一个进程的文件描述符表:内部的一个个指针指向加载到内存的文件结构体

默认 fd=0、fd=1、fd=2 分别指向 stdin、stdout、stderror

而我们将默认打开的 fd=1 指向的 stdout 标准输出流关闭,则 fd = 1 随后被分配给新打开的其他文件



[插播] 理解 printf 函数的本质

我们看一眼文档:可以发现 printf 和 fprintf 的参数基本一致,只是 fprintf 多了个 FILE* stream

int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);

printf 的本质是封装了 fprintf 并且默认指定向文件描述符 fd=1 :众所周知,printf 默认向显示器上打印字符,这是因为,在本质上,printf 默认向文件描述符 fd=1 打印字符,而因为文件描述符 fd=1 默认指向显示器,所以 printf 才默认向显示器打印

// printf 函数
int printf(const char *format, ...)
{
    // 固定向 `fd=1` 的文件打印字符
    int fprintf(1, format, ...);
}

当主动关闭 fd=1(即标准输出 stdout),会导致这个文件描述符变为可用状态。在Unix-like系统中,如果之后有新的文件打开操作,系统可能会将最低可用的文件描述符编号(在这个情况下是 fd=1)重新分配给新打开的文件。然而,重要的是要理解,这种情况下 fd=1 不再默认指向显示器。因此,后续执行 printf 语句时,因为 printf 默认向 fd=1 打印字符,它会将输出定向到新分配给 fd=1 的文件,而不是原来的显示器。

演示向新文件中打印

此时,我们关闭 fd=1stdout ,然后刷新一下输入流:fflush(stdout);

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

int main()
{
    close(1);
    int fd1 = open("log1.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd2 = open("log2.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd3 = open("log3.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd4 = open("log4.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);

    printf("fd1: %d\n", fd1);
    printf("fd2: %d\n", fd2);
    printf("fd3: %d\n", fd3);
    printf("fd4: %d\n", fd4);

    fflush(stdout);
    
    close(fd1);
    close(fd2);
    close(fd3);
    close(fd4);

    return 0;
}

可以发现,第一个新建的文件 log1.txt 中,居然有我们 4 个 printf 打印的内容(即原本会向显示器上打印的内容)


这就验证了,printf 语句默认往 fd=1 的文件中打印,即 log1.txt


为什么需要加上 fflush(stdout),这个和用户层缓冲区的设置有关



用户语言级缓冲区

关闭 fd=1 默认指定的文件,系统将 fd = 1 的文件描述符分配给新建文件,此时为什么 printf 语句之后需要刷新一下输入流:fflush(stdout)

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

int main()
{
    close(1);
    int fd1 = open("log1.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);

    printf("fd1: %d\n", fd1);

    fflush(stdout);
    
    close(fd1);

    return 0;
}

1、调用系统调用需要成本

前面文章讲解过,向文件写入操作,数据会从用户层拷贝到文件内核缓冲区,再从文件内核缓冲区拷贝到磁盘中。数据从用户层拷贝到文件内核缓冲区需要使用到 write 等系统调用,而调用系统调用,也是需要成本的!(指时间或空间)

我们之前学 C++ 时,说到:vector 这类的 STL 容器在申请空间时,会一次性申请原有空间的 1.5 ~ 2 倍的空间,一次申请多一点,可以减少之后的申请空间次数,这个过程主要是为了减少调用系统调用的次数!!减少了时间消耗。


2、用户层缓冲区:提升用户层数据拷贝到内核缓冲区的效率

数据从用户层拷贝到文件内核缓冲区需要使用到 write 等系统调用,而系统调用需要付出一些成本,因此应该想方法减少系统调用的次数,即减少数据拷贝次数,与内核缓冲区的发明原因同理,我们也给用户层设计一个用户层的缓冲区:收集足够多数量的数据,再统一拷贝到内核缓冲区

在C语言文件结构体 struct FILE 中就存有一个用户语言级缓冲区!这个用户语言级缓冲区可以存放缓存一些用户层的数据,当积累到一定数量或因为某些刷新策略而将数据统一刷新到 文件内核缓冲区中

 struct FILE
 {
     int fd;
     char buffer[NUM]; // 用户级缓冲区
 }

3、用户级缓冲区的几个刷新策略

(1)行刷新:对于显示器文件来说,当你写入用户缓冲区的字符串含有一个 \n 时,会自动帮你把 \n 前的字符串刷新到文件内核缓冲区

(2)写满刷新:对于普通文件来说,当缓冲区被写满后,会自动刷新一次

(3)进程退出全部刷新:当进程退出时会刷新一次所有该进程的缓冲区,即所有 FILE 类型文件,包括 stdin、stdout、stderror

(4)fclose 刷新:该函数内部程序关闭目标文件前,会自动刷新一次该文件对应的用户缓冲区



4、在内核缓冲区的数据和用户无关

只要用户的数据从用户缓冲区写到内核缓冲区,就代表该数据已经被操作系统拿到了,该数据已经和用户无关了,此话如何理解:

当用户的数据从用户缓冲区写入到内核缓冲区时,意味着数据已经传递给了操作系统内核,这个过程通常通过系统调用完成(例如,在Unix/Linux系统中使用write()函数)。此时,可以认为该数据已经被操作系统“拿到”,并且与用户进程直接相关的责任已经结束。以下是几个关键点解释为什么这么说:

这一点非常重要,一定要记住和理解!


5、解释程序需要加上 fflush(stdout) 进行主动刷新原因

下面我逐层讲解:

首先,fflush(stdout) 的作用是进行主动的刷新目标文件对应的用户层缓冲区(也就是 FILE 结构体内部的那个缓冲区),此处是主动刷新 stdout 对应的FILE 结构体中用户缓冲区。

其次,printf 会将数据打印到 stdoutFILE 类型的结构体中的用户缓冲区。

系统调用 close 的作用是将指定 fd 的文件描述符对应的文件结构体 struct file 销毁,即为关闭该文件,程序的最后 return 0; 会结束本进程。


在这个过程中,程序最后的 return 0 结束进程时,会刷新一次本程序中所有相关的用户缓冲区,但是此时 fd=1 的文件已经被先一步 close 了!!意味着该文件的文件结构体 struct file 已被销毁,包括其中的内核缓冲区!,前面第4点知识中讲解过,只有将数据从用户缓冲区刷新到文件内核缓冲区才算数据打印成功,才算数据成功移交给文件。

但此时该文件内核缓冲区都被销毁了,最后结束进程刷新的用户缓冲区也就没有效果了,当然 fd=1 对应的文件也就没有数据输入了


因此我们需要主动在 close 语句之前执行一次 fflush ,目的是刷新该 FILE 类型结构中的用户缓冲区,把内容拷贝到文件内核缓冲区中,这才算数据打印成功,才算数据传输成功。



但此处又产生一个问题:不对呀,之前你不是说输入的内容要是带了一个 \n 不就表示缓冲区会行刷新,会自动帮你把 \n 前的字符串刷新到文件内核缓冲区吗?

这就需要重新理解之前提到过的缓冲区刷新策略了:

  • 对于显示器文件来说,采用行刷新!!
  • 对于普通文件来说,采用全刷新,即写满了再刷新!

我们这份代码已经将 fd = 1 默认对应的那个显式器文件关闭了,换成了一个普通文件 log.txt

当然采用全刷新的策略辣!




一段代码:综合理解前面讲解内容


(1)打印+ fork() 创建子进程

我们先写三个C标准库中的打印函数,再写一个系统调用打印函数,最后加上一个 fork() 创建子进程

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

int main()
{
    // C库 函数
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");
    const char *message = "hello fwrite\n";
    fwrite(message, 1, strlen(message), stdout);

    // 系统调用
    const char *w = "hello write\n";
    write(1, w, strlen(w));

    // 创建子进程
    fork();
    return 0;
}



[拓展] 简单解释一下这三个标准库打印函数

printf:用于直接向标准输出(默认为屏幕)打印格式化的字符串。

fprintf:可以指定向目标文件打印格式化的字符串。

fwrite

  • 与前两者不同,fwrite不是用来输出格式化文本的。它是用来写二进制数据块到文件中的。
  • 它通常用于保存结构体、数组或其他复杂的数据类型到文件中,而不是简单的文本输出。
  • 示例:fwrite(&data, sizeof(int), 1, fp); 将一个整数data以二进制形式写入文件指针fp指向的文件中。

该代码运行结果:


是不是比较正常,再来看:如果将打印结果重定向放到文件 log.txt 中呢?




你可以发现奇怪的点:

1、 write 的结果先一步被打印 而且 write 只打印了一份;

2、其他三个打印函数各自打印了两份

是不是特别奇怪???解释原因如下:


先看结构图:




1、子进程继承父进程的代码数据与用户缓冲区:父进程通过函数将字符串打印到用户缓冲区中,而 fork 出来的子进程继承了父进程的各种类型结构体和代码数据,当然包括用户层面的用户缓冲区。在 return 0 之前,父进程都没有刷新各个用户缓冲区,导致缓冲区中数据仍存留,因此两个进程在最后退出时,都各自刷新了各自的缓冲区,相当于打印了两份数据,相当于 fflush 了两遍。

2、为什么函数在写入缓冲区时,不会被 \n 刷新缓冲区,而是堆积到进程结束才刷新呢?

要注意,因为命令将 fd = 1 重定向到了一个普通文件 log.txt ,普通文件的缓冲区刷新策略是全刷新,当缓冲区满时才会刷新,而显示器文件的刷新策略才是行刷新

3、为什么重定向到文件,write 函数会先被打印出来?

因为 write 函数是系统调用,是直接将字符串打印到系统内核中,普通文件 log.txt的文件结构体中的内核缓冲区,而其他三个函数是C语言库函数,会先打印到用户层面的标准输入缓冲区,等到进程退出时才刷新

因此,虽然C语言库函数先执行,系统调用 write 函数后执行,但是两者的刷新时间相反

4、为什么重定向到文件,write 函数只被打印一份

因为子进程是继承父进程的用户层面的用户层缓冲区,而系统调用 write 函数直接打印到文件 log.txt的文件结构体中的内核缓冲区,当然这个步骤没有被继承



(2)去掉 \n 的版本

我们如果去掉 \n,再将结果打印到显示器文件中(也就是不重定向了)

为了好区分,给每个字符串前后额外加上空格

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

int main()
{
    // C库 函数
    printf(" hello printf ");
    fprintf(stdout, " hello fprintf ");
    const char *message = " hello fwrite ";
    fwrite(message, 1, strlen(message), stdout);

    // 系统调用
    const char *w = " hello write ";
    write(1, w, strlen(w));

    // 创建子进程
    fork();
    return 0;
}

代码运行结果如下:

1、write 照样只打了一次

2、其他三个C库函数的内容打印两次

原因:因为没有了 \n ,则按照全刷新策略:满了再刷新,否则进程结束再刷新



3、[拓展] fsync 函数


我们前面说明了通过 fflush(fd) 函数将数据从用户级缓冲区刷新到文件内核缓冲区

而这个 fsync 函数用于将数据从文件内核缓冲区刷新到外部设备上!

(虽然数据从文件内核缓冲区刷新到外部设备上的工作,系统会自己做,但是有某些特殊情况需要使用)

一般情况,系统的 IO 压力较小,基本上,写入文件内核缓冲区的数据都会被直接刷新到外设中,而不会过多缓存

当系统IO压力较大,则系统会灵活,增加一次缓存量,减少IO次数


synchronize:同步

而该函数就是用于把数据持久化(即存储在磁盘中)



4、重定向

前面文章中,我们手动将 fd = 1 指针指向的文件换成了新文件 log1.txt ,则 printf 就向该新文件中打印。

这个过程只有 fd = 1 指针指向的文件改变了,其他东西没有改变

本来应该向显示器文件写入,却转而写到了新文件中

这就是重定向的底层原理!



重定向就是修改文件描述符指向的文件,使得用户的 `printf` 等打印操作,将数据打印到新文件中

重定向只有下层的内核做了修改操作,而上层 printf 不知道,固定看到的依旧是 fd = 1,它的工作就是固定的向 fd = 1 打印数据,而无需关心 fd = 1 具体指向什么文件。


文件描述符和文件描述符表的设计十分巧妙强大,它允许上层应用代码无需关心底层具体的数据流向,只需按常规方式使用文件描述符进行读写操作即可。而所有的重定向操作都由操作系统内核级别处理,这保证了即使对文件描述符进行了修改,也不会影响到应用程序本身的行为逻辑。因此,无论是日志记录、调试信息输出还是其他需要改变默认I/O流的应用场景,都可以通过简单的重定向操作来实现,极大地提高了灵活性和可维护性。




我们上面实现重定向的方法是:直接先 close(1) 关闭stdout ,再通过系统分配 fd 机制将 fd=1 分配给新文件,最后实现 fd=1 指向新文件

但是,这种方式有点不优雅,有没有可以将 fd=1 直接转让给其他文件的方法,即通过系统调用 dup2



系统调用 dup2

前面讲解过,在文件描述符表中,每个数组元素都是一个 文件结构体指针,指向对应已打开的文件(结构体)


我们可以无需通过 ”关闭指定 fd 再让系统分配“ 这种方式重定向,我们可以设法修改指定 fd 中的 文件结构体指针 ,让其指向其他文件(结构体),这样就完成一次重定向,这就需要通过系统调用 dup2:

int dup2(int oldfd, int newfd);

dup: duplicate file descriptor,即“复制文件描述符”


系统调用 dup2 的作用是:将文件描述符表中指定 fd 位置的文件指针浅拷贝到另一个指定 fd 位置,达到修改指针而重定向的目的


int dup2(3, 1);



问题:为什么系统调用 dup2 的参数这么填:oldfd=3,newfd=1

看文档:


最后会关闭掉 `newfd` ,而留下 `oldfd` ,而我们浅拷贝 `fd=3` 的指针到 `fd=1` 最后留下的肯定是 `fd=3` 的那个文件指针,`fd=1` 原先指向的 `stdout` 就被关闭了

其实这里的参数命名是有点奇怪的:oldfd、newfd

何为 old 何为 new

我们就这么理解: old是活得久的意思,最后存活下来的 fd 是被拷贝过来的那个,活得久



问题:浅拷贝指针会引起多指针指向同一文件吗

会的

前面我们将 fd=3 的文件指针拷贝到 fd=1 ,相当于此时有两个指针同时指向同一个文件结构体

当然,系统自己会在某些时候关闭掉 fd=3 的指向

而文件结构体内部 struct file 其实有个属性:count

这是引用计数,便于记录指针指向数量,处理多指针指向问题


演示:使用 dup2 重定向

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

int main() {
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    
    dup2(fd, 1);
    
    printf("hello fd: %d\n", fd);
    fprintf(stdout, "hello fd: %d\n", fd);
    fputs("hello world\n", stdout);
    
    const char *message = "hello fwrite\n";
    fwrite(message, 1, strlen(message), stdout);
    
    close(fd);
    
    return 0;
}

问题:那两个打印函数向标准输入流 stdout 打印,为什么最后却打印到了新文件 log.txt 中呢?

C 语言中,stdout 是一个文件流指针,固定指向 fd=1 。当我们执行程序 dup2(fd, 1) 之后, fd=1 已经被重定向到了 log.txt 文件,而原来默认的 显示器。

简单理解:stdout 类似一种宏,程序中 stdout 等于 fd = 1


综上,何是追加重定向?



追加重定向

​ 追加重定向不仅仅是改变了文件描述符所指向的目标文件,而且还特别指定了文件的打开方式为“追加”模式。这意味着当程序通过这个文件描述符进行写操作时,新写入的数据会添加到文件的末尾,而不会覆盖文件中已有的内容。

代码实现:先 open 打开一个文件,并通过参数传递 O_APPEND 属性,再通过 dup2 重定向,则完成追加重定向


代码演示:

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

int main() {
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    
    dup2(fd, 1);
    
    printf("hello fd: %d\n", fd);
    
    
    close(fd);
    
    return 0;
}

运行结果如下:



输入重定向

输入重定向是一种允许你改变程序读取输入来源的机制。通常情况下,程序的标准输入(stdin)是从键盘获取的,即用户在终端中输入的内容。然而,通过输入重定向,你可以改变这一行为,使得程序从文件或其他数据源读取输入信息,而不是直接从键盘输入。

比如,我将输入重定向到文件 log.txt,则 程序就从文件 log.txt 获取数据了

代码逐步演示:

(1)演示从键盘读取数据:即从 fd=0 的标准输入流中读取数据,放到字符数组 buff 中,并通过 printf 将 字符数组 buff 打印到显示器上

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

int main() {
    
    char buffer[2048];
    size_t s = read(0, buffer, sizeof(buffer));  // 这里 fd 直接设置为 0
    if (s > 0) {
        buffer[s] = '\0';
        printf("stdin redir:\n%s\n", buffer);
    }

    return 0;
}

运行结果如下:意思是我键盘输入的数据,因为 printf 使得数据显示在屏幕上给你看到,代表当前情况下,程序是默认从键盘获取数据



(2)输入重定向:我们将标准输入,即 fd=0 重定向成 新打开的文件 log.txt

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

int main() {
    int fd = open("log.txt", O_RDONLY);
    dup2(fd, 0);

    char buffer[2048];
    size_t s = read(0, buffer, sizeof(buffer));
    if (s > 0) {
        buffer[s] = '\0';
        printf("stdin redir:\n%s\n", buffer);
    }

    close(fd);

    return 0;
}

运行结果如下:结果表示该程序直接从文件 log.txt 中读取数据(而非从键盘上)



Linux 中的重定向命令

>:输出重定向

echo "Hello, World!"  // 默认输出到显示器上,即 fd=1
echo "Hello, World!" > output.txt  // 输出重定向到新文件 output.txt

>>:追加重定向

echo "Hello, World!" >> output.txt  // 追加重定向到新文件 output.txt

8、标准IO流 stdin & stdout & stder 和 文件描述符fd 的关系



9、理解 Linux 下一切皆为文件


前面学习了文件描述符与重定向的知识,其中提及到文件描述符 0 默认对应键盘,文件描述符 1 和 2 默认对应显示器,而这些文件描述符可以被重定向对应其他的新文件,这里就产生了一个问题:文件描述符为什么能对应硬件,也能对应文件?


前面文章还提及:在文件描述符表中,每个文件描述符都是该表的数组下标,每个数组下标位置上存有一个文件结构体指针,用于指向不同文件,既然是文件类型的指针,前面文章还提及:在文件描述符表中,每个文件描述符都是该表的数组下标,每个数组下标位置上存有一个文件结构体指针,用于指向不同文件,既然是文件类型的指针,岂不是可以这样理解:键盘、显示器等设备也是一种文件?

悟出这一点,就和理解 Linux 下一切皆为文件的道理不远了



由于篇幅,跳转本篇文章继续阅读:
本文讲解:描述外设的struct device、外设对应的 struct file、外设的上层和底层两套读写函数、理解为何Linux下一切皆文件、文件操作方法集合、不同终端是不同设备




10、语言封装的意义

本文 从 “显示器是字符设备,只接收和打印字符” 这个例子讲解 输入格式化;输出格式化;参数buff 的类型是 void* 的原因 等等 知识点

因篇幅问题不能全部显示,请点此查看更多更全内容