Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 16

Digital I/O Basics

Recommended resources

Hello! Welcome back to our C++ for ESP32 course.

In the last lesson, we laid the groundwork by learning how to configure GPIO pins. You saw how to use the gpio_config_t struct to set a pin's direction (input or output) and to enable internal pull-up resistors. This configuration step is essential before a pin can be used.

Now that we know how to set up the pins, it's time to put them to work. This lesson will teach you how to read the state of a digital input and how to control the state of a digital output. By the end of this session, you'll be able to write a complete program that reads a button press and lights up an LED in response—a "Hello, World!" for the physical world. We'll start with the fundamental ESP-IDF C functions and then, true to the goal of our course, see how to wrap this functionality within C++ classes for a cleaner, more robust interface.

1. Writing Digital Outputs

Once a GPIO pin is configured as an output, you can control the voltage on that pin, setting it to either HIGH (3.3V) or LOW (0V). This is how you control LEDs, relays, and provide signals to other digital devices.

The ESP-IDF function for this is gpio_set_level().

GPIO & RTC GPIO - API Reference

Let's first consult the official ESP-IDF documentation. This is the definitive source for API function details.

In the 'Functions' list, find gpio_set_level(). Take a look at its two parameters: gpio_num and level. Note that level is a simple integer: 1 for HIGH and 0 for LOW. Also, notice it returns an esp_err_t, allowing for error checking.

As you saw, the function is straightforward: gpio_set_level(GPIO_NUM_X, 1); sets pin X to HIGH.

Now let's see this in a practical demonstration. The following video will walk you through writing a simple "blink" program. It uses a slightly different configuration method than we learned (multi-pin template), but the core of the output logic uses gpio_set_level.

Getting Started with GPIO | Esp32 | C++ | PlatformIO | ESP-IDF | Visual Studio Code | ESP IDF C++

This video from AbIoTs Vidya demonstrates writing a simple blink program. It will reinforce the use of gpio_set_level to control an LED.

First, quickly watch the segment from 14:44 to 15:20, which introduces the gpio_set_level function. Then, jump to the blink program example from 21:18 to 24:00. Focus on how gpio_set_level(..., 1) and gpio_set_level(..., 0) are used inside the loop to turn the LED on and off.

Here is a simplified code snippet that demonstrates the core logic for blinking an LED connected to GPIO_NUM_2:

#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

// Assume configure_led() from the previous lesson has been called.

void blink_task(void *pvParameter)
{
    while(1) {
        // Turn the LED on (HIGH is the voltage level)
        gpio_set_level(GPIO_NUM_2, 1);
        vTaskDelay(500 / portTICK_PERIOD_MS); // Wait for 500ms

        // Turn the LED off (LOW is the voltage level)
        gpio_set_level(GPIO_NUM_2, 0);
        vTaskDelay(500 / portTICK_PERIOD_MS); // Wait for 500ms
    }
}

Note: The vTaskDelay function is part of FreeRTOS, the operating system underneath the ESP-IDF. It's the proper way to create delays without halting the entire CPU. We'll cover FreeRTOS in detail in a later module.


2. Reading Digital Inputs

Reading a digital input involves checking the voltage on a pin to see if it's HIGH or LOW. This is how you detect a button press, read a simple switch, or get a signal from another digital device.

The ESP-IDF function for this is gpio_get_level().

GPIO & RTC GPIO - API Reference

Let's return to the official documentation to look up the function for reading an input.

Find gpio_get_level() in the function list. Note its single parameter (gpio_num) and its return type (int). It will return 1 if the input level is HIGH and 0 if it's LOW. Pay attention to the warning: if the pin isn't configured as an input, it will always return 0.

A common application for digital input is reading a pushbutton. For a reliable reading, the pin must have a defined state when the button is not pressed. This is achieved with a pull-up or pull-down resistor, which we configured in the last lesson.

The circuit below shows a typical setup. A pull-down resistor (R1) holds the input at a defined LOW state. When the button is pressed, the input is connected to 3.3V and reads HIGH. (Alternatively, a pull-up resistor would hold it HIGH, and pressing the button would connect it to ground, making it read LOW).

ESP32 Digital Input-Output with Pushbutton and LED Circuit Diagram
A typical circuit for testing digital I/O. The pushbutton (input) is connected to a GPIO pin with a pull-down resistor to ground. The LED (output) is connected to another GPIO pin with a current-limiting resistor.

Let's watch a demonstration of reading an input and using it to control an output.

Getting Started with GPIO | Esp32 | C++ | PlatformIO | ESP-IDF | Visual Studio Code | ESP IDF C++

This video segment demonstrates reading from a sensor (which acts as a digital input) and using that value to control the onboard LED.

Watch the segment from 26:22 to 30:30. The code reads the digital output of an LDR sensor module using gpio_get_level(). If the level is HIGH, it uses gpio_set_level() to turn on the onboard LED; otherwise, it turns it off. This is a perfect example of combining input and output.

The core logic to read a button on GPIO_NUM_4 and control an LED on GPIO_NUM_2 would look like this:

#include "driver/gpio.h"

// Assume configure_button() and configure_led() have been called.

void read_button_and_control_led() {
    // Read the logic level of the input pin
    int button_state = gpio_get_level(GPIO_NUM_4);

    if (button_state == 1) {
        // Button is pressed, turn LED on
        gpio_set_level(GPIO_NUM_2, 1);
    } else {
        // Button is not pressed, turn LED off
        gpio_set_level(GPIO_NUM_2, 0);
    }
}
Test your understanding!

Imagine you've configured GPIO_NUM_12 as an input with a pull-up resistor. The button is wired to connect this pin to Ground (0V) when pressed. How would you modify the if condition in the code above to correctly turn the LED on only when the button is pressed?

Show answer

With a pull-up resistor, the default state is HIGH (1). Pressing the button pulls the pin LOW (0). Therefore, a "pressed" state corresponds to a reading of 0.

// ...
int button_state = gpio_get_level(GPIO_NUM_12);

// The condition is inverted
if (button_state == 0) {
    // Button is pressed, turn LED on
    gpio_set_level(GPIO_NUM_2, 1);
} else {
    // Button is not pressed, turn LED off
    gpio_set_level(GPIO_NUM_2, 0);
}

This "active-low" configuration is very common in embedded systems.


3. The C++ Approach: Encapsulation

We've now seen the C-style functions gpio_set_level() and gpio_get_level(). They work, but calling them directly scatters low-level hardware details throughout your application logic. A much cleaner approach in C++ is to encapsulate this behavior within the GpioOutput and GpioInput classes we began designing in the last lesson.

Let's look at how to implement the methods for these classes.

ESP32 GPIO in C++ Part 1 - EmbeddedTutorials

The EmbeddedTutorials guide provides a great example of implementing C++ wrapper classes. We will now study the implementation of the methods that perform the actual reading and writing.

First, read the subsection 'Implement the methods for the Output Class'. Pay close attention to how methods like on(), off(), and toggle() are simple wrappers around gpio_set_level(). Next, read the subsection 'Implement the Read Method' under 'Implement the methods for the Input Class'. Notice how the read() method simply calls and returns the value from gpio_get_level(). These examples show a clear and powerful pattern of wrapping C APIs in C++ methods.

By implementing these classes, you transform your application code from this:

// C-style
int state = gpio_get_level(GPIO_NUM_12);
if (state == 0) {
    gpio_set_level(GPIO_NUM_2, 1);
} else {
    gpio_set_level(GPIO_NUM_2, 0);
}

...into something far more readable and object-oriented:

// C++ style
#include "cppgpio.h" // Our custom header

CPPGPIO::GpioOutput led(GPIO_NUM_2);
CPPGPIO::GpioInput button(GPIO_NUM_12, true); // Pin 12, active-low

// ... in a loop ...
if (button.read() == 1) { // The class handles the active-low inversion
    led.on();
} else {
    led.off();
}

// Or even more concisely:
led.setLevel(button.read());

This C++ approach provides several key advantages:

  • Readability: led.on() is more descriptive than gpio_set_level(GPIO_NUM_2, 1).
  • Abstraction: The application code doesn't need to know about gpio_set_level or gpio_get_level. The implementation is hidden inside the class.
  • Safety: The class can handle details like active-low logic internally, so the user of the class can simply ask if (button.is_pressed()) without worrying about whether that means a 0 or a 1 at the hardware level.
  • Resource Management (RAII): The class constructor handles the pin configuration (gpio_config), and a destructor (which we'll implement later) could handle cleanup. The resource's lifetime is tied to the object's lifetime.

Conclusion

In this lesson, you've bridged the gap between configuring a pin and using it to interact with the world. You now have the complete set of fundamental skills for basic digital I/O on the ESP32.

Here are the key takeaways:

  • Writing Outputs: The gpio_set_level(pin, level) function sets an output pin to HIGH (1) or LOW (0).
  • Reading Inputs: The gpio_get_level(pin) function returns the current digital state (1 or 0) of an input pin.
  • The C++ Wrapper: Encapsulating these C functions within C++ class methods (led.on(), button.read()) is a core practice for writing robust, readable, and maintainable embedded C++ code. This abstracts away the low-level details and provides a cleaner interface for your application.

In our next lesson, we'll take a deeper dive into the internal pull-up and pull-down resistors. While we've already used them for configuration, understanding them more thoroughly is key to designing reliable input circuits for buttons and switches, and for interfacing with different types of sensors.