我越听说在不同的渠道采用嵌入式 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:

  1. 从 C 调用 Rust 代码:要从 C 调用 Rust 代码,你需要在 Rust 中定义一个可以从其他语言调用的“外来函数”。这通常使用 Rust extern 的关键字和 C 兼容的函数签名来完成。然后,您需要将 Rust 代码编译为 C 兼容的动态库(例如 .dll 或 .so 文件),并将其链接到其他语言代码。

  2. 从 Rust 调用 C 代码:要从 Rust 调用用 C 编写的代码,可以使用 Rust 的 extern 关键字和 lib 属性来定义一个与你想要调用的代码对应的“外来函数”。您还需要使用诸如“libc”之类的 Rust crate,它提供与 C 兼容的类型和函数来与外部代码进行接口。

一般来说,使用 Rust FFI 的过程将涉及使用适当的签名定义 Rust 函数,将 Rust 代码编译为库,并在运行时或静态地将该库链接到其他语言代码。下图显示了流的示例。如图所示,有两个独立的工具链,链接器是两者之间的公共部分。例如,如果我们想从 C 导入一个 square() 函数以在 Rust 中使用,通常步骤如下:

  1. 我们将编译 C 标头和代码,生成目标文件(扩展名为 .o)。

  2. 使用存档工具和创建的对象文件作为输入,创建链接器将利用的静态(或动态)库。

  3. 切换到 Rust 端,在 Rust 应用程序中,需要使用 extern 关键字来指定外部函数和一个附加 lib 属性来指定包含该函数的静态库(从 C 工具链创建)。

  4. 构建 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接口的示例。有任何问题/意见?在下面的👇评论中分享您的想法。