尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

VC++6.0五档行情解析:Socket通信、二进制报文与界面刷新实践

VC++6.0五档行情解析:Socket通信、二进制报文与界面刷新实践 简介面向股票软件开发者与MFC学习者的VC 6.0五档行情示例工程演示如何通过WinInet/HTTP与FTP方式获取沪深股市实时买卖盘口并在MFC列表控件中动态展示买一至卖三的六档报价变化。资源共18个文件其中5个头文件.h与4个源文件.cpp构成核心代码配合工程文件.dsw/.dsp、界面资源.rc/.ico及说明文档压缩包仅33KB结构紧凑便于快速定位网络请求、数据解析、界面显示与后台刷新等模块。工程覆盖网络会话初始化、HTTP请求发送、XML/JSON格式解析、CListCtrl控件绑定、CWinThread多线程刷新、本地缓存策略以及异常恢复机制并附有ReadMe.txt说明可在VC 6.0中直接打开编译。对于希望学习早期MFC网络编程、理解五档盘口数据结构或进行股票行情客户端二次开发的读者这份示例提供了可运行的完整参考。已有578人学习下载。1. 五档行情接进来VC 6.0 的旧工程到底还能解决什么做行情工具的程序员手里多半收到过这样一份历史包袱一个 .rar 解压出来是 VC 6.0 的工程目标是在老式 Windows 窗口里显示沪深股市的五档行情。五档就是买一到买五、卖一到卖五它比单笔最新价更能看出瞬时买卖力量。用 VC 6.0 来做这事听起来过时但网络 Socket、二进制报文解析、界面刷新这三件事到今天依然是行情软件的核心路径。这篇笔记会把完整链路拆开行情源怎么选、登录心跳怎么做、五档怎么解析、窗口怎么刷新以及那些不调好就翻车的细节。适合正在维护同类旧工程或者想把老代码改成还能跑起来的人。2. 行情源选型与登录握手先让服务器认可你的客户端2.1 行情源的三种路线前置机私有协议、动态库、HTTP 轮询标题里写的是“获取沪深股市实时行情数据”这里的“获取”落在哪一条数据链路上直接决定工程结构。常见做法有三种。第一种是券商或行情商提供的前置机私有协议。你拿到的是一份协议文档、一个IP地址和端口程序用 TCP 连上去发登录包然后持续收行情快照。这条路最接近旧工程的原始形态数据密度高五档字段齐全延迟低适合在 VC 6.0 里用阻塞 Socket 或 select 模型自己控制。第二种是动态库接口比如部分行情商的 DLL。开发快但 VC 6.0 调用时要注意回调函数所在线程有些 SDK 要求在主线程初始化在子线程接收数据时容易触发断言。如果只是把别人封装好的接口搬进对话框问题不大一旦你需要自己缓存五档、自己重连DLL 的线程模型反而成为黑匣子。第三种是 HTTP 轮询这是后来才流行的方式。对五档行情来说轮询间隔很难压到一秒以内还要处理 JSON 字符串解析VC 6.0 里没有现成 JSON 库要么引入第三方代码要么自己写简单解析器。实时性不够且老机器上频繁 HTTP 请求会造成不必要的 CPU 开销。所以我的判断很明确这个标题对应的核心实现应该是 TCP 私有协议而不是 HTTP。既然选定 TCP那就有两件事躲不开登录握手和心跳保活。2.2 最小可运行的 Socket 连接与登录包VC 6.0 的 MFC 工程里加网络功能先做三件事包含 winsock2.h、链接 ws2_32.lib、调用 WSAStartup。顺序不对会得到一堆 C2079 之类的编译错误后面会单独说。先看一个最小连接函数#include winsock2.h #include stdio.h #pragma comment(lib, ws2_32.lib) BOOL ConnectServer(SOCKET sock, const char* ip, unsigned short port) { WSADATA wsa; if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) return FALSE; sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock INVALID_SOCKET) return FALSE; SOCKADDR_IN addr; addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.S_un.S_addr inet_addr(ip); if (connect(sock, (SOCKADDR*)addr, sizeof(addr)) SOCKET_ERROR) return FALSE; return TRUE; }这段代码里值得注意的只有两个地方。第一WSAStartup(MAKEWORD(2,2), wsa)的版本号要和 ws2_32.lib 匹配VC 6.0 默认支持到 2.2不要写MAKEWORD(1,1)否则下载传输时某些特性不可用。第二inet_addr只处理点分十进制 IP如果你在配置里写的是域名需要先gethostbyname再取地址旧代码里这个坑出现频率极高。连接只是第一步行情服务器通常要求先登录。登录包的格式各家用各家的但骨架一致包头固定两字节、命令字一字节、用户名字段若干字节、密码字段若干字节最后跟校验或保留字段。演示一种常见的打包方式#pragma pack(push, 1) typedef struct _LOGIN_PACKET { unsigned short head; // 固定值比如 0x00F1 unsigned char cmd; // 0x01 表示登录 char user[16]; char pass[16]; unsigned char reserve[8]; } LOGIN_PACKET; #pragma pack(pop) LOGIN_PACKET pkg; memset(pkg, 0, sizeof(pkg)); pkg.head htons(0x00F1); pkg.cmd 0x01; strncpy(pkg.user, szUser, 16); strncpy(pkg.pass, szPass, 16); send(sock, (const char*)pkg, sizeof(pkg), 0);注意#pragma pack(push, 1)。VC 6.0 默认按 4 字节对齐如果结构体里unsigned short后面紧跟unsigned char编译器会塞入填充字节你按文档构造的字节流就会多出空洞服务器直接判定非法包。这是最常见登录失败原因没有之一。登录包发完之后不要立刻去 recv先用select等一段时间检查服务器是否回了确认包。有些协议的回包很短可能就两个字节你要把它完整读出来确认登录成功再进入行情接收循环。很多行情商的前置机为安全考虑登录失败的连接会被立即断开。2.3 心跳与超时参数叫“实时”靠的是能撑住不掉的连接登录成功后最容易遇到的问题就是“每隔几分钟被踢下线”。原因是行情服务器会监控空闲连接一段时间没有任何数据交互就断开。你需要按协议要求在空闲时发送心跳包。心跳周期一般由协议文档给出常见值是 45 到 90 秒。实现上不要单独建一个定时器线程最简单的是在接收循环里记录最后一次发送时间超过阈值就补一个心跳DWORD lastHeart GetTickCount(); char heartCmd 0x0F; // 各家协议不同以文档为准这里只是示意 while (!m_bExit) { fd_set readSet; FD_ZERO(readSet); FD_SET(sock, readSet); timeval tv; tv.tv_sec 3; tv.tv_usec 0; int ret select(0, readSet, NULL, NULL, tv); if (ret 0) { // 收到数据先读进来再解析 ReceiveQuoteByPacket(sock); } if (GetTickCount() - lastHeart 50000) // 国内行情服务器常见60秒要求 { send(sock, heartCmd, 1, 0); lastHeart GetTickCount(); } }这里 select 的超时设置很关键。设得太短select 频繁空转CPU 占用高设得太长心跳会变得不及时。一般经验是 2 到 5 秒。另外GetTickCount返回的是毫秒超过 49 天会回绕但行情连接一般不会连续挂那么久旧代码里能接受。如果你追求严谨换成GetTickCount64在 VC 6.0 里又不太好用折中方案是用连接时间做差值。这段逻辑解决的是“连接能不能稳住”的问题。连接稳不住后面解析写得再好界面也收不到行情。3. 五档盘口解析从二进制快照到买卖十档结构体3.1 行情快照里到底有什么时间、最新价、成交量与五档五档行情的数据载体是行情快照。一份快照包含证券代码、最新价、成交量、成交额、开盘价、最高价、最低价、昨收以及买一到买五、卖一到卖五的委托价和委托量。跨市场看沪深两所的标准字段高度相似但字节顺序和价格类型会有差异。解析前先把协议文档摊开确认三件事价格是多少位的整数单位是分还是厘字段顺序是否和文档一致有没有对齐填充。我习惯先把结构体定义出来再用静态样例数据跑一遍内存布局#pragma pack(push, 1) typedef struct _QUOTE_SNAPSHOT { unsigned char code[6]; // 证券代码按字符串处理 unsigned int time; // 成交时间 hhmmss unsigned int last; // 最新价放大100倍25301 253.01 int volume; // 成交量单位通常是手 int amount; // 成交额单位是元 int open; // 今日开盘价 int high; // 今日最高价 int low; // 今日最低价 int preClose; // 昨收价 struct { int price; // 委托价放大100倍 int volume; // 委托量 } bid[5]; // 买一至买五 struct { int price; int volume; } ask[5]; // 卖一至卖五 } QUOTE_SNAPSHOT; #pragma pack(pop)注意我在结构体里全部用unsigned int和int表示价格而不是 float。这是经验之谈国内行情协议普遍把价格拆成整数传输比如 12.34 元存成 1234到客户端再除以 100 显示。直接用 float 解析二进制的做法在老协议里经常会遇到字节序和精度问题。#pragma pack(push, 1)这里又出现一次因为结构体里 char 和 int 混排VC 6.0 默认对齐会往结构体里插入空闲字节导致sizeof(QUOTE_SNAPSHOT)比协议实际长度大不少。解析时按错误长度切包后面的五档数据全部错位。3.2 处理粘包半包逐层切出完整快照TCP 是流式协议recv 拿回来的数据没有边界。你请求 120 字节可能一次返回 30 字节也可能是 300 字节塞进两条快照。直接拿 recv 返回值做结构体强转十条里有五条是错数据。正确做法是准备一个环形缓冲。先把数据收进来按包头里的包长度字段切出一包完整快照处理完再把剩余数据往前挪等待下一条#define RING_BUF_SIZE (64 * 1024) static char g_ring[RING_BUF_SIZE]; static int g_len 0; BOOL ReceiveQuoteByPacket(SOCKET sock) { int n recv(sock, g_ring g_len, RING_BUF_SIZE - g_len, 0); if (n 0) return FALSE; g_len n; int pos 0; while (g_len - pos sizeof(QUOTE_HEAD)) { QUOTE_HEAD* pHead (QUOTE_HEAD*)(g_ring pos); // 长度字段校验防止脏数据 if (pHead-bodyLen 0 || pHead-bodyLen RING_BUF_SIZE) { g_len 0; return FALSE; } if (g_len - pos - sizeof(QUOTE_HEAD) pHead-bodyLen) break; // 包还没收完整等下一次 recv QUOTE_SNAPSHOT snap; if (ParseQuote(g_ring pos sizeof(QUOTE_HEAD), pHead-bodyLen, snap)) ProcessSnapshot(snap); pos sizeof(QUOTE_HEAD) pHead-bodyLen; } if (pos 0) { memmove(g_ring, g_ring pos, g_len - pos); g_len - pos; } return TRUE; }这里QUOTE_HEAD通常携带命令字和包体长度我用的定义是这样#pragma pack(push, 1) typedef struct _QUOTE_HEAD { unsigned short cmd; // 命令字快照行情一般是某个固定值 unsigned short bodyLen; // 包体长度 } QUOTE_HEAD; #pragma pack(pop)拆包时最常犯的错是忽略了“包体可能还没收全”这半句话。如果你的循环里发现长度不够就直接返回剩下的半包数据会留在缓冲区里。但要做一件事把pos归零前更新g_len也就是把已处理的部分从头部清掉。上面代码通过最后的memmove做到了。这个拷贝在 64KB 缓冲区里成本很低只要不是每笔行情都触发大块内存搬运VC 6.0 完全扛得住。3.3 字段换算和字节对齐价格翻一百倍的元凶解析最迷惑的问题是显示出来的价格不对。昨天收盘还是 10.50你解析出来变成 1050 或者 105000。原因通常出在两个地方。第一个是整数放大倍率。协议文档写“价格×100 存储”你按普通整数输出就是 1050 而不是 10.50。纠正方式很简单float DisplayPrice(int rawPrice) { return rawPrice / 100.0f; // 故意用100.0f整数除法会丢掉两位小数 }第二个是字节序。沪深交易所的行情传输基本采用 little endianIntel 和 Windows 都是这个字节序所以直接 memcpy 通常没毛病。但有些行情商前置机做过跨平台转发会把数据转成网络序 big endian这时价格字段就会顺序颠倒1034读出来是 0x34 0x10 组合成的错误大数。遇到这种需要在解析时手动转换unsigned int ntohu(unsigned int value) { unsigned char* p (unsigned char*)value; return ((unsigned int)p[0]) | ((unsigned int)p[1] 8) | ((unsigned int)p[2] 16) | ((unsigned int)p[3] 24); }还有一个隐蔽坑是代码字段。code[6]是字符串比如600519但协议可能不按0结尾。你直接 printf 会一直输出到内存里下一个空字节。处理时要手动复制到局部变量并补上终止符char szCode[16]; memcpy(szCode, snap.code, 6); szCode[6] \0;这些细节凑在一起才是“解析五档”完整动作。光会连服务器不发消息只能叫打通链路能把五档稳定整理出来才能谈界面上显示。4. VC 6.0 界面显示五档行情线程模型与刷新策略4.1 直接把 recv 放 UI 线程为什么不行拿到行情数据后最自然的想法是在OnTimer里调 recv收完数据直接往 CListCtrl 里塞。这个方案在行情慢的时候能跑但行情源一旦快起来recv 的阻塞会卡住界面消息循环。最典型的表现是行情正常跳动但窗口根本拖不动点按钮也没反应。原因在于 MFC 的窗口重绘、按钮消息、输入响应都依赖 UI 线程的消息泵。你在线程里写while循环 recv消息泵被堵死整个窗口变成假死状态。所以第一步是把网络接收扔到独立工作线程里UI 线程只负责贴数据。4.2 工作线程 PostMessage 最小框架VC 6.0 里开线程最常用的接口是AfxBeginThread比_beginthreadex更适合配合 MFC 类使用。实际接收回调里只做一件事把快照数据暂存到全局表然后 PostMessage 通知界面线程取数据。自定义消息的映射是这样写的#define WM_QUOTE_UPDATE (WM_APP 101) class CQuoteDlg : public CDialog { public: afx_msg LRESULT OnQuoteUpdate(WPARAM wParam, LPARAM lParam); BOOL m_bExit; ... }; BEGIN_MESSAGE_MAP(CQuoteDlg, CDialog) ON_MESSAGE(WM_QUOTE_UPDATE, OnQuoteUpdate) END_MESSAGE_MAP()在OnInitDialog里启动线程BOOL CQuoteDlg::OnInitDialog() { CDialog::OnInitDialog(); m_bExit FALSE; AfxBeginThread((AFX_THREADPROC)QuoteThreadProc, this); return TRUE; }线程函数这样写UINT QuoteThreadProc(LPVOID pParam) { CQuoteDlg* pDlg (CQuoteDlg*)pParam; while (!pDlg-m_bExit) { if (ReceiveQuoteByPacket(g_sock)) { // 不要直接操作 CListCtrl只发消息 pDlg-PostMessage(WM_QUOTE_UPDATE, 0, 0); } Sleep(20); } return 0; }线程里不直接改界面原因是 MFC 控件对象创建在 UI 线程工作线程跨线程访问有概率触发断言比如从非 UI 线程调用 CListCtrl 的SetItemText。PostMessage是非阻塞的接收线程发完消息立刻回来继续 recv不会因为界面的更新速度慢而丢行情。真正解析和保存快照的函数ProcessSnapshot里我会用一个全局映射表来临时保留最近快照#include map static std::mapunsigned long, QUOTE_SNAPSHOT g_quotes; static CRITICAL_SECTION g_cs; void ProcessSnapshot(const QUOTE_SNAPSHOT* snap) { unsigned long key atol((const char*)snap-code); EnterCriticalSection(g_cs); g_quotes[key] *snap; LeaveCriticalSection(g_cs); }然后在OnQuoteUpdate里读取这份全局快照来更新控件。临界区很小只在复制结构体时用不会拖慢界面线程。4.3 CListCtrl 的刷新参数只更新变动的单元格界面显示五档行情时最稳妥的控件是 CListCtrl 的 Report 模式。列依次设置为代码、最新价、成交量、买一价、买一量、卖一价、卖一量等等。不要贪多两行五档全塞进去会变得密不透风。刷新策略上血红教训是不要整行删除再重插。每次DeleteAllItems再InsertItemCListCtrl 会触发大量重绘窗口闪烁到几乎看不清数字。正确做法是定位到行用SetItemText只改变化了的列int row FindRowByCode(szCode); if (row 0) { row m_List.InsertItem(0, szCode); } m_List.SetRedraw(FALSE); m_List.SetItemText(row, 1, FormatPrice(snap.last)); m_List.SetItemText(row, 2, FormatVolume(snap.volume)); m_List.SetItemText(row, 3, FormatPrice(snap.bid[0].price)); m_List.SetItemText(row, 4, FormatVolume(snap.bid[0].volume)); m_List.SetItemText(row, 5, FormatPrice(snap.ask[0].price)); m_List.SetItemText(row, 6, FormatVolume(snap.ask[0].volume)); m_List.SetRedraw(TRUE);SetRedraw(FALSE)和SetRedraw(TRUE)包住批量修改能明显减少闪烁。如果你还要按涨跌变颜色就在SetItemText前后判断 now 和 preClose 的大小m_List.SetItemText(row, 1, sPrice); m_List.SetItemColor(...) // 自定义扩展或交给 DrawItemVC 6.0 的原版 CListCtrl 不带SetItemColor需要子类处理NM_CUSTOMDRAW通知。想省事的初版可以暂时不做颜色等链路稳定了再加。刷新频率上也控制一下。行情源每秒可能推多笔快照如果每笔都刷界面会白白吃掉 CPU。在OnQuoteUpdate里加一个时间闸门只保证每秒刷新 4 到 5 次static DWORD lastRefresh 0; if (GetTickCount() - lastRefresh 200) return; lastRefresh GetTickCount();这样刷屏节奏稳定人眼看起来也不卡。5. 五档行情程序调试避坑断开、粘包、乱码与假死排查清单5.1 登录后立刻被踢错误码 10054现象是 connect 成功登录包发出去几秒后 recv 返回 0错误码 10054 表示远端强迫关闭连接。原因是登录包格式不对或命令字错误。服务器不认识这个包按非法连接直接断开。另一个常见原因是登录包发完没等确认就又发了行情订阅命令服务器认为状态不对直接切断。解决方法是先抓包把发出去的字节流和协议文档逐字节比对。不要靠猜。先用一个简单的日志函数把发送和接收的十六进制内容打出来确认登录包的head、cmd、用户名字段是否和文档一致。特别留意htons到底用了没有如果协议要求 big endian 而你直接用 0x00F1发送后字节顺序就是 F1 00服务器解析成别的命令字结果必然是踢人。5.2 收到的快照老是缺一半第二条然后数据错乱现象是同一个股票的价格和五档量一会正常一会异常而且异常规律随网络延时变化。原因是 TCP 粘包和半包没有处理。recv 返回 N 字节不代表这就是一条完整快照它可能是一条快照的前半段也可能是两条快照粘在一起。你直接把指针强转成QUOTE_SNAPSHOT解析后半个包自然读出来是脏数据。解决办法用第 3 章的环形缓冲拆包逻辑。要点只有一个严格按QUOTE_HEAD里的包体长度切长度不够就留着长度判断必须是在循环里做完一包再切下一包。另外注意mysql 这类用 memmove 挪数据的方案在 64KB 缓冲里完全没问题别为了“性能优化”去写复杂的链表环形队列在 VC 6.0 上往往不如简单直接的 memmove 可靠。5.3 价格显示成 842150451 或某几个奇怪的重复数字现象是最新价和五档价格显示成一串明显不合常理的整数比如 842150451。原因是 0xCD 填充字节被当成 int 解析了。你定义的结构体宽度大于实际数据包或者结构体里有未初始化的内存。更常见的是没有#pragma pack(1)导致 VC 6.0 在结构体字段之间塞了 3 字节对齐填充解析时把空字节当成数据的一部分。解决时先打印sizeof(QUOTE_SNAPSHOT)和sizeof(QUOTE_HEAD)对比协议文档给的长度。如果长度不一致优先检查是否少了#pragma pack(1)。然后用一段固定样例二进制数据做单测逐步比对字段偏移不要等跑真实行情时才靠眼力看对错。5.4 窗口拖动时行情卡住松开后恢复现象是连接本身没断行情数据在后台也有但窗口拖不动按钮无响应等鼠标松开后界面又补刷。原因是 recv 放在 UI 线程的消息循环里比如在OnTimer中直接调用 recv。recv 等待数据时是阻塞的单次超时可能是秒级消息泵被卡住整个窗口失去响应。解决方法是把网络接收全部移进工作线程。UI 线程不碰 recv也不在OnTimer里做任何耗时操作。OnTimer最多用来刷新当前界面不要在里面干等网络。5.5 连接正常但十分钟后服务器主动断开重连后过一会又断现象是行情收得好好的到某个固定时间点突然 recv 返回 0没有错误码或错误码 10054。原因是空闲心跳没有发。很多行情前置机对超过 60 到 90 秒没有任何交互的连接直接回收。如果你只发了登录包收行情收到爽就没考虑“空闲”这个概念服务器不会为你放水。解决方式是在接收循环里记录上次发送事件的时刻距离上次发送超过阈值就发送心跳。心跳包内容不一定是 0x0F以协议文档为准。发送时要加一个日志方便确认行情源确实收到并响应了。我曾经拿一份没标注心跳的老协议硬调最后抓包发现心跳命令字其实是另一个值改过来就再没掉过线。6. 把“能显示”改成“拿得出手”回放验证、断线重连与盘口自检程序跑到这一步已经能从服务器收快照在 CListCtrl 里看到五档盘口。可“能显示”离“能交付”还差两步。第一步是验证。连真实行情源之前多数工程师会先做回放验证把一段原始行情报文存成文件让程序按时间戳一条一条喂进去。这样能在稳定环境下检查解析逻辑不用依赖网络。实现上可以在工程里留一个编译开关#ifdef SIMULATE_MODE FILE* fp fopen(quote_trace.bin, rb); while (fread(head, sizeof(QUOTE_HEAD), 1, fp) 0) { char* buf new char[head.bodyLen]; fread(buf, head.bodyLen, 1, fp); ParseQuote(buf, head.bodyLen); delete[] buf; Sleep(50); } #else // 连真实行情源 #endif回放文件怎么来找一个行情源连接后打开抓包或自己的收发日志把 recv 到的原始数据原样落盘就成了最可靠的验证集。这里我习惯从第一天就给收发日志加上十六进制输出方便回头重放。不要嫌日志影响性能连接到行情源后再决定要不要关掉。第二步是断线重连。行情源不会永远可靠中间网络闪断是常态。重连时不要用死循环猛连退避策略是常见做法第一次失败等 1 秒第二次等 3 秒第三次等 8 秒封顶 30 秒。每次重连前先closesocket然后重新WSAStartup、connect、发登录包再重新订阅行情。重连时把环形缓冲区清零避免新老数据拼接错位。第三步是盘口自检。解析出来的五档数据要经历一致性校验才有可信度。最基本的规则是“卖一价必须高于买一价”如果买一价格大于等于卖一价格说明当前数据源有问题或者你的字段顺序写反了。委托量不能为负价格不能出现0xCDCDCDCD这类未初始化标志。自检不过的数据宁可丢弃也不要显示出来误导人。我早年调行情程序连续三天卡在价格不对上最后承认是字节对齐的问题。从那以后每写一个结构体都会先打印 sizeof再对一份样例二进制做逐字段比对不再靠肉眼扫几十个价格数字来猜哪里错。你在复刻这套旧工程时先把回放跑通、日志打全、盘口自检加上再考虑界面颜色和布局。稳定链路有了其他东西都只是时间问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表