用編譯時聲明在早期發現錯誤
上網時間 : 2005年11月02日
一段時間以來,筆者一直在討論如何在C和C++中使用結構來定義記憶體映射元件暫存器的佈局,並曾討論了可以用來為相應暫存器給每個結構成員以合適的尺寸和排列。然而,不同的平台對數據的排列和填充不一樣。因此,一個特定的結構定義對一個平台能正確佈局結構成員,但對另外一個不同的平台進行編譯時,可能會產生錯誤的佈局。
一種不正確的佈局結構在編譯時常常沒有警告出現,但是最終的程式在執行時不能依所期望的那樣工作。你可以改進程式碼而不用費時費力地除錯,這樣編譯器能發現佈局錯誤。其中的技巧就是利用聲明(assertion),聲明能在結構成員出現尺寸或者排列錯誤時產生明顯的編譯時 (compile-time)錯誤。
C和C++提供了實現聲明的不同方法。筆者偏愛於那些能提供標準C聲明巨集編譯時對等量的方法。我們從簡要了解一下這個巨集開始。
執行時(Run-time)聲明
聲明巨集在標準的C頭文件和標準的C++頭文件中定義。形式為:
assert(condition);
的調用擴展到測試條件的程式碼。如果條件為真(產生一個非零值),將什麼也不會產生。即在巨集調用之後,程式繼續執行下一個語句。另一方面,如果條件為假(等於零),程式寫一個診斷消息到stderr(標準錯誤串流),並透過調用標準中斷函數終止程式的執行。
聲明巨集能幫助在程式中發現邏輯錯誤。例如,假設調用get_token(f, t, n)掃描來自FILE *f的輸入,並將掃描的輸入拷貝到字符數組中,以*t開頭,長度為n,可以在get_token中調用聲明來發現錯誤的自變量值,否則將導致產生不確定的行為,如下面的函數:
bool get_token(FILE *f, char *t, size_t n)
{
...
assert(f != NULL);
assert(t != NULL);
assert(n >= 2);
...
}
如果程式不經意地用一個空指針作為第一個自變量來調用get_token函數,第一個聲明將向stderr寫入一個消息,並中斷執行。對於大多數編譯器來說,消息看起來類似於:
Assertion failed: f != NULL, file get_token.c, line 18
將聲明寫入到程式碼中能幫助進行歸檔,並提高開發程式碼的成功率。然而,因為是寫到stderr,在缺乏對標準C I/O系統支援的嵌入式環境中,標準的聲明巨集毫無用處。然而,寫你自己的聲明巨集版、在別的地方顯示消息並不那麼困難。
儘管聲明巨集可以是一種有用的除錯輔助手段,但並不適合於處理在已付運的終端用戶產品中的執行時錯誤。已付運的產品應該產生對於一般終端用戶來說很有意義的診斷消息。它還應該比透過調用中斷函數更可靠地恢復或關斷程式的執行。因此,提供了一種在原始程式碼很少或不改變的情況下使所有聲明無效的簡單方法。開發者可以在程式碼中保留聲明作為文檔,但是應該使它們不會產生程式碼。
如果在包入之前在原始文件中定義NDEBUG巨集,聲明巨集將這樣簡單地定義:
#define assert(cond) ((void)0)
因此,隨後的調用諸如:
assert(f != NULL);
擴展為:
((void)0);
編譯器能夠優化這個語句,使其根本不會產生程式碼。
可以在包入指令前,將NDEBUG的定義寫入到原始程式碼中:
#define NDEBUG
#include }
這種方法的問題是,每次想打開或者關閉聲明的時候必須修改原始程式。大多數編譯器允許在調用編譯器的時候,透過使用命令行自變量來定義巨集,通常選擇D選項。例如,像下面的命令行出現在原始程式的第一行之前,則將聲明關斷:
cc -DNDEBUG get_token.c
compiles get_token.c as if:
#define NDEBUG
使用預處理程式的編譯時聲明
開發者可以使用聲明來驗證記憶體映射結構成員具有正確的尺寸和排列。例如,假設像下面這樣為一個定時器定義元件暫存器:
typedef struct timer timer;}
struct timer
{
uint8_t MODE;
uint32_t DATA;
uint32_t COUNT;
};
可以使用一個聲明和offsetof巨集來驗證DATA成員在結構內部具有偏移4,如下:
assert(offsetof(timer, DATA) == 4);
在標準C頭文件和標準C++頭文件中定義了tffsetof巨集。一種fsetof(t, m)形式的表達式從結構類型t的開始處返回成員m的偏移,偏移佔若干位元組。
這個聲明確實解決了一個潛在的排列問題,但是並不十分理想。使用聲明來檢查一個結構成員的偏移將應該在編譯時完成的檢查延遲到執行時。對聲明的調用只可以出現在函數內,因此不得不將調用包含在一個函數里,將這個函數作為程式啟動的一部份調用,或者緊接著程式啟動調用這個函數。
這裡需要澄清的是,筆者並不是建議每個聲明都可以在編譯時檢查。例如,像測試一個變量的值的聲明如:
assert(f != NULL);
必須在執行時完成。然而,測試一個常數表達式的值的聲明,例如一個結構成員的尺寸和偏移,可以在編譯時完成。
對於只涉及常數表達式的聲明來說,一些C和C++編譯器將允許使用一個預處理程式條件語句來測試聲明,如下列語句:
#if (offsetof(timer, DATA) != 4)}
#error DATA must be at offset 4 in timer
#endif
利用這種方法,編譯器在編譯時評估這個條件─實際上是在預處理期間。如果聲明失敗(#if條件為真),預處理程式執行#error指令,這個指令顯示一個包含指令中的文本的消息,並結束編譯。不同的編譯器的消息形式各不相同,但可發現一些相似之處:
timer.h, line 14: #error: DATA must be at offset 4 in timer
使用#error指令使你能寫出非常清楚的診斷消息。
因為這種方法在編譯時評估聲明,因此聲明不會存在任何執行時的代價,因此你可以不必將聲明關閉。一個程式的編譯時聲明失敗只會導致編譯失敗。
與聲明調用不同的是,預處理指令可以出現在任何地方─全局、局部,或者甚至是在一個類或者結構定義內部,而聲明調用只能出現在函數體內。
儘管有這些優勢,使用#if指令實現聲明至少會有幾個問題。第一個問題不太嚴重,即必須對#if語句中的聲明條件求反,在這個語句中通常你會使用聲明巨集來寫。例如,為測試定時器的DATA成員的偏移為4,依下面的語句寫執行時聲明:
assert(offsetof(timer, DATA) = = 4);
為測試在編譯時的相同條件,需要用運算符!=替換= =,如:
#if (offsetof(timer, DATA) != 4)}
#error ...
#endif
或者在邏輯上對整個條件求反,如:
#if (!(offsetof(timer, DATA) == 4))
#error ...
#endif
或者不管條件,將#error指令放到#else部份,如:
#if (offsetof(timer, DATA) == 4)}
#else
#error ...
#endif
第二個問題是關於使用#if指令來實現聲明,這個問題比較嚴重:標準C和C++不識別在#if條件中的sizeof和 offsetof。它們也不能識別在#if條件中的枚舉常數。一些編譯器允許在#if中作為擴展存在sizeof、offsetof和枚舉常數,但是大多數編譯器是不允許的。慶幸的是,你可以以另外一種沒有這種限制的方式來寫編譯時聲明。
編譯時聲明的無效聲明
在C和C++中,規定一個數組聲明中元素數量的一個常數表達式必須具有一個正值。例如:
int w[10];
int x[1];
是有效的數組聲明,而int y[0];不是。一個常數維數組具有多個作業數和運算符,包括sizeof和offsetof子表達式,例如:
int z[2 * sizeof(w) / sizeof(w[0])];
這聲明數組z具有兩倍於數組w的元素。
開發者可以利用常數數組的維數必須是正數的要求,來以巨集的形式實現編譯時聲明:
#define compile_time_assert(cond)
char assertion[(cond) ? 1 : 0]
如果x是一個評估為真的表達式,於是調用:
compile_time_assert(x);
擴展到一個有效的數組聲明(一維)。否則,擴展到一個無效的數組聲明(0維),這個數組聲明產生一個編譯時診斷消息(錯誤或者警告)。
然而,當聲明失敗時錯誤消息的文本對於不同的編譯器是不同的。筆者看到過的消息如『數組必須至少具有一個元素’,或者『無效的腳本或者腳本過大’。
如果幸運,編譯器產生包含數組名的一個消息,例如數組大小『聲明’為零。在那種情況下,它幫助使數組名成為一個額外的巨集參數,如下:
#define compile_time_assert(cond, msg)
char msg[(cond) ? 1 : 0]
然後,你可以使用數組名來描述聲明失敗的原因。例如,如果調用:
compile_time_assert(offsetof(timer, DATA) == 4,}
DATA_must_be_at_offset_4);
造成一個聲明失敗,那麼將可能看到一個錯誤消息,類似於:
size of array 'DATA_must_be_at_offset_4' is zero
如上所述,這個巨集有一個很容易解決的小問題。這個問題是,在某些情況下,數組聲明可能是分配儲存的一個定義。可以將數組聲明轉變為typedef來避免這個問題,如:
#define compile_time_assert(cond, msg)
typedef char msg[(cond) ? 1 : 0]
在相同的範圍內,兩個typedefs不能具有相同的名字,因此必須使用msg參數來給每個typedef一個獨特的名字。如果不願意採用msg參數,那麼可以將數組聲明為外部數組,如下所示:
#define compile_time_assert(cond)
extern char assertion[(cond) ? 1 : 0]
然而,如果採用這種方法,將不能在C++類中使用巨集,因為你不能將C++類成員聲明為外部量。你可以發現編譯器不會對0大小的數組提出『抱怨’。在這種情況下,你可能嘗試將0改變為-1,如下所示:
#define compile_time_assert(cond, msg)
typedef char msg[(cond) ? 1 : -1]
Boost庫(www.boost.org)為C++程式設計師提供了另一種以稱為BOOST_STATIC_ASSERT 的巨集的形式實現編譯時聲明的方法。利用C++模板可以巧妙地實現這個巨集。如果你是一個C++程式設計師,而且你理解模板的特殊性,你可以進行嘗試。
作者: Dan Saks
總裁
Email:dsaks@wittenberg.edu
Saks & Associates公司