PAYATHON 2026

Electron 中调用 Napi::Function::New 时崩溃,如何排查?

排障赵师傅

Napi::Function::New(env, HelloWorld) 的写法本身没有明显错误。现有日志只能说明程序没有正常执行到下一条日志,还不能确认是否发生了内存访问错误,也无法据此判断 HelloWorld 有问题。下一步应让原生调试器在异常发生时暂停,查看调用栈。

构建配置中有一处需要优先检查:你覆盖了 cmake-js 提供的 CMAKE_JS_SRC。在 Windows 上,这可能导致 Electron 加载原生模块所需的 delay-load hook 没有参与编译。不过,是否由此导致崩溃,还需要调试确认。

为什么日志和 try/catch 还不能确定原因

Napi::Function::New 会创建 JavaScript 函数并注册回调,此时不会执行 HelloWorld。只有 JavaScript 调用 addon.hello() 时,才会进入这个回调,因此创建函数时看不到 HelloWorld 中的日志是正常的。

你的异常处理代码是空的:

catch (...) {
}

即使捕获到 C++ 异常,也不会输出任何信息,程序随后还会执行:

exports.Set("hello", helloFunc);

如果创建函数失败,helloFunc 可能仍为空,继续使用它会掩盖最初的问题。

非法内存访问、运行时致命错误或 abort() 也不属于普通 C++ 异常。即使启用了 NAPI_CPP_EXCEPTIONS 和 /EHsc,也无法让所有这类故障都进入 catch (...)。

调试时可以先去掉空的异常处理,直接执行初始化代码:

#include <napi.h>
#include <iostream>

Napi::String HelloWorld(const Napi::CallbackInfo& info) {
    return Napi::String::New(info.Env(), "Hello from C++!");
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    std::cout << "[C++] Creating helloFunc..." << std::endl;

    auto helloFunc = Napi::Function::New(env, HelloWorld, "hello");

    std::cout << "[C++] Created helloFunc." << std::endl;
    exports.Set("hello", helloFunc);
    return exports;
}

NODE_API_MODULE(addon, Init)

增加的 "hello" 用来指定函数名称,方便识别;修复崩溃并不需要这个参数。

先修正 CMake 中可疑的源码配置

CMAKE_JS_SRC 由 cmake-js 提供,可能包含平台所需的辅助源码,不要用自己的源码路径覆盖它。

将:

set(CMAKE_JS_SRC "native/addon.cpp")

add_library(${PROJECT_NAME} SHARED ${CMAKE_JS_SRC})

改为:

set(ADDON_SOURCES
    native/addon.cpp
)

add_library(${PROJECT_NAME} SHARED
    ${ADDON_SOURCES}
    ${CMAKE_JS_SRC}
)

保留原来的链接配置:

target_link_libraries(${PROJECT_NAME} ${CMAKE_JS_LIB})

在 Windows 上,Electron 加载原生模块时,需要将对 Node 的延迟导入解析到 Electron 进程。cmake-js 提供的辅助源码可能负责这部分工作。覆盖 CMAKE_JS_SRC 后,即使编译和链接成功,也可能在首次调用相关 API 时失败。

可以临时打印这些变量,检查实际构建输入:

message(STATUS "CMAKE_JS_SRC=${CMAKE_JS_SRC}")
message(STATUS "CMAKE_JS_INC=${CMAKE_JS_INC}")
message(STATUS "CMAKE_JS_LIB=${CMAKE_JS_LIB}")

同时查看编译和链接日志,确认辅助源码确实参与了构建。如果当前平台上的 CMAKE_JS_SRC 为空,这条线索就无法解释崩溃,需要继续获取调用栈。

full-build 只是重新执行现有配置,配置本身的问题不会因完整重建而消失。

建立只加载 addon 的复现入口

暂时排除 Angular 和 IPC,在 electron/repro.cjs 中只保留 addon 的加载和调用:

const { app } = require('electron');
const path = require('node:path');

const addonPath = path.resolve(
  __dirname,
  '../build/Release/my_native_addon.node'
);

console.log({
  executable: process.execPath,
  arch: process.arch,
  versions: process.versions,
  addonPath
});

try {
  const addon = require(addonPath);
  console.log('Native addon loaded successfully!');
  console.log(addon.hello());
} catch (err) {
  console.error('Addon load or call failed:', err);
  app.exit(1);
}

app.whenReady().then(() => app.quit());

运行:

npx electron electron/repro.cjs

这里把 require() 放进了 try。原来的 main.js 在进入 try 之前就加载 addon,后面的异常处理无法捕获加载阶段抛出的 JavaScript 异常。调整位置后可以捕获这类异常,但 JavaScript 的 try/catch 仍无法捕获原生进程崩溃。

如果这个入口能复现问题,就可以直接调试 Electron 主进程,不必每次都构建 Angular。

编译调试版本,确认加载的是同一个文件

使用 cmake-js 编译调试版本:

npx cmake-js rebuild --runtime=electron --runtime-version=40.6.1 --debug

40.6.1 沿用项目配置。排查时应确认它与启动时打印的 process.versions.electron 一致,并检查构建目标架构是否与 process.arch 匹配。

构建完成后,找到实际生成的 .node 文件,并修改复现脚本中的路径,加载这个文件。使用常见的多配置生成器时,调试产物通常位于:

build/Debug/my_native_addon.node

具体位置以构建输出为准。如果仍然加载 build/Release 中的旧文件,调试构建就没有起作用。

Windows 下还需要保留对应的 PDB。如果调试器无法将断点绑定到 addon.cpp,先检查模块路径和符号加载情况,再确认源码是否与二进制对应。

用原生调试器启动 Electron

需要调试的是执行 C++ 的 Electron 进程。DevTools 和 --inspect 主要用于 JavaScript 调试,无法代替原生调用栈。

Windows:使用 Visual Studio

  1. 用 Visual Studio 打开项目安装的 node_modules\electron\dist\electron.exe。
  2. 将调试器类型设为 Native,工作目录设为项目根目录。
  3. 将启动参数设为 electron/repro.cjs。
  4. 在异常设置中启用 Win32 访问冲突异常 0xC0000005 的中断。如果还需要检查被吞掉的 C++ 异常,也启用 C++ 异常抛出时中断。
  5. 在 Init 的 Napi::Function::New 所在行设置断点,启动调试。

先确认程序能停在断点处,再单步进入。发生异常后,记录异常代码、出错指令和完整调用栈。

留意调用栈是否经过延迟加载相关函数。如果出现 __delayLoadHelper2,或模块、导出符号解析失败,再结合前面的 CMAKE_JS_SRC 检查加载钩子和链接配置。

macOS:使用 LLDB

在项目根目录启动实际的 Electron 可执行文件:

lldb -- ./node_modules/electron/dist/Electron.app/Contents/MacOS/Electron electron/repro.cjs

进入 LLDB 后:

breakpoint set --name Init
run

如果 Init 名称匹配不明确,可以按 addon.cpp 的实际行号设置源码断点。

程序因异常或信号暂停后,执行:

thread backtrace all
frame variable
register read

Linux:使用 GDB

gdb --args ./node_modules/electron/dist/electron electron/repro.cjs

进入 GDB 后:

set breakpoint pending on
break Init
run

崩溃暂停后:

bt full
thread apply all bt
info registers

LLDB 和 GDB 通常会在相关致命信号发生时暂停。先记录现场,再决定是否继续运行。

根据崩溃现场决定下一步

调用栈可以帮助区分故障类型,定位到某一行还不足以确定原因。

调试器中的现象下一步
停在 C++ throw查看异常内容,确认是否曾被空的 catch (...) 吞掉
停在延迟加载、动态链接相关函数检查 Windows 加载钩子、导入符号和链接输入
出现访问冲突、SIGSEGV 或 EXC_BAD_ACCESS检查出错指令访问的地址,以及调用栈中的参数和对象状态
出现 SIGABRT、断言或 fatal error查看终端错误和调用栈中的终止原因
源码断点始终不命中检查是否加载了预期的 .node 文件及其调试符号

即使确认发生了非法内存访问,崩溃位置也可能只是问题暴露的地方。内存破坏可能发生得更早,直到创建函数时才触发崩溃。调用栈出现相关线索后,再进一步使用内存检查工具。

当前应先保留 cmake-js 提供的辅助源码,并让原生调试器捕获第一次故障。如果仍然崩溃,后续诊断至少需要操作系统、架构、实际加载路径、异常类型和完整原生调用栈。仅凭两条日志之间的执行中断,无法可靠确定根因。

备注:内容仅供参考。