Correct wrapper for buffer filled by other process

Hi everyone,
I have a question regarding what wrappers I have to use so that the following pseudo code becomes safe (no UB):

fn(buffer: &mut[u8]){
  assert!(buffer_is_long_enough(buffer));
  write_buffer_address_into_mmio_register(buffer);
  start_hardware_task_writing_to_buffer();
  busy_wait_for_task_to_finish();
}

I expect that the compiler does not know that the hardware might change the values in buffer after writing its address into an mmio register and starting the task, possibly allowing the compiler to optimize out reads from the buffer since "it is never written to".
What would be the correct way to handle this? Do I need implement a wrapper which implements all reads from the buffer as 'volatile_read'? Is there a better way to achieve this?

UnsafeCell is a magic type that tells the compiler the memory may change in mysterious ways. I presume this could be expressed as a buffer of &[UnsafeCell<u8>] or &UnsafeCell<[u8]>, but I'm not sure what are the rules for going from/to a &mut [u8] slice with it.

1 Like

I’m not an unsafe expert, but I think you’re looking for something like this:

fn(buffer: &mut[u8]){
  assert!(buffer_is_long_enough(buffer));
  let buffer = std::cell::Cell::from_mut();  // Tell the compiler the buffer might change

  write_buffer_address_into_mmio_register(buffer.as_ptr());
  start_hardware_task_writing_to_buffer();
  busy_wait_for_task_to_finish();
}

But it feels like UnsafeCell shouldn’t be required here, as you have a mutable reference already: the compiler will assume that no other code changes the buffer so that this code is free to. The only real question is how to tell the compiler that you’re giving that right to modify to the external hardware until the end of scope.

1 Like

volatile?

I don't know how to use it in Rust. In C / C++ is fairly simple. You just adorn the data.

Maybe it's useful for what you're trying to do?

If I understand the Stacked Borrows model correctly, your original code contains no UB as long as:

  • The external system doesn’t access the buffer contents after busy_wait_for_task_to_finish returns.
  • Neither of start_hardware_task_writing_to_buffer and busy_wait_for_task_to_finish inspects the buffer before the write is finished.

In order to be fully sound, however, you’ll need to make the APIs of these theoretical functions impossible to misuse without unsafe. I see two ways to do this:

  1. Mark write_buffer_address_into_mmio_register as an unsafe fn, and document the safety requirement that the buffer is untouched until the hardware is done with it
  2. Return a lifetime-annotated drop guard that prevents the illegal accesses:
struct BufferGuard<‘a>(PhantomData<&’a mut [u8]>);

fn write_buffer_address_into_mmio_register<‘a>(buffer:&’a mut [u8])->BufferGuard<‘a> { … }

impl Drop for BufferGuard<‘_> {
    fn drop(&mut self) {
        busy_wait_for_task_to_finish();
    }
}

(Ideally, there would also be runtime protection against someone calling write_buffer_address_into_mmio_register while an MMIO operation is already in progress.)

2 Likes

If you pass the buffer's raw pointer to the mmio register with a volatile write, then the compiler understands that the raw pointer has escaped and that it might be written to by unknown sources. The only danger is that you reassert uniqueness by touching the mutable reference, but as long as you don't touch it until the writes have finished, it is fine.

3 Likes

Thanks to everyone for the helpful answers. I will try to go through all of them.

That would also be my problem with the solution and I definitely do not want to expose something more complicated than &[u8] to the user.

This makes sense to me. Also from the documentation UnsafeCell<T> opts-out of the immutability guarantee for &T: a shared reference &UnsafeCell<T> may point to data that is being mutated”. doesn't sound like what is actually needed here.

Exactly :slight_smile:

I think I would need to expose the volatile as part of the buffer type to the user, if possible I would like to prevent this.

I'm not convinced about this. If the compiler decided that no one writes into the buffer and removes the entire buffer (but still writes some address into the mmio register, the hardware would overwrite memory it is not supposed to.

Since you took your time I still want to answer your points:

  • The external system shouldn't write to the pointer without triggering another task, the only methods triggering those tasks always overwrite the pointer to a valid buffer
  • Both start_hardware_task_writing_to_buffer and busy_wait_for_task_to_finish do not inspect the buffer. Actually both are just a volatile write to a specific register.

I guess this is just a problem of me oversimplifying the code a bit. write_buffer_address_into_mmio_register only gets a raw pointer to the buffer and is marked unsafe, so it shouldn't need a drop guard. The rest are volatile writes. I would argue the entire function itself acts as the drop guard of the buffer as it only returns when the write has finished.

Again, probably caused by oversimplification. External access to the register would require unsafe code so this should be fine. Small side note: I don`t think there is something like a "MMIO operation". At least in my context "MMIO" just stands for "memory mapped input/output" which is just a specific way to implement communication between cpu and peripherals without requiring special cpu instructions. Maybe you meant a "DMA Transfer"?
I try to implement (most) of the states of the hardware in the typesystem. I still haven't figured out a few tricky ones in a safe manner though (looking at you "power saving by turning off ram sections").

Do you have any reference to this? This would obviously be perfect for this use case :slight_smile: I could not find any documentation on this in the write_volatile section. I would have expected it there, since its a property of the volatile write which signals the escape of the whole buffer, if I understood your sentence right.

1 Like

No, I don't have a reference for it. However, volatile operations are generally treated equivalently to calls to unknown external C functions, and they have the same behavior.

1 Like

Thank you for the answer, but I'm not yet entirely convinced. Consider the following scenarios: function A takes a buffer &mut[u8], creates a raw pointer from it, and passes it to function B which then performs the volatile write.
How would it be obvious to the compiler that B performs a volatile write with the buffer address (without in lining B)?

Or is it a property of creating the raw pointer which actually makes the compiler assume it cannot reason about the buffer contents anymore?

It’s letting a raw pointer escape the scope of analysis. In your first scenario, assuming functions a and b are analyzed separately, a must assume that b might change the buffer, and b knows it might change because of the volatile write. If b gets inlined, then a can see the volatile write directly.

1 Like