SVF-based call graph analysis tool that performs Andersen-style pointer analysis on LLVM bitcode and outputs a topological call graph tree.
svf-api-demo/src/main.cpp— CLI driver: parses flags, loads LLVM bitcode, runs the pointer analysis, and renders per-entry call graphs (ASCII tree by default, or tab-separated spans).svf-api-demo/src/callgraph_analysis.cpp/svf-api-demo/include/callgraph_analysis.h— Analysis core (analyzeCallGraph) andAnalysisConfig.svf-api-demo/src/callgraph_output.cpp/svf-api-demo/include/callgraph_output.h— Report writer: call-graph traversal into spans, metadata header, per-entry sink dispatch.svf-api-demo/src/callgraph_tsv_sink.cpp/svf-api-demo/include/callgraph_tsv_sink.h—TsvSink(machine-readable tab-separated spans) plus the shared span-line/escaping writers.svf-api-demo/src/callgraph_tree_sink.cpp/svf-api-demo/include/callgraph_tree_sink.h—TreeSink(indented ASCII call tree per entry).svf-api-demo/include/callgraph_sink.h/svf-api-demo/include/callgraph_span.h—SpanSinktraversal-output interface and the shared span data types.svf-api-demo/src/function_filter.cpp/svf-api-demo/include/function_filter.h— Library-code classification, user filter patterns, and debug-line ranges.svf-api-demo/CMakeLists.txt— CMake build (out-of-source intobuild/): builds the analyzer, the native test program, and the test bitcode.svf-api-demo/build/clang_analyzer— Compiled analyzer binary (CMake build output).
| File | Description |
|---|---|
svf-api-demo/tests/test_prog.c |
Simple C program with direct function calls (foo, bar, zoo) |
svf-api-demo/tests/test_complex.cpp |
Comprehensive C++ test covering all indirect call patterns |
The complex test covers the following call patterns that SVF's pointer analysis must resolve:
directCall()→printf— trivial direct call
- Basic function pointer:
FuncPtr fp = calleeViaPtr; fp(); - Function pointer array:
FuncPtr table[3] = {fa, fb, fc}; table[i](); - Callback parameter:
sortWithCallback(arr, n, cmp)wherecmpis a function pointer parameter, called withcompareAsc/compareDesc - Pointer to function pointer (double/triple indirection):
FuncPtr* fpp = &fp; FuncPtr** fppp = &fpp; - Struct member function pointer:
Handlerstruct withHandlerFunc onEventmember, including runtime swap between instances - Function returning function pointer:
chooseHandler(kind)returnsfaorfb; caller invokes the returned pointer - Array of pointers to function pointers:
FuncPtr* fptrs[2] = {&fns[0], &fns[1]}; (*fptrs[i])(); - Nested callback:
middleWare()receives both aComparatorand avoid (*report)(int)function pointer, demonstrating multi-layer indirect call chains
overloaded(int)andoverloaded(double)— resolved via LLVM mangling
- Single virtual inheritance:
Base* b = new Derived(); b->virtualMethod(); - Polymorphic array:
Animal* animals[3]holdingAnimal,Dog,Catinstances, iterated in a loop - Multiple inheritance:
MultiDerivedinherits from bothMixinAandMixinB, each with virtual methods; calls dispatch through each base pointer with this-pointer adjustment - Diamond virtual inheritance:
DiamondTipinherits fromMidLeftandMidRight, both virtually derived fromDiamondBase; virtual base pointer resolution - Abstract base class dispatch:
AbstractBasewith pure virtualdoit()/report(), dispatched through array ofConcreteA/ConcreteBpointers - Multi-level virtual chain:
VBase → VMid → VDerivedthree-level override hierarchy dispatched through base pointer array - Virtual calls in constructor/destructor:
TraceDerivedcalls virtuallog()during construction and destruction (dynamic type changes during these contexts) - Pure virtual call in base destructor:
AbstractBase::~AbstractBase()dispatches viallvm.trap(undefined behavior guard)
- Stateless lambda:
[](){} - Capture-by-value lambda with return:
[x](int y) -> int - Capture-by-reference lambda calling another function
- Generic lambda (C++14):
[](auto a, auto b){}— templated call operator instantiated withintanddouble - Capture by move (C++14):
[p = ptr](){}— lambda captures a dynamically allocated pointer via move - Nested lambda: Lambda
makeAdder()returns a capturing lambda; closure-in-closure call chain - Lambda wrapped in std::function: Stateless lambda assigned to
std::function<void()>, invoked through the type-erased wrapper
- std::function: Wraps free functions (
stdFuncTarget1,stdFuncTarget2), a lambda, and supports reassignment between targets - std::bind: Binds arguments to
bindTarget(a,b,c)usingstd::placeholders, creating callable objects with fewer parameters - std::function + bind: A
std::function<void()>wrapping astd::bindexpression - std::invoke (C++17): Unified call syntax invoking a free function, a member function (
Calculator::addvia pointer-to-member), and a lambda
- Pointer to member function:
int (Calculator::*MathOp)(int,int)called via.*and->*syntax on instances ofCalculator, selectingadd,sub,mulat runtime - Functor (function object):
Greeterclass withoperator()(const char*)— different instances hold different state; callinghello("World")vsgoodbye("World")dispatches to the same operator with different internal data
- Ternary/conditional dispatch:
FuncPtr f = flag ? condTrue : condFalse;— runtime condition selects between two function pointers - State machine with function pointer table:
StateHandler states[3] = {stateIdle, stateRunning, stateStopped};dispatched in a loop - Function pointer reassignment in loop:
FuncPtr fpreassigned each iteration through a loop overfns[]array - Struct dispatch table:
DispatchEntrystruct array withnamestring andhandlerfunction pointer; iterated and invoked - Function pointer on struct with operator():
CallableOpsstruct wrapping a function pointer, called viaoperator()()overload
- Direct recursion:
factorial(n)calls itself withn-1 - Mutual recursion:
mutualA()andmutualB()call each other alternately with decreasing counter
- Function templates:
maxOf<int>(3,7)andmaxOf<double>(3.14,2.72)— different template instantiations produce distinct call targets - Variadic function template:
varargSink(1,2,3,4,5)— recursive template instantiation over parameter pack - CRTP (Curiously Recurring Template Pattern):
ShapeBase<Derived>::draw()callsstatic_cast<Derived*>(this)->drawImpl(), resolved at compile time toCircle::drawImpl()orSquare::drawImpl()
- Default arguments:
defaultArgsFunc(1),defaultArgsFunc(2,20),defaultArgsFunc(3,30,"explicit")— calls with different argument counts expand differently at IR level - Operator overloading: Built-in
operator+called onintoperands to verify operator call edges
- std::mem_fn:
std::mem_fn(&Calculator::add)wraps a member function pointer into a callable, invoked via_Mem_fn::operator()with__invoke_memfun_ref/__invoke_memfun_derefpaths - std::bind with member function:
std::bind(&Calculator::add, &calc, _1, _2)— binds a member function pointer with an instance pointer and placeholders, creating a complex nested call chain through_Bind::__call→__invoke_memfun_deref - Delegate pattern:
Buttonclass storesstd::function<void()>/std::function<void(int)>as members. Callbacks (delegateClickA/B,delegateKeyHandler) are dynamically registered withsetOnClick/setOnKeyand dispatched throughoperator()— includes runtime reassignment - std::map dispatch table:
std::map<int, FuncPtr>storingmapCmdStart/mapCmdStop/mapCmdStatus, looked up viafind()and invoked throughit->second() - if constexpr dispatch (C++17):
algoDispatch<true>()vsalgoDispatch<false>()— compile-time branch elimination viaif constexpr, only the selected path survives in IR - Fold expression call (C++17):
(foldTarget(args), ...)expands a parameter pack over the comma operator into sequential calls;(fns(), ...)calls a pack of function pointers - std::apply (C++17):
std::apply(applyTarget, tuple)unpacks a tuple into function arguments; also tested with a lambda - Overloaded lambda pattern (C++17):
Overloaded{[](int){}, [](const char*){}}uses variadicusing Ts::operator()...and CTAD deduction guide to create a multi-overload callable; dispatched through the synthesizedoperator() - std::variant + std::visit:
VariantType = std::variant<int, double, const char*>visited in a loop with anOverloadedvisitor, dispatching tovariantIntHandler/variantDoubleHandler/variantStrHandlerper alternative - Recursive lambda via std::function: Lambda that captures itself by reference (
[&fib]) in astd::function<int(int)>and callsfib(n-1) + fib(n-2)recursively to compute Fibonacci, relying on type-erased self-reference
- SVF framework at
/home/test/src/SVF(or-DSVF_ROOT=<path>) - LLVM 21 toolchain at
/usr/lib/llvm-21(or-DLLVM_PREFIX=<path>) - Z3 solver
CMake, out-of-source into svf-api-demo/build/:
cmake -S svf-api-demo -B svf-api-demo/build
cmake --build svf-api-demo/buildThis produces:
svf-api-demo/build/clang_analyzer— analyzer binarysvf-api-demo/build/test_complex— native test program executablesvf-api-demo/build/tests/test_complex.bc,test_prog.bc— test bitcode (generated by thebitcodetarget with the LLVM-21 clang; the system clang 18 cannot produce/read bitcode compatible with the LLVM-21 SVF build)
SVF_ROOT (default /home/test/src/SVF) and LLVM_PREFIX (default
/usr/lib/llvm-21) can be overridden at configure time:
cmake -S svf-api-demo -B svf-api-demo/build -DSVF_ROOT=/path/to/SVFThe analyzer supports multiple analysis modes via CLI flags. The default
is tuned for the most complete + precise call graph: flow-sensitive
PTA + context-sensitive SVFG (heap-object model off). Enabling the heap
model resolves indirect calls through local fn-ptr arrays (e.g.
void (*fns[2])() = {regA, regB}) that the default misses, but it also
measurably under-resolves virtual dispatch through base-class arrays and
struct dispatch tables — a net completeness loss on the test suite — so it
is opt-in.
The output call graph excludes library code by default, so only the analyzed application's own call chains are reported. The decision is made from where each function is defined, not from a list of library function names:
- the application's source tree is derived from the module's compile units (each unit's directory and its parent, so project headers next to the sources stay application code);
- a function whose definition file lies outside that tree is library code —
this is what filters the C library (
strrchr,memcpy, ...), libstdc++ headers (std::,__gthread_mutex_lock, ...) and any other library source linked into the module, regardless of where the toolchain keeps its headers; - a function with no definition in the module (a body-less declaration) is imported library code. This check runs on the original module, before SVF's ExtAPI materialises stub bodies for modelled C functions;
- symbols whose namespace identifies them as std/runtime —
std::,__gnu_cxx::,__cxa_*,__gxx_*,_Unwind_*, sanitizer hooks,operator new/delete— are filtered by symbol, so they are also hidden on release builds without-g; - library/runtime functions with no debug info when the module carries debug info elsewhere, and calls made from inside a system header.
On a build without -g there is no definition file to attribute by, so
body-bearing header inlines (e.g. __gthread_mutex_lock) cannot be
classified; declarations and std symbols are still filtered.
Three rules shape the reported call tree. They apply identically to library code and to functions hidden by a filter file:
- an edge from a user function into a hidden function (library code or a filter match) is not reported;
- an edge from a hidden function into a user function is reported: the user function is grafted onto the nearest visible ancestor (a callback invoked from inside library machinery keeps its own subtree);
- the hidden function's own call tree is not traversed, so calls between two hidden functions never appear and callbacks reachable only through library-internal chains are not reported.
Pass --include-stdlib to keep library code in the output; filter-file matches
are still hidden.
Project-specific filtering is done with --filter-file <file>: the file
holds one regular expression per line (ECMAScript syntax, blank lines and
#-comment lines are skipped). Every function whose demangled name,
parameter-stripped name, or mangled symbol matches an expression is hidden
exactly like library code — rules 1–3 above apply, so a noisy helper (log_*,
tracing wrappers, test scaffolding) disappears while the user functions it
calls stay visible. Matching is a substring search, so log matches everywhere
in the name and ^log_ anchors at the start. Invalid expressions are reported
on stderr and skipped, and the filter applies regardless of
--include-stdlib.
# hide logging helpers; the user functions they call are still shown
^log_
^trace_
_dbg$
| Flag | Description |
|---|---|
| (default) | FlowSensitive + context-sensitive SVFG, library code excluded — most complete & precise |
--fs |
FlowSensitive (FSSPARSE_WPA) — sparse flow-sensitive analysis [default on] |
--vfs |
VersionedFlowSensitive (VFS_WPA) — versioned flow-sensitive |
--ander |
Downgrade to flow-insensitive AndersenWaveDiff |
--dda |
Demand-driven value-flow analysis (ContextDDA): only the function-pointer / vtable-pointer variables feeding indirect callsites are solved on demand on the SVFG — much faster than a full points-to solve for call-graph construction |
--dda-flow |
Demand-driven with FlowDDA (flow-sensitive on-demand solve; implies --dda) |
--dda-cxt-len <n> |
DDA: maximum calling-context length (SVF MaxContextLen, default 3; larger = more precise, slower) |
--dda-opt-svfg |
DDA: build the optimised pointer-only SVFG (SVFGOPT — merges context-insensitive regions, drops formal-in/actual-out): smaller graph, faster on-demand traversal, some precision loss |
--cs |
Context-sensitive SVFG (SVF default; flag is descriptive) [default on] |
--heap-model |
Heap object model (ModelConsts, ModelArrays) — opt-in |
--include-stdlib |
Keep C++ std / C library / system-header / declaration-only library functions in the call graph [default: excluded] |
--entry <name> / --entry-file <f> |
Call-graph entry functions (repeatable; demangled or mangled) |
--filter-file <f> |
Hide functions matching the regexes in <f> (one per line), like library code: their direct user callees stay visible, their internal call tree does not |
--format <tsv|tree> |
Report layout: an indented ASCII call tree per entry (default) or tab-separated spans [default: tree] |
# Default: flow-sensitive + context-sensitive (most complete & precise)
LD_LIBRARY_PATH=/home/test/src/SVF/Release-build/lib:/usr/lib/llvm-21/lib \
./svf-api-demo/build/clang_analyzer svf-api-demo/build/tests/test_complex.bc
# Flow-insensitive Andersen (faster, less precise)
LD_LIBRARY_PATH=/home/test/src/SVF/Release-build/lib:/usr/lib/llvm-21/lib \
./svf-api-demo/build/clang_analyzer --ander svf-api-demo/build/tests/test_complex.bc
# Demand-driven value-flow analysis (fastest precise mode for call graphs)
LD_LIBRARY_PATH=/home/test/src/SVF/Release-build/lib:/usr/lib/llvm-21/lib \
./svf-api-demo/build/clang_analyzer --dda svf-api-demo/build/tests/test_complex.bc
# Also resolve indirect calls through local fn-ptr arrays (may lose
# virtual/struct dispatch targets)
LD_LIBRARY_PATH=/home/test/src/SVF/Release-build/lib:/usr/lib/llvm-21/lib \
./svf-api-demo/build/clang_analyzer --heap-model svf-api-demo/build/tests/test_complex.bc
# Versioned flow-sensitive (object versioning)
LD_LIBRARY_PATH=/home/test/src/SVF/Release-build/lib:/usr/lib/llvm-21/lib \
./svf-api-demo/build/clang_analyzer --vfs svf-api-demo/build/tests/test_complex.bcConvenience target — build and run the analyzer on the test-complex bitcode
with the default config. The bundled runtime libs in build/lib/ are picked
up via the $ORIGIN rpath, so no LD_LIBRARY_PATH is needed:
cmake --build svf-api-demo/build --target analyzeclang_analyzer links against shared SVF/LLVM/z3 libraries and carries an
$ORIGIN-relative rpath, so the runtime libraries travel with the binary.
Build a self-contained deployment folder:
cmake --build svf-api-demo/build --target deployThis assembles svf-api-demo/build/deploy/:
deploy/
├── clang_analyzer
└── lib/
├── libSvfCore.so.3
├── libSvfLLVM.so.3
├── libLLVM.so.21.1
├── libz3.so.4
└── extapi.bc # SVF's external-API model (located beside libSvfCore.so)
Copy that folder to any x86-64 Linux machine and run — no installs, no
LD_LIBRARY_PATH:
./deploy/clang_analyzer my_program.bcThe target machine only needs the usual base system libraries (glibc,
libstdc++, zlib, zstd, ICU …), which are present on essentially every Linux
distro. Fully static linking is not possible without rebuilding SVF itself
as a static library (it is built shared-only); the $ORIGIN bundle is the
pragmatic equivalent.
./svf-api-demo/build/test_complexThe report comes in two layouts carrying exactly the same spans, selected with
--format (default tree). The same metadata header and per-entry summary are
written in both; the hiding rules and --filter-file apply identically.
The default layout renders each entry's call chain as an indented ASCII tree:
one line per span, ├──/└── connectors with │ verticals,
[indirect]/[cycle] markers, and the call site in parentheses. The default
output name is <bitcode-stem>.callgraph.tree:
#schema_version 2
#bitcode build/tests/test_prog.bc
#analysis pta=FlowSensitive context_sensitive=1 heap_model=0 exclude_std=1
#entries main
===== entry: main =====
main
├── foo (tests/test_prog.c:6)
├── bar (tests/test_prog.c:7)
└── zoo (tests/test_prog.c:8)
===== end: main spans=4 reachable=4 =====
The definition file / line-range columns are not repeated on tree lines — consult the TSV report for them. Unlike the fully streamed TSV layout, the tree buffers one entry's spans in memory to place the connectors.
--format tsv writes a line-oriented report (default
<bitcode-stem>.callgraph.tsv) so it can be streamed both when writing and
when parsing — nothing is materialized in memory. #-prefixed lines are
metadata/markers; every other line is one span, tab-separated, in pre-order (a
span's parent always appears before it):
#schema_version 2
#bitcode test_prog.bc
#analysis pta=FlowSensitive context_sensitive=1 heap_model=0 exclude_std=1
#filter_file filters.txt patterns=2
#entries main
#entry main
0 root 0 0 main main test_prog.c 3 6
1 0 direct 1 0 foo() _Z3foov test_prog.c 8 10 test_prog.c 4
2 1 indirect 2 0 bar() _Z3barv test_prog.c 12 14 test_prog.c 9
3 1 direct 2 1 foo() _Z3foov test_prog.c 8 10 test_prog.c 13
#end main spans=4 reachable_funcs=3
Span-line columns: span_id, parent_span_id, kind (root/direct/indirect),
depth, cycle (0/1), function (demangled, params stripped), mangled,
file, start_line, end_line, call_file, call_line. call_file and
call_line are the file and line of the call site backing the edge — the
location in the caller where the function is invoked (the representative site,
lowest line number, when an edge has several). Hidden functions (library code,
filter matches, and library callees reached from a call site inside a system
include header) emit no span of their own, and their internal call tree is not
traversed; their user-code callees are still shown, grafted onto the nearest
visible ancestor. A user function is never hidden merely because its call site
sits in a system header. Empty fields mean "absent" (the root has no parent;
functions without debug info have no line columns; a call without debug info
has no call-site columns). A cycle span is a recursion back-edge whose
subtree is not expanded. #end carries the entry's span count and the number
of unique reachable functions. #filter_file records the filter file applied and
its pattern count; it is omitted when no filter file was given.
===== Call Graph Topology =====
main
├── directCall()
│ └── printf
├── testFuncPtr()
│ └── [Indirect]
│ └── calleeViaPtr()
│ └── printf
├── testOverloaded()
│ ├── overloaded(int)
│ │ └── printf
│ └── overloaded(double)
│ └── printf
├── testVirtual()
│ ├── operator new(unsigned long)
│ ├── Derived::Derived()
│ │ └── Base::Base()
│ ├── [Indirect]
│ │ └── Derived::virtualMethod()
│ │ └── printf
│ └── [Indirect]
│ └── Derived::~Derived()
│ ├── Derived::~Derived()
│ │ └── Base::~Base()
│ └── operator delete(void*, unsigned long)
├── testLambda()
│ └── testLambda()::$_0::operator()() const
│ └── printf
├── testCallbackParam()
│ └── sortWithCallback(int*, int, int (*)(int, int))
│ ├── [Indirect] → compareAsc(int, int)
│ └── [Indirect] → compareDesc(int, int)
├── testPtrToFuncPtr()
│ └── [Indirect] → targetViaDoublePtr() → printf
├── testStructFuncPtr()
│ └── [Indirect] → handleEventA(int) → printf
├── testReturnFuncPtr()
│ └── chooseHandler(int)
│ └── [Indirect] → fa() → printf
└── testNestedCallback()
└── middleWare(int, int, int (*)(int, int), void (*)(int))
├── [Indirect] → compareAsc(int, int)
├── [Indirect] → compareDesc(int, int)
└── [Indirect] → reportResult(int) → printf
Indirect calls (function pointers, virtual dispatch) are marked [Indirect]. The tree uses ├──/└── connectors with │ vertical lines for hierarchy. If a call graph cycle is detected, [cycle] is printed to prevent infinite recursion.