mirror of
https://github.com/esphome/esphome.git
synced 2026-09-20 11:38:48 +00:00
[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.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
#endif
|
||||
#ifdef USE_ESP32
|
||||
#include <esp_chip_info.h>
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#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<char, BUILD_TIME_STR_SIZE> buffer) {
|
||||
|
||||
+15
-12
@@ -25,7 +25,7 @@
|
||||
|
||||
#ifdef USE_SOCKET_SELECT_SUPPORT
|
||||
#include <sys/select.h>
|
||||
#ifdef USE_WAKE_LOOP_THREADSAFE
|
||||
#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32)
|
||||
#include <lwip/sockets.h>
|
||||
#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<Component *> looping_components_{};
|
||||
#ifdef USE_SOCKET_SELECT_SUPPORT
|
||||
std::vector<int> 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
|
||||
|
||||
@@ -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 <lwip/api.h>
|
||||
#include <lwip/priv/sockets_priv.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#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
|
||||
@@ -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 <stdbool.h>
|
||||
|
||||
#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
|
||||
Reference in New Issue
Block a user