From 6ac705cd66519d2a2d1b4440b591b5b995d38916 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 22:13:33 -1000 Subject: [PATCH 1/3] [esp32] Use xTaskGetTickCount() for millis() when tick rate is 1kHz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESPHome sets CONFIG_FREERTOS_HZ=1000, so one FreeRTOS tick equals one millisecond and xTaskGetTickCount() returns ms directly. This replaces esp_timer_get_time() + micros_to_millis() (a hardware timer read plus a 64-bit multiply-shift conversion) with a single volatile memory read. Benchmarked on real ESP32 hardware: Before: 686 ns/call (esp_timer_get_time + micros_to_millis) After: ~20 ns/call (volatile read of xTickCount) millis() is called 1+N times per main loop iteration (once at the top and once per component via WarnIfComponentBlockingGuard::finish()), so on a 5-component device this saves ~3.4 μs per loop iteration. millis_64() is left on esp_timer_get_time() for full 64-bit μs precision — it is only called once per loop by the Scheduler. micros() is also unchanged (runtime_stats needs μs precision). Falls back to the original implementation if CONFIG_FREERTOS_HZ != 1000 (non-standard user override). --- esphome/components/esp32/core.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 313818e601..eaa8bf2d17 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -23,7 +23,19 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { void HOT yield() { vPortYield(); } -uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); } +uint32_t IRAM_ATTR HOT millis() { + // ESPHome sets CONFIG_FREERTOS_HZ=1000 (see esp32/__init__.py), so one tick = one millisecond + // and xTaskGetTickCount() returns ms directly. This is a single volatile memory read (~20ns) + // vs esp_timer_get_time() + micros_to_millis() which does a hardware timer read + 64-bit + // multiply-shift conversion (~686ns measured). millis() is called 1+N times per main loop + // iteration (once at top + once per component for warn_blocking), so this saves ~3.4μs/loop + // on a 5-component device. micros() still uses esp_timer_get_time() for μs precision. +#if CONFIG_FREERTOS_HZ == 1000 + return xTaskGetTickCount(); +#else + return micros_to_millis(static_cast(esp_timer_get_time())); +#endif +} uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } From c155f411109c97c94193ed1eb27227520ddc4167 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 08:49:04 -1000 Subject: [PATCH 2/3] [esp32] Inline millis() in hal.h, drop IRAM_ATTR Move millis() to an inline function in hal.h that just returns xTaskGetTickCount() -- eliminates the function call entirely at every call site. No IRAM consumed, no flash function call. Drop IRAM_ATTR from the fallback path. The original IRAM placement was inherited from ESP8266 where Arduino millis() is called from ISR handlers (Wiegand, ZyAura). ESPHome does not call millis() from ISR on ESP32. --- esphome/components/esp32/core.cpp | 18 ++++++------------ esphome/core/hal.h | 13 +++++++++++++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index eaa8bf2d17..8ce3458ea9 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -23,19 +23,13 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { void HOT yield() { vPortYield(); } -uint32_t IRAM_ATTR HOT millis() { - // ESPHome sets CONFIG_FREERTOS_HZ=1000 (see esp32/__init__.py), so one tick = one millisecond - // and xTaskGetTickCount() returns ms directly. This is a single volatile memory read (~20ns) - // vs esp_timer_get_time() + micros_to_millis() which does a hardware timer read + 64-bit - // multiply-shift conversion (~686ns measured). millis() is called 1+N times per main loop - // iteration (once at top + once per component for warn_blocking), so this saves ~3.4μs/loop - // on a 5-component device. micros() still uses esp_timer_get_time() for μs precision. -#if CONFIG_FREERTOS_HZ == 1000 - return xTaskGetTickCount(); -#else - return micros_to_millis(static_cast(esp_timer_get_time())); +// millis() is inlined in hal.h when CONFIG_FREERTOS_HZ == 1000 (just xTaskGetTickCount()). +// Fallback for non-standard tick rates. No IRAM_ATTR — the original IRAM placement was +// inherited from ESP8266 where Arduino's millis() is called from ISR handlers (Wiegand, +// ZyAura). ESPHome doesn't call millis() from ISR on ESP32. +#if CONFIG_FREERTOS_HZ != 1000 +uint32_t HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); } #endif -} uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 03a30b7459..3d615fd395 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -28,10 +28,23 @@ #endif +// On ESP32 with 1 kHz FreeRTOS tick rate, millis() is just xTaskGetTickCount() — +// a single volatile read of a DRAM global. Inlining it here eliminates the +// function call entirely at every call site. No IRAM needed (no flash code +// executed), no 64-bit math, no HAL call. +#if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000 +#include +#include +#endif + namespace esphome { void yield(); +#if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000 +inline uint32_t millis() { return xTaskGetTickCount(); } +#else uint32_t millis(); +#endif uint64_t millis_64(); uint32_t micros(); void delay(uint32_t ms); From b5a2d58b80f3bf79350a0a46f933aab3762de8d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 09:07:08 -1000 Subject: [PATCH 3/3] [esp32] Keep millis() out-of-line with IRAM_ATTR, add ISR context check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the inline hal.h approach — millis() must remain IRAM_ATTR because Wiegand and ZyAura call it from IRAM_ATTR ISR handlers on all platforms including ESP32. Use xPortInIsrContext() to dispatch to xTaskGetTickCountFromISR() from ISR or xTaskGetTickCount() from task context, satisfying the FreeRTOS API contract. ISR check is [[unlikely]] so the hot path stays fast. Benchmarked: 686 ns -> 361 ns (1.9x faster). Correctness verified. --- esphome/components/esp32/core.cpp | 19 +++++++++++++------ esphome/core/hal.h | 13 ------------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 8ce3458ea9..c97e4a5eb1 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -23,13 +23,20 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { void HOT yield() { vPortYield(); } -// millis() is inlined in hal.h when CONFIG_FREERTOS_HZ == 1000 (just xTaskGetTickCount()). -// Fallback for non-standard tick rates. No IRAM_ATTR — the original IRAM placement was -// inherited from ESP8266 where Arduino's millis() is called from ISR handlers (Wiegand, -// ZyAura). ESPHome doesn't call millis() from ISR on ESP32. -#if CONFIG_FREERTOS_HZ != 1000 -uint32_t HOT millis() { return micros_to_millis(static_cast(esp_timer_get_time())); } +// Use xTaskGetTickCount() when tick rate is 1 kHz (ESPHome's default via sdkconfig), +// falling back to esp_timer for non-standard rates. IRAM_ATTR is required because +// Wiegand and ZyAura call millis() from IRAM_ATTR ISR handlers on ESP32. +// xTaskGetTickCountFromISR() is used in ISR context to satisfy the FreeRTOS API contract. +uint32_t IRAM_ATTR HOT millis() { +#if CONFIG_FREERTOS_HZ == 1000 + if (xPortInIsrContext()) [[unlikely]] { + return xTaskGetTickCountFromISR(); + } + return xTaskGetTickCount(); +#else + return micros_to_millis(static_cast(esp_timer_get_time())); #endif +} uint64_t HOT millis_64() { return micros_to_millis(static_cast(esp_timer_get_time())); } void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); } diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 3d615fd395..03a30b7459 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -28,23 +28,10 @@ #endif -// On ESP32 with 1 kHz FreeRTOS tick rate, millis() is just xTaskGetTickCount() — -// a single volatile read of a DRAM global. Inlining it here eliminates the -// function call entirely at every call site. No IRAM needed (no flash code -// executed), no 64-bit math, no HAL call. -#if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000 -#include -#include -#endif - namespace esphome { void yield(); -#if defined(USE_ESP32) && CONFIG_FREERTOS_HZ == 1000 -inline uint32_t millis() { return xTaskGetTickCount(); } -#else uint32_t millis(); -#endif uint64_t millis_64(); uint32_t micros(); void delay(uint32_t ms);