[apue] 標準 I/O 庫那些事兒

2022-09-20 15:00:22

前言

標準 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 ("OK\n"); 
  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 %d\n", wide); 
  else
      fprintf (fp, "do fwide %d\n", 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 = %d\n", 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 ("unbuffered\n"); 
  else if (fp->_flags & _IO_LINE_BUF)
    printf ("line-buffered\n"); 
  else 
    printf ("fully-buffered\n"); 

  printf ("buffer size is %d, %p\n", fp->_IO_buf_end - fp->_IO_buf_base, fp->_IO_buf_base); 
  printf ("discriptor is %d\n\n", 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 = %d\n", a); 
  tell_buf ("stdin", stdin); 
  tell_buf ("stdout", stdout); 
  tell_buf ("stderr", stderr); 
  fprintf (stderr, "a = %d\n", a); 
  tell_buf ("stderr", stderr); 
  printf ("\n"); 

  char buf[BUFSIZ] = { 0 }; 
  printf ("bufsiz = %d, address = %p\n", 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 = %d\n", a); 
  tell_buf ("stdout (full, 2048)", stdout); 
  setvbuf (stderr, buf, _IOLBF, 1024); 
  fprintf (stderr, "a = %d\n", a); 
  tell_buf ("stderr (line, 1024)", stderr); 
  setvbuf (stdout, NULL, _IOLBF, 4096); 
  fprintf (stdout, "a = %d\n", a); 
  tell_buf ("stdout (line null 4096)", stdout); 
  setvbuf (stderr, NULL, _IOFBF, 3072); 
  fprintf (stderr, "a = %d\n", a); 
  tell_buf ("stderr (full null 3072)", stderr); 
  setvbuf (stdout, NULL, _IOFBF, 0); 
  fprintf (stdout, "a = %d\n", a); 
  tell_buf ("stdout (full null 0)", stdout); 
  setvbuf (stderr, NULL, _IOLBF, 0); 
  fprintf (stderr, "a = %d\n", 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 %p\n", 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 %c\n", (unsigned char) ret); 
  }

  if (feof (stdin))
    printf ("reach EndOfFile\n"); 
  else 
    printf ("not reach EndOfFile\n"); 

  if (ferror (stdin))
    printf ("read error\n"); 
  else 
    printf ("not read error\n"); 

  ungetc ('O', stdin); 
  printf ("after ungetc\n"); 

  if (feof (stdin))
    printf ("reach EndOfFile\n"); 
  else 
    printf ("not reach EndOfFile\n"); 

  if (ferror (stdin))
    printf ("read error\n"); 
  else 
    printf ("not read error\n"); 

  unsigned long long i = 0; 
  char ch = 0; 
  while (1)
  {
    ch = 'a' + i % 26; 
    if (ungetc (ch, stdin) < 0)
    {
      printf ("ungetc %c failed\n", ch); 
      break; 
    }
    ++ i; 
    if (i % 100000000 == 0)
        printf ("unget %llu: %c\n", i, ch); 
  }

  printf ("unget %llu chars\n", i); 
  if (ungetc (EOF, stdin) == EOF)
    printf ("ungetc EOF failed\n"); 

  while (1)
  {
    ret = getc (stdin); 
    if (ret == EOF)
      break; 

    if (i % 100000000 == 0 || i <  30)
        printf ("read %llu: %c\n", 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 %c\n", '\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 %d\n", pos[i], errno); 
      }
      else 
      {
          printf ("fseeko to %llu\n", pos[i]); 
          ret = ftello (fp); 
          printf ("after fseeko: %llu\n", 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 %c\n", (unsigned char) ret); 
  }

  if (feof (stdin))
    printf ("reach EndOfFile\n"); 
  else 
    printf ("not reach EndOfFile\n"); 

  if (ferror (stdin))
    printf ("read error\n"); 
  else 
    printf ("not read error\n"); 

  if (fseek (stdin, 13, SEEK_SET) == -1)
    printf ("fseek failed\n"); 
  else 
    printf ("fseek to 13\n"); 

  printf ("after fseek\n"); 
  if (feof (stdin))
    printf ("reach EndOfFile\n"); 
  else 
    printf ("not reach EndOfFile\n"); 

  if (ferror (stdin))
    printf ("read error\n"); 
  else 
    printf ("not read error\n"); 

  int i = 0; 
  char ch = 0; 
  for (i=0; i<26; ++ i)
  {
    ch = 'a'+i; 
    if (ungetc (ch, stdin) != ch)
    {
      printf ("ungetc failed\n"); 
      break; 
    }
    else 
      printf ("ungetc %c\n", ch); 
  }

  if (fseek (stdin, 20, SEEK_SET) == -1)
    printf ("fseek failed\n"); 
  else 
    printf ("fseek to 20\n"); 

  while (1)
  {
    ret = getc (stdin); 
    if (ret == EOF)
      break; 

    printf ("read %c\n", (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, %#5o\n", 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