// custom_timer.h
#pragma once
#include "defines.h"
#include
#if USE_RP2040
#include
#elif USE_ESP32C6
#endif
#if USE_RP2040
int8_t add_custom_timer(uint32_t delay_ms, repeating_timer_callback_t callback, void *user_data);
int8_t cancel_custom_timer(int8_t slot_idx);
int8_t change_period_ms(int8_t slot_idx, uint32_t delay_ms, bool reset_triggers=false);
#elif USE_ESP32C6
typedef void (*custom_callback_function) (void*);
int8_t add_custom_timer(uint32_t delay_ms, custom_callback_function userFunc, void *user_data);
int8_t cancel_custom_timer(int8_t slot_idx);
int8_t change_period_ms(int8_t slot_idx, uint32_t delay_ms, bool reset_triggers=false);
#endif
Scheduling Periodic Events with ESP32C6
July 2, 2026
Microcontrollers, ESP32, Arduino IDE
Intro & Problem
I was (and still am) working on a Morse Code Keyboard.
What is a Morse Code Keyboard?
That's not important right now.
What's important is that I was developing it with the XIAO ESP32C6.
The design was simple: One microcontroller, one hat with a Kailh switch socket for a single mechanical keyboard key, and one neopixel for visual feedback and state.
The hardware design on the prototype and version 2 were both plug-and-play simple.
The software for version 2 on the ESP32 was a bit of a problem for me, though.
See, my initial (successful) prototype was built with a Raspberry Pi Pico W, which uses an RP2040 processor.
I used the Arduino IDE and associated libraries in C++, and came up with something acceptable.
In my design, I made pretty liberal use of add_repeating_timer_ms for things like periodically blinking the LED in specific patterns, checking the battery and USB state, and timing/emitting dits and dahs.
Basically, I relied pretty heavily on timers.
While the RP2040 (and its libraries) could handle the number of timers I was making, the ESP32 couldn't out-of-the-box.
That's because the ESP32C6 only has 2 hardware timers.
And because I was using the Arduino IDE, it seemed like I had to use timerAttachInterrupt and timerAlarm to accomplish the same thing.
Putting this together told me that I had to come up with my own software-based solution to the lack of timers.
Plan & Concessions
If it wasn't clear, I had to make a custom timer scheduler to accomodate the ESP32C6. My requirements are:
- Configurable number of timers.
- Low precision (~10ms granularity).
- Cancel timers.
- Alter existing timer periods.
Because of the low precision necessary for my use, I decided to go with a single central timer that invokes scheduled callbacks.
Outline (.h)
Here is the header file for my custom_timer.h scheduler code:
Now for the skeleton of custom_timer.cpp, disregarding the RP2040 implementation:
// custom_timer.cpp
#include
#include "custom_timer.h"
#include "utils.h"
#if USE_RP2040
#elif USE_ESP32C6
#include "esp32-hal-timer.h"
#endif
#define MAX_TIMERS 8
#if USE_RP2040
// Implementation skipped...
#elif USE_ESP32C6
// Define some configurable constants
hw_timer_t* scheduler_timer;
uint32_t scheduler_gcd = 1;
const uint32_t scheduler_tick_freq = 1000; // (ticks per second) 1000000 = 1MHz is default, here it is 1000 = 1kHz = 1 tick per 1ms
const uint32_t scheduler_tick_resolution = 5; // (ticks per trigger) 1 trigger per 5 ticks, aka. 1 trigger per 5.0ms here
const uint32_t ms_per_second = 1000;
// Define the data arrays
bool used_slots[MAX_TIMERS] = {0};
uint32_t slot_periods_ms[MAX_TIMERS] = {0};
uint32_t slot_period_triggers[MAX_TIMERS] = {0};
uint32_t slot_triggers[MAX_TIMERS] = {0};
custom_callback_function slot_callbacks[MAX_TIMERS] = {0};
void* slot_args[MAX_TIMERS] = {0};
// Utility Functions
// Check if any timers are currently scheduled
bool any_scheduled() {
for (int i = 0; i < MAX_TIMERS; i++) {
if (used_slots[i]) {
return true;
}
}
return false;
}
// UNUSED. Could update in the future to
// automatically change base "tick period"
// to the greatest common divisor of all
// scheduled timer periods. Basically,
// trying to give timer a dynamic resolution.
uint32_t gcd(uint32_t a, uint32_t b) {
while (b != 0) {
uint32_t temp = b;
b = a % b;
a = temp;
}
return a;
}
// The actual callback for the single running timer.
// Checks each slot, increments the trigger for each
// used timer slot, and invokes the associated callback
// if the required number of triggers reached.
void scheduler_trigger_callback() {
for (int i = 0; i < MAX_TIMERS; i++) {
if (!used_slots[i]) { continue; }
slot_triggers[i] += 1;
// Check for trigger in slot
if (slot_triggers[i] >= slot_period_triggers[i]) {
// "Reset" triggers for the slot
slot_triggers[i] -= slot_period_triggers[i];
// Perform trigger
slot_callbacks[i](slot_args[i]);
}
}
}
// Populates timer slot with data needed to run the
// repeating timer.
void schedule_timer_callback(int8_t slot_idx, uint32_t delay_ms, void (*userFunc)(void*), void *user_data) {
slot_periods_ms[slot_idx] = delay_ms;
slot_period_triggers[slot_idx] = ((uint64_t)delay_ms*scheduler_tick_freq) / (ms_per_second*scheduler_tick_resolution); // number of triggers to activate this callback
slot_triggers[slot_idx] = 0;
slot_callbacks[slot_idx] = userFunc;
slot_args[slot_idx] = user_data;
used_slots[slot_idx] = true;
print("Scheduled callback with period (%d ms / %d triggers)", slot_periods_ms[slot_idx], slot_period_triggers[slot_idx]);
}
#endif
Let's talk about the idea a bit more.
The point is to schedule only a single hardware timer with some minimum frequency.
At the same time, we have some number of "slots" to accomodate user-provided custom timer information.
When we schedule a new custom timer, the period of that timer is converted into some number of "triggers".
A trigger represents one period of the hardware timer, so each custom timer period is really measured by the number of times the hardware timer triggers.
Each time the timer is triggered, we call a special function that loops through all our desired, scheduled timers, incrementing their number of triggers.
When a specific custom timer has triggered a certain number of times, we finally invoke its callback and reset its trigger count.
By using a small value for the hardware timer period, we can increase the precision of the custom timers at the cost of having to run the special function more times per second.
Essentially, the tick resolution is being defined by the scheduler_tick_freq and scheduler_tick_resolution.
The scheduler_tick_freq defines the tick rate for the hardware timer itself (for timerBegin).
The scheduler_tick_resolution defines the number of ticks before the special scheduling callback is performed (for timerAlarm).
Increasing scheduler_tick_freq and decreasing scheduler_tick_resolution will improve the resolution of the timer.
Next up we have the actual interface for scheduling, changing, and cancelling timers. First is add_custom_timer:
// custom_timer.cpp continued
#if USE_RP2040
// Implementation skipped...
#elif USE_ESP32C6
// ...
// Interface for starting a repeating timer.
int8_t add_custom_timer(uint32_t delay_ms, void (*userFunc)(void*), void *user_data) {
print("add custom timer with delay_ms %d", delay_ms);
// Iterate slots, looking for open scheduling slot
int8_t slot = -1;
for (int i = 0; i < MAX_TIMERS; i++) {
if (!used_slots[i]) {
slot = i;
break;
}
}
if (slot < 0) { print("NO OPEN TIMER SLOTS"); return -1; } // Didn't find an open slot
bool should_restart = !any_scheduled(); // Restart if there were no timers scheduled before this one
schedule_timer_callback(slot, delay_ms, userFunc, user_data);
// Remake the hardware timer if needed
if (should_restart) {
scheduler_timer = timerBegin(scheduler_tick_freq);
timerAttachInterrupt(scheduler_timer, scheduler_trigger_callback);
timerAlarm(scheduler_timer, scheduler_tick_resolution, true, 0);
}
return slot;
}
#endif
This is relatively simple.
We try to add a repeating timer with some period (in ms) and some callback method to run upon activation along with other data to be passed to the callback.
We first iterate custom timer slots, looking for availability.
If we find no open slots, we can't schedule the timer and we're done.
If there is an open slot, then we simply fill in the slot's data using schedule_timer_callback.
If no custom timers were previously scheduled, then we must also restart the underlying hardware timer, configuring it as necessary.
One important aspect I neglected to mention before is returning the slot index.
This index is simply an ID telling the caller which slot the timer was scheduled into.
This is necessary to detect failures (index of -1) and to pass to other methods to change and cancel the correct timer.
Next up is change_period_ms for changing an existing timer's period:
// custom_timer.cpp continued
#if USE_RP2040
// Implementation skipped...
#elif USE_ESP32C6
// ...
// Interface for changing the period of an existing repeating timer
int8_t change_period_ms(int8_t slot_idx, uint32_t delay_ms, bool reset_triggers) {
if (slot_idx < 0 || slot_idx >= MAX_TIMERS) { return -1; }
if (used_slots[slot_idx]) {
slot_period_triggers[slot_idx] = (delay_ms*scheduler_tick_freq) / (ms_per_second*scheduler_tick_resolution); // triggers per activation of this callback
if (reset_triggers) { slot_triggers[slot_idx] = 0; }
return slot_idx;
}
return -1;
}
#endif
Here, we simply access the appropriate timer slot, recalculate the number of triggers necessary for the custom timer's callback, and replace the old value. There is also the option for resetting the number of triggers for the slot when we do so. It also returns the slot index on success and -1 on failure.
Finally, we have cancel_custom_timer:
// custom_timer.cpp continued
#if USE_RP2040
// Implementation skipped...
#elif USE_ESP32C6
// ...
// Interface for cancelling an existing timer.
int8_t cancel_custom_timer(int8_t slot_idx) {
if (slot_idx < 0 || slot_idx >= MAX_TIMERS) { return -1; }
// Directly remove the callback scheduled in slot
slot_periods_ms[slot_idx] = 0;
slot_period_triggers[slot_idx] = 0;
slot_triggers[slot_idx] = 0;
slot_callbacks[slot_idx] = NULL;
slot_args[slot_idx] = NULL;
used_slots[slot_idx] = false;
bool should_end = !any_scheduled();
if (should_end && scheduler_timer != NULL) {
timerStop(scheduler_timer);
scheduler_timer = NULL;
}
return slot_idx;
}
#endif
This method is also simple. Here, we just reset all of the data associated with the given slot index. Finally if, no slots have scheduled custom timers, then we stop the hardware timer as well. Returns the slot index on success and -1 on failure.
Conclusion
Here we looked at a simple custom repeating timer scheduler for the ESP32C6 in the Arduino IDE.
For my needs, I set the resolution incredibly low (5ms ticks) because most of my timings are for visuals and human input.
The code for the RP2040 architecture is mostly a wrapper around the add_repeating_timer_ms and cancel_repeating_timer methods.
Hopefully this helps anyone else who needs more custom timers for their ESP32 in the Arduino IDE.