Rust str与ffi::CString互转时字符串部分损坏问题排查
我来帮你排查这个问题——你遇到的是悬垂指针导致的未定义行为,具体原因和修复方案如下:
问题根源
在你的str::to_c_str实现里,你创建了一个CString实例result,然后把它的内部指针赋值给c_str_t.data。但按照Rust的所有权规则,函数返回时result会被自动销毁,对应的内存也会被释放,这就导致c_str_t.data变成了悬垂指针——指向已经被回收的内存。后续访问这个指针时,会触发未定义行为,你看到的首字符变成\u{0}就是这种行为的一种表现(也可能出现程序崩溃、数据乱码等其他诡异结果)。
另外还有个小细节:CString内部会自动在字符串末尾添加一个null终止符,但你的c_str_t.len是原Rust字符串的长度,这本身没问题,但悬垂指针才是核心问题。
修复方案
我们需要确保c_str_t.data指向的内存在使用期间保持有效,有两种常见的处理方式,分别适合不同的场景:
方案1:转移CString的所有权(纯Rust场景)
修改MyCStrExt的返回类型,把CString和c_str_t一起返回,这样CString的所有权会被带出函数,不会被提前销毁,指针也就始终有效:
#![allow(non_camel_case_types)] use libc::{c_uchar, size_t}; use std::ffi::{CString, NulError}; use std::slice; #[repr(C)] pub struct c_str_t { pub len: size_t, pub data: *const c_uchar, } pub trait MyCStrExt<T> { // 修改返回类型,携带CString的所有权 fn to_c_str(&self) -> Result<(CString, c_str_t), NulError>; } pub trait MyCStringExt { fn from_c_str_ref(nstr: &c_str_t) -> Option<String>; } impl<'a> MyCStrExt<&'a str> for str { fn to_c_str(&self) -> Result<(CString, c_str_t), NulError> { let c_string = CString::new(self)?; let c_str = c_str_t { data: c_string.as_ptr() as *const u8, len: self.len(), }; Ok((c_string, c_str)) } } impl MyCStringExt for String { fn from_c_str_ref(nstr: &c_str_t) -> Option<String> { unsafe { if nstr.data.is_null() { return None; } let slice = slice::from_raw_parts(nstr.data, nstr.len); String::from_utf8(slice.to_vec()).ok() } } } #[test] fn test_to_c_str() { let s = "What does the fox say?"; let (c_string, c_str) = s.to_c_str().unwrap(); let round_trip = String::from_c_str_ref(&c_str); assert_eq!(round_trip, Some(s.to_string())); println!("{:?}", round_trip); // 输出: Some("What does the fox say?") }
这种方式完全依赖Rust的所有权机制自动管理内存,不需要手动释放,适合纯Rust内部的字符串转换场景。
方案2:手动分配堆内存(FFI交互场景)
如果你的c_str_t需要传递给C代码,无法携带CString的所有权,那么可以手动在堆上复制字符串字节,让c_str_t.data指向这个独立的堆内存(注意:这种方式需要手动释放内存,避免泄漏):
#![allow(non_camel_case_types)] use libc::{c_uchar, size_t}; use std::ffi::NulError; use std::slice; use std::alloc::{alloc, dealloc, Layout}; #[repr(C)] pub struct c_str_t { pub len: size_t, pub data: *const c_uchar, } pub trait MyCStrExt<T> { fn to_c_str(&self) -> Result<c_str_t, NulError>; } pub trait MyCStringExt { fn from_c_str_ref(nstr: &c_str_t) -> Option<String>; // 添加手动释放内存的方法 unsafe fn free_c_str(c_str: c_str_t); } impl<'a> MyCStrExt<&'a str> for str { fn to_c_str(&self) -> Result<c_str_t, NulError> { // 先验证字符串不含null字节(和CString的校验逻辑一致) if self.contains('\0') { return Err(NulError); } let layout = Layout::array::<u8>(self.len()).unwrap(); // 分配堆内存 let data = unsafe { alloc(layout) } as *mut c_uchar; if data.is_null() { std::alloc::handle_alloc_error(layout); } // 复制字符串字节到堆内存 unsafe { std::ptr::copy_nonoverlapping(self.as_ptr(), data as *mut u8, self.len()); } Ok(c_str_t { data, len: self.len(), }) } } impl MyCStringExt for String { fn from_c_str_ref(nstr: &c_str_t) -> Option<String> { unsafe { if nstr.data.is_null() { return None; } let slice = slice::from_raw_parts(nstr.data, nstr.len); String::from_utf8(slice.to_vec()).ok() } } unsafe fn free_c_str(c_str: c_str_t) { if !c_str.data.is_null() { let layout = Layout::array::<u8>(c_str.len).unwrap(); dealloc(c_str.data as *mut u8, layout); } } } #[test] fn test_to_c_str() { let s = "What does the fox say?"; let c_str = s.to_c_str().unwrap(); let round_trip = String::from_c_str_ref(&c_str); assert_eq!(round_trip, Some(s.to_string())); println!("{:?}", round_trip); // 手动释放内存 unsafe { String::free_c_str(c_str) }; }
这种方式的内存是独立分配的,C代码也可以通过标准的free函数释放(如果使用的是系统分配器),适合需要和C代码交互的场景。
额外注意点
- 永远不要在Rust中保留悬垂指针,Rust的所有权机制就是用来避免这类内存安全问题的,一定要确保指针指向的内存在使用期间始终有效。
- 你的
c_str_t是带长度的结构(类似Pascal字符串),其实不需要null终止符,所以方案2里直接复制字节的方式比用CString更高效,也避免了额外的null字节开销。
内容的提问来源于stack exchange,提问作者Paul C

