前言
本章通过六部分带你去梳理,基础IO的内容。
一、重新认识文件--从c接口出发
1、文件=属性+内容;
2、进程打开文件后会记录当前工作路径(cwd)。
这里可以通过实验来看
int main()
{
FILE *fp = fopen("myfile", "w");
if(!fp)
{
printf("fopen error!\n");
}
w hile(1);
fclose(fp);
return 0;
}
可以通过ls -l /proc/PID 来查看,此时PID是你运行程序的PID。此时cwd就是当前工作路径
3、语言层接口(fopen,fwrite,fread)是系统封装函数,方便用户接入。
FILE *fopen(const char *restrict pathname, const char *restrict mode);
这个接口是打开一个文件的,有了就打开,没有就直接创建,mode是类型,看你选择是读还是写。size_t fread(void ptr[restrict .size * .nmemb], size_t size, size_t nmemb,FILE *restrict stream);
这个接口是读的,从你的文件里读取内容到内存里。size_t fwrite(const void ptr[restrict .size * .nmemb],size_t size, size_t nmemb, FILE *restrict stream);
这个接口是做写入的,将数据写入到这个文件里。
在这里不做详细介绍,可以取Linux系统中man相关函数去进行详细的了解。
//////////////////////////////////////////////////////////////////////////////////////////////////
int main()
{
FILE* fp=fopen("myfile","w");
char buf[1024];
const char* msg="hello world";
if(!fp)
{
printf("fopen is error\n");
}
int count=5;
while(count--)
{
fwrite(msg,strlen(msg),1,fp);
}
fclose(fp);
fp=fopen("myfile","r");
size_t i;
while((i=(fread(buf,1,sizeof(buf)-1,fp)))>0)
{
buf[i]='\0';
printf("%s\n",buf);
}
fclose(fp);
return 0;
}
上边是一段代码,可以练习读写文件的操作,同时完成一个简易的cat小程序。
二、跨过语言层--使用系统调用
我们上文讲述的fwrite,fopen等函数其实底层调用的就是这里的open,write等的系统调用。
1、系统调用
int open(const char * pathname,int flags,mode_t mode);
这个pathname就是你想要打开的文件。
flags是可以传入多个参数选项,下边列举几个:
O_RDONLY:只读打开
O_WRONLY:只写打开
O_RDWR:读写打开
O_CREAT:若文件不存在就创建
O_APPEND:追加写
返回值:-1:就是失败了,如果成功就会返回文件描述符(fd).
2、ssize_t write(int fd, const void buf[.count], size_t count);
fd就是上文提到的文件描述符,也就是open如果成功打开的返回值,fd是进程内部的资源编号,write,read等系统调用只认fd,也就是说通过fd可以找到你这个文件。
buf这个就是一个数组,在这里返回也就是返回的是buf的指针。这里buf其实就是后文要讲的用户缓冲区,提前了解一下就是,系统调用是有代价的,如果频繁的调用系统调用的话会导致效率低下,所以在这里采用了缓冲区的思想。
count就是第二个参数的大小。
返回值:>0的情况是成功写入了多少字节数
==0的情况在write极少出现,只需要记住>0即可
==-1出错
3、ssize_t read(int fd, void buf[.count], size_t count);
这个与上文的write类似,就不再多说了,着重说一下返回值。
返回值:>0的情况就是读到了多少字节数
==0的情况是读到了文件末尾
==-1就是出错了
代码练习:实现向文件写入5次,然后读取并打印到显示器上
int main()
{
int fd=open("myfile",O_WRONLY|O_CREAT,0644);
int count=5;
const char* msg="hello world\n";
while(count--)
{
write(fd,msg,strlen(msg));
}
close(fd);
fd=open("myfile",O_RDONLY);
size_t i;
char buf[1024];
while((i=read(fd,buf,sizeof(buf)))>0)
{
buf[i]='\0';
printf("%s",buf);
}
close(fd);
return 0;
}
该部分思考
1、open 的返回值是什么?
答:失败了返回-1,成功了返回文件描述符fd。
2、为什么系统调用要传递mode(如0644)?
答:因为文件创建需要权限。
3、write的第三个参数是期望写入的字节数,第二个参数是缓冲区首地址,返回值实际写入的字节数,如果说返回比期望少该怎么办?
答:循环重试。(解析:出现这个情况就是说部分写入部分没有写入,发现写入信号中断就会进行循环重试,移动缓冲区首地址,变相的相当于又有了一块地方可以写入,此时就可以继续写,直到写完为止。)
三、文件描述符的秘密--0、1、2
图片:
1、实验并观察现象
1、先close(0),再open,打印fd,观察现象
int main()
{
close(0);
int fd=open("myfile",O_CREAT|O_RDONLY);
printf("fd的值:%d\n",fd);
return 0;
}这里可以加一个部分就是可以回顾之前的知识,就是你可以在open那里的代码中第二个参数把O_CREAT去掉,此时就会返回-1,也就是打开失败,为什么呢?可以想一想。
2、再试试close(2),惯出现象。
{
close(2);
int fd=open("myfile",O_CREAT|O_RDONLY);
printf("fd的值:%d\n",fd);
return 0;
}3、指令:ls -l /proc/PID/fd
复制新的ssh渠道去观察现象(对了记得把close注释掉要不然观察不全)。
会发现0,1,2,3.后续还会讲,此时点一点,这个数字其实就是文件描述符fd。
坑点:
注意:在这里有一个点会非常容易出错,就是open失败的场景,不妨看一下这个代码以及图示
int main()
{
close(2);
pid_t id=getpid();
printf("PID的值是:%d\n",id);
int fd=open("myfile",O_CREAT|O_WRONLY|O_TRUNC,0644);if(fd<0)
{
printf("error\n");
}
while(1)
{
sleep(1);
}
close(fd);
return 0;
}
乍一看没有错误对吧看,文件创建失败返回-1,为什么呢?注意如果按照本文代码一点一点的敲的话,会发现open的话之前会创建一个权限是只读的权限,此时如果你修改了权限但是之前的文件没有删除就会open失败,之前的文件是只读的,你如果要写入就会直接失败没有权限,同时可以看得到一个结论就是O_CREAT只管新文件创建。
删除后恢复正常。
2、文件描述符(fd)的分配规则
同样观察现象,代码和上边的代码一样改改就行这里就不放了,就是那个坑点的代码。
1、如果说close(2)的情况下
可以观察到,close(2)之后就少了一个2->/dev/pts/0,反而说原来的/home/.../myfile却被2指了。
2、再观察一组现象,把close(2)改为close(0)
可以观察到,/home/..../myfile又到了0。
也就是说我close哪个就去哪个,这也就是fd分配的规则就是:找当前未被占用的最小下标
3、剩余知识
1、Linux的三个标准流:stdin(0),stdout(1),stderr(2),分别为标准输入,标准输出,标准错误。
2、每一个进程都会有一个file_struct,里边有一个指针数组fd_array[],下标就是文件描述符fd,指向打开的struct_file。
该部分思考
1、为什么fd从3开始?
答:0、1、2都被标准流占用,自己的文件依据分配规则占用最小下标也就是3.
2、为什么操作系统无法用一个整数直接表示复杂文件信息?
答:fd只是一个索引,真正的文件信息都在struct_file,由内核维护。
四、重定向的本质--dup2与shell增强
1、给出代码观察实验现象引出重定向
int main()
{
close(1);
int fd=open("myfile",O_WRONLY|O_CREAT,0644);
if(fd==-1)
{
perror("open fail");
}
printf("hello");
fflush(stdout);
close(fd);
return 0;
}可以观察到,当关闭stdout时,运行hello可执行程序却没有打印,而这个值却放在了myfile里。修改代码并再次观察实验现象。
int main()
{
pid_t id=getpid();
printf("PID:%d\n",id);
close(1);
int fd=open("myfile",O_WRONLY|O_CREAT,0644);
if(fd==-1)
{
perror("open fail");
}
printf("hello\n");
fflush(stdout);
while(1)
{
sleep(1);
}
close(fd);
return 0;
}根据fd分配规则,我们创建的文件自然会在fd=1的位置出现。fd=1的为标准输出,通过这个索引可以指向对应的文件,标准情况下就是显示器,所以说没有close(1)的时候能在显示器上看到。当close掉1之后,我们创建的文件就会被1索引,也就是说输出从显示器变到了我们创建的文件,此时计算机并不知道这些,依然写,所以数据就被写入到了我们的文件之中,显示器不显示而cat我们的文件可以发现数据在我们的文件之中。这也就是重定向。
2、dup2
格式:int dup2(int oldfd, int newfd);
部分代码:
int fd=open("myfile",O_WRONLY|O_CREAT,0644);
dup2(fd,1);
printf("hello\n");
fflush(stdout);
可以观察到,dup2第一个参数是旧的fd,第二个参数是新的fd。效果和上边的代码一样,但是这里没有close(1);也就是说让newfd指向oldfd的文件表,会自动关闭newfd打开的文件。自然就不用close了。这也就是dup2的一个大好处。
3、知识点详解
1、重定向的底层本质就是修改fd,也就是修改指向,让某个fd指向另外一个文件。
2、dup2(oldfd,newfd),newfd会复制oldfd的副本,即使newfd已经打开,会被覆盖。
该部分思考
1、重定向的本质是改变了什么?
答:改变了进程的fd数组项,使得原本指向终端的fd指向了别的文件。
2、为什么重定向必须在fork后的子进程中做?
答:因为shell是父进程,如果说修改shell的文件描述符,则会修改I/O环境,后续的进程都会受影响,而创建子进程时会复制父进程的文件描述符,也就是所谓的副本,此时修改文件描述符不会影响父进程。
五、一切皆文件--内核视角
1、内核部分
在这里可以发现,file是一个文件对象,其中最重要的就是有一个f_op指针指向struc file_operations(函数操作表),接下来看看这个函数操作表。
这是一部分,也就是说当用户调用read(fd,buf,n)的时候就会通过fd找到这个struct file此时,再通过调用f_op->read去完成相关操作。下边看一张图片去理解部分内容。
通过这一张图片可以发现,当调用read时会去调用不同硬件的read,和write怎么理解呢?
指令:ls -l /dev。这个指令可以打印硬件设备。
所以可以看到,各个硬件会被抽象成统一设备文件,此时各个硬件实现自己的read,write函数,当open设备的时候,内核把struct_file 里的f_op指针指向该驱动自己的file_operations实例。这体现了一个点就是多态。
该部分思考
1、为什么read系统调用既能读取磁盘文件,也能读键盘?
答:因为read通过fd找到对应的struct file,再通过f_op->read回调到不同的驱动函数。
2、Linux下一切接文件的好处是什么?
答:统一的API,简化开发,方便管理。
六、缓冲区之谜--为什么重定向之后printf出现两次
1、缓冲区类型
全缓冲区:这种缓冲方式要求填满整个缓冲区之后才进行I/O系统调用操作,一般情况下对于磁盘文件的操作通常使用全缓冲的方式访问。
行缓冲区:在行缓冲情况下,当在输入和输出中遇到换行符是,标准I/O库函数会执行系统调用操作。
无缓冲区:指的是标准I/O库不对字符进行缓存,直接调用系统调用。
2、代码实验:(简单认识行缓冲和全缓冲)
int main()
{
close(1);
int fd=open("myfile",O_CREAT|O_WRONLY|O_TRUNC,0664);
if(fd<0)
{
perror("open fail");
}
printf("hello world%d:",fd);
close(fd);
return 0;
}此时可以发现文件内是没有信息的,可以cat看看
默认是全缓冲,此时没有存满用户缓冲区是不会去调用系统函数的,然后close就关掉了,如果想要写入就加一个fflush强制刷新即可,此时就是行缓冲。
int main()
{
close(1);
int fd=open("myfile",O_CREAT|O_WRONLY|O_TRUNC,0664);
if(fd<0)
{
perror("open fail");
}
printf("hello world%d:\n",fd);
close(fd);
return 0;
}
3、实验(认识系统调用无缓冲区和库函数调用有缓冲区)
int main()
{
const char* msg0="hello printf\n";
const char *msg1="hello fwrite\n";
const char* msg2="hello write\n";
printf("%s",msg0);
fwrite(msg1,strlen(msg1),1,stdout);
write(1,msg2,strlen(msg2));
fork();
return 0;
}
运行可执行程序可以发现这种情况正常打印,是因为终端是行缓冲,遇到换行符会立即刷新,所以会直接在终端上看见。
但是当重定向到文件后,cat文件可以看到一个现象。
可以看到系统调用的write是只打印了一次,而使用库函数的两个分别打印了两次。
系统调用是无用户缓冲,也就是说会直接打印在终端上,而重定向到普通文件时数据的缓冲方式就变成了全缓冲,也就是说没有存满是不可能刷新的,此时fork创建子进程,子进程会继承父进程的数据,也就是说此时有了两份数据。进程退出之前会强制刷新,所以就看到了两份数据。
同时,如果说在fork之前刷新用户缓冲区,那么将数据刷洗到内核缓冲区,此时用户缓冲区数据清零,子进程继承不到所以重定向只会去打印一份。(代码不重复了)
该部分思考
1、用户缓冲区是哪个模块提供的?
答:C标准库提供的
2、为什么fork会使缓冲区的内容出现两份?
答:因为firk发生了写时拷贝,子进程会拿到父进程缓冲区的副本,退出时都会刷新。
后记
后续会更新文件系统相关内容,觉得写的不错的可以点点赞关注一下。