Part 2: Baby's first FFI
8 minutes read •
We’ve successfully skimmed the high-level overview of libva in the previous part, so with our newly acquired thorough understanding (ha!), let’s think about how we’re even going to bind to this very obviously C API from Rust.
Every language is different. C is extremely low level, C# has a garbage collector, Rust has its memory ownership model with the borrow checker, and these features all result in the languages’ respective compilers emitting completely different binaries that would violently explode if interleaved. But programmers can’t standardise on a single language, so an immense amount of effort has gone into developing standards that define ways for one language to call functions written in another language.
You could say that REST is a way to call a function that is written in another language. Same for gRPC. Web browsers call into code that isn’t just written in a different language, but is running on a completely separate computer too. But the highest performance language-independent interop standard is…
…that most languages can pretend to be C.
Pretending to be C
For Rust, this feature is called Foreign Function Interface (FFI). You can define a boundary of functions that travel from the cosy embrace of the borrow checker to the wild west of external code.
You can either have a statically linked bundle of external functions that get assimilated into our executable, or link to functions stored in shared libraries at runtime.
We’ll need to link against the drivers provided by the users’ machine, which will be stored in a shared library. Dynamic linking can be achieved in two ways: Before our program executes using ld, or manually linking to the shared object through our own code.
Both have their positives and negatives:
ld-based dynamic linking:- Very easy to set up. Just add some compiler flags and define some external functions. The rest will be handled automagically.
- program-controlled linking:
- Much more flexible than
ld, as we can still execute in case of a missing dynamic library.
- Much more flexible than
In this case, since we’re writing a library that others might depend on as an optional optimisation for their processing, it’s better to take matters into our own hands. This way we can throw an error to the library’s user if VA-API isn’t available on the system, and they can fall back to whatever other implementation they have in store.
Rust has a library for this purpose that makes the process trivial even across platforms: libloading. It’s what Ferrovanadium uses as well, so we’re staying on the beaten path.
Speaking of Ferrovanadium, I’m immediately going to lift an idea from that codebase and write a macro to simplify loading the VA-API functions.
Thinking in macro
Let’s first look at FeV’s implementation
};
}
That’s a lot of macro code, but there’s no reason to worry, let’s break it down slowly and carefully. We’re definitely going to write and interpret a few macros during the course of developing this as-of-yet-unnamed library, so the following is a very simple crash course mostly paraphrased from the excellent Little Book of Rust Macros: Rust supports two types of macros: declarative and procedural ones. We’ll likely only need declarative ones, so procedural macros are left as an exercise for the reader. To define a new declarative macro, we start by calling This creates a completely blank macro that can be invoked as Rust doesn’t support function overloading in normal code, but declarative macros live and die by overloads. A macro can have multiple sets of parameters that it can accept, with each set being able to generate completely independent code. Each of these overloads has to be listed between the braces following the new macro’s name, with a syntax not too dissimilar to Parameters have a type, which limits what sort of value they can match. Instead of having integers and strings and the like, we have Rust syntactic elements, like blocks, identifiers, expressions, lifetimes… But we aren’t limited to just specifying explicit parameters, we can make our parameter list read small parts of a longer predefined sequence, like regex capture groups. We can also capture a variable multiple times and then iterate over each capture There are a lot more supported parameter types, but this should be enough information to understand what we’re about to dig through. Read moreBasic declarative macros in Rust
In case you (like me for the longest time) were terrified of Rust’s macro syntax, I can assure you that there’s nothing much to be afraid of, especially if you keep things simple. macro_rules!:
new_macro_name!. A macro like this is going to replace itself and its list of parameters with some code that is defined within it, kind of like how C does it, except much smarter than a simple find-replace.match’s: (parameter list) => {substituted code};
new_macro_name!;
We define a macro called dylib that has a single rule. This rule matches one line that defines a struct (pub struct $strukt:ident;), then a list of function declarations in the shape fn $func:ident( $( $name:ident : $t:ty ),* $(,)? ) $( -> $ret:ty )?;.
The structure definition is pretty straightforward. It captures an identifier and stores it as $strukt. The next three lines are a bit more involved:
$+ // - Capture the function name as $func
//^ Capture at least one instance of the contained rule
The rule is then expanded into the following code:
// Create a type for each declared function that is an unsafe extern "C" function pointer
$+
// Build a structure called $strukt that stores a function pointer for each function we've declared
$strukt {
$(
$func: $func,
)+
}
// ] to suppress some potentially errant lints
Simple enough, though I see a few points where this API could be improved:
- Forcing the structure name to match the name of the shared library is a bit limiting
- The way
libnameis calculated inload()only works on UNIX-like platforms - If loading the library failed once, that error is stuck in the OnceLock until the program is completely restarted
[!WARNING] I’ve also made a mistake here that will come and bite me in a coming post.
Turns out that since if you drop the
libloading::Library, the dynamic library gets unloaded and all function pointers are then pointing into invalid memory. Fun little SEGFAULT opportunity.The solution is to store the
Libraryinside the$strukt.
Decouple the structure name from the library name
Only requires a very simple edit. I think it would be the most ergonomic if the “fake” struct definition that the macro expands into the real deal had an additional field:
//...
=> Of course, we don’t need to write any of that const LIB_NAME stuff before the parameter, but I like that it keeps the macro invocation looking like plain Rust code. And forcing the end user to write LIB_NAME means that it’s very hard for them to misunderstand what the parameter is for.
We’re about to completely rewrite how the library name is selected, so we’ll get back to where this needs to be edited in once that’s done.
VA-API isn’t exclusive to Linux
Currently, the filename is hardcoded to end in .so in load, which limits us to only those platforms that name their dynamic libraries that way. Thankfully, we don’t necessarily have to be familiar with how platforms differ in their naming schemes, because there’s libloading::library_filename.
Adding this to our code is as simple as replacing the line defining libname in load with
let libname = library_filename?;
Much nicer.
Better load failure handling
This one could be fixed by using OnceLock::get_or_try_init. It’s a nightly-only experimental API, and I’d prefer to stay on stable Rust, so let’s just make a note to fix this once get_or_try_init is stabilised.
What to load?
VA-API’s dynamic libraries are split into multiple blocks. There’s the core file, libva, that contains system-agnostic function calls operating on a display, then there are smaller, platform-specific libraries that implement features like converting from a window handle to a VADisplay or presenting a VASurface to a window.
We also have to think about which subset of a dynamic library we’d like to bind with, since we’ll have to manually specify the signature of each function individually. For now let’s not worry about compatibility with earlier versions of the library and fallbacks, though later on, it might be interesting to implement polyfills of sorts that implement a common subset of capabilities which achieve their functions differently depending on what calls are available.
So in the next part we are going to map out what we require for an absolutely minimal demo and get started on the actual FFI layer.