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.
Callbacks run on a task, not on the tick
Section titled “Callbacks run on a task, not on the tick”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*toesp_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 doesvTaskNotifyGiveFromISR; the callback runs ontimer_taskatESP_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.
Functions
Section titled “Functions”esp_timer_get_time
Section titled “esp_timer_get_time”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.
Safety
Section titled “Safety”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.
timer_arm
Section titled “timer_arm”unsafe fn timer_arm(timer: *mut core::ffi::c_void, ms: u32, repeat: bool)_timer_arm(timer, ms, repeat).
Safety
Section titled “Safety”timer is identity only. Called by the blob.
timer_arm_us
Section titled “timer_arm_us”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.
Safety
Section titled “Safety”timer is identity only. Called by the blob.
timer_disarm
Section titled “timer_disarm”unsafe fn timer_disarm(timer: *mut core::ffi::c_void)_timer_disarm(timer) — stop it firing, keep the callback.
Safety
Section titled “Safety”timer is identity only. Called by the blob.
timer_done
Section titled “timer_done”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.
Safety
Section titled “Safety”timer is identity only. Called by the blob.
timer_setfn
Section titled “timer_setfn”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.
Safety
Section titled “Safety”timer is used as identity and never dereferenced. Called by the blob.
Constants
Section titled “Constants”MAX_TIMERS
Section titled “MAX_TIMERS”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.