From 406546876a1be7391a4c9aed6f449b0343545c59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:16:38 -1000 Subject: [PATCH 01/14] [esp8266] Replace millis() with fast accumulator, wrap Arduino callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arduino ESP8266's millis() uses 4x 64-bit multiplies with magic constants to convert system_get_time() to ms while tracking overflow. On the LX106 (no hardware multiply-high instruction), each 64-bit multiply goes through the __umulsidi3 software helper — costing ~3.3 us per call. Replace with a simple accumulator that tracks a running millis counter from system_get_time() deltas using pure 32-bit integer ops (subtract, add, compare, subtract). No 64-bit math, no __umulsidi3. Use -Wl,--wrap=millis to intercept all ::millis() calls site-wide so Arduino libraries and ISR handlers (Wiegand, ZyAura) also get the fast version. Brief interrupt disable (~125 ns) protects the static state. Overflow safety: unsigned 32-bit delta arithmetic handles the 71-minute system_get_time() wrap correctly for one wrap. ESPHome calls millis() thousands of times per second, so missing a full wrap is not a realistic concern. At boot, both the accumulator and system_get_time() start at 0, so no special initialization is needed. Benchmarked on real ESP8266 hardware: Before: 3348 ns/call (Arduino 4x 64-bit multiply) After: ~800-900 ns/call (accumulator, estimated) --- esphome/components/esp8266/__init__.py | 5 +++ esphome/components/esp8266/core.cpp | 44 ++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index bef7e36470..34540bd48d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -314,6 +314,11 @@ async def to_code(config): for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap Arduino's millis() so all callers (including Arduino libraries and ISR + # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply + # implementation in the Arduino ESP8266 core. + cg.add_build_flag("-Wl,--wrap=millis") + cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 159ec20e77..a4a6c49cf6 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -16,8 +16,44 @@ extern "C" { namespace esphome { void HOT yield() { ::yield(); } -uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -uint64_t millis_64() { return Millis64Impl::compute(::millis()); } +// Arduino ESP8266's millis() uses 4× 64-bit multiplies with magic constants to +// convert system_get_time() → ms while tracking overflow (~3.3 μs per call on +// the LX106 which has no hardware multiply-high instruction). We replace it with +// a simple accumulator that tracks a running millis counter from μs deltas using +// pure 32-bit ops (subtract, add, compare-and-subtract). No __umulsidi3 software +// multiply calls. +// +// Overflow safety: system_get_time() is a uint32_t that wraps every ~71.6 minutes. +// Unsigned subtraction (now - last) handles one wrap correctly. ESPHome calls +// millis() thousands of times per second (1+N per loop iteration at 60+ Hz), so +// missing a full 71-minute wrap period is not a realistic concern. At boot, +// s_last_us starts at 0 and system_get_time() counts from 0, so the first call's +// delta equals the real elapsed time — no special initialization needed. +// +// This function is also installed as __wrap_millis (via -Wl,--wrap=millis) so +// that Arduino library code and ISR handlers (e.g. Wiegand, ZyAura) calling +// ::millis() directly also get the fast version. Interrupts are briefly disabled +// (~10 instructions, ~125 ns at 80 MHz) to protect the static state from +// concurrent ISR access. +static uint32_t IRAM_ATTR HOT millis_accumulator_() { + static uint32_t s_cache = 0; + static uint32_t s_remainder = 0; + static uint32_t s_last_us = 0; + uint32_t ps = xt_rsil(15); + uint32_t now_us = system_get_time(); + uint32_t delta = now_us - s_last_us; + s_last_us = now_us; + s_remainder += delta; + while (s_remainder >= 1000) { + s_cache++; + s_remainder -= 1000; + } + uint32_t result = s_cache; + xt_wsr_ps(ps); + return result; +} +uint32_t IRAM_ATTR HOT millis() { return millis_accumulator_(); } +uint64_t millis_64() { return Millis64Impl::compute(millis()); } void HOT delay(uint32_t ms) { ::delay(ms); } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } @@ -78,4 +114,8 @@ extern "C" void resetPins() { // NOLINT } // namespace esphome +// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. +// Requires -Wl,--wrap=millis in build flags (added by __init__.py). +extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } + #endif // USE_ESP8266 From 16f7effcf33edddcc4165f72ede716eb74f1223f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:21:57 -1000 Subject: [PATCH 02/14] [esp8266] Fix clang-format: drop trailing underscore from static function --- esphome/components/esp8266/core.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index a4a6c49cf6..0cbb4a008d 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -35,7 +35,7 @@ void HOT yield() { ::yield(); } // ::millis() directly also get the fast version. Interrupts are briefly disabled // (~10 instructions, ~125 ns at 80 MHz) to protect the static state from // concurrent ISR access. -static uint32_t IRAM_ATTR HOT millis_accumulator_() { +static uint32_t IRAM_ATTR HOT millis_accumulator() { static uint32_t s_cache = 0; static uint32_t s_remainder = 0; static uint32_t s_last_us = 0; @@ -52,7 +52,7 @@ static uint32_t IRAM_ATTR HOT millis_accumulator_() { xt_wsr_ps(ps); return result; } -uint32_t IRAM_ATTR HOT millis() { return millis_accumulator_(); } +uint32_t IRAM_ATTR HOT millis() { return millis_accumulator(); } uint64_t millis_64() { return Millis64Impl::compute(millis()); } void HOT delay(uint32_t ms) { ::delay(ms); } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } From dff6199fc8cb4bf46848bf519c8c7c778bc906e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:37:45 -1000 Subject: [PATCH 03/14] [esp8266] Add USE_FAST_MILLIS_ACCUMULATOR define for benchmark guard --- esphome/components/esp8266/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 34540bd48d..f8c37e3e94 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -318,6 +318,7 @@ async def to_code(config): # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply # implementation in the Arduino ESP8266 core. cg.add_build_flag("-Wl,--wrap=millis") + cg.add_define("USE_FAST_MILLIS_ACCUMULATOR") cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) From 325d9db499c4a5b346cab750fa491f656eaaf3ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:56:59 -1000 Subject: [PATCH 04/14] [esp8266] Replace delay() with yield loop to allow GC of original millis Arduino's ::delay() is implemented in core_esp8266_wiring.cpp alongside millis(). Because __delay calls millis() within the same compilation unit, --wrap=millis can't intercept that intra-object reference, which prevents the linker from garbage-collecting the original millis body (~80 bytes of IRAM). Replace esphome::delay() with a simple yield loop that uses our fast millis() accumulator. This has the same behavior as Arduino's delay: feeds the watchdog and processes SDK tasks via yield(), keeping WiFi alive. With __delay unreferenced, the linker can now GC both __delay and the original millis function. --- esphome/components/esp8266/core.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 0cbb4a008d..68ca8a903b 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -54,7 +54,17 @@ static uint32_t IRAM_ATTR HOT millis_accumulator() { } uint32_t IRAM_ATTR HOT millis() { return millis_accumulator(); } uint64_t millis_64() { return Millis64Impl::compute(millis()); } -void HOT delay(uint32_t ms) { ::delay(ms); } +// Avoid calling ::delay() which pulls in __delay from core_esp8266_wiring.cpp. +// __delay has an intra-object call to the original millis() that --wrap=millis +// can't intercept, preventing the linker from garbage-collecting the expensive +// original millis body (~80 bytes IRAM). This yield loop achieves the same +// behavior: feeds the watchdog, processes SDK tasks, keeps WiFi alive. +void HOT delay(uint32_t ms) { + uint32_t start = millis(); + while (millis() - start < ms) { + yield(); + } +} uint32_t IRAM_ATTR HOT micros() { return ::micros(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { From cb654a1e503c3e3554213290c25430255e5eac7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:58:13 -1000 Subject: [PATCH 05/14] [esp8266] Handle delay(0) as a yield point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arduino's delay(0) calls yield() once — used as a yield point by some code. Our millis-based loop would skip yielding entirely since 0 < 0 is false. Add explicit delay(0) → yield() handling. --- esphome/components/esp8266/core.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 68ca8a903b..32dd097c8d 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -60,9 +60,13 @@ uint64_t millis_64() { return Millis64Impl::compute(millis()); } // original millis body (~80 bytes IRAM). This yield loop achieves the same // behavior: feeds the watchdog, processes SDK tasks, keeps WiFi alive. void HOT delay(uint32_t ms) { + if (ms == 0) { + optimistic_yield(1000); + return; + } uint32_t start = millis(); while (millis() - start < ms) { - yield(); + optimistic_yield(1000); } } uint32_t IRAM_ATTR HOT micros() { return ::micros(); } From dff7757d1e2cb996d0a2c66a5d6075c0a4a71bb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:02:16 -1000 Subject: [PATCH 06/14] [esp8266] Suppress clang-tidy for __wrap_millis reserved identifier The __wrap_ prefix is required by the GNU linker's --wrap feature. It's a reserved identifier by C++ rules but mandated by the toolchain. --- esphome/components/esp8266/core.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 32dd097c8d..2a5349b143 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -130,6 +130,7 @@ extern "C" void resetPins() { // NOLINT // Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. // Requires -Wl,--wrap=millis in build flags (added by __init__.py). +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } #endif // USE_ESP8266 From bf2040355867b823859e06bf3708ac444476e60f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:08:53 -1000 Subject: [PATCH 07/14] =?UTF-8?q?[esp8266]=20Revert=20init()=20wrap=20?= =?UTF-8?q?=E2=80=94=20micros64()=20needs=20the=20overflow=20timer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arduino's micros64() reads the same overflow tracking statics that init() sets up. Wrapping init() as a no-op would cause micros64() to silently return wrong values after 71 minutes. The overflow timer cost is negligible (~3 μs per 60 seconds). --- esphome/components/esp8266/core.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 2a5349b143..6df0db7a1c 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -132,5 +132,8 @@ extern "C" void resetPins() { // NOLINT // Requires -Wl,--wrap=millis in build flags (added by __init__.py). // NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } +// Note: Arduino's init() registers a 60-second overflow timer for micros64(). +// We leave it running — wrapping init() as a no-op would break micros64()'s +// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). #endif // USE_ESP8266 From 3d3e26d141af2bce145948d966921993b5aedacc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:10:19 -1000 Subject: [PATCH 08/14] [esp8266] Merge millis_accumulator into millis(), use struct for statics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge the separate millis_accumulator() function directly into millis() to eliminate the extra function call overhead and prologue/epilogue. Pack the three statics into a struct so the compiler loads one base address instead of three literal pool entries. Saves ~12 bytes of IRAM (87+6 → 81 bytes). --- esphome/components/esp8266/core.cpp | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 6df0db7a1c..02aea6f2e8 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -35,24 +35,27 @@ void HOT yield() { ::yield(); } // ::millis() directly also get the fast version. Interrupts are briefly disabled // (~10 instructions, ~125 ns at 80 MHz) to protect the static state from // concurrent ISR access. -static uint32_t IRAM_ATTR HOT millis_accumulator() { - static uint32_t s_cache = 0; - static uint32_t s_remainder = 0; - static uint32_t s_last_us = 0; +uint32_t IRAM_ATTR HOT millis() { + // Struct packs the three statics so the compiler loads one base address + // instead of three separate literal pool entries (saves ~8 bytes IRAM). + static struct { + uint32_t cache; + uint32_t remainder; + uint32_t last_us; + } state = {0, 0, 0}; uint32_t ps = xt_rsil(15); uint32_t now_us = system_get_time(); - uint32_t delta = now_us - s_last_us; - s_last_us = now_us; - s_remainder += delta; - while (s_remainder >= 1000) { - s_cache++; - s_remainder -= 1000; + uint32_t delta = now_us - state.last_us; + state.last_us = now_us; + state.remainder += delta; + while (state.remainder >= 1000) { + state.cache++; + state.remainder -= 1000; } - uint32_t result = s_cache; + uint32_t result = state.cache; xt_wsr_ps(ps); return result; } -uint32_t IRAM_ATTR HOT millis() { return millis_accumulator(); } uint64_t millis_64() { return Millis64Impl::compute(millis()); } // Avoid calling ::delay() which pulls in __delay from core_esp8266_wiring.cpp. // __delay has an intra-object call to the original millis() that --wrap=millis From aec0be356faba48cb0c206da4e9f78aa0dc62526 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:16:06 -1000 Subject: [PATCH 09/14] [esp8266] Remove unused USE_FAST_MILLIS_ACCUMULATOR define --- esphome/components/esp8266/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index f8c37e3e94..34540bd48d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -318,7 +318,6 @@ async def to_code(config): # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply # implementation in the Arduino ESP8266 core. cg.add_build_flag("-Wl,--wrap=millis") - cg.add_define("USE_FAST_MILLIS_ACCUMULATOR") cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) From da9c44d28dc5e247867e5a998d1a3c3e31e37698 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:21:15 -1000 Subject: [PATCH 10/14] [esp8266] Bound the critical section in millis() accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the μs→ms conversion into two paths to keep the interrupt- disabled critical section bounded: - Common path (delta < 10 ms): while loop runs at most 10 iterations (~100 ns). This covers the normal hot-path case where millis() is called thousands of times per second. - Rare path (delta >= 10 ms): constant-time multiply-by-reciprocal via /1000 (compiled to __umulsidi3, ~2.5 μs). Only fires after a long block (WiFi scan, boot, component stall), where the extra latency is negligible relative to the block that caused it. Addresses the concern that a multi-second WiFi scan block could cause the unbounded while loop to hold interrupts for tens of microseconds. --- esphome/components/esp8266/core.cpp | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 02aea6f2e8..f7106e077e 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -33,8 +33,10 @@ void HOT yield() { ::yield(); } // This function is also installed as __wrap_millis (via -Wl,--wrap=millis) so // that Arduino library code and ISR handlers (e.g. Wiegand, ZyAura) calling // ::millis() directly also get the fast version. Interrupts are briefly disabled -// (~10 instructions, ~125 ns at 80 MHz) to protect the static state from -// concurrent ISR access. +// to protect the static state from concurrent ISR access. The critical section +// is bounded: the common path (delta < 10 ms) runs at most 10 subtract-and- +// compare iterations (~100 ns). Large gaps (WiFi scan, boot) fall back to a +// constant-time multiply-by-reciprocal (~2.5 μs, rare). uint32_t IRAM_ATTR HOT millis() { // Struct packs the three statics so the compiler loads one base address // instead of three separate literal pool entries (saves ~8 bytes IRAM). @@ -48,9 +50,22 @@ uint32_t IRAM_ATTR HOT millis() { uint32_t delta = now_us - state.last_us; state.last_us = now_us; state.remainder += delta; - while (state.remainder >= 1000) { - state.cache++; - state.remainder -= 1000; + if (state.remainder >= 10000) { + // Rare path: large gap (>10 ms — WiFi scan, boot, long block). + // Use constant-time multiply-by-reciprocal (compiled to __umulsidi3, + // ~2.5 μs) to keep the critical section bounded. Only fires when the + // caller was already blocked for >10 ms, so the extra latency is + // negligible relative to the block that caused it. + uint32_t ms = state.remainder / 1000; + state.cache += ms; + state.remainder -= ms * 1000; + } else { + // Common path: small gap (<10 ms). Loop runs at most 10 times + // (~100 ns), well within the WiFi stack's ~10 μs interrupt budget. + while (state.remainder >= 1000) { + state.cache++; + state.remainder -= 1000; + } } uint32_t result = state.cache; xt_wsr_ps(ps); From b882d8c7218119a4e058fbf092aee958f8bd3655 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:31:48 -1000 Subject: [PATCH 11/14] [esp8266] Fix stale s_last_us reference in comment Updated to state.last_us to match the struct refactor. --- esphome/components/esp8266/core.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index f7106e077e..91854e3877 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -27,7 +27,7 @@ void HOT yield() { ::yield(); } // Unsigned subtraction (now - last) handles one wrap correctly. ESPHome calls // millis() thousands of times per second (1+N per loop iteration at 60+ Hz), so // missing a full 71-minute wrap period is not a realistic concern. At boot, -// s_last_us starts at 0 and system_get_time() counts from 0, so the first call's +// state.last_us starts at 0 and system_get_time() counts from 0, so the first call's // delta equals the real elapsed time — no special initialization needed. // // This function is also installed as __wrap_millis (via -Wl,--wrap=millis) so From ebbc3b57c779430234f922e2ce135301f37b2815 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:34:14 -1000 Subject: [PATCH 12/14] [esp8266] Document delay() semantic difference from Arduino Arduino's delay() uses esp_suspend() with a one-shot os_timer for efficient single-suspension waiting. Our replacement polls millis() with optimistic_yield(), which also enters esp_schedule/esp_suspend via yield() but resumes repeatedly. Functionally correct for ESPHome (delay is cold path, SDK tasks run via yield), just less power- efficient for long delays. --- esphome/components/esp8266/core.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 91854e3877..c26cddea7e 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -75,8 +75,14 @@ uint64_t millis_64() { return Millis64Impl::compute(millis()); } // Avoid calling ::delay() which pulls in __delay from core_esp8266_wiring.cpp. // __delay has an intra-object call to the original millis() that --wrap=millis // can't intercept, preventing the linker from garbage-collecting the expensive -// original millis body (~80 bytes IRAM). This yield loop achieves the same -// behavior: feeds the watchdog, processes SDK tasks, keeps WiFi alive. +// original millis body (~80 bytes IRAM). +// +// Semantic difference from Arduino's delay(): Arduino sets up a one-shot +// os_timer and calls esp_suspend() to suspend the continuation once for the +// full duration. Our loop polls millis() + optimistic_yield(1000) which still +// calls esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks +// and WiFi run correctly. Less power-efficient for long delays, but ESPHome +// uses delay() only during setup and OTA — never on the hot path. void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); From c863a204e8514aecef9ceb5da8db686c6be650f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 23:36:40 -1000 Subject: [PATCH 13/14] [esp8266] Fix incorrect claim about delay() usage scope delay() is called from component loop() and update() methods too (NFC, RTTTL, sensors), not just setup/OTA. --- esphome/components/esp8266/core.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index c26cddea7e..ab991da9f4 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -81,8 +81,8 @@ uint64_t millis_64() { return Millis64Impl::compute(millis()); } // os_timer and calls esp_suspend() to suspend the continuation once for the // full duration. Our loop polls millis() + optimistic_yield(1000) which still // calls esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks -// and WiFi run correctly. Less power-efficient for long delays, but ESPHome -// uses delay() only during setup and OTA — never on the hot path. +// and WiFi run correctly. Less power-efficient for long delays but +// functionally equivalent. void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); From a8cb5b44cdd43f905a0e2a4c40ec689102f95490 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 00:03:23 -1000 Subject: [PATCH 14/14] [esp8266] Address review: fix misleading comment, add named constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 'No __umulsidi3' claim from top comment — the rare path does use it via /1000. Reworded to 'pure 32-bit ops on the common path'. - Extract MILLIS_RARE_PATH_THRESHOLD_US and US_PER_MS constants to replace magic numbers 10000 and 1000. --- esphome/components/esp8266/core.cpp | 31 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index ab991da9f4..7551ecc3a7 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -20,8 +20,8 @@ void HOT yield() { ::yield(); } // convert system_get_time() → ms while tracking overflow (~3.3 μs per call on // the LX106 which has no hardware multiply-high instruction). We replace it with // a simple accumulator that tracks a running millis counter from μs deltas using -// pure 32-bit ops (subtract, add, compare-and-subtract). No __umulsidi3 software -// multiply calls. +// pure 32-bit ops on the common path (subtract, add, compare-and-subtract). +// Large gaps (>10 ms) fall back to a constant-time /1000 conversion. // // Overflow safety: system_get_time() is a uint32_t that wraps every ~71.6 minutes. // Unsigned subtraction (now - last) handles one wrap correctly. ESPHome calls @@ -37,6 +37,12 @@ void HOT yield() { ::yield(); } // is bounded: the common path (delta < 10 ms) runs at most 10 subtract-and- // compare iterations (~100 ns). Large gaps (WiFi scan, boot) fall back to a // constant-time multiply-by-reciprocal (~2.5 μs, rare). +// Threshold above which we use constant-time /1000 instead of the while loop. +// 10 ms means the while loop runs at most 10 iterations (~100 ns) on the +// common path, well within the WiFi stack's ~10 μs interrupt latency budget. +static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; +static constexpr uint32_t US_PER_MS = 1000; + uint32_t IRAM_ATTR HOT millis() { // Struct packs the three statics so the compiler loads one base address // instead of three separate literal pool entries (saves ~8 bytes IRAM). @@ -50,21 +56,18 @@ uint32_t IRAM_ATTR HOT millis() { uint32_t delta = now_us - state.last_us; state.last_us = now_us; state.remainder += delta; - if (state.remainder >= 10000) { - // Rare path: large gap (>10 ms — WiFi scan, boot, long block). - // Use constant-time multiply-by-reciprocal (compiled to __umulsidi3, - // ~2.5 μs) to keep the critical section bounded. Only fires when the - // caller was already blocked for >10 ms, so the extra latency is - // negligible relative to the block that caused it. - uint32_t ms = state.remainder / 1000; + if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { + // Rare path: large gap (WiFi scan, boot, long block). Constant-time + // conversion keeps the critical section bounded. + uint32_t ms = state.remainder / US_PER_MS; state.cache += ms; - state.remainder -= ms * 1000; + state.remainder -= ms * US_PER_MS; } else { - // Common path: small gap (<10 ms). Loop runs at most 10 times - // (~100 ns), well within the WiFi stack's ~10 μs interrupt budget. - while (state.remainder >= 1000) { + // Common path: small gap. Loop runs at most + // MILLIS_RARE_PATH_THRESHOLD_US / US_PER_MS iterations. + while (state.remainder >= US_PER_MS) { state.cache++; - state.remainder -= 1000; + state.remainder -= US_PER_MS; } } uint32_t result = state.cache;