1. fd 到底是什么
fd 是个小整数(一般 0-1023),是当前进程文件描述符表的下标。
进程的 fd 表(fd 是整数索引,内核中每个槽存一个 struct file *):
| |
|---|
| struct file |
| struct file |
| struct file |
| struct file |
| struct file |
| |
每个槽指向内核的 struct file,里面包含:偏移 / 引用计数 / 操作函数表 / 打开模式 等。
每个 fd 在用户空间是一个 int,对应内核里的 struct file。struct file 又指向 struct inode(真正的文件元数据)。多个 fd 可能指向同一个 file(dup 出来的),多个 file 可能指向同一个 inode(不同进程 open 同一个文件)。
1.1 三个标准 fd
#define STDIN_FILENO 0#define STDOUT_FILENO 1#define STDERR_FILENO 2
进程启动时由 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>int open(const char *path, int flags, ... /* mode_t mode */ );int openat(int dirfd, const char *path, int flags, ...);
2.1 必选 flags:模式
2.2 常用可选 flags
| |
|---|
O_CREAT | |
O_EXCL | |
O_TRUNC | |
O_APPEND | 写时追加(每次 write 自动 lseek 到末尾) |
O_NONBLOCK | |
O_CLOEXEC | exec |
O_DIRECTORY | |
O_PATH | |
O_TMPFILE | |
O_DSYNC | |
2.3 例子
/* 只读打开 */int fd = open("/etc/passwd", O_RDONLY);/* 写、不存在就创建、权限 0644 */int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);/* 打开但 exec 后关闭,避免泄漏到子进程 */int fd = open("/var/log/app.log", O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC, 0644);/* 安全的"创建独占"模式,避免覆盖 */int fd = 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 会被泄漏到子进程。这是经典的安全漏洞来源。
int fd = open(path, O_RDONLY | O_CLOEXEC); /* 写就对了 */
3. read / write —— 数据搬运
#include<unistd.h>ssize_t read(int fd, void *buf, size_t count);ssize_t write(int fd, const void *buf, size_t count);
返回值:
> 00:read 时表示 EOF;write 时一般不返回 0-1
3.1 短读 / 短写:必须循环
read 不保证一次读满 count 字节:
- 管道 / socket:对端没那么多数据,先返回已有的
write 不保证一次写完 count 字节:
正确写 IO 必须循环:
ssize_t write_all(int fd, const void *buf, size_t n) { const char *p = buf; while (n > 0) { ssize_t w = write(fd, p, n); if (w < 0) { if (errno == EINTR) continue; /* 信号打断,重试 */ return -1; } p += w; n -= w; } return 0;}ssize_t read_all(int fd, void *buf, size_t n) { char *p = buf; while (n > 0) { ssize_t r = read(fd, p, n); if (r < 0) { if (errno == EINTR) continue; return -1; } if (r == 0) break; /* EOF */ p += r; n -= r; } return p - (char*)buf;}
3.2 pread / pwrite:带偏移的版本
ssize_t pread (int fd, void *buf, size_t count, off_t offset);ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
不修改 fd 的当前偏移。多线程并发读同一文件不同位置时必须用,否则 read + lseek 不原子。
3.3 readv / writev:scatter-gather
ssize_t readv (int fd, const struct iovec *iov, int iovcnt);ssize_t writev(int fd, const struct iovec *iov, int iovcnt);struct iovec { void *iov_base; size_t iov_len;};
一次系统调用读/写多块不连续内存:
struct iovec iov[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 —— 必须关,但不一定立刻关
关闭一个 fd。但是:
- 如果该 file 被多个 fd 共享(dup 出来的),只是引用计数 -1
int fd1 = open("a", O_RDONLY);int fd2 = dup(fd1);close(fd1); /* 文件还活着,fd2 可以继续用 */read(fd2, ...);close(fd2); /* 现在文件真的关了 */
close 失败也通常意味着已关。errno 可能是 EIO(之前 write 的延迟错误现在才报),但 fd 本身已无效,不要重试 close。
5. lseek —— 改文件偏移
#include<unistd.h>off_t lseek(int fd, off_t offset, int whence);/* whence: * SEEK_SET - 从文件开头 * SEEK_CUR - 从当前偏移 * SEEK_END - 从文件末尾 */
例子:
lseek(fd, 0, SEEK_END); /* 文件末尾,返回文件大小 */off_t cur = 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
int dup(int oldfd);int dup2(int oldfd, int newfd);int dup3(int oldfd, int newfd, int flags);
让两个 fd 指向同一个内核 file(共享偏移、模式、锁)。
6.1 经典用法:重定向 stdin/stdout
/* 把 stdout 重定向到文件 */int fd = 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 的 > 重定向
shell 内部:
pid_t pid = fork();if (pid == 0) { int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); dup2(fd, STDOUT_FILENO); close(fd); execvp("ls", argv);}
6.3 shell 的管道 |
A | B 内部:
int p[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>int fcntl(int fd, int cmd, ... /* arg */ );
万能 fd 操作工具,常用的几个:
7.1 F_DUPFD / F_DUPFD_CLOEXEC:dup 加强版
int newfd = fcntl(oldfd, F_DUPFD, 100); /* 找 ≥100 的最小可用 fd */int newfd = fcntl(oldfd, F_DUPFD_CLOEXEC, 0); /* 同时设 CLOEXEC */
7.2 F_GETFD / F_SETFD:fd 标志(仅有 FD_CLOEXEC)
int flags = fcntl(fd, F_GETFD);fcntl(fd, F_SETFD, flags | FD_CLOEXEC); /* 老办法,今天用 O_CLOEXEC 更好 */
7.3 F_GETFL / F_SETFL:文件状态标志
int flags = fcntl(fd, F_GETFL);fcntl(fd, F_SETFL, flags | O_NONBLOCK); /* 改非阻塞 */fcntl(fd, F_SETFL, flags & ~O_NONBLOCK); /* 改回阻塞 */
7.4 F_SETLK / F_SETLKW:建议性文件锁
struct flock lk = { .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
int fd = open("a", O_RDONLY);fork();read(fd, buf, 10); /* 父和子都读这个 fd,谁先读谁拿前 10 字节 */read(fd, buf, 10); /* 另一个再读,从第 10 字节开始 */
8.2 dup 后两个 fd 共享 file,同一 offset
int fd2 = dup(fd1);read(fd1, buf, 10);read(fd2, buf, 10); /* 接着读,offset 继续走 */
8.3 两次 open 同一个文件 → 两个 file,不同 offset
int a = open("x", O_RDONLY);int b = open("x", O_RDONLY);read(a, buf, 10); /* a 的 offset = 10 */read(b, buf, 10); /* b 的 offset = 10(独立)*/
9. /proc/PID/fd —— 看进程打开了什么
调试神器:
$/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 泄漏
int fd = 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 的新文件!*/int newfd = 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(多线程)
两个线程并发:A lseek + B lseek + A read + B read,A 的偏移被 B 改了。多线程同 fd 用 pread/pwrite。
10.6 不同进程 mmap 同一文件 vs read
mmap 的更新对其他进程立即可见(共享映射);read 是拷贝快照。
11. 总结表
| | |
|---|
open / openat | | |
read / write | | 必须循环处理短读短写 |
pread / pwrite | | |
readv / writev | | |
close | | 失败也算关;fd = -1 防 use-after-close |
lseek | | |
dup / dup2 / dup3 | | |
fcntl | | |
/proc/PID/fd | | |
12. 收尾
fd 是 Linux 系统编程的最核心抽象。所有更高级的概念都建立在 fd 之上:
- 多路复用(select/poll/epoll)操作 fd 集合
理解了 fd 的三层表(fd 表 → file 表 → inode 表),fork 共享、dup 复制、O_CLOEXEC 这些细节就一目了然。理解了短读短写要循环、所有错误路径都要 close,写出来的 IO 代码就稳。