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

  1. ADC2 cannot be used while WiFi is active
  2. GPIO 34-39 are input-only (no pull-up/pull-down)
  3. ADC is non-linear - use calibration for accuracy
  4. 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

FeatureDACPWM
Output TypeTrue analogDigital pulses
Resolution8-bitUp to 16-bit
Channels2 (GPIO 25, 26)16
FrequencyDC to ~1MHzConfigurable
Use CasesAudio, voltage referenceLEDs, motors, servos
Filtering NeededNoYes (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);
}

Next: Communication Protocols →