2. 標準I/O庫函數

2.1. 檔案的基本概念

我們已經多次用到了檔案,例如源檔案、目標檔案、執行檔、庫檔案等,現在學習如何用C標準庫對檔案進行讀寫操作,對檔案的讀寫也屬於I/O操作的一種,本節介紹的大部分函數在標頭檔stdio.h中聲明,稱為標準I/O庫函數。

檔案可分為文本檔案(Text File)和二進制檔案(Binary File)兩種,源檔案是文本檔案,而目標檔案、執行檔和庫檔案是二進制檔案。文本檔案是用來保存字元的,檔案中的位元組都是字元的某種編碼(例如ASCII或UTF-8),用cat命令可以查看其中的字元,用vi可以編輯其中的字元,而二進制檔案不是用來保存字元的,檔案中的位元組表示其它含義,例如執行檔中有些位元組表示指令,有些位元組表示各Section和Segment在檔案中的位置,有些位元組表示各Segment的加載地址。

第 5.1 節 “目標檔案”中我們用hexdump命令查看過一個二進制檔案。我們再做一個小實驗,用vi編輯一個檔案textfile,在其中輸入5678然後保存退出,用ls -l命令可以看到它的長度是5:

$ ls -l textfile 
-rw-r--r-- 1 akaedu akaedu 5 2009-03-20 10:58 textfile

5678四個字元各占一個位元組,vi會自動在檔案末尾加一個換行符,所以檔案長度是5。用od命令查看該檔案的內容:

$ od -tx1 -tc -Ax textfile 
000000 35 36 37 38 0a
         5   6   7   8  \n
000005

-tx1選項表示將檔案中的位元組以十六進制的形式列出來,每組一個位元組,-tc選項表示將檔案中的ASCII碼以字元形式列出來。和hexdump類似,輸出結果最左邊的一列是檔案中的地址,預設以八進制顯示,-Ax選項要求以十六進制顯示檔案中的地址。這樣我們看到,這個檔案中保存了5個字元,以ASCII碼保存。ASCII碼的範圍是0~127,所以ASCII碼文本檔案中每個位元組只用到低7位,最高位都是0。以後我們會經常用到od命令。

文本檔案是一個模糊的概念。有些時候說文本檔案是指用vi可以編輯出來的檔案,例如/etc目錄下的各種配置檔案,這些檔案中只包含ASCII碼中的可見字元,而不包含像'\0'這種不可見字元,也不包含最高位是1的非ASCII碼位元組。從廣義上來說,只要是專門保存字元的檔案都算文本檔案,包含不可見字元的也算,採用其它字元編碼(例如UTF-8編碼)的也算。

2.2. fopen/fclose

在操作檔案之前要用fopen打開檔案,操作完畢要用fclose關閉檔案。打開檔案就是在操作系統中分配一些資源用於保存該檔案的狀態信息,並得到該檔案的標識,以後用戶程序就可以用這個標識對檔案做各種操作,關閉檔案則釋放檔案在操作系統中占用的資源,使檔案的標識失效,用戶程序就無法再操作這個檔案了。

#include <stdio.h>

FILE *fopen(const char *path, const char *mode);
返回值:成功返回檔案指針,出錯返回NULL並設置errno

path是檔案的路徑名,mode表示打開方式。如果檔案打開成功,就返回一個FILE *檔案指針來標識這個檔案。以後調用其它函數對檔案做讀寫操作都要提供這個指針,以指明對哪個檔案進行操作。FILE是C標準庫中定義的結構體類型,其中包含該檔案在內核中標識(在第 2 節 “C標準I/O庫函數與Unbuffered I/O函數”將會講到這個標識叫做檔案描述符)、I/O緩衝區和當前讀寫位置等信息,但調用者不必知道FILE結構體都有哪些成員,我們很快就會看到,調用者只是把檔案指針在庫函數介面之間傳來傳去,而檔案指針所指的FILE結構體的成員在庫函數內部維護,調用者不應該直接訪問這些成員,這種編程思想在面向對象方法論中稱為封裝(Encapsulation)。像FILE *這樣的指針稱為不透明指針(Opaque Pointer)或者叫句柄(Handle)FILE *指針就像一個把手(Handle),抓住這個把手就可以打開門或抽屜,但用戶只能抓這個把手,而不能直接抓門或抽屜。

下面說說參數pathmodepath可以是相對路徑也可以是絶對路徑,mode表示打開方式是讀還是寫。比如fp = fopen("/tmp/file2", "w");表示打開絶對路徑/tmp/file2,只做寫操作,path也可以是相對路徑,比如fp = fopen("file.a", "r");表示在當前工作目錄下打開檔案file.a,只做讀操作,再比如fp = fopen("../a.out", "r");只讀打開當前工作目錄上一層目錄下的a.outfp = fopen("Desktop/file3", "w");只寫打開當前工作目錄下子目錄Desktop下的file3。相對路徑是相對於當前工作目錄(Current Working Directory)的路徑,每個進程都有自己的當前工作目錄,Shell進程的當前工作目錄可以用pwd命令查看:

$ pwd
/home/akaedu

通常Linux發行版都把Shell配置成在提示符前面顯示當前工作目錄,例如~$表示當前工作目錄是主目錄,/etc$表示當前工作目錄是/etc。用cd命令可以改變Shell進程的當前工作目錄。在Shell下敲命令啟動新的進程,則該進程的當前工作目錄繼承自Shell進程的當前工作目錄,該進程也可以調用chdir(2)函數改變自己的當前工作目錄。

mode參數是一個字元串,由rwatb+六個字元組合而成,r表示讀,w表示寫,a表示追加(Append),在檔案末尾追加數據使檔案的尺寸增大。t表示文本檔案,b表示二進制檔案,有些操作系統的文本檔案和二進制檔案格式不同,而在UNIX系統中,無論文本檔案還是二進制檔案都是由一串位元組組成,tb沒有區分,用哪個都一樣,也可以省略不寫。如果省略tbrwa+四個字元有以下6種合法的組合:

"r"

只讀,檔案必須已存在

"w"

只寫,如果檔案不存在則創建,如果檔案已存在則把檔案長度截斷(Truncate)為0位元組再重新寫,也就是替換掉原來的檔案內容

"a"

只能在檔案末尾追加數據,如果檔案不存在則創建

"r+"

允許讀和寫,檔案必須已存在

"w+"

允許讀和寫,如果檔案不存在則創建,如果檔案已存在則把檔案長度截斷為0位元組再重新寫

"a+"

允許讀和追加數據,如果檔案不存在則創建

在打開一個檔案時如果出錯,fopen將返回NULL並設置errnoerrno稍後介紹。在程序中應該做出錯處理,通常這樣寫:

if ( (fp = fopen("/tmp/file1", "r")) == NULL) {
	printf("error open file /tmp/file1!\n");
	exit(1);
}

比如/tmp/file1這個檔案不存在,而r打開方式又不會創建這個檔案,fopen就會出錯返回。

再說說fclose函數。

#include <stdio.h>

int fclose(FILE *fp);
返回值:成功返回0,出錯返回EOF並設置errno

把檔案指針傳給fclose可以關閉它所標識的檔案,關閉之後該檔案指針就無效了,不能再使用了。如果fclose調用出錯(比如傳給它一個無效的檔案指針)則返回EOF並設置errnoerrno稍後介紹,EOFstdio.h中定義:

/* End of file character.
   Some things throughout the library rely on this being -1.  */
#ifndef EOF
# define EOF (-1)
#endif

它的值是-1。fopen調用應該和fclose調用配對,打開檔案操作完之後一定要記得關閉。如果不調用fclose,在進程退出時系統會自動關閉檔案,但是不能因此就忽略fclose調用,如果寫一個長年累月運行的程序(比如網絡伺服器程序),打開的檔案都不關閉,堆積得越來越多,就會占用越來越多的系統資源。

2.3. stdin/stdout/stderr

我們經常用printf打印到屏幕,也用過scanf讀鍵盤輸入,這些也屬於I/O操作,但不是對檔案做I/O操作而是對終端設備做I/O操作。所謂終端(Terminal)是指人機交互的設備,也就是可以接受用戶輸入並輸出信息給用戶的設備。在計算機剛誕生的年代,終端是電傳打字機和打印機,現在的終端通常是鍵盤和顯示器。終端設備和檔案一樣也需要先打開後操作,終端設備也有對應的路徑名,/dev/tty就表示和當前進程相關聯的終端設備(在第 1.1 節 “終端的基本概念”會講到這叫進程的控制終端)。也就是說,/dev/tty不是一個普通的檔案,它不表示磁碟上的一組數據,而是表示一個設備。用ls命令查看這個檔案:

$ ls -l /dev/tty
crw-rw-rw- 1 root dialout 5, 0 2009-03-20 19:31 /dev/tty

開頭的c表示檔案類型是字元設備。中間的5, 0是它的設備號,主設備號5,次設備號0,主設備號標識內核中的一個設備驅動程式,次設備號標識該設備驅動程式管理的一個設備。內核通過設備號找到相應的驅動程式,完成對該設備的操作。我們知道常規檔案的這一列應該顯示檔案尺寸,而設備檔案的這一列顯示設備號,這表明設備檔案是沒有檔案尺寸這個屬性的,因為設備檔案在磁碟上不保存數據,對設備檔案做讀寫操作並不是讀寫磁碟上的數據,而是在讀寫設備。UNIX的傳統是Everything is a file,鍵盤、顯示器、串口、磁碟等設備在/dev目錄下都有一個特殊的設備檔案與之對應,這些設備檔案也可以像普通檔案一樣打開、讀、寫和關閉,使用的函數介面是相同的。本書中不嚴格區分“檔案”和“設備”這兩個概念,遇到“檔案”這個詞,讀者可以根據上下文理解它是指普通檔案還是設備,如果需要強調是保存在磁碟上的普通檔案,本書會用“常規檔案”(Regular File)這個詞。

那為什麼printfscanf不用打開就能對終端設備進行操作呢?因為在程序啟動時(在main函數還沒開始執行之前)會自動把終端設備打開三次,分別賦給三個FILE *指針stdinstdoutstderr,這三個檔案指針是libc中定義的全局變數,在stdio.h中聲明,printfstdout寫,而scanfstdin讀,後面我們會看到,用戶程序也可以直接使用這三個檔案指針。這三個檔案指針的打開方式都是可讀可寫的,但通常stdin只用於讀操作,稱為標準輸入(Standard Input)stdout只用於寫操作,稱為標準輸出(Standard Output)stderr也只用於寫操作,稱為標准錯誤輸出(Standard Error),通常程序的運行結果打印到標準輸出,而錯誤提示(例如gcc報的警告和錯誤)打印到標准錯誤輸出,所以fopen的錯誤處理寫成這樣更符合慣例:

if ( (fp = fopen("/tmp/file1", "r")) == NULL) {
	fputs("Error open file /tmp/file1\n", stderr);
	exit(1);
}

fputs函數將在稍後詳細介紹。不管是打印到標準輸出還是打印到標准錯誤輸出效果是一樣的,都是打印到終端設備(也就是屏幕)了,那為什麼還要分成標準輸出和標准錯誤輸出呢?以後我們會講到重定向操作,可以把標準輸出重定向到一個常規檔案,而標准錯誤輸出仍然對應終端設備,這樣就可以把正常的運行結果和錯誤提示分開,而不是混在一起打印到屏幕了。

2.4. errno與perror函數

很多系統函數在錯誤返回時將錯誤原因記錄在libc定義的全局變數errno中,每種錯誤原因對應一個錯誤碼,請查閲errno(3)的Man Page瞭解各種錯誤碼,errno在標頭檔errno.h中聲明,是一個整型變數,所有錯誤碼都是正整數。

如果在程序中打印錯誤信息時直接打印errno變數,打印出來的只是一個整數值,仍然看不出是什麼錯誤。比較好的辦法是用perrorstrerror函數將errno解釋成字元串再打印。

#include <stdio.h>

void perror(const char *s);

perror函數將錯誤信息打印到標准錯誤輸出,首先打印參數s所指的字元串,然後打印:號,然後根據當前errno的值打印錯誤原因。例如:

例 25.4. perror

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
	FILE *fp = fopen("abcde", "r");
	if (fp == NULL) {
		perror("Open file abcde");
		exit(1);
	}
	return 0;
}

如果檔案abcde不存在,fopen返回-1並設置errnoENOENT,緊接着perror函數讀取errno的值,將ENOENT解釋成字元串No such file or directory並打印,最後打印的結果是Open file abcde: No such file or directory。雖然perror可以打印出錯誤原因,傳給perror的字元串參數仍然應該提供一些額外的信息,以便在看到錯誤信息時能夠很快定位是程序中哪裡出了錯,如果在程序中有很多個fopen調用,每個fopen打開不同的檔案,那麼在每個fopen的錯誤處理中打印檔案名就很有幫助。

如果把上面的程序改成這樣:

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

int main(void)
{
	FILE *fp = fopen("abcde", "r");
	if (fp == NULL) {
		perror("Open file abcde");
		printf("errno: %d\n", errno);
		exit(1);
	}
	return 0;
}

printf打印的錯誤號並不是fopen產生的錯誤號,而是perror產生的錯誤號。errno是一個全局變數,很多系統函數都會改變它,fopen函數Man Page中的ERRORS部分描述了它可能產生的錯誤碼,perror函數的Man Page中沒有ERRORS部分,說明它本身不產生錯誤碼,但它調用的其它函數也有可能改變errno變數。大多數系統函數都有一個Side Effect,就是有可能改變errno變數(當然也有少數例外,比如strcpy),所以一個系統函數錯誤返回後應該馬上檢查errno,在檢查errno之前不能再調用其它系統函數。

strerror函數可以根據錯誤號返回錯誤原因字元串。

#include <string.h>

char *strerror(int errnum);
返回值:錯誤碼errnum所對應的字元串

這個函數返回指向靜態內存的指針。以後學綫程庫時我們會看到,有些函數的錯誤碼並不保存在errno中,而是通過返回值返回,就不能調用perror打印錯誤原因了,這時strerror就派上了用場:

fputs(strerror(n), stderr);

習題

1、在系統標頭檔中找到各種錯誤碼的宏定義。

2、做幾個小練習,看看fopen出錯有哪些常見的原因。

打開一個沒有訪問權限的檔案。

fp = fopen("/etc/shadow", "r");
if (fp == NULL) {
	perror("Open /etc/shadow");
	exit(1);
}

fopen也可以打開一個目錄,傳給fopen的第一個參數目錄名末尾可以加/也可以不加/,但只允許以只讀方式打開。試試如果以可寫的方式打開一個存在的目錄會怎麼樣呢?

fp = fopen("/home/akaedu/", "r+");
if (fp == NULL) {
	perror("Open /home/akaedu");
	exit(1);
}

請讀者自己設計幾個實驗,看看你還能測試出哪些錯誤原因?

2.5. 以位元組為單位的I/O函數

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

#include <stdio.h>

int fgetc(FILE *stream);
int getchar(void);
返回值:成功返回讀到的位元組,出錯或者讀到檔案末尾時返回EOF

注意在Man Page的函數原型中FILE *指針參數有時會起名叫stream,這是因為標準I/O庫操作的檔案有時也叫做流(Stream),檔案由一串位元組組成,每次可以讀或寫其中任意數量的位元組,以後介紹TCP協議時會對流這個概念做更詳細的解釋。

對於fgetc函數的使用有以下幾點說明:

  • 要用fgetc函數讀一個檔案,該檔案的打開方式必須是可讀的。

  • 系統對於每個打開的檔案都記錄著當前讀寫位置在檔案中的地址(或者說距離檔案開頭的位元組數),也叫偏移量(Offset)。當檔案打開時,讀寫位置是0,每調用一次fgetc,讀寫位置向後移動一個位元組,因此可以連續多次調用fgetc函數依次讀取多個位元組。

  • fgetc成功時返回讀到一個位元組,本來應該是unsigned char型的,但由於函數原型中返回值是int型,所以這個位元組要轉換成int型再返回,那為什麼要規定返回值是int型呢?因為出錯或讀到檔案末尾時fgetc將返回EOF,即-1,保存在int型的返回值中是0xffffffff,如果讀到位元組0xff,由unsigned char型轉換為int型是0x000000ff,只有規定返回值是int型才能把這兩種情況區分開,如果規定返回值是unsigned char型,那麼當返回值是0xff時無法區分到底是EOF還是位元組0xff。如果需要保存fgetc的返回值,一定要保存在int型變數中,如果寫成unsigned char c = fgetc(fp);,那麼根據c的值又無法區分EOF和0xff位元組了。注意,fgetc讀到檔案末尾時返回EOF,只是用這個返回值表示已讀到檔案末尾,並不是說每個檔案末尾都有一個位元組是EOF(根據上面的分析,EOF並不是一個位元組)。

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

#include <stdio.h>

int fputc(int c, FILE *stream);
int putchar(int c);
返回值:成功返回寫入的位元組,出錯返回EOF

對於fputc函數的使用也要說明幾點:

  • 要用fputc函數寫一個檔案,該檔案的打開方式必須是可寫的(包括追加)。

  • 每調用一次fputc,讀寫位置向後移動一個位元組,因此可以連續多次調用fputc函數依次寫入多個位元組。但如果檔案是以追加方式打開的,每次調用fputc時總是將讀寫位置移到檔案末尾然後把要寫入的位元組追加到後面。

下面的例子演示了這四個函數的用法,從鍵盤讀入一串字元寫到一個檔案中,再從這個檔案中讀出這些字元打印到屏幕上。

例 25.5. 用fputc/fget讀寫檔案和終端

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
	FILE *fp;
	int ch;

	if ( (fp = fopen("file2", "w+")) == NULL) {
		perror("Open file file2\n");
		exit(1);
	}
	while ( (ch = getchar()) != EOF)
		fputc(ch, fp);
	rewind(fp);
	while ( (ch = fgetc(fp)) != EOF)
		putchar(ch);
	fclose(fp);
	return 0;
}

從終端設備讀有點特殊。當調用getchar()fgetc(stdin)時,如果用戶沒有輸入字元,getchar函數就阻塞等待,所謂阻塞是指這個函數調用不返回,也就不能執行後面的代碼,這個進程阻塞了,操作系統可以調度別的進程執行。從終端設備讀還有一個特點,用戶輸入一般字元並不會使getchar函數返回,仍然阻塞着,只有當用戶輸入回車或者到達檔案末尾時getchar才返回[34]。這個程序的執行過程分析如下:

$ ./a.out
hello(輸入hello並回車,這時第一次調用getchar返回,讀取字元h存到檔案中,然後連續調用getchar五次,讀取ello和換行符存到檔案中,第七次調用getchar又阻塞了)
hey(輸入hey並回車,第七次調用getchar返回,讀取字元h存到檔案中,然後連續調用getchar三次,讀取ey和換行符存到檔案中,第11次調用getchar又阻塞了)
(這時輸入Ctrl-D,第11次調用getchar返回EOF,跳出循環,進入下一個循環,回到檔案開頭,把檔案內容一個位元組一個位元組讀出來打印,直到檔案結束)
hello
hey

從終端設備輸入時有兩種方法表示檔案結束,一種方法是在一行的開頭輸入Ctrl-D(如果不在一行的開頭則需要連續輸入兩次Ctrl-D),另一種方法是利用Shell的Heredoc語法:

$ ./a.out <<END
> hello
> hey
> END
hello
hey

<<END表示從下一行開始是標準輸入,直到某一行開頭出現END時結束。<<後面的結束符可以任意指定,不一定得是END,只要和輸入的內容能區分開就行。

在上面的程序中,第一個while循環結束時fp所指檔案的讀寫位置在檔案末尾,然後調用rewind函數把讀寫位置移到檔案開頭,再進入第二個while循環從頭讀取檔案內容。

習題

1、編寫一個簡單的檔案複製程序。

$ ./mycp dir1/fileA dir2/fileB

運行這個程序可以把dir1/fileA檔案拷貝到dir2/fileB檔案。注意各種出錯處理。

2、雖然我說getchar要讀到換行符才返回,但上面的程序並沒有提供證據支持我的說法,如果看成每敲一個鍵getchar就返回一次,也能解釋程序的運行結果。請寫一個小程序證明getchar確實是讀到換行符才返回的。

2.6. 操作讀寫位置的函數

我們在上一節的例子中看到rewind函數把讀寫位置移到檔案開頭,本節介紹另外兩個操作讀寫位置的函數,fseek可以任意移動讀寫位置,ftell可以返回當前的讀寫位置。

#include <stdio.h>

int fseek(FILE *stream, long offset, int whence);
返回值:成功返回0,出錯返回-1並設置errno

long ftell(FILE *stream);
返回值:成功返回當前讀寫位置,出錯返回-1並設置errno

void rewind(FILE *stream);

fseekwhenceoffset參數共同決定了讀寫位置移動到何處,whence參數的含義如下:

SEEK_SET

從檔案開頭移動offset個位元組

SEEK_CUR

從當前位置移動offset個位元組

SEEK_END

從檔案末尾移動offset個位元組

offset可正可負,負值表示向前(向檔案開頭的方向)移動,正值表示向後(向檔案末尾的方向)移動,如果向前移動的位元組數超過了檔案開頭則出錯返回,如果向後移動的位元組數超過了檔案末尾,再次寫入時將增大檔案尺寸,從原來的檔案末尾到fseek移動之後的讀寫位置之間的位元組都是0。

先前我們創建過一個檔案textfile,其中有五個位元組,5678加一個換行符,現在我們拿這個檔案做實驗。

例 25.6. fseek

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
	FILE* fp;
	if ( (fp = fopen("textfile","r+")) == NULL) {
		perror("Open file textfile");
		exit(1);
	}
	if (fseek(fp, 10, SEEK_SET) != 0) {
		perror("Seek file textfile");
		exit(1);
	}
	fputc('K', fp);
	fclose(fp);
	return 0;
}

運行這個程序,然後查看檔案textfile的內容:

$ ./a.out 
$ od -tx1 -tc -Ax textfile 
000000 35 36 37 38 0a 00 00 00 00 00 4b
         5   6   7   8  \n  \0  \0  \0  \0  \0   K
00000b

fseek(fp, 10, SEEK_SET)將讀寫位置移到第10個位元組處(其實是第11個位元組,從0開始數),然後在該位置寫入一個字元K,這樣textfile檔案就變長了,從第5到第9個位元組自動被填充為0。

2.7. 以字元串為單位的I/O函數

fgets從指定的檔案中讀一行字元到調用者提供的緩衝區中,gets從標準輸入讀一行字元到調用者提供的緩衝區中。

#include <stdio.h>

char *fgets(char *s, int size, FILE *stream);
char *gets(char *s);
返回值:成功時s指向哪返回的指針就指向哪,出錯或者讀到檔案末尾時返回NULL

gets函數無需解釋,Man Page的BUGS部分已經說得很清楚了:Never use gets()。gets函數的存在只是為了兼容以前的程序,我們寫的代碼都不應該調用這個函數。gets函數的介面設計得很有問題,就像strcpy一樣,用戶提供一個緩衝區,卻不能指定緩衝區的大小,很可能導致緩衝區溢出錯誤,這個函數比strcpy更加危險,strcpy的輸入和輸出都來自程序內部,只要程序員小心一點就可以避免出問題,而gets讀取的輸入直接來自程序外部,用戶可能通過標準輸入提供任意長的字元串,程序員無法避免gets函數導致的緩衝區溢出錯誤,所以唯一的辦法就是不要用它。

現在說說fgets函數,參數s是緩衝區的首地址,size是緩衝區的長度,該函數從stream所指的檔案中讀取以'\n'結尾的一行(包括'\n'在內)存到緩衝區s中,並且在該行末尾添加一個'\0'組成完整的字元串。

如果檔案中的一行太長,fgets從檔案中讀了size-1個字元還沒有讀到'\n',就把已經讀到的size-1個字元和一個'\0'字元存入緩衝區,檔案中剩下的半行可以在下次調用fgets時繼續讀。

如果一次fgets調用在讀入若干個字元後到達檔案末尾,則將已讀到的字元串加上'\0'存入緩衝區並返回,如果再次調用fgets則返回NULL,可以據此判斷是否讀到檔案末尾。

注意,對於fgets來說,'\n'是一個特別的字元,而'\0'並無任何特別之處,如果讀到'\0'就當作普通字元讀入。如果檔案中存在'\0'字元(或者說0x00位元組),調用fgets之後就無法判斷緩衝區中的'\0'究竟是從檔案讀上來的字元還是由fgets自動添加的結束符,所以fgets只適合讀文本檔案而不適合讀二進制檔案,並且文本檔案中的所有字元都應該是可見字元,不能有'\0'

fputs向指定的檔案寫入一個字元串,puts向標準輸出寫入一個字元串。

#include <stdio.h>

int fputs(const char *s, FILE *stream);
int puts(const char *s);
返回值:成功返回一個非負整數,出錯返回EOF

緩衝區s中保存的是以'\0'結尾的字元串,fputs將該字元串寫入檔案stream,但並不寫入結尾的'\0'。與fgets不同的是,fputs並不關心的字元串中的'\n'字元,字元串中可以有'\n'也可以沒有'\n'puts將字元串s寫到標準輸出(不包括結尾的'\0'),然後自動寫一個'\n'到標準輸出。

習題

1、用fgets/fputs寫一個拷貝檔案的程序,根據本節對fgets函數的分析,應該只能拷貝文本檔案,試試用它拷貝二進制檔案會出什麼問題。

2.8. 以記錄為單位的I/O函數

#include <stdio.h>

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);
返回值:讀或寫的記錄數,成功時返回的記錄數等於nmemb,出錯或讀到檔案末尾時返回的記錄數小於nmemb,也可能返回0

freadfwrite用於讀寫記錄,這裡的記錄是指一串固定長度的位元組,比如一個int、一個結構體或者一個定長數組。參數size指出一條記錄的長度,而nmemb指出要讀或寫多少條記錄,這些記錄在ptr所指的內存空間中連續存放,共占size * nmemb個位元組,fread從檔案stream中讀出size * nmemb個位元組保存到ptr中,而fwriteptr中的size * nmemb個位元組寫到檔案stream中。

nmemb是請求讀或寫的記錄數,freadfwrite返回的記錄數有可能小於nmemb指定的記錄數。例如當前讀寫位置距檔案末尾只有一條記錄的長度,調用fread時指定nmemb為2,則返回值為1。如果當前讀寫位置已經在檔案末尾了,或者讀檔案時出錯了,則fread返回0。如果寫檔案時出錯了,則fwrite的返回值小於nmemb指定的值。下面的例子由兩個程序組成,一個程序把結構體保存到檔案中,另一個程序和從檔案中讀出結構體。

例 25.7. fread/fwrite

/* writerec.c */
#include <stdio.h>
#include <stdlib.h>

struct record {
	char name[10];
	int age;
};

int main(void)
{
	struct record array[2] = {{"Ken", 24}, {"Knuth", 28}};
	FILE *fp = fopen("recfile", "w");
	if (fp == NULL) {
		perror("Open file recfile");
		exit(1);
	}
	fwrite(array, sizeof(struct record), 2, fp);
	fclose(fp);
	return 0;
}
/* readrec.c */
#include <stdio.h>
#include <stdlib.h>

struct record {
	char name[10];
	int age;
};

int main(void)
{
	struct record array[2];
	FILE *fp = fopen("recfile", "r");
	if (fp == NULL) {
		perror("Open file recfile");
		exit(1);
	}
	fread(array, sizeof(struct record), 2, fp);
	printf("Name1: %s\tAge1: %d\n", array[0].name, array[0].age);
	printf("Name2: %s\tAge2: %d\n", array[1].name, array[1].age);
	fclose(fp);
	return 0;
}

$ gcc writerec.c -o writerec
$ gcc readrec.c -o readrec
$ ./writerec
$ od -tx1 -tc -Ax recfile 
000000 4b 65 6e 00 00 00 00 00 00 00 00 00 18 00 00 00
         K   e   n  \0  \0  \0  \0  \0  \0  \0  \0  \0 030  \0  \0  \0
000010 4b 6e 75 74 68 00 00 00 00 00 00 00 1c 00 00 00
         K   n   u   t   h  \0  \0  \0  \0  \0  \0  \0 034  \0  \0  \0
000020
$ ./readrec 
Name1: Ken	Age1: 24
Name2: Knuth	Age2: 28

我們把一個struct record結構體看作一條記錄,由於結構體中有填充位元組,每條記錄占16位元組,把兩條記錄寫到檔案中共占32位元組。該程序生成的recfile檔案是二進制檔案而非文本檔案,因為其中不僅保存着字元型數據,還保存着整型數據24和28(在od命令的輸出中以八進制顯示為030和034)。注意,直接在檔案中讀寫結構體的程序是不可移植的,如果在一種平台上編譯運行writebin.c程序,把生成的recfile檔案拷到另一種平台並在該平台上編譯運行readbin.c程序,則不能保證正確讀出檔案的內容,因為不同平台的大小端可能不同(因而對整型數據的存儲方式不同),結構體的填充方式也可能不同(因而同一個結構體所占的位元組數可能不同,age成員在name成員之後的什麼位置也可能不同)。

2.9. 格式化I/O函數

現在該正式講一下printfscanf函數了,這兩個函數都有很多種形式。

#include <stdio.h>

int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);

#include <stdarg.h>

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

返回值:成功返回格式化輸出的位元組數(不包括字元串的結尾'\0'),出錯返回一個負值

printf格式化打印到標準輸出,而fprintf打印到指定的檔案stream中。sprintf並不打印到檔案,而是打印到用戶提供的緩衝區str中並在末尾加'\0',由於格式化後的字元串長度很難預計,所以很可能造成緩衝區溢出,用snprintf更好一些,參數size指定了緩衝區長度,如果格式化後的字元串長度超過緩衝區長度,snprintf就把字元串截斷到size-1位元組,再加上一個'\0'寫入緩衝區,也就是說snprintf保證字元串以'\0'結尾。snprintf的返回值是格式化後的字元串長度(不包括結尾的'\0'),如果字元串被截斷,返回的是截斷之前的長度,把它和實際緩衝區中的字元串長度相比較就可以知道是否發生了截斷。

上面列出的後四個函數在前四個函數名的前面多了個v,表示可變參數不是以...的形式傳進來,而是以va_list類型傳進來。下面我們用vsnprintf包裝出一個類似printf的帶格式化字元串和可變參數的函數。

例 25.8. 實現格式化打印錯誤的err_sys函數

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <stdarg.h>
#include <string.h>

#define MAXLINE 80

void err_sys(const char *fmt, ...)
{
	int err = errno;
	char buf[MAXLINE+1];
	va_list ap;

	va_start(ap, fmt);

	vsnprintf(buf, MAXLINE, fmt, ap);
	snprintf(buf+strlen(buf), MAXLINE-strlen(buf), ": %s", strerror(err));
	strcat(buf, "\n");
	fputs(buf, stderr);

	va_end(ap);
	exit(1);
}

int main(int argc, char *argv[])
{
	FILE *fp;
	if (argc != 2) {
		fputs("Usage: ./a.out pathname\n", stderr);
		exit(1);
	}
	fp = fopen(argv[1], "r");

	if (fp == NULL)
		err_sys("Line %d - Open file %s", __LINE__, argv[1]);
	printf("Open %s OK\n", argv[1]);
	fclose(fp);
	return 0;
}

有了err_sys函數,不僅簡化了main函數的代碼,而且可以把fopen的錯誤提示打印得非常清楚,有原始碼行號,有打開檔案的路徑名,一看就知道哪裡出錯了。

現在總結一下printf格式化字元串中的轉換說明的有哪些寫法。在這裡只列舉幾種常用的格式,其它格式請參考Man Page。每個轉換說明以%號開頭,以轉換字元結尾,我們以前用過的轉換說明僅包含%號和轉換字元,例如%d%s,其實在這兩個字元中間還可以插入一些可選項。

表 25.1. printf轉換說明的可選項

選項描述舉例
#八進制前面加0(轉換字元為o),十六進制前面加0x(轉換字元為x)或0X(轉換字元為X)。printf("%#x", 0xff)打印0xffprintf("%x", 0xff)打印ff
-格式化後的內容居左,右邊可以留空格。見下面的例子
寬度用一個整數指定格式化後的最小長度,如果格式化後的內容沒有這麼長,可以在左邊留空格,如果前面指定了-號就在右邊留空格。寬度有一種特別的形式,不指定整數值而是寫成一個*號,表示取一個int型參數作為寬度。printf("-%10s-", "hello")打印-␣␣␣␣␣hello-printf("-%-*s-", 10, "hello")打印-hello␣␣␣␣␣-
.用於分隔上一條提到的最小長度和下一條要講的精度。見下面的例子
精度用一個整數表示精度,對於字元串來說指定了格式化後保留的最大長度,對於浮點數來說指定了格式化後小數點右邊的位數,對於整數來說指定了格式化後的最小位數。精度也可以不指定整數值而是寫成一個*號,表示取下一個int型參數作為精度。printf("%.4s", "hello")打印hellprintf("-%6.4d-", 100)打印-␣␣0100-printf("-%*.*f-", 8, 4, 3.14)打印-␣␣3.1400-
字長對於整型參數,hhhlll分別表示是charshortlonglong long型的字長,至於是有符號數還是無符號數則取決於轉換字元;對於浮點型參數,L表示long double型的字長。printf("%hhd", 255)打印-1

常用的轉換字元有:

表 25.2. printf的轉換字元

轉換字元描述舉例
d iint型參數格式化成有符號十進製表示,如果格式化後的位數小於指定的精度,就在左邊補0。printf("%.4d", 100)打印0100
o u x Xunsigned int型參數格式化成無符號八進制(o)、十進制(u)、十六進制(x或X)表示,x表示十六進制數字用小寫abcdef,X表示十六進制數字用大寫ABCDEF,如果格式化後的位數小於指定的精度,就在左邊補0。printf("%#X", 0xdeadbeef)打印0XDEADBEEFprintf("%hhu", -1)打印255
cint型參數轉換成unsigned char型,格式化成對應的ASCII碼字元。printf("%c", 256+'A')打印A
sconst char *型參數所指向的字元串格式化輸出,遇到'\0'結束,或者達到指定的最大長度(精度)結束。printf("%.4s", "hello")打印hell
pvoid *型參數格式化成十六進製表示。相當於%#xprintf("%p", main)打印main函數的首地址0x80483c4
fdouble型參數格式化成[-]ddd.ddd這樣的格式,小數點後的預設精度是6位。printf("%f", 3.14)打印3.140000printf("%f", 0.00000314)打印0.000003
e Edouble型參數格式化成[-]d.ddde±dd(轉換字元是e)或[-]d.dddE±dd(轉換字元是E)這樣的格式,小數點後的預設精度是6位,指數至少是兩位。printf("%e", 3.14)打印3.140000e+00
g Gdouble型參數格式化,精度是指有效數字而非小數點後的數字,預設精度是6。如果指數小於-4或大於等於精度就按%e(轉換字元是g)或%E(轉換字元是G)格式化,否則按%f格式化。小數部分的末尾0去掉,如果沒有小數部分,小數點也去掉。printf("%g", 3.00)打印3printf("%g", 0.00001234567)打印1.23457e-05
%格式化成一個%printf("%%")打印一個%

我們在第 6 節 “可變參數”講過可變參數的原理,printf並不知道實際參數的類型,只能按轉換說明指出的參數類型從棧幀上取參數,所以如果實際參數和轉換說明的類型不符,結果可能會有些意外,上面也舉過幾個這樣的例子。另外,如果s指向一個字元串,用printf(s)打印這個字元串可能得到錯誤的結果,因為字元串中可能包含%號而被printf當成轉換說明,printf並不知道後面沒有傳其它參數,照樣會從棧幀上取參數。所以比較保險的辦法是printf("%s", s)

下面看scanf函數的各種形式。

#include <stdio.h>

int scanf(const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);
int sscanf(const char *str, const char *format, ...);

#include <stdarg.h>

int vscanf(const char *format, va_list ap);
int vsscanf(const char *str, const char *format, va_list ap);
int vfscanf(FILE *stream, const char *format, va_list ap);
返回值:返回成功匹配和賦值的參數個數,成功匹配的參數可能少於所提供的賦值參數,返回0表示一個都不匹配,出錯或者讀到檔案或字元串末尾時返回EOF並設置errno

scanf從標準輸入讀字元,按格式化字元串format中的轉換說明解釋這些字元,轉換後賦給後面的參數,後面的參數都是傳出參數,因此必須傳地址而不能傳值。fscanf從指定的檔案stream中讀字元,而sscanf從指定的字元串str中讀字元。後面三個以v開頭的函數的可變參數不是以...的形式傳進來,而是以va_list類型傳進來。

現在總結一下scanf的格式化字元串和轉換說明,這裡也只列舉幾種常用的格式,其它格式請參考Man Page。scanf用輸入的字元去匹配格式化字元串中的字元和轉換說明,如果成功匹配一個轉換說明,就給一個參數賦值,如果讀到檔案或字元串末尾就停止,或者如果遇到和格式化字元串不匹配的地方(比如轉換說明是%d卻讀到字元A)就停止。如果遇到不匹配的地方而停止,scanf的返回值可能小於賦值參數的個數,檔案的讀寫位置指向輸入中不匹配的地方,下次調用庫函數讀檔案時可以從這個位置繼續。

格式化字元串中包括:

  • 空格或Tab,在處理過程中被忽略。

  • 普通字元(不包括%),和輸入字元中的非空白字元相匹配。輸入字元中的空白字元是指空格、Tab、\r\n\v\f

  • 轉換說明,以%開頭,以轉換字元結尾,中間也有若干個可選項。

轉換說明中的可選項有:

  • *號,表示這個轉換說明只是用來匹配一段輸入字元,但匹配結果並不賦給後面的參數。

  • 用一個整數指定的寬度N。表示這個轉換說明最多匹配N個輸入字元,或者匹配到輸入字元中的下一個空白字元結束。

  • 對於整型參數可以指定字長,有hhhlll(也可以寫成一個L),含義和printf相同。但lL還有一層含義,當轉換字元是efg時,表示賦值參數的類型是float *而非double *,這一點跟printf不同(結合以前講的類型轉換規則思考一下為什麼不同),這時前面加上lL分別表示double *long double *型。

常用的轉換字元有:

表 25.3. scanf的轉換字元

轉換字元描述
d匹配十進制整數(開頭可以有負號),賦值參數的類型是int *
i匹配整數(開頭可以有負號),賦值參數的類型是int *,如果輸入字元以0x或0X開頭則匹配十六進制整數,如果輸入字元以0開頭則匹配八進制整數。
o u x匹配八進制、十進制、十六進制整數(開頭可以有負號),賦值參數的類型是unsigned int *
c匹配一串字元,字元的個數由寬度指定,預設寬度是1,賦值參數的類型是char *,末尾不會添加'\0'。如果輸入字元的開頭有空白字元,這些空白字元並不被忽略,而是保存到參數中,要想跳過開頭的空白字元,可以在格式化字元串中用一個空格去匹配。
s匹配一串非空白字元,從輸入字元中的第一個非空白字元開始匹配到下一個空白字元之前,或者匹配到指定的寬度,賦值參數的類型是char *,末尾自動添加'\0'
e f g匹配符點數(開頭可以有負號),賦值參數的類型是float *,也可以指定double *long double *的字長。
%轉換說明%%匹配一個字元%,不做賦值。

下面幾個例子出自[K&R]。第一個例子,讀取用戶輸入的浮點數累加起來。

例 25.9. 用scanf實現簡單的計算器

#include <stdio.h>

int main(void)  /* rudimentary calculator */
{
	double sum, v;

	sum = 0;
	while (scanf("%lf", &v) == 1)
		printf("\t%.2f\n", sum += v);
	return 0;
}

如果我們要讀取25 Dec 1988這樣的日期格式,可以這樣寫:

char *str = "25 Dec 1988";
int day, year;
char monthname[20];

sscanf(str, "%d %s %d", &day, monthname, &year);

如果str中的空白字元再多一些,比如" 25 Dec 1998",仍然可以正確讀取。如果格式化字元串中的空格和Tab再多一些,比如"%d %s %d ",也可以正確讀取。scanf函數是很強大的,但是要用對了不容易,需要多練習,通過練習體會空白字元的作用。

如果要讀取12/25/1998這樣的日期格式,就需要在格式化字元串中用/匹配輸入字元中的/

int day, month, year;

scanf("%d/%d/%d", &month, &day, &year);

scanf把換行符也看作空白字元,僅僅當作欄位之間的分隔符,如果輸入中的欄位個數不確定,最好是先用fgets按行讀取,然後再交給sscanf處理。如果我們的程序需要同時識別以上兩種日期格式,可以這樣寫:

while (fgets(line, sizeof(line), stdin) > 0) {
	if (sscanf(line, "%d %s %d", &day, monthname, &year) == 3)
		printf("valid: %s\n", line); /* 25 Dec 1988 form */
	else if (sscanf(line, "%d/%d/%d", &month, &day, &year) == 3)
		printf("valid: %s\n", line); /* mm/dd/yy form */
	else
		printf("invalid: %s\n", line); /* invalid form */
}

2.10. C標準庫的I/O緩衝區

用戶程序調用C標準I/O庫函數讀寫檔案或設備,而這些庫函數要通過系統調用把讀寫請求傳給內核(以後我們會看到與I/O相關的系統調用),最終由內核驅動磁碟或設備完成I/O操作。C標準庫為每個打開的檔案分配一個I/O緩衝區以加速讀寫操作,通過檔案的FILE結構體可以找到這個緩衝區,用戶調用讀寫函數大多數時候都在I/O緩衝區中讀寫,只有少數時候需要把讀寫請求傳給內核。以fgetc/fputc為例,當用戶程序第一次調用fgetc讀一個位元組時,fgetc函數可能通過系統調用進入內核讀1K位元組到I/O緩衝區中,然後返回I/O緩衝區中的第一個位元組給用戶,把讀寫位置指向I/O緩衝區中的第二個字元,以後用戶再調fgetc,就直接從I/O緩衝區中讀取,而不需要進內核了,當用戶把這1K位元組都讀完之後,再次調用fgetc時,fgetc函數會再次進入內核讀1K位元組到I/O緩衝區中。在這個場景中用戶程序、C標準庫和內核之間的關係就像在第 5 節 “Memory Hierarchy”中CPU、Cache和內存之間的關係一樣,C標準庫之所以會從內核預讀一些數據放在I/O緩衝區中,是希望用戶程序隨後要用到這些數據,C標準庫的I/O緩衝區也在用戶空間,直接從用戶空間讀取數據比進內核讀數據要快得多。另一方面,用戶程序調用fputc通常只是寫到I/O緩衝區中,這樣fputc函數可以很快地返回,如果I/O緩衝區寫滿了,fputc就通過系統調用把I/O緩衝區中的數據傳給內核,內核最終把數據寫回磁碟。有時候用戶程序希望把I/O緩衝區中的數據立刻傳給內核,讓內核寫回設備,這稱為Flush操作,對應的庫函數是fflushfclose函數在關閉檔案之前也會做Flush操作。

下圖以fgets/fputs示意了I/O緩衝區的作用,使用fgets/fputs函數時在用戶程序中也需要分配緩衝區(圖中的buf1buf2),注意區分用戶程序的緩衝區和C標準庫的I/O緩衝區。

圖 25.1. C標準庫的I/O緩衝區

C標準庫的I/O緩衝區

C標準庫的I/O緩衝區有三種類型:全緩衝、行緩衝和無緩衝。當用戶程序調用庫函數做寫操作時,不同類型的緩衝區具有不同的特性。

全緩衝

如果緩衝區寫滿了就寫回內核。常規檔案通常是全緩衝的。

行緩衝

如果用戶程序寫的數據中有換行符就把這一行寫回內核,或者如果緩衝區寫滿了就寫回內核。標準輸入和標準輸出對應終端設備時通常是行緩衝的。

無緩衝

用戶程序每次調庫函數做寫操作都要通過系統調用寫回內核。標准錯誤輸出通常是無緩衝的,這樣用戶程序產生的錯誤信息可以儘快輸出到設備。

下面通過一個簡單的例子證明標準輸出對應終端設備時是行緩衝的。

#include <stdio.h>

int main()
{
	printf("hello world");
	while(1);
	return 0;
}

運行這個程序,會發現hello world並沒有打印到屏幕上。用Ctrl-C終止它,去掉程序中的while(1);語句再試一次:

$ ./a.out
hello world$

hello world被打印到屏幕上,後面直接跟Shell提示符,中間沒有換行。

我們知道main函數被啟動代碼這樣調用:exit(main(argc, argv));main函數return時啟動代碼會調用exitexit函數首先關閉所有尚未關閉的FILE *指針(關閉之前要做Flush操作),然後通過_exit系統調用進入內核退出當前進程[35]

在上面的例子中,由於標準輸出是行緩衝的,printf("hello world");打印的字元串中沒有換行符,所以只把字元串寫到標準輸出的I/O緩衝區中而沒有寫回內核(寫到終端設備),如果敲Ctrl-C,進程是異常終止的,並沒有調用exit,也就沒有機會Flush I/O緩衝區,因此字元串最終沒有打印到屏幕上。如果把打印語句改成printf("hello world\n");,有換行符,就會立刻寫到終端設備,或者如果把while(1);去掉也可以寫到終端設備,因為程序退出時會調用exitFlush所有I/O緩衝區。在本書的其它例子中,printf打印的字元串末尾都有換行符,以保證字元串在printf調用結束時就寫到終端設備。

我們再做個實驗,在程序中直接調用_exit退出。

#include <stdio.h>
#include <unistd.h>

int main()
{
	printf("hello world");
	_exit(0);
}

結果也不會把字元串打印到屏幕上,如果把_exit調用改成exit就可以打印到屏幕上。

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

  • 用戶程序調用庫函數從無緩衝的檔案中讀取

  • 或者從行緩衝的檔案中讀取,並且這次讀操作會引發系統調用從內核讀取數據

那麼在讀取之前會自動Flush所有行緩衝。例如:

#include <stdio.h>
#include <unistd.h>

int main()
{
	char buf[20];
	printf("Please input a line: ");
	fgets(buf, 20, stdin);
	return 0;
}

雖然調用printf並不會把字元串寫到設備,但緊接着調用fgets讀一個行緩衝的檔案(標準輸入),在讀取之前會自動Flush所有行緩衝,包括標準輸出。

如果用戶程序不想完全依賴于自動的Flush操作,可以調fflush函數手動做Flush操作。

#include <stdio.h>

int fflush(FILE *stream);
返回值:成功返回0,出錯返回EOF並設置errno

對前面的例子再稍加改動:

#include <stdio.h>

int main()
{
	printf("hello world");
	fflush(stdout);
	while(1);
}

雖然字元串中沒有換行,但用戶程序調用fflush強制寫回內核,因此也能在屏幕上打印出字元串。fflush函數用於確保數據寫回了內核,以免進程異常終止時丟失數據。作為一個特例,調用fflush(NULL)可以對所有打開檔案的I/O緩衝區做Flush操作。

2.11. 本節綜合練習

1、編程讀寫一個檔案test.txt,每隔1秒向檔案中寫入一行記錄,類似於這樣:

1 2009-7-30 15:16:42
2 2009-7-30 15:16:43

該程序應該無限循環,直到按Ctrl-C終止。下次再啟動程序時在test.txt檔案末尾追加記錄,並且序號能夠接續上次的序號,比如:

1 2009-7-30 15:16:42
2 2009-7-30 15:16:43
3 2009-7-30 15:19:02
4 2009-7-30 15:19:03
5 2009-7-30 15:19:04

這類似於很多系統服務維護的日誌檔案,例如在我的機器上系統服務進程acpid維護一個日誌檔案/var/log/acpid,就像這樣:

$ cat /var/log/acpid
[Sun Oct 26 08:44:46 2008] logfile reopened
[Sun Oct 26 10:11:53 2008] exiting
[Sun Oct 26 18:54:39 2008] starting up
...

每次系統啟動時acpid進程就以追加方式打開這個檔案,當有事件發生時就追加一條記錄,包括事件發生的時刻以及事件描述信息。

獲取當前的系統時間需要調用time(2)函數,返回的結果是一個time_t類型,其實就是一個大整數,其值表示從UTC(Coordinated Universal Time)時間1970年1月1日00:00:00(稱為UNIX系統的Epoch時間)到當前時刻的秒數。然後調用localtime(3)time_t所表示的UTC時間轉換為本地時間(我們是+8區,比UTC多8個小時)並轉成struct tm類型,該類型的各數據成員分別表示年月日時分秒,具體用法請查閲Man Page。調用sleep(3)函數可以指定程序睡眠多少秒。

2、INI檔案是一種很常見的配置檔案,很多Windows程序都採用這種格式的配置檔案,在Linux系統中Qt程序通常也採用這種格式的配置檔案。比如:

;Configuration of http
[http]
domain=www.mysite.com
port=8080
cgihome=/cgi-bin

;Configuration of db
[database]
server = mysql
user = myname
password = toopendatabase

一個配置檔案由若干個Section組成,由[]括號括起來的是Section名。每個Section下面有若干個key = value形式的鍵值對(Key-value Pair),等號兩邊可以有零個或多個空白字元(空格或Tab),每個鍵值對占一行。以;號開頭的行是註釋。每個Section結束時有一個或多個空行,空行是僅包含零個或多個空白字元(空格或Tab)的行。INI檔案的最後一行後面可能有換行符也可能沒有。

現在XML興起了,INI檔案顯得有點土。現在要求編程把INI檔案轉換成XML檔案。上面的例子經轉換後應該變成這樣:

<!-- Configuration of http -->
<http>
        <domain>www.mysite.com</domain>
        <port>8080</port>
        <cgihome>/cgi-bin</cgihome>
</http>

<!-- Configuration of db -->
<database>
        <server>mysql</server>
        <user>myname</user>
        <password>toopendatabase</password>
</database>

3、實現類似gcc-M選項的功能,給定一個.c檔案,列出它直接和間接包含的所有標頭檔,例如有一個main.c檔案:

#include <errno.h>
#include "stack.h"

int main()
{
	return 0;
}

你的程序讀取這個檔案,打印出其中包含的所有標頭檔的絶對路徑:

$ ./a.out main.c
/usr/include/errno.h
/usr/include/features.h
/usr/include/bits/errno.h
/usr/include/linux/errno.h
...
/home/akaedu/stack.h: cannot find

如果有的標頭檔找不到,就像上面例子那樣打印/home/akaedu/stack.h: cannot find。首先複習一下第 2.2 節 “標頭檔”講過的標頭檔查找順序,本題目不必考慮-I選項指定的目錄,只在.c檔案所在的目錄以及系統目錄/usr/include中查找。



[34] 這些特性取決於終端的工作模式,終端可以配置成一次一行的模式,也可以配置成一次一個字元的模式,預設是一次一行的模式(本書的實驗都是在這種模式下做的),關於終端的配置可參考[APUE2e]

[35] 其實在調_exit進內核之前還要調用戶程序中通過atexit(3)註冊的退出處理函數,本書不做詳細介紹,讀者可參考[APUE2e]