Chapter 7: Structuring Real Firmware (The 'C with Classes' Pattern)

Updated on 2026-08-30ai-generated

C has no classes, no inheritance, no polymorphism, and no interfaces. Yet professional embedded codebases are often highly modular, with clean abstractions and reusable components. How? Embedded C developers have developed patterns that emulate object-oriented design using structs, function pointers, and disciplined conventions. This chapter teaches you these patterns—not as academic exercises, but as practical tools for building maintainable firmware.

Why OOP Patterns in C?

You might wonder: why not just use C++? Many embedded projects do. But C remains dominant for several reasons:

  • Legacy codebases: Decades of firmware written in C must be maintained and extended
  • Toolchain limitations: Some MCU toolchains have poor C++ support
  • Certification: Safety standards (MISRA-C, ISO 26262) have mature C guidelines but immature C++ ones
  • Code size: C++ features (exceptions, RTTI, templates) can increase binary size
  • Team familiarity: Embedded engineers often have deep C expertise and limited C++ experience

The patterns in this chapter give you the benefits of OOP—encapsulation, polymorphism, modularity—while staying in C.

The Fundamental Pattern: Structs with Function Pointers

The simplest OOP-like pattern is a struct that contains both data and function pointers:

// led.h
typedef struct {
    GPIO_TypeDef *port;
    uint16_t pin;
    uint32_t on_time_ms;
    uint32_t off_time_ms;
    uint8_t state;
    
    // "Methods"
    void (*init)(struct led *self);
    void (*on)(struct led *self);
    void (*off)(struct led *self);
    void (*toggle)(struct led *self);
    void (*update)(struct led *self);  // For blinking pattern
} led_t;

void led_init(led_t *led, GPIO_TypeDef *port, uint16_t pin);
// led.c
static void led_on_impl(led_t *self) {
    self->port->BSRR = self->pin;
    self->state = 1;
}

static void led_off_impl(led_t *self) {
    self->port->BSRR = (self->pin << 16);
    self->state = 0;
}

static void led_toggle_impl(led_t *self) {
    if (self->state) {
        led_off_impl(self);
    } else {
        led_on_impl(self);
    }
}

static void led_update_impl(led_t *self) {
    uint32_t now = get_system_ticks();
    if (self->state && (now - self->last_change >= self->on_time_ms)) {
        led_off_impl(self);
        self->last_change = now;
    } else if (!self->state && (now - self->last_change >= self->off_time_ms)) {
        led_on_impl(self);
        self->last_change = now;
    }
}

void led_init(led_t *led, GPIO_TypeDef *port, uint16_t pin) {
    led->port = port;
    led->pin = pin;
    led->on_time_ms = 500;
    led->off_time_ms = 500;
    led->state = 0;
    led->last_change = 0;
    
    // Assign method pointers
    led->init = NULL;  // Already initialized
    led->on = led_on_impl;
    led->off = led_off_impl;
    led->toggle = led_toggle_impl;
    led->update = led_update_impl;
    
    // Configure hardware
    port->MODER |= (1 << (pin * 2));
}

Usage:

led_t status_led;
led_init(&status_led, GPIOD, GPIO_PIN_12);

status_led.on(&status_led);
delay_ms(1000);
status_led.off(&status_led);

This is clean but verbose. The explicit self parameter is required because C has no implicit this.

The VTable Pattern: True Polymorphism

The function-pointer-in-struct pattern works, but every instance carries its own method pointers. The VTable pattern separates methods from data:

// peripheral.h
typedef struct peripheral peripheral_t;

typedef struct {
    void (*init)(peripheral_t *self);
    void (*deinit)(peripheral_t *self);
    uint32_t (*read)(peripheral_t *self);
    void (*write)(peripheral_t *self, uint32_t value);
} peripheral_vtable_t;

struct peripheral {
    const peripheral_vtable_t *vtable;
    void *data;  // Instance-specific data
};

// Inline "methods"
static inline void peripheral_init(peripheral_t *p) {
    p->vtable->init(p);
}

static inline uint32_t peripheral_read(peripheral_t *p) {
    return p->vtable->read(p);
}

Now implement a specific peripheral:

// adc_peripheral.c
typedef struct {
    ADC_TypeDef *regs;
    uint8_t channel;
    uint32_t last_value;
} adc_data_t;

static void adc_init(peripheral_t *self) {
    adc_data_t *adc = (adc_data_t *)self->data;
    // Configure ADC registers...
    adc->regs->CR1 |= (1 << 0);  // Enable ADC
}

static uint32_t adc_read(peripheral_t *self) {
    adc_data_t *adc = (adc_data_t *)self->data;
    // Start conversion, wait for completion
    adc->regs->CR2 |= (1 << 0);
    while (!(adc->regs->SR & (1 << 0))) {
        // Wait
    }
    adc->last_value = adc->regs->DR;
    return adc->last_value;
}

static const peripheral_vtable_t adc_vtable = {
    .init = adc_init,
    .deinit = NULL,
    .read = adc_read,
    .write = NULL,
};

void adc_create(peripheral_t *peripheral, ADC_TypeDef *regs, uint8_t channel) {
    static adc_data_t adc_instance;
    adc_instance.regs = regs;
    adc_instance.channel = channel;
    
    peripheral->vtable = &adc_vtable;
    peripheral->data = &adc_instance;
}

Now different peripheral types can be treated uniformly:

peripheral_t sensors[4];
adc_create(&sensors[0], ADC1, 0);
adc_create(&sensors[1], ADC1, 1);
spi_sensor_create(&sensors[2], SPI1, 0);
i2c_sensor_create(&sensors[3], I2C1, 0x48);

for (int i = 0; i < 4; i++) {
    peripheral_init(&sensors[i]);
}

uint32_t value = peripheral_read(&sensors[2]);  // SPI sensor read

This is true polymorphism: the same function call dispatches to different implementations based on the vtable.

Inheritance via Embedded Structs

C does not support inheritance, but you can achieve the same effect by embedding a base struct inside a derived struct:

// base: all peripherals have these
typedef struct {
    const char *name;
    uint8_t initialized;
    uint32_t error_count;
} peripheral_base_t;

// derived: UART-specific
typedef struct {
    peripheral_base_t base;  // Must be first
    USART_TypeDef *regs;
    uint32_t baud;
    ring_buffer_t rx_buffer;
    ring_buffer_t tx_buffer;
} uart_peripheral_t;

// derived: SPI-specific
typedef struct {
    peripheral_base_t base;  // Must be first
    SPI_TypeDef *regs;
    uint8_t mode;
    uint32_t speed_hz;
} spi_peripheral_t;

Because the base struct is first, a pointer to the derived struct can be cast to a pointer to the base struct:

void peripheral_log_error(peripheral_base_t *base) {
    base->error_count++;
    printf("Error in %s (count: %lu)\n", base->name, base->error_count);
}

uart_peripheral_t uart;
peripheral_log_error((peripheral_base_t *)&uart);  // Cast to base

The base struct members are accessible through the derived struct:

uart_peripheral_t uart;
uart.base.name = "USART2";
uart.base.initialized = 0;
peripheral_log_error(&uart.base);  // Pass base pointer

This pattern is widely used in vendor HALs. The STM32 HAL UART_HandleTypeDef contains a SPI_HandleTypeDef base struct with common members.

The Handle Pattern: Opaque Pointers Revisited

Chapter 4 introduced opaque pointers. The handle pattern extends this with explicit lifecycle management and polymorphism:

// sensor.h
typedef struct sensor sensor_t;

typedef struct {
    void (*init)(sensor_t *self);
    float (*read_value)(sensor_t *self);
    void (*calibrate)(sensor_t *self);
} sensor_ops_t;

sensor_t *sensor_create(const sensor_ops_t *ops, void *specific_data);
void sensor_destroy(sensor_t *sensor);
float sensor_read(sensor_t *sensor);

// sensor.c
struct sensor {
    const sensor_ops_t *ops;
    void *data;
    uint8_t initialized;
};

sensor_t *sensor_create(const sensor_ops_t *ops, void *specific_data) {
    sensor_t *sensor = pool_alloc(&sensor_pool);
    if (!sensor) {
        return NULL;
    }
    sensor->ops = ops;
    sensor->data = specific_data;
    sensor->initialized = 0;
    return sensor;
}

float sensor_read(sensor_t *sensor) {
    if (!sensor || !sensor->ops || !sensor->ops->read_value) {
        return 0.0f;
    }
    return sensor->ops->read_value(sensor);
}

The caller never sees the struct internals. They interact through a stable API, and the implementation can change freely.

Real-World Example: A Driver Framework

Let's combine these patterns into a complete example: a generic driver for character displays (LCDs). Different LCD controllers (HD44780, ST7735, SSD1306) share a common interface but have different implementations.

// display.h
typedef struct display display_t;

typedef struct {
    void (*init)(display_t *self);
    void (*clear)(display_t *self);
    void (*draw_text)(display_t *self, uint16_t x, uint16_t y, const char *text);
    void (*draw_pixel)(display_t *self, uint16_t x, uint16_t y, uint8_t color);
    void (*refresh)(display_t *self);
} display_ops_t;

struct display {
    const display_ops_t *ops;
    void *driver_data;
    uint16_t width;
    uint16_t height;
};

// Display "constructor"
display_t *display_create(const display_ops_t *ops, uint16_t width, uint16_t height);

// Inline "methods"
static inline void display_init(display_t *d) {
    d->ops->init(d);
}

static inline void display_clear(display_t *d) {
    d->ops->clear(d);
}

static inline void display_draw_text(display_t *d, uint16_t x, uint16_t y, const char *text) {
    d->ops->draw_text(d, x, y, text);
}

An SSD1306 OLED implementation:

// ssd1306.c
typedef struct {
    I2C_HandleTypeDef *i2c;
    uint8_t framebuffer[128 * 64 / 8];
    uint8_t address;
} ssd1306_data_t;

static void ssd1306_init(display_t *self) {
    ssd1306_data_t *data = (ssd1306_data_t *)self->driver_data;
    // Send initialization commands over I2C...
}

static void ssd1306_clear(display_t *self) {
    ssd1306_data_t *data = (ssd1306_data_t *)self->driver_data;
    memset(data->framebuffer, 0, sizeof(data->framebuffer));
}

static void ssd1306_refresh(display_t *self) {
    ssd1306_data_t *data = (ssd1306_data_t *)self->driver_data;
    // Send framebuffer over I2C...
}

static const display_ops_t ssd1306_ops = {
    .init = ssd1306_init,
    .clear = ssd1306_clear,
    .draw_text = ssd1306_draw_text,
    .draw_pixel = ssd1306_draw_pixel,
    .refresh = ssd1306_refresh,
};

display_t *ssd1306_create(I2C_HandleTypeDef *i2c, uint8_t address) {
    static ssd1306_data_t driver_data;
    driver_data.i2c = i2c;
    driver_data.address = address;
    
    static display_t display;
    display.ops = &ssd1306_ops;
    display.driver_data = &driver_data;
    display.width = 128;
    display.height = 64;
    
    return &display;
}

The application code is completely portable:

display_t *screen = ssd1306_create(&hi2c1, 0x3C);
display_init(screen);
display_clear(screen);
display_draw_text(screen, 0, 0, "Hello, World!");
display_refresh(screen);

Swapping to a different display only changes the ssd1306_create call to st7735_create. The rest of the application is unchanged.

When Not to Use OOP Patterns

These patterns add complexity. They are valuable for:

  • Multiple implementations of the same interface: Different sensors, displays, communication protocols
  • Testability: Mock implementations for unit testing
  • Large codebases: Where modularity and clear boundaries are essential

They are overkill for:

  • Simple one-off drivers: A single LED that will never have alternative implementations
  • Performance-critical paths: Function pointer calls cannot be inlined; there is minor overhead
  • Small projects: Where the indirection obscures rather than clarifies

Use these patterns judiciously. Not every struct needs a vtable.

Testing with Mock Objects

One major benefit of the vtable pattern is testability. You can create mock implementations that run on your PC:

// test_mock_display.c
static uint8_t mock_framebuffer[128 * 64 / 8];
static int refresh_count = 0;

static void mock_refresh(display_t *self) {
    refresh_count++;
}

static const display_ops_t mock_ops = {
    .init = NULL,
    .clear = mock_clear,
    .draw_text = mock_draw_text,
    .draw_pixel = mock_draw_pixel,
    .refresh = mock_refresh,
};

// Test function
void test_display_application(void) {
    display_t mock_display;
    mock_display.ops = &mock_ops;
    mock_display.driver_data = mock_framebuffer;
    
    // Run application code against mock
    application_draw_ui(&mock_display);
    
    assert(refresh_count == 1);
    assert(mock_framebuffer[0] != 0);  // Something was drawn
}

This runs on your PC without hardware, enabling unit testing and CI integration.

What to Actually Learn vs. Look Up

Look up (as needed):

  • Advanced C++ features (templates, multiple inheritance) if you choose C++
  • Design patterns from the Gang of Four book (many translate to C)
  • Specific framework architectures (Zephyr, RIOT-OS driver models)

Deeply understand:

  • The struct-with-function-pointers pattern as the foundation
  • The VTable pattern for true polymorphism with shared method tables
  • Embedded struct inheritance (base struct first in derived struct)
  • The handle pattern with opaque pointers for encapsulation
  • When to apply these patterns and when to keep things simple
  • How these patterns enable hardware-independent testing

Key Takeaways

  1. C can emulate OOP effectively. Function pointers, structs, and disciplined conventions provide encapsulation and polymorphism.
  2. The VTable pattern separates methods from data. Multiple instances share a single method table; instances differ in data.
  3. Embedded structs provide inheritance. Place the base struct first and cast pointers as needed.
  4. The handle pattern hides implementation details. Callers see only opaque pointers and stable APIs.
  5. These patterns enable portability and testability. Swap implementations easily; test on PC with mocks.
  6. Apply judiciously. Not every driver needs a vtable; simple code is often better.

Chapter 8 will cover the idiomatic C practices, preprocessor techniques, and subtle language behaviors that distinguish embedded C from general C programming.

Found an issue? Open an issue or submit a pull request on GitHub