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>
This commit is contained in:
1 parent
9ffcfb95d2
commit
8efa810e4e
4 files changed
+598
-15
No files matched your search
@@ -31,24 +31,31 @@ with ArduinoOTA and Home Assistant (MQTT) support.
|
|||||||
- No pin numbers are visible as strings; confirming the pinout needs disassembly or the
|
- No pin numbers are visible as strings; confirming the pinout needs disassembly or the
|
||||||
bit-stepping test sketch.
|
bit-stepping test sketch.
|
||||||
|
|
||||||
## Pinout (from the 12 V v2 schematic, `docs/schematic_v2_12V.jpg`)
|
## Pinout
|
||||||
|
|
||||||
Not yet verified on this 5 V board. The stock firmware image is the same for both, which
|
From the 12 V v2 schematic (`docs/schematic_v2_12V.jpg`), checked against the stock firmware
|
||||||
suggests the GPIOs match.
|
by disassembly (`docs/stock-firmware-pins.md`, tools in `tools/`). "Binary" = confirmed in
|
||||||
|
the stock SV1.3 code; it has not yet been tested on the hardware.
|
||||||
|
|
||||||
| Function | GPIO |
|
| Function | GPIO | Source |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| 74HC595 DATA / CLOCK / LATCH (OE tied to GND) | 13 / 14 / 15 (15 has 1k pull-down) |
|
| 74HC595 DATA / CLOCK (OE tied to GND) | 13 / 14 = HSPI MOSI / SCK (hardware SPI, MSB first) | Binary |
|
||||||
| DS3231M RTC SDA / SCL | 4 / 5 |
|
| 74HC595 LATCH | 15, plain `digitalWrite` (has 1k pull-down) | Binary |
|
||||||
| WS2812 data | 12 |
|
| WS2812 data | 12 (NeoPixel bit-bang, GRB 800 kHz; stock assumes 12 LEDs, 8 fitted) | Binary |
|
||||||
| Photoresistor (LDR, 10k to GND) | ADC (A0) |
|
| Photoresistor (LDR, 10k to GND) | ADC (A0) | Binary |
|
||||||
| BTN1 / BTN2 (not fitted) | 0 / 2 |
|
| BTN1 / BTN2 (not fitted) | 0 / 2, INPUT_PULLUP. Stock: GPIO0 is a button, GPIO2 a motion/presence input that wakes the display | Binary |
|
||||||
|
| DFPlayer Mini (stock feature) | UART0: GPIO1 TX / GPIO3 RX, 9600 Bd | Binary |
|
||||||
|
| DS3231M RTC SDA / SCL | 4 / 5. **Stock firmware has no I2C code** (NTP only); if fitted, unknown | Schematic only |
|
||||||
|
|
||||||
Two chained 74HC595s make one 16-bit word, all bits active-high:
|
Two chained 74HC595s make one 16-bit word, all bits active-high (confirmed from the binary;
|
||||||
- U1 QA..QH = cathodes 0..7, U2 QA = cathode 8, QB = cathode 9 (via 2x ULN2003, shared by all tubes)
|
the high byte is sent first and goes to U2):
|
||||||
- U2 QC = P1 (neon dot 1), QD..QG = anodes A1..A4 (MMBTA42/92), QH = P2 (neon dot 2, and the decimal
|
- U1 QA..QH = cathodes 0..7 (bits 0..7), U2 QA = cathode 8 (bit 8), QB = cathode 9 (bit 9)
|
||||||
point if jumper JP2 is closed)
|
(via 2x ULN2003, shared by all tubes)
|
||||||
- The display is multiplexed: one anode on at a time. Which physical tube is A1 is unknown.
|
- U2 QC = P1 (neon dot 1, bit 10), QD..QG = anodes A1..A4 (bits 11..14, MMBTA42/92), QH = P2
|
||||||
|
(bit 15; neon dot 2, and the decimal point if jumper JP2 is closed)
|
||||||
|
- The display is multiplexed: one anode on at a time. **A1 = leftmost tube (hour tens),
|
||||||
|
A4 = rightmost (minute ones)**, from the stock digit order. Stock lights P1/P2 in only one
|
||||||
|
multiplex slot.
|
||||||
|
|
||||||
## Programming header
|
## Programming header
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Wrap the segments of an ESP8266 (v1) app image in a minimal ELF so binutils can read it.
|
||||||
|
|
||||||
|
Reads only the app image (the 0x1000 slot of a full flash dump), never SPIFFS.
|
||||||
|
|
||||||
|
python tools/esp8266_image_to_elf.py <full_dump.bin> <out.elf> [--offset 0x1000]
|
||||||
|
|
||||||
|
Each image segment becomes one section (.irom0, .iram0_N, .dram0_N) at its load address,
|
||||||
|
so `xtensa-lx106-elf-objdump -d out.elf` disassembles with real addresses.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import struct
|
||||||
|
|
||||||
|
APP_SLOT_MAX = 0x100000 # never read past the 1 MB app slot (SPIFFS starts at 0x200000)
|
||||||
|
|
||||||
|
|
||||||
|
def read_segments(image):
|
||||||
|
magic, nseg, _mode, _sizefreq, entry = struct.unpack_from("<BBBBI", image, 0)
|
||||||
|
if magic != 0xE9:
|
||||||
|
raise SystemExit(f"bad image magic 0x{magic:02x}")
|
||||||
|
off = 8
|
||||||
|
segs = []
|
||||||
|
for _ in range(nseg):
|
||||||
|
addr, size = struct.unpack_from("<II", image, off)
|
||||||
|
off += 8
|
||||||
|
segs.append((addr, image[off:off + size]))
|
||||||
|
off += size
|
||||||
|
return entry, segs
|
||||||
|
|
||||||
|
|
||||||
|
def section_name(addr, counters):
|
||||||
|
if 0x40200000 <= addr < 0x40300000:
|
||||||
|
kind = "irom0"
|
||||||
|
elif 0x40100000 <= addr < 0x40108000:
|
||||||
|
kind = "iram0"
|
||||||
|
else:
|
||||||
|
kind = "dram0"
|
||||||
|
counters[kind] = counters.get(kind, 0) + 1
|
||||||
|
return f".{kind}_{counters[kind] - 1}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_elf(entry, segs):
|
||||||
|
counters = {}
|
||||||
|
names = [section_name(a, counters) for a, _ in segs]
|
||||||
|
shstrtab = b"\0"
|
||||||
|
name_off = []
|
||||||
|
for n in names + [".shstrtab"]:
|
||||||
|
name_off.append(len(shstrtab))
|
||||||
|
shstrtab += n.encode() + b"\0"
|
||||||
|
|
||||||
|
ehsize, phentsize, shentsize = 52, 32, 40
|
||||||
|
nseg = len(segs)
|
||||||
|
data_off = ehsize + phentsize * nseg
|
||||||
|
body = b""
|
||||||
|
offsets = []
|
||||||
|
for _, data in segs:
|
||||||
|
while (data_off + len(body)) % 4:
|
||||||
|
body += b"\0"
|
||||||
|
offsets.append(data_off + len(body))
|
||||||
|
body += data
|
||||||
|
shstr_off = data_off + len(body)
|
||||||
|
body += shstrtab
|
||||||
|
while (data_off + len(body)) % 4:
|
||||||
|
body += b"\0"
|
||||||
|
shoff = data_off + len(body)
|
||||||
|
shnum = nseg + 2
|
||||||
|
|
||||||
|
out = bytearray()
|
||||||
|
out += b"\x7fELF" + bytes([1, 1, 1]) + bytes(9)
|
||||||
|
out += struct.pack("<HHIIIIIHHHHHH", 2, 94, 1, entry, ehsize, shoff, 0x300,
|
||||||
|
ehsize, phentsize, nseg, shentsize, shnum, shnum - 1)
|
||||||
|
for (addr, data), o in zip(segs, offsets):
|
||||||
|
flags = 5 if addr >= 0x40000000 else 6 # R+X for code, R+W for data
|
||||||
|
out += struct.pack("<IIIIIIII", 1, o, addr, addr, len(data), len(data), flags, 4)
|
||||||
|
out += body
|
||||||
|
out += bytes(shentsize) # null section
|
||||||
|
for i, ((addr, data), o) in enumerate(zip(segs, offsets)):
|
||||||
|
code = addr >= 0x40000000
|
||||||
|
sh_flags = 0x2 | (0x4 if code else 0x1) # ALLOC + EXECINSTR / WRITE
|
||||||
|
out += struct.pack("<IIIIIIIIII", name_off[i], 1, sh_flags, addr, o, len(data), 0, 0, 4, 0)
|
||||||
|
out += struct.pack("<IIIIIIIIII", name_off[-1], 3, 0, 0, shstr_off, len(shstrtab), 0, 0, 1, 0)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("dump")
|
||||||
|
ap.add_argument("out")
|
||||||
|
ap.add_argument("--offset", type=lambda s: int(s, 0), default=0x1000)
|
||||||
|
args = ap.parse_args()
|
||||||
|
with open(args.dump, "rb") as f:
|
||||||
|
f.seek(args.offset)
|
||||||
|
image = f.read(APP_SLOT_MAX - args.offset)
|
||||||
|
entry, segs = read_segments(image)
|
||||||
|
with open(args.out, "wb") as f:
|
||||||
|
f.write(build_elf(entry, segs))
|
||||||
|
for addr, data in segs:
|
||||||
|
print(f"0x{addr:08x} 0x{len(data):06x}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Cross-reference helper for the ESP8266 stock firmware (Xtensa LX106, call0 ABI).
|
||||||
|
|
||||||
|
Linear disassembly of the image goes out of sync on inline literal pools, so xrefs are
|
||||||
|
found by brute force: every byte offset is decoded as l32r / call0 and kept if it hits
|
||||||
|
the target. Results are then shown with objdump from a given start address, with each
|
||||||
|
l32r annotated with the literal value it loads.
|
||||||
|
|
||||||
|
python tools/xref.py <dump.bin> lit 0x60000304 # literal slots holding a value + their l32r users
|
||||||
|
python tools/xref.py <dump.bin> calls 0x40100abc # call0 sites + l32r/callx users of a function
|
||||||
|
python tools/xref.py <dump.bin> str "Light" # string address(es) + l32r users
|
||||||
|
python tools/xref.py <dump.bin> dis 0x40100abc 0x40100b20
|
||||||
|
|
||||||
|
Only the app image (0x1000 slot) is read. The objdump path defaults to the PlatformIO
|
||||||
|
toolchain; override with $OBJDUMP.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from esp8266_image_to_elf import APP_SLOT_MAX, build_elf, read_segments # noqa: E402
|
||||||
|
|
||||||
|
OBJDUMP = os.environ.get("OBJDUMP") or os.path.expanduser(
|
||||||
|
"~/.platformio/packages/toolchain-xtensa/bin/xtensa-lx106-elf-objdump")
|
||||||
|
|
||||||
|
|
||||||
|
class Image:
|
||||||
|
def __init__(self, dump, offset=0x1000):
|
||||||
|
with open(dump, "rb") as f:
|
||||||
|
f.seek(offset)
|
||||||
|
raw = f.read(APP_SLOT_MAX - offset)
|
||||||
|
self.entry, self.segs = read_segments(raw)
|
||||||
|
self._elf = None
|
||||||
|
|
||||||
|
def code_segs(self):
|
||||||
|
return [(a, d) for a, d in self.segs if a >= 0x40000000]
|
||||||
|
|
||||||
|
def seg_of(self, addr):
|
||||||
|
for a, d in self.segs:
|
||||||
|
if a <= addr < a + len(d):
|
||||||
|
return a, d
|
||||||
|
return None
|
||||||
|
|
||||||
|
def word(self, addr):
|
||||||
|
s = self.seg_of(addr)
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
a, d = s
|
||||||
|
o = addr - a
|
||||||
|
return struct.unpack_from("<I", d, o)[0] if o + 4 <= len(d) else None
|
||||||
|
|
||||||
|
def literals(self, value):
|
||||||
|
"""Aligned words equal to value (literal pool slots)."""
|
||||||
|
out = []
|
||||||
|
pat = struct.pack("<I", value)
|
||||||
|
for a, d in self.segs:
|
||||||
|
i = d.find(pat)
|
||||||
|
while i >= 0:
|
||||||
|
if (a + i) % 4 == 0:
|
||||||
|
out.append(a + i)
|
||||||
|
i = d.find(pat, i + 1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def l32r_users(self, lit_addr):
|
||||||
|
"""(pc, reg) of every byte offset that decodes as l32r loading lit_addr."""
|
||||||
|
out = []
|
||||||
|
for a, d in self.code_segs():
|
||||||
|
for i in range(len(d) - 2):
|
||||||
|
b0 = d[i]
|
||||||
|
if b0 & 0xF != 1:
|
||||||
|
continue
|
||||||
|
imm16 = d[i + 1] | (d[i + 2] << 8)
|
||||||
|
pc = a + i
|
||||||
|
tgt = (((pc + 3) & ~3) + ((imm16 | ~0xFFFF) << 2)) & 0xFFFFFFFF
|
||||||
|
if tgt == lit_addr:
|
||||||
|
out.append((pc, b0 >> 4))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def call0_sites(self, func):
|
||||||
|
"""Byte offsets that decode as call0 to func (CALL format: op0=5, n=0)."""
|
||||||
|
out = []
|
||||||
|
for a, d in self.code_segs():
|
||||||
|
for i in range(len(d) - 2):
|
||||||
|
v = d[i] | (d[i + 1] << 8) | (d[i + 2] << 16)
|
||||||
|
if v & 0x3F != 0x05:
|
||||||
|
continue
|
||||||
|
off = v >> 6
|
||||||
|
if off & 0x20000:
|
||||||
|
off -= 0x40000
|
||||||
|
pc = a + i
|
||||||
|
if ((pc & ~3) + (off << 2) + 4) & 0xFFFFFFFF == func:
|
||||||
|
out.append(pc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def elf(self):
|
||||||
|
if not self._elf:
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".elf")
|
||||||
|
with os.fdopen(fd, "wb") as f:
|
||||||
|
f.write(build_elf(self.entry, self.segs))
|
||||||
|
self._elf = path
|
||||||
|
return self._elf
|
||||||
|
|
||||||
|
def dis(self, start, stop):
|
||||||
|
out = subprocess.run([OBJDUMP, "-d", f"--start-address={start}",
|
||||||
|
f"--stop-address={stop}", self.elf()],
|
||||||
|
capture_output=True, text=True).stdout
|
||||||
|
lines = []
|
||||||
|
for ln in out.splitlines():
|
||||||
|
m = re.match(r"\s*([0-9a-f]+):\s", ln)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
m2 = re.search(r"l32r\s+a\d+, 0x([0-9a-f]+)", ln)
|
||||||
|
if m2:
|
||||||
|
w = self.word(int(m2.group(1), 16))
|
||||||
|
if w is not None:
|
||||||
|
ln += f" ; ={w:#010x}{self.describe(w)}"
|
||||||
|
lines.append(ln)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def ctx(self, addr, before=24, after=12):
|
||||||
|
"""Disassembly around addr, starting at an offset that decodes in step with addr."""
|
||||||
|
tag = f"{addr:x}:"
|
||||||
|
for back in range(before, before + 16):
|
||||||
|
text = self.dis(addr - back, addr + after)
|
||||||
|
lines = text.splitlines()
|
||||||
|
if any(ln.strip().startswith(tag) for ln in lines):
|
||||||
|
return "\n".join((">" if ln.strip().startswith(tag) else " ") + ln for ln in lines)
|
||||||
|
return self.dis(addr - before, addr + after)
|
||||||
|
|
||||||
|
def describe(self, w):
|
||||||
|
"""Short hint for literal values: a printable string or a known register."""
|
||||||
|
regs = {0x60000300: "GPO", 0x60000304: "GPOS", 0x60000308: "GPOC",
|
||||||
|
0x6000030c: "GPE", 0x60000310: "GPES", 0x60000314: "GPEC",
|
||||||
|
0x60000318: "GPI", 0x6000031c: "GPIE", 0x60000320: "GPIES",
|
||||||
|
0x60000324: "GPIEC", 0x60000800: "IOMUX", 0x60000768: "GP16O",
|
||||||
|
0x60000774: "GP16E", 0x6000078c: "GP16I", 0x60000790: "GPF16",
|
||||||
|
0x60000100: "SPI1CMD", 0x60000140: "SPI1W0"}
|
||||||
|
if w in regs:
|
||||||
|
return f" {regs[w]}"
|
||||||
|
if 0x60000000 <= w < 0x60001000:
|
||||||
|
return " (periph)"
|
||||||
|
s = self.seg_of(w)
|
||||||
|
if s and w < 0x40300000:
|
||||||
|
a, d = s
|
||||||
|
o = w - a
|
||||||
|
end = d.find(b"\0", o, o + 64)
|
||||||
|
if end > o + 2:
|
||||||
|
t = d[o:end]
|
||||||
|
if all(32 <= c < 127 for c in t):
|
||||||
|
return f' "{t.decode()}"'
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
raise SystemExit(__doc__)
|
||||||
|
img = Image(sys.argv[1])
|
||||||
|
cmd, arg = sys.argv[2], sys.argv[3]
|
||||||
|
if cmd == "lit":
|
||||||
|
for lit in img.literals(int(arg, 0)):
|
||||||
|
users = ", ".join(f"{pc:#x}(a{r})" for pc, r in img.l32r_users(lit))
|
||||||
|
print(f"literal @ {lit:#x}: {users or '-'}")
|
||||||
|
elif cmd == "calls":
|
||||||
|
f = int(arg, 0)
|
||||||
|
for pc in img.call0_sites(f):
|
||||||
|
print(f"call0 @ {pc:#x}")
|
||||||
|
for lit in img.literals(f):
|
||||||
|
users = ", ".join(f"{pc:#x}(a{r})" for pc, r in img.l32r_users(lit))
|
||||||
|
print(f"literal @ {lit:#x}: {users or '-'}")
|
||||||
|
elif cmd == "str":
|
||||||
|
pat = arg.encode()
|
||||||
|
for a, d in img.segs:
|
||||||
|
i = d.find(pat)
|
||||||
|
while i >= 0:
|
||||||
|
sa = a + i
|
||||||
|
users = []
|
||||||
|
for lit in img.literals(sa):
|
||||||
|
users += [f"{pc:#x}(a{r})" for pc, r in img.l32r_users(lit)]
|
||||||
|
print(f"{sa:#x}: {', '.join(users) or '-'}")
|
||||||
|
i = d.find(pat, i + 1)
|
||||||
|
elif cmd == "dis":
|
||||||
|
print(img.dis(int(arg, 0), int(sys.argv[4], 0)))
|
||||||
|
elif cmd == "ctx":
|
||||||
|
before = int(sys.argv[4], 0) if len(sys.argv) > 4 else 24
|
||||||
|
after = int(sys.argv[5], 0) if len(sys.argv) > 5 else 12
|
||||||
|
for a in arg.split(","):
|
||||||
|
print(f"--- {a}")
|
||||||
|
print(img.ctx(int(a, 0), before, after))
|
||||||
|
else:
|
||||||
|
raise SystemExit(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user