了解 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 的分葉節點函式和函式,以取得最大效益。

何時標注?

以下是一些指導方針:

  • 標注所有指標參數。

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

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

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

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

另請參閱