目录
[1.1 狭义理解](#1.1 狭义理解)
[1.2 广义理解](#1.2 广义理解)
[1.3 文件操作的归类认识](#1.3 文件操作的归类认识)
[1.4 操作系统角度](#1.4 操作系统角度)
[2.1 fopen打开文件](#2.1 fopen打开文件)
[2.2 fclose关闭文件](#2.2 fclose关闭文件)
[2.3 fread读文件](#2.3 fread读文件)
[2.4 fwrite写文件](#2.4 fwrite写文件)
[2.5 写文件](#2.5 写文件)
[2.6 读文件](#2.6 读文件)
[2.7 实现简单的cat命令](#2.7 实现简单的cat命令)
[2.8 输出信息到显示器,有哪些方法](#2.8 输出信息到显示器,有哪些方法)
[2.9 stdin&stdout&stderr](#2.9 stdin&stdout&stderr)
[3.1 一种传递标志位的方法](#3.1 一种传递标志位的方法)
[3.2 open系统调用接口](#3.2 open系统调用接口)
[3.3 close系统调用接口](#3.3 close系统调用接口)
[3.4 write系统调用接口](#3.4 write系统调用接口)
[3.5 read系统调用接口](#3.5 read系统调用接口)
[3.6 open打开文件总结](#3.6 open打开文件总结)
[3.7 open函数的返回值](#3.7 open函数的返回值)
[3.7.1 用户层面理解文件文件描述符](#3.7.1 用户层面理解文件文件描述符)
[3.7.1 系统层面理解文件文件描述符](#3.7.1 系统层面理解文件文件描述符)
[3.7.3 文件描述符的分配规则](#3.7.3 文件描述符的分配规则)
[3.7.4 重定向](#3.7.4 重定向)
[3.7.5 dup2系统调用](#3.7.5 dup2系统调用)
[3.8 在myshell中添加重定向功能](#3.8 在myshell中添加重定向功能)
[3.8.1 之前代码](#3.8.1 之前代码)
[3.8.2 重定向分析](#3.8.2 重定向分析)
[6.1 什么是缓冲区](#6.1 什么是缓冲区)
[6.2 为什么要引入缓冲区](#6.2 为什么要引入缓冲区)
[6.3 缓存区的刷新方式](#6.3 缓存区的刷新方式)
[6.4 FILE](#6.4 FILE)
[6.5 代码样例](#6.5 代码样例)
[6.6 缓冲区总结](#6.6 缓冲区总结)
一、理解文件
1.1 狭义理解
- 文件在磁盘里
- 磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的
- 磁盘是外设(即是输出设备,也是输入设备)
- 磁盘上的文件 ,对文件的所有操作,都是对外设的输入和输出,简称IO
1.2 广义理解
- Linux下一切皆文件(键盘、显示器、网卡、磁盘......这些都是抽象化的过程)(后面会讲如何去理解)
1.3 文件操作的归类认识
- 对于0KB的空文件是占用磁盘空间的
- 文件是文件属性(元数据)和文件内容的集合(文件=属性(元数据)+内容)
- 所有的文件操作本质是文件内容操作和文件属性操作
1.4 操作系统角度
- 对文件的操作,本质是进程对文件的操作
- 磁盘的管理者是操作系统
- 文件的读写本质不是通过C语言/C++的库函数来操作的(这些库函数只是为用户提供方便),而是通过文件相关的系统调用接口来实现的
我们在访问文件之前,需要先打开文件,那么是谁打开文件呢?答案是进程打开文件,对文件的操作,本质是进程对文件的操作!!!
文件是存放在磁盘上的,操作系统需要对磁盘进程管理,如果需要访问文件,就需要访问磁盘,但是磁盘是硬件,是操作系统管理的,普通人无法直接读取磁盘文件,所以操作系统需要提供系统调用接口,让用户可以通过系统调用接口来访问文件,因此我们c语言学习到的fopen和fclose一定封装了底层操作系统的文件系统调用!!!
如何理解打开文件,计算机可以打开很多文件,那么操作系统要不要把 被打开的文件管理起来呢?答案是需要的,那么如何管理呢?先描述在组织!!
虽然我们还没有学习文件,我们把文件打开之后,在操作系统内部就需要为被打开的文件创建struct结构体,这个结构体内部需要包含文件的相关属性。然后以双链表的方式将打开文件的结构体连接起来。对打开文件的管理,就转化成了对链表的增删查改。
二、回顾C语言文件接口
上面这个文章详细介绍了C语言的文件操作。下面我们介绍一下接口。
2.1 fopen打开文件

第一个参数pathname是打开指定路径的文件,没有加路径就会在当前路径下查找。
第二个参数mode是打开的方式。
返回值:打开失败会返回NULL,打开成功会返回一个FILE指针。
打开方式:
- r:只读方式打开文件,文件不存在则打开失败;
- w:写入的方式打开文件,文件不存在则创建;
- a:追加式写入的方式打开文件,文件不存在则创建。
小细节:
以"w"方式打开文件,首先会清空文件内容,再写入;
以"a"方式打开文件,会在文件结尾开始写入,不清空。
2.2 fclose关闭文件

第一个参数是打开文件的FILE指针。
返回值:关闭成功返回0,关闭失败EOF(-1)被返回,并且设置errno。
2.3 fread读文件

第一个参数是从流中读取数据放到ptr所指向的空间里面
第二个参数是指一个数据有多大
第三个按时是指要读多少个数据
第四个参数是指从哪一个文件流中读取
2.4 fwrite写文件

第一个参数是指指向数组元素的指针
第二个参数是指我要写的数据占多少个字节
第三个参数是指我要写多少个这样的数据
第四个参数是指写到哪一个文件流
2.5 写文件
代码如下所示:
cpp
#include <stdio.h>
#include <string.h>
int main()
{
FILE* pf=fopen("myfile.txt","w");
if(!pf)
{
perror("fopen fail");
return 1;
}
const char* msg="hello world\n";
int cnt=5;
while(cnt--)
{
fwrite(msg,strlen(msg),1,pf);
}
fclose(pf);
return 0;
}
运行如下所示:

2.6 读文件
代码如下所示:
cpp
#include <stdio.h>
#include <string.h>
int main()
{
FILE* pf=fopen("myfile.txt","r");
if(!pf)
{
perror("fopen fail");
return 1;
}
char buffer[1024];
while(1)
{
fread(buffer,1,sizeof(buffer),pf);
if(feof(pf))
break;
}
printf("%s",buffer);
fclose(pf);
return 0;
}
运行如下所示:

2.7 实现简单的cat命令
代码如下所示:
cpp
#include <stdio.h>
#include <string.h>
int main(int argc,char* argv[])
{
if(argc!=2)
{
perror("argv error\n");
return 1;
}
FILE* pf=fopen(argv[1],"r");
if(!pf)
{
perror("fopen fail");
return 1;
}
char buffer[1024];
while(1)
{
fread(buffer,1,sizeof(buffer),pf);
if(feof(pf))
break;
}
printf("%s",buffer);
fclose(pf);
return 0;
}
运行如下所示:

2.8 输出信息到显示器,有哪些方法
cpp
#include <stdio.h>
#include <string.h>
int main()
{
printf("1:hello printf\n");
fprintf(stdout,"2:hello fprintf\n");
const char* msg="3:hello fwrite\n";
fwrite(msg,1,strlen(msg),stdout);
return 0;
}

当我们向显示器打印,本质上就是向显示器文件写入,因为linux系统下,一切皆文件。
2.9 stdin&stdout&stderr
- C默认会打开三个输入输出流,分别是stdin,stdout,stderr
- 仔细观察发现,这三个流的类型都是FILE*,fopen返回值类型,文件指针
include <stdio.h>
extern FILE * stdin ;
extern FILE * stdout ;
extern FILE * stderr ;
- stdin 标准输入 键盘文件
- stdout 标准输出 显示器文件
- stderr 标准输出 显示器文件
我们写的C语言代码,在编译的时候是动过手脚的,编译器在编译的时候会给我们写的代码加上一段代码,会将这三个fopen打开。
为什么编译器会默认将这个三个文件给打开呢?
答案:因为我们写的程序是做数据处理的,程序需要获取数据和输出数据,所以会将这三个打开。我们可以手动将这三个文件流给关闭,关闭之后就无法scanf和printf了
三、系统文件IO
文件是存放在磁盘上的,操作系统需要对磁盘进程管理,如果需要访问文件,就需要访问磁盘,但是磁盘是硬件,是操作系统管理的,普通人无法直接读取磁盘文件,所以操作系统需要提供系统调用接口,让用户可以通过系统调用接口来访问文件,因此我们c语言学习到的fopen和fclose一定封装了底层操作系统的文件系统调用!!!
打开文件的方式不仅仅是fopen,ifstream等流式,语言层的方案,其实系统才是打开文件最底层的方案(open和close)。不过,在学习系统文件IO之前,先要了解下如何给函数传递标志位,该方法在系统文件IO接口中会使用到。
3.1 一种传递标志位的方法
我们之前再写C语言代码的时候,如果需要传入标记位,就需要使用一个形参,如果有四五个标记位,就需要使用四五个形参,函数形参的个数就变多了,非常麻烦。
linux内核传递标记位的时候,一般使用位图的方式,按照比特位进行操作。一个int类型有32位,就可以传入32个标记位。
代码如下所示:
cpp
#include <stdio.h>
#define ONE_FLAG (1<<0) //0000 0000 0000 0000 .... 0000 0001
#define TWO_FLAG (1<<1) //0000 0000 0000 0000 .... 0000 0010
#define THREE_FLAG (1<<2) //0000 0000 0000 0000 .... 0000 0100
#define FOUR_FLAG (1<<3) //0000 0000 0000 0000 .... 0000 1000
void Print(int flags)
{
if(flags & ONE_FLAG)
{
printf("One\n");
}
if(flags & TWO_FLAG)
{
printf("Two\n");
}
if(flags & THREE_FLAG)
{
printf("Three\n");
}
if(flags & FOUR_FLAG)
{
printf("Four\n");
}
}
int main()
{
Print(ONE_FLAG);
Print(FOUR_FLAG);
Print(ONE_FLAG|TWO_FLAG);
Print(ONE_FLAG|TWO_FLAG|THREE_FLAG);
Print(ONE_FLAG|TWO_FLAG|THREE_FLAG|FOUR_FLAG);
return 0;
}
运行如下所示:

3.2 open系统调用接口

第一个参数是打开文件的路径。
第二个参数是打开的模式。
第三个参数是文件不存在,新创建文件的权限是什么
打开的模式,常用的有5个:
O_RDONLY:只读
O_WRONLY:写入
O_CREAT:创建
O_TRUNC:清空
O_APPEND:追加
这些都是C语言定义的宏,如下所示:
代码如下所示:
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
int main()
{
int fd=open("log.txt",O_CREAT | O_WRONLY);
if(fd<0)
{
perror("open fail");
return 1;
}
return 0;
}
运行如下所示:

我们发现log.txt的权限部分是乱码,这是为什么呢?这是因为我们打开文件的时候,没有加上第三个参数,导致权限乱码,如下不想让权限成为乱码,可以加上第三个参数。
代码如下所示:
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
int main()
{
int fd=open("log.txt",O_CREAT | O_WRONLY,0666);
if(fd<0)
{
perror("open fail");
return 1;
}
return 0;
}
运行如下所示:

我们打开的时候,设置的权限不是666嘛?为什么ll查看log.txt文件的权限的时候,他的权限是664呢?这是因为有权限掩码umask的概念,
3.3 close系统调用接口

第一个参数是需要关闭的文件描述符
返回值:close成功返回0,close失败返回-1
3.4 write系统调用接口

第一个参数是文件描述符fd。
第二个参数是写入的数据的指针。
第三个参数是写入多少个字节。
返回值:成功写入返回写个的字节个数,写入失败返回-1.
代码如下所示:
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd=open("log.txt",O_CREAT | O_WRONLY, 0666);
if(fd<0)
{
perror("open fail");
return 1;
}
printf("fd:%d\n",fd);
const char* msg ="hello world\n";
int cnt=5;
while(cnt--)
{
write(fd,msg,strlen(msg));
}
close(fd);
return 0;
}
运行如下所示:

然后我们修改代码,只写入一行字符串,如下所示:
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd=open("log.txt",O_CREAT | O_WRONLY, 0666);
if(fd<0)
{
perror("open fail");
return 1;
}
printf("fd:%d\n",fd);
const char* msg ="abcd\n";
int cnt=1;
while(cnt--)
{
write(fd,msg,strlen(msg));
}
close(fd);
return 0;
}

我们发现不对啊,我们之前使用c语言库函数接口的时候,写入数据会先清空数据再写入啊,为什么这里没有清空呢?而是从头开始写abcd\n呢?
因为我们再打开文件的时候,只告诉了要创建,要写入,没有告诉清空啊,也就是只传入了O_CREAT和O_WRONLY,如果需要清空,就需要传入参数O_TRUNC
代码如下所示:
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd=open("log.txt",O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd<0)
{
perror("open fail");
return 1;
}
printf("fd:%d\n",fd);
const char* msg ="abcd\n";
int cnt=1;
while(cnt--)
{
write(fd,msg,strlen(msg));
}
close(fd);
return 0;
}
运行如下所示:

如果我们需要追加写入呢?就需要传入参数O_APPEND参数,代码如下所示:
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd=open("log.txt",O_CREAT | O_WRONLY | O_APPEND, 0666);
if(fd<0)
{
perror("open fail");
return 1;
}
printf("fd:%d\n",fd);
const char* msg ="abcd\n";
int cnt=5;
while(cnt--)
{
write(fd,msg,strlen(msg));
}
close(fd);
return 0;
}
运行如下所示:

如果打开文件的时候,需要清空写入,文件不存在就创建
写C语言代码的时候,打开文件传入了打开方式是w,就会转成上面的系统调用。
如果打开文件的时候,需要追加写入,文件不存在就创建
写C语言代码的时候,打开文件传入了打开方式是a,就会转成上面的系统调用。
我们还需要注意一个点,write写入的时候,参数不是char* 而是void*。这就说明了write做二进制写入也可以,字符串写入也可以。
二进制写入:
cpp#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdio.h> #include <unistd.h> #include <string.h> int main() { int fd=open("log.txt",O_CREAT | O_WRONLY | O_TRUNC, 0666); if(fd<0) { perror("open fail"); return 1; } printf("fd:%d\n",fd); int a=1234567; int cnt=1; while(cnt--) { write(fd,&a,sizeof(a)); } close(fd); return 0; }运行之后打开文件如下所示:
为什么写入的1234567是乱码啊。因为我们刚刚写入的是二进制写入,是把我们a这个整形变量写到了文件上。所以文件的大小是4个字节,就是将1234567的二进制写道了文件里面了,显示不出来。如果我们需要写入字符串1234567,就需要将整形的1234567转换成字符串1234567。如下所示:
cpp#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdio.h> #include <unistd.h> #include <string.h> int main() { int fd=open("log.txt",O_CREAT | O_WRONLY | O_TRUNC, 0666); if(fd<0) { perror("open fail"); return 1; } printf("fd:%d\n",fd); int a=1234567; int cnt=1; while(cnt--) { char buffer[1024]; snprintf(buffer,sizeof(buffer),"%d",a); write(fd,buffer,strlen(buffer)); } close(fd); return 0; }在系统层面上存不存在二进制写入和文本写入呢?系统关心你的写入类型嘛?
系统不关心写入类型,随便写,想写二进制就写入二进制,想写入文件就写入文本,这就是为什么write的参数是void*了。
所谓的二进制写入和文本写入是语言层的概念,写入字符串有fputs这样的接口,写入二进制有fwrite接口。这两个函数的底层都调用write接口就可以了。
需要写入字符串文本需要我们自己转成字符串文本。
3.5 read系统调用接口

第一个参数是从指定的文件描述符去读。
第二个参数是读数据的缓冲区。
第三个参数是缓冲区的大小。
返回值:读入成功返回读入成功的字节数,读到文件结尾返回0,读入失败返回-1。
如果需要读取文件,打开文件的时候就需要修改参数,文件不存在就不能新建文件了,如果新建了文件,文件也是空的,读取也没有意义。也不需要传入权限,也没有文本读入,读到的都是二进制的,需要做文本解释自己解释。
代码如下所示:
cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd=open("log.txt",O_RDONLY);
if(fd<0)
{
perror("open fail");
return 1;
}
printf("fd:%d\n",fd);
while(1)
{
char buffer[64];
int n=read(fd,buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n]=0;
printf("%s",buffer);
}else if(n==0)
{
break;
}
}
close(fd);
return 0;
}
运行如下所示:

3.6 open打开文件总结
- 读取文件:int fd=open("log.txt",O_RDONLY);
- 清空写入:int fd=open("log.txt",O_CREAT | O_WRONLY | O_TRUNC, 0666);
- 追加写入:int fd=open("log.txt",O_CREAT | O_WRONLY | O_APPEND, 0666);
3.7 open函数的返回值
在认识返回值之前,先来认识一下两个概念:系统调用和库函数
- 上面的fopen,fclose,fread,fwrite都是C标准库当中的函数,我们称之为库函数(libc) 。
- 而open,close,read,write,都属于系统提供的接口,称之为系统调用接口
- 回忆一下我们讲操作系统概念时,画的一张图

系统调用接口和库函数的关系,一目了然。
所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。
3.7.1 用户层面理解文件文件描述符
在上面的代码中,我们打印输出了open的返回值fd,他的值是3,我们使用read和write系统调用都需要传入这个3,居然能用一个整数来读写文件,关闭文件。进程拿到这个fd整数就能够读写文件了,那我们之前打开文件的FILE*指针是什么呢?
我们一次打开多个文件,看看它们的返回值有何特点:
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
// 打开文件 写入 + 创建 + 清空
int fd1 = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd2 = open("log2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd3 = open("log3.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd4 = open("log4.txt", 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);
// 关闭
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}

可以看到这些返回值都是一个个连续的整数 ,很类似于数组的下标!
我们每一次打开文件都是从3开始的。那么文件描述符0、1、2是什么呢?
上面我们说过在我们进程运行的时候会默认打开三个文件流:
标准输入流:stdin (键盘) 0
标准输出流:stdout(显示器)1
标准错误流:stderr(显示器) 2
OS/C语言为什么要默认把这三个流打开呢?
最主要的原因是为了方便程序员进行默认的输入输出的代码编写。
C语言中打开文件的返回值是FILE*,FILE是一个文件类型,是C语言提供的一个结构体,typedef XXX{...}FILE;
在操作系统接口层面,只认fd文件描述符,所以我们可以大胆的预测,C语言的FILE结构体一定封装了文件描述符!!!即stdin,stdout,stderr对象里面一定封装了0,1,2.
测试代码如下:
cpp#include <stdio.h> #include <unistd.h> int main() { // 打开文件 FILE *fp1 = fopen("log.txt1", "w"); FILE *fp2 = fopen("log.txt2", "w"); FILE *fp3 = fopen("log.txt3", "w"); printf("fp1->fd: %d\n", fp1->_fileno); printf("fp2->fd: %d\n", fp2->_fileno); printf("fp3->fd: %d\n", fp3->_fileno); fclose(fp1); fclose(fp2); fclose(fp3); return 0; }
测试stdin,stdout,stderr中的fd是0,1,2;代码如下:
cpp#include <stdio.h> #include <unistd.h> 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; }
所以我们回答上面的问题:进程拿到这个fd整数就能够读写文件了,那我们之前打开文件的FILE*指针是什么呢?
答案是,FILE是一个结构体,这个结构体里面封装了fd文件描述符!!!
我们之前在C语言学到的文件操作,在类型层面,文件对象FILE封装了fd文件描述符,在接口层面,fopen打开文件时封装了选项。
谈一谈封装:
我们常说,C 语言标准库的文件操作封装了操作系统原生的系统调用接口。以此延伸:C++、Java、Python 等编程语言的文件操作 API,同样都在底层封装了操作系统提供的文件相关系统调用。
不同编程语言对外暴露的文件操作接口语法各不相同,但万变不离其宗,最终执行读写、打开、关闭文件的行为,都依赖操作系统提供的文件系统调用。
那为什么要做这一层封装? 如果直接使用操作系统原生系统调用(Linux 下 open、close、read、write)编写文件操作代码,把这段代码直接放到 Windows 系统中运行,是无法正常工作的。 原因很清晰:Linux 原生系统调用是平台专属接口,直接基于系统调用编写的代码不具备跨平台能力,移植到其他操作系统时无法保证正常运行。
但倘若我们不直接调用原生系统调用,转而使用语言标准库提供的文件操作接口,例如 C 语言的
fopen、fclose、fread、fwrite,代码移植到其他操作系统后大多可以正常编译运行。原理在于:C 标准库本身是分平台实现的。Linux 平台上的 C 标准库,内部封装的是 Linux 的文件系统调用;Windows 平台的 C 标准库,则封装 Windows 自身的文件系统 API。
开发者只需要编写符合 C 标准的代码,操作系统之间底层 API 的差异全部由标准库屏蔽。我们无需关心程序最终运行在 Linux、Windows 还是 macOS,由对应平台的标准库完成底层适配,这也是 C 标准库代码具备可移植性的核心原因。
3.7.1 系统层面理解文件文件描述符
接下来就从系统的层面来理解一下文件fd:
在磁盘中存在众多的文件,每打开一个文件就需要在内核中形成对应的文件结构体对象struct file。这个结构体里面需要包含读写位置,属性集合,打开方式,缓冲区,操作方法,指向下一个打开文件对象的指针等等。操作系统对打开文件的管理就转换成为了对链表的增删查改。
OS既然要管理这些被打开的文件,久而久之当被打开的文件越来越多,那么OS怎么知道哪几个文件是哪个进程打开的呢?
所以在OS中就会有一个struct files_struct的结构体对象,而这个结构体中存在一个struct file *fd_array\[\]的结构体指针数组,该数组被我们称为进程文件描述符表。每一个进程PCB都有一个指向该数组的指针,在这个数组中,记录了进程与文件结构体对象的对应关系,所以每一个进程都可以找到自己打开的文件。

整个过程就是open先给文件创建对应的结构体对象,再将对应的文件与文件描述符表联系起来,再通过返回之将下标返回给外部。
所以文件描述符fd本质就是数组的下标,OS访问文件只认文件描述符。
3.7.3 文件描述符的分配规则
我们上面说了0对应stdin,1对应stdout,2对应stderr,我们可以使用close关闭对应的文件,scanf默认是从stdin中读取内容,printf默认是将内容写道stdout文件中。如果我们使用close关闭文件描述符0,我们就不能从标准输入中读取字符了,如果我们使用close关闭文件描述符1,我们就不能将字符输出到屏幕了。
文件描述符分配规则:寻找最小的,没有被使用的位置,分配给指定的打开文件!
我们关闭0和2,然后打开文件,发现新打开的文件的文件描述符是0,
代码如下所示:
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
close(0);
close(2);
int fd=open("log.txt",O_WRONLY | O_CREAT | O_APPEND,0666);
if(fd<0)
{
perror("fail open");
exit(1);
}
printf("fd:%d\n",fd);
close(fd);
return 0;
}
运行如下所示:

我们发现我们关闭文件描述符0和2之后,再次打开文件,这个打开文件的文件描述符是0.
所以文件描述符分配规则:寻找最小的,没有被使用的文件描述符,分配给指定的打开文件!
3.7.4 重定向
我们在上面的代码中关闭了0和2,没有关闭1,如果我们将1关闭会怎么样呢?
代码如下所示:
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
close(1);
int fd=open("log.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);
if(fd<0)
{
perror("fail open");
exit(1);
}
printf("fd:%d\n",fd);
return 0;
}
运行如下所示:

此时,我们发现,本来应该输出到显示器上的内容,输出到了文件log.txt当中,其中,fd=1。
代码解析:我们运行代码之后,发现printf打印的字符串没有显示在屏幕上,然后我们打开log.txt文件,发现打印的字符串写入到了文件中,这是为什么呢?
这是因为我们关闭了文件描述符1之后,打开log.txt文件之后,他的文件描述符就是1,printf是默认往文件描述符1中写入字符的,所以会将字符写到log.txt中。
这种现象叫做输出重定向。常见的重定向有:>,>>,<!!

重定向的本质就是更改文件描述符表的指针指向!!!
我们首先来看一个现象,
代码如下所示:我们只是在上面的代码中close(fd);
cpp#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdlib.h> int main() { close(1); int fd=open("log.txt",O_WRONLY | O_CREAT | O_TRUNC,0666); if(fd<0) { perror("fail open"); exit(1); } printf("fd:%d\n",fd); close(fd); return 0; }运行如下所示:
我们运行之后发现为什么log.txt文件中没有内容了呢?这个现象到缓冲区讲解!!
3.7.5 dup2系统调用

dup2的作用是修改文件描述符表中的指针指向!!!
第一个参数是oldfd,
第二个参数是newfd。
那这个参数怎么填呢?我现在想要让输出的内容写到新打开的文件中,是dup2(1,fd)还是dup(fd,1)呢?要想让输出的内容写到新打开的文件中,就需要让文件描述符表中1号下表指向新打开的文件,也就是将下标fd的指针覆盖到下标为1的位置!!也就是dup2(fd,1).
测试代码如下
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
int fd=open("log.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);
if(fd<0)
{
perror("fail open");
exit(1);
}
dup2(fd,1);
close(fd);
printf("fd:%d\n",fd);
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
fprintf(stdout,"hello stdout\n");
fprintf(stdout,"hello stdout\n");
fprintf(stdout,"hello stdout\n");
fprintf(stdout,"hello stdout\n");
return 0;
}
运行如下所示:

我们使用dup2将写入到stdout中的字符都写入到了log.txt文件中了。
printf是C库当中的lO函数,一般往stdout中输出,但是stdout底层访问文件的时候,找的还是fd:1,但此时,fd:1下标所表示内容,已经变成了log.txt的地址,不再是显示器文件的地址,所以,输出的任何消息都会往文件中写入,进而完成输出重定向。那追加和输入重定向如何完成呢?
如果想要完成追加重定向,就需要打开文件的时候,将O_TRUNC换成O_APPEND。
代码如下所示:
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
int fd=open("log.txt",O_WRONLY | O_CREAT | O_APPEND,0666);
if(fd<0)
{
perror("fail open");
exit(1);
}
dup2(fd,1);
close(fd);
printf("fd:%d\n",fd);
printf("hello world\n");
fprintf(stdout,"hello stdout\n");
return 0;
}
运行如下所示:

如果想要完成输入重定向,就需要打开文件的时候,将选项写成O_RDONLY。让dup2(fd,0);
代码如下所示:
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
int fd=open("log.txt",O_RDONLY);
if(fd<0)
{
perror("fail open");
exit(1);
}
dup2(fd,0);
close(fd);
while(1)
{
char buffer[64];
if(!fgets(buffer,sizeof(buffer),stdin)) break;
printf("%s",buffer);
}
return 0;
}
运行如下所示:原本是从标准输入读取,现在从文件中读取!!

所以重定向的本质就是:打开文件的方式+dup2!!
3.8 在myshell中添加重定向功能
我们之前在这一篇文章中详细解释了shell的模拟实现
【Linux】17:自定义shell命令行解释器-CSDN博客
下面我们在我们自己模拟的shell中添加重定向功能。
3.8.1 之前代码
cpp
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string>
#include <iostream>
#define COMMAND_SIZE 1024
#define FORMAT "%s@%s:~%s$"
//命令行参数表
#define MAXARGC 128
char* g_argv[MAXARGC];
int g_argc=0;
//环境变量表
#define MAX_ENVS 100
char* g_env[MAX_ENVS];
int g_envs=0;
//for test
char cwd[1024];
char cwdenv[2048];
//last exit code
int lastcode=0;
void InitEnv()
{
extern char **environ;
memset(g_env,0,sizeof(g_env));
g_envs=0;
//1:获取环境变量
for(int i=0;environ[i];i++)
{
g_env[i]=(char*)malloc(strlen(environ[i])+1);
strcpy(g_env[i],environ[i]);
g_envs++;
}
//新增一个环境变量
g_env[g_envs++]=(char*)"myenv=123456789";
g_env[g_envs]=NULL;
//2:导成环境变量
for(int i=0;g_env[i];i++)
{
putenv(g_env[i]);
}
}
const char* GetUserName()
{
const char* name=getenv("USER");
return name==NULL?"None":name;
}
const char* GetHostName()
{
const char* hostname=getenv("HOSTNAME");
return hostname==NULL?"None":hostname;
}
const char* GetPwd()
{
//const char* pwd=getenv("PWD");
const char* pwd =getcwd(cwd,sizeof(cwd));
if(pwd!=NULL)
{
snprintf(cwdenv,sizeof(cwdenv),"PWD=%s",cwd);
putenv(cwdenv);
}
return pwd==NULL?"None":pwd;
}
const char* GetHome()
{
const char* home=getenv("HOME");
return home==NULL?"":home;
}
void MakeCommandLine(char cmd_prompt[],int size)
{
snprintf(cmd_prompt,size,FORMAT,GetUserName(),GetHostName(),GetPwd());
}
void PrintCommandPrompt()
{
char prompt[COMMAND_SIZE];
MakeCommandLine(prompt,sizeof(prompt));
printf("%s",prompt);
fflush(stdout);
}
bool GetCommandLine(char* out,int size)
{
// ls -a -l =>"ls -a -l\n"
char* c=fgets(out,size,stdin);
if(c==NULL) return false;
out[strlen(out)-1]=0;//清理"\n"
if(strlen(out)==0) return false;
return true;
}
//3:命令行分析 "ls- a -l"=> "ls" "-a" "-l" 构建命令行参数表
bool CommandParse(char* commandline)
{
#define SEP " "
g_argc=0;
//"ls- a -l"=> "ls" "-a" "-l" 构建命令行参数表
g_argv[g_argc++]=strtok(commandline,SEP);
while(g_argv[g_argc++]=strtok(NULL,SEP));
g_argc--;
return g_argc>0?true:false;
}
void PrintArgv()
{
for(int i=0;g_argv[i];i++)
{
printf("argv[%d]->%s\n",i,g_argv[i]);
}
printf("argc->%d\n",g_argc);
}
//command
bool Cd()
{
if(g_argc==1)
{
std::string home=GetHome();
if(home.empty()) return true;
chdir(home.c_str());
}else
{
std::string where=g_argv[1];
// cd ~
if(where=="~")
{
//切换到当前用户的家目录
std::string home=GetHome();
if(home.empty()) return true;
chdir(home.c_str());
}else
{
chdir(where.c_str());
}
}
return true;
}
void Echo()
{
if(g_argc==2)
{
//echo "hello world"
//echo $?
//echo $PATH
std::string opt=g_argv[1];
if(opt=="$?")
{
std::cout<<lastcode<<std::endl;
lastcode=0;
}else if(opt[0]=='$')
{
std::string env_name=opt.substr(1);
const char* env_value=getenv(env_name.c_str());
if(env_value)
std::cout<<env_value<<std::endl;
}else
{
std::cout<<opt<<std::endl;
}
}
}
void Export()
{
std::string k_v=g_argv[1];
g_env[g_envs++]=(char*)malloc(strlen(k_v.c_str())+1);
strcpy(g_env[g_envs-1],k_v.c_str());
putenv(g_argv[1]);
}
bool CheckAndBuildIn()
{
//如果是内建命令就返回true,不是就返回false
std::string cmd=g_argv[0];
//因为使用了g_agrv[0],所以需要判断g_agrc大于0
if(cmd=="cd")
{
Cd();
return true;
}
else if (cmd=="echo")
{
Echo();
return true;
}else if(cmd=="export")
{
Export();
return true;
}
return false;
}
int Execute()
{
pid_t id=fork();
if(id==0)
{
//子进程
execvp(g_argv[0],g_argv);
exit(1);
}
//父进程
int status=0;
pid_t rid=waitpid(id,&status,0);
if(rid>0)
{
//等待成功
lastcode=WEXITSTATUS(status);
}
return 0;
}
void DestoryEnv()
{
for(int i=0;g_env[i];i++)
{
free(g_env[i]);
}
}
int main()
{
//shell 启动的时候,需要从系统中获取环境变量
//0:初始化环境变量表,从父进程shell获取
InitEnv();
while(1)
{
//1:输入命令行提示符
PrintCommandPrompt();
//2:获取用户输入命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline,sizeof(commandline)))
continue;
//3:命令行分析 "ls- a -l"=> "ls" "-a" "-l" 构建命令行参数表
if(!CommandParse(commandline))
continue;
//PrintArgv();
//4:检测并且处理内建命令
if(CheckAndBuildIn())
continue;
//5:执行指令
Execute();
}
DestoryEnv();
return 0;
}
3.8.2 重定向分析
我们在步骤2和3之间增加一步重定向分析。如果有重定向分析的话,就需要把命令拆解为两部分,一部分是命令,一部分是文件,还需要知道重定向方式是什么。
程序替换是不影响曾经的重定向;
程序替换没有创建新的进程,只是替换了代码和数据,并不会影响进程PCB,所以程序替换不会影响重定向。
代码如下所示:
cpp
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <ctype.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string>
#include <iostream>
#define COMMAND_SIZE 1024
#define FORMAT "%s@%s:~%s$"
//1:命令行参数表
#define MAXARGC 128
char* g_argv[MAXARGC];
int g_argc=0;
//2:环境变量表
#define MAX_ENVS 100
char* g_env[MAX_ENVS];
int g_envs=0;
//for test
char cwd[1024];
char cwdenv[2048];
//last exit code
int lastcode=0;
//3:关于重定向
#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3
int redir = NONE_REDIR;
std::string filename;
void InitEnv()
{
extern char **environ;
memset(g_env,0,sizeof(g_env));
g_envs=0;
//1:获取环境变量
for(int i=0;environ[i];i++)
{
g_env[i]=(char*)malloc(strlen(environ[i])+1);
strcpy(g_env[i],environ[i]);
g_envs++;
}
//新增一个环境变量
g_env[g_envs++]=(char*)"myenv=123456789";
g_env[g_envs]=NULL;
//2:导成环境变量
for(int i=0;g_env[i];i++)
{
putenv(g_env[i]);
}
}
const char* GetUserName()
{
const char* name=getenv("USER");
return name==NULL?"None":name;
}
const char* GetHostName()
{
const char* hostname=getenv("HOSTNAME");
return hostname==NULL?"None":hostname;
}
const char* GetPwd()
{
//const char* pwd=getenv("PWD");
const char* pwd =getcwd(cwd,sizeof(cwd));
if(pwd!=NULL)
{
snprintf(cwdenv,sizeof(cwdenv),"PWD=%s",cwd);
putenv(cwdenv);
}
return pwd==NULL?"None":pwd;
}
const char* GetHome()
{
const char* home=getenv("HOME");
return home==NULL?"":home;
}
void MakeCommandLine(char cmd_prompt[],int size)
{
snprintf(cmd_prompt,size,FORMAT,GetUserName(),GetHostName(),GetPwd());
}
void PrintCommandPrompt()
{
char prompt[COMMAND_SIZE];
MakeCommandLine(prompt,sizeof(prompt));
printf("%s",prompt);
fflush(stdout);
}
bool GetCommandLine(char* out,int size)
{
// ls -a -l =>"ls -a -l\n"
char* c=fgets(out,size,stdin);
if(c==NULL) return false;
out[strlen(out)-1]=0;//清理"\n"
if(strlen(out)==0) return false;
return true;
}
//3:命令行分析 "ls- a -l"=> "ls" "-a" "-l" 构建命令行参数表
bool CommandParse(char* commandline)
{
#define SEP " "
g_argc=0;
//"ls- a -l"=> "ls" "-a" "-l" 构建命令行参数表
g_argv[g_argc++]=strtok(commandline,SEP);
while(g_argv[g_argc++]=strtok(NULL,SEP));
g_argc--;
return g_argc>0?true:false;
}
void PrintArgv()
{
for(int i=0;g_argv[i];i++)
{
printf("argv[%d]->%s\n",i,g_argv[i]);
}
printf("argc->%d\n",g_argc);
}
//command
bool Cd()
{
if(g_argc==1)
{
std::string home=GetHome();
if(home.empty()) return true;
chdir(home.c_str());
}else
{
std::string where=g_argv[1];
// cd ~
if(where=="~")
{
//切换到当前用户的家目录
std::string home=GetHome();
if(home.empty()) return true;
chdir(home.c_str());
}else
{
chdir(where.c_str());
}
}
return true;
}
void Echo()
{
if(g_argc==2)
{
//echo "hello world"
//echo $?
//echo $PATH
std::string opt=g_argv[1];
if(opt=="$?")
{
std::cout<<lastcode<<std::endl;
lastcode=0;
}else if(opt[0]=='$')
{
std::string env_name=opt.substr(1);
const char* env_value=getenv(env_name.c_str());
if(env_value)
std::cout<<env_value<<std::endl;
}else
{
std::cout<<opt<<std::endl;
}
}
}
void Export()
{
std::string k_v=g_argv[1];
g_env[g_envs++]=(char*)malloc(strlen(k_v.c_str())+1);
strcpy(g_env[g_envs-1],k_v.c_str());
putenv(g_argv[1]);
}
bool CheckAndBuildIn()
{
//如果是内建命令就返回true,不是就返回false
std::string cmd=g_argv[0];
//因为使用了g_agrv[0],所以需要判断g_agrc大于0
if(cmd=="cd")
{
Cd();
return true;
}
else if (cmd=="echo")
{
Echo();
return true;
}else if(cmd=="export")
{
Export();
return true;
}
return false;
}
int Execute()
{
pid_t id=fork();
if(id==0)
{
//子进程检测重定向情况
if(redir==INPUT_REDIR)
{
int fd=open(filename.c_str(),O_RDONLY);
if(fd<0) exit(1);
dup2(fd,0);
close(fd);
}else if(redir==OUTPUT_REDIR)
{
int fd=open(filename.c_str(),O_CREAT | O_WRONLY | O_TRUNC,0666);
if(fd<0) exit(1);
dup2(fd,1);
close(fd);
}
else if(redir==APPEND_REDIR)
{
int fd=open(filename.c_str(),O_CREAT | O_WRONLY | O_APPEND,0666);
if(fd<0) exit(1);
dup2(fd,1);
close(fd);
}else
{}
//子进程
execvp(g_argv[0],g_argv);
exit(1);
}
//父进程
int status=0;
pid_t rid=waitpid(id,&status,0);
if(rid>0)
{
//等待成功
lastcode=WEXITSTATUS(status);
}
return 0;
}
void DestoryEnv()
{
for(int i=0;g_env[i];i++)
{
free(g_env[i]);
}
}
void TrimSpace(char cmd[],int& end)
{
while(isspace(cmd[end]))
{
end++;
}
}
void RedirCheck(char cmd[])
{
redir=NONE_REDIR;
filename.clear();
int start=0;
int end=strlen(cmd)-1;
//"ls -a -l > log,txt" ->"ls -a -l" "lof.txt" >> > <
while(end>start)
{
if(cmd[end]=='<')
{
cmd[end]=0;
end++;
TrimSpace(cmd,end);
redir=INPUT_REDIR;
filename=cmd+end;
break;
}else if(cmd[end]=='>')
{
if(cmd[end-1]=='>')
{
cmd[end]=0;
cmd[end-1]=0;
end++;
TrimSpace(cmd,end);
redir=APPEND_REDIR;
}else
{
cmd[end]=0;
end++;
TrimSpace(cmd,end);
redir=OUTPUT_REDIR;
}
filename=cmd+end;
break;
}
end--;
}
}
int main()
{
//shell 启动的时候,需要从系统中获取环境变量
//0:初始化环境变量表,从父进程shell获取
InitEnv();
while(1)
{
//1:输入命令行提示符
PrintCommandPrompt();
//2:获取用户输入命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline,sizeof(commandline)))
continue;
//3:重定向分析 "ls -a -l > file.txt" -> "ls -a -l" "file.txt" 重定向方式
RedirCheck(commandline);
//printf("redir:%d,filename:%s\n",redir,filename.c_str());
//4:命令行分析 "ls- a -l"=> "ls" "-a" "-l" 构建命令行参数表
if(!CommandParse(commandline))
continue;
//PrintArgv();
//4:检测并且处理内建命令
if(CheckAndBuildIn())
continue;
//5:执行指令
Execute();
}
DestoryEnv();
return 0;
}

四、stderr
stderr是编译器默认给我们代码打开的标准错误,它的类型是FILE*,FILE里面封装的文件描述符fd是2,也是对应的显示器文件。往stderr里面写数据,也会默认输出到显示器。
printf和cout默认都是往文件描述符1打印,也就是往显示器文件进行打印。
代码如下所示:
cpp
#include <stdio.h>
#include <iostream>
int main()
{
std::cout<<"hello world"<<std::endl;
printf("hello printf\n");
return 0;
}
运行如下所示:

cout和printf在底层都会找到文件描述符1,文件描述符1是指向标准输出显示器的,所以会往显示器输出。图示如下:

但是如果我们使用输出重定向,就会让文件描述符1指向新打开的文件,将打印到显示器的内容重定向到文件上,如下所示:

此时运行code1程序就不会有输出,而把输出的内容转换写到myfile文件中,
图示如下:

但是我们写**./code1 > myfile.txt** 是不完整的,完整的写法应该是这样的:./code1 1 > myfile.txt.。如下所示:

这样写**./code1 1 > myfile.txt特别好理解,就是将文件描述符1指向新打开的文件。但是我们平时都是将1进行了省略。**
我们再加两行代码,如下所示:
cpp
#include <stdio.h>
#include <iostream>
int main()
{
std::cout<<"hello world"<<std::endl;
printf("hello printf\n");
std::cerr<<"hello cerr"<<std::endl;
fprintf(stderr,"hello fprintf\n");
return 0;
}
运行如下所示:

cerr和stderr都是默认向标准错误进行打印,也就是向文件描述符2里面打印,而标准输出和标准错误对应的都是显示器文件。换句话说,相当于我们的显示器文件被进程打开了两次。
也就是说我们上面画的图是错误的,这样画只是为了好理解,正确的如下所示:

但是如果我们使用输出重定向,会有和上面不同的结果,如下所示:

我们发现标准输出写到了myfile文件里面,而标准错误还是写到了显示器里面。这是为什么呢?
这是因为:标准输出和标准错误都是显示器文件,但进行重定向的时候,会将1重定向到新文件,也就是让1指向新打开的文件,而2还是指向标准错误,所以标准输出会将内容写道myfile文件里面,而标准错误依旧是将内容写到显示器。
如果我们非要把标准错误的内容写到文件中,那应该怎么办呢?
如下所示:./code2 1>log.normal 2>log.error

这样就会把文件描述符1指向log.normal文件,而文件描述符2指向log.error文件了,因此cout和printf往文件描述符1里面打印就会打印到log.normal文件中,fprintf和cerr往文件描述符2里面打印就会打印到log.error文件中。
1:为什么要存在一个标准错误呢,printf/perror,cout/cerr呢?
打印结果分为错误打印和正确打印,正确打印对应的是stdout,错误打印对应的是stderr,它们两者都是打印在显示器上的。
有了stderr之后,就可以将正确打印和错误打印的数据分别存储在两个不同的文件,最主要的是为了查错,当程序出错时,直接去存储错误结果文件查找错误原因。
2:如果想要让stdout和stderr打印到同一个文件呢?
方法1:./code2 1>myfile 2>>myfile
方法2:./code2 1>myfile 2>&1
这样写./code2 1>myfile 2>myfile是错误的,第二次重定向的时候,会将内容情况打开文件
五、理解一切皆文件
磁盘、键盘、显示器、网卡等,这些外设统一按照文件的方式去看待,如何理解呢?
在底层的这些外设,站在方法的角度去考虑,每一种设备都有对应的读写方法,例如键盘,它具有读方法,写方法认为是空,显示器具有写方法,读方法认为是空,所以,这些设备的读写方法均不同,为了提升使用效率,在他们各自对应的文件结构体对象中会设置读写方法的函数指针,用于指向各自设备的具体读写方法,在访问这些设备时,直接去各自的文件结构体对象中调用读写方法,不需要关注底层,所以访问外设这一工作只需要访问各自对应的文件结构体,统一把它们当成文件对待,所以Linux下一切皆文件。

六、缓冲区
6.1 什么是缓冲区
缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
6.2 为什么要引入缓冲区
读写文件时,如果不会开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。
**为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。**比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。
又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备占用CPU,解放出CPU,使其能够高效率工作
6.3 缓存区的刷新方式
因为缓冲区可以暂存数据,所以它必须要有对应的刷新策略;
一般策略:
- 无缓冲(有数据立即刷新)
- 行缓冲(按行为单位进行刷新)
- 全缓冲(等到数据写满缓冲区再刷新)
特殊策略:
- 强制刷新
- 进程退出的时候,一般要进行刷新缓冲区
对于显示器文件,一般使用的是行刷新策略;
对于磁盘文件,一般使用的是全缓冲策略。
那么如何刷新呢?如何拷贝呢?
答案就是fd+系统调用write。
在上面我们看到了一个现象:
代码一:
cpp#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdlib.h> int main() { close(1); int fd=open("log.txt",O_WRONLY | O_CREAT | O_TRUNC,0666); if(fd<0) { perror("fail open"); exit(1); } printf("fd:%d\n",fd); return 0; }运行如下所示:
代码二:
cpp#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdlib.h> int main() { close(1); int fd=open("log.txt",O_WRONLY | O_CREAT | O_TRUNC,0666); if(fd<0) { perror("fail open"); exit(1); } printf("fd:%d\n",fd); close(fd); return 0; }运行如下所示:
如果我们不关闭close(1),就可以把内容写到log.txt文件之中。
如果我们关闭close(1),运行程序之后,内容没有写到log.txt之中。这是为什么呢?
这就是由于缓冲区的原因!!!
答案:
我们写入的数据只是写到了用户级缓冲区里面,刷新条件不满足,所以没有将用户级缓冲区的内容拷贝到系统级缓冲区里面。此时系统描述符1是指向这个新打开的log.txt文件的。
如果我们没有close(1).当程序结束之后,满足刷新条件,就会使用fd+系统调用write来刷新用户级缓冲区,将缓冲区里面的内容拷贝到内核级缓冲区里面,然后操作系统就会把内容写道log.txt之中。
如果我们close(1)了,那么文件描述符1就没有指向这个新打开的lon.txt文件。当程序结束的时候,满足刷新条件,但是1的指向已经关闭了,就无法将用户级缓冲区的内容拷贝到系统级缓冲区里面。也就不会将内容写到文件之中。
如果我们就想close(1),也想让内容显示到文件之中,我们就可以使用fflush来刷新用户级缓冲区,如下所示:
cpp#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <stdlib.h> int main() { close(1); int fd=open("log.txt",O_WRONLY | O_CREAT | O_TRUNC,0666); if(fd<0) { perror("fail open"); exit(1); } printf("fd:%d\n",fd); fflush(stdout); close(fd); return 0; }使用fflush就是当进程还没有退出的时候,赶紧使用fflush刷新用户级缓冲区!!
6.4 FILE
因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通
过fd访问的。
所以C语言库当中的FILE结构体内部,必定封装了fd。
在C语言中,我们只要使用fopen打开了一个文件,就会创建一个FILE类型的对象,也会为这个文件分配对应的用户级缓冲区,我们往这个文件里面写内容,都会写到对应的缓冲区里面,只有满足一定的条件才会将用户级缓冲区里面的内容拷贝到系统级缓冲区里面,然后由操作系统将系统级缓冲区里面的内容写到硬件。
在C语言中,FILE的定义如下:
cppstruct _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 };里面包含了文件描述符fd,用户级缓冲区,读写文件的方法,打开文件的方式等等,
6.5 代码样例
代码如下所示:
cpp
#include <cstdio>
#include <iostream>
#include <cstring>
#include <unistd.h>
int main()
{
//库函数
printf("hello printf\n");
fprintf(stdout,"hello fprintf\n");
const char* s="hello fwrite\n";
fwrite(s,1,strlen(s),stdout);
//系统调用
const char* ss="hello write\n";
write(1,ss,strlen(ss));
return 0;
}
运行如下所示:

代码分析:我们使用printf,fprintf,fwrite这三个库函数往stdout里面输出内容,使用write系统调用往显示器文件输出内容,执行代码之后,这四个语句都会被打印到显示器文件,让我们看见,当我们使用重定向的时候,将输出的内容拷贝到log.txt文件之中。但是为什么系统调用输出的语句在前面呢?
这是因为:我们使用重定向之后,文件描述符1指向打开的log.txt文件,不是显示器文件,普通文件不是按行刷新的。执行printf,fprintf,fwrite这三个库函数之后,内容都还在用户据缓冲区里面,而执行write系统调用会将内容直接写到内核级缓冲区里面。当程序结束之后,就会刷新用户级缓冲区,将用户级缓冲区里面的内容拷贝到系统级缓冲区里面,所以系统级缓冲区里面的内容先是write写的内容。
现在我们在代码的最后面创建一个子进程,再次指向上面的操作。
代码如下所示:
cpp
#include <cstdio>
#include <iostream>
#include <cstring>
#include <unistd.h>
int main()
{
//库函数
printf("hello printf\n");
fprintf(stdout,"hello fprintf\n");
const char* s="hello fwrite\n";
fwrite(s,1,strlen(s),stdout);
//系统调用
const char* ss="hello write\n";
write(1,ss,strlen(ss));
fork();
return 0;
}
运行如下所示:

为什么直接输出到显示器文件只有4条语句,而重定向输出到log.txt文件有7条语句呢?为什么库函数输出的内容在文件里面会有两份呢?系统调用函数输出的内容只有一份呢?
答案:如果输出到显示器文件,使用的是行刷新策略,用户级缓冲区遇到换行符会刷新缓冲区,所以会直接输出4条语句。
如果重定向到log.txt文件,一般都是缓冲区满了或者进程退出在刷新缓冲区。此时printf,fprintf,fwrite这三个库函数打印的语句在stdout的用户级缓冲区里面,而write打印的语句在内核级缓冲区里面。当我们使用fork创建子进程的时候,子进程的所有内容,如PCB,页表,数据,代码,用户级缓冲区,打开文件表等都是父进程的拷贝,并且文件描述符1也是指向log.txt文件的,所以子进程的用户级缓冲区里面也会有printf,fprintf,fwrite这三个库函数打印的语句,当子进程结束之后。会将stdout缓冲区里面的内容刷新到log.txt的内核级缓冲区里面,同样的,当父进程结束之后,也会将stdout缓冲区里面的内容刷新到log.txt的内核级缓冲区里面。因此,log.txt的内核级缓冲区里面有7条语句。所以创建子进程之后,使用重定向输出到log.txt文件有7条语句。库函数里面的内容有两份,而系统调用只有一份。
6.6 缓冲区总结


七、总结
- 狭义文件存放在磁盘,广义上 Linux 一切外设都可抽象为文件,文件由元数据与内容构成,文件操作本质是进程发起外设 IO。
- C 标准 IO 提供 fopen、fread、fwrite 等库函数,通过 FILE * 操作文件,进程默认打开 stdin、stdout、stderr 三个文件流。
- 系统调用采用位图方式传递打开标志,open/read/write/close 是底层原生 IO 接口,新建文件权限受 umask 影响。
- 文件描述符 fd 是进程文件描述符数组下标,遵循最小未占用编号分配规则,FILE 结构体内部封装文件描述符 fd。
- 关闭指定 fd 后新建文件会占用该编号,利用该特性可简易实现重定向,dup2 函数能够更规范修改 fd 指向完成重定向。
- 可以基于 dup2 在自制 myshell 中解析命令,识别输入、输出、追加重定向并完成对应功能。
- stdout (fd=1) 与 stderr (fd=2) 相互独立,默认都输出到显示器,普通重定向只作用于标准输出,二者能够分开定向至不同文件。
- Linux 一切皆文件的原理是内核把各类外设封装成统一 struct file,通过函数指针调用硬件专属读写方法,上层使用统一接口访问。
- 缓冲区是一块内存空间,作用是减少系统调用次数、协调高速 CPU 与低速外设,提升 IO 整体效率。
- 缓冲区分为无缓冲、行缓冲、全缓冲三种刷新策略,显示器一般行缓冲,普通磁盘文件一般全缓冲。
- C 标准 IO 存在用户缓冲区,系统调用直接操作内核缓冲区,fork 会复制用户缓冲区,引发库函数输出重复现象,原生系统调用不受此影响。






测试stdin,stdout,stderr中的fd是0,1,2;代码如下:

