1. 程式人生 > >C語言結構體裡的成員陣列(長度為0)和指標---from coolshell

C語言結構體裡的成員陣列(長度為0)和指標---from coolshell

單看這文章的標題,你可能會覺得好像沒什麼意思。你先別下這個結論,相信這篇文章會對你理解C語言有幫助。這篇文章產生的背景是在微博上,看到@Laruence同學出了一個關於C語言的題,微博連結。微博截圖如下。我覺得好多人對這段程式碼的理解還不夠深入,所以寫下了這篇文章。

zero_array

為了方便你把程式碼copy過去編譯和除錯,我把程式碼列在下面:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

#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語句改成:

1

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語言中這些詭異的問題。

結構體中的成員

首先,我們需要知道——所謂變數,其實是記憶體地址的一個抽像名字罷了。在靜態編譯的程式中,所有的變數名都會在編譯時被轉成記憶體地址。機器是不知道我們取的名字的,只知道地址。

所以有了——棧記憶體區,堆記憶體區,靜態記憶體區,常量記憶體區,我們程式碼中的所有變數都會被編譯器預先放到這些記憶體區中。

有了上面這個基礎,我們來看一下結構體中的成員的地址是什麼?我們先簡單化一下程式碼:

1

2

3

4

struct test{

int i;

char *p;

};

上面程式碼中,test結構中i和p指標,在C的編譯器中儲存的是相對地址——也就是說,他們的地址是相對於struct test的例項的。如果我們有這樣的程式碼:

1

struct test t;

我們用gdb跟進去,對於例項t,我們可以看到:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

# 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了的地址。於是,你就知道,不管結構體的例項是什麼——訪問其成員其實就是加成員的偏移量

下面我們來做個實驗:

1

2

3

4

5

6

7

8

9

10

struct test{

int i;

short c;

char *p;

};

int main(){

struct test *pt=NULL;

return 0;

}

編譯後,我們用gdb除錯一下,當初始化pt後,我們看看如下的除錯:(我們可以看到就算是pt為NULL,訪問其中的成員時,其實就是在訪問相對於pt的內址)

1

2

3

4

5

6

7

8

(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檢視後發現:

  • 對於char s[0]來說,彙編程式碼用了lea指令,lea   0×04(%rax),   %rdx
  • 對於char*s來說,彙編程式碼用了mov指令,mov 0×04(%rax),   %rdx

lea全稱load effective address,是把地址放進去,而mov則是把地址裡的內容放進去。所以,就crash了。

從這裡,我們可以看到,訪問成員陣列名其實得到的是陣列的相對地址,而訪問成員指標其實是相對地址裡的內容(這和訪問其它非指標或陣列的變數是一樣的)

換句話說,對於陣列 char s[10]來說,陣列名 s 和 &s 都是一樣的(不信你可以自己寫個程式試試)。在我們這個例子中,也就是說,都表示了偏移後的地址。這樣,如果我們訪問 指標的地址(或是成員變數的地址),那麼也就不會讓程式掛掉了。

正如下面的程式碼,可以執行一點也不會crash掉(你彙編一下你會看到用的都是lea指令):

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

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;

}

看到這裡,你覺得這能算坑嗎?不要出什麼事都去怪語言,想想是不是問題出在自己身上。

關於零長度的陣列

首先,我們要知道,0長度的陣列在ISO C和C++的規格說明書中是不允許的。這也就是為什麼在VC++2012下編譯你會得到一個警告:“arning C4200: 使用了非標準擴充套件 : 結構/聯合中的零大小陣列”。

那麼為什麼gcc可以通過而連一個警告都沒有?那是因為gcc 為了預先支援C99的這種玩法,所以,讓“零長度陣列”這種玩法合法了。關於GCC對於這個事的文件在這裡:“Arrays of Length Zero”,文件中給了一個例子(我改了一下,改成可以執行的了):

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

#include <stdlib.h>

#include <string.h>

struct line {

int length;

char contents[0]; // C99的玩法是:char contents[]; 沒有指定陣列長度

};

int main(){

int this_length=10;

struct line *thisline = (struct line *)

malloc (sizeof (struct line) + this_length);

thisline->length = this_length;

memset(thisline->contents, 'a', this_length);

return 0;

}

上面這段程式碼的意思是:我想分配一個不定長的陣列,於是我有一個結構體,其中有兩個成員,一個是length,代表陣列的長度,一個是contents,程式碼陣列的內容。後面程式碼裡的 this_length(長度是10)代表是我想分配的資料的長度。(這看上去是不是像一個C++的類?)這種玩法英文叫:Flexible Array,中文翻譯叫:柔性陣列。

我們來用gdb看一下:

1

2

3

4

5

6

7

8

(gdb) p thisline

$1 = (struct line *) 0x601010

(gdb) p *thisline

$2 = {length = 10, contents = 0x601010 "\n"}

(gdb) p thisline->contents

$3 = 0x601014 "aaaaaaaaaa"

我們可以看到:在輸出*thisline時,我們發現其中的成員變數contents的地址居然和thisline是一樣的(偏移量為0×0??!!)。但是當我們輸出thisline->contents的時候,你又發現contents的地址是被offset了0×4了的,內容也變成了10個‘a’。(我覺得這是一個GDB的bug,VC++的偵錯程式就能很好的顯示)

我們繼續,如果你sizeof(char[0])或是 sizeof(int[0]) 之類的零長度陣列,你會發現sizeof返回了0,這就是說,零長度的陣列是存在於結構體內的,但是不佔結構體的size。你可以簡單的理解為一個沒有內容的佔位標識,直到我們給結構體分配了記憶體,這個佔位標識才變成了一個有長度的陣列。

看到這裡,你會說,為什麼要這樣搞啊,把contents宣告成一個指標,然後為它再分配一下記憶體不行麼?就像下面一樣。

1

2

3

4

5

6

7

8

9

10

11

12

13

struct line {

int length;

char *contents;

};

int main(){

int this_length=10;

struct line *thisline = (struct line *)malloc (sizeof (struct line));

thisline->contents = (char*) mallocsizeof(char) * this_length );

thisline->length = this_length;

memset(thisline->contents, 'a', this_length);

return 0;

}

這不一樣清楚嗎?而且也沒什麼怪異難懂的東西。是的,這也是普遍的程式設計方式,程式碼是很清晰,也讓人很容易理解。即然這樣,那為什麼要搞一個零長度的陣列?有毛意義?!

這個事情出來的原因是——我們想給一個結構體內的資料分配一個連續的記憶體!這樣做的意義有兩個好處:

第一個意義是,方便記憶體釋放。如果我們的程式碼是在一個給別人用的函式中,你在裡面做了二次記憶體分配,並把整個結構體返回給使用者。使用者呼叫free可以釋放結構體,但是使用者並不知道這個結構體內的成員也需要free,所以你不能指望使用者來發現這個事。所以,如果我們把結構體的記憶體以及其成員要的記憶體一次性分配好了,並返回給使用者一個結構體指標,使用者做一次free就可以把所有的記憶體也給釋放掉。(讀到這裡,你一定會覺得C++的封閉中的解構函式會讓這事容易和乾淨很多)

第二個原因是,這樣有利於訪問速度。連續的記憶體有益於提高訪問速度,也有益於減少記憶體碎片。(其實,我個人覺得也沒多高了,反正你跑不了要用做偏移量的加法來定址)

我們來看看是怎麼個連續的,用gdb的x命令來檢視:(我們知道,用struct line {}中的那個char contents[]不佔用結構體的記憶體,所以,struct line就只有一個int成員,4個位元組,而我們還要為contents[]分配10個位元組長度,所以,一共是14個位元組)

1

2

3

(gdb) x /14b thisline

0x601010:       10      0       0       0       97      97      97      97

0x601018:       97      97      97      97      97      97

從上面的記憶體佈局我們可以看到,前4個位元組是 int length,後10個位元組就是char contents[]。

如果用指標的話,會變成這個樣子:

1

2

3

4

5

6

(gdb) x /16b thisline

0x601010:       1       0       0       0       0       0       0       0

0x601018:       32      16      96      0       0       0       0       0

(gdb) x /10b this->contents

0x601020:       97      97      97      97      97      97      97      97

0x601028:       97      97

上面一共輸出了四行記憶體,其中,

  • 第一行前四個位元組是 int length,第一行的後四個位元組是對齊。
  • 第二行是char* contents,64位系統指標8個長度,他的值是0×20 0×10 0×60 也就是0×601020。
  • 第三行和第四行是char* contents指向的內容。

從這裡,我們看到,其中的差別——陣列的原地就是內容,而指標的那裡儲存的是內容的地址

後記

好了,我的文章到這裡就結束了。但是,請允許我再嘮叨兩句。

1)看過這篇文章,你覺得C複雜嗎?我覺得並不簡單。某些地方的複雜程度不亞於C++。

2)那些學不好C++的人一定是連C都學不好的人。連C都沒學好,你們根本沒有資格鄙視C++。

3)當你們在說有坑的時候,你得問一下自己,是真有坑還是自己的學習能力上出了問題。

如果你覺得你的C語言還不錯,歡迎你看看《C語言的謎題》還有《誰說C語言很簡單?》還有《語言的歧義》以及《深入理解C語言》一文。

(全文完)

(轉載本站文章請註明作者和出處 酷 殼 – CoolShell.cn ,請勿用於任何商業用途)

——===  ===——