# 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. Learn how ISRs, FreeRTOS priorities, and non-blocking queues enhance FFB pedal performance for sim racing.

- Repository: [chrgri/diy-sim-racing-ffb-pedal](https://github.com/chrgri/diy-sim-racing-ffb-pedal)
- Tags: performance
- Published: 2026-02-27

---

**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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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:

```cpp
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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/Common_Libs/TaskScheduler/src/TaskScheduler.h), the callback declaration:

```cpp
static void IRAM_ATTR timerCallback(void* arg);

```

The ISR implementation in [`TaskScheduler.cpp`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/TaskScheduler.cpp) minimizes execution time by only incrementing tick counters and notifying tasks via `vTaskNotifyGiveFromISR`:

```cpp
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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/ESP32/src/Main.cpp), tasks are created with explicit core assignment:

```cpp
// 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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/Main.cpp):

```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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/ESP32/src/Main.cpp), the load-cell reading task sends data via `xQueueSend` with a zero timeout:

```cpp
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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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.