Mentor: David Malcolm | Organization: GNU Compiler Collection
Summary
The goal of my project for GSoC'26 was to improve C++ support in the -fanalyzer.
When the project was proposed, several features needed to be implemented in order to be able to support
large sets of C++ programs, and one of the targets that provided the most improvement was adding support
for polymorphism.
The project ended up implementing
exception subclass matching,
virtual function devirtualization
and modeling dynamic_cast.
These additions let the analyzer reason about polymorphic C++ by tracking exceptions through class
hierarchies, analyzing the real callee of a virtual dispatch, and catching bugs like dereferencing the
result of a failed dynamic_cast.
Since much of the standard library uses dynamic_cast or virtual methods in some form (and many common
methods throw exceptions), adding support for these features is an important pre-requisite towards bigger
goals like analyzing and modeling std::vector and other containers, checking iterators for
invalidation, and analyzing std::variant or std::unique_ptr.
Background
As a general overview, GCC’s static analyzer does coverage-guided symbolic execution over gimple-SSA IR
(which is GCC’s Intermediate Representation used for performing optimizing transformations, and all
frontends are lowered to it before being lowered to assembly). It builds a graph of the user’s code
(called the supergraph) and then explores it with a worklist, building an exploded graph whose nodes
are <program point, state> pairs.
Since it is doing static analysis, it never performs any transformation on the input IR. Instead, it just walks paths and emits warnings when a path reaches a “bad” state, with the goal of providing users with useful diagnostics about bugs or vulnerabilities in their code (i.e. use-after-free, double-close, memory leaks, etc).
The state at each node includes a region_model, which is a representation of memory where a store
records bindings from region instances (a variable, a field, a heap allocation) to svalue instances
(a constant, an initial value, a pointer, the result of a binop). Values can be pointers to regions,
which means the full region representation is graph-like.
The region model also includes:
- a
constraint_manager, which records what’s known about relationships between values. dynamic extents, mapping dynamically-allocated regions to svalues (their capacities).
In practice, when the model can’t determine something it chooses to be conservative, and where it can’t model a construct at all it stops exploring that path. Note that, from a formal standpoint, the analyzer is neither sound nor complete.
It’s also important to note that the analyzer was written with the goal of being able to support various frontends and eventually supporting LTO (which is why it works on top of gimple-SSA IR), but each language it wants to support invariably needs many specific constructs to be modeled in order to provide useful diagnostics.
Before this project, the C++ constructs whose behaviour depends on an object’s runtime type were
handled conservatively as unknown functions (like virtual calls, catch matching and dynamic_cast), so
each encounter with these constructs would result in imprecise modeling of state. That meant most of a
C++ program past the first virtual call or past a catch clause couldn’t be analyzed meaningfully, which
doesn’t allow for very useful analysis.
Luckily, all of these missing features touch on the same general problem, and the analyzer already has the infrastructure it needs to solve it. To see why, it is useful to look at what the compiler actually emits for a polymorphic object.
How to find the runtime type of a polymorphic object
If a class has virtual methods, every object of that class carries a vptr, which is a hidden pointer
member to a table of function pointers called the vtable:
struct A { int a; virtual void v (); };
struct B : A { int b; };
Representation of a given instance of a B object and its vtable:
The vtable:
+-----------------------+
| 0 (top_offset) |
The B object: +-----------------------+
b_ptr --> +----------+ | ptr to typeinfo for B |
| vptr |------> +-----------------------+
+----------+ | A::v() |
| a | +-----------------------+
+----------+
| b |
+----------+
Note that the vptr doesn’t point at the start of the vtable, but at the first function slot, with
top_offset and the typeinfo pointer sitting above it at negative offsets.
The important detail here is that the vtable which a B object’s vptr points to belongs to B, not to A,
and not to whatever the static type of the pointer we’re holding happens to be. Each constructor that is
called when creating a polymorphic object writes the vptr for the class it is constructing, so by the
time the most-derived constructor finishes, the vptr is the runtime type of the object.
The concept of a most-derived object is important when
talking about polymorphism, and for understanding the rules of dynamic_cast later.
A most-derived object is an object that isn’t a subobject of anything else (it is the complete object).
For example, if we use the types defined in the above example and have:
B b {};
A* a = &b;
When we are talking about a, the most-derived object it points into is still b, and its most-derived
type is B. The A we hold a pointer to is only a subobject of it, and the static type of the pointer
says nothing about how far the real object extends. When an object isn’t a subobject of anything it is
trivially its own most-derived object.
Another case to consider is that, under multiple inheritance, an object ends up with more than one vptr:
struct A { int a; virtual void v (); };
struct B { int b; virtual void w (); };
struct C : A, B { int c; };
Representation of a given instance of a C object and its vtable:
The vtable:
+-----------------------+
| 0 (top_offset) |
The C object: +-----------------------+
c_ptr --> +-----------+ | ptr to typeinfo for C |
| vptr |------> +-----------------------+
+-----------+ | A::v() |
| a | +-----------------------+
+-----------+ | -16 (top_offset) |
| (padding) | +-----------------------+
+-----------+ | ptr to typeinfo for C |
| vptr |------> +-----------------------+
+-----------+ | B::w() |
| b | +-----------------------+
+-----------+
| c |
+-----------+
Both of them point into the same vtable object, just at different top_offset11top_offset is the distance from this subobject back to the start of the most-derived object, which
is 0 for the A subobject at the top and -16 for the B subobject (the A subobject is a vptr plus an int,
padded up to the vptr’s alignment of 8). It’s what __dynamic_cast uses to find the most-derived object
given only a pointer to a base subobject.
values.
This is what makes type substitution work: passing a C* to a method expecting a B* adjusts the
pointer by 8 bytes, and the callee finds a B subobject there with C’s vptr, which lets us identify
what the original most-derived type for our object was.
In practice this means the pair (vtable decl + byte offset into it) carries two pieces of information
at once which we care about: the vtable’s owner is the most-derived type of the object, and the offset
identifies which subobject’s vptr was read.
How dynamic_cast works
A downcast or crosscast with dynamic_cast<T*>(p) generally can’t be resolved statically, since the
whole point of dynamic_cast is that the most-derived type isn’t known at the cast site. So the frontend
lowers it to a
library call:
void *__dynamic_cast (const void *__src_ptr,
const __class_type_info *__src_type,
const __class_type_info *__dst_type,
ptrdiff_t __src2dst);
__src2dst22
Each __src2dst value has a different meaning:>= 0: the source type is a unique public non-virtual base of the target, sitting at this offset (dst_ptr + src2dst == src_ptr);-1: no hint, the relationship is unspecified;-2: the source type is not a public base of the target;-3: the source type is a public base of the target more than once, but never a virtual base.
is a hint the frontend computes from the static types, describing how the source relates
to the target. The runtime uses it to shortcut its search, since a non-negative value lets the walk check
a candidate target by adding the offset and comparing against the source pointer.
A call to __dynamic_cast might look like this:
d = __cxxabiv1::__dynamic_cast (b, &_ZTI4Base, &_ZTI7Derived, 8);
At runtime, libstdc++’s __dynamic_cast reads the vptr out of the source pointer and steps back into the
vtable prefix, i.e. the header sitting at negative offsets. This prefix gives it the offset from this
subobject to the start of the complete object and the __class_type_info of that complete object. From
there it walks the class hierarchy in a single traversal, looking for the source subobject and for every
subobject of the target type, accumulating along the way which of src, dst and the whole object are
publicly reachable from which. Once the walk returns, those flags are tested in
sequence to decide if the rules in [expr.dynamic.cast]/9
were satisfied.
It’s also useful to go over the rules specified in the C++ standard, as the analyzer has to
implement them in order to correctly evaluate dynamic_cast calls. Effectively, these rules act as 2
distinct searches:
/9.1 (the downcast): does src point to a public
base subobject of a target object, and is that the only target object derived from the subobject src
points to? The search starts at the source subobject, so we expect the hierarchy to look like SrcType -> ... -> DstType -> ... -> MDType, and the path from the target down to the source must be public.
/9.2 (the crosscast): otherwise, does src point to
a public base subobject of the most-derived object, and is the target an unambiguous public base of it?
This search starts at the most-derived object (MDType -> ... -> DstType). This means the target it
finds can be on a branch of the hierarchy which isn’t from the source.
If neither succeeds, /9.3 says the runtime check
failed, and the result is a null pointer (or a std::bad_cast when casting to references).
Both clauses require that the found target is both unique and publicly reachable. Note that these two requirements are orthogonal, since a second target found through a private path is still a duplicate and makes the cast ambiguous. This means the search has to ignore access specifiers while checking for ambiguity, and needs to track accessibility separately.
How the analyzer implements it
Putting both of the earlier concepts together, __dynamic_cast needs the most-derived type at runtime,
and has to go through considerable work to obtain it, like checking typeinfo subclasses, doing hierarchy
walks, and adding various checks to bail out early wherever possible.
However, unlike libstdc++, the analyzer isn’t called during the runtime (it runs right before
optimizations, after each frontend has lowered to gimple-SSA IR). More importantly, it has already
analyzed past constructor calls, which means the store mechanism binds the object’s vptr field to the
_ZTV* decl like it would for any other write. The vptr ends up stored as a binop_svalue over a
region_svalue and a constant:
cluster for: c
_vptr.C: (&_ZTV1C + (long unsigned int)16)
This means the dynamic type is already sitting in the model, and no feature needs to go through the effort of modelling this type separately or adding any dynamic-type tracking. It is, then, enough for both of the features that depend on the runtime type to recover the decl and the offset from that binding:
- for a virtual call, the vtable decl and the vptr offset, together with
OBJ_TYPE_REF_TOKEN, index the vtable initializer and give the concrete callee; - for
dynamic_cast, the vtable’s owner is the most-derived type and the offset identifies which subobject’s vptr was read. Then, [expr.dynamic.cast]/9 can be run over the BINFOs at analysis time, yielding either a pointer to a subobject or a null.
Finally, catch matching doesn’t rely on the machinery above, since the thrown type is known statically at
the throw site and no vptr is involved. It is, however, still the same kind of question about the class
hierarchy (is the handler type a base of the thrown type, publicly and unambiguously reachable). Since
the frontend already knows how to answer this, the analyzer queries it to decide if a handler matches or
not the thrown type.
Most of the work performed here is reading back information the analyzer already had access to from its core infrastructure and modeling it explicitly with C++ semantics, while also deciding when it needs to be conservative about what it can derive in each case.
Technical details and implemented work
Exception subclass matching - PR analyzer/119697
exception_matches_type_p previously only treated an exception as caught when
the handler type and the exception type were identical, so a handler for a base
class never matched a thrown derived class.
Since the analyzer is made with the goal of supporting various languages, we can’t just implement C++
exception handling rules directly. Even if we did, this would have to be done again for each frontend the
analyzer wanted to support.
Given that, an exception_matches_type_p langhook that each frontend can implement was added, returning
whether a handler of one type catches an exception of another per the analyzed language’s rules. The
default returns false, keeping the behaviour for frontends without exception support, and the C++
frontend implements it with handler_match_for_exception_type.
This keeps the analyzer language-agnostic while correctly analyzing
[except.handle] cases like ambiguous bases, private bases,
cv-qualification, pointer conversions and nullptr.
It should be noted that since there is a single global lang_hooks instance, only one frontend’s rules
are available for the whole analysis regardless of which translation unit a given catch came from. This
means the implementation doesn’t handle LTO when compiling mixed C and C++.
Virtual function devirtualization - PR analyzer/97114
Virtual calls appear in GIMPLE as OBJ_TYPE_REF calls, which were not previously handled, so every one
of them resulted in an unresolved call.
get_fndecl_for_call now handles them. It reads the value bound to the object’s vptr field, and if that
value has the form &vtable_decl + constant, the vtable decl and offset are recovered and passed to
gimple_get_virt_method_for_vtable together with OBJ_TYPE_REF_TOKEN (the slot index) to look up the
concrete fndecl in the vtable initializer, which is then resolved through ultimate_alias_target.
Finding a conjured vptr or a non-constant offset means the dynamic type is unknown and the call stays
unresolved.
can_throw_p was also extended to resolve through function_symbol(), so thunks are followed to the
function underneath, and to then check TREE_NOTHROW on the result. This means noexcept is read off
the resolved override rather than off whatever wrapper happened to sit in the vtable slot. Thunks show up
here because a vtable slot is only a function pointer, with nowhere to record that this needs adjusting
by a fixed offset first, so under multiple inheritance the adjustment has to be a real function that
fixes the pointer and jumps to the override.
The implementation does not enumerate possible subtypes, so when the most-derived type isn’t known it
won’t check whether each candidate override could throw or leak. Thunk fixed_offset is also not applied
before binding this, so the interprocedural multiple-inheritance case is still imprecise, which is
likely fixable by applying the offset at bind time. This was left as follow-up.
Note that none of what was implemented here is really C++-specific, since it only deals with
OBJ_TYPE_REF and the GIMPLE representation of vtables, so this work is applicable to other frontends
that support virtual calls.
dynamic_cast modeling - PR analyzer/110578
dynamic_cast lowers to a call
to __cxxabiv1::__dynamic_cast, so this adds a
known_function
for it.
The runtime check of [expr.dynamic.cast]/9 is
evaluated statically when the dynamic type is known, yielding either a pointer to the target subobject or
a null pointer, and otherwise the LHS is left conjured. This makes bugs like dereferencing the result of
a failed cast visible.
The reference form doesn’t need any extra handling, since the frontend emits the null check and the
__cxa_bad_cast call itself, thus from this known_function’s perspective, modeling the null result is
enough to get [expr.dynamic.cast]/10 (although
std::bad_cast still needs to be specifically modeled with its own known_function).
The dynamic type comes from the same vptr binding used for devirtualization, so get_vtable_from_obj was
factored out of get_fndecl_for_virtual_call. The vtable’s DECL_CONTEXT gives
the most-derived type, and the offset identifies which subobject’s vptr was read. The source and target
class types are recovered from the &_ZTI* typeinfo arguments since the C++ frontend adds them to the
TREE_TYPE of the tinfo decl’s DECL_NAME. The __src2dst hint is ignored, since it only exists to let
the runtime prune its search. The analyzer walks the BINFOs directly so it never needs the hint, and
using it would make the implementation more complex for marginal benefit.
Both clauses of /9 are implemented as a BINFO search over the most-derived type’s hierarchy, tracking per-path accessibility. Some things need to be considered during the BINFO search:
- ambiguity ignores access specifiers, so a second match down a private path still counts as a duplicate;
- morally-virtual matches of the same type under different virtual ancestors are distinct subobjects, so
matches are deduplicated by
BINFO_OFFSETrather than by type; - with shared primary-base vtables the binfo owning a vptr value may be an enclosing type, so the source subobject is found by descending the primary chain at relative offset 0.
The result is built as a concrete offset_region relative to the operand’s base region, so it aliases
the frontend’s own field accesses to the same bytes, which for e.g., lets us prove that c->m == 50 after
the cast rather than just knowing that the cast didn’t fail.
known_function_manager::get_match previously only matched functions at global scope, and now also
matches namespace __cxxabiv1 (added in a
separate helper patch).
Since __dynamic_cast is an ordinary function that can also be called directly, none of the shapes above
can be assumed when analyzing code. The handler sets the LHS to its defaults before doing anything else,
so bailing-out leaves a conjured result. The same applies when the most-derived type simply isn’t known,
which is the common case for a Base * coming out of an unknown function.
Various tests were added to cover all the possible cases of each clause and the various unique type
hierarchy scenarios that might arise with dynamic_cast.
Useful links
Bugs/PRs resolved
Upstream review of these patches can be found here:
- analyzer: support exception subclass matching [PR119697]
- analyzer: support vfunc devirtualization [PR97114]
- analyzer: add dynamic_cast support [PR110578]
Notes from this project are maintained on the GCC Wiki at
StaticAnalyzer/C++.
Status reports throughout the project can be seen
here.
Additionally, my GCC commits can be seen here.
For further information on the inner workings of GCC’s -fanalyzer, it is
useful to read the analyzer internal documentation.
For people interested in using GCC’s analyzer, see the
user facing documentation.
Moyang Wang’s Vtable Notes are also a good resource to read more about the layout of GCC’s vtables and other internal details.
What’s left
I was able to implement all of the items I had planned for the project, and also ended up contributing
dynamic_cast support since I finished my project early (ironically, it ended up taking longer than both
of the items I had initially set out to do).
A couple details were left as out of scope throughout the project, and they are left here as possible things to work on:
- Applying thunk
fixed_offsetbefore bindingthis, to fix the interprocedural multiple-inheritance case. - Exception matching with LTO / mixed-language compilation.
Conclusion
Thanks to David Malcolm for mentoring and reviewing my patches, and to the gcc-patches reviewers. David was very helpful throughout the entire project and I really appreciate the time he set out to review and guide my project.
GSoC'26 helped me get more comfortable with new areas of GCC, and has helped solidify my ability to do various work around the codebase and the project itself, from the wiki to the mailing lists. It was also a good opportunity to learn more about static analysis academically, but also learn how it is handled in a project like GCC where the goal is finding bugs while developers are compiling their projects, rather than to offer an academic and formal implementation of a static analyzer. David also recommended some good books that have been quite useful in learning more about the field, and I hope to continue studying and learning more about static analysis.
I was happy to have the opportunity to contribute in a more structured manner to GCC and interact more with the community through GSoC'26, and I’ll definitely continue doing work for the static analyzer and other areas in GCC in the future.