This is the engineering reference for the model: what it implements, register by register, for people writing firmware against it. For using the board in the editor, start with The ESP8266 NodeMCU and the board page.
ESP8266 NodeMCU board contract (Boards Wave C)
The NodeMCU V1.0 is the thirty-pin board around an ESP-12E module: an Xtensa LX106 at 80 MHz, the smallest and oldest part here and still the one most people's first WiFi board was. One kernel component (esp8266 in crates/parts) wraps an ESP8266 SoC model (crates/esp8266) on the same Xtensa interpreter as the ESP32 (crates/xtensa-core), in a different configuration. This document is the contract, and it wins over any comment in the code.
The catalog type is esp8266. The catalog program string is elf32 xtensa lx106 esp8266.
Read section 6 first if you are taking a build from here to real silicon. Most of this board is at its real address; the ADC is not, and that is said in section 3.5, on the board page and in the help article as well as here, because Espressif has never published the TOUT converter's register interface at all.
A sketch for this board compiles in the browser with clang-v3, the wasm build of Espressif's LLVM fork (-mcpu=esp8266), published 2026-09-28 and checked on this simulator (docs/compile.md). An untouched example still runs its prebuilt ELF from web/public/firmware/esp8266/, which Espressif's GCC builds. Section 7 has the details.
1. Board pins (catalog order)
Top view, the micro-USB socket at the bottom, the ESP-12E module and its PCB antenna at the top. The board is 48.3 x 25.6 mm and its two 15-pin headers are 22.86 mm (0.9 inch) apart, so it straddles a breadboard with one row of holes left free on each side. The visual draws it at that size.
That is the V1.0 Amica, which is the board this part models. The V3 "LoLin" that people also call a NodeMCU is wider, with a full inch between its header rows: on a breadboard it leaves no row free at all, which is where this board's reputation for needing two breadboards comes from.
Left header, top to bottom (15 holes): A0, RSV, RSVb, SD3, SD2, SD1, CMD, SD0, CLK, GND, 3V3, EN, RST, GNDb, VIN.
Right header, top to bottom (15 holes): D0, D1, D2, D3, D4, 3V3b, GNDc, D5, D6, D7, D8, RX, TX, GNDd, 3V3c.
30 pins. Duplicated names carry a letter because catalog pin names are unique.
The silkscreen names are not GPIO numbers
This is the single thing this board catches people out with, so it is written here, in the help article and in the board page:
| silkscreen | D0 |
D1 |
D2 |
D3 |
D4 |
D5 |
D6 |
D7 |
D8 |
RX |
TX |
|---|---|---|---|---|---|---|---|---|---|---|---|
| GPIO | 16 | 5 | 4 | 0 | 2 | 14 | 12 | 13 | 15 | 3 | 1 |
digitalWrite(2, HIGH) is D4, not D2. The runtime defines D0 to D8 so a sketch can use either, and crates/parts/src/parts/esp8266.rs has a test that holds the whole table.
Eight header pins that are connected to nothing
SD0, SD1, SD2, SD3, CMD and CLK are GPIO 7, 8, 9, 10, 11 and 6: the flash chip's six wires, and the program is running out of them. They are on a real NodeMCU's header, so they are on this part; they neither drive nor are read here. The two RSV pins are the same. A sketch that worked in a simulator because it used SD1 would stop the flash on a desk, and that is the one thing this part exists to prevent.
A0 is behind the board's divider
The chip's TOUT pin converts 0 to 1.0 V. A NodeMCU puts a 220k/100k divider in front of it, so the A0 on the header is 0 to about 3.2 V and a pot across the 3.3 V rail very nearly spans the range. The divider belongs to the board, not the chip, so it lives in the board part: the pin loads its net with 320 k and the SoC is handed the third of the voltage that reaches the chip pin. A0 is not a GPIO and cannot be written.
The two LEDs, and both are active low
- GPIO 2, which is
D4, has the ESP-12 module's blue LED. It is also UART1's TX on a real part, which is why a sketch that usesSerial1loses it. - GPIO 16, which is
D0, has the NodeMCU carrier board's LED.
Both are wired from 3V3 to the pin, so LOW lights them. Getting this the wrong way around is the mistake this board is best at provoking, so probe reports each lamp as lit when its pad is low (section 5).
GPIO 16 is not an ordinary pin
D0 is the RTC's pin. It is in none of the GPIO block's registers (it has its own output, enable and input registers in the RTC block, section 3.2); it has a pull-down where every other pad on the part has a pull-up, and it cannot raise an interrupt. On a NodeMCU it is the pin you wire to RST for deep-sleep wake, and it is D0, so it is not an oddity a sketch can ignore.
Electrical: every GPIO drives push-pull at 3.3 V through R_STRONG, sits high-Z, or pulls through 45 k. An input reads high at 1.65 V and a floating or contended net reads 0. 3V3 drives 3.3 V at R_SUPPLY, VIN drives 5 V, the four grounds drive 0 V. RST and EN are tied to the same node here, as a NodeMCU ties them, with a 10 k pull-up to 3.3 V; pulling either below 1.65 V holds the core in reset. The capacitor beside that resistor is the board's power-on reset and is not modeled.
The exact silkscreen ordering of the two headers is this model's reading of the NodeMCU V1.0 (Amica) pinout. The GPIO numbers are the chip's and are what firmware depends on; their position on the header is what a diagram depends on. A V3 LoLin moves two of them as well as being wider, and a circuit drawn here would need rewiring for one.
2. Memory map
| IRAM (instruction) | 32 KB at 0x4010_0000 to 0x4010_7FFF |
| DRAM (data) | 80 KB at 0x3FFE_8000 to 0x3FFF_BFFF |
| stack top | 0x3FFF_C000 |
| UART0 | 0x6000_0000, section 3.4 |
| GPIO | 0x6000_0300, section 3.1 |
| RTC (GPIO16) | 0x6000_0700, section 3.2 |
| IO_MUX | 0x6000_0800, section 3.3 |
| SAR (TOUT) | 0x6000_0D00, section 3.5, this model's own |
Each peripheral block is 0x100 here, because on this part they really are that close together.
Two windows, like the ESP32's and for the same reason: code in iram1_0_seg, data in dram0_0_seg. Every ESP8266 linker script uses these two names and these two addresses. The 32 KB of instruction RAM is the real ceiling on an example here, and it is small: a sketch that would not fit in IRAM on a real part does not fit here either, which is the honest thing for this board to do.
Every PT_LOAD segment must land inside one of those two ranges; anything else is refused with the offending address in the message, and the chip is left with no program.
The core resets with pc at the ELF entry point and every address register zero. There is no boot ROM: the real part's ROM loader, the flash reads, the cache set-up and the SDK's own initialization are absent, and firmware/esp8266/crt0.S makes the state it wants explicit.
3. Peripheral registers
Only the subset below is implemented. Anything else inside a mapped block reads 0 and counts as a stray access; anything outside every block is an access fault.
3.1 GPIO (0x6000_0300)
Seventeen pins, but only sixteen are here: GPIO 0 to 15 are ordinary and GPIO 16 is in the RTC block instead (section 3.2).
| offset | register |
|---|---|
0x00 |
GPIO_OUT |
0x04 |
GPIO_OUT_W1TS |
0x08 |
GPIO_OUT_W1TC |
0x0C |
GPIO_ENABLE |
0x10 |
GPIO_ENABLE_W1TS |
0x14 |
GPIO_ENABLE_W1TC |
0x18 |
GPIO_IN |
0x1C |
GPIO_STATUS |
0x20 |
GPIO_STATUS_W1TS |
0x24 |
GPIO_STATUS_W1TC |
0x28 + 4n |
GPIO_PINn, for n in 0..16 |
Verified in the sense this part allows: the base and every offset here are the ones every ESP8266 header, linker script and open register description has used for a decade, and they are what the Espressif ESP8266 Technical Reference Manual gives for the GPIO block. They are not a number this model chose.
GPIO_PINn bit 2 PAD_DRIVER makes the pad open drain; bits 7..9 INT_TYPE choose what raises the pin's interrupt: 0 off, 1 rising, 2 falling, 3 any edge, 4 low level, 5 high level. There is no GPIO matrix on this part: a pad is GPIO or it is a peripheral's, and which is chosen in IO_MUX.
There is no per-pin interrupt-enable bit beyond INT_TYPE, and no second half of any register, because sixteen bits is all the block needs.
3.2 The RTC registers behind GPIO16 (0x6000_0700)
| offset | register | bits |
|---|---|---|
0x68 |
RTC_GPIO_OUT |
bit 0 is GPIO16's output level |
0x74 |
RTC_GPIO_ENABLE |
bit 0 is GPIO16's output driver |
0x8C |
RTC_GPIO_IN_DATA |
bit 0 is what GPIO16's pad is seeing |
0x90 |
RTC_GPIO_CONF |
bit 0 clear puts the pad under RTC_GPIO_ENABLE |
0xA0 |
PAD_XPD_DCDC_CONF |
bits 0..1 select GPIO16 rather than the DCDC pin |
Believed rather than verified. These five are the registers every ESP8266 GPIO16 sequence has used since 2015 and the ones the Arduino core's pinMode(16, ...) writes, but they are not a page of a manual quoted back. The pull on this pad is a pull-down, unlike every other pad on the part.
Nothing else in the RTC block is modeled: no RTC counter, no RTC memory, no deep sleep, no wake source.
3.3 IO_MUX (0x6000_0800)
PERIPHS_IO_MUX_CONF_U at 0x00, then one register per pad. Bit 7 is the internal pull-up.
There is no input-enable bit anywhere, and no internal pull-down on GPIO 0 to 15. An ESP8266's input buffer is always on, and INPUT_PULLDOWN does not exist on this part; only GPIO16 has a pull-down at all, and that is in the RTC block. A sketch that wants a pull-down needs a resistor.
As on the ESP32, the registers are in pad order, not GPIO order:
| GPIO | offset | GPIO | offset |
|---|---|---|---|
| 0 | 0x34 |
8 | 0x24 |
| 1 | 0x18 |
9 | 0x28 |
| 2 | 0x38 |
10 | 0x2C |
| 3 | 0x14 |
11 | 0x30 |
| 4 | 0x3C |
12 | 0x04 |
| 5 | 0x40 |
13 | 0x08 |
| 6 | 0x1C |
14 | 0x0C |
| 7 | 0x20 |
15 | 0x10 |
Believed rather than verified. GPIO16 has no IO_MUX register at all.
3.4 UART0 (0x6000_0000)
| offset | register |
|---|---|
0x00 |
UART_FIFO |
0x04 |
UART_INT_RAW |
0x08 |
UART_INT_ST |
0x0C |
UART_INT_ENA |
0x10 |
UART_INT_CLR |
0x14 |
UART_CLKDIV |
0x1C |
UART_STATUS |
0x20 |
UART_CONF0 |
0x24 |
UART_CONF1 |
Verified: the base and every offset, and the bits below, against the ESP8266 Technical Reference v1.7, appendix 3 "UART Registers". The offsets are the ESP32's and the ESP32-C3's, but this part's fields are narrower, so crates/esp8266/src/uart.rs is no longer crates/esp32/src/uart.rs twice.
Two 128-byte FIFOs. UART_STATUS bits 0..7 RXFIFO_CNT and bits 16..23 TXFIFO_CNT, and the model and the runtime read eight-bit fields there, as appendix 3 gives them. The runtime once read ten, which gave the same numbers because the bits above each field are reserved and read 0; the header now matches the manual, as on the ESP32, where the bits above are not reserved. UART_CONF0 bit 17 RXFIFO_RST and bit 18 TXFIFO_RST. UART_CONF1 bits 0..6 RXFIFO_FULL_THRHD and bits 8..14 TXFIFO_EMPTY_THRHD, both 96 at reset. UART_CLKDIV bits 0..19 are the divisor, which divides the 80 MHz APB clock and resets to 694 (the appendix's 0x2B6), which is 115 274 baud.
Three of those were wrong here before that check, and none changed a built image: the two FIFO-reset bits were swapped (the runtime's Serial.begin sets and clears both together, so the value written was the same); CONF1 had the ESP32-C3's nine-bit fields at 0 and 9 (the runtime never writes CONF1); and the transmit pacing read only twelve bits of the divisor, so Serial.begin(9600), whose divisor is 8333, sent bytes about 59 times too fast. The pacing now uses all twenty bits.
Bytes leave the transmit FIFO at the baud rate, not instantly, for the reason docs/esp32.md section 3.3 gives. crates/esp8266/tests/runtime.rs measures the gap between two bytes of the serial example against the 86.8 us a byte takes at 115200.
UART1 is not modeled. On a real ESP8266 UART1's TX is GPIO 2, which is also the module's LED, and that is the whole of why Serial1 and the blue LED cannot both be had.
3.5 The TOUT converter (0x6000_0D00): the one block that is not the chip's
Read this before believing an address. Every other peripheral in this model is at its real address with its real offsets, so firmware built here runs on a real NodeMCU. The ADC is the exception, and it is not a small one. Espressif has never published the ESP8266's SAR registers: system_adc_read() lives inside the SDK's binary blob, the Arduino core calls it, and there is no page of a manual to quote. Rather than invent an address and let a reader assume it is the chip's, this model puts three registers of its own at SAR_BASE:
| offset | register | bits |
|---|---|---|
0x00 |
SAR_CFG |
bit 0 START begins a conversion; bit 1 is stored and does nothing |
0x04 |
SAR_STATUS |
bit 31 DONE; bits 0..9 the ten-bit result |
Unverified, and not merely unverified: invented. analogRead is the one call on this board that would need changing for real hardware. There it is system_adc_read() out of the SDK. Nothing else would.
What is modeled is the part a circuit can see: one channel, ten bits, round(1023 x Vin / 1.0 V), clamped, of the voltage at the chip's pin. The NodeMCU's 220k/100k divider is the board part's (section 1), so the header's full scale is about 3.2 V. An unconnected pin reads 0.
A conversion takes a fixed 80 us of simulated time. The real figure depends on the SDK's sampling loop and is nearer a hundred microseconds; this is the one number on this block that is a model's choice rather than a measurement, and section 6 lists it.
3.6 The clock: CCOUNT, and no timer peripheral
FRC1 and FRC2 are not modeled. The ESP8266 has two hardware timers and neither is here. The clock this board's runtime reads is the core's own CCOUNT, and its millisecond tick and its software PWM both come from CCOMPARE0. Both are core registers on every Xtensa, so no believed peripheral address stands behind millis().
The cost is written down rather than hidden. CCOUNT is thirty-two bits at 80 MHz, so it wraps every 53.7 seconds. The sixty-four-bit count the runtime hands out is kept by sampling CCOUNT and adding the difference, which is exact as long as no more than one wrap happens between two samples. Every millis(), micros() and delay() samples it, and the scheduler never lets the next interrupt be more than 2^30 cycles (13.4 seconds) away. So only a sketch that turns interrupts off for a minute and asks the time for nothing in that minute can lose a wrap. Section 6 says so again.
crates/esp8266/tests/runtime.rs measures millis() against the kernel's own clock over five seconds.
3.7 PWM, which is software on this part
The ESP8266 has no PWM hardware of any kind: no LEDC, nothing. The Arduino ESP8266 core generates its waveforms from a timer handler and so does this runtime: eight slots, each with a pin, a high time and a low time in CPU cycles, and the shared CCOMPARE0 deadline armed for whichever edge comes next.
That is the honest difference between the two Xtensa boards here. On an ESP32 a fading LED costs the core nothing, because LEDC is hardware and the simulator wakes the board only for the edge. On an ESP8266 the core wakes twice a period to move the pin itself, which is exactly what the real part costs, and it shows in the instruction count. The board page says it too.
Eight pins at once. The default frequency is 1 kHz and analogWriteFreq takes 40 Hz to 60 kHz. crates/esp8266/tests/runtime.rs holds the fade example's period to 1 kHz within a handler's worth of jitter, rather than the ESP32's five microseconds, because a handler's worth of jitter is what software PWM has.
The range is 1023, not 255; see section 7.
3.8 What has no registers here at all
SPI and HSPI, I2C (there is no I2C peripheral on this part in the first place; the Arduino core bit-bangs it), I2S, the hardware UART1, the watchdogs, the RTC counter and memory, deep sleep, the eFuses, and the whole WiFi MAC and PHY.
3.9 Mokxi network mailbox (0x6FFF_E000)
This block is not the ESP8266's. Everything else in this document is either
the part's or is marked as this model's own invention with the reason beside it
(section 3.5, the TOUT converter). This is neither: it is Mokxi's simulated
network of crates/net, at the top of a peripheral window whose real blocks
are all in its first few kilobytes, so that an image using it faults on
silicon rather than reading some other peripheral's register. That fault is
the honest answer, because the thing behind this block does not exist on the
chip. Nothing here is taken from the Espressif SDK or the Arduino ESP8266 core.
This is the part with a radio and no documentation for it at all. The ESP8266's WiFi is a binary blob in the SDK; Espressif has never published its registers, and this model does not guess at them. What it offers instead is a mailbox that is honestly Mokxi's, at an address that is honestly not the part's.
The block raises no interrupt. The interrupt numbers this model uses are
the ones every ESP8266 header agrees on, and there is no such number for a
peripheral the chip does not have, so the mailbox is given none. The firmware
polls, and a core parked in waiti is still woken when the network has
something to say.
There is no radio, no 802.11 and no real internet here. Nothing in the model
transmits, scans, associates, encrypts or opens a socket: a password is compared
as a string, a name that is not under .mokxi does not resolve, and the three
origins the network serves are functions in crates/net/src/http.rs. The
mailbox is the shape of the real seam rather than a fiction about a MAC: on a
real ESP32 the WiFi hardware is a closed binary blob with a command interface,
and Arduino's WiFi.h ends up on the other side of one of these.
docs/wifi.md is the contract for what is behind it; this section is only the
registers.
A command register, a status register, a 4096-byte ring each way and an
interrupt bit. Writing NET_CMD_REG runs the command; a command that takes
simulated time raises BUSY and clears it, with DONE in NET_INT_RAW_REG,
when it is finished.
| offset | name | bits |
|---|---|---|
0x00 |
NET_ID_REG |
reads 0x494B4F4D, and nothing else does. A sketch that wants to know whether it is on Mokxi reads this; on silicon the access faults |
0x04 |
NET_CMD_REG |
write a command code (table below); the write runs it |
0x08 |
NET_ARG_REG |
one argument word, read back: the port for LISTEN, the status for REPLY |
0x0C |
NET_STATUS_REG |
bit 0 BUSY, bit 1 ERR, bit 2 RX_READY, bit 3 LINK, bit 4 SERVE, bits 8..11 STATE |
0x10 |
NET_RESULT_REG |
what the last command answered |
0x14 |
NET_INT_RAW_REG |
bit 0 DONE, bit 1 EVENT |
0x18 |
NET_INT_ENA_REG |
the same two bits |
0x1C |
NET_INT_CLR_REG |
write 1 to clear, the same two bits |
0x20 |
NET_TX_LEN_REG |
bytes staged in the outbound ring |
0x24 |
NET_TX_DATA_REG |
write: push one byte (bits 0..7) into the outbound ring |
0x28 |
NET_RX_LEN_REG |
bytes waiting in the inbound ring |
0x2C |
NET_RX_DATA_REG |
read: pop one byte from the inbound ring, 0 when it is empty |
0x30 |
NET_RING_LEN_REG |
capacity of each ring in bytes, so firmware need not hard-code 4096 |
STATE is where the station is: 0 idle, 1 starting, 2 connecting, 3 connected,
4 failed, 5 no such network. NET_CMD_STATUS turns the same thing into the
Arduino wl_status_t code a sketch reads from WiFi.status().
NET_CMD_REG |
name | what it does |
|---|---|---|
| 0 | NOP |
nothing |
| 1 | RESET |
disconnect, drop both rings, drop any request |
| 2 | CONNECT |
the outbound ring holds ssid\0password; raises BUSY for one to three seconds |
| 3 | DISCONNECT |
drop the association and the address |
| 4 | STATUS |
NET_RESULT_REG = the wl_status_t code |
| 5 | IP |
NET_RESULT_REG = the leased address, first octet in the low byte |
| 6 | RSSI |
NET_RESULT_REG = the signal in dBm, sign extended |
| 7 | SSID |
the inbound ring gets the access point's name |
| 8 | MAC |
the inbound ring gets the board's six MAC bytes |
| 9 | REQUEST |
the outbound ring holds method\0url\0headers\0body; raises BUSY |
| 10 | POLL |
NET_RESULT_REG = 0 in flight, then the HTTP status, or a negative error with ERR up; the body lands in the inbound ring |
| 11 | LISTEN |
listen on the port in NET_ARG_REG |
| 12 | ACCEPT |
NET_RESULT_REG = 1 and the inbound ring holds method\0path\0query\0body, or 0 when nothing is waiting |
| 13 | REPLY |
answer the accepted request with the status in NET_ARG_REG; the outbound ring holds content-type\0body |
| 14 | STOP |
stop listening |
| 15 | WL_GPIO_SET |
drive the wireless part's own GPIOs from NET_ARG_REG. Not this board: this part's radio has no such pin, so it sets ERR and drives nothing. It is the path a Pico W's on-board LED takes (docs/picow.md section 3.8) |
| 16 | WL_GPIO_GET |
read them back, and the same ERR here |
The negative POLL codes: -1 the name does not resolve, -2 the station is not
associated, -3 https:// and TLS is not simulated, -4 a body longer than the
ring, -5 a URL this network cannot parse, -6 a request is already in flight.
firmware/include/HTTPClient.h turns them into the Arduino-shaped codes a
sketch compares against, and docs/wifi.md lists the mapping.
Timing, all of it this model's own and all of it stated in docs/wifi.md: an
association takes between one and three seconds, decided from the SSID so a
lesson takes the same time twice; a wrong password becomes WL_CONNECT_FAILED
after five seconds; a request to a simulated origin comes back 60 ms later; a
request into the sketch's own server that the sketch never answers is closed by
the network with a 504 after ten seconds.
4. The core: which ABI, and how an interrupt arrives
crates/xtensa-core is the same interpreter the ESP32 runs on, in the LX106 configuration. Nothing in it is derived from QEMU, from the Espressif SDK, from the Arduino ESP8266 core or from any other emulator; xtensa-lx106-elf-as was used as a bench instrument and crates/xtensa-core/tests/encoding.rs keeps the encodings that came back.
The call0 ABI, and there was no choice. The LX106 has sixteen address registers and no register window at all, so xtensa-lx106-elf-gcc emits call0, ret, and an explicit addi sp, sp, -16. There is no ENTRY, no RETW, no ROTW, no spilling, and no window overflow or underflow handlers to write. crt0.S here is much shorter than the ESP32's for that one reason, and its vector table is six slots shorter. The window vectors at VECBASE + 0x000 to +0x140 cannot be reached on a part without the windowed option, so they hold a jump to the fault reporter rather than whatever happens to be in memory.
The other consequence is that this board's interrupt handler may save registers on the interrupted stack, which the ESP32's may not: without a window there is no older frame keying off this window's stack pointer.
What the LX106 configuration does not have, and each difference was put to the assembler rather than assumed: hardware divide, the 32-bit high multiply, MIN/MAX, SEXT, CLAMPS, the zero-overhead loop, and the atomic loads. MUL16 and MULL are present. A program built for the ESP32 therefore faults on this core rather than misbehaving quietly, which is why the two boards' program strings differ.
Vectors. VECBASE is a special register; the two that matter here are KernelException at +0x300 and UserException at +0x340. Every level-1 interrupt takes the user exception vector with EXCCAUSE 4, so crt0.S sends them all to one place and the runtime works out which source it was.
Interrupts. INTENABLE masks and PS.INTLEVEL gates, and only level 1 is modeled. Every interrupt the ESP8266 brings out is level 1 except the NMI, which is not modeled, so the level-1 mask here is all ones. That is believed rather than verified.
There is no interrupt matrix on this part: every source has a fixed CPU interrupt number, which is why attachInterrupt here is two writes rather than the ESP32's three.
| source | CPU interrupt |
|---|---|
| GPIO | 4 |
| UART0 | 5 |
CCOMPARE0 |
6 |
Believed rather than verified. 4 is what every ESP8266 header calls ETS_GPIO_INUM and 5 is ETS_UART_INUM; they are not numbers quoted from a manual. CCOMPARE0's 6 is the Xtensa ISA's.
Floating point. The LX106 has no FPU, so there is nothing here to leave out: every floating-point encoding raises EXCCAUSE 32, coprocessor disabled, and the runtime is soft float, which is what a real ESP8266 sketch gets too.
The CCOMPARE rules of docs/esp32.md section 4 (the comparison is "reached or passed", and a deadline already in the past is not armed) apply here, and this board is the one that found the second of them.
5. Time model, probe, poke and the host channel
The CPU runs at 80 MHz, one instruction per cycle, so an instruction is exactly 12.5 ns and an 800-instruction slice is exactly 10 us. Unlike the ESP32's, this bus can multiply rather than divide: instruction k completes at slice_start + 12.5 ns * k.
The ESP8266 can be clocked at 160 MHz. This model is always at 80, which is where the part comes up and where the Arduino core leaves it; there is no clock tree here to change it with.
Inside a slice the instruction count is the clock: a CCOUNT read, a UART byte and a pin change all land at the instruction that caused them. A core parked in WAITI costs nothing and is woken at its next CCOMPARE0 deadline (the millisecond tick, a delay waking up, or the next software PWM edge) or by a pin change or a host byte.
probe is a bitmask:
| bit | meaning |
|---|---|
| 1 | running |
| 2 | parked in waiti |
| 4 | the module's LED on GPIO 2 is lit |
| 8 | the carrier board's LED on GPIO 16 is lit |
0 means no program, held in reset, or crashed. Both LEDs are active low, so each bit is set when its pad is low.
poke: 1 holds FLASH (GPIO 0 low), 0 releases it; 2 holds RST, 3 releases it.
The host channel. host_write puts bytes in UART0's receive FIFO; host_read takes the bytes that have left its transmit FIFO. That is Serial in a sketch and the serial monitor in the UI. Bytes are UTF-8 as far as the UI is concerned and opaque to the model. A NodeMCU's micro-USB socket goes to a CP2102 or CH340 bridge on this port; the bridge is not modeled, and neither is the DTR/RTS auto-reset dance.
Crash. 1000 consecutive traps with no forward progress and the core is declared crashed and halted, with where it died recorded.
6. What this model is sure of, and what it is not
As real as this part gets
The ESP8266 has no technical reference manual of the ESP32's kind (Espressif's is short and leaves whole blocks out), so "verified" here means the number is the one every ESP8266 header, linker script and open register description has used for a decade and is what the official register listing gives. On that standard:
UART0_BASE 0x6000_0000, GPIO_BASE 0x6000_0300, IOMUX_BASE 0x6000_0800 and RTC_BASE 0x6000_0700. Every register offset inside the GPIO block and inside UART0, and UART0's CONF0 FIFO resets, CONF1 thresholds, CLKDIV width and STATUS counts, each checked against appendix 3 of the ESP8266 Technical Reference v1.7 (section 3.4). The iram1_0_seg at 0x4010_0000 with 32 KB and the dram0_0_seg at 0x3FFE_8000 with 80 KB. The seventeen GPIOs, that 6 to 11 are the flash, and that 16 is the RTC's pin and is in no GPIO register. The D0 to D8 silkscreen table. The module's LED on GPIO 2 and the carrier board's on GPIO 16, both active low. The 220k/100k divider on A0. That there is no PWM hardware, no input-enable bit and no pull-down on GPIO 0 to 15. The Xtensa vector offsets and EXCCAUSE numbers, which are the ISA's.
Believed, not verified
- The IO_MUX pad-order table (section 3.3).
- The five RTC registers behind GPIO16 (section 3.2).
- The CPU interrupt numbers: GPIO 4, UART0 5 (section 4). They are
ETS_GPIO_INUMandETS_UART_INUM, not quoted numbers. - The level-1 interrupt mask, taken to be every interrupt the part brings out (section 4).
All of them are in one place the firmware reads, firmware/esp8266/include/esp8266.h, so an image being taken to real silicon has a short list of #defines to check. crates/esp8266/tests/contract.rs holds that header and crates/esp8266/src/map.rs to the same numbers; it does not make either of them right.
Invented, and named as invented
- The three SAR registers at 0x6000_0D00 (section 3.5).
SAR_CFG,SAR_STATUSand their bits are this model's, because the chip's are not public.analogReadis the one call on this board that would need changing for a real NodeMCU.
Modeled, but not to the part's numbers
- A conversion takes exactly 80 us. The real figure depends on the SDK's sampling loop and is nearer a hundred.
- The ADC is a straight line over 0 to 1.0 V at the chip pin, and the divider is exactly 100/320.
- The clock is always 80 MHz. No 160 MHz, and nothing to switch with.
RSTandENare a 10 k pull-up alone; the power-on reset capacitor is not modeled.- There is no boot ROM.
POWER_ONhere is 1 us; a real ESP8266 spends much longer in its ROM loader and prints the 74880-baud boot message this board never prints. micros64()can lose a wrap if interrupts are held off for longer than 53.7 seconds and nothing asks the time in that window (section 3.6).- Software PWM jitters by a handler's worth, because it is software (section 3.7). That is what the real part does too.
- Every pad powers up the same: plain GPIO, no pull.
Deliberately absent
- WiFi, in every form. No radio, no PHY, no MAC, no stack, no
ESP8266WiFi.h, not even a stub that fails politely. On a part whose whole reason to exist is WiFi, this is the biggest thing missing, and the board page says it first rather than last. - Flash and the cache that maps it. Code runs from IRAM; there is no
PROGMEMthat reads from flash, no SPIFFS, no LittleFS, no OTA. FRC1andFRC2, the two hardware timers, and both watchdogs (section 3.6).- Deep sleep and the RTC, its counter and its memory, and the GPIO16-to-RST wake.
- SPI, HSPI, I2S and the hardware UART1 (section 3.8).
- The eFuses and the chip ID.
- Every interrupt level above one, and the NMI.
That is why vendor SDK and Arduino-core binaries do not run here, and firmware built for this document does.
An ESP32 image will not run here either. The catalog program string is elf32 xtensa lx106 esp8266 against the DevKit's elf32 xtensa lx6 esp32, and that is a real difference: the LX106 has no register window, so the first entry in a windowed image is an illegal instruction rather than a subtle wrong answer.
7. Firmware runtime (firmware/esp8266/)
An Arduino-shaped runtime: setup() and loop(), pinMode, digitalWrite, digitalRead, shiftOut, analogRead, analogWrite and its range and frequency controls, millis, micros, micros64, delay, delayMicroseconds, attachInterrupt, Serial, map, random. No Arduino core, no Espressif SDK, no libc.
Static constructors come out of .ctors here, not .init_array. Espressif's GCC 8.4 is configured without --enable-initfini-array and has no -fuse-init-array, so a global object's constructor lands in .ctors where every other toolchain in this repository emits .init_array. link.ld collects both into the one range crt0.S walks. Without that a sketch with a global object (WebServer server(80) in wifilamp) would link, run, and find its members zero. The two are walked in one pass and .ctors is not reversed, because the order constructors run in across translation units is unspecified in C++ and a sketch must not depend on it.
LED_BUILTIN is 2: the module's LED, active low. FLASH_BUTTON is 0. D0 to D8, RX and TX are defined as the table in section 1, and A0 is 17, a pseudo-pin: it is not a GPIO, and analogRead of anything else returns 0 rather than a number this board could not have produced.
analogWrite takes 0 to 1023, not 0 to 255. That is what the Arduino ESP8266 core's PWMRANGE is, and copying it was the choice between surprising you in the browser and surprising you on the desk. analogWriteRange(255) makes an Uno sketch behave, and analogWriteResolution(8) is the same thing said the ESP32 way. It is software PWM (section 3.7): eight pins at once, 1 kHz by default, 40 Hz to 60 kHz.
analogRead is ten bits and analogReadResolution shifts what it returns rather than changing the converter, the way the Arduino ESP32 core's does.
millis() and micros() come from CCOUNT extended to sixty-four bits by the tick handler (section 3.6). micros() wraps every 71.6 minutes and millis() every 49.7 days, like Arduino; micros64() does not wrap. delay() parks the core on CCOMPARE0 rather than spinning. One comparator has two customers (the next delay deadline and the next PWM edge), so the scheduler works out which comes first, arms that, and the handler services whatever was due and schedules again.
attachInterrupt is two writes rather than three, because there is no interrupt matrix (section 4). GPIO16 cannot raise one.
The shipped examples are built by Espressif's GCC, and a user's sketch by clang-v3 in the browser, for the reasons docs/esp32.md section 7 gives. deploy/fetch-xtensa.sh puts xtensa-lx106-elf-gcc 8.4.0 in tools/xtensa/, which is gitignored: GCC is GPL and nothing it builds here links against libgcc, so no GPL code reaches a shipped ELF.
Three of this runtime's files are the ESP32's, compiled again for this board rather than copied: the number formatting, the math helpers and the freestanding floor, plus Print and main. This board's own are crt0.S, the register header, the linker script, and the GPIO, ADC, PWM, time, UART and interrupt drivers. support32.c is here because the LX106 has no hardware divide: the 32-bit division and modulo helpers the compiler calls are ours, so that nothing links against libgcc.
Linked against firmware/esp8266/link.ld: .text in the 32 KB of IRAM, everything else in the 80 KB of DRAM, stack at the top. A NodeMCU has 4 MB of flash and an example should be a small part of it, so the build refuses an image over its budget; the real ceiling is the 32 KB of instruction RAM, and it is the tightest of any board here.
Five examples, each with a sketch the UI shows: blink (the module's LED on D4 and a wired one on D1, always opposite because one is active low), button (a switch to ground on D2 with the pad's own pull-up), serial (a tick a second, and an echo), fade (an LED on D1 on the software PWM, 0 to 1023) and knob (the pot on A0, ten bits, straight into the PWM duty because the two ranges are the same number).
crates/esp8266/tests/runtime.rs boots those five ELFs and measures: blink's half period, the software PWM's 1 kHz within a handler's worth of jitter, millis() against the kernel's clock, and UART0's byte pacing at 115200 baud. Each test skips itself when the images are not built.