文章目录
- 一、文件相关基础知识
- 二、文件操作
- 1、语言层面的文件操作与操作系统层面的文件操作的关系
- 2、C语言文件操作
- 3、操作系统文件操作
- 3.1 比特位传递选项
- 3.2 文件相关系统调用
- 3.3 文件操作接口的使用
- 三、文件描述符
- 1、什么是文件描述符
- 2、文件描述符的分配规则
- 四、重定向
- 1、什么是重定向
- 2、dup2 系统调用
- 3、三种重定向的实现
- 五、Linux 下一切皆文件
- 六、缓冲区
- 1、为什么要有缓冲区
- 2、缓冲区的刷新策略
- 3、缓冲区的位置
- 4、实现一个简易的C语言缓冲区
- 5、内核缓冲区
一、文件相关基础知识
在学习文件操作之前,我们需要先学习文件相关的一些基础知识,如下:
- 文件由文件内容和文件属性两部分构成;
- 空文件只是文件内容为空,而文件属性并不为空,所以空文件也要占用磁盘空间;
- 由于文件由内容和属性构成,所以文件操作分为对文件内容进行操作、对文件属性进行操作以及对文件内容+属性进行操作;
- Linux/windows 中目录都采用多叉树的形式表示,即树的中间节点表示目录,树的叶节点表示文件;所以我们使用文件路径+文件名来唯一标识一个文件;
- 在进行文件访问时,如果没有指明文件路径,则默认在当前路径下进行文件访问,而当前路径是指当前进程的工作路径;
- 在C语言中,当我们对包含 fopen、fclose、fwrite、fread 等函数接口的程序进行编译链接形成可执行程序之后,如果我们不运行该可执行程序,那么对应的函数就不会被调用,则对应的文件操作也不会被执行,因为函数在运行时才会建立栈帧;所以对文件的操作本质上是进程对文件的操作;
- 同时,一个文件要被访问首先需要先被打开,而文件存储在外设 – 磁盘上,由于计算机体系结构的原因,磁盘上的文件必须通过OS才能和进程进行交互,所以文件的打开是由用户进程和OS配合完成的 – 用户进程调用文件接口,OS实现这些系统调用接口;
- 最后,磁盘上存在许许多多的文件,并不是所有的这些文件都被打开了,那些没有被打开的文件被称为文件系统;今天我们只研究这些被打开的文件。
所以,文件操作本质上是 进程 与 被打开文件 之间的关系!
二、文件操作
1、语言层面的文件操作与操作系统层面的文件操作的关系
当我们涉猎的范围越来越广之后我们会发现,每一种语言都有其对应的文件操作,包括面向过程语言C、面向对象语言C++/java、静态编译语言go、解释型语言python,甚至包括脚本语言shell 等等,最令人苦恼的是这些语言的文件操作接口都不相同,导致我们的学习成本非常高。
站在语言层面上看确实是这样的,但是 站在操作系统的层面看 就不一样了,我们前面学习了 计算机的软硬件体系结构,知道了操作系统为了同时满足 保护自身安全 与 为上层用户提供服务 两个方面,会给用户提供访问软硬件的 系统调用接口;同时,为了降低使用成本,人们又在系统调用接口的基础上开发了 用户操作接口,其包括 shell 外壳与各种语言的函数库,而用户就通过调用用户操作接口来完成指令、开发、管理等操作。
也就是说,站在操作系统的角度看,虽然每种语言的文件操作接口都不一样,但是这些接口底层调用的一定是同一种系统调用接口,因为操作系统是计算机管理软硬件资源的软件,进程想要访问文件只能通过调用操作系统提供的系统调用接口,我们使用的 fopen、fclose 等接口其底层也是调用的系统调用接口;
而系统调用接口只有一套,所以我们学习文件操作只需要学习操作系统系统调用有关文件操作方面的接口即可,学会了系统调用就相当于学会了底层,以后我们再学习语言的文件操作时只需要学习一些新的特性即可,总体层面上是不变的,大大降低了学习成本。
2、C语言文件操作
在学习操作系统文件操作之前,我们先来回顾一下C语言的文件操作。
C语言文件操作接口
-函数名 | -函数功能 |
---|---|
fopen | 打开指定文件 |
fclose | 关闭指定文件 |
fwrite | 以二进制的形式向文件中写入数据 |
fread | 以二进制的形式从文件中读取数据 |
fscanf | 把文件中的数据格式化的读取到内存中 |
fprintf | 把内存中的数据格式化的写入到文件中 |
C语言文件打开的几种方式
-文件打开方式 | -含义 | 如果指定文件不存在 |
---|---|---|
“r”(只读) | 为了输入数据,打开一个已经存在的文本文件 | 出错 |
“w”(只写) | 为了输出数据,打开一个文本文件 | 建立一个新的文件 |
“a”(追加) | 向文本文件尾添加数据 | 建立一个新的文件 |
“rb”(只读) | 为了输入数据,打开一个二进制文件 | 出错 |
“wb”(只写) | 为了输出数据,打开一个二进制文件 | 建立一个新的文件 |
“ab”(追加) | 向一个二进制文件尾添加数据 | 出错 |
“r+”(读写) | 为了读和写,打开一个文本文件 | 出错 |
“w+”(读写) | 为了读和写,建议一个新的文件 | 建立一个新的文件 |
“a+”(读写) | 打开一个文件,在文件尾进行读写 | 建立一个新的文件 |
C语言文件操作的例子
向文件中写数据:write.c
#include <stdio.h>
#define FILE_NAME "log.txt"
int main() {
FILE* fp = fopen(FILE_NAME, "w");
if(fp == NULL) {
perror("fopen");
return 1;
}
int cnt = 5;
while(cnt) {
fprintf(fp, "%s : %d\n", "hello file_operation", cnt--);
}
fclose(fp);
return 0;
}
从文件中读数据:read.c
#include <stdio.h>
#define FILE_NAME "log.txt"
int main() {
FILE* fp = fopen(FILE_NAME, "r");
if(fp == NULL) {
perror("fopen");
return 1;
}
char buffer[64];
while(fgets(buffer, sizeof(buffer) - 1, fp) != NULL) {
fprintf(stdout, "%s", buffer);
}
fclose(fp);
return 0;
}
这里有几个细节:
1、C语言以 “w” 方式打开文件时,无论是否写入新数据,都会清空之前文件中的数据;
2、通过 fwrite 创建出的文件 log.txt,其权限是664,这是由于普通文件的默认权限为0666,Linux默认的 umask 为0002,而文件最终权限等于默认权限 & ~umask,所以 log.txt 的权限为 0664;
3、操作系统文件操作
3.1 比特位传递选项
C语言常通过一个整形来传递选项,但是当选项较多时,每一个选项都用一个整形太浪费空间,所以有人想出了办法 – 使用一个比特位来传递一个选项,这样一个整形就可以传递32种选项,大大节省了空间,具体案例如下:
#include <stdio.h>
//每一个宏只占用一个比特位,该比特位为1说明该选项成立,且各个宏的位置不重叠
#define OPTION_1 (1<<0)
#define OPTION_2 (1<<1)
#define OPTION_3 (1<<2)
#define OPTION_4 (1<<3)
void Func(int flags) {
//flags与上面哪个选项匹配,就执行对应的操作
//按位与的结果为1,说明flags对应比特位为1
if(flags & OPTION_1) printf("OPTION_1"); //...
if(flags & OPTION_2) printf("OPTION_2"); //...
if(flags & OPTION_3) printf("OPTION_3"); //...
if(flags & OPTION_4) printf("OPTION_4"); //...
}
int main() {
//主函数中通过传递不同选项来达到不同效果
Func(OPTION_1);
printf("\n----------------------\n");
Func(OPTION_2);
printf("\n----------------------\n");
Func(OPTION_3 | OPTION_4);
printf("\n----------------------\n");
return 0;
}
如上,我们将宏与比特位对应,然后在 Func 函数中编写每一个宏对应的功能,之后我们就可以在其他函数中通过调用 Func 函数并传递对应的选项来达到我们想要的效果,并且我们可以通过按位或来实现同时传递几个选项。
3.2 文件相关系统调用
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(比特位传递选项)
# 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
3.3 文件操作接口的使用
操作系统系统调用文件相关接口的使用和C语言文件操作接口的使用总体上是差不多的,只是一些细节上有所不同。
向文件中写数据 – write.c:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#define FILE_NAME1 "file1.txt" //已存在
#define FILE_NAME2 "file2.txt" //不存在
int main() {
//以只写形式打开,并清空文件中之前的数据
int fd1 = open(FILE_NAME1, O_WRONLY | O_TRUNC);
//创建文件并以只写形式打开,并指定文件的默认权限为0666(还受umask的影响)
//同时,我们可以通过umask接口手动设置当前进程的文件掩码,而不使用从父进程继承过来的umask
umask(0000);
int fd2 = open(FILE_NAME2, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd1 == -1 || fd2 == -1) {
perror("open");
return 1;
}
const char* buf1 = "hello file1\n";
const char* buf2 = "hello file2\n";
int cnt = 5;
while(cnt--) {
//注意:这里strlen求得的长度不用加1,因为字符串以'\0'结尾只是C语言的特性,而文件中并不这样规定
write(fd1, buf1, strlen(buf1));
write(fd2, buf2, strlen(buf2));
}
close(fd1);
close(fd2);
}
文件掩码设置系统调用:
上述文件操作中有几个细节:
1、向文件中写入数据时如果不指定 O_TRUNC 选项,新数据就会逐字节覆盖原数据,所以有时候就会出现下面只覆盖了一部分原数据的情况:(先写入五行 hello world,再写入五行 hello)
2、创建 file2 时我们通过 umask 系统调用将 umask 设置为了 0000(第一个0代表八进制),然后将 mode 设置为 0666,所以 file2 的最终权限为 默认权限 & ~umask – 0666 & ~0000 = 0666;
3、C语言中字符串以 ‘\0’ 结尾,但是文件中字符串并不以 ‘\0’ 结尾,所以我们向文件中写入字符串时,count 设置为 strlen(str) 就行,不用把最后面的 ‘\0’ 字符加上,如果加上了就会出现部分乱码:
从文件中读数据 – read.c:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <unistd.h>
#define FILE_NAME "file1.txt"
int main() {
int fd = open(FILE_NAME, O_RDONLY);
if(fd == -1) {
perror("open");
return 1;
}
char buf[1024];
//C语言字符串以'\0'结尾,所以留一个位置来放置
int ret = read(fd, buf, sizeof(buf) - 1);
//read读到文件末尾返回0
while(ret != 0) {
buf[ret] = '\0';
printf("%s", buf);
ret = read(fd, buf, sizeof(buf) - 1);
}
close(fd);
}
这里也有一个细节:由于C语言字符串以 ‘\0’ 结尾,而文件中的字符串数据并不包含 ‘\0’,所以这里我们需要预留一个位置,便于在数据量大于等于1024字节这种极端情况下 buf 中仍有空间来放置 ‘\0’。
三、文件描述符
1、什么是文件描述符
文件管理:
我们知道,文件操作本质上是进程与被打开文件之间的关系,同时,一个进程可以打开多个文件,且操作系统同时运行着许多个进程;那么操作系统中就一定存在着大量被打开的文件,那这些被打开的文件要不要被操作系统管理起来呢?答案是肯定的。
如何管理呢?和 计算机软硬件体系结构 中总结的一样 – 再描述,在组织,即将文件的所有属性都总结到一个结构体中,并为每一个文件都创建一个结构体对象,再用一种数据结构将这些结构体对象组织起来,这样对众多被打开文件的管理就变成了对某一种数据结构的增删查改;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,从而实现数据的读取与写入。
总结:
当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件,于是就有了 file 结构体,表示一个已经打开的文件对象。
而进程执行 open 系统调用,所以必须让进程和文件关联起来,于是每个进程都有一个 *files 指针,指向一张表 files_struct,该表最重要的部分就是包含一个指针数组,数组中每个元素都是一个指向打开文件的指针。
因此,只要拿着文件描述符,就可以找到对应的文件。
所以,文件描述符是从0开始的小整数,其本质是文件描述符表中的数组下标。
2、文件描述符的分配规则
现在我们知道了文件描述符是什么,那么文件描述符是如何进行分配的呢?我们通过具体例子来探索:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <unistd.h>
#define FILE_NAME(number) "log.txt"#number
int main() {
umask(0);
int fd1 = open(FILE_NAME(1), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd2 = open(FILE_NAME(2), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd3 = open(FILE_NAME(3), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd4 = open(FILE_NAME(4), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd5 = open(FILE_NAME(5), O_WRONLY | O_CREAT | O_APPEND, 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);
}
注:C语言 # 在宏当中的作用 – 将参数插入到字符串中。
从运行结果可以看到,文件描述符是连续分配且依次增大的,这也很合理,因为文件描述符本质上是数组下标,而连续增长正好是数组下标的特性;但是这里有一个很奇怪的地方 – 文件描述符是从3开始的,那么0、1、2号下标呢?这是由三个默认打开的标准流引起的。
标准输入、标准输出与标准错误流
我们在运行一个程序的时候,操作系统会自动为我们打开三个流 – 标准输入流 stdin、标准输出流 stdout、标准错误流 stderr,它们分别对应键盘文件、显示器文件与显示器文件,其文件描述符分别是 0号、1号和2号,所以我们打开其他文件时 fd 默认是从3号开始分配的。
#include <stdio.h>
int main() {
printf("stdin->fd:%d\n", stdin->_fileno);
printf("stdout->fd:%d\n", stdout->_fileno);
printf("stderr->fd:%d\n", stderr->_fileno);
}
注:Linux 系统调用 open 接口的返回值是文件描述符 fd,而C语言 fopen 接口的返回值是 FILE*,其中 FILE 是一个结构体类型;我们知道,fopen 底层调用的是 open 接口,而 fopen 又不使用 fd 作为函数返回值,那么 FILE 结构体里面就一定会封装一个变量来表示 fd;gcc 中这个变量是 _fileno;
既然系统默认打开三个文件,那么我们可不可以将其关闭呢?当然可以:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <unistd.h>
#define FILE_NAME(number) "log.txt"#number
int main() {
close(0);
close(2);
umask(0);
int fd1 = open(FILE_NAME(1), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd2 = open(FILE_NAME(2), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd3 = open(FILE_NAME(3), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd4 = open(FILE_NAME(4), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd5 = open(FILE_NAME(5), O_WRONLY | O_CREAT | O_APPEND, 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);
}
可以看到,当0号和2号文件描述符被关闭以后,系统将其分配给了新打开的文件 log.txt1 和 log.txt2。
注:close 关闭文件并不是将 fd 指向的 file 对象释放掉,而仅仅是让当前进程文件描述符表中的对应下标不再指向该 file 对象,因为同一个文件可能会被多个进程访问,特别是父子进程。
(其底层可以采用 f_count **引用计数 **的方式来实现,即当有指向该文件的进程关闭时文件计数减1,有指向该文件的进程打开时文件计数加1,当 f_count 为 0 时操作系统才释放该文件的内核数据结构,即真正意义上的关闭文件)
所以,文件描述符的分配规则是:从小到大依次搜寻,寻找未被使用的最小 fd 作为新打开文件的 fd。
四、重定向
1、什么是重定向
我们上面在演示 fd 分配规则的时候,关闭了标准输入和标准错误,那么如果我们关闭标准输出呢?如下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#define FILE_NAME "log.txt"
int main() {
close(1); //关闭标准输出
int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd == -1) {
perror("open");
return 1;
}
printf("fd:%d\n", fd);
fprintf(stdout, "fd:%d\n", fd);
fflush(stdout);
close(fd);
return 0;
}
可以看到,本来应该打印到显示器上的数据却最终输出到了 log.txt 文件中,原因如下:
我们在调用 open 打开 log.txt 之前关闭了标准输出,那么其对应的1号 fd 就闲置了出来,而 fd 的分配规则是从小到大依次寻找未被使用的最小值,所以 log.txt 对应的 fd 就为1;
同时,我们调用的 printf、fprintf 是C语言封装的输出函数,其底层调用系统调用,效果是向标准输出文件中写入数据,而标准输出 fd 默认为 1,但是 printf 与 fprintf 并不知道 1 号 fd 指向了另一个文件 log.txt,所以原本应该写入到显示器上的数据会写入到 log.txt 中。
注:由于向文件中写数据的缓冲区刷新策略不同,所以这里我们需要在关闭文件之前进行缓冲区刷新,否则 log.txt 中没有数据,具体细节在后文。
像这样,本来应该往一个文件中写入数据,但是却写入到另一个文件中去了,这种特性就叫做重定向;而重定向的本质是上层使用的 fd 不变,在内核中更改 fd 指向的 file 对象,即更改文件描述符表数组中 fd 下标中的内容,让其变为另一个 file 对象的地址。(同一个 fd 指向不同的 file 对象)
2、dup2 系统调用
我们可以使用上面 close(1) 的方式实现重定向,但是我们发现先关闭、再打开这种方式非常麻烦,并且如果 0 和 1 号 fd 都被关闭时,我们还需要先创建一个无用的临时文件占用掉 0 号 fd 之后才能使新文件的 fd 为 1。为了解决这种尴尬的情况,操作系统提供了一个系统调用接口 dup2 来让我们直接进行重定向。
函数功能
dup2() makes newfd be the copy of oldfd, closing newfd first if necessary – dup2 函数会让 newfd 成为 oldfd 的一份拷贝,并且在必要时关闭 newfd。
函数参数
int dup2(int oldfd, int newfd);
# 头文件:<unistd.h>
# oldfd:旧的文件描述符
# newfd:新的文件描述符
# int:函数返回值,成功返回 newfd,失败返回-1
函数使用
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#define FILE_NAME "log.txt"
int main() {
umask(0);
int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd == -1) {
perror("open");
return 1;
}
int ret = dup2(fd, 1); //fd是oldfd,1是newfd,让1成为fd的拷贝
if(ret == -1) {
perror("dup2");
return 1;
}
printf("fd:%d\n", fd);
fprintf(stdout, "fd:%d\n", fd);
fflush(stdout);
close(fd);
return 0;
}
可以看到,和之前一样,本该写入到标准输出 (显示器) 中的数据写入到了 log.txt 中,完成了输出重定向;不同的地方在于:之前是提前关闭了 1 号 fd,然后让 1 号 fd 直接指向 log.txt file 对象,3 号 fd 并没有被使用;而这里 open log.txt 时 1 号 fd 仍然指向标准输出,所以 log.txt 的 fd 是 3,只是后面通过 dup2 使得 1 号 fd 也指向了 log.txt file 对象,所以最后 log.txt 中的内容为 3,如下:
注意事项
dup2 系统调用让 newfd 成为 oldfd 的一份拷贝,其本质是将 oldfd 下标里面存放的 file 对象的地址拷贝到 newfd 下标的空间中,即拷贝的是 fd 对应空间中的数据,而并不是两个 fd 数字之间进行拷贝,这样也没有意义;并且拷贝完成后只会留下 oldfd。
3、三种重定向的实现
Linux 中主要有三种主要的重定向 – 输入重定向、输出重定向和追加重定向;在 Linux 命令行中它们分别使用 <、>、>> 表示,如下:
输出重定向
输入重定向我们上面已经实现了,就是通过 dup2(fd, 1) 系统调用将目标文件 fd 中的内容拷贝到 1 号 fd 中,从而将本该写入到显示器中的数据写入到目标文件中。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#define FILE_NAME "log.txt"
int main() {
umask(0);
int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd == -1) {
perror("open");
return 1;
}
int ret = dup2(fd, 1); //fd是oldfd,1是newfd,让1成为fd的拷贝
if(ret == -1) {
perror("dup2");
return 1;
}
printf("fd:%d\n", fd);
fprintf(stdout, "fd:%d\n", fd);
char* msg = "hello redirect\n";
write(1, msg, strlen(msg));
fflush(stdout);
close(fd);
return 0;
}
注:这里出现了一个有趣的现象,“hello redirect” 是最后写入的,但是它却出现在了文本的最前面,这还是由缓冲区造成的,我们后文再详细说明。
追加重定向
理解了输出重定向之后,追加重定向就变得非常简单了,只需要在打开文件时去掉 O_TRUNC 选项,加上 O_APPEND 选项即可。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#define FILE_NAME "log.txt"
int main() {
umask(0);
int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_APPEND, 0666);
if(fd == -1) {
perror("open");
return 1;
}
int ret = dup2(fd, 1); //fd是oldfd,1是newfd,让1成为fd的拷贝
if(ret == -1) {
perror("dup2");
return 1;
}
printf("fd:%d\n", fd);
fprintf(stdout, "fd:%d\n", fd);
char* msg = "hello redirect\n";
write(1, msg, strlen(msg));
fflush(stdout);
close(fd);
return 0;
}
输入重定向
输入重定向就是通过 dup2(fd, 0) 系统调用将目标文件 fd 中的内容拷贝到 0 号 fd 中,从而将本该从标准输入 (键盘) 读入的数据转换为从目标文件中读入。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#define FILE_NAME "log.txt"
int main() {
int fd = open(FILE_NAME, O_RDONLY);
if(fd == -1) {
perror("open");
return 1;
}
int ret = dup2(fd, 0); //输入重定向
if(ret == -1) {
perror("dup2");
return 1;
}
char buf[64];
while(fgets(buf, sizeof(buf) - 1, stdin) != NULL) {
buf[strlen(buf)] = '\0';
printf("%s", buf); //将从stdin中读入的数据(重定向后是log.txt文件)写入到stdout中
}
close(fd);
return 0;
}
五、Linux 下一切皆文件
在刚开始学习 Linux 的时候,我们就说 Linux 下一切皆文件,包括键盘、显示器、磁盘以及网卡这些硬件,现在我们来正式理解这句话:
在 计算机的软硬件体系结构 中我们学到,操作系统是一款管理软件,它通过向下管理好各种软硬件资源 (手段),来向上提供良好 (安全、稳定、高效) 的运行环境 (目的);也就是说,键盘、显示器、磁盘、网卡等硬件也是由操作系统来管理的。而操作系统管理软硬件的方法是 先描述、再组织,即先将这些设备的各种属性抽象出来组成一个结构体,然后为每一个设备都创建一个结构体对象,再用某种数据结构将这些对象组织起来;这也就是我们上面学习到的 文件内核数据结构 file;
同时,每种硬件的访问方法都是不一样的,比如,向磁盘中读写数据与向网卡中读写数据是有明显差异的,所以操作系统需要为每一种硬件都单独提供对应的 Read、Write 方法,这些方法位于驱动层。
但是,内核数据结构是位于操作系统层的,它如何与对应的读写方法联系起来呢?-- 通过函数指针,即在 struct file 结构体中创建函数指针变量,用于指向具体的 Write 和 Read 方法函数,这样每一个硬件都可以通过自己 file 对象中的 writep 和 readp 函数指针变量来找到位于驱动层的 Write 和 Read 方法,如下:struct file { //文件的各种属性 int types; //文件的类型 int status; //文件的状态 int (*writep)(...); //函数指针,指向读函数 int (*readp)(...); //函数指针,指向写函数 struct file* next; //下一个file对象的地址 ... }
如图,站在操作系统内核数据结构上层来看,所有的软硬件设备和文件统一都是 file 对象,即 Linux 下一切皆文件。
注:对于键盘来说,我们只能从其中读入数据,而并不能向其写入数据;同样的,对于显示器来说,我们只能向其写入数据,而并不能从它读入数据;所以,键盘的 Write 方法和显示器的显示器的 Read 方法我们都设为 NULL。
其实 Linux 一切皆文件的特性就是面向对象语言多态的特性,file 结构体相当于基类,驱动层的各种方法和结构就相当于子类。(Linux 在编写时C++等面向对象的语言还并没有出现,所以这里是用C语言模拟实现C++面向对象)
同时,struct file 是操作系统当中虚拟出来的一层文件对象,在 Linux 中,我们一般将这一层称为 虚拟文件系统 vfs,通过它,我们就可以摒弃掉底层设备的差别,统一使用文件接口的方式来进行文件操作。
六、缓冲区
1、为什么要有缓冲区
我们在之前编写 进度条代码 的时候就提到了缓冲区,而 缓冲区本质上就是一段内存,那么缓冲区是由谁申请的?缓冲区属于谁?以及为什么要有缓冲区呢?
我们以快递行业为例来说明:
假设你在江西读书,你女朋友在重庆读书,你们两个非常恩爱,常常会给对方做一些手工礼物,那么礼物如何送给对方呢?这里有两个选择 (假设交通不便捷):
1、你们每次都亲自骑自行车将礼物送到对方手中,不过一个来回要花费一个月左右;2、你们将礼物通过快递的方式由顺丰发给对方,花费时间不超过5天;
相信绝大部分人都会选择通过快递的方式来传递礼物,因为骑自行车亲自送的成本太高了,要花费非常多的时间。
我们将上面的过程转化为计算机 IO 的过程:
江西就相当于内存,位于江西的你就相当于内存中的一个进程,重庆就相当于磁盘,位于重庆的她就相当于磁盘中的一个文件,你给她送礼物就相当于进程向磁盘文件中写数据;
但由于磁盘属于外设,进程直接向磁盘文件中写数据的效率非常低,所以有了缓冲区,进程可以将自己的数据拷贝到缓冲区中,再由缓冲区将数据写入到磁盘文件中去;
不过,和现实生活中一样,顺丰快递不会为了你一个人的一件快递就运输一趟,而是快递积累到一定数量时统一运输,缓冲区也不会一有数据就立马刷新,而是会采取一定的刷新策略。
所以,在现实生活中,快递行业的意义是节省发送者的时间;而在计算机中,缓冲区的意义是节省进程进行数据 IO 的时间。
注:与其理解 fwrite 是将数据写入到文件的函数,不如理解 fwrite 是进行数据拷贝的函数,因为 fwrite 函数只是将数据从进程拷贝到缓冲区中,并没有真正将数据写入到磁盘文件中。
2、缓冲区的刷新策略
在学习缓冲区刷新策略之前我们需要达成一个共识:对于缓冲区中的一块数据,一次写入到外设的效率是要高于少量多批次写入到外设的,因为缓冲区等待磁盘就绪的时间要远多于写入数据的时间。(比如缓冲区写入一次数据一共要花费 1s,那么可能其中 990ms 都在等待外设就绪,只有 10ms 左右的时间在进行数据写入)
所以,为了提高效率,缓冲区一定会结合具体的设备定制自己的刷新策略,Linux 中缓冲区一共存在三种刷新策略和两种特殊情况;
三种刷新策略:
- 立即刷新 (无缓冲):缓冲区中一出现数据就立马刷新,这种很少出现;
- 行刷新 (行缓冲):每拷贝一行数据就刷新一次,显示器采用的就是这种刷新策略,因为显示器是给人看了,而按行刷新符合人的阅读习惯,同时刷新效率也不会太低;
- 缓冲区满 (全缓冲):待数据把缓冲区填满后再刷新,这种刷新方式效率最高,一般应用于磁盘文件。
两种特殊情况:
- 用户使用 fflush 等函数强制进行缓冲区刷新;
- 进程退出时一般都要进行缓冲区刷新;
在知道了缓冲区的属性策略之后,我们来观察几个现象:
//代码1
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main() {
//C语言接口
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString, stdout);
const char* msg = "hello write\n";
write(1, msg, strlen(msg));
return 0;
}
现象1:
现象2:
//代码2:在程序末尾使用fork创建一个子进程
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main() {
//C语言接口
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString, stdout);
const char* msg = "hello write\n";
write(1, msg, strlen(msg));
fork();
return 0;
}
现象3:
现象4:
要解释并理解上面的现象,我们需要知道缓冲区的位置。
3、缓冲区的位置
在之前的 进度条代码 中我们知道,我们谈论的所有缓冲区都不在操作系统内核中,而是位于用户级语言层面;实际上,对于C语言来说,缓冲区位于 FILE 结构体中,Linux 下,我们可以在 /usr/include/libio.h 中找到缓冲区的相关信息:
综上:printf、fwrite、fputs 等库函数会自带缓冲区,而 write 系统调用没有带缓冲区;同时,我们这里所说的缓冲区,都是用户级缓冲区。那这个缓冲区谁提供呢? printf、fwrite、fputs 是库函数, write 是系统调用,库函数在系统调用的 “上层”, 是对系统调用的 “封装”,但是 write 没有缓冲区,而 printf、fwrite、fputs 有,足以说明该缓冲区是二次加上的,又因为是C库函数,所以是由C标准库提供的。
现在我们就可以解释上面的四种现象了:
现象1:printf、fprintf、fputs 三种C语言接口函数都是向标准输出即显示器中打印数据,而显示器采用的是行缓冲区,同时,我们每条打印语句后面都带有换行符,所以 printf、fprintf、fputs 语句执行后立即刷新缓冲区;而 write 是系统调用,不存在缓冲区,所以也是语句执行后立即刷新;所以输出结果是四条语句顺序打印。
现象2:我们通过输入重定向指令 > 将本该写入到显示器文件中的数据写入到了磁盘文件中,由于磁盘文件采用全缓冲刷新策略,所以 printf、fprintf、fputs 三条语句执行完毕后数据并不会刷新,因为缓冲区并没有被写满,而是等到进程退出这种特殊情况才会将三条语句刷新到磁盘文件中,但此时,write 语句也已经执行完毕,而 write 系统调用没有缓冲区,执行立即写入;所以输出结果是 write 在最前面。
现象3:显示器采用行缓冲,所以在 fork 之前 printf、fprintf、fputs 三条语句的数据均已刷新到显示器上了,而对于进程数据来说,如果数据位于缓冲区内,那么该数据属于进程,此时 fork 子进程也会指向该数据;但如果该数据已经写入到磁盘文件了,那么数据就不属于进程了,此时 fork 子进程也不在指向该数据了;所以,这里 fork 子进程不会做任何事情,结果和现象1一样。
现象4:我们使用重定向指令将本该写入显示器文件的数据写入到磁盘文件中,而磁盘文件采用全缓冲,所以 fork 子进程时 printf、fprintf、fputs 的数据还存在于缓冲区中 (缓冲区没满,同时父进程还没有退出,所以缓冲区没有刷新),也就是说,此时数据还属于父进程,那么 fork 之后子进程也会指向该数据;而 fork 之后紧接着就是进程退出,父子进程某一方先退出时会刷新缓冲区,由于刷新缓冲区会清空缓冲区中的数据,为了保持进程独立性,先退出的一方会发生 写时拷贝,然后向磁盘文件中写入 printf、fprintf、fputs 三条数据;然后,后退出的一方也会进行缓冲区的刷新;所以,最终 printf、fprintf、fputs 的数据会写入两份 (父子进程各写入一份),且 write 由于属于系统调用没有缓冲区,所以只写入一份数据且最先写入。
4、实现一个简易的C语言缓冲区
在理解了缓冲区的各种原理之后,我们可以手动的实现一个简易的C语言缓冲区,即实现一个简易的 FILE 结构体以及 fopen、fwrite、fclose、fflush 等C语言文件操作的相关函数,来使得我们对缓冲区的理解能够更加深刻。
myStdio.h:
#pragma once
#include <assert.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define SIZE 1024 //缓冲区大小
#define SYNC_NOW 1 //无缓冲
#define SYNC_LINE 2 //行缓冲
#define SYNC_FULL 4 //全缓冲
typedef struct _FILE{
int flags; //刷新方式
int fileno; //文件描述符
int cap; //buffer的总容量
int size; //buffer当前的使用量
char buffer[SIZE];
}FILE_;
//C语言文件操作接口
FILE_ *fopen_(const char *path_name, const char *mode);
void fwrite_(const void *ptr, int num, FILE_ *fp);
void fclose_(FILE_ * fp);
void fflush_(FILE_ *fp);
myStdio.c:
#include "myStdio.h"
FILE_ *fopen_(const char *path_name, const char *mode) {
int flags = 0;
int defaultMode=0666; //默认权限
//文件打开方式
if(strcmp(mode, "r") == 0) { //只读
flags |= O_RDONLY;
} else if(strcmp(mode, "w") == 0) { //只写
flags |= (O_WRONLY | O_CREAT |O_TRUNC);
} else if(strcmp(mode, "a") == 0) { //追加
flags |= (O_WRONLY | O_CREAT |O_APPEND);
} else { //其他方式打开不考虑
//TODO
}
//调用系统调用打开文件
int fd = 0;
if(flags & O_RDONLY) fd = open(path_name, flags);
else fd = open(path_name, flags, defaultMode);
if(fd < 0) {
const char *err = strerror(errno);
write(2, err, strlen(err));
return NULL; //C语言打开文件失败会返回NULL的原因
}
//申请堆空间
FILE_ *fp = (FILE_*)malloc(sizeof(FILE_));
assert(fp);
fp->flags = SYNC_LINE; //默认设置成为行刷新
fp->fileno = fd;
fp->cap = SIZE;
fp->size = 0;
memset(fp->buffer, 0 , SIZE);
return fp; //C语言打开一个文件会返回一个FILE *指针的原因
}
void fwrite_(const void *ptr, int num, FILE_ *fp) {
//写入到缓冲区中
memcpy(fp->buffer + fp->size, ptr, num); //这里不考虑缓冲区溢出的问题
fp->size += num;
//判断是否刷新
if(fp->flags & SYNC_NOW) { //无缓冲
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0; //清空缓冲区
} else if(fp->flags & SYNC_FULL) { //全缓冲
if(fp->size == fp->cap) {
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
} else if(fp->flags & SYNC_LINE) { //行缓冲
if(fp->buffer[fp->size-1] == '\n') { //不考虑\0出现在字符串中间的情况
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
} else {
//TODO
}
}
void fflush_(FILE_ *fp) {
if( fp->size > 0) write(fp->fileno, fp->buffer, fp->size);
//fsync(fp->fileno); //强制要求OS进行外设刷新
fp->size = 0;
}
void fclose_(FILE_ * fp) {
fflush_(fp);
close(fp->fileno);
}
test.c:
#include "myStdio.h"
#include <stdio.h>
#define FILE_NAME "log.txt"
int main() {
FILE_ *fp = fopen_(FILE_NAME, "w");
if(fp == NULL) {
perror("fopen_");
return 1;
}
//int cnt = 5;
//while(cnt--) {
// const char* msg = "hello file";
// fwrite_(msg, strlen(msg), fp);
// fflush_(fp);
// sleep(1);
//}
const char* msg_fwrite_ = "hello fwrite\n";
fwrite_(msg_fwrite_, strlen(msg_fwrite_), fp);
const char* msg_write = "hello write\n";
write(fp->fileno, msg_write, strlen(msg_write));
fclose_(fp);
return 0;
}
5、内核缓冲区
我们之前理解的通过C语言文件接口向磁盘文件写入数据的过程是这样的:进程先通过 fprintf、fwrite、fputs 等函数将数据拷贝到缓冲区中,然后再由缓冲区以某种刷新方式刷新 (写入) 到磁盘文件中;
但实际上缓冲区并不是直接将数据写入到磁盘文件中的,而是先将数据拷贝到 内核缓冲区 – 位于内核数据结构 file 结构体中的一块内存空间 中,最后再由操作系统自主决定以什么样的刷新策略将数据写入到外设中,而这个写入的过程和用户毫无关系。
也就是说,我们向外设中写入数据其实一共分为三个步骤 – 先通过 fwrite 等语言层面的文件操作接口将进程数据拷贝到语言层面的缓冲区中,然后再根据缓冲区的刷新策略 (无、行、全) 通过 write 系统调用将数据拷贝到 file 结构体中的内核缓冲区中,最后再由操作系统自主将数据真正的写入到外设中。(所以 fwrite 和 write 其实叫做拷贝函数更合适)
注:这里操作系统的刷新策略比我们应用层 FILE 中的缓冲区的刷新策略要复杂的多,因为操作系统要根据不同的整体内存使用情况来选择不同的刷新策略,而不仅仅是死板的分为分行缓冲、全缓冲、无缓冲这么简单。
这里还存在一种特殊情况,既然进程数据被拷贝到内核缓冲区中,再由操作系统自主刷新,那么如果操作系统崩溃了就势必会出现数据丢失;这样对于像银行这种对数据丢失0容忍的机构来说就存在一定的风险,所以操作系统提供了一个系统调用函数 fsync,其作用就是将内核缓冲区中的数据立刻直接同步到外设中,而不再采用操作系统的刷新策略。
我们可以使用 fsync 接口将我们实现的简易C语言缓冲区设置为在内核缓冲区中采用无缓冲:
注:内核缓冲区大家可以作为扩展了解的知识,我们平时理解的时候理解到用户级语言层面的缓冲区就已经完全足够了!!!
(即大家就理解到 fwrite 等C语言库函数具有缓冲区,该缓冲区位于 FILE 结构体中,我们通过这些接口向外设写入数据时需要先将数据拷贝到缓冲区中,然后再由缓冲区根据特定的刷新策略将数据写入到外设中;而 write 等系统调用没有缓冲区,进程数据将直接写入到外设中 就完全 OK 了)