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>, 並改int8_t, uint8_t, int16_t, uint16_t 或者是 int32_t, uint32_t 又或者是 int64_t, uint64_t 這類明確大小的資料型態, 來避免這一類問題再出現.

註一: 其實不止是 int 有問題: 維基百科上 "C data types" 這一篇文章中就明白指出:
char, short, int, long, long long... 都只有規定最少要能表示多大的數值, 而不是規定剛好要能表示多大的數值.
所以現下在 64 位元作業系統上, 某些 C 編譯器的 int 甚至是 8 bytes (ILP64 和 SILP64); 而 long 則有些是 4 bytes (LLP64), 有些是 8 bytes (LP64, ILP64, SILP64). (參閱維基百科 64-bit computing#64-bit data models).

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

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

結構 struct


對於計算結構 (struct) 所佔的空間大小, 一般會有問題的點是:

  • 不同大小的資料型態的結構成員之間會不會有補空 (padding) 出現?
  • 以及整體結構是否也需要補空?
  • 還有它應該使用哪一個對界值?

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

自然對界 (Alignment)


現在市場上存活下來的 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 的倍數上即可.

自然對界值 (CPU vs 資料型態)
資料型態記憶體CPU 型式
8 bits16 bits32 bits64 bits
int8_t,   uint8_t 1 bytex1x1x1x1
int16_t, uint16_t 2 bytex1x2x2x2
int32_t, uint32_t 4 bytex1x2x4x4
int64_t, uint64_t 8 bytex1x2x4x8
float 4 bytex1x2x4x4
double 8 bytex1x2x4x8

註二: 請注意: 資料匯流排的寬度雖然加倍了, 但是為了維持和舊系統的相容性 (包括軟體, 硬體架構, 零件...等等), 還有節約記憶體空間等種種原因, 記憶體的最小定址單位依舊是 8 位元, 只是增加了加倍後的定址模式 (如: 16 位元定址模式, 32 位元定址模式...), 並預設使用加倍後的定址模式. 意即: 16 位元的 CPU 是以 (0,1), (2,3), (4,5), (6,7)... 這樣一對一對的地址進行資料的 IO. 對所以當配置地址不在邊界上時 (例如: 16bits 資料放置於地址 (3,4)), CPU 得分二次存取才能拼湊出完整的資料, 並且需要額外的電路來完成這個動作. 因此, 有些 CPU 是可以存取不在邊界上的資料, 但是速度會慢下來 (像是早期的 x86 CPU); 有些 CPU 則是無法存取, 像 ARM7, ARM9 是直接產生 hard fault.

由於自然對界的需要, 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 的倍數上.

除了每一個成員也都必需遵守自然對界 (或者指定的對界) 的要求之外, 接著就是:

  1. 結構本身如何對界?
  2. 結構的大小需不需要 padding? 如果要 padding, 必需 padding 成多少?

答案是: 不論是結構的對界或者結構的大小都必需以 max(所有成員的對界值) (或者指定的對界, 二者取其小者) 為基準值. 為什麼呢? 試想: 大小為 4 bytes 的成員 (例如: 資料型態為 unit32_t), 它相對於結構的啓始位置一定是 0 或者是 4 bytes 的倍數. 因此結構的啓始地址只要對齊了 4 bytes 倍數的地址, 則該成員自然也對齊了 4 bytes 倍數的地址. 但是大小為 4 bytes 的成員地址對齊了, 不見得大小為 8 bytes 的成員地址也是對齊的 (以 64 位元 CPU 為例). 所以, 最終當然是依照成員裡對界值最大的那一個來調整結構的啓始位置啊, 同時也要依照它來調整結構的大小. 因為唯有如此, 才能在以 "結構為元素" 定義陣列時, 原本用在陣列上的地址計算規則依然完全正確. 來看下面的例子:

	struct _STA {
		uint8_t  ch[3];
		uint32_t sz[2];
	};
	struct _STB {
		uint8_t  ch[3];
		uint64_t sz;
	};

	struct {
		uint32_t sz1[9];
		struct _STA sta;
	} st1;

	struct {
		uint32_t sz1[9];
		struct _STB stb;
	} st2;

如果把上面這個例子以 64 位元的 C 編譯器編譯, 則幾個結構的大小分別是:

  • struct _STA大小是 12 bytes, 必需對齊到 4 bytes 的邊界.
    • sz 的陣列元素大小是 4 bytes, 整體是 8 bytes, 但必需對齊到 4 bytes 的邊界.
    • ch 原始大小 3 bytes, 多加 1byte padding, 變成 4 的整數倍以調整 sz 啟始地址.
    • 所以變成 4+8=12 bytes.
    • 12bytes 符合結構大小必需對齊到 4 bytes (uint32_t) 的整數倍.
  • struct _STB大小是 16 bytes, 必需對齊到 8 bytes 的邊界.
    • sz 的大小是 8 bytes, 必需對齊到 8 bytes 的邊界.
    • ch 原始大小 3 bytes, 多加 5byte padding, 變成 8 的整數倍以調整 sz 啟始地址.
    • 所以變成 8+8=16 bytes.
    • 16bytes 符合結構大小必需對齊到 8 bytes (uint64_t) 的整數倍.
  • st1大小是 48 bytes, 必需對齊到 4 bytes 的邊界.
    • sta 大小是 12 bytes, 必需對齊到 4 bytes 的整數倍.
    • sz1 原始大小 36 bytes, 無需 padding.
    • 所以變成 36+12=48 bytes.
    • 48bytes 符合結構大小必需對齊到 4 bytes (uint32_t) 的整數倍.
  • st2大小是 56 bytes, 必需對齊到 8 bytes 的邊界.
    • stb 大小是 16 bytes, 必需對齊到 8 bytes 的整數倍.
    • sz1 原始大小 36 bytes, 多加 4 bytes padding, 變成 8 的整數倍以調整 stb 啟始地址..
    • 所以變成 40+16=56 bytes
    • 56bytes 符合結構大小必需對齊到 8 bytes (uint64_t) 的整數倍.

看一下 Repl.it 執行的結果, (按一下 "三角形" 執行符號即可)現在需要到 Replit.com 去執行了.

請注意如果我們將 struct _STA, struct _STB 內的二個成員位置交換, 其結果依然相同, 但是原因是不同的.

  • struct _STA大小是 12 bytes.
    • sz 的陣列整體是 8 bytes
    • ch 原始大小 3 bytes, 但結構大小必需對齊到 4 bytes (uint32_t) 的整數倍.
    • 所以變成 8+4=12 bytes.
  • struct _STB大小是 16 bytes.
    • sz 的大小是 8 bytes
    • ch 原始大小 3 bytes, 但結構大小必需對齊到 8 bytes (uint64_t) 的整數倍.
    • 所以變成 8+8=16 bytes.

補空 (padding) 造成的問題及補救方法


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

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

註三: 把預設的 padding 值改小雖然可以達成結構中的成員地址符合資料結構上的對位需求, 但是可能也會同時引發機器當機 (原因如註二). 因此傳輸用的封包結構必需小心設計. 像是 Modbus TCP 的封包格式中就有這麼一個陷阱 (例如: FC=0x04 的回應封包格式中, 由於長度計數為 1byte 而造成後續的 uint16_t 資料都在奇數地址上).

struct alignment

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

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

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

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

同位 union


union 和 struct 的主要不同點在於: struct {} 的大括號內定義的每一成員都會佔據一份對應大小的記憶體; 而 union {} 的大括號內定義的每一成員則是共用同一份記憶體.

所以計算 struct 的大小時, 我們先依次將每一個成員元素排放在它們的正確對界地址上 (所以整個 struct 大小逐漸的膨脹, 可能多出一些成員與成員之間的 padding). 最後 struct 的大小應調整成 max(所有成員的對界值) (或者指定的對界數值, 二者取數值小者) 的整數倍 (可能又多了一些 padding).

而計算 union 的大小時, 則省略了依次排放每一個成員元素的步驟 (因為所有成員都疊在同一地址上) 只需找到 size 最大的成員即可. 最後一樣的將 union 的大小調整成 max(所有成員的對界值) (或者指定的對界數值, 二者取數值小者) 的整數倍. 例如以下的例子:

	union _U0 {
		uint8_t  ch[11];
		uint8_t  sz;
	} u0;

	union _U1 {
		uint8_t  ch[11];
		uint32_t sz;
	} u1;

	union _U2 {
		uint8_t  ch[11];
		uint64_t sz;
	} u2;

例子中各個同位變數 size 最大的成員都是 11 byte 的ch[11], 但是依照 max(所有成員的對界值) 調整 union 的大小之後, 幾個變數所佔用的記憶體大小分別是 u0 11 byte, u1 12 byte, u2 16 byte (以 64 位元的 C 編譯器編譯).

計算 union 的大小及補空的原則和 struct 相同, 不再贅述. 整個 alignment 的原則整理如下:

  • 多層次結構 struct 或同位 union 的處理原則都是由最底層開始.
  • 對界: 依 自然對界原則 (或者指定的對界數值, 二者取數值小者) 進行對齊.
    • 基本資料型態只進行啟始地址對齊, 大小不補空.
    • 結構 struct 中的每一個基本資料型態成員, 依上項原則只需進行啟始地址對齊, 大小不補空 (補空只計為 struct 整體的大小, 但不計入成員自身的大小).
    • 子結構 struct (或子同位 union) 的對齊地址為 max(所有子成員的對界值) (或者指定的對界數值, 二者取數值小者).
  • 補空: 只有結構 struct 及同位 union 需要針對其整體大小進行補空. 其大小應調整為 max(所有成員的對界值) (或者指定的對界數值, 二者取數值小者) 的整數倍.

陣列 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 之後, 再以答案當作地址來取出字元". 所以二種寫法編譯出來的程式大小不同, 執行速度也是不同.

註四: 變數 strX 如果是區域變數, 它的元素應該是放在 stack 裡. 如果是全域變數則是 .data 段落中. 變數 strY 變數本身的位置和變數 strX 一樣, 但是它的字串資料則是放在 .text 或是 .rodata 段落中.

補充說明


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

另外, strlen() 雖然是 C 的標準函數, 但是一部份現代的 C 編譯器也會在最佳化時固定字串的 strlen() 直接換算成正確的長度數值填充在程式中, 而不是程式執行的時候才去計算結果. (參看 StackOverflow determining the length of a string literal)

考題補充


最後來一題 union 大小的題目, 請問 struct B 的大小是?

union _unionA {
	int a[5];
	char b;
	double c;
};

struct B {
	int n;
	_unionA a;
	char c[10];
}

再來一個據說是 Intel、微軟等大公司曾經出過的面試 C++ 考題:

#include <iostream.h>
#pragma pack(8)
struct example1 {
	short a;
	long b;
};
struct example2 {
	char c;
	example1 struct1;
	short e;
};
#pragma pack()

int main(int argc, char* argv[])
{
	example2 struct2;
	cout << sizeof(example1) << endl;
	cout << sizeof(example2) << endl;
	cout << (unsigned int)(&struct2.struct1) - (unsigned int)(&struct2) << endl;
	return 0;
}

答案就不貼了, 請你自己到 C++ 線上編譯器 上試一試了.

arrow
arrow
    創作者介紹
    創作者 MagicJackTing 的頭像
    MagicJackTing

    傑克! 真是太神奇了!

    MagicJackTing 發表在 痞客邦 留言(7) 人氣()