打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

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

概述

早期发表的文章是利用 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);#importvoid 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);#importvoid 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。

本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
打开APP,阅读全文并永久保存 查看更多类似文章
猜你喜欢
类似文章
'使用WinInet.dll通过网络在终端间进行数据交互
智能交易系统和自定义指标
一篇关于mql5自动交易语法开发(第一篇)
VC++加载动态库和静态库
VC中加载LIB文件
通过使用类型库提高VB调用DLL函数的性能
更多类似文章 >>
生活服务
热点新闻
分享 收藏 导长图 关注 下载文章
绑定账号成功
后续可登录账号畅享VIP特权!
如果VIP功能使用有故障,
可点击这里联系客服!

联系客服