一、问题背景
在使用 Rust 进行 FFI(Foreign Function Interface,外部函数接口)开发时,经常需要向 C/C++ 传递数组数据。为了保证内存安全,我们希望内存的分配和释放都在 Rust 侧完成。但这带来了一个技术挑战:如何在保持 C/C++ 接口兼容性的同时,正确地管理动态分配的数组内存?
二、核心问题分析
2.1 为什么不能直接使用 Vec?
Rust 的 Vec 包含三个字段:

ptr: 指向数据的指针
len: 当前长度
cap: 容量

C/C++ 接口通常只接受指针和长度,不理解容量的概念。如果使用 Vec::into_raw_parts()(实验性 API),需要在 C 侧追踪容量信息,这破坏了接口的兼容性。
2.2 Box<[T]> 的优势
Box<[u8]> 是一个装箱切片,它的特点是:

长度等于容量(没有多余的预分配空间)
在 FFI 边界只需要传递指针和长度
可以安全地重建并释放

三、实现方案
3.1 基础实现(带改进)
rustuse std::ptr;

/// 分配字节数组并返回给 C/C++
/// 调用方必须使用相同的指针和长度调用 free_data
#[no_mangle]
pub extern “C” fn get_data(ptr: *mut *const u8, len: *mut usize) -> i32 {
// 参数校验
if ptr.is_null() || len.is_null() {
return -1; // 错误:空指针
}

// 获取数据(模拟实际业务逻辑)
let data: Vec<u8> = fetch_data();

// 转换为装箱切片(如果 capacity > length 可能会重新分配)
let boxed_slice = data.into_boxed_slice();
let slice_len = boxed_slice.len();

// 转换为裸指针(胖指针 *mut [u8])
let raw_slice = Box::into_raw(boxed_slice);

unsafe {
    // 从胖指针提取数据指针和长度
    *len = slice_len;
    *ptr = raw_slice as *const u8;
}

0 // 成功

}

/// 释放由 get_data 分配的字节数组
/// 安全要求:ptr 和 len 必须是 get_data 返回的原始值
#[no_mangle]
pub extern “C” fn free_data(ptr: *mut u8, len: usize) {
if ptr.is_null() {
return;
}

unsafe {
    // 重建胖指针
    let slice_ptr = ptr::slice_from_raw_parts_mut(ptr, len);
    // 丢弃 Box,自动释放内存
    let _ = Box::from_raw(slice_ptr);
}

}

// 辅助函数(示例)
fn fetch_data() -> Vec {
vec![1, 2, 3, 4, 5]
}
3.2 避免重新分配的版本
如果担心 into_boxed_slice() 可能导致的内存重新分配,可以使用以下方法:
rust#[no_mangle]
pub extern “C” fn get_data_no_realloc(ptr: *mut *const u8, len: *mut usize) -> i32 {
if ptr.is_null() || len.is_null() {
return -1;
}

let mut data: Vec<u8> = fetch_data();

// 显式收缩到实际大小
data.shrink_to_fit();

let length = data.len();
let data_ptr = data.as_ptr();

// 泄漏 Vec 以防止自动释放
std::mem::forget(data);

unsafe {
    *len = length;
    *ptr = data_ptr;
}

0

}

#[no_mangle]
pub extern “C” fn free_data_no_realloc(ptr: *mut u8, len: usize) {
if ptr.is_null() {
return;
}

unsafe {
    // 用 capacity = length 重建 Vec
    let _ = Vec::from_raw_parts(ptr, len, len);
}

}
四、C/C++ 调用示例
4.1 C 语言调用
c#include <stdio.h>
#include <stdint.h>

// Rust 函数声明
int get_data(const uint8_t** ptr, size_t* len);
void free_data(uint8_t* ptr, size_t len);

int main() {
uint8_t* data = NULL;
size_t len = 0;

if (get_data(&data, &len) == 0) {
    printf("接收到 %zu 字节数据: ", len);
    for (size_t i = 0; i < len; i++) {
        printf("%02x ", data[i]);
    }
    printf("\n");
    
    // 使用完毕后释放
    free_data(data, len);
}

return 0;

}
4.2 C++ 语言调用
cpp#include
#include
#include

extern “C” {
int get_data(const uint8_t** ptr, size_t* len);
void free_data(uint8_t* ptr, size_t len);
}

int main() {
const uint8_t* data = nullptr;
size_t len = 0;

if (get_data(&data, &len) == 0) {
    // 可以转换为 std::vector
    std::vector<uint8_t> vec(data, data + len);
    
    std::cout << "接收到 " << len << " 字节数据" << std::endl;
    
    // 必须释放原始数据
    free_data(const_cast<uint8_t*>(data), len);
}

return 0;

}
五、关键技术点解析
5.1 胖指针与瘦指针
Rust 的切片指针 *mut [u8] 是"胖指针",包含:

数据指针(8 字节)
长度信息(8 字节)

转换为 *mut u8 时,只保留数据指针部分,长度需要单独传递。
5.2 内存布局保证
关键问题:重建切片时是否能正确释放内存?
答案是肯定的,因为:

Box<[u8]> 的长度等于容量
Rust 的分配器 API 保证:使用相同大小信息释放内存时一定成功
Box::from_raw 重建时使用的是相同的指针和长度

5.3 分配器兼容性
您的担心是有道理的,但 Rust 的设计已经处理了这个问题:

全局分配器:Rust 使用全局分配器,分配和释放使用同一个分配器实例
大小记录:现代分配器通常会记录分配块的大小,即使请求的大小略小于实际分配的大小,释放时也能正确处理
精确匹配:Box<[u8]> 确保分配大小正好等于切片长度,避免了这个问题

5.4 潜在的重新分配
into_boxed_slice() 可能会重新分配内存,当且仅当:
rustvec.capacity() > vec.len()
这种情况下会分配一个新的、恰好等于长度的内存块,并复制数据。如果这是性能瓶颈,使用 shrink_to_fit() + mem::forget() 方案。
六、最佳实践建议
6.1 添加错误处理
rust#[repr©]
pub enum RustError {
Success = 0,
NullPointer = -1,
AllocationFailed = -2,
}

#[no_mangle]
pub extern “C” fn get_data_safe(
ptr: *mut *const u8,
len: *mut usize
) -> RustError {
if ptr.is_null() || len.is_null() {
return RustError::NullPointer;
}

// ... 其余代码
RustError::Success

}
6.2 使用类型别名增强可读性
rustpub type ByteArray = Box<[u8]>;

#[no_mangle]
pub extern “C” fn create_byte_array(size: usize) -> *mut u8 {
let array: ByteArray = vec![0u8; size].into_boxed_slice();
Box::into_raw(array) as *mut u8
}
6.3 文档化所有权契约
rust/// # Safety
///
/// - 调用者必须确保返回的指针最终传递给 free_data
/// - 传递给 free_data 的指针和长度必须完全一致
/// - 不能多次释放同一个指针
/// - 不能在释放后继续使用指针
#[no_mangle]
pub extern “C” fn get_data(ptr: *mut *const u8, len: *mut usize) -> i32 {
// …
}
七、常见陷阱与调试技巧
7.1 忘记释放内存
c// ❌ 错误:内存泄漏
get_data(&ptr, &len);
// 使用 ptr…
// 忘记调用 free_data
解决方案:使用 RAII 封装(C++)或明确的清理流程©。
7.2 双重释放
c// ❌ 错误:双重释放
get_data(&ptr, &len);
free_data(ptr, len);
free_data(ptr, len); // 未定义行为!
解决方案:释放后立即置空指针。
7.3 长度不匹配
c// ❌ 错误:使用错误的长度
get_data(&ptr, &len);
free_data(ptr, len + 1); // 未定义行为!
八、性能考虑
8.1 避免不必要的复制
如果数据已经在正确的格式:
rust// 如果 fetch_data 已经返回 Box<[u8]>
pub extern “C” fn get_data_zero_copy(
ptr: *mut *const u8,
len: *mut usize
) -> i32 {
let data: Box<[u8]> = fetch_data_boxed();
// 直接转换,无需 Vec 中转
// …
}
8.2 批量操作
对于频繁的分配/释放,考虑使用对象池模式:
ruststatic POOL: Mutex<Vec<Box<[u8]>>> = Mutex::new(Vec::new());

pub extern “C” fn get_pooled_data(…) {
// 从池中获取或新建
}

pub extern “C” fn return_to_pool(…) {
// 归还到池而不是释放
}
九、总结
您的原始实现是正确的! 主要要点:

✅ 分配器兼容性:Box<[u8]> 方案与任何分配器都兼容
✅ 内存安全:只要指针和长度匹配,重建切片就是安全的
✅ 接口简洁:只暴露指针和长度,符合 C/C++ 习惯
⚠️ 注意重新分配:into_boxed_slice() 可能导致一次额外的分配
✅ 最佳实践:添加空指针检查和错误处理

这个模式是 Rust FFI 中传递动态数组的标准做法,被广泛应用于生产环境中。记住核心原则:谁分配谁释放,参数精确匹配。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐