Skip to content
ESP32 Deep Dive — GPIO, ADC, PWM, I2C, WiFi, BLE, FreeRTOS, and OTA

ESP32 Deep Dive — GPIO, ADC, PWM, I2C, WiFi, BLE, FreeRTOS, and OTA

DodaTech Updated Jun 20, 2026 5 min read

The ESP32 is more than a WiFi-enabled Arduino — it’s a dual-core microcontroller with a rich peripheral set, real-time operating system (FreeRTOS), Bluetooth Classic and BLE, cryptographic accelerators, and multiple low-power modes. This deep dive covers every subsystem you’ll use in production.

In this tutorial, you’ll move beyond blinking LEDs to using GPIO interrupts, ADC with precision calibration, PWM for motor control, I2C/SPI bus communication, BLE GATT services, FreeRTOS task scheduling, OTA firmware updates, and sensor fusion. This is the hardware knowledge you need to build reliable, professional IoT products.

A predictive maintenance system using the ESP32 reads vibration from an I2C accelerometer, processes it with an FFT running on the second core via FreeRTOS, publishes alerts over MQTT, and sleeps between readings at 10µA — running on a battery for a year.

ESP32 Block Diagram

    graph TD
    subgraph "ESP32 SoC"
        CPU0[Core 0<br/>Protocol Stack] --> MEM[520KB SRAM]
        CPU1[Core 1<br/>Application] --> MEM
        MEM --> Flash[External Flash<br/>Up to 16MB]
        MEM --> PSRAM[External PSRAM<br/>Up to 8MB]
    end
    subgraph "Peripherals"
        GPIO[GPIO x 34] --> CPU0
        ADC[ADC 12-bit x 2] --> CPU1
        I2C[I2C x 2] --> CPU1
        SPI[SPI x 4] --> CPU1
    end
    subgraph "Wireless"
        WiFi[802.11 b/g/n] --> CPU0
        BLE[Bluetooth 4.2/BLE] --> CPU0
    end
    style CPU0 fill:#e74c3c,color:#fff
    style CPU1 fill:#e67e22,color:#fff
  

GPIO and Interrupts

ESP32 GPIO pins are multiplexed — most peripherals share pins 0–39. Configure them carefully to avoid conflicts.

// GPIO interrupt on button press
const int BUTTON_PIN = 0;  // BOOT button on dev board
volatile bool button_pressed = false;

void IRAM_ATTR isr_handler() {
    button_pressed = true;
}

void setup() {
    Serial.begin(115200);
    pinMode(BUTTON_PIN, INPUT_PULLUP);
    attachInterrupt(BUTTON_PIN, isr_handler, FALLING);
    Serial.println("Press the button");
}

void loop() {
    if (button_pressed) {
        Serial.println("Button pressed — interrupt fired!");
        button_pressed = false;
    }
}

ADC — Analog-to-Digital Conversion

ESP32’s ADC has 12-bit resolution (0–4095) with two SAR ADC units. The input range is 0–3.3V, but attenuation settings extend it.

#define ADC_PIN 34
#define ADC_ATTEN ADC_11db  // 0–3.6V range

void setup() {
    Serial.begin(115200);
    analogReadResolution(12);
    analogSetAttenuation(ADC_ATTEN);
}

void loop() {
    int raw = analogRead(ADC_PIN);
    float voltage = raw * (3.6f / 4095.0f);
    Serial.printf("Raw: %d, Voltage: %.2fV\n", raw, voltage);
    delay(1000);
}

Expected output:

Raw: 2048, Voltage: 1.80V
Raw: 2050, Voltage: 1.80V

PWM with LEDC

ESP32’s LEDC peripheral generates up to 16 independent PWM channels at variable frequency and duty cycle.

const int LED_PIN = 2;
const int PWM_CHANNEL = 0;
const int PWM_FREQ = 5000;
const int PWM_RES = 8;

void setup() {
    ledcSetup(PWM_CHANNEL, PWM_FREQ, PWM_RES);
    ledcAttachPin(LED_PIN, PWM_CHANNEL);
}

void loop() {
    for (int duty = 0; duty <= 255; duty++) {
        ledcWrite(PWM_CHANNEL, duty);
        delay(5);
    }
    for (int duty = 255; duty >= 0; duty--) {
        ledcWrite(PWM_CHANNEL, duty);
        delay(5);
    }
}

I2C Sensor Integration (BME280)

#include <Wire.h>
#include <Adafruit_BME280.h>

Adafruit_BME280 bme;

void setup() {
    Serial.begin(115200);
    Wire.begin(21, 22);  // SDA, SCL
    if (!bme.begin(0x76)) {
        Serial.println("BME280 not found!");
        return;
    }
}

void loop() {
    float temp = bme.readTemperature();
    float humid = bme.readHumidity();
    float press = bme.readPressure() / 100.0F;
    Serial.printf("%.1f°C, %.1f%%, %.1fhPa\n", temp, humid, press);
    delay(5000);
}

Expected output:

24.7°C, 55.2%, 1013.2hPa
24.8°C, 55.0%, 1013.1hPa

FreeRTOS — Dual-Core Tasking

FreeRTOS runs on both cores. Assign WiFi/BLE tasks to core 0 and application logic to core 1.

TaskHandle_t sensorTask;
TaskHandle_t wifiTask;

void sensorLoop(void *param) {
    while (1) {
        Serial.printf("Core %d: Reading sensor\n", xPortGetCoreID());
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

void wifiLoop(void *param) {
    while (1) {
        Serial.printf("Core %d: WiFi task\n", xPortGetCoreID());
        vTaskDelay(pdMS_TO_TICKS(5000));
    }
}

void setup() {
    Serial.begin(115200);
    xTaskCreatePinnedToCore(sensorLoop, "Sensor", 4096, NULL, 1, &sensorTask, 1);
    xTaskCreatePinnedToCore(wifiLoop, "WiFi", 8192, NULL, 1, &wifiTask, 0);
}

void loop() { vTaskDelete(NULL); }

Expected output:

Core 1: Reading sensor
Core 1: Reading sensor
Core 0: WiFi task
Core 1: Reading sensor

Deep Sleep Modes

ModeCurrentWake sourcesRTC memory
Modem sleep~3mATimer, GPIOPreserved
Light sleep~0.8mATimer, GPIO, touchPreserved
Deep sleep~10µATimer, GPIO, touch, ULPPreserved
Hibernation~2.5µAGPIO onlyLost

Common Mistakes

  1. GPIO conflict with flash: GPIO 6–11 are used for internal SPI flash. Do not use them for external circuits.
  2. ADC2 usage during WiFi: ADC2 works only when WiFi is disabled. Use ADC1 (pins 32–39) for analog reads during WiFi operation.
  3. No watchdog in production: If WiFi or MQTT hangs, the device freezes. Enable the task watchdog timer: esp_task_wdt_init(10, true).
  4. PSRAM without proper config: Using external PSRAM requires specific flash voltage and timing settings in menuconfig.
  5. Stack overflow in FreeRTOS tasks: Default task stack (2048 bytes) is often insufficient. Monitor with uxTaskGetStackHighWaterMark().

Practice Questions

  1. Why should you avoid GPIO 6–11 on ESP32? These pins are connected to the internal SPI flash. Using them causes boot failures and flash corruption.

  2. What’s the difference between ADC1 and ADC2 on ESP32? ADC1 has 8 channels (pins 32–39) and works independently. ADC2 has 10 channels but is disabled when WiFi is active.

  3. How does the ULP coprocessor work in deep sleep? The Ultra-Low-Power co-processor runs simple programs from RTC memory while the main CPU sleeps, reading sensors or GPIO at <1mA.

  4. What is the LEDC peripheral used for? LEDC generates PWM signals for LED dimming, servo control, and motor speed control with 16 independent channels.

  5. How do FreeRTOS task priorities work on ESP32? Priorities range from 0 (idle) to configMAX_PRIORITIES. WiFi/BLE tasks run at priority 23. Application tasks typically use 1–5.

Challenge

Build a dual-core sensor hub: Core 0 runs WiFi + MQTT to publish data, Core 1 reads three sensors (temperature, humidity, pressure) at 10Hz and stores readings in a FreeRTOS queue. The device enters deep sleep for 5 minutes between measurement bursts.

FAQ

Can ESP32 run TensorFlow Lite?
Yes, TensorFlow Lite Micro runs on ESP32 with quantized models under 500KB. Use the ESP32-S3 for better performance with its vector extensions.
What’s the maximum WiFi throughput?
ESP32 achieves ~50 Mbps UDP and ~30 Mbps TCP. Real-world throughput depends on antenna, distance, and interference.
Does deep sleep preserve WiFi credentials?
Yes, RTC memory is preserved during deep sleep. Store WiFi credentials there. WiFi must reconnect on wake.
What’s the difference between ESP32 and ESP32-S3?
ESP32-S3 adds vector instructions (for ML), more GPIO, USB OTG, and improved cryptographic acceleration.
Can I use both BLE and WiFi simultaneously?
Yes, the ESP32 supports dual-mode Bluetooth + WiFi, but they share the radio. Throughput drops when both are active.

Cross-References

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro