17、文件描述符与文件指针:文件描述符的概念、fileno与fdopen函数、两者转换

说到文件操作,很多初学者会困惑一个问题:为什么C语言里既有FILE *指针,又有文件描述符?这两个东西到底什么关系?

嗯,这其实是个历史遗留问题,也是Unix/Linux系统设计的精妙之处。我当年刚接触嵌入式Linux开发时,也被这两个概念绕晕过。有一次调试一个串口通信程序,明明fopen成功了,但用read()去读就是不行——后来才发现,我把文件指针和文件描述符混用了。

今天咱们就把这事彻底说清楚。

17.1 文件描述符的概念

文件描述符(File Descriptor,简称fd),说白了就是一个非负整数。它是操作系统内核用来标识打开文件的“编号”。

你想想看,内核要管理成千上万个打开的文件,总不能每次都传文件名吧?效率太低了。所以内核给每个打开的文件分配一个整数ID,这就是文件描述符。

核心要点:

  • 文件描述符是整数,范围从0开始
  • 每个进程有自己的文件描述符表
  • 0 = 标准输入,1 = 标准输出,2 = 标准错误
  • 新打开的文件,分配当前可用的最小整数

我在项目中遇到过一个问题:一个嵌入式设备同时打开多个传感器文件,结果文件描述符用到了200多。这时候就要小心了,因为有些系统默认的ulimit -n只有1024。如果忘记关闭文件,描述符泄漏了,程序就会莫名其妙地报“Too many open files”。

17.2 文件指针(FILE *)是什么

文件指针是C标准库(stdio.h)提供的抽象。它本质上是一个结构体指针,这个结构体里封装了:

  • 文件描述符(没错,底层还是它)
  • 缓冲区(buffer)
  • 当前读写位置
  • 错误标志、EOF标志等

说白了,文件指针是“带缓冲”的文件描述符。标准库帮你管理了缓冲区,这样你每次读一个字节时,不用真的去调用系统调用(read/write),而是先从缓冲区拿。效率高很多。

我的建议:

如果你做的是普通文件读写,用fopen/fread/fwrite就对了。标准库的缓冲机制能帮你省掉很多麻烦。

但如果你做的是网络通信、串口、管道这类需要实时性的操作,我建议直接用文件描述符+read/write。因为缓冲可能会让你收不到即时数据。

17.3 fileno函数:从文件指针获取文件描述符

有时候你手里只有FILE *,但需要调用read()、write()、select()、poll()这些系统调用。怎么办?

fileno()函数。

#include <stdio.h>

int fileno(FILE *stream);

这个函数很简单:传入一个FILE *,返回它底层的文件描述符。如果失败,返回-1。

示例:

#include <stdio.h>
#include <unistd.h>

int main() {
    FILE *fp = fopen("test.txt", "r");
    if (fp == NULL) {
        perror("fopen");
        return 1;
    }

    int fd = fileno(fp);
    printf("文件描述符: %d\n", fd);

    // 现在可以用read()直接读了
    char buf[100];
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = '\0';
        printf("读取内容: %s\n", buf);
    }

    fclose(fp);
    return 0;
}

注意:

调用fileno()后,不要自己close(fd)。因为fclose(fp)会自动关闭底层的文件描述符。如果你手动close了,fclose会尝试关闭一个已经关闭的描述符,导致未定义行为。

我曾经在一个项目中犯过这个错——为了“确保资源释放”,在fclose之前先close了fd,结果程序崩溃了。排查了半天才发现是double close的问题。

17.4 fdopen函数:从文件描述符获取文件指针

反过来,如果你通过open()、socket()、accept()拿到了文件描述符,但想用fprintf、fscanf这些方便的库函数,怎么办?

fdopen()函数。

#include <stdio.h>

FILE *fdopen(int fd, const char *mode);

这个函数把一个文件描述符“包装”成一个FILE *指针。mode参数和fopen一样,比如"r"、"w"、"a"等。

示例:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    // 用系统调用打开文件
    int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    // 包装成FILE *
    FILE *fp = fdopen(fd, "w");
    if (fp == NULL) {
        perror("fdopen");
        close(fd);
        return 1;
    }

    // 现在可以用fprintf了
    fprintf(fp, "Hello, 文件描述符转文件指针!\n");
    fprintf(fp, "fd = %d\n", fd);

    // 关闭时只用fclose
    fclose(fp);
    return 0;
}

关键点:

fdopen之后,文件描述符的所有权转移给了FILE *。你只需要fclose(fp),它会自动关闭底层的fd。不要自己再close(fd)了。

另外,mode参数必须和打开fd时的权限一致。比如你用O_RDONLY打开的,mode就不能用"w"。

17.5 两者转换的完整关系图

下面这张图展示了文件描述符和文件指针的关系,以及转换函数的位置:

文件描述符与文件指针转换关系图 用户空间(C标准库) 内核空间(系统调用) FILE * 文件指针 带缓冲的抽象 文件描述符 (int) 内核分配的整数ID fileno() FILE * → int fdopen() int → FILE * 底层关系 FILE * 结构体内部包含文件描述符 fd 成员 fopen() 内部调用 open(),fclose() 内部调用 close()

17.6 实际项目中的选择策略

我做了这么多年嵌入式开发,总结了一套选择策略,分享给你:

场景 推荐方式 原因
普通文件读写(文本、二进制) FILE * + fopen/fread/fwrite 缓冲提高性能,接口方便
网络socket通信 文件描述符 + read/write 需要非阻塞、select/poll/epoll
串口/管道操作 文件描述符 + read/write 实时性要求高,不能有缓冲
需要格式化输出(fprintf) FILE *(可用fdopen包装) 格式化方便,不用自己拼字符串
需要同时用缓冲和系统调用 FILE * + fileno获取fd 两者兼顾,但要注意缓冲问题

避坑指南:

我曾经在一个项目中,用fdopen包装了一个socket描述符,然后用fprintf发送数据。结果发现数据发不出去——因为标准库的缓冲区没满,没有真正调用write()。解决方案是:要么用setbuf(fp, NULL)关闭缓冲,要么在关键位置调用fflush()。

记住:FILE *的缓冲是好事,但在某些场景下会坏事

17.7 总结

文件描述符和文件指针,一个是内核视角的整数ID,一个是用户视角的带缓冲结构体。它们之间通过fileno()和fdopen()可以互相转换。

我个人习惯是:

  • 写普通文件用FILE *
  • 做网络/串口编程用文件描述符
  • 需要格式化输出时,用fdopen包装一下
  • 永远记住:谁打开,谁关闭;不要重复关闭

嗯,掌握了这些,你在Linux下的文件操作基本就稳了。


公众号:蓝海资料掘金营,微信deep3321