PAYATHON 2026

C# Notepad++ plugin 为何被识别为 ANSI 不兼容

排障赵师傅

结论

Notepad++ 将 Demo.dll 识别为 ANSI plugin,是因为它没有在 DLL 的原生导出表中找到名为 isUnicode 的入口点,与 isUnicode() 的返回结果无关。

普通 C# 静态方法只会写入 .NET 元数据。即使方法名完全相同,Win32 GetProcAddress 也无法找到它。项目需要通过 DllExport、NativeAOT 等方式生成真正的 unmanaged export,同时确保导出名称、调用约定、参数布局和进程位数符合 Notepad++ plugin API 的要求。

PluginBase.SetCommand() 用于注册插件菜单命令,不能导出 Notepad++ 要求的 plugin 入口点。

为什么会显示 ANSI 不兼容

Notepad++ 加载 plugin 时,会通过原生 DLL 加载机制检查约定的导出函数,包括:

isUnicode
getName
setInfo
getFuncsArray
beNotified
messageProc

Unicode plugin 应导出 isUnicode,并返回 Win32 TRUE。如果找不到这个导出函数,Notepad++ 可能会将 DLL 视为旧式 ANSI plugin,随后显示:

This ANSI plugin is not compatible with your Unicode Notepad++.

这条消息有时表示 DLL 的 plugin 接口无法识别,不一定与 DLL 内部是否使用 ANSI 字符串有关。

下面的方法虽然存在于 C# 程序集中,却没有写入原生导出表:

public static bool isUnicode()
{
    return true;
}

反射或 .NET 反编译器能够看到该方法,Win32 GetProcAddress 仍然无法找到它。

C++ 代码中的关键部分

#ifdef UNICODE
extern "C" __declspec(dllexport) BOOL isUnicode()
{
    return TRUE;
}
#endif

这段代码中:

  • __declspec(dllexport) 会把函数写入 DLL 的 PE 导出表,供外部程序按函数名查找。
  • extern "C" 会关闭 C++ 名称修饰,避免导出名变成包含类型信息的编译器内部名称。
  • #ifdef UNICODE 是 C++ 编译条件,只有定义了 UNICODE 才会编译该函数。
  • BOOL 是 Win32 的 32 位布尔类型,与一个字节的 C/C++ bool 不同。

#ifdef UNICODE 不会直接影响 C# 方法的声明。C# 项目仍需在构建过程中通过其他方式完成 __declspec(dllexport) 对应的原生导出工作。

正确组织 C# 导出入口

不同 DllExport 实现使用的属性名称和项目配置可能略有差异。下面的代码用于说明接口结构,实际引用方式应以项目采用的 DllExport 工具为准:

using System;
using System.Runtime.InteropServices;

internal static class UnmanagedExports
{
    private static readonly IntPtr PluginName =
        Marshal.StringToHGlobalUni("DDM_Demo");

    [DllExport("isUnicode", CallingConvention = CallingConvention.Cdecl)]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static bool IsUnicode()
    {
        return true;
    }

    [DllExport("getName", CallingConvention = CallingConvention.Cdecl)]
    public static IntPtr GetName()
    {
        // 返回值必须在函数返回后继续有效,不能返回临时缓冲区。
        return PluginName;
    }

    [DllExport("setInfo", CallingConvention = CallingConvention.Cdecl)]
    public static void SetInfo(NppData notepadPlusData)
    {
        PluginBase.nppData = notepadPlusData;
    }

    [DllExport("beNotified", CallingConvention = CallingConvention.Cdecl)]
    public static void BeNotified(IntPtr notification)
    {
        Main.OnNotification(notification);
    }

    [DllExport("messageProc", CallingConvention = CallingConvention.Cdecl)]
    public static IntPtr MessageProc(
        uint message,
        UIntPtr wParam,
        IntPtr lParam)
    {
        return Main.OnMessage(message, wParam, lParam);
    }

    [DllExport("getFuncsArray", CallingConvention = CallingConvention.Cdecl)]
    public static IntPtr GetFuncsArray(ref int functionCount)
    {
        return PluginBase.GetFuncsArray(ref functionCount);
    }
}

Main.OnNotification、Main.OnMessage 和 PluginBase.GetFuncsArray 在这里是示意性的内部实现,需要替换为项目模板实际使用的方法。

C# 方法写成 IsUnicode 或 isUnicode 都可以,但 DllExport 最终生成的导出名必须精确写成:

isUnicode

大小写、拼写和调用约定都必须符合接口要求。

数据结构也必须匹配原生 ABI

NppData 需要与 Notepad++ plugin API 中的原生结构一致。例如,结构中的窗口句柄应使用 IntPtr:

[StructLayout(LayoutKind.Sequential)]
public struct NppData
{
    public IntPtr _nppHandle;
    public IntPtr _scintillaMainHandle;
    public IntPtr _scintillaSecondHandle;
}

SCNotification、FuncItem 等结构也要正确设置字段顺序、字段宽度、字符编码和 StructLayout。在 64 位进程中,HWND、WPARAM、LPARAM、指针和 LRESULT 都采用指针宽度,不能统一声明为 int。

比较稳妥的做法是选用一个已经能够正常工作的 C# plugin 模板,确认它与目标 Notepad++ 的版本和位数匹配,再把业务逻辑移入模板,避免自行推测各个结构的布局。

SetCommand() 为什么不能注册这些入口

下面这类代码产生 CS1503 属于正常结果:

PluginBase.SetCommand(
    21,
    "informs your plugin about Notepad++ and Scintilla handles",
    Kbg.NppPluginNET.UnmanagedExports.setInfo);

SetCommand() 通常接收无参数的菜单命令委托,例如:

static void ShowDemoDialog()
{
    // Menu command implementation
}

PluginBase.SetCommand(0, "Demo", ShowDemoDialog);

setInfo 是 Notepad++ 加载 plugin 时调用的生命周期入口,需要接收 NppData,其签名与菜单命令委托不兼容。getName、isUnicode 和 getFuncsArray 同样不是菜单项,无法通过 SetCommand() 暴露。

调用关系如下:

Notepad++ native loader
    -> DLL export: setInfo
    -> C# UnmanagedExports.SetInfo(...)
    -> 保存句柄或调用内部托管代码

CommandMenuInit() 只应注册需要显示在 Plugins 菜单中的命令。

如何确认 DllExport 是否真的生效

构建完成后,应检查最终复制到 plugin 目录的 DLL,仅查看源代码无法确认导出是否成功。可以在 Visual Studio Developer Command Prompt 中执行:

dumpbin /exports Demo.dll

输出中至少应包含:

isUnicode
getName
setInfo
getFuncsArray
beNotified
messageProc

如果导出列表为空,说明 DllExport 没有参与最终构建。常见原因有:

  • 复制了 obj 目录、中间目录或旧版本 DLL。
  • DllExport 的 MSBuild target 或后处理步骤没有执行。
  • 当前 DllExport 工具不支持项目采用的目标框架或构建方式。
  • 导出属性来自错误的程序集或命名空间。
  • 构建结果被后续复制步骤覆盖。
  • 构建过程只生成了普通托管程序集,没有生成 unmanaged export thunk。

Dependency Walker 较老,对现代 Windows API Set 和托管依赖的显示可能不完整,但仍应能看到 PE 导出表中的函数。如果它和 dumpbin /exports 都没有显示上述名称,应先修复构建配置,无需继续调试 isUnicode() 的函数体。

也可以使用支持现代 PE 文件的 Dependencies、PE-bear 等工具复核导出表。

位数和部署目录必须一致

DLL 的目标平台需要与当前运行的 Notepad++ 保持一致:

  • 64 位 Notepad++ 使用 64 位 plugin。
  • 32 位 Notepad++ 使用 32 位 plugin。
  • 需要生成原生导出的项目不宜依赖 Any CPU 自动决定位数。

还要确认 DLL 已复制到实际启动实例所读取的目录。现代目录布局通常要求:

plugins\Demo\Demo.dll

DLL 依赖的其他程序集也要放在加载器能够找到的位置。调试时可能启动了另一份 notepad++.exe,因此不能只根据安装目录判断当前实例。

为什么断点没有命中

断点未命中通常是因为 DLL 在托管代码执行前就被卸载了:

  1. Notepad++ 调用 LoadLibrary 尝试装载 DLL。
  2. 加载器检查 plugin 所需的原生导出。
  3. GetProcAddress("isUnicode") 查找失败。
  4. Notepad++ 判定 DLL 不兼容并将其卸载。
  5. CLR 没有进入对应的 C# 方法,断点自然不会命中。

因此,dump 中可能没有可用的托管调用栈,Modules 窗口里也找不到稳定加载的 Demo.dll。

确认导出表正确后,再检查以下调试条件:

  • 启动的是位数正确、路径正确的 notepad++.exe。
  • 调试器启用了与项目匹配的托管调试类型。
  • DLL 和对应的 PDB 来自同一次构建。
  • 部署目录中 DLL 的时间戳和校验结果与构建输出一致。
  • Notepad++ 没有因缺少依赖而在进入托管代码前卸载 DLL。

另一种实现方式

采用支持 NativeAOT 的现代 .NET 时,也可以通过 UnmanagedCallersOnly 和 NativeAOT 生成原生共享库,例如:

[UnmanagedCallersOnly(
    EntryPoint = "isUnicode",
    CallConvs = new[] { typeof(CallConvCdecl) })]
public static int IsUnicode()
{
    return 1;
}

仅在普通 .NET DLL 中添加 UnmanagedCallersOnly,不会自动生成可供 Notepad++ 加载的 plugin。项目还需要按 NativeAOT 共享库方式发布,并保证所有入口点、结构和字符串处理都与原生 ABI 兼容。对于现有的 NppPlugin.NET 风格项目,经过验证的 DllExport 模板通常更方便。

排查顺序

可以按以下顺序排查:

  1. 确认 Notepad++ 是 32 位还是 64 位。
  2. 将 C# 项目设置为相同的平台目标。
  3. 选用适合当前目标框架的 DllExport 方案或可工作的 plugin 模板。
  4. 为六个 plugin API 入口添加原生导出。
  5. 检查调用约定、结构布局和指针宽度。
  6. 清理并重新构建项目。
  7. 用 dumpbin /exports Demo.dll 检查最终 DLL。
  8. 将同一个 DLL 及其依赖复制到正确的 plugin 目录。
  9. 确认 Notepad++ 已成功加载 DLL,再调试入口函数和菜单命令。

Notepad++ 只有在 isUnicode 等函数确实存在于 PE 导出表中,并且 DLL 位数、依赖和 ABI 均正确时,才能调用这些 C# unmanaged exports。在类中添加同名静态方法无法满足这些条件。

备注:内容仅供参考。