When working with microcontrollers, the ultimate goal is typically to have the system you’re designing interact with the world around it in some fashion. These interactions often take place through the use of a variety of inputs and outputs to and from your microcontroller. Fortunately, most microcontrollers come with a set of features called General Purpose Input/Ouput (GPIO). These features enable you to interface with and control a broad range of peripherals and components such as sensors, actuators, and more! In this tutorial, we will cover some basics you should know for working with GPIO on your microcontroller.

Pin Diagram

We are going to begin our discussion of GPIO with an overview of the connections on the HUZZAH32 Feather microntroller board to better understand its capabilities. Take a look at Figure 1 showing the pin diagram for our microcontroller.

Figure 1. HUZZAH32 Feather microcontroller board pinout.

There are a ton of things to talk about here with regard to this pin diagram, but let’s cover some of the essentials first. At the top of the board, we have a micro USB connector, which we can use to both power and program the device from a computer. In the top right corner, there is a black connector for an optional lithium battery for autonomous operation (i.e. when not connected to a computer or other master device).

The remainder of the connections (numbered 1-28 on the diagram) are arrays of “pins”, or electrical connections, that provide a range of functions, which includes mostly GPIO. Your version of the HUZZAH32 Feather board should have what are referred to as “stacking headers” that allow you to plug directly into these pins with jumper wires.

Let’s cover the pins that are dedicated exclusively for power.

NameFunction
3.3V (3V)3.3V output from the on-board regulator. Can supply up to 500mA.
GND (GND)Common ground for power and logic.
VUSB (USB)Positive voltage from USB jack, if connected. ~5V.
VBAT (BAT)Positive voltage from the backup Lipoly battery, if connected. ~3.7V.
EN 3.3V (EN)3.3V regulator enable pin. Tie to ground to disable.
RESET (RST)Reset, tie to GND to reset (or press Reset button).

As mentioned earlier, you can opt to power the device through the USB connector from your computer or through an external LiPo battery. Your board also has the potential to be powered by an external power supply, but Adafruit discourages this practice due to board limitations.

Power can be supplied from your device to peripheral components using a range of options. The first option is to leverage the onboard $3.3V$ regulator through the 3.3V pin. If not being used, the $3.3V$ regulator can be disabled by pulling the EN 3.3V pin low. Another option for powering components is to draw power directly from your external battery by connecting to VBAT. Finally, you have the option to power external components from the USB connection through the VUSB pin.

The RESET pin let’s you manually reset the board by connecting it to GND. This is the same as pressing the reset button on the board itself. We will learn more about powering options later on! For now, let’s continue on to the remaining GPIO pins.

Notice that we have labels for each of the remaining pins that fall within the categories of “GPIO”, “ALT”, and “ID”. The “GPIO” label corresponds to the number of the pin in the official documentation of the ESP32 from EspressIf. The “ID” column shows the primary function of the given pin, and the “ALT” column applies to alternative pin functions that can be accessed if their primary function is not being used.

Most pins can be configured by software to perform one of several functions, depending on the need of a particular application. This allows for great flexibility: the same microcontroller can be used in many projects with different peripherals. We will discuss the meaning behind the various function names as they become relevant.

Also, keep in mind that processes for accessing and using the various pins on your board will change depending on your use of MicroPython or Arduino to program functions. We will provide examples of both implementations where possible.

WARNING: The pins of the ESP32 chip are rated for 3.3V maximum. Exceeding this value may damage the chip.

Digital Signals

The basic unit of information in digital systems is bits. Each bit can assume one of two values, e.g. $0$ or $1$, or, equivalently True or False. Values that require more resolution or precision are represented by combining several bits. These collections of bots represent integers, floating point numbers, strings, or more complex data structures.

In the hardware, the state of each bit is represented by voltage. Voltages near $0$ (or ground) are interpreted as a logical $0$ (LOW or False), while voltages near the supply (in our case $3.3V$) are interpreted as a logical $1$ (HIGH or True). This results in great robustness against signal cirruption in the presence of interference: unless the value of a digital signal approaches half the supply voltage, it is always correctly interpreted as $0$ or $1$.

So how are digital signals used in the context of GPIO on a microcontroller? Well, lets start with digital inputs.

Digital Inputs

Probably the most obvious example of a digital input function that you would want to design into your embedded system is that of a button. Using digital input, it is easy to integrate a button that controls some aspect of operation in your system.

First, we should discuss how to configure a given GPIO pin to accept digital inputs. Refer back to Figure 1 and note that any of the pins not dedicated to power can be used as digital inputs.

Here is how we configure a GPIO pin to be a digital input using MicroPython:

from machine import Pin
p = Pin(id, mode=Pin.IN)
p()   # 0 if voltage is close to 0V
      # 1 if voltage is close to VDD (3.3V)

And here is how to do it in Arduino:

pinMode(id, INPUT);
p = digitalRead(id);
p;  // 0 if voltage is close to 0V
    // 1 if voltage is close to VDD (3.3V)

In MicroPython, we start by importing the correct package on line 1. On line 2, we assign a Pin object, give it the pin identifier in id (note that you will have to import this pin identifier as well, e.g. A0), and assign it to be in Pin.IN mode (input mode).

In Arduino, we can go straight to assigning a pin and mode using pinMode. We can then read from the pin by it’s id.

Optionally, the ESP32 offers internal pull-up and pull-down resistors that can be enabled in the mode argument. Note that this feature is not available on pins A2, A3, A4.

from machine import Pin
p = Pin(id, mode=Pin.IN, pull=<None|Pin.PULL_UP|Pin.PULL_DOWN>)
pinMode(id, <INPUT_PULLUP|INPUT_PULLDOWN>);

The values of the pull-down and pull-up resistors vary from chip-to-chip and pin-to-pin and for the ESP32 are typically in the range of $R_{up}=30…80k\Omega$ and $R_{down}=17k\Omega$.

Figure 2 shows a simplified circuit diagram. Switches $S_1$ and $S_2$ are closed if the modes are set accordingly, connecting the corresponding resistor to the input pin $D_{in}$. The triangle represents an amplifier that restores correct digital levels ($0V$ or $V_{DD}$) if a mid-supply input is applied.

Figure 2. Simplified circuit diagram of a digital input.

Pull-ups and pull-downs are very practical when reading e.g. the state of a switch or button. Figure 3 shows an example. One terminal of the Button is connected to a GPIO input, and the other is connected to ground. Pressing the button connects $D_{IN}$ to ground, thus establishing a clear logic 0. However, when the button is released, $D_{IN}$ is disconnected and its state is undefined. Enabling the pull-up results in $D_{IN}=V_{DD}$ when the button is not pressed, hence establishing a logic 1. The pull-down resistor is disabled and not shown in the diagram.

Figure 3. Circuit for detecting button presses with a GPIO input.

Figure 4 shows a typical waveform when reading a mechanical switch or button. Rather than simply switching on or off, the state changes rapidly between the two states until finally settling. It is caused by the spring effect of the contact. Frequently this is not a problem. For example, when turning lights on or off, short periods of “flicker” are not noticeable.

Figure 4. Button bouncing signal.

Computers are much faster than humans and detect these changes, typically occurring over a period of a few milliseconds, as individual events. This is problematic for example when counting button presses: the operator presses the button only once, but the machine detects several presses. Imagine a candy vending machine distributing a dozen treats rather than just one.

The simplest solution is to explicitly program the MCU to ignore presses for a short period, e.g. $50ms$, after a change is detected. Then only the first change is registered, the “bounce” events in the figure are ignored.

Digital Outputs

Let’s move on to digital outputs! Digital outputs are frequently used to control ON and OFF states for peripheral components such as LEDs. All pins A0A21 except A2, A3, A4 can be used for digital output.

from machine import Pin
p = Pin(id, mode=Pin.OUT)
p(0)    # Write digital pin to LOW
p(1)    # Write digital pin to HIGH
pinMode(id, OUTPUT);
digitalWrite(id, LOW);    // Write digital pin to LOW
digitalWrite(id, HIGH);   // Write digital pin to HIGH

In the case of MicroPython, you can see that we are using the Pin.OUT mode, allowing us to write a digital LOW with p(0) and HIGH with p(1).

On the other hand, Arduino uses the digitalWrite command to control the pin to either a LOW or HIGH output.

Figure 5 shows a simplified diagram of the circuit for a standard digital output used in microcontrollers. It consists of two switches, $S_1$ and $S_2$. The switches are actually transistors, but the difference is not relevant for this explanation. Calling the appropriate function to set the digital output will open one switch and close the other. As a result, we get either a $3.3V$ or ground connection at $D_{OUT}$.

Figure 5. Circuit diagram for a standard digital output.

Open Drain Output

As another option for digital signals, we can configure output pins as open drains. When configured as open drains, the corresponding pin is pulled low (i.e. tied to ground) when set to 0, and open circuited (i.e. not connected) when set to 1. The circuit is identical to that shown in Figure 5, except switch $S_1$ never closes.

from machine import Pin
p = Pin(id, mode=Pin.OPEN_DRAIN)
p(0)  # pin driven to 0V
p(1)  # pin open (not driven)

In order to accomplish the same thing in Arduino, you’ll need to rely on EspressIf’s ESP-IDF API functions that we added to Arduino. If you run into issues, refer back to the steps of how to install ESP-IDF packages in Arduino.

gpio_set_direction(id, GPIO_MODE_OUTPUT_OD);
gpio_set_level(id, 0);  // Set pin to no connect
gpio_set_level(id, 1);  // Set pin to open drain to ground

The code shown here essentially uses the same structure with different function calls and type specifiers that are specific to the ESP-IDF framework.

Analog Signals

Analog signals refer to any signal that varies continuously over a time interval. This is in contrast to digital signals that only take two discrete values. Examples of applications of analog signals include sensor readings, radio communication, audio amplification, and more.

Since analog signals vary continuously, an analog signal at a given instant can take theoretically an infinite number of values within the parameters of the system. As an example, imagine that an electrical component in your system outputs an analog signal with a voltage that varies between $0$ and $1$. The number of values that can be represented between $0$ and $1$ is infinite and only dependent on the resolution with which the signal can be generated and read. First, we will examine the way that we read analog signals as inputs.

Analog Inputs

If analog signals are continuous, how do we get our microcontroller to read and represent an analog input? Well, our microcontrollers operate in the digital domain; therefore, we must approximate analog signals with a discrete representation. Microcontrollers do this through the use of an analog-to-digital converter (ADC).

The ESP32 chip contains two 12-Bit ADCs. In the HUZZAH32 Feather board, we are usually limited to using only one of the two ADCs. The second ADC is disabled when the built-in WiFi is activated.

The ADC output code for a given input voltage $V_{in}$ can be represented with the following equation.

$$D_{out}=round\left(2^{B}*\frac{V_{in}}{V_{ref}}\right)$$

where $B$ is the number of bits of the ADC. In the case of the ESP32, this value is $12$. From a bit resolution perspective, this means that we can represent a total of $4096$ distinct values from our ADC. However, the ESP32 also gives us the option to change the scaling with $V_{ref}$. Let’s look at an example.

from machine import Pin, ADC

adc0 = ADC(Pin(ADC0))
adc0.atten(ADC.ATTN_0DB)  # set full-scale range

d_out = adc0.read()       # perform conversion
analogSetAttenuation(ADC_0db);  // Set full-scale range

val = analogRead(id);           // Perform conversion

Note that the ADC defaults to full-scale range in the case that you do not set the attenuation manually. The following table shows the possible attenuation settings and their associated specifiers.

Configuration parameter (MicroPython)Configuration parameter (Arduino)$V_{ref}$
ADC.ATTN_0DBADC_0db$1.1V$
ADC.ATTN_2_5DBADC_2_5db$1.3V$
ADC.ATTN_6DBADC_6db$1.8V$
ADC.ATTN_11DBADC_11db$3.2V$

Unfortunately, the ESP32 chip ADCs suffer from notoriously inconsistent performance, which includes a large offset and poor linearity. As a result, it may be in your best interest to use external ADC ICs in the case that you need reliably accurate analog input measurements. See the measurement results below.

Figure 6. ADC output values versus input voltage for different attenuations.
Figure 7. ADC offset for different attenuations.
Figure 8. ADC INL for 6dB attenuation.

Analog Outputs

What if we want to go in the opposite direction and output an analog signal from our microcontroller? This might be useful if we needed to control the brightness of an LED or the position of an analog readout. Generally, however, powered analog signals like those used to control actuators are handled by external power equipment to protect the microcontroller.

As you might have guessed, analog outputs are handled by what is known as a digital-to-analog converter (DAC). Similar to the ADC, we have an equation that we can use to determine the output of our DAC.

$$V_{out}=\frac{D_{in}}{2^{B}}*V_{DD}$$

where $B$ is the number of bits of the DAC. The DAC on the ESP32 has 8-Bit resolution (i.e. 256 distinct values). The reference voltage for our DAC is taken from the supply voltage, $V_{DD}$ (nominally $3.3V$).

And here, we take a look at an implementation in both MicroPython and Arduino.

from machine import Pin, DAC

dac1 = DAC(Pin(id))

dac1.write(128)   # perform conversion (8-bit DAC)
dacWrite(DAC1, 128);  // perform conversion (8-bit DAC)

Leave a Comment.