Rust FFI 调用的疑惑

Rust FFI 调用的疑惑

总结:Rust 的 FFI 就是 C/C++ 那套编译链接模型,只是包了一层 unsafe 的皮。

最近使用 FFI 的交互比较多,通过 FFI 的交互来窥探 FFI 的使用技巧以及注意事项。在学习 rCoreOS 教程的过程中,遇到的场景全部是上述三种情况,无例外,其他操作也都是基于这些内容展开的。

遇到一个典型案例:在编写裸机 OS 时,需要摒弃 std 库中所有内容,转而使用no_std环境;其次,需要通过汇编来调整整个 OS 的入口函数。
可以这样理解:我们平时编写的普通软件,入口函数都是编译器根据现代操作系统的抽象自动生成的;而在裸机环境下,需要遵循不同的规范、手动定义入口地址,相当于从自动挡变成手动挡

在上述需求中,需要同时使用到链接脚本、汇编、Rust 源码三部分。

汇编代码及其注释

这里只需关注_start和call rust_main,其他可忽略

# os/src/entry.asm
    # 定义一个 .text.entry 段
    .section .text.entry
    # 定义全局符号,链接时所有目标文件可见
    .globl _start

# _start 是一段代码的起始地址
_start:
    # 将栈顶地址加载到 sp 寄存器
    la sp, boot_stack_top
    # 跳转到 rust_main 函数执行
    call rust_main

    .section .bss.stack
    .globl boot_stack_bottom
boot_stack_bottom:
    # 分配 64KiB 栈空间
    .space 4096 * 16
    .globl boot_stack_top
boot_stack_top:

汇编功能:

  1. 定义.text.entry代码段
  2. 定义全局符号_start
  3. 设置栈指针,为Rust运行准备栈空间
  4. 调用rust_main进入 Rust 主逻辑
  5. 定义栈空间(BSS 段分配)

链接脚本

链接脚本的作用:将所有汇编生成的 .o 文件重新组织、合并段、解决冲突、将符号转换为最终内存地址,生成可执行文件。

OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80200000;

SECTIONS
{
    . = BASE_ADDRESS;
    start_kernel = .;

    start_text = .;
    .text : {
        *(.text.entry)  # 入口段放在最前面
        *(.text)
    }
    . = ALIGN(4K);
    end_text = .;

    start_rodata = .;
    .rodata : {
        *(.rodata .rodata.*)
        *(.srodata .srodata.*)
    }
    . = ALIGN(4K);
    end_rodata = .;

    start_data = .;
    .data : {
        *(.data .data.*)
        *(.sdata .sdata.*)
    }
    . = ALIGN(4K);
    end_data = .;

    .bss : {
        *(.bss.stack)     # 栈放在 BSS 最前面
        start_bss = .;    # BSS 段起始
        *(.bss .bss.*)
        *(.sbss .sbss.*)
    }
    . = ALIGN(4K);
    end_bss = .;

    end_kernel = .;
    /DISCARD/ : {
        *(.eh_frame)
    }
}

链接脚本核心作用:

  1. 指定架构、入口符号、基地址
  2. 重新排布段顺序,确保入口代码在指定位置
  3. 定义内核各段(text/rodata/data/bss)的布局
  4. 明确start_bss/end_bss等符号地址

Rust 代码实现

#![no_main]
#![no_std]

global_asm!(include_str!("entry.asm"));

#[allow(function_casts_as_integer)]
#[unsafe(no_mangle)]
fn rust_main() {
    clear_bss(); // 手动清零 BSS

    unsafe extern "C" {
        // 汇编中定义的栈顶/栈底符号
        fn boot_stack_top();
        fn boot_stack_bottom();
    }
}

/// 操作系统内核必须手动清零 BSS 段
#[allow(function_casts_as_integer)]
fn clear_bss() {
    unsafe extern "C" {
        // 链接脚本中定义的地址符号
        fn start_bss();
        fn end_bss();
    }

    (start_bss as usize..end_bss as usize).for_each(|x| unsafe {
        (x as *mut u8).write_volatile(0);
    });
}

核心原理解析

重点是fn clear_bss()#[unsafe(no_mangle)]:前者实现 BSS 段清零,后者保证函数名称不被编译器混淆,保持固定符号名。

重点来看clear_bss():内部通过 C ABI 规范 进行 FFI 交互,声明了链接脚本中定义的start_bssend_bss两个符号地址。因为链接脚本只有段、符号、地址三种概念,不存在任何数据类型。

但是这里导入的是fn start_bss();明明只是地址,却声明为函数类型,这里有两个核心疑问:

  1. 地址在什么时候、以什么方式获取?
  2. 为什么要声明为函数类型?

一、地址在什么时候、怎么获取?

编译阶段,Rust 编译器只知道start_bss是外部符号,不会分配地址,只在目标文件中留下占位符。

真正的地址在链接阶段(link time) 确定:链接器合并所有.o文件,根据链接脚本的内存布局,为每个符号分配最终物理地址,并将符号名替换为对应地址。

所以:符号的值 = 链接器分配的地址运行时,符号已经是常量地址。

二、为什么要声明为函数类型?

这触及了 FFI 最核心的本质:

  1. 链接脚本只有符号、地址、段,没有任何数据类型
  2. Rust FFI 必须给外部符号一个合法类型,否则无法编译
  3. 函数是 C ABI 下最简洁、零开销、稳定表示地址的占位类型

写成fn start_bss()不代表它真的是函数,我们永远不会调用它,它只是一个地址占位符。真正使用时,直接转为整数地址start_bss as usize。这也体现 FFI 核心规则:

  1. FFI 只共享内存地址,不共享类型信息
  2. 内存如何解析,完全依赖 C ABI 约定
  3. 内存遵循 谁申请谁释放,不能跨边界释放

三、函数类型 与 static 类型的本质区别

这里不一定非要声明为函数,也可以写成unsafe extern "C" { static start_bss: usize; },函数类型 和static类型作用完全一致:仅仅是获取符号地址。选择哪一种只取决于习惯,不影响最终地址获取。但是,声明函数是社区通用的约定

两者的区别仅存在于 Rust 编译器阶段:

  1. fn 被编译器视为 代码地址(.text 段属性)
  2. static 被编译器视为 数据地址(.data/.bss 段属性)

但到了 链接器与优化阶段,这些差异会被完全抹平。最终生成的取地址代码、运行时行为完全一样,都只是一个立即数地址。

汇编中的符号

汇编中定义的符号boot_stack_topboot_stack_bottom,与上文链接器中使用、声明的clear_bss原理完全相同;想要在 Rust 编译后的目标文件(object 文件)中找到这些符号,必须将其声明为全局符号,使其对所有汇编文件、目标文件可见,链接器才能完成符号查找与地址绑定。

同理,所有的其他高级语言的 FFI 交互原理都是如此

以下待补充

动态链接(以 libc.so 为例)

这是最常见的 FFI 场景。你的程序依赖系统库(如libc.so),编译时只知道符号名,运行时由系统动态链接器加载。也就是说编译时需要本地有,运行时需要在运行的机器上有

你不需要手动加载任何东西。编译时添加-l c标记,系统加载器(Linux上的ld.so)会在程序启动时自动加载libc.so并解析这些符号。

核心原理

动态链接的本质与静态链接相同:FFI 只共享地址,不共享类型。区别在于地址确定的时间:

  1. 静态链接:链接器直接把地址写进二进制
  2. 动态链接:加载器把地址填到 GOT/PLT 表中,程序启动时才确定

但最终 Rust 代码看到的,仍然是一个地址常量(虽然它实际上指向 PLT 中的跳转表项)。

运行时加载

这是更灵活的方式:你在运行时主动决定加载哪个库、获取哪个函数。

posted @ 2026-01-21 00:55  咕咚!  阅读(14)  评论(0)    收藏  举报