Post-freeze note (added 26 July 2026).
This article is dated 11 February 2026. Its historical
LifetimeCheckbaseline is the ClangIR incubator commit0c130cda66d7, dated 9 February 2026. The incubator was frozen on 20 February 2026. TheLifetimeCheckpass remained an incubator-only experiment and was not transferred tollvm-project. Observations from later revisions are explicitly marked as post-freeze additions. Where a source example uses a standard-library owner, read it as C++ motivation unless the text ties it to an annotated test-local stand-in: the frozen classifier did not infer Owner status from the system-library type alone.
This article explores ClangIR, an intermediate representation that bridges Clang’s AST and LLVM IR. ClangIR is implemented as an MLIR dialect. It keeps source-level C and C++ information for longer than LLVM IR while still giving us SSA values, regions, and structured control flow.
We will study ClangIR through the LifetimeCheck pass. It detects lifetime
bugs such as use-after-move and use-after-free by implementing parts of
the C++ Core Guidelines lifetime safety profile. It is a good example
because it needs both worlds at the same time: AST semantic information
such as type categories, smart pointer detection, and move semantics;
and IR information such as value flow and structured control flow.
MLIR and intermediate representations
We will start with an Intermediate Representation, or IR. It is one of the key components of a modern compiler. The IR allows the compiler to analyze the program and to perform transformations before final code generation. The best known example for us is LLVM IR. Clang generates LLVM IR, and then LLVM middle-end and back-end passes optimize and lower it.
The problem is that LLVM IR is already too low level for some C++ analyses. As soon as we lower from AST to LLVM IR, we lose direct access to many AST facts: declarations, references, class methods, special member functions, and standard library types are no longer represented in the same way. On the other hand, some analyses require better control-flow and data-flow structure than the AST itself provides. This is the gap where Multi-Level Intermediate Representation, or MLIR, becomes useful. ClangIR is an MLIR dialect designed to keep C and C++ semantics before lowering to LLVM IR.
Static analysis: AST vs IR
A common Clang static analysis starts from the control-flow graph, or CFG. Clang builds the CFG on top of the AST. Thus the analysis is still based on the AST as the primary data structure. The AST is not always the best data structure for static analysis. Let us recall why. The parser and semantic analyzer give us two kinds of information:
Syntax analysis
Semantic analysis
The first one represents the syntactic structure of the program. The AST is the data structure designed to represent it. Semantic analysis is about the meaning of the program. For static analysis we also have to analyze control flow, i.e., how statements are executed, and data flow, i.e., how values are propagated through the program. The AST is built with knowledge about program semantics, but most of this semantic information comes from declarations and types, not from an explicit control-flow representation.
Important note.
Static Single Assignment (SSA) is a form of program representation that simplifies the static analysis and program optimizations. SSA is the key component of LLVM IR. In a program written in SSA form, each variable has only one assignment. Consider the following program fragment:
1 x = 1; 2 y = x + 1; 3 x = 2; 4 z = x + 1;The obvious conclusion may be \(z = y\) because they have the same right hand side, but this reasoning is false. After careful consideration we should conclude that \(z \ne y\).
The same program in SSA form is as follows:
1 x1 = 1; 2 y = x1 + 1; 3 x2 = 2; 4 z = x2 + 1;Now even a trivial analysis concludes that \(z \ne y\) because the two expressions use different SSA values.
Thus SSA makes reasoning about program meaning easier than in the non-SSA case. Therefore, it simplifies program analysis and compiler optimizations.
Program flow is important for many optimizations performed by the compiler middle-end and back-end. For that reason, compilers use intermediate representations that make control flow and data flow more explicit than the source AST.
MLIR fundamentals
MLIR stands for Multi-Level Intermediate Representation. It is a framework for building domain-specific IRs on top of LLVM infrastructure. MLIR has many dialects, and these dialects can be combined in one program. The dialects can be used for different domains, for example:
Accelerators and GPU code (amdgpu, gpu, etc.)
Parallel programming (openmp)
External MLIR-based projects and IRs (for example, Triton)
High level IRs (ClangIR, VAST)
One of the most important dialects is the llvm dialect, which is used as a path toward LLVM IR. It is worth noting that the conversion is staged. A program can be lowered into the MLIR LLVM dialect and then translated to LLVM bitcode or assembly.
Dialects can coexist. For example, ClangIR can be combined with OpenMP-related dialects while the compiler is still working at a level higher than LLVM IR. As an IR framework, MLIR also provides infrastructure for transformations, diagnostics, location tracking, and pass management.
High-level IRs are the most interesting for this article. For instance, VAST uses MLIR to represent an AST-like program form that can later be used for advanced static analysis. MLIR has several properties that make it suitable for static analysis. The most important are:
Every MLIR operation has a
Location. This location may beUnknownLoc; when CIR preserves a concrete source location, it helps the analysis produce useful diagnostics.MLIR dialects can define higher-level operations that are easier to analyze than a raw AST for control-flow-based tasks.
MLIR regions and blocks give us a structured way to represent execution order and nested control-flow constructs.
ClangIR basics
ClangIR architecture
ClangIR, or CIR, is a Clang-native MLIR dialect designed to preserve C/C++ semantics before lowering to LLVM IR. The dialect definition and op/type/attr registration live in:
1clang/lib/CIR/Dialect/IR/CIRDialect.cpp
2clang/include/clang/CIR/Dialect/IR/CIRDialect.h
CIR’s frontend builds MLIR directly from the Clang AST (not from LLVM
IR). Entry points are cir::CIRGenerator
and the frontend action:
1clang/include/clang/CIR/CIRGenerator.h
2clang/lib/CIR/FrontendAction/CIRGenAction.cpp
Keeping a C/C++-semantic IR makes it easier to build language-aware optimizations and analyses before aggressive lowering destroys structure.
Clang CIR is built directly from the Clang AST. The CIR generator
walks AST declarations and statements to emit CIR ops. Those ops carry
language semantics via CIR-specific types, attributes, and interfaces.
Per-translation-unit state in CIRGenModule keeps mappings from AST
entities to CIR constructs. MLIR provides the infrastructure to register
the CIR dialect and run passes. AST-derived metadata is preserved
through early CIR passes. A DropAST
pass removes AST attachments before final emission. This keeps
source-level meaning intact while enabling staged lowering.
AST attribute access in ClangIR
CIR stores AST pointers inside CIR attributes, and the analysis
accesses them through AST attr interfaces on operations and types before
DropAST runs.
How it is done:
CIR ops/types carry AST attrs like
ASTFunctionDeclAttr,ASTVarDeclAttr,ASTRecordDeclAttr. SeeCIRAttrs.td.The interfaces are in
ASTAttrInterfaces.hand the matching.tdfile. They expose helpers likegetDeclName(),getTLSKind(), etc., and usegetAst()under the hood.
Typical access patterns:
From a CIR op (e.g., function op):
1 #include "clang/CIR/Interfaces/ASTAttrInterfaces.h" 2 3 auto astAttr = funcOp.getAstAttr(); // OptionalAttr on the op 4 if (auto funcDeclAttr = mlir::dyn_cast<cir::ASTFunctionDeclAttr>(astAttr)) { 5 const clang::FunctionDecl *FD = funcDeclAttr.getAst(); 6 // Use FD or interface methods, e.g. funcDeclAttr.getMangledName() 7 }From a CIR type (e.g., record type):
1 auto recordType = mlir::dyn_cast<cir::RecordType>(ty); 2 if (recordType) { 3 auto astAttr = recordType.getAst(); 4 if (astAttr) { 5 const clang::RecordDecl *RD = astAttr.getRawDecl(); // debug-focused 6 } 7 }
Building and testing the incubator
The LifetimeCheck pass and the tests used
in this series belong to the ClangIR incubator checkout. We will start
with a Debug configuration. We have to enable Clang and MLIR together
with the CLANG_ENABLE_CIR option. From the
root of the incubator checkout, the configuration is as follows:
$ cmake -G Ninja -S llvm -B build \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_INSTALL_PREFIX="$PWD/install" \
-DLLVM_ENABLE_PROJECTS="clang;mlir;clang-tools-extra" \
-DLLVM_USE_SPLIT_DWARF=ON \
-DBUILD_SHARED_LIBS=ON \
-DCLANG_ENABLE_CIR=ON
We have to build Clang and the test utilities before invoking llvm-lit. We can then run the focused lifetime
tests, or use check-clang-cir for the complete
CIR test suite:
1$ ninja -C build clang clang-test-depends
2$ ./build/bin/llvm-lit -v \
3 clang/test/CIR/Transforms/lifetime-check-owner.cpp \
4 clang/test/CIR/Transforms/lifetime-check-use-after-move.cpp
5$ ./build/bin/llvm-lit -v \
6 clang/test/CIR/Transforms/lifetime-check-smart-pointer-after-move.cpp
7$ ninja -C build check-clang-cir
Test files use the standard LIT format with RUN, CHECK, and diagnostic
verification directives. For example, the owner test checks both the
invalidation note at the end of the inner scope and the warning at the
later dereference:
1// RUN: %clang_cc1 -triple x86_64-unknown-linux-gnu -fclangir \
2// RUN: -fclangir-lifetime-check="history=all;history_limit=1" \
3// RUN: -clangir-verify-diagnostics -emit-cir %s -o %t.cir
4
5void testOwnerScope() {
6 MyIntPointer ptr;
7 {
8 MyIntOwner owner(1);
9 ptr = owner;
10 }
11 // expected-note@-1 {{pointee 'owner' invalidated}}
12 *ptr = 4; // expected-warning {{use of invalid pointer 'ptr'}}
13}
Post-freeze correction (added 27 July 2026).
The command above does not enable
remarks=all. The excerpt checks only an invalidation note and a warning, so enabling all remarks without matchingexpected-remarkdirectives would make-clangir-verify-diagnosticsreject the test.
The tests use small test-local stand-ins annotated with [[gsl::Owner(T)]]
and [[gsl::Pointer(T)]].
At this historical baseline, Owner and Pointer record
classification requires these annotations; raw CIR pointer types are
classified directly. Owner semantics for standard-library records are
not inferred from <memory> by
itself.
Compiler passes overview
Before diving into the LifetimeCheck analysis implementation, it is important to understand the concept of compiler passes and how ClangIR fits into the pass infrastructure.
A compiler pass is a discrete phase of compilation that transforms or analyzes the intermediate representation (IR) of a program. Modern compilers organize their work into multiple passes, each with a specific responsibility.
Compiler passes are useful because they let us focus on one task at a time (modularity), combine passes in different orders (reusability), and enable or disable them through compiler options.
Passes can perform different tasks. One important application is transformation. Optimizations such as dead code elimination and inlining are common transformations; lowering from one IR level to another is another example. In this article we are mostly interested in analysis passes.
Analysis passes can answer different questions. A pass may analyze control flow, data flow, aliasing, ownership, or lifetime. The LifetimeCheck pass is in this last group.
The lifetime safety problem
Important note.
The code examples in this section use API names and methods from the ClangIR implementation, especially
LifetimeCheck.cpp. Some validation logic and edge case handling is simplified for clarity. Simplified sections are marked with comments. The focus is on demonstrating analysis patterns rather than production-ready error handling.
In this section, we will walk through a practical analysis implemented for ClangIR: the LifetimeCheck pass. The goal is to detect lifetime bugs such as use-after-move and use-after-free while preserving enough semantic context to explain why the bug is unsafe.
Motivation for lifetime analysis
One of the most dangerous classes of bugs in C++ programs involves incorrect lifetime management: using pointers after the memory they point to has been freed (use-after-free), accessing moved-from objects (use-after-move), or dereferencing dangling references. Consider this simple example:
1std::unique_ptr<int> ptr = std::make_unique<int>(42);
2std::unique_ptr<int> ptr2 = std::move(ptr);
3int value = *ptr; // ERROR: use-after-move!
After the move on line 2, ptr is
in a moved-from state (equivalent to null for smart pointers).
Dereferencing it on line 3 is undefined behavior. While this example is
trivial, such bugs become much harder to spot in real code with complex
control flow, function calls, and multiple indirections.
The C++ Core Guidelines define a lifetime safety profile (Bjarne Stroustrup and Herb Sutter 2025) that provides rules for detecting such issues, as formalized in the P1179 paper (Sutter 2019). The LifetimeCheck pass implements part of this profile. It demonstrates why ClangIR is interesting for static analysis: it gives us AST semantic information and an IR structure in the same place.
1struct [[gsl::Owner(int)]] MyIntOwner {
2 int value;
3 explicit MyIntOwner(int v) : value(v) {}
4 int &operator*();
5};
6
7struct [[gsl::Pointer(int)]] MyIntPointer {
8 int *ptr;
9 MyIntPointer(int *p = nullptr) : ptr(p) {}
10 MyIntPointer(const MyIntOwner &);
11 int &operator*();
12};
13
14void example() {
15 MyIntPointer ptr;
16 {
17 MyIntOwner owner(42);
18 ptr = owner;
19 *ptr = 3; // OK: owner is still in scope
20 } // owner is invalidated here
21 *ptr = 4; // ERROR: invalid pointer 'ptr'
22}
| Line | Category | LifetimeCheck analysis |
|---|---|---|
| 15 | Pointer | alloca: pmap[ptr] = {invalid}, ptrs.insert(ptr); default constructor: pmap[ptr] = {null} |
| 17 | Owner | pmap[owner] = {owner__1’} |
| 18 | Assignment | pmap[ptr] = {owner__1’} |
| 19 | Check | ptr refers to the current owner generation (OK) |
| 20 | Scope exit | KILL(owner) replaces owner__1’ with invalid in pmap[ptr] |
| 21 | Check | pmap[ptr] = {invalid} |
| ERROR: use of invalid pointer ‘ptr’ |
The source in Figure 3 follows the
annotated test classes in lifetime-check-owner.cpp. The pointer’s alloca first
receives the invalid state. Its default
constructor then replaces that transient state with null. Assignment from owner associates the pointer with the
owner’s current generation, so the dereference inside the inner scope is
valid. At the end of the scope, the KILL rule replaces that generation
with invalid. Therefore, the final dereference
produces a warning.
Post-freeze note (added 26 July 2026; corrected 27 July 2026).
A later audit replaced the original example with the relationship that the pass actually models. The annotated
MyIntPointeris associated with an annotatedMyIntOwner, and the association is invalidated when the owner’s generation ends. A pointer obtained from a general call such asstd::unique_ptr<int>::get()is not used here: this baseline does not establish the same owner relationship for an arbitrary call result. The Pointer alloca’s initialinvalidstate is transient; the default constructor sets its pmap tonullbefore the later assignment.
Why ClangIR for lifetime analysis
Traditional approaches face trade-offs:
AST-based analysis has full semantic information but poor control flow representation. Building a CFG from AST is complex.
LLVM IR-based analysis has excellent control flow (SSA form) but loses high-level semantics—smart pointers become raw pointers, and move operations are hard to identify.
The LifetimeCheck example shows why ClangIR is useful:
AST attributes on operations provide semantic information: “Is this a move constructor?” and “Is this type a std::unique_ptr?”
SSA form and structured control flow simplify dataflow analysis.
High-level operations preserve C++ semantics:
StoreOp,LoadOp, andCallOpcarry more meaning than LLVM’s generic instructions.
| Feature | Clang AST | ClangIR | LLVM IR |
|---|---|---|---|
| Control flow | Nested AST statements | Structured operations | Basic blocks |
| SSA form | No | Yes | Yes |
| Type information | Full C++ types | Preserved C++ types | Lowered types |
| Smart pointers | AST node types | AST attrs | Lost in lowering |
| Move semantics | Explicit AST nodes | Explicit CIR attrs | Usually lost |
| Source locations | Always available | Location required; source may be unknown | Optional metadata |
| Dataflow | Requires CFG | Natural | Natural |
AST represents control flow implicitly through nested statement nodes
such as IfStmt and WhileStmt. Extracting
explicit control flow requires building a CFG. This is why ClangIR is
more convenient for the dataflow part of the analysis.
Conclusion
We have seen why ClangIR is a useful point for C++ static analysis: it keeps source-level semantic information while giving us an IR with SSA values and structured control flow. The next article, Building the LifetimeCheck Pass, starts from this motivation and follows the concrete state model used by the pass.
Discussion
Register with a username and password to join the discussion.