学习C语言的时候,有文件操作的知识,但那只是学到了操作函数,然后直接用即可,至于原理如何,并不清楚。这篇会逐渐写出来文件的通用知识。
当文件没被打开的时候,文件是存储在磁盘中的;当操作者使用函数打开文件后,文件会被加载进内存里。文件是由内容+属性构成的,所以文件的什么会被加载进内存?不管是什么,属性是一定要有的。加载进内存这个过程,是操作系统做的,让他这样做的是操作者引起的进程。进程令系统加载文件,那么内存中一定不会只有一个文件,多个文件在内存中的时候,操作系统对它们会进行管理,管理的方式就是先描述再组织,如同系统管理内存数据一样,对于文件,系统也会用结构体struct file来存储它们的各项数据,比如属性,以及还有指向下一个文件的指针和其他指针,所以对多个文件的管理就变成了链表的管理。这里也体现出了Linux里一切皆文件的思想。
在C语言中有一系列文件操作函数,Linux也一样。
和fopen有所不同,返回数据的类型并不是FILE*而int。如果执行无误那就返回文件描述符,错误则返回-1.描述符先不管,先看函数
用man查看,会发现有很多参数,函数先不管,先想一个问题,操作系统如何了解操作者传给它的标志位,也就是函数参数这些?传多个标志位的时候,我们会多设几个位置,但是如果标志位太多这样做也不行,而系统对它的处理就是把这个标志位当做位图结构,一个int的参数有32个比特位,每个比特位就表示一个传过来的参数,也就是一个标志位。
写一段相对应的代码
1 #include <stdio.h>
2
3 #define one 0x1
4 #define two 0x2
5 #define three 0x4
6 #define four 0x8
7 #define five 0x10
8
9 void Print(int flags)
10 {
11 if(flags & one) printf("hello 1\n");
12 if(flags & two) printf("hello 2\n");
13 if(flags & three) printf("hello 3\n");
14 if(flags & four) printf("hello 4\n");
15 if(flags & five) printf("hello 5\n");
16 }
17
18 int main()
19 {
20 printf("------------------------\n");
21 Print(one);
22 Print(two);
23 Print(three);
24 Print(four | five);
25 Print(one | four | five);
26 Print(two | three | four | five);
27 return 0;
28 }
现在回到myfile.c,写文件相关的代码
1: myfile.c ? ? ?? buffers
1 #include <stdio.h>
2 #include <errno.h>
3 #include <string.h>
4 #include <stdlib.h>
5 #include <unistd.h>
6 #include <sys/types.h>
7 #include <sys/stat.h>
8 #include <fcntl.h>
9
10 #define LOG "log.txt"
11
12 int main()
13 {
14 int fd = open(LOG, O_WRONLY | O_CREAT);
15 if(fd == -1)
16 {
17 printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
18 }
19 else printf("fd: %d, errno: %d, errstring: %s\n", fd, errno, strerror(errno));
20 close(fd);
21 return 0;
22 }
两个参数的意思就是只读,如果读不了那就创建。
但是创建出来的文件的权限不够好,可以添加第三个参数,表示文件权限应该是多少。
还可以使用umask改一下权限编码。然后往文件里写东西
往缓冲区里写,然后write读取。正常地运行,但是如下图
在这里插入图片描述
还有一个问题,O_CREAT会延续之前的内容,不会清空文件而继续写。
int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
所以当进行文件写入时,底层会传递O_等选项。C语言还有a用来追加,Linux也有O_APPEND用来追加,但必须先写文件才能追加。
int fd = open(LOG, O_WRONLY| O_CREAT | O_APPEND, 0666);
读取文件O_RDONLY,int fd = open(LOG, O_RDONLY);
所以像fopen,fclose,open,close这些,就是库函数和系统调用,库函数封装了系统调用,fopen里就封装了系统的open。
----------------------------
无论是打开还是写入文件,都是用户层在访问硬件,通过系统调用来访问,他们不能越过操作系统去访问。无论哪个语言,文件操作的时候都是调用OS的open,write等函数,只不过各个语言封装形式有所不同。
---------------------------
刚才这个程序,如果正常运行,fd会得到返回值,但是为什么是3?
任何一个进程在启动的时候都会默认打开3个文件,标准输入,标准输出,标准错误,它们本质上都是文件,而它们在语言层面的表现就是像C语言中stdin,stdout,stderr,C++中cin,cout, cerr一样,不过C++里那是一个类。
1 #include <iostream>
2 #include <cstdio>
3
4 int main()
5 {
6 //Linux里一切皆文件,向显示器打印本质就是向文件写入
7 //C
8 printf("printf->stdin\n");
9 fprintf(stdout, "printf->stdout\n");
10 fprintf(stderr, "printf->stderr\n");
11
12 //C++
13 std::cout << "cout->cout" << std::endl;
14 std::cerr << "cerr->cerr" << std::endl;
15
16 }
所有打印的语句都会出现在显示器上。三个标准文件是这样的
标准输入—设备文件–>键盘文件
标准输出—设备文件–>显示器文件
标准错误—设备文件–>显示器文件
所以3之前的012对应的就是标准输入输出错误,所以我们得到的就是3.这几个数字,也就是文件描述符,也就是open返回值,是数组的下标。为什么会出现数组?
在系统底层,用户通过进程命令操作系统打开文件,会建立一个进程的pcb,在内存中也会建立文件的struct file,只要找到file,就能找到文件的内容和属性。一个进程可以打开多个文件,进程会再建立一个files_struct结构体,里面有一个struct file类型的指针数组,每一个元素都是struct file的指针,用来找到这个进程打开的文件,进程pcb里又有一个struct files_struct* 类型的指针指向这个结构体,所以进程就可以找到所有自己打开的文件了,数组下标也就能够解释了。
当用write往文件写入内容时,这个进程会找到用户给的文件描述符所对应的文件,每个文件会链接一个缓冲区,write函数把要写的内容拷贝到这个缓冲区,至于缓冲区什么时候刷新到文件里,由系统决定。所以这些IO类write等函数,本质上是在用户空间和内核空间进行数据的来回拷贝。
一个进程pcb通过一个结构体,结构体里有数组,通过下标访问每个文件的结构体struct file。file有文件的内容+属性,还有缓冲区。不止这些,像键盘,显示器,网卡,显卡等这些外设,它们都有自己的IO函数,比如读写函数,而Linux下每个文件结构体里都有一些函数指针,来指向这些外设自己的IO函数。在上层,系统有read,write等函数,前面写到过,它们的本质是向缓冲区里拷贝数据,然后函数指针调用底层不同设备的方法,那么就可以把数据放到设备里。这就是一切皆文件。底层无论有什么差异,最终都变成一个公式,函数指针调用函数去操作数据,把缓冲区的数据放到设备里。
继续深入
FILE是什么?为什么C语言中文件操作函数的类型是FILE?
FILE是一个结构体,是由C语言的库提供的。
底层有系统创建的文件结构体,再往上一层就是系统调用接口,比如read,write等接口,再往上就是语言库,C语言里有FILE相关的结构体,里面有在库中文件的所有属性。C语言想用fopen函数,就需要FILE结构体,想要调用open函数就得需要fd。所以FILE结构体必定封装了fd,这样才能用系统调用接口。
同理,其他语言的文件库也必须有文件描述符。
回到描述符的问题,虽然现在已经知道这是数组下标了,但还是有问题的。打开多个文件,依次打印3456789后,如果把3这个文件给去掉,再添加一遍,那么还会是3吗?还是10?
写一些代码
15 int fd1 = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
16 int fd2 = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
17 int fd3 = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
18 int fd4 = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);
19
20 printf("%d\n", fd1);
21 printf("%d\n", fd2);
22 printf("%d\n", fd3);
23 printf("%d\n", fd4);
在这之前加上这一句fclose(stdin);
0就出来了。再关闭stderr就会这样
进程中,文件描述符的分配规则:在文件描述符表中,最小的,没有被使用的数组元素,分配给新文件。
现在针对1做一些操作
关闭标准输出
没有输出,并且这时候LOG,也就是log.txt的内容却是you can see me!。本来要打印到显示器的内容打印到了文件了,也就是发生了重定向。在语言层上,printf是向标准输出打印,而这时候标准输出关掉了,取而代之的是log.txt,所以就发生了重定向。而这个过程上层并不知道。
所以重定向的原理就是在上层无法感知的情况下,在OS内部,更改进程对应的文件描述符表中特定下标的指向。
除了输入和输出重定向,还有追加重定向。只要O_TRUNC改成O_APPEND就可。
像stdout,cout都是向1号描述符对应的文件打印,而cerr则是2;输出重定向只改的是1号对应的指向,2号不影响。
现在写个代码,把常规信息打印到log.normal,异常消息打印到log.error中。
1 #include <stdio.h>
2 #include <errno.h>
3 #include <string.h>
4 #include <stdlib.h>
5 #include <unistd.h>
6 #include <sys/types.h>
7 #include <sys/stat.h>
8 #include <fcntl.h>
9
10 #define LOG "log.txt"
11 #define LOGN "lognormal.txt"
12 #define LOGE "logerror.txt"
13
14 int main()
15 {
16 close(1);
17 open(LOGN, O_WRONLY | O_CREAT | O_APPEND, 0666);
18 close(2);
19 open(LOGE, O_WRONLY | O_CREAT | O_APPEND, 0666);
20 printf("hello printf->stdout\n");
21 printf("hello printf->stdout\n");
22 fprintf(stdout, "fprintf->stdout\n");
23 fprintf(stdout, "fprintf->stdout\n");
24 fprintf(stderr, "fprintf->stderr\n");
25 fprintf(stderr, "fprintf->stderr\n");
要是想都放到一个文件里,./a.out > log.txt >2&1
但是这样不方便。有dup2函数可以重定向,两个参数为oldfd,newfd,newfd会是oldfd的拷贝,所以最终只有oldfd这个数值,比如要把1重定向到fd上,那么oldfd就是fd,1就是newfd。
如果在最后加上fork()。
为什么会出现三行?去掉fork就是正常的两行,所以一定是fork影响了这里的结果。
在操作系统里,有进程pcb,文件结构体,指向标准输入输出等的指针,而在这上层,有C语言库。当我们自己的程序调用fprintf后,会通过struct FILE调用系统调用接口,C库会封装fd,也还有给我们准备好的缓冲区。当我们写入数据,会先把数据放到C的缓冲区里,然后函数返回,暂时还没有把数据放到系统内部的缓冲区。至于数据如何刷新给系统,C库有自己的策略,按照自己的策略去调用write给对应的文件缓冲区里。
C库常有的策略有:
1、无缓冲(也就是直接调用write把数据放到系统中)
2、行缓冲(遇到\n就把\n这之前的数据放到系统中)
3、全缓冲(全部写完后在放到系统中)
显示器采用行缓冲。普通文件采用全缓冲。
缓冲区能够节省调用者的时间,让它有更多的时间去做别的事,因为系统调用是有时间代价的,这样交给C库缓冲区后,进程可以继续忙自己的事。而C库会把所有要缓冲的数据都输入进来后,在送给系统,节省时间。
那么C的缓冲区在哪里?缓冲区在FILE结构体里,比较复杂。在操作者使用fopen等函数时,FILE结构体就会在库中建立好。
在上面的代码中,write是系统调用,所以和C的缓冲区无关,它直接去找系统。像显示器打印时,两条语句都是带\n的,所以在fork之前,fprintf的内容就已经通过行缓冲打印出来了,那么fork就无用;而当重定向到文件里后,就是全缓冲策略了,fprintf的内容会在C的缓冲区里,当创建子进程后,刚才的就是父进程写入的数据,而子进程又会写一遍,当刷新时,就出来了两次fprintf的内容。
链接: main.c
链接: my_stdio.c
链接: mystdio.h
当使用文件函数时,系统会先把数据放到缓冲区里,系统调用接口会根据缓冲区刷新策略用fd把数据放到系统内部的文件缓冲区里,最后根据策略把数据从缓冲区刷新到磁盘。
用户也可以强制刷新,把内核的文件缓冲区的数据刷新出来。fsync接口。
结束。