esp-emac
Native ESP32 Ethernet MAC driver for #![no_std] Rust. Owns the DMA
engine and brings the EMAC peripheral up directly via memory-mapped
register helpers — no ph-esp32-mac, no esp-idf-svc, no esp-eth.
Pairs with eth-phy-lan87xx
(or any eth_mdio_phy::PhyDriver
implementation) for the PHY side, and with embassy-net for the
TCP/IP stack.
Installation
[]
= { = "0.2", = ["esp-hal", "mdio-phy", "embassy-net"] }
= "0.2"
= "0.2" # or any other eth_mdio_phy::PhyDriver impl
# Required runtime stack
= { = "1.1", = ["esp32", "unstable"] }
= "0.10"
= { = "0.9", = ["dhcpv4", "medium-ethernet"] }
= "0.5"
= "2"
= "1.0"
= { = "0.19", = ["esp32", "panic-handler", "println"] }
= { = "0.17", = false, = ["esp32", "uart"] }
= { = "0.3", = ["esp32", "embassy"] }
Target triple: xtensa-esp32-none-elf (install via espup install).
MSRV: 1.88 (constrained by esp-hal = "1.1"'s declared
rust-version). The driver works only on the original ESP32
(Xtensa LX6).
Features
| Feature | Default | Pulls in | When to enable |
|---|---|---|---|
esp-hal |
off | esp_hal::interrupt for ISR binding |
Always, for hardware bring-up |
mdio-phy |
off | eth-mdio-phy (and EspMdio: MdioBus impl) |
When using a PhyDriver-based PHY (LAN87xx etc.) |
embassy-net |
off | embassy-net-driver, embassy-sync, critical-section |
When using embassy-net TCP/IP stack |
async |
off | embedded-hal-async |
When using AsyncResetController |
defmt |
off | defmt::Format derives |
When logging through defmt |
The typical firmware build enables esp-hal + mdio-phy + embassy-net.
Compatibility
| esp-emac | esp-hal | embassy-net | embassy-executor | Rust target |
|---|---|---|---|---|
| 0.2.x | 1.1.x | 0.9.x | 0.10.x | xtensa-esp32-none-elf |
Other ESP variants (S2/S3/C-series/H2) have no built-in EMAC — use SPI Ethernet (W5500, ENC28J60) instead. ESP32-P4 has a newer Synopsys GMAC revision and is not yet supported (planned).
Quick start (embassy-net + LAN8720A)
The complete working example is in
examples/embassy_net_lan8720a.rs.
The skeleton looks like this:
use esp_backtrace as _; // installs the `#[panic_handler]`
use Spawner;
use ;
use ;
use DelayNs;
use ;
use ;
use ;
use ;
use EspMdio;
use EmacDefault;
use ;
use PhyLan87xx;
// 1. Static storage — DMA holds raw pointers into the `Emac` instance,
// so it must live in `static` and never move. `Emac::new` (and
// therefore `EmacDefault::new`) is a `const fn`, so the value is
// built at compile time and lives in BSS — zero runtime stack cost
// on boot. The default ring sizing is currently 10 RX / 10 TX /
// 1600-byte buffers (~32 KiB), sourced from `DEFAULT_RX` /
// `DEFAULT_TX` / `DEFAULT_BUF`. We deliberately do NOT wrap it in
// `StaticCell::init(EmacDefault::new(...))` — that pattern would
// risk materialising the 32 KiB struct on the caller's stack
// before moving it into the cell. The `static mut` form below
// avoids that hazard at the cost of one well-isolated `unsafe`.
static mut EMAC: EmacDefault = new;
static EMAC_STATE: EmacDriverState = new;
// 2. Bind the EMAC interrupt to the driver's state.
async
async
Bare-metal sync usage (without embassy-net) is documented in the
crate-level rustdoc — see Emac::transmit
and Emac::receive.
Interrupt binding
EmacDriver is event-driven: each frame received or descriptor freed
fires a MAC interrupt that wakes the embassy-net runner. Three pieces
need to line up:
- A
static EMAC_STATE: EmacDriverState. Holds theWAKERthe driver polls and thelink_upflag. Created withEmacDriverState::new()and never moved. - A handler annotated with
#[esp_hal::handler]that callsEMAC_STATE.handle_emac_interrupt(). UsePriority::Priority1— the driver does not gate on priority, but level 1 keeps it well below timer/scheduler interrupts. emac.bind_interrupt(handler)afterinit()— this maps the ESP32 EMAC IRQ to the handler symbol viaesp-hal's interrupt table.
Forgetting step 3 silently produces a working link but no incoming
frames at the embassy-net layer (is_link_up() true, config_v4()
permanently None).
Troubleshooting
Link is up but DHCP never completes
Symptoms: stack.is_link_up() returns true, but stack.config_v4()
stays None for tens of seconds.
Most likely:
- MAC address bit 0 set (multicast bit). The frame filter rejects
multicast as a source — the DHCP server's reply is delivered but
silently dropped before user space. Double-check the bytes you pass
to
set_mac_address. - Interrupt handler not bound (see Interrupt binding).
- PHY ANAR not restored after cold boot. Use
eth-phy-lan87xx(which writesANAR=0x01E1explicitly) or follow that pattern in your custom PHY driver.
EmacError::InvalidConfig on init
You picked an impossible RmiiClockConfig:
External { Gpio16 / Gpio17 }— those pads only have an output function 5 on ESP32. OnlyGpio0works as RMII clock input.InternalApll { Gpio0 }—Gpio0only has the input function on this peripheral. UseGpio16(0° phase) orGpio17(180° phase).
Link goes up at 10 Mbps when the PHY supports 100 Mbps
ANAR got partially programmed and auto-neg converged on a subset.
Cold boot of LAN87xx is the textbook case — that's why eth-phy-lan87xx
writes ANAR=0x01E1 explicitly. If using a different PHY driver, mirror
that pattern.
Unicast RX silently fails (broadcast/multicast still arrive)
The MAC address-filter latch in GMACADDR0 was programmed in the
wrong order. HIGH first, LOW second — the latch fires on the LOW
write. Emac::set_mac_address does this correctly; if you bypass it
and write through regs::mac::* raw, observe the order and the
AE (ADDRESS_ENABLE) bit at bit 31 of GMACADDR0H.
XtalFreq::Mhz40 but the link still won't come up
Verify your module's actual crystal — there is no runtime detection. Most ESP32 modules (WROOM, WROVER, MINI, JXD-CPU-E1ETH) ship with 40 MHz, but some legacy boards have 26 MHz. Picking the wrong value silently produces an off-frequency RMII reference clock.
Reference
What's in the box
Emac<RX, TX, BUF>— the driver. RX/TX descriptor ring sizes and per-buffer length are const generics so the entire packet memory layout is static; nothing on the heap.EmacDriver—embassy_net_driver::Driveradaptor (featureembassy-net). Tokens copy frames through a stack-allocated buffer on everyconsume; no heap allocations.EspMdio— Station Management (SMI / MDIO) controller. Implementseth_mdio_phy::MdioBus(featuremdio-phy) so any PHY driver written against that trait Just Works.regs::{mac, dma, ext, gpio}— typed bit constants + tinyread/write/set_bits/clear_bitshelpers + composite operations (set_mac_address,start_tx,enable_peripheral_clock, ...). Use these directly only if you need to do something the high-levelEmacAPI doesn't expose.reset::ResetController— DMA software-reset state machine; takes anyembedded_hal::delay::DelayNs.clock— APLL 50 MHz programming for the RMII reference clock, plus GPIO0/16/17 routing.
RMII clock modes
ESP32 supports two mutually exclusive RMII reference-clock modes. The
choice is dictated by the board layout — Emac::init rejects mismatched
GPIO selections with EmacError::InvalidConfig.
| Mode | GPIO | Direction | When to use | Caveat |
|---|---|---|---|---|
InternalApll { Gpio16, xtal } |
16 | output (EMAC_CLK_OUT, 0°) |
dev boards where the MCU drives the PHY's REF_CLK pin | Errata CLK-3.22 — clock pad is corrupted by RF noise during WiFi/BT TX. Avoid if the radio is active. |
InternalApll { Gpio17, xtal } |
17 | output (EMAC_CLK_OUT_180, 180°) |
LAN8720A reference design — phase shift improves RX setup margin | Same CLK-3.22 caveat. |
External { Gpio0 } |
0 | input (EMAC_TX_CLK) |
production designs with a PHY crystal / oscillator (e.g. JXD-CPU-E1ETH); required for Ethernet + WiFi coexistence | GPIO0 is also the boot-strapping pin — make sure the oscillator level at reset matches the boot-mode requirement. |
xtal is an XtalFreq enum (Mhz26, Mhz32, Mhz40)
selecting APLL SDM coefficients. It must match the actual on-board
crystal — there is no detection at runtime.
Hardware bring-up sequence
Emac::init follows the canonical ESP32 GMAC sequence — every step is
documented inline at src/emac.rs:
- Programme APLL to 50 MHz and route the RMII clock to the chosen
GPIO (
InternalApll) — or set GPIO0 IO_MUX function 5 to take an external 50 MHz oscillator (External). - Configure SMI pins (MDC=GPIO23, MDIO=GPIO18 by default) through the GPIO Matrix; route the six fixed RMII data pins (TXD0=19, TXD1=22, TX_EN=21, RXD0=25, RXD1=26, CRS_DV=27) through IO_MUX function 5.
- Enable the EMAC peripheral clock via DPORT.
- Set the PHY interface (RMII) and the chosen clock source.
- Enable the EMAC extension clocks and power up the EMAC RAM.
- Issue a DMA software reset; wait for
DMABUSMODE.SWRto self-clear. - Programme the MAC core: PORT_SELECT=1 (MII/RMII), 100 Mbps, full
duplex, auto-pad/CRC strip, jabber/watchdog disabled. Frame filter
passes broadcast + all multicast; perfect-match unicast filter is
on
ADDR0. - Programme the DMA bus mode (
ATDS=1enhanced 8-word descriptors, PBL=32, AAL, USP, FIXED_BURST) and operation mode (TSF + RSF — store-and-forward). - Hand the DMA the descriptor list base addresses.
- Programme the primary MAC address into
GMACADDR0H/L— HIGH first, then LOW.
Emac::start then enables MAC TX, DMA TX, DMA RX, MAC RX in that order
and issues a poll-demand to wake the RX DMA out of Suspended.
Choosing static buffer sizes
Emac<RX, TX, BUF> is const-generic on the RX/TX ring counts and the
per-buffer length. Each descriptor is 32 bytes (ATDS layout); each
buffer is BUF bytes (typical 1536 or 1600).
| Profile | RX | TX | BUF | RAM |
|---|---|---|---|---|
EmacDefault |
10 | 10 | 1600 | ~32 KiB |
EmacSmall |
4 | 4 | 1600 | ~13 KiB |
Emac::memory_usage() returns the exact byte count for any chosen
combination. Pick the size at compile time; the value lives in .bss.
Emac::default()is intentionally not provided. The clock and pin configuration is hardware-specific and any default the crate could pick (internal APLL on GPIO17, MDC/MDIO 23/18) would silently mis-drive boards that expect a different layout. Always construct an explicitEmacConfig.
Known gotchas (baked into the driver)
GMACADDR0write order. HIGH first (with theAEbit at bit 31), LOW second. The internal address-filter latch fires on the LOW write only. —regs::mac::set_mac_address.DMARXPOLLDEMANDafter every successfulreceive(). Without it the RX DMA entersSuspendedonce the ring drains and never recovers. —Emac::receive.- RX descriptor
ATDS=1. The MAC writes RX status into descriptor word 4, which only exists in the enhanced 8-word layout. The legacy 4-word layout silently mis-decodes every received frame. - APLL 50 MHz must be programmed BEFORE the DMA software reset. The reset sequencer needs a working RMII reference clock to deassert the busy bit.
- PHY reset register. A
BMCR.RESETcycle does NOT restoreANARto0x01E1on the LAN8720A on cold boot. After resetting the PHY, write0x01E1toANARexplicitly. Already handled ineth-phy-lan87xx.
Hardware verified on
- JXD-PM3-80-E1ETH (factory MAC, BLK3 efuse empty)
- JXD-R6-E1ETH-LCD (custom MAC
f0:57:8d:01:04:e0programmed in BLK3 viaespefuse.py burn_custom_mac)
Cold boot, soft reset (DTR-toggle / RTC_CNTL.SW_SYS_RST), and USB
power-cycle all yield the same behaviour: PHY init → link up
100 Mbps full → DHCP → ICMP/HTTP.
A reference firmware integration is in
testsystem-firmware-esp / src-hal/firmware/src/net/ethernet.rs.
License
Licensed under either of:
- GNU General Public License, Version 2.0 or later (LICENSE-GPL)
- Apache License, Version 2.0 (LICENSE-APACHE)
at your option.
Copyright (c) Viacheslav Bocharov (v at baodeep dot com) and JetHome (r).