Skip to content

← Macros, FFI and Type-Driven Design step 22 of 28

Medium Framework

Calling C from Rust: libc is already linked

Call three C standard library functions from Rust. No crates, no build script, no linker flags, no Cargo.toml.

pub fn shout(s: &str) -> String
pub fn abs_sum(xs: Vec<i32>) -> i64
pub fn c_strlen_of(bytes: Vec<u8>) -> usize

The thing nobody tells beginners

FFI is usually presented as a chapter you read and do not practise, because “you’d need the libc crate and a build script”. That is false for the C standard library.

The Rust standard library itself links the platform C runtime — it has to, since std is built on top of the OS’s C interfaces. So the C standard library is already in your binary, and a plain rustc build can declare and call strlen, abs, toupper, sqrt, memcmp, qsort, malloc, snprintf with nothing but a declaration.

The libc crate is enormously useful — it gives you correct, per-platform declarations for thousands of functions and constants so you do not have to write them yourself. But it is a convenience, not a requirement, and knowing that turns FFI from theory into something you can execute one line at a time.

Declaring a foreign function

use std::ffi::{c_char, c_int};

unsafe extern "C" {
    fn abs(v: c_int) -> c_int;
    fn strlen(s: *const c_char) -> usize;
    fn toupper(c: c_int) -> c_int;
}

extern "C" fixes the calling convention (18.21). The unsafe on the block is edition 2024 and item 18.23’s subject; take it as given for now.

Three details in those three lines matter more than they look:

Use the c_* aliases, never concrete Rust types. c_int is 32 bits on every common target but the C standard does not guarantee it, and c_char is genuinely different: i8 on x86-64 Linux and macOS, u8 on ARM Linux. Write i8 and your code is wrong on a Raspberry Pi and right everywhere you tested it. The aliases cost nothing and are always correct.

A wrong signature is instant undefined behaviour with no diagnostic. The compiler has exactly one source of truth about strlen: your declaration. It does not read the C header, it cannot check anything, and it will emit a call that passes arguments the way you described. Declare strlen as taking an i32 and the program will happily truncate a pointer to 32 bits and hand the result to C. There is no error, no warning, and often no crash — until there is.

Do not lie about pointer types. malloc returns *mut c_void, not *mut u8. qsort‘s comparator takes *const c_void. Using the honest type is what lets rustc’s improper_ctypes lint help you (18.25).

The three functions

toupper(c_int) -> c_int. C’s contract is that the argument must be representable as an unsigned char, or EOF. Passing an arbitrary byte above 127 is implementation-defined at best. So uppercase only the ASCII bytes and pass everything else through — which also happens to keep the string valid UTF-8, since multi-byte sequences are left alone.

abs(c_int) -> c_int. Here is the trap, and it is a good one. abs(INT_MIN) is undefined behaviour in C: there is no positive int equal to 2147483648, so there is no correct answer, and the standard declines to specify one. The C library does not check. Rust cannot check. Your wrapper has to.

This is worth pausing on because it generalises: crossing into C means taking on C’s preconditions, and those preconditions are in prose in a standards document, not in any type. abs_sum must handle i32::MIN itself.

(Note that i32::abs in Rust has the same problem stated differently: it panics in debug and wraps in release. Compilation here uses -O, so overflow checks are off — see 18.26.)

strlen(*const c_char) -> usize. The one that shows you what a C string is. Build a byte buffer, push a 0, and hand C a pointer to it. C walks forward until it finds the nul and reports the distance.

Give it [104, 105, 0, 120] — “hi”, nul, “x” — and it answers 2, not 4. A Rust &[u8] knows its own length; a C string does not, and stops at the first nul it meets. That single asymmetry is the source of a large fraction of all C security bugs, and of all of item 18.25.

Safe wrappers, not pub unsafe fn

All three of your functions are safe. Every unsafe block is small, and each one is preceded by a // SAFETY: comment saying which precondition you are discharging.

That is not a stylistic preference; the gate enforces it. If you write

pub unsafe fn shout(s: &str) -> String

then clippy’s missing_safety_doc requires a # Safety section in the doc comment explaining what the caller must guarantee — and you would have nothing to put in it, because there is nothing the caller can get wrong. Making a function unsafe when its safety is entirely your responsibility pushes the burden onto every caller for no reason.

The related lint not_unsafe_ptr_arg_deref (which you will meet properly in 18.24) is the other half of the rule: a safe public function must not take a raw pointer and dereference it, because a caller has no way to satisfy the precondition. Safe wrapper around unsafe internals, or unsafe fn with a documented contract. Not a safe function with a hidden landmine.

::: question Why does the starter fail with E0133 rather than just working?

Because calling a foreign function is unsafe, and edition 2024 will not let that slide.

error[E0133]: call to unsafe function `toupper` is unsafe and requires
              unsafe function or block

The compiler cannot verify anything about toupper — it has no body to analyse, only your declaration. Whether the call is sound depends on whether the declaration matches reality, and only you know that. unsafe { } is where you say so.

E0133 is the most common error in FFI work and it is a good one: it is impossible to call foreign code by accident. Item 18.23 shows the edition-2024 refinement, where a declaration can be marked safe so that particular function needs no block — because for some functions, sqrt for instance, there genuinely is no precondition. :::

Your job

shout is written but does not compile. abs_sum and c_strlen_of are todo!().