【Linux系统】从 C 语言文件操作到系统调用的核心原理

发布于:2025-05-16 ⋅ 阅读:(14) ⋅ 点赞:(0)

在这里插入图片描述


前言

本文将从文件的基本概念出发,先回顾 C 语言中文件操作的常用接口,再逐步过渡到 Linux 系统调用,解析文件描述符、文件打开对象、进程与文件的关系等关键概念。通过代码示例和原理分析,带你揭开 Linux 基础 IO 的神秘面纱,理解操作系统如何管理文件、进程如何与文件交互的底层逻辑。


lesson 15_基础IO

一、共识原理

  • 文件 = 内容 + 属性。

  • 文件分为 打开的文件没打开的文件

  • 打开的文件:谁打开的?进程!—— 本质是研究进程和文件的关系。

  • 没打开的文件:在哪里放着呢?在磁盘上。我们最关注的问题?没有被打开的文件非常多,文件如何被分门别类的放置好(如何存储) —— 我们要快速的进行增删查改 —— 快速找到文件。

  • 文件被打开,必须先被加载到内存!

  • 进程:打开的文件 = 1:n。

小结:操作系统内部,一定存在大量的别打开的文件!—— OS 要不要管理这些被打开的文件呢? —— 怎么管理???—— 先描述,在组织。—— 在内核中,一个被打开的文件都必须有自己的文件打开对象,包含文件的很多属性。struct XXX{文件属性;struct XXX *next};

二、回顾C语言接口

2.1 文件的打开操作

  • fopen 函数用于打开文件,格式为

    • FILE *fopen(const char *path, const char *mode);
      
    • path: 文件路径或文件名。如果只有文件名,操作系统会在当前工作目录(cwd)下查找该文件。

    • mode: 文件打开模式。常见模式有:

      • w: 如果文件已存在,先清空文件再写入。如果文件不存在,创建新文件。
      • a: 以追加模式打开文件,在文件末尾添加内容。
  • 当前路径 (cwd): 每个进程维护一个当前工作目录,操作系统会根据该目录来查找文件。如果路径没有指定,fopen 会使用进程的当前工作路径。

  • 2.2 文件的读取与写入操作

    • fwrite 用于向文件写入数据。其函数声明为:

      size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
      
      • ptr: 指向要写入数据的指针。
      • size: 每个写入对象的大小。
      • nmemb: 要写入的对象个数。
      • stream:文件流指针
    • 举例使用:

      int main()
      {
          FILE *fp = fopen("log.txt", "w");
          if (fp == NULL)
          {
              perror("fopen");
              return errno;
          }
          char* str = "Hello Linux!";
          fwrite(str, strlen(str), 1, fp);
          fclose(fp);
          return 0;
      }
      
      • fwrite的第二个参数是指每个写入对象的大小,strlen函数返回的值是不包含字符串结束标识符,那么我们传参是加一还是不加一呢?加一就代表把\0写入到文件中,那么我们是应该怎么选择呢?这里不妨试一试加一的结果:

        注意 log.txt 文件中,字符串的末尾有一个^@,是什么意思呢?实际上这个字符组合是表示\0的ASCII码,所以写入字符串时,使用 strlen 计算字符串长度时,不包括结束符 \0。通常不需要将 \0 写入文件,因为它是 C 语言中的结束标志,而在其它语言中读取文件时,可能不希望看到这些无关的字符。

    2.3 三个标准输入输出流

    C 程序启动时,会自动打开以下三个标准流:

    • stdin: 标准输入流(通常与键盘连接)。
    • stdout: 标准输出流(通常与显示器连接)。
    • stderr: 标准错误流(通常与显示器连接)。

    这三个流都由操作系统和 C 标准库提供,并用于处理程序与外部交互的基本输入输出。

三、过渡到系统,认识文件系统调用

文件其实是在磁盘上的,磁盘是外部设备,访问磁盘文件其实是访问硬件!几乎所有的库只要是访问硬件设备,必定要封装系统调用。

3.1 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: 文件路径。
    • flags: 打开文件时的标志,例如:
      • O_RDONLY:只读打开。
      • O_WRONLY:只写打开。
      • O_RDWR:读写打开。
      • O_CREAT:文件不存在时创建文件。
      • O_TRUNC:打开文件时清空文件内容。
      • O_APPEND:以追加模式打开文件。
    • mode: 在使用 O_CREAT 时,需要指定新文件的访问权限。
  • 返回值:成功返回文件描述符,失败返回 -1。
1. 比特位标志位示例

通过按位或(|)传递多个标志位,可以在同一次调用中同时指定多个选项。

代码示例:

#define ONE (1<<0) // 1
#define TWO (1<<1) // 2
#define FOUR (1<<2) // 4
#define EIGHT (1<<3) // 8

void show(int flags)
{
    if(flags & ONE) printf("function1\n");
    if(flags & TWO) printf("function2\n");
    if(flags & FOUR) printf("function3\n");
    if(flags & EIGHT) printf("function4\n");

    return;
}

int main()
{
    printf("--------------------------------------\n");
    show(ONE);
    printf("--------------------------------------\n");
    show(ONE | TWO);
    printf("--------------------------------------\n");
    show(ONE | TWO | FOUR );
    printf("--------------------------------------\n");
    show(ONE | TWO | FOUR | EIGHT);
    printf("--------------------------------------\n");
    return 0;
}

输出示例:

3.2 write 系统调用

write 用于将数据写入文件,其原型为:

ssize_t write(int fd, const void *buf, size_t count);
  • 参数说明:
    • fd: 文件描述符。
    • buf: 指向数据缓冲区的指针。
    • count: 要写入的数据字节数。
  • 返回值:实际写入的字节数。
1. 模拟实现 w 选项

模拟 fopenw 模式(清空文件后写入):

int main()
{
    umask(0); // 将权限掩码设置成0000
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); 
    if(fd < 0)
    {
        printf("open file error\n");
        return 1;
    }

    const char* str = "bbb";
    ssize_t ret = write(fd, str, strlen(str));

    close(fd);

    return 0;
}
2. 模拟实现 a 选项

模拟 fopena 模式(追加写):

int main()
{
    umask(0); 
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666); 
    if(fd < 0)
    {
        printf("open file error\n");
        return errno;
    }

    const char* str = "bbb";
    ssize_t ret = write(fd, str, strlen(str));

    close(fd);

    return 0;
}

3.3 read 系统调用

read 用于从文件中读取数据,其原型为:

ssize_t read(int fd, void *buf, size_t count);
  • 参数说明
    • fd: 文件描述符。
    • buf: 存储读取数据的缓冲区。
    • count: 缓冲区的大小。
  • 返回值:实际读取的字节数。

四、访问文件的本质

  1. struct file 结构体的作用
  • 当文件被打开时,操作系统为该文件创建一个 struct file 结构体对象,负责管理该文件的元数据和访问信息。
  • 操作系统对文件的管理本质上就是对这些 struct file 结构体对象的管理,它们被组织成一个双链表,保存所有当前打开的文件。
  1. 文件描述符表(files_struct
  • 每个进程都有一个 struct files_struct 类型的对象,它记录了该进程所打开的所有文件的信息。
  • struct files_struct 中有一个文件描述符表,维护了一个 struct file* 类型的数组。数组的下标就是文件描述符,指向进程打开的文件的 struct file 结构体对象。
  1. 文件描述符的分配规则
  • 操作系统会为进程打开的新文件分配一个文件描述符,分配从 3 开始(因为标准输入、输出、错误流占用文件描述符 0、1、2)。
  • 新打开的文件将从进程的文件描述符表中找到最小的未使用下标,作为文件描述符。
  1. FILE 类型在 C 语言中的作用
  • FILE 是 C 语言库中的封装类型,用于描述文件,它提供了更高层次的文件操作接口。

  • FILE 类型内部封装了文件描述符,_fileno 字段就是对应的文件描述符,可以通过它来访问底层的文件描述符。

    int main()
    {
        umask(0); 
        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);
    
        return 0;
    }
    
  1. 文件的引用计数与关闭
  • 文件可以被多个进程同时打开,struct file 中有一个 f_count 字段来记录文件的引用计数。
  • 当进程关闭文件时,close 系统调用会将文件描述符表中对应位置的内容置为 NULL,减少文件的引用计数。如果引用计数为 0,操作系统会回收该文件对应的资源。
  1. 标准输入、输出和错误流(文件描述符 0, 1, 2)
  • 操作系统会在程序启动时自动打开标准输入(文件描述符 0)、标准输出(文件描述符 1)和标准错误(文件描述符 2)。

  • 这三个文件描述符是预留的,程序中打开的新文件会从文件描述符 3 开始。

  1. 文件描述符的关闭与输出
  • 通过 close 系统调用关闭文件描述符后,进程无法再通过该文件描述符进行文件操作。例如,关闭标准输出(close(1))会导致后续的 printf 输出无法显示,但其他流如标准错误仍然有效。

    int main()
    {
        close(1); // 将 stdout 关闭
        int ret = printf("stdin->fd: %d\n", stdin->_fileno);
        printf("stdout->fd: %d\n", stdout->_fileno);
        printf("stderr->fd: %d\n", stderr->_fileno);
    
        fprintf(stderr, "printf ret: %d\n", ret);
        return 0;
    }
    

结语

IO 操作是操作系统的 “血脉”,理解其底层原理不仅能帮助我们写出更健壮的代码,还能为深入学习进程通信、网络编程等高级主题奠定基础。希望本文能成为你探索 Linux 系统编程的一块基石,在后续的学习中,你可以尝试结合实际项目,对比不同 IO 接口的性能差异,或深入分析内核源码中的文件管理逻辑,进一步提升技术深度。

今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!

在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到