C 程序员看 struct,通常会自然想到内存布局:
#include <stdint.h>
struct Header {
uint8_t version;
uint32_t length;
uint16_t flags;
};
字段按声明顺序摆放,中间可能插入 padding,结构体整体也有对齐要求。这个直觉在写协议、文件格式、驱动寄存器映射和 FFI 时很重要。
Rust 也有 struct,但第一层要分清两件事:
Rust 普通 struct:表达程序内部的数据模型
#[repr(C)] struct:表达需要和 C ABI 对齐的内存布局
名字一样,不代表默认布局一样。普通 Rust 代码里,先把结构体当成“有名字的字段集合”;到 C ABI、二进制格式和跨语言边界时,再明确谈布局。
字段和初始化
C 里定义和初始化结构体:
struct Config {
uint16_t port;
uint8_t retry;
};
struct Config cfg = {
.port = 1883,
.retry = 3,
};
Rust 写法很接近:
struct Config {
port: u16,
retry: u8,
}
let cfg = Config {
port: 1883,
retry: 3,
};
字段访问也直接:
println!("{}", cfg.port);
如果要修改字段,绑定本身要是可变的:
let mut cfg = Config {
port: 1883,
retry: 3,
};
cfg.retry = 5;
这一点和 C 的“变量是否 const”有点像,但 Rust 的 mut 说的是这个绑定能不能被改,不是改变类型本身的 ABI 或内存布局。
方法不放在 struct 里面
C 里结构体只放数据,相关函数通常靠命名前缀组织:
void config_reset(struct Config *cfg)
{
cfg->retry = 0;
}
Rust 的数据定义和方法实现分开写:
struct Config {
port: u16,
retry: u8,
}
impl Config {
fn reset(&mut self) {
self.retry = 0;
}
}
调用时:
let mut cfg = Config {
port: 1883,
retry: 3,
};
cfg.reset();
impl 不会把函数塞进结构体对象里。Config 的内存里仍然只有字段。方法只是和这个类型关联的函数,self、&self、&mut self 分别对应拿走对象、只读借用、可写借用。
默认布局不是 C 布局
C 结构体的字段顺序和 ABI 布局是语言和平台 ABI 的一部分。Rust 默认结构体是 repr(Rust):
struct Header {
version: u8,
length: u32,
flags: u16,
}
不要把它默认理解成 C 的:
struct Header {
uint8_t version;
uint32_t length;
uint16_t flags;
};
Rust 默认布局不承诺字段顺序、padding 或 C ABI 兼容性。编译器可以为了优化选择布局。普通 Rust 代码不应该依赖这些细节。
这不是说 Rust 结构体“没有布局”。它当然会在内存中占空间,也有大小和对齐。问题是:默认布局不是给你当跨语言 ABI 或外部二进制格式用的稳定契约。
需要 C ABI 时写 repr(C)
如果结构体要和 C 交互,应该明确写:
#[repr(C)]
struct Header {
version: u8,
length: u32,
flags: u16,
}
#[repr(C)] 表示按 C ABI 风格布局字段,适合这些场景:
- C 调用 Rust 导出的函数,参数里有这个结构体
- Rust 调用 C 函数,需要传入或接收这个结构体
- Rust 类型要和 C 头文件里的
struct对齐 - 结构体布局本身就是 ABI 的一部分
FFI 里常见写法:
#[repr(C)]
pub struct SensorReading {
pub temperature: i32,
pub humidity: u16,
pub status: u8,
}
#[unsafe(no_mangle)]
pub extern "C" fn sensor_read(out: *mut SensorReading) -> i32 {
if out.is_null() {
return -1;
}
unsafe {
*out = SensorReading {
temperature: 2350,
humidity: 60,
status: 0,
};
}
0
}
这里用 pub 是因为字段也要暴露给 C 侧含义。repr(C) 管布局,pub 管 Rust 模块可见性,它们不是一回事。
padding 仍然存在
repr(C) 不会取消 padding。它只是按 C ABI 规则处理 padding。
这个类型:
#[repr(C)]
struct Header {
version: u8,
length: u32,
flags: u16,
}
不是简单的 1 + 4 + 2 = 7 字节。为了让 length 按 u32 对齐,中间可能会有填充字节。结构体末尾也可能补齐到整体对齐。
Rust 里看大小和对齐:
use std::mem::{align_of, size_of};
fn main() {
println!("size = {}", size_of::<Header>());
println!("align = {}", align_of::<Header>());
}
这对应 C 里的 sizeof 和对齐直觉。做 FFI 时,Rust 侧和 C 侧都应该检查,尤其是结构体来自不同编译器、不同目标平台或不同编译选项时。
字段顺序会影响大小
C 程序员会通过调整字段顺序减少 padding。repr(C) 下 Rust 也一样。
例如:
#[repr(C)]
struct Bad {
a: u8,
b: u32,
c: u8,
}
#[repr(C)]
struct Better {
b: u32,
a: u8,
c: u8,
}
Better 往往更紧凑,因为先放对齐要求更高的字段。具体大小仍然以目标平台为准。
普通 Rust 结构体不要为了猜测默认布局而手动排字段。如果你在乎二进制布局,就把意图写成 #[repr(C)],再用 size_of、align_of 和测试确认。
不要把网络包直接强转成 struct
C 里经常能看到这种写法:
const struct Header *hdr = (const struct Header *)buf;
这类写法在 Rust 里不要作为默认选择。即使 Header 是 #[repr(C)],直接把 &[u8] 当成结构体也会遇到很多问题:
- buffer 长度可能不够
- 起始地址可能不满足对齐
- 字段有大小端要求
- padding 字节不是协议字段
- 输入数据可能不满足类型的不变量
解析网络协议或文件格式时,更稳的写法是逐字段读取:
struct Header {
version: u8,
length: u32,
flags: u16,
}
fn parse_header(data: &[u8]) -> Option<Header> {
if data.len() < 7 {
return None;
}
let version = data[0];
let length = u32::from_be_bytes([data[1], data[2], data[3], data[4]]);
let flags = u16::from_be_bytes([data[5], data[6]]);
Some(Header {
version,
length,
flags,
})
}
这里的 Header 是普通 Rust 数据模型,不需要 repr(C)。外部格式的布局由解析代码控制,而不是由编译器结构体布局碰运气。
什么时候该关心布局
普通库代码里,结构体多半是用来表达概念:
struct Device {
id: u32,
name: String,
online: bool,
}
这种类型不应该暴露“第几个字节是什么”的承诺。你更应该关心字段含义、构造方式、方法和 API。
下面这些情况才应该把布局拿到台前:
- FFI 边界
- mmap 或共享内存结构
- 硬件寄存器映射
- 和 C 头文件保持 ABI 一致
- 二进制文件格式或网络协议
即使是二进制文件和网络协议,也不总是该用 repr(C) 结构体。很多时候逐字段解析更清楚,也更能处理大小端、版本兼容和非法输入。
这一层先记住什么
C 的 struct 和 Rust 的 struct 都能组织字段,但默认语义不同:
普通 Rust struct -> 程序内部数据模型
impl -> 给类型关联方法,不改变对象布局
#[repr(C)] struct -> 需要 C ABI 兼容时使用
size_of / align_of -> 查看当前目标平台大小和对齐
网络包 / 文件格式 -> 优先从 &[u8] 逐字段解析
不要把 Rust struct 默认当成 C struct 的二进制替身。需要布局稳定时,把边界写出来;不需要布局稳定时,让结构体服务 API 和数据模型。