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 在托管代码执行前就被卸载了:
- Notepad++ 调用
LoadLibrary尝试装载 DLL。 - 加载器检查 plugin 所需的原生导出。
GetProcAddress("isUnicode")查找失败。- Notepad++ 判定 DLL 不兼容并将其卸载。
- 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 模板通常更方便。
排查顺序
可以按以下顺序排查:
- 确认 Notepad++ 是 32 位还是 64 位。
- 将 C# 项目设置为相同的平台目标。
- 选用适合当前目标框架的 DllExport 方案或可工作的 plugin 模板。
- 为六个 plugin API 入口添加原生导出。
- 检查调用约定、结构布局和指针宽度。
- 清理并重新构建项目。
- 用
dumpbin /exports Demo.dll检查最终 DLL。 - 将同一个 DLL 及其依赖复制到正确的 plugin 目录。
- 确认 Notepad++ 已成功加载 DLL,再调试入口函数和菜单命令。
Notepad++ 只有在 isUnicode 等函数确实存在于 PE 导出表中,并且 DLL 位数、依赖和 ABI 均正确时,才能调用这些 C# unmanaged exports。在类中添加同名静态方法无法满足这些条件。
备注:内容仅供参考。
