文件操作对于不少编程初学者来说都是一件头疼的事情,不管你学习的是什么编程语言,C/C++/Java/Go/Python ,因为我们学习到的文件相关的操作对你来说都是一个黑盒,我们只是知道我们应该怎么使用相应的文件函数进行操作,而对于内部的实现方式的一无所知,这导致很多初学者对于文件操作都很恐惧。
其实这不是你的问题,因为文件操作涉及了许多操作系统相关的知识,我们只有站在操作系统的角度才能对文件操作有比较深刻的理解,所以本篇文章我们来学习一下Linux
下的基础IO操作,来加深我们对于语言级别文件操作以及系统的理解。
Linux的文件操作
- 一、关于文件的预备知识
- 二、Linux下文件读写的系统调用
- 1、open函数
- 2、close函数
- 3、write函数
- 4、read函数
- 三、文件描述符
- 1、理解文件描述符
- 2、文件描述符的分配规则
- 四、理解Linux下一切皆文件
- 五、重定向
- 重定向的原理
- 系统调用dup2
- 六、缓冲区的理解
一、关于文件的预备知识
- 文件 = 内容 + 属性,针对文件的操作都是对文件内容和属性的操作。
- 当我们没有打开文件时,文件位于磁盘中,当文件被打开时,文件就要操作系统加载到内存中,这是冯诺依曼体系结构对我们文件操作的限制!
- 文件是被进程打开的!
- 实际进程在运行的过程中肯定会打开多个文件,对于多个文件我们操作系统要将它们组织起来以便于管理和维护。所以当文件被打开时,操作系统要为被打开的文件创建相应的内核数据结构
- 为了管理和组织文件,
Linux
操作系统定义了一个struct file
的结构体,里面记录了文件的各种属性以及文件内容的位置,每个struct file
结构体里面又都有一个struct file*
的指针,通过这个指针将我们的所有文件对象组织成了链表,于是我们对文件的操作就变成了对链表的增删查改。
二、Linux下文件读写的系统调用
Linux
中关于文件读写的系统调用主要有以下几个接口,下面我们将一 一介绍:
open
close
write
read
1、open函数
我们打开man
手册在2
号目录里面可以看到open
的函数原型以及介绍:
我们可以看到open
函数有三个头文件,我们在使用时一个也不能落下!
我们还看到open
函数有两个,这里我们先介绍第一个open
函数。
-
第一个参数是路径名称,这个路径可以是相对路径,也可以是绝对路径!
-
第二个参数是一个位图结构的参数,我们可以使用
|
来进行连接,从而一次传递多个参数。
其参数主要如下:- O_RDONLY 只读打开 (英文read only的缩写)
- O_WRONLY 只写打开 (英文write only的缩写)
- O_CREAT 文件不存在就创建该文件 (英文creat)
- O_TRUNC 文件每次打开都会进行清空 (英文truncate)
- O_APPEND 文件写入时以追加的方式进行写入 (英文append)
-
返回值:如果打开成功会返回一个大于或等于0整数,这个整数叫做文件表示符,用来标定一个文件,如果打开失败会返回-1表示打开失败。
我们先用第一个接口打开一个文件
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<sys /stat.h>
#include<fcnt1.h>#define LOG "mylog.txt"int main()
{//由于是位图结构,我们传参时要用 | 将参数连接起来int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC); //以写的方式打开文件 ,如果文件不存在就创建。if(fd == -1){perror( "open fail");exit(-1);}return 0;
}
第一次打开:
这里新创建的文件打开的权限有一点奇怪,我们删除再重新运行程序:
新创建的文件打开的权限还是有一点奇怪,而且和上一次的创建的文件权限竟然不同,我们再次删除再重新运行程序:
这一次就更加奇怪了,这时为什么呢?
因为我们使用的第一个open
函数是没有指明创建后的文件权限是什么的,所以创建出的文件权限是一个随机值,这时我们就要考虑第二个open
函数了!
- 第三个参数 :
mode_t
是一种无符号整形,我们的第三个参数可以以8进制的方式传入我们创建的文件的权限。
修改我们上面的代码:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<sys /stat.h>
#include<fcnt1.h>#define LOG "mylog.txt"int main()
{//由于是位图结构,我们传参时要用 | 将参数连接起来int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666); //创建的文件权限是 -rw-rw-rw-。if(fd == -1){perror( "open fail");exit(-1);}return 0;
}
运行结果:
最终的结果并不是:-rw-rw-rw-
这是为什么呢?
这是因为我们Linux
的普通用户的权限掩码影响了我们文件创建的权限,我们可以使用系统调用umask()
来进行设置我们进程的权限掩码。
运行下面的代码,我们得到了我们想要的结果:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<sys /stat.h>
#include<fcnt1.h>#define LOG "mylog.txt"int main()
{umask(0) //将权限掩码设置为0//由于是位图结构,我们传参时要用 | 将参数连接起来int fd = open(LOG, O_WRONLY | O_CREAT| O_TRUNC, 0666); //创建的文件权限是 -rw-rw-rw-。if(fd == -1){perror( "open fail");exit(-1);}return 0;
}
2、close函数
close
是关闭文件,我们打开的文件最后都要进行关闭,不然会占用系统资源,因此我们上面的代码其实是不规范的。
- 参数: 要关闭的文件标识符fd。
- 返回值:如果执行成功返回0,执行失败返回 -1,并设置错误码。
下面就是使用Linux
系统调用比较标准的打开和关闭操作。
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys /stat.h>
#include<fcnt1.h>#define LOG "mylog.txt"int main()
{umask(0) //将权限掩码设置为0//打开文件int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666); //创建的文件权限是 -rw-rw-rw-。if(fd == -1){perror( "open fail");exit(-1);}//关闭文件close(fd);return 0;
}
3、write函数
write
函数是写入函数,通过此函数我们能向文件中写入数据。
- 第一个参数是文件标识符,也就是要写入的文件。
- 第二个参数是一个指针,指向的是要写入的数据
- 第三个参数是一个变量,表示最多写入多少个字节的数据
- 返回值:实际写入的字节个数。
注意:需要注意的是我们使用系统调用时,不要将C语言的\0
输入到文件中。
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys /stat.h>
#include<fcnt1.h>#define LOG "mylog.txt"int main()
{umask(0) //将权限掩码设置为0//打开文件int fd = open(LOG, O_WRONLY | O_CREAT| O_TRUNC, 0666); //创建的文件权限是 -rw-rw-rw-。if(fd == -1){perror( "open fail");exit(-1);}const char* str1 = "hello world!\n" ;for(int i = 0; i < 5; ++i){ssize_t n = write(fd, str1, strlen(str1));//strlen()后面不要 +1if(n < 0){perror( "write fail: ");exit( -1);}}//关闭文件close(fd);return 0;
}
运行结果
4、read函数
read
函数是读取函数,可以从文件中将数据读进变量中。
- 第一个参数是:要读取的文件的文件标识符。
- 第二个参数是:指针,这个指针指向了一块可以存储数据的空间。
- 第三个参数是: 要读取的字节个数。
- 返回值:实际读取的字节个数。
注意:read
函数会读取换行符(\n
)并将其作为普通字符处理,只有在读取到指定的字节数或者遇到文件的结束才会停止读取。
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h> #define LOG "mylog.txt" int main()
{ int fd = open(LOG, O_RDONLY); if(fd == -1) { perror("open fail"); } //定义缓冲区 char str2[64]; for(int i = 0; i < 5; ++i) { ssize_t n = read(fd, str2, sizeof(str2) - 1); if(n < 0) { perror("write fail: "); exit(-1); }//注意在读取到的字符串后面加'\0'才是C风格的字符串 str2[n] = '\0'; printf("%s",str2); } close(fd);
}
三、文件描述符
我们知道:C语言的文件操作的函数是C库函数,库函数与系统调用之间的关系是:库函数封装了系统调用。
我们还知道: 一个进程默认会打开三个流,标准输入流,标准输出流,标准错误流。
这三个流在C/C++中默认是:
- C中:
stdin
stdout
stderr
- C++中
cin
cout
cerr
1、理解文件描述符
我们知道C语言打开一个文件之后,会返回一个FILE*
的指针,我们的C的三个标准流也是FILE*
的指针,并且它们分别指向了 键盘文件 、显示器文件、显示器文件。当我们尝试去向这标准输出流或者是标准错误流中去输入数据,其实就是向显示器文件进行数据写入,当我们尝试向标准输入流中读取数据,其实就是对键盘文件进行数据读取。
既然是文件,那么在Linux
下进行文件操作必须要有文件描述符,那么我们C程序(以及其他编程语言写的程序)默认打开的三个流,也要有文件描述符,只有有了文件描述符我们系统才能找到对应的文件。
实际上C程序(以及其他编程语言写的程序)形成的进程默认打开的三个流在Linux
中对应的文件描述符都是:
流 | 文件描述符 |
---|---|
标准输入流 | 0 |
标准输出流 | 1 |
标准错误流 | 2 |
观察它们的文件描述符的数字规律,我们可以思考一下我们的进程在打开默认的三个流之后再打开一个文件,那么这个文件的文件描述符是多少?
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>#define LOG "mylog.txt"int main()
{int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd == -1){ perror("open fail"); exit(-1);} //打印出文件标识符printf("%d\n",fd); close(fd); return 0;
}
没有错!是3!你可能会觉得这里和数组的下标好像啊!其实文件描述符就是数组的下标!
我们以前说过操作系统要为我们打开的文件创建一个struct file
对象用来组织管理被打开的文件,我们又知道文件是被进程打开的,那么进程一定要知道操作系统为文件创建的struct file
在哪里,所以在内核数据结构中,还要有一张表,这张表里面记录了每一个被打开的文件的struct file
的位置。
于是操作系统又会为我们创建一个struct file_struct
结构,这个结构里面有一个指针struct file* fd_array
,指向了一个指针数组,指针数组里面存储的就是struct file
对象的地址,而我们得到的文件描述符其实就是这个指针数组的下标。
我们进程的PCB(在Linux
下是task_struct
)里面存放一个struct file_struct*
指针,通过这个指针能够找到struct file_struct
结构,通过这个结构我们能找到文件描述符的指针数组,通过指针数组,我们能够找到struct file
对象,通过这个对象我们又能够文件的内容数据。
2、文件描述符的分配规则
有了文件描述符的理解以后我们就要讨论下一个问题了,文件描述符是怎么分配的呢?
有了上面的经验我们可能会猜测文件描述符的分配规则是:在files_struct
数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。
那么实际上是不是这样呢?我们可以进行实验来确定我们的结果,假设我们在打开新的文件时,先关闭标准输入流,那么0号位置的文件标识符就被空出来了,我们再进行打开文件,看新打开的文件标识符是不是0。
实验代码:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>#define LOG "mylog.txt"int main()
{close(0);int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd == -1){ perror("open fail"); exit(-1);} //打印出文件标识符printf("%d\n",fd); close(fd); return 0;
}
结果说明我们猜想的结论是正确的!
四、理解Linux下一切皆文件
学习过Linux
的人大多数都听过一句话:“Liunx
下一切皆文件”,这一点对于文件操作非常重要,下面我们来谈一谈对于这句话的理解。
我们知道像:键盘,显示器,网卡… 这些都是硬件,根本不是文件,为什么我们能把它们当成文件去看待呢?
首先,我们在操作硬件时都是通过驱动程序进行操作硬件的,而对于不同的硬件它们的驱动程序肯定是不同的,例如键盘只能被读取而不能被写入,显示器只能被写入,而不能被读取,网卡既可以被读取又可以被写入 …
尽管它们的驱动是不同点,但是我们可以设计一个类,对于它们的进行封装,封装完以后,上面的调用者就看不到底层的内容了,上面的调用者只需要对于这个类进行操作就可以达到同样的效果,这样对于调用者来说,它的操作都是在操作这个类,它接触不到底层的驱动以及硬件,当然对于它来说它看到的是什么那就是什么了。
于是Linux
用struct file
结构体封装这些驱动的函数指针,这样进程在使用键盘,显示器时就像是在使用文件一样,这样一来不管是普通文件还是硬件,对于进程来说都是文件。
而我们用户使用操作系统又是通过进程的方式来使用操作系统的,所以我们用户的视角和进程的视角是一样的,在我们用户来看想要让磁盘帮我们保存一些数据,我们只需要保存一下文件就行了,我们没有必要把磁盘拿出来,然后通过一些物理手段向磁盘中刻入数据,进程也是和我们用户一样,它也认为我只要保存一下文件就能完成任务了,所以对于我们用户和进程来说一切都是文件,我们也只能接触到文件,所以Linux
下一切皆文件。
五、重定向
在谈论重定向之前我们先来谈论一下C语言中的FILE
我们使用C语言进行打开文件时,系统都会给我们一个FILE
指针那这个FILE
指针是什么呢?是谁给我们提供的呢?
答案是:是C语言给我们提供的,这个FILE
其实就是一个C库给我们封装的一个结构体,而且这个结构体内部一定要有文件描述符fd
,因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd
访问的。所以C库当中的FILE
结构体内部,必定封装了fd
。
在C库的内部源代码中有这样一些源代码:
//将 _IO_FILE 重命名为FILE
typedef struct _IO_FILE FILE; //在/usr/include/stdio.hstruct _IO_FILE {
int _flags;
// ......struct _IO_FILE *_chain;
int _fileno; //封装的文件描述符//......
};
通过这段源代码,我们知道FILE
内部有一个叫 _fileno
的文件描述符,那么我们就可以将stdin
stdout
stderr
的文件描述符打印出来,看看与我们上面的结论是不是一样的。
打印三个标准流的文件描述符
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>#define LOG "mylog.txt"int main()
{//打印出文件标识符printf("%d\n", stdin->_fileno); printf("%d\n", stdout->_fileno); printf("%d\n", stderr->_fileno); close(fd); return 0;
}
结果和我们以前给的结论是一样的。
重定向的原理
输入重定向
看下面一段代码,我们就可以尝试如果我们关闭1
号文件描述符,然后我们再打开一个文件,之后我们向stdout
里面输入一些数据,看一看会发生什么?还是打印到显示器上面吗?
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h> #define LOG "mylog.txt"
//#define N 64
int main()
{ //关闭标准输出流close(1); int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC); if(fd < 0) { perror("open fail:"); exit(-1); } printf("you can see me?\n"); printf("you can see me?\n"); printf("you can see me?\n"); printf("you can see me?\n"); return 0;
}
答案是并没有打印到显示器中,而是打印到了文件中,相信有了前面的基础你已经明白了,我们将stdout
关闭后,新打开的文件占据了1
号文件描述符,而我们的printf
函数只认识1
号文件描述符,所以向1
号文件描述符指向的文件输入内容,就导致数据输入到了文件里面!
追加重定向
追加重定向的原理很简单,我们只需要将文件的打开方式加上O_APPEND
去掉O_TRUNC
。
例如对于刚才的文件进行重定向:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h> #define LOG "mylog.txt"
//#define N 64
int main()
{ close(1); int fd = open(LOG, O_WRONLY | O_CREAT | O_APPEND); if(fd < 0) { perror("open fail:"); exit(-1); } printf("this is append\n"); printf("this is append\n"); printf("this is append\n"); return 0;
输入重定向
同理,我们把0
号文件标识符给关闭,然后打开我们的新文件进行scanf
,那么我们应该会从新打开的文件中读取数据,我们看一看结果:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h> #define LOG "mylog.txt"
//#define N 64
int main()
{ close(0); int fd = open(LOG, O_RDONLY); if (fd < 0) { perror("open fail:"); exit(-1); } int a; char c; scanf("%d %c",&a, &c); printf("%d %c\n", a, c); return 0;
}
结果是符合我们的预期的!
重定向的原理:在上层无法感知的情况下,在操作系统内部,更改进程对应的文件描述符表中,特定下标的指向!!!
根据这些原理我们来实现一个需求:将标准输出流与标准错误流的信息进行分流。
分析:我们知道标准输入流与标准输出流其实打开的是都是显示器文件,如果我们直接用标准输出标准错误流一起使用,就会导致错误信息与正确信息混合在一起,导致我们难以找到错误所在。
我们可以使用重定向进行分流,我们先关闭1
号文件描述符,然后新打开一个文件normal.txt
,然后关闭2
号文件描述符,再打开一个新的文件error.txt
这样我们再使用标准输入或标准错误流时,信息会被写入两个不同的文件中,我们关心错误信息就可以打开error.txt
进行查看,关心正确信息,就可以打开normal.txt
进行查看。
原本不分流时:
#include<stdio.h> int main()
{ fprintf(stdout, "stdout->normal\n"); fprintf(stdout, "stdout->normal\n"); fprintf(stdout, "stdout->normal\n"); fprintf(stdout, "stdout->normal\n"); fprintf(stderr, "stderr->error\n"); fprintf(stderr, "stderr->error\n"); fprintf(stderr, "stderr->error\n"); return 0;
}
错误信息与正确信息混在一起!!
进行分流:
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h> int main()
{ umask(0); close(1); open("normal.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); close(2); open("error.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); fprintf(stdout, "stdout->normal\n"); fprintf(stdout, "stdout->normal\n"); fprintf(stdout, "stdout->normal\n"); fprintf(stdout, "stdout->normal\n"); fprintf(stderr, "stderr->error\n"); fprintf(stderr, "stderr->error\n"); fprintf(stderr, "stderr->error\n"); close(1);close(2); return 0;
}
分流完成!
系统调用dup2
其实呢,对于上面的操作我们手动关闭其实是有一些不方便的,对于上面的操作Linux
给我们提供了一个系统调用dup2
,它的作用就是用第一个标识符里面的地址覆盖第二个的标识符中的地址。从而达到重定向的目的。
- 第一个参数:要保留的参数。
- 第二个参数: 要被覆盖的参数。
- 返回值: 成功就返回第二个文件表示符,失败就返回 -1,并设置错误码。
对于上面的分流代码我们就可以:
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h> int main()
{ umask(0); int fd1 = open("normal.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); int fd2 = open("error.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); int n1 = dup2(fd1, 1); int n2 = dup2(fd2, 2);//将dup2的返回值打印进文件中 printf("%d %d\n", n1, n2);fprintf(stdout, "stdout->normal\n"); fprintf(stdout, "stdout->normal\n"); fprintf(stdout, "stdout->normal\n"); fprintf(stdout, "stdout->normal\n"); fprintf(stderr, "stderr->error\n"); fprintf(stderr, "stderr->error\n"); fprintf(stderr, "stderr->error\n"); close(fd1);close(fd2); return 0;
}
六、缓冲区的理解
我们以前学习C语言的文件操作时,我们都知道FILE
里面应该是有缓冲区的,现在我们学习操作系统时我们又知道操作系统内核里面也是有缓冲区的,那这两个缓冲区是一样的吗?
对于这个问题我们现在不好回答,我们只能先给出结论:是不一样的,FILE
是C库提供给我们的一个结构体,里面的缓冲区对应的是用户态的缓冲区,linux
内核中的缓冲区,对应的是内核态的缓冲区。
我们先看下面的代码,根据现象我们来分析问题,最后再来理解一下缓冲区。
#include<stdio.h>
#include<unistd.h>
#include<string.h> int main()
{ printf("printf : hello world!\n"); const char* str = "write: hello world!\n"; write(1, str, strlen(str)); //创建子进程 fork(); return 0;
}
结果:
我们发现当我们直接运行和重定向后的结果是不同的,而且printf()
会比write
多一次打印,这时为什么呢?
其实呢这与C库的缓冲区有关系!缓冲区在哪里?在你进行fopen打开文件的时候,你会得到FILE结构体,缓冲区就在这个FILE结构体中!!
C库会结合一定的刷新策略,将我们缓冲区中的数据写入给操作系统(通过write (FILE->fd,xXXX) ) ;
- 无缓冲
- 行缓冲 (显示器采用的刷新策略: 行缓冲)
- 全缓冲 (普通文件采用的刷新策略:全缓冲)
通过这张图片我们就能很好的知道,为什么会出现上面的情况了。
在运行时,printf
函数使用的是显示器文件,所以代码运行后立即就被C库的刷新到了操作系统内核里面的缓冲区了,write
函数本身就是向操作系统内核里面写入数据,因此也将数据写入到操作系统内核里面的缓冲区了,fork
之后FILE
里面的缓冲区的数据内容要被清空,但是FILE
的缓冲区里面本身就没有数据,无法输出数据了,进程也结束了。
但是,当变成重定向时,由于printf
函数使用的文件变成了普通文件了,数据的刷新方式变成了全缓冲,所以printf
代码运行之后数据被暂存到了FILE
的缓冲区里面了,而write
函数写的数据向系统内核里面直接写入了数据,程序运行完毕,FILE
内部的缓冲区要被清洗(此时缓冲区里面有数据),但是进程从一个变成了两个,要清洗两次缓冲区,于是log.txt
里面就有了两次printf
打印的内容。
为什么C库的
FILE
里面要有缓冲区呢?
答案是:节省调用者的时间! 如果我们想直接把数据写到操作系统内核中就需要调用系统调用,而系统调用的使用代价是要比普通函数大的多的,因此为了尽量少的使用系统调用,尽量一次IO能够读取和写入更多的数据,所以FILE
内部才有了缓冲区。