Building and Analyzing Call Graphs in Static and Dynamic ToolsA call graph is a representation of which functions (or methods) call which other functions within a program. It is a powerful abstraction that helps developers understand program structure, locate performance bottlenecks, reason about dependencies, and perform security and correctness analyses. This article explains how call graphs are constructed and analyzed using both static and dynamic techniques, explores trade-offs and practical toolchains, and provides actionable guidance for real-world workflows.
Why call graphs matter
Call graphs make complex codebases comprehensible by turning runtime or potential control flow into a graph: nodes represent functions, and directed edges represent call relationships (caller → callee). Common uses include:
- Performance profiling and hot-path identification
- Program comprehension and documentation
- Dead code and reachability analysis
- Interprocedural dataflow and taint analysis
- Security audits (e.g., finding reachable vulnerable functions)
Key property: call graphs encode possible call relationships; the precision of that encoding depends on the analysis technique (static vs dynamic).
Static call graph construction
Static call graph construction analyzes source code, intermediate representations (IR), or binaries without running the program. It yields a superset of possible calls and can be applied early in development or across all code paths, but it must conservatively account for dynamic language features, polymorphism, reflection, and indirect calls.
Common static techniques
-
Direct-call graph (naïve)
- Resolve only syntactic direct calls (explicit function identifiers).
- Fast and simple, but misses indirect calls and polymorphic dispatch.
-
Class Hierarchy Analysis (CHA)
- For object-oriented languages, CHA uses the class inheritance tree to resolve virtual calls conservatively: every override in subclasses is considered a potential target.
- Scales well but can be overly conservative in deep class hierarchies.
-
Rapid Type Analysis (RTA)
- Tracks instantiated types to refine CHA — only methods of classes that are ever instantiated are considered.
- Improves precision versus CHA with modest additional cost.
-
Points-to and alias analysis (e.g., Andersen’s, Steensgaard’s)
- Compute the set of objects or functions that references/variables can point to; used to resolve indirect calls and function-pointer targets.
- Can be flow-insensitive or flow-sensitive; context-insensitive or context-sensitive. Higher precision typically means higher cost.
-
Context-sensitive and context-insensitive analyses
- Context-insensitive merges call contexts (faster, less precise). Context-sensitive (k-CFA, call-site sensitivity) distinguishes calling contexts to reduce spurious edges at higher compute cost.
-
Object-sensitive analyses
- Tailored for OO languages, sensitivity based on receiver object identity.
-
Whole-program vs modular analyses
- Whole-program requires having all code and libraries available and can be more precise. Modular or incremental approaches analyze modules with summaries to scale to very large codebases.
Static challenges
- Dynamic dispatch, reflection, and runtime code generation can hide call relationships.
- Native-code callbacks or foreign-function interfaces (FFI) complicate resolution.
- Conservative static analyses often overapproximate, producing false-positive edges that pollute downstream analyses (e.g., false reachability).
- Analysis cost: highly precise points-to/context-sensitive analyses are computationally heavy for large codebases.
Example static toolchain
- For Java: Soot (with Spark points-to), WALA, OPAL — support CHA/RTA/points-to-based call graphs and interprocedural analyses.
- For C/C++: Clang/LLVM-based analyses, call-graph construction via Clang Static Analyzer, LLVM’s CallGraph and points-to passes, or tools like CodeSonar.
- For JavaScript/Python: static analyses struggle due to dynamic typing and reflection; tools use heuristics (e.g., Closure Compiler for JS) or partial inference.
Dynamic call graph construction
Dynamic call graph construction records actual calls made during program execution. It produces precise edges for observed runs and is useful for runtime profiling, test-case-guided analyses, and runtime monitoring systems.
Approaches to dynamic call graphs
-
Instrumentation via source or bytecode rewriting
- Insert logging at call sites or function entries/exits during compile-time or build-time. For Java, bytecode instrumentation via ASM or Javassist; for .NET, use profilers or IL rewriting.
-
Binary instrumentation
- Tools like DynamoRIO, Intel PIN, or Valgrind can instrument compiled binaries and record control transfers, including indirect calls.
-
Sampling and stack unwinding
- Profilers (e.g., Linux perf, gprof) sample program counters and unwind stacks to approximate call edges and hot paths with lower overhead than full tracing.
-
Tracing via runtime hooks or event-based telemetry
- Languages and runtimes often expose hooks (e.g., JVM TI for Java, DTrace, eBPF) to capture call stacks or function events with variable overhead and fidelity.
Dynamic advantages and limits
-
Advantages:
- Highly precise for executed paths — no false-positive edges for observed behavior.
- Often lower analysis complexity; useful for performance profiling and real-user monitoring.
-
Limits:
- Incomplete coverage: unexecuted paths won’t appear (false negatives for possible edges).
- Observability overhead: fine-grained tracing can add runtime cost and generate large traces.
- Non-determinism and environment-specific behavior can affect reproducibility.
Example dynamic tools
- Linux perf, Flamegraphs (Brendan Gregg’s tools) for performance call stacks.
- Intel VTune, Google’s gprof, pyinstrument for sampling-based profiles.
- PIN, DynamoRIO, Valgrind for full instrumentation at binary level.
- JVM profilers (YourKit, Java Flight Recorder) capture call stacks and method-level metrics.
- eBPF-based tracers (bcc, bpftrace) for low-overhead kernel- and user-space tracing.
Hybrid approaches: combining static and dynamic
Best practical workflows combine static and dynamic analyses to balance coverage and precision:
- Use static call graph to enumerate possible edges; overlay dynamic traces to mark actual usage and to prune infeasible edges.
- Use dynamic traces to guide and focus expensive static analyses (e.g., run context-sensitive analysis only on hot call sites).
- Generate test suites or fuzzing inputs guided by static call graph gaps to increase dynamic coverage of rarely exercised code paths.
- Use sampling profilers for performance hotspots and static analysis to trace potential upstream causes (e.g., resource allocation chains).
Construction pipeline — practical steps
- Choose scope: whole program, module, or library.
- Decide method(s): static, dynamic, or hybrid. Consider language, available symbols, and runtime features.
- Gather artifacts: source, bytecode, binaries, build information, runtime environment, tests, and representative inputs.
- Build initial graph:
- Static: run chosen analysis (CHA/RTA/points-to) to produce a conservative call graph.
- Dynamic: instrument or sample running program to log observed calls.
- Post-process:
- Aggregate traces, collapse or expand nodes (e.g., inline wrappers), annotate edges with metadata (frequency, stack depth, origin).
- Remove noise (e.g., runtime scaffolding) and merge library/system-call nodes as needed.
- Analyze and visualize:
- Use graph algorithms to find strongly connected components (cycles/recursion), compute dominators, compute path reachability, and find hot paths. Visualize with tools like Graphviz, Gephi, or custom front-ends.
- Iterate: refine static settings, increase test coverage, or change instrumentation granularity.
Graph analysis techniques and metrics
- Reachability: which functions are reachable from an entry point (e.g., main, web handler). Important for dead-code elimination and vulnerability reachability.
- Hotness/weighting: annotate edges/nodes with call frequency, cumulative time, or memory allocation counts from dynamic traces.
- Call chains and hot paths: find common chains (paths) by frequency to focus optimization.
- Cyclomatic-like metrics: strongly connected components reveal recursive structures and potential complexity.
- Centrality and influence: nodes with high in-degree/out-degree or betweenness centrality may be maintenance hotspots.
- Slicing and impact analysis: determine which call paths can propagate a change or dataflow effect.
Visualization and scaling
- Small graphs: direct node-link diagrams (Graphviz dot) are useful.
- Large graphs: hierarchical edge bundling, aggregation by module/package/class, and interactive exploration (zoom, search, filtering) become necessary.
- Use summarization: collapse library internals or third-party modules into single nodes; show call-frequency heatmaps.
- Persist graphs in scalable formats (adjacency lists, databases like Neo4j) for queries and long-term analysis.
Practical examples & recipes
-
Profiling a Java web service:
- Run the service with Java Flight Recorder (JFR) to collect method-level samples and allocation events.
- Produce flamegraphs from JFR or async-profiler. Identify hot endpoints and methods.
- Use Soot or WALA to generate a static call graph for the service plus dependencies. Intersect dynamic hot paths with static graph to understand upstream callers and potential optimization sites.
-
Hard-to-observe native code:
- Use Intel PIN to instrument binaries to log CALL/RET events for specific modules. Aggregate and annotate edges with call counts.
- Cross-reference with symbol tables or DWARF debug info to map addresses to function names.
-
Security analysis (vulnerability reachability):
- Statically compute call graph with conservative points-to analysis to list all possible paths from entry points to sink functions (e.g., system(), execve(), file writes).
- Run dynamic tests that exercise user inputs to collect actual traces. Prioritize statically found paths that are also dynamically observed, and generate inputs (fuzzing) for uncovered but statically reachable paths.
Limitations, pitfalls, and best practices
- Expect trade-offs: precision vs performance; completeness vs practicality. Choose the level of analysis according to the use case (profiling vs security).
- Tooling matters: different static analyzers make different conservative assumptions. Validate results against dynamic traces when possible.
- Instrumentation overhead: use sampling for low-overhead profiling; use tracing selectively for hotspots.
- Keep library and runtime noise manageable: collapse third-party/system libraries unless they’re the focus.
- Automate: integrate call-graph generation into CI for regression detection (e.g., new reachable sinks introduced by code changes).
Summary
Static and dynamic call graphs offer complementary views: static graphs provide exhaustive — but potentially overapproximated — structure across all code, while dynamic graphs provide precise, execution-proven relationships for observed behavior. Combining both yields the most practical and actionable insights: use static analysis for broad coverage and enforcement, dynamic tracing for performance and runtime confirmation, and hybrid workflows to guide testing and advanced analyses.
For engineers, mastering both construction techniques, selecting appropriate tools for your language/runtime, and building iterative pipelines that merge static and dynamic results will drastically improve program comprehension, debugging, optimization, and security assessment.
Leave a Reply