Files
nixie-clock/docs/stock-firmware-pins.md
mangavalkandClaude Opus 5.5 8efa810e4e Map stock firmware GPIO use from disassembly
Disassembled the SV1.3 app image and confirmed the shift-register
(HSPI 13/14 + latch 15), WS2812 (12), LDR (A0) and button pins, the
16-bit bit map and the tube order. The stock firmware has no I2C code
and drives a DFPlayer on UART0. Adds the ELF/xref helper tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:42:56 +01:00

15 KiB
Raw Permalink Blame History

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<<pin to GPOS 0x60000304 / GPOC 0x60000308; pin 16 branch to 0x60000768
0x40100ba8 __digitalRead(pin) (IRAM) reads GPI 0x60000318; pin 16 reads 0x6000078c
0x4021ae84 __pinMode(pin, mode) GPC 0x60000328+4*pin (literal 0x180000ca<<2), GPF table esp8266_gpioToFn at 0x4025d194, GPES/GPEC, SPECIAL = 0xF8, GPIO16 via 0x60000790/0x60000774
0x4021ae30 analogRead(pin) pin 0 or 17 → system_adc_read() (0x4024f270), else digitalRead()*1023
0x4010066c Adafruit_NeoPixel espShow(pin, pixels, n) (IRAM) rsr.ccount loop with 32/64/99 cycles (800 kHz at 80 MHz) writing 1<<pin to GPOS/GPOC
0x40216ea0 SPIClass::begin() pinMode(14/12/13, SPECIAL), SPI1C(0x60000108)=0, SPI1U=0x08000041, SPI1U1=0x000e0700
0x40216d18 SPIClass::setFrequency() writes SPI1CLK 0x60000118, IOMUX bit 9 via 0x40216ccc
0x40216f44 SPIClass::transfer(uint8_t) SPI1 base 0x60000100 (SPI1CMD), called with the SPI object 0x3ffeee30
0x40217188 Adafruit_NeoPixel::Adafruit_NeoPixel(n, pin, type) calls updateType, updateLength, setPin (0x40217138, stores pin at this+6)
0x40217ec8 HardwareSerial::begin() only caller is the app's Serial.begin(9600)
0x402175d0 DFRobotDFPlayerMini::volume(v) sendStack(this, 0x06, v); default frame 7E FF 06 00 01 00 00 00 00 EF at 0x3ffe85b2

All register literals and their users can be listed with python tools/xref.py <dump> 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 Qk (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) - "<p>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

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 "<p>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).