Skip to content

Latest commit

 

History

History
 
 

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

MicroZig Driver Framework

A collection of device drivers and driver abstractions for embedded systems using MicroZig.

Table of Contents

Introduction

The MicroZig driver framework provides a set of base interfaces that enable portable, reusable drivers for embedded peripherals. These interfaces abstract common communication patterns (SPI, I2C, UART, GPIO, etc.) so that drivers can work across different microcontroller families.

Two Usage Modes

The framework (typically) supports two modes of operation:

  1. Vtable Mode (Runtime Polymorphism): Default mode using interface types with virtual function tables. Provides maximum flexibility and code reuse at the cost of one pointer dereference per method call.
  2. Zero-Cost Mode (Compile-Time Specialization): Accepts concrete struct types instead of interfaces, eliminating all vtable overhead through compile-time duck typing. The compiler verifies that the concrete type has the required methods at compile time.

When to Use Each Mode

Use Vtable Mode when:

  • You need runtime polymorphism (e.g., swapping devices at runtime)
  • Code size is more important than performance
  • The overhead of a pointer dereference is negligible for your use case
  • You want maximum compatibility with different HAL implementations
  • Your HAL peripheral doesn't implement all the methods in the interface
    • For example, some SPI HALs don't hold a handle to the CS pin, since they are stateless enums, so connect and disconnect are not implemented for them

Use Zero-Cost Mode when:

  • Every cycle counts (e.g., WS2812 bit-banging, high-speed SPI)
  • You know the concrete types at compile time
  • You want to enable aggressive compiler optimizations (inlining, constant propagation)
  • You're working on resource-constrained systems

Available Drivers

Drivers with a checkmark are already implemented, drivers without are missing:

  • Input

    • Keyboard Matrix
    • Rotary Encoder
    • Debounced Button
    • Touch
  • Display

  • LED

    • WS2812
  • Wireless

  • Stepper Motors

    • A4988
    • DRV8825 (Implemented but untested)
    • ULN2003

Base Interfaces

All base interfaces are located in drivers/base/ and are re-exported through microzig.drivers.base.*.

Datagram_Device

Purpose: Abstract packet-oriented communication devices where data is transferred in fixed-size packets with known boundaries.

Common use cases: SPI, Ethernet, datagram sockets

Structure:

pub const Datagram_Device = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        connect_fn: ?*const fn (*anyopaque) ConnectError!void,
        disconnect_fn: ?*const fn (*anyopaque) void,
        writev_fn: ?*const fn (*anyopaque, datagrams: []const []const u8) WriteError!void,
        readv_fn: ?*const fn (*anyopaque, datagrams: []const []u8) ReadError!usize,
        writev_then_readv_fn: ?*const fn (
            *anyopaque,
            write_chunks: []const []const u8,
            read_chunks: []const []u8,
        ) (WriteError || ReadError)!void = null,
    };
};

Error Types:

  • ConnectError = {IoError, Timeout, DeviceBusy}
  • WriteError = {IoError, Timeout, Unsupported, NotConnected}
  • ReadError = {IoError, Timeout, Unsupported, NotConnected, BufferOverrun}

Key Methods:

  • connect() - Establish connection (e.g., assert chip-select for SPI)
  • disconnect() - Release device (e.g., deassert chip-select)
  • write(data) / writev(chunks) - Write single or multiple datagrams
  • read(buffer) / readv(buffers) - Read datagrams, returns bytes read
  • write_then_read(src, dst) - Atomic write-then-read in single transaction
  • writev_then_readv(write_chunks, read_chunks) - Vectored write-then-read

Usage Example:

const mdf = microzig.drivers;

// Using vtable interface
var spi_dd = spi_dev.datagram_device();
try spi_dd.connect();
defer spi_dd.disconnect();

const cmd: []const u8 = &.{0x03, 0x00, 0x00, 0x00}; // Read command
var data: [256]u8 = undefined;
try spi_dd.write_then_read(cmd, &data);

Stream_Device

Purpose: Abstract stream-oriented communication devices with continuous data flow and no packet boundaries.

Common use cases: UART, character devices, streaming protocols

Structure:

pub const Stream_Device = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        connect_fn: ?*const fn (*anyopaque) ConnectError!void,
        disconnect_fn: ?*const fn (*anyopaque) void,
        writev_fn: ?*const fn (*anyopaque, datagram: []const []const u8) WriteError!usize,
        readv_fn: ?*const fn (*anyopaque, datagram: []const []u8) ReadError!usize,
    };
};

Error Types:

  • ConnectError = {IoError, Timeout, DeviceBusy}
  • WriteError = {IoError, Timeout, Unsupported, NotConnected}
  • ReadError = {IoError, Timeout, Unsupported, NotConnected}

Key Methods:

  • connect() / disconnect()
  • write(data) / writev(chunks) - Returns actual bytes written (may be partial)
  • read(buffer) / readv(buffers) - Returns actual bytes read (may be partial)
  • writer() - Returns std.io.Writer compatible wrapper
  • reader() - Returns std.io.Reader compatible wrapper

Usage Example:

var uart_sd = uart.stream_device();
try uart_sd.connect();

// Direct write
_ = try uart_sd.write("Hello, World!\r\n");

// Or use std.io.Writer interface
var writer = uart_sd.writer();
try writer.print("Temperature: {d}°C\r\n", .{temperature});

Digital_IO

Purpose: Abstract single digital pin control (GPIO).

Common use cases: LED control, button input, chip-select pins, reset lines

Key Types:

pub const State = enum(u1) {
    low = 0,
    high = 1,

    pub fn invert(state: State) State;
    pub fn value(state: State) u1;
};

pub const Direction = enum { input, output };

Structure:

pub const Digital_IO = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        set_direction_fn: *const fn (*anyopaque, dir: Direction) SetDirError!void,
        set_bias_fn: *const fn (*anyopaque, bias: ?State) SetBiasError!void,
        write_fn: *const fn (*anyopaque, state: State) WriteError!void,
        read_fn: *const fn (*anyopaque) ReadError!State,
    };
};

Error Types:

  • SetDirError = {IoError, Timeout, Unsupported}
  • SetBiasError = {IoError, Timeout, Unsupported}
  • WriteError = {IoError, Timeout, Unsupported}
  • ReadError = {IoError, Timeout, Unsupported}

Key Methods:

  • set_direction(Direction) - Configure as input or output
  • set_bias(?State) - Configure pull-ups/pull-downs (null for no bias)
  • write(State) - Drive pin low or high
  • read() - Read current pin state

Usage Example:

var led_pin = pin.digital_io();

// Configure as output
try led_pin.set_direction(.output);

// Blink LED
while (true) {
    try led_pin.write(.high);
    hal.time.sleep_ms(500);
    try led_pin.write(.low);
    hal.time.sleep_ms(500);
}

I2C_Device

Purpose: Abstract I2C (Inter-Integrated Circuit) protocol interface with address-based communication.

Common use cases: Sensors, EEPROMs, display controllers, IO expanders

Key Types:

pub const Address = enum(u7) {
    _,
    pub const general_call: Address = @enumFromInt(0x00);

    // Validates address is not reserved (0x00-0x07, 0x78-0x7F)
    pub fn check_reserved(addr: Address) Error!void;
};

pub const Error = error{
    DeviceNotPresent,
    NoAcknowledge,
    Timeout,
    TargetAddressReserved,
    NoData,
    BufferOverrun,
    UnknownAbort,
    IllegalAddress,
};

Structure:

pub const I2C_Device = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        writev_fn: ?*const fn (*anyopaque, Address, datagrams: []const []const u8) InterfaceError!void,
        readv_fn: ?*const fn (*anyopaque, Address, datagrams: []const []u8) InterfaceError!usize,
        writev_then_readv_fn: ?*const fn (
            *anyopaque,
            Address,
            write_chunks: []const []const u8,
            read_chunks: []const []u8,
        ) InterfaceError!void = null,
    };
};

Key Methods:

  • write(address, data) / writev(address, chunks) - Write to I2C device
  • read(address, buffer) / readv(address, buffers) - Read from I2C device
  • write_then_read(address, src, dst) - Atomic write-then-read with repeated START
  • writev_then_readv(address, write_chunks, read_chunks) - Vectored variant

Usage Example:

const SENSOR_ADDR: I2C_Device.Address = @enumFromInt(0x48);
const TEMP_REG: u8 = 0x00;

var i2c = i2c_device.i2c_device();

// Read temperature register (register address + 2 bytes data)
var temp_data: [2]u8 = undefined;
try i2c.write_then_read(SENSOR_ADDR, &.{TEMP_REG}, &temp_data);

const temp_raw = (@as(u16, temp_data[0]) << 8) | temp_data[1];
const temp_celsius = @as(f32, @intCast(temp_raw)) * 0.0625;

Clock_Device

Purpose: Provide time tracking and sleep functionality for drivers that need timing.

Common use cases: Timeouts, delays, periodic events, deadline checking

Key Types (from microzig.drivers.time):

pub const Absolute = enum(u64) {
    _,
    pub fn from_us(us: u64) Absolute;
    pub fn to_us(abs: Absolute) u64;
    pub fn is_reached_by(deadline: Absolute, point: Absolute) bool;
    pub fn diff(future: Absolute, past: Absolute) Duration;
    pub fn add_duration(abs: Absolute, dur: Duration) Absolute;
};

pub const Duration = enum(u64) {
    _,
    pub fn from_us(us: u64) Duration;
    pub fn from_ms(ms: u64) Duration;
    pub fn to_us(duration: Duration) u64;
    pub fn less_than(self: Duration, other: Duration) bool;
};

pub const Deadline = struct {
    pub const no_deadline: Deadline = .init_absolute(null);

    pub fn init_absolute(abs: ?Absolute) Deadline;
    pub fn init_relative(since: Absolute, dur: ?Duration) Deadline;
    pub fn is_reached_by(deadline: Deadline, now: Absolute) bool;
    pub fn check(deadline: Deadline, now: Absolute) error{Timeout}!void;
};

Structure:

pub const Clock_Device = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        get_time_since_boot: *const fn (*anyopaque) mdf.time.Absolute,
        sleep: ?*const fn (*anyopaque, u64) void = null,
    };
};

Key Methods:

  • get_time_since_boot() - Returns microseconds since boot as Absolute time
  • sleep_us(time_us) - Sleep for N microseconds (polls if no custom sleep)
  • sleep_ms(time_ms) - Sleep for N milliseconds
  • make_timeout(duration) - Create deadline from current time + duration
  • Timeout.is_reached() - Check if time point has passed

Usage Example:

var clock = hal.drivers.clock_device();

// Create timeout
const timeout = clock.make_timeout(mdf.time.Duration.from_ms(100));

// Poll with timeout
while (!is_ready()) {
    if (timeout.is_reached()) {
        return error.Timeout;
    }
}

// Or just sleep
clock.sleep_ms(10);

Block_Memory

Purpose: Abstract flash/block memory operations with sector-based erase and write.

Common use cases: Flash memory, EEPROM, SD cards, on-chip flash

Error Types:

pub const BaseError = error{ Unsupported, InvalidSector };
pub const WriteError = BaseError || error{ SectorOverrun, WriteDisabled };
pub const ReadError = BaseError || error{ReadDisabled};

Structure:

pub const Block_Memory = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    pub const VTable = struct {
        enable_write_fn: ?*const fn (*anyopaque) BaseError!void,
        disable_write_fn: ?*const fn (*anyopaque) BaseError!void,
        erase_fn: ?*const fn (*anyopaque, sector: u32) WriteError!void,
        write_fn: ?*const fn (*anyopaque, sector: u32, data: []u8) WriteError!void,
        read_fn: ?*const fn (*anyopaque, offset: u32, data: []u8) ReadError!usize,
        sector_size_fn: ?*const fn (*anyopaque, sector: u32) BaseError!u32,
    };
};

Key Methods:

  • enable_write() / disable_write() - Control write protection
  • erase_sector(sector) - Erase a sector (required before writing)
  • write(sector, data) - Write to sector (automatically erases first, checks bounds)
  • read(offset, buffer) - Read from memory, returns bytes read
  • sector_size(sector) - Get sector capacity in bytes

Usage Example:

var flash = dev.block_memory();

// Write to sector 0
const data = "Configuration data...";
try flash.enable_write();
defer flash.disable_write() catch {};

try flash.write(0, data);

// Read back
var buffer: [256]u8 = undefined;
const bytes_read = try flash.read(0, &buffer);

Writing a Simple Driver

Let's create a simple LED driver that demonstrates the basic pattern. This driver will control an LED using a Digital_IO interface with optional compile-time specialization.

Driver Structure

const std = @import("std");
const mdf = @import("microzig").drivers;

/// Configuration options for LED driver
pub const LED_Driver_Options = struct {
    /// Digital I/O interface type (defaults to vtable interface)
    Digital_IO: type = mdf.base.Digital_IO,
    /// Whether LED is active-high (true) or active-low (false)
    active_high: bool = true,
};

/// Create an LED driver instance
pub fn LED_Driver(comptime options: LED_Driver_Options) type {
    return struct {
        const Self = @This();

        /// The pin controlling the LED
        pin: options.Digital_IO,

        /// Initialize the LED driver
        pub fn init(pin: options.Digital_IO) !Self {
            var self = Self{ .pin = pin };

            // Configure pin as output
            try self.pin.set_direction(.output);

            // Start with LED off
            try self.off();

            return self;
        }

        /// Turn LED on
        pub fn on(self: Self) !void {
            const state = if (options.active_high) .high else .low;
            try self.pin.write(state);
        }

        /// Turn LED off
        pub fn off(self: Self) !void {
            const state = if (options.active_high) .low else .high;
            try self.pin.write(state);
        }

        /// Toggle LED state
        pub fn toggle(self: Self) !void {
            const current = try self.pin.read();
            try self.pin.write(current.invert());
        }
    };
}

Usage - Vtable Mode

This mode uses the default vtable interface, allowing runtime flexibility:

const hal = microzig.hal;

// Get GPIO pin and wrap in Digital_IO interface
var gpio_pin = hal.gpio.Pin.init(0, 13); // PA13
var digital_io = gpio_pin.digital_io();  // Returns vtable interface

// Create LED driver with default options (uses vtable)
const LED = LED_Driver(.{});
var led = try LED.init(digital_io);

// Use the LED
try led.on();
hal.time.sleep_ms(500);
try led.off();

Usage - Zero-Cost Mode

This mode uses concrete types, eliminating vtable overhead:

const hal = microzig.hal;

// Get GPIO pin (concrete type, no vtable)
var gpio_pin = hal.gpio.Pin.init(0, 13); // PA13

// Create LED driver specialized for concrete Pin type
const LED = LED_Driver(.{
    .Digital_IO = hal.drivers.GPIO_Device, // Concrete type!
    .active_high = true,
});
var led = try LED.init(gpio_pin);  // Pass concrete pin directly

// Use the LED (all calls are direct, no vtable indirection)
try led.on();
hal.time.sleep_ms(500);
try led.off();

The compiler will verify that hal.gpio.Pin has the required methods (set_direction, write, read) with compatible signatures. If the interface doesn't match, you'll get a compile error.


Zero-Cost Abstraction Pattern

This section explores the comptime type parameter pattern in depth, showing how MicroZig drivers achieve zero-cost abstraction.

The Problem

Traditional interface-based designs face a trade-off:

  1. Vtable Interfaces: Flexible and reusable, but incur runtime overhead (pointer dereference per call)
  2. Concrete Types: Zero overhead, but require code duplication for each platform

For high-performance drivers (e.g., WS2812 LED timing, high-speed SPI), the vtable overhead can be significant.

The Solution

Zig's comptime system enables a third approach: type parameters with defaults. The driver accepts a type parameter that defaults to the vtable interface but can be overridden with a concrete struct type.

pub const Driver_Options = struct {
    // Defaults to vtable interface
    Device_Interface: type = mdf.base.Datagram_Device,
    // Other configuration...
};

pub fn Driver(comptime options: Driver_Options) type {
    return struct {
        // Field uses the provided type (vtable or concrete)
        dev: options.Device_Interface,

        pub fn init(dev: options.Device_Interface) !@This() {
            return .{ .dev = dev };
        }

        pub fn operation(self: @This()) !void {
            // Call method - direct if concrete, vtable if interface
            try self.dev.connect();
            defer self.dev.disconnect();
            // ...
        }
    };
}

How it works:

  1. Default behavior: Driver(.{}) uses vtable interface (backward compatible)
  2. Zero-cost mode: Driver(.{ .Device_Interface = ConcreteType }) uses concrete type
  3. Compile-time verification: Compiler checks that concrete type has required methods
  4. No runtime penalty: Direct method calls when using concrete types

Helper Init Functions

A common pattern is to provide a helper init() function that infers the concrete type from the argument using @TypeOf() (for example, in the PCA9685 driver:

const std = @import("std");
const mdf = @import("microzig").drivers;

pub const Display_Options = struct {
    width: comptime_int,
    height: comptime_int,
    Datagram_Device: type = mdf.base.Datagram_Device,
};

/// Helper init function that infers type from argument
pub fn init(
    comptime width: comptime_int,
    comptime height: comptime_int,
    datagram_device: anytype,  // Accept any type
) !Display(.{
    .width = width,
    .height = height,
    .Datagram_Device = @TypeOf(datagram_device),  // Infer concrete type
}) {
    // Create specialized type
    const Type = Display(.{
        .width = width,
        .height = height,
        .Datagram_Device = @TypeOf(datagram_device),
    });

    // Forward to actual init
    return Type.init(datagram_device);
}

/// Main generic driver
pub fn Display(comptime options: Display_Options) type {
    return struct {
        const Self = @This();
        dd: options.Datagram_Device,

        pub fn init(dd: options.Datagram_Device) !Self {
            return .{ .dd = dd };
        }

        // ... driver implementation
    };
}

Usage with type inference:

const hal = microzig.hal;

// Create concrete SPI device
var spi_dev = hal.drivers.SPI_Datagram_Device(...).init(...);

// Type is inferred from spi_dev - no need to specify!
var display = try init(160, 68, spi_dev);
//                                ^^^^^^
//                        Type inferred as @TypeOf(spi_dev)

// Equivalent to manually specifying:
// var display = Display(.{
//     .width = 160,
//     .height = 68,
//     .Datagram_Device = @TypeOf(spi_dev),
// }).init(spi_dev);

This pattern provides the best of both worlds: zero-cost abstraction with ergonomic usage.

Conditional Fields

You can use comptime to include or exclude fields based on configuration, achieving true zero-cost for unused features:

pub const Display_Config = struct {
    width: comptime_int,
    height: comptime_int,
    vcom_mode: enum { none, software },  // VCOM toggling mode
    Datagram_Device: type = mdf.base.Datagram_Device,
    Digital_IO: ?type = null,  // Optional display enable pin
};

pub fn Display(comptime config: Display_Config) type {
    return struct {
        const Self = @This();

        dd: config.Datagram_Device,

        // Pin field only exists if Digital_IO is provided
        disp_pin: if (config.Digital_IO) |T| T else void,

        // VCOM state only exists if software VCOM is enabled
        vcom_state: if (config.vcom_mode == .software) bool else void,

        pub fn init(
            dd: config.Datagram_Device,
            disp_pin: if (config.Digital_IO) |T| T else void,
        ) !Self {
            var self = Self{
                .dd = dd,
                .disp_pin = disp_pin,
                .vcom_state = if (config.vcom_mode == .software) false else {},
            };

            // Enable display if Digital_IO is provided
            if (comptime config.Digital_IO != null) {
                try self.disp_pin.write(.high);
            }

            return self;
        }

        /// Toggle VCOM (no-op if vcom_mode == .none)
        pub fn toggle_vcom(self: *Self) !void {
            if (comptime config.vcom_mode == .software) {
                self.vcom_state = !self.vcom_state;
                // ... send VCOM command
            }
            // If vcom_mode == .none, this function compiles to nothing
        }
    };
}

Benefits:

  • Zero overhead: Unused fields don't exist in the struct
  • Zero code: Disabled features compile away completely
  • Type safety: Compiler enforces correct usage at compile time

Usage:

// Display with no VCOM, no enable pin - minimal overhead
const DisplayBasic = Display(.{
    .width = 160,
    .height = 68,
    .vcom_mode = .none,
    .Digital_IO = null,
});

// Display with VCOM and enable pin - only pays for what you use
const DisplayFull = Display(.{
    .width = 400,
    .height = 240,
    .vcom_mode = .software,
    .Digital_IO = hal.gpio.Pin,
});

Real-World Examples

This section shows excerpts from actual MicroZig drivers demonstrating various patterns.

Sharp Memory LCD - Optional Features

File: drivers/display/sharp_memory_lcd.zig

This driver demonstrates conditional fields and compile-time feature selection:

pub const Config = struct {
    width: comptime_int,
    height: comptime_int,
    vcom_mode: VCOM_Mode = .none,
    Datagram_Device: type = mdf.base.Datagram_Device,
    Digital_IO: ?type = null,
};

pub fn Sharp_Memory_LCD(comptime config: Config) type {
    return struct {
        const Self = @This();

        dd: config.Datagram_Device,

        // Pin only exists if Digital_IO is provided
        disp_pin: if (config.Digital_IO) |T| T else void,

        // VCOM state only exists if software VCOM is enabled
        vcom_state: if (config.vcom_mode == .software) bool else void,

        /// Get command byte with optional VCOM bit (zero-cost when disabled)
        fn get_command_byte(self: *Self, base_cmd: Cmd) u8 {
            if (comptime config.vcom_mode == .software) {
                // Toggle VCOM state
                self.vcom_state = !self.vcom_state;
                return @intFromEnum(base_cmd) |
                       if (self.vcom_state) @intFromEnum(Cmd.VCOM) else 0;
            }
            // No VCOM overhead for displays that don't need it
            return @intFromEnum(base_cmd);
        }
    };
}

Usage:

// nice!view display - no VCOM needed
const Display = mdf.display.Sharp_Memory_LCD(.{
    .width = 160,
    .height = 68,
    .vcom_mode = .none,  // Feature disabled at compile time
    .Datagram_Device = @TypeOf(spi_dev),  // Zero-cost concrete type
});

WS2812 - Multiple Type Parameters

File: drivers/led/ws2812.zig

This driver accepts multiple interface types:

pub fn WS2812(options: struct {
    max_led_count: usize = 1,
    Datagram_Device: type = mdf.base.Datagram_Device,
    Clock_Device: type = mdf.base.Clock_Device,
}) type {
    return struct {
        const Self = @This();

        dev: options.Datagram_Device,
        clock_dev: options.Clock_Device,

        pub fn init(
            dev: options.Datagram_Device,
            clock_dev: options.Clock_Device,
        ) Self {
            return .{
                .dev = dev,
                .clock_dev = clock_dev,
            };
        }

        pub fn write(self: Self, colors: []const Color) !void {
            // Use clock for timing
            const deadline = self.clock_dev.make_timeout(
                mdf.time.Duration.from_us(50)
            );

            // Use datagram device for SPI transfer
            try self.dev.connect();
            defer self.dev.disconnect();

            for (colors) |color| {
                // Encode and send RGB data
                try self.dev.write(&encode_color(color));
            }

            // Wait for latch
            while (!self.clock_dev.is_reached(deadline)) {}
        }
    };
}

Usage with concrete types:

// Create with concrete types for maximum performance
var ws2812: WS2812(.{
    .max_led_count = 1,
    .Datagram_Device = hal.drivers.SPI_Device,
    .Clock_Device = hal.drivers.Clock,
}) = .init(spi_dev, clock);

const red: Color = .{ .r = 255, .g = 0, .b = 0 };
try ws2812.write(&.{red});

PCA9685 - Helper Init with @TypeOf

File: drivers/io_expander/pca9685.zig

This driver provides a helper init function that infers the type:

pub const PCA9685_Config = struct {
    Datagram_Device: type = mdf.base.Datagram_Device,
    oscillator_frequency: u32 = 25_000_000,
};

/// Helper function that infers concrete type from argument
pub fn init(
    datagram_device: anytype,
    frequency: u32,
) !PCA9685(.{ .Datagram_Device = @TypeOf(datagram_device) }) {
    const Type = PCA9685(.{
        .Datagram_Device = @TypeOf(datagram_device),
        //                  ^^^^^^ Type inference!
    });

    return Type.init(datagram_device, frequency);
}

/// Main generic driver
pub fn PCA9685(comptime config: PCA9685_Config) type {
    return struct {
        const Self = @This();
        dd: config.Datagram_Device,

        fn init(dd: config.Datagram_Device, frequency: u32) !Self {
            var self = Self{ .dd = dd };
            try self.reset();
            try self.set_pwm_freq(frequency);
            return self;
        }

        // ... PWM control methods
    };
}

Usage:

// Type is automatically inferred from i2c_dev
var pwm = try pca9685.init(i2c_dev, 50);  // 50 Hz for servos

// Equivalent to manually specifying:
// var pwm = pca9685.PCA9685(.{
//     .Datagram_Device = @TypeOf(i2c_dev),
// }).init(i2c_dev, 50);

SSD1306 - Mode-Dependent Types

File: drivers/display/ssd1306.zig

This driver adjusts struct fields based on the communication mode:

pub const Driver_Mode = enum { i2c, spi_3wire, spi_4wire, dynamic };

pub const SSD1306_Options = struct {
    mode: Driver_Mode,
    Datagram_Device: type = mdf.base.Datagram_Device,
    Digital_IO: type = mdf.base.Digital_IO,
};

pub fn SSD1306_Generic(comptime options: SSD1306_Options) type {
    return struct {
        const Self = @This();
        const Datagram_Device = options.Datagram_Device;

        // Digital_IO only exists for 4-wire SPI mode
        const Digital_IO = switch (options.mode) {
            .spi_4wire, .dynamic => options.Digital_IO,
            .i2c, .spi_3wire => void,  // Not used - no overhead
        };

        dd: Datagram_Device,
        dev_pin: Digital_IO,  // Will be 'void' for I2C/3-wire SPI

        // Init varies by mode
        pub fn init_without_io(dd: Datagram_Device) !Self {
            return .{
                .dd = dd,
                .dev_pin = {},  // void for I2C/3-wire modes
            };
        }

        pub fn init_with_io(dd: Datagram_Device, pin: Digital_IO) !Self {
            return .{
                .dd = dd,
                .dev_pin = pin,
            };
        }
    };
}

Keyboard Matrix - Arrays of Concrete Types

File: drivers/input/keyboard-matrix.zig

This driver uses arrays of concrete Digital_IO types:

pub const Keyboard_Matrix_Options = struct {
    rows: usize,
    columns: usize,
    Digital_IO: type = mdf.base.Digital_IO,
};

pub fn Keyboard_Matrix(comptime options: Keyboard_Matrix_Options) type {
    return struct {
        const Matrix = @This();
        const Digital_IO: type = options.Digital_IO;

        // Arrays of concrete types
        cols: [options.columns]Digital_IO,
        rows: [options.rows]Digital_IO,

        pub fn init(
            cols: [options.columns]Digital_IO,
            rows: [options.rows]Digital_IO,
        ) !Matrix {
            var self = Matrix{
                .cols = cols,
                .rows = rows,
            };

            // Configure all pins
            for (&self.cols) |*col| {
                try col.set_direction(.output);
                try col.write(.high);
            }
            for (&self.rows) |*row| {
                try row.set_direction(.input);
                try row.set_bias(.high);
            }

            return self;
        }

        pub fn scan(self: *Matrix) !u64 {
            var state: u64 = 0;
            for (self.cols, 0..) |*col, col_idx| {
                try col.write(.low);

                for (self.rows, 0..) |*row, row_idx| {
                    const pin_state = try row.read();
                    if (pin_state == .low) {
                        const key = row_idx * options.columns + col_idx;
                        state |= (@as(u64, 1) << @intCast(key));
                    }
                }

                try col.write(.high);
            }
            return state;
        }
    };
}

HAL Implementation Guide

This section shows how to create HAL wrappers that implement the base interfaces.

Creating a Concrete Implementation

Here's how the CH32V port implements SPI_Datagram_Device:

File: port/wch/ch32v/src/hals/drivers.zig

const spi = @import("./spi.zig");
const gpio = @import("./gpio.zig");
const mdf = microzig.drivers;

/// SPI Datagram Device implementation
/// Generic over SPI configuration to enable compile-time DMA optimization
pub fn SPI_Datagram_Device(comptime config: spi.Config) type {
    return struct {
        const Self = @This();
        // ... Add required fields

        pub fn init(...) Self {
            return .{...};
        }

        /// Create vtable interface from this concrete type
        pub fn datagram_device(dev: *Self) Datagram_Device {
            return .{
                .ptr = dev,
                .vtable = &datagram_vtable,
            };
        }

        // Direct method implementations (for zero-cost mode)
        pub fn connect(dev: Self) !void {
            ...
        }

        pub fn disconnect(dev: Self) void {
            ...
        }

        pub fn writev(dev: Self, chunks: []const []const u8) !void {
            ...
        }

        pub fn readv(dev: Self, chunks: []const []u8) !usize {
            ...
        }

        pub fn writev_then_readv(
            dev: Self,
            write_chunks: []const []const u8,
            read_chunks: []const []u8,
        ) !void {
            ...
        }

        // Vtable implementation (for runtime polymorphism mode)

        const datagram_vtable = Datagram_Device.VTable{
            .connect_fn = connect_fn,
            .disconnect_fn = disconnect_fn,
            .writev_fn = writev_fn,
            .readv_fn = readv_fn,
            .writev_then_readv_fn = writev_then_readv_fn,
        };

        fn connect_fn(dd: *anyopaque) !void {
            ...
        }

        fn disconnect_fn(dd: *anyopaque) void {
            ...
        }

        fn writev_fn(dd: *anyopaque, chunks: []const []const u8) !void {
            ...
        }

        fn readv_fn(dd: *anyopaque, chunks: []const []u8) !usize {
            ...
        }

        fn writev_then_readv_fn(
            dd: *anyopaque,
            write_chunks: []const []const u8,
            read_chunks: []const []u8,
        ) !void {
            ...
        }
    };
}

Key Points

  1. Concrete struct: Define a regular struct with the actual implementation
  2. Direct methods: Implement methods directly on the struct (e.g., connect(), writev())
  3. Error mapping: Map HAL-specific errors to interface error types
  4. Vtable wrapper: Provide _fn wrapper functions that extract the pointer and call the direct methods
  5. VTable const: Define a const VTable instance with pointers to wrapper functions
  6. Helper function: Provide a function (e.g., datagram_device()) to create the vtable interface

Usage Patterns

// Pattern 1: Use vtable interface (runtime polymorphism)
var spi_dev = SPI_DD.init(spi1, cs_pin, false, timeout);
var datagram_if = spi_dev.datagram_device();  // Get vtable interface

const Display = Sharp_Memory_LCD(.{});  // Uses default vtable type
var display = Display.init(datagram_if, {});

// Pattern 2: Use concrete type directly (zero-cost)
const SPI_DD = hal.drivers.SPI_Datagram_Device(spi_config);
var spi_dev = SPI_DD.init(spi1, cs_pin, false, timeout);

const Display = Sharp_Memory_LCD(.{
    .Datagram_Device = SPI_DD,  // Concrete type
});
var display = Display.init(spi_dev, {});

Performance Considerations

Overhead Analysis

Vtable Mode:

  • Each method call requires one pointer dereference: vtable.method_fn(ptr, args...)
  • Typically 1-4 CPU cycles of overhead per call
  • Prevents inlining and constant propagation across the interface boundary
  • Code size: one vtable per implementation (~6-8 pointers)

Zero-Cost Mode:

  • Direct method calls resolved at compile time
  • Zero runtime overhead - identical to hand-written code
  • Enables full compiler optimizations (inlining, constant propagation, dead code elimination)
  • Code size: potentially larger due to monomorphization (one copy per concrete type)

When Overhead Matters

Vtable overhead is negligible for:

  • Infrequent operations (initialization, configuration)
  • I/O-bound operations (the communication time dominates)
  • Operations with error handling overhead
  • Most typical embedded use cases

Vtable overhead is significant for:

  • Timing-critical bit-banging (WS2812, software SPI, 1-Wire)
  • High-frequency polling loops
  • Operations inside interrupt handlers
  • Real-time signal processing

Optimization Guidelines

  1. Start with vtable mode - It's simpler and usually fast enough
  2. Measure first - Profile before optimizing
  3. Use zero-cost mode when:
    • Profiling shows interface calls are a bottleneck
    • Timing is critical (e.g., nanosecond-level precision)
    • Code size is not a constraint
  4. Consider hybrid approach:
    // I2C uses vtable (plenty fast)
    i2c_dev: mdf.base.I2C_Device,
    
    // Clock uses concrete type (called frequently in tight loops)
    clock_dev: hal.Clock,

Measuring Performance

const start = clock.get_time_since_boot();

// Your operation
for (0..1000) |_| {
    try device.operation();
}

const elapsed = clock.get_time_since_boot().to_us() - start.to_us();
std.log.info("Average time: {} us per operation", .{elapsed / 1000});

Compare vtable vs zero-cost to quantify the difference for your specific use case.