文件描述符基础详解 —— Linux"一切皆文件"的真正起点
在 Linux 里,文件、socket、管道、设备、定时器、信号 …… 几乎所有 I/O 资源都用一个小整数表示:文件描述符(file descriptor,fd)。这篇把
open / read / write / close / lseek / dup / fcntl这套 fd 操作的基础讲清楚,理解之后再看高级特性(多路复用、mmap、io_uring)都会顺很多。
0. 引言:"一切皆文件"是什么意思
intfd1=open("/tmp/data",O_RDONLY);/* 普通文件 */intfd2=socket(AF_INET,SOCK_STREAM,0);/* 网络 socket */intfd3=open("/dev/tty",O_RDWR);/* 终端设备 */intfd4;intpipefd[2];pipe(pipefd);/* 管道 */fd4=pipefd[0];charbuf[100];read(fd1,buf,100);/* 读文件 */read(fd2,buf,100);/* 收网络包 */read(fd3,buf,100);/* 读键盘 */read(fd4,buf,100);/* 读管道 */四种完全不同的资源,用同一个read函数和同一种 fd 类型操作。这就是 Unix "一切皆文件"哲学的精髓 —— 用一套 API 屏蔽所有 I/O 资源的差异。
1. fd 到底是什么
fd是个小整数(一般 0-1023),是当前进程文件描述符表的下标。
进程的 fd 表(fd是整数索引,内核中每个槽存一个struct file *):
| fd | 指向 |
|---|---|
| 0 | struct file(stdin) |
| 1 | struct file(stdout) |
| 2 | struct file(stderr) |
| 3 | struct file(/tmp/data) |
| 4 | struct file(socket) |
| … | … |
每个槽指向内核的struct file,里面包含:偏移 / 引用计数 / 操作函数表 / 打开模式等。
每个 fd 在用户空间是一个 int,对应内核里的struct file。struct file又指向struct inode(真正的文件元数据)。多个 fd 可能指向同一个 file(dup 出来的),多个 file 可能指向同一个 inode(不同进程 open 同一个文件)。
1.1 三个标准 fd
#defineSTDIN_FILENO0#defineSTDOUT_FILENO1#defineSTDERR_FILENO2进程启动时由 shell / init 准备好,分别连到终端的输入、输出、错误流。
write(1,"hello\n",6);/* 等价 printf("hello\n") + flush */1.2 fd 是可继承的
fork后子进程继承父进程所有 fd(指向同一个 file,共享偏移)。exec后默认也保留(除非设了O_CLOEXEC)。
2. open —— 打开文件,拿到 fd
#include<fcntl.h>intopen(constchar*path,intflags,.../* mode_t mode */);intopenat(intdirfd,constchar*path,intflags,...);2.1 必选 flags:模式
| flag | 含义 |
|---|---|
O_RDONLY | 只读 |
O_WRONLY | 只写 |
O_RDWR | 读写 |
2.2 常用可选 flags
| flag | 含义 |
|---|---|
O_CREAT | 不存在则创建(要配mode参数) |
O_EXCL | 配合O_CREAT:已存在则失败 |
O_TRUNC | 已存在则截断到 0 |
O_APPEND | 写时追加(每次write自动lseek到末尾) |
O_NONBLOCK | 非阻塞 IO |
O_CLOEXEC | exec时自动关闭 |
O_DIRECTORY | 必须是目录 |
O_PATH | 只用作目录 fd,不读不写 |
O_TMPFILE | 创建匿名临时文件(Linux 扩展) |
O_DSYNC/O_SYNC | 写后同步刷盘 |
2.3 例子
/* 只读打开 */intfd=open("/etc/passwd",O_RDONLY);/* 写、不存在就创建、权限 0644 */intfd=open("out.txt",O_WRONLY|O_CREAT|O_TRUNC,0644);/* 打开但 exec 后关闭,避免泄漏到子进程 */intfd=open("/var/log/app.log",O_WRONLY|O_APPEND|O_CREAT|O_CLOEXEC,0644);/* 安全的"创建独占"模式,避免覆盖 */intfd=open("lockfile",O_WRONLY|O_CREAT|O_EXCL,0600);if(fd<0&&errno==EEXIST){/* 文件已存在 */}2.4 O_CLOEXEC 是必选
多线程程序里 open 不带 O_CLOEXEC 是潜在 bug:另一个线程可能正在 fork+exec,你刚 open 的 fd 会被泄漏到子进程。这是经典的安全漏洞来源。
intfd=open(path,O_RDONLY|O_CLOEXEC);/* 写就对了 */3. read / write —— 数据搬运
#include<unistd.h>ssize_tread(intfd,void*buf,size_tcount);ssize_twrite(intfd,constvoid*buf,size_tcount);返回值:
> 0:实际读/写的字节数0:read 时表示 EOF;write 时一般不返回 0-1:出错,看 errno
3.1 短读 / 短写:必须循环
read不保证一次读满count字节:
- 文件:可能读到末尾就提前返回
- 管道 / socket:对端没那么多数据,先返回已有的
- 信号中断:
EINTR
write不保证一次写完count字节:
- 内核缓冲满(pipe/socket)
- 信号中断
- 磁盘满(部分写入)
正确写 IO 必须循环:
ssize_twrite_all(intfd,constvoid*buf,size_tn){constchar*p=buf;while(n>0){ssize_tw=write(fd,p,n);if(w<0){if(errno==EINTR)continue;/* 信号打断,重试 */return-1;}p+=w;n-=w;}return0;}ssize_tread_all(intfd,void*buf,size_tn){char*p=buf;while(n>0){ssize_tr=read(fd,p,n);if(r<0){if(errno==EINTR)continue;return-1;}if(r==0)break;/* EOF */p+=r;n-=r;}returnp-(char*)buf;}3.2 pread / pwrite:带偏移的版本
ssize_tpread(intfd,void*buf,size_tcount,off_toffset);ssize_tpwrite(intfd,constvoid*buf,size_tcount,off_toffset);不修改 fd 的当前偏移。多线程并发读同一文件不同位置时必须用,否则 read + lseek 不原子。
3.3 readv / writev:scatter-gather
ssize_treadv(intfd,conststructiovec*iov,intiovcnt);ssize_twritev(intfd,conststructiovec*iov,intiovcnt);structiovec{void*iov_base;size_tiov_len;};一次系统调用读/写多块不连续内存:
structioveciov[3];iov[0].iov_base=header;iov[0].iov_len=sizeof(header);iov[1].iov_base=body;iov[1].iov_len=body_len;iov[2].iov_base=footer;iov[2].iov_len=sizeof(footer);writev(fd,iov,3);/* 一次系统调用写完三块 */减少 syscall 次数,适合协议封包。
4. close —— 必须关,但不一定立刻关
intclose(intfd);关闭一个 fd。但是:
- 如果该 file 被多个 fd 共享(dup 出来的),只是引用计数 -1
- 引用计数归 0 时才真的释放底层资源
intfd1=open("a",O_RDONLY);intfd2=dup(fd1);close(fd1);/* 文件还活着,fd2 可以继续用 */read(fd2,...);close(fd2);/* 现在文件真的关了 */close 失败也通常意味着已关。errno 可能是 EIO(之前 write 的延迟错误现在才报),但 fd 本身已无效,不要重试 close。
5. lseek —— 改文件偏移
#include<unistd.h>off_tlseek(intfd,off_toffset,intwhence);/* whence: * SEEK_SET - 从文件开头 * SEEK_CUR - 从当前偏移 * SEEK_END - 从文件末尾 */例子:
lseek(fd,0,SEEK_END);/* 文件末尾,返回文件大小 */off_tcur=lseek(fd,0,SEEK_CUR);/* 拿当前偏移 */lseek(fd,100,SEEK_SET);/* 跳到 100 字节处 */lseek(fd,-1024,SEEK_END);/* 末尾前 1KB */5.1 lseek 不能用于 pipe/socket/fifo
它们没有"位置"概念。返回 -1 + ESPIPE。
5.2 文件空洞
lseek(fd,1*1024*1024*1024,SEEK_SET);write(fd,"x",1);/* 中间 1GB 是"空洞",磁盘上不占空间 */读空洞会得到 0 字节。
6. dup / dup2 / dup3 —— 复制 fd
intdup(intoldfd);intdup2(intoldfd,intnewfd);intdup3(intoldfd,intnewfd,intflags);让两个 fd 指向同一个内核 file(共享偏移、模式、锁)。
6.1 经典用法:重定向 stdin/stdout
/* 把 stdout 重定向到文件 */intfd=open("out.log",O_WRONLY|O_CREAT|O_TRUNC,0644);dup2(fd,STDOUT_FILENO);/* 关闭原 stdout,让 1 指向 fd 指向的 file */close(fd);/* 不再需要原 fd,文件还活着 */printf("this goes to out.log\n");6.2 shell 的>重定向
ls>out.txtshell 内部:
pid_tpid=fork();if(pid==0){intfd=open("out.txt",O_WRONLY|O_CREAT|O_TRUNC,0644);dup2(fd,STDOUT_FILENO);close(fd);execvp("ls",argv);}6.3 shell 的管道|
A | B内部:
intp[2];pipe(p);if(fork()==0){/* 进程 A */dup2(p[1],STDOUT_FILENO);/* stdout → 管道写端 */close(p[0]);close(p[1]);execvp("A",...);}if(fork()==0){/* 进程 B */dup2(p[0],STDIN_FILENO);/* stdin → 管道读端 */close(p[0]);close(p[1]);execvp("B",...);}close(p[0]);close(p[1]);wait(NULL);wait(NULL);6.4 dup3:原子设 flags
dup3(oldfd,newfd,O_CLOEXEC);/* 原子 dup + 设 close-on-exec */跟pipe2同思路,避免 race window。多线程程序首选。
7. fcntl —— fd 杂项操作
#include<fcntl.h>intfcntl(intfd,intcmd,.../* arg */);万能 fd 操作工具,常用的几个:
7.1 F_DUPFD / F_DUPFD_CLOEXEC:dup 加强版
intnewfd=fcntl(oldfd,F_DUPFD,100);/* 找 ≥100 的最小可用 fd */intnewfd=fcntl(oldfd,F_DUPFD_CLOEXEC,0);/* 同时设 CLOEXEC */7.2 F_GETFD / F_SETFD:fd 标志(仅有 FD_CLOEXEC)
intflags=fcntl(fd,F_GETFD);fcntl(fd,F_SETFD,flags|FD_CLOEXEC);/* 老办法,今天用 O_CLOEXEC 更好 */7.3 F_GETFL / F_SETFL:文件状态标志
intflags=fcntl(fd,F_GETFL);fcntl(fd,F_SETFL,flags|O_NONBLOCK);/* 改非阻塞 */fcntl(fd,F_SETFL,flags&~O_NONBLOCK);/* 改回阻塞 */7.4 F_SETLK / F_SETLKW:建议性文件锁
structflocklk={.l_type=F_WRLCK,/* 写锁 */.l_whence=SEEK_SET,.l_start=0,.l_len=0,/* 0 = 锁到末尾 */};fcntl(fd,F_SETLKW,&lk);/* 阻塞获取 *//* ... */lk.l_type=F_UNLCK;fcntl(fd,F_SETLK,&lk);仅在所有进程都用 fcntl 锁时才有效(advisory lock,建议性)。
7.5 F_SETPIPE_SZ / F_GETPIPE_SZ:调 pipe 缓冲大小
fcntl(pipe_fd,F_SETPIPE_SZ,1024*1024);/* pipe 缓冲调到 1MB */8. 文件描述符表 vs 打开文件表 vs i-node 表
Linux 内核有三层数据结构:
理解这三层后,下面这些行为就解释通了:
8.1 fork 后父子共享 file,同一个 offset
intfd=open("a",O_RDONLY);fork();read(fd,buf,10);/* 父和子都读这个 fd,谁先读谁拿前 10 字节 */read(fd,buf,10);/* 另一个再读,从第 10 字节开始 */8.2 dup 后两个 fd 共享 file,同一 offset
intfd2=dup(fd1);read(fd1,buf,10);read(fd2,buf,10);/* 接着读,offset 继续走 */8.3 两次 open 同一个文件 → 两个 file,不同 offset
inta=open("x",O_RDONLY);intb=open("x",O_RDONLY);read(a,buf,10);/* a 的 offset = 10 */read(b,buf,10);/* b 的 offset = 10(独立)*/9. /proc/PID/fd —— 看进程打开了什么
调试神器:
$ls-l/proc/$$/fd0->/dev/pts/01->/dev/pts/02->/dev/pts/03->/var/log/app.log4->socket:[123456]5->pipe:[789012]每个进程的 fd 都能在这里看到,文件名也能反查。调"fd 泄漏"必备。
lsof -p PID是更友好的工具。
10. 几个常见坑
10.1 fd 泄漏
intfd=open(...);if(something_failed)return-1;/* ⚠️ fd 没 close */每个错误路径都要 close。C 没 RAII,纪律 + cleanup_push + goto 错误处理。
10.2 close 后 fd 立即可被复用
close(3);/* 如果在其他地方还在用 fd 3(跨线程、信号处理),现在再 read(3, ...) 可能读到刚 open 的新文件!*/intnewfd=open(...);/* 经常拿到 3 */close 后立刻fd = -1,避免 use-after-close。
10.3 短读短写没循环
90% 的 IO bug 来源。所有生产代码都要read_all/write_all包装。
10.4 忘记 O_CLOEXEC
多线程 + fork+exec 场景的隐藏 bug。
10.5 用 lseek + read 代替 pread(多线程)
两个线程并发:Alseek+ Blseek+ Aread+ Bread,A 的偏移被 B 改了。多线程同 fd 用 pread/pwrite。
10.6 不同进程 mmap 同一文件 vs read
mmap 的更新对其他进程立即可见(共享映射);read 是拷贝快照。
11. 总结表
| 函数 | 用途 | 关键点 |
|---|---|---|
open / openat | 打开文件,返回 fd | flags 必有模式;推荐加 O_CLOEXEC |
read / write | 字节搬运 | 必须循环处理短读短写 |
pread / pwrite | 带偏移版本 | 多线程并发同 fd 必备 |
readv / writev | scatter-gather | 一次 syscall 多块内存 |
close | 关闭 | 失败也算关;fd = -1防 use-after-close |
lseek | 改文件偏移 | pipe/socket 不支持 |
dup / dup2 / dup3 | 复制 fd | shell 重定向、管道用 |
fcntl | 杂项操作 | 改 flags、文件锁、pipe size 等 |
/proc/PID/fd | 看进程打开了什么 | 调试 fd 泄漏神器 |
12. 收尾
fd 是 Linux 系统编程的最核心抽象。所有更高级的概念都建立在 fd 之上:
- 多路复用(select/poll/epoll)操作 fd 集合
- mmap 把文件映射成内存,但起点还是 fd
- 网络编程的 socket fd
- 异步 IO(io_uring)也是用 fd
理解了 fd 的三层表(fd 表 → file 表 → inode 表),fork 共享、dup 复制、O_CLOEXEC 这些细节就一目了然。理解了短读短写要循环、所有错误路径都要 close,写出来的 IO 代码就稳。