8 - MR18 Deep Dive: Talking to Hardware
Contents
The boring infrastructure that makes everything else possible
I got my mind on my money and my money on my mind
Every previous post in this series focused on the dramatic stuff—cache coherency nightmares, hand-encoded MIPS assembly, twenty-minute UART transfers. But none of that works without the plumbing. The mr18_flash.py script has about 300 lines of infrastructure code that handles three things: talking to a bench power supply, talking to OpenOCD over telnet, and managing the OpenOCD process itself. It’s not glamorous. It’s the kind of code you write at 1am because you’re tired of manually typing commands into four different terminals. Let’s walk through it.
Constants: The Address Map
Wall of constants at the top that took me way too long to get right:
INITRAMFS = os.path.join(_PROJECT_DIR, "firmware",
"openwrt-25.12.0-ath79-nand-meraki_mr18-initramfs-kernel.bin")
SYSUPGRADE = os.path.join(_PROJECT_DIR, "firmware",
"openwrt-25.12.0-ath79-nand-meraki_mr18-squashfs-sysupgrade.bin")
PSU_PIPE = "/tmp/scpi_pipe"
HOST_NIC = "enx6c1ff71fee83"
HOST_IP = "192.168.1.2/24"
Hardcoded paths, hardcoded NIC name. This script runs on exactly one machine for exactly one purpose. The addresses are where it gets interesting:
LOAD_ADDR = "0xa005FC00" # KSEG1 uncached
ENTRY_ADDR = "0x80060000" # decompressor entry (KSEG0, cached)
TRAMPOLINE_ADDR = "0xa0800000" # scratch area for tiny programs
LOAD_ADDR is in KSEG1—the uncached segment. Writes bypass the D-cache and go straight to physical RAM. If you’ve read post 4 you know why this matters—Cisco’s Nandloader leaves dirty cache lines everywhere, and KSEG1 sidesteps that nightmare. TRAMPOLINE_ADDR at 0xa0800000 (physical 8MB) is where I park my hand-encoded MIPS programs—1MB above the binary end, safe from being clobbered during load. Then there’s the EJTAG stuff:
EJTAG_PROBEN = (1 << 15) # 0x8000
EJTAG_JTAGBRK = (1 << 12) # 0x1000
EJTAG_BRKST = (1 << 11) # 0x0800
EJTAG_BIT3 = (1 << 3) # 0x0008 always set
EJTAG_HALT_WR = EJTAG_PROBEN | EJTAG_JTAGBRK | EJTAG_BIT3 # 0x9008
EJTAG_HALT_WR = 0x9008 is the magic value you write to the EJTAG control register to halt the CPU. PROBEN enables the processor access channel, JTAGBRK requests a debug break, BIT3 is one of those “always set this or weird shit happens” bits. BRKST is what you poll to confirm the CPU actually stopped.
PSU Control: Talking to a Bench Supply Through a Pipe
I have a programmable bench PSU that speaks SCPI. A separate tool called scpi-repl handles the USB connection. The flash script talks to it through a named pipe:
PSU_PIPE = "/tmp/scpi_pipe"
def psu(cmd, delay=0.4):
with open(PSU_PIPE, 'a') as f:
f.write(cmd + '\n')
time.sleep(delay)
Open the pipe, append the command, sleep. Lifecycle management is more involved:
def kill_repl():
subprocess.run(["pkill", "-f", "scpi-repl"], capture_output=True)
subprocess.run(["pkill", "-f", f"tail.*{PSU_PIPE}"], capture_output=True)
time.sleep(1.0)
kill_repl() kills both the repl and the tail feeding it. scpi-repl applies a safe state on exit—output disabled, 0V, 0A—so if the script crashes, the PSU doesn’t keep dumping 48V into the MR18. That’s the kind of safety net you add after the first time you smell burning electronics. start_repl() wires up tail -f /tmp/scpi_pipe | scpi-repl and polls the log for the eset> ready prompt with a 30s timeout. Poor man’s IPC. Works great.
OCD Class: A Telnet Client in 30 Lines
OpenOCD exposes a telnet interface on localhost:4444. My OCD class is the thinnest wrapper imaginable:
class OCD:
def connect(self, retries=20) -> bool:
for _ in range(retries):
try:
s = socket.socket()
s.settimeout(2.0)
s.connect((OCD_HOST, OCD_PORT))
self.sock = s
self._drain() # consume banner + first prompt
return True
except OSError:
time.sleep(0.2)
return False
def _drain(self, timeout=1.0):
data = b""
self.sock.settimeout(timeout)
try:
while True:
chunk = self.sock.recv(4096)
if not chunk: break
data += chunk
if data.rstrip().endswith(b">"): break # OpenOCD's prompt is ">", means it's done talking
except socket.timeout:
pass
return data.decode(errors="replace") # bytes to string, replace any garbage chars
def cmd(self, command: str, timeout=5.0) -> str:
self.sock.sendall((command + "\n").encode()) # send the command as bytes over the socket
return self._drain(timeout=timeout) # read everything back until the next ">" prompt
connect() retries 20 times with 200ms gaps. _drain() reads until it hits the > prompt or times out. cmd() sends a command and drains. Simple, ugly, works.
OpenOCD Process Management (and a Bug)
def kill_openocd():
subprocess.run(["pkill", "-9", "-f", "openocd"], capture_output=True)
time.sleep(0.5)
def start_openocd() -> subprocess.Popen:
log = open("/tmp/openocd.log", "w")
proc = subprocess.Popen(
["openocd",
"-f", f"{CFG_DIR}/esp-prog.cfg",
"-f", f"{CFG_DIR}/mr18.cfg",
"-c", "init"],
stdout=log, stderr=log,
)
return proc
kill_openocd() uses pkill -9 because OpenOCD occasionally gets stuck where a polite SIGTERM doesn’t cut it. Graceful shutdown is for people who aren’t debugging JTAG at 4am. start_openocd() launches with two config files—one for the ESP-Prog adapter, one for the MR18 target—plus -c init to immediately scan the TAP chain.
Now here’s a fun one—CFG_DIR is referenced but never defined anywhere in the file. No assignment, no import, nothing. Straight up NameError at runtime. I always had it set in my REPL environment so I never hit this in practice, but yeah, it’s a bug. The joys of writing automation at 3am and testing exclusively through an interactive session where every global is already set by hand.
The ESP-Prog UART: Hardware Reset Over Serial
The last piece is the ESP-Prog’s UART interface—serial console and hardware reset in one. ESPPROG_UART = "/dev/ttyUSB4", FAILSAFE_EN_DELAY = 2.0 seconds, FAILSAFE_EN_HOLD = 40.0 seconds. The FT2232H has two channels—interface A does JTAG, interface B does UART. Interface B’s RTS pin is wired through an NPN transistor—same auto-reset circuit esptool.py uses for ESP32 boards:
ser.rts = True -> transistor conducts -> EN pin pulled LOW -> GPIO17 reads LOW
GPIO17 is the reset/failsafe button input on the MR18. By asserting RTS, I electrically simulate holding the reset button without touching anything. Assert EN at t=2s after kernel launch, hold LOW for 40 seconds, blanket the entire preinit failsafe window (t=18-28s). Way wider than needed, but I’d rather overshoot by 20 seconds than miss by half a second and restart the entire flash sequence.
Why This Matters
None of this code is interesting on its own. But without it, the interesting stuff—PRACC error correction, cache flush trampolines, the timing attack on the bootloader—would require me to manually coordinate four terminals and pray I’m fast enough to halt the CPU in a 2-second window after power-on. Automation turns “quickly switch terminals and type this command” into “run the script and go make coffee.” Even if the script has a bug in a function you never call directly.