ARM64 OS Handbook
🔍

Chapter 43: Testing

What You Will Learn in This Chapter
  • Why kernel testing differs from user-space testing
  • How to write unit tests for kernel modules on the host system
  • How to run integration tests inside QEMU
  • How to implement a kernel test harness
  • How to test exception handlers and fault paths
  • How to automate testing in the build pipeline

43.1 Why Kernel Testing Is Different

Testing kernel code presents unique challenges compared to user-space testing:

  • Hardware dependency: code that manipulates MMU registers or UART hardware cannot run on the host
  • No process isolation: a failed test in the kernel can crash the entire test run
  • Boot required: most kernel code can only run after the system boots
  • Timing sensitivity: race conditions and interrupt timing are hard to reproduce
  • Cross-compilation: tests must be compiled for ARM64 but developed on x86

We address these with a three-tier testing strategy: unit tests (host), integration tests (QEMU), and manual verification (real hardware).

43.2 Unit Tests on the Host

Kernel modules that do not depend on hardware can be compiled and tested on the host system. The page allocator, linked list library, string functions, and data structures are all portable:

// test_bitmap.c: unit test for bit operations
// Compiled with: gcc -o test_bitmap test_bitmap.c -I../kernel/include
// Run on host: ./test_bitmap

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

void test_bitmap_alloc(void) {
    bitmap_t bm;
    bitmap_init(&bm, 1024);

    int idx = bitmap_alloc(&bm);
    assert(idx >= 0);
    assert(bitmap_test(&bm, idx));

    bitmap_free(&bm, idx);
    assert(!bitmap_test(&bm, idx));
    printf("PASS: bitmap_alloc\n");
}

void test_bitmap_alloc_contiguous(void) {
    bitmap_t bm;
    bitmap_init(&bm, 1024);

    int idx = bitmap_alloc_range(&bm, 64);
    assert(idx >= 0);
    for (int i = 0; i < 64; i++) {
        assert(bitmap_test(&bm, idx + i));
    }
    printf("PASS: bitmap_alloc_range\n");
}

int main(void) {
    test_bitmap_alloc();
    test_bitmap_alloc_contiguous();
    printf("All bitmap tests passed.\n");
    return 0;
}

To make kernel headers compatible with host compilation, wrap hardware-dependent code in #ifdef __KERNEL__ guards:

/* kernel/include/bitmap.h */
#ifndef __BITMAP_H__
#define __BITMAP_H__

#include <stdint.h>
#include <stddef.h>

#ifdef __KERNEL__
#include "kernel.h"  /* Only available in kernel builds */
#else
#include <stdlib.h>  /* Host: use standard library */
#include <string.h>
#define kmalloc(s) malloc(s)
#define kfree(p) free(p)
#endif

/* Portable bitmap implementation (no hardware dependencies) */
typedef struct {
    uint32_t *bits;
    size_t    nbits;
} bitmap_t;

void bitmap_init(bitmap_t *bm, size_t nbits);
int  bitmap_alloc(bitmap_t *bm);
int  bitmap_alloc_range(bitmap_t *bm, size_t count);
void bitmap_free(bitmap_t *bm, int idx);
int  bitmap_test(bitmap_t *bm, int idx);

#endif

43.3 Integration Tests in QEMU

Hardware-dependent code must be tested inside QEMU. We provide a kernel test mode that runs test suites at boot time and reports results via UART:

/* test_runner.c: kernel test framework */
typedef void (*test_fn)(void);

typedef struct {
    const char *name;
    test_fn     fn;
} test_case_t;

#define TEST(name) \
    static void test_##name(void); \
    __attribute__((constructor)) \
    static void register_##name(void) { \
        test_register(#name, test_##name); \
    } \
    static void test_##name(void)

static test_case_t *tests[1024];
static int num_tests = 0;
static int tests_passed = 0;
static int tests_failed = 0;

void test_register(const char *name, test_fn fn) {
    tests[num_tests].name = name;
    tests[num_tests].fn = fn;
    num_tests++;
}

void test_run_all(void) {
    kprintf("Running %d kernel tests...\n", num_tests);
    for (int i = 0; i < num_tests; i++) {
        kprintf("[%d/%d] %s ... ", i + 1, num_tests, tests[i].name);
        tests[i].fn();
        kprintf("OK\n");
        tests_passed++;
    }
    kprintf("Results: %d passed, %d failed\n",
            tests_passed, tests_failed);
}

Tests are registered automatically using constructor attributes. Each test exercises a specific kernel subsystem:

/* Test: physical page allocator */
TEST(phys_alloc) {
    void *p1 = alloc_page();
    void *p2 = alloc_page();
    ASSERT(p1 != NULL);
    ASSERT(p2 != NULL);
    ASSERT(p1 != p2);
    free_page(p1);
    free_page(p2);
}

/* Test: virtual memory mapping */
TEST(vm_map) {
    void *virt = map_pages(0x1000, 4, VM_READ | VM_WRITE);
    ASSERT(virt != NULL);
    /* Write to each page to verify mapping works */
    for (int i = 0; i < 4096 * 4; i++) {
        ((volatile char *)virt)[i] = i & 0xFF;
    }
    unmap_pages(virt, 4);
}

/* Test: context switch */
TEST(context_switch) {
    int counter = 0;
    /* Yield to scheduler; verify we come back */
    schedule();
    ASSERT(counter == 0);  /* Should still be 0 */
    counter++;
    ASSERT(counter == 1);
}

43.4 Testing Exception Paths

Exception handlers are hard to test because exceptions are triggered by hardware. We can deliberately trigger exceptions to verify the handler path:

/* Test: data abort handling */
TEST(data_abort) {
    /* Deliberately trigger a NULL dereference */
    volatile int *null_ptr = (int *)0x0;

    /* This should cause a data abort, caught by sync handler.
     * The handler should dump registers and return.
     * If we reach here, the exception was handled. */
    (void)*null_ptr;

    kprintf("data abort handled successfully\n");
}

/* Test: undefined instruction */
TEST(undefined_instr) {
    /* Execute an undefined instruction (0xDEAD) */
    asm volatile(".inst 0xDEAD");
    kprintf("undefined instruction handled\n");
}

/* Test: misaligned SP */
TEST(sp_align) {
    /* Set SP to misaligned address and trigger an SP-alignment check */
    asm volatile("mov sp, #0x3\n\t"
                 "stp x0, x1, [sp, #-16]!\n\t");
    kprintf("SP alignment handled\n");
}

These tests verify that exception handlers do not crash or hang the system when unexpected events occur.

43.5 Automated Testing with Make

We automate testing in the Makefile with targets for unit tests, integration tests, and full test suites:

# Makefile test targets

HOST_TESTS := test_bitmap test_list test_string test_heap

# Build and run host-based unit tests
.PHONY: test-host
test-host: $(HOST_TESTS)
    @for test in $(HOST_TESTS); do \
        echo "Running $$test..."; \
        ./$$test || exit 1; \
    done

# Build unit test executables
test_%: test_%.c
    gcc -o $@ $< -I../kernel/include -I../kernel/test -g -O0

# Run kernel integration tests in QEMU
.PHONY: test-qemu
test-qemu: kernel-test.elf
    qemu-system-aarch64 -M virt -cpu cortex-a72 -nographic \
        -kernel kernel-test.elf \
        -serial stdio \
        -append "test=all" 2>&1 | tee test-results.log
    @grep -q "Results:.*0 failed" test-results.log && echo "ALL TESTS PASSED" || echo "TESTS FAILED"

# Build kernel with test mode enabled
kernel-test.elf: CFLAGS += -DTEST_MODE -g -O0
kernel-test.elf: $(KERNEL_OBJS) $(TEST_OBJS)
    $(LD) -T kernel.ld $^ -o $@

# Run everything
.PHONY: test
test: test-host test-qemu
    echo "All tests completed."

43.6 Our Testing Infrastructure

Our kernel includes the following testing support:

  • Host unit tests: portable kernel modules (bitmap, list, ring buffer) compiled with gcc and run directly on the development machine
  • Kernel test framework: test_runner.c with automatic registration, pass/fail tracking, and UART reporting
  • QEMU integration tests: a test kernel image that runs all tests at boot and reports results
  • Make targets: make test-host, make test-qemu, make test
  • Coverage: each kernel subsystem should have tests for normal operation, edge cases, and fault paths

43.7 Exercises

Exercise 1: Write a Unit Test

Choose a portable kernel module (e.g., the circular buffer from the UART driver). Write a host-compilable test that covers allocation, write, read, overflow, and empty conditions.

Exercise 2: Add a QEMU Test

Write a kernel integration test for the scheduler: create two threads, verify both run and complete within a timeout. Register it with the test framework and run it in QEMU.

Exercise 3: Fuzz the Syscall Handler

Write a test that calls each syscall with invalid arguments (NULL pointers, negative sizes, invalid file descriptors). Verify the kernel does not crash.

43.8 Summary

Kernel testing is challenging but essential. A three-tier strategy addresses the difficulty: host-based unit tests for portable code, QEMU integration tests for hardware-dependent code, and automated Makefile targets for continuous testing. Portable kernel headers with #ifdef __KERNEL__ guards allow running tests on the development machine. The kernel test framework runs inside QEMU and reports results over UART. Together, these tools make it possible to verify kernel correctness systematically.