In this issue I'm going to present the DMA API I have implemented for the blue-pill and that I have used in my recent robotic application with the goal of using it to start a discussion about DMA based API.
Pre-requisites
You should understand how RefCell
works and how it achieves dynamic borrowing.
Detailed design
Core idea
The DMA peripheral behaves like an "external mutator". I like to think of it as an independent processor / core / thread that can mutate / access data in parallel to the main processor. From that POV a DMA transfer looks like a fork-join operation like the ones you can do in std-land with threads.
With that mindset: in a DMA transfer you want to hand out "ownership" of the data / buffer from the processor to the DMA for the span of the transfer and then claim it back when the transfer finishes. Except that "ownership" in the full sense ("the owner is in charge of calling the destructor when the value is no longer needed") is not applicable here because the DMA can't destroy the buffer is using for the transfer. So instead of ownership what the proposed API will transfer is temporary read or write access to the data.
Read or write access sounds like &-
and &mut-
references but the proposed API won't use those. Instead it will use buffers with RefCell
-style dynamic borrowing.
Buffer
The main component of the proposed API is the Buffer
abstraction:
struct Buffer<B, CHANNEL> {
_marker: PhantomData<CHANNEL>,
data: B,
flag: Cell<BorrowFlag>, // exactly like `RefCell`'s
status: Cell<Status>, // "Lock status"
}
enum Status {
Unlocked,
Locked,
MutLocked,
}
The data
and flag
fields effectively form a RefCell<B>
. Although not explicitly bounded B
can only be an array of bytes ([u8; N]
). The CHANNEL
type parameter indicates which DMA channel this buffer can work with; possible values are phantom types like Dma1Channel1
, Dma1Channel2
, etc. Finally the status
field indicates if the DMA is currently in possession of the buffer.
impl<B, CHANNEL> Buffer<B, CHANNEL> {
pub fn borrow(&self) -> Ref<'a, B> { .. }
pub fn borrow_mut(&self) -> RefMut<'a, B> { .. }
fn lock(&self) -> &B { .. }
fn lock_mut(&self) -> &mut B { .. }
unsafe unlock(&self) { .. }
unsafe unlock_mut(&self) { .. }
}
Buffer
exposes public borrow
and borrow_mut
methods that behave exactly like the ones RefCell
has. Buffer
also exposes private lock
s and unlock
s methods that are meant to be used only to implement DMA based APIs.
The lock
and unlock
pair behaves like a split borrow
operation. A borrow
call will check that no mutable references exist and then hand out a shared reference to data wrapped in a Ref
type while increasing the Buffer
shared reference counter by one. When that Ref
value goes out of scope (it's destroyed) the Buffer
shared reference counter goes down by one. A lock
call does the first part: it checks for mutable references, increases the counter and hands out a shared reference to the data. However the return type is a plain shared reference &T
so when that value goes out of scope the shared reference counter won't be decremented. To decrement that counter unlock
must be called. Likewise the lock_mut
and unlock_mut
pair behaves like a split borrow_mut
operation.
You can see why it's called lock
and unlock
: once lock
ed the Buffer
can't no longer hand out mutable references (borrow_mut
) to its inner data until it gets unlock
ed. Similarly once a Buffer
has been lock_mut
ed it can't hand out any reference to its inner data until it gets unlock_mut
ed.
DMA based API
Now let's see how to build a DMA based API using the Buffer
abstraction. As an example we'll build a Serial.write_all
method that asynchronously serializes a buffer using a DMA transfer.
impl Serial {
pub fn write_all<'a>(
&self,
buffer: Ref<'a, Buffer<B, Dma1Channel4>>,
) -> Result<(), Error>
where
B: AsRef<[u8]>,
{
let usart1 = self.0;
let dma1 = self.1;
// There's a transfer in progress
if dma1.ccr4.read().en().bit_is_set() {
return Err(Error::InUse)
}
let buffer: &[u8] = buffer.lock().as_ref();
// number of bytes to write
dma1.cndtr4.write(|w| w.ndt().bits(buffer.len() as u16));
// from address
dma1.cmar4.write(|w| w.bits(buffer.as_ptr() as u32));
// to address
dma1.cpar4.write(|w| w.bits(&usart1.dr as *const _ as u32));
// start transfer
dma1.ccr4.modify(|_, w| w.en().set());
Ok(())
}
}
Aside from the Ref
in the function signature, which is not a cell::Ref
(it's a static_ref::Ref
), this should look fairly straightforward. For now read Ref<'a, T>
as &'a T
; they are semantically equivalent. I'll cover what the Ref
newtype is for in the next section.
Let's see how to use this method:
static BUFFER: Mutex<Buffer<[u8; 14], Dma1Channel4>> =
Mutex::new(Buffer::new([0; 14]));
let buffer = BUFFER.lock();
// mutable access
buffer.borrow_mut().copy_from_slice("Hello, world!\n");
serial.write_all(buffer).unwrap();
// immutable access
let n = buffer.borrow().len(); // OK
// mutable access
// let would_panic = &mut buffer.borrow_mut()[0];
At this point the transfer is ongoing and we can't mutably access the buffer while the transfer is in progress. How do we get the buffer back from the DMA?
impl<B> Buffer<B, Dma1Channel4> {
/// Waits until the DMA transfer finishes and releases the buffer
pub fn release(&self) -> nb::Result<(), Error> {
let status = self.status.get();
// buffer already unlocked: no-op
if status == Status::Unlocked {
return Ok(());
}
if dma1.isr.read().teif4().is_set() {
return Err(nb::Error::Other(Error::Transfer))
} else if dma1.isr.read().tcif4().is_set() {
if status == Status::Locked {
unsafe { self.unlock() }
} else if status == Status::MutLocked {
unsafe { self.unlock_mut() }
}
// clear flag
dma1.ifcr.write(|w| w.ctcif5().set());
} else {
// transfer not over
Err(nb::Error::WouldBlock)
}
}
}
The Buffer.release
is a potentially blocking operation that checks if the DMA transfer is over and unlock
s the Buffer
if it is. Note that the above impl
ementation is specifically for CHANNEL == Dma1Channel4
. Other similar implementations can cover the other DMA channels.
Continuing the example:
serial.write_all(buffer).unwrap();
// immutable access
let n = buffer.borrow().len(); // OK
// .. do stuff ..
// wait for the transfer to finish
block!(buffer.release()).unwrap();
// can mutably access the buffer again
buffer.borrow_mut()[12] = b'?';
serial.write_all(buffer).unwrap();
Alternatively, using callbacks / tasks:
fn tx() {
// ..
SERIAL.write_all(BUFFER.lock()).unwrap();
// ..
}
// DMA1_CHANNEL4 callback
fn transfer_done() {
BUFFER.lock().release().unwrap();
}
static_ref::Ref
The Ref<'a, T>
abstraction is a newtype over &'a T
that encodes the invariant that the T
to which the Ref
is pointing to is actually stored in a static
variable and thus can't never be deallocated.
The reason Ref
is used instead of just &-
in the write_all
method is to prevent using stack allocated Buffer
s with that method. Stack allocated Buffer
s are rather dangerous as there's no mechanism to prevent them from being deallocated. See below what can happen if a Serial.read_exact
method used &-
instead of Ref
:
fn main() {
foo();
bar();
}
#[inline(never)]
fn foo() {
let buffer = Buffer::new([0; 256]);
SERIAL.read_exact(&buffer);
// returns, DMA transfer may not have finished, `buffer` is
// destroyed / deallocated
}
#[inline(never)]
fn bar() {
// DMA transfer ongoing; these *immutable* values allocated on the stack
// will get written to by the DMA
let x = 0u32;
let y = 0u32;
// ..
}
Unresolved questions
-
Is there an equally flexible (note that with this approach the DMA transfer start and finish operations can live in different tasks / interrupts / contexts) alternative that involves no runtime checks?
-
Should we extend this to also work with Buffer
s allocated on the stack? My first idea to allow that was to add a "drop bomb" to Buffer
. As in the destructor will panic if there's an outstanding lock
. See below. (But this probably a bad idea since panicking destructor is equal to abort (?))
{
let buffer = Buffer::new(..);
buffer.lock();
// panic!s
}
{
let buffer = Buffer::new(..);
buffer.lock();
unsafe { buffer.lock() }
// OK
}
{
let buffer = Buffer::new(..);
let x = buffer.borrow();
// OK
}
Do note that an unsafe Ref::new
exists so you can create a Buffer
on the stack and wrap a reference to it in a Ref
value. This will be like asserting that the buffer will never get deallocated. Of course it's up to you to ensure that's actually the case.
-
In the blue-pill implementation Buffer
is defined in the blue-pill
crate itself but to turn DMA based APIs into traits we would have to move that Buffer
abstraction into the embedded-hal
crate. That complicates things because (a) lock
et al. would have to become public and (b) e.g. Dma1Channel1
wants to be defined in the stm32f103xx-hal-impl
crate but that means one can't write impl Buffer<B, Dma1Channel1>
due to coherence. I have no idea how to sort out these implementation details.
-
This API doesn't support using a single Buffer
with more than one DMA channel (neither serially or concurrently). Is that something we want to support?
-
Since we have the chance: should we reduce the size of the flag
field? The core implementation uses flag: usize
but 4294967295
simultaneous cell::Ref
sounds like a very unlikely scenario in a microcontroller.