From e23a6bf59f28b896b45da319d98086010e8ab683 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 11:23:16 -0500 Subject: [PATCH] [core] Inline micros()/millis_64() at the HAL layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-function ``__attribute__((optimize("O2")))`` approach in #15693 with a direct inline definition of micros() / millis_64() in hal.h for ESP32, plus inline definitions for ESP8266 micros() and RP2040 millis()/millis_64(). The original goal of #15693 was to inline micros() into the main loop so that ``call esphome::micros() → call esp_timer_get_time()`` collapses to a single ``call esp_timer_get_time``. That wrapper-collapse benefits every micros() call site, not just loop_task — so the real fix is to mark the wrapper inline, not to bump the loop's optimization level. Doing this at the HAL layer also makes runtime_stats measurements more accurate: each timing read no longer hides a wrapper call/return between the component end-time capture and the underlying clock read. Refactor: move ``micros_to_millis<>()`` from helpers.h to a new lightweight ``time_conversion.h`` so hal.h can include it without pulling the rest of helpers.h into every TU that includes hal.h. --- esphome/components/esp32/core.cpp | 17 +++++----- esphome/components/esp8266/core.cpp | 3 +- esphome/components/rp2040/core.cpp | 4 +-- esphome/core/hal.h | 43 +++++++++++++++++++++++++ esphome/core/helpers.h | 41 +++-------------------- esphome/core/time_conversion.h | 50 +++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 48 deletions(-) create mode 100644 esphome/core/time_conversion.h diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 1c63137183..9df358f2f9 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -37,15 +37,16 @@ uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); #endif } -// millis_64() stays on esp_timer — a different clock from xTaskGetTickCount(). This is -// safe because the two are never cross-compared: millis() values are only used for -// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), while -// millis_64() is used by the Scheduler and uptime sensors. On ESP32 (USE_NATIVE_64BIT_TIME), -// Scheduler::millis_64_from_(now) discards the 32-bit now and calls millis_64() directly, -// so the Scheduler is internally consistent on the esp_timer clock. -uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } +// millis_64() and micros() are defined inline in hal.h on ESP32. Both sit on +// esp_timer — a different clock from xTaskGetTickCount(). That is safe because +// the two are never cross-compared: millis() values are only used for +// millis()-vs-millis() deltas (feed_wdt, warn_blocking, component start time), +// while millis_64() is used by the Scheduler and uptime sensors. On ESP32 +// (USE_NATIVE_64BIT_TIME), Scheduler::millis_64_from_(now) discards the 32-bit +// now and calls millis_64() directly, so the Scheduler is internally consistent +// on the esp_timer clock. Inlining both collapses the wrapper call/return that +// runtime_stats and the main-loop hot path would otherwise incur every iteration. void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } -uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { esp_restart(); diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 159ec20e77..c08cce718f 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -19,7 +19,8 @@ void HOT yield() { ::yield(); } uint32_t IRAM_ATTR HOT millis() { return ::millis(); } uint64_t millis_64() { return Millis64Impl::compute(::millis()); } void HOT delay(uint32_t ms) { ::delay(ms); } -uint32_t IRAM_ATTR HOT micros() { return ::micros(); } +// micros() is defined inline in hal.h on ESP8266 so callers collapse the +// wrapper down to a direct ::micros() call. void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { system_restart(); diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index b7a9000612..74d33ec049 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -14,8 +14,8 @@ namespace esphome { void HOT yield() { ::yield(); } -uint64_t millis_64() { return micros_to_millis(time_us_64()); } -uint32_t HOT millis() { return micros_to_millis(time_us_64()); } +// millis() and millis_64() are defined inline in hal.h on RP2040 so callers +// collapse the wrapper down to a direct time_us_64() call. void HOT delay(uint32_t ms) { ::delay(ms); } uint32_t HOT micros() { return ::micros(); } void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index e4083622b9..bd8792e0ed 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -2,6 +2,7 @@ #include #include #include "gpio.h" +#include "esphome/core/time_conversion.h" #if defined(USE_ESP32) #include @@ -70,6 +71,18 @@ extern "C" uint32_t platform_is_in_interrupt_context(void); #endif +#ifdef USE_ESP8266 +// Forward-declared from (esp8266 core) so the inline esphome::micros() +// wrapper below can call it without pulling Arduino.h into every TU. +extern "C" unsigned long micros(void); // NOLINT(google-runtime-int,readability-identifier-naming) +#endif + +#ifdef USE_RP2040 +// Forward-declared from so the inline esphome::millis()/millis_64() +// wrappers below can call it without pulling pico/time.h into every TU. +extern "C" uint64_t time_us_64(void); +#endif + namespace esphome { /// Returns true when executing inside an interrupt handler. @@ -103,9 +116,39 @@ __attribute__((always_inline)) inline bool in_isr_context() { } void yield(); +#if defined(USE_ESP32) +// Forward-declared from to avoid pulling the full header (and its +// transitive IDF deps) into every translation unit that includes hal.h. Signature +// is stable IDF public API. +extern "C" int64_t esp_timer_get_time(void); + +uint32_t millis(); +// Inlined so callers (especially the main loop via runtime_stats) collapse the +// wrapper: ``call micros → call esp_timer_get_time`` becomes a single +// ``call esp_timer_get_time``. Inlining into an IRAM_ATTR ISR is safe because +// ``esp_timer_get_time`` itself lives in IRAM (IDF marks it so). +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(esp_timer_get_time()); } +__attribute__((always_inline)) inline uint64_t millis_64() { + return micros_to_millis(static_cast(esp_timer_get_time())); +} +#elif defined(USE_ESP8266) +// Arduino's ::micros() (esp8266 core) is itself in IRAM, so inlining our wrapper +// into IRAM_ATTR ISRs is safe. Forward-declared at global scope below to avoid +// pulling Arduino.h into every TU that includes hal.h. +__attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } uint32_t millis(); uint64_t millis_64(); +#elif defined(USE_RP2040) uint32_t micros(); +// Pico SDK clock used by ::millis()/time_us_64() under the hood; forward-declared +// at global scope below. +__attribute__((always_inline)) inline uint32_t millis() { return micros_to_millis(::time_us_64()); } +__attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis(::time_us_64()); } +#else +uint32_t millis(); +uint32_t micros(); +uint64_t millis_64(); +#endif void delay(uint32_t ms); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) void __attribute__((noreturn)) arch_restart(); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 4a91c46074..366ae6da9e 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -20,6 +20,7 @@ #include #include "esphome/core/optional.h" +#include "esphome/core/time_conversion.h" // Backward compatibility re-export of heap-allocating helpers. // These functions have moved to alloc_helpers.h. External components should @@ -833,43 +834,9 @@ template constexpr uint32_t fnv1a_hash_extend(uint32_t hash, T constexpr uint32_t fnv1a_hash(const char *str) { return fnv1a_hash_extend(FNV1_OFFSET_BASIS, str); } inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str()); } -/// Convert a 64-bit microsecond count to milliseconds without calling -/// __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz). -/// -/// Returns uint32_t by default (for millis()), or uint64_t when requested -/// (for millis_64()). The only difference is whether hi * Q is truncated -/// to 32 bits or widened to 64. -/// -/// On 32-bit targets, GCC does not optimize 64-bit constant division into a -/// multiply-by-reciprocal. Since 1000 = 8 * 125, we first right-shift by 3 -/// (free divide-by-8), then use the Euclidean division identity to decompose -/// the remaining 64-bit divide-by-125 into a single 32-bit division: -/// -/// floor(us / 1000) = floor(floor(us / 8) / 125) [exact for integers] -/// 2^32 = Q * 125 + R (34359738 * 125 + 46) -/// (hi * 2^32 + lo) / 125 = hi * Q + (hi * R + lo) / 125 -/// -/// GCC optimizes the remaining 32-bit "/ 125U" into a multiply-by-reciprocal -/// (mulhu + shift), so no division instruction is emitted. -/// -/// Safe for us up to ~3.2e18 (~101,700 years of microseconds). -/// -/// See: https://en.wikipedia.org/wiki/Euclidean_division -/// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html -template inline constexpr ESPHOME_ALWAYS_INLINE ReturnT micros_to_millis(uint64_t us) { - constexpr uint32_t d = 125U; - constexpr uint32_t q = static_cast((1ULL << 32) / d); // 34359738 - constexpr uint32_t r = static_cast((1ULL << 32) % d); // 46 - // 1000 = 8 * 125; divide-by-8 is a free shift - uint64_t x = us >> 3; - uint32_t lo = static_cast(x); - uint32_t hi = static_cast(x >> 32); - // Combine remainder term: hi * (2^32 % 125) + lo - uint32_t adj = hi * r + lo; - // If adj overflowed, the true value is 2^32 + adj; apply the identity again - // static_cast(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q - return static_cast(hi) * q + (adj < lo ? (adj + r) / d + q : adj / d); -} +// micros_to_millis<>() lives in its own lightweight header so hal.h can pull it +// in for inline millis_64() without forcing every TU that includes hal.h to +// also include the rest of helpers.h. /// Return a random 32-bit unsigned integer. /// Not thread-safe. Must only be called from the main loop. diff --git a/esphome/core/time_conversion.h b/esphome/core/time_conversion.h new file mode 100644 index 0000000000..d9bee0912b --- /dev/null +++ b/esphome/core/time_conversion.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +namespace esphome { + +/// Convert a 64-bit microsecond count to milliseconds without calling +/// __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz). +/// +/// Returns uint32_t by default (for millis()), or uint64_t when requested +/// (for millis_64()). The only difference is whether hi * Q is truncated +/// to 32 bits or widened to 64. +/// +/// On 32-bit targets, GCC does not optimize 64-bit constant division into a +/// multiply-by-reciprocal. Since 1000 = 8 * 125, we first right-shift by 3 +/// (free divide-by-8), then use the Euclidean division identity to decompose +/// the remaining 64-bit divide-by-125 into a single 32-bit division: +/// +/// floor(us / 1000) = floor(floor(us / 8) / 125) [exact for integers] +/// 2^32 = Q * 125 + R (34359738 * 125 + 46) +/// (hi * 2^32 + lo) / 125 = hi * Q + (hi * R + lo) / 125 +/// +/// GCC optimizes the remaining 32-bit "/ 125U" into a multiply-by-reciprocal +/// (mulhu + shift), so no division instruction is emitted. +/// +/// Safe for us up to ~3.2e18 (~101,700 years of microseconds). +/// +/// Lives in its own header (rather than helpers.h) so hal.h can include it +/// for inline millis_64() definitions without dragging the rest of helpers.h +/// into every translation unit. +/// +/// See: https://en.wikipedia.org/wiki/Euclidean_division +/// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html +template +__attribute__((always_inline)) inline constexpr ReturnT micros_to_millis(uint64_t us) { + constexpr uint32_t d = 125U; + constexpr uint32_t q = static_cast((1ULL << 32) / d); // 34359738 + constexpr uint32_t r = static_cast((1ULL << 32) % d); // 46 + // 1000 = 8 * 125; divide-by-8 is a free shift + uint64_t x = us >> 3; + uint32_t lo = static_cast(x); + uint32_t hi = static_cast(x >> 32); + // Combine remainder term: hi * (2^32 % 125) + lo + uint32_t adj = hi * r + lo; + // If adj overflowed, the true value is 2^32 + adj; apply the identity again + // static_cast(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q + return static_cast(hi) * q + (adj < lo ? (adj + r) / d + q : adj / d); +} + +} // namespace esphome