C语言结构体里的成员数组和指针
单看这文章的标题,你可能会觉得好像没什么意思。你先别下这个结论,相信这篇文章会对你理解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。 这下你知道了,访问0x4的内存地址,不crash才怪。于是,你一定会有如下的问题:
1)为什么不是 13行if语句出错?f.a被初始化为空了嘛,用空指针访问成员变量为什么不crash?
2)为什么会访问到了0x4的地址?靠,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 + 0x0), t.p 的其实就是 (&t + 0x4)。0x0和0x4这个偏移地址就是成员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的偏移之所以是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指令):
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”,文档中给了一个例子(我改了一下,改成可以运行的了):
#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看一下:
(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声明成一个指针,然后为它再分配一下内存不行么?就像下面一样。
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; }
这不一样清楚吗?而且也没什么怪异难懂的东西。是的,这也是普遍的编程方式,代码是很清晰,也让人很容易理解。即然这样,那为什么要搞一个零长度的数组?有毛意义?!
这个事情出来的原因是——我们想给一个结构体内的数据分配一个连续的内存!这样做的意义有两个好处:
第一个意义是,方便内存释放。如果我们的代码是在一个给别人用的函数中,你在里面做了二次内存分配,并把整个结构体返回给用户。用户调用free可以释放结构体,但是用户并不知道这个结构体内的成员也需要free,所以你不能指望用户来发现这个事。所以,如果我们把结构体的内存以及其成员要的内存一次性分配好了,并返回给用户一个结构体指针,用户做一次free就可以把所有的内存也给释放掉。(读到这里,你一定会觉得C++的封闭中的析构函数会让这事容易和干净很多)
第二个原因是,这样有利于访问速度。连续的内存有益于提高访问速度,也有益于减少内存碎片。(其实,我个人觉得也没多高了,反正你跑不了要用做偏移量的加法来寻址)
我们来看看是怎么个连续的,用gdb的x命令来查看:(我们知道,用struct line {}中的那个char contents[]不占用结构体的内存,所以,struct line就只有一个int成员,4个字节,而我们还要为contents[]分配10个字节长度,所以,一共是14个字节)
(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[]。
如果用指针的话,会变成这个样子:
(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指向的内容。
从这里,我们看到,其中的差别——数组的原地就是内容,而指针的那里保存的是内容的地址。
后记
好了,我的文章到这里就结束了。但是,请允许我再唠叨两句。
1)看过这篇文章,你觉得C复杂吗?我觉得并不简单。某些地方的复杂程度不亚于C++。
2)那些学不好C++的人一定是连C都学不好的人。连C都没学好,你们根本没有资格鄙视C++。
3)当你们在说有坑的时候,你得问一下自己,是真有坑还是自己的学习能力上出了问题。
如果你觉得你的C语言还不错,欢迎你看看《C语言的谜题》还有《谁说C语言很简单?》还有《语言的歧义》以及《深入理解C语言》一文。
(全文完)
(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)
《C语言结构体里的成员数组和指针》的相关评论
耗子哥太敬业啦
我怎么一直都觉得C比C++更复杂呢。。。
这是昨天晚上熬夜写出来的不,太牛了。
理解透了确实是基础问题,使用数组的时候相当于去地址,使用指针的话,指针保存的是地址,等于要去指针的内容。
做应用,不做研究。
VC6 中编译,通过,命令行运行无输出
顶一个。
对于使用0地址访问结构体成员的使用方式之前还是遇到过的:
#define OFFSET(type, f) ((int)&(((type*)0)->f))
还是第一次遇到0长度数组的情况,涨姿势啦
真是厉害
第二条‘提高速度’,使用数组可以提高spactial locality,一次分配比两次分配,在cache的命中率上应该好很多。
顶一个!
这句“t.p 的其实就是 (&t + 0×4)”有点小问题。对于结构体而言,取地址后的偏移是以结构体大小为移动单位的,所以这句其实应该这样写会不会更加准确? t.p 的地址其实就是 ((size_t)&t + 0×4)。呵呵,有点吹毛求疵了。
同感,这个是 cpu cache 的问题,而不是指针的问题。连续访存,可以减少 cache miss ,对需要频繁访存的应用来说,性能会提升得比较明显。
膜拜下耗子哥。
想请问下如果想深入了解C语言的特性,应该去看什么资料呢?
浩哥牛逼,又这么有耐心,真是程序员的好榜样.
数组的原地就是内容,而指针的那里保存的是内容的地址。这个是核心。就这一句话,如果没有上面的分析,也不是好理解,单看上面的内容没有这句话的注解,也不是很容易理解。
$2 = {length = 10, contents = 0x601010 “\n”} 应该是 $2 = {length = 10, contents = 0x601014 “\n”}
我的gdb中就是那样显示的。
关于赋0和偏移可以参考linux内核中的offset_of宏.
原来叫做柔性数组, 一直找不到相关资料. 以前琢磨过http://t.cn/8sxhqv8, 猜测和性能有关系
人家写“C语言的经典的坑”也好,你陈浩写这么一大篇也好,结果都是为了把新手吓跑,高端黑啊 (开玩笑的)
“访问成员数组名其实得到的是数组的相对地址,而访问成员指针其实是相对地址里的内容”
我觉得这句话后半句存在一定的误解。“访问成员指针其实是相对地址里的内容”,这个内容其实还是个地址。
就像经典的在函数参数内传入一级指针,然后在函数体内分配内存一样,函数返回后这个指针并没有返回分配的空间;当传入二级指针后,则可以正确分配内存。
传地址实际上还是pass-by-value,只不过这个value不是一般的value,而是指针里面的地址而已。
一个指针ptr有三个值:其本身的位置&ptr,其指向的地址ptr,其指向地址里的值*ptr。按地址传递实际是把ptr,copy一份到函数的形参中。
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
(gdb) p pt->p 这里少了取地址符号。
没少- –@cloveryume
耗子叔有效率,谢谢啦!
@陈皓
额,你的gcc版本是?我在gcc4.5.1下面显示的地址确是有4个字节的便宜
本来以为这块基本理解了,但是看皓哥的文章还是能获取新的认识.赞!
指针,数组,这玩意是需要好好把玩的
个人觉得这句才是关键:
struct foo f={0};
C的编译器允许将一个结构体指向 0x0 的位置却不警告(假如0改为1,则编译会就会有warning出来).
还有个疑问,将代码修改一下,使用if (f.a->len),代码在if处core了,这个是因为访问了非法地址。
但是如果是if (&(f.a->len)),就在printf处core了,改成if ((int)&(f.a->len)),就直接跳过if,程序正常结束啦。
这个地方有啥玄机?
这个是结构体的初始化赋值方式….
原来第13行的不会crash的原因在于对数组汇编是lea,对指针是mov。受教了,谢谢。
C语言还有好多东西 值得我学习研究。。。
说点题外话,我来介绍C99一个坑吧,叫Variable-length array:http://en.wikipedia.org/wiki/Variable-length_array,它的语义是这样的
可是在维基下面有这么一句话“One problem that may be hidden by a language’s support for VLAs is that of the underlying memory allocation: in environments where there is a clear distinction between a heap and a stack, it may not be clear which, if any, of those will store the VLA”也就是说同一个语义不同编译器有不同的实现,比如GCC是栈分配的,用SIZE_MAX限制大小。而微软用堆分配的,居然隐藏了“库函数”调用:
_malloca:http://msdn.microsoft.com/en-us/library/5471dc8s.aspx
_freea:http://msdn.microsoft.com/en-us/library/k8984a8h.aspx
我很不解为啥C99标准会允许同一个语义存在不一致的实现,而且这种实现还是隐藏的很深,像alloca虽然未纳入标准,起码还是个显示调用,由用户控制逻辑和运行。而_malloca和_freea简直是“库函数”级别,居然允许作为语义实现。令我怀疑这还是“干净的”“可移植性的”“对汇编很薄的胶合层的”C语言吗?感觉有向C++演变的趋势。我个人对C99的部分标准执保留意见,但我的担忧是如果碰上这种代码,这种“未定义”的行为会让程序运行变得琢磨不透,让开发人员平添许多心智负担。
其实就一个新的东西,那就是使用零长度的数组作为占位符,使得可以在后来的初始化中分配连续的内存,方便数组内容的取值和释放一整体的内存。
那就是if 判断地址与内容的区别啊 , 跟博主这篇内容一直在说的相呼应,if (f.a->len) 使用的是 mov 指令,而取地址操作使用的应该是 lea 指令,而你把地址强转为 int 类型后,因为 int 的偏移地址为 0x0 所以 int 的值也是 0, 故 if 条件判断为假 直接忽略 if body 的内容。@aoyaya
看了这个又巩固一些,幸亏之前有看过零长度数组的代码,不然真的会有点纠结
那就是if 判断地址与内容的区别啊 , 跟博主这篇内容一直在说的相呼应,if (f.a->len) 使用的是 mov 指令,意思是要把结构体中的len内容取出来做真假判断,而if (&(f.a->len))取地址操作使用的应该是 lea 指令,即取得 len 在结构体中的位置,而你把地址强转为 int 类型后,因为 int 的偏移地址为 0×0 所以 int 的值也是 0, 故 if 条件判断为假 直接忽略 if body 的内容。
响应非常快,不愧是大牛,得建立高执行力,学习了~
学习了,感谢耗子大神,让我们看到了真相。
背景知识两句话能说清的,不要这么复杂:
1. 结构体成员说明了结构体内部的结构(相对首地址的偏移)。->是一种指针运算(加偏移)
2. 0长的数组可以用于表示一个位置(数组的首地址或偏移)。这里0并不神秘,任何其它常数都有一样的含义,区别只是在计算内存大小,0比较简单。
解释问题只有一句话:如果有int a[3];,a表示&a[0],不表示a[0];对应到题目中,f.a->s表示&(f.a->s[0]),谁还认为会crash?
耗子威武,涨姿势鸟
@jjj
len在结构体中的位置也是0啊
不错 支持一下
直接用指针,多占了一个字的空间。不利于内存拷贝通信。
I kind of getting the feeling that the real reason is hindered by unnecessary using of zero-length array. In fact, the real reason could be better illustrated by using one ordinary array, say `char s[10]`.
耗子叔,“thisline->length = this_length; ”这一句是不是可以拿掉?还是说有特殊意义。
这个其实涉及到汇编了 其实就是个细节 深究的话意义不大
感觉说明的顺序有点问题。直接把最后关于0长度数组的内容放到最前面就清楚了,哪有那么多事……
其实是很简单的事啊。所以说应该先学汇编,再学C,了解C语言是如何简化汇编的,而不是C的某语句是什么意思。
耗子叔叔非常牛叉啊,学到了很多!搞懂了其实上面的代码不是非常简单吗!!!
struct foo f={0};这里f.a指向了内存里面的0地址,这个地址是受保护的,直接访问肯定挂掉啊。
char s[0]在if里面访问f.a->s是访问的地址肯定没问题,在printf里面是访问内容挂掉
如果将char s[0]改成char *s不论是在if还是printf里面都是访问的内容那么在if处就会挂掉
C语言真是不简单,佩服佩服
挑个刺儿,“对于数组 char s[10]来说,数组名 s 和 &s 都是一样的”。s和&s对于汇编来说是一样的,但对C本身来说不完全一样,不是同一个类型,可以用sizeof运算符鉴别。
除此之外,绝对的好文。
说的太好了,又学到一招。