Embedded HALs and safety

I am currently writing Rust code for a microcontroller, and I am trying to get a better understanding of how safety works here. I am also writing a blog post alongside it, so I want to be precise about it. I have the following example code:

struct PORT;
impl PORT {
    const DIR01: *mut u32 = 0x41000080 as *mut u32;
    const OUT01: *mut u32 = 0x41000080 as *mut u32;

    /// # SAFETY
    ///
    /// - Must only be called on a PIC32CM6408PL100XX.
    unsafe fn new() -> Option<Self> {
        // ...
    }
}

fn set_led0_on(_port: &mut PORT) {
    // SAFETY: we have an exclusive reference to `PORT`
    unsafe {
        let dir = core::ptr::read_volatile(PORT::DIR01);
        core::ptr::write_volatile(PORT::DIR01, dir | (1 << 2));
        let out = core::ptr::read_volatile(PORT::OUT01);
        core::ptr::write_volatile(PORT::OUT01, out | (1 << 2));
    }
}

This is the classic singleton pattern for peripheral access in embedded devices.

But I have two questions:

  • What should uphold the requirement “Must only be called on a PIC32CM6408PL100XX”? From the HALs I have seen on crates.io, calling Peripherals::take() is considered safe.
  • If I provide my HAL as a crate, what guarantees that my crate and all the unsafe code in it has exclusive access to PORT? For all I know, another crate could try to make the same guarantee.

Is there a formal way I should be thinking about these safety concerns? I guess these questions also apply to crates that expose OS-specific resources and may be related to std::env::set_var.

One way I can think about it is that:

  • adding dependencies is unsafe: the developer is responsible for ensuring that adding the crate is sound; and
  • running a (Rust) program is unsafe: the user is responsible for ensuring that it is running on a system that upholds the safety preconditions.

But I think that is too exaggerated a way to put it.

I think you're right that this is the same general idea as how std has to make some compromises/assumptions about reasonable OS behavior in order to offer useful abstractions.

For the common HAL vs PAC design it seems to just be assumed that all access to the registers is intermediated through a single PAC crate, and specifically through the PAC's Peripherals::take() to ensure there can be only one owner.

It's true that another create could undermine that by just going straight to the hardware registers itself and potentially expose a competing abstraction, but that seems similar to how a crate running on a Linux system can potentially perform arbitrary system calls that undermine assumptions that std is making, or that callers of std are making about the abstractions that std provides.

But I think I'm essentially just restating essentially what you said in less precise terms:

  • adding dependencies is unsafe: yes, in general any code you include in your program could potentially do something that undermines assumptions that other code in your program is making, and it's your responsibility to review each library you are using to make sure they aren't doing that.
  • running a program is unsafe: yes, in the sense that code is compiled with a particular target in mind and unfortunately not everything about the target is expressed in the target tuple or practical to check dynamically at runtime, and running a program on a system that doesn't meet whatever assumptions the code was compiled for could cause arbitrary behavior, including memory unsafety[1].

We have to be able to make at least some general assumptions about the code in the program and functionality of the target in order to write any code at all though, right? So where to draw the line with what parts of those are explicitly checked by the compiler as unsafe and which are not is a tradeoff, rather than an absolute.

I think code that directly targets a hardware platform instead of an operating system is just more exposed to this because we don't normally encode exactly what hardware platform we're targeting in the target tuple, whereas at least when targeting OSes you directly tell the compiler what OS you're intending the program to run on.


  1. For example, if you're unfortunate enough that a memory-mapped register that's just a GPIO port on one platform happens to be part of a DMA controller on another platform. ↩︎

3 Likes

I'm quite confused by your use of the word "unsafe" here. I suspect there's a misunderstanding of that somehow.

One way I like to think about safety is: unsafe assigns blame on who is at fault when UB happens. When a function is declared as unsafe, it is the responsibility of the caller to follow the documented requirements. If UB happens, and the requirements aren't followed, then we "blame" the author of the caller code. On the other hand, if UB happens, and all requirements are followed (or the function being called isn't declared as unsafe), then we "blame" the author of the function being called.

When I say "blame" above, I mean: the "rust social norm" considers the blamed code to be buggy, and ought to be fixed.

(This explanation doesn't cover more complicated cases like: library invariants, unsafe code relying on a buggy dependency, or soundness conflicts. But this should be good enough for most cases.)

3 Likes

In case you plan to copy/paste this example in your blog post, you might want to fix 2 details:

  1. DIR01 and OUT01 have the same address, probably a typo.
  2. PORT has no private fields, so calling PORT::new() is not needed to create a value of that type. This is unsound if PORT must be a singleton like you suggest.

Now to your questions:

  • I mentioned a few times in this forum the notion of "unsafe crate". An unsafe crate is a crate that is unsafe to link. Whoever links that crate (usually at the binary crate level) needs to make sure the safety requirements of that crate hold. The most common example is when a crate uses unsafe(no_mangle). But your example (where a crate claims a register address) also fits that bag. The safety requirement is that no other crate in the binary must claim that same address (or name in the case of no_mangle).
  • Yes that's an implicit safety requirement: the program must run in a suitable environment (which includes target triple, but not only). This means that all Rust programs are unsafe, because the program alone cannot guarantee in which environment it will run, it must assume it. So for pragmatic reasons, this unsafe is implicit. It's up to the final user executing the program to satisfy that it executes it in a suitable environment.

So in short:

  • unsafe function has call-time requirements
  • unsafe trait has implementation-time requirements
  • unsafe crate has link-time requirements
  • unsafe program (all programs) has execution-time requirements

Those last 2 concepts don't really exist in Rust. The last one for obvious reasons. The third one is a bug in my opinion.

1 Like

Dumb question from someone who has practically no embedded experience: Couldn't one place a ZST (or extern type?) static at the relevant address and then let the linker detect double-uses?

Yes, that is what I mean when I say that "adding a dependency is unsafe" or "running a program is unsafe." That means that if UB happens, the person who added the dependency or ran the code is responsible for the UB, and they should, for example, reconsider the dependencies added to their program or fix the system on which they are running the program.

Thanks for the responses! After reading a couple of topics (like Conditions for unsafe code to rely on correctness, Language vision regarding safety guarantees, and Idea to disallow multiple imports of a -sys crate), I reached the conclusion that it is well known that there is no formality around how multiple crates may interact with regard to safety. You can only necessarily tell whether a piece of Rust code is sound in the context of a final Rust program.

I like the idea of an "unsafe crate." At least, I like the idea that an unsafe crate has certain safety preconditions that a final Rust program should satisfy for its code to be guaranteed to be safe, and that these preconditions may or should be documented.

This makes me think: maybe each HAL crate should declare its own target tuple. So my HAL, for example, may be guarded behind something like:

#![cfg(not(target = "thumbv6m-rodrigodd-pic32cm6408l10"))]
compile_error!("wrong target!");

By owning the target tuple, I can claim that only my code should interact with this microcontroller's peripherals. Each HAL would have its own target, so they would be mutually exclusive.

But a target tuple is not the ideal solution for this. A HAL like stm32-hal is built on top of cortex-m-rt, and each one could not create its own unique target; they need to follow a subtype relationship.

A better solution might be for the final program to provide a devicetree (like in Zephyr) instead of a target tuple. That way, each crate could check at compile time whether the device node it is targeting is in the device tree and take ownership of it (so cortex-m-rt would take ownership of the CPU, and stm32-hal of the individual peripherals), and the build would fail if a node had more than one owner. But that would require a broader system to handle all of that.

Related to this, I found these GitHub issues that mention that custom rustc --cfgs could encode the current microcontroller or core:

But anyway, this part is outside the scope of the "unsafe guidelines" discussed in this topic.

1 Like

I don't think you can tell from Rust code that a static variable is located at a certain address. But you can tell that it is in a certain section, and then specify in a linker script that the section is at a certain address. I think that would allow you to detect double-uses and even overlaps if you create a proper struct containing all the memory-mapped peripheral registers, although this requires some coordination between all peripheral access crates and the linker script.

1 Like