FiiO X1 Rockbox bootloader: 12 KB of SD, FAT32 and a menu
Contents
The first critical finding in the code review said my bootloader would jump into the firmware it had just loaded and execute whatever the instruction cache happened to remember instead. The code compiled. It had a boot menu, an SD card driver and a FAT32 reader, and it would have crashed on the one line that mattered, silently, on a device with no debugger.
The device is the FiiO X1, a $99 music player on an Ingenic JZ4760B MIPS chip. Part 12 replaced its equalizer presets by patching floats inside the stock firmware, and it worked, for exactly nine presets, because the firmware has nine slots. More slots would mean finding the menu code in Ghidra and patching loop bounds and arrays, and every feature after that would cost the same again. The alternative is Rockbox, the open-source player firmware, and Rockbox needs a way onto the device. This part is that way in: a small bootloader that runs from the SD card, offers a menu, and loads a Rockbox image from the card into RAM.
What factory.x1 hands you#
The X1's second-stage loader, the SPL, has a recovery path (Part 5, Part 11): if a file named factory.x1 sits on the SD card, it reads it, XOR-descrambles it with a fixed key and jumps to 0x80001000. No signature, no hash, no CRC.
By then, according to my notes on the SPL, the hardware is in this state:
- DDR initialized by the IPL, the 8 KB first stage decompiled in Part 4
- the LCD up, 320x240, with the boot logo in a framebuffer whose address sits in the
LCD_SA0register - UART1 configured, at 57600 baud
- the SD controller, MSC0, just used to read the file
- caches flushed and invalidated before the jump
Nothing I've written for this path has run on the device yet. The NAND dumper from Part 6 was a factory.x1 payload too, and it never ran. So the bootloader starts with no proven ground under it.
The plan was four phases: a boot menu with LCD and buttons, SD reads plus FAT32, a minimal Rockbox kernel stub, the full port. This part is the first two. The result: fourteen source files, 1,582 lines, 12,512 bytes.
Entry: a stack and a cleared BSS#
The startup code, from entry.S:
_start:
li $sp, 0x80800000
/* Clear BSS */
la $t0, __bss_start
la $t1, __bss_end
1: beq $t0, $t1, 2f
nop
sw $zero, 0($t0)
addiu $t0, $t0, 4
j 1b
nop
2:
j main_entry
nop
Set the stack pointer, zero the BSS, jump to C. The nops fill the MIPS branch delay slots. The BSS loop is itself a review fix: the first version didn't clear it, and C code that trusts its zeroed globals gets whatever the SPL left in that memory.
There is no libc. The first build failed because GCC had quietly turned my little str_len loop into a call to strlen, a function that doesn't exist here; -fno-builtin stopped it being helpful.
Reading the SD card with CMD18#
The Part 6 dumper only ever wrote to the card, with CMD25, multi-block write. The bootloader needs the opposite, CMD18, multi-block read, through the same MSC0 controller.
The difference that bites is the FIFO. The first version of the read loop polled DATA_FIFO_EMPTY, which is the wrong question for a receive. The review caught it before any hardware could. The loop now waits for RXFIFO_RD_REQ and drains 16 words per request:
/* Read 16 words from FIFO (matches RXFIFO_RD_REQ threshold) */
for (j = 0; j < 16 && cnt > 0; j++) {
*buf++ = MSC_RXFIFO;
cnt--;
}
A sector is 128 words, eight of those bursts. The same review pass found that a timeout on DATA_TRAN_DONE fell through as success, and now it aborts.
FAT32 from the boot sector down#
No libc also means no fopen. To find ROCKBOX.BIN the bootloader walks the card's structures itself:
MBR (sector 0)
→ partition table entry at offset 446
→ partition LBA start
→ BPB (BIOS Parameter Block)
→ FAT location, cluster size, root directory
→ root directory scan (8.3 filename match)
→ starting cluster + file size
→ cluster chain via FAT table lookups
→ data sectors → buffer
Each step reads a sector and pulls fields out at fixed offsets: sect_u16(11) is bytes per sector, sect_byte(13) sectors per cluster, sect_u16(14) reserved sectors. Those offsets date from MS-DOS and nobody has dared move them since.
The directory scan compares 11-byte 8.3 names, "ROCKBOX BIN", space-padded and uppercase. No long names, no subdirectories:
static int name_match(const u8 *dir_name, const char *target)
{
int i;
for (i = 0; i < 11; i++) {
if (dir_name[i] != (u8)target[i])
return 0;
}
return 1;
}
FAT16 keeps its root directory in a fixed region; FAT32 makes it a cluster chain that starts at the cluster number in BPB offset 44. Once there's a starting cluster, following the chain is the same for both. The read has two paths: whole clusters go straight from the card into the load address, and anything partial goes through a 512-byte sector buffer and gets copied byte by byte. That review round also asked for the whole-cluster path, which cuts the number of SD commands by the cluster size.
The SD driver is 278 lines and the FAT driver 346: a read-only filesystem in 624 lines of C with no dependencies.
The cache flush before the jump#
Back to the finding this post opened with.
The bootloader reads ROCKBOX.BIN to 0x80100000, in KSEG0, the cached, unmapped region. The driver's stores, *buf++ = MSC_RXFIFO, land in the data cache, and there is no promise they reach RAM before the read returns. Then the bootloader jumps to 0x80100000, and instruction fetch goes through the instruction cache, which has never seen the new code and may still hold lines from before. The two caches don't talk to each other.
The tempting fix is sync. On MIPS32, sync orders memory accesses; it doesn't write back or invalidate a single cache line. The fix is the CP0 cache instruction, by index, over every line:
u32 addr;
/* Index Writeback Invalidate D-cache (op 0x01) */
for (addr = 0x80000000; addr < 0x80004000; addr += 32)
__asm__ volatile("cache 0x01, 0(%0)" : : "r"(addr));
/* Index Invalidate I-cache (op 0x00) */
for (addr = 0x80000000; addr < 0x80004000; addr += 32)
__asm__ volatile("cache 0x00, 0(%0)" : : "r"(addr));
The JZ4760B has 16 KB of data cache and 16 KB of instruction cache with 32-byte lines, so each loop makes 512 passes and touches every index. Write back and invalidate the data side so the loaded image is in RAM; invalidate the instruction side so the first fetch at 0x80100000 has to go to RAM for it. The SPL does the same two steps before it jumps to the stock firmware.
Memory layout#
Bare-metal memory management is a linker script, a few #defines, and the discipline not to overlap:
0x80000000 ┌─────────────────────┐
│ (below load address)│ 4 KB
0x80001000 ├─────────────────────┤
│ bootloader │ 12,512 bytes
│ (.text .rodata .bss)│
├─────────────────────┤
│ (unused) │
0x80100000 ├─────────────────────┤
│ ROCKBOX.BIN load │ up to 7 MB
│ target │
│ ... │
│ stack (grows down │
│ from 0x80800000) │
0x80800000 ├─────────────────────┤
│ (unused) │
0x80F00000 ├─────────────────────┤
│ SECT_BUF │ 512 bytes
└─────────────────────┘
0x80001000 is where the SPL puts any factory.x1. The stack starts at 0x80800000 and grows down into the top of the load region, and the loader refuses anything over 7 MB. That cap ends exactly where the stack starts, so a file right at the limit would still run into the stack the loader is using while it reads.
SECT_BUF, the FAT driver's scratch sector, is where a quieter bug lived. The first version put it at 0x80300000, which is inside the load region for any file larger than 2 MB: loading a big enough ROCKBOX.BIN would have overwritten the buffer the loader was reading it through. Now it sits at 0x80F00000, above everything the loader can write.
Boot menu and buttons#
Two choices, Rockbox or the original firmware, with PREV/NEXT to move, SELECT to confirm, and a five-second timeout that picks Rockbox.
The SPL already set up the LCD, so the bootloader reads the framebuffer address from LCD_SA0, converts it to KSEG1, the uncached alias, and draws directly. Text is an 8x8 bitmap font, ASCII 32 to 126: 95 glyphs, one byte per pixel row, 760 bytes.
Buttons are GPIO reads, eight pins, active low. You enter factory.x1 with SHORTCUTS and power held down, so the first thing the menu does is wait for every button to be released. After that it reacts to presses, not to held buttons, and the 100 ms polling interval doubles as debounce.
Why it can't brick the player#
factory.x1 only runs when the file is on the card during an update-mode boot. Pull the card and the X1 boots its stock firmware from NAND. The bootloader has no NAND write code at all. It carries the dumper's SD write function, but nothing calls it, so the loader path only reads.
If it crashes: power off, pull the card. If ROCKBOX.BIN is broken: same. If the FAT driver can't mount the card, the screen says so and the loader halts. The "Original FW" entry doesn't try anything clever, no jump back into the SPL, no NAND read. It prints Remove SD card and and reboot device and stops. The safest recovery is the one you do with a fingernail.
What the 12,512 bytes contain#
scripts/rockbox-bootloader/
├── jz4760b.h 120 lines — register definitions, GPIO pin maps
├── uart.h 42 lines — UART debug output, header-only
├── delay.h 19 lines — udelay/mdelay busy loops
├── buttons.h 42 lines — 8 GPIO buttons (active-low, edge detect)
├── lcd.h + lcd.c 231 lines — framebuffer, 8x8 font, text rendering
├── sd.h + sd.c 292 lines — SD card init + CMD18 read + CMD25 write
├── fat32.h + fat32.c 378 lines — FAT16/FAT32 read-only filesystem
├── bootloader.c 339 lines — boot menu, file loader, cache flush, jump
├── entry.S 26 lines — MIPS32 startup (BSS clear, stack)
├── bootloader.ld 19 lines — linker script (entry 0x80001000)
└── Makefile 74 lines — cross-compile + XOR scramble
Two of those headers carry numbers I'd now correct. jz4760b.h says UART1 is already initialized at 115200; the SPL's own kernel command line string says 57600n8, and so did the IPL in Part 4. The UART code never sets a baud rate, so the comment is wrong and nothing depends on it. delay.h says calibrated for ~288MHz, and Part 5 found the IPL programming PLL0 for 384 MHz. The loop is a busy count, approximate either way, but the comment is the wrong half of that pair.
The SPL that loads all this is 1 MB, with NAND drivers, USB fastboot, an LCD driver, update logic and a FAT driver of its own. The stock firmware is 4.1 MB of µC/OS-II. The bootloader shows a menu, loads a file and jumps.
Rule five, and what's next#
That earns the series its fifth rule. Rule five: code that has never run has a size, not a status. This one has a size, 12,512 bytes, and a review that found four critical bugs and a handful of lesser ones, all fixed. It hasn't touched the hardware. The cache flush, the FIFO handshake, the cluster chain and the button edges all still have to survive real silicon, and by rule five I don't get to call any of them working until they have.
After that comes the Rockbox kernel stub, and its hard part is audio: the PCM5142 DAC needs configuring, the audio controller needs clocks, and samples need to reach it. Part 14 starts there, still without running anything, which by now you can tell I've made a habit of.
Filed under: reverse-engineering, mips, embedded, fiio, hardware, rockbox
Read next#
- Bare-metal audio for the X1: four GPIO offsets, all wrong — the audio stub, and the register header that nearly sank it.
- The X1's bootloader at the right base address — where the
factory.x1path and the SPL's hand-off state come from. - 128 MB over USB, one block at a time — the earlier
factory.x1payload that never ran.
Sources#
- Rockbox — open-source firmware for digital audio players
- Microsoft FAT specification (fatgen103) — BPB layout, cluster chains, directory entries
- Rockbox JZ4760 tools (packtools) — the XOR scramble for building
factory.x1 - MIPS32 Instruction Set Manual, Vol. II-A, rev. 6.04 (PDF, archived copy) — the CACHE instruction, Index Writeback Invalidate, Index Invalidate, SYNC
- Ingenic X-Boot — the SDK the X1's SPL is built from
- FiiO X1 review (Headfonics, 2014-10-07) — launch price, $99