A dynamic analysis tool that detects memory leaks and dangling pointer accesses in C programs.
Built as a course project for CPSC 410 (Advanced Software Engineering) at UBC, MemoryGuard instruments C source code at the AST level using LLVM/Clang, tracks heap memory events at runtime, and produces a syntax-highlighted visual report pinpointing the exact lines where errors occur.
C programs are highly prone to subtle memory bugs - allocating memory that's never freed, or dereferencing a pointer after the memory it points to has been freed. These bugs are silent at compile time, often only surfacing as crashes or corruption in production.
MemoryGuard catches two categories of bug dynamically, by wrapping your program's execution in a lightweight analysis layer:
- Memory Leaks — heap allocations that are never freed before program exit
- Dangling Pointer Accesses — reads or writes through a pointer after its underlying allocation has been freed
Because the analyzer tracks heap addresses rather than variable names, the dangling pointer class also covers calling free() on memory that was already freed through a different alias. That case is reported as a dangling pointer, not as a separately named bug class. See src/tests/test_dangling_pointer.c line 36: ptr was assigned from hi, hi was freed, and the later free(ptr) is flagged because it reaches a block the analyzer already knows is dead.
MemoryGuard is a three-stage pipeline:
Your C File
│
▼
┌─────────────────────────────────────────────────────┐
│ Stage 1 — AST Instrumentation (C++ / LLVM / Clang) │
│ Traverses the AST and injects analysis hooks │
│ around malloc, free, dereferences, and aliasing │
└─────────────────────────────────────────────────────┘
│ instrumented.c
▼
┌─────────────────────────────────────────────────────┐
│ Stage 2 — Runtime Analysis (C++ shared library) │
│ Tracks pointer state at runtime, builds a shadow │
│ map of allocations, and records errors to JSON │
└─────────────────────────────────────────────────────┘
│ output_report.json
▼
┌─────────────────────────────────────────────────────┐
│ Stage 3 — Visual Report (Python / Pygments) │
│ Annotates the original source with error messages │
│ and renders a syntax-highlighted PNG │
└─────────────────────────────────────────────────────┘
│ test.png
▼
Annotated source image showing exact error locations
A custom Clang AST plugin (PointerCallInsertionAST) walks the abstract syntax tree of your C file and injects instrumentation hooks at every relevant memory operation:
| Event | Hook |
|---|---|
malloc call |
_ANALYSIS_INJECTION_call_before/after_malloc |
free call |
_ANALYSIS_INJECTION_call_before_free |
Pointer dereference (*ptr) |
_ANALYSIS_INJECTION_call_before_dereference |
Arrow access (ptr->field) |
_ANALYSIS_INJECTION_call_before_arrow_access |
Pointer aliasing (p = q) |
_ANALYSIS_INJECTION_call_before/after_pointer_aliasing |
| Function entry/exit | _ANALYSIS_INJECTION_call_before/after_function |
The analyzer (a C++ shared library) maintains a shadow memory map — a runtime model of which heap addresses are currently live, which are freed, and which pointers alias them. At program exit, it serializes all detected errors (with source location metadata) to a JSON report.
A Python script reads the JSON report and the original source file, annotates each flagged line with its error type and severity inline as a code comment, then renders the result to a PNG image using Pygments with Monokai syntax highlighting.
| Layer | Technology |
|---|---|
| AST Instrumentation | C++, LLVM, Clang LibTooling |
| Build system | CMake |
| Runtime Analyzer | C++ (compiled as a shared library via Clang) |
| Visualization | Python 3, Pygments, Pillow |
macOS only. LLVM toolchain must be installed via Homebrew.
brew install llvm@16 cmakePython 3 with:
pip install pygments pillow
# If pillow pip install fails:
brew install PillowLLVM 16 specifically. The instrumenter is built on Clang's LibTooling, whose API has changed since this project was written in 2024. On LLVM 17 and later it no longer compiles — FileManager::getFile, SourceManager::createFileID, and CompilerInstance::createSourceManager have all changed signature. Porting it is not a packaging problem; it is a rewrite of the frontend setup code, and has not been done.
Apple's bundled toolchain does not ship the Clang development headers at all, and Homebrew keeps LLVM keg-only, so llvm-config is usually not on your PATH. Set LLVM_CONFIG before running anything:
export LLVM_CONFIG="$(brew --prefix llvm@16)/bin/llvm-config"Sanity check it before you build — Homebrew leaves llvm@N symlinks behind that point at whatever LLVM version you actually have:
$LLVM_CONFIG --version # must print 16.xTwo more optional overrides, for when clang is not on your PATH:
export CC="$(brew --prefix llvm@16)/bin/clang" # default: clang
export CXX="$(brew --prefix llvm@16)/bin/clang++" # default: clang++These compile the runtime analyzer and the instrumented program, and are unrelated to the LibTooling constraint above — the system clang is fine for both. Most people only need LLVM_CONFIG.
CMake caches the LLVM it found on the first configure. If you change
LLVM_CONFIGafter a failed build, deletesrc/backend/instrumenter/build/before retrying, or the stale cache wins.
cd src/
./run_project.sh <path_to_your_c_file>Example — using a bundled test file:
./run_project.sh tests/test_dangling_pointer.c
./run_project.sh tests/ML_test1.cThe script will:
- Build the LLVM instrumenter
- Instrument your source file and compile it with the runtime analyzer
- Execute the instrumented binary to collect the error report
- Run the Python frontend to produce an annotated PNG
Output: frontend/test.png — an annotated, syntax-highlighted image of your source code with errors marked inline.
Note: If CMake fails with
Could not find a package configuration file provided by "Clang",LLVM_CONFIGis unset or pointing at an LLVM without the Clang development headers. See Pointing the build at your LLVM.
Running MemoryGuard on src/tests/DP_test2.c, which frees a pointer, reads through a surviving alias, and leaks a second allocation:
Each flagged line is annotated inline with its error type, severity, and the supporting locations the analyzer used to reach that conclusion — where the memory was aliased, and where it was freed.
The src/tests/ directory holds small C programs used during development, named by the bug they exercise:
| Prefix | Bug Type |
|---|---|
ML_test*.c |
Memory leaks |
DP_test*.c |
Dangling pointers, including aliasing |
test_dangling_pointer.c |
Combined leak and aliasing cases |
dynamic_test.c |
Sanity check: bugs in functions that are never called, which a dynamic tool should not report |
src/tests/outputs/ holds the JSON report each program produced when it was last run, recorded as-is. An empty report means the analyzer found nothing, which is the correct result for dynamic_test.c and a miss for DP_test5.c and DP_test6.c.
- Frees performed inside a called function are not attributed back to the caller's aliases.
DP_test5.candDP_test6.cboth free a linked list insidefreeListand then read through a pointer the caller still holds. The read is instrumented and the analyzer inspects the correct heap address, but concludes the block is live, so both report nothing.DP_test6.cis commented in detail and is the clearest illustration. - The analyzer cannot report a bug that kills the process first.
DP_test3.cwrites through a freed pointer and takes a bus error before the report is serialized at exit, so it produces no report at all. - Repeated allocations at one site are reported once per allocation.
ML_test4.cleaks in a loop and produces ten identical entries for the same line;ML_test5.cproduces five from a recursive call.
docs/ # Example output image used by this README
src/
├── backend/
│ ├── instrumenter/ # Clang AST plugin (C++)
│ ├── analyzer/ # Runtime analysis library (C++)
│ └── run_analysis.sh # Backend orchestration script
├── frontend/
│ └── frontend.py # Visualization script
├── tests/ # Example C programs
│ └── outputs/ # JSON report each program last produced
└── run_project.sh # Main entry point
