前言:
上節課我們講授重定向的概念時,曾提到了一點緩沖區的概念。本文將會為大家更詳細的帶來緩沖區的有關內容:用戶級緩沖區是什么,以及其與內核級緩沖區的關系,最后,我會為大家模擬實現一下stdio.h的關于FILE結構體的有關內容,當然,這個模擬只是從原理上實現。真正的stdio.h肯定會更加復雜。
一、再談緩沖區
我們上篇文章鏈接曾做了一個實驗:
這里我們加了一個fflush之后,就可以在文件中看到我們的輸出結果了。當時我們解釋的是“?printf
?的輸出被緩沖在內存中,尚未寫入文件,而程序結束時沒有觸發緩沖區的自動刷新。”?
這句話還是太抽象了,那么更加具體點的解釋是什么呢?
我們曾經說過,內核級緩沖區的出現,是為了提高IO的效率,讓數據積累后再一次性保存在文件中,而不是一次一次的連續IO。
那么我們的C語言,同樣也是為了提高效率,也實現了一個緩沖區,而這個緩沖區被我們稱為用戶級緩沖區。我們調用的printf,fprintf,fputs實際上都是C語言封裝后的函數。這個緩沖區也恰好對應了這些函數。
在stdio.h文件中,FILE實際上是struct _IO_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? };
大家可以看到,在?_IO_FILE結構體中,有一段關于緩沖區的代碼,而這個緩沖區,就是我們說得用戶級的緩沖區。
它存在的意義與內核級緩沖區是一模一樣的,調用printf等C語言封裝后的函數,把數據拷貝到用戶級緩沖區里,隨后根據一定的條件,把堆積的數據拷貝到內核級緩沖區。
所以printf等一系列的C語言函數本質上,也是一個拷貝函數。
如果我們的目標文件是顯示器文件,那么這個拷貝條件就是行刷新,當檢測到\n時就會拷貝到內核級緩沖區。如果是普通文件,對應的就是全緩沖,即等待緩沖區寫滿后再刷新。
只要把數據從用戶級緩沖區拷貝到了內核級緩沖區,我們就認為把數據交給了操作系統,這個數據就和用戶無關了。
二、重定向與緩沖區
仍然是這個代碼:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
? ? close(1);
? ? int fd=open("log1.txt",O_WRONLY | O_CREAT | O_TRUNC, 0666);
? ? printf("%d\n",fd);
? ? return 0;
}
如果我們把close(fd)刪除掉,那又是什么情況呢?
?
我們可以看見,重定向后,雖然不能打印到顯示屏,但是文件中是存在打印的的結果的。
為什么把后面的close(fd)去掉后,文件中就存在打印結果了呢?
?先不著急回答,我們再把后面的close換成fclose:
可以看見,fclose也可以達成目標。
所以我們可以告訴大家原因了,當一個進程退出時,?它會自動刷新自己的用戶級緩沖區,但當你調用系統的close把fd關掉后,它想要把數據刷新到操作系統內部都沒有機會了。
而C語言的fflush與close也都只是把用戶級緩沖區的數據刷新到內核級緩沖區,最多也就是fclose會釋放?FILE
?結構體,并調用?close(fd)
?關閉底層文件描述符。
由于我們重定向了,導致原本打印1到顯示器文件的行緩沖模式變成了全緩沖,導致不會因為‘\n’而刷新數據。
把用戶級緩沖區的數據刷新到內核級我們可以調用fflush,那有沒有什么方式把內核級緩沖區數據刷新到文件里?
有的:
我們可以調用fsync系統調用接口。
總的來說,C語言之所以存在緩沖區,就是為了提高效率,也就是說,C語言從設計上,就十分注重效率。
那C++有沒有自己的用戶級緩沖區呢?
肯定是有的。但是C++的緩沖區效率沒有C語言高,所以才會出現在一些十分注重時間復雜度的算法題上面,出現同樣結構的思維的代碼,出現使用printf可以通過,但是使用cout無法通過的現象。
?
三、子進程與緩沖區
請看下面的代碼:
int main()
{
? ? //C庫函數
? ? pinrtf("hello printf\n");
? ? fprintf(stdout,"hello fprintf\n");
? ? const char*message="hello fwrite\n";
? ? fwrite(message,strlen(message),1,stdout);
? ? //系統調用
? ? const char*w="hello write";
? ? write(1,w,strlen(w));
? ?
? ?
? ? fork();
? ? return 0;
}
它的運行結果是:?
很好,看起來沒有問題,那我們試著重定向一下呢?
?
誒,為什么重定向后,C語言的打印就都執行了兩次,系統調用的打印只執行了一次呢?
這是因為重定向后,用戶級緩沖區的緩沖方式變成全緩沖,父子進程結束后各自fflush了一次到內核級緩沖區?,導致C語言中,拷貝到用戶級緩沖區的數據,各自被刷新到了內核級緩沖區一次,于是當內核級緩沖區的數據刷新到文件中時,就出現了這個現狀。
當我們把\n取消時:
int main()
{
? ? //C庫函數
? ? printf(" hello printf ");
? ? fprintf(stdout," hello fprintf ");
? ? const char*message=" hello fwrite ";
? ? fwrite(message,strlen(message),1,stdout);
? ? //系統調用
? ? const char*w="hello write\n";
? ? write(1,w,strlen(w));
? ?
? ?
? ? fork();
? ? return 0;
}
打印結果如下:
?
這就是因為,在打印到顯示器文件時,緩沖方式為行緩沖,我們沒有遇見\n,同樣導致了重復的刷新。
如果想要達成正確的打印效果,就需要子進程之前使用fflush刷新用戶級緩沖區。
四、模擬實現stdio.h?
相信前面的用例,已經能夠讓大家較為清楚的理解到緩沖區與重定向之間的精密聯系了。
那么現在我就來帶大家簡答的模擬實現一下stdio中的FILE結構體,與fwrite,fclose,fflush,fopen函數吧!!
(注意,本次模擬只是為了讓大家更能理解原理,真正的實現肯定有所不同)
?
我們先不管C標準庫中是什么樣子的,我們只知道,有.h文件,就自然要有.c文件來實現;
首先,我們先根據所知道的知識,把基礎的FILE結構體與這四個函數的聲明寫到.h文件里:
#pragma once#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<fcntl.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<stdlib.h>#define SIZE 1024//定義刷新方式
#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2struct _OI_FILE
{int flag;//刷新方式int fileno;//文件描述符char outbuffer[SIZE];//緩沖區int size;int cap;//TODO
};typedef struct _OI_FILE mFILE;mFILE *mfopen(const char *pathname, const char *mode);
int mfflush(mFILE *_stream);
size_t mfwrite(const void *ptr,int size, mFILE *stream);
int mfclose(mFILE *stream);
隨后,我們要在.c文件里實現:
第一個就是fopen函數了,首先,我們要先根據傳入的打開方式,通過open把文件描述符獲取到。而打開方式的判斷,我們選擇用if語句與strcmp相結合。
隨后,我們要為這個文件創建一個FILE結構體對象,并對里面的參數進行初始化:
設置緩沖區的大小,文件標識符,刷新方式,以及容量,這里的容量我們選擇在.h中定義一個SIZE為1024常數,當然,要記得判斷一下是否失敗。
:
mFILE *mfopen(const char *pathname, const char *mode)
{int fd=-1;// 根據打開模式選擇不同的文件打開方式 if(strcmp(mode,"w")==0) // 寫入模式:創建/截斷文件 {fd=open(pathname,O_WRONLY | O_CREAT | O_TRUNC, 0666);}else if(strcmp(mode,"r")==0) // 只讀模式 {fd=open(pathname,O_RDONLY);}else if(strcmp(mode,"a")==0) // 追加模式 {fd=open(pathname,O_WRONLY | O_CREAT | O_APPEND, 0666);}// 檢查文件是否成功打開 if(fd<0){return NULL;}// 分配mFILE結構體內存 mFILE* mf=malloc(sizeof(mFILE));// 內存分配失敗處理 if(!mf){close(fd);return NULL;}// 初始化結構體字段 mf->flag=FLUSH_LINE; // 默認行緩沖模式 mf->fileno=fd; // 設置文件描述符 mf->size=0; // 初始化緩沖區大小為0 mf->cap=SIZE; // 設置緩沖區容量 return mf;
}
隨后就是fwrite拷貝函數,他最主要的作用就是通過memcpy函數將數據拷貝到用戶級緩沖區:
我們要注意的是,size參數始終代碼我們的大小,所以outbuffer[size-1]就代表當前數據的最后一個字符。所以我們可以通過這個特性找到memcpy的初始地址,以及判斷是否最后一個字符為\n。根據我們的刷新方式參數以及size的大小是否不為0,來調用我們的mfflush刷新。
而fflush的刷新就更加簡單,我們只需要判斷size大小是否大于0,隨后調用系統接口write,將用戶級緩沖區的內容拷貝到內核級緩沖區就行了。
int mfflush(mFILE *_stream)
{// 檢查緩沖區是否有數據需要刷新 if(_stream->size>0){// 將緩沖區數據寫入文件 write(_stream->fileno,_stream->outbuffer,_stream->size);_stream->size=0; // 重置緩沖區大小 }return 0; // 總是返回成功
}size_t mfwrite(const void *ptr,int size, mFILE *stream)
{// 將數據拷貝到緩沖區 memcpy(stream->outbuffer+stream->size,ptr,size);stream->size+=size; // 更新緩沖區當前大小 // 根據刷新模式決定是否立即刷新 if(stream->flag==FLUSH_LINE && stream->size>0&&stream->outbuffer[stream->size-1]=='\n') // 行緩沖且遇到換行符 {mfflush(stream);}else if(stream->flag==FLUSH_FULL&&stream->size>=stream->cap) // 全緩沖且緩沖區滿 {mfflush(stream);}return size; // 返回成功寫入的字節數
}
最后只剩下一個mfclose。這個函數又該怎么實現呢?
我們需要先明確一下該函數應該完成的任務:
-
刷新緩沖區:如果緩沖區還有未寫入的數據(size > 0),調用mfflush寫入內核緩沖區。
-
關閉文件描述符:使用close()?關閉 fileno文件描述符的文件。
-
釋放內存:釋放 mFILE 結構體占用的內存。
所以具體實現如下:
int mfclose(mFILE *stream)
{if (stream == NULL) {return -1; // 錯誤:傳入空指針}// 1. 刷新緩沖區(如果還有未寫入的數據)if (stream->size > 0) {mfflush(stream);}// 2. 關閉文件描述符int ret = close(stream->fileno);if (ret < 0) {// 關閉失敗,但仍然需要釋放內存free(stream);return -1;}// 3. 釋放 mFILE 結構體free(stream);return 0; // 成功
}
?實現還是比較簡單的,最重要的就是理解這些函數干了什么事情,達成了什么效果,在內部一一通過系統調用或者函數的復用來實現。
另外,請注意我們應該把在.c文件中用到的各種函數、系統調用的相關頭文件,在.h中進行聲明。
最后,我們可以添加一個測試用例:
?
int main()
{mFILE *mf = mfopen("test.txt", "w");if (!mf) {perror("mfopen failed"); // 打印錯誤信息return 1;}size_t written = mfwrite("Hello", 5, mf); // written = 5mfclose(mf);return 0;
}
?
?
總結:
?
?本文我們繼續詳細談了緩沖區有關的概念,并為大家模擬實現了FILE結構體的有關內容,希望通過這些知識點,能夠幫助你更加了解操作系統中,文件的緩沖區的相關知識。
如果有任何疑問與指正歡迎私信或者評論區留言