C语⾔结构体⾥的成员数组和指针
单看这⽂章的标题,你可能会觉得好像没什么意思。你先别下这个结论,相信这篇⽂章会对你理解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);
}
return0;
}
你编译⼀下上⾯的代码,在VC++和GCC下都会在14⾏的printf处crash掉你的程序。说这个是个经典的坑,我觉得这怎么会是经典的坑呢?上⾯这代码,你⼀定会问,为什么if语句判断的不是f.a?⽽是f.a⾥⾯的数组?写这样代码的⼈脑⼦⾥在想什么?还是⽤这样的代码来玩票?不管怎么样,看过原微博的回复,我个⼈觉得⼤家主要还是对C语⾔理解不深,如果这算坑的话,那么全都是坑。
接下来,你调试⼀下,或是你把14⾏的printf语句改成:
1printf("%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 4struct test{ int i;
char*p; };
上⾯代码中,test结构中i和p指针,在C的编译器中保存的是相对地址——也就是说,他们的地址是相对于struct test的实例的。如果我们有这样的代码:
1struct test t;
我们⽤gdb跟进去,对于实例t,我们可以看到:
1
2 3 4 5 6 7# 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
7 8 9 10 11 12 13 14 15$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 10struct test{
int i;
short c;
char*p;
};
int main(){
struct test *pt=NULL; return0;
}
编译后,我们⽤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位系统上)。关于内存对齐,可参看《》⼀⽂。
好了,现在你知道为什么原题中会访问到了0x4的地址了吧,因为是相对地址。
相对地址有很好多处,其可以玩出⼀些有意思的编程技巧,⽐如把C搞出⾯向对象式的感觉来,你可以参看我正好11年前的⽂章《》(⽤指针类型强转的危险玩法——相对于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 都是⼀样的(不信你可以⾃⼰写个程序试试)。在我们这个例⼦中,也就是说,都表⽰了偏移后的地址。这样,如果我们访问指针的地址(或是成员变量的地址),那么也就不会让程序挂掉了。
春节为什么要放鞭炮>qos正如下⾯的代码,可以运⾏⼀点也不会crash掉(你汇编⼀下你会看到⽤的都是lea指令):
1 2 3 4 5struct test{ int i;
short c; char*p; char s[10];
海南特产有哪些6 7 8 9 10 11 12 13 14 15};
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);
return0;
}
看到这⾥,你觉得这能算坑吗?不要出什么事都去怪语⾔,⼤家要想想是不是问题出在⾃⼰⾝上。
关于零长度的数组
⾸先,我们要知道,0长度的数组在ISO C和C++的规格说明书中是不允许的。这也就是为什么在VC++2012下编译你会得到⼀个警
告:“arning C4200: 使⽤了⾮标准扩展 : 结构/联合中的零⼤⼩数组”。
那么为什么gcc可以通过⽽连⼀个警告都没有?那是因为gcc 为了预先⽀持C99的这种玩法,所以,让“零长度数组”这种玩法合法了。关于GCC对于这个事的⽂档在这⾥:“”,⽂档中给了⼀个例⼦(我改了⼀下,改成可以运⾏的了):
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;
湿润的近义词memt(thisline->contents, 'a', this_length);
return0;
}
创意空间上⾯这段代码的意思是:我想分配⼀个不定长的数组,于是我有⼀个结构体,其中有两个成员,⼀个是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的地址是被offt了0x4了的,内容也变成了10个‘a’。(我觉得这是⼀个GDB的bug,VC++的调试器就能很好的显⽰)
我们继续,如果你sizeof(char[0])或是 sizeof(int[0]) 之类的零长度数组,你会发现sizeof返回了0,这就是说,零长度的数组是存在于结构体内的,但是不占结构体的size。你可以简单的理解为⼀个没有内容的占位标识,直到我们给结构体分配了内存,这个占位标识才变成了⼀个有长度的数组。
看到这⾥,你会说,为什么要这样搞啊,把contents声明成⼀个指针,然后为它再分配⼀下内存不⾏么?就像下⾯⼀样。
1 2 3 4 5 6 7 8struct line {
int length;
char*contents; };
int main(){
int this_length=10;
9 10 11 12 13 struct line *thisline = (struct line *)malloc(sizeof(struct line)); thisline->contents = (char*) malloc( sizeof(char) * this_length ); thisline->length = this_length;
memt(thisline->contents, 'a', this_length);
return0;
}
这不⼀样清楚吗?⽽且也没什么怪异难懂的东西。是的,这也是普遍的编程⽅式,代码是很清晰,也让⼈很容易理解。即然这样,那为什么要搞⼀个零长度的数组?有⽑意义?!
这个事情出来的原因是——我们想给⼀个结构体内的数据分配⼀个连续的内存!这样做的意义有两个好处:
第⼀个意义是,⽅便内存释放。如果我们的代码是在⼀个给别⼈⽤的函数中,你在⾥⾯做了⼆次内存分配,并把整个结构体返回给⽤户。⽤户调⽤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个长度,他的值是0x20 0x10 0x60 也就是0x601020。
第三⾏和第四⾏是char* contents指向的内容。
从这⾥,我们看到,其中的差别——数组的原地就是内容,⽽指针的那⾥保存的是内容的地址。
后记
好了,我的⽂章到这⾥就结束了。但是,请允许我再唠叨两句。
1)看过这篇⽂章,你觉得C复杂吗?我觉得并不简单。某些地⽅的复杂程度不亚于C++。
2)那些学不好C++的⼈⼀定是连C都学不好的⼈。连C都没学好,你们根本没有资格鄙视C++。
3)当你们在说有坑的时候,你得问⼀下⾃⼰,是真有坑还是⾃⼰的学习能⼒上出了问题。
如果你觉得你的C语⾔还不错,欢迎你看看《》还有《》还有《》以及《》⼀⽂。