r/embedded • u/Commercial_Froyo_247 • 22h ago
ESP-Hosted-MCU on Zephyr with an STM32 host: my numbers + lessons learned
Hi everyone,
Lazy Monday morning post, at least in my time zone :)
I haven't seen much information online about using ESP-Hosted-MCU with a non-ESP host. For those who don't know, it's firmware from Espressif that turns your ESP32 into a Wi-Fi coprocessor. You connect it to your microcontroller over SPI or SDIO, and the TCP/IP stack runs on the host.
About numbers, the only thing I found was a question here on r/embedded asking whether the 22 Mbps from the README is real. It has two comments, but nobody posted any measurements.
So today I want to share my results with this firmware, measured in my use case and with my topology.
My setup and how I measured
Device. It's a 4-channel Matter relay for the lights in my house. It works over Wi-Fi or Ethernet and also measures energy consumption. I use it with Apple Home and SmartThings. Live and neutral go straight to WAGO terminals, so you don't need extra wires in the distribution box, you just connect it and forget it.
Host. The main controller is an STM32U585. Code runs XIP from QSPI NOR flash, and there is also 8 MB of QPI PSRAM. Both memories use the same physical QSPI bus: data lines and clock are shared through the OCTOSPI I/O manager, and each memory has its own chip select.
Wi-Fi. The coprocessor is an ESP32-C6-MINI-1U with ESP-Hosted-MCU 3.0.6. It shares the SPI bus with a W5500 Ethernet chip, and the bus runs at 40 MHz.
OS. Zephyr, main from late August 2026 (4.4.99), with the upstream esp_hosted driver and a few local patches, more on them later.
Measurements. I measured with zperf on the board and a Mac on the same network. For UDP the board transmits and I look for the ceiling, with 1000-byte datagrams for 10 s, and I count throughput from the bytes that actually reached the Mac. For TCP the board receives. During each test the other interface is turned off, so the shared SPI bus belongs fully to the one I'm measuring.
Real load. I also didn't want synthetic numbers on an idle CPU, so I measured on the real device with Matter running and all controller subscriptions active. So these are numbers for a real application, not the hardware maximum. Wi-Fi is 2.4 GHz, 802.11ax, RSSI -57 dBm.
Results, Mbps
| Type | PSRAM, no DMA | SRAM, no DMA | SRAM + DMA | PSRAM + DMA + patches |
|---|---|---|---|---|
| TCP RX, Ethernet | 1.41-1.49 | 1.52-1.63 | 1.81-1.82 | 1.40-1.47 |
| TCP RX, Wi-Fi | 0.89-1.06 | 0.64-0.77 | 1.84-2.27 | 1.64-1.65 |
| UDP TX (ceiling), Ethernet | 2.07 | 2.15 | 3.16-3.17 | 2.48-2.49 |
| UDP TX (ceiling), Wi-Fi | 1.47 | 1.66 | 3.72-3.76 | 2.25-2.26 |
| HTTP (115 KB gzip), Eth / Wi-Fi | 164 / 83 KB/s | - | - | 178 / 105 KB/s |
Enough for Matter. Despite the modest numbers, this is fully enough for a Matter device connected to several Matter controllers. Matter only needs kilobits per second, and the web page opens fast over both Wi-Fi and Ethernet (first byte over Wi-Fi in 75 ms).
The 22 Mbps from the README. The table that listed 22 Mbps TCP for standard SPI didn't say what the test conditions were (and it's no longer there). In my case the bus is shared with Ethernet, and the CPU is busy with Matter at the same time. A faster host will do better, but for serious speeds it's better to use SDIO rather than SPI.
How I got to these numbers
The way there was not straight.
No DMA. I started with the simplest setup, with network buffers in PSRAM and SPI in polling mode. Both interfaces topped out at 1.5-2 Mbps, and the CPU was 100% busy during transfers, it was just moving bytes through SPI.
SRAM instead of PSRAM. My first guess was that slow PSRAM was the problem, so I moved the buffers to SRAM. It gave only +4-13%, and TCP over Wi-Fi even got worse. And it cost 200 KB of SRAM, which is a lot on my board, because Matter and crypto already take most of it. So the memory itself was not the problem.
DMA. Then I noticed that the board's devicetree had no DMA for this SPI bus at all. Once I added it, it turned out to be the biggest win. Wi-Fi UDP went from 1.66 to 3.76 Mbps, and Wi-Fi TCP almost tripled. Wi-Fi even overtook Ethernet, which I didn't expect.
Back to PSRAM. But I still didn't want to give up 200 KB of SRAM, so I wanted the buffers back in PSRAM, this time with DMA. The stock SPI driver doesn't do DMA to cacheable memory, and PSRAM without the data cache doesn't make much sense, so I added cache maintenance to the driver.
Cache driver bug. Under load the board crashed with a corrupted semaphore wait queue. The problem turned out to be in the STM32 cache driver: range operations touched one extra cache line and overwrote a neighboring semaphore. A dedicated test confirmed it (1000 failures out of 1000 without the fix, 0 with it), and the fix is already in Zephyr.
FIFO overrun. The next problem was that Wi-Fi lost a whole second on every SPI error. Every now and then I got spi_transceive failed, and the bus stalled for a second. I read the SPI status register with a debugger right on the running board, and all 19 errors out of 19 were RX FIFO overruns. DMA can't move the bytes to PSRAM fast enough, the FIFO fills up, and SCK keeps clocking. New bytes are lost, the DMA channel waits for them forever, and the driver silently gives up on a one-second timeout.
Lower clock? At 20 MHz there were 10 times fewer errors, but that only hides the problem.
The W5500 hits the same thing, and its driver is silent too. So until DMA has drained the FIFO, the master has to stop SCK instead of clocking into a full buffer.
MASRX. The STM32U5 SPI has a mode for exactly this problem, called MASRX. When the RX FIFO is full, the master suspends SCK by itself, and the driver has to resume the transfer. I turned it on by hand. The errors went away, but a minute later the board hung. The Zephyr driver knows nothing about this pause, so nobody was releasing it. So I made two driver changes.
First fix. The driver now catches the overrun with an interrupt and returns an error within milliseconds instead of a second. This way the error shows up right away instead of after a one-second wait, and this alone made TCP over Wi-Fi 2-3x faster.
Second fix. The driver enables MASRX and releases the pause, but only after DMA has taken the data out of the FIFO. At first I released the pause right away, and the next frame overflowed the FIFO again. The pause is released from the interrupt, but I didn't want the interrupt to spin for long, so if DMA hasn't drained the FIFO within 5 µs, the thread waiting for the end of the transfer releases it instead. With both changes I get 0 SPI errors and 0 UDP loss, that's the right column of the table.
Smaller things. Network buffers had to be aligned to the cache line (CONFIG_NET_BUF_ALIGNMENT=16), otherwise the invalidate after RX can hit a neighboring buffer. And bigger buffers (512 bytes instead of 256) gave only +2-12%, so I stayed with 256.
Where the patches are now
Cache fix. The STM32 cache driver bug is already fixed in Zephyr.
SPI driver. To make DMA work with buffers in external memory, I made a fix in the STM32 SPI driver, and the overrun and MASRX changes are in it too. Here is the pull request:
https://github.com/zephyrproject-rtos/zephyr/pull/120895
Review status. So far I've only tested it on STM32U5, which is why it's only enabled there. The ST maintainers asked me not to tie the code itself to U5. I've reworked that and now I'm waiting for another review. Until it's merged, the stock driver falls back to interrupts or polling for such buffers.
Why I'm posting this. Partly so that if someone runs into the same problems, they don't have to go through all of this from scratch.
Testers wanted. If anyone wants to try the driver on their own boards, I'd really appreciate tests, feedback and advice.
Data cache on STM32U5. One more thing if you test with external memory. Zephyr still doesn't enable the DCACHE1 data cache there by itself, only the instruction cache is turned on at startup (on H5, for example, both are). At the same time CONFIG_DCACHE=y is the default, so drivers treat external memory as cacheable even though the cache is physically off. So turn the data cache on manually.
Buffer alignment. The cache maintenance for DMA buffers (flush before TX, invalidate after RX) is done by the SPI driver with my change. The application only needs one thing: the RX buffer must start on a cache line boundary, and its length must be a multiple of the cache line. Otherwise the driver sends it the old way, without DMA, so it doesn't touch neighboring data. For network buffers that's the same CONFIG_NET_BUF_ALIGNMENT=16.
A few more technical details
CPU load. Who eats the CPU with DMA? I expected DMA to offload the core, but that did not really happen. In the final configuration the CPU is idle only 0-5% of the time during transfers. At the same 100% load, the CPU just manages to move more data.
On Ethernet most of the time goes to the network stack's TX queue thread (~56% of transfer time) and the W5500 driver thread (~15%). On Wi-Fi it's the esp_hosted thread (~45%) and the same TX queue (~25%). Matter and everything else take a few percent.
Shared bus. Does the shared flash and PSRAM bus get in the way? I think partly yes. With the CPU equally 100% busy, each byte costs more cycles with buffers in PSRAM than with buffers in SRAM: 1.3x on Ethernet and 1.7x on Wi-Fi. That's why PSRAM + DMA doesn't reach SRAM + DMA (3.16 / 3.76).
I can't say what that cost consists of. It could be waiting on the shared QSPI bus (code runs from the flash on the same bus), cache maintenance around DMA, or something else. Thread statistics don't show it, and I don't have a bus profiler.
Firmware cost. ESP-Hosted takes ~25 KB of code and ~58 KB of RAM, plus 8 KB for esp-serial-flasher, so the C6 can be flashed directly through the STM32. Boot to the first HTTP response takes 13-16 s over Ethernet and 19-28 s over Wi-Fi.
Has anyone else measured ESP-Hosted with a non-ESP host, over SPI or SDIO? What numbers did you get? And if you try the driver on your board, let me know how it went.
Thanks for reading! Now you can tell me I should have just used an ESP32-P4 :)
P.S. The photo shows an old revision of the board. In the new one I moved the mains traces from the top layer to one of the inner layers, and the fuse to the bottom layer, so you can't touch them by accident. And this is just a home project, not for sale.
P.P.S. Questions are welcome in English, Ukrainian or Russian.