Lesson 05 / 12
The Foreign Function Interface
Calling a C function whose body the compiler never sees, trusting only its declared signature; that the result of two foreign calls matches Rust's own counterpart is shown by running code, and that the correctness of the declaration is the writer's responsibility is explained.
Published Updated
In this lesson
- Explains that the compiler never sees the body of a function called through an extern "C" declaration, and trusts only the declared signature
- Proves, by comparing the result of two foreign calls with Rust's own counterpart, that trusting only the signature produces the correct result
- Explains with an example that the compiler cannot catch an incorrectly written declaration, and that correctness is the writer's responsibility
Contents
The previous lesson showed the guarantee delegated to an unsafe function’s signature:
get_second_item’s body was written in Rust, the compiler could read that body, it just
did not test the precondition on its own. This lesson carries the same delegation one step
further: calling a function whose body was not written in Rust, one the compiler never
sees at all. There, the contract is no longer a precondition sentence, only a signature —
the number of parameters, the parameter types, the return type.
This boundary exists for a practical reason: rewriting every function a program needs in its own language is not mandatory. Calls provided by the operating system, long-used numeric and text-processing libraries, hardware-specific drivers — a large share of these are written in C, and C’s binary compatibility convention serves as a shared boundary that belongs to no single language. Rust does not reinvent this boundary; it offers a form that can speak C’s calling convention directly and communicates with the other side without touching its body.
Calling by Trusting Only the Signature
Calling a C library function from Rust does not require seeing that function’s source, or
even its having been compiled on the same machine — common C functions are already linked
into the operating system’s own standard library. The Rust program only writes a
declaration: the function’s name, the types of the parameters it takes, the type of the
value it returns. This declaration is placed inside an extern "C" block; the "C" word
says which binary compatibility convention (in what order the parameters are carried on
the stack or in registers) the call will follow — Rust’s own functions can be compiled
with a different convention, and speaking to a C library requires this convention to be
explicitly switched to C’s.
Once the declaration is written, no extra compile step or link step is required: the
compiler searches the system library it always links the program against, for these two
names, and finds them. Parameter types are not made up by hand either; the std::os::raw
module defines names like c_int and c_char that correspond to the width of C’s int
and char types on this platform — these share the same memory layout as Rust’s own i32
or u8 types, but the naming makes it readable from the source that the declaration
matches the C side.
The boundary does not operate in only this direction. A Rust function can also be opened
outward with a signature that C can call; in that case the compiler leaves that function’s
name unchanged so the linker can find it under the name C expects. This lesson measures
only the direction from Rust to C — abs and strlen are both provided by the system,
the side the Rust program calls. The reverse direction rests on the same contract
convention, only which side writes the declaration in the source changes.
The declaration itself and calling the declared function do not carry the same status.
Writing an extern "C" block resembles constructing the raw pointer measured in the
previous lesson: the compiler only records a name and a signature, it has not yet crossed
to the other side. The danger begins when there is a genuine jump to where that name
points — which is why only the call requires an unsafe block, the declaration itself does
not.
// declaration.rs — the declaration itself does not require unsafe, calling it does
use std::os::raw::c_int;
extern "C" {
fn abs(x: c_int) -> c_int;
}
fn main() {
// Carrying the function as a pointer does not call it; unsafe is not required.
let fn_ptr: unsafe extern "C" fn(c_int) -> c_int = abs;
println!("declaration compiled, not called: {}", fn_ptr as usize != 0);
}
declaration compiled, not called: true
This run never called abs; it only carried the declared function into a variable and
confirmed that this variable was not empty (did not point to address zero) — the real
address itself was never printed anywhere. LL6. Compilation gave no warning on this
line because the declared function was genuinely used, only not called; an unused
declaration would have been flagged as dead code by the compiler. The declaration itself
not requiring unsafe is another example of the distinction the language draws between
saying “this name points somewhere” and saying “I am jumping there now” — the same
distinction as between constructing a raw pointer and reading it in the previous lesson.
Measuring Two Foreign Calls
The run below calls two foreign functions: abs, which takes an absolute value, and
strlen, which counts the length of a byte array. Neither has a body inside this program
— only a declaration. The results are compared against Rust counterparts that do the same
job.
LL7. The number whose absolute value is taken is -7; the string whose length is
measured is "merhaba" (seven bytes, carrying no multi-byte characters).
// foreign.rs — the result of two foreign calls and their rust counterpart
use std::ffi::CString;
use std::os::raw::{c_char, c_int};
extern "C" {
fn abs(x: c_int) -> c_int;
fn strlen(s: *const c_char) -> usize;
}
fn main() {
let abs_value = unsafe { abs(-7) };
let text = CString::new("merhaba").unwrap();
let length = unsafe { strlen(text.as_ptr()) };
println!("foreign abs: {} (rust counterpart {})", abs_value, (-7i32).abs());
println!("foreign length: {} (rust counterpart {})", length, "merhaba".len());
}
foreign abs: 7 (rust counterpart 7) foreign length: 7 (rust counterpart 7)
Both calls sit inside an unsafe block — but for a different reason than the unsafe in
the previous lesson. There, the danger was a read spilling past the slice’s bounds; here,
the danger is that the compiler knows nothing at all about the other side. The source code
of abs and strlen is nowhere in this program; the compiler produced the call without
ever seeing them, trusting only the two-line declaration above. LL8. The result
matched exactly on both lines: the foreign abs call returned 7, and Rust’s own abs
method also returned 7; the foreign strlen call returned 7, and Rust’s len method
also returned 7. The match is not a coincidence — it is the result of the declaration
matching the real C-side signature exactly.
The CString::new call here is a deliberate step: Rust’s own string type does not carry a
zero byte at the end and keeps its length in a separate counter; C’s string convention, by
contrast, is a byte array that ends with a zero byte. Calling strlen directly with a
Rust string would lead to a type mismatch at a boundary where the two conventions differ;
CString performs the translation between them, building a byte array that ends with a
zero byte, and passes only that array’s starting address to strlen.
Separate Rules on the Two Sides of the Boundary
In this lesson, only single integers and byte arrays cross the boundary; if a struct crossed the boundary, a further rule would come into play. Rust’s own struct layout engine can reorder fields however it likes during compilation — a freedom that does not change the program’s behavior, only affects its layout in memory. The C side, by contrast, guarantees that it places a struct’s fields one after another in declared order, following alignment rules. Even if both sides agree on a struct carrying the same names, if one reorders the fields while the other keeps the order fixed, the same memory block is read with two different interpretations. This lesson does not pass a struct, only an integer and a pointer; this part of the boundary is not measured here, it is only mentioned from the source as something that exists.
abs and strlen were chosen because both only read memory — neither allocates a new
memory block and hands it over to the Rust side. This shows one of the most important
restrictions at the FFI boundary: if a C function allocated memory inside itself and
returned a pointer, who releases that memory, and with which allocator, would become a
separate matter of contract. Rust’s own growable array and C’s malloc function are not
required to use the same allocator; trying to release a memory block allocated on the C
side with Rust’s own release rule mixes up the two separate allocators’ ledgers, and that
produces undefined behaviour. This lesson does not run that situation — abs returns an
integer, strlen returns a length, neither allocates new memory — but it is mentioned to
make this part of the boundary’s existence visible from the source.
Not every part of the boundary falls into the handed to the writer or outside the
language class; the CString::new call does not belong to either. C’s
zero-byte-terminated string convention means a string cannot carry a zero byte inside it —
the string ends the moment a zero byte is seen. CString::new tests at run time whether
the byte array it is given carries a zero byte inside it, and if it does, stops the call
not with a panic but with an error value.
// zero_byte.rs — CString::new checks for a zero byte inside the body
use std::ffi::CString;
fn main() {
let clean = CString::new("merhaba");
let broken = CString::new("me\0rhaba");
println!("clean string built: {}", clean.is_ok());
println!("zero byte inside the body rejected: {}", broken.is_err());
}
clean string built: true zero byte inside the body rejected: true
LL9. This is a different spot from the delegated part of the boundary in the strlen
call: here the checking is done not by the compiler but at run time, but it is still
done — the Result type gives the caller no permission to ignore the error. Not all of
the boundary is handed to the writer; an input that does not conform to C’s own format
rule is stopped before it ever reaches the foreign call.
The way errors are reported also differs on the two sides of the boundary. In Rust, an
operation’s failure is usually carried by a Result value, and the compiler checks
whether this value has been handled. C has no such type; a function’s failure is usually
marked by a special return value (for instance, a negative number or zero) or by a
separate global state. An extern "C" declaration does not remove this difference — if the
declared return type is c_int, knowing whether that value is an error code or an
ordinary result still depends on the caller reading the C side’s documentation; the
compiler cannot derive this distinction from the type, because the type is the same for
both.
Character encoding carries a separate trap concerning what the number strlen returns
means. Both Rust’s own len method and strlen count the same thing: the string’s
byte length — not how many characters the string visually consists of. Because the
"merhaba" used in this lesson consists only of single-byte characters, this distinction
did not show up; in a string carrying a character encoded with more than one byte, the two
numbers separate from each other.
// byte_char.rs — separating byte length from character count
use std::ffi::CString;
use std::os::raw::c_char;
extern "C" {
fn strlen(s: *const c_char) -> usize;
}
fn main() {
let text = "señor";
let bytes = CString::new(text).unwrap();
let foreign_length = unsafe { strlen(bytes.as_ptr()) };
println!("rust byte length: {}", text.len());
println!("rust character count: {}", text.chars().count());
println!("foreign strlen: {}", foreign_length);
}
rust byte length: 6 rust character count: 5 foreign strlen: 6
LL10. text.len() and the foreign strlen call matched exactly here too (6) — both
count bytes. What comes apart is the character count found with the chars().count()
method (5): the ñ letter in the string, though written as a single character in the
Rust source, takes two bytes in UTF-8 encoding. A C side that relies on strlen always
reads this number as bytes; a caller that mistakes it for a character count has proceeded
with a wrong assumption on a string that is not single-byte. The compiler cannot catch this
wrong assumption either — the return type is usize, and usize does not say whether it
carries a byte count or a character count.
The Limiting Measurement
Both calls working correctly depended on the declaration having been written correctly —
and the compiler never tested this correctness at any point. abs‘s real C signature
takes a single integer and returns a single integer; because the declaration wrote this
correctly, the call worked correctly. If the declaration had been written wrongly — for
instance, if abs had been declared to take two parameters or return a floating-point
number — the compiler would not have caught it. The compiler has no access to the other
side’s body; it only looks at the declaration written and produces a call that matches
that declaration. The result of a wrong declaration is not run in this lesson, because its
result is specific to this machine and falls into the undefined behaviour class — values
on the stack can be interpreted as the wrong type, the program can produce a wrong number,
or it can crash.
This is where the handed to the writer and outside the language classes intersect. Checking, as in the previous lesson, was handed to the writer — but what is handed over here is not a precondition sentence, it is a signature carrying no guarantee at all about the other side’s correctness. The other side itself was written outside Rust, compiled outside Rust’s compilation process, and already linked by the system; the compiler’s only knowledge of it is the two lines the programmer wrote. If the declaration is correct, the boundary between the two languages becomes invisible — the run above showed exactly this. If the declaration is wrong, it is not the compiler but only an observation at run time that can reveal where the error is.
The breadth of this boundary is measured not by a single wrong declaration but by every
field of the declaration. If the parameter count is wrong, the call shifts the positions
it reads from the stack or registers, and every parameter after it carries a wrong value
too. If a parameter type is not the correct width — for instance, if the real C signature
expects a four-byte integer while the declaration writes an eight-byte integer — the
caller ends up sending extra bytes the other side never reads, or sending too few. If the
return type is wrong, the bytes the call returns are interpreted according to the wrong
type. What all three cases share is that the compiler rejects none of them: an
extern "C" block is a syntactically valid declaration, and whether it matches the real
signature on the C side is not compilation’s concern.
Summary
- An
extern "C"block declares a function whose body is written outside Rust using only its signature; the compiler never sees that body, and its guarantee stays entirely outside the language - Common C library functions are already linked on the system; once the declaration is written, no extra compile or link step is required
- The result of two foreign calls (
abs,strlen) matched Rust’s own counterpart exactly — the match is the result of the declaration having been written correctly, matching the real C signature CStringcloses the convention gap between Rust’s length-counted string and C’s zero-byte-terminated array with an explicit conversion at the boundary- If the declaration had been written wrongly, the compiler could not have caught it; correctness is entirely the writer’s responsibility — this is the point where the handed to the writer and outside the language classes intersect
- Not every part of the boundary is handed over:
CString::newtests at run time whether there is a zero byte inside the body, and if there is, stops with an error value — this check, even though it happens at run time, still holds
Next Step
In this lesson, the boundary was between two languages; as long as the declaration was
correct, which machine it ran on did not matter — the abs and strlen calls produced the
same result no matter which processor or operating system the program ran on, because both
were linked to the system’s own standard library. The next lesson reverses this
independence: the same source needing a different body depending on the target it runs
on. There, the guarantee will not rest on a declaration but on the constraints the target
itself gives — and that constraint will be written not in an external library’s signature,
as in this lesson, but conditionally, in the source itself.
To keep your progress and take notes, Log in
My notes
Log in to take notes.