☰
如何用 Spinel 通过 FFI 直接调用 C 函数:跳过扩展编译步骤的完整教程
2026/10/8 7:06:37 网站建设 项目流程

如何用 Spinel 通过 FFI 直接调用 C 函数:跳过扩展编译步骤的完整教程

【免费下载链接】spinelRuby AOT compiler项目地址: https://gitcode.com/gh_mirrors/spin/spinel

Spinel 是一款 Ruby AOT(提前编译)编译器,它能把 Ruby 源码直接编译为独立的本地可执行文件。它的内置 FFI 能力是本文主角:你只需要在 Ruby 源码里写几行声明,AOT 编译器就会生成直连的 C 调用点——不需要 C 扩展编译、不需要mkmf、不需要require "ffi",就能从 Ruby 直接调用strlen、sqrt、sqlite3_open等 C 函数。本教程面向新手,带你从零跑通第一个 Spinel FFI 程序。

一、为什么可以跳过扩展编译步骤?

在传统 CRuby 中调用 C 函数,通常有两条路:

方式流程痛点
C 扩展(C Extension)写 C 代码 →mkmf.rb→ 编译.so→ 运行时加载每个平台都要重新编译,部署麻烦
ffi gem运行时dlopen+ libffi 间接调用动态查找符号,性能有开销,需系统 libffi

而 Spinel 的思路完全不同:声明写在 Ruby 源码里,编译期就完成一切。

  • ffi_func等声明在module体内,属于编译期语法
  • 代码生成阶段(src/codegen*.c 同级的 FFI 输出逻辑)直接生成 C 的extern声明与调用语句
  • spinel编译器从生成的 C 代码中刮取/* SPINEL_LINK: -lsqlite3 */这类标记注释,自动附加到链接命令
  • 最终产物是一个零运行时依赖的本地二进制

整个过程就是两条命令:

./spinel prog.rb && ./prog

详细说明见官方文档 docs/FFI.md。

二、准备工作:构建 Spinel 编译器

Spinel 不从 RubyGems 分发,从源码构建即可,全程只需系统 C 编译器:

git clone https://gitcode.com/gh_mirrors/spin/spinel cd spinel make deps # 拉取 libprism 解析器(一次性) make # 构建编译器 spinel 和项目工具 spin

构建完成后,仓库根目录会出现spinel可执行文件。它解析 Ruby → 全程序类型推断 → 生成一个 C 文件 → 调用系统cc链接成原生二进制(架构原理见 README.md 的 "How It Works" 一节)。

三、快速上手:5 行代码调用 libc

Spinel 的 FFI 声明都放在module体内,模块名就是函数的命名空间。libc和libm默认就会链接,所以第一个例子连ffi_lib都不用写:

module LibC ffi_func :strlen, [:str], :size_t ffi_func :getpid, [], :int end puts LibC.strlen("hello, world") # => 12 puts LibC.getpid

ffi_func的签名是ffi_func :函数名, [参数类型列表], 返回类型。仓库里有一个可直接运行的完整示例 examples/ffi/libm/libm.rb,它额外声明了cos、sin、sqrt、pow四个数学函数,编译运行:

./spinel examples/ffi/libm/libm.rb ./libm # 输出: # 1 # 4 # 1024 # 12 # pid > 0: true

四、ffi_func 支持哪些类型?

这是新手最常查的一节。类型规格(type spec)与 C 类型的对应关系如下(摘自 docs/FFI.md):

specC 类型Spinel 侧类型
:intint整数
:uint32/:int32/:int16/:int8/:size_t/:long对应宽度整型整数(内部统一为 int64)
:float/:doublefloat/double浮点
:boolint布尔
:strconst char *字符串(NUL 结尾,按strlen构造结果)
:binstrconst char *二进制安全字符串(仅返回值,可含内嵌 NUL)
:ptrvoid *不透明指针;参数位也可传IO::Buffer
:float_arrayconst double *Array<Float>的连续存储指针
:int_arrayconst int64_t *Array<Int>的连续存储指针
:voidvoid仅返回位

几个实用细节:

  • 数组要自己传长度::int_array只给指针,长度作为单独的:size_t参数传入,和:str+strlen的套路一致
  • blocking: true:给耗时调用(数据库 step、阻塞读、sleep)加这个关键字,多线程运行时会让出世界、避免 GC 屏障等待
  • 变参函数:固定参数部分可以声明,尾部加...;但纯变参函数(如printf)不支持,用 Spinel 内置printf代替

五、链接外部库:ffi_lib 与 sqlite3 实战

调用系统库之外的函数,用ffi_lib "name"声明,它会变成链接行的-lname。以 SQLite3 为例(需先安装libsqlite3-dev等开发包):

module SQL ffi_lib "sqlite3" ffi_func :sqlite3_open, [:str, :ptr], :int ffi_func :sqlite3_close, [:ptr], :int ffi_func :sqlite3_errmsg, [:ptr], :str end

仓库自带一个完整的 SQLite 博客系统示例(帖子/标签/评论,含多表 join),两个文件值得精读:

  • 最小 FFI 绑定层:examples/ffi/sqlite/sqlite3_lib.rb
  • 演示程序:examples/ffi/sqlite/blog.rb

运行方式(前置依赖安装步骤见 examples/ffi/sqlite/README.md):

./spinel examples/ffi/sqlite/blog.rb ./blog

5.1 处理 C 的"输出参数":ffi_buffer + ffi_read_ptr

sqlite3_open(path, &db)会写回一个数据库句柄,这是 C API 最常见的模式。Spinel 用两个声明搞定:

ffi_buffer :db_out, 8 # 静态 8 字节缓冲,整个程序生命周期存在 ffi_read_ptr :read_ptr, 0 # 从 buf 偏移 0 处读 void * rc = SQL.sqlite3_open(":memory:", SQL.db_out) db = SQL.read_ptr(SQL.db_out) # 取出真正的 sqlite3*

如果只需要少量结构体字段,ffi_read_u8/u16/u32/u64、ffi_write_*系列可以在任意偏移处读写字节,且对ffi_buffer的越界访问会在编译期被拒绝。

5.2 其他常用声明速查

声明作用用法示例
ffi_const :NAME, 值整型常量,Module::NAME访问ffi_const :SQLITE_OK, 0
ffi_buffer :name, 大小静态字节缓冲(scratch 区 / 输出参数)ffi_buffer :db_out, 8
ffi_struct :Name, [[字段, 类型], ...]声明 C 结构体,自动生成Name_new/Name_get_x/Name_set_x访问器,布局交给 C 编译器见 docs/FFI.md
ffi_callback :name, [参数], 返回声明函数指针类型,可把method(:x)直接传给 C见 docs/FFI.md 中 qsort 例子
ffi_source <<~C ... C把一段 C 源码内嵌进编译单元单文件小适配器场景
ffi_cflags "..."追加头文件目录 / 链接搜索路径等编译参数库装在非标准位置时

ffi_struct的小例子,声明布局由目标 ABI 决定,不用手写偏移:

module M ffi_struct :Point, [[:x, :long], [:y, :long]] end pt = M.Point_new M.Point_set_x(pt, 3) puts M.Point_get_x(pt) # => 3

六、指针安全须知(新手必看)

FFI 强大也危险。:ptr值不受 Spinel 垃圾回收跟踪,外国内存完全由你负责,记住两条铁律:

  1. 显式调用析构函数——没有任何人替你调sqlite3_close、sqlite3_finalize或free()
  2. 传给 C 的字符串只在调用期间有效——Spinel 字符串受 GC 管理,C 若把指针存起来,后续 GC 可能把它释放掉;需要跨调用存活就先拷进ffi_buffer

另外两个小习惯:

  • :ptr为 NULL 时与nil相等,判断db == nil即可
  • C 侧看不到缓冲长度,长度参数务必传buf.size(或由其派生的值),越界读写不会被检查

七、进阶:运行时 FFI 的兜底方案

编译期 DSL 覆盖了绝大多数场景,但如果你要迁移现成的 gem——运行时才计算库路径、函数名来自数据、FFI::Struct布局等——Spinel 直接内置了ffigem 和fiddle两个兼容包:

  • require "ffi":加载 packages/ffi/ 打包的运行时实现,extend FFI::Library、attach_function、MemoryPointer、回调等 gem 风格 API 都可用,底层是 libffi + dlopen(需要系统 libffi)
  • require "fiddle":标准库FiddleAPI,同样基于同一原生层

选型建议:新项目优先用编译期 DSL(每次调用都是直连 C 调用,没有动态查表开销);只有维护存量 gem 代码时才落到require "ffi"。

八、局限性与常见问题

来自官方 docs/FFI.md "Limitations" 一节的明确清单:

  • 不支持纯变参 C 函数(如printf(...))——格式化输出用 Spinel 内置printf
  • :ptr不能进入多态值——不要塞进poly_array或泛型Hash,保持为普通局部变量,或包一个带ptr类型实例变量的类
  • 想完全接管链接过程(静态链接、自定义库路径)?用-c停在 C 代码生成阶段,自己驱动链接器

Q:编译期 DSL 和 ffi gem 能混用吗?能。extend FFI::Library的模块若全是字面量声明,会被编译期 DSL 直接理解;只有真正需要动态能力的部分才走运行时路径。

Q:Windows 上能用吗?Spinel 目标是 POSIX 平台,Windows 建议走 WSL(见 README.md 的 "Portability" 一节)。

九、小结

Spinel 把 "Ruby 调 C" 从一条需要编译扩展、链接动态库的繁琐流水线,压缩成了源码里几行声明 + 一次 AOT 编译:

  1. make deps && make构建编译器
  2. 在module体内写ffi_func/ffi_lib/ffi_buffer等声明
  3. ./spinel prog.rb && ./prog,拿到零依赖的原生二进制

想继续深挖,按这条路径走效率最高:docs/FFI.md(完整 DSL 规范与类型表)→ examples/ffi/libm/libm.rb(最小示例)→ examples/ffi/sqlite/(输出参数、join 查询等真实场景)。

【免费下载链接】spinelRuby AOT compiler项目地址: https://gitcode.com/gh_mirrors/spin/spinel

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询