Debugging and undefined behavior · about 45 min

Defensive C: warnings, assert and habits

Why this matters

C will not stop you from making mistakes, so experienced C programmers build their own guard rails. Some come from the compiler (warnings), some from the code itself (assertions that check assumptions), and some are simply habits that make whole classes of bugs impossible. None of them are exciting, and together they are the difference between C code that is a liability and C code that runs for decades. The SQLite and curl projects, both famously reliable, use every technique in this lesson.

Let the compiler help: warning flags

-Wall -Wextra is the baseline you have used all along. Other flags worth knowing:

flagcatches
-Werrorturns every warning into an error, so none are ignored
-Wshadowa variable that hides another of the same name in an outer scope
-Wconversionimplicit conversions that may change a value (double to int, int to short, signed to unsigned)
-Wpedanticuse of gcc extensions that are not standard C
-Wformat=2stricter printf/scanf format checking
-Wnull-dereferencepaths where the compiler can prove a NULL dereference (works with -O2)
-Wvlavariable length arrays, which can blow the stack

Here is a program that is clean under -Wall -Wextra:

#include <stdio.h>

int scale = 3;

int scaled_total(const int *v, int n)
{
    int total = 0;
    for (int i = 0; i < n; i++) {
        int scale = 2;
        total += v[i] * scale;
    }
    return total;
}

int main(void)
{
    int data[3] = {1, 2, 3};
    double avg = scaled_total(data, 3) / 3.0;
    int rounded = avg + 0.5;
    unsigned len = -1;
    printf("%d %d %u\n", scaled_total(data, 3), rounded, len);
    return 0;
}

Adding -Wshadow -Wconversion:

w.c:9:13: warning: declaration of 'scale' shadows a global declaration [-Wshadow]
w.c:19:19: warning: conversion from 'double' to 'int' may change value [-Wfloat-conversion]
w.c:20:20: warning: unsigned conversion from 'int' to 'unsigned int' changes value from '-1' to '4294967295' [-Wsign-conversion]

None of these is a bug here, but each is a pattern that frequently is one: someone later edits the loop expecting the global scale; a truncation silently loses data; a negative number becomes four billion. Fixing a warning usually means stating your intent: rename, cast explicitly, or use the right constant. A codebase with zero warnings is one where a new warning stands out.

Note: -Wconversion is noisy in older code. Turn it on from the start of a project, when it is cheap, rather than retrofitting it.

assert: executable assumptions

<assert.h> provides the macro assert(expression). If the expression is false (zero), it prints the file, line, function and expression to stderr and calls abort(), which kills the program with the signal SIGABRT (the shell shows exit status 134 = 128 + 6):

#include <assert.h>
#include <stdio.h>

double average(const int *v, int n)
{
    assert(v != NULL);
    assert(n > 0);
    int sum = 0;
    for (int i = 0; i < n; i++)
        sum += v[i];
    return (double)sum / n;
}

int main(void)
{
    int data[] = {4, 8, 15};
    printf("%.2f\n", average(data, 3));
    printf("%.2f\n", average(data, 0));
    return 0;
}
$ ./prog
9.00
prog: as.c:7: average: Assertion `n > 0' failed.
Aborted (core dumped)
$ echo $?
134

(On a terminal you see 9.00 first. When output goes to a pipe, the buffered 9.00 is lost, because abort does not flush stdio buffers.)

An assertion turns a silent wrong answer (here a division by zero that yields nan) into an immediate, precise report. Use them for:

Separate asserts per condition give better messages than one assert(v && n > 0).

NDEBUG

If the macro NDEBUG is defined when <assert.h> is included (usually with -DNDEBUG on the command line for release builds), every assert expands to nothing. Two rules follow:

  1. Never put side effects in an assert. assert(fclose(f) == 0) closes the file only in debug builds.
  2. Never use assert for errors that can really happen. Bad user input, missing files, failed malloc: these need real error handling that is present in every build. assert is for programmer errors.

static_assert

C11 added compile-time assertions. static_assert (from <assert.h>) checks a constant expression during compilation:

#include <assert.h>
#include <limits.h>

static_assert(CHAR_BIT == 8, "this code assumes 8-bit bytes");
static_assert(sizeof(int) >= 4, "int must be at least 32 bits");

If the condition is false, the build fails with your message. Use it to document assumptions about sizes, struct layouts and array lengths, at zero run-time cost.

Habits that prevent bugs

These are the habits that experienced C programmers apply without thinking:

Common mistakes

Recap

Going further: read man 3 assert, then look at how a large project configures warnings: git and curl both document their developer warning flags. The CERT C Coding Standard is a free, searchable catalogue of rules like the ones above, each with examples.

Exercises

Exercise 1 · quiz

assert and NDEBUG

A program contains assert(remove_item(list, id) == 0);. It works in testing. The release build is compiled with -DNDEBUG. What happens in the release build?

Answer
  • The whole assert expression disappears, so remove_item is never called and items are never removed

With NDEBUG defined, assert(expr) expands to ((void)0): the expression is not evaluated at all. Any side effect inside it vanishes. Never put work that must happen inside assert. Write int rc = remove_item(list, id); assert(rc == 0); instead (and think about whether a real error check, not an assertion, is what you want: assertions are for programmer mistakes, not for conditions that can legitimately happen at run time).

Exercise 2 · quiz

Assertion or error handling?

Which of these situations should be handled with a normal error check (return an error code, print a message) rather than an assert? Select all that apply.

Answer
  • The user typed a number that is out of range

  • A file the user named does not exist

  • malloc returned NULL

Bad input, missing files and allocation failures are things that really happen to correct programs; they need error handling that survives a release build. An assert documents and checks something that can only be false if the program is wrong: a broken precondition between your own functions or a violated invariant. When it fires, the right response is to stop and fix the code.

Exercise 3 · write code

Guard a precondition

average(v, n) requires a non-NULL v and n > 0; calling it otherwise is a bug in the caller. Add asserts (from <assert.h>) at the top of average that check both preconditions, as two separate asserts. Do not change the computation.

The program prints the average of the numbers it reads (first a count, then that many integers) with two decimals. With a count of 0 the assertion must fire, which aborts the program (exit status 134, and a message on stderr).

$ echo "3  4 8 15" | ./prog
9.00

File: main.c

Starting point

#include <stdio.h>

double average(const int *v, int n)
{
    int sum = 0;
    for (int i = 0; i < n; i++)
        sum += v[i];
    return (double)sum / n;
}

int main(void)
{
    int data[100];
    int n;
    if (scanf("%d", &n) != 1 || n < 0 || n > 100)
        return 1;
    for (int i = 0; i < n; i++)
        if (scanf("%d", &data[i]) != 1)
            return 1;
    printf("%.2f\n", average(data, n));
    return 0;
}

Example

input

3  4 8 15

expected output

9.00
Hint 1

Add #include <assert.h> at the top.

Hint 2

assert(condition) aborts when the condition is false, so write the condition that must be true: assert(n > 0);

Reference solution
#include <assert.h>
#include <stdio.h>

double average(const int *v, int n)
{
    assert(v != NULL);
    assert(n > 0);
    int sum = 0;
    for (int i = 0; i < n; i++)
        sum += v[i];
    return (double)sum / n;
}

int main(void)
{
    int data[100];
    int n;
    if (scanf("%d", &n) != 1 || n < 0 || n > 100)
        return 1;
    for (int i = 0; i < n; i++)
        if (scanf("%d", &data[i]) != 1)
            return 1;
    printf("%.2f\n", average(data, n));
    return 0;
}

Exercise 4 · write code

Survive the strict flags

This program compiles cleanly with -Wall -Wextra, but a stricter build adds -Wshadow (a local variable hides another with the same name) and -Wconversion (an implicit conversion may change a value), and it produces three warnings. Fix all of them so the program builds with

gcc -std=c17 -Wall -Wextra -Wshadow -Wconversion -Werror

and still prints exactly:

12 4 4294967295

Make each conversion explicit or remove it; do not just delete the variables. (The third number is the largest 32-bit unsigned value, which is what the program intends to print.)

File: main.c

Starting point

#include <stdio.h>

int scale = 3;

int scaled_total(const int *v, int n)
{
    int total = 0;
    for (int i = 0; i < n; i++) {
        int scale = 2;
        total += v[i] * scale;
    }
    return total;
}

int main(void)
{
    int data[3] = {1, 2, 3};
    double avg = scaled_total(data, 3) / 3.0;
    int rounded = avg + 0.5;
    unsigned len = -1;
    printf("%d %d %u\n", scaled_total(data, 3), rounded, len);
    return 0;
}

Example

expected output

12 4 4294967295
Hint 1

The local scale inside the loop hides the global scale. Rename the local one.

Hint 2

Converting a double to int throws away the fraction: write the cast (int)(avg + 0.5) to say you mean it.

Hint 3

Instead of assigning -1 to an unsigned, say what you mean: UINT_MAX from <limits.h> (or -1u... but UINT_MAX is clearer).

Reference solution
#include <stdio.h>
#include <limits.h>

int scale = 3;

int scaled_total(const int *v, int n)
{
    int total = 0;
    for (int i = 0; i < n; i++) {
        int factor = 2;
        total += v[i] * factor;
    }
    return total;
}

int main(void)
{
    int data[3] = {1, 2, 3};
    double avg = scaled_total(data, 3) / 3.0;
    int rounded = (int)(avg + 0.5);
    unsigned len = UINT_MAX;
    printf("%d %d %u\n", scaled_total(data, 3), rounded, len);
    return 0;
}