C 語言的編譯器有一個內建的巨集 sizeof() 可以用來取得配置給變數的記憶體大小. 例如:

    uint32_t varX = 1234;
    int      size;

    size = sizeof(varX);

這樣變數 size 所存儲的數值就會是變數 varX 到底在電腦裡佔用了多大的記憶體. uint32_t 型態的變數 (沒有意外的話) 應該都會佔用 4 bytes.

不過對於基本資料型態, sizeof() 基本上並沒有多大用途. 因為在一個系統上 (不論是大型主機, PC, 手機, 平板或是 embedded) 大部份的基本資料型態會佔用多大空間應該是程式師在該系統上撰寫 C 程式時最基本要知道的東西.

基本資料型態中最令我們困擾唯一會有變化的是 int 的大小到底是 2 bytes 還是 4 bytes 或者...? 不過這個問題只會在移植舊程式時才會出現. 舊的 CPU 用的 C 編譯器和現下要用的 CPU 的 C 編譯器預設值不同, 才會出現這樣的困擾. 有經驗的工程師 (尤其是有過系統移植經驗的 embedded 工程師), 現在應該都知道要引入標頭檔<stdint.h>, 並改int16_t, uint16_t 或者是 int32_t, uint32_t 這類明確大小的資料型態, 來避免這一類問題再出現.

其實 sizeof() 真正好用的地方在於計算下列幾種資料的大小

  • 結構 struct
  • 陣列 array
  • 固定的字串 string literal

結構 struct


對於計算結構 (struct) 所佔的空間大小, 一般會有問題的點是: 不同大小的資料型態的結構成員之間會不會有補空 (padding) 出現?

要了解這個問題, 我們需要先了解自然對界.

現在市場上存活下來的 CPU 都是以 8 位元為單位, 做 2 的冪次升級. 例如: 16 位元 (8x21), 32 位元 (8x22), 64 位元 (8x23)... 而我們使用的基本資料型態也有這個現象. 例如: char 是 1 個 byte (8 位元), short 是 2 個 byte (16 位元), float 是 4 個 byte (32 位元), double 是 8 個 byte (64 位元).

這主要是因為一開始 8 位元的 CPU 在市場上流行起來. 8 位元的 CPU 配備的當然是 8 位元的 ALU, 以 8 個 bit 為單位處理資料. 所以可想見的資料是以 8 位元為單位, 做 2 的冪次擴充.

後來要處理的資料變多變大, 想要能快速處理完成, 自然下一代的 CPU 就把 ALU 的寬度加倍, 並且也把資料匯流排 (data bus) 加倍 (否則 CPU 可以快速完成計算, 卻無法快速存儲並取得一下組資料). 自然而然的, CPU 的設計者就會要求資料存放的地址也要對齊, 否則又要分成二次來存取, 失去了資料匯流排加倍的好處.(註一)

所以 16 位元的 CPU 就要求 16 位元以上的資料型態其存放的地址必需對齊 2 的倍數. 以此類推 32 位元的 CPU 更進一步的要求 32 位元以上資料型態的存放地址必需對齊 4 的倍數; 64 位元的 CPU 更進一步的要求 64 位元以上資料型態的存放地址必需對齊 8 的倍數. 這個就是所謂的自然對界.

例如: 以 32 位元的 CPU 來說 uint16_t (大小是 2 bytes) 必需配置在地址為 2 的倍數上; uint32_t (大小是 4 bytes) 必需配置在地址為 4 的倍數上, 所以 uint32_t 的變數必需安置在地址未碼 0x0, 0x4, 0x8, 0xc 的地址上; 而不可以是 0x1, 0x2, 0x3, 0x5... 等等. 不過 8 byte 大小的 long long 或者 double 則只要配置在地址為 4 的倍數上即可.

由於自然對界的需要, C 編譯器一般都是依據 CPU 的位元數作為預設的 padding 大小. 像 16 位元 CPU 用的 C 編譯器, 預設會在每一個結構成員之間加 padding 成為 2 bytes 的倍數; 32 位元 CPU 用的 C 編譯器, 則預設會 padding 成 4 bytes 的倍數; 當然如果是現下流行的 64 位元 x86 CPU 用的 C 編譯器, 則預設會 padding 成 8 bytes 的倍數.

這個現象自然的擴及了 struct 內部的成員: 每一個成員也都必需遵守自然對界的要求. 我們來看下面的例子:

    struct {
        uint8_t  ch;
        uint16_t sz;
    } st1;

    struct {
        uint8_t  ch;
        uint32_t sz;
    } st2;

使用 16 位元的 C 編譯器來編譯時, struct st1struct st2 的成員 chsz 之間會多了 1 個 byte 的 padding. 如果改用 32 位元的 C 編譯器來編譯時, 結構成員 st1.chst1.sz 之間會多了 1 個 byte 的 padding, 但是結構成員 st2.chst2.sz 之間則會多了 3 個 byte 的 padding. 因為 uint32_t 在 16 位元的機器上位址只要對齊到 2 的倍數上, 但是在 32 位元的機器上位址必需對齊到 4 的倍數上.

大部份狀況下, 到底有沒有 padding, 或者 padding 成多少的倍數基本上並沒有多大關係, 頂多就浪費一些記憶體而已 (不過 embedded system 記憶體有限, 還是節制一點的好). 但是如果這個結構 (struct) 是用來對應傳輸程式要組成傳送的封包或是拆解接收封包用的資料結構 (data structure), 那問題就大了. 因為如果對方使用了不同的 CPU, 或是用了不同的 C 編譯器, 對我們的結構 (struct) 作了不一致的 padding, 那...結構裡的資料就會全都位移了. 因此傳輸程式用的結構 (struct) 我們一般會使用下列的技巧來避免不一致的 padding:

  • 定義結構 (struct) 時強制修改為程式原本所需要的 padding (一般是不要 padding).
    一般 C 編譯器都會支援 #pragma pack(push, 1)#pragma pack(pop) 或是類似的語法讓使用者改變 C 編譯器預設的 padding.(註二)
  • 調整結構 (struct) 的定義.
    例如: 將長度是 4 bytes (或是 4 bytes 倍數) 的成員往結構的前面排, 再接著排放長度是 2 bytes (或是 2 bytes 倍數) 的成員, 最後纔是長度是 1 byte (或是奇數) 的成員. 這樣可以確保 4 bytes 的成員變數在記憶體中都擺放在 4 bytes 的邊界上, 而 2 bytes 的成員變數在記憶體中都擺放在 2 bytes 的邊界上, 同時所需要的 padding 也最小.

註一: 當配置地址不在邊界上時, CPU 得分二次存取才能拼湊出完整的資料, 並且需要額外的電路來完成這個動作. 因此, 有些 CPU 是可以存取不在邊界上的資料, 但是速度會慢下來 (像是 x86 CPU); 有些 CPU 則是無法存取, 像 ARM7, ARM9 是直接產生 hard fault.

註二: 把預設的 padding 值改小雖然可以達成結構中的成員地址符合資料結構上的對位需求, 但是可能也會同時引發機器當機 (原因如註一). 因此傳輸用的封包結構必需小心設計. 像是 Modbus TCP 的封包格式中就有這麼一個陷阱.

struct alignment

自然對界及強制對界對結構大小及成員位置所產生的影響.
紅色為可能產生 Hard Fault 之成員.

另外, C 語言的編譯器還有一個內建的巨集 offsetof() 可以用來取得結構成員相對於結構地址的偏移量 (定義於標頭檔 stddef.h 中, 使用前請引入). 你可以拿它來檢視結構成員確實的位置偏移; 或者運用它來自行計算某個成員的確實位置 (通常是運用在撰寫各個結構成員都可以共用的函數時). offsetof() 需要二個參數:

  1. 結構資料名稱, 或者用 typedef 定義的結構資料型態
  2. 該結構的成員

回傳值就是該成員相對於結構位置的偏移量. 所以加上結構的地址 (或者結構指標的值) 就是該成員的實際位置了. 不過要小心計算實際位置前要把結構的地址 type casting 成 uint8_t * (或者是類似的單位偏移量為 1 的指標) 纔不會算錯.

陣列 array


sizeof() 用在陣列變數上, 我們可以取得整個陣列所佔用的記憶體大小. 例如:

    int arrayX[7] = { 1, 2, 3, 4, 5, 6, 7 };
    int size;

    size = sizeof(arrayX);

在 32 位元的 CPU 上, 我們應該是取得數值 28. 這裡要注意的是: 因為我們在前面指定要 7 個元素, 所以 C 編譯器為變數 arrayX 配置了 7 個 int 所需要的空間. 因此不論後面大括號中設定元素數值的個數有沒有填滿 7 個, sizeof(arrayX) 取得的大小都會是 28.

sizeof() 其實真正好用的地方在於我們沒有明確指定的元素個數的陣列上.

    int arrayY[] = { 1, 2, 3, 4, 5, 6 };
    int size;

    size = sizeof(arrayY);

在 32 位元的 CPU 上, 我們應該是取得數值 24. 這裡要注意的是: 因為我們在前面沒有指定變數 arrayY 到底要放多少個元素, 所以 C 編譯器依據後面大括號中設定元素數值的個數 (6 個) 來為變數 arrayY 配置 6 個 int 所需要的空間 24 byte. 這樣子, 變數 arrayY 的元素個數需要改變時, 我們就只需要補充或者刪除後面大括號中的設定數值就可以了.

但是, 大部份狀況下, 我們需要的是陣列的元素個數, 而不是陣列到底佔用多少記憶體. 要取得陣列的元素個數我們只需要把整個陣列佔用的記憶體數除以一個元素所佔用的記憶體數即可. 例如:

    int arrayY[] = { 1, 2, 3, 4, 5, 6 };
    int memSize, elemCnt;

    memSize = sizeof(arrayY);
    elemCnt = sizeof(arrayY)/sizeof(int);

上面的例子中, 變數 elemCnt 的數值會是 6. 這個例子主要是告訴大家 sizeof() 括號裡面除了可以放變數, 也可以放資料型態 (包含指標, 結構 struct 以及我們使用 typedef 定義出來的各種衍生資料型態).

不過, 利用上面例子中的寫法來計算陣列元素個數, 有時會有點小問題: 萬一變數 arrayY 的資料型態變更了 (例如: 原本是 int, 現在因為擴充內容, 必需改成用 struct), 我們就必需要記得修改後面的 sizeof(int)int 改成正確的資料型態. 萬一忘了修改或者是打錯了, C 編譯器可不會對我們提出警告. 這一點對於一些大型專案的應用實在不利. 所以我們需要改一下寫法:

    int arrayY[] = { 1, 2, 3, 4, 5, 6 };
    int memSize, elemCnt;

    memSize = sizeof(arrayY);
    elemCnt = sizeof(arrayY)/sizeof(arrayY[0]);

這樣子, 不論變數 arrayY 的資料型態是什麼 (或者改成什麼) 都不會出錯了. 因此我們可以把它定義成一個巨集, 需要時直接使用:

#define SIZEOF_ARRAY(x)    (sizeof(x)/sizeof(x[0]))

固定的字串 string literal


當然, sizeof() 也可以取得程式中的固定字串所佔用的記憶大小, 例如:

    char strX[] = "0123456789";
    int sizeX;

    sizeX = sizeof(strX);

上面的例子, 變數 sizeX 的數值是 11.

啊! 怎麼會這樣? 不對吧! 數字字元 0~9 不是隻有 10 個字元嗎? 變數 sizeX 怎麼是 11 呢?

這是因為 sizeof() 計算的是存放變數所需要的空間, 而 C 語言的字串後面必需補一個 null 字元來表示字串結束. 因此存放字串的空間會比字串的字數再多加 1.

指標 pointer


接下來我們要探討一下指標變數和 sizeof() 的關係. 先來看一下有點小機車的例子:

    char strX[] = "0123456789";
    char * strY = "0123456789";
    char * strZ;
    int sizeX, sizeY, slenY, sizeZ;

    sizeX = sizeof(strX);
    sizeY = sizeof(strY);
    slenY = strlen(strY);
    sizeZ = sizeof(strZ);

在 32 位元的 CPU 上, 變數 sizeX 的數值是 11; 而變數 sizeYsizeZ 的數值都是 4; 變數 slenY 則是 10.

前面已經解釋過變數 sizeX 的問題了, 不再多說. 但是有人或許會有疑問: 變數 strY 不就只是變數 strX 換一個寫法而已嗎?

答案是: 真的不是換一個寫法而已. 變數 strX 是一個陣列. 變數 strY 則是一個指向字元的指標. 在 32 位元的 CPU 上指標通常是佔用 4 bytes 記憶體. 所以變數 sizeYsizeZ 的數值都是 4.

如果我們另外定義一個字元變數 ch, 然後用 ch = strX[5]; 這一行程式取出字串中的第 6 個字元, C 編譯器輸出的機器碼程式應該是 "由一個固定地址取出字元". 相對 ch = strY[5]; 這一行程式輸出的機器碼程式則應該是 "由變數 strY 取出字串的地址值加 5 之後, 再以答案當作地址來取出字元". 所以二種寫法編譯出來的程式大小不同, 執行速度也是不同.

補充說明


有一點要注意的是 sizeof() 是一個巨集, C 編譯器會換算成正確的數值填充在程式中, 而不是程式執行的時候纔去計算結果.

另外, strlen() 雖然是 C 的標準函數, 但是現代的 C 編譯器也會把固定字串的 strlen() 直接換算成正確的長度數值填充在程式中, 而不是程式執行的時候纔去計算結果.

相關文章