Seja sábio, higienize: Mantendo seu código C++ livre de bugs
Apesar de todas as perdas que causou, essa pandemia pelo menos nos tornou mais conscientes sobre nossa higiene pessoal.
Estamos pulverizando espaços, superfícies e nossas mãos com muito mais frequência, então por que não higienizar nosso código enquanto fazemos isso? Afinal de contas, o software comanda o mundo, e os bugs que causam o mau funcionamento dos programas podem causar sérios danos, assim como os vírus.
Se estiver desenvolvendo em C e C++, você sabe disso muito bem. É fácil alocar um pedaço de memória e esquecer de liberá-lo mais tarde ou escrever acidentalmente além do buffer de memória. Esses problemas são extremamente difíceis de serem encontrados sem as ferramentas adequadas e, muitas vezes, causam falhas esporádicas e repentinas.
O uso de sanitizadores durante a criação e o teste do programa pode ajudar você a detectar uma grande quantidade de problemas no código-fonte logo no início, inclusive vazamentos de memória, estouros de buffer e comportamento indefinido.
Hoje, daremos uma olhada em três tipos de higienizadores do Clang, como eles são usados e quais bugs podem nos ajudar a eliminar pela raiz.
Limpando seu espaço de endereço com o AddressSanitizer (ASan)
O AddressSanitizer (abreviado como ASan) é usado para detectar estouros e subestouros de buffer (pilha, heap e buffer global) de use-after-free, double-free, além de outros erros de memória.
Ele consiste em um módulo de instrumentação do compilador e em uma biblioteca de tempo de execução que insere zonas vermelhas em torno de cada conjunto de bytes alocados com a função malloc. Ela também envenena os bytes liberados e mantém o controle da pilha de chamadas para cada malloc/free pair.
Este é o aspecto do nosso código sem o ASan:
E aqui está o que o ASan faz com ele para detectar erros relacionados ao endereço:
Para usar o ASan, basta que você adicione o -fsanitize=address como sinalizador do compilador e do vinculador. Você também pode definir um grande número de opções de tempo de execução por meio da variável de ambiente ASAN_OPTIONS (aqui há uma lista completa de sinalizadores do ASan que você pode usar).
Para realmente entender a utilidade dessa ferramenta, aqui está um pequeno teste para você. Tente encontrar um bug no trecho de código abaixo:
char const * src{ "Hello world!" };
auto const dst{ std::make_unique< char[] >( std::strlen( src ) ) };
std::strcpy( dst.get(), src );
std::puts( dst.get() );
É difícil, não é? Agora, imagine que esse bug faz parte de uma base de código muito maior. Levaríamos séculos para depurá-lo manualmente, ao passo que, com o ASan ativado, podemos identificar o estouro do buffer de heap oculto em segundos (veja a demonstração):
==1==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000001c at pc 0x00000044b754 bp 0x7ffc9ea586d0 sp 0x7ffc9ea57e80
WRITE of size 13 at 0x60200000001c thread T0
#0 0x44b753 (/app/output.s+0x44b753)
O ASan também avisará você se o seu programa continuar a usar um ponteiro depois de ele ter sido liberado (uma vulnerabilidade de segurança comum chamada de erro use-after-free). Aqui está um programa de exemplo que contém o erro:
constexpr std::size_t bodyOffset{ 96u };
char * message{ new char[ 1024 ] };
// fill in the message
char * bodyBegin{ message + bodyOffset };
delete [] message;
// many lines of code ...
std::puts( bodyBegin ); // heap-use-after-free bug
E aqui está um relatório que você recebe da ASan depois de testar o código:
==1==ERROR: AddressSanitizer: heap-use-after-free on address 0x6190000000e0 at pc 0x000000470a69 bp 0x7ffe047e6b90 sp 0x7ffe047e6340
READ of size 929 at 0x6190000000e0 thread T0
Muito legal, não é?
Agora, você deve estar se perguntando: quanto isso custa? Bem, nada vem de graça no mundo de hoje e, infelizmente, o mesmo se aplica à ASan.
Ainda assim, se você considerar a quantidade de tempo que essa ferramenta pode economizar, ela não é tão cara assim. A sobrecarga média é de aproximadamente 2x, tanto em termos de desempenho quanto de uso de memória.
Falando em desempenho, o principal concorrente do ASan é o Valgrind, Valgrindimporá ao seu programa uma desaceleração de 10 a 100 vezes maior. Para nós, isso é uma melhoria sólida.
Agora que limpamos nosso espaço de endereço, vamos fazer mais algumas limpezas em nossa memória.
Detecção de leituras de memória não inicializadas com o MemorySanitizer (MSan)
Você pode pensar que o AddressSanitizer cobre todos os erros relacionados à memória, mas esse não é o caso. Ele não lida com leituras de memória não inicializadas, e é aí que entra outro sanitizador chamado MemorySanitizer (MSan).
O MSan permite que você copie a memória não inicializada e execute operações lógicas e aritméticas simples nela, mas quando você tentar usá-la em uma instrução de tomada de decisão, receberá um sinalizador vermelho. Para entender isso melhor, dê uma olhada no seguinte trecho de código (demo):
int *src{ new int[ 5 ] };
int dst[ 5 ];
std::memcpy( dst, src, 5 ); // copying uninitialized memory, OK
if ( src[ 0 ] ) // MSan warning: use-of-uninitialized-value
{
// more code ...
}
Como você pode ver, o MSan nos avisará sobre qualquer valor não inicializado em uso. Aquele vazamento de memória que você pode ter notado não será sinalizado porque faz parte do que o ASan trata – é importante não nos confundirmos com isso.
Para chegar à origem desse valor, use o sinalizador -fsanitize-memory-track-origins junto com o -fsanitize=memory.
Agora, vamos falar um pouco sobre como o MSan realmente funciona. Ele implementa um mapeamento de sombra bit a bit, conforme mostrado na figura abaixo, em que 1 significa bit “envenenado” ou não inicializado. Isso permite um cálculo muito eficiente do endereço da memória shadow. Dado o endereço de memória do aplicativo ProductAddr, o ShaddowAddr computado é ProductAddr & ShadowMask, em que ShadowMask é uma constante específica da plataforma.
Sempre que o acesso a um dos bits envenenados tiver algum efeito colateral (por exemplo, na ramificação), será emitido um aviso. Esse bit adicional introduz uma sobrecarga de 2,5x na CPU e 2x na memória. A sobrecarga será um pouco maior se as origens da memória também forem rastreadas, 5x na CPU e 3x na memória, para ser mais específico.
Captura de comportamento indefinido com o UndefinedBehaviorSanitizer (UBSan)
Por último, mas não menos importante, o UndefinedBehaviorSanitizer (UBSan).
O UBSan detectará estouro de inteiro assinado, uso de ponteiros nulos, divisão por zero e outros comportamentos indefinidos enquanto você estiver executando o programa. Além do sinalizador do compilador -fsanitize=undefined, que verifica todos os tipos de erros, há muitos outros sinalizadores que podem ser úteis para encontrar erros mais específicos, incluindo:
-fsanitize=bounds
-fsanitize=vptr
-fsanitize=enum
-fsanitize=signed-integer-overflow
-fsanitize=null
-fsanitize=unsigned-integer-overflow
-fsanitize=return
-fsanitize=integer-divide-by-zero
-fsanitize=unreachable
-fsanitize=alignment
Como você pode ver, o UBSan lida muito bem com erros simples como o mostrado no exemplo a seguir (demo):
int main()
{
int m = std::numeric_limits< int >::max();
return m + 1;
}
E aqui está o relatório que recebemos:
runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
O UBSan demonstrará melhor seu poder no desenvolvimento diário, bem como em grandes bases de código. Com relação ao desempenho, há uma sobrecarga de aproximadamente 1,25x na CPU e nenhum impacto no uso da memória (o UBSan não afeta o layout do espaço de endereço).
Pontos negativos a considerar
Agora que vimos como os sanitizadores podem nos ajudar a criar um código melhor, ainda precisamos mencionar algumas desvantagens de usá-los.
Primeiro, ao contrário do Valgrind, que faz todas as verificações sem a necessidade de recompilação do código-fonte, os sanitizadores exigem que o código seja recompilado. Isso pode levar algum tempo, especialmente se você trabalha com uma grande base de código.
A segunda desvantagem (e, sem dúvida, a mais importante) é o fato de que o ASan e o MSan não podem trabalhar juntos. Isso significa que você precisará executar várias execuções para testar seu software, o que, novamente, pode levar algum tempo. Talvez esse seja o motivo pelo qual os sanitizadores ainda não são muito apreciados pela comunidade C++.
Apesar de suas desvantagens, recomendamos enfaticamente o uso de sanitizadores. Eles detectarão problemas que podem parecer seguros para as ferramentas concorrentes e não interromperão seu fluxo de trabalho com grandes lentidões.
Faz todo o sentido, então, integrá-los aos seus processos de desenvolvimento “rápidos”, como a integração contínua ou os pipelines de solicitação pull.
No entanto, não vamos descartar o Valgrind ainda, pois ele ainda pode ser muito útil para nós. Falaremos sobre suas vantagens em um dos próximos posts.
Por enquanto é só. Fique atento às próximas postagens interessantes e lembre-se de que a criação de um software confiável começa com um código limpo.