一、先聊聊 NIF 到底是啥,为啥要伺候它

咱们写 Erlang 程序时,大多数活儿 Erlang 虚拟机自己就干了——天然的并发、容错、热更新,特别省心。但有时候,你需要跟操作系统底层打交道,比如调用 C 语言写的加密库、操作硬件设备,或者处理海量数据计算。这时候 Erlang 的 NIF(Native Implemented Functions,原生实现函数)就派上用场了。简单说,NIF 允许你把 C 代码编译成动态库,然后像普通 Erlang 函数一样直接调用。

听起来很爽对吧?但 NIF 有个大坑:它跑在 Erlang 调度器的同一个线程里,如果 C 代码卡住或者崩了,整个 Erlang 节点也会跟着挂掉。打个比方,Erlang 虚拟机就像一栋大楼,每个房间都装着防地震的弹簧,但 NIF 就像从楼顶直接一根钢钉插到地基里,这根钢钉要是断了,整栋楼都得晃。所以,写 NIF 的时候,异常处理、资源管理、内存安全这三样东西,任何一个没做好,生产环境就会给你颜色看。

今天咱们就用最接地气的方式,把这些关键措施掰开揉碎聊清楚。所有示例都基于 Erlang + C 的技术栈,你跟着敲一遍就能明白。


二、异常处理:别让 C 代码的“嚎叫”带崩整个 Erlang

2.1 传统 C 错误处理有多粗鲁

C 语言出错的时候,通常返回一个错误的整数,或者设置 errno,然后调用者自己去查。但 Erlang 虚拟机可不吃这套——它期望每个 NIF 调用都能优雅地完成,如果 C 代码里出现了段错误、除零或者空指针,虚拟机直接崩溃,你连错误日志都来不及看。

举个例子,你写了一个 NIF 函数,用来把两个大整数相加。

// 错误的示例:没有考虑整数溢出
#include "erl_nif.h"

static ERL_NIF_TERM add_big(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
    ErlNifBigInt a, b;
    if (!enif_get_bigint(env, argv[0], &a) || !enif_get_bigint(env, argv[1], &b)) {
        // 如果传进来的不是大整数,直接返回 atom 'badarg'
        return enif_make_badarg(env);
    }
    // 直接做个加法,结果可能溢出
    ErlNifBigInt result = a + b; // 如果 a 和 b 都接近极限,结果就不可预测了
    return enif_make_bigint(env, result);
}

上面这段看似没问题,但假如 a 和 b 都是 Erlang 大整数,C 语言里的 ErlNifBigInt 实际上是 signed long long,最大值有限。一旦溢出,结果就是未定义行为,轻则返回错误值,重则内存破坏。正确的做法是:在 C 代码里捕获所有能预见的异常,并且保证返回的 Erlang 术语是合法的

2.2 用 enif_make_badarg 正确拒绝非法输入

在 NIF 里,如果参数不对,最安全的做法是直接返回 enif_make_badarg(env),Erlang 虚拟机收到这个 atom 后,会抛出一个 badarg 异常。请记住,永远不要在 NIF 里自己抛异常或者调用 enif_raise_exception 之外的接口来“模拟”错误,因为大多数开发者根本想不到在 C 里处理 Erlang 异常。

下面是一个比较稳健的版本:

// 正确示例:先校验参数范围,再执行操作
#include "erl_nif.h"

static ERL_NIF_TERM safe_add_big(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
    ErlNifBigInt a, b;

    // 步骤1:检查参数数量(虽然 Erlang 端会保证,但防御性编程总是没错)
    if (argc != 2) {
        return enif_make_badarg(env);
    }

    // 步骤2:尝试转换成大整数,失败就返回 badarg
    if (!enif_get_bigint(env, argv[0], &a) || !enif_get_bigint(env, argv[1], &b)) {
        return enif_make_badarg(env);
    }

    // 步骤3:在 C 层面做溢出检测。这里用最简单的办法:先把结果计算出来,
    // 然后判断是否回卷(假设用带符号的 64 位整数)
    // 注意:真正的产品代码中,ErlNifBigInt 可能不是固定长度,但咱先演示思路
    if (a > 0 && b > 0 && a > (ERL_NIF_BIGINT_MAX - b)) {
        // 正溢出,返回一个 atom 'overflow',让 Erlang 端自己决定怎么处理
        return enif_make_atom(env, "overflow");
    } else if (a < 0 && b < 0 && a < (ERL_NIF_BIGINT_MIN - b)) {
        return enif_make_atom(env, "underflow");
    }

    ErlNifBigInt result = a + b;
    return enif_make_bigint(env, result);
}

你看,代码中加了大量的判断分支,虽然啰嗦,但保证了一次 NIF 调用要么成功,要么返回一个明确的 Erlang 术语(atom、tuple 啥的),绝对不把 C 层面的段错误带到 Erlang 里。

2.3 关于 Erlang 端的异常处理惯例

在 Erlang 里调用 NIF 的时候,建议用 try ... catch 包一下,因为 NIF 有可能返回的不是期望的类型。比如上面那个函数返回了 overflow atom,你可以在 Erlang 里解析它:

-module(test_nif).
-export([safe_add/2]).

% 假设 NIF 模块已经加载
-define(NIF_STUB, ""). % 实际要加载动态库

try_add(A, B) ->
    case safe_add_big_nif(A, B) of
        {ok, Result} -> Result;
        overflow -> {error, overflow};
        underflow -> {error, underflow};
        Other -> erlang:error({unexpected_nif_return, Other})
    end
catch
    error:badarg -> 
        {error, badarg}
end.

这样一来,不管 NIF 函数内部怎么折腾,Erlang 端都不会挂掉。记住一条铁律:NIF 函数永远不要被设计成会直接崩溃的调用,所有错误都通过返回值传达


三、资源管理:管好你的 C 内存,别让 Erlang 背锅

3.1 谁分配,谁负责释放

Erlang 虚拟机有自己的一套内存分配器:enif_allocenif_free。如果你的 NIF 代码中用了 malloc 分配了内存,而忘记 free,时间长了就会内存泄漏。反过来,如果你用 enif_alloc 分配了内存,却用 C 的 free 去释放,行为是未定义的。所以,一致性很关键。

下面是一个演示资源管理错误的小例子:一个 NIF 函数创建一个字符串,然后在 Erlang 里使用:

// 错误的做法:混合使用内存分配
#include "erl_nif.h"
#include <string.h>
#include <stdlib.h>

static ERL_NIF_TERM create_greeting(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
    char *msg = (char*) malloc(100); // C 标准库分配
    if (!msg) return enif_make_badarg(env);
    
    strcpy(msg, "Hello, World!");
    
    // 转换为 Erlang 二进制时,enif_make_string 内部会拷贝一份,所以原来的 msg 不再需要
    // 但是!我们忘了 free(msg),导致泄漏
    return enif_make_string(env, msg, ERL_NIF_LATIN1);
}

上面的代码每次调用都泄漏 100 字节。正确做法是:用 enif_alloc 分配,或者分配后用 enif_free 释放。更常见的是,我们可以用 Erlang 二进制来管理缓冲区,让虚拟机自动回收。

3.2 用二进制(binary)来管理 C 缓冲区

Erlang 的二进制是一种高效的内存块,NIF 可以直接创建和操作它们。而且二进制由 Erlang 虚拟机负责引用计数,当不再需要时自动释放,这就省去了手动释放的烦恼。

// 正确示例:使用 Erlang 二进制来管理缓冲区
#include "erl_nif.h"
#include <string.h>

static ERL_NIF_TERM create_greeting_binary(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
    const char *greeting = "Hello, World!";
    size_t len = strlen(greeting);
    
    // 创建一个可写二进制(Erlang 二进制),大小为 len
    ErlNifBinary bin;
    if (!enif_alloc_binary(env, len, &bin)) {
        // 内存分配失败,返回 badarg
        return enif_make_badarg(env);
    }
    
    // 写入内容
    memcpy(bin.data, greeting, len);
    
    // 转换为 Erlang 二进制术语,注意:转换后 bin 会被释放,不需要我们手动调用 enif_release_binary
    return enif_make_binary(env, &bin);
    // 到这里,二进制已交给 Erlang 管理,C 侧不再持有。
}

你看,enif_alloc_binary 分配的内存由 VM 负责释放,我们只管写入数据,然后通过 enif_make_binary 转移所有权。这样既没有泄漏,也没有野指针。

3.3 资源对象的生命周期:用 ErlNifResourceType

如果你的 NIF 函数需要创建一个“对象”——比如打开一个文件句柄、一个数据库连接等——你不能直接把它暴露给 Erlang,因为 C 指针在 Erlang 里没法安全保存。这时要用 ErlNifResourceType,它可以把 C 结构体包装成一个 Erlang 术语,当这个术语不再被引用时,会自动触发我们注册的回调函数来释放资源。

下面是一个简单的例子:模拟一个“计数器”资源。

// 定义资源的结构体
typedef struct {
    int count;
} counter_t;

// 资源析构函数,当 Erlang 侧不再使用该资源时会被调用
static void counter_dtor(ErlNifEnv* env, void* obj)
{
    counter_t *counter = (counter_t*) obj;
    // 可以在这里关闭文件、释放内存等
    // 本例中不需要特殊释放,但打印个日志演示
    // 注意:不要在析构函数里调用 enif_free 因为资源本身就是用 enif_alloc 分配的
    // 系统会自动释放 obj 的内存块
}

// 初始化模块时注册资源类型
static int load(ErlNifEnv* env, void** priv, ERL_NIF_TERM load_info)
{
    // 注册一个名为 "counter" 的资源类型,大小为 sizeof(counter_t)
    ErlNifResourceType *rt = enif_open_resource_type(env, NULL, "counter", counter_dtor, ERL_NIF_RT_CREATE, NULL);
    if (rt == NULL) return -1;
    // 把资源类型指针保存到 priv 中,以便后面的 NIF 函数使用
    *priv = rt;
    return 0;
}

// 创建一个新的计数器资源,返回一个 Erlang 引用
static ERL_NIF_TERM new_counter(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
    ErlNifResourceType *rt = (ErlNifResourceType*) enif_priv_data(env);
    // 分配资源对象(内存)
    counter_t *counter = (counter_t*) enif_alloc_resource(env, rt, sizeof(counter_t));
    if (!counter) {
        return enif_make_badarg(env);
    }
    counter->count = 0;
    
    // 将资源包装成一个 Erlang 引用(类似指针),返回给 Erlang
    ERL_NIF_TERM term = enif_make_resource(env, counter);
    // 资源分配时引用计数为 1,enif_make_resource 会增加一次引用,现在引用计数为 2
    // 我们需要释放最初的那个引用,否则会泄漏
    enif_release_resource(env, counter);
    return term;
}

// 给计数器加一
static ERL_NIF_TERM counter_inc(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
    ErlNifResourceType *rt = (ErlNifResourceType*) enif_priv_data(env);
    counter_t *counter = NULL;
    // 安全地获取资源指针,如果类型不对,返回 badarg
    if (!enif_get_resource(env, argv[0], rt, (void**) &counter)) {
        return enif_make_badarg(env);
    }
    counter->count++;
    return enif_make_int(env, counter->count);
}

这段代码的思路是:用 ErlNifResourceType 建一个“容器”,Erlang 侧持有一个引用,当引用被垃圾回收时,counter_dtor 会被调用,你可以放心的释放 C 资源。这样资源管理就变得安全了。


四、内存安全:小心指针的“幽灵”

4.1 永远不要返回栈上变量的指针

这大概是 C 程序员的常见错误:在函数内定义一个局部数组,然后返回其指针。一旦函数返回,栈上的内存就被回收了,指针变成了野指针。Erlang NIF 中同样危险,而且可能导致虚拟机访问非法内存。

// 错误示例:返回局部变量地址
static int* create_int_bad(void) {
    int local = 42;
    return &local; // local 是栈上的,函数结束后就失效了
}

static ERL_NIF_TERM get_value(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
    int* ptr = create_int_bad();
    // 此时 ptr 是野指针,访问它不确定会发生什么
    return enif_make_int(env, *ptr);
}

正确做法:用 enif_alloc 或者直接在堆上分配,而且保证生命周期可控。

4.2 警惕 enif_make_string 的隐式拷贝

enif_make_string 会拷贝你传入的 C 字符串,所以之后你可以安全释放原字符串。但如果你传入了长字符串,每次拷贝可能造成性能损失。更好的办法是用二进制,像我们之前那样。

// 用二进制避免不必要的拷贝
static ERL_NIF_TERM long_string(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
    // 假设我们有一个很大的 C 字符串
    char huge_buffer[102400] = { ... }; // 假设已经填满
    size_t len = strlen(huge_buffer);
    
    // 创建二进制直接指向 huge_buffer ?不行,因为 huge_buffer 是栈的,函数返回就失效。
    // 所以还是得 allocate 并拷贝
    ErlNifBinary bin;
    if (!enif_alloc_binary(env, len, &bin)) {
        return enif_make_badarg(env);
    }
    memcpy(bin.data, huge_buffer, len);
    return enif_make_binary(env, &bin);
}

注意:如果你想避免内存拷贝,可以使用 enif_make_subbinary 或直接引用已有的二进制,但这个比较复杂,先不展开。

4.3 线程安全与原子操作

NIF 默认是在 Erlang 调度器线程里运行的,所以不用考虑多线程竞争问题。但是,如果你在 NIF 里创建了线程,或者使用了全局变量,那就得自己加锁。Erlang 提供了 enif_mutex_createenif_cond_create 等接口,但大多数情况下我们不建议在 NIF 里创建线程,因为会破坏 Erlang 的调度猜想。如果非要创建,请一定确保锁的正确释放,并且不要在持有锁的时候调用任何可能会调度 Erlang 进程的函数。


五、优化方法:让 NIF 既安全又快速

5.1 减少 NIF 调用次数

每次 NIF 调用都有一定的开销(参数打包、上下文切换)。如果你有一堆小操作要执行,不如把它们合并成一个大的 NIF 函数。比如,要更新某个数组的多个元素,不如一次传递整个更新列表,让 C 端循环处理。

5.2 使用 dirty NIF 来处理耗时操作

标准 NIF 是同步的,如果 C 函数执行时间超过一毫秒,就会阻塞 Erlang 调度器,导致其他进程无法运行。Erlang/OTP 21 之后引入了 dirty NIF,让你可以声明某个 NIF 函数是“脏的”(耗时或阻塞),调度器会在单独线程中执行它。

// 在 NIF 初始化时声明 dirty 选项
static ErlNifFunc nif_funcs[] = {
    {"heavy_compute", 1, heavy_compute, ERL_NIF_DIRTY_JOB_CPU} // 声明为 CPU 密集型脏任务
};

注意:dirty NIF 不能直接访问 Erlang 进程的内部状态,而且每次调用都有额外调度开销。适用于长时间计算,不适合频繁小任务。

5.3 预分配资源,避免重复分配

如果你的 NIF 函数频繁创建临时二进制,可以考虑在模块加载时分配一个较大的内存池,然后重复使用。不过这会增加复杂度,必须确保线程安全。多数情况下,直接用二进制分配器就够了,因为 Erlang 的 enif_alloc_binary 已经做了优化。


六、应用场景与优缺点分析

6.1 典型应用场景

  • 加密解密:OpenSSL 库是 C 写的,用 NIF 包装后可以高效调用。
  • 图像处理:像 libjpeg、libpng 等 C 库,处理大量像素时性能远高于纯 Erlang。
  • 硬件驱动:与串口、GPIO 交互,需要底层 ioctl 调用。
  • 高性能计算:矩阵乘法、哈希运算等,C 代码可以充分优化。

6.2 优点

  • 性能直逼原生 C,没有额外的进程间通信开销。
  • 复用现有 C 库,不用重写。
  • 与 Erlang 进程无缝集成,调用方式简单。

6.3 缺点

  • 稳定性风险极高:一个 bug 就能拖垮整个 Erlang 节点。
  • 调试困难:C 代码崩溃时,Erlang 日志寥寥无几,往往要靠 GDB。
  • 热升级受限:NIF 模块升级需要重新加载动态库,如果处理不好会导致旧版本残留。

七、注意事项总结

  • 永远不要信任 NIF 内部的数据:在返回前,确保 Erlang 术语是有效的,不包含野指针。
  • 使用模块级资源类型管理复杂对象:而不是直接用二进制或整数存储指针。
  • 在 NIF 入口和出口做好边界检查:参数类型、范围、长度的校验一个都不能少。
  • 避免在 NIF 中调用 erlang 内部的非线程安全函数:比如 enif_send 在某些上下文可能不安全。
  • 编写测试脚本:用大量随机参数、边界值反复调用 NIF,捕捉可能的崩溃。
  • 考虑使用 erl_ddll 的 reload 功能受限,生产环境尽量不要动态卸载 NIF。

八、文章总结

写 NIF 就像开一辆改装车——动力强劲,但刹车系统需要格外注意。异常处理让错误不会变成灾难;资源管理确保你的程序不泄露内存;内存安全防止指针变成幽灵。再加上 dirty NIF 等优化手段,你就可以在 Erlang 中安全地调用 C 的能力,同时享受 Erlang 的容错特性。记住,NIF 是最后的手段,能用纯 Erlang 写的,尽量别碰 C。但当你真需要的时候,按本文的步骤去做,至少不会半夜被线上报警吵醒。