Optimal Task Priority and Timing Configurations for Deterministic Performance in ESP32 Sim Racing Pedals

Configure equal-priority round-robin scheduling with a 300 µs base tick and RUN_IN_CACHE mode to achieve sub-millisecond deterministic latency across all real-time control loops.

The chrgri/diy-sim-racing-ffb-pedal firmware relies on precise timing for force-feedback calculations, load-cell sampling, and joystick output. Achieving deterministic performance requires careful configuration of the embedded TaskScheduler library, FreeRTOS task priorities, and hardware timer settings. This guide explains the optimal task priority and timing configurations drawn directly from the source code to eliminate jitter and guarantee predictable execution.

Understanding the TaskScheduler Architecture

The project uses a lightweight cooperative scheduler located in Common_Libs/TaskScheduler. Unlike standard FreeRTOS preemptive scheduling, this implementation drives all real-time tasks from a single ESP-IDF esp_timer interrupt running at a configurable base frequency.

In TaskScheduler.h, the fundamental time quantum is defined as:

#define BASE_TICK_US 300  // 300 µs ≈ 3.33 kHz base frequency

All task periods are expressed as integer multiples of this BASE_TICK_US. This fixed-resolution time base prevents drift and ensures that the interrupt fires exactly when a task is due, regardless of other system activity.

Configuring the Base Tick and Timer Source

Deterministic behavior starts with the hardware timer configuration. The TaskScheduler::begin() method in TaskScheduler.cpp creates a periodic esp_timer that runs independently of the FreeRTOS system tick.

Key configuration points:

  • Timer source: Use esp_timer (hardware-backed) rather than software delays or the RTOS tick. This isolates the scheduler from scheduler-level jitter caused by Wi-Fi or Bluetooth interrupts.
  • Base tick: Keep BASE_TICK_US at 300 µs. This provides sufficient resolution for 1 kHz load-cell sampling while leaving CPU headroom for force-feedback calculations.

Setting Optimal Task Priorities with RUN_IN_CACHE

The most critical setting for deterministic performance is the RUN_IN_CACHE flag defined in ESP32/include/Main.h. When enabled, this macro forces all real-time tasks to run at equal priority ((UBaseType_t)1), creating a round-robin scheduling pattern that eliminates priority inversion.

Priority configuration from Main.h (lines 61-74):

#define TASK_PRIORITY_PEDAL_UPDATE_TASK        (UBaseType_t)1
#define TASK_PRIORITY_LOADCELL_READING_TASK    (UBaseType_t)1
#define TASK_PRIORITY_JOYSTICKOUTPUT_TASK      (UBaseType_t)1
#define TASK_PRIORITY_ESP_NOW_TASK             (UBaseType_t)1
// All real-time tasks share priority 1 when RUN_IN_CACHE is defined

Why equal priority matters: When every task runs at the same priority level, FreeRTOS schedules them round-robin within that priority. The TaskScheduler ISR simply calls vTaskNotifyGiveFromISR() for the next due task. This creates a fully deterministic cycle: timer interrupt → task notification → task execution → back to timer, with no preemption jitter.

Defining Task Periods and Core Affinity

Task periods must be integer multiples of the 300 µs base tick. The macros in Main.h convert microseconds to scheduler ticks:

#define REPETITION_INTERVAL_PEDAL_UPDATE_TASK_IN_US      (int64_t)600   // 600 µs
#define REPETITION_INTERVAL_LOADCELL_READING_TASK_IN_US  (int64_t)1000  // 1 ms

Core affinity is equally important. The ESP32 runs Wi-Fi and Bluetooth stacks on core 0. To prevent network ISR latency from interfering with control loops, pin all real-time tasks to core 1:

#define CORE_ID_PEDAL_UPDATE_TASK        (uint8_t)1
#define CORE_ID_LOADCELLREADING_TASK     (uint8_t)1
#define CORE_ID_JOYSTICKOUTPUT_TASK      (uint8_t)1

Stack sizes should remain at the defaults (e.g., STACK_SIZE_PEDAL_UPDATE_TASK = 7000 bytes). These are large enough to prevent overflow—which would cause a reset and break determinism—without being so large that memory allocation becomes nondeterministic.

Implementation Example

Initialising the Scheduler

In Main.cpp, instantiate the scheduler and register tasks:

#include "TaskScheduler.h"

TaskScheduler taskScheduler;

void setup()
{
    // Hardware initialisation omitted ...
    
    // Start scheduler with timer 0
    taskScheduler.begin(0);
    
    // Register pedal update task: 600 µs period, priority 1, core 1
    taskScheduler.addScheduledTask(
        pedalUpdateTask,
        "Pedal Update",
        REPETITION_INTERVAL_PEDAL_UPDATE_TASK_IN_US,
        TASK_PRIORITY_PEDAL_UPDATE_TASK,
        CORE_ID_PEDAL_UPDATE_TASK,
        STACK_SIZE_PEDAL_UPDATE_TASK
    );
    
    // Register load-cell task: 1 ms period, priority 1, core 1
    taskScheduler.addScheduledTask(
        loadCellTask,
        "Loadcell Read",
        REPETITION_INTERVAL_LOADCELL_READING_TASK_IN_US,
        TASK_PRIORITY_LOADCELL_READING_TASK,
        CORE_ID_LOADCELLREADING_TASK,
        STACK_SIZE_LOADCELL_READING_TASK
    );
}

Writing a Deterministic Task

Tasks must use ulTaskNotifyTake to synchronise with the scheduler:

void pedalUpdateTask(void *pvParameters)
{
    while (true)
    {
        // Block until scheduler notification arrives
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        
        // Deterministic work must complete before next tick
        readPedalADC();
        applyFIRFilter();
        computeForceFeedback();
        
        // Task yields automatically when loop ends
    }
}

Adding Custom Tasks

Maintain determinism by keeping priority at 1 and using integer multiples of 300 µs:

#define REPETITION_INTERVAL_MYTASK_IN_US (int64_t)3000  // 3 ms = 10 ticks
#define TASK_PRIORITY_MYTASK (UBaseType_t)1
#define CORE_ID_MYTASK (uint8_t)1

taskScheduler.addScheduledTask(
    myTask,
    "Custom Task",
    REPETITION_INTERVAL_MYTASK_IN_US,
    TASK_PRIORITY_MYTASK,
    CORE_ID_MYTASK,
    4000
);

Summary

  • Use a 300 µs base tick (BASE_TICK_US in TaskScheduler.h) to provide fixed-resolution timing for all control loops.
  • Enable RUN_IN_CACHE in Main.h to force equal priority ((UBaseType_t)1) across all real-time tasks, creating deterministic round-robin scheduling.
  • Pin tasks to core 1 using the CORE_ID_* macros to isolate them from Wi-Fi and Bluetooth interrupts running on core 0.
  • Express periods as integer multiples of the base tick (e.g., 600 µs for pedal updates, 1000 µs for load-cell reads) to prevent timing drift.
  • Use ulTaskNotifyTake in task loops to synchronise exactly with the esp_timer ISR, ensuring one execution per tick with minimal jitter.

Frequently Asked Questions

What is the optimal base tick interval for the DIY Sim Racing FFB Pedal firmware?

The optimal base tick is 300 microseconds (approximately 3.33 kHz), defined as BASE_TICK_US in Common_Libs/TaskScheduler/src/TaskScheduler.h. This value provides sufficient resolution for 1 kHz load-cell sampling and force-feedback calculations while maintaining CPU headroom for other tasks. All task periods must be configured as integer multiples of this 300 µs quantum to ensure deterministic timing.

Why should all real-time tasks use the same priority level?

Equal priority ((UBaseType_t)1) enables round-robin scheduling that eliminates priority inversion and preemption jitter. When the RUN_IN_CACHE flag is defined in ESP32/include/Main.h, all tasks run at priority 1, allowing the TaskScheduler to dispatch them sequentially via vTaskNotifyGiveFromISR() without scheduler-level conflicts. This creates a predictable execution cycle where each task runs exactly once per scheduled tick.

How do I prevent Wi-Fi and Bluetooth interrupts from affecting pedal latency?

Pin all time-critical tasks to core 1 using the CORE_ID_* macros defined in Main.h. The ESP32 runs Wi-Fi and Bluetooth protocol stacks on core 0, which generates frequent interrupts. By setting CORE_ID_PEDAL_UPDATE_TASK, CORE_ID_LOADCELLREADING_TASK, and similar macros to (uint8_t)1, you isolate the control loops from network-related latency spikes.

What happens if I choose task periods that are not multiples of 300 µs?

Non-integer multiples of the base tick cause timing drift and jitter. The TaskScheduler counts ticks using the BASE_TICK_US quantum (300 µs). If you specify a period of, for example, 500 µs, the scheduler cannot represent this exactly as an integer number of ticks, leading to inconsistent intervals between task executions. Always define periods in Main.h using values like 600, 1000, or 3000 µs that divide evenly by 300.

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 →