[core] Inline micros()/millis_64() at the HAL layer

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.
This commit is contained in:
J. Nick Koston
2026-04-24 11:23:16 -05:00
parent 94e300389c
commit e23a6bf59f
6 changed files with 110 additions and 48 deletions
+9 -8
View File
@@ -37,15 +37,16 @@ uint32_t IRAM_ATTR HOT millis() {
return micros_to_millis(static_cast<uint64_t>(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<uint64_t>(static_cast<uint64_t>(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();
+2 -1
View File
@@ -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();
+2 -2
View File
@@ -14,8 +14,8 @@
namespace esphome {
void HOT yield() { ::yield(); }
uint64_t millis_64() { return micros_to_millis<uint64_t>(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); }
+43
View File
@@ -2,6 +2,7 @@
#include <string>
#include <cstdint>
#include "gpio.h"
#include "esphome/core/time_conversion.h"
#if defined(USE_ESP32)
#include <esp_attr.h>
@@ -70,6 +71,18 @@
extern "C" uint32_t platform_is_in_interrupt_context(void);
#endif
#ifdef USE_ESP8266
// Forward-declared from <Arduino.h> (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 <pico/time.h> 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 <esp_timer.h> 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<uint32_t>(esp_timer_get_time()); }
__attribute__((always_inline)) inline uint64_t millis_64() {
return micros_to_millis<uint64_t>(static_cast<uint64_t>(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<uint32_t>(::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<uint64_t>(::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();
+4 -37
View File
@@ -20,6 +20,7 @@
#include <strings.h>
#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<std::integral T> 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<typename ReturnT = uint32_t> inline constexpr ESPHOME_ALWAYS_INLINE ReturnT micros_to_millis(uint64_t us) {
constexpr uint32_t d = 125U;
constexpr uint32_t q = static_cast<uint32_t>((1ULL << 32) / d); // 34359738
constexpr uint32_t r = static_cast<uint32_t>((1ULL << 32) % d); // 46
// 1000 = 8 * 125; divide-by-8 is a free shift
uint64_t x = us >> 3;
uint32_t lo = static_cast<uint32_t>(x);
uint32_t hi = static_cast<uint32_t>(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<ReturnT>(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q
return static_cast<ReturnT>(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.
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <cstdint>
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<typename ReturnT = uint32_t>
__attribute__((always_inline)) inline constexpr ReturnT micros_to_millis(uint64_t us) {
constexpr uint32_t d = 125U;
constexpr uint32_t q = static_cast<uint32_t>((1ULL << 32) / d); // 34359738
constexpr uint32_t r = static_cast<uint32_t>((1ULL << 32) % d); // 46
// 1000 = 8 * 125; divide-by-8 is a free shift
uint64_t x = us >> 3;
uint32_t lo = static_cast<uint32_t>(x);
uint32_t hi = static_cast<uint32_t>(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<ReturnT>(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*q
return static_cast<ReturnT>(hi) * q + (adj < lo ? (adj + r) / d + q : adj / d);
}
} // namespace esphome