oxidecomputer / oxidecomputer/hubris
General purpose DMA
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 3.6k
- Forks
- 239
- Avg merge
- 1d 12h
- Merged PRs (30d)
- 23
Description
"Does Hubris support DMA?" is a question I get occasionally, and DMA comes up in internal design discussions as well. I'm starting this issue as a place to record context and approaches.
Current situation
We use DMA today for Ethernet. The Ethernet controller on the STM32H7 (and many other similar parts) has its own dedicated DMA engine. This is the easy case, and I'll expand on why it's easy below.
Target hardware capabilities
Most of our target processors --- possibly all of them, in fact --- have at least a rudimentary DMA engine, capable of moving data from place to place. In every case, it is a vendor-specific DMA engine, different on different models. On the H7 in particular there are no fewer than three general-purpose DMA engines, each of which has slightly different connectivity to the bus matrix, making it best suited for slightly different purposes. (If you count the blitter in the graphics controller, which is arguably general purpose, there are four.)
None of our target processors have what the Big Computer folks would call an IOMMU. There is no memory protection unit that limits the capability of the DMA engine, or for that matter any of the other peripherals that are capable of initiating AXI/AHB transactions. Some of our target processors have limited facilities for approximating this --- the LPC55 in particular has some complicated stuff to keep firmware running in nonsecure mode from being able to DMA the DRM code out of secure mode --- but it tends to be limited and is often tied to other hardware features in awkward ways (on LPC55, it's tied to secure mode).
In other words, generally speaking, a DMA engine on our target processors is more privileged than the kernel. (Even the kernel goes through the MPU, though we have it set to only intercept kernel null pointer dereferences at the moment.)
Our processors are not at all unique in this respect, but it presents some unique challenges because of other aspects of our architecture.
DMA and task isolation
The Hubris kernel is architecture-specific but vendor-independent. We've been able to pull this off because the small set of peripherals required to support the kernel --- which is basically just a tick timer and a memory protection unit --- are specified by the ARMv6-M and later architectures, so we don't have a separate ST MPU vs NXP MPU. The remaining drivers, including all the vendor specific bits, are outside the kernel in tasks.
This is a valuable property, in my opinion, and I'd sure like to maintain it.
If we allow a task access to a DMA controller, in the absence of an IOMMU, that task becomes capable of destroying the kernel, escaping memory protection, escalating its own privileges, etc. The fact that a task has this capability is not necessarily a showstopper. Let's talk about Ethernet.
Currently, our use of DMA is limited to Ethernet in the netstack. The Ethernet controller has a limited DMA controller built in. Because it's limited, its ability to stomp on certain kinds of kernel state is reduced (for reasons not relevant here). But the current situation is that, if we were sufficiently motivated, we could probably bust out of the task containment for the net task.
Does this mean we've failed at our goal of isolated components that can fail separately? I don't think it does. It's not like net runs with the MPU off; it still can't execute code from RAM, overflow its stack, etc. "Mostly isolated with a DMA controller available" is still a useful intermediate step between "fully isolated" and "privileged." I tried to mitigate risk here by writing the Ethernet DMA code very carefully, basically.
The Ethernet example is the easy case, because the net task is the sole owner of...
- The
ETH_MACperipheral where the DMA controller lives. - The SRAM bank where we aim the DMA controller.
- The incoming and outgoing queues in the Ethernet controller itself.
This makes it harder for a bug in the net stack to cause it to execute "confused deputy" style attacks, abusing its access to DMA to subvert other mechanisms. (If anyone reading this wants to try and exploit the net task, I will help you! Talk to me.)
It also makes it more likely that the common classes of DMA-related mistakes, like running small-number-of-bytes over the end of a buffer, or getting buffers confused, or accidental aliasing of memory that the DMA is still using, will remain confined to the net task. It does not guarantee this, to be clear, but it makes it more likely.
So now the hard case.
Shared DMA is complicated
The general-purpose DMA controllers that vendors tend to include in our target processors are multi-function peripherals that manage N DMA channels (where N is often 8 or 16). They have two key attributes that make them a poor fit for our existing mechanisms in Hubris:
- They are shared. Each DMA channel is not an independent peripheral with its own resources that can be separately mapped into a task. Generally speaking, to use the DMA, you have to poke a common bank of registers.
- They are an allocatable resource. DMA channels are finite, and on most processors there's a complex web of which DMA channel can be used with which peripheral, and vice versa. Many operating systems would treat this as a dynamic pool that can be managed at runtime; we don't do that, because that's how you get hard-to-reproduce load-dependent failures.
The fact that the controller is shared suggests that there should maybe be a task responsible for it, acting as a server to other tasks who want DMA to happen. This could work. There are a couple of missing mechanisms required, however.
- It's not clear how callers would tell the DMA what memory to use. Hubris IPC leases are the standard way of temporarily giving up control of some of your address space to a server, but they are deliberately opaque to the server. The server cannot discover the address where the memory actually lives, something that's important for DMA.
- Leases are atomically revoked if the client gets restarted before the server is done processing the message. While rare, this is important for our security model. We can do this because all server accesses to loaned memory are kernel-mediated, and all task state can be atomically changed from the kernel's perspective (it never preempts itself). We do not currently have a way for the kernel to "know" that a task's memory is being used by a hardware device for DMA, and that some specific action must be performed to cancel that transfer before the task can be restarted.
(You're probably wondering how we avoid number 2 with the Ethernet driver. The answer is: by using vendor-specific knowledge. We treat the memory shared with the Ethernet DMA as uninitialized from Rust's perspective, and we carefully go through at net start and fill it in. Before doing this, we assert the reset line to the Ethernet DMA controller, which is a very heavy-handed way of ensuring that all DMA has stopped. We can do this because we have vendor-specific knowledge of the STM32H7 reset controller, clock tree, and Ethernet peripheral; the kernel does not have any such knowledge. If there were more than one task involved, this approach would fail because they could be restarted at different times.)
Vague ideas
We could make DMA a first-class operating system thing and build it into the kernel. This would add a nontrivial amount of driver code to the kernel, and that code would be vendor-specific. But, it would then be easy for the kernel to keep track of DMA state and abort transfers if required. This is my least favorite option, but I wanted to note it for completeness --- this is how almost every other privileged-mode kernel approaches DMA and we could definitely make it work.
If we wanted to do DMA in userspace, there are a handful of mechanisms we might add to support it.
- It's important that DMA happen only to portions of the physical address space that are correctly configured for it --- for instance, the observing processor needs to either have set it to bypass the caches, or needs to know when to flush. We could add a mechanism for a task to mark a Lease being sent to a server as "DMA." (We already track this attribute for memory protection regions.)
- A server receiving such a lease could potentially instruct the kernel to lock it. This would set a flag on the task, which would stick around until a corresponding unlock (say). While locked, a task cannot be restarted, even by the supervisor. While a client is locked, the server would gain the ability to learn the physical addresses of leased memory.
- We would keep track of who locked the task (this is easy, since the locked task will by definition be in
WaitingForReplystate from the task that locked it). The locks should probably not reset when the server restarts, because we can't guarantee that DMA stops at server restart. So, servers need a way to discover their locked tasks at startup so they can unwind them. This would almost certainly merit a new task state for the clients, because currently all "client is waiting on server" states are cleared if the server restarts. - We would probably want to add a new kernel-to-supervisor notification on task lock / unlock, so that the supervisor could wait for a task to unlock and then restart it if needed.
There are a lot of parts of this handwavey sketch that I don't love -- in particular, locking a task seems like an availability risk.
Please post more ideas, half-baked or otherwise.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the current Ethernet DMA implementation in the netstack and the kernel's IPC lease and task-restart handling. Review the shared-DMA constraints and the listed userspace and kernel approaches; done would require a decided, implementable design with clear handling for DMA memory, cancellation, and task restarts.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- embedded-iot, operating-systems
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 20/100