一、文件的概念
C语言中有关于文件操作的库函数,知道了这些库函数接口的用法,也就仅限于这些接口过的使用;对于文件的了解没有一点帮助。这也就说明了文件不是语言层面的而是系统层面的。
文件可以分为两大类,一类是打开的文件,一类是没有打开的文件;
没有打开的文件是存储在磁盘中的,这种文件会在文件系统中详细解释,我们重点先了解打开的文件;
打开的文件是是被加载到内存中的;文件=文件内容+属性。因为打开文件就意味着要操作文件,操作文件就意味着要对文件内容和属性进行操作,操作这些信息就必须按照冯诺依曼体系进行操作,从内存中存取数据;
操作系统要操作文件也就必须管理文件,管理=描述+组织,描述就是创建它的struct结构体struct_file{},组织就是在os内核中创建文件对应的数据链struct_file1->struct_file2->…
二、文件操作
在高级语言中c/c++,Python,java,等等这些语言中都有对应的文件操作库函数,但是它们又不怎么一样,其实我们可以用统一的视角去看这些接口。从系统调用的视角去看,因为不同语言文件 操作接口都是封装了相同的系统接口。
1.c语言的文件操作接口
文件打开接口
FILE *fopen(const char *path, const char *mode);
参数:path文件路径名,
mode文件打开方式,只读w 只写r 追加a 读写r+,读写w+ 追加读写a+ / 二进制数据只读rb 只写wb 追加ab
返回值:成功返回一个指向文件的文件流,失败返回NULL
写入接口
int fputs(const char *s, FILE *stream);
参数:s字符串指针
stream被写入的文件的文件流指针
返回值:成功返回非负数,否则返回-1;
读取接口
char *fgets(char *s, int size, FILE *stream);
参数:s要写入的字符串指针
size写入的字符串字节大小
stream要读取的文件的文件流指针
返回值:成功返回字符串指针,失败返回NULL
关闭文件
int fclose(FILE *fp);
参数:fp文件流
返回值:成功返回0,失败返回-1;
格式化数据转换字符串接口
int snprintf(char *str, size_t size, const char *format, ...);
参数:str被写入的字符串指针
size被写入的字符串字节大小
format格式化数据,根printf参数一样
#include<iostream>
#include<cstdio>
#include<cerrno>
#include<cstring>
#include<cassert>
using namespace std;
int main()
{
FILE* fp=fopen("myfile.txt","w");//成功返回一个文件流,失败返回NULL
if(!fp)
{
exit(-1);
cout<<errno<<strerror(errno)<<endl;
}
const char* msg="hello world";
int count=5;
char buf[4096];
while(count--)
{
//先将字符串输出到缓冲区,然后再有缓冲区输出到文件
snprintf(buf,sizeof(buf),"%s %d\n",msg,count+1);
fputs(buf,fp); //成功返回非负的数字,失败返回EOF
}
fclose(stream);
return 0;
}
myfile.txt
hello world 5
hello world 4
hello world 3
hello world 2
hello world 1
#include<cassert>
using namespace std;
int main()
{
FILE* fp=fopen("myfile.txt","r");
if(!stream)
{
exit(-1);
cout<<errno<<strerror(errno)<<endl;
}
char buf[4096];
while(1)
{
char*ret=fgets(buf,sizeof(buf),sfp);
if(ret)
{
printf("%s",buf);
}
else
{
break;
}
}
fclose(fp);
return 0;
}
终端
hello world 5
hello world 4
hello world 3
hello world 2
hello world 1
2.系统调用
高级语言的读写文件接口都是封装了库函数的,那么库函数的功能自然也是继承自系统调用
2.1接口介绍
**打开文件接口**
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
参数:pathname文件路径名
flags打开方式 只写O_WRONLY 只读O_RDONLY 读写O_RDWR 清空O_TRUNC 创建文件O_CREART 追加O_APPEND
mode模式,就是创建文件的拥有者,所属组,other的权限,可以用6代表110 7代表111 4代表100的方式传参,例如666 代表rw_rw_rw_
返回值:成功返回文件的描述符,失败返回-1
创建文件默认权限修改接口
mode_t umask(mode_t mask);
mask默认权限,文件都有读写执行三个权限有用1表示没有用0表示 0 代表000, 2 代表010 也就是 _w_
默认权限取反然后按位&上创建文件时设置的权限才是最终权限,此接口作用域只在当前进程
**写入接口**
ssize_t write(int fd, const void *buf, size_t count);
参数:fd文件描述符,后面解释
buf往文件中写入字符串的指针
count往文件中写入字符串的字节大小
返回值:成功返回写入的字节数(0表示没有写入),失败返回-1;如果size=1那么正常情况下每次返回1,否则返回0或者-1
**读取接口**
ssize_t read(int fd, void *buf, size_t count);
参数:fd文件描述符
buf用于接收读取到的字符串的容器指针
count读取的字节数
返回值:成功返回读取的字节数,同时这个数字也是文件读取的位置,0代表文件尾部;如果读取的数字不等于count那么可能是文件读取完了,或者是读取错误会返回errno代码
关闭文件
int close(int fd);
参数:fd文件描述符
返回值:成功返回0,失败返回-1;
2.2位图
需要解释一下falgs的传参方式,像O_WRONLY这些参数都是系统指定的宏,这里是利用了位图传参标志位的方式
比如函数参数是一个整型,给它传一个1就执行功能1传一个2就执行功能2,位图则是利用了比特位的方式传递信号 。利用这种方式大大节省了空间,一个int右32 个比特位,那么就可以传参32个标志位了。
代码测试
#include<iostream>
using namespace std;
#define PRINT 1 //0001
#define REFRESH 2 //0010
#define PERFORM 4 //0100
void func(int flags)
{
if(flags&PRINT) // 0000&0001不等于0说明有PRINT
{
cout<<"打印";
}
if(flags&REFRESH)
{
cout<<"刷新";
}
if(flags&PERFORM)
{
cout<<"执行";
}
cout<<endl;
}
int main()
{
func(REFRESH);
func(PRINT);
func(PERFORM);
func(PERFORM|PRINT|REFRESH);
return 0;
}
2.3系统调用代码测试
往myfile.txt中写入5条信息
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<cerrno>
#include<cstring>
using namespace std;
int main()
{
umask(0);//设置当前进程默认权限
int fd=open("myfile.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
if(!fd)
{
cout<<errno<<strerror(errno)<<endl;
}
const char* msg="hello system call";
char buf[1024];
int count=5;
while(count--)
{
snprintf(buf,sizeof(buf),"%s %d\n",msg,count+1);
ssize_t ret=write(fd,buf,strlen(buf));//注意这里的msg中的'\0'是不能写入到文件中的,会被解析成乱码
if(ret==-1)
{
cout<<errno<<strerror(errno)<<endl;
}
}
close(fd);
return 0;
}
myfile.txt
hello system call 5
hello system call 4
hello system call 3
hello system call 2
hello system call 1
从myfile.txt中读取所有信息
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<cerrno>
#include<cstring>
using namespace std;
int main()
{
umask(0);//设置当前进程默认权限
int fd=open("myfile.txt",O_RDONLY);
if(!fd)
{
cout<<errno<<strerror(errno)<<endl;
}
char buf[1024];
while(true)
{
ssize_t ret=read(fd,buf,sizeof(buf)-1);
//这里不能用strlen因为strlen(buf)是随机数
if(ret>0)
{
cout<<buf<<endl;
}
else
{
if(ret==-1)
{
cout<<errno<<strerror(errno)<<endl;
break;
}
else
{
break;
}
}
}
close(fd);
return 0;
}
终端
hello system call 5
hello system call 4
hello system call 3
hello system call 2
hello system call 1
2.4系统调用与库函数的本质区别
任何语言的库函数文件操作接口都是封装了系统调用的,因为打开文件的本质相当于从磁盘加载到内存,是需要访问硬件设施的,而管理硬件的是操作系统,库函数是不能直接去访问硬件资源的,必须经过操作系统来访问,而操作系统提供的访问方式就是系统调用,以后我们不管学习哪一门语言的文件操作都可以有一个统一的视角去看待文件操作了,那就是从系统调用的视角去看。
三、文件描述符
1.文件描述符是什么
1.1输出多个文件描述符测试
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
using namespace std;
int main()
{
int fd1=open("myfile.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
int fd2=open("myfile.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
int fd3=open("myfile.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
int fd4=open("myfile.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
int fd5=open("myfile.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
int fd6=open("myfile.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
cout<<fd1<<endl;
cout<<fd2<<endl;
cout<<fd3<<endl;
cout<<fd4<<endl;
cout<<fd5<<endl;
cout<<fd6<<endl;
close(fd1);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
close(fd5);
close(fd6);
return 0;
}
结果:
3
4
5
6
7
8
1.2linux三个默认打开文件测试
可以看到创建的文件的文件描述符都是从3及以后开始排列的,那是因为liux系统默认情况下会打开三个文件即标准输入,标准输出,标准错误。这三个文件对应的是键盘,屏幕,屏幕;分别对应的文件描述符是0,1 , 2
向1和2中写入字符串,最终会打印到屏幕上,因为1和2对应是屏幕
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<cstring>
using namespace std;
int main()
{
const char* msg="hello world\n";
write(1,msg,strlen(msg));
write(2,msg,strlen(msg));
return 0;
}
结果
hello world
hello world
1.3文件底层原理
经过上面的测试可以看出文件描述根数组的下标有点相似,其实文件描述符就是一个数组的下标。
用户通过进程打开一个文件,os内核就会为改文件创建一个file结构体用于存储文件属性+内容,但是进程是可以打开多个文件的,形成一对多的局面,进程需要维护这些结构体,os会创建一个files_struct的结构体,这个结构体内部有一个类型为file* 的指针数组,用于存储file结构体的指针,名叫fd_array[];进程会在PCB也就是task_struct结构体中创建一个files_struct*类型的指针用于存储files_struct结构体指针。
1.31文件操作过程
进程从PCB中找到files_struct结构体–>在files_struct中找到fd_array–>遍历fd_array找到第一个空余的数组下标–>创建file结构体,将指针存放在该数组下标–>返回数组下标
1.32linux下一切皆文件的理解
linux默认会打开三个文件,一个是键盘对应的标准输入,一个是屏幕对应的标准输出,一个是屏幕对应的标准错误;这三个文件对应的都是硬件。操作系统是分层管理的,硬件的上层是驱动程序,驱动程序可以对硬件直接执行写入和读取操作,驱动程序的上层是操作系统,file结构体中其实是封装了对应的硬件驱动的程序的,当我们要对键盘进行读取操作时,进程会调用标准输入文件的file结构体,file结构体调用驱动程序就可以完成对键盘的读取操作了。
所以诸如此类的其他硬件资源操作也都是如此,本质上都是对设备进程I/O操作,所以进程看待一切都是以文件的方式看待的,进程看一切都是文件,我们的所有请求都是进程帮我们完成的,所以我们看待一切也都是文件。
1.4结论
files_struct结构体中的fd_array[]数组的下标就是文件描述符,向键盘或则屏幕输入输出本质上就是对文件进行读取和写入。
2.重定向的原理
2.1关闭0、1、2文件测试
关闭标准输入然后打开一个文件,再从stdin接收数据
myfile.txt
hello world\n
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<cstring>
using namespace std;
int main()
{
close(0);
int fd=open("myfile.txt",O_RDONLY);
char buff[1024];
fgets(buff,sizeof(buff)-1,stdin);
cout<<buff<<endl;
return 0;
}
结果
hellow world\n
本来应该从键盘接收数据的,变成了从文件中获取数据,这种现象叫做输入重定向;
关闭标准输出,创建一个新的文件,再向stdout输出数据
myfile.txt
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<cstring>
using namespace std;
int main()
{
close(1);
int fd=open("myfile.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
const char* msg="hello world\n";
fputs(msg,stdout);
return 0;
}
myfile.txt
hello world
本来应该输出到屏幕的数据输出到了myfile.txt文件中,这种现象叫做输出重定向
关闭标准输出,创建一个可以追加数据的新文件,再向stdout输出数据,
myfile.txt
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<cstring>
using namespace std;
int main()
{
close(1);
int fd=open("myfile.txt",O_WRONLY|O_CREAT|O_APPEND,0666);
const char* msg="hello world\n";
fputs(msg,stdout);
return 0;
}
[admin@VM-12-7-centos basic_IO]$ ./test
[admin@VM-12-7-centos basic_IO]$ ./test
[admin@VM-12-7-centos basic_IO]$ ./test
myfile.txt
hello world
hello world
hello world
运行三次向文件输出了三个字符串,这个现象叫做追加重定向
2.2文件描述符的分配原则
文件描述符的分配原则是从小到大分配的,从数组下标0的位置开始找,遇到的第一个空余的位置就会将新的file指针分配个当前位置。当1号文件被关闭,那么下一个新打开的文件就会取代它的位置。
2.3dup2函数接口介绍
重定向也有对应的系统调用接口,就是dup2()
int dup2(int oldfd, int newfd);
参数:oldfd就是新打开文件的文件描述符
newfd就是要重定向的文件的fd,意思是用数组oldfd位置得file指针覆盖newfd位置的file指针
返回值:成功则返回一个新的fd,失败返回-1;
2.31dup2接口测试
myfile.txt
#include<iostream>
#include<cstdio>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<cstring>
using namespace std;
int main()
{
int fd=open("myfile.txt",O_WRONLY|O_CREAT|O_APPEND,0666);
int ret=dup2(fd,1);
if(ret==-1)
{
perror("dup2;");
exit(0);
}
const char* msg="hello world\n";
fputs(msg,stdout);
return 0;
}
[admin@VM-12-7-centos basic_IO]$ ./test
[admin@VM-12-7-centos basic_IO]$ ./test
[admin@VM-12-7-centos basic_IO]$ ./test
myfile.txt
hello world
hello world
hello world
3.语言对系统调用的封装
3.1C语言FILE结构体
从文件的打开原理可以看出,文件操作必须是要通过文件描述符操作,比如C语言中的接口,C语言的fopen()文件打开接口返回值是一个FILE的指针,其实FILE是C语言库提供的一个结构体指针,这个结构体中一定是封装了fd的;在/usr/include/libio.h文件中可以找到FILE结构体。
3.2C语言库函数缓冲区刷新策略
另外C语言接口内部还封装了一个缓冲区,有自己特定的缓冲区刷新策略,当库函数向显示器写入数据时是行缓冲就是遇见\n就将缓冲区的数据刷新到外设,向文件写入时是全缓冲,当进程结束的时候才会将数据刷新到文件。
3.21库函数缓冲区刷新策略测试
用库函数和系统调用同时想显示屏写入数据,然后再创建fork文件
#include<iostream>
#include<cstdio>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<cstring>
using namespace std;
int main()
{
const char* msg0="printf\n";
const char* msg1="fwrite\n";
const char* msg2="write\n";
printf("%s",msg0);
fwrite(msg1,sizeof(msg1)-1,1,stdout);
write(1,msg2,sizeof(msg2)-1);
fork();
return 0;
}
结果
printf
fwrite
write
补充一下fork的运行原理,fork()在创建后,子进程是从fork()代码下面的位置开始运行的。
将上面的代码输出重定向到myfile.txt文件
[admin@VM-12-7-centos basic_IO]$ ./test > myfile.txt
myfile.txt
write
printf
fwrite
printf
fwrite
因为是向myfile文件写入数据,库函数由行缓冲变为全缓冲,当子进程创建的时候,printf和fwrite函数还没有将数据刷新到文件,也就是还没有执行完,这个时候子进程与父进程运行同步,都会想文件执行写入操作。
4.自定义封装系统调用
有了上面的基础,可以尝试自己封装一个文件操作接口
my_stdio.h
#pragma once
#include<stdio.h>
#define BUFF_NONE 1
#define BUFF_LINE 2
#define BUFF_ALL 4
#define NUM 1024
typedef struct _MY_FILE
{
//文件描述符
int _fd;
//刷新策略
int _flag;
//缓冲区当前size
int _current;
//缓冲区
char _outputbuffer[NUM];
}MY_FILE;
MY_FILE* my_fopen(const char* str,const char* mode);
size_t my_fwrite(const char* str,size_t size ,size_t number,MY_FILE* stream);
int my_fclose(MY_FILE* fp);
int my_fflush(MY_FILE* fp);
头文件定义了一个简洁版的FILE结构体,其中封装了fd 刷新策略标志flag,缓冲区当前大小,缓冲区;
声明了打开文件接口,写入文件接口,关闭文件接口,以及刷新内部使用接口
my_stdio.c
#include"my_stdio.h"
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<assert.h>
#include<string.h>
#include<malloc.h>
#include<unistd.h>
MY_FILE* my_fopen(const char* str,const char* mode)
{
//打开文件
int flag=0,m=0666,fd=0;
if(strcmp(mode,"w")==0)flag=O_WRONLY|O_CREAT|O_TRUNC;
else if(strcmp(mode,"r")==0)flag=O_RDONLY;
else if(strcmp(mode,"a")==0)flag=O_WRONLY|O_CREAT|O_APPEND;
if(flag&O_CREAT)fd=open(str,flag,m);
else fd=open(str,flag);
//初始化file结构体
MY_FILE* fp=(MY_FILE*)malloc(sizeof(MY_FILE));
if(fp==NULL)
{
close(fd);
return NULL;
}
fp->_fd=fd;
fp->_flag=0;
fp->_flag|=BUFF_LINE;
fp->_current=0;
memset(fp->_outputbuffer,'\0',sizeof(fp->_outputbuffer));
return fp;
}
size_t my_fwrite(const char* str,size_t size ,size_t number,MY_FILE* stream)
{
//缓冲区判满
if(stream->_current==NUM)my_fflush(stream);
//向缓冲区拷贝字符串
int usr_size=number*size;
int my_size=NUM-stream->_current;
size_t writen=0;
if(my_size>=usr_size)
{
memcpy(stream->_outputbuffer+stream->_current,str,usr_size);
stream->_current+=usr_size;
writen=usr_size;
}
else
{
memcpy(stream->_outputbuffer+stream->_current,str,my_size);
stream->_current+=my_size;
writen=my_size;
}
//判断是否刷新
if(stream->_flag&BUFF_LINE)
{
if(stream->_outputbuffer[stream->_current-1]=='\n')my_fflush(stream);
}
else if(stream->_flag&BUFF_ALL)
{
if(stream->_current==NUM)my_fflush(stream);
}
else{}
//返回写入的字节数
return writen;
}
int my_fclose(MY_FILE* fp)
{
//冲刷缓冲区
if(fp->_current>0)my_fflush(fp);
//关闭文件
close(fp->_fd);
//释放堆空间
free(fp);
//置空fp
fp=NULL;
return 0;
}
int my_fflush(MY_FILE* fp)
{
assert(fp);
write(fp->_fd,fp->_outputbuffer,fp->_current);
fp->_current=0;
int ret=fsync(fp->_fd);
(void)ret;
assert(ret==0);
}
打开文件接口主要功能是,识别用户传过来的打开模式w , r , a打开一个文件,将fd返回给FILE结构体,初始化结构体。
写入文件接口功能是,将用户传参的字符串拷贝到FILE结构体的缓冲区中,然后按照一定行或者全刷新策略将数据刷新到系统内核,系统在根据系统自己的刷新策略刷新到文件。
刷新接口,本质就是调用系统调用将用户层数据拷贝至内核缓冲区,但是有一个系统调用可以直接刷新数据到文件,也就是磁盘,fsync()函数。
关闭文件接口,主要是冲刷缓冲区,关闭文件,释放FILE结构体对象的堆空间。
main.c
#include"my_stdio.h"
#include<stdio.h>
#include<string.h>
#include<assert.h>
#include<unistd.h>
int main()
{
const char* msg="hello world";
int count=500;
MY_FILE* fp=my_fopen("myfile.txt","w");
assert(fp);
/* while(count--)
{
char buff[1024];
snprintf(buff,sizeof(buff),"%s %d",msg,count+1);
my_fwrite(buff,strlen(buff),1,fp);
} */
while(count--)
{
char buff[1024];
snprintf(buff,sizeof(buff),"%s %d",msg,count+1);
//这里用sizeof会生写入乱码,strlen(buff)以后就没有数据了。
my_fwrite(buff,strlen(buff),1,fp);
sleep(1);
//没写入五次刷新一次
if(count%5==0)
{
my_fwrite("\n",strlen("\n"),1,fp);
}
}
my_fclose(fp);
return 0;
}
我们可以再不影响使用的情况下,多次拷贝一次刷新,可以提高io效率,main.c测试了默认按行刷新策略,写入五次的时候才写入一个\n,这样可以实现写入五次刷新一次的刷新策略。