文章目录
对文件的基本理解
1.在Linux中,文件由文件属性和文件内容组成,并且一个文件所占用的内存不可能为0,因为即使一个文件的内容为空,它依然具有属性(何时创建,最后修改时间…),这些属性需要占用内存
2.打开文件的本质是将文件从磁盘加载到内存中,当一个文件被打开之前,该文件存储在磁盘上,属于磁盘文件,当打开文件时,文件被加载到内存中,此时的文件属于内存文件
3.我们研究文件操作时,是研究谁和文件之间的关系,谁在操作文件?在C语言中,我们使用fopen,fclose,fwrite,fread等函数对文件进行操作,这些函数在代码中被使用,代码最后会被编译成为可执行程序,可执行程序运行后成为进程,当可执行程序成为进程时,代码中对文件的操作才会被执行,所以我们研究文件操作,实际上是研究进程与文件之间的关系,只有搞懂了进程与文件间的关系才能对文件操作有深刻的理解。
何为当前路径?
使用C语言以w的方式打开一个文件时,如果文件不存在,默认会在当前路径下创建该文件并打开。
fopen("log.txt", "w");
(先用ll指令展示当前目录下的文件,当前目录下没有log.txt,file作为一个可执行程序,将以w的方式打开log.txt文件,运行file程序,再用ll指令查看当前目录下的文件,发现log.txt被创建)
所以当前路径指的是当前所处的目录吗?又或者说当前路径是指与可执行程序所在的目录相同的目录吗(因为log.txt与可执行程序file创建在了同一个目录下)?
修改file.c的代码,使该程序打印进程的pid,接着打开log.txt文件,最后死循环的休眠(暂停程序),然后再复制一个渠道,通过进程的pid查看进程的相关消息
int main()
{
printf("%d\n", getpid());
fopen("log.txt", "w");
while (1)
{
sleep(1);
}
return 0;
}
proc存储了进程的信息,在复制的渠道中展示proc目录下进程pid为9010的目录,可以查看该进程所对应的信息。
可以看到有两行信息,一个是exe路径信息,表示可执行所在的路径,一个是cwd(current work dirtory)当前工作路径,我们经常说的当前路径,实际上指的是当前工作路径。若打开文件,但文件不存在,文件的默认创建路径为当前工作路径。
使用chdir可以改变当前进程的工作路径,修改file.c文件,添加改变该进程工作路径的代码,如果在修改后的工作路径下能查找到创建的log.txt文件,则验证了文件的默认创建路径为当前工作路径的说法。
// 修改后的file.c文件
int main()
{
printf("%d\n", getpid());
chdir("/home/cw");
fopen("log.txt", "w");
while (1)
{
sleep(1);
}
return 0;
}
运行修改后的文件
当前工作路径被修改为/home/cw,查看该路径下的文件
发现log.txt文件被创建在该目录下
退出死循环的进程,使用ll打印当前目录下的文件,log.txt没有被创建,说明log.txt只被创建在/home/cw路径下。
经过以上代码验证,可以得到结论:当前路径指的是当进程的工作路径,与可执行程序所在的路径无关。
文件与操作系统的关系
当我们向文件写入数据时,最终是向磁盘写入数据,磁盘属于硬件的一种,硬件由操作系统管理,所以因为操作系统是磁盘的管理者,想要访问磁盘就必须经过操作系统(我们不能绕过管理者去访问被其管理的对象),因此,所有对文件的操作都必须贯穿操作系统,操作系统作为软件与硬件之间的软件层,暴露出接口供上层使用,向上接收上层的操作,向下使用并管理着硬件。
语言对系统接口的封装
如何理解printf?printf是一个C语言函数,作用是写入数据到显示器上,显示器作为一个硬件,由操作系统管理,而printf想要访问显示器,就必须经过操作系统,所以可以推测,printf只能使用操作系统提供的接口,访问底层的显示器。
综上所述,printf作为C语言的流输出函数,实际上是对系统接口的封装。对于不同操作系统,printf的封装都不相同,因为不同操作系统提供的接口肯定不同。那么每种语言都有流输入与流输出,它们相关的IO函数也是对系统文件接口的封装吗?答案是是的,那么为什么要对系统的文件接口进行封装?
1.系统接口较为复杂,学习成本高,使用难度大
2.使语言具有跨平台性,对于不同的操作系统,语言级别的IO函数都可以正常使用,不会出现可以在linux下能跑的代码,在windows下不能跑
其中第二点原因为主要原因,每种语言采用:穷举所有操作系统+条件编译的方法,对所有操作系统的文件接口进行了封装。在不同操作系统下,一个语言封装好的IO函数将被条件编译为不同的代码。因为这层封装,使用者可以以一种统一的视角看待所有操作系统的文件接口,使用者只需要关心封装好的函数该怎么使用,不用去关心底层的系统接口要怎样使用。
但是,学习了系统级别的文件接口,可以使我们对语言级别的文件接口有更深的理解。
系统的文件接口
打开与关闭
打开文件函数open:pathname是要打开的文件,与C语言一样,如果不带路径,默认在当前工作路径下创建文件,flags表示打开文件的方式,mode为文件的权限状态(如果创建文件需要设置其权限状态),该函数返回一个文件描述符fd,fd是一个文件的唯一标识符。
其中的重点是flags,flags作为一个标记位,以位图结构表示打开文件的方式(位图:假设有一个32位bit的数,一般情况下31个比特位为0,1个比特位为1,当1出现在不同的比特位,就表示不同的标记,通常用宏来表示一个标记,所以一个32位bit的数可以表示32个不同的标记,这就是位图的思想)。
// test.c文件
#include <stdio.h>
#define PRINT_A 0x1 // 0000 0001
#define PRINT_B 0x2 // 0000 0010
#define PRINT_C 0x4 // 0000 0100
#define PRINT_D 0x8 // 0000 1000
#define PRINT_DF 0x0 // 0000 0000
void print(int flags)
{
if(flags & PRINT_A) printf("hello a\n");
if(flags & PRINT_B) printf("hello b\n");
if(flags & PRINT_C) printf("hello c\n");
if(flags & PRINT_D) printf("hello d\n");
if(flags == PRINT_DF) printf("hello df\n");
}
int main()
{
print(PRINT_A);
print(PRINT_A | PRINT_B);
print(PRINT_A | PRINT_B | PRINT_C);
return 0;
}
以上代码是一个简单的位图运用,不同的宏表示不同的标记,不同的标记表示不同的功能,如果想要调用两个以上的功能,需要传两个以上的宏,宏之间用按位或运算符连接。
在print函数中,将传入的标记位flags与不同的宏进行按位与操作,如果得到的结果为1,说明需要调用该宏对应的方法,以上代码将根据宏的不同打印出不同的语句。
(代码执行结果)
文件的系统接口函数open需要用到的宏
O_RDONLY:以只读方式打开文件
O_WRONLY:以只写方式打开文件
O_RDWR:以可读可写的方式打开文件
O_APPEND:以追加方式打开文件
O_CREAT:打开文件时,若文件不存在,则创建文件
O_TRUNC:如果文件存在,并且打开方式包含可写,文件的内容将被清空
// file.c文件
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_TRUNC | O_CREAT, 0666);
// 与C语言的fopen("log.txt", "w")等价
fclose(fd);
return 0;
}
(以只写的方式打开文件,若文件不存在则创建,若文件存在则清空文件内容,最后将文件的权限状态设置为666,由于默认的权限掩码为002,最终文件的权限状态是664)。
关闭文件函数close的介绍:使用close函数需要传入文件的fd,如果函数返回0,文件关闭成功,返回-1表示关闭时发生了错误。每个文件都有一个计数器,用来记录当前有几个进程打开了文件,如果计数大于1,关闭文件只会导致计数–,不会真的关闭文件,如果计数为1,此时的关闭文件才会去释放文件所占用的资源
可以发现这三个宏组成的打开方式与C语言的w打开方式产生的效果是一样的,同时也能看出C语言对系统接口进行了封装,使得函数的使用成本降低。
读写函数
系统接口的写函数write:将buf的数据写入count个字节大小数据到fd文件中。
//file.c文件
int main()
{
int fd = open("log.txt", O_WRONLY | O_TRUNC | O_CREAT, 0666);
char buffer[128] = {"hello file\n"};
int i = 0;
// 向log.txt文件中写入5次buffer
for (i = 0; i < 5; i++)
{
// 注意这里的buffer不用加1,加1表示将'\0'也写入文件,但是'\0'只是C语言的字符串结束标识符
// linux不认识'\0',如果将其写入会导致乱码
write(fd, buffer, strlen(buffer));
}
close(fd);
return 0;
}
(代码运行结果,buffer的内容被写到log.txt中五次)
linux的读函数read:从文件fd中读取count个字节大小数据到buf中。该函数会返回实际上读取到的字节数,如果发生错误,函数返回-1。
int main()
{
// 以只读的方式打开文件
int fd = open("log.txt", O_RDONLY);
char buffer[128] = { 0 };
ssize_t size = read(fd, buffer, sizeof(buffer) - 1);
if (size > 0)
{
// 将读取到的字符串最后放上'\0'表示字符串的结束
buffer[size] = '\0';
printf("%s\n", buffer);
}
close(fd);
return 0;
}
文件描述符fd的理解
通过阅读open函数的返回值介绍,我们得知如果使用open函数打开一个文件成功,函数返回值大于等于0,文件打开失败函数返回值为-1。
连续创建多个文件,观察它们的文件描述符是怎样的
// file.c文件
int main()
{
int fda = open("loga.txt", O_WRONLY | O_CREAT);
int fdb = open("loga.txt", O_WRONLY | O_CREAT);
int fdc = open("loga.txt", O_WRONLY | O_CREAT);
int fdd = open("loga.txt", O_WRONLY | O_CREAT);
int fde = open("loga.txt", O_WRONLY | O_CREAT);
int fdf = open("loga.txt", O_WRONLY | O_CREAT);
printf("fda:%d\n", fda);
printf("fdb:%d\n", fdb);
printf("fdc:%d\n", fdc);
printf("fdd:%d\n", fdd);
printf("fde:%d\n", fde);
printf("fdf:%d\n", fdf);
return 0;
}
可以看到从创建的第一个文件开始,文件描述符从3递增到8,刚才说过,一个文件如果创建成功,open函数会返回大于等于0的fd值,为什么上面的进程创建的第一个文件的fd却不是0,而是3?0,1,2这些fd被谁使用了?
其中的原因为:一个进程运行起来后,默认会打开三个文件流,标准输入流,标准输出流,标准错误流,它们分别对应着0,1,2三个fd文件描述符。
C语言分别用stdin,stdout,stderr来表示这三个文件流,它们的类型为FILE结构体指针。由于linux系统用fd区分不同的文件,linux不识别什么FILE,stdin,它只关心文件的描述符fd,所以作为上层封装,C语言的FILE中肯定有一个fd,以供操作系统识别文件。有了以上的理论支持,我们可以写一段代码验证,stdin,stdout,stderr对应的fd为0,1,2
// testfd.c文件
#include <stdio.h>
int main()
{
// 在FILE中fileno对应着文件描述符fd
printf("stdin:%d\n", stdin->_fileno);
printf("stdout:%d\n", stdout->_fileno);
printf("stderr:%d\n", stderr->_fileno);
return 0;
}
综上所述,fd从3开始使用的原因是:一个进程运行起来后,默认打开了三个文件流,分别占用了0,1,2三个文件描述符。
如何理解Linux中,一切皆文件?
linux对文件的管理
一个系统中,可能同时打开许多文件,与进程一样,操作系统需要对这些文件进行管理,要进行管理,首先要描述管理对象的属性,再组织这些属性,linux下,描述文件属性的结构体叫做struct file,每个结构体描述一个文件的信息,操作系统用合适的数据结构连接这些结构体,这里以链表为例,用链表连接这些描述文件信息的结构体
所以操作系统用对一张链表的管理替代了对文件的管理。
进程对文件的管理
对于进程来说,一个进程可能打开多个个文件,那么进程也需要对其打开的文件进行管理,进程在系统中对应的结构为task_struct,该结构中有一个指针files,指向一个类型为struct files_struct的结构体,该结构体维护了一个进程打开的所有文件的信息(可结合下图理解),struct files_struct结构体中有一个数组fd_array,其存储元素的类型是指向struct file的指针。之前说过struct file是操作系统用来描述文件的结构体,所以fd_array中的元素指向的是操作系统用来描述文件的结构体struct file。因为文件描述符fd是从0开始增加的,所以fd就可以作为fd_array数组中的下标,该数组的0,1,2下标指向了操作系统中的三个标准文件流,其他下标则指向其他文件,若有的下标没有文件可以指向,则指向空。所以进程打开文件,实际上是将该文件在系统中的结构struct file的地址放到进程的fd_array数组中,并将指向该文件结构的元素下标fd返回给用户。
文件描述符的分配规则
(补充文件描述符的分配规则:打开一个文件时,操作系统会遍历fd_array数组,找到一个最小的且没有被使用的下标,将其分配给新的文件,由于0,1,2下标被三个标准文件流占用,所以一般情况下fd都是从3开始使用的)
但是如果先关闭了fd为0的文件,再打开一个其他文件,根据分配规则,该文件的fd将是0。
// testfd.c 文件
int main()
{
close(0);
int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fd : %d\n", fd);
return 0;
}
(先关闭fd为0的文件,再打开一个新文件,最后打印该文件的下标)
新文件的fd为0,结果与分配规则的说法相同
一切皆文件
C语言如何实现面向对象?在C++问世前,使用C语言编写操作系统的过程中就有了面向对象的思想,但是C语言的结构体中只能定义变量,不能定义函数,要在结构体中实现一个函数,只能使用函数指针。在初始化结构体变量时,将函数指针指向对应的函数即可,只是函数被定义在了类外。
对于所有的IO设备,肯定包含了两个操作:输入和输出。操作系统不能直接操作硬件,只能通过驱动与硬件交互,驱动是由硬件厂商提供的配置程序,有了驱动操作系统就能和硬件实现数据的通信。而驱动中肯定包含了一个IO设备的读方法和写方法,所以操作系统就可以调用IO设备驱动中的读方法,读取硬件信息,调用驱动中的写方法,向硬件写入信息。
Linux将所有的硬件看成文件,在操作系统与底层硬件之间加入软件层struct_file,使操作系统可以以统一的视角(文件)看待所有的硬件以及软件。比如,操作系统看待磁盘的方式就是:在内存中创建struct_file结构体,该结构体包含了磁盘的属性以及方法,其中的方法包括写方法和读方法两种,分别对应着两个函数指针,将结构体中写方法指向磁盘驱动中对应的写方法,再将结构体中的读方法指向磁盘驱动中对应的读方法。当进程需要读取磁盘时,操作系统就会找到该进程中的磁盘文件,调用该文件的读方法,由于该指针指向磁盘驱动中的读方法,最终会通过驱动的读方法读取磁盘。
可以结合上图理解,所有的IO设备都有自己的读写方法,系统创建的文件中肯定有两个函数指针,指向了该文件所对应了读写方法。操作系统将软硬件都看成文件,这样的文件系统叫做虚拟文件系统。
综上所述,当进程需要调用一个IO设备的读(写)方法时,操作系统会在该进程中找到对应的文件,接着执行文件中读(写)指针所指向的函数。如果一个IO设备没有读或写方法时,对应文件中的指针将指向空,以表示该设备没有读或写操作。
Linux下,一切皆文件的本质是:操作系统以一种统一的视角看待软硬件,要实现这样的设计,最常用的做法是在操作系统与软硬件之间加入一层软件层,实现操作系统与底层软硬件之间的解耦。这样的操作与虚拟地址空间的实现有着异曲同工之妙,虚拟地址空间的设计使得进程以统一的视角看待内存,虚拟文件系统的设计使得操作系统将软硬件都描述为一种结构体,每个结构体对象的读写方法各不相同,但操作系统只需要通过函数指针调用其指向的方法即可。
重定向的原理
对于文件,语言只识别fd文件描述符,如C语言的FILE结构体,该结构体中有一个fd,C语言就是通过该fd识别文件流。如调用类似scanf函数,C语言就会去封装了0号fd的FILE文件中读取数据。调用printf函数,C语言就会去封装了1号fd的FILE文件中读取数据
那么重定向也就很好理解了,重定向即重新确定数据的流动方向。原本要输出到一个fd文件,现在却输出到了另一个fd文件,这叫输出重定向。原本要从一个fd文件中读取数据,现在却从另一个fd文件读取数据,这叫输入重定向。什么时候会发生重定向?假设语言要向fd为1的文件中输出信息,虽然fd为1的文件是显示器,但我们可以将其他文件的fd修改为1,此时数据就向其他文件输出,而不向显示器输出了
// testfd.c 文件
int main()
{
close(1); // 先关闭fd为1的文件
int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); // 此时打开的文件的fd为1
printf("fd : %d\n", fd);
fflush(stdout);
return 0;
}
(先关闭fd为1的文件,再打开一个txt文件,根据fd的分配规则该文件的fd为1,此时调用printf函数,语言就会通过操作系统向标准输出流输出信息,但语言只识别fd,于是fd为1的文件被操作系统识别为了标准输出流,但此时fd为1的文件是一个普通txt文件,向该文件输出信息不会使信息显示在屏幕上,而是写入到文件中(由于普通文件采用全刷新策略,需要使用fflush刷新缓冲区才能将数据写入到文件中)。)
但重定向不需要用户手动操作,以上代码只是演示重定向的原理,系统提供了dup2接口执行重定向
比如dup2(3, 1)这行代码,是将进程中的fd_array数组(数组存储了系统级别的文件结构体struct file的地址)中下标为3的元素拷贝到下标为1的元素位置上,也就是说下标为1的元素被下标为3的元素覆盖了,不再执行标准输出流文件,其指向的文件与下标为3的元素所指向的文件相同。由于fd为1的下标元素指向了fd为3的下标元素所指向的文件,所以原来向fd为1的文件输出的数据现在输出到了fd为3的文件中。
重定向函数dup2的使用
// dup2.c 文件
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(fd, 1); // 将标准输出重定向到fd文件中
printf("hello fd:%d", fd);
// 由于缓冲区的存在,需要刷新才能使信息显示到log.txt文件中
fflush(stdout);
close(fd);
return 0;
}
以上代码将fd为3的文件地址拷贝到fd为1的文件地址,printf会向fd为1的文件中写入数据,由于fd为1的文件被重定向成了一个普通文件,所以printf的输出信息将写入到fd为3的log.txt文件中,在运行程序的前后查看log.txt文件中的内容,发现数据确实被写到了log.txt文件中。
int main()
{
int fd = open("log.txt", O_RDONLY, 0666);
dup2(fd, 0); // 将标准输入重定向到fd文件中
char buffer[1024];
while (fgets(buffer, sizeof(buffer), stdin))
{
printf("%s", buffer);
}
close(fd);
return 0;
}
代码解读:打开一个log.txt文件并获取它的fd值,将fd_array的fd的文件地址拷贝到fd为0的元素位置,使标准输入被重定向到fd文件中,原来需要从键盘(标准输入)中读取数据,现在从fd文件(log.txt文件)中读取。向log.txt文件中写入一些数据,在运行dup2程序前查看txt文件内容,接着运行dup2,程序成功读取并打印了log.txt文件中的内容。
如果将重定向函数注释,那么程序将从键盘中读取数据并打印
最后再谈一下重定向:由于操作系统只识别文件的fd,而上层的C语言将文件封装成了FILE,其中包括了文件的fd,那么只需要修改FILE文件中的fd,并使之与其他FILE文件的fd不相同,就能瞒过操作系统。比如操作系统关闭fd为1的文件(将标准输出流文件关闭),再打开一个普通文件,它的fd为1,调用printf时,操作系统只会向fd为1的文件输出信息,却不知道此时的fd为1的文件不再是标准输出流文件,而是其他文件了。