在学习文件描述符前,首先要了解一下Linux系统常用的文件系统接口。
//open函数所在的头文件和函数声明
#include
#include
#include
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
其中flags参数采用位图结构来接收要操作文件的打开模式,位图结构在这里的作用是只使用一个参数就可以传入多个标志信息,位图结构的具体形式是将flags参数的这样一个32位的整形变量中的每一个比特位都看作为一个个体,每个比特位都代表一个标志信息,位图结构的示意图如下:
假设flags参数的倒数第一比特位表示是否清空文件,如果该比特位为1表示需要清空文件,如果为0则表示不需要清空文件。
为了测试open函数编写如下代码:
#include
#include
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
int fd = open(LOG, O_WRONLY);
if (fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
exit(1);
}
return 0;
}
编译代码运行并查看结果:
由于当前路径下不存在log.txt文件,O_WRONLY
打开模式是以读的方式打开文件,因此open函数使用失败,从错误信息中也可以看到失败原因是文件不存在。
再编写如下代码测试open函数:
#include
#include
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
int fd = open(LOG, O_WRONLY | O_CREAT);
if (fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
exit(1);
}
return 0;
编译代码运行并查看结果:
执行程序后,我们能够发现由于文件权限是存在问题的,因此文件名被高亮显式了,由于本次使用的是O_WRONLY | O_CREAT
模式打开 O_CREAT
模式是如果文件不存在就创建文件,但是由于没有设置权限,因此文件权限存在问题。
再编写如下代码测试open函数:
#include
#include
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
umask(0);
int fd = open(LOG, O_WRONLY | O_CREAT, 0666);
if (fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
exit(1);
}
else printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
return 0;
}
编译代码运行并查看结果:
三参数的open函数接口第三个接口提供了创建文件是设置文件权限的功能,另外本段代码中使用了umask
函数,设置了该进程所使用的umask值,保证所创建的文件权限是想获得的。
//umask函数所在的头文件和函数声明
#include
#include
mode_t umask(mode_t mask);
//close函数所在的头文件和函数声明
#include
int close(int fd);
编写如下代码测试close函数:
#include
#include
#include
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
umask(0);
int fd = open(LOG, O_WRONLY | O_CREAT, 0666);
if (fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
exit(1);
}
else printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
close(fd);
return 0;
}
编译代码运行并查看结果:
//write函数所在的头文件和函数声明
#include
ssize_t write(int fd, const void *buf, size_t count);
编写如下代码测试write函数:
#include
#include
#include
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
umask(0);
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
exit(1);
}
else printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
const char* msg = "hello world\n";
write(fd, msg, strlen(msg));
close(fd);
return 0;
}
编译代码运行并查看结果:
说明:
O_TRUNC
模式是在打开文件时先清空文件'\0'
写入,其作用是在语言上表示字符串的结尾,在文件中不需要。再编写如下代码测试write函数:
#include
#include
#include
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
umask(0);
int fd = open(LOG, O_WRONLY | O_CREAT | O_APPEND, 0666);
if (fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
exit(1);
}
else printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
const char* msg = "hello world i love you\n";
write(fd, msg, strlen(msg));
close(fd);
return 0;
}
编译代码运行并查看结果:
说明: O_APPEND
模式是采用追加的方式,需要和O_WRONLY
模式配合使用才能实现追加写入。
//read函数所在的头文件和函数声明
#include
ssize_t read(int fd, void *buf, size_t count);
#include
#include
#include
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
int fd = open(LOG, O_RDONLY);
if (fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
exit(1);
}
else printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
char buffer[256];
ssize_t n = read(fd, buffer, sizeof(buffer)-1);
if (n > 0)
{
buffer[n] = '\0';
}
printf("buffer: %s", buffer);
close(fd);
return 0;
}
编译代码运行并查看结果:
说明:
O_RDONLY
模式是以读取的方式打开文件'\0'
由于计算机的体系结构中,操作系统是对上向用户提供服务,向下管理硬件的部分,并且文件的操作是会涉及到硬件的使用,导致编程语言语言库函数中的文件操作一定是对系统接口的封装,无论任何编程语言都是如此,因此无论何种编程语言其文件操作的本质都是相同的。计算机体系结构示意图如下:
在学习文件描述符前要知道,观察Linux操作系统的文件系统接口,能够看出文件操作和文件描述符强相关,open函数的返回值,close函数、write函数、read函数的参数都是文件描述符。
首先,进行文件操作时,操作系统不会将磁盘中的文件加载到内存中,而是在内存中创建一个描述文件的结构(如下图struct file),该结构会管理一个缓冲区,内存和磁盘文件的数据交换都是通过这个缓冲区。示意图如下:
其次,由于文件操作是由进程调用了系统接口完成的,因此进程控制块(如下图struct task_struct)需要记录这些描述文件的结构,因此进程控制块会申请一块创建一个结构(如下图struct files_struct)描述进程操作的文件,然后在进程控制块中使用一个变量(如下图struct files_struct*)记录这个进程操作的文件的地址。示意图如下:
最后,由于一个进程可以操作的文件数量众多,因此描述进程操作的文件的结构(如下图struct files_struct)中,会用一个数组(如下图struct file* fd_array[])记录所有该进程操作的文件的描述结构(如下图struct file)的地址,而这个数组的下标就是文件描述符。示意图如下:
说明: 文件描述符的存在使得进程管理和文件管理处于轻耦合的状态,二者之间只是使用了文件描述结构的指针联系在了一起。
进程调用系统接口write
之后,操作系统会将要传入到磁盘文件的数据拷贝至该文件描述结构中管理的缓冲区,然后操作系统会根据自身的刷新策略,在合适的时候将数据刷新到磁盘文件中。
进程调用系统接口read
之后,操作系统会将磁盘文件中的数据拷贝至该文件描述结构中管理的缓冲区,然后将该缓冲区中的内容拷贝至进程指定的位置中。
在Linux操作系统中,一切的外设都被看作是文件,因此说“Linux下一切皆文件”。关于“一切皆文件”的理解如下:
首先,操作系统在管理硬件时,并不是直接管理硬件设备的,二者之间要通过驱动程序这一中间软件来完成交互,外设与计算机的交互方式就是数据写入和读取,因此在各个硬件对应的驱动程序中会存在该硬件的读写方法。示意图如下:
虽然驱动程序都设计了对应硬件的读写方法声明,但并不是都有具体实现的,比如键盘只有读取方法,无法向键盘写入。
其次,Linux操作系统会使用描述文件的结构(如下图struct file)来描述每一个外设,并且该结构中会使用函数指针的形式来记录驱动程序所提供的读写方法,然后操作系统要进行外设数据的写入时,只需要将数据写入至文件对应的缓冲区,然后使用函数指针调用对应的函数方法将数据写入写入外设,操作系统读取外设数据时,只需要调用函数指针调用对应的方法将数据写入文件对应的缓冲,然后从缓冲区中读取数据。示意图如下:
由于所有的外设都被统一的使用了描述文件的结构描述,并且进程都是利用缓冲区交换数据,调用对应的读写方法进行读写,其操作方式和文件的操作方式相同,因此从进程的角度看外设和磁盘文件是一样的,因此才说“Linux下一切皆文件”。
进程在运行时,会默认打开三个文件,分别是标准输入、标准输出、标准错误。为了验证这编写如下代码:
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fd:%d\n", fd);
close(fd);
return 0;
}
编译代码运行并查看结果:
文件描述符代表的是记录进程操作文件的结构的数组的下标,文件描述符的使用规则是从数组起始位置开始,找到第一个没被使用的位置,进程运行时默认打开了标准输入(对应文件描述符为0)、标准输出(对应文件描述符为1)、标准错误(对应文件描述符为2),因此再打开的文件的文件描述符是从3开始。所以能够看到上面的代码打开文件获取的文件描述符为3。
在C语言标准库中使用了struct FILE
描述文件信息,将标准输入命名为stdin
,标准输出命名为stdout
,标准错误命名为stderr
,由于Linux操作系统的提供的文件操作接口都需要使用文件描述符,因此struct FILE
需要包含文件描述符字段。C语言进行进程文件操作和struct FILE
强相关,C语言库函数中提供的文件操作函数都会利用struct FILE
中记录的文件描述符来调用Linux系统接口来完成。在Linux系统下C语言的struct FILE
会有一个变量 _fileno
记录文件描述符。为了验证这编写如下代码:
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
printf("stdin:%d\n", stdin->_fileno);
printf("stdout:%d\n", stdout->_fileno);
printf("stderr:%d\n", stderr->_fileno);
FILE* fp = fopen("LOG", "w");
printf("fp:%d\n", fp->_fileno);
fclose(fp);
return 0;
}
编译代码运行并查看结果:
在Linux操作系统中,无论何种编程语言,只要使用文件操作必然需要对文件描述符进行一定的封装来使用,才能完成Linux操作系统下的文件操作。
输出重定向是将进程原本应该打印到显示器上的数据,输出到文件中。
实现输出重定向的原理: 输出函数是将文件描述符1作为参数调用系统接口函数实现的,修改文件描述符表中1号位置指向的文件,即可完成输出重定向。输出函数的封装中只是使用文件描述符1,无法知晓输出位置的改变。落实到C语言中就是printf
是向stdout
输出数据,但是stdout
也只是封装了文件描述符1,修改文件描述符表中的指向的文件,printf
也无法知晓。
为了模式实现输出重定向编写如下代码:
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
int fd = close(1);
open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
close(fd);
return 0;
}
编译代码运行查看结果:
输入重定向是将进程原本应该从键盘中获取数据,变为从文件中获取数据。
实现输入重定向的原理: 输入函数是将文件描述符0作为参数调用系统接口函数实现的,修改文件描述符表中0号位置指向的文件,即可完成输入重定向。输入函数的封装中只是使用文件描述符0,无法知晓输入位置的改变。落实到C语言中就是scanf
是从stdin
获取数据,但是stdin
也只是封装了文件描述符0,修改文件描述符表中的指向的文件,scanf
也无法知晓。
为了模式实现输入重定向编写如下代码:
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
close(0);
int fd = open(LOG, O_RDONLY);
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("%d %d\n", a, b);
close(fd);
return 0;
}
编译代码运行查看结果:
追加重定向是将进程原本应该追加打印到显示器上的数据,变为追加打印到文件中。
实现追加重定向的原理: 将输出函数使用的文件描述符1指向改为指定文件,并且在打开文件时使用追加的方式。
为了模式实现追加重定向编写如下代码:
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
close(1);
int fd = open(LOG, O_WRONLY | O_CREAT | O_APPEND, 0666);
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
close(fd);
return 0;
}
编译代码运行查看结果:
说明: 在实现重定向时也可以选择如下的输入或输出方式:
fscanf(stdin, ...); //等效于scanf
fprintf(stdout, ...); //等效于printf
Linux操作系统中可以使用指令完成重定向,为了验证编写如下代码:
#include
int main()
{
printf("hello->printf\n");
fprintf(stdout, "hello->fprintf stdout\n");
fprintf(stderr, "hello->fprintf stderr\n");
return 0;
}
编译代码运行查看结果:
./myfile > log.txt
将./myfile的输出重定向到 log.txt文件中,也就是将 log.txt的文件信息写入到该进程的文件描述符1中,2>&1
是将该进程的文件描述符1的文件信息写入到文件描述符2中。
Linux操作系统中提供了能够重定向的系统调用dup2
:
//dup2函数所在的头文件和声明
#include
int dup2(int oldfd, int newfd);
为了验证dup2
编写如下代码:
#include
#include
#include
#include
#include
#define LOG "log.txt"
int main()
{
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC);
dup2(fd, 1);
printf("this is printf\n");
fprintf(stdout, "this is fprintf->stdout\n");
close(fd);
return 0;
}
编译代码运行查看结果:
在C语言标准库中提供的struct FILE
中包含了缓冲区字段,使用C语言库函数中的文件操作向文件写入数据,C语言标准库只会将数据写入到对应文件的struct FILE
中的缓冲区,然后结合一定的刷新策略将缓冲区中的内容刷新到Linux操作系统中的文件缓冲区中。示意图如下:
缓冲区刷新策略:
无缓冲 – 数据不经过缓冲区直接写入操作系统
行缓冲 – 数据写入缓冲区后遇到'\n'
,将'\n'
及以前的数据写入操作系统
全缓冲 – 缓冲区写满后将数据写入操作系统
显示器采用的刷新策略:行缓冲
普通文件采用的刷新策略:全缓冲
将缓冲区中的数据刷新到操作系统需要使用系统调用,而系统调用的使用要花费大量时间,因此采用缓冲区刷新策略,可以提高效率。
为了验证C语言struct FILE
中的缓冲区编写如下代码:
#include
#include
#include
#define LOG "log.txt"
int main()
{
fprintf(stdout, "hello fprint->stdout\n");
const char* msg = "hello write\n";
write(1, msg, strlen(msg));
fork();
return 0;
}
编译代码运行查看结果:
由于不进行重定向时,打印数据的位置是显示器,采用的是行缓冲的策略,因此数据会在创建子进程前被刷新到操作系统,进行重定向后打印数据的位置是普通文件,采用的是全缓冲的策略,但数据不够写满缓冲区,因此数据会在进程解说,也就是创建子进程之后刷新,而无论父子进程哪一个先刷新都会发生写时拷贝,导致最终数据被刷新到操作系统两次。调用系统调用wrire
是直接写入操作系统因此不受影响。