Skip to content
Learn Rust
Theme

Rust and C/C++

Rust and C can call each other because they share one thing, the C calling convention, which extern "C" selects. Everything else is a convention you keep by hand: struct layout, who frees a pointer, what happens on failure. This page builds a small Rust library, bridge, and calls it from C and C++ (and from five other languages). Then it goes the other way: Rust calling a C library through bindgen, and a C++ class through cxx.

Everything not marked “not run” ran in the image behind this site (Linux ARM64, Rust 1.99.1, GCC 14.2; .a, .so and LD_LIBRARY_PATH are Linux names). The image has no cbindgen or libclang, so the runs install them; steps that need the network say so, and versions are pinned.

RememberThe compiler cannot check the other side of the boundary or what a raw pointer points to, so each unsafe block is a promise you write down: who owns the memory, what the layout is, and that nothing unwinds across it.

Project layout
bridge/
├── c/                          C programs that call the library
│   ├── catch.c
│   ├── double_free.c
│   ├── layout.c
│   ├── leak.c
│   ├── main.c
│   └── panic.c
├── cpp/                        a C++ program that calls it
│   └── unwind.cpp
├── csharp/
│   ├── callrust.csproj
│   └── Program.cs
├── go/                         the same call from Go (and java, kotlin, csharp, py)
│   ├── go.mod
│   └── main.go
├── java/
│   └── CallRust.java
├── kotlin/
│   └── CallRust.kt
├── py/
│   └── call.py
├── src/
│   ├── bin/
│   │   ├── layout.rs
│   │   └── strings.rs
│   └── lib.rs                  the Rust side: every function C can call
├── Cargo.lock
├── Cargo.toml
└── cbindgen.toml               settings for the header generator
Rust
//! A small library for C and C++ programs to call.
use std::ffi::{CStr, CString, c_char};
use std::panic::catch_unwind;
use std::ptr;
/// Two doubles, in this order. C lays out `struct Point` the same way.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct Point {
pub x: f64,
pub y: f64,
}
/// Fields of different sizes, so that the layout rules matter.
#[repr(C)]
pub struct Reading {
pub id: u8,
pub value: f64,
pub flag: u8,
}
/// An opaque handle: C only ever holds a pointer to it and never sees the fields.
pub struct Accumulator {
total: i64,
}
#[unsafe(no_mangle)]
pub extern "C" fn point_distance(a: Point, b: Point) -> f64 {
(a.x - b.x).hypot(a.y - b.y)
}
/// # Safety
/// `r` must point to a valid `Reading`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn reading_value(r: *const Reading) -> f64 {
// SAFETY: the caller promised that `r` is valid (see above).
unsafe { (*r).value }
}
#[unsafe(no_mangle)]
pub extern "C" fn accumulator_new() -> *mut Accumulator {
Box::into_raw(Box::new(Accumulator { total: 0 }))
}
/// # Safety
/// `acc` must come from `accumulator_new` and must not have been freed.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn accumulator_add(acc: &mut Accumulator, value: i64) -> i64 {
acc.total += value;
acc.total
}
/// # Safety
/// `acc` must be null, or come from `accumulator_new` and not have been freed.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn accumulator_free(acc: *mut Accumulator) {
if !acc.is_null() {
// SAFETY: made by `Box::into_raw` in `accumulator_new`, not freed yet.
drop(unsafe { Box::from_raw(acc) });
}
}
/// Adds up `len` numbers. A null pointer counts as an empty array.
///
/// # Safety
/// If `ptr` is not null, it must point to `len` readable `i32` values.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn sum_i32(ptr: *const i32, len: usize) -> i64 {
if ptr.is_null() {
return 0;
}
// SAFETY: not null, and the caller promised `len` readable values.
let numbers = unsafe { std::slice::from_raw_parts(ptr, len) };
numbers.iter().map(|&n| i64::from(n)).sum()
}
/// Returns a new string, "hello, <name>", that the caller must give back to
/// `greeting_free`. Returns null if `name` is null.
///
/// # Safety
/// If `name` is not null, it must point to a NUL-terminated string.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn greeting(name: *const c_char) -> *mut c_char {
if name.is_null() {
return ptr::null_mut();
}
// SAFETY: not null, and the caller promised a NUL-terminated string.
let name = unsafe { CStr::from_ptr(name) }.to_string_lossy();
match CString::new(format!("hello, {name}")) {
Ok(text) => text.into_raw(),
Err(_) => ptr::null_mut(),
}
}
/// # Safety
/// `text` must be null, or a pointer returned by `greeting` that has not been freed yet.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn greeting_free(text: *mut c_char) {
if !text.is_null() {
// SAFETY: `greeting` made this pointer with `CString::into_raw`, and
// the caller promised it is still unfreed.
drop(unsafe { CString::from_raw(text) });
}
}
/// Panics when `fail` is not zero. A panic cannot leave an `extern "C"` function.
#[unsafe(no_mangle)]
pub extern "C" fn explode(fail: i32) -> i32 {
if fail != 0 {
panic!("boom");
}
7
}
/// Divides `a` by `b` and writes the answer to `*out`. Returns 0 on success and
/// -1 if the division panicked (a zero `b`).
///
/// # Safety
/// `out` must be valid for writing one `i32`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn checked_div(a: i32, b: i32, out: *mut i32) -> i32 {
match catch_unwind(|| a / b) {
Ok(q) => {
// SAFETY: the caller promised that `out` can be written.
unsafe { *out = q };
0
}
Err(_) => -1,
}
}
/// Like `explode`, but declared so that the panic may unwind into the caller.
#[unsafe(no_mangle)]
pub extern "C-unwind" fn explode_unwind() {
panic!("boom, unwinding");
}

Four things make a Rust function callable from C. extern "C" picks the C calling convention. #[unsafe(no_mangle)] keeps the symbol name as written, so the linker finds point_distance. #[repr(C)] gives a struct the layout a C compiler would. And the signature uses types C has: integers, floats, pointers, repr(C) structs. Dereferencing a raw pointer is unsafe (the unsafe lesson), so each block carries a // SAFETY: comment and each function a # Safety section for its caller.

The three crate-type values build libbridge.a (static), libbridge.so (shared) and an rlib for Rust users (linkage).

The edition guide says Rust 1.82 let any edition write unsafe(no_mangle) and that the 2024 edition requires it, because exported names share one global namespace. It also requires unsafe extern on a block of foreign declarations (Reference: “Prior to the 2024 edition, the unsafe keyword is optional”). The messages say what to write:

$ cargo build --lib
error: unsafe attribute used without unsafe
 --> src/lib.rs:1:3
  |
1 | #[no_mangle]
  |   ^^^^^^^^^ usage of unsafe attribute
  |
help: wrap the attribute in `unsafe(...)`
  |
1 | #[unsafe(no_mangle)]
  |   +++++++         +
$ cargo build --lib
error: extern blocks must be unsafe
 --> src/lib.rs:1:1
  |
1 | / extern "C" {
2 | |     fn abs(x: i32) -> i32;
3 | | }
  | | ^ help: needs `unsafe` before the extern keyword: `unsafe`
  | |_|

A lint covers signatures C cannot express. improper_ctypes_definitions only warns by default; here it is denied so the message shows:

$ cargo build --lib
error: `extern` fn uses type `str`, which is not FFI-safe
 --> src/lib.rs:4:36
  |
4 | pub extern "C" fn first_word(text: &str) -> usize {
  |                                    ^^^^ not FFI-safe
  |
  = help: consider using `*const u8` and a length instead
  = note: string slices have no C equivalent
note: the lint level is defined here
 --> src/lib.rs:1:9
  |
1 | #![deny(improper_ctypes_definitions)]
  |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^

error: `extern` fn uses type `Vec<i32>`, which is not FFI-safe
 --> src/lib.rs:9:28
  |
9 | pub extern "C" fn total(v: Vec<i32>) -> i32 {
  |                            ^^^^^^^^ not FFI-safe
  |
  = help: consider adding a `#[repr(C)]` or `#[repr(transparent)]` attribute to this struct
  = note: this struct has unspecified layout

You do not write the C declarations by hand. cbindgen (“creates C/C++11 headers for Rust libraries which expose a public C API”, says its README) reads the source and writes the header. This view drops the comments copied from the /// docs:

terminal
$ cargo install cbindgen --version 0.29.4 --locked   # needs network
$ cbindgen --crate bridge --output bridge.h
$ grep -vE '^( [^ ]|/\*|$)' bridge.h   # bridge.h without its comments
#ifndef BRIDGE_H
#define BRIDGE_H
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
typedef struct Accumulator Accumulator;
typedef struct Point {
  double x;
  double y;
} Point;
typedef struct Reading {
  uint8_t id;
  double value;
  uint8_t flag;
} Reading;
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
double point_distance(struct Point a, struct Point b);
double reading_value(const struct Reading *r);
struct Accumulator *accumulator_new(void);
int64_t accumulator_add(struct Accumulator *acc, int64_t value);
void accumulator_free(struct Accumulator *acc);
int64_t sum_i32(const int32_t *ptr, uintptr_t len);
char *greeting(const char *name);
void greeting_free(char *text);
int32_t explode(int32_t fail);
int32_t checked_div(int32_t a, int32_t b, int32_t *out);
void explode_unwind(void);
#ifdef __cplusplus
}  // extern "C"
#endif  // __cplusplus
#endif  /* BRIDGE_H */

Accumulator is an incomplete type: C holds a pointer and cannot look inside. &mut Accumulator became struct Accumulator *, and the promise that the pointer is valid lives in the # Safety docs. cpp_compat = true in cbindgen.toml adds the extern "C" guard for C++.

Cc/main.c
#include <stdio.h>
#include <stdlib.h>
#include "bridge.h"
int main(void) {
Point a = {0.0, 0.0}, b = {3.0, 4.0};
printf("distance: %.1f\n", point_distance(a, b));
Reading r = {1, 2.5, 0};
printf("reading: %.1f\n", reading_value(&r));
int numbers[] = {10, 20, 30};
printf("sum: %lld\n", (long long)sum_i32(numbers, 3));
char *text = greeting("C");
printf("%s\n", text);
greeting_free(text);
Accumulator *acc = accumulator_new();
accumulator_add(acc, 5);
printf("total: %lld\n", (long long)accumulator_add(acc, 6));
accumulator_free(acc);
return 0;
}
terminal
$ gcc -Wall -Wextra -I. c/main.c target/debug/libbridge.a -lpthread -ldl -lm -o main && ./main
distance: 5.0
reading: 2.5
sum: 60
hello, C
total: 11

A static library does not say what it needs, but rustc can print the list (it is where -lpthread -ldl -lm came from):

terminal
$ cargo rustc --lib --crate-type staticlib -- --print native-static-libs
note: native-static-libs: -lgcc_s -lutil -lrt -lpthread -lm -ldl -lc

Reading is a byte, a double and a byte. The same struct measured from C and from Rust:

terminal
$ gcc -I. c/layout.c -o layout && ./layout
C, Reading: size 24, id at 0, value at 8, flag at 16
$ cargo run -q --bin layout
Rust, Reading: size 24, id at 0, value at 8, flag at 16
default repr, same fields: size 16

The two Reading lines agree because of repr(C). The last line is the same fields with no repr: 16 bytes in this build. The Reference says the field order “does not have to be the same as the order in which the fields are specified”. Try it yourself 1 removes repr(C).

Rust allocated the string greeting returns, so Rust must free it: greeting_free takes it back with CString::from_raw. Accumulator follows the same rule with Box::into_raw and Box::from_raw. The docs for CString::from_raw warn that taking ownership of a string “allocated by foreign code” is “likely to lead to undefined behavior or allocator corruption”. The rule runs both ways: the cgo docs say a C.CString “is allocated in the C heap using malloc” and “it is the caller’s responsibility to arrange for it to be freed”. Give a pointer back to the code that made it.

A C program that follows the rule gets no report from AddressSanitizer (this is c/main.c rebuilt with -fsanitize=address):

terminal
$ gcc -fsanitize=address -g -I. c/main.c target/debug/libbridge.a -lpthread -ldl -lm -o main_asan && ./main_asan
distance: 5.0
reading: 2.5
sum: 60
hello, C
total: 11

One that forgets greeting_free gets the leak check’s report when it exits, with the allocation traced into Rust. The command keeps the key lines of a longer report:

Cc/leak.c
#include <stdio.h>
#include "bridge.h"
int main(void) {
char *text = greeting("leak");
printf("%s\n", text);
/* No greeting_free(text): Rust allocated this string, and nobody tells Rust to free it. */
return 0;
}
terminal
$ gcc -fsanitize=address -g -I. c/leak.c target/debug/libbridge.a -lpthread -ldl -lm -o leak
$ ./leak 2>&1 | grep -E 'ERROR|Direct leak|in (greeting|main) |SUMMARY'   # the report, trimmed to its key lines
==827==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 12 byte(s) in 1 object(s) allocated from:
    #14 0xc7a86d813fe0 in greeting src/lib.rs:90
    #15 0xc7a86d813bb8 in main c/leak.c:5
SUMMARY: AddressSanitizer: 12 byte(s) leaked in 1 allocation(s).
exit code: 1

This output changes from run to run. This is one real run.

And one that calls greeting_free twice:

Cc/double_free.c
#include <stdio.h>
#include "bridge.h"
int main(void) {
char *text = greeting("twice");
printf("%s\n", text);
greeting_free(text);
greeting_free(text); /* the second call hands Rust a pointer that is already freed */
return 0;
}
terminal
$ gcc -fsanitize=address -g -I. c/double_free.c target/debug/libbridge.a -lpthread -ldl -lm -o double_free
$ ./double_free 2>&1 | grep -E 'ERROR|READ of|freed by|previously allocated|from_raw|in (strlen|greeting_free|greeting|main) |SUMMARY'   # trimmed
==836==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000030 at pc 0xfac141916d38 bp 0xffffcce8b900 sp 0xffffcce8b940
READ of size 2 at 0x502000000030 thread T0
    #0 0xfac141916d34 in strlen ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:391
    #1 0xb44441c1b850 in _RNvMs_NtNtCsj011dqJFTMq_5alloc3ffi5c_strNtB4_7CString8from_raw library/alloc/src/ffi/c_str.rs:413
    #2 0xb44441ba4108 in greeting_free src/lib.rs:103
    #3 0xb44441ba3bd4 in main c/double_free.c:8
freed by thread T0 here:
    #9 0xb44441ba412c in greeting_free src/lib.rs:103
    #10 0xb44441ba3bcc in main c/double_free.c:7
previously allocated by thread T0 here:
    #14 0xb44441ba3ff0 in greeting src/lib.rs:90
    #15 0xb44441ba3bb8 in main c/double_free.c:5
SUMMARY: AddressSanitizer: heap-use-after-free ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors.inc:391 in strlen
exit code: 1

This output changes from run to run. This is one real run.

The report says heap-use-after-free, not “double free”: from_raw recomputes the length “from the pointer”, so Rust reads freed bytes before it frees them again. The docs also warn that “a double-free may occur if the function is called twice on the same raw pointer”. The language leaves this undefined. Without AddressSanitizer, one run on this image (glibc, varies by system) was stopped by the C library instead:

terminal
$ gcc -g -I. c/double_free.c target/debug/libbridge.a -lpthread -ldl -lm -o double_free_plain && ./double_free_plain   # no AddressSanitizer
hello, twice
free(): double free detected in tcache 2
bash: line 1:   844 Aborted                 (core dumped) ./double_free_plain
exit code: 134

This output changes from run to run. This is one real run.

A C string is bytes up to the first 0; a Rust String is UTF-8 and may contain 0 (Strings). CString is the owned C string and CStr the borrowed one; both conversions can fail:

Rustsrc/bin/strings.rs
use std::ffi::{CStr, CString};
fn main() {
// Rust to C: a Rust string may hold a 0 byte, but a C string ends at the first one.
match CString::new("ab\0cd") {
Ok(_) => println!("built a CString"),
Err(e) => println!("CString::new failed: {e}"),
}
let owned = CString::new("héllo").unwrap();
println!("bytes with NUL: {:?}", owned.as_bytes_with_nul());
// C to Rust: borrowing C's bytes. UTF-8 is a promise C does not make.
let latin1 = CStr::from_bytes_with_nul(b"caf\xe9\0").unwrap();
println!("len without NUL: {}", latin1.count_bytes());
println!("to_str: {:?}", latin1.to_str());
println!("to_string_lossy: {}", latin1.to_string_lossy());
// A slice with no NUL is rejected, not read past its end.
println!("no NUL: {:?}", CStr::from_bytes_with_nul(b"abc"));
}
$ cargo run -q --bin strings
CString::new failed: nul byte found in provided data at position: 2
bytes with NUL: [104, 195, 169, 108, 108, 111, 0]
len without NUL: 4
to_str: Err(Utf8Error { valid_up_to: 3, error_len: None })
to_string_lossy: caf�
no NUL: Err(NotNulTerminated)

C promises no encoding, so to_str returned an Err for the Latin-1 byte, and to_string_lossy replaced it with �.

explode(1) panics inside an extern "C" function. The Reference’s table on unwinding says that with panic=unwind, a panic that reaches a non-unwinding ABI such as "C" aborts. The C program saw this (backtrace frames, which start with spaces, are left out, and the thread number changes between runs):

terminal
$ ./panic 2>&1 | grep -v '^ '   # without the backtrace frames, which start with spaces
explode(0) = 7

thread '<unnamed>' (997) panicked at src/lib.rs:111:9:
boom
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread '<unnamed>' (997) panicked at /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/panicking.rs:225:5:
panic in a function that cannot unwind
stack backtrace:
thread caused non-unwinding panic. aborting.
exit code: 134

This output changes from run to run. This is one real run.

A library can call catch_unwind at each entry point and return an error code. checked_div does; the panic hook still prints its message first:

terminal
$ gcc -I. c/catch.c target/debug/libbridge.a -lpthread -ldl -lm -o catch && ./catch
checked_div(7, 2): status 0, out 3

thread '<unnamed>' (999) panicked at src/lib.rs:123:27:
attempt to divide by zero
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
checked_div(1, 0): status -1

This output changes from run to run. This is one real run.

The Reference describes extern "C-unwind" as identical to "C" “but with different behavior when the callee unwinds (by panicking or throwing a C++ style exception)”. explode_unwind is declared that way, so its panic may leave the function. This C++ program catches it with catch (...) and does not rethrow:

C++cpp/unwind.cpp
#include <cstdio>
#include "bridge.h"
int main() {
try {
explode_unwind();
std::puts("not reached");
} catch (...) {
std::puts("C++ caught what Rust threw");
std::fflush(stdout); /* so the line survives the abort even when output is piped */
}
return 0;
}
terminal
$ g++ -I. cpp/unwind.cpp target/debug/libbridge.a -lpthread -ldl -lm -o unwind && ./unwind; echo "exit code: $?"
thread '<unnamed>' (1011) panicked at src/lib.rs:136:5:
boom, unwinding
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
C++ caught what Rust threw
fatal runtime error: Rust panics must be rethrown, aborting
bash: line 1:  1011 Aborted                 (core dumped) ./unwind
exit code: 134

This output changes from run to run. This is one real run.

The C++ handler ran, and then Rust aborted the process. That is consistent with the Reference, which gives no guarantee here: “an unwind originated from a Rust runtime must either lead to termination of the process or be caught by the same runtime.” Try it yourself 2 shows panic = "abort" defeating catch_unwind.

The same C program, linked both ways. The sizes are from one debug build and say only what these files were:

terminal
$ ls -l target/debug/libbridge.a target/debug/libbridge.so   # a debug build, Linux ARM64, rustc 1.99.1
-rw-r--r-- 2 root root 24814700 Oct 25 14:26 target/debug/libbridge.a
-rwxr-xr-x 2 root root  4744304 Oct 25 14:26 target/debug/libbridge.so
$ nm -D --defined-only target/debug/libbridge.so | grep ' T '   # the exported functions
0000000000009208 T accumulator_add
0000000000009270 T accumulator_free
00000000000092e4 T accumulator_new
0000000000009358 T checked_div
0000000000009450 T explode
00000000000094b8 T explode_unwind
00000000000094e0 T greeting
00000000000096ac T greeting_free
0000000000009728 T point_distance
000000000000978c T reading_value
0000000000009800 T sum_i32

This output changes from run to run. This is one real run.

The T lines are the 11 functions marked #[unsafe(no_mangle)]. Now the C program linked against the .a, then against the .so:

terminal
$ gcc -I. c/main.c target/debug/libbridge.a -lpthread -ldl -lm -o main_static     # and, with -Ltarget/debug -lbridge, main_dynamic
$ ls -l main_static main_dynamic
-rwxr-xr-x 1 root root   70848 Oct 25 14:26 main_dynamic
-rwxr-xr-x 1 root root 5822576 Oct 25 14:26 main_static
$ readelf -d main_static | grep NEEDED; readelf -d main_dynamic | grep NEEDED
 0x0000000000000001 (NEEDED)             Shared library: [libm.so.6]
 0x0000000000000001 (NEEDED)             Shared library: [libgcc_s.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
---
 0x0000000000000001 (NEEDED)             Shared library: [libbridge.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
$ ./main_dynamic
./main_dynamic: error while loading shared libraries: libbridge.so: cannot open shared object file: No such file or directory
$ LD_LIBRARY_PATH=target/debug ./main_dynamic | head -2
distance: 5.0
reading: 2.5

This output changes from run to run. This is one real run.

The program linked against the .a lists no libbridge, because that code was copied in (libc and others are still loaded dynamically). The other asks the loader for libbridge.so at start-up, and the loader failed to find it until LD_LIBRARY_PATH pointed there. (The linker’s -rpath option can store a path in the program; I did not run it.)

Without #[unsafe(no_mangle)] a function still exists, under a Rust-mangled name, while the header still declares point_distance. The error comes from the linker:

terminal
$ nm -C target/debug/libbridge.a | grep ' T .*point_distance'
0000000000000000 T bridge::point_distance
$ gcc -I. c/main.c target/debug/libbridge.a -lpthread -ldl -lm -o main
/usr/bin/ld: /tmp/ccPwT2jZ.o: in function `main':
main.c:(.text+0x40): undefined reference to `point_distance'
collect2: error: ld returned 1 exit status

This output changes from run to run. This is one real run.

This direction needs a Rust declaration for every C function. The bindgen crate writes them from a header in build.rs, and the cc crate compiles the C file:

Rust
use std::{env, path::PathBuf};
fn main() {
println!("cargo:rerun-if-changed=c/stats.h");
println!("cargo:rerun-if-changed=c/stats.c");
// Compile the C library and link it into the Rust program.
cc::Build::new().file("c/stats.c").compile("stats");
// Read the header with libclang and write Rust declarations for it.
let bindings = bindgen::Builder::default()
.header("c/stats.h")
.allowlist_function("stats_.*")
.allowlist_type("stats_t")
.generate()
.expect("bindgen could not read c/stats.h");
let out = PathBuf::from(env::var("OUT_DIR").unwrap()).join("bindings.rs");
bindings.write_to_file(out).expect("could not write bindings.rs");
}

bindgen reads headers with libclang, which the image lacks. The session shows the failed build, the install (Debian’s libclang-dev; the requirements ask for “Clang 9.0 or greater”), then the program and the file bindgen wrote:

terminal
$ cargo build    # the error line, before libclang is installed
Unable to find libclang: "couldn't find any valid shared libraries matching: ['libclang.so', 'libclang-*.so', 'libclang.so.*', 'libclang-*.so.*'], set the `LIBCLANG_PATH` environment variable to a path where one of these files can be found (invalid: [])"
$ apt-get install --no-install-recommends libclang-dev   # needs network; the image has no libclang
apt-get exit code: 0
Version: 1:19.0-63
$ cargo run
count 3, sum 10, max 4.5
error: empty input
$ cat target/debug/build/stats-sys-*/out/bindings.rs
/* automatically generated by rust-bindgen 0.73.2 */

#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct stats_t {
    pub count: usize,
    pub sum: f64,
    pub max: f64,
}
#[allow(clippy::unnecessary_operation, clippy::identity_op)]
const _: () = {
    ["Size of stats_t"][::std::mem::size_of::<stats_t>() - 24usize];
    ["Alignment of stats_t"][::std::mem::align_of::<stats_t>() - 8usize];
    ["Offset of field: stats_t::count"][::std::mem::offset_of!(stats_t, count) - 0usize];
    ["Offset of field: stats_t::sum"][::std::mem::offset_of!(stats_t, sum) - 8usize];
    ["Offset of field: stats_t::max"][::std::mem::offset_of!(stats_t, max) - 16usize];
};
unsafe extern "C" {
    pub fn stats_compute(
        values: *const f64,
        len: usize,
        out: *mut stats_t,
    ) -> ::std::os::raw::c_int;
}
unsafe extern "C" {
    pub fn stats_describe_status(status: ::std::os::raw::c_int) -> *const ::std::os::raw::c_char;
}

The bindings include compile-time checks of the size and each field offset, and every function sits in an unsafe extern "C" block, so each call needs unsafe. The safe compute wrapper keeps the promises: valid pointers in, and a result that is only borrowed, because the header comment says the library owns it.

C++ classes, templates and exceptions have no C equivalent, so a C-style header cannot describe them. The cxx crate takes another route: you describe the boundary once in a #[cxx::bridge] module, and cxx-build generates the C++ side. The Stats struct is declared once and used by both languages. String in the bridge is rust::String, and std::string is CxxString (type table). A C++ function that can throw is declared -> Result<i32> and its exception arrives as an Err (docs).

Rust
#[cxx::bridge]
mod ffi {
// A struct declared here exists on both sides, with the same layout.
struct Stats {
count: usize,
total: i32,
}
unsafe extern "C++" {
include!("counter-cxx/src/counter.h");
type Counter;
fn new_counter(name: &str) -> UniquePtr<Counter>;
fn add(self: Pin<&mut Counter>, value: i32);
fn stats(self: &Counter) -> Stats;
fn name(self: &Counter) -> &CxxString;
// A C++ function that may throw: cxx turns the exception into an Err.
fn at(self: &Counter, index: usize) -> Result<i32>;
// This one calls back into Rust's `describe`.
fn print_report(counter: &Counter);
}
extern "Rust" {
// C++ code can call this one.
fn describe(stats: &Stats) -> String;
}
}
fn describe(stats: &ffi::Stats) -> String {
format!("{} values, total {}", stats.count, stats.total)
}
fn main() {
let mut counter = ffi::new_counter("clicks");
counter.pin_mut().add(4);
counter.pin_mut().add(5);
let stats = counter.stats();
println!("{}: {}", counter.name(), describe(&stats));
ffi::print_report(&counter);
println!("at(1) = {:?}", counter.at(1));
match counter.at(9) {
Ok(v) => println!("at(9) = {v}"),
Err(e) => println!("at(9) failed: {e}"),
}
drop(counter);
println!("Rust: after the drop");
}
terminal
$ cargo run
clicks: 2 values, total 9
C++ asked Rust: 2 values, total 9
at(1) = Ok(5)
at(9) failed: index out of range
C++: ~Counter(clicks)
Rust: after the drop

The Counter lives in a UniquePtr owned by Rust, and dropping it ran the C++ destructor. print_report is C++ calling back into Rust’s describe. main.rs has no unsafe block; the unsafe extern "C++" line is the claim, which the cxx docs call “an item level unsafe block to indicate that an unchecked safety claim is being made about the contents of the block”.

Each program calls greeting and returns the string with greeting_free; all but Kotlin also call point_distance (a struct passed by value). I ran them against libbridge.so (Go against libbridge.a).

Language How it calls into Rust Run?
C, C++ the generated header (shown above); C++ can also use cxx run
Go cgo: import "C" after a comment with #cgo flags and #include run
Java java.lang.foreign (Panama), final in JDK 22 (JEP 454); the other route is JNI Panama run; JNI not run
Kotlin the same JDK API; the JNI route is an external fun (Kotlin docs) API run; JNI not run
C# P/Invoke: [DllImport] and [StructLayout] (docs) run
Python ctypes (docs); to write the extension in Rust, see Rust and Python ctypes run
TypeScript a native addon: Rust and Node not run here
The programs and what they printed

Go frees its C.CString with C.free and returns Rust’s string to Rust:

Gogo/main.go
package main
/*
#cgo CFLAGS: -I${SRCDIR}/..
#cgo LDFLAGS: ${SRCDIR}/../target/debug/libbridge.a -lpthread -ldl -lm
#include <stdlib.h>
#include "bridge.h"
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
a := C.Point{x: 0, y: 0}
b := C.Point{x: 3, y: 4}
fmt.Println("distance:", float64(C.point_distance(a, b)))
numbers := []C.int32_t{10, 20, 30}
fmt.Println("sum:", int64(C.sum_i32(&numbers[0], C.uintptr_t(len(numbers)))))
name := C.CString("Go") // allocated by C's malloc: Go's garbage collector does not manage it
defer C.free(unsafe.Pointer(name))
text := C.greeting(name)
fmt.Println(C.GoString(text)) // copies the bytes into a Go string
C.greeting_free(text)
}
terminal
$ go run .
distance: 5
sum: 60
hello, Go

Java describes the struct’s layout to pass it by value. The second run leaves out --enable-native-access=ALL-UNNAMED, and JDK 25.0.5 prints warnings:

Javajava/CallRust.java
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import java.nio.file.Path;
public class CallRust {
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
try (Arena arena = Arena.ofConfined()) {
SymbolLookup rust = SymbolLookup.libraryLookup(Path.of(args[0]), arena);
// struct Point { double x; double y; } is passed by value
StructLayout point = MemoryLayout.structLayout(
ValueLayout.JAVA_DOUBLE.withName("x"), ValueLayout.JAVA_DOUBLE.withName("y"));
MethodHandle distance = linker.downcallHandle(
rust.find("point_distance").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_DOUBLE, point, point));
MemorySegment a = arena.allocate(point);
MemorySegment b = arena.allocate(point);
b.set(ValueLayout.JAVA_DOUBLE, 0, 3.0);
b.set(ValueLayout.JAVA_DOUBLE, 8, 4.0);
System.out.println("distance: " + (double) distance.invokeExact(a, b));
MethodHandle greeting = linker.downcallHandle(
rust.find("greeting").orElseThrow(),
FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS));
MethodHandle greetingFree = linker.downcallHandle(
rust.find("greeting_free").orElseThrow(),
FunctionDescriptor.ofVoid(ValueLayout.ADDRESS));
MemorySegment name = arena.allocateFrom("Java");
MemorySegment text = (MemorySegment) greeting.invokeExact(name);
// The returned address has size 0 until you say how long the memory is.
System.out.println(text.reinterpret(Long.MAX_VALUE).getString(0));
greetingFree.invokeExact(text);
}
}
}
terminal
$ java --enable-native-access=ALL-UNNAMED java/CallRust.java target/debug/libbridge.so
distance: 5.0
hello, Java
$ java java/CallRust.java target/debug/libbridge.so   # without --enable-native-access
WARNING: A restricted method in java.lang.foreign.SymbolLookup has been called
WARNING: java.lang.foreign.SymbolLookup::libraryLookup has been called by CallRust in an unnamed module (file:/home/you/bridge/java/CallRust.java)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

distance: 5.0
hello, Java

Kotlin 2.4.21 calls the same API:

Kotlinkotlin/CallRust.kt
import java.lang.foreign.Arena
import java.lang.foreign.FunctionDescriptor
import java.lang.foreign.Linker
import java.lang.foreign.MemorySegment
import java.lang.foreign.SymbolLookup
import java.lang.foreign.ValueLayout
import java.nio.file.Path
fun main(args: Array<String>) {
val linker = Linker.nativeLinker()
Arena.ofConfined().use { arena ->
val rust = SymbolLookup.libraryLookup(Path.of(args[0]), arena)
val greeting = linker.downcallHandle(
rust.find("greeting").orElseThrow(),
FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.ADDRESS))
val greetingFree = linker.downcallHandle(
rust.find("greeting_free").orElseThrow(),
FunctionDescriptor.ofVoid(ValueLayout.ADDRESS))
val text = greeting.invokeWithArguments(arena.allocateFrom("Kotlin")) as MemorySegment
println(text.reinterpret(Long.MAX_VALUE).getString(0))
greetingFree.invokeWithArguments(text)
}
}
terminal
$ kotlinc kotlin/CallRust.kt -include-runtime -d call.jar && java --enable-native-access=ALL-UNNAMED -jar call.jar target/debug/libbridge.so
hello, Kotlin

C# on .NET 10:

C#csharp/Program.cs
using System.Runtime.InteropServices;
Console.WriteLine($"distance: {Native.point_distance(new Point { x = 0, y = 0 }, new Point { x = 3, y = 4 })}");
IntPtr text = Native.greeting("C#");
Console.WriteLine(Marshal.PtrToStringUTF8(text));
Native.greeting_free(text);
[StructLayout(LayoutKind.Sequential)]
struct Point { public double x; public double y; }
static class Native
{
[DllImport("bridge")]
public static extern double point_distance(Point a, Point b);
[DllImport("bridge")]
public static extern IntPtr greeting([MarshalAs(UnmanagedType.LPUTF8Str)] string name);
[DllImport("bridge")]
public static extern void greeting_free(IntPtr text);
}
terminal
$ LD_LIBRARY_PATH=../target/debug dotnet run
distance: 5
hello, C#

Python 3.13:

Pythonpy/call.py
import ctypes
import sys
lib = ctypes.CDLL(sys.argv[1])
class Point(ctypes.Structure):
_fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)]
lib.point_distance.argtypes = [Point, Point]
lib.point_distance.restype = ctypes.c_double
print("distance:", lib.point_distance(Point(0, 0), Point(3, 4)))
lib.greeting.argtypes = [ctypes.c_char_p]
lib.greeting.restype = ctypes.c_void_p # keep the address, to give it back to greeting_free
lib.greeting_free.argtypes = [ctypes.c_void_p]
text = lib.greeting(b"Python")
print(ctypes.string_at(text).decode())
lib.greeting_free(text)
terminal
$ python3 py/call.py target/debug/libbridge.so
distance: 5.0
hello, Python

Every one of them calls greeting_free, the only function that may free what greeting returned.

  1. Remove #[repr(C)] from Reading. Rust builds it without a warning, and its size drops from 24 to 16, with value first. The regenerated header no longer lets C see inside Reading, so gcc rejects c/main.c:

    terminal
    $ cargo build
    $ cbindgen --crate bridge --output bridge.h && gcc -I. c/main.c target/debug/libbridge.a -lpthread -ldl -lm -o main
    c/main.c:9:5: error: variable ‘r’ has initializer but incomplete type
        9 |     Reading r = {1, 2.5, 0};
          |     ^~~~~~~
    $ cargo run -q --bin layout
    Rust, Reading: size 16, id at 8, value at 0, flag at 9
    default repr, same fields: size 16
    
  2. Add panic = "abort" to the dev profile. catch_unwind cannot catch an abort: checked_div(1, 0) no longer returns -1.

    terminal
    $ gcc -I. c/catch.c target/debug/libbridge.a -lpthread -ldl -lm -o catch && ./catch
    checked_div(7, 2): status 0, out 3
    
    thread '<unnamed>' (1164) panicked at src/lib.rs:123:27:
    attempt to divide by zero
    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
    bash: line 1:  1164 Aborted                 (core dumped) ./catch
    exit code: 134
    

    This output changes from run to run. This is one real run.

  3. Run c/main.c under AddressSanitizer (the first ownership run above). It printed the same lines as the plain build and no report. Then run c/leak.c, a smaller program that never calls greeting_free (the leak run above).