單看這文章的標題,你可能會覺得好像沒什麼意思。你先別下這個結論,相信這篇文章會對你理解C語言有幫助。這篇文章產生的背景是在微博上,看到@Laruence同學出了一個關於C語言的題,微博鏈接。微博截圖如下。我覺得好多人對這段代碼的理解還不夠深入,所以寫下了這篇文章。
為了方便你把代碼copy過去編譯和調試,我把代碼列在下面:
- #include <stdio.h>
- struct str{
- int len;
- char s[0];
- };
- struct foo {
- struct str *a;
- };
- int main(int argc, char** argv) {
- struct foo f={0};
- if (f.a->s) {
- printf( f.a->s);
- }
- return 0;
- }
你編譯一下上面的代碼,在VC++和GCC下都會在14行的printf處crash掉你的程序。@Laruence 說這個是個經典的坑,我覺得這怎麼會是經典的坑呢?上面這代碼,你一定會問,為什麼if語句判斷的不是f.a?而是f.a裡面的數組?寫這樣代碼的人腦子裡在想什麼?還是用這樣的代碼來玩票?不管怎麼樣,我個人覺得這主要還是對C語言理解不深,如果這算坑的話,那麼全都是坑。
接下來,你調試一下,或是你把14行的printf語句改成:
- printf("%x\n", f.a->s);
你會看到程序不crash了。程序輸出:4。 這下你知道了,訪問0×4的內存地址,不crash才怪。於是,你一定會有如下的問題:
1)為什麼不是 13行if語句出錯?f.a被初始化為空了嘛,用空指針訪問成員變量為什麼不crash?
2)為什麼會訪問到了0×4的地址?靠,4是怎麼出來的?
3)代碼中的第4行,char s[0] 是個什麼東西?零長度的數組?為什麼要這樣玩?
讓我們從基礎開始一點一點地來解釋C語言中這些詭異的問題。
首先,我們需要知道——所謂變量,其實是內存地址的一個抽像名字罷了。在靜態編譯的程序中,所有的變量名都會在編譯時被轉成內存地址。機器是不知道我們取的名字的,只知道地址。
所以有了——棧內存區,堆內存區,靜態內存區,常量內存區,我們代碼中的所有變量都會被編譯器預先放到這些內存區中。
有了上面這個基礎,我們來看一下結構體中的成員的地址是什麼?我們先簡單化一下代碼:
- struct test{
- int i;
- char *p;
- };
上面代碼中,test結構中i和p指針,在C的編譯器中保存的是相對地址——也就是說,他們的地址是相對於struct test的實例的。如果我們有這樣的代碼:
- struct test t;
我們用gdb跟進去,對於實例t,我們可以看到:
- # t實例中的p就是一個野指針
- (gdb) p t
- $1 = {i = 0, c = 0 '\000', d = 0 '\000', p = 0x4003e0 "1\355I\211\..."}
- # 輸出t的地址
- (gdb) p &t
- $2 = (struct test *) 0x7fffffffe5f0
- #輸出(t.i)的地址
- (gdb) p &(t.i)
- $3 = (char **) 0x7fffffffe5f0
- #輸出(t.p)的地址
- (gdb) p &(t.p)
- $4 = (char **) 0x7fffffffe5f4
我們可以看到,t.i的地址和t的地址是一樣的,t.p的址址相對於t的地址多了個4。說白了,t.i 其實就是(&t + 0×0), t.p 的其實就是 (&t + 0×4)。0×0和0×4這個偏移地址就是成員i和p在編譯時就被編譯器給hard code了的地址。於是,你就知道,不管結構體的實例是什麼——訪問其成員其實就是加成員的偏移量。
下面我們來做個實驗:
- struct test{
- int i;
- short c;
- char *p;
- };
- int main(){
- struct test *pt=NULL;
- return 0;
- }
編譯後,我們用gdb調試一下,當初始化pt後,我們看看如下的調試:我們可以看到就算是pt為NULL,訪問其中的成員時,其實就是在訪問相對於pt的內址)
- (gdb) p pt
- $1 = (struct test *) 0x0
- (gdb) p pt->i
- Cannot access memory at address 0x0
- (gdb) p pt->c
- Cannot access memory at address 0x4
- (gdb) p pt->p
- Cannot access memory at address 0x8
注意:上面的pt->p的偏移之所以是0×8而不是0×6,是因為內存對齊了我在64位系統上)。關於內存對齊,可參看《深入理解C語言》一文。
好了,現在你知道為什麼原題中會訪問到了0×4的地址了吧,因為是相對地址。
相對地址有很好多處,其可以玩出一些有意思的編程技巧,比如把C搞出面向對象式的感覺來,你可以參看我正好11年前的文章《用C寫面向對像的程序》用指針類型強轉的危險玩法——相對於C++來說,C++編譯器幫你管了繼承和虛函數表,語義也清楚了很多)
有了上面的基礎後,你把源代碼中的struct str結構體中的char s[0];改成char *s;試試看,你會發現,在13行if條件的時候,程序因為Cannot access memory就直接掛掉了。為什麼聲明成char s[0],程序會在14行掛掉,而聲明成char *s,程序會在13行掛掉呢?那麼char *s 和 char s[0]有什麼差別呢?
在說明這個事之前,有必要看一下匯編代碼,用GDB查看後發現:
lea全稱load effective address,是把地址放進去,而mov則是把地址裡的內容放進去。所以,就crash了。
從這裡,我們可以看到,訪問成員數組名其實得到的是數組的相對地址,而訪問成員指針其實是相對地址裡的內容這和訪問其它非指針或數組的變量是一樣的)
換句話說,對於數組 char s[10]來說,數組名 s 和 &s 都是一樣的不信你可以自己寫個程序試試)。在我們這個例子中,也就是說,都表示了偏移後的地址。這樣,如果我們訪問 指針的地址或是成員變量的地址),那麼也就不會讓程序掛掉了。
正如下面的代碼,可以運行一點也不會crash掉你匯編一下你會看到用的都是lea指令):
- struct test{
- int i;
- short c;
- char *p;
- char s[10];
- };
- int main(){
- struct test *pt=NULL;
- printf("&s = %x\n", pt->s); //等價於 printf("%x\n", &(pt->s) );
- printf("&i = %x\n", &pt->i); //因為操作符優先級,我沒有寫成&(pt->i)
- printf("&c = %x\n", &pt->c);
- printf("&p = %x\n", &pt->p);
- return 0;
- }
看到這裡,你覺得這能算坑嗎?不要出什麼事都去怪語言,想想是不是問題出在自己身上。