Memory Testing in C/C++: Valgrind, AddressSanitizer, and Memory Leak Detection

Memory Testing in C/C++: Valgrind, AddressSanitizer, and Memory Leak Detection

Memory bugs in C/C++ are notoriously hard to reproduce — use-after-free, buffer overflows, and memory leaks often cause failures far from their origin. Valgrind and AddressSanitizer (ASan) catch these at the point of occurrence with precise diagnostics. This guide shows you how to use both tools and integrate them into CI.


Two Approaches to Memory Testing

Valgrind (Memcheck)

  • Runs your program in an instrumented virtual machine
  • No recompilation required
  • Slower (5-20x slower)
  • Catches every memory error regardless of code path coverage
  • Works on any binary, even without debug symbols

AddressSanitizer (ASan)

  • Compiler instrumentation (compile with -fsanitize=address)
  • Fast (1.5-3x slower than uninstrumented code)
  • Requires recompilation
  • Catches errors only on executed code paths
  • Better error messages with source locations

Rule of thumb: Use ASan in CI (fast, CI-friendly). Use Valgrind for deep investigations of subtle bugs or legacy binaries you can't recompile.


AddressSanitizer (ASan)

Setup

# GCC or Clang — add flags at compile time
clang++ -fsanitize=address -fno-omit-frame-pointer -g -O1 myapp.cpp -o myapp
gcc -fsanitize=address -fno-omit-frame-pointer -g -O1 myapp.cpp -o myapp

In CMake:

# CMakeLists.txt
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)

if(ENABLE_ASAN)
    add_compile_options(-fsanitize=address -fno-omit-frame-pointer -g)
    add_link_options(-fsanitize=address)
endif()

Build with ASan:

cmake -B build -DENABLE_ASAN=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build
./build/myapp  # or ./build/tests

What ASan Catches

Heap Buffer Overflow

// Bug: writing past end of allocation
void heap_overflow_example() {
    int* arr = new int[5];
    arr[5] = 42;  // one past the end!
    delete[] arr;
}

ASan output:

==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000001c
WRITE of size 4 at 0x60200000001c thread T0
    #0 0x401234 in heap_overflow_example example.cpp:4

0x60200000001c is located 0 bytes to the right of 20-byte region
[0x602000000008,0x60200000001c)
allocated by thread T0 here:
    #0 0x7f1a2b3c4d5e in operator new[](unsigned long)
    #1 0x401220 in heap_overflow_example example.cpp:3

Use After Free

void use_after_free_example() {
    int* p = new int(42);
    delete p;
    *p = 100;  // use after free!
}

ASan output:

==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
WRITE of size 4 at 0x602000000010 thread T0
    #0 0x401234 in use_after_free_example example.cpp:4

0x602000000010 was freed by thread T0 here:
    #0 0x7f1a2b3c4d5e in operator delete(void*)
    #1 0x401228 in use_after_free_example example.cpp:3

Stack Buffer Overflow

void stack_overflow_example() {
    char buf[10];
    strcpy(buf, "hello world");  // 11 bytes into 10-byte buffer
}

Memory Leak

ASan reports leaks at program exit by default:

void memory_leak_example() {
    int* p = new int[100];
    // forgot delete[]
}
==12345==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 400 byte(s) in 1 object(s) allocated from:
    #0 0x7f1a2b3c4d5e in operator new[](unsigned long)
    #1 0x401234 in memory_leak_example example.cpp:2

ASan Configuration

# Disable leak detection (useful when testing subsystems, not whole programs)
ASAN_OPTIONS=detect_leaks=0 ./myapp

<span class="hljs-comment"># More verbose output
ASAN_OPTIONS=verbosity=1 ./myapp

<span class="hljs-comment"># Halt on first error vs. continue collecting
ASAN_OPTIONS=halt_on_error=0 ./myapp

<span class="hljs-comment"># Full configuration
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1:print_stats=1 ./myapp

MemorySanitizer (MSan)

Catches reads of uninitialized memory — a different class of bug from ASan:

clang++ -fsanitize=memory -fno-omit-frame-pointer -g myapp.cpp -o myapp
void uninitialized_read_example() {
    int x;
    if (x > 0) {  // read of uninitialized x
        do_something();
    }
}

MSan output:

==12345==WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 0x401234 in uninitialized_read_example example.cpp:3

Note: MSan requires all linked libraries to be compiled with MSan, which means you typically need to rebuild the standard library. Practically, use it for code you control; accept false negatives from system libraries.


UndefinedBehaviorSanitizer (UBSan)

Catches C++ undefined behavior: signed integer overflow, null pointer dereference, misaligned access, etc.

clang++ -fsanitize=undefined -g myapp.cpp -o myapp
# Or combine:
clang++ -fsanitize=address,undefined -g myapp.cpp -o myapp
void ubsan_examples() {
    // Signed integer overflow (UB in C++)
    int x = INT_MAX;
    int overflow = x + 1;  // UB!
    
    // Null pointer dereference
    int* p = nullptr;
    int val = *p;  // UB!
    
    // Signed shift past bitwidth
    int shifted = 1 << 32;  // UB on 32-bit int!
    
    // Array index out of bounds on static arrays
    int arr[5];
    int oob = arr[10];  // UB!
}

Valgrind Memcheck

Basic Usage

# Install
<span class="hljs-built_in">sudo apt install valgrind  <span class="hljs-comment"># Ubuntu/Debian
brew install valgrind      <span class="hljs-comment"># macOS (limited support)

<span class="hljs-comment"># Run your program under Valgrind
valgrind --tool=memcheck ./myapp

<span class="hljs-comment"># Full options for maximum detection
valgrind \
    --leak-check=full \
    --show-leak-kinds=all \
    --track-origins=<span class="hljs-built_in">yes \
    --verbose \
    --error-exitcode=1 \
    ./myapp

What Valgrind Catches

Invalid Memory Read

char* buf = malloc(5);
char c = buf[10];  // invalid read!
free(buf);
Invalid read of size 1
    at 0x40123: main (example.c:3)
Address 0x5204055 is 5 bytes after a block of size 5 alloc'd
    at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck.so)
    at 0x401229: main (example.c:2)

Memory Leak

LEAK SUMMARY:
   definitely lost: 100 bytes in 1 blocks
   indirectly lost: 0 bytes in 0 blocks
     possibly lost: 0 bytes in 0 blocks
   still reachable: 0 bytes in 1 blocks
  • Definitely lost — leaked, no pointer to it remains
  • Still reachable — pointer exists at exit, but was never freed (sometimes intentional)
  • Possibly lost — pointer arithmetic may be involved

Double Free

char* p = malloc(10);
free(p);
free(p);  // double free!
Invalid free() / delete / delete[] / realloc()
    at 0x4C30D3B: free (vg_replace_malloc.c:540)
    at 0x401238: main (example.c:4)
Address 0x5204050 is 0 bytes inside a block of size 10 free'd
    at 0x4C30D3B: free (vg_replace_malloc.c:540)
    at 0x40122F: main (example.c:3)

Running Tests Under Valgrind

With gtest:

valgrind --leak-check=full --error-exitcode=1 ./tests

With CTest:

# Add a Valgrind test target
find_program(VALGRIND valgrind)
if(VALGRIND)
    add_test(NAME valgrind_tests
        COMMAND ${VALGRIND}
            --leak-check=full
            --error-exitcode=1
            $<TARGET_FILE:unit_tests>
    )
    set_tests_properties(valgrind_tests PROPERTIES LABELS "memcheck")
endif()

Suppression Files

Valgrind reports errors in system libraries that you can't fix. Suppress them:

# valgrind.supp — suppress known false positives
{
    openssl_false_positive
    Memcheck:Leak
    fun:malloc
    obj:*/libssl.so*
}

{
    glibc_pthread_leak
    Memcheck:Leak
    fun:calloc
    fun:_dl_allocate_tls
}
valgrind --suppressions=valgrind.supp ./tests

ASan with Google Test

# CMakeLists.txt
add_executable(tests tests/test_*.cpp)
target_link_libraries(tests PRIVATE mylib GTest::gtest_main)

# Add ASan build configuration
target_compile_options(tests PRIVATE
    $<$<CONFIG:Debug>:-fsanitize=address,undefined -fno-omit-frame-pointer -g>
)
target_link_options(tests PRIVATE
    $<$<CONFIG:Debug>:-fsanitize=address,undefined>
)
cmake -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 ./build/tests

CI Integration

GitHub Actions with ASan

name: Memory Safety Tests

on: [push, pull_request]

jobs:
  asan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install clang
        run: sudo apt install -y clang
      
      - name: Configure with ASan
        run: |
          cmake -B build \
            -DCMAKE_C_COMPILER=clang \
            -DCMAKE_CXX_COMPILER=clang++ \
            -DCMAKE_BUILD_TYPE=Debug \
            -DENABLE_ASAN=ON
      
      - name: Build
        run: cmake --build build --parallel
      
      - name: Run tests with ASan
        env:
          ASAN_OPTIONS: detect_leaks=1:halt_on_error=1
        run: cd build && ctest --output-on-failure

  valgrind:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install valgrind
        run: sudo apt install -y valgrind cmake
      
      - name: Configure
        run: cmake -B build -DCMAKE_BUILD_TYPE=Debug
      
      - name: Build
        run: cmake --build build --parallel
      
      - name: Run under Valgrind
        run: |
          valgrind \
            --leak-check=full \
            --error-exitcode=1 \
            --suppressions=valgrind.supp \
            ./build/tests

Practical Workflow

Development

  1. Always compile debug builds with ASan — catch bugs immediately during development
  2. Run tests with ASan enabled — your test suite doubles as a memory checker
# Add to your development Makefile or script
build-test:
    cmake -B build -DCMAKE_BUILD_TYPE=Debug -DENABLE_ASAN=ON
    cmake --build build --parallel
    ASAN_OPTIONS=detect_leaks=1 ./build/tests

<span class="hljs-comment"># Quick check: production build without ASan
build-release:
    cmake -B build-release -DCMAKE_BUILD_TYPE=Release
    cmake --build build-release --parallel

CI

Stage Tool Speed Purpose
Every commit ASan Fast Catch regressions immediately
Every commit UBSan Fast Undefined behavior detection
Nightly Valgrind Slow Deep leak analysis
Pre-release Valgrind + MSan Very slow Comprehensive check

Common False Positives and Workarounds

ASan and Clang's libc++

Sometimes ASan reports leaks in system libraries:

LSAN_OPTIONS=suppressions=lsan.supp ./tests

Valgrind and OpenSSL

OpenSSL has known "still reachable" memory at exit. Use suppressions (see above).

Preloaded Libraries

# If ASan conflicts with LD_PRELOAD
ASAN_OPTIONS=verify_asan_link_order=0 ./tests

Interpreting Results Quickly

Error type First suspect
Heap buffer overflow Array index calculation, off-by-one
Stack buffer overflow sprintf/strcpy with unbounded input
Use after free Premature delete, dangling reference
Uninitialized read Missing constructor initialization
Memory leak Missing delete/free, exception before cleanup
Double free Shared ownership without shared_ptr

The fastest fix is usually not "where ASan reports the error" but "where the memory was allocated." Both Valgrind and ASan show the allocation site in the trace.


Next Steps

  • Enable ASan now in your CMakeLists.txt Debug configuration — low effort, high payoff
  • Add the CI step — 5 minutes of setup, catches an entire class of bugs
  • Combine with gtest — run the Google Test tutorial tests under ASan
  • Try UBSan too-fsanitize=address,undefined catches both classes in one build
  • Add to Catch2 — see the Catch2 guide for framework integration

Read more