04 — Sanitizers and Static Analysis¶
Sanitizers are dynamic bug detectors compiled directly into your binary. They catch classes of C++ bugs that no code review, no linter, and no unit test will consistently find: heap-use-after-free, data races between threads, signed-integer overflow, use of uninitialized memory. clang-tidy and clang-format are the static counterparts — they read your code without running it, catching style drift and known bug patterns before compilation. Together, these tools are the difference between “my tests pass locally” and “my code is safe to run in production.”
Every serious C++ shop — Google, Meta, NVIDIA, Microsoft, Bloomberg, financial-tech firms, and mature open-source projects (LLVM, Chromium, gRPC, PyTorch) — runs sanitizers in CI. If you cannot answer “which sanitizer catches what, and which combinations are legal?” in an study, you have not shipped C++. Fix that here.
1. The four sanitizers — what each catches¶
Sanitizer |
Flag |
Catches |
Overhead |
Combinable? |
|---|---|---|---|---|
ASan |
|
Heap/stack/global buffer overflow, use-after-free, use-after-return, double-free, memory leaks |
~2x slower, ~3x memory |
With UBSan. Not with TSan or MSan. |
UBSan |
|
Signed-int overflow, misaligned loads, invalid enum values, |
~20% slower |
With ASan, TSan, or MSan. |
TSan |
|
Data races, deadlocks (via annotation), unsafe locking patterns |
~5-15x slower, ~5x memory |
With UBSan. Not with ASan or MSan. |
MSan |
|
Reads of uninitialized memory (only). Requires all deps (incl. libc++) instrumented — Clang + Linux only, painful setup. |
~3x slower |
With UBSan. Not with ASan or TSan. |
Rule of thumb for CI: run one ASan+UBSan job and one TSan+UBSan job on every PR. MSan is Linux+Clang only and needs an instrumented libc++, so save it for a nightly job or skip it unless you have unmanaged input parsing code where uninitialized reads are a real risk.
Why can’t you combine ASan + TSan?¶
They use overlapping shadow-memory regions and instrument the same load/store instructions in incompatible ways. If you enable both, the binary either fails to link, crashes on startup, or reports garbage. Same for ASan + MSan. UBSan is different — it’s a set of tiny per-check instrumentations with no shadow memory, so it composes with all three.
2. CMake integration — parameterized sanitizers¶
The CMake pattern from 01_modern_cmake.md:
option(ENABLE_ASAN "AddressSanitizer" OFF)
option(ENABLE_UBSAN "UndefinedBehaviorSanitizer" OFF)
option(ENABLE_TSAN "ThreadSanitizer" OFF)
option(ENABLE_MSAN "MemorySanitizer" OFF)
function(add_sanitizers target)
if(ENABLE_ASAN AND ENABLE_TSAN)
message(FATAL_ERROR "ASan and TSan are incompatible; enable one at a time")
endif()
if(ENABLE_ASAN AND ENABLE_MSAN)
message(FATAL_ERROR "ASan and MSan are incompatible")
endif()
if(ENABLE_TSAN AND ENABLE_MSAN)
message(FATAL_ERROR "TSan and MSan are incompatible")
endif()
if(ENABLE_ASAN)
target_compile_options(${target} PRIVATE -fsanitize=address -fno-omit-frame-pointer -O1)
target_link_options(${target} PRIVATE -fsanitize=address)
endif()
if(ENABLE_UBSAN)
target_compile_options(${target} PRIVATE -fsanitize=undefined -fno-omit-frame-pointer)
target_link_options(${target} PRIVATE -fsanitize=undefined)
endif()
if(ENABLE_TSAN)
target_compile_options(${target} PRIVATE -fsanitize=thread -fno-omit-frame-pointer -O1)
target_link_options(${target} PRIVATE -fsanitize=thread)
endif()
if(ENABLE_MSAN)
target_compile_options(${target} PRIVATE -fsanitize=memory -fno-omit-frame-pointer -O1 -fPIE)
target_link_options(${target} PRIVATE -fsanitize=memory -pie)
endif()
endfunction()
Key extras:
-fno-omit-frame-pointer→ sanitizer stack traces resolve correctly.-O1under sanitizers → keep some optimization, else the report is verbose and slow.-fPIE -piefor MSan → required on Linux to avoidlibcinterposition issues.
Runtime environment for cleaner reports¶
Set these before running the binary:
export ASAN_OPTIONS="detect_leaks=1:abort_on_error=1:strict_string_checks=1:check_initialization_order=1:strict_init_order=1:print_stacktrace=1"
export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1"
export TSAN_OPTIONS="halt_on_error=1:second_deadlock_stack=1"
# ASan symbolizer path (macOS Homebrew LLVM):
export ASAN_SYMBOLIZER_PATH="$(brew --prefix llvm)/bin/llvm-symbolizer"
Wire these into your ctest runner so every sanitizer-flavored test run gets them.
3. clang-tidy — static analysis with real teeth¶
clang-tidy is a Clang-based linter that understands the AST. It ships hundreds of checks in named categories. You do not enable them all. You curate a set that matches your project.
.clang-tidy at repo root¶
---
Checks: >
bugprone-*,
clang-analyzer-*,
cppcoreguidelines-*,
modernize-*,
performance-*,
portability-*,
readability-*,
-bugprone-easily-swappable-parameters,
-cppcoreguidelines-avoid-magic-numbers,
-cppcoreguidelines-owning-memory,
-modernize-use-trailing-return-type,
-readability-magic-numbers,
-readability-identifier-length
WarningsAsErrors: 'bugprone-*,clang-analyzer-*,performance-*'
HeaderFilterRegex: '.*/(include|src)/.*\.(hpp|h)$'
FormatStyle: file
CheckOptions:
- key: readability-identifier-naming.NamespaceCase
value: lower_case
- key: readability-identifier-naming.ClassCase
value: CamelCase
- key: readability-identifier-naming.FunctionCase
value: lower_case
- key: readability-identifier-naming.PrivateMemberSuffix
value: '_'
What each family gives you:
bugprone-*— real bugs (use-after-move, unchecked null, incorrect suppression of exceptions). Always on.clang-analyzer-*— path-sensitive analysis, division by zero, null dereference across function boundaries. Always on.performance-*— unnecessary copy,std::stringconstruction fromnullptr, missingmove, inefficient container reads. Always on for a template repo.modernize-*—auto, range-for,nullptroverNULL,override,= default. On, with dropouts (use-trailing-return-typeis polarizing).cppcoreguidelines-*— Bjarne + Herb Sutter’s Core Guidelines. Turn on selectively; some are noisy (owning-memory,avoid-magic-numbers).readability-*— style. Curate.
Running it¶
# On one file:
clang-tidy src/layer.cpp -p build/
# On the whole project (from build dir with compile_commands.json):
run-clang-tidy -p build/ -header-filter='.*/(include|src)/.*' -quiet
# Auto-fix (careful — commit first):
run-clang-tidy -p build/ -fix -quiet
Requires CMAKE_EXPORT_COMPILE_COMMANDS=ON (in your presets already) so clang-tidy knows the compile flags for each file.
4. clang-format — remove all style discussions forever¶
Pick a base style and one file. This is not something to argue about at code review; it’s automated.
.clang-format at repo root¶
---
BasedOnStyle: Google
Language: Cpp
Standard: c++20
ColumnLimit: 100
IndentWidth: 4
TabWidth: 4
UseTab: Never
AccessModifierOffset: -4
NamespaceIndentation: None
FixNamespaceComments: true
AlignAfterOpenBracket: Align
AllowShortFunctionsOnASingleLine: Inline
AllowShortIfStatementsOnASingleLine: Never
BreakBeforeBraces: Attach
IncludeBlocks: Regroup
IncludeCategories:
- Regex: '^<.*\.h>' # C system headers
Priority: 1
- Regex: '^<.*>' # C++ std headers
Priority: 2
- Regex: '^"mynn/.*"' # our own
Priority: 4
- Regex: '.*' # third-party
Priority: 3
PointerAlignment: Left
ReferenceAlignment: Left
SortIncludes: CaseSensitive
Run:
find src include tests -name '*.cpp' -o -name '*.hpp' | xargs clang-format -i
Wire a pre-commit hook so it runs on git commit:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/mirrors-clang-format
rev: v18.1.8
hooks:
- id: clang-format
files: \.(cpp|hpp|h|cc|c)$
5. IWYU (include-what-you-use)¶
IWYU is a Clang-based tool that suggests header additions and removals so every source file directly #includes exactly what it uses (no more relying on transitive includes). It has notoriously false positives, but its recommendations, applied with judgment, produce cleaner build graphs.
brew install include-what-you-use # or apt install iwyu on Linux
# From build dir with compile_commands.json:
iwyu_tool.py -p build/ src/layer.cpp
# Auto-apply (with care):
iwyu_tool.py -p build/ src/layer.cpp -- -Xiwyu --update_comments | fix_include.py
Realistic policy: run IWYU quarterly, not per-commit. It’s a periodic clean-up tool, not a gate.
6. Full CI workflow example¶
.github/workflows/ci.yml fragment focused on the sanitizer/lint story (the full matrix is in 05_ci_and_docker.md):
name: ci
on: [push, pull_request]
jobs:
sanitizers:
strategy:
fail-fast: false
matrix:
san: [asan-ubsan, tsan-ubsan]
os: [ubuntu-24.04, macos-14]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install LLVM 18
if: matrix.os == 'ubuntu-24.04'
run: |
wget https://apt.llvm.org/llvm.sh
sudo bash llvm.sh 18
sudo apt-get install -y clang-tidy-18 clang-format-18
- uses: lukka/get-cmake@latest
- name: Configure (${{ matrix.san }})
env:
CC: clang-18
CXX: clang++-18
run: |
if [[ "${{ matrix.san }}" == "asan-ubsan" ]]; then
cmake --preset asan
else
cmake --preset tsan
fi
- name: Build
run: cmake --build --preset ${{ matrix.san == 'asan-ubsan' && 'asan' || 'tsan' }} -j
- name: Test
env:
ASAN_OPTIONS: detect_leaks=1:abort_on_error=1:print_stacktrace=1
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
TSAN_OPTIONS: halt_on_error=1
run: ctest --preset ${{ matrix.san == 'asan-ubsan' && 'asan' || 'tsan' }}
lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
wget https://apt.llvm.org/llvm.sh && sudo bash llvm.sh 18
sudo apt-get install -y clang-tidy-18 clang-format-18
- name: clang-format check
run: |
find src include tests -regex '.*\.\(cpp\|hpp\|h\|cc\)' \
-exec clang-format-18 --dry-run --Werror {} +
- name: Configure (for compile_commands.json)
run: cmake --preset debug
- name: clang-tidy
run: run-clang-tidy-18 -p build/debug -quiet
7. What most people get wrong¶
They “enable sanitizers” and don’t check that the tests actually ran under them. ASan silently no-ops if a shared library was compiled without the flag. Verify by adding a deliberate bug and confirming the sanitizer catches it.
They combine ASan and TSan and are baffled by cryptic link errors. Pick one per binary.
They enable every clang-tidy check and get 50,000 warnings, then disable clang-tidy. Curate. Start with
bugprone-*,performance-*,clang-analyzer-*. Add categories over weeks.They run
run-clang-tidywithout-p build/. Then clang-tidy has no compile commands, uses default flags, and produces spurious errors.They forget
-fno-omit-frame-pointer. Sanitizer reports become useless — the stack trace stops at the sanitizer runtime.They only run sanitizers on ubuntu-latest in CI. macOS finds different bugs (different libc, different memory layout). Include both.
They don’t
.clang-format. The first PR review of a new joiner is 80% “please reformat.” Automate this.They put
HeaderFilterRegextoo broad. clang-tidy tries to analyze headers from/usr/include/and slows to a crawl. Constrain to your source tree.
8. Practice exercises¶
Write a program that has a heap-use-after-free. Confirm ASan catches it with a clean stack trace pointing at your source line.
Write a program with a data race on a shared
int. Confirm TSan catches it. Then wrap theintinstd::atomic<int>. Confirm TSan is quiet.Write a program that does
int x = INT_MAX; x + 1;. Confirm UBSan reports signed overflow.Enable
performance-unnecessary-copy-initializationin clang-tidy. Find one real occurrence in your Phase 3 thread pool code. Fix it.Wire the CI matrix above into your P4.1 template. Get all four jobs green.