Calling C from Rust

Rust reaches the operating system and every C library through the same door: a declaration of a function with the C calling convention, and an unsafe block at the call. The other direction works too, with an attribute on the exported function and a crate type that produces a library the C linker accepts.

This lesson calls two libc functions, builds a shared library that a C program loads, passes a struct by value across the boundary, and ends with the compiler refusing a call outside the unsafe block. Every command below ran on this machine, and the C program was built with the g++ toolchain of the same container.

The same boundary, seen from the other side, is the subject of Extending Python with Rust: a shared library loaded by an interpreter instead of a C program.

A C function from Rust

An extern "C" block declares the functions and their C types. Since edition 2024 the block itself is unsafe, because a wrong signature is undefined behaviour and the compiler cannot check the C side.

The program calls abs and strlen from libc, which every Linux binary links anyway. A CString owns a NUL-terminated copy for the call, and CStr borrows one back.

use std::ffi::{c_char, CStr, CString}; unsafe extern "C" { fn abs(value: i32) -> i32; fn strlen(text: *const c_char) -> usize; } fn main() { let message = CString::new("hello").unwrap(); unsafe { println!("{}", abs(-7)); println!("{}", strlen(message.as_ptr())); } let back = unsafe { CStr::from_ptr(message.as_ptr()) }; println!("{}", back.to_str().unwrap()); }

rustc --edition 2024 libc_call.rs
./libc_call

7
5
hello

The three lines are a C call, a C function that takes a pointer and the conversion back to a Rust string. The c_char type is the C character type on the target, which is why the signature uses it instead of u8. The lesson on unsafe explains what the block unlocks.

A Rust function from C

#[unsafe(no_mangle)] keeps the symbol name, and extern "C" on the function gives it the C calling convention. Both are needed: the attribute alone keeps the name but not the convention, and the convention alone lets the compiler rename the symbol.

The crate is built as a cdylib, which is a shared library with no Rust metadata. The C program declares the function by hand, links against the library and prints the result.

#[unsafe(no_mangle)] pub extern "C" fn triple(value: i32) -> i32 { value * 3 }

rustc --edition 2024 --crate-type cdylib triple.rs -o libtriple.so
vi cmain.c
gcc cmain.c -L. -ltriple -o cmain
LD_LIBRARY_PATH=. ./cmain

#include <stdio.h>

int triple(int value);

int main(void)
{
printf("%d\n", triple(14));
return 0;
}

42

The library is called libtriple.so because the linker wants the lib prefix and the -l flag to agree. Without LD_LIBRARY_PATH the program would not find the library at run time, which is the usual first error of a shared-library build. The lesson on cdylib and staticlib crates does the same with cargo.

A struct across the boundary

A struct that crosses to C needs #[repr(C)], so both compilers agree on the offsets and on the size. The fields use the C types, and the C file declares the same struct.

The program passes a rectangle by value to a C function that multiplies the sides. The C file is compiled to an object file and handed to rustc as a link argument, which is the smallest form of a mixed build.

use std::ffi::c_int; #[repr(C)] struct Rect { width: c_int, height: c_int, } unsafe extern "C" { fn area(rect: Rect) -> c_int; } fn main() { let rect = Rect { width: 6, height: 7 }; println!("{}", unsafe { area(rect) }); }

vi rect.c
gcc -c rect.c -o rect.o
rustc --edition 2024 rect.rs -C link-arg=rect.o -o rect_run
./rect_run

typedef struct {
int width;
int height;
} Rect;

int area(Rect rect)
{
return rect.width * rect.height;
}

42

The struct is two integers in both languages, so the value crosses in two registers and the call needs no copy. Without repr(C) the compiler may reorder the two fields, and the C side would read the height as the width. The lesson on layout shows the reordering that makes the attribute necessary.

What the boundary promises

Four rules cover most of the boundary, and each one is a promise the compiler cannot check.

The signature must match the C function exactly: a wrong argument type is undefined behaviour, not an error. The strings must be NUL-terminated and valid UTF-8 before they become a Rust &str, which is why CStr and CString exist. The memory a C function returns must be freed by the same allocator that created it, so a malloc result goes back to free and never to Rust's Vec. A panic must not cross into C, because unwinding through a C frame is undefined behaviour: an exported function catches it or the crate is built with panic = "abort".

A safe wrapper keeps all four promises in one place. The public function takes a &str, builds the CString, calls the C function inside the unsafe block and returns a Rust value, so the caller never writes an unsafe block at all.

A call without the block

The declaration is safe to write and the call is not. Without the block the compiler reports E0133 and points at the call, with a note that the function may break the rules Rust relies on.

The program declares abs and calls it in safe code. The compiler stops at the call.

unsafe extern "C" { fn abs(value: i32) -> i32; } fn main() { println!("{}", abs(-7)); }

rustc --edition 2024 unsafe_err.rs

error[E0133]: call to unsafe function `abs` is unsafe and requires unsafe block
--> unsafe_err.rs:6:20
|
6 | println!("{}", abs(-7));
| ^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0133`.

The note is the whole contract in one sentence: the documentation of the C function is the only source of truth, and the unsafe block says it was read. The same rule applies to the attribute that exports a function: #[no_mangle] without unsafe(...) is error: unsafe attribute used without unsafe, which edition 2024 introduced for exactly this reason.

Exercises

  1. Call getpid from libc and print the number.
  2. Export a function that takes two integers and returns their larger value, and call it from a C program.
  3. Write a safe wrapper around strlen that takes a &str and returns a usize.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. Why is an extern "C" block unsafe since edition 2024?

    Answer

    A wrong signature is undefined behaviour the compiler cannot check

    The C side is outside Rust's type system, so the declaration is a promise.

  2. What does #[unsafe(no_mangle)] do?

    Answer

    Keeps the function name in the object file

    Without it the compiler would rename the symbol and the C linker would not find it.

  3. Why does a struct that crosses to C need #[repr(C)]?

    Answer

    So both compilers agree on offsets and size

    A Rust struct has no defined layout until the attribute fixes it.

  4. What owns the text passed to a C function as a char*?

    Answer

    A CString, which keeps the buffer alive for the call

    The CString owns the NUL-terminated copy, and it must outlive the call.

  5. What does this program print? use std::ffi::c_char; unsafe extern "C" { fn strlen(text: *const c_char) -> usize; } fn main() { let text = c"hello".as_ptr(); println!("{}", unsafe { strlen(text) }); }

    Answer

    5

    The five letters are counted and the terminating zero is not.

  6. What does this program print? use std::ffi::c_int; #[repr(C)] struct Pair { left: c_int, right: c_int, } fn main() { let pair = Pair { left: 20, right: 22 }; println!("{} {}", pair.left + pair.right, std::mem::size_of::<Pair>()); }

    Answer

    42 8

    Two C integers add to forty-two and take two four-byte slots.

  7. What does this program print? #[unsafe(no_mangle)] pub extern "C" fn twice(value: i32) -> i32 { value * 2 } fn main() { println!("{}", twice(21)); }

    Answer

    42

    An exported function is still an ordinary Rust function inside the crate.

  8. What does this program print? use std::ffi::CString; fn main() { let text = CString::new("rust").unwrap(); println!("{} {}", text.as_bytes().len(), text.as_bytes()[0] as char); }

    Answer

    4 r

    The four letters are the bytes of the string, and the first one is r.

  9. What is the first line of the compiler output for this program? unsafe extern "C" { fn abs(value: i32) -> i32; } fn main() { println!("{}", abs(-7)); }

    Answer

    error[E0133]: call to unsafe function abs is unsafe and requires unsafe block

    The call crosses into code the compiler cannot check, so it needs the block.

  10. What is the first line of the compiler output for this program? #[no_mangle] pub extern "C" fn triple(value: i32) -> i32 { value * 3 } fn main() {}

    Answer

    error: unsafe attribute used without unsafe

    Edition 2024 requires the attribute to be wrapped in unsafe(...).

The code on this page was built with rustc 1.96.1 and the C programs with gcc 15.2.0, and all of them were run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev