10 分钟掌握 MQL5 的 DLL(第二部分):使用 Visual Studio 2017 创建

14 五月 2019, 11:19
Andrei Novichkov
0
994

概述

早期发表的文章是利用 Visual Studio 2005/2008 创建 DLL,而撰写本文则是一脉相承。 初版文章依然具有其相关性,因此如果您对此主题感兴趣,请务必阅读第一篇文章。 从初版起已经过了很久时间,而当前的 Visual Studio 2017 具有全新的界面。 MetaTrader 5 平台也拥有了诸多新功能。 显然,需要更新观念并考虑一些新功能。 在本文中,我们将在 Visual Studio 2017 中经历所有步骤来开发 DLL 项目,并将完成的 DLL 与终端相连接供其使用。

本文适合想要学习如何创建 C++ 库,并将其连接到终端的初学者。

为什么要将 DLL 与终端连接?

一些开发人员认为不应该将任何函数库与终端连接,因为没有什么任务必须要进行此类连接,其所需的功能可以利用 MQL 方式来实现。 这种观点在某种程度上是正确的。 很少有什么任务需要函数库。 大多数所需任务都可以使用 MQL 工具解决。 此外,在连接函数库时,应该理解使用此函数库的智能交易系统或指表将无法在没有该 DLL 的情况下运行。 如果您需要将此类应用程序转移给第三方,则必须传输两个文件,即应用程序本身和函数库。 有时这可能非常不方便,甚至不可能做到。 另一个缺点是函数库可能不安全,且可能隐藏恶意代码。

然而,函数库也有优点,这肯定超过了缺点。 例如:

  • 函数库可有助于解决 MQL 无法解决的问题。 以邮件列表为例,当您需要发送带附件的电子邮件时。 可以编写 DLL 来沟通 Skype, 等等。
  • 使用函数库可以更快速、更高效地执行以 MQL 语言实现的一些任务。 这包括 HTML 页面解析和使用正则表达式。

如果您想解决这些复杂的任务,您应掌握自己的技能,并正确学习如何创建和连接函数库。

我们已经研究过在我们的项目中使用 DLL 的“优点”和“缺点”。 现在我们来一步步地研究使用 Visual Studio 2017 创建 DLL 的过程。

创建一个简单的 DLL

整个过程已在初版文章中有所描述。 如今我们再次研究软件的更新和变化。

运行 Visual Studio 2017,并导航到文件 -> 新建 -> 项目。 在新项目窗口的左侧,展开 Visual C++ 列表,然后从中选择 Windows 桌面。 在中间部分选择 Windows 桌面向导那一行。 使用底部的输入字段,您可以编辑项目名称(建议您设置有意义的名称),并设置项目位置(推荐保留建议值)。 单击“确定”,然后继续下一个窗口:


从下拉列表中选择动态链接库(.dll),然后选中“导出符号”。 勾选此项是可选的,但建议初学者这样做。 在这种情况下,演示代码将添加到项目文件中。 这段代码可以查看,之后删除或注释。 单击“确定”将创建项目文件,然后可以对其进行编辑。 不过,我们先要考虑项目设置。 首先,请记住,MetaTrader 5 仅可协同 64 位函数库操作。 如果您尝试连接 32 位 DLL,您将收到以下消息:

'E:\...\MQL5\Libraries\Project2.dll' is not 64-bit version
Cannot load 'E:\MetaTrader 5\MQL5\Libraries\Project2.dll' [193]

因此,您将无法使用此函数库。

相反的限制适用于 MetaTrader 4 的 DLL:只允许使用 32 位函数库,而无法连接 64 位 DLL。 牢记这一点并为您的平台创建相应的版本。

现在进入项目设置。 从“项目”菜单中选择“名称属性...”,其中“名称”是开发人员在创建阶段指定的项目名称。 这会打开一个包含各种不同设置的窗口。 首先,您应该启用 Unicode。 在窗口的左侧选择“常规”。 在右侧部分中,选择第一列中的标题行:“字符集”。 第二列中将提供下拉列表。 从该列表中选择“使用 Unicode 字符集”。 在某些情况下,不需要 Unicode 支持。 我们稍后会讨论这些案例。

项目属性中另一个非常有用(但不是必需的)变化:将完成的函数库复制到终端的 “Library” 文件夹中。 在初版文章中,这是通过更改“输出目录”参数来完成的,该参数位于项目的“常规”元素的同一窗口中。 而在 Visual Studio 2017 中无需执行此操作。 请勿更改此参数。 然而,请注意 “构建事件” 项:您应该选择其 “构建后事件” 子元素。 “命令行”参数将出现在右侧窗口的第一列中。 选择它,在第二列中打开可编辑列表。 这应该是 Visual Studio 2017 在构建函数库后会执行的操作列表。 将以下行添加到此列表内:

xcopy "$(TargetDir)$(TargetFileName)" "E:\...\MQL5\Libraries\" /s /i /y

此处您应指定相应终端文件夹的完整路径来替代 ”...“。 成功构建函数库后,您的 DLL 将被复制到指定的文件夹。 在这种情况下,“输出目录”中的所有文件都将被保留,这对于进一步的版本控制开发非常重要。

最后一个非常重要的项目设置步骤如下。 想象一下,该函数库已构建完毕,并包含一个可供终端使用的函数。 假设此函数具有以下简单原型:

int fnExport(wchar_t* t);
该函数可以从终端脚本调用,如下所示:
#import "Project2.dll"
int fnExport(string str);
#import

然而,在这种情况下将返回以下错误消息:

如何解决这种状况? 在函数库代码生成期间,Visual Studio 2017 形成了以下宏:

#ifdef PROJECT2_EXPORTS
#define PROJECT2_API __declspec(dllexport)
#else
#define PROJECT2_API __declspec(dllimport)
#endif

所需函数的完整原型如下所示:

PROJECT2_API int fnExport(wchar_t* t);

在函数库编译之后查看导出表:


若要查看它,请在全部指令窗口中选择函数库文件,然后按 F3。 注意导出函数的名称。 现在我们来编辑上述的宏(这是在初版文章中完成的方式):

#ifdef PROJECT2_EXPORTS
#define PROJECT2_API extern "C" __declspec(dllexport)
#else
#define PROJECT2_API __declspec(dllimport)
#endif

此处

extern "C"

表示在接收目标文件时使用简单的函数签名生成(C 语言风格)。 特别是,这会禁止 C++ 编译器在导出到 DLL 时使用附加字符“装饰”函数名称。 重新编译并查看导出表:

导出表中的变化很明显,现在从脚本调用函数时不会发生错误。 不过,该方法有一个缺点:您必须编辑由编译器创建的脚本。 有一种更安全的方式来执行相同的操作,但是有点冗长:

定义文件

这是一个带有 .def 扩展名的纯文本文件,通常其名称与项目名称相匹配。 在我们的案例中,会是 Project2.def 文件。 该文件是由常用的记事本所创建。 切勿使用 Word 或类似的编辑器。 文件内容如下:

; PROJECT2.def : 声明 DLL 模块的参数。

LIBRARY      "PROJECT2"
DESCRIPTION  'PROJECT2 Windows Dynamic Link Library'

EXPORTS
    ; Explicit exports can go here
        fnExport @1
        fnExport2 @2
        fnExport3 @3
        ....

标题后面是导出的函数列表。 字符 @1,@2 等等表示函数库中期望的函数顺序。 将此文件保存在项目文件夹中。

现在我们来创建这个文件,并连接到项目。 在项目属性窗口的左侧,选择“链接器”元素及其“输入”子元素。 然后在右侧部分中选择“模块定义文件”参数。 如同以前的情况,访问可编辑列表并添加文件名:“Project2.def”。 单击“确定”并重复编译。 结果与上一个屏幕截图相同。 该名称未进行修饰,并且在脚本调用该函数时不会遇到任何错误。 我们已分析了项目设置。 现在我们开始编写函数库代码。

创建函数库和 DllMain

初版文章提供了与数据交换和 DLL 的各种函数调用相关问题的全面描述,因此我们不再赘述。 我们在函数库中创建一段简单的代码来查看一些特定的功能:

1. 添加以下函数进行导出(不要忘记编辑定义文件):

PROJECT2_API int fnExport1(void) {
        return GetSomeParam();
}

2. 创建 Header1.h 头文件,并将其添加到项目中,还要向其添加另一个函数:

const int GetSomeParam();
3. 编辑 dllmain.cpp 文件:
#include "stdafx.h"
#include "Header1.h"

int iParam;

BOOL APIENTRY DllMain( HMODULE hModule,
                       DWORD  ul_reason_for_call,
                       LPVOID lpReserved
                     )
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
                iParam = 7;
                break;
    case DLL_THREAD_ATTACH:
                iParam += 1;
                break;
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

const int GetSomeParam() {
        return iParam;
}

这段代码目的应该是清晰的:将一个变量添加到函数库中。 其值在 DllMain 函数中计算,可使用 fnExport1 函数获得。 我们在脚本中调用该函数:

#import "Project2.dll"
int fnExport1(void);
#import
...
void OnStart() {
Print("fnExport1: ",fnExport1() );

以下条目是其输出:

fnExport1: 7

这意味着 DllMain 代码的这部分未被执行:

    case DLL_THREAD_ATTACH:
                iParam += 1;
                break;

这很重要吗? 在我看来,这是至关重要的,因为如果开发人员在这里添加了部分函数库初始化代码,期望将函数库连接到流时执行它,操作将失败。 但是,不会返回任何错误,因此很难发现问题。

字符串

初版文章中已论述了如何操作字符串。 这项操作并不困难。 然而我想澄清以下具体问题。

我们在函数库中创建一个简单的函数(并编辑定义文件):

PROJECT2_API void SamplesW(wchar_t* pChar) {
        size_t len = wcslen(pChar);
        wcscpy_s(pChar + len, 255, L" Hello from C++");
}
在脚本中调用此函数:
#import "Project2.dll"
void SamplesW(string& pChar);
#import

void OnStart() {

string t = "Hello from MQL5";
SamplesW(t);
Print("SamplesW(): ", t);

将收到以下预期消息:

SamplesW(): Hello from MQL5 Hello from C++

现在编辑函数调用:

#import "Project2.dll"
void SamplesW(string& pChar);
#import

void OnStart() {

string t;
SamplesW(t);
Print("SamplesW(): ", t);

这次我们收到一条错误消息:

Access violation at 0x00007FF96B322B1F read to 0x0000000000000008

初始化传递给库函数的字符串,并重复执行脚本:

string t="";

没有收到错误消息,所以我们得到预期的输出:

SamplesW():  Hello from C++

上述代码建议如下:必须初始化传递给库函数导出的字符串!

现在是时候返回 Unicode 了。 如果您不打算将字符串传递给 DLL(如上一个案例所示),则不需要 Unicode 支持。 但是我建议在任何情况下都启用 Unicorn 支持,因为导出的函数签名可以修改,可以添加新函数,开发人员可以忘记缺少 Unicode 支持。

符号数组以通用方式传递和接收,这在初版文章中有所论述。 因此,我们无需再讨论它们。

结构

我们在函数库和脚本中定义最简单的结构:

//在 dll 里:
typedef struct E_STRUCT {
        int val1;
        int val2;
}ESTRUCT, *PESTRUCT;

//在 MQL 脚本里:
struct ESTRUCT {
   int val1;
   int val2;
};

添加用于处理函数库内结构的函数:

PROJECT2_API void SamplesStruct(PESTRUCT s) {
        int t;
        t = s->val2;
        s->val2 = s->val1;
        s->val1 = t;
}

从代码中可以看出,该函数只是交换自身的字段。

从脚本中调用此函数:

#import "Project2.dll"
void SamplesStruct(ESTRUCT& s);
#import
....
ESTRUCT e;
e.val1 = 1;
e.val2 = 2;
SamplesStruct(e);
Print("SamplesStruct: val1: ",e.val1," val2: ",e.val2);

运行脚本并获得预期结果:

SamplesStruct: val1: 2 val2: 1

该对象通过引用传递给被调用函数。 该函数处理该对象,并将其返回给调用代码。

不过,我们经常需要更复杂的结构。 我们将任务复杂化:在结构中再添加一个具有不同类型的字段:

typedef struct E_STRUCT1 {
        int val1;
        char cval;
        int val2;
}ESTRUCT1, *PESTRUCT1;

还要加入一个处理它的函数:

PROJECT2_API void SamplesStruct1(PESTRUCT1 s) {
        int t;
        t = s->val2;
        s->val2 = s->val1;
        s->val1 = t;
        s->cval = 'A';
}

与前一种情况一样,该函数交换 int 类型的字段,并将值赋给 'char' 类型字段。 在脚本中调用此函数(与前一个函数完全相同的方式)。 但是,这次的结果如下:

SamplesStruct1: val1: -2144992512 cval: A val2: 33554435

int 类型的结构字段包含错误的数据。 这并非是一个例外,而是不正确的随机数据。 发生了什么? 原因在于对齐(alignment)! 对齐并非是一个非常复杂的概念。 与结构相关的文档部分 pack 提供了对齐的详细说明。 Visual Studio C++ 还提供与对齐相关的综合材料。

在我们的示例中,发生错误是因为函数库和脚本具有不同的对齐方式。 有两种方法可以解决问题:

  1. 在脚本中指定新的对齐方式。 这可以利用 pack(n) 属性完成。 我们尝试根据最大字段对齐结构,即 int
    struct ESTRUCT1 pack(sizeof(int)){
            int val1;
            char cval;
            int val2;
    };
    
    我们重复执行脚本。 日志中的条目已变为: SamplesStruct1: val1: 3 cval: A val2: 2。 因此错误得以解决。

  2. 指定新的函数库对齐。 MQL 结构的默认对齐方式是 pack(1)。 将相同的内容应用于函数库:
    #pragma pack(1)
    typedef struct E_STRUCT1 {
            int val1;
            char cval;
            int val2;
    }ESTRUCT1, *PESTRUCT1;
    #pragma pack()
    
    构建函数库并运行脚本:结果是正确的,与第一种方法相同。
再检查一下。 如果结构包含除数据字段之外的方法,会发生什么? 这很有可能。 程序员也可以添加构造函数(不是方法),析构函数或其他东西。 我们在以下函数库结构中检查这些情况:
#pragma pack(1)
typedef struct E_STRUCT2 {
        E_STRUCT2() {
                val2 = 15;
        }
        int val1;
        char cval;
        int val2;
}ESTRUCT2, *PESTRUCT2;
#pragma pack()
该结构将由以下函数使用:
PROJECT2_API void SamplesStruct2(PESTRUCT2 s) {
        int t;
        t = s->val2;
        s->val2 = s->val1;
        s->val1 = t;
        s->cval = 'B';
}
在脚本中进行相应的修改:
struct ESTRUCT2 pack(1){
        ESTRUCT2 () {
           val1 = -1;
           val2 = 10;
        }
        int val1;
        char cval;
        int f() { int val3 = val1 + val2; return (val3);}
        int val2;
};

#import "Project2.dll" 
void SamplesStruct2(ESTRUCT2& s); 
#import
...
ESTRUCT2 e2;
e2.val1 = 4;
e2.val2 = 5;
SamplesStruct2(e2);
t = CharToString(e2.cval);
Print("SamplesStruct2: val1: ",e2.val1," cval: ",t," val2: ",e2.val2);

请注意,已将 f() 方法添加到结构中,因此与函数库中的结构有了更多差异。 运行脚本。 以下条目写入流水日志:SamplesStruct2:  val1: 5 cval: B val2: 4  The execution is correct! 在我们的结构中存在构造函数和附加方法不会影响结果。

最后一个实验。 从脚本中的结构中删除构造函数和方法,同时只保留数据字段。 函数库中的结构保持不变。 再次执行脚本生成正确的结果。 这令我们能够得出最终结论:结构中存在的其他方法不会影响结果。

Visual Studio 2017 的这个函数库项目和 MetaTrader 5 脚本如下所示。

您不能做什么

DLL 的操作存在某些限制,这些限制在相关文档中均有所论述。 我们在此不再重复。 这是一个示例:

struct BAD_STRUCT {
   string simple_str;
};

此结构无法传递给 DLL。 这是一个包裹在结构中的字符串。 更复杂的对象无法传递给 DLL,这不会有例外。

怎么办,如果什么都做不了

我们经常需要向 DLL 传递对象,尽管这不被允许。 这些包括含有动态对象的结构,啮合数组,等等。 在这种情况下可以做些什么? 无法访问函数库代码,无法使用此解决方案。 访问代码可以帮助解决问题。

我们不会考虑数据设计的变化,因为我们应当尝试可用的方法来解决它,并避免异常。 需要做一些澄清。 本文不适合经验丰富的用户,所以我们只概述该问题可能的解决方案。

  1. 利用 StructToCharArray() 函数。 这似乎是一个很好的机会,它允许在脚本中使用以下代码:
    struct Str 
      {
         ...
      };
    
    Str s;
    uchar ch[];
    StructToCharArray(s,ch);
    
    SomeExportFunc(ch);
    
    cpp 函数库文件中的代码:
    #pragma pack(1)
    typedef struct D_a {
    ...
    }Da, *PDa;
    #pragma pack()
    
    void SomeExportFunc(char* pA)
      {
            PDa = (PDa)pA;
            ......
      }
    
    除了安全性和品质问题之外,这个思路是无用的:StructToCharArray() 仅适用于 POD 结构,可以将其传递给函数库而无需额外的转换。 我还没有在实际代码上测试过这个函数的操作。

  2. 在对象中创建可传递给函数库的结构封装器/解封器。 这种方法是可行的,但非常复杂,且是资源和时间密集型。 但是,这种方法提出了一个完全可以接受的解决方案:

  3. 所有无法直接传递给函数库的对象都应该封装成脚本中的 JSON 字符串,然后解封到函数库里的结构中。 反之亦然。 为此有众多可用的工具:JSON 解析器,可用于 C++,C# 和 用于 MQL。 如果您准备花一些时间封装/解封对象,可以使用此方法。 不过,除了明显的时间损失之外,还有其他优点。 该方法能够以非常复杂的结构(以及其他对象)进行操作。 甚或,您可以优化现有的封装/解封器,而无需从头开始编写。

所以请记住,将复杂对象传递(并接收)到函数库中是可能的。

实际运用

现在我们尝试创建一个有用的函数库。 该函数库将发送电子邮件。 请注意以下时刻:

  • 该函数库不能用来发送垃圾邮件。
  • 函数库可以从地址和服务器发送电子邮件,而不是终端设置中指定的电子邮件。 甚或,可以在终端设置中禁用电子邮件,但这不会影响该函数库的操作。

最后一件事。 大多数 C++ 代码不是我写的,而是从 Microsoft 论坛下载的。 这都是久经考验的示例,其变体也可在 VBS 上获得。

我们开始吧。 在 Visual Studio 2017 中创建项目,并按照文章开头所述更改其设置。 创建定义文件并将其连接到项目。 只有一个导出函数:

SENDSOMEMAIL_API bool  SendSomeMail(LPCWSTR addr_from,
        LPCWSTR addr_to,
        LPCWSTR subject,
        LPCWSTR text_body,

        LPCWSTR smtp_server,
        LPCWSTR smtp_user,
        LPCWSTR smtp_password);

其参数的含义很明确,所以这里有一个简短的解释:

  • addr_from, addr_to — 发件人和收件人邮件地址。
  • subject, text_body — 主题和电子邮件正文。
  • smtp_server, smtp_user, smtp_password — SMTP 服务器地址,用户登录名和服务器密码。

注意以下几点:

  • 从参数说明中可以看出,若要发送邮件,您需要在邮件服务器上拥有一个帐户,并知晓其地址。 因此发件人不能匿名。
  • 端口号在函数库中进行了硬编码。 这是标准端口编号 25。
  • 函数库接收所需数据,连接到服务器并向其发送电子邮件。 在一次调用中,电子邮件只能发送到一个地址。 若要发送更多邮件,请使用新地址重复调用函数。

我不会在此提供 C++ 代码。 下面的附件 SendSomeMail.zip 项目中提供了此代码以及整个项目。 用到的 CDO 对象具有许多功能,应用于未来函数库的开发和改进。

除了这个项目,我们还编写一个简单的脚本来调用库函数(它位于附件的 SendSomeMail.mq5 文件中):

#import "SendSomeMail.dll"
bool  SendSomeMail(string addr_from,string addr_to,string subject,string text_body,string smtp_server,string smtp_user,string smtp_password);
#import

//+------------------------------------------------------------------+
//| 脚本程序开始函数                                                     |
//+------------------------------------------------------------------+
void OnStart()
  {
   bool b = SendSomeMail("XXX@XXX.XX", "XXXXXX@XXXXX.XX", "hello", "hello from me to you","smtp.XXX.XX", "XXXX@XXXX.XXX", "XXXXXXXXX");
   Print("Send mail: ", b);
  }

添加您自己的帐户详细信息替代 X 字符。 至此,开发完成。 添加您自己的详细信息,添加您可能需要的任何内容,令该函数库完整可用。

结束语

使用初版文章,并考虑到本文中包含的更新,任何人都可以快速掌握基础知识,并继续学习更复杂和有趣的项目。

我想再谈一个有趣的事实,这在特定情况下非常重要。 如何保护 DLL 代码? 标准解决方案是使用封装器。 有很多不同的封装器,其中许多可以提供良好的保护等级。 我有两个封装器:Themida 2.4.6.0 和 VMProtect Ultimate v.3.0.9。 我们利用每个封装器将我们的第一个简单 Project2.dll 封装为两个变体。 之后,使用终端中现有脚本调用导出的函数。 一切正常! 终端可以运行这些函数库。 然而,不保证用其他封装工具保护的函数库也能够正常运行。 Project2_Pack.zip 中提供了以两种方法封装的 Project2.dll

就是这样。 祝好运伴随您进一步的开发。

本文中用到的程序

 # 名称
类型
 说明
1 Project2.zip 存档
简单 DLL 项目
2
Project2.mq5
脚本
调用 DLL 操作的脚本
3 SendSomeMail.zip 存档 发送邮件 DLL 项目
4 SendSomeMail.mq5 脚本
调用 SendSomeMail 函数库 dll 进行操作的脚本
5 Project2_Pack.zip 存档 用 Themida 和 VMProtect 保护的 Project2.dll。




本文译自 MetaQuotes Software Corp. 撰写的俄文原文
原文地址: https://www.mql5.com/ru/articles/5798

附加的文件 |
Project2.mq5 (3.42 KB)
SendSomeMail.mq5 (1.15 KB)
SendSomeMail.zip (16.62 KB)
Project2_Pack.zip (4645.25 KB)
Project2.zip (18.65 KB)
在 MetaTrader 5 中使用 MATLAB 2018 的计算功能 在 MetaTrader 5 中使用 MATLAB 2018 的计算功能

在2015年升级了 MATLAB 包之后,有必要考虑一种现代的创建 DLL 库的方法。本文利用样本预测指标,说明了在目前使用的64位平台上关联 MetaTrader 5 和 MATLAB 的特点。通过探讨连接 MATLAB 的整个过程,MQL5 开发人员将能够更快地创建具有高级计算能力的应用程序,从而避免“陷阱”。

用于轻松快速开发 MetaTrader 程序的函数库(第三部分)。 市价订单和仓位的集合,搜索和排序 用于轻松快速开发 MetaTrader 程序的函数库(第三部分)。 市价订单和仓位的集合,搜索和排序

在第一部分中,我们曾创建了一个大型跨平台函数库,简化 MetaTrader 5 和 MetaTrader 4 平台程序的开发。 再者,我们实现了历史订单和成交的集合。 我们的下一步是创建一个类,用来针对订单、成交和仓位的集合进行选择和排序。 我们将实现名为引擎(Engine)的基准函数库对象,并向函数库中添加市价订单和仓位的集合。

开发一个跨平台网格 EA 开发一个跨平台网格 EA

在本文中,我们将学习如何创建在 MetaTrader 4 和 MetaTrader 5 中都能工作的 EA 交易。为此,我们将开发一个 EA 构建的订单网格,网格是指将多个限价订单置于当前价格之上,同时将相同数量的限价订单置于当前价格之下的 EA 交易。

研究烛条分析技术(第四部分):形态分析器的更新和补充 研究烛条分析技术(第四部分):形态分析器的更新和补充

本文论述了形态分析器(Pattern Analyzer)应用程序的新版本。 此版本修复了已发现错误并提供了一些新功能,还改进了用户界面。 在新版本的开发过程中参考了上一篇文章中的意见和建议。 最终的应用程序会在本文中进行说明。