当前位置: 首页 > 科技观察

C语言结构体中的成员数组和指针

时间:2023-03-14 15:51:29 科技观察

光看这篇文章的标题,你可能会觉得没什么意思。不要先下这个结论,相信这篇文章会帮助你理解C语言。本文背景为微博。看到@Laruence同学问了一个关于C语言的问题,微博链接。微博截图如下。我觉得很多人对这段代码理解不够深入,所以写了这篇文章。为了方便大家复制代码编译调试,我把代码列在下面:#includestructstr{intlen;字符[0];};structfoo{structstr*a;};intmain(intargc,char**argv){structfoof={0};如果(f.a->s){printf(f.a->s);}返回0;如果你编译上面的代码,它会在VC++和GCC下的第14行的printf处崩溃并丢弃你的程序。@Laruence说这是经典的坑,我怎么觉得这是经典的坑呢?有了上面的代码,你肯定会问,为什么if语句不判断f.a呢?但是f.a里面的数组?编写这段代码的人脑子里在想什么?或者使用这样的代码来玩票?不管怎么说,我个人认为这主要是对C语言的理解不够。如果这是一个坑,那就全是坑。接下来,您调试,或者您将第14行的printf语句更改为:printf("%x\n",f.a->s);你会看到程序没有崩溃。程序输出:4.现在你知道了,访问0×4的内存地址,不死机才怪。因此,你一定会有以下疑问:1)为什么第13行的if语句会出错?f.a初始化为空,为什么用空指针访问成员变量不崩溃?2)为什么访问0×4地址?妈的,4怎么出来的?3)代码第4行,chars[0]是什么?零长度数组?你为什么要这样玩?下面我们从基础开始,一点点解释C语言中的这些奇葩问题。结构体中的成员首先我们要知道,所谓的变量其实就是一个内存地址的抽象名称。在静态编译的程序中,所有的变量名都会在编译时转换为内存地址。机器不知道我们取的名字,只知道地址。于是就有了——栈内存区、堆内存区、静态内存区、常量内存区,我们代码中的所有变量都会被编译器预先放在这些内存区中。有了上面的基础,我们再来看看结构体中成员的地址是什么?让我们先简化代码:structtest{inti;炭*p;};上述代码中,test结构体中的i和p指针是C编译器中保存的相对地址——即它们的地址是相对于structtest实例的。如果我们有这样一段代码:structtestt;我们用gdb跟进,对于实例t,我们可以看到:实例中的#tp是一个野指针(gdb)pt$1={i=0,c=0'\000',d=0'\000',p=0x4003e0"1\355I\211\..."}#输出t的地址(gdb)p&t$2=(structtest*)0x7fffffffe5f0#输出(t.i)(gdb)p&(t.i)$3的地址=(char**)0x7fffffffe5f0#output(t.p)address(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的地址。因此,您知道无论结构的实例是什么-访问其成员实际上是添加成员的偏移量。让我们做一个实验:structtest{inti;短路;炭*p;};intmain(){structtest*pt=NULL;返回0;}编译完成后,我们用gdb调试一下。初始化pt后,看下面Debugging:(可以看到即使pt为NULL,访问其成员时,实际上访问的是相对于pt的内部地址)(gdb)ppt$1=(structtest*)0x0(gdb)ppt->iCannotaccessmemoryataddress0x0(gdb)ppt->cCannotaccessmemoryataddress0x4(gdb)ppt->pCannotaccessmemoryataddress0x8注意:上面的pt->p偏移量之所以是0×8而不是0×6是因为内存是对齐的(我在64位系统上)。内存对齐见文章《深入理解C语言》。好了,现在你知道原题中为什么要访问0×4地址了吧,因为它是相对地址。相对地址有很多,可以发挥一些有趣的编程技巧,比如让C有面向对象的感觉。可以参考我11年前的文章《用C写面向对像的程序》(使用指针类型强制转换的危险)。玩法——C++相比C++,C++编译器帮你管理继承表和虚函数表,语义更清晰)指针和数组的区别有了上面的基础,你把structstr结构放在源码里了字符[0];将其更改为char*s;试试看,你会发现在if条件的第13行,程序直接因为Cannotaccessmemory挂掉了。为什么声明为chars[0],程序会在14行挂掉,声明为char*s时,程序会在13行挂掉?那么char*s和chars[0]有什么区别呢?在解释这个之前,有必要看一下汇编代码。用GDB查看后发现:对于chars[0],汇编代码使用了lea指令,lea0×04(%rax),%rdxforchar*s比如汇编代码使用了movinstruction,mov0×04(%rax),%rdxlea全名加载有效地址,它把地址放进去,mov把内容放到地址里。所以,它崩溃了。从这里我们可以看出,访问成员数组名实际上获取的是数组的相对地址,访问成员指针实际获取的是相对地址中的内容(这与访问其他非指针或数组变量是一样的)中也就是说,对于数组chars[10],数组名s和&s是一样的(不信可以自己写个程序)。在我们的例子中,也就是说,它们都代表偏移地址。这样,如果我们访问指针的地址(或者成员变量的地址),那么程序就不会挂掉。就像下面的代码一样,完全可以运行而不会crash(编译的时候会看到用到了lea指令):structtest{inti;短路;炭*p;字符[10];};intmain(){structtest*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);返回0;}看到这里,是不是觉得这也算是一个陷阱?不要把发生的一切都归咎于语言,想想问题是否出在你身上。#p#关于零长度数组首先,我们需要知道ISOC和C++规范中不允许使用零长度数组。这就是为什么在VC++2012下编译你会得到一个警告:“arningC4200:non-standardextensionused:zero-sizedarrayinstructure/union”。那为什么gcc连一个警告都没有就可以通过呢?那是因为gcc提前支持了C99的玩法,所以“零长数组”的玩法是合法的。GCC关于这件事的文档在这里:《ArraysofLengthZero》,文档中给出了一个例子(我改了改成run):#include#includestructline{长度;charcontents[0];//C99的玩法是:charcontents[];没有指定数组长度};intmain(){intthis_length=10;structline*thisline=(structline*)malloc(sizeof(structline)+this_length);thisline->length=this_length;memset(thisline->contents,'a',this_length);返回0;}看到这里,是不是觉得这是个陷阱?不要把发生的一切都归咎于语言,想想问题是否出在你身上。关于零长度数组,首先我们要知道ISOC和C++规范中是不允许零长度数组的。这就是为什么在VC++2012下编译你会得到一个警告:“arningC4200:non-standardextensionused:zero-sizedarrayinstructure/union”。那为什么gcc连一个警告都没有就可以通过呢?那是因为gcc提前支持了C99的玩法,所以“零长数组”的玩法是合法的。GCC关于这件事的文档在这里:《ArraysofLengthZero》,文档中给出了一个例子(我改了改成run):#include#includestructline{长度;charcontents[0];//C99的玩法是:charcontents[];没有指定数组长度};intmain(){intthis_length=10;structline*thisline=(structline*)malloc(sizeof(structline)+this_length);thisline->length=this_length;memset(thisline->contents,'a',this_length);返回0;上面代码的意思是:我要分配一个变长的数组,所以我有一个结构体,它有两个成员,一个是length,代表数组的长度,一个是contents,就是contents的内容代码数组。下面代码中的this_length(长度为10)代表我要分配的数据长度。(这看起来像C++类吗?)这个游戏英文名:FlexibleArray,中文译名:FlexibleArray。让我们用gdb看看:(gdb)pthisline$1=(structline*)0x601010(gdb)p*thisline$2={length=10,contents=0x601010"\n"}(gdb)pthisline->contents$3=0x601014"aaaaaaaaaa"我们可以看到:在输出*thisline时,发现成员变量内容的地址与thisline相同(偏移量为0×0??!!)。但是当我们输出thisline->contents的时候,你发现contents的地址偏移了0×4,content变成了10'a'。(我认为这是一个GDB的bug,VC++调试器可以很好的显示出来)我们继续,如果你有一个零长度的数组比如sizeof(char[0])或者sizeof(int[0]),你就会发现sizeof返回0,说明零长度数组存在于结构体??中,但不占用结构体的大小。你可以简单的理解为一个没有内容的占位符,直到我们为结构体分配内存后,占位符就变成了一个有长度的数组。看到这里,你会说,你为什么要这样做?将内容声明为指针然后为其分配内存不是可以吗?就像下面一样。结构线{intlength;字符*内容;};intmain(){intthis_length=10;structline*thisline=(structline*)malloc(sizeof(structline));thisline->contents=(char*)malloc(sizeof(char)*this_length);thisline->length=this_length;memset(thisline->contents,'a',this_length);返回0;这不是一样清楚吗?并没有什么奇怪的。是的,这也是一种常见的编程方式,代码非常清晰易懂。既然如此,为什么要制作一个零长度数组呢?合理?!这件事的原因是我们要给一个结构体中的数据分配一块连续的内存!这样的意思有两个好处:第一个意思是方便内存释放。如果我们的代码在其他人的函数中,您在其中进行二次内存分配并将整个结构返回给用户。用户可以通过调用free来释放结构,但是用户并不知道这个结构的成员也需要free,所以你不能指望用户发现这一点。因此,如果我们一次性分配结构体的内存及其成员所需要的内存,并返回一个指向该结构体的指针给用户,用户只要做一次free就可以释放所有的内存。(读到这里,你一定会想到C++闭包中的析构函数会让这个变得简单和干净很多。)第二个原因是这样有利于访问速度。连续内存有利于提高访问速度和减少内存碎片。(其实我个人觉得不算太高,反正跑不掉就需要用offsets的加法来解决)看看它是怎么连续的。使用gdb的x命令查看:(我们知道,使用structline{}中的charcontents[]不占用结构体的内存,所以structline只有一个int成员,4个字节,我们要contents[]分配10个字节,so,一共14个字节)(gdb)x/14bthisline0x601010:10000979797970x601018:979797979797从上面的内存布局可以看出,前4个字节是int长度,后10个字节是字符内容[]。如果使用指针的话,会是这样:(gdb)x/16bthisline0x601010:100000000x601018:32169600000(gdb)x/10bthis->contents0x601020:97979797979797970x601028:9797上面一共输出了四行内存,其中,第一行的前四个字节是int长度,第一行的后四个字节是对齐的。第二行是char*内容,64位系统指针有8个长度,其值为0×200×100×60,即0×601020。第三四行是char*contents指向的内容。从这里,我们可以看出区别——数组的in-place是内容,而内容的地址存储在指针中。后记好了,我的文章到此结束。但请允许我再说几句。1)看完这篇文章,你觉得C语言复杂吗?我不认为这很简单。有些地方和C++一样复杂。2)学不好C++的,一定是连C都学不好的。即使你没有学好C,也没有资格轻视C++。3)当你在说陷阱的时候,你要问问自己是真的有陷阱还是你的学习能力有问题。如果你觉得你的C语言还不错的话,欢迎你去看看文章《C语言的谜题》和《谁说C语言很简单?》和《语言的歧义》和《深入理解C语言》。原文链接:http://coolshell.cn/articles/11377.html