Babashka 1.13.220 新增 FFI:Clojure 脚本直接调用 C 库
Clojure 开发者 Michiel Borkent 在个人博客宣布,Babashka 1.13.220 正式发布,新版本引入了 babashka.ffi 命名空间,允许从 Babashka 脚本中直接调用 C 库。同时,babashka.ffi 库也可以作为独立库用于 JVM Clojure,因此在常规的 Clojure 项目中也可以使用。需要注意的是,该 API 目前仍是实验性的,虽然目前没有计划进行更改,但需要更多的曝光和用户反馈。
背景:Babashka 是什么
Babashka 简介
Babashka 是一个用于快速启动 Clojure 脚本的工具:
- 快速启动:使用 GraalVM native-image 编译,启动时间在毫秒级
- Clojure 语法:使用完整的 Clojure 语言语法
- 内置库:内置了常用的 Clojure 库(clojure.core、clojure.string、clojure.java.io 等)
- 脚本友好:适合编写 shell 脚本、自动化任务、开发工具
- 跨平台:支持 Linux、macOS、Windows
为什么需要 FFI
在 Babashka 中调用 C 库之前,开发者面临几个问题:
- 性能瓶颈:某些计算密集型任务用 Clojure 实现较慢,需要调用 C 实现
- 系统交互:需要调用系统级 C API(如文件系统、网络、进程管理)
- 现有库复用:有大量成熟的 C 库可以复用(如 zlib、openssl、sqlite)
- 原生功能:某些功能只有 C 库提供(如特定硬件的驱动接口)
- JVM 已有方案:JVM Clojure 可以通过 JNA/JNI 调用 C 库,但 Babashka 之前没有对应方案
babashka.ffi 入门
加载系统库
第一个示例加载系统中的 libz(zlib 压缩库)并获取版本:
(require '[babashka.ffi :as ffi :refer [defcfn]])
;; 加载系统库 zlib
(def zlib (ffi/load-system-library "z"))
;; 定义 C 函数 zlibVersion,返回字符串,无参数
(defcfn zlib-version zlib "zlibVersion" String [])
;; 调用
(println (zlib-version))
;; 输出: 1.3.1
加载自定义库
除了系统库,也可以加载自定义的 C 库:
;; 加载指定路径的库
(def mylib (ffi/load-library "/path/to/libmylib.so"))
;; macOS 上的 .dylib
(def mylib-mac (ffi/load-library "/path/to/libmylib.dylib"))
;; Windows 上的 .dll
(def mylib-win (ffi/load-library "C:/path/to/mylib.dll"))
核心 API
load-system-library
加载系统库,根据操作系统自动查找库文件:
(ffi/load-system-library "z")
;; Linux: libz.so
;; macOS: libz.dylib
;; Windows: z.dll
参数:
lib-name:库名称(不需要前缀lib和后缀)
返回:库对象,用于后续的函数定义。
load-library
加载指定路径的库:
(ffi/load-library "/absolute/path/to/libfoo.so")
(ffi/load-library "relative/path/libfoo.so")
参数:
path:库文件的路径(绝对或相对)
返回:库对象。
defcfn
定义 C 函数,这是最核心的宏:
(defcfn function-name library "c_function_name" ReturnType [ArgType1 ArgType2 ...])
参数说明:
function-name:Clojure 中的函数名library:库对象(由 load-library 或 load-system-library 返回)"c_function_name":C 函数的名称ReturnType:返回值类型[ArgType1 ArgType2 ...]:参数类型列表
支持的类型
| Clojure 类型 | C 类型 | 说明 |
|---|---|---|
String | const char* | 字符串(以 null 结尾) |
Integer / Long | int / long | 整数 |
Double / Float | double / float | 浮点数 |
Boolean | int(0/1) | 布尔值 |
Void | void | 无返回值 |
Pointer | void* | 通用指针 |
ByteBuffer | void* | 字节缓冲区 |
| 数组类型 | type* | 数组指针 |
更多示例
示例 1:调用 libc 的函数
(require '[babashka.ffi :as ffi :refer [defcfn]])
;; 加载 libc
(def libc (ffi/load-system-library "c"))
;; getpid: 返回进程 ID
(defcfn getpid libc "getpid" Integer [])
(println "进程 ID:" (getpid))
;; getenv: 获取环境变量
(defcfn getenv libc "getenv" String [String])
(println "HOME:" (getenv "HOME"))
;; system: 执行 shell 命令
(defcfn system libc "system" Integer [String])
(system "echo Hello from C")
示例 2:使用 zlib 压缩
(require '[babashka.ffi :as ffi :refer [defcfn]])
(require '[clojure.java.io :as io])
(def zlib (ffi/load-system-library "z"))
;; compress2: 压缩数据
;; int compress2(Bytef *dest, uLongf *destLen, const Bytef *source, uLong sourceLen, int level)
(defcfn compress2 zlib "compress2" Integer [Pointer Pointer Pointer Integer Integer])
;; uncompress: 解压数据
(defcfn uncompress zlib "uncompress" Integer [Pointer Pointer Pointer Integer])
(defn compress-bytes [data level]
(let [src (byte-array data)
dest-len (+ (alength src) 12)
dest (byte-array dest-len)
dest-len-ptr (int-array [dest-len])]
(compress2 dest dest-len-ptr src (alength src) level)
(java.util.Arrays/copyOf dest (aget dest-len-ptr 0))))
(defn uncompress-bytes [data original-len]
(let [src (byte-array data)
dest (byte-array original-len)
dest-len-ptr (int-array [original-len])]
(uncompress dest dest-len-ptr src (alength src))
dest))
示例 3:调用 SQLite
(require '[babashka.ffi :as ffi :refer [defcfn]])
(def sqlite (ffi/load-system-library "sqlite3"))
;; 打开数据库
(defcfn sqlite3-open sqlite "sqlite3_open" Integer [String Pointer])
;; 执行 SQL
(defcfn sqlite3-exec sqlite "sqlite3_exec" Integer [Pointer String Pointer Pointer Pointer])
;; 关闭数据库
(defcfn sqlite3-close sqlite "sqlite3_close" Integer [Pointer])
(defn query [db-path sql]
(let [db-ptr (make-array Pointer 1)
_ (sqlite3-open db-path db-ptr)
db (aget db-ptr 0)]
(try
(sqlite3-exec db sql nil nil nil)
(finally
(sqlite3-close db)))))
示例 4:JVM Clojure 中使用
babashka.ffi 也可以作为独立库在 JVM Clojure 中使用:
;; 在 deps.edn 中添加依赖
{:deps {babashka/ffi {:mvn/version "0.1.0"}}}
;; 在 JVM Clojure 中使用
(require '[babashka.ffi :as ffi :refer [defcfn]])
(def libc (ffi/load-system-library "c"))
(defcfn getpid libc "getpid" Integer [])
(println (getpid))
这意味着同一份代码可以在 Babashka 和 JVM Clojure 中运行,提高了可移植性。
工作原理
基于 GraalVM 的 FFI
Babashka 使用 GraalVM native-image 编译,因此 babashka.ffi 底层基于 GraalVM 的 FFI 机制:
- GraalVM Native Image FFI:GraalVM 提供了调用原生库的机制
- JNI 兼容:在 JVM 模式下,可能使用 JNA 或 JNI
- 类型映射:Clojure 类型与 C 类型的自动映射
- 内存管理:自动处理指针和内存分配
函数调用流程
Clojure 代码调用 (my-c-func arg1 arg2)
│
▼
babashka.ffi 类型检查和转换
│
▼
GraalVM FFI / JNA 调用 C 函数
│
▼
C 函数执行
│
▼
返回值转换为 Clojure 类型
│
▼
返回给 Clojure 代码
注意事项和限制
1. API 仍是实验性的
- 可能变更:API 可能在未来版本中发生变更
- 需要反馈:作者鼓励用户提供反馈和使用案例
- 生产环境谨慎:在生产环境中使用时需要评估稳定性
2. 类型安全
- 运行时检查:类型错误在运行时才会被发现
- 崩溃风险:错误的类型映射可能导致程序崩溃(segfault)
- 建议:仔细核对 C 函数签名,确保类型映射正确
3. 内存管理
- 手动管理:某些情况下需要手动管理内存
- 指针有效性:确保指针在使用时仍然有效
- 内存泄漏:不当使用可能导致内存泄漏
- 建议:对于复杂的内存管理,考虑使用 Clojure 的
with-open或类似模式
4. 线程安全
- C 库可能不是线程安全的:某些 C 库使用全局状态,不支持多线程并发调用
- 建议:查阅 C 库的文档,了解其线程安全特性
- 同步:必要时使用 Clojure 的锁或原子变量进行同步
5. 平台差异
- 库名称差异:不同操作系统的库名称和路径可能不同
- 函数签名差异:某些 C 函数在不同平台上的签名可能不同
- 建议:使用
load-system-library自动处理平台差异,或使用条件编译
6. 错误处理
- C 错误码:C 函数通常通过返回错误码表示失败
- 不会自动抛出异常:babashka.ffi 不会自动将 C 错误码转换为 Clojure 异常
- 建议:在 Clojure 代码中检查返回值,手动处理错误
性能考虑
优势
- 原生性能:C 函数的执行速度通常比 Clojure 快
- 低开销:FFI 调用的开销相对较小
- 适合计算密集型:适合调用计算密集型的 C 库(如压缩、加密、数值计算)
开销
- 类型转换:Clojure 类型与 C 类型之间的转换有一定开销
- 函数调用:跨语言函数调用有一定开销
- 不适合频繁小调用:对于频繁的小函数调用,FFI 开销可能占比较大
- 建议:尽量批量处理,减少 FFI 调用次数
与其他方案的对比
| 维度 | babashka.ffi | JNA | JNI | Clojure interop (Java) |
|---|---|---|---|---|
| 适用环境 | Babashka + JVM | JVM | JVM | JVM |
| 易用性 | 高(宏定义) | 中 | 低(需要 C 代码) | 高 |
| 性能 | 高 | 中 | 高 | 中 |
| 类型安全 | 运行时 | 运行时 | 编译期 | 编译期 |
| 跨平台 | 支持 | 支持 | 需要编译 | 支持 |
| 学习曲线 | 低 | 中 | 高 | 低 |
| 适用场景 | Babashka 脚本 | JVM 通用 | 高性能需求 | Java 库可用时 |
实际应用场景
1. 系统管理脚本
- 调用系统 C API 获取系统信息(CPU、内存、磁盘)
- 调用 libc 进行进程管理
- 调用系统库进行网络操作
2. 数据处理
- 调用 zlib 进行压缩/解压
- 调用 openssl 进行加密/解密
- 调用 sqlite 进行嵌入式数据库操作
- 调用 libxml2 进行 XML 解析
3. 科学计算
- 调用 BLAS/LAPACK 进行矩阵运算
- 调用 FFTW 进行快速傅里叶变换
- 调用 GSL(GNU Scientific Library)进行科学计算
4. 开发工具
- 调用 libgit2 进行 Git 操作
- 调用 libcurl 进行 HTTP 请求
- 调用 imagemagick 进行图像处理
5. 硬件交互
- 调用串口库进行串口通信
- 调用 GPIO 库进行树莓派等硬件的 GPIO 操作
- 调用 USB 库进行 USB 设备交互
未来展望
短期
- API 稳定:根据用户反馈稳定 API
- 更多类型支持:支持更多 C 类型(结构体、联合体、回调函数)
- 更好的文档:完善文档和示例
- 错误处理改进:可能添加自动错误处理机制
中期
- 结构体支持:支持 C 结构体的定义和操作
- 回调函数:支持将 Clojure 函数作为 C 回调函数
- 内存管理工具:提供更好的内存管理工具
- 调试工具:提供 FFI 调用的调试工具
长期
- 自动绑定生成:自动从 C 头文件生成 Clojure 绑定
- 类型安全增强:编译期类型检查
- 跨语言互操作:支持更多语言(C++、Rust、Go)的互操作
- 标准化:成为 Clojure 生态中 FFI 的标准方案
总结
Babashka 1.13.220 引入的 babashka.ffi 命名空间是一个重要的新功能,它让 Babashka 脚本可以直接调用 C 库,大大扩展了 Babashka 的能力边界。
核心要点:
- 背景:Babashka 是快速启动的 Clojure 脚本工具,之前缺少调用 C 库的能力
- 核心 API:
load-system-library(加载系统库)、load-library(加载指定路径库)、defcfn(定义 C 函数) - 支持类型:String、Integer、Long、Double、Float、Boolean、Void、Pointer、ByteBuffer、数组
- 双平台支持:同时支持 Babashka 和 JVM Clojure,提高可移植性
- 应用场景:系统管理、数据处理、科学计算、开发工具、硬件交互
- 注意事项:API 实验性、类型安全、内存管理、线程安全、平台差异、错误处理
- 性能:原生性能优势,但有类型转换和函数调用开销,适合计算密集型任务
对于 Babashka 用户和 Clojure 开发者来说,babashka.ffi 提供了一个强大的工具,可以在保持 Clojure 开发效率的同时,利用丰富的 C 库生态。这使得 Babashka 不仅适合编写简单的脚本,也能够处理需要原生性能和系统级交互的复杂任务。
正如作者所说,API 仍是实验性的,需要更多的曝光和用户反馈。对于有 FFI 需求的开发者来说,现在是尝试并提供反馈的好时机。
原文链接:https://blog.michielborkent.nl/babashka-ffi.html