【Linux 系统篇(二十五)】文件(二) 系统文件I/O详解


大家好,欢迎来到 huangjin007_ 的博客
⭐ 个人主页:huangjin007_
🔥 文章收录专栏:Linux 内功修炼手册(系统篇)
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇


Linux 系统篇(二十五) ------ 系统文件I/O

文章目录

  • [Linux 系统篇(二十五) ------ 系统文件I/O](#Linux 系统篇(二十五) —— 系统文件I/O)
    • 一、从标志位说起
      • [1.1 问题:如何给函数传递多个开关?](#1.1 问题:如何给函数传递多个开关?)
      • [1.2 位运算基础](#1.2 位运算基础)
      • [1.3 示例代码与解析](#1.3 示例代码与解析)
      • [1.4 为什么系统调用也这么用?](#1.4 为什么系统调用也这么用?)
    • [二、系统文件 I/O 初体验](#二、系统文件 I/O 初体验)
      • [2.1 写文件:hello.c](#2.1 写文件:hello.c)
      • [2.2 读文件:hello.c](#2.2 读文件:hello.c)
    • [三、open 函数详解](#三、open 函数详解)
      • [3.1 函数原型与头文件](#3.1 函数原型与头文件)
      • [3.2 flags 参数:打开模式与附加选项](#3.2 flags 参数:打开模式与附加选项)
      • [3.3 mode 参数与 umask](#3.3 mode 参数与 umask)
      • [3.4 返回值:文件描述符](#3.4 返回值:文件描述符)
      • [3.5 语言层与系统调用的映射](#3.5 语言层与系统调用的映射)
      • [3.6 文本写入 vs 二进制写入](#3.6 文本写入 vs 二进制写入)
    • 四、系统调用与库函数
      • [4.1 库函数:fopen, fclose, fread, fwrite](#4.1 库函数:fopen, fclose, fread, fwrite)
      • [4.2 系统调用:open, close, read, write](#4.2 系统调用:open, close, read, write)
      • [4.3 关系图](#4.3 关系图)
      • [4.4 为什么要有库函数?](#4.4 为什么要有库函数?)
    • [五、文件描述符 fd](#五、文件描述符 fd)
      • [5.1 fd 是什么?](#5.1 fd 是什么?)
      • [5.2 默认打开的 0、1、2](#5.2 默认打开的 0、1、2)
      • [5.3 文件描述符的分配规则:最小未使用](#5.3 文件描述符的分配规则:最小未使用)
      • [5.4 文件描述符的分配规则总结](#5.4 文件描述符的分配规则总结)
    • 六、重定向
      • [6.1 输出重定向:close(1) + open](#6.1 输出重定向:close(1) + open)
      • [6.2 重定向的本质](#6.2 重定向的本质)
      • [6.3 dup2 系统调用](#6.3 dup2 系统调用)
      • [6.4 输入重定向:dup2(fd, 0)](#6.4 输入重定向:dup2(fd, 0))
    • 七、深入内核
      • [7.1 进程控制块 task_struct](#7.1 进程控制块 task_struct)
      • [7.2 files_struct 与 fd_array](#7.2 files_struct 与 fd_array)
      • [7.3 struct file:打开的文件对象](#7.3 struct file:打开的文件对象)
      • [7.4 inode:磁盘文件的元信息](#7.4 inode:磁盘文件的元信息)
      • [7.5 一个进程可以打开多个文件,一个文件可以被多个进程打开](#7.5 一个进程可以打开多个文件,一个文件可以被多个进程打开)
      • [7.6 引用计数 ref_cnt](#7.6 引用计数 ref_cnt)
    • 结语:

一、从标志位说起

1.1 问题:如何给函数传递多个开关?

  假设你要写一个函数,它支持三种功能:ONE、TWO、THREE。用户可以单独使用某一个,也可以组合使用。怎么设计参数?

  一种笨办法是传三个布尔值:func(bool one, bool two, bool three)。但这样不优雅,而且如果功能增加到几十个,参数列表会爆炸。

  更好的办法是:用一个整数的不同二进制位来表示不同的功能。这就是位掩码(bitmask)。

1.2 位运算基础

先复习一下位运算:

  • & 按位与:两个位都为 1 时结果为 1,否则为 0。
  • | 按位或:两个位只要有一个为 1,结果就为 1。
  • << 左移:把二进制位向左移动,右边补 0。

例如:

c 复制代码
#define ONE   (1 << 0)  // 0000 0001
#define TWO   (1 << 1)  // 0000 0010
#define THREE (1 << 2)  // 0000 0100
#define FOUR  (1 << 3)  // 0000 1000

  这样每个宏只占一个二进制位,互不干扰。

1.3 示例代码与解析

代码:

c 复制代码
#include <stdio.h>

#define ONE   0001 // 0000 0001
#define TWO   0002 // 0000 0010
#define THREE 0004 // 0000 0100

void func(int flags) 
{
    if (flags & ONE)   printf("flags has ONE! ");
    if (flags & TWO)   printf("flags has TWO! ");
    if (flags & THREE) printf("flags has THREE! ");
    printf("\n");
}

int main() 
{
    func(ONE);
    func(THREE);
    func(ONE | TWO);
    func(ONE | THREE | TWO);
    return 0;
}

解析:

  • ONE、TWO、THREE 分别是八进制数 0001、0002、0004,对应二进制的 001、010、100。
  • func(ONE):传入 0001,flags & ONE 非零,打印 ONE。
  • func(ONE | TWO):0001 | 0010 = 0011,所以 ONE 和 TWO 都会被打印。
  • 通过 & 运算,可以检测某个位是否被设置;通过 | 运算,可以组合多个标志。

1.4 为什么系统调用也这么用?

  因为系统调用是 C 函数,参数是整数。用位掩码可以在一个整数里塞下几十个开关,既节省参数,又灵活高效。


二、系统文件 I/O 初体验

  语言层的 fopen、fwrite 我们很熟悉。现在看看如何用系统调用实现同样的功能。

2.1 写文件:hello.c

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

int main()
{
    umask(0); // 设置文件创建时的权限掩码为 0,这样创建的文件权限就是我们指定的 0644
    int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
    if (fd < 0) 
    {
        perror("open");
        return 1;
    }

    int count = 5;
    const char *msg = "hello Linux!\n";
    int len = strlen(msg);

    while (count--) 
    {
        write(fd, msg, len); // fd: 文件描述符,msg: 缓冲区首地址,len: 期望写入的字节数
                             // 返回值:实际写入的字节数
    }

    close(fd);
    return 0;
}

解释:

  • umask(0):umask 是文件权限掩码。系统在创建文件时,会用 mode & ~umask 计算实际权限。把 umask 设为 0,文件权限就完全由 open 的第三个参数决定。这里我们指定 0644,即 rw-r--r--。
  • open("myfile", O_WRONLY | O_CREAT, 0644):
    • O_WRONLY:只写打开。
    • O_CREAT:如果文件不存在则创建。
    • 0644:创建文件时的权限。
    • 返回值 fd 是文件描述符,是一个非负整数。失败返回 -1。
  • write(fd, msg, len):把 msg 指向的 len 个字节写入文件。返回值是实际写入的字节数。
  • close(fd):关闭文件描述符,释放资源。

2.2 读文件:hello.c

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("myfile", O_RDONLY);
    if (fd < 0) 
    {
        perror("open");
        return 1;
    }
    const char *msg = "hello Linux!\n";
    char buf[1024];
    ssize_t s;
    while (1) 
    {
        s = read(fd, buf, strlen(msg));
        if (s > 0) 
        {
            buf[s] = '\0';   // 读到s个字节,在s下标位置补字符串结束符
            printf("%s", buf);
        } 
        else 
        {
            // s==0 读到文件末尾;s<0 读取出错
            if(s < 0)
            {
                perror("read");
            }
            break;
        }
    }
    close(fd);
    return 0;
}

三、open 函数详解

3.1 函数原型与头文件

c 复制代码
#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:要打开或创建的目标文件路径。
  • flags:打开方式,由多个常量按位或(|)组成。
  • mode:创建文件时的权限,仅在使用了 O_CREAT 时有效。
  • 返回值:成功返回新的文件描述符(非负整数),失败返回 -1 并设置 errno。

3.2 flags 参数:打开模式与附加选项

必须指定且只能指定一个的访问模式:

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

常用的附加选项:

  • O_CREAT:若文件不存在,则创建它。需要使用 mode 参数指明新文件的访问权限。
  • O_APPEND:追加写。每次写操作前,文件偏移量自动移到文件末尾。
  • O_TRUNC:截断。如果文件已存在,且以只写或读写方式打开,则将其长度截断为 0(清空)。
  • O_EXCL:与 O_CREAT 一起使用,如果文件已存在,则 open 失败。用于确保创建的是新文件。

示例:

c 复制代码
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666); // 类似 "w"
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666); // 类似 "a"
int fd = open("log.txt", O_RDONLY); // 类似 "r"

3.3 mode 参数与 umask

  mode 是一个八进制数,表示文件权限。比如 0644 表示:

  • 所有者:读、写(6)
  • 组:读(4)
  • 其他:读(4)

  实际创建的文件权限是 mode & ~umask。umask 是进程的文件权限掩码,通常默认为 022。所以 0644 & ~022 = 0644。如果 umask 是 002,那么 0644 & ~002 = 0644 不变;如果 umask 是 077,那么 0644 & ~077 = 0600。

  可以用 umask 命令查看当前 shell 的掩码,也可以用 umask() 函数在程序中设置。

3.4 返回值:文件描述符

  open 成功时返回一个文件描述符,它是一个小的非负整数。失败返回 -1。

  文件描述符是本文的核心概念,后面会详细讲。

3.5 语言层与系统调用的映射

  C 标准库的 fopen 有几种模式:

  • "r":只读打开,对应 O_RDONLY。
  • "w":只写打开,文件存在则清空,不存在则创建,对应 O_WRONLY | O_CREAT | O_TRUNC。
  • "a":追加写,文件不存在则创建,对应 O_WRONLY | O_CREAT | O_APPEND。
  • "r+":读写打开,对应 O_RDWR。
  • "w+":读写打开,清空或创建,对应 O_RDWR | O_CREAT | O_TRUNC。
  • "a+":读写追加,对应 O_RDWR | O_CREAT | O_APPEND。

  所以,fopen 内部就是根据这些字符串拼出 flags,然后调用 open。

3.6 文本写入 vs 二进制写入

  系统调用不关心 你写的是文本还是二进制。write 只认字节流:你给它一个缓冲区,它就把里面的字节原样写入文件。所谓的"文本模式"和"二进制模式"是 C 标准库在 Windows 等平台上的概念(比如换行符转换)。在 Linux 上,文本和二进制没有区别,系统只认字节。

  因此,你可以用 write 写入整数、结构体、图片等任何数据,只要把它们转成字节序列即可。


四、系统调用与库函数

4.1 库函数:fopen, fclose, fread, fwrite

  这些是 C 标准库(libc)提供的函数,我们称之为库函数。它们运行在用户态,提供了更高级、更可移植的接口。

4.2 系统调用:open, close, read, write

  这些是操作系统内核提供的接口,我们称之为系统调用。它们直接操作文件描述符,是底层的。

4.3 关系图

  fopen 等库函数内部会调用 open 等系统调用。比如 fwrite 最终会调用 write。

4.4 为什么要有库函数?

  • 可移植性 :不同操作系统的系统调用可能不同,但 C 标准库的接口是统一的。程序用 fopen 可以在 Linux、Windows、macOS 上编译运行,而 open 是 Linux 特有的。
  • 缓冲 :库函数自带用户态缓冲区,减少系统调用次数,提高效率。比如 fwrite 可能先写到缓冲区,攒够了一定数量再调用 write。
  • 格式化 :printf、fprintf 等提供了格式化输出,系统调用 write 只能写原始字节。

五、文件描述符 fd

5.1 fd 是什么?

  通过 open 返回的 fd,是一个小的非负整数。比如 3、4、5......它到底是什么?

  本质:文件描述符是进程的文件描述符表中的数组下标。

  操作系统为了管理进程打开的文件,在每个进程的 PCB(进程控制块)中维护了一个文件描述符表 。这个表是一个指针数组,每个元素指向一个打开文件对象 (struct file)。数组的下标就是文件描述符。

  所以,你拿着 fd=3,操作系统就能通过 files_struct 找到对应的 struct file,从而找到文件的所有信息。

5.2 默认打开的 0、1、2

  Linux 进程在启动时,默认会打开三个文件描述符:

  • 0:标准输入(stdin),通常对应键盘。
  • 1:标准输出(stdout),通常对应显示器。
  • 2:标准错误(stderr),通常对应显示器。

  在 C 语言中,stdin、stdout、stderr 是 FILE* 类型的指针,它们内部封装了对应的文件描述符。

示例:用 read/write 操作标准输入输出

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main()
{
    char buf[1024];
    ssize_t s = read(0, buf, sizeof(buf)); // 从标准输入读
    if (s > 0) 
    {
        buf[s] = 0;
        write(1, buf, strlen(buf)); // 写到标准输出
        write(2, buf, strlen(buf)); // 写到标准错误
    }
    return 0;
}

  这段代码从键盘读取输入,然后分别输出到显示器和标准错误。因为 0、1、2 已经默认打开,所以可以直接使用。

5.3 文件描述符的分配规则:最小未使用

  当进程打开一个新文件时,操作系统会在 files_struct 的数组中,找到当前没有被使用的最小的下标,作为新的文件描述符。

验证代码:

c 复制代码
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
    int fd = open("myfile", O_RDONLY);
    if (fd < 0) 
    {
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    close(fd);
    return 0;
}

  因为 0、1、2 已经被占用,所以新打开的 fd 通常是 3。

  如果关闭 0:

c 复制代码
close(0);
int fd = open("myfile", O_RDONLY);
printf("fd: %d\n", fd); // 输出 0

  如果关闭 2:

c 复制代码
close(2);
int fd = open("myfile", O_RDONLY);
printf("fd: %d\n", fd); // 输出 2

  这就验证了分配规则:最小未使用。

5.4 文件描述符的分配规则总结

  • 默认打开 0、1、2。
  • 新打开的 fd 是当前未被使用的最小下标。
  • 关闭一个 fd 后,它的下标可以被重新分配。

六、重定向

6.1 输出重定向:close(1) + open

看这段代码:

c 复制代码
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    close(1); // 关闭标准输出
    int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    fflush(stdout);
    close(fd);
    exit(0);
}

  现象: 本来应该输出到显示器的内容,现在输出到了 myfile 文件中,而且 fd 是 1。

  原因: 我们关闭了 1 号文件描述符(标准输出),然后 open 新文件时,根据"最小未使用"原则,新文件占用了 1 号。此时 fd_array[1] 指向的不再是显示器,而是 myfile。printf 默认往 stdout 输出,而 stdout 底层就是 1 号 fd,所以内容写到了 myfile。

  这就是输出重定向 。常见的 shell 重定向 >、>>、< 就是基于这个原理。

6.2 重定向的本质

  重定向的本质:更改文件描述符表的指针指向。

  fd_array 的下标(如 1)没有变,但它指向的 struct file 对象变了。所有往这个 fd 写数据的行为,都会写到新的文件。

6.3 dup2 系统调用

  手动 close + open 实现重定向有点麻烦,而且容易出错。Linux 提供了 dup2 系统调用,专门用来复制文件描述符。

函数原型:

c 复制代码
#include <unistd.h>
int dup2(int oldfd, int newfd);

  功能: 让 newfd 成为 oldfd 的副本。如果 newfd 已经被打开,会先自动关闭它。成功返回新的描述符(即 newfd),失败返回 -1。

  官方描述: dup2() makes newfd be the copy of oldfd, closing newfd first if necessary.

示例:输出重定向

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
    int fd = open("./log", O_CREAT | O_RDWR, 0644);
    if (fd < 0) 
    {
        perror("open");
        return 1;
    }

    // close(1); // dup2 会自动关闭 newfd,所以这行可以省略
    dup2(fd, 1); // 让 1 号 fd 指向 fd 所指向的文件

    for (;;) 
    {
        char buf[1024] = {0};
        ssize_t read_size = read(0, buf, sizeof(buf) - 1);
        if (read_size < 0) 
        {
            perror("read");
            break;
        }
        printf("%s", buf);
        fflush(stdout);
    }
    return 0;
}

过程:

  • fd 是新打开文件的描述符(比如 3)。
  • dup2(fd, 1):让 1 号描述符指向 fd 指向的 struct file。原本 1 号指向显示器,现在指向 log 文件。显示器的 struct file 引用计数减 1,如果减到 0 就关闭。
  • 之后 printf 往 stdout(即 1 号)写,就写到了 log 文件。

注意: dup2 会自动关闭 newfd,所以不需要手动 close(1)。

6.4 输入重定向:dup2(fd, 0)

  类似地,可以重定向标准输入:

c 复制代码
int fd = open("input.txt", O_RDONLY);
dup2(fd, 0); // 让 0 号 fd 指向 input.txt
close(fd);   // 关闭多余的 fd

char buffer[64];
while (fgets(buffer, sizeof(buffer), stdin)
{
    printf("%s", buffer);
}

  这样,fgets 从 stdin 读取时,实际是从 input.txt 读取。


七、深入内核

  要真正理解文件描述符,必须看看内核中的数据结构。我们以 Linux 内核为例(版本不同,细节可能略有差异,但思想一致)。

7.1 进程控制块 task_struct

  每个进程在内核中都有一个 task_struct 结构体,它包含了进程的所有信息。其中有一个成员:

c 复制代码
struct files_struct *files;

  它指向该进程的文件描述符表。

7.2 files_struct 与 fd_array

  files_struct 是文件描述符表,内部最重要的部分是一个指针数组:

c 复制代码
struct file *fd_array[NR_OPEN_DEFAULT];

  这个数组的每个元素都是一个指向 struct file 的指针。数组的下标就是文件描述符。

7.3 struct file:打开的文件对象

  struct file 代表一个已经打开的文件。注意:它不代表磁盘上的文件本身,而是代表"进程与文件的一次打开会话"。

  它包含:

  • int ref_cnt;:引用计数。有多少个文件描述符指向它。
  • 文件读写位置(偏移量)。
  • 文件操作函数指针(如 read、write 的具体实现)。
  • 指向 inode 的指针(磁盘文件的元信息)。
  • 文件缓冲区(内核态)。

7.4 inode:磁盘文件的元信息

  inode 是磁盘上文件的元信息,包括文件大小、权限、创建时间、数据块位置等。每个文件在磁盘上都有一个唯一的 inode。struct file 通过指针关联到 inode,从而找到文件的实际数据。

7.5 一个进程可以打开多个文件,一个文件可以被多个进程打开

  • 一个进程可以打开多个文件:files_struct 的数组中有多个 struct file 指针。
  • 一个文件可以被多个进程打开:每个进程的 open 会创建不同的 struct file 对象,但它们都指向同一个 inode。struct file 中的 ref_cnt 记录有多少个这样的对象。
  • 同一个进程也可以多次打开同一个文件:每次 open 都会创建新的 struct file,返回不同的 fd。

7.6 引用计数 ref_cnt

  当 dup2 或 fork 等操作导致多个 fd 指向同一个 struct file 时,ref_cnt 增加。当 close 一个 fd 时,ref_cnt 减少。减到 0 时,内核才会释放这个 struct file 对象。

图示:

复制代码
进程 A
  task_struct
    files (files_struct)
      fd_array[0] --> struct file (键盘)
      fd_array[1] --> struct file (显示器)
      fd_array[2] --> struct file (显示器)
      fd_array[3] --> struct file (myfile) --> inode (myfile)

  当 close(1) 后,fd_array[1] 置空。再 open 新文件,新文件占用 1 号,fd_array[1] 指向新的 struct file。


结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

相关推荐
微小冷3 小时前
Mermaid画甘特图
运维·markdown·甘特图·mermaid·时间图
ShineWinsu3 小时前
对于ProtoBuffer:ProtoBuffer的介绍以及安装
linux·网络·google·协议·序列化·反序列化·protobuffer
云表无代码开发3 小时前
日本开发者彻底破防:中文太强了!换成英文直接裂开
大数据·服务器·人工智能·microsoft·信息可视化
FlightYe3 小时前
视界原理之2D视频(二):视频文件里有什么
android·linux·网络·c++·ffmpeg·音视频·aac
LBL12204 小时前
内网容器部署FTP日志监控服务
运维·服务器·容器
志栋智能4 小时前
超自动化巡检:驱动持续改进的运维飞轮
运维·自动化
海宇大数据4 小时前
零信任架构实战:基于海宇学历核验版构建自动化风控审查网关
运维·人工智能·架构·自动化
NailiConveyor4 小时前
输送机系统与WMS仓储管理系统集成的5个关键技术点
大数据·运维·自动化·制造·业界资讯
本人手速666+4 小时前
企微开发API如何设计客户冻结状态?WeComApi 在删除、投诉和异常客户场景中的自动化边界
运维·分布式·自动化·企业微信·企微外部群开发·wecomapi·企业微信二次开发