Rust and Cpp interoperability
By Tomasz Kuczma
I’m a huge Rust enthusiast and you can read more about it in my previous article . Today, I’m gonna show you 2 examples of how Rust can be used together with some existing C and C++ codebases. Rust was designed with its FFI (Foreign Function Interface) in mind so it allows cheap (or even zero cost) interoperability with C and C++. For both solutions (plain C and C++), I’ll demonstrate that we can call C/C++ and Rust code back and forth (pass Rust callback to C++ code). I’ll focus on performance, flexibility, and limitations too.
Full working code can be found on my GitHub: https://github.com/Tuczi/rust-cpp-interopt
Rust and plain C
Rust and plain C can cooperate via a tool called bindgen
.
It allows for creating a binding between C and Rust world.
C code
Let’s start with defining some .h
:
#pragma once
typedef void (*callback_t)(unsigned long);
void helloWorld(int val);
void power(int base, unsigned int exp, callback_t callback);
and .c
files:
#include <stdio.h>
#include "example.h"
void helloWorld(int val) {
printf("Hello World from C. Val=%d\n", val);
}
// Calculate `result = base^exp` and call `callback(result)`
void power(int base, unsigned int exp, callback_t callback) {
unsigned long result = base;
if( exp == 0 ) {
result = 1;
}
for(int i=1; i<exp; i++) {
result*=base;
}
callback(result);
}
Rust build
Let’s say we decide to build c-lib
static library (I do it with CMake).
And then all we have to do in the Rust project is to add build.rs
with the following code:
// Include cpp-lib target dir
println!("cargo:rustc-link-search=native={}", manifest_dir.join("../cpp-lib/target/lib").as_path().display());
// Link static c-lib library
println!("cargo:rustc-link-lib=static=c-lib");
println!("cargo:rerun-if-changed=wrapper.h");
let bindings = bindgen::Builder::default()
.header("wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks))
.generate()
.expect("Unable to generate bindings");
// Write the bindings to the $OUT_DIR/bindings.rs file.
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
The above code defines:
- new directory for a linker to search for libraries (I just set CMake output directory for simplicity). Note that you can use any location (e.g. download compiled artifacts from the repository)!
- tells a linker to link our
c-lib
statically - calls
bindgen::Builder
with magicwrapper.h
to generate files intobindings.rs
file.
in wrapper.h
we need to specify (#include
) all headers that we want to generate bindings for.
Rust code
We can now use it from our main.rs
file:
// import generated bindings
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
// Note that we need to make this function `extern "C"`
pub extern "C" fn rust_callback(value: u64) {
println!("Welcome back in Rust! Value={}", value);
}
fn main() {
println!("Hello, world from Rust!");
unsafe {
// Call `helloWorld` function written in C
helloWorld(55);
// Call `power` function written in C with callback function written in Rust
power(2, 10, Some(rust_callback));
}
}
If you don’t understand the need for extern "C"
in the above code, read more about it in rustbook. In short, it tells rust compiler to use C ABI which is different than Rust ABI.
Performance
Let’s install cargo install cargo-asm
to inspect asm code easily.
Then just call cargo asm rust_and_c::main
and you should see:
rust_and_c::main:
sub rsp, 56
lea rax, [rip, +, .L__unnamed_2]
mov qword, ptr, [rsp, +, 8], rax
mov qword, ptr, [rsp, +, 16], 1
mov qword, ptr, [rsp, +, 24], 0
lea rax, [rip, +, .L__unnamed_3]
mov qword, ptr, [rsp, +, 40], rax
mov qword, ptr, [rsp, +, 48], 0
lea rdi, [rsp, +, 8]
call qword, ptr, [rip, +, _ZN3std2io5stdio6_print17hcbc8e5359e4501b6E@GOTPCREL]
mov edi, 55
call qword, ptr, [rip, +, helloWorld@GOTPCREL]
lea rdx, [rip, +, rust_callback]
mov edi, 2
mov esi, 10
add rsp, 56
jmp qword, ptr, [rip, +, power@GOTPCREL]
Which looks pretty good. My asm is a little rusty ;) but I can tell we just move some registers and call our c functions there. This is what we wanted. Super cheap interoperability with C.
Rust and C++
This is actually a bigger deal.
C has stable ABI and hence Rust can easily call C code without any expensive middle layer (e.g. just by moving registers for function call in C ABI style instead of Rust ABI).
But what about C++? AFAIK, C++ does not have stable ABI.
Fortunately in many cases, we can still cheaply communicate between Rust and C++.
This time we are gonna use autocxx
(Google’s solution) to automate bindings. Under the hood, autocxx
just calls bindgen
and/or cxx
. With autocxx
, it is just much faster to add new binding so it is really worth showing.
Limitations
Let’s start with some limitations.
I couldn’t make it work with just passing function pointer (like in plain C example) or mapping std::function<long>
.
Of course, there is a workaround for that: define a new C++ class with a pure virtual method (e.g. onResult(long)
).
Note that this is a strongly typed listener/observer pattern.
C++ code
Again, write .hpp
and .cpp
files:
#pragma once
namespace example {
// Define Callback as abstract class (listener pattern)
class Callback {
public:
virtual void onResult(long) const = 0;
virtual ~Callback() {}
};
class Power {
// Just for demonstration. It does not make much sense to store `base` as a field
int base;
public:
Power(int base_) : base(base_) {}
void helloWorld(int val) const;
void power(unsigned int exp, const Callback& callback) const;
};
struct Pod {
int i;
int j;
};
}
#include <iostream>
#include "example2.hpp"
namespace example {
void Power::helloWorld(int val) const {
std::cout << "Hello World from C++. Val=" << val << std::endl;
}
// Calculate `result = base^exp` and call `callback.onResult(result)`
void Power::power(unsigned int exp, const Callback& callback) const {
long result = base;
if( exp == 0 ) {
result = 1;
}
for(int i=1; i<exp; i++) {
result*=base;
}
callback.onResult(result);
}
}
and compile it to static library cpp-lib
.
Rust build
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
// Include cpp-lib target dir
println!("cargo:rustc-link-search=native={}", manifest_dir.join("../cpp-lib/target/lib").as_path().display());
// Link static cpp-lib library
println!("cargo:rustc-link-lib=static=cpp-lib");
// This assumes all your C++ bindings are in main.rs
println!("cargo:rerun-if-changed=src/main.rs");
// Define path to resolve #include relative position
let include_path = manifest_dir.join("../cpp-lib/src");
let mut b = autocxx_build::Builder::new("src/main.rs", &[&include_path]).build().unwrap();
b.flag_if_supported("-std=c++17")
.compile("auto-cpp-lib");
build.rs
is similar to bindgen
and plain C examples.
We link static lib in the same way, just call autocxx_build::Builder
.
Note that we don’t create wrapper.h
but point to the directory where header files are stored.
Rust code
Surprisingly, we had to write more code to provide an implementation for C++ Callback
class.
Callback
is the most verbose but in fact, we generate an implementation for abstract class here so it is understandable.
Power
class is quite straightforward.
use autocxx::prelude::*;
use autocxx::subclass::*;
autocxx::include_cpp! {
#include "example2.hpp" // Relative to whatever we specify in `build.rs`
generate!("example::Power")
safety!(unsafe_ffi)
subclass!("example::Callback", RustCallback)
}
#[is_subclass(superclass("example::Callback"))]
#[derive(Default)]
pub struct RustCallback;
impl ffi::example::Callback_methods for RustCallback {
fn onResult(&self, value: autocxx::c_long) {
let value: i64 = value.into();
println!("Welcome back in Rust! Value={}", value);
}
}
fn main() {
println!("Hello, world from Rust!");
let mut pow = ffi::example::Power::new(2.into()).within_unique_ptr();
pow.pin_mut().helloWorld(6.into());
let rust_callback = RustCallback::default_rust_owned();
pow.pin_mut().power(10.into(), rust_callback.as_ref().borrow().as_ref());
}
A few things that I like about autocxx
is that:
- it allows specifying needed header files and C++ classes in Rust source code directly (we do not need to edit
wrapper.h
) - it really automates a lot of stuff (using macros)
- we don’t have an unsafe block and the code is really safe e.g. we need to convert rust types (like
i32
) toautocxx
types, memory is guarded (e.g.within_unique_ptr()
) - there is more possibility about memory ownership - read more in
autocxx
manual
Performance
Worth noticing is that cargo asm rust_and_cpp::main
is now 360 lines long while cargo asm rust_and_c::main
was only 18.
This is because autocxx
generated much more safety layers and all of them require some calls e.g. alloc/dealloc like:
jne .LBB4_29
mov qword, ptr, [rbx, +, 16], -1
mov edi, 8
mov esi, 8
call qword, ptr, [rip, +, __rust_alloc@GOTPCREL]
test rax, rax
je .LBB4_32
mov rbp, rax
mov qword, ptr, [rax], r15
cmp dword, ptr, [rbx, +, 24], 1
jne .LBB4_35
mov r15, qword, ptr, [rbx, +, 32]
mov rax, qword, ptr, [r15]
mov qword, ptr, [rsp, +, 8], rax
lea rdi, [rsp, +, 8]
call qword, ptr, [rip, +, cxxbridge1$unique_ptr$RustCallbackCpp$drop@GOTPCREL]
mov esi, 8
mov edx, 8
mov rdi, r15
call qword, ptr, [rip, +, __rust_dealloc@GOTPCREL]
mov rcx, qword, ptr, [rbx, +, 16]
add rcx, 1
jmp .LBB4_39
not all of the generated code will be always called (e.g. due to conditional jumps for errors handling) and fortunately Power::power
call cost looks similar to the plain C version (at least for me):
.LBB4_58:
call qword, ptr, [rip, +, cxxbridge1$RustCallbackCpp$As_Callback@GOTPCREL]
mov dword, ptr, [rsp, +, 8], ebp
lea rsi, [rsp, +, 8]
mov rdi, r14
mov rdx, rax
call qword, ptr, [rip, +, example$cxxbridge1$Power$power@GOTPCREL]
add qword, ptr, [rbx, +, 16], -1
add qword, ptr, [rbx], -1
jne .LBB4_66
Final thoughts
It works! We indeed can call C and C++ code (functions, classes, and methods) back and forth between Rust and C++.
Generated assemblers look cheap enough to be used in a performance-critical code.
There are some tools like autocxx
that automate the process which can be useful if your C/C++ codebase is big. And other tools (bindgen
) allow for more control.
Software engineer with a passion. Interested in computer networks and large-scale distributed computing. He loves to optimize and simplify software on various levels of abstraction starting from memory ordering through non-blocking algorithms up to system design and end-user experience. Geek. Linux user.