当前位置:首页>Linux>linux系统编程(七):文件描述符基础详解

linux系统编程(七):文件描述符基础详解

  • 2026-10-11 06:14:24
linux系统编程(七):文件描述符基础详解

文件描述符基础详解 —— Linux"一切皆文件"的真正起点

在 Linux 里,文件、socket、管道、设备、定时器、信号 …… 几乎所有 I/O 资源都用一个小整数表示:文件描述符(file descriptor,fd)。这篇把 open / read / write / close / lseek / dup / fcntl 这套 fd 操作的基础讲清楚,理解之后再看高级特性(多路复用、mmap、io_uring)都会顺很多。


0. 引言:"一切皆文件"是什么意思

int fd1 = open("/tmp/data", O_RDONLY);          /* 普通文件 */int fd2 = socket(AF_INET, SOCK_STREAM, 0);      /* 网络 socket */int fd3 = open("/dev/tty", O_RDWR);             /* 终端设备 */int fd4;int pipefd[2]; pipe(pipefd);                    /* 管道 */fd4 = pipefd[0];char buf[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

#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:模式

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_CLOEXECexec
 时自动关闭
O_DIRECTORY
必须是目录
O_PATH
只用作目录 fd,不读不写
O_TMPFILE
创建匿名临时文件(Linux 扩展)
O_DSYNC
 / O_SYNC
写后同步刷盘

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);

返回值:

  • > 0
    :实际读/写的字节数
  • 0
    :read 时表示 EOF;write 时一般不返回 0
  • -1
    :出错,看 errno

3.1 短读 / 短写:必须循环

read 不保证一次读满 count 字节:

  • 文件:可能读到末尾就提前返回
  • 管道 / socket:对端没那么多数据,先返回已有的
  • 信号中断:EINTR

write 不保证一次写完 count 字节:

  • 内核缓冲满(pipe/socket)
  • 信号中断
  • 磁盘满(部分写入)

正确写 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 —— 必须关,但不一定立刻关

int close(int fd);

关闭一个 fd。但是:

  • 如果该 file 被多个 fd 共享(dup 出来的),只是引用计数 -1
  • 引用计数归 0 时才真的释放底层资源
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 的 > 重定向

ls > out.txt

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
打开文件,返回 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 代码就稳。

请在微信客户端打开

最新文章

随机文章