天天看點

C 标準庫 IO 使用詳解

其實輸入與輸出對于不管什麼系統的設計都是異常重要的,比如設計 C 接口函數,首先要設計好輸入參數、輸出參數和傳回值,接下來才能開始設計具體的實作過程。C 語言标準庫提供的接口功能很有限,不像 Python 庫。不過想把它用好也不容易,本文總結 C 标準庫基礎 IO 的常見操作和一些特别需要注意的問題,如果你覺着自己還不是大神,那麼請相信我,讀完全文後你肯定會有不少收獲。

一、操作句柄

打開檔案其實就是在作業系統中配置設定一些資源用于儲存該檔案的狀态資訊及檔案的辨別,以後使用者程式可以用這個辨別做各種讀寫操作,關閉檔案則釋放占用的資源。

打開檔案的函數:

#include <stdio.h>
FILE *fopen(const char *path, const char *mode);           

FILE 是 C 标準庫定義的結構體類型,其包含檔案在核心中的辨別(檔案描述符)、I/O 緩沖區和目前讀寫位置資訊,調用者不需知道 FILE 的具體成員,由庫函數内部維護,調用者不應該直接通路這些成員。像 FILE* 這樣的檔案指針稱為句柄(Handle)。

打開檔案操作是對檔案資源進行操作的,是以有可能打開檔案失敗,是以在打開函數時一定要判斷傳回值,如果失敗則傳回錯誤資訊,以友善快速定位錯誤。

打開檔案應該與關閉檔案成對存在,雖然程式在退出時會釋放相應的資源,但是對于一個長時間運作服務程式來說,經常打開而不關閉檔案是會造成程序資源耗盡的,因為程序的檔案描述符個數是有限的,及時關閉檔案是個好習慣。

關閉檔案的函數:

#include <stdio.h>
int fclose(FILE *fp);           

fopen 函數參數 mode 總結:

  • "r":隻讀,檔案必須存在。
  • "w":隻寫,如果不存在則建立,存在則覆寫。
  • "a":追加,如果不存在則建立。
  • "r+":允許讀和寫,檔案必須存在。
  • "w+":允許讀和寫,檔案不存在則建立,存在則覆寫。
  • "a+":允許讀和追加,檔案不存在則建立。

二、關于stdin/stdout/stderr

在使用者程式啟動時,main 函數還沒開始執行之前,會自動打開三個 FILE* 指針分别是:stdin、stdout、stderr,這三個檔案指針是 libc 中定義的全局變量,在 stdio.h 中聲明,printf 向 stdout 寫,而 scanf 從 stdin 讀,使用者程式也可以直接使用這三個檔案指針。

  • stdin 隻用于讀操作,稱為标準輸入
  • stdout 隻用于寫操作,稱為标準輸出
  • stderr 也用于寫操作,稱為标準錯誤輸出

通常程式的運作結果列印到标準輸出,而錯誤提示列印到标準錯誤輸出,一般标準輸出和标準錯誤都是螢幕。通常可以标準輸出重定向到一個正常檔案,而标準錯誤輸出仍然對應終端裝置,這樣就可以将運作結果與錯誤資訊分開。

三、以位元組為機關的IO函數

fgetc 函數從指定的檔案中讀一個位元組,getchar從标準輸入讀一個位元組,調用 getchar() 相當于 fgetc(stdin)

#include <stdio.h>
int fgetc(FILE *stream);
int getchar(void);           

fputc 函數向指定的檔案寫入一個位元組,putchar 向标準輸出寫一個位元組,調用 putchar() 相當于調用 fputc(c, stdout)。

#include <stdio.h>
int fputc(int c, FILE *stream);
int putchar(int c);           

參數和傳回值類型為什麼使用 int 類型?可以看到這幾個函數的參數和傳回值類型都是 int,而非 unsigned char 型。因為錯誤或讀到檔案末尾時将傳回 EOF,即 -1,如果傳回值是 unsigned char(0xff),與實際讀到位元組 0xff 無法區分,如果使用 int 就可以避免這個問題。

四、操作讀寫位置函數

當我們在操作檔案時,有一個叫「檔案指針」的家夥來記錄目前操作的檔案位置,比如剛打開檔案,調用了 1 次 fgetc 後,此時檔案指針指向了第 1 個位元組後邊,注意是以位元組為機關記錄的。

改變檔案指針位置的函數:

#include <stdio.h>
int fseek(FILE *stream, long offset, int whence);
whence:從何處開始移動,取值:SEEK_SET | SEEK_CUR | SEEK_END
offset:移動偏移量,取值:可取正 | 負
void rewind(FILE *stream);           

舉幾個簡單例子:

fseek(fp, 5, SEEK_SET);     // 從檔案頭向後移動5個位元組
fseek(fp, 6, SEEK_CUR);     // 從目前位置向後移動6個位元組
fseek(fp, -3, SEEK_END);    // 從檔案尾向前移動3個位元組           

offset 可正可負,負值表示向檔案開頭的方向移動,正值表示向檔案尾方向移動,如果向前移動的位元組數超過檔案開頭則出錯傳回,如果向後移動的位元組數超過了檔案末尾,再次寫入會增加檔案尺寸,檔案空洞位元組都是 0

$ echo "5678" > file.txt

fp = fopen("file.txt", "r+");
fseek(fp, 10, SEEK_SET);
fputc('K', fp)
fclose(fp)

// 通過結果可以看出字母K是從第10個位置開始寫的
liwei:/tmp$ od -tx1 -tc -Ax file.txt 
0000000    35  36  37  38  0a  00  00  00  00  00  4b                    
           5   6   7   8  \n  \0  \0  \0  \0  \0   K           

rewind(fp) 等價于 fseek(fp, 0, SEEK_SET)

ftell(fp) 函數比較簡單,直接傳回目前檔案指針在檔案中的位置

// 實作計算檔案位元組數的功能
fseek(fp, 0, SEEK_END);
ftell(fp);           

五、以字元串為機關的IO函數

fgets 從指定的檔案中讀一行字元到調用者提供的緩沖區,讀入内容不超過 size 。

char *fgets(char *s, int size, FILE *stream);
char *gets(char *s);           

首先要說明 gets() 函數強烈不推薦使用,類似 strcpy 函數,使用者不可以指定緩沖區大小,很容易造成緩沖區溢出錯誤。不過 strcpy 程式員還是可以避免,而 gets 的輸入使用者可以提供任意長的字元串,唯一避免方法就是不使用 gets,而使用 fgets(buf, size, stdin)

fgets 函數從 stream 所指檔案讀取以 '\n' 結尾的一行,包括 '\n' 在内,存到緩沖區中,并在該行結尾添加一個 '\0' 組成完整的字元串。如果檔案一行太長,fgets 從檔案中讀了 size-1 個字元還沒有讀到 '\n',就把已經讀到的 size-1 個字元和一個 '\0' 字元存入緩沖區,檔案行剩餘的内容可以在下次調用 fgets 時繼續讀。

若一次 fgets 調用在讀入若幹字元後到達檔案末尾,則将已讀到的字元加上 '\0' 存入緩沖區并傳回,如果再次調用則傳回 NULL,可以據此判斷是否讀到檔案末尾。

fputs 向指定檔案寫入一個字元串,緩沖區儲存的是以 '\0' 結尾的字元串,與 fgets 不同的是,fputs 不關心字元串中的 '\n' 字元。

int fputs(const char *s, FILE *stream);
int puts(const char *s);           

六、以記錄為機關的IO函數

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

fread 和 fwrite 用于讀寫記錄,這裡的記錄是指一串固定長度的位元組,比如一個 int、一個結構體貨或一個定長數組。

參數 size 指出一條記錄的長度,nmemb 指出要讀或寫多少條記錄,這些記錄在 ptr 所指記憶體空間連續存放,共占 size * nmemb 個位元組。

fread 和 fwrite 傳回的記錄數有可能小于 nmemb 指定的記錄數。例如當讀寫位置距檔案末尾隻有一條記錄長度,調用 fread 指定 nmemb 為 2,則傳回值為 1。如果寫檔案時出錯,則 fwrite 的傳回值小于 nmemb 指定的值。

struct t{
    int   a;
    short b;
};
struct t val = {1, 2};
FILE *fp = fopen("file.txt", "w");
fwrite(&val, sizeof(val), 1, fp);
fclose(fp);

liwei:/tmp$ od -tx1 -tc -Ax file.txt 
0000000    01  00  00  00  02  00  00  00                                
         001  \0  \0  \0 002  \0  \0  \0           

從結果可以看出,寫入的是 8 個位元組,有興趣的同學可以就此分析下系統的「大小端」和結構體的「對齊補齊」問題。

七、格式化IO函數

(1). printf / scanf

int printf(const char *format, ...);
int scanf(const char *format, ...);           

這兩個函數是我們學習 C 語言最早接觸,可能也是接觸比較多的了,沒什麼特别要說的。printf 就是格式化列印到标準輸出。下面總結下 printf 常用的方式。

printf("%d\n", 5);            // 列印整數 5
printf("-%10s-\n", "hello")   // 設定顯示寬度并左對齊:-     hello-
printf("-%-10s-\n", "hello")  // 設定顯示寬度并右對齊:-     hello-
printf("%#x\n", 0xff);        // 0xff 不加#則顯示ff
printf("%p\n", main);         // 列印 main 函數首位址
printf("%%\n");               // 列印一個 %           

scanf 就是從标準輸入中讀取格式化資料,簡單舉個例子:

int year, month, day;
scanf("%d/%d/%d", &year, &month, &day);
printf("year = %d, month = %d, day = %d\n", year, month, day);           

(2). sprintf / sscanf / snprintf

sprintf 并不列印到檔案,而是列印到使用者提供的緩沖區中并在末尾加 '\0',由于格式化後的字元串長度很難預計,是以很可能造成緩沖區溢出,強烈推薦 snprintf 更好一些,參數 size 指定了緩沖區長度,如果格式化後的字元串超過緩沖區長度,snprintf 就把字元串截斷到 size - 1 位元組,再加上一個 '\0',保證字元串以 '\0' 結尾。如果發生截斷,傳回值是截斷之前的長度,通過對比傳回值與緩沖區實際長度對比就知道是否發生截斷。

int sscanf(const char *str, const char *format, ...);
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);           

sscanf 是從輸入字元串中按照指定的格式去讀取相應的資料,函數功能非常的強大,支援類似正規表達式比對的功能。具體的使用格式請自行查詢官方手冊,這裡總結出最常用、最重要的幾種使用場景和方式。

  • 最基本的用法
char buf[1024] = 0;
sscanf("123456", "%s", buf);
printf("%s\n", buf);
// 結果為:123456           
  • 取指定長度的字元串
sscanf("123456", "%4s", buf);
printf("%s\n", buf);
// 結果為:1234           
  • 取第1個字元串
sscanf("hello world", "%s", buf);
printf("%s\n", buf);
// 結果為:hello  因為預設是以空格來分割字元串的,%s讀取第一個字元串hello           
  • 讀取到指定字元為止的字元串
sscanf("123456#abcdef", "%[^#]", buf);
// 結果為:123456
// %[^#]表示讀取到#符号停止,不包括#           
  • 讀取僅包含指定字元集的字元串
sscanf("123456abcdefBCDEF", "%[1-9a-z]", buf);
// 結果為:123456abcdef
// 表達式是要比對數字和小寫字母,比對到大寫字母就停止比對了。           
  • 讀取指定字元集為止的字元串
sscanf("123456abcdefBCDEF", "%[^A-Z]", buf);
// 結果為:123456abcdef           
  • 讀取兩個符号之間的内容(@和.之間的内容)
sscanf("[email protected]", "%*[^@]@%[^.]", buf);
// 結果為:linuxblogs
// 先讀取@符号前邊内容并丢棄,然後讀@,接着讀取.符号之前的内容linuxblogs,不包含字元.           
  • 給一個字元串
sscanf("hello, world", "%*s%s", buf);
// 結果為:world
// 先忽略一個字元串"hello,",遇到空格直接跳過,比對%s,儲存 world 到 buf
// %*s 表示第 1 個比對到的被過濾掉,即跳過"hello,",如果沒有空格,則結果為 NULL           
  • 稍微複雜點的
sscanf("ABCabcAB=", "%*[A-Z]%*[a-z]%[^a-z=]", buf);
// 結果為:AB  自己嘗試分析哈           
  • 包含特殊字元處理
sscanf("201*1b_-cdZA&", "%[0-9|_|--|a-z|A-Z|&|*]", buf);
// 結果為:201*1b_-cdZA&           

如果能将上述幾個例子搞明白,相信基本上已經掌握了 sscanf 的用法,實踐才是檢驗真理的唯一标準,隻有多使用,多思考才能真正了解它的用法。

(3). fprintf / fscanf

fprintf 列印到指定的檔案 stream 中,fscanf 從檔案中格式化讀取資料,類似 scanf 函數。相關函數的聲明如下:

int fprintf(FILE *stream, const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);           

還是通過簡單執行個體來說明基本用法。

FILE *fp = fopen("file.txt", "w");
fprintf(fp, "%d-%s-%f\n", 32, "hello", 0.12);
fclose(fp);

liwei:/tmp$ cat file.txt 
32-hello-0.120000           

而 fscanf 函數的使用基本上與 sscanf 函數使用方式相同。

八、IO緩沖區

還有個關于 IO 非常重要的概念,就是 IO 緩沖區。

C 标準庫為每個打開的檔案配置設定一個 I/O 緩沖區,使用者調用讀寫函數大多數都在 I/O 緩沖區中讀寫,隻有少數請求傳遞給核心。

以 fgetc/fputc 為例,當第一次調用 fgetc 讀一個位元組時,fgetc 函數可能通過系統調用進入核心讀 1k 位元組到緩沖區,然後傳回緩沖區中第一個位元組給使用者,以後使用者再調用 fgetc,就直接從緩沖區讀取。

另一方面,fputc 通常隻是寫到緩沖區中,如果緩沖區滿了,fputc 就通過系統調用把緩沖區資料傳遞給核心,核心将資料寫回磁盤。如果希望把緩沖區資料立即寫入磁盤,可以調用 fflush 函數。

C 标準庫 IO 緩沖區有三種類型:全緩沖、行緩沖和無緩沖區,不同類型的緩沖區具有不同的特性。

  • 全緩沖

    :如果緩沖區寫滿了就寫回核心。正常檔案通常是全緩沖的。
  • 行緩沖

    :如果程式寫的資料中有換行符就把這一行寫回核心,或者緩沖區滿就寫回核心。标準輸入和标準輸出對應終端裝置時通常是行緩沖的。
  • 無緩沖

    :使用者程式每次調用庫函數做寫操作都要通過系統調用寫回核心。标準錯誤輸出通常是無緩沖的,使用者程式的錯誤資訊可以盡快輸出到裝置。
printf("hello world");
while(1);
// 運作程式會發現螢幕并沒有列印hello world
// 因為緩沖區沒滿,且沒有\n符号           

除了寫滿緩沖區、寫入換行符之外,行緩沖還有一種情況會自動做 flush 操作,如果:

  • 使用者程式調用庫函數從無緩沖的檔案中讀取
  • 或從行緩沖的檔案中讀取,且這次讀操作會引發系統調用從核心讀取資料,那麼會讀之前自動 flush 所有行緩沖
  • 程式退出時通常也會自動 flush 緩沖區

如果不想完全依賴自動的 flush 操作,可以調用 fflush 函數手動操作。若調用 fflush(NULL) 可以對所有打開檔案的 IO 緩沖區做 flush 操作。緩沖區大小也可以自定義設定,一般情況無需設定,預設即可。