Procedural Programming with C · Professional · Week 56

Maintaining Long-Lived C Codebases

C's defining property is that code written in 1985 still compiles and still runs in production. That is the language's greatest strength and the source of its hardest engineering problem: changing something nobody fully understands, without breaking what it currently does.

By the end of this week you can
  • Read K&R-era C and recognize the constructs that no longer belong in new code.
  • Put untested code under characterization tests before touching it.
  • Refactor incrementally with the behavior pinned at every step.
  • Migrate a codebase across standards and compilers safely.
  • Argue for or against paying down a specific piece of technical debt.

1Reading old C

A codebase that predates C89 uses constructs the language has since replaced. They are not errors — they still compile — but they mean something subtly different, and knowing which is which is the first skill.

/* K&R parameter declarations: no type checking at the call site */
int add(a, b)
    int a;
    int b;
{
    return a + b;
}

/* implicit int: gone in C99 */
static count;                    /* means: static int count */
foo(void) { }                    /* means: int foo(void)    */

/* empty parameter list: "unspecified", not "none" */
int process();                   /* any arguments accepted, unchecked */
Old formReplacementWhy it matters
K&R parameter listsPrototypesCalls are type-checked. Removed in C23.
Implicit intExplicit typesInvalid since C99.
int f()int f(void)Week 11: the first disables checking.
Casting malloc's resultNo castCould hide a missing <stdlib.h>.
char * for a literalconst char *Week 14: writing through it crashes.
register, old autoDelete themNo effect on any modern compiler.
gets, strcpy, sprintffgets, snprintfWeek 14: unbounded writes.

Two warning flags make the audit mechanical:

gcc -Wstrict-prototypes -Wold-style-definition -Wmissing-prototypes …

Old is not the same as wrong. Before modernizing a construct, check whether it is load-bearing. A cast that looks redundant may be silencing a warning on a compiler you do not have; a strange loop may work around a bug in a target you do not build for. Week 43's advice applies: git log -S first, and treat silence in the history as a reason for caution rather than permission.

2Characterization tests

The rule is unconditional: do not refactor untested code. Without tests you cannot distinguish a refactoring from a rewrite that happens to compile.

But legacy code has no tests, and writing correct ones requires knowing the intended behavior — which is exactly what is missing. The way out is to stop trying. A characterization test records what the code currently does, correct or not:

  1. Call the function with a wide range of inputs.
  2. Record the outputs, whatever they are.
  3. Turn those recordings into assertions.
  4. Refactor. Any change in behavior now fails a test.

If step 2 reveals something wrong, resist fixing it. Pin the current behavior, complete the refactoring, and fix the bug afterwards as a separate change — week 43's minimal-change discipline. Doing both at once means a failing test cannot tell you which change caused it.

/* Generated from the current implementation, not from the specification. */
CHECK(legacy_parse("42")      == 42);
CHECK(legacy_parse("  42  ")  == 42);
CHECK(legacy_parse("42abc")   == 42);     /* questionable, but current */
CHECK(legacy_parse("abc")     == 0);      /* indistinguishable from "0" */
CHECK(legacy_parse("")        == 0);
CHECK(legacy_parse("99999999999") == -1); /* whatever it actually returns */

Coverage tells you when you have enough. Run the tests under --coverage from week 35 and add cases until the function is covered; uncovered branches are behavior your tests cannot protect.

3Refactoring safely

Every step below is small enough to be obviously correct, and the tests run after each one. That is the entire technique.

StepEffect
Add missing prototypesThe compiler starts checking calls
Turn on warnings, one flag at a timeFix the fallout before adding the next
Add const where nothing is modifiedDocuments and enforces — week 16
Mark file-local functions staticShrinks the interface — week 28
Introduce a variable for a repeated expressionNames a concept; no behavior change
Extract a block into a functionMakes it testable in isolation
Replace an unbounded call with a bounded oneRemoves a defect class — week 14
Replace a magic number with a named constantExplains it

Verify mechanically where you can. For a pure function, compare the compiled output before and after:

gcc -O2 -S old.c -o old.s
gcc -O2 -S new.c -o new.s
diff old.s new.s        # identical means the behavior cannot have changed

When the assembly is unchanged, the refactoring is provably behavior-preserving — a stronger guarantee than any test suite. It works for renaming, extracting constants, and adding const; it will not survive extracting a function, and that is fine.

4Migrating standards and compilers

Move one step at a time, with the full test suite between each:

-std=c89  →  -std=c99  →  -std=c11  →  -std=c17  →  -std=c23
TargetWatch for
C99Implicit int removed; implicit function declarations removed
C11gets removed; VLAs become optional
C23K&R definitions removed; bool/true/false are keywords

A new compiler is a separate migration, and often more revealing than a new standard — it optimizes differently, so latent undefined behavior surfaces. Week 36's lesson arrives in the form of a build that works with GCC 9 and fails with GCC 14.

gcc -std=c17 -O2 -fsanitize=undefined …      # run the suite under UBSan
clang -std=c17 -O2 …                          # a second opinion
gcc -fanalyzer …                              # week 35's static analysis

Run the tests under sanitizers before the migration. A test suite that passes at -O0 on the old compiler and fails at -O2 on the new one is usually reporting a bug that was always present.

5Technical debt

Not all debt is worth repaying. The decision is an engineering judgement with four inputs:

AskPay it down when
How often is this code changed?Often. Untouched code costs nothing to leave.
How dangerous is it?Memory safety and input handling first.
What does it block?It is preventing work you need to do.
What does the fix risk?The risk is lower than the status quo.

A gnarly function nobody has edited in eight years, with no known defects, is not a priority regardless of how it reads. An strcpy on network input is, even in code that is otherwise pristine.

The sustainable approach is the boy scout rule: improve slightly whatever you touch, as part of the work you were doing. A rewrite proposed as its own project competes with features and usually loses — and when it wins, it discards years of accumulated bug fixes in favour of code that has never met a user.

Record what you decide not to do, with the reason. A TODO with no rationale is noise; one that says "unbounded copy, but input is validated by the caller at parse.c:88; fix if that ever changes" is documentation.

6Worked example: modernizing a 1990s function

Here is the code as found. It works, ships, and has no tests.

/* legacy.c — as found */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char buf[256];
int count;

parse_record(line, out_name, out_value)
char *line;
char *out_name;
int *out_value;
{
    char *p, *q;
    int i;

    strcpy(buf, line);

    p = buf;
    while (*p == ' ' || *p == '\t') p++;

    q = p;
    while (*q != '=' && *q != 0) q++;
    if (*q == 0) return 0;

    *q = 0;
    i = strlen(p);
    while (i > 0 && (p[i-1] == ' ' || p[i-1] == '\t')) { p[i-1] = 0; i--; }

    strcpy(out_name, p);

    q++;
    while (*q == ' ' || *q == '\t') q++;
    *out_value = atoi(q);

    count++;
    return 1;
}

Six defects, each from an earlier week: a K&R definition with no prototype, implicit int return, two unbounded strcpy calls, a global buffer that makes it non-reentrant, atoi that cannot report failure, and a global counter nobody asked for.

Step 1 — find out what it does

/* characterize.c — generates the assertions */
#include <stdio.h>
#include <string.h>

int parse_record();                 /* no prototype exists yet */

int main(void)
{
    const char *inputs[] = {
        "timeout = 30", "  name = 42  ", "key=7", "noequals",
        "= 5", "empty =", "x = abc", "x = 99999999999", ""
    };

    for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; i++) {
        char name[256] = { 0 };
        int  value = -999;
        char copy[256];
        snprintf(copy, sizeof copy, "%s", inputs[i]);

        int rc = parse_record(copy, name, &value);
        printf("CHECK_PARSE(\"%s\", %d, \"%s\", %d);\n",
               inputs[i], rc, name, value);
    }
    return 0;
}
gcc -w -o characterize characterize.c legacy.c && ./characterize > pinned.h
CHECK_PARSE("timeout = 30", 1, "timeout", 30);
CHECK_PARSE("  name = 42  ", 1, "name", 42);
CHECK_PARSE("key=7", 1, "key", 7);
CHECK_PARSE("noequals", 0, "", -999);
CHECK_PARSE("= 5", 1, "", 5);            /* an empty name is accepted */
CHECK_PARSE("empty =", 1, "empty", 0);   /* missing value becomes 0 */
CHECK_PARSE("x = abc", 1, "x", 0);       /* atoi cannot say "not a number" */
CHECK_PARSE("x = 99999999999", 1, "x", …);
CHECK_PARSE("", 0, "", -999);

Three of those are wrong. Pin them anyway — the goal right now is a refactoring, and mixing in behavior changes destroys the ability to attribute a failure.

Step 2 — a prototype and a test harness

/* legacy.h */
#ifndef LEGACY_H
#define LEGACY_H
int parse_record(char *line, char *out_name, int *out_value);
#endif
/* test_legacy.c */
#include "legacy.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

static int checks = 0, failures = 0;

#define CHECK_PARSE(input, want_rc, want_name, want_value) do {        \
    char line[256], name[256] = { 0 };                                 \
    int value = -999;                                                  \
    snprintf(line, sizeof line, "%s", (input));                        \
    int rc = parse_record(line, name, &value);                         \
    checks++;                                                          \
    if (rc != (want_rc) || strcmp(name, (want_name)) != 0              \
        || value != (want_value)) {                                    \
        failures++;                                                    \
        printf("  FAIL \"%s\": rc %d name \"%s\" value %d\n",          \
               (input), rc, name, value);                              \
    }                                                                  \
} while (0)

int main(void)
{
    #include "pinned.h"
    printf("%d checks, %d failed\n", checks, failures);
    return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}

Step 3 — modernize, one change at a time

Run the tests after every single step.

/* 1. a prototype-style definition — behavior identical */
int parse_record(char *line, char *out_name, int *out_value)

/* 2. mark file-local helpers static, remove the unused global counter */

/* 3. bound the copies: the first real defect fixed */
snprintf(buf, sizeof buf, "%s", line);

/* 4. remove the global buffer — the function becomes reentrant.
      This changes the SIGNATURE, so callers must be updated. */
int parse_record(const char *line, char *out_name, size_t name_size,
                 int *out_value);

/* 5. replace atoi with strtol, but KEEP the pinned behavior for now:
      report 0 on failure exactly as before, and record the intent */
long v;
*out_value = parse_long(value_start, &v) ? (int)v : 0;   /* TODO: reject */

Step 5 is the discipline in miniature. strtol can now detect a malformed value, but the function still returns 0 for one — because that is what the tests pin, and changing it is a behavior change belonging to a separate commit with its own review.

The result

/* parser.c — after the refactoring, before the behavior fixes */
#include "parser.h"
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <limits.h>

#define LINE_MAX_LEN 256

static const char *skip_space(const char *s)
{
    while (*s == ' ' || *s == '\t') s++;
    return s;
}

static void trim_trailing(char *s)
{
    size_t n = strlen(s);
    while (n > 0 && (s[n-1] == ' ' || s[n-1] == '\t')) s[--n] = '\0';
}

static bool parse_long(const char *text, long *out)
{
    if (text == NULL || *text == '\0') return false;
    errno = 0;
    char *end;
    long v = strtol(text, &end, 10);
    if (end == text || errno == ERANGE) return false;
    *out = v;
    return true;
}

/* No globals: safe to call from two threads. */
bool parse_record(const char *line, char *out_name, size_t name_size,
                  int *out_value)
{
    if (line == NULL || out_name == NULL || out_value == NULL) {
        return false;
    }

    char work[LINE_MAX_LEN];
    if ((size_t)snprintf(work, sizeof work, "%s", line) >= sizeof work) {
        return false;                       /* too long: refuse, not truncate */
    }

    char *equals = strchr(work, '=');
    if (equals == NULL) {
        return false;
    }
    *equals = '\0';

    const char *name = skip_space(work);
    trim_trailing((char *)name);

    if ((size_t)snprintf(out_name, name_size, "%s", name) >= name_size) {
        return false;                       /* caller's buffer too small */
    }

    const char *value_text = skip_space(equals + 1);

    long v;
    /* TODO(#412): pinned legacy behavior — a malformed value yields 0.
       Changing this to a rejection is a separate, reviewed change. */
    *out_value = (parse_long(value_text, &v) && v >= INT_MIN && v <= INT_MAX)
               ? (int)v : 0;
    return true;
}
gcc -std=c17 -Wall -Wextra -Wstrict-prototypes -Wold-style-definition \
    -g -fsanitize=address,undefined -o test_parser test_parser.c parser.c
./test_parser
9 checks, 0 failed

Every pinned behavior preserved, and the function is now prototyped, bounded, reentrant, const-correct, and clean under sanitizers.

Step 4 — now fix the behavior, separately

git commit -m "Modernize parse_record without changing behavior"
git commit -m "Reject malformed values instead of returning 0

parse_record() returned 0 for 'x = abc', indistinguishable from
'x = 0'. Callers cannot detect the difference. Now returns false.

Updates the two pinned tests that encoded the old behavior, and the
three call sites that relied on it. Closes #412."

Two commits. The first is provably behavior-preserving and can be reviewed quickly; the second changes exactly one thing, names the affected callers, and can be reverted alone if it turns out something depended on the old behavior.

Prove a step changed nothing

gcc -O2 -S before.c -o before.s
gcc -O2 -S after.c  -o after.s
diff before.s after.s && echo "identical machine code"

For step 1 — converting the K&R definition to a prototype — the assembly is typically identical, which is a stronger statement than the tests can make.

Then migrate the standard

for std in c89 c99 c11 c17 c23; do
    printf "%-5s " $std
    gcc -std=$std -Wall -Wextra -o /tmp/t test_parser.c parser.c 2>&1 \
        | head -1 || true
    /tmp/t >/dev/null && echo "pass" || echo "FAIL"
done

The original fails at C99 — implicit int and the K&R definition are gone — and at C23 even more thoroughly. The refactored version passes at every level, which is the practical measure of having modernized it.

7Common mistakes

MistakeWhat happensFix
Refactoring without testsYou cannot tell a refactor from a rewriteCharacterize first.
Fixing bugs while refactoringA failing test names no causePin, refactor, then fix.
A rewrite instead of incremental changeYears of fixes discardedRefactor in place.
Deleting code you do not understandReintroduces an old defectgit log -S — week 43.
Migrating several standards at onceFailures cannot be attributedOne step, full suite between.
Not running sanitizers before migratingA new compiler surfaces old UBUBSan on the old compiler first.
Modernizing code nobody touchesRisk with no returnPrioritize by change frequency and danger.
A TODO with no reasonNoise nobody can act onState the condition and the constraint.

8Check yourself

What is a characterization test, and why pin behavior you believe is wrong?

It records what the code currently does rather than what it should do, so a refactoring can be verified even without a specification. Wrong behavior is pinned deliberately: if a test fails during the refactoring you know the refactoring caused it. Fixing the behavior is a separate change with its own commit and its own review.

How can you prove a refactoring step changed nothing at all?

Compile before and after at the same optimization level with -S and diff the assembly. Identical output means the observable behavior cannot have changed — a stronger guarantee than any test suite. It works for renames, added const, and prototype conversion; it will not survive extracting a function.

Why migrate one standard at a time?

Because each revision removes or changes different things — implicit int at C99, gets at C11, K&R definitions at C23 — and jumping several at once produces a pile of failures with no way to attribute them. One step with the full test suite between makes each failure traceable to a specific change.

Why is a rewrite usually worse than incremental refactoring?

Because the existing code encodes years of bug fixes, edge cases, and workarounds that nobody remembers and nothing documents. A rewrite discards all of it and starts with code that has never met a user. Refactoring in place keeps that accumulated knowledge and lets you verify each step against the behavior it must preserve.

Which technical debt is worth paying down?

Debt in code that is changed often, or that is dangerous — unbounded copies, unvalidated input, memory-safety hazards — or that is actively blocking work you need to do. A difficult function nobody has edited in years, with no known defects, is not a priority no matter how it reads: the fix carries risk and returns nothing.

9The end of the course

Fifty-six weeks ago the first program printed a line. Since then: the machine's model of memory, the language in full through C23, the standard library, the tooling that makes C tractable, data structures, the operating system interface, networking, concurrency, the practices of working with other people, and bare-metal development on a processor with no operating system at all.

What distinguishes a professional C programmer is not knowledge of syntax — that was weeks 1 to 14. It is the set of habits the rest of the course was actually teaching:

  • Compile with warnings on, and fix every one.
  • Run under sanitizers by default, not when something breaks.
  • Check every return value that can fail.
  • Say who owns every allocation, in the interface.
  • Bound every copy, and validate every input at the boundary.
  • Test the empty case, the boundary case, and the error path.
  • Know the difference between what works and what is guaranteed.
  • Measure before optimizing; read the generated code when it matters.
  • Leave the next reader — usually yourself — something they can change safely.

C will not enforce any of these. That is the whole of what makes it difficult, and the whole of what makes the discipline worth having.

Where to go next: read the codebases from week 42 in earnest, contribute a small fix to one of them using weeks 43 and 44, and pick a domain — systems, embedded, security, or numerical — and go deeper than this course could. The language has been stable for fifty years and will outlast most of what is written about it. That is a reasonable thing to have learned properly.