從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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM