我们对文件有如下的认识:
1.文件 = 文件内容 + 文件属性,即文件包括文件的内容和属性两个部分
2.空文件只是文件的内容为空,但是文件的属性不为空,所以空文件也需要占据磁盘的空间
3.由于文件包括内容和属性,那么我们对文件的操作可以分为对文件内容非操作,对文件的属性进行操作以及对文件的内容和属性进行操作
3.Linux/windows中目录都采用多叉树的形式进行表示,即树的中间节点表示目录,树的叶子节点表示文件,所以我们可以使用文件路径+文件名来唯一的标识一个文件
4.在对文件进行访问的时候,如果没有指定文件的路径,那么默认在当前路径下对文件进行访问,当前路径指的是当前进程所在的工作路径
5.在C语言中,当我们把fopen,fclose,fwrite,fread等函数接口的程序编译链接形成可执行程序,如果我们运行该可执行程序,那么对应的函数就不会被调用,则对应的文件操作也就不会被执行,因为函数在运行时才会建立栈帧,所以对文件的操作本质上是进程对文件的操作
6.我们知道,我们要访问一个文件,那么就必须先打开这个文件,而文件存储在磁盘上,由于计算机结构的原因,磁盘上的文件必须通过OS才能和进程进行交互,所以文件打开是由用户进程和OS配合来完成的—用户进程调用文件的接口,OS系统实现这些系统调用接口
7.磁盘上存在许许多多的文件,并不是所有的这些文件都被打开了,所以文件分为被打开的文件和没有被打开的文件,那些没有被打开的文件被成为文件系统
所以,文件操作的本质是进程与被打开文件的关系
在谈论文件操作之前,我们需要了解 语言层面上的文件操作与操作系统层面上的文件操作的关系
我们知道,每一种语言都有其对应的文件操作,包括面向过程语言C,面向对象语言C++/java,静态编译语言go,解释型语言python,甚至包括脚本语言shell等等,但是每一种语言对文件操作提供的接口都不相同,这样就会导致我们的学习成本变得很高。
站在语言的角度我们觉得是这样的,但是站在操作系统的角度就不是这样了 ,我们知道计算机的软硬件体系结构之后,就会知道操作系统为了同时满足 保护自身安全 与 为上层用户提供良好的(稳定的,安全的,高效的)服务,会给用户提供访问软硬件的系统调用接口,同时,为了降低用户使用成本,人们又在系统调用接口的基础上开发了用户操作接口,其中包括shell外壳与各种语言的函数库,而用户就是通过调用用户操作接口类完成指令,开发与管理等操作
也就是说,站在操作系统的角度,虽然每一种语言的文件操作接口都不一样,但是这些接口底层调用的一定是同一种系统调用接口,因为操作系统是计算机管理软硬件资源的软件,进程想要访问文件只能通过调用操作系统提供的系统调用接口,我们使用的fopen,fwrite,fclose等等接口底层也是调用系统调用接口
而系统调用接口只有一套,语言有无数种,每一种又不一样,那么我们学习文件操作只需要学习操作系统提供的系统调用中有关文件操作的接口即可,学习了系统调用就相当于学习了底层,以后我们再学习语言的文件操作时只需要学习一些新的方式即可,但是底层是不变的,这样就会大大降低学习的成本
在学习C语言的文件操作之前,我们先回顾一下C语言的操作函数
C语言文件操作接口
函数名 | 函数功能 |
---|---|
fopen | 打开指定文件 |
fclose | 关闭指定文件 |
fwrite | 以二进制的形式向文件中写入数据 |
fread | 以二进制的形式从文件中读取数据 |
fscanf | 把文件中的数据格式化的读取到内存中 |
fprintf | 把内存中的数据格式化的写入到文件中 |
总结:
fopen
:用于打开文件,并返回一个文件指针,可以指定不同的打开模式(如只读、只写、追加等)和文件类型(文本或二进制)。配合其他文件操作函数使用,如fprintf
、fscanf
等。
fclose
:用于关闭文件,关闭后文件指针指向的文件将不可访问。
fread
:从文件中读取数据到指定的内存缓冲区中,可以指定要读取的数据块的大小和数量。
fwrite
:将指定大小的数据块从内存缓冲区写入文件中,可以指定要写入的数据块的大小和数量。
fseek
:设置文件指针的位置,用于定位读写位置。可以通过设置相对于文件开头、文件末尾或当前位置的偏移量来移动文件指针。
ftell
:获取文件指针的当前位置,返回当前位置的偏移量。
rewind
:将文件指针重置到文件的起始位置,相当于调用fseek(file, 0, SEEK_SET)
。
feof
:检查文件指针是否已到达文件末尾,返回非零值表示到达文件末尾。
fgets
:从文件中读取一行数据到指定的字符数组中。
fprintf
:将格式化的数据写入文件中。
需要注意的是,在使用文件操作接口时,应当检查返回值以确保操作是否成功,并且在不需要使用文件时要及时关闭文件以释放资源。
C语言文件打开的几种方式
文件打开方式 | 含义 | 如果指定文件不存在 |
---|---|---|
“r”(只读) | 为了输入数据,打开一个已经存在的文本文件 | 出错 |
“w”(只写) | 为了输出数据,打开一个文本文件 | 建立一个新的文件 |
“a”(追加) | 向文本文件尾部添加数据 | 建立一个新的文件 |
“rb”(只读) | 为了输入数据,打开一个二进制文件 | 出错 |
“wb”(只写) | 为了输出数据,打开一个二进制文件 | 建立一个新的文件 |
“ab”(追加) | 向一个二进制文件尾部添加数据 | 出错 |
“r+”(读写) | 为了读和写,打开一个文件文件 | 出错 |
“w+”(读写) | 为了读和写,建立一个新的文件 | 建立一个新的文件 |
“a+”(读写) | 打开一个文件,在文件的尾部进行读写 | 建立一个新的文件 |
总结:
r
(只读):以只读方式打开文件。文件必须存在,否则打开失败。w
(只写):以只写方式打开文件。如果文件存在,则文件内容会被截断为空;如果文件不存在,则会创建新文件。a
(追加写):以追加写入方式打开文件。如果文件存在,则新的数据会追加到文件末尾;如果文件不存在,则会创建新文件。r+
(读写):以读写方式打开文件。文件必须存在,允许读取和写入文件内容。w+
(读写,创建新文件):以读写方式打开文件。如果文件存在,则文件内容会被截断为空;如果文件不存在,则会创建新文件。a+
(追加读写):以追加读写方式打开文件。如果文件存在,则新的数据会追加到文件末尾;如果文件不存在,则会创建新文件。允许读取和写入文件内容。需要注意的是,以上文件打开模式只适用于文本文件。对于二进制文件,可以在以上模式后添加b
来表示二进制模式(例如rb
、wb
等)。
此外,还有一些其他的文件打开模式,如rb+
、wb+
等。这些模式在于读取和写入的组合方式,具体的操作方式和特性与上述模式类似,但会有一些细微的区别。
在选择文件打开模式时,需要根据具体的需求和操作来确定使用哪种模式。例如,如果需要只读取文件内容,则使用r
模式;如果需要追加写入内容,则使用a
或a+
模式;如果需要同时读取和写入文件内容,则使用r+
、w+
或a+
模式,具体选择根据是否需要创建新文件或截断文件内容来决定。
C语言文件操作的例子
1.向文件中写入数据
#include
#define FILE_NAME "log.txt"
int main()
{
FILE *fp = fopen(FILE_NAME, "w");
if (NULL == fp)
{
perror("fopen failed");
return 1;
}
int cnt = 5;
while (cnt)
{
fprintf(fp,"%s:%d\n","hello world",cnt--);
}
fclose(fp);
return 0;
}
2.从文件中读取数据
#include
#include
#define FILE_NAME "log.txt"
int main()
{
FILE *fp = fopen(FILE_NAME, "r");
if (NULL == fp)
{
perror("fopen failed");
return 1;
}
char buffer[64];
while(fgets(buffer,sizeof (buffer)-1,fp)!=NULL)
{
buffer[strlen(buffer) - 1] = 0;
puts(buffer);
}
fclose(fp);
return 0;
}
注意:
1.r(只读),w(只写), r+(读写,不存在出错),w+(读写, 不存在创建), a(append, 追加), a+()
2.以w方式单纯的打开文件,无论是否写入数据,c语言会自动清空内部的数据
3.通过fwrite创建出来的文件log.txt,其权限是664,这是由于普通文件的默认权限为0666,linux默认的umask为0002,而文件的最终权限等于默认权限 & ~umask,所以log.txt的权限为0664
C语言常通过一个整形来传递选项,但是当选项较多时,每一个选项都用一个整形来进行传递,那么函数的参数就会很多,这时就提出了使用一个比特位来传递一个选项,这样一个整形有32个比特位,就可以传递32种选项,多个传递时使用 | 运算即可,具体案例如下:
#include
// 每一个宏只占用一个比特位,该比特位为1说明该选项成立,且各个宏的位置不重叠
#define OPTION_ONE (1 << 0)
#define OPTION_TWO (1 << 1)
#define OPTION_TREE (1 << 2)
#define OPTION_FOUR (1 << 3)
void show(int flags)
{
// flags与上面哪个选项匹配,就执行对应的操作
// 按位与的结果为1,说明flags对应的比特位为1
if (flags & OPTION_ONE)
printf("OPTION_ONE\n");
if (flags & OPTION_TWO)
printf("OPTION_TWO\n");
if (flags & OPTION_TREE)
printf("OPTION_TREE\n");
if (flags & OPTION_FOUR)
printf("OPTION_FOUR\n");
}
int main()
{
// 主函数中通过传递不同的选项来达到不同的效果
show(OPTION_ONE);
printf("-----------------------\n");
show(OPTION_TWO);
printf("-----------------------\n");
show(OPTION_ONE | OPTION_TWO);
printf("-----------------------\n");
show(OPTION_ONE | OPTION_TWO | OPTION_TREE);
printf("-----------------------\n");
show(OPTION_ONE | OPTION_TWO | OPTION_TREE | OPTION_FOUR);
printf("-----------------------\n");
return 0;
}
如上,我们将宏与比特位对应,然后在show函数中编写每一个宏对应的功能,之后我们就可以在其他函数中通过调用show函数并传递对应的选项来达到我们想要的结果,并且我们可以通过按位或来实现同时传递几个选项
open & close
函数功能
open:打开或创建一个文件,
close:关闭一个文件
函数参数
int open(const char* pathname, int flags);
int open(const cahr *pathname, int flags, mode_t mode);
# 头文件:<sys/types.h> <sys/stat.h> <fcntl.h>
# pathname: 文件路径/文件名
# flags: 打开文件时,可以传入多个参数选项,用一个或者多个宏常量进行“或”运算,构成flags(比特位传递选项)
参数:
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR : 读,写打开
这三个常量,必须指定一个且只能指定一个
O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
O_APPEND: 追加写
# mode: 指定创建新文件时文件的默认权限(文件最终权限还要受umask的影响)
# 函数返回值:int:文件打开或创建成功返回文件对应的文件描述符(整形),失败返回-1;
int close(int fd);
# 头文件:<unistd.h>
# fd:目标文件对应的文件描述符
# int:函数返回值,关闭成功返回0,关闭失败返回-1;
文件打开方式 | -含义 | 如果指定文件不存在 |
---|---|---|
O_RDONLY | 以只读形式打开 | 出错 |
O_WRONLY | 以只写形式打开 | 出错 |
O_RDWR | 以读写形式打开 | 出错 |
O_APPEND | 向文本文件尾添加数据 | 出错 |
O_CREAT | 如果文件不存在,创建新文件 | 建立一个新的文件 |
O_TRUNC | 打开文件时清空文件中之前的数据 | 出错 |
上述这些宏表示不同的文件打开方式,其底层原理和我们上面讲的 通过比特位传递选项 是一样的,我们可以在调用 open 函数时传递一个或多个宏,来实现不同的文件打开方式。
同时,我们可以通过文件操作的系统调用接口和封装后的C语言文件操作接口还是存在很多细节上的不同的,如下:
C语言以 “w” 的方式打开文件,若文件不存在会自动创建一个新文件,而系统调用目标文件不存在直接报错,除非指定了 O_CREAT 选项;
C语言以 “w” 方式打开文件时会自动清空之前文件中的数据,而系统调用则是逐个字符进行覆盖,并不会提前清空文件中的数据,如果要清空必须指定 O_TRUNC 选项;
需要注意的是,O_CREAT 是一个建议性选项,即当文件存在时我们传递此选项也不会报错;同时,文件不存在创建文件时需要传递 mode 选项来指定新文件的访问权限;
上面这些细节的不同也从侧面印证了C语言文件操作接口是对系统调用接口的封装 – “w” 选项会自动清空旧数据、创建新文件,又比如创建新文件时C语言不用手动传递 mode 选项指定权限等等,这些细节都隐藏在了函数的具体实现中。
write 与 read
函数功能
write:向文件中写数据; read:从文件中读数据;
函数参数
ssize_t write(int fd, const void* buf, size_t count);
# 头文件:<unistd.h>
# fd:目标文件的文件描述符
# buf:要写入数据的来源
# count:要写入数据的字节数
# ssize_t:函数返回值,写入成功返回成功写入的字节数,写入失败返回-1
ssize_t read(int fd, void* buf, size_t count);
# 头文件:<unistd.h>
# fd:目标文件的文件描述符
# buf:读取数据存放的位置
# count:要读取数据的字节数
# ssize_t:函数返回值,读取成功返回读取写入的字节数,读到文件末尾返回0,读取失败返回-1
操作系统系统调用文件相关接口的使用和C语言文件操作接口的使用总体上是差不多的,只是一些细节上有所不同。
1.向文件中写数据
#include
#include
#include
#include
#include
#define FILE_NAME "log.txt"
int main()
{
// 创建文件并以只写形式打开,并指定文件的默认权限为0666(还受umask的影响)
// 同时,我们可以通过umask接口手动设置当前进程的文件掩码,而不使用从父进程继承过来的umask
umask(0000);
int fd = open(FILE_NAME, O_WRONLY | O_CREAT, 0666);
if (fd < 0)
{
perror("open failed");
return 1;
}
int cnt = 5;
char buffer[64];
while (cnt)
{
sprintf(buffer, "%s:%d\n", "hello world", cnt--);
// 注意:这里strlen求得的长度不用加1,因为字符串以'\0'结尾只是C语言的特性,而文件中并不这样规定
write(fd, buffer, strlen(buffer));
}
close(fd);
return 0;
}
注意:
1.建 文件时我们通过 umask 系统调用将 umask 设置为了 0000(第一个0代表八进制),然后将 mode 设置为 0666,所以 文件的最终权限为 默认权限 & ~umask – 0666 & ~0000 = 0666;
2.向文件中写入数据时如果不指定 O_TRUNC 选项,新数据就会逐字节覆盖原数据,所以有时候就会出现下面只覆盖了一部分原数据的情况:
3.C语言中字符串以 ‘\0’ 结尾,但是文件中字符串并不以 ‘\0’ 结尾,所以我们向文件中写入字符串时,count 设置为 strlen(str) 就行,不用把最后面的 ‘\0’ 字符加上,如果加上了就会出现部分乱码:
2.从文件中读数据
#include
#include
#include
#include
#define FILE_NAME "log.txt"
int main()
{
// 创建文件并以只写形式打开,并指定文件的默认权限为0666(还受umask的影响)
// 同时,我们可以通过umask接口手动设置当前进程的文件掩码,而不使用从父进程继承过来的umask
umask(0000);
int fd = open(FILE_NAME, RD_ONLY, 0666);
if (fd < 0)
{
perror("open failed");
return 1;
}
char buffer[1024];
ssize_t num = read(fd, buffer, sizeof(buffer) - 1);
if (num > 0)
buffer[num] = 0; // 0, '\0', NULL -> 0
printf("%s", buffer);
close(fd);
return 0;
}
由于C语言字符串以 ‘\0’ 结尾,而文件中的字符串数据并不包含 ‘\0’,所以这里我们需要预留一个位置,便于在数据量大于等于1024字节这种极端情况下 buffer中仍有空间来放置 ‘\0’。
我们知道,文件操作本质上是进程与被打开文件之间的关系,同时,一个进程可以打开多个文件,且操作系统同时运行着许多个进程;那么操作系统中就一定存在着大量被打开的文件,那这些被打开的文件要不要被操作系统管理起来呢?答案是肯定的。
如何管理呢? 答案是先描述,再组织,即将文件的所有属性都总结到一个结构体中,并为每一个文件都创建一个结构体对象,再用一种数据结构将这些结构体对象组织起来,这样对众多被打开文件的管理就变成了对某一种数据结构的增删查改;Linux 中用于管理文件的内核数据结构叫做 struct file {} 结构体,其中包含了文件的大部分属性。
进程如何知道哪些被打开文件属于它呢?如图:
进程的 task_struct 里面有一个 struct files_struct *files 指针变量,它指向一个属于该进程的数据结构对象 struct files_struct,该对象里面包含了一个指针数组 struct file* fd_array[],即进程的文件描述符表,数组里面的每个元素都是指针,指向一个 struct file 对象,而这个数组的下标就是我们用户得到的文件描述符 fd。
也就是说,进程可以通过进程控制块中的 files 变量找到 files_struct 结构体,再通过 files_struct 中的文件描述符表具体下标中保存的地址找到具体文件的内核数据结构 file,从而实现数据的读取与写入。
总结:现在知道,文件描述符就是从0开始的小整数,当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件,于是就有了 file 结构体,表示一个已经打开的文件对象。而进程执行 open 系统调用,所以必须让进程和文件关联起来,于是每个进程都有一个 *files 指针,指向一张表 files_struct,该表最重要的部分就是包含一个指针数组,数组中每个元素都是一个指向打开文件的指针。所以,本质上,文件描述符就是该数组的下标,因此,只要拿着文件描述符,就可以找到对应的文件。
所以,文件描述符是从0开始的小整数,其本质是文件描述符表中的数组下标。
我们知道了文件描述符是什么,那么文件描述符是如何进行分配的呢?
#include
#include
#include
#include
#include
#define FILE_NAME(number) "log.txt"#number
int main()
{
int fd1 = open(FILE_NAME(1),O_WRONLY | O_CREAT | O_TRUNC,0666);
int fd2 = open(FILE_NAME(2),O_WRONLY | O_CREAT | O_TRUNC,0666);
int fd3 = open(FILE_NAME(3),O_WRONLY | O_CREAT | O_TRUNC,0666);
int fd4 = open(FILE_NAME(4),O_WRONLY | O_CREAT | O_TRUNC,0666);
int fd5 = open(FILE_NAME(5),O_WRONLY | O_CREAT | O_TRUNC,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);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
close(fd5);
return 0;
}
注:C语言 # 在宏当中的作用 – 将参数插入到字符串中。
从运行结果可以看到,文件描述符是连续分配且依次增大的,这也很合理,因为文件描述符本质上是数组下标,而连续增长正好是数组下标的特性;但是这里有一个很奇怪的地方 – 文件描述符是从3开始的,那么0、1、2号下标呢?这是由三个默认打开的标准流引起的。
标准输入、标准输出与标准错误流
我们在运行一个程序的时候,操作系统会自动为我们打开三个流 – 标准输入流 stdin、标准输出流 stdout、标准错误流 stderr,它们分别对应键盘文件、显示器文件与显示器文件,其文件描述符分别是 0号、1号和2号,所以我们打开其他文件时 fd 默认是从3号开始分配的。即Linux进程默认情况下会有3个缺省打开的文件描述符,分别是标准输入0, 标准输出1, 标准错误2.0,1,2对应的物理设备一般是:键盘,显示器,显示器
#include
int main() {
printf("stdin->fd:%d\n", stdin->_fileno);
printf("stdout->fd:%d\n", stdout->_fileno);
printf("stderr->fd:%d\n", stderr->_fileno);
return 0;
}
注:Linux 系统调用 open 接口的返回值是文件描述符 fd,而C语言 fopen 接口的返回值是 FILE*,其中 FILE 是一个结构体类型;我们知道,fopen 底层调用的是 open 接口,而 fopen 又不使用 fd 作为函数返回值,那么 FILE 结构体里面就一定会封装一个变量来表示 fd;gcc 中这个变量是 _fileno;
既然系统默认打开三个文件,那么我们可不可以将其关闭呢?当然可以:
#include
#include
#include
#include
#include
#define FILE_NAME(number) "log.txt"#number
int main()
{
close(0);
close(2);
int fd1 = open(FILE_NAME(1),O_WRONLY | O_CREAT | O_TRUNC,0666);
int fd2 = open(FILE_NAME(2),O_WRONLY | O_CREAT | O_TRUNC,0666);
int fd3 = open(FILE_NAME(3),O_WRONLY | O_CREAT | O_TRUNC,0666);
int fd4 = open(FILE_NAME(4),O_WRONLY | O_CREAT | O_TRUNC,0666);
int fd5 = open(FILE_NAME(5),O_WRONLY | O_CREAT | O_TRUNC,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);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
close(fd5);
return 0;
}
可以看到,当0号和2号文件描述符被关闭以后,系统将其分配给了新打开的文件 log.txt1 和 log.txt2。
注:close 关闭文件并不是将 fd 指向的 file 对象释放掉,而仅仅是让当前进程文件描述符表中的对应下标不再指向该 file 对象,因为同一个文件可能会被多个进程访问,特别是父子进程。
(其底层可以采用 f_count **引用计数 **的方式来实现,即当有指向该文件的进程关闭时文件计数减1,有指向该文件的进程打开时文件计数加1,当 f_count 为 0 时操作系统才释放该文件的内核数据结构,即真正意义上的关闭文件)
所以,文件描述符的分配规则是:从小到大依次搜寻,寻找未被使用的最小 fd 作为新打开文件的 fd,即在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符