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

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

单看这文章的标题,你可能会觉得好像没什么意思。你先别下这个结论,相信这篇文章会对你理解C语言有帮助。这篇文章产生的背景是在微博上,看到@Laruence同学出了一个关于C语言的题,微博链接。微博截图如下。我觉得好多人对这段代码的理解还不够深入,所以写下了这篇文章。

zero_array

为了方便你把代码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 ,请勿用于任何商业用途)

好烂啊有点差凑合看看还不错很精彩 (111 人打了分,平均分: 4.63 )
Loading...

C语言结构体里的成员数组和指针》的相关评论

  1. char s[0],或者 unsigned char s[0],一个常用的地方是在协议解释的时候,定义某种协议报文格式的头部,而不想要payload,一般定义如下, typedef struct xx{
    char xx,
    UINT8 xx,
    unsigned char payload[0],
    } XX;
    这个只是一个头部,而没有payload。

  2. Fangzhen :
    顶一个。
    对于使用0地址访问结构体成员的使用方式之前还是遇到过的:
    #define OFFSET(type, f) ((int)&(((type*)0)->f))
    还是第一次遇到0长度数组的情况,涨姿势啦

    协议解释数据包头部的时候很常见。

  3. Albert :
    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]`.

    It’s a rountine usage for parsing PDU header, for this case just to give an example, maybe not.

    Fangzhen :
    顶一个。
    对于使用0地址访问结构体成员的使用方式之前还是遇到过的:
    #define OFFSET(type, f) ((int)&(((type*)0)->f))
    还是第一次遇到0长度数组的情况,涨姿势啦

    协议解释数据包头部的时候很常见。

  4. 还是没说清楚为什么printf(f.a->s)会打印地址内容 ,printf(“%x\n”,f.a->s)就直接打印地址了,是printf函数规定的?

  5. zero length array使用起来有很大的局限性. GCC只允许顶层struct中的array初始化长度. 对于原文中的成员是struct的情况, 没办法初始化成员struct中的array的内容. 这种情况下还是存在free的需求.

    我会先为里层struct做malloc, 再定义顶层struct. 不知道有没有其他办法?

    struct str{
        int len;
        char s[0];
    };
    
    struct foo {
        struct str *a;
    };
    
    int main(int argc, char** argv) {
        int i;
        
        struct str * s = (struct str *)malloc(sizeof(struct str) + LEN);
        struct foo f = {s};
        struct foo f2;
        f2.a = s;
        printf("sizeof struct foo: %d\n", sizeof(struct foo));
        printf("sizeof struct str: %d\n", sizeof(struct str));
    
        printf("ptr of f: %x\n", (unsigned int)&amp;f);
        printf("ptr of f2: %x\n", (unsigned int)&amp;f2);
        printf("address of f2 %x\n", *(unsigned int *)&amp;f2);
        printf("address of f2.a %x\n", (unsigned int)f2.a);
        printf("address of f2.a-&gt;len %x\n", (unsigned int)&amp;(f2.a-&gt;len));
    
        f.a-&gt;len = LEN;
        for (i = 0; i s[i] = 1;
        }
        printf("value of f2.a-&gt;len %d\n", f2.a-&gt;len);
    
        free(s);
        return 0;
    }
  6. 【好些人把晦涩的,复杂的,难以理解的,不合常理的叫『坑』,这无可厚非。不过我看到很多人其实是用『坑』当自己能力不足的借口。我对『坑』的定义是:你熟知了并搞懂了所有的细节,还拥有10年以上的实践经验,熟知每一种错误,但TMD还会犯已犯过的错误,这就是『坑』。C/C++的内存问题就属于这个范畴。】
    对于用户,可以这样要求。
    不过也苛求了:【熟知了并搞懂了所有的细节,还拥有10年以上的实践经验,熟知每一种错误】

    但是对于语言,应该要求它:简明平实,没有歧义,不易用错;
    再高一点点,就是:运行高效,开发、维护、上手也高效。
    而歧义多,(普通人)易用错,就肯定是语言的坑了

    好语言的2/8法则:
    普通用户花20%的努力,就能掌握80%的功能

  7. “访问成员数组名其实得到的是数组的相对地址,而访问成员指针其实是相对地址里的内容”,这句话比较诡异,除了通过查看汇编语言来解释,没有更好的解释吗?

  8. “这种玩法英文叫:Flexible Array,中文翻译叫:柔性数组。” free的时候,编译器怎么知道那个malloc出来的大小?
    我用这个方法试验skiplist的例子时候,用window自带工具检查内存泄露时,一直出错(也可能是我初学的问题),用指针就不会出错。

  9. @xs
    学的不过关啊。printf的第一个参数是格式字符串,printf(f.a->s)的意思就是 把 f.a->s 作为格式字符串去打印,但是因为没有需要格式化的参数。那意思就是打印(f.a->s)的内容了

  10. “对于数组 char s[10]来说,数组名 s 和 &s 都是一样的”,这样说会不会稍稍有那么一点不太严谨,s+1 和 &s+1 是不同的,所以应该是表面上一样吧,^_^

  11. 这么些语法,现实工作几乎都不用。过多讨论这些晦涩容易出错的语法其实没多大意义

  12. 对于char s[0]来说,汇编代码用了lea指令,lea 0×04(%rax), %rdx
    对于char*s来说,汇编代码用了mov指令,mov 0×04(%rax), %rdx

    对于学过汇编和C的,这两句很是关键。
    同时点明lea和mov的区别。

    本文非常受用,获益良多,谢谢。(来自腾讯2014实习生)

  13. 对于char s[0],数组名s表示数组在内存中的首地址。而对于char *s ,s只不过是个变量。跟 int s这样都没什么区别。

  14. 这么写0长数组的话会不会遇到移植性的问题,比如嵌入式平台更换编译器导致代码出错

  15. 指针也是一个变量,a->s是去访问变量的值,而在非法内存区域。
    而数组名不是一个变量,没有为他分配的空间,a->s编译器翻译为数组的起始地址。

  16. 感觉char *p 和char p[0]两个并没有说清楚

    char*P 是实际在内存中分配的容器
    char p[0] 则只是给编译器用的符号,并不会真正在内存中分配空间。

    用sizeof查看一下大小就能理解了。

  17. 如果大学认真学过汇编语言,就知道这个是考试必考的直接寻址&间接寻址的差异。

  18. 结构体成员让我想起了linux kernel中的containerof宏
    零数组在读书时就有做过一些实验,但是还真没用过,学习了!

  19. 以前一直以为数组名和指针是一样的,看了本博才知道差别很大,因为取数组名根本没有取出内存的过程,也就无所谓页面错误了。照这样说来所有对数组的访问都不应该用数组名来做保护,而是使用会实际存储在内存中的数组元素。

  20. @lupy33
    当s是数组时,数组名就是数组第一个元素的地址 if (f.a->s) 相当于 if (&f.a->s[0]), &不会去访问该元素的内容,只是获取地址; 而printf需要一个指针参数, 并访问该地址处的内容, 但f.a->s 现在的地址是4,这个地址是受保护的,不属于本进程的,所以会down在printf那句。

    而是s指针时, if (f.a->s) 就是 if (f.a->s), 此时a已经是0,访问0地址中的内容一定会down掉这就是会在if这句core。

  21. 好文章,再请教博主:
    下面两种形式相同吗?
    struct str{
    int len;
    char s[0];
    };

    struct str{
    int len;
    char s[];
    };

  22. 这个OFFSET的宏在linux内核里见过,常用作从结构体里的变量的地址,获得外层结构的地址 @eft

    #define container_of(ptr, type, member) ({ \
    const typeof ((type *)0)->member __mptr = (ptr);\
    (type *) ((char*)__mptr – offsetof(type, member));})

    #define offsetof(type, member) ((size_t) &((type *)0)->member)

  23. 补充一点点,其实在memset(thisline->contents, ‘a’, this_length);之后,最好加上一句thisline->contents[this_length] = ”;否则又会出现意想不到的情况!

  24. QSir :
    补充一点点,其实在memset(thisline->contents, ‘a’, this_length);之后,最好加上一句thisline->contents[this_length] = ”;否则又会出现意想不到的情况!

    thisline->contents[this_length] = ”;

  25. @xs
    一个是内容访问 一个仅仅是输出地址 系统只限制了对内容的访问 地址输出是可以的
    这相当于 你能看到人家门牌号 但不能进到人家里去

回复 c86jeff 取消回复

您的电子邮箱地址不会被公开。 必填项已用*标注