跳转到主内容结构体和内存布局 | 物联网民工

结构体和内存布局

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 和数据模型。

继续阅读