如何用 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.getpidffi_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):
| spec | C 类型 | Spinel 侧类型 |
|---|---|---|
:int | int | 整数 |
:uint32/:int32/:int16/:int8/:size_t/:long | 对应宽度整型 | 整数(内部统一为 int64) |
:float/:double | float/double | 浮点 |
:bool | int | 布尔 |
:str | const char * | 字符串(NUL 结尾,按strlen构造结果) |
:binstr | const char * | 二进制安全字符串(仅返回值,可含内嵌 NUL) |
:ptr | void * | 不透明指针;参数位也可传IO::Buffer |
:float_array | const double * | Array<Float>的连续存储指针 |
:int_array | const int64_t * | Array<Int>的连续存储指针 |
:void | void | 仅返回位 |
几个实用细节:
- 数组要自己传长度:
: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 ./blog5.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 垃圾回收跟踪,外国内存完全由你负责,记住两条铁律:
- 显式调用析构函数——没有任何人替你调
sqlite3_close、sqlite3_finalize或free() - 传给 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 编译:
make deps && make构建编译器- 在
module体内写ffi_func/ffi_lib/ffi_buffer等声明 ./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),仅供参考