Chapter 17: Debugging and Profiling
Even experienced C programmers spend much of their time debugging and tuning performance. Tools such as gdb, lldb, Valgrind, and gprof make this process systematic. This chapter explains how to inspect a running program, how to recognize and fix common runtime errors, how to find memory leaks, and how to profile function level performance to guide optimization.
-g. This includes symbol names and line numbers that make your tools far more useful.
Using gdb and lldb
gdb (GNU Debugger) and lldb (LLVM Debugger) let you pause a program, inspect variables, step through code, and trace crashes. They read symbols from the compiled binary when you build with -g. Both share many concepts but differ slightly in command syntax.
Starting a program under the debugger
Compile with debug info first, then run the program inside the debugger shell. For gdb:
cc -g -O0 -o demo demo.c
gdb ./demo
(gdb) run arg1 arg2
For lldb (the default on macOS):
clang -g -O0 -o demo demo.c
lldb ./demo
(lldb) run
Setting breakpoints and stepping through code
A breakpoint stops execution at a chosen line or function so you can examine the program state.
(gdb) break main
(gdb) run
(gdb) next # step over
(gdb) step # step into
(gdb) continue # resume until next breakpoint
(gdb) print x # display variable value
lldb uses similar commands:
(lldb) breakpoint set --name main
(lldb) run
(lldb) next
(lldb) step
(lldb) frame variable x
Inspecting stack traces after a crash
When a program crashes, run it inside the debugger and type bt to show a backtrace. Each frame reveals the function chain leading to the failure.
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
(gdb) bt
#0 crash_here () at demo.c:10
#1 main () at demo.c:15
-g remove symbol names, making debugging difficult. Keep separate debug builds even when releasing optimized code.
Common runtime errors and how to diagnose them
C gives you direct memory access but minimal runtime protection. This power means mistakes cause crashes or silent corruption. Learning to interpret error messages and patterns quickly is an essential skill.
Segmentation faults and invalid memory access
A segmentation fault occurs when the program touches an invalid address such as NULL or memory that has been freed. Use a debugger to find the offending line and print the pointer values involved.
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
(gdb) bt
(gdb) print ptr
ulimit -c unlimited to generate a core dump on crash. You can then inspect it later with gdb ./program core.
Buffer overflows and string errors
Writing past the end of an array corrupts nearby data. Always check array bounds and prefer safer functions such as snprintf() or strncpy(). Enable AddressSanitizer during compilation to detect these errors.
cc -fsanitize=address -g -O1 -o app app.c
./app
Uninitialized variables and undefined values
Reading from uninitialized variables leads to unpredictable behavior. Modern compilers can warn about this with -Wall -Wextra, and sanitizers can detect it at runtime. Initialize every variable explicitly.
Mismatched malloc/free and resource leaks
Allocating memory without freeing it causes leaks, while freeing memory twice corrupts the heap. Tools like Valgrind (explained next) can reveal these problems automatically.
Memory leak detection with Valgrind
Valgrind is a dynamic analysis tool that simulates a CPU and monitors every memory access. It detects invalid reads, writes, and leaks, making it invaluable for debugging memory issues. It runs on Linux and macOS (Intel), though not on Windows natively.
Running a program under Valgrind
Compile with debugging symbols and minimal optimization, then run your program through Valgrind:
cc -g -O0 -o app app.c
valgrind --leak-check=full ./app
A typical leak report looks like this:
==12345== 20 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4846DEF: malloc (vg_replace_malloc.c:381)
==12345== by 0x109176: main (app.c:10)
The stack trace shows where memory was allocated but never freed. Fix it by calling free() on the same pointer before the function exits.
Detecting invalid memory access
Valgrind can also detect reads or writes beyond allocated blocks. These often appear as "Invalid write of size ..." messages. Each report includes the address, size, and stack trace of both the invalid access and the allocation site.
==54321== Invalid read of size 4
==54321== at 0x1091B2: print_item (list.c:42)
==54321== by 0x109246: main (app.c:18)
Profiling performance with gprof
Profiling identifies where a program spends its time so you can optimize the right functions. gprof instruments function calls and counts how often each is invoked and how much time they consume. The results guide targeted improvements rather than blind rewriting.
Compiling with profiling support
To generate profiling data, compile and link with the -pg option, then run the program normally to create a gmon.out file.
cc -pg -O2 -o sortdemo sortdemo.c
./sortdemo
This file records how often functions are called and their approximate runtime cost.
Generating and reading a gprof report
After running the program, execute gprof to produce a readable summary:
gprof ./sortdemo gmon.out > report.txt
less report.txt
The report includes a flat profile and a call graph. The flat profile lists functions sorted by total time percentage, helping you find bottlenecks quickly.
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls Ts/call Ts/call name
45.0 0.09 0.09 10000 0.00 0.00 quicksort
35.0 0.16 0.07 9999 0.00 0.00 partition
20.0 0.20 0.04 1 0.04 0.20 main
Interpreting profiling results and optimizing safely
Focus optimization on functions that dominate runtime. Often only a small part of the code causes most of the delay. Avoid premature optimization; make changes only after measuring. Rebuild and rerun the profiler after each major change to confirm improvement.
gprof's timing. For finer granularity you can use perf on Linux or Instruments on macOS to supplement traditional profiling.