Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/img/authors/sylwester-sosnowski.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions content/authors/sylwester-sosnowski/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
title: Sylwester Sosnowski
---
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
351 changes: 351 additions & 0 deletions content/blog/2026/06/rust-smoltcp-network-stack-for-esp-idf/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
---
title: "Rust smoltcp as an alternative TCP/IP stack for ESP-IDF"
date: "2026-06-19"
summary: "A set of ESP-IDF components that run the Rust smoltcp stack as the IPv4/IPv6 data plane, while keeping esp_http_server, esp-tls and esp-mqtt working without source changes. This article explains the linker --wrap shim that makes it compatible, the single-task poll architecture, the throughput I measured on an ESP32-P4 (91.15 Mbit/s on a 100 Mbit link), and the limitations to be aware of."
featureAsset: "img/featured/featured-rust.webp"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's usually more engaging if you attach some picture, maybe just a screenshot from the component manager landing on your smaltcp component

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a screenshot of the esp_smoltcp registry page in the Getting started section.

authors:
- sylwester-sosnowski
tags: ["Rust", "smoltcp", "Networking", "TCP/IP", "ESP-IDF", "ESP32-P4", "lwIP"]
---

## Introduction

ESP-IDF ships with lwIP, and for most projects that's the right choice. It's
mature, well documented, and every networking component in ESP-IDF is built and
tested against it. Nothing below is an argument to replace it by default.

Still, there are two reasons you might want an alternative. The first is the
implementation language: the IP stack is the part of your firmware that
parses bytes arriving from machines you don't control, and lwIP is written
in C. The second is the concurrency model β€” lwIP runs several tasks and
takes a fair number of mutexes, which is harder to reason about when you
need confidence that a device will stay up for months without intervention.

## Why a second TCP/IP stack

[smoltcp](https://github.com/smoltcp-rs/smoltcp) is a `#![no_std]` TCP/IP
stack written in Rust, with no heap requirement and a single, explicit poll
model: you give it the current time and a device to read and write frames,
it does one pass of work, and you call it again. The packet parsing is safe
Rust, and there's no background thread operating on the stack behind your
code.

Now, getting smoltcp to run on an ESP32 is not the hard part. The hard part
is running it *under ESP-IDF without giving up the native networking stack
you already depend on* β€” `esp_http_server`, `esp-tls`, `esp_http_client`,
`esp-mqtt`, mbedTLS. If switching stacks means forking all of that, it's a
toy. So the goal was source compatibility: keep those components, and the
application code that uses them, completely unchanged.

That turned out to be achievable, and verified on real hardware at roughly
wire-line speed. This article goes through how it works and where the edges
are.

## How compatibility works: a linker `--wrap` shim

Every ESP-IDF networking component eventually calls BSD sockets β€” `socket()`,
`bind()`, `listen()`, `accept()`, `send()`, `recv()`, `select()`,
`getaddrinfo()`. In ESP-IDF, these come from `<lwip/sockets.h>`, and the
key detail is that they are defined as `static inline` wrappers:

```c
/* lwip/sockets.h, simplified */
static inline int socket(int domain, int type, int protocol) {
return lwip_socket(domain, type, protocol);
}
static inline int bind(int s, const struct sockaddr *name, socklen_t len) {
return lwip_bind(s, name, len);
}
```

So when `esp_http_server` calls `socket()`, the symbol that actually ends
up in the link is `lwip_socket()`. That's the seam the whole project hangs
on.

GNU `ld` provides `--wrap=symbol`: every reference to `symbol` is redirected
to `__wrap_symbol`, and the original remains reachable as `__real_symbol`.
The component adds one of these for each BSD-socket entry point:

```
-Wl,--wrap=lwip_socket
-Wl,--wrap=lwip_bind
-Wl,--wrap=lwip_listen
-Wl,--wrap=lwip_accept
-Wl,--wrap=lwip_send
-Wl,--wrap=lwip_recv
-Wl,--wrap=lwip_select
/* ... and so on for the full BSD-socket surface */
```

With that in place, every BSD-socket call site across ESP-IDF β€” none of
which is modified β€” lands in a thin C shim that talks to smoltcp instead.
lwIP is still compiled into the image, because its headers define types the
ESP-IDF networking source needs, but its socket layer is never reached at
runtime. The application source change required to switch stacks is zero.

## Using it

The intended workflow is "install your own Ethernet driver, hand over the
handle, and let the component take it from there." You bring up `esp_eth`
the way your board requires β€” pins, PHY, clocks, all board-specific β€” and
then attach it:

```c
#include "esp_smoltcp.h"

void app_main(void)
{
nvs_flash_init();
esp_event_loop_create_default();

/* Your driver, your pins. The stack does not need to know the details. */
esp_eth_handle_t eth = my_install_eth_driver();

esp_smoltcp_init();
esp_smoltcp_attach_eth(eth);
esp_smoltcp_wait_for_ip(ESP_SMOLTCP_IFACE_ETH, 15000); /* DHCP by default */

/* BSD sockets work from here, so unmodified ESP-IDF code works too: */
httpd_handle_t server;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
httpd_start(&server, &config);
/* register handlers as usual */
}
```

`httpd_start()` and everything beneath it are stock ESP-IDF. They just happen
to be running on smoltcp now.

## Architecture

The implementation is three components stacked on top of each other:

```
esp_http_server | esp-tls | esp-mqtt | mdns (unchanged ESP-IDF source)
|
BSD sockets: socket/bind/recv/select/getaddrinfo
|
+---------------------v---------------------+
| esp_smoltcp_lwip_compat | linker --wrap shim:
| FD table, select scan, getaddrinfo, | rewrites every lwip_* call
| esp_netif shim, in-RAM 127.0.0.0/8 | to __wrap_lwip_*; provides
| loopback for httpd's control socket | an in-RAM loopback
+---------------------+---------------------+
|
+---------------------v---------------------+
| esp_smoltcp | single poll task owns the
| poll loop, slab-allocated RX frame pool, | stack; FreeRTOS event-group
| L2 frame tap, per-interface stats, SNTP | wakeups; per-iface counters
+---------------------+---------------------+
|
+---------------------v---------------------+
| esp_smoltcp_glue | Rust no_std staticlib,
| smoltcp 0.12 + DNS resolver + C FFI | riscv32imafc-unknown-none-elf
+---------------------+---------------------+
|
esp_eth_handle_t (your driver, attached above)
```

Three decisions shape how this behaves under load:

**A single task owns the stack.** There's one poll loop. It blocks on a
FreeRTOS event group, wakes when a frame arrives or a socket needs service,
runs exactly one `iface.poll()` pass, and goes back to sleep. Because
nothing else ever touches smoltcp, there are no locks protecting it. The
BSD shim marshals work onto that task instead of reaching into the stack
from arbitrary contexts.

**RX frames come from a slab pool, not the heap.** The pool is fixed in
size and count and allocated once. Under load that gives you bounded RAM
and an explicit drop counter (`esp_smoltcp_frame_pool_drops()`) instead of
heap fragmentation and a mystery. If the pool runs dry, that's a number you
can read, not a leak you have to hunt.

**The Rust side is `no_std`** with an internal-RAM-first allocator, built
for `riscv32imafc-unknown-none-elf` to match the ESP32-P4 high-performance
cores (hard float, compressed instructions). The TX scratch buffer is a
static pool. The pinned toolchain is recorded in a `rust-toolchain.toml`,
so `rustup` selects it automatically.

## Performance

The hardware is a
[Waveshare ESP32-P4-Nano](https://www.waveshare.com/esp32-p4-nano.htm) with
the built-in 100 Mbit/s EMAC, ESP-IDF v6.0, MTU 1500. The example application serves a synthetic
download endpoint; the measurement is a 200 MB transfer pulled with `curl`
(curl reports it as 190M because it counts in MiB):

```bash
$ curl http://<ip>/dl/200000000 --output foo
100 190M 0 190M 0 0 10.8M 0 --:--:-- 0:00:17 --:--:-- 10.8M

# the server logs, after the transfer completes:
I (...) app: dl: 200000000 bytes in 17552922 us = 91.15 Mbit/s
```

That's **91.15 Mbit/s sustained.** The practical ceiling on a 100 Mbit
link at MTU 1500, after Ethernet, IP and TCP framing, is about 94.85
Mbit/s, so this is roughly 96% of the realistic maximum; the remaining
few percent is framing overhead that nothing can get back. Across the
200 MB transfer there were 0 TX failures and 0 frame-pool drops, and
round-trip ping on the wire sits at 0.4–0.7 ms.

To be clear, that number is the result of tuning, not the first run.
Getting there took a 1 kHz FreeRTOS tick so the poll loop is serviced often
enough, enabling the EMAC hardware flow control, and moving the hot path
into IRAM so it isn't stalled on flash access. A throughput figure without
its conditions isn't worth much, so those are the conditions.

### Footprint vs lwIP

Throughput is only half the story; the other half is what it costs. The
numbers below are from `idf.py size-components` on an ESP32-C3 build with
default config (measured by David Cermak while reviewing this article β€”
thanks for that):

| Archive | Flash code | Static RAM (`.data` + `.bss`) |
|---|---|---|
| `libsmoltcp_glue.a` (Rust stack + FFI) | 82.2 KiB | 24.6 KiB |
| `libesp_smoltcp.a` (poll task, frame pool) | 3.4 KiB | 49.9 KiB |
| **smoltcp total** | **~85.6 KiB** | **~74.5 KiB** |
| `liblwip.a` | 47.4 KiB | ~1 KiB |

So smoltcp costs about 38 KiB more flash, and on paper ~73 KiB more RAM.
The RAM comparison needs one caveat to be fair in both directions: the
smoltcp figure is the *whole* budget, allocated statically up front β€” the
24.6 KiB `.data` is the TX scratch pool and the 49.9 KiB `.bss` is the RX
slab and socket buffers, and it never grows past that. lwIP's ~1 KiB static
figure excludes everything it allocates from the heap at runtime (pbufs,
PCBs, socket buffers), so its real RAM use under load is well above 1 KiB
and varies with traffic. A proper apples-to-apples comparison needs runtime
heap watermarks under identical load, which I haven't measured yet β€” until
then, read the table as "smoltcp pre-pays a fixed, bounded RAM budget;
lwIP pays a smaller but variable one as it goes."

On an ESP32-P4 the totals are negligible either way; on smaller chips the
~75 KiB of static RAM is a real line item and the main reason to think
twice.

## One version-specific caveat: `select()` and the VFS

The released v0.1.x requires `CONFIG_VFS_SUPPORT_SELECT=n` in your
sdkconfig. The short reason: ESP-IDF's `select()` doesn't go through a symbol
that `--wrap` can intercept β€” the VFS layer dispatches it through a
per-FD-range function-pointer table that lwIP populates at init, so smoltcp
sockets were invisible to it. Disabling VFS select sidesteps the table.

v0.2 (currently `0.2.0-rc.1` on the registry) removes the constraint
properly: the component claims the BSD-socket FD range with
`esp_vfs_register_fd_range()` and provides its own `esp_vfs_select_ops_t`,
so `select()` dispatches through the VFS the way ESP-IDF intends and the
default `CONFIG_VFS_SUPPORT_SELECT=y` just works. The design history β€”
including how this fix came out of the review discussion β€” is in the
[RFC on the esp-idf tracker](https://github.com/espressif/esp-idf/issues/18549).

## Current status and limitations

What is verified and in use:

- BSD sockets, `select()`/`poll()`, and the full `esp_http_server` stack
including chunked encoding and WebSockets
- `esp_https_server` with mbedTLS, plus `esp-tls`, `esp_http_client` and
`esp-mqtt`, all over BSD sockets and all unchanged
- An in-RAM `127.0.0.0/8` loopback β€” `esp_http_server`'s internal control
socket needs one, and loopback traffic is forwarded entirely in RAM
instead of going out through the Ethernet driver
- IGMPv2 multicast, ICMP echo, and IPv6 link-local with ping6 and
NDP/ICMPv6
- A raw L2 frame tap for PTP, LLDP and custom EtherTypes, with the P4
hardware timestamp wired up
- Per-interface statistics and the frame-pool drop counter

And what I'm not claiming:

- **Only the ESP32-P4 is hardware-verified.** Other RISC-V SoCs should
work; the classic Xtensa ESP32 needs a different Rust target and hasn't
been done.
- **The ESP-Hosted Wi-Fi path is scaffolded but not yet flashed.** The code
exists; until it's proven on hardware it stays listed as untested.
- **No SLAAC and no DHCPv6.** IPv6 support is link-local plus the native
socket API. smoltcp doesn't ship a Router-Solicitation client and I
haven't written one yet.
- **The bundled DNS resolver is minimal** β€” single-shot, A-records only.
Production use should bring its own resolver.

For a sense of how hard the working list has been exercised: the
socket-handle map used to run out of slots after about 24 connections until
it got proper recycling, and an early `select()` implementation spun on a
`vTaskDelay(1)` until it was made event-driven. Both are fixed β€” and
finding that class of bug is what separates "works" from "demo", which is
why I mention them.

## When to use it

Choose this if the safety properties of a Rust IP stack matter for your
project, or if a single predictable poll loop with bounded RAM and explicit
drop counters fits your reliability requirements better than lwIP's
threaded model. Those are the two cases it was built for.

Stay on lwIP otherwise. It integrates with the whole ecosystem, it's
thoroughly documented, and "already present and proven" is a strong
argument on its own. The aim here is to make the alternative exist, not to
argue that everyone should switch.

## Getting started

One prerequisite beyond a working ESP-IDF setup: the Rust toolchain,
because the glue component runs `cargo` during the build. If you don't have
it yet, [rustup](https://rustup.rs/) is the standard installer:

```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```

That's all the Rust setup there is β€” the component pins its exact toolchain
in a `rust-toolchain.toml`, so on the first build `rustup` fetches the
right nightly and the RISC-V target on its own.

The components themselves are on the
[ESP Component Registry](https://components.espressif.com/):

{{< figure
default=true
src="img/esp-smoltcp-registry.webp"
alt="The esp_smoltcp component on the ESP Component Registry"
caption="esp_smoltcp on the ESP Component Registry"
>}}

Adding them to a project is two lines:

```bash
idf.py add-dependency "datanoisetv/esp_smoltcp^0.1.0"
idf.py add-dependency "datanoisetv/esp_smoltcp_lwip_compat^0.1.0"
```

(Use at least `esp_smoltcp_lwip_compat` 0.1.1 β€” 0.1.0 had two packaging
bugs that broke standalone installs, found during the review of this very
article. The `^0.1.0` range above resolves to 0.1.1 automatically.)

Then set the required options in your project's `sdkconfig.defaults`:

```
CONFIG_LWIP_COMPAT_ENABLE=y # turn the BSD-sockets shim on
CONFIG_LWIP_NETIF_LOOPBACK=y # esp_http_server compile-time check
CONFIG_VFS_SUPPORT_SELECT=n # v0.1.x only β€” see the select() section
```

The option `CONFIG_VFS_SUPPORT_SELECT` is the v0.1.x constraint explained
above; the v0.2 release candidate doesn't need it.

- Source, the complete `eth_basic` example, and architecture notes:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed two minor errors (on the main branch) worth fixing before publishing the article:

  1. include dir is no-existent: just remove this line https://github.com/DatanoiseTV/esp-smoltcp/blob/main/components/esp_smoltcp/CMakeLists.txt#L29
  2. undefined hostname:
#ifndef CONFIG_APP_HOSTNAME
#define CONFIG_APP_HOSTNAME "espressif"
#endif

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed, and published as esp_smoltcp_lwip_compat 0.1.1 on the registry (the ^0.1.0 range in the article resolves to it automatically):

  1. The missing include dir was actually in esp_smoltcp_lwip_compat (its CMakeLists declared INCLUDE_DIRS "include" with no such dir β€” the component's API is purely --wrap symbols, so the line is simply removed). esp_smoltcp itself does ship its include/ with the two public headers, so that one stays.
  2. CONFIG_APP_HOSTNAME now defaults to "espressif" when undefined, exactly as you suggested β€” it was a leftover from the original template app's Kconfig. v0.2 replaces it with a proper component-level option (CONFIG_LWIP_COMPAT_HOSTNAME).

Fix commit: DatanoiseTV/esp-smoltcp@f7952ec, tagged v0.1.1. Thanks for actually installing it β€” both bugs were invisible from inside the template project where the symbols happened to exist.

[github.com/DatanoiseTV/esp-smoltcp](https://github.com/DatanoiseTV/esp-smoltcp)
- Registry pages:
[esp_smoltcp](https://components.espressif.com/components/datanoisetv/esp_smoltcp)
and
[esp_smoltcp_lwip_compat](https://components.espressif.com/components/datanoisetv/esp_smoltcp_lwip_compat)
- Design RFC and discussion:
[esp-idf#18549](https://github.com/espressif/esp-idf/issues/18549)

If you run it on hardware I haven't tested β€” another RISC-V SoC, or the
ESP-Hosted Wi-Fi path β€” I'd genuinely like to hear how it went, working or
not. At this stage the failure reports are the more valuable ones.
9 changes: 9 additions & 0 deletions data/authors/sylwester-sosnowski.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "Sylwester Sosnowski",
"type": "community",
"bio": "Embedded systems developer",
"image": "img/authors/sylwester-sosnowski.webp",
"social": [
{ "github": "https://github.com/DatanoiseTV" }
]
}
Loading