一、理解 Linux 系统视角的"文件"

1.1 系统角度下的文件

文件大小是 0KB 有没有占据磁盘空间?

答案是肯定的。

在深入探讨 Linux 基础 I/O 之前,必须明确一个最核心的概念:文件 = 内容 + 属性(无论是一个空文件还是填满数据的大文件,它们在磁盘上必然占据存储文件属性的空间)。因此,所有针对文件的操作,无外乎是对内容的操作或对属性的操作,甚或两者皆有。

从操作系统的底层视角来看,对文件的存取操作本质是进程对文件的操作。普通的物理磁盘本身作为硬件,其管理者是操作系统内核。

1.2 C 语言的文件操作复习

1. 三大模式

在 C 语言中,我们对文件的核心操作都是围绕"文件流"展开的。通过 fopen 函数,传入不同的打开模式可以改变程序对文件的读写行为。其中最核心、最常用的三种基础模式如下:

  • "r"(只读模式):打开一个已存在的文件进行读取。若文件不存在,则直接报错返回 NULL。数据流的初始读写位置被初始化在文件的开头;
  • "w"(只写清空模式):打开文件进行写入。如果目标文件不存在,系统会自动创建它;如果目标文件已经存在,标准库会自动调用底层系统接口,将文件长度截断为零,即文件会被彻底清空,然后从头开始写入;
  • "a"(追加写入模式):打开文件进行末尾追加。如果文件不存在则创建新文件;若存在,数据流的初始写位置会被强行定位在文件的末尾,新数据永远接在旧数据后面。

对于其余的混合读写模式(如 "r+"、"w+"、"a+"),它们允许同时进行读与写,但在实际开发中应用时,由于读写共用同一个位置指示器,往往需要配合位置调整函数使用。

我们先来回顾一下接口的使用。

2. fopen

  • 原型:FILE *fopen(const char *path, const char *mode);
  • 参数:
    • path:文件路径字符串;
    • mode:打开模式。
  • 功能:打开文件,返回文件指针;失败返回 NULL。

3. fclose

  • 原型:int fclose(FILE *fp);
  • 参数:
    • fp:已经打开的文件指针。
  • 功能:关闭文件,自动刷新缓冲区,成功返回 0。

4. fgets

  • 原型:char *fgets(char *s, int size, FILE *fp);
  • 参数:
    • s:存放读到字符串的缓冲区;
    • size:缓冲区最大容量;
    • fp:文件指针。
  • 功能:读一行,最多读 size-1 个字符,自动补 \0;读到换行会把 \n 存入缓冲区。

5. fread

  • 原型:size_t fread(void *ptr, size_t size, size_t nmemb, FILE *fp);
  • 参数:
    • ptr:接收数据的缓冲区;
    • size:单个数据块字节大小;
    • nmemb:要读取多少个数据块;
    • fp:文件指针。
  • 功能:从文件读取数据块,返回读到的块个数。
  • 使用技巧:fread(buf, 1, count, fp),此时返回值就是读到的字节数。

6. fwrite

  • 原型:size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *fp);
  • 参数:
    • ptr:待写入数据的缓冲区;
    • size:单个数据块字节大小;
    • nmemb:要写入多少个数据块;
    • fp:文件指针。
  • 功能:向文件写入数据块,返回成功写入的块个数。

7. feof

  • 原型:int feof(FILE *fp);
  • 参数:
    • fp:文件指针。
  • 功能:判断是否读到文件末尾。
  • 注意:feof 标志读完之后才会置位,不能直接用来当循环前置判断条件。

8. 模拟实现 cat

void Func1(int argc, char *argv[])
{
    if (argc != 2)
    {
        printf("使用手册:%s 文件名称\n", argv[0]);
    }
    else
    {
        FILE *f = fopen(argv[1], "r");
        if (f == NULL)
        {
            printf("打开失败\n");
        }
        else
        {
            char buf[1024];
            // -1 是为了给 \0 留的
            int size = fread(buf, 1, sizeof(buf) - 1, f);
            if (size == 0)
            {
                printf("无内容\n");
            }
            else
            {
                buf[size] = '\0';
                printf("%s\n", buf);
            }
            fclose(f);
        }
    }
}

1.3 位置调整函数

1. fseek

  • 原型:int fseek(FILE *fp, long offset, int whence);
  • 参数:
    • fp:文件指针;
    • offset:偏移量,可正可负;
    • whence:参考基准位置。

whence 参数:

  • SEEK_SET:文件起始位置;

  • SEEK_CUR:当前指针位置;

  • SEEK_END:文件末尾。

  • 功能:设置文件指针位置。

2. ftell

  • 原型:long ftell(FILE *fp);
  • 参数:
    • fp:文件指针。
  • 功能:获取当前文件指针距离文件开头的偏移量。

3. rewind

  • 原型:void rewind(FILE *fp);
  • 参数:
    • fp:文件指针。
  • 功能:文件指针回到开头,等价于 fseek(fp, 0, SEEK_SET)。

4. 举例子

我举个例子:假设文件 log.txt 里的内容只有 5 个字母:ABCDE。

  1. fseek —— 移动游标到指定位置

它的本质就是:以某处为基准,向前或向后移动几格。

  • 场景 A:想跳过前面的 ‘A’,直接读 ‘B’
fseek(fp, 1, SEEK_SET); // 基准设定在开头,向后移动 1 个字节
char ch = fgetc(fp);    // 读出来的就是 'B'
  • 场景 B:想直接读倒数第一个字母 ‘E’
fseek(fp, -1, SEEK_END); // 基准设定在末尾(SEEK_END),往前倒退 1 个字节
char ch = fgetc(fp);     // 读出来的就是 'E'
  1. ftell —— 测量游标当前在第几格

它的本质就是:一把尺子,告诉你游标现在距离文件开头有多远(当前位置的下标)。

fseek(fp, 3, SEEK_SET); // 先把游标移到开头往后第 3 个字节(此时游标指向 'D')
long pos = ftell(fp);   // 问系统:"游标现在在哪呢?"
printf("%ld\n", pos);   // 打印出来的结果就是 3
  • 面试常考:怎么知道一个文件有多大?

先用 fseek(fp, 0, SEEK_END); 把游标直接拉到最末尾,然后立刻用 ftell(fp); 量一下它距离开头的格数。返回的数字,就是这个文件的总字节数!

  1. rewind —— 游标一键瞬间回起点

它的本质就是:不管游标现在在哪了,一秒钟拉回文件最开头。

fseek(fp, 4, SEEK_SET); // 游标移到了 'E'
char ch1 = fgetc(fp);   // 读出 'E',此时游标已经走到文件最后了,后面没东西了

rewind(fp);             // 瞬间把游标重置回文件开头!
char ch2 = fgetc(fp);   // 又可以从头开始读了,读出来的又是 'A'

1.4 进程默认打开的三大标准流

在 Linux 环境下,当我们的 C/C++ 源代码被编译为可执行程序并运行起来时,编译器与操作系统的运行时库会在文件的开头(即程序刚启动时)自动帮我们打开三个文件流。因此,我们不需要显式打开就可以直接访问以下三个指针:

  • FILE *stdin:标准输入,默认对应键盘文件;
  • FILE *stdout:标准输出,默认对应显示器文件;
  • FILE *stderr:标准错误,默认对应显示器文件。

操作系统和编译器之所以默认打开它们,目的是:为了程序提供一种默认的数据源和处理结果显示处(程序本质就是做数据处理的)。

1.5 重定向的本质与原理

结合上述 C 语言的底层逻辑,我们在 Linux 命令行中频繁使用的 >(输出重定向)和 >>(追加重定向),其底层的科学本质完全可以用两句话解释:

  • 输出重定向(>)可以清空文件的本质:是因为其底层在执行时,本质是使用了 "w" 的方式打开了文件。只要命令一执行,系统就会先一步把目标文件截断清空;
  • 追加重定向(>>)的本质:也就是其在底层完全等价于使用了 "a" 方式打开文件。因而其写入的数据永远只会老老实实地追加在文件的最后方。

1.6 C 库与系统调用接口的层级关系

在日常编写 C/C++ 程序时,我们频繁使用 fopen、fclose、fwrite、fread 以及 cin、cout 等库函数或流对象。然而,文件的读写本质上并不是直接通过语言层面的库函数来控制硬件的。这些库函数只是为了给程序员提供开发上的便利与跨平台的可移植性而封装的一层"外衣"。

  • 跨平台的可移植性与接口语言封装:

不同的平台底层是不同的操作系统,他们的系统调用是各不相同的。如果我在 Linux 下开发的软件使用了 Linux 的系统调用,那么这个程序在 Windows 平台下就运行不了了。所以我们需要语言层的封装,来屏蔽底层系统调用方面的差距(另外在 C/C++ 的库制作的时候,就已经把各个平台的版本都写好了)。

在这里插入图片描述

在操作系统内部,任何针对文件的底层访问,最终都必须且只能通过操作系统提供的系统调用接口来实现。

二、操作系统如何管理打开的文件

2.1 先描述再组织

当你想访问一个文件时,必须先将文件打开。是谁去打开的?是进程去打开的。一个进程可以打开多个文件,文件与进程是多对一的关系。这么多文件操作系统要不要管理起来?要的。

操作系统的管理哲学是:先描述,再组织。

  • 描述:通过结构体(在 Linux 中为 struct file)将一个被打开文件的所有属性、缓冲区状态、操作方法指针等信息进行封装;
  • 组织:将这些代表文件的结构体对象通过双向链表或其他高效的数据结构紧密组织起来。至此,操作系统对被打开文件的管理,就转化为了对特定数据结构链表的增删改查。

打开文件 → 组织文件结构体 → 链接成为链表。

于是:文件和进程的关系 == 结构体与结构体(struct file 和 struct task_struct)之间的关系。

2.2 文件的两大分类

由此,我们可以清晰地将文件划分为两大阵营:

  • 内存级文件:已经被进程打开,其描述结构体已被加载到内存中,正处于被操作系统管理状态的文件;
  • 磁盘级文件:尚未被打开,安静地存放于物理存储介质之上的文件。

我们在《Linux 文件篇》的前几篇讲的都是内存级文件,当进入文件系统时讲解的是磁盘级文件。

三、文件操作的系统调用

3.1 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);

3.2 标志位 flags 的设计

open 函数的 flags 参数,它并不是简单的整数,而是一个位图。在 Linux 内部,各种操作都被定义为了宏(通常是只有一个二进制位为 1 的 16 进制数或 8 进制数):

// 这个位图的任何一个位上的 0 变成 1 就能代表一种意思
000000000000000000000001
000000000000000000000010
000000000000000000000100
000000000000000000001000
000000000000000000010000
000000000000000000100000
  • O_RDONLY:只读打开;
  • O_WRONLY:只写打开;
  • O_RDWR:读写打开;
  • O_CREAT:若文件不存在则创建;
  • O_TRUNC:打开文件的同时清空文件内容(Truncate);
  • O_APPEND:追加写模式。

在传入参数时,可以通过**按位或(|)**将多个标志位组合在一起。怎么证明?

在这里插入图片描述

在这里插入图片描述

// 组合标志位:只写打开、不存在则创建、打开并清空
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);

我写一个例子你就明白了:

#define ONE_FLAG   (1<<0) // 0000 0000 0000...0000 0001
#define TWO_FLAG   (1<<1) // 0000 0000 0000...0000 0010
#define THREE_FLAG (1<<2) // 0000 0000 0000...0000 0100
#define FOUR_FLAG  (1<<3) // 0000 0000 0000...0000 1000

void Print(int flags)
{
    // 按位与结果非 0 为真
    if (flags & ONE_FLAG)
    {
        printf("1!\n");
    }
    if (flags & TWO_FLAG)
    {
        printf("2\n");
    }
    if (flags & THREE_FLAG)
    {
        printf("3\n");
    }
    if (flags & FOUR_FLAG)
    {
        printf("4\n");
    }
}

int main()
{
    Print(ONE_FLAG);
    printf("\n");
    Print(ONE_FLAG | TWO_FLAG);
    printf("\n");
    Print(ONE_FLAG | TWO_FLAG | THREE_FLAG);
    printf("\n");
    Print(ONE_FLAG | TWO_FLAG | THREE_FLAG | FOUR_FLAG);
    printf("\n");
    Print(ONE_FLAG | FOUR_FLAG);
    printf("\n");
    return 0;
}

在这里插入图片描述

因为按位与结果非 0 为真,所以只要位图中包含那一位就会输出对应的值。

0000 0000 0000...0000 0010
0000 0000 0000...0000 0100
或运算后
0000 0000 0000...0000 0110

0000 0000 0000...0000 0100 与 TWO_FLAG(0010) 按位与 -> 全 0,不成立
0000 0000 0000...0000 0110 与 TWO_FLAG(0010) 按位与 -> 0010,非 0,输出 2
0000 0000 0000...0000 0110 与 THREE_FLAG(0100) 按位与 -> 0100,非 0,输出 3

3.3 权限位 mode

当使用了 O_CREAT 标志位去创建新文件时,必须显式调用带有第三个参数 mode 的 open 重载函数。这个参数用于指定新创建文件的起始权限(如 0666 或 0644)。

为什么 O_CREAT 一个文件要有权限位?没有权限位会怎么样?

在这里插入图片描述

open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);

在这里插入图片描述

我们也可以通过修改权限掩码,它会依据就近原则,去取我们程序里的掩码:

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

// 返回修改后的值
mode_t umask(mode_t mask);

在这里插入图片描述

3.4 模拟语言层行为:清空写与追加写

通过不同的系统调用标志位组合,我们可以完美复刻 C 语言中 fopen 的模式行为。

1. 清空写模式(模拟 fopen("log.txt", "w"))

在 C 语言中,以 "w" 方式打开文件,如果文件存在,其内容会被彻底清空,如果不存在则创建。在内核系统调用层面,对应的代码实现为:

int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);

2. 追加写模式(模拟 fopen("log.txt", "a"))

以 "a" 方式打开文件时,不会清空原内容,而是在文件的末尾进行数据的追加写入。对应的底层系统调用代码实现为:

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

四、系统调用与语言层 I/O 的本质

Linux 系统调用(如 write/read)处于系统层,标准 C 库(如 fprintf/fwrite)处于语言层。

4.1 系统层:字节流

内核的系统调用是"盲目"的,根本不关心数据类型。

ssize_t write(int fd, const void *buf, size_t count);
  • 统一视作二进制:入参为 void *,系统只管按字节数(count)把二进制写入到文件;
  • 无类型概念:传字符数组就写入文本,传 int 指针就直接写内存二进制码。底层读写的本质全部都是二进制字节流。

4.2 语言层:格式与类型的封装

日常提及的"文本"或"二进制"写入,只是语言层提供给开发者的上层概念,最终都会转为系统层的字节流。

  • 文本/格式化写入(fprintf、fputs):标准库将数据转换为 ASCII/UTF-8 字符序列。例如将 123 转为 '1','2','3' 三个字符的对应编码再调用 write;
  • 二进制写入(fwrite):标准库不做转换,直接将内存中原生的 4 字节 int 数据通过 write 投递给内核。

由于 read 读出的全是原生二进制流,数据如何还原完全由应用层逻辑决定:

char buffer[64];
int n = read(fd, buffer, sizeof(buffer) - 1);
if (n > 0) {
    buffer[n] = 0;        // 强行追加 '\0' 当作字符串处理
    printf("%s", buffer); // 格式化为文本输出
}

加 \0 就是对数据的还原。

系统调用只负责搬运字节流。数据究竟是文本还是二进制,由用户层代码如何去解析和看待这串字节来决定。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐