ESP32 on Zephyr RTOS [part 6]: Custom Device Driver

Video version

This article is also available in video form.

You can also find a playlist of all the videos in this tutorial series here. I’m always happy to receive feedback or suggestions: feedback@pullupresistor.com

Preamble

Making our first device driver in Zephyr may seem a bit daunting. And frankly…it is. We must understand macro magic, CMake, Kconfig, and many other concepts. Therefore, we’re breaking this journey down into three chapters, and we will reach a new goal in each chapter.

In this first chapter, we will implement a simple stepper motor driver by defining the hardware information in the devicetree. In the second chapter, we will implement the Zephyr driver instance mechanism to allow any number of stepper motors to be controlled by the same driver code. Finally, in the third chapter, we will separate the application code and the driver code into their own files, so that we can reuse the driver in other applications.

By taking one step at the time (pardon the pun), things will start to make sense, and once we’ve built our first driver, it will serve as a reusable template for any future drivers we’d like to develop.

As a final pre-amble, I should mention that I have never contributed a driver to the upstream Zephyr project, and there are several requirements for doing so that won’t be covered in these articles. However, I do try to get us to a place where it should be easy to move the driver from out-of-tree to in-tree Zephyr and get it working.

That being said, getting things working and being able to upstream are not the same thing. I’ve added links to the guidelines for how to upstream in the footnotes.1 I would appreciate feedback from anyone who has gone through the upstreaming process, as there are undoubtedly things that can be improved on in my method.

The circuit

We’ll create a driver for the 28BYJ-48 5 volts unipolar stepper motor in the next few videos. This motor is readily available and cheap.

28byj48.webp

Figure 1: 28BYJ-48 stepper motor

The motor is often sold together with a driver board PCB containing a ULN2003A IC and some LED indicator lights.

uln2003a.webp

Figure 2: ULN2003A driver

The ULN2003A is an array of 7 darlington NPN transistors. It takes the 5 volts power supply, amplifies the current and handles the kick-back with a diode to common.

Click this link to download a PDF of the schematic. Note that the ULN2003A inverts the input signal, so that 5 volts is supplied to the motor from the ULN2003A whenever the corresponding input is LOW.

To drive the motor, we connect 4 wires from the microcontroller. Let us choose four GPIO pins and call them IN1, IN2, IN3, IN4 to correspond to the driver inputs.

A word of caution. This motor is produced by many different manufacturers, and sometimes the color coding on the cables may be different to what my motor has. Read the datasheet that came with your motor, if there is one, or do a bit of trial and error through the various permutations. Also note, there is a 12 volts version of this motor. Finally, if you have another type of stepper motor lying around, you can probably adapt the driver to work with that as well.

As a bit of a heads-up, in the next video, we’ll drive two motors. So you may want to buy two sets of motor and driver board PCB if you plan to follow along.

I’m currently running Zephyr version 3.4.0-rc2 with Zephyr SDK 0.16.1.

We’re first going to hardcode everything for the 28BYJ-48 motor in the main.c file. Once we get things working we will refactor and clean up. Listing 1 shows the initial code to get started.

#include <stdint.h>

enum rotation_direction {
  CLOCKWISE,
  COUNTER_CLOCKWISE,
};

int take_steps(const uint32_t target_num_steps,
               const enum rotation_direction rot_dir,
               const int32_t sleep_time_ms)
{
  return 0;
}

void main(void)
{
  return;
}

A stepper motor moves by taking discrete steps, so let’s create a function called take_steps to serve as our driver’s first application programming interface, or API. The application will call this API to rotate the motor. The function will return 0 if the call was successful, and takes three arguments.

First, we need to know how many steps to rotate the motor. Let’s call that target_num_steps.

Second, we can rotate the motor either clockwise or counterclockwise. Let’s create an enum to capture these two options and call it rotation_direction with CLOCKWISE and COUNTER_CLOCKWISE as its members. The second argument to the take_steps function can then use this enum type, and let’s call the local variable rot_dir.

The third argument tells the driver how long it should sleep between each step. This determines the speed of the rotation. A lower number indicates a shorter pause, and hence a faster rotation speed. We’ll call it sleep_time_ms to indicate that we are expecting the argument to be in milliseconds.

Finally, let’s include stdint.h for the 32-bit integer types, uint32_t etc.

Zephyr uses CMake to build projects, so we need to provide some suitable configuration. Listing 2 shows a basic CMakeLists.txt file in the application root folder that will get us started.

cmake_minimum_required(VERSION 3.22)
set(BOARD esp32)
find_package(Zephyr)
project(app LANGUAGES C)
target_sources(app PRIVATE src/main.c)

Create an empty prj.conf file in the project root directory, and build main.c using west to ensure we have the basic project set up correctly.

So far, so good. But our application doesn’t actually do anything. Not to worry, we’ll fix that now. To rotate the motor, as we learned in the beginning of this article, we have to set and clear the GPIO pins in a specific pattern. Let’s first specify the GPIO pins we have connected from the microcontroller to the darlington transistor driver pins: GPIO pins 25, 26, 27 and 14. It’s important that these are in the order that they connect to IN1, IN2, IN3, and IN4 on the transistor driver. We’ll create an array of type gpio_pin_t called IN, and include zephyr/drivers/gpio.h for the declaration of that type.

#include <zephyr/drivers/gpio.h>
...
static const gpio_pin_t IN[] = {25, 26, 27, 14};

Next, we’ll get a pointer to the struct device for GPIO controller gpio0, that controls the state of the four GPIO pins we’re interested in. The header file zephyr/device.h contains the declaration for DEVICE_DT_GET and DT_NODELABEL, so let us include that in main.c as well.

#include <zephyr/device.h>
...
static const struct device *gpio_dev = DEVICE_DT_GET(DT_NODELABEL(gpio0));

Let’s check whether the device is ready in the take_steps function, and log an error message and return if it is not. After all, what is the use of a stepper motor driver if the GPIO controller is not ready? We include the zephyr/logging/log.h header and register the log module using LOG_MODULE_REGISTER with the name stepper_driver and set the log level to debug using LOG_LEVEL_DBG. This helps us to understand which module wrote the log message.

#include <zephyr/logging/log.h>

LOG_MODULE_REGISTER(stepper_driver, LOG_LEVEL_DBG);

  ... take_steps(...) ...

    if (!device_is_ready(gpio_dev)) {
      LOG_ERR("device %s is not ready", gpio_dev->name);
      return -ENODEV;
    }

For the logs to work, we also have to enable the Kconfig option by entering CONFIG_LOG=y in the file prj.conf in our application root directory.

CONFIG_LOG=y

Next, let’s implement the GPIO pin pattern to make the motor take steps and rotate. The actual details of the implementation are not important, since this article focuses on how to structure a device driver in Zephyr.

First, let’s configure all four GPIO pins and set them low.

for (size_t i = 0; i < sizeof(IN); i++) {
  if (gpio_pin_configure(gpio_dev, IN[i], GPIO_OUTPUT_LOW) != 0) {
    LOG_ERR("could not set GPIO pin %zu to 0", i);
    return -EIO;
  }
}

With the initialization taken care of, it’s time to step the motor.

First, we need some way to track how many steps the motor has already taken. We’ll declare curr_steps_count to do this and initialize it to zero. The steps should continue as long as curr_steps_count is less than target_num_steps, so we’ll capture that in a while loop. Then we slog through changing the pins according to the set sequence, taking care to turn them on and off at the right time, and updating the curr_steps_count variable for each step taken.

We’ll also include the zephyr/kernel.h header file where the k_msleep function is declared.

#include <zephyr/kernel.h>
  ...
  uint32_t curr_steps_count = 0U;
  while (curr_steps_count < target_num_steps) {
              // STEP 1
              k_msleep(sleep_time_ms);
              gpio_pin_set_raw(gpio_dev, IN[3], 0);
              gpio_pin_set_raw(gpio_dev, IN[1], 1);
              curr_steps_count++;
              // STEP 2
              k_msleep(sleep_time_ms);
              gpio_pin_set_raw(gpio_dev, IN[0], 0);
              gpio_pin_set_raw(gpio_dev, IN[2], 1);
              curr_steps_count++;
              // STEP 3
              k_msleep(sleep_time_ms);
              gpio_pin_set_raw(gpio_dev, IN[1], 0);
              gpio_pin_set_raw(gpio_dev, IN[3], 1);
              curr_steps_count++;
              // STEP 4
              k_msleep(sleep_time_ms);
              gpio_pin_set_raw(gpio_dev, IN[2], 0);
              gpio_pin_set_raw(gpio_dev, IN[0], 1);
              curr_steps_count++;
      }

To get the motor spinning, we call the take_steps function from the main function. Let’s ask the motor to take 2048 steps, which happens to be one full rotation on this particular stepper motor. We’ll tell the function to step in the clockwise direction, although, if you’ve been following carefully, the take_steps function doesn’t use this argument yet. Finally, the sleep value. This may vary a bit from stepper motor to stepper motor, but let’s start with a value of 50ms.

if (take_steps(2048, CLOCKWISE, 50) != 0) {
  LOG_ERR("could not take steps on motor");
  return;
}

Let’s flash this firmware onto the ESP32, and see that the stepper motor rotates 360 degrees, albeit slowly. The flashing lights indicate which pins are activated, which is a nice feature of this commonly available stepper driver board.

With the clockwise direction working, let’s implement the counterclockwise direction as well. First, we check if the rotation direction is clockwise or counter-clockwise using an if statement. If the direction is CLOCKWISE, we run through the while loop we already created. If it is COUNTER_CLOCKWISE, we will use another while loop that works in the reverse. Finally, let’s log an error if another direction has been passed in, just in case.

Listing 10 shows the implementation of the counterclockwise direction.

if (rot_dir == CLOCKWISE) {
    // Existing code....
  }
} else if (rot_dir == COUNTER_CLOCKWISE) {
  while (curr_steps_count < target_num_steps) {
    // STEP 1
    k_msleep(sleep_time_ms);
    gpio_pin_set_raw(gpio_dev, IN[0], 0);
    gpio_pin_set_raw(gpio_dev, IN[2], 1);
    curr_steps_count++;
    // STEP 2
    k_msleep(sleep_time_ms);
    gpio_pin_set_raw(gpio_dev, IN[3], 0);
    gpio_pin_set_raw(gpio_dev, IN[1], 1);
    curr_steps_count++;
    // STEP 3
    k_msleep(sleep_time_ms);
    gpio_pin_set_raw(gpio_dev, IN[2], 0);
    gpio_pin_set_raw(gpio_dev, IN[0], 1);
    curr_steps_count++;
    // STEP 4
    k_msleep(sleep_time_ms);
    gpio_pin_set_raw(gpio_dev, IN[1], 0);
    gpio_pin_set_raw(gpio_dev, IN[3], 1);
    curr_steps_count++;
  }
} else {
  LOG_ERR("direction should be CLOCKWISE or COUNTER_CLOCKWISE");
  return -EINVAL;
}

Let’s add a call from the main function to rotate counterclockwise one full rotation, and to speed things up, change the sleep time to 4ms for both directions.

if (take_steps(2048, CLOCKWISE, 4) != 0) {
  LOG_ERR("could not take steps on motor");
  return;
}
if (take_steps(2048, COUNTER_CLOCKWISE, 4) != 0) {
  LOG_ERR("could not take steps on motor");
  return;
}

Flash the firmware again and see if we get the expected behaviour. And, indeed, we see that the motor first rotates 360 degrees in the clockwise direction, and then 360 degrees in the counter-clockwise direction. It rotates much faster now as well due to the lower sleep time.

Initial devicetree implementation

I’m sure there are better ways to implement the take_steps function, but at least this code makes it very clear how the motor is being rotated.

However, we have several other issues. One problem is that we are hardcoding all of the hardware-specific information in our main.c file. Instead, we should put all the hardware related information in the devicetree. But how do we go about doing that?

Let’s start to put together a devicetree overlay in a file called esp32.overlay in the root application folder.

We’ll call the node stepper, with the same node label. We’ll have to come up with a compatible. I don’t know the name of the manufacturer of my stepper motors, so I’ll put generic as a placeholder for now, followed by the motor part name, 28byj48. Finally, let’s list the GPIO pin numbers in an array called pins. This is just to get things working. Rest assured that we’ll return to the devicetree again later. Listing 12 provides what we need to get started.

/ {
        stepper: stepper {
                compatible = "generic,28byj48";
                status = "okay";
                pins = <25 26 27 14>;
        };
};

Back in the main.c file, let’s change the IN array to take the pin numbers from the devicetree using the DT_PROP macro. DT_PROP takes a node identifier and the property name we’re interested in. In this case it’s the property pins. DT_PROP and DT_NODELABEL is declared in the zephyr/devicetree.h header file, so let’s include that as well.

#include <zephyr/devicetree.h>
  ...
static const gpio_pin_t IN[] = DT_PROP(DT_NODELABEL(stepper), pins);

If we try to build this code, we get an error saying that DT_N_S_stepper_P_pins is undeclared. The default behaviour of devicetree is to treat properties as integers unless they are explicitly specified in the bindings.

error: 'DT_N_S_stepper_P_pins' undeclared here (not in a function); did you mean 'DT_N_S_stepper_PARENT'?

So, let’s define the binding in a yaml file. The name of the file should reflect the compatible, so we’ll call it generic,28byj48.yaml. But where should we store this bindings file? Previously in this series, we’ve always used existing yaml bindings files and the Zephyr build system just magically found them for us.

In the Zephyr documentation we can read that the build system looks for binding files in the dts/bindings subdirectory of our application root directory, so lets put it there.

The GPIO pins are an array of four ints, so we’ll specify the type as array, and required as true, since we can’t use the driver without having the GPIO pin configuration. Listing 15 shows the way to configure these properties.

description: "28BYJ-48 stepper motor"

compatible: "generic,28byj48"

properties:
  pins:
    type: array
    required: true

We should do a pristine build using west build -p to make sure that our new directory and yaml file will be included.

Making a higher level API

Our take_steps API requires the calling application code to pass how many steps to rotate as an argument. This works fine as a low level interface to a stepper motor. After all, it operates in a step by step fashion. However, it would be nice for the application to have something a little bit higher level, say an API to rotate by a certain number of degrees. For example, if we pass 360 degrees as an argument to this function, the stepper would make one full rotation.

Let’s name this function rotate_degrees, with arguments for the number of degrees, the direction of rotation and the sleep time between each step.

int rotate_degrees(const uint32_t degrees,
                   const enum rotation_direction rot_dir,
                   const int32_t sleep_time_ms)
{

}

If we know how many steps it takes to make a full rotation, we can simply divide this by 360 to get the number of steps per degree. Then we can multiply by the number of degrees the application asked for. In practice, let’s do the multiplication first, so that we get better precision when doing the integer division. If I recall correctly, integer division in C truncates the decimal part of the result, so we can probably make the result more accurate by rounding to the nearest integer, but the default behaviour is fine for our simple purposes.

Then, simply call the take_steps function with that number as the first argument and pass along the rotation_direction and sleep_time_ms arguments without modification.

const uint32_t num_steps = (steps_per_rotation * degrees) / 360;
if (take_steps(num_steps, rot_dir, sleep_time_ms) != 0) {
  LOG_ERR("could not rotate %ud degrees", degrees);
  return -EIO;
}

But where do we get the constant steps_per_rotation from? This number will depend on the type of stepper motor and its associated gear ratio. What’s for certain is that it depends on the hardware device, and hardware devices should be described in the devicetree.

Therefore, let’s add another devicetree property called steps-per-rotation to configure how many steps the motor takes to rotate 360 degrees. Our stepper takes 2048 steps per rotation so we’ll add that to the property. Note that the property name uses underscores in our code, but must use hyphens in the devicetree overlay file.

/ {
        stepper: stepper {
                compatible = "generic,28byj48";
                status = "okay";
                steps-per-rotation = <2048>;
                pins = <25 26 27 14>;
        };
};

Let’s also update the device tree bindings yaml file. We make it required, since our API won’t work if the application developer doesn’t tell the driver how many steps it takes to rotate a particular stepper motor.

description: "28BYJ-48 stepper motor"

compatible: "generic,28byj48"

properties:
  pins:
    type: array
    required: true
  steps-per-rotation:
    type: int
    required: true

Back in our main.c file, initialize a static constant called steps_per_rotation with the property from the devicetree.

static const uint32_t steps_per_rotation = DT_PROP(DT_NODELABEL(stepper), steps_per_rotation);

Let’s change the code in our main function to call the new API. To start with, let’s try 360 degrees, first clockwise, then counterclockwise.

if (rotate_degrees(360, CLOCKWISE, 4) != 0) {
  LOG_ERR("failed to rotate motor");
  return;
}
if (rotate_degrees(360, COUNTER_CLOCKWISE, 4) != 0) {
  LOG_ERR("failed to rotate motor");
  return;
}

Of course, we can add all kinds of other APIs to this driver. For example microstepping, adjust the speed, and so on. However, we’ll keep it simple here since our main objective is to understand how to make a basic driver in Zephyr. Let’s flash the code and see that the motor makes one full rotation each way.

At this point, we have a working application with most of the hardware specific properties defined in the devicetree. However, there are still several issues with our code. For example, what if our application wants to use two stepper motors instead of just one? What shall we do then? And how can we separate the driver code from our application code so that we can reuse the driver easily in other applications? These are questions we will address in the next couple of articles.

References and further information

Footnotes:

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.