Ferrous Systems wanted to build a GUI application in Rust on QNX, which meant interfacing with the QNX Screen Graphics Subsystem. That library has some tricky requirements about the order in which objects must be destroyed. Jonathan Pallant explains how they used Rust to make use-after-free bugs impossible when using this library.
Back in 2023, Ferrous Systems decided to develop a new demo for Embedded World – a first-of-a-kind graphical Rust application running on QNX. For the UI, we picked Slint - a portable, high-performance modern Rust-native UI framework. But Slint needs somewhere to put the pixels it creates, and on QNX that meant using the QNX Screen Graphics Subsystem (known as cscreen).
The cscreen library is provided in QNX SDP as a dynamic library with a C header file. It is possible to manually convert that C header file into the Rust equivalent, but instead we used bindgen to auto-generate the bindings, avoiding any errors in translation. We were then able to port over a couple of the existing cscreen demo applications to Rust using those bindings, to prove out the concept. These were tested at the time on a Renesas R-Car-V3H board running QNX Neutrino 7.1.0.
When using cscreen to put pixels on the screen, there are three basic steps:
- Make a Context, with:
-
screen_create_context - Use the Context to make a Window, with:
-
screen_create_window - Use the Window to make a Buffer, with:
-
screen_create_window_buffers - Put RGBA pixels in the Buffer
This diagram from the QNX Screen Graphics Subsystem documentation illustrates the full object hierarchy:
There is, however, a subtle issue here, which is covered in the documentation but not otherwise enforced by the C compiler – you must not use a Window after its parent Context has been destroyed, and you must not use a Buffer after its parent Window has been destroyed. When using raw C APIs from Rust, the functions are all marked as unsafe. In Rust-speak, this means you must read the documentation and promise me no rules are being broken. And when using those low-level unsafe APIs, it is totally possible to break your promise, destroy the objects in the wrong order, and cause Undefined Behaviour - just as in C .
What we wanted instead was a high-level Rust wrapper over those bindings – one which was safe, and hence impossible to use incorrectly. And, as it turns out, Rust had exactly the syntax needed to explain this problem to the compiler: lifetime annotations.
To wrap the raw pointers from the cscreen bindings, we simply placed them as a private field in a Rust structure:
pub struct Context(*mut screen_context_t);
pub struct Window(*mut screen_window_t);
These types were then given safe methods that would construct the appropriate cscreen objects, returning errors if the cscreen API failed for any reason.
The trick we then used was to tell the Rust compiler that the Window type is also borrowing from the Context it was created from. The normal Rust borrow-checker rules then enforce that a Context cannot move or be destroyed whilst a Window created from it still exists (although it will allow more Windows to be created with the same Context, because this is a so-called shared borrow, rather than a more restrictive exclusive borrow). To do this, the Window type was changed to:
pub struct Window<’ctx> {
raw_pointer: *mut screen_window_t,
_phantom: PhantomData<&’ctx Context> }
The <‘ctx> syntax is Rust’s way of saying, “I care about how long something lives for, and for the purposes of this type we’re going to label that lifetime ‘ctx.” The reference to the Context is tagged with that same ‘ctx label, and so now Rust knows when you use a Window, there is something else that must also exist. And all this is checked at compile time, so the use-after-free is impossible.
As an aside, the PhantomData type in Rust basically says, “Hey compiler, pretend this exists for borrow checking purposes, but don’t actually waste any bytes storing it.” We don’t need the reference to the Context – the Window pointer is sufficient – so by doing this, the Window type is back to being the exact same size as a pointer. This means there are no run-time costs for this check! It would therefore run at the same speed as the equivalent application written in C.
In practice, this means that this code works fine:
let ctx = Context::new(ContextType::APPLICATION | ContextType::INPUT_MANAGER)?;
let root_window = ctx.create_window()?;
let buffers = root_window.get_buffers()?;
But this code is a compile error – the use-after-free has been detected and caught before it could ever be a problem:
let ctx = Context::new(ContextType::APPLICATION | ContextType::INPUT_MANAGER)?;
let root_window = ctx.create_window()?;
drop(ctx); // destroy our context
let buffers = root_window.get_buffers()?; // we get a compile error here
This approach is not specific to QNX – it works with any kind of C library that returns pointers to managed objects, where you need to ensure that one object always outlives another. If you’d like some more background, there is also our 2023 blog post, which goes into a bit more detail about how the wrapper works and how to use Slint with it.
The good news here in 2026 is that Ferrous Systems has now made the Rust qnxscreen library open-source and freely available, and we’ve updated it to support QNX Everywhere. You can check out the library and the associated demo applications for yourself at https://github.com/ferrocene/qnxscreen.
If Rust is interesting to you, Ferrous Systems can offer training and support for any Rust use case, including its safety-qualified Rust toolchain Ferrocene, which was used to build and test the qnxscreen library.