TLDR
I forked clang and added a warning that catches null pointer dereferences at compile time:
int first_value(struct node *n) {
return n->value; // warning: dereference of nullable pointer [-Wflow-nullable-dereference]
}
You can try it in the playground, and I wrote up the design as an RFC on the LLVM forum. This post follows one function through the compiler to show how it works.
Background
In C and C++, every pointer might be null, yet the type system can’t tell a nullable pointer from a non-nullable one. Rust, Swift, Kotlin, and TypeScript make them different types: string and string | null aren’t interchangeable, and the compiler enforces it. In C, int *p might be null and nothing stops you from writing *p without looking.
Tools to do better exist, but none flag a null dereference the way you’d expect a compiler to:
- GCC added
__attribute__((nonnull))to annotate function parameters decades ago - Clang added
_Nullableand_Nonnulltype qualifiers
These stop at the type or API boundary. The Clang Static Analyzer (which clang-tidy can run) does follow control flow and catch these, but it’s a heavyweight separate pass, not a warning you get on every build. None of them is a fast, on-every-build check that walks a function body statement by statement and flags the unsafe *p itself. That flow-sensitive step is what’s missing, and none of it reached the C or C++ standard, so it stays optional.
Adding flow-aware null dereference checks
What you can do, without changing the language, is assume pointers might be null, and then statically analyze code to find unsafe use.
This is what my fork of clang does. 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.
Here’s a C function that triggers the warning:
struct node { int value; };
int first_value(struct node *n) {
return n->value; // warning: dereference of nullable pointer
}
Two flags turn this on:
1.) -fflow-sensitive-nullability enables the analysis, and
2.) -fnullability-default=nullable says “treat every unannotated pointer as possibly null.” It only sets the fallback for pointers with no annotation; an explicit _Nullable or _Nonnull always wins. The other values are nonnull and unspecified (the default without the flag, which assumes nothing and stays quiet).
_Nullable/_Nonnull are a clang extension, not part of the C standard, so most real code carries no annotations at all. -fnullability-default=nullable is what makes the check useful on that code: every bare struct node * is assumed nullable until proven otherwise. The result is a warning!
clang -fflow-sensitive-nullability -fnullability-default=nullable -c node.c
# node.c:3:10: warning: dereference of nullable pointer [-Wflow-nullable-dereference]
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 static analysis has to be smart enough to warn on the first and not on the second. So how do we do it?
Here’s the stack the warning is built on, bottom to top. Each layer is built from the one below it, and the warning falls out of the top. The rest of this post climbs it:
The AST: from text to a 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.
Straight out of the parser, the tree is pure shape: one node per construct, and nothing else. No types, no resolved names, no implicit conversions:
FunctionDecl first_value
|-ParmVarDecl n
`-CompoundStmt // the { ... } body
|-IfStmt
| |-UnaryOperator '!' // the condition: !n
| | `-DeclRefExpr 'n'
| `-ReturnStmt
| `-IntegerLiteral 0 // return 0
`-ReturnStmt
`-MemberExpr ->value // n->value
`-DeclRefExpr 'n'
Read it top-down and it’s your function:
FunctionDeclholds a parameternand a body- the body holds an
IfStmtand aReturnStmt - the
IfStmtholds the condition (!n) and the early return
But notice what isn’t there: no types on any node, neither n is linked to the parameter yet, and n->value has no idea it loads a pointer. Filling all of that in is the next stage.
Semantic analysis: giving the tree types
Parsing gives you shape, not meaning. The parser knows n->value is a member access but not that value is an int, that n is the parameter two lines up, or that the expression is well-typed.
Filling that in is semantic analysis, or “Sema” (clang/lib/Sema/, the largest directory in clang). It builds no separate structure; it enriches the AST in place.
So clang -ast-dump, which runs after Sema, prints a richer tree than the bare shape above, now carrying types and conversions:
clang -Xclang -ast-dump -fsyntax-only node.c 2>&1 | tail -20
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 *'
Sema added all of that: resolved each n to its ParmVarDecl, attached the types, inserted the ImplicitCastExpr (LValueToRValue) nodes. The checker leans on this heavily: it reads a pointer’s nullability straight off the QualType Sema attached.
The fork makes Sema do one more thing. Under -fnullability-default=nullable, it injects a _Null_unspecified qualifier onto every unannotated pointer (SemaType.cpp), so a bare struct node * becomes struct node * _Null_unspecified. That weak marker keeps an inferred default distinct from a _Nullable you wrote by hand, and the checker treats it as nullable.
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?
This fork of clang adds one more analysis to that lineup:
- are any nullable pointers dereferenced?
It runs only when you pass -fflow-sensitive-nullability (off by default), and reports a family of related warnings under one umbrella diagnostic group, so you can tune them together or one at a time:
// clang/include/clang/Basic/DiagnosticGroups.td
def FlowNullability : DiagGroup<"flow-nullability", [
FlowNullableDereference, // *p where p may be null (this post)
FlowNullableArithmetic, // p + 1 where p may be null
FlowNullableReturn, // returning a nullable value as a _Nonnull return
FlowNullableAssignment, // assigning null to a _Nonnull variable
FlowNullableArgument]>; // passing a nullable value as a _Nonnull argument
Once the analysis is enabled the warnings are on by default; the group names just let you tune them, through the usual -W machinery (-Wno-flow-nullable-dereference silences one sub-check, -Werror=flow-nullability promotes the whole family to errors). -Wflow-nullable-dereference is the single check we trace from here on.
From the tree to a flow graph
Now we have a structured tree of our code, with semantic analysis embedded in it. Next we need to walk through the tree.
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. The framework walks the whole tree depth-first and fires your method only at matching nodes:
A dereference shows up as one of a few AST node kinds: p->field is a MemberExpr (with the arrow flag set), *p is a UnaryOperator (with the deref opcode), and p[i] is an ArraySubscriptExpr. Our example only has the first, n->value. A deref-finder registers one handler per kind and inspects the base pointer wherever one appears, which is how it knows where a warning might be needed.
Simple AST checks
For many checks, a plain tree walk is enough.
For example, if we didn’t want gotos in our codebase, we could emit a warning each time we encountered them like this:
struct WarnOnGoto : RecursiveASTVisitor<WarnOnGoto> {
bool VisitGotoStmt(GotoStmt *G) {
diag(G->getGotoLoc(), "goto considered harmful");
return true;
}
};
But null derefs don’t fit this mold. A plain RecursiveASTVisitor sees one MemberExpr in isolation, so it would warn on n->value even though if (!n) return 0; above it already proved n non-null. So our checker keeps the one-handler-per-node-kind idea but runs those handlers as it walks the control-flow graph, not as a RecursiveASTVisitor over the raw tree. That is what puts the earlier null check in scope.
We need information beyond the AST: the control-flow graph.
Complex checks with the control-flow graph
The AST encodes how code nests, but the Control-Flow Graph (CFG) encodes how it runs, and which statements can execute before which.
Each block ends with one or more edges to the blocks that can run next, and every decision in the code becomes an edge:
- an
ifends its block with two outgoing edges, one per branch - a loop adds a back-edge
&&and||add short-circuit edges (covered later)
Our CFG looks like this:
Now the structure tells the story. The if (!n) block has two outgoing edges:
- Left edge:
!nwas true, sonis null. That path returns 0 immediately. - Right edge:
!nwas false, sonis guaranteed non-null. That’s the only way to reachn->value.
Based on this, we can tell it’s safe to dereference the pointer in one case but not the other.
You can view clang’s understanding of your code’s CFG with the debug.DumpCFG analyzer:
clang -cc1 -analyze -analyzer-checker=debug.DumpCFG node.c
int first_value(struct node *n) // implicit-cast lines trimmed for readability
[B4 (ENTRY)]
Succs (1): B3
[B3]
3: !n
T: if [B3.3]
Preds (1): B4
Succs (2): B2 B1 // the branch: true edge -> B2, false edge -> B1
[B2]
2: return 0;
Preds (1): B3
Succs (1): B0
[B1]
3: [B1.2]->value // n->value, the dereference
5: return [B1.4];
Preds (1): B3
Succs (1): B0
[B0 (EXIT)]
Preds (2): B1 B2
So clang has already done the hard part: it builds the CFG and hands it to you. All that’s left is to walk it, tracking what’s known about each pointer from one block to the next.
The analysis: tracking pointers block by block
Walking a CFG and carrying facts from block to block is called dataflow analysis.
That makes it the top layer in the pipeline above: source feeds the AST, the AST builds the CFG, and the analysis walks the CFG tracking nullability per program point.
At a high level the analysis is a loop over the CFG, driven by a worklist of blocks still to process. It walks one block at a time, carrying a small bundle of facts (the state): which pointers are proven non-null right now (the narrowed set) and which are known nullable.
Each statement applies a transfer function that updates that state. At a branch the state forks, one copy per outgoing edge, so the code after if (!n) sees n narrowed on the path where the check just proved it non-null. The worklist revisits blocks until the facts stop changing.
Here’s our running example as that walk, with the state carried along each edge:
At entry the state is empty, but that does not mean n is safe. A pointer in neither set falls back to its declared type, and n is nullable by default (under -fnullability-default=nullable), so a deref there would still warn.
The sets record what the flow has proven on top of that baseline. if (!n) is where it gets proven: the state forks. On the edge where n is non-null it goes into NarrowedVars; on the edge where n is null it stays in neither set, still unsafe under the default. On the green edge the transfer function for n->value finds n narrowed and stays quiet; the red edge never reaches a deref.
The reverse happens where two branches rejoin. Take a second pointer p that only one branch checks:
void f(int *n, int *p) {
if (cond) {
if (!n || !p) return; // branch A: proves both n and p
} else {
if (!n) return; // branch B: proves only n
}
// branches rejoin here
use(n->value); // ok: n proven on both edges
use(p->value); // warns: p proven on only one edge
}
Where the branches meet, their states join, keeping only what both agree on. n stays narrowed; p gets dropped. A pointer stays narrowed only if it was proven non-null on every incoming edge.
The implementation in clang
With the concepts in mind, here is how they look in the fork’s actual code.
State variable
The state propagated in the CFG visitor is a struct, NullState, whose core is two sets keyed on clang’s VarDecl (the declaration node for a variable):
// clang/lib/Analysis/FlowNullability.cpp (trimmed)
struct NullState {
llvm::DenseSet<const VarDecl *> NarrowedVars; // proven non-null right now
llvm::DenseSet<const VarDecl *> NullableVars; // known nullable right now
// ...plus member-access paths, pointer aliases, and bool guards
// (`bool ok = p != nullptr;`), which all narrow the same way
};
Worklist
A worklist loop, runFlowNullabilityAnalysis in FlowNullability.cpp, drives it. The worklist isn’t part of the CFG; it’s generic dataflow infrastructure (ForwardDataflowWorklist in clang/include/clang/Analysis/FlowSensitive/DataflowWorklist.h, the same engine behind -Wuninitialized) constructed over the CFG to decide block visit order. For each block it does three things:
- Run the transfer functions. A
visit()method routes each statement to a handler (handleMemberExpr,handleBinaryOperator, …) that reads and updatesNullState. - Fork at a branch. If the block ends in an
if, the state is copied per edge and the condition applied: the edge that provesnnon-null getsninserted intoNarrowedVars. - Propagate each edge’s state to its successor block, revisiting blocks until the sets stop changing.
Each pass first joins the block’s incoming edge states, and skips the block if that joined input is unchanged from last time. That convergence check is the fixpoint: the loop drains when nothing changes.
Narrowing
The rule is simple: when a branch condition tests a pointer, the analysis narrows that pointer (adds it to the proven-non-null set) on the edge where the test guarantees it. narrowOnTerminator and analyzeCondition (FlowNullability.cpp) do the work: they pull the condition off the branch, find which pointer it checks and which outgoing edge that proves non-null, and narrow it there. if (n) narrows the true edge; if (!n) return narrows the fall-through. The same code also handles &&/||, member paths (obj.ptr), aliases (y = x; if (y) narrows x), smart pointers, and bool guards (bool ok = p != nullptr; if (ok)).
Warning emission
At each dereference, the handler skips the pointer if isNarrowed already proved it non-null. Otherwise checkVarDeref emits a warning when the declared type or NullableVars says it’s nullable. Findings go through a FlowNullabilityHandler rather than the diagnostic engine directly, so the same walk drives a compiler warning or an IDE squiggle in clangd. Sema’s AnalysisBasedWarnings::IssueWarnings (the entry behind -Wuninitialized and others) wires it in.
Putting it all together
Putting the whole pipeline together for our first_value example:
- Parse → AST. The parser turns the text into a tree.
if (!n)becomes anIfStmt, andn->valuebecomes aMemberExprwhose base is aDeclRefExprforn. - Sema types it. “Sema”, clang’s semantic analysis, resolves each
nto itsParmVarDecl, attaches types, and inserts the implicit casts. Under-fnullability-default=nullableit also stampsstruct node *nwith_Null_unspecified, marking it nullable-by-default, and registers the dereference check alongside the otherAnalysisBasedWarnings. - AST → CFG. clang builds the control-flow graph: an entry block, the
if (!n)block with two outgoing edges, areturn 0;block, and areturn n->value;block. - Walk the CFG. The worklist starts at entry with an empty state. At the
if (!n)terminator,narrowOnTerminatoraddsntoNarrowedVars(the proven-non-null set) on the false edge, the only edge that reachesn->value. - Emit, or stay quiet. In the
return n->value;block the deref handler checksn: it’s inNarrowedVars, so no warning. Without theif (!n)check,nwould not be inNarrowedVars, andcheckVarDerefwould fire-Wflow-nullable-dereference.
Conclusion
clang gives you a fantastic foundation to build on. The parser, Sema, and the CFG are all there already, so a useful warning is mostly a matter of walking what they produce.
The checker here uses that pipeline to flag a possibly-null dereference at compile time, staying inside one function and cheap enough to run on every build. It misses some null dereferences by design, which is the tradeoff we made to keep a high signal-to-noise ratio that developers value.
The code, the design writeup, and a live playground:
Comments are not configured yet. Set
REMARK_HOSTinsrc/components/Comments.astro.