The border problem
You are wrapping a C library in Rust. You pass a filename to fopen. The C function segfaults deep inside the standard library. You check your Rust string. It looks perfect. "config.txt". No weird characters. The problem isn't your string. It's the boundary.
C expects a null byte at the end of every string. Rust strings don't have one. Worse, C strings cannot contain a null byte in the middle. If your Rust string has a null byte, you are handing C a lie. C will think the string ended early, or it will read past the end of your buffer until it finds a random zero byte in memory.
Rust strings are length-prefixed. A &str is a pointer and a length. C strings are null-terminated. A C string is just a pointer. The length is found by scanning for \0. This mismatch is where FFI bugs hide. CString and CStr exist to bridge this gap safely.
CString: packing for C
CString is the type you use when sending data from Rust to C. It owns the memory. It ensures the data is valid C before you cross the border.
When you call CString::new, Rust scans your string for internal null bytes. If it finds one, it returns an error. You cannot create a CString with a null byte inside. This prevents you from passing invalid data to C. If the scan passes, CString allocates memory on the heap, copies your string, and appends a null byte.
The allocation looks like this: [H][e][l][l][o][\0]. The length is implicit. C will read until it hits the \0.
CString implements Drop. When the CString goes out of scope, the memory is freed. This is crucial. If you pass a pointer from CString to C, and the CString drops while C is still using the pointer, you have a dangling pointer. C will read freed memory. That is undefined behavior.
use std::ffi::CString;
use std::os::raw::c_char;
fn prepare_for_c(rust_str: &str) -> Result<CString, std::ffi::NulError> {
// CString::new returns a Result. It fails if rust_str contains a null byte.
// This check happens at runtime, not compile time.
let c_string = CString::new(rust_str)?;
// Convention: use expect with a message in main or top-level code.
// The error message tells you exactly which string failed.
// c_string.expect("Filename contained a null byte");
Ok(c_string)
}
fn main() {
let safe_str = "hello";
let c_str = prepare_for_c(safe_str).unwrap();
// as_ptr returns a *const c_char.
// The CString still owns the memory.
let ptr: *const c_char = c_str.as_ptr();
// You can pass ptr to C here.
// The memory is valid as long as c_str is alive.
println!("Pointer: {:?}", ptr);
}
Treat the null terminator as the border guard. Without it, C walks right off the edge of your memory.
CStr: reading from C
CStr is the type you use when receiving data from C. It is a borrowed view of C string data. It does not own the memory. It wraps a pointer and treats the data as a null-terminated string.
You create a CStr using CStr::from_ptr. This function is unsafe. Rust cannot verify that the pointer is valid, that it points to a null-terminated string, or that the data won't change while you read it. You must guarantee these things.
CStr provides methods to convert the data back to Rust types. to_str returns a Result<&str, Utf8Error>. It checks if the bytes are valid UTF-8. If they are not, it returns an error. C strings are bytes, not necessarily text. They might be Latin-1, UTF-16, or binary data. to_str fails on anything that isn't UTF-8.
to_string_lossy returns a Cow<str>. It replaces invalid UTF-8 sequences with the replacement character �. This is useful for logging or display where you want to see the data even if it's malformed. to_bytes returns the raw bytes without the null terminator. to_bytes_with_nul includes the null terminator.
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
fn read_from_c(c_ptr: *const c_char) -> String {
// SAFETY:
// 1. c_ptr is valid and points to allocated memory.
// 2. The memory is null-terminated within the bounds.
// 3. The data is not mutated while CStr is used.
let c_str = unsafe { CStr::from_ptr(c_ptr) };
// to_string_lossy handles non-UTF8 data gracefully.
// Use to_str if you need strict UTF-8 validation.
c_str.to_string_lossy().into_owned()
}
fn main() {
let rust_str = "Hello from Rust";
let c_string = CString::new(rust_str).unwrap();
// Simulate receiving a pointer from C.
let ptr = c_string.as_ptr();
let result = read_from_c(ptr);
println!("{}", result);
}
Trust the borrow checker here. CStr borrows the pointer. If the underlying memory is freed, the CStr becomes invalid. Keep the source alive.
The ownership transfer pattern
Sometimes C needs to own the string. You pass a pointer to C, and C stores it for later use. C will free the memory when it's done. In this case, you cannot use CString directly. If CString drops, it frees the memory, and C gets a dangling pointer.
Use CString::into_raw. This converts the CString into a raw pointer and leaks the memory. The memory is not freed. You must reclaim it later using CString::from_raw. If you forget to call from_raw, you leak memory.
This pattern is dangerous. It requires manual memory management. Use it only when the C API demands ownership transfer.
use std::ffi::CString;
use std::os::raw::c_char;
fn transfer_ownership(rust_str: &str) -> *mut c_char {
let c_string = CString::new(rust_str).expect("Null byte in string");
// into_raw consumes the CString and returns a *mut c_char.
// The memory is leaked. Rust will not free it.
// You must call CString::from_raw later to reclaim it.
c_string.into_raw()
}
fn reclaim_ownership(c_ptr: *mut c_char) {
// SAFETY:
// 1. c_ptr was created by CString::into_raw.
// 2. c_ptr has not been freed yet.
// 3. c_ptr is aligned and valid.
unsafe {
// from_raw reclaims the pointer and converts it back to a CString.
// The CString will free the memory when it drops.
let _c_string = CString::from_raw(c_ptr);
}
}
fn main() {
let ptr = transfer_ownership("owned by C");
println!("Pointer handed to C: {:?}", ptr);
// Simulate C finishing with the string.
reclaim_ownership(ptr);
}
If you call into_raw, you are the garbage collector now. Write the from_raw call immediately, or leak.
Pitfalls and compiler errors
Internal null bytes
CString::new fails if the string contains a null byte. This is a runtime error, not a compile-time error. If you unwrap blindly, your program panics. Handle the Result.
Convention: Use expect with a descriptive message. "Input contained null byte" is better than "called Result::unwrap() on an Err value".
Dangling pointers
Passing as_ptr to C and letting CString drop is a common mistake. The pointer becomes invalid. C reads garbage.
Solution: Keep the CString alive for the duration of the C call. Or use into_raw if C takes ownership.
UTF-8 assumptions
C strings are bytes. They are not UTF-8. CStr::to_str panics if the data is not valid UTF-8. Use to_string_lossy for display. Use to_bytes if you need raw bytes.
c_char portability
Always use std::os::raw::c_char. Do not use i8 or u8 directly. c_char is i8 on most platforms, but u8 on some Windows targets. Using the wrong type causes type mismatches and potential sign-extension bugs.
If you try to pass a &str directly to a C function expecting *const c_char, the compiler rejects you with E0308 (mismatched types). Rust strings are not pointers. They are fat pointers with a length. You must convert them.
CStr::from_ptr on invalid data
Calling CStr::from_ptr on a dangling pointer, an unaligned pointer, or a pointer to non-null-terminated data is undefined behavior. The compiler cannot catch this. You must verify the pointer before calling from_ptr.
Treat the SAFETY comment as a proof. If you can't write the invariants, you don't have one.
Realistic example: calling a C function
Here is a complete example of calling a C function that takes a string and returns an integer. The C function signature is declared using extern "C". The Rust code handles the conversion and error handling.
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
// Declare the C function.
// The ABI is "C". The argument is a pointer to c_char.
extern "C" {
fn process_name(name: *const c_char) -> c_int;
}
/// Calls the C function process_name with a Rust string.
/// Returns the result from C, or an error if the string is invalid.
fn call_process_name(name: &str) -> Result<i32, std::ffi::NulError> {
// CString::new validates the string and allocates memory.
// It returns Err if name contains a null byte.
let c_name = CString::new(name)?;
// Call the C function.
// c_name is alive during the call, so the pointer is valid.
let result = unsafe {
// SAFETY:
// 1. c_name.as_ptr() returns a valid pointer to null-terminated data.
// 2. process_name is declared with the correct signature.
// 3. process_name does not store the pointer beyond the call.
process_name(c_name.as_ptr())
};
Ok(result)
}
fn main() {
match call_process_name("Alice") {
Ok(code) => println!("C returned: {}", code),
Err(e) => eprintln!("Invalid string: {:?}", e),
}
}
Don't fight the compiler here. Reach for CString and CStr. They handle the heavy lifting.
Decision matrix
Use CString::new when you need to pass a Rust string to C and want the compiler to verify there are no internal null bytes. Use CString::into_raw when you must hand ownership of the string to C code that will free it later, and you have a matching CString::from_raw call to reclaim it. Use CStr::from_ptr when you receive a pointer from C and need to read it as a Rust string slice, ensuring the pointer is valid and null-terminated. Use CStr::from_bytes_with_nul when you have a byte slice in Rust that represents a C string and you want to parse it without crossing the FFI boundary. Reach for &str when you are staying entirely within Rust; crossing the FFI boundary always requires conversion.
Counter-intuitive but true: the more you use unsafe, the harder the rest of your code becomes to reason about. Keep the unsafe blocks small and isolated.