[apue] 标准 I/O 库那些事儿

  • [apue] 标准 I/O 库那些事儿已关闭评论
  • 181 次浏览
  • A+
所属分类:linux技术
摘要

标准 IO 库自 1975 年诞生以来,至今接近 50 年了,令人惊讶的是,这期间只对它做了非常小的修改。除了耳熟能详的 printf/scanf,回过头来对它做个全方位的审视,看看到底优秀在哪里。


前言

标准 IO 库自 1975 年诞生以来,至今接近 50 年了,令人惊讶的是,这期间只对它做了非常小的修改。除了耳熟能详的 printf/scanf,回过头来对它做个全方位的审视,看看到底优秀在哪里。

打开关闭

要想使用 IO 流就必需打开它们。三个例外是标准输入 stdin、标准输出 stdout、标准错误 stderr,它们在进入 main 时就准备好了,可以直接使用,与之对应的文件描述符分别是 STDIN_FILENO / STDOUT_FILENO / STDERR_FILENO。除此之外的流需要打开才能使用:

FILE* fopen(const char * restrict path, const char * restrict mode); FILE* fdopen(int fildes, const char *mode); FILE* freopen(const char *path, const char *mode, FILE *stream); FILE* fmemopen(void *restrict *buf, size_t size, const char * restrict mode);
  • fopen 用于打开指定的文件作为流
  • fdopen 用于打开已有的文件描述符作为流
  • freopen 用于在指定的流上打开指定的文件
  • fmemopen 用于打开已有的内存作为流

fopen

大部分打开操作都需要提供 mode 参数,它主要由 r/w/a/b/+ 字符组成,相关的组合与 open 的 oflag 参数对应关系如下:

mode oflag
r O_RDONLY
r+ O_RDWR
w O_WRONLY | O_CREAT | O_TRUNC
w+ O_RDWR | O_CREAT | O_TRUNC
a O_WRONLY | O_CREAT | O_APPEND
a+ O_RDWR | O_CREAT | O_APPEND

其中 b 表示按二进制数据处理,不提供时按文本数据处理,不过 unix like 的文件不区分二进制数据与文本数据,加不加没什么区别,所以上面没有列出。

fdopen

fdopen 提供了一种便利,将已有的 fd 封装在 FILE* 中,特别当描述符是通过接口传递进来时就尤为有用了。fdopen 的一个问题是 fd 本身的读写标志要与 mode 参数相容,否则会打开失败,下面的程序用来验证 mode 与 oflags 的相容关系:

#include "../apue.h" #include <wchar.h>   int main (int argc, char* argv[]) {   if (argc < 4)     err_sys ("Usage: fdopen_t path type1 type2");     char const* path = argv[1];    char const* type1  = argv[2];    char const* type2 = argv[3];    int flags = 0;    if (strchr (type1, 'r') != 0)   {     if (strchr (type1, '+') != 0)       flags |= O_RDWR;      else        flags |= O_RDONLY;   }   else if (strchr (type1, 'w') != 0)   {     flags |= O_TRUNC;      if (strchr (type1, '+') != 0)       flags |= O_RDWR;      else        flags |= O_WRONLY;   }   else if (strchr (type1, 'a') != 0)   {     flags |= O_APPEND;      if (strchr (type1, '+') != 0)       flags |= O_RDWR;      else        flags |= O_WRONLY;   }    int fd = open (path, flags, 0777);     if (fd == 0)     err_sys ("fopen failed");     printf ("(%d) open type %s, type %s ", getpid (), type1, type2);   FILE* fp = fdopen (fd, type2);    if (fp == 0)     err_sys ("fdopen failed");     printf ("OKn");    fclose (fp);    return 0;  }

程序接收 3 个参数,分别是待测试文件、oflags 和 mode,因 oflags 为二进制不方便直接传递,这里借用 mode 的 r/w/a 在内部做个转换。

使用下面的脚本驱动:

#! /bin/sh  oflags=("r" "w" "a" "r+" "w+" "a+") modes=("r" "r+" "w" "w+" "a" "a+") for oflag in ${oflags[@]} do     for mode in ${modes[@]}     do         ./fdopen_t abc.txt ${oflag} ${mode}     done done

下面是程序输出:

$ sh fdopen_t.sh (62061) open type r, type r OK (62062) open type r, type r+ fdopen failed: Invalid argument (62063) open type r, type w fdopen failed: Invalid argument (62064) open type r, type w+ fdopen failed: Invalid argument (62065) open type r, type a fdopen failed: Invalid argument (62066) open type r, type a+ fdopen failed: Invalid argument (62067) open type w, type r fdopen failed: Invalid argument (62068) open type w, type r+ fdopen failed: Invalid argument (62069) open type w, type w OK (62070) open type w, type w+ fdopen failed: Invalid argument (62071) open type w, type a OK (62072) open type w, type a+ fdopen failed: Invalid argument (62073) open type a, type r fdopen failed: Invalid argument (62074) open type a, type r+ fdopen failed: Invalid argument (62075) open type a, type w OK (62076) open type a, type w+ fdopen failed: Invalid argument (62077) open type a, type a OK (62078) open type a, type a+ fdopen failed: Invalid argument (62079) open type r+, type r OK (62080) open type r+, type r+ OK (62081) open type r+, type w OK (62082) open type r+, type w+ OK (62083) open type r+, type a OK (62084) open type r+, type a+ OK (62085) open type w+, type r OK (62086) open type w+, type r+ OK (62087) open type w+, type w OK (62088) open type w+, type w+ OK (62089) open type w+, type a OK (62090) open type w+, type a+ OK (62091) open type a+, type r OK (62092) open type a+, type r+ OK (62093) open type a+, type w OK (62094) open type a+, type w+ OK (62095) open type a+, type a OK (62096) open type a+, type a+ OK

总结一下:

mode oflags
r O_RDONLY/O_RDWR
w O_WRONLY/O_RDWR
a O_WRONLY/O_RDWR
r+/w+/a+ O_RDWR

其中与创建文件相关的选项均会失效,如 w 的 O_TRUNC 与 a 的 O_APPEND,也就是说 fdopen 指定 mode a 打开成功的流可能完全没有 append 能力;指定 w 打开成功的流也可能压根没有 truncate,感兴趣的读者可以修改上面的 demo 验证。

fileno

fdopen 无意间已经展示了如何将 fd 转换为 FILE*,反过来也可以获取 FILE* 底层的 fd,这就需要用到另外一个接口了:

int fileno(FILE *stream);

freopen

freopen 一般用于将一个指定的文件打开为一个预定义的流,在使用方式上有些类似 dup2:

  • 如果 stream 代表的流已经打开,则先关闭
  • 打开成功后返回 stream

如果想在程序中将 stdin/stdout/stderr 重定向到文件,使用 freopen 将非常方便,不然的话就需要 fopen 一个新流,并使用 fprintf / fputs / fscanf / fgets ... 等带一个流参数的版本在新流上执行读写工作。如果已有大量的这类函数调用,重构起来会非常头疼,freopen 很好的解决了这个痛点。

不过无法在指定的流上使用特定的 fd,这是因为 freopen 只接受 path 作为参数,没有名为 fdreopen 这样的东东。freopen 会清除流的 eof、error 状态及定向和缓冲方式,这些概念请参考后面的小节。

fmemopen

fmemopen 是新加入的接口,用于在一块内存上执行 IO 操作,如果给 buf 参数 NULL,则它会自动分配 size 大小的内存,并在关闭流时自动释放内存。

fclose

fclose 用于关闭一个流,关闭流会自动关闭底层的 fd,使用 fdopen 打开的流也是如此。

int fclose(FILE *stream);

进程退出时会自动关闭所有打开的流。

定向 (orientation)

除了针对 ANSI 字符集,标准 IO 库还可以处理国际字符集,此时一个字符由多个字节组成,称为宽字符集,ANSI 单字符集也称为窄字符集。宽字符集中一般使用 wchar_t 代替 char 作为输入输出参数,下面是宽窄字符集接口对应关系:

窄字符集 宽字符集
printf/fprintf/sprintf/snprintf/vprintf wprintf/fwprintf/swprintf/vwprintf
scanf/fscanf/sscanf/vscanf wscanf/fwscanf/swscanf/vwscanf
getc/fgetc/getchar/ungetc getwc/fgetwc/getwchar/ungetwc
putc/fputc/putchar putwc/fputwc/putwchar
gets/fgets fgetws
puts/fputs fputws

主要区别是增加了一个 w 标志。由于宽窄字符集主要影响的是字符串操作,上表几乎列出了所有的标准库与字符/字符串相关的接口。接口不是一一对应的关系,例如没有 getws/putws 这种接口,一个可能的原因是 gets/puts 本身已不建议使用,所以也没有必要增加对应的宽字符接口;另外也没有 swnprintf 或 snwprintf 这种接口,可能是考虑到类似 utf-8 这种变长多字节字符集不好计算字符数吧。

下面才是重点,一个流只能操作一种宽度的字符集,如果已经操作过宽字符集,就不能再操作窄字符集,反之亦然,这就是流的定向。除了调用上面的接口来隐式定向外,还可以通过接口显示定向:

int fwide(FILE *stream, int mode);

fwide 只有在流未定向时才能起作用,对一个已定向的流调用它不会改变流的定向,mode 含义如下:

  • mode < 0:窄字符集定向
  • mode > 0:宽字符集定向
  • mode == 0:不对流进行定向,仅返回流的当前定向,返回值含义同参数

下面的程序用来验证 fwide 的上述特性:

#include "../apue.h" #include <wchar.h>   void do_fwide (FILE* fp, int wide) {   if (wide > 0)       fwprintf (fp, L"do fwide %dn", wide);    else       fprintf (fp, "do fwide %dn", wide);  }  /**  *@param: wide  *   -1 : narrow  *   1  : wide  *   0  : undetermine  */ void set_fwide (FILE* fp, int wide) {   int ret = fwide (fp, wide);    printf ("old wide = %d, new wide = %dn", ret, wide);  }  void get_fwide (FILE* fp) {     set_fwide (fp, 0);  }  int main (int argc, char* argv[]) {   int towide = 0;    FILE* fp = fopen ("abc.txt", "w+");    if (fp == 0)     err_sys ("fopen failed");   #if defined (USE_WCHAR)   towide = 1; #else   towide = -1;   #endif  #if defined (USE_EXPLICIT_FWIDE)   // set wide explicitly   set_fwide (fp, towide);  #else   // set wide automatically by s[w]printf   do_fwide (fp, towide);  #endif     get_fwide (fp);     // test set fwide after wide determined   set_fwide (fp, towide > 0 ? -1 : 1);     get_fwide (fp);     // test output with same wide   do_fwide (fp, towide);    // test output with different wide   do_fwide (fp, towide > 0 ? -1 : 1);     fclose (fp);    return 0;  }

通过给 Makefile 不同的编译开关来控制生成的 demo:

all: fwide fwidew  fwide: fwide.o apue.o 	gcc -Wall -g $^ -o $@  fwide.o: fwide.c ../apue.h  	gcc -Wall -g -c $< -o $@   fwidew: fwidew.o apue.o 	gcc -Wall -g $^ -o $@  fwidew.o: fwide.c ../apue.h  	gcc -Wall -g -c $< -o $@ -DUSE_WCHAR  apue.o: ../apue.c ../apue.h  	gcc -Wall -g -c $< -o $@  clean:  	@echo "start clean..." 	-rm -f *.o core.* *.log *~ *.swp fwide fwidew  	@echo "end clean"  .PHONY: clean

生成两个程序:fwide 使用窄字符集,fwidew 使用宽字符集:

$ ./fwide old wide = -1, new wide = 0 old wide = -1, new wide = 1 old wide = -1, new wide = 0 $ cat abc.txt  do fwide -1 do fwide -1  $ ./fwidew old wide = 1, new wide = 0 old wide = 1, new wide = -1 old wide = 1, new wide = 0 $ cat abc.txt  do fwide 1 do fwide 1

分别看两个 demo 的输出,其中 old wide 表示返回值,new wide 是参数,可以观察到以下现象:

  • 一旦设置为一个定向,就无法更改定向
  • 如果不显示设置定向,通过第一个标准 IO 库调用可以确定定向,这里使用的是 s[w]printf (可以设置 USE_EXPLICIT_FWIDE 来启用显示定向)
  • 使用非本定向的输出接口无法输出字符串到流 (do_fwide 向文件流写入一行,共调用 3 次,只打印 2 行信息)

如果设置了 USE_EXPLICT_FWIDE 来显示设置定向,输出稍有不同:

$ ./fwide old wide = -1, new wide = -1 old wide = -1, new wide = 0 old wide = -1, new wide = 1 old wide = -1, new wide = 0 $ cat abc.txt do fwide -1  $ ./fwidew old wide = 1, new wide = 1 old wide = 1, new wide = 0 old wide = 1, new wide = -1 old wide = 1, new wide = 0 $ cat abc.txt do fwide 1

首先因为显示设置 fwide 导致上面的输出增加了一行,其次因为省略了隐式的 f[w]printf 调用,下面的输出少了一行,但是结论不变。

最后注意 fwide 无出错返回,需要使用 errno 来判断是否发生了错误,为了防止上一个调用的错误码干扰结果,最好在发起调用前清空 errno。

freopen 会清除流的定向。

缓冲

缓冲是标准 IO 库的核心,通过缓冲来减少内核 IO 的次数以提升性能是标准 IO 对内核 IO (read/write) 的重大改进。

一个流对象 (FILE*) 内部记录了很多信息:

  • 文件描述符  (fd)
  • 缓冲区指针
  • 缓冲区长度
  • 当前缓冲区字符数
  • 出错标志位
  • 文件结束标志位
  • ...

其中很多信息是与缓冲相关的。

缓冲类型

标准 IO 的缓冲主要分为三种类型:

  • 全缓冲,填满缓冲区后才进行实际 IO 操作
  • 行缓冲,在输入和输出中遇到换行符或缓冲区满才进行实际 IO 操作
  • 无缓冲,每次都进行实际 IO 操作

对于行缓冲,除了上面提到的两种场景,当通过标准 IO 库试图从以下流中得到输入数据时,会造成所有行缓冲输出流被冲洗 (flush):

  • 从不带缓冲的流中得到输入数据
  • 从行缓冲的流中得到输入数据,后者要求从内核得到数据 (行缓冲用尽)

这样做的目的是,所需要的数据可能已经在行缓冲区中,冲洗它们来保证从系统 IO 中获取最新的数据。

术语冲洗 (flush) 也称为刷新,使流所有未写的数据被传送至内核:

int fflush(FILE *stream);

如果给 stream 参数 NULL,将导致进程所有输出流被冲洗。

对于三个预定义的标准 IO 流 (stdin/stdout/stderr) 的缓冲类型,ISO C 有以下要求:

  • 当且仅当 stdin/stdout 不涉及交互式设备时,它们才是全缓冲的
  • stderr 不可以是全缓冲的

很多系统默认使用下列类型的缓冲:

  • stdin/stdout
    • 关联终端设备:行缓冲
    • 其它:全缓冲
  • stderr :无缓冲

stdin/stdout 默认是关联终端设备的,除非重定向到文件。

在进行第一次 IO 时,标准库会自动为全缓冲或行缓冲的流分配 (malloc) 缓冲区,也可以直接指定流的缓冲类型,这一点与流的定位类似:

void setbuf(FILE *restrict stream, char *restrict buf); void setbuffer(FILE *stream, char *buf, int size); int setlinebuf(FILE *stream); int setvbuf(FILE *restrict stream, char *restrict buf, int type, size_t size);

与流的定位不同的是,流的缓冲类型在确定后仍可以更改。

上面几个接口中的重点是 setvbuf,其中 type 为流类型,可以选取以下几个值:

  • _IONBF:unbuffered,无缓冲
  • _IOLBF:line buffered,行缓冲
  • _IOFBF:fully buffered,全缓冲

根据 type、buf、size 的不同组合,可以得到不同的缓冲效果:

type size buffer 效果
_IONBUF ignore ignore 无缓冲
_IOLBUF 0 NULL (自动分配合适大小的缓冲,关闭时自动释放) 行缓冲
非 NULL (同上,用户提供的 buffer 被忽略)
>0 NULL (自动分配 size 大小的缓冲,关闭时自动释放) *
非 NULL (缓冲区长度大于等于 size,关闭时用户释放)
_IOFBF 同上 同上 全缓冲

其中标星号的表示 ANSI C 扩展。其它接口都可视为 setvbuf 的简化:

接口 等价效果
setbuf setvbuf (stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ);
setbuffer setvbuf (stream, buf, buf ? _IOFBF : _IONBF, size);
setlinebuffer setvbuf (stream, (char *)NULL, _IOLBF, 0);

setbuf 要求 buf 参数不为 NULL 时缓冲区大小应大于等于 BUFSIZ (CentOS 上为 8192)。

freopen 会重置流的缓冲类型。

setvbuf 不带 buf 时的语义

构造程序验证第一个表中的结论,在开始之前,我们需要准确的获取流当前的缓冲区类型、大小等信息,然而标准 IO 库没有提供这方面的接口,幸运的是,如果只看 linux 系统,可以将问题简化:

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;   int _flags2;   _IO_off_t _old_offset; /* This used to be _offset but it's too small.  */ };

上面是 linux 中 FILE 结构体的定义,其中

  • _IO_file_flags/_flags 存放缓冲区类型
  • _IO_buf_base 为缓冲区地址
  • _IO_buf_end 为缓冲区末尾+1
  • _IO_buf_end - _IO_buf_base 为缓冲区长度

这样单纯通过 FILE* 就能获取缓冲区信息了:

void tell_buf (char const* name, FILE* fp) {   printf ("%s is: ", name);    if (fp->_flags & _IO_UNBUFFERED)     printf ("unbufferedn");    else if (fp->_flags & _IO_LINE_BUF)     printf ("line-bufferedn");    else      printf ("fully-bufferedn");     printf ("buffer size is %d, %pn", fp->_IO_buf_end - fp->_IO_buf_base, fp->_IO_buf_base);    printf ("discriptor is %dnn", fileno (fp));  }

有了 tell_buf 就可以构造验证程序了:

#include "../apue.h" #include <stdio.h>   int main (int argc, char* argv[]) {   tell_buf ("stdin", stdin);     int a;    scanf ("%d", &a);    printf ("a = %dn", a);    tell_buf ("stdin", stdin);    tell_buf ("stdout", stdout);    tell_buf ("stderr", stderr);    fprintf (stderr, "a = %dn", a);    tell_buf ("stderr", stderr);    printf ("n");     char buf[BUFSIZ] = { 0 };    printf ("bufsiz = %d, address = %pn", BUFSIZ, buf);    setbuf (stdout, NULL);    tell_buf ("stdout (no)", stdout);    setbuf (stderr, buf);    tell_buf ("stderr (has)", stderr);    setbuf (stdin, buf);    tell_buf ("stdin (has)", stdin);    printf ("n");     setvbuf (stderr, NULL, _IONBF, 0);    tell_buf ("stderr (no)", stderr);    setvbuf (stdout, buf, _IOFBF, 2048);    tell_buf ("stdout (full, 2048)", stdout);    setvbuf (stderr, buf, _IOLBF, 1024);    tell_buf ("stderr (line, 1024)", stderr);    setvbuf (stdout, NULL, _IOLBF, 4096);    tell_buf ("stdout (line null 4096)", stdout);    setvbuf (stderr, NULL, _IOFBF, 3072);    tell_buf ("stderr (full null 3072)", stderr);    setvbuf (stdout, NULL, _IOFBF, 0);    tell_buf ("stdout (full null 0)", stdout);    setvbuf (stderr, NULL, _IOLBF, 0);    tell_buf ("stderr (line null 0)", stderr);    return 0;  }

程序依据空行分为三部分,做个简单说明:

  • 第一部分验证 stdin/stdout/stderr 缓冲的初始状态、第一次执行 IO 后的状态
    • 为了验证 stdin 第一次执行 IO 操作后的状态,加了一个 scanf 操作
    • 对于 stdout 因 tell_buf 本身使用到了 printf 操作,会导致 stdout 缓冲区的默认分配,所以无法验证它的初始状态
    • 因没有使用 stderr 输出,所以可以验证它的初始状态
  • 第二部分验证 setbuf 调用
    • stdout 无缓冲
    • stderr/stdin 全缓冲
  • 第三部分验证 setvbuf 调用
    • stderr 无缓冲
    • stdout 带 buf 全缓冲
    • stderr 带 buf 行缓冲
    • stdout 无 buf 指定 size 行缓冲
    • stderr 无 buf 指定 size 全缓冲
    • stdout 无 buf 0 size 全缓冲
    • stderr 无 buf 0 size 行缓冲

下面是程序输出:

$ ./fgetbuf  stdin is: fully-buffered buffer size is 0, (nil) discriptor is 0  <42> a = 42 stdin is: line-buffered buffer size is 1024, 0x7fcf9483d000 discriptor is 0  stdout is: line-buffered buffer size is 1024, 0x7fcf9483e000 discriptor is 1  stderr is: unbuffered buffer size is 0, (nil) discriptor is 2  a = 42 stderr is: unbuffered buffer size is 1, 0x7fcf94619243 discriptor is 2   bufsiz = 8192, address = 0x7fff8b5bbcb0 stdout (no) is: unbuffered buffer size is 1, 0x7fcf94619483 discriptor is 1  stderr (has) is: fully-buffered buffer size is 8192, 0x7fff8b5bbcb0 discriptor is 2  stdin (has) is: fully-buffered buffer size is 8192, 0x7fff8b5bbcb0 discriptor is 0   stderr (no) is: unbuffered buffer size is 1, 0x7fcf94619243 discriptor is 2  stdout (full, 2048) is: fully-buffered buffer size is 2048, 0x7fff8b5bbcb0 discriptor is 1  stderr (line, 1024) is: line-buffered buffer size is 1024, 0x7fff8b5bbcb0 discriptor is 2  stdout (line null 4096) is: line-buffered buffer size is 2048, 0x7fff8b5bbcb0 discriptor is 1  stderr (full null 3072) is: fully-buffered buffer size is 1024, 0x7fff8b5bbcb0 discriptor is 2  stdout (full null 0) is: fully-buffered buffer size is 2048, 0x7fff8b5bbcb0 discriptor is 1  stderr (line null 0) is: line-buffered buffer size is 1024, 0x7fff8b5bbcb0 discriptor is 2

为了方便观察,用两个换行区分各个部分的输出。可以看出:

  • stdin/stderr 初始时是没有分配缓冲区的,执行第一次 IO 后,stdin/stdout 变为行缓冲类型,stderr 变为无缓冲,都分配了独立的缓冲区空间 (地址不同)。特别是 stderr,虽然是无缓冲的,底层也有 1 字节的缓冲区存在,这点需要注意
  • setbuf 调用设置全缓冲后,stderr/stdin 的缓冲区地址变为 buf 字符数组地址;stdout 设置为无缓冲后,缓冲区重新获得 1 字节的新地址
  • setvbuf 设置 stderr 无缓冲场景同 setbuf 情况,缓冲区重新分配为 1 字节的新地址
  • setvbuf 设置 stdout 全缓冲、设置 stderr 行缓冲的场景同 setbuf 情况,缓冲区地址变为 buf 字符数组地址,大小变为 size 参数的值
  • setvbuf 设置 stdout 行缓冲、设置 stderr 全缓冲不带 buf (NULL) 的结果就不太一样了,缓冲区地址和大小均未改变,仅缓冲类型发生变更
  • setvbuf 设置 stdout 全缓冲、设置 stderr 行缓冲不带 buf (NULL) 0 size 的结果同上,缓冲区地址和大小均未改变,仅缓冲类型发生变更

最后两个 case 与书上所说不同,看看 man setvbuf 怎么说:

Except for unbuffered files, the buf argument should point to a buffer at least size bytes long; this buffer will be used  instead  of  the current  buffer.   If  the argument buf is NULL, only the mode is affected; a new buffer will be allocated on the next read or write opera‐ tion.  The setvbuf() function may be used only after opening a stream and before any other operations have been performed on it.

翻译一下:当不带 buf 调用时只更新缓冲类型,缓冲区地址将在下一次 IO 时更新。对程序稍加改造进行验证,每个 setvbuf 调用后加上输出语句 (fprintf) 来强制 IO 库分配空间:

  setvbuf (stderr, NULL, _IONBF, 0);    tell_buf ("stderr (no)", stderr);    setvbuf (stdout, buf, _IOFBF, 2048);    fprintf (stdout, "a = %dn", a);    tell_buf ("stdout (full, 2048)", stdout);    setvbuf (stderr, buf, _IOLBF, 1024);    fprintf (stderr, "a = %dn", a);    tell_buf ("stderr (line, 1024)", stderr);    setvbuf (stdout, NULL, _IOLBF, 4096);    fprintf (stdout, "a = %dn", a);    tell_buf ("stdout (line null 4096)", stdout);    setvbuf (stderr, NULL, _IOFBF, 3072);    fprintf (stderr, "a = %dn", a);    tell_buf ("stderr (full null 3072)", stderr);    setvbuf (stdout, NULL, _IOFBF, 0);    fprintf (stdout, "a = %dn", a);    tell_buf ("stdout (full null 0)", stdout);    setvbuf (stderr, NULL, _IOLBF, 0);    fprintf (stderr, "a = %dn", a);    tell_buf ("stderr (line null 0)", stderr);    return 0; 

再执行 tell_buf,然鹅输出没有任何改观。不过发现缓冲类型和缓冲区 buffer 确实起作用了:

  • 设置为全缓冲的流 fprintf 不会立即输出,需要使用 fflush 冲洗一下
  • 由于 stdout 和 stderr 使用了一块缓冲区,同样的信息会被分别输出一次

为了避免上面这些问题,决定使用文件流重新验证上面 4 个 case,构造验证程序如下:

#include "../apue.h" #include <stdio.h>   int main (int argc, char* argv[]) {   FILE* fp = NULL;    FILE* fp1 = fopen ("flbuf.txt", "w+");    FILE* fp2 = fopen ("lnbuf.txt", "w+");    FILE* fp3 = fopen ("nobuf.txt", "w+");    FILE* fp4 = fopen ("unbuf.txt", "w+");       fp = fp1;    if (setvbuf (fp, NULL, _IOFBF, 8192) != 0)       err_sys ("fp (full null 8192) failed");    tell_buf ("fp (full null 8192)", fp);     fp = fp2;    if (setvbuf (fp, NULL, _IOLBF, 3072) != 0)       err_sys ("fp (line null 3072) failed");    tell_buf ("fp (line null 3072)", fp);     fp = fp3;    if (setvbuf (fp, NULL, _IOLBF, 0) != 0)       err_sys ("fp (line null 0) failed");    tell_buf ("fp (line null 0)", fp);     fp = fp4;    if (setvbuf (fp, NULL, _IOFBF, 0) != 0)       err_sys ("fp (full null 0) failed");    tell_buf ("fp (full null 0)", fp);     fclose (fp1);    fclose (fp2);    fclose (fp3);    fclose (fp4);    return 0; 

这个程序相比之前主要改进了以下几点:

  • 使用文件 IO 流代替终端 IO 流
  • 每个流都是新构造的,调用 setvbuf 之前未执行任何 IO 操作
  • 加入错误处理,判断 setvbuf 是否出错 (返回非 0 值)

编译运行得到下面的输出:

$ ./fgetbuf_fp fp (full null 8192) is: fully-buffered buffer size is 4096, 0x7fccd6c23000 discriptor is 3  fp (line null 3072) is: line-buffered buffer size is 0, (nil) discriptor is 4  fp (line null 0) is: line-buffered buffer size is 0, (nil) discriptor is 5  fp (full null 0) is: fully-buffered buffer size is 4096, 0x7fccd6c21000 discriptor is 6

有了一些改观:

  • 全缓冲的缓冲区都创建了
  • 行缓冲的缓冲区都没有创建
  • 缓冲区的长度都没有使用用户提供的值,而使用默认值 4096

结合之前 man setvbuf 对延后分配缓冲区的说明,在每个 setvbuf 调用后面加一条输出语句强制 IO 库分配空间:

fputs ("fp", fp); 

观察输出:

$ ./fgetbuf_fp fp (full null 8192) is: fully-buffered buffer size is 4096, 0x7f8047525000 discriptor is 3  fp (line null 3072) is: line-buffered buffer size is 4096, 0x7f8047523000 discriptor is 4  fp (line null 0) is: line-buffered buffer size is 4096, 0x7f8047522000 discriptor is 5  fp (full null 0) is: fully-buffered buffer size is 4096, 0x7f8047521000 discriptor is 6

这次都有缓冲区了,且默认值都是 4K。结合前后两个例子,可以合理的推测 setvbuf 不带 buf 参数的行为:

  • 只有当流没有分配缓冲区时,setvbuf 调用才生效,否则仍延用之前的缓冲区不重新分配
  • 忽略 size 参数,统一延用之前的 size 或默认值

稍微修改一下程序进行验证:

fp = fp1;

将所有为 fp 赋值的地方都改成上面这句,即保持 fp 不变,让 4 个用例都使用 fp1,再次运行:

$ ./fgetbuf_fp fp (full null 8192) is: fully-buffered buffer size is 4096, 0x7f6ea5349000 discriptor is 3  fp (line null 3072) is: line-buffered buffer size is 4096, 0x7f6ea5349000 discriptor is 3  fp (line null 0) is: line-buffered buffer size is 4096, 0x7f6ea5349000 discriptor is 3  fp (full null 0) is: fully-buffered buffer size is 4096, 0x7f6ea5349000 discriptor is 3

观察到缓冲区地址一直没有变化。当已经为流指定了用户提供的缓冲区,使用 setvbuf 不带 buf 参数的方式并不能让系统释放这块内存地址的使用权。

这引入了另外一个问题 —— 一旦指定了用户提供的缓冲区空间,还能让系统自动分配缓冲区吗?答案是不能。有的读者可能不信,凭直觉认为分以下两步可以实现这个目标:

  1. 设置流的类型为无缓冲类型
  2. 设置流的类型为不带 buf 的行或全缓冲类型,从而触发流缓冲区的自动分配

构造下面的程序验证:

#include "../apue.h" #include <stdio.h>   int main (int argc, char* argv[]) {   char buf[BUFSIZ] = { 0 };   printf ("BUFSIZ = %d, address %pn", BUFSIZ, buf);    FILE* fp = fopen ("unbuf.txt", "w+");       setbuf (fp, buf);    tell_buf ("fp (full)", fp);     setbuf (fp, NULL);    if (setvbuf (fp, NULL, _IOLBF, 4096) != 0)       err_sys ("fp (line null 4096) failed");    fputs ("fp", fp);    tell_buf ("fp (line null 4096)", fp);     setbuf (fp, NULL);    if (setvbuf (fp, NULL, _IOFBF, 3072) != 0)       err_sys ("fp (full null 3072) failed");    tell_buf ("fp (full null 3072)", fp);     setbuf (fp, NULL);    if (setvbuf (fp, NULL, _IOLBF, 2048) != 0)       err_sys ("fp (line null 2048) failed");    fputs ("fp", fp);    tell_buf ("fp (line null 2048)", fp);     setbuf (fp, NULL);    if (setvbuf (fp, NULL, _IOFBF, 1024) != 0)       err_sys ("fp (full null 1024) failed");    tell_buf ("fp (full null 1024)", fp);     fclose (fp);    return 0;  }

每次调用 setvbuf 前增加一个 setbuf 调用,重置为无缓冲类型来释放流的缓冲区。得到如下输出:

$ ./fgetbuf_un  BUFSIZ = 8192, address 0x7ffe07ddcb80 fp (full) is: fully-buffered buffer size is 8192, 0x7ffe07ddcb80 discriptor is 3  fp (line null 4096) is: line-buffered buffer size is 1, 0xf69093 discriptor is 3  fp (full null 3072) is: fully-buffered buffer size is 1, 0xf69093 discriptor is 3  fp (line null 2048) is: line-buffered buffer size is 1, 0xf69093 discriptor is 3  fp (full null 1024) is: fully-buffered buffer size is 1, 0xf69093 discriptor is 3

观察到最后缓冲区大小都是 1 字节,地址不再改变,且看着不像有效内存地址。所以最终结论是:一旦用户为流提供了缓冲区,这块缓冲区的内存就会一直被该流占用,直到流关闭、流设置为无缓冲、用户提供其它的缓冲区代替。这个结论只在 linux (CentOS) 上有效,其它平台因 FILE 结构不同没有验证,感兴趣的读者可以修改程序自行验证。

最后,虽然流的缓冲区可以更改,但是不建议这样做,从上面的例子可以看出,大多数类型变更会引发缓冲区重新分配,其中的数据就会随之丢失,导致信息读取、写入不全的问题。

行缓冲流的自动冲洗

有了上面的铺垫,回过头用它来验证一下行缓冲流被冲洗的两种情况:

  • 从不带缓冲的流中得到输入数据
  • 从行缓冲的流中得到输入数据,后者要求从内核得到数据 (行缓冲用尽)

构造 fflushline 程序如下:

#include "../apue.h"  int main (int argc, char* argv[]) {   FILE* fp1 = fopen ("flbuf.txt", "w+");    FILE* fp2 = fopen ("lnbuf.txt", "w+");    FILE* fp3 = fopen ("nobuf.txt", "r+");    if (fp1 == 0 || fp2 == 0 || fp3 == 0)     err_sys ("fopen failed");     // initialize buffer type   // fp1 keep full buffer   if (setvbuf (fp2, NULL, _IOLBF, 0) < 0)       err_sys ("set line buf failed");     if (setvbuf (fp3, NULL, _IONBF, 0) < 0)       err_sys ("set no buf failed");     // fill buffer   printf ("first line to screen! ");    fprintf (fp1, "first line to full buf! ");    fprintf (fp2, "first line to line buf! ");     // case 1: read from line buffered FILE* and need fetch data from system   sleep (3);    getchar();     // fill buffer again   printf ("last line to screen.");    fprintf (fp1, "last line to full buf.");    fprintf (fp2, "last line to line buf.");     // case 2: read from no buffered FILE*    sleep (3);    int ret = fgetc (fp3);    // give user some time to check file content   // note no any output here to avoid repeat case 1   sleep (10);     printf ("n%c: now all over!n", ret);     fclose (fp1);    fclose (fp2);    fclose (fp3);    return 0;  }

初始化了三个文件,从文件名可以了解到它们的缓冲类型,前两个用于写,后一个用于读,用于读的 nobuf.txt 必需在程序运行前手工创建并写入一些数据。

分别为各个文件流的缓冲区填充了一些数据,注意这里没有加换行符,以防行缓冲的文件遇到换行符冲洗数据。然后分两个用例来检验书中的两个结论,如果书中说的没错,当 getchar 从行缓冲的 stdin 或 fgetc 从无缓冲的 fp3 读数据时,行缓冲的 fp2 对应的文件中应该有数据,而全缓冲的 fp1 对应的文件中没有数据。下面是实际的运行输出:

> ./fflushline ... > first line to screen!                                                                       cat lnbuf.txt flbuf.txt < ... > last line to screen.                                                                      cat lnbuf.txt flbuf.txt < .......... > a: now all over!                                                                      cat lnbuf.txt flbuf.txt < first line to line buf! last line to line buf.first line to full buf! last line to full buf. <

为了清晰起见,将两个终端的输出放在了一起,> 开头的是测试程序的输出,< 结尾的是 cat 文件的输出。

其中第一个 cat 是为了验证对 stdin 调用 getchar 的结果,第二个 cat 是为了验证 fgetc (fp3) 的结果,最后一个是为了验证程序结束后的结果。与预期不同的是,不论是读取行缓冲 (stdin) 还是无缓冲文件 (fp3),fp2 文件均没有被冲洗,直到最后文件关闭才发生了冲洗。为了验证 fp2 确实是行缓冲的,将 fprintf fp2 的语句都加上换行符,新的输出果然变了:

> ./fflushline ... > first line to screen!                                                                       cat lnbuf.txt flbuf.txt <                                                                      first line to line buf! < ... > last line to screen.                                                                      cat lnbuf.txt flbuf.txt <                                                                      first line to line buf! <                                                                       last line to line buf. < .......... > a: now all over!                                                                      cat lnbuf.txt flbuf.txt <                                                                       last line to line buf. <                                               first line to full buf! last line to full buf. <

看起来行缓冲确实是起作用了。回过头来观察程序的第一次输出,对于 stdout 的 printf 输出,当读取 stdin 或无缓冲文件 fp3 时,都会被冲洗!为了证明是 getchar / fgetc(fp3) 的影响,特地在它们之前加了 sleep,而输出的 ... 中点的个数表示的就是等待秒数,与程序中设定的一致!另外不光是输出时机与读取文件相吻合,输出的内容还会自动加换行符,按理说冲洗文件仅仅把缓存中的内容写到硬件即可,不应该修改它们,可现实就是这样。

因此结论是,如果仅限于 stdout,书中结论是成立的。读取 stdin 会冲洗 stdout 这个我觉得是有道理的,但是读 fp3 会冲洗 stdout 我是真没想到,有些东西不亲自去试一下,永远不清楚居然会是这样。一开始怀疑只要是针对字符设备的行缓冲文件,都有这个特性,猜测 fp2 没有自动冲洗是因为它重定向的磁盘是块设备的缘故,看看 man setvbuf 怎么说:

The three types of buffering available are unbuffered, block buffered, and line buffered.  When an output stream is unbuffered, information appears on the destination file or terminal as soon as written; when it is block buffered many characters are saved up  and  written  as  a block;  when  it is line buffered characters are saved up until a newline is output or input is read from any stream attached to a terminal device (typically stdin).  The function fflush(3) may be used to force the block out early.  (See fclose(3).)  Normally all files are block buffered.   When the first I/O operation occurs on a file, malloc(3) is called, and a buffer is obtained.  If a stream refers to a terminal (as stdout normally does) it is line buffered.  The standard error stream stderr is always unbuffered by default.

翻译一下第三行关于行缓冲的说明:当关联在终端上的流 (典型的如 stdin) 被读取时,所有行缓冲流会被冲洗。相比书中的结论,加了一个限定条件——关联到终端的流,与测试结论是相符的。

所以最终的结论是,关联到终端的行缓冲流 (stdout) 被冲洗的条件:

  • 从不带缓冲的流中得到输入数据
  • 从行缓冲的流中得到输入数据,后者要求从内核得到数据 (行缓冲用尽)

至于是关联到终端的流,还是关联到一切字符设备的流,感兴趣的读者可以修改上面的例子自行验证。

读写

打开一个流后有三种方式可以对其进行读写操作。

一次一个字符

int getc(FILE *stream); int fgetc(FILE *stream); int getchar(void);  int putc(int c, FILE *stream); int fputc(int c, FILE *stream); int putchar(int c);

其中 getc/fgetc、putc/fputc 的区别主要是前者一般实现为宏,后者一般实现为函数,因此在使用第一个版本时,需要注意宏的副作用,如参数的多次求值,举个例子:

int ch = getc (files[i++]);

就可能会对 i 多次自增,使用函数版本就不存在这个问题。不过相应的,使用函数的性能低于宏版本。下面是一种 getc 的实现:

#define getc(_stream)     (--(_stream)->_cnt >= 0                  ? 0xff & *(_stream)->_ptr++ : _filbuf(_stream))

由于 _stream 在宏中出现了多次,因此上面的多次求值问题是铁定出现的。当然了,有些系统这个宏是转调了一个底层函数,就不存在这方面的问题了。

getchar 等价于 fgetc (stdin),putchar 等价于 fputc (stdout)。

读取字符接口均使用 unsigned char 接收下一个字符,再将其转换为 int 返回,这样做主要是有两个方面的考虑:

  • 直接将 char 转换为 int 返回,存在高位为 1 时得到负值的可能性,容易与出错场景混淆
  • 出错或到达文件尾时,返回 EOF (-1),此值无法存放在 char/unsigned char 类型中

因此千万不要使用 char 或 unsigned char 类型接收 getc/fgetc/getchar 返回的结果,否则上面的问题仍有可能发生。

读取流出错和到达文件尾返回的错误一样,在这种场景下,如果需要进一步甄别发生了哪种情况,需要调用以下接口进行判断:

int feof(FILE *stream); int ferror(FILE *stream);

这些接口返回流内部的 eof 和 error 标记。对于写流出错的场景,就不需要判断 eof 了,铁定是 error 了。

当流处于出错或 eof 状态时,继续在流上进行读写操作将直接返回 EOF,需要手动清空错误或 eof 标志:

void clearerr(FILE *stream);

针对输入,可以将已读取的字符再压入流中:

int ungetc(int c, FILE *stream);

对于通过查看下个字符来决定如何处理后面输入的程序而言,回送是一个很有用的操作,可以避免使用单独的变量保存已读取的字符,并根据是否已读取来判断是从该变量获取下个字符、还是从流中,从而简化了程序的编写。

一次只能回送一个字符,虽然可以通过多次调用来回送多个字符,但不保证都能回送成功,因为回送不会写入设备,只是放在缓冲区,受缓冲区大小限制有回送上限。回送的字符可以不必是 getc 返回的字符,但是不能为 EOF。ungetc 是除 clearerr 外可以清除 eof 标志位的接口之一,达到文件尾可以回送字符而不返回错误就是这个原因。

对于 ungetc 到底能回送多少个字符,构造了下面的程序去验证:

#include "../apue.h" #include <wchar.h>   int main (int argc, char* argv[]) {   int ret = 0;    while (1)   {     ret = getc (stdin);      if (ret == EOF)       break;       printf ("read %cn", (unsigned char) ret);    }    if (feof (stdin))     printf ("reach EndOfFilen");    else      printf ("not reach EndOfFilen");     if (ferror (stdin))     printf ("read errorn");    else      printf ("not read errorn");     ungetc ('O', stdin);    printf ("after ungetcn");     if (feof (stdin))     printf ("reach EndOfFilen");    else      printf ("not reach EndOfFilen");     if (ferror (stdin))     printf ("read errorn");    else      printf ("not read errorn");     unsigned long long i = 0;    char ch = 0;    while (1)   {     ch = 'a' + i % 26;      if (ungetc (ch, stdin) < 0)     {       printf ("ungetc %c failedn", ch);        break;      }     ++ i;      if (i % 100000000 == 0)         printf ("unget %llu: %cn", i, ch);    }    printf ("unget %llu charsn", i);    if (ungetc (EOF, stdin) == EOF)     printf ("ungetc EOF failedn");     while (1)   {     ret = getc (stdin);      if (ret == EOF)       break;       if (i % 100000000 == 0 || i <  30)         printf ("read %llu: %cn", i, (unsigned char) ret);             --i;     // prevent unsigned overflow     if (i > 0)         --i;    }    printf ("over!n");    return 0;  }

程序包含三个大的循环:

  • 第一个循环是处理输入字符的,当用户输入 Ctrl+D 时退出这个循环,并打印当前 ferror/feof 的值,通过 ungetc 回送字符后再次打印 ferror/feof 的值;
  • 第二个循环不停的回送字符,直到系统出错,并打印回送的字符总量,之后验证回送 EOF 返回失败的用例;
  • 第三个循环将回送的字符读取回来,并打印最后 30 个字符的内容,看看和开头回送的内容是否一致;

最后用户输入 Ctrl+D 退出整个程序,下面来看看程序的输出吧:

查看代码
$ ./fungetc  abc123 read a read b read c read 1 read 2 read 3 read   <Ctrl+D> reach EndOfFile not read error after ungetc not reach EndOfFile not read error unget 100000000: v unget 200000000: r unget 300000000: n unget 400000000: j unget 500000000: f unget 600000000: b unget 700000000: x unget 800000000: t unget 900000000: p unget 1000000000: l unget 1100000000: h unget 1200000000: d unget 1300000000: z unget 1400000000: v unget 1500000000: r unget 1600000000: n unget 1700000000: j unget 1800000000: f unget 1900000000: b unget 2000000000: x unget 2100000000: t unget 2200000000: p unget 2300000000: l unget 2400000000: h unget 2500000000: d unget 2600000000: z unget 2700000000: v unget 2800000000: r unget 2900000000: n unget 3000000000: j unget 3100000000: f unget 3200000000: b unget 3300000000: x unget 3400000000: t unget 3500000000: p unget 3600000000: l unget 3700000000: h unget 3800000000: d unget 3900000000: z unget 4000000000: v unget 4100000000: r unget 4200000000: n ungetc v failed unget 4294967295 chars ungetc EOF failed read 4200000000: n read 4100000000: r read 4000000000: v read 3900000000: z read 3800000000: d read 3700000000: h read 3600000000: l read 3500000000: p read 3400000000: t read 3300000000: x read 3200000000: b read 3100000000: f read 3000000000: j read 2900000000: n read 2800000000: r read 2700000000: v read 2600000000: z read 2500000000: d read 2400000000: h read 2300000000: l read 2200000000: p read 2100000000: t read 2000000000: x read 1900000000: b read 1800000000: f read 1700000000: j read 1600000000: n read 1500000000: r read 1400000000: v read 1300000000: z read 1200000000: d read 1100000000: h read 1000000000: l read 900000000: p read 800000000: t read 700000000: x read 600000000: b read 500000000: f read 400000000: j read 300000000: n read 200000000: r read 100000000: v read 29: c read 28: b read 27: a read 26: z read 25: y read 24: x read 23: w read 22: v read 21: u read 20: t read 19: s read 18: r read 17: q read 16: p read 15: o read 14: n read 13: m read 12: l read 11: k read 10: j read 9: i read 8: h read 7: g read 6: f read 5: e read 4: d read 3: c read 2: b read 1: a read 0: O <Ctrl+D> over!

下面做个简单说明:

  • 用户输入 abc123 实际上是 7 个字符 (包含结尾 n),这是打印 7 行内容的原因,一个多余空行是 printf ("read %cn", 'n') 的结果
  • 第一次 Ctrl+D 后 eof 标志为 true,error 状态为 false;ungetc 后,两个状态都被重置
  • 进入回送循环,为防止打印太多内容,每一亿行打印一条日志,最终输出 4294967295 条记录
  • 进入读取循环,读取了 UINT_MAX+1 条记录,刚好包含了第一次 ungetc 的 '0' 字符。可以认为这个缓存大小是 4294967295+1 即 4 GB

注意这里使用 unsigned long long 类型避免 int 或 unsigned int 溢出问题。

从试验结果来看,ungetc 的缓冲比想象的要大的多,一般认为有个 64 KB 就差不多了,实际远远超过了这个。不清楚这个是终端设备专有的,还是所有缓冲区都这么大,感兴趣的读者可以修改上面的程序自行验证。

一次一行

char* gets(char *str); char* fgets(char * restrict str, int size, FILE * restrict stream);     int puts(const char *s); int fputs(const char *restrict s, FILE *restrict stream);  

其中 gets 等价于 fgets (str, NaN, stdin), puts 等价于 fputs (s, stdout)。但是在一些细节上它们还有差异:

接口  gets fgets puts fputs
获取字符数 无限制 * <size-1 * n/a n/a
尾部换行 去除 保留 添加 不添加 *
末尾 null 添加 添加 不写出 不写出

做个简单说明:

  • gets 无法指定缓冲区大小从而可能导致缓冲区溢出,不推荐使用
  • fgets 读取的字符数 (包含末尾换行) 若大于 size-1,则只读取 size-1,最后一个字符填充 null 返回,下次调用继续读取此行;反之将返回完整的字符行 (包含末尾换行) 与结尾 null
  • puts/fputs 输出一行时不要求必需以换行符结束,puts 会自动添加换行符,fputs 原样输出,如果希望在一行内连续打印多个字符串,fputs 是唯一选择

一次一个记录

size_t fread(void *restrict ptr, size_t size, size_t nitems, FILE *restrict stream); size_t fwrite(const void *restrict ptr, size_t size, size_t nitems, FILE *restrict stream);

可以用来直接读写简单类型数组、结构体、结构体数组,其中 size 表明元素尺寸,一般是简单类型、结构体的 sizeof 结果,nitems 表示数组长度,如果是单元素操作,则为 1。

返回值表示读写的元素个数,如果与 nitems 一致则无错误发生;如果小于 nitems,对于读,需要通过 feof 或 ferror 来判断是否出错,对于写,则铁定是出错了。

不推荐跨机器传递二进制数据,主要是结构尺寸随操作系统 (字节顺序及表达方式)、编译器及编译器选项 (字节对齐)、程序版本而变化,处理不好可能直接导致应用崩溃,如果有这方面的需求,最好是求助于 grpc、protobuf 等第三方库。

定位

同 read/write 可以使用 lseek 定位一样,标准 IO 库也支持文件定位。

int fseek(FILE *stream, long offset, int whence); int fseeko(FILE *stream, off_t offset, int whence);  long ftell(FILE *stream); off_t ftello(FILE *stream);  int fgetpos(FILE *restrict stream, fpos_t *restrict pos); int fsetpos(FILE *stream, const fpos_t *pos);  void rewind(FILE *stream);

fseek/ftell 用于设置/读取小于 2G 的文件偏移,fseeko/ftello 可以操作大于 2G 的文件偏移,fsetpos/fgetpos 是 ISO C 的一部分,兼容非 unix like 系统。

fseek/fseeko 的 whence 参数与 lseek 相同,可选择 SEEK_SET/SEEK_CUR/SEEK_END/SEEK_HOLE...,fseeko 的 off_t 类型是 long 还是 long long 由宏 _FILE_OFFSET_BITS 为 32 还是 64 决定,如果想操作大于 2 GB 的文件,需要定义 _FILE_OFFSET_BITS=64,这个定义同样会影响 lseek。下面是这个宏的一些说明:

Macro: _FILE_OFFSET_BITS
    This macro determines which file system interface shall be used, one replacing the other. Whereas _LARGEFILE64_SOURCE makes the 64 bit interface available as an additional interface, _FILE_OFFSET_BITS allows the 64 bit interface to replace the old interface.
    If _FILE_OFFSET_BITS is defined to the value 32, the 32 bit interface is used and types like off_t have a size of 32 bits on 32 bit systems.
    If the macro is defined to the value 64, the large file interface replaces the old interface. I.e., the functions are not made available under different names (as they are with _LARGEFILE64_SOURCE). Instead the old function names now reference the new functions, e.g., a call to fseeko now indeed calls fseeko64.
    If the macro is not defined it currently defaults to 32, but this default is planned to change due to a need to update time_t for Y2038 safety, and applications should not rely on the default.
    This macro should only be selected if the system provides mechanisms for handling large files. On 64 bit systems this macro has no effect since the *64 functions are identical to the normal functions.

翻译一下:文件系统提供了两套接口,一套是 32 位的 (fseeko32),一套是 64 位的 (fseeko64),_FILE_OFFSET_BITS 的值决定了 fseeko 是调用 fseeko32 还是 fseeko64。如果是 32 位系统,还需要定义 _LARGEFILE64_SOURCE 使能 64 位接口;如果是 64 位系统,则定不定义 _FILE_OFFSET_BITS=64 都行,因为默认已经指向 64 位的了。在一些系统上即使定义了 _FILE_OFFSET_BITS 也不能操作大于 2GB 的文件,此时需要使用 fseek64 或 _llseek,详见附录。

下面这个程序演示了使用 fseeko 进行大于 4G 文件的读写:

#include "../apue.h" #include <errno.h>  int main (int argc, char* argv[]) {   FILE* fp = fopen ("large.dat", "r");    if (fp == 0)     err_sys ("fopen failed");     int i = 0;    off_t ret = 0;    off_t pos[2] = { 2u*1024*1024*1024+100 /* 2g more */, 4ull*1024*1024*1024+100 /* 4g more */ };    for (i = 0; i<2; ++ i)   {       if (fseeko (fp, pos[i], SEEK_SET) == -1)       {           printf ("fseeko failed for %llu, errno %dn", pos[i], errno);        }       else        {           printf ("fseeko to %llun", pos[i]);            ret = ftello (fp);            printf ("after fseeko: %llun", ret);        }   }    return 0;  }

读取的文件事先通过 dd 创建:

$ dd if=/dev/zero of=larget.dat bs=1G count=5 5+0 records in 5+0 records out 5368709120 bytes (5.4 GB) copied, 22.9034 s, 234 MB/s

文件大小是 5G,刚好可以用来验证大于 2G 和大于 4G 的场景。下面是程序输出:

$ ./fseeko64 fseeko to 2147483748 after fseeko: 2147483748 fseeko to 4294967396 after fseeko: 4294967396

注意程序中使用了 2u 和 4ull 来分别指定常量类型为 unsigned int 与 unsigned long long,来防止 int 溢出。

在 64 位 linux 上编译不需要增加额外宏定义:

all: fseeko64  fseeko64: fseeko64.o apue.o 	gcc -Wall -g $^ -o $@  fseeko64.o: fseeko.c ../apue.h  	gcc -Wall -g -c $< -o $@  apue.o: ../apue.c ../apue.h  	gcc -Wall -g -c $< -o $@  clean:  	@echo "start clean..." 	-rm -f *.o core.* *.log *~ *.swp fseeko64  	@echo "end clean"  .PHONY: clean

在 32 位上需要同时指定两个宏定义:

all: fseeko32  fseeko32: fseeko32.o apue.o 	gcc -Wall -g $^ -o $@  fseeko32.o: fseeko.c ../apue.h  	gcc -Wall -g -c $< -o $@  -D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE  apue.o: ../apue.c ../apue.h  	gcc -Wall -g -c $< -o $@  clean:  	@echo "start clean..." 	-rm -f *.o core.* *.log *~ *.swp fseeko32 	@echo "end clean"  .PHONY: clean

注意在 64 位上无法通过指定 -D_FILE_OFFSET_BITS=32 来访问 32 位接口。

成功的 fseek/fseeko 清除流的 EOF 标志,并清除 ungetc 缓冲内容;rewind 等价于 fseek (stream, 0L, SEEK_SET),成功的 rewind 还会清除错误标志。

下面的程序演示了 fseek 的这个特性:

#include "../apue.h"  int main (int argc, char* argv[]) {   int ret = 0;    while (1)   {     ret = getc (stdin);      if (ret == EOF)       break;       printf ("read %cn", (unsigned char) ret);    }    if (feof (stdin))     printf ("reach EndOfFilen");    else      printf ("not reach EndOfFilen");     if (ferror (stdin))     printf ("read errorn");    else      printf ("not read errorn");     if (fseek (stdin, 13, SEEK_SET) == -1)     printf ("fseek failedn");    else      printf ("fseek to 13n");     printf ("after fseekn");    if (feof (stdin))     printf ("reach EndOfFilen");    else      printf ("not reach EndOfFilen");     if (ferror (stdin))     printf ("read errorn");    else      printf ("not read errorn");     int i = 0;    char ch = 0;    for (i=0; i<26; ++ i)   {     ch = 'a'+i;      if (ungetc (ch, stdin) != ch)     {       printf ("ungetc failedn");        break;      }     else        printf ("ungetc %cn", ch);    }    if (fseek (stdin, 20, SEEK_SET) == -1)     printf ("fseek failedn");    else      printf ("fseek to 20n");     while (1)   {     ret = getc (stdin);      if (ret == EOF)       break;       printf ("read %cn", (unsigned char) ret);    }    return 0;  }

做个简单说明:

  • 读取文件直到 eof,将验证文件处于 EOF 状态
  • fseek 到文件中某一位置,验证文件 EOF 状态清空
  • ungetc 填充回退缓存数据,再次 fseek,验证 ungetc 缓存清空
  • 从文件当前位置读取直到结尾

因为需要对输入进行 fseek,这里将 stdin 重定向到文件,测试文件中包含由 26 个小写字母按顺序组成的一行内容,下面是程序输出:

查看代码
 ./fseek < abc.txt  read a read b read c read d read e read f read g read h read i read j read k read l read m read n read o read p read q read r read s read t read u read v read w read x read y read z read   reach EndOfFile not read error fseek to 13 after fseek not reach EndOfFile not read error ungetc a ungetc b ungetc c ungetc d ungetc e ungetc f ungetc g ungetc h ungetc i ungetc j ungetc k ungetc l ungetc m ungetc n ungetc o ungetc p ungetc q ungetc r ungetc s ungetc t ungetc u ungetc v ungetc w ungetc x ungetc y ungetc z fseek to 20 read u read v read w read x read y read z read 

最后只读取了 6 个字母,证实确实 seek 到了位置 20 且 ungetc 缓存为空 (否则会优先读取回退缓存中的 26 个字符)。

格式化 (format)

标准 IO 库的格式化其实是一系列函数组成的函数族,按输入输出分为 printf/scanf 两大类。

printf 函数族

int printf(const char * restrict format, ...); int fprintf(FILE * restrict stream, const char * restrict format, ...); int sprintf(char * restrict str, const char * restrict format, ...); int snprintf(char * restrict str, size_t size, const char * restrict format, ...); int asprintf(char **ret, const char *format, ...);
  • printf 等价于 fprintf (stdin, format, ...)
  • sprintf 将变量打印到字符缓冲区,便于后续进一步处理。它在缓冲区末尾添加一个 null 字符,但这个字符不计入返回的字符数中
  • snprintf 在 sprintf 的基础上增加了越界检查,超过缓冲区尾端的任何字符都会被丢弃
  • asprintf 在 sprintf 的基础上增加了缓冲区自动分配 (malloc),通过 *ret 参数获取,缓冲区的销毁 (free) 是调用者的责任

以上接口返回负数表示编码错误。

重点关注一下 snprintf,如果返回的字符数大于等于 size 参数,则表明发生了截断,如果以 result 代表生成的总字符数、size 代表缓冲区大小,那么可以分以下几种情况讨论:

  • result == size,因末尾补 null 原则,实际只能写入 size-1 个字符,返回 result == size
  • result < size,因末尾补 null,实际写入 result+1 个字符 <= size,返回 result < size
  • result > size,因末尾补 null,实际写入 size-1 个字符,返回 result > size

综上,在发生截断时 result >= size。其实关键就在理解等于的情况——因末尾补 null 占用了一个字符,导致写入的字符少了一个从而发生截断——即有一个字符因为末尾 null 被挤出去了。

上面列出的不是 printf 函数族的全部,如果考虑 va_list 的话,它还有差不多数量的版本:

int vprintf(const char * restrict format, va_list ap); int vfprintf(FILE * restrict stream, const char * restrict format, va_list ap); int vsprintf(char * restrict str, const char * restrict format, va_list ap); int vsnprintf(char * restrict str, size_t size, const char * restrict format, va_list ap); int vasprintf(char **ret, const char *format, va_list ap);

区别只是将变长参数 (...) 换作为了 va_list,适用于已经将变长参数转换为 va_list 的场景,因为这一转换是单向的。

printf format

所有 printf 函数族都接受统一的 format 格式,它遵循下面的格式:

% [flags] [fldwidth] [precision] [lenmodifier] convtype
  • flags:支持 +/-/space/#/0 等符号,用于控制对齐、前导符号填充、前缀等
  • fldwidth:用于说明转换的最小字段宽度,可以设置为非负十进制数或星号 (*),当为后者时,宽度由被转换参数的前一个整型参数指定
  • precision:用于指定精度,格式为 .NNN 或 .*,NNN 为整型数字,星号作用同上。用于说明:
    • 整型转换后最少输出的数字位数
    • 浮点转换后小数点后的最少位数
    • 字符串转换后的最大字符数
  • lenmodifier:支持 hh/h/l/ll/j/z/t/L 等符号,用于说明参数长度 (及是否有符号)
  • convtype:支持 d/i/o/u/x/X/f/F/g/G/a/A/c/s/p/n/%/C/S 等符号,控制如何解释参数
flags
标志 说明
- 在字段内左对齐输出 (默认右对齐)
+ 总是显示带符号转换的符号 (即显示正负号)
space 如果第一个字符不是符号,则在其前面加上一个空格
# 指定另一种转换形式 (十六进制加 0x 前缀)
0 添加前导 0 (而非空格) 进行对齐

这里对 # 做个单独说明,直接上代码:

printf ("#: %#5d, %#5x, %#5on", 42, 42, 42);

同样的数据,使用 d/x/o 不同的转换类型 (转换类型请参考下面的小节) 指定输出 10/16/8 进制时,# 可以为它们添加合适的前缀 (无/0x/0):

#:    42,  0x2a,   052
lenmodifier
修饰符 说明
hh 有符号 (d/i) 或无符号 (u) 的 char
h 有符号 (d/i) 或无符号 (u) 的 short
l 有符号 (d/i) 或无符号 (u) 的 long 或宽字符
ll 有符号 (d/i) 或无符号 (u) 的 long long
j intmax_t 或 uintmax_t
z size_t
t ptrdiff_t
L long double

大部分人对于 lu/llu/ld/lld 更熟悉一些,如果只想打印一个整型的低两位字节或者最低位字节,可以用 hu/hd/hhu/hhd 代替强制转换。

对于 size_t/ptrdiff_t 等随系统位数变更长度的类型,不好指定 %lu 还是 %llu,因此统一使用单独的 %zu 及 %tu 代替。

除了可以用 %ld 或 %lu 指定长整数外,还可以通过 %lc 与 %ls 指定宽字符与宽字符串,以及 %Lf 或 %LF 指定长精度浮点。

%j 对应的 intmax_t 和 uintmax_t 是两种独立的类型,用来表示标准库支持的最大有符号整型和无符号整型,目前流行的系统支持的最大整数是 64 位,不过不排除将来扩展到 128 位、256 位… 无论位数如何扩展,intmax_t/uintmax_t 都可以指向系统支持的最大位数整型,不过目前支持的并不是非常好,不建议使用,原因参考附录。

convtype
转换类型 说明
d, i 有符号十进制
o 无符号八进制
u 无符号十进制
x, X 无符号十六进制
f, F double 精度浮点
e, E 指数格式的 double 精度浮点
g, G 解释为 f/F/e/E,取决于被转换的值
a, A 十六进制指数格式的 double 精度浮点数
c 字符
s 字符串
p 指向 void 的指针
n 将到目前为止所写的字符数写入到指针所指向的无符号整型中
% % 符号自身
C 宽字符,等价于 lc
S 宽字符串,等价于 ls

这里对 %n 做个单独说明,它可以将当前已经转换的字符数写入调用者提供的指向整型的指针中,用户可以根据得到的字符数排除输出数据中前 N 个转换成功的字符,方便出问题时缩小排查范围、快速定位转换失败位置。

scanf 函数族

int fscanf(FILE *restrict stream, const char *restrict format, ...); int scanf(const char *restrict format, ...); int sscanf(const char *restrict s, const char *restrict format, ...);  int vfscanf(FILE *restrict stream, const char *restrict format, va_list arg); int vscanf(const char *restrict format, va_list arg); int vsscanf(const char *restrict s, const char *restrict format, va_list arg);

因为不需要提供输出缓冲区,scanf 函数族的数量大为精简:

  • scanf 等价于 fscanf (stdint, format, ...);
  • sscanf 从缓冲区中获取变量的值而不是标准 IO,便于对已经从 IO 获取的数据进行处理
  • v 前缀的接口接受 va_list 参数代替可变参数 (...)

以上接口返回 EOF 表示遇到文件结尾或转换出错。

scanf format

所有 scanf 函数族都接受统一的 format 格式,它遵循下面的格式:

% [*] [fldwidth] [lenmodifier] convtype
  • *:用于抑制转换,按照转换说明的部分进行转换,但转换结果并不存放在参数中,适用于测试的场景
  • fldwidth:用于说明转换的最大字段宽度,含义刚好与 printf 函数族中的相反,类似前者的 precision。
  • lenmodifier:支持 hh/h/l/ll/j/z/t/L 等符号,用于说明要用转换结果初始化的参数大小 (及是否有符号),与 printf 函数族的用法相同
  • convtype:支持 d/i/o/u/x/f/F/g/G/a/A/e/E/c/s/[]/[^]//p/n/%/C/S 等符号,控制如何解释参数
convtype
转换类型 说明
d 有符号十进制,基数为 10
i 有符号十进制,基数由输入格式决定 (0x/0...)
o 无符号八进制 (输入可选的有符号)
u 无符号十进制,基数为 10 (输入可选的有符号)
x 无符号十六进制 (输入可选的有符号)
a, A, e, E, f, F, g, G 浮点数
c 字符
s 字符串
[ 匹配列出的字符序列,以 ] 终止
[^ 匹配除列出的字符以外的所有字符,以 ] 终止
p 指向 void 的指针
n 将到目前为止读取的字符数写入到指针所指向的无符号整型中
% % 字符本身
C 宽字符,等价于 lc
S 宽字符串,等价于 ls

与 printf 中的 convtype 的最大不同之处,是可以为无符号转换类型提供有符号的数据,例如 scanf ("%u", &longval) 将 -1 转换为 4294967295 存放在 int 整型中。

另外还有几品点不同:

  • %d/%i 含义不同,%d 仅能解析十进制数据,%i 可以解析 10/16/8 进制数据,取决于输入数据的前缀
  • %[a-zA-Z0-9_] 指定的范围可以读取一个由字母数字下划线组成的分词,[] 中可以设置任何想要的字符
  • %[^a-zA-Z0-9_] 刚好相反,遇到字母数字下划线则停止解析,[^] 中也可以设置任何不想要的字符中断解析

临时文件

不同的标准定义的临时文件函数不同,下面分别说明。

ISO C

ISO C 提供了两个函数用于帮助创建临时文件:

char *tmpnam(char *s); FILE *tmpfile(void);
  • tmpnam:只负责产生唯一的文件名,打开过程由调用者负责
  • tmpfile:以 "wb+" 方式打开一个临时文件流,调用者可以直接使用

tmpnam 的参数 s 用于存储生成的临时文件名,要求它指向的缓冲区长度至少是 L_tmpnam (CentOS 上是 20),生成成功时返回 s 给调用者。如果这个参数为 NULL,tmpnam 将使用内部的静态存储区记录临时文件名并返回,这样一来将导致 tmpnam 不是可重入的,既不线程安全也不信号安全。特别是连续生成多个临时文件名并分别保存指针的做法,所有指针都将指向最后一个文件名,这一点需要注意。

tmpfile 可以理解为在 tmpnam 的基础上做了一些额外的工作:

FILE* tmpfile(void) {     FILE* fp = NULL;      char tmp[L_tmpnam] = { 0 };      char *ptr = tempnam (tmp);      if (ptr != NULL)     {         fp = fopen (ptr, "wb+");          if (fp != NULL)             unlink (ptr);      }      return fp; }

上面伪代码的关键点就是自动打开临时文件并立即 unlink,以便在关闭文件流时系统自动删除临时文件。

虽然演示代码跨越了两个调用,实际上这个接口是原子的,它比 tmpnam + fopen 更安全,后者仍有一定的机率遇到进程间竞争导致的同名文件存在的问题,因此推荐使用前者。

tmpnam 生成的临时文件格式为:/tmp/fileXXXXXX,每个文件后缀由 6 个随机的数字、大小写字母组成,理论上可以生成 C626=61474519 (62=大写字母 26+小写字母 26+数字 10)。不过每个系统都会限制 tmpnam 不重复生成临时文件名的上限,这由 TMP_MAX 指明 (CentOS 上是 238328)。

XSI

Single UNIX Specification 的 XSI 扩展定义了另外的临时文件处理函数:

char *tempnam(const char *dir, const char *pfx); char *mktemp(char *template); int mkstemp(char *template);

类比 ISO C 提供的接口:

  • tempnam 与 mktemp 均生成文件名不创建文件,类似于 tmpnam
  • mkstemp 直接生成文件,类似于 tmpfile

不过有一些细节不同,下面分别说明。

tempnam 可以指定生成的临时文件名目录和前缀,目录规则由以下逻辑决定:

  1. 定义了 TMPDIR 环境变量且存在,用它;
  2. 参数 dir 非 NULL 且存在,用它;
  3. 常量 P_tmpdir (CentOS 上为 /tmp) 指定的目录存在,用它;
  4. 使用系统临时目录作为目录,通常是 /tmp。

系统临时目录 /tmp 作为保底策略时回退到和 tmpnam 相同的目录。需要注意,若提供的 dir 参数不起作为,可以检查

  • dir 指向的目录是否存在
  • 是否定义了 TMPDIR 环境变量

tempnam 的 pfx 参数指定临时文件前缀,至多使用这个参数的前 5 个字符,剩余部分将由系统随机生成来保证唯一性,例如以 cnblogs 作为前缀,生成的文件名可能是 cnbloaslfBV,即 cnbloXXXXXX 的形式,随机部分长度与形式和 tmpnam 保持一致。不提供 pfx 参数时 (指定 NULL),使用前缀 file 作为默认值。返回值由 malloc 分配,使用后需要调用者释放 (free),这避免了 tmpnam 使用静态存储区的弊端。

mktemp 也不限制临时文件目录,它采取的是另外一种策略:由用户提供临时文件的完整路径,只在末尾预留 6 个 X 字符以备系统改写,改写后的 template 参数用作返回值,调用失败时会清空 template 中的内容。整个过程只操作用户提供的存储空间,既无静态存储区,也无内存的分配和释放,在存储空间方面几乎是最优雅的解决方案。以 /home/yunh/code/apue/05.chapter/this_is_a_temp_name_XXXXXX 为例,生成的文件名为 /home/yunh/code/apue/05.chapter/this_is_a_temp_name_Rb89wh,随机变化的部分同 tmpnam 和 tempnam,如果没有将 XXXXXX 放在文件名末尾,或末尾的 X 字符数不足 6 个,则直接返回参数非法 (22) 的错误。

mkstemp 的临时文件命名规则与 mktemp 完全一致,可以理解为 mktemp + open 的组合,与 tmpfile = tmpnam + fopen 相似,不同的是:

  • mkstemp 返回文件句柄 (fd) 而不是 FILE*
  • mkstemp 打开文件后没有自动 unlink,关闭临时文件句柄后文件不会自动删除,需要手动调用 unlink 清理,文件路径可以直接通过更新后的 template 参数获取

以上就是  ISO C 与 SUS XSI 提供的临时文件接口,如果只在 *nix 系统上开发,可以使用强大的 XSI 接口;如果需要兼容 windows 等非 *nix 系统,最好使用 ISO C 接口。

结语

标准 IO 库固然优秀,但是也有一些后来者尝试改进它,主要是以下几种:

  • 减少数据复制,提高效率
    • fio:读取时直接返回 IO 库内部存储地址,减少一次数据拷贝
    • sfio:性能与 fio 差不多,提供存储区流、流处理模块、异常处理等功能。可以对一个流压入 (push) 处理模块,这一点非常类似 Solaris 的 STREAMS 系统,可参考 《[apue] 神奇的 Solaris pipe
    • ASI:使用 mmap 提高性能,接口类似于存储分配函数 (malloc/realloc/free)
  • 嵌入式等内存受限系统环境下更好的工作 (IO 库直接实现为 C 库的一部分)
    • uClibc
    • newlibc

原书提到的这几个库基本是老古董了,有一些早已停止更新,相对于性能提升,stdio 带来的通用性、可移植性它们无法取代的,不建议替换。不过作为一个审视标准 IO 库缺点的视角,还是有一定意义的,感兴趣的读者可以自行搜索相关资讯。

参考

[1]. linux编程 fmemopen函数打开一个内存流 使用FILE指针进行读写访问

[2]. 文件输入/输出 | File input/output

[3]. 走进C标准库(3)——"stdio.h"中的getc和ungetc

[4]. linux下如何通过lseek定位大文件

[5]. 对大文件写操作时谨慎使用fseek/lseek

[6]. lseek64的使用

[7]. 组合排列在线计算器

[8]. 32位Linux下使用2G以上大文件的几个相关宏的关系

[9]. A Special Kind of Hell - intmax_t in C and C++

[10]. Are the benefits of SFIO over STDIO still valid?

[11]. 关于setvbuf()函数的详解

[12]. setbuf函数详解

[13]. setvbuf - cppreference.com