“‘No’ means let’s do it anyway!”
Gene Frantz
I almost abandoned this project ten months ago. The board would not talk to the Ethernet
chip, I found an erratum saying the chip needs an 8 MHz SPI clock, I could reach
about 35 kHz bit-banged, and that seemed like the end of it. In 2026 I picked it up again
and gave it a second shot.
The temptation to get this going anyways remained a bit of a personal challenge.
With so many IP stacks dangling around for the tinyest MCUs I was wondering if we
can pull of IP networking on this chip.
Ethernet on an STC89C52
None of the STC89 parts have an Ethernet MAC or PHY, so the protocol has to
live in an external controller. Dallas Semiconductor saw this coming in the
90s and shipped the DS80C400,
an MCS-51 derivative with the MAC built in, still in production. But I do not feel
like acquiring another dev kit to catch dust. The low-cost option everyone
reaches for is the Microchip ENC28J60: a
stand-alone 10BASE-T controller with an SPI interface and 8 KB of buffer
memory, around since the early 2000s and still everywhere in hobby and
industrial gear. It handles framing, Manchester encoding, collision detection
and CRC.
Protocol Background
Every device on the network carries a unique six-byte MAC address, plus two
special forms: the broadcast address FF:FF:FF:FF:FF:FF, which reaches
everything, and multicast addresses, which reach a group.
For a product you register with the IEEE for your own
block. Having built a number of chip-down embedded systems for vision tasks
over the years, I have an example of what that looks like.
After laying down a significant amount of funds, you own a turf of 4095
addresses. On the ENC28J60 they live in six registers called MAADR0 through MAADR5, and those six registers are where this project fell over.
Yet another IP stack
So before writing anything, I went looking for a stack I could borrow. The 8051 has
been around since 1980 and the ENC28J60 since 2004, so somebody must have done this.
I built the candidates rather than reading their README files. Each one was compiled
with SDCC 4.5.0 for mcs51, linked against a stub main() and stubbed drivers, and
the sizes read off the linker’s .mem output. That is the same measurement my own
stack reports, so the numbers compare directly.
| stack | flash | RAM | MTU |
|---|
| this port, ARP through TCP | 10,272 | 501 | 1500 |
| uIP 1.0 + DHCP + DNS | 19,360 | 1,113 | ~380 |
| tuxgraphics 3rd gen | 7,451 | 776 | ~530 |
| lwIP 2.2.0, minimal | 137,900 | 13,503 | n/a |
| picoTCP, on ARM | 30,508 | ~19,000 | n/a |
The STC89C52 has 8 KB of flash and 256 bytes of XRAM. Every row except one is over
budget on RAM alone, and RAM is the wall you hit first.
lwIP fails to compile, in six files, with SDCC error 92: functions called via pointers must be reentrant. SDCC overlays
parameters and locals in statically allocated RAM, so an indirect call cannot carry
more than a couple of bytes of arguments. lwIP is built on exactly those calls: netif->input, netif->output, pcb->recv. Making every function reentrant clears
the errors and produces the 137 KB above, which then fails to link.
tuxgraphics was written for an ATmega88 with an ENC28J60, almost exactly this board’s budget,
and it ships the NIC driver. 7.5 KB of flash is genuinely under the limit. Then its
driver reads each frame into buf[] with BUFFER_SIZE 550, and the TCP code indexes
that array everywhere.
The pattern across all of them is keeping a whole frame in MCU RAM, because
every part they were written for has kilobytes of it to spare.
Searching for 8051 ports turned up two that published their build output
rather than a claim.
A Keil C51 port of uIP 0.9 has MEMORY MODEL: LARGE, Program Size: data=30.1 xdata=1024 code=20310 sitting in its repository. Subtracting the 5,635 bytes of ROM web pages
leaves about 14.7 KB of stack and driver, using the whole 1 KB of XDATA, at an MTU of
233, with UDP compiled out and no DHCP or DNS. Its SPI is bit-banged on P1.5, P1.6 and
P1.7, within one pin of this board.
A hand-rolled SDCC stack reports 18,181 bytes of flash and 2,015 of XDATA for ARP
plus one TCP connection plus HTTP, with no ICMP and no UDP at all.
Both needed large memory model. Both needed at least 1 KB of XDATA. Neither managed
UDP and ICMP and DHCP and DNS together. No published 8051 IP stack I could find runs
below about 14 KB of flash or 1 KB of XDATA.
This idea comes from UIPEthernet. Headers land in a
42-byte scratch buffer, payloads stream from the NIC’s receive buffer straight to its
transmit buffer through a 32-byte window, and the TCP retransmit copy lives in a slot in
the ENC28J60’s own 8 KB of SRAM. An MTU of 1500 therefore costs no XRAM at all.
UIPEthernet reaches the by setting UIP_CONF_BUFFER_SIZE 98 so the RAM buffer
holds headers only and carving the payload pool out of the ENC’s transmit SRAM. Microchip’s
own TCP/IP Lite does the same thing. Both are unusable here, one being Arduino C++ and the
other licensed to Microchip silicon, but both had the idea first.
Both halves of that conclusion were wrong, and the interesting part is how they
were wrong. The erratum does not apply to any chip you can buy today. And the
8051 turns out to have a hardware SPI port that its datasheet never calls one.
Rolling our Own
So picking up the best design lessons from the other stacks, I rolled my own to make it work. It
was quite a bit of work in many stages.
| stage | feature | flash | XRAM |
|---|
| 1 | ARP reply | 1,827 | 78 |
| 2 | IPv4, ICMP echo | 2,510 | 78 |
| 3 | UDP echo | 2,630 | 78 |
| 4 | DHCP client | 4,025 | 97 |
| 5 | MTU 1500 | 4,025 | 97 |
| 6 | lease, renew, rebind, expiry | 5,137 | 126 |
| 7 | ARP client, DNS A records | 7,147 | 209 |
| 8 | TCP echo, one connection | 10,272 | 279 |
Stage 5 is the one worth staring at. Going from a 576-byte MTU to 1500 costs no flash
and no RAM at all, because the MTU is a constant and a different buffer layout inside
the NIC.
Stage 8 does not fit the STC89C52RC. Ten kilobytes of flash against an eight kilobyte
part is not a near miss, so TCP needs the pin-compatible STC89C516RD+ with 61 KB. Every
other stage up to 7 fits the small part with room left.
Why It failed the First Time
The document is DS80349C, and
erratum #1 says exactly what I remembered:
When the SPI clock from the host microcontroller is run at frequencies of less
than 8 MHz, reading or writing to the MAC registers may be unreliable.
So the dusty one I had in the box had to be replaced by a newer silicon revision. So the
first thing the firmware does now, before configuring anything, is say what
it is talking to:
ENC28J60 bring-up
EREVID: 0x06 B5/B7 -- errata #1 does NOT apply
And if it is an affected die, there is still a way through that does not need
8 MHz. Read what the erratum actually restricts: access is unreliable, not
impossible, and it covers the MAC registers only, not the ETH registers and not the
packet buffer, which is where all the throughput lives. The MAC registers are
written about a dozen times and all of them during initialisation. So write them,
read them back, and retry:
static void enc28j60WriteMacReg(uint8_t address, uint8_t data)
{
uint8_t attempt;
if (!Enc28j60MacVerify) {
enc28j60WriteReg(address, data);
return;
}
for (attempt = 0; attempt < 8; attempt++) {
enc28j60WriteReg(address, data);
if (enc28j60ReadReg(address) == data)
return;
}
}
Bit-banged SPI
The second failure of the previous attempts were board peripherals. The pins I
initially picked were used for LED control and landed in a logic chip.
On the initial attempt. EREVID should read 0x06. It read 0x0C.
Byte-identical across three cold
boots, which is the shape of a wiring fault rather than noise: noise is not
repeatable to the bit. 0x0C is 0x06 shifted left by one. Every register
read came back doubled.
The SPI lines were on P0. On an 8051, P0 has no internal pull-up at all: it is
open-drain, and on this board it is pulled up through a 10k resistor pack into a
bus it shares with an always-enabled 74HC245, the LED matrix rows, and the LCD
data lines. None of that is hidden. It is on page one of the board schematic, which I had not read closely enough to notice what
else was hanging off the port I had chosen.
Moving the pins away from things that could cause side effects did the trick. This is
the configuration that actually worked:
| signal | 8051 pin | ENC28J60 pin |
|---|
| clock | P1.7 | SCK |
| host out, device in | P1.6 | SI |
| host in, device out | P1.4 | SO |
| chip select | P3.3 | CS |
Those four are the only pins on this board that are actively driven and not shared
with something else.
One thing to get right before powering it on: the module needs 3.3 V, and its
pins tolerate 5 V. The ENC28J60 core is a 3.3 V part, so feeding the module 5 V destroys
it. Its digital inputs are 5 V tolerant.
All sixteen write-and-read-back patterns then came back exact. UDP
echo is 50 of 50, byte-exact, from 1 to 100 bytes. Which is terrible by modern
standards, but impressive for such a low-spec part.
Those numbers come from a freshly booted board answering a handful of packets. Run
it for a few minutes and it stopped replying altogether, and stayed deaf until a
reset. Twelve and then fourteen consecutive test runs came back 0 of 5.
Flaky Connections
The SPI link is electrically marginal. A read-only EREVID canary, which must return 0x06, misreads one to three times per run once traffic is flowing. Corrupted
headers come back carrying recognisable frame payload, 0x4141 for two ASCII
letter As, or pointers that are odd, or pointers outside the ring entirely.
Widening the clock made it worse rather than better.
Look at the wiring and it stops being mysterious.
My former boss and Mentor from 17 years ago that taught me the ins and outs of embedded
design advised that any wired connection off
a PCB needs a checksum and recovery, well good luck doing this with this protocol. So
this required some creativity, that a custom PCB probably does not need.
The driver now defends itself rather than trusting what it reads. spi_resync() pulses CS to get a slave stuck mid-opcode back in step. rd_stable() reads twice
and believes a repeat. Every packet header is read twice and validated, with
recovery if the ring pointer is nonsense. There is an RXEN watchdog, because on
this link even control writes get corrupted, and one lost ECON1 write leaves the
board deaf while every status register still reads healthy.
The board no longer goes deaf. It answers one to five of five pings under sustained
traffic instead of none. That is an improvement and it is not a fix.
Summary
The chip was never the problem. What made this hard was reading an erratum without
its affected-revisions table, debugging through a console that had quietly stopped
telling the truth, and then putting the bus on the one port that cannot drive it.
Four things I would take to the next project of this shape:
- Read the part number out loud before believing a datasheet. One
EREVID read would have saved ten months. - Fix the instrument first. When the debug channel and the device under test
break in the same week, everything you conclude afterwards is decoration.
- Audit the port before you audit the protocol. P0 is open-drain and shared,
and no amount of correct SPI timing survives a line that three other devices
can pull down. A repeatable wrong answer is a wiring fault. Only noise is
random.
Useful References
Code is in the stc89c52-demos repository. The simulator work is not published yet.
The two 8051 ports that published their build output, which is what made the
comparison decidable rather than arguable:
Board and part documentation:
To learn more about Ethernet itself, Microchip University has a good
free course covering the physical layer, data link layer and higher-level
protocols, and there is a solid application note on Ethernet as well.
The quote at the top is from Gene Frantz. Without some unconventional hacking
there would have been no answering machines and no Speak and Spell. His retirement
symposium talk is worth a watch:
Published: 2025-11-29
Updated : 2026-09-26
Not a spam bot? Want to leave comments or provide editorial guidance? Please click any of the social
links below and make an effort to connect. I promise I read all messages and will respond at my
choosing.