C 标准 I/O 库粗略实现

2018-01-17 10:35:12来源:https://github.com/zhangyachen/zhangyachen.github.io/issues/作者:Github人点击

分享
第七城市

写一下fopen/getc/putc等C库的粗略实现,参考了K&R,但是有几点根据自己理解的小改动,下面再具体说一下^_^


写这篇文章主要是帮助自己理解下标准I/O库大体是怎么工作的。


fopen与open之间的关系

操作系统提供的接口即为系统调用。而C语言为了让用户更加方便的编程,自己封装了一些函数,组成了C库。而且不同的操作系统对同一个功能提供的系统调用可能不同,在不同的操作系统上C库对用户屏蔽了这些不同,所谓一次编译处处运行。这里open为系统调用,fopen为C库提供的调用。



C库对的读写操作封装了一个缓冲区。试想假如用户频繁的对文件读写少量字符,会频繁的进行系统调用(read函数),而系统调用比较耗时。C库自己封装了一个缓冲区,每次读取特定数据量到缓冲区,读取时优先从缓冲区读取,当缓冲区内容被读光后才进行系统调用将缓冲区再次填满。



FILE结构体

上面我们看到一个结构体,里面有5个参数,分别记录了:缓冲区剩余的字符数cnt、下一个字符的位置ptr、缓冲区的位置base、文件访问模式flag、文件描述符fd。


其中文件描述符就是系统调用open返回的文件描述符fd,是int类型。ptr与base上面图中已经展示了。cnt是缓冲区剩余字符数,当cnt为0时,会系统调用read来填满缓冲区。flag为文件访问模式,记录了文件打开方式、是否到达文件结尾等。


结构体的具体定义如下,对应调用fopen返回的文件指针 FILE *fp = fopen(xxx,r) :


typedef struct _iobuf{
int cnt;
//缓冲区剩余字节数
char *base;
//缓冲区地址
char *ptr;
//缓冲区下一个字符地址
int fd;
//文件描述符
int flag;
//访问模式
} FILE; //别名,与标准库一致

结构体中有flag字段,flag字段可以是以下几种的并集:


enum _flags {
_READ = 1,
_WRITE = 2,
_UNBUF = 4,
//不进行缓冲
_EOF = 8,
_ERR = 16
};

我们注意到其中有一个字段,标识不进行缓冲,说明此种情况下每一次读取和输出都调用系统函数。一个例子就是标准错误流stderr : 当stderr连接的是终端设备时,写入一个字符就立即在终端设备显示。


而stdin和stdout都是带缓冲的,明确的说是行缓冲。本文不考虑行缓冲,默认都是全缓冲,即缓冲区满了才刷新缓冲区。(详细可以参考《UNIX环境高级编程》标准I/O库章节)。


现在我们可以初始化stdin、stdout与stderr:


FILE _iob[OPEN_MAX] = {
{0,NULL,NULL,_READ,0},
{0,NULL,NULL,_WRITE,1},
{0,NULL,NULL,_WRITE|_UNBUF,2}
};
_ferror/_feof/_fileno
//判断文件流中是否有错误发生
int _ferror(FILE *f){
return f-> flag & _ERR;
}
//判断文件流是否到达文件尾
int _feof(FILE *f){
return f-> flag & _EOF;
}
//返回文件句柄,即open函数的返回值
int _fileno(FILE *f){
return f->fd;
}
_fopen
FILE *_fopen(char *file,char *mode){
int fd;
FILE *fp;
if(*mode != 'r' && *mode != 'w' && *mode != 'a') {
return NULL;
}
for(fp = _iob; fp < _iob + OPEN_MAX; fp++) { //寻找一个空闲位置
if (fp->flag == 0){
break;
}
}
if(fp >= _iob + OPEN_MAX){
return NULL;
}
if(*mode == 'w'){
fd = creat(file,PERMS);
}else if(*mode == 'r'){
fd = open(file,O_RDONLY,0);
}else{
//a模式
if((fd = open(file,O_WRONLY,0)) == -1){
fd = creat(file,PERMS);
}
lseek(fd,0L,2);
//文件指针指向末尾
}
if(fd == -1){
return NULL;
}
fp->fd = fd;
fp->cnt = 0;
//fopen不分配缓存空间
fp->base = NULL;
fp->ptr = NULL;
fp->flag = *mode == 'r' ? _READ : _WRITE;
return fp;
}

fopen的处理过程:


判断打开模式的合法性。
在_iob中寻找一个空闲位置,找不到的话说明程序打开的文件数已经到达的最大值,不能再打开新的文件。
如果是w模式,创建一个新文件。如果是r模式,以只读方式打开文件。如果是a模式,首先打开文件,如果打开失败则创建文件,否则通过系统调用lseek将文件指针置到末尾。
对FILE结构体进行初始化,注意fopen不会分配缓冲区。
_getc

getc的作用是从文件中返回下一个字符,参数是文件指针,即FILE:


int _getc(FILE *f){
return --f->cnt >= 0 ? *f->ptr++ : _fillbuf(f);
}

对照上面的图示:当缓冲区中还有剩余字符待读取时,读取该字符并返回,并将缓冲区指针向后移动一个char单位,否则就调用_fillbuf函数填满缓冲区,_fillbuf的返回值就是待读取的字符。


这里有一个问题:当读取到最后一个字符时,cnt为0,但是ptr已经越界了,如下图:



这种情况虽然是非法的,但是C语言中保证:数组末尾之后的第一个元素(即&arr[n],或者arr + n)的指针算术运算可以正确执行。下面的例子也是上述的一种应用场景:


int a[10] = {1,2,3,4,5,6,7,8,9,10};
for(int *x = a;x < a + 10; x++){
printf("%d/n",*x);
}

当for循环到最后一步时,x也指向了a[10],虽然是非法的,但是C语言保证可以正确执行,只要不出现如下情况就ok:


int a[10] = {1,2,3,4,5,6,7,8,9,10};
int *x;
for(x = a;x < a + 10; x++){
printf("%d/n",*x);
}
*x = 11; //越界进行值访问
_fillbuf

我们看下_getc中的_fillbuf的实现,_fillbuf是当缓冲区没有可以读取的字符时,通过系统调用read读取一定字节的数据填满缓冲区,供之后使用:


int _fillbuf(FILE *f){
int bufsize;
if((f->flag & (_READ | _EOF | _ERR)) != _READ){ //判断文件是否可读
return EOF;
}
bufsize = f->flag & _UNBUF ? 1 : BUFSIZ;
if(f->base == NULL){
//没有分配过缓冲区
if((f->base = (char *)malloc(bufsize)) == NULL){
return EOF;
}
}
f->ptr = f->base;
int n = read(f->fd,f->ptr,BUFSIZ);//系统调用read
if(n == 0){ //到达文件结尾
f->base = NULL;
f->cnt = 0;
f-> flag |= _EOF;
return EOF;
}else if(n == -1){//出错
f->cnt= 0;
f->flag |= _ERR;
return EOF;
}else{
f->cnt = --n;
return *f->ptr++;
}
}

_fillbuf的处理过程:


判断文件是否可读
判断调用read函数时应该读取的字节数,当文件设置了无缓冲时,读取1个字节,否则读取BUFSIZ个字节,BUFSIZ在<stdlib.h>中定义,定义了在该操作系统条件下缓冲区的最佳大小。
判断是否分配过缓冲区(fopen不会分配缓冲区,会再第一次调用getc时分配)。
调用系统函数read。
判断read返回值,分为到达文件结尾、出错和正常读取三种情况。
正常情况下返回缓冲区第一个字符给getc函数,并将cnt减1。

这里注意,到达文件结尾和出错都是返回EOF,区别是前者会将flag的_EOF位置1,后者会将flag的_ERR位置1,上游可以通过 feof 和 ferror 函数进行判断(这两个函数在上面已经实现过了)。


The character read is returned as an int value.
If the End-of-File is reached or a reading error happens, the function returns EOF and the corresponding error or eof indicator is set. You can use either ferror or feof to determine whether an error happened or the End-Of-File was reached.
_putc
int _putc(int x,FILE *f){
return --f->cnt >= 0 ? *f->ptr++ = x : _flushbuf(x,f);
}

与_getc的实现相似,将写入的字符放到ptr指向的位置,并将ptr向后移动一位。当缓冲区满时,调用_flushbuf将缓冲区内容刷新到文件中。


_flushbuf
int _flushbuf(int x,FILE *f){
if((f->flag & (_WRITE | _EOF | _ERR)) != _WRITE){ //判断文件是否可写
return EOF;
}
int n;
int bufsize = f->flag & _UNBUF ? 1 : BUFSIZ;
if(f->base != NULL){
n = write(f->fd,f->base,f->ptr - f->base);//判断需要写入多少字节
if(n != f->ptr - f->base){
f->flag |= _ERR;
return EOF;
}
}else{
if((f->base = (char *)malloc(bufsize)) == NULL){
f->flag |= _ERR;
return EOF;
}
}
if(x != EOF){
f->cnt = bufsize - 1;
f->ptr = f->base;
*f->ptr++ = x;
}else{//当写入EOF时,代表强制刷新缓冲区内容到文件中
f->cnt = bufsize;
f->ptr = f->base;
}
return x;
}

_flushbuf的处理过程:


判断文件是否可写。
当已分配过缓冲区时,将缓冲区的内容通过系统调用write写入文件中。
当没有分配过缓冲区时,分配缓冲区。
判断当写入的字符为EOF时,说明调用此函数的目的为强制刷新缓冲区,不写入字符。将cnt赋值为BUFSIZ,ptr赋值为缓冲区首地址base。
当写入字符不为EOF时,说明缓冲区已满,需要将缓冲区刷新到文件中。cnt为BUFSIZE - 1,将写入的字符x放到到缓冲区的第一格,然后将ptr向后移动一个char单位。

注意,调用write函数时,写入的字节数不能写死为1或者BUFSIZ:


n = write(f->fd,f->base,f->ptr - f->base);//判断需要写入多少字节


如上图,我们需要写入base至ptr之间的数据,而不是BUFSIZ,因为我们可能会强制刷新缓冲区而不是等到缓冲区满了才刷新缓冲区。


还有一点:当我们想要强制刷新缓冲区时,第一个参数x该传入什么呢?K&R传递的是字符0,但是我认为这样会污染缓冲区,所以我的实现是传入一个特殊字符EOF,根据EOF来做不同的处理:


if(x != EOF){
f->cnt = bufsize - 1;
f->ptr = f->base;
*f->ptr++ = x;
}else{//当写入EOF时,代表强制刷新缓冲区内容到文件中
f->cnt = bufsize;
f->ptr = f->base;
}

当缓冲区满时,刷新缓冲区后缓冲区的表现:



当强制刷新缓冲区时,缓冲区的表现:



但是按照K&R的方式来强制刷新缓冲区时,缓冲区的表现:



这样会污染缓冲区,所以我的实现是传入EOF来强制刷新缓冲区。


_fflush

_fflush(FILE *f)的作用是把缓冲区内容写入文件。当参数为空时,会刷新所有文件:


int _fflush(FILE *f){
int res = 0;
if(f == NULL){
for(int i = 0; i < OPEN_MAX; i++){//当参数为NULL时,刷新所有的文件流
if((f->flag & _WRITE) && (_fflush(&_iob[i]) == -1)){//有一个出错即返回-1
res = EOF;
}
}
}else{
if(f->flag & _WRITE){
_flushbuf(EOF,f);
}else{
res = EOF;
}
}
if(f->flag & _ERR){ //出错
res = EOF;
}
return res;
}

_fflush的处理过程:


判断参数是否为空,如果为空的话,遍历_iob数组,将所有文件流都强制刷新。
如果参数不为空,判断文件是否可写,再调用_flushbuf进行刷新,注意此处传递给_flushbuf的参数是EOF。还有一点就是判断_flushbuf是否出错不是判断返回值是否为-1,因为参数为EOF时的返回值也为-1,所以此处用flag & _ERR判断是否出错。

注意,这里我们只针对可写的文件流进行操作,忽略了只读的文件流:


If the stream was open for reading, the behavior depends on the specific implementation. In some implementations this causes the input buffer to be cleared.

针对只读的文件流,不同系统处理的方式不一样,有的系统会清空缓冲区。


_fclose
int _fclose(FILE *f){
int ret;
if((ret = _fflush(f)) != EOF){
free(f->base);
f->base = NULL;
f->ptr = NULL;
f->fd = 0;
f->flag = 0;
f->cnt=0;
}
return 0;
}

fclose调用fflush函数,保证在文件关闭前将缓冲区中的内容刷到文件中,并且释放掉缓冲区的内存空间。


_fseek

关于fseek的介绍请看 fseek


int _fseek(FILE *f,long offset,int origin){
int rc;
if(f->flag & _READ) {
if(origin == 1) {
offset -= f->cnt;
}
rc = lseek(f->fd,offset,origin);
f->cnt = 0; //将缓冲区剩余字符数清0
}else if(f->flag & _WRITE) {
rc = _fflush(f); //强制刷新缓冲区
if(rc != EOF) {
rc = lseek(f->fd,offset,origin);
}
}
return rc == -1 ? EOF : 0;
}

当文件流为可读时,见下图:



由于有缓冲区的存在,我们直觉上的文件指针位置和真实的文件指针位置是不同的,差了cnt个单位长度。所以当我们设置移动offset个长度时,真实的文件指针需要移动offset-cnt个单位长度(offset为正数或者负数)。


之后我们需要将cnt置为0,以便下次读取时将缓冲区的数据更新。


当origin为0或者2时,直接调动lseek即可。


而当文件流为可写时,见下图:



真实的文件指针位置与我们直觉上的文件指针位置差了ptr - base个单位长度,即我们新写入缓冲区的内容长度,所以我们直接调用_fflush即可。(K&R中直接调用的write,但是我觉得这样没有重置ptr指针的位置和cnt,这样的话base与ptr之间的内容会被刷入到文件中两次)。


当文件是以a模式打开时,fseek无效:
a+ Open for reading and appending (writing at end of file).The file is created if it does not exist.The initial file
position forreading is at the beginning of the file, but output is always appended to the end of the file.
_getchar
int _getchar(){
return _getc(stdin);
}

我们可以发现,_getchar调用的就是_getc,只不过_getc可以传入任意的文件指针,而对_getchar来说,_getc传入的是stdin,也就是 {0,NULL,NULL,_READ,0} 。



当调用getchar时,首先去stdin结构体中的缓存取数据,如果缓存为空,会在_fillbuf中的 int n = read(f->fd,f->ptr,BUFSIZ); //系统调用read 处阻塞住,等待用户输入字符。
当标准输入(stdin)连接的是终端时,终端I/O会采用规范模式输入处理:对于终端输入以行为单位进行处理,对于每个读请求,终端设备输入队列会返回一行(用户输入的字符会缓存在终端输入队列中,直到用户输入一个行定界符,输入队列中的数据会返回给read函数)。
这一行数据会缓存在标准I/O缓冲区中,下次调用getchar时会返回缓冲区第一个字符。当缓冲区数据被读光时,重复上述过程。
_putchar
int _putchar(int x){
return _putc(x,stdout);
}

我们可以发现,_putchar调用的就是_putc,只不过_putc可以传入任意的文件指针,而对_putchar来说,_putc传入的是stdout,也就是 {0,NULL,NULL,_WRITE,1} 。



调用putchar时,数据会缓存在stdout中的缓冲中。
当stdout的缓冲被装满时,会调用write将数据写入到stdout中,stdout将数据写入到终端设备输出队列中,输出队列将数据写入到终端。
完整代码
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#define EOF -1
#define BUFSIZ 1024
#define OPEN_MAX 20//打开的最大文件数
#define PERMS 0666
typedef struct _iobuf{
int cnt;
//缓冲区剩余字节数
char *base;
//缓冲区地址
char *ptr;
//缓冲区下一个字符地址
int flag;
//访问模式
int fd;
//文件描述符
} FILE; //别名,与标准库一致
extern FILE _iob[OPEN_MAX];
//八进制
enum _flags {
_READ = 01,
_WRITE = 02,
_UNBUF = 04,
//不进行缓冲
_EOF = 010,
_ERR = 020
};
FILE _iob[OPEN_MAX] = {
{0,NULL,NULL,_READ,STDIN_FILENO},
{0,NULL,NULL,_WRITE,STDOUT_FILENO},
{0,NULL,NULL,_WRITE|_UNBUF,STDERR_FILENO}
};
#define stdin (&_iob[0])
#define stdout (&_iob[1])
#define stderr (&_iob[2])
int _ferror(FILE *f){
return f-> flag & _ERR;
}
int _feof(FILE *f){
return f-> flag & _EOF;
}
int _fileno(FILE *f){
return f->fd;
}
//返回第一个字符
int _fillbuf(FILE *f){
int bufsize;
if((f->flag & (_READ | _EOF | _ERR)) != _READ){ //判断文件是否可读
return EOF;
}
bufsize = f->flag & _UNBUF ? 1 : BUFSIZ;
if(f->base == NULL){
//没有分配过缓冲区
if((f->base = (char *)malloc(bufsize)) == NULL){
return EOF;
}
}
f->ptr = f->base;
int n = read(f->fd,f->ptr,BUFSIZ);//系统调用read
if(n == 0){ //到达文件结尾
f->base = NULL;
f->cnt = 0;
f-> flag |= _EOF;
return EOF;
}else if(n == -1){//出错
f->cnt= 0;
f->flag |= _ERR;
return EOF;
}else{
f->cnt = --n;
return *f->ptr++;
}
}
int _flushbuf(int x,FILE *f){
if((f->flag & (_WRITE | _EOF | _ERR)) != _WRITE){
return EOF;
}
int n;
int bufsize = f->flag & _UNBUF ? 1 : BUFSIZ;
if(f->base != NULL){
n = write(f->fd,f->base,f->ptr - f->base);//判断需要写入多少字节
if(n != f->ptr - f->base){
f->flag |= _ERR;
return EOF;
}
}else{
if((f->base = (char *)malloc(bufsize)) == NULL){
f->flag |= _ERR;
return EOF;
}
}
if(x != EOF){
f->cnt = bufsize - 1;
f->ptr = f->base;
*f->ptr++ = x;
}else{//当写入EOF时,代表强制刷新缓冲区内容到文件中
f->cnt = bufsize;
f->ptr = f->base;
}
return x;
}
/**
* @brief _fflush
* @param f
* @return
*/
int _fflush(FILE *f){
int res = 0;
if(f == NULL){
for(int i = 0; i < OPEN_MAX; i++){//当参数为NULL时,刷新所有的文件流
if((f->flag & _WRITE) && (_fflush(&_iob[i]) == -1)){//有一个出错即返回-1
res = EOF;
}
}
}else{
if(f->flag & _WRITE){
_flushbuf(EOF,f);
}else{
res = EOF;
}
}
if(f->flag & _ERR){ //出错
res = EOF;
}
return res;
}
int _fclose(FILE *f){
int ret;
if((ret = _fflush(f)) != EOF){
free(f->base);
f->base = NULL;
f->ptr = NULL;
f->fd = 0;
f->flag = 0; //@TODO
}
return 0;
}
int _fseek(FILE *f,long offset,int origin){
int rc;
if(f->flag & _READ) {
if(origin == 1) {
offset -= f->cnt;
}
rc = lseek(f->fd,offset,origin);
f->cnt = 0; //将缓冲区剩余字符数清0
}else if(f->flag & _WRITE) {
rc = _fflush(f); //强制刷新缓冲区
if(rc != EOF) {
rc = lseek(f->fd,offset,origin);
}
}
return rc == -1 ? EOF : 0;
}
int _getc(FILE *f){
return --f->cnt >= 0 ? *f->ptr++ : _fillbuf(f);
}
int _putc(int x,FILE *f){
return --f->cnt >= 0 ? *f->ptr++ = x : _flushbuf(x,f);
}
int _getchar(){
return _getc(stdin);
}
int _putchar(int x){
return _putc(x,stdout);
}
FILE *_fopen(char *file,char *mode){
int fd;
FILE *fp;
if(*mode != 'r' && *mode != 'w' && *mode != 'a') {
return NULL;
}
for(fp = _iob; fp < _iob + OPEN_MAX; fp++) { //寻找一个空闲位置
if (fp->flag == 0){
break;
}
}
if(fp >= _iob + OPEN_MAX){
return NULL;
}
if(*mode == 'w'){
fd = creat(file,PERMS);
}else if(*mode == 'r'){
fd = open(file,O_RDONLY,0);
}else{
//a模式
if((fd = open(file,O_WRONLY,0)) == -1){
fd = creat(file,PERMS);
}
lseek(fd,0L,2);
//文件指针指向末尾
}
if(fd == -1){
return NULL;
}
fp->fd = fd;
fp->cnt = 0;
//fopen不分配缓存空间
fp->base = NULL;
fp->ptr = NULL;
fp->flag = *mode == 'r' ? _READ : _WRITE;
return fp;
}
int main(int argc,char *argv[]){
FILE *f = _fopen("zyc.txt","a");
/*char c;
for(int i = 0; i < 10; i++){
c = _getc(f);
}*/
/*for(int i = 0; i < 9; i++){
_putc('6',f);
}
_fseek(f,-5,1);
for(int i = 0; i < 9; i++){
_putc('8',f);
}
_fclose(f);*/
int c;
while((c = _getchar()) != '/n'){
_putchar(c);
}
_fclose(stdout);
return 0;
}

上面提到的部分函数在 Answer to Exercise 8-3, page 179 中有更详细的实现。


参考资料:


C程序设计语言(第2版•新版) 第8章 UNIX系统接口
Macros vs Functions
从"read"看系统调用的耗时
stdin/stdout/stderr的缓冲方式疑问
Answer to Exercise 8-3, page 179
缓存与IO
UNIX环境高级编程(第3版) 第18章 终端I/O

第七城市

最新文章

123

最新摄影

闪念基因

微信扫一扫

第七城市微信公众平台