Linux -- I/O接口,文件标识符fd、file结构体、缓冲区、重定向、简单封装C文件接口
一、理解文件
狭隘理解(传统视角)
- 聚焦物理存储:文件特指存储在磁盘等外存设备上的二进制数据集合
- 输入输出特性:
- 写入文件:CPU 通过总线将数据输出到磁盘
- 读取文件:磁盘通过 DMA 将数据输入到内存
(DMA(Direct Memory Access)即直接存储器访问,它允许某些硬件子系统可以独立地直接读写计算机内存,而不需要经过中央处理器(CPU),从而提高数据传输的效率。例如在硬盘与内存之间的数据传输中,可以使用 DMA 来减少 CPU 的负担,加快数据传输速度。)- 典型操作:open/read/write/close 等标准文件操作接口
Linux 广义抽象
- 设备文件化:
- 硬件设备映射为 /dev 目录下的特殊文件(如 /dev/sda 代表磁盘)
- 进程间通信管道也被视为文件(如命名管道文件)
- 统一操作接口:
- 所有设备都通过文件描述符(fd)操作
- 实现方式:驱动程序提供 file_operations 结构体
- 典型案例:
- 键盘输入:/dev/console
- 网络通信:/dev/net/tun
- 随机数生成:/dev/urandom
这种设计的核心优势在于:
- 实现 "一处代码,多处复用" 的设备无关性
- 进程可以用相同的系统调用操作不同设备
- 极大简化了设备管理和驱动开发的复杂度
例如,当我们使用 cat 命令读取文件时,实际上是在读取磁盘设备文件;而当使用 cat > /dev/tty 时,数据就会直接输出到终端设备,两者使用的都是 read/write 系统调用。
归类认知:
- 文件 = 文件内容 + 文件属性
- 对于0KB的空文件是占用磁盘空间的
- 所有的文件操作本质是文件内容操作和文件属性操作
- 对文件的操作本质是进程对文件的操作
- 磁盘的管理者是操作系统
- 文件的读写本质不是通过C/C++的库函数来操作的,而是通过文件相关的系统调用接口来实现的,C/C++库函数对系统调用进行了封装。
二、C文件接口
fopen:打开文件
#include <stdio.h> FILE *fopen(const char *filename, const char *mode);
- 功能:打开指定名称的文件,并返回一个指向该文件的
FILE
指针。如果打开失败,返回NULL
。- 参数:
filename
:要打开的文件的名称,可以包含路径。mode
:指定文件的打开模式,常见的模式有:
"r"
:(read)以只读模式打开文本文件。"w"
:(write)以写入模式打开文本文件,如果文件不存在则创建,如果文件已存在则清空内容,shell的输出重定向就是这个原理。"a"
:(append)以追加模式打开文本文件,如果文件不存在则创建;shell的追加重定向就是这个原理。"rb"
、"wb"
、"ab"
:分别对应二进制文件的只读、写入和追加模式- 例子:
#include <stdio.h> int main() { FILE *fp = fopen("test.txt", "w"); if (fp == NULL) { perror("Failed to open file"); return 1; } // 文件操作代码 fclose(fp); return 0; }
fclose:关闭文件
#include <stdio.h> int fclose(FILE *stream);
- 功能:关闭指定的文件流。如果关闭成功,返回
0
;否则返回EOF
。同时会刷新用户态标准I/O库缓冲区- 参数:
stream
:指向要关闭的文件的FILE
指针。
fwrite: 二进制写入文件(wb)
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
ptr
:指向要写入的数据的指针,const void *
类型表示可以指向任意类型的数据。size
:每个数据项的大小(以字节为单位)。nmemb
:要写入的数据项的数量。stream
:指向目标文件的FILE
指针,该文件通常以写入模式(如"wb"
用于二进制写入)打开。- 返回实际成功写入的数据项的数量。如果返回值小于
nmemb
,可能表示发生了错误或者到达了文件末尾。
fread:二进制读取文件(rb)
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
ptr
:指向用于存储读取数据的缓冲区的指针。size
:每个数据项的大小(以字节为单位)。nmemb
:要读取的数据项的数量。stream
:指向源文件的FILE
指针,该文件通常以读取模式(如"rb"
用于二进制读取)打开。- 返回实际成功读取的数据项的数量。如果返回值小于
nmemb
,可能表示发生了错误或者到达了文件末尾。
其他相关C文件操作的库函数还有:
fputc:写入字符;原型:int fputc(int c, FILE *stream);
fgetc:读取字符;原型:int fgetc(FILE *stream);
fputs:写入字符串;原型:int fputs(const char *s, FILE *stream);
fgets:读取字符串;原型:char *fgets(char *s, int size, FILE *stream);
fprintf:写入格式化数据;原型:int fprintf(FILE *stream, const char *format, ...);
fscanf:读取格式化数据;原型:int fscanf(FILE *stream, const char *format, ...);
ftell:返回当前文件指针的位置。如果发生错误,返回-1L
。
fseek:将文件指针移动到指定的位置。如果成功,返回0
;否则返回非零值; 原型:int fseek(FILE *stream, long offset, int whence); stream:指向目标文件的 FILE 指针。 offset:偏移量,以字节为单位。 whence:指定偏移的起始位置,有三个可选值: SEEK_SET:从文件开头开始偏移。 SEEK_CUR:从当前文件指针位置开始偏移。 SEEK_END:从文件末尾开始偏移。
本文并非对这些接口进行详细讲解,如有需要可以跳转之前的博客:文件与文件操作_1文件-CSDN博客
编译并执行proc:
#include <stdio.h> #include <stdlib.h> int main() { FILE *file = fopen("myfile","w"); if(!file){ perror("myfile open"); exit(1); } fputs("hello world\n",file); fclose(file); return 0; }
myfile在proc执行之前是不存在的,打开文件使用"w"模式,如果打开文件不存在就会在当前工作路径下创建这个文件。
cwd:当前工作路径,指的是当前进程所在的路径,会随着进程在不同的路径而发生改变;(可使用系统调用chdir来改变当前工作路径;使用系统调用getcwd来获取当前工作路径)
exe:指的是启动该进程的路径,在启动进程时就确定了
打开文件,本质是进程打开的,进程通过当前工作路径知道自己在哪里,所以文件不存在且文件不带路径,进程通过当前工作路径也能够在该路径下创建文件。
实现简单cat命令:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(int argc, char *argv[]) { if(argc != 2){ printf("argv error\n"); exit(1); } FILE *file = fopen(argv[1],"r"); if(!file){ perror("file fopen"); exit(1); } char buf[1024]; while(1){ int size = fread(buf,sizeof(char),sizeof(buf),file); if(size > 0){ buf[size] = '\0'; printf("%s",buf); } if(feof(file)){ break; } } printf("\n"); fclose(file); return 0; }
三、C语言的输入输出流
C会默认打开三个输入输出流,分别是stdin、stdout、stderr,这三个流的类型都是FILE* 文件指针。
- stdin:标准输入,对应的外设:键盘
- stdout:标准输出,对应的外设:显示器
- stderr:标准错误,对应的外设:显示器
既然stdout是标准输出流,类型是FILE*,对应的外设是显示器,我们说Linux下一切皆为文件,那么显示器也可以当成文件,所以可以通过文件操作函数,将信息输出到显示器:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int main() { char s[] = "hello linux\n"; fwrite(s,sizeof(char),sizeof(s),stdout); printf("hello world\n"); fprintf(stdout,"hello hhhh\n"); return 0; }
stdout和stderr对应的外设都是显示器,但它们在机制上存在差异:
缓冲机制
stdout
stdout
通常是行缓冲(line-buffered)或全缓冲(fully-buffered)的。- 行缓冲:在遇到换行符(
\n
)时,缓冲区中的内容会被刷新并输出到终端。例如,使用printf("Hello, World!\n");
时,Hello, World!
会立即输出。- 全缓冲:只有当缓冲区满或者调用
fflush(stdout)
函数时,缓冲区中的内容才会被输出。在某些情况下,例如程序重定向输出到文件时,stdout
可能会变成全缓冲模式。- 这里使用标准I/O库函数进行文件操作,刷新的是用户态标准 I/O 库缓冲区;使用系统调用进行文件操作,刷新的是内核缓冲区。
stderr
stderr
是无缓冲(unbuffered)的。这意味着一旦有数据写入stderr
,数据会立即被输出到终端,不会进行缓冲等待。这种设计确保了错误信息能够及时显示,避免因缓冲导致错误信息显示不及时而影响调试。输出重定向的影响
stdout
- 可以很方便地将
stdout
的输出重定向到文件或其他设备。在类 Unix 系统的命令行中,可以使用>
符号进行重定向。例如,./a.out > output.txt
会将程序a.out
的stdout
输出重定向到output.txt
文件中。stderr
stderr
的输出不会受到stdout
重定向的影响。如果只对stdout
进行重定向,stderr
的信息仍然会输出到终端。不过,也可以单独对stderr
进行重定向,在类 Unix 系统中,可以使用2>
符号,例如./a.out 2> error.txt
会将程序a.out
的stderr
输出重定向到error.txt
文件中。#include <stdio.h> #include <unistd.h> #include <stdlib.h> int main() { printf("hello\n"); fprintf(stderr,"hello world\n"); return 0; }
在C++中,默认打开的三个流为cin、cout、cerr,分别对应标准输入、标准输出、标准错误。
四、系统文件I/O
fopen、ifstream等流式属于语言层的方案,对系统调用进行了封装,系统调用才是打开文件最底层的方案。
介绍一种传递标志位的方法:
假设设计一个函数,根据传递的参数不同而实现不同的功能,这就可以使用位图的结构,将每一位设置为每一种功能的标志位。使用&可以鉴别各个功能的存在,使用 | 可以使一个位图传递多个功能。#include <stdio.h> #include <stdlib.h> #include <unistd.h> #define ONE 1 //0001 #define TWO 2 //0010 #define THREE 4 //0100 void func(int flag){ if(flag & ONE) printf("func of ONE\n"); if(flag & TWO) printf("func of TWO\n"); if(flag & THREE) printf("func of THREE\n"); printf("\n"); } int main() { func(ONE); func(TWO); func(THREE); func(ONE | TWO); func(TWO | THREE); func(ONE | TWO | THREE); return 0; }
系统调用open:
- 函数原型:
int open(const char *pathname, int flags, mode_t mode);
- 参数解析
pathname
:要打开或创建的文件的路径名,可以是绝对路径(如/home/user/test.txt
)或相对路径(如test.txt
,相对当前工作目录)。flags
:指定打开文件的方式和选项,常用取值如下:
O_RDONLY
:以只读方式打开文件。O_WRONLY
:以只写方式打开文件。O_RDWR
:以可读可写方式打开文件。O_CREAT
:如果文件不存在,则创建新文件。O_EXCL
:与O_CREAT
一起使用,确保文件创建时不存在,否则返回错误。O_APPEND
:以追加模式打开文件,每次写入数据时,数据将追加到文件末尾。- 这些选项都是每个二进制位表示一个功能,与传递标志位的方法一样。
- 头文件:<fcntl.h>
mode
:当O_CREAT
标志被指定时,用于指定新文件的访问权限。取值通常使用八进制表示,如0644
表示所有者可读可写,组用户和其他用户只读;
需要注意,每个启动的进程都设置有文件权限掩码(例如为0002),实际文件权限 = 设置的文件权限值 - 文件权限掩码(0644 - 0002 = 0642);
每个进程的权限掩码互不影响,可以通过调用umask(掩码值),来设置当前进程的文件权限掩码。
文件权限这部分博主有过介绍,这里不再赘述,需要可移步:Linux权限管理_linux没有root用户怎么办-CSDN博客- 返回值:成功时返回一个非负的文件描述符fd,用于后续的文件操作;失败时返回 - 1,并设置
errno
变量来指示错误原因。
系统调用read:
- 函数原型:
ssize_t read(int fd, void *buf, size_t count);
- 参数解析
fd
:要读取的文件的描述符,由open
函数返回。buf
:用于存储读取数据的缓冲区,是一个指向字符数组或其他数据类型的指针。count
:指定要读取的字节数。- 返回值:成功时返回实际读取的字节数。如果到达文件末尾,返回 0。出错时返回 - 1,并设置
errno
变量。
系统调用write:
- 函数原型:
ssize_t write(int fd, const void *buf, size_t count);
- 参数解析
fd
:要写入的文件的描述符。buf
:指向要写入数据的缓冲区的指针,数据类型通常为const char *
,但也可以是其他数据类型,取决于实际需求。count
:指定要写入的字节数。- 返回值:成功时返回实际写入的字节数。出错时返回 - 1,并设置
errno
变量。
系统调用close:
- 函数原型:
int close(int fd);
- 参数解析:
fd
为要关闭的文件的描述符。- 返回值:成功时返回 0,失败时返回 - 1,并设置
errno
变量。关闭文件后,相应的文件描述符将被释放,可以被其他文件重新使用。同时,系统会将与该文件描述符相关的内核缓冲区数据刷新到磁盘,确保数据的完整性。
五、内核缓冲区、用户态标准I/O库缓冲区、缓冲区刷新
内核缓冲区:
当使用系统调用函数(如open
、read
、write
、close
)进行文件操作时,涉及到内核缓冲区。在这种情况下,系统调用close
函数会刷新内核缓冲区:close
函数在关闭文件描述符时,会确保将内核缓冲区中与该文件描述符相关的所有待写入数据刷新到磁盘。这是为了保证数据的完整性,避免数据丢失。例如,当你使用write
函数将数据写入文件时,数据可能只是被暂时存储在内核缓冲区中,而不是立即写入磁盘。当调用close
函数关闭文件描述符时,内核会将这些缓冲的数据写入磁盘。
内核缓冲区的刷新机制:
- 延迟写入策略:内核为了提高文件 I/O 性能,采用延迟写入策略。当应用程序调用
write
系统调用将数据写入文件时,数据并不会立即被写入磁盘,而是先被复制到内核缓冲区。内核会在合适的时机(如缓冲区满、达到一定时间间隔、系统空闲等)将缓冲区中的数据批量写入磁盘,这样可以减少磁盘的 I/O 次数,提高整体性能。- 数据一致性保证:内核会确保在某些关键操作(如关闭文件close、系统关机等)时,将内核缓冲区中的数据刷新到磁盘,以保证数据的一致性和持久性。
刷新方式:
- close函数:当调用
close
函数关闭文件描述符fd时,内核会检查该文件描述符对应的内核缓冲区,将其中尚未写入磁盘的数据刷新到磁盘,然后释放相关的内核资源。- fsync函数:
fsync
函数会强制将指定文件描述符fd对应的文件的所有数据和元数据(如文件权限、修改时间等)从内核缓冲区同步到磁盘。它会阻塞调用进程,直到数据完全写入磁盘,确保数据的持久性。- fdatasync函数:
fdatasync
函数与fsync
类似,但它只保证文件的数据部分被写入磁盘,而不强制刷新文件的元数据,因此在性能上可能会比fsync
稍好。写入流程:打开文件 --> 写入数据 --> 刷新内核缓冲区(可选) --> 关闭文件
用户态标准I/O库缓冲区 :
当使用标准 I/O 库函数(如fopen
、fread
、fwrite
、fclose
)进行文件操作时,会使用用户态的标准 I/O 库缓冲区。在这种情况下:close
函数不会刷新标准 I/O 库缓冲区:close
函数是系统调用,它只能处理文件描述符相关的内核缓冲区,而无法直接操作标准 I/O 库缓冲区。如果你使用fopen
打开文件,然后使用fwrite
写入数据,数据会先被存储在标准 I/O 库缓冲区中。如果想要确保数据被写入磁盘,需要使用fflush
函数刷新标准 I/O 库缓冲区,或者使用fclose
函数,因为fclose
函数在关闭文件流时会自动刷新标准 I/O 库缓冲区。
标准I/O库缓冲区的刷新机制:
- 缓冲类型:标准 I/O 库提供了三种缓冲类型,分别是全缓冲、行缓冲和无缓冲。
- 全缓冲:当缓冲区满时才会将数据刷新到内核缓冲区。通常用于对磁盘文件的操作。
- 行缓冲:当遇到换行符
\n
或者缓冲区满时,将数据刷新到内核缓冲区。标准输入和标准输出默认采用行缓冲。- 无缓冲:数据会立即被写入内核缓冲区,不会进行缓冲。标准错误输出通常采用无缓冲。
- 自动刷新:在某些情况下,标准 I/O 库会自动刷新缓冲区,如程序正常结束时。
刷新方式:
fflush
函数,fflush
函数用于将指定FILE
指针对应的标准 I/O 库缓冲区中的数据刷新到内核缓冲区。如果传入NULL
作为参数,则会刷新所有打开的输出流的缓冲区。fclose
函数,调用fclose
函数关闭文件流时,它会自动调用fflush
函数将标准 I/O 库缓冲区中的数据刷新到内核缓冲区,然后再关闭底层的文件描述符。写入流程:打开文件 --> 写入数据 --> 刷新标准I/O库缓冲区(可选) --> 关闭文件
六、文件描述符fd、重定向
通过上面对open的介绍,知道open会返回一个int类型的值,这个返回值称之为文件描述符(File Descriptor),即为fd。
在 Linux 系统里,一切皆文件,诸如普通文件、目录、设备文件(像键盘、鼠标、磁盘等)都可以通过文件描述符来进行操作。当进程打开或创建一个文件时,内核会为该操作分配一个唯一的文件描述符,进程后续对该文件的读写、定位等操作都通过这个文件描述符来进行。
Linux进程默认情况下会有三个缺省打开的文件描述符fd:
- 标准输入(stdin):0,对应的物理设备:键盘
- 标准输出(stdout):1,对应的物理设备:显示器
- 标准错误(stderr):2,对应的物理设备:显示器
- 通过文件操作和fd的结合使用,从键盘输入到文件,再从文件输入到显示器:
#include <stdio.h> #include <string.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main() { char buf[1024]; ssize_t size = read(0,buf,sizeof(buf));//input buf from fd:0 if(size > 0){ buf[size] = '\0'; write(1,buf,strlen(buf)); write(2,buf,strlen(buf)); } return 0; }
当进程后续打开新的文件时,系统会从最小的未使用的非负整数开始分配文件描述符。例如,若 0、1、2 已被占用,新打开文件时会分配 3 作为文件描述符。
那么为什么fd是int类型?为什么fd是0、1、2、3...线性分配的?
当我们打开一个文件时,OS在内存中创建相应的数据结构 file结构体 来描述这个打开的文件属性,使用OS对打开的文件进行管理;进程执行open调用,就需要让进程和文件关联起来,所以在每个进程的PCB中,都有一个file*字段,指向一张表file_struct(文件描述符表),表内存在一个指针数组,每个元素都是一个指向进程打开的文件的指针,所以,文件描述符就是数组的下标,一个进程只需要拿着文件描述符,就可以找到对应的文件。
文件描述符的分配规则和重定向:
通过两段代码看现象:
clude <stdio.h> #include <string.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdlib.h> int main() { int fd = open("myfile",O_CREAT | O_RDONLY, 0664); if(fd < 0){ perror("myfile open"); exit(1); } printf("myfile fd:%d\n",fd); close(fd); return 0; }
输出:
前面说过,fd的0、1、2这三个是默认打开的,如果新打开的文件,fd就会分配当前没有被使用的最小的一个下标作为新的文件描述符。#include <stdio.h> #include <string.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdlib.h> int main() { int fd1 = open("myfile1",O_CREAT | O_RDWR, 0664); close(1); int fd2 = open("myfile2",O_CREAT | O_RDWR, 0664); if(fd1 < 0 && fd2 < 0){ perror("myfile1 or myfile2 open"); exit(1); } printf("myfile1 fd:%d\n",fd1); printf("myfile2 fd:%d\n",fd2); fflush(stdout); close(fd1); close(fd2); return 0; }
close(1)后,fd为1的输出流被关闭了,所以当前没有被使用的最小下标就是1,所以新打开的myfile2分配的fd就是1;因为输出流被关闭了,所以printf语句没有输出到显示器上,而是输出到当前fd为1的myfile2,通过cat就能发现输出内容。
(注意:这里需要调用fflush(stdout))刷新一下缓存区才能将内容重定向到myfile2中,因为printf
是标准 I/O 库函数,它使用标准 I/O 缓冲区。在标准输出未重定向时,标准输出通常采用行缓冲模式,即遇到换行符\n
时会自动刷新缓冲区。但当标准输出被重定向到文件时,标准 I/O 库会将缓冲模式切换为全缓冲模式,此时遇到换行符\n
不会自动刷新缓冲区,只有当缓冲区满或者手动调用fflush
函数时,缓冲区中的数据才会被写入文件。
原本应该输出到显示器的内容,被输出到其他文件中,fd=1指向其他file结构体,这样的现象就叫做输出重定向。常见的输出重定向有shell的:> ,>> ,<,。
可以通过系统调用 dup2 来完成fd的指向,完成重定向操作。
函数原型
#include <unistd.h> int dup2(int oldfd, int newfd);
参数
oldfd
:旧的文件描述符,也就是要被复制的文件描述符。它必须是一个已经打开的有效的文件描述符。newfd
:新的文件描述符,复制操作将把oldfd
所指向的文件对象关联到newfd
上。如果newfd
已经打开,那么在复制之前会先关闭newfd
。返回值
- 成功:返回新的文件描述符
newfd
。- 失败:返回 -1,并设置
errno
来指示具体的错误原因。常见的错误包括oldfd
无效、newfd
是一个无效的文件描述符编号等。功能
dup2
函数的主要功能是将oldfd
对应的文件描述符复制到newfd
上。复制完成后,oldfd
和newfd
都会指向同一个文件对象,它们共享文件的偏移量、文件状态标志等信息。也就是说,对oldfd
或newfd
进行的读写操作都会影响同一个文件。
使用场景
- 输入输出重定向:在 shell 脚本中,经常会使用
dup2
来实现输入输出的重定向。例如,将标准输出重定向到一个文件,这样程序的所有输出都会被写入该文件而不是显示在终端上。- 多进程编程:在创建子进程时,子进程可以使用
dup2
来复制父进程的文件描述符,从而实现父子进程之间共享文件资源。示例:
#include <stdio.h> #include <unistd.h> #include <fcntl.h> #include <sys/types.h> #include <stdlib.h> int main() { int fd = open("myfile",O_CREAT | O_RDWR, 0664); if(fd < 0){ perror("myfile open"); exit(1); } if(dup2(fd,1) == -1){ perror("dup2"); close(fd); exit(1); } printf("This output will be redirected to the file\n"); close(fd); return 0; }
七、file结构体
上面谈到的file结构体,是一个关键的数据结构,它用于表示一个打开的文件。当进程打开一个文件时,内核会为这个打开的文件实例创建一个 file
结构体,通过该结构体对文件进行管理和操作。file
结构体定义在内核源码的 <linux/fs.h>
头文件中,其定义较为复杂,下面给出一个简化版本,展示一些主要成员:
struct file {
union {
struct llist_node fu_llist;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;
/*
* Protects f_ep_links, f_flags.
* Must not be taken from IRQ context.
*/
spinlock_t f_lock;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;
u64 f_version;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* needed for tty driver, and maybe others */
void *private_data;
#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct list_head f_ep_links;
struct list_head f_tfile_llink;
#endif
struct address_space *f_mapping;
};
1. 文件路径和索引节点信息
struct path f_path
:包含文件的挂载点和目录项信息,用于确定文件在文件系统中的位置。struct inode *f_inode
:指向文件对应的inode
结构体。inode
存储了文件的元数据,如文件大小、权限、创建时间等,以及文件数据块在磁盘上的存储位置信息。
2. 文件操作函数集
const struct file_operations *f_op
:指向一个file_operations
结构体,该结构体包含了一系列用于操作文件的函数指针,如read
、write
、open
、release
等。不同类型的文件(如普通文件、字符设备文件、块设备文件)会有不同的file_operations
实现,通过f_op
可以调用相应的操作函数来完成文件的读写等操作。
3. 文件状态和标志
unsigned int f_flags
:存储文件的打开标志,如O_RDONLY
(只读)、O_WRONLY
(只写)、O_RDWR
(读写)等,这些标志在调用open
系统调用时指定。fmode_t f_mode
:表示文件的访问模式,如读、写、执行等权限信息。
4. 文件偏移量
loff_t f_pos
:记录文件当前的读写位置,即文件偏移量。每次进行读写操作时,会根据操作的字节数更新这个偏移量。
5. 引用计数
atomic_long_t f_count
:用于记录对该file
结构体的引用计数。当有新的文件描述符指向这个file
结构体时,引用计数加 1;当关闭文件描述符时,引用计数减 1。当引用计数为 0 时,内核会释放该file
结构体。
6.file
结构体的作用
- 管理打开的文件:内核通过
file
结构体对每个打开的文件进行管理,记录文件的状态、位置、操作函数等信息,方便进行文件的读写、定位等操作。 - 实现文件共享:多个文件描述符可以指向同一个
file
结构体,从而实现文件的共享访问。不同的进程或同一个进程的不同文件描述符可以共享同一个file
结构体,它们共享文件的偏移量和状态信息。 - 抽象文件操作:
file
结构体中的f_op
成员提供了统一的文件操作接口,使得内核可以以相同的方式处理不同类型的文件,提高了系统的可扩展性和兼容性。
7.相关操作
- 创建
file
结构体:当进程调用open
系统调用打开一个文件时,内核会分配一个新的file
结构体,并初始化其各个成员。 - 释放
file
结构体:当所有引用该file
结构体的文件描述符都被关闭后,内核会释放该file
结构体,回收相关资源。 - 文件操作:通过
file
结构体中的f_op
成员调用相应的操作函数,实现文件的读写、定位等操作。
8.指向问题(多个进程或者同一个进程里的多个文件描述符指向同一个文件时,是否需要创建多个 file
结构体)
同一个进程内多个文件描述符指向同一文件
- 使用
dup
或dup2
复制文件描述符:当在同一个进程里使用dup
或者dup2
系统调用复制文件描述符时,不会创建新的file
结构体。这两个系统调用的作用是复制已有的文件描述符,让新的文件描述符和原文件描述符指向同一个file
结构体。多个文件描述符共享同一个file
结构体意味着它们共享文件的偏移量、状态标志等信息。例如,若一个文件描述符改变了文件的偏移量,其他指向同一file
结构体的文件描述符也会受到影响。 - 多次调用
open
打开同一文件:如果在同一个进程里多次调用open
函数打开同一个文件,每次调用都会创建一个新的file
结构体。这是因为每次open
调用都被视为一个独立的打开操作,每个file
结构体有自己独立的文件偏移量和状态标志。不同的file
结构体虽然对应同一个文件(即共享同一个inode
结构体),但它们之间的操作是相互独立的。
不同进程指向同一文件
- 父子进程通过
fork
继承文件描述符:当父进程打开一个文件后调用fork
创建子进程,子进程会继承父进程的文件描述符。这些继承的文件描述符会指向和父进程相同的file
结构体。也就是说,父子进程共享同一个file
结构体,它们对文件的操作会相互影响,例如文件偏移量的改变会被双方感知。 - 不同进程分别调用
open
打开同一文件:如果不同的进程分别调用open
函数打开同一个文件,每个进程都会创建自己的file
结构体。不同进程的file
结构体是相互独立的,它们有各自的文件偏移量和状态标志,彼此之间的操作不会直接相互影响。
9.重新理解“一切皆文件”
在Linux中,进程、磁盘、显示器、键盘等都被抽象成了文件,可以使用访问文件的方法访问它们获取信息;这样做的好处就是,开发者只需要一套API和开发工具,即可调取Linux系统中绝大部分的资源。举个简单例子,Linux中几乎所有读操作(读文件,读系统状态,读PIPE)都可以用read函数进行;几乎所有更改操作(更改文件、更改系统参数、写PIPE)都可以用write函数来进行。
在结构体struct file中的 f_op 指针指向一个 file_operations 结构体,这个结构体中的成员除了 struct module *owner 其余都是函数指针。
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
int (*iterate) (struct file *, struct dir_context *);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*aio_fsync) (struct kiocb *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **, void **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
unsigned (*mmap_capabilities)(struct file *);
#endif
};
file_operator 就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都对应着一个系统调用,读取 file_operator 中相应的函数指针,然后把控制权交给函数,从而完成Linux设备驱动程序的工作。每个设备都有自己的read、write,但一定是对应着不同的操作方法(实现不同),但通过struct file下 file_operation 中的各种函数回调,让开发者只用file便可以调取Linux系统中绝大部分的资源,这就是“一切皆文件”的核心。
八、FILE与fd
重新梳理一下FILE和fd的区别和关系:
抽象层次的区别:
fd
(文件描述符):属于操作系统内核层面的概念。它是一个非负整数,是内核为了管理进程打开的文件而分配的索引。进程通过文件描述符与内核中的文件对象交互,是一种底层的文件操作方式。FILE
:是标准 C 库层面的抽象。FILE
是一个结构体类型,它封装了文件描述、系统调用以及其他与文件操作相关的信息,如缓冲区状态、错误标志等。接口的区别:
fd
:使用系统调用函数进行操作,例如open
、read
、write
、close
等。这些系统调用直接与内核交互,绕过了标准 C 库的缓冲区,数据直接在用户空间和内核空间之间传输。FILE
:使用标准 C 库函数进行操作,如fopen
、fread
、fwrite
、fclose
等。这些函数在内部会调用相应的系统调用,同时还处理了缓冲区管理、错误检查等额外的工作。缓冲区管理:
fd
:不涉及标准 C 库的缓冲区。使用read
和write
系统调用时,数据直接在用户空间和内核缓冲区之间复制。内核会根据自身的策略(如延迟写入)来决定何时将内核缓冲区中的数据写入磁盘。FILE
:FILE
结构体管理着标准 C 库的缓冲区,有全缓冲、行缓冲和无缓冲三种模式。全缓冲模式下,缓冲区满时才会将数据刷新到内核缓冲区;行缓冲模式下,遇到换行符或缓冲区满时刷新;无缓冲模式下,数据立即写入内核缓冲区(比如标准错误)。可以使用fflush
函数手动刷新缓冲区。
FILE和fd相互转换:
- 从
fd
到FILE
:可以使用fdopen
函数将一个文件描述符转换为FILE
指针。这样就可以使用标准 C 库函数对该文件进行操作。- 从
FILE
到fd
:可以使用fileno
函数从FILE
指针获取对应的文件描述符。这在需要使用系统调用对FILE
指针指向的文件进行操作时非常有用。
FILE
结构体在内部会持有一个文件描述符,通过这个文件描述符与内核中的文件对象进行交互。也就是说,标准 C 库函数在实现文件操作时,最终还是会调用系统调用,借助文件描述符来完成实际的文件读写等操作。
看如下代码:
#include <stdio.h> #include <string.h> #include <unistd.h> int main() { const char *s1 = "Hello printf\n"; const char *s2 = "Hello fwrite\n"; const char *s3 = "Hello write\n"; printf("%s",s1); fwrite(s2,strlen(s2),1,stdout); write(1,s3,strlen(s3)); fork(); return 0; }
为什么同一个代码,输出到显示器和输出到文件的结构不同呢?
- 一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲。
- printf fwrite库函数会自带缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲,所以不会遇到\n就刷新了,而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后,也没有刷新缓冲区;fork的时候,父子数据会发生写时拷贝,子进程也有一份同样的数据,在父进程的缓冲区的数据在子进程也有一份,进程退出之后,会统一刷新,父子进程的缓冲区的数据写入,所以同样的一份数据,随即产生两份数据。
- 然而write 没有变化,说明没有所谓printf和write的缓冲。
所以,printf和fwrite 库函数会自带缓冲区,而 write 系统调用没有带缓冲区。另外,我们这
里所说的缓冲区,都是用户级缓冲区(标准库I/O缓冲区)。为了提升整机性能,OS也会提供相关内核级缓冲区。
那这个缓冲区谁提供呢?
printf和fwrite是库函数,write是系统调用,库函数在系统调用的“上层”,是对系统调用的“封装”,但是 write 没有缓冲区,而 printf和fwrite 有,足以说明,该缓冲区是二次加上的,又因为是C,所以由C标准库提供。
glibc 中的FILE
结构体:struct _IO_FILE { int _flags; /* 文件状态标志,如是否为只读、是否已到达文件末尾等 */ char *_IO_read_ptr; /* 当前读指针位置 */ char *_IO_read_end; /* 读缓冲区的结束位置 */ char *_IO_read_base; /* 读缓冲区的起始位置 */ char *_IO_write_base; /* 写缓冲区的起始位置 */ char *_IO_write_ptr; /* 当前写指针位置 */ char *_IO_write_end; /* 写缓冲区的结束位置 */ char *_IO_buf_base; /* 缓冲区的起始位置 */ char *_IO_buf_end; /* 缓冲区的结束位置 */ int _fileno; /* 文件描述符 */ /* 其他成员,用于处理错误状态、锁机制、流的方向等 */ }; typedef struct _IO_FILE FILE;
可以看出FILE结构体封装了fd,和缓冲区。
模拟简单封装FILE和操作函数:
//my_stdio.h #pragma once #define SIZE 2048 #define FLUSH_NONE 0 #define FLUSH_LINE 1 #define FLUSH_FULL 2 typedef struct IO_FILE{ int flag; int fileno; char outbuffer[SIZE]; int cap; int size; }myFILE; myFILE *myfopen(const char *filename,const char *mode); int myfwrite(const void *ptr,int num,myFILE *stream); void myfflush(myFILE *stream); void myclose(myFILE *stream);
//my_stdio.c #include "my_stdio.h" #include <string.h> #include <stdlib.h> #include <unistd.h> #include <sys/stat.h> #include <sys/types.h> #include <fcntl.h> myFILE*myfopen(const char *filename, const char *mode){ int fd = -1; if(strcmp(mode,"r") == 0) fd = open(filename,O_RDONLY); else if(strcmp(mode,"w") == 0) fd = open(filename,O_CREAT | O_WRONLY | O_TRUNC, 0666); else if(strcmp(mode,"a") == 0) fd = open(filename,O_CREAT | O_WRONLY | O_APPEND, 0666); else{ perror("myfopen mode"); exit(1); } if(fd < 0) return NULL; myFILE *mf = (myFILE*)malloc(sizeof(myFILE)); if(!mf){ close(fd); return NULL; } mf->fileno = fd; mf->flag = FLUSH_LINE; mf->size = 0; mf->cap = SIZE; return mf; } int myfwrite(const void *ptr,int num,myFILE *stream){ memcpy(stream->outbuffer+stream->size,ptr,num); stream->size += num; //flush if(stream->flag == FLUSH_LINE && stream->size > 0 && stream->outbuffer[stream->size - 1] == '\n') myfflush(stream); return num; } void myfflush(myFILE *stream){ if(stream->size > 0){ write(stream->fileno,stream->outbuffer,stream->size); fsync(stream->fileno); stream->size = 0; } } void myclose(myFILE *stream){ if(stream->size > 0) myfflush(stream); close(stream->fileno); }
//main.c #include "my_stdio.h" #include <stdio.h> #include <string.h> #include <unistd.h> int main() { myFILE *fp = myfopen("myfile","w"); if(fp == NULL) return 1; int i; for(i = 1; i <= 10; i++){ printf("write %d\n",i); char buffer[64]; snprintf(buffer,sizeof(buffer),"hello, num is %d",i); myfwrite(buffer,strlen(buffer),fp); myfflush(fp); sleep(1); } myclose(fp); return 0; }