☰
HarmonyOS 7 Node-API + Rust FFI:三方 Rust 库 C ABI 封装与 Native 崩溃边界【鸿蒙心迹】
2026/10/1 15:52:49 网站建设 项目流程

把一个 Rust 库编译进 HarmonyOS 工程并不难,真正麻烦的是“出错以后会发生什么”:参数错误应该回到 ArkTS,Rust panic 不能穿过 FFI,真正的 Native 越界更不是 try/catch 能兜住。这次我用一个感知哈希库,把边界一层层拆开。

一、我不是为了“用 Rust”而用 Rust

这次 Demo 叫RustHashLab,做的是相册重复图片诊断。

测试批次固定为:

RUST-20260930-019 扫描图片:24 张 重复分组:3 组 Native 调用:24 次 Rust 错误:1 次

业务本身并不复杂:读取图片,交给 Rust 三方库计算感知哈希,再按汉明距离做相似分组。

真正让我重新改架构的,是第 13 张测试图IMG_2026_0912.jpg。

这张图故意做成损坏文件。最早版本里,ArkTS 直接通过 Node-API 调 C++,C++ 再调 Rust。Rust 库内部对图片解码结果做了一个unwrap(),结果不是正常返回错误,而是直接 panic。

在纯 Rust 程序里,panic 还能沿 Rust 调用栈处理;跨到 C ABI 以后,事情就不一样了。panic 不能被当成一种正常跨语言异常机制。如果让它跨过 FFI 边界,轻则进程中止,重则进入未定义行为风险。

于是我把这次接入目标改成了三层:

ArkTS ↓ Node-API C++ Bridge ↓ C ABI Rust Wrapper ↓ 第三方 Rust Library

三层分别处理三类问题:

  • ArkTS 参数不合法:Node-API 直接抛 JS/ArkTS 异常;
  • Rust 可预期错误或 panic:在 Rust C ABI 边界内收口成状态码;
  • 真正的 Native 越界、非法指针、SIGSEGV:不能假装能被 ArkTS try/catch 捕获,只能依赖更严格的内存管理和崩溃诊断。

HarmonyOS 的 Node-API 本来就是 ArkTS/JS 与 C/C++ 交互的稳定桥梁。我的做法不是让 ArkTS 直接理解 Rust,而是让 Rust 先表现成一个普通 C 库,再由 Node-API 暴露给 ArkTS。

二、Rust 对外只暴露 C ABI,不把第三方类型带出去

第三方库内部可能有Result<T, E>、枚举、泛型、trait 对象,这些都不适合直接穿过语言边界。

我最后只暴露一个非常窄的 C 接口:

typedefenum{RUST_OK=0,RUST_INVALID_IMAGE=1,RUST_IO_ERROR=2,RUST_PANIC=3}RustStatus;RustStatusrh_hash_file(constchar*path,uint64_t*hash_out);

这段代码解决什么问题:把复杂的 Rust 返回值压缩成稳定的 C ABI,让上层只处理状态码和基础类型。

Rust 侧的关键实现如下:

usestd::ffi::CStr;usestd::os::raw::c_char;usestd::panic::{catch_unwind,AssertUnwindSafe};#[repr(C)]pubenumRustStatus{Ok=0,InvalidImage=1,IoError=2,Panic=3,}#[no_mangle]pubextern"C"fnrh_hash_file(path:*constc_char,hash_out:*mutu64,)->RustStatus{ifpath.is_null()||hash_out.is_null(){returnRustStatus::IoError;}letresult=catch_unwind(AssertUnwindSafe(||{letc_path=unsafe{CStr::from_ptr(path)};letpath_str=c_path.to_str().map_err(|_|RustStatus::IoError)?;lethash=compute_hash(path_str).map_err(|_|RustStatus::InvalidImage)?;unsafe{*hash_out=hash;}Ok::<(),RustStatus>(())}));matchresult{Ok(Ok(()))=>RustStatus::Ok,Ok(Err(status))=>status,Err(_)=>RustStatus::Panic,}}

catch_unwind()在这里不是“万能崩溃捕获器”。

它只能把 Rust 的 unwind 型 panic 收口在 Rust 内部。真正的段错误、非法内存访问、进程 abort,不会因此变成RustStatus::Panic。这一点必须说清楚,否则很容易给团队一种“Native 代码已经安全了”的错觉。

我也不会把catch_unwind()包在整个应用所有 Rust 逻辑外面。它应该只存在于明确的 FFI 出口,作用是阻止 Rust panic 穿出 C ABI。

三、Node-API 只做类型转换和异常映射,不承载业务算法

C++ 这一层我刻意写得很薄。

这段代码解决什么问题:检查 ArkTS 参数,调用 Rust C ABI,并把 Rust 状态码转换成 ArkTS 可处理的异常。

#include"napi/native_api.h"#include"rust_bridge.h"#include<string>staticnapi_valueHashFile(napi_env env,napi_callback_info info){size_t argc=1;napi_value argv[1]={nullptr};napi_get_cb_info(env,info,&argc,argv,nullptr,nullptr);if(argc!=1){napi_throw_error(env,nullptr,"hashFile requires one file path");returnnullptr;}boolisString=false;napi_is_string(env,argv[0],&isString);if(!isString){napi_throw_error(env,nullptr,"file path must be string");returnnullptr;}size_t len=0;napi_get_value_string_utf8(env,argv[0],nullptr,0,&len);std::stringpath(len+1,'\0');napi_get_value_string_utf8(env,argv[0],path.data(),path.size(),&len);path.resize(len);uint64_thash=0;RustStatus status=rh_hash_file(path.c_str(),&hash);if(status!=RUST_OK){std::string message="rust hash failed, code="+std::to_string(status);napi_throw_error(env,nullptr,message.c_str());returnnullptr;}napi_value result=nullptr;napi_create_bigint_uint64(env,hash,&result);returnresult;}

Node-API 这一层最容易变坏的写法,是顺手把图片分组、缓存、线程池都塞进 C++。

这样一旦出问题,就很难回答“是 ArkTS 状态错了、C++ 桥接错了,还是 Rust 库错了”。

我只让 Bridge 做三件事:校验、转换、映射。

真正的图片去重策略仍然留在 ArkTS 服务层;哈希算法留在 Rust;跨语言桥只负责把两边接起来。

图二里调试现场保持了同一批数据:RUST-20260930-019、24 次 Native 调用、1 次 Rust 错误。底部 HiLog 能看到损坏文件最终变成RUST_INVALID_IMAGE,而不是把进程直接打掉。

四、ArkTS 看到的是普通异常,不需要知道 panic 是什么

Bridge 稳定以后,ArkTS 侧反而最简单。

这段代码解决什么问题:单张 Native 处理失败时,只标记当前文件,不让整个批次中断。

import rustHash from 'librusthash.so' import { hilog } from '@kit.PerformanceAnalysisKit' interface HashResult { path: string hash?: bigint error?: string } async function scanImages(paths: string[]): Promise<HashResult[]> { const results: HashResult[] = [] for (const path of paths) { try { const hash = rustHash.hashFile(path) results.push({ path, hash }) } catch (error) { hilog.error( 0x0000, 'RustHashLab', `hash failed: ${path}, ${JSON.stringify(error)}` ) results.push({ path, error: 'RUST_INVALID_IMAGE' }) } } return results }

我没有因为一张损坏图失败就让 24 张批次一起失败。

这也是三方 Native 库接入以后很重要的一层业务判断:底层错误应该怎样影响上层任务?

RustHashLab 里,图片损坏属于单项失败,继续扫描后面的图;如果是库加载失败、ABI 不兼容、初始化失败,那才应该让整批任务停止。

把错误严重性分层以后,页面状态就不会只剩一个“失败”。

五、真正的 Native 崩溃,ArkTS try/catch 救不了

这是这次最想强调的边界。

如果 C++ 传了悬空指针,或者 Rustunsafe代码访问非法内存,进程级崩溃不是:

try { nativeCall() } catch (e) { }

就能兜住的。

ArkTS 异常只适合处理 Node-API 主动抛回来的异常。真正的 Native crash 需要从源头降低发生概率:

  • C ABI 参数尽量只用 POD / 基础类型;
  • 不跨边界传 Rust 生命周期引用;
  • 不把 Rust 分配的内存交给 C++ 随意 free;
  • 明确“谁分配谁释放”;
  • 所有裸指针在进入 Rust 前先检查;
  • unsafe控制在最小范围;
  • Native 代码开启日志和符号信息,出问题用崩溃栈定位。

如果确实需要返回字符串或缓冲区,我会设计成:

Rust 分配 → 返回 pointer + length → C++ 读取 → 调 Rust 提供的 free 函数

而不是 Rustmalloc一块,C++ 想当然用另一套释放接口处理。

六、CMake 和 Cargo 的真正边界是“产物”,不是互相接管构建

这次三方库不是把整个 HarmonyOS 工程改成 Cargo 项目。

我的做法是先让 Rust crate 产出稳定的静态库或动态库,再让 HarmonyOS Native 工程通过 CMake 链接。

目录大概是:

RustHashLab/ ├── entry/src/main/ets/ ├── entry/src/main/cpp/ │ ├── napi_init.cpp │ ├── rust_bridge.h │ └── CMakeLists.txt └── native/rusthash/ ├── src/lib.rs └── Cargo.toml

CMake 只关心目标库文件和头文件,Cargo 只关心 Rust crate 怎么编译。

这种边界比“让一个脚本把所有事情都做了”更容易排错。Rust 编译失败先在 Cargo 层解决,Node-API 链接失败再查 CMake,ArkTS 调用失败最后看模块导出。

七、我专门留了一张坏图,而不是把异常案例删掉

最终运行结果是:

扫描图片:24 重复分组:3 Native 调用:24 Rust 错误:1 状态:COMPLETED

损坏文件:

IMG_2026_0912.jpg RUST_INVALID_IMAGE

图三里把这条错误专门圈出来了。

执行流程是:

Node-API 参数校验 → C ABI 调 Rust → Rust catch_unwind → 状态码返回 → ArkTS 抛出并记录当前文件异常

最重要的是“没有跨 FFI panic”。

这张图不是要证明“Rust 永远不会崩”,而是证明可预期的第三方库错误已经被收口到了明确边界内。

八、Rust 库能编译过,不代表 ABI 就已经稳定

这次把第三方库接进来以后,我专门做了一轮“升级 Rust 依赖”的测试。

最容易踩的坑,是 Node-API 这一层虽然没改,Rust crate 升级以后内部类型、错误枚举甚至哈希算法参数都发生了变化。如果 C++ Bridge 直接 include Rust 侧自动生成的大量结构体,很容易被下层变化牵着走。

所以我后来把rust_bridge.h控制得非常小。

对外只暴露:

uint32_trh_abi_version();RustStatusrh_hash_file(constchar*path,uint64_t*hash_out);

启动时先检查 ABI 版本。

这段代码解决什么问题:应用加载 Native 模块时先确认 Rust 库 ABI 版本,避免“能链接但语义已经不一致”。

constexpruint32_tEXPECTED_ABI=3;staticboolCheckRustAbi(){uint32_tactual=rh_abi_version();if(actual!=EXPECTED_ABI){OH_LOG_ERROR(LOG_APP,"rust abi mismatch, expected=%{public}u actual=%{public}u",EXPECTED_ABI,actual);returnfalse;}returntrue;}

我不建议用 Rust crate 的版本号直接代替 ABI 版本。

0.6.2 → 0.6.3可能完全不影响 C 接口,也可能因为自己的 Wrapper 改动导致 ABI 不兼容。ABI 版本应该由桥接层自己维护,只在跨语言契约变化时升级。

这样做以后,升级三方库就多了一道显式保护。至少不会出现 Rust 内部已经把某个状态码重新排序,C++ 仍然按旧枚举解释的情况。

九、字符串和缓冲区是 FFI 最容易把“谁负责释放”写乱的地方

感知哈希这次只返回uint64_t,所以内存所有权非常简单。

但我还是提前验证了一个返回诊断字符串的场景。比如 Rust 侧希望把详细错误原因返回给 C++,如果直接返回String指针,然后 C++ 用free()释放,就可能把两个不同分配器混在一起。

我的规则是:

哪一侧分配,哪一侧提供释放函数。

如果 Rust 返回缓冲区,就同时提供:

RustBufferrh_last_error();voidrh_free_buffer(RustBuffer buffer);

C++ 读取后调用rh_free_buffer(),绝不自己猜释放方式。

同理,C++ 传给 Rust 的const char*默认只在当前调用期间有效,Rust 不能把这个地址偷偷保存到全局变量里,等下一次再用。

这种问题在 Demo 里未必立刻出现,但一旦碰到批量任务和异步线程,悬空指针往往比普通业务 Bug 难排得多。

十、CPU 密集型 Rust 逻辑不要长期堵住 ArkTS 主线程

24 张测试图片规模不大,但感知哈希本身属于 CPU 和解码混合型工作。

如果 Node-API 暴露的是同步函数,ArkTS 在 UI 主线程连续调用 500 张图片时,页面一样会卡住。底层换成 Rust 并不会自动变成“异步”。

这次 Demo 为了把错误边界讲清楚,截图里用同步hashFile()更直观;正式工程我会把批量任务放到 Worker / TaskPool 或 Native 工作线程,主线程只接收结果。

但这里又会产生一个新边界:不能从任意 Native 子线程直接操作 ArkTS UI 对象。

更稳定的做法是:

后台线程调用 Rust ↓ 生成纯数据结果 ↓ 安全切回 ArkTS / 主线程 ↓ 更新页面

如果用 Node-API 异步任务,也要遵守 env、callback、生命周期对应规则,不要把主线程创建的napi_value随意保存到 Native 后台线程长期使用。

我现在判断一个三方 Rust 库能不能接,不只看算法跑得快不快,还会先问它是否能被拆成“输入纯数据 → 输出纯数据”。越接近纯函数,跨线程和跨语言都越容易管理。

十一、Native 日志要能够定位到“哪一次跨语言调用”

RustHashLab 给每一批扫描都有:

batchId = RUST-20260930-019

但只靠 batchId 还不够。

真正排 Native 问题时,我会给每次调用再分配一个callSeq:

batch=RUST-20260930-019 call=13 file=IMG_2026_0912.jpg

ArkTS、C++ 和 Rust 三层都打印同一个序号。

这样一条错误链可以连起来:

ArkTS: call=13 start C++: call=13 path validated Rust: call=13 decode failed C++: call=13 status=RUST_INVALID_IMAGE ArkTS: call=13 marked failed

这比三层各打一套“开始 / 失败”要实用得多。

正式线上日志当然不应该直接打印用户完整相册路径。我一般只保留脱敏文件 ID、扩展名、尺寸、调用序号和状态码。能定位工程问题就够了,没必要把用户内容写进日志。

十二、我还专门测试了“错误很多但进程不崩”的情况

只放一张坏图还不够。

我又构造了一批测试数据:

正常图 20 张 损坏图 2 张 空文件 1 张 不存在路径 1 条

目标不是看错误提示好不好看,而是确认错误连续发生时,C ABI 层不会泄漏资源,Rust Wrapper 不会残留脏状态,下一张正常图片还能继续得到正确哈希。

这类测试很适合发现全局缓存和静态变量问题。

比如某个三方库第一次 decode 失败以后,把内部 decoder 留在错误状态;下一次正常调用仍然失败。单测只跑一张图时完全看不出来,批量混合测试才会暴露。

所以我最后把 Native 接入验收拆成:

正常输入 可预期错误 连续错误 错误后恢复正常 长批次资源稳定 ABI 版本不匹配

真正的稳定不是“没报错”,而是错误发生以后,后面的合法调用仍然可以继续。

十三、性能优化放在错误边界之后做,顺序不要反

Rust 接入很容易让人一开始就盯性能:

单张哈希 8 ms 还是 5 ms 能不能并发 8 个

我这次反过来做。

先把 C ABI、错误码、资源归属、panic 收口全部稳定,再测性能。

原因很简单:并发会放大所有原本不清楚的边界。

一个全局缓存如果线程不安全,单线程永远没问题;一个错误字符串如果放在静态缓冲区,多线程一跑就会互相覆盖;一个第三方 crate 如果内部依赖线程局部状态,盲目并发可能直接让结果变得不可解释。

RustHashLab 当前 24 张测试只记录一次批次耗时,用来做版本对比,不把它包装成任何固定性能结论。

真正产品里我会测:

单张 P50 / P90 不同尺寸图片耗时 1 / 2 / 4 并发 峰值内存 失败后资源回落 连续 1000 张稳定性

性能数据只对自己的设备、图片集和版本有效。

这也是我这次接 Rust 库以后一个很明确的顺序:

先让错误能回来,再让任务能跑久,最后再让它跑快。

十四、接三方 Rust 库以后,我会固定做这几项检查

第一,先看库有没有unsafe、全局状态、线程模型和 panic 假设,不要只看 crates.io 上能不能编译。

第二,先设计 C ABI,再写 Node-API。ABI 稳定以后,上层和下层才能各自迭代。

第三,Rust panic 在 Rust 边界里转成状态码,绝不把 panic 当跨语言异常。

第四,Node-API 负责把状态码映射成 ArkTS 异常,但不要假装能捕获段错误。

第五,批量任务要区分“单项失败”和“系统性失败”。一张坏图不应该让全部图片停止,Native 模块无法加载则应该立即终止。

HarmonyOS 的 Node-API 已经给 ArkTS 和 C/C++ 提供了稳定交互机制,而 Rust 三方库通常最适合通过 C ABI 接进来。真正决定这个方案能不能长期维护的,不是“Rust 性能快不快”,而是出了问题以后,错误会停在哪一层。

这次 RustHashLab 最后留下来的结论很简单:

跨语言调用不是把函数调通,而是把错误边界也一起设计出来。

参考资料

  • HarmonyOS Node-API 跨语言调用
    https://developer.huawei.com/consumer/cn/doc/doccenter-games/games-universal-using-napi-interaction-0000002411166425
  • 使用 Node-API 实现 ArkTS/JS 与 C/C++ 交互
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/use-napi-about-object
  • ArkTS
    https://developer.huawei.com/consumer/en/arkts/

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询