摘要:Rust 得以在编程语言中火速崛起的原因之一,就是它能够与 C 语言进行互操作。因此在本文中,作者介绍了在 Rust 与 C 之间传递字符串的七种方式,这些方法不仅可用于传递字符串,也可用于其他数据。 (英)原文链接:https://dev.to/kgrech/7-ways-to-pass-a-string-between-rust-and-c-4ieb Rust和C语言能够互操作,这恐怕是Rust最不可思议的功能之一。能够在C语言中调用安全地Rust代码,能够在Rust中通过C接口使用一些流行库,正是Rust能够在整个行业迅速流行的原因之一。此外,我们还可以通过C接口,用不同的语言编写代码,这样凡是能够调用C的语言都可以使用这些代码。 FFI 接口的编写难度很高,新手很难成功。如何处理 into_raw 和 as_ptr 方法,才不会导致内存泄漏或引发安全漏洞?在编写代码时,我们难免会使用一些不安全的关键字,这会令我们不安。我将在本文中介绍有关FFI接口的内存处理,并提供一些我在项目中使用过的有效模式。 > 注意:这里我以字符串作为例进行说明,实际上这些技术也适用于将字节数组或指针传输到 Box 或 Arc 类型的堆结构上。 ![87a0c036bc575b09d97517ce0921839b_47d58a77a02edeb8d1972506c9ae6f7c.jpeg](87a0c036bc575b09d97517ce0921839b_47d58a77a02edeb8d1972506c9ae6f7c.jpeg) ## **基本规则** 在学习如何实现FFI函数之前,我想先介绍一些基本的规则。你应该在设计的过程中牢记这些规则,因为缺少其中任何一个都可能引发各种bug,最终导致函数全面崩溃或内存泄漏。 ### **规则1:一个指针,一个分配器** 你可能会认为内存分配只不过是调用一些操作系统API。然而实际上,获取一大块内存,写入缓存区是一项复杂且开销很大的操作。编译器和库开发人员很想应用各种优化,比如获得更大的内存块以避免频繁调用操作系统API,而且实现方式也各异。 你不应该假设调用库的人会使用某种类型的内存分配器。他们不一定会使用malloc,而且也不会受限于libc。换句话说,Rust代码分配的内存应该由Rust代码删除,越过FFI边界获取的指针应该交还给创建者去释放。如果使用malloc分配内存,请不要将其转换为Box,然后drop。我们应该通过调用Box::into_raw()获取的指针,不应该通过调用free来释放。 ### **规则2:所有权** Rust是一种内存安全语言,会明确指出所有权。如果在代码中看到Box,你就知道在你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成果物的位置: ```SQL 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的静态导入库,并根据目标构建它: ```Plain Text 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代码中启用了地址清理器: ```PHP 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/目录下: ```Swift 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文件夹中查找头文件: ```Shell SET(LIB_HEADER_FOLDER "${CMAKE_CURRENT_SOURCE_DIR}/rust_lib/include") set_target_properties(rust_lib PROPERTIES IMPORTED_LOCATION ${LIB_FILE} INTERFACE_INCLUDE_DIRECTORIES ${LIB_HEADER_FOLDER}) ``` **将Rust字符串传递到C的五种方式** 一切准备就绪。下面,我们来看看如何从Rust的数据中获取字符串,然后在C中使用。我们怎么才能安全地传递字符串,同时不会造成内存泄漏? ### **方法1:提供创建和删除方法** 如果不知道C代码需要使用字符串多久,就可以采用这种方式。为了将所有权移交给C,我们可以构建CString对象,并使用into_raw将其转换为指针。free方法只需要构建CString,再drop这个对象就可以释放内存: ```PHP #[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 owner let _ = CString::from_raw(ptr as *mut _); } ``` 不要忘记调用free_string,以避免内存泄漏: ```C++ 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需要多少内存,然后为它分配一个缓冲区: ```C++ 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): ```PHP #[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: ```C# 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: ```C++ 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,尽管这种方式有点冒险: ```Properties files #[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代码不变: ```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: ```C++ 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代码如下: ```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代码如下。我们在堆上分配数据,但实际上我们也可以将指针传递给栈: ```C++ char *test = (char*) malloc(13*sizeof(char)); strcpy(test, "Hello from C"); print_c_string(test); free(test); ``` Rust的实现如下: ```Swift #[no_mangle] /// # Safety /// The ptr should be a pointer to valid String pub 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 vulnerability println!("1. Printed from rust: {}", rust_str); let owned = rust_str.to_owned(); // calling libc::free(ptr as *mut _); does not cause after free vulnerability println!("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连接到其他编程语言。