起初只是希望能在运行时调试通达信 DLL 时输出一些调试信息,直接想到的是采用一个窗体,窗体内包含输出信息的文本框,和一个清除文本框内容的按钮。但我并没有通过 C++ 开发桌面端应用的经验。否则用 Qt 应该是个不错的选择。

我对桌面端应用的开发经验大部分是关于 C# 的,首先想到的就是用 C# 来做窗体控件了,这就涉及到了 C++ 调用 C#,或者 C++ 和 C# 的混合编程。经过短时间调研,最终选择了采用 C++/CLI 来建立混合编程的方式,来构建 DLL。

这样做的好处是,我不用再额外点太多 C++ 的技能点,而且还可以享受丰富的 .NET 生态。譬如,除了设计一个调试输出窗口,还可以借助 .NET 的生态,嵌入 javascript 引擎,采用 js 写指标,让我能够在运行时动态调整指标代码。

现在开始吧。

先决条件

  • 你需要了解 C++ 语言的基础知识。
  • 在 Visual Studio 2017 及更高版本中,C++/CLI 支持是一个可选组件。 若要安装它,请从 Windows 开始菜单打开“Visual Studio 安装程序”。 确保选中“使用 C++ 的桌面开发”磁贴,并在“可选”组件部分选中“C++/CLI 支持”。

环境搭建

Win10 系统,采用 Visual Studio 2022(17.5.3),在 Visual Studio Installer 里,单个组件要勾选上:

  • .NET 桌面开发
  • 使用 C++ 的桌面开发
    • 默认选项
    • 对 v143 生成工具(最新)的 C++/CLI 支持(具体版本由你选定的 C++ 版本确定,我这里是 v143)

更多内容请参考 演练:在 Visual Studio 中编译面向 CLR 的 C++/CLI 程序

新建项目

打开 VS,创建新项目,选择“CLR 空项目(.NET Framework)”:

创建新的_clr_项目.png

新建一个名称为CLRDllTest的项目,载入后右侧是标准的 VS 开发 C++ 项目的结构,按照通达信DLL函数编程规范将指定的头文件和源文件放入对应的结构内。

新建一个 .NET Framework 类库项目DebugForm,并设计新建一个窗体类LogForm

using System;
using System.Windows.Forms;

namespace DebugForm
{

    public partial class LogForm : Form
    {
        public LogForm()
        {
            InitializeComponent();
        }

        public void Log(string message)
        {
            textBox1.AppendText($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.FFF}] {message}\r\n");
            textBox1.ScrollToCaret();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            textBox1.Text = "";
        }
    }
}

窗体设计内容如下:

logform_design.png

为了方便CLRDllTest引用,我们可以调整项目DebugForm生成后事件,将其 copy 一份到CLRDllTest的项目下。此处不赘述。

CLRDllTest 引用 C# 窗体

假设CLRDllTest项目已经能引用DebugForm.dll,为了能在全局引用托管的DebugForm,在TCalcFuncSets内定义一个全局引用类:

ref class GlobalObjects {
public:
    static DebugForm::LogForm^ logForm;
};

修改导出给通达信 TCalc 的函数RegisterTdxFunc

BOOL RegisterTdxFunc(PluginTCalcFuncInfo** pFun)
{
    GlobalObjects::logForm = gcnew DebugForm::LogForm();
    GlobalObjects::logForm->Show();
    GlobalObjects::logForm->Log("DLL loaded.");
    if (*pFun == NULL)
    {
        (*pFun) = g_CalcFuncSets;
        return TRUE;
    }
    return FALSE;
}

试着在任意自定义函数内调用logForm

void TestPlugin1(int DataLen, float* pfOUT, float* pfINa, float* pfINb, float* pfINc)
{
    GlobalObjects::logForm->Log("1 号函数被调用,DataLen:" + DataLen);
    for (int i = 0; i < DataLen; i++)
        pfOUT[i] = i;
}

测试使用

编译DebugForm.dllCLRDllTest.dll,将前者放在通达信执行目录(即包含 tdxw.exe 文件的目录)下,后者放在通达信目录下的 T0002/dlls/ 下,打开通达信配置调用 DLL 指标:

{这里 H, L, C 是随便填来占位的,实际在函数内用不到}
X1:TDXDLL1(1, H, L, C);

结果:

testclrdll.png

由此便完成了 C++/CLI 模式下的 .NET Framework 混合编程。

注意事项

dll 最终放置位置/引用

DebugForm.dll 是通过 C++/CLI 托管的动态链接库,载入时,应该由主程序 EXE 载入,即放在 tdxw.exe 同目录下,由 tdxw.exe 载入,而非放在指标 DLL 目录下,否则 tdxw.exe 会找不到 DebugForm.dll,导致程序崩溃。

除非 DebugForm.dll 是通过 COM 注册的 .NET DLL,但那就是运行时动态加载了。此时,Native C++ 项目内其实是有相关 .NET DLL 的信息的(tlb, tlh)文件的,并且此时也不能使用 CLR 模式了。

窗体控件缓冲

此处调出DebugForm::LogForm,并用 TextBox 来存放日志只是一种示范,实测中,当不断调用 Log 方法将日志写入 TextBox 一段时间后,程序便卡死了。为此我调试了两天时间,什么智能指针之类的东西都用了,最后发现居然是……调试窗体的 TextBox 被输入了太多文本,内存溢出了。

这是我万万没想到的。

解决方法是定期清理缓冲区。我编写的程序中,TextBox 的MaxLength被设置为了默认值 32767,输入的日志长度刚超过这个范围时,无事发生,因为此处的MaxLength仅阻止用户输入,而不会阻止通过设置Text属性变化的文本1。但超过大约 138308 (确切值未知)的字符串长度后,程序便卡死。后来我对 TextBox 做了超过指定长度便清除的操作,就没再出现过卡死。猜测应该是被托管的窗体的 TextBox 控件的缓冲区内存溢出了,具体达到多少值便溢出,以及这个最大值由什么决定,没有深究。

所以实际上,当使用某种托管控件时,应该要考虑到该控件的性能限制,按照功能需求合理选择控件,且要考虑到控件能接受的运行时的最大缓冲,避免因内存溢出而导致程序卡死。

为了解决这种情况,如果托管的窗口有太多资源内容的,可以考虑将窗口作为一个独立的进程,通达信和额外窗口之间采用管道进行通信,而非直接调用。(见在通达信 C++/CLI DLL 与外部应用程序之间使用管道通信)。

全局引用

在示例代码中,托管窗体logForm被我放在了一个 ref class 中作为静态变量。实际上经过测试,也可以直接在最顶层声明静态/非静态的托管类型。

资源回收

采用 C++ 编程的最重要的一点,即是要注意资源的回收,否则可能会出现内存溢出的情况。