文章目录
- 一、文件描述符
- 1. 理解:Linux下一切皆文件
- 2. 文件描述符(fd)的概念
- 3. 文件描述符的分配规则
- 4. 进程创建时默认打开的 0 & 1 & 2 号文件
- 二、重定向
- 1. 重定向的本质
- 2. 使用dup2系统调用函数
- 3. bash下的三种重定向
- 4. 三种重定向的实现
- 输出重定向
- 输入重定向
- 追加重定向
- 三、理解C语言文件指针和Linux中的文件fd的关系
- 四、理解用户级缓冲区
[!Abstract] Linux文件相关重点
- 复习C文件IO相关操作
- 认识文件相关系统调用接口
- 认识文件描述符,理解重定向
- 对比fd和FILE,理解系统调用和库函数的关系
- 理解文件系统中inode的概念
- 认识软硬链接,对比区别
- 认识动态静态库,学会结合gcc选项,制作动静态库
一、文件描述符
1. 理解:Linux下一切皆文件
在Linux系统中,"一切皆文件"是一个核心概念,意味着系统中的各种资源,包括设备、套接字、管道等,都被抽象为文件,并通过文件描述符进行访问。
[!Quote]
在 【Linux】从冯诺依曼体系结构到操作系统 中我们学到,操作系统是一款管理软件,它通过向下管理好各种软硬件资源 (手段),来向上提供良好 (安全、稳定、高效) 的运行环境 (目的);也就是说,键盘、显示器、磁盘、网卡等硬件也是由操作系统来管理的。而操作系统管理软硬件的方法是 先描述、再组织,即先将这些设备的各种属性抽象出来组成一个结构体,然后为每一个设备都创建一个结构体对象,再用某种数据结构将这些对象组织起来;这也就是我们上面学习到的 文件内核数据结构 file;同时,每种硬件的访问方法都是不一样的,比如,向磁盘中读写数据与向网卡中读写数据是有明显差异的,所以操作系统需要为每一种硬件都单独提供对应的 Read、Write 方法,这些方法位于驱动层。
但是,内核数据结构是位于操作系统层的,它如何与对应的读写方法联系起来呢?-- 通过函数指针,即在 struct file 结构体中创建函数指针变量,用于指向具体的 Write 和 Read 方法函数,这样每一个硬件都可以通过自己 file 对象中的 writep 和 readp 函数指针变量来找到位于驱动层的 Write 和 Read 方法,如下:
struct file { //文件的各种属性 int types; //文件的类型 int status; //文件的状态 int (*writep)(...); //函数指针,指向读函数 int (*readp)(...); //函数指针,指向写函数 struct file* next; //下一个file对象的地址 ... }
如图,站在操作系统内核数据结构上层来看,所有的软硬件设备和文件统一都是 file 对象,即 Linux 下一切皆文件。
注:对于键盘来说,我们只能从其中读入数据,而并不能向其写入数据;同样的,对于显示器来说,我们只能向其写入数据,而并不能从它读入数据;所以,键盘的 Write 方法和显示器的显示器的 Read 方法我们都设为 NULL。
其实 Linux 一切皆文件的特性就是面向对象语言多态的特性,file 结构体相当于基类,驱动层的各种方法和结构就相当于子类。(Linux 在编写时C++等面向对象的语言还并没有出现,所以这里是用C语言模拟实现C++面向对象)
同时,struct file 是操作系统当中虚拟出来的一层文件对象,在 Linux 中,我们一般将这一层称为 虚拟文件系统 vfs,通过它,我们就可以摒弃掉底层设备的差别,统一使用文件接口的方式来进行文件操作。
原文链接:https://blog.csdn.net/m0_62391199/article/details/128600173
2. 文件描述符(fd)的概念
先说结论:文件描述符(file descriptor)本质上是文件描述符表的数组下标
文件是由进程运行时打开的,一个进程可以打开多个文件,而系统当中又存在大量进程,也就是说,在系统中任何时刻都可能存在大量已经打开的文件。因此,操作系统务必要对这些已经打开的文件进行管理,管理的方式还是:先描述在组织。
- 操作系统会为每个已经打开的文件创建各自的
struct file
结构体 - 描述 - 然后将这些结构体以双链表的形式连接起来 - 组织
- 之后操作系统对文件的管理也就变成了对这张双链表的增删查改等操作。
而为了区分已经打开的文件哪些属于特定的某一个进程,我们就还需要建立进程和文件之间的对应关系。
[!Question] 进程和文件之间的对应关系如何建立?
- 我们知道,当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的
task_struct
、mm_struct
、页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的映射关系。
![[进程地址空间_页表_文件fd.drawio (1).png]]- 而
task_struct
当中有一个指针,该指针指向一个名为files_struct
的结构体:
在该结构体当中就有一个名为
fd_array
的指针数组,该数组的下标就是我们所谓的文件描述符。
下面是源码中fd_array的定义:
fd_array的大小可能是32或64,取决于平台:
当进程用open()系统调用打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file连入文件双链表,并将该结构体的首地址填入fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后open()返回该文件的文件描述符给调用进程即可。
进程与被打开文件:
当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件,于是就有了 file 结构体,表示一个已经打开的文件对象。
而进程执行 open 系统调用,所以必须让进程和文件关联起来,于是每个进程都有一个 *files 指针,指向一张表 files_struct,该表最重要的部分就是包含一个指针数组,数组中每个元素都是一个指向打开文件的指针。
因此,一个进程只要拿着文件描述符,就可以找到此进程打开的文件。
3. 文件描述符的分配规则
尝试打开几个文件来研究文件描述符的分配规则:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <unistd.h>// C语言中 # 在宏当中的作用:将参数插入到字符串中
#define FILE_NAME(number) "log.txt"#number int main() {umask(0);int fd1 = open(FILE_NAME(1), O_WRONLY | O_CREAT | O_APPEND, 0666);int fd2 = open(FILE_NAME(2), O_WRONLY | O_CREAT | O_APPEND, 0666);int fd3 = open(FILE_NAME(3), O_WRONLY | O_CREAT | O_APPEND, 0666);int fd4 = open(FILE_NAME(4), O_WRONLY | O_CREAT | O_APPEND, 0666);int fd5 = open(FILE_NAME(5), O_WRONLY | O_CREAT | O_APPEND, 0666);printf("fd1:%d\n", fd1);printf("fd2:%d\n", fd2);printf("fd3:%d\n", fd3);printf("fd4:%d\n", fd4);printf("fd5:%d\n", fd5);close(fd1);close(fd2);close(fd3);close(fd4);close(fd5);
}
现象:
可以看到,文件描述符是连续分配且依次增大的,这很合理,因为文件描述符本质上是数组下标。但是这里有一个很奇怪的地方,就是文件描述符是从3开始的,那么0、1、2号下标呢?这是由于三个默认打开的标准流引起的,分别是标准输入、标准输出、标准错误流。
4. 进程创建时默认打开的 0 & 1 & 2 号文件
[!Abstract]
在Linux下,进程启动时,会默认打开文件描述符为0(stdin)、1(stdout)、2(stderr)的文件。这三个文件描述符分别代表标准输入、标准输出和标准错误。默认情况下,#include <stdio.h>int main() {printf("stdin->fd:%d\n", stdin->_fileno);printf("stdout->fd:%d\n", stdout->_fileno);printf("stderr->fd:%d\n", stderr->_fileno); }
stdin(标准输入):默认情况下,stdin对应键盘输入。当你在终端输入内容时,这些输入会被送到stdin。
stdout(标准输出):默认情况下,stdout对应显示器(终端屏幕)。当程序输出信息时,这些信息会被显示在终端屏幕上。
stderr(标准错误):同样,默认情况下,stderr也对应显示器(终端屏幕)。但stderr通常被用于输出错误信息,使得错误信息和正常输出信息可以分开显示。
在实际应用中,这些标准输入输出也可以被重定向到其他地方,例如从文件读取输入或将输出写入文件。
既然系统默认打开三个文件,那么我们可不可以将其关闭呢?当然可以!
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <unistd.h>#define FILE_NAME(number) "log.txt"#number int main() {close(0);close(2);umask(0);int fd1 = open(FILE_NAME(1), O_WRONLY | O_CREAT | O_APPEND, 0666);int fd2 = open(FILE_NAME(2), O_WRONLY | O_CREAT | O_APPEND, 0666);int fd3 = open(FILE_NAME(3), O_WRONLY | O_CREAT | O_APPEND, 0666);int fd4 = open(FILE_NAME(4), O_WRONLY | O_CREAT | O_APPEND, 0666);int fd5 = open(FILE_NAME(5), O_WRONLY | O_CREAT | O_APPEND, 0666);printf("fd1:%d\n", fd1);printf("fd2:%d\n", fd2);printf("fd3:%d\n", fd3);printf("fd4:%d\n", fd4);printf("fd5:%d\n", fd5);close(fd1);close(fd2);close(fd3);close(fd4);close(fd5);
}
现象:当下标为0号和2号的文件描述符所指向的文件被关闭后,
close(0);
close(2);
系统将其分配给了新打开的文件 log.txt1 和 log.txt2。
[!Attention] 注意
close 关闭文件并不是将 fd 指向的 file 对象释放掉,而仅仅是让当前进程文件描述符表中的对应下标不再指向该 file 对象,因为同一个文件可能会被多个进程访问,特别是父子进程。
事实上,Linux的文件对象底层采用 f_count 引用计数的方式来实现:
- 每当一个新的文件描述符指向同一个文件对象时,该文件对象的引用计数会增加。
- 当文件描述符被关闭时,引用计数会减少。
- 当引用计数变为零时,表示没有文件描述符或进程再引用这个文件对象,此时文件对象可以被释放,可以真正意义上的关闭文件。
总结文件描述符的分配规则:从小到大依次搜寻,寻找未被使用的最小 fd 作为新打开文件的 fd。
二、重定向
1. 重定向的本质
重定向的本质就是修改文件描述符下标对应的 struct file*
指针的指向。
我们上面在演示 fd 分配规则的时候,关闭了标准输入和标准错误,那么如果我们关闭标准输出呢?如下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>#define FILE_NAME "log.txt"int main()
{close(1); //关闭标准输出 int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd == -1) {perror("open");return 1;}printf("fd:%d\n", fd);fprintf(stdout, "fd:%d\n", fd);fflush(stdout);close(fd);return 0;
}
现象:
本来应该往一个文件(屏幕)中写入数据,但是却写入到另一个文件(log.txt)中去了,这种特性就叫做重定向;而重定向的本质是上层使用的 fd 不变,在内核中更改 fd 指向的 file 对象,即更改文件描述符表数组中 fd 下标中的内容,让其变为另一个 file 对象的地址。
2. 使用dup2系统调用函数
我们可以使用上面 close(1) 的方式实现重定向,但是我们发现先关闭、再打开这种方式非常麻烦,并且如果 0 和 1 号 fd 都被关闭时,我们还需要先创建一个无用的临时文件占用掉 0 号 fd 之后才能使新文件的 fd 为 1。为了解决这种尴尬的情况,操作系统提供了一个系统调用接口 dup2 来让我们直接进行重定向。
dup2的函数原型如下:
int dup2(int oldfd, int newfd);
函数功能: dup2会将fd_array[oldfd]
的指针拷贝到fd_array[newfd]
当中,如果有必要的话我们需要先使用关闭文件描述符为newfd的文件。
函数返回值: dup2如果调用成功,返回newfd,否则返回-1。
使用dup2时,我们需要注意以下两点:
- 如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。
- 如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。
使用一下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>#define FILE_NAME "log.txt"int main() {umask(0);int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd == -1) {perror("open");return 1;}int ret = dup2(fd, 1); //fd是oldfd,1是newfd,让1成为fd的拷贝if(ret == -1) {perror("dup2");return 1;}printf("fd:%d\n", fd);fprintf(stdout, "fd:%d\n", fd);fflush(stdout);close(fd);return 0;
}
3. bash下的三种重定向
在 Linux 命令行中,可以使用 <
进行输入重定向,>
进行输出重定向,>>
进行追加重定向。下面是简单的例子演示这三种重定向的用法:
- 输出重定向
>
:
# 将命令 ls 的输出写入文件 output.txt
ls -l > output.txt
>
将 ls -l
命令的输出重定向到文件 output.txt
。
- 追加重定向
>>
:
# 将命令 echo 的输出追加到文件 append.txt
echo "New content" >> append.txt
>>
将 echo "New content"
命令的输出追加到文件 append.txt
。
[!Question] 没有在这个字符串末尾添加‘\n’,但是却换了行???
在类Unix系统中,包括Linux,
echo
命令默认会在输出字符串的末尾自动添加换行符(\n
)。所以,当你执行五次echo "New content" >> append.txt
时,每次输出的字符串末尾都会有一个换行符。可以通过查看文件的内容,或者使用
cat -v
命令来显示不可见字符,来确认是否有换行符。cat -v append.txt
如果希望在每次追加时都不添加换行符,可以使用
echo -n
来禁止echo
添加换行符:echo -n "New content" >> append.txt
这样做会在文件中追加不带换行符的字符串。
- 输入重定向
<
:
# 从文件 input.txt 中读取内容,并传递给命令 cat
cat < input.txt
通过 <
进行输入重定向,你可以让 cat
命令读取文件 input.txt
的内容而不是从标准输入中读取。这样,cat
将会把 input.txt
文件的内容输出到终端,就好像你直接键入了这些内容一样。
上述命令的执行过程是,cat
命令从标准输入读取数据,但 < input.txt
的作用是将文件 input.txt
的内容传递给 cat
命令的标准输入。因此,cat
将显示 input.txt
文件的内容到终端上。
cat input.txt
和cat < input.txt
在输出上的结果可能是相同的,但它们的本质是有一些区别的。
cat input.txt
: 这是直接使用文件名作为参数传递给cat
命令。cat
命令会打开文件input.txt
并显示其内容。这种方式直接在命令行中指定了文件名,cat
命令会自己打开文件并读取内容。cat input.txt
cat < input.txt
: 这是使用输入重定向符<
将文件input.txt
的内容传递给cat
命令的标准输入。在这种情况下,cat
命令并不知道文件名,而是从标准输入中读取数据。这样的写法主要用于将文件的内容传递给需要从标准输入读取数据的命令。cat < input.txt
从输出结果上来看,两者可能是相同的,因为它们都会将文件的内容输出到终端。但是,本质上,第一个例子是直接将文件名传递给
cat
命令,而第二个例子是通过输入重定向将文件的内容传递给cat
命令。
4. 三种重定向的实现
输出重定向
通过 dup2(fd, 1) 系统调用将目标文件 fd 中的内容拷贝到 1 号 fd 中,从而将本该写入到显示器中的数据写入到目标文件中。注:stdout的值是1。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>#define FILE_NAME "log.txt"int main()
{umask(0);int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd == -1){perror("open");return 1;}int ret = dup2(fd, 1); //fd是oldfd,1是newfd,让1成为fd的拷贝if(ret == -1){perror("dup2");return 1;}printf("I am printf, fd:%d\n", fd);fprintf(stdout, "I am fprintf through stdout, fd:%d\n", fd);char* msg = "hello redirect\n";write(1, msg, strlen(msg));fflush(stdout);close(fd);return 0;
}
“hello redirect” 是最后写入的,但是它却出现在了文本的最前面,这还是由缓冲区造成的,我们后文会详细讲解缓冲区。
输入重定向
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>#define FILE_NAME "log.txt"int main()
{int fd = open(FILE_NAME, O_RDONLY);if(fd == -1) {perror("open");return 1;}int ret = dup2(fd, 0); //输入重定向if(ret == -1) {perror("dup2");return 1;}char buf[64];while(fgets(buf, sizeof(buf) - 1, stdin) != NULL) {buf[strlen(buf)] = '\0';printf("%s", buf); //将从stdin中读入的数据(重定向后是从log.txt中读取)写入到stdout中}close(fd);return 0;
}
追加重定向
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>#define FILE_NAME "log.txt"int main()
{umask(0);int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_APPEND, 0666);if(fd == -1) {perror("open");return 1;}int ret = dup2(fd, 1); //fd是oldfd,1是newfd,让1成为fd的拷贝if(ret == -1) {perror("dup2");return 1;}printf("fd:%d\n", fd);fprintf(stdout, "fd:%d\n", fd);char* msg = "hello redirect\n";write(1, msg, strlen(msg));fflush(stdout);close(fd);return 0;
}
三、理解C语言文件指针和Linux中的文件fd的关系
- 因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。
- 所以C库当中的FILE结构体内部,必定封装了fd。
验证一下:
首先,我们在/usr/include/stdio.h
头文件中可以看到下面这句代码,也就是说FILE实际上就是struct _IO_FILE
结构体的一个别名。
typedef struct _IO_FILE FILE;
而我们在/usr/include/libio.h
头文件中可以找到struct _IO_FILE
结构体的定义,在该结构体的众多成员当中,我们可以看到一个名为_fileno
的成员,这个成员实际上就是封装的文件描述符。
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 0int _blksize;
#elseint _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
};
[!Question] 现在我们再来理解一下C语言当中的fopen函数究竟在做什么?
fopen函数在上层为用户申请FILE结构体变量,并返回该结构体的地址(FILE*),在底层通过系统接口open打开对应的文件,得到文件描述符fd,并把fd填充到FILE结构体当中的_fileno变量中,至此便完成了文件的打开操作。
而C语言当中的其他文件操作函数,比如fread、fwrite、fputs、fgets等,都是先根据我们传入的文件指针找到对应的FILE结构体,然后在FILE结构体当中找到文件描述符,最后通过文件描述符对文件进行的一系列操作。
四、理解用户级缓冲区
我们常说的缓冲区一般都是是语言级的,而不是操作系统级别的,操作系统的缓冲区叫内核缓冲区。
来段代码研究一下:
#include <stdio.h>
#include <string.h>
int main()
{const char *msg0="hello printf\n";const char *msg1="hello fwrite\n";const char *msg2="hello write\n";printf("%s", msg0);fwrite(msg1, strlen(msg1), 1, stdout);write(1, msg2, strlen(msg2));fork(); // 注意fork的位置!return 0;
}
运行出结果:
但如果对进程实现输出重定向呢? ./test > file
, 我们发现结果变成了:
我们发现printf
和fwrite
(库函数)都输出了2次,而write
只输出了一次(系统调用)。为什么呢?肯定和fork()有关!
首先我们应该知道的是,缓冲的方式有以下三种:
- 无缓冲。
- 行缓冲。(常见的对显示器进行刷新数据)
- 全缓冲。(常见的对磁盘文件写入数据)
解释现象:
- 一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲。
- printf fwrite 库函数会自带缓冲区(进度条例子就可以说明),当发生重定向到普通文件时,整个程序的数据的缓冲方式由行缓冲变成了全缓冲。
- 而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork被执行之后。
- 但是进程退出之后,会统一刷新,写入文件当中。
- 但是fork的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。
- write 没有变化,说明write没有提供缓冲区。
综上:
printf fwrite 库函数会自带缓冲区,而 write 系统调用没有带缓冲区。另外,我们这里所说的缓冲区,都是用户级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区,不过不再我们讨论范围之内。那这个缓冲区谁提供呢? printf fwrite 是库函数, write 是系统调用,库函数在系统调用的“上层”, 是对系统调用的“封装”,但是write 没有缓冲区,而 printf fwrite 有,足以说明该缓冲区是C语言加上的,该缓冲区由C标准库提供。