This lesson has one job: integrate the two peripherals the boot flow stands on. The SPI master loads the 8 KB second-stage image that the ROM cannot contain, and the GPIO block gives software sixteen pins to read and drive. NanoSoC has no boot straps and no GPIO read in its boot flow: the ROM reads the reset cause and then loads over SPI from the one boot source. Both are simple blocks with one dangerous habit each: the SPI chip-select is software-driven with no interlock, and the GPIO direction register decides whether the chip drives or listens on every pin. Get either wrong and the symptom is physical: contention on the bus or on the board.
The SPI block is a mode-0 master with four registers. DATA carries one byte per touch: write to transmit, read for the last received byte. STATUS reports BUSY and RXNE. CLKDIV divides the 24 MHz clock as SPI clock equals 24 MHz over twice CLKDIV plus 1, so the reset value 1 gives 6 MHz. CTRL holds the enable, the IE interrupt enable for receive-full, and the CS bit that drives spi_cs_n low while set: software asserts CS, clocks out the 4-byte read command plus 8,192 image bytes, then releases CS, and nothing in hardware checks the framing. The load costs 8,196 bytes times 8 bits at 6 Mb/s, which is 10.928 ms, 86.9 percent of the entire boot time; the SPI clock is the boot clock in every sense that matters.
The GPIO block is sixteen pins with four registers. DATA_IN reads the pins, DATA_OUT drives them, DIR selects direction with 1 for output, and INT_EN masks the level interrupt, which is the OR over all pins of DATA_IN AND INT_EN, active-high while any enabled pin reads high. DIR resets to all inputs, so the chip never drives at reset; DATA_OUT resets to 0, so the first configured output drives low. The interrupt follows the pin condition with no latch: configure an enabled pin as an output driving high and the block interrupts itself, which is correct behaviour for a level contract and a classic first-board surprise.
The script below replays the boot ROM's SPI arithmetic and models the GPIO interrupt mask over a representative pin pattern, checking the load time against the boot ledger and showing how one mask bit decides between waking on a pin and ignoring it.
# SPI image load: (8192 + 4) bytes at 6 Mb/s, CLKDIV reset = 1.
clk_per = 24e6
clkdiv = 1
spi_clk = clk_per / (2 * (clkdiv + 1))
assert spi_clk == 6e6
load_bits = (8192 + 4) * 8
load_time = load_bits / spi_clk
print(f"SPI load: {load_bits} bits at 6 Mb/s = {load_time * 1e3:.3f} ms")
assert abs(load_time - 10.928e-3) < 1e-6
# GPIO: irq follows enabled pins only; outputs driving high self-trigger.
def gpio_irq(data_in, int_en):
return 1 if (data_in & int_en) & 0xFFFF else 0
pins = 0b0000_0010_0000_0100 # pins 2 and 9 read high
assert gpio_irq(pins, 0xFFFF) == 1
assert gpio_irq(pins, 0x0004) == 1 # pin 2 enabled: wakes
assert gpio_irq(pins, 0x0200) == 1 # pin 9 enabled: wakes
assert gpio_irq(pins, 0x0000) == 0 # masked: sleeps through it
assert gpio_irq(pins, 0x00F8) == 0 # wrong nibble enabled: silent
print("PASS: 10.928 ms image load; GPIO mask selects pins 2 and 9")The SPI command framing, CS assertion window and 10.928 ms load budget are written in the boot plan with nothing left to the driver author's imagination.
Every GPIO pin has a direction, a reset state and an interrupt-mask decision in the pin table before first silicon; no pin is configured by trial on the bench.
The wake-up pins for sleep exit are named explicitly, with their INT_EN bits set by the sleep-entry code and cleared on wake.
For SPI, read back the image checksum the ROM computes over 2,048 words: a single-bit wire fault fails it deterministically. If the load stalls with BUSY never clearing, CS was released early or the flash never saw the command; scope CS against the clock. For GPIO, drive each pin both ways with the direction set both ways and confirm DATA_IN follows the pin in input mode and the pin follows DATA_OUT in output mode; a pin that follows neither is muxed to another function, which is the next topic's conflict table.
The interrupt fires immediately and stays asserted: the output drives the pin high, DATA_IN reads high, and the enabled mask passes it through. The block is behaving correctly; the configuration asked for a self-trigger. Mask the pin or use it as an input.