Bare-metal Rust firmware support for the Texas Instruments MSP430FR5969 microcontroller — a Peripheral Access Crate (PAC) and a Hardware Abstraction Layer (HAL) built on top of it, plus getting-started examples, a copy-out starter project, demo/consumer binaries, and a two-tier hardware-in-the-loop test suite (single-board fixtures, plus a two-LaunchPad cross-wired rig).
Both library crates are published:
msp430fr5969
(docs) and
msp430fr5969-hal
(docs).
This is #![no_std] / #![no_main] embedded development: no RTOS, no operating
system, no allocator. Startup is provided by msp430-rt — its Reset
handler is the reset-vector entry point (stack pointer, .bss/.data init)
and calls the #[entry] fn main() -> !. The project targets msp430-none-elf
and requires the nightly toolchain (for -Z build-std=core).
| Crate | Purpose |
|---|---|
pac/ |
Peripheral Access Crate, auto-generated by svd2rust from msp430fr5969.svd. Typed register access for every peripheral. |
hal/ |
Hardware Abstraction Layer built on the PAC: GPIO, UART, SPI, I²C (master + slave), DMA, ADC12 (single / sequence-of-channels / window comparator), timers (incl. input capture), PWM (Timer_B0 + Timer_A), RTC (calendar + alarm + prescaler ticks), watchdog, comparator, capacitive touch sensing (CAPTIO), CRC16/AES256 accelerators, FRAM MPU, clock profiles up to 16 MHz, power modes down to LPM4.5, FRAM — see the module list below. Getting-started examples in hal/examples/. |
template/ |
Copy-out starter project depending on the published crates: minimal main.rs, the full .cargo/config.toml link recipe, and a README with toolchain setup. Not a workspace member — copy the directory out and rename it (see template/README.md). |
pac_consumer/ |
Example binary that exercises the PAC directly. |
single_board_test_firmwares/ |
On-target demo/test binaries that exercise the HAL (SPI loopback, I²C scan, I²C slave register file, PWM fade, RTC clock + alarm + ticks, DMA, ADC, LPMx.5, accelerators, comparator, capacitive touch, MPU, input capture, clock profiles, …). |
single_board_test_orchestrators/ |
Host-side hardware-in-the-loop runner: talks to the flashed test binaries over the UART backchannel and asserts their verdicts (detached from the workspace; builds for the host). |
two_board_test_firmwares/ |
One command-server fixture (two_board_fixture) flashed identically to both LaunchPads of the permanently-wired two-board rig: single-byte commands over the UART backchannel, framed report/verdict responses. |
two_board_test_orchestrators/ |
Host-side runner for the cross-board suites — builds and flashes the fixture to both boards, discovers which is parent/child (identity in Info FRAM), and drives the suites: identity, i2c_bridge (HAL master vs. the peer's I²C slave — the slave driver's hardware verification), uart_link, gpio_edge, lpm4_wake, pwm_cross. Wiring table and setup in two_board_test_orchestrators/README.md (detached from the workspace; builds for the host). |
unit_tests/ |
Host-target unit tests for the HAL's pure math, one module per subject — baud rate, timer ticks, FRAM addressing, ADC calibration + sequence encoding, the ISR→main RX queue, the CRC16 software model, the comparator ladder math, MPU segmentation, capture timestamp math, RTC alarm + prescaler-tick encoding, I²C slave address/IV math, and the CAPTIO control-word encoding (detached from the workspace). |
tools/ |
Toolchain-discovery wrappers: msp430-linker.sh (finds TI's msp430-elf-gcc) and flash.sh (finds DSLite and flashes an ELF; an optional second argument selects a target-configuration ccxml, which the two-board runner uses to address each of two attached probes). Both honor env-var overrides (MSP430_GCC, MSP430_DSLITE), so no machine-specific paths are checked in. |
Prerequisites: the nightly Rust toolchain with rust-src (for
-Z build-std=core), and — to link flashable ELFs — TI's free
MSP430-GCC toolchain, found
automatically via PATH, the MSP430_GCC env var, or the usual install
locations (tools/msp430-linker.sh does the discovery; nothing
machine-specific is checked in).
# Build (debug; the dev profile is size-tuned to fit 48 KB of FRAM)
cargo +nightly build
# Type-check only
cargo +nightly check
# Build the getting-started examples (hal/examples/)
cargo +nightly build -p msp430fr5969-hal --examples --features rt,critical-section
# Flash any built ELF (DSLite discovered via MSP430_DSLITE / PATH / CCS installs)
tools/flash.sh target/msp430-none-elf/debug/pwm_fade
# ...or let cargo's runner do the same:
cargo +nightly run --bin pwm_fade
# Run the host-side math tests (NOT on the msp430 target)
cd unit_tests && cargo +nightly test
# Run the hardware-in-the-loop suite (device flashed with the matching runner)
cd single_board_test_orchestrators && cargo +nightly run # default suite
cd single_board_test_orchestrators && cargo +nightly run -- spi # named suite
# Run the cross-board suites (both boards of the two-board rig attached)
cd two_board_test_orchestrators && cargo +nightly runTo start your own out-of-tree project against the published crates, copy the
template/ directory. Per-demo board wiring lives in
CLAUDE.md; the two-board rig's wiring table lives in
two_board_test_orchestrators/README.md.
The pac crate (pac/) is generated by svd2rust
(v0.37.1, --target msp430) from the device SVD file. It provides typed,
read/modify/write register access for all MSP430FR5969 peripherals, a
Peripherals::take() singleton, and (under the rt feature) the interrupt
vector table plus the memory.x/device.x linker scripts. pac/src/lib.rs
is ~73K lines of generated code and should not be edited by hand — fixes are
made by patching the SVD and regenerating. Regeneration is reproducible (last
done 2026-07-11, picking up the MPU block): the msp430 target flavor emits the
vector table correctly at source — u16 slots and
extern "msp430-interrupt" handler declarations, matching the ABI msp430-rt's
#[interrupt] macro emits — leaving one small hand-patch to re-apply, gating
the nightly ABI feature and the Vector union on the rt feature so non-rt
builds don't need it (see CLAUDE.md, "PAC generation").
Discrepancies found between the MSP430FR5969 datasheet and the generated PAC.
Full context for each item — datasheet references, SVD locations, and fix
notes — lives in TODO_pac_datasheet_discrepancies.md.
- High impact (both resolved)
-
Timer_A0 is missing 2 capture/compare channels— datasheet erratum, not an SVD gap (HW-established 2026-07-11). TA0 on this silicon really has exactly 3 CC channels; SLAS704G's TA0 register-offset table (which lists TA0CCR3/4) contradicts its own prose and Table 6-13, and a one-off silicon probe found no state, no CCIFG, and no IV slot at the putative addresses. The SVD, PAC, andTimer0_A3name are all correct. -
MPU defined in SVD but missing from PAC— resolved by the 2026-07-11 regeneration.svd2rustnever dropped anything; the old PAC had been generated from an earlier SVD than the one checked in. The PAC (v0.2.0) now has thempumodule at 0x05A0 andPeripherals.mpu, andhal::mpuownspac::Mpuwith typed register access. (MPUCTL0deliberately stays raw byte lanes — the SVD doesn't model theMPUPWpassword byte, so a PACmodifyon it would write a wrong password and PUC.)
-
- Low impact (cosmetic / modeling differences)
- Capacitive Touch I/O base address is the register, not the module. The PAC uses 0x043E/0x047E (the CAPTIOxCTL register address) as the base instead of the datasheet's module base 0x0430/0x0470. Functionally correct since there is only one register.
- Hardware multiplier split into two PAC peripherals. The datasheet documents one unified MPY module at 0x04C0; the PAC models it as
Mpy16(0x04C0) andMpy32(0x04D0). Different organization, same registers. - DMA channels flattened into a single peripheral. The datasheet splits DMA into a general-control block plus per-channel bases; the PAC combines everything into one
Dmaperipheral at 0x0500.
- Code review follow-ups
- Interrupt handlers use the wrong ABI. The old checked-in PAC's vector-table declarations were
extern "C"; MSP430 ISRs needextern "msp430-interrupt"(RETI, notRET). Resolved at source by the 2026-07-11 regeneration (the msp430 target flavor emits the correct ABI); real ISRs are in use and hardware-verified against the regenerated table. -
rtpath is wired tomsp430-rt. Previously latent-broken: the PAC emits the table into.vector_table.interruptsbut the oldmemory.xkept*(.vector_table)(no match) and emitted no.reset_vector/__RESET_VECTOR. Resolved by depending onmsp430-rt(itslink.xsupplies the SECTIONS and reset glue andINCLUDEsmemory.x/device.x) and reducingmemory.xto RAM/ROM/VECTORS regions; theVectorunion'su16slot width (au32there doubled the table width and overran VECTORS) is now emitted at source by the regeneration. Binaries link andResetbegins withmov #0x2400, r1. - Packaging metadata is absent (high). Resolved:
pac/Cargo.toml(andhal/Cargo.toml) now carrylicense,description,repository,documentation,keywords,categories, and[package.metadata.docs.rs](default-targetmsp430-none-elf, docs built with thert/critical-sectionfeatures and build-std). Both crates are published on crates.io (v0.2.0, 2026-07-11/12) with green docs.rs builds. -
static mut DEVICE_PERIPHERALSuses the old singleton idiom (medium). Newersvd2rustemits anAtomicBool; the current form will warn under thestatic_mut_refslint. The 2026-07-11 regeneration with v0.37.1 (msp430 flavor) still emits this form, so a plain regen does not fix it. - eUSCI UART-mode block omits
UCAxIE/UCAxIFG(low). These registers exist in silicon but aren't modeled, which is exactly why the HAL has to drop to raw pointers forIFG. Patch the SVD so the PAC exposes them.
- Interrupt handlers use the wrong ABI. The old checked-in PAC's vector-table declarations were
The hal crate (hal/) is a hand-written abstraction layer on top of the PAC.
It re-exports pac, embedded_hal, embedded_hal_nb, embedded_io, and
embedded_storage, and passes the rt and critical-section features through
to the PAC. The crate-root hal::peripherals::take(watchdog::WdtMode) is the boot front
door: watchdog policy + Peripherals::take(), fused in guaranteed order.
Implemented modules (hardware-verified on the MSP430FR5969 LaunchPad unless
noted otherwise):
gpio— zero-sized, typestate-typed pins (Pin<PORT, N, MODE>) with theembedded-haldigital traits, mode transitions, and port edge interrupts (ISR-sidegpio::read_iv).serial— eUSCI_A0/A1 UART (embedded-hal-nb+embedded-io), with a datasheet-derived baud generator, RX interrupts into an application-owned queue, and DMA-paced TX/RX.spi— instance-generic 3-pin SPI master over eUSCI_A0/A1/B0 (embedded-halSpiBus), plusSpiDma(two-channel DMA pacing).i2c— eUSCI_B0 I²C master (embedded-halI²C traits) and slave (native event-pump API, hardware SCL stretching; hardware-verified 2026-07-18 via the two-board rig'si2c_bridgesuite in polled mode — theUCB0IVtable and the interrupt-driven path remain documentation-pinned).dma— 3-channel DMA controller: safe CPU-halting block copies, the peripheral-pacing primitives serial/spi/adc build on, and theDMA-vector demuxdma::read_iv.adc— ADC12_B: ratiometric and VREF-referenced reads, conversion-complete interrupts, repeat-mode conversions drained by DMA (including the workaround for an undocumented ADC12→DMA trigger-latch erratum), hardware sequence-of-channels scans (≤ 8 members, per-member reference selection, polled or DMA-drained — encoding math inadc_seq, host-tested), and the window comparator (threshold checks in silicon: one-shot windowed reads and a free-running zero-CPU monitor with above/below/within interrupts).ref_a/tlv/adccalibration — REF_A shared 1.2/2.0/2.5 V reference (incl. REFOUT onto P1.1), factory TLV calibration constants, and the full gain/offset/reference correction chain for absolute-mV and die temperature readings.comp_e— Comp_E analog comparator: typed channel pins, reference-ladder thresholds with hysteresis (comp_laddermath), both-edge interrupts viacomp_e::read_iv, LPM wake.crc/aes— CRC16-CCITT and AES256 hardware accelerators: both CRC bit conventions plus the four catalog one-shots (host-tested software model incrc_soft); AES ECB/CBC with transparent key reload across encrypt↔decrypt switches.timer/pwm— Timer_A free-running counter/tick math; PWM (embedded_hal::pwm::SetDutyCycle) with glitch-free 0/100 % endpoints on Timer_B0 (four pins) and, instance-generically, on Timer_A0/A1 (PwmTimerA— frees TB0 and the P1.6/P1.7 eUSCI_B0 pin conflict).capture— Timer_A input capture, instance-generic over TA0/TA1: hardware edge timestamps from typed pins (CCIxA), the internal Comp_E output (CCI1B) and ACLK (CCI2B— hands-free DCO-vs-crystal measurement), software-triggered captures, overcapture (COV) semantics split between paired reads and span bracketing (capture_math, host-tested), and theTAxIVdemux.captio— Capacitive Touch I/O: any port pad becomes a relaxation oscillator whose frequency tracks the pad's capacitance, counted by the instance's silicon-paired internal timer (CAPTIO0→TA2, CAPTIO1→TA3, both consumed so the pairing can't be crossed). Typed-pin routing and re-routing scans, gated frequency measurement with a wrap refusal instead of aliasing (bare pads oscillate at 3–7 MHz — measured), raw count deltas as the touch primitive, and a count-overflow interrupt that wakes LPM0 (the oscillator is self-clocked). Control-word encoding host-tested incaptio_ctl.rtc— RTC_B calendar (native API):RTCRDY-gated reads, event/second interrupts, the hardware alarm (per-field minute/hour/weekday/day enables,set_alarmwith SLAU367's disable-first/clear-flag-last update order, polled orRTCIV-demuxed interrupt delivery that wakes LPM3 — encoding math inrtc_alarm, host-tested), sub-second crystal-accurate prescaler ticks (sixteen power-of-two rates, 16.384 kHz–0.5 Hz, LPM3 wake — rate math inrtc_tick, host-tested), andRtc::attachfor reclaiming the calendar after an LPM3.5 wake.clocks/delay— clock-system (CS) configuration: four profiles (8 MHz SMCLK default, 1 MHz low-power with LFXT crystal startup, and two 16 MHz DCO profiles whose FRAM wait-state (NWAITS) programming is fused with the DCO switch so the ordering can't be gotten wrong), and a cycle-counted busy-wait delay.power/sys— LPM0/3/4 entry (GIE set atomically with sleeping) and the regulator-off LPM3.5/LPM4.5 modes with FRAM-persisted state andsys::ResetReasonwake-cause reporting.fram— FRAM controller support withembedded-storagetraits and overflow-safe bounds checks (fram_addr).mpu— FRAM Memory Protection Unit: three movable main-memory segments plus the fixed Info segment, per-segment R/W/X enables with flag/NMI/PUC violation policies, andlock()(frozen until BOR). Owns the PAC'sMpublock (consume-by-move) with typed 16-bit register access; onlyMPUCTL0stays raw byte lanes, because the SVD doesn't model itsMPUPWpassword byte. Border andMPUSAMmath host-tested inmpu_seg.watchdog— WDT_A: disable/feed/force-reset plus interval-timer mode (periodicWDT-vector interrupt).rx_queue—Cell-based SPSC byte queue for ISR→main handoff (host-tested).
Remaining coverage and follow-ups from code review.
- Open
- Hardware-confirm the I²C slave
UCB0IVtable and interrupt-driven mode. The slave itself is now hardware-verified in polled mode (see Done below), but theUCB0IVslot values are still documentation-pinned only (the RTCIV table was wrong once already) and the interrupt-driven slave path has not run on silicon. - Timer_B0 input capture.
captureis instance-generic over TA0/TA1 only; TB0's seven CCRs also do capture. Low urgency — the Timer_A coverage serves the use cases, and TB0 is the main PWM block. - ADC12 differential-pair mode (
ADC12DIF) and the sequence auto-restart (CONSEQ=3) variants. - eUSCI SPI slave mode — all three SPI instances are master-only.
- I²C multi-master and 10-bit addressing — the master is single-master 7-bit; the slave answers one 7-bit own address (the silicon offers three more own-address slots plus an address mask).
- UART automatic baud-rate detection (
UCABDEN) and IrDA framing. -
embedded-hal-async— an async story would be a genuine differentiator (MSP430 has no atomics, so a critical-section-based waker design), but it is a large bet; polling + interrupts serve today.
- Hardware-confirm the I²C slave
- Done (coverage milestones)
- Capacitive Touch I/O —
captio::TouchSense(routing, gated frequency measurement, LPM0-waking overflow interrupt), host-tested encoding incaptio_ctl, and the hands-freecaptioHiL suite. All eight verdicts hardware-verified 2026-07-11 (the cosmetic PAC base-address quirk noted above stands — the driver addresses the one register directly). - I²C slave mode — implemented (
i2c::I2cSlave, a native event-pump API: own-address setup with general-call support, hardware SCL stretching, repeated-START turnarounds, speculative-TX-byte flush at STOP,UCB0IVdemux; pure math host-tested ini2c_slave). Hardware-verified 2026-07-18 via the two-board rig'si2c_bridgesuite — the HAL's own master on the peer board ran probe/ID/write-read/read-only/ wrap with an exact 8-transaction event tally, including the speculative-TXBUF flush and clock stretching (which exposed and fixed a master-side zero-length-write STOP wedge). TheUCB0IVtable and interrupt-driven mode are the residual open item above. - Two-board integration rig — two LaunchPads permanently wired
together (10 signal wires through series resistors, fixed pin directions
so contention is impossible), giving every suite a genuinely independent
partner: a second clock domain, a second calibrated analog chain, real
external edges, and a live bus master. All six default suites
hardware-verified 2026-07-18 (
identity,i2c_bridge,uart_link,gpio_edge,lpm4_wake,pwm_cross— cross-DCO PWM measured 991/1009 Hz against a 1 kHz ±5% gate). - Timer capture mode —
captureimplements Timer_A input capture (TA0/TA1 generic; pin, Comp_E, ACLK, and software sources). Hardware-verified 2026-07-08 via thecapturesuite (the two jumper-dependent PWM verdicts run in the interactivecapture_jumpervariant). - High-speed clock profiles — the two 16 MHz DCO profiles
(
configure_high_speed,configure_max_speedincl. the fusedFRCTL0.NWAITSwait-state programming) hardware-verified 2026-07-08, each under its own default-set suite. - MPU write-protection —
mpuimplements the FRAM MPU (hardware-verified 2026-07-05; all eight verdicts re-verified 2026-07-11 through the typed driver after the PAC regeneration added theMpublock). - Timing/Clocks —
clocks(CS driver incl. LFXT),delay, andtimerare implemented and hardware-verified. - I2c (master) — eUSCI_B0 master implemented (
embedded-halI²C traits). - SPI — eUSCI_A0/A1/B0 master implemented, including DMA pacing.
- FRAM — implemented with
embedded-storagetraits.
- Capacitive Touch I/O —
- Code review follow-ups
- Shared-register read-modify-writes are not atomic (high) — partially addressed. Interrupt-related registers (
PxIE,PxIES,PxIFG, sharedSFRIE1) are now RMW'd only insidecritical_section::with. Data/direction registers (PxOUT,PxDIR,PxSELx) still use plain volatile read/modify/write, which races if a pin is driven from an ISR whilemaintouches another pin on the same port. - TX/RX pins aren't owned, so two drivers can claim the same pins (high).
into_uartmuxes the P2PxSELbits directly but never consumes the matchingPinobjects. (SPI/PWM/comparator pins do go through the typestate; the UART still has this hole.) - The HAL duplicates the memory map the PAC already owns (medium). GPIO/serial/SPI hardcode absolute addresses (
0x0200,0x05C0, …) that the PAC already encodes. Derive bases from the consumed peripheral so a PAC regen can't silently desync them. (Partly forced by the PAC's missingUCAxIE/UCAxIFGmodeling, above.) -
flushwaits onUCBUSY, which is also set during RX (medium). Aflush()issued mid-reception blocks on unrelated RX traffic. Conventional for eUSCI (no dedicated TX-shift-complete flag), but worth a doc note. - Missing crate-level lints and
# Safetydocs (low). Add#![deny(missing_docs)]/#![warn(unsafe_op_in_unsafe_fn)]and document the invariants on theunsafe fnMMIO helpers. - No
free()/release path (low) — mostly done.free(self) -> PAC_PERIPHERALnow exists ontimer,pwm,rtc,adc,ref_a,aes,crc,comp_e,watchdog, andi2c::I2cSlave;serial,gpio, and the I²C master remain one-way. -
Pin/SerialSend/Syncare left to auto-trait inference (low). They alias global MMIO yet deriveSend + Sync— reason about these deliberately. -
Pin<PORT, const N: u8, MODE>has no0..=7bound (nit). Safe today because the constructor is private, but1 << Nwould wrap forN >= 8with overflow checks off. -
compute_baudlacks guards forbaud == 0/baud > clock_freq(nit). Adddebug_assert!s on the precondition.
- Shared-register read-modify-writes are not atomic (high) — partially addressed. Interrupt-related registers (