I had a board booting XIP from a Winbond W25Q128JV and no idea whether the flash was
running anywhere near its limit. The FlexSPI config had been copied from an SDK example,
serialClkFreq said 133MHz, and everything worked. That is exactly the situation where
you assume you are fine and you are not.
So I measured it. The read path turned out to be at 97% of its theoretical ceiling — done, nothing to tune. The write path reported a number so good it was obviously wrong, and chasing that down was the more useful half of the exercise.
In this article I show how FlexSPI actually gets configured on the RT1062, how to compute the read ceiling and check you are at it, and why write speed must be verified against the flash datasheet rather than against the bus.

The whole result in five lines. Read is maxed. The write number is the one to be suspicious of — see step 5.
TL;DR
tPP — verify against the datasheet, not against the FlexSPI clock.
This board hits ~518 Mbps read (97.4% of theoretical) at 132.9 MHz quad SDR, which is
maxed out. The write number needed a second look.Platform
| MCU | NXP i.MX RT1062 (Cortex-M7 @ 600 MHz) |
| Flash | Winbond W25Q128JVFIQ — 128 Mbit (16 MB) QSPI NOR |
| Controller | FlexSPI (chapter 27, i.MX RT1060X Reference Manual) |
| Mode | Quad SDR, XIP (execute-in-place) from 0x60000000 |
Step 1 — Know which of the two paths you are on
FlexSPI sits between the AHB bus and up to two SPI channels (port A / port B), each with two chip-selects. It exposes two independent paths into the same LUT-driven sequence engine, and almost every FlexSPI bug comes from confusing them:
flowchart LR
CPU["Cortex-M7"] -->|"memory-mapped read/write<br>0x60000000"| AHB["AHB path<br>(XIP, transparent)"]
CPU -->|"IPCR0/IPCR1 + IPCMD.TRG"| IP["IP path<br>(software-triggered)"]
AHB --> RXB["AHB RX buffer"]
AHB --> TXB["AHB TX buffer"]
IP --> FIFO["IP TX/RX FIFO<br>128 bytes each"]
RXB --> SEQ["LUT Sequence Engine"]
TXB --> SEQ
FIFO --> SEQ
SEQ -->|"SCLK, IO0-3, CS"| FLASH["W25Q128JV"]
- AHB path (XIP). The CPU reads the
0x60000000…window and FlexSPI silently runs the LUT read sequence, buffering through the AHB RX buffer. This is what code execution andmemcpy-style reads use. No status polling, fully transparent. - IP path. You explicitly write
IPCR0/IPCR1, setIPCMD[TRG], and push/pop bytes through the 128-byte TX/RX FIFOs. Required for erase and page-program, because those are explicit commands, not memory accesses.
Step 2 — Write the LUT, because there is nothing else
There is no read() or erase() register in FlexSPI. Every operation is a sequence of up
to 8 instructions (4 × 16-bit words) in the 64-entry LUT. Each instruction is
(opcode, pads, operand):
/* Quad I/O Fast Read (0xEB): CMD on 1 pad, address on 4 pads, 6 dummy, data on 4 pads */
[4 * NOR_CMD_LUT_SEQ_IDX_READ + 0] =
FLEXSPI_LUT_SEQ(CMD_SDR, FLEXSPI_1PAD, 0xEB, RADDR_SDR, FLEXSPI_4PAD, 0x18),
[4 * NOR_CMD_LUT_SEQ_IDX_READ + 1] =
FLEXSPI_LUT_SEQ(DUMMY_SDR, FLEXSPI_4PAD, 0x06, READ_SDR, FLEXSPI_4PAD, 0x04),
You are always programming a tiny bytecode interpreter, not calling a library. SDR vs DDR and Single/Dual/Quad/Octal are chosen per-instruction by the opcode and pad count — there is no global mode register. A single sequence can mix a 1-pad command with 4-pad data.
Opcodes come from the flash datasheet, not the FlexSPI manual
CMD_SDR, RADDR_SDR, pad counts). The actual
opcode byte (0x6B, 0xEB, …) is defined by Winbond. That is why you will not find 0x6B
in the RM even though it is a perfectly valid instruction — FlexSPI shifts out whatever byte
you put in the operand field.Step 3 — Pick the sampling clock, because it caps your frequency
This is the most-skipped part of the RM (§27.3.15), and it is the part that decides how fast
you are allowed to go. To read data back reliably at high speed, FlexSPI must sample the
incoming data at the right instant. It offers four RX clock sources via MCR0[RXCLKSRC]:
| RXCLKSRC | Source | Max freq | Notes |
|---|---|---|---|
| 0 | Internal dummy strobe, internal loopback | lowest | Boot-safe, no DQS pad needed |
| 1 | Internal dummy strobe, loopback from DQS pad | higher | What I use — compensates pad delay |
| 2 | SCK output loopback from SCK pad | ~ same as 1 | Rarely used |
| 3 | Flash-provided read strobe (real DQS) | highest | Needs a flash + board that route DQS |
For modes 0 and 1 the DLL (DLLxCR) is set to a fixed one-delay-cell value (0x00000100) —
no auto-lock. Mode 3 requires the DLL to lock to half the serial clock and is the only way
past ~133 MHz, but it needs a genuine DQS strobe from the flash.
Board reality check
FLASH_DQS pin is deliberately left floating — the note on the
sheet reads “FlexSPI_DQS PIN need floating for QSPI Flash RW @133MHz”. The W25Q128JV does
not drive a DQS output in standard QSPI mode anyway. So RXCLKSRC=3 is physically impossible
here, and mode 1 is the ceiling, not a compromise. Mode 1’s “DQS loopback” is an internal
pad-delay-compensation trick; the pad is never connected to the flash, which is exactly why
it must float.Step 4 — Configure for the W25Q128JVFIQ
Read LUT: use Quad I/O (0xEB), not Quad Output (0x6B)
Both are valid Winbond instructions. 0xEB (1-4-4) sends the address on 4 pads too,
versus 0x6B (1-1-4) which sends the address on 1 pad. Less protocol overhead per
transaction, same clock rating.
| Field | Value | Why |
|---|---|---|
readSampleClkSrc | LoopbackFromDqsPad (mode 1) | Only usable mode (DQS floats) |
| Read opcode | 0xEB Quad I/O Fast Read | Address on 4 pads → less overhead |
DUMMY_SDR | 0x06 (6 clocks) | Datasheet: mode byte (2) + 4 dummy = 6 |
serialClkFreq | 133MHz | Datasheet fC1 max at 3.0–3.6 V |
Why dummy = 6, not 8
A7-0 → M7-0 (mode byte, 2 clocks) → 4 dummy clocks =
6 total. Setting 8 is not wrong — the extra dummies just waste 2 clocks per transaction —
but it is slower for no reason. Setting fewer than 6 would corrupt reads. Note 11: the mode
byte M7-0 must read as Fxh; driving 0xAx enters continuous-read/XIP-enhanced mode.Clock tree
The FlexSPI serial root clock comes from PLL3_PFD0_CLK with FLEXSPI_PODF = 2, giving
≈132.9 MHz — measured live via CLOCK_GetClockRootFreq, which is the first line in the
log capture above. The serialClkFreq enum in the boot-header struct only selects a divider
the boot ROM uses transiently; the runtime frequency is set by clock_config.c. Keep both
pointing at the same target.
Write path: quad page program (0x32)
Erase and program go through the IP path with their own LUT slots — write-enable 0x06,
sector-erase 0x20/0xD7, quad page-program 0x32.
The #1 FlexSPI write bug
.rodata live in the 0x60000000 flash window, the CPU hangs the instant it tries
to fetch them mid-command. Put them in ITCM (AT_QUICKACCESS_SECTION_CODE) and make LUT
values compile-time immediates, not memory loads.Step 5 — Verify, and distrust the good news
Read and write have fundamentally different ceilings and need different verification.
Read: compare against the theoretical bus ceiling
For quad SDR the ceiling is pure math — no flash-specific mystery number:
$$ \text{ceiling} = f_{clk} \times 4\ \text{bits/cycle} = 132{,}923{,}076 \times 4 \approx 531.7\ \text{Mbps} $$Measured over a 128 KB sequential AHB read, timed with the DWT cycle counter:
| Read config | Measured | % of ceiling |
|---|---|---|
0x6B Quad Output | 512,999 kbps | 96.5% |
0xEB Quad I/O | 518,027 kbps | 97.4% |
The remaining ~2.6% is the one-time command + address + dummy prefix (~20 clocks) amortised over 128 KB. To prove 128 KB was a large-enough sample, sweep the benchmark length (4 KB → 32 KB → 128 KB → 1 MB) and confirm the throughput curve is already flat. If it is still climbing, your sample was too small and undersold the real number.
Note
fC1 (all instructions except plain 03h Read) at 133 MHz at
3.0–3.6 V. We are at 132.9 MHz — there is no clock headroom left. 97% of theoretical is
maxed out for this opcode and mode.Write: compare against the datasheet’s tPP, not the FlexSPI clock
Write speed is silicon-limited, not bus-limited. The SPI transfer of 256 bytes at 132.9 MHz quad takes only ~4 µs — negligible next to the flash’s internal program time. From the W25Q128JV AC Characteristics (§9.6):
| Parameter | Symbol | Typ | Max |
|---|---|---|---|
| Page Program (256 B) | tPP | 0.4 ms | 3 ms |
| Sector Erase (4 KB) | tSE | 45 ms | 400 ms |
So “max write speed” is a fixed property — you verify you are at tPP, not that you have
tuned past it.
Now look again at the last line of the log capture: write throughput 24,972 kbps. That is 3.12 MB/s, which works out to 256 bytes ÷ 3,121,500 bytes/s ≈ 82 µs per page.
Watch for measurements faster than the datasheet
82 µs/page is below the 0.4 ms typical — five times faster than the silicon is specified to
manage. Real NOR flash essentially never beats its own tPP-typ by 5×. That is a red flag
that the WIP-polling loop (wt_wait_not_busy) is exiting before the flash actually
finished programming, so the timed window is too short.
A clean write verified 1 does not disprove it — the readback happens later, giving the
flash slack to finish in the background. To confirm: instrument the poll loop to count
status-read iterations per page, and compare poll_count × poll_round_trip against tPP.
Verification checklist
- Read throughput ≥ ~95% of
f_clk × 4→ read path is saturated - Read throughput curve flat across benchmark sizes → sample large enough
- Measured page-program time inside the datasheet
tPPtyp–max band - Per-page WIP poll count is plausible (not suspiciously low)
- Readback verify passes (data integrity, separate from timing)
-
serialClkFreq(boot header) and runtime clock tree agree
Summary
The read path is finished: 518 Mbps at 132.9 MHz quad SDR is 97.4% of the theoretical ceiling, and with DQS unrouted there is no faster mode available on this board. Nothing left to tune.
The write path is not finished. The measurement is physically implausible, which means the
busy-poll is racing completion rather than the flash being fast. Next step is instrumenting
the poll loop and re-measuring against tPP.
The general lesson is the one worth keeping: a benchmark that beats the datasheet is not a result, it is a bug report about your measurement.
Related
- i.MX RT1062 pin config: pull/keeper, drive strength and slew rate — the pad-control fields behind these signals
- Adding a microSD card active object with QP/C and USDHC1 — the other storage path on the same board
Sources
💬 Questions, corrections, or your own war story with FlexSPI? Leave a comment below — I read and answer all of them.
💬 Discussion
Questions, corrections, or war stories from your own board? Leave a comment below — I read and answer all of them. (Sign-in with GitHub.)