简介:面向WinCE嵌入式开发者的TCP通信样例工程,基于Visual Studio 2005环境搭建,适合需要掌握Winsock网络编程的初中级开发者对照学习。工程围绕socket创建、connect连接、send/recv收发及closesocket清理等核心流程给出可调试的客户端与服务器示例,并涉及ws2_32.lib链接依赖与_WIN32_WCE预处理器配置,便于理解WinCE目标下的项目设置与异常处理思路。压缩包共29个文件、约4.45MB,以h头文件、cpp源文件、vcproj工程文件、rc资源文件为主,另含exe、pdb等编译产物,结构上可区分PCTCP与TCP两组样例,方便按客户端/服务端模块检索。已有315人学习,读者可通过编译调试样例,梳理TCP连接建立、数据收发循环及错误清理的关键调用,并将其迁移到实际嵌入式网络通信项目中。
1. 存量设备上的通信改造:WinCE 实现 TCP 通信到底在解决什么问题
去年接手一台工业手持终端的维护,系统是 WinCE 5.0,屏幕 240×320 电阻屏,内存 64MB。原来的数据回传走串口,线拉到七八米就开始丢字节,现场布线还得单独走一根 RS232,维护起来很麻烦。后来改成网口加无线,上位机开一个 TCP Server,手持端做 Client,设备不动、系统不升,只在应用层加一层 Winsock 通信。这类改造在存量设备上特别多,所以我把这次拆解整理成可复用的项目笔记。WinCE 实现 TCP 通信真正的难点不在 socket 那几个 API,而在平台镜像裁剪、内存吃紧、没有现成的标准库可用、调试手段也少——照着 PC 上那一套写,十个有八个会卡住。
2. 能力盘点与工程骨架:WinCE 下 Winsock 方案怎么选、目录怎么分
动手写代码之前,值得先花半天把平台能提供什么弄清楚。WinCE 的内核镜像按组件裁剪,网络这部分属于可选项,如果平台目录里没打上 TCP/IP 和 Winsock,WSAStartup会直接返回一个错误码,你写再多业务代码也没用。所以整个项目的工程骨架,第一件事就是把网络层和界面层彻底分开,让网络模块能脱离界面单独启动、单独测试。
2.1 四条可选路线的取舍
在嵌入式设备上做 TCP,能落地的方案其实就那么几种,选错了后面改起来很麻烦。
| 方案 | 依赖组件 | 内存开销 | 适用场景 | 主要问题 |
|---|---|---|---|---|
| 原生 Winsock(ws2.dll) | TCP/IP + Winsock 组件 | 最低 | 绝大多数 C/C++ 项目 | 全裸 C 接口,超时、重连都要自己写 |
| MFC 的 CAsyncSocket | MFC + 消息泵 | 中 | 已经有 MFC 框架的界面程序 | 依赖窗口消息,后台线程里用起来别扭 |
| .NET Compact Framework 的 Socket | 需要装 CF 运行时 | 高 | 快速做业务界面 | 冷启动慢,老机型内存扛不住 |
| 第三方协议库 | 视具体库而定 | 视库而定 | 已有明确协议栈需求 | 版本和平台适配是黑匣子 |
我最后选的是原生 Winsock。理由很实际:设备内存只有 64MB,CF 运行时一装就吃掉一大块;而且现场需要长时间运行,MFC 那层消息泵在后台线程里容易出问题。原生接口虽然啰嗦,但每一行在干什么都看得见,出问题好排查。
版本上要注意一点,WSAStartup请求的版本号不代表设备支持。WinCE 4.x 及更早一些的镜像只带 Winsock 1.1,请求 2.2 会失败。常见做法是先请求 2.2,失败再降级到 1.1,成功之后把实际协商到的版本记进日志,方便现场定位。
// net/NetInit.cpp —— 网络模块统一初始化,带引用计数 #include <winsock2.h> #pragma comment(lib, "ws2_32.lib") // 大多数 CE SDK 里就是 ws2.lib 的别名 static LONG g_refCount = 0; // 引用计数,避免多处调用 WSAStartup static WSADATA g_wsaData; BOOL NetInit() { if (InterlockedIncrement(&g_refCount) > 1) return TRUE; // 已经初始化过,直接复用 // 先按 Winsock 2.2 请求,老镜像上会失败 int ret = WSAStartup(MAKEWORD(2, 2), &g_wsaData); if (ret != 0) { // 降级到 1.1,这一步在 CE4.x 的镜像上是常态 ret = WSAStartup(MAKEWORD(1, 1), &g_wsaData); if (ret != 0) { InterlockedDecrement(&g_refCount); return FALSE; // 上层要弹提示:镜像缺网络组件 } } // 记录实际版本,现场排查时很有用 OutputDebugStringA(g_wsaData.szDescription); return TRUE; } void NetUninit() { if (InterlockedDecrement(&g_refCount) == 0) WSACleanup(); }MAKEWORD(2, 2)的高字节是主版本、低字节是副版本,这一点和 PC 上完全一样。真正容易踩的是引用计数:界面初始化时调一次、业务模块启动时又调一次,如果不计数,某一方退出时调了WSACleanup,另一方的 socket 会集体失效,现象是运行十分钟后所有连接同时断掉,非常难查。用InterlockedIncrement而不用普通++,是因为初始化可能发生在两个线程里。
2.2 工程目录:网络、协议、界面三层分开
项目结构我按职责切了三块,界面只负责显示和触发,不碰 socket:
TcpClientApp/ ├── app/ 界面层 │ ├── MainDlg.cpp 只有一个「连接/断开」按钮和日志框 │ ├── MainDlg.h │ └── TcpClientApp.rc ├── net/ 传输层,可独立编译成静态库 │ ├── NetInit.cpp/.h WSAStartup 引用计数 │ ├── TcpClient.cpp/.h connect / recv / send / 重连状态机 │ └── SocketUtil.cpp/.h 超时连接、SendAll 这类工具函数 ├── proto/ 协议层,不依赖 socket │ ├── Protocol.h 帧头常量、命令字枚举 │ ├── FrameParser.cpp/.h 粘包拆包 │ ├── Crc16.cpp/.h 校验 │ └── Codec.cpp/.h 业务字段编解码 ├── common/ │ ├── Logger.cpp/.h 环形日志,定期落文件 │ └── RingBuffer.h 定长环形缓冲,替代 STL 容器 └── TcpClientApp.vcproj分层的关键收益在联调阶段特别明显:proto/目录不包含任何 socket 头文件,可以直接拿到 PC 上做一个纯 C++ 的控制台测试程序,把抓到的原始字节喂进去验证拆包逻辑,完全不用连设备。如果协议解析和recv搅在一起,每次验证都要烧一次设备镜像,一个来回十几分钟,改三轮就受不了了。
common/RingBuffer.h的存在是因为 CE 上的 STL 版本比较老,std::string和std::vector反复扩容在内存碎片化的设备上并不友好。我一般把收发缓冲做成定长的环形队列,大小按最大帧长的两倍开,够用又不会因为频繁申请释放把堆搞碎。
2.3 编译与部署的基本动作
工程用配套的 Visual Studio(2005 或 2008 都行)建一个 Smart Device 的 MFC 或 Win32 项目,平台选设备对应的 SDK。几个必须确认的点:
- 链接器里加上
ws2.lib(部分 SDK 里叫winsock.lib),否则WSAStartup会报链接错误; - 目标平台要选对,选成 Pocket PC 的 SDK 生成的 exe 在工控设备上跑不起来;
- 部署时用「复制文件到设备」或者做成 CAB 安装包,调试阶段用 ActiveSync 的共享目录最快;
- 如果设备支持
CeGetSystemInfo,启动时打印一下可用内存,方便判断是不是内存不足导致的崩溃。
调通第一个connect之前,建议先用上位机的网络调试工具起一个 TCP Server,手动发几个字节,确认设备能收到——这一步能把「网络不通」和「代码写错」两类问题彻底分开,省掉大量瞎猜。
3. 主链路拆解:一次 connect 到 recv 的完整线程模型
传输层的核心就三件事:连得上、收得全、断得掉。这三件事在 PC 上都有现成库帮你兜底,在 CE 上得自己写。下面这套模型是我在几个项目里反复用过的,结构简单,出问题也容易定位。
3.1 线程模型:一个收、一个发、一个调度
最省事也最容易出错的写法是在界面的按钮响应里直接connect然后recv。recv是阻塞的,没数据就一直挂着,界面当场假死,用户以为设备死机了。正确的做法是把收发拆到独立线程:
- 收线程:阻塞在
recv上,收到数据就丢给FrameParser,不在这里做业务处理; - 发线程:等一个事件对象,从发送队列里取帧,调
SendAll发出去; - 调度线程(心跳/重连):每秒跑一次状态机,检查心跳超时和重连时机。
为什么不用非阻塞 + 轮询?CE 上select是支持的,但轮询会持续占 CPU,手持设备是电池供电,功耗敏感。阻塞模型让线程真正睡在系统调用里,反而更省电。代价是需要一套干净的退出机制,下面会讲。
3.2 connect 超时:非阻塞加 select
connect在阻塞模式下默认超时由协议栈决定,实测在信号差的现场能挂到几十秒,这期间界面完全无响应。所以连接阶段必须切成非阻塞:
// net/SocketUtil.cpp —— 带超时的连接,超时后返回 INVALID_SOCKET SOCKET ConnectWithTimeout(const char* host, unsigned short port, int timeoutMs) { SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s == INVALID_SOCKET) return INVALID_SOCKET; SOCKADDR_IN addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = inet_addr(host); if (addr.sin_addr.s_addr == INADDR_NONE) { // 传的是域名,需要镜像里带 DNS 组件,没有的话这里必然失败 hostent* he = gethostbyname(host); if (!he) { closesocket(s); return INVALID_SOCKET; } memcpy(&addr.sin_addr, he->h_addr_list[0], he->h_length); } unsigned long nonBlock = 1; ioctlsocket(s, FIONBIO, &nonBlock); // 切成非阻塞 int ret = connect(s, (SOCKADDR*)&addr, sizeof(addr)); if (ret == SOCKET_ERROR && WSAGetLastError() != WSAEWOULDBLOCK) { closesocket(s); return INVALID_SOCKET; // 地址不可达之类,立刻失败 } fd_set writeSet, errSet; FD_ZERO(&writeSet); FD_SET(s, &writeSet); FD_ZERO(&errSet); FD_SET(s, &errSet); timeval tv; tv.tv_sec = timeoutMs / 1000; tv.tv_usec = (timeoutMs % 1000) * 1000; // WinCE 上 select 的第一个参数被忽略,传 0 即可 ret = select(0, NULL, &writeSet, &errSet, &tv); if (ret <= 0) { closesocket(s); return INVALID_SOCKET; } // 0 是超时 if (FD_ISSET(s, &errSet)) { closesocket(s); return INVALID_SOCKET; } nonBlock = 0; ioctlsocket(s, FIONBIO, &nonBlock); // 连上了,切回阻塞给收发线程用 return s; }几个参数值得说明。timeoutMs我一般给 3000,无线信号差的现场可以放宽到 5000,再长就没意义了——用户等不了那么久。select的nfds参数在 WinCE 上是被忽略的,PC 上必须填s + 1,代码如果两边共用要写条件编译。FD_ISSET检查errSet这一步不能省,连接被拒时writeSet同样会被置位,只看writeSet会把失败当成成功,后面send就一路报错。
3.3 收线程与安全退出
收线程的骨架很短,但退出条件要想清楚:
// net/TcpClient.cpp —— 收线程主体 DWORD WINAPI TcpClient::RecvThread(LPVOID param) { TcpClient* self = (TcpClient*)param; char buf[2048]; while (!self->m_exit) { int n = recv(self->m_sock, buf, sizeof(buf), 0); if (n > 0) { // 只做字节搬运,业务解析交给 proto 层,别在这里解 self->m_parser.Append(buf, n); self->m_lastRxTick = GetTickCount(); } else if (n == 0) { break; // 对端正常关闭 } else { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { Sleep(10); continue; } // 非阻塞模式下无数据 if (err == WSAEINTR) continue; // 被信号打断,重试 break; // 其它错误一律断开 } } self->m_lastRxTick = 0; self->m_linkState = LINK_BACKOFF; // 交给调度线程去重连 return 0; }n > 0这条分支里我特意没做任何业务判断,因为 TCP 是字节流,一次recv拿到的可能是半个帧、一个半帧、或者三个帧连在一起,在收线程里解析必然出错。所有数据都进FrameParser的缓冲区,由它决定哪些是完整帧。
停线程时不能直接closesocket,那样收线程会拿着一个已经失效的句柄继续用,运气不好句柄被系统复用给别的 socket,就会误关别人的连接。正确顺序是先shutdown让它从阻塞里退出来:
// net/TcpClient.cpp —— 停止客户端,顺序不能反 void TcpClient::Stop() { m_exit = TRUE; if (m_sock != INVALID_SOCKET) { // shutdown 会让阻塞中的 recv 立刻返回 0,线程自然跳出循环 shutdown(m_sock, SD_BOTH); } if (m_hRecvThread) { WaitForSingleObject(m_hRecvThread, 3000); // 等线程真正退出 CloseHandle(m_hRecvThread); m_hRecvThread = NULL; } if (m_hTxThread) { SetEvent(m_hTxEvent); // 唤醒发送线程 WaitForSingleObject(m_hTxThread, 3000); CloseHandle(m_hTxThread); m_hTxThread = NULL; } if (m_sock != INVALID_SOCKET) { closesocket(m_sock); // 线程都退了再关 m_sock = INVALID_SOCKET; } }shutdown和closesocket的区别这里必须吃透:shutdown只关闭数据通道,句柄还在,阻塞中的调用会返回;closesocket直接释放句柄。先shutdown再等线程退出,最后closesocket,这个顺序换来的是退出时不会偶发崩溃。
3.4 发送要走队列
多线程直接调send是另一个高频翻车点。send在 TCP 上是把数据拷进内核发送缓冲区,它不保证原子性,两个线程同时发,字节流可能交错,对端收到的就是一坨乱码。解决办法很简单:所有业务线程只把帧丢进队列,只有发线程真正调send。
// net/TcpClient.cpp —— 发送队列,业务线程只入队 void TcpClient::PostSend(const char* frame, int len) { EnterCriticalSection(&m_cs); // CE 上临界区比 Mutex 轻 m_txQueue.push_back(std::string(frame, len)); LeaveCriticalSection(&m_cs); SetEvent(m_hTxEvent); // 唤醒发送线程 } DWORD WINAPI TcpClient::TxThread(LPVOID param) { TcpClient* self = (TcpClient*)param; while (!self->m_exit) { WaitForSingleObject(self->m_hTxEvent, 100); for (;;) { std::string pkt; EnterCriticalSection(&self->m_cs); if (self->m_txQueue.empty()) { LeaveCriticalSection(&self->m_cs); break; } pkt = self->m_txQueue.front(); self->m_txQueue.pop_front(); LeaveCriticalSection(&self->m_cs); if (!SendAll(self->m_sock, pkt.data(), (int)pkt.size())) { self->m_linkState = LINK_BACKOFF; // 发失败直接转重连 break; } } } return 0; }SendAll负责把「一次send没发完」这件事处理干净:
// net/SocketUtil.cpp —— 循环发送直到发完,处理部分发送 BOOL SendAll(SOCKET s, const char* data, int len) { int sent = 0; while (sent < len) { int n = send(s, data + sent, len - sent, 0); if (n == SOCKET_ERROR) { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { Sleep(5); continue; } // 内核缓冲满,等一会 return FALSE; } sent += n; } return TRUE; }注意这里没有对发送做超时控制。如果对端卡死一直不收,内核缓冲区满了之后WSAEWOULDBLOCK会一直循环,理论上会挂住发线程。实际项目里我加了一个累计等待计数,超过 30 秒直接判定链路异常,转去重连——这比无限等下去要靠谱得多。
4. 协议帧设计:粘包拆包与 CRC 校验的实现细节
TCP 提供的是可靠字节流,它保证字节不丢不乱序,但完全不保证你发一次、对端就收一次。一个 200 字节的帧被拆成 30 + 170 两次recv返回,或者三个小帧被合在一次recv里返回,都是正常行为。所以在应用层必须自己定边界。
4.1 帧格式定死,后面全好办
我用的帧结构总共 10 字节头部加变长体:
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | magic | 2 | 固定 0xAA55,小端存储,用于失步后重新对齐 |
| 2 | ver | 1 | 协议版本,方便后期兼容老设备 |
| 3 | cmd | 1 | 命令字,决定 body 怎么解 |
| 4 | seq | 2 | 请求序号,应答里原样带回,用来配对 |
| 6 | len | 2 | body 长度,不含头尾,上限 4096 |
| 8 | body | N | 业务数据 |
| 8+N | crc | 2 | CRC16/MODBUS,覆盖偏移 0 到 8+N-1 |
magic 的作用是在失步时重新找到帧起点。现场电磁干扰或者上位机程序出错都可能发来垃圾字节,如果没有 magic,只能靠长度字段瞎猜,猜错就把后面的正常帧全部带偏。
4.2 拆包器实现
// proto/FrameParser.cpp —— 增量拆包,收到多少解多少 void FrameParser::Append(const char* data, int len) { m_buf.insert(m_buf.end(), data, data + len); Parse(); } void FrameParser::Parse() { const int HEAD = 8; // magic(2) + ver(1) + cmd(1) + seq(2) + len(2) const int TAIL = 2; // crc while (m_buf.size() >= (size_t)(HEAD + TAIL)) { const unsigned char* p = &m_buf[0]; // 1. 对齐:不是帧头就丢一个字节继续找 if (p[0] != 0xAA || p[1] != 0x55) { m_buf.erase(m_buf.begin()); continue; } // 2. 取长度,小端 int bodyLen = p[6] | (p[7] << 8); if (bodyLen > MAX_BODY_LEN) { // 长度离谱,判定为脏数据 m_buf.erase(m_buf.begin()); continue; } int total = HEAD + bodyLen + TAIL; if ((int)m_buf.size() < total) return; // 半包,等下一批数据 // 3. 校验,失败就丢一个字节重新找帧头 unsigned short crcRecv = p[HEAD + bodyLen] | (p[HEAD + bodyLen + 1] << 8); unsigned short crcCalc = Crc16(p, HEAD + bodyLen); if (crcRecv != crcCalc) { m_buf.erase(m_buf.begin()); continue; } // 4. 完整帧,回调给业务层 Dispatch(p[3], p + HEAD, bodyLen); m_buf.erase(m_buf.begin(), m_buf.begin() + total); } // 缓冲过长时间没有完整帧,防止内存被脏数据撑爆 if (m_buf.size() > MAX_BUF_LEN) m_buf.clear(); }这段代码有几个细节是踩过坑才补上的。第一步对齐用的是「丢一个字节再继续」而不是「清空缓冲」,因为脏字节后面往往紧跟着一个正常帧,清空就把好数据一起扔了。长度上限MAX_BODY_LEN必须判,如果不判,一个脏字节被解释成 60000 的长度,缓冲会一直涨到内存耗尽,设备直接重启。末尾的MAX_BUF_LEN判断是最后一道保险,正常业务下永远触发不到,触发就说明协议两侧对不上,清空重来比继续吞内存强。
Dispatch里做业务分发,注意不要在解析过程中调PostSend发应答之外的重活。拆包是在收线程里跑的,一旦这里阻塞,后续数据全部积压在内核缓冲区里,对端的发送窗口很快填满,看起来就像网络卡死。
4.3 CRC 与字节序
// proto/Crc16.cpp —— 标准 CRC16/MODBUS,多项式 0xA001 unsigned short Crc16(const unsigned char* data, int len) { unsigned short crc = 0xFFFF; for (int i = 0; i < len; ++i) { crc ^= data[i]; for (int b = 0; b < 8; ++b) { if (crc & 0x0001) crc = (unsigned short)((crc >> 1) ^ 0xA001); else crc = (unsigned short)(crc >> 1); } } return crc; }这个实现按位算,一帧 200 字节大概几十微秒,在中低端 ARM 上完全够用,没必要上查表版本——查表要占 512 字节的常量区,在内存紧的设备上不划算。
字节序是另一个容易翻车的地方。CE 跑在 ARM 上,默认小端,和 x86 的上位机一致,所以大部分时候直接memcpy结构体就能对上。但一旦以后换了平台或者对接第三方系统,就必须显式转换。我一般在Codec.cpp里手写取值函数,不直接强转结构体指针:
// proto/Codec.cpp —— 显式按小端取 16 位,避免结构体对齐差异 static inline unsigned short ReadU16(const unsigned char* p) { return (unsigned short)(p[0] | (p[1] << 8)); } static inline void WriteU16(unsigned char* p, unsigned short v) { p[0] = (unsigned char)(v & 0xFF); p[1] = (unsigned char)((v >> 8) & 0xFF); }强转结构体指针的风险在于编译器的对齐和填充规则,一个struct { char a; int b; }在不同编译选项下大小可能不一样,用sizeof发出去,对端按自己想的大小解,字段全错位。手写读写函数啰嗦一点,但永远不会有这个问题。
5. 避坑清单:WinCE 上 TCP 通信最容易翻车的五处
下面五条都是我在现场真遇到过的,按「现象 → 原因 → 解决」记下来,遇到同类问题可以省掉不少排查时间。
第一条:模拟器上跑得好好的,装到设备上WSAStartup返回 10091(WSASYSNOTREADY)。现象是程序一启动就提示网络初始化失败,但同一份代码在模拟器里完全正常。原因是模拟器用的是完整的桌面网络栈,而设备的内核镜像是裁剪出来的,平台目录里没勾选 TCP/IP 和 Winsock 组件,或者勾了但没编进最终镜像。解决分两步:先在设备上跑一个最小的WSAStartup测试程序确认组件在不在;确实缺组件就只能重新做镜像,这一点在代码层面无解。如果设备是第三方出货、拿不到 BSP,那就只能走串口或者让厂商开放组件,别在这里耗时间。
第二条:点「连接」之后界面卡死十几秒,用户以为设备死机。现象是按钮按下去没反应,过了很久才弹出「连接失败」。原因是直接在界面线程里调了阻塞模式的connect,无线信号弱的时候协议栈的重传要等几十秒。解决就是把连接阶段切成非阻塞加select超时,也就是第 3 章那段ConnectWithTimeout,超时给 3 到 5 秒,超时就返回,界面立刻能提示。顺带一提,recv也别放在界面线程,同一个道理。
第三条:跑了两三个小时后连接静默失效,send不报错,但对端收不到数据。现象很有迷惑性,日志里send一直返回成功,可上位机那边什么都没收到,只能重启设备恢复。原因是无线模块休眠或者路由器回收了 NAT 会话表,链路的实际状态已经断了,但本地 socket 还停留在已连接状态,内核缓冲区能收下数据所以send不报错。解决是加心跳:每 5 秒发一个cmd=0x01的空帧,同时记录最后一次收到数据的时间,超过 15 秒没收到任何字节就判定链路失效,主动shutdown转重连。光靠 TCP 自身的 keepalive 不够用,默认两小时才探测一次,而且 CE 上不一定开放了修改接口。
第四条:收到的数据偶尔少几个字节,或者两帧粘在一起。现象是业务层解出来的字段偶尔错位,重启程序又好了。原因是把recv的返回当成「一帧」来处理了,没有做缓冲拼接。TCP 只保证字节流有序,不保证边界。解决就是第 4 章那套FrameParser,所有收到的字节先进缓冲,按长度字段切完整帧,半包就留着等下一批。这个改动上线之后,同类问题再没出现过。
第五条:退出程序或者断开重连时偶发崩溃。现象是点「断开」大概十次里有一次崩溃,调用栈指向recv或closesocket。原因有两种:一是先closesocket再停线程,收线程拿着失效句柄继续用;二是重连时旧线程还没退干净,新线程已经起来了,两个线程操作同一个m_sock。解决是把Stop的流程标准化:置退出标志 →shutdown→ 等线程句柄 3 秒 →closesocket→ 句柄置为INVALID_SOCKET,同时用一个状态机保证同一时刻只有一个连接在跑。重连之前必须先完整走一遍Stop,不能图省事直接覆盖m_sock。
提示:这五条里有四条都能靠「启动时打印组件版本 + 全程记日志」提前发现。日志不用复杂,环形缓冲加定期落文件就行,关键是出问题时能拿到断线前那几十行。
6. 二次开发:把通信层从界面里剥出来
这份代码拿过去之后,绝大多数人的第一件事是接自己公司的业务协议,第二件事是加断线重连和心跳。这两件事如果在一开始没设计好,后面会越改越乱。我的建议是把传输层做成一个只依赖回调的独立模块,业务层通过接口注册进来,界面完全不参与。
具体做法是TcpClient只暴露三个东西:Start(ip, port)、Stop()、PostSend(frame, len),外加两个回调OnFrame(cmd, body, len)和OnLinkStateChanged(state)。界面层订阅OnLinkStateChanged,收到LINK_ONLINE就把按钮改成「断开」,收到LINK_BACKOFF就显示「重连中」。这样一来,界面里一行 socket 代码都没有,换个界面框架也不影响通信。
心跳和重连我一般做成一个每秒 tick 的状态机,放在调度线程里跑:
// net/TcpClient.cpp —— 心跳与重连,一个状态机全包 void TcpClient::Tick() { DWORD now = GetTickCount(); switch (m_linkState) { case LINK_IDLE: m_linkState = LINK_CONNECTING; break; case LINK_CONNECTING: m_sock = ConnectWithTimeout(m_host.c_str(), m_port, 3000); if (m_sock != INVALID_SOCKET) { m_linkState = LINK_ONLINE; m_lastRxTick = now; m_backoffMs = 1000; // 连上了就把退避重置 StartThreads(); } else { m_linkState = LINK_BACKOFF; } break; case LINK_ONLINE: if (now - m_lastRxTick > 15000) { // 15 秒没收到任何字节 StopThreadsOnly(); // 内部会 shutdown m_linkState = LINK_BACKOFF; } else if (now - m_lastHeartbeat > 5000) { SendHeartbeat(); m_lastHeartbeat = now; } break; case LINK_BACKOFF: if (now - m_lastTry > m_backoffMs) { m_lastTry = now; m_backoffMs = min(m_backoffMs * 2, 30000); // 指数退避,封顶 30 秒 m_linkState = LINK_CONNECTING; } break; } }指数退避这一步不能省。早期版本我用固定 1 秒重连,结果现场路由器重启的时候,几十台设备同时每秒发一次connect,把路由器的连接表打满,反而谁都连不上。改成 1、2、4、8、16、30 秒封顶之后,这个问题基本消失。
协议扩展方面,cmd只有一个字节,最多 256 个命令,够用但要注意别乱分配。我习惯按 0x10 到 0x7F 给上行,0x80 到 0xEF 给下行,0x01 固定给心跳,0xF0 以上留给调试命令。ver字段现在是摆设,等以后真要改帧结构的时候就能派上用场:收到ver比自己支持的高的帧,直接回一个错误码,而不是按老格式硬解出垃圾数据。
有一件事我踩过两次,现在写进团队规范了:FrameParser的Dispatch回调里绝对不能调Stop(),会自己等自己造成死锁——收线程正在等这个回调返回,而Stop()要等收线程退出。要做断线,只能置一个标志,让调度线程下一轮 tick 处理。这种死锁在调试器里看不到堆栈,只会表现成程序某天突然卡住不动。
验证方法上,除了常规的联调,我会固定做三件事:第一,用上位机发一批故意错位的字节流(比如在正常帧前面插三个随机字节),确认FrameParser能靠 magic 重新对齐;第二,中途拔网线或者关掉上位机的 Server,看设备能不能在 30 秒内自动重连上;第三,挂着跑一整个通宵,第二天看日志里OnLinkStateChanged切换了几次,如果超过三五次就要查是什么原因导致的断线。这套流程走下来,基本上线后就不会有太大问题。
从那以后我每次写这类通信模块,都会先把「停线程的顺序」和「回调里不许调 Stop」写成注释钉在代码顶部,宁可多花十分钟,也不想再为了一个偶发死锁在现场熬到后半夜。希望帮到你。
本文还有配套的精品资源,点击获取