Appendix D: Assertions and debug output

Kernel bugs are unusually hard to find, and this project makes your kernel much more complex, which invites new classes of bugs. Because your OS doesn’t have memory protection yet, a bug can quietly corrupt memory and the machine misbehaves thousands of instructions later in code that is entirely innocent. And there is concurrency, so the misbehaviour may not even show up in the same process as the bug.

The defence is to check your assumptions where you make them rather than where they blow up. We provide you with the code in kernel/debug.h, which gives you two tools for this purpose:

MacroWhat it does
CHECK(expr)If expr is false, panic() and print the failing expression as source text. Always compiled in.
DEBUG(stmt)Run stmt only if this build’s debug level is 1 or higher.
DEBUG_L(n, stmt)Run stmt only if this build’s debug level is at least n.

Assertions with CHECK()

CHECK(expr) is an assertion: it says “at this point in the program, expr must be true”, and it stops the machine immediately if it is not.

#include "debug.h"

void timer_interrupt(void) {
  CHECK(timer_callback != nullptr);  // panics: "CHECK failed: timer_callback != nullptr"
  CHECK(period > 0);
  ...
}

The trick that makes the message useful is the #EXPR in the macro definition: # is the preprocessor’s stringification operator, which turns the expression you wrote into a string literal. You get the source text of the condition back in the panic message without having to write it out yourself.

CHECK needs your panic. debug.h deliberately includes nothing at all, so that it can be included from anywhere without dragging other headers along. CHECK expands into a call to the panic() helper you wrote in Project 1, which means panic has to be declared wherever you use CHECK – include whichever of your own headers declares it, alongside debug.h.

What to assert (invariants)

A good assertion states an invariant that your code depends on but does not itself establish. In this project, some key invariants you may want to assert are:

WhereAssert that
Your process allocatorThe process table is not full; the stack top you computed is 16-byte aligned.
resume()The process you are about to run has a non-null saved context and is not already RUNNING.
pick_next()The current process is no longer RUNNING when the scheduler is asked for the next one.
After scheduler(), yield(), restore_context()Control never gets here as these functions never return, so a CHECK(0) or panic() after the call can catch bugs.
Your timer driverThe callback is non-null and the period is non-zero before you use them.
Your interrupt dispatcherThe interrupt source is one you actually enabled.

There are likely other invariants in your code, and liberally adding CHECKs to catch violations of them is a good idea.

Note the key idea behiny these assertions: each one fires at the moment an invariant breaks rather than at the point where the consequence becomes visible. That distance is exactly what makes kernel debugging painful, and assertions help you shorten it. See also “Make the impossible loud” in Appendix C.

Two things to know about CHECK

It is never compiled out. Unlike the C standard library’s assert(), which NDEBUG disables in release builds, CHECK is always active. That is deliberate: a kernel is better off stopping loudly than continuing into corruption. The cost is a compare and a branch, which is nothing next to a context switch. Write conditions that only read state, though: CHECK(x = 5) is wrong, as it performs an assignment and never fails.

It is a runtime check. If what you want to verify is knowable at compile time, use _Static_assert instead, which costs nothing at all and fails at compile time. The classic example in this project is keeping your context_t and your assembly in agreement:

_Static_assert(sizeof(context_t) == S_FRAME_SIZE, "context_t must be S_FRAME_SIZE");

Debug output with DEBUG()

DEBUG(stmt) runs stmt only when the kernel is built with debugging enabled:

DEBUG(printf("switching from pid %d to pid %d\r\n", old->pid, next->pid));
DEBUG_L(2, print_elf_header(ehdr));  // only at the higher level

The level is a build-time setting, and defaults to 0 (all debug output off):

$ make qemu                 # DEBUG_LEVEL=0: no debug output
$ make DEBUG_LVL=1 qemu     # DEBUG() and DEBUG_L(1, ...) run
$ make DEBUG_LVL=2 qemu     # ... and DEBUG_L(2, ...) as well

Use level 1 for output you would want on any run where something looks wrong—one line per context switch, say—and level 2 for much more detailed output: dumping a whole saved context, or every program header the ELF loader walks.

Background: Why is DEBUG() a macro, and why does it use an if rather than an #ifdef?

DEBUG wraps your statement in if (DEBUG_LEVEL > 0) { ... }, where DEBUG_LEVEL is a compile-time constant. At level 0 the condition is constantly false, so the compiler deletes the block entirely: it costs nothing in the built kernel.

But the compiler still parses and type-checks what is inside, which #ifdef DEBUG ... #endif would not. This matters more than it sounds. Debug code that isn’t typechecked often “rots” silently: you rename a struct field, and your debug code breaks without you noticing. Debug code behind an if cannot rot, because every build checks it.

Caveats specific to this project

  • Debug output changes timing. Printing inside your context-switch path perturbs exactly the thing you are trying to observe: a bug that depends on a timer interrupt arriving at a particular moment may vanish when you add a DEBUG print, and reappear when you remove it. Appendix C describes lower-impact alternatives, notably recording events into an array and dumping it afterwards.
  • Until Quest 4, Part E, printf is not atomic. A DEBUG print from a process that is then preempted mid-line will interleave with another process’s output. If your debug output looks scrambled, that is a symptom of the shared-resource problem you fix in that step, not of a broken printf.
  • printf runs on the current process’s stack and, depending on your implementation, may use a kilobyte of it for a formatting buffer. A DEBUG print deep inside the switch path is spending the stack of whichever process happens to be running.