Files
nixie-clock/docs/stock-firmware-pins.md
T
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

277 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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) - "<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
```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 "<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).