基础I/O

理解文件

Linux下⼀切皆⽂件(键盘、显⽰器、⽹卡、磁盘......这些都是抽象化的过程)

归类认知

• 对于0KB的空⽂件是占⽤磁盘空间的

• ⽂件是⽂件属性(元数据)和⽂件内容的集合(⽂件=属性(元数据)+内容)

• 所有的⽂件操作本质是⽂件内容操作和⽂件属性操作

系统角度

对⽂件的操作 本质是进程对⽂件的操作

• 磁盘的管理者是操作系统

• ⽂件的读写本质不是通过C语⾔/C++的库函数来操作的(这些库函数只是为⽤⼾提供⽅便),⽽ 是通过⽂件相关的**系统调⽤接⼝**来实现的

回顾C⽂件接⼝

  1. fopen 是打开文件的。比如:

FILE *fp = fopen("myfile", "w");

意思就是:打开一个叫 myfile 的文件,准备往里面写。

  1. FILE * 叫文件指针。你可以先把它粗暴理解成: "以后我要通过它操作这个文件"

不用现在深究结构体。

  1. fwrite 是写文件。比如:

fwrite(msg, strlen(msg), 1, fp);

意思就是: 把 msg 这段内容写到 fp 对应的文件里

4.fread 是读文件。比如:

fread(buf, 1, 10, fp);

意思就是: 从文件里读一些内容,放进 buf

  1. stdout 其实就是"屏幕"。 比如:

fprintf(stdout, "hello\n");

这就是往屏幕输出。所以:

  • printf("hello\n")

  • fprintf(stdout, "hello\n")

本质差不多。

  1. C 程序默认有 3 个特殊文件:

**- stdin:键盘输入

  • stdout:屏幕输出
  • stderr:错误输出到屏幕**

打开文件

FILE *fp = fopen("myfile", "w");

要点:

  • fopen 用来打开文件,返回值类型是 FILE *
    - FILE * 可以理解为"C 标准库中的文件流对象"

  • 打开失败返回 NULL

  • 如果文件名没有写路径,比如 "myfile",那么它是相对当前进程的工作目录 cwd 来查找或创建的

  • 不是相对源码目录,也不是固定相对可执行程序所在目录

即:文件名如果没写路径,就在"当前运行目录"里找或创建

写文件

这一小节讲的是:如何通过 C 标准库把数据写入文件。

cpp 复制代码
FILE *fp = fopen("myfile", "w");
  const char *msg = "hello bit!\n";
  fwrite(msg, strlen(msg), 1, fp);
  fclose(fp);

要点:

  • fopen("myfile", "w")

  • 以写方式打开

  • 文件不存在就创建

  • 文件存在通常会清空原内容

  • fwrite 用来向文件流写数据

  • fclose 用来关闭文件流,关闭前通常会刷新缓冲区

fwrite 参数含义: fwrite(ptr, size, nmemb, stream);

  • ptr:待写入数据的起始地址

  • size:每个元素的大小

  • nmemb:元素个数

  • stream:目标文件流

返回值:

  • 返回成功写入的"元素个数"

  • 不是固定意义上的"字节数"

想表达:

  • 用 fopen(..., "w") 打开文件

  • 用 fwrite 往里写

  • 用完要 fclose

你先掌握这个模板:

FILE *fp = fopen("a.txt", "w");

fwrite("hello\n", 6, 1, fp);

fclose(fp);

读文件

这一小节讲的是:如何通过 C 标准库从文件中读数据。

cpp 复制代码
 FILE *fp = fopen("myfile", "r");
  char buf[1024];
  ssize_t s = fread(buf, 1, strlen(msg), fp);
  if (s > 0) {
      buf[s] = 0;
      printf("%s", buf);
  }
  if (feof(fp)) break;
  fclose(fp);

要点:

  • fopen("myfile", "r")

  • 只读打开

  • 文件必须存在

  • fread 从文件流中读取数据到缓冲区

  • fread 读出来的是原始字节,不会自动补字符串结束符 \0

  • 如果后面要把缓冲区当字符串打印,通常要手动补 \0

fread 参数含义:

fread(ptr, size, nmemb, stream);

  • ptr:读到哪里

  • size:每个元素大小

  • nmemb:最多读取多少个元素

  • stream:从哪个文件流读

返回值:

  • 返回成功读取的元素个数

  • 当 size == 1 时,返回值数值上才等于读取的字节数

想表达:

  • 用 fopen(..., "r") 打开文件

  • 用 fread 读取内容

  • 读完再关掉

你先只要知道:

  • "r" 是读

  • "w" 是写

输出到显示器有哪些方法

cpp 复制代码
fwrite(msg, strlen(msg), 1, stdout);
  printf("hello printf\n");
  fprintf(stdout, "hello fprintf\n");

屏幕输出本质上也是文件流输出。

要点:

  • printf 默认写到 stdout

  • fprintf(stdout, ...) 明确指定写到标准输出流

  • fwrite(..., stdout) 也能直接向标准输出流写

它真正想表达的是:

  • 终端输出不特殊,本质也是对文件流的写操作

  • "往屏幕打印"这件事,在 C 标准库视角里,就是"往 stdout 写"

想表达:

  • 输出到屏幕,本质也是"往一个文件里写"

  • 那个文件就叫 stdout

所以这几种都能打印:

printf("hello\n");

fprintf(stdout, "hello\n");

fwrite("hello\n", 6, 1, stdout);

你只要先记:

  • stdout 就是标准输出,也就是屏幕

stdin stdout stderr

C 程序启动时默认打开的三个标准流。

定义上它们都是:

extern FILE *stdin;

extern FILE *stdout;

extern FILE *stderr;

含义:

  • stdin:标准输入,默认对应键盘

  • stdout:标准输出,默认对应显示器终端

  • stderr:标准错误,默认也对应显示器终端

这一节真正想表达的是:

**- C 程序一启动,系统和库就已经帮你准备好了三个可直接使用的流

  • 它们和普通 fopen 得到的流在类型上是统一的,都是 FILE ***

想表达:

C 程序一启动,系统默认给你准备了 3 个地方:

  • stdin:从键盘读

  • stdout:往屏幕写普通内容

  • stderr:往屏幕写错误内容

你现在先背,不用深究。

打开模式

这节最重要,你只记最常用的 3 个就够了:

  • "r":读文件,文件必须存在

  • "w":写文件,文件不存在就创建,存在就清空

  • "a":追加写,写到文件最后面

在程序的当前路径下,那系统怎么知道程序的当前路径在哪⾥呢?

使⽤ ls /proc/[ 进程 id] -l命令查看当前正在运⾏进程的信息:

cwd:指向当前进程运⾏⽬录的⼀个符号链接。

• exe:指向启动当前进程的可执⾏⽂件(完整路径)的符号链接

打开⽂件,本质是进程打开,所以,进程知道⾃⼰在哪⾥,即便⽂件不带路径,进程也知道。由此OS 就能知道要创建的⽂件放在哪⾥

1. 操作的核心句柄:FILE*

这是C库文件操作的唯一通行证

  • 如何获取 :通过 FILE* fp = fopen("文件名", "模式")获得。

  • 本质是什么FILE是一个由C标准库定义的结构体类型。FILE*指针指向的这个结构体内,封装了两个至关重要的东西:

    1. 底层文件描述符(fd):一个整数,是操作系统识别文件的真实ID。

    2. 用户级缓冲区 :一块内存区域,用于提升I/O效率,也是后续许多有趣现象(如fork后输出重复)的根源。

三个默认流 :C程序启动时自动打开三个 FILE*类型的流:

  • stdin :标准输入(对应文件描述符 0),通常为键盘。

  • stdout :标准输出(对应文件描述符 1),通常为显示器。

  • stderr :标准错误(对应文件描述符 2),通常为显示器。

记住这个模式缓冲区 + 循环fread/while(!feof)是处理任何流式数据的标准方法。

文件打开模式:意图的声明

fopen的第二个参数------模式字符串,直接决定了你的操作意图:

模式 含义 文件不存在时 文件存在时
"r" 只读 打开失败 正常打开
"w" 只写 创建新文件 清空原内容
"a" 追加写 创建新文件 在末尾追加
"r+" 读写 打开失败 从头开始读写
"w+" 读写 创建新文件 清空原内容
"a+" 读写 创建新文件 读从头,写从尾

特别警示"w""w+"清空(Truncate)行为是许多数据丢失bug的元凶,使用时务必小心。

系统⽂件I/O是打开⽂件最底层的⽅案。

在学习系统⽂件IO之前,先要了解下如何给函数传递标志位,该⽅法在系统⽂件IO接⼝中 会使⽤到:

打开⽂件的⽅式不仅仅是fopen,ifstream等流式,语⾔层的⽅案,其实系统⽂件I/O才是打开⽂件最底层的⽅ 案。不过,在学习系统⽂件IO之前,先要了解下如何给函数传递标志位,该⽅法在系统⽂件IO接⼝中 会使⽤到:

上一节讲的是 fopen/fread/fwrite,这一节讲的是更底层的 open/read/write/close。

  • 上一节是"库函数"

  • 这一节是"系统调用接口"

  • 后面讲 fd、重定向、shell,都是建立在这一节上的

系统文件 I/O 到底想表达什么

这整节其实想让你明白 4 件事:

  1. 文件 IO 最底层靠的是系统调用,不是 fopen

  2. Linux 用 fd 来表示"打开的文件"

  3. 重定向的本质是"让某个 fd 指向了别的文件"

  4. C 库的 FILE* 和 Linux 的 fd 不是一层东西,但它们一定有关系

传递标志位的方法

这一小节先没讲文件,而是先讲按位或 |、按位与 &。

示例大意是:

cpp 复制代码
  #define ONE   0x1
  #define TWO   0x2
  #define THREE 0x4
  func(ONE | TWO);

它想表达的是:

-**一个函数的某个参数,可以同时携带多个"开关"

  • 这些开关通常用二进制位来表示
  • 系统调用里大量使用这种设计**

为什么先讲这个?因为后面你会看到:

open("myfile", O_WRONLY | O_CREAT, 0644);

这里的 O_WRONLY | O_CREAT 就是多个标志位组合。

**- | 用来组合多个标志

  • & 用来检测某个标志是否存在

  • 系统接口常常这么设计,因为省参数、可扩展**

  • flags 通常是"多个选项组合后的结果"

**- 组合用 |

  • 判断是否包含某选项用 &**

hello.c 写文件

这里把上一节的 fopen + fwrite 换成了系统调用版本:

cpp 复制代码
  int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
  write(fd, msg, len);
  close(fd);

这一小节想讲的是:

  • 不用 FILE*,直接用系统提供的 fd

  • 不用 fwrite,直接用 write

  • 不用 fclose,直接用 close

核心变化:

  • C 库那套对象是 FILE*

  • 系统调用这套对象是 int fd

这里的 fd 是文件描述符,你先粗暴记成:

fd 就是操作系统分给这个打开文件的编号。

这节还出现了:

umask(0);

它的作用是暂时不让"权限掩码"干扰演示,好让 0644 更直观地生效。你现在不用深挖,先知道:

  • open(..., O_CREAT, 0644) 里的 0644 是"想给新文件设置的初始权限"

  • 但最终权限还会受 umask 影响

这一小节你必须掌握:

  • open 打开文件,返回 fd

  • write 往 fd 里写

  • close 关闭 fd

  • O_WRONLY | O_CREAT 的意思

  • 第三个参数 0644 是创建文件时用的权限

你该记的笔记:

  • open 成功返回非负整数 fd,失败返回 -1

  • write(fd, buf, len) 返回实际写入的字节数

  • close(fd) 用来关闭文件描述符

  • O_CREAT 只有在可能创建文件时才有意义

  • 0644 是八进制权限

hello.c 读文件

这部分把读文件改成系统调用版:

cpp 复制代码
int fd = open("myfile", O_RDONLY);
  ssize_t s = read(fd, buf, strlen(msg));
  close(fd);

这一小节想表达:

  • 读文件的系统调用版本是 open + read + close

  • read 和 write 是成对理解的

  • 它们操作的都是 fd

read 的核心意思:

read(fd, buf, n);

意思是:

  • 从 fd 对应文件里读最多 n 个字节

  • 放到 buf 里

  • 返回实际读到的字节数

这一节想让你形成的认知是:

  • 系统调用不管"字符串"不"字符串"

  • 它只管字节流

  • 你要读多少字节、写多少字节,都得自己控制

你要掌握:

  • O_RDONLY 是只读打开

  • read 返回实际读取的字节数

  • 返回 0 通常表示读到文件结尾

  • 返回 < 0 说明出错

你该做笔记:

  • read/write 都是"按字节数"工作的

  • read 返回 0 很重要,通常表示 EOF

  • 系统调用处理的是原始字节,不懂字符串语义

接口介绍

这里正式给出 open 原型:

cpp 复制代码
  int open(const char *pathname, int flags);
  int open(const char *pathname, int flags, mode_t mode);

这一小节是在系统整理 open 的参数和常见选项。

它重点讲了这些标志:

**- O_RDONLY:只读

  • O_WRONLY:只写
  • O_RDWR:读写
  • O_APPEND:追加写
  • O_CREAT:文件不存在就创建**

这一节最重要的意思有两个。

第一: O_RDONLY / O_WRONLY / O_RDWR 这三者里,必须选一个,而且只能选一个。

因为你总得告诉系统:这次打开到底是读、写,还是读写。

第二: 像 O_CREAT、O_APPEND 这种,是附加选项。所以它们通常和前面三种主模式组合使用。

例如:

cpp 复制代码
  open("a.txt", O_WRONLY | O_CREAT, 0644);
  open("a.txt", O_WRONLY | O_APPEND);

这一节你必须掌握:

  • pathname 是路径

  • flags 是打开方式和附加选项的组合

  • mode 只有在需要创建文件时才有意义

  • flags 常常通过按位或 | 组合

你该做笔记:

  • open 有两个常见版本

  • 需要创建文件时才传第三个参数 mode

  • O_RDONLY / O_WRONLY / O_RDWR 三选一

  • O_CREAT / O_APPEND 是附加选项


open 函数返回值

这一节表面上讲 open 返回值,实际上是在引出"库函数 vs 系统调用"。

它想表达的是:

  • fopen/fclose/fread/fwrite 是 C 标准库函数

  • open/close/read/write/lseek 是系统调用接口

  • 库函数通常是对系统调用的封装

这节不是要你背定义,而是要你建立层次感:

上层:

  • printf

  • fopen

  • fread

  • fwrite

下层:

  • open

  • read

  • write

  • close

你要理解:

  • 库函数更方便

  • 系统调用更接近 OS

  • 系统调用才是更底层的"真入口"

这一节你必须掌握:

  • open 成功返回文件描述符

  • open 失败返回 -1

  • 系统调用和库函数不是一回事

该记笔记:

  • 库函数:更方便,更上层

  • 系统调用:更底层,直接向 OS 请求服务

  • f# 系列很多都是对系统调用的封装


3-6 文件描述符 fd

这部分是整节最核心的内容。

一句话先说透:

fd 本质上就是一个整数编号,用来代表"这个进程打开的某个文件"。

你不要把它理解得太玄。现在先记:

  • 打开一个文件后,系统返回一个整数

  • 以后读写就靠这个整数

  • 这个整数就叫文件描述符 fd


3-6-1 0 & 1 & 2

这一小节讲的是默认的三个文件描述符:

  • 0:标准输入

  • 1:标准输出

  • 2:标准错误

通常默认对应:

  • 0 -> 键盘

  • 1 -> 屏幕

  • 2 -> 屏幕

示例里写:

read(0, buf, sizeof(buf));

write(1, buf, strlen(buf));

write(2, buf, strlen(buf));

意思就是:

  • 从键盘读

  • 往标准输出写

  • 往标准错误写

这一节真正想表达的是:

  • Linux 进程一启动,默认就已经打开了 0、1、2

  • 所以后面 C 库里的 stdin/stdout/stderr,最终一定和这三个 fd 有关系

你必须掌握:

  • 0/1/2 的意义

  • read(0, ...) 就是在读标准输入

  • write(1, ...) 就是在写标准输出

  • write(2, ...) 就是在写标准错误

这节最该记笔记:

  • 0 标准输入

  • 1 标准输出

  • 2 标准错误


3-6-2 文件描述符的分配规则

这里做了一个实验:

  • 正常 open 一个文件,通常得到 fd = 3

  • 如果先 close(0),再 open,那新打开的文件可能拿到 fd = 0

  • 如果先 close(2),再 open,可能拿到 fd = 2

它想表达的是:

新分配的 fd,总是当前最小可用的那个编号。

这是非常重要的规则。

为什么这条规则这么关键?

因为它直接解释了后面的"重定向为什么能成立"。

比如你先把 1 关掉,再打开一个文件,那么这个文件很可能正好占据 1。

那以后所有写到 1 的内容,就自然写进这个文件了。

你必须掌握:

  • fd 不是随机分配的

  • 分配规则是"最小可用下标"

  • 这条规则和重定向密切相关

你该记笔记:

  • 默认 0/1/2 已经占用,所以普通新文件常从 3 开始

  • open 会返回当前最小可用 fd


3-6-3 重定向

这里做了一个实验:

close(1);

int fd = open("myfile", O_WRONLY | O_CREAT, 0644);

printf("fd: %d\n", fd);

如果 1 先被关掉,那么 open 很可能返回 1。

于是原本写到屏幕的内容,变成写到文件里。

这一节想表达的是:

重定向的本质,不是"printf 会魔法变身",而是 1 号 fd 不再指向屏幕了。

这句话是这章最关键的理解之一。

你必须理解:

  • printf 默认最终会往标准输出写

  • 标准输出底层对应 fd = 1

  • 如果 1 不再代表屏幕,而代表某个普通文件

  • 那 printf 的内容就会进这个文件

这就是输出重定向。

你该做笔记:

  • 重定向的本质:改变 0/1/2 所对应的目标

  • 不是应用层 API 变了,而是底层 fd 指向变了


3-6-4 使用 dup2 系统调用

这里开始讲更正规、也更常用的重定向方式:

dup2(oldfd, newfd);

它的意思是:

让 newfd 指向和 oldfd 一样的打开文件对象。

比如:

int fd = open("./log", O_CREAT | O_RDWR);

dup2(fd, 1);

效果就是:

  • 让 1 号文件描述符改为指向 log

  • 以后所有写到 1 的输出,都进入 log

为什么要用 dup2?

因为"先 close(1) 再 open()"是一种借助分配规则的做法,比较绕。

dup2 更直接,就是明确指定:

  • 我就要把 1 改到这里来

这节你必须掌握:

  • dup2(oldfd, newfd) 的含义

  • 它是重定向的核心系统调用

  • shell 的 >、<、>> 本质上都离不开这类操作

你该做笔记:

  • dup2(fd, 1):把标准输出重定向到 fd

  • dup2(fd, 0):把标准输入重定向到 fd

  • dup2(fd, 2):把标准错误重定向到 fd


3-6-5 在 minishell 中添加重定向功能

这一小节是前面知识的综合应用。

它想表达的是:

  • shell 里的重定向不是嘴上说说

  • 本质上就是 shell 在启动子进程后、执行新程序前,先把 0/1/2 调整好

  • 然后程序一运行,就天然以为自己的标准输入输出就是现在这个样子

最关键的一句话是:

重定向应该由子进程在 exec 前完成。

为什么?

因为你如果在父进程里改:

  • 父进程自己的标准输入输出也会被改掉

  • shell 本身就乱了

正确流程通常是:

  1. shell 解析命令

  2. 发现有 >、<、>>

  3. fork 创建子进程

  4. 子进程里 open 目标文件

  5. 子进程里用 dup2 改 0/1/2

  6. 子进程 exec 执行目标程序

这样程序启动后,看到的标准输入输出已经被重定向好了。

你作为学习者至少要理解到:

  • shell 重定向不是程序自己做的

  • 是 shell 在启动程序之前替你做好了

  • 重定向和程序替换 exec 不冲突,因为 fd 会被继承

你该做笔记:

  • 重定向应在子进程中完成

  • 要在 exec 前做

  • exec 不会自动恢复 0/1/2


这一整节你最少要掌握什么

最低要求:

  • 会写 open/read/write/close

  • 知道 fd 是文件描述符

  • 知道 0/1/2 分别是什么

  • 知道 open 返回新 fd

  • 知道 read/write 处理的是字节

中等要求:

  • 理解 flags 为什么能用 |

  • 理解 fd 分配规则是"最小可用"

  • 理解重定向本质是修改 0/1/2 的目标

  • 理解 dup2 是重定向核心接口

较好掌握:

  • 能说清楚库函数和系统调用的区别

  • 能解释为什么 shell 重定向必须在子进程里做

  • 能把 stdout 和 fd=1 联系起来


这一节最值得做笔记的地方

优先级最高的是这 8 条:

  • 系统文件 IO 常用接口:open/read/write/close

  • open 成功返回 fd,失败返回 -1

  • read/write 返回实际读写的字节数

  • 0/1/2 分别是标准输入、标准输出、标准错误

  • fd 的分配规则是"当前最小可用编号"

  • 重定向的本质是改变 0/1/2 的指向

  • dup2(oldfd, newfd) 是重定向关键接口

  • shell 中重定向应由子进程在 exec 前完成

次优先级是:

  • O_RDONLY / O_WRONLY / O_RDWR

  • O_CREAT / O_APPEND

  • mode 只在创建文件时有意义

  • 库函数和系统调用的层次区别

理解"⼀切皆⽂件"

进程、磁盘、显⽰器、键盘这样硬件设备也被抽象成了⽂件,你可以使⽤访问⽂件的⽅法访 问它们获得信息;甚⾄管道,也是⽂件;将来我们要学习⽹络编程中的socket(套接字)这样的东西, 使⽤的接⼝跟⽂件接⼝也是⼀致的。

开发者仅需要使⽤⼀套API和开发⼯具,即可调取Linux系统中绝⼤部分的 资源。举个简单的例⼦,Linux中⼏乎所有读(读⽂件,读系统状态,读PIPE)的操作都可以⽤ read 函数来进⾏;⼏乎所有更改(更改⽂件,更改系统参数,写PIPE)的操作都可以⽤ 数来进⾏。它们被操作系统抽象成了"可以像文件那样访问的对象"。

"一切皆文件"不是说 Linux 里所有东西都真的是磁盘上的普通文件。
它真正的意思是:

Linux 把很多不同的资源,尽量用"像操作文件一样"的统一方式来操作。

"一切皆文件"的最大价值是:统一抽象、统一接口、降低学习和使用成本。

第一层:底层到底靠什么实现

内核结构体 struct file 和 struct file_operations。

struct file: 内核里用来表示**"一个被打开的文件对象"的结构体。**

注意,这里说的是"被打开的文件对象",不是磁盘上文件名本身。

当进程执行 open() 时,内核会在内部创建对应的数据结构来管理这次打开行为,其中就有 struct file。它里面有几个你现在要知道的成员:

  • f_pos: f_pos 记录当前读写偏移位置。意思是当前读写位置,也就是"文件指针现在走到哪了"。 比如你已经读了前 100 个字节,那 f_pos 就会往后移动。

  • f_flags:**f_flags 记录打开文件时的一些标志位信息。**意思是: 这个文件是以什么标志打开的,比如: - 只读 - 只写 - 追加 - 非阻塞

  • f_mode:它描述"能怎么用这个文件"。意思是: - 对这个打开文件对象的访问模式 比如可读、可写之类。

- f_op:这个是最重要的。 它指向 struct file_operations。

你可以把 file_operations 理解成:

这类文件支持哪些操作,以及这些操作具体该怎么做。比如这类对象如果支持读,那它就要提供"读"的实现函数。 如果支持写,就要提供"写"的实现函数。 如果支持定位,就要提供 llseek 的实现函数。

所以你会看到里面有很多函数指针: - read - write - open - llseek - mmap - ioctl - release

-flush - fsync

这里最关键的理解只有一句:

用户调用的是统一的系统调用接口,但内核会根据这个对象对应的 file_operations,跳到不同的底层实现。

第二层:为什么不同东西都能用 read/write,但内部行为却不同

你表面上看到的是: read(fd, buf, size); write(fd, buf, size);

看起来好像读普通文件、读键盘、读管道、读 socket 都一样。

但实际上它们内部根本不是一套逻辑。

比如:

  • 读普通文件:从磁盘文件数据中取内容

  • 读键盘:从终端输入缓冲里取内容

  • 读管道:从内核管道缓冲区里取内容

  • 读 socket:从网络接收缓冲区里取内容

为什么你还能统一写成 read?

因为:

  • 对用户来说,系统调用入口统一了

  • 对内核来说,不同对象的 file_operations->read 不一样

也就是说: 接口统一了,实现没有统一。不同资源可以共用同一个接口名,但底层实现函数可以完全不同。

这就是 Linux 抽象的强大之处。

第三层 :file_operations 到底在扮演什么角色

file_operations 是系统调用和具体设备/文件实现之间的桥。

你调用 read(fd, ...) 时,不是直接神奇地就读到了。

中间大致发生的事情是:

  1. 你传进来一个 fd

  2. 内核先根据 fd 找到对应的 struct file

  3. 再从 struct file 里找到 f_op

  4. 再看 f_op->read 指向哪个具体函数

  5. 然后去执行那个具体函数

即: fd 先找到 struct file --> struct file 里有 f_op --> f_op 决定这个对象的具体读写行为即具体操作函数

所以你看到的 read 是统一的, 但真正干活的是后面那个被回调的具体实现函数。

核心:

**- 每个外设都可以有自己的 read/write

  • 但对程序员暴露出来的接口形式尽量统一**

总结

  • "一切皆文件"不是说所有东西都是普通磁盘文件

  • 它真正表示:很多资源都被抽象成了可按文件方式访问的对象

  • 统一接口的好处是降低学习和使用成本

  • 同样的 read/write,底层实现可以不同

  • struct file 表示内核中的已打开文件对象

  • struct file_operations 决定这个对象支持哪些操作以及如何实现

  • f_pos:当前读写位置

  • f_flags:打开标志

  • f_mode:访问模式

  • f_op:操作方法表,最关键

缓冲区

缓冲区就是先把数据暂时放在内存里,不马上和设备打交道。 比如:

  • 你想写文件,不一定每写 1 个字节就立刻写磁盘

  • 你想读文件,也不一定每读 1 个字节就立刻找磁盘拿

  • 可以先在内存里攒一批,再一次性处理

这块"先临时放一下数据的内存",就叫缓冲区。

这节主要想讲 5 件事:

  1. 缓冲区是什么

  2. 为什么要有缓冲区

  3. 缓冲区有哪几种工作方式

  4. 为什么 printf 和 write 行为不一样

  5. 为什么 FILE* 和 fd 一定有关系

什么是缓冲区

缓冲区就是内存里预留出来的一块空间,用来临时存放输入或输出的数据

分两种理解:

  • 输入缓冲区:先把外部数据放进内存,再给程序慢慢用

  • 输出缓冲区:程序先把数据写进内存,之后再统一发给外设

你可以把它理解成"中转站"。 比如写文件时:没缓冲区:你每写一次,系统就得真去写一次磁盘

有缓冲区:你先写进内存,等积累一些再统一写磁盘

这个"先攒一会"的地方,就是缓冲区。

  • 缓冲区的本质是内存空间

  • 它是输入输出过程中的中间层

  • 它存在的目的,是暂存数据,不是永久存储数据

  • 缓冲区 = 内存中的临时存储区域

  • 输入缓冲区:先接住输入数据

  • 输出缓冲区:先攒住输出数据

为什么要引入缓冲区机制

缓冲区的核心价值是提升效率,让快的 CPU 和慢的设备之间更协调。

  • 缓冲区的根本目的是提高效率

  • 它减少系统调用次数

  • 它减少设备访问次数

  • 它协调"CPU 快、外设慢"的速度矛盾

  • 引入缓冲区的原因:减少系统调用、减少上下文切换、减少设备访问、提高整体

效率

  • 缓冲区本质是时间换空间,或者说用内存换效率

缓冲类型

标准 I/O 的 3 种缓冲方式:
  1. 全缓冲: 缓冲区不满,就先不真正输出。等缓冲区满了,或者你主动刷新,才真正进行 I/O。适合普通文件

常见场景: - 普通磁盘文件 为什么磁盘文件常用全缓冲? 因为磁盘本来就慢,最适合"攒一批再统一写"。

  • 全缓冲:满了再刷

  • 适合普通文件

  1. 行缓冲: 遇到换行 \n 时就刷新。 当然,如果缓冲区先满了,也会刷新。

常见场景: - 终端上的标准输入输出 为什么终端适合行缓冲?因为终端交互需要"别太慢",但也没必要每个字符都立刻系统调用。

例如:

printf("hello\n"); 因为有换行,通常能很快看到输出。 但如果你写: printf("hello"); 可能就不一定立刻看到。

  • 行缓冲:遇到换行就刷新

  • 常见于终端交互

  1. 无缓冲:不在 C 标准库这一层先攒数据,直接尽快交给系统调用。 - stderr 通常是不带缓冲区的,因为错误信息通常希望马上看到,不能憋着。
  • 无缓冲:尽快输出

  • 常见于 stderr

了默认规则,还会在什么时候刷新: 1. 缓冲区满了 2. 显式调用刷新语句,比如 fflush

几个常见触发点:

  • fflush(stdout)

  • fclose(fp)

  • 程序正常退出时,标准库通常也会做刷新

同样是输出,什么时候真正写出去,取决于缓冲策略。

cpp 复制代码
 #include <stdio.h>
 #include <string.h>
 #include <sys/types.h>
 #include <sys/stat.h>
 #include <fcntl.h>
 #include <unistd.h>
 int main() {
 close(1);
 int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
 if (fd < 0) {
 perror("open");
 return 0;
 }
 printf("hello world: %d\n", fd);
 close(fd);
 return 0;
 }

本来你以为:1 已经被重定向到 log.txt, printf 往标准输出写,所以文件里应该有内容

但结果文件居然空的。原因是:

printf 不是直接写文件,它先写到 C 库的缓冲区里。 而这时标准输出已经不连终端了,而是连到普通文件。于是 stdout 的缓冲方式从"行缓冲"变成了"全缓冲"。

于是即使你打印了: printf("hello world: %d\n", fd); 带了换行也没用,因为现在不是终端,不按"行缓冲"规则来了。它会按"全缓冲"走:

  • 缓冲区没满

  • 你又没 fflush

  • 内容就还留在用户缓冲区里

  • 后面直接 close(fd),关的是底层 fd,不是 stdout 这个 FILE*

  • 所以 stdout 自己的缓冲数据可能没来得及正确冲出去

这就是为什么文件空了。解决方法: fflush(stdout); 这一句就是强制把 stdout 的用户级缓冲刷到底层去。

即:- printf 走的是 stdout

  • stdout 有自己的 C 库缓冲

  • 重定向到普通文件后,stdout 常变成全缓冲

  • 所以内容可能先留在内存里,不会立刻进文件

  • fflush(stdout) 能强制刷出去

还有⼀种解决⽅法,刚好可以验证⼀下stderr是不带缓冲区的,代码如下:

cpp 复制代码
#include <stdio.h>
 #include <string.h>
 #include <sys/types.h>
 #include <sys/stat.h>
 #include <fcntl.h>
 #include <unistd.h>
 int main() {
 close(2);
 int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
 if (fd < 0) {
 perror("open");
return 0;
 }
 perror("hello world");
 close(fd);
 return 0;
 }

stderr 通常是不带缓冲的。 所以错误信息一产生,标准库基本立刻交给底层输出,不会像 stdout 那样先憋在用户缓冲区里。

  • stdout 常常有缓冲

  • stderr 常常无缓冲

  • 所以错误信息更容易"立刻看到"

FILE

这一小节是整个缓冲区章节最关键、最难但也最值的部分。

因为IO相关函数与系统调⽤接⼝对应,并且库函数封装系统调⽤,所以本质上,访问⽂件都是通 过fd访问的。 • 所以C库当中的FILE结构体内部,必定封装了fd。

cpp 复制代码
 #include <stdio.h>
 #include <string.h>
 int main()
 {
 const char *msg0="hello printf\n";
 const char *msg1="hello fwrite\n";
 const char *msg2="hello write\n";
 printf("%s", msg0);
 fwrite(msg1, strlen(msg0), 1, stdout);
 write(1, msg2, strlen(msg2));
 fork();
 }
 return 0;

正常在终端运行时,大概看到:printf fwrite write 各输出一次

但如果你把输出重定向到文件:./hello > file 就会发现: write 只出现一次 printf 和 fwrite 可能各出现两次。原因分两层:

第一层:printf/fwrite 有用户级缓冲

它们是 C 标准库函数,不是直接的系统调用。所以它们先把数据写入 stdout 对应的 FILE 缓冲区。当输出被重定向到普通文件后,stdout 往往是全缓冲。于是:

  • printf/fwrite 写的数据先留在缓冲区

  • 还没真正写到文件

第二层:fork 会复制进程用户空间

fork() 后:

  • 子进程会得到父进程用户空间的一份副本

  • 缓冲区里的那份"还没刷出去的数据"也被复制了

于是父子进程退出时都会刷新自己的缓冲区。结果就是:

  • 同一份缓冲数据被刷了两次

  • 所以 printf/fwrite 输出重复了

那为什么 write 不重复?因为: write 是系统调用,不走 C 库的这一层用户级缓冲。

它调用时就直接往内核那边去了。所以在 fork 之前,它早就写完了,不会在用户缓冲区里留一份待刷新的副本。

这部分一定要理解透:

  • printf/fwrite 有 C 库缓冲

  • write 没有 C 库这一层缓冲

  • fork 复制的是用户空间数据

  • 所以会复制 printf/fwrite 留在缓冲区里的内容

  • 不会复制已经通过 write 交出去的那种"未刷新用户缓冲"

这就是整节最难但最重要的地方。

总结

  1. printf/fwrite 和 write 不是同一层的东西

  2. printf/fwrite 有 C 标准库提供的用户级缓冲

  3. write 是系统调用,不带这层缓冲

  4. FILE* 内部一定封装了 fd 和缓冲信息

什么说 FILE 内部一定封装了 fd?因为:

  • printf/fwrite 最终也得写到某个具体目标

  • 底层真正访问文件还是要靠 fd

  • 所以 FILE* 内部不可能脱离 fd 存在

FILE 不是"和 fd 无关的另一套系统",而是"库层对 fd 的封装 + 加上缓冲区等管
理信息"。

简单设计一下 libc 库

一个 C 标准库文件对象,最少就应该包含这几类信息:

  1. 底层文件描述符 fd

  2. 一块缓冲区

  3. 当前缓冲区里用了多少

  4. 刷新策略是什么

  5. 什么时候把缓冲区内容真正写出去

作为学习者,不需要手写完整 libc, 但必须理解这个设计思想。

  • FILE 不是只有一个"文件句柄"那么简单

  • 它至少包含 fd + buffer + flush policy

  • C 标准库就是靠这层封装,在系统调用上面提供更方便的 IO

  • FILE = fd + 缓冲区 + 状态信息 + 刷新策略

  • 库函数是在系统调用基础上的再封装

总结

缓冲区,最核心的理解链

  1. write 是系统调用,直接更靠近 OS

  2. printf/fwrite 是库函数,在 write 上面又包了一层

  3. 这层额外封装里最重要的功能之一,就是"用户级缓冲区"

  4. 所以 printf/fwrite 和 write 的行为可能不同

  5. fork 时,用户空间缓冲区会被复制

  6. 于是可能出现重复输出

  7. FILE* 内部因此一定封装了 fd 和缓冲信息

优先级最高的 8 条:

  • 缓冲区是内存中的临时数据区

  • 引入缓冲区是为了提高效率

  • 全缓冲:满了才刷

  • 行缓冲:遇到换行刷

  • 无缓冲:尽快刷

  • stdout 连终端时常是行缓冲,连普通文件时常变全缓冲

  • stderr 通常无缓冲

  • fflush(stdout) 用来强制刷新

第二优先级:

  • printf/fwrite 有 C 库用户级缓冲

  • write 没有这层用户级缓冲

  • fork 会复制用户空间缓冲区

  • FILE = fd + buffer + 状态管理

必须掌握哪些内容

最低要求:

  • 知道缓冲区是内存中的临时存储区

  • 知道缓冲区的作用是提高效率

  • 知道 3 种缓冲:全缓冲、行缓冲、无缓冲

  • 知道 stdout 和 stderr 的默认缓冲策略可能不同

中等要求:

  • 理解为什么重定向后 printf 不一定立刻写进文件

  • 理解 fflush(stdout) 的作用

  • 理解 printf/fwrite 和 write 的区别在"有没有 C 库缓冲"

较好掌握:

  • 理解 fork 为什么会导致缓冲内容重复输出

  • 理解 FILE* 内部封装了 fd

  • 理解 glibc 在系统调用之上加了一层用户级 IO 管理

相关推荐
木易 士心1 小时前
Java中 synchronized 和 volatile 详解
java·开发语言·jvm
小码狐1 小时前
Spring相关知识【知识整理】
java·后端·spring
洛菡夕1 小时前
Linux系统安全
linux
巫山老妖1 小时前
多 Agent 协作实战:我用 3 只龙虾组了个「AI小分队」,效率直接翻倍
java·前端
xienda1 小时前
Spring Boot 核心定义与用处
java·spring boot·后端
DyLatte1 小时前
理性到最后,其实是一场下注
前端·后端·程序员
橘哥哥1 小时前
vue中读取静态配置文件中内容
前端·javascript·vue.js
废嘉在线抓狂.2 小时前
简易拆开即用的高性能计时器(C#)
前端·unity·c#
yuguo.im2 小时前
91 行代码实现一个打飞机游戏(HTML5 Canvas 版)
前端·游戏·html5·打飞机