Introducción a SAL

El lenguaje de anotación de código fuente de Microsoft (SAL) proporciona un conjunto de anotaciones que puede usar para describir la forma en la que una función usa sus parámetros, las suposiciones que hace sobre ellos y las garantías que hace al terminar. Las anotaciones se definen en el archivo de encabezado <sal.h>. Code Analysis de Visual Studio para C++ usa anotaciones SAL para modificar su análisis de funciones. Para obtener más información sobre SAL 2.0 para el desarrollo de controladores de Windows, consulte Anotaciones SAL 2.0 para controladores de Windows.

De forma nativa, C y C++ solo proporcionan formas limitadas para que los desarrolladores expresen de forma coherente la intención y la invarianza. Mediante las anotaciones SAL, puede describir las funciones con mayor detalle para que los desarrolladores que las consuman puedan comprender mejor cómo usarlas.

¿Qué es SAL y por qué debería usarlo?

En pocas palabras, SAL es una manera económica de permitir que el compilador compruebe el código automáticamente.

SAL hace que el código sea más valioso

SAL puede ayudarle a que el diseño del código sea más comprensible, tanto para humanos como para herramientas de análisis de código. Considere este ejemplo que muestra la función memcpy del entorno de ejecución de C:

void * memcpy(
   void *dest,
   const void *src,
   size_t count
);

¿Puede saber qué hace esta función? Cuando se implementa o se llama a una función, se deben mantener determinadas propiedades para garantizar la corrección del programa. Simplemente examinando una declaración como la del ejemplo, no se sabe cuáles son. Sin anotaciones SAL, tendría que confiar en la documentación o los comentarios del código. Esto es lo que indica la documentación de memcpy:

"memcpy copia el recuento de bytes de src a dest; wmemcpy copia el recuento de caracteres anchos (dos bytes). Si el origen y el destino se superponen, el comportamiento de memcpy no está definido. Use memmove para controlar las áreas superpuestas.
Importante: Asegúrese de que el búfer de destino sea del mismo tamaño o mayor que el búfer de origen. Para obtener más información, consulte Evitar saturaciones del búfer".

La documentación contiene un par de fragmentos de información que sugieren que el código tiene que mantener determinadas propiedades para garantizar la corrección del programa:

  • memcpy copia el count (recuento) de bytes del búfer de origen en el búfer de destino.

  • El búfer de destino debe ser al menos tan grande como el búfer de origen.

Sin embargo, el compilador no puede leer la documentación ni los comentarios informales. No sabe que hay una relación entre los dos búferes y count, y tampoco puede adivinar eficazmente una relación. SAL podría proporcionar más claridad sobre las propiedades y la implementación de la función, como se muestra aquí:

void * memcpy(
   _Out_writes_bytes_all_(count) void *dest,
   _In_reads_bytes_(count) const void *src,
   size_t count
);

Observe que estas anotaciones se asemejan a la información de la documentación, pero son más concisas y siguen un patrón semántico. Al leer este código, puede comprender rápidamente las propiedades de esta función y cómo evitar problemas de seguridad de saturación del búfer. Aún mejor, los patrones semánticos que proporciona SAL pueden mejorar la eficacia de las herramientas de análisis de código automatizadas en la detección temprana de posibles errores. Imagine que alguien escribe esta implementación incorrecta de 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;
}

Esta implementación contiene un error común fuera de uno. Afortunadamente, el autor del código incluyó la anotación de tamaño de búfer de SAL: una herramienta de análisis de código podría detectar el error mediante el análisis de esta función por sí sola.

Conceptos básicos de SAL

SAL define cuatro tipos básicos de parámetros, que se clasifican por patrón de uso.

Categoría Anotación de parámetros Descripción
Entrada para la función llamada _In_ Los datos se pasan a la función llamada y se tratan como de solo lectura.
Entrada para la función llamada y salida para el autor de la llamada _Inout_ Se pasan datos utilizables a la función y, potencialmente, se modifican.
Salida para el autor de la llamada _Out_ El autor de la llamada solo proporciona espacio para que la función llamada escriba en él. La función llamada escribe datos en ese espacio.
Salida de puntero para el autor de la llamada _Outptr_ Como Salida para el autor de la llamada. El valor devuelto por la función llamada es un puntero.

Estas cuatro anotaciones básicas se pueden hacer más explícitas de varias maneras. De manera predeterminada, se supone que se requieren parámetros de puntero anotados, no deben ser NULL para que la función se ejecute correctamente. La variación más usada de las anotaciones básicas indica que un parámetro de puntero es opcional; si es NULL, la función puede seguir funcionando correctamente.

En esta tabla, se muestra cómo distinguir entre parámetros obligatorios y opcionales:

Los parámetros son obligatorios. Los parámetros son opcionales.
Entrada para la función llamada _In_ _In_opt_
Entrada para la función llamada y salida para el autor de la llamada _Inout_ _Inout_opt_
Salida para el autor de la llamada _Out_ _Out_opt_
Salida de puntero para el autor de la llamada _Outptr_ _Outptr_opt_

Estas anotaciones ayudan a identificar los posibles valores no inicializados y los punteros NULL no válidos que se usan de forma formal y precisa. Pasar NULL a un parámetro necesario puede provocar un bloqueo o puede provocar que se devuelva un código de error "con errores". En cualquier caso, la función no puede realizar correctamente su trabajo.

Ejemplos de SAL

En esta sección, se muestran ejemplos de código para las anotaciones SAL básicas.

Uso de la herramienta Code Analysis de Visual Studio para buscar defectos

En los ejemplos, se usa la herramienta Code Analysis de Visual Studio junto con anotaciones SAL para buscar defectos de código. Aquí te mostramos cómo hacerlo.

Uso de las herramientas de análisis de código de Visual Studio y SAL

  1. En Visual Studio, abra un proyecto de C++ que contenga anotaciones SAL.

  2. En la barra de menús, elija Compilar y Ejecutar análisis de código en la solución.

    Considere el ejemplo de _In_ de esta sección. Si ejecuta el análisis de código en él, se muestra esta advertencia:

    C6387 Valor de parámetro no válido 'pInt' podría ser '0': esto no cumple la especificación de la función 'InCallee'.

Ejemplo: anotación _In_

La anotación _In_ indica que:

  • El parámetro debe ser válido y no se va a modificar.

  • La función solo leerá desde el búfer de un solo elemento.

  • El autor de la llamada debe proporcionar el búfer e inicializarlo.

  • _In_ especifica "solo lectura". Un error común es aplicar _In_ a un parámetro que debe tener la anotación _Inout_ en su lugar.

  • Se permite _In_, pero el analizador la omite en escalares que no son de puntero.

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
}

Si usa Code Analysis de Visual Studio en este ejemplo, este valida que los autores de la llamada pasen un puntero distinto de null a un búfer inicializado para pInt. En este caso, el puntero pInt no puede ser NULL.

Ejemplo: anotación _In_opt_

_In_opt_ es igual que _In_, excepto que el parámetro de entrada puede ser NULL y, por lo tanto, la función debe comprobarlo.

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);
}

Code Analysis de Visual Studio valida que la función compruebe si hay valores NULL antes de acceder al búfer.

Ejemplo: anotación _Out_

_Out_ admite un escenario común en el que se pasa un puntero que no es NULL que apunta a un búfer de elementos y la función inicializa el elemento. El autor de la llamada no tiene que inicializar el búfer antes de la llamada; la función llamada promete inicializarlo antes de volver.

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;
}

La herramienta Code Analysis de Visual Studio valida que el autor de la llamada pase un puntero distinto de NULL a un búfer para pInt y que la función inicialice el búfer antes de volver.

Ejemplo: anotación _Out_opt_

_Out_opt_ es igual que _Out_, excepto que el parámetro puede ser NULL y, por lo tanto, la función debe comprobarlo.

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);
}

Code Analysis de Visual Studio valida que esta función compruebe si hay NULL antes de desreferenciar pInt y, si pInt no es NULL, que la función inicialice el búfer antes de volver.

Ejemplo: anotación _Inout_

_Inout_ se usa para anotar un parámetro de puntero que la función puede cambiar. El puntero debe apuntar a datos inicializados válidos antes de la llamada e, incluso si cambia, debe tener un valor válido al volver. La anotación especifica que la función puede leer y escribir libremente en el búfer de un elemento. El autor de la llamada debe proporcionar el búfer e inicializarlo.

Nota:

Al igual que _Out_, _Inout_ se debe aplicar a un valor modificable.

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
}

Code Analysis de Visual Studio valida que los autores de las llamadas pasen un puntero distinto de NULL a un búfer inicializado para pInt y que, antes de volver, pInt siga sin ser NULL y se haya inicializado el búfer.

Ejemplo: anotación _Inout_opt_

_Inout_opt_ es igual que _Inout_, excepto que el parámetro de entrada puede ser NULL y, por lo tanto, la función debe comprobarlo.

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);
}

Code Analysis de Visual Studio valida que esta función compruebe si hay NULL antes de acceder al búfer y, si pInt no es NULL, que la función inicialice el búfer antes de volver.

Ejemplo: anotación _Outptr_

_Outptr_ se usa para anotar un parámetro destinado a devolver un puntero. El propio parámetro no debe ser NULL y la función llamada devuelve un puntero distinto de NULL en él y ese puntero apunta a datos inicializados.

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);
}

Code Analysis de Visual Studio valida que el autor de la llamada pase un puntero distinto de NULL para *pInt y que la función inicialice el búfer antes de volver.

Ejemplo: anotación _Outptr_opt_

_Outptr_opt_ es igual que _Outptr_, salvo que el parámetro es opcional; el autor de la llamada puede pasar un puntero NULL para el parámetro.

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);
}

Code Analysis de Visual Studio valida que esta función compruebe si hay NULL antes de desreferenciar *pInt y que la función inicialice el búfer antes de volver.

Ejemplo: anotación _Success_ en combinación con _Out_

Se pueden aplicar anotaciones a la mayoría de los objetos. Específicamente, puede anotar una función completa. Una de las características más obvias de una función es que puede ejecutarse correctamente o producir un error. Pero, al igual que la asociación entre un búfer y su tamaño, C/C++ no puede expresar la ejecución correcta o con error de la función. Mediante el uso de la anotación _Success_, puede indicar qué aspecto tiene la ejecución correcta de una función. El parámetro de la anotación _Success_ es simplemente una expresión que, cuando es true, indica que la función se ha ejecutado correctamente. La expresión puede ser cualquier cosa que pueda controlar el analizador de anotaciones. Los efectos de las anotaciones después de que la función vuelva solo son aplicables cuando la función se ejecuta correctamente. En este ejemplo, se muestra cómo interactúa _Success_ con _Out_ para hacer lo correcto. Puede usar la palabra clave return para representar el valor devuelto.

_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;
   }
}

La anotación _Out_ hace que Code Analysis de Visual Studio valide que el autor de la llamada pase un puntero distinto de NULL a un búfer para pInt y que la función inicialice el búfer antes de volver.

Procedimiento recomendado para SAL

Adición de anotaciones al código existente

SAL es una tecnología eficaz que puede ayudarle a mejorar la seguridad y confiabilidad del código. Después de aprender SAL, puede aplicar esta nueva aptitud a su trabajo diario. En el nuevo código, puede usar especificaciones basadas en SAL por diseño en todo el conjunto; en el código anterior, puede agregar anotaciones incrementalmente y, por tanto, aumentar las ventajas cada vez que actualice.

Los encabezados públicos de Microsoft ya están anotados. Por lo tanto, se recomienda que en los proyectos primero anote las funciones del nodo hoja y las funciones que llaman a las API de Win32 para obtener las mayores ventajas.

¿Cuándo realizar las anotaciones?

Estas son algunas directrices:

  • Anote todos los parámetros de puntero.

  • Anote las anotaciones de valor-intervalo para que Code Analysis pueda garantizar la seguridad de búferes y punteros.

  • Anote las reglas de bloqueo y los efectos secundarios del bloqueo. Para más información, consulte Anotación del comportamiento de los bloqueos.

  • Anote las propiedades de los controladores y otras propiedades específicas del dominio.

O bien, puede anotar todos los parámetros para que su intención sea clara en conjunto y para facilitar la comprobación de que se hayan realizado las anotaciones.

Consulte también