Dynamic analysis is the practice of examining a program's behavior during execution. Unlike static analysis, which inspects source code without running it, dynamic analysis reveals actual runtime behavior, memory usage, performance characteristics, and potential bugs that only manifest during execution. This comprehensive guide explores the tools, techniques, and best practices for dynamic analysis of C programs.
What is Dynamic Analysis?
Dynamic analysis involves executing a program and observing its runtime behavior. It helps answer critical questions:
- Is memory being allocated and freed correctly?
- Are there any memory leaks or buffer overflows?
- How is the program performing?
- What code paths are actually executed?
- Are there any race conditions or concurrency issues?
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Source Code │────▶│ Compilation │────▶│ Instrumented │ │ │ │ with Tools │ │ Executable │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ Analysis │◀────│ Program │◀────│ Runtime │ │ Reports │ │ Execution │ │ Monitoring │ └─────────────────┘ └─────────────────┘ └─────────────────┘
Memory Error Detection with Valgrind
Valgrind is the most comprehensive dynamic analysis tool for C programs, detecting memory leaks, uninitialized memory usage, buffer overflows, and more.
1. Basic Memcheck Usage
// buggy_program.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void memory_leak() {
char *leak = malloc(100);
// No free - memory leak
}
void uninitialized_use() {
int *arr = malloc(10 * sizeof(int));
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += arr[i]; // Uninitialized memory!
}
printf("Sum: %d\n", sum);
free(arr);
}
void buffer_overflow() {
char buffer[10];
strcpy(buffer, "This string is way too long!");
printf("Buffer: %s\n", buffer);
}
void invalid_free() {
int x = 42;
free(&x); // Invalid free - not heap memory
}
int main() {
memory_leak();
uninitialized_use();
buffer_overflow();
invalid_free();
return 0;
}
Compile and run with Valgrind:
gcc -g -o buggy_program buggy_program.c valgrind --leak-check=full --show-leak-kinds=all ./buggy_program
2. Advanced Valgrind Options
// Comprehensive Valgrind example
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int *data;
size_t size;
} Vector;
Vector* vector_create(size_t size) {
Vector *v = malloc(sizeof(Vector));
if (!v) return NULL;
v->data = calloc(size, sizeof(int));
v->size = size;
return v;
}
void vector_destroy(Vector *v) {
if (!v) return;
free(v->data);
// Missing free(v) - memory leak
}
void vector_set(Vector *v, size_t index, int value) {
if (index >= v->size) {
// Buffer overflow if index is out of bounds
v->data[index] = value;
} else {
v->data[index] = value;
}
}
int vector_get(Vector *v, size_t index) {
return v->data[index];
}
int main() {
Vector *v = vector_create(5);
vector_set(v, 0, 10);
vector_set(v, 1, 20);
vector_set(v, 5, 60); // Buffer overflow!
printf("Value: %d\n", vector_get(v, 2)); // Uninitialized
vector_destroy(v);
// v still leaks
return 0;
}
Valgrind command with all options:
valgrind --tool=memcheck \ --leak-check=full \ --show-leak-kinds=all \ --track-origins=yes \ --verbose \ --log-file=valgrind-out.txt \ ./program
3. Valgrind Suppression Files
// Suppress known library leaks
// suppression.supp
{
ignore_libc_leaks
Memcheck:Leak
...
obj:/lib/libc-*.so
}
{
ignore_glibc_leaks
Memcheck:Leak
...
obj:*/libc.so.*
}
// Use suppression file
// valgrind --suppressions=suppression.supp ./program
AddressSanitizer (ASan)
AddressSanitizer is a fast memory error detector built into GCC and Clang that detects:
- Use-after-free
- Heap buffer overflows
- Stack buffer overflows
- Global buffer overflows
- Memory leaks
// asan_example.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void heap_overflow() {
char *buffer = malloc(10);
buffer[10] = 'A'; // Heap buffer overflow
free(buffer);
}
void use_after_free() {
char *buffer = malloc(10);
free(buffer);
buffer[0] = 'A'; // Use after free
}
void stack_overflow() {
char buffer[10];
buffer[10] = 'A'; // Stack buffer overflow
}
void double_free() {
char *buffer = malloc(10);
free(buffer);
free(buffer); // Double free
}
int main() {
heap_overflow();
use_after_free();
stack_overflow();
double_free();
return 0;
}
Compile with AddressSanitizer:
gcc -fsanitize=address -g -o asan_example asan_example.c ./asan_example
ASan with Leak Detection:
# Enable leak detection export ASAN_OPTIONS=detect_leaks=1 ./asan_example # Custom options export ASAN_OPTIONS=detect_leaks=1:log_path=asan.log:verbosity=1
UndefinedBehaviorSanitizer (UBSan)
UBSan detects undefined behavior at runtime:
// ubsan_example.c
#include <stdio.h>
#include <limits.h>
void signed_overflow() {
int x = INT_MAX;
x = x + 1; // Signed integer overflow
printf("Overflow: %d\n", x);
}
void null_pointer_deref() {
int *ptr = NULL;
*ptr = 42; // Null pointer dereference
}
void shift_exponent() {
int x = 1;
x = x << 31; // Shift exponent too large
}
void division_by_zero() {
int x = 42;
int y = 0;
int z = x / y; // Division by zero
}
int main() {
signed_overflow();
null_pointer_deref();
shift_exponent();
division_by_zero();
return 0;
}
Compile with UBSan:
gcc -fsanitize=undefined -g -o ubsan_example ubsan_example.c ./ubsan_example # With specific checks gcc -fsanitize=undefined -fsanitize=null -fsanitize=alignment -g -o ubsan_example ubsan_example.c
ThreadSanitizer (TSan) for Concurrency
ThreadSanitizer detects data races and threading bugs:
// tsan_example.c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
int shared_counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// Race condition - no mutex
void *race_thread(void *arg) {
for (int i = 0; i < 100000; i++) {
shared_counter++; // Data race!
}
return NULL;
}
// Proper synchronization
void *safe_thread(void *arg) {
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&mutex);
shared_counter++;
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main() {
pthread_t t1, t2;
// Race condition example
printf("Race condition test:\n");
shared_counter = 0;
pthread_create(&t1, NULL, race_thread, NULL);
pthread_create(&t2, NULL, race_thread, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Counter (expected 200000): %d\n", shared_counter);
// Safe example
printf("\nSafe example:\n");
shared_counter = 0;
pthread_create(&t1, NULL, safe_thread, NULL);
pthread_create(&t2, NULL, safe_thread, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Counter (expected 200000): %d\n", shared_counter);
return 0;
}
Compile with ThreadSanitizer:
gcc -fsanitize=thread -g -pthread -o tsan_example tsan_example.c ./tsan_example
Code Coverage Analysis (gcov, lcov)
Code coverage tools show which parts of your code are executed during testing:
// coverage_example.c
#include <stdio.h>
#include <stdlib.h>
int compute(int a, int b, char op) {
int result = 0;
switch(op) {
case '+':
result = a + b;
break;
case '-':
result = a - b;
break;
case '*':
result = a * b;
break;
case '/':
if (b != 0) {
result = a / b;
} else {
fprintf(stderr, "Division by zero!\n");
result = 0;
}
break;
default:
fprintf(stderr, "Unknown operator\n");
break;
}
return result;
}
// Test functions
void test_addition() {
assert(compute(5, 3, '+') == 8);
assert(compute(0, 0, '+') == 0);
assert(compute(-5, 3, '+') == -2);
}
void test_subtraction() {
assert(compute(10, 4, '-') == 6);
assert(compute(0, 5, '-') == -5);
}
int main() {
test_addition();
test_subtraction();
// Missing tests for multiplication, division, error cases
printf("Tests passed!\n");
return 0;
}
Generate coverage report:
# Compile with coverage flags gcc -fprofile-arcs -ftest-coverage -g -o coverage_example coverage_example.c # Run the program ./coverage_example # Generate coverage data gcov coverage_example.c # Generate HTML report with lcov lcov --capture --directory . --output-file coverage.info genhtml coverage.info --output-directory coverage_html
Dynamic Taint Analysis
Track how user input flows through your program to detect injection vulnerabilities:
// taint_example.c - Simple taint tracking
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define MAX_TAINT 1000
typedef struct {
char *ptr;
int tainted;
} TaintedPtr;
TaintedPtr taint_table[MAX_TAINT];
int taint_count = 0;
void mark_tainted(void *ptr, size_t size) {
if (taint_count < MAX_TAINT) {
taint_table[taint_count].ptr = ptr;
taint_table[taint_count].tainted = 1;
taint_count++;
}
}
int is_tainted(void *ptr) {
for (int i = 0; i < taint_count; i++) {
if (taint_table[i].ptr == ptr) {
return taint_table[i].tainted;
}
}
return 0;
}
void taint_string(char *str) {
mark_tainted(str, strlen(str));
for (char *p = str; *p; p++) {
mark_tainted(p, 1);
}
}
// Simulate reading user input
char* get_user_input() {
char *input = malloc(100);
strcpy(input, "user_input_123");
taint_string(input);
return input;
}
// Dangerous operation - should be checked
void execute_command(char *cmd) {
if (is_tainted(cmd)) {
printf("SECURITY ALERT: Attempting to execute tainted command: %s\n", cmd);
// In real code, you might reject or sanitize
return;
}
printf("Executing safe command: %s\n", cmd);
}
int main() {
char *user_input = get_user_input();
// User input is tainted
execute_command(user_input);
// Clean input
char *safe_input = "ls -la";
execute_command(safe_input);
free(user_input);
return 0;
}
Performance Profiling (gprof)
Profile your program to identify performance bottlenecks:
// profile_example.c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
void expensive_function(int n) {
volatile int sum = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
sum += i * j;
}
}
}
void fast_function(int n) {
volatile int sum = 0;
for (int i = 0; i < n; i++) {
sum += i;
}
}
void medium_function(int n) {
volatile int sum = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n / 10; j++) {
sum += i;
}
}
}
int main() {
for (int i = 0; i < 100; i++) {
expensive_function(1000);
fast_function(10000);
medium_function(1000);
}
return 0;
}
Profile with gprof:
# Compile with profiling gcc -pg -g -o profile_example profile_example.c # Run to generate gmon.out ./profile_example # View profile gprof profile_example gmon.out > profile.txt # Flat profile shows time spent # Call graph shows calling relationships
Memory Profiling (Massif)
Analyze memory usage over time with Valgrind's Massif:
// massif_example.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void allocate_memory(int n) {
for (int i = 0; i < n; i++) {
char *ptr = malloc(100);
if (i % 10 == 0) {
free(ptr); // Free some allocations
}
// Others leak
}
}
void allocate_large_memory() {
char *big = malloc(1024 * 1024); // 1 MB
// Not freed
}
int main() {
printf("Allocating memory...\n");
allocate_memory(1000);
allocate_large_memory();
printf("Done. Check memory usage.\n");
return 0;
}
Run Massif:
valgrind --tool=massif --massif-out-file=massif.out ./massif_example ms_print massif.out > massif_report.txt
Cache Analysis (Cachegrind)
Analyze cache behavior with Valgrind's Cachegrind:
// cache_example.c
#include <stdio.h>
#include <stdlib.h>
#define SIZE 1000
void matrix_multiply(int n, double A[SIZE][SIZE],
double B[SIZE][SIZE], double C[SIZE][SIZE]) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
double sum = 0.0;
for (int k = 0; k < n; k++) {
sum += A[i][k] * B[k][j]; // Bad cache locality
}
C[i][j] = sum;
}
}
}
void matrix_multiply_optimized(int n, double A[SIZE][SIZE],
double B[SIZE][SIZE], double C[SIZE][SIZE]) {
for (int i = 0; i < n; i++) {
for (int k = 0; k < n; k++) {
double aik = A[i][k];
for (int j = 0; j < n; j++) {
C[i][j] += aik * B[k][j]; // Better cache locality
}
}
}
}
int main() {
static double A[SIZE][SIZE], B[SIZE][SIZE], C[SIZE][SIZE];
// Initialize matrices
for (int i = 0; i < SIZE; i++) {
for (int j = 0; j < SIZE; j++) {
A[i][j] = i + j;
B[i][j] = i - j;
C[i][j] = 0;
}
}
printf("Running naive multiplication...\n");
matrix_multiply(SIZE, A, B, C);
printf("Running optimized multiplication...\n");
matrix_multiply_optimized(SIZE, A, B, C);
return 0;
}
Run Cachegrind:
valgrind --tool=cachegrind --cachegrind-out-file=cache.out ./cache_example cg_annotate cache.out > cache_report.txt
Dynamic Analysis Framework
Here's a complete dynamic analysis framework combining multiple techniques:
// dynamic_analysis_framework.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <setjmp.h>
#include <time.h>
// Analysis statistics
typedef struct {
size_t memory_allocated;
size_t memory_freed;
size_t memory_leaks;
size_t function_calls;
size_t recursion_depth;
clock_t start_time;
} AnalysisStats;
AnalysisStats stats = {0};
jmp_buf recovery_jmp;
// Memory tracking
typedef struct Allocation {
void *ptr;
size_t size;
const char *file;
int line;
struct Allocation *next;
} Allocation;
Allocation *allocations = NULL;
// Record allocation
void* track_malloc(size_t size, const char *file, int line) {
void *ptr = malloc(size);
if (ptr) {
Allocation *alloc = malloc(sizeof(Allocation));
alloc->ptr = ptr;
alloc->size = size;
alloc->file = file;
alloc->line = line;
alloc->next = allocations;
allocations = alloc;
stats.memory_allocated += size;
}
return ptr;
}
// Record free
void track_free(void *ptr) {
Allocation **curr = &allocations;
while (*curr) {
if ((*curr)->ptr == ptr) {
Allocation *to_free = *curr;
*curr = (*curr)->next;
stats.memory_freed += to_free->size;
free(to_free);
free(ptr);
return;
}
curr = &(*curr)->next;
}
// Trying to free unknown pointer
fprintf(stderr, "ERROR: Freeing unknown pointer %p\n", ptr);
}
// Report leaks
void report_leaks() {
if (allocations) {
printf("\n=== MEMORY LEAKS DETECTED ===\n");
Allocation *curr = allocations;
while (curr) {
printf(" Leak: %p (%zu bytes) allocated at %s:%d\n",
curr->ptr, curr->size, curr->file, curr->line);
stats.memory_leaks += curr->size;
curr = curr->next;
}
printf("Total leaked: %zu bytes\n", stats.memory_leaks);
} else {
printf("No memory leaks detected.\n");
}
}
// Signal handler for crashes
void crash_handler(int sig) {
printf("\n!!! PROGRAM CRASH !!!\n");
printf("Signal: %d (%s)\n", sig, strsignal(sig));
report_leaks();
printf("Function calls: %zu\n", stats.function_calls);
longjmp(recovery_jmp, 1);
}
// Function call tracking macro
#define TRACE_FUNC() \
stats.function_calls++; \
printf(" Entering: %s (calls: %zu)\n", __func__, stats.function_calls)
// Simple function to test
void recursive_function(int depth) {
TRACE_FUNC();
if (depth > 0) {
recursive_function(depth - 1);
}
}
void memory_leak_function() {
TRACE_FUNC();
void *ptr = track_malloc(100, __FILE__, __LINE__);
// No free - intentional leak
(void)ptr;
}
void buffer_overflow_function() {
TRACE_FUNC();
char buffer[10];
// This will cause a crash
for (int i = 0; i < 20; i++) {
buffer[i] = 'A';
}
}
int main() {
// Setup crash handler
signal(SIGSEGV, crash_handler);
signal(SIGABRT, crash_handler);
signal(SIGFPE, crash_handler);
if (setjmp(recovery_jmp) == 0) {
stats.start_time = clock();
printf("=== Dynamic Analysis Framework ===\n\n");
printf("Testing recursion:\n");
recursive_function(3);
printf("\nTesting memory allocation:\n");
void *ptr1 = track_malloc(50, __FILE__, __LINE__);
void *ptr2 = track_malloc(200, __FILE__, __LINE__);
track_free(ptr1);
printf("\nTesting memory leak:\n");
memory_leak_function();
printf("\nTesting buffer overflow (will crash):\n");
buffer_overflow_function();
} else {
printf("\n=== Recovery after crash ===\n");
}
// Report final statistics
printf("\n=== Program Statistics ===\n");
double elapsed = (double)(clock() - stats.start_time) / CLOCKS_PER_SEC;
printf("Execution time: %.3f seconds\n", elapsed);
printf("Memory allocated: %zu bytes\n", stats.memory_allocated);
printf("Memory freed: %zu bytes\n", stats.memory_freed);
printf("Memory in use: %zu bytes\n", stats.memory_allocated - stats.memory_freed);
report_leaks();
printf("Total function calls: %zu\n", stats.function_calls);
return 0;
}
Continuous Integration Integration
# .github/workflows/dynamic-analysis.yml name: Dynamic Analysis on: [push, pull_request] jobs: analysis: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install Valgrind run: sudo apt-get install -y valgrind lcov - name: Build with AddressSanitizer run: | gcc -fsanitize=address -g -o program program.c - name: Run AddressSanitizer run: | ./program || true # Allow crash - name: Run Valgrind run: | valgrind --leak-check=full --error-exitcode=1 ./program || true - name: Generate Coverage Report run: | gcc -fprofile-arcs -ftest-coverage -g -o program program.c ./program gcov program.c lcov --capture --directory . --output-file coverage.info genhtml coverage.info --output-directory coverage_html - name: Upload Coverage uses: actions/upload-artifact@v2 with: name: coverage-report path: coverage_html/
Best Practices for Dynamic Analysis
- Run with sanitizers in development: Use ASan, UBSan, TSan during development
- Use Valgrind for thorough checking: Especially for memory leaks
- Profile before optimizing: Use gprof, perf, or Cachegrind to find bottlenecks
- Measure code coverage: Ensure tests exercise all code paths
- Automate in CI/CD: Run dynamic analysis on every commit
- Test with realistic workloads: Simulate production conditions
- Address warnings immediately: Don't ignore sanitizer reports
- Use multiple tools: Different tools find different issues
- Document suppressions: Keep suppression files for known false positives
- Test edge cases: Boundary conditions, extreme values, error paths
Common Dynamic Analysis Commands Reference
| Tool | Purpose | Command |
|---|---|---|
| Valgrind | Memory errors, leaks | valgrind --leak-check=full ./program |
| AddressSanitizer | Buffer overflows, use-after-free | gcc -fsanitize=address -g program.c |
| UndefinedBehaviorSanitizer | UB detection | gcc -fsanitize=undefined -g program.c |
| ThreadSanitizer | Race conditions | gcc -fsanitize=thread -g -pthread program.c |
| gprof | Profiling | gcc -pg -g program.c; ./program; gprof ./program |
| gcov | Code coverage | gcc -fprofile-arcs -ftest-coverage -g program.c |
| Cachegrind | Cache analysis | valgrind --tool=cachegrind ./program |
| Massif | Heap profiling | valgrind --tool=massif ./program |
| perf | Performance counters | perf record ./program; perf report |
| strace | System calls | strace -o trace.txt ./program |
Conclusion
Dynamic analysis is an essential practice for developing robust C programs. By combining multiple tools and techniques, you can:
- Detect memory errors before they cause crashes
- Identify performance bottlenecks
- Ensure comprehensive test coverage
- Catch undefined behavior
- Debug concurrency issues
- Analyze program behavior under real conditions
The tools covered in this guide—Valgrind, AddressSanitizer, ThreadSanitizer, profilers, and coverage tools—form a complete toolkit for runtime program analysis. Integrating these into your development workflow will significantly improve code quality, security, and reliability. Remember that dynamic analysis complements static analysis; use both for the best results.