Motivation
Today, in svd2rust and cortex-m APIs, peripherals are modeled as global singletons that require
some synchronization, e.g. a critical section like interrupt::free
, to be modified.
The consequences of this choice is that (a) driver APIs are not ergonomic as one would expect, see
below:
use stm32f103xx::TIM6;
// Periodic timer
struct Timer<'a>(&'a TIM6);
impl<'a> Timer<'a> { .. }
fn main() {
interrupt::free(|cs| {
let tim6 = TIM6.borrow(cs);
let timer = Timer(tim);
timer.init(..);
// ..
timer.resume();
});
}
interrupt!(TIM6, tim6);
fn tim6() {
interrupt::free(|cs| {
let tim6 = TIM6.borrow(cs);
let timer = Timer(tim);
timer.clear_update_flag();
// ..
});
}
Here the Timer
abstraction has to be reconstructed in each execution context. One would prefer to
instantiate Timer
as a static variable and then use that from each execution context. However,
that's not possible with this Timer
struct because of the non-static lifetime of the inner field.
(It is possible to remove the inner field from Timer
at the expense of having a critical section
per method call -- which has worse performance than the non-static lifetime approach).
Even worst is that (b) driver abstractions can be easily broken due to the global visibility
property of peripherals. This means that there's no way to e.g. make sure that TIM6
is only used
as a Timer
in main
and tim6
. Nothing prevents you, or some other crate author, from silently
using TIM6
in other execution context -- TIM6
doesn't even have to appear as a function argument
because it's always in scope. This issue not only breaks abstractions; you can also have race
conditions on TIM6 -- yes, even with interrupt::free
you can have race conditions and that's
totally memory safe per Rust definition -- if Timer
is being used in other execution contexts
and you don't know about it.
So, what can we do to fix these issues? We can remove the root of all these problems: global
visibility.
This RFC is proposing to go from global singletons to scoped singletons. Instead of having
peripheral singletons with global visibility you'll have to explicitly import a peripheral
singleton into scope, into the current execution context. Because we are talking about singletons
here you can only import singleton P into scope (execution context) S once. IOW, if you imported P
into an execution context S then you can't import P into another scope S'.
This RFC not only addresses the problems (a) and (b) mentioned above; it also helps us tackle the
problem of compile time checked configuration -- more about that later on.
Detailed design
Zero sized proxies that represent a peripheral register block, as shown below, will be added to
cortex-m
and svd2rust
generated crates.
pub struct GPIOA { _marker: PhantomData<*const ()> }
// Peripherals are `Send` but *not* `Sync`
unsafe impl Send for GPIOA {}
impl Deref for GPIOA {
type Target = gpioa::RegisterBlock;
fn deref(&self) -> &gpio::RegisterBlock { /* .. */ }
}
These proxies will be impossible to directly instantiate. Instead there will be a guarded function
that returns all the proxies only once.
pub struct Peripherals {
pub GPIOA: GPIOA,
pub GPIOB: GPIOB,
// ..
}
impl Peripherals {
pub fn all() -> Option<Self> {
interrupt::free(|_| unsafe {
static mut USED = false;
if USED {
None
} else {
USED = true;
Some(Peripherals { .. })
}
})
}
}
The user will be able to access the proxies like this:
fn main() {
let p = Peripherals::all().unwrap(); // first call: OK
//let p = Peripherals::all().unwrap(); // a second call would panic!
p.GPIOA.bsrr.write(|w| /* .. */);
p.GPIOB.bsrr.write(|w| /* .. */);
}
Thus the proxies are singletons: there can only exist a single instance of each of them during the
whole lifetime of the program. The proxies are also scoped, in this case they are tied to main
, so
they are not visible to other execution contexts, unless they are explicitly moved into another
execution context.
Zero cost
An unsafe, unguarded variant of Peripherals::all
will also be provided:
static mut USED = false;
impl Peripherals {
pub fn all() -> Option<Self> {
interrupt::free(|_| unsafe { /* .. */ })
}
// NOTE for safety this function can only be called once and before `Peripherals::all` is called
pub unsafe fn _all() -> Self {
USED = true;
Peripherals { .. }
}
}
When only the unsafe
variant is used the cost of having scoped singletons becomes zero:
fn main() {
let p = unsafe { Peripherals::_all() };
p.GPIOA.bssr.write(|w| /* .. */)
}
This program has the same cost as using a global, unsynchronized GPIOA
register block (which is
what you see in C HALs).
Sidestepping the proxy
Each peripheral will provide a static method, ptr
, that returns a raw pointer into the register
block:
impl GPIOA {
pub fn ptr() -> *const gpioa::RegisterBlock {
0x4001_0800 as *const _
}
}
This is useful for implementing safe APIs that perform "unsynchronized" reads on registers that
have no side effect when read:
impl DWT {
/// Reads the cycle counter
// NOTE this is a static method and doesn't require an instance of `DWT`
pub fn cyccnt() -> u32 {
unsafe { (*DWT::ptr()).cyccnt.read() }
}
}
// time something
let before = DWT::cyccnt();
// ..
let after = DWT::cyccnt();
let elapsed = after.wrapping_sub(before);
Enabling new APIs
Scoped singletons effectively give move semantics to peripherals. This enables richer, type safer
APIs than what can be expressed with the current peripheral API. Let's see some examples:
(you can find some initial experiments with these APIs in the singletons
branch of the f3
repository)
Type state as a contract
Digital I/O pins can be configured as inputs, outputs or to some special functionality like serial,
SPI or I2C. In some cases you want to configure a pin to operate in a certain mode for the duration
of the whole program; that is you don't want the pin to be re-configured by mistake.
Type state is a good way to encode this property: a type is used to encode the state of an object.
To transition the object from a state to another it needs to be moved so that the previous state
can no longer be used.
Here's a tentative GPIO API that uses type state:
use blue_pill::GpioExt;
let p = Peripherals::all().unwrap();
// from `GpioExt`: `fn pins(self) -> gpioa::Pins`
// `Pins` is a struct that contains all the pins. Each pin is (also) a singleton (!).
let pins = p.GPIOA.pins();
// `fn as_output(self) -> Output<Self>`
// `as_output` configures a pin as an output pin
let pa0 = pins.PA0.as_output();
// `fn as_input(self) -> Input<Self>`
// `as_input` configures a pin as an input pin
let pa1 = pins.PA1.as_input();
// `Output::high(&mut self)`
// set PA0 high (to 3.3V)
pa0.high();
// `Input::is_high(&self) -> bool`
if pa1.is_high() {
// ..
}
// this would cause a compile error because `PA0` is not in the `Input` state
// pa0.is_high();
// this would cause a compile error because `PA1` is not in the `Output` state
// pa1.high();
// this would cause a compile error because `GPIOA` was consumed by the `pins` call
// p.GPIOA.moder.modify(|_, w| /* configure PA0 as an input */)
Here the Input
and Output
newtypes are used to encode the type state. The most important part
here is that GPIOA
is consumed to generate the individual pins and thus it can't no longer be
used to configure the pins -- which would break Input
/ Output
contract of "this pin can only
be used as an input for the rest of the program".
Compile time pin allocation
On modern microcontrollers a single pin can be configured to have one of many functionalities
(Serial, PWM, I2C, etc.). This lets vendor pack more peripherals in a microcontroller without
increasing the number of pins.
A problem that arises from this flexibility is that one might wrongly configure two, or more,
peripherals to use the same pin. With move semantics you can construct an API that ensures that
this can't happen:
let p = Peripherals::all().unwrap();
let pins = p.GPIOA.pins();
// use PA9 and PA10 for the USART1 serial port
// NOTE consumes `pa9`
let tx = pa9.as_usart1_tx();
// NOTE consumes `pa10`
let rx = pa10.as_usart1_rx();
// `Serial::new` in turn consumes `tx` and `rx`
let serial = Serial::new(p.USART1, (tx, rx), 115_200.bps());
// this would error because `pa9` was consumed above
//let ch2 = pa9.as_tim1_ch2();
//let pwm = Pwm::new(p.TIM1, ch2, 10.khz());
Here we have high level abstractions like Serial
consume the pins they are going to use. This way
once one such abstraction is created no other abstraction can't use any of the pins the first one is
using.
Non overlapping register access
The "split" technique used for GPIO pins can also be used to split a peripherals in "parts" that (a)
modify different registers and (b) modify non overlapping parts of a same register. This comes in
handy with peripherals like the DMA which usually interacts with several other peripherals.
Vendors usually design their DMA peripherals so that even if the settings related to different
channels are stored in a single register that register can be modified atomically using e.g. "clear
flag" bits (no RMW operation required to clear a flag). If the vendor doesn't provide such mechanism
bit banding can probably be used in its place, if the device has support for it.
RTFM protects peripherals at the register block level. Without move semantics, to clear "transfer
complete" interrupt flags from two interrupts handlers running at different priorities you would
need a lock in the lowest priority handler:
// priority = 2
fn dma1_channel1(r: DMA1_CHANNEL1::Resources) {
r.DMA1.lock(|dma1| {
// clear the transfer complete flag for this channel
dma1.ifcr.write(|w| w.ctcif1().set_bit());
// ..
});
}
// priority = 3
fn dma1_channel2(r: DMA1_CHANNEL2::Resources) {
let dma1 = r.DMA1;
// clear the transfer complete flag for this channel
dma1.ifcr.write(|w| w.ctcif2().set_bit())
// ..
}
But with move semantics you can split the DMA in channels and assign exclusive access to each
channel to each interrupt handler:
fn init(p: init::Peripherals) -> init::LateResourceValues {
let channels = p.DMA1.split();
// ..
init::LateResourceValues { CH1: channels.1, CH2: channels.2 }
}
// priority = 2
// resources = [CH1]
fn dma1_channel1(r: DMA1_CHANNEL1::Resources) {
// clear the transfer complete flag for this channel
r.CH1.clear_transfer_complete_flag();
}
// priority = 3
// resources = [CH2]
fn dma1_channel2(r: DMA1_CHANNEL2::Resources) {
// clear the transfer complete flag for this channel
r.CH2.clear_transfer_complete_flag();
}
Thus no locking is needed. Each channel will operate on a non-overlapping portion of the shared
IFCR
register.
Configuration freezing
In some cases you want to configure the core and peripherals clocks to operate at certain
frequencies during initialization and then make sure that these frequencies are not changed later at
runtime.
Again, move semantics can help here by "discarding" the peripheral in charge of clock configuration
once the clock has been configured:
use blue_pill::RccExt;
let p = Peripherals::all().unwrap();
// .. use `p.RCC`, or a higher level API based on it, to configure the clocks ..
// from `RccExt`: `fn clocks(self) -> Clocks`
// `clocks` contains information about the operating frequency of the peripheral buses
let clocks = p.RCC.freeze();
// To compute USART1 internal prescalers and achieve the desired baud rate,
// information about the current clock configuration is required.
// `clocks` provides that information
let serial = Serial::new(p.USART1, (tx, rx), &clocks, 115_200.bps());
Here, once the clock is configured, its configuration gets frozen by consuming / discarding the
RCC
peripheral. With this ... move the clock frequencies can no longer be changed. Freezing RCC
returns a Clocks
struct that contains the frequency of every peripheral bus. This information is
required to properly configure the operating frequency of each peripheral so it's passed to
peripherals' init
functions.
Drawbacks
Regression when not using RTFM
And you still want to use interrupts.
RTFM supports moving runtime initialized resources into tasks (interrupts) at zero, or very little,
cost but if you are not using RTFM then a static
variable is required to share a peripheral
between e.g. main and an interrupt handler. Putting a peripheral singleton in a static
variable
means making it globally visible which means you have global singletons again, and all their
disadvantages, but with worse performance (because an Option
and a RefCell
are needed, see
below) than what you get with today's API.
With this RFC:
// you want to share `GPIOA` between `main` and `exti0`
// but this makes it global so any other execution context can also use it
static GPIOA: Mutex<RefCell<Option<GPIOA>>> = Mutex::new(RefCell::new(None));
fn main() {
// initialize `GPIOA`
let p = Peripherals::all().unwrap();
interrupt::free(move |cs| {
*GPIOA.borrow(cs).borrow_mut() = Some(p.GPIOA);
});
loop {
interrupt::free(|cs| {
let gpioa = GPIOA.borrow(cs).borrow().as_ref().unwrap();
// ..
});
// ..
}
}
interrupt!(EXTI0, exti0);
fn exit0() {
interrupt::free(|cs| {
let gpioa = GPIOA.borrow(cs).borrow().as_ref().unwrap();
// ..
});
}
With today's API:
// you don't have the RefCell + Option overhead but you still have global visibility
fn main() {
loop {
interrupt::free(|cs| {
let gpioa = GPIOA.borrow(cs);
// ..
});
// ..
}
}
interrupt!(EXTI0, exti0);
fn exit0() {
interrupt::free(|cs| {
let gpioa = GPIOA.borrow(cs);
// ..
});
}
Compare this to RTFM + this RFC:
app! {
resources: {
static GPIOA: GPIOA;
},
idle: {
resources: [GPIOA],
},
tasks: {
EXTI0: {
path: exti0,
resources: [GPIOA],
},
},
}
fn init(p: init::Peripherals) -> init::LateResourceValues {
init::LateResourceValues {
GPIOA: p.GPIOA,
}
}
fn idle(t: &mut Threshold, r: idle::Resources) -> ! {
loop {
r.GPIOA.claim(threshold, |gpioa| {
// do stuff with `r.GPIOA`
});
// ..
}
}
fn exti0(r: EXTI0::Resources) {
// do stuff with `r.GPIOA`
}
No Mutex
, no RefCell
, no Option
and no global visibility. Plus only idle
needs a lock to
access GPIOA
. Without this RFC, even with RTFM, it's possible to access GPIOA
from
an execution context that's not idle
or exti0
due to this issue / bug / hole:
japaric/cortex-m-rtfm#13.
It breaks the world
Breaking changes in cortex-m
, svd2rust
and cortex-m-rtfm
are required to implement this.
Unresolved questions
-
Should we have finer grained access to peripherals as in a GPIOA::take()
that returns
Option<GPIOA>
(in addition to Peripherals::all()
-- one would invalidate the other)?
-
The unsafe variant, Peripherals::_all
, needs a better name.
Implementation bits
Note that the implementation is a bit crude at this point. Expect bugs. Still, I'm posting now to
get feedback and to allow others to experiment.
- The
f3
repo contains a high level API, and examples that use it, based on this RFC.
- japaric/cortex-m#65
- japaric/svd2rust#158
- japaric/cortex-m-rtfm#50
cc @pftbest @thejpster @therealprof @hannobraun @nagisa @kjetilkjeka
EDIT: I realized that it may not be obvious how the RFC solves problem (a) so here's an example:
Let's say that you want to use a serial port in idle
and in some interrupt handler. You know that
idle
only needs to use the transmitter half the serial port and the interrupt handler only needs
to use the receiver part.
app! {
resources: {
static TX: Tx;
static RX: Rx;
},
idle: {
resources: [TX],
},
tasks: {
EXTI0: {
path: exti0,
resources: [RX],
},
},
}
fn init(p: init::Peripherals) -> init::LateResourceValues {
// consumes `GPIOA`
let pins = p.GPIOA.pins();
// consumes `PA9`
let pa9 = pins.PA9.as_usart1_tx();
// consumes `PA10`
let pa10 = pins.PA10.as_usart1_rx();
// consumes `USART1`, `pa9` and `pa10`
let serial: Serial = Serial::new(p.USART1, (pa9, pa10), 115_200.bps());
// consumes `serial`
let (tx, rx): (Tx, Rx) = serial.split();
// initializes the resources `TX` and `RX`
init::LateResourceValues {
TX: tx,
RX: rx,
}
}
fn idle(_t: &mut Threshold, r: idle::Resources) -> ! {
let tx: &mut Tx = r.TX;
loop {
// do stuff with `tx`
}
}
fn exti0(_t: &mut Threshold, r: EXTI0::Resources) {
let rx: &mut Rx = r.RX;
// do stuff with `rx`
}
With all the moves in init
you are sure that:
-
No task or crate (dependency) can reconfigure the pins through GPIOA
, because it (GPIOA
) was
created (cf. the init::Peripherals
argument of init
) and consumed in init
.
-
No task or crate (dependency) can reconfigure or drive the pins PA9
and PA10
, because both
pins were created and consumed in init
.
-
No task or crate (dependency) can do serial I/O through USART1
because it (USART1
) was created
and consumed in init
.
-
No task or crate (dependency) other than idle
can do serial writes because only idle
has
access to Tx
.
-
No task or crate (dependency) other than exti0
can do serial reads because only exti0
has
access to Rx
.
Without this RFC you can't have any of these guarantees because the svd2rust API lets you use
GPIOA
, USART1
in any execution context so even a function foo
with signature fn()
(note:
no arguments) can reconfigure the pins or start a serial operation.