Rust and C++ with Cargo and CMake

About

Yesterday I’ve described shortly how to link program written in Rust with a simple static library written in C and built with CMake .

This time, I’ll extend the example by introducing a static library written in C++ that is conumed by program in Rust. Again, the whole build is managed by cargo

I was playing recently with and one of the feature it supports is ability to link to C code. This post demonstrates the steps to do so, using Cargo as a primary build tool for Rust part of the code, invoking to do the native C code compilation, producing a static library that Rust picks up and calls.

This description is a delta to the previous post, so please read that one first whenever things surprise you here.

You may get the working example from this git repository - just remember to pull and checkout cplusplus branch.

Extra compiler

Off course we’re going to use C++ compiler now. I’m using clang, but G++ is fine too. Just for reference:

g++ --version
Configured with: --prefix=/Applications/Xcode.app/Contents/Developer/usr --with-gxx-include-dir=/usr/include/c++/4.2.1
Apple LLVM version 9.1.0 (clang-902.0.39.1)
Target: x86_64-apple-darwin17.5.0
Thread model: posix

Another static library

For the sake of playing with Rust and C++ let’s add another (sibling) static library (per analogy located in libfoo++ subdirectory), also build with CMake, but this time written in C++:

# Standard CMake prelude - nothing special
cmake_minimum_required(VERSION 3.9)
project(LibFoo CXX)

add_library(foo++ STATIC foo.cpp)

# Here is not-so-standard element.
# The target installation command has to exist
# and has to install all linkable targets 
# into `.` (current) directory for cargo to find it.  
install(TARGETS foo++ DESTINATION .)

Then let’s make the library source file having some simple hello world content, using cout and streams:

#include <iostream>

extern "C" void testcall_cpp(float value)
{
    std::cout << "Hello, world from C++! Value passed: " << value << std::endl;
}

Then, we should extend our build.rs file with:

...
Config::new("libfoo++").build();  
...
// same for C++ like previously for C, this time using foo++ as library name
println!("cargo:rustc-link-lib=static=foo++");   

All sounds pretty strighforward. However, an attempt to build with with cargo build will give us a shout:

  = note: Undefined symbols for architecture x86_64:
            "std::terminate()", referenced from:
                ___clang_call_terminate in libfoo++.a(foo.cpp.o)
            "std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >::__init(unsigned long, char)", referenced from:
                std::__1::ostreambuf_iterator<char, std::__1::char_traits<char> > std::__1::__pad_and_output<char, std::__1::char_traits<char> >(std::__1::ostreambuf_iterator<char, std::__1::char_traits<char> >, char const*, char const*, char const*, std::__1::ios_base&, char) in libfoo++.a(foo.cpp.o)
            "std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >::~basic_string()", referenced from:
                std::__1::ostreambuf_iterator<char, std::__1::char_traits<char> > std::__1::__pad_and_output<char, std::__1::char_traits<char> >(std::__1::ostreambuf_iterator<char, std::__1::char_traits<char> >, char const*, char const*, char const*, std::__1::ios_base&, char) in libfoo++.a(foo.cpp.o)
            "std::__1::basic_ostream<char, std::__1::char_traits<char> >::sentry::sentry(std::__1::basic_ostream<char, std::__1::char_traits<char> >&)", referenced from:
            ... blah blah blah ...

It turns that we have forgotten obviously link to C++ standard library.

On my Mac with clang++ being used by Rust as a linker, the following extra line in build.rs will do the job:

...
// and also extra linking C++ on Mac/Clang
println!("cargo:rustc-link-lib=dylib=c++"); 

But this is also a place when things get tricky a bit. The exact command is related to a native C++ toolchain (linker actually) specific. however, for Linux users with GCC, would need to tweak it to be more like this:

...
// and also extra linking C++ on Linux/GCC
println!("cargo:rustc-link-lib=dylib=stdc++"); 

so I came up with a rather quick and dirty solution with detecting target platform name (using env variable) and acting accrordingly. Note that in general this logic will fail in more specific scnearios (e.g. using clang on Linux, or telling rust to use custom linker etc), so if you plan to re-use this technique - be carefull and craft the logic that is correct for your case. Appologies for Windows folks, as I havent figeured out yet what to pass to that case.

...
let target  = env::var("TARGET").unwrap();
if target.contains("apple")
{
    println!("cargo:rustc-link-lib=dylib=c++");
}
else if target.contains("linux")
{
    println!("cargo:rustc-link-lib=dylib=stdc++");
}
else 
{
    unimplemented!();
}
...

Calling from Rust program

Now, we need to extend the extern section in our rust program to have testcall_cpp(...) declaration available:

extern {
    ...
    #[link(name="foo++", kind="static")]
    fn testcall_cpp(v: f32); 
    ...
}

And we can now call it from main function, off course using unsafe code block:

    unsafe { 
        testcall_cpp(3.14159); 
    };

That’s it. The project will compile and run like a charm.

Consequences

This method causes the final executable to depend on libc++ library being present in the system:

$ otool -L target/debug/rust-and-cpp
target/debug/rust-and-cpp:
	/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 400.9.0)
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1252.50.4)
	/usr/lib/libresolv.9.dylib (compatibility version 1.0.0, current version 1.0.0)
comments powered by Disqus