ESP32 Programming Fundamentals
This guide covers the essential programming concepts for ESP32 development: GPIO control, analog I/O, interrupts, timers, and multitasking with FreeRTOS.
GPIO: Digital Input/Output
Basic Output
#include <Arduino.h>
#define LED_PIN 2
#define RELAY_PIN 4
void setup() {
// Configure pins as outputs
pinMode(LED_PIN, OUTPUT);
pinMode(RELAY_PIN, OUTPUT);
// Initial states
digitalWrite(LED_PIN, LOW);
digitalWrite(RELAY_PIN, LOW);
}
void loop() {
// Turn on
digitalWrite(LED_PIN, HIGH);
delay(1000);
// Turn off
digitalWrite(LED_PIN, LOW);
delay(1000);
}
Basic Input
#define BUTTON_PIN 15
#define LED_PIN 2
void setup() {
Serial.begin(115200);
// Configure button with internal pull-up
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
}
void loop() {
// Read button state (LOW when pressed due to pull-up)
int buttonState = digitalRead(BUTTON_PIN);
if (buttonState == LOW) {
Serial.println("Button pressed!");
digitalWrite(LED_PIN, HIGH);
} else {
digitalWrite(LED_PIN, LOW);
}
delay(50); // Simple debounce
}
Input Modes
pinMode(pin, INPUT); // High impedance input
pinMode(pin, INPUT_PULLUP); // Input with ~45kΩ pull-up
pinMode(pin, INPUT_PULLDOWN); // Input with ~45kΩ pull-down
pinMode(pin, OUTPUT); // Push-pull output
pinMode(pin, OUTPUT_OPEN_DRAIN); // Open-drain output
Button Debouncing
Mechanical buttons create electrical noise. Here's a proper debounce implementation:
#define BUTTON_PIN 15
#define DEBOUNCE_TIME 50 // milliseconds
bool lastButtonState = HIGH;
bool buttonState = HIGH;
unsigned long lastDebounceTime = 0;
bool readDebouncedButton() {
bool reading = digitalRead(BUTTON_PIN);
if (reading != lastButtonState) {
lastDebounceTime = millis();
}
if ((millis() - lastDebounceTime) > DEBOUNCE_TIME) {
if (reading != buttonState) {
buttonState = reading;
if (buttonState == LOW) {
return true; // Button was pressed
}
}
}
lastButtonState = reading;
return false;
}
void loop() {
if (readDebouncedButton()) {
Serial.println("Debounced button press!");
}
}
GPIO Speed and Drive Strength
For demanding applications, configure GPIO characteristics:
#include "driver/gpio.h"
// Set drive strength (ESP-IDF)
gpio_set_drive_capability(GPIO_NUM_2, GPIO_DRIVE_CAP_3); // Strongest
// Drive capabilities:
// GPIO_DRIVE_CAP_0: ~5mA
// GPIO_DRIVE_CAP_1: ~10mA
// GPIO_DRIVE_CAP_2: ~20mA (default)
// GPIO_DRIVE_CAP_3: ~40mA
Analog Input (ADC)
The ESP32 has two ADC units with multiple channels.
Basic Analog Read
#define POTENTIOMETER_PIN 34 // ADC1 channel
void setup() {
Serial.begin(115200);
// ADC pins don't need pinMode configuration
}
void loop() {
int rawValue = analogRead(POTENTIOMETER_PIN); // 0-4095
float voltage = rawValue * (3.3 / 4095.0);
Serial.printf("Raw: %d, Voltage: %.2fV\n", rawValue, voltage);
delay(100);
}
ADC Configuration
void setup() {
// Set resolution (9-12 bits)
analogReadResolution(12); // 0-4095
// Set attenuation (input voltage range)
analogSetAttenuation(ADC_11db); // 0-3.3V range
}
// Attenuation options:
// ADC_0db: 0-1.1V (most accurate)
// ADC_2_5db: 0-1.5V
// ADC_6db: 0-2.2V
// ADC_11db: 0-3.3V (full range, less accurate)
ADC Calibration and Averaging
ESP32 ADC is noisy and non-linear. Always average readings:
#define ADC_PIN 34
#define NUM_SAMPLES 64
float readAveragedVoltage() {
long sum = 0;
for (int i = 0; i < NUM_SAMPLES; i++) {
sum += analogRead(ADC_PIN);
delayMicroseconds(100); // Small delay between samples
}
int average = sum / NUM_SAMPLES;
return average * (3.3 / 4095.0);
}
ADC with ESP-IDF (More Accurate)
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
adc_oneshot_unit_handle_t adc1_handle;
adc_cali_handle_t adc_cali_handle;
void setupADC() {
// Configure ADC unit
adc_oneshot_unit_init_cfg_t init_config = {
.unit_id = ADC_UNIT_1,
};
adc_oneshot_new_unit(&init_config, &adc1_handle);
// Configure channel
adc_oneshot_chan_cfg_t config = {
.bitwidth = ADC_BITWIDTH_12,
.atten = ADC_ATTEN_DB_12,
};
adc_oneshot_config_channel(adc1_handle, ADC_CHANNEL_6, &config);
// Configure calibration
adc_cali_line_fitting_config_t cali_config = {
.unit_id = ADC_UNIT_1,
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_12,
};
adc_cali_create_scheme_line_fitting(&cali_config, &adc_cali_handle);
}
int readCalibratedMillivolts() {
int raw, voltage;
adc_oneshot_read(adc1_handle, ADC_CHANNEL_6, &raw);
adc_cali_raw_to_voltage(adc_cali_handle, raw, &voltage);
return voltage; // Returns millivolts
}
ADC Important Notes
- ADC2 cannot be used while WiFi is active
- GPIO 34-39 are input-only (no pull-up/pull-down)
- ADC is non-linear - use calibration for accuracy
- Average multiple readings for stable values
Analog Output: PWM and DAC
PWM (Pulse Width Modulation)
ESP32 has 16 PWM channels via the LEDC (LED Control) peripheral.
#define LED_PIN 2
#define PWM_CHANNEL 0
#define PWM_FREQ 5000 // 5 kHz
#define PWM_RESOLUTION 8 // 8-bit: 0-255
void setup() {
// Configure PWM channel
ledcSetup(PWM_CHANNEL, PWM_FREQ, PWM_RESOLUTION);
// Attach pin to channel
ledcAttachPin(LED_PIN, PWM_CHANNEL);
}
void loop() {
// Fade up
for (int duty = 0; duty <= 255; duty++) {
ledcWrite(PWM_CHANNEL, duty);
delay(10);
}
// Fade down
for (int duty = 255; duty >= 0; duty--) {
ledcWrite(PWM_CHANNEL, duty);
delay(10);
}
}
PWM for Servo Motors
#define SERVO_PIN 18
#define PWM_CHANNEL 0
#define PWM_FREQ 50 // Servos use 50 Hz
#define PWM_RESOLUTION 16 // 16-bit for precision
// Servo pulse widths (in microseconds)
#define SERVO_MIN_US 500 // 0 degrees
#define SERVO_MAX_US 2500 // 180 degrees
void setServoAngle(int angle) {
// Map angle to pulse width
int pulseWidth = map(angle, 0, 180, SERVO_MIN_US, SERVO_MAX_US);
// Convert to duty cycle
// At 50Hz, period = 20ms = 20000us
// 16-bit resolution = 65536 counts per period
int duty = (pulseWidth * 65536) / 20000;
ledcWrite(PWM_CHANNEL, duty);
}
void setup() {
ledcSetup(PWM_CHANNEL, PWM_FREQ, PWM_RESOLUTION);
ledcAttachPin(SERVO_PIN, PWM_CHANNEL);
}
void loop() {
setServoAngle(0);
delay(1000);
setServoAngle(90);
delay(1000);
setServoAngle(180);
delay(1000);
}
DAC (Digital to Analog Converter)
ESP32 has two 8-bit DAC channels on GPIO 25 and 26.
#define DAC_PIN 25 // or 26
void setup() {
// DAC pins don't need configuration
}
void loop() {
// Output sine wave
for (int i = 0; i < 360; i++) {
float rad = i * PI / 180.0;
int value = (sin(rad) + 1) * 127.5; // 0-255
dacWrite(DAC_PIN, value);
delayMicroseconds(100);
}
}
DAC vs PWM Comparison
| Feature | DAC | PWM |
|---|---|---|
| Output Type | True analog | Digital pulses |
| Resolution | 8-bit | Up to 16-bit |
| Channels | 2 (GPIO 25, 26) | 16 |
| Frequency | DC to ~1MHz | Configurable |
| Use Cases | Audio, voltage reference | LEDs, motors, servos |
| Filtering Needed | No | Yes (for true analog) |
Interrupts
Interrupts allow immediate response to events without polling.
Basic Interrupt
#define BUTTON_PIN 15
#define LED_PIN 2
volatile bool ledState = false;
volatile unsigned long lastInterruptTime = 0;
// Interrupt Service Routine (ISR)
void IRAM_ATTR buttonISR() {
unsigned long currentTime = millis();
// Debounce in ISR
if (currentTime - lastInterruptTime > 200) {
ledState = !ledState;
lastInterruptTime = currentTime;
}
}
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
// Attach interrupt
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
}
void loop() {
digitalWrite(LED_PIN, ledState);
// Do other work here - interrupt handles button
delay(100);
}
Interrupt Modes
attachInterrupt(pin, ISR, mode);
// Modes:
// RISING - Trigger on low-to-high transition
// FALLING - Trigger on high-to-low transition
// CHANGE - Trigger on any transition
// LOW - Trigger while pin is LOW
// HIGH - Trigger while pin is HIGH
ISR Best Practices
// IRAM_ATTR keeps function in RAM for faster execution
void IRAM_ATTR myISR() {
// DO:
// - Keep it short and fast
// - Use volatile variables
// - Set flags for main loop to process
// DON'T:
// - Use Serial.print()
// - Use delay()
// - Allocate memory
// - Call non-IRAM functions
}
// Use volatile for variables shared with ISR
volatile bool flag = false;
volatile int counter = 0;
void IRAM_ATTR incrementCounter() {
counter++;
flag = true;
}
void loop() {
if (flag) {
flag = false;
Serial.printf("Counter: %d\n", counter);
}
}
Hardware Timer Interrupts
hw_timer_t *timer = NULL;
volatile int interruptCounter = 0;
portMUX_TYPE timerMux = portMUX_INITIALIZER_UNLOCKED;
void IRAM_ATTR onTimer() {
portENTER_CRITICAL_ISR(&timerMux);
interruptCounter++;
portEXIT_CRITICAL_ISR(&timerMux);
}
void setup() {
Serial.begin(115200);
// Configure timer 0, prescaler 80 (1MHz tick), count up
timer = timerBegin(0, 80, true);
// Attach ISR
timerAttachInterrupt(timer, &onTimer, true);
// Set alarm: trigger every 1,000,000 ticks (1 second)
timerAlarmWrite(timer, 1000000, true);
// Enable alarm
timerAlarmEnable(timer);
}
void loop() {
if (interruptCounter > 0) {
portENTER_CRITICAL(&timerMux);
int count = interruptCounter;
interruptCounter = 0;
portEXIT_CRITICAL(&timerMux);
Serial.printf("Timer fired %d time(s)\n", count);
}
}
Timers and Delays
Non-Blocking Timing
Never use delay() for timing in complex applications. Use millis():
unsigned long previousMillis = 0;
const unsigned long interval = 1000; // 1 second
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
previousMillis = currentMillis;
// This code runs every 1 second
Serial.println("Tick!");
}
// Other code runs without blocking
}
Multiple Timers Pattern
struct Timer {
unsigned long previousMillis;
unsigned long interval;
void (*callback)();
};
void blinkLED() {
static bool state = false;
state = !state;
digitalWrite(2, state);
}
void readSensor() {
int value = analogRead(34);
Serial.printf("Sensor: %d\n", value);
}
void checkWiFi() {
Serial.println("WiFi status check");
}
Timer timers[] = {
{0, 500, blinkLED}, // Every 500ms
{0, 2000, readSensor}, // Every 2 seconds
{0, 10000, checkWiFi}, // Every 10 seconds
};
const int numTimers = sizeof(timers) / sizeof(timers[0]);
void loop() {
unsigned long currentMillis = millis();
for (int i = 0; i < numTimers; i++) {
if (currentMillis - timers[i].previousMillis >= timers[i].interval) {
timers[i].previousMillis = currentMillis;
timers[i].callback();
}
}
}
Microsecond Timing
For precise timing, use micros():
unsigned long startMicros = micros();
// Do something fast
unsigned long elapsedMicros = micros() - startMicros;
Serial.printf("Operation took %lu microseconds\n", elapsedMicros);
FreeRTOS Multitasking
ESP32 runs FreeRTOS by default. This enables true multitasking on dual cores.
Creating Tasks
void Task1(void *parameter) {
for (;;) { // Infinite loop
Serial.println("Task 1 running on core " + String(xPortGetCoreID()));
vTaskDelay(1000 / portTICK_PERIOD_MS); // Delay 1 second
}
}
void Task2(void *parameter) {
for (;;) {
Serial.println("Task 2 running on core " + String(xPortGetCoreID()));
vTaskDelay(500 / portTICK_PERIOD_MS); // Delay 500ms
}
}
void setup() {
Serial.begin(115200);
// Create tasks
xTaskCreatePinnedToCore(
Task1, // Function
"Task1", // Name
10000, // Stack size (bytes)
NULL, // Parameters
1, // Priority (1 = low, higher = more priority)
NULL, // Task handle
0 // Core (0 or 1)
);
xTaskCreatePinnedToCore(
Task2,
"Task2",
10000,
NULL,
1,
NULL,
1 // Run on core 1
);
}
void loop() {
// Main loop can be empty or do other work
vTaskDelay(10000 / portTICK_PERIOD_MS);
}
Task Parameters
struct TaskParams {
int ledPin;
int delayMs;
const char* name;
};
void blinkTask(void *parameter) {
TaskParams *params = (TaskParams*)parameter;
pinMode(params->ledPin, OUTPUT);
for (;;) {
digitalWrite(params->ledPin, HIGH);
vTaskDelay(params->delayMs / portTICK_PERIOD_MS);
digitalWrite(params->ledPin, LOW);
vTaskDelay(params->delayMs / portTICK_PERIOD_MS);
}
}
TaskParams led1Params = {2, 500, "LED1"};
TaskParams led2Params = {4, 1000, "LED2"};
void setup() {
xTaskCreate(blinkTask, "Blink1", 2048, &led1Params, 1, NULL);
xTaskCreate(blinkTask, "Blink2", 2048, &led2Params, 1, NULL);
}
Queues for Task Communication
QueueHandle_t sensorQueue;
void sensorTask(void *parameter) {
for (;;) {
int sensorValue = analogRead(34);
// Send to queue (wait up to 100ms if full)
xQueueSend(sensorQueue, &sensorValue, 100 / portTICK_PERIOD_MS);
vTaskDelay(100 / portTICK_PERIOD_MS);
}
}
void processingTask(void *parameter) {
int receivedValue;
for (;;) {
// Wait for data from queue
if (xQueueReceive(sensorQueue, &receivedValue, portMAX_DELAY)) {
Serial.printf("Received: %d\n", receivedValue);
// Process the data
}
}
}
void setup() {
Serial.begin(115200);
// Create queue with 10 items
sensorQueue = xQueueCreate(10, sizeof(int));
xTaskCreate(sensorTask, "Sensor", 2048, NULL, 1, NULL);
xTaskCreate(processingTask, "Process", 2048, NULL, 1, NULL);
}
void loop() {
vTaskDelete(NULL); // Delete loop task if not needed
}
Semaphores for Synchronization
SemaphoreHandle_t mutex;
int sharedCounter = 0;
void incrementTask(void *parameter) {
for (;;) {
// Take mutex (wait forever if not available)
if (xSemaphoreTake(mutex, portMAX_DELAY)) {
// Critical section - only one task at a time
sharedCounter++;
Serial.printf("Counter: %d (Core %d)\n",
sharedCounter, xPortGetCoreID());
// Release mutex
xSemaphoreGive(mutex);
}
vTaskDelay(100 / portTICK_PERIOD_MS);
}
}
void setup() {
Serial.begin(115200);
// Create mutex
mutex = xSemaphoreCreateMutex();
xTaskCreatePinnedToCore(incrementTask, "Inc1", 2048, NULL, 1, NULL, 0);
xTaskCreatePinnedToCore(incrementTask, "Inc2", 2048, NULL, 1, NULL, 1);
}
Binary Semaphore for Signaling
SemaphoreHandle_t buttonSemaphore;
void IRAM_ATTR buttonISR() {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(buttonSemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
void buttonTask(void *parameter) {
for (;;) {
// Wait for semaphore (blocks until button pressed)
if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY)) {
Serial.println("Button pressed - handling event");
// Handle button press
}
}
}
void setup() {
Serial.begin(115200);
pinMode(15, INPUT_PULLUP);
buttonSemaphore = xSemaphoreCreateBinary();
attachInterrupt(digitalPinToInterrupt(15), buttonISR, FALLING);
xTaskCreate(buttonTask, "Button", 2048, NULL, 2, NULL);
}
Task Priorities and Watchdog
// Priority levels (higher number = higher priority)
// 0: Idle
// 1: Low priority tasks
// 2-3: Normal tasks
// configMAX_PRIORITIES - 1: Highest priority
void setup() {
// Create task with specific priority
xTaskCreate(criticalTask, "Critical", 4096, NULL, 5, NULL);
xTaskCreate(normalTask, "Normal", 4096, NULL, 1, NULL);
}
// For long-running tasks, feed the watchdog
void longTask(void *parameter) {
for (;;) {
// Doing heavy work...
// Yield to prevent watchdog timeout
vTaskDelay(1); // Minimum delay to feed watchdog
}
}
Memory Management
Checking Available Memory
void printMemoryInfo() {
Serial.printf("Free heap: %d bytes\n", ESP.getFreeHeap());
Serial.printf("Min free heap: %d bytes\n", ESP.getMinFreeHeap());
Serial.printf("Max alloc heap: %d bytes\n", ESP.getMaxAllocHeap());
// PSRAM (if available)
if (psramFound()) {
Serial.printf("PSRAM size: %d bytes\n", ESP.getPsramSize());
Serial.printf("Free PSRAM: %d bytes\n", ESP.getFreePsram());
}
}
Dynamic Memory Allocation
// Allocate from internal RAM
void* buffer = malloc(1024);
if (buffer == NULL) {
Serial.println("Allocation failed!");
}
free(buffer);
// Allocate from PSRAM (if available)
void* psramBuffer = ps_malloc(1024 * 1024); // 1MB from PSRAM
if (psramBuffer == NULL) {
Serial.println("PSRAM allocation failed!");
}
free(psramBuffer);
// Allocate with specific capabilities
void* dmaBuffer = heap_caps_malloc(4096, MALLOC_CAP_DMA);
void* iramBuffer = heap_caps_malloc(1024, MALLOC_CAP_INTERNAL);
Stack Size for Tasks
// Check remaining stack space
UBaseType_t stackHighWaterMark = uxTaskGetStackHighWaterMark(NULL);
Serial.printf("Minimum free stack: %d words\n", stackHighWaterMark);
// Recommended stack sizes:
// Simple tasks: 2048-4096 bytes
// Tasks with Serial: 4096+ bytes
// Tasks with WiFi/HTTP: 8192+ bytes
// Complex tasks: 16384+ bytes
Practical Example: Sensor Data Logger
Combining multiple concepts into a real application:
#include <Arduino.h>
// Configuration
#define SENSOR_PIN 34
#define LED_PIN 2
#define BUTTON_PIN 15
#define SAMPLE_INTERVAL_MS 100
#define QUEUE_SIZE 100
// FreeRTOS objects
QueueHandle_t dataQueue;
SemaphoreHandle_t printMutex;
TaskHandle_t sensorTaskHandle;
volatile bool loggingEnabled = true;
// Sensor data structure
struct SensorData {
unsigned long timestamp;
int value;
float voltage;
};
// Button interrupt
void IRAM_ATTR buttonISR() {
loggingEnabled = !loggingEnabled;
}
// Sensor reading task (Core 0)
void sensorTask(void *parameter) {
TickType_t xLastWakeTime = xTaskGetTickCount();
for (;;) {
if (loggingEnabled) {
SensorData data;
data.timestamp = millis();
data.value = analogRead(SENSOR_PIN);
data.voltage = data.value * (3.3 / 4095.0);
// Send to queue, drop if full
xQueueSend(dataQueue, &data, 0);
}
// Precise timing
vTaskDelayUntil(&xLastWakeTime, SAMPLE_INTERVAL_MS / portTICK_PERIOD_MS);
}
}
// Data processing task (Core 1)
void processingTask(void *parameter) {
SensorData data;
float sum = 0;
int count = 0;
for (;;) {
if (xQueueReceive(dataQueue, &data, portMAX_DELAY)) {
sum += data.voltage;
count++;
// Print every 10 samples
if (count >= 10) {
float avg = sum / count;
xSemaphoreTake(printMutex, portMAX_DELAY);
Serial.printf("[%lu] Avg voltage: %.3fV (last: %.3fV)\n",
data.timestamp, avg, data.voltage);
xSemaphoreGive(printMutex);
sum = 0;
count = 0;
}
}
}
}
// LED status task
void ledTask(void *parameter) {
for (;;) {
if (loggingEnabled) {
digitalWrite(LED_PIN, HIGH);
vTaskDelay(100 / portTICK_PERIOD_MS);
digitalWrite(LED_PIN, LOW);
vTaskDelay(100 / portTICK_PERIOD_MS);
} else {
digitalWrite(LED_PIN, HIGH);
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
pinMode(BUTTON_PIN, INPUT_PULLUP);
// Create FreeRTOS objects
dataQueue = xQueueCreate(QUEUE_SIZE, sizeof(SensorData));
printMutex = xSemaphoreCreateMutex();
// Setup interrupt
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
// Create tasks
xTaskCreatePinnedToCore(sensorTask, "Sensor", 4096, NULL, 2, &sensorTaskHandle, 0);
xTaskCreatePinnedToCore(processingTask, "Process", 4096, NULL, 1, NULL, 1);
xTaskCreatePinnedToCore(ledTask, "LED", 2048, NULL, 1, NULL, 1);
Serial.println("Sensor logger started. Press button to toggle logging.");
printMemoryInfo();
}
void printMemoryInfo() {
Serial.printf("Free heap: %d bytes\n", ESP.getFreeHeap());
}
void loop() {
// Print status every 5 seconds
static unsigned long lastPrint = 0;
if (millis() - lastPrint > 5000) {
lastPrint = millis();
xSemaphoreTake(printMutex, portMAX_DELAY);
Serial.printf("Status: Logging %s, Queue: %d/%d\n",
loggingEnabled ? "ON" : "OFF",
uxQueueMessagesWaiting(dataQueue),
QUEUE_SIZE);
xSemaphoreGive(printMutex);
}
vTaskDelay(100 / portTICK_PERIOD_MS);
}