
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); #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++ 还提供与对齐相关的综合材料。
在我们的示例中,发生错误是因为函数库和脚本具有不同的对齐方式。 有两种方法可以解决问题:
- 在脚本中指定新的对齐方式。 这可以利用 pack(n) 属性完成。 我们尝试根据最大字段对齐结构,即 int:
struct ESTRUCT1 pack(sizeof(int)){ int val1; char cval; int val2; };
我们重复执行脚本。 日志中的条目已变为: SamplesStruct1: val1: 3 cval: A val2: 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 传递对象,尽管这不被允许。 这些包括含有动态对象的结构,啮合数组,等等。 在这种情况下可以做些什么? 无法访问函数库代码,无法使用此解决方案。 访问代码可以帮助解决问题。
我们不会考虑数据设计的变化,因为我们应当尝试可用的方法来解决它,并避免异常。 需要做一些澄清。 本文不适合经验丰富的用户,所以我们只概述该问题可能的解决方案。
- 利用 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 结构,可以将其传递给函数库而无需额外的转换。 我还没有在实际代码上测试过这个函数的操作。
- 在对象中创建可传递给函数库的结构封装器/解封器。 这种方法是可行的,但非常复杂,且是资源和时间密集型。 但是,这种方法提出了一个完全可以接受的解决方案:
- 所有无法直接传递给函数库的对象都应该封装成脚本中的 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 Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/5798
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.




我没跟你争论;-)
所以--我想起了一些注意事项,也许对某些人有用。
不与文章争论
在两种语言的交界处,会有什么样的"新手程序员"?
顺便说一句,你的内存很大。
内存怎么了,我是不是哪里弄错了?
新手程序员是个很牵强的概念 ) 我绝对是 Python 的新手 ) 或者是 java 脚本的新手。还有很多其他东西,我都可以称自己是初学者。在这里,如果一个人以前没有做过程序库,但却做了二十年的 CAD 或为 Adobe 程序编写插件,会是什么情况呢?当然,他在新领域是初学者,但在老领域经验丰富。总之,没关系,这些术语在这里并不重要。