Skip to content

radio_esp32::ets_timer

ETSTimer — the blob’s software timers, and esp_timer_get_time.

Six table entries: _timer_setfn, _timer_arm, _timer_arm_us, _timer_disarm, _timer_done and _esp_timer_get_time.

The blob owns the memory, we own the meaning

Section titled “The blob owns the memory, we own the meaning”

An ETSTimer is a struct the blob allocates and hands back as a void *. Its layout is esp-idf’s, and writing into it would mean matching a struct definition that is not in anything the archives ship. So the pointer is used only as an identity — a key into a table here — and never dereferenced. That costs a linear scan over at most [MAX_TIMERS] entries and buys not having to guess a layout.

The obvious implementation hangs the scan off kernel::timer, whose callbacks run in trap context. esp-idf’s do not: an ETSTimer callback runs on the timer task, and the blob’s callbacks are written for that — they take semaphores, post to queues, and call back into the blob. kernel::queue refuses to block in interrupt context, correctly, so those callbacks would start failing in ways that look like the radio misbehaving.

So [start] spawns a task that sleeps and scans. That also puts a floor under the resolution: the scan runs once per scheduler tick, so a timer armed for less than a tick fires at the next one. _timer_arm_us is therefore honest about milliseconds and approximate below that, which is documented on it rather than hidden.

The poll is the wrong shape, and this is where that gets fixed

Section titled “The poll is the wrong shape, and this is where that gets fixed”

“Approximate below a millisecond” was written here as a limitation. It is a defect, and all three references agree on what the right shape is:

  • esp-idf v4.4 routes ets_timer_arm* to esp_timer_start_once / _periodic, backed by the TG0 LAC timer — a 64-bit up-counter with a programmable alarm and a level interrupt. The ISR does vTaskNotifyGiveFromISR; the callback runs on timer_task at ESP_TASK_PRIO_MAX - 3.
  • NuttX does the same, explicitly with .dispatch_method = ESP_TIMER_TASK.
  • Arduino is esp-idf with a different build system, so it inherits it. Not independent evidence.

An alarm, not a poll. _timer_arm_us(t, 100, false) here fires at the next millisecond — up to ten times late, and jittered by whatever else is runnable — and the Wi-Fi MAC arms microsecond timers. A state machine given them ten times late is being lied to.

That is also the leading explanation for the timing-sensitive hang in esp_wifi_start (doc/plan-radio.md), because it predicts both of that bug’s properties: it needs this task to exist, and it moves when a few microseconds of unrelated work shift the phase of the poll.

The port costs no general-purpose timer. The LAC timer is a separate counter inside a timer group (TIMG_LACTCONFIG_REG), not one of the four GP timers — which is why esp-idf chose it, and it matters here because FlintOS has all four spoken for: TIMG1/T1 is kernel::clock, and TIMG0/T0, TIMG0/T1 and TIMG1/T0 drive on-target self-tests.

unsafe fn esp_timer_get_time() -> i64

_esp_timer_get_time() — microseconds since boot.

i64 because that is esp-idf’s signature. The underlying counter is 64-bit at 1 MHz, so the sign bit is 292,000 years away.

Reads a timer. Called by the blob.

fn start()

Start the timer service. Idempotent.

Brings up [kernel::alarm] — TG0’s LAC timer — and spawns the service task. Nothing arms a timer until the blob does, so this is cheap until then: a task asleep on a 50 ms backstop.

The alarm is initialised here rather than in kernel::boot because the radio is its only user, and an application that never touches the radio should not spend a CPU interrupt on it.

Nothing called this, and the radio could not scan

Section titled “Nothing called this, and the radio could not scan”

The five timer entries went into the OSI table, the blob armed timers through them, collect_due was written and host-tested — and no task ever ran it, because start had no caller anywhere in the tree. A scan hops thirteen channels on a software timer, so it advanced only when the driver gave up on each channel: 8.7 seconds to time out, then a SCAN_DONE with zero results.

It is called from [crate::wifi::init] now, which is the only place that can guarantee “before the blob is initialised”, rather than being left to each application to remember. That is the fix for the class as well as the instance: a service the adapter depends on should not be an application’s responsibility to start.

unsafe fn timer_arm(timer: *mut core::ffi::c_void, ms: u32, repeat: bool)

_timer_arm(timer, ms, repeat).

timer is identity only. Called by the blob.

unsafe fn timer_arm_us(timer: *mut core::ffi::c_void, us: u32, repeat: bool)

_timer_arm_us(timer, us, repeat).

Resolution is one scheduler tick, not one microsecond. The scan task wakes on the tick, so anything armed for less than that fires at the next one. The blob uses this for timeouts in the tens of milliseconds, where the difference does not arise; a genuine sub-millisecond timer would need a hardware alarm per timer rather than a scan.

timer is identity only. Called by the blob.

unsafe fn timer_disarm(timer: *mut core::ffi::c_void)

_timer_disarm(timer) — stop it firing, keep the callback.

timer is identity only. Called by the blob.

unsafe fn timer_done(timer: *mut core::ffi::c_void)

_timer_done(timer) — release the entry.

The blob frees its own ETSTimer after this, so the handle must not stay in the table: the same address can come back as a different timer, and a stale entry would fire someone else’s callback.

timer is identity only. Called by the blob.

unsafe fn timer_setfn(timer: *mut core::ffi::c_void, f: *mut core::ffi::c_void, arg: *mut core::ffi::c_void)

_timer_setfn(timer, f, arg) — attach a callback to a timer.

Creates the entry if this handle has not been seen. Does not arm it; esp-idf’s ets_timer_setfn does not either.

timer is used as identity and never dereferenced. Called by the blob.

const MAX_TIMERS: usize;

Timers the blob may have at once.

The ROM’s own ets_timer is an unbounded linked list, so this is a static stand-in, not a value the reference fixes. 16 covered scanning — but a connect arms more (auth and association timeouts on top of the scan and connection-management timers), and overran 16 by two at the association step, failing with AssocFailed. 32 is headroom over that; note_peak logs the real high-water mark so the number stays honest, and running out is still reported rather than silently dropping a timer.