目录
前言
书接上文【Linux】基础I/O----文件描述符与重定向详情请点击,今天继续介绍【Linux】基础I/O----理解"一切皆文件"和缓冲区,并实现一个简单的libc库
一、理解"一切皆文件"
- 操作系统是软硬件资源的管理软件,所以操作系统也需要管理硬件(先组织、再描述)
- 不同设备的访问方式是不同的,比如磁盘是读写设备,键盘是读设备,显示器是写设备
- 访问硬件资源,是因为我们自己写的代码(程序)加载到内存,变为进程之后,进程需要访问硬件资源(读取键盘/磁盘数据、向显示器写入数据),访问设备,都是进程在访问
- 在
struct file文件中有一个const struct file_operations* f_op结构(函数指针表),file_operation就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都对应着一个系统调用。读取file_operation中相应的函数指针,接着把控制权转交给函数,从而完成了Linux设备驱动程序的工作


二、缓冲区
什么是缓冲区
- 缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区
- 缓冲区的本质就是一段内存空间
引入缓冲区机制的原因
- 引入缓冲区机制:提高了使用缓存的进程的效率,允许进程单位时间内做更多的任务
- 类似于如果我需要将一份礼物送给远方的朋友,如果没有缓冲区(快递站),我只能自己亲自送过去,这样我自己的效率很低(不影响我朋友的效率),如果我将礼物交给驿站打包,由驿站将快递发出,这样我只需要将礼物交给驿站,之后我就能去干自己的事情,这样效率大大提高
- 驿站发出包裹就是将数据刷新,但是驿站不会说来一个包裹就立马发送一个包裹,而是有了很多需要发送出去的包裹再将其全部发送出去。所以数据也是允许在缓冲区积压,一次刷新多次数据,减少I/O效率(批量化刷新)
语言层面的刷新策略
C/C++语言层面上刷新策略:
- 无缓冲,立即刷新
- 有缓冲,行刷新(显示器)
- 有缓冲,写满再刷新(普通文件)----全缓冲
两种情况:
- 进程退出,主动刷新缓存
- 进程控制,fflush(stdout)
显示器行刷新
-
显示器行刷新在前面已经介绍过,这里不做演示,这里printf写入的缓冲区叫做
FILE缓冲区,不是内核的文件缓冲区 -
stdout是FILE *类型,struct FILE本质是一个结构体
-
C语言访问文件,都是通过FILE访问,包括stdin、stdout、stderr
-
FILE结构体内部,除了封装了fd,还有语言级别的缓冲区空间

-
所以进程默认打开stdin(0)、stdout(1)、stderr(2),在用户层面上会封装成FILE* stdin、FILE* stdout、FILE* stderr,printf直接写入到FILE* stdout结构体中的语言级别缓冲区中,scanf读取也是读取的FILE* stdin结构体中语言级别的缓冲区内容

-
当我们新打开一个文件,fd = 3,返回到C语言层面时,将其封装成了FILE* 结构体中封装了fd、以及语言级别缓冲区
-
当我们需要写入到该文件数据时,我们自己会先定义一个数组(可能键盘读取或者直接定义一个常字符串)来将我们要写入的数据保存到数组中,文件打开,将数据内容拷贝到FILE结构中的语言级别缓冲区中(将数据从用户拷贝到语言)
-
比如此时我们写入使用的是fputs函数,将我们定义的数组中的内容拷贝到语言级别缓冲区中,语言级别缓冲区有不同的刷新策略,不刷新、行刷新、写满再刷新
-
所以当满足语言级别缓冲区刷新策略之后,底层调用的write系统调用,write(3,...)将语言级别缓冲区内容刷新到文件内核缓冲区,再由操作系统决定何时将文件内核缓冲区内容拷贝到磁盘文件中
-
只要把数据交给OS,则用户认为写入完毕
-
这就是为什么我们
printf("hello, linux");sleep(1);当后面没有换行符时,不会立刻在显示器文件中看到打印信息,而是等程序结束之后主动刷新,这就是为什么程序结束之后才显示出来的原因,因为显示器是行刷新策略
总结
- 我们之前说的缓冲区是语言级别缓冲区,缓冲区在FILE内部
- 为什么要有语言级别缓冲区:
- 系统调用是有成本,有了FILE结构之后,可以先将内容保存在语言级别缓冲区中,当满足刷新情况之后再一次性全部刷新到内核缓冲区中,减少了系统调用次数
- 加速I/O函数的调用效率,printf、scanf、fgets、fputs只需要写入或者读取语言缓冲区的内容,这样能快速返回,执行后面的任务,使用C语言I/O结构的效率提高
- 重新理解printf、scanf格式化:
int a = 123;printf("%d", a);将整型类型格式化为单个字符,保存在文件缓冲区中,检查是否需要刷新,如果满足刷新条件,调用write; - scanf也是同样的道理,我们从键盘读取数据,并没有直接给到用户(自己定义的变量中),而是将其放入到FILE结构体的语言级别缓冲区中(一个一个字符),
int a;scanf("%d", &a);就是将语言级别缓冲区中的一个一个字符格式化为int 类型数据
- 这是我们在【Linux】基础I/O----文件描述符与重定向的重定向引入部分的代码,当时我们关闭stdout文件,重定向到我们自己打开文件,printf写入到文件中,关闭之后,发现文件中并没有打印结果
- 当时我们先屏蔽close(fd)函数,或者在关闭前fflush(stdout)后,发现文件中写入了内容
- 这是因为printf是向stdout中、fd = 1的输出缓冲区中写入,但是我们关闭了fd,再进程结束,本来在进程结束后会将输出缓冲区的内容刷新到文件中,但是进程结束前文件就关闭了,因此刷新失败,文件中没有打印信息
- 所以如果我们不关闭文件,则进程结束自动刷新到文件中,或者关闭文件之前fflush(stdout)->调用write系统调用,将数据刷新到内核缓冲区中
- 同理C++中cin、cout、cerr也是一个class类,里面包含fd、缓冲区
注意:stderr是不带缓冲区
- 标准输出、标准错误都是显示器文件,从结果可以看到perror和cerr打印的信息并没有重定向写入到log.txt文件中,因为重定向(标准输出重定向)我们是将1文件(标准输出)重定向到log.txt,但是cerr和perror是写入到2的,并没有做标准错误重定向
- 所以根据这是性质,我们可以将正常输出和错误输出进行分离,
./test 1> OK.txt 2>Error.txt- 如果将标准输出标准错误写到一个文件中,使用
./test 1> OK.txt 2>&1
代码分析
- 下面有一个代码,分析结果
cpp
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
const char* s1 = "hello printf\n";
printf(s1);
const char* s2 = "hello, fprintf\n";
fprintf(stdout, s2);
const char* s3 = "hello, fwrite\n";
fwrite(s3, strlen(s3), 1, stdout);
const char* s4 = "hello write[syscall]\n";
write(1, s4, strlen(s4));
//创建子进程
fork();
return 0;
}
- 从运行结果可以看到,当默认打印到显示器中时,按照代码顺序,一条一条打印到了显示器上;但是我将打印结果重定向到log.txt文件中时,出来系统调用write的打印结果只打印了一份,其他都打印了两份
- printf、fprintf、fwrite函数将写入到FILE文件缓冲区中,由于显示器是行刷新策略,所以写入到语言级别缓冲区立即就被刷新到内核缓冲区中
- 但是当将printf、fprintf、fwrite写入内容重定向到普通文件时,普通文件默认是全刷新,因此会将写入内容一直保存到log.txt文件的FILE结构的缓冲区中,但是系统调用是直接写入到操作系统的内核缓冲区的,所以查看文件内容第一条是
hello write[syscall],然后执行fork函数,创建出来了子进程,子进程继承了父进程PCB、文件描述符表、以及打开文件,以及同样的FILE- 进程退出,自动刷新自己的缓冲区,因此父子进程都各自刷新缓冲区数据,因此前3个C语言接口的写入父子进程都刷新了一次
内核层面的刷新
- 内核缓冲区刷新即将内核缓冲区内容刷新到外设中
- 一般是全缓冲,显示器是行刷新。但是实际情况比较复杂,比如OS会根据内存的使用情况来动态刷新
- 一般刷新到内核中就不再需要用户去管理操作,但是如果想要刷新到内核缓冲区的数据立刻刷新到外设,我们可以使用系统调用----fsync函数

三、简单设计一个libc库
- 根据上面介绍的内容,设计一个简单的libc库,实现C接口文件I/O操作
- 创建三个文件,main.c测试文件,mystdio.h函数声明,mystdio.c函数定义
mystdio.h
mystdio.h文件,实现文件I/O操作的函数声明,包括文件打开、关闭、写入、刷新函数,定义FILE文件结构只定义输出缓冲区
cpp
#ifndef __MYSTDIO_H__
#define __MYSTDIO_H__
#define SIZE 4096
#define FLUSH_NONE 1
#define FLUSH_LINE 2
#define FLUSH_FULL 4
typedef struct _MY_IO_FILE
{
int fileno; // 文件描述符
int flag; //刷新方式
char outbuffer[SIZE];
int cur;
int cap;
}MyFILE;
MyFILE* my_fopen(const char* filename, const char* mode);
void my_fcolse(MyFILE* fp);
int my_fwrite(const char* s, int size, MyFILE* fp);
void my_fflush(MyFILE* fp);
mystdio.c
- mystdio.c文件中包含mystdio.h头文件,实现在mystdio.h声明的函数
my_fopen函数
- open函数,本质就是将文件FILE结构创建出来。
- 该函数底层调用open系统调用,根据mode的不同实现不同的打开模式
cpp
MyFILE* my_fopen(const char* filename, const char* mode)
{
int fd = -1;
if(strcmp(mode, "r") == 0)
{
fd = open(filename, O_RDONLY);
}
else if(strcmp(mode, "w") == 0)
{
fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, UMASK);
}
else if(strcmp(mode, "a") == 0)
{
fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, UMASK);
}
else if(strcmp(mode, "a+") == 0)
{
fd = open(filename, O_CREAT | O_RDWR| O_APPEND, UMASK);
}
else if(strcmp(mode, "w+") == 0)
{
fd = open(filename, O_CREAT | O_RDWR| O_TRUNC, UMASK);
}
else if(strcmp(mode, "r+"))
{
fd = open(filename, O_RDWR);
}
else
{}
if(fd < 0)
{
return NULL;
}
MyFILE* fp = (MyFILE*)malloc(sizeof(MyFILE));
if(!fp)
return NULL;
fp->fileno = fd;
fp->flag = FLUSH_LINE; // 默认行刷新
fp->cur = 0;
fp->cap = SIZE;
fp->outbuffer[0] = 0;
return fp;
}
my_fflush函数
- 根据不同的刷新规则进行刷新
- 但是如果我们强制进行刷新,也需要立刻刷新,因此我们再定义一个static的
my_fflush_core函数,供内部使用,在.h文件中定义宏 - 定义了一个内部才能调用的函数,外部刷新函数直接封装即可,我们调用my_fflush函数时,就是在没有满足刷新条件的时候进行强制刷新,传入参数FORCE
cpp
//mystdio.h
#define FORCE 1
#define NORMAL 2
static void my_fflush_core(MyFILE* fp, int force)
{
if(fp->cur <= 0)
return;
if(force == FORCE)
{
write(fp->fileno, fp->outbuffer, fp->cur);
fp->cur = 0;
}
//判断刷新条件是否满足
if((fp->flag & FLUSH_LINE) && fp->outbuffer[fp->cur - 1] == '\n')
{
write(fp->fileno, fp->outbuffer, fp->cur);
fp->cur = 0;
}
else if((fp->flag & FLUSH_FULL) && fp->cur == fp->cap)
{
write(fp->fileno, fp->outbuffer, fp->cur);
fp->cur = 0;
}
else
{
// write(fp->fileno, fp->outbuffer, fp->cur);
}
}
void my_fflush(MyFILE* fp)
{
my_fflush_core(fp, FORCE);
}
my_fwrite函数
my_fwrite(const char* s, int size, MyFILE* fp)函数的本质就是将s中的数据拷贝给MyFILE文件中的outbuffer缓冲区- 再根据刷新规则来判断是否需要刷新到内核中
cpp
int my_fwrite(const char* s, int size, MyFILE* fp)
{
memcpy(fp->outbuffer+fp->cur, s, size);
fp->cur += size;
//检测是否需要将缓冲区的内容刷新到内核中(正常刷新情况)
my_fflush_core(fp, NORMAL);
return size;
}
my_fclose函数
- 关闭文件时,自动刷新,调用my_fflush函数刷新到内核中
- 然后调用close函数关闭文件,将malloc申请的空间释放
cpp
void my_fcolse(MyFILE* fp){
if(fp->fileno >= 0)
{
my_fflush(fp);
close(fp->fileno);
free(fp);
}
}
main.c
- 验证代码,在s中的数据在末尾带上了'\n',验证行刷新策略
cpp
include "mystdio.h"
#include <string.h>
#include <unistd.h>
int main()
{
MyFILE* fp = my_fopen("log.txt", "w");
if(fp == NULL)
{
return 1;
}
const char* s = "hello linux \n";
int cnt = 20;
while(cnt--)
{
my_fwrite(s, strlen(s), fp);
sleep(1);
}
my_fcolse(fp);
return 0;
}
- 通过实时查看log.txt文件可以看到,字符在按行刷新到文件中
- 我将写入字符串后面的'\n'删掉,发现在运行过程中并没有刷新到文件中,而是进程结束后才看到文件中已经写入了字符串了










