How to Get Microsecond-Accurate Timing on the ESP32-C6 with esp_timer

Many firmware jobs need to run on a fixed schedule, such as sampling a sensor every millisecond or stepping a motor at a steady rate. On the ESP32, many programs use vTaskDelay() to implement schedules, but it doesn’t work for jobs that need to run in microseconds. In this guide, you’ll learn why vTaskDelay() has its limitations, how esp_timer works better for microsecond timing, and test esp_timer for yourself on the ESP32-C6-DevKitC-1.

vTaskDelay() Limitations

By default, the ESP32-C6 runs FreeRTOS. In this RTOS, all tasks, like app_main(), are managed with ticks, system interrupts that fire at a fixed rate. The tick rate is set by CONFIG_FREERTOS_HZ, which defaults to 100 Hz, or one tick every 10 ms.

The scheduler, the part of FreeRTOS that picks which task runs, can switch tasks only during a tick. This means any function that relies on ticks to implement a fixed timing schedule, like vTaskDelay(), can only be as accurate as the tick rate.

Consider an application in which you need to read a sensor every 100 microseconds. If you wanted to use vTaskDelay(), you would write a loop that would read from the sensor and then call vTaskDelay() to wait for 100 microseconds. The problem is that vTaskDelay() can only sleep the calling task for a whole number of ticks, and the maximum tick rate is 1000 Hz, or one tick every millisecond. Therefore, in this situation, your task would never “sleep” and would just continuously read the sensor without any waits.

esp_timer Basics

To get microsecond-accurate timing, you can use esp_timer instead. It works by reading the ESP32-C6 system timer (or systimer), a hardware counter that counts 16 times every microsecond.

In addition to providing much better timing accuracy than ticks, systimer is designed to work with esp_timer to make implementing scheduled jobs simple. Let’s say you still want to design an application that reads a sensor every 100 microseconds. All you need to do is register the period and callback function with esp_timer. Then esp_timer passes the period to systimer, which independently tracks time. When 100 microseconds pass, systimer raises a high-priority interrupt to trigger esp_timer, which then calls your callback function. Once the function returns, the cycle repeats automatically until you tell esp_timer to remove the job from its internal list.

Despite its benefits, there are two notable downsides to esp_timer. One is that every callback shares a single esp_timer task. With one scheduled job, that’s not an issue, but if you want multiple concurrent jobs, remember that a slow callback will hold up every subsequent job. Thus, the best practice is to keep every callback function short and quick.

Another downside is that every scheduled job has unavoidable latency because of how long the CPU takes to process the interrupt and switch tasks. Espressif measured that floor at about 50 microseconds on an ESP32 running at 240 MHz and notes that the latency increases with lower CPU frequencies. Thus, on the 160 MHz ESP32-C6, you can’t run a scheduled job faster than every 50+ microseconds.

Simulate a Scheduled Job with esp_timer

Before you begin, make sure you have ESP-IDF v6.0 or later installed on your PC. Create a new project within ESP-IDF and set the target chip to esp32c6.

Add the following code to your project’s main.c to simulate and observe a scheduled job that runs every 100 microseconds. The program starts a periodic timer at 100 µs, runs for one second, and then prints how many callbacks ran and the range of their actual timings.

#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_timer.h"
#include "esp_log.h"

static const char *TAG = "timing";

#define PERIOD_US  100     // ask for a callback every 100 microseconds
#define RUN_MS     1000    // let the timer run for one second

// The callback runs in the esp_timer task and keeps nothing between calls,
// so every value it carries forward lives at file scope.
static int64_t last_us;                 // time of the previous callback
static int64_t min_gap_us = INT64_MAX;  // any real gap is smaller than this
static int64_t max_gap_us;
static int32_t callbacks;

// This is the whole callback, one timestamp and three updates, because
// every microsecond spent here delays every other esp_timer callback in
// the system. No logging. At 115200 baud the console needs longer than
// this period to send a single character.
static void on_tick(void *arg)
{
    int64_t now = esp_timer_get_time();
    if (last_us != 0) {                 // zero means no previous callback yet
        int64_t gap = now - last_us;
        if (gap < min_gap_us) {
            min_gap_us = gap;
        }
        if (gap > max_gap_us) {
            max_gap_us = gap;
        }
    }
    last_us = now;
    callbacks++;
}

void app_main(void)
{
    // dispatch_method stays at its default, ESP_TIMER_TASK, so on_tick()
    // runs in the esp_timer task.
    const esp_timer_create_args_t args = {
        .callback = on_tick,
        .name = "tick",     // shows up in esp_timer_dump() output
    };
    esp_timer_handle_t timer;
    ESP_ERROR_CHECK(esp_timer_create(&args, &timer));

    // Start the timer, sleep this task for a second, then stop the timer.
    // The esp_timer task runs at a far higher priority than this one, so
    // nothing here can delay the callback. Read the clock on both sides so
    // the report shows the real elapsed time, which vTaskDelay() only
    // holds to the nearest tick.
    int64_t start_us = esp_timer_get_time();
    ESP_ERROR_CHECK(esp_timer_start_periodic(timer, PERIOD_US));
    vTaskDelay(pdMS_TO_TICKS(RUN_MS));
    ESP_ERROR_CHECK(esp_timer_stop(timer));
    int64_t elapsed_us = esp_timer_get_time() - start_us;

    // A running timer cannot be deleted, so the stop above comes first.
    ESP_ERROR_CHECK(esp_timer_delete(timer));

    ESP_LOGI(TAG, "%" PRId32 " callbacks in %" PRId64 " us at a %d us period, "
             "gaps from %" PRId64 " to %" PRId64 " us",
             callbacks, elapsed_us, PERIOD_US, min_gap_us, max_gap_us);
}

Build the program, flash it, and open the serial terminal.

Results

You should see something like the following in your terminal:

Serial terminal showing the esp_timer callback count, elapsed time, period, and gap range

Divide the total period by the total number of callbacks to calculate the average time between callbacks. If you see a number around 100 µs, you correctly used esp_timer to implement a scheduled job that accurately works every 100 microseconds.

The following numbers are the gap figures, or the range of observed time between callbacks. If your ESP32-C6 was supposed to read a sensor every 100 microseconds, the gap figures show how often it actually read the sensor. In the example output, you can see that sometimes two readings were only done 78 µs apart, while others were done 100 µs apart.

These numbers indicate how far behind your timer got due to jitter, or the delay between the systimer raising the interrupt and the CPU running the callback function. Jitter is unpredictable and variable because it depends on what the CPU is doing at the moment, what the active task is doing, what needs to be loaded into memory, etc.

Troubleshooting

Watch for the following signs when implementing esp_timer in your application:

Maximum gap figure is greater than period and/or minimum gap figure is close to zero This means your timer fell behind enough that callbacks sometimes didn’t happen on schedule. Inspect your CPU and other system usage to reduce jitter.

No timing:... log This means your period was too low for esp_timer to handle. esp_timer always has a minimum unavoidable latency; if your wait period is below that, esp_timer will never pause, and the task watchdog will trigger. Fix this error by raising the waiting period PERIOD_US until the log line returns. By testing different periods in this way, you can manually find the minimum latency for your board and application.

Next Steps

Now you know how to schedule microsecond-level jobs with esp_timer and measure how well your ESP32-C6 executes them.

If you wanted to use esp_timer in conjunction with sleep modes in production, remember that the task stops during light sleep unless skip_unhandled_events is set. Read ESP32 Sleep Modes Explained to learn more about how sleep modes can affect your application.


Ready to connect your devices anywhere on Earth? Get started with Hubble Network for free →