第二节 组件 crate_interface - 打破循环依赖

在基于 Rust 语言开发时,可以通过引用现有的 crate 来引入其功能,基本方式就是在 Cargo.toml 中建立对外部 crate 的依赖,然后就可以像引用内部模块一样调用它的代码。但是这种方式是有局限性的,如果两个 crate 之间存在相互依赖,或者多个 crate 存在环形依赖,都会阻碍编译。我们称这种问题为循环依赖。

我们的内核基于组件化思想并且采用 Rust 语言实现,在这种开发条件下,crate 是作为组件的实现形式。在后面的开发过程中,我们将经常会遇到循环依赖的问题,例如下节将要引入的 axlog 日志组件:

打破循环依赖LogIf

组件 axruntime 在初始化时,将会初始化 axhal 和 axlog 这两个组件。对于 axhal 和 axlog 这两个组件来说,一方面,axhal 组件需要日志功能,所以依赖 axlog 组件;与此同时,axlog 必须依赖 axhal 提供的标准输出或写文件功能以实现日志输出,所以 axlog 又反过来依赖 axhal。这就在二者之间形成了循环依赖。所以我们必须想办法替换其中一个方向的依赖,以打破循环依赖。

在 Rust 开发中,还存在一种 extern ABI 的机制,见 Rust 参考手册External blocks - The Rust Reference (rust-lang.org) 中关于 ABI 的那一节。形式上采用 extern "Rust" 来声明外部函数,随后就可以在当前 crate 中直接调用该函数。本质上这是一种在低级的 ABI 层面进行符号引用的方法,好处是不用在 Cargo.toml 中定义依赖,可以跳出循环依赖的限制;但是不利之处在于,它是 unsafe 方式,Rust 不做参数正确性检查,不保证其安全性。

为了提高上述方式的可读和易用性,降低编码出错的可能,我们创建一个组件 crate_interface,基于一组过程宏 proc_macro 来封装 extern ABI 的机制。以后在我们的内核开发中,将会经常用到这个组件。

下面仍然以 axhal 和 axlog 为例,说明 crate_interface 的用法。我们选择让 axhal 通过 Cargo.toml 的 [dependencies] 对 axlog 建立依赖,而 axlog 对 axhal 的反向引用基于 crate_interface 提供的机制,如下图:

\crate_interface
  1. 通过 #[def_interface] 宏,在 axlog 中定义一个跨 crate 的接口 LogIf

    接口 LogIf 包含调用方和实现方交互的两个具体方法,分别是写字符串 write_str() 和获取当前时间 get_time()。为方便,直接把 LogIf 定义在 axlog 中。

    // axlog/src/lib.rs
    #![no_std]
    
    #[crate_interface::def_interface]
    pub trait LogIf {
        fn write_str(s: &str);
        fn get_time() -> core::time::Duration;
    }
    
    // axlog/Cargo.toml
    [dependencies]
    crate_interface = "0.1.1"

    第 4 行:过程宏 crate_interface::def_interface 在预编译的过程中,对 Trait LogIf 的声明进行处理。

    预编译处理后,形成如下形式的代码。

    extern "Rust" {
    	fn __LogIf_write_str(s: &str);
        fn __LogIf_get_time() -> core::time::Duration;
    }

    可以看到,实际上就是声明了一组 extern "Rust" 的外部函数,每个函数对应 LogIf 的一个方法,函数名是接口名+对应方法名。这样将来在引入了 LogIf 接口的代码文件中,就可以调用这些外部函数了。当然我们不需要直接调用,继续看下面的封装。

  2. 在 axhal 中定义 LogIfImpl 实现 LogIf 接口,并通过 #[impl_interface] 宏进行标记,说明它是一个跨 crate 的接口实现:

    // /axhal/src/riscv64.rs
    struct LogIfImpl;
    
    #[crate_interface::impl_interface]
    impl axlog::LogIf for LogIfImpl {
        fn write_str(s: &str) {
            console::write_bytes(s.as_bytes());
        }
    
        fn get_time() -> core::time::Duration {
            time::current_time()
        }
    }
    
    // axhal/Cargo.toml
    [dependencies]
    axconfig = { path = "../axconfig" }
    page_table =  { path = "../page_table" }
    axlog = { path = "../axlog" }
    crate_interface = "0.1.1"

    第 4 行:过程宏 crate_interface::impl_interface 在预编译阶段,处理第 5 到第 13 行的 LogIfImpl 实现。

    效果是新增了如下形式的代码:

    extern "Rust" {
    	fn __LogIf_write_str(s: &str) {
            let IMPL: LogIfImpl = LogIfImpl;
            IMPL.write_str(s)
        }
        fn __LogIf_get_time() -> core::time::Duration {
            let IMPL: LogIfImpl = LogIfImpl;
            IMPL.get_time()
        }
    }

    实际就是生成了两个框架函数 __LogIf_write_str 和 __LogIf_get_time,符号名与 def_interface 生成的两个外部函数声明是对应一致的。它们同样是放在 extern "Rust" 代码块中,将作为跨 crate 的符号使用。当其它 crate 调用这两个框架函数时,它们在内部生成 LogIfImpl 的临时实例,并调用其对应的实现方法。

  3. 通过宏 call_interface!(LogIf::XXX) 调用 LogIf 的实现

    在 axlog 中定义函数 init(),在其中通过 call_interface! 这个宏去调用接口 LogIf,如下:

    pub fn init() {
        extern crate alloc;
    
        let now = crate_interface::call_interface!(LogIf::get_time());
        let s = alloc::format!("Logging startup time: {}.{:06}",
            now.as_secs(), now.subsec_micros());
        crate_interface::call_interface!(LogIf::write_str(s.as_str()));
    }
    

    第 4 行:call_interface! 宏调用 LogIf::get_time,预编译后该宏被替换为如下形式的代码:

    unsafe {
    	__LogIf_get_time()
    }

    第 7 行:call_interface! 宏调用 LogIf::write_str,预编译后该宏被替换为如下形式的代码:

    unsafe {
    	__LogIf_write_str(s)
    }

    也就是说第 4 和第 7 行会转化为对这两个跨 crate 外部函数的调用。回顾前面的第 1 项已经通过 def_interface 宏处理 LogIf,声明了这两个符号,这里直接调用即可。最后,Rust 链接器会对调用方和实现方的符号进行链接,由此在 ABI 层面完成调用关系的建立。

  4. 下面来验证 crate_interface 的功能。

    在 axruntime 组件的初始化过程中,初始化 axlog。

    //axruntime/src/lib.rs
    #[no_mangle]
    pub extern "C" fn rust_main(_hartid: usize, _dtb: usize) -> ! {
        extern "C" {
            fn _skernel();
            fn main();
        }
    
        println!("\nArceOS is starting ...");
    
        // We reserve 2M memory range [0x80000000, 0x80200000) for SBI,
        // but it only occupies ~194K. Split this range in half,
        // requisition the higher part(1M) for early heap.
        axalloc::early_init(_skernel as usize - 0x100000, 0x100000);
    
        axlog::init();
    
        unsafe { main(); }
        loop {}
    }
    
    // axruntime/Cargo.toml
    [dependencies]
    axhal = { path = "../axhal" }
    axalloc = { path = "../axalloc" }
    axlog = { path = "../axlog" }

    注意:第 16 行调用 axlog::init() 中需要动态申请内存,所以它必须放在第 14 行 axalloc::early_init 动态内存初始化之后。

    执行 make run,查看结果:

    ArceOS is starting ... Logging startup time: 0.122987 Now: 0.126629 Hello, ArceOS![from String] Elapsed: 0.001787

    上面打印了 logging 成功启动的信息!当然本节主要目标是介绍 crate_interface 组件,axlog 只是作为示例引入,下一节我们将会给出 axlog 组件的完整实现。

最后来总结一下,组件 crate_interface 实现了如下的效果:

基于预定义的接口 trait,在任意 crate 中对该接口的功能实现,可以被其它任意 crate 中的代码所调用。实现方和调用方之间在 ABI 层完成链接,不需要通过 Cargo.toml 建立依赖就可以实现跨 crate 的接口调用,可以跳出循环依赖的限制。