Project 10: Bare-Metal Display Driver - No SDK, Just Registers

Bring up the RP2350 and the LCD from reset using only the datasheet, custom startup, and direct register access.

Quick Reference

Attribute Value
Difficulty Level 5: Master
Time Estimate 1 month
Main Programming Language C (Alternatives: Rust no_std, Assembly)
Alternative Programming Languages Rust (no_std), Assembly
Coolness Level Level 5: Pure Magic
Business Potential 1. The “Resume Gold” Level
Prerequisites Project 1, linker scripts, boot flow concepts
Key Topics Startup code, linker scripts, clock/reset, MMIO

1. Learning Objectives

By completing this project, you will:

  1. Write a minimal startup routine and linker script for RP2350.
  2. Initialize clocks, GPIO, and SPI using raw registers.
  3. Bring up the ST7789 LCD without the Pico SDK.
  4. Understand memory sections (.text, .data, .bss) and boot flow.
  5. Produce a binary under 10 KB that displays a test pattern.

2. All Theory Needed (Per-Concept Breakdown)

2.1 Startup Code and Linker Scripts

Fundamentals

Bare-metal firmware starts at the reset vector. Before main() can run, you must set up the stack pointer, initialize .data (copy from flash to RAM), and zero .bss. These steps are typically handled by startup code and a linker script. The linker script defines where sections live in memory, and the startup code uses those symbols to initialize memory. Without correct startup, your program will crash or behave unpredictably.

Deep Dive into the concept

A linker script defines memory regions (flash, RAM) and places sections into them. For example, .text and .rodata live in flash; .data and .bss live in RAM. The linker script also defines symbols like _sidata, _sdata, _edata, _sbss, _ebss, and _stack_top. The startup code uses these symbols to copy .data from flash to RAM and zero .bss. It then sets the vector table and stack pointer, and calls main().

On ARM, the vector table is at a fixed address (usually 0x00000000 or remapped), and the first word is the initial stack pointer. On RP2350, you may run from XIP flash, so the vector table must be accessible there or relocated. You must also configure the VTOR (Vector Table Offset Register) if the vector table is in RAM. A minimal reset handler must also disable interrupts until the system is stable. Common mistakes include incorrect alignment, wrong stack top, or missing section initialization. These issues often appear as hard faults with little debug information.

How this fits on projects

Startup code is central to Section 3.1 and Section 5.10 Phase 1. It also informs Project 7 (toolchain differences) and Project 13 (context switching). Also used in: Project 7, Project 13.

Definitions & key terms

  • Linker script -> Defines memory layout and section placement.
  • Reset handler -> First C function executed after reset.
  • .data -> Initialized global variables in RAM.
  • .bss -> Zero-initialized globals in RAM.

Mental model diagram (ASCII)

Flash: [.text][.rodata][.data init]
RAM:   [.data][.bss][stack]
Startup: copy .data, zero .bss, jump to main

How it works (step-by-step)

  1. CPU reads initial SP from vector table.
  2. CPU jumps to reset handler.
  3. Reset handler copies .data to RAM.
  4. Reset handler zeros .bss.
  5. Sets up clocks, then calls main().

Failure modes:

  • Wrong stack pointer -> immediate crash.
  • .data not copied -> wrong values.
  • .bss not cleared -> garbage state.

Minimal concrete example

extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss;
void reset_handler(void) {
  uint32_t *src = &_sidata;
  for (uint32_t *dst = &_sdata; dst < &_edata;) *dst++ = *src++;
  for (uint32_t *dst = &_sbss; dst < &_ebss;) *dst++ = 0;
  main();
}

Common misconceptions

  • “The compiler handles startup.” -> Not without a runtime.
  • “Linker script is optional.” -> It defines where code lives.

Check-your-understanding questions

  1. Why must .data be copied to RAM?
  2. What happens if .bss is not zeroed?
  3. Why is the stack pointer in the vector table?

Check-your-understanding answers

  1. Variables initialized in flash need to be writable in RAM.
  2. Globals start with garbage values.
  3. The CPU needs an initial stack for function calls.

Real-world applications

  • Bootloaders and firmware for safety systems
  • Bare-metal bring-up for custom boards

Where you’ll apply it

  • This project: Section 3.1, Section 5.10 Phase 1
  • Also used in: Project 13

References

  • “Bare Metal C” by Steve Oualline
  • RP2350 datasheet (memory map)

Key insights

Startup code is the hidden foundation that makes C work on bare metal.

Summary

Without correct startup and linker setup, no firmware can run reliably.

Homework/Exercises to practice the concept

  1. Modify a linker script to move .bss to a different RAM bank.
  2. Intentionally break .data copy and observe behavior.
  3. Print stack pointer value at runtime.

Solutions to the homework/exercises

  1. Update MEMORY and SECTIONS blocks.
  2. Globals will contain wrong values.
  3. Use inline assembly to read SP.

2.2 Clock, Reset, and MMIO Register Access

Fundamentals

Peripheral registers are accessed via memory-mapped IO (MMIO). To use SPI, GPIO, or clocks, you write to specific addresses documented in the datasheet. Before using a peripheral, you must enable its clock and remove it from reset. Clock configuration determines peripheral speed and timing; if you misconfigure clocks, peripherals will not work or will run too fast/slow.

Deep Dive into the concept

The RP2350 has a clock tree that routes the system clock to peripherals. You must configure the system clock source (e.g., crystal oscillator or PLL) and then enable peripheral clocks. Each peripheral has reset bits; you must deassert reset before configuration. MMIO access is typically done by defining structs or macros pointing to base addresses. For example, SPI0_BASE + 0x0 might be the control register. You must respect register write order: some registers require a specific sequence. Incorrect clock or reset handling leads to silent failures: SPI returns nothing, GPIO doesn’t toggle, or the system faults.

When writing bare-metal code, it’s easy to miss a clock enable. A good pattern is to define a “clock init” function that sets up system clocks and enables essential peripherals. For SPI, configure clock divider, mode, and enable bits. For GPIO, configure function selection and pad control. Always consult the datasheet for reset default values. If you use XIP flash, you must be careful not to reconfigure the QSPI clock incorrectly, or you will crash mid-execution. A safe approach is to leave XIP clocks untouched unless you fully understand them.

How this fits on projects

Clock/reset and MMIO are required for Section 3.1 and Section 5.10 Phase 1. They are also used in Project 1 (SPI bring-up) and Project 12 (USB). Also used in: Project 1, Project 12.

Definitions & key terms

  • MMIO -> Memory-mapped IO register access.
  • Clock tree -> Hierarchy of clocks feeding peripherals.
  • Reset control -> Registers that hold peripherals in reset.
  • PLL -> Phase-locked loop to generate clocks.

Mental model diagram (ASCII)

Clock Source -> PLL -> System Clock -> Peripheral Clock -> SPI
Reset line -> SPI reset bit

How it works (step-by-step)

  1. Select clock source and configure PLL.
  2. Enable peripheral clock for SPI/GPIO.
  3. Deassert reset for SPI/GPIO.
  4. Configure peripheral registers.
  5. Enable peripheral and test.

Failure modes:

  • Clock not enabled -> peripheral dead.
  • Reset not released -> registers ignore writes.
  • Wrong divider -> incorrect timing.

Minimal concrete example

#define RESETS_BASE 0x40020000
#define RESETS_RESET (RESETS_BASE + 0x0)
#define RESETS_RESET_DONE (RESETS_BASE + 0x8)

// Clear reset bit for SPI0
*(volatile uint32_t*)RESETS_RESET &= ~(1u << 16);
while ((*(volatile uint32_t*)RESETS_RESET_DONE & (1u << 16)) == 0) {}

Common misconceptions

  • “Writing registers is enough.” -> Clocks and resets must be handled first.
  • “Clock tree is fixed.” -> It must be configured.

Check-your-understanding questions

  1. Why enable clocks before using SPI?
  2. What does reset deassertion do?
  3. Why is MMIO access volatile?

Check-your-understanding answers

  1. Without clock, peripheral logic is not running.
  2. It releases the peripheral from reset state.
  3. To prevent compiler from optimizing away reads/writes.

Real-world applications

  • Bare-metal firmware in bootloaders
  • Bring-up of custom hardware boards

Where you’ll apply it

  • This project: Section 3.1, Section 5.10 Phase 1
  • Also used in: Project 1

References

  • RP2350 datasheet (clock and reset sections)

Key insights

Bare-metal work is about controlling clocks and resets as much as code.

Summary

MMIO and clock control are prerequisites for any peripheral use.

Homework/Exercises to practice the concept

  1. Toggle a GPIO using raw register writes.
  2. Enable SPI clock and verify register changes.
  3. Read reset status for a peripheral.

Solutions to the homework/exercises

  1. Configure GPIO function to SIO and set output bit.
  2. Use reset_done to verify release.
  3. Read reset status bits in RESETS_RESET_DONE.

3. Project Specification

3.1 What You Will Build

A bare-metal firmware that boots without the Pico SDK, initializes clocks, configures SPI and GPIO via MMIO, and displays a test pattern on the ST7789 LCD.

3.2 Functional Requirements

  1. Custom linker script and startup code.
  2. Clock and reset init for SPI and GPIO.
  3. Raw SPI LCD driver (init sequence + pixel writes).
  4. Test pattern (color bars + text).

3.3 Non-Functional Requirements

  • Performance: Display updates in under 250 ms.
  • Reliability: Boot works on 10 consecutive resets.
  • Usability: Buildable with a single Make/CMake command.

3.4 Example Usage / Output

Boot: Bare-metal driver
Status: SPI OK, LCD OK

3.5 Data Formats / Schemas / Protocols

  • Raw ST7789 command sequence
  • RGB565 pixel stream

3.6 Edge Cases

  • Incorrect linker script location
  • SPI clock not enabled
  • Reset vector misaligned

3.7 Real World Outcome

The LCD shows a boot logo and gradient with no SDK dependencies. The binary size is reported in build logs.

3.7.1 How to Run (Copy/Paste)

cd LEARN_RP2350_LCD_DEEP_DIVE/bare_metal_display
make
cp bare_metal_display.uf2 /Volumes/RP2350

3.7.2 Golden Path Demo (Deterministic)

  • Boot logo appears within 2 seconds.
  • Serial log shows binary size and clock config.

3.7.3 Failure Demo (Deterministic)

  • Break .bss zeroing in startup.
  • Expected: random colors or crash.
  • Fix: restore .bss initialization.

4. Solution Architecture

4.1 High-Level Design

[Startup] -> [Clock Init] -> [SPI + GPIO Init] -> [LCD Init] -> [Pattern]

4.2 Key Components

| Component | Responsibility | Key Decisions | |———–|—————-|—————| | Startup | Memory init | Minimal C runtime | | Clock init | PLL and dividers | Stable 120 MHz | | LCD driver | Command sequence | Datasheet-based |

4.3 Data Structures (No Full Code)

typedef struct { uint32_t ctrl; uint32_t status; uint32_t data; } spi_regs_t;

4.4 Algorithm Overview

Key Algorithm: Boot Sequence

  1. Set stack pointer.
  2. Initialize .data and .bss.
  3. Configure clocks.
  4. Initialize SPI and LCD.
  5. Render pattern.

Complexity Analysis:

  • Time: O(pixels)
  • Space: minimal static data

5. Implementation Guide

5.1 Development Environment Setup

# Install arm-none-eabi-gcc and build tools

5.2 Project Structure

bare_metal_display/
- startup.s
- linker.ld
- src/
  - main.c
  - lcd.c

5.3 The Core Question You’re Answering

“Can I bring up the RP2350 from reset to pixels with only a datasheet?”

5.4 Concepts You Must Understand First

  1. Linker scripts and startup flow
  2. Clock tree and reset control
  3. MMIO register access

5.5 Questions to Guide Your Design

  1. Where does your vector table live?
  2. How will you keep XIP flash stable?
  3. What is the minimum clock setup for SPI?

5.6 Thinking Exercise

List the absolute minimum steps to light a single pixel.

5.7 The Interview Questions They’ll Ask

  1. Why is the linker script critical in bare metal?
  2. What happens if .data is not initialized?
  3. How do you enable a peripheral clock?

5.8 Hints in Layers

  • Hint 1: Start from a minimal startup template.
  • Hint 2: Use datasheet register addresses directly.
  • Hint 3: Verify clocks before SPI.

5.9 Books That Will Help

| Topic | Book | Chapter | |——-|——|———| | Bare-metal startup | “Bare Metal C” | Ch. 1-3 | | Low-level C | “Effective C” | Ch. 5 |

5.10 Implementation Phases

Phase 1: Startup + Linker (1 week)

Goals: Boot into main(). Tasks: Create linker script and startup. Checkpoint: Toggle GPIO in main.

Phase 2: Clock + SPI (1 week)

Goals: Configure clocks and SPI. Tasks: Setup PLL and SPI registers. Checkpoint: SPI toggles verified on scope.

Phase 3: LCD Init (1-2 weeks)

Goals: Display output. Tasks: Implement ST7789 init and draw pattern. Checkpoint: LCD shows test image.

5.11 Key Implementation Decisions

| Decision | Options | Recommendation | Rationale | |———-|———|—————-|———–| | Startup language | C vs ASM | ASM for reset | Explicit control | | Clock source | XOSC vs PLL | PLL | Stable high freq |


6. Testing Strategy

6.1 Test Categories

| Category | Purpose | Examples | |———-|———|———-| | Unit Tests | Register writes | GPIO toggle | | Integration Tests | LCD output | test pattern | | Regression Tests | Boot | 10 resets |

6.2 Critical Test Cases

  1. Boot test: GPIO toggles in main.
  2. SPI test: MOSI toggles on scope.
  3. LCD test: color bars visible.

6.3 Test Data

Pattern: RGB bars + gradient

7. Common Pitfalls & Debugging

7.1 Frequent Mistakes

| Pitfall | Symptom | Solution | |———|———|———-| | Wrong vector table | Hard fault | Fix linker script | | Clock misconfig | Peripheral dead | Verify clock tree | | Missing reset deassert | No SPI | Clear reset bits |

7.2 Debugging Strategies

  • Use SWD or UART logs to confirm boot stages.
  • Toggle GPIOs at each stage.

7.3 Performance Traps

  • Overclocking SPI without verifying signal integrity.

8. Extensions & Challenges

8.1 Beginner Extensions

  • Add UART logging.

8.2 Intermediate Extensions

  • Add bare-metal DMA for LCD updates.

8.3 Advanced Extensions

  • Implement secure boot checks.

9. Real-World Connections

9.1 Industry Applications

  • Bootloaders and secure firmware
  • Custom hardware bring-up
  • Pico SDK startup code (for reference)

9.3 Interview Relevance

  • Bare-metal bring-up is a senior embedded topic.

10. Resources

10.1 Essential Reading

  • RP2350 datasheet
  • ARM Cortex-M33 reference manual

10.2 Video Resources

  • Bare-metal boot tutorials

10.3 Tools & Documentation

  • GDB or OpenOCD for debugging

11. Self-Assessment Checklist

11.1 Understanding

  • I can explain .data and .bss initialization.
  • I can configure peripheral clocks manually.

11.2 Implementation

  • LCD shows a test pattern with no SDK.
  • Binary size is under 10 KB.

11.3 Growth

  • I can explain bare-metal bring-up in an interview.

12. Submission / Completion Criteria

Minimum Viable Completion:

  • Boot into main and toggle GPIO.
  • LCD init works without SDK.

Full Completion:

  • Full test pattern and documented boot flow.

Excellence (Going Above & Beyond):

  • Add secure boot or TrustZone partitioning.