Tooling for C: Sanitizers

Home · Blog

27 May 2025

Part of a series: Tooling for C

In my last article, I showed how to use the clang compiler for C. One feature I didn’t touch on is the -fsanitize flag, which enables one of the five “sanitizers” included in clang. I think this deserves its own article. Sanitizers are dynamic checkers that can detect certain errors, for example memory leaks, code reading an uninitialized value, or data races in multi-threaded code.

To use a sanitizer, you run the compiler with the corresponding flag, then run your program or tests as usual. If an error is detected, the program will print diagnostics, to standard error, hopefully giving you enough information to fix the issue.

Sanitizers work by adding instrumentation to the program. That can slow it down quite a bit, so you probably won’t be using them all the time, even during development.

Sample code

I’ve written some sample code with bugs that can be detected by the sanitizers. If you want to try it yourself, the full code is here: sample-code/c/sanitizers.

I’ve tested it with clang 19.1. For the most part, it should also work with GCC, though, which has the same sanitizers. The exception is that GCC doesn’t have Memory Sanitizer.

Undefined Behavior Sanitizer

The Undefined Behavior Sanitizer detects various kinds of undefined behavior in C code. Not all undefined behavior, though -- it won’t catch use-after-free errors, for example. Its runtime cost is small, so you could just enable this one for all development builds.

As an example, here’s a program that will cause a signed integer overflow, which an error in C:

#include <limits.h>
#include <stdio.h>

void negate(int x) {
	// signed integer overflow, will be caught by undefined behavior sanitizer
	int negated = -x;
	printf(" x is %d\n", x);
	printf("-x is %d\n", negated);
}

int main() {
	negate(INT_MIN);
	return 0;
}

If I compile and run the program like this:

$ clang -std=c23 -g -O0 -fsanitize=undefined -o undefined undefined.c
$ ./undefined

it prints the following to standard error1:

undefined.c:6:16: runtime error: negation of -2147483648 cannot be represented in
    type 'int'; cast to an unsigned type to negate this value to itself
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior undefined.c:6:16

As you can see, it tells us exactly what went wrong and where (undefined.c:6:16 is indeed the minus sign).

Leak Sanitizer

Going from least overhead to most overhead, the next one is Leak Sanitizer. It just detects memory leaks. Here’s a trivial example:

#include <stdlib.h>

int main() {
	// memory leak, will be caught by leak sanitizer
	malloc(1000);
	return 0;
}

And the output when compiled with -fsanitize=leak:

=================================================================
==34703==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 1000 byte(s) in 1 object(s) allocated from:
    #0 0x557ffc1fc0d9 in malloc
        (/home/levin/code/sample-code/c/sanitizers/leak+0x340d9)
        (BuildId: 9f9d5d508f991315c62be118681beea969beeb02)
    #1 0x557ffc1ff438 in main
        /home/levin/code/sample-code/c/sanitizers/leak.c:5:2
    #2 0x7f76202bb249 in __libc_start_call_main
        csu/../sysdeps/nptl/libc_start_call_main.h:58:16

SUMMARY: LeakSanitizer: 1000 byte(s) leaked in 1 allocation(s).

The output is a bit verbose, but it still tells us what went wrong and where.

Address Sanitizer

Address Sanitizer detects several types of bugs related to invalid use of the heap, the stack, or global variables. According to the documentation, it typically slows down your code by a factor of 2.

Here’s a sample program that uses heap memory after it’s been deallocated:

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

int main() {
	int * p = malloc(sizeof(int));
	free(p);
	int i = *p; // use-after-free, will be caught by address sanitizer
	printf("i is %d\n", i);
	return 0;
}

The Address Sanitizer output is very verbose, so I’ll just show the summary line here:

SUMMARY: AddressSanitizer: heap-use-after-free
    /home/levin/code/sample-code/c/sanitizers/address.c:7:10 in main

The full output also includes the source location where the memory was allocated and where it was deallocated.

Memory Sanitizer

Memory Sanitizer detects invalid use of variables and heap memory that haven’t been initialized. Here’s a simple example:

#include <stdio.h>

int main() {
	int x;
	// uninitialized read, will be caught by memory sanitizer
	printf("x is %d\n", x);
	return 0;
}

and the sanitizer’s summary line:

SUMMARY: MemorySanitizer: use-of-uninitialized-value
    /home/levin/code/sample-code/c/sanitizers/memory.c:6:2 in main

Thread Sanitizer

The final one is Thread Sanitizer, which detects data races in multi-threaded code. Here’s the simplest example I’ve come up with:

#include <assert.h>
#include <pthread.h>
#include <stdio.h>

size_t number = 0;

void * set_number(void * p) {
	size_t * value = p;
	number = *value; // data race, will be caught by ThreadSanitizer
	return nullptr;
}

int main() {
	pthread_t t1;
	size_t v1 = 4;
	assert(!pthread_create(&t1, nullptr, set_number, &v1));

	pthread_t t2;
	size_t v2 = 5;
	assert(!pthread_create(&t2, nullptr, set_number, &v2));

	assert(!pthread_join(t1, nullptr));
	assert(!pthread_join(t2, nullptr));

	printf("number is: %zu\n", number);

	return 0;
}

This starts two threads using the pthreads library and waits for them to finish. Both threads write to the same global variable, without a mutex or other protection. Here’s the output after compiling with -fsanitize=thread:

==================
WARNING: ThreadSanitizer: data race (pid=35590)
  Write of size 8 at 0x55a5bebf2698 by thread T2:
    #0 set_number /home/levin/code/sample-code/c/sanitizers/thread.c:9:9
        (thread+0xe45c8) (BuildId: e9893c1ca4be04f0ba571013588708df7a3909ef)

  Previous write of size 8 at 0x55a5bebf2698 by thread T1:
    #0 set_number /home/levin/code/sample-code/c/sanitizers/thread.c:9:9
        (thread+0xe45c8) (BuildId: e9893c1ca4be04f0ba571013588708df7a3909ef)

  Location is global 'number' of size 8 at 0x55a5bebf2698 (thread+0x1490698)

  Thread T2 (tid=35593, running) created by main thread at:
    #0 pthread_create <null>
        (thread+0x61ea1) (BuildId: e9893c1ca4be04f0ba571013588708df7a3909ef)
    #1 main /home/levin/code/sample-code/c/sanitizers/thread.c:20:2
        (thread+0xe467f) (BuildId: e9893c1ca4be04f0ba571013588708df7a3909ef)

  Thread T1 (tid=35592, finished) created by main thread at:
    #0 pthread_create <null>
        (thread+0x61ea1) (BuildId: e9893c1ca4be04f0ba571013588708df7a3909ef)
    #1 main /home/levin/code/sample-code/c/sanitizers/thread.c:16:2
        (thread+0xe4630) (BuildId: e9893c1ca4be04f0ba571013588708df7a3909ef)

SUMMARY: ThreadSanitizer: data race
    /home/levin/code/sample-code/c/sanitizers/thread.c:9:9 in set_number
==================
ThreadSanitizer: reported 1 warnings

Thread Sanitizer is a bit limited -- it only works with pthreads, it has very high overhead, and it doesn’t work on every platform. Still, data races are such a pain to debug that this is still an important tool to have available if you happen to work with C and pthreads.


1 I’ve added some line breaks to the sanitizer outputs for readability; otherwise they’re copied straight from the terminal.