微博截图例如以下。我认为好多人对这段代码的理解还不够深入,所以写下了这篇文章。

读陈浩的《C语言结构体里的成员数组和指针》总结,零长度数组_c语言

为了方便你把代码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。 这下你知道了,訪问0x4的内存地址,不crash才怪。于是。你一定会有例如以下的问题:

1)为什么不是 13行if语句出错?f.a被初始化为空了嘛。用空指针訪问成员变量为什么不crash?

2)为什么会訪问到了0x4的地址?靠,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 + 0x0)t.p 的事实上就是 (&t + 0x4)。0x0和0x4这个偏移地址就是成员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的偏移之所以是0x8而不是0x6,是由于内存对齐了(我在64位系统上)。关于内存对齐,可參看《​​深入理解C语言​​》一文。

好了。如今你知道为什么原题中会訪问到了0x4的地址了吧,由于是相对地址。

相对地址有非常好多处。其能够玩出一些有意思的编程技巧,比方把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   0x04(%rax),   %rdx
  • 对于char*s来说。汇编代码用了mov指令,mov 0x04(%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是一样的(偏移量为0x0??!!)。

可是当我们输出thisline->contents的时候。你又发现contents的地址是被offset了0x4了的,内容也变成了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​​​​*)​​​​malloc​​​​(​​​​sizeof​​​​(​​​​char​​​​) * this_length );​


​thisline->length = this_length;​


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


​return​​​​0;​


​}​



这不一样清晰吗?并且也没什么怪异难懂的东西。是的,这也是普遍的编程方式,代码是非常清晰,也让人非常easy理解。即然这样,那为什么要搞一个零长度的数组?有毛意义?!

这个事情出来的原因是——我们想给一个结构体内的数据分配一个连续的内存!这样做的意义有两个优点:

第一个意义是。方便内存释放。假设我们的代码是在一个给别人用的函数中,你在里面做了二次内存分配,并把整个结构体返回给用户。

用户调用free能够释放结构体,可是用户并不知道这个结构体内的成员也须要free,所以你不能指望用户来发现这个事。

所以。假设我们把结构体的内存以及其成员要的内存一次性分配好了,并返回给用户一个结构体指针,用户做一次free就能够把全部的内存也给释放掉。(读到这里。你一定会认为C++的封闭中的析构函数会让这事easy和干净非常多)

第二个原因是,这样有利于訪问速度

连续的内存故意于提高訪问速度,也故意于降低内存碎片。

(事实上。我个人认为也没多高了,反正你跑不了要用做偏移量的加法来寻址)

我们来看看是怎么个连续的。用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个长度。他的值是0x20 0x10 0x60 也就是0x601020。
  • 第三行和第四行是char* contents指向的内容。

从这里,我们看到,当中的区别——数组的原地就是内容,而指针的那里保存的是内容的地址

一、printf 的參数

首先对14行的 printf(f.a->s); 使用方法感到非常陌生。这个要输出的是什么?printf 还能够直接输出一个变量、前面没有不论什么双引號(输出格式说明)吗?类似地。我们试试输出成员变量 len。

printf(f.a->len);

这样直接报错:invalid conversion from `int' to `const char*' 

查看 printf 的函数声明。例如以下:

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

第一个是const char* 型。后面是可变參数。注意,第一个是const char*,也就是字符指针!所以直接printf(f.a->s)当然能够。由于f.a->s就是字符指针!

而我们寻常所写的printf("..."); 当中的双引號字符串就是const char*类型!


这样。再写一个简单的測试程序:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char** argv) {
char *s="abc";
printf(s);
system("pause");
return 0;
}

能够看到,能够正常输出abc。

就是输出字符指针所指向的内容。

而我们知道。对于一个指向struct的null指针来说,取得其成员变量的地址是能够的,而取其成员变量则会出问题(详细原因见上面陈浩原文解释)。这个类似于C++中一个指向class的null指针,能够通过该指针调用其成员函数,而通过该指针获得成员变量则会出问题。

二、零长度数组

见上文作者总结。