Primitive types in FFI
So far, the exercises have passed data between C and Rust mostly as pointers and plain integers. The following sections cover how other types cross the FFI boundary, starting with primitive types, then structs, enums and unions, slices, and opaque types.
A C header describes its parameters in C’s own types: int, long, char,
size_t. Rust has i32, i64, u8, usize. Writing a binding means pairing
them up, and most of those pairs work on every platform you’re likely to target.
Take this function:
int scale(int value, int factor);
On Windows, macOS, and Linux, on x86-64 and aarch64, an int is the same as an
i32 in Rust. If you only target those platforms, the Rust code looks like
this:
unsafe extern "C" {
fn scale(value: i32, factor: i32) -> i32;
}
The mapping
Here is how the common primitive types map from C to Rust when targeting Windows, macOS, or Linux on x86-64 or aarch64:
| C type | Rust type | Alias |
|---|---|---|
char | i8 (or u8 on aarch64 Linux) | c_char |
signed char | i8 | c_schar |
unsigned char | u8 | c_uchar |
short | i16 | c_short |
unsigned short | u16 | c_ushort |
int | i32 | c_int |
unsigned int | u32 | c_uint |
long | i64 (or i32 on Windows) | c_long |
unsigned long | u64 (or u32 on Windows) | c_ulong |
long long | i64 | c_longlong |
unsigned long long | u64 | c_ulonglong |
float | f32 | c_float |
double | f64 | c_double |
size_t | usize | – |
ptrdiff_t | isize | – |
int32_t, uint64_t, … | i32, u64, … | – |
void * | *mut c_void | – |
T * | *mut T | – |
const T * | *const T | – |
The names in the last column are Rust type aliases for each C type. Each alias stands for whichever Rust type matches that C type on the platform you compile for.
They are defined in core::ffi, and std::ffi re-exports them, so
std::ffi::c_int and core::ffi::c_int are the same type. You’ll also see
std::os::raw::c_int in bindgen output and libc::c_int from the libc
crate. All of them resolve to the same alias.
The aliases are for code that targets platforms where a fixed-width Rust type
doesn’t match. On MSP430 or AVR, for example, C’s int is 16 bits, so c_int
is i16. If your code has to build there as well as on x86-64, where c_int is
an i32, the alias is the right option.
c_char and u8
A C char is a byte, and Rust’s byte type is u8, so writing *const u8 in a
binding where the header says const char * looks harmless, and it’s the most
common mistake in hand-written bindings: c_char is not always u8. It is i8
on x86-64, and on macOS and Windows for aarch64. It is u8 on aarch64 Linux, on
32-bit ARM, and on RISC-V.
The u8 binding might just work on your own machine, which is what makes this
easy to miss. Then someone builds for a target where c_char is an i8 and the
code stops compiling. Anything that hands you a c_char no longer lines up with
the u8 you wrote: CStr::as_ptr gives a *const c_char, and passing it where
a *const u8 is expected is now a type mismatch. So write c_char in the
signature.
The second issue is that c_char is not the type Rust’s byte methods work on.
b'a' literals, u8::to_ascii_lowercase, str::as_bytes, and CStr::to_bytes
all speak u8. So cast a c_char to u8 as soon as you have the value, do the
work there, and cast back on the way out:
use std::ffi::c_char;
/// Lowercases `byte` if it is an ASCII letter, and returns it unchanged
/// otherwise.
#[unsafe(no_mangle)]
pub extern "C" fn lower_byte(byte: c_char) -> c_char {
(byte as u8).to_ascii_lowercase() as c_char
}
Two details about those casts are worth naming:
- They change the type, not the value.
asbetweenc_charandu8keeps the bit pattern exactly, and casting back gives you the same byte again. - They are allowed because the layouts match.
c_charandu8have the same size and the same alignment on every target, and every bit pattern is valid for both. That makes the cast purely a compile time check.
Head to the exercise
The exercise gives you a C library over bytes with its bindings already written, and two small wrappers to finish.
Exercise
The exercise for this section is located in 02_basics/02_primitives