FiiO X1 NAND dump: hwstub, fee1:dead and a 64-bit compiler
Contents
The tool that finally dumped the flash is 12.4KB of someone else's firmware, built with a compiler meant for a different target, after the canonical route had failed at every step I tried. The flash belongs to a FiiO X1, a portable music player on an Ingenic JZ4760B MIPS chip, and it holds the IPL: the initial program loader that sets up the player's RAM at power-on, and the piece the Rockbox port (open-source music-player firmware) has been missing.
At the end of Part 2, I had a DDR configuration read from live registers and timing values from a template. The DDR controller, still configured from the previous firmware boot, gave up what it had through the warm reentry path: boot the stock firmware, shut it down, then drop into the chip's USB boot mode without a power cycle. The tool for the next step was ingenic-boot, the open-source USB boot kit for Ingenic chips: a small stage1 program that initializes DDR, then a stage2 that runs from RAM and talks to the host. The plan was simple:
- Build ingenic-boot stage1 with the captured parameters
- Upload via USB boot, initialize DDR
- Upload stage2 to SDRAM
- Dump NAND
Four steps. Should take an afternoon.
I distrust a plan the way I distrust a datasheet: by the size of what it promises. A plan built from steps labelled "mechanical" promises a great deal.
Three ways the plan doesn't work#
Warm SDRAM dump#
The obvious shortcut: if the DDR controller survives the warm reboot, maybe the SDRAM contents do too. Read everything before anything overwrites it.
This time reads died past the first 64KB. The MMAP register was the reason: 0x0000FF00 instead of 0x000020FE, the value that maps 32MB at the base. Fixing that unlocked full SDRAM addressing; reads as far out as 0x81000000 came back. But a 1MB read held zero firmware strings, only repeating patterns and noise. Whatever the shutdown does on its way out, it doesn't leave the firmware's memory behind. The DDR controller outlives the firmware. The data doesn't.
I-cache code can't read the NAND port#
In Part 1, I got code running through the boot ROM's USB mode, built from MIPS opcodes in Python, and read a battery voltage with it. This time it could also write to memory: my code stored a HELLO_X1 marker with sb into a buffer at 0x80002000, and I read it back over USB.
NAND reads need lbu/lhu/lw from the NAND data port on the AHB2 bus. From I-cache code, those loads return 0 or crash. Only sb writes work. The execution model that got me 4062 millivolts in Part 1 hits a wall: can write to peripherals, can't read back.
Stage1 DDR init#
Built ingenic-boot stage1 with the captured parameters. Three attempts:
- PLL reconfig killed USB. Device stopped responding.
- DDR calibration hung in the DQS sweep loop. Never completed.
- Register writes from I-cache code crashed the device outright.
The parameters from Part 2 were incomplete, and some of them, as the IPL would show, were wrong. A DDR init sequence is a choreographed procedure with precise ordering, delays, and a calibration loop, not a bag of register values. Knowing the final register state doesn't mean you can reproduce the dance that gets there. A register dump promises you the destination and says nothing at all about the road.
Every compiled stage1 approach failed.
The tool that doesn't build: ingenic-boot stage2#
The other half of ingenic-boot had been broken since my first build of the kit, before any of this. Its host-side stage2_cmd_tool, the program that talks to stage2 once it runs, doesn't compile: stage2_main.c calls sd_card_init with no declaration in scope. From that first make, trimmed:
stage2_main.c: In function ‘main’:
stage2_main.c:208:21: error: implicit declaration of function ‘sd_card_init’ [-Wimplicit-function-declaration]
208 | if (sd_card_init(ingenic_dev))
| ^~~~~~~~~~~~
make[1]: *** [<builtin>: stage2_main.o] Error 1
make: *** [Makefile:40: bin/stage2_cmd_tool] Error 2
That's the canonical NAND dumping tool for Ingenic SoCs, stopped at the first make.
There's a Rockbox project called hwstub — a tiny USB firmware that runs on the target device, giving the host full memory access. Completely different codebase, same concept. And the Rockbox tree includes a JZ4760B target.
hwstub from wrong parts#
hwstub runs from TCSM — Tightly Coupled Shared Memory, a 16KB on-chip scratchpad at 0xF4000000. Not SDRAM. It implements its own USB stack, independent of the boot ROM's USB handler. Upload it via the same USB boot mode I've been using since Part 1, and it takes over the device: memory read/write, register access, code execution. No DDR initialization required.
The 16KB constraint that made I-cache code execution barely useful in Part 1 is exactly the size of TCSM. But hwstub uses that 16KB properly — compiled C with a real USB stack, not Python-assembled opcodes shoved through a vendor protocol.
Building it is the adventure.
Cross-compiling with a 64-bit MIPS gcc#
The Rockbox hwstub Makefile expects a mipsel-elf- toolchain. Available compiler: mips64-linux-gnu-gcc from Fedora. A 64-bit MIPS compiler for a 32-bit target: the compiler equivalent of borrowing a lorry to deliver a letter. The letter, to its credit, arrived.
make PREFIX=mips64-linux-gnu- \
GCCOPTS="-EL -mips32 -mabi=32 -G0 -fno-pic -mno-abicalls" \
LDFLAGS="-EL -mabi=32 -lgcc -Os -nostdlib -T\$(TMP_LDS) -Wl,-Map,\$(TMP_MAP) \$(INCLUDES) -L\$(BUILD_DIR)"
-EL because the JZ4760 is little-endian, on the link line too: the first link refused the objects as "compiled for a little endian system and target is big endian". -mips32 -mabi=32 to convince the 64-bit compiler to emit 32-bit code. -fno-pic -mno-abicalls because the first link also tripped on GOT relocations. The resulting binary: 12.4KB. Fits in TCSM with room to spare.
Lua 5.4 compatibility#
hwstub_shell — the host-side tool — uses Lua for scripting, and its Makefile asks for Lua 5.2. Against Lua 5.4 the C side wouldn't build (luaL_checkunsigned is gone), and the Lua side wants the bit32 library, which 5.4 dropped in favour of native bitwise operators. Two compatibility shims: a few #defines for the C API, a small bit32 module for the scripts.
regtools library#
hwstub_shell depends on Rockbox's regtools library for SoC register descriptions. Separate build from the main Rockbox system.
Three build system fights: cross-compiler flags, Lua compat shim, regtools library. Each one a complete stop-and-debug cycle. None of them interesting enough to describe in detail. All of them mandatory.
fee1:dead: the stub comes alive#
Upload the 12.4KB binary via USB boot:
$ usbboot --stage1 hwstub.bin
The device re-enumerates on the USB bus. lsusb shows a new device:
Bus 007 Device 049: ID fee1:dead Rockbox.org Rockbox hardware stub
0xFEE1:0xDEAD. That's the hwstub USB Vendor ID and Product ID — defined in hwstub_protocol.h as HWSTUB_USB_VID and HWSTUB_USB_PID. Rockbox developer humor: "feel dead". After a night and most of a day of dead ends, it was the first thing in this project that understood how I felt. The device is alive and speaking hwstub protocol.
hwstub_shell connects. The first script read two words, then hit the Lua gap:
BootROM[0]: 0x00000013
CPUID: 0x352920e4
error: ./lua/jz/gpio.lua:76: attempt to index a nil value (global 'bit32')
Word 0 of the boot ROM and the CPU ID, read live by a 12.4KB stub running entirely from on-chip SRAM. No DDR. No firmware. No boot ROM assistance. (The ID belongs to a JZ4760B, the variant Rockbox's stub targets.)
The wall from Part 1 — lbu/lhu/lw failing from I-cache code — doesn't apply here. hwstub runs from TCSM with its own properly initialized USB stack, and word-width peripheral reads work: whatever the cache-execution trick was missing, the stub has it.
Dumping the IPL with a Lua script#
With hwstub running, the NAND controller is accessible, and Rockbox already ships the hard part: its jz/nand.lua reads NAND the way the boot ROM does. The whole IPL dump, once the bit32 shim was in (output path shortened):
dofile("lua/bit32_compat.lua")
require "jz/nand"
JZ.nand.rom.init()
JZ.nand.rom.read_flags()
print("Reading IPL (bootloader from NAND block 0)...")
local ipl = JZ.nand.rom.read_bootloader()
print("IPL read! Writing to file...")
JZ.nand.rom.write_to_file("dumps/nand_ipl.bin", ipl)
print("DONE — IPL saved to dumps/nand_ipl.bin")
It reads the geometry from the flag bytes, then pulls each 256-byte block through the hardware ECC. The start of its output:
NAND: reset
NAND: colcycle = 2
NAND: buswidth = 16
NAND: rowcycle = 2
NAND: pagesize = 2048
Reading IPL (bootloader from NAND block 0)...
NAND: try at page offset 0
NAND: page 0
NAND: block 0
NAND: ecc = 0x8
NAND: correcting 0 errors
The last two lines repeat for blocks 1 to 7 of pages 0 to 3: 32 blocks, zero corrections. The file opens the way the boot ROM spec says it should:
00000000: aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa ................
00000010: aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa ................
00000020: aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa ................
00000030: aaaa aaaa aaaa aaaa aaaa aaaa aaaa aaaa ................
00000040: 5555 5555 5555 5555 5555 5555 5555 5555 UUUUUUUUUUUUUUUU
00000050: 5555 5555 5555 5555 5555 5555 5555 5555 UUUUUUUUUUUUUUUU
00000060: 5555 5555 5555 5555 5555 5555 5555 5555 UUUUUUUUUUUUUUUU
00000070: 5555 5555 5555 5555 5555 5555 5555 5555 UUUUUUUUUUUUUUUU
Sixty-four bytes of 0xAA: the bus-width flag, 16-bit. Then runs of 0x55: the row-cycle and page-size flags. And at offset 0xA0, where the boot ROM jumps:
000000a0: 00b0 083c 0800 0835 0000 098d 0400 2931 ...<...5......)1
Bytes 00 b0 08 3c are the little-endian word 0x3C08B000: lui $t0, 0xB000, the first instruction of the IPL, loading the upper half of the address of the CPM (the clock and power manager) into $t0.
Four 2KB pages. 8192 bytes. The entire IPL, on disk.
What fee1:dead taught me#
Part 2 ended with six "mechanical" steps. The first two, building stage1 and initializing DDR with it, never worked, and the warm SDRAM shortcut died too. What did work wasn't on the list: hwstub, which took a 64-bit cross-compiler, a Lua version gap and a separately built library. The IPL dump itself, the thing I've been chasing since Part 1, took under six minutes once the stub was up.
About an hour of build systems. One 12.4KB binary. Then everything the I-cache trick couldn't do.
I've added a line to my notes on what things promise. A step called "mechanical" means the machinery is involved, and gets a vote.
The IPL is 8KB. What's in it is the subject of Part 4.
Filed under: reverse-engineering, mips, embedded, fiio, hardware
Read next#
- FiiO X1 IPL decompiled — the next part: what the dumped IPL actually does between power-on and the FiiO logo.
- FiiO X1 DDR deadlock: reading RAM config from live registers — where the parameters that failed in stage1 here came from.
Sources#
- Rockbox hwstub source — stub firmware, protocol definitions, JZ4760B target
- hwstub protocol header — VID:PID definitions, version constants, descriptor types
- Rockbox hwstub Lua tools — NAND read scripts
- hwstub JZ4760B target config — TCSM address, cache sizes
- ingenic-boot stage2 source — the build that fails on
sd_card_init - Rockbox FiiO X1 port thread — hwstub usage notes, build system context
- JZ4760 Board Design Guide — NEMC registers, PN randomizer, NAND controller