第 7 部分 · 走向生产:工程化与实战
FFI——当 Racket 需要调用 C
Racket 不被困在自己的生态里——它的 FFI 让你直接调用 C 的共享库,把两门语言的能力拼在一起。这篇讲清调用、内存管理和最常见的坑。
Racket 不是被困在自己生态里的语言——遇到现成的 C 库,你不必重写,直接调就行。它的 FFI(ffi/unsafe)基于 libffi,能做的事包括:
- 调用已有共享库(
.so/.dylib/.dll)中的函数 - 将 Racket 的 procedure 作为 C 函数指针传给 C(回调)
- 分配/传递内存、操作指针与结构体
重要安全提示:
ffi/unsafe是“不安全”的 ——类型不匹配、错误的内存管理或错误的调用约定会导致 Racket 进程崩溃(segfault)。在生产代码中要格外小心。
1. 准备工作(如何编译共享库)
假设你写了 add.c:
// add.c
int add(int a, int b) { return a + b; }
在 Linux(gcc):
gcc -shared -fPIC -o libadd.so add.c
在 macOS(clang):
clang -shared -fPIC -o libadd.dylib add.c
# 或者用 dynamiclib 形式:
clang -dynamiclib -o libadd.dylib add.c
在 Windows(MSVC):
cl /LD add.c /Fe:add.dll
把生成的库放到可加载路径或与 Racket 程序同目录(示例里我会用相对路径 ./libadd.so / ./libadd.dylib)。
2. 基本概念与主要 API 快速参考
首先在 Racket 中导入 FFI:
(require ffi/unsafe) ; 核心 FFI
(require ffi/unsafe/define) ; 可选,帮你写一组绑定的宏(后文示例)
加载库:
(ffi-lib <path-or-#f-or-name>)返回一个ffi-lib值,通常传#f或库路径。#f` 表示“默认库集合”(可查找系统库名)。
查找函数并生成 Racket 函数:
(get-ffi-obj "symbol-name" lib (_fun <arg-ctypes> -> <ret-ctype>))返回一个可以直接调用的 Racket 函数。
常用 C 类型(部分):
_int_int32_int64_uint8_double_float_void_string(用作 C 风格的 null-terminatedchar *)_pointer:一般 C 指针_fun:函数类型构造器,如(_fun _int _int -> _int)表示int f(int,int)_bytes、_array、_struct等(处理复杂/缓冲区/结构体)
内存管理 & 指针操作(常见):
malloc,free(FFI 文档里有相应包装或你可绑定 libc 的 malloc)ptr-ref,ptr-set!,ptr-add,cast等用于读取/写入指针、把指针 cast 为字符串 /bytes/ 数组
(下文会用到 malloc / cast / ptr-ref 的示例)
3. 示例 1:调用简单的 C 函数(add)
C 代码(见上文 add.c),编译后生成 libadd.so / libadd.dylib。
Racket 代码:
#lang racket/base
(require ffi/unsafe)
;; 加载库(相对路径)
(define lib (ffi-lib "./libadd.so")) ; macOS -> "./libadd.dylib", Windows -> "add.dll"
;; 查找符号并声明类型:int add(int,int)
(define add
(get-ffi-obj "add" lib
(_fun _int _int -> _int)))
;; 使用
(printf "2 + 3 = ~a\n" (add 2 3))
运行会打印 2 + 3 = 5。
注意:函数名大小写和导出名必须和你编译的共享库一致(有时候 C++ 需要
extern "C")。
4. 示例 2:C 调用 Racket 作为回调(function pointer)
C 函数示例 call_twice.c:
// call_twice.c
// f: int (*f)(int)
// returns f(x) + f(x)
int callTwice(int (*f)(int), int x) {
return f(x) + f(x);
}
编译为共享库。
Racket 端:
#lang racket/base
(require ffi/unsafe)
(define lib (ffi-lib "./libcall_twice.so")) ; adjust name
;; 声明 callTwice:第一个参数是 function pointer (_fun _int -> _int)
(define call-twice
(get-ffi-obj "callTwice" lib
(_fun (_fun _int -> _int) _int -> _int)))
;; 在 Racket 中定义一个普通 procedure
(define (square x) (* x x))
;; 直接把 Racket procedure 传给 C,FFI 会自动把它包装成 C 函数指针
(printf "call-twice(square,3) -> ~a\n" (call-twice square 3))
输出应该是 18(3*3 + 3*3)。
要点:
- 当 C 期待函数指针时,FFI 可以把 Racket 的 procedure 自动转换成可被 C 调用的函数指针(libffi closure)。
- 保持引用:如果你把回调传给 C 并期望 C 在未来某个时间调用它,必须确保 Racket 端保留引用(避免被 GC 回收)。通常把回调存到顶层变量或结构中,直到你不再需要它为止。
- 回调的并发与线程模型复杂(见文档),跨 Place / 不同执行上下文的回调可能无法工作。
5. 示例 3:分配缓冲区、C 写入,再在 Racket 读字符串
C 代码 fill.c:
// fill.c
# include <string.h>
void fill(char *buf, int n) {
const char *msg = "hello from c";
strncpy(buf, msg, n-1);
buf[n-1] = '\\0';
}
Racket 代码:
#lang racket/base
(require ffi/unsafe)
(define lib (ffi-lib "./libfill.so"))
(define fill
(get-ffi-obj "fill" lib
(_fun (_pointer _int) -> _void)))
;; 分配原始内存(raw bytes)
(define SIZE 64)
(define buf (malloc 'raw SIZE)) ; 分配 SIZE 字节
;; 调用 C,C 会写入 buf
(fill buf SIZE)
;; 把 buf cast 为 C 字符串,再读成 Racket 字符串
(define s (cast buf _pointer _string))
(printf "C wrote: ~a\n" s)
;; 释放(如果你用的 malloc 是 libc malloc,你应该绑定 free 并调用它)
解释与注意:
malloc、cast、_pointer、_string这些 API 让你在 Racket 与 C 内存表示之间转换。- 有时候你可能直接使用 Racket 的 byte string(
make-bytes)并传bytes,FFI 会在需要时做转换或复制(见文档关于_bytes与_ptr)。 - 一定要确认你分配的内存如何释放:如果你用 libc 的
malloc,就需要在 Racket 绑定free并调用之。
6. 结构体与指针(简介)
Racket 的 FFI 支持定义 C 风格 struct 类型,并以一种更安全的方式操作它们:
- 你可以用文档中提供的
define-cstruct/define-cpointer-type/(_struct ...)之类方式描述结构体(不同文档版本里 API 命名略有差异,但核心思想相同:把 C struct 映射为能在 Racket 中读/写的类型)。 - 在很多情形下,把结构体当作
_pointer传递,然后用ptr-ref/ptr-set!或_array/_bytes等转换是更直接的做法。 - 对于复杂库(如 cairo、OpenGL)通常会为结构体写一个 Racket 包装层(constructors/accessors),以减少手写偏移与管理错误。
(建议:当你需要精确表示 C 结构并在 Racket 中方便访问时,参阅 Racket FFI 文档“C Struct Types”章节的实际 API。)
7. 更方便的绑定写法:ffi/unsafe/define
当你需要绑定很多符号时,ffi/unsafe/define 提供了更高层的宏风格绑定。例如:
#lang racket/base
(require ffi/unsafe
ffi/unsafe/define)
(define mylib (ffi-lib "./libadd.so"))
(define-ffi-definer define-mylib mylib)
(define-mylib add (_fun _int _int -> _int))
这会在模块中定义 add 符号。define-ffi-definer 支持一系列选项(命名规则、变量 vs 函数、默认值等),很方便组织大量 C 绑定。
8. 常见坑与调试建议
- 类型必须精确匹配:C 的
int在不同平台(32/64-bit)大小不同(通常 32 位),但你也可以指定_int32、_int64等更精确的类型以避免歧义。对返回值与参数用错类型会崩溃。 - 调用约定:大多数 C 库使用 C 调用约定(cdecl)。Windows 上有的 API 使用 stdcall(
__stdcall),需要注意(Racket 的 FFI 支持常见约定,但要查文档)。 - 字符串的所有权:如果 C 返回一个
char*指向内部静态缓冲区或需要 free,你要按库文档处理——可能要复制到 Racket string 或调用 free。 - 回调的生命周期:千万别让回调对象被 GC 掉(保持引用)。如果 C 保存回调并在之后调用它,必须保证 Racket 的回调仍存活。
- 并发 & 线程:Racket 的绿线程与 C 线程交互要谨慎;某些跨线程回调可能不安全(参见 FFI 文档对“places / threads / callouts”的说明)。
- 调试 segfault:先把最小化示例跑通(如
add),用strace/lldb/gdb看调用过程;把类型从复杂变简单逐步定位。 - 64-bit vs 32-bit 二进制兼容:确保你的 Racket 是 64-bit(或 32-bit),并且你编译的共享库位宽匹配。混用会导致加载失败或崩溃。