In the previous two posts, we created a binding and our first software module to help us control some LEDs. Now it’s time to put both pieces together and build our own device driver — one that ties the binding and the library into a proper Zephyr device.

First, in the simple_leds.c file, define a structure that will represent the device configuration for our simple driver. This structure will typically hold the information extracted from the DeviceTree (for example, GPIO specifications).

// Configuration
struct simple_leds_config {
    struct gpio_dt_spec led;
    uint32_t id;
};

At the top of simple_leds.c, define the DT_DRV_COMPAT macro using the name of the compatible string from your binding. Since the original compatible is "simple,leds" and we cannot use a comma in the macro name, we replace it with an underscore:

#define DT_DRV_COMPAT simple_leds

This macro connects the driver source file with the corresponding DeviceTree nodes that declare: From this point on, Zephyr’s driver macros (such as DT_INST_*) will automatically match instances of that compatible and generate the necessary device structures for each one.

compatible = "simple,leds";

At the bottom of simple_leds.c, after your driver functions, define a macro that registers the device so it is initialized during the Zephyr boot process. In Zephyr, this is typically done using DEVICE_DT_INST_DEFINE(). Just like this:

#define SIMPLE_LEDS_DEFINE(inst)                                            \
                                                                            \
    /* Create an instance of the config struct, populate with DT values */  \
    static struct simple_leds_config leds_cfg_##inst = {                    \
        .led = GPIO_DT_SPEC_GET(DT_INST(inst, simple_leds), gpios),         \
        .id = inst                                                          \
    };                                                                      \
                                                                            \
    /* Create a "device" instance from a Devicetree node identifier and */  \
    /* registers the init function to run during boot. */                   \
    DEVICE_DT_INST_DEFINE(inst,                                             \
                          simple_leds_init,                                 \
                          NULL,                                             \
                          NULL,                                             \
                          &leds_cfg_##inst,                                 \
                          POST_KERNEL,                                      \
                          CONFIG_GPIO_INIT_PRIORITY,                        \
                          NULL);                                            \

DT_INST_FOREACH_STATUS_OKAY(SIMPLE_LEDS_DEFINE)

Redefine the initialization function as static and make it accept a pointer to a struct device, as required by the Zephyr driver model. Inside the function, cast the device configuration to your simple_leds_config structure so you can access the gpio_dt_spec and configure the GPIO properly.

Also, don’t forget to remove the prototype in simple_leds.h if it was previously declared since now is meant to remain private to the driver.

static int simple_leds_init(const struct device *dev)
{
    const struct simple_leds_config *cfg = dev->config; 
    gpio_pin_configure_dt(&cfg->led, GPIO_OUTPUT_INACTIVE);

    return 0;
}

With this, your driver is now fully integrated into the Zephyr Project device model and there is no need to call the init function manually, since it is automatically executed during the Zephyr boot process as part of the device initialization sequence.

In your main.c, you can now test the driver using the device API. Keep in mind that we still don’t have the simple_leds_toggle() function adapted to work with our new device structure. So for now, we’ll continue using the previous toggle implementation that operates directly on a gpio_dt_spec.

#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include "simple_leds.h"

/*get the device descriptor from DTC*/
const struct device *led_dev0 = DEVICE_DT_GET( DT_NODELABEL(myled0) );
/*also get the values to populate the gpio_dt_spec structure (port, pin, flags)*/
const struct gpio_dt_spec my_led0 = GPIO_DT_SPEC_GET(DT_NODELABEL(myled0), gpios);

int main(void)
{
    /*now the pin configuration will be carried out by our simple led dirver inti function*/
    while(true)
    {
        simple_leds_toggle(&my_led0);
        k_msleep(1000);
    }
        
	return 0;
}

The next step is to expose the rest of the LED control functions through the device driver interface. In Zephyr, this is typically done by defining an API structure that contains pointers to the functions the driver provides. In simple_leds.h, define a new structure with function pointers, like this:

struct simple_leds_api {
    int (*on)(const struct device *dev);
    int (*off)(const struct device *dev);
    int (*toggle)(const struct device *dev);
};

Create a new instance of this structure in simple_leds.c and initialize it with our driver functions. Before doing that, rename all the functions to use simpler and cleaner names so they better match the public API of the driver.

static const struct simple_leds_api leds_api_funcs = {
    .on = leds_on,
    .off = leds_off,
    .toggle = leds_toggle
};

Also add a reference to this API structure to the DEVICE_DT_INST_DEFINE macro we use to initialize our driver. Notice that previously we passed NULL to last parameter; now we replace it with a pointer to our API structure so the device exposes its functions properly through the driver model.

...
/* Create a "device" instance from a Devicetree node identifier and */      \
    /* registers the init function to run during boot. */                   \
    DEVICE_DT_INST_DEFINE(inst,                                             \
                          simple_leds_init,                                 \
                          NULL,                                             \
                          NULL,                                             \
                          &leds_cfg_##inst,                                 \
                          POST_KERNEL,                                      \
                          CONFIG_GPIO_INIT_PRIORITY,                        \
                          &leds_api_funcs);                                 \ 

In simple_leds.h, we can define our public functions as static inline.

This lets us create small wrapper functions that call the driver API through the device pointer, keeping the interface clean and easy to use. It also allows the compiler to optimize the calls, since inline functions avoid additional function call overhead while hiding the internal API structure from the application code.

static inline int simple_leds_on( const struct device *dev )
{
    const struct simple_leds_api *api = dev->api;
    return api->on( dev );
}
static inline int  simple_leds_off( const struct device *dev )
{
    const struct simple_leds_api *api = dev->api;
    return api->off( dev );
}

static inline int  simple_leds_toggle( const struct device *dev )
{
    const struct simple_leds_api *api = dev->api;
    return api->toggle( dev );
}

Last but not least, we need to modify our driver functions so they accept a const struct device *dev parameter and make them private to the driver (by declaring them static in the .c file).

From now on, these internal functions will not be called directly by the application. Instead, the application will only use the static inline wrapper functions defined in the header file, which act as the public interface to the driver.

static int leds_on( const struct device *dev )
{
    const struct simple_leds_config *cfg = dev->config; 
    gpio_pin_set_dt(&cfg->led, 1);
    return 0;
}

static int leds_off( const struct device *dev )
{
    const struct simple_leds_config *cfg = dev->config; 
    gpio_pin_set_dt(&cfg->led, 0);
    return 0;
}

static int leds_toggle( const struct device *dev )
{
    const struct simple_leds_config *cfg = dev->config; 
    gpio_pin_toggle_dt(&cfg->led);
    return 0;
}

To test our driver, we simply call the simple_leds_toggle() function and remove the previous code that was directly using the GPIO API.

Since the driver is now properly integrated into the Zephyr device model and initialized during boot, there’s no need to manipulate the GPIO manually. All interaction should go through our driver interface, keeping the application clean and fully abstracted from the low-level GPIO details.

#include <zephyr/kernel.h>
#include <zephyr/drivers/gpio.h>
#include "simple_leds.h"

const struct device *led_dev0 = DEVICE_DT_GET( DT_NODELABEL(myled0) ); 

int main(void)
{   
    while(true)
    {
        simple_leds_toggle(led_dev0);
        k_msleep(1000);
    }
        
	return 0;
}

It’s a good idea to take advantage of the GPIO API to validate parameters before performing any operation in our functions. For example, we can check whether the GPIO device is ready using device_is_ready() before attempting to set or toggle a pin. This adds an extra layer of safety and makes the driver more robust.

You can also use logging to indicate when an error occurs due to a misconfiguration or a failure during initialization.

#include <errno.h>
#include <zephyr/logging/log.h>
#include "simple_leds.h"

// Enable logging at CONFIG_LOG_DEFAULT_LEVEL
LOG_MODULE_REGISTER(simple_leds);

static int leds_init( const struct device *dev )
{
    const struct simple_leds_config *cfg = dev->config;
    const struct gpio_dt_spec *led = &cfg->led;

    // Print to console
    LOG_DBG("Initializing button (instance ID: %u)\r\n", cfg->id);

    // Check that the button device is ready
    if( gpio_is_ready_dt( led ) == false ) {
        LOG_ERR("GPIO is not ready\r\n");
        return -ENODEV;
    }

    // Set the button as input (apply extra flags if needed)
    if( gpio_pin_configure_dt(led, GPIO_INPUT) < 0) {
        LOG_ERR("Could not configure GPIO as input\r\n");
        return -ENODEV;
    }

    return 0;
}

Oh right — congratulations! 🎉 You’ve just built your first driver. Now it’s your turn. Try creating another driver for a different device you may have available — maybe a display, an LCD, some external memory, or a sensor.

There are still a few things we didn’t cover, such as initializing internal runtime data structures, but that was intentional — we kept this example simple to focus on the fundamentals. Maybe in the next post we’ll build a sensor driver and explore those additional concepts.

See you in the next blog post — and don’t forget to read the official documentation of the Zephyr Project. Now that you’ve built a driver yourself, it will make a lot more sense.