Brilliantwxx头像
【Linux】文件描述符详解 和 动手实现 C 标准库:myStdio 项目学习指南封面图

【Linux】文件描述符详解 和 动手实现 C 标准库:myStdio 项目学习指南


   文件描述符(File Descriptor)超详解:从内核结构到 fork/exec/重定向  

一、再认识文件描述符

        文件描述符是进程打开文件时内核返回的非负小整数,它是 Linux "一切皆文件"的统一入口。

        但要真正理解它,必须深入到内核的多级数据结构,以及它在 fork、exec、线程、重定向中的不同行为。

一句话定位:文件描述符是进程文件描述符表的下标,表项指向一个打开的文件对象。


二、内核数据结构(源码级)

一个文件描述符在内核里要经过四层结构:

task_struct(进程)
    └─ files → files_struct(进程的文件管理)
                  └─ fdtable(文件描述符表)
                        └─ fd[N] → struct file(打开的文件对象)
                                        └─ f_path → dentry → inode(真正的文件)

2.1 files_struct 与 fdtable

每个进程的 task_struct 里有一个 files 指针,指向 struct files_struct:

struct files_struct {
    atomic_t count;                 // 共享该结构的任务数(线程共享)
    struct fdtable __rcu *fdt;      // 指向当前 fdtable
    struct fdtable fdtab;           // 内嵌的 fdtable
    struct file __rcu *fd_array[NR_OPEN_DEFAULT]; // 内嵌 32 个 file 指针
    ...
};

struct fdtable {
    unsigned int max_fds;           // 表的最大容量
    struct file __rcu **fd;         // file 指针数组(fd 就是它的下标)
    unsigned long *close_on_exec;   // 位图:哪些 fd 设了 O_CLOEXEC
    unsigned long *open_fds;        // 位图:哪些 fd 正被占用
    ...
};

关键细节:

  • 进程默认内嵌 32 个 file 指针(NR_OPEN_DEFAULT = 32),当打开的 fd 超过 32 个,内核会重新分配一个更大的 fdtable 并让 fdt 指向它;
  • open_fds 位图记录 fd 的占用情况,分配新 fd 时就在这张位图上找第一个 0;
  • close_on_exec 位图记录哪些 fd 在 exec 时要关闭(后面重点讲)。

2.2 struct file 详解

struct file 表示一个"打开的文件实例",是理解 fd 共享行为的核心:

struct file {
    struct path f_path;             // 含 dentry 和挂载点 vfsmount
    const struct file_operations *f_op; // 操作函数表(read/write/...)
    atomic_long_t f_count;          // 引用计数(被几个 fd 指向)
    unsigned int f_flags;           // 文件状态标志(O_APPEND、O_NONBLOCK...)
    fmode_t f_mode;                 // 访问模式(只读/只写/读写)
    loff_t f_pos;                   // 文件偏移(当前读写位置)
    void *private_data;             // 驱动/文件系统私有数据
    ...
};

注意:

   f_pos 在 struct file 里,而不是在 fd 表里。

        这决定了"共享同一个 struct file 的 fd 共享文件偏移,而各自独立的 struct file 拥有独立偏移"。

2.3 struct inode

struct inode 代表文件系统中真正的文件,与是否打开无关:

  • 文件大小、权限(mode)、属主(uid/gid);
  • 时间戳(atime/mtime/ctime);
  • 数据块位置指针、链接计数;
  • 每个文件只有一个 inode,但可以被 open 多次、对应多个 struct file。

2.4 完整结构图


三、文件描述符的分配规则

内核分配 fd 时遵循**"最小可用整数"原则**:

        在 open_fds 位图上找第一个为 0 的位置返回。

int fd1 = open("a.txt", O_RDONLY);  // 3(0/1/2 已占用)
close(1);                            // 释放 1
int fd2 = open("b.txt", O_RDONLY);  // 1(最小可用)

四、文件描述符标志 vs 文件状态标志

这是一个高频混淆点,必须分清两类标志:

类别存放位置操作方式包含
文件描述符标志

fdtable

(每个 fd 独立)

fcntl F_GETFD/F_SETFD只有 FD_CLOEXEC
文件状态标志

struct file

(共享该 file 的 fd 都生效)

fcntl F_GETFL/F_SETFLO_APPEND、O_NONBLOCK、O_ASYNC、O_DIRECT 等
int flags = fcntl(fd, F_GETFL);       // 取文件状态标志
flags |= O_NONBLOCK;
fcntl(fd, F_SETFL, flags);           // 改状态标志

fcntl(fd, F_SETFD, FD_CLOEXEC);      // 设 fd 标志(exec 时关闭)

五、fork、exec、线程中的 fd 行为(重点)

5.1 fork:复制 fd 表,共享 file

  fork() 时,子进程复制父进程的 files_struct(fd 表),但表项指向的 struct file 不复制,只把 f_count 加 1。因此:

  • 父子共享 f_pos:父读了 10 字节,子接着从 10 读;
  • 父子写同一个 fd 不会互相覆盖(共享偏移),但写各自 open 的 fd 会覆盖。

5.2 exec:默认保留 fd,CLOEXEC 的关闭

execve() 执行新程序时,fd 表默认保留,但 close_on_exec 位图中标记的 fd 会被关闭。

  • 默认情况下 fd 跨 exec 保留;
  • 打开时用 O_CLOEXEC,或事后 fcntl F_SETFD FD_CLOEXEC,可让该 fd 在 exec 时自动关闭;
  • 为什么需要 CLOEXEC:避免 fd 意外泄漏给 exec 后的新程序(尤其是子进程 exec 后,父进程的管道写端没关,会导致读端收不到 EOF)。

5.3 线程:完全共享 fd 表

pthread_create 创建的线程通过 CLONE_FILES 共享同一个 files_struct:

  • 一个线程 open 的 fd,其他线程立即可用;
  • 一个线程 close 一个 fd,其他线程也立刻失效;
  • 多线程 close 同一个 fd 要小心重复关闭。

5.4 fork/exec 对比图


六、dup 系列深入

fd符号常量名字默认指向
0STDIN_FILENO标准输入终端键盘
1STDOUT_FILENO标准输出终端屏幕
2STDERR_FILENO标准错误终端屏幕
int dup(int oldfd);              // 新 fd 指向同一 file,返回最小可用
int dup2(int oldfd, int newfd);  // 指定新 fd,已占用先关闭
int dup3(int oldfd, int newfd, int flags); // 可加 O_CLOEXEC

dup 后两个 fd 共享同一个 struct file,所以共享 f_pos 和文件状态标志:

int fd = open("a.txt", O_RDONLY);
int d = dup(fd);
lseek(fd, 100, SEEK_SET);  // 改 fd 的偏移
// d 的偏移也变成 100,因为它们共享同一个 file

七、文件锁

锁接口关联对象特点
flock 锁

flock

(fd, LOCK_EX/SH/UN)

struct file建议锁,dup/fork 共享,新 open 不共享
fcntl 记录锁fcntl F_SETLK/F_SETLKW进程 + inode可锁字节范围,进程退出自动释放

八、重定向与管道(shell 原理)

fd符号常量名字默认指向
0STDIN_FILENO标准输入终端键盘
1STDOUT_FILENO标准输出终端屏幕
2STDERR_FILENO标准错误终端屏幕

8.1 重定向就是 dup2

cmd > file      # fd 1 → file
cmd < file      # fd 0 ← file
cmd >> file     # 追加
cmd 2> err      # fd 2 → err
cmd > file 2>&1 # fd 2 指向 fd 1 当前的 file(都进 file)
cmd 2>&1 > file# fd 2 先指向终端,fd 1 再指 file(顺序不同结果不同)

注意 2>&1 的位置顺序:

        它是"复制当前 fd 1 的指向",所以 > file 2>&1 两个都进文件,2>&1 > file 只有 stdout 进文件。

# 写法1:两条都进 file,终端无输出
{ echo "stdout-msg"; echo "stderr-msg" >&2; } > out.txt 2>&1
cat out.txt
# stdout-msg
# stderr-msg

# 写法2:stdout 进 file,stderr 仍显示在终端
{ echo "stdout-msg"; echo "stderr-msg" >&2; } 2>&1 > out.txt
# 终端显示:stderr-msg
cat out.txt
# stdout-msg
写法含义
cmd &> filebash 简写,stdout 和 stderr 都进 file(等价于 > file 2>&1)
cmd >& file同上(bash 扩展)
cmd 2> file只把 stderr 重定向到 file
cmd 1>&2把 stdout 重定向到 stderr(脚本里常用)
cmd > /dev/null 2>&1所有输出都丢弃

8.2 管道

    cmd1 | cmd2 底层是 pipe() 创建两个 fd,cmd1 写、cmd2 读,配合 fork 和 dup2 连接。


九、一切皆文件:特殊设备

设备行为
/dev/null写丢弃,读返回 EOF
/dev/zero读无限返回 0
/dev/full读返回 0,写返回 ENOSPC(测试磁盘满)
/dev/random、/dev/urandom随机数
/dev/tty进程的控制终端
/dev/stdin、/dev/stdout、/dev/stderr、/dev/fd/N对应 fd

十、零拷贝与 fd

  • sendfile(out_fd, in_fd, ...):两个 fd 间直接在内核搬运,不经过用户态;
  • splice(fd_in, ..., fd_out, ...):fd 与管道间搬运;
  • tee():在两个管道 fd 间复制,不消耗数据。

十一、fd 上限与泄漏排查

# 进程级上限(默认 1024)
ulimit -n

# 系统级 file 对象上限
cat /proc/sys/fs/file-max
cat /proc/sys/fs/file-nr      # 已分配、空闲、上限

# 查看进程打开的 fd
ls -l /proc/1234/fd
lsof -p 1234
lsof -i :8080                 # 查占用端口的进程

fd 泄漏的典型表现:

        fd 数持续增长,最终 open/socket/accept 返回 -1,错误码 EMFILE。


十二、常见错误码

错误码含义
EBADFfd 无效或未打开
EMFILE进程 fd 数达上限
ENFILE系统 fd 数达上限
EPIPE管道破裂(读端已关)
EAGAIN/EWOULDBLOCK非阻塞且暂无数据
EINTR被信号中断
EACCES权限不足
ENOENT文件不存在
ENOSPC磁盘已满

十三、面试题(含答案)

文件描述符的本质?

        进程 fdtable 中 file 指针数组的下标,表项指向 struct file。

两次 open 同一文件,fd 共享偏移吗?

        不共享。各自创建 struct file,独立 f_pos。

dup 后两个 fd 共享偏移吗?

         共享。它们指向同一个 struct file。

fork 后父子共享偏移吗?

         共享。子进程复制 fd 表但共享 struct file,f_count 加 1。

exec 后 fd 还在吗?

         默认保留;设了 FD_CLOEXEC / O_CLOEXEC 的会被关闭。

线程间 fd 共享吗?

        完全共享同一个 files_struct(CLONE_FILES)。

fd 标志和文件状态标志的区别?

         fd 标志(只有 FD_CLOEXEC)存在 fdtable,用 F_GET/SETFD;

         状态标志(O_APPEND 等)存在 struct file ,用 F_GET/SETFL。

为什么需要 O_CLOEXEC?

         防止 fd 泄漏给 exec 后的程序,典型是管道写端不关导致读端收不到 EOF。

> file 2>&1 和 2>&1 > file 的区别?

        前者两个都进文件(2 复制 1 已指向 file 的状态);

        后者只有 stdout 进文件。

怎么排查 fd 泄漏?

         ls /proc/pid/fd 或 lsof -p pid 看数量是否持续增长。

EMFILE 和 ENFILE 的区别?

         EMFILE 是进程级 fd 达上限,ENFILE 是系统级 fd 达上限。

f_pos 存在哪个结构体里?为什么重要?

         存在 struct file,决定了共享该 file 的 fd 共享偏移,而独立 file 偏移独立。


总结

理解文件描述符的核心是抓住四层结构 + 三种共享行为:

  • 结构上:task_struct → files_struct/fdtable → struct file → inode,fd 是数组下标,f_pos 在 struct file 里;
  • 行为上:fork 共享 file、exec 默认保留 fd(CLOEXEC 关闭)、线程完全共享 fd 表;
  • 应用上:重定向是 dup2、管道是 pipe+fork+dup2、一切设备都通过 fd 统一访问。

   动手实现 C 标准库:myStdio 项目学习指南  

一、项目目标与整体架构

glibc 的 stdio 本质就做一件事:

        在用户态开一块缓冲区,先把数据攒起来,条件满足时再通过 write() 一次性交给内核。我们要复刻的就是这个过程:

myfputs("...")
   → 数据拷贝到 myFILE.buffer(用户态缓冲区)
   → 判断是否满足刷新条件(换行 / 缓冲区满 / 强制)
   → 满足则 write(fd, buffer, pos) 交给内核 Page Cache
   → myfclose 时强制刷完并关闭 fd

二、前置知识对照

代码中的东西对应知识点
open() 返回的 fd文件描述符(内核 fd 表的下标)
myFILE.buffer用户态 stdio 缓冲区(glibc 里在 FILE 结构体内部)
flush_mode = LINE_BUFFER行缓冲 / 全缓冲 / 无缓冲三种类型
myfflushcore缓冲区刷新逻辑(缓冲区满、遇 \n、fflush)
write()系统调用,把数据从用户态送入内核 Page Cache
fsync()强制内核落盘(标准 fclose 一般不做)

三、第一步:设计 myFILE 数据结构(头文件)

myFILE 对应 glibc 的 struct _IO_FILE,核心字段就是 fd、缓冲区、当前位置、容量、缓冲类型:

// mystdio.h
#ifndef _MY_STDIO_H_
#define _MY_STDIO_H_

#include <stddef.h>

#define MY_BUF_SIZE  1024          // 缓冲区容量
#define MY_FILE_MODE 0644          // 新建文件权限

typedef enum {
    MY_NBF = 0,                    // 无缓冲 _IONBF
    MY_LBF = 1,                    // 行缓冲 _IOLBF
    MY_FBF = 2                     // 全缓冲 _IOFBF
} my_buf_mode_t;

typedef struct myFILE {
    int           fd;              // 文件描述符
    int           flags;           // open 标志
    int           pos;             // 缓冲区当前位置
    int           cap;             // 缓冲区容量
    my_buf_mode_t flush_mode;      // 缓冲类型
    char          buffer[MY_BUF_SIZE]; // 输出缓冲区
} myFILE;

myFILE *myfopen(const char *path, const char *mode);
int     myfclose(myFILE *fp);
int     myfputs(const char *s, myFILE *fp);
int     myfflush(myFILE *fp);

#endif

四、第二步:myfopen —— 打开文件

myfopen 做两件事:

        解析 mode 字符串 → 调 open 拿 fd → 分配 myFILE。

重点是 mode 到 open 标志的映射:

modeopen 标志含义
rO_RDONLY只读,文件必须存在
wO_WRONLY|O_CREAT|O_TRUNC只写,不存在则建,存在则清空
aO_WRONLY|O_CREAT|O_APPEND追加写,每次写到末尾
r+O_RDWR读写,文件必须存在
w+O_RDWR|O_CREAT|O_TRUNC读写,清空
a+O_RDWR|O_CREAT|O_APPEND读写,追加

五、第三步:myfflushcore —— 缓冲区刷新核心

        这是整个项目最核心、也最容易写错的部分。

三种缓冲类型的刷新判断:

  • 无缓冲:任何数据都立即 write;
  • 行缓冲:缓冲区里出现 \n 就刷,或缓冲区满也刷;
  • 全缓冲:只有缓冲区满才刷;
  • 强制刷新(MUST_FLUSH):无论哪种类型都刷。

六、第四步:myfputs —— 写入数据

正确逻辑是一个**"分片拷贝 + 边写边刷"**的循环

(因为一次写入的字符串可能比缓冲区大):


七、myfclose —— 关闭文件

顺序:

        强制刷新残留数据 → close(fd) → free(myFILE)。

        注意 fsync 不是标准库的做法(标准 fclose 只保证数据到内核,不保证落盘),按需保留即可。

八、完整可运行代码

// mystdio.h
#ifndef _MY_STDIO_H_
#define _MY_STDIO_H_

#include <stddef.h>

#define MY_BUF_SIZE  1024          // 缓冲区容量
#define MY_FILE_MODE 0644          // 新建文件权限

typedef enum {
    MY_NBF = 0,                    // 无缓冲 _IONBF
    MY_LBF = 1,                    // 行缓冲 _IOLBF
    MY_FBF = 2                     // 全缓冲 _IOFBF
} my_buf_mode_t;

typedef struct myFILE {
    int           fd;              // 文件描述符
    int           flags;           // open 标志
    int           pos;             // 缓冲区当前位置
    int           cap;             // 缓冲区容量
    my_buf_mode_t flush_mode;      // 缓冲类型
    char          buffer[MY_BUF_SIZE]; // 输出缓冲区
} myFILE;

myFILE *myfopen(const char *path, const char *mode);
int     myfclose(myFILE *fp);
int     myfputs(const char *s, myFILE *fp);
int     myfflush(myFILE *fp);

#endif
// mystdio.c
#include "mystdio.h"
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

#define TRY_FLUSH  0
#define MUST_FLUSH 1

myFILE *myfopen(const char *path, const char *mode) {
    int flags = 0;
    if      (strcmp(mode, "r")  == 0) flags = O_RDONLY;
    else if (strcmp(mode, "w")  == 0) flags = O_WRONLY | O_CREAT | O_TRUNC;
    else if (strcmp(mode, "a")  == 0) flags = O_WRONLY | O_CREAT | O_APPEND;
    else if (strcmp(mode, "r+") == 0) flags = O_RDWR;
    else if (strcmp(mode, "w+") == 0) flags = O_RDWR | O_CREAT | O_TRUNC;
    else if (strcmp(mode, "a+") == 0) flags = O_RDWR | O_CREAT | O_APPEND;
    else return NULL;

    int fd = (flags & O_CREAT)
           ? open(path, flags, MY_FILE_MODE)
           : open(path, flags);
    if (fd < 0) return NULL;

    myFILE *fp = calloc(1, sizeof(myFILE));
    if (fp == NULL) { close(fd); return NULL; }

    fp->fd = fd;
    fp->flags = flags;
    fp->cap = MY_BUF_SIZE;
    fp->pos = 0;
    fp->flush_mode = MY_LBF;
    return fp;
}

static int myflush_core(myFILE *fp, int force) {
    if (fp->pos == 0) return 0;

    int need = 0;
    if (force)                                    need = 1;
    else if (fp->flush_mode == MY_NBF)            need = 1;
    else if (fp->flush_mode == MY_FBF) {
        if (fp->pos >= fp->cap)                   need = 1;
    } else {                                      // 行缓冲
        if (fp->pos >= fp->cap ||
            memchr(fp->buffer, '\n', fp->pos))    need = 1;
    }
    if (!need) return 0;

    int sent = 0;
    while (sent < fp->pos) {
        ssize_t n = write(fp->fd, fp->buffer + sent, fp->pos - sent);
        if (n <= 0) return -1;
        sent += n;
    }
    fp->pos = 0;
    return 0;
}

int myfflush(myFILE *fp) {
    if (fp == NULL) return -1;
    return myflush_core(fp, MUST_FLUSH);
}

int myfputs(const char *s, myFILE *fp) {
    if (s == NULL || fp == NULL) return -1;

    int len = strlen(s);
    int off = 0;
    while (off < len) {
        int n = len - off;
        if (n > fp->cap - fp->pos) n = fp->cap - fp->pos;
        memcpy(fp->buffer + fp->pos, s + off, n);
        fp->pos += n;
        off += n;

        if (fp->pos >= fp->cap) {
            if (myflush_core(fp, MUST_FLUSH) < 0) return -1;
        } else {
            if (myflush_core(fp, TRY_FLUSH)  < 0) return -1;
        }
    }
    return len;
}

int myfclose(myFILE *fp) {
    if (fp == NULL) return -1;
    myflush_core(fp, MUST_FLUSH);
    close(fp->fd);
    free(fp);
    return 0;
}

九、测试用例

// test.c
#include "mystdio.h"

int main() {
    myFILE *fp = myfopen("test.txt", "w");
    if (fp == NULL) return 1;

    myfputs("first line\n", fp);   // 遇换行,立即刷
    myfputs("second line\n", fp);
    myfputs("no newline yet", fp); // 无换行,关闭时才刷
    myfclose(fp);
    return 0;
}

编译运行:

gcc mystdio.c test.c -o test
./test
cat test.txt

        可以用 strace -e trace=write ./test 观察实际触发了几次 write,验证缓冲合并效果。


十、面试题

  1. 为什么库函数要在用户态再开一层缓冲区?(减少系统调用次数)
  2. 行缓冲、全缓冲、无缓冲的刷新条件分别是什么?
  3. 一次写入的字符串比缓冲区大,该怎么处理?(分片拷贝、边写边刷)
  4. write 会一次写完所有数据吗?(不一定,要处理部分写)
  5. fflush 和 fsync 的区别?(前者到内核,后者落盘)
  6. 为什么 myfputs 里必须先算 cap-pos?(防止缓冲区越界)
  7. fclose 一般需要 fsync 吗?(不需要,标准库只到内核)

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2602_95649725/article/details/169166998

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--