Valgrind: Uma ferramenta negligenciada das sombras ou uma ferramenta de depuração séria?

Há alguns meses, demos uma olhada nos sanitizadores de C++ – pequenas ferramentas úteis que nos ajudam a manter nosso código limpo e livre de erros de memória.

Hoje, voltaremos ao passado e falaremos sobre uma ferramenta mais antiga, mas ainda muito poderosa, chamada Valgrind. Ela tem servido aos desenvolvedores há algum tempo, pois detecta uma grande variedade de erros, como leituras de memória não inicializadas, sobrecarga de buffer de heap, vazamentos de memória, deadlocks etc.

Depois que a Clang lançou sua coleção de sanitizadores, o Valgrind ficou em segundo plano, mas, como você verá nesta postagem do blog, há alguns casos em que o Valgrind realmente deixa os sanitizadores em segundo plano.

Uma fera sob o capô

Uma das maiores vantagens do Valgrind em relação aos sanitizadores é o fato de não exigir que o software seja instrumentado antes de ser verificado. Isso significa que ele pode ser usado para depurar qualquer tipo de software “direto da caixa”, sem que você tenha acesso ao código-fonte.

Isso não seria possível sem a fera que está sob o capô do Valgrind. Em resumo, depois que o Valgrind é inicializado, ele assume o controle do seu programa e o executa em uma CPU simulada fornecida pelo núcleo do Valgrind. Em seguida, ele adiciona seu próprio código de instrumentação, dependendo do tipo de erro que você deseja detectar.

Diferentemente dos sanitizadores, o Valgrind usa a Instrumentação Binária Dinâmica (DPI) e a compilação Just-In-Time (JIT) para incorporar o código do nosso software ao código de instrumentação, ou seja, para interceptar chamadas de função de alocação a fim de armazenar algumas informações adicionais.

Veja como isso funciona. Cada bloco alocado é mapeado para um shadow block em que a pilha de chamadas no momento da chamada da função malloc é armazenada. Quando a função free é chamada, o Valgrind tenta encontrar o shadow block correspondente ao endereço passado para free. Se o bloco não for encontrado, o Valgrind emite uma mensagem de erro. Caso contrário, o bloco é adicionado à fila e é marcado como inacessível. Dessa forma, é possível detectar o acesso inválido à memória liberada. No entanto, observe que os blocos podem ser removidos da fila quando o sistema ficar sem espaço livre.

Flexibilidade proporcionada por uma arquitetura em camadas

Conforme mostrado na figura abaixo, o Valgrind é composto de duas camadas: O núcleo do Valgrind e o plug-in da ferramenta, que pode ser qualquer uma das ferramentas do conjunto de ferramentas do Valgrind, incluindo:

  • Memcheck – rastreia alocações de memória e relata vazamentos de memória
  • Helgrind – detecta problemas relacionados a multi-threading (por exemplo, deadlocks, corridas de dados etc.)
  • Cachegrind – atua como profiler de cache e de previsão de ramificação
  • Massif – analisa o uso da memória do heap

Lembre-se de que o Valgrind é de código aberto e você pode criar sua própria ferramenta, se quiser.

Cada uma dessas duas camadas tem sua própria função. A parte principal carrega o software sob teste no processo e desmonta seu código. Feito isso, os fragmentos de código são passados para o plug-in da ferramenta, que adiciona a instrumentação ao código e, por fim, o monta novamente.

Uma maneira mais fácil de depurar

Conforme mencionado em nossa postagem anterior no blog, os sanitizadores C++ exigem que o código seja recompilado. Isso pode ser inconveniente quando você deseja testar seu código com o AddressSanitizer e o MemorySanitizer. Como os dois não podem trabalhar juntos, você precisaria executar vários testes para detectar leituras de memória não inicializadas e outros erros relacionados a endereços. Por outro lado, o Valgrind pode executar praticamente qualquer software como está. A única coisa que ele precisa é de suporte para todas as instruções que nosso software está usando.

Digamos que você queira depurar uma biblioteca cujo código-fonte esteja inacessível. O uso de sanitizadores não produziria resultados porque, ao contrário do Valgrind, eles funcionam no nível do compilador. Com o Valgrind, você pode lidar com esses casos por padrão, mas lembre-se de que você pode acabar com mensagens de erro que significam muito pouco para você, porque você não tem controle sobre esse código. No entanto, você pode filtrar essas mensagens escrevendo-as em um arquivo de supressão que é lido quando o Valgrind é iniciado.

Além de um processo de depuração mais simplificado, o Valgrind é um ótimo substituto em plataformas em que não há suporte para sanitizadores. O Apple Clang, por exemplo, não é fornecido com o LeakSanitizer, o que torna o Valgrind sua melhor alternativa, a menos que você esteja disposto a mudar para outro compilador.

O desempenho é a maior desvantagem

Vamos dar uma olhada em uma versão ligeiramente modificada do exemplo do nosso primeiro post sobre sanitizadores C++:

char const * src{ "Hello world!" };
auto const dst{ std::make_unique< char[] >( std::strlen( src ) ) };

for ( auto i{ 0ul }; i < 1000000; ++i )
{
std::strcpy( dst.get(), src );
}

std::puts( dst.get() );

Um for loop foi adicionado para enfatizar a diferença de desempenho entre o AddressSanitizer e o Valgrind. Em ambos os casos, o código é compilado usando o seguinte comando (a execução do ASan também exige que você adicione -fsanitize=address):

clang++ example.cpp -g -o example.out

Uma rápida olhada no gráfico abaixo revela uma enorme diferença de desempenho entre o Valgrind e o ASan, e é por isso que muitos desenvolvedores podem relutar em usar a ferramenta em primeiro lugar.

Comparação lado a lado

Para que você entenda melhor quais erros o Valgrind e os sanitizadores são capazes de detectar, vamos dar uma olhada na tabela a seguir:

* MSan detecta leituras de memória não inicializadas
** UBSan detecta comportamento indefinido

Como você pode ver, o Valgrind não o ajudará a detectar transbordamentos na pilha e nas variáveis globais. Isso ocorre porque ele só tem acesso às alocações de heap realizadas pela função malloc. Além disso, não conte com ele para detectar qualquer comportamento indefinido em seu código, embora você seja avisado sobre a tentativa de acessar a memória que resultou de um comportamento indefinido.

Ao mesmo tempo, o ASan também não é tão perfeito. Ele não detecta leituras de memória não inicializadas nem comportamento indefinido, mas o MSan e o UBSan detectam.

Valgrind ou sanitizantes: o que você deve usar?

A pergunta que fica é: devemos usar o Valgrind ou os higienizadores são um substituto melhor? Não há uma resposta correta para essa pergunta, pois essas duas ferramentas funcionam de maneiras muito diferentes. O ideal é que você use ambas, dependendo do seu ambiente e dos erros que deseja detectar.

Embora os sanitizadores atuais imponham uma sobrecarga de CPU muito menor e ofereçam uma gama mais ampla de erros detectados, eles ainda têm suas desvantagens. Eles funcionam no nível do compilador, o que significa que você precisa do código-fonte. Eles também exigem que você recompile o código a cada execução de teste, levam mais tempo para serem integrados e não têm suporte em determinadas plataformas.

Se você tiver um grande projeto em mãos e não quiser se preocupar com a recompilação do código, talvez faça mais sentido usar o Valgrind. Achamos que ele é especialmente útil na depuração de bibliotecas de código fechado e na detecção de coisas como bugs de uso após a liberação com mais facilidade. Embora os sanitizadores estejam cada vez melhores, tendemos a preferi-los ao Valgrind.

No final, independentemente das ferramentas que você usar, o mais importante é tornar seu código o mais seguro possível. Com isso em mente, fique atento a mais publicações interessantes sobre depuração em C++.

July 30, 2021

Descubra nossas soluções

Para explorar nossas soluções, você está a apenas um clique de distância. Experimente nossos produtos ou converse com um de nossos especialistas para se aprofundar no que oferecemos.