Scala Native 可以把 Scala 程序编译为本机可执行文件,并通过 FFI 调用 C 接口。程序仍使用 Scala 类型和集合,但一旦拿到 C 指针,内存所有权就不能沿用“对象不再引用时由 JVM GC 回收”的假设。分配器、释放器、指针有效期与 ABI 都成为接口契约。

本篇把订单纯计算与一个小型 C 资源接口放在同一程序里。纯核心返回 3000 分,C 函数为这个整数分配内存,Scala 读取后通过配对函数释放,再查询 C 侧活动分配计数。这样能够同时验证目标编译、符号链接、指针访问与释放路径。

实验冻结 Scala Native 0.5.8、Scala 3.3.6,使用本机 Apple clang 21.0.0、aarch64 macOS;构建工具运行于 JDK 21。工程在 examples/scala-lab/electives/E05,最终通过记录为 evidence/20261002-e/native-ffi.log,绑定实际引用的 E04/Core.scala 摘要。该次编译设置 JAVA_TOOL_OPTIONS=-Xmx2g,限制编译 JVM 的最大堆;这项设置不改变生成本机程序的 GC 堆策略。Scala 补丁与主线不同,是独立生态版本组合,不把主线版本强套给后端插件。

纯核心复用到本机目标

本篇直接编译 E04 的 Core.scala,没有复制另一份计算实现。它只依赖 List、Either 和有限范围整数计算,因此不需要 JVM 文件或线程 API。Native 主程序首先断言同样的两条订单行合计为 3000。

共享源码经过 Native 后端生成中间表示,再经过优化、LLVM IR 生成、本机编译和链接。实际构建日志记录了这些阶段,并显示本次选择 immix GC、未启用多线程支持。这个配置只描述当前小程序,不是 Scala Native 对所有程序的固定选择。

目标文件不是 JVM class 的替代加载方式。它由本机操作系统执行,依赖目标架构与链接环境。一个 macOS arm64 产物不能直接当作 Linux x86_64 产物交付。跨架构发行需要各自的工具链、系统库与运行验证。

共享规则通过断言,也不能证明所有库都能迁移。JVM 专用驱动、动态类加载或依赖反射的代码需要另查 Native 支持边界。把纯核心与环境适配分离,主要价值是缩小需要重新验证的部分。

C 接口显式给出所有权

C 文件定义三个函数:分配一个整数、释放该整数,以及返回活动分配数量。计数仅用于单线程实验,帮助观察 allocate 与 free 是否配对,不是线程安全的生产分配器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdlib.h>
static int live = 0;

int *order_alloc(int value) {
int *result = malloc(sizeof(int));
if (result != NULL) { *result = value; live++; }
return result;
}

void order_free(int *value) {
if (value != NULL) { free(value); live--; }
}

int order_live(void) { return live; }

order_alloc 成功时把所有权交给调用方。调用方必须在最后一次使用后调用 order_free,而且只能对这次分配执行一次有效释放。失败返回空指针,不增加 live,因此调用方必须先检查结果再解引用。

这里故意提供库自己的释放函数。真实 C 库可能使用私有分配器、引用计数或内部缓存,不能见到指针就统一调用 libc free。配对释放器属于 ABI 与所有权约定,Scala 的指针类型只描述地址所指向的数据布局,无法推断谁拥有它。

反向也一样:一个函数返回“借用指针”时,调用方可能没有释放权限。它的有效期可能依赖另一个上下文对象;关闭上下文后继续持有地址,会产生悬垂指针。文档中的 owned、borrowed、retain、release 等词需要转成具体生命周期图,而不是只翻译成中文。

extern 声明连接符号与类型

Scala 侧通过 extern 对象声明 C 符号,使用 CInt 与 Ptr[CInt] 描述参数和返回值。

1
2
3
4
5
6
import scala.scalanative.unsafe.*

@extern object Owned:
def order_alloc(value: CInt): Ptr[CInt] = extern
def order_free(value: Ptr[CInt]): Unit = extern
def order_live(): CInt = extern

这些方法没有 Scala 实现。链接器需要从编译进工程的 C 文件找到同名符号。本实验把 C 文件放进资源目录的 scala-native 子目录,并通过 --resource-dir 明确提供构建输入。漏掉 C 文件时,类型检查可能已经成功,但链接仍会因符号缺失失败。

签名必须与 C 原型一致。把返回指针误写成整数,或把参数宽度声明错误,不能指望 Scala 编译器读取任意 C 头文件替调用方纠错。跨平台时 long 的宽度、结构体对齐、调用约定等细节尤其需要按实际 ABI 检查。

本例只使用一个 C int,避免结构体布局掩盖所有权问题。进入真实库绑定时,可以从头文件生成或核对声明,但生成成功仍不代表全部运行路径正确。至少需要一个真实调用验证参数、返回值与错误码,不能只编译空 facade。

[PATTERN] FFI 有两个独立契约:二进制布局决定怎样调用,所有权决定何时使用与释放。签名正确只能覆盖前者的一部分。

try/finally 覆盖最后一次使用

Native 主程序获得指针后立即检查空值,再把读取放入 try 范围,释放放入 finally。

1
2
3
4
5
6
7
8
9
10
11
val pointer = Owned.order_alloc(3000)
if pointer == null then
throw new OutOfMemoryError("C allocation failed")

try
assert(!pointer == 3000 && Owned.order_live() == 1)
println(s"native-total=${!pointer};live-during=${Owned.order_live()}")
finally
Owned.order_free(pointer)

assert(Owned.order_live() == 0)

!pointer 是指针解引用。它只在明确的资源范围内出现,释放之后只读取 C 侧计数,不再次访问已经释放的地址。这样避免把 use-after-free 作为“可能偶然还能输出”的正常示例。

finally 能覆盖 Scala 控制流通过该范围退出的路径,但不能保证进程被强制终止时仍执行,也不能修复 C 代码造成的内存破坏。若本机函数写越界,错误可能发生在稍后的无关位置;Scala 层的异常处理未必有机会获得可恢复错误。

GC 管理的对象与 malloc 内存是不同资源。一个 Ptr 包装值不再被使用,不等于 C 分配自动释放。相反,GC 对象是否移动、传入 C 的地址需要怎样保持有效,也必须按该 Native 版本的接口约定处理,不能把 JVM 的 JNI 经验直接套入。

本篇没有使用 Zone。Zone 适合某些有界临时分配,退出范围后会统一释放,但因此也不能返回仍指向其中内存的地址供外部长期使用。使用更方便的分配 API 只会改变责任形式,不会取消有效期约束。

不执行未定义行为也能验证边界

直接运行两次 free,或释放后再读取,可能崩溃,也可能暂时没有明显症状。把“没有崩溃”当作支持证据是错误的:未定义行为不承诺以固定方式失败。教学实验应当让错误责任可推导,不必为了展示风险而运行损坏内存的程序。

当前正例证明了一次成功分配对应一次释放,C 计数由 0 到 1 再回到 0。它没有证明任意复杂程序都不存在泄漏,也没有验证失败分配的真实系统内存耗尽路径。若要测试错误分支,应给 C 接口增加受控失败注入,而不是尝试耗尽整台机器内存。

计数器也有能力边界。错误代码可能执行一次 free 后漏掉另一处内存,最终计数仍不具备完整堆分析能力;在真实库里,还可能有内部缓存和嵌套资源。动态内存检查工具能够补充越界、重复释放与泄漏诊断,但必须与目标平台工具链相匹配。

实验中的 live 使用普通静态 int,只适合单线程。若把接口交给多个线程并发调用,计数读写可能竞争,观测值将失去可靠性。此时需要先修复测试仪表本身,再用其判断被测对象;错误的观测器会制造看似严谨的假证据。

性能结论需要另做实验

生成本机可执行文件,并不能直接推出它比 JVM 服务更快。启动时间、峰值吞吐、内存占用、垃圾回收和优化预热属于不同指标。本次只记录构建阶段与功能断言,没有进行任何性能比较。

C FFI 调用也不是免费的性能捷径。频繁跨边界、数据拷贝、字符串编码和缓存局部性,都可能抵消本机库的优势。若热路径每处理一个订单字段就跨一次 FFI,应与批量传输设计比较,而不是只测一个空函数调用。

分配策略还会改变延迟。每条订单 malloc/free 一次适合展示所有权,未必适合生产热路径;复用缓冲需要额外的并发与容量管理。优化之前先固定正确的获得、借用和释放契约,否则一次性能改动可能把清晰的责任边界变成共享指针竞争。

部署也需要观察动态依赖。当前 C 代码只调用标准 malloc/free,本机链接很简单;引入数据库客户端或加密库后,需要固定库版本、目标架构和运行时搜索路径。编译机存在某个库,不代表目标机器也具备相同 ABI。

所有权协议需要覆盖失败返回

C 接口只返回一个指针时,调用方至少需要知道空指针表示什么、非空指针由谁释放、允许保存多久,以及释放函数是否接受空指针。本文把这些规则放在成对的 alloc/free 接口中,并在获得指针后立即建立 try/finally。若在建立 finally 之前先调用可能失败的辅助函数,获得成功与进入清理范围之间就会留下泄漏窗口。

更复杂的接口还可能返回借用指针。例如查询函数返回对象内部数组的地址,所有权仍属于原对象;调用方不能看到 Ptr 就调用 free。反过来,某些函数会转移输入指针的所有权,调用方成功传入后也不能再次释放。Scala 类型别名只能说明地址如何解释,不能自动表达这些 C 库约定,需要包装类型和清晰的构造边界配合。

错误码与内存责任也可能有关。一个初始化函数如果部分成功后返回失败,应由接口规定是函数内部回收,还是调用方必须调用专门的销毁函数。单纯检查返回码不等于清理完整。本文的 alloc 只有一次 malloc,失败时不增加计数,协议较小;扩展到多块内存或外部句柄时,应增加每个失败点的注入测试。

最后,计数器只能观察经由这些包装函数的分配与释放。绕过包装直接 malloc、库内部缓存和运行时堆内存不会自动计入 live。因此归零证明的是本例这一对接口的配对关系,不能把它描述成整个进程没有泄漏。进程级内存分析需要专门工具和长时间负载,属于另一项实验。

结果与练习

本机 Native 进程退出码为 0,程序输出如下:

1
2
native-total=3000;live-during=1
live-after=0
检查层次 实际观察 没有覆盖的范围
共享规则 总额断言通过 全部数值边界
本机编译 LLVM 与链接阶段完成 其他操作系统与架构
C 指针读取 3000 复杂结构体布局
所有权释放 live 从 1 回到 0 系统级泄漏检测

手算题:如果把 order_free 放到 println 前面,再继续执行 !pointer,是否只会得到旧值或空值?都没有保证;指针已失效,读取属于未定义行为,不能用某一次输出解释成合法语义。

修改练习:在 C 接口增加一个受控参数,使某次分配直接返回 NULL。Scala 应在解引用前转成明确失败,并检查 live 仍为 0。再在 try 内抛一个 Scala 异常,外层捕获后检查 live 回到 0,以验证异常控制流中的释放路径。

可迁移规则 接口说明必须包含
owned 指针必须有配对释放 分配器、释放器、转移时点
borrowed 指针依赖外部有效期 所属上下文及可用范围
extern 需要真实链接验收 C 原型、符号和目标 ABI
计数只证明已观测路径 线程条件及未测故障分支

运行方式见实验说明。

参考资料

顺序导航:系列入口:00 · 上一篇:E04 · 下一篇:E06。