Skip to main contentPointers, References, and Raw Pointers | IoT Worker

Pointers, References, and Raw Pointers

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 cfg means the caller’s binding can change
  • &mut cfg means 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.

Continue Reading