C语言结构体

前言

随笔

1
2
3
4
5
6
7
8
9
10
11
12
13
void main()
{
struct explain
{
char* word_means;
int word_means_count; // 包含的释义数量
char** case_word; // 数组,包含例子释义
int case_word_count;
friend** friends; // 学生的朋友
}explain2;

printf("zqq 0x%p\n", &explain2);
}

其中 explain2 是变量名称。而 struct explain 和括号内的内容是类型定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct explain1
{
char* word_means;
int word_means_count; // 包含的释义数量
char** case_word; // 数组,包含例子释义
int case_word_count;
friend** friends; // 学生的朋友
} explain2;

void main()
{
explain2 explain3;

printf("zqq 0x%p\n", &explain2);
}

将代码优化后会变成这样。意思是,使用了 typedef 将 struct explain1 和括号 内容重新定义为 explain2,这样可以简化代码。而 explain3 是变量名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
void main()
{
struct
{
char* word_means;
int word_means_count; // 包含的释义数量
char** case_word; // 数组,包含例子释义
int case_word_count;
friend** friends; // 学生的朋友
}explain2;

printf("zqq 0x%p\n", &explain2);
}

而这样是使用了匿名的方法。其他变量不能使用这种结构体。

结构体及成员地址

首先,我们需要知道,所谓变量,其实是内存地址的一个抽像名字罢了。在静态编译的程序中,所有的变量名都会在编译时被转成内存地址。机器是不知道我们取的名字的,只知道地址。

所以有了——栈内存区,堆内存区,静态内存区,常量内存区,我们代码中的所有变量都会被编译器预先放到这些内存区中。

先来看一段简单的代码:

1
2
3
4
struct test{
int i;
char *p;
};

上面代码中,test结构中i和p指针,在C的编译器中保存的是相对地址——也就是说,他们的地址是相对于struct test的实例的。

如果我们有这样的代码:

1
struct test t;

如果实例t在内存中的绝对地址是0x7fffffffe5f0,则t.i的绝对地址也是0x7fffffffe5f0,而t.p的地址是0x7fffffffe5f4。

说白了,t.i其实就是(&t + 0x0), t.p的其实就是(&t + 0x4)。

而0x0和0x4这两个偏移地址就是成员i和p在编译时被编译器给计算好了地址。于是,你就知道,不管结构体的实例是什么——访问其成员其实就是实例地址加上成员的偏移量。

结构体的内存对齐

再来看下面这段代码:

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;
}

这段代码中指针pt的值是NULL,这代表指针指向的内存地址是0x0。其中pt->i的指向的内存地址是0x0,pt->c指向的地址是0x4,而pt->p指向的地址是0x8。

这里short类型是占2个字节,但pt->p的指向的地址确是0x8,而不是0x6。这是因为发生了内存对齐。

关于内存对齐这里不展开说明。但简单来说,如果不对齐的话,编译器就要向内存一个字节一个字节的取,这样做太浪费性能。如果一次取4个字节则高效很多。

因此来看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#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;
}

这段代码会在printf(f.a->s);处导致程序崩溃。而其中的根源在于f.a->s访问了0x04的内存地址。这通常是一个受保护的内存地址,因此导致了程序崩溃。

结构体中指针和数组的差别

修改下之前的代码,将源代码中的struct str结构体中的char s[0];改成char *s;:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

struct str{
int len;
char *s;
};

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;
}

程序会在if (f.a->s)处因为无法访问内存Cannot access memory而崩溃。char *s和char s[0]到底有什么差别呢?

这个涉及到了汇编:

对于char s[0]的f.a->s来说,汇编代码用了lea指令,lea 0x04(%rax), %rdx
对于char *s的f.a->s来说,汇编代码用了mov指令,mov 0x04(%rax), %rdx

lea全称load effective address,是把地址放进去。

mov则是把地址里的内容放进去。相当于把0x4这个地址的内容放进去。这代表访问了不该访问的内容,所以,程序就崩溃了。

从这里,我们可以看到,访问成员数组名其实得到的是数组的相对地址,而访问成员指针其实是相对地址里的内容(这和访问其它非指针或数组的变量是一样的)

换句话说,对于数组char s[10]来说,数组名s和&s都是一样的。

而下面这段代码并不会崩溃:

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;
}

执行结果是:

1
2
3
4
&s = c
&i = 0
&c = 4
&p = 8

零长度数组

首先要知道0长度的数组在ISO C和C++的规格说明书中是不允许的。在VC++2012下编译你会得到一个警告:“arning C4200: 使用了非标准扩展 : 结构/联合中的零大小数组”。

那么为什么gcc可以通过而连一个警告都没有?

那是因为gcc为了预先支持C99的这种玩法,所以,让“零长度数组”这种玩法合法了。参考下面这篇文章: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代表数组内容。通过malloc()去分配我指定的数组大小——也就是this_length的值。

这种方式叫做柔性数组。

在这个数组中,如果thisline的地址是0x601010,则contents的地址也是0x601010。但thisline->contents的值是0x601014,并且数组的是也是aaaaaaaaaa。

你如果使用sizeof()会发现返回0。这就是说,零长度的数组是存在于结构体内的,但是不占结构体的size。

你可以简单的理解为一个没有内容的占位标识,直到我们给结构体分配了内存,这个占位标识才变成了一个有长度的数组。

虽然也可以使用指针来解决上面提到的问题:

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;
}

但这个结构体的内存并不是连续的。使用零长度数组的好处有下面两个:

  1. 第一个意义是,方便内存释放。如果我们的代码是在一个给别人用的函数中,你在里面做了二次内存分配,并把整个结构体返回给用户。用户调用free可以释放结构体,但是用户并不知道这个结构体内的成员也需要free,所以你不能指望用户来发现这个事。所以,如果我们把结构体的内存以及其成员要的内存一次性分配好了,并返回给用户一个结构体指针,用户做一次free就可以把所有的内存也给释放掉。
  2. 第二个原因是,可以实现一些刁钻的需求。可以结构体的地址偏移来进行定位和寻址。

这是使用char contents[]的内存地址:

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[]。

这是使用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

上面一共输出了四行内存,其中:

  1. 第一行前四个字节是 int length,第一行的后四个字节是对齐。
  2. 第二行是char* contents,64位系统指针8个长度,他的值是0x20 0x10 0x60,也就是0x601020。
  3. 第三行和第四行是char* contents指向的内容。

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

参考:

  1. C语言结构体里的成员数组和指针
  2. 深入理解C语言