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

-fsanitize=address

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

-fsanitize=undefined

Signed-int overflow, misaligned loads, invalid enum values, nullptr deref of member, oversized shift, float→int overflow

~20% slower

With ASan, TSan, or MSan.

TSan

-fsanitize=thread

Data races, deadlocks (via annotation), unsafe locking patterns

~5-15x slower, ~5x memory

With UBSan. Not with ASan or MSan.

MSan

-fsanitize=memory

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.

  • -O1 under sanitizers → keep some optimization, else the report is verbose and slow.

  • -fPIE -pie for MSan → required on Linux to avoid libc interposition 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::string construction from nullptr, missing move, inefficient container reads. Always on for a template repo.

  • modernize-*auto, range-for, nullptr over NULL, override, = default. On, with dropouts (use-trailing-return-type is 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-tidy without -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 HeaderFilterRegex too broad. clang-tidy tries to analyze headers from /usr/include/ and slows to a crawl. Constrain to your source tree.

8. Practice exercises

  1. Write a program that has a heap-use-after-free. Confirm ASan catches it with a clean stack trace pointing at your source line.

  2. Write a program with a data race on a shared int. Confirm TSan catches it. Then wrap the int in std::atomic<int>. Confirm TSan is quiet.

  3. Write a program that does int x = INT_MAX; x + 1;. Confirm UBSan reports signed overflow.

  4. Enable performance-unnecessary-copy-initialization in clang-tidy. Find one real occurrence in your Phase 3 thread pool code. Fix it.

  5. Wire the CI matrix above into your P4.1 template. Get all four jobs green.


Nav: ← 03 Testing and benchmarks · Next: 05 CI and Docker →