了解 SAL
Microsoft 原始程式碼批註語言 (SAL) 提供一組批註,可用來描述函式如何使用其參數、它對其做出的假設,以及它完成時所做的保證。 批註定義于標頭檔中 <sal.h>
。 適用于 C++ 的 Visual Studio 程式碼分析會使用 SAL 注釋來修改其函式分析。 如需有關 SAL 2.0 for Windows 驅動程式開發的詳細資訊,請參閱 適用于 Windows 驅動程式 的 SAL 2.0 注釋。
原生而言,C 和 C++ 只提供有限的方式讓開發人員一致表達意圖和不變。 藉由使用 SAL 批註,您可以更詳細地描述函式,讓取用函式的開發人員可以進一步瞭解如何使用它們。
什麼是 SAL,為什麼您應該使用它?
簡單地說,SAL 是一種廉價的方式,可讓編譯器檢查您的程式碼。
SAL 讓程式碼更有價值
SAL 可協助您讓程式碼設計更容易理解,無論是針對人類還是程式碼分析工具。 請考慮顯示 C 執行時間函 memcpy
式的這個範例:
void * memcpy(
void *dest,
const void *src,
size_t count
);
您是否可以判斷此函式有何作用? 實作或呼叫函式時,必須維護特定屬性,以確保程式正確性。 只要查看範例中的宣告,即表示您不知道它們是什麼。 如果沒有 SAL 批註,您必須依賴檔或程式碼批註。 以下是檔 memcpy
說明的內容:
「
memcpy
會從 src 複製 計數 位元組到 dest ;wmemcpy
複製會 計算 寬字元數(兩個位元組)。 如果來源和目的地重疊,則memcpy
的行為是未定義。 使用memmove
處理重疊的區域。
重要事項: 請確定目的地緩衝區的大小或大於來源緩衝區。 如需詳細資訊,請參閱避免緩衝區滿溢。」
檔包含幾個資訊,建議程式碼必須維護特定屬性,以確保程式正確性:
memcpy
將count
來源緩衝區中的位元組複製到目的地緩衝區。目的地緩衝區必須至少和來源緩衝區一樣大。
不過,編譯器無法閱讀檔或非正式批註。 它不知道兩個緩衝區和 count
之間有關聯性,而且也無法有效地猜測關聯性。 SAL 可以更清楚說明函式的屬性和實作,如下所示:
void * memcpy(
_Out_writes_bytes_all_(count) void *dest,
_In_reads_bytes_(count) const void *src,
size_t count
);
請注意,這些批註與檔中的資訊類似,但它們更簡潔,而且遵循語意模式。 當您閱讀此程式碼時,您可以快速瞭解此函式的屬性,以及如何避免緩衝區溢位安全性問題。 更棒的是,SAL 提供的語意模式可以提升自動化程式碼分析工具在早期探索潛在 Bug 的效率與有效性。 假設有人撰寫的這個 Buggy 實作 wmemcpy
:
wchar_t * wmemcpy(
_Out_writes_all_(count) wchar_t *dest,
_In_reads_(count) const wchar_t *src,
size_t count)
{
size_t i;
for (i = 0; i <= count; i++) { // BUG: off-by-one error
dest[i] = src[i];
}
return dest;
}
此實作包含一般非同一錯誤。 幸運的是,程式碼作者包含 SAL 緩衝區大小批註,程式碼分析工具可以單獨分析此函式來攔截 Bug。
SAL 基本概念
SAL 定義四種基本類型的參數,這些參數會依使用模式分類。
類別 | 參數批註 | 描述 |
---|---|---|
呼叫函式的輸入 | _In_ |
資料會傳遞至呼叫的函式,並視為唯讀。 |
呼叫函式的輸入,以及呼叫端的輸出 | _Inout_ |
可使用的資料會傳遞至 函式,並可能經過修改。 |
呼叫端的輸出 | _Out_ |
呼叫端只提供呼叫函式寫入的空間。 呼叫的函式會將資料寫入該空間。 |
呼叫端指標的輸出 | _Outptr_ |
就像呼叫端 的輸出一樣 。 所呼叫函式傳回的值是指標。 |
這四個基本注釋可以透過各種方式更明確。 根據預設,批註指標參數會假設為必要參數,它們必須是非 Null,函式才能成功。 基本批註最常使用的變化表示指標參數是選擇性的,如果它是 Null,函式仍然可以成功執行其工作。
下表說明如何區分必要和選擇性參數:
需要參數 | 參數是選擇性的 | |
---|---|---|
呼叫函式的輸入 | _In_ |
_In_opt_ |
呼叫函式的輸入,以及呼叫端的輸出 | _Inout_ |
_Inout_opt_ |
呼叫端的輸出 | _Out_ |
_Out_opt_ |
呼叫端指標的輸出 | _Outptr_ |
_Outptr_opt_ |
這些批註可協助識別可能的未初始化值,並以正式且精確的方式使用不正確 Null 指標。 將 Null 傳遞至必要的參數可能會導致當機,或可能會導致傳回「失敗」錯誤碼。 無論哪種方式,函式都無法成功執行其工作。
SAL 範例
本節顯示基本 SAL 注釋的程式碼範例。
使用 Visual Studio Code 分析工具尋找瑕疵
在範例中,Visual Studio Code Analysis 工具會與 SAL 注釋搭配使用,以尋找程式碼缺失。 方法如下所示。
使用 Visual Studio 程式碼分析工具和 SAL
在 Visual Studio 中,開啟包含 SAL 批註的 C++ 專案。
在功能表列上,選擇 [建置 ]、 [在方案 上執行程式碼分析]。
請考慮本節中的 _In_ 範例。 如果您對此執行程式碼分析,則會顯示此警告:
C6387 不正確參數值 'pInt' 可以是 '0':這不符合函式 'InCallee' 的規格。
範例:_in_ 注釋
批 _In_
注指出:
參數必須有效且不會修改。
函式只會從單一專案緩衝區讀取。
呼叫端必須提供緩衝區並將其初始化。
_In_
會指定 「唯讀」。 常見的錯誤是套用_In_
至應該有注釋的參數_Inout_
。_In_
允許,但由非指標純量上的分析器忽略。
void InCallee(_In_ int *pInt)
{
int i = *pInt;
}
void GoodInCaller()
{
int *pInt = new int;
*pInt = 5;
InCallee(pInt);
delete pInt;
}
void BadInCaller()
{
int *pInt = NULL;
InCallee(pInt); // pInt should not be NULL
}
如果您在此範例上使用 Visual Studio Code Analysis,它會驗證呼叫端是否將非 Null 指標傳遞至 的 pInt
初始化緩衝區。 在此情況下, pInt
指標不可以是 Null。
範例:_In_opt_注釋
_In_opt_
與 _In_
相同,不同之處在于允許輸入參數為 Null,因此函式應該檢查此專案。
void GoodInOptCallee(_In_opt_ int *pInt)
{
if(pInt != NULL) {
int i = *pInt;
}
}
void BadInOptCallee(_In_opt_ int *pInt)
{
int i = *pInt; // Dereferencing NULL pointer 'pInt'
}
void InOptCaller()
{
int *pInt = NULL;
GoodInOptCallee(pInt);
BadInOptCallee(pInt);
}
Visual Studio Code Analysis 會先驗證函式在存取緩衝區之前檢查 Null。
範例:_Out_ 注釋
_Out_
支援一般案例,其中會傳入指向專案緩衝區的非 Null 指標,而函式會初始化專案。 呼叫端不需要在呼叫之前初始化緩衝區;呼叫的函式會承諾在傳回之前將其初始化。
void GoodOutCallee(_Out_ int *pInt)
{
*pInt = 5;
}
void BadOutCallee(_Out_ int *pInt)
{
// Did not initialize pInt buffer before returning!
}
void OutCaller()
{
int *pInt = new int;
GoodOutCallee(pInt);
BadOutCallee(pInt);
delete pInt;
}
Visual Studio Code Analysis Tool 會驗證呼叫端是否將非 Null 指標傳遞給 緩衝區 pInt
,而且緩衝區在傳回之前會由函式初始化。
範例:_Out_opt_批註
_Out_opt_
與 _Out_
相同,不同之處在于參數允許為 Null,因此函式應該檢查此專案。
void GoodOutOptCallee(_Out_opt_ int *pInt)
{
if (pInt != NULL) {
*pInt = 5;
}
}
void BadOutOptCallee(_Out_opt_ int *pInt)
{
*pInt = 5; // Dereferencing NULL pointer 'pInt'
}
void OutOptCaller()
{
int *pInt = NULL;
GoodOutOptCallee(pInt);
BadOutOptCallee(pInt);
}
Visual Studio Code Analysis 會驗證此函式在取值之前 pInt
檢查 Null,如果 pInt
不是 Null,則會先由函式初始化緩衝區再傳回。
範例:_inout_ 注釋
_Inout_
用來標注函式可能變更的指標參數。 指標必須在呼叫之前指向有效的初始化資料,即使它變更,它仍必須有有效的傳回值。 批註指定函式可以自由讀取和寫入至單一元素緩衝區。 呼叫端必須提供緩衝區並將其初始化。
注意
如同 _Out_
, _Inout_
必須套用至可修改的值。
void InOutCallee(_Inout_ int *pInt)
{
int i = *pInt;
*pInt = 6;
}
void InOutCaller()
{
int *pInt = new int;
*pInt = 5;
InOutCallee(pInt);
delete pInt;
}
void BadInOutCaller()
{
int *pInt = NULL;
InOutCallee(pInt); // 'pInt' should not be NULL
}
Visual Studio Code Analysis 會驗證呼叫端是否將非 Null 指標傳遞至 的 pInt
已初始化緩衝區,而且在傳回之前, pInt
仍為非 Null 且緩衝區已初始化。
範例:_Inout_opt_批註
_Inout_opt_
與 _Inout_
相同,不同之處在于允許輸入參數為 Null,因此函式應該檢查此專案。
void GoodInOutOptCallee(_Inout_opt_ int *pInt)
{
if(pInt != NULL) {
int i = *pInt;
*pInt = 6;
}
}
void BadInOutOptCallee(_Inout_opt_ int *pInt)
{
int i = *pInt; // Dereferencing NULL pointer 'pInt'
*pInt = 6;
}
void InOutOptCaller()
{
int *pInt = NULL;
GoodInOutOptCallee(pInt);
BadInOutOptCallee(pInt);
}
Visual Studio Code Analysis 會先驗證此函式在存取緩衝區之前檢查 Null,如果 pInt
不是 Null,則會先由函式初始化緩衝區再傳回。
範例:_Outptr_ 注釋
_Outptr_
用來標注要傳回指標的參數。 參數本身不應該是 Null,而呼叫的函式會傳回其中非 Null 指標,且該指標指向初始化的資料。
void GoodOutPtrCallee(_Outptr_ int **pInt)
{
int *pInt2 = new int;
*pInt2 = 5;
*pInt = pInt2;
}
void BadOutPtrCallee(_Outptr_ int **pInt)
{
int *pInt2 = new int;
// Did not initialize pInt buffer before returning!
*pInt = pInt2;
}
void OutPtrCaller()
{
int *pInt = NULL;
GoodOutPtrCallee(&pInt);
BadOutPtrCallee(&pInt);
}
Visual Studio Code Analysis 會驗證呼叫端是否傳遞 的非 Null 指標 *pInt
,而且緩衝區會在傳回之前由函式初始化。
範例:_Outptr_opt_注釋
_Outptr_opt_
與 _Outptr_
相同,不同之處在于參數是選擇性的,呼叫端可以傳入參數的 Null 指標。
void GoodOutPtrOptCallee(_Outptr_opt_ int **pInt)
{
int *pInt2 = new int;
*pInt2 = 6;
if(pInt != NULL) {
*pInt = pInt2;
}
}
void BadOutPtrOptCallee(_Outptr_opt_ int **pInt)
{
int *pInt2 = new int;
*pInt2 = 6;
*pInt = pInt2; // Dereferencing NULL pointer 'pInt'
}
void OutPtrOptCaller()
{
int **ppInt = NULL;
GoodOutPtrOptCallee(ppInt);
BadOutPtrOptCallee(ppInt);
}
Visual Studio Code Analysis 會先驗證此函式在取值之前 *pInt
檢查 Null,而且該函式在傳回之前先由函式初始化。
範例:_Success_ 注釋與 _Out_ 結合
批註可以套用至大多數物件。 特別是,您可以標注整個函式。 函式最明顯的特性之一是它可以成功或失敗。 但是,如同緩衝區與其大小之間的關聯,C/C++ 無法表示函式成功或失敗。 藉由使用 _Success_
批註,您可以說函式的成功是什麼樣子。 批註的參數 _Success_
只是當其為 true 表示函式成功時的運算式。 運算式可以是注釋剖析器可以處理的任何專案。 函式傳回之後注釋的效果僅適用于函式成功時。 此範例示範如何 _Success_
與 _Out_
互動以執行正確的動作。 您可以使用 關鍵字 return
來表示傳回值。
_Success_(return != false) // Can also be stated as _Success_(return)
bool GetValue(_Out_ int *pInt, bool flag)
{
if(flag) {
*pInt = 5;
return true;
} else {
return false;
}
}
注釋 _Out_
會讓 Visual Studio Code Analysis 驗證呼叫端是否將非 Null 指標傳遞給 的緩衝區 pInt
,而且緩衝區會在傳回之前由 函式初始化。
SAL 最佳做法
將批註新增至現有的程式碼
SAL 是一項功能強大的技術,可協助您改善程式碼的安全性和可靠性。 學習 SAL 之後,您可以將新技能套用至日常工作。 在新的程式碼中,您可以透過設計方式使用 SAL 型規格;在舊版程式碼中,您可以累加新增批註,進而在每次更新時增加優點。
Microsoft 公用標頭已加上批註。 因此,建議您在您的專案中先標注呼叫 WIN32 API 的分葉節點函式和函式,以取得最大效益。
何時標注?
以下是一些指導方針:
標注所有指標參數。
標注值範圍批註,讓程式碼分析可以確保緩衝區和指標安全性。
標注鎖定規則和鎖定副作用。 如需詳細資訊,請參閱 標注鎖定行為 。
標注驅動程式屬性和其他網域特定屬性。
或者,您可以標注所有參數,讓整個意圖清楚,並輕鬆地檢查是否已完成批註。