# Stock firmware SV1.3: GPIO use from disassembly Offline analysis of the app image in `firmware/stock/nixie_stock_SV1.3_full_4MB.bin` (0x1000 slot only; SPIFFS was not read). The binary has no symbols, so the Arduino core functions were identified by the registers they touch and matched against core 2.x source. Bottom line: the 16-bit shift-register word, the WS2812 pin, the latch pin and the light sensor all match the 12 V v2 schematic exactly. **The firmware never uses I2C**, so it does not talk to a DS3231 at all; time comes from NTP only. It also drives a DFPlayer Mini on UART0 and reads GPIO0/GPIO2 as inputs. ## Pin summary | Function | GPIO | How it is driven | Confidence | |---|---|---|---| | 74HC595 DATA | 13 | Hardware HSPI MOSI (`SPI.begin()`, pin set to SPECIAL) | High | | 74HC595 CLOCK | 14 | Hardware HSPI SCK (`SPI.begin()`) | High | | 74HC595 LATCH | 15 | `pinMode(15, OUTPUT)`, `digitalWrite(15, 0/1)` around each 2-byte transfer | High | | WS2812 data | 12 | Adafruit_NeoPixel bit-bang, `Adafruit_NeoPixel(12, 12, NEO_GRB+NEO_KHZ800)` | High | | Light sensor | A0 (ADC) | `analogRead(17)` → `system_adc_read()` | High | | GPIO0 (BTN1 pad) | 0 | `pinMode(0, INPUT_PULLUP)`, polled with `digitalRead(0)`; LOW triggers an action | High (pin), medium (meaning) | | GPIO2 (BTN2 pad) | 2 | `pinMode(2, INPUT_PULLUP)`, `digitalRead(2)` when a feature flag is set; a change resets the "Time to sleep" timer and wakes the display | High (pin), medium (meaning) | | DFPlayer Mini | 1 (TX), 3 (RX) | `Serial.begin(9600, SERIAL_8N1)` on UART0, DFRobotDFPlayerMini frames | High | | DS3231 SDA / SCL | (4 / 5) | **Not used**: no I2C code in the image | High | | GPIO16 | – | Not used by the app (only the core's generic pin-16 branches) | High | | SPI MISO | 12 | Set to SPECIAL by `SPI.begin()`, then taken back as GPIO by the NeoPixel `begin()` | High | ## Core functions identified | Address | Function | How it was recognised | |---|---|---| | `0x40100b44` | `__digitalWrite(pin, val)` (IRAM) | `1< lit 0x60000304` etc. GPES/GPEC (the registers software I2C uses for open-drain SDA/SCL) are written only by `pinMode`. ## 1. Shift registers: hardware SPI + GPIO15 latch ### Setup (`setup()`, from `0x402085b0`) ``` 402085b0: addi a1, a1, -32 402085b3: movi.n a3, 1 ; OUTPUT 402085b5: movi.n a2, 15 402085bd: call0 0x4021ae84 ; pinMode(15, OUTPUT) 402085c0: l32r a12, =0x3ffeee30 ; SPI object 402085c5: call0 0x40216ea0 ; SPI.begin() 402085c8: l32r a3, =0x017d7840 ; 25 000 000 402085cd: call0 0x40216d18 ; SPI.setFrequency(25 MHz) 402085d0: movi.n a2, 15 ; movi.n a3, 0 402085d4: l32r a0, =0x40100b44 ; callx0 a0 ; digitalWrite(15, LOW) 402085de: call0 0x40216f44 ; SPI.transfer(0) 402085e5: call0 0x40216f44 ; SPI.transfer(0) 402085e8: movi.n a2, 15 ; movi.n a3, 1 402085ec: l32r a0, =0x40100b44 ; callx0 a0 ; digitalWrite(15, HIGH) 402085f2: movi.n a2, 0 ; movi.n a3, 2 402085f6: call0 0x4021ae84 ; pinMode(0, INPUT_PULLUP) 402085f9: movi.n a2, 2 ; mov.n a3, a2 402085fd: call0 0x4021ae84 ; pinMode(2, INPUT_PULLUP) ``` `SPI.begin()` (`0x40216ee2`): ``` 40216ee2: movi.n a2, 14 ; movi a3, 248 ; call0 0x4021ae84 ; pinMode(14, SPECIAL) SCK 40216eea: movi a2, 12 ; movi a3, 248 ; call0 0x4021ae84 ; pinMode(12, SPECIAL) MISO 40216ef3: movi.n a2, 13 ; movi a3, 248 ; call0 0x4021ae84 ; pinMode(13, SPECIAL) MOSI 40216efb: l32r a2, =0x60000108 ; s32i.n a13(=0), a2, 0 ; SPI1C = 0 -> MSB first ``` `SPI1C` has no other writer, so `setBitOrder(LSBFIRST)` is never called: **MSB first**. The hardware chip select (`setHwCs`, `pinMode(15, SPECIAL)`) is only reached in the `HSPI_OVERLAP` branch, which this firmware does not use. GPIO15 stays a plain output. ### Frame: 2 bytes per tube, high byte first Each update is `digitalWrite(15, LOW); SPI.transfer(hi); SPI.transfer(lo); digitalWrite(15, HIGH)`. The simpler of the two multiplex routines, at `0x402010f8`: ``` 4020110c: l32r a13, =0x3ffee828 ; current tube slot (0..3) 4020110f: l32r a3, =0x3ffee87e ; hi[] per slot 40201118: l8ui a12, a3[slot] 4020111b: l32r a3, =0x3ffe8527 ; P1 enable (default 1) 40201121: beqz a3, ... ; bnei a2(slot), 1, ... 40201127: movi a3, 4 ; or a12, a12, a3 ; hi |= 0x04 (P1) only in slot 1 4020112d: l32r a3, =0x3ffe854f ; P2 slot (default 4 = never) 40201133: bne a3, a2(slot), ... 40201136: movi a2, 128 ; or a12, a12, a2 ; hi |= 0x80 (P2) 4020113c: digitalWrite(15, LOW) 4020114d: call0 0x40216f44 ; SPI.transfer(hi) 40201153: l32r a3, =0x3ffee87a ; lo[] per slot 4020115f: call0 0x40216f44 ; SPI.transfer(lo) 40201162: digitalWrite(15, HIGH) 40201170: slot = (slot + 1) & 3 ``` There are two multiplex routines, chosen in `setup()` by the flag at `0x3ffee9a5`: when it is set, `0x402010f8` is registered as a callback (`0x40208681`, most likely a Ticker); when it is clear, the IRAM timer1 ISR at `0x40100208` is attached (`0x402086d4`). The ISR also does the digit crossfade and sends the same sequence at `0x4010055c..0x401005ad`. ### Encoding (`0x40207800`) ``` 40207811: l32r a11, =0x3ffe8810 ; T_hi[10] 40207814: l32r a12, =0x3ffee882 ; digit[4] 40207817: l32r a10, =0x3ffee87a ; lo[4] 4020781a: l32r a9, =0x3ffe8806 ; T_lo[10] 4020781d: l32r a8, =0x3ffee87e ; hi[4] movi.n a7, 6 ; movi.n a6, 1 loop i=0..3: d = digit[i] lo[i] = T_lo[d] hi[i] = T_hi[d] | (1 << (6 - i)) ; sub a4,a7,a2 ; ssl a4 ; sll a4,a6 ; or ``` Tables in `.data`: ``` T_lo (0x3ffe8806) = 01 02 04 08 10 20 40 80 00 00 ; digits 0..7 -> lo bit 0..7 T_hi (0x3ffe8810) = 00 00 00 00 00 00 00 00 01 02 ; digits 8, 9 -> hi bit 0, 1 ``` Digit slots (`0x40207694` onwards, `__umodsi3`=`0x4000e268`, `__udivsi3`=`0x4000e21c`): `digit[0] = min % 10`, `digit[1] = min / 10`, `digit[2] = hour % 10`, `digit[3] = hour / 10`. So slot 3 is the hour-tens tube and slot 0 the minute-ones tube. ### Resulting 16-bit word With two cascaded 74HC595s, the first byte shifted (MSB first) ends up in U2 and the second in U1, with byte bit *k* on output Q*k* (QA = bit 0). Written as one 16-bit word (hi byte = bits 15..8): | Bit | 595 output | Function (firmware) | Schematic (CLAUDE.md) | |---|---|---|---| | 0..7 | U1 QA..QH | cathode for digit 0..7 | cathodes 0..7 ✔ | | 8 | U2 QA | cathode for digit 8 | cathode 8 ✔ | | 9 | U2 QB | cathode for digit 9 | cathode 9 ✔ | | 10 | U2 QC | neon dot P1 (only during slot 1) | P1 ✔ | | 11 | U2 QD | anode, slot 3 = hour tens (leftmost) | A1 ✔ | | 12 | U2 QE | anode, slot 2 = hour ones | A2 ✔ | | 13 | U2 QF | anode, slot 1 = minute tens | A3 ✔ | | 14 | U2 QG | anode, slot 0 = minute ones (rightmost) | A4 ✔ | | 15 | U2 QH | P2 / decimal point, only in the slot stored at `0x3ffe854f` (default 4 = off) | P2 ✔ | All bits are active-high and one anode is on at a time, as in the schematic. New fact: **A1 is the leftmost tube (hour tens) and A4 the rightmost**. This follows from the slot order above, assuming the stock clock shows HH:MM left to right (it does). Blank = `0x0000` (`setup()` and the sleep code at `0x40207873` both send two zero bytes). ## 2. WS2812: GPIO12, 12 pixels, GRB 800 kHz Static constructor (`0x4020ccae`): ``` 4020ccae: movi.n a3, 12 ; n = 12 pixels 4020ccb0: l32r a2, =0x3ffee8b4 ; strip object 4020ccb3: or a4, a3, a3 ; pin = 12 4020ccb6: movi a5, 82 ; 0x52 = NEO_GRB + NEO_KHZ800 4020ccb9: call0 0x40217188 ; Adafruit_NeoPixel(12, 12, NEO_GRB + NEO_KHZ800) ``` `setPin` (`0x40217138`) stores the pin at `this+6` and, once begun, calls `pinMode(pin, OUTPUT)` and `digitalWrite(pin, LOW)`. `begin()` runs in `setup()` (`0x4020861d`) after `SPI.begin()`, so GPIO12 ends up as a GPIO output, not HSPI MISO. `setBrightness(50)` is called at `0x40208629`. Output is the bit-bang loop at `0x4010066c` (GPOS/GPOC, ccount timing), not UART or DMA. The firmware is configured for **12 pixels**; the board has 8 (CLAUDE.md). The LED loops also run to 12 (`bnei a13, 12` at `0x40201cbc`). The extra 4 pixels' data is shifted out past the last LED and has no effect. ## 3. I2C / DS3231: not used - No I2C library in the image: no strings ("Wire", "RTC", "DS3231", "twi"), no `twi_init`. - Software I2C on this core drives SDA/SCL with GPES/GPEC (`0x60000310`/`0x60000314`). The only code that writes those is `pinMode` (`0x4021ae84`); the one other byte-offset match (`0x4024cb5e`) is in the middle of an unrelated instruction. - No `pinMode(4|5, …)`, `digitalWrite(4|5, …)` or `digitalRead(4|5)` call exists (all call sites of the three functions were enumerated; see below). So the stock firmware keeps time with NTP only. Whether a DS3231M is fitted on this 5 V board is unknown; if it is, it sits unused. ESP_UniClock can still try SDA=4/SCL=5 from the schematic. ## 4. Light sensor: A0 ``` 4020cab5: movi a2, 17 4020cab8: call0 0x4021ae30 ; analogRead(A0) - auto-brightness 4020b151: movi.n a2, 17 4020b153: call0 0x4021ae30 ; analogRead(A0) - "

Light: " on the brightness page ``` `analogRead` (`0x4021ae30`) calls `system_adc_read()` (`0x4024f270`) for pin 0 or 17. ## 5. Other GPIO use All call sites of `digitalWrite`, `digitalRead` and `pinMode` with their pin arguments: | Call site | Call | Context | |---|---|---| | `0x402085bd` | `pinMode(15, OUTPUT)` | setup | | `0x402085f6` | `pinMode(0, INPUT_PULLUP)` | setup | | `0x402085fd` | `pinMode(2, INPUT_PULLUP)` | setup | | `0x40216ee7/ef0/ef8` | `pinMode(14/12/13, SPECIAL)` | `SPI.begin()` | | `0x40216c2d/c3e` | `pinMode(15, SPECIAL/…)` | `SPI.setHwCs()`, not reached | | `0x40216fd2/ffa/715d/716f` | `pinMode(strip.pin, …)` | NeoPixel (pin 12) | | `0x4021b1f7` | `pinMode(0..16 except 6..11, INPUT)` | core `resetPins()` | | `0x4021c812..0x4021cac3` | UART pin setup | core `uart_set_pins` / `uart_init` | | `0x40100562/58e/5ad`, `0x40201140/167/1b9/1d4`, `0x40207875/890`, `0x402085d4/5ec`, `0x4020ca2f/a4b` | `digitalWrite(15, …)` | latch | | `0x40217176` | `digitalWrite(strip.pin, LOW)` | NeoPixel `setPin` | | `0x40218b13` | `digitalWrite(_ledPin, …)` if `_ledPin != -1` | core `Updater` (no LED set) | | `0x4020c2dd` | `digitalRead(0)` | LOW → DFPlayer call at `0x4021767c` and sets flag `0x3ffee9a9` | | `0x4020ca7c` | `digitalRead(2)` | only when flag `0x3ffee999` is set; on change reloads the sleep counter `0x3ffe8554` and clears "display off" `0x3ffee998` | - **GPIO0 / GPIO2**: read as active-low inputs with pull-ups. The pads are not fitted on this board. GPIO2 looks like the input for a presence/motion sensor: the display blanks itself after the "Time to sleep" (`st`) count (`0x40207852..0x40207893`), and a level change on GPIO2 resets that count. GPIO0 is a button-style input tied to the DFPlayer (probably "stop alarm"). Both need pull-ups at boot anyway (boot strapping). - **DFPlayer Mini on UART0**: `Serial.begin(9600, SERIAL_8N1, SERIAL_FULL, tx_pin=1)` at `0x402086f7` on the UART0 object `0x3ffeee78` (constructed with uart_nr 0 at `0x40217fd4`), then `dfplayer.begin(Serial, true, true)` (`0x40208712`) and `volume(13)` (`0x40208719`). So GPIO1 = TX to the DFPlayer RX and GPIO3 = RX. `Serial1` (UART1, GPIO2 TX) is constructed but never started. The programming header uses the same pins. - **GPIO16**: no call with pin 16; `0x60000768`/`0x6000078c`/`0x60000790` only appear in the pin-16 branches of the core functions. - **GPIO4, GPIO5**: unused (see section 3). ## Differences from the 12 V schematic | Item | Schematic | Stock firmware | |---|---|---| | RTC on GPIO4/5 | DS3231M on I2C | No I2C code at all; NTP only | | WS2812 count | 8 on this board | Firmware assumes 12 | | Extra peripherals | – | DFPlayer Mini on UART0 (GPIO1/3, 9600 Bd); GPIO2 used as motion/presence input | | Tube order | unknown | A1 = leftmost (hour tens) … A4 = rightmost (minute ones) | Everything else (DATA/CLOCK/LATCH = 13/14/15, WS2812 = 12, LDR = A0, buttons = 0/2, and the 16-bit map) matches. ## Notes for ESP_UniClock - The display can use hardware SPI: MOSI 13, SCK 14, latch on GPIO15 by `digitalWrite`, MSB first, high byte (anodes/dots/8/9) first. Stock uses 25 MHz; a slower clock is safer. - GPIO15 must stay low at boot (it has the 1 k pull-down), so keep it a plain output. - After `SPI.begin()`, reclaim GPIO12 for the WS2812 with `pinMode(12, OUTPUT)`. - Stock shows the dots only during one multiplex slot. Keeping a dot bit set in every slot will make it brighter. ## Reproduce ```sh D=firmware/stock/nixie_stock_SV1.3_full_4MB.bin python tools/esp8266_image_to_elf.py $D /tmp/app.elf # ELF of the app image for objdump python tools/xref.py $D lit 0x60000304 # GPOS users -> digitalWrite, espShow python tools/xref.py $D calls 0x4021ae84 # all pinMode call sites python tools/xref.py $D ctx 0x402085bd,0x402085f6 20 4 # aligned disassembly around call sites python tools/xref.py $D dis 0x40207800 0x40207896 # digit encoder python tools/xref.py $D str "

Light: " # string xrefs ``` Tools: `xtensa-lx106-elf-objdump` from the PlatformIO `toolchain-xtensa` package (already installed) and Python 3 standard library. `xref.py` finds `l32r`/`call0` references by decoding every byte offset, so a few hits are false positives in the middle of other instructions (noted above where they came up).