The project started with a small end-to-end goal: take an image file from removable storage and draw it on a screen, with the Pico responsible for every step. That simple path expands into several layers: two independent SPI buses, SD-card initialization, block reads, a FAT32 parser, directory traversal, image conversion, and a display pipeline that has to work within tight RAM limits.
Hardware and pin map
The Pico talks to the ILI9341 display on SPI0 and the SD-card module on SPI1. The LCD connection is write-only in this setup, so it does not use MISO. UART provides a 115200-baud serial log, the onboard LED is a status indicator, and three buttons provide UP, DOWN, and ENTER input. Each switch connects its GPIO to ground and uses the RP2040's internal pull-up.
| Signal | Connection | Purpose |
|---|---|---|
| SPI0: SCK, MOSI, CS, D/C, RESET, BL | GP16–GP21 | ILI9341 display control and pixel data |
| SPI1: SCK, MOSI, MISO, CS | GP10–GP13 | SD-card module |
| UART0 TX/RX | GP0/GP1 | 115200 8N1 diagnostic log |
| Onboard LED | GP25 | Status |
| UP / DOWN / ENTER | GP14 / GP15 / GP22 | Button input with internal pull-ups |
The SD module exposes only CS, SCK, MOSI, MISO, VCC, and GND. There is no card-detect signal, so the card must be inserted at boot and firmware cannot detect a card being removed. Touch input is deferred: an XPT2046-family controller would share SPI0 with the LCD and add bus arbitration.
Clocks, SPI, and the FIFO constraint
The 12 MHz crystal feeds the PLL, which produces a 125 MHz system clock. The SPI peripheral is PL022-class, full duplex, and has transmit and receive FIFOs only about eight entries deep. Every transmitted byte also creates a received byte. If the receive FIFO is not drained, it fills and the peripheral stalls.
The SPI clock is derived from the peripheral clock and divider settings. The useful divider points considered here are:
| Divider | SPI clock |
|---|---|
| 12 | 10.42 MHz (current setting) |
| 10 | 12.5 MHz |
| 8 | 15.625 MHz |
| 5 | 25 MHz |
| 2 | 62.5 MHz theoretical ceiling |
The SD bus has to start slowly: cards require an initialization clock in the 100–400 kHz range before switching to the normal transfer rate. The current SD setting is 12.5 MHz, below the 25 MHz SPI-mode limit. The ILI9341 write-cycle figure is 100 ns (10 MHz), so the current 10.42 MHz display clock is marginally above that nominal figure; clock selection is being kept conservative, around 12.5–15 MHz at most for the system's chosen settings.
The peripheral supports frames from 4 to 16 bits. In 8-bit mode a pixel needs two writes; in 16-bit mode one write carries the whole RGB565 pixel, with the peripheral sending the most-significant byte first as the panel expects. Batching work matters because a branch and status check for every byte leaves most of the FIFO unused.
Talking to the SD card
The card's native protocol is switched into SPI mode at each boot. Initialization sends CMD0 to reset the card, CMD8 to check voltage and version, loops through CMD55 and ACMD41 until the card is ready, reads the OCR with CMD58, and sets a 512-byte block length with CMD16. The CCS bit in the OCR identifies SDHC block addressing, so the filesystem code works in logical block addresses rather than byte offsets.
A single-block read uses CMD17. Each command is followed by response polling, dummy 0xFF clock bytes as needed, a 0xFE data token, 512 bytes, and CRC bytes. Reads work, and an integrity check reads each block twice and compares the results. The major performance cost is not just wire time: each CMD17 also pays the card's internal access latency.
For long sequential reads, CMD18 can request multiple blocks in one transaction and stream them with less per-block latency. The host must keep chip select asserted through the transfer and stop the stream with CMD12. This is a key optimization candidate for image data.
Building FAT32 from sectors
An SD card presents sectors; it does not know about files. The firmware parses the partition and filesystem structures itself:
- MBR: read block 0 and inspect its four 16-byte partition entries beginning at offset 446. The FAT32 LBA partition type is 0x0C; its first-LBA field locates the volume.
- BPB / volume boot record: read bytes-per-sector, sectors-per-cluster, reserved-sector count, number and size of FATs, root cluster, and volume serial number.
- Data-region arithmetic: data start is reserved sectors plus the number of FATs multiplied
by FAT size. A cluster maps to
data_start + (cluster - 2) * sectors_per_cluster; clusters begin at number 2 because the first FAT entries are reserved. - FAT chain: each cluster's 32-bit FAT entry uses its low 28 bits. Values at or above 0x0FFFFFF8 mean end-of-chain; 0x0FFFFFF7 marks a bad cluster. One 512-byte FAT sector contains 128 entries.
- Directory entries: directories are chains of 32-byte records. A first byte of 0x00 ends the listing, 0xE5 marks a deleted entry, and attribute 0x0F marks a long-filename record. The short entry provides the starting cluster and file size.
Long filenames are stored in records before the corresponding 8.3 entry, in reverse physical order and 13 UTF-16 characters per record. The parser reassembles the pieces and checks their checksum against the short name. If validation fails, it falls back to the 8.3 filename.
FAT32's root is an ordinary cluster chain rather than a fixed directory region. That keeps directory traversal consistent: opening a subdirectory and opening root use the same basic operation. The UI can open a file directly from the selected entry's starting cluster, so it does not need a path parser.
One bug surfaced in the recursive directory walker. Static local buffers for directory sectors, FAT data, and LFN assembly were shared by every recursion level. A child directory read overwrote the parent's sector while the parent was still iterating. Moving these buffers into a depth-indexed scratch pool gives each active level its own state and allows the recursive walk to finish correctly.
Driving the ILI9341
The 240 × 320 panel is initialized with a command table, configured for 16-bit RGB565 pixels, and oriented through MADCTL settings for color order and rotation. The display uses separate command and data phases: D/C low for a command, high for parameters or pixel data. A region is drawn by setting its column and page window with 0x2A and 0x2B, then streaming pixels with 0x2C.
Sleep-out needs roughly 120 ms, in addition to other power-up delays. The driver initializes once rather than repeating those delays. The panel also supports hardware scrolling: a list can be scrolled within a defined region and only the row that wraps needs repainting.
Streaming images without a framebuffer
A full 240 × 320 RGB565 frame is 153,600 bytes, about 58% of the Pico's 264 KB SRAM. Reserving that much memory would leave too little room for the rest of the firmware, stacks, and useful transfer buffers. The display path therefore streams data from the card to the panel instead of storing a complete frame.
| Memory item | Size |
|---|---|
| RP2040 SRAM | 264 KB (256 KB main + 8 KB scratch) |
| Full RGB565 frame | 153,600 bytes |
| SD sector buffer | 512 bytes |
| Directory sector buffer per depth | 512 bytes |
| LFN buffer | 261 bytes |
| 240-pixel-wide, 16-row buffer | 7,680 bytes |
A PC-side converter currently turns JPEG images into headerless RGB565 data. The Pico reads the raw file a sector at a time and sends its pixels to the LCD; an end-to-end image transfer from a roughly 300-block file works. The current frame time is about one second.
Where the frame time goes
A frame contains 153,600 bytes, or 1,228,800 bits. At 10.42 MHz, the ideal display wire time alone is about 118 ms; at 12.5 MHz it is about 98 ms. Reading 300 sectors at 12.5 MHz adds roughly 98 ms of wire time, plus card access latency for each single-block CMD17 request. A realistic serial path is around 216 ms, while overlapping SD work on one core with LCD work on the other could approach 120 ms.
That leaves most of the measured second in software and transfer overhead rather than raw bus bandwidth. Draining the receive FIFO, batching writes, using 16-bit SPI frames, trying multi-block reads with CMD18, and considering DMA are more promising than simply raising the clock. Serial diagnostics also cost time: at roughly 87 microseconds per character, a 1,400-character startup log can consume about 120 ms.
From bring-up to a usable explorer
The work has progressed from clocks and GPIO pads, to both SPI buses, display initialization, SD initialization and block reads, FAT32 parsing, root listing, recursive directory walking, and finally image streaming. The filesystem is browsable from diagnostics today; the next milestone is the on-device button-driven file explorer.
The UI will not reuse the recursive walker. The walker is designed to visit everything and return; an explorer must show one directory, move a cursor, enter a folder, and later return to the exact previous selection. The planned UI keeps a small breadcrumb stack per directory level, recording the cluster, cursor index, and scroll offset. Since root is a normal directory chain, it needs no special case.
The panel refresh cost makes partial redraws important. Moving the cursor should repaint only the old and new rows, roughly 12 ms at the current display clock; a full redraw costs the whole frame transfer plus card and text work. Button handling will debounce for about 20–30 ms, then support a delayed repeat. A small event queue and state machine keep input handling separate from drawing.
Opening a selected file routes through a small extension-to-handler table. Raw RGB565 already works. BMP can be streamed by reading its header and converting bottom-up BGR rows; text can use a paged viewer. JPEG decoding is deferred: it would require Huffman decoding, dequantization, IDCT, and upsampling on a chip without an FPU. Keeping the file layer independent lets a decoder arrive later without redesigning the UI.
Current status and next work
- Register-level clock, GPIO, and SPI bring-up; both SPI buses are operational.
- SDHC initialization and block reads work, including a read-twice comparison integrity check.
- MBR/BPB parsing, cluster-to-LBA mapping, FAT traversal, LFN checksum validation, 8.3 fallback, root listing, and the recursive walker work.
- The ILI9341 initializes, renders fonts and solid regions, and accepts an image streamed from the SD card.
- The PC-side JPEG-to-RGB565 conversion works; on-device JPEG decoding and the button-driven explorer remain future steps.
Immediate performance work is to measure the SD and LCD paths separately, fix any receive-FIFO stalls, reduce noisy logging, avoid duplicate initialization, and evaluate CMD18. Then the explorer can be built in stages around the file layer, with dirty-rectangle rendering and button events.
← Back to all posts