Racket 编程入门

第 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-terminated char *)
  • _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),并且你编译的共享库位宽匹配。混用会导致加载失败或崩溃。