rust-embedded / svd2rust

Generate Rust register maps (`struct`s) from SVD files
Apache License 2.0
692 stars 150 forks source link

[RFC] API tailored for specific microcontrollers #122

Open japaric opened 7 years ago

japaric commented 7 years ago

Original issue: japaric/stm32f103xx#9

The problem

SVD files usually describe a family of microcontrollers and contain information about all the peripherals any member of the family could have. When svd2rust produces a device crate from a SVD file it produces an API to access every single of these peripherals.

The problem is that the lower density members of a family are likely to contain less peripherals than the set of peripherals described by the SVD file. As a result using the device crate with such devices lets you access nonexistent peripherals.

As a concrete example the stm32f103xx crate exposes an API for the TIM6 and TIM7 peripherals (basic timers) but these peripherals are not available on the STM32F103C8 microcontroller. So if you write an application for that microcontroller using the stm32f103xx crate you may end up using those timers without realizing they are not available. Worst part is that the program won't crash -- it won't hit an exception -- but rather it will likely have undefined behavior (writes are no-op and reads return junk values or zero)

Possible solutions

Constraints

Cargo features

One of the ideas brought up in the original issue was to encode the presence of each peripheral through a Cargo feature and then have one Cargo feature per microcontroller. That microcontroller feature would enable all the peripherals, through their features, that are present on that microcontroller. Example:

# Cargo.toml
[features]
TIM2 = []
TIM3 = []
TIM4 = []
TIM6 = []
TIM7 = []
stm32f103c8 = ["TIM2", "TIM3", "TIM4"]
stm32f103vg = ["TIM2", "TIM3", "TIM4", "TIM6", "TIM7"]

The device crate would make use #[cfg] attributes like this:

#[cfg(feature = "TIM2")]
pub const TIM2: Peripheral<TIM2> = ..;

#[cfg(feature = "TIM3")]
pub const TIM3: Peripheral<TIM3> = ..;

to prevent exposing APIs not available to a certain microcontroller.

As you know Cargo features are additive so there's nothing stopping you from enabling more than one microcontroller feature at the same time, even by mistake (e.g. a dependency enables one microcontroller feature and another dependency enables a different one). In those cases we can raise an error in the device crate like this:

#[allow(dead_code)]
#[cfg(feature = "stm32f103c8")]
const ERROR: &str = "feature stm32f103c8 is enabled";

#[allow(dead_code)]
#[cfg(feature = "stm32f103vg")]
const ERROR: &str = "feature stm32f103vg is enabled";

If more than one microcontroller feature is enabled this will raise a name collision error.

Library crates that depend on the device crate can write device specific APIs like this:

extern crate stm32f103xx;

#[cfg(feature = "TIM2")]
fn foo(tim2: &stm32f103xx::TIM2) { .. }

#[cfg(feature = "TIM3")]
fn bar(tim2: &stm32f103xx::TIM3) { .. }

Testing a library crate that depends on a device crate for the different devices that the device crate supports is as simple as calling the Cargo command with different --feature arguments:

$ cargo check --feature stm32f103c8
$ cargo check --feature stm32f103vg

Upsides

This is straightforward to implement in svd2rust.

Downsides

Due to the additive nature of Cargo features it seems to be easy to break the device selection mechanism: it's just enough that a dependency directly enables a peripheral feature:

# Cargo.toml
[package]
name = "application"

[dependencies.stm32f103xx]
features = ["stm32f103c8"]
version = "0.1.0"

[dependencies]
# this crate depends on the stm32f103xx crate and directly enables its TIM6
# feature, but this peripheral is not available on the stm32f103c8
# microcontroller
foo = "0.1.0"

This problem can be avoided if library crates never enable any feature of the device crate but there's no mechanism to enforce this so discipline would be required.

--cfg device=

Another approach is to not use Cargo features at all but to directly use #[cfg] attributes and the --cfg rustc flag. With this approach the device crate would look like this:

#[cfg(any(device = "stm32f103c8", device = "stm32f103vg"))]
pub const TIM2: Peripheral<TIM2> = ..;

#[cfg(any(device = "stm32f103c8", device = "stm32f103vg"))]
pub const TIM3: Peripheral<TIM3> = ..;

// ..

#[cfg(device = "stm32f103vg")]
pub const TIM6: Peripheral<TIM6> = ..;

Application crates that depend on the device crate can then pick one specific device or other using the --cfg flag:

$ RUSTFLAGS='--cfg device="stm32f103vg"' cargo build

Library crates would require #[cfg] attributes similar to the ones used in the device crate:

extern crate stm32f103xx;

#[cfg(any(device = "stm32f103c8", device = "stm32f103vg"))]
fn foo(tim2: &stm32f103xx::TIM2) { .. }

#[cfg(device = "stm32f103vg")]
fn bar(tim6: &stm32f103xx::TIM6) { .. }

Enabling more than one device cfg seems hard to do by mistake but an error can be raised in the device crate like this:

#[allow(dead_code)]
#[cfg(device = "stm32f103c8")]
const ERROR: &str = "feature stm32f103c8 is enabled";

#[allow(dead_code)]
#[cfg(device = "stm32f103vg")]
const ERROR: &str = "feature stm32f103vg is enabled";

The more common error scenario is that people will likely forget to pass the --cfg device= flag. In that case a helpful error can be raised in the device crate:

#[cfg(not(any(device = "stm32f103c8", device = "stm32f103vg")))]
const ERROR: &str = "No device selected! Add `--cfg device=something` to `RUSTFLAGS`";

Downsides

Implementing this is hard and would require teaching svd2rust to parse a file that maps a device to the peripherals it has.

Writing library crates is tedious as it requires checking which peripheral is available for each specific device the device crate supports.

RUSTFLAGS is not the first thing that comes to people's mind when they think about configuring dependencies.

one crate per device

Another approach is to not solve this in svd2rust. Instead we can create device specific SVD files from a more generic one, and then generate one device crate for each of those files. This means that instead of a generic stm32f103xx crate we would have several crates: stm32f103c8, stm32f103vg, etc.

Downsides

Lots of duplicated code.

More work would likely be required to write crates that abstract over device specific details. Mainly because stm32f103c8::TIM2 and stm32f103vg::TIM2 are not the same type.

re-exports

Yet another approach is to have device specific crates but that only include re-exports of a more generic device crate. For instance:

// crate: stm32f103c8
extern crate stm32f103xx;

pub use stm32f103xx::{..,TIM2,TIM3,TIM4};
// crate: stm32f103vg
extern crate stm32f103xx;

pub use stm32f103xx::{..,TIM2,TIM3,TIM4,TIM5,TIM6};

Downsides

Unless the application crate directly depends on a device specific crate some intermediate library crate will end up looking like this:

// Looks familiar?
#[cfg(..)]
extern crate stm32f103c8;

#[cfg(..)]
extern crate stm32f103vg;

Unresolved questions

cc @protomors

protomors commented 7 years ago

If we were able to implement #96, then the solution to this problem would be greatly simplified. Then third-party crates could use not "stm32f103c8::TIM2" but something like "stm32::GPTimer" or "stm32::DAC". This should work very well for STM32 microcontrollers because their peripherals are very unified. But now svd2rust starts to support other types of microcontrollers so I do not know whether this will be a good solution, for example, for MSP430.

pftbest commented 7 years ago

I think MSP430 is not affected by this issue yet, because we generate a separate SVD file for each specific MCU.

But we can highly benefit from grouping devices into families and grouping peripherals across devices or even families. For example, for 596 devices in dslite database, we have only 4 kinds of watchdog timer, 12 kinds of rtc clock, 3 kinds of AES accelerator, etc. In theory we can put the most popular peripherals in one big crate, and have device crates to pull only ones they need.

protomors commented 7 years ago

For example, for 596 devices in dslite database, we have only 4 kinds of watchdog timer, 12 kinds of rtc clock, 3 kinds of AES accelerator, etc. In theory we can put the most popular peripherals in one big crate, and have device crates to pull only ones they need.

This is much more diverse than in STM32 devices (in them there are only two types of RTC). But not so bad as I was afraid. Benefits could still outweigh the need to write custom SVD files for this to work.

japaric commented 7 years ago

@protomors

If we were able to implement #96, then the solution to this problem would be greatly simplified.

Could you elaborate? I see #96 as the "dual" of this issue. #96 is about reducing the number of types to share them across different device crates. This issue is about restricting the use of instances (not types; i.e. const TIM2 not struct TIM2) when developing for specific devices and it's more tied to conditional compilation, not code reuse.

protomors commented 7 years ago

@japaric I was referring to "re-exports" solution.

If there was some way to tell svd2rust that peripherals in different devices are the same (like proposed in #96) then common crate could be not for stm32f103 but for the whole stm32f1 family or even more generic. Like @pftbest said

In theory we can put the most popular peripherals in one big crate, and have device crates to pull only ones they need.

And #96 would allow to automate this process (if SVD files were modified to include such information).