printf函數和scanf函數,C語言printf函數和scanf函數詳解

2020-07-16 10:04:22
對於 printf 函數,相信大家並不陌生。之所以稱它為格式化輸出函數,關鍵就是該函數可以按使用者指定的格式,把指定的資料顯示到顯示器螢幕上。該函數原型的一般格式如下:

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

很顯然,與其他庫函數不同的是,printf 函數是一個“可變引數函數”(即函數引數的個數是可變的)。確切地說,是其輸出引數的個數是可變的,且每一個輸出引數的輸出格式都有對應的格式說明符與之對應,從格式串的左端第 1 個格式說明符對應第 1 個輸出引數,第 2 個格式說明符對應第 2 個輸出引數,第 3 個格式說明符對應第 3 個輸出引數,以此類推。其中,格式說明符的一般形式如下(方括號 [] 中的項為可選項):

%[flags][width][.prec][length] type_char
/*用中文標識如下:*/
%[標誌符][寬度][精度][長度]型別符

1) 型別符(type_char)

它用以表示輸出資料的型別,如表 1 所示。

表 1 常見的型別符及其說明
符 號 類 型 說 明 示 例 結 果
% 輸出字元“%”本身 pnntf("%%"); %
d、i int 以整型輸出 printf("%i,%d", 100,100); 100,100
u unsigned int 以無符號整型輸出 printf( "%u,%u",100u,100); 100,100
o unsigned int 以八進位制無符號整S輸出 printf( "%o”,100); 144
x unsigned int 以十六進位制小寫輸出 printf("%x",11); b
X unsigned int 以十六制大寫輸出 printf("%X",11); B

除表 1 所示的型別符之外,還有一個比較特殊與另類的型別符“%n”,當在格式化字串中碰到“%n”時,在“%n”之前輸出的字元個數會儲存到下一個引數里。例如,下面的範例程式碼演示了如何獲取在兩個格式化的數位之間空間的偏量:
int main(void)
{
    int pos=0;
    int x = 123;
    int y = 456;
    printf("%d%n%dn", x, &pos, y);
    printf("pos=%dn", pos);
    return 0;
}
很顯然,上面程式碼中的 pos 將輸出 3,即“123”的長度,執行結果為:
123456
pos=3

這裡需要特別注意,“%n”返回的是應該被輸出的字元數目,而不是實際輸出的字元數目。當把一個字串格式化輸出到一個定長緩衝區內時,輸出字串可能被截短。不考慮截短的影響,“%n”格式表示如果不被截短的偏量值(輸出字元數目)。看下面的範例程式碼:
int main(void)
{
    char buf[20];
    int pos=0;
    int x = 0;
    snprintf(buf, sizeof(buf), "%.100d%n", x, &pos);
    printf("pos=%dn", pos);
    return 0;
}
很顯然,上面的程式碼會輸出 100,而不是 20。

由此可見,相對於“%d”“%x”“%s”等,“%n”的顯著不同之處就是“%n”會改變變數的值,這也就是格式化字串攻擊的爆破點,如下面的範例程式碼所示:
char daddr[16];
int main(void)
{
    char buf[100];
    int x=1;
    memset(daddr,'/0',16);
    printf("前X: %d/%#x (%p)n", x, x, &x);
    strncpy(daddr,"AAAAAAA%n",9);
    snprintf(buf,sizeof(buf),daddr);
    buf[sizeof(buf) - 1] = 0;
    printf("後X: %d/%#x (%p)n",x, x, &x);
    return 0;
}
在上面的程式碼中,x 將被從 1 修改成 7,其執行結果為:
前X: 1/0x1 (0061FEA8)
後X: 7/0x7 (0061FEA8)

之所以會出現這樣的結果,是因為程式在呼叫 snprintf 函數之前,首先呼叫了 printf 函數,而這時 printf 函數的 &x 引數在 main 函數的堆疊記憶體中留下了 &x 的記憶體殘像。當呼叫 snprintf 時,系統本來只給 snprintf 準備了 3 個引數,但是由於格式化字串攻擊原因,使得 snprinf 認為應該有 4 個引數傳給它,這樣 snprintf 就私自把 &x 的記憶體殘像作為第 4 個引數讀走,而 snprintf 所謂的第 4 個引數對應的就是“%n”,於是 snprintf 就成功修改了變數 x 的值。這也就是最常見的使用 Linux 函數呼叫時的記憶體殘像來實現格式化字串攻擊的方法之一,所以在使用的時候一定要注意。

2) 標誌符(flags)

它用於規定輸出格式,如表 2 所示。

表 2 標誌符及其說明
符號 說 明
(空白) 右對齊,左邊填充 0 和空格
(空格) 輸出值為正時加上空格,為負時加上負號
- 輸出結果為左對齊(預設為右對齊),邊填空格(如果存在表格最後一行介紹的0,那麼將忽略0)
+ 在數位前增加符號“+”(正號)或“-”(負號)
# 類塑符是o、x、X吋,增加字首0、0x、0X;型別符是e、E、f、F、g、G時,一定要使用小數點;型別符是g、G時,尾部的 0 保留
0 引數的前面用0填充,直到佔滿指定列寬為止(如果同時存在“-”,將被“-”覆蓋,導致 0 被忽略

3) 寬度(width)

它用於控制顯示數值的寬度,如表 3 所示。

表 3 寬度及其說明
符號 說 明
n 至少輸出 n 個字元(n 是一個正整數)。如果輸出少於 n 個字元,則用空格填滿餘下的位置(如果識別符號為“-”,則在右側填,否則在左端填)
0n 至少輸出 n 個字元(n 是一個正整數)。如果輸出值少於 n 個字元,則在左側填滿 0
* 輸出字元個數由下一個輸出引數指定(其必須為一個整形量)

4) 精度(.prec)

它用於控制顯示數值的精度。如果輸出的是數位,則表示小數的位數;如果輸出的是字元,則表示輸出字元的個數;若實際位數大於所定義的精度數,則截去超過的部分。如表 4 所示。
表 4 精度及其說明
符號 說 明
系統預設精度
.0 對於 d、i、o、u、x、X等整形型別符,採用系統預設精度;對於f、F、e、E等浮點型別符,不輸出小數部分
.n 1) 對於d、i、o、u、x、X型別符,至少輸出 n 位數位,且:
  • 如果對應的輸出引數少於 n 位數位,則在其左端用零(0)填充
  • 如果對應的輸出引數多於 n 位數位,則輸出時不對其進行截斷
2) 對於f、F、e、E型別符,輸出結果保留 n 位小數。如果小數部分多於 n 位,則對其四捨五入
3) 對於 g 和 G 型別符,最多輸出 n 位有效數位
4) 對於 s 型別符,如果對應的輸出串的長度不超過 n 個字元,則將其原樣輸出,否則輸出其頭 n 個寧符
* 輸出精度由下一個輸出引數指定(其必須為一個整型量)

5) 長度(length)

它用於控制顯示數值的長度,如表 5 所示。

表 5 長度及其說明
符號 說 明
hh 與d、i 一起使用,表示一個signed char 型別的值;與o、u、x、X—起使用,表示一個unsigned char 型別的值;與 n 一起使用,表示相應的變元是指向 signed char 型變數的指標(c99 )
h 與d、i、o、u、x、X 或 n 一起使用,表示一個short int 或 unsigned short int 型別的值
l 與d、i、o、u、x、X 或 n 一起使用,表示一個 long int 或者 unsigned long int 型別的值
ll 與 d、i、o、u、x、X 或 n —起使用,表示相應的變元是 long long int 或 unsigned long long int 型別的值(c99 )
j 與 d、i、o、u、x、X 或 n —起使用,表示匹配的變元是 intmax_t 或 uintmax_t 型別,這些型別在“stdint. h”中宣告(c99 )
z 與 d、i、o、u、x、X 或 n —起使用,表示匹配的變元是指向 size_t 型別物件的指標,該型別在“stddef. h”中宣告(c99 )
t 與d、i、o、u、x、X 或 n —起使用,表示匹配的變元是指向 ptrdiff_t 型別物件的指標,該型別在“stddef. h”中宣告(c99 )
L 和a、A、e、E、f、F、g、G—起使用,表示一個long double型別的值

最後,在使用 printf 函數時還必須注意,盡量不要在 printf 語句中改變輸出變數的值,因為可能會造成輸出結果的不確定性。如下面的範例程式碼所示:
int k=8;
printf("%d,%dn",k,++k);
對於上面的程式碼,表面上看起來輸出的結果應該是“8,9”。但實際情況並非如此,在呼叫printf函數時,其引數是從右至左進行處理的,即將先進行 ++k 運算,所以最後的結果是“9,9”。由此可見,千萬不要在 printf 語句中試圖改變輸出變數的值,如果確實需要改變,可以按照下面的範例程式碼形式來處理:
printf("%dn",k);
printf("%dn",++k);
這樣處理之後,其結果就是我們所需要的“8,9”了。

除此之外,每一個輸出引數的輸出格式都必須有對應的格式說明符與之一一對應,並且型別必須匹配。若二者不能夠一一對應匹配,則不能夠正確輸出,而且編譯時可能不會報錯。同時,若格式說明符個數少於輸出項個數,則多餘的輸出項將不予輸出;若格式說明符個數多於輸出項個數,則可能會輸出一些毫無意義的數位亂碼。

scanf 函數

相對於 printf 函數,scanf 函數就簡單得多。scanf 函數的功能與 printf 函數正好相反,執行格式化輸入功能。即 scanf 函數從格式串的最左端開始,每遇到一個字元便將其與下一個輸入字元進行“匹配”,如果二者匹配(相同)則繼續,否則結束對後面輸入的處理。而每遇到一個格式說明符,便按該格式說明符所描述的格式對其後的輸入值進行轉換,然後將其存於與其對應的輸入地址中。以此類推,直到格式串結束為止。該函數原型的一般格式如下:
int scanf (const char *format, ...);
從函數原型可以看出,同 printf 函數相似,scanf 函數也是一個“可變引數函數”。同時,scanf 函數的第一個引數 format 也必須是一個格式化串。除此格式化串之外,scanf 函數還可以有若干個輸入地址,且對於每一個輸入地址,在格式串中都必須有一個格式說明符與之一一對應。即從格式串的左端第 1 個格式說明符對應第 1 個輸入地址,第 2 個格式說明符對應第 2 個輸入地址,第 3 個格式說明符對應第 3 個輸入地址,以此類推。

也就是說,除第 1 個格式化串引數之外,其他引數的個數是可變的,且每一個輸入地址必須指向一個合法的儲存空間,以便能正確地接受相應的輸入值。每個輸入值的轉換格式都由格式說明符決定。格式說明符的一般形式如下(方括號 [] 中的項為可選項):

%[*][width][length] type_char
/*用中文標識如下:*/
%[*][寬度][長度]型別符

在使用 scanf 函數的時候,需要特別注意的就是緩衝區問題。對 scanf 函數來說,估計最容易出錯、最令人捉摸不透的問題應該是緩衝區問題了。

下面先來看一段範例程式碼:
int main(void)
{
    char c[5];
    int i=0;
    printf("輸入資料(hello):n");
    for(i = 0;i < 5; ++i)
    {
        scanf("%c", &c[i]);
    }
    printf("輸出資料:n");
    printf("%sn", c);
    return 0;
}
對於上面這段範例程式碼,我們希望在“c[5]”字元陣列中能夠儲存“hello”字串,並在最後輸出到螢幕上。從表面上看,這段程式沒有任何問題,但實際情況並非如此。當我們依次輸入“h(回車)”“e(回車)”,然後再輸入“l”時,問題發生了。此時,程式不僅中斷輸入操作,而且會列印出字元陣列 c 中的內容,其執行結果為:
輸入資料(hello):
h
e
l
輸出資料:
h
e
l

很顯然,字元陣列“c[5]”是完全能夠儲存“hello”字串的,但為什麼輸入到“l”就結束了呢?

其實原因很簡單,在我們輸入“h”和第一個回車後,“h”和這個回車符“n”都保留在緩衝區中。第 1 個 scanf 讀取了“h”,但是輸入緩衝區裡面還留有一個“n”,於是第 2 個 scanf 讀取這個“n”,然後輸入“e”和第 2 個回車符“n”。同理,第 3 個 scanf 讀取了“e”,第 4 個 scanf 讀取了第 2 個回車符“n”,第 5 個 scanf讀取了“l”。因此,程式並沒有提前結束,而是完整地迴圈了5次scanf語句,只不過有兩次scanf都讀取到回車符“n”而已。

由此可見,在使用 scanf 函數時,如果不及時重新整理輸入緩衝區,有時會出現莫名其妙的錯誤。對於這類問題,其實解決辦法有許多,比如可以使用“fflush(stdin);”語句來重新整理輸入緩衝區。但不得不說明的是,fflush 函數在可移植性上並不是很好。當然,也可以通過自己編寫程式碼來解決,如下面的範例程式碼所示:
#include <stdio.h>
void flush()
{
    char c;
    while ((c=getchar()) != 'n'&&c!=EOF);
}
int main(void)
{
    char c[5];
    int i=0;
    printf("輸入資料(hello):n");
    for(i = 0; i < 5; ++i)
    {
        scanf("%c", &c[i]);
        flush();
    }
    printf("輸出資料:n");
    printf("%sn", c);
    return 0;
}
這樣,就從根本上解決了輸入緩衝區問題,其執行結果為:
輸入資料(hello):
h
e
l
l
o
輸出資料:
hello

除此之外,還應該注意 scanf 中的空白符(這裡所指的空白符包括空格、製表符、換行符、回車符和換頁符)帶來的問題,如下面的程式碼所示:
int main(void)
{
    int a=0;
    printf("輸入資料:n");
    /*請注意,這裡多了一個回車符n*/
    scanf("%dn",&a);
    printf("輸出資料:n",a);
    printf("%dn",a);
    return 0;
}
在上面的程式碼中,因為在“scanf("%dn",&a);”語句中多加了一個回車符“n”,導致的結果就是要輸入兩個數,程式才會正常結束,而不是我們所期望的一個數。執行結果為:
輸入資料:
22
11
輸出資料:
22

原因就是在用空白符結尾時,scanf 會跳過空白符去讀下一個字元,所以必須再輸入一個數。因此在編寫程式時一定要多注意這類手誤導致的錯誤。