Valgrind and Sanitizers in Practice

On this page

These tools discover errors by observing actual program execution. They provide earlier and more specific evidence than a generic "crash somewhere," but unexecuted paths may still contain issues. First, distinguish whether you are detecting out-of-bounds access, uninitialized data, language-defined undefined behavior, or unsynchronized multi-threaded access.

Quick Selection

IssueCommon ToolsKey Boundaries
Heap overflow, use-after-freeASan, MemcheckRelated to coverage, object layout, and allocator integration
Stack or global object overflowASanDoes not equate to detecting all internal object overflows
Uninitialized values affecting behaviorMemcheck, MSanMSan requires instrumentation of dependent libraries
Signed overflow, misaligned access, etc.UBSanOnly checks enabled items; does not cover all UB
Data racesTSan, HelgrindRequires coverage of relevant execution and synchronization mechanisms
Heap allocation growth, peaksMassif and other profilersMemory growth is not necessarily a leak
Reachability-based leaksLSan, MemcheckReachable objects may still be retained for business reasons

Detectors and profilers serve different purposes. Cachegrind provides simulated or instrumented statistics, which are not equivalent to real CPU PMU measurements; Massif describes memory changes over execution but does not guarantee it will identify all ownership errors for all objects.

Valgrind: Analyzing Machine Code of Execution

Valgrind uses dynamic binary translation to add analysis required by tools along the execution path. It typically does not require re-instrumentation of the target, but debug symbols help with localization; the target architecture, system calls, instruction set, and runtime must still be supported.

valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all \
  --errors-for-leak-kinds=definite,possible --error-exitcode=99 ./prog
valgrind --tool=memcheck --track-origins=yes ./prog
valgrind --tool=helgrind ./threaded_prog
valgrind --tool=massif ./prog

track-origins mainly helps trace the source of uninitialized values; it is not "turning it on to know where the leak was allocated." Memcheck tracks address accessibility and whether values are defined separately; an address can be valid, but the value inside it may not be initialized. Memcheck Manual

Memcheck is not a source-level array bounds checker for every array. If a stack or global array goes out of bounds but remains within an accessible region, it may not be reported. Static linking cannot be broadly stated as completely unsupported: allocator function replacement may be affected and needs to be handled according to the tool documentation. Valgrind FAQ

It requires additional shadow state and translation caches, so "no extra memory overhead" is incorrect. Time and memory overhead should be measured per specific tool and workload; you cannot fix the entire tool family to a single multiplier.

ASan: Checking if Accessed Addresses Are Allowed

ASan inserts checks at compile time and cooperates with runtime mechanisms such as allocators. A common implementation uses a shadow byte to describe 8 application bytes, combined with redzones around objects and poisoning after free; the quarantine queue delays address reuse, helping to detect use-after-free.

ASan Illustration: Valid address range and shadow state jointly checked Allocating 10 bytes: two 8-byte granularity shadow entries First 8 bytes fully accessible shadow = 0 2 bytes Valid Last 6 bytes Inaccessible Second shadow entry = 2 Common ASan mapping; redzones and poisoning after free are used to detect errors and do not prove that all object boundaries are safe.

This can detect many heap, stack, and global overflows, but does not track all program semantics. For example, an out-of-bounds access that happens to enter another valid object, certain internal object overflows, or accesses not instrumented may not be reported. ASan is also not responsible for general uninitialized value propagation. AddressSanitizer Documentation

How does one shadow byte describe eight bytes?

At size ten, granules are 00 and 02. Offset nine with width one is valid; width two includes out-of-bounds byte ten despite a valid start. After free, previously valid bytes are poisoned, separating object lifetime from the continued existence of a mapping.

Preparing the visual
How does one shadow byte describe eight bytes?

Shadow zero allows eight bytes, 1–7 allows a prefix, poison disallows access. Check the full access, not only its first byte.

A Practical Heap Overflow Example

Save the following as bounds.c. It intentionally allows the index to equal the length, using a separate access function to make the error easier to observe; the code is for local detector demonstration only.

#include <stdio.h>
#include <stdlib.h>

__attribute__((noinline))
static void store(unsigned char *p, size_t index) {
    p[index] = 7;
}

int main(int argc, char **argv) {
    size_t index = argc > 1 ? strtoul(argv[1], NULL, 10) : 10;
    unsigned char *p = malloc(10);
    if (p == NULL) return 1;
    store(p, index);
    printf("%u\n", (unsigned)p[index]);
    free(p);
    return 0;
}
clang -g -O1 -fno-omit-frame-pointer -fsanitize=address \
  bounds.c -o bounds-asan
./bounds-asan 9
./bounds-asan 10

9 is the last valid index; 10 points past the allocation interval and is expected to report heap-buffer-overflow. The access location in the report answers "where the illegal access occurred," and the allocation stack answers "where this region was created"; both need to be read together. Address and specific stack formats vary by build and run.

Fixes should explicitly define input ranges and data length relationships, such as validating index < 10 before access; simply increasing the malloc size by one byte ignores the issue of arbitrary indices. Keep the triggering input as a regression test case and re-run valid and invalid paths.

Fix Input Parsing and Bounds, Rather Than Expanding Allocation to Mask One Error

Save the following as bounds-fixed.c. It only accepts non-empty decimal digits, disallowing negative signs, trailing text, or values exceeding size_t; after successful conversion, it checks the business index range. Illegal input is rejected with exit code 2, and allocation failure with 1.

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

static int parse_index(const char *text, size_t *out) {
    size_t value = 0;
    if (*text == '\0') return 0;
    for (; *text; ++text) {
        if (*text < '0' || *text > '9') return 0;
        size_t digit = (size_t)(*text - '0');
        if (value > (SIZE_MAX - digit) / 10) return 0;
        value = value * 10 + digit;
    }
    *out = value;
    return 1;
}
int main(int argc, char **argv) {
    const size_t length = 10;
    size_t index = 0;
    if (argc != 2 || !parse_index(argv[1], &index) || index >= length) {
        fputs("expected index in 0..9\n", stderr);
        return 2;
    }
    unsigned char *bytes = malloc(length);
    if (!bytes) return 1;
    bytes[index] = 7;
    printf("%u\n", (unsigned)bytes[index]);
    free(bytes);
    return 0;
}
clang -g -O1 -fno-omit-frame-pointer -fsanitize=address,undefined \
  -fno-sanitize-recover=undefined bounds-fixed.c -o bounds-fixed
./bounds-fixed 0
./bounds-fixed 9
./bounds-fixed 10
./bounds-fixed 9junk
./bounds-fixed -1

The first two succeed and print 7; the last three should be rejected before memory access, rather than relying on ASan to terminate the process again. Regression tests should also include empty strings and huge numbers; successful parsing and valid addresses are two layers of conditions, and verifying only one layer is insufficient. The original strtoul did not check endptr and conversion errors; simply appending index < length would still parse some unexpected text as a valid prefix.

Three Locations in the Report Answer Different Questions

Report InformationHow to Trace
WRITE/READ of size NVerify actual access width and index, not just the pointer value
Access Call StackIdentify the path that executed out-of-bounds, continue tracing parameter sources
Allocation Region and Allocation StackVerify allocation length, element units, and lifetime
freed by (if present)Identify premature release and ownership relationships where old pointers are still held
Shadow StateHelps distinguish redzones, post-free regions, etc., but is not the business cause itself

For example, "address is located 0 bytes after the 10-byte region" means it steps exactly onto the one-past boundary. C allows constructing a one-past pointer for comparison or iteration endpoints under corresponding rules, but dereferencing it is not allowed; the report does not mean the tenth valid element was also incorrectly intercepted.

UBSan: Checking According to Language Rules

clang -g -O1 -fsanitize=undefined -fno-sanitize-recover=undefined \
  prog.c -o prog-ubsan

Common checks include signed integer overflow, illegal shifts, null pointers, and alignment issues. Unsigned integer overflow via modular arithmetic is typically a defined behavior in C/C++ and should not be collectively referred to as all "integer overflows" being UB; Clang provides additional checks for some suspicious but defined behaviors, which differ from the undefined group.

Many UBSan checks continue execution after reporting by default; automation requires explicit recovery, exit, or trap strategies. -fno-sanitize-recover=undefined causes the corresponding error to stop execution, rather than relying on humans reading logs to know the run failed. UBSan Documentation

Signed Overflow Needs to Be Judged Before Operation

The following overflow.c intentionally performs addition beyond the int range to observe the first UBSan error; volatile only preserves the read in this example and does not make overflow legal.

#include <limits.h>
#include <stdio.h>
int main(void) {
    volatile int maximum = INT_MAX;
    int result = maximum + 1;
    printf("%d\n", result);
    return 0;
}

Use the previous UBSan command, replacing prog.c with overflow.c. With recovery disabled, expect a non-zero exit and a signed integer overflow report; do not rely on numbers after overflow to continue calculations.

The correct approach is to judge before the addition occurs. Below is an independent checked-add.c:

#include <assert.h>
#include <limits.h>
static int checked_add(int a, int b, int *out) {
    if ((b > 0 && a > INT_MAX - b) || (b < 0 && a < INT_MIN - b)) return 0;
    *out = a + b;
    return 1;
}
int main(void) {
    int result = 7;
    assert(!checked_add(INT_MAX, 1, &result));
    assert(result == 7);
    assert(!checked_add(INT_MIN, -1, &result));
    assert(result == 7);
    assert(checked_add(INT_MAX, INT_MIN, &result) && result == -1);
    assert(checked_add(INT_MIN, 0, &result) && result == INT_MIN);
    assert(checked_add(20, 22, &result) && result == 42);
}

out is provided by the caller as a valid pointer in this example and does not modify the existing result on failure. Do not calculate a+b first and then detect signed overflow by whether the result becomes smaller, because the first addition may already have caused UB. Also, do not change the type to unsigned and claim the requirement is fixed; whether modular wraparound conforms to the business contract needs to be decided separately. UBSan Overflow Checks

TSan: Checking if Cross-Thread Access Establishes Synchronization

clang -g -O1 -fsanitize=thread -pthread threaded.c -o threaded-tsan

TSan discovers potential data races by combining memory accesses and synchronization events. The key is not "whether two threads write at exactly the same nanosecond," but whether conflicting accesses lack an appropriate happens-before relationship. Reports typically provide two access stacks and thread creation information.

Changing a normal variable to volatile does not establish thread synchronization. You should use mutexes, atomic operations, or other correct protocols based on the data structure; even with all atomic accesses, higher-level logical races that the tool does not report may still exist.

TSan and ASan usually require separate builds and cannot be simply combined into an all-in-one detector by concatenating parameters. Custom synchronization, uninstrumented libraries, inline assembly, and platform support affect results. ThreadSanitizer Documentation

MSan and Leak Detection

MSan tracks the initialization state of values and is mainly used in Clang environments with corresponding support. It usually requires the program and related dependencies to be consistently instrumented; recompiling only main and directly mixing in arbitrary precompiled libraries may lead to incomplete results or false positives. Origins can be enabled when tracing sources is needed, but this increases overhead. MemorySanitizer Documentation

LeakSanitizer can be integrated with ASan on supported platforms or used standalone, with default enabling depending on the platform. Reachability analysis considers memory with remaining references as not the same type of leak, but infinite growth of global caches may still be a business issue. Judgment should be combined with memory growth curves, lifecycles, and ownership, rather than just looking at "0 leaks."

Valid address, initialized value, and concurrent synchronization are different checking dimensions Not written after malloc Address accessible, value undefined Read after free Initialized value, invalid address Two unsynchronized writers Address and value may be valid Memcheck / MSan ASan / Memcheck TSan / Helgrind What is detected depends on execution coverage, instrumentation, and runtime support; no single tool provides a complete proof.

Replacing Everything with Atomic Operations Does Not Equal Algorithm Correctness

Consider two threads both atomically loading 0, then both atomically storing 1; these accesses may have no C/C++ data race, but the business requirement of "each increments once, final result should be 2" still fails. You should use atomic read-modify-write operations matching the needs, such as fetch_add, or use a mutex to cover the entire compound operation.

TSan reports help find missing synchronization edges but do not prove business balance conservation, tasks executing only once, or no message loss on exit for you. Concurrency regression should simultaneously assert business invariants and create critical interleavings via barriers or controlled scheduling, avoiding relying on repeated sleep to get lucky.

Separate Detector Failures from Program Errors in Records

If the runtime cannot initialize shadow addresses, the platform does not support relevant instructions, or the instrumentation library does not match, it means the detector did not perform a valid check and should not be recorded as "no errors found." Re-run after fixing the environment; when the tool reports real access errors, save the triggering input and matching artifacts, then fix the program contract.

Automation must at least distinguish between success, business rejection, detector-found errors, and detector self-failure. For the program fixed on this page, exit code 2 is expected input rejection; for the intentionally out-of-bounds program, a non-zero exit must be accompanied by an expected heap-buffer-overflow report, and arbitrary startup failures cannot be counted as test success.

Organize into a Stable Debugging Workflow

Prioritize saving minimal triggering inputs and matching binaries/symbols, and fix the earliest credible report first, because early overflows may corrupt subsequent state. Then expand input coverage, including boundary lengths, failure branches, and concurrent paths.

Correctly pass sanitizer options and link matching runtimes during both compilation and final linking stages. Suppressions for third-party issues should precisely explain the reason, avoiding hiding your own code issues with broad suppression. Detector overhead changes scheduling and layout; the disappearance of reports does not automatically prove the absence of concurrency errors.

Performance evaluation should ultimately return to builds conforming to delivery configurations; detector builds are for finding errors and cannot be directly used as the baseline performance of the original program. For CPU hotspot analysis, continue to Deep Dive into Perf.

Keywords: Valgrind, Memcheck, ASan, UBSan, TSan, MSan, LSan, shadow memory, redzone, data race, uninitialized value