C commonly uses bit flags:
#define FLAG_READ (1u << 0)
#define FLAG_WRITE (1u << 1)
#define FLAG_ERROR (1u << 2)
uint32_t flags = FLAG_READ | FLAG_WRITE;
if (flags & FLAG_ERROR) {
handle_error();
}
Rust can do the same bit operations, but one habit must change:
C: if (flags & FLAG_ERROR)
Rust: if flags & FLAG_ERROR != 0
Rust if conditions must be bool. Integers are not implicitly truthy or falsy.
Bit Flags Can Be const
C often defines masks with macros:
#define FLAG_READ (1u << 0)
#define FLAG_WRITE (1u << 1)
#define FLAG_ERROR (1u << 2)
Rust can use const:
const FLAG_READ: u32 = 1 << 0;
const FLAG_WRITE: u32 = 1 << 1;
const FLAG_ERROR: u32 = 1 << 2;
Combine flags:
let flags = FLAG_READ | FLAG_WRITE;
Test a flag:
if flags & FLAG_ERROR != 0 {
handle_error();
}
Set, clear, and toggle:
let mut flags = 0_u32;
flags |= FLAG_READ; // set
flags &= !FLAG_WRITE; // clear
flags ^= FLAG_ERROR; // toggle
Rust uses ! for bitwise not, not C’s ~.
Hide Bit Operations Behind Functions or Methods
Passing raw u32 everywhere has the same problem as passing uint32_t flags everywhere in C: meaning leaks across the codebase. Start by collecting the check:
fn has_flag(flags: u32, flag: u32) -> bool {
flags & flag != 0
}
if has_flag(flags, FLAG_READ) {
read();
}
If the flags are a stable domain concept, wrap them in a newtype:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Permissions(u32);
impl Permissions {
const READ: u32 = 1 << 0;
const WRITE: u32 = 1 << 1;
const EXECUTE: u32 = 1 << 2;
fn empty() -> Self {
Self(0)
}
fn from_bits(bits: u32) -> Self {
Self(bits)
}
fn bits(self) -> u32 {
self.0
}
fn contains(self, flag: u32) -> bool {
self.0 & flag != 0
}
fn insert(&mut self, flag: u32) {
self.0 |= flag;
}
fn remove(&mut self, flag: u32) {
self.0 &= !flag;
}
}
Use it like this:
let mut p = Permissions::empty();
p.insert(Permissions::READ);
if p.contains(Permissions::READ) {
println!("can read");
}
This is easier to maintain than scattered flags & (1 << n). The raw integer is still available through bits() for files, protocols, or FFI.
Multi-Bit Fields Use Masks and Shifts
C often packs multi-bit fields into an integer:
#define MODE_MASK (0x3u << 4)
#define MODE_SHIFT 4
uint32_t mode = (reg & MODE_MASK) >> MODE_SHIFT;
reg = (reg & ~MODE_MASK) | ((new_mode << MODE_SHIFT) & MODE_MASK);
Rust is similar:
const MODE_MASK: u32 = 0x3 << 4;
const MODE_SHIFT: u32 = 4;
fn mode(reg: u32) -> u32 {
(reg & MODE_MASK) >> MODE_SHIFT
}
fn set_mode(reg: u32, value: u32) -> u32 {
(reg & !MODE_MASK) | ((value << MODE_SHIFT) & MODE_MASK)
}
If the value has a valid range, check it:
fn set_mode(reg: u32, value: u32) -> Option<u32> {
if value > 0x3 {
return None;
}
Some((reg & !MODE_MASK) | (value << MODE_SHIFT))
}
C code often relies on the caller to keep value in range. Rust can put the failure path into the function signature.
Do Not Copy C bit-field Layout Directly
C has bit-fields:
struct Control {
unsigned enable : 1;
unsigned mode : 2;
unsigned error : 1;
};
They look useful for hardware registers and protocol headers, but their layout details are a poor cross-language contract. Allocation order, alignment, and storage unit rules depend on compiler, ABI, and target.
Rust does not have stable C-style struct bit-fields. For external binary layouts, prefer an explicit-width integer plus methods:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Control(u32);
impl Control {
const ENABLE: u32 = 1 << 0;
const MODE_MASK: u32 = 0x3 << 1;
const MODE_SHIFT: u32 = 1;
const ERROR: u32 = 1 << 3;
fn from_bits(bits: u32) -> Self {
Self(bits)
}
fn bits(self) -> u32 {
self.0
}
fn enabled(self) -> bool {
self.0 & Self::ENABLE != 0
}
fn mode(self) -> u32 {
(self.0 & Self::MODE_MASK) >> Self::MODE_SHIFT
}
fn has_error(self) -> bool {
self.0 & Self::ERROR != 0
}
}
This makes the contract explicit: the external data is a u32, and fields are defined by masks and shifts.
Protocols and Files Need Endianness
If a bit field comes from a packet or file, parse byte order first:
fn parse_control(data: &[u8]) -> Option<Control> {
if data.len() < 4 {
return None;
}
let raw = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
Some(Control::from_bits(raw))
}
Do not cast byte arrays directly into a struct bit-field. The real concerns are:
- length checks
- endianness
- alignment
- padding
- compiler bit-field layout differences
Explicit parsing is a few more lines, but the rules are not hidden inside compiler layout.
FFI Should Keep the C Representation Clear
If a C API receives integer flags:
int open_device(uint32_t flags);
The Rust FFI declaration should start with the integer:
unsafe extern "C" {
fn open_device(flags: u32) -> i32;
}
Rust internals can wrap it:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct OpenFlags(u32);
impl OpenFlags {
const READ: u32 = 1 << 0;
const WRITE: u32 = 1 << 1;
fn bits(self) -> u32 {
self.0
}
}
At the boundary, extract the bits:
let flags = OpenFlags(OpenFlags::READ | OpenFlags::WRITE);
let rc = unsafe { open_device(flags.bits()) };
The boundary uses the integer C understands; the Rust interior uses a type that carries meaning.
What to Keep
The first mapping is:
#define FLAG (1u << n) -> const FLAG: u32 = 1 << n
flags & FLAG as condition -> flags & FLAG != 0
set / clear / toggle -> |= / &= ! / ^=
C bit-field -> explicit-width integer + mask/shift methods
protocol/file bit fields -> handle endianness, then expose field methods
FFI flags -> u32 at ABI boundary, optional newtype inside
Rust does not remove bit operations. It asks you to make truth conditions, field widths, byte order, and layout boundaries explicit. For library APIs, that is usually a win: callers see contains(), mode(), and bits() instead of scattered magic masks.