TLDR

I added a warning to clang that tracks each pointer through a function’s control-flow graph and catches null dereferences at compile time. It plugs into clang’s existing pipeline, runs fast enough for every build, and you can try it in the playground. This post follows one function through the compiler to show how it works.

Background

C and C++ are among the few widely-used languages where every pointer is implicitly nullable and the type system has no way to distinguish nullable from non-nullable. In Rust, Swift, Kotlin, and TypeScript, a nullable value and a non-nullable value are different types: string and string | null are not interchangeable, and the compiler enforces the check. In C, int *p might be null, and nothing stops you from writing *p without looking. Dereference a null pointer and your program dies on the spot, at run time, on a user’s machine, when the fix was sitting right there in the source.

This wasn’t for lack of trying:

  • GCC added __attribute__((nonnull)) to annotate function parameters decades ago
  • The C++ Core Guidelines introduced gsl::not_null<T> as a library wrapper
  • Clang added _Nullable and _Nonnull type qualifiers; Apple shipped them so Objective-C headers could import into Swift as optionals
  • Herb Sutter’s safety profiles (a committee proposal to bolt memory-safety guarantees onto existing C++) proposed making null checks enforceable at the language level

But changing a specification used by millions of codebases is genuinely hard, and none of these became part of the C or C++ standard. The annotations that exist are optional, advisory, and largely ignored.

What you can do, without changing the language, is statically analyze code for pointers that might be null. I added a warning to clang that does this: it tracks each pointer through a function’s control flow, statement by statement, and warns when a possibly-null pointer is dereferenced without a check. This post walks through how it works.

The pipeline

Here’s a function that triggers the warning:

int first_value(struct node *n) {
  return n->value;  // warning: dereference of nullable pointer
}

That’s plain C, no annotations. _Nullable/_Nonnull are a clang extension, not part of the C standard, so most real code doesn’t carry them either. The mode this post uses treats every unannotated pointer as possibly null, so n is nullable by default and the dereference warns.

The fix is a null check:

struct node { int value; };

int first_value(struct node *n) {
  if (!n) return 0;
  return n->value;  // no warning: n is proven non-null here
}

The warning has to be smart enough to stay quiet on the second version and speak up on the first. Getting that right is the whole game. We’ll trace the fixed version through the pipeline to see how.

Source your code, as text AST syntax tree (typed by Sema) CFG blocks + branch edges Analysis walk it, flag null derefs

The AST: from text to a typed tree

The compiler’s first job is to stop seeing your code as a string of characters and start seeing its structure. The parser (clang/lib/Parse/) groups characters into tokens (if, (, !, n…) and arranges them into a tree that mirrors the grammar of the language.

That tree is the AST (Abstract Syntax Tree), defined in clang/include/clang/AST/ across files like Expr.h, Stmt.h, and Decl.h. It is the single most important data structure in a compiler. Almost everything downstream is a walk over this tree.

You can ask clang to print the tree for any file. Save both the struct and the function in node.c, then run:

clang -Xclang -ast-dump -fsyntax-only node.c 2>&1 | tail -20

The tail filters out hundreds of implicit builtin typedefs that clang dumps before your code. In your terminal the output is colorized (green for node types, blue for source locations, bold for identifiers), making the tree easy to scan. What you get (lightly trimmed, minus the colors) is the AST for our function:

FunctionDecl first_value 'int (struct node *)'
|-ParmVarDecl n 'struct node *'
`-CompoundStmt                       // the { ... } body
  |-IfStmt
  | |-UnaryOperator '!'              // the condition: !n
  | | `-DeclRefExpr 'n'
  | `-ReturnStmt
  |   `-IntegerLiteral 'int' 0       // return 0
  `-ReturnStmt
    `-MemberExpr ->value 'int'       // n->value (the -> means pointer dereference)
      `-ImplicitCastExpr <LValueToRValue>
        `-DeclRefExpr 'n' 'struct node *'

Read it top-down and it’s just your function:

  • FunctionDecl holds a parameter n and a body
  • The body holds an IfStmt and a ReturnStmt
  • The IfStmt holds the condition (!n) and the early return
  • MemberExpr ->value is the dereference: the -> (as opposed to .) means “dereference pointer, then access field.” There’s no separate “deref” node; the arrow flag on MemberExpr is the dereference, and that’s what the analysis checks
  • Each node knows its type: n is struct node * everywhere it appears

That type, nullability and all, rides along on every reference to n, which is exactly what the analysis reads later.

Semantic analysis: giving the tree types

Parsing gives you shape; it doesn’t give you meaning. The parser knows n->value is a member access, but it doesn’t yet know that value is an int, that n is the parameter declared two lines up, or that the whole expression is well-typed.

Filling that in is the job of semantic analysis, or “Sema” in clang’s codebase (clang/lib/Sema/, the largest directory in clang by far: 71 Sema*.cpp files, split by domain: SemaExpr.cpp for expressions, SemaDecl.cpp for declarations, SemaType.cpp for type resolution, SemaOverload.cpp for overload resolution, and so on). It doesn’t produce a separate data structure; it enriches the AST in place. Here’s the MemberExpr node for n->value before and after Sema:

// After parsing (shape only):
MemberExpr ->value
  `-DeclRefExpr 'n'

// After Sema (typed, from clang -ast-dump):
ImplicitCastExpr 'int' <LValueToRValue>        // load the int result
  `-MemberExpr ->value 'int' lvalue             // access the field
    `-ImplicitCastExpr 'struct node *' <LValueToRValue>  // load the pointer
      `-DeclRefExpr 'struct node *' lvalue ParmVar 'n'   // resolved to ParmVarDecl

Sema resolved n to its ParmVarDecl, attached the type struct node *, and inserted two implicit casts: one to load the pointer value from n, another to load the int result from the field. Every expression node gets a QualType, clang’s representation of a type plus qualifiers, and that QualType is what our analysis reads to decide whether a pointer is nullable.

In summary, Sema:

  • Resolves names to declarations (n links to its ParmVarDecl)
  • Checks and attaches types (n->value gets type int)
  • Inserts implicit nodes (casts, conversions, promotions)
  • Attaches nullability qualifiers (_Nullable, _Nonnull, or nothing)

Sema is also where compiler warnings live. When the parser finishes a function body, lib/Sema/AnalysisBasedWarnings.cpp runs a battery of analyses over it: “is any variable used before it’s initialized?”, “is any code unreachable?”, “are thread-safety annotations respected?” My null-pointer checker is one more analysis in that lineup, plugged into the same hook. They all start from the typed AST.

From the tree to a flow graph

So you have a tree and you want to “do something at every node of a certain kind.” Compilers solve this with the visitor pattern: you write a class with a method per node type, and a framework drives it over the tree, calling your method each time it meets a matching node. In clang this is RecursiveASTVisitor (and a newer variant, DynamicRecursiveASTVisitor). Conceptually:

struct FindDerefs : RecursiveASTVisitor<FindDerefs> {
  bool VisitMemberExpr(MemberExpr *E) { ... }      // a->b
  bool VisitUnaryOperator(UnaryOperator *UO) { ... } // *p
  bool VisitCallExpr(CallExpr *CE) { ... }           // f(p)
  // one method per node type you care about; the framework walks the tree
};

You never write the recursion yourself; you just declare which nodes you care about. This is how a huge amount of compiler tooling works: linters, refactoring tools, and clang-tidy checks are mostly visitors that pattern-match on AST shapes.

For many checks, a plain tree walk is enough. Want to warn on every goto?

struct WarnOnGoto : RecursiveASTVisitor<WarnOnGoto> {
  bool VisitGotoStmt(GotoStmt *G) {
    diag(G->getGotoLoc(), "goto considered harmful");
    return true;
  }
};

That’s it. One method, one node type, full coverage. You might try the same thing for null derefs:

struct NaiveNullWarn : RecursiveASTVisitor<NaiveNullWarn> {
  bool VisitMemberExpr(MemberExpr *E) {
    if (auto *Base = dyn_cast<DeclRefExpr>(E->getBase()))
      if (Base->getType()->isPointerType())
        diag(E->getExprLoc(), "possible null dereference");
    return true;
  }
};

This warns on every p->field where p is a pointer. It would flag n->value in our example, even though if (!n) return 0; guarantees n is non-null at that point. A tree walk can’t see that because it doesn’t understand order and flow.

The control-flow graph

That missing sense of order and flow is exactly what the next structure adds. Where the AST encodes how code nests, the Control-Flow Graph (CFG) encodes how it runs — which statements can execute before which.

The CFG chops the function into basic blocks: straight-line runs of statements with no decisions inside them. Every decision becomes an edge between blocks — an if, for example, ends its block with two outgoing edges, one per branch. A block never branches internally; it runs start to finish, then control follows one of its outgoing edges. (Loops add a back-edge, and &&/|| add short-circuit edges; we’ll get to those later.)

That edge split is the whole point. Take our body:

if (!n) return 0;
return n->value;     // is this safe?

The naive visitor saw only the MemberExpr and warned. The CFG sees what it couldn’t: if (!n) return 0; sits on its own branch, so the only path that reaches n->value is the one where !n was false — where n is non-null. clang builds the CFG from the AST in clang/lib/Analysis/CFG.cpp, and you can print it:

clang -cc1 -analyze -analyzer-checker=debug.DumpCFG node.c

Here’s the CFG for our first_value function (the one that checks if (!n) before dereferencing n->value), simplified to its shape:

entry if (!n) return 0; return n->value; exit !n true → n IS null !n false → n NON-null

Now the structure tells the story. The if (!n) block has two outgoing edges:

  • Left edge: !n was true, so n is null. That path returns 0 immediately.
  • Right edge: !n was false, so n is guaranteed non-null. That’s the only way to reach n->value.

The dereference is provably safe, and the CFG is what makes “provably” possible.

The analysis: tracking pointers block by block

This is what the checker actually does, and we can look at the real clang internals. What we’re doing has a name:

The worklist loop at the bottom of FlowNullability.cpp owns one NullState per block, stores it in a DenseMap<BlockID, NullState>, and passes it by reference to TransferFunctions. Each visit() call mutates the state in place as it processes statements. This is standard dataflow plumbing, not a hack: clang’s existing -Wuninitialized and -Wunreachable-code work the same way. The state itself is a small struct. The core is two sets of variable declarations, keyed on clang’s VarDecl (the AST node for a declared variable):

// clang/lib/Analysis/FlowNullability.cpp  (trimmed)
struct NullState {
  // Plain variables/parameters, keyed by their clang VarDecl:
  llvm::DenseSet<const VarDecl *> NarrowedVars;     // proven non-null right now
  llvm::DenseSet<const VarDecl *> NullableVars;     // known nullable right now

  // The same two facts, but for member-access paths like `obj.inner.next`,
  // so a null check on a field narrows that field (not just whole variables):
  llvm::DenseSet<MemberAccessPath> NarrowedMembers;
  llvm::DenseSet<MemberAccessPath> NullableMembers;

  // `bool ok = (p != nullptr);` remembers the bool `ok` stands for a
  // null check on `p`, so a later `if (ok)` can narrow `p`. Maps the bool's
  // VarDecl to (the checked pointer, whether true means null):
  using BoolGuardMap =
      llvm::DenseMap<const VarDecl *, std::pair<const VarDecl *, bool>>;
  BoolGuardMap BoolGuards;

  // `q = p;` makes q and p the same pointer, so checking one narrows both.
  // Maps an alias to the variable it points at:
  using AliasMap = llvm::DenseMap<const VarDecl *, const VarDecl *>;
  AliasMap Aliases;
};

That’s the whole state: a few sets and maps keyed on clang’s declaration nodes. NarrowedVars is the heart of it; membership is the fact “we’ve proven this pointer non-null at this program point.” The lookup is a one-liner:

bool isNarrowed(const VarDecl *VD) const {
  return State.NarrowedVars.contains(VD);
}

Each kind of statement gets a small transfer function that reads and updates that state. The class that drives this is TransferFunctions in FlowNullability.cpp. It holds a mutable reference to the NullState and dispatches each statement to a handler:

// clang/lib/Analysis/FlowNullability.cpp (trimmed)
class TransferFunctions {
  NullState &State;
  FlowNullabilityHandler &Handler;

  void visit(const Stmt *S) {
    if (const auto *DS = dyn_cast<DeclStmt>(S))
      handleDeclStmt(DS);
    else if (const auto *BO = dyn_cast<BinaryOperator>(S))
      handleBinaryOperator(BO);
    else if (const auto *UO = dyn_cast<UnaryOperator>(S))
      handleUnaryOperator(UO);
    else if (const auto *ME = dyn_cast<MemberExpr>(S))
      handleMemberExpr(ME);
    else if (const auto *CE = dyn_cast<CallExpr>(S))
      handleCallExpr(CE);
    else if (const auto *RS = dyn_cast<ReturnStmt>(S))
      handleReturnStmt(RS);
    // ... plus array subscripts, construct exprs, init lists
  }
};

The entry point is runFlowNullabilityAnalysis in FlowNullability.cpp. It asks clang for the CFG, sets up the initial state, and runs a worklist loop. The worklist itself is clang’s ForwardDataflowWorklist (clang/include/clang/Analysis/FlowSensitive/DataflowWorklist.h), a priority queue of CFGBlock pointers that dequeues in reverse post order (predecessors before successors). The same worklist powers -Wuninitialized and other dataflow analyses; we just reuse it.

// clang/lib/Analysis/FlowNullability.cpp (trimmed)
void runFlowNullabilityAnalysis(AnalysisDeclContext &AC,
                                FlowNullabilityHandler &Handler, ...) {
  CFG *Cfg = AC.getCFG();           // clang builds the CFG on demand
  NullState InitState;              // empty: nothing proven yet
  Worklist.enqueueBlock(&Entry);    // seed with the entry block

  // Then: iterate until states stabilize
  while (const CFGBlock *Block = Worklist.dequeue()) {

Inside that loop, each block goes through three steps. This is our code in FlowNullability.cpp, using the CFG that clang built:

// For each block in the worklist:
TransferFunctions TF(State, Handler, Ctx, StrictMode, Default);

// 1. Walk statements inside the block (transfer functions):
for (const auto &Elem : *Block)
  if (auto CS = Elem.getAs<CFGStmt>())
    TF.visit(CS->getStmt());    // mutates State in place

// 2. Handle edges: split state for branch terminators:
NullState TrueState = State;
NullState FalseState = State;
if (const auto *IS = dyn_cast<IfStmt>(Block->getTerminatorStmt())) {
  // e.g., if (!n): TrueState marks n nullable, FalseState narrows n
  applyCondition(IS->getCond(), TrueState, FalseState);
}

// 3. Propagate the right state along each outgoing edge:
for (auto [SucIdx, Succ] : enumerate(Block->succs()))
  EdgeStates[{BlockID, Succ->getBlockID()}] =
      (Block->succ_size() == 2) ? (SucIdx == 0 ? TrueState : FalseState)
                                : State;

Step 1 is the transfer functions: they process statements within a block, updating NullState as they go (handleMemberExpr, handleBinaryOperator, etc.). Step 2 is the edge logic: at a branch terminator like if (!n), the state forks into TrueState and FalseState, each with different narrowing. Step 3 propagates the appropriate state to each successor block.

When the analysis visits a dereference like n->value, it goes through two steps. First, in handleMemberExpr (FlowNullability.cpp), it checks whether the pointer has already been proven safe:

// clang/lib/Analysis/FlowNullability.cpp, handleMemberExpr (trimmed)
if (const auto *DRE = dyn_cast<DeclRefExpr>(Base)) {
  if (const auto *VD = dyn_cast<VarDecl>(DRE->getDecl())) {
    if (!isNarrowed(VD))       // <-- guard: skip if proven non-null
      checkVarDeref(ME, VD);   // only reached for un-narrowed pointers
  }
}

If the pointer isn’t narrowed, checkVarDeref asks whether the type is nullable:

void checkVarDeref(const Expr *DerefExpr, const VarDecl *VD) {
  QualType Ty = VD->getType();
  if (isNullableType(Ty, StrictMode, DefaultNullability) ||
      State.NullableVars.contains(VD)) {
    ++NumDereferenceWarnings;
    return Handler.handleNullableDereference(DerefExpr, Ty);
  }
}

The warning is never even considered for a narrowed variable.

handleNullableDereference doesn’t print anything itself. Clang has a Handler interface that separates the analysis from what you do with the results, so the same CFG walk can drive different consumers:

  • A compiler warning (-Wflow-nullable-dereference)
  • An IDE squiggle (same handler, surfaced by clangd)
// clang/include/clang/Analysis/Analyses/FlowNullability.h
class FlowNullabilityHandler {
public:
  virtual void handleNullableDereference(const Expr *DerefExpr, QualType) = 0;
  virtual void handleNullableReturn(const Expr *, QualType, QualType) = 0;
  virtual void handleNullableArgument(const Expr *, const ParmVarDecl *) = 0;
  // ...one hook per kind of finding
};

The other half is putting variables into NarrowedVars. That happens at branches.

When a block ends in an if, the walk makes two copies of the state (one per outgoing edge) and applies the condition to each. On the edge where the condition proves n non-null, it inserts n:

NullState TrueState = State;
NullState FalseState = State;
// ...analyze the terminator condition, then on the proving edge:
TrueState.NarrowedVars.insert(VD);   // 'n' is non-null down this edge

And where two edges rejoin, the states merge. This merge operation has a name, and so does the structure that guarantees it behaves:

So the merge is a set intersection: proven on both edges, or dropped:

// join(): start with an empty state, keep a narrowing only if BOTH predecessors had it
NullState Result;  // all sets empty
for (const auto *VD : A.NarrowedVars)
  if (B.NarrowedVars.contains(VD))
    Result.NarrowedVars.insert(VD);
edge from branch A: proven non-null n, p (checked n and p here) edge from branch B: proven non-null n, q (checked n and q here) merge point: join() after join: still proven n intersection: only n was proven on both paths

p, q dropped: proven on only one path, so unsafe to assume

Walking through first_value

That’s the machinery. Here’s what actually happens when the analysis runs on our example, block by block. The worklist loop lives at the bottom of FlowNullability.cpp (around line 2637): it dequeues a block, merges incoming edge states with join(), runs the transfer functions on every statement, then computes edge states for successors.

Entry block. The analysis initializes a NullState. Parameter n has type struct node *, an unannotated pointer. In strict mode (-fnullability-default=nullable), that’s treated as nullable. NarrowedVars starts empty. The entry state is stored and the entry block is enqueued.

Block: if (!n). The worklist pops this block. No statements to process (the condition is the terminator, not a statement). The analysis hits the IfStmt terminator, calls analyzeCondition on !n, and gets back a ConditionResult saying “this is a null check on n, negated.” It makes two copies of the state:

  • True edge (!n is true, meaning n IS null): state unchanged, n stays nullable. This edge leads to return 0.
  • False edge (!n is false, meaning n is NOT null): narrowWithAliases(FalseState, n) runs, which calls NarrowedVars.insert(n). This edge leads to return n->value.

Both successor blocks are enqueued with their edge states.

Block: return 0. Nothing to check. No dereferences. Block reaches exit.

Block: return n->value. The transfer function visits a MemberExpr with isArrow() == true, meaning this is n->value, a pointer dereference. It calls handleMemberExpr, which finds the base is a DeclRefExpr for n, a VarDecl. It checks isNarrowed(n): yes, n is in NarrowedVars (the false edge put it there). So checkVarDeref is never called. No warning. The null check worked.

What about the buggy version? Go back to the original function without the if (!n) guard. Now there’s only one path from entry to return n->value, no branch ever narrows n, so NarrowedVars is empty when handleMemberExpr runs. isNarrowed(n) returns false, checkVarDeref fires, and handleNullableDereference produces:

warning: dereference of nullable pointer 'struct node *' [-Wflow-nullable-dereference]
    return n->value;
              ^
note: add a null check before dereferencing, or annotate as '_Nonnull' if this pointer cannot be null

Soundness vs. completeness

How aggressive should a static analysis be?

You can rarely have both, so a checker picks a side.

We chose few false alarms. A compiler warning that cries wolf gets switched off, and a switched-off check finds zero bugs. Ours stays quiet unless it’s confident, and deliberately accepts some missed bugs as the price. Concretely, it gives up rather than guess in two situations:

  • Address escape: when a pointer’s address is taken (&n passed somewhere that could overwrite it)
  • Deep aliasing: when alias chains run more than one hop deep

Those are real holes, and they’re the honest cost of a warning developers actually leave on.

Other approaches to null-deref detection

Our analysis is one point in a design space. A few other approaches exist:

  • Symbolic execution (KLEE, old Coverity): explores each path through the function independently, carrying full path conditions. Never merges, so never loses precision, but path count is exponential in the number of branches. Suited to offline analysis tools, not compiler warnings.
  • Abstract interpretation (Facebook’s Infer): same family as our approach (abstract states, fixpoints), but with a more formal lattice framework and inter-procedural analysis by default. Designed as a standalone tool, not a -Wall flag.
  • SAT/SMT solvers (Google’s Crubit): described below.

Google’s Crubit project takes a more powerful approach. Crubit’s nullability checker represents what’s known at each program point as boolean formulas and hands questions like “can p be null here?” to a SAT solver.

A solver reasons about how conditions relate, so it follows correlations our flat sets can’t:

  • p is non-null exactly when flag is set”
  • A guard stashed in a bool three branches back
  • Two pointers that are null together

It catches cases we wave through. The price: a solver runs per function, ships as a separate tool rather than a built-in warning, and you pay real time for that precision.

The tradeoff is rooted in computational complexity. Boolean satisfiability (SAT) is the original NP-complete problem: worst-case exponential in the number of variables. Modern solvers dodge that worst case on real inputs, but there’s no guarantee. A pathological function can stall the solver. That’s fine for an analysis you run overnight; it’s disqualifying for a warning that runs on every build.

Our analysis can’t blow up like that. It’s a monotone dataflow analysis over a finite lattice: each pointer can flip state a bounded number of times before everything settles. The work is roughly

We took the lattice-of-sets path precisely so the check runs on every build, and ported Crubit’s test suite as regression coverage (flow-nullability-crubit-regression.cpp), matching its results where our lighter engine can and documenting the cases where the solver still wins.

Across functions

Everything above happens inside a single function. But pointers cross function boundaries: a function returns one, you dereference it.

Reasoning about that needs one more structure: the call graph, which records who calls whom. The checker walks it bottom-up (callees before callers). After the fixpoint for a function completes, a ReturnNonnullTracker checks whether every return path was provably non-null:

// clang/lib/Analysis/FlowNullability.cpp (trimmed)
class ReturnNonnullTracker : public FlowNullabilityHandler {
  bool HasPointerReturn = false;
  bool AllNonnull = true;

  void handleReturnEvidence(const Expr *RetExpr, const FunctionDecl *Func,
                            bool IsNonnull) override {
    HasPointerReturn = true;
    if (!IsNonnull)
      AllNonnull = false;
  }

  void emitSummary(const FunctionDecl *FD) {
    if (HasPointerReturn && AllNonnull)
      Inner.handleAllReturnsNonnull(FD);  // callers can now trust this
  }
};

If every return is non-null, handleAllReturnsNonnull records that summary. When a caller is analyzed later, calls to that function are treated as returning a non-null pointer, no annotation needed.