<< Home

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.

💡 Always compile with debugging information during development using -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
⚠️ Stripped binaries or those compiled without -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
💡 Run the program with 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.

⚠️ Undefined behavior means anything can happen. The program might appear correct in small tests and then fail randomly later. Do not rely on luck or compiler behavior; fix the root cause.

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)
💡 Valgrind slows programs dramatically because it simulates instructions. Use it for debugging and regression testing, not for production performance measurement.

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.

⚠️ Some modern systems and sanitizers interfere with gprof's timing. For finer granularity you can use perf on Linux or Instruments on macOS to supplement traditional profiling.