How to Optimize ESP32 Real-Time Task Scheduling and Core Assignment for Sim Racing FFB Pedals

Optimize ESP32 real-time task scheduling and core assignment by using a high-resolution 300 µs timer ISR running in IRAM, pinning latency-critical tasks to dedicated cores with elevated FreeRTOS priorities, and employing non-blocking queues for inter-task communication.

The diy-sim-racing-ffb-pedal firmware runs on ESP32 and ESP32-S3 microcontrollers to deliver sub-millisecond force-feedback response. Achieving deterministic real-time performance requires careful orchestration of the FreeRTOS scheduler, hardware timer interrupts, and core affinity settings. This guide explains the exact implementation found in the repository and provides practical optimization strategies.

Understanding the ESP32 Real-Time Architecture

The firmware abandons the default FreeRTOS tick in favor of a dedicated high-resolution timer that drives a custom task scheduler. This architecture decouples real-time control loops from Wi-Fi, Bluetooth, and other background operations.

In Common_Libs/TaskScheduler/src/TaskScheduler.cpp, the TaskScheduler::begin() method creates an esp_timer that fires every BASE_TICK_US (300 µs by default), generating a 3.3 kHz base tick:

void TaskScheduler::begin() {
    const esp_timer_create_args_t timer_args = {
        .callback = &TaskScheduler::timerCallback,
        .arg = this,
        .dispatch_method = ESP_TIMER_ISR,
        .name = "task_scheduler"
    };
    esp_timer_create(&timer_args, &timer_handle);
    esp_timer_start_periodic(timer_handle, BASE_TICK_US);
}

The timer ISR runs in IRAM and has sub-microsecond accuracy, providing a deterministic foundation for all real-time tasks.

Configuring the High-Resolution Timer ISR

The timer callback executes with the IRAM_ATTR attribute to ensure it runs from internal RAM even when flash operations (such as OTA updates) are active. This prevents missed ticks during firmware updates.

In Common_Libs/TaskScheduler/src/TaskScheduler.h, the callback declaration:

static void IRAM_ATTR timerCallback(void* arg);

The ISR implementation in TaskScheduler.cpp minimizes execution time by only incrementing tick counters and notifying tasks via vTaskNotifyGiveFromISR:

void IRAM_ATTR TaskScheduler::onTimer() {
    for (int i = 0; i < taskCount; i++) {
        tasks[i].tickCounter++;
        if (tasks[i].tickCounter >= tasks[i].intervalTicks) {
            tasks[i].tickCounter = 0;
            BaseType_t xHigherPriorityTaskWoken = pdFALSE;
            vTaskNotifyGiveFromISR(tasks[i].taskHandle, &xHigherPriorityTaskWoken);
            portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
        }
    }
}

This design keeps ISR latency under 1 µs, leaving the majority of each 300 µs time slice available for task execution.

Assigning Core Affinity for Deterministic Execution

The ESP32's dual-core architecture allows the firmware to isolate real-time control from Wi-Fi and Bluetooth drivers. The firmware pins compute-heavy tasks to Core 0 (which also runs the timer ISR) while offloading communication tasks to Core 1.

In ESP32/src/Main.cpp, tasks are created with explicit core assignment:

// Pin critical control tasks to Core 0
addScheduledTask(loadcellReadingTask, "loadcellReadingTask", 
                 REPETITION_INTERVAL_LOADCELL_READING_TASK_IN_US,
                 TASK_PRIORITY_LOADCELL_READING_TASK, 
                 CORE_ID_LOADCELL_READING_TASK,  // Core 0
                 4096);

// Pin Wi-Fi/Serial communication to Core 1
addScheduledTask(serialCommunicationTaskRx, "serComRx",
                 REPETITION_INTERVAL_SERIALCOMMUNICATION_TASK_IN_US,
                 TASK_PRIORITY_SERIALCOMMUNICATION_RX_TASK,
                 1,  // Core 1
                 6000);

This separation prevents Wi-Fi interrupts from preempting the 1 kHz load-cell sampling loop, reducing jitter to under 50 µs.

Setting Task Priorities for Latency-Critical Workloads

FreeRTOS priority levels determine preemption order when multiple tasks are ready to run. The firmware assigns distinct priorities to ensure force-feedback calculations execute before serial communication or diagnostic logging.

Priority constants defined in the configuration header and used in Main.cpp:

#define TASK_PRIORITY_LOADCELL_READING_TASK          5
#define TASK_PRIORITY_PEDAL_UPDATE_TASK              4
#define TASK_PRIORITY_SERIALCOMMUNICATION_RX_TASK    2
#define TASK_PRIORITY_SERIALCOMMUNICATION_TX_TASK    2

Higher values indicate higher priority. When the timer ISR notifies both the load-cell task (priority 5) and the serial RX task (priority 2), the scheduler immediately switches to the load-cell task if it is in the ready state, ensuring sub-millisecond response to pedal force changes.

Implementing Non-Blocking Inter-Task Communication

The firmware uses FreeRTOS queues for data transfer between tasks, but critically, it never blocks the producer. This prevents high-priority tasks from stalling when consumers lag behind.

In ESP32/src/Main.cpp, the load-cell reading task sends data via xQueueSend with a zero timeout:

void loadcellReadingTask(void *pvParameters) {
    while (true) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        
        LoadcellDataPackage newLoadcellPackage;
        newLoadcellPackage.reading = loadCell.getReading();
        newLoadcellPackage.timestamp = micros();
        
        if (loadcellDataQueue != NULL) {
            // Non-blocking send: drops data if queue full rather than waiting
            xQueueSend(loadcellDataQueue, &newLoadcellPackage, (TickType_t)0);
        }
    }
}

This "fire-and-forget" approach ensures the 1 kHz load-cell sampling rate remains constant even if the pedal update task experiences temporary latency.

Practical Optimization Steps

Apply these specific optimizations to achieve deterministic real-time performance:

  1. Reduce the base tick interval – For control loops requiring sub-millisecond precision, modify BASE_TICK_US in TaskScheduler.h from 300 µs to 100 µs. Recalculate intervalTicks for each task to maintain the same effective frequency (e.g., 1 kHz task becomes intervalTicks = 10 instead of 3).

  2. Consolidate critical tasks on Core 0 – Pin the load-cell reading, pedal update, and stepper control tasks to Core 0. This minimizes cross-core notification latency and keeps the timer ISR and task execution on the same CPU cache.

  3. Implement priority ceiling – Assign the load-cell task priority 5 (highest), pedal update priority 4, and communication tasks priority 2. Avoid using priority 6-24 (system reserved) to prevent starvation of TCP/IP stack tasks.

  4. Minimize ISR duration – Keep the onTimer() ISR under 50 lines of code. Move any floating-point math or PID calculations to the task context where FPU usage is safe and interrupts are enabled.

  5. Monitor stack high-water mark – During development, call uxTaskGetStackHighWaterMark() on the stepper task (which runs PID calculations) to verify the 7 kB allocation is sufficient. Reduce stack size only after profiling confirms safety.

  6. Enable watchdogs in production – Keep disableCore0WDT() and disableCore1WDT() commented out in Main.cpp. The watchdog will reset the system if a task deadlocks, providing fail-safe operation during racing sessions.

Summary

Optimizing ESP32 real-time task scheduling and core assignment requires a multi-layered approach combining hardware timers, FreeRTOS configuration, and careful task design:

  • Use a dedicated high-resolution timer (esp_timer with IRAM_ATTR ISR) running at 300 µs or faster to drive scheduling, avoiding the default system tick jitter.
  • Pin latency-critical tasks to Core 0 (load-cell reading, pedal update, stepper control) while offloading Wi-Fi and serial communication to Core 1.
  • Assign strict priority levels (5 for sensor reading, 4 for control loops, 2 for I/O) to ensure preemption of less critical work.
  • Implement non-blocking communication using xQueueSend with zero timeout to prevent high-priority tasks from stalling on full queues.
  • Monitor resources using uxTaskGetStackHighWaterMark and printTaskStats to verify timing and stack safety during development.

Frequently Asked Questions

How does the custom task scheduler differ from standard FreeRTOS scheduling?

The custom TaskScheduler class implements a time-triggered cooperative layer on top of FreeRTOS. While FreeRTOS uses a preemptive priority-based scheduler with a default 1 ms tick, the custom scheduler uses a 300 µs hardware timer ISR to increment tick counters and notify tasks via vTaskNotifyGiveFromISR. This provides sub-millisecond timing resolution and deterministic jitter under 50 µs, which standard FreeRTOS cannot achieve on ESP32 due to Wi-Fi driver interference.

Why are critical tasks pinned to Core 0 instead of distributing them across both cores?

Core 0 hosts the esp_timer ISR that drives the task scheduler. By pinning the load-cell reading task (loadcellReadingTask), pedal update task (pedalUpdateTask), and stepper control to Core 0, the firmware eliminates cross-core notification latency and cache coherency delays. Core 1 is reserved for Wi-Fi, Bluetooth, and serial communication tasks that tolerate higher jitter. This separation prevents TCP/IP stack interrupts from preempting the 1 kHz force-feedback control loop.

What is the risk of using xQueueSend with zero timeout, and why does the firmware use it?

Using xQueueSend(queue, &data, 0) risks data loss if the consumer task cannot keep up and the queue fills. The firmware accepts this trade-off because real-time deadlines are more critical than data completeness. If the pedal update task lags, it is better to drop a 1 ms old load-cell reading than to block the 1 kHz sampling task and cause timing jitter. This "fire-and-forget" approach maintains deterministic sampling rates at the cost of occasional dropped packets.

How can I verify that my task priorities and core assignments are working correctly?

The firmware provides the printTaskStats() function (located in ESP32/src/Main.cpp lines 122-162) which prints per-task CPU utilization percentages and high-water stack marks via the serial console. Additionally, you can call uxTaskGetStackHighWaterMark(taskHandle) on individual tasks to verify stack safety. For timing verification, scope the GPIO debug pins toggled at the start of loadcellReadingTask and pedalUpdateTask to measure actual execution jitter against the 300 µs base tick.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →