TamgaOS (yula)
RSS github
August 2026 TamgaOS Ethernet DMA MPU D-Cache STM32H7

Ethernet MAC+DMA From Scratch
Seven Bugs Deep, No HAL, Down to a Real Packet Arriving on a PC

This started as a narrow goal: get the STM32H753ZI talking to its onboard Ethernet PHY over MDIO and report real link status. That part worked cleanly, first flash. Then came the actual point of having Ethernet — sending and receiving real frames — which turned into a seven-bug descent through DMA memory regions, cache coherency, and MAC filtering before a single test packet, sent from a PC via a Python script, was received correctly on the board with zero loss across 50 tries.
Every fix below was found by reading the actual bit, not by guessing — this project's running discipline throughout TamgaOS.

— Full Timeline —

From "does the PHY answer" to "50/50 packets received"

Link detection — worked first try
RCC clocks, SYSCFG RMII selection, nine GPIO pins at AF11, and the MDIO management interface, all confirmed against RM0433 and UM2407 before writing a line of code. PHY ID read back 0x7 — a real, sane value. Cable in: "LINK UP — 100Mbps Full-Duplex." Cable out: "LINK DOWN."
First TX attempt — Fatal Bus Error, every time
Descriptor rings and buffers were plain static arrays. This project's linker script places those in DTCM — which its own memory-map comment already flagged as "no DMA!" A bus-master peripheral simply cannot reach that memory.
Moved to AXI SRAM — TX worked exactly N times, then died forever
With a 2-descriptor ring: two sends succeeded, the third timed out waiting for the OWN bit to clear. With the ring shrunk to 1 descriptor: exactly one success, then permanent failure. The pattern was too precise to be random.
An MPU region caused an immediate UNALIGNED hard fault
Enabling an MPU region to make the descriptor memory non-cacheable — the eventual right idea — immediately faulted, because it was configured as Strongly Ordered, which forbids the multi-register access patterns a normal memcpy() generates.
TX became fully, indefinitely reliable
Normal Non-cacheable memory (not Strongly Ordered) plus an 8KB-aligned linker section resolved the D-Cache coherency problem outright. Confirmed in Wireshark: a real "TamgaOS!" payload, arriving on a PC, sent continuously without a single failure.
RX stayed completely silent — no error, just nothing
Two more reset-default fields turned out to matter: the receive buffer size field (left at 0 — the DMA had nowhere to write incoming data) and the MTL queue's store-and-forward mode.
RX started working — for everyone except us
Real network broadcast traffic (DHCP discovers, mDNS) began arriving cleanly. Frames sent directly to the board's own address, confirmed leaving the sending PC via tcpdump, never showed up. The MAC's own address register had never been written — unicast filtering was silently dropping every packet addressed to it.

— Part 1: Link Detection —

— Part 2: Seven Bugs Deep —

ı used ether type: 0x88B5 because this value reserved by IEEE for "local/experimental use" The IEEE 802 standard specifically designates 0x88B5 and 0x88B6 as "Local Experimental EtherTypes" sooo ı usd this value n code and fillter because of that on wireshark....

Bug 1 — Fatal Bus Error, and a linker comment that already had the answer

Bug 1DMACSR = 0x3F1102 — Fatal Bus Error on every TX attempt

The TX/RX descriptor arrays and their buffers were declared as ordinary static variables. This project's linker script places plain statics in DTCM (0x20000000) — and the linker script's own memory-map comment, written months earlier during a different session, already said: DTCM: 0x20000000 128KB — zero wait-state, CPU only (no DMA!). The Ethernet DMA engine is a bus-master peripheral; it cannot reach DTCM at all. Every descriptor and buffer address handed to it was fundamentally unreachable.

FIXMoved all four arrays into the .axi_bss linker section — already defined, mapped to AXI SRAM (0x24000000), reachable by D1-domain DMA masters — via __attribute__((section(".axi_bss"))).

Bug 2 — TX dies after exactly N sends, where N is the descriptor count

Bug 22 descriptors → 2 successes, then permanent failure. 1 descriptor → 1 success, then permanent failure.

This exact-count pattern, reproduced identically at two different ring sizes, ruled out anything random and pointed at descriptor reuse specifically — the first use of any given descriptor always succeeded, the second attempt to reuse it never did. RM0433 states the tail pointer register "points to the location of the LAST VALID descriptor" — an early attempt had it pointing one descriptor past the ring instead, which was wrong, but fixing that alone didn't resolve the symptom, because the actual root cause was one layer deeper (Bug 3).

FIXTail pointer written to the address of the descriptor just filled (TX) or just returned to the DMA (RX) — matching RM0433's literal wording, not an offset from it.

Bug 3 — the fault that revealed the real problem: D-Cache

Bug 3UNALIGNED hard fault immediately after enabling an MPU region

Suspecting cache coherency (this project's startup code enables D-Cache globally, per PM0253 §4.8's mandatory invalidate-then-enable sequence), an MPU region was added over the AXI SRAM buffers to mark them non-cacheable. The very first access after enabling it produced an immediate UNALIGNED fault — even though the buffers were correctly 4-byte aligned.

The region had been configured as Strongly Ordered memory (TEX=000, C=0, B=0) — genuinely non-cacheable, but also far more restrictive than intended: Strongly Ordered memory forbids the multi-register load/store instructions a compiler generates for an ordinary memcpy(), regardless of alignment. The fix wasn't "make it aligned," it was "use a different, less restrictive non-cacheable memory type."

FIXTEX=001 (Normal memory, Non-cacheable) instead of TEX=000 (Strongly Ordered) — permits normal compiler-generated access patterns while remaining fully non-cacheable, resolving both the fault AND the original D-Cache coherency problem in one change.

This is the bug that actually explains Bug 2. Once D-Cache could no longer hide the DMA's writes from the CPU, the tail pointer semantics from Bug 2's fix started working correctly — the two issues had been compounding each other the whole time.

Bug 4 — a linker script detail the MPU quietly requires

Bug 4MPU region silently rejected — .axi_bss only had 4-byte alignment

ARMv7-M's MPU requires a region's base address to be aligned to the region's own size — an 8KB region must start at an address that's a multiple of 8192. The .axi_bss section's linker placement only specified ALIGN(4), inherited from before any MPU region existed over it.

FIXAdded . = ALIGN(8192); immediately before _axi_bss_start in linker.ld — confirmed afterward via the .map file that the section genuinely starts at 0x24000000, a clean multiple of 8192.

Bug 5 — RX stayed completely silent, not even an error

Bug 5DMACSR read 0x0 forever — RX descriptor OWN bit never touched by hardware

With TX fully working, RX produced nothing — no error flags, no activity, just a permanently unclear OWN bit on every descriptor. ETH_DMACRXCR's Receive Buffer Size field (RBSZ, bits 14:1) had been left at its reset value: zero. The DMA was being told every receive buffer was zero bytes long — there was nowhere for it to write anything.

FIXRBSZ explicitly set to the actual buffer size (1536 bytes), shifted into its bit position.

Bug 6 — a queue-level setting one layer above the DMA

Bug 6RBSZ fixed, RX still received nothing

The MTL (MAC Transaction Layer) sits between the MAC and the DMA, with its own receive queue and its own operating mode register — never touched so far. ETH_MTLRXQOMR's Store-and-Forward bit (RSF) was still at its reset default, leaving the queue in threshold/cut-through mode, which didn't reliably forward the small test frames being used.

FIXRSF set explicitly — wait for a complete packet in the MTL queue before forwarding it to the DMA, rather than cut-through at a byte threshold.

Bug 7 — real traffic worked, our own test frames didn't

Bug 7DHCP/mDNS broadcasts received cleanly; unicast frames sent directly to the board vanished

With RBSZ and RSF both fixed, real network traffic started arriving — DHCP discovers, mDNS queries, router solicitations, all consistently received. But test frames sent specifically to the board's chosen MAC address (02:00:00:00:00:01), confirmed leaving the sending PC via tcpdump on the transmitting interface, never showed up on the board.

The board's own MAC address had never been written into ETH_MACA0HR/ETH_MACA0LR — the registers the hardware uses for unicast destination filtering. Broadcast frames bypass this filter by design (which is exactly why "other traffic" had been working all along); unicast frames were being silently dropped because the MAC didn't know its own address to compare against.

FIXETH_MACA0HR/LR written with the board's chosen address, in the byte order RM0433 specifies (the first destination-address byte received maps to the register's least-significant byte — not the most obvious ordering to guess).

— Verification —

Seeing our own packet — TX proof

Once Bugs 1–4 were resolved, a standalone TX-only test binary sent a broadcast frame with a custom EtherType (0x88B5) and an 8-byte "TamgaOS!" payload every second, connected directly to a PC's Ethernet port.

Wireshark — hex dump of the received frame
Data: 54616d67614f53210100000000000000...
      T  a  m  g  a  O  S  !

The exact bytes sent by the board, arriving intact on a real PC over a real cable — the first end-to-end proof that the DMA/MPU fixes actually worked, not just that the driver stopped reporting errors.

50/50 — real frames received, zero loss

With Bugs 5–7 resolved, a small Python script (scapy) sent 50 frames from a PC directly to the board's MAC address. Every single one arrived.

Board UART output
[ETH] *** OUR TEST FRAME *** #48 (rx total #51) — 64 bytes: 
02 00 00 00 00 01 02 00 00 00 00 02 88 B5 54 61 6D 67 61 4F 53 21 ...
[ETH]   payload ascii: TamgaOS!..........
[ETH] *** OUR TEST FRAME *** #49 (rx total #52) — 64 bytes: ...
[ETH] *** OUR TEST FRAME *** #50 (rx total #53) — 64 bytes: ...

50 sent, 50 received (frame #50 of 50, rx total #53 — the extra 3 being ordinary broadcast traffic mixed in on the same wire). No drops, no corruption, no reordering — the RX chain, from PHY through MTL through DMA through MPU-protected memory, working exactly as designed.

— Final State —

What works now

AXI SRAM and MPU bugs maybe will not come if someone will use standart linker.ld and startup file of board. Everything ı wanted to write custom And actually they are not bug all connected subject. I mean ethernet using DMA and without MPU (memory protection unit) will be weird. You want to touch memory without protction... sooo all in one logic actually

✓ PHY link detection — up/down, speed, duplex, confirmed on real hardware
✓ TX — confirmed in Wireshark, sustained continuous sends, zero failures
✓ RX — 50/50 real unicast test frames received with zero loss
✓ Both TX and RX use the same AXI SRAM + MPU Non-cacheable region, verified stable across a clean rebuild

BugRoot CauseStatus
1Descriptors in DTCM (DMA-unreachable)Fixed — moved to AXI SRAM
2Tail pointer semanticsFixed — points at last valid descriptor
3D-Cache + Strongly Ordered memoryFixed — MPU Normal Non-cacheable
4Linker alignment for MPU regionFixed — 8KB ALIGN added
5RBSZ (RX buffer size) unsetFixed — explicitly set to 1536
6MTL Store-and-Forward unsetFixed — RSF bit set
7MAC address never programmedFixed — MACA0HR/LR written

Lessons worth writing down

A linker script comment can already contain the answer, months later

The DTCM memory-map comment ("no DMA!") had been written during an earlier, unrelated session — and turned out to be the exact answer to Bug 1, sitting unread in a file that had been open the whole time. Comments documenting hardware constraints are worth re-reading, not just writing.

An identical failure pattern at different scales is a strong clue, not noise

"Works exactly N times, then fails forever" at both N=1 and N=2 descriptors was the detail that ruled out randomness and pointed straight at descriptor reuse — a coincidence at one ring size might be ignored, but the same exact-count pattern repeating at a different size is a signal worth trusting.

Cache coherency bugs often look like something else entirely

The actual symptom of the D-Cache problem wasn't a cache-related error message — it was a tail pointer seemingly not working, then an unrelated-looking alignment fault once an MPU region was added to fix it. Cache issues frequently present as "this other thing I just changed is now broken," not as anything obviously cache-shaped.

Reset defaults are not always safe defaults

Three separate reset-default values (RBSZ=0, RSF=0, MAC address=0) each silently produced "receives nothing, reports no error" rather than any diagnosable failure. When a peripheral goes quiet without complaining, checking every register the driver never explicitly writes is often more productive than re-checking the ones it does.

Real network traffic is a better RX test than loopback

Internal MAC loopback (MACCR.LM=1) never worked reliably on this STM32H7/RMII combination even after every fix above — while the exact same descriptor/DMA/MPU code, tested against real network traffic instead, worked immediately. When a "simpler" self-test keeps failing while the real-world path works, it's worth questioning whether the self-test itself is reliable on this hardware, rather than continuing to debug against it.

References

Everything above can be verified against these primary sources.

When in doubt, go to the spec, datasheet, programm,ng manual... — not a blog post.

It is only what ı understand can be wrong !

STM32H7

Board

ARM Architecture

Project