Поделиться через


Основные сведения о языке SAL

Язык заметки исходного кода Майкрософт (SAL) предоставляет набор заметок, которые можно использовать для описания того, как функция использует свои параметры, предположения, которые она делает о них, и гарантии того, что она делает после завершения. Заметки определяются в заголовочном файле <sal.h>. Анализ кода Visual Studio для C++ использует заметки SAL для изменения его анализа функций. Дополнительные сведения о разработке драйверов SAL 2.0 для Windows см. в аннотациях SAL 2.0 для драйверов Windows.

В языке C и C++ изначально предоставляются лишь ограниченные средства для разработчиков, чтобы последовательно выражать намерения и инварианты. Используя заметки SAL, вы можете подробно описать функции, чтобы разработчики, использующие их, могли лучше понять, как их использовать.

Что такое SAL и почему его следует использовать?

Просто говоря, SAL является недорогим способом, чтобы компилятор проверял ваш код.

SAL делает код более ценным

SAL поможет вам сделать разработку кода более понятным как для людей, так и для средств анализа кода. Рассмотрим этот пример, показывающий функцию memcpyсреды выполнения C:

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, могут повысить эффективность и эффективность автоматизированных средств анализа кода в начале обнаружения потенциальных ошибок. Представьте, что кто-то пишет эту ошибку реализации 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— средство анализа кода может поймать ошибку, проанализировав эту функцию отдельно.

Основы 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 для поиска дефектов

В примерах средство анализа кода Visual Studio используется вместе с заметками SAL для поиска дефектов кода. Вот как это сделать.

Использование средств анализа кода Visual Studio и SAL

  1. В Visual Studio откройте проект C++, содержащий заметки SAL.

  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 в этом примере, он подтверждает, что вызывающие передают ненулевой указатель на инициализированный буфер для 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 проверяет, проверяет ли функция значение 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 проверяет, передает ли вызывающий указатель на буфер pInt , отличный от NULL, и что буфер инициализируется функцией перед возвратом.

Пример: заметка _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 проверяет, что эта функция проверяет на NULL перед разыменованием pInt, и если 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 проверяет, что вызывающие передают ненулевой указатель на инициализированный буфер для 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 проверяет, проверяет ли эта функция значение 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 проверяет, что вызывающий объект передает указатель *pInt, отличный от NULL, и что буфер инициализируется функцией перед возвратом.

Пример: аннотация _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 проверяет, проверяет ли эта функция значение NULL, прежде чем *pInt разыменовывается, и что буфер инициализируется функцией перед возвратом.

Пример: аннотация _Success_ в сочетании с _Out_

Аннотации можно применять к большинству объектов. В частности, можно аннотировать всю функцию. Одна из самых очевидных характеристик функции заключается в том, что она может завершиться успешной или неудачной. Но, как и связь между буфером и его размером, C/C++ не может выразить успех или сбой функции. Используя аннотацию _Success_, вы можете описать, как выглядит успешное выполнение функции. Параметр заметки _Success_ — это просто выражение, указывающее, что функция успешно выполнена. Выражение может быть любым, что может обработать парсер аннотаций. Эффекты заметок после возврата функции применимы только при успешном выполнении функции. В этом примере показано, как _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 для проверки того, что вызывающий объект передает указатель, отличный от NULL, в буфер pInt, и что буфер инициализирован функцией перед возвратом.

Лучшие практики SAL

Добавление заметок в существующий код

SAL — это мощная технология, которая помогает повысить безопасность и надежность кода. После обучения SAL вы можете применить новый навык к повседневной работе. В новом коде можно использовать спецификации на основе SAL по всему дизайну; в более старом коде можно добавлять заметки постепенно и тем самым увеличивать преимущества при каждом обновлении.

Общедоступные заголовки Майкрософт уже аннотированы. Поэтому мы рекомендуем в ваших проектах сначала аннотировать функции конечных узлов и функции, которые вызывают Win32 API, чтобы извлечь максимальную пользу.

Когда я делаю аннотацию?

Ниже приведены некоторые рекомендации.

  • Заметите все параметры указателя.

  • Аннотируйте аннотации диапазона значений, чтобы анализ кода мог обеспечить безопасность буфера и указателя.

  • Аннотировать правила блокировки и побочные эффекты блокировки. Дополнительные сведения см. в Аннотировании поведения блокировки.

  • Заметите свойства драйвера и другие свойства, относящиеся к домену.

Или вы можете пометить все параметры, чтобы сделать ваше намерение ясным, и упростить проверку того, что аннотации были выполнены.

См. также