Rust 与 C 之间,传递字符串的 7 种方式!

2022年8月29日 303点热度 0人点赞 0条评论

图片

摘要:Rust 得以在编程语言中火速崛起的原因之一,就是它能够与 C 语言进行互操作。因此在本文中,作者介绍了在 Rust 与 C 之间传递字符串的七种方式,这些方法不仅可用于传递字符串,也可用于其他数据。

原文链接:https://dev.to/kgrech/7-ways-to-pass-a-string-between-rust-and-c-4ieb

声明:本文为 CSDN 翻译,未经授权,禁止转载。


作者 |
Konstantin Grechishchev
译者 | 弯月
出品 | CSDN(ID:CSDNnews)

Rust和C语言能够互操作,这恐怕是Rust最不可思议的功能之一。能够在C语言中调用安全地Rust代码,能够在Rust中通过C接口使用一些流行库,正是Rust能够在整个行业迅速流行的原因之一。此外,我们还可以通过C接口,用不同的语言编写代码,这样凡是能够调用C的语言都可以使用这些代码。

FFI接口的编写难度很高,新手很难成功。如何处理 into_raw 和 as_ptr 方法,才不会导致内存泄漏或引发安全漏洞?在编写代码时,我们难免会使用一些不安全的关键字,这会令我们不安。

我将在本文中介绍有关FFI接口的内存处理,并提供一些我在项目中使用过的有效模式。

(注意:这里我以字符串作为例进行说明,实际上这些技术也适用于将字节数组或指针传输到 Box 或 Arc 类型的堆结构上。)

基本规则

在学习如何实现FFI函数之前,我想先介绍一些基本的规则。你应该在设计的过程中牢记这些规则,因为缺少其中任何一个都可能引发各种bug,最终导致函数全面崩溃或内存泄漏。

规则1:一个指针,一个分配器

你可能会认为内存分配只不过是调用一些操作系统API。然而实际上,获取一大块内存,写入缓存区是一项复杂且开销很大的操作。编译器和库开发人员很想应用各种优化,比如获得更大的内存块以避免频繁调用操作系统API,而且实现方式也各异。

你不应该假设调用库的人会使用某种类型的内存分配器。他们不一定会使用malloc,而且也不会受限于libc。换句话说,Rust代码分配的内存应该由Rust代码删除,越过FFI边界获取的指针应该交还给创建者去释放。如果使用malloc分配内存,请不要将其转换为Box,然后drop。我们应该通过调用Box::into_raw()获取的指针,不应该通过调用free来释放。

规则2:所有权

Rust是一种内存安全语言,会明确指出所有权。如果在代码中看到Box<dyn Any>,你就知道在你drop Box之后,存储Any的内存会被立即释放。相反,如果看到void*,则无法判断是应该调用free释放内存,还是由其他人来释放这些内存(或许这些内存压根不需要被释放,因为它指向堆栈)。

在Rust中,将结构转化为原始指针的方法有一种命名约定。标准的库,比如Box、Arc、CStr和CString提供了as_ptr,还有一对into_raw和from_raw方法。并非每个结构都提供这三种方法,因此实际情况更加混乱。

我们来具体讨论一下这些库。首先是CString,它提供以上三种方法,as_ptr和into_raw方法都提供了相同类型的指针。然而,就像上面提到的void*一样,这些指针的所有权略有不同。

as_ptr方法以引用的形式接受&self。这意味着,在as_ptr返回后,CString实例依然会留在栈上,而数据的所有权也会保留。换句话说,返回的指针指向的数据仍归CString实例所有。一旦删除实例,指针就会悬空。在删除CString实例后,你永远不应再使用此指针。在安全的Rust中,指针的此属性由引用的生命周期(类似于指针)表示,并由编译器控制,但如果使用原始指针,一切都将变成未知。

与as_ptr不同,into_raw会通过值接受并销毁self。那么,会不会破坏释放内存?事实证明,into_raw不会调用drop方法。它会创建一个自己拥有的指针,然后将Rust分配器提供的内存块“泄漏”出来,脱离Rust编译器的控制范围。如果你只删除该指针,而不调用from_raw方法,就会引发内存泄漏。但是,它永远不会悬空(除非在调用from_raw之前修改或克隆它)。

如果你想让C暂时“借用”Rust的内存,则应该使用as_ptr。它有一个巨大的优势,因为 C 代码不必释放这块内存,而且还会限制指针的生命周期。但请不要将这个指针保存到某个全局结构中,或将其传递给另一个线程,也不应该将这样的指针作为函数调用的结果返回。

into_raw方法会将数据的所有权转移到C中。只要代码需要,它就可以保留指针,但请务必记得将它转移回Rust删除。

字符串的内存表示

不幸的是,在Rust和语言C中,字符串的表示方式不同。C的字符串通常是char*指针,指向以 /0 结尾的char数组。而Rust则会保存字符数组及其长度。

由于这个原因,Rust的String和str类型与原始指针之间不应该互相转换。你应该使用CString和CStr中间类型来实现。通常,我们使用CString将Rust字符串传递给C代码,使用CStr将C的字符串转换为Rust的&str。请注意,这种转换并不一定会复制底层的数据。因此,通过CStr获得的&str会指向C分配的数组,而且它的生命周期与指针绑定。

注意:String:new会复制数据,但CStr::new不会。

项目设置

如何将Rust和C连接起来

网上有很多关于如何构建C代码,以及使用build.rs将C连接到Rust crate的资料,但是如何将Rust代码添加到C项目的文章却很少。相比之下,我更喜欢用C语言实现主要功能,并使用CMake作为构建系统。我希望CMake项目将Rust crate作为库,并根据Rust代码生成C的头文件。

通过CMake运行Cargo

我建立了一个简单的CMake 3控制台应用程序。

首先,我们需要定义构建Rust库的命令和保存Rust成果物的位置:

if (CMAKE_BUILD_TYPE STREQUAL "Debug")set(CARGO_CMD RUSTFLAGS=-Zsanitizer=address cargo build -Zbuild-std --target x86_64-unknown-linux-gnu)set(TARGET_DIR "x86_64-unknown-linux-gnu/debug")else ()set(CARGO_CMD cargo build --release)set(TARGET_DIR "release")endif ()SET(LIB_FILE "${CMAKE_CURRENT_BINARY_DIR}/${TARGET_DIR}/librust_lib.a")

对于熟悉Rust的人来说,这个构建crate调试版本的命令可能看起来有点古怪。我们完全可以使用cargo build来代替这个命令,但是我想利用Rust不稳定的地址清理器功能来确保内存不会被泄漏。

其次,我们需要自定义命令和目标,让它们根据命令输出结果。然后,我们可以定义一个名为rust_lib的静态导入库,并根据目标构建它:

add_custom_command(OUTPUT ${LIB_FILE}COMMENT "Compiling rust module"COMMAND CARGO_TARGET_DIR=${CMAKE_CURRENT_BINARY_DIR} ${CARGO_CMD}WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/rust_lib)add_custom_target(rust_lib_target DEPENDS ${LIB_FILE})add_library(rust_lib STATIC IMPORTED GLOBAL)add_dependencies(rust_lib rust_lib_target)

最后,我们可以使用将二进制文件与Rust库(以及其他必需的系统库)链接在一起。我们还在C代码中启用了地址清理器:

target_compile_options(rust_c_interop PRIVATE -fno-omit-frame-pointer -fsanitize=address)target_link_libraries(rust_c_interop PRIVATE Threads::Threads rust_lib ${CMAKE_DL_LIBS} -fno-omit-frame-pointer -fsanitize=address)

如此一来,运行CMake即可自动构建rust create,并与之链接。但是,我们还需要从C代码中调用Rust的方法。

生成C的头文件,并将它们添加到CMake项目中

最简单的在Rust代码中获取C头文件的方法是使用cbingen库。

我们可以将以下代码添加到Rust crate的build.rs文件中,以检测Rust中定义的所有extern "C"函数,为其生成头文件定义,并保存到include/目录下:

let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();let package_name = env::var("CARGO_PKG_NAME").unwrap();let output_file = PathBuf::from(&crate_dir).join("include").join(format!("{}.h", package_name));cbindgen::generate(&crate_dir).unwrap().write_to_file(output_file);

此外,我们还应该在Rust crate的根目录中创建cbindgen.toml文件,并指明language = "C"。

接下来,CMake需要在Rust crate的include文件夹中查找头文件:

SET(LIB_HEADER_FOLDER "${CMAKE_CURRENT_SOURCE_DIR}/rust_lib/include")set_target_properties(rust_libPROPERTIESIMPORTED_LOCATION ${LIB_FILE}INTERFACE_INCLUDE_DIRECTORIES ${LIB_HEADER_FOLDER})

将Rust字符串传递到C的五种方式


一切准备就绪。下面,我们来看看如何从Rust的数据中获取字符串,然后在C中使用。我们怎么才能安全地传递字符串,同时不会造成内存泄漏?

方法1:提供创建和删除方法

如果不知道C代码需要使用字符串多久,就可以采用这种方式。为了将所有权移交给C,我们可以构建CString对象,并使用into_raw将其转换为指针。free方法只需要构建CString,再drop这个对象就可以释放内存:

#[no_mangle]pub extern fn create_string() -> *const c_char {let c_string = CString::new(STRING).expect("CString::new failed");c_string.into_raw() // Move ownership to C}/// # Safety/// The ptr should be a valid pointer to the string allocated by rust#[no_mangle]pub unsafe extern fn free_string(ptr: *const c_char) {// Take the ownership back to rust and drop the ownerlet _ = CString::from_raw(ptr as *mut _);}

不要忘记调用free_string,以避免内存泄漏:

const char* rust_string = create_string();printf("1. Printed from C: %s\n", rust_string);free_string(rust_string);

不要调用libc free方法,也不要尝试修改此类指针指向的数据。

这个方法虽然效果很好,但如果我们想在使用内存时释放Rust库,或者在不知道Rust库的代码中释放内存,该怎么办?你可以考虑以下三种方法。

方法2:分配缓冲区并复制数据

还记得规则1吗?如果我们想在C中使用free方法释放内存,就应该使用malloc分配内存。但是,Rust怎么会知道malloc呢?一种解决方案是,“问一问”Rust需要多少内存,然后为它分配一个缓冲区:

size_t len = get_string_len();char *buffer = malloc(len);copy_string(buffer);printf("4. Printed from C: %s\n", buffer);free(buffer);

Rust只需要告诉我们缓冲区的大小,并小心翼翼地将Rust字符串复制到其中(注意不要漏掉末尾的字节0):

#[no_mangle]pub extern fn get_string_len() -> usize {STRING.as_bytes().len() + 1}/// # Safety/// The ptr should be a valid pointer to the buffer of required size#[no_mangle]pub unsafe extern fn copy_string(ptr: *mut c_char) {let bytes = STRING.as_bytes();let len = bytes.len();std::ptr::copy(STRING.as_bytes().as_ptr().cast(), ptr, len);std::ptr::write(ptr.offset(len as isize) as *mut u8, 0u8);}

这个方法的优势在于,我们不必实现free_string,可以直接使用free。还有一个优点是,如有需要C代码也可以修改缓冲区(这就是我们使用*mut c_char,而不是*const c_char的原因)。

问题在于,我们仍然需要实现额外的方法get_string_len,而且还需要分配一块新内存,并复制数据(但其实CString::new也需要)。

如果你想将Rust字符串移动到C函数栈上分配的缓冲区,也可以使用此方法,但应该确保有足够的空间。

方法3:将内存分配器方法传递给Rust

我们可以避免使用get_string_len方法吗?有没有其他方法在Rust中分配内存?一种简单的方法是将分配内存函数传递给Rust:

type Allocator = unsafe extern fn(usize) -> *mut c_void;/// # Safety/// The allocator function should return a pointer to a valid buffer#[no_mangle]pub unsafe extern fn get_string_with_allocator(allocator: Allocator) -> *mut c_char {let ptr: *mut c_char = allocator(get_string_len()).cast();copy_string(ptr);ptr}

上述示例使用了的copy_string,接下来我们可以使用get_string_with_allocator:

char* rust_string_3 = get_string_with_allocator(malloc);printf("3. Printed from C: %s\n", rust_string_3);free(rust_string_3);

这个方法与方法2相同,而且优缺点也一样。

但是,我们现在必须传递额外的参数allocator。其实,我们可以进行一些优化,将其保存到某个全局变量中,就可以避免向每个函数传递。

方法4:从Rust调用glibc

如果我们的C代码会使用malloc/free来分配内存,则可以尝试在Rust代码中引入libc crate,尽管这种方式有点冒险:

#[no_mangle]pub unsafe extern fn get_string_with_malloc() -> *mut c_char {let ptr: *mut c_char = libc::malloc(get_string_len()).cast();copy_string(ptr);ptr}

C代码不变:

char* rust_string_4 = get_string_with_malloc();printf("4. Printed from C: %s\n", rust_string_4);free(rust_string_4);

在这种方式下,我们不需要提供分配内存的方法,但是C代码也会受到很多限制。我们最好做好文档记录,尽量避免使用这种方式,除非我们确定百分百安全。

方法5:借用Rust字符串

以上这些方法都是将数据的所有权传递给C。但如果我们不需要传递所有权呢?举个例子,Rust代码需要同步调用C方法,并向它传递一些数据。这时,可以考虑使用CString的as_ptr:

type Callback = unsafe extern fn(*const c_char);#[no_mangle]pub unsafe extern fn get_string_in_callback(callback: Callback) {let c_string = CString::new(STRING).expect("CString::new failed");// as_ptr() keeps ownership in rust unlike into_raw()callback(c_string.as_ptr())}

不幸的是,即便在这种情况下,CString:new也会复制数据(因为它需要在末尾添加字节0)。

C代码如下:

void callback(const char* string) {printf("5. Printed from C: %s\n", string);}int main() {get_string_in_callback(callback);return 0;}

如果有一个生命周期已知的C指针,则我们应该优先使用这种方式,因为它可以保证没有内存泄漏。

将C字符串传递给Rust的两种方法

下面,我们来介绍两种反向操作的方法,即将C的字符串转换为Rust的类型。主要方法有以下两种:

  • 将C字符串转换成&str,不复制数据;

  • 复制数据并接收字符串。

这两种方法的示例相同,因为它们非常相似。实际上,方法2需要先使用方法1。

C代码如下。我们在堆上分配数据,但实际上我们也可以将指针传递给栈:

char *test = (char*) malloc(13*sizeof(char));strcpy(test, "Hello from C");print_c_string(test);free(test);

Rust的实现如下:

#[no_mangle]/// # Safety/// The ptr should be a pointer to valid Stringpub unsafe extern fn print_c_string(ptr: *const c_char) {let c_str = CStr::from_ptr(ptr);let rust_str = c_str.to_str().expect("Bad encoding");// calling libc::free(ptr as *mut _); causes use after free vulnerabilityprintln!("1. Printed from rust: {}", rust_str);let owned = rust_str.to_owned();// calling libc::free(ptr as *mut _); does not cause after free vulnerabilityprintln!("2. Printed from rust: {}", owned);}

注意,此处我们使用了CStr,而不是CString。如果不是CString::into_raw创建的指针,请不要调用CString:from_raw。

这里还需要注意,&str引用的生命周期不是“静态”的,而是绑定到了c_str对象方法。Rust编译器会阻止你在该方法之外返回&str,或将其移动到全局变量/另一个线程,因为一旦C代码释放内存,&str引用就会变成非法。

如果需要在Rust中长时间保留数据的所有权,只需调用to_owned()即可获取字符串的副本。如果不想复制,则可以使用CStr,但我们应该确保C代码不会在字符串还在使用期间释放内存。

总结

在本文中,我们讨论了Rust与C之间的互操作,并介绍了几种跨FFI边界传递数据的方法。这些方法不仅可用于传递字符串,也可用于其他数据,或者利用FFI将Rust连接到其他编程语言。

希望本文能对你有所帮助,如有任何问题或反馈,请在下方留言。

— 推荐阅读 —
雷军:“下一代 Ultra旗舰将面向全球发售”;戴尔宣布完全退出俄罗斯;TypeScript 4.8发布|极客头条
☞微软揭秘史上最重的软件:高达 36 斤的 C/C++ 编译器!
80岁还在写代码!Hello World发明人、UNIX命名者项目登上GitHub热榜

85070Rust 与 C 之间,传递字符串的 7 种方式!

这个人很懒,什么都没留下

文章评论