Rust FFI 和 bindgen:在 Rust 中集成 C 代码
我越听说在不同的渠道采用嵌入式 Rust,似乎有一个反复出现的主题。主题是似乎有些人认为 Rust 的采用将逐渐发生。这意味着许多项目完全从头开始采用 Rust 的可能性可能较小。相反,一些项目会将新的 Rust 代码集成到现有的 C 或 C++ 代码库中,或者将现有的 C 或 C++ 库集成到 Rust 项目中。因此,对于有兴趣进入嵌入式的人来说,获得做这些事情的能力将是有益的。
为了实现其他语言的集成,Rust 提供了所谓的外部函数接口 (FFI)。FFI 是一种从其他编程语言调用 Rust 代码或从 Rust 调用其他语言代码的方法。它允许 Rust 代码在各种上下文和环境中使用,并且还支持与用其他语言编写的现有库和框架的互操作性。因此,FFI 允许开发人员利用 Rust 的安全和性能特性,同时仍然能够使用用其他语言编写的现有代码。
在这篇文章中,我将通过将 STM32 HAL C 标头导入 Rust 来演示 FFI 的使用。之后,我将使用 Rust 中导入的 STM32 HAL 函数创建一个闪烁的程序。但是,首先我将修改链接过程的工作原理,并介绍一个名为bindgen的工具,这将使我们的生活更加轻松。
FFI 和链接流程
为了进一步阐述 FFI,并以 C 为例,根据具体的用例,有几种不同的方法来使用 Rust FFI:
从 C 调用 Rust 代码:要从 C 调用 Rust 代码,你需要在 Rust 中定义一个可以从其他语言调用的“外来函数”。这通常使用 Rust
extern
的关键字和 C 兼容的函数签名来完成。然后,您需要将 Rust 代码编译为 C 兼容的动态库(例如 .dll 或 .so 文件),并将其链接到其他语言代码。从 Rust 调用 C 代码:要从 Rust 调用用 C 编写的代码,可以使用 Rust 的
extern
关键字和lib
属性来定义一个与你想要调用的代码对应的“外来函数”。您还需要使用诸如“libc”之类的 Rust crate,它提供与 C 兼容的类型和函数来与外部代码进行接口。
一般来说,使用 Rust FFI 的过程将涉及使用适当的签名定义 Rust 函数,将 Rust 代码编译为库,并在运行时或静态地将该库链接到其他语言代码。下图显示了流的示例。如图所示,有两个独立的工具链,链接器是两者之间的公共部分。例如,如果我们想从 C 导入一个 square()
函数以在 Rust 中使用,通常步骤如下:
我们将编译 C 标头和代码,生成目标文件(扩展名为 .o)。
使用存档工具和创建的对象文件作为输入,创建链接器将利用的静态(或动态)库。
切换到 Rust 端,在 Rust 应用程序中,需要使用
extern
关键字来指定外部函数和一个附加lib
属性来指定包含该函数的静态库(从 C 工具链创建)。构建 Rust 应用程序代码。
Bindgen
在这篇文章中,我提到我将把STM32 HAL C头文件导入到Rust中。因此,根据所描述的流程,并考虑到STM32 HAL项目的复杂性,可以想象为项目中的每个功能创建一个单独的接口是多么乏味。事实上,我自己首先尝试这样做,但很快就感到沮丧。特别是有很多依赖关系。
在前面显示的图中,您可能已经注意到使用了 bindgen 和 cbindgen 关键字。这些是 Rust 生态系统中广泛使用的强大工具,其中 bindgen 可能是更流行的工具。Bindgen 是一个 Rust 库,它生成 Rust FFI 绑定到 C 和 C++ 库。它将库的 C 或 C++ 头文件作为输入,并生成可用于调用这些头文件中定义的函数和类型的 Rust 代码。生成的 Rust 代码为 C/C++ 库提供了一个安全的 Rust API,允许 Rust 开发人员使用该库,而无需手动编写 FFI 绑定。
Bindgen 利用 clang 工具来解析 C/C++ 标头并提取有关其中定义的类型和函数的信息。它还提供了自定义生成的 Rust 代码的选项,例如重命名类型和函数、将某些类型标记为不透明以及忽略某些函数或类型。
Cbindgen 是一个类似于 bindgen 的工具,但它为 Rust 代码生成 C/C++ FFI 绑定,而不是为 C/C++ 代码生成 Rust 绑定。它将 Rust 库作为输入,并生成可用于从 C/C++ 代码调用 Rust 函数和类型的 C/C++ 头文件。与 bindgen 一样,cbindgen 使用 clang 来解析 Rust 代码并提取有关其中定义的类型和函数的信息。
在本文的其余部分,我将介绍使用 bindgen 从 STM32 HAL 生成 Rust 兼容函数的步骤,而不是手动创建绑定的过程。之后,我将在现有的 Rust 闪烁应用程序中使用生成的函数之一。您会注意到,尽管使用了绑定,但该过程仍然有些复杂。
为 STM32 HAL 创建 FFI 绑定的步骤
📝 注意:本文中使用的代码和项目文件夹可在apollolabsdev Nucleo-F401RE git repo上找到。
硬件
在这篇文章中,使用的参考硬件是Nucleo-F401RE板。可以使用不同的STM32硬件,但需要替换参数以适应不同的器件/板。
步骤 1 - 生成 STM32 HAL 项目
在第一步中,我们需要生成/获取STM32 C项目,其中包含特定设备的所有头文件和实现文件。幸运的是,意法半导体提供了一个名为CubeMX的工具,用于配置并生成特定电路板/器件的C STM HAL项目文件。对于此步骤,可以遵循本ST微电子教程中的步骤。出于本文的目的,由于将创建一个简单的闪烁应用程序,因此我在CubeMX中保留了Nucleo-F401RE板的基本配置。生成代码之前的主要区别在于,对于工具链/ IDE选项,我选择了Makefile。
步骤 2 - 构建 STM32 HAL C 项目
创建项目后,我们需要在 Cube MX 生成的 STM32 HAL 项目根目录中创建一个文件夹(我的项目文件名为 ffi_c_project
),然后从同一根 build
文件夹运行 make
。在运行 make
之前,更改 main
例程名称很重要(我使用了 mainc
)。如果不更改,链接器稍后会遇到冲突,因为它会找到两个对 的 main
调用。这是因为 Rust 应用程序也有一个调用 main
,它是我们需要的。更好的是,如果不需要主头文件和实现文件,则可以从整个构建过程中消除它们。在这种情况下,它们没有被消除,因为其他文件中存在主标头的依赖关系。
ffi_c_project % mkdir build
ffi_c_project % make
完成后 make
,所有生成的对象文件都可以位于 build
文件夹中。
步骤 3 - 从 STM32 HAL C 项目输出生成静态库
接下来,我们需要 cd 到构建文件夹,在构建文件夹中,我们需要运行归档工具,用于 ar rcs
从生成的对象文件创建静态库
ffi_c_project % cd build
build % ar rcs libstm32.a gpio.o mainc.o stm32f4xx_hal_cortex.o stm32f4xx_hal_dma.o stm32f4xx_hal_dma_ex.o stm32f4xx_hal_exti.o stm32f4xx_hal_flash_ex.o stm32f4xx_hal_flash.o stm32f4xx_hal_gpio.o stm32f4xx_hal_msp.o stm32f4xx_hal_pwr.o stm32f4xx_hal_pwr_ex.o stm32f4xx_hal_rcc.o stm32f4xx_hal_rcc_ex.o stm32f4xx_hal_tim.o stm32f4xx_hal_tim_ex.o stm32f4xx_hal_uart.o stm32f4xx_it.o system_stm32f4xx.o usart.o stm32f4xx_hal.o
请注意,提供给 ar rcs
的第一个参数是我们要使用 .a
扩展名创建的静态库文件的名称。我选择作为 libstm32
名字。此外,需要注意的是,库名称需要以 开头 lib
,否则在 Rust 项目结束时的构建过程中将无法识别它。
步骤 4 - 将静态库复制到 Rust 项目的构建路径中
在此步骤中,我们首先需要设置/创建和构建一个简单的 Rust 项目(为此,我克隆并修改了基于 stm32f4xx-hal 的过去模板,并将 Rust 项目重命名为 ffi_rust_project
)。构建项目后,我将 libstm32.a
文件复制到一个目录中,构建脚本稍后可以在该目录中找到它。为此, libstm32.a
将文件复制到 /ffi_rust_project/target/debug/deps
该文件夹。
步骤 5 - 添加 bindgen
为生成依赖项
这是文档之后 bindgen
的第一步。此处 bindgen
已添加到项目 Cargo.toml
文件 [build-dependencies]
的部分。
[build-dependencies]
bindgen = "0.53.1"
值得注意的是,在此步骤中,需要始终检查依赖项的最新版本。
步骤 6 - 创建 wrapper.h
标头
现在在 Rust 项目的根目录中,我们需要创建一个 wrapper.h
文件并包含包含我们想要绑定的声明的所有各种标头。在 include 语句中,我们还需要声明头文件所在位置的路径。为了减少路径名的长度,我创建了一个本地文件夹,我调用 cheaders
并复制了所有必需的标头。因此,以下是文件的内容 wrapper.h
:
#include "cheaders/stm32f4xx.h"
#include "cheaders/system_stm32f4xx.h"
#include "cheaders/stm32f401xe.h"
#include "cheaders/core_cm4.h"
#include "cheaders/cmsis_version.h"
#include "cheaders/cmsis_compiler.h"
#include "cheaders/cmsis_gcc.h"
#include "cheaders/mpu_armv7.h"
#include "cheaders/stm32f4xx_hal_conf.h"
#include "cheaders/stm32f4xx_it.h"
#include "cheaders/mainc.h"
#include "cheaders/gpio.h"
#include "cheaders/usart.h"
#include "cheaders/stm32f4xx_hal.h"
#include "cheaders/stm32f4xx_hal_rcc_ex.h"
#include "cheaders/stm32f4xx_hal_rcc.h"
#include "cheaders/stm32f4xx_hal_def.h"
#include "cheaders/Legacy/stm32_hal_legacy.h"
#include "cheaders/stm32f4xx_hal_gpio.h"
#include "cheaders/stm32f4xx_hal_gpio_ex.h"
#include "cheaders/stm32f4xx_hal_exti.h"
#include "cheaders/stm32f4xx_hal_dma.h"
#include "cheaders/stm32f4xx_hal_dma_ex.h"
#include "cheaders/stm32f4xx_hal_cortex.h"
#include "cheaders/stm32f4xx_hal_flash.h"
#include "cheaders/stm32f4xx_hal_flash_ex.h"
#include "cheaders/stm32f4xx_hal_flash_ramfunc.h"
#include "cheaders/stm32f4xx_hal_pwr.h"
#include "cheaders/stm32f4xx_hal_pwr_ex.h"
#include "cheaders/stm32f4xx_hal_uart.h"
步骤 7 - 创建 build.rs
文件
在此步骤中,我们需要在 Rust 项目的根目录中创建一个 build.rs
文件。本质上, build.rs
提供了一种自动化自定义构建脚本的方法。如果项目rool中存在 build.rs
文件,cargo将在构建时自动检测它,并在构建过程中执行其内容。在我们的例子中,我们将在编译时为我们在 中 wrapper.h
列出的头文件生成 Rust FFI 绑定。生成的绑定将被写入由 选择的位置 cargo
, $OUT_DIR/bindings.rs
$OUT_DIR
在我们的例子中看起来像 ./ffi_rust_project/target/thumbv7em-none-eabihf/debug/build/stm32f4xxgpio-f938e89b3fb3229f/out/
.
build.rs
内容或多或少由绑定文档提供,如下所示:
use std::env;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
fn main() {
// Put `memory.x` in our output directory and ensure it's
// on the linker search path.
let out = &PathBuf::from(env::var_os("OUT_DIR").unwrap());
File::create(out.join("memory.x"))
.unwrap()
.write_all(include_bytes!("memory.x"))
.unwrap();
println!("cargo:rustc-link-search={}", out.display());
println!("cargo:rustc-link-lib=static=stm32");
// By default, Cargo will re-run a build script whenever
// any file in the project changes. By specifying `memory.x`
// here, we ensure the build script is only re-run when
// `memory.x` is changed.
println!("cargo:rerun-if-changed=memory.x");
// Tell cargo to invalidate the built crate whenever the wrapper changes
println!("cargo:rerun-if-changed=wrapper.h");
// The bindgen::Builder is the main entry point
// to bindgen, and lets you build up options for
// the resulting bindings.
let bindings = bindgen::Builder::default()
// The input header we would like to generate
// bindings for.
.header("wrapper.h")
.use_core()
.ctypes_prefix("cty")
// Tell cargo to invalidate the built crate whenever any of the
// included header files changed.
.parse_callbacks(Box::new(bindgen::CargoCallbacks))
// Finish the builder and generate the bindings.
.generate()
// Unwrap the Result and panic on failure.
.expect("Unable to generate bindings");
// Write the bindings to the $OUT_DIR/bindings.rs file.
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings!");
}
但是,这里有一些差异。前面存在的一些代码与链接器脚本有关,包括 中的 memory.x
设备内存映射。除此之外,与 bindgen
代码相关的还有两件事需要注意。首先,请注意以下行:
println!("cargo:rustc-link-lib=static=stm32");
此行声明不带 lib
前缀的链接库 stm32
的名称。其次,请注意创建句柄时使用的 use_core()
bindings
and ctypes_prefix("cty")
方法。这些方法是必需的,因为我们需要生成 [no_std]
兼容的绑定。如果没有它们,将创建绑定,假设有 std
支持导致以后的错误。
步骤 8 - 将生成的绑定 main.rs
包含在
现在我们已经创建了绑定,我们需要包含所需的语句以 main.rs
允许我们使用它们。这是通过以下代码完成的:
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
需要编译 #![allow(...)]
指示来抑制一堆不遵循 Rust 风格约定的警告。
最后一步(步骤9) - 集成任何需要的功能
最后,我们可以使用在 中创建 bindings.rs
的函数接口。以下是 Rust blinky 项目中函数的 main
片段,其中一些使用 HAL_GPIO_TogglePin()
函数接口:
const GPIOA: *mut GPIO_TypeDef = GPIOA_BASE as *mut GPIO_TypeDef;
const GPIO_PIN_5: u16 = 0x0020;
#[entry]
fn main() -> ! {
// Application Loop
// Setup handler for device peripherals
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.use_hse(8.MHz()).freeze();
let mut del = dp.TIM1.delay_ms(&clocks);
let gpioa = dp.GPIOA.split();
let _led = gpioa.pa5.into_push_pull_output();
loop {
// Call C function in bindings.rs that toggles pin
unsafe {
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
}
del.delay_ms(1000_u32);
}
}
在此应用程序代码中,我改为将预期的 led.toggle()
函数接口替换为 HAL_GPIO_TogglePin()
函数接口。在这种情况下,使用的任何外部代码都包装在一个 unsafe{}
块中。这实质上意味着 Rust 编译器无法保证此代码的安全性。此外,如果您早期注意到定义 const
,则是一个导入的类型, GPIO_TypeDef
表示具有 GPIO 内存块中寄存器偏移量的结构。此外,是一个导入的, GPIOA_BASE
u32
表示 GPIOA
寄存器在内存中所在的基址。
📝 注意:构建脚本允许用户执行更多操作来自动化构建过程。实际上,我们所做的可能还有更多步骤可以自动化。可以浏览 bindgen 用户指南和 Builder 结构 docs.rs 文档,以了解存在哪些其他方法可以自动化更多流程。
📝 注意:请注意,事情可能看起来比实际简单,并且可能会变得棘手。首先,在使用 Rust FFI 时必须小心,以确保数据在语言之间正确传递,并且一种语言分配的任何内存都被另一种语言正确释放。此外,即使对于我在这篇文章中演示的代码,并非所有导入的函数都按预期工作。例如,我通过使用导入
HAL_Delay()
的函数尝试了延迟。有趣的是,在执行HAL_Delay()
后,应用程序代码在Default_Handler()
中断服务例程中一直无法解释地输入和挂起。
结论
在嵌入式系统的上下文中,Rust 的逐步采用预计将需要与来自 C 或 C++ 等其他语言的现有代码库进行接口。为此,Rust 提供了支持该功能的外部函数接口 (FFI)。此外,在 Rust 生态系统中,存在类似的 bindgen
工具,可以自动连接更复杂的代码库。在这篇文章中,将解释FFI,并提供分步教程,介绍为基于C的STM32 HAL库创建Rust接口的示例。有任何问题/意见?在下面的👇评论中分享您的想法。