Bobbin SDK: Richer Hardware Abstractions for Embedded Systems Programming

Bobbin SDK is a suite of tools and code that has been used in embedded development projects running on a variety of ARM Cortex-M MCUs over the last year and a half. It exists to bridge the gap between the existing Rust ecosystem centered around svd2rust and rich but proprietary vendor SDKs.

svd2rust has jump-started embedded development in Rust, but is fundamentally limited by the CMSIS-SVD data format itself.

Meanwhile proprietary vendor SDKs handle much of the heavy lifting by providing initialization code, basic drivers, and various wizards and code generation tools. Unfortunately, each SDK is different, with a significant learning curve for the APIs and for the tools. They are also a major source of vendor lock-in - you can’t take code from one vendor’s SDK and use it in another.

Bobbin SDK is an approach to embedded systems programming in Rust that provides richer, more flexible hardware abstractions than svd2rust so that we can get much of what vendor SDKs provide to accelerate development, but in a pure-Rust, open source environment.

svd2rust

svd2rust is monolithic in a few important ways:

  • Each SVD file is an independent description of a specific MCU model. There are no provisions for sharing peripheral descriptions between MCUs produced by the same vendor.
  • The generated SVD crates contain a single large source file, often megabytes in size. These cannot be easily be viewed in Github and are unwieldy to work with.
  • All MCU crates are generated by the same svd2rust binary, meaning that any change to the svdrust code is potentially a breaking change to every MCU crate in the ecosystem.

This is particularly an issue for driver authors. There is no way for a driver author to write a crate that directly targets a peripheral which is common across many MCUs sold by a vendor. Instead, the author must either publish a driver crate for each individual MCU crate (potentially dozens) or publish a single crate that uses Cargo features to import the correct MCU crate at compile time. In either case, the author must re-test any time any of the MCU crates change because there is no way to know whether the change affects the specific peripheral being used.

This is also a burden for driver users, too. If device driver crates are MCU-specific, then there is a chance that the specific MCU variant used by the user isn’t supported by the driver author, and the burden is on the user to determine whether any of the existing supported MCUs are compatible. There is nothing that prevents the user from selecting the wrong crate. In the worst case, the user could select a crate that works in most cases but fails when handing a critical edge case that is not caught in testing.

It would be much better to have a hierarchy of crates, where a user can depend on an MCU crate which in turn would depends on vendor common crates and architecture common crates.

Driver writers could then target vendor common peripheral crates directly and have their code automatically work with any MCUs using those peripherals. The burden would be on the MCU crate author to depend on the specific peripheral variant or to create a new variant if needed.

Limitations of SVD

Additionally, SVD itself only concerns itself with specific aspects of an MCU: peripheral register and field definitions, and interrupts. There are many other important aspects of MCUs that aren’t covered at all, such as:

  • Sub-peripheral entities such as channels, often used by ADCs, Timers, and DMA engines
  • Pins and their relationship to peripherals and channels
  • MCU clock trees and peripheral clock gates.

The SVD-based MCU crate ecosystem cannot deal with any of these concepts because they aren’t present in the source files that they use, and never will be. This means that users of these crates are on their own when it comes to initializing and configuring their systems: they will have to manually set up pins, enable peripheral clocks, and set up prescalers and clock dividers without any assistance or checking.

Bobbin DSL

Bobbin DSL is a s-expression based superset of SVD designed to tackle some of these issues. Some specific goals:

  • Easy for humans to read and write
  • Easy for tools (including non-Rust tools) to parse and generate
  • Support for multi-file organization
  • Support for Rust concepts such as crate and module imports
  • Support for Channels, Pins, Signals, Clocks, and Gates and other concepts
  • Support for non-MCU peripherals (i.e. standalone devices accessed through I2C and SPI)
  • Support for defining Boards / Systems.

More information about Bobbin DSL is available in the repository, but a top-level MCU file might look like this:

(device
    (name STM32F3x)
    (size 0x20) ; the default register size

    ; the number of interrupts, this must be defined to generate placeholder
    ; interrupt handlers.
    (interrupt-count 82) 
    (description "STM32F3x")

    ; A list of device variants

    (variants
        (variant (name STM32F303xB) (link "f303xb.ld"))
        (variant (name STM32F303xC) (link "f303xc.ld"))
        (variant (name STM32F303xD) (link "f303xd.ld"))
        (variant (name STM32F303xE) (link "f303xe.ld"))
    )

    ; A crate import, in this case from the vendor-common crate for this device.

    (crate
        (name "stm32-common")
        (module (name bobbin_bits))
        (module (name bobbin_mcu))
        (module (name bobbin_hal))
        (module (name nvic))
        (module (name scb))
        (module (name systick))
        (module (name mpu))
        (module (name fpu))
        (module (name dcb))
        (module (name itm))
    )

    ; Clock definitions for this device.

    (clocks
        (input (name OSC) (min 4000000) (max 26000000))
        (input (name OSC32) (min 32768) (max 32768))
        
        (source (name HSI) (speed 8000000))
        (source (name HSE) (input (name OSC)))
        (source (name LSI) (speed 40000))
        (source (name LSE) (input (name OSC32)))
        (output (name PLLCLK))
        (output (name SYSCLK) (max 216000000))

        (output (name HCLK))
        (output (name SYSTICK))
        (output (name FHCLK))
        (output (name PCLK1))
        (output (name PCLK2))
        (output (name TIM_PCLK1))
        (output (name TIM_PCLK2))

        (output (name I2C1))
        (output (name I2C2))
        (output (name I2C3))

        (output (name I2S2))
        (output (name I2S3))

        (output (name USBCLK))

        (output (name USART1))
        (output (name USART2))
        (output (name USART3))
        (output (name UART4))
        (output (name UART5))

        (output (name TIM1))
        (output (name TIM2))
        (output (name TIM3))
        (output (name TIM4))
        (output (name TIM8))
        (output (name TIM15))
        (output (name TIM16))
        (output (name TIM17))
        (output (name TIM20))

        (output (name RTCCLK))
        (output (name ADC12))
        (output (name ADC34))
    )    

    ; Local peripherals - these are device-specific and only have one instance.

    (peripheral (include "periph/rcc.rx"))
    (peripheral (include "periph/syscfg.rx"))
    (peripheral (include "periph/flash.rx"))
    (peripheral (include "periph/pwr.rx"))

    ; External peripherals - imported from the vendor-common crate.

    ...

    (peripheral-group
        (name IWDG)
        (module (name "::stm32_common::iwdg::*"))
        (peripheral
            (name IWDG)
            (address 0x40003000)
            (clock
                (input (name LSI))
            )            
            (description "Independent watchdog")        
        )
    )

    (peripheral-group
        (name WWDG)
        (module (name "::stm32_common::wwdg::*"))
        (peripheral
            (name WWDG)
            (address 0x40002c00)
            (clock
                (input (name PCLK1))
                (gate (type RST) (periph RCC) (register APB1RSTR) (field WWDGRST))
                (gate (type EN) (periph RCC) (register APB1ENR) (field WWDGEN))
            )            
            (description "System window watchdog")
            (interrupt
                (name WWDG)
                (value 0)
                (type WWDG)
                (description "Window Watchdog interrupt")
            ) 
        )
    )    

    ...
)

and a peripheral might look like this:

(peripheral
    ; signature: 0a8c569a91066543 

    ; The signature comment is auto-generated by bobbin-svd and is a hash of the
    ; registers and fields (but not reset values) of the peripheral.
    
    (group-name DAC) ; Each peripheral definition should include a group name.

    ; This peripheral declaration does not include a base address, which means that
    ; it is to be used as a prototype.

    (address-block ; The size and usage of the peripheral address space.
        (offset 0x0)
        (size 0x10)
        (usage registers)
    )
    (description "Digital Analog Converter")
    (register
        (name CTRLA)
        (offset 0x0) ; Offset in bytes from the peripheral base address.
        (size 0x8)   ; Size in bits of the register, uses the peripheral size if not specified.
        (description "Control A")

        ; All fields currently require a name, bit-offset, bit-width, and description.
        ; Future version could allow shorthand: (field NAME BIT-OFFSET BIT-WIDTH "DESCRIPTION")

        ; Registers may have an access attribute of read-only, read-write, etc.
        ; Access is inherited from the peripheral if not specified

        (field
            (name SWRST)
            (bit-offset 0) ; The offset in bits from the LSB end of the register.
            (bit-width 1)  ; The width of the field in bits.

            ; Fields may have an access attribute of read-only, read-write, etc.
            ; Access is inherited from the register if not specified.

            (description "Software Reset")
        )
        (field
            (name ENABLE)
            (bit-offset 1)
            (bit-width 1)
            (description "Enable")
        )
        (field
            (name RUNSTDBY)
            (bit-offset 2)
            (bit-width 1)
            (description "Run in Standby")
        )
    )
    (register
        (name CTRLB)
        (offset 0x1)
        (size 0x8)
        (description "Control B")
        (field
            (name EOEN)
            (bit-offset 0)
            (bit-width 1)
            (description "External Output Enable")
        )
        (field
            (name IOEN)
            (bit-offset 1)
            (bit-width 1)
            (description "Internal Output Enable")
        )
        (field
            (name LEFTADJ)
            (bit-offset 2)
            (bit-width 1)
            (description "Left Adjusted Data")
        )
        (field
            (name VPD)
            (bit-offset 3)
            (bit-width 1)
            (description "Voltage Pump Disable")
        )
        (field
            (name BDWP)
            (bit-offset 4)
            (bit-width 1)
            (description "Bypass DATABUF Write Protection")
        )
        (field
            (name REFSEL)
            (bit-offset 6)
            (bit-width 2)
            (description "Reference Selection")

            ; Field records may include value lists. 
            
            ; Currently these are not used for codegen because in some cases enums 
            ; are preferred but in others individual constants work better.

            ; Medium term plan is to have a way to define enums and constant lists
            ; at the peripheral level so that they can be used across registers
            ; and fields.

            (value
                (value "0x0")
                (name "INT1V")
                (description "Internal 1.0V reference")
            )
            (value
                (value "0x1")
                (name "AVCC")
                (description "AVCC")
            )
            (value
                (value "0x2")
                (name "VREFP")
                (description "External reference")
            )
        )
    )
    (register
        (name DATA)
        (offset 0x8)
        (size 0x10)
        (description "Data")

        (field
            (name DATA)
            (bit-offset 0)
            (bit-width 16)
            (description "Data value to be converted")
        )
    )

    ...

    ; The following register is read-only, with a single read-only field.

    (register
        (name STATUS)
        (offset 0x7)
        (size 0x8)
        (access read-only)
        (description "Status")
        (field
            (name SYNCBUSY)
            (bit-offset 7)
            (bit-width 1)
            (access read-only)
            (description "Synchronization Busy Status")
        )
    )
)

While the format has been designed to be easy to read and write by hand, in most cases a SVD file will be used as a starting point. The bobbin-svd tool reads a SVD file and creates a directory with a top-level DSL file and a single file per peripheral. It also exposes an API that can be used to build tools that work with the file data: alternate code generators, documentation generators, debugging and visualization interfaces, etc.

Bobbin MCU Crates

The bobbin-chip tool generates MCU crates from MCU Definitions.

Each MCU depends on a vendor-common crate which contains shared vendor peripherals, which itself depends on an architecture-common crate.

The crates themselves are structured so that each peripheral is in its own file for easy introspection. Effort has been placed in generating code that is readable for debugging and learning purposes. Individual files also make it much easier for version control tools to identify the specific files that have been affected after changes to the MCU source files or to code generation.

Additionally, each crate has a src/ext directory that contains hand-written code that implements traits and other functionality that cannot be automatically generated.

Looking at an example of an application using an MCU crate directly (see bobbin-stm32f746zg-example):

There is a single MCU crate dependency in Cargo.toml

[dependencies]
cortex-m-rt = "0.4.0"
panic-abort = "0.1.1"

[dependencies.stm32f74x]
path = "../../mcu/bobbin-stm32/stm32f74x"
features = ["STM32F74xxG"]

and a simple console.rs example shows how to initialize and configure peripherals:

const USART: Usart3 = USART3;
const USART_TX: Pd8 = PD8;
const USART_RX: Pd9 = PD9;
const USART_CLOCK: u32 = 16_000_000; // Use HSI Clock
const USART_BAUD: u32 = 115_200;

fn main() {
    // Initialize the MCU (enable instruction cache, disable watchdogs, etc.)
    mcu::ext::init();
    
    USART_TX
        .port_gate_enable() // Enable the clock for the port associated with this pin.
        .connect_to(USART); // Connect the pin to the USART that we are using.

    USART_RX
        .port_gate_enable() // Enable the clock for the port associated with this pin.
        .connect_to(USART); // Connect the pin to the USART that we are using.

    USART
        .set_clock_source(DedicatedClock::Hsi) // use the 16MHz HSI clock source
        .gate_enable() // Enable the USART clock
        .set_config(|c| c.set_baud_clock(USART_BAUD, USART_CLOCK)) // Configure the USART
        .enable(); // Enable the USART

    loop {
        USART.write(b"Hello, World\r\n"); // Write a byte slice. Use `\r\n` for a newline.
        for _ in 0..10_000_000 { unsafe { asm!("nop") }}
    }    
}

All configuration is type-checked at compile time. Assigning the wrong USART_TX or USART_RX pin to the USART will result in a compile-time error with hints suggesting the pins that can be connected to the peripheral.

Board Crates

In most cases users will want to work with Board crates. More details will be covered in a future post, but Bobbin DSL supports defining boards, which include a specific MCU, clock definitions, and peripherals as well as basic system services such as a console, global timer, interrupt registry, and heap.

The equivalent to the console example above, using a board crate and higher-level services:

#![no_std]

#[macro_use]
extern crate nucleo_f746zg as board;

use board::prelude::*;

fn main() {
    let mut sys = board::init();
    sys.run(|sys| loop {
        println!("Hello, World");
        sys.tick().delay(500);
    })
}

You can see more information in Board Definitions and Board Crates.

What’s Next

While much of Bobbin SDK (particularly the DSL and code generation) is fairly mature and has been used in multiple projects, higher-level APIs are newer and are likely to evolve further. There’s also a significant amount of documentation that still needs to be written.

There are several unpublished MCU and board crates that need to be updated to be consistent with the current examples. These will be added as they are completed.

The overall Rust embedded ecosystem is currently facing a good amount of churn in order to support stable Rust, and Bobbin SDK is no exception. There will also be additions or changes to APIs and drivers in order to ensure commonality with the rest of the Rust ecosystem.

Because of this, the SDK is currently available only as a Git repository, and crates have not yet been published.

Meanwhile, feedback and pull requests are welcome.