文件基本概念
文件存放在磁盘中,而磁盘是永久性存储介质,故文件可以永久地存储在磁盘中。磁盘是一种外设,既是输入设备也是输出设备。对磁盘上文件的所有操作都是对外设的输入和输出,简称IO。
文件由两部分组成,一是文件内容,二是文件属性,所以即使是空文件也会占用磁盘空间。文件操作本质就是文件内容操作和文件属性操作。文件在磁盘中,磁盘又是硬件,由操作系统管理,所有文件操作必须通过操作系统完成,但没有进程时,操作系统本身不会主动进行任何文件操作。所有的文件操作都源自进程,进程通过操作系统进行文件操作。具体就是通过文件相关的系统调用接口进行文件操作。
文件操作
文件路径
在进程进行文件操作前,首先需要找到文件。我们在代码中通过fopen函数打开文件时并不需要输入文件的路径,这时因为进程的PCB(task_struct)中记录了进程的当前工作目录的路径cwd,没有指明文件的绝对路径时,进程会通过cwd中的路径查找文件。如果我们知道进程的PID,可以通过命令ls /proc/[进程PID] -l调出进程的信息,在里面就有cwd的具体信息。cwd可以通过chdir函数更改。
相关库函数
只有操作系统能对文件进行操作,文件操作相关的各个库函数底层都使用了系统调用接口。不同操作系统的系统调用接口各不相同,为了让代码在不同的操作系统下也能运行,编程语言都会对系统调用接口进行封装。现在我们先复习一下常用的文件操作库函数。
fopen
原型
FILE *fopen(const char *filename, const char *mode);fopen用于打开文件,打开成功返回该文件的FILE指针,用于后续对文件进行读写或关闭等操作,打开失败则返回NULL。参数filename是要打开的文件或其路径,mode用于指定打开的方式,具体如下。
" r "只读。文件必须存在,打开后只能进行读操作,下面的各个选项也是类似。
" w "只写。如果文件存在会先清空文件内容再写入,不存在则会创建新文件。
" a "追加。在文件末尾写入,文件不存在会创建新文件。
"r+"读写。文件必须存在。
"w+"也是读写。会创建新文件或清空已有文件
"a+"读写。读取操作会从头开始读,写操作则是在文件末尾追加。文件不存在则创建。
'b'在以上模式后面加 b 表示以二进制模式打开文件,如 rb,wb+。
fclose
原型
int fclose(FILE *stream);关闭对应的文件,成功返回0,失败返回EOF。
fgets
char *fgets(char *str, int n, FILE *stream); 从文件中读取一行(最多n-1个字符)到 str指向的缓冲区,直到遇到换行符、EOF 或读取结束。换行符会被保留在字符串中。成功返回 str,失败或到文件尾返回 NULL。
fputs
int fputs(const char *str, FILE *stream);将 str指向的字符串写入 stream。成功返回非负整数,失败返回 EOF。注意不要把 \0 写到文件里,它是不可显字符,若文件内存在\0,查看文件内容时会乱码。
fscanf
int fscanf(FILE *stream, const char *format, ...);读取文件内容进行格式化输入。 用法和scanf类似,第一个参数stream用于指定文件,后面的参数用法和scanf一样。返回成功匹配并赋值的输入项数。
fprintf
int fprintf(FILE *stream, const char *format, ...);向指定文件stream格式化输出,类似 printf。成功会返回实际写入的字符数,失败则返回负值。
fread
size_t fread(void ptr[], size_t size, size_t nmemb, FILE *stream);
从 stream中读取 nmemb个元素,每个元素大小为 size字节,存放到ptr指向的内存。返回实际成功读取的元素个数。
fwrite
size_t fwrite(const void ptr[], size_t size, size_t nmemb, FILE *stream);将ptr指向的内存中的 nmemb个大小为size字节的元素写入 stream。返回实际成功写入的元素个数,如果小于 nmemb,表示出错。
fseek
int fseek(FILE *stream, long int offset, int whence);移动位置指示器。将stream的位置指示器移动到相对于 whence偏移 offset字节的位置,成功返回 0,失败返回非 0。我们在进行多次读写操作时,我们读取或写入的位置是一直变化的。比如我们用fgets读取了3个字符,下一次调用fgets会从上一次读取结束的位置,也就是第3个字符开始读取。这是因为对文件读写的同时会自动移动位置指示器,这样可以方便我们读写文件。修改位置指示器的位置,则可以实现精确读取或写入,不用从头找。参数whence有几个宏定义的特殊取值,如下
SEEK_SET:文件开头。
SEEK_CUR:当前位置。
SEEK_END:文件末尾。
ftell
long int ftell(FILE *stream);用于查找位置指示器的位置。返回文件的位置指示器相对于文件开头的字节偏移量。失败返回 -1。
rewind
void rewind(FILE *stream);将文件的位置指示器设置到文件开头。
feof
int feof(FILE *stream);检测文件stream的位置指示器已经到了文件结尾。如果是则返回非零值,否则返回 0。
fflush
int fflush(FILE * stream);刷新文件的缓冲区,如果传入的是NULL则会刷新所有文件的缓冲区,成功返回0,失败返回EOF。缓冲区的概念后面会讲解
标准IO原理
Linux下一切皆文件,显示器等硬件也是用文件管理的,向显示器输出的本质就是向显示器文件中写入字符,从键盘获取输入的本质就是从键盘文件中读取字符。换言之,使用文件操作也可以实现用键盘输入、向显示器输出。
在C语言中默认会打开三个流,分别是stdin、stdout和stderr。但其实这三者的本质都是文件指针 FILE*。其中stdin对应键盘文件,stdout和stderr对应显示器文件。
#include <stdio.h> extern FILE *stdin;//对应键盘文件 extern FILE *stdout;//对应显示器文件 extern FILE *stderr;//对应显示器文件通过stdin和stdout进行文件读取写入操作即可实现以文件操作的形式输入输出,不过比较麻烦
#include<stdio.h> int main(){ char temp[10]; printf("请输入: "); int realsize=fread(temp,1,sizeof(temp),stdin); if(realsize==0) return 1; else{ temp[realsize]='\0'; printf("下面是通过文件操作输出的结果\n"); fputs(temp,stdout); printf("\n"); } return 0; }系统文件IO
前面提到,文件操作必须通过操作系统完成,这些库函数内部都封装了系统调用接。,下面我们将介绍Linux中常见的、与文件操作相关的系统调用接口。
open
#include <fcntl.h> int open(const char *pathname, int flags, .../* mode_t mode */ );顾名思义,open函数用于打开文件,如果打开文件失败会返回-1。参数pathname就是文件名或路径。flags是标志位,用于选择打开文件的方式,是以位图的形式使用的。比如O_WRONLY的值是1,表示以只写模式打开文件,O_CREAT的值是64,表示文件不存在时创建新文件,当我们要将这两个选项组合起来时,可以用O_WRONLY | O_CREAT,也就是将二者进行或运算的结果传入即可。此时位图flags中表示只写模式和新建文件的位置都为1,而在open函数内部会让flags对各个选项进行与运算(&),如果结果不为0说明flags带有该选项,open就会函数就会执行对应的模式。比如flags & O_WRONLY如果不为0就说明flags带有O_WRONLY选项。
下面是一些常用的选项,其中前三个选项必须且只能选其中一个
|
| 0 | 只读 |
|
| 1 | 只写 |
|
| 2 | 读写 |
|
| 64 | 文件不存在则创建 |
|
| 512 | 打开时清空文件 |
|
| 1024 | 追加写入 |
如果没有传入权限值,创建的文件的权限是混乱的。
int fd = open("data.txt", O_WRONLY|O_CREAT);而mode参数就是用于初始化新建文件的权限的。我们知道权限的本质就是一串数字,用3位的八进制即可表示所有访问者的所有权限,我们要传给mode的就是这个3位的八进制数字。在C语言中,数字以0开头即可表示8进制数字。但是传入的权限会受到权限掩码(默认为2)的影响,权限掩码是系统用于确保新文件不会有过于宽松的访问条件,我们可以先调用umask函数将掩码改为0。
#include <sys/stat.h> mode_t umask(mode_t mask);#include<fcntl.h> #include<unistd.h> #include<sys/stat.h> #include<stdio.h> int main(){ umask(0); int fd=open("data.txt",O_WRONLY|O_CREAT,0776); return 0; }下面是运行结果
文件描述符、close
open函数成功打开文件后,返回值就是文件描述符,可以看作是文件的编号,使用系统调用接口进行文件的读写或关闭等操作时只能通过文件描述符来确定操作哪个文件,比如用系统调用接口close关闭文件时,我们将open返回的值传入close的fd即可关闭对应的文件。在C语言中这些文件描述符被封装在结构体FILE内部,也就是FILE内部的fileno成员。
#include <unistd.h> int close(int fd);我们打开的文件的文件描述符从3开始,而0、1和2其实就是标准输入、标准输出和标准错误的文件描述符。close关闭文件成功后会返回0,失败返回-1。
write
写入操作对应的系统调用接口是write。参数fd是文件的文件描述符,buf就是要输入的内容的指针,count是要输入的字节数。返回类型ssize_t比较特殊,是一种有符号整型,通常用于表示系统调用返回的字节数或错误指示。它的定义在 sys/types.h 中,适用于需要返回负值以表示错误的情况。write函数返回的是实际写入的字节数,写入失败会返回-1。
#include <unistd.h> ssize_t write(int fd, const void buf[], size_t count);read
读取操作对应的系统调用接口是read。参数fd同上,buf指向用于存储读取内容的空间,参数count是要读取的字节数。返回值是实际读取的字节数,返回0表示读到了文件结尾,-1则代表读取失败。
#include <unistd.h> ssize_t read(int fd, void buf[], size_t count);fsync
将内核的文件缓冲区的内容刷新到文件中,成功会返回0,失败则返回-1。同样,缓冲区的相关内容会在后面讲解。
#include <unistd.h> int fsync(int fd);文件读写原理
事实上,write只能向文件写入二进制数字,也就是操作系统写入文件的只有二进制数字。在计算机中,各种数据最终都会存储为二进制形式,对文件进行写入操作时,操作系统不关心要写入的内容是什么类型,只会将底层存储的二进制形式的内容直接写入文件中。而在读取时,操作系统则会将整个文件的内容视为一个一维的char数组,一个字节一个字节地读取。
例如调用write函数时,buf传入的是int类型的数组,写入时是当作二进制数字写入。由于数字与字符形式的数字对应的二进制数字不同,此时读出来的就是乱码。如果要写入正确的数字,只能先转化成字符串,如下
#include<fcntl.h> #include<unistd.h> #include<sys/stat.h> #include<stdio.h> #include<string.h> int main(){ umask(0); int fd=open("data.txt",O_WRONLY|O_CREAT,0776); char temp[128]; int num=1234567; snprintf(temp,128,"%d\n",num); write(fd,temp,strlen(temp)); close(fd); return 0; }文件管理
file结构体
操作系统管理打开的文件的模式同样是先描述再组织,即通过管理一个存储文件信息的结构体 file (struct file)间接管理文件。这些struct file 同样是连成一个链表统一管理。进程要打开文件时,操作系统会将文件的内容加载到文件缓冲区,并根据文件的属性初始化struct file 。文件缓存区位于内存中的操作系统内核部分。struct file 中包含了指向文件缓冲区的指针,通过它即可访问文件内容。进程修改文件时其实修改的是文件缓冲区,操作系统会定期将文件缓冲区的内容刷新到磁盘的文件中。另外,前面提到的文件读写时的位置指示器也在struct file中。
如果有多个进程打开了同一份文件,操作系统也不会分配新的struct file。在struct file中会进行引用计数,也就是设置一个变量,每有一个进程打开该文件就会加1,关闭该文件就-1,只有当没有进程打开该文件时才会真正关闭文件。
在进程的PCB(task_struct)中有一个文件描述符表(file_struct),记录了进程打开的所有文件和各种数据。file_struct内有一个数组名为fd_array,数组内就是struct file 的指针。通过这个数组就能找到对应的文件。文件描述符的本质就是该数组的下标。当进程打开新文件时,操作系统会遍历fd_array数组,将下标最小且未被使用的部分分配给新文件,用于存储新文件的struct file指针。
重定向
printf函数本质就是向文件描述符为 1 的文件中输出字符串,也就是fd_array的第二个指针指向的标准输出文件,如果我们将标准输出文件关闭,再打开其它文件如 data.txt。这时操作系统就会将fd_array的下标1对应的空间分配给 data.txt,总的来看就是将文件描述符 1 对应的指针改成了data.txt,此时再使用 printf 函数就会将字符串输出到 data.txt 中,这就是重定向的本质——更改文件描述符的指针指向
#include<fcntl.h> #include<unistd.h> #include<sys/stat.h> #include<stdio.h> #include<string.h> int main(){ close(1); umask(0); open("data.txt",O_WRONLY|O_CREAT,0776); printf("Hellow World\n"); return 0; }如果要更改文件描述符的指向,unistd.h 中有更方便的系统调用接口dup2,将文件描述符oldfd改成newfd,也就是关闭fd_array中下标为newfd的指针对应的文件(如果二者是同一个文件或者有其它进程打开了该文件则不关闭),用下标为oldfd指针覆盖下标为newfd的指针,而oldfd对应的文件默认不会关闭。成功会返回newfd,失败返回-1。
#include <unistd.h> int dup2(int oldfd, int newfd);使用不同的方式打开文件,如读取、写入或追加,再使用dup2更改文件描述符的指针指向,就能实现重定向。前面提到,stdout和stderr对应的都是同一个文件,即显示器文件,它们都用于向屏幕输出内容,看上去有些冗余,实际上是为了让我们通过重定向就可以将常规消息和错误消息分离。
在进行重定向时,不一定要写到代码里,在命令行中就可以完成。在运行程序的命令中添加重定向的命令即可,例如下面这行命令将标准输出重定向到文件log.txt中。
如果要把标准输出和标准错误重定向到同一个文件,可以用下面的方法。这个&是bash中的语法,不是取地址,反而和解引用有点相似。感兴趣的可以自行了解
前面的部分都一样,但是在重定向标准错误时不能直接用>直接写入或用>>进行追加,这样会让一个进程打开文件两次,可能导致输出内容交错混乱,如下
#include<stdio.h> int main(){ printf("Negetive\n"); perror("Error"); printf("Negetive\n"); perror("Error"); return 0; }一切皆文件
要使用硬件需要通过硬件的驱动程序,而硬件是通过file结构体管理的。如下图,在struct file中有一个指针 f_op,它指向了file_operations结构体,这个结构体中存储了一套文件操作函数的函数指针,这些函数就是驱动程序的接口,但是被封装为各种文件操作,如open、read、write等。 通过file结构体使用这些函数指针就能调用驱动程序,对相应的硬件进行读写。
这就是虚拟文件系统(VFS),使用同一套标准的文件操作函数对各种文件和设备进行访问。一切皆文件的本质是一切皆通过结构体file管理。
缓冲区
数据要写入文件总共要经过两个缓冲区。当我们调用printf等库函数进行输出时,本质就是将数据写入显示器文件中。这些库函数会将输入的字符串存入C标准库中的缓冲区,这个缓冲区是用户级、语言层面的缓冲区。在操作系统内核中还有一个缓冲区,当用户强制刷新缓冲区,或刷新条件满足,又或是进程退出时,C标准库才会将缓存区的内容通过write等接口拷贝到内核的文件缓冲区,这是为了避免频繁使用系统调用接口,系统调用是有成本的,频繁使用的话代码效率会很低。最后,由操作系统将缓冲区的数据写入文件中。
当我们在代码中通过文件操作进行输出重定向时,如果像下面这样调用库函数printf输出后,就用系统调用close把文件关闭了,此时printf要输出的字符串仍在C标准库的缓冲区中。当代码执行到return语句,进程退出后,C标准库才刚要把输出内容拷贝到内核的文件缓冲区,但此时文件已经关闭了,输出就失败了。这是因为使用系统调用close只会刷新操作系统内核中的文件缓冲区,刷新完成就会关闭文件。而write函数本身就是系统调用,会将内容直接输入到操作系统内核的缓冲区,故能正常输出。若在关闭文件前调用fflush对库中的缓冲区进行刷新即可正常输出。由此可以发现库中确实存在文件缓冲区。
在C语言中,每个文件在C标准库中都有自己的缓冲区。在C语言的FILE中就存储了指向该文件缓冲区的指针。不同种文件的刷新条件也有所不同,如普通文件是将缓冲区填满才进行刷新的全缓冲,显示器文件是输入一行刷新一次的行缓冲,而一写入缓冲区就刷新就是效率低下的无缓冲。要注意的是,运行时用命令将输出重定向到文件中,这时C标准库中的缓冲区就不是行缓冲模式了,如下。原理和前面的代码是一样的,只是变成用命令进行重定向。
至于操作系统管理的文件内核缓冲区则是由操作系统自主决定刷新方案,这个会复杂的多,不过也可以调用前面提到的fsync接口进行主动刷新。