sysl

The dma module

sysl.dma — Region, memory a device can reach by address: it carries the device’s address of its first byte, a part of it is a region too, and to_device/from_device are the cache maintenance a handover needs.

Every declaration in sysl.dma, with its signature: the generated API page. This page is the argument — what the module is for, and how its pieces fit; that one is the list.

A device reads and writes memory by the address it sees — a physical address, or a bus address where an IOMMU stands between — in runs that have to be contiguous on its side. A []u8 says none of that. It may be a heap block, a local array on a stack, or a page of a window mapped from whatever frames were free, and nothing in its type says which; so a driver that puts a caller’s slice into a descriptor compiles for every slice and is right for only some of them, and where it is wrong the device writes to memory that is not the buffer.

sysl.dma is the other kind of buffer. A Region is a run of memory together with the address the device uses for its first byte:

import sysl.dma
import sysl.slices.as_mut_ptr

var frames: [4096]u8 = [0; 4096]
val r = dma.adopt(as_mut_ptr(frames[..]), 0x4100_0000, 4096)

val header = r.part(0, 16)
val data = r.part(512, 512)

print(r.len(), header.len(), data.len())
print(f"${header.bus()}%x ${data.bus()}%x")
4096 16 512
41000000 41000200
adopt(base, bus, len, coherent = false)the one place a device address enters a program
r.len(), r.is_empty()how many bytes it holds
r.bus()the device’s address of the first byte — what goes into a descriptor
r.bytes()the region as the processor reads and writes it, a []u8
r.part(from, len)a part of it, which is a region too
r.at[T](offset)the T laid over it at that byte, checked to fit and be aligned
r.bus_of(p)where the device sees the T at p, checked to lie inside
r.to_device(), r.from_device()the handover, which is the cache maintenance
Sourcewhat hands regions out: take(len) and give(r)

A device address is stated once, and computed never

adopt is the trust point, and there is only one. Whoever owns device-reachable memory — a kernel’s frame allocator, a reserved carve-out, a user-space driver’s pinned pages — knows the two addresses and says so there. Everything after it is arithmetic the module does: a part’s bus address moves with its processor address, bus_of answers for a field of a structure laid over the region, and nothing a driver writes ever subtracts a base from a pointer.

A Region‘s fields are private, so a program cannot build one from a slice it happens to hold:

import sysl.dma

var buf: [512]u8 = [0; 512]
val r = dma.Region(&buf[0], 0, 512, false)

print(r.len())
the constructor names every field of 'sysl.dma.Region' in order, and 'base' is private to 'library/sysl/dma/dma.sysl', the file that declares it

and a function that hands memory to a device takes a Region, so the stack buffer that would have been handed over as a physical address that is not memory at all is refused at the call:

import sysl.dma

read_sector(n: u64, into: dma.Region)
    print(n, into.len())

var buf: [512]u8 = [0; 512]
read_sector(1, buf[..])
'into' of 'read_sector' is sysl.dma.Region, but []byte was given

The region does not own its memory, the bargain Ring and a *T make: whoever adopted it gives it back, through the Source it came from, after every device that was handed it has finished with it. That is also what makes it cheap — a region is four words and no count, so cutting a part costs what cutting a slice costs, and a region crosses a concurrency domain as freely as a raw pointer does: a driver’s state in a &sync box may hold them, and an interrupt handler may read one.

A structure laid over a region, and the addresses of its fields

A device’s queue is usually a structure the processor and the device share — descriptor tables, rings, request headers — laid over a run of frames. at[T] is that overlay, and it checks the two things a hand-written ptr_cast beside an @assert of the size leaves to the reader: that the T fits, and that it is aligned. bus_of is the other direction, the device’s address of anything inside:

import sysl.dma
import sysl.slices.as_mut_ptr

struct Request
    kind: u32
    reserved: u32
    sector: u64

var frames: [512]u64 = [0; 512]
val r = dma.adopt(ptr_cast(as_mut_ptr(frames[..])), 0x4100_0000, 4096)
val req: *Request = r.at(64)

req.sector = 7

print(f"${r.bus_of(req)}%x ${r.bus_of(&req.sector)}%x")
41000040 41000048

A pointer that is not inside the region panics rather than answering, because the address it would have answered is exactly the one a device must never be handed. A part that runs past the end and an overlay that does not fit or is misaligned panic the same way — the bargain an index past the end of a slice makes.

The handover is the cache maintenance

On a machine whose caches the device does not see, two things go wrong without help: a write the processor made may still be in a cache when the device reads the memory, and a line the processor cached before the device wrote may be read in place of what the device wrote. to_device before the device is told about the region and from_device after it says it has finished are what make both right:

import sysl.dma

@export("submit")
submit(r: dma.Region, doorbell: *volatile u32) -> u64
    r.to_device()
    *doorbell = 1
    r.bus()

@export("complete")
complete(r: dma.Region) -> u8
    r.from_device()
    r.bytes()[0]

On AArch64 each one cleans and invalidates every data cache line the region touches (dc civac, stepping by the smallest line CTR_EL0 reports), then issues dsb sy, so the maintenance is finished before the store that rings the doorbell. One operation serves both directions — cleaned so the device reads what was written, invalidated so neither a dirty line evicted later nor a clean one read later stands in for the memory — and that keeps the rule one sentence:

While the device holds a region, the processor writes nothing in the cache lines it touches.

A line is the unit, so two regions sharing a line share the rule: a request header and the status byte after it may sit in one line, the processor writing neither while the request is in flight.

A region adopted with coherent: true — the device sees the processor’s caches, or the memory is mapped uncached — has a handover that is the barrier alone. Only AArch64 has its maintenance in this module: on any other machine adopt refuses a region that is not coherent rather than handing one over without the maintenance it needs, and the handover there is atomic_fence(SeqCst).

Where regions come from

Source is what hands them out, so a driver written as a package allocates its rings and buffers without knowing whose memory it is:

import sysl.dma
import sysl.dma.Source
import sysl.slices.as_mut_ptr

struct Arena
    base: *u8
    room: usize
    taken: bool

impl Source for Arena
    take(*self, len: usize) -> Option[dma.Region]
        if self.taken || len > self.room then return None

        self.taken = true
        Some(dma.adopt(self.base, 0x4100_0000, len))

    give(*self, r: dma.Region)
        self.taken = false

queue_memory[S: Source](src: *S) -> Option[dma.Region] = src.take(2048)

var store: [4096]u8 = [0; 4096]
var arena = Arena(as_mut_ptr(store[..]), 4096, false)

print(queue_memory(&arena).is_some(), queue_memory(&arena).is_some())
true false

A kernel’s version adopts frames from its frame allocator, each at its linear-map address and its physical one; a hosted user-space driver’s adopts pages it pinned and asked the IOMMU about. Either way the two addresses are known where the memory is made, and nowhere else does a program need them.

Search

Esc
↑↓ to navigate ↵ to open Esc to close