初探:C语言FILE结构之文件描述符与缓冲区的实现原理

目录

一、FILE当中的文件描述符

二、FILE当中的缓冲区

1、程序行为分析

2、直接输出到显示器时

3、重定向到文件时

4、差异原因

5、解决方案

6、另一种验证stderr无缓冲特性的方法是通过文件描述符重定向

代码如下:

程序功能解析

关键点分析

三、缓冲区的来源和位置

1、缓冲区的来源

2、缓冲区的类型

3、缓冲区的物理位置

[(1) 用户空间缓冲区](#(1) 用户空间缓冲区)

[(2) 内核缓冲区](#(2) 内核缓冲区)

4、示例程序中的缓冲区

5、fork()对缓冲区的影响

6、缓冲区的实际位置验证

7、关键点总结


一、FILE当中的文件描述符

由于库函数本质上是对系统调用接口的封装,所有文件访问操作最终都是通过文件描述符(fd)实现的。因此,C标准库中的FILE结构体内部必然包含文件描述符fd。

通过查看/usr/include/stdio.h头文件可以发现,FILE实际上是struct _IO_FILE结构体的别名。

cpp 复制代码
typedef struct _IO_FILE FILE;

/usr/include/libio.h头文件中定义的struct _IO_FILE结构体包含一个名为_fileno的成员,该成员实际上封装了文件描述符。

cpp 复制代码
struct _IO_FILE {
	int _flags;       /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

	//缓冲区相关
	/* The following pointers correspond to the C++ streambuf protocol. */
	/* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
	char* _IO_read_ptr;   /* Current read pointer */
	char* _IO_read_end;   /* End of get area. */
	char* _IO_read_base;  /* Start of putback+get area. */
	char* _IO_write_base; /* Start of put area. */
	char* _IO_write_ptr;  /* Current put pointer. */
	char* _IO_write_end;  /* End of put area. */
	char* _IO_buf_base;   /* Start of reserve area. */
	char* _IO_buf_end;    /* End of reserve area. */
	/* The following fields are used to support backing up and undo. */
	char *_IO_save_base; /* Pointer to start of non-current get area. */
	char *_IO_backup_base;  /* Pointer to first valid character of backup area */
	char *_IO_save_end; /* Pointer to end of non-current get area. */

	struct _IO_marker *_markers;

	struct _IO_FILE *_chain;

	int _fileno; //封装的文件描述符
#if 0
	int _blksize;
#else
	int _flags2;
#endif
	_IO_off_t _old_offset; /* This used to be _offset but it's too small.  */

#define __HAVE_COLUMN /* temporary */
	/* 1+column number of pbase(); 0 is unknown. */
	unsigned short _cur_column;
	signed char _vtable_offset;
	char _shortbuf[1];

	/*  char* _save_gptr;  char* _save_egptr; */

	_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

让我们深入理解C语言中fopen函数的工作原理:

fopen函数主要完成两项工作:

  1. 在用户层面,它会创建并返回一个FILE结构体指针(FILE*);
  2. 在系统层面,通过调用open系统接口获取文件描述符fd,并将该fd存储在FILE结构体的_fileno成员中,完成文件打开的全过程。

C语言的其他文件操作函数(fread、fwrite、fputs、fgets等)的工作流程如下:

  1. 通过用户传入的文件指针定位对应的FILE结构体;
  2. 从结构体中获取文件描述符fd;
  3. 最终通过该文件描述符完成实际的文件操作。

二、FILE当中的缓冲区

这段代码使用了两个C库函数和一个系统调用来实现屏幕输出,最后还调用了fork函数:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
int main()
{
	//c
	printf("hello printf\n");
	fputs("hello fputs\n", stdout);
	//system
	write(1, "hello write\n", 12);
	fork();
	return 0;
}

1、程序行为分析

程序执行了以下操作:

  1. 使用printf()打印"hello printf\n"

  2. 使用fputs()打印"hello fputs\n"

  3. 使用系统调用write()直接打印"hello write\n"

  4. 调用fork()创建子进程

2、直接输出到显示器时

当程序输出直接显示在终端时,你会看到:

只输出一次,因为所有输出都在fork()之前完成。

3、重定向到文件时

当使用./demo > log.txt重定向输出时,log.txt内容可能是:

为什么C库函数重定向输出到文件时会出现两份内容,而系统调用却只有一份?

4、差异原因

  1. 缓冲机制不同

    • 输出到终端时:默认是行缓冲(line-buffered),遇到换行符\n会立即刷新缓冲区

    • 输出到文件时:默认是全缓冲(fully-buffered),不会立即刷新,除非缓冲区满或程序结束

  2. fork()的影响

    • write()是系统调用,无缓冲,直接输出

    • printf()fputs()使用标准I/O库,有缓冲

    • 当fork()时,缓冲区的数据会被复制到子进程

    • 程序结束时,父进程和子进程都会刷新各自的缓冲区,导致重复输出

  3. 输出顺序变化

    • write()立即输出,不受缓冲影响

    • 其他输出可能因为缓冲机制而改变顺序

**5、**解决方案

要避免这种差异,可以在fork()前显式刷新缓冲区:

cpp 复制代码
fflush(stdout);  // 刷新标准输出缓冲区

或者设置无缓冲模式:

cpp 复制代码
setvbuf(stdout, NULL, _IONBF, 0);  // 设置无缓冲

这样无论输出到终端还是文件,结果都会一致。

6、另一种验证stderr无缓冲特性的方法是通过文件描述符重定向

代码如下:

c 复制代码
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>

int main() {
    close(2);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
        perror("open");
        return 0;
    }
    perror("hello world");
    close(fd);
    return 0;
}

通过关闭2号文件描述符并重定向到文件,由于stderr无缓冲特性,"hello world"信息无需调用fflush即可直接写入文件:

程序功能解析

  1. 关闭标准错误流

    cpp 复制代码
    close(2); // 关闭文件描述符2(标准错误stderr)
  2. 打开文件

    cpp 复制代码
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    • 以只写方式打开文件"log.txt"

    • 如果文件不存在则创建(O_CREAT)

    • 如果文件存在则清空内容(O_TRUNC)

    • 设置文件权限为0666(rw-rw-rw-)

  3. 错误检查:如果文件打开失败,使用perror输出错误信息

    cpp 复制代码
    if (fd < 0) {
        perror("open");
        return 0;
    }
  4. 输出错误信息:输出自定义错误信息

    cpp 复制代码
    perror("hello world");
  5. 关闭文件

    cpp 复制代码
    close(fd);

关键点分析

  1. 文件描述符重用机制

    • 当关闭文件描述符2(stderr)后,系统会将该描述符标记为可用

    • 接下来打开的文件会优先使用最小的可用文件描述符(这里是2)

    • 因此fd实际上会获得值2,即原来的stderr描述符

  2. 错误输出重定向

    • perror默认向stderr(文件描述符2)输出

    • 由于我们关闭后又重新打开了文件描述符2,现在它指向"log.txt"文件

    • 所以perror("hello world")会写入到"log.txt"而非终端

  3. 程序执行结果

    • 正常情况下,"log.txt"文件会包含"hello world: Success"(或类似内容)

    • 因为perror在无错误时会输出"Success"

    • 如果open失败,则会在终端看到"open: [错误原因]"


三、缓冲区的来源和位置

在C标准I/O库中,缓冲区的来源和位置涉及多个层次,我来详细解释:

1、缓冲区的来源

缓冲区是由C标准I/O库(stdio)提供的抽象机制,主要目的是减少系统调用的次数,提高I/O效率。当使用printffputs等标准I/O函数时,数据不会立即写入设备,而是先存储在缓冲区中。

2、缓冲区的类型

C标准库提供了三种缓冲模式:

  • 全缓冲:缓冲区满时才进行实际I/O操作(常用于文件)

  • 行缓冲:遇到换行符或缓冲区满时刷新(常用于终端)

  • 无缓冲 :立即输出(如stderr

3、缓冲区的物理位置

缓冲区的位置可以从两个层面理解:

(1) 用户空间缓冲区

  • 位置:位于进程的用户空间内存中

  • 管理:由C标准库管理

  • 结构 :每个FILE结构体(如stdout)都包含自己的缓冲区**(也就是说,这里的缓冲区是由C语言提供,在FILE结构体当中进行维护的,FILE结构体当中不仅保存了对应文件的文件描述符还保存了用户缓冲区的相关信息。)**

    cpp 复制代码
    //缓冲区相关
    /* The following pointers correspond to the C++ streambuf protocol. */
    /* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
    char* _IO_read_ptr;   /* Current read pointer */
    char* _IO_read_end;   /* End of get area. */
    char* _IO_read_base;  /* Start of putback+get area. */
    char* _IO_write_base; /* Start of put area. */
    char* _IO_write_ptr;  /* Current put pointer. */
    char* _IO_write_end;  /* End of put area. */
    char* _IO_buf_base;   /* Start of reserve area. */
    char* _IO_buf_end;    /* End of reserve area. */
    /* The following fields are used to support backing up and undo. */
    char *_IO_save_base; /* Pointer to start of non-current get area. */
    char *_IO_backup_base;  /* Pointer to first valid character of backup area */
    char *_IO_save_end; /* Pointer to end of non-current get area. */
  • 大小 :通常由宏BUFSIZ定义(常见为8192字节)

(2) 内核缓冲区

操作系统也存在缓冲区机制。当我们刷新用户缓冲区的数据时,并不会直接写入磁盘或显示器,而是先将数据写入操作系统缓冲区,再由操作系统按自身机制同步到磁盘或显示器。(操作系统的缓冲区刷新遵循特定规则,无需用户干预)

  • 位置:位于内核空间

  • 管理:由操作系统管理

  • 触发 :当用户空间缓冲区刷新时,数据会通过系统调用(如write)进入内核缓冲区

因为操作系统是进行软硬件资源管理的软件,根据下面的层状结构图,用户区的数据要刷新到具体外设必须经过操作系统:

4、示例程序中的缓冲区

在上面的程序中:

cpp 复制代码
printf("hello printf\n");       // 使用stdout的缓冲区
fputs("hello fputs\n", stdout); // 使用stdout的缓冲区
write(1, "hello write\n", 12);  // 直接系统调用,绕过缓冲区
  • printffputs使用的缓冲区是用户空间的stdout缓冲区

  • write直接进入内核,不经过用户空间缓冲区

5、fork()对缓冲区的影响

当调用fork()时:

  1. 整个进程的用户空间(包括所有缓冲区)被复制到子进程

  2. 如果缓冲区中有未刷新的数据,父进程和子进程会各自刷新自己的缓冲区副本

  3. 导致数据被重复写入(如你的示例中"hello printf"和"hello fputs"出现两次)

6、缓冲区的实际位置验证

可以通过查看FILE结构体了解缓冲区位置(具体实现可能不同):

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

int main() {
    printf("stdout buffer address: %p\n", stdout->_IO_buf_base);
    return 0;
}

这将打印出stdout缓冲区在用户空间的起始地址:

运行的缓冲区地址验证程序显示stdout buffer address: (nil),这表明在你的系统环境下,stdout的缓冲区尚未被分配或初始化。 具体原因可以查资料,这个不是重点。

7、关键点总结

  1. 用户空间缓冲区由C库管理,位于进程内存空间

  2. 内核缓冲区由OS管理,位于内核空间

  3. 标准I/O函数使用用户空间缓冲区

  4. 系统调用(如write)直接操作内核缓冲区

  5. fork()会复制整个用户空间,包括所有未刷新的缓冲区

理解这一点就能明白为什么重定向到文件时会出现重复输出 - 因为缓冲区内容被fork()复制了。