如何定义 #[repr(C)] 结构体?

解读

国内校招/社招面试里,这道题常被用来**快速区分“写过 FFI”与“只写过纯 Rust”**的候选人。
面试官真正想听的不是“加一行属性”那么简单,而是:

  1. 你为什么敢把内存布局交给 C 决定;
  2. 你怎么保证 Rust 侧与 C 侧看到的字节级布局完全一致
  3. 如果布局不对,你会用哪些工具在第一时间定位。
    答不到这三层,基本会被归为“只看过书”。

知识点

  • repr(C) 的语义:把 Rust struct 的内存布局按 C 语言规则排列,字段顺序、对齐、填充均由“目标平台 C ABI”决定,Rust 编译器不再重排
  • 对齐与填充规则:每个字段取其 align_of::<T>() 的倍数开始;结构体整体大小是其最大对齐值的倍数
  • 字段顺序敏感:Rust 默认可重排,C 不允许;repr(C)冻结顺序,因此字段声明顺序必须与头文件一致
  • ZST 与 1-ZST:Rust 的零大小类型在 repr(C)仍占 0 字节,但 C 侧若声明为 char _dummy[0] 会触发 UB;必须补一字节占位
  • enum 的陷阱repr(C) 只能用于无变体数据的 C-like enum,且默认底层标签是平台 int;若 C 侧用 uint8_t,必须再写 repr(u8)
  • UB 边界
    – 不可包含 StringVecBox 等带有 Rust 特有分配器指针的字段;
    – 不可包含 bool 除非 C 侧明确用 _Bool字节值严格 0/1
    – 不可包含 char(4 字节)去对 C 的 char(1 字节)。
  • 验证工具链
    cargo expand 看展开后属性;
    #[repr(C)] + static_assertions::assert_eq_size!编译期大小断言
    bindgen --rust-target nightly 自动生成 Rust 侧绑定,反向比对布局;
    cbindgen 从 Rust 生成头文件,与已有 .h 文件 diff
  • 多平台差异:32/64 位、Windows MSVC/GNU、ARM packed 对齐差异;CI 里必须交叉编译跑测试

答案

use std::os::raw::{c_int, c_char};

/// 与 C 头文件里
/// typedef struct {
///     int  id;
///     char name[32];
/// } person_t;
/// 保持一字节不差。
#[repr(C)]           // 1. 强制 C 布局
#[derive(Debug, Copy, Clone)]  // 2. 建议加派生,方便调试
pub struct Person {
    pub id: c_int,              // 3. 类型用 libc 别名,避免平台差异
    pub name: [c_char; 32],     // 4. 定长数组,拒绝 String
}

/// 编译期断言:Rust 侧大小 == C 侧大小
#[cfg(test)]
mod tests {
    use super::*;
    use std::mem::size_of;

    #[test]
    fn layout_sanity() {
        assert_eq!(size_of::<Person>(), 36); // 4 + 32
        assert_eq!(size_of::<Person>(), size_of::<ffi::person_t>());
    }
}

关键点逐条说明

  1. #[repr(C)] 必须写在 struct 前面,属性作用域仅对该项生效;
  2. 所有字段类型必须能在 C 侧找到一字节兼容的对应类型
  3. 数组长度、字段顺序、对齐值必须与头文件完全一致
  4. std::mem::transmute 或指针强转前,先用 static assertion 保证 size/align 相等,否则立即 UB;
  5. 若 C 侧用 #pragma pack(1),Rust 侧必须再补 #[repr(C, packed(1))],否则会出现“Rust 侧 36 字节、C 侧 33 字节”的惨案。

拓展思考

  1. “双 repr” 组合#[repr(C, packed(1)]#[repr(C, align(64))] 同时出现时的优先级与交互规则是什么?
  2. Rust 1.82 即将稳定的 repr(simd)repr(C) 混用,如何保证向量类型在 FFI 边界不崩
  3. async FFI:如果结构体里要传一个 tokio::task::JoinHandle,你打算**怎样在 repr(C) 结构体里表达“不透明指针”**才能既安全又避免泄漏?
  4. CI 实战:在 GitHub Actions 里,如何一次性跑 bindgen + cbindgen + cross 验证 x86_64、aarch64、riscv32 三种目标的 layout 一致性?
  5. 面试反杀:当面试官追问“为什么不用 repr(packed) 直接省内存”时,你可以用 “未对齐访问在 ARM 上会触发 SIGBUS” 把话题拉进底层硬件,反向展示你懂 CPU 架构,从而拿到加分。