操作系统学习笔记 · 第 6 课 · 文件描述符——"一切皆文件"
从这一课开始,我们进入第二个阶段——系统编程入门。前面我们站在高处看操作系统,现在要蹲下来,摸一摸它最常用的"零件"。第一个要认识的,是贯穿整个 Unix/Linux 世界的一个哲学:一切皆文件。而连接你程序和这些"文件"的,是一张小小的号码牌——文件描述符。
从"程序怎么读写文件"说起
你现在写一个 C 程序,要往硬盘上写点东西。你会怎么写?
int fd = open("hello.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write(fd, "hello os\n", 9);
close(fd);注意这个 open 的返回值 fd——它不是文件名,不是路径,而是一个整数。之后你要写数据,不再说"往 hello.txt 写",而是说"往这个号码写"。这个整数,就是这一课的主角:文件描述符(File Descriptor,简称 fd)。
6.1 fd 是什么:打开文件的"号码牌"
一句话理解:你每次 open 一个文件,内核给你发一个号码牌(一个非负整数),以后读写都用这个号码,不用再重复填路径。这个号码就是文件描述符。打个比方:你去图书馆存包,把包交给管理员,管理员给你一个号码牌。之后你要取包,报号码就行,不用每次都描述"我那个黑色的、上面有拉链的包"。
文件描述符就是那个号码牌:
- 你调用
open("hello.txt", ...),内核在内部记录下"这个号码对应 hello.txt 这个文件、可写、当前读到哪了"等信息; - 返回给你一个整数(比如
3); - 之后
write(3, ...)就是"往 3 号这个文件写"。
为什么这个设计很妙?因为把"文件"这件事,从一长串路径,简化成了一个整数。你程序里传一个 int 就够,简单、统一、高效。
6.2 标准三流:0/1/2 的约定
这里有个非常有意思的细节:每个进程一出生,内核就自动帮它打开了 3 个 fd,号码是固定的 0、1、2。
| 号码 | 名字 | 全称 | 默认指向 |
|---|---|---|---|
0 | stdin | 标准输入 | 键盘 |
1 | stdout | 标准输出 | 屏幕 |
2 | stderr | 标准错误 | 屏幕 |
这就是为什么你平时用 printf 输出到屏幕——printf 默认往 fd 1(stdout) 写;而程序报错,往 fd 2(stderr) 写。
关键点:正常输出和错误输出是分开的两个 fd。所以你可以做到"把正常结果存到文件里,但把错误信息显示在屏幕上"——因为它们走的是不同的号码牌。
现在回头看你写的那行 write(1, "这是标准输出\n", ...) 和 write(2, "这是标准错误\n", ...),就一目了然了:你是在往 1 号和 2 号这两个"默认打开的 fd"里写东西。
6.3 重定向的本质:换掉 fd 指向
你肯定在命令行里用过重定向:
ls > out.txt这行命令背后的本质,是我们这课最核心的洞察之一:
>做的,是把 fd 1 从"屏幕"换成"指向 out.txt 文件"。程序ls本身一行代码都没改,它照样往 fd 1 写,只是此时 fd 1 指向的地方变了。
ls 根本不知道、也不关心自己的输出最后去了屏幕还是文件。它只负责"往 fd 1 写"。真正决定输出去向的,是这个 fd 指向谁。 这就是重定向的全部秘密。
同理:
cat < input.txt # 把 fd 0 从键盘换成指向 input.txt 文件
ls 2> error.txt # 把 fd 2 从屏幕换成指向 error.txt(只重定向错误输出)一句话理解:重定向不是改变程序的行为,而是改变 fd 的指向。程序没变,号码牌对应的"取包处"变了。
6.4 为什么说"一切皆文件"
现在可以点题了。Unix/Linux 有一个著名的设计哲学:一切皆文件(Everything is a file)。
它的意思是:普通文件、键盘、屏幕、磁盘、管道、网络 socket……统统被抽象成"文件",统一用 fd 来操作。
带来的好处是巨大的——编程模型被极度简化:
- 写硬盘文件:
write(fd, ...) - 发网络数据:
write(fd, ...) - 写管道(进程间通信):
write(fd, ...)
底层是同一套 write(fd, ...),只是 fd 指向的东西不同。你学会一个 write,就同时会了"写文件、发网络、写管道"三件事。这就是"一切皆文件"的力量——用一套统一的接口,覆盖所有 I/O。
这个哲学你会在后面不断遇见:第 6 课用它读写文件,第 9 课用它做进程间通信,第 22 课用它写网络程序。记住"一切皆文件",就等于拿到了一把能开很多锁的钥匙。
动手实验:C 版 + Python 对照
C 版(注意要 #include <fcntl.h> 和 <unistd.h>):
// lesson06.c —— 打开/读写/关闭文件
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main(void) {
// 打开(不存在则创建),拿到 fd
int fd = open("hello.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) { perror("open"); return 1; }
write(fd, "hello os\n", 9); // 写
close(fd); // 关闭,释放 fd
// 再看标准三流
write(1, "这是标准输出\n", 22); // fd 1 = stdout
write(2, "这是标准错误\n", 22); // fd 2 = stderr
return 0;
}编译运行:
gcc lesson06.c -o lesson06 && ./lesson06你会看到屏幕上出现两行(标准输出和标准错误都默认指向屏幕),同时 hello.txt 里被写入了 hello os。
再试试这招,观察重定向如何"拆开"两条流:
./lesson06 2>/dev/null # 把 fd 2(stderr)丢进黑洞,stdout 还在你会发现:这是标准错误 这一行消失了,而 这是标准输出 还在。因为 2>/dev/null 把 2 号 fd 指向了 /dev/null(一个"吞掉一切"的特殊文件),但 1 号 fd 没动。
Python 对照版:
import os, sys
fd = os.open("hello.txt", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
os.write(fd, b"hello os\n")
os.close(fd)
sys.stdout.write("这是标准输出\n") # 对应 fd 1
sys.stderr.write("这是标准错误\n") # 对应 fd 2你看,Python 里也是同样的 os.open → os.write → os.close,同样区分 stdout/stderr。换了语言,换不掉"一切皆文件"的底层逻辑。
深入点:两个容易踩坑的细节
① fd 表与偏移量
每个 fd 不只记录"指向哪个文件",还记录一个偏移量——"现在读/写到文件的哪个位置了"。
这本身很方便(你不用每次重新定位),但也埋了个雷:如果多个进程(或 fork 后的父子进程)共享同一个 fd,它们会共享这个偏移量。于是你写一点、它写一点,最后文件内容可能交错错位。很多"读写错位"的诡异 bug,根源就在这里。
② fd 复用:最小可用原则
close(fd) 之后,这个号码就被"注销"了。下一次你 open 新文件,内核会把最小可用的号码发给你。
所以一个很经典的细节是:如果你 close(1)(关掉 stdout),然后 open 一个新文件,这个新文件会拿到 1 号——于是原本该输出到屏幕的内容,就"神不知鬼不觉"地写进了这个文件。这其实正是 shell 实现重定向的底层手段(第 7 课 fork+exec 时会看到它的威力)。
小结与思考题
这一课,我们认识了 Unix 最核心的抽象——文件描述符:
- fd 是打开文件的"号码牌",一个非负整数,读写都靠它;
- 每个进程出生自带 0/1/2 三个 fd:stdin/stdout/stderr;
- 重定向的本质是换掉 fd 的指向,而不是改变程序的行为;
- "一切皆文件":文件、键盘、屏幕、管道、socket 统一用
write(fd, ...)操作,编程模型被极大简化。
留三个问题:
- 用一句话解释 fd 是什么。
- 为什么 stdin/stdout/stderr 是 0/1/2?
ls > out.txt到底改变了什么?(提示:不是改变了ls的行为)
下一课,我们要回答一个更"魔幻"的问题:shell 敲下ls,到底是怎么"变"出一个进程来跑ls的?答案是一对黄金搭档——fork 和 exec:一个负责"复制",一个负责"变身"。