From 5cb145a8c3869e568adbb4628d99d10181e0b473 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Sat, 23 May 2026 17:53:53 -0400 Subject: [PATCH] [ethernet] Offload W5500 bulk SPI transfers from the busy-wait path (#16596) --- .../ethernet/ethernet_component_esp32.cpp | 5 + .../components/ethernet/w5500_custom_spi.cpp | 118 ++++++++++++++++++ .../components/ethernet/w5500_custom_spi.h | 35 ++++++ 3 files changed, 158 insertions(+) create mode 100644 esphome/components/ethernet/w5500_custom_spi.cpp create mode 100644 esphome/components/ethernet/w5500_custom_spi.h diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index d4585bf100..46e2bb4ec1 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -5,6 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "w5500_custom_spi.h" #include #include @@ -207,6 +208,10 @@ void EthernetComponent::setup() { #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT w5500_config.poll_period_ms = this->polling_interval_; #endif + // Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait + // path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which + // runs the driver's init(). + install_w5500_async_spi(w5500_config); #elif defined(USE_ETHERNET_DM9051) dm9051_config.int_gpio_num = this->interrupt_pin_; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp new file mode 100644 index 0000000000..ed4f149738 --- /dev/null +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -0,0 +1,118 @@ +#include "w5500_custom_spi.h" + +#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500) + +#include +#include +#include +#include +#include + +namespace esphome::ethernet { + +namespace { + +// Per-device context returned by init() and handed back to read/write/deinit. +struct W5500CustomSpiContext { + spi_device_handle_t handle; + SemaphoreHandle_t lock; +}; + +// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger +// transfers (the frame payloads) use the blocking, DMA-backed transmit. +constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64; +constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50; + +void *w5500_custom_spi_init(const void *spi_config) { + const auto *config = static_cast(spi_config); + auto *ctx = new (std::nothrow) W5500CustomSpiContext{}; + if (ctx == nullptr) { + return nullptr; + } + // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control + // byte in the address phase; mirror what the stock driver configures. + spi_device_interface_config_t devcfg = *config->spi_devcfg; + devcfg.command_bits = 16; + devcfg.address_bits = 8; + if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { + delete ctx; + return nullptr; + } + ctx->lock = xSemaphoreCreateMutex(); + if (ctx->lock == nullptr) { + spi_bus_remove_device(ctx->handle); + delete ctx; + return nullptr; + } + return ctx; +} + +esp_err_t w5500_custom_spi_deinit(void *spi_ctx) { + auto *ctx = static_cast(spi_ctx); + spi_bus_remove_device(ctx->handle); + vSemaphoreDelete(ctx->lock); + delete ctx; + return ESP_OK; +} + +// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size. +// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register +// accesses stay on the cheaper polling path. Used by both read and write. +esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) { + if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + esp_err_t ret; + if (len > W5500_SPI_BULK_THRESHOLD) { + ret = spi_device_transmit(ctx->handle, trans); + } else { + ret = spi_device_polling_transmit(ctx->handle, trans); + } + xSemaphoreGive(ctx->lock); + return ret; +} + +esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) { + auto *ctx = static_cast(spi_ctx); + spi_transaction_t trans = {}; + trans.cmd = static_cast(cmd); + trans.addr = addr; + trans.length = 8 * len; + trans.tx_buffer = data; + return w5500_custom_spi_transfer(ctx, &trans, len); +} + +esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) { + auto *ctx = static_cast(spi_ctx); + spi_transaction_t trans = {}; + // Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary + // overwrites of adjacent registers (same guard the stock driver uses). + const bool use_rxdata = len <= 4; + trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0; + trans.cmd = static_cast(cmd); + trans.addr = addr; + trans.length = 8 * len; + trans.rx_buffer = data; + esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len); + if (use_rxdata && (ret == ESP_OK)) { + memcpy(data, trans.rx_data, len); + } + return ret; +} + +} // namespace + +void install_w5500_async_spi(eth_w5500_config_t &config) { + // Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and + // spi_devcfg back out of it. The self-reference is valid because both the config and the + // spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init(). + config.custom_spi_driver.config = &config; + config.custom_spi_driver.init = w5500_custom_spi_init; + config.custom_spi_driver.deinit = w5500_custom_spi_deinit; + config.custom_spi_driver.read = w5500_custom_spi_read; + config.custom_spi_driver.write = w5500_custom_spi_write; +} + +} // namespace esphome::ethernet + +#endif // USE_ESP32 && USE_ETHERNET_W5500 diff --git a/esphome/components/ethernet/w5500_custom_spi.h b/esphome/components/ethernet/w5500_custom_spi.h new file mode 100644 index 0000000000..8756a149af --- /dev/null +++ b/esphome/components/ethernet/w5500_custom_spi.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500) + +#include +// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t +// is no longer reachable through esp_eth.h and needs the explicit header. +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else +#include +#endif + +namespace esphome::ethernet { + +// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path. +// +// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which +// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame, +// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX +// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers +// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps +// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait +// is cheaper than an interrupt round-trip. +// +// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back +// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay +// alive until esp_eth_mac_new_w5500() returns. +void install_w5500_async_spi(eth_w5500_config_t &config); + +} // namespace esphome::ethernet + +#endif // USE_ESP32 && USE_ETHERNET_W5500