【Linux】基础IO(二)

发布于:2024-12-06 ⋅ 阅读:(249) ⋅ 点赞:(0)


继续学习文件相关内容:理解Linux下一切皆文件,缓冲区的概念,最后简易实现一下C语言文件流相关操作,加深对缓冲区及对系统接口的封装的理解。

一切皆文件

如何理解LInux下一切皆文件? 首先,在windows中是文件的东西,它们在linux中也是文件;其次一些在windows中不是文件的东西,比如进程、磁盘、显示器、键盘这样硬件设备也被抽象成了文件,你可以使用访问文件的方法访问它们获得信息;甚至管道,也是文件;将来我们要学习网络编程中的socket(套接字)这样的东西,使用的接口跟文件接口也是⼀致的。

这样做最明显的好处是,开发者仅需要使用⼀套 API 和开发工具,即可调取 Linux 系统中绝⼤部分的资源。举个简单的例子,Linux 中几乎所有读(读文件,读系统状态,读PIPE)的操作都可以用read函数来进行;几乎所有更改(更改文件,更改系统参数,写 PIPE)的操作都可以用write函数来进行。

操作系统管理软硬件资源,对于软资源的文件化理解都还可以接受,那么硬件资源是如何文件化的呢?怎么做到把硬件也一文件的视角看待呢?

在冯诺依曼体系中,外设必须把资源加载到内存中才能被执行。这些外设有很多,如显示器,键盘,网卡等;它们有各自不同的特性,但是他们都必须共有一个特性——支持IO操作。但是不同的设备的IO方式可能会有所不同,但对于操作系统而言,我只需要提供一个指向硬件对应方法的方式即可,具体的方法由硬件厂商自己提供,由此一来,OS就能对硬件进行读写操作。

再以内核的视角看:当打开⼀个文件时,操作系统为了管理所打开的件,都会为这个文件创建⼀个file结构体,由这个file就找到对应文件。
那么对于这个文件而言,是如何读取键盘输入的内容的呢? 在文件对象file中,还有一个f_op的指针指向file_operations结构体,该结构体中包含了大量的函数指针,这些函数指针就是OS提供的指向硬件所提供的读写方法,所以,当该文件进行读写时,就能通过这些函数指针访问硬件资源了。

f_op
这些函数指针,VFS(虚拟文件系统)定义了统一的文件操作接口,使得应用程序可以通过统一的接口来与不同的设备进行交互。file_operation 就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每⼀个成员都对应着⼀个系统调用。读取 file_operation 中相应的函数指针,接着把控制权转交给函数,从而完成了Linux设备驱动程序的工作。

一切皆文件
上图中的外设,每个设备都可以有自己的readwrite,但⼀定是对应着不同的操作方法!!但通过struct file 下 file_operation 中的各种函数回调,让我们开发者只用file便可调取 Linux 系统中绝大部分的资源!!这便是“Linux下⼀切皆文件”的核心理解

缓冲区

什么是缓冲区

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

为什么要引入缓冲区机制

读写文件时,如果不会开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行⼀次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行⼀次系统调用,执行以次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗⼀定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。

为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以⼀次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大文快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。

又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是⼀块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调⼯作,避免低速的输⼊输出设备占用CPU,解放出CPU,使其能够高效率⼯作。

就如同你到菜鸟驿站取快递,菜鸟驿站承担的就是缓冲区的责任,不用客户频繁去取快递,节省了客户的时间。
快递站

缓冲类型

标准I/O提供了3种类型的缓冲区。

  • 全缓冲区:这种缓冲方式要求填满整个缓冲区后才进行I/O系统调调操作。对于磁盘文件的操作通常使用全缓冲的方式访问。
  • 行缓冲区:在行缓冲情况下,当在输入和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作。当所操作的流涉及⼀个终端时(例如标准输⼊和标准输出),使用行缓冲方式。因为标准I/O库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行I/O系统调用操作,默认行缓冲区的大小为1024。
  • 无缓冲区:无缓冲区是指标准I/O库不对字符进行缓存,直接调用系统调用。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。

除了上述列举的默认刷新方式,下列特殊情况也会引发缓冲区的刷新:

  1. 缓冲区满时;
  2. 执行fflush语句强制刷新;

来看以下例子感受三种缓冲方式

int main()
{
    close(1);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0)
    {
        perror("open");
        return 0;
    }
    printf("hello world: %d\n", fd);
    close(fd);
    return 0;
}

我们本来想使用重定向思维,让本应该打印在显示器上的内容写到“log.txt”文件中,但我们发现,程序运行结束后,文件中并没有被写入内容
重定向

这是由于我们将1号描述符重定向到磁盘文件后,缓冲区的刷新方式成为了全缓冲。而我们写入的内容并没有填满整个缓冲区,导致并不会将缓冲区的内容刷新到磁盘文件中。怎么办呢?可以使用fflush强制刷新下缓冲区。

fflush

其中,标准出错流stderr通常是不带缓冲区的,如果我们使用标准出错流stderr演示上述代码,理论上不需要fflush也能将内容刷新到磁盘文件。

int main()
{
    close(2);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0)
    {
        perror("open");
        return 0;
    }
    perror("hello world");
    close(fd);
    return 0;
}
  • perror对应使用的就是stderr

可以看到确实不需要使用fflush就能将内容输出到文件中,所以证实stderr流是不带缓冲区的,采用的是无缓冲的刷新方式。
stderr

  • perror用于打印错误信息,此时没有错误,所以会有个success

所以这个缓冲区在哪?

FILE

FILE是C语言封装的一个结构体,因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。所以C库当中的FILE结构体内部,必定封装了fd。

如以下示例:
使用C语言的方式以只写的方式打开一个文件,再使用FILE_fileno成员向系统调用接口write来向对应文件写入。

//FILE的封装
int main()
{
    FILE* fp=fopen("log.txt","w");
    if(!fp)
    {
        perror("fopen failed\n");
        return -1;
    }
    const char*msg="hello fopen\n";
    write(fp->_fileno,msg,strlen(msg));
    printf("fp->_fileno:%d\n",fp->_fileno);
    fclose(fp);
}

不出意料,打开的文件的fd为3,而且FILE_fileno指向的就是我们所打开的文件(fd=3),内容也最终通过系统调用write写到了文件中。所以FILE只是C语言文件操作封装的一个结构体,其内部必定包含了系统调用所打开文件按的fd,也就是FILE中的_fileno成员

FILE

在C语言的角度,进行IO操作都是通过三个流stdinstdoutstderr来完成的。所以,这个缓冲区实际上就在封装的FILE结构体当中。 也就是说,用户层会有一个缓冲区,等到必要时再使用系统调用将缓冲区内容刷新到内核中。

struct _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
};

通过以下demo来理解用户层缓冲区;使用三种方式向屏幕输出,其中printffwrite为库函数write为系统调用。

int main()
{
    const char *msg0 = "hello printf\n";
    const char *msg1 = "hello fwrite\n";
    const char *msg2 = "hello write\n";

    //库函数:输出到屏幕
    printf("%s", msg0);
    fwrite(msg1, strlen(msg0), 1, stdout);
    //系统调用:输出到屏幕
    write(1, msg2, strlen(msg2));

    fork();
    return 0;
}

结果也符合我们的认知。
缓冲区

如果将该内容重定向输入到文件中呢?./file >log.txt

缓冲区

此时会发现,我们发现printffwrite(库函数)都输出了2次,而write只输出了⼀次(系统调用)且输出的顺序有所不同,write本应该是最后一个写入的,但是这里却成了第一个。

为什么同一份代码,当重定向之后却是不同的结果?这时候就需要结合fork和缓冲区刷新方式来看待这个问题了;

⼀般C库函数写如文件时是全缓冲的,而写入显示器是行缓冲。printf fwrite 库函数会自带缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。而我们放在缓冲区中的数据,就不会被立即刷新,直到缓冲区写满或者进程退出时会统⼀刷新写入文件当中。而fork的时候,父子进程会共享同一份代码和数据,当父子进程缓冲区准备刷新的时候,会发生写时拷贝,至此父子进程就会有独立拥有一份同样的数据。所以当父子进程结束后会各自将自己的缓冲区内容刷新到文件中,所以使用库函数的printf fwrite打印的语句会有两份;而write作为系统调用没有用户层缓冲区,直接会把内容写道内核中输出到文件,这也是为什么打印的write语句最先看到并且只有一份的原因

  • write有自己对应的内核级缓冲区,此时我们认为内容刷新到内核中就可以刷新到文件即可。

简易模拟C语言文件流

使用

test.c

#include"mystdio.h"

int main()
{
    MY_FILE*pf=MY_fopen("log.txt","w");
    if(!pf)
    {
        perror("MY_fopen failed\n");
        return -1;
    }

    int cnt=5;
    const char*str="hello world\n";
    
    while(cnt--)
    {
        MY_fwrite(str,strlen(str),1,pf);
    }

    MY_fclose(pf);

    return 0;
}

// int main()
// {
//     MY_FILE*pf=MY_fopen("log.txt","r");
//     if(!pf)
//     {
//         perror("MY_fopen failed\n");
//         return -1;
//     }
//     char buffer[64];
//     size_t sz=MY_fread(buffer,sizeof(buffer),1,pf);
//     printf("%s",buffer);

//     MY_fclose(pf);

//     return 0;
// }

头文件

stdio.h

#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

#define SIZE 1024
#define FLUSH_NOW 0//立即刷新
#define FLUSH_LINE 1//行刷新
#define FLUSH_ALL 2//满了再刷


struct IO_FILE//简易设计的文件类;模仿FILE
{
    int file_no;//文件描述符fd
    int flag;//刷新方式
    char buffer[SIZE];//模拟的缓冲区
    int size;//下标
    int capacity;//缓冲区大小
};

typedef struct IO_FILE MY_FILE;//模拟FILE

MY_FILE* MY_fopen(const char *pathname, const char *mode);//打开文件
int MY_fclose(MY_FILE *stream);//关闭文件
size_t MY_fread(void *ptr, size_t size, size_t nmemb, MY_FILE *stream);//读取文件
int MY_fflush(MY_FILE *stream);//将缓冲区内容刷新到内核缓冲区中
size_t MY_fwrite(const void *ptr, size_t size, size_t nmemb,MY_FILE *stream);//写入文件

模拟实现

stdio.c

#include"mystdio.h"

MY_FILE* MY_fopen(const char *pathname, const char *mode)
{
    assert(pathname && mode);
    //打开方式
    int fd=-1;
    if(strcmp(mode,"r")==0)
    {
        fd=open(pathname,O_RDONLY);
    }
    else if(strcmp(mode,"w")==0)
    {
        fd=open(pathname,O_WRONLY|O_CREAT|O_TRUNC,0666);
    }
    else if(strcmp(mode,"a")==0)
    {
        fd=open(pathname,O_WRONLY|O_CREAT|O_APPEND,0666);
    }
    if(fd<0)
    {
        perror("open failed\n");
        return NULL;
    }

    MY_FILE*pf=(MY_FILE*)malloc(sizeof(MY_FILE));
    //初始化FILE
    pf->file_no=fd;
    pf->capacity=SIZE;
    pf->size=0;
    pf->flag=FLUSH_LINE;//默认行刷新
    memset(pf->buffer,0,SIZE);

    return pf;

}


int MY_fflush(MY_FILE *stream)
{
    assert(stream);
    if(stream->size>0)
    {
        write(stream->file_no,stream->buffer,stream->size);//'\0'注意
        stream->size=0;
    }

}
size_t MY_fwrite(const void *ptr, size_t size, size_t nmemb,MY_FILE *stream)
{
    // 写入先判断缓冲区空间是否足够
    if ((stream->size+size+1) >= SIZE) MY_fflush(stream);

    strcpy(stream->buffer+stream->size,ptr);//连"\0"也拷
    stream->size+=size;

    //检查是否刷新
    if(stream->flag==FLUSH_LINE&&stream->size>0&&stream->buffer[stream->size-1]=='\n')
    {
        MY_fflush(stream);
    }
    return size;
}

size_t MY_fread(void *ptr, size_t size, size_t nmemb, MY_FILE *stream)
{
    
    ssize_t sz=1;
    size=0;
    while(sz)
    {
        sz=read(stream->file_no,stream->buffer,SIZE);
        size+=sz;
    }
    stream->buffer[size] = '\0';
    strcpy(ptr, stream->buffer);
    return size;
}


int MY_fclose(MY_FILE *stream)
{
    //关闭前需要把缓冲数据刷新到内核缓冲区中。
    assert(stream);
    MY_fflush(stream);
    int rfd=close(stream->file_no);
    free(stream);//释放
    if(rfd!=0)//失败
    {
        return -1;
    }
    return rfd;
}
  • 简易实现,禁不起测试

通过以上简易实现C语言文件流操作,帮助我们更好理解函数调用与系统调用的关系;认识缓冲区:用户层及内核层。


网站公告

今日签到

点亮在社区的每一天
去签到