5.1.19 · D3C Programming

Worked examples — Valgrind — detecting memory errors

2,804 words13 min readBack to topic

Before anything, a picture of the vocabulary. A malloc hands you a block — a contiguous strip of heap bytes (see Stack vs Heap). Every byte in that strip carries two invisible tags Memcheck maintains:

Look at the strip above. Green = A-bit set (you may touch it). The hatching = V-bit undefined (the byte is addressable but holds garbage — you may touch it, but the value is meaningless until you write it). Red = A-bit clear (touching it is an error). Every example below is just "which coloured region did the code poke, and how?"


The scenario matrix

Every Memcheck bug is one cell of this table. The Direction column is the geometric "which side of the block" axis (the sign, our "quadrant" here); the Kind column is which of the three questions failed.

# Cell class Concrete trigger Question failed
1 Overflow — past the end (positive side) a[N] on an N-element array A-bit
2 Underflow — before the start (negative side) a[-1] A-bit
3 Use-after-free (touch a red block) read *p after free(p) A-bit
4 Double free (free red twice) free(p); free(p); A-bit (freelist)
5 Uninitialised value used in control flow if (*p) on unwritten heap V-bit
6 Uninitialised value merely copied (the no-error case) int y = x; unused none (V travels)
7 Zero / degenerate: malloc(0), free(NULL) boundary allocation sizes none / defined
8 Leak — definitely lost overwrite the only pointer Leak
9 Leak — indirectly lost lose a struct that owned children Leak
10 Leak — still reachable (the harmless case) global pointer left unfreed Leak (benign)
11 Real-world word problem a parser that forgets one branch mixed
12 Exam twist why -O2 hides the bug tooling

The two "no-error" cells (6, 7, 10-benign) matter as much as the red ones: you must know when Valgrind stays silent on purpose, or you'll chase ghosts.


Example 1 — Overflow past the end (Cell 1, positive side)

Forecast: guess the byte offset the bad write lands on before reading on.

  1. Count the block's bytes. 5 * sizeof(int) with sizeof(int) = 4 gives bytes, offsets through . Why this step? The A-bits are set exactly on — everything else on that side is red.
  2. Locate a[5]. Index means byte offset , writing 4 bytes: offsets . Why this step? Offset is the first byte past the block — the very first red byte on the positive side.
  3. Check the A-bits. Invalid write of size 4. Why this step? The parent's rule: access OK every touched byte is accessible. Byte 20 isn't.

Valgrind prints: Address 0x... is 0 bytes after a block of size 20 alloc'd.

Verify: offset ; block size ; gap bytes after. ✓


Example 2 — Underflow before the start (Cell 2, negative side)

Forecast: which byte does a[-1] write, and is it before or after?

  1. Compute the offset. Index → byte offset , i.e. offsets . Why this step? Negative index walks backwards — the mirror image of Example 1. Many students forget this whole quadrant exists.
  2. Check A-bits. Bytes at offset are before the block, A-bit Invalid write of size 4. Why this step? Same rule, opposite direction; Memcheck guards both edges (see figure).

Valgrind prints: Address 0x... is 4 bytes before a block of size 20 alloc'd.

Verify: offset ; magnitude bytes before the block. ✓


Example 3 — Use-after-free (Cell 3, touching red)

Forecast: you wrote a defined value first — does that make the read safe?

  1. After free(p). The parent's rule: free sets and quarantines the block so it isn't reused. Why this step? Quarantine keeps the A-bit at 0 long enough to catch us; without it a fresh malloc might recolour the bytes green and hide the bug.
  2. Read *p. Byte offset 0 now has A-bit Invalid read of size 4. Why this step? It's an A-bit error — "not yours to touch" — even though the value was once defined. Defined-ness is irrelevant once you're forbidden to touch.

Valgrind prints: Invalid read of size 4 ... Address 0x... is 0 bytes inside a block of size 4 free'd.

Verify: block size bytes; read at offset , which is bytes inside a freed block. ✓


Example 4 — Double free (Cell 4)

Forecast: the first free succeeded — why does the second one fail?

  1. First free(p). Block goes onto the quarantine freelist; A-bits . Why this step? Memcheck now remembers this block is dead.
  2. Second free(p). Valgrind checks: is this pointer to a currently allocated block? No — it's on the freelist → Invalid free() / delete / delete[]. Why this step? Freeing an already-freed pointer corrupts the real allocator's bookkeeping (see malloc and free) — classic Undefined Behaviour in C.

Valgrind prints: Invalid free() ... Address 0x... is 0 bytes inside a block of size 4 free'd.

Verify (logical): allocated blocks after two frees , impossible → second free is invalid. ✓


Example 5 — Uninitialised value in control flow (Cell 5, V-bit)

Forecast: the byte is addressable — so where's the crime?

  1. A-bit is fine. You malloc'd it, so over — the read itself is allowed. Why this step? Rules out an A-bit report; it will be silent about addressability.
  2. V-bit is undefined. You never wrote *p, so its V-bits say "garbage". Why this step? The comparison *p == 0 produces a result whose truth depends on garbage.
  3. The if uses that result for control flow. Now Memcheck fires: Conditional jump or move depends on uninitialised value(s). Why this step? Memcheck lets undefined values travel silently; it only complains at the moment garbage actually changes what the program does — a branch or a syscall.

Verify (logical): the branch outcome is a function of undefined bits ⇒ program behaviour is non-deterministic ⇒ exactly the condition Memcheck flags. ✓


Example 6 — Uninitialised value merely copied (Cell 6, the NO-error case)

Forecast: we read undefined memory — surely that's a bug?

  1. int y = *p; copies undefined V-bits into y. No error. Why this step? Copying garbage is legal in Memcheck's model — the V-bits ride along with the value into y.
  2. y is never used in a branch, index, or output. So the garbage never influences behaviour → silent. Why this step? This is the deliberate design: flagging every copy would drown you in false positives.

Verify (logical): number of Memcheck errors here (no branch/output consumes undefined bits). ✓


Example 7 — Zero and degenerate inputs (Cell 7)

Forecast: can you allocate nothing? Can you free nothing?

  1. malloc(0) returns either NULL or a unique pointer you may pass to free but not dereference (0 addressable bytes). Why this step? The block has size 0, so any *q would be "0 bytes after a block of size 0" — but we don't dereference, so no error.
  2. free(q) is valid whether q is that zero-size block or NULL. Why this step? The C standard makes this well-defined; Memcheck agrees.
  3. free(NULL) is explicitly a no-op by the standard. Why this step? This is the degenerate limit — the "zero input" of the free operation — and it is silent, not an error.

Verify (logical): errors ; both frees are standard-defined no-ops / valid. ✓


Example 8 — Definitely lost (Cell 8)

Forecast: how many blocks live? How many pointers survive to reach them?

  1. After buf = malloc(200). The address of the 100-byte block is nowhere — the only variable holding it was overwritten. Why this step? The exit-time mark-and-sweep starts from live pointers; if none point into a block, it's unreachable.
  2. Classify. Unreachable and no interior pointer → definitely lost: 100 bytes in 1 block. Why this step? Matches the parent's leak-rule tree exactly.
  3. The 200 block is freed → fine.

Verify: leaked bytes ; freed cleanly . ✓


Example 9 — Indirectly lost (Cell 9)

Forecast: head is gone — but a pointer to C still exists inside P. Does that save C?

  1. Drop P. Setting head = NULL makes block P unreachable → definitely lost, 8 bytes. Why this step? No live variable points to P.
  2. What about C? The only pointer to C lives inside P — but P itself is lost. So C is reachable only through a lost blockindirectly lost, 8 bytes. Why this step? This is the whole point of the "indirectly lost" category: you lost the root, and everything it owned falls with it.

Verify: definitely lost ; indirectly lost ; total unreachable bytes. ✓


Example 10 — Still reachable, the benign case (Cell 10)

Forecast: we never free(pool) — is that a "definitely lost" bug?

  1. At exit, pool still holds the address. A live (global) pointer points into the block. Why this step? Reachability from a global counts — the block is findable.
  2. Classify as still reachable: 50 bytes. Not "definitely lost". Why this step? The OS reclaims it at process exit; this is the harmless leak category the parent warned you not to over-prioritise.

Verify: still-reachable bytes ; definitely-lost . ✓


Example 11 — Real-world word problem (Cell 11)

Forecast: which lines produce lost blocks?

  1. Valid lines (7). Pointer stored in the array → reachable at exit → not definitely lost. Why this step? The array root keeps them findable.
  2. Blank lines (3). Buffer allocated, pointer overwritten on next iteration, never stored, never freed → each unreachable. Why this step? The continue skips both storage and free, orphaning the block.
  3. Total. bytes definitely lost in 3 blocks. Why this step? Each blank line contributes one orphaned 64-byte block.

Verify: leaked bytes in blocks; reachable bytes. ✓


Example 12 — Exam twist: why -O2 hides the bug (Cell 12)

Forecast: the bug didn't vanish — so why is the report clean?

  1. Dead-store elimination. The optimiser sees the value written to a[5] is never read, so it deletes the store entirely. Why this step? Under Undefined Behaviour in C the compiler may assume no out-of-bounds write happens; with no observable effect, it removes the instruction.
  2. No memory access ⇒ nothing for Memcheck to instrument. Valgrind can only check accesses that survive to run. Why this step? Memcheck watches the emitted machine code, not your source.
  3. Fix: always debug with -O0 -g first (see Debugging with GDB), then re-audit the release build separately. Cross-check with AddressSanitizer (ASan), which instruments at compile time and can catch some cases the optimiser reshapes.

Verify (logical): eliminated accesses ; accesses Memcheck can see ⇒ zero reports. ✓


Recall Cover check: name the cell for each symptom

"Invalid read, 0 bytes inside a free'd block" ::: Cell 3, use-after-free (A-bit) "4 bytes before a block of size 20" ::: Cell 2, underflow (A-bit, negative side) "Conditional jump depends on uninitialised value" ::: Cell 5, V-bit in control flow Silent report after copying garbage into an unused variable ::: Cell 6, V-bits travel, no use "indirectly lost" 8 bytes ::: Cell 9, child reachable only through a lost parent Clean report under -O2 that fails under -O0 ::: Cell 12, dead-store elimination