From 64364961dba73edebd267bb0b0021678578cc48b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Feb 2026 20:47:54 -0600 Subject: [PATCH] [core] Replace lwip_select() with direct rcvevent reads on ESP32 On ESP32, replace lwip_select() in yield_with_select_() with direct socket event reads via lwip_socket_dbg_get_socket(), reducing poll cost from 133us to ~7us (18.7x faster). Replace the UDP loopback wake socket with FreeRTOS task notifications (<2us, ISR-safe). Benchmarks (ESP32, 4 listen sockets): - Poll path: 7,087 ns vs 132,402 ns (18.7x faster) - Wake signal: 1,826 ns vs ~130,000 ns (72x faster, now ISR-safe) - Binary: yield_with_select_ 108B vs 188B (43% smaller) Non-ESP32 platforms (LibreTiny, ESP8266, RP2040) are unchanged. --- esphome/components/socket/__init__.py | 15 +++-- esphome/core/application.cpp | 89 ++++++++++++++++++++------- esphome/core/application.h | 27 ++++---- esphome/core/lwip_fast_select.c | 84 +++++++++++++++++++++++++ esphome/core/lwip_fast_select.h | 37 +++++++++++ 5 files changed, 211 insertions(+), 41 deletions(-) create mode 100644 esphome/core/lwip_fast_select.c create mode 100644 esphome/core/lwip_fast_select.h diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index d82f0c7abad..06b0ba2bf72 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -126,13 +126,14 @@ def require_wake_loop_threadsafe() -> None: """Mark that wake_loop_threadsafe support is required by a component. Call this from components that need to wake the main event loop from background threads. - This enables the shared UDP loopback socket mechanism (~208 bytes RAM). - The socket is shared across all components that use this feature. + + On ESP32: Uses FreeRTOS task notifications (<1 us, ISR-safe, no socket needed). + On other platforms: Uses a shared UDP loopback socket mechanism (~208 bytes RAM). This call is a no-op if networking is not enabled in the configuration. - IMPORTANT: This is for background thread context only, NOT ISR context. - Socket operations are not safe to call from ISR handlers. + On ESP32, this is safe to call from ISR context. + On other platforms, this is for background thread context only, NOT ISR context. Example: from esphome.components import socket @@ -147,8 +148,10 @@ def require_wake_loop_threadsafe() -> None: ): CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True cg.add_define("USE_WAKE_LOOP_THREADSAFE") - # Consume 1 socket for the shared wake notification socket - consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) + if not CORE.is_esp32: + # Only non-ESP32 platforms need a UDP socket for wake notifications. + # ESP32 uses FreeRTOS task notifications instead (no socket needed). + consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 1cb7dc00755..9de5aa1e9ea 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -9,6 +9,9 @@ #endif #ifdef USE_ESP32 #include +#include "esphome/core/lwip_fast_select.h" +#include +#include #endif #include "esphome/core/version.h" #include "esphome/core/hal.h" @@ -145,8 +148,13 @@ void Application::setup() { #endif #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#ifdef USE_ESP32 + // Initialize fast select: saves main loop task handle for xTaskNotifyGive wake + esphome_lwip_fast_select_init(); +#else // Set up wake socket for waking main loop from tasks this->setup_wake_loop_threadsafe_(); +#endif #endif this->schedule_dump_config(); @@ -523,7 +531,7 @@ void Application::enable_pending_loops_() { } void Application::before_loop_tasks_(uint32_t loop_start_time) { -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif @@ -576,11 +584,15 @@ bool Application::register_socket_fd(int fd) { #endif this->socket_fds_.push_back(fd); +#ifdef USE_ESP32 + // Hook the socket's netconn callback for instant wake on receive events + esphome_lwip_hook_socket(fd); +#else this->socket_fds_changed_ = true; - if (fd > this->max_fd_) { this->max_fd_ = fd; } +#endif return true; } @@ -599,8 +611,11 @@ void Application::unregister_socket_fd(int fd) { if (i < this->socket_fds_.size() - 1) this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); +#ifdef USE_ESP32 + // Unhook the socket's netconn callback + esphome_lwip_unhook_socket(fd); +#else this->socket_fds_changed_ = true; - // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { this->max_fd_ = -1; @@ -609,6 +624,7 @@ void Application::unregister_socket_fd(int fd) { this->max_fd_ = sock_fd; } } +#endif return; } } @@ -616,16 +632,34 @@ void Application::unregister_socket_fd(int fd) { #endif void Application::yield_with_select_(uint32_t delay_ms) { - // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run - // since select() with 0 timeout only polls without yielding. -#ifdef USE_SOCKET_SELECT_SUPPORT - if (!this->socket_fds_.empty()) { + // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run. +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) + // ESP32 fast path: direct rcvevent reads (~858 ns for 4 sockets vs 133 us for lwip_select) + if (!this->socket_fds_.empty()) [[likely]] { + FD_ZERO(&this->read_fds_); + for (int fd : this->socket_fds_) { + if (esphome_lwip_socket_has_data(fd)) { + FD_SET(fd, &this->read_fds_); + } + } + } + + if (delay_ms == 0) [[unlikely]] { + yield(); + return; + } + + // Sleep with instant wake via FreeRTOS task notification. + // Woken by: callback wrapper (socket data arrives), wake_loop_threadsafe() (other tasks), or timeout. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms)); + +#elif defined(USE_SOCKET_SELECT_SUPPORT) + // Non-ESP32 platforms (LibreTiny bk72xx/rtl87xx): use select() + if (!this->socket_fds_.empty()) [[likely]] { // Update fd_set if socket list has changed - if (this->socket_fds_changed_) { + if (this->socket_fds_changed_) [[unlikely]] { FD_ZERO(&this->base_read_fds_); - // fd bounds are already validated in register_socket_fd() or guaranteed by platform design: - // - ESP32: LwIP guarantees fd < FD_SETSIZE by design (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS) - // - Other platforms: register_socket_fd() validates fd < FD_SETSIZE + // fd bounds are validated in register_socket_fd() for (int fd : this->socket_fds_) { FD_SET(fd, &this->base_read_fds_); } @@ -641,7 +675,7 @@ void Application::yield_with_select_(uint32_t delay_ms) { tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000; // Call select with timeout -#if defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || (defined(USE_ESP32) && defined(USE_SOCKET_IMPL_BSD_SOCKETS)) +#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS int ret = lwip_select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); #else int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); @@ -651,19 +685,18 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret < 0: error (except EINTR which is normal) // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected - if (ret < 0 && errno != EINTR) { - // Actual error - log and fall back to delay - ESP_LOGW(TAG, "select() failed with errno %d", errno); - delay(delay_ms); + if (ret >= 0 || errno == EINTR) [[likely]] { + // Yield if zero timeout since select(0) only polls without yielding + if (delay_ms == 0) [[unlikely]] { + yield(); + } + return; } - // When delay_ms is 0, we need to yield since select(0) doesn't yield - if (delay_ms == 0) { - yield(); - } - } else { - // No sockets registered, use regular delay - delay(delay_ms); + // select() error - log and fall through to delay() + ESP_LOGW(TAG, "select() failed with errno %d", errno); } + // No sockets registered or select() failed - use regular delay + delay(delay_ms); #elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) // No select support but can wake on socket activity via esp_schedule() socket::socket_delay(delay_ms); @@ -676,6 +709,14 @@ void Application::yield_with_select_(uint32_t delay_ms) { Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + +#ifdef USE_ESP32 +void Application::wake_loop_threadsafe() { + // Direct FreeRTOS task notification — ISR-safe, <1 us + esphome_lwip_wake_main_loop(); +} +#else // !USE_ESP32 + void Application::setup_wake_loop_threadsafe_() { // Create UDP socket for wake notifications this->wake_socket_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); @@ -742,6 +783,8 @@ void Application::wake_loop_threadsafe() { lwip_send(this->wake_socket_fd_, &dummy, 1, 0); } } +#endif // USE_ESP32 + #endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) void Application::get_build_time_string(std::span buffer) { diff --git a/esphome/core/application.h b/esphome/core/application.h index cd275bb97fc..fba2ae54c23 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -25,7 +25,7 @@ #ifdef USE_SOCKET_SELECT_SUPPORT #include -#ifdef USE_WAKE_LOOP_THREADSAFE +#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) #include #endif #endif // USE_SOCKET_SELECT_SUPPORT @@ -497,9 +497,10 @@ class Application { bool is_socket_ready(int fd) const { return fd >= 0 && this->is_socket_ready_(fd); } #ifdef USE_WAKE_LOOP_THREADSAFE - /// Wake the main event loop from a FreeRTOS task - /// Thread-safe, can be called from task context to immediately wake select() - /// IMPORTANT: NOT safe to call from ISR context (socket operations not ISR-safe) + /// Wake the main event loop from a FreeRTOS task or ISR. + /// Thread-safe, can be called from any context to immediately wake the main loop. + /// On ESP32: uses xTaskNotifyGive (<1 us, ISR-safe) + /// On other platforms: uses UDP loopback socket (NOT ISR-safe) void wake_loop_threadsafe(); #endif #endif @@ -541,7 +542,7 @@ class Application { /// Perform a delay while also monitoring socket file descriptors for readiness void yield_with_select_(uint32_t delay_ms); -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) void setup_wake_loop_threadsafe_(); // Create wake notification socket inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined) #endif @@ -571,7 +572,7 @@ class Application { FixedVector looping_components_{}; #ifdef USE_SOCKET_SELECT_SUPPORT std::vector socket_fds_; // Vector of all monitored socket file descriptors -#ifdef USE_WAKE_LOOP_THREADSAFE +#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks #endif #endif @@ -584,7 +585,7 @@ class Application { uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) int max_fd_{-1}; // Highest file descriptor number for select() #endif @@ -600,14 +601,16 @@ class Application { bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes #endif #ifdef USE_SOCKET_SELECT_SUPPORT // Variable-sized members - fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes - fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_ + fd_set read_fds_{}; // Working fd_set: populated by select() or direct rcvevent reads +#ifndef USE_ESP32 + fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes (select() path) +#endif #endif // StaticVectors (largest members - contain actual array data inline) @@ -694,7 +697,7 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) // Inline implementations for hot-path functions // drain_wake_notifications_() is called on every loop iteration @@ -716,6 +719,6 @@ inline void Application::drain_wake_notifications_() { } } } -#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) } // namespace esphome diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c new file mode 100644 index 00000000000..9a54b4ef04c --- /dev/null +++ b/esphome/core/lwip_fast_select.c @@ -0,0 +1,84 @@ +// Fast socket monitoring for ESP32 (ESP-IDF LwIP) +// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. +// +// This must be a .c file (not .cpp) because: +// 1. lwip/priv/sockets_priv.h conflicts with C++ compilation units that include bootloader headers +// 2. The netconn callback is a C function pointer +// +// defines.h is force-included by the build system (-include flag), providing USE_ESP32 etc. + +#ifdef USE_ESP32 + +// LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc. +#include +#include +#include +#include + +#include "esphome/core/lwip_fast_select.h" + +// Task handle for the main loop — set during setup, read from any thread/ISR +static TaskHandle_t s_main_loop_task = NULL; + +// Saved original event_callback pointer (same for all LwIP sockets) +static netconn_callback s_original_callback = NULL; + +// Wrapper callback: calls original event_callback + notifies main loop task. +// Called from LwIP's TCP/IP thread when socket events occur. +static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) { + // Call original LwIP event_callback first — updates rcvevent/sendevent/errevent, + // signals any select() waiters. This preserves all LwIP behavior. + if (s_original_callback) { + s_original_callback(conn, evt, len); + } + // Wake the main loop task if sleeping in ulTaskNotifyTake(). + // Only notify on receive events to avoid spurious wakeups from send-ready events. + // xTaskNotifyGive is thread-safe and ISR-safe, costs <1 us. + if (evt == NETCONN_EVT_RCVPLUS) { + TaskHandle_t task = s_main_loop_task; + if (task != NULL) { + xTaskNotifyGive(task); + } + } +} + +void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); } + +bool esphome_lwip_socket_has_data(int fd) { + struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); + return sock != NULL && sock->conn != NULL && sock->rcvevent > 0; +} + +void esphome_lwip_hook_socket(int fd) { + struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); + if (sock == NULL || sock->conn == NULL) + return; + + // Save original callback (only once — same event_callback for all LwIP sockets) + if (s_original_callback == NULL) { + s_original_callback = sock->conn->callback; + } + + // Replace with our wrapper + sock->conn->callback = esphome_socket_event_callback; +} + +void esphome_lwip_unhook_socket(int fd) { + struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); + if (sock == NULL || sock->conn == NULL) + return; + + // Restore original callback + if (s_original_callback != NULL) { + sock->conn->callback = s_original_callback; + } +} + +void esphome_lwip_wake_main_loop(void) { + TaskHandle_t task = s_main_loop_task; + if (task != NULL) { + xTaskNotifyGive(task); + } +} + +#endif // USE_ESP32 diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h new file mode 100644 index 00000000000..8bd2a052aff --- /dev/null +++ b/esphome/core/lwip_fast_select.h @@ -0,0 +1,37 @@ +#pragma once + +// Fast socket monitoring for ESP32 (ESP-IDF LwIP) +// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. +// See fast_select.md for design rationale and benchmarks. + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// Initialize fast select — must be called from the main loop task during setup(). +/// Saves the current task handle for xTaskNotifyGive() wake notifications. +void esphome_lwip_fast_select_init(void); + +/// Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket). +/// Uses lwip_socket_dbg_get_socket() which is a direct array lookup — no locking, no refcount. +/// Safe for single-threaded polling from the main loop. +bool esphome_lwip_socket_has_data(int fd); + +/// Hook a socket's netconn callback to notify the main loop task on receive events. +/// Wraps the original event_callback with one that also calls xTaskNotifyGive(). +/// Must be called from the main loop after socket creation. +void esphome_lwip_hook_socket(int fd); + +/// Unhook a socket's netconn callback, restoring the original event_callback. +/// Must be called from the main loop before closing the socket. +void esphome_lwip_unhook_socket(int fd); + +/// Wake the main loop task from any thread or ISR — costs <1 us. +/// Replaces the UDP loopback socket wake mechanism. +void esphome_lwip_wake_main_loop(void); + +#ifdef __cplusplus +} +#endif