# 第?25?章?C標準庫
**目錄**
+ [1\. 字符串操作函數](ch25s01.html)
+ [1.1\. 初始化字符串](ch25s01.html#id2827594)
+ [1.2\. 取字符串的長度](ch25s01.html#id2827671)
+ [1.3\. 拷貝字符串](ch25s01.html#id2827741)
+ [1.4\. 連接字符串](ch25s01.html#id2828376)
+ [1.5\. 比較字符串](ch25s01.html#id2828656)
+ [1.6\. 搜索字符串](ch25s01.html#id2828881)
+ [1.7\. 分割字符串](ch25s01.html#id2829046)
+ [2\. 標準I/O庫函數](ch25s02.html)
+ [2.1\. 文件的基本概念](ch25s02.html#id2829671)
+ [2.2\. fopen/fclose](ch25s02.html#id2829869)
+ [2.3\. stdin/stdout/stderr](ch25s02.html#id2830485)
+ [2.4\. errno與perror函數](ch25s02.html#id2830807)
+ [2.5\. 以字節為單位的I/O函數](ch25s02.html#id2831236)
+ [2.6\. 操作讀寫位置的函數](ch25s02.html#id2831814)
+ [2.7\. 以字符串為單位的I/O函數](ch25s02.html#id2832034)
+ [2.8\. 以記錄為單位的I/O函數](ch25s02.html#id2832480)
+ [2.9\. 格式化I/O函數](ch25s02.html#id2832755)
+ [2.10\. C標準庫的I/O緩沖區](ch25s02.html#id2834346)
+ [2.11\. 本節綜合練習](ch25s02.html#id2834904)
+ [3\. 數值字符串轉換函數](ch25s03.html)
+ [4\. 分配內存的函數](ch25s04.html)
在前面的各章中我們已經見過C標準庫的一些用法,總結如下:
* 我們最常用的是包含`stdio.h`,使用其中聲明的`printf`函數,這個函數在`libc`中實現,程序在運行時要動態鏈接`libc`共享庫。
* 在[第?1?節 “數學函數”](ch03s01.html#func.mathfunc)中用到了`math.h`中聲明的`sin`和`log`函數,使用這些函數需要動態鏈接`libm`共享庫。
* 在[第?2?節 “數組應用實例:統計隨機數”](ch08s02.html#array.statistic)中用到了`stdlib.h`中聲明的`rand`函數,還提到了這個頭文件中定義的`RAND_MAX`常量,在[例?8.5 “剪刀石頭布”](ch08s05.html#array.scissor)中用到了`stdlib.h`中聲明的`srand`函數和`time.h`中聲明的`time`函數。使用這些函數需要動態鏈接`libc`共享庫。
* 在[第?2?節 “`main`函數和啟動例程”](ch19s02.html#asmc.main)中用到了`stdlib.h`中聲明的`exit`函數,使用這個函數需要動態鏈接`libc`共享庫。
* 在[第?6?節 “折半查找”](ch11s06.html#sortsearch.binary)中用到了`assert.h`中定義的`assert`宏,在[第?4?節 “其它預處理特性”](ch21s04.html#prep.other)中我們看到了這個宏的一種實現,它的實現需要調用`stdio.h`和`stdlib.h`中聲明的函數,所以使用這個宏也需要動態鏈接`libc`共享庫。
* 在[第?2.4?節 “sizeof運算符與typedef類型聲明”](ch16s02.html#op.sizeoftypedef)中提到了`size_t`類型在`stddef.h`中定義,在[第?1?節 “指針的基本概念”](ch23s01.html#pointer.intro)中提到了`NULL`指針也在`stddef.h`中定義。
* 在[第?1?節 “本章的預備知識”](ch24s01.html#interface.prereq)中介紹了`stdlib.h`中聲明的`malloc`和`free`函數以及`string.h`中聲明的`strcpy`和`strncpy`函數,使用這些函數需要動態鏈接`libc`共享庫。
* 在[第?6?節 “可變參數”](ch24s06.html#interface.va)中介紹了`stdarg.h`中定義的`va_list`類型和`va_arg`、`va_start`、`va_end`等宏定義,并給出了一種實現,這些宏定義的實現并沒有調用庫函數,所以不依賴于某個共享庫,這一點和`assert`不同。
總結一下,Linux平臺提供的C標準庫包括:
* 一組頭文件,定義了很多類型和宏,聲明了很多庫函數。這些頭文件放在哪些目錄下取決于不同的編譯器,在我的系統上,`stdarg.h`和`stddef.h`位于`/usr/lib/gcc/i486-linux-gnu/4.3.2/include`目錄下,`stdio.h`、`stdlib.h`、`time.h`、`math.h`、`assert.h`位于`/usr/include`目錄下。C99標準定義的頭文件有24個,本書只介紹其中最基本、最常用的幾個。
* 一組庫文件,提供了庫函數的實現。大多數庫函數在`libc`共享庫中,有些庫函數在另外的共享庫中,例如數學函數在`libm`中。在[第?4?節 “共享庫”](ch20s04.html#link.shared)講過,通常`libc`共享庫是`/lib/libc.so.6`,而我的系統啟用了hwcap機制,`libc`共享庫是`/lib/tls/i686/cmov/libc.so.6`。
本章介紹另外一些最基本和最常用的庫函數(包括一些不屬于C標準但在UNIX平臺上很常用的函數),寫這一章是為了介紹字符串操作和文件操作的基本概念,而不是為了寫一本C標準庫函數的參考手冊,Man Page已經是一本很好的手冊了,讀者學完這一章之后在開發時應該查閱Man Page,而不是把我這一章當參考手冊來翻,所以本章不會面面俱到介紹所有的庫函數,對于本章講到的函數有些也不會講得很細,因為我假定讀者經過上一章的學習再結合我講過的基本概念已經能看懂相關的Man Page了。很多技術書的作者給自己的書太多定位,既想寫成一本入門教程,又想寫成一本參考手冊,我覺得這樣不好,讀者過于依賴技術書就失去了看真正的手冊的能力。
## 1.?字符串操作函數
程序按功能劃分可分為數值運算、符號處理和I/O操作三類,符號處理程序占相當大的比例,符號處理程序無處不在,編譯器、瀏覽器、Office套件等程序的主要功能都是符號處理。無論多復雜的符號處理都是由各種基本的字符串操作組成的,本節介紹如何用C語言的庫函數做字符串初始化、取長度、拷貝、連接、比較、搜索等基本操作。
### 1.1.?初始化字符串
```
#include <string.h>
void *memset(void *s, int c, size_t n);
返回值:s指向哪,返回的指針就指向哪
```
`memset`函數把`s`所指的內存地址開始的`n`個字節都填充為`c`的值。通常`c`的值為0,把一塊內存區清零。例如定義`char buf[10];`,如果它是全局變量或靜態變量,則自動初始化為0(位于`.bss`段),如果它是函數的局部變量,則初值不確定,可以用`memset(buf, 0, 10)`清零,由`malloc`分配的內存初值也是不確定的,也可以用`memset`清零。
### 1.2.?取字符串的長度
```
#include <string.h>
size_t strlen(const char *s);
返回值:字符串的長度
```
`strlen`函數返回`s`所指的字符串的長度。該函數從`s`所指的第一個字符開始找`'\0'`字符,一旦找到就返回,返回的長度不包括`'\0'`字符在內。例如定義`char buf[] = "hello";`,則`strlen(buf)`的值是5,但要注意,如果定義`char buf[5] = "hello";`,則調用`strlen(buf)`是危險的,會造成數組訪問越界。
### 1.3.?拷貝字符串
在[第?1?節 “本章的預備知識”](ch24s01.html#interface.prereq)中介紹了`strcpy`和`strncpy`函數,拷貝以`'\0'`結尾的字符串,`strncpy`還帶一個參數指定最多拷貝多少個字節,此外,`strncpy`并不保證緩沖區以`'\0'`結尾。現在介紹`memcpy`和`memmove`函數。
```
#include <string.h>
void *memcpy(void *dest, const void *src, size_t n);
void *memmove(void *dest, const void *src, size_t n);
返回值:dest指向哪,返回的指針就指向哪
```
`memcpy`函數從`src`所指的內存地址拷貝`n`個字節到`dest`所指的內存地址,和`strncpy`不同,`memcpy`并不是遇到`'\0'`就結束,而是一定會拷貝完`n`個字節。這里的命名規律是,以`str`開頭的函數處理以`'\0'`結尾的字符串,而以`mem`開頭的函數則不關心`'\0'`字符,或者說這些函數并不把參數當字符串看待,因此參數的指針類型是`void *`而非`char *`。
`memmove`也是從`src`所指的內存地址拷貝`n`個字節到`dest`所指的內存地址,雖然叫move但其實也是拷貝而非移動。但是和`memcpy`有一點不同,`memcpy`的兩個參數`src`和`dest`所指的內存區間如果重疊則無法保證正確拷貝,而`memmove`卻可以正確拷貝。假設定義了一個數組`char buf[20] = "hello world\n";`,如果想把其中的字符串往后移動一個字節(變成`"hhello world\n"`),調用`memcpy(buf + 1, buf, 13)`是無法保證正確拷貝的:
**例?25.1.?錯誤的memcpy調用**
```
#include <stdio.h>
#include <string.h>
int main(void)
{
char buf[20] = "hello world\n";
memcpy(buf + 1, buf, 13);
printf(buf);
return 0;
}
```
在我的機器上運行的結果是`hhhllooworrd`。如果把代碼中的`memcpy`改成`memmove`則可以保證正確拷貝。`memmove`可以這樣實現:
```
void *memmove(void *dest, const void *src, size_t n)
{
char temp[n];
int i;
char *d = dest;
const char *s = src;
for (i = 0; i < n; i++)
temp[i] = s[i];
for (i = 0; i < n; i++)
d[i] = temp[i];
return dest;
}
```
借助于一個臨時緩沖區`temp`,即使`src`和`dest`所指的內存區間有重疊也能正確拷貝。思考一下,如果不借助于臨時緩沖區能不能正確處理重疊內存區間的拷貝?
用`memcpy`如果得到的結果是`hhhhhhhhhhhhhh`倒不奇怪,可為什么會得到`hhhllooworrd`這個奇怪的結果呢?根據這個結果猜測的一種可能的實現是:
```
void *memcpy(void *dest, const void *src, size_t n)
{
char *d = dest;
const char *s = src;
int *di;
const int *si;
int r = n % 4;
while (r--)
*d++ = *s++;
di = (int *)d;
si = (const int *)s;
n /= 4;
while (n--)
*di++ = *si++;
return dest;
}
```
在32位的x86平臺上,每次拷貝1個字節需要一條指令,每次拷貝4個字節也只需要一條指令,`memcpy`函數的實現盡可能4個字節4個字節地拷貝,因而得到上述結果。
### C99的`restrict`關鍵字
我們來看一個跟`memcpy`/`memmove`類似的問題。下面的函數將兩個數組中對應的元素相加,結果保存在第三個數組中。
```
void vector_add(const double *x, const double *y, double *result)
{
int i;
for (i = 0; i < 64; ++i)
result[i] = x[i] + y[i];
}
```
如果這個函數要在多處理器的計算機上執行,編譯器可以做這樣的優化:把這一個循環拆成兩個循環,一個處理器計算i值從0到31的循環,另一個處理器計算i值從32到63的循環,這樣兩個處理器可以同時工作,使計算時間縮短一半。但是這樣的編譯優化能保證得出正確結果嗎?假如`result`和`x`所指的內存區間是重疊的,`result[0]`其實是`x[1]`,`result[i]`其實是`x[i+1]`,這兩個處理器就不能各干各的事情了,因為第二個處理器的工作依賴于第一個處理器的最終計算結果,這種情況下編譯優化的結果是錯的。這樣看來編譯器是不敢隨便做優化了,那么多處理器提供的并行性就無法利用,豈不可惜?為此,C99引入`restrict`關鍵字,如果程序員把上面的函數聲明為`void vector_add(const double *restrict x, const double *restrict y, double *restrict result)`,就是告訴編譯器可以放心地對這個函數做優化,程序員自己會保證這些指針所指的內存區間互不重疊。
由于`restrict`是C99引入的新關鍵字,目前Linux的Man Page還沒有更新,所以都沒有`restrict`關鍵字,本書的函數原型都取自Man Page,所以也都沒有`restrict`關鍵字。但在C99標準中庫函數的原型都在必要的地方加了`restrict`關鍵字,在C99中`memcpy`的原型是`void *memcpy(void * restrict s1, const void * restrict s2, size_t n);`,就是告訴調用者,這個函數的實現可能會做些優化,編譯器也可能會做些優化,傳進來的指針不允許指向重疊的內存區間,否則結果可能是錯的,而`memmove`的原型是`void *memmove(void *s1, const void *s2, size_t n);`,沒有`restrict`關鍵字,說明傳給這個函數的指針允許指向重疊的內存區間。在`restrict`關鍵字出現之前都是用自然語言描述哪些函數的參數不允許指向重疊的內存區間,例如在C89標準的庫函數一章開頭提到,本章描述的所有函數,除非特別說明,都不應該接收兩個指針參數指向重疊的內存區間,例如調用`sprintf`時傳進來的格式化字符串和結果字符串的首地址相同,諸如此類的調用都是非法的。本書也遵循這一慣例,除非像`memmove`這樣特別說明之外,都表示“不允許”。
關于`restrict`關鍵字更詳細的解釋可以參考[[BeganFORTRAN]](bi01.html#bibli.restrict "The New C: It All Began with FORTRAN(http://www.ddj.com/cpp/184401313)")。
字符串的拷貝也可以用`strdup(3)`函數,這個函數不屬于C標準庫,是POSIX標準中定義的,POSIX標準定義了UNIX系統的各種接口,包含C標準庫的所有函數和很多其它的系統函數,在[第?2?節 “C標準I/O庫函數與Unbuffered I/O函數”](ch28s02.html#io.twoioflavors)將詳細介紹POSIX標準。
```
#include <string.h>
char *strdup(const char *s);
返回值:指向新分配的字符串
```
這個函數調用`malloc`動態分配內存,把字符串`s`拷貝到新分配的內存中然后返回。用這個函數省去了事先為新字符串分配內存的麻煩,但是用完之后要記得調用`free`釋放新字符串的內存。
### 1.4.?連接字符串
```
#include <string.h>
char *strcat(char *dest, const char *src);
char *strncat(char *dest, const char *src, size_t n);
返回值:dest指向哪,返回的指針就指向哪
```
`strcat`把`src`所指的字符串連接到`dest`所指的字符串后面,例如:
```
char d[10] = "foo";
char s[10] = "bar";
strcat(d, s);
printf("%s %s\n", d, s);
```
調用`strcat`函數后,緩沖區`s`的內容沒變,緩沖區`d`中保存著字符串`"foobar"`,注意原來`"foo"`后面的`'\0'`被連接上來的字符串`"bar"`覆蓋掉了,`"bar"`后面的`'\0'`仍保留。
`strcat`和`strcpy`有同樣的問題,調用者必須確保`dest`緩沖區足夠大,否則會導致緩沖區溢出錯誤。`strncat`函數通過參數`n`指定一個長度,就可以避免緩沖區溢出錯誤。注意這個參數`n`的含義和`strncpy`的參數`n`不同,它并不是緩沖區`dest`的長度,而是表示最多從`src`緩沖區中取`n`個字符(不包括結尾的`'\0'`)連接到`dest`后面。如果`src`中前`n`個字符沒有出現`'\0'`,則取前`n`個字符再加一個`'\0'`連接到`dest`后面,所以`strncat`總是保證`dest`緩沖區以`'\0'`結尾,這一點又和`strncpy`不同,`strncpy`并不保證`dest`緩沖區以`'\0'`結尾。所以,提供給`strncat`函數的`dest`緩沖區的大小至少應該是`strlen(dest)+n+1`個字節,才能保證不溢出。
### 1.5.?比較字符串
```
#include <string.h>
int memcmp(const void *s1, const void *s2, size_t n);
int strcmp(const char *s1, const char *s2);
int strncmp(const char *s1, const char *s2, size_t n);
返回值:負值表示s1小于s2,0表示s1等于s2,正值表示s1大于s2
```
`memcmp`從前到后逐個比較緩沖區`s1`和`s2`的前`n`個字節(不管里面有沒有`'\0'`),如果`s1`和`s2`的前`n`個字節全都一樣就返回0,如果遇到不一樣的字節,`s1`的字節比`s2`小就返回負值,`s1`的字節比`s2`大就返回正值。
`strcmp`把`s1`和`s2`當字符串比較,在其中一個字符串中遇到`'\0'`時結束,按照上面的比較準則,`"ABC"`比`"abc"`小,`"ABCD"`比`"ABC"`大,`"123A9"`比`"123B2"`小。
`strncmp`的比較結束條件是:要么在其中一個字符串中遇到`'\0'`結束(類似于`strcmp`),要么比較完`n`個字符結束(類似于`memcmp`)。例如,`strncmp("ABCD", "ABC", 3)`的返回值是0,`strncmp("ABCD", "ABC", 4)`的返回值是正值。
```
#include <strings.h>
int strcasecmp(const char *s1, const char *s2);
int strncasecmp(const char *s1, const char *s2, size_t n);
返回值:負值表示s1小于s2,0表示s1等于s2,正值表示s1大于s2
```
這兩個函數和`strcmp`/`strncmp`類似,但在比較過程中忽略大小寫,大寫字母A和小寫字母a認為是相等的。這兩個函數不屬于C標準庫,是POSIX標準中定義的。
### 1.6.?搜索字符串
```
#include <string.h>
char *strchr(const char *s, int c);
char *strrchr(const char *s, int c);
返回值:如果找到字符c,返回字符串s中指向字符c的指針,如果找不到就返回NULL
```
`strchr`在字符串`s`中從前到后查找字符`c`,找到字符`c`第一次出現的位置時就返回,返回值指向這個位置,如果找不到字符`c`就返回`NULL`。`strrchr`和`strchr`類似,但是從右向左找字符`c`,找到字符`c`第一次出現的位置就返回,函數名中間多了一個字母r可以理解為Right-to-left。
```
#include <string.h>
char *strstr(const char *haystack, const char *needle);
返回值:如果找到子串,返回值指向子串的開頭,如果找不到就返回NULL
```
`strstr`在一個長字符串中從前到后找一個子串(Substring),找到子串第一次出現的位置就返回,返回值指向子串的開頭,如果找不到就返回NULL。這兩個參數名很形象,在干草堆`haystack`中找一根針`needle`,按中文的說法叫大海撈針,顯然`haystack`是長字符串,`needle`是要找的子串。
搜索子串有一個顯而易見的算法,可以用兩層的循環,外層循環把`haystack`中的每一個字符的位置依次假定為子串的開頭,內層循環從這個位置開始逐個比較`haystack`和`needle`的每個字符是否相同。想想這個算法最多需要做多少次比較?其實有比這個算法高效得多的算法,有興趣的讀者可以參考[[算法導論]](bi01.html#bibli.algorithm "Introduction to Algorithms")。
### 1.7.?分割字符串
很多文件格式或協議格式中會規定一些分隔符或者叫界定符(Delimiter),例如`/etc/passwd`文件中保存著系統的帳號信息:
```
$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/bin/sh
bin:x:2:2:bin:/bin:/bin/sh
...
```
每條記錄占一行,也就是說記錄之間的分隔符是換行符,每條記錄又由若干個字段組成,這些字段包括用戶名、密碼、用戶id、組id、個人信息、主目錄、登錄Shell,字段之間的分隔符是:號。解析這樣的字符串需要根據分隔符把字符串分割成幾段,C標準庫提供的`strtok`函數可以很方便地完成分割字符串的操作。tok是Token的縮寫,分割出來的每一段字符串稱為一個Token。
```
#include <string.h>
char *strtok(char *str, const char *delim);
char *strtok_r(char *str, const char *delim, char **saveptr);
返回值:返回指向下一個Token的指針,如果沒有下一個Token了就返回NULL
```
參數`str`是待分割的字符串,`delim`是分隔符,可以指定一個或多個分隔符,`strtok`遇到其中任何一個分隔符就會分割字符串。看下面的例子。
**例?25.2.?strtok**
```
#include <stdio.h>
#include <string.h>
int main(void)
{
char str[] = "root:x::0:root:/root:/bin/bash:";
char *token;
token = strtok(str, ":");
printf("%s\n", token);
while ( (token = strtok(NULL, ":")) != NULL)
printf("%s\n", token);
return 0;
}
```
```
$ ./a.out
root
x
0
root
/root
/bin/bash
```
結合這個例子,`strtok`的行為可以這樣理解:冒號是分隔符,把`"root:x::0:root:/root:/bin/bash:"`這個字符串分隔成`"root"`、`"x"`、`""`、`"0"`、`"root"`、`"/root"`、`"/bin/bash"`、`""`等幾個Token,但空字符串的Token被忽略。第一次調用要把字符串首地址傳給`strtok`的第一個參數,以后每次調用第一個參數只要傳`NULL`就可以了,`strtok`函數自己會記住上次處理到字符串的什么位置(顯然這是通過`strtok`函數中的一個靜態指針變量記住的)。
用`gdb`跟蹤這個程序,會發現`str`字符串被`strtok`不斷修改,每次調用`strtok`把`str`中的一個分隔符改成`'\0'`,分割出一個小字符串,并返回這個小字符串的首地址。
```
(gdb) start
Breakpoint 1 at 0x8048415: file main.c, line 5.
Starting program: /home/akaedu/a.out
main () at main.c:5
5 {
(gdb) n
6 char str[] = "root:x::0:root:/root:/bin/bash:";
(gdb)
9 token = strtok(str, ":");
(gdb) display str
1: str = "root:x::0:root:/root:/bin/bash:"
(gdb) n
10 printf("%s\n", token);
1: str = "root\000x::0:root:/root:/bin/bash:"
(gdb)
root
11 while ( (token = strtok(NULL, ":")) != NULL)
1: str = "root\000x::0:root:/root:/bin/bash:"
(gdb)
12 printf("%s\n", token);
1: str = "root\000x\000:0:root:/root:/bin/bash:"
(gdb)
x
11 while ( (token = strtok(NULL, ":")) != NULL)
1: str = "root\000x\000:0:root:/root:/bin/bash:"
```
剛才提到在`strtok`函數中應該有一個靜態指針變量記住上次處理到字符串中的什么位置,所以不需要每次調用時都把字符串中的當前處理位置傳給`strtok`,但是在函數中使用靜態變量是不好的,以后會講到這樣的函數是不可重入的。`strtok_r`函數則不存在這個問題,它的內部沒有靜態變量,調用者需要自己分配一個指針變量來維護字符串中的當前處理位置,每次調用時把這個指針變量的地址傳給`strtok_r`的第三個參數,告訴`strtok_r`從哪里開始處理,`strtok_r`返回時再把新的處理位置寫回到這個指針變量中(這是一個Value-result參數)。`strtok_r`末尾的r就表示可重入(Reentrant),這個函數不屬于C標準庫,是在POSIX標準中定義的。關于`strtok_r`的用法Man Page上有一個很好的例子:
**例?25.3.?strtok_r**
```
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[])
{
char *str1, *str2, *token, *subtoken;
char *saveptr1, *saveptr2;
int j;
if (argc != 4) {
fprintf(stderr, "Usage: %s string delim subdelim\n",
argv[0]);
exit(EXIT_FAILURE);
}
for (j = 1, str1 = argv[1]; ; j++, str1 = NULL) {
token = strtok_r(str1, argv[2], &saveptr1);
if (token == NULL)
break;
printf("%d: %s\n", j, token);
for (str2 = token; ; str2 = NULL) {
subtoken = strtok_r(str2, argv[3], &saveptr2);
if (subtoken == NULL)
break;
printf(" --> %s\n", subtoken);
}
}
exit(EXIT_SUCCESS);
}
```
```
$ ./a.out 'a/bbb///cc;xxx:yyy:' ':;' '/'
1: a/bbb///cc
--> a
--> bbb
--> cc
2: xxx
--> xxx
3: yyy
--> yyy
```
`a/bbb///cc;xxx:yyy:`這個字符串有兩級分隔符,一級分隔符是:號或;號,把這個字符串分割成`a/bbb///cc`、`xxx`、`yyy`三個子串,二級分隔符是/,只有第一個子串中有二級分隔符,它被進一步分割成`a`、`bbb`、`cc`三個子串。由于`strtok_r`不使用靜態變量,而是要求調用者自己保存字符串的當前處理位置,所以這個例子可以在按一級分隔符分割整個字符串的過程中穿插著用二級分隔符分割其中的每個子串。建議讀者用`gdb`的`display`命令跟蹤`argv[1]`、`saveptr1`和`saveptr2`,以理解`strtok_r`函數的工作方式。
Man Page的_BUGS_部分指出了用`strtok`和`strtok_r`函數需要注意的問題:
* 這兩個函數要改寫字符串以達到分割的效果
* 這兩個函數不能用于常量字符串,因為試圖改寫`.rodata`段會產生段錯誤
* 在做了分割之后,字符串中的分隔符就被`'\0'`覆蓋了
* `strtok`函數使用了靜態變量,它不是線程安全的,必要時應該用可重入的`strtok_r`函數,以后再詳細介紹“可重入”和“線程安全”這兩個概念
#### 習題
1、出于練習的目的,`strtok`和`strtok_r`函數非常值得自己動手實現一遍,在這個過程中不僅可以更深刻地理解這兩個函數的工作原理,也為以后理解“可重入”和“線程安全”這兩個重要概念打下基礎。
2、解析URL中的路徑和查詢字符串。動態網頁的URL末尾通常帶有查詢,例如:
http://www.google.cn/search?complete=1&hl=zh-CN&ie=GB2312&q=linux&meta=
http://www.baidu.com/s?wd=linux&cl=3
比如上面第一個例子,`http://www.google.cn/search`是路徑部分,?號后面的`complete=1&hl=zh-CN&ie=GB2312&q=linux&meta=`是查詢字符串,由五個“key=value”形式的鍵值對(Key-value Pair)組成,以&隔開,有些鍵對應的值可能是空字符串,比如這個例子中的鍵`meta`。
現在要求實現一個函數,傳入一個帶查詢字符串的URL,首先檢查輸入格式的合法性,然后對URL進行切分,將路徑部分和各鍵值對分別傳出,請仔細設計函數接口以便傳出這些字符串。如果函數中有動態分配內存的操作,還要另外實現一個釋放內存的函數。完成之后,為自己設計的函數寫一個Man Page。
## 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?節 “目標文件”](ch18s05.html#asm.relocatable)中我們用`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函數”](ch28s02.html#io.twoioflavors)將會講到這個標識叫做文件描述符)、I/O緩沖區和當前讀寫位置等信息,但調用者不必知道`FILE`結構體都有哪些成員,我們很快就會看到,調用者只是把文件指針在庫函數接口之間傳來傳去,而文件指針所指的`FILE`結構體的成員在庫函數內部維護,調用者不應該直接訪問這些成員,這種編程思想在面向對象方法論中稱為封裝(Encapsulation)。像`FILE *`這樣的指針稱為不透明指針(Opaque Pointer)或者叫句柄(Handle),`FILE *`指針就像一個把手(Handle),抓住這個把手就可以打開門或抽屜,但用戶只能抓這個把手,而不能直接抓門或抽屜。
下面說說參數`path`和`mode`,`path`可以是相對路徑也可以是絕對路徑,`mode`表示打開方式是讀還是寫。比如`fp = fopen("/tmp/file2", "w");`表示打開絕對路徑`/tmp/file2`,只做寫操作,`path`也可以是相對路徑,比如`fp = fopen("file.a", "r");`表示在當前工作目錄下打開文件`file.a`,只做讀操作,再比如`fp = fopen("../a.out", "r");`只讀打開當前工作目錄上一層目錄下的`a.out`,`fp = 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系統中,無論文本文件還是二進制文件都是由一串字節組成,`t`和`b`沒有區分,用哪個都一樣,也可以省略不寫。如果省略`t`和`b`,`rwa+`四個字符有以下6種合法的組合:
`"r"`
只讀,文件必須已存在
"w"
只寫,如果文件不存在則創建,如果文件已存在則把文件長度截斷(Truncate)為0字節再重新寫,也就是替換掉原來的文件內容
"a"
只能在文件末尾追加數據,如果文件不存在則創建
"r+"
允許讀和寫,文件必須已存在
"w+"
允許讀和寫,如果文件不存在則創建,如果文件已存在則把文件長度截斷為0字節再重新寫
"a+"
允許讀和追加數據,如果文件不存在則創建
在打開一個文件時如果出錯,`fopen`將返回`NULL`并設置`errno`,`errno`稍后介紹。在程序中應該做出錯處理,通常這樣寫:
```
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`并設置`errno`,`errno`稍后介紹,`EOF`在`stdio.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?節 “終端的基本概念”](ch34s01.html#jobs.intro)會講到這叫進程的控制終端)。也就是說,`/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)這個詞。
那為什么`printf`和`scanf`不用打開就能對終端設備進行操作呢?因為在程序啟動時(在`main`函數還沒開始執行之前)會自動把終端設備打開三次,分別賦給三個`FILE *`指針`stdin`、`stdout`和`stderr`,這三個文件指針是`libc`中定義的全局變量,在`stdio.h`中聲明,`printf`向`stdout`寫,而`scanf`從`stdin`讀,后面我們會看到,用戶程序也可以直接使用這三個文件指針。這三個文件指針的打開方式都是可讀可寫的,但通常`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`變量,打印出來的只是一個整數值,仍然看不出是什么錯誤。比較好的辦法是用`perror`或`strerror`函數將`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并設置`errno`為`ENOENT`,緊接著`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`才返回<sup>[[34](#ftn.id2831641)]</sup>。這個程序的執行過程分析如下:
```
$ ./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);
```
`fseek`的`whence`和`offset`參數共同決定了讀寫位置移動到何處,`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
```
`fread`和`fwrite`用于讀寫記錄,這里的記錄是指一串固定長度的字節,比如一個`int`、一個結構體或者一個定長數組。參數`size`指出一條記錄的長度,而`nmemb`指出要讀或寫多少條記錄,這些記錄在`ptr`所指的內存空間中連續存放,共占`size * nmemb`個字節,`fread`從文件`stream`中讀出`size * nmemb`個字節保存到`ptr`中,而`fwrite`把`ptr`中的`size * nmemb`個字節寫到文件`stream`中。
`nmemb`是請求讀或寫的記錄數,`fread`和`fwrite`返回的記錄數有可能小于`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函數
現在該正式講一下`printf`和`scanf`函數了,這兩個函數都有很多種形式。
```
#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)`打印`0xff`,`printf("%x", 0xff)`打印`ff`。 |
| - | 格式化后的內容居左,右邊可以留空格。 | 見下面的例子 |
| 寬度 | 用一個整數指定格式化后的最小長度,如果格式化后的內容沒有這么長,可以在左邊留空格,如果前面指定了`-`號就在右邊留空格。寬度有一種特別的形式,不指定整數值而是寫成一個`*`號,表示取一個`int`型參數作為寬度。 | `printf("-%10s-", "hello")`打印`-?????hello-`,`printf("-%-*s-", 10, "hello")`打印`-hello?????-`。 |
| . | 用于分隔上一條提到的最小長度和下一條要講的精度。 | 見下面的例子 |
| 精度 | 用一個整數表示精度,對于字符串來說指定了格式化后保留的最大長度,對于浮點數來說指定了格式化后小數點右邊的位數,對于整數來說指定了格式化后的最小位數。精度也可以不指定整數值而是寫成一個`*`號,表示取下一個`int`型參數作為精度。 | `printf("%.4s", "hello")`打印`hell`,`printf("-%6.4d-", 100)`打印`-??0100-`,`printf("-%*.*f-", 8, 4, 3.14)`打印`-??3.1400-`。 |
| 字長 | 對于整型參數,`hh`、`h`、`l`、`ll`分別表示是`char`、`short`、`long`、`long long`型的字長,至于是有符號數還是無符號數則取決于轉換字符;對于浮點型參數,`L`表示`long double`型的字長。 | `printf("%hhd", 255)`打印`-1`。 |
常用的轉換字符有:
**表?25.2.?printf的轉換字符**
| 轉換字符 | 描述 | 舉例 |
| --- | --- | --- |
| d i | 取`int`型參數格式化成有符號十進制表示,如果格式化后的位數小于指定的精度,就在左邊補0。 | `printf("%.4d", 100)`打印`0100`。 |
| o u x X | 取`unsigned int`型參數格式化成無符號八進制(o)、十進制(u)、十六進制(x或X)表示,x表示十六進制數字用小寫abcdef,X表示十六進制數字用大寫ABCDEF,如果格式化后的位數小于指定的精度,就在左邊補0。 | `printf("%#X", 0xdeadbeef)`打印`0XDEADBEEF`,`printf("%hhu", -1)`打印`255`。 |
| c | 取`int`型參數轉換成`unsigned char`型,格式化成對應的ASCII碼字符。 | `printf("%c", 256+'A')`打印`A`。 |
| s | 取`const char *`型參數所指向的字符串格式化輸出,遇到`'\0'`結束,或者達到指定的最大長度(精度)結束。 | `printf("%.4s", "hello")`打印`hell`。 |
| p | 取`void *`型參數格式化成十六進制表示。相當于`%#x`。 | `printf("%p", main)`打印`main`函數的首地址`0x80483c4`。 |
| f | 取`double`型參數格式化成`[-]ddd.ddd`這樣的格式,小數點后的默認精度是6位。 | `printf("%f", 3.14)`打印`3.140000`,`printf("%f", 0.00000314)`打印`0.000003`。 |
| e E | 取`double`型參數格式化成`[-]d.ddde±dd`(轉換字符是e)或`[-]d.dddE±dd`(轉換字符是E)這樣的格式,小數點后的默認精度是6位,指數至少是兩位。 | `printf("%e", 3.14)`打印`3.140000e+00`。 |
| g G | 取`double`型參數格式化,精度是指有效數字而非小數點后的數字,默認精度是6。如果指數小于-4或大于等于精度就按`%e`(轉換字符是g)或`%E`(轉換字符是G)格式化,否則按`%f`格式化。小數部分的末尾0去掉,如果沒有小數部分,小數點也去掉。 | `printf("%g", 3.00)`打印`3`,`printf("%g", 0.00001234567)`打印`1.23457e-05`。 |
| % | 格式化成一個`%`。 | `printf("%%")`打印一個`%`。 |
我們在[第?6?節 “可變參數”](ch24s06.html#interface.va)講過可變參數的原理,`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個輸入字符,或者匹配到輸入字符中的下一個空白字符結束。
* 對于整型參數可以指定字長,有`hh`、`h`、`l`、`ll`(也可以寫成一個`L`),含義和`printf`相同。但`l`和`L`還有一層含義,當轉換字符是`e`、`f`、`g`時,表示賦值參數的類型是`float *`而非`double *`,這一點跟`printf`不同(結合以前講的類型轉換規則思考一下為什么不同),這時前面加上`l`或`L`分別表示`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]](bi01.html#bibli.kr "The C Programming Language")。第一個例子,讀取用戶輸入的浮點數累加起來。
**例?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”](ch17s05.html#arch.memh)中CPU、Cache和內存之間的關系一樣,C標準庫之所以會從內核預讀一些數據放在I/O緩沖區中,是希望用戶程序隨后要用到這些數據,C標準庫的I/O緩沖區也在用戶空間,直接從用戶空間讀取數據比進內核讀數據要快得多。另一方面,用戶程序調用`fputc`通常只是寫到I/O緩沖區中,這樣`fputc`函數可以很快地返回,如果I/O緩沖區寫滿了,`fputc`就通過系統調用把I/O緩沖區中的數據傳給內核,內核最終把數據寫回磁盤。有時候用戶程序希望把I/O緩沖區中的數據立刻傳給內核,讓內核寫回設備,這稱為Flush操作,對應的庫函數是`fflush`,`fclose`函數在關閉文件之前也會做Flush操作。
下圖以`fgets`/`fputs`示意了I/O緩沖區的作用,使用`fgets`/`fputs`函數時在用戶程序中也需要分配緩沖區(圖中的`buf1`和`buf2`),注意區分用戶程序的緩沖區和C標準庫的I/O緩沖區。
**圖?25.1.?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`時啟動代碼會調用`exit`,`exit`函數首先關閉所有尚未關閉的`FILE *`指針(關閉之前要做Flush操作),然后通過`_exit`系統調用進入內核退出當前進程<sup>[[35](#ftn.id2834688)]</sup>。
在上面的例子中,由于標準輸出是行緩沖的,`printf("hello world");`打印的字符串中沒有換行符,所以只把字符串寫到標準輸出的I/O緩沖區中而沒有寫回內核(寫到終端設備),如果敲Ctrl-C,進程是異常終止的,并沒有調用`exit`,也就沒有機會Flush I/O緩沖區,因此字符串最終沒有打印到屏幕上。如果把打印語句改成`printf("hello world\n");`,有換行符,就會立刻寫到終端設備,或者如果把`while(1);`去掉也可以寫到終端設備,因為程序退出時會調用`exit`Flush所有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?節 “頭文件”](ch20s02.html#link.header)講過的頭文件查找順序,本題目不必考慮`-I`選項指定的目錄,只在`.c`文件所在的目錄以及系統目錄`/usr/include`中查找。
* * *
<sup>[[34](#id2831641)]</sup> 這些特性取決于終端的工作模式,終端可以配置成一次一行的模式,也可以配置成一次一個字符的模式,默認是一次一行的模式(本書的實驗都是在這種模式下做的),關于終端的配置可參考[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")。
<sup>[[35](#id2834688)]</sup> 其實在調`_exit`進內核之前還要調用戶程序中通過`atexit(3)`注冊的退出處理函數,本書不做詳細介紹,讀者可參考[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")。
## 3.?數值字符串轉換函數
```
#include <stdlib.h>
int atoi(const char *nptr);
double atof(const char *nptr);
返回值:轉換結果
```
`atoi`把一個字符串開頭可以識別成十進制整數的部分轉換成`int`型,相當于下面要講的`strtol(nptr, (char **) NULL, 10);`。例如`atoi("123abc")`的返回值是123,字符串開頭可以有若干空格,例如`atoi(" -90.6-")`的返回值是-90。如果字符串開頭沒有可識別的整數,例如`atoi("asdf")`,則返回0,而`atoi("0***")`也返回0,根據返回值并不能區分這兩種情況,所以使用`atoi`函數不能檢查出錯的情況。下面要講的`strtol`函數可以設置`errno`,因此可以檢查出錯的情況,在嚴格的場合下應該用`strtol`,而`atoi`用起來更簡便,所以也很常用。
`atof`把一個字符串開頭可以識別成浮點數的部分轉換成`double`型,相當于下面要講的`strtod(nptr, (char **) NULL);`。字符串開頭可以識別的浮點數格式和C語言的浮點數常量相同,例如`atof("31.4 ")`的返回值是31.4,`atof("3.14e+1AB")`的返回值也是31.4。`atof`也不能檢查出錯的情況,而`strtod`可以。
```
#include <stdlib.h>
long int strtol(const char *nptr, char **endptr, int base);
double strtod(const char *nptr, char **endptr);
返回值:轉換結果,出錯時設置errno
```
`strtol`是`atoi`的增強版,主要體現在這幾方面:
* 不僅可以識別十進制整數,還可以識別其它進制的整數,取決于`base`參數,比如`strtol("0XDEADbeE~~", NULL, 16)`返回0xdeadbee的值,`strtol("0777~~", NULL, 8)`返回0777的值。
* `endptr`是一個傳出參數,函數返回時指向后面未被識別的第一個字符。例如`char *pos; strtol("123abc", &pos, 10);`,`strtol`返回123,`pos`指向字符串中的字母a。如果字符串開頭沒有可識別的整數,例如`char *pos; strtol("ABCabc", &pos, 10);`,則`strtol`返回0,`pos`指向字符串開頭,可以據此判斷這種出錯的情況,而這是`atoi`處理不了的。
* 如果字符串中的整數值超出`long int`的表示范圍(上溢或下溢),則`strtol`返回它所能表示的最大(或最小)整數,并設置`errno`為`ERANGE`,例如`strtol("0XDEADbeef~~", NULL, 16)`返回0x7fffffff并設置`errno`為`ERANGE`。
回想一下使用`fopen`的套路`if ( (fp = fopen(...)) == NULL) { 讀取errno }`,`fopen`在出錯時會返回`NULL`,因此我們知道需要讀`errno`,但`strtol`在成功調用時也可能返回0x7fffffff,我們如何知道需要讀`errno`呢?最嚴謹的做法是首先把`errno`置0,再調用`strtol`,再查看`errno`是否變成了錯誤碼。Man Page上有一個很好的例子:
**例?25.10.?strtol的出錯處理**
```
#include <stdlib.h>
#include <limits.h>
#include <stdio.h>
#include <errno.h>
int main(int argc, char *argv[])
{
int base;
char *endptr, *str;
long val;
if (argc < 2) {
fprintf(stderr, "Usage: %s str [base]\n", argv[0]);
exit(EXIT_FAILURE);
}
str = argv[1];
base = (argc > 2) ? atoi(argv[2]) : 10;
errno = 0; /* To distinguish success/failure after call */
val = strtol(str, &endptr, base);
/* Check for various possible errors */
if ((errno == ERANGE && (val == LONG_MAX || val == LONG_MIN))
|| (errno != 0 && val == 0)) {
perror("strtol");
exit(EXIT_FAILURE);
}
if (endptr == str) {
fprintf(stderr, "No digits were found\n");
exit(EXIT_FAILURE);
}
/* If we got here, strtol() successfully parsed a number */
printf("strtol() returned %ld\n", val);
if (*endptr != '\0') /* Not necessarily an error... */
printf("Further characters after number: %s\n", endptr);
exit(EXIT_SUCCESS);
}
```
`strtod`是`atof`的增強版,增強的功能和`strtol`類似。
## 4.?分配內存的函數
除了`malloc`之外,C標準庫還提供了另外兩個在堆空間分配內存的函數,它們分配的內存同樣由`free`釋放。
```
#include <stdlib.h>
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);
返回值:成功返回所分配內存空間的首地址,出錯返回NULL
```
`calloc`的參數很像`fread`/`fwrite`的參數,分配`nmemb`個元素的內存空間,每個元素占`size`字節,并且`calloc`負責把這塊內存空間用字節0填充,而`malloc`并不負責把分配的內存空間清零。
有時候用`malloc`或`calloc`分配的內存空間使用了一段時間之后需要改變它的大小,一種辦法是調用`malloc`分配一塊新的內存空間,把原內存空間中的數據拷到新的內存空間,然后調用`free`釋放原內存空間。使用`realloc`函數簡化了這些步驟,把原內存空間的指針`ptr`傳給`realloc`,通過參數`size`指定新的大小(字節數),`realloc`返回新內存空間的首地址,并釋放原內存空間。新內存空間中的數據盡量和原來保持一致,如果`size`比原來小,則前`size`個字節不變,后面的數據被截斷,如果`size`比原來大,則原來的數據全部保留,后面長出來的一塊內存空間未初始化(`realloc`不負責清零)。注意,參數`ptr`要么是`NULL`,要么必須是先前調用`malloc`、`calloc`或`realloc`返回的指針,不能把任意指針傳給`realloc`要求重新分配內存空間。作為兩個特例,如果調用`realloc(NULL, size)`,則相當于調用`malloc(size)`,如果調用`realloc(ptr, 0)`,`ptr`不是`NULL`,則相當于調用`free(ptr)`。
```
#include <alloca.h>
void *alloca(size_t size);
返回值:返回所分配內存空間的首地址,如果size太大導致棧空間耗盡,結果是未定義的
```
參數`size`是請求分配的字節數,`alloca`函數不是在堆上分配空間,而是在調用者函數的棧幀上分配空間,類似于C99的變長數組,當調用者函數返回時自動釋放棧幀,所以不需要`free`。這個函數不屬于C標準庫,而是在POSIX標準中定義的。
- Linux C編程一站式學習
- 歷史
- 前言
- 部分?I.?C語言入門
- 第?1?章?程序的基本概念
- 第?2?章?常量、變量和表達式
- 第?3?章?簡單函數
- 第?4?章?分支語句
- 第?5?章?深入理解函數
- 第?6?章?循環語句
- 第?7?章?結構體
- 第?8?章?數組
- 第?9?章?編碼風格
- 第?10?章?gdb
- 第?11?章?排序與查找
- 第?12?章?棧與隊列
- 第?13?章?本階段總結
- 部分?II.?C語言本質
- 第?14?章?計算機中數的表示
- 第?15?章?數據類型詳解
- 第?16?章?運算符詳解
- 第?17?章?計算機體系結構基礎
- 第?18?章?x86匯編程序基礎
- 第?19?章?匯編與C之間的關系
- 第?20?章?鏈接詳解
- 第?21?章?預處理
- 第?22?章?Makefile基礎
- 第?23?章?指針
- 第?24?章?函數接口
- 第?25?章?C標準庫
- 第?26?章?鏈表、二叉樹和哈希表
- 第?27?章?本階段總結
- 部分?III.?Linux系統編程
- 第?28?章?文件與I/O
- 第?29?章?文件系統
- 第?30?章?進程
- 第?31?章?Shell腳本
- 第?32?章?正則表達式
- 第?33?章?信號
- 第?34?章?終端、作業控制與守護進程
- 第?35?章?線程
- 第?36?章?TCP/IP協議基礎
- 第?37?章?socket編程
- 附錄?A.?字符編碼
- 附錄?B.?GNU Free Documentation License Version 1.3, 3 November 2008
- 參考書目
- 索引