skywalker-1/tools/i2c_recovery_boot.py
Ryan Malloy 0d6facb321 Add experimental I2C debugging and EEPROM analysis tools
One-off diagnostic scripts from experiments 0xD7-0xDB investigating
the I2C BERR deadlock. Documents the systematic elimination of
software-only recovery approaches:

- i2c_host_test.py: Proved 0xA0 register writes cannot drive I2C bus
- i2c_register_test.py: Tested I2C register writability from host
- i2c_recovery_boot.py: Attempted I2C state machine recovery via boot
- eeprom_flash_a0.py: Host-side EEPROM flash attempt (failed)
- boot_ab_test.py / boot_test.py: EEPROM boot reliability testing
- a8_autoclear_test.py: BCM4500 command register auto-clear behavior
- addr_gateway_test.py: BCM3440 gateway address routing analysis
- stock_fw_compare.py / stock_fw_test.py: Stock vs custom fw analysis
2026-02-20 10:57:10 -07:00

260 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Two-stage boot with I2C slave recovery.
The FX2LP I2C controller enters a stuck state (I2CS=0xF6) when the CPU
restarts after being halted mid-I2C-transaction. The stock firmware was
interrupted by fw_load.py's CPUCS halt while an I2C transfer was in
progress. The slave (BCM4500/BCM3440) is still driving SDA LOW, waiting
for clock pulses to finish its byte.
When CPUCS goes back to 0:
1. I2C pull-ups reconnect
2. Controller detects SDA LOW (slave still holding)
3. Controller enters permanent BERR state (0xF6)
4. All subsequent I2C operations fail
Fix: two-stage boot process:
Stage 1: Upload a tiny slave recovery stub, run it briefly to release
the stuck slave (32 SCL pulses @ ~10us + STOP), then halt again.
Stage 2: Upload the real firmware. The I2C bus is now clean, so when
CPUCS goes to 0, the controller initializes correctly.
"""
import sys
import time
import usb.core
import usb.util
I2CS_ADDR = 0xE678
I2CTL_ADDR = 0xE67A
CPUCS_ADDR = 0xE600
def fx2_read(dev, addr, n=1):
return dev.ctrl_transfer(0xC0, 0xA0, addr, 0, n, timeout=1000)
def fx2_write(dev, addr, data):
dev.ctrl_transfer(0x40, 0xA0, addr, 0, data, timeout=1000)
def i2cs_str(v):
flags = []
for bit, name in [(7,'START'),(6,'STOP'),(5,'LASTRD'),
(4,'ID1'),(3,'ID0'),(2,'BERR'),(1,'ACK'),(0,'DONE')]:
if v & (1 << bit): flags.append(name)
return f"0x{v:02X} ({' | '.join(flags) if flags else 'idle'})"
def build_recovery_stub():
"""Build an 8051 stub that holds BCM4500 in reset to release the I2C bus.
The I2C controller (I2CS=0xF6 BERR) has exclusive control of PA0 (SDA)
and PA1 (SCL). GPIO operations on those pins are OVERRIDDEN by the I2C
engine — bit-bang SCL clocking has NO EFFECT on the actual bus.
Strategy: assert BCM4500 RESET (PA5 LOW) and KEEP it asserted. The
BCM4500's I2C interface goes high-impedance, releasing SDA. Then when
the host halts and loads real firmware, CPUCS restart will find SDA=HIGH
and the I2C controller will initialize cleanly (no BERR).
The real firmware handles BCM_RESET de-assertion during its normal init.
CRITICAL: Do NOT modify OEA bits 0-1 — the I2C controller owns those.
Diagnostics in XDATA:
0x3C00: 0xAA = stub started
0x3C01: initial OEA
0x3C02: initial IOA
0x3C03: I2CS at boot (via XDATA 0xE678)
0x3C04: IOA after asserting BCM4500 reset (~50ms hold)
0x3C05: 0xDD = stub complete, looping with BCM4500 held in reset
"""
code = []
# 0x0000: LJMP main_start (to 0x0010)
code += [0x02, 0x00, 0x10] # LJMP 0x0010
# 0x0003-0x000F: padding
while len(code) < 0x10:
code += [0x00]
# ========== 0x0010: Main code ==========
# --- Marker: stub started (0xAA → 0x3C00) ---
code += [0x74, 0xAA] # MOV A, #0xAA
code += [0x90, 0x3C, 0x00] # MOV DPTR, #0x3C00
code += [0xF0] # MOVX @DPTR, A
# --- Capture initial OEA → 0x3C01 ---
code += [0xE5, 0xB2] # MOV A, OEA
code += [0x90, 0x3C, 0x01] # MOV DPTR, #0x3C01
code += [0xF0] # MOVX @DPTR, A
# --- Capture initial IOA → 0x3C02 ---
code += [0xE5, 0x80] # MOV A, IOA
code += [0x90, 0x3C, 0x02] # MOV DPTR, #0x3C02
code += [0xF0] # MOVX @DPTR, A
# --- Capture initial I2CS → 0x3C03 ---
code += [0x90, 0xE6, 0x78] # MOV DPTR, #0xE678
code += [0xE0] # MOVX A, @DPTR
code += [0x90, 0x3C, 0x03] # MOV DPTR, #0x3C03
code += [0xF0] # MOVX @DPTR, A
# --- Assert BCM4500 RESET: PA5 LOW, make output ---
# PA5 = IOA bit 5, bit address 0x85 (IOA base 0x80 + 5)
# ORL OEA with only bit 5 — do NOT touch bits 0-1 (I2C engine)
code += [0xC2, 0x85] # CLR PA5 (assert reset)
code += [0x43, 0xB2, 0x20] # ORL OEA, #0x20 (PA5 = output)
# Hold reset for ~50ms
# R2=250, R1=240: 250*240*3 = 180,000 cycles = ~3.75ms
# R0=15: 15 * 3.75ms = ~56ms
code += [0x78, 15] # MOV R0, #15
reset_outer = len(code)
code += [0x7A, 250] # MOV R2, #250
reset_mid = len(code)
code += [0x79, 240] # MOV R1, #240
code += [0xD9, 0xFE] # DJNZ R1, $-2
djnz2_pc = len(code) + 2
code += [0xDA, (reset_mid - djnz2_pc) & 0xFF] # DJNZ R2, reset_mid
djnz0_pc = len(code) + 2
code += [0xD8, (reset_outer - djnz0_pc) & 0xFF] # DJNZ R0, reset_outer
# --- Capture IOA during reset → 0x3C04 ---
code += [0xE5, 0x80] # MOV A, IOA
code += [0x90, 0x3C, 0x04] # MOV DPTR, #0x3C04
code += [0xF0] # MOVX @DPTR, A
# --- Marker: stub complete (0xDD → 0x3C05) ---
# BCM4500 stays in reset! Do NOT de-assert.
code += [0x74, 0xDD] # MOV A, #0xDD
code += [0x90, 0x3C, 0x05] # MOV DPTR, #0x3C05
code += [0xF0] # MOVX @DPTR, A
# Infinite loop — BCM4500 held in reset
code += [0x80, 0xFE] # SJMP $ (loop forever)
return bytes(code)
def main():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("SkyWalker-1 not found")
sys.exit(1)
print("Two-Stage Boot: I2C Slave Recovery (v2 — with markers)")
print("=" * 55)
# Stage 0: Initial state
print(f"\n[0] Device found: Bus {dev.bus} Addr {dev.address}")
# Stage 1: Halt CPU
print("\n[1] Halting CPU...")
fx2_write(dev, CPUCS_ADDR, bytes([0x01]))
time.sleep(0.05)
i2cs = fx2_read(dev, I2CS_ADDR, 1)[0]
print(f" I2CS during halt = {i2cs_str(i2cs)}")
# Clear XDATA diagnostic area first (so stale data doesn't confuse us)
print(" Clearing XDATA 0x3C00-0x3C0F...")
fx2_write(dev, 0x3C00, bytes([0x00] * 16))
# Stage 2: Upload stub
print("\n[2] Uploading slave recovery stub...")
stub = build_recovery_stub()
print(f" Stub size: {len(stub)} bytes")
fx2_write(dev, 0x0000, stub)
# Verify upload by reading back first 16 bytes
readback = fx2_read(dev, 0x0000, 16)
match = all(readback[i] == stub[i] for i in range(16))
print(f" Upload verify (first 16): {'MATCH' if match else 'MISMATCH'}")
if not match:
print(f" Expected: {' '.join(f'{b:02X}' for b in stub[:16])}")
print(f" Got: {' '.join(f'{b:02X}' for b in readback)}")
# Stage 3: Run stub (~50ms reset hold + ~100ms settle + margin)
print("\n[3] Running stub (500ms)...")
fx2_write(dev, CPUCS_ADDR, bytes([0x00]))
time.sleep(0.5)
# Stage 4: Halt and read diagnostics
print("\n[4] Halting CPU after recovery...")
fx2_write(dev, CPUCS_ADDR, bytes([0x01]))
time.sleep(0.05)
# Read all diagnostic bytes
diag = fx2_read(dev, 0x3C00, 8)
i2cs_halt = fx2_read(dev, I2CS_ADDR, 1)[0]
print(f"\n Raw XDATA 0x3C00-0x3C07:")
print(f" {' '.join(f'{b:02X}' for b in diag)}")
# Decode diagnostics
marker_start = diag[0] # 0x3C00: expect 0xAA
oea_init = diag[1] # 0x3C01: initial OEA
ioa_init = diag[2] # 0x3C02: initial IOA
i2cs_init = diag[3] # 0x3C03: initial I2CS
ioa_during_rst = diag[4] # 0x3C04: IOA during BCM4500 reset
marker_done = diag[5] # 0x3C05: expect 0xDD
def bus_str(ioa):
sda = 'H' if ioa & 1 else 'L'
scl = 'H' if ioa & 2 else 'L'
rst = 'H' if ioa & 0x20 else 'L'
return f"SDA={sda} SCL={scl} RST={rst}"
print(f"\n Markers:")
print(f" Start (0xAA?): 0x{marker_start:02X} {'' if marker_start == 0xAA else '✗ STUB DID NOT EXECUTE'}")
print(f" Done (0xDD?): 0x{marker_done:02X} {'' if marker_done == 0xDD else ''}")
if marker_start != 0xAA:
print(f"\n FATAL: Stub never started!")
else:
print(f"\n Initial state:")
print(f" OEA = 0x{oea_init:02X}")
print(f" IOA = 0x{ioa_init:02X} {bus_str(ioa_init)}")
print(f" I2CS = {i2cs_str(i2cs_init)}")
print(f"\n BCM4500 held in RESET (~50ms):")
print(f" IOA = 0x{ioa_during_rst:02X} {bus_str(ioa_during_rst)}")
sda_rst = 'H' if ioa_during_rst & 1 else 'L'
if sda_rst == 'H':
print(f" ✓ SDA HIGH with BCM4500 in reset")
else:
print(f" ✗ SDA still LOW — not the BCM4500 holding it")
print(f"\n I2CS during halt: {i2cs_str(i2cs_halt)}")
print(f" BCM4500 is held in RESET (PA5 driven LOW)")
# Stage 5: Now load real firmware with BCM4500 still in reset
# The real firmware's init sequence will de-assert BCM_RESET.
# Since SDA is HIGH (slave in reset), the I2C controller should
# initialize cleanly when CPUCS restarts for the real firmware.
print(f"\n[5] BCM4500 held in reset. Loading real firmware...")
print(f" The CPUCS restart for firmware load will re-init I2C controller.")
print(f" With BCM4500 in reset (SDA=HIGH), I2CS should come up clean.")
import subprocess
import os
fw_path = os.path.join(os.path.dirname(__file__), '..', 'firmware', 'build', 'skywalker1.ihx')
fw_path = os.path.abspath(fw_path)
fw_load = os.path.join(os.path.dirname(__file__), 'fw_load.py')
if os.path.exists(fw_path):
print(f"\n Running: python3 {fw_load} load {fw_path} --wait 5")
result = subprocess.run(
['python3', fw_load, 'load', fw_path, '--wait', '5'],
capture_output=True, text=True, timeout=30
)
print(result.stdout)
if result.stderr:
print(f" stderr: {result.stderr}")
else:
print(f"\n Firmware not found: {fw_path}")
print(f" Build with: cd firmware && make")
print(f" Then run: python3 tools/fw_load.py load {fw_path} --wait 5")
if __name__ == '__main__':
main()