
◆ 博主名称: 小此方-CSDN博客 大家好,欢迎来到小此方的博客。
⭐️Linux系列个人专栏: 【主题曲】Linux
⭐️此方的GitHub: github_此方
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)
文章目录
- 概要&序論
- [一、 理解Linux 系统视角的"文件"](#一、 理解Linux 系统视角的“文件”)
-
- [1.1 系统角度下的文件](#1.1 系统角度下的文件)
- [1.2 C语言的文件操作复习与机制探底](#1.2 C语言的文件操作复习与机制探底)
-
- [1.2.1 文件的打开与关闭:流的本质与三大模式](#1.2.1 文件的打开与关闭:流的本质与三大模式)
- [1.2.2 核心补充:三大读写位置调整函数](#1.2.2 核心补充:三大读写位置调整函数)
- [1.2.3 进程默认打开的三大标准流](#1.2.3 进程默认打开的三大标准流)
- [1.2.4 重定向的本质与原理](#1.2.4 重定向的本质与原理)
- [1.3 C语言/C++标准库与系统调用接口的层级关系](#1.3 C语言/C++标准库与系统调用接口的层级关系)
- 1.4操作系统如何管理打开的文件
- [三、 文件操作的系统调用使用](#三、 文件操作的系统调用使用)
-
- [3.1 open 接口与标志位](#3.1 open 接口与标志位)
-
- [3.1.1 标志位 flags 的设计](#3.1.1 标志位 flags 的设计)
- [3.1.2 权限位 mode](#3.1.2 权限位 mode)
- [3.2 模拟语言层行为:清空写与追加写](#3.2 模拟语言层行为:清空写与追加写)
-
- [3.2.1 清空写模式(模拟 fopen("log.txt", "w"))](#3.2.1 清空写模式(模拟 fopen("log.txt", "w")))
- [3.2.2 追加写模式(模拟 fopen("log.txt", "a"))](#3.2.2 追加写模式(模拟 fopen("log.txt", "a")))
- [四、系统调用与语言层 I/O 的本质](#四、系统调用与语言层 I/O 的本质)
-
- [4.1 系统层:纯粹的字节流](#4.1 系统层:纯粹的字节流)
- 4.2语言层:格式与类型的封装
概要&序論
Hello大家好,我是此方 ,本文剖析了 Linux 系统视角下的文件管理与 I/O 本质,打通 C/C++ 语言层到操作系统内核的关系。
- Linux 文件本质:阐述系统级文件的定义,探究文件流与重定向的底层机制;
- 标准库与系统调用 :解构 C 语言文件函数与系统调用接口(如
open)的层级封装与标志位设计;- 内核文件管理:揭示操作系统"先描述、再组织"的内核管理策略与文件分类;
- I/O 本质对比 :对比系统层纯粹的字节流 与语言层格式化封装的差异,并模拟语言层读写行为。
好的,我们开始吧。
一、 理解Linux 系统视角的"文件"
1.1 系统角度下的文件
先问你一个问题:文件大小是0KB有没有占据磁盘空间?答案是肯定的。
在深入探讨 Linux 基础 I/O 之前,必须明确一个最核心的概念:文件 = 内容 + 属性 。(无论是一个空文件还是填满数据的大文件,它们在磁盘必然占据存储文件属性的空间。)因此,所有针对文件的操作,无外乎是对内容的操作或对属性的操作,甚或两者皆有。
从操作系统的底层视角来看,对文件的存取操作本质是进程对文件的操作 。普通的物理磁盘本身作为硬件,其管理者是操作系统内核。
1.2 C语言的文件操作复习与机制探底
1.2.1 文件的打开与关闭:流的本质与三大模式
在 C 语言中,我们对文件的核心操作都是围绕"文件流"展开的。通过 fopen 函数,传入不同的 打开模式 可以改变程序对文件的读写行为。其中最核心、最常用的三种基础模式如下:
"r"(只读模式) :打开一个已存在的文件进行读取。若文件不存在,则直接报错返回NULL。数据流的初始读写位置被初始化在文件的开头。"w"(只写清空模式) :打开文件进行写入。如果目标文件不存在,系统会自动创建它;如果目标文件已经存在 ,标准库会自动调用底层系统接口,将文件长度截断为零,即文件会被彻底清空,然后从头开始写入。"a"(追加写入模式) :打开文件进行末尾追加。如果文件不存在则创建新文件;若存在,数据流的初始写位置会被强行定位在文件的 末尾,新数据永远接在旧数据后面。
对于其余的混合读写模式(如 "r+"、"w+"、"a+"),它们允许同时进行读与写,但在实际开发中应用时,由于读写共用同一个位置指示器,往往需要配合位置调整函数使用。
写一个简单的模拟cat,回顾一下接口的使用:
cpp
// cat myfile.txt
int main(int argc, char *argv[]){
if(argc != 2){
printf("Usage: %s filename\n", argv[0]);
return 1;
}
FILE *fp = fopen(argv[1], "rb");
if(NULL == fp){
perror("fopen");
return 2;
}
while(1){
char buffer[128];
int n = fread(buffer, sizeof(buffer) - 1, 1, fp);
if(n > 0){
buffer[n] = 0;
printf("%s", buffer);
}
if(feof(fp))//到达文件末尾是否
break;
}
fclose(fp);
return 0;
}
1.2.2 核心补充:三大读写位置调整函数
从 "w" 模式与 "a" 模式的对比中我们可以发现,数据往哪里写、从哪里读,有什么决定?由文件流内部的位置指针 决定。在处理混合读写(特别是 w+ 和 r+ 读了之后再写、写了之后再读)的时候,必须了解以下三大读写位置调整函数:
c
#include <stdio.h>
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
void rewind(FILE *stream);
fseek:根据参考基准whence(文件开头SEEK_SET、当前位置SEEK_CUR、文件末尾SEEK_END),将读写指针移动offset字节的偏移量。ftell:返回当前读写指针相对于文件开头的当前字节偏移量。rewind:这是一个高内聚的便利接口,作用是直接将读写位置一键重置到文件的开头。
看不懂?我举个例子:假设文件 log.txt 里的内容只有 5 个字母:ABCDE。
1. fseek ------ 移动游标到指定位置
它的本质就是:以某处为基准,向前或向后移动几格。
- 场景 A:想跳过前面的 'A',直接读 'B'
c
fseek(fp, 1, SEEK_SET); // 基准设定在开头(SEEK_SET),向后移动 1 个字节
char ch = fgetc(fp); // 读出来的就是 'B'
- 场景 B:想直接读倒数第一个字母 'E'
c
fseek(fp, -1, SEEK_END); // 基准设定在末尾(SEEK_END),往前倒退 1 个字节
char ch = fgetc(fp); // 读出来的就是 'E'
2. ftell ------ 测量游标当前在第几格
它的本质就是:一把尺子,告诉你游标现在距离文件开头有多远(当前位置的下标)。
c
fseek(fp, 3, SEEK_SET); // 先把游标移到开头往后第 3 个字节(此时游标指向 'D')
long pos = ftell(fp); // 问系统:"游标现在在哪呢?"
printf("%ld\n", pos); // 打印出来的结果就是 3
面试常考 :怎么知道一个文件有多大?
先用
fseek(fp, 0, SEEK_END);把游标直接拉到最末尾,然后立刻用ftell(fp);量一下它距离开头的格数。返回的数字,就是这个文件的总字节数!
3. rewind ------ 游标一键瞬间回起点
它的本质就是:不管游标现在瞎溜道去哪了,一秒钟无条件拉回文件最开头。
c
fseek(fp, 4, SEEK_SET); // 游标移到了 'E'
char ch1 = fgetc(fp); // 读出 'E',此时游标已经走到文件最后了,后面没东西了
rewind(fp); // 瞬间把游标重置回文件开头!
char ch2 = fgetc(fp); // 又可以从头开始读了,读出来的又是 'A'
1.2.3 进程默认打开的三大标准流
在 Linux 环境下,当我们的 C/C++ 源代码被编译为可执行程序并运行起来时,编译器与操作系统的运行时库会在文件的开头(即程序刚启动时)自动帮我们打开三个文件流。因此,我们不需要显式打开就可以直接访问以下三个指针:
FILE*stdin:标准输入,默认对应键盘文件。FILE*stdout:标准输出,默认对应显示器文件。FILE*stderr:标准错误,默认对应显示器文件。
操作系统和编译器之所以默认打开它们,一句话的核心目的是:为了程序提供一种默认的数据源和处理结果显示处(程序本质就是做数据处理的)。
1.2.4 重定向的本质与原理
结合上述 C 语言的底层逻辑,我们在 Linux 命令行中频繁使用的 >(输出重定向)和 >>(追加重定向),其底层的科学本质完全可以用两句话 解释:
- 输出重定向(
>)可以清空文件的本质:是因为其底层在执行时,本质是使用了w的方式打开了文件。只要命令一执行,系统就会先一步把目标文件截断清空。 - 追加重定向(
>>)的本质:也就是其在底层完全等价于使用了a方式打开文件。因而其写入的数据永远只会老老实实地追加在文件的最后方。
1.3 C语言/C++标准库与系统调用接口的层级关系
在日常编写 C/C++ 程序时,我们频繁使用 fopen、fclose、fwrite、fread 以及 cin、cout 等库函数或流对象。然而,文件的读写本质上并不是直接通过语言层面的库函数来控制硬件的。 这些库函数只是为了给程序员提供开发上的便利与跨平台的可移植性而封装的一层"外衣"。
聊一聊跨平台的可移植性与接口语言封装 :不同的平台底层是不同的操作系统,他们的系统调用是各不相同的。如果我在Linux下开发的软件使用了Linux的系统调用,那么这个程序在Windows平台下是运行不了了。所以我们需要语言层的封装,来屏蔽底层系统调用方面的差距。(另外在C/c++的库制作的时候,就已经把各个平台的版本都写好了。)

在操作系统内部,任何针对文件的底层访问,最终都必须且只能通过操作系统提供的系统调用接口来实现。
1.4操作系统如何管理打开的文件
1.4.1先描述再组织
当你想访问一个文件时,必须先将文件打开。是谁去打开的?是进程去打开的。一个进程可以打开多个文件,文件:进程是多对一的关系。 这么多文件操作系统要不要管理起来?要的。
操作系统的管理哲学是:先描述,再组织。
- 描述 :通过结构体(在 Linux 中为
struct file)将一个被打开文件的所有属性、缓冲区状态、操作方法指针等信息进行封装。 - 组织 :将这些代表文件的结构体对象通过双向链表或其他高效的数据结构紧密组织起来。至此,操作系统对被打开文件的管理,就转化为了对特定数据结构链表的增删改查。
整个链路:打开文件------>组织文件结构体------>链接成为链表
于是 :文件和进程的关系==结构体与结构体(struct file和struct task_struct)之间的关系。
1.4.2文件的两大分类
由此,我们可以清晰地将文件划分为两大阵营:
- 内存级文件:已经被进程打开,其描述结构体已被加载到内存中,正处于被操作系统管理状态的文件。
- 磁盘级文件:尚未被打开,安静地存放于物理存储介质之上的文件。
我们在《Linux文件篇》的前几篇讲的都是内存级文件,当进入文件系统时讲解的时磁盘级文件。
三、 文件操作的系统调用使用
3.1 open 接口与标志位
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);
3.1.1 标志位 flags 的设计
open函数的 flags 参数,它并不是简单的整数,而是一个位图。在 Linux 内部,各种操作都被定义为了宏(通常是只有一个二进制位为 1 的 16 进制数或 8 进制数):
cpp
//这个位图的任何一个位上的0变成1就能代表一种意思
000000000000000000000001
000000000000000000000010
000000000000000000000100
000000000000000000001000
000000000000000000010000
000000000000000000100000
- O_RDONLY: 只读打开
- O_WRONLY: 只写打开
- O_RDWR: 读写打开
- O_CREAT: 若文件不存在则创建
- O_TRUNC : 打开文件的同时清空文件内容(Truncate)
- O_APPEND: 追加写模式
在传入参数时,可以通过按位或(|)将多个标志位组合在一起。例如:
我说这些东西是宏,怎么证明?


cpp
// 组合标志位:只写打开、不存在则创建、打开并清空
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
没听懂?我写一个例子你就明白了:
cpp
#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)
{
if(flags & ONE_FLAG)
{
printf("One!\n");
}
if(flags & TWO_FLAG)
{
printf("Two\n");
}
if(flags & THREE_FLAG)
{
printf("Three\n");
}
if(flags & FOUR_FLAG)
{
printf("Four\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;
}
/* 整个程序的控制台输出结果如下:
One!
One!
Two
One!
Two
Three
One!
Two
Three
Four
One!
Four
*/
3.1.2 权限位 mode
当使用了 O_CREAT 标志位去创建新文件时,必须显式调用带有第三个参数 mode 的 open 重载函数。这个参数用于指定新创建文件的起始权限(如 0666 或 0644)。
权限掩码这块儿传送门:这篇文章的章节一,解析缺省权限与 umask 掩码的小点:1.3.2
为什么O_CREAT一个文件要有权限位?没有权限位会怎么样?


3.2 模拟语言层行为:清空写与追加写
通过不同的系统调用标志位组合,我们可以完美复刻 C 语言中 fopen 的模式行为。
3.2.1 清空写模式(模拟 fopen("log.txt", "w"))
在C 语言中,以 "w" 方式打开文件,如果文件存在,其内容会被彻底清空,如果不存在则创建。在内核系统调用层面,对应的代码实现为: 对!这样打开文件你不去写入也会清空。
c
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
3.2.2 追加写模式(模拟 fopen("log.txt", "a"))
以 "a" 方式打开文件时,不会清空原内容,而是在文件的末尾进行数据的追加写入。对应的底层系统调用代码实现为:
c
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
四、系统调用与语言层 I/O 的本质
Linux 系统调用(如 write/read)处于系统层 ,标准 C 库(如 fprintf/fwrite)处于语言层。
4.1 系统层:纯粹的字节流
内核的系统调用是"盲目"的,根本不关心数据类型。
c
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 读出的全是原生二进制流,数据如何还原完全由应用层逻辑决定:
c
char buffer[64];
int n = read(fd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = 0; // 强行追加 '\0' 当作字符串处理
printf("%s", buffer); // 格式化为文本输出
}
系统调用只负责搬运字节流。数据究竟是文本还是二进制,由用户层代码如何去解析和看待这串字节来决定。
好的本期内容就到这里,如果对你有帮助,还不要忘记点赞三联支持。我是此方,我们下期再见。bye!