从C语言结构对齐重谈变量存放地址与内存分配
【@.1 结构体对齐】
@->1.1
如果你看过我的这一篇博客,一定会对字节的大小端对齐方式有了重新的认识。简单回顾一下,对于我们常用的小端对齐方式,一个数据类型其高位数据存放在地址高位,地位数据在地址低位,如下图所示↓
这种规律对于我们的基本数据类型是很好理解的,但是对于像结构、联合等一类聚合类型(Aggregate)来说,存储时在内存的排布是怎样的?大小又是怎样的?我们来做实验。
*@->我们会经常用到下面几个宏分别打印变量地址、大小、格式化值输出、十六进制值输出↓
#define Prt_ADDR(var) printf("addr: 0x%p \'"#var"\'\n",&(var))
#define Prt_SIZE(var) printf("Size of \'"#var"\': %dByte\n",(int)sizeof(var))
#define Prt_VALU_F(var,format) printf(" valu: "#format" \'"#var"\'\n",var)
#define Prt_VALU(var) Prt_VALU_F(var,0x%p)
*@->如果你没有C语言编译环境可以参考我的博客配置一个命令行gcc编译环境,或者基于gcc的eclipse。
考虑下面代码,
#include <stdio.h> #define Prt_ADDR(var) printf("addr: 0x%p \'"#var"\'\n",&(var)) #define Prt_SIZE(var) printf("Size of \'"#var"\': %dByte\n",(int)sizeof(var)) #define Prt_VALU_F(var,format) printf(" valu: "#format" \'"#var"\'\n",var) #define Prt_VALU(var) Prt_VALU_F(var,0x%p) typedef struct{ char a; char b; char c; char d; } MyType,*pMyType; //含有四个char成员的结构 int main() { pMyType pIns; //结构指针实例 int final; //拼接目标变量 pIns->a=0xAA; pIns->b=0xBB; pIns->c=0xCC; pIns->d=0xDD; final = *(unsigned int *)pIns; //拼接结构到int类型变量 Prt_VALU(final); return 0; }
上面代码定义了一个含有4个char成员的结构,MyType和其指针pMyTYpe。新建一个实例pIns,赋值内部的四个成员,再将整体拼接到int类型的变量final中。MyType中只有四个char类型,所以该结构大小为4Byte(可以用sizeof观察),而32位CPU中int类型也是4Byte所以大小正好合适,就看顺序,你认为最终的顺序是“0xAABBCCDD”,还是“0xDDCCBBAA”?
下面是输出结果(我用的eclipse+CDT)。
为什么?
结构体中地址的高低位对齐的规律是什么?
我们说,局部变量都存放在栈(stack)里,程序运行时栈的生长规律是从地址高到地址低。C语言到头来讲是一个顺序运行的语言,随着程序运行,栈中的地址依次往下走。遇到自定义结构MyType的变量Ins时(我们程序里写的是指针pIns,道理一样),首先计算出MyType所需的大小,这里是4Byte,在栈里开辟一片4Byte的空间,其最低端就是这个结构的入口地址(而不是最上端!)。进入这个结构后,依次往上放结构中的成员,因此结构中第一个成员a在最下面,d在最上面。联系到我们的小端(little-endian)对齐,因此最后输出的结果是按照高位到低位,d-c-b-a的顺序输出一个完整的数。因此最终的final=0xDDCCBBAA。
IN A NUTSHELL
结构体中的成员按照定义的顺序其存储地址依次增长。
@->1.2
之前我们提到一句,遇到一个结构体时首先计算其大小,再从栈上开辟相应区域。那么这个大小是怎么计算的?
typedef struct{ char a; int b; char c; char d; } T1,*pT1; typedef struct{ char a; char b; char c; int d; } T2,*pT2;
现在计算上面定义的两个结构体T1,T2的大小是多少?可以通过下面代码打印
#include <stdio.h> #define Prt_ADDR(var) printf("addr: 0x%p \'"#var"\'\n",&(var)) #define Prt_SIZE(var) printf("Size of \'"#var"\': %dByte\n",(int)sizeof(var)) #define Prt_VALU_F(var,format) printf(" valu: "#format" \'"#var"\'\n",var) #define Prt_VALU(var) Prt_VALU_F(var,0x%p) typedef struct{ char a; int b; char c; char d; } T1,*pT1; typedef struct{ char a; char b; char c; int d; } T2,*pT2; int main() { T1 Ins1; T2 Ins2; Prt_SIZE(Ins1); Prt_SIZE(Ins2); }
其结果如下↓
参考这篇文章,总结结构对齐原则是:
原则1、数据成员对齐规则:结构(struct或联合union)的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小的整数倍开始(比如int在32位机为4字节,则要从4的整数倍地址开始存储)。
原则2、结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储。(struct a里存有struct b,b里有char,int,double等元素,那b应该从8的整数倍开始存储。)
原则3、收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的整数倍,不足的要补齐。
很明显按照以上原则,分析之前T1,T2结构的存储方式如图所示,打X的是按照规则之后的补充位↓
好了,现在可以考虑将结构T2改为:
typedef struct{
char a;
char b;
char c;
int d;
T1 e; //T1类型成员e
}T2, *pT2
结构T2的大小是多大?(20Byte)
而如果改为:
typedef struct{
char a;
char b;
char c;
int d;
pT1 e; //pT1类型成员e
}T2, *pT2
结构T2的大小是多大?(12Byte)
这些情况均可以用上面三原则进行分析。
因此,按照上面原则可以总结出一条经验性的习惯:将结构中数据类型大的成员往后放可以节省空间。
【@.2 变量存放地址,堆、栈,及内存分配】
我们先考虑一下局部变量在内存中的分布及顺序,考虑如下代码:
#include <stdio.h> #define Prt_ADDR(var) printf("addr: 0x%p \'"#var"\'\n",&(var)) #define Prt_SIZE(var) printf("Size of \'"#var"\': %dByte\n",(int)sizeof(var)) #define Prt_VALU_F(var,format) printf(" valu: "#format" \'"#var"\'\n",var) #define Prt_VALU(var) Prt_VALU_F(var,0x%p) int ga=32; int gb=777; int gc; int gd; int main() { int a=23; int b; const char c='m'; static int ss1; static int ss2=0; static int ss3=81; int * php1 = (int*)malloc(8*sizeof(int)); int * php2 = (int*)malloc(sizeof(int)); int hp3=malloc(sizeof(int)); //不好的写法 char _pause; Prt_ADDR(a); Prt_ADDR(b); Prt_ADDR(c); Prt_ADDR(ss1); Prt_ADDR(ss2); Prt_ADDR(ss3); Prt_ADDR(php1);Prt_ADDR(*php1); Prt_ADDR(php2);Prt_ADDR(*php2); Prt_ADDR(hp3); Prt_VALU(hp3); //hp3内部存放分配的地址值 Prt_ADDR(ga); Prt_ADDR(gb); Prt_ADDR(gc); Prt_ADDR(gd); _pause=getchar(); }
这段代码用于测试变量所分配的地址值,其中包含了局部变量(a,b,c),静态局部变量(ss,ss2),全局变量(ga,gb,gc,gd)。变量_pause仅仅用于在VC中调试方便。
参考这篇博客里的解释,内存通常可分为如下几块:
BSS段:BSS段(bss segment)通常是指用来存放程序中未初始化,或初始化为0的全局变量,静态局部变量的一块内存区域。BSS是英文Block Started by Symbol的简称。BSS段属于静态内存分配。
数据段:数据段(data segment)通常是指用来存放程序中已初始化为非0的全局变量的一块内存区域。数据段属于静态内存分配。
代码段:代码段(code segment/text segment)通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读, 某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。
堆(heap):堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)
栈(stack):栈又称堆栈, 是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
highest address
=========
| stack |
| vv |
| |
| |
| ^^ |
| heap |
=========
| bss |
=========
| data |
=========
| text |
=========
address 0
另外,栈(stack)的增长方向往地址低方向走,具有先进先出特点,栈顶指针位于低地址,随着程序运行在不断变化。堆(heap)的增长方向往地址高方向走,堆是一个类似于链表的结构,因此并不见得是个连续的空间。
好了,我们通常的理解就到此为,运行上面代码结果如下(前者Visual Studio,后者eclipse调用gcc的编译结果如下)。
每次程序运行这些变量的绝对地址可能变化,所以分析时我们注重观察变量的相对地址变化。
变量a,b,c均为局部变量,不管初始化与否,都被分配在栈上,而且顺序是按照从低至高向地址低分配的。其中变量c我添加了一个const是想说明,在修饰变量时,const对于地址分配无关,仅仅表示此变量是readonly的。另外,在VS系的编译器中,这些局部变量所占的空间大小比本身数据结构大,而gcc编译时的每个变量是地址上一个接着一个排,并且对齐方式也可以用前面的结构体对齐规律解释。
变量ss1,ss2,ss3就有区别了。ss1是未初始化的静态局部变量,ss2初始化为0,将被分配到BSS区,而且二者在gcc或VC编译后都是紧挨着的而不是像栈时有区别(后面会解释)。ss3初始化了的静态局部变量,分配在data段。
接下来的php1,php2和hp3变量用于演示堆(heap)操作。堆是由程序员自己控制并释放的,一般由malloc()等内存函数进行申请,最后需要用free进行释放(我在程序中没有用free了,最后将由系统释放)。这篇文章对内存操作有较详细的描述(这也是一篇比较优秀的在线C教程,而且是一页流)。mallloc()返回void*类型的指针,指向在堆中开辟的一片区域。注意并没有初始化这片区域,所以其中的值可能是任意的。
我这里之所以打印了php1和*php1的地址是想说明,php本身是指针,其本身存在于栈中,而通过malloc分配之后,保存了一块分配好大小的堆的地址值。比较上面VS和gcc的编译结果,堆中的*php1和*php2分配的地址并不连续,而且地址增长方向也不同。虽然说堆是按照地址从低到高增长的,但是实际使用上堆相当于链表,一块链下一块,所以堆的地址增长方式我们可以不用太纠结。
hp3演示了一个非常规的堆的申请,malloc本身返回一个void*类型指针,赋值给int类型的hp3,严格意义上即使强制转换也不允许的。那么int hp3=malloc(sizeof(int)); 这句话做了什么?通过后面Prt_VALU()打印其值可知,由于void*类型的特殊性,hp3中保存了分配好的堆的地址值。
全局变量,ga,gb初始化为非0,分配在data段,而gc,gd未初始化,分配在BSS段。以上可以通过观察打印出来的地址理解。
最后,总结一些有趣的实验现象如下:
@-> 栈的地址位于所有区域的地址最下面,跟理论上栈位于地址高位有出入。
@-> 堆的增长方向不见得是从地址低到高。gcc中是低到高,而VS中是高到低。
@-> 在BSS区域,未初始化(或初始化为0)的全局变量(gc,gd)按地址从高到低分配,而静态局部变量(ss1,ss2)按地址从低到高分配。
@-> 初始化的全局变量和静态局部变量(ga,gb,ss3)分配在Data段,从低到高分配,且地址上连续。
那么,为什么堆栈(stack、heap)上的地址分配并不见得是一个挨着一个(VS编译下的局部变量a,b,c),而DATA段,BSS段往往是一个挨着一个的呢?这个问题我想其实很多新手并没有太深究(比如我),包括关于所谓静态区域和非静态区域到底意味着什么。
【@.3 可执行文件包含的区域】
前面一直在提到内存可分为BSS段、堆、栈、DATA、TEXT,那么对于程序经过编译后的可执行文件,如.out,.exe,.hex等,我们运行时是需要加载到内存中区的,那么他们的代码所占的段有哪些?是全部都包含了么?当然不是。
对于这点,1997年出版的著名的《Expert C Programming: Deep C Secrets》中有一个详细的解释。对于如下图中左侧source file中的源代码,经过编译后到out文件时的变量存储区域如图所示。
当程序运行时,a.out加载到实际内存中去的分布如下图↓
OK,有了这两张图已经很能说明问题了!(上图没标明堆 heap)
main函数中的局部变量,在编译时是不会编译到out文件,而是将申明变量的这条语句作为机器码放在text段,直到运行时再从栈或堆中分配内存。所以如果做实验发现,申明了局部变量之后发现编译后的文件变大了(有时又不会变大),以为是因为为局部变量分配了内存,其实应该是增加了申明局部变量这句话的操作的机器码。而BSS段虽然在输出文件里有,但是本身不占大小,仅仅是包含了一段最终所需BSS段的大小的信息,在运行时(runtime)会扩张为相应大小。因此
@-> 初始化为非0的全局变量和静态局部变量会直接在输出文件中分配地址,运行时直接拷贝到内存data段。
@-> 未初始化或初始化为0的全局变量和静态局部变量在输出文件中不占大小,仅仅记录下最终需要的BSS段大小,运行时扩张到内存中的BSS段初始化为0。
@-> 局部变量,仅仅体现在申明时所执行操作语句的大小上,本身不占大小,运行时动态申请栈或堆。
@.[FIN] @.date->Dec 6, 2012 @.author->apollius