共用方式為


了解 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 處理重疊的區域。
重要事項: 請確定目的地緩衝區的大小或大於來源緩衝區。 如需詳細資訊,請參閱避免緩衝區滿溢。」

檔案包含幾個資訊,建議程式代碼必須維護特定屬性,以確保程序正確性:

  • memcpycount 來源緩衝區中的位元組複製到目的地緩衝區。

  • 目的地緩衝區必須至少和來源緩衝區一樣大。

不過,編譯程式無法閱讀檔或非正式批注。 它不知道兩個緩衝區和 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

  1. 在 Visual Studio 中,開啟包含 SAL 批注的C++專案。

  2. 在功能表欄上,選擇 [建置]、 [在方案上執行程序代碼分析]。

    請考慮本節中的 _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 的分葉節點函式和函式,以取得最大效益。

何時標註?

以下是一些指導方針:

  • 標註所有指標參數。

  • 標註值範圍批註,讓程式代碼分析可以確保緩衝區和指標安全性。

  • 標註鎖定規則和鎖定副作用。 如需詳細資訊,請參閱 標註鎖定行為

  • 標註驅動程式屬性和其他網域特定屬性。

或者,您可以標註所有參數,讓整個意圖清楚,並輕鬆地檢查是否已完成批註。

另請參閱