A tutorial for accessing C/C++ functions within a shared library (.dll/.so/.dylib) from Rust
[19-09-2023] Updates:
Add instructions and code for an alternative way to build and run using
build.rs
and cmake-rsFix the reason why not to link to C++ functions directly
Include
<cstring>
in the code and usefree
instead ofdelete
asstrdup
generally usesmalloc
to allocateUpdate code and add information to prevent memory leakage when returning a heap-allocated object through the FFI
Removed libc dependency in favour of std::ffi in the Rust wrapper
In the C/C++ example, used cmake instead of msbuild as it is more cross-platform.
Background
At my workplace, we are developing algorithms for a cloud-based analysis app. The algorithms are written in C++ and exported as a shared library to be used by the middleware in Java and the front end in JavaScript. Everything was working except for one problem, the service we had deployed faced the issue of memory pile-up. And since all the different components were tightly coupled it became very difficult to debug the issue.
So, after some discussion, we decided to decouple the different components by running the C++ algorithms as a REST web service.
We searched for C++ libraries to develop the REST endpoint but found that either they were not maintained or were extremely hard to set up.
Having some prior experience with Rust (especially REST web service development using Actix), I suggested that we could develop a wrapper in Rust and write the REST web API using some framework like Actix. I knew it was possible to wrap C/C++ code in Rust but not how. I searched for and found bits and pieces here and there but not a complete tutorial. This article is my attempt to compile all the information that I found and present it as a tutorial. I assume you already have some familiarity with C/C++, CMake, and Rust.
Introduction
Let's start with some of the basics first.
Consider, we are developing a shared library named
c_cpp
. The shared library file will be calledc_ccp.dll
in Windows,libc_cpp.so
in Linux, andlibc_cpp.dylib
in macOS.It is not a good idea to link to C++ functions directly, as C++ doesn't have a stable ABI. Hence, If you are writing a C++ function you need to put it in your header(s) within the
extern "C" { }
block. This also tells the C++ compiler not to mangle its name.If you are writing a C function you don't have to put it inside the
extern "C" { }
block, as the Rust compiler knows how to call it.
The C/C++ example
I am taking an example of a C++ function called introduce
. Let's see what its function declaration in the header (c_cpp\include\lib.h
) looks like.
#if defined(WIN32) || defined(_WIN32)
#define EXPORT __declspec(dllexport)
#else
#define EXPORT
#endif
extern "C" {
EXPORT const char * introduce(const char * name, int age);
}
The introduce
function takes a C-style string (name) and an int (age) as parameters, constructs another string from the parameters, and returns it as a C-style string. In this tutorial, I'll be using Windows as the development platform. You should be able to easily adapt it to other platforms.
Following is the function definition in the source file (c_cpp\src\lib.cpp
).
#include <sstream>
#include <cstring>
#include "lib.h"
std::stringstream ss;
const char * introduce(const char * name, int age) {
ss.clear();
ss << "Hi, I am " << name << ". My age is " << age << ".";
return strdup(ss.str().c_str());
}
To the above code files, we will also add the deallocate_string
function. This will aid in preventing memory leaks that can occur if the heap-allocated string is not deallocated. We are going to call this function from the Rust wrapper.
c_cpp\include\lib.h
#if defined(WIN32) || defined(_WIN32)
#define EXPORT __declspec(dllexport)
#else
#define EXPORT
#endif
extern "C" {
EXPORT const char * introduce(const char * name, int age);
EXPORT void deallocate_string(const char * s);
}
c_cpp\src\lib.cpp
#include <sstream>
#include <cstdlib>
#include <cstring>
#include "lib.h"
std::stringstream ss;
const char * introduce(const char * name, int age) {
ss.clear();
ss << "Hi, I am " << name << ". My age is " << age << ".";
return strdup(ss.str().c_str());
}
void deallocate_string(const char * s) {
free((void*)s);
}
I'm also going to create the main source file (c_cpp\src\main.cpp
) to build an executable and quickly test my function.
#include <iostream>
#include "lib.h"
int main() {
const char * s = introduce("Srikanth", 31);
std::cout << s << std::endl;
deallocate_string(s);
return 0;
}
I will use CMake as the build tool. So, the following is my c_cpp\CMakeLists.txt
file.
cmake_minimum_required(VERSION 3.24)
project("c_cpp")
include_directories(./include)
add_library(${PROJECT_NAME} SHARED ./src/lib.cpp)
add_executable(${PROJECT_NAME}_main ./src/main.cpp ./src/lib.cpp)
install(TARGETS ${PROJECT_NAME} DESTINATION .)
I'll build the shared library and the test executable using the following commands:
cd c_cpp
cmake -S . -B .\build\
cmake --build .\build\
Running the test executable should be straightforward:
C:\rust_ffi_c_cpp\c_cpp> .\build\Debug\c_cpp_main.exe
Hi, I am Srikanth. My age is 31.
The Rust wrapper
For developing the Rust wrapper I'm first going to create a library project using cargo.
C:\rust_ffi_c_cpp> cargo new --lib rust_ffi
Now replace the contents of the rust_ffi\src\lib.rs
file with the following:
use std::{ffi::{CString, CStr, c_char, c_int}, error::Error};
#[link(name = "c_cpp")] // name of the C shared library
extern "C" {
fn introduce(name: *const c_char, age: c_int) -> *const c_char;
fn deallocate_string(s: *const c_char);
}
fn get_str_slice_from_c_char_ptr<'a>(input_c_char: *const c_char) -> Result<&'a str, Box<dyn Error>> {
let input_c_str = unsafe { CStr::from_ptr(input_c_char) };
match input_c_str.to_str() {
Ok(str_slice) => Ok(str_slice),
Err(error) => Err(Box::new(error)),
}
}
pub fn introduce_rust(name: &str, age: c_int) -> Result<String, Box<dyn Error>> {
let name = CString::new(name)?;
let introduction = unsafe { introduce(name.as_ptr(), age) };
let introduction_string = get_str_slice_from_c_char_ptr(introduction)?.to_string();
unsafe { deallocate_string(introduction) };
Ok(introduction_string)
}
The first use
line imports standard library features related to handling C-style strings, the C types and the Error trait.
The next block of code is where we specify the name of the C/C++ shared library and the function(s) within it that we are going to use. In our case, the signature of the function(s) should match those in the c_cpp\include\lib.h
file.
The get_str_slice_from_c_char_ptr
convenience function is the one that converts the raw const char *
pointer from C to a &str
in Rust. It makes use of an unsafe
code block to accomplish this.
The introduce_rust
function is the actual wrapper around the introduce
C/C++ function. It takes the first input parameter as &str
and converts it into a C-style string. The second parameter is an integer and is passed as it is. This function also makes use of an unsafe code block for the C function. Then it makes use of the get_str_slice_from_c_char_ptr
convenience function to convert the returned C-style string into a Rust String
. And it deallocates the c-string to prevent memory leakage.
In order to test that our approach actually works let's write a rust_ffi\src\main.rs
. Following are its contents:
use std::error::Error;
use rust_ffi::introduce_rust;
fn main() -> Result<(), Box<dyn Error>> {
let introduction = introduce_rust("Srikanth", 31)?;
println!("{introduction}");
Ok(())
}
We can build our project by using the following commands from the rust_ffi
directory:
set RUSTFLAGS=-L..\c_cpp\build\Debug
cargo build
The first command sets the Rust linker flag -L
to the path containing the C/C++ shared library. And the second command builds the project.
Now we can run the built Rust executable by using the following commands from the rust_ffi
directory:
set PATH=..\c_cpp\build\Debug;%PATH%
target\debug\rust_ffi.exe
And voila we get our expected output:
Hi, I am Srikanth. My age is 31.
An alternative way to build and run the Rust wrapper using build.rs and cmake-rs
Being in the same Rust project modify rust_ffi\Cargo.toml
to look like as follows:
[package]
name = "rust_ffi"
version = "0.1.0"
edition = "2021"
build = "build.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
[build-dependencies]
cmake = "0.1.50"
And add rust_ffi\build.rs
with the following contents:
use std::path::Path;
use cmake::Config;
fn main()
{
let dst = Config::new(Path::new("..").join("c_cpp")).build();
println!("cargo:rustc-link-search=native={}", dst.display());
println!("cargo:rustc-link-lib=dylib=c_cpp");
}
Now one can simply run from the rust_ffi
directory, the command cargo build
to build the C/C++ shared library and the Rust executable in one shot. One can also run the Rust executable by simply running the command cargo run
.