【Linux系统】文件IO:理解文件描述符、重定向、缓冲区


各位读者大佬好,我是落羽!一个坚持不断学习进步的学生。
如果您觉得我的文章还不错,欢迎多多三连分享交流,一起学习进步!

欢迎关注我的blog主页: 落羽的落羽

文章目录

一、C语言中的文件IO操作

在学习C语言时,我们了解过一些关于文件打开关闭读写的C库函数fopen fwrite fputs fread fclose fseek rewind ftell等等。详见:【C语言篇】文件操作

其中,值得关注的是fopen函数

它的第一个参数是文件名,第二个参数是文件的打开模式,有以下模式及其作用:

文件打开模式 含义 如果指定文件不存在
r(只读) 为了输入数据,打开一个文本文件 返回空指针
w(只写) 为了输出数据,打开一个文本文件 创建一个新的文件(如果文件存在,会清空原内容)
a(追加) 向文本文件尾添加数据 创建一个新的文件
rb(只读) 为了输入数据,打开一个二进制文件 返回空指针
wb(只写) 为了输出数据,打开一个二进制文件 创建一个新的文件
ab(追加) 向二进制文件尾添加数据 创建一个新的文件
r+(读写) 为了读写数据,打开一个文本文件 返回空指针
w+(读写) 为了读写数据,打开一个文本文件 创建一个新的文件
a+(读写) 在文本文件尾读写数据 创建一个新的文件
rb+(读写) 为了读写数据,打开一个二进制文件 返回空指针
wb+(读写) 为了读写数据,创建一个二进制文件 创建一个新的文件
ab+(读写) 在二进制文件尾读写数据 创建一个新的文件

最常用的就是r(只读) w(只写) a(追加),其中w模式会在打开文件时清空原内容。

我们知道,大多数文件是放在磁盘上的,根据冯诺依曼体系规则,必须先把文件从磁盘加载到内存才能被CPU访问,这个过程需要硬件层面的 I/O 操作支持,硬件操作一定不是靠C语言完成的,操作系统一定会提供相关的系统调用!

二、Linux中的文件IO系统调用

C语言中,一套正确的文件IO流程是:打开文件、读/写内容、关闭文件。

实际上,在其他语言或操作系统中,基本都是这样的流程,先打开文件,再读写,再关闭!

1. open、write、close

open:

系统调用open,用来打开一个文件:

  • 第一个参数pathname是目标文件
  • 第三个参数mode可以不带,用于控制文件权限,传递一个权限八进制数,如传递0666,实际文件权限值是0666-权限掩码
  • 第二个参数是打开文件的选项,选项是宏定义常量,同时有多个选项,选项之间以|(或)连接 ,常见的有:
    • 以下三个选项必须有且仅有指定一个:
      • O_RDONLY(只读打开)
      • O_WRONLY(只写打开)
      • O_RDWR(读写打开)
    • 以下选项可以同时指定:
      • O_CREAT(若指定文件不存在则创建它,使用该选项时必须带mode参数,否则新文件初始权限是乱的)
      • O_APPEND(追加写打开)
      • O_TRUNC(打开文件时清空原内容)

open是返回值是一个int,它代表着目标文件的文件描述符fd !这个概念一会再细说,目前可以理解为标识一个文件的数字,文件描述符是一个非负整数! 如果open返回值小于0,说明打开文件失败。

write

系统调用write的作用是向某个文件中写内容:

  • 第一个参数是目标文件的文件描述符,第二个参数是待写入的数据,第三个参数是你想要写入的字节数。
  • 返回值是:
    • 写入成功时,返回实际写入的字节数(可能小于 count,比如磁盘空间不足时)。
    • 失败时,返回 -1,并设置全局变量 errno 来指示错误原因。

close:

系统调用close用于关闭一个文件:

参数是目标文件的文件描述符,很简单。

我们演示一下使用这些系统调用完成文件读写:

c 复制代码
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>

int main()
{
    umask(0000); // 设置权限掩码
    int fd = open("log.txt", O_WRONLY|O_CREAT, 0644); // 只读打开,若文件不存在则创建
    if(fd < 0)
    {
        perror("打开文件失败");
        return 1;
    }

    const char*  msg = "hello!\n";
    write(fd, msg, strlen(msg));

    close(fd);

    return 0;
}

系统调用还有read、lseek等等。此时我们可以初步得出结论:C库函数fopen、fwrite、fread、fclose、fseek等,本质都是封装了相应的系统调用open、write、read、close、lseek等!

三、文件描述符fd

1. 文件描述符的本质

文件描述符,是一个非负整数。
Linux进程默认情况下会有三个自动打开的文件描述符,分别是:stdin标准输入0、stdout标准输出1、stderr标准错误2,对应的物理设备是:键盘、显示器、显示器。Linux中一切皆文件,从键盘打字就是从键盘文件中读取数据、显示器显示就是将数据写到显示器文件中!

所以,我们想从键盘读取数据,在屏幕上打印出来,也可以这样实现:

c 复制代码
char buf[16];
ssize_t s = read(0, buf, sizeof(buf)); // 从0号文件(标准输入,也就是键盘)中读取
if(s > 0)
{
	buf[s] = '\0';
	write(1, buf, strlen(buf)); // 写入1号文件(标准输出1,也就是显示器)
}

一个进程可以打开多个文件,每个文件的状态可能都不一样,所以进程一定需要管理文件!当我们打开文件时,操作系统要在内存中创建相应的数据结构来描述组织文件,于是就可以看到在task_struct中的struct files_struct* files结构!


可以看到,struct files_struct包含着一个数组fd_array。数组元素是struct file*类型,这个结构体类型,就对应着每一个具体的文件,里面包含着文件各种详细属性!

结论:本质上,文件描述符 fd 就是这个数组 fd_array 的下标,每个文件的 fd 就是它在这个数组中的下标!

2. 文件描述符的分配规则

我们来看几段代码和现象:

c 复制代码
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

int main()
{
    int fd = open("myfile.txt", O_WRONLY | O_CREAT);
    if(fd < 0)
    {
        perror("打开文件失败");
        return 1;
    }

    printf("fd: %d\n", fd);
    close(fd);

    return 0;
}
  • 现象1:直接打开一个新的文件,fd是3。
c 复制代码
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

int main()
{
    close(0);

    int fd = open("myfile.txt", O_WRONLY | O_CREAT);
    if(fd < 0)
    {
        perror("打开文件失败");
        return 1;
    }

    printf("fd: %d\n", fd);
    close(fd);

    return 0;
}
  • 现象2:先关闭标准输入0,再打开一个新文件,它的fd是0。
c 复制代码
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

int main()
{
    close(0);

    int fd = open("myfile.txt", O_WRONLY | O_CREAT);
    if(fd < 0)
    {
        perror("打开文件失败");
        return 1;
    }

    printf("fd: %d\n", fd);
    close(fd);

    return 0;
}
  • 现象3:先关闭标准错误2,再打开一个新文件,它的fd是2。

这三个现象可以得出结论了:文件描述符的分配规则是,在files_struct的fd_array数组中,找到一个当前没有被使用的最小的下标,作为新打开文件的文件描述符!当文件被关闭时,它的文件描述符就"空"出来了。

3. 重定向

在上面的现象中,如果先关闭标准输出1呢?

c 复制代码
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

int main()
{
    close(1);

    int fd = open("myfile.txt", O_WRONLY | O_CREAT, 0644);
    if(fd < 0)
    {
        perror("打开文件失败");
        return 1;
    }

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

    return 0;
}

我们发现,printf函数并没有打印到显示器上,而是写入到了 myfile.txt 文件中。

原因:

文件描述符1被关闭了,而1正是标准输出stdout的默认文件描述符。

再新打开一个文件myfile.txt,根据刚才说的文件描述符分配规则,它的 fd 就成了1。
printf 库函数,内部本来封装了write(1, ...);这样的系统调用,所以正常情况下就是向标准输出1显示器写入,可是现在1变成myfile.txt,但printf不知道,它只认文件描述符1,于是执行后内容被写进了myfile.txt!

这种现象叫做输出重定向!命令行中< > >>也是重定向!

在 Linux 系统中,重定向的本质是修改文件描述符 fd 与文件的关系,用一个文件描述符的内容指向另一个文件描述符的内容 。例如,文件描述符 x 原本指向文件A的 struct file,文件描述符 y 原本指向文件B的 struct file。若通过某些系统调用让 x 也指向文件B的 struct file,则后续所有通过 x 的文件操作,都会作用于文件B而非文件A。

实现这个过程的系统调用是dup2

调用后,第二个参数 fd 的指针会指向第一个参数的 fd 指向的文件,也就是第一个 fd 覆盖了第二个 fd。

c 复制代码
#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_CREAT | O_WRONLY, 0644);
    
    dup2(fd, 1); // fd覆盖了1, 1也指向fd指向的文件

    const char* s = "hello!\n";
    write(1, s, strlen(s)); // 本来想写入1,但实际上写入了fd

    close(fd);

    return 0;
}

四、缓冲区与刷新操作

1. 为什么要有缓冲区

缓冲区是内存空间中的一部分,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间叫做缓冲区。

读写文件时,如果不会开辟文件操作的缓冲区,直接通过系统调用对磁盘进行IO操作,那么每一次执行系统调用都涉及到CPU状态的切换,这将付出一定的效率成本,所以频繁的磁盘访问会对程序的执行效率造成很大影响!
我们可以采用缓冲机制,比如从磁盘中读文件时,可以一次性从文件中读取大量的数据到缓冲区中,然后这部分的访问就不用再调用系统调用了,以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作速度,所以缓冲区可以提高计算机的运行速度。

2. 语言级缓冲区与内核级缓冲区

缓冲区其实分为:语言级缓冲区、内核级缓冲区。

语言级缓冲区是由语言维护的,属于用户层。

内核级缓冲区是系统维护的,用户不必关心。

举例,你执行fprintf(fp, "hello");

  • 数据先被写入C库维护的语言级缓冲区(用户态),此时既没有触发系统调用,也没有写内核;
  • 刷新时,C库调用 write() 系统调用,把数据从语言级缓冲区拷贝到内核级缓冲区(内核态),此时仍未写硬盘;
  • 内核会异步地把内核级缓冲区的数据写到磁盘;
  • 如果你想强制写到磁盘,需要调用 fsync(fd) 系统调用,把内核级缓冲区的数据强制刷到物理磁盘中。

刷新(Flush)就是把缓冲区里暂存的数据,强制写入到目标文件中,并清空缓冲区。
本质上,语言级缓冲区刷新就是语言级缓冲区用write()系统调用将数据拷贝到内核文件缓冲区。

理论上,数据从C语言缓冲区刷新到内核文件缓冲区后,后面全由OS控制了,用户已经可以认为数据写入了磁盘中,但实际上还要等到内核缓冲区的刷新才是真正的完成。

你也可以使用系统调用fsync来强制让内核缓冲区刷新到磁盘文件中:

C语言缓冲区是由FILE结构体维护的!这个结构体封装了文件描述符、缓冲区等信息.在/usr/include/stdio.h中可以看到:

/usr/include/libio.h中:

3. 刷新方式

语言级缓冲区有几种刷新方式:

  • 进程结束时,会自动刷新,除非是系统调用_exit()结束的进程。
  • 当目标文件是显示器时,缓冲类型是"行缓冲",遇到 \n 换行符时自动刷新。
  • 当目标文件是普通文件时,缓冲类型是"全缓冲",缓冲区写满了才会刷新。
  • 通过fflush(FILE*)函数强制刷新指定的语言级文件缓冲区。

举个例子:

c 复制代码
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

int main()
{
    close(1);
    int fd = open("log.txt", O_CREAT | O_WRONLY, 0644);
    printf("hello!\n");

    close(fd);
        
    return 0;
}

先关闭了1,然后打开log.txt文件,执行printf函数,然后close(fd)。按照刚才的逻辑,hello!语句应该会写入log.txt文件中,可是执行程序后却发现log.txt没有内容!

原本,目标文件是stdout显示器时,语言级缓冲区会执行行缓冲,执行printf时遇到 \n 就会立刻刷新一次。
可是,现在重定向到了log.txt文件,这是一个普通文件,执行的是全缓冲!只有当缓冲区满或进程结束才会刷新。但是显然此时缓冲区没满,没有刷新。进程结束前,代码close(fd)使log.txt文件关闭了!"hello!\n"只存在于C语言的缓冲区中,既没有被刷新到内核级缓冲区,也没有写入log.txt,最终随着程序退出,缓冲区被释放,数据丢失。

另一个例子:

c 复制代码
#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 fd = open("log.txt", O_CREAT | O_WRONLY, 0644);

    printf("hello, printf!\n");
    const char* s = "hello, write!\n";
	write(fd, s, strlen(s));

    fork();        
    return 0;
}

向log.txt中分别用库函数printf和系统调用write写入,然后创建一个子进程,结果是:

原因:

  • write是系统调用,直接操作内核级缓冲区,调用write时,数据直接传递到内核态缓冲区(后续由内核刷新到log.txt),子进程不会影响。
  • printf是C库函数,数据存在C语言缓冲区中,目标文件是普通文件,缓冲区没满,只有进程结束后才刷新。而fork()会完整拷贝父进程的内存空间,包括C库的缓冲区,导致父子进程的C语言缓冲区里各有一份 "hello, printf!\n"。父子进程退出时,都会刷新自己的缓冲区,也就输出了两次!

本篇完,感谢阅读!

相关推荐
lly2024062 小时前
HTML DOM 访问
开发语言
sin_hielo2 小时前
leetcode 3637
数据结构·算法·leetcode
仍然.2 小时前
算法题目---双指针算法
数据结构·算法·排序算法
xie_pin_an2 小时前
Linux 基础入门:从历史到核心指令全解析
linux·运维·服务器
遇见火星2 小时前
服务器HBA卡与RAID卡:到底有什么区别?
运维·服务器·hba卡·raid卡
翼龙云_cloud2 小时前
亚马逊云渠道商:AWS RDS备份恢复实战
服务器·云计算·aws
爱吃泡芙的小白白2 小时前
深入权重之核:机器学习权重参数最新技术与实践全解析
人工智能·学习·机器学习
源代码杀手2 小时前
大型语言模型的主体推理(一项综述):2026 最新!Agentic Reasoning 终极指南——最全 LLM 智能体推理论文合集 + 核心架构解析
人工智能·语言模型·自然语言处理
ajole2 小时前
Linux学习笔记——基本指令
linux·服务器·笔记·学习·centos·bash