Back to .md Directory

mk20dx-hal: Strategy

Produce a correct, ergonomic Hardware Abstraction Layer crate for the MK20DX128 (Teensy 3.0) and MK20DX256 (Teensy 3.1/3.2) that implements `embedded-hal` 1.0 traits, built on top of the validated `mk20dx-pac` peripheral access crates.

May 2, 2026
0 downloads
1 views
ai
View source

mk20dx-hal: Strategy

Goal

Produce a correct, ergonomic Hardware Abstraction Layer crate for the MK20DX128 (Teensy 3.0) and MK20DX256 (Teensy 3.1/3.2) that implements embedded-hal 1.0 traits, built on top of the validated mk20dx-pac peripheral access crates.

The PAC is mature (Phase 5 complete, Phase 6 publishing in progress) with correctness patches validated against kinetis.h and semantic enum names across key peripherals (PORT MUX, FTM, ADC, MCG, SIM, DMA ATTR). PAC documentation lives at ../mk20dx-pac/docs/.


Phase 1: Project Scaffold

1.1 Crate Structure

Set up a standard no_std library crate with:

[package]
name = "mk20dx-hal"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
categories = ["embedded", "hardware-support", "no-std"]
keywords = ["arm", "cortex-m", "teensy", "kinetis", "nxp"]

[dependencies]
cortex-m = "0.7"
cortex-m-rt = { version = "0.7", optional = true }
embedded-hal = "1.0"
embedded-hal-nb = "1.0"
embedded-io = "0.6"
nb = "1.1"
fugit = "0.3"
critical-section = "1.1"

[dependencies.mk20d5]
path = "../mk20dx-pac/mk20d5"
optional = true

[dependencies.mk20d7]
path = "../mk20dx-pac/mk20d7"
optional = true

[features]
default = ["mk20d7"]
mk20d5 = ["dep:mk20d5", "mk20d5/rt"]
mk20d7 = ["dep:mk20d7", "mk20d7/rt"]
rt = []

1.2 Feature-Gated PAC Re-export

lib.rs re-exports whichever PAC is selected as pac:

#![no_std]

#[cfg(feature = "mk20d5")]
pub use mk20d5 as pac;

#[cfg(feature = "mk20d7")]
pub use mk20d7 as pac;

#[cfg(not(any(feature = "mk20d5", feature = "mk20d7")))]
compile_error!("Select a chip variant: mk20d5 or mk20d7");

1.3 Memory Layout

Provide memory.x linker scripts for each variant. Values from the K20 reference manuals and Teensy schematics:

MK20DX128 (Teensy 3.0):

MEMORY {
    FLASH : ORIGIN = 0x00000000, LENGTH = 128K
    RAM   : ORIGIN = 0x1FFFE000, LENGTH = 16K
}

MK20DX256 (Teensy 3.1/3.2):

MEMORY {
    FLASH : ORIGIN = 0x00000000, LENGTH = 256K
    RAM   : ORIGIN = 0x1FFF8000, LENGTH = 64K
}

A build.rs selects and copies the correct linker script based on feature flags.

1.4 Validation

  • cargo check --features mk20d7 --target thumbv7em-none-eabi compiles
  • cargo check --features mk20d5 --target thumbv7em-none-eabi compiles
  • Both features simultaneously produces a compile error

Phase 1.5: Flash Configuration & Watchdog

1.5.1 Flash Configuration Field

A 16-byte flash configuration field at 0x400-0x40F using #[link_section = ".flashconfig"]. Contains backdoor key, FPROT (flash protection disabled), FSEC (unsecured), FOPT (defaults). The memory.x linker script defines the .flashconfig section placement.

1.5.2 Watchdog Disable

The watchdog uses an extension trait on the PAC WDOG peripheral:

pub trait WdogExt {
    fn disable(self);
}

impl WdogExt for pac::Wdog {
    fn disable(self) {
        // Unlock: write 0xC520 then 0xD928 within 20 bus clocks
        // Disable: clear WDOGEN within 256 bus clocks of unlock
        cortex_m::interrupt::free(|_| { ... });
    }
}

This consumes the WDOG peripheral to prevent further access.


Phase 2: Clock Configuration

This is the critical-path foundation. Almost every peripheral driver needs to know bus frequencies, and every peripheral needs its clock gate enabled before register access.

2.1 SIM Clock Gating

The System Integration Module (SIM) gates clocks to all peripherals via SCGC registers:

  • SCGC4: UART0, UART1, UART2, I2C0, I2C1, CMP, USBOTG
  • SCGC5: PORTA, PORTB, PORTC, PORTD, PORTE
  • SCGC6: FTM0, FTM1, PIT, ADC0, RTC, DMAMUX, SPI0, FTM2 (mk20d7), ADC1 (mk20d7)
  • SCGC7: DMA

Provide a safe API that enables peripheral clock gates. This can be as simple as a method on the SIM wrapper:

impl Sim {
    pub fn enable_clock<P: PeripheralClock>(&mut self) { ... }
}

Or it can be integrated into each peripheral's ::new() method.

2.2 MCG Configuration

The Multipurpose Clock Generator produces the system clock from the external 16 MHz crystal on Teensy boards. The standard configuration path for Teensy:

  1. Start in FEI (FLL Engaged Internal) mode after reset
  2. Transition to FBE (FLL Bypassed External) — enable external crystal
  3. Transition to PBE (PLL Bypassed External) — configure PLL
  4. Transition to PEE (PLL Engaged External) — switch to PLL output

Target frequencies (default):

  • MK20DX128 (Teensy 3.0): 48 MHz core, 48 MHz bus, 24 MHz flash
  • MK20DX256 (Teensy 3.1/3.2): 72 MHz core, 36 MHz bus, 24 MHz flash

Overclock presets (mk20d7 only, via freeze_at(ClockSpeed::*, ..)):

  • Mhz96: 96 MHz core, 48 MHz bus, 24 MHz flash (PRDIV=3, VDIV=0)
  • Mhz120: 120 MHz core, 60 MHz bus, 24 MHz flash (PRDIV=3, VDIV=6)

The PAC now provides semantic MCG enums for clock source selection (c1().clks().fll(), c1().clks().internal(), c1().clks().external()), FLL reference divider, oscillator range, and PLL VDIV — use these instead of raw bit patterns.

Reference: K20 ref manual chapter 24 (MCG), chapter 5 (Clock Distribution).

2.3 Extension Trait API

Clock configuration uses the extension trait pattern:

/// Extension trait on the PAC MCG peripheral.
pub trait McgExt {
    fn constrain(self) -> Mcg;
}

impl McgExt for pac::Mcg {
    fn constrain(self) -> Mcg { Mcg { _mcg: self } }
}

/// Wrapper that consumes the PAC MCG and provides `freeze()` / `freeze_at()`.
pub struct Mcg { _mcg: pac::Mcg }

impl Mcg {
    /// Configure the clock tree at the default speed and return frozen clock frequencies.
    /// mk20d7: 72 MHz, mk20d5: 48 MHz. On mk20d7, delegates to freeze_at(Mhz72, ..).
    pub fn freeze(self, osc: OscPeripheral, sim: &pac::Sim) -> Clocks { ... }

    /// Configure the clock tree at a specific speed preset (mk20d7 only).
    #[cfg(feature = "mk20d7")]
    pub fn freeze_at(self, speed: ClockSpeed, osc: OscPeripheral, sim: &pac::Sim) -> Clocks { ... }
}

2.4 Clocks Token

The freeze() method configures the MCG, sets SIM dividers, and returns a frozen Clocks struct:

pub struct Clocks {
    core_clk: Hertz,    // MCGOUTCLK / OUTDIV1
    bus_clk: Hertz,     // MCGOUTCLK / OUTDIV2
    flash_clk: Hertz,   // MCGOUTCLK / OUTDIV4
}

impl Clocks {
    pub fn core_clk(&self) -> Hertz { self.core_clk }
    pub fn bus_clk(&self) -> Hertz { self.bus_clk }
    pub fn flash_clk(&self) -> Hertz { self.flash_clk }
}

This struct is passed by reference to peripheral constructors for baud rate / prescaler calculation.

2.4 Validation

  • Configure clocks and verify SysTick-based delays are reasonably accurate on hardware
  • Verify peripheral clock gates work (accessing ungated peripheral should fault; gated should work)

Phase 2.5: SysTick Delay

2.5.1 Delay Type

A Delay struct wrapping cortex_m::peripheral::SYST that implements embedded_hal::delay::DelayNs. Constructor requires &Clocks to compute cycles-per-microsecond.

pub struct Delay { syst: SYST, cycles_per_us: u32 }

impl Delay {
    pub fn new(syst: SYST, clocks: &Clocks) -> Self { ... }
    pub fn free(self) -> SYST { self.syst }
}

The SysTick reload register is 24 bits wide (max 0x00FFFFFF), so longer delays loop.


Phase 3: GPIO

3.1 Architecture

Kinetis GPIO is split across two peripheral blocks that must be coordinated:

BlockRegistersPurpose
PORT (PORTA..E)PCR[0..31] (Pin Control Register)Mux selection, pull, drive strength, interrupt config
GPIO (GPIOA..E)PDOR, PSOR, PCOR, PTOR, PDIR, PDDRData output, set, clear, toggle, input, direction

Each pin needs access to both its PORT PCR register and its GPIO port registers.

3.2 Type-State Design

Use const generics for port and pin number, with phantom types for mode:

pub struct Pin<const PORT: char, const N: u8, MODE = Input<Floating>> {
    _mode: PhantomData<MODE>,
}

// Mode types
pub struct Input<PULL>(PhantomData<PULL>);
pub struct Output<DRIVE>(PhantomData<DRIVE>);
pub struct Alternate<const MUX: u8>;

// Pull/drive types
pub struct Floating;
pub struct PullUp;
pub struct PullDown;
pub struct PushPull;
pub struct OpenDrain;

3.3 Port Splitting via Extension Trait

Each GPIO port is split into individual pin types via a GpioExt extension trait:

/// Extension trait on PAC PORT peripherals.
pub trait GpioExt {
    type Pins;
    /// Split the port into individual pin types.
    /// Consumes both PORT and GPIO peripherals; borrows SIM for clock gating.
    fn split(self, gpio: impl GpioPeriph, sim: &pac::Sim) -> Self::Pins;
}

impl GpioExt for pac::Porta {
    type Pins = PortAPins;
    fn split(self, gpio: pac::Pta, sim: &pac::Sim) -> PortAPins {
        sim.scgc5().modify(|_, w| w.porta()._1()); // Enable port clock
        PortAPins { pa0: Pin { _mode: PhantomData }, ... }
    }
}

pub struct PortAPins {
    pub pa0: Pin<'A', 0, Disabled>,
    pub pa1: Pin<'A', 1, Disabled>,
    // ...
    pub pa31: Pin<'A', 31, Disabled>,
}

This consumes the PAC's PORT and GPIO peripherals, preventing raw register access. The GpioExt trait is re-exported in the prelude for ergonomic use.

3.4 Pin Mux for Alternate Functions

When a peripheral (UART, SPI, etc.) claims a pin, it converts the pin to the correct alternate function:

// UART0 TX on PTA2 uses MUX=3 (ALT3)
let tx_pin = gpioa.pa2.into_alternate::<3>();
let serial = Serial::new(uart0, tx_pin, rx_pin, baud, &clocks);

The specific MUX values come from the K20 reference manual signal multiplexing table. These should be encoded as type-level constraints so invalid pin assignments are compile errors where practical.

The PAC now provides semantic PORT MUX enums (Disabled, Gpio, Alt2..Alt7) so the HAL can use w.mux().gpio() instead of w.mux()._001().

3.5 embedded-hal Trait Implementations

TraitImplemented ForNotes
InputPinPin<P, N, Input<PULL>>Reads PDIR bit
OutputPinPin<P, N, Output<MODE>>Writes PSOR/PCOR (set/clear)
StatefulOutputPinPin<P, N, Output<MODE>>Reads PDOR bit; toggle() via PTOR

Error type: Infallible (GPIO operations cannot fail on this hardware).

3.6 Validation

  • LED blink on Teensy pin 13 (PTB5 on Teensy 3.0, PTC5 on Teensy 3.1/3.2)
  • Read a button input with pull-up
  • Verify type-state prevents configuring an input pin as output without mode change

Phase 4: UART / Serial

4.1 Architecture

MK20 has 3 UART peripherals. UART0 has a 8-byte FIFO; UART1 and UART2 have single-byte buffers.

All UART registers are 8-bit. The PAC correctly generates u8-sized register access.

4.2 Pin Assignment

UART pins are selected via PORT mux. Common Teensy mappings:

UARTTX PinRX PinMUXTeensy Pin
UART0PTA2 / PTB17 / PTD7PTA1 / PTB16 / PTD631/TX, 0/RX (default)
UART1PTC4 / PTE0PTC3 / PTE135, 21
UART2PTD3PTD238, 7

4.3 Baud Rate Calculation

UART baud = (bus_clock or core_clock) / (SBR * (OSR + 1))

Where SBR is the 13-bit baud rate modulus (BDH[4:0] + BDL[7:0]) and OSR is the oversampling ratio in UART0_C4. UART0 is clocked from the core clock; UART1/2 from the bus clock.

4.4 Trait Implementations

CrateTraitNotes
embedded-hal-nbserial::Read<u8>Non-blocking read from RDR; returns WouldBlock if RDRF=0
embedded-hal-nbserial::Write<u8>Non-blocking write to TDR; returns WouldBlock if TDRE=0
embedded-ioReadBlocking byte reads
embedded-ioWriteBlocking byte writes with flush()

4.5 Validation

  • Print to serial console via UART0 at 115200 baud
  • Loopback test (TX→RX jumper)
  • Verify baud rate accuracy across different system clock frequencies

Phase 5: SPI

5.1 Architecture

MK20 uses DSPI (Deserial SPI) with hardware chip select and configurable frame sizes. SPI0 on both variants, SPI1 on mk20d7 only.

Key registers:

  • MCR: Module configuration (master/slave, continuous clock, etc.)
  • CTAR0/CTAR1: Clock and Transfer Attributes (baud rate, frame size, polarity, phase)
  • SR: Status register (TCF, RFDF, TFFF flags)
  • PUSHR: Push TX data with command bits (CS assertion, continuous transfer)
  • POPR: Pop RX data

5.2 Baud Rate

DSPI baud = (bus_clock / PBR) * ((1 + DBR) / BR)

Where PBR (prescaler) and BR (baud rate scaler) are fields in CTARn, and DBR is the double baud rate bit.

5.3 Trait Implementation

Implement embedded_hal::spi::SpiBus for the HAL SPI type. Users compose SpiDevice via embedded-hal-bus wrappers (ExclusiveDevice, RefCellDevice, CriticalSectionDevice) which combine a SpiBus + CS OutputPin.

MethodImplementation
read()Push dummy bytes to PUSHR, read from POPR
write()Push bytes to PUSHR, discard POPR
transfer()Push write bytes, collect read bytes
transfer_in_place()In-place variant
flush()Wait for TXCTR=0 and TCF

5.4 Validation

  • SPI loopback (MOSI→MISO jumper)
  • Communication with a real SPI device (e.g., SPI flash, display)

Phase 6: I2C

6.1 Architecture

MK20 I2C supports 7-bit and 10-bit addressing. I2C0 on both variants, I2C1 on mk20d7 only. All I2C registers are 8-bit.

Key registers:

  • F: Frequency divider (ICR field selects from a lookup table of dividers)
  • C1: Control (IICEN, MST, TX, TXAK)
  • S: Status (BUSY, TCF, IICIF, ARBL, RXAK)
  • D: Data register

6.2 Clock Rate

The I2C clock rate is derived from the bus clock using a divider table indexed by the ICR field in the F register. The K20 reference manual (chapter 37) contains the full divider table.

6.3 Trait Implementation

Implement embedded_hal::i2c::I2c with SevenBitAddress:

MethodImplementation
read()START → address+R → read N bytes with ACK/NACK → STOP
write()START → address+W → write bytes → STOP
write_read()START → address+W → write → REPEATED START → address+R → read → STOP
transaction()Compose read/write operations with repeated starts

6.4 Validation

  • I2C bus scan (detect devices at each address)
  • Communication with a real I2C device (e.g., temperature sensor, OLED)

Phase 7: Timers and Delay

7.1 SysTick Delay

The simplest DelayNs implementation uses the ARM Cortex-M SysTick timer (24-bit countdown). This is available via cortex-m crate and doesn't require any MK20-specific peripheral setup.

pub struct Delay {
    syst: cortex_m::peripheral::SYST,
    core_clk: Hertz,
}

impl embedded_hal::delay::DelayNs for Delay { ... }

7.2 PIT Timer

The Periodic Interrupt Timer has 4 independent 32-bit channels that can be chained for 64-bit operation. PIT is clocked from the bus clock.

Provide a HAL-specific timer API (no standard embedded-hal trait for countdown timers in 1.0):

pub struct PitChannel<const CH: u8> { ... }

impl PitChannel<CH> {
    pub fn start(&mut self, period: impl Into<Duration>) { ... }
    pub fn wait(&mut self) -> nb::Result<(), Infallible> { ... }
    pub fn cancel(&mut self) { ... }
}

7.3 Validation

  • DelayNs produces reasonably accurate delays (verify with oscilloscope or logic analyzer)
  • PIT periodic interrupt fires at expected rate

Phase 8: PWM

8.1 Architecture

MK20 FlexTimer Modules (FTM) provide PWM output:

  • FTM0: 8 channels (both variants)
  • FTM1: 2 channels (both variants)
  • FTM2: 2 channels (mk20d7 only)

FTM uses a 16-bit counter with configurable prescaler. PWM mode is selected per-channel via the CnSC register (MSB:MSA:ELSB:ELSA fields).

The PAC provides semantic FTM enums: clock source (sc().clks().system(), .fixed(), .external()) and prescaler (sc().ps().div1() through .div128()).

8.2 Trait Implementation

Implement embedded_hal::pwm::SetDutyCycle on individual FTM channels:

pub struct PwmChannel<FTM, const CH: u8> { ... }

impl SetDutyCycle for PwmChannel<FTM0, CH> {
    fn max_duty_cycle(&self) -> u16 { ... }      // MOD register value
    fn set_duty_cycle(&mut self, duty: u16) { ... } // CnV register
}

PWM frequency is set at the FTM level (shared MOD register); duty cycle is per-channel.

8.3 Validation

  • PWM output on Teensy pin 13 LED (FTM channel) — should dim smoothly
  • Frequency measurement with oscilloscope or logic analyzer

Phase 9: ADC

9.1 Architecture

MK20 has a 16-bit SAR ADC. ADC0 on both variants, ADC1 on mk20d7 only. Supports hardware averaging, multiple resolutions (8/10/12/16-bit), and a calibration sequence.

No standard embedded-hal 1.0 trait for ADC. Provide a HAL-specific API. The PAC provides semantic ADC enums for resolution (cfg1().mode().mode8_bit() etc.), clock source, and sample time:

pub struct Adc<ADC> { ... }

impl Adc<ADC0> {
    pub fn new(adc: pac::ADC0, clocks: &Clocks) -> Self { ... }
    pub fn calibrate(&mut self) { ... }
    pub fn read(&mut self, channel: u8) -> u16 { ... }
    pub fn set_resolution(&mut self, res: Resolution) { ... }
}

9.2 Validation

  • Read ADC value from a known voltage source
  • Verify calibration sequence runs correctly
  • Verify resolution switching works

Phase 10: eDMA

10.1 Architecture

The eDMA (Enhanced Direct Memory Access) controller enables CPU-free data transfers between memory and peripherals. The DMAMUX routes peripheral request signals to DMA channels.

  • MK20D5: 4 channels, 4 DMAMUX slots
  • MK20D7: 16 channels, 16 DMAMUX slots

Both share identical register layouts, differing only in array sizes. The DMA module uses a DmaChannel<const CH: u8> zero-sized type per channel, following the same const generic pattern as PIT.

10.2 Transfer Model

Each DMA activation executes one minor loop (transfers NBYTES bytes). The major loop counts how many minor loop iterations to perform (CITER/BITER). After the major loop completes, DONE is set and optionally an interrupt fires. DREQ=1 auto-disables the hardware request on completion.

10.3 Extension Trait API

pub trait DmaExt: Sized {
    fn split(self, dmamux: pac::Dmamux, sim: &pac::Sim) -> DmaChannels;
}

The split() method consumes both DMA and DMAMUX peripherals, enables clock gates, configures default channel priorities (channel N = priority N, preemptable), and returns individual channel handles.

10.4 Scope

Implemented:

  • Full TCD configuration (configure())
  • DMAMUX source routing (set_source(), disable_source())
  • Transfer control (enable_request(), disable_request(), start())
  • Status (is_complete(), has_error(), is_active(), error_status())
  • Flag/interrupt management
  • Convenience methods (configure_memcpy, configure_peripheral_read, configure_peripheral_write)

Deferred to future phases:

  • Scatter/gather (CSR.ESG, TCD chaining via DLASTSGA)
  • Channel linking (CITER.ELINK, CSR.MAJORELINK)
  • Minor loop offset mapping (CR.EMLM, NBYTES_MLOFF variants)
  • Circular buffers (SMOD/DMOD)
  • Safe Transfer type (borrow-based lifetime guarantee)
  • Async integration (embedded-hal-async)

Phase 11: USB Device

Implements usb_device::bus::UsbBus (v0.3) for the Kinetis USB-FS controller.

11.1 Hardware

  • USB0 at 0x4007_2000 — single instance, identical on both mk20d5 and mk20d7
  • 16 endpoints, bidirectional, with ping-pong (EVEN/ODD) buffering
  • BDT (Buffer Descriptor Table) in RAM, 512-byte aligned
  • All USB registers are u8, Safety = crate::Unsafe
  • ISTAT/ERRSTAT are w1c — must use write() not modify()

11.2 Clock Configuration

USB requires exactly 48 MHz. Derived from PLL via SIM:

  • SIM SOPT2: pllfllsel().pll() + usbsrc()._1()
  • SIM CLKDIV2: mk20d7 USBFRAC=1,USBDIV=2 (72×2/3=48); mk20d5 USBFRAC=0,USBDIV=0 (48×1=48)
  • SIM SCGC4: usbotg()._1() clock gate

11.3 Implementation

  • UsbBus struct with UnsafeCell<Inner> for interior mutability (trait requires &self)
  • Static BDT (512-byte aligned) and buffer pool (64×64 bytes)
  • UsbBusExt extension trait: pac::Usb0.usb_bus(&sim)UsbBus
  • Full usb_device::bus::UsbBus trait implementation
  • Compatible with usb-device class crates (usbd-serial, usbd-hid, etc.)

11.4 Validation

cargo check --features mk20d7 --target thumbv7em-none-eabi
cargo check --no-default-features --features mk20d5 --target thumbv7em-none-eabi

Hardware: Flash to Teensy, verify USB enumeration.


Phase 12: Hardware Validation Test Suite

12.1 Framework

Using defmt-test v0.3 with probe-rs runner for on-target test execution. Tests are organized as integration test binaries in mk20dx-testsuite/tests/, one per peripheral area.

Key properties:

  • #[defmt_test::tests] macro on a module, #[init] for one-time setup, #[test] per test
  • Shared &mut State across tests (init runs once per binary, no device reset between tests)
  • defmt-rtt for structured logging, panic-probe for panic handling
  • Standard cargo test --test <binary> workflow via probe-rs

12.2 Self-Tests (No External Wiring)

These tests validate peripheral drivers using only on-chip resources.

tests/watchdog.rs — 3 tests | Priority: CRITICAL

TestDescriptionPass Criteria
test_watchdog_disableRead STCTRLH after disable()WDOGEN bit == 0
test_watchdog_survives_500msBusy-wait 500ms after disableCompletes without reset
test_system_functional_after_disableRead MCG.S registerRegisters accessible

tests/clocks.rs — 5 tests | Priority: CRITICAL

TestDescriptionPass Criteria
test_core_clk_72mhzclocks.core_clk() + MCG PLL cross-check== 72_000_000 Hz, PRDIV=7, VDIV=12
test_bus_clk_36mhzclocks.bus_clk() + SIM OUTDIV2== 36_000_000 Hz, OUTDIV2=1
test_flash_clk_24mhzclocks.flash_clk() + SIM OUTDIV4== 24_000_000 Hz, OUTDIV4=2
test_pll_lockedRead MCG.S registerLOCK0=1, PLLST=1, CLKST=0b11
test_osc_initializedRead MCG.S.OSCINIT0== 1

tests/clocks_96mhz.rs — 5 tests | Priority: HIGH

Uses freeze_at(ClockSpeed::Mhz96).

TestDescriptionPass Criteria
test_core_clk_96mhzclocks.core_clk() + MCG PLL cross-check== 96_000_000 Hz, PRDIV=3, VDIV=0
test_bus_clk_48mhzclocks.bus_clk() + SIM OUTDIV2== 48_000_000 Hz, OUTDIV2=1
test_flash_clk_24mhzclocks.flash_clk() + SIM OUTDIV4== 24_000_000 Hz, OUTDIV4=3
test_pll_lockedRead MCG.S registerLOCK0=1, PLLST=1, CLKST=0b11
test_osc_initializedRead MCG.S.OSCINIT0== 1

tests/clocks_120mhz.rs — 5 tests | Priority: HIGH

Uses freeze_at(ClockSpeed::Mhz120).

TestDescriptionPass Criteria
test_core_clk_120mhzclocks.core_clk() + MCG PLL cross-check== 120_000_000 Hz, PRDIV=3, VDIV=6
test_bus_clk_60mhzclocks.bus_clk() + SIM OUTDIV2== 60_000_000 Hz, OUTDIV2=1
test_flash_clk_24mhzclocks.flash_clk() + SIM OUTDIV4== 24_000_000 Hz, OUTDIV4=4
test_pll_lockedRead MCG.S registerLOCK0=1, PLLST=1, CLKST=0b11
test_osc_initializedRead MCG.S.OSCINIT0== 1

tests/gpio.rs — 8 tests | Priority: HIGH

Uses PTC5 (LED) and PTD4 (unconnected). First test uses typed pins; subsequent tests fall back to raw register access due to type-state pin consumption in shared-state model.

TestDescriptionPass Criteria
test_output_set_highPTC5 push-pull output, set highis_set_high() == true
test_output_set_lowPTC5 set low (raw regs)PDOR bit == 0
test_output_togglePTC5 low → toggle (raw regs)PDOR bit == 1
test_pull_up_reads_highPTD4 pull-up inputPDIR bit == 1
test_pull_down_reads_lowPTD4 pull-down input (raw regs)PDIR bit == 0
test_mode_transition_out_to_inPTC5 output → input (raw regs)No fault
test_mode_transition_in_to_outPTD4 input → output (raw regs)No fault
test_open_drain_set_lowPTC5 open-drain (raw regs)PDOR bit == 0

tests/delay.rs — 7 tests | Priority: HIGH

Uses PIT as independent timing reference to cross-validate SysTick delays.

TestDescriptionPass Criteria
test_delay_1ms_completesdelay.delay_ms(1)Completes
test_delay_100ms_completesdelay.delay_ms(100)Completes
test_delay_1s_completesdelay.delay_ms(1000) — exercises SysTick loop pathCompletes
test_delay_ns_1usdelay.delay_ns(1000)Completes
test_delay_zerodelay.delay_ns(0)Completes immediately
test_delay_10ms_pit_crosscheckPIT free-run → delay 10ms → read PIT deltaWithin ±5% of 360k ticks
test_delay_100ms_pit_crosscheckPIT free-run → delay 100ms → read PIT deltaWithin ±2% of 3.6M ticks

tests/timer.rs — 10 tests | Priority: HIGH

TestDescriptionPass Criteria
test_start_and_waitch0 1ms period, nb::block!(wait())Returns Ok(())
test_wait_would_blockch0 1s period, immediate pollErr(WouldBlock)
test_cancelch0 1s, cancel, pollErr(WouldBlock)
test_current_counts_downch0 1s, read current twice with delaysecond < first
test_has_expiredch0 1ms, check before + delay 5ms + check afterBefore=false, After=true
test_clear_interrupt_flagAfter expiry, clear, checkhas_expired() == false
test_enable_disable_interruptEnable TIE, verify set; disable, verify clearedRegister bits match
test_channels_independentch0=10ms, ch1=50ms, wait ch0, check ch1 not expiredCorrect ordering
test_reload_after_expiryExpire, restart, expire againBoth waits succeed
test_start_ticks_rawstart_ticks(36_000) = 1ms at 36MHzCompletes

tests/adc.rs — 10 tests | Priority: HIGH

Internal ADC channels provide known reference voltages — the strongest self-tests in the suite.

TestDescriptionPass Criteria
test_calibration_succeedsadc.calibrate() in initReaches test
test_vrefsl_reads_zeroChannel 30 (VSSA/GND) at 10-bitValue < 50
test_vrefsh_reads_maxChannel 29 (VDDA/3.3V) at 10-bitValue > 974
test_bandgap_in_rangeChannel 27 (~1.0V) at 10-bit250–370
test_temperature_in_rangeChannel 26 (temp sensor)100–900 (loose bounds)
test_resolution_8bit8-bit mode, VREFSH245–255
test_resolution_12bit12-bit, VREFSH> 3995
test_resolution_16bit16-bit, VREFSH> 64500
test_averaging_reduces_noise10× raw vs 10× avg32 on bandgap, compare varianceavg_var ≤ raw_var
test_sequential_channelsRead VREFSL, bandgap, VREFSH in sequenceAll within ranges

tests/dma.rs — 12 tests | Priority: MEDIUM

All memory-to-memory — no external peripherals needed.

TestDescriptionPass Criteria
test_memcpy_4_bytesCopy [1,2,3,4] via DMAdst == [1,2,3,4]
test_memcpy_256_bytesCopy 256-byte patternAll match
test_memcpy_1024_bytesLarger transferAll match
test_aligned_uses_32bit4-byte aligned, check TCD ATTRSSIZE/DSIZE = Bits32
test_unaligned_uses_8bitOdd-aligned address, check TCD ATTRSSIZE/DSIZE = Bits8
test_not_complete_before_startConfigure only, no startis_complete() == false
test_complete_after_transferConfigure + start + waitis_complete() == true
test_clear_done_flagAfter complete, clear_done()is_complete() == false
test_multiple_channelsch0 + ch1 independent memcpyBoth dst correct
test_source_routingSet DMAMUX to ALWAYS_ON0, verify CHCFGSource=54, ENBL=1
test_disable_sourceSet then disable sourceCHCFG.ENBL == 0

tests/pwm.rs — 7 tests | Priority: MEDIUM

Register validation only — no oscilloscope needed.

TestDescriptionPass Criteria
test_max_duty_nonzeroFTM1 at 1kHz, max_duty_cycle()> 0
test_max_duty_reasonable1kHz @ 36MHz bus → MOD ~3599935000–36500
test_set_duty_zeroset_duty_cycle(0)Returns Ok(())
test_set_duty_maxset_duty_cycle(max)Returns Ok(())
test_set_duty_halfset_duty_cycle(max/2), read CnVCnV ≈ max/2
test_enable_channelEnable, verify CnSC MSB+ELSB bitsBits set
test_ftm_sc_registerVerify SC CLKS=System + PS=div1Register match

tests/i2c.rs — 4 tests | Priority: MEDIUM

Requires 4.7kΩ pull-up resistors on PTB0 (SCL) and PTB1 (SDA) to 3.3V.

TestDescriptionPass Criteria
test_write_nack_on_empty_busWrite to addr 0x50Err(AddressNack)
test_read_nack_on_empty_busRead from addr 0x50Err(AddressNack)
test_bus_recovers_after_nackNACK, then retrySecond also NACKs (not hang)
test_scan_empty_busProbe addrs 0x08–0x77All 112 return NACK

tests/usb.rs — 6 tests | Priority: LOW

Init/allocation tests only — no USB host needed.

TestDescriptionPass Criteria
test_clock_gate_enabledCheck SIM.SCGC4.USBOTG== 1
test_clock_48mhz_sourceCheck SIM.SOPT2 PLL + USBSRCBits correct
test_clock_dividerCheck SIM.CLKDIV2USBFRAC=1, USBDIV=2
test_alloc_ep0_controlAllocate EP0 controlSucceeds
test_alloc_bulk_endpointsAllocate bulk IN + OUTBoth succeed
test_enable_no_crashCall enable()No fault

tests/flash.rs — 6 tests | Priority: HIGH (read-only)

Read-only and error-path tests only. Erase/write tests are deliberately omitted — erasing the wrong sector bricks the chip and requires mass erase recovery via J-Link. A round-trip erase-write-read test targeting the last flash sector may be added later with explicit opt-in (e.g., --features destructive-flash-test).

TestDescriptionPass Criteria
test_capacityflash.capacity()== 262144 (mk20d7)
test_security_unsecuredsecurity_status() & 0x3== 0b10 (unsecured)
test_safety_floor_rejects_eraseerase_sector(0x000)Err(Protected)
test_safety_floor_rejects_writeprogram_longword(0x400, ..)Err(Protected)
test_not_aligned_rejectedprogram_longword(0x801, ..)Err(NotAligned)
test_read_vector_tableReadNorFlash::read() at offset 0, 16 bytesFirst 4 bytes (initial SP) are non-zero

tests/dac.rs — 5 tests | Priority: MEDIUM

Register-level validation only. Verifying actual analog output voltage requires an oscilloscope or ADC loopback (DAC0_OUT → ADC pin).

TestDescriptionPass Criteria
test_clock_gate_enabledCheck SIM.SCGC2.DAC0 after init== 1
test_init_value_zeroget_value() immediately after dac()== 0
test_set_get_roundtripset_value(2048) then get_value()== 2048
test_12bit_maskset_value(0xFFFF) then get_value()== 0x0FFF (masked)
test_enable_disabledisable(), read C0.DACEN; enable(), read C0.DACEN0 then 1

tests/rtc.rs — 7 tests | Priority: MEDIUM

Uses the on-board 32.768 kHz crystal. The oscillator may take up to 500 ms to start after first power-on, but is typically already running if VBAT is maintained. Tests that depend on counting use a SysTick delay to wait.

TestDescriptionPass Criteria
test_clock_gate_enabledCheck SIM.SCGC6.RTC after init== 1
test_oscillator_enabledRead RTC.CR.OSCE after init== 1
test_set_time_and_readset_time(1000), then seconds()Ok(1000) or Ok(1001)
test_counter_incrementsRead seconds, delay ~2s, read againsecond > first
test_time_valid_after_setset_time(500), time_is_valid()== true
test_alarm_flagset_time(100), set_alarm(101), delay ~2salarm_fired() == true
test_disable_stops_counterdisable(), read, delay ~2s, readValues equal

tests/cmp.rs — 7 tests | Priority: MEDIUM

Uses the CMP internal 6-bit DAC as a self-referencing test source. By connecting both inputs to the internal DAC (IN7) or setting known DAC levels, we can validate comparator behavior without external wiring.

TestDescriptionPass Criteria
test_clock_gate_enabledCheck SIM.SCGC4.CMP after init== 1
test_enabled_after_initRead CMP0 CR1.EN== 1
test_internal_dac_high_vs_lowPlus=INTERNAL_DAC @ level 63, minus=INTERNAL_DAC disabled (IN0, floating) — compare with known asymmetryOutput is deterministic (does not crash)
test_hysteresis_registerSet each hysteresis level (0-3), read CR0.HYSTCTRMatches for all 4 levels
test_invert_flips_outputRead output(), set_inverted(true), read againDifferent (or both stable)
test_disable_enabledisable() → read CR1.EN=0, enable() → read CR1.EN=1Both match
test_clear_flagsclear_flags(), read SCRCFR=0 and CFF=0

12.3 Loopback Tests (Require Wiring)

Wiring Requirements

WireFrom (Teensy Pin)To (Teensy Pin)Test Binary
GPIO loopbackPTD5 (pin 20)PTD6 (pin 21)gpio_loopback.rs
UART2 loopbackPTD3 (pin 8, TX)PTD2 (pin 7, RX)uart_loopback.rs
SPI0 loopbackPTC6 (pin 11, MOSI)PTC7 (pin 12, MISO)spi_loopback.rs

tests/gpio_loopback.rs — 2 tests

TestDescriptionPass Criteria
test_output_drives_inputPTD5 output → PTD6 input. High/lowPTD6 reads match
test_toggle_reflectedToggle PTD5 10×, verify PTD6 followsAll 10 match

tests/uart_loopback.rs — 5 tests

TestDescriptionPass Criteria
test_single_byteWrite 0xA5 TX → read RXRead == 0xA5
test_multiple_bytesWrite [0x01..0x04] → readAll match
test_all_byte_valuesWrite 0x00..0xFF → readAll 256 match
test_split_tx_rxRaw register loopbackByte matches
test_rx_empty_returns_wouldblockRead with nothing sentErr(WouldBlock)

tests/spi_loopback.rs — 7 tests

TestDescriptionPass Criteria
test_transfer_in_place1 byte 0xA5 via transfer_in_placeRead == 0xA5
test_transfer_multiple4 bytes in-placeAll match
test_transfer_separate_bufstransfer(read, write)read == write
test_all_byte_valuesTransfer 0x00..0xFFAll match
test_read_sends_zerosread() into bufferAll == 0x00
test_write_completeswrite() 4 bytesNo hang
test_flushflush() after writeCompletes

12.4 Async Tests (Interrupt-Driven)

These tests validate the Phase 15 async support — interrupt handler wiring, AtomicWaker wakeup, and embedded-hal-async / embedded-io-async trait implementations. Each async test binary requires:

  1. Interrupt handlers#[interrupt] functions that call the HAL's on_*_interrupt() exports
  2. NVIC unmaskingcortex_m::peripheral::NVIC::unmask() for each used interrupt
  3. A minimal executor — a block_on() helper that polls futures using SEV/WFE for efficient wakeup

Executor Helper

Since defmt-test runs synchronous #[test] functions, async tests use a minimal block_on() that bridges sync→async. The waker calls cortex_m::asm::sev() (Set Event) so that wfe() (Wait For Event) returns when an ISR calls waker.wake().

fn block_on<T>(future: impl core::future::Future<Output = T>) -> T {
    use core::pin::pin;
    use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};

    const VTABLE: RawWakerVTable = RawWakerVTable::new(
        |_| RawWaker::new(core::ptr::null(), &VTABLE),
        |_| cortex_m::asm::sev(),
        |_| cortex_m::asm::sev(),
        |_| {},
    );

    let waker = unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &VTABLE)) };
    let mut cx = Context::from_waker(&waker);
    let mut future = pin!(future);

    loop {
        match future.as_mut().poll(&mut cx) {
            Poll::Ready(val) => return val,
            Poll::Pending => cortex_m::asm::wfe(),
        }
    }
}

This works with embassy-sync's AtomicWaker because: ISR fires → HAL handler calls waker.wake() → SEV unblocks WFE → block_on re-polls the future.

Testsuite Dependencies

[dependencies]
mk20dx-hal = { path = "../mk20dx-hal", features = ["mk20d7", "rt", "critical-section", "async"] }
# ... existing deps ...

tests/async_timer.rs — 6 tests | Priority: HIGH | Wiring: None

Validates async PIT delay via embedded_hal_async::delay::DelayNs.

Interrupt wiring:

use mk20dx_hal::pac::interrupt;

#[interrupt] fn PIT0() { mk20dx_hal::timer::on_pit0_interrupt(); }
#[interrupt] fn PIT1() { mk20dx_hal::timer::on_pit1_interrupt(); }
#[interrupt] fn PIT2() { mk20dx_hal::timer::on_pit2_interrupt(); }
// PIT3 used as free-running reference timer (no ISR needed)
TestDescriptionPass Criteria
test_async_delay_1msblock_on(ch0.delay_us(1000))Completes without hang
test_async_delay_100ms_accuracych3 free-run reference, block_on(ch0.delay_us(100_000)), read ch3 deltaWithin ±2% of 3.6M ticks
test_async_delay_1usblock_on(ch0.delay_us(1))Completes
test_async_delay_ns_traitblock_on(DelayNs::delay_ns(&mut ch0, 5000))Completes
test_async_two_channels_sequentialdelay ch0 1ms, then delay ch1 1msBoth complete
test_async_delay_repeated10× sequential 1ms delays on ch0All 10 complete, ch3 delta within ±5% of 360k×10 ticks

Key validation points:

  • ISR clears TIF and disables TIE → Future sees TIE=0 as "ISR already fired" condition
  • AtomicWaker correctly bridges ISR → task wake → poll
  • PIT interrupt enable/disable lifecycle (TIE enabled during delay, disabled after)
  • Multiple channels don't interfere

tests/async_gpio_loopback.rs — 5 tests | Priority: MEDIUM | Wiring: PTD5 → PTD6

Validates embedded_hal_async::digital::Wait on input pins. Uses PIT one-shot timer to asynchronously toggle the output pin, so the edge/level change happens while the GPIO future is pending.

Interrupt wiring:

use mk20dx_hal::pac::interrupt;

#[interrupt] fn PORTD() { mk20dx_hal::gpio::on_portd_interrupt(); }
#[interrupt] fn PIT0() { mk20dx_hal::timer::on_pit0_interrupt(); }

Pattern for edge tests: Configure PIT ch0 as a one-shot timer. In the PIT0 ISR (after calling the HAL handler), toggle the output pin. Then block_on(input_pin.wait_for_*_edge()) — the PIT fires first, toggling the pin, which triggers the PORT ISR, which wakes the GPIO future.

TestDescriptionPass Criteria
test_wait_for_high_already_highSet PTD5 high, block_on(wait_for_high()) on PTD6Returns Ok(()) immediately (first poll)
test_wait_for_low_already_lowSet PTD5 low, block_on(wait_for_low()) on PTD6Returns Ok(()) immediately
test_wait_for_rising_edgePTD5 starts low. PIT one-shot 1ms → toggle high in ISR. block_on(wait_for_rising_edge())Returns Ok(()) after PIT fires
test_wait_for_falling_edgePTD5 starts high. PIT one-shot 1ms → toggle low in ISR. block_on(wait_for_falling_edge())Returns Ok(()) after PIT fires
test_wait_for_any_edgePTD5 starts low. PIT one-shot 1ms → toggle high in ISR. block_on(wait_for_any_edge())Returns Ok(()) after PIT fires

Key validation points:

  • PORT IRQC field correctly configured per wait type (0b1001=rising, 0b1010=falling, 0b1011=both)
  • Per-port AtomicWaker + per-pin AtomicU32 pending flag mechanism
  • ISR reads ISFR, clears flags (w1c), ORs into PORT_PENDING atomics
  • Level waits (high/low) re-check PDIR; edge waits check/clear pending bit via fetch_and
  • PCR ISF w1c hazard handled (.isf()._0() on all PCR modify calls)
  • IRQC cleared after wait completes

tests/async_dma.rs — 5 tests | Priority: MEDIUM | Wiring: None

Validates async DmaChannel::wait_complete() with memory-to-memory transfers.

Interrupt wiring:

use mk20dx_hal::pac::interrupt;

#[interrupt] fn DMA_CH0() { mk20dx_hal::dma::on_dma0_interrupt(); }
#[interrupt] fn DMA_CH1() { mk20dx_hal::dma::on_dma1_interrupt(); }
TestDescriptionPass Criteria
test_async_memcpy_4_bytesConfigure memcpy, start, block_on(wait_complete())dst == [1,2,3,4]
test_async_memcpy_256_bytes256-byte patterned transferAll bytes match
test_async_wait_complete_returns_okVerify wait_complete() returns Ok(())Result is Ok
test_async_two_channelsch0 + ch1 concurrent memcpy, block_on both sequentiallyBoth dst correct
test_async_error_on_bad_alignment32-bit transfer with misaligned source, block_on(wait_complete())Returns Err(DmaError)

Key validation points:

  • ISR clears CINT and wakes per-channel AtomicWaker
  • wait_complete() enables INTMAJOR, polls for DONE/ERROR
  • Interrupt disabled and DONE flag cleared after completion
  • Error path: ES register parsed into DmaError variants

tests/async_uart_loopback.rs — 5 tests | Priority: MEDIUM | Wiring: PTD3 (TX) → PTD2 (RX)

Validates embedded_io_async::Read and embedded_io_async::Write for UART via loopback.

Interrupt wiring:

use mk20dx_hal::pac::interrupt;

#[interrupt] fn UART2_RX_TX() { mk20dx_hal::uart::on_uart2_rx_tx_interrupt(); }
TestDescriptionPass Criteria
test_async_write_read_single_byteAsync write 0xA5, async read 1 byteRead == 0xA5
test_async_write_read_multipleAsync write [0x01..0x04], async read 4 bytesAll match in order
test_async_write_flushAsync write + async flushCompletes without hang
test_async_split_tx_rxSplit serial → Tx + Rx. Async write on Tx, async read on RxByte matches
test_async_all_byte_valuesWrite/read 0x00..0xFF one at a timeAll 256 match

Key validation points:

  • ISR checks S1 flags: wakes RX waker on RDRF/errors, wakes TX waker on TDRE (disables TIE)
  • RIE (RX interrupt enable) enabled during read, disabled after
  • TIE (TX interrupt enable) enabled only when TDRE not ready, disabled by ISR
  • TCIE (TX complete interrupt enable) for flush
  • Split Tx/Rx types share the same underlying waker pair
  • Reuses existing nb_read/nb_write/nb_flush helpers internally

tests/async_spi_loopback.rs — 7 tests | Priority: MEDIUM | Wiring: PTC6 (MOSI) → PTC7 (MISO)

Validates embedded_hal_async::spi::SpiBus<u8> via MOSI→MISO loopback.

Interrupt wiring:

use mk20dx_hal::pac::interrupt;

#[interrupt] fn SPI0() { mk20dx_hal::spi::on_spi0_interrupt(); }
TestDescriptionPass Criteria
test_async_transfer_in_place1 byte 0xA5 via async transfer_in_placeRead == 0xA5
test_async_transfer_multiple4 bytes in-placeAll match
test_async_transfer_separate_bufsasync transfer(read, write)read == write
test_async_all_byte_valuesTransfer 0x00..0xFFAll match
test_async_read_sends_zerosasync read() into bufferAll == 0x00
test_async_write_completesasync write() 4 bytesNo hang
test_async_flushasync flush() after writeCompletes

Key validation points:

  • ISR clears TCF in SR, disables TCF_RE in RSER, wakes per-instance AtomicWaker
  • transfer_byte_async: waits for TFFF (spin), pushes data, enables TCF_RE, awaits, reads POPR
  • RX overflow (RFOF) detection and error return
  • Per-byte async loop for multi-byte operations

tests/async_i2c.rs — 4 tests | Priority: MEDIUM | Wiring: 4.7kΩ pull-ups on PTB0/PTB1

Validates embedded_hal_async::i2c::I2c<SevenBitAddress> on empty bus.

Interrupt wiring:

use mk20dx_hal::pac::interrupt;

#[interrupt] fn I2C0() { mk20dx_hal::i2c::on_i2c0_interrupt(); }
TestDescriptionPass Criteria
test_async_write_nack_on_empty_busAsync write to addr 0x50Err(AddressNack)
test_async_read_nack_on_empty_busAsync read from addr 0x50Err(AddressNack)
test_async_bus_recovers_after_nackNACK, then retrySecond also NACKs (not hang)
test_async_scan_empty_busAsync probe addrs 0x08–0x77All 112 return NACK

Key validation points:

  • ISR disables IICIE (does NOT clear IICIF — driver reads S register for ARBL/RXAK first)
  • wait_transfer_async(): enables IICIE, polls for IICIF, checks ARBL
  • START/RSTART/STOP sequencing mirrors blocking transaction protocol
  • Bus idle check (BUSY spin-wait — not interrupt-driven)
  • Error recovery: MST cleared for STOP after NACK, bus returns to idle

Async Test Summary

BinaryTestsWiringInterrupts
async_timer6NonePIT0, PIT1, PIT2
async_gpio_loopback5PTD5 → PTD6PORTD, PIT0
async_dma5NoneDMA_CH0, DMA_CH1
async_uart_loopback5PTD3 → PTD2UART2_RX_TX
async_spi_loopback7PTC6 → PTC7SPI0
async_i2c4Pull-ups PTB0/PTB1I2C0
Total32

12.5 Tests Requiring Additional Hardware (Not Implemented)

These tests cannot be performed with the current test suite and would require additional equipment.

CategoryWhatWhyHardware Needed
I2C device communicationRead/write to real I2C slaveBus protocol needs a responding deviceI2C EEPROM (AT24C32) + 4.7kΩ pull-ups
USB enumerationVerify device appears on hostRequires USB host handshakeUSB cable + host PC running lsusb
USB data transferEnd-to-end host↔device I/OFull CDC/HID class testingUSB cable + host test script
PWM frequency accuracyMeasure actual output waveformRegister validation can't confirm analog outputOscilloscope or logic analyzer
PWM duty cycle accuracyMeasure analog duty cycleNeed time-domain measurementOscilloscope
Delay absolute accuracyVerify actual wall-clock timingPIT crosscheck validates relative but not absoluteOscilloscope or frequency counter
ADC external pin accuracyMeasure real-world voltagesInternal refs only test internal channelsKnown voltage source + wiring to ADC pin
UART baud rate accuracyVerify actual bit timingLoopback proves data integrity, not timingLogic analyzer
DMA peripheral transfersDMA with SPI/UART/ADC triggersMemory-to-memory only without peripheral sourceConfigured peripheral + peripheral-specific wiring
SPI with real deviceProtocol correctness with slaveLoopback tests framing, not CS/protocolSPI flash or EEPROM

12.6 Running Tests

cd mk20dx-testsuite

# Self-tests (no wiring needed)
cargo test --test watchdog
cargo test --test clocks
cargo test --test gpio
cargo test --test delay
cargo test --test timer
cargo test --test adc
cargo test --test dma
cargo test --test pwm

# Requires pull-ups on PTB0/PTB1
cargo test --test i2c

# Requires loopback wires
cargo test --test gpio_loopback   # PTD5 → PTD6
cargo test --test uart_loopback   # PTD3 → PTD2
cargo test --test spi_loopback    # PTC6 → PTC7

# Async self-tests (no wiring needed)
cargo test --test async_timer
cargo test --test async_dma

# Async loopback tests (require wiring + pull-ups)
cargo test --test async_gpio_loopback   # PTD5 → PTD6
cargo test --test async_uart_loopback   # PTD3 → PTD2
cargo test --test async_spi_loopback    # PTC6 → PTC7
cargo test --test async_i2c             # Pull-ups PTB0/PTB1

# Run all
cargo test

Phase 13: Flash Memory (FTFL)

13.1 Hardware

The FTFL controller provides erase and program access to the internal program flash:

  • FTFL at 0x4002_0000 — identical register layout on both mk20d5 and mk20d7
  • Program flash: 128 KB (mk20d5) / 256 KB (mk20d7), memory-mapped at 0x0000_0000
  • Sector size: 2 KB (smallest erase unit)
  • Write unit: 4 bytes (longword), must be longword-aligned
  • Single flash block — cannot read program flash while a command is running

13.2 Critical Constraints

  1. RAM execution: The function that launches commands and polls CCIF must execute from RAM (#[link_section = ".data"]) because flash is unreadable while CCIF=0.
  2. Flash config protection: Sector 0 contains the flash config field (0x400-0x40F). Erasing it without restoring FSEC=0xFE bricks the chip. A safety floor at 0x800 prevents all writes below sector 1.
  3. Critical section: ISR code lives in flash, so flash commands must run inside cortex_m::interrupt::free().
  4. No cumulative programming: A longword can only be written once after erase.

13.3 Extension Trait API

pub trait FlashExt {
    fn flash(self) -> Flash;
}
impl FlashExt for pac::Ftfl { ... }

The Flash struct is zero-sized (all state in hardware registers). Consuming the PAC Ftfl prevents aliased access. Provides HAL-specific methods (erase_sector, program_longword, is_protected, security_status) plus embedded_storage::nor_flash trait implementations (ReadNorFlash, NorFlash).

13.4 Validation

On-target testing requires extreme care. Initial validation should use a sector well above the firmware (e.g., last sector of flash) and verify round-trip erase-write-read.


Phase 14: DAC, RTC, CMP

14.1 DAC (mk20d7 only)

Single-instance driver for the 12-bit DAC0 peripheral. Entire module gated with #[cfg(feature = "mk20d7")]. Simple single-instance pattern (no macro needed), similar to watchdog.

API: DacExt::dac(sim)Dac with set_value(u16), get_value(), set_vref(), enable(), disable().

Init defaults: DACEN=1, VREF1 (VDDA), software trigger, buffer disabled, high power mode, output=0. No Clocks parameter.

14.2 RTC (both variants)

Single-instance driver for the Real-Time Clock. Uses independent 32.768 kHz oscillator (on-board on Teensy 3.x). Key constraint: TSR can only be written when TCE=0.

API: RtcExt::rtc(sim)Rtc with seconds()Result<u32, TimeInvalid>, set_time(u32), alarm support, interrupt control.

Init preserves existing time if VBAT maintained. Enables oscillator with ~10 pF caps, disables interrupts, starts counter if valid.

14.3 CMP (multi-instance)

Macro-generated driver following the adc.rs pattern. CMP0+CMP1 on both variants, CMP2 on mk20d7 only. All share a single SIM clock gate bit (SCGC4.CMP).

Critical w1c hazard in SCR register: CFF (bit 1) and CFR (bit 2) are w1c flags mixed with r/w config bits. All SCR writes use write() with manual config bit preservation to avoid accidentally clearing flags.

API: CmpExt::cmp(plus, minus, sim)Cmp<Instance> with output reading, input mux, internal 6-bit DAC, hysteresis, edge detection, and interrupt control.


Phase 15: Async Support

15.1 Architecture

The HAL uses a split-responsibility interrupt pattern:

  1. HAL exports on_*_interrupt() functions per peripheral — these clear hardware flags and wake AtomicWakers
  2. User writes #[interrupt] handlers that call the HAL's functions
  3. User unmasks IRQs in the NVIC
  4. Async trait impls use core::future::poll_fn + AtomicWaker::register/wake pattern

This avoids linker conflicts and gives users full control over interrupt priority and NVIC configuration. The pattern is used by embassy-stm32 and embassy-nrf.

15.2 Dependencies

embassy-sync = { version = "0.7", optional = true }
embedded-hal-async = { version = "1.0", optional = true }
embedded-io-async = { version = "0.7", optional = true }

[features]
async = ["dep:embassy-sync", "dep:embedded-hal-async", "dep:embedded-io-async"]

15.3 Per-Peripheral Design

PIT Timer (timer.rs): One AtomicWaker per channel (4 total). ISR clears TIF and disables TIE to prevent re-triggering. Future checks TIE=0 && TEN=1 as "ISR already fired" condition.

GPIO (gpio.rs): One AtomicWaker per port (5 total) + one AtomicU32 per port for per-pin pending flags. ISR reads ISFR, clears it (w1c), ORs into pending atomics, wakes. Level waits (high/low) re-check pin level on each poll. Edge waits use fetch_and to atomically check and clear their pin's pending bit.

DMA (dma.rs): One AtomicWaker per channel (4 on mk20d5, 16 on mk20d7). ISR clears CINT, wakes. wait_complete() enables INTMAJOR, polls for DONE/ERROR.

UART (uart.rs): Per-instance RX and TX AtomicWakers. ISR checks S1 flags: wakes RX on RDRF/error, wakes TX on TDRE (disabling TIE) or TC (disabling TCIE). Async read waits for first byte via interrupt, then drains FIFO non-blockingly. Async write sends first byte via interrupt, then fills FIFO non-blockingly.

SPI (spi.rs): Per-instance AtomicWaker. ISR clears TCF in SR, disables TCF_RE in RSER, wakes. Async transfer_byte: waits for TFFF (spin), pushes data, enables TCF_RE, awaits completion, reads POPR.

I2C (i2c.rs): Per-instance AtomicWaker. ISR disables IICIE in C1 (does NOT clear IICIF — driver needs to read S register for ARBL/RXAK before clearing). Full async transaction mirrors blocking protocol: START → addr → data → STOP, with RSTART for direction changes.

15.4 User Setup Example

use cortex_m_rt::interrupt;

#[interrupt]
fn PIT0() { mk20dx_hal::timer::on_pit0_interrupt(); }
#[interrupt]
fn UART0_RX_TX() { mk20dx_hal::uart::on_uart0_rx_tx_interrupt(); }
#[interrupt]
fn PORTA() { mk20dx_hal::gpio::on_porta_interrupt(); }

// In main:
unsafe {
    cortex_m::peripheral::NVIC::unmask(pac::Interrupt::PIT0);
    cortex_m::peripheral::NVIC::unmask(pac::Interrupt::UART0_RX_TX);
    cortex_m::peripheral::NVIC::unmask(pac::Interrupt::PORTA);
}

Phase 16: EEPROM / FlexMemory

16.1 Hardware

The MK20DX series includes FlexMemory — a hardware EEPROM emulation system with hardware-managed wear leveling. This is NOT software-emulated EEPROM; the flash controller handles all wear-leveling, page management, and journaling internally.

Components:

  • FlexNVM (D-flash): 32 KB of data flash at 0x1000_0000, used as EEPROM backup
  • FlexRAM: 2 KB at 0x1400_0000, appears as byte-addressable EEPROM when configured in EEE mode
  • FTFL controller: Same controller as program flash — shared between Flash and Eeprom drivers

16.2 How FlexMemory EEPROM Works

  1. Partition once: FTFL command 0x80 (Program Partition) splits FlexNVM into EEPROM backup vs. data flash, and sets FlexRAM mode. This is typically done once at factory provisioning.
  2. Runtime: FlexRAM at 0x1400_0000 is byte-readable instantly (memory-mapped) and byte-writable. Writes trigger the hardware EEE state machine which journals changes to FlexNVM backup.
  3. After write: Poll FCNFG.EEERDY to wait for the EEE state machine to complete the background flash write.
  4. Power loss: On next boot, the hardware automatically restores FlexRAM contents from the FlexNVM journal — no software involvement.

16.3 FTFL Sharing Design

Both Flash (program flash) and Eeprom (FlexMemory) use the same FTFL controller. Options:

Option A: Split ownershipFlashExt::flash(ftfl) returns (Flash, Eeprom). Both are zero-sized and share FTFL via raw pointer access (same as current Flash). The user must ensure they don't issue FTFL commands concurrently (enforced by &mut self on both).

Option B: Combined driver — Single FlashController type that provides both flash and EEPROM APIs.

Recommended: Option A — keeps existing Flash API unchanged and allows EEPROM to be used independently.

16.4 API

pub trait FlashExt {
    fn flash(self) -> (Flash, Eeprom);
}

pub struct Eeprom { _ftfl: () }

impl Eeprom {
    /// Read a byte from EEPROM (FlexRAM).
    pub fn read(&self, offset: u16) -> u8;

    /// Read a slice from EEPROM.
    pub fn read_slice(&self, offset: u16, buf: &mut [u8]);

    /// Write a byte to EEPROM. Waits for EEE state machine.
    pub fn write(&mut self, offset: u16, value: u8) -> Result<(), EepromError>;

    /// Write a slice to EEPROM.
    pub fn write_slice(&mut self, offset: u16, data: &[u8]) -> Result<(), EepromError>;

    /// Check if FlexRAM is configured for EEPROM mode.
    pub fn is_eee_enabled(&self) -> bool;

    /// Partition command (one-time factory provisioning).
    pub fn partition(&mut self, eeprom_size: EepromSize, backup_size: BackupSize) -> Result<(), EepromError>;

    /// EEPROM capacity in bytes.
    pub fn capacity(&self) -> usize;
}

16.5 Key Constraints

  • Partition is permanent until mass erase — partition command should require explicit confirmation (builder pattern or separate unsafe function)
  • EEERDY polling — writes must wait for FCNFG.EEERDY before returning
  • FTFL mutual exclusion — cannot issue flash commands while EEE state machine is active, and vice versa. Both Flash and Eeprom use &mut self, so the borrow checker prevents simultaneous access within a single task. Cross-task safety requires user discipline.
  • FlexRAM mode — FlexRAM defaults to traditional RAM mode after reset. Must check/set EEPROM mode via FCNFG.RAMRDY or Set FlexRAM command (0x81).
  • Endurance — Hardware provides ~10,000–100,000 write cycles per EEPROM location (depending on partition sizes), with automatic wear leveling across the FlexNVM backup area.

16.6 Variant Differences

MK20D5 (Teensy 3.0)MK20D7 (Teensy 3.1/3.2)
FlexNVM32 KB at 0x1000_000032 KB at 0x1000_0000
FlexRAM2 KB at 0x1400_00002 KB at 0x1400_0000
Max EEPROM2 KB2 KB

Both variants have identical FlexMemory — no #[cfg] needed.

16.7 Validation

TestDescriptionPass Criteria
test_eee_mode_checkCheck is_eee_enabled()Returns true/false without crash
test_read_eepromread(0)Returns a byte, no fault
test_write_read_roundtripwrite(offset, 0xA5) then read(offset)Read == 0xA5
test_write_slice_roundtripWrite 16 bytes, read backAll match
test_capacitycapacity()<= 2048

Note: Partition tests should be in a separate opt-in binary (destructive, one-time operation).

Reference: K20 ref manual chapter 29 (FTFL), chapter 30 (FlexMemory)


Phase 17: Peripheral Improvements

17.1 defmt::Format Feature

Add an optional defmt feature flag that derives defmt::Format on all public error types and key structs, enabling structured logging of errors in defmt-based applications.

[dependencies]
defmt = { version = "0.3", optional = true }

[features]
defmt = ["dep:defmt"]

Types to derive defmt::Format on:

  • uart::Error — already has Debug, add #[cfg_attr(feature = "defmt", derive(defmt::Format))]
  • spi::Error
  • i2c::Error
  • dma::DmaError
  • flash::FlashError
  • adc::Resolution, adc::Averaging
  • clocks::Clocks (manual impl — display frequencies)
  • timer::PitChannel status
  • cmp::Hysteresis, cmp::Input
  • rtc::TimeInvalid

Pattern:

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum Error { ... }

17.2 Pin Validation Traits

Add marker traits that encode valid pin-peripheral mappings at compile time. Currently, any Alternate<N> pin can be passed to any peripheral constructor — incorrect MUX values compile but fail silently at runtime.

// Marker traits for SPI0 pins
pub trait Spi0SckPin { const MUX: u8; }
pub trait Spi0MosiPin { const MUX: u8; }
pub trait Spi0MisoPin { const MUX: u8; }

// Implementations for valid pins
impl Spi0SckPin for Pin<'C', 5, Alternate<2>> { const MUX: u8 = 2; }
impl Spi0SckPin for Pin<'D', 1, Alternate<2>> { const MUX: u8 = 2; }

// Constructor enforces valid pin types
impl SpiExt for pac::Spi0 {
    fn spi<SCK: Spi0SckPin, MOSI: Spi0MosiPin, MISO: Spi0MisoPin>(
        self, sck: SCK, mosi: MOSI, miso: MISO, ...
    ) -> Spi<Spi0>;
}

Peripherals to add pin validation:

  • SPI0, SPI1: SCK, MOSI, MISO, PCS0
  • UART0, UART1, UART2: TX, RX
  • I2C0, I2C1: SDA, SCL
  • FTM0 ch0-7, FTM1 ch0-1, FTM2 ch0-1: PWM output pins

Source: K20 ref manual signal multiplexing table (chapter 10).

17.3 release() Methods

Add release() / free() methods to all peripheral drivers that return the consumed PAC peripheral, enabling reconfiguration or mode changes at runtime.

impl Serial<UART> {
    pub fn release(self) -> (pac::Uart0, TxPin, RxPin) { ... }
}

impl Spi<SPI> {
    pub fn release(self) -> (pac::Spi0, SckPin, MosiPin, MisoPin) { ... }
}

impl I2c<I2C> {
    pub fn release(self) -> (pac::I2c0, SdaPin, SclPin) { ... }
}

This requires storing the PAC peripheral in the driver struct (currently some drivers use zero-sized types with raw pointer access). Evaluate the trade-off: storing the PAC peripheral adds a word of storage but enables proper release.

Drivers to add release():

  • Serial<UART>(UartN, TxPin, RxPin)
  • Spi<SPI>(SpiN, SckPin, MosiPin, MisoPin)
  • I2c<I2C>(I2cN, SdaPin, SclPin)
  • Adc<ADC>AdcN
  • DacDac0
  • Rtcpac::Rtc
  • DelaySYST (already implemented)
  • PitChannelspac::Pit (would need all 4 channels returned)

17.4 SpiDevice Helper

Provide a convenience re-export or wrapper for embedded-hal-bus ExclusiveDevice, which combines SpiBus + CS OutputPin into an SpiDevice. This is the most common user need and currently requires them to find and import embedded-hal-bus themselves.

// Re-export from embedded-hal-bus (add as optional dependency)
#[cfg(feature = "embedded-hal-bus")]
pub use embedded_hal_bus::spi::ExclusiveDevice;

Phase 18: DMA-Backed Peripheral Transfers

18.1 Overview

Integrate DMA with SPI, UART, and ADC for high-throughput transfers without CPU involvement during data movement. The DMA channel handles reading from / writing to the peripheral data register, freeing the CPU.

18.2 DMA + SPI

Two DMA write modes:

impl Spi<SPI0> {
    /// 8-bit DMA write — data bytes only, no per-frame command control.
    pub fn write_dma<'a>(&'a mut self, buf: &'a [u8], ch: &'a mut DmaChannel<CH>) -> DmaTransfer<'a, CH>;

    /// 32-bit DMA write to PUSHR — each u32 is a pre-packed command word
    /// with data + PCS + CONT + EOQ fields. Use `pack_pushr()` to build.
    pub fn write_dma_pushr<'a>(&'a mut self, buf: &'a [u32], ch: &'a mut DmaChannel<CH>) -> DmaTransfer<'a, CH>;
}

/// Build a complete PUSHR command word (const fn, module-level).
pub const fn pack_pushr(data: u16, pcs: u8, cont: bool, eoq: bool) -> u32;

MCR/SR/RSER control methods for DMA workflows:

impl Spi<SPI0> {
    pub fn flush_fifos(&mut self);           // CLR_TXF + CLR_RXF
    pub fn set_rx_fifo(&mut self, enabled: bool);  // DIS_RXF
    pub fn set_rooe(&mut self, enabled: bool);     // ROOE
    pub fn clear_status(&mut self);          // Clear all W1C flags
    pub fn disable_dma_requests(&mut self);  // Zero RSER
    pub fn wait_tx_complete(&self);          // Block until TXCTR=0 + TCF
}

DMAMUX sources:

  • SPI0 RX: DmaSource::SPI0_RX (14)
  • SPI0 TX: DmaSource::SPI0_TX (15)
  • SPI1 RX/TX: mk20d7 only

18.3 DMA + UART

impl Serial<UART0> {
    pub fn write_dma<'a>(&'a mut self, buf: &'a [u8], ch: &'a mut DmaChannel<CH>) -> DmaUartWrite<'a>;
    pub fn read_dma<'a>(&'a mut self, buf: &'a mut [u8], ch: &'a mut DmaChannel<CH>) -> DmaUartRead<'a>;
}

DMAMUX sources:

  • UART0 RX: DmaSource::UART0_RX (2)
  • UART0 TX: DmaSource::UART0_TX (3)

18.4 DMA + ADC

impl Adc<ADC0> {
    /// Trigger ADC conversions via hardware trigger and collect results via DMA.
    pub fn read_dma<'a>(
        &'a mut self,
        channels: &'a [u8],
        results: &'a mut [u16],
        ch: &'a mut DmaChannel<CH>,
    ) -> DmaAdcRead<'a>;
}

ADC can trigger DMA on conversion complete (SC2.DMAEN=1). DMAMUX source: DmaSource::ADC0 (40).

18.5 Transfer Lifetime Safety

DMA transfers borrow the peripheral, buffers, and DMA channel for the transfer duration. The DmaTransfer handle ensures buffers live long enough and prevents aliased access. Dropping the handle aborts the transfer.

18.6 Async Integration

DMA transfers integrate naturally with the async DMA wait_complete():

let transfer = spi.transfer_dma(tx, rx, &mut dma_ch0, &mut dma_ch1);
transfer.start();
dma_ch0.wait_complete().await?;
dma_ch1.wait_complete().await?;

Phase 19: FTM Input Capture / Output Compare

19.1 Overview

The FlexTimer Module supports more than just PWM. Adding input capture and output compare extends FTM utility for timing measurements, pulse counting, and precise event generation.

19.2 Input Capture

Captures the FTM counter value on a pin edge (rising, falling, or both). Used for pulse width measurement, frequency measurement, and event timestamping.

pub struct InputCapture<FTM, const CH: u8> { ... }

impl<FTM, const CH: u8> InputCapture<FTM, CH> {
    /// Read the last captured value.
    pub fn capture(&self) -> Option<u16>;

    /// Wait for next capture event (blocking).
    pub fn wait(&mut self) -> nb::Result<u16, Infallible>;

    /// Enable capture interrupt.
    pub fn enable_interrupt(&mut self);
}

Channel mode: CnSC MSB:MSA=00, ELSB:ELSA selects edge (01=rising, 10=falling, 11=both).

19.3 Output Compare

Toggles/sets/clears a pin when the FTM counter matches the channel value. Used for precise timing events.

pub struct OutputCompare<FTM, const CH: u8> { ... }

impl<FTM, const CH: u8> OutputCompare<FTM, CH> {
    pub fn set_compare(&mut self, value: u16);
    pub fn set_action(&mut self, action: CompareAction); // Toggle, Set, Clear
}

19.4 Quadrature Decoder

FTM1 and FTM2 support quadrature decoder mode (QDCTRL register). Used for rotary encoders.

pub struct QuadratureDecoder<FTM> { ... }

impl QuadratureDecoder<FTM1> {
    pub fn new(ftm: pac::Ftm1, pha: PhAPin, phb: PhBPin, sim: &pac::Sim) -> Self;
    pub fn count(&self) -> u16;
    pub fn direction(&self) -> Direction;
}

19.5 Validation

TestDescriptionPass Criteria
test_output_compare_toggleOC on pin, use PIT to measure periodToggle occurs
test_input_capture_loopbackFTM output → FTM input captureCaptured value reasonable
test_quad_decoder_manualRead counter, manually pulse pinsCounter increments

Reference: K20 ref manual chapter 36 (FTM)


Phase 20: Low-Power Modes

20.1 Overview

The MK20 supports multiple low-power modes for battery-operated or power-sensitive applications. Each mode trades off wake-up latency vs. power savings.

20.2 Power Modes

ModeCoreBusFlashPeripheralsWake Sources
RunActiveActiveActiveActive
WaitWFIActiveActiveActiveAny interrupt
VLPR4 MHz max1 MHz max1 MHz maxLimited
VLPWWFI (4 MHz)1 MHz max1 MHz maxLimitedAny interrupt
VLPSOffOffOffSomeLLWU, GPIO, LPTMR
LLSOffOffOffOffLLWU only
VLLSxOffOffOffOffLLWU, reset

20.3 MCG Mode Transitions

Low-power modes require MCG clock mode changes:

  • Run → VLPR: MCG must be in BLPI (Bypassed Low-Power Internal) mode, 4 MHz max
  • VLPR → Run: Transition back through BLPE → PBE → PEE
  • VLPS/LLS: Enter from Run or VLPR via SMC registers
pub struct PowerControl { _smc: () }

impl PowerControl {
    pub fn new(smc: pac::Smc) -> Self;

    /// Enter Wait mode (WFI — wakes on any interrupt).
    pub fn wait(&self);

    /// Enter VLPR mode (low-power run, 4 MHz max).
    /// Requires MCG reconfiguration to BLPI mode.
    pub fn enter_vlpr(&mut self, mcg: &mut Mcg) -> Result<(), PowerError>;

    /// Exit VLPR mode back to normal Run.
    pub fn exit_vlpr(&mut self, mcg: &mut Mcg) -> Result<(), PowerError>;

    /// Enter VLPS mode (very low-power stop).
    /// CPU halts; wakes on LLWU, GPIO, or LPTMR.
    pub fn enter_vlps(&mut self);

    /// Enter LLS mode (low-leakage stop).
    /// CPU halts; wakes only on LLWU sources.
    pub fn enter_lls(&mut self);
}

20.4 LLWU (Low-Leakage Wake-Up Unit)

The LLWU provides wake-up sources for deep sleep modes (LLS, VLLSx):

pub struct Llwu { ... }

impl Llwu {
    pub fn new(llwu: pac::Llwu) -> Self;
    pub fn enable_pin_wakeup(&mut self, pin: LlwuPin, edge: WakeEdge);
    pub fn enable_module_wakeup(&mut self, module: LlwuModule);
    pub fn wakeup_flags(&self) -> u32;
    pub fn clear_flags(&mut self);
}

20.5 Dependencies

  • LPTMR (Low-Power Timer) may be needed as a wake source — consider adding an LPTMR driver in this phase or as a separate sub-phase
  • MCG BLPI/BLPE mode transitions added to the clocks module. PeeState saves the SIM CLKDIV1 register so BLPI exit restores the correct dividers regardless of which ClockSpeed preset was used

20.6 Validation

Low-power mode testing is challenging without current measurement equipment. Register-level validation and wake-up source testing are possible:

TestDescriptionPass Criteria
test_wait_wakes_on_pitEnter wait, PIT fires, wakeResumes after PIT
test_vlpr_entry_exitEnter VLPR, check MCG, exitMCG returns to PEE
test_llwu_flag_setConfigure LLWU, trigger, checkFlag set correctly

Reference: K20 ref manual chapters 6 (Power Management), 7 (LLWU), 15 (SMC)


Phase 21: Additional Peripherals

21.1 LPTMR (Low-Power Timer)

A 16-bit timer/counter that operates in all power modes including VLPS and LLS. Can use the 1 kHz LPO clock, 32.768 kHz RTC oscillator, or external pin as clock source.

Use cases: periodic wake from low-power modes, pulse counting, long-period timing (seconds to minutes).

pub struct Lptmr { ... }

impl Lptmr {
    pub fn new(lptmr: pac::Lptmr0, sim: &pac::Sim) -> Self;
    pub fn start(&mut self, period_ms: u32);
    pub fn wait(&mut self) -> nb::Result<(), Infallible>;
    pub fn enable_interrupt(&mut self);
}

Reference: K20 ref manual chapter 27 (LPTMR)

21.2 CRC Module

Hardware CRC computation with configurable polynomial (CRC-16, CRC-32).

pub struct Crc { ... }

impl Crc {
    pub fn new(crc: pac::Crc, config: CrcConfig) -> Self;
    pub fn feed(&mut self, data: &[u8]);
    pub fn result(&self) -> u32;
    pub fn reset(&mut self);
}

Reference: K20 ref manual chapter 26 (CRC)


Design Decisions

Extension Traits for Peripheral Initialization

Following the dominant pattern across the embedded Rust ecosystem (stm32f4xx-hal, stm32f1xx-hal, stm32h7xx-hal, nrf-hal, rp2040-hal, lpc8xx-hal, imxrt-hal), peripherals are initialized via extension traits on PAC types:

  • McgExt on pac::Mcg — provides constrain() returning an MCG builder, which has freeze() (default speed) and freeze_at(ClockSpeed, ..) (overclock presets, mk20d7 only) methods that configure clocks and return the Clocks token
  • GpioExt on port types — provides split() that consumes both PORT and GPIO peripherals, enables the SIM clock gate, and returns individual pin structs
  • WdogExt on pac::Wdog — provides disable() that consumes the watchdog peripheral

This pattern was chosen after surveying 9 HAL crates. Benefits:

  1. Discoverability — users type peripheral. and see available methods via IDE autocomplete
  2. Ecosystem consistency — developers familiar with any other HAL instantly know the API shape
  3. Ownership enforcementconstrain()/split() consume the PAC peripheral, preventing raw register access
  4. Prelude-friendly — extension traits are re-exported in the prelude so use mk20dx_hal::prelude::* makes them available

Example usage:

use mk20dx_hal::prelude::*;
let dp = pac::Peripherals::take().unwrap();
dp.WDOG.disable();
let clocks = dp.MCG.constrain().freeze(dp.OSC, &dp.SIM);  // 72 MHz default
// Or overclock (mk20d7 only):
// use mk20dx_hal::clocks::ClockSpeed;
// let clocks = dp.MCG.constrain().freeze_at(ClockSpeed::Mhz96, dp.OSC, &dp.SIM);
let pins_a = dp.PORTA.split(dp.PTA, &dp.SIM);

Variant Selection via Feature Flags (not separate crates)

Both MK20DX128 and MK20DX256 share the same peripheral register layout — they differ only in peripheral instance counts and clock limits. A single HAL crate with feature flags avoids code duplication. Feature-gated cfg blocks expose additional peripherals on the larger variant.

Const Generics for GPIO (not macros)

Modern Rust (1.51+) supports const generics. Using Pin<const PORT: char, const N: u8, MODE> instead of macro-generated PA0, PA1, etc. reduces code bloat and makes the implementation more readable. Trade-off: slightly more verbose type annotations in user code.

embedded-hal 1.0 Only (no 0.2 compatibility)

Target embedded-hal 1.0 exclusively. The 1.0 API is stable and the ecosystem is migrating. No compatibility shim for 0.2 traits — keeps the implementation simple.

Blocking First (async later)

Implement blocking embedded-hal traits first. Async (embedded-hal-async) can be added in a future phase now that the blocking drivers and DMA subsystem (Phase 10) are complete.

Error Types

  • GPIO: Infallible (register writes cannot fail)
  • UART/SPI/I2C: Peripheral-specific error enums implementing the embedded-hal::*::Error traits with appropriate ErrorKind mappings
  • Delay: Infallible

Reference Implementations

Study these HALs for architectural patterns:

HALRelevanceNotes
stm32f4xx-halGold standardCortex-M4, comprehensive type-state GPIO, excellent clock config
imxrt-halMost mature NXP HALMulti-chip family, good architecture for feature-gated variants
mkw41z-halSame Kinetis familySimilar PORT/GPIO split, MCG clock tree
kea-halKinetis KEASimpler Kinetis part, same patterns

Risk Assessment

RiskLikelihoodImpactMitigation
Clock tree configuration is wrongMediumHighCross-reference against Teensy 3.x Arduino core startup code (mk20dx128.c) which is known-working
GPIO pin mux table has errorsLowMediumVerify against K20 ref manual signal multiplexing table
Baud rate calculations are offMediumMediumTest at standard baud rates (9600, 115200); compare against Arduino core calculations
I2C divider table is incompleteLowMediumFull table is in K20 ref manual chapter 37; verify against Arduino Wire library
Feature-gated compilation breaks one variantMediumLowCI checks both variants
PAC has undiscovered register bugsLowMediumPAC is validated against kinetis.h with 0 address mismatches; all known Kinetis SVD bugs verified absent; hardware testing will catch remaining issues

Open Questions

  1. Memory layout for Teensy bootloader: Teensy uses a custom HalfKay bootloader. Does the flash start address need to account for a bootloader offset, or is the bootloader in a separate flash region?

  2. Flash configuration field: The MK20 has a 16-byte flash configuration field at 0x400-0x40F. The Teensy Arduino core writes specific values here. Do we need to provide this in the HAL or is it the application's responsibility?

  3. Pin mapping table: Should the HAL include a Teensy-specific pin mapping (Arduino pin numbers → port/pin pairs), or should that be a separate board support crate?

  4. USB bootloader interaction: Teensy boards enter the bootloader via USB. Does the HAL need to support this, or is it handled at a lower level?

Related Documents