天天看點

圖文并茂,一文講透C語言結構體記憶體對齊

面試官:你知道C語言的結構體對齊嗎? 

應聘者:聽說過……平時很少關注 

……

面試官:好吧,那回去等通知吧 

C語言結構體對齊問題,是面試必備問題。

本文,除了用圖解的方式講清楚結構體知識點外,還将為你解答以下問題:

  • 為什麼會有結構體記憶體對齊?
  • 結構體怎麼對齊?
  • 學習結構體對齊有什麼用?
  • 結構體對齊有沒有實際應用?

▍結構體記憶體對齊的原因

一句話,為了提高效率,這個跟晶片設計有關。

自從我們剛學習程式設計開始,就會接觸到例如字、雙字、四字等概念這裡涉及到記憶體邊界問題,它們的位址分别是可被2/4/8整除的。另外,在彙編中,不同長度的記憶體通路會用到不同的彙編指令。

如果,一塊記憶體在位址上随便放的,CPU有可能就會用到多條指令來通路,這就會降低效率。

對于32位系統,如下圖的A可能需要2條指令通路,而B隻需1條指令。

圖文并茂,一文講透C語言結構體記憶體對齊
圖文并茂,一文講透C語言結構體記憶體對齊

▍結構體記憶體對齊的規則

1. C語言基本類型的大小

不要瞎猜,直接上代碼。每個平台都不一樣,請讀者自行測試,以下我是基于Windows上MinGW的GCC測的。

#define BASE_TYPE_SIZE(t)   printf("%12s : %2d Byte%s\n", #t, sizeof(t), (sizeof(t))>1?"s":"")              void base_type_size(void)              {              BASE_TYPE_SIZE(void);              BASE_TYPE_SIZE(char);              BASE_TYPE_SIZE(short);              BASE_TYPE_SIZE(int);              BASE_TYPE_SIZE(long);              BASE_TYPE_SIZE(long long);              BASE_TYPE_SIZE(float);              BASE_TYPE_SIZE(double);              BASE_TYPE_SIZE(long double);              BASE_TYPE_SIZE(void*);              BASE_TYPE_SIZE(char*);              BASE_TYPE_SIZE(int*);                  typedef struct               {              }StructNull;              BASE_TYPE_SIZE(StructNull);              BASE_TYPE_SIZE(StructNull*);              }
           

結果是:

​​​​​​​

void :  1 Byte              char :  1 Byte              short :  2 Bytes              int :  4 Bytes              long :  4 Bytes              long long :  8 Bytes              float :  4 Bytes              double :  8 Bytes              long double : 12 Bytes              void* :  4 Bytes              char* :  4 Bytes              int* :  4 Bytes              StructNull :  0 Byte              StructNull* :  4 Bytes
           

這些内容不用記住,不同平台是不一樣的,使用之前,一定要親自測試驗證下,但是可以總結出以下資訊:

  • void類型不是空的,占一個位元組
  • long不一定比int大
  • C語言空結構體的大小為0(注意:C++的為1)
  • 不管什麼類型,指針都是相同大小的

2. C語言結構體的記憶體對齊

我先看個例子:​​​​​​​

#define offset(type, member)      (size_t)&(((type *)0)->member)
#define STRUCT_E_ADDR(s,e)          printf("%5s size = %2d %16s addr: %p\n", #s, sizeof(s), #s"."#e, &s.e)
#define STRUCT_E_OFFSET(s,e)        printf("%5s size = %2d %16s offset: %2d\n", #s, sizeof(s), #s"."#e, offset(__typeof__(s),e))
#define STRUCT_E_ADDR_OFFSET(s,e)   printf("%5s size = %2d %16s addr: %p, offset: %2d\n", #s, sizeof(s), #s"."#e, &s.e, offset(__typeof__(s),e))

typedef struct 
{
    int e_int;
    char e_char;
}S1;
S1 s1;
STRUCT_E_ADDR_OFFSET(s1, e_int);
STRUCT_E_ADDR_OFFSET(s1, e_char);
typedef struct 
{
    int e_int;
    double e_double;
}S11;
S11 s11; 
STRUCT_E_ADDR_OFFSET(s11, e_int);
STRUCT_E_ADDR_OFFSET(s11, e_double);
           

咦……這宏定義是啥意思?傳送門:《基于C99規範,最全C語言預處理知識總結》

輸出結果:​​​​​​​

s1 size =  8         s1.e_int addr: 0028FF28, offset:  0              s1 size =  8        s1.e_char addr: 0028FF2C, offset:  4              s11 size = 16        s11.e_int addr: 0028FF18, offset:  0              s11 size = 16     s11.e_double addr: 0028FF20, offset:  8
           

結論1:一般情況下,結構體所占的記憶體大小并非元素本身大小之和。

結論2:不嚴謹地,結構體記憶體的大小按最大元素大小對齊。

繼續看例子:​​​​​​​

typedef struct               {              int e_int;              long double e_ld;              }S12;              typedef struct               {              long long e_ll;              long double e_ld;              }S13;              typedef struct               {              char e_char;              long double e_ld;              }S14;                  S12 s12;              S13 s13;              S14 s14;              STRUCT_E_ADDR_OFFSET(s12, e_int);              STRUCT_E_ADDR_OFFSET(s12, e_ld);              STRUCT_E_ADDR_OFFSET(s13, e_ll);              STRUCT_E_ADDR_OFFSET(s13, e_ld);              STRUCT_E_ADDR_OFFSET(s14, e_char);              STRUCT_E_ADDR_OFFSET(s14, e_ld);
           

輸出結果:​​​​​​​

s12 size = 16        s12.e_int addr: 0028FF08, offset:  0              s12 size = 16         s12.e_ld addr: 0028FF0C, offset:  4              s13 size = 24         s13.e_ll addr: 0028FEF0, offset:  0              s13 size = 24         s13.e_ld addr: 0028FEF8, offset:  8              s14 size = 16       s14.e_char addr: 0028FEE0, offset:  0              s14 size = 16         s14.e_ld addr: 0028FEE4, offset:  4
           

出現問題了,你看s12和s14,sizeof(long long)應該是12,按結論而推斷sizeof(s12)和sizeof(s13)應該都是24。

這裡跟平台和編譯器的一個模數有關。

對結論2修正:結構體記憶體大小應按最大元素大小對齊,如果最大元素大小超過模數,應按模數大小對齊。

額外再送一條結論:如果結構體的最大元素大小超過模數,結構體的起始位址是可以被模數整除的。如果,最大元素大小沒有超過模數大小,那它的起始位址是可以被最大元素大小整除。

那麼,這個模數是什麼?

每個特定平台上的編譯器都有自己的預設“對齊系數”(也叫對齊模數)。

網上流傳一個表:

平台 長度/模數 char short int long float double long long long double
Win-32 長度 1 2 4 4 4 8 8 8
模數 1 2 4 4 4 8 8 8
Linux-32 長度 1 2 4 4 4 8 8 12
模數 1 2 4 4 4 4 4 4
Linux-64 長度 1 2 4 8 4 8 8 16
模數 1 2 4 8 4 8 8 16

本文的的例子我用的是MinGW32的GCC來測試,你猜符合上表的哪一項?

别急,再看一個例子:​​​​​​​

typedef struct               {              int e_int;              double e_double;              }S11;              S11 s11;                  STRUCT_E_ADDR_OFFSET(s11, e_int);              STRUCT_E_ADDR_OFFSET(s11, e_double);
           

結果是:​​​​​​​

s11 size = 16        s11.e_int addr: 0028FF18, offset:  0              s11 size = 16     s11.e_double addr: 0028FF20, offset:  8
           

很明顯,上表沒有一項完全對應得上的。簡單彙總以下我測試的結果:

長度/模數 char short int long float double long long long double
長度 1 2 4 4 4 8 8 12
模數 1 2 4 4 4 8 8 8

是以,再強調一下:因為環境的差異,在你參考使用之前,請自行測試一下。

另外,提一下:這個模數是可以改變的,可以用預編譯指令#pragma pack(n),n=1,2,4,8,16來改變這一系數,其中的n就是你要指定的“對齊系數”。

例如​​​​​​​

#pragma pack(1)              typedef struct               {              char e_char;              long double e_ld;              }S14;              #pragma pack()
           

#pragma是啥玩意?有興趣可以看看:《基于C99規範,最全C語言預處理知識總結》

好了,我們繼續,這似乎沒啥技術含量,我們提升下難度:​​​​​​​

typedef struct               {              int e_int;              char e_char1;              char e_char2;              }S2;              typedef struct               {              char e_char1;              int e_int;              char e_char2;              }S3;                  S2 s2;              S3 s3;
           

你覺得這倆結構體所占記憶體是一樣大嗎?那你就錯了:

​​​​​​​

s2 size =  8         s2.e_int addr: 0028FED4, offset:  0              s2 size =  8       s2.e_char1 addr: 0028FED8, offset:  4              s2 size =  8       s2.e_char2 addr: 0028FED9, offset:  5              s3 size = 12       s3.e_char1 addr: 0028FEC4, offset:  0              s3 size = 12         s3.e_int addr: 0028FEC8, offset:  4              s3 size = 12       s3.e_char2 addr: 0028FECC, offset:  8
           

why?

上個圖先看看,它們記憶體是怎麼對齊的:

圖文并茂,一文講透C語言結構體記憶體對齊

我們套一遍那幾條結論就可以知道:

了解按最大元素大小或模數對齊,就可以看到S2的記憶體分布;

對于S3,e_int的位置位址,肯定是要按int的大小對齊的(位址可被int大小整除),這樣才能提高通路效率。同時,這導緻了很大的記憶體浪費。

以上例子,我們看到挨在一起的兩個char會放在同一個對齊單元,如果挨在一起的short和char會不會放一起?​​​​​​​

typedef struct               {              char e_char1;              short e_short;              char e_char2;              int e_int;              char e_char3;              }S4;              S4 s4;              STRUCT_E_ADDR_OFFSET(s4, e_char1);              STRUCT_E_ADDR_OFFSET(s4, e_short);              STRUCT_E_ADDR_OFFSET(s4, e_char2);              STRUCT_E_ADDR_OFFSET(s4, e_int);              STRUCT_E_ADDR_OFFSET(s4, e_char3);
           

輸出結果:​​​​​​​

s4 size = 16       s4.e_char1 addr: 0028FEB4, offset:  0              s4 size = 16       s4.e_short addr: 0028FEB6, offset:  2              s4 size = 16       s4.e_char2 addr: 0028FEB8, offset:  4              s4 size = 16         s4.e_int addr: 0028FEBC, offset:  8              s4 size = 16       s4.e_char3 addr: 0028FEC0, offset: 12
           
圖文并茂,一文講透C語言結構體記憶體對齊

得出一個經驗:

我們在定義結構體的時候,盡量把大小相同或相近的元素放一起,以減少結構體占用的記憶體空間。

再來一個問題:

結構體套着另一個結構體怎麼計算?

typedef struct 
    {
        int e_int;
        char e_char;
    }S1;
   typedef struct 
    {
        S1 e_s;
        char e_char;
    }SS1;

    typedef struct 
    {
        short e_short;
        char e_char;
    }S6;

    typedef struct 
    {
        S6 e_s;
        char e_char;
    }SS2; 
    
    SS1 ss1;
    STRUCT_E_ADDR_OFFSET(ss1, e_s);
    STRUCT_E_ADDR_OFFSET(ss1, e_char);

    SS2 ss2;
    STRUCT_E_ADDR_OFFSET(ss2, e_s);
    STRUCT_E_ADDR_OFFSET(ss2, e_char);
           

​​​​​​​

輸出結果:​​​​​​​

ss1 size = 12          ss1.e_s addr: 0028FE94, offset:  0              ss1 size = 12       ss1.e_char addr: 0028FE9C, offset:  8              ss2 size =  6          ss2.e_s addr: 0028FE8E, offset:  0              ss2 size =  6       ss2.e_char addr: 0028FE92, offset:  4
           
圖文并茂,一文講透C語言結構體記憶體對齊

得出結論:結構體内的結構體,結構體内的元素并不會和結構體外的元素合并占一個對齊單元。

溫馨提示:大家不要刻意去記這些結論,動手去試試并思考下效果會更好。

3. 聯合體union的記憶體對齊

直接上代碼:​​​​​​​

typedef union 
    {
        char e_char;
        int e_int;
    }U1;

    U1 u1;
    STRUCT_E_ADDR(u1, e_char);
    STRUCT_E_ADDR(u1, e_int);
           

輸出結果:

​​​​​​​

u1 size =  4        u1.e_char addr: 0028FF2C              u1 size =  4         u1.e_int addr: 0028FF2C
           

從教科書上,我都可以了解,聯合體裡面的元素,實際上共享同一個空間。

圖文并茂,一文講透C語言結構體記憶體對齊

那麼,union跟struct結合呢?​​​​​​​

typedef struct
    {
        int e_int1; 
        union
        {
            char ue_chars[9]; 
            int ue_int;
        }u;
        double e_double; 
        int e_int2; 
    }SU2;
    SU2 su2;
    STRUCT_E_ADDR_OFFSET(su2, e_int1);
    STRUCT_E_ADDR_OFFSET(su2, u.ue_chars);
    STRUCT_E_ADDR_OFFSET(su2, u.ue_int);
    STRUCT_E_ADDR_OFFSET(su2, e_double);
    STRUCT_E_ADDR_OFFSET(su2, e_int2)
           

輸出:​​​​​​​

su2 size = 32       su2.e_int1 addr: 0028FEF8, offset:  0              su2 size = 32   su2.u.ue_chars addr: 0028FEFC, offset:  4              su2 size = 32     su2.u.ue_int addr: 0028FEFC, offset:  4              su2 size = 32     su2.e_double addr: 0028FF08, offset: 16              su2 size = 32       su2.e_int2 addr: 0028FF10, offset: 24
           
圖文并茂,一文講透C語言結構體記憶體對齊

實際上跟結構體類似,也沒有特别的規則。

順便提一下,使用union時,要留意平台的大小端問題。

大端模式,是指資料的高位元組儲存在記憶體的低位址中,而資料的低位元組儲存在記憶體的高位址中,這樣的存儲模式有點兒類似于把資料當作字元串順序處理:位址由小向大增加,而資料從高位往低位放;這和我們的閱讀習慣一緻。 

小端模式,是指資料的高位元組儲存在記憶體的高位址中,而資料的低位元組儲存在記憶體的低位址中,這種存儲模式将位址的高低和資料位權有效地結合起來,高位址部分權值高,低位址部分權值低。

百度百科——大小端模式

怎麼獲知自己使用的平台的大小端?Linux有個方法

static union { 
        char c[4]; 
        unsigned long l; 
    } endian_test = { { 'l', '?', '?', 'b' } };
    #define ENDIANNESS ((char)endian_test.l)

    printf("ENDIANNESS: %c\n", ENDIANNESS);
           

4. 位域(Bitfield)的相關

位域在本文沒什麼好探讨的,在結構體對齊方面沒什麼特别的地方。

直接看個測試代碼,就可以明白:

void bitfield_type_size(void)
{
    typedef struct
    {
        char bf1:1;
        char bf2:1;
        char bf3:1;
        char bf4:3;
    }SB1;

    typedef struct
    {
        char bf1:1;
        char bf2:1;
        char bf3:1;
        char bf4:7;
    }SB2;

    typedef struct
    {
        char bf1:1;
        char bf2:1;
        char bf3:1;
        int  bfint:1;
    }SB3;

    typedef struct
    {
        char bf1:1;
        char bf2:1;
        int  bfint:1;
        char bf3:1;
    }SB4;

    SB1 sb1;
    SB2 sb2;
    SB3 sb3;
    SB4 sb4;
    VAR_ADDR(sb1);
    VAR_ADDR(sb2);
    VAR_ADDR(sb3);
    VAR_ADDR(sb4);
    
    typedef struct
    {
        unsigned char bf1:1;
        unsigned char bf2:1;
        unsigned char bf3:1;
        unsigned char bf4:3;
    }SB11;

    typedef union 
    {
        SB11 sb1;
        unsigned char  e_char;
    }UB1;
    UB1 ub1;

    STRUCT_E_ADDR_OFFSET(ub1, sb1);
    STRUCT_E_ADDR_OFFSET(ub1, e_char);

    ub1.e_char = 0xF5;
    BITFIELD_VAL(ub1, e_char);
    BITFIELD_VAL(ub1, sb1.bf1);
    BITFIELD_VAL(ub1, sb1.bf2);
    BITFIELD_VAL(ub1, sb1.bf3);
    BITFIELD_VAL(ub1, sb1.bf4);
}
           

輸出結果是:

sb1 size =  1        sb1 addr: 0028FF2F              sb2 size =  2        sb2 addr: 0028FF2D              sb3 size =  8        sb3 addr: 0028FF24              sb4 size = 12        sb4 addr: 0028FF18              ub1 size =  1          ub1.sb1 addr: 0028FF17, offset:  0              ub1 size =  1       ub1.e_char addr: 0028FF17, offset:  0              ub1 :  1 Byte, ub1.e_char=0xF5              ub1 :  1 Byte, ub1.sb1.bf1=0x1              ub1 :  1 Byte, ub1.sb1.bf2=0x0              ub1 :  1 Byte, ub1.sb1.bf3=0x1              ub1 :  1 Byte, ub1.sb1.bf4=0x6
           

有幾個點需要注意下:

  1. 記憶體的計算機關是byte,不是bit
  2. 結構體内即使有bitfield元素,其對齊規則還是按照基本類型來
  3. bitfield元素不能獲得其位址(即程式中不能通過&取址)

5. 規則總結

首先,不推薦記憶這些條條框框的文字,以下内容僅供參考:

  1. 結構體的記憶體大小,并非其内部元素大小之和;
  2. 結構體變量的起始位址,可以被最大元素基本類型大小或者模數整除;
  3. 結構體的記憶體對齊,按照其内部最大元素基本類型或者模數大小對齊;
  4. 模數在不同平台值不一樣,也可通過#pragma pack(n)方式去改變;
  5. 如果空間位址允許,結構體内部元素會拼湊一起放在同一個對齊空間;
  6. 結構體内有結構體變量元素,其結構體并非展開後再對齊;
  7. union和bitfield變量也遵循結構體記憶體對齊原則。

▍程式設計為什麼要關注結構體記憶體對齊

也許你會問,結構體愛怎麼對齊就怎麼對齊,我管它幹嘛!

1. 節省記憶體

在嵌入式軟體開發中,特别是記憶體資源匮乏的小MCU,這個尤為重要。如果優化程式記憶體,使得MCU可以選更小的型号,對于大批量出貨的産品,可以帶來更高利潤。

也許你還還感覺不到,上段代碼:

typedef struct 
    {
        int e_int;
        char e_char1;
        char e_char2;
    }S2;

    typedef struct 
    {
        char e_char1;
        int e_int;
        char e_char2;
    }S3;
    
    S2 s2[1024] = {0};
    S3 s3[1024] = {0};
           

s2的大小為8K,而s3的大小為12K,一放大,就有很明顯的差別了。

2. union的記憶體對齊需要

對于同一個記憶體,有時為了滿足不同的通路形式,定義一個聯合體變量,或者一個結構體和聯合體組合的變量。此時就要知道其記憶體結構是怎麼分布的。

3. 記憶體拷貝

有時候,我們在通信資料接收處理時候,往往遇到,數組和結構體的搭配。

即,通信時候,通常使用數組參數形式接收,而處理的時候,按照預定義格式去通路處理。例如:

U8 comm_data[10];
typedef struct
{
    U8 id;
    U16 len;
    U8 data[6];
}FRAME;

FRAME* pFram = (FRAME*)comm_data;
           

此處,必須要了解這個FRAM的記憶體結構是怎麼樣的對齊規則。

4. 調試仿真時看壓棧資料

在調試某些奇葩問題時,迫不得已,我們會研究函數跳轉或者線程切換時的棧資料,遇到結構體内容,肯定要懂得其記憶體對齊方式才能更好地獲得棧内資訊。

當然,還有其他方面的原因,在此就不一一列舉了。

▍結構體記憶體對齊實際應用

上面一個章節已經部分講到這個結構體記憶體對齊的應用了,例如通信資料的處理等。另外,再舉兩個例子:

1. 記憶體的mapping

假設你要做一個燒錄檔案,你想往檔案頭空間128個位元組内放一段項目資訊(例如程式大小、CRC校驗碼、其他項目資訊等)。第一反應,你會考慮用一個結構體,定義一段這樣的資料,程式運作的時候也定義同樣的結構體去讀取這個記憶體。但是你需要知道結構體大小啊,這個結構體記憶體對齊的規則還是需要了解的。

2. 單片機寄存器的mapping

在寫MCU驅動的時候,通路寄存器的方式有很多種,但是做到清晰明了,适配性好的,往往需要諸多考量。

直接通過整型指針指到特定位址去通路,是沒有問題的,但是對于某一類型的寄存器,往往不是一個固定位址,其後面還有一堆子寄存器屬性需要配置。每個位址都通過整型指針通路,那就很多很淩亂。

我們可以通過定義一個特定的結構體,用其指針直接mapping到寄存器的base位址。但是遇到有些位址是空的怎麼辦?甚至有些寄存器是32位的,有些16位,甚至8位的,各種參差不齊都在裡面。

那就要考慮結構體記憶體對齊了,特别是結構體内有不同類型的元素。

圖文并茂,一文講透C語言結構體記憶體對齊

這裡隻探讨應用場景,具體實作還要根據實際情況來定義。

繼續閱讀