FiiO X1 DDR deadlock: reading RAM config from live registers
Contents
I assumed the DDR parameters would be in the firmware update file. Firmware has to get RAM working somehow, and the update file was the one thing I already had. Those parameters, the timings the memory controller needs before RAM works, are the one thing keeping Rockbox, the open-source music-player firmware, off the FiiO X1, a portable music player built on an Ingenic JZ4760B MIPS32 chip. The Rockbox port has wanted them for years, and they are in no public datasheet.
Part 1 ended in a loop. Through the chip's boot ROM, the code baked into the silicon that runs at reset, I can upload and execute code over USB, but only from instruction cache, with no RAM behind it and no working word stores. That is enough to read a battery voltage and nothing useful. NAND dumps need DDR. DDR init needs the IPL, the initial program loader that sets up RAM at every power-on. The IPL is on NAND.
Three paths through the deadlock. Two fail. One works — and it's the one that shouldn't. I took them in order of how respectable they sounded, a habit this player would spend several more parts punishing.
Path 1: decrypt the firmware (XOR with primes)#
The firmware update file x1.fw is a 43.7MB SFHI container. Rockbox's packtools unpacks it:
packtools --unpack -i x1.fw -o x1firmware
Out comes sys.bin (4.1MB), some fonts, themes, and UI resources. sys.bin is the monolithic µC/OS-II binary — the entire OS in one file. It's XOR-encrypted.
The encryption is charming. It wins this series' first award, Most Optimistic Use of the Word "Encryption". The key is 344 bytes: the lower 8 bits of the first 172 prime numbers, repeated twice. No key derivation. No IV. No authentication. Just XOR with [2, 3, 5, 7, 11, 13, ...] truncated to bytes and doubled.
primes = []
n = 2
while len(primes) < 172:
if all(n % p for p in primes):
primes.append(n)
n += 1
key = bytes([p & 0xFF for p in primes]) * 2 # 344 bytes
Someone at FiiO (or Ingenic, or whoever wrote the firmware tooling) thought: what's a number sequence every programmer knows but isn't trivially guessable? Primes. The truncation to 8 bits adds just enough indirection that you can't eyeball the pattern. The key is documented in Rockbox's own packtools.cpp.
Apply it to sys.bin, load the result into Ghidra at 0x80000000 as MIPS32-LE. Strings confirm the decryption: DDR CAPICITY:, DDR TESTING...., USB Driver Build on Jun 7 2016 10:39:08.
Now find the DDR init code, extract the parameters, done. Right? I believed that right up until I went looking for the IPL.
The decrypted firmware is 4.1MB of µC/OS-II. It contains DDR test routines — the string DDR TESTING.... proves they exist. But the IPL (Initial Program Loader) isn't part of sys.bin. The IPL lives in NAND block 0, loaded and executed by the boot ROM before sys.bin ever touches memory. The firmware image assumes DDR is already working. The DDR init parameters are in the IPL, not here.
Chicken and egg, encrypted.
Path 2: read NAND from the boot ROM#
From Part 1: sb works from I-cache code, sw doesn't. The NAND controller needs sw to configure NEMC registers. Dead end for proper NAND reads.
But my NAND readers did bring something back. Each one wrote what it read into a buffer and returned to the boot ROM, and I read the buffer over USB. It always held the same 2KB, whatever page I asked for, and even when I sent no read command at all. My first guess: the NAND chip's page buffer, still holding the last page the boot ROM read before it fell through to USB mode. Actual bytes. The first two: 0x47, 0x75. Not the 0xAA 0xAA flag bytes. Not obviously MIPS code either.
The PN randomizer#
The JZ4760 NEMC has a built-in PN (pseudo-noise) randomizer — a 15-bit LFSR that XORs NAND data during read/write. 0x47 XOR 0xAA = 0xED. Brute-force search across all 32768 possible 15-bit LFSR seeds: seed 0x5FED produces the right first two bytes after de-randomization.
The de-randomized output starts with 0xAA 0xAA. The flag bytes. But the code after offset 0xA0 doesn't decode as valid MIPS — only 18% of instructions are valid opcodes. This isn't the IPL.
Why: nothing tied that buffer to page 0. If it was the NAND chip's buffer, it held whichever page the boot ROM read last, and the boot ROM tries backup copies as far out as page 1280. Later tests showed my code's stores never reached the buffer at all, so the bytes may not have come from NAND in the first place. Without controlling which page gets read, the buffer is forensic noise. I had carefully de-randomized one page of something, with no way to say which.
Building a proper NAND reader requires sw to NEMC registers. I don't have sw. The maximum extraction rate through the byte-store-only path — writing one byte to an SADC register, reading it back over USB, repeating 2048 times per page — works out to roughly an hour per page. For the 8KB IPL, four pages, that's about four and a half hours, if every byte arrives. And it might not work at all, because you can't configure the NAND addressing mode without sw. That isn't a dump. It's a hostage negotiation conducted one byte at a time.
Failed NAND dump attempts#
TCSM TLB approach — Write a TLB entry mapping TCSM (the chip's 16KB on-chip scratchpad) at 0xF4000000, use it as scratch space to buffer NAND reads, then let the boot ROM USB handler upload the buffer. Problem: the TLB isn't initialized, page tables aren't set up, and a bad TLB write could corrupt the running code's own mapping. Tried it anyway. The device stayed alive, the code returned, and the buffer held the same bytes as before: whatever the TLB write did, it did nothing I could see.
KSEG1 TCSM alias — Maybe TCSM is accessible at 0xB4000000 (uncached segment)? I guessed it might sit at a fixed physical address reachable through KSEG1. Writes there never showed up in the buffer.
Byte-at-a-time via SADC — Read one byte from the NAND data port with lhu. Store it to ADENA via sb. Read ADENA over USB. Repeat. The first step already failed: from my code, lhu on the data port returned zeros, while the buffer I had read over USB started 47 75.
Path 3: the DDR controller doesn't know it's supposed to die#
Everything so far assumes a cold start. Boot ROM mode with no prior state. But the FiiO X1 can enter boot ROM mode right after a normal boot: let µC/OS-II load, shut it down, then hold the center button and plug USB. No power cycle, no reset pinhole in between.
The DDR controller stays alive.
The most useful component in this player is the one that didn't get the memo. The boot ROM doesn't reset the DDR controller. It doesn't gate its clock. The controller is just there, still configured.
This is an accident of the boot ROM's design. It only initializes what it needs: NAND controller for the boot attempt, USB for the fallback. Everything else is left in whatever state the prior firmware configured.
Live DDR register capture#
Read the DDR controller registers at 0xB3020000 via USB bulk upload. My script's first listing:
SUCCESS — DDR controller readable!
DDRC_ST [+0x00] = 0x00000001
DDRC_CFG [+0x04] = 0xA0020000
DDRC_REFCNT [+0x18] = 0x00FF0000
DDRC_DQS [+0x1C] = 0x12B33333
DDRC_MMAP0 [+0x24] = 0x0000FF00
DDRC_MMAP1 [+0x28] = 0x0000FF00
The timing registers (TIMING1, TIMING2) read as zero: the controller had been parked in self-refresh, and the timing values didn't survive the trip. But the configuration and the DQS value did. DQS is the delay that lines the data strobe up with the data on this board (revision m442c), and it isn't a datasheet number: the boot code calibrates it at power-on, which is why a later capture read 0x12B43434 instead. You can't calculate it. You can only catch it.
Clock configuration#
The CPM (Clock/Power Management) registers are also alive:
CPCCR (Clock Control) [+0x00] = 0x01211110
LCR (Low Power) [+0x04] = 0x000000F8
CPPCR (PLL Control) [+0x10] = 0x300A8520
Decoded by the same script, with the 12MHz crystal:
CPCCR = 0x01211110
CDIV=0 HDIV=1 H2DIV=1 PDIV=1 MDIV=1 SDIV=2 PCS=0
CPPCR = 0x300A8520
PLLM=48 PLLN=2 PLLOD=2 PLLEN=0
PLL output = 12MHz × 48×2 / (2 × 2) = 288MHz
CPU clock = 288MHz (÷1)
MEM clock = 144MHz (÷2)
The script got the PLL wrong. In the CPPCR table of Ingenic's JZ4760 programming manual, a PLLOD field of 10 means divide by 4, not by 2, so the PLL was running at 12MHz × 96 / (2 × 4) = 144MHz when I read it: 144MHz for the CPU and 72MHz for memory, with the two dividers the script did get right. Its PLLEN=0 is wrong too: bit 8 is set. Everything below that came from this decode, the 288MHz in the board config included, carries the script's mistake.
DDR configuration decode#
DDRC_CFG = 0xA0020000:
DDRC_CFG = 0xA0020000
TYPE = 1 (SDRAM)
ROW = 12 bits (encoded 0)
COL = 8 bits (encoded 0)
BA = 4 banks
BW = 32-bit bus
DW = 16-bit data port
CS0EN = 0
CS1EN = 0
CAPACITY = 16777216 bytes = 16 MB
Taken at face value, that's plain SDRAM with chip select 0 switched off, which can't be what the firmware had been running on. I read it as a controller parked for sleep, and assumed mobile DDR (MDDR) with 13 row bits and 9 column bits, the defaults in ingenic-boot's MDDR template. The IPL will know the real value.
I planned for MDDR at the script's 144MHz memory clock, a tCK of 6.94ns. The timing values are the template's generic MDDR ones (ingenic-boot's mddr.h); at 144MHz they come to:
| Parameter | Value | Clocks at 144MHz |
|---|---|---|
| tRAS | 45ns | 7 |
| tRP | 18ns | 3 |
| tRCD | 18ns | 3 |
| tRC | 60ns | 9 |
| tRFC | 72ns | 11 |
| tWR | 15ns | 3 |
| CAS Latency | — | 3 |
These are template values, not captured ones: the live timing registers were zero. At the 72MHz the manual gives, the same nanoseconds need fewer clocks than the table lists. Whether the template matches this RAM chip is a question only the IPL can answer. What the live registers did give me is the clocks, the DQS value and a layout to start from.
SDRAM: reachable, but not the firmware#
With the controller configured, SDRAM at 0x80000000 answers too, once a write to DDRC+0x50 brings it out of self-refresh. The value I wrote, 0xFF000000, came from the firmware's resume code:
SDRAM READ SUCCESS!
00000000: 35bd 26a8 13c3 aad7 3134 4775 aa7b b2ba 5.&.....14Gu.{..
That's where the good news stopped. Any transfer above 16KB hung the boot ROM's USB handler, and every hang meant a reset and another warm entry. The reset routine and I were on first-name terms by the end of the night. The 16KB I did get matched nothing in the firmware image: 0% of it. Whatever happened to the firmware's memory between shutdown and boot ROM, it wasn't there any more.
So the prize is the register file, not the memory. The configuration, the clocks and the DQS value came from doing the least expected thing: booting the stock firmware first and then entering the boot ROM from a system that had just been running.
The extracted DDR parameters#
The ingenic-boot stage1 board configuration I wrote from it: values from the capture where the registers had them (the script's clock decode, the DQS value), the MDDR template everywhere else. CFG_CPU_SPEED holds the script's 288MHz, and DDR_DW32 is a guess the IPL contradicts in Part 4: bit 0 of the DDRC_CFG value it writes is set, a 32-bit data bus. Part 4 corrects some of the template timings too.
#define CFG_EXTAL 12000000
#define CFG_CPU_SPEED 288000000
/* MDDR configuration */
#define DDR_CS0EN 1
#define DDR_DW32 0 /* 16-bit data width (try 1 if fails) */
#define DDR_ROW 13
#define DDR_COL 9
#define DDR_BANK8 0
#define DDR_CL 3
/* Timing (ns) */
#define DDR_tRAS 45
#define DDR_tRTP 12
#define DDR_tRP 18
#define DDR_tRCD 18
#define DDR_tRC 60
#define DDR_tRRD 12
#define DDR_tWR 15
#define DDR_tWTR 1
#define DDR_tRFC 72
#define DDR_tMINSR 6
#define DDR_tXP 3
#define DDR_tMRD 2
// DDRC_DQS = 0x12B33333 (from HW calibration capture)
/* NAND configuration */
#define CFG_NAND_BW8 0 /* 16-bit bus */
#define CFG_NAND_PAGE_SIZE 2048
#define CFG_NAND_BCH_BIT 8
#define CFG_NAND_USE_PN 1 /* PN randomizer enabled */
That DDRC_DQS line is the one value you cannot get from a datasheet. It's the board's analog signature. Every other line is a clock my script misread or a template default; that one is this board's own calibration result.
What's next: from parameters to a NAND dump#
With DDR parameters in hand, the next steps are mechanical:
- Build ingenic-boot stage1 with the captured parameters
- Upload via USB boot, initialize DDR
- Upload stage2 to SDRAM
- Dump entire NAND with PN de-randomization
- Extract and decompile the IPL
- Provide DDR init code to the Rockbox port
On paper, the deadlock is broken. The chicken-and-egg resolved itself because the DDR controller doesn't know it's supposed to reset when the boot ROM takes over. It just sits there, fully configured, waiting for someone to read its registers.
Most of what I need was sitting in that controller the whole time.
Which makes award number two easy: Best Supporting Component goes to the DDR controller, for staying configured through a reboot nobody asked it to survive. The configuration came out of a register file that forgot to forget, and I'd like the record to show I only got here after trying the respectable paths first.
Filed under: reverse-engineering, mips, embedded, fiio, hardware
Read next#
- FiiO X1 NAND dump: hwstub, fee1:dead and a 64-bit compiler — the next part, where the "mechanical" steps listed above meet reality.
- FiiO X1 IPL decompiled — the IPL itself, and which of the parameters captured here it corrects.
Sources#
- ingenic-boot stage1 source (JZ4760) — DDR init reference code, register definitions
- Rockbox FiiO X1 port thread — DDR blocker discussion, hardware findings
- Rockbox packtools (firmware decrypt) — XOR decryption, SFHI container format
- JZ4760 Boot ROM Specification — USB vendor protocol
- JZ4760 Board Design Guide — DDR controller registers, clock tree
- Ingenic JZ4760 Programming Manual (PDF, mirror of Ingenic's legacy FTP) — CPPCR field table (PLLOD
10= divide by 4), PLL output formula, CPCCR and DCFG field layouts - 4PDA FiiO X1 thread — Custom firmware community, hardware revisions
- Head-Fi X1 custom firmware thread — Firmware structure, XOR key discovery