ESP32 GPIO in C++ Part 2

Advanced ESP32 GPIO in C++

Add ESP32 input interrupts to the GPIO input class created in ESP32 GPIO in C++ Part 1

This is part 2 of a 3 part tutorial

ESP32 GPIO in C++ Part 1
ESP32 GPIO in C++ Part 2
ESP32 GPIO in C++ Part 3

In part 1 of this series, we created basic wrapper classes to easily access the GPIO functionality using C++ methodology. However, there are some IO features that we are not utilizing.

In this tutorial, we are going to implement input interrupts for the ESP32. We are also going to see how we can split up source files to make the codebase easier to maintain.

Recap

Open up the project that we were busy with in part 1. Notice how the header file cppgpio.h defines multiple classes. We then implemented all of the methods for both classes in one source file. This is fine for relatively small classes, however, as classes grow larger it becomes cumbersome to find navigate through methods of different classes to find a specific method.

A source file for each of the class implementations

We are going to split the contents of cppgpio.h into 2 new source files: cppgpio_input.cpp and cppgpio_output.cpp. See the new structure to the right.

Now only the GpioInput class method implementations will be in a source file and only the GpioOutput class method implementations will be in another source file.

This will make it easier to manage the code base when we add additional methods. This might become apparent when we implement the interrupt method for the GpioInput.

ESP-IDF C++ Project folder structure
Project Structure

A new source file for Inputs

Create a file names cppgpio_input.cpp in the CPPGPIO folder and add the existing implementations. The content should look like the following:

#include "cppgpio.h"

namespace CPPGPIO
{
    esp_err_t GpioInput::_init(const gpio_num_t pin, const bool activeLow)
    {
        esp_err_t status{ESP_OK};

        _active_low = activeLow;
        _pin = pin;

        gpio_config_t cfg;
        cfg.pin_bit_mask = 1ULL << pin;
        cfg.mode = GPIO_MODE_INPUT;
        cfg.pull_up_en = GPIO_PULLUP_DISABLE;
        cfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
        cfg.intr_type = GPIO_INTR_DISABLE;

        status |= gpio_config(&cfg);

        return status;
    }

    GpioInput::GpioInput(const gpio_num_t pin, const bool activeLow)
    {
        _init(pin, activeLow);
    }

    GpioInput::GpioInput(const gpio_num_t pin)
    {
        _init(pin, false);
    }

    GpioInput::GpioInput(void)
    {
    }

    esp_err_t GpioInput::init(const gpio_num_t pin, const bool activeLow)
    {
        return _init(pin, activeLow);
    }

    esp_err_t GpioInput::init(const gpio_num_t pin)
    {
        return _init(pin, false);
    }

    int GpioInput::read(void)
    {
        return _active_low ? !gpio_get_level(_pin) : gpio_get_level(_pin);
    }
} // Namespace CPPGPIO

A new source file for Outputs

Create a file names cppgpio_output.cpp in the CPPGPIO folder and add the existing implementations. The content should look like the following:

#include "cppgpio.h"

namespace CPPGPIO
{
    esp_err_t GpioOutput::_init(const gpio_num_t pin, const bool activeLow)
    {
        esp_err_t status{ESP_OK};

        _active_low = activeLow;
        _pin = pin;

        gpio_config_t cfg;
        cfg.pin_bit_mask = 1ULL << pin;
        cfg.mode = GPIO_MODE_OUTPUT;
        cfg.pull_up_en = GPIO_PULLUP_DISABLE;
        cfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
        cfg.intr_type = GPIO_INTR_DISABLE;

        status |= gpio_config(&cfg);

        return status;
    }

    GpioOutput::GpioOutput(const gpio_num_t pin, const bool activeLow)
    {
        _init(pin, activeLow);
    }

    GpioOutput::GpioOutput(const gpio_num_t pin)
    {
        _init(pin, false);
    }

    GpioOutput::GpioOutput(void)
    {
    }

    esp_err_t GpioOutput::init(const gpio_num_t pin, const bool activeLow)
    {
        return _init(pin, activeLow);
    }

    esp_err_t GpioOutput::init(const gpio_num_t pin)
    {
        return _init(pin, false);
    }

    esp_err_t GpioOutput::on(void)
    {
        _level = true;
        return gpio_set_level(_pin, _active_low ? 0 : 1);
    }

    esp_err_t GpioOutput::off(void)
    {
        _level = false;
        return gpio_set_level(_pin, _active_low ? 1 : 0);
    }

    esp_err_t GpioOutput::toggle(void)
    {
        _level = _level ? 0 : 1;
        return gpio_set_level(_pin, _level ? 1 : 0);
    }

    esp_err_t GpioOutput::setLevel(int level)
    {
        _level = _active_low ? !level : level;
        return gpio_set_level(_pin, _level);
    }
} // Namespace CPPGPIO

Modify CMakeLists.txt

Next, we need to make sure Cmake knows which source files need to be compiled and where to find them. Open CMakeLists.txt in the src folder and change the content to the following:

set(SOURCES main.cpp
            CPPGPIO/cppgpio_input.cpp
            CPPGPIO/cppgpio_output.cpp)
            
idf_component_register(SRCS ${SOURCES}
                    INCLUDE_DIRS . ../include/CPPGPIO)

We have removed cppgpio.cpp from the list of files to be compiled and then we added cppgpio_input.cpp and cppgpio_output.cpp to the list of files to be compiled.

You may now delete cppgpio.cpp.

Advanced GPIO Input Implementations: Interrupts

We are already able to capture inputs however, with the current methods, we have to manually poll the input regularly to check if the input state has changed. This is not ideal for task-driven applications.

Interrupts solve this problem. Each time there is an event in the designated input pin, an interrupt will be triggered. That way we do not have to concern ourselves with regularly checking the state of the input pin, we can simply write a function that will handle the interrupt when the state change.

Update the Header

Add the needed include to cppgpio.h.

#include "esp_event.h"

This contains the Event Loop Library, for more information check out the official ESP-IDF documentation.

In this example we are going to use the system event loop for the sake of simplicity. In many instances it would be beneficial to create a custom event loop so that the service routine is run in a separate thread. In part 3 we will implement both custom event loops as well as message queues.

Declare the Event Base

Add the following line in cppgpio.h directly after the namespace opening curly bracket before any class declarations have been made:

ESP_EVENT_DECLARE_BASE(INPUT_EVENTS);

This declares the base events as INPUT_EVENTS. We are going to use that later to tell the driver where to post the input interrupt event to.

Add private variables to GpioInput class

Add the following private variables to the GpioInput class definition in cppgpio.h:

bool _event_handler_set;

This variable keeps track of whether or not an event handler has been set per instance of the class. If an event has not been set and we attempt to post to the event loop from the interrupt service routine then nothing will happen.

static bool _interrupt_service_installed;

Keeps track of whether or not the interrupt service has been installed. The interrupt service will only be installed the first time that an interrupt is enabled on any input pin. The static modifier has been added because the state needs to be consistent across instances of the class.

Add public methods to GpioInput class

Add the following public methods to the GpioInput class definition in cppgpio.h:

esp_err_t enablePullup(void);
esp_err_t disablePullup(void);
esp_err_t enablePulldown(void);
esp_err_t disablePulldown(void);
esp_err_t enablePullupPulldown(void);
esp_err_t disablePullupPulldown(void);

These are methods to set the internal pullup and pulldown resistors for the GPIO when set up as inputs.

esp_err_t enableInterrupt(gpio_int_type_t int_type);

This method is used to enable the interrupt on the pin and set the interrupt type:

  • Rising edge: Interrupt triggers when input change from low to high
  • Falling edge: Interrupt triggers when inpup change from high to low
  • Both falling and rising edge: Interrupt triggers when input change from either low to high or high to low
  • Input low level: Interrupt triggers when input level is low
  • Input high level: Interrupt triggers when input is high
esp_err_t setEventHandler(esp_event_handler_t Gpio_e_h);

This method will set the event handler for the specific instance of the class. You can register to the same event handler function for all inputs or you can register different event handler functions for different inputs.

If you register the same event handler function for all inputs then the event_id can be used to determine which input triggered the interrupt. This is demonstrated in the example at the end of this tutorial.

static void IRAM_ATTR gpio_isr_callback(void* arg);

This is the GPIO Interrupt Service Routine. It must always be static, meaning only one instance of this method in the same memory location.

The IRAM_ATTR tells the IDF to place this instruction into the instruction RAM. This is a requirement for GPIO interrupt service routines.

Update the source file

Now we only need to implement the methods defines in the header file. However, before we do that we need to define the Event Base and initialize our static variables to known states.

Initialize static variables

Open cppgpio_input.ccp and add the following line of code underneath the namespace opening curly bracket

bool GpioInput::_interrupt_service_installed{false};

This will initialize the _interrupt_service_installed variable to false.

Define the Event Base

Add the following line underneath the static variable initializations:

ESP_EVENT_DEFINE_BASE(INPUT_EVENTS);

In the header we declared the event base, now we are defining the event base.

Implement the Interrupt Service Routine

I prefer to place my ISR implementation right at the top though it can be placed anywhere within the namespace.

Add the following code underneath ESP_EVENT_DEFINE_BASE(INPUT_EVENTS):

void IRAM_ATTR GpioInput::gpio_isr_callback(void *args)
    {
        int32_t pin = reinterpret_cast<int32_t>(args);
       
        esp_event_isr_post(INPUT_EVENTS, pin, nullptr, 0, nullptr);
        
    }

This is the Interrupt Service Routine (ISR). Note that it is important to keep any ISR as short as possible.

Let us look at each of the two lines:

int32_t pin = reinterpret_cast<int32_t>(args);

The ISR received a void* type as arguments. This means that when calling the function you can place just about anything in as arguments. This is useful to receive non-static context-specific information. In this case, we pass the pin number as an argument.

To be able to read the data we need to cast the void* into a known type. We are going to pass the pin number in as the event_id and the event_id is of type int32_t.

esp_event_isr_post(INPUT_EVENTS, pin, nullptr, 0, nullptr);

This line post the event to the INPUT_EVENTS group in the system event loop. The pin argument provides an event ID which we can then use in the event handler function to determine which input pin triggered the interrupt.

Implement input option methods in GpioInput class

Right at the bottom before the last closing curly braces, ad the following:

    esp_err_t GpioInput::enablePullup(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_PULLUP_ONLY);
    }

    esp_err_t GpioInput::disablePullup(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_FLOATING);
    }

    esp_err_t GpioInput::enablePulldown(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_PULLDOWN_ONLY);
    }

    esp_err_t GpioInput::disablePulldown(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_FLOATING);
    }

    esp_err_t GpioInput::enablePullupPulldown(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_PULLUP_PULLDOWN);
    }

    esp_err_t GpioInput::disablePullupPulldown(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_FLOATING);
    }

All of these methods only enable or disable the different available internal resistor pullup and pulldown configurations.

You might have noticed that many of the disable methods are basically just copies. I added all the options to make more logical sense to the end user.

Implement the method to enable interrupts

Add the following code underneath the code that you have just added:

    esp_err_t GpioInput::enableInterrupt(gpio_int_type_t int_type)
    {
        esp_err_t status{ESP_OK};

        // Invert triggers if active low is enabled
        if (_active_low)
        {
            switch (int_type)
            {
            case GPIO_INTR_POSEDGE:
                int_type = GPIO_INTR_NEGEDGE;
                break;
            case GPIO_INTR_NEGEDGE:
                int_type = GPIO_INTR_POSEDGE;
                break;
            case GPIO_INTR_LOW_LEVEL:
                int_type = GPIO_INTR_HIGH_LEVEL;
                break;
            case GPIO_INTR_HIGH_LEVEL:
                int_type = GPIO_INTR_LOW_LEVEL;
                break;
            default:
                break;
            }
        }

        if (!_interrupt_service_installed)
        {
            status = gpio_install_isr_service(0);
            if (ESP_OK == status)
            {
                _interrupt_service_installed = true;
            }            
        }

        if (ESP_OK == status)
        {
            status = gpio_set_intr_type(_pin, int_type);
        }

        if (ESP_OK == status)
        {
            status = gpio_isr_handler_add(_pin, gpio_isr_callback, (void*) _pin);
        }
        return status;
    }

Let us unpack what is happening in this method:

        if (_active_low)
        {
            switch (int_type)
            {
            case GPIO_INTR_POSEDGE:
                int_type = GPIO_INTR_NEGEDGE;
                break;
            case GPIO_INTR_NEGEDGE:
                int_type = GPIO_INTR_POSEDGE;
                break;
            case GPIO_INTR_LOW_LEVEL:
                int_type = GPIO_INTR_HIGH_LEVEL;
                break;
            case GPIO_INTR_HIGH_LEVEL:
                int_type = GPIO_INTR_LOW_LEVEL;
                break;
            default:
                break;
            }
        }

This part applies the ActiveLow feature to the interrupts. It swaps rising edge trigger for falling edge trigger and visa versa and also low level trigger for high level trigger and visa versa.

I do not personally use the ActiveLow feature, however since this is going to be a library for reuse in multiple projects, it is worth having the option.

        if (!_interrupt_service_installed)
        {
            status = gpio_install_isr_service(0);
            if (ESP_OK == status)
            {
                _interrupt_service_installed = true;
            }            
        }

Checks if the interrupt service has been installed. If it has not then it installs the service and sets the flag to indicate that the service has been installed.

        if (ESP_OK == status)
        {
            status = gpio_set_intr_type(_pin, int_type);
        }

Enable interrupt on the specific input pin and set the trigger condition.

        if (ESP_OK == status)
        {
            status = gpio_isr_handler_add(_pin, gpio_isr_callback, (void*) _pin);
        }

Tell the IDF what ISR to call when this specific input pin is triggered.

Set the event handler

Add the following to underneath the method that you just implemented:

    esp_err_t GpioInput::setEventHandler(esp_event_handler_t Gpio_e_h)
    {
        esp_err_t status{ESP_OK};

        status = esp_event_handler_instance_register(INPUT_EVENTS, _pin, Gpio_e_h, 0, nullptr);

        if (ESP_OK == status)
        {
            _event_handler_set = true;
        }

        return status;
    }

Finally, we need to set the event handler. The ISR is going to post the input event to an event loop. The event handler is the function that will then do whatever needs to be done when an input event has been triggered.

The event handler function is an external function with will be passed into the GpioInput class by pointer reference. The execution will take place outside of the GpioInpout class.

Complete Header and source files

Complete cppgpio.h

#pragma once

#include "esp_event.h"
#include "driver/gpio.h"

namespace CPPGPIO
{
    ESP_EVENT_DECLARE_BASE(INPUT_EVENTS);

    class GpioBase
    {
    protected:
        gpio_num_t _pin;
        bool _active_low = false;
    }; // GpioBase Class

    class GpioInput : public GpioBase
    {
    private:
        esp_err_t _init(const gpio_num_t pin, const bool activeLow);
        bool _event_handler_set = false;
        static bool _interrupt_service_installed;

    public:
        GpioInput(const gpio_num_t pin, const bool activeLow);
        GpioInput(const gpio_num_t pin);
        GpioInput(void);
        esp_err_t init(const gpio_num_t pin, const bool activeLow);
        esp_err_t init(const gpio_num_t pin);
        int read(void);

        esp_err_t enablePullup(void);
        esp_err_t disablePullup(void);
        esp_err_t enablePulldown(void);
        esp_err_t disablePulldown(void);
        esp_err_t enablePullupPulldown(void);
        esp_err_t disablePullupPulldown(void);
        
        esp_err_t enableInterrupt(gpio_int_type_t int_type);
        esp_err_t setEventHandler(esp_event_handler_t Gpio_e_h);
        static void IRAM_ATTR gpio_isr_callback(void* arg);
    }; // GpioInput Class

    class GpioOutput : public GpioBase
    {
    private:
        int _level = 0;
        esp_err_t _init(const gpio_num_t pin, const bool activeLow);

    public:
        GpioOutput(const gpio_num_t pin, const bool activeLow);
        GpioOutput(const gpio_num_t pin);
        GpioOutput(void);
        esp_err_t init(const gpio_num_t pin, const bool activeLow);
        esp_err_t init(const gpio_num_t pin);
        esp_err_t on(void);
        esp_err_t off(void);
        esp_err_t toggle(void);
        esp_err_t setLevel(int level);
    }; // GpioOutput Class
} // Gpio Namespace

Complete cppgpio_input.cpp

#include "cppgpio.h"

namespace CPPGPIO
{
    // Static variable initializations
    bool GpioInput::_interrupt_service_installed{false};

    ESP_EVENT_DEFINE_BASE(INPUT_EVENTS);

    void IRAM_ATTR GpioInput::gpio_isr_callback(void *args)
    {
        int32_t pin = reinterpret_cast<int32_t>(args);

        esp_event_isr_post(INPUT_EVENTS, pin, nullptr, 0, nullptr);
    }

    esp_err_t GpioInput::_init(const gpio_num_t pin, const bool activeLow)
    {
        esp_err_t status{ESP_OK};

        _active_low = activeLow;
        _pin = pin;

        gpio_config_t cfg;
        cfg.pin_bit_mask = 1ULL << pin;
        cfg.mode = GPIO_MODE_INPUT;
        cfg.pull_up_en = GPIO_PULLUP_DISABLE;
        cfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
        cfg.intr_type = GPIO_INTR_DISABLE;

        status |= gpio_config(&cfg);

        return status;
    }

    GpioInput::GpioInput(const gpio_num_t pin, const bool activeLow)
    {
        _init(pin, activeLow);
    }

    GpioInput::GpioInput(const gpio_num_t pin)
    {
        _init(pin, false);
    }

    GpioInput::GpioInput(void)
    {
    }

    esp_err_t GpioInput::init(const gpio_num_t pin, const bool activeLow)
    {
        return _init(pin, activeLow);
    }

    esp_err_t GpioInput::init(const gpio_num_t pin)
    {
        return _init(pin, false);
    }

    int GpioInput::read(void)
    {
        return _active_low ? !gpio_get_level(_pin) : gpio_get_level(_pin);
    }

    esp_err_t GpioInput::enablePullup(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_PULLUP_ONLY);
    }

    esp_err_t GpioInput::disablePullup(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_FLOATING);
    }

    esp_err_t GpioInput::enablePulldown(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_PULLDOWN_ONLY);
    }

    esp_err_t GpioInput::disablePulldown(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_FLOATING);
    }

    esp_err_t GpioInput::enablePullupPulldown(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_PULLUP_PULLDOWN);
    }

    esp_err_t GpioInput::disablePullupPulldown(void)
    {
        return gpio_set_pull_mode(_pin, GPIO_FLOATING);
    }

    esp_err_t GpioInput::enableInterrupt(gpio_int_type_t int_type)
    {
        esp_err_t status{ESP_OK};

        // Invert triggers if active low is enabled
        if (_active_low)
        {
            switch (int_type)
            {
            case GPIO_INTR_POSEDGE:
                int_type = GPIO_INTR_NEGEDGE;
                break;
            case GPIO_INTR_NEGEDGE:
                int_type = GPIO_INTR_POSEDGE;
                break;
            case GPIO_INTR_LOW_LEVEL:
                int_type = GPIO_INTR_HIGH_LEVEL;
                break;
            case GPIO_INTR_HIGH_LEVEL:
                int_type = GPIO_INTR_LOW_LEVEL;
                break;
            default:
                break;
            }
        }

        if (!_interrupt_service_installed)
        {
            status = gpio_install_isr_service(0);
            if (ESP_OK == status)
            {
                _interrupt_service_installed = true;
            }
        }

        if (ESP_OK == status)
        {
            status = gpio_set_intr_type(_pin, int_type);
        }

        if (ESP_OK == status)
        {
            status = gpio_isr_handler_add(_pin, gpio_isr_callback, (void *)_pin);
        }
        return status;
    }

    esp_err_t GpioInput::setEventHandler(esp_event_handler_t Gpio_e_h)
    {
        esp_err_t status{ESP_OK};

        status = esp_event_handler_instance_register(INPUT_EVENTS, _pin, Gpio_e_h, 0, nullptr);

        if (ESP_OK == status)
        {
            _event_handler_set = true;
        }
        return status;
    }
} // Namespace CPPGPIO

Create an implementation of the interrupt and event

Main header

Edit main.h in the src folder to look like the following:

#pragma once

#include <iostream>

#include "cppgpio.h"

// Main class used for testing only
class Main final
{
public:
    void run(void);
    void setup(void);

    // LED pin on my NodeMCU
    CPPGPIO::GpioOutput cppLed { GPIO_NUM_2, true };
    // Repurpose the BOOT button to work as an input
    CPPGPIO::GpioInput cppButton { GPIO_NUM_0 };
    // A second input to demonstrate Event_ID different event handlers
    CPPGPIO::GpioInput cppButton2 { GPIO_NUM_12 };

    // Event Handler for cppButton
    static void  button_event_handler(void *handler_args, esp_event_base_t base, int32_t id, void *event_data);
    // Event Handler for cppButton2
    static void  button2_event_handler(void *handler_args, esp_event_base_t base, int32_t id, void *event_data);

}; // Main Class

Main source

Edit main.cpp in the src folder to look like the following:

#include "main.h"

Main App;

void Main::run(void)
{
    vTaskDelay(pdMS_TO_TICKS(200));
    cppLed.setLevel(cppButton.read());
}

void Main::setup(void)
{
    cppButton.enableInterrupt(GPIO_INTR_NEGEDGE);
    cppButton.setEventHandler(&button_event_handler);

    cppButton2.enablePullup();
    cppButton2.enableInterrupt(GPIO_INTR_NEGEDGE);
    cppButton2.setEventHandler(&button2_event_handler);
}

extern "C" void app_main(void)
{
    esp_event_loop_create_default();

    App.setup();

    while (true)
    {
        App.run();
    }    
}

void  Main::button_event_handler(void *handler_args, esp_event_base_t base, int32_t id, void *event_data)
{
    std::cout << "Button triggered interrupt with ID: " << id << '\n';
}

void  Main::button2_event_handler(void *handler_args, esp_event_base_t base, int32_t id, void *event_data)
{
    std::cout << "Button triggered interrupt with ID: " << id << '\n';
}

Short explanation

We created 3 GPIO objects

  • cppLed: GpioOutput Object
  • cppButton: GpioInput Object
  • cppButton2: GpioInput Object

When cppButton is pressed then cppLed turns on. When released cppLed turns off. I added a 200ms delay in the main.run method. This is to throttle the processor a little bit.

When cppButton in pressed then an interrupt is triggered in the GpioInoput class. The ISR then post an input event to the system event loop and is ultimately handled by button_event_handler. In this function, the pin number is output on the serial port.

When cppButton2 is pressed the same happens as with cppButton but instead of button_event_handler, button2_event_handler receives the event and executes.

You can now play around with cppButton and CppButton2. Maybe see what happens when both use the same event handler or change the interrupt type to a different setting and see what happens.

Conclusion

We have now a C++ GPIO library in which we can easily achieve inputs and outputs. We can also create interrupts and handle input events in the event loop.

We can still add some improvements; Rather than using the system event loop, we can create a custom user event. This will ensure that our input events are sent to their own thread which in turn means that it will not block other tasks if there is much to do.

We can also implement a software debounce routine into the GpioInput class however, that would be a tutorial on its own.

Thanks for reading.

Similar Posts