【Linux】基础I/O----理解“一切皆文件“和缓冲区,并实现一个简单的libc库

目录

前言

书接上文【Linux】基础I/O----文件描述符与重定向详情请点击,今天继续介绍【Linux】基础I/O----理解"一切皆文件"和缓冲区,并实现一个简单的libc库

一、理解"一切皆文件"

  • 操作系统是软硬件资源的管理软件,所以操作系统也需要管理硬件(先组织、再描述)
  • 不同设备的访问方式是不同的,比如磁盘是读写设备,键盘是读设备,显示器是写设备
  • 访问硬件资源,是因为我们自己写的代码(程序)加载到内存,变为进程之后,进程需要访问硬件资源(读取键盘/磁盘数据、向显示器写入数据),访问设备,都是进程在访问
  • struct file文件中有一个const struct file_operations* f_op结构(函数指针表),file_operation 就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都对应着一个系统调用。读取 file_operation 中相应的函数指针,接着把控制权转交给函数,从而完成了Linux设备驱动程序的工作

二、缓冲区

什么是缓冲区

  • 缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区
  • 缓冲区的本质就是一段内存空间

引入缓冲区机制的原因

  • 引入缓冲区机制:提高了使用缓存的进程的效率,允许进程单位时间内做更多的任务
  • 类似于如果我需要将一份礼物送给远方的朋友,如果没有缓冲区(快递站),我只能自己亲自送过去,这样我自己的效率很低(不影响我朋友的效率),如果我将礼物交给驿站打包,由驿站将快递发出,这样我只需要将礼物交给驿站,之后我就能去干自己的事情,这样效率大大提高
  • 驿站发出包裹就是将数据刷新,但是驿站不会说来一个包裹就立马发送一个包裹,而是有了很多需要发送出去的包裹再将其全部发送出去。所以数据也是允许在缓冲区积压,一次刷新多次数据,减少I/O效率(批量化刷新)

语言层面的刷新策略

  • C/C++语言层面上刷新策略
  1. 无缓冲,立即刷新
  2. 有缓冲,行刷新(显示器)
  3. 有缓冲,写满再刷新(普通文件)----全缓冲
  • 两种情况
  1. 进程退出,主动刷新缓存
  2. 进程控制,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);当后面没有换行符时,不会立刻在显示器文件中看到打印信息,而是等程序结束之后主动刷新,这就是为什么程序结束之后才显示出来的原因,因为显示器是行刷新策略


总结
  1. 我们之前说的缓冲区是语言级别缓冲区,缓冲区在FILE内部
  2. 为什么要有语言级别缓冲区:
  • 系统调用是有成本,有了FILE结构之后,可以先将内容保存在语言级别缓冲区中,当满足刷新情况之后再一次性全部刷新到内核缓冲区中,减少了系统调用次数
  • 加速I/O函数的调用效率,printf、scanf、fgets、fputs只需要写入或者读取语言缓冲区的内容,这样能快速返回,执行后面的任务,使用C语言I/O结构的效率提高
  1. 重新理解printf、scanf格式化:int a = 123;printf("%d", a);将整型类型格式化为单个字符,保存在文件缓冲区中,检查是否需要刷新,如果满足刷新条件,调用write;
  2. 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'删掉,发现在运行过程中并没有刷新到文件中,而是进程结束后才看到文件中已经写入了字符串了
相关推荐
安科士andxe8 小时前
深入解析|安科士1.25G CWDM SFP光模块核心技术,破解中长距离传输痛点
服务器·网络·5g
小白同学_C11 小时前
Lab4-Lab: traps && MIT6.1810操作系统工程【持续更新】 _
linux·c/c++·操作系统os
今天只学一颗糖11 小时前
1、《深入理解计算机系统》--计算机系统介绍
linux·笔记·学习·系统架构
2601_9491465311 小时前
Shell语音通知接口使用指南:运维自动化中的语音告警集成方案
运维·自动化
儒雅的晴天11 小时前
大模型幻觉问题
运维·服务器
Gofarlic_OMS12 小时前
科学计算领域MATLAB许可证管理工具对比推荐
运维·开发语言·算法·matlab·自动化
通信大师12 小时前
深度解析PCC策略计费控制:核心网产品与应用价值
运维·服务器·网络·5g
dixiuapp13 小时前
智能工单系统如何选,实现自动化与预测性维护
运维·自动化
不做无法实现的梦~13 小时前
ros2实现路径规划---nav2部分
linux·stm32·嵌入式硬件·机器人·自动驾驶
Elastic 中国社区官方博客13 小时前
如何防御你的 RAG 系统免受上下文投毒攻击
大数据·运维·人工智能·elasticsearch·搜索引擎·ai·全文检索