简介:PB与C#开发者在跨语言DLL对接时,经常遇到类型转换、调用约定和工程配置不匹配等问题。资源以PowerBuilder和C#两组代码为主线,分别演示生成DLL与调用DLL的完整流程。PB部分包含DLL制作demo,以及调用C# DLL和自身DLL的示例;C#部分则覆盖生成DLL与调用DLL两个方向,并附带说明文档和演示程序。zip压缩包内共53个文件,以cs源码、pbl/pbd库文件、exe演示程序及sln/csproj工程文件为主,另有tlb/idl、manifest等文件辅助理解COM接口相关信息,整体仅554KB,下载后即可参照工程结构实践。目前已有842人学习过,适合有一定基础的PB或C#程序开发者用于解决接口封装、跨语言模块交互等问题,可直接对照代码理解DLL导出与调用的具体写法。
1. 别急着写互调代码:PB 的 dll 和 C# 的 dll 根本是两种东西
PB 制作的“dll”和 C# 制作的 dll,后缀相同,本质是两种东西:一个是给 Windows 加载器用的原生模块,一个是给 PB 虚拟机识别的内部格式,甚至有人把 PBDLL 当成换了后缀的资源包。做 PB 与 C# 接口互相调用前,最有价值的动作是先搞清楚你手里那份 dll 是什么形态——是 C# 编译的 COM 组件,还是 PB 的 PBDLL,还是 PB 构建的 COM Server。选型错了,后面所有注册脚本、OLEObject 代码、添加引用全在打空气。这篇文章按我在迁移项目里验证过的路子,把 PB 调 C#、C# 调 PB 两个方向分别拆开讲:谁调谁、接口怎么暴露、参数怎么传、注册怎么注、翻车时看什么。适合手里有 PB 老系统、想用 C# 写新逻辑、又不想推翻老界面的开发者;也适合想在 C# 里复用 PB 已有业务逻辑的场景。
2. PB 造 DLL 的三种形态:PBDLL、PBNI、COM Server,选错就是翻车起点
2.1 PBDLL:后缀是 .pbd,它不是 Windows DLL,C# 拿它没办法
PB 的工程构建,默认产出 PBL 源码库和 PBD 动态库两种东西。PBD 内部是 PB 运行库才能解析的二进制格式,文件头不是 PE 结构,也没有导出表。你把它复制给 C# 侧的同事,对方 LoadLibrary 只会拿到 126 错误(找不到模块);就算运气好加载进来,GetProcAddress 也拿不到任何函数地址。这不是调用姿势的问题,是格式根本不兼容。
我在一个改造项目里就见过这种翻车:交付物清单写“提供 xxx.dll 给 C# 调用”,结果发过去一个 PBD。C# 那边查了一整天加载失败,最后发现后缀虽然叫 dll,其实是 PB 的资源库。所以第一课很简单:先看文件头,再谈调用。PBD 只能在装了 PB 运行库的机器上由 PB 加载,脱离 PB 虚拟机它毫无价值。判断方法也很土但有效:用记事本打开 dll,前两个字节是 MZ 的是 Windows 模块;不是 MZ 的,直接怀疑它是 PBD 或别的封装格式。
2.2 PBNI 扩展:PB 官方留给原生接口的口子,但门槛在 C++ 侧
PBNI(PowerBuilder Native Interface)是 PB 给原生语言做扩展的机制。你用 C/C++ 实现一组 PBX 导出函数(PBX_GetVersion、PBX_RegisterFunctions 等),编译成标准 DLL,PB 端声明外部函数后就能调。PBNI 的能力上限很高:直接操作系统 API、持有资源句柄,甚至在里面内嵌 .NET 运行时把 C# 程序集加载进来。
代价同样明显:要自己维护 PB 对象引用计数,参数是从 PB 传下来的 pvalue 结构,处理字符串要自己分配内存,稍不留神就把 PB 虚拟机写崩。我一般只建议两种场景上 PBNI:一是 PB 必须同步直调某个原生库,而 COM/OLE 的调度开销不可接受;二是需要从 PB 侧发起高频计算,数据量大到跨模块复制成为瓶颈。普通业务互调,PBNI 是杀鸡用牛刀,我的血泪经验是:PBNI 的排错成本可以抵掉三倍 COM 方案的开发量,没有十足把握不要开这条线。
2.3 COM Server:把 PB 的 NVO 变成 C# 能引用的标准 DLL
PB 工程里创建 Custom Class(Non-Visual Object,NVO),在生成选项中勾选 COM Server,构建后产出真正的 DLL 和一份同名 .tlb 类型库。这个 DLL 是标准 COM 组件,C# 项目里可以添加引用,也可以按 ProgId 动态激活。这条路径是把 PB 业务逻辑暴露给 C# 的正式通道。
原理是:PB 在 DLL 里实现了一层 COM 转发,调用进入后由 PB 虚拟机找到对应的 NVO 实例,把参数解包后执行业务方法,再打包返回。每一步都有一层 VARIANT 转换,所以性能不如纯原生调用,但对查询、校验、格式化这类业务逻辑完全够用。我主导的一次老系统改造里,PB 端 30 多个校验方法就是靠这条路径暴露出去的,C# 新接口调用后响应在毫秒级,没人感知到多了一层分发。
2.4 三选一怎么定:先回答“谁调谁”和“调用方向”
三种形态摆到一起,决策就一个问题:调用方向和数据量。PB 要调 C# 的 DLL,首选把 C# 类库注册成 COM 组件,PB 端用 OLEObject 调,这个方向最常见,因为老系统里 PB 是界面层,C# 负责新算法。C# 要调 PB 的 DLL,首选 PB 的 COM Server 输出;次选让 PB 主导流程,通过轮询或消息反向联动。
什么情况下才考虑 PBNI?当两边调用频率高到每秒钟上千次、参数又是大数组或大数据结构时。那已经不是接口问题而是架构问题。做一个简单对比就能定:
| 形态 | 本质 | 谁可以调用 | 开发门槛 | 典型场景 |
|---|---|---|---|---|
| PBD | PB 内部资源库 | 仅 PB 运行库 | 低 | 老系统分发对象与界面 |
| PBNI 扩展 DLL | 标准原生 DLL | PB 声明外部函数后调用 | 高 | 高频直调、内嵌运行时 |
| COM Server | 标准 COM DLL | 任何 COM 客户端 | 中 | 跨语言业务互调 |
表里可以看出,真正能“互相调用”的只有中间和下面两条路。下面两章,一条给 PB 调 C#,一条给 C# 调 PB,参数和坑分开说。
3. C# DLL 给 PB 调:COM 注册与 OLEObject 的最小可跑通路
3.1 C# 侧:让类库成为 COM 可见的三处关键配置
第一步是新建一个 C# 类库,放一个显式接口和实现类。三处关键配置分别是:ComVisible 特性、显式接口、稳定的 GUID 与 ProgId。
using System; using System.Runtime.InteropServices; namespace PbCalc { [ComVisible(true)] [Guid("B2A1F0C0-5E41-4A90-9C52-3D0A7B0C6E21")] public interface ICalc { double Add(double a, double b); [return: MarshalAs(UnmanagedType.BSTR)] string Echo([MarshalAs(UnmanagedType.BSTR)] string text); } [ComVisible(true)] [Guid("7C4FEA21-3C84-4B9A-9ED0-1C88F0A2D5A3")] [ProgId("PbCalc.Calc")] [ClassInterface(ClassInterfaceType.None)] [ComDefaultInterface(typeof(ICalc))] public class Calc : ICalc { public double Add(double a, double b) { return a + b; } public string Echo(string text) { return "PB->C#: " + text; } } }逻辑说明:接口和实现类都标了 ComVisible(true),接口定了 GUID,类上也定了 GUID 和 ProgId。ClassInterface 设成 None,再配合 ComDefaultInterface 指定默认接口,意思是不要自动生成分派接口,我们手动暴露 ICalc。这样 PB 侧拿到的类型信息干净,不会因为 C# 类加了重载导致接口的 DISPID 整体漂移。
参数说明:double 是 COM 里的 VT_R8,PB 那边 Numeric 类型直接收。string 显式标了 BSTR,这是 COM 字符串的标准形态,PB 的 String 正好也走 BSTR。C# 默认的 string 在 COM 里就是 BSTR,但显式标出来,后面维护的人不会误加 CharSet 把参数改成 LPSTR——那是乱码的常见来源。
3.2 regasm 注册:选对注册工具比记住命令更重要
C# 类库不是直接拷给 PB 就能用的,它必须先被注册到 COM 系统里。注册工具是 .NET 自带的 regasm.exe,注意它有两个版本,对应 32 位和 64 位注册表视图:
# 32 位视角注册(PB 是老版本 32 位时必须用这个) "C:\Windows\Microsoft.NET\Framework\v4.0.30319\regasm.exe" /codebase /tlb "C:\build\PbCalc.dll" # 64 位视角注册(PB 2019 及以后的 64 位版本用这个) "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\regasm.exe" /codebase /tlb "C:\build\PbCalc.dll"逻辑说明:Framework 和 Framework64 两个目录下的 regasm 各自维护独立的注册表视图。PB 12.x 是 32 位进程,PB 2019 及以后提供了 64 位版本。注册工具选错,现象往往是:本机调试成功、换台机器失败,或者 32 位程序调得到、64 位程序调不到。我习惯在部署脚本里同时跑两个 regasm,反正注册两次不影响结果,能少排查一个变量。
参数说明:/codebase 让注册表直接记 DLL 的物理路径,开发阶段必须带,否则 regasm 会要求程序集有强名称。/tlb 参数会顺带生成类型库文件,C# 侧调试时用得上。开发调试阶段不要做强命名,版本管理省很多事;进生产再做强命名和 GAC 也不迟。
3.3 PB 侧:CreateObject 与 OLEObject 的双保险写法
PB 端调用有两种写法,一种是 CreateObject 简写,另一种是 OLEObject 加 ConnectToNewObject。我推荐后者,因为它能把错误码暴露出来:
OLEObject lole integer li_rc lole = CREATE OLEObject li_rc = lole.ConnectToNewObject("PbCalc.Calc") if li_rc <> 0 then MessageBox("错误", "连接 COM 失败,错误码:" + string(li_rc)) return end if double ld_result ld_result = lole.Add(100, 23.5) MessageBox("结果", string(ld_result)) lole.DisconnectObject() DESTROY lole逻辑说明:CREATE OLEObject 先建一个空的 COM 对象引用,再用 ConnectToNewObject 按 ProgId 激活目标组件。这个写法比直接 CreateObject("PbCalc.Calc") 多一步,但能拿到具体错误码,而不是只有一个笼统的 Automation 异常。返回值是 OLE 变体,赋给 double 时 PB 会自动做类型转换。
参数说明:ConnectToNewObject 的第一个参数是 ProgId,必须和 C# 里 [ProgId("PbCalc.Calc")] 一致,拼写错了会报 Class not registered。我建议把 ProgId 定义成常量,两边各写一份,改的时候同时改。DisconnectObject 和 DESTROY 成对出现,COM 引用计数才能干净释放,否则频繁调用会把进程里的 COM 缓存撑爆。
PB 和 C# 的类型对应关系,第一次做互调的人最容易在这里掉坑,先记一张表:
| PB 类型 | C# 侧接收 | 注意点 |
|---|---|---|
| String | string | 显式走 BSTR,避免 LPSTR |
| Numeric / Decimal | double | 接口里优先用 double |
| Long | int | 32 位整数,别用 C# long 接 |
| LongLong | long | PB 高版本才有的 64 位整数 |
| Boolean | bool | COM 里是 VARIANT_BOOL,注意 -1 与 1 的差异 |
3.4 第一个必调参数:接口要显式,别裸奔 Public Class
没有显式接口、直接把 public 方法堆在 class 上、ClassInterface 用 AutoDual 的代码也能跑,但那样 COM 会自动生成接口。问题在于:PB 端会把接口的分派 ID(DISPID)缓存起来,C# 类加一个重载或改一次签名,DISPID 漂移,旧 PB 调用就会出现“参数个数不对”或者 Automation 错误。显式接口加 None 模式,就是给这个缓存上了保险。
如果你接手的是别人写的旧 C# 类库,第一件事就是检查两个地方:ClassInterface 是不是 AutoDual,接口和类是不是各有一个固定的 Guid。如果都是自动的,趁早手工固定下来。这个动作看起来小,但它决定了互调代码是不是过两个月就变成玄学问题。
4. C# 调 PB 的 dll:三条路线与它们各自的适用场景
4.1 路线一:PB 编译成 COM Server,C# 直接引用
这是我处理“C# 想调 PB 已有逻辑”时的默认首选。PB 侧步骤是:新建 Custom Class NVO,在里面写公开方法,方法名建议保留 of_ 前缀;新建一个 Project,选择 COM/MTS Component 向导,指定这个 NVO,设置 ProgID;构建后得到 DLL 和 TLB。
using System; using System.Runtime.InteropServices; class PbCaller { public static void CallPb() { // 方式 A:按 ProgId 动态激活 PB COM Server Type t = Type.GetTypeFromProgID("PbBiz.Check"); if (t == null) throw new COMException("PB COM Server 未注册"); object obj = Activator.CreateInstance(t); object result = t.InvokeMember("of_check", System.Reflection.BindingFlags.InvokeMethod, null, obj, new object[] { "ABC-123" }); Console.WriteLine(result); Marshal.FinalReleaseComObject(obj); } }逻辑说明:Type.GetTypeFromProgID 去注册表找 ProgId,找到的是 PB 构建出来的 COM Server。Activator.CreateInstance 激活它,InvokeMember 按方法名和参数数组反射调用。PB 方法返回值会包成 VARIANT,C# 拿到的是 object,再按上章的类型表转成期望的类型即可,不要想当然去强转。
参数说明:PB 的 String 到 C# 是 string,PB 的 Long 到 C# 是 int(32 位),PB 的 LongLong 才是 C# 的 long。初学者最容易在这出错——拿 C# long 接 PB Long,数值一超过 32 位就溢出成负数。另一个点:InvokeMember 的 BindingFlags 要明确写 InvokeMethod,否则遇到同名属性时会解析失败。
4.2 路线二:PB 主动调 C#,再用回调实现反向通知
场景往往是:PB 界面发起请求,C# 处理耗时较长,处理完了要通知 PB 刷新界面。直接的做法是在 C# COM 组件里定义回调接口,把 PB 的一个对象引用传进去:
[ComVisible(true)] public interface ICalcClient { [DispId(1)] void OnResult(double value); } [ComVisible(true)] [ClassInterface(ClassInterfaceType.None)] public class Calc : ICalc { public void ComputeAndNotify(double a, double b, ICalcClient client) { double r = a + b; client.OnResult(r); } }逻辑说明:C# 端把接口对象当参数接收,在方法内部直接调用它的 OnResult。PB 端的问题是,PB 没有干净的“我实现某个 COM 接口”的语法,要把 PB 对象包装成接口传入非常别扭。我在项目里见到的可行做法有两种:一是 PB 端用一个注册接口的方式把 NVO 的引用交给 C#,由 C# 持有并回调;二是干脆让 C# 处理完写结果表,PB 用计时器轮询。
参数说明:DispId 特性给回调方法一个稳定的分派号,避免方法名排序变化导致调用错位。回调接口里的参数类型同样遵循上一章的类型映射表,这里用的是 double,两边都省心。
4.3 路线三:PBNI 宿主加 C++/CLI 包装,把 C# 逻辑装进 PB 的 DLL
这条路线给“必须同步直调且性能敏感”的极端场景。思路是:用 C++/CLI 写一个 DLL,导出 extern "C" 函数,函数内部创建 .NET 运行时并加载 C# 程序集;PB 通过声明外部函数直接调用。好处是少一层 COM 分发,坏处是要同时维护 C++/CLI、C#、PB 三侧的调用约定,一个指针写错就能把 PB 虚拟机带崩。
我的结论是:除非你实际测过 COM 调用确实是瓶颈,否则不要走这条。真实项目里 COM 分发的开销通常在零点几毫秒级别,真正贵的是跨模块复制大对象,而那是数据设计问题,不换调用机制也解决不了。真要做,建议先给 PBNI 侧写好独立的自测程序,把导出函数全部用 C 程序验证过再接入 PB。
4.4 实在不行就绕开:Web API 与消息队列也是接口
如果两边调用方向是双向的、改动又频繁,我会停下来问一句:为什么一定要 DLL 互调?PB 十二以后的版本有 HttpClient 对象,可以直接 POST JSON 给 C# 的 Web API;C# 也可以异步调用 PB 暴露的 Web Service。两边的耦合立刻从“进程内共享内存”降级成“HTTP 协议”,调试、灰度、部署都松一大截。
// PB 12+ 通过 WinHttp 组件发 JSON 请求的常见写法 OLEObject lhc lhc = CREATE OLEObject lhc.ConnectToNewObject("WinHttp.WinHttpRequest.5.1") lhc.Open("POST", "http://内网服务名/calc", false) lhc.SetRequestHeader("Content-Type", "application/json") lhc.Send('{"a":100,"b":23.5}') string ls_resp = lhc.ResponseText逻辑说明:这段代码用系统自带的 WinHttp 组件做 HTTP 调用,不依赖第三方库。Open 的第三个参数 false 表示同步等待,配合超时设置可以避免界面卡死。JSON 字符串在 PB 里手工拼容易出错,数据量大时建议用 PB 的 JSONObject 先构造再序列化。
参数说明:内网服务名要保证 PB 客户端机器能解析,部署时在 hosts 或者 DNS 里统一配置。这个方案牺牲了一点性能,换来的是两边团队可以独立发版,互不牵制。在我做过的项目里,这往往比强行互调 DLL 更受双方欢迎。
5. 接口互调避坑:四个高频翻车现场的诊断记录
5.1 Automation 错误:注册成功不等于人家认你
现象:regasm 跑完没有任何报错,注册表里也能看到 ProgId,但 PB 端 ConnectToNewObject 返回 Automation 错误。
原因:最常见的三个。一是 C# 程序集没有强命名却用了 /codebase,系统要求它进 GAC,导致注册表里 CLSID 指向的路径无效;二是 regasm 注册到了 64 位视图,而 PB 是 32 位进程,读的是 32 位注册表视图;三是 ClassInterface 设成了 AutoDual,PB 解析自动生成的接口时 DISPID 冲突。
解决:先确认位数,再确认强命名状态。打开注册表编辑器,在 HKEY_CLASSES_ROOT 下找到 ProgId,展开 CLSID,确认 InprocServer32 里的默认值是 mscoree.dll,并且 CodeBase 指向的 DLL 路径真实存在。另外一条规矩:接口和类各用固定 Guid,不要每次编译生成新的。
5.2 String 参数乱码:BSTR 与 PB String 的血型不合
现象:C# 方法收到 PB 传过来的字符串,中文变乱码,或者字符串只剩前半截。
原因:C# 端 string 参数默认按 BSTR 编组,但如果有人给方法加了 CharSet.Ansi,COM 会按 LPSTR 处理,编码从 Unicode 变成 ANSI,和 PB 的 Unicode 字符串一碰就乱。截断则是 PB 那边声明外部函数时参数长度写小了,或者用了 Char 数组而不是 String。
解决:C# 端所有 string 参数和返回值都显式标 [MarshalAs(UnmanagedType.BSTR)],不留给默认行为猜。PB 端不要用 Blob 去装字符串,直接用 String。如果需要 C# 改完再传回 PB,用 StringBuilder 配 ref 参数,并且同样标 BSTR,而不是靠返回值传。改完这两处,乱码问题一般当场消失。
5.3 LoadLibrary 失败:x86/x64 位数不一,一切白搭
现象:PBNI 或 C++/CLI 包装的 DLL 在 PB 里加载失败,报 126 或 193 错误。126 是找不到依赖模块,193 是位数不匹配。
原因:PB 12 及更早版本是 32 位进程,C# 项目如果编译成 AnyCPU,在 64 位机器上会以 64 位运行,两边不是同一个世界。另一种情况是 C++/CLI 包装 DLL 依赖 VC++ 运行库和 .NET 运行时,部署机器上缺了这些运行时库,LoadLibrary 一样失败。
解决:C# 编译目标固定 x86,前提是 PB 侧 32 位;C++/CLI 项目也对应设置平台。部署机器装好对应版本的 VC++ Redistributable。验证方法用 dumpbin /dependents 查看 DLL 依赖清单,缺哪个补哪个。位数问题是最不值得反复试的坑,先看进程位数再看编译目标,一次到位。
5.4 状态丢失:COM 的实例模型和你的单例习惯冲突
现象:C# 调 PB COM Server,第一次调用正确,第二次再调用,PB 那边实例变量的值全空了。
原因:PB COM Server 默认是 single-use,每次调用都会激活一个新的 PB 虚拟机实例,实例变量不跨调用保留。如果你把校验中间结果放在 PB 的实例变量里,先 Fill 后 Get 的流程,第二次就全是空值。
解决:两个办法。一是 C# 端持有同一个 COM 实例,一个实例完成完整流程,不要每次调用都重新 CreateObject;二是把状态下沉到数据库或共享存储,PB 方法每次现取。我倾向第二个,因为它不依赖 COM 实例的生命周期,后续加并发也安全。
6. 验证与进阶:用日志探针把互调链路钉死在一个请求上
互相调用出的问题最难的一点是:你不知道断在哪一侧。我的习惯是,每个暴露给对端的 COM 方法,第一行就写日志,参数原样落盘;返回前再写一条结果日志。两侧各写各的文件,用前缀区分。一个请求跑完,日志长这样:
[PB ] 10:00:00.123 调用 C# Add(100, 23.5) [C#] 10:00:00.201 收到 Add a=100 b=23.5 [C#] 10:00:00.202 返回 123.5 [PB ] 10:00:00.980 拿到结果 123.5哪条线断了,问题就在哪一步。如果卡在第一条之后,说明 COM 激活或者参数编组出了问题;如果 C# 返回了但 PB 没拿到,怀疑 VARIANT 转换或引用释放。这套日志探针不需要任何第三方工具,C# 用 StreamWriter,PB 用 FileOpen 加 FileWrite,十分钟就能铺完。
更进阶的用法是给每个请求带一个唯一编号。PB 在调用前生成一个流水号,作为参数传给 C#,C# 记日志时带上它。两边日志按编号一过滤,就能把同一个请求的完整路径拉出来。实测一次卡死问题,就是靠这个编号定位到 C# 侧某个方法在等待数据库锁,和 COM 没关系。
我还会做一件看起来很笨的事:在交付清单里维护一张互调登记表,记录每一对接口的 ProgId、接口 Guid、方法签名、位数、是否强命名、调用方向。这张表平时没人看,但每次出问题,第一件事就是拿它核对环境。这个习惯帮我省掉的排查时间,比做这个方案的时间还多。接口互调的代码可以写得很精巧,但真正让它稳定运行的是这些笨功夫。希望帮到你。
本文还有配套的精品资源,点击获取