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 streams per-entry call graphs as tab-separated lines.svf-api-demo/src/callgraph_analysis.cpp/svf-api-demo/include/callgraph_analysis.h— Analysis core (analyzeCallGraph) andAnalysisConfig.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.
User callbacks invoked from inside library functions (comparators, lambdas,
functors, std::function targets) are still shown, grafted onto the caller
that reached them. Pass --include-stdlib to keep everything in the output.
| 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 |
--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] |
# 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
# 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 (default <bitcode-stem>.callgraph.tsv) is line-oriented 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
#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). Spans whose call site is inside
a system include header (/usr/...) are dropped entirely: a call that happens
inside standard-library code (C/C++/LLVM headers, e.g. a user callback invoked
from within libstdc++) is not part of the program's own call graph. The
callback's own calls, made from user code, are still shown grafted onto the
nearest emitted ancestor. 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.
===== 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.