文章目录
- 1.前言(提出问题)
- 2.认识问题
- 3.回顾c文件接口
- 4.学习系统文件IO
- open函数
- 第一个参数
- 第二个参数
- 第三个参数
- ==函数的返回值==
- write函数
- read函数
- close函数
- 5.文件描述符
1.前言(提出问题)
在C语言阶段学习文件操作的时候,往往会有一些困惑:
1.我真的理解文件的原理和操作吗?不只是语言的问题,而且是在系统层面的问题;2.是不是只有C/C++有文件操作呢?Python,java,php,go…等语言他们的文件操作方法是不是一样的?有没有统一的视角,看待所有语言的文件操作呢?3.操作文件的时候,我们写的第一行代码都是打开文件,打开文件到底底层是在做什么呢?如何理解呢?等等一系列的问题。
这些问题也同样困扰我很久,那么这篇文章主要就是想深入理解一下文件操作。
2.认识问题
我们先从对文件已有的认识开始,认识一下文件:
- 文件=内容+属性,那么针对文件的操作,就转换成了对文件的内容进行操作+对文件的属性进行操作;
- 当文件没有被操作时,文件一般都是待在磁盘的;
- 当我们对文件进行操作时,文件需要在内存里,这是由冯诺依曼体系结构决定的,CPU只能和内存打交道。
- 那么当我们对文件进行操作时,文件肯定需要被提前从磁盘加载到内存,那么加载的是属性还是内容呢?从常识来看,至少也得load文件属性吧。
- 当我们对文件进行操作时,是不是只有一个文件被打开呢?肯定不是啦,内存中肯定存在大量的不同文件的属性。
- 所以综上,打开文件的本质就是将需要的文件属性从磁盘加载到内存中,操作系统OS内部一定会同时存在大量的被打开的文件,那么OS当然就要管理这些文件——先描述,再组织。
- 先描述:每一个被打开的文件,都要在OS内创建对应文件对象的struct结构体,再组织:将所有struct文件结构体用某种数据结构链接起来。。。那么在OS内部,对被打开的文件进行管理,就转变成了对数据结构的增删查改。
- 结论:文件被打开,操作系统要为被打开的文件,创建对应的内核数据结构,里面包括各种属性,各种链接关系等等。
- 那么讲到这里,应该可以知道文件其实可以被分为两大类:磁盘文件,被打开的文件(我们暂且称其为内存文件吧)。
10.那么文件到底是谁在打开呢?我们知道是OS操作系统,但又是谁让OS去打开文件的呢?当然就是用户啦,那么实际上就是进程。所以所有的文件操作,就是进程和被打开文件的关系,struct task_struct和struct file之间的关系。
3.回顾c文件接口
写文件
fputs功能是向指定的文件写入一个字符串。成功写入一个字符串后,文件的位置指针会自动后移,函数返回值为非负整数;否则返回EOF(符号常量,其值为-1)
fwrite() 是 C 语言标准库中的一个文件处理函数,功能是向指定的文件中写入若干数据块,如成功执行则返回实际写入的数据块数目。该函数以二进制形式对文件进行操作,不局限于文本文件。如果成功,该函数返回一个 size_t 对象,表示元素的总数,该对象是一个整型数据类型。如果该数字与 nmemb 参数不同,则会显示一个错误。
fprintf是C/C++中的一个格式化库函数,其作用是格式化输出到一个流文件中; printf的函数声明中没有FILE* stream,因为它默认打印到stdout(显示器文件中)。
snprintf()将可变个参数按照format格式化成字符串。
各个函数原型:
#include <stdio.h>
int fputs(const char *s, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
int fprintf (FILE* stream, const char*format, [argument]);
int snprintf(char* dest_str,size_t size,const char* format,...);
使用方法:
//fputs
#include<stdio.h>
#define LOG "log.txt"//定义写入的文件名
int main()
{
//w:以默认写的方式打开文件,如果文件不存在,就自动创建之
//1.默认如果只是打开,文件内容会被自动清空
//2.同时,每次进入写入的时候,都会从最开始进行写入
FILE* fp = fopen(LOG,"w");
//a:不会清空文件,而是每一次写入都是从文件结尾开始追加的
FILE* fp = fopen(LOG,"a");
if(fp == NULL)
{
perror("fopen error");//perror的功能是打印错误
return 1;
}
//正常进行文件操作
const char* msg = "hello world\n";
int cnt = 5;
while(cnt)
{
//int fputs(const char *s, FILE *stream);
//将hello world\n以文件流的方式写入LOG文件中
fputs(msg,fp);
cnt--;
}
fclose(fp);
return 0;
}
//演示fwrite,printf,fprintf,snprintf函数的使用
#include <stdio.h>
#include <string.h>
int main()
{
FILE *fp = fopen("myfile", "w");
if(!fp){
printf("fopen error!\n");
}
const char *msg = "hello world!\n";
int count = 5;
while(count--){
//都是将hello world\n以文件流的方式写入LOG文件中
//size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream)
fwrite(msg, strlen(msg), 1, fp);
//int fprintf (FILE* stream, const char*format, [argument]);
fprintf(fp,"%s: %d :nan\n",msg,cnt);
fprintf(stdout,"%s: %d :nan\n",msg,cnt);//Linux下一切皆文件,这句代码相当printf,打印到显示器文件
//int snprintf(char* dest_str,size_t size,const char* format,...);
//1.先定义一个缓冲区字符串
char buffer[256];
//2.向buffer中写入字符串,相当于先写入一个字符缓冲区了
snprintf(buffer ,sizeof(buffer), "%s: %d :nan\n",msg,cnt);
//3.然后再将buffer中的数据以文件流的方式写入LOG文件
fputs(buffer,fp);
}
fclose(fp);
return 0;
}
读文件
fgets()函数的作用是用来读取一行数据的。
函数原型:
# include <stdio.h>
//有三个参数。它的功能是从 stream 流中读取 size 个字符存储到字符指针变量 s 所指向的内存空间。
//它的返回值是一个指针,指向字符串中第一个字符的地址。
//stream 表示从何种流中读取,可以是标准输入流 stdin,也可以是文件流.
char *fgets(char *s, int size, FILE *stream);
使用方式:
#include<stdio.h>
#define LOG "log.txt"//定义写入的文件名
int main()
{
FILE* fp = fopen(LOG,"r");
if(fp == NULL)
{
perror("fopen error");//perror的功能是打印错误
return 1;
}
while(1)
{
//1.先定义一个缓冲区
char line[128];
//2.char *fgets(char *s, int size, FILE *stream);
//将文件中的数据读到缓冲区line字符数组中
if(fgets(line, sizeof(line), fp) == NULL)
{
break;
}
else//并打印出来
{
printf("%s",line);
}
}
fclose(fp);
return 0;
}
4.学习系统文件IO
open函数
系统接口中使用open函数打开文件,open函数的函数原型如下:
int open(const char *pathname, int flags, mode_t mode);
第一个参数
表示路径名称,若pathname以路径的方式给出,则当需要创建该文件时,就在pathname路径下进行创建;若pathname以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建。
第二个参数
flags,表示打开文件的方式。
常用的参数选项如下:
注意:O_CREAT需要使用mode选项(函数的第三个参数),来指明新文件的访问权限,不然会导致乱码。
打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
例如:
//创建文件并可以向文件写入,相当于C语言的fopen("文件名","w");
//注意默认不会对文件内容做清空
O_WRONLY | O_CREAT
//加上O_TRUNC,就不会对原始内容做清空了
O_WRONLY | O_CREAT | O_TRUNC
//相等于C语言的fopen("文件名","a");
O_WRONLY | O_CREAT | O_APPEND
扩展:第二个参数本质到底是什么?——是宏定义
这样一来,在open函数内部就可以通过使用“与”运算来判断是否设置了某一选项。
int open(arg1, arg2, arg3){
if (arg2&O_RDONLY){
//设置了O_RDONLY选项
}
if (arg2&O_WRONLY){
//设置了O_WRONLY选项
}
if (arg2&O_RDWR){
//设置了O_RDWR选项
}
if (arg2&O_CREAT){
//设置了O_CREAT选项
}
//...
}
第三个参数
mode,表示创建文件的默认权限。
例如,将mode设置为0666,则文件创建出来的权限如下:-rw-rw-rw-
但实际上创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。
若想创建出来文件的权限值不受umask的影响,则需要在创建文件前使用umask函数将文件默认掩码设置为0:umask(0)
函数的返回值
打开文件成功:open函数的返回值是新打开文件的文件描述符。
打开文件失败:返回-1
write函数
系统接口中使用write函数向文件写入信息,write函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
我们可以使用write函数,将buf位置开始向后count字节的数据写入文件描述符为fd的文件当中。
- 如果数据写入成功,实际写入数据的字节个数被返回。
- 如果数据写入失败,-1被返回。
使用方式:
写文件:下面的代码先将“bbb”写入文件中,然后再追加“a”
1 #include<stdio.h>
2 #include<errno.h>
3 #include<string.h>
4 #include<unistd.h>
5 #include<sys/types.h>
6 #include<sys/stat.h>
7 #include<fcntl.h>
8
9 #define LOG "log.txt"
10
11 //系统方案
12 int main()
13 {
14 //设置权限掩码umask,默认是0002
15 umask(0);
16
17 //O_WRONLY | O_CREAT :默认不会对原始文件做清空
18 //int fd = open(LOG, O_WRONLY | O_CREAT, 0666);
19 //int fd = open(LOG, O_WRONLY | O_CREAT | O_TRUNC, 0666); //加上O_TRUNC在打开文件时就会默认对文件的原始内容做清空,并且从文件开头开始写
20
21 int fd = open(LOG, O_WRONLY | O_CREAT | O_APPEND, 0666);//追加写
22
23 //创建失败返回-1
24 if(fd == -1)
25 {
26 printf("fd: %d, errno: %d, errstring: %s\n",fd, errno,strerror(errno));
27 }
28 else
29 {
30 printf("fd: %d, errno: %d, errstring: %s\n",fd,errno, strerror(errno));
31 }
32
33 //将数据写入文件
34 //const char* msg = "bbb";
35 //追加写"a"
36 const char* msg = "a";
37 int cnt = 5;
38 while(cnt--)
39 {
40 //定义缓冲区
41 char line[128];
42 //将数据格式化写入字符串字符数组中
43 snprintf(line,sizeof(line),"%s, %d\n",msg,cnt);
44
45 write(fd,line,strlen(line));//将缓冲区的数据写入到文件中
46 //这里的strlen不要+1,\0是C语言的规定,不是文件的规定
47 }
48
49 close(fd);
50
51 return 0;
52 }
read函数
系统接口中使用read函数从文件读取信息,read函数的函数原型如下:
ssize_t read(int fd, void *buf, size_t count);
我们可以使用read函数,从文件描述符为fd的文件读取count字节的数据到buf位置当中。
- 如果数据读取成功,实际读取数据的字节个数被返回。
- 如果数据读取失败,-1被返回。
使用方式:
读文件:上面我们已经将数据写入文件中,那么现在我们就把数据从文件中读出来
#include<stdio.h>
#include<errno.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#define LOG "log.txt"
//系统方案
int main()
{
//设置权限掩码umask,默认是0002
umask(0);
int fd = open(LOG, O_RDONLY);
//创建失败返回-1
if(fd == -1)
{
printf("fd: %d, errno: %d, errstring: %s\n",fd, errno,strerror(errno));
}
else
{
printf("fd: %d, errno: %d, errstring: %s\n",fd,errno, strerror(errno));
}
//读文件
//这里我们无法做到按行读取,我们是整体读取的
//做法是将文件中的数据读到缓冲区中,再打印出来
char buffer[1024] = {0};
ssize_t n = read(fd,buffer,sizeof(buffer)-1);//使用系统接口来进行IO的时候,一定要注意\0的问题
if(n>0)
{
buffer[n] = '\0';
printf("%s\n",buffer);
}
close(fd);
return 0;
}
close函数
系统接口中使用close函数关闭文件,close函数的函数原型如下:
int close(int fd);
使用close函数时传入需要关闭文件的文件描述符即可,若关闭文件成功则返回0,若关闭文件失败则返回-1。
5.文件描述符
1.open函数的返回值是文件描述符
由于文件描述符这块知识点比较重要,所以放在这里统一讲解。
前面通过对open函数的学习,我们知道了文件描述符就是一个小整数,是open函数的返回值,我们再来回顾一下open函数的原型:
int open(const char *pathname, int flags, mode_t mode);
将前面的代码(用系统接口进行读写)在Linux下运行后,在Linux上的运行结果都有这么一句话:
fd: 3, errno:0, errstring:Success//运行成功
不管你怎么运行,会发现一个奇怪的点,那就是:文件描述符都是从3开始的。为什么呢?想搞明白的宝子请继续往下看——>
2.Linux下一切皆文件。
请看下列代码:
1 #include<iostream>
2 #include<cstdio>
3
4 int main()
5 {
6 //Linux下一切皆文件
7 //C
8 printf("hello printf->stdout\n");
9 fprintf(stdout,"hello fprintf->stdout\n");
10 fprintf(stderr,"hello fprintf->stderr\n");
11
12 //C++
13 std::cout << "hello cout -> cout" << std::endl;
14 std::cerr << "hello cerr -> cerr" << std::endl;
15 }
运行结果:标准输出和标准错误都会向显示器打印。
ps:我们重定向时只会对标准的输出进行正常的重定向,但是标准错误不受重定向的影响。
那么为什么我们上述代码打印出来的文件描述符是从3开始的呢?
- Linux进程默认情况下会有3个缺省打开的文件描述符,分别是标准输入0, 标准输出1, 标准错误2
- 0,1,2对应的物理设备一般是:键盘,显示器,显示器
所以输入输出还可以采用如下方式:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
char buf[1024];
ssize_t s = read(0, buf, sizeof(buf));
if(s > 0){
buf[s] = 0;
write(1, buf, strlen(buf));
write(2, buf, strlen(buf));
}
return 0;
}
3.文件描述符的本质是数组下标
可以让我们通过进程快速找到对应的打开文件。这个文件一定先被打开过,只有这样它才有对应的文件描述符。
解析:文件存储在磁盘当中,当打开一个文件的时候,OS都会在内核中创建一个struct file
结构体来描述该文件,充当这个打开的文件。struct file结构体中包含着文件的大部分属性,就好像struct task_struct结构体中包含着进程的大部分属性。对文件的增删查改就转换成了对链表的增删查改。只要找到了file,就可以获取所有文件属性和内容。——这就是先描述,再组织的思想。
而文件是用户通过进程(调用系统调用接口)让OS打开的,我们在执行代码的时候,能最直接找到的就是进程的PCB,所以我们要维护进程和被打开文件之间的对应关系,即要维护好进程和被打开文件的映射关系:
一个进程可以打开多个文件,OS为了能让进程快速找到对应的文件,在内核中定义了一个数据结构struct files_struct
,在该结构体中包含着一个数组struct file* fd_array[]
,其中包含的类型都是file*
。当从磁盘加载一个文件时OS就要创建为该文件创建一个对应的对象,然后OS就要在当前执行打开操作的进程的struct files_struct内部的数组中从上到下进行遍历,找到一个没有被使用位置,将新加载的文件的结构体对象的地址填入到数组中,这样就建立了struct files_struct与被打开文件之间的对应关系。同时在进程的结构体当中,又包含了一个struct file_struct *files;
指针,在进程初始化的时候,会为该进程创建struct files_struct对象,并将地址存在struct file_struct *files指针内,这样就建立了进程与被打开文件之间的映射关系。
综上:在应用层返回的文件描述符就是上面说的数组的下标,所以当使用read、write、close这些系统调用接口的时候,必须在参数列表传入文件对应的文件描述符,通过结构体对象的映射关系来找到对应的文件进行操作。
4.对文件缓冲区的理解
接下来,我们对文件的写入、读取操作进行进一步的理解(关于常常涉及的缓冲区):
每个文件都要匹配一个缓冲区,比如当我们使用write(3, buffer, xxx)向缓冲区写入操作时,所做的工作过程是:我们调用了write函数 ——> OS识别到了系统调用 ——> 找到进程对应的pcb ——> 找到file_struct结构体 ——> 在file_struct结构体中找到fd_array[]数组,通过传入的文件描述符找到索引 ——> 找到匹配的文件结构体file,找到后从用户层将对应的数据拷贝到缓冲区。调用结束后返回写了多少字节。而拷贝的数据什么时候由缓冲区刷新到外设磁盘,由OS自主决定。(过程如下如所示)
所以我们的IO类read、write函数本质是拷贝函数!(用户空间与内核空间进行数据的来回拷贝)
5.如何理解Linux下一切皆文件。
计算机有很多的硬件外设,比如键盘、显示器、网卡、磁盘等,并且这些硬件都有与之匹配的驱动程序来实现与硬件的交互。
以键盘为例:读取方法(read_keyboard();)站在内存的视角来看,就是要将数据从外设键盘读取到内存中;而键盘这个外设不需要写入方法(write_keyboard();),因为总不能从内存写入到键盘让键盘自己动,即让键盘驱动中的写入操作只有声明,函数体是空的、什么都不做即可。
与键盘类似的,计算机所有的外设都有IO操作,即驱动都有read与write功能。
再举个例子:显示器设备,在显示器显示数据,就是将数据写入到了显示器外设,所以显示器也有写入方法(write_screen();),而读取方法(read_screen();)则不需要,因为计算机是从键盘读取的数据,只是回显到了显示器上。
所有的硬件设备都是不同的,那么在Linux下一切皆文件是怎么做到的呢,是如何将不同的硬件设备都看做是文件的呢?——当打开一个硬件设备的时候,在OS内部也同样要创建一个struct file结构体对象,来描述这个硬件文件,其中包含了该文件的权限、大小、以及各种其他属性,除此之外还包含两个函数指针int (*readp)(int fd, char buffer, int size);和int (*writep)(int fd, char buffer, int size);,同时这个文件的struct file结构体对象中也有一小块缓冲区。读写的两个函数指针就指向属于自己的硬件驱动中的读写方法,并且每一个硬件外设在被打开时都有一个这样的结构体对象,其中都会将属于自己的读写方法的地址填入自己的函数指针中。
在进程的struct files_struct中的数组中保存着使用的外设文件对应的结构体地址,在进程通过该数组(文件描述符)访问对应的外设时,在进程看来,所有的外设都是struct file文件结构体对象。数据拷贝放入struct file的缓冲区中,在需要进行硬件设备的读写时,只需要通过函数指针调用对应底层驱动中的方法来完成读写即可,根本不用关心底层实现的差异。而OS也有读写操作,它的读写操作本质就是拷贝,只需要将上层应用层的数据拷贝到缓冲区中,然后通过指针调用底层不同的读写方法就可以将数据放到对应的外设中,所以在文件对象struct file以上来看,就实现了Linux下一切皆文件!不用关心底层的差异化。
我们都是通过进程的方式来进行OS的访问的!而进程只能看到文件(struct file),所以才有了一切皆文件。这其实也是设计面向对象的思想(多态特性)。
6.标准输入、输出、错误的返回值类型FILE*的理解
标准输入输出错误、fopen的类型都是FILE*,那么FILE到底是什么?
FILE是一个封装后的结构体,由是C语言提供的。可以进入/usr/include/stdio.h
文件中进行查看:发现其名字是_IO_FILE,重命名为:typedef struct _IO_FILE FILE。
FILE与struct file有关系吗?FILE结构体中封装了什么?
没有关系。并且这个结构体中必定封装了fd(系统调用的文件描述符),因为返回值为FILE*类型的例如fopen、fclose、fwrite、fread,这些C的文件操作接口底层都调用了open、close、write、read这样的系统调用接口。
- 因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访
问的。 - 所以C库当中的FILE结构体内部,必定封装了fd。
如果有兴趣,可以看看FILE结构体:
typedef struct _IO_FILE FILE; 在/usr/include/stdio.h
在/usr/include/libio.h
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno; //封装的文件描述符
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
既然返回值的FILE*是一个指针,我就可以通过指针来查看_fileno(文件描述符):
//头文件略
#define LOG "log.txt"
int main()
{
printf("%d\n",stdin->_fileno);//0
printf("%d\n",stdout->_fileno);//1
printf("%d\n",stderr->_fileno);//2
FILE* fp = fopen(LOG, "w");
printf("%d\n", fp->_fileno);//3
return 0;
}
运行结果于上述的解释一致:0,1,2描述符被stdin、stdout、strerr占用,我们自己打开的文件描述符为3。