一.理解文件
- 广义上 :在Linux系统中,一切皆文件 。键盘、显示器、网卡、甚至进程信息(在
/proc目录下)都被抽象成了文件。所有外设的核心操作无外乎是读(read)和写(write)。- 狭义上 :我们通常说的文件,存放在磁盘(永久存储介质)上。磁盘也是一种外设,对文件的操作就是对它进行输入输出(I/O)。
⽂件在磁盘⾥
• 磁盘是永久性存储介质,因此⽂件在磁盘上的存储是永久性的
• 磁盘是外设(即是输出设备也是输⼊设备)
• 磁盘上的⽂件 本质是对⽂件的所有操作,都是对外设的输⼊和输出 简称 IO
1-1文件的组成:内容和属性
一个文件由两部分组成:
文件内容:就是文件里存的数据,比如"hello world"。
文件属性(元数据) :描述文件的数据,比如文件名、大小、创建时间、权限等。
所以,对文件的所有操作,要么是在修改它的内容,要么是在修改它的属性。
- 如果想往文件里写入 "hello world",这叫作对文件内容进行操作; fread、fwrite、fgets、fputs、fgetc、fputc 都是对文件内容进行操作。
- 如果想更改文件权限、拥有者所属组、文件名等,叫作对文件属性进行操作;fseek、ftell、rewind 都是对文件属性进行操作。
注意:所有的⽂件操作本质是⽂件内容操作和⽂件属性操作
1-2⽂件操作的归类认知
对于 0KB 的空⽂件是占⽤磁盘空间的,这是为什么呢?我峨嵋你一般认为0应该就不代表有内存的占用啊?
尽管它是一个显示是 0KB空的文本文件,但它依旧会占用磁盘空间,因为一个新文件的创建,它仍然还有很多数据信息都需要维护,其中包括文件名、类型、修改日期、权限等。

1-3系统怎么看待访问文件
- 我们写的 fread、fwrite 的代码去访问文件的 C 程序 , 经过编译形成 .exe(可执行程序) 双击或 ./ 运行程序,把程序加载到内存中。总结:对文件的访问本质上就是进程对文件的访问。
进程视角 :文件访问的本质是进程在访问文件。
分层路径:用户 → 库函数 → 系统调用 → 操作系统 → 驱动程序 → 硬件。
职责划分 :库函数(如
fread)负责提供便利接口,而真正干活的是操作系统通过系统调用完成的。1. 为什么要加"库函数"这一层?
跨平台性 :
fopen在 Windows 和 Linux 下都能用,但底层分别调用的是 Windows 和 Linux 不同的系统调用。C 标准库帮你屏蔽了平台差异。便利性与性能 :库函数提供了更友好的接口(如格式化输出
printf、行缓冲等),减少了直接调用系统调用的次数(系统调用有上下文切换开销)。2. 从"程序"到"进程"的关键一步
".exe 加载到内存中成为进程"。可以明确一点:可执行程序本身只是磁盘上的静态文件,而进程是操作系统运行这个程序代码的动态实体 。文件 I/O 的所有操作(打开、读写、关闭),都是由这个进程代表用户程序去执行系统调用来完成的。
总结:导致了不同语言有不同的语言级别的文件访问接口,但封装的都是系统接口,所以本质上并不是 C 语言帮我们把数据写到磁盘文件中,真正干活的是操作系统所提供的与文件相关的系统调用接口。
图解:

问题一:为什么我们要去学习操作系统提供的文件接口,而不是只学语言的文件函数?
因为操作系统的文件接口是唯一的底层标准。
无论你使用 C、C++、Python 还是 Java,任何编程语言最终都要调用操作系统提供的文件系统调用(如 Linux 下的
open、read、write)。操作系统只有这一套底层接口,所有上层封装都必须建立在这个基础之上。学习它,你才能理解各种语言文件操作的共同本质。
问题二:假设某门语言没有提供任何文件操作的封装函数,那么程序员要访问文件是不是只能直接使用操作系统的接口?
是的,只能直接使用操作系统接口。
语言的库函数(如
fopen、fread)本质上是对系统调用的封装。如果一门语言没有做这层封装,程序员就必须自己直接调用操作系统的open、read、write等系统调用来完成文件读写。没有中间层,就只能用底层接口。
问题三:既然直接使用操作系统接口会导致代码不能跨平台,那使用某种语言的用户到底需不需要访问文件?如果用了系统接口会有什么后果?
用户当然需要访问文件,但直接使用系统接口会导致代码失去跨平台能力。
不同操作系统的系统调用接口是不同的(比如 Linux 的
open和 Windows 的CreateFile)。如果直接在代码中使用某个操作系统的接口,那么这份代码就无法在其他操作系统上编译运行。语言的库函数通过在不同平台底层调用不同的系统调用,向上提供统一的接口,从而实现了跨平台。
问题四:显示器是不是硬件?printf 向屏幕打印也是一种写入操作,为什么平时用起来不觉得它和写磁盘文件有什么奇怪的区别?
显示器确实是硬件,
printf向屏幕打印本质上就是一次写入操作。你觉得不奇怪,主要原因是:向显示器写入是立即可见的 ,你敲下代码运行,屏幕上立刻出现结果,有实时反馈;而向磁盘文件写入,数据不会立即显现,你需要用
cat或打开文件才能看到,具有滞后性。但从操作系统的视角来看,两者没有本质区别------它们都是通过
write系统调用向某个文件描述符写入数据。printf默认写入的是stdout(标准输出文件),只不过这个文件通常被指向了显示器硬件而已。如果你用重定向>把printf的输出指向磁盘文件,它就会变成"滞后"的了。
1-4如何理解Linux以下皆文件的说法?
备注:"一切皆文件"是广义的设计思想;而我们平时说的"文件"往往是狭义的磁盘文件。
- 默认情况下,标准输入是键盘文件,标准输出是显示器文件,标准错误是显示器文件。
- 详解:
很多人学 Linux 时,都听过一句经典口号:一切皆文件。这句话听起来很酷,但接触时键盘、显示器明明是硬件,怎么能是文件?
printf和cout跟write又是什么关系?
一、起点:硬件最核心的操作是什么?
先说结论:
所有外设硬件,本质对应的核心操作,无外乎就是 read 和 write。
为什么这么说?我们找两个最直观的例子:
键盘文件
键盘的作用是什么?把用户的按键数据输入到计算机。
从计算机的角度看,这就是:
读方法(read):从键盘读取数据到内存
写方法(write):有意义吗?没有。没人需要把数据写到键盘上 所以键盘的 write 方法可以设置为空。
显示器文件
显示器的核心作用是向用户展示信息。从计算机角度看:
写方法(write) :把数据写入显示器(
printf、cout最终都落到这一步)读方法(read):有意义吗?操作系统一般不主动从显示器读数据。所以显示器的 read 方法可以设置为空。
到这里,就可以理解:
每个硬件设备,本质上就是能读、能写、或两者兼具的东西。
键盘 = 可读、不可写
显示器 = 可写、不可读
普通文件 = 可读、可写
难道说这就是文件的定义吗?
二、一个关键的纠错:真的是"读显示器"吗?
那我来举个例子:
看命令行操作:
-
我用键盘输入
ls命令 -
屏幕上显示出
ls -
系统执行命令,返回结果
如果按照上面的逻辑,显示器只有 write 方法,没有 read 方法,那系统怎么知道用户输入了什么命令?
我一开始以为:系统是从显示器上读取命令的。
但这显然是错的。仔细一想就明白了:
-
真正输入命令的硬件是 键盘,不是显示器
-
系统通过 读取键盘数据 获取用户输入
-
同时,为了用户能看到自己输入了什么,系统会把同样一份数据 写入显示器(这叫"回显")
验证一下:输入密码的时候,键盘输入是有的,但屏幕上不显示字符。说明:
-
系统仍然在 读键盘
-
只是 没有写显示器
键盘负责输入(read),显示器负责输出(write)。读和写是两条独立的操作流。
三、统一的抽象:struct file 与函数指针
现在我们已经确认:
-
不同的硬件,有自己的 read / write 行为
-
从操作系统角度看,它们都能被归结为读和写两种操作
下一个问题是:
操作系统如何用统一的方式,管理这些行为完全不同的硬件?
答案就藏在 C 语言的一个经典技巧 里:函数指针。
我们可以设计这样一个结构体:
cpp
c
struct file {
// 属性:文件类型、权限、状态......
// 方法:读写操作
ssize_t (*read)(...);
ssize_t (*write)(...);
};
这个结构体就叫 struct file。
-
每个硬件(键盘、显示器、磁盘......)都对应一个
struct file实例 -
每个硬件的
read/write方法指向 自己的具体实现函数 -
在操作系统看来,它们全是同一个结构体类型 → 差异被描述消除了
这就是所谓的:
先描述,再组织。
-
描述 :
struct file定义了统一的文件视图 -
组织 :用链表或数组把所有的
struct file串起来,由操作系统统一管理
四、多态的落地:同样的调用,不同的行为
当我们这么设计以后,神奇的事情就发生了。
在操作系统内部:
cpp
用户调用 read(fd, ...)
内核通过文件描述符 fd,找到对应的 struct file
执行 file->read(...)
真正的
read,是键盘驱动的读函数?还是磁盘的读函数?完全由file这个结构体在运行时决定。这就是 运行时多态(C 语言用函数指针模拟出来的多态)。
对操作系统而言:
它看到的只有统一的 struct file,和统一的 read / write 方法名。至于这个 read 到底是读键盘、读磁盘、还是读管道,那是驱动程序的事。
所以那句话可以真正的来说是:
一切皆文件,不是所有东西都像普通文件一样存数据,而是所有东西都能通过文件一样的接口被访问。
五、闭环理解
| 硬件 / 对象 | 在 Linux 中的表现 | read / write 的真实行为 |
|---|---|---|
| 键盘 | /dev/input/eventX |
read = 读取按键数据;write = 空 |
| 显示器 | /dev/tty、/dev/fb0 |
read = 一般不支持;write = 写显存 |
| 普通文件 | /home/a.txt |
read / write = 读写磁盘 |
| 管道 | 内存中的缓冲区 | 一端写、一端读 |
| 进程信息 | /proc/1234/ |
read / write = 读写内核数据结构 |
每一个都对应一个
struct file结构体。每一个都通过函数指针调用自己的真实 read / write 方法。
Linux 操作系统站在这些
struct file之上,说:我看到的,全是文件。
二、回顾 C 文件操作及相关文件操作
2-1打开文件
cpp
#include <stdio.h>
FILE *fopen(const char *path, const char *mode);
FILE *fdopen(int fd, const char *mode);
FILE *freopen(const char *path, const char *mode, FILE *stream);
| 函数 | 输入来源 | 核心用途 |
|---|---|---|
fopen |
文件路径 | 最常用,打开磁盘文件 |
fdopen |
文件描述符 | 将Unix文件描述符包装成FILE*流 |
freopen |
文件路径 + 已有流 | 重定向标准流或复用FILE对象 |
打开⽂件的⽅式
cpp
r
Open text file for reading.
The stream is positioned at the beginning of the file.
r+
Open for reading and writing.
The stream is positioned at the beginning of the file.
w
Truncate(缩短) file to zero length or create text file for writing.
The stream is positioned at the beginning of the file.
w+
Open for reading and writing.
The file is created if it does not exist, otherwise it is truncated.
The stream is positioned at the beginning of the file.
a
Open for appending (writing at end of file).
The file is created if it does not exist.
The stream is positioned at the end of the file.
a+
Open for reading and appending (writing at end of file).
The file is created if it does not exist. The initial file position
for reading is at the beginning of the file,
but output is always appended to the end of the file.
2-2写文件
cpp
#include <stdio.h>
int main()
{
FILE *fp = fopen("log.txt", "w");
if(!fp){
printf("fopen error!\n");
}
while(1);
fclose(fp);
return 0;
}
打开的test⽂件在哪个路径下?
• 在程序的当前路径下,那系统怎么知道程序的当前路径在哪⾥呢?
可以使⽤ ls /proc/[进程id] -l 命令查看当前正在运⾏进程的信息:
运行结果:



我们可以看见虽然没有 ./ 指定路径,但它还是在当前路径下新建文件了。因为每个进程都有一个内置的属性 cwd,cwd 可以让进程知道自己当前所处的路径。就解释了在 VS 中不指明路径情况下,它也能新建对应的文件在对应的路径的原因。所以,进程在哪个路径运行,新建的文件就在哪个路径。
那什么叫做当前路径?
当一个进程运行起来时,每个进程都会记录自己当前所处的工作路径(进程运行时所关联的工作目录,是进程的一个内置属性)。
cpp
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
// 以写模式打开文件("w"模式会清空文件)
FILE* fp = fopen("log.txt", "w");
if(fp == NULL)
{
perror("fopen");
return 1;
}
// 方式1: fwrite - 二进制写入
const char* s1 = "hello fwrite\n";
fwrite(s1, strlen(s1), 1, fp);
// 方式2: fprintf - 格式化写入
const char* s2 = "hello fprintf\n";
fprintf(fp, "%s", s2);
// 方式3: fputs - 字符串写入
const char* s3 = "hello fputs\n";
fputs(s3, fp);
//
//fclose(fp); // 这行被注释掉了
return 0;
}
运行结果:

注意这里的关闭文件被注释掉了但结果也能正常运行。
**【补充】:**可以把 > 看成一个命令,向文件写入内容,直接输入命令:>log.txt 的话就相当于先打开文件清空。
写文件所有核心注意点(纯文件写入细节)
1. fopen 写模式 "w" 关键
- 只要用 "w" 打开文件,打开瞬间直接清空文件原有全部内容,之后才执行写入。
- 对应Linux命令: > log.txt ,单独执行就是直接清空文件,和 w 模式逻辑一致。
2. fwrite 写入致命避坑
正确写法: fwrite(s1, strlen(s1), 1, fp);
错误写法: fwrite(s1, strlen(s1)+1, 1, fp);
原因: strlen+1 会把C语言字符串末尾不可见的 \0 写入文件, cat 看不到,用 vim log.txt 打开会出现 ^@ 乱码;文件不需要 \0 结束符。
3. 缓冲区与 fclose 对写文件的影响
写入的数据先放在内存缓冲区,不会立刻写入磁盘。
注释 fclose(fp) :程序正常 return 0 退出,系统自动刷新缓冲区,3行内容完整写入文件。
强制要求:规范写文件必须加 fclose(fp) ,若程序异常崩溃(kill -9、段错误),缓冲区来不及刷新,写入内容会丢失。
4. 三种写文件函数区别
fwrite :二进制写入,纯字节输出,速度最快,适合原始数据写入。
fprintf :格式化写入,支持 %s、%d 等格式,和 printf 用法一致。
fputs :纯字符串写入,直接输出字符串,自带换行识别。
不写绝对路径时,文件默认生成在程序运行的当前目录,不是代码存放目录。
fwrite 函数深度解析
函数原型
cpp#include <stdio.h> size_t fwrite(const void* ptr, size_t size, size_t count, FILE* stream);
参数含义
| 参数 | 含义 | 举例 |
|---|---|---|
ptr |
要写入的数据的起始地址 | &data 或 buffer |
size |
每个基本单元的字节数 | sizeof(int) = 4 |
count |
要写入几个这样的基本单元 | 写入 5 个 int = 5 |
stream |
目标文件流指针 | fp |
实际写入字节数 = size × count
参数组合示例
cpp
int arr[10] = {0};
// 方式1:一个字节一个字节写
fwrite(arr, 1, 40, fp); // size=1, count=40 → 40字节
// 方式2:4个字节一组,写10组
fwrite(arr, 4, 10, fp); // size=4, count=10 → 40字节
// 方式3:40个字节一组,写1组
fwrite(arr, 40, 1, fp); // size=40, count=1 → 40字节
三种方式最终都写入 40 字节,但推荐使用 方式3(size 大,count 小)。
为什么推荐 size 大、count 小?
原因 :返回值代表成功写入的元素个数 (即成功写入了几组 size)
cpp
// 推荐写法
fwrite(data, 100, 1, fp); // 写1个单元,每个100字节
// 返回值:成功返回1,失败返回0
// 不推荐写法
fwrite(data, 1, 100, fp); // 写100个单元,每个1字节
// 返回值:可能返回50(只写了50个字节),需要处理部分写入
类比你的例子:
小明:爸,我想要 10 块钱(count=10,每个单元 size=1元)
爸:我只有 5 块钱,给你 3 块钱吧(实际写入 3 个单元)
如果换个说法:
小明:爸,我想要 1 张 10 块钱(count=1,每个单元 size=10元)
爸:给你(要么成功给10块,要么不给)
结论 :size 大、count 小 → 要么全成功,要么全失败,逻辑简单。
size 小、count 大 → 可能部分成功,需要处理复杂情况。
2-3读文件
cpp
#include <stdio.h>
#include <string.h>
int main()
{
FILE *fp = fopen("log.txt", "r");
if(!fp){
printf("fopen error!\n");
return 1;
}
char buf[1024];
const char *msg = "hello bit!\n";
while(1){
//注意返回值和参数,此处有坑,仔细查看man⼿册关于该函数的说明
ssize_t s = fread(buf, 1, strlen(msg), fp);
if(s > 0){
buf[s] = 0;
printf("%s", buf);
}
if(feof(fp)){
break;
}
}
fclose(fp);
return 0;
}
fread 的参数和返回值细节
cpp
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
参数含义
| 参数 | 本代码中的值 | 含义 |
|---|---|---|
ptr |
buf |
存放读取数据的缓冲区 |
size |
1 |
每次读 1 字节 |
nmemb |
strlen(msg) = 11 |
最多读 11 个元素 |
stream |
fp |
从 log.txt 读 |
返回值
成功时 :返回实际读取的完整元素个数(不是字节数)
因为
size = 1,所以返回值 = 实际读取的字节数到达文件末尾时,可能返回一个小于
nmemb的数发生错误或文件末尾时返回
0
本代码的坑点
cpp
ssize_t s = fread(buf, 1, strlen(msg), fp);
strlen(msg)写在循环里:每次循环都重新计算长度(效率低,但问题不大)每次循环读固定长度:如果文件内容不是 11 的整数倍,最后一次读取会不足
没有检查
ferror:只检查了feof,如果发生读取错误会死循环根本问题:文件里没有数据,读了个寂寞
三、feof 的正确用法
feof(fp) 只有在尝试读取并失败后 才会返回真,不能用来预测是否到达末尾。
cpp
//错误写法
while(!feof(fp)) { // 先判断,再读取 ------ 会多读一次
fread(...);
}
正确写法
cpp
while(1) {
size_t s = fread(buf, 1, sizeof(buf), fp);
if(s <= 0) {
break; // 读不到数据了就退出
}
// 处理数据
}
注意:
|------------------------|--------------------------------------|
| 文件为空 | 没有提前写入内容,读不到任何数据 |
| strlen(msg) 在循环中 | 每次循环都计算,效率低 |
| 固定大小读取 | 如果文件大小不是 strlen(msg) 的倍数,最后一次读取会不足 |
| feof 检查位置 | 读取失败后才应该用 feof 判断是否到末尾 |
| 没有检查 ferror | 如果发生真正的读取错误,会死循环 |
稍作修改,实现简单 cat 命令:
cpp
#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[])
{
if (argc != 2)
{
printf("argv error!\n");
return 1;
}
FILE *fp = fopen(argv[1], "r");
if(!fp){
printf("fopen error!\n");
return 2;
}
char buf[1024];
while(1){
int s = fread(buf, 1, sizeof(buf), fp);
if(s > 0){
buf[s] = 0;
printf("%s", buf);
}
if(feof(fp)){
break;
}
}
fclose(fp);
return 0;
}
运行结果:

2-4追加文件
cpp
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main()
{
// "a" 模式:追加写入,文件不存在则创建,存在则在末尾追加
FILE* fp = fopen("log.txt", "a");
if(fp == NULL)
{
// 两种错误处理方式任选其一
// 方式1:perror - 自动添加描述信息
perror("fopen");
// 方式2:strerror - 更灵活的错误信息
// printf("fopen error: %s\n", strerror(errno));
return 1;
}
// 进行文件操作(追加写入)
const char* s1 = "hello fwrite\n";
// 注意:strlen(s1) 不包含 '\0',文件不需要存储字符串结束符
fwrite(s1, strlen(s1), 1, fp);
// 可以继续追加更多内容
const char* s2 = "hello fprintf\n";
fprintf(fp, "%s", s2);
const char* s3 = "hello fputs\n";
fputs(s3, fp);
// 关闭文件
fclose(fp);
return 0;
}

2-5输出信息到显⽰器,你有哪些⽅法
函数原型
cpp
#include <stdio.h>
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);
参数说明
| 函数 | 参数1 | 参数2 | 参数3 | 参数4 |
|---|---|---|---|---|
printf |
format(格式字符串) |
...(可变参数) |
- | - |
fprintf |
stream(输出流) |
format(格式字符串) |
...(可变参数) |
- |
sprintf |
str(目标字符串) |
format(格式字符串) |
...(可变参数) |
- |
snprintf |
str(目标字符串) |
size(缓冲区大小) |
format(格式字符串) |
...(可变参数) |
返回值含义
| 函数 | 返回值 |
|---|---|
printf |
成功输出的字符数(不含 \0),失败返回负值 |
fprintf |
成功输出的字符数,失败返回负值 |
sprintf |
成功写入的字符数(不含 \0),失败返回负值 |
snprintf |
成功写入的字符数(不含 \0),若返回值 ≥ size 表示输出被截断 |
cpp
#include <stdio.h>
#include <string.h>
int main()
{
const char *msg = "hello fwrite\n";
fwrite(msg, strlen(msg), 1, stdout);
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
return 0;
}
2-6 stdin & stdout & stderr(重要)
• 不是任何 C 程序运行会默认打开,而是进程在启动时会默认打开三个 "文件,分别是stdin, stdout, stderr
• 仔细观察发现,这三个流的类型都是FILE*, fopen返回值类型,⽂件指针

cpp
#include <stdio.h>
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
- C 接口除了对普通文件进行读写之外(需要手动打开),还可以对 stdin、stdout、stderr 进行读写(不需要手动打开)。C进程一运行,
stdin、stdout、stderr就默认打开了,不需要手动fopen,可以直接使用fread/fwrite等接口进行读写。
为什么 C 进程一运行就会默认打开 stdin、stdout、stderr?
核心原因
为了方便程序员直接使用输入输出功能,而不用每次都要手动打开设备。
详细解释
cpp
// 如果不默认打开,代码就得这样写:
FILE* kb = fopen("/dev/keyboard", "r"); // 打开键盘
FILE* screen = fopen("/dev/screen", "w"); // 打开屏幕
fscanf(kb, "%d", &num); // 从键盘读
fprintf(screen, "%d", num); // 向屏幕写
// 太麻烦了!而且不同操作系统键盘/屏幕的设备名还不一样
// 默认打开后,直接就能用:
scanf("%d", &num); // 简单!
printf("%d", num); // 方便!
三个原因
| 原因 | 说明 |
|---|---|
| 1. 绝大多数程序都需要 IO | 没有输入输出的程序几乎没用 |
| 2. 提高开发效率 | 程序员不需要关心底层设备细节 |
| 3. 统一接口 | 跨平台兼容(Windows/Linux/macOS 都用 stdin/stdout) |
二、scanf/printf/perror 底层一定用了 stdin/stdout/stderr 吗?
是的!
cpp
// printf 的底层实现(简化版)
int printf(const char *format, ...)
{
// 本质就是调用 fprintf 并传入 stdout
return fprintf(stdout, format, ...);
}
// scanf 的底层实现(简化版)
int scanf(const char *format, ...)
{
// 本质就是调用 fscanf 并传入 stdin
return fscanf(stdin, format, ...);
}
// perror 的底层实现(简化版)
void perror(const char *s)
{
// 本质就是向 stderr 输出错误信息
fprintf(stderr, "%s: %s\n", s, strerror(errno));
}
仅仅只是 C 语言这样吗?
答案:不是!几乎所有语言都这样
| 语言 | 标准输入 | 标准输出 | 标准错误 |
|---|---|---|---|
| C | stdin |
stdout |
stderr |
| C++ | cin |
cout |
cerr |
| Java | System.in |
System.out |
System.err |
| Python | sys.stdin |
sys.stdout |
sys.stderr |
| Go | os.Stdin |
os.Stdout |
os.Stderr |
| Rust | std::io::stdin() |
std::io::stdout() |
std::io::stderr() |
所有语言的第一个程序都是 "Hello World!"
cpp
// C
printf("Hello World!\n");
// C++
cout << "Hello World!" << endl;
// Java
System.out.println("Hello World!");
// Python
print("Hello World!")
// Go
fmt.Println("Hello World!")
为什么所有语言都这样?------ 操作系统的支持
商贩卖货的类比
一条人山人海的路从头到尾只有个别商贩在摆摊 → 商贩个人行为,管理者不支持
一整条路从头到尾都有商贩在摆摊 → 管理者支持这种行为
同样道理:
只有 C 语言默认打开 stdin/stdout/stderr → 可能是 C 语言自己的设计
C、C++、Java、Python、Go、Rust 全都默认打开 → 一定是操作系统支持的
操作系统层面的真相
cpp
在 Linux 中:
bash
# 查看进程打开的文件描述符
$ ls -l /proc/$$/fd
lrwx------ 1 user user 64 ... 0 -> /dev/pts/0 # stdin
lrwx------ 1 user user 64 ... 1 -> /dev/pts/0 # stdout
lrwx------ 1 user user 64 ... 2 -> /dev/pts/0 # stderr
# 任何进程(不仅是C程序)启动时,都会继承这三个文件描述符
总结:
1. 语言层面: 默认打开标准流是为了方便开发(99% 的程序都需要 IO)
2. 操作系统层面: 每个进程启动时,操作系统都会自动分配文件描述符 0、1、3.
3.语言无关性: 所有高级语言都基于操作系统提供的这个特性,包装成自己的标准输入/输出
三.系统⽂件I/O
- 打开⽂件的⽅式不仅仅是fopen,ifstream等流式,语⾔层的⽅案,其实系统才是打开⽂件最底层的⽅案。不过,在学习系统⽂件IO之前,先要了解下如何给函数传递标志位,该⽅法在系统⽂件IO接⼝中会使⽤到:
cpp
#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;
}
3-1位掩码(Bitmask)的使用
1.代码功能
这段代码演示了位掩码的用法,通过一个整数的不同二进制位来表示多个开关状态。
2.宏定义的含义
cpp
#define ONE 0001 // 二进制:0000 0001 → 第0位为1
#define TWO 0002 // 二进制:0000 0010 → 第1位为1
#define THREE 0004 // 二进制:0000 0100 → 第2位为1
| 宏 | 十进制 | 二进制 | 含义 |
|---|---|---|---|
ONE |
1 | 0001 |
标志位1 |
TWO |
2 | 0010 |
标志位2 |
THREE |
4 | 0100 |
标志位3 |
3.核心原理:按位与(&)判断
cpp
if (flags & ONE) // 判断 flags 的第0位是否为1
if (flags & TWO) // 判断 flags 的第1位是否为1
if (flags & THREE) // 判断 flags 的第2位是否为1
原理:
flags & ONE只有当flags的第0位为1时,结果才非0非0值在C语言中表示
true
4.函数调用分析
cpp
func(ONE); // 只传 ONE
func(THREE); // 只传 THREE
func(ONE | TWO); // 传 ONE 和 TWO(按位或组合)
func(ONE | THREE | TWO); // 传三个标志
| 调用 | flags的值 | 判断结果 |
|---|---|---|
func(ONE) |
1 (0001) |
√ ONE × TWO × THREE |
func(THREE) |
4 (0100) |
× ONE × TWO √ THREE |
| `func(ONE | TWO)` | 3 (0011) |
| `func(ONE | THREE | TWO)` |
逐行解释表格结果
- func(ONE) → flags=1(0001)
ONE & 1 = 0001 & 0001 = 1 ≠ 0 → √ ONE
TWO & 1 = 0010 & 0001 = 0 → × TWO
THREE & 1 = 0100 & 0001 = 0 → × THREE
- func(THREE) → flags=4(0100)
ONE & 4 = 0 → ×
TWO & 4 = 0 → ×
THREE & 4 = 4 ≠ 0 → √ THREE
- func(ONE | TWO) → flags=3(0011
ONE & 3 = 1 ≠ 0 → √
TWO & 3 = 2 ≠ 0 → √
THREE & 3 = 0 → ×
- func(ONE | TWO | THREE) → flags=7(0111)
ONE & 7 = 1 ≠ 0 → √
TWO & 7 = 2 ≠ 0 → √
THREE & 7 = 4 ≠ 0 → √
3-2
3-2为什么要学习文件的系统调用接口?
学习文件的系统接口,本质上是在学习"操作系统是如何真正处理文件请求的"。
C语言提供的
fopen、fread、fwrite、fclose等库函数,并不是直接操作硬件,它们的底层必须调用系统调用接口(open、read、write、close),最终由操作系统完成真正的文件读写。而不同语言------无论是C++、Java还是Python------它们各自的文件操作接口,本质上都是对同一套系统调用接口的不同封装。
- 因此,只要你学懂了这套系统接口,后面再学习任何其他语言的文件操作,你只需要学它的语法和封装风格,底层原理完全不需要重新学习。这就是所谓的"一通百通"。
- 更深入一层来说,库函数(如
fopen)往往会隐藏很多操作系统的细节,比如:文件描述符、阻塞与非阻塞、权限控制、原子操作、内存映射等。而系统接口(如open/read/write)更加贴近操作系统,能让你直接看到并理解这些关键特性。只有理解了这些,你才能真正搞懂:文件为什么打不开?数据为什么没有立即写入磁盘?多个进程同时写一个文件为什么会出现混乱?- 所以,学习系统接口的真正目的不是替代库函数,而是让你从"只会调用接口"提升到"理解接口背后的操作系统行为"。这也是你图中从"C语言接口"向下走向"操作系统"的核心意义------只有走到更底层,才能掌握文件的本质。
图解:

四.系统调用接口的介绍
4-1man open

cpp
#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: 打开⽂件时,可以传⼊多个参数选项,⽤下⾯的⼀个或者多个常量进⾏"或"运算,构成
flags。
参数:
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR : 读,写打开
这三个常量,必须指定⼀个且只能指定⼀个
O_CREAT : 若⽂件不存在,则创建它。需要使⽤mode选项,来指明新⽂件的访
问权限
O_APPEND: 追加写
返回值:
成功:新打开的⽂件描述符
失败:-1
注意:mode_t理解:直接 man ⼿册,⽐什么都清楚。open 函数具体使⽤哪个,和具体应⽤场景相关,如⽬标⽂件不存在,需要open创建,则第三个参数表⽰创建⽂件的默认权限,否则,使⽤两个参数的open。write read close lseek ,类⽐C⽂件相关接⼝。

cpp
# CentOS / RHEL / Fedora
sudo yum install man-pages
# Ubuntu / Debian
sudo apt-get install manpages-dev
man 不出来执行上面操作下载
(1)操作系统传递标志位的方案
一、核心问题
操作系统需要让用户告诉内核:"我要以什么方式打开/操作这个文件?"
比如:
只读还是只写?
文件不存在要不要创建?
写入是追加还是覆盖?
要不要独占锁?
问题:这些标志位怎么传递给操作系统?
二、方案:按位掩码(Bitmask)
操作系统采用按位掩码的方案,用一个整数的不同二进制位表示不同的标志。
原理
cpp
// 每个标志占用一个独立的二进制位
#define O_RDONLY 00 // 0000 0000 只读
#define O_WRONLY 01 // 0000 0001 只写
#define O_RDWR 02 // 0000 0010 读写
#define O_CREAT 0100 // 0001 0000 创建
#define O_TRUNC 0200 // 0010 0000 截断
#define O_APPEND 0400 // 0100 0000 追加
组合方式:按位或(|)
cpp
// 组合多个标志
int flags = O_WRONLY | O_CREAT | O_TRUNC;
// 二进制:0000 0001 | 0001 0000 | 0010 0000 = 0011 0001
解析方式:按位与(&)
cpp
// 内核判断是否包含某个标志
if (flags & O_CREAT) {
// 用户要求:文件不存在就创建
}
if (flags & O_APPEND) {
// 用户要求:追加写入
}
三、具体示例:open 系统调用
cpp
#include <fcntl.h>
int open(const char *path, int flags, ... /* mode_t mode */);
使用示例
cpp
// 示例1:只读打开(必须存在)
int fd = open("file.txt", O_RDONLY);
// 示例2:只写打开,不存在则创建,存在则清空
int fd = open("file.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// 示例3:追加写入
int fd = open("file.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
// 示例4:读写打开,不存在就创建
int fd = open("file.txt", O_RDWR | O_CREAT, 0644);
四、为什么不用多个独立参数?
| 方案 | 示例 | 缺点 |
|---|---|---|
| 多个bool参数 | open(path, true, false, true, false, ...) |
参数太多、顺序难记、扩展性差 |
| 字符串 | open(path, "r+") |
解析慢、类型不安全 |
| 位掩码(一个int) | `open(path, O_RDWR | O_CREAT)` |
位掩码的优势:
一个整数传递所有信息
按位或组合,按位与判断
扩展性好(新增标志只用新的一位)
性能高(位运算极快)
C语言原生支持一句话总结 :操作系统用一个整数的不同二进制位 代表不同标志,用户通过按位或 组合标志,内核通过按位与解析标志。这是操作系统传递多个开关选项的经典方案。
总结:操作系统用一个整数的不同二进制位 代表不同标志,用户通过按位或 组合标志,内核通过按位与解析标志。这是操作系统传递多个开关选项的经典方案。
筛选标志位grep -ER 'O_CREAT | O_RDONLY' /usr/include/

cpp
#include <stdio.h>
#define ONE 0x1 // 0000 0001
#define TWO 0x2 // 0000 0010
#define THREE 0x4 // 0000 0100
void show(int flags) {
if (flags & ONE)
printf("hello one\n");
if (flags & TWO)
printf("hello two\n");
if (flags & THREE)
printf("hello three\n");
}
int main() {
show(ONE);
printf("---\n");
show(TWO);
printf("---\n");
show(ONE | TWO);
printf("---\n");
show(ONE | TWO | THREE);
printf("---\n");
show(ONE | THREE);
printf("---\n");
return 0;
}
运行结果:

宏定义(标志位)
cpp
#define ONE 0x1 // 二进制:0001
#define TWO 0x2 // 二进制:0010
#define THREE 0x4 // 二进制:0100
每个宏独占一个二进制位
ONE占第0位,TWO占第1位,THREE占第2位这样组合时不会互相干扰
show 函数(判断标志)
cpp
void show(int flags) {
if (flags & ONE) // 检查第0位是否为1
printf("hello one\n");
if (flags & TWO) // 检查第1位是否为1
printf("hello two\n");
if (flags & THREE) // 检查第2位是否为1
printf("hello three\n");
}
核心 :
flags & ONE的含义
如果
flags的第0位是1 → 结果非0 → 条件为真如果第0位是0 → 结果为0 → 条件为假
main 函数各次调用分析
cpp
调用1:show(ONE)
text
flags = 0x1 = 0001
flags & ONE = 0001 & 0001 = 0001 → 真 → 输出 "hello one"
flags & TWO = 0001 & 0010 = 0000 → 假 → 不输出
flags & THREE= 0001 & 0100 = 0000 → 假 → 不输出
输出:hello one
cpp
调用2:show(TWO)
text
flags = 0x2 = 0010
flags & ONE = 0010 & 0001 = 0000 → 假
flags & TWO = 0010 & 0010 = 0010 → 真 → 输出 "hello two"
flags & THREE= 0010 & 0100 = 0000 → 假
输出:hello two
cpp
调用3:show(ONE | TWO)
text
ONE | TWO = 0x1 | 0x2 = 0001 | 0010 = 0011
flags = 0011
flags & ONE = 0011 & 0001 = 0001 → 真 → 输出 "hello one"
flags & TWO = 0011 & 0010 = 0010 → 真 → 输出 "hello two"
flags & THREE= 0011 & 0100 = 0000 → 假
输出:
hello one
hello two
cpp
调用4:show(ONE | TWO | THREE)
text
ONE | TWO | THREE = 0001 | 0010 | 0100 = 0111
flags = 0111
flags & ONE = 0111 & 0001 = 0001 → 真 → 输出 "hello one"
flags & TWO = 0111 & 0010 = 0010 → 真 → 输出 "hello two"
flags & THREE= 0111 & 0100 = 0100 → 真 → 输出 "hello three"
输出三行
cpp
调用5:show(ONE | THREE)
text
ONE | THREE = 0001 | 0100 = 0101
flags = 0101
flags & ONE = 0101 & 0001 = 0001 → 真 → 输出 "hello one"
flags & TWO = 0101 & 0010 = 0000 → 假
flags & THREE= 0101 & 0100 = 0100 → 真 → 输出 "hello three"
输出:
hello one
hello three
与 open 函数的联系
cpp
// open 系统调用就是这种设计思想
int fd = open("file.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// ↑ 多个标志用 | 组合
// 内核内部判断
if (flags & O_CREAT) {
// 需要创建文件
}
if (flags & O_TRUNC) {
// 需要截断文件
}
为啥传两个标志位的时候要用 |?
O_WRONLY、 O_RDONLY、O_CREATE、O_APPEND 都是标志位。
你可以这么理解:每个标志位,比如
ONE、TWO、THREE,它们本质上就是一个整数里的某一位 。
ONE占第 0 位,TWO占第 1 位,THREE占第 2 位,谁也不占谁的地盘。那你想同时传
ONE和TWO的时候,就得把第 0 位和第 1 位都变成 1 。
|这个操作符的作用就是:把两个数的"1"全部保留下来。所以:
ONE | TWO就是:第 0 位是 1、第 1 位是 1 → 一个整数同时包含两个标志。这就是"按位或"的直觉含义:你有我也有,咱们合并。
| 标志位 | 意思 | 大白话 |
|---|---|---|
| O_RDONLY | 只读 | 只能看内容,不能改 |
| O_WRONLY | 只写 | 只能写内容,不能看 |
| O_RDWR | 读写 | 又能看又能写 |
| O_CREAT | 创建 | 文件不存在就自动创建 |
| O_APPEND | 追加 | 每次写都在文件末尾加内容,不覆盖原来的 |
| O_TRUNC | 截断 | 打开文件后,把原来的内容全清空 |
那用 + 行不行?
大多数时候结果一样,但容易翻车。 为啥?
因为
|是位运算,它的心里想的是:只管哪几位是 1 。而
+是算术运算,它想的是:值加起来。举个翻车的例子:
如果你不小心把
ONE | ONE写成了ONE + ONE,
ONE + ONE= 1 + 1 = 2,而 2 正好是TWO。那你本来想传"一个 ONE",结果变成了 TWO,意思完全变了。
而
ONE | ONE还是ONE,不会乱。所以结论是:
用|是安全的、语义正确的;用+是拿运气写代码。
那为啥不直接传数字,比如传 3?
答:技术上完全可以,比如
ONE | TWO就等于 3。
但是别人看你代码的时候会一脸懵: 3 是啥意思?你传了个 3 是想表达什么?但如果你写ONE | TWO:表明你既要 ONE 又要 TWO,一看就明白。
操作系统是怎么用这个设计的?
操作系统收到你传进来的
flags之后,根本不管你传的是 3 还是ONE | TWO,它只看哪些位是 1。比如:
它想判断你有没有传
O_CREAT,就执行:
if (flags & O_CREAT)这句话的意思就是:你那个整数的"创建文件"这一位是不是 1?
是 1 就执行,不是就跳过。
这就叫 位掩码判断,效率极高,一次位运算就完事
补充:用
|组合多个标志位,用&判断是否包含某个标志,这就是操作系统传递标志位的核心方案。
问题:为什么语言要对系统接口做封装?
因为系统接口(如
open)在不同操作系统上不一样,而语言的库函数(如fopen)会帮你自动选择对的那个,这样你写的代码就能到处跑。
1.先搞明白:什么是"可移植性"?
可移植性 就是你在一台电脑上写的代码,拿到另一台不同系统的电脑上,不用改就能运行。
比如:
你在 Linux 上写的 C 程序
拿到 Windows 上也能编译运行
2.问题来了:系统接口不通用
Linux 打开文件的系统调用:
cpp
open("file.txt", O_RDONLY);
Windows 打开文件的系统调用:
cpp
CreateFile("file.txt", GENERIC_READ, ...);
名字不一样,参数不一样,写法不一样。
如果你的代码里直接写 open(...),那拿到 Windows 上就编译不过,因为 Windows 根本不认识 open 这个函数。
3.解决方案:语言封装一层"中间人"
C 语言提供了一个叫 fopen 的函数,你在代码里永远写:
cpp
fopen("file.txt", "r");
在 Linux 上:
fopen的底层会调用open()在 Windows 上:
fopen的底层会调用CreateFile()你写的是同一行代码,但底层干活的"人"不一样。
4.打个比方帮你理解
你去国外旅游,想买一瓶水。
| 场景 | 做法 | 问题 |
|---|---|---|
| 不用封装 | 你直接用当地语言说"我要水" | 去一个地方就要学一种新语言 |
| 用封装 | 你找一个翻译(语言库函数) | 你只说"我要水",翻译帮你换成当地方言 |
你 = 程序员写的代码
"我要水" =
fopen翻译 = C 语言标准库
当地方言 =
open(Linux)或CreateFile(Windows)你只管说"我要水",翻译帮你搞定一切。
5.为什么不能直接让所有语言都用 open?
你原文里提到一个很关键的观点:
如果所有语言都用
open这一套接口,那么这套接口在 Windows 下是不能运行的。
原因:
Windows 没有
open这个函数 ,它用的是CreateFile就算 Windows 强行模仿一个
open,内部细节也不一样(比如文件路径格式、权限表示方式)各种语言的语法不同,C 语言用
int fd,Java 用FileInputStream对象,不可能统一
所以结论是:
不是让语言去适配系统接口,而是让语言的库函数去封装系统接口的差异。
6.封装的好处总结
| 好处 | 说明 |
|---|---|
| 可移植性 | 同一段代码可以在 Linux、Windows、macOS 上跑 |
| 更简单 | fopen 比 open 用起来省事(不用记 O_RDONLY 这些宏) |
| 更安全 | 库函数会帮你做错误检查、缓冲管理 |
| 符合语言习惯 | C++ 用 fstream,Java 用 File,Python 用 open,都符合各自的语法风格 |
大白话:
系统接口像方言,不同系统各说各话;语言的库函数像翻译官,你跟翻译官说话,翻译官帮你跟系统沟通。所以你写的代码不用改,就能在世界各地跑。
4-2open 函数的返回值
- fopen fclose fread fwrite 都是 C 标准库当中的函数,我们称之为库函数(libc)。
- open close read write lseek 都属于系统提供的接口,称之为系统调用接口。

这张图片再次出现应该不用陌生吧,系统调⽤接⼝和库函数的关系,⼀⽬了然。
所以,可以认为, f# 系列的函数,都是对系统调⽤的封装,⽅便⼆次开发。

运行结果:


没有设置权限结果是啥?

运行结果和我之前一样
那我加上权限


为什么第一次和第二次结果一样?
因为:
第一次:随机拿到一个权限(幸运地跟 0664 一样)因为你没有给权限的
第二次:你要求 0666(-rw-rw-rw),被 umask 降级成 0664(-rw-rw-r)
结果相同,但原因完全不同。
【补充】
只写
O_WRONLY:文件必须已经存在,否则报错。写
O_WRONLY | O_CREAT:文件不存在就自动创建,存在就直接打开
umask
umask 的数值含义
你敲 umask 命令:
cpp
$ umask
0002
这个 0002 的意思是:
把"其他人的写权限"这一位,从你申请的权限里去掉。
计算规则(重点)
最终权限 = 你申请的权限 & (~umask)
举例
你申请
0666(二进制:110110110)
umask是0002(二进制:000000010)先取反
~umask=111111101然后按位与:
cpp110110110 (0666) & 111111101 (~0002) = 110110100 (0664)结果:
rw- rw- r--
常见的 umask 值及效果
| umask 值 | 你申请 0666 时 | 你申请 0777 时 | 含义 |
|---|---|---|---|
0000 |
0666 (rw-rw-rw-) |
0777 (rwxrwxrwx) |
不挡任何权限 |
0002 |
0664 (rw-rw-r--) |
0775 (rwxrwxr-x) |
去掉其他人的写 |
0022 |
0644 (rw-r--r--) |
0755 (rwxr-xr-x) |
去掉组和其他人的写 |
0077 |
0600 (rw-------) |
0700 (rwx------) |
只留自己的权限 |
umask 是"减法"还是"掩码"?
很多人误会 umask 是"直接减",其实是"按位屏蔽"。
umask里哪一位是 1,就表示"申请的这一位权限我不给"。
例如:
umask的002= 二进制010→ 屏蔽"其他人的写权限"(第2位)
umask的022= 二进制010010→ 屏蔽"组的写"和"其他人的写"

运行结果:就已经有w权限了umask在作怪,运行这个代码,重新创建log.txt

cpp
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
这个写法:
| 文件状态 | 行为 | 权限怎么变 |
|---|---|---|
| 不存在 | 创建新文件 | 按 0666 创建 |
| 已存在 | 直接打开 | 不修改已有权限 |
所以:
第一次运行:创建文件,权限 = 0666(因为
umask(0)生效)第二次及之后:文件已存在,直接打开,权限保持不变(还是 0666)



运行结果:



直接打印会出现这个现象。全部返回
-1,说明 4 个open都失败了。O_RDONLY的意思是:只读打开,且文件必须存在。如果
log1.txt、log2.txt、log3.txt、log4.txt这四个文件都不存在 ,那么open就会失败,返回-1。
方案1:提前创建文件
touch log1.txt log2.txt log3.txt log4.txt ./test
方案2:代码里加上 O_CREAT(自动创建)
cpp
int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);
但注意:O_RDONLY | O_CREAT 有点奇怪(只读方式创建文件),更常见的是:
cpp
int fd1 = open("log1.txt", O_RDWR | O_CREAT, 0666);
方案3:用 O_WRONLY | O_CREAT 写文件
如果你是要写文件:
cpp
int fd1 = open("log1.txt", O_WRONLY | O_CREAT, 0666);

问题一:为什么 open 返回的 fd 不从 0 开始,而是从 3 开始?
回答:
因为 C 程序一运行,操作系统就会自动帮你打开三个文件:标准输入(stdin)、标准输出(stdout)、标准错误(stderr)。它们分别占用了文件描述符 0、1、2。
所以你手动调用 open 打开的第一个文件,自然就从 3 开始分配了。如果关闭其中某一个(比如 close(0)),那么下一次 open 就可能复用这个位置。
结论:文件描述符 0、1、2 被预留了,所以用户打开的文件从 3 开始。
问题二:为什么文件描述符是连续的小整数?它本质是什么?
回答:
因为你看到的 fd(比如 3、4、5、6),本质上是一个数组的下标。
操作系统中,每个进程的 task_struct 里有一个 files_struct 结构体,里面包含了一个指针数组 fd_array[],数组的每一个槽位都指向一个 struct file(这个结构体用来描述一个打开的文件)。
当你调用 open 时:
操作系统创建一个新的
struct file把这个结构体的地址,放进
fd_array[]中第一个空闲的位置把这个位置的下标 作为
fd返回给你
所以用户层拿到的 fd,本质上就是内核里 fd_array[] 的索引。
补充:文件描述符是进程和文件之间的"挂号信",操作系统用一个数组管理所有打开的文件,fd 就是你在数组中的"座位号"。
FILE 是什么?由谁提供的?
回答:
FILE 是一个结构体(struct) ,由 C 标准库 提供的。
我们平时用的 fopen、fread、fwrite、fclose 等函数,返回值或参数都是 FILE* 类型。这个 FILE 结构体内部封装了与文件相关的各种信息,比如:缓冲区指针、当前读写位置、错误标志、以及------最重要的------底层对应的文件描述符(fd)。
结论:FILE 是 C 标准库定义的一个结构体,用来代表一个被打开的文件流。
从操作系统的角度来看,要读写一个文件,一定需要的是 FILE 还是 fd?
回答:
一定是 fd(文件描述符)。
操作系统根本不认识 FILE,那是 C 标准库自己搞的东西。操作系统内核提供的系统调用(如 read、write、close)接受的参数都是 文件描述符(int 类型的整数) ,而不是 FILE*。
所以:
用户层(C 程序)看到的是
FILE*内核层看到的是
fd
两者之间的关系是:FILE 结构体内部一定封装了一个 fd。
怎么证明 FILE 里面封装了 fd?
回答:
简单实验:
cpp
#include <stdio.h>
#include <stdlib.h>
int main()
{
FILE* fp = fopen("log.txt", "w");
if (fp == NULL) {
perror("fopen");
return 1;
}
printf("FILE* fp = %p\n", fp);
printf("fileno(fp) = %d\n", fileno(fp)); // 这个函数可以取出 fd
fclose(fp);
return 0;
}
运行结果:

fileno 这个函数就是用来从 FILE* 中取出底层封装的那个 fd。
总结:
FILE是 C 标准库提供的结构体,里面封装了操作系统需要的fd。用户层用FILE*,内核层用fd。C 库函数(如fread)内部会调用系统调用(如read),并把FILE里的fd传给内核。

问题一:不同硬件的操作方法不一样,操作系统怎么统一处理?
答:
没错,硬盘、键盘、鼠标、显示器、网卡......这些硬件的读写方法完全不同。
硬盘:要读扇区、写扇区
键盘:要读扫描码
显示器:要刷新显存
如果没有统一抽象,程序员写代码时就得区分:这是硬盘、那是键盘,分别调用不同的函数,累死个人。
操作系统为了解决这个问题,在 struct file 里面放了一个 函数指针结构体 ,叫做 file_operations。
这个结构体里全是函数指针:
cpp
struct file_operations {
read_t read; // 函数指针
write_t write; // 函数指针
open_t open; // 函数指针
close_t close; // 函数指针
// ...
};
每个硬件驱动程序,会自己去"填充"这些函数指针:
-
硬盘驱动:把
read指向硬盘的读函数 -
键盘驱动:把
read指向键盘的读函数 -
显示器驱动:把
write指向显示器的写函数
结论:上层调用同一个名字
read,底层实际执行的是不同硬件的不同函数。这就是"统一接口,底层差异"的核心。
问题二:什么是"一切皆文件"?进程是怎么看到这个统一的?
答:
这是 Linux 操作系统最核心的设计哲学之一。
对于进程来说,它根本不需要关心"我操作的这个东西是硬盘还是键盘还是显示器"。
进程只需要做三件事:
用
open拿到一个fd用
read/write读写数据用
close关闭
至于这个 fd 背后对应的是硬盘上的普通文件,还是键盘输入,还是网卡数据,进程完全不关心。
为什么能做到?
因为在操作系统内部,每个被打开的对象都有一个 struct file,而这个 struct file 里面的 file_operations 函数指针,已经指向了正确的硬件操作方法。
进程眼中:一切都是文件。
操作系统手中:根据不同类型,调用不同的底层函数。
问题三:用户拿到的 fd 和这些底层机制有什么关系?
答:
用户(程序员)从头到尾只看到一个整数 fd。
这个 fd 是操作系统内部 fd_array[] 数组的下标,数组里存的是 struct file* 指针,而 struct file 里面有 file_operations 函数指针,最终指向具体硬件的驱动程序。
所以:
cpp
fd (整数)
↓
fd_array[fd]
↓
struct file*
↓
file_operations (函数指针表)
↓
具体硬件的 read/write 函数
用户只需要记住 fd,操作系统负责把剩下的每一步都走完。
结论:用户只跟 fd 打交道,操作系统在背后把"一切皆文件"变成现实