FiiO X1 boot ROM: running code on a JZ4760B with no RAM

Assumes familiarity with memory-mapped I/O and register-level hardware programming. MIPS knowledge helps but isn't required — the constraints are the story, not the instruction set.

How do you read the code that wakes up a machine's RAM, when that code sits on flash you can't read until the RAM is awake? That question is the whole problem with the FiiO X1, a portable music player built on an Ingenic JZ4760B (a MIPS32 chip, which the player's own boot loader sets to 384MHz) that runs FiiO's firmware on the µC/OS-II real-time kernel. Rockbox, the open-source firmware that replaces the stock software on music players, has had an unfinished X1 port for years. What stalled it is the DDR initialization parameters: the timing values the chip needs before its RAM works at all, which nobody outside FiiO knows.

The RAM chip is undocumented, the firmware image is scrambled, and the only code that knows how to wake up the memory controller is locked inside NAND flash that you can't read without first waking up the memory controller. I have a weakness for problems shaped like a locked box with the key inside it. It has never once saved me time.

The first way in is the chip's boot ROM: the small program baked into the silicon that runs at reset before anything on flash. It has a USB boot mode, and it will run your code. What it won't give you is the rest of this post.

USB boot mode: entering the device#

Normal USB: 2972:0003 FiiO Electronics Technology X1. Mass storage for the SD card slot. Boring.

Hold the center button, plug the cable: 601A:4760 Ingenic JZ4760 Boot Device. Vendor-specific bulk interface, two endpoints, no questions asked. The boot ROM is waiting.

The USB boot protocol is minimal — six vendor control requests:

RequestCodeWhat it does
VR_GET_CPU_INFO0x00Returns "JZ4760V1"
VR_SET_DATA_ADDRESS0x01Sets target memory address
VR_SET_DATA_LENGTH0x02Sets transfer size
VR_FLUSH_CACHES0x03Flush I/D caches
VR_PROGRAM_START10x04Execute from cache (stage1)
VR_PROGRAM_START20x05Execute from SDRAM (stage2)

Bulk OUT writes to the target address. Bulk IN reads from it. PROGRAM_START1 fills the I-cache with your code and jumps. That last part is where things get interesting.

The boot ROM under Ghidra#

8KB of MIPS32 little-endian at 0x1FC00000, the reset vector. Load it into Ghidra, set the language to MIPS:LE:32, run auto-analysis, and the structure reveals itself immediately.

Entry does an ALU self-test (11 iterations of mtlo/mflo — paranoid, but fair for a mask ROM), pokes an RTC register, masks all interrupts, then reads boot mode. There's no stack yet, so it uses $sp as a plain base register, pointed at the GPIO block. A chip that checks its own arithmetic eleven times before it trusts it: I felt seen.

lw   $t1, 0x300($sp)         # $sp = 0xB0010000: PORTD pin register
srl  $t1, $t1, 17
andi $s5, $t1, 7             # mode = (PORTD >> 17) & 7

Five cases in the switch. Three matter:

  • Mode 7: Full NAND boot. Init NEMC (the NAND/external memory controller), read the IPL header, load with 24-bit BCH ECC, execute from I-cache.
  • Mode 4: SD/MMC boot: 8KB from the start of the card.
  • Anything else: Fall through to USB boot. That's my way in.

The NAND boot path (mode 7) is the valuable one. It reveals the format of the IPL, the initial program loader: the first thing the chip reads from flash on every cold boot.

The IPL header: what the boot ROM looks for in NAND#

The boot ROM starts by reading a 160-byte header at the start of NAND: four flags, each a run of 0x55 or 0xAA bytes. The first flag, 64 bytes long, gives the bus width: 0x55 for 8-bit, 0xAA for 16-bit, decided by count (more than seven matching bytes wins). Row-cycle and page-size flags follow it. If page 0 fails, the boot ROM tries the backup copies at pages 64, 128, 192 and so on up to page 1280, then gives up and falls through to USB boot.

The FiiO X1's NAND has a 16-bit bus and 2048-byte pages (Rockbox's fiiox1.lua lists both), so its bus-width flag should be all 0xAA. Once the header checks out, the boot ROM reads 8KB of code through the hardware randomizer and the 24-bit BCH engine, one 256-byte block at a time, into the cache at 0x80000000, locks the cache lines, and jumps to 0x800000A0.

That entry point offset — 0xA0 — skips the 160 bytes of flags. The actual MIPS code starts at byte 160. The IPL is 8KB, half of the 16KB cache. Its job: initialize DDR, then load the next bootloader stage into RAM.

The NEMC base address gotcha#

One thing the boot ROM taught me, after a round of NAND tests aimed at the wrong address: the NEMC base is 0xB3410000. Not 0xB3050000, the address my notes had taken from the JZ4760 documentation. The actual register you need for SMCR1 is at 0xB3410014. Boot ROM writes 0x0FFF7700 there for relaxed timing.

The Ghidra decompilation showed this clearly — the immediate values in the lui/ori pairs don't lie. Documentation does. That gives this series its first rule, and I suspect not its last. Rule one: when the chip's own code and the chip's documentation disagree, the code is right and the documentation is a rumour.

Code execution: the I-cache model#

PROGRAM_START1 doesn't just "run code." It fills the 16KB I-cache with your payload and jumps to it. Your code executes exclusively from I-cache at 0x80000000. There is no SDRAM behind that address — DDR isn't initialized. The cache is the only backing store.

This means:

  1. No stack. $sp points into the void. Any lw/sw through it returns garbage or faults silently.
  2. No nested calls. jal overwrites $ra, and the usual place to save the old value is the stack. There is no stack.
  3. Register-only programming. You get 32 registers. Use them wisely.

And then the real constraint.

sb works, sw silently fails#

Store byte (sb) to memory-mapped peripherals at KSEG1 addresses (0xBxxxxxxx, the uncached MIPS address window) works perfectly. Store word (sw) to the registers I tested does absolutely nothing. No fault. No bus error. The write just vanishes.

The most likely explanation: without SDRAM, the D-cache write buffer has no drain path. Word stores get buffered in the write buffer and never flush to the bus. Byte stores bypass the write buffer — they're a different bus transaction type that goes directly to the peripheral. The JZ4760 may gate KSEG1 stores through the write buffer even for peripheral addresses when DRAM is uninitialized — the datasheet doesn't say, and the behavior proves the effect, not the mechanism.

The boot ROM's own code works fine because it executes from 0x1FC00000 (KSEG1, uncached). My code executes from KSEG0 I-cache, the cached window. Same CPU, different caching rules, completely different behavior.

This took multiple failed attempts to prove. sw to SADC configuration register — nothing. sw to GPIO interrupt flags — nothing. sw to NEMC NFCSR — nothing. Every time: the register reads back unchanged, the peripheral doesn't respond, the code returns cleanly as if everything succeeded. The silence is the cruelty. In fairness to sw, it never once claimed to have worked. The one claiming that, attempt after attempt, was me.

TCSM: the scratchpad that isn't#

The JZ4760 has 16KB of on-chip TCSM (Tightly Coupled Shared Memory, a scratchpad) at 0xF4000000. That's KSEG3, the mapped segment: it needs a TLB entry, and my code doesn't get one. I tried writing an entry with TLBWI; the device stayed alive and nothing changed. Writes to 0xB4000000, a guessed KSEG1 alias, never landed either. Later, neither did writes to 0xB32B0000, the uncached TCSM address in Rockbox's hwstub config. Sixteen kilobytes of scratchpad, on paper. From where I was standing, a brochure.

So: no word stores to peripherals, no scratchpad, no stack, no SDRAM. You get I-cache code execution with byte stores. That's it.

Proving life: reading battery voltage over the SADC#

The constraints are severe, but sb to a peripheral that responds to byte writes is enough to prove the execution model works. The SADC (SAR-ADC Controller, the chip's analog-to-digital converter) at 0xB0070000 accepts byte-width register writes.

All the MIPS code at this stage is hand-assembled by Python bytearray builders. I had no MIPS cross-compiler installed, and a payload that can't use a stack doesn't need one. The working battery reader came to 396 bytes; this is its SADC part:

# --- Init SADC: sb 0x00 to ADENA, sb 0xFF to ADCTRL ---
lui(3, 0xB007)          # SADC base
sb(0, 0x00, 3)          # ADENA = 0
nop(); nop()
addiu(2, 0, 0xFF)
sb(2, 0x08, 3)          # ADCTRL = 0xFF
nop(); nop()
sw(0, 0x04, 3)          # ADCFG = 0
nop(); nop()

# Short delay (~50us)
addiu(7, 0, 0x2000)
addiu(7, 7, -1)
bne(7, 0, -2)
nop()

# --- Enable battery: sb 0x02 to ADENA ---
addiu(2, 0, 0x02)
sb(2, 0x00, 3)
nop(); nop()

# Delay for conversion (~200us)
addiu(7, 0, 0x4000)
addiu(7, 7, -1)
bne(7, 0, -2)
nop()

(Each helper packs one little-endian instruction word: sb(rt, off, bs) is I((0x28 << 26) | (bs << 21) | (rt << 16) | (off & 0xFFFF)), and I() is struct.pack('<I', v) into a bytearray. Register numbers are raw: 2 is $v0, 3 is $v1, 7 is $a3. The file starts with move $s0, $ra and ends with jr $s0, the only way back to the boot ROM without a stack. It also tried a few sw writes, among them a checkpoint value for an RTC scratch register. The checkpoint never landed.)

Upload via usbboot --stage1. Execute. Return to boot ROM (the device doesn't reset — USB stays alive). Read the SADC registers back via bulk upload; the ADVDAT result register at 0xB007001C (the marker is my script's, for "changed since before the run"):

  ADVDAT = 0x00000680 <<<< CHANGED

That's 1664 in decimal. With a 2500mV reference and a 12-bit ADC, the pin sits at 1016mV. ADCFG read back 0, no divider selected, so the divider is my assumption: 4:1 is the ratio that turns 1016mV into a plausible cell voltage.

(1664 * 2500 / 4096) * 4 = 4062mV

4.06V: a nearly full Li-ion cell. The code ran. The peripheral responded. The number is physically plausible. The first thing I got this player to tell me was that a battery sitting on a USB cable was nearly full, which is not news, and I was still unreasonably pleased with it.

What the boot ROM execution model allows#

The execution model is validated:

  • Code uploads to I-cache
  • Jumps to my entry point
  • sb hits the peripheral bus
  • Return to boot ROM preserves USB state
  • Device remains explorable without reset

But the constraints make a NAND dump from this context nearly impossible. The NAND controller needs sw to configure NEMC registers. Reading NAND data from my code failed too: lhu from the NAND data port returned zeros. And a byte that did arrive would have nowhere to go that the USB handler can reach, except one 8-bit SADC register.

The real path forward requires DDR initialization. Which requires the IPL. Which is on NAND. Which requires DDR to dump properly.

Chicken, meet egg. The boot ROM knows how to read NAND, how to speak USB and how to check its own ALU eleven times; it knows everything except the one thing the port needs. Rule one says trust the code over the documentation. The code I need to trust is the code I can't read yet.

Part 2 breaks the deadlock.

Filed under: reverse-engineering, mips, embedded, fiio, hardware

Sources#