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>
15 KiB
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 ispinMode(0x4021ae84); the one other byte-offset match (0x4024cb5e) is in the middle of an unrelated instruction. - No
pinMode(4|5, …),digitalWrite(4|5, …)ordigitalRead(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)at0x402086f7on the UART0 object0x3ffeee78(constructed with uart_nr 0 at0x40217fd4), thendfplayer.begin(Serial, true, true)(0x40208712) andvolume(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/0x60000790only 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 withpinMode(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).