In C, a binary buffer is often represented as pointer plus length:
int parse_packet(const uint8_t *buf, size_t len);
This pattern is flexible, but it carries risk. Whether buf may be null, whether len is trustworthy, whether the function mutates data, whether it stores the pointer, and when the caller may free memory are all conventions outside the type.
Rust does not remove this pattern. It splits it into more precise types:
read-only view: &[u8]
writable view: &mut [u8]
owned buffer: Vec<u8>
fixed array: [u8; N]
This article is only about binary buffers. Strings, String, and &str come later.
Pointer Plus Length Says Too Much
Start with a C function:
#include <stdint.h>
#include <stddef.h>
uint16_t checksum(const uint8_t *buf, size_t len)
{
uint32_t sum = 0;
for (size_t i = 0; i < len; i++) {
sum += buf[i];
}
return (uint16_t)(sum & 0xffff);
}
The signature suggests the function will not mutate the buffer because the pointer is const uint8_t *. But much is still outside the type:
- whether
buf == NULLis allowed - whether
lenreally matches accessible memory - whether
buf + lenis out of bounds - whether the function stores the pointer elsewhere
- whether another thread may mutate the memory during the call
C types express part of the contract. The rest is calling convention.
&[u8] Is a Read-Only View
In Rust, the function can be written as:
fn checksum(buf: &[u8]) -> u16 {
let mut sum: u32 = 0;
for byte in buf {
sum += *byte as u32;
}
(sum & 0xffff) as u16
}
You can first read &[u8] as:
read-only view over u8 data = pointer + length + read-only access
It is close to const uint8_t *buf, size_t len, but tighter:
- length is attached to the view
- iteration knows the boundary
- the function cannot mutate through
&[u8] - the function borrows the memory instead of owning it
The caller can pass an array, a Vec<u8>, or part of a larger buffer:
let data = [1u8, 2, 3, 4];
let sum = checksum(&data);
let packet = vec![0x10, 0x20, 0x30];
let sum = checksum(&packet);
Both &data and &packet become slice views. The function sees contiguous bytes and does not care whether the storage came from an array or a Vec.
&mut [u8] Is an Exclusive Writable View
If a C function needs to mutate a buffer, it usually drops const:
void xor_in_place(uint8_t *buf, size_t len, uint8_t key)
{
for (size_t i = 0; i < len; i++) {
buf[i] ^= key;
}
}
The Rust version uses:
fn xor_in_place(buf: &mut [u8], key: u8) {
for byte in buf {
*byte ^= key;
}
}
&mut [u8] is not just a writable pointer. It means an exclusive writable borrow of a buffer region.
The first engineering model is:
&[u8] read only
&mut [u8] writable, with no conflicting access during the borrow
Borrowing will be covered later. For now, keep the useful part: Rust puts “may this function mutate the buffer?” into the parameter type.
Vec Owns a Growable Buffer
In C, a function that creates data and returns it often uses patterns like:
uint8_t *make_packet(size_t *out_len);
Or lets the caller provide storage:
int make_packet(uint8_t *out, size_t capacity, size_t *out_len);
Both require extra rules: who allocates, who frees, whether capacity is enough, and whether output parameters are valid on failure.
In Rust, a function that creates and returns bytes usually uses Vec<u8>:
fn make_packet(payload: &[u8]) -> Vec<u8> {
let mut packet = Vec::new();
packet.push(0x01);
packet.push(payload.len() as u8);
packet.extend_from_slice(payload);
packet
}
You can first read Vec<u8> as:
owned contiguous byte buffer = pointer + length + capacity
It resembles a dynamically allocated C buffer, but ownership is in the type. Returning Vec<u8> gives ownership of the allocation to the caller. The caller does not call free; the value releases memory when it leaves scope.
len and capacity Are Different
C code that writes into caller-provided storage often tracks both “used length” and “maximum capacity”:
int encode(uint8_t *out, size_t capacity, size_t *out_len);
Rust Vec has both concepts:
let mut buf = Vec::with_capacity(1024);
println!("len = {}", buf.len());
println!("cap = {}", buf.capacity());
len is the number of valid elements. capacity is how many elements the current allocation can hold.
buf.push(1);
buf.push(2);
push increases len. If capacity is not enough, Vec reallocates and moves the data.
This matters for library and FFI work. If external code keeps an old raw pointer, reallocation can invalidate it. Safe Rust code makes this kind of relationship hard to keep accidentally; FFI and unsafe boundaries need extra care.
Fixed Length Uses Arrays
If the length is known at compile time, use an array:
let magic: [u8; 4] = [0x12, 0x34, 0x56, 0x78];
[u8; 4] and [u8; 8] are different types because the length is part of the type.
This is close to C arrays, but more explicit. Fixed headers, magic numbers, short keys, and fixed-width fields are natural array use cases.
When passing an array to a read-only function, it can become a slice:
let sum = checksum(&magic);
The common relationship is:
[u8; N] fixed-size array, owns data
&[u8] read-only slice, borrows data
&mut [u8] writable slice, exclusively borrows data
Vec<u8> growable buffer, owns data
&[u8] Is Not a Weaker Vec
The main difference between &[u8] and Vec<u8> is not just growth. It is ownership.
If a function only reads bytes, prefer &[u8]:
fn parse_packet(input: &[u8]) -> Result<(), &'static str> {
if input.len() < 2 {
return Err("packet too short");
}
let version = input[0];
let length = input[1] as usize;
if input.len() < 2 + length {
return Err("payload too short");
}
Ok(())
}
The caller can pass an array, a Vec, bytes read from a file, or part of a larger buffer. The function does not care where the data came from and does not take ownership.
If a function creates and returns bytes, use Vec<u8>:
fn encode_packet(payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(2 + payload.len());
out.push(1);
out.push(payload.len() as u8);
out.extend_from_slice(payload);
out
}
That API boundary is usually better than using Vec<u8> everywhere.
What to Keep from This Article
C pointer plus length becomes several Rust types, split by access and ownership:
const uint8_t *buf, size_t len -> &[u8]
uint8_t *buf, size_t len -> &mut [u8]
malloc-owned growable buffer -> Vec<u8>
uint8_t buf[N] -> [u8; N]
The important change is that Rust separates length, access permission, and ownership.
Use &[u8] to read, &mut [u8] to mutate in place, Vec<u8> to own and return data, and [u8; N] for fixed-size data.
The next article is about strings. It will separate C char *, NUL termination, byte strings, UTF-8 text, and Rust String / &str.