Noções básicas de SAL

A SAL (linguagem de anotação de código-fonte) da Microsoft fornece um conjunto de anotações que você pode usar para descrever como uma função usa seus parâmetros, as suposições que ela faz em relação a eles e as garantias que ela faz ao concluir. As anotações são definidas no arquivo de cabeçalho <sal.h>. A análise de código do Visual Studio para C++ usa anotações da SAL para modificar a análise de funções. Para obter mais informações sobre a SAL 2.0 para desenvolvimento de driver do Windows, confira Anotações da SAL 2.0 para Drivers do Windows.

Nativamente, C e C++ fornecem apenas maneiras limitadas para os desenvolvedores expressarem intenção e invariância de modo consistente. Usando anotações da SAL, você pode descrever suas funções com mais detalhes para que os desenvolvedores que as consomem possam entender melhor como usá-las.

O que é a SAL e por que você deveria usá-la?

Em poucas palavras, a SAL é uma forma barata de permitir que o compilador verifique o código para você.

A SAL torna o código mais valioso

A SAL pode ajudá-lo a tornar seu design de código mais compreensível, tanto para humanos quanto para ferramentas de análise de código. Considere este exemplo que mostra a função memcpy do runtime do C:

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

Você consegue dizer o que essa função faz? Quando uma função é implementada ou chamada, determinadas propriedades devem ser mantidas para garantir a correção do programa. Apenas olhando para uma declaração como a do exemplo, você não sabe o que ela é. Sem anotações da SAL, você teria que contar com a documentação ou comentários de código. Veja o que a documentação da memcpy diz:

"A memcpy copia bytes de count de src para dest; cópias wmemcpy caracteres largos (dois bytes) de count. Se a origem e o destino se sobrepõem, o comportamento de memcpy é indefinido. Use memmove para lidar com regiões sobrepostas.
Importante: o buffer de destino deve ser do mesmo tamanho ou maior que o buffer de origem. Para obter mais informações, confira Evitando saturações de buffer."

A documentação contém algumas informações que sugerem que seu código precisa manter determinadas propriedades para garantir a correção do programa:

  • A memcpy copia o count de bytes do buffer de origem para o buffer de destino.

  • O buffer de destino deve ser no mínimo tão grande quanto o buffer de origem.

No entanto, o compilador não consegue ler a documentação nem comentários informais. Ele não sabe que há uma relação entre os dois buffers e count e não consegue chegar efetivamente a uma opinião sobre uma relação. A SAL poderia fornecer mais clareza sobre as propriedades e a implementação da função, conforme mostrado aqui:

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

Observe que essas anotações se assemelham às informações na documentação, mas são mais concisas e seguem um padrão semântico. Ao ler esse código, você consegue entender rapidamente as propriedades dessa função e como evitar problemas de segurança de saturação de buffer. Melhor ainda, os padrões semânticos fornecidos pela SAL podem aprimorar a eficiência e a eficácia das ferramentas automatizadas de análise de código na descoberta antecipada de possíveis bugs. Imagine que alguém escreva esta implementação de wmemcpy com bugs:

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

Essa implementação contém um erro comum off-by-one. Entretanto, o autor do código incluiu a anotação de tamanho do buffer na SAL — uma ferramenta de análise de código poderia capturar o bug analisando essa função sozinho.

Conceitos básicos da SAL

A SAL define quatro tipos básicos de parâmetros, que são categorizados pelo padrão de uso.

Categoria Anotação de parâmetro Descrição
Entrada para a função chamada _In_ Os dados são passados para a função chamada e são tratados como somente leitura.
Entrada para a função chamada e saída para o chamador _Inout_ Os dados utilizáveis são passados para a função e possivelmente são modificados.
Saída para o chamador _Out_ O chamador fornece apenas espaço para a função chamada gravar. A função chamada grava dados nesse espaço.
Saída do ponteiro para o chamador _Outptr_ Como a Saída para o chamador. O valor retornado pela função chamada é um ponteiro.

Essas quatro anotações básicas podem ser mais explícitas de várias maneiras. Por padrão, os parâmetros de ponteiro anotados são considerados necessários– eles devem ser não NULL para que a função tenha êxito. A variação mais usada das anotações básicas indica que um parâmetro de ponteiro é opcional; mesmo que seja NULL, a função conseguirá fazer seu trabalho.

Esta tabela mostra como distinguir entre parâmetros obrigatórios e opcionais:

Parâmetros são necessários Parâmetros são opcionais
Entrada para a função chamada _In_ _In_opt_
Entrada para a função chamada e saída para o chamador _Inout_ _Inout_opt_
Saída para o chamador _Out_ _Out_opt_
Saída do ponteiro para o chamador _Outptr_ _Outptr_opt_

Essas anotações ajudam a identificar possíveis valores não inicializados e usos de ponteiros nulos inválidos de maneira formal e precisa. Passar NULL para um parâmetro necessário poderá causar uma falha ou poderá fazer com que um código de erro "com falha" seja retornado. De qualquer maneira, a função não poderá ter êxito para fazer seu trabalho.

Exemplos da SAL

Esta seção mostra exemplos de código para as anotações básicas da SAL.

Usar a Ferramenta de Análise de Código do Visual Studio para localizar defeitos

Nos exemplos, a Ferramenta de Análise de Código do Visual Studio é usada junto com anotações da SAL para localizar defeitos de código. Veja a seguir como fazer isso.

Para usar ferramentas de análise de código do Visual Studio e a SAL

  1. No Visual Studio, abra um projeto do C++ que contenha anotações da SAL.

  2. Na barra de menus, escolha Compilar, Executar Análise de Código na Solução.

    Considere o exemplo _In_ nesta seção. Se você executar a análise de código nele, este aviso será exibido:

    C6387 Valor do Parâmetro Inválido 'pInt' pode ser '0': isso não adere à especificação da função 'InCallee'.

Exemplo: a anotação _In_

A anotação _In_ indica que:

  • O parâmetro deve ser válido e não será modificado.

  • A função só será lida do buffer de elemento único.

  • O chamador deve fornecer o buffer e inicializá-lo.

  • A _In_ especifica "somente leitura". Um erro comum é aplicar _In_ a um parâmetro que deveria ter a anotação _Inout_.

  • _In_ é permitido, mas ignorado pelo analisador em escalares sem ponteiro.

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
}

Se você usar a Análise de Código do Visual Studio neste exemplo, ela validará que os chamadores passam um ponteiro não nulo para um buffer inicializado para pInt. Nesse caso, o ponteiro pInt não pode ser NULL.

Exemplo: a anotação _In_opt_

A _In_opt_ é semelhante à _In_, exceto que o parâmetro de entrada tem permissão para ser NULL e, portanto, a função deve verificar isso.

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

A Análise de Código do Visual Studio valida se a função verifica se é NULL antes de acessar o buffer.

Exemplo: a anotação _Out_

A _Out_ dá suporte a um cenário comum no qual um ponteiro não NULL que aponta para um buffer de elemento é passado e a função inicializa o elemento. O chamador não precisa inicializar o buffer antes da chamada; a função chamada promete inicializá-lo antes de retornar.

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

A Ferramenta Análise de Código do Visual Studio valida se o chamador passa um ponteiro não NULL para um buffer para o pInt e se o buffer é inicializado pela função antes de retornar.

Exemplo: a anotação _Out_opt_

A _Out_opt_ é semelhante à _Out_, exceto que o parâmetro tem permissão para ser NULL e, portanto, a função deve verificar isso.

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

A Análise de Código do Visual Studio valida se essa função verifica se o NULL antes de pInt é desreferenciado e, se o pInt não for NULL, se o buffer é inicializado pela função antes de retornar.

Exemplo: a anotação _Inout_

A _Inout_ é usada para anotar um parâmetro de ponteiro que pode ser alterado pela função. O ponteiro deve apontar para dados inicializados válidos antes da chamada e, mesmo que seja alterado, ainda deverá ter um valor válido no retorno. A anotação especifica que a função pode ler livremente e gravar no buffer de um elemento. O chamador deve fornecer o buffer e inicializá-lo.

Observação

Assim como _Out_, a _Inout_ deve ser aplicada a um valor modificável.

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
}

A Análise de Código do Visual Studio valida se os chamadores passam um ponteiro não NULL para um buffer inicializado para o pInt e se, antes do retorno, pInt permanece não NULL e se o buffer está inicializado.

Exemplo: a anotação _Inout_opt_

A _Inout_opt_ é semelhante à _Inout_, exceto que o parâmetro de entrada tem permissão para ser NULL e, portanto, a função deve verificar isso.

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

A Análise de Código do Visual Studio valida se essa função verifica o NULL antes de acessar o buffer e, se o pInt não for NULL, se o buffer é inicializado pela função antes de retornar.

Exemplo: a anotação _Outptr_

A _Outptr_ é usada para anotar um parâmetro destinado a retornar um ponteiro. O parâmetro em si não deve ser NULL e a função chamada retorna um ponteiro não NULL nele; esse ponteiro aponta para dados 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);
}

A Análise de Código do Visual Studio valida se o chamador passa um ponteiro não NULL para o *pInt e se o buffer é inicializado pela função antes de retornar.

Exemplo: a anotação de _Outptr_opt_

A _Outptr_opt_ é semelhante à _Outptr_, exceto que o parâmetro é opcional – o chamador pode passar um ponteiro NULL para o 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);
}

A Análise de Código do Visual Studio valida se essa função verifica se o NULL antes de *pInt é desreferenciado e se o buffer é inicializado pela função antes de retornar.

Exemplo: a anotação _Success_ em combinação com _Out_

As anotações podem ser aplicadas à maioria dos objetos. Especificamente, você pode anotar uma função inteira. Uma das características mais óbvias de uma função é que ela pode ter êxito ou falhar. Mas, como a associação entre um buffer e seu tamanho, o C/C++ não pode expressar o êxito ou a falha da função. Usando a anotação _Success_, você consegue dizer como é o êxito de uma função. O parâmetro para a anotação _Success_ é apenas uma expressão que, quando verdadeira, indica que a função foi bem-sucedida. A expressão pode ser qualquer coisa que o analisador de anotação possa manipular. Os efeitos das anotações após os retornos da função só são aplicáveis quando a função é bem-sucedida. Este exemplo mostra como a _Success_ interage com _Out_ para fazer a coisa certa. Você pode usar a palavra-chave return para representar o valor retornado.

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

A anotação _Out_ faz com que a Análise de Código do Visual Studio valide se o chamador passa um ponteiro não NULL para um buffer para o pInt e se o buffer é inicializado pela função antes de retornar.

Prática recomendada da SAL

Adição de anotações a código existente

A SAL é uma tecnologia avançada que pode ajudá-lo a aprimorar a segurança e a confiabilidade do seu código. Depois de aprender a SAL, você poderá aplicar a nova habilidade ao seu trabalho diário. Em código novo, você pode usar especificações baseadas em SAL por design em todo o processo; em código mais antigo, você pode adicionar anotações de maneira incremental e, assim, aumentar os benefícios sempre que fizer atualizações.

Os cabeçalhos públicos da Microsoft já estão anotados. Portanto, sugerimos que, em seus projetos, você primeiro anote funções de nó folha e funções que chamam APIs do Win32 para obter os maiores benefícios.

Quando fazer anotações?

Aqui estão algumas diretrizes:

  • Anote todos os parâmetros de ponteiro.

  • Faça anotações de intervalo de valor para que a Análise de Código possa garantir a segurança do buffer e do ponteiro.

  • Anote regras de bloqueio e efeitos colaterais de bloqueio. Para obter mais informações, confira Anotação de comportamento de bloqueio.

  • Anote propriedades do driver e outras propriedades específicas do domínio.

Você também pode anotar todos os parâmetros para deixar sua intenção clara e facilitar a verificação de que as anotações foram feitas.

Confira também