FiiO X1 IPL decompiled: 8 KB of JZ4760B DDR init code

Every time a FiiO X1 boots cold, the first code it reads from flash prints its own build date to a serial port that nothing is connected to. That code is the IPL, the initial program loader of this Ingenic JZ4760B-based music player, and it holds the RAM setup that the developer of the Rockbox port (open-source music-player firmware) wrote on its forum he couldn't find. Part 3 dumped it from NAND flash with Rockbox's hwstub debug stub. Now it goes into Ghidra, the NSA's open-source decompiler, function by function.

The IPL is the first code the JZ4760 executes from NAND on every cold boot. Before DDR. Before clocks. Before anything the main firmware touches. It sets up the PLL (the chip's clock generator), brings up the DDR controller, reads the SPL, the second-stage loader, from NAND, and hands off. This one was built in October 2014. I couldn't find a published decompilation of it anywhere, which for 8 KB of code felt less like an oversight than a dare.

Load nand_ipl.bin into Ghidra. MIPS:LE:32. Base address 0x80000000. Run auto-analysis.

21 functions across 8192 bytes. Clear boundaries, a handful of strings, no obfuscation. The XOR scramble on sys.bin, the main firmware image from Part 2, doesn't apply here — the IPL lives on raw NAND, loaded by the boot ROM which doesn't know the XOR key. The IPL is plaintext. After three parts of this hardware refusing me things, 21 clean functions felt like a trap. It wasn't. Some things are just plaintext.

The IPL memory map#

0x0000-0x009F  160 bytes   NAND parameter table (bus width, page size, ECC config)
0x00A0-0x019B   252 bytes   Entry point + warm resume logic + early init
0x019C-0x096B  ~2KB        NAND read for the SPL + BCH-8 ECC
0x09E8-0x0B03   284 bytes   UART1 initialization + serial helpers
0x0C00-0x0D63   356 bytes   sdram_init: PLL reconfig + DDR pre-init
0x0D64-0x0E17   180 bytes   gpio_init: UART/NAND pin mux
0x0EF4-0x116B   632 bytes   ddr_dma_test: DMA-based memory verification
0x116C-0x1313   424 bytes   ddr_set_dqs_and_init: DDRC register programming
0x1314-0x1A6F  ~1.8KB      load_spl_from_nand: timing calc + DQS calibration
0x1C40-0x1C63    36 bytes   DDR config table (9 × u32)
0x1CA0-0x1DFF   352 bytes   Strings + NAND parameter table

Roughly 3KB of the 8KB image is DDR initialization. Close to half the IPL exists to wake up RAM.

The warm resume path#

Entry point at offset 0xA0. The first five instructions decide between a cold boot and a resume:

800000a0:	3c08b000 	lui	t0,0xb000
800000a4:	35080008 	ori	t0,t0,0x8
800000a8:	8d090000 	lw	t1,0(t0)
800000ac:	31290004 	andi	t1,t1,0x4
800000b0:	1520000e 	bnez	t1,800000ec <_binary_dumps_nand_ipl_bin_start+0xec>

If bit 2 of 0xB0000008 is set, execution lands here:

800000ec:	3c08b302 	lui	t0,0xb302
800000f0:	35080050 	ori	t0,t0,0x50
800000f4:	3409ff00 	li	t1,0xff00
800000f8:	ad090000 	sw	t1,0(t0)
800000fc:	3c08b000 	lui	t0,0xb000
80000100:	35080034 	ori	t0,t0,0x34
80000104:	8d090000 	lw	t1,0(t0)
80000108:	3c088000 	lui	t0,0x8000
8000010c:	01094025 	or	t0,t0,t1
80000110:	01000008 	jr	t0

0xB0000008 is CPM_RSR, the clock and power module's reset status register; ingenic-boot's jz4760.h names bit 2 CPM_RSR_P0R. With it set, the IPL skips cold DDR initialization entirely: it writes 0xFF00 to 0xB3020050 (DDRC_PMEMPS0 in the same header, which says nothing more about it), reads a resume address from the CPM scratch-pad register at 0xB0000034, ORs in 0x80000000 and jumps straight back into the suspended firmware.

Part 2's warm entry was a different trick: enter USB boot from running firmware, so DDR never goes through a cold reset. But it touched the same register. Before reading SDRAM over USB I had written 0xFF000000 to 0xB3020050, a value lifted from the stock firmware's own resume code, as Part 2 says in passing. The IPL writes 0x0000FF00: the same sixteen set bits, in the other half of the word. 0xFF000000 was enough for the 16 KB SDRAM reads I got; the IPL's value I never sent over USB, so I can't say the controller prefers one. What I can say is that the IPL's value had been sitting in flash the whole time. I'd love to claim I knew. I did not know.

The UART nobody mentioned#

uart1_init at 0x9E8 programs UART1 at 0xB0031000. The middle of it:

80000a18:	24020003 	li	v0,3
80000a1c:	24030010 	li	v1,16
80000a20:	a0a20000 	sb	v0,0(a1)
80000a24:	a0e30000 	sb	v1,0(a3)
80000a28:	a1000000 	sb	zero,0(t0)
80000a2c:	90a20000 	lbu	v0,0(a1)
80000a30:	34841000 	ori	a0,a0,0x1000
80000a34:	304200ff 	andi	v0,v0,0xff
80000a38:	34420080 	ori	v0,v0,0x80
80000a3c:	3043007f 	andi	v1,v0,0x7f
80000a40:	304200ff 	andi	v0,v0,0xff
80000a44:	a0a20000 	sb	v0,0(a1)
80000a48:	2402000d 	li	v0,13
80000a4c:	a1200000 	sb	zero,0(t1)
80000a50:	a0820000 	sb	v0,0(a0)
80000a54:	24020017 	li	v0,23
80000a58:	a0a30000 	sb	v1,0(a1)
80000a5c:	a1420000 	sb	v0,0(t2)

The base registers are loaded a few instructions earlier: a1 is the line control register (+0x0C), a3 is +0x24, t0 is +0x28, t1 is +0x04, a0 the divisor latch at +0x00 and t2 the FIFO control register (+0x08). So: line control 3 (8 data bits, no parity, 1 stop), 16 into +0x24 (UMR in ingenic-boot's header), divisor-latch access on, divisor 13, latch off, FIFOs on with 0x17. The divisor half is the same sequence as serial_init in Ingenic's public stage1 (board_4760.c), which hardcodes baud_div = (CFG_EXTAL / 16 / 57600);. From the 12 MHz crystal that is 13, and 12,000,000 / (16 × 13) ≈ 57,692 baud: 57600, not the 115200 I had scribbled next to this function at first. The SPL that runs next agrees; it still carries a leftover Linux command line from Ingenic's X-Boot with console=ttyS1,57600n8 in it.

The IPL initializes UART1 and immediately starts logging. The tail of strings nand_ipl.bin:

Bad block value:
BCH_INTS_ERR
Uncorrectable
Serial ok Time:
Oct  9 2014
12:22:16
Sdram initted ...
Starting SPL ...
Freq:
Jump to SPL
IPL: 
DDR INIT ERROR: can't find a suitable mask delay.

Every cold boot of this X1 sends the build banner and the progress lines down UART1: port D pins 26 and 28, 57600 baud. The IPL logs its own build timestamp every time.

The error string is the interesting one. If DQS calibration fails — none of the 64 combinations passes the DMA test — the IPL prints "DDR INIT ERROR: can't find a suitable mask delay." and then does this:

800019e0:	3c048000 	lui	a0,0x8000
800019e4:	0c0002b2 	jal	80000ac8 <_binary_dumps_nand_ipl_bin_start+0xac8>
800019e8:	24841d64 	addiu	a0,a0,7524
800019ec:	0800067b 	j	800019ec <_binary_dumps_nand_ipl_bin_start+0x19ec>
800019f0:	00000000 	nop

0x1D64 is the error string. The instruction after the print jumps to itself. That is the whole error handling: say why, then spin forever.

The X1 just sits there. The only place the reason goes is UART1, and UART1 isn't connected to anything user-accessible. FiiO's factory probably has a test jig that reads it.

That earns this series its second rule. Rule two: an error message printed to a port nobody can reach counts as a comment.

The DQS calibration loop#

Every function name in this listing is mine; Ghidra names nothing. The one I called load_spl_from_nand turned out not to load the SPL at all: c_main reads the SPL afterwards with the separate NAND routine at 0x19C. Its roughly 1,800 bytes are DDR timing math and a calibration loop.

The loop walks 4 values of tsel (0–3) against 16 of msel (0–15): 64 combinations. For each, it reinitializes the DDR controller with that pair, runs a DMA-based memory test and records pass or fail. Then it takes the midpoint of the passing range and initializes DDR one last time with those parameters.

The value 0x12B33333 — the DDRC_DQS register from Part 2, the one Part 2 said no datasheet could give you and only this specific PCB could — is found this way. Not factory-calibrated and burned into flash. Recalibrated on every cold boot by this loop. I called it the board's analog signature in Part 2. It is, but the board signs again every time it boots cold.

Every cold boot: 64 DDR controller reinitializations, 64 DMA tests, pick the middle, and only then does the IPL read the SPL, the stage that draws the boot logo, out of NAND.

The DMA test#

ddr_dma_test fills SDRAM with a pattern computed from each word's index (the index times 0x5A5A5A5A on odd words, times 0xA5A5A5A6 on even ones), DMA-copies it to another region, and compares. The MDMAC (memory DMA controller) at 0xB3030000, from my annotated decompile:

*(volatile uint32_t *)0xB3030000 = src_phys;
*(volatile uint32_t *)0xB3030004 = dst_phys;
*(volatile uint32_t *)0xB303000C = 8;        /* request source */
*(volatile uint32_t *)0xB3030008 = 0x80;      /* transfer count */
*(volatile uint32_t *)0xB3030014 = 0xC00402;   /* DMA command */
*(volatile uint32_t *)0xB3030010 = 0x80000001; /* enable + start */
*(volatile uint32_t *)0xB3030300 = 1;          /* global enable */

/* Wait DMA complete */
while (*(volatile uint32_t *)0xB3030010 != 0);

Wrong DQS delay → read/write margins violated → DMA comparison fails. Right delay → DMA succeeds → pattern matches. The calibration loop exploits this directly.

The CPUID branch: JZ4760 or JZ4760B#

uint32_t cpuid = *(volatile uint32_t *)0xBFC0132C;
if (cpuid != 0x352920E4)  // JZ4760 (non-B)
    clkgr &= 0x3DDEFFFC;
else                       // JZ4760B
    clkgr &= 0xBDDEFFFC;

The IPL reads CPUID from the boot ROM's constant table at 0xBFC0132C and distinguishes JZ4760 from JZ4760B. The FiiO X1 reports 0x352920E4 — JZ4760B. The two variants get different clock-gate masks: only the non-B path also clears bit 31.

This settles what the "JZ4760V1" string in Part 1 left ambiguous: the chip is JZ4760B. The "V1" was a boot ROM version string, not a chip revision. The branch itself comes from Ingenic: the public ingenic-boot stage1 makes the same test (if(cpu_id == 0x352920e4), commented // for jz4760B), and FiiO's IPL inherited it.

The DDR configuration table#

At offset 0x1C40, 9 × u32:

{0, 0, 1, 2, 3, 0, 4, 0, 5}

The sdram_init function copies this to the stack. My notes guessed at the fields (chip selects, bus width, bank, row and column codes) but never pinned them down, so the table stays a row of numbers here.

The value that matters comes later: every DDR init writes 0xB569 to DDRC_CFG (li v0,0xb569 at 0x1A9C, stored to 0xB3020004). Read with ingenic-boot's own MDDR config builder (jz4760_mddr.c), that is MDDR, 13 row bits, 9 column bits, CAS latency 3, 4 banks, chip select 0 enabled, and bit 0 set, which that code calls DDR_DW32: a 32-bit data bus. By the same file's size formula, 2^(13+9) × 4 bytes × 4 banks is 64 MB. The IPL doesn't take a formula's word for it either: it probes the memory size at run time before it programs the address map.

Part 2 captured DDRC_CFG = 0xA0020000 from the live registers. A completely different value. That capture was taken after the firmware had put DDR into self-refresh (Part 2's timing registers read as zero), so 0xA0020000 records the register at that moment; 0xB569 is what the IPL writes at init.

The parameters I derived in Part 2 were close but not authoritative. The IPL has the truth. Rule one, back in Part 1, said the chip's own code beats the chip's documentation. It turns out to beat my own captures too, which I took less gracefully.

The DDR timing registers, corrected#

The timing calculation in load_spl_from_nand hardcodes these values in picoseconds:

tRAS = 42,000ps  (42ns)
tRP  = 18,000ps  (18ns)
tRCD = 18,000ps  (18ns)
tRC  = 60,000ps  (60ns)
tRRD = 12,000ps  (12ns)
tWR  = 15,000ps  (15ns)
tRTP = 12,000ps  (12ns)
tRFC = 110,000ps (110ns)

Then it converts them to clock cycles at the memory clock it computes from the CPM's PLL settings, packs them into the 32-bit DDRC_TIMING1 register, and writes it.

Part 2 extrapolated tRFC = 72ns from JEDEC specs for a generic MDDR chip. The IPL says 110ns — 53% higher. Part 2 had tRAS = 45ns; the IPL hardcodes 42ns. The JEDEC extrapolation got me into the right neighborhood. The IPL has the exact numbers — probably from the datasheet of a RAM chip whose markings match no public one.

The Rockbox port needed these values. They've been sitting in this 8KB binary since October 2014.

IPL build date: October 9, 2014#

The IPL was compiled on October 9, 2014, at 12:22:16. It logs this timestamp on every cold boot.

21 functions. 8192 bytes. Build timestamp visible. Every DDR timing parameter, every calibration step, every clock configuration — the complete picture of what happens between power-on and the jump to the SPL.

And it tells that whole story, every cold boot, to a UART with nothing attached. Thoroughly documented, to an audience of nobody. Rule two exists for a reason.

Part 5 covers the other side: what runs after the IPL, and how to get your own code there without soldering anything.

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

Sources#