ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HarmonyOS 7 Node-API + Rust FFI:三方 Rust 库 C ABI 封装与 Native 崩溃边界【鸿蒙心迹】

HarmonyOS 7 Node-API + Rust FFI:三方 Rust 库 C ABI 封装与 Native 崩溃边界【鸿蒙心迹】 把一个 Rust 库编译进 HarmonyOS 工程并不难真正麻烦的是“出错以后会发生什么”参数错误应该回到 ArkTSRust 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 调 CC 再调 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不把第三方类型带出去第三方库内部可能有ResultT, E、枚举、泛型、trait 对象这些都不适合直接穿过语言边界。我最后只暴露一个非常窄的 C 接口typedefenum{RUST_OK0,RUST_INVALID_IMAGE1,RUST_IO_ERROR2,RUST_PANIC3}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{Ok0,InvalidImage1,IoError2,Panic3,}#[no_mangle]pubexternCfnrh_hash_file(path:*constc_char,hash_out:*mutu64,)-RustStatus{ifpath.is_null()||hash_out.is_null(){returnRustStatus::IoError;}letresultcatch_unwind(AssertUnwindSafe(||{letc_pathunsafe{CStr::from_ptr(path)};letpath_strc_path.to_str().map_err(|_|RustStatus::IoError)?;lethashcompute_hash(path_str).map_err(|_|RustStatus::InvalidImage)?;unsafe{*hash_outhash;}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 可处理的异常。#includenapi/native_api.h#includerust_bridge.h#includestringstaticnapi_valueHashFile(napi_env env,napi_callback_info info){size_t argc1;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;}boolisStringfalse;napi_is_string(env,argv[0],isString);if(!isString){napi_throw_error(env,nullptr,file path must be string);returnnullptr;}size_t len0;napi_get_value_string_utf8(env,argv[0],nullptr,0,len);std::stringpath(len1,\0);napi_get_value_string_utf8(env,argv[0],path.data(),path.size(),len);path.resize(len);uint64_thash0;RustStatus statusrh_hash_file(path.c_str(),hash);if(status!RUST_OK){std::string messagerust hash failed, codestd::to_string(status);napi_throw_error(env,nullptr,message.c_str());returnnullptr;}napi_value resultnullptr;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[]): PromiseHashResult[] { 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.tomlCMake 只关心目标库文件和头文件Cargo 只关心 Rust crate 怎么编译。这种边界比“让一个脚本把所有事情都做了”更容易排错。Rust 编译失败先在 Cargo 层解决Node-API 链接失败再查 CMakeArkTS 调用失败最后看模块导出。七、我专门留了一张坏图而不是把异常案例删掉最终运行结果是扫描图片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_ABI3;staticboolCheckRustAbi(){uint32_tactualrh_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 问题时我会给每次调用再分配一个callSeqbatchRUST-20260930-019 call13 fileIMG_2026_0912.jpgArkTS、C 和 Rust 三层都打印同一个序号。这样一条错误链可以连起来ArkTS: call13 start C: call13 path validated Rust: call13 decode failed C: call13 statusRUST_INVALID_IMAGE ArkTS: call13 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-objectArkTShttps://developer.huawei.com/consumer/en/arkts/
返回列表