• 从C语言结构对齐重谈变量存放地址与内存分配


    【@.1 结构体对齐】

    @->1.1

    如果你看过我的这一篇博客,一定会对字节的大小端对齐方式有了重新的认识。简单回顾一下,对于我们常用的小端对齐方式,一个数据类型其高位数据存放在地址高位,地位数据在地址低位,如下图所示↓

     image

    这种规律对于我们的基本数据类型是很好理解的,但是对于像结构、联合等一类聚合类型(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)。

    image

    为什么?

    结构体中地址的高低位对齐的规律是什么?

    我们说,局部变量都存放在栈(stack)里,程序运行时栈的生长规律是从地址高到地址低。C语言到头来讲是一个顺序运行的语言,随着程序运行,栈中的地址依次往下走。遇到自定义结构MyType的变量Ins时(我们程序里写的是指针pIns,道理一样),首先计算出MyType所需的大小,这里是4Byte,在栈里开辟一片4Byte的空间,其最低端就是这个结构的入口地址(而不是最上端!)。进入这个结构后,依次往上放结构中的成员,因此结构中第一个成员a在最下面,d在最上面。联系到我们的小端(little-endian)对齐,因此最后输出的结果是按照高位到低位,d-c-b-a的顺序输出一个完整的数。因此最终的final=0xDDCCBBAA。

    image

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

    其结果如下↓

    image

    参考这篇文章,总结结构对齐原则是:

    原则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的是按照规则之后的补充位↓

    image

    好了,现在可以考虑将结构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的编译结果如下)。

    image  image

    每次程序运行这些变量的绝对地址可能变化,所以分析时我们注重观察变量的相对地址变化。

    变量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文件时的变量存储区域如图所示。

    image

    当程序运行时,a.out加载到实际内存中去的分布如下图↓

    image

    OK,有了这两张图已经很能说明问题了!(上图没标明堆 heap)

    main函数中的局部变量,在编译时是不会编译到out文件,而是将申明变量的这条语句作为机器码放在text段,直到运行时再从栈或堆中分配内存。所以如果做实验发现,申明了局部变量之后发现编译后的文件变大了(有时又不会变大),以为是因为为局部变量分配了内存,其实应该是增加了申明局部变量这句话的操作的机器码。而BSS段虽然在输出文件里有,但是本身不占大小,仅仅是包含了一段最终所需BSS段的大小的信息,在运行时(runtime)会扩张为相应大小。因此

    @-> 初始化为非0的全局变量和静态局部变量会直接在输出文件中分配地址,运行时直接拷贝到内存data段。

    @-> 未初始化或初始化为0的全局变量和静态局部变量在输出文件中不占大小,仅仅记录下最终需要的BSS段大小,运行时扩张到内存中的BSS段初始化为0。

    @-> 局部变量,仅仅体现在申明时所执行操作语句的大小上,本身不占大小,运行时动态申请栈或堆。

    @.[FIN]      @.date->Dec 6, 2012      @.author->apollius

  • 相关阅读:
    leetcode100
    leetcode237
    leetcode171
    leetcode122
    leetcode387
    2018-8-10-win10-uwp-如何打包Nuget给其他人
    2018-8-10-win10-uwp-如何打包Nuget给其他人
    2019-11-13-如何在国内发布-UWP-应用
    2019-11-13-如何在国内发布-UWP-应用
    2019-2-21-PowerShell-通过-WMI-获取设备厂商
  • 原文地址:https://www.cnblogs.com/apollius/p/2803339.html
Copyright © 2020-2023  润新知