简介:这是一份面向C/C++开发者的大智慧新一代外部扩展函数接口模板,采用VC编写并以WIN32动态链接库形式组织,主要用于股票软件公式接口的二次开发、外部函数接入与本地化扩展。压缩包共6个文件、约6KB,包含2个cpp源文件、2个h头文件,以及1个dsw工作区文件和1个dsp工程文件,并配有预编译头相关文件,可提供从函数声明、接口实现到VC工程配置的基础骨架。已有1892人学习下载,适合需要了解DLL扩展机制、快速搭建编译环境、验证导出函数或替换公式逻辑的读者参考。通过这套模板,读者可对照头文件与源码理解外部扩展函数的导出方式、参数组织、依赖关系及旧版VC工程的构建方法,并在此基础上加入自己的计算逻辑、调试编译流程,减少从零创建WIN32 DLL工程的重复工作,也便于后续迁移到其他公式计算场景。
1. 从公式编辑器到 C 语言接口:什么时候值得换工具
行情分析终端里写公式,写到一定程度都会撞墙:循环要靠while硬凑,数组要靠一堆中间变量拼接,同一个逻辑要在不同周期重复复制。新一代终端把公式接口开放成 C 语言接口以后,这些限制基本消失——你可以用指针、结构体、数组和递归函数,把指标从脚本搬进 DLL。这篇讲的就是这套 C 语言接口怎么跑通:从调用约定、内存分配讲起,到序列函数落成 C 代码、宿主进程里怎么打印调试、最后落到几条我实际踩过的坑。适合已经能写指标公式、但没写过 DLL 的从业者,也适合接手了旧公式想重构的人。
2. 调用链拆解:宿主、公式引擎和 DLL 到底谁调谁
先把调用关系搞清楚,后面所有报错都能对号入座。公式引擎发现某个公式被标记成外部实现时,会去指定目录加载动态库,按固定签名取到函数地址,然后逐个品种、逐个周期调用一次;每调一次,把这一批 K 线的开高低收量、参数值、输出缓冲区一起交给你,你算完写回输出缓冲区就算结束。整个过程里,宿主是调用方,你是被调用方,谁也不能假设对方的内部结构。
2.1 一次计算的三段式:准备、回调、回收
我一般把一次完整的加载过程分成三段看。第一段是准备:宿主读配置,决定要加载哪个库、参数有哪几个、输出几路。第二段是回调:宿主按品种循环,每次调用你的入口函数,指针全部指向宿主自己的内存,你不持有、也不释放。第三段是回收:宿主读完输出缓冲区就继续下一轮,如果你的 DLL 里还挂着静态缓存,它不会帮你清理。
这个模型带来的第一个约束就是:入口函数一定要写成纯函数形态,进出全靠参数,不要依赖上一次调用的残留状态。有人会把上一根 K 线的计算结果放在全局变量里,图省事,结果宿主一换品种、一换周期,序列长度变了,缓存还在,指标直接错位。除非你确认宿主是严格串行且每次重新初始化,否则不要这么干。
第二个约束是异常不能外抛。C 语言接口这一层通常不做异常捕获,你的代码里如果 C++ 抛出未捕获异常,或者发生除零、访问越界,崩的是整个终端进程,用户看到的是闪退,日志里什么都没有。血泪经验:入口函数的整个函数体都包在try/catch(...)里,出错就返回错误码,宁可这一根指标画不出来,也别把终端带走。
第三个约束是调用频次。公式引擎是按品种和周期分别调的,一个自选股列表加多周期,几百上千次调用很常见。所以初始化工作要放到一次性完成的路径上,别在每次回调里读文件、做字符串解析。真需要读外部配置,用一次性的静态初始化标记,并且注意线程安全(见后文避坑章)。
2.2 最小可编译的指标 DLL:从空工程到能被加载
第一步是工程配置。以常见的 Windows 工具链为例,新建 Win32 动态库工程,注意位数和宿主一致——宿主是 32 位就必须产出 32 位 DLL,这个错配的表现是“公式加载失败”但没有任何提示。运行时库我倾向静态链接(/MT),理由很实际:宿主目录里的 CRT 版本你控制不了,动态链接一旦版本对不上,加载阶段就跪。
第二步是定义入口签名。具体函数名和结构体名以手头那份头文件为准,这里是通用骨架,字段含义基本一致:
// indicator_dll.h —— 宿主与 DLL 之间的契约,只放在本地,不要提交到公式目录 #ifndef INDICATOR_DLL_H #define INDICATOR_DLL_H #ifdef __cplusplus extern "C" { #endif typedef struct { int nCount; // 本次计算有效 K 线根数,所有序列长度都等于它 int nParamCount; // 参数个数,与配置里的参数项一一对应 double* pParams; // 参数值数组,只读 double* pOpen; // 下面六个均为只读行情序列,长度 nCount double* pHigh; double* pLow; double* pClose; double* pVol; double* pAmount; double* pOut0; // 输出缓冲区,由宿主分配并清零,长度 nCount double* pOut1; // 第二路输出,没有就在配置里只声明一路 } KL_CTX; // 返回值约定:0 表示成功,非 0 交给宿主当作错误码记录 typedef int (__stdcall *PFN_CALC)(KL_CTX* pCtx); #ifdef __cplusplus } #endif #endif第三步写入口实现。注意extern "C"和__declspec(dllexport)都要有,C++ 编译器会做名字修饰,少了extern "C"宿主按名字找不到符号:
// indicator_ma.c —— 一路 MA,参数 p[0] 为周期 #include "indicator_dll.h" #include <math.h> __declspec(dllexport) int __stdcall CalcIndicator(KL_CTX* pCtx) { if (pCtx == 0 || pCtx->pClose == 0 || pCtx->pOut0 == 0) return -1; // 参数自检,永远先做 if (pCtx->nCount <= 0 || pCtx->nParamCount < 1) return -1; int n = (int)(pCtx->pParams[0] + 0.5); // 参数是 double,周期要取整 if (n < 1) n = 1; if (n > pCtx->nCount) n = pCtx->nCount; // 周期大于总长度,不要越界 double dSum = 0.0; for (int i = 0; i < pCtx->nCount; ++i) { dSum += pCtx->pClose[i]; if (i >= n) dSum -= pCtx->pClose[i - n]; // 前 n-1 根数据不足,填 0 或填 NaN 由你定,但要和公式写法一致 pCtx->pOut0[i] = (i >= n - 1) ? dSum / n : 0.0; } return 0; }这段的逻辑是两个指针滑窗:i往前走加一根,i-n往前走减一根,全程 O(n),没有内层循环。参数说明上,pParams[0]是配置里声明的第一个参数,类型是 double,取整方式要固定,四舍五入和截断会让同一份配置在两个版本里算出不同结果。输出前 n-1 根填 0 还是填 NaN 影响很大:填 0 会让后续的比较函数误判成金叉,填 NaN 更安全,但要看宿主的绘图模块能不能处理 NaN。
第四步是编译产出,把 DLL 放回公式目录,重启终端,在公式配置里挂上外部实现并声明参数和输出路数。第五步是验证:随便选一只票,把同一段逻辑用内置公式再写一遍,两条线叠在一起看有没有肉眼可见的偏移;有偏移就先怀疑对齐问题,而不是怀疑算法。
2.3 长度、对齐和所有权:三个必须钉死的约定
长度问题看起来最琐碎,实际最容易翻车。nCount是宿主告诉你的有效根数,不是数组的真实容量,也绝对不等于图形上能看到的 K 线数量——图形支持缩放,宿主可能只传可视区间,也可能传全量再做裁剪。所有循环一律以nCount为界,一个sizeof(pClose)/sizeof(double)这种写法都不要出现。
对齐问题更隐蔽。序列函数都是“当前索引引用历史索引”,你的输出第 i 个元素必须严格对应输入的第 i 个元素,中间不能有任何跳过。常见的错误是循环写成从 1 开始、或者把i和i-1弄反,结果整条线左移一格,看着像指标有延迟,其实是下标错位。我一般会在调试期加一行断言:输出缓冲区里第一根非零值的位置,应该等于周期减一。
所有权要分清:pOut0由宿主分配,你只写不读、不释放;pParams和行情序列只读;你在函数内部malloc的内存,必须在本函数返回前释放,不能把指针存到静态变量留给下一次调用做缓存,因为宿主的生命周期你控制不了。曾经有人为了省一次计算,把中间数组缓存在静态指针里,第一个品种正常,切到第二个品种直接读到上一个品种的残留数据,指标全乱,排查了一整天。
提示:入口函数里不要调用任何会弹窗的 API。宿主在后台批量计算时会调你几百次,弹一次窗就卡一次,批量计算直接卡死。
3. 公式到 C 的翻译:序列函数怎么落地成指针运算
把公式改写成 C,难点不在语法,在思维切换。公式是序列思维——每一根 K 线上都有一整个数组;C 是指针思维——你要自己维护索引和窗口。只要把常见函数各自对应到一种窗口形态,翻译就变成填空。
3.1 ref、ma、hhv、cross 的四种窗口形态
ref(x, n)是最简单的形态,本质是x[i-n],代价是要处理前 n 根的边界。我一般不去特判,而是把整个序列当成有一个“前置缓冲区”:越界统一返回 NaN,然后在用到的地方判断,这样代码只有一处边界处理,不会散落到十几个地方。
ma(x, n)是累加滑窗,上一节已经写过,关键点是加减对称,浮点误差会累积,长序列上最好每隔几百根重算一次和。hhv/llv是单调队列滑窗,用双端队列维护,复杂度 O(n);图省事用 O(n·m) 的双层循环在几百根上也能跑,但品种一多就明显慢。
cross(a, b)是相邻两根上的比较,公式里写cross(ma5, ma10),C 里就是a[i-1] <= b[i-1] && a[i] > b[i],边界上i=0没有前一根,返回 0。这种函数最容易被“未来函数”污染:如果a或b里任何一处用到了i+1之后的数据,回测就会异常好看,实盘完全对不上。写 C 版本时,凡是用到i+k(k>0)的地方都要停下来问一句——这是不是偷看了未来。
3.2 参数区、输出区与多品种循环的写法
参数区的坑在于语义。配置里声明的参数是有序的,但用户可能在界面上填了小数、填了 0、填了负数。入口函数前几行就该做一轮清洗:周期类参数钳到[1, nCount],比例类参数钳到合理区间,输出路数不够就直接返回错误码,不要写一半就中断。
输出区要注意清零责任。有些宿主动手前不清零,缓冲区里是上一轮的残留;稳妥的做法是在入口函数开头把pOut0到pOutN全部写一遍,哪怕写 0,也不留任何未定义位置。多品种循环这块,宿主通常按品种串行调用,但同一进程里可能同时跑多个公式线程,所以入口函数内不要用可变全局状态,需要缓存就用栈上数组或每次重新分配。
3.3 公式函数与 C 实现对照
| 公式写法 | C 里的形态 | 复杂度 | 主要边界 |
|---|---|---|---|
ref(x,n) | x[i-n],越界返回 NaN | O(n) | 前 n 根无值 |
ma(x,n) | 累加滑窗,加减对称 | O(n) | 前 n-1 根无值 |
sum(x,n) | 同 ma,不做除法 | O(n) | 同 ma |
hhv(x,n) | 单调队列 / 双层扫描 | O(n) / O(n·m) | 窗口不足时取已有最大值 |
std(x,n) | 滑窗平方和,减去均值平方 | O(n) | 浮点抵消,长窗口要重算 |
cross(a,b) | 相邻两根比较 | O(n) | i=0 返回 0 |
count(cond,n) | 布尔序列滑窗累加 | O(n) | 前 n-1 根按已有根数计 |
这张表的价值在于:翻译时先查表定形态,再写代码,比对着公式一行行硬译快得多,也不容易漏边界。
4. 调试:宿主进程里怎么看到自己的输出
DLL 挂在别人的进程里,printf没地方显示,断点也经常挂不上(宿主可能做了保护或延迟加载)。这一段是我花时间最多的地方,两套手段配合用基本够。
4.1 日志与调试器两条通道
日志优先。不要用fopen每次打开关闭,性能扛不住几百次调用;用一次性打开的静态文件句柄,写的时候加锁,路径放在 DLL 同目录,文件名带上进程 ID,避免多个终端实例互相覆盖:
// dbg_log.c —— 只在调试版编译进去,发布版用宏整体关掉 #include <stdio.h> #include <stdarg.h> #include <windows.h> static FILE* g_fp = NULL; static CRITICAL_SECTION g_cs; void LogInit(const char* pszDir) { InitializeCriticalSection(&g_cs); char szPath[MAX_PATH] = {0}; wsprintfA(szPath, "%s\\ind_%lu.log", pszDir, GetCurrentProcessId()); g_fp = fopen(szPath, "w"); // 每次启动覆盖,避免日志无限增长 } void Logf(const char* pszFmt, ...) { if (g_fp == NULL) return; EnterCriticalSection(&g_cs); va_list ap; va_start(ap, pszFmt); vfprintf(g_fp, pszFmt, ap); va_end(ap); fputc('\n', g_fp); fflush(g_fp); // 崩溃前的最后一行必须落盘 LeaveCriticalSection(&g_cs); }关键点是fflush。进程崩掉时缓冲区里的日志全丢,而崩溃前那一行恰恰是最有价值的。日志内容建议固定三行:进入时的nCount和参数值、中间某个关键下标的计算结果、退出时的返回码。三行足够定位九成的对齐和边界问题。
第二条通道是调试输出。OutputDebugStringA配合调试器查看器,可以在不落盘的情况下实时看,适合高频调用场景。再进一步是挂调试器:把宿主启动起来,附加到进程,在 DLL 的导出函数上下断点。这一步失败率不低,常见原因是 DLL 被延迟加载或者调试符号对不上,那就退回日志法,别在这上面耗。
4.2 自己搭一个 harness,把宿主摘出去
真正高效的调试是不在宿主里调。写一个控制台程序,把宿主那一侧的调用完整模拟一遍:读一份导出的 CSV 行情,填结构体,调用CalcIndicator,把结果写回 CSV。这样断点、单步、内存检查全部可用,还能顺手做成回归测试。
// harness.cpp —— 独立复现宿主调用,输入 out.csv: date,open,high,low,close,vol #include "indicator_dll.h" #include <cstdio> #include <vector> int main() { std::vector<double> vClose; FILE* fp = fopen("in.csv", "r"); if (!fp) return 1; double dO, dH, dL, dC, dV; char szLine[256]; fgets(szLine, sizeof(szLine), fp); // 跳过表头 while (fscanf(fp, "%*[^,],%lf,%lf,%lf,%lf,%lf\n", &dO, &dH, &dL, &dC, &dV) == 5) vClose.push_back(dC); // 只取收盘价,够验证 MA fclose(fp); int n = (int)vClose.size(); std::vector<double> vOut(n, 0.0); double dParam[1] = { 20.0 }; // 与配置里的参数顺序一致 KL_CTX ctx = {0}; ctx.nCount = n; ctx.nParamCount = 1; ctx.pParams = dParam; ctx.pClose = vClose.data(); ctx.pOut0 = vOut.data(); int nRet = CalcIndicator(&ctx); printf("ret=%d, first valid idx=", nRet); for (int i = 0; i < n; ++i) { if (vOut[i] != 0.0) { printf("%d\n", i); break; } } return nRet; }这份 harness 里最值钱的是最后那个“首个非零位置”打印。周期设 20 时它必须是 19,一旦输出 18 或 20,就说明滑窗边界写错了,不用看图形就能判断。参数说明上,dParam的顺序必须和宿主配置里声明的参数顺序一致,错一个位置就是拿成交量当周期用,算出来的线看着还挺像,特别容易蒙混过关。
4.3 逐根比对:和内置公式对答案
最后一层验证是把数据导出成两份 CSV:一份是终端里内置公式的计算结果,一份是 harness 跑出来的结果,写个脚本逐根比,容差按指标类型给——均线类1e-6足够,涉及开方或者幂运算的放宽到1e-4。差异集中在头部几十根,八成是初始值处理不同;差异从中间某根突然开始,八成是遇到了除零或者缺失值;差异整体平移一格,就是下标对齐问题。这个比对脚本跑一次大概十秒钟,但能把绝大多数“看着差不多”的隐患提前暴露。
5. 避坑与排查:五条真实翻车记录
这一段是全篇最该先看的部分。下面五条都是我在不同项目里真实遇到过的,每条按“现象、原因、解决”写,看到类似现象可以直接对照。
5.1 五条高频翻车记录
第一条,加载成功但一算就崩,崩在宿主里,日志一行都没有。原因是调用约定不匹配——头文件里写__stdcall,实现里忘了写,编译器默认__cdecl,参数由调用方清栈,两边不一致,栈指针一路错下去,返回时直接跳到野地址。解决办法是头文件和实现共用同一份声明,别手抄;工程里打开“调用约定不匹配”的警告等级。
第二条,指标线整体左移一格,看图形像提前反应。原因是循环里把i-n写成i-n+1,或者边界判断用了i > n而不是i >= n-1。解决办法是加“首个有效位置”断言,跑一次 harness 就现原形。
第三条,切品种后第一个品种的数据串到第二个品种上,指标出现一段不连贯的假信号。原因是用了静态数组或静态指针做缓存,宿主换品种时不清你的状态。解决办法是把所有中间量放栈上或者每次重新分配,确实需要重缓存就用nCount和参数值做键,键不匹配就重算。
第四条,多品种并行时段错误,单品种永不崩。原因是入口函数里用了未加锁的全局变量,宿主起了多个计算线程。解决办法是入口函数内零全局可变状态;必须共享的只读配置,在加载时初始化一次,之后只读不改。
第五条,本机跑得好好的,换一台机器就加载失败。原因基本是位数或运行时库不匹配:32 位宿主加载 64 位 DLL 是静默失败,动态链接 CRT 在目标机缺版本也是静默失败。解决办法是编译前确认宿主位数,运行时库选静态链接,交付时把 DLL 依赖用依赖查看工具过一遍。
5.2 一套固定的排查顺序
遇到“公式加载不上”或者“算得不对”,我按这个顺序走,从不跳步。先确认加载层:DLL 位数、导出符号名、依赖库三项,用依赖查看工具一次看完。再确认调用层:入口函数有没有被调到,日志里第一行是否出现。然后确认数据层:nCount和参数值打印出来,和配置对一遍。最后才是算法层:用 harness 跑同一份数据,和终端结果比对。
这个顺序的价值在于从外往里收敛,每一层都能给出明确的“是/否”,不会出现“感觉是算法问题,改了三版还没好”的情况。翻车的往往是顺序颠倒——先怀疑算法,花半天重写公式,最后发现是位数不对。
注意:发布前把调试日志的写文件路径改成可配置,或者用编译开关整体关掉。日志文件在某些目录下会因为权限问题静默失败,表现是“什么都没写”,很容易被误判成函数没被调用。
6. 进阶:把一堆单指标 DLL 收成一个指标库
单个指标一个 DLL,跑到十几个指标就管不动了:版本混乱、加载慢、改一个参数要重编一堆。我后来的做法是收成一个库,对外仍按宿主能识别的入口暴露,但内部用一张注册表分发,多指标共用一份序列计算工具。
// indicator_lib.c —— 一个 DLL 承载多个指标,用 pszName 分发 typedef int (__stdcall *PFN_IMPL)(KL_CTX*); typedef struct { const char* pszName; // 与公式配置里填的名字严格一致 PFN_IMPL pfn; // 具体实现 int nOut; // 输出路数,用于校验配置 } IND_ENTRY; static int __stdcall ImplMa (KL_CTX* p) { /* 见 2.2 节 */ return 0; } static int __stdcall ImplAtr(KL_CTX* p) { /* 真实波幅实现 */ return 0; } static const IND_ENTRY g_Table[] = { { "MY_MA", ImplMa, 1 }, { "MY_ATR", ImplAtr, 1 }, }; // 第二个导出:让宿主(或你自己的自检脚本)查询支持哪些指标 __declspec(dllexport) int __stdcall GetIndCount(void) { return (int)(sizeof(g_Table) / sizeof(g_Table[0])); } __declspec(dllexport) const char* __stdcall GetIndName(int nIndex) { if (nIndex < 0 || nIndex >= GetIndCount()) return 0; return g_Table[nIndex].pszName; }分发的关键是名字匹配要严格:大小写、下划线、前后空格,任何一处不一致都是“找不到指标”。我一般在加载配置时先把名字全部规整成大写再去查表,查不到就直接返回错误码并在日志里打出实际传入的字符串,这比让用户看“加载失败”有用得多。
注册表这套结构还顺手解决了版本问题。加一个导出函数返回库的版本号和编译时间,加载的时候打一行日志,线上出问题第一件事就是确认跑的到底是哪一版。验证方法也简单:写一个小脚本,遍历GetIndName返回的所有名字,逐个用同一份 CSV 调用,和上一版的输出做逐根比对,容差之外任何一处变化都要能解释。
顺手提一个我常用的技巧:在库内部统一一个Series小工具集,把Ref/Ma/Hhv/Cross都实现成接收const double*加长度的函数,指标实现只写业务逻辑,边界全在工具集里处理一次。这样新增一个指标从半天缩到十几分钟,而且边界处理不会各写各的。
从那以后我每次交付这类库,都强制走一遍“独立 harness 全指标回归 + 首个有效位置断言 + 位数与依赖检查”这三步,哪怕只是改了一个参数默认值也不例外。栽过的跟头都在这一步被拦下来过。希望帮到你。
本文还有配套的精品资源,点击获取