esp-emac 0.2.0

ESP32 EMAC bare-metal Ethernet MAC driver with DMA, RMII, and MDIO
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# esp-emac

[![License: GPL-2.0-or-later OR Apache-2.0](https://img.shields.io/badge/license-GPL--2.0--or--later%20OR%20Apache--2.0-blue.svg)](#license)
[![Crates.io](https://img.shields.io/crates/v/esp-emac.svg)](https://crates.io/crates/esp-emac)
[![Documentation](https://docs.rs/esp-emac/badge.svg)](https://docs.rs/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`](https://crates.io/crates/eth-phy-lan87xx)
(or any [`eth_mdio_phy::PhyDriver`](https://docs.rs/eth-mdio-phy)
implementation) for the PHY side, and with `embassy-net` for the
TCP/IP stack.

---

## Installation

```toml
[dependencies]
esp-emac        = { version = "0.2", features = ["esp-hal", "mdio-phy", "embassy-net"] }
eth-mdio-phy    = "0.2"
eth-phy-lan87xx = "0.2"   # or any other eth_mdio_phy::PhyDriver impl

# Required runtime stack
esp-hal           = { version = "1.1", features = ["esp32", "unstable"] }
embassy-executor  = "0.10"
embassy-net       = { version = "0.9", features = ["dhcpv4", "medium-ethernet"] }
embassy-time      = "0.5"
static_cell       = "2"
embedded-hal      = "1.0"
esp-backtrace     = { version = "0.19", features = ["esp32", "panic-handler", "println"] }
esp-println       = { version = "0.17", default-features = false, features = ["esp32", "uart"] }
esp-rtos          = { version = "0.3", features = ["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`](examples/embassy_net_lan8720a.rs).
The skeleton looks like this:

```rust no_run
#![no_std]
#![no_main]

use esp_backtrace as _; // installs the `#[panic_handler]`

use embassy_executor::Spawner;
use embassy_net::{DhcpConfig, Runner, Stack, StackResources};
use embassy_time::{Duration, Timer};
use embedded_hal::delay::DelayNs;
use esp_hal::{delay::Delay, interrupt::Priority, rng::Rng};

use esp_emac::config::{ClkGpio, EmacConfig, RmiiClockConfig, RmiiPins, XtalFreq};
use esp_emac::emac::{Duplex as EmacDuplex, Speed as EmacSpeed};
use esp_emac::embassy::{EmacDefaultDriver, EmacDriverState};
use esp_emac::mdio::EspMdio;
use esp_emac::EmacDefault;

use eth_mdio_phy::{Duplex as PhyDuplex, PhyDriver, Speed as PhySpeed};
use eth_phy_lan87xx::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 = EmacDefault::new(EmacConfig {
    clock: RmiiClockConfig::InternalApll {
        gpio: ClkGpio::Gpio17,
        xtal: XtalFreq::Mhz40,
    },
    pins: RmiiPins { mdc: 23, mdio: 18 },
});
static EMAC_STATE: EmacDriverState = EmacDriverState::new();

// 2. Bind the EMAC interrupt to the driver's state.
#[esp_hal::handler(priority = Priority::Priority1)]
fn emac_interrupt_handler() {
    EMAC_STATE.handle_emac_interrupt();
}

#[embassy_executor::task]
async fn net_task(mut runner: Runner<'static, EmacDefaultDriver<'static>>) {
    runner.run().await
}

#[esp_rtos::main]
async fn main(spawner: Spawner) {
    let peripherals = esp_hal::init(esp_hal::Config::default());

    // esp-rtos owns the embassy timer + scheduler. Start it before any
    // `Timer::after(...)` or `spawner.spawn(...)` can fire.
    let timg0 = esp_hal::timer::timg::TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    let mut delay = Delay::new();
    let rng = Rng::new();

    // 3. Bring up MAC + PHY. SAFETY: EMAC is touched only here — single
    //    owner — so no aliasing.
    let emac = unsafe { &mut *core::ptr::addr_of_mut!(EMAC) };
    emac.set_mac_address([0x00, 0x70, 0x07, 0x24, 0x3B, 0x87]);
    emac.init(&mut delay).expect("EMAC init");
    emac.bind_interrupt(emac_interrupt_handler);

    let mut mdio = EspMdio::new();
    let mut phy = PhyLan87xx::new(/* PHY addr */ 1);
    phy.init(&mut mdio).expect("PHY init");

    // 4. Wait for link, programme speed/duplex.
    loop {
        match phy.poll_link(&mut mdio) {
            Ok(Some(status)) => {
                emac.set_speed(match status.speed {
                    PhySpeed::Mbps10 => EmacSpeed::Mbps10,
                    PhySpeed::Mbps100 => EmacSpeed::Mbps100,
                });
                emac.set_duplex(match status.duplex {
                    PhyDuplex::Half => EmacDuplex::Half,
                    PhyDuplex::Full => EmacDuplex::Full,
                });
                EMAC_STATE.set_link_up();
                break;
            }
            Ok(None) => delay.delay_ms(200),
            Err(_) => delay.delay_ms(200),
        }
    }

    emac.start().expect("EMAC start");

    // 5. Plumb into embassy-net. `EmacDefaultDriver` is a type alias
    //    whose inherent `new` is `EmacDriver::new` — keeps the call
    //    site free of the const-generic ceremony (currently
    //    `<10, 10, 1600>`, sourced from `DEFAULT_RX` / `DEFAULT_TX` /
    //    `DEFAULT_BUF`).
    let driver = EmacDefaultDriver::new(emac, &EMAC_STATE);
    let net_seed = rng.random() as u64 | ((rng.random() as u64) << 32);

    static RESOURCES: static_cell::StaticCell<StackResources<8>> =
        static_cell::StaticCell::new();
    let (stack, runner) = embassy_net::new(
        driver,
        embassy_net::Config::dhcpv4(DhcpConfig::default()),
        RESOURCES.init(StackResources::<8>::new()),
        net_seed,
    );

    spawner.spawn(net_task(runner)).unwrap();

    // 6. Wait for DHCP, use the stack.
    loop {
        if let Some(cfg) = stack.config_v4() {
            // got IP address: cfg.address
            break;
        }
        Timer::after(Duration::from_millis(500)).await;
    }
}
```

Bare-metal sync usage (without `embassy-net`) is documented in the
crate-level rustdoc — see [`Emac::transmit`](https://docs.rs/esp-emac/latest/esp_emac/emac/struct.Emac.html#method.transmit)
and [`Emac::receive`](https://docs.rs/esp-emac/latest/esp_emac/emac/struct.Emac.html#method.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:

1. **A `static EMAC_STATE: EmacDriverState`.** Holds the `WAKER` the
   driver polls and the `link_up` flag. Created with
   `EmacDriverState::new()` and never moved.
2. **A handler annotated with `#[esp_hal::handler]`** that calls
   `EMAC_STATE.handle_emac_interrupt()`. Use `Priority::Priority1`   the driver does not gate on priority, but level 1 keeps it well below
   timer/scheduler interrupts.
3. **`emac.bind_interrupt(handler)`** after `init()` — this maps the
   ESP32 EMAC IRQ to the handler symbol via `esp-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]#interrupt-binding).
* **PHY ANAR not restored after cold boot.** Use `eth-phy-lan87xx`
  (which writes `ANAR=0x01E1` explicitly) 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. Only `Gpio0` works as RMII clock input.
* `InternalApll { Gpio0 }``Gpio0` only has the input function on
  this peripheral. Use `Gpio16` (0° phase) or `Gpio17` (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::Driver` adaptor (feature
  `embassy-net`). Tokens copy frames through a stack-allocated buffer
  on every `consume`; no heap allocations.
* `EspMdio` — Station Management (SMI / MDIO) controller. Implements
  `eth_mdio_phy::MdioBus` (feature `mdio-phy`) so any PHY driver
  written against that trait Just Works.
* `regs::{mac, dma, ext, gpio}` — typed bit constants + tiny `read` /
  `write` / `set_bits` / `clear_bits` helpers + composite operations
  (`set_mac_address`, `start_tx`, `enable_peripheral_clock`, ...).
  Use these directly only if you need to do something the high-level
  `Emac` API doesn't expose.
* `reset::ResetController` — DMA software-reset state machine; takes
  any `embedded_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`](src/config.rs) 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`](src/emac.rs):

1. 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`).
2. 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.
3. Enable the EMAC peripheral clock via DPORT.
4. Set the PHY interface (RMII) and the chosen clock source.
5. Enable the EMAC extension clocks and power up the EMAC RAM.
6. Issue a DMA software reset; wait for `DMABUSMODE.SWR` to self-clear.
7. 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`.
8. Programme the DMA bus mode (`ATDS=1` enhanced 8-word descriptors,
   PBL=32, AAL, USP, FIXED_BURST) and operation mode
   (TSF + RSF — store-and-forward).
9. Hand the DMA the descriptor list base addresses.
10. 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
> explicit `EmacConfig`.

### Known gotchas (baked into the driver)

* **`GMACADDR0` write order.** HIGH first (with the `AE` bit at
  bit 31), LOW second. The internal address-filter latch fires on the
  LOW write only. — `regs::mac::set_mac_address`.
* **`DMARXPOLLDEMAND` after every successful `receive()`.** Without
  it the RX DMA enters `Suspended` once 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.RESET` cycle does NOT restore
  `ANAR` to `0x01E1` on the LAN8720A on cold boot. After resetting
  the PHY, write `0x01E1` to `ANAR` explicitly. Already handled in
  `eth-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:e0` programmed in BLK3
  via `espefuse.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`](https://github.com/jethome-iot/testsystem-firmware-esp/blob/main/src-hal/firmware/src/net/ethernet.rs).

## License

Licensed under either of:

* GNU General Public License, Version 2.0 or later
  ([LICENSE-GPL]LICENSE-GPL)
* Apache License, Version 2.0 ([LICENSE-APACHE]LICENSE-APACHE)

at your option.

Copyright (c) Viacheslav Bocharov (v at baodeep dot com) and JetHome (r).