開頭語
上面一篇已經寫過,c語言不僅僅只是文法很好玩,很讓人捉摸不透,它的思想更是深奧,需要慢慢體會的。看了關於c語言的書籍不少於50本,每看一遍都感覺認識深了一點,但是終究還是無法完全理解它背後最深層的思想,只是在不斷地向它走近。正因為如此,下面的描述不會按照一個固定的說明文格式來編寫,而是採用對話的方式來將這個過程展現出來,這樣應該能更多地展示出c語言的思想。
格式: 問題(Question)將以 Q: 開頭, 回答(Answer)將以 A: 作為開頭。
Q: c語言的HelloWorld版本是什麼樣子的?
A: 起名為hello.c:
#include <stdio.h>int main(){ printf("Hello World!"); return 0;}
Q: #include這條語句是什麼意思?
A: 它以#開頭,它表示預先處理過程,一般不把它當做編譯過程,而是用一個單獨的前置處理器來處理。因為,從編譯原始碼的角度來說,將一個標頭檔插入到最終被編譯的代碼和編譯原始碼真的沒什麼一致的地方。
Q:如何才能感受前置處理器的存在呢?
A: 一般的編譯命令都提供預先處理選項,可以查看預先處理後的原始碼是什麼樣子。
[Windows-VS2010] 可以使用cl.exe命令來查看預先處理後的原始碼:
cl /P hello.c
會在同目錄下產生一個.i的預先處理後的檔案hello.i, 內容如下(因為篇幅問題,無法將所有內容上傳,否則儲存不了,省略符號省略了大部分代碼):
#line 1 "hello.c"#line 1 "d:\\Program Files\\Microsoft Visual Studio 10.0\\VC\\INCLUDE\\stdio.h"..........................................................#line 739 "d:\\Program Files\\Microsoft Visual Studio 10.0\\VC\\INCLUDE\\stdio.h"#line 2 "hello.c"int main(){ printf("Hello World!"); return 0;}
Q:看了那個檔案,檔案實在是太長了!
A: 是的,這其實也是c和c++的一個缺點,#include將原始碼插入的方式可能會導致被編譯的原始碼長度迅速增加,這也是先行編譯出現的一個原因。也可以看到其實hello.c代碼那幾行也就是最後的那幾句,前面都是#include標頭檔插入的代碼,確實大的很直接。
Q:hello.i檔案最後幾句,有#line 2 "hello.c"這句,它是什麼意義?
A: #符號開頭,依然是編譯器的指令,它表示當前行被設定為2,檔案名稱是hello.c.因為,之前的#include<stdio.h>被插入了很多其它檔案的內容,但是在hello.c中,第二行是接著#include <stdio.h>後面的,所以將行數重新設定,這樣才能正確地解析hello.c的程式碼數。
Q:cl命令我怎麼知道/P參數是預先處理作用的?
A:當然可以用cl /?命令來得到它的協助,然後找到需要功能的參數。
Q: 這麼多參數,尋找要的有點難啊?
A: 可以想到unix下的grep命令來過濾字串。當然,這需要cygwin的支援。
上面的grep命令是在裝了cygwin後才有的。
Q: 再回到hello.c裡面吧。main函數必須有個傳回值嗎?
A: 是的,其實這很能體現分層思想,任何一個應用程式最終都可能有個傳回值,供調用它的程式來使用,所以在這裡有個int的傳回值
也是可以理解的。
Q: 怎麼查看調用一個程式的傳回值呢?
A: [Windows] 在命令列下,可以使用echo %errorlevel%來擷取它的值。
[Mac]在命令列下,可以使用echo $?來擷取。
[Ubuntu]同[Mac].
比如,hello.c的代碼編譯成hello.exe,
cl hello.c:
產生hello.exe,運行它:
使用echo %errorlevel%列印傳回值:
可以看到,返回的正是代碼中return語句後面的0.
Q: 可以返回的不是0嗎?
A: 當然可以。修改hello.c原始碼為:
#include <stdio.h>int main(){ printf("Hello World!"); return 1;}
再次編譯運行後,使用echo%errorlevel%得到:
Q: printf是一個函數,它的原型是什嗎?
A: 在stdio.h中可以看到它的原型是:
Q: 怎麼這麼複雜?紫色的部分是什嗎?
A: 其實,很多原型中有一些附加的文本,它們在編譯過程中並沒有充當很有意義的東西,只是一種注釋或者說明資訊,在一些特定情況下起了作用,可以暫且不用特別關注它們。
比如,_Check_return_opt_的宏定義是:
再接著進入_Check_return_也可以看到類似的宏定義,依次分析。
Q: 那麼printf函數的原型簡化版就是
int __cdecl printf(const char * _Format, ...);
?
A:是的,也可以再簡化成
int printf( const char * _Format, ...);
Q: __cdecl是什嗎?
A: 這是一種函數調用方式;對應的還有__stdcall,__ fastcall等等,它們有一定的區別,比如函數壓棧順序,參數優先儲存在寄存器中等,c語言預設是__cdecl方式。
Q: const char * _Format中的const可以去掉嗎?
A: 可以的,但是用上const表示不可更改的變數,printf函數內部不可以改變_Format指向的字串,這是一種防禦性編程的思想。
Q: c++中的const不是表示常量嗎?
A: 是的,但是c語言中的const表示的是一個變數,但是不可更改。這個關鍵字在c語言和c++中是有區別的。
Q: 舉個例子。
A: 比如在c語言中用const修飾的一個變數,不可以作為靜態數組的參數個數,因為它是一個變數;在c++中,這是可以的。
如hello.c代碼修改為如下:
#include <stdio.h>int main(){ const int size = 10; int arr[size]; return 0;}
出現編譯錯誤:
錯誤原因就是需要一個常量的大小表示數組的大小。
Q:如果是c++,如何呢?
A: 使用cl /TP hello.c將hello.c代碼當成c++代碼進行編譯:
沒有出現錯誤。
Q: 原始碼不是hello.c,尾碼名是.c, 怎麼是當做c++代碼編譯了呢?
A: 這主要是在於/TP參數的作用了:
可以看到,/TP命令列將所有檔案當成.cpp來編譯,即也會把hello.c代碼當成.cpp來編譯。
Q: 這麼說來,編譯器編譯原始碼不一定看尾碼名的了?
A: 當然是的,對於編譯器來說,只要給文本即可,對於尾碼只不過在通常情況下按照指定類型代碼編譯而已,可以指定將
某種副檔名的代碼當成特定類型代碼編譯的。
Q: printf函數原型參數中的三個點表示什嗎?
A: 它表示可變參數,即是參數個數不能確定,也許是1個,也許2個或者更多。
Q: 為什麼要這麼設計?
A: 因為對於輸出功能來說,該輸出多少東西,設計者開始是不知道的,所以交給了程式員來是實現,編譯器只需要根據擷取
到的參數最後正確轉換給內部處理函數即可。
比如:
printf("Hello World!"); printf("My name is %s", "xichen"); printf("My name is %s, age is %d", "xichen", 25);
Q: 那麼printf函數返回什麼呢?
A: 它返回成功輸出的位元組數。
原始碼:
#include <stdio.h>int main(){ int ret; ret = printf("abc"); printf(" ret is %d\n", ret); ret = printf("Hello World!"); printf(" ret is %d\n", ret); ret = printf("My name is %s", "xichen"); printf(" ret is %d\n", ret); return 0;}
輸出:
Q:如果有中文在字串中,位元組數怎麼算呢?
A: 這需要根據終端最終輸出的位元組數來得到了。
在Windows下的記事本開啟編寫如下代碼:
#include <stdio.h>int main(){ int ret; ret = printf("abc中國"); printf(" ret is %d\n", ret); return 0;}
以ANSI格式儲存,ANSI即以本地化編碼格式儲存;
[Windows7]本地化語言預設為ANSI格式,在註冊表中查看得到:
是0804,0804代表簡體中文,對應編碼為GBK.
再次查看cmd輸出字元使用的編碼:
可以看到,確定編碼格式是GBK.
所以,上面的字串中的"中國"是以GBK格式輸出,即一個中文字元對應2個位元組,即"abc中國"這個字串總長度為7.
所以,輸出:
Q: 如果將此原始碼另存新檔UTF8格式儲存,進行編譯,最終的結果會不會變呢?
A: 來看看。
將原始碼以UTF8格式儲存:
再次編譯hello.c,並運行:
可以看到,它的結果依然是7.
Q:不是編碼格式變成UTF8, 這兩個中文每個字元佔有3個位元組嗎?輸出的位元組數怎麼還是7,它怎麼認為每個中文佔用2個位元組呢?
把UTF8格式的hello.c原始碼用十六進位開啟:
可以看到,"中國"兩個中文確實總共佔用6個位元組,可是為什麼printf輸出後計算的總位元組數不是9而是7呢?
A: 當然,原始碼的編碼格式以及原始碼中字串的編碼格式和最終輸出的編碼格式不一定是一樣的。正如前面所說,printf
函數的傳回值以終端真實輸出的位元組數為準,終端的字元編碼是GBK,不管原來編寫代碼的對應編碼是什麼,和最終輸出並沒
有必然關係,所以結果依然是7.
Q:可執行檔hello.exe中的"abc中國"字串對應的編碼是什麼呢?
A: 將hello.exe用十六進位開啟,
可以發現,"中國"字串用的是用GBK編碼格式儲存的;這裡,應該可以理解為什麼一直輸出7了吧。
Q: 編碼這個東西還真有意思,如何改變cmd終端的編碼格式?
A: 這個編碼格式也被稱為字碼頁,可以使用chcp命令:
直接輸入chcp可以得到當前的字碼頁:
更改為utf-8格式,使用chcp 65001 :
此時再運行hello.exe,結果依然是7,但是"中國"字元變成了亂碼。
我想原因你應該知道了。
Q:輸出字串,不也是可以用unicode寬字元的嗎?
A: 是的。
如下代碼:
#include <stdio.h>int main(){ int ret; ret = wprintf(L"abc%S", L"中國"); printf(" ret is %d\n", ret); return 0;}
[Windows-VS2010]輸出:
在Mac或者Ubuntu下,此代碼暫未測試;因為關於wprintf以及%ls和%S,不同系統下表現不一致,可能需要修改代碼。
Q: printf函數裡面的輸出格式有好多種,但是和對應輸出格式的資料如果不一致,會導致什麼問題呢?
A: 當然是可能會發生一些問題了,因為printf函數是動態解析格式字串,然後將對應的資料來填充,可能出現本來是
%d格式,但是卻用了一個double的資料來填充,這樣就可能導致錯誤。
Q: 比如:
#include <stdio.h>int main(){ printf("%d", 1.5); return 0;}
為什麼編譯階段沒有出現編譯的錯誤呢?
A: 因為編譯器將"%d"當成了一個字串,且並不去分析其中的格式,這個交給了printf函數的實現內部。其實,也就是把判斷是否正確的責任交給了程式員,
如果程式員弄錯了,那麼結果就可能會跟著錯。
Q: printf函數解析格式輸出的代碼該怎麼寫?
A: 這個需要根據字串中的%為標誌,出現這個,說明後面可能跟著一個對應格式的資料,比如d,那麼說明是個整數,將棧中對應的資料來填充;如果是s,
那就是取字串來填充,依次類推;如果沒有遇到%符號,那麼按原樣輸出。
一個簡單且調用了printf的類printf函數:
int __cdecl cc_printf( const char *format, ... ){ va_list argulist; int ret = 0; va_start(argulist, format); while (*format) {if(*format != '%'){ putchar(*format); ++ret; goto loop;}else{ ++format; switch (*format) { case 'c':{ int value = va_arg(argulist, int); ret += printf("%c", (char)value); goto loop;} case 's':{ char *value = va_arg(argulist, char *); ret += printf("%s", value); goto loop;} case 'd':{ int value = va_arg(argulist, int); ret += printf("%d", value); goto loop;} case 'o':{ int value = va_arg(argulist, int); ret += printf("%x", value); goto loop;} case 'x':{ int value = va_arg(argulist, int); ret += printf("%x", value); goto loop;} case 'X':{ int value = va_arg(argulist, int); ret += printf("%X", value); goto loop;} case 'u':{ unsigned value = va_arg(argulist, unsigned); ret += printf("%u", value); goto loop;} case 'f':{ double value = va_arg(argulist, double); ret += printf("%f", value); goto loop;} default:{ goto loop;} }}loop:++format; } va_end(argulist); return ret;}
Q: 還回到剛剛那個問題吧。
printf("%d", 1.5);
這個會輸出什麼呢?
A: 編譯一下,輸出:
Q: 為什麼會輸出這個神奇的數字呢?
A: 根據IEEE754的標準,雙精確度浮點數1.5的二進位表示形式是:
00 00 00 00 00 00 F8 3F
可以看到,低4個位元組都是0,而%d正好只取了低4個位元組,所以結果是0.
為了方便地列印出double資料中各個位元組的值,可以使用如下的union結構:
union double_data{ double f; unsigned char data[sizeof(double) / sizeof(char)];};
Q: 有的時候,printf格式串後的參數個數超過了格式串中的對應格式,會是什麼結果?
A: 先寫個代碼:
#include <stdio.h>int main(){ printf("%d %d", 1, 2, 3); return 0;}
輸出的結果是什麼呢?
Q: 為什麼不是輸出2和3呢,或者輸出3和2呢?
A: 看彙編:
使用cl /Fa hello.c命令編譯,得到hello.asm檔案:
; Listing generated by Microsoft (R) Optimizing Compiler Version 16.00.30319.01 TITLEF:\c_codes\hello.c.686P.XMMinclude listing.inc.modelflatINCLUDELIB LIBCMTINCLUDELIB OLDNAMES_DATASEGMENT$SG2637DB'%d %d', 00H_DATAENDSPUBLIC_mainEXTRN_printf:PROC; Function compile flags: /Odtp_TEXTSEGMENT_mainPROC; File f:\c_codes\hello.c; Line 5pushebpmovebp, esp; Line 6push3push2push1pushOFFSET $SG2637call_printfaddesp, 16; 00000010H; Line 8xoreax, eax; Line 9popebpret0_mainENDP_TEXTENDSEND
可以看到; Line 6標識的地方,有4個參數壓棧的操作,依次是3, 2, 1和$SG2637, $SG2637也就是對應"%d %d"這個字串。
這個時候,你應該看明白了吧,調用printf("%d %d", 1, 2, 3);函數的時候,此函數有4個參數,但是從右向左依次壓棧,最後解析
"%d %d"字串的時候,第一個格式就對應棧中下一個元素,也就是1, 依次找到第二個對應格式的資料,也就是2.所以最後輸出
了1和2.
Q: 如果printf參數中第一個參數中的資料格式數多於後面的參數,那又會發生什麼呢?
A: 當然按照之前的原理,解析資料格式的時候會從棧裡多解析一個資料,這很可能導致之後的運行錯誤,因為它很可能並不是
程式員的意圖。
Q: 看到一些地方,printf函數後面的參數有好幾個帶有自增或者自減的操作,最後的結果真的很神奇,這有什麼規律嗎?
A: 你是說,比如
#include <stdio.h>int main(){ int i = 1; printf("%d %d", ++i, ++i); return 0;}
最後的結果很可能不是你想要的,不過這個代碼也可能讓別人有好幾種推測。所以,這種代碼最好是不要寫,要麼寫就寫清楚的,大家
都能明白的代碼;同時,這種代碼也不是能夠很方便移植的,所以還是盡量少寫這樣的代碼。
Q: 聽說,printf函數是帶緩衝的輸出?這個和不帶緩衝的輸出有什麼區別?
A: 正如一個道理,cpu速度很快,外設運行速度很慢,為了避免這種速度差距導致cpu的效率被嚴重降低,所以,對於外設的操作,很多都有緩衝,包括顯示器、鍵盤、硬碟等等。不帶緩衝的也就是如果需要輸出,那麼立即輸出,不會被緩衝系統來處理。
Q: 我怎麼感覺不到是不是緩衝輸出的?
A: 一個很容易感受到緩衝的是在cmd裡面提示輸入的地方。你可以輸入一段資料,然後按斷行符號,系統會進行對應處理。這個地方表示的是緩衝輸入。
對於緩衝輸出,舉個例子:
#include <stdio.h>#include <stdlib.h>int main(){ int i = 1; char *buf = (char *)malloc(512); if(buf) {setbuf(stdout, buf); } printf("hello"); printf("xichen"); return 0;}
這裡,申請了一個512位元組的空間,然後使用setbuf函數將標準輸出的緩衝區設定為buf.
然後向標準輸出輸出2個字串。
在printf("xichen");行打斷點,調試到此行,可以發現控制台什麼也沒輸出,繼續運行依然沒有輸出,沒有輸出的原因就在於它們被緩衝了。
然後,修改代碼,設定緩衝區為空白:
#include <stdio.h>#include <stdlib.h>int main(){ int i = 1; setbuf(stdout, NULL); printf("hello"); printf("xichen"); return 0;}
依然在printf("xichen");行打斷點,調試:
運行到斷點處,此時查看控制台輸出:
可以看出,前面一句代碼的字串已經被輸出了,這就是沒有緩衝的。
結束語
前面寫了這麼多,不知不覺發現printf這個基本輸出的函數確實不簡單;但是瞭解了關於它的代碼的底層資訊,也會對它的理解更深刻,
前面寫的東西不一定只適用於printf, 很多內部實現的實驗都可以從上面的內容找到一些影子,希望這些對大家有協助。
xichen
2012-5-2 16:09:06