I C and .so does Rust

- (12 min read)

Distributing software is a tricky thing. If you want to share the functionality with someone you may share the source with them and give them the build instructions and it would all play out good. But that may not be the case if the source is a different language and the consumer is using a different language. For example, there are a lot of libraries that use common functionalities like openssl for crypto operations or network libraries or packages like zlib just because they are reliable and have been tested for years on speed and correctness. You might not want to translate it in the language of your choice.

How do you accomplish something like that. Calling a library written in some other language that your source. There is a concept called FFI or Foreign Function Interface which is used for this exact thing.

You might have heard about JNI or Java Native Interfaces which is used to call subroutines in such native libraries. This came to me as a surprise, a lot of the core components of Java is written in C and is called within the language using JNI bindings. You would have seen the following signature in some Java classes when traversing the Go-To definitions.

public native String foo()

For example, This is what FileInputStream.open looks like

JNIEXPORT void JNICALL
Java_java_io_FileInputStream_open0(JNIEnv *env, jobject this, jstring path) {
    fileOpen(env, this, path, fis_fd, O_RDONLY);
}

That's the calling convention for JNI, the java.io.FileInputStream.open0() would be translated to Java_java_io_FileInputStream_open0().

Java is hardwired to look for these symbols when someone calls the Java name of these functions and most of it is present in libjava.so and libjvm.so files. These are distributable Shared Objects that contains the implementation of these native functions and is platform dependant. This may come as a surprise to some people (or I may be making a fool out of myself), Java isn't platform independant, atleast not all the core components. The bytecode generated by Java compiler is platform independant. The VM on which the bytecode executes has to be written and compiled for all platforms and architectures.

That looks something like this jdk/src/java.base/ .

Shared objects

Shared objects or dynamic libraries are an interesting concept and sometimes a pain. Let's go through an example.

#include <stdio.h>
int main() {
    printf("hello world");
    return 0;
}

This program is just calling a function called printf. But, wait, I can call the functions that actually exists, otherwise the compiler goes batshit crazy, unless it's javascript ofcourse, which will break at runtime. This code compiles, so this function must exist, but where, it's not present in my source file.

Its present in the /usr/include/stdio.h file.

extern int printf (const char *__restrict __format, ...);

Wait, it's only the declaration, where is the implementation. When I build this this code and run the linked dependency tools ldd, it shows this,

$ gcc hello.c -o hello
$ ldd hello
        linux-vdso.so.1 (0x00007fff715ea000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1f085a1000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f1f08b94000)

Notice, there is something called, libc present in here. Linux exports all these implementations in this shared object. For example, notice this file in the glibc repository, glibc/printf.c. If libc is not present on the target host, the simple hello world program won't work since it would not find the printf subroutine.

Now, if the code is in a binary file, how do people and compilers know if they using it correctly. That's where the header files come in. Header files are basically signatures that the compiler can rely on to check if the code is correct syntactically. This is generally exported as a public include folder in C projects.

There is a very nice explanation on what shared objects are in this SO post. TL;DR, it's a binary which contains implementation of the corresponding headers. Shared object naming convention is specific to link. For other platforms, they are called dylib for MacOS and dll for windows. They are not quite the same but, they behave in a similar fashion and this can be backed up by the C API dlopen which is used to load dynamic libraries.

Now, that we have vague idea of what shared objects represent, let's move to "why this waste of internet resources, this article".

Interoperability with Rust

Rust is popular because it boasts a good interoperability with the C APIs. Which means, it's simple in Rust to call C APIs with minimal efforts. Let's see a minimal API.

The C code we would call.

We need to have some functionality that we want to call from other languages. Let's write a toy project which exposes such API.

The project structure looks like this.

.
├── Makefile
└── src
   ├── include
   │  └── shared.h
   └── shared
      └── shared.c

This is a fairly standard structure for C projects. To have definitions for all public APIs in the include folder.

The shared.h file.

#ifndef SHARED_H
#define SHARED_H
#endif

struct key_spec {
    char key[16];
    const char *type;
};

struct key_spec* get_key();

The shared.c file that contains the actual implementation. This is a very advanced key generator ! almost cryptographically secure.

#include <shared.h>
#include <stdlib.h>

struct key_spec *get_key() {
    struct key_spec *ks = (struct key_spec*) malloc(sizeof(struct key_spec*));
    for (int i = 0; i < 16; i++) {
        ks->key[i] = i + 32;
    }

    ks->type = (const char*)"dummy\0";
    return ks;
}

How to build it into a shared object (notice it does not have a int main(). Create a Makefile with

build:
        gcc -shared -Isrc/include src/shared/shared.c -o libshared.so

And that's it. Just run make and it should dump a shared object file.

$ make
$ file libshared.so
libshared.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), 
dynamically linked, BuildID[sha1]=65587972f8df8f099b66363f0cc44f96f43c2828, not stripped

Interfacing rust with the shared object

Now that we have a shared object which has out advanced key generator, we need to tell Rust, how does it look, function definitions, fields etc. There is just the tool for it, bindgen. It's a rust language project that generates FFI bindings (the interface in the target language, i.e. Rust for it's compiler to understand. It's basically a header file but in rust.)

Install the bindgen crate via cargo install bindgen. Make sure you have some form of a C compiler present, I am using gcc here.

Generating the header-ish files for rust

Let's ask rust to generate some code.

$ cargo init --bin test-rs
$ cd test-rs
$ bindgen cproject/src/include/shared.hpp -o src/shared.rs

This should generate a rust source file.

/* automatically generated by rust-bindgen 0.55.1 */

#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct key_spec {
    pub key: [::std::os::raw::c_char; 16usize],
    pub type_: *const ::std::os::raw::c_char,
}

/* omitting tests */ 

extern "C" {
    pub fn get_key() -> *mut key_spec;
}

Notice, it has a similar structure of our header file and defintions. Alright, it looks nice and usable in rust. Let's move on.

Let's write the main.rs file.

mod shared;

fn main() {
    unsafe {
        let k = shared::get_key();
        println!("the key: {:#?}", k.as_ref());
    };
}

Since, this is a call from a different library, rust can not guarantee it would not do something funny, so we have to write it inside the unsafe block.

That's all code we need.

Building it in rust

Before we rush and do a cargo run, we need to tell the compiler what are we trying to do.

If you try to build it now, the linker will throw a huge error saying, it can't find

$ cargo build
error: linking with `cc` failed: exit code: 1
...
error: linking with `cc` failed: exit code: 1
  |
  = note: "cc" "-Wl,--as-needed"...
  = note: test-rs/target/debug/deps/test_rs-b3e83acc1bd66527.3yrtf1vyhbvxamca.rcgu.o: In function `test_rs::main':
          test-rs/src/main.rs:5: undefined reference to `get_key'
          collect2: error: ld returned 1 exit status

which is logical as we just told rust about the definition, we never told rust where to look for the actual implementation is, notice it's a linker ld error saying it can't find get_key function.

There are 2 ways to do it.

We can do it the old school way using the -L and the -l options in ld.

The rust compiler, just like the gcc can take some linker flags and pass it on to the linker in the linking step.

$ env RUSTFLAGS="-Lcproject/ -lshared" cargo build

This will tell ld to look for libshared.so file in the search path cproject (since we build the shared object in that project folder).

The all rust way

We can tell rust by specifying a links key in package section in Cargo.toml.

This naming convention is same as dlopen, omit the leading lib from the shared object name. So, libshared.so becomes shared.

After adding this, cargo build will ask you to have a custom build script, i.e. build.rs. This very specific use case is present in the bindgen manual

use std::env;

fn main() {
    let project_dir = env::var("CARGO_MANIFEST_DIR").unwrap();

    println!("cargo:rustc-link-search={}", project_dir); // the "-L" flag
    println!("cargo:rustc-link-lib=shared"); // the "-l" flag
}

We tell cargo to use this build.rs file by specifying the build key in the package section in Cargo.toml.

[package]
name = test-rs
...
links = "shared"
build = "build.rs"

Now, when we run, we should get a successful build. Run a cargo clean to make sure old artefacts are removed.

$ cargo clean && cargo build

Let's celebrate our victory

We are ready to fly..

$ cargo build
$ ./target/debug/test-rs
./target/debug/test-rs: error while loading shared libraries: libshared.so: 
cannot open shared object file: No such file or directory

But wait, this is trivial, remember LD_LIBRARY_PATH. All binaries that have dynamic dependencies should be told where to find those dependencies. eg:

$ ldd $HOME/.cargo/bin/cargo
        linux-vdso.so.1 (0x00007ffe401f4000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f6ed709c000)
        librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007f6ed6e94000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f6ed6c75000)
        libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f6ed6a5d000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6ed666c000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f6ed62ce000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f6ed7e2c000)

Notice, how all the shared objects are mapped to a physical location in the memory which is usually part of /lib and /lib64. For an exhustive list look at /etc/ld.so.conf.d/.

We can set the environment variable LD_LIBRARY_PATH and then it should run.

$ env LD_LIBRARY_PATH=cproject target/debug/test-rs
the key: Some(
    key_spec {
        key: [
            32,
            33,
            ...
            45,
            46,
            47,
        ],
        type_: 0x00007fbafa2f2665,
    },
)

Rejoice!!

Something interesting that I observed

The original intent of this excercise was to get Rust to use C++ library. So, the initial version of the C source was in C++. Then, I decided to move to something simpler. But I forgot to rename the header file to a .h extension from a .hpp extension. Due to that, bindgen was compiling those headers into mangled link_names causing a lot of pain to me.

When generating the ffi bindings for the headers named as .hpp instead of .h, I was getting

extern "C" {
    #[link_name = "\u{1}_Z7get_keyv"]
    pub fn get_key() -> *mut key_spec;
}

Notice the link_name, an additional attribute.The cargo builds kept failing due to the following errors when I was trying to use the above generated code with a C version of the shared object build, i.e. using gcc.

"cc" "-Wl,--as-needed" ... "-L" "cproject" ... "-l" "shared" ... 

It seems to be looking at the correct locations, but it's not able to find the mangled name.

Notice the link_name, it's not get_key but _Z7get_keyv. This is called Name Mangling, compiler's way of embedding meta data for the linker. Let's see what name is present in the gcc version of the shared object that the linker is trying to look in.

$ objdump -d libshared.so | grep get_key
000000000000060a <get_key>:
 627:   eb 18                   jmp    641 <get_key+0x37>
 645:   7e e2                   jle    629 <get_key+0x1f>

This seems to be plain and simple, no mangling at all, as expected.

We can explicitly tell bindgen to not use those compiler provided mangled link names. by specifying

bindgen --distrust-clang-mangling <header file> -o src/shared.rs

Observing bindgen with C++

Mangling is a very prominent feature in C++. Which means, g++ should be able to produce the same results as bindgen when asserting the project to be a c++ project by the .hpp extension.

Let's compile the source shared object with g++ instead of gcc.

$ g++ -shared -Isrc/include src/shared/shared.c -o libshared.so
$ file libshared.so
libshared.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), 
dynamically linked, BuildID[sha1]=002b495798ab8683e9596c1e2a85104dc5e48fb6, not stripped

$ objdump -d libshared.so | grep get_key
000000000000061a <_Z7get_keyv>:
 63b:   7f 1a                   jg     657 <_Z7get_keyv+0x3d>
 655:   eb e0                   jmp    637 <_Z7get_keyv+0x1d>

So, the shared object built with g++ does produce the expected mangled name.

$ c++filt _Z7get_keyv
get_key()

And is able to reverse properly too.

Thanks to /u/boomshroom for pointing this .h to .hpp error out.

References

Some interesting things that I came across that may help


Discussion thread: here