摘要訊息 : 對位的記憶體能夠極大地提升程式效能.

0. 前言

C 和 C++ 都有記憶體對位的機制, 很多程式設計語言也都繼承了這個機制. 記憶體是否對位對於程式效能的影響很大. 在 C++ 11 還引入了標識符 alignas 與運算子 alignof, 這兩個是專門處理記憶體對位的關鍵字.

對於結構體

struct Foo {
    char c[3];
    int i;
};

來說, sizeof(Foo) 的值是多少呢? 在 64 位元的作業系統下, char 型別的大小為 1, int 型別的大小為 4, 那麼 sizeof(Foo) 的值本應該是 7. 然而預設情況下, sizeof(Foo) 的值是 8. 接下來我們就來分析 C++ 中的記憶體對位.

更新紀錄 :

  • 2022 年 4 月 26 日進行第一次更新和修正.

1. 記憶體對位

對於某一個固定的型別, 硬體是按照一定的倍數對記憶體進行存取的. 例如對於 64 位元的作業系統來說, 一個 int 型別的大小為 4. 因此, 硬體是按照 4 的倍數進行存取的. 若從記憶體位址 0x000000000010 (簡寫為 0x10, 這是一個十六進制數) 開始, 存取一個 int 型別的變數, 那麼這個 int 型別的變數在記憶體中儲存的位址是 0x10 - 0x13. 如果 0x14 上也儲存著一個 int 型別的變數, 那麼其儲存的記憶體位址是 0x14 - 0x17.

給定一個記憶體位址和型別, 要測試是否對位, 我們可以使用 \displaystyle {\text {記憶體位址 } \mod \text { 型別大小}}. 當結果為 0, 則說明記憶體對位; 否則, 就說明記憶體不對位.

那麼記憶體對位為什麼那麼重要呢? 假設現在一個 int 變數儲存在 0x6 - 0x9 上, 要存取這個變數, 硬體首先會存取 0x4, 向後存取四個位元得到 0x60x7 中的資料; 然後存取 0x8, 向後存取四個位元得到 0x80x9 中的資料, 最後將 0x6, 0x70x8, 0x9 這兩段資料拼接起來的到一個 int 的值. 如果這個 int 變數儲存在 0x4 上, 那麼只需要存取 0x4 開始的四個位元中的資料即可, 不需要拼接.

記憶體對位與不對位相比較, 首先是效率問題. 不對位的記憶體要存取的次數比對位的記憶體要存取的次數多, 另外不對位的記憶體資料需要進行拼接. 然後是出錯的問題, 對於現代的民用電腦來說, 很少會存在這種問題, 但是對於有些 CPU 架構, 若存取的記憶體並不是對位的, 那麼硬體可能會擲出例外情況甚至拒絕存取的請求.

C/C++ 中的記憶體對位是以 2^{k} 為基礎, 並且以結構體或者類別中 sizeof 值最大的那個型別為基底. 例如第 0 節中的結構體 Fooint 作為基底, 對位的大小為 4 = 2^{2}. 結構體

struct s1 {
    short a;
    struct {
        char a[16];
        int b;
    } b;
};

是以 int 對位的, 所以 sizeof(s1) 的值為 24. 那麼成員變數 a 之後就會有 2 位元組的資料一直是空的, 如果不想浪費的話我們可以改為

struct s2 {
    short a;
    short b;
    struct {
        char a[16];
        int b;
    } c;
};

sizeof(s2) 的值仍然為 24. 將巢狀類別中的 b 的型別從 int 修改為 long, 那麼 sizeof(s2) 的值會變成 32. 此時, s2 的成員變數 b 之後會有 4 位元組的資料一直是空的.

2. #pragma pack

有些時候, 我們可能需要儘量節省記憶體, 寧可用拼接資料的時間來換取記憶體. 這個時候, 我們需要借助前處理指令 #pragma pack(n). 其中, n 小於 0 的話, 編碼器會擲出編碼警告並且忽略這條前處理指令. 另外, 如果宣告 #pragma pack(0), 那麼編碼器就會按照預設的對位方案進行對位.

對於第 1 節中的結構體 s1, 我們說過, 成員變數 b 後面有兩個位元組是空著的, 為了節省這四個位元組的記憶體, 我們可以宣告 #pragma pack(1). 這樣, sizeof(s1) 的值為 22.

3. alignasalignof

有時候, 我們想要將記憶體的對位擴大, 例如第 1 節中的結構體 s2, 它是以 int 型別的大小作為對位基底的, 如果我們想要在不改變 s2 的情況下, 把記憶體對位擴大到十六位元組, 那麼可以使用 alignas 標識符. alignas 標識符接受一個參數, 這個參數可以是型別, 表達式和整型常數表達式. 對於 alignas(T), 就是讓類別的對位和型別 T 相同; 對於 alignas(expression), 就是讓類別的對位和表達式 expression 對應的型別 decltype(expression) 相同; 對於 alignas(n), 就是設定類別的記憶體對位大小為 n. 那麼 s2 可以寫成

struct alignas(long) s2 {
    short a;
    short b;
    struct {
        char a[16];
        int b;
    } c;
};

此時, sizeof(s2) 的值為 32, 不再是 24, 因為 24 不是 16 的倍數.

如果要查詢一個類別的記憶體對位情況, 可以使用 alignof 運算子, 它的用法和 sizeof 類似. 唯一的不同是, C++ 標準暫時還不允許計算一個表達式的記憶體對位情況, 即 alignof(expression) 或者 alignof expression 是暫時不被允許的. 但是部分編碼器 (例如 Apple Clang 和 GCC) 提供了擴展, 讓 alignof 也可以支援表達式的記憶體對位計算.

在對類別使用 alignas 標識符的時候, 需要注意, 如果 alignas 中的值小於類別中任意成員的 alignof 值, 那麼就會產生編碼錯誤. 所以說, alignas 只能擴大記憶體對位, 不能像 #pragma pack 那樣縮小記憶體對位.

alignas 允許標識在結構體, 類別, 等位和列舉中, 也可以標識在一個普通變數上, 例如 alignas(8) int a;. 這樣, sizeof a 的值是 4, alignof a (若允許計算) 的值為 8.