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:
mangavalkandClaude Opus 5.5 committed 2026-10-25 20:42:56 +01:00
1 parent 9ffcfb95d2
commit 8efa810e4e
4 files changed
+598 -15

No files matched your search

+22 -15
View File
@@ -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
+276
View File
@@ -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).
+102
View File
@@ -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
View File
@@ -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()