C uses T * everywhere. It can mean many different things:
void print_config(const struct Config *cfg);
void reset_config(struct Config *cfg);
int parse_packet(const uint8_t *data, size_t len, struct Packet *out);
void driver_write(void *addr, uint32_t value);
These all look like pointers, but the meanings differ:
- read-only access to an object
- mutation of a caller-provided object
- pointer plus length for a buffer
- output parameter
- nullable pointer
- hardware address or FFI-provided address
Rust does not put all of these into one pointer concept. Start with this table:
const T * -> &T / Option<&T>
T * -> &mut T / Option<&mut T>
const T * + len -> &[T]
T * + capacity -> &mut [T]
out parameter -> return value / &mut T / &mut [T]
void * -> generic, trait, raw pointer, or FFI context
real address / FFI ptr -> *const T / *mut T
The first layer is the split itself: ordinary library APIs should prefer references and slices, while raw pointers should stay close to FFI, hardware addresses, and low-level wrappers.
Read-only input uses &T
C uses const T * for read-only struct parameters:
void print_config(const struct Config *cfg)
{
printf("%u\n", cfg->port);
}
Rust usually uses a shared reference:
fn print_config(cfg: &Config) {
println!("{}", cfg.port);
}
&Config means the function can read Config but cannot mutate it.
Call site:
let cfg = Config { port: 1883 };
print_config(&cfg);
C still has to document whether cfg may be NULL. Rust &Config is not a null reference. If the function receives &Config, the caller must provide a valid object.
If “no config” is a valid state, use Option<&Config>:
fn print_config(cfg: Option<&Config>) {
if let Some(cfg) = cfg {
println!("{}", cfg.port);
}
}
Do not carry nullable pointer habits into ordinary Rust APIs.
Writable objects use &mut T
C mutates caller-provided objects with pointers:
void reset_config(struct Config *cfg)
{
cfg->port = 0;
}
Rust uses a mutable reference:
fn reset_config(cfg: &mut Config) {
cfg.port = 0;
}
The caller must explicitly pass a mutable reference:
let mut cfg = Config { port: 1883 };
reset_config(&mut cfg);
Two pieces of information are visible:
let mut cfgmeans the caller’s binding can change&mut cfgmeans writable access is passed to the function
C T * relies on names and comments to say whether mutation happens. Rust puts that into the parameter type.
Pointer plus length becomes a slice
C buffer parameters often look like this:
uint32_t checksum(const uint8_t *data, size_t len);
Rust uses a slice:
fn checksum(data: &[u8]) -> u32 {
data.iter().map(|b| *b as u32).sum()
}
&[u8] says two things:
- this is a contiguous sequence of
u8 - the function can read but not mutate it
Callers can pass arrays, Vecs, or slices:
let data = [1_u8, 2, 3];
let sum = checksum(&data);
let data = vec![1_u8, 2, 3];
let sum = checksum(&data);
In C, pointer and length are separate parameters and can disagree. A Rust slice binds them together.
Writable buffers use &mut [T]
C writes to an output buffer:
int fill_packet(uint8_t *out, size_t capacity, size_t *written);
The first Rust mapping is a mutable slice:
fn fill_packet(out: &mut [u8]) -> Result<usize, EncodeError> {
if out.len() < 4 {
return Err(EncodeError::TooSmall);
}
out[0] = 1;
out[1] = 2;
out[2] = 3;
out[3] = 4;
Ok(4)
}
&mut [u8] means the function can write to the buffer, but not beyond its slice length.
Call site:
let mut buf = [0_u8; 16];
let written = fill_packet(&mut buf)?;
This is more direct than uint8_t *out, size_t capacity: the buffer start and length live in one value.
Out parameters should often become return values
C uses out parameters for results:
int parse_u16(const uint8_t *data, size_t len, uint16_t *out);
Rust does not need to keep that shape:
fn parse_u16(data: &[u8]) -> Result<u16, ParseError> {
if data.len() < 2 {
return Err(ParseError::TooShort);
}
Ok(u16::from_be_bytes([data[0], data[1]]))
}
For multiple values, return a struct or tuple:
fn parse_field(data: &[u8]) -> Result<(u16, usize), ParseError> {
let value = parse_u16(data)?;
Ok((value, 2))
}
Prefer &mut output parameters when you must reuse a caller buffer, avoid allocation, or match a low-level API.
Raw pointers belong at boundaries
Rust has raw pointers:
*const u8
*mut u8
They are closer to C pointers: they may be null, may come from arbitrary addresses, and do not carry borrow-checking protection.
FFI declarations use them:
unsafe extern "C" {
fn sensor_read(out: *mut u8, capacity: usize) -> i32;
}
C-callable Rust boundaries also use them:
#[unsafe(no_mangle)]
pub extern "C" fn packet_checksum(ptr: *const u8, len: usize) -> u32 {
if ptr.is_null() {
return 0;
}
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
checksum(data)
}
The strategy is:
raw pointer at boundary -> check null and length -> convert to slice/reference -> use safe Rust internally
Do not make *const T and *mut T the default parameter types for ordinary Rust APIs. They belong to FFI, system calls, hardware addresses, and special low-level wrappers.
void * should not always become a raw pointer
C uses void * for generic context:
typedef void (*callback_t)(void *ctx, int event);
void register_callback(callback_t cb, void *ctx);
In ordinary Rust, *mut c_void is not always the right answer. Depending on the design, this may become a generic function:
fn handle_event<F>(mut callback: F)
where
F: FnMut(Event),
{
callback(Event::Ready);
}
Or a trait:
trait Handler {
fn handle(&mut self, event: Event);
}
At the C ABI boundary, *mut c_void is common:
use std::ffi::c_void;
type Callback = extern "C" fn(ctx: *mut c_void, event: i32);
The first rule is: C void * carries many abstraction jobs. Rust may use generics, traits, closures, or raw pointers depending on the boundary.
Minimal migration table
C const T * -> Rust &T
C nullable const T * -> Rust Option<&T> / raw pointer at FFI boundary
C T * for mutation -> Rust &mut T
C const T * + len -> Rust &[T]
C T * + capacity -> Rust &mut [T]
C out parameter -> Rust return value / Result<T, E> / &mut T
C void * context -> Rust generic / trait / closure / *mut c_void at FFI
C raw address -> Rust *const T / *mut T
C pointer API internally -> Rust boundary conversion to reference or slice
Rust does have pointers, but it splits the many jobs of C T * into more precise forms. Ordinary library APIs should prefer references and slices; raw pointers should stay close to boundaries.