31 Ffirust How to Bridge the Communication Gap With Your Language

31 FFI: How Rust Bridges Communication with Your Language #

Hello, I’m Chen Tian.

FFI (Foreign Function Interface) is a mysterious existence for most developers. It’s rarely encountered in everyday coding, let alone writing FFI code.

In fact, a large part of the ecosystem you are using in your language is built with FFI. For example, you might be happily performing numerical calculations with NumPy in Python, unaware that NumPy’s underlying details are constructed in C; or when using Rust, you may be happily utilizing OpenSSL to protect your HTTP service, but underneath, it’s C handling all protocol algorithms.

In the software world we live in, almost every programming language is dealing with ecosystems crafted in C, so a language that manages its relationship well with the C ABI (Application Binary Interface) can almost communicate with any other language.

Of course, for most users of other languages, it doesn’t matter if they don’t know how to communicate with C, as the open-source world always has “predecessors” who have paved the way for us; but for Rust users, in addition to progressing on the path laid by others, occasionally we need to pave the way for ourselves and others. That’s because Rust is a system-level language. As the saying goes, with great power comes great responsibility.

That’s why, while most languages are still living off the C ecosystem, Rust is generously feeding back into it as much as possible. For example, both cloudflare and Baidu’s mesalink have introduced the pure Rust implementations of HTTP/3 quiche and TLS Rustls into the C/C++ ecosystem, making it more beautiful and secure.

So now, in addition to using C/C++ for the base, more and more libraries are initially implemented in Rust and then build out corresponding Python (pyo3), JavaScript (wasm), Node.js (neon), Swift (uniffi), Kotlin (uniffi), and other implementations.

One of the benefits of learning Rust is that as you learn, you’ll find that not only can you create a bunch of wheels for your own use, but you can also create a bunch of wheels for other languages to use. The Rust ecosystem also supports and encourages you to create wheels for other languages. Thus, the Java ideal of “write once, run anywhere” becomes “write once, call everywhere” with Rust.

Alright, after talking so much, are you very curious about the capabilities of Rust FFI? In fact, we had a glimpse of the tip of the iceberg in [Lecture 6]get hands dirty, where we implemented the Python and Node.js bindings for that SQL query tool. Today, let’s learn more broadly about how Rust can bridge communication with your language.

Rust Calling C Libraries #

First, let’s look at the interoperation between Rust and C/C++. Generally speaking, when we see a C/C++ library that we want to use in Rust, we can start by writing some simple shim code to expose the desired interfaces, then use bindgen to generate the corresponding Rust FFI code.

bindgen will generate low-level Rust APIs. By convention in Rust, the crate using bindgen is named xxx-sys, containing a lot of unsafe code due to FFI. Then, on top of that, the xxx crate is created, encapsulating these low-level codes with higher-level code to provide other Rust developers with a more Rust-like experience.

For example, providing Rust’s own struct/enum/trait interfaces around the low-level data structures and functions. -

Let’s take the example of using bindgen to encapsulate bz2 for compression/decompression, to see how Rust calls a C library (the following code should be tested under OS X/Linux; Windows users can refer to bzip2-sys).

First, create a project with cargo new bzlib-sys --lib, then add to Cargo.toml:

[dependencies]
anyhow = "1"

[build-dependencies]
bindgen = "0.59"

Since bindgen is needed at build time, we create a build.rs at the root directory to run it during compilation:

fn main() {
    // Tell rustc to link bzip2
    println!("cargo:rustc-link-lib=bz2");

    // Tell cargo to rerun when wrapper.h changes
    println!("cargo:rerun-if-changed=wrapper.h");

    // Configure bindgen and generate bindings
    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks))
        .generate()
        .expect("Unable to generate bindings");

    // Generate Rust code
    bindings
        .write_to_file("src/bindings.rs")
        .expect("Failed to write bindings");
}

In build.rs, we included a wrapper.h. Let’s create it in the root directory and reference bzlib.h:

#include <bzlib.h>

Now, running cargo build will generate src/bindings.rs with about two thousand lines of Rust code generated by bindgen based on the constants, data structures, and functions exposed in bzlib.h. If interested, you can take a look.

With the generated code, we refer to it in src/lib.rs:

// The generated bindings code is based on the C/C++ code and contains some conventions not adhered to by Rust, so we don't let the compiler warn about them
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(deref_nullptr)]

use anyhow::{anyhow, Result};
use std::mem;

mod bindings;

pub use bindings::*;

Then we can write two high-level interfaces, compress and decompress. Normally, we should create another crate to write these interfaces, as this is Rust’s convention for handling FFI, which helps separate high-level interfaces from low-level ones. Here, we’ll write them directly in src/lib.rs:

// High-level API, handling compression, usually should be in another crate
pub fn compress(input: &[u8]) -> Result<Vec<u8>> {
    let output = vec![0u8; input.len()];
    unsafe {
        let mut stream: bz_stream = mem::zeroed();
        let result = BZ2_bzCompressInit(&mut stream as *mut _, 1, 0, 0);
        if result != BZ_OK as _ {
            return Err(anyhow!("Failed to initialize"));
        }

        // Pass input/output for compression
        stream.next_in = input.as_ptr() as *mut _;
        stream.avail_in = input.len() as _;
        stream.next_out = output.as_ptr() as *mut _;
        stream.avail_out = output.len() as _;
        let result = BZ2_bzCompress(&mut stream as *mut _, BZ_FINISH as _);
        if result != BZ_STREAM_END as _ {
            return Err(anyhow!("Failed to compress"));
        }

        // End compression
        let result = BZ2_bzCompressEnd(&mut stream as *mut _);
        if result != BZ_OK as _ {
            return Err(anyhow!("Failed to end compression"));
        }
    }

    Ok(output)
}

// High-level API, handling decompression, usually should be in another crate
pub fn decompress(input: &[u8]) -> Result<Vec<u8>> {
    let output = vec![0u8; input.len()];
    unsafe {
        let mut stream: bz_stream = mem::zeroed();
        let result = BZ2_bzDecompressInit(&mut stream as *mut _, 0, 0);
        if result != BZ_OK as _ {
            return Err(anyhow!("Failed to initialize"));
        }

        // Pass input/output for decompression
        stream.next_in = input.as_ptr() as *mut _;
        stream.avail_in = input.len() as _;
        stream.next_out = output.as_ptr() as *mut _;
        stream.avail_out = output.len() as _;
        let result = BZ2_bzDecompress(&mut stream as *mut _);
        if result != BZ_STREAM_END as _ {
            return Err(anyhow!("Failed to compress"));
        }

        // End decompression
        let result = BZ2_bzDecompressEnd(&mut stream as *mut _);
        if result != BZ_OK as _ {
            return Err(anyhow!("Failed to end compression"));
        }
    }

    Ok(output)
}

Finally, don’t forget our good habit of writing tests to ensure normal work:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compression_decompression_should_work() {
        let input = include_str!("bindings.rs").as_bytes();
        let compressed = compress(input).unwrap();
        let decompressed = decompress(&compressed).unwrap();

        assert_eq!(input, &decompressed);
    }
}

Running cargo test, the test passes successfully. You can see that there are also quite a few tests in bindings.rs, and cargo test runs a total of 16 tests.

How is it, we wrote about 100 lines of code and integrated the C library bz2 with Rust. Isn’t it very convenient? If you have dealt with similar C bindings in other languages, you’ll find that FFI development with Rust is just too convenient and thoughtful.

If you think this example is too simple, you can look at the implementation of Rust RocksDB, which is very suitable for you to further understand how complex libraries that require additional integration of C source code are integrated into Rust.

Key Points to Handle When Working with FFI #

Tools like bindgen do a lot of dirty work for us. Although most of the time we don’t need to care too much about the generated FFI code, we still need to pay attention to three key issues when building higher-level APIs with them.

  • How to handle differences in data structures?

For example, C strings end with NULL, while Rust Strings are an entirely different structure. We need to understand the difference in how the data structures are organized in memory in order to properly handle them. Rust offers std::ffi to deal with such problems, such as CStr and CString to handle strings.

  • Who will free the memory?

Unless special circumstances apply, whoever allocates the memory should be responsible for freeing it. Rust’s memory allocator may be different from other languages, so releasing memory, allocated by Rust, in the context of C may lead to undefined behavior.

  • How to handle errors?

In the code above, we also saw that C reports errors in execution by returning an error code. We used the anyhow! macro to conveniently generate errors, which is a bad example. In formal code, you should use thiserror or similar mechanism to define all possible error situations corresponding to error codes and then generate errors accordingly.

Rust Calling Other Languages #

So far, we have been discussing how Rust calls C/C++. What about Rust calling other languages?

As mentioned earlier, since the C ABI is deeply ingrained, interfaces between two languages often use the C ABI. From this perspective, if we need Rust to call Golang code (no matter how unreasonable it seems), then first compile the Golang code using cgo into a C-compatible library; after that, Rust can use bindgen to generate the corresponding APIs as it would with C/C++.

As for Rust calling other languages, it’s similar; except for languages like JavaScript/Python, it’s more convenient and straightforward to compile their interpreters into C libraries or WASM and then call their interpreters in Rust than to try to compile their code into C libraries. After all, JavaScript/Python are script languages.

Compiling Rust Code into C Libraries #

After discussing how Rust uses other languages, let’s look at how to compile Rust code into C ABI compatible libraries, so other languages can use Rust just like C.

The logic here is similar to that of Rust calling C, but with roles reversed: -

To provide Rust code and data structures for use in C, we first need to create the respective Rust shim layer to encapsulate the original, normal Rust implementation for easy calling from C.

Rust shims mainly do four things:

  • Provide separate functions for Rust methods, trait methods, and other public interfaces. Note that C does not support generics, so you need to provide specific shim functions for a certain type of generic functions.
  • All independent functions to be exposed to C must be declared as #[no_mangle] to avoid function name mangling.

If you don’t use #[no_mangle], the Rust compiler will generate very complicated names, which are difficult to obtain correctly in C. At the same time, the interfaces of these functions should use C-compatible data structures.

  • Data structures need to be transformed into C-compatible structures.

If these are your own-defined structs, use #[repr©]. For functions to be exposed to C, you cannot use Rust structures such as String/Vec/Result that C cannot operate correctly.

  • Use catch_unwind to wrap all codes that could possibly invoke panic!.

Remember, other languages calling Rust and encountering Rust’s panic!() can lead to undefined behavior. Therefore, catch_unwind at the FFI boundary to prevent Rust’s stack unwinding from running out.

Here’s an example:

// Use no_mangle to prevent function name mangling, so other languages can call this function via C ABI
#[no_mangle]
pub extern "C" fn hello_world() -> *const c_char {
    // C Strings end with "\\0", you can remove the "\\0" to see what will happen
    "hello world!\\0".as_ptr() as *const c_char
}

This code uses #[no_mangle], and returns a string terminated with “\0”. Since this string lives in the RODATA segment with ‘static’ lifetime, returning its pointer as a raw pointer is not a problem. If you want to compile this snippet into a usable C library, set crate-type = [“cdylib”] in Cargo.toml.

That example was too simple, so let’s look at another advanced example. In this example, a string pointer is passed from the C side, formatted with format!(), and a string pointer is returned:

#[no_mangle]
pub extern "C" fn hello_bad(name: *const c_char) -> *const c_char {
    let s = unsafe { CStr::from_ptr(name).to_str().unwrap() };

    format!("hello {}!\\0", s).as_ptr() as *const c_char
}

Can you find the problem with this code? It makes almost all the mistakes a beginner could make.

First, can the passed-in name be a NULL pointer? Is it a valid address? Although we cannot detect whether it is a valid address, at least we can check for NULL.

Next, unwrap() can cause panic!(), and if there is an error converting CStr into &str, this panic!() will lead to undefined behavior. We could use catch_unwind(), but a better approach is to handle errors.

Lastly, format!("hello {}!\\0", s) generates a string structure, as_ptr() gets its starting position on the heap, and we ensure that the heap memory ends with NULL, so it seems fine. However, when this function finishes execution, as the string s goes out of scope, its heap memory will be dropped along with it. Therefore, this function returns a dangling pointer, and it will crash on the C side when called.

The correct way should be:

#[no_mangle]
pub extern "C" fn hello(name: *const c_char) -> *const c_char {
    if name.is_null() {
        return ptr::null();
    }

    if let Ok(s) = unsafe { CStr::from_ptr(name).to_str() } {
        let result = format!("hello {}!", s);
        // Can use unwrap since result does not contain \\0
        let s = CString::new(result).unwrap();

        s.into_raw()
        // Equivalent to:
        // let p = s.as_ptr();
        // std::mem::forget(s);
        // p
    } else {
        ptr::null()
    }
}

In this code, we check for NULL pointer, perform error handling, and use into_raw() to let the Rust side relinquish control of the memory.

Remember the three key points mentioned before, who allocates the memory is responsible for freeing it, so we still need to provide another function for the C side to use to safely free the Rust allocated string:

#[no_mangle]
pub extern "C" fn free_str(s: *mut c_char) {
    if !s.is_null() {
        unsafe { CString::from_raw(s) };
    }
}

The C code must call this interface to safely release the CString created by Rust. If not called, there will be memory leaks; if using C’s free(), it can cause undefined errors.

One might wonder, doesn’t CString::from_raw(s) only recover a CString from a raw pointer without actually freeing it?

Get used to this way of “freeing memory” because it actually leverages Rust’s ownership rules: when the owner leaves the scope, the owned memory will be released. Creating an owned object here is just to make the memory automatically release when the function ends. If you look at the standard library or third-party libraries, you often see similar code for “freeing memory”.

The above hello code is not safe enough. Although it appears not to use any code that may cause direct or indirect panic!, you cannot guarantee the later complexity of the code and the implicit invocation of panic!(). For example, if we add some logic later and use copy_from_slice(), which internally calls panic!(), it could be problematic. So, the best way is to encapsulate the main logic in catch_unwind:

#[no_mangle]
pub extern "C" fn hello(name: *const c_char) -> *const c_char {
    if name.is_null() {
        return ptr::null();
    }

    let result = catch