前言:本期介绍文件基础I/O。
那是不是所有磁盘的文件都被打开呢?显然不是这样!因此我们可以将文件划分成两种:a.被打开的文件;b.没有被打开的文件 。对于文件操作,一定是被打开的文件才能进行操作,本篇文章只会讲解被打开的文件。
文件操作的本质:进程和被打开文件的关系
下面fp按顺序对应以下三个操作依次:写入文件、打印文本信息、追加文本信息到文件中。
#include
#include
#include
int main()
{
// 没有指明路径就是在当前路径下创建
// r,w, r+(读写,不存在出错),w+(读写, 不存在创建), a(append, 追加), a+()
// FILE* fp = fopen(FILE_NAME, "w");
// FILE *fp = fopen(FILE_NAME, "r");
FILE *fp = fopen(FILE_NAME, "a");
// if(NULL == fp)
// {
// perror("fopen");
// return 1;
// }
//
// char buffer[64];
// while(fgets(buffer, sizeof(buffer) - 1, fp) != NULL)
// {
// buffer[strlen(buffer) - 1] = 0; // 消除\n
// puts(buffer);
// }
int cnt = 5;
while(cnt)
{
fprintf(fp, "%s:%d\n", "hello world", cnt--);
}
fclose(fp);
return 0;
}
注:以w方式单纯的打开文件,c会自动清空内部的数据。
对于C语言调用的fopen打开文件,实际上底层调用的是操作系统的接口open,其他语言也是这样,只不过语言级别的接口是多了一些特性,接下来就看看手册内容:
对于flag标记位,一般来说对于C语言,一个int类型代表一个标记位,那如果要传10个标记位呢?对于整型来说,实际上有32个比特位,那是不是可以将每一个比特位赋予特定的含义,通过比特位传递选项,从而实现对应的标记呢?一定是可以的。因此在介绍open函数之前,先来介绍一下标记位的实现:
注意:一个比特位一个选项,不能重复。(标记位传参)
#include
#include
#include
// 每一个宏,对应的数值,只有一个比特位是1,彼此位置不重叠
#define ONE (1<<0) // 0x1
#define TWO (1<<1) // 0x2
#define THREE (1<<2) // 0x4
#define FOUR (1<<3) // 0x8
void show(int flags)
{
if(flags & ONE) printf("one\n");
if(flags & TWO) printf("two\n");
if(flags & THREE) printf("three\n");
if(flags & FOUR) printf("four\n");
}
int main()
{
show(ONE);
printf("-----------------------\n");
show(TWO);
printf("-----------------------\n");
show(ONE | TWO);
printf("-----------------------\n");
show(ONE | TWO | THREE);
printf("-----------------------\n");
show(ONE | TWO | THREE | FOUR);
printf("-----------------------\n");
}
[hins@VM-12-13-centos file]$ ./myfile
one
-----------------------
two
-----------------------
one
two
-----------------------
one
two
three
-----------------------
one
two
three
four
-----------------------
因此我们再看这个open函数,就明白了是什么含义,就是通过不同的flags,传入不同的标记位,那接下来看看open函数怎么用:
int open(const char* pathname, int flags )
int open(const char* pathname, int flags, mode_t mode )
第一个函数是在文件已经存在的基础上使用的,如果不存在源文件,那么就需要用第二个函数,即第二个函数如果文件不存在就会自动创建文件。
#include
#include
#include
#include
#include
#include
// 注意并没有指明路径
#define FILE_NAME "log.txt"
int main()
{
umask(0); // 将系统继承给此进程的掩码设置为0,防止影响此进程
int fd = open(FILE_NAME/*文件路径*/, O_WRONLY/*标记位*/ | O_CREAT, 0666/*权限*/); // WRONLY:只写
if (fd < 0)
{
perror("open");
return 1;
}
printf("%d\n", fd);
close(fd); // close也是系统接口
return 0;
}
[hins@VM-12-13-centos file]$ ./myfile
3 # 下节会讲这个值
[hins@VM-12-13-centos file]$ ll
total 20
-rw-rw-rw- 1 hins hins 0 Feb 16 21:53 log.txt
# 权限:0666
对于C语言来讲,除了打开关闭,还有写入fwrite等函数接口,因此对于OS也存在一个接口:write
无论这个buf是什么类别,在OS看来都是二进制!至于这个类别是文本还是图片,都是由语言本身决定的。
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...); // 将特定的format格式化形成字符串放在str里面
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);
下面是个例子:
int main()
{
umask(0); // 将系统继承给此进程的掩码设置为0,防止影响此进程
int fd = open(FILE_NAME/*文件路径*/, O_WRONLY/*标记位*/ | O_CREAT, 0666/*权限*/); // WRONLY:只写
if (fd < 0)
{
perror("open");
return 1;
}
int cnt = 5;
char outBuffer[64];
while(cnt)
{
sprintf(outBuffer, "%s:%d\n", "Hello", cnt--); // outBuffer相当于写入的缓冲区
// 以\0作为字符串的结尾,是C语言的规定,与文件无关
write(fd, outBuffer, strlen(outBuffer)); //向文件中写入string的时候,长度+1是C语言的规定
}
printf("%d\n", fd);
close(fd); // close也是系统接口
return 0;
}
[hins@VM-12-13-centos file]$ ./myfile
[hins@VM-12-13-centos file]$ cat log.txt
Hello:5
Hello:4
Hello:3
Hello:2
Hello:1
可以看出,对于C语言中的w
,封装了文件接口的标识符:O_WRONLY
(写)、O_CREAT
(不存在就创建文件)、O_TRUNC
(清空文件),以及权限。
想要把清空变成追加,只需要将open内部的最后一个清空标识符改成追加的标识符:
int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd = open(FILE_NAME, O_RDONLY);
int main()
{
umask(0); // 将系统继承给此进程的掩码设置为0,防止影响此进程
int fd = open(FILE_NAME, O_RDONLY);
if (fd < 0)
{
perror("open");
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); // close也是系统接口
return 0;
}
小结:
系统调用接口 | 对应的C语言库函数接口 |
---|---|
open | fopen |
close | fclose |
write | fwrite |
read | fread |
lseek | fseek |
O_RDONLY //只读打开
O_WRONLY //只写打开
O_RDWR //读写打开
//以上三个常亮,必须且只能指定一个
O_CREAT //若文件不存在则创建文件
O_APPEND //追加写入
// 如果想要多个选项一起,那就使用 | 运算符即可。
对应不同功能的打开方式:
文件使用方式 | 含义 | 如果指定的文件不存在 |
---|---|---|
“r”(只读) | 为了输入数据,打开一个已经存在的文本文件 | 出错 |
“w”(只写) | 为了输出数据,打开一个文本文件 | 建立一个新的文件 |
“a”(追加) | 向文本文件尾添加数据 | 建立一个新的文件 |
“rb”(只读) | 为了输入数据,打开一个二进制文件 | 出错 |
“wb”(只写) | 为了输出数据,打开一个二进制文件 | 建立一个新的文件 |
“ab”(追加) | 向一个二进制文件尾添加数据 | 出错 |
“r+”(读写) | 为了读和写,打开一个文本文件 | 出错 |
“w+”(读写) | 为了读和写,建议一个新的文件 | 建立一个新的文件 |
“a+”(读写) | 打开一个文件,在文件尾进行读写 | 建立一个新的文件 |
“rb+”(读写) | 为了读和写打开一个二进制文件 | 出错 |
“wb+”(读写) | 为了读和写,新建一个新的二进制文件 | 建立一个新的文件 |
“ab+”(读写) | 打开一个二进制文件,在文件尾进行读和写 | 建立一个新的文件 |
即库函数接口是封装了系统调用接口的,所有语言的库函数都存在系统调用的影子。
文件操作的本质:进程和被打开文件的关系
进程可以打开多个文件,那是不是意味着系统中一定会存在大量的被打开的文件,被打开的文件要不要被操作系统管理起来呢?答案是一定的。
那么OS如何管理呢? 先描述,再组织。因此操作系统为了管理对应的打开文件,必定要为文件创建对应的内核数据结构标识文件:struct file{}
(包含了文件的大部分属性)因此将结构体链式链接,通过找到链表的首地址从而实现对链表内容的增删查改。
#include
#include
#include
#include
#include
#include
// 注意并没有指明路径
#define FILE_NAME(number) "log.txt"#number
// 将括号内部的值转换成字符串,两个字符串通过#具有自动连接特性
int main()
{
umask(0); // 将系统继承给此进程的掩码设置为0,防止影响此进程
int fd0 = open(FILE_NAME(1), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd1 = open(FILE_NAME(2), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd2 = open(FILE_NAME(3), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd3 = open(FILE_NAME(4), O_WRONLY | O_CREAT | O_APPEND, 0666);
int fd4 = open(FILE_NAME(5), O_WRONLY | O_CREAT | O_APPEND, 0666);
printf("fd: %d\n", fd0);
printf("fd: %d\n", fd1);
printf("fd: %d\n", fd2);
printf("fd: %d\n", fd3);
printf("fd: %d\n", fd4);
close(fd0);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
}
创建多个文件并打印其返回值:
[hins@VM-12-13-centos file]$ ./myfile
fd: 3
fd: 4
fd: 5
fd: 6
fd: 7
[hins@VM-12-13-centos file]$ ll
total 24
-rw-rw-rw- 1 hins hins 0 Feb 17 11:47 log.txt1
-rw-rw-rw- 1 hins hins 0 Feb 17 11:47 log.txt2
-rw-rw-rw- 1 hins hins 0 Feb 17 11:47 log.txt3
-rw-rw-rw- 1 hins hins 0 Feb 17 11:47 log.txt4
-rw-rw-rw- 1 hins hins 0 Feb 17 11:47 log.txt5
-rw-rw-r-- 1 hins hins 64 Feb 16 21:25 Makefile
-rwxrwxr-x 1 hins hins 8512 Feb 17 11:47 myfile
-rw-rw-r-- 1 hins hins 2295 Feb 17 11:46 myfile.c
现在就有个问题:为什么从3开始连续变化呢?
首先我们需要了解三个标准的输入输出流:stdin
(键盘),stdout
(显示器),stderr
(显示器)
FILE* fp = fopen();
这个FILE实际上是一个结构体,而对于上面的三个输入输出流,实际上也是FILE的结构体:
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
对于这个结构体必有一个字段–>文件描述符,下面就看一下这个文件描述符的值是什么:
通过对open函数的学习,我们知道了文件描述符就是一个小整数,即open的返回值。
int main()
{
printf("stdin->fd: %d\n", stdin->_fileno);
printf("stdout->fd: %d\n", stdout->_fileno);
printf("stderr->fd: %d\n", stderr->_fileno);
......
[hins@VM-12-13-centos file]$ ./myfile
stdin->fd: 0
stdout->fd: 1
stderr->fd: 2
fd: 3
fd: 4
fd: 5
fd: 6
fd: 7
因此这也就解释了为什么文件描述符默认是从3开始的,因为0,1,2默认被占用。我们的C语言的这批接口封装了系统的默认调用接口。同时C语言的FILE结构体也封装了系统的文件描述符。
那为什么是0,1,2,3,4,5……呢?下面就来解释:
PCB中包含一个files指针,它指向一个属于进程和文件对应关系的一个结构体:struct files_struct
,而这个结构体里面包含了一个数组叫做struct file* fd _array[]
的指针数组,因此如图前三个0、1、2被键盘和显示器调用,这也就是为什么之后的文件描述符是从3开始的,然后将文件的地址填入到3号文件描述符里,此时3号文件描述符就指向这个新打开的文件了。
再把3号描述符通过系统调用给用户返回,所以在一个进程访问文件时,需要传入3,通过系统调用找到对应的文件描述符表,从而通过存储的地址找到对应的文件,文件找到了,就可以对文件进行操作了。因此文件描述符的本质就是数组下标。
而现在知道,文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files
, 指向一张表files_struct
,该表最重要的部分就是包含一个指针数组,每个元素都是一个指向打开文件的指针。所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。
#include
#include
#include
#include
int main()
{
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
输出发现是fd: 3
关闭0或2,再看
#include
#include
#include
#include
int main()
{
close(0); // fd: 0
//close(2); // fd: 2
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
因此,我们知道了,文件fd的分配规则就是将这个array数组从小到大,按照循序寻找最小的且没有被占用的fd,这就是fd的分配规则。
对于上面的例子,我们关闭了文件描述符0和2对应的文件吗,那么如果关闭1呢?
#include
#include
#include
#include
#include
int main()
{
umask(0);
close(1);
int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
if(fd < 0){
perror("open");
return 1;
}
printf("open fd: %d\n", fd); // 往stdout输出
fprintf(stdout, "fd :%d\n", fd);// 与上面打印是一样的功能
close(fd);
return 0;
}
运行发现什么都没输出,这时我们刷新一下缓冲区,发现输出到文件上了。
fflush(stdout);
明明应该输出到显示器上,为什么会变成文件呢?
如图,可以看到由于文件描述符1所连接的stdout
被关闭,文件的fd发现1的位置是空着的,于是将这个新创建的文件log.txt
与对应的指针进行连接:
重定向的本质:上层用的fd不变,在内核中更改fd对应的struct file*的地址。
常见的重定向有:>(输入), >>(追加), <(输出)。
int dup2(int oldfd, int newfd);
// newfd的内容最终会被oldfd指向的内容覆盖
dup2的返回值也就是fd的文件描述符,失败返回-1
接下来修改一下刚刚的代码
...
// close(1);
dup2(fd, 1);
...
cat log.txt
,输出:
open fd: 3
open fd: 3
可以发现,这样操作简化了刚才的操作,另外,fd的值也不会被改变。
输出重定向演示完了,那我们就可以实现我们刚才提到的三个重定向剩下的追加、输入重定向了。
int main()
{
umask(0);
int fd = open("log.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
if(fd < 0){
perror("open");
return 1;
}
dup2(fd, 1);
printf("open fd: %d\n", fd); // 往stdout输出
fprintf(stdout, "fd :%d\n", fd);// 与上面打印是一样的功能
const char *msg= "hello world";
write(1, msg, strlen(msg));
fflush(stdout);
close(fd);
return 0;
}
cat log.txt
,输出:
open fd: 3
open fd: 3
hello world
int main()
{
umask(0);
int fd = open("log.txt", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
char line[64];
while(1)
{
printf("> ");
if(fgets(line, sizeof(line), stdin) == NULL) break; //stdin->0
printf("%s", line);
}
fflush(stdout);
close(fd);
return 0;
}
[hins@VM-12-13-centos file]$ ./myfile
>hello
hello
>world
world
注:在Linux中,Ctrl+D
表示文件结尾。
上面是从键盘中读取,如果不想从键盘读,我们可以重定向到向指定文件中读取:
dup2(fd, 0); // 输入重定向
[hins@VM-12-13-centos file]$ ./myfile
open fd: 3
open fd: 3
hello world
接下来,我们完善上期的myshell.c
,把这三个重定向加上,完整版本如下:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define NUM 1024
#define OPT_NUM 64
#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3
#define trimSpace(start) do{\
while(isspace(*start)) ++start;\
}while(0)
char lineCommand[NUM];
char *myargv[OPT_NUM]; //指针数组
int lastCode = 0;
int lastSig = 0;
int redirType = NONE_REDIR;
char *redirFile = NULL;
// "ls -a -l -i > myfile.txt" -> "ls -a -l -i" "myfile.txt" ->
void commandCheck(char *commands)
{
assert(commands);
char *start = commands;
char *end = commands + strlen(commands);
while(start < end)
{
if(*start == '>')
{
*start = '\0';
start++;
if(*start == '>')
{
// "ls -a >> file.log"
redirType = APPEND_REDIR;
start++;
}
else
{
// "ls -a > file.log"
redirType = OUTPUT_REDIR;
}
trimSpace(start); // 过滤空格
redirFile = start;
break;
}
else if(*start == '<')
{
//"cat < file.txt"
*start = '\0';
start++;
trimSpace(start);
// 填写重定向信息
redirType = INPUT_REDIR;
redirFile = start;
break;
}
else
{
start++;
}
}
}
int main()
{
while(1)
{
redirType = NONE_REDIR;
redirFile = NULL;
errno = 0;
// 输出提示符
printf("用户名@主机名 当前路径# ");
fflush(stdout);
// 获取用户输入, 输入的时候,输入\n
char *s = fgets(lineCommand, sizeof(lineCommand)-1, stdin);
assert(s != NULL);
(void)s;
// 清除最后一个\n , abcd\n
lineCommand[strlen(lineCommand)-1] = 0; // ?
//printf("test : %s\n", lineCommand);
// "ls -a -l -i" -> "ls" "-a" "-l" "-i" -> 1->n
// "ls -a -l -i > myfile.txt" -> "ls -a -l -i" "myfile.txt" ->
// "ls -a -l -i >> myfile.txt" -> "ls -a -l -i" "myfile.txt" ->
// "cat < myfile.txt" -> "cat" "myfile.txt" ->
commandCheck(lineCommand);
// 字符串切割
myargv[0] = strtok(lineCommand, " ");
int i = 1;
if(myargv[0] != NULL && strcmp(myargv[0], "ls") == 0)
{
myargv[i++] = (char*)"--color=auto";
}
// 如果没有子串了,strtok->NULL, myargv[end] = NULL
while(myargv[i++] = strtok(NULL, " "));
// 如果是cd命令,不需要创建子进程,让shell自己执行对应的命令,本质就是执行系统接口
// 像这种不需要让我们的子进程来执行,而是让shell自己执行的命令 --- 内建/内置命令
if(myargv[0] != NULL && strcmp(myargv[0], "cd") == 0)
{
if(myargv[1] != NULL) chdir(myargv[1]);
continue;
}
if(myargv[0] != NULL && myargv[1] != NULL && strcmp(myargv[0], "echo") == 0)
{
if(strcmp(myargv[1], "$?") == 0)
{
printf("%d, %d\n", lastCode, lastSig);
}
else
{
printf("%s\n", myargv[1]);
}
continue;
}
// 测试是否成功, 条件编译
#ifdef DEBUG
for(int i = 0 ; myargv[i]; i++)
{
printf("myargv[%d]: %s\n", i, myargv[i]);
}
#endif
// 内建命令 --> echo
// 执行命令
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
// 因为命令是子进程执行的,真正重定向的工作一定要是子进程来完成
// 如何重定向,是父进程要给子进程提供信息的
// 这里重定向会影响父进程吗?不会,进程具有独立性
switch(redirType)
{
case NONE_REDIR:
// 什么都不做
break;
case INPUT_REDIR:
{
int fd = open(redirFile, O_RDONLY);
if(fd < 0){
perror("open");
exit(errno);
}
// 重定向的文件已经成功打开了
dup2(fd, 0);
}
break;
case OUTPUT_REDIR:
case APPEND_REDIR:
{
umask(0);
int flags = O_WRONLY | O_CREAT;
if(redirType == APPEND_REDIR) flags |= O_APPEND;
else flags |= O_TRUNC;
int fd = open(redirFile, flags, 0666);
if(fd < 0)
{
perror("open");
exit(errno);
}
dup2(fd, 1);
}
break;
default:
printf("bug?\n");
break;
}
execvp(myargv[0], myargv); // 执行程序替换的时候,会不会影响曾经进程打开的重定向的文件?不会
exit(1);
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);
assert(ret > 0);
(void)ret;
lastCode = ((status>>8) & 0xFF);
lastSig = (status & 0x7F);
}
}
注:文件是共享的,不会因为进程不同而权限不同,因为文件是磁盘上的,与进程之间是独立的。即当子进程被创建并且发生写时拷贝时,原来的文件并不会再次被拷贝一次。
我们利用虚拟文件系统就可以摒弃掉底层设备之间的差别,统一使用文件接口的方式进行文件操作。
文件的引用计数: Linux文件引用计数的逻辑
#include
#include
#include
int main()
{
//C接口
printf("hello printf");
fprintf(stdout, "hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString, stdout);
//系统接口
const char* wstring = "hello write\n";
write(1, wstring, strlen(wstring));
return 0;
}
[hins@VM-12-13-centos buffer]$ ./myfile
hello printf
hello fprintf
hello fputs
hello write
当在代码最后添加一个fork()
后:
[hins@VM-12-13-centos buffer]$ ./myfile > log.txt
[hins@VM-12-13-centos buffer]$ cat log.txt
hello write
hello printf
hello fprintf
hello fputs
hello printf
hello fprintf
hello fputs
直接运行仍是正常的现象,但当重定向到log.txt中,C接口的打印了两次,这是什么原因呢?
缓冲区本质就是一段内存
下面有个场景:
小陈和一个网友相聊甚欢,一天小陈想给对方一份特产,但是小陈人在广州,对方在四川,如果小陈亲自去送,会占用小陈大量的时间,而且也不现实,所以为了不占用小陈自己的时间,就把包裹送到快递公司让其送到对方手里。
现实生活中,快递行业的意义就是节省发送者的时间,而对于这个例子来说,广州就相当于内存,发送者小陈相当于进程,包裹就是进程需要发送的数据,四川就相当于磁盘,对方就是磁盘上的文件,那么可以看成这样:
在冯诺依曼体系中,我们知道内存直接访问磁盘这些外设的速度是相对较慢的,即正如我们所举的例子一样,小陈亲自送包裹会占用他大量的时间,因此顺丰同样属于内存中开辟的一段空间,将我们在内存中已有的数据拷贝到这段空间中,拷贝函数就直接返回了,即小陈接收到顺丰的通知就离开了。在执行你的代码期间,顺丰对应的内存空间的数据也就是包裹就会不断的发送给对方,即发送给磁盘。而这个过程中,顺丰这块开辟的空间就相当于缓冲区。
那么缓冲区的意义是什么呢?——节省进程进行数据IO的时间。
在上述的过程中,拷贝是什么,我们在fwrite的时候没有拷贝啊?因此我们需要重新理解fwrite这个函数,与其理解fwrite是写入到文件的函数,倒不如理解fwrite是拷贝函数,将数据从进程拷贝到“缓冲区”或者外设中!
那我们送的包裹何时会发送出去呢?即我们的数据什么时候会到磁盘中呢?这就涉及到缓冲区刷新策略的问题:
还是上面的情景,小陈的包裹送到了顺丰,但是当小陈再次来到顺丰邮寄另一个包裹时,发现之前的包裹还在那里放着,于是小陈感到不满,而工作人员此时解释道:我们的快递是通过飞机运的,如果只送你这一件包裹,路费都不够!因此可以看出,快递不是即送即发,也就是说数据不是直接次写入外设的。
那么如果有一块数据,一次写入到外设,还是少量多次的效率高呢?
一定是一次写入最高。一块数据写入到外设,需要外设准备,如果多次写入外设,每一次外设进行的准备都会占用时间,而积攒到一定程度一次发送到外设,外设的准备次数就会大幅减少,效率也会提高。因此,为了在不同设备的效率都是最合适的,缓冲区一定会结合具体的设备,定制自己的刷新策略:
当然还有两种特殊情况
文章开始时我们提到了C语言接口打印两次的现象,毫无疑问,我们能够从中获得以下信息:
因此我们之前谈论的所有的缓冲区,都指的是用户级语言层面给我们提供的缓冲区。这个缓冲区在stdout,stdin,stderr->FILE*
,FILE作为结构体,其不仅包括fd,缓冲区也在这个结构体中。所以我们自己要强制刷新的时候,fflush传入的一定是文件指针,fclose也是如此,即:fflush(文件指针),fclose(文件指针)
通过查看:vim /usr/include/libio.h
typedef struct _IO_FILE FILE;
在/usr/include/stdio.h
因此我们所调用的fscanf,fprintf,fclose
等C语言的文件函数,传入文件指针时,都会把相应的数据拷贝到文件指针指向的文件结构体中的缓冲区中。
即缓冲区也可以看做是一块内存,对于内存的申请:无非就是malloc new出来的。
小结:
缓冲区(语言级别)是用户申请的(底层通过malloc/new);缓冲区属于FILE结构体;为了节省进程进行IO的时间
有了缓冲区的理解,现在就足以解释打印两次的现象:
由于代码结束之前,进行创建子进程:
如果我们不进行重定向,看到四条消息
stdout默认使用的是行刷新,在进程进行fork之前,三条C函数已经将数据进行打印输出到显示器上(外设),也就是说FILE内部的缓冲区不存在对应的数据。
如果进行了重定向>,写入的就不是显示器而是普通文件,采用的刷新策略是全缓冲,之前的三条C显示函数,虽然带了\n,但是不足以将stdout缓冲区写满!数据并没有被刷新,而在fork的时候,stdout属于父进程,创建子进程时,紧接着就是进程退出!无论谁先退出,都一定会进行缓冲区的刷新(就是修改缓冲区)一旦修改,由于进程具有独立性,因此会发生写时拷贝,因此数据最终会打印两份。
write函数为什么没有呢?因为上述的过程都与write无关,write没有FILE,用的是fd,没有C对应的缓冲区。
因此如上就是对于现象的解释。
那缓冲区和OS有什么关系呢?下面就通过写一个demo实现一下行刷新:touch myStdio.h;touch myStdio.c;touchmain.c
# Makefile
main:main.c myStdio.c
gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
rm -f main
// myStdio.h
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include
#include
#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_;
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; // 为什么打开文件失败会返回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; // 为什么你们打开一个文件,就会返回一个FILE *指针
}
void fwrite_(const void *ptr, int num, FILE_ *fp)
{
// 1. 写入到缓冲区中
memcpy(fp->buffer+fp->size, ptr, num); //这里我们不考虑缓冲区溢出的问题
fp->size += num;
// 2. 判断是否刷新
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') // abcd\nefg , 不考虑
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
}
else{
}
}
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);
}
// main.c
#include "myStdio.h"
int main()
{
FILE_ *fp = fopen_("./log.txt", "w");
if(fp == NULL)
{
return 1;
}
int cnt = 10;
const char *msg = "hello world ";
while(1)
{
fwrite_(msg, strlen(msg), fp);
fflush_(fp);
sleep(1);
printf("count: %d\n", cnt);
//if(cnt == 5) fflush_(fp);
cnt--;
if(cnt == 0) break;
}
fclose_(fp);
return 0;
}
我们所写入到磁盘的数据hello world是按照行刷新进行写入的,但并不是直接写入到磁盘中,而是先写到操作系统内的文件所对应的缓冲区里,对于操作系统中的file结构体,除了一些接口之外还有一段内核缓冲区,而我们的数据则通过file结构体与文件描述符对应,再写到内核缓冲区里面,最后由操作系统刷新到磁盘中,而刷新的这个过程是由操作系统自主决定的,而不是我们刚才所讨论的一些行缓冲、全缓冲、无缓冲……,因为我们提到的这些缓冲是在应用层C语言基础之上FILE结构体的刷新策略,而对于操作系统自主刷新策略则比我们提到的策略复杂的多(涉及到内存管理),因为操作系统需要考虑自己的存储情况而定,因此数据从操作系统写到外设的过程和用户毫无关系。
所以一段数据被写到硬件上(外设)需要进行这么长的周期:首先通过用户写入的数据进入到FILE对应的缓冲区,这是用户语言层面的,然后通过我们提到的刷新的策略刷新到由操作系统中struct file*
的文件描述符引导写到操作系统中的内核缓冲区,最后通过操作系统自主决定的刷新策略写入到外设中。如果OS宕机了,那么数据就有可能出现丢失,因此如果我们想及时的将数据刷新到外设,就需要一些其他的接口强制让OS刷新到外设,即一个新的接口:int fsync(int fd)
,调用这个函数之后就可以立即将内核缓冲区的数据刷新到外设中,就比如我们常用的快捷键:ctrl + s
总结:
因此以上我们所提到的缓冲区有两种:用户缓冲区和内核缓冲区,用户缓冲区就是语言级别的缓冲区,对于C语言来说,用户缓冲区就在FILE结构体中,其他的语言也类似;而内核缓冲区属于操作系统层面,他的刷新策略是按照OS的实际情况进行刷新的,与用户层面无关。
OK,以上就是本期知识点“文件基础”的知识啦~~ ,感谢友友们的阅读。后续还会继续更新,欢迎持续关注哟~
如果有错误❌,欢迎批评指正呀~让我们一起相互进步
如果觉得收获满满,可以点点赞支持一下哟~
❗ 转载请注明出处
作者:HinsCoder
博客链接: 作者博客主页