Electronics Design AU
ESP32

How Do You Use GPIO, ADC, and Timers on the ESP32?

Last updated 28 June 2026 · 10 min read

Direct Answer

In ESP-IDF, configure GPIO with gpio_config() (sets direction, pull-up/down, and interrupt type in one call), read ADC channels using the adc_oneshot API (ESP-IDF v5.x) with adc_cali_create_scheme_curve_fitting() for calibrated millivolt output, and create periodic timers with esp_timer_start_periodic() for task-context callbacks or gptimer for ISR-precision hardware timers. Key limitations: GPIO34–GPIO39 are input-only; ADC2 cannot be used while Wi-Fi is active — use ADC1 channels (GPIO32–GPIO39) for sensor readings in Wi-Fi applications.

Detailed Explanation

GPIO, ADC, and hardware timers are the three peripheral categories most firmware engineers reach for first on the ESP32. This page covers the ESP-IDF v5.x driver API for each, with working code examples and the ESP32-specific constraints that cause the most problems in practice.

All code examples use ESP-IDF v5.x. The ESP-IDF v4.x ADC API (driver/adc.h) and legacy timer API (driver/timer.h) differ significantly; if you are on v4.x, consult the Espressif documentation for the legacy equivalents. For new designs, target ESP-IDF v5.x.


GPIO

Pin Capabilities and Constraints

The classic ESP32 has 34 GPIO pins (GPIO0–GPIO39), but not all are equivalent:

  • GPIO0, GPIO2, GPIO5, GPIO12, GPIO15 — strapping pins that control boot mode (UART download vs SPI flash boot). GPIO0 is pulled low by the programming circuit during flashing. Avoid using these as general I/O unless you control the boot sequence carefully; at minimum, ensure their state at power-up does not trigger unintended boot modes.
  • GPIO34–GPIO39 — input-only pins with no internal pull-up or pull-down resistors and no output driver. Safe to use for ADC, digital input from driven sources, or signals that are always actively driven.
  • GPIO6–GPIO11 — connected to the internal SPI flash on most ESP32 modules. Never use these for general purpose I/O.
  • GPIO1, GPIO3 — UART0 TX and RX. Typically used by idf.py monitor for console output; available as GPIO if the serial console is redirected.

The ESP32 includes a GPIO matrix — a software-programmable crossbar that connects peripheral signals (UART, SPI, I2C, PWM, etc.) to any GPIO pin. This provides significant PCB routing flexibility, but some peripherals offer a faster IOMUX path for specific pin assignments that bypasses the matrix.

Configuring GPIO with gpio_config()

gpio_config() applies a full configuration — direction, pull resistors, and interrupt type — to one or more pins simultaneously using a bitmask.

#include "driver/gpio.h"

/* Configure GPIO2 as output, no pull resistors, no interrupt */
const gpio_config_t output_cfg = {
    .pin_bit_mask = (1ULL << GPIO_NUM_2),
    .mode         = GPIO_MODE_OUTPUT,
    .pull_up_en   = GPIO_PULLUP_DISABLE,
    .pull_down_en = GPIO_PULLDOWN_DISABLE,
    .intr_type    = GPIO_INTR_DISABLE,
};
gpio_config(&output_cfg);
gpio_set_level(GPIO_NUM_2, 1); /* Drive high */
gpio_set_level(GPIO_NUM_2, 0); /* Drive low  */

/* Configure GPIO4 as input with internal pull-up, edge interrupt */
const gpio_config_t input_cfg = {
    .pin_bit_mask = (1ULL << GPIO_NUM_4),
    .mode         = GPIO_MODE_INPUT,
    .pull_up_en   = GPIO_PULLUP_ENABLE,
    .pull_down_en = GPIO_PULLDOWN_DISABLE,
    .intr_type    = GPIO_INTR_NEGEDGE, /* Trigger on falling edge */
};
gpio_config(&input_cfg);
int level = gpio_get_level(GPIO_NUM_4); /* 0 or 1 */

See How Do GPIO Pins Work? for the concepts behind pull-up resistors, push-pull vs open-drain output modes, and output drive levels.

GPIO Interrupts

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

static QueueHandle_t gpio_event_queue;

/* ISR runs in interrupt context — keep it minimal */
static void IRAM_ATTR gpio_isr_handler(void *arg)
{
    uint32_t gpio_num = (uint32_t)arg;
    /* Send to queue; do not do any processing here */
    xQueueSendFromISR(gpio_event_queue, &gpio_num, NULL);
}

void gpio_interrupt_init(void)
{
    gpio_event_queue = xQueueCreate(10, sizeof(uint32_t));

    /* Install the GPIO ISR service (once per application) */
    gpio_install_isr_service(0);

    /* Attach ISR handler to GPIO4 */
    gpio_isr_handler_add(GPIO_NUM_4, gpio_isr_handler, (void *)GPIO_NUM_4);
}

void gpio_event_task(void *pvParameters)
{
    uint32_t gpio_num;
    while (1) {
        if (xQueueReceive(gpio_event_queue, &gpio_num, portMAX_DELAY)) {
            /* Process the GPIO event in task context */
            ESP_LOGI("GPIO", "Edge detected on GPIO %lu", gpio_num);
        }
    }
}

ISR constraints: GPIO interrupt callbacks (and all ISRs) execute with the FreeRTOS scheduler suspended on that core. Do not call standard FreeRTOS APIs (semaphores, queues, delays) from an ISR without the FromISR suffix. The IRAM_ATTR attribute ensures the ISR is loaded into IRAM and is accessible during flash cache misses.


ADC

ESP32 ADC Architecture

The classic ESP32 has two 12-bit SAR ADCs with a total of 18 channels:

  • ADC1: 8 channels on GPIO32–GPIO39. Always use ADC1 for sensor readings in Wi-Fi or BLE applications — ADC1 is independent of the radio hardware.
  • ADC2: 10 channels on GPIO0, GPIO2, GPIO4, GPIO12–GPIO15, GPIO25–GPIO27. Cannot be used while Wi-Fi or BLE is active. Reading ADC2 when the radio is active returns ESP_ERR_TIMEOUT in ESP-IDF v5.x.

The ADC input range is controlled by attenuation:

AttenuationEffective input range (typical)
ADC_ATTEN_DB_00–750 mV
ADC_ATTEN_DB_2_50–1050 mV
ADC_ATTEN_DB_60–1300 mV
ADC_ATTEN_DB_110–2500 mV

These are typical values from Espressif's ESP32 datasheet; exact upper limits vary slightly by chip revision and supply voltage. Never apply a voltage above 3.3 V (VDD) to an ADC pin.

Reading ADC with Calibration (ESP-IDF v5.x)

The ESP32 ADC has significant non-linearity, particularly near the extremes of each attenuation range. Use the calibration API to convert raw counts to millivolts:

#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"

/* ADC unit and calibration handles */
static adc_oneshot_unit_handle_t adc1_handle;
static adc_cali_handle_t adc1_cali_handle;

void adc_init(void)
{
    /* Initialise ADC1 */
    adc_oneshot_unit_init_cfg_t init_config = {
        .unit_id  = ADC_UNIT_1,
        .ulp_mode = ADC_ULP_MODE_DISABLE,
    };
    adc_oneshot_new_unit(&init_config, &adc1_handle);

    /* Configure ADC1 channel 6 (GPIO34) for 0–2.5 V range */
    adc_oneshot_chan_cfg_t chan_config = {
        .atten    = ADC_ATTEN_DB_11,
        .bitwidth = ADC_BITWIDTH_12,
    };
    adc_oneshot_config_channel(adc1_handle, ADC_CHANNEL_6, &chan_config);

    /* Create calibration scheme (curve fitting preferred if eFuse calibration data present) */
    adc_cali_curve_fitting_config_t cali_config = {
        .unit_id  = ADC_UNIT_1,
        .chan     = ADC_CHANNEL_6,
        .atten    = ADC_ATTEN_DB_11,
        .bitwidth = ADC_BITWIDTH_12,
    };
    esp_err_t ret = adc_cali_create_scheme_curve_fitting(&cali_config, &adc1_cali_handle);
    if (ret != ESP_OK) {
        /* Fall back to line fitting if curve fitting eFuse data is not present */
        adc_cali_line_fitting_config_t lf_config = {
            .unit_id  = ADC_UNIT_1,
            .atten    = ADC_ATTEN_DB_11,
            .bitwidth = ADC_BITWIDTH_12,
        };
        adc_cali_create_scheme_line_fitting(&lf_config, &adc1_cali_handle);
    }
}

int adc_read_mv(void)
{
    int raw;
    adc_oneshot_read(adc1_handle, ADC_CHANNEL_6, &raw);

    int voltage_mv;
    adc_cali_raw_to_voltage(adc1_cali_handle, raw, &voltage_mv);
    return voltage_mv;
}

Hardware averaging: The adc_oneshot_read() API takes one sample. For noisy signals (NTC thermistors, unfiltered analog sensors), take multiple samples and average them:

int adc_read_averaged_mv(int num_samples)
{
    int sum = 0;
    int raw;
    for (int i = 0; i < num_samples; i++) {
        adc_oneshot_read(adc1_handle, ADC_CHANNEL_6, &raw);
        sum += raw;
    }
    int avg_raw = sum / num_samples;
    int voltage_mv;
    adc_cali_raw_to_voltage(adc1_cali_handle, avg_raw, &voltage_mv);
    return voltage_mv;
}

For sensor signal conditioning beyond the ADC itself — anti-aliasing filters, op-amp gain stages, and 4–20 mA conversion — see Sensor Signal Conditioning Basics.

ADC Hardware Recommendations

  • Add a 100 nF ceramic capacitor between each ADC input pin and GND close to the ESP32 module. This filters switching noise from the GPIO matrix and nearby digital signals that couples onto ADC input traces.
  • Use GPIO34–GPIO39 for ADC channels. These are input-only pins with no output driver, reducing noise from the drive circuit.
  • Keep ADC input traces short and away from high-current switching traces (buck converter SW node, motor drive lines).

Timers

ESP-IDF provides three timer abstractions at different levels of precision and complexity:

esp_timer (Recommended for Most Use Cases)

esp_timer provides 1 µs resolution timers backed by a hardware counter. Callbacks execute in a dedicated esp_timer FreeRTOS task — not in an ISR — so you can use standard FreeRTOS APIs, log output, and heap allocation freely in the callback.

#include "esp_timer.h"

static void sensor_timer_cb(void *arg)
{
    /* Runs every 500 ms in esp_timer task context */
    int mv = adc_read_averaged_mv(8);
    ESP_LOGI("Timer", "Sensor: %d mV", mv);
}

esp_timer_handle_t sensor_timer;

void timer_init(void)
{
    const esp_timer_create_args_t timer_args = {
        .callback = sensor_timer_cb,
        .name     = "sensor",
    };
    esp_timer_create(&timer_args, &sensor_timer);
    esp_timer_start_periodic(sensor_timer, 500000); /* 500 000 µs = 500 ms */
}

void timer_stop(void)
{
    esp_timer_stop(sensor_timer);
    esp_timer_delete(sensor_timer);
}

esp_timer timers are one-shot (esp_timer_start_once) or periodic (esp_timer_start_periodic). Multiple timers can be active simultaneously. The esp_timer task has a configurable priority; if callbacks are longer than the timer period, subsequent firings are deferred rather than accumulated.

GPTimer (Hardware Precision, ISR Context)

gptimer provides direct access to a hardware general-purpose timer, with alarm callbacks that run in ISR context. Use this when the timing requirement is tighter than esp_timer's task-scheduling jitter allows.

#include "driver/gptimer.h"
#include "freertos/semphr.h"

static SemaphoreHandle_t timer_sem;

static bool IRAM_ATTR gptimer_alarm_cb(gptimer_handle_t timer,
                                        const gptimer_alarm_event_data_t *edata,
                                        void *user_ctx)
{
    BaseType_t high_task_awoken = pdFALSE;
    /* Signal a waiting task from ISR — do not process data here */
    xSemaphoreGiveFromISR(timer_sem, &high_task_awoken);
    return high_task_awoken == pdTRUE; /* Request context switch if needed */
}

void gptimer_init(void)
{
    timer_sem = xSemaphoreCreateBinary();

    gptimer_handle_t gptimer;
    const gptimer_config_t timer_config = {
        .clk_src      = GPTIMER_CLK_SRC_DEFAULT,
        .direction    = GPTIMER_COUNT_UP,
        .resolution_hz = 1000000, /* 1 MHz → 1 µs per tick */
    };
    gptimer_new_timer(&timer_config, &gptimer);

    const gptimer_event_callbacks_t cbs = {
        .on_alarm = gptimer_alarm_cb,
    };
    gptimer_register_event_callbacks(gptimer, &cbs, NULL);

    const gptimer_alarm_config_t alarm_config = {
        .alarm_count              = 10000, /* 10 000 µs = 10 ms */
        .reload_count             = 0,
        .flags.auto_reload_on_alarm = true,
    };
    gptimer_set_alarm_action(gptimer, &alarm_config);
    gptimer_enable(gptimer);
    gptimer_start(gptimer);
}

FreeRTOS Software Timers

FreeRTOS timers (xTimerCreate) tick at the RTOS tick rate (typically every 10 ms) and are suitable for coarse periodic tasks like LED blinking, debounce timeouts, and watchdog refresh. They are simpler than esp_timer but have lower resolution.

Timer Comparison

Timer typeResolutionCallback contextBest for
FreeRTOS xTimerCreate1 tick (typically 10 ms)Timer daemon taskCoarse events: debounce, LED patterns, watchdog
esp_timer1 µsesp_timer taskPeriodic sensor reads, timeouts requiring µs resolution
gptimer1 / resolution_hzISRHardware-precise capture/compare, waveform generation

For PWM output on the ESP32 (servo control, LED dimming, motor speed), use the LEDC (LED Control) peripheral via ledc_channel_config() and ledc_timer_config() — this is separate from the general-purpose timer API and provides up to 16 independent PWM channels across the available GPIO pins.


Common Mistakes

  • Using ADC2 channels in a Wi-Fi application. ADC2 shares hardware with the Wi-Fi RF front end. Any project that uses Wi-Fi must use ADC1 channels (GPIO32–GPIO39) exclusively for sensor readings. This is an architecture-level constraint — swapping ADC channels later often requires a hardware revision if the original schematic routed sensors to GPIO0/2/4/12–15.
  • Omitting IRAM_ATTR from GPIO ISR functions. The ESP32 executes application code from external flash via the instruction cache. A flash cache miss during an ISR causes a crash (LoadProhibited exception) if the ISR function is not in IRAM. Always apply IRAM_ATTR to ISR callbacks to ensure they remain accessible during cache misses.
  • Calling FreeRTOS APIs without the FromISR suffix inside an ISR. xQueueSend() and xSemaphoreGive() must not be called from interrupt context — they may block. Use xQueueSendFromISR() and xSemaphoreGiveFromISR() instead, and pass the pxHigherPriorityTaskWoken parameter so the RTOS can schedule a context switch on ISR exit.
  • Not adding a filter capacitor on ADC inputs. ADC input traces near digital GPIO lines, switching supplies, or the ESP32 RF section pick up noise that appears as ADC jitter. A 100 nF ceramic capacitor from the ADC pin to GND, placed close to the ESP32 module, is the minimum mitigation for most designs.
  • Using esp_timer when ISR-level precision is required. esp_timer callbacks run in a FreeRTOS task. If other high-priority tasks are running, callbacks fire after their scheduled time — jitter of tens of microseconds is typical. For precision waveform generation or control loops requiring µs-level determinism, use gptimer with an ISR callback.

Design Considerations

  • ADC2 vs Wi-Fi conflict is the single most common ESP32 ADC mistake. If your application uses Wi-Fi or BLE at any point, commit to ADC1 channels from the start — changing ADC channel assignments later often requires hardware revision.
  • Strapping pin management: document GPIO0, GPIO2, GPIO5, GPIO12, and GPIO15 usage explicitly in your schematic. A floating GPIO0 or GPIO2 at power-on can cause the ESP32 to enter UART download mode instead of booting from flash. Add a 10 kΩ pull-up to GPIO0 in designs where a USB-to-UART programmer is not permanently connected.
  • Deep sleep compatibility: GPIO interrupt wake sources from deep sleep use the RTC GPIO API (rtc_gpio_*), not the standard GPIO driver. Only a subset of pins support RTC GPIO — see How Do You Manage Power and Use Deep Sleep on the ESP32? for the GPIO wake source configuration.
  • For ESP32 firmware development including sensor integration, ADC calibration, GPIO interrupt handling, and FreeRTOS task architecture, Zeus Design delivers production-ready firmware for IoT and embedded products.

Frequently Asked Questions

Why does my ESP32 ADC reading stop working when Wi-Fi connects?
ADC2 is shared hardware with the Wi-Fi RF analog front end. When Wi-Fi is active, the RF radio occupies ADC2, and any attempt to read an ADC2 channel returns an error (ESP_ERR_TIMEOUT in ESP-IDF v5.x) or an incorrect value. The fix is to use ADC1 channels exclusively for sensor readings in Wi-Fi applications. ADC1 channels are on GPIO32–GPIO39 for the classic ESP32 variant. ADC2 channels (GPIO0, GPIO2, GPIO4, GPIO12–GPIO15, GPIO25–GPIO27) are only available when Wi-Fi and BLE are both inactive.
What is the maximum safe input voltage on an ESP32 GPIO or ADC pin?
ESP32 GPIO and ADC inputs are not 5 V tolerant. The maximum voltage on any GPIO pin is VDD + 0.3 V, typically 3.6 V for a 3.3 V powered ESP32 per Espressif's datasheet. Applying 5 V directly to an ESP32 GPIO will damage the input protection diode over time and may immediately damage the pin. Use a resistor voltage divider, level shifter, or a dedicated ADC front-end to interface with 5 V signals. For ADC, the maximum recommended input range with 11 dB attenuation is typically 2.5 V for accurate readings — inputs above this are clipped at the ADC ceiling but do not cause immediate damage if kept below the GPIO absolute maximum.
Can I use the same GPIO for both digital output and ADC input at different times?
Yes, with care. GPIO pins that are also ADC channels (GPIO32–GPIO39 for ADC1) can be reconfigured at runtime between digital output mode and ADC input mode using gpio_config() and the ADC oneshot API. Reconfigure the pin direction and disable the internal pull resistors before switching to ADC mode — pull resistors affect ADC readings, and any remaining drive state from output mode will skew measurements. In practice, it is cleaner to dedicate ADC pins as inputs-only in hardware design to avoid accidental drive conflicts.

References

Related Questions

Related Forum Discussions