Skip to content
C++
Debugger
Updated 2025-01-01T00:00:00.000Z

Valgrind Memory Debugging

Valgrind Memcheck, Callgrind, Helgrind, and DRD — detecting memory errors, use-after-free, leaks, data races, and reading Valgrind reports.

TL;DR

Valgrind is a dynamic analysis framework. memcheck (default) finds heap errors and leaks. callgrind profiles function calls. helgrind/drd find data races.

bash
valgrind --tool=memcheck --leak-check=full ./app
valgrind --tool=callgrind ./app && callgrind_annotate callgrind.out.*
valgrind --tool=helgrind ./app

Installation

bash
sudo apt install valgrind       # Debian/Ubuntu
sudo dnf install valgrind       # Fedora/RHEL
brew install valgrind            # macOS (limited support)

Build with debug info for readable reports:

bash
cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-g -O0" ..
# or
g++ -g -O0 -o app main.cpp

Memcheck — Memory Error Detection

bash
valgrind --tool=memcheck \
         --leak-check=full \
         --show-leak-kinds=all \
         --track-origins=yes \
         --verbose \
         ./app [args]
OptionPurpose
--leak-check=fullDetailed leak reports (not just totals)
--show-leak-kinds=allShow definite, indirect, possible, and reachable leaks
--track-origins=yesTrack where uninitialized values come from (slower)
--error-exitcode=1Return non-zero exit code on errors (for CI)
--suppressions=fileSuppress known false positives
--gen-suppressions=allGenerate suppression entries for all errors

Reading Memcheck Reports

Invalid read/write

cpp
==12345== Invalid read of size 8
==12345==    at 0x401B2F: use_ptr (main.cpp:42)
==12345==    at 0x401A10: main (main.cpp:15)
==12345==  Address 0x5204080 is 0 bytes after a block of size 24 alloc'd
==12345==    at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck.so)
==12345==    at 0x401A20: allocate (main.cpp:10)
  • Invalid read of size 8 — reading 8 bytes out of bounds
  • The stack trace shows where the bad access happened
  • The second block shows where the buffer was allocated

Use after free

cpp
==12345== Invalid read of size 4
==12345==  Address 0x5204e80 is 4 bytes inside a block of size 40 free'd
==12345==    at 0x4C30D3B: free (in .../vgpreload_memcheck.so)
==12345==    at 0x401B10: cleanup (main.cpp:55)
==12345==  Block was alloc'd at
==12345==    at 0x4C2FB0F: malloc (in .../vgpreload_memcheck.so)

Memory leak summary

cpp
==12345== LEAK SUMMARY:
==12345==    definitely lost: 1,024 bytes in 1 blocks
==12345==    indirectly lost: 512 bytes in 4 blocks
==12345==      possibly lost: 0 bytes in 0 blocks
==12345==    still reachable: 8,192 bytes in 2 blocks
  • Definitely lost — no pointer to this memory exists (real leak)
  • Indirectly lost — reachable only through definitely-lost memory
  • Possibly lost — pointer exists but points to middle of block
  • Still reachable — pointer exists at exit (often fine: global/static)

Callgrind — Call Graph Profiler

bash
# Profile the application
valgrind --tool=callgrind \
         --callgrind-out-file=cg.out \
         ./app

# Text report
callgrind_annotate cg.out

# GUI visualization (install kcachegrind)
kcachegrind cg.out
bash
# Profile only part of execution
valgrind --tool=callgrind --instr-atstart=no ./app
# In code:
#include <valgrind/callgrind.h>
CALLGRIND_START_INSTRUMENTATION;
hot_function();
CALLGRIND_STOP_INSTRUMENTATION;
CALLGRIND_DUMP_STATS;

Helgrind — Data Race Detector

bash
valgrind --tool=helgrind \
         --history-level=approx \
         ./threaded_app

Sample output:

cpp
==12345== Possible data race during read of size 4 at 0x601060
==12345==    at 0x4008E4: worker (race.cpp:15)
==12345==  This conflicts with a previous write of size 4
==12345==    at 0x400911: main (race.cpp:30)

Helgrind also detects:

  • Lock order violations (potential deadlocks)
  • pthread API misuse
  • Unlocked variable accesses

DRD — Data Race Detector (Alternative)

bash
valgrind --tool=drd ./threaded_app

DRD uses less memory than Helgrind and is faster on programs with many threads. Helgrind gives more detailed "happens-before" analysis.


Suppression Files

Suppress known false positives or third-party library noise:

cpp
# myapp.supp
{
   openssl_init_leak
   Memcheck:Leak
   match-leak-kinds: reachable
   fun:malloc
   ...
   fun:OPENSSL_init_ssl
}
bash
valgrind --suppressions=myapp.supp ./app

Generate suppressions automatically:

bash
valgrind --gen-suppressions=all ./app 2>&1 | grep -A 20 "^{" > new.supp

CMake Integration

cmake
find_program(VALGRIND valgrind)
if(VALGRIND)
    add_custom_target(memcheck
        COMMAND ${VALGRIND}
            --leak-check=full
            --error-exitcode=1
            $<TARGET_FILE:myapp>
        DEPENDS myapp
    )
endif()

Valgrind vs Address Sanitizer

Valgrind MemcheckAddressSanitizer
Slowdown10–50x2x
Requires recompileNoYes (-fsanitize=address)
Stack buffer overflowsNoYes
Use-after-returnNoYes (with flag)
Leak detectionYesYes
Initialization errorsYesPartial (MSan)
Thread errorsHelgrind/DRDThreadSanitizer
CI overheadHigh (slow)Low
Best forLegacy binaries, thorough leak huntFast CI integration

Rule of thumb: use ASan in CI (fast), Valgrind for deep investigations or when you can't recompile.


Massif — Heap Memory Profiler

Massif shows not just how much memory leaks, but how much memory your program consumes over its lifetime. Use --time-unit=B (bytes allocated) instead of the default wall-clock time — it produces stable, reproducible snapshots unaffected by CPU speed or system load.

bash
valgrind --tool=massif \
         --time-unit=B \
         --pages-as-heap=yes \
         --stacks=yes \
         ./app
OptionEffect
--time-unit=BX-axis = cumulative bytes allocated (recommended)
--pages-as-heap=yesProfile all pages (RSS), not just malloc/new heap
--stacks=yesInclude stack memory in measurements
--detailed-freq=NTake a detailed snapshot every N snapshots (default 10)

Massif writes massif.out.<pid>. Two ways to read it:

bash
# Text output with heap tree
ms_print massif.out.*

# GUI (install separately)
massif-visualizer massif.out.*

A typical ms_print snapshot looks like this:

cpp
  n        time(B)         total(B)   useful-heap(B) extra-heap(B)    stacks(B)
--------------------------------------------------------------------------------
 10      1,048,576        1,049,600        1,024,000        25,600            0

99.56% (1,024,000B) (heap allocation functions) malloc/new/new[], --alloc-fns, etc.
->79.10% (811,008B) 0x402C1A: std::vector<int>::push_back (stl_vector.h:1198)
|  ->79.10% (811,008B) 0x401B4E: fill_data (main.cpp:34)
|     ->79.10% (811,008B) 0x401A5F: main (main.cpp:18)
->20.46% (212,992B) 0x402D8F: operator new (main.cpp:61)

The tree format directly tells you which call chain is responsible for each allocation. massif-visualizer renders the same data as an interactive area chart, which makes it easier to spot the moment peak memory was reached.


DHAT — Dynamic Heap Analysis Tool

DHAT answers a different question than Massif: it finds memory that is allocated but barely used, revealing over-allocation and short-lived churn. Where Massif tracks peak consumption over time, DHAT tracks access frequency per allocation site.

bash
valgrind --tool=dhat ./app

DHAT writes a JSON file (dhat.out.<pid>) and prints its location on exit. Open it in the browser-based viewer:

bash
# Open the viewer (ships with Valgrind >= 3.15)
firefox $(dpkg -L valgrind | grep dh_view.html)
# Or point your browser at:
# /usr/lib/valgrind/dh_view.html   (Debian/Ubuntu)
# /usr/share/valgrind/dh_view.html (Fedora)

The viewer shows each allocation site with three key columns:

ColumnMeaning
Total bytesCumulative bytes allocated at this site
Max bytes livePeak simultaneous live bytes
Total lifetimesSum of lifetimes (bytes × instructions alive)

A large gap between Total bytes and Max bytes live means objects are allocated and freed rapidly — a candidate for object pooling or pre-allocation. A high Total lifetimes with low access counts means memory is being kept alive longer than it needs to be.

bash
# Copy profiler: track which memcpy/memmove calls are hottest
valgrind --tool=dhat --mode=copy ./app

Cachegrind — Cache Simulation

Cachegrind simulates the CPU cache hierarchy and counts every instruction fetch and data access, letting you find cache-thrashing functions without hardware counter access. Unlike perf, Cachegrind runs on any machine and produces deterministic, reproducible output — at the cost of being a simulation of a fixed cache model rather than your actual hardware.

bash
valgrind --tool=cachegrind \
         --cache-sim=yes \
         --branch-sim=yes \
         ./app

cg_annotate cachegrind.out.* --auto=yes

--auto=yes annotates every source line that was executed. The key output columns:

ColumnMeaning
IrInstruction reads (total instructions executed)
I1mr / ILmrL1 / last-level instruction cache misses
Dr / DwData reads / data writes
D1mr / DLmrL1 / LL data read misses
D1mw / DLmwL1 / LL data write misses
Bc / BcmConditional branches / mispredictions

A function with high DLmr (last-level read misses) is causing DRAM traffic — the most expensive memory access. Focus optimization there first: restructure data for sequential access, reduce struct sizes, or switch from pointer-chasing (linked lists, std::map) to flat arrays (std::vector, std::unordered_map).

bash
# Compare two runs (e.g. before/after optimization)
cg_diff cachegrind.out.1234 cachegrind.out.5678

Cachegrind vs perf: Cachegrind uses a fixed simulated cache (portable, repeatable, no root needed). perf reads hardware performance counters (accurate to your physical CPU, faster to run, requires perf_event_paranoid ≤ 1). Use Cachegrind when you need portability or CI reproducibility; use perf when you need accuracy on a known machine.


Running Tests Under Valgrind with CTest

CTest has built-in Memcheck integration — one flag turns your test suite into a Valgrind run without touching any test code. The results appear in the same DartConfiguration XML format that CI dashboards consume.

bash
# Run all tests under Valgrind memcheck
ctest -T memcheck --test-dir build -j4

Set the Valgrind binary and options via environment or CTestCustom.cmake:

cmake
# CTestCustom.cmake (place in build dir or project root)
set(CTEST_MEMORYCHECK_COMMAND "/usr/bin/valgrind")
set(CTEST_MEMORYCHECK_TYPE "Valgrind")
set(CTEST_MEMORYCHECK_COMMAND_OPTIONS
    "--leak-check=full --show-leak-kinds=all --error-exitcode=1 --track-origins=yes"
)
set(CTEST_MEMORYCHECK_SUPPRESSIONS_FILE
    "${CMAKE_SOURCE_DIR}/valgrind.supp"
)

# Tests that are known-flaky under Valgrind (e.g. due to sanitizer conflicts)
list(APPEND CTEST_CUSTOM_MEMCHECK_IGNORE
    "MyFlakeyTest"
)

CTest picks up CTestCustom.cmake automatically from the build directory. A minimal CMakeLists addition to make it available:

cmake
configure_file(
    ${CMAKE_SOURCE_DIR}/CTestCustom.cmake
    ${CMAKE_BINARY_DIR}/CTestCustom.cmake
    COPYONLY
)

In CI, read the XML results from build/Testing/:

bash
# GitHub Actions / generic CI step
cmake -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-g -O0"
cmake --build build

# Run memcheck; exit code is non-zero if any test fails Valgrind checks
ctest --test-dir build -T memcheck --output-on-failure 2>&1 | tee valgrind-ci.log

# DartConfiguration.tcl in build/ records test metadata
# Detailed XML is under build/Testing/<timestamp>/DynamicAnalysis.xml

The DynamicAnalysis.xml file lists each test with its Valgrind defect counts. Parse it or upload it to a CDash dashboard for trend tracking across commits.


Suppression File Generation Workflow

Every real codebase eventually hits false positives from system libraries — a repeatable workflow prevents suppression files from growing into unreviewed noise.

Step 1 — Capture raw suppression candidates:

bash
valgrind --leak-check=full \
         --gen-suppressions=all \
         ./app 2>&1 | tee raw.txt

Step 2 — Extract the suppression blocks:

bash
grep -A 20 "^{" raw.txt > candidate.supp

Step 3 — Review manually. Open candidate.supp and keep only genuine false positives. Delete blocks that correspond to real bugs. For each remaining block, add a descriptive name and a comment explaining why it is suppressed:

cpp
{
   openssl_crypto_init_reachable
   # OpenSSL 3.x keeps internal state alive intentionally; not a real leak.
   Memcheck:Leak
   match-leak-kinds: reachable
   fun:malloc
   ...
   fun:CRYPTO_THREAD_run_once
   fun:OPENSSL_init_crypto
}

Step 4 — Apply the suppression file:

bash
valgrind --suppressions=myapp.supp --leak-check=full ./app

Scoping suppressions — broad fun: wildcards suppress the error anywhere in the call stack. Tighten them by adding more fun: frames to match a specific call path. Use obj: to suppress all errors originating from a shared library when you have no source:

cpp
{
   libpython_startup
   Memcheck:Leak
   match-leak-kinds: reachable
   fun:malloc
   obj:*/libpython3*.so*
}

Well-known public suppression files — rather than writing from scratch, start from community-maintained files:

LibrarySuppression file location
OpenSSLcontrib/valgrind.supp in OpenSSL source tree
glib / GLib/usr/share/doc/valgrind/valgrind.supp (Debian) or glib source glib.supp
Python (embedding)Misc/valgrind-python.supp in CPython source
Qtmkspecs/common/valgrind.supp in Qt source

Chain multiple suppression files with repeated --suppressions= flags:

bash
valgrind \
  --suppressions=openssl.supp \
  --suppressions=glib.supp \
  --suppressions=myapp.supp \
  ./app

See Also

  • Address Sanitizer — faster alternative for CI; catches stack overflows and use-after-return
  • Heaptrack — lower-overhead heap profiler with GUI; complements Massif
  • perf — hardware counter profiling; pairs with Cachegrind for cache analysis
  • GDB — step through errors that Valgrind's stack trace points to
Edit on GitHubUpdated 2025-01-01T00:00:00.000Z