了解 SAL

Microsoft 源代码注释语言 (SAL) 提供一组描述函数如何使用其参数的注释,有关注释的假设以及完成注释时的保证。 注释是在头文件 <sal.h> 中定义的。 适用于 C++ 的 Visual Studio 代码分析使用 SAL 注释来修改其函数分析。 有关适用于 Windows 驱动程序开发的 SAL 2.0 的详细信息,请参阅适用于 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 中的 count 个字节复制到 dest;wmemcpy 复制 count 个宽字符(两个字节)。 如果源和目标重叠,则 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 的效率和有效性。 假设编写了以下存在错误的 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 传递给必需参数可能会导致崩溃,或者可能会导致返回“失败”错误代码。 无论哪种情况,函数都无法成功完成其工作。

SAL 示例

本部分演示了基本 SAL 注释的代码示例。

使用 Visual Studio 代码分析工具查找 Bug

在这些示例中,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,它将验证调用方是否将非空指针传递给 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_ 支持一种常见方案,即传入一个指向元素缓冲区的非空指针,并由函数初始化该元素。 调用方不必在调用之前初始化缓冲区;调用的函数承诺在返回结果之前对缓冲区进行初始化。

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 工具验证调用方是否将非空指针传递给 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,调用的函数将返回非空指针,且该指针指向已初始化的数据。

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 验证调用方是否为 *pInt 传递非空指针,以及函数是否在返回结果之前初始化缓冲区。

示例: _Outptr_opt_ 批注

_Outptr_opt__Outptr_ 相同,只不过该参数为可选参数,调用方可以为该参数传入空指针。

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,以及此函数是否在返回结果之前初始化缓冲区。

示例: 与 _Out_ 组合的 _Success_ 批注

大多数对象都可应用注释。 尤其可以对整个函数进行注释。 函数最明显的特征之一是它可以成功或失败。 但如缓冲区与其大小之间的关联一样,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 验证调用方是否将非空指针传递给 pInt 缓冲区,以及函数是否在返回结果之前初始化该缓冲区。

SAL 最佳做法

向现有代码中添加批注

SAL 是一种功能强大的技术,有助于提高代码的安全性和可靠性。 学习 SAL 后,你可以将新技能应用于日常工作。 在新的代码中,可以在整个设计过程中使用基于 SAL 的规范;在旧的代码中,可以增量添加注释,从而在每次更新时增加优势。

Microsoft 公用标头已经过注释。 因此,建议在项目中首先注释叶节点函数和调用 Win32 API 的函数,以获得最大益处。

何时批注?

下面是一些准则:

  • 注释所有指针参数。

  • 提供值范围注释,以便 Code Analysis 可以确保缓冲区和指针的安全。

  • 注释锁定规则和锁定副作用。 有关详细信息,请参阅注释锁定行为

  • 注释驱动程序属性和其他特定于域的属性。

或者,可以注释所有参数,以在整个过程中清晰表明你的意图,并轻松检查是否已完成注释。

另请参阅