✨✨所属专栏:Linux✨✨
✨✨作者主页:嶔某✨✨
理解文件
- ⽂件在磁盘⾥
- 磁盘是永久性存储介质,因此⽂件在磁盘上的存储是永久性的
- 磁盘是外设(即是输出设备也是输⼊设备)
- 磁盘上的⽂件本质是对⽂件的所有操作,都是对外设的输⼊和输出简称 IO
- 文件 = 文件属性 + 文件内容
- 0 kb的空文件也是在磁盘中占用空间的
- 对文件的操作无非都是进程对属性的操作和对内容的操作
- 磁盘的管理者是操作系统
-
⽂件的读写本质不是通过 C 语⾔ / C++ 的库函数来操作的(这些库函数只是为⽤户提供⽅便),⽽是通过⽂件相关的系统调⽤接⼝来实现的
广义上对文件的理解:可以认为在Linux下一切皆文件(键盘,显示器,网卡,磁盘……)Linux下都把它们做了抽象。
回顾C语言的文件操作
回顾C语言文件操作接口:C语言中的文件和文件操作
文件操作函数 | 功能 |
fopen | 打开文件 |
fclose | 关闭文件 |
fputc | 写入一个字符 |
fgetc | 读取一个字符 |
fputs | 写入一个字符串 |
fgets | 读取一个字符串 |
fprintf | 格式化写入数据 |
fscanf | 格式化读取数据 |
fwrite | 向二进制文件写入数据 |
fread | 从二进制文件读取数据 |
fseek | 设置文件指针的位置 |
ftell | 计算当前文件指针相对于起始位置的偏移量 |
rewind | 设置文件指针到文件的起始位置 |
ferro | 判断文件操作过程中是否发生错误 |
feof | 判断文件指针是否读取到文件末尾 |
打开一个文件的模式:
文件使用方式 | 含义 | 如果指定文件不存在 |
“r”(只读) | 为了输⼊数据,打开⼀个已经存在的⽂本⽂件 | 出错 |
“w”(只写) | 为了输出数据,打开⼀个⽂本⽂件 | 建⽴⼀个新的⽂件 |
“a”(追加) | 向⽂本⽂件尾添加数据 | 建⽴⼀个新的⽂件 |
“rb”(只读) | 为了输⼊数据,打开⼀个⼆进制⽂件 | 出错 |
“wb”(只写) | 为了输出数据,打开⼀个⼆进制⽂件 | 建⽴⼀个新的⽂件 |
“ab”(追加) | 向⼀个⼆进制⽂件尾添加数据 | 建⽴⼀个新的⽂件 |
“r+”(读写) | 为了读和写,打开⼀个⽂本⽂件 | 出错 |
“w+”(读写) | 为了读和写,建议⼀个新的⽂件 | 建⽴⼀个新的⽂件 |
“a+”(读写) | 打开⼀个⽂件,在⽂件尾进⾏读写 | 建⽴⼀个新的⽂件 |
“rb+”(读写) | 为了读和写打开⼀个⼆进制⽂件 | 出错 |
“wb+”(读写) | 为了读和写,新建⼀个新的⼆进制⽂件 | 建⽴⼀个新的⽂件 |
“ab+”(读写) | 打开⼀个⼆进制⽂件,在⽂件尾进⾏读和写 | 建⽴⼀个新的⽂件 |
示例
#include<stdio.h>
int main()
{
FILE*fp=fopen("log.txt","w");
if(fp==NULL)
{
perror("fopen fail:");
return 1;
}
//open success
const char*msg="hello Qin!\n";
int count=5;
while(count--)
{
fputs(msg,fp);
}
fclose(fp);
return 0;
}
进程在当前目录下新建文件并写入
一般而言如果没有定义对应log.txt文件,系统会在当前路径自动创建该文件。并且当前路径并不是指可执行程序所处的路径,而是指该可执行程序运行成为进程时我们所处的路径。并且当前路径并不是指可执行程序所处的路径,而是指该可执行程序运行成为进程时我们所处的路径。比如我们可以在上级目录执行test 文件:
打开⽂件,本质是进程打开,所以,进程知道⾃⼰在哪⾥(环境变量cwd 等等),即便⽂件不带路径,进程也知道。由此OS 就能知道要创建的⽂件放在哪⾥。
可以使⽤ ls /proc/[进程id] -l 命令查看当前正在运⾏进程的信息:
然后我们可以看见两个软连接
- cwd:指向当前进程运⾏⽬录的⼀个符号链接。
- exe:指向启动当前进程的可执⾏⽂件(完整路径)的符号链接。
三个默认打开流
我们常说Linux下一切皆文件,那么我们的键盘与显示器自然也是文件。我们向键盘输入数据,本质就是操作系统向键盘文件中读取数据;我们能从显示器看见数据,本质就是操作系统向显示器文件写入数据。但是我们在使用键盘与显示器时并没有手动进行任何文件相关的读写操作,那我们又是如何对键盘文件与显示器文件进行读写的呢?
答案自然是操作系统自动帮我们打开的,任何进程在运行时,操作系统都会默认打开三个输入输出流,分别为:标准输入流,标准输出流以及标准错误流。对于C语言分别就是:stdin、stdout以及stderr。对于C++分别就是:cin、cout和cerr,自然其他语言也会有相似的概念,因为这是操作系统所支持的,而不是某个语言所独有的。
我们可以在Linux中的man查看对应的声明:
其中标准输入流对应的就是我们的键盘,而标准输出流与标准错误流对应的就是我们显示器。
其中我们也可以通过fputs 函数验证一下:
#include<stdio.h>
int main()
{
//向显示器打印
fputs("hello QinMou!\n",stdout);
fputs("hello QinMou!\n",stdout);
fputs("hello QinMou!\n",stdout);
fputs("hello QinMou!\n",stdout);
return 0;
}
系统调用文件接口
在前面我们学习操作系统时知道,为了方便用户使用,一般我们会对系统接口进行封装。我们的文件操作也不例外,像fopen、fclose 等接口本质其实对操作系统提供的文件接口的封装。接下来我们就来学习一下系统提供的文件接口。
open函数
首先我们来介绍文件打开操作的系统接口。
- pathname:表示打开或者创建的目标文件,若pathname以路径的方式给出,则当需要创建该文件时,就在pathname路径下进行创建。若pathname以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建。
- flags:表示打开文件的方式。
- mode:表示创建文件的默认权限(八进制数)。
其中常用文件打开方式有如下几个:
参数选项 | 含义 |
---|---|
O_RDONLY | 以只读的方式打开文件 |
O_WRNOLY | 以只写的方式打开文件 |
O_APPEND | 以追加的方式打开文件 |
O_RDWR | 以读写的方式打开文件 |
O_CREAT | 当目标文件不存在时,创建文件 |
参数 flags 使用了位图的传参方式,如果想同时兼具多个打开方式,可以使用逻辑与 | 链接两个选项。比如说我们想打开文件并且文件不存在时创建文件,可以写成:
O_WRNOLY | O_CREAT
所以我们也可以使用按位与&
操作来检测是否设置某个选项:
if (flags&O_RDONLY){
//设置了O_RDONLY选项
}
if (flags&O_WRONLY){
//设置了O_WRONLY选项
}
if (flags&O_RDWR){
//设置了O_RDWR选项
}
if (flags&O_CREAT){
//设置了O_CREAT选项
}
//...
并且如果我们打开的文件已存在就使用两个参数的接口,如果打开的文件不存在就需要使用三个参数的接口,即需要为创建的文件设置默认权限。
如果我们要为文件设置默认权限,就需要考虑文件默认掩码umask的影响。我们之前讲过文件的默认权限为:mode&(~mask),我们除了可以在命令行通过指令umask 八进制数来修改默认的掩码umask(默认为002)外,还能在程序中调用umask函数进行修改。比如我们将umask设置为0:
umask(0); // 将文件默认掩码设置为0
最后再来探究一下open 的返回值,也就是文件描述符 fd
#include<stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
umask(0);//设置文件掩码为0
int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);
int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);
int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);
int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);
int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 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);
return 0;
}
运行之后我观察到文件描述符是从3开始的,并且依次递增,这起始并不是偶然。0 1 2系统默认给了标准输入,标准输出,标准错误。
当然这只是文件成功返回的情况,如果文件打开失败,那将返回-1。
close函数
我们可以调用系统接口close 来关闭指定文件,其原型为:
使用close 函数时传入需要关闭文件的文件描述符即可,若关闭文件成功则返回0,若关闭文件失败则返回-1。
write函数
同样我们也能通过系统接口write 对文件进行写入,其原型为:
其中fd 指的是文件描述符,buf 为用户缓冲区,而count 为期望写的字节数。如果写入成功返回实际写入的字节数,若写入失败则返回-1。
注意:ssize_t其实就是一个有符号整型,具体来说就是被typedef重新定义过:typedef int ssize_t
以下我们可以利用write函数对一个log.txt文件进行写入:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
int main()
{
int fd = open("log.txt",O_WRONLY | O_CREAT);
if(fd < 0)
{
//open error
perror("open fail:");
return 1;
}
const char* msg = "hello QinMou!\n";
for(int i = 0; i < 8; i++)
{
write(fd, msg, strlen(msg));
}
close(fd);
return 0;
}
read函数
同样我们也能通过系统接口read 对文件进行读写,其原型为:
其中fd 指的是文件描述符,buf 为用户缓冲区,而count 为期望读的字节数。如果读出成功返回实际读出的字节数,若读出失败则返回 -1。
以下我们可以利用read 函数对一个log.txt 文件进行读出:
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
int main()
{
int fd = open("log.txt", O_RDONLY);
if(fd < 0)
{
perror("open fail:");
return 1;
}
char buf[1024] = {'\0'};
ssize_t ret = read(fd, buf, 1023);
if(ret > 0) printf("%s",buf);
close(fd);
return 0;
}
文件描述符——fd
在我们的操作系统中,文件是由我们进程所打开的,存在大量进程就意味着存在大量被打开的文件。为了方便我们对文件进行管理,我们就将每个文件struct file 链入我们的双向链表之中。
struct File
{
//包含了打开文件的相关属性
//链接属性
};
而一个文件也可能被多个进程所读写,为了让操作系统能够准确识别每个进程对应的文件,我们就一定要让进程与我们的文件建立联系。事实也是如此,我们的进程控制块task_struct 中就存在一个指针指向一个名为struct file_struct 的结构体,这个结构体中存在一个结构体指针数组struct file*fd_array[] 分别存放着着每个文件struct file 的地址。这样我们的进程就与文件建立起了联系。
一般我们的指针数组struct file*fd_array[] 的0,1,2下标分别对应我们的标准输入流,标准输出流,标准错误流这三个文件,而这些下标就是我们所说的文件描述符——fd。这也解释了我们打开文件的描述符为什么从3开始,并且依次递增。并且,通过对应的文件描述符,进程只需要找到对应的指针数组fd_array 就能访问对应的文件,这也是为什么我们文件的系统调用接口的参数一定会有fd 的原因。
当然如果我们在中途关掉某个文件,操作系统就会为该下标重新分配对应的文件。
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
close(0);
close(2);
int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);
int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);
int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);
int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);
int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 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);
return 0;
}
我们也知道,当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的task_struct、mm_struct、页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的映射关系。如果与我们的文件管理联系起来,就是一个磁盘文件log.txt 加载进内存形成内存文件,最后加入对应双向链表中管理起来。
当文件存储在磁盘上时,我们称之为磁盘文件。而当磁盘文件被加载到内存中后,就变成了内存文件。磁盘文件与内存文件的关系,恰似程序和进程的关系。程序在运行起来后成为进程,同样,磁盘文件在加载到内存后成为内存文件。磁盘文件主要由两部分构成,即文件内容和文件属性。文件内容指的是文件中存储的数据,而文件属性则是文件的一些基本信息,包括文件名、文件大小以及文件创建时间等。这些文件属性也被称为元信息。在文件加载到内存的过程中,一般会先加载文件的属性信息。这是因为在很多情况下,我们可能只需要了解文件的基本属性,而不一定立即需要对文件内容进行操作。当确实需要对文件内容进行读取、输入或输出等操作时,才会延后式地加载文件数据。这样的设计可以提高系统的效率,避免在不必要的时候浪费资源加载大量的文件数据。
重定向原理
重定向的底层原理就是修改进程的文件结构体指针数组,例如将原本输出到屏幕的信息输出到一个文件中只需要将fd为1的位置修改为指向相应的文件结构体就行了。
输出重定向
输出重定向的本质就是,将我们本应该输出到一个文件的数据重定向输出到另一个文件中,即关闭对应标准输出流的文件描述符1,然后让该文件描述符重新指向新的文件,最后如果我们再对该文件描述符进行写入,本应该打印在屏幕的数据就重定向进入新文件。
#include<stdio.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<sys/types.h>
int main()
{
close(1); // 关闭标准输出流
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if(fd < 0)
{
perror("open failed");
return 1;
}
printf("Hello, world\n");
printf("Hello, world\n");
printf("Hello, world\n");
fflush(stdout);
close(fd);
return 0;
}
此时输出的信息在语言缓冲区里面, 还没有刷新到系统缓冲区中,也就写不到文件中,需要用fflush函数提前刷新,详细见后文。
本应将信息写入(打印)屏幕上,现在却写进了log.txt文件里,这就是输出重定向。
输入重定向
输入重定向的本质也是与输出重定向同理,将我们本应该输入到一个文件的数据重定向输入到另一个文件中,即关闭对应标准输出流的文件描述符0,然后让该文件描述符重新指向新的文件,最后如果我们再对该文件描述符进行读取,本应该从键盘读取的数据就重定向变为从新文件读取。
#include<stdio.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<sys/types.h>
int main()
{
close(0);
int fd = open("log.txt", O_RDONLY);
if(fd < 0)
{
perror("open failed");
return 1;
}
char buf[128] = {0};
while(scanf("%s", buf) != EOF)
{
printf("%s\n", buf);
}
close(fd);
return 0;
}
本应该向标准输入读取的,现在成了从log.txt文件中读取,这就是输入重定向。
追加重定向
追加重定向相对于输出重定向的区别仅仅是,将打开文件的方式改为追加打开即可
int fd = open("log.txt", O_WRONLY | O_APPEND | O_CREAT, 0666);
标准输出和标准错误对应的设备都是显示器,那么它们有什么区别呢?
printf("Hello, world\n");
perror("Hello, world\n");
fprintf(stdout, "stdout Hello, world\n");
fprintf(stderr, "stderr Hello, world\n");
上面的代码的运行结果重定向到log.txt文件里:
可以发现,标准错误流的信息不会重定向到文件里。 这是因为输出重定向默认关闭的是1号文件描述符,并没有关闭2号文件描述符。
我们可以使用2>操作将标准错误流重定向到其他文件里。
当然如果想将标准输出与标准输出的内容输出到同一文件中,也可以使用类似的指令。
dup2函数
其中Linux操作系统也为了我们提供了专门的重定向接口——dup2函数
- 原型:
int dup2(int oldfd, int newfd);
- 函数功能:dup2会将fd_array[oldfd]的内容拷贝到fd_array[newfd]当中,如果有必要的话我们需要先关闭文件描述符为newfd的文件。
- 函数返回值: 如果调用成功,返回newfd,否则返回-1
使用dup2函数时,需要注意以下两点:
- 如果oldfd不是有效的文件描述符,dup2就会调用失败,此时文件描述符为newfd的文件没有被关闭。
- 如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。
#include<stdio.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if(fd < 0)
{
perror("open fail:");
return 1;
}
close(1);
dup2(fd, 1); // 将fd处的指针拷贝到1处,进行重定向
printf("hello printf!\n");
fprintf(stdout, "hello fprintf!\n");
close(fd);
return 0;
}
缓冲区
语言缓冲区
在计算机领域,缓冲区是一块存储区域。它用于暂存数据,以协调不同速度的设备或操作之间的数据传输。比如我们再来看看下面这段代码:
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
close(1); // 关闭标准输出流
int fd=open("log.txt", O_WRONLY | O_CREAT, 0666);
if(fd<0)
{
perror("open fail:");
return 1;
}
//向屏幕打印信息
printf("hello world!\n");
printf("hello world!\n");
close(fd);
return 0;
}
为什么没有打印信息呢?其实这就与我们C语言的缓冲区有关,因为缓冲区常见的刷新策略有三种:
- 无缓冲
- 行缓冲(常见的对显示器进行刷新数据)
- 全缓冲(常见的对磁盘文件写入数据)
其中对于我们的printf函数,如果没有加\n就是全缓冲,否则就是行缓冲。
因为我们对文件进行了重定向,让本应该向屏幕打印的信息输入进一个磁盘文件,这时缓冲策略就从行缓冲变成了全缓冲,全缓冲需要程序结束之后才会向磁盘刷新文件内容,但是在此之前文件我们已经调用close接口关闭了对于的文件描述符,此时程序结束后就无法找到对应的文件,自然也不会对文件进行任何写入。所以一般为了解决这个问题,我们可以使用fflush函数提前刷新缓冲区。
由于我们使用的printf是C语言提供的接口,所以这个缓冲区也是C语言提供的,其被包含在名为File的结构体中,不光是缓冲区,文件描述符fd也被包含在其中。这也是为什么C语言的文件接口需要返回File*的原因。
系统缓冲区
不仅是我们语言方面存在缓冲区,我们操作系统内部也会存在一个缓冲区,我们一般称为内核缓冲区。同样语言缓冲区刷新到系统缓冲区也遵循三种刷新策略:
- 无缓冲。
- 行缓冲。(常见的对显示器进行刷新数据)
- 全缓冲。(常见的对磁盘文件写入数据)
所以说我们使用语言所提供的接口如printf对文件进行写入数据,首先会将数据存放在语言缓冲区,然后根据不同的刷新规则再刷新到系统缓冲区中,最后才会将系统缓冲区的数据刷新到磁盘或者对应的外设之中。
对于下面的代码:
#include <stdio.h>
#include <unistd.h>
int main()
{
//c
printf("hello printf\n");
fputs("hello fputs\n", stdout);
//system
write(1, "hello write\n", 12);
fork();
return 0;
}
为什么重定向之后的内容会与之前截然不同呢?
这是因为我们执行可执行程序,打印到屏幕,默认是行缓冲,所以直接打印所以数据。但是如果我们对数据进行重定向的话,向磁盘写入数据,默认为全缓冲,此时数据都会存在语言缓冲区中。而此时我们创建子进程,父子进程之间代码数据共享,进程结束之后对语言缓冲区进行刷新,本质就是对数据进行修改,为了进程之间的独立性,就会发生写实拷贝,所以重定向之后C语言接口的数据打印会打印两份。而因为系统接口write写入的数据是直接写入系统缓冲区的,不需要发生写实拷贝,所以只打印一份。
本期博客到这里就结束了,如果有什么错误,欢迎指出,如果对你有帮助,请点个赞,谢谢!