WinCE TCP长连接稳定实践:心跳、断线重连与socket生命周期 简介面向WinCE嵌入式网络开发初学者及需要打通TCP通信链路的开发者这份基于Visual Studio 2005的SampleTCP工程样例围绕Winsock API演示套接字创建、与服务器建立连接、数据收发循环以及异常关闭等关键环节可用于理解WinCE设备上的客户端/服务器通信流程并为后续移植到实际嵌入式项目提供参照。压缩包为zip格式共29个文件、约4.45MB包含C源码与头文件、VS2005工程及解决方案、资源脚本、清单文件和调试输出等工程结构相对完整便于直接打开对照学习也能观察编译产物与项目配置之间的关系。已有315人学习适合作为嵌入式网络编程的入门样本。读者可从中获得可运行的TCP通信样例、ws2_32.lib链接与_WIN32_WCE预处理器等工程配置线索以及连接失败、收发异常时关闭套接字并清理资源的排错思路进而理解从套接字创建到数据收发的完整链路。1. WinCE 上跑 TCP 通信先别急着 connect连接生命周期才是主战场WinCE TCP 通信最容易被低估的地方不是 connect 能不能返回 0而是连接跑上几小时后突然假死。某产线手持终端通过 Wi-Fi 上报数据界面显示在线服务端却收不到心跳抓包看到设备端不再发 ACK——这类血泪经验我踩过不止一次。这份 WinCE TCP 通信示例源码把服务端监听、客户端连接、收发线程、心跳与断线重连拆成了可独立替换的模块适合做设备端数据上报、上位机联调、老设备网络化改造。它不追求大而全重点是把 socket 生命周期、线程退出和弱网重连讲清楚。适合从桌面 Windows 迁到 WinCE 的嵌入式开发者也适合手里有老设备、需要快速搭一条稳定 TCP 链路的现场工程师。2. WinCE 的 TCP 通信底座Winsock 初始化、socket 选型与线程模型2.1 WinCE 网络栈与 Winsock 版本边界WinCE 不是桌面 Windows 的简单缩小版。网络栈从 NDIS 驱动、TCP/IP 协议栈到 Winsock API任何一层在平台镜像里被裁掉socket 都可能在创建阶段就失败。我一般先确认目标设备能正常获取 IP再用上位机 ping 一下设备地址如果 ping 不通先查网卡驱动和 IP 配置别急着怀疑代码。WSAStartup 返回 0 也不代表协议栈完整它只说明 Winsock 库加载成功。真正要看的是 socket 创建失败时的 WSAGetLastErrorWSAEAFNOSUPPORT 多半是地址族不支持WSAEPROTONOSUPPORT 多半是 TCP 协议组件没进镜像WSAENOBUFS 则要查内存和连接数。工程链接也要对齐。WinCE 工程通常链接目标 SDK 自带的 ws2.lib不要拿桌面工程的 ws2_32.lib 硬套否则编译能过、运行时报错排查起来很玄学。Winsock 版本按目标 SDK 头文件支持来常见做法是先试 MAKEWORD(2,2)失败再降级到 1,1 并记录日志不要在不确认平台能力的情况下把版本写死。WSAStartup 和 WSACleanup 要配对建议在主线程初始化一次工作线程只使用 socket不要在每次收发前反复 WSAStartup。检查项常见现象处理方式平台组件socket 创建返回 INVALID_SOCKET查镜像是否包含 TCP/IP 与 Winsock 组件链接库编译通过运行时报找不到入口链接目标 SDK 的 ws2.lib清理旧中间文件版本协商WSAStartup 返回非 0记录错误码按 SDK 支持版本降级多线程初始化偶发 WSAStartup 失败主线程统一初始化避免并发清理提示WSAStartup 失败时不要继续创建 socket先把 WSAGetLastError 写进设备日志否则后面所有错误都会变成“连接失败”很难定位。2.2 客户端与服务端的 socket 选型及线程模型在 WinCE 上我优先用原生 Winsock不用 MFC 的 CSocket。CSocket 依赖消息泵放到没有消息循环的工作线程里容易阻塞出问题时还多一层黑匣子。原生 socket 虽然代码多一点但阻塞、非阻塞、select 超时都可控。客户端侧常见做法是“一个收线程 发送加锁”收线程阻塞在 recv 上发送方在临界区里调用 sendAll避免两个线程同时写同一个 socket 导致数据交错。服务端侧如果连接数少可以 accept 线程加每连接一个收线程连接数一多就要限制最大连接数或者改成 select 单线程轮询WinCE 设备内存和线程资源都有限别开口就是每连接一线程。阻塞还是非阻塞要看场景。设备端主循环要定时发心跳纯阻塞 recv 会拖住状态机所以我一般把收数据放在独立线程主线程只做状态判断和心跳调度。connect 阶段用非阻塞加 select 等超时避免网络不通时卡住 UI 或看门狗。TCP_NODELAY 对少量控制命令有用能减少小包延迟心跳包本身不追求低延迟别为了省事把所有 socket 都设成一有数据就发。SO_KEEPALIVE 在 WinCE 上支持程度不一不能把它当应用层心跳的替代品弱网下还是自己发心跳、自己判超时更稳。2.3 初始化、绑定与工程链接的落地步骤先跑最小闭环WSAStartup、创建 socket、绑定地址、监听或连接。下面这段是服务端初始化骨架客户端把 bind/listen 换成 connect 即可。代码里保留了错误码记录现场排查时比只看“失败”有用。#include winsock2.h // 工程里链接目标 SDK 的 ws2.lib桌面 MSVC 才常见 ws2_32.lib bool InitServer(unsigned short port, SOCKET listenSock) { WSADATA wsa; int ret WSAStartup(MAKEWORD(2, 2), wsa); if (ret ! 0) { // 记录 ret按 SDK 支持情况决定是否降级到 1,1 return false; } listenSock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock INVALID_SOCKET) { // 重点看 WSAGetLastError组件裁剪、库不匹配都会在这里暴露 WSACleanup(); return false; } BOOL reuse TRUE; setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (const char*)reuse, sizeof(reuse)); sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); // 端口必须转网络字节序 addr.sin_addr.s_addr htonl(INADDR_ANY); // 服务端监听所有网卡 if (bind(listenSock, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { closesocket(listenSock); listenSock INVALID_SOCKET; WSACleanup(); return false; } if (listen(listenSock, 4) SOCKET_ERROR) { // backlog 按设备能力给别盲目调大 closesocket(listenSock); listenSock INVALID_SOCKET; WSACleanup(); return false; } return true; }这段代码的逻辑很直白先初始化 Winsock再创建 TCP socket设置地址复用绑定端口并进入监听。参数上port 用业务约定端口示例里可以是 9000backlog 给 4 是设备端保守值连接数多时再按内存调。SO_REUSEADDR 主要解决重启后端口占用不能解决协议错误。bind 的地址用 INADDR_ANY 表示监听所有网卡客户端连接时则要填服务端具体 IP不能照抄。初始化失败时先关 socket 再 WSACleanup顺序反了会增加排查噪音。3. 把 TCP 通信跑起来服务端监听、客户端连接与收发闭环3.1 服务端bind/listen/accept 的 WinCE 写法服务端最容易翻车的地方不是 accept 本身而是 accept 返回错误后直接退出循环。WinCE 设备上网络会抖动accept 可能被信号或关闭动作打断正确做法是判断错误码能继续就继续。下面这个 accept 循环带了退出标志和连接数限制实际工程里把 ClientThread 换成自己的收线程即可。volatile bool g_exit false; const int MAX_CONN 8; volatile LONG g_connCount 0; DWORD WINAPI ClientThread(LPVOID param) { SOCKET client (SOCKET)(INT_PTR)param; char buf[1024]; while (!g_exit) { int n recv(client, buf, sizeof(buf), 0); if (n 0) { // 这里交给协议解析器不要直接在收线程里做耗时业务 continue; } if (n 0) break; // 对端正常关闭 int err WSAGetLastError(); if (err WSAEWOULDBLOCK) continue; break; } shutdown(client, SD_BOTH); closesocket(client); InterlockedDecrement(g_connCount); return 0; } void AcceptLoop(SOCKET listenSock) { while (!g_exit) { SOCKET client accept(listenSock, NULL, NULL); if (client INVALID_SOCKET) { int err WSAGetLastError(); if (err WSAEINTR) break; // 主动关闭监听 socket Sleep(50); // 避免错误状态下空转 continue; } if (InterlockedIncrement(g_connCount) MAX_CONN) { InterlockedDecrement(g_connCount); shutdown(client, SD_BOTH); closesocket(client); continue; } HANDLE h CreateThread(NULL, 0, ClientThread, (LPVOID)(INT_PTR)client, 0, NULL); if (h) CloseHandle(h); // 线程句柄不用时及时关线程本身继续跑 } }这里 accept 返回 INVALID_SOCKET 时没有一律 break而是区分 WSAEINTR 和普通错误。MAX_CONN 给 8 是示例值按设备内存和业务并发调连接数超限时先 shutdown 再 closesocket避免对端还挂在半开连接上。ClientThread 里只做 recv 和转交解析耗时业务放到队列或单独线程否则一个慢客户端就能拖住整个收线程。CreateThread 返回的句柄用完就 CloseHandle不等于杀线程只是释放句柄资源。3.2 客户端非阻塞 connect 与超时控制客户端 connect 在弱网下可能卡很久阻塞调用会把主线程拖死。常见做法是临时把 socket 设成非阻塞connect 返回 WSAEWOULDBLOCK 后用 select 等可写再用 SO_ERROR 判断真实结果。连接成功后恢复阻塞模式收线程继续用阻塞 recv。SOCKET ConnectWithTimeout(const char* ip, unsigned short port, int timeoutSec) { SOCKET s socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s INVALID_SOCKET) return INVALID_SOCKET; u_long nonblock 1; ioctlsocket(s, FIONBIO, nonblock); 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(ip); // 现场建议先校验返回值 int r connect(s, (sockaddr*)addr, sizeof(addr)); if (r SOCKET_ERROR) { int err WSAGetLastError(); if (err ! WSAEWOULDBLOCK err ! WSAEINPROGRESS) { closesocket(s); return INVALID_SOCKET; } fd_set wset; FD_ZERO(wset); FD_SET(s, wset); timeval tv; tv.tv_sec timeoutSec; tv.tv_usec 0; r select(0, NULL, wset, NULL, tv); // WinCE 下第一个参数忽略 if (r 0) { closesocket(s); // 0 是超时SOCKET_ERROR 是 select 失败 return INVALID_SOCKET; } int soErr 0; int len sizeof(soErr); getsockopt(s, SOL_SOCKET, SO_ERROR, (char*)soErr, len); if (soErr ! 0) { closesocket(s); return INVALID_SOCKET; } } nonblock 0; ioctlsocket(s, FIONBIO, nonblock); // 恢复阻塞后续 recv 逻辑更简单 return s; }timeoutSec 建议 3 到 5 秒现场网络差可以放到 8 秒但不要无限等。select 返回 0 表示超时返回 SOCKET_ERROR 要看 WSAGetLastError不能一律当超时。getsockopt 的 SO_ERROR 才是 connect 的最终结果select 可写只说明“有结果了”不代表连接成功。inet_addr 失败会返回 INADDR_NONE正式代码里要先校验 IP 字符串别把错误地址带进 connect。3.3 收发循环粘包、半包与 sendAll 的实现TCP 是字节流没有消息边界。设备端发一个结构体上位机可能一次收到半个也可能两次粘在一起。我一般定固定包头magic、type、lengthlength 表示 payload 长度所有多字节字段走网络字节序。接收侧维护一个缓冲区循环 recv 后按长度切包长度非法就断开别让越界解析把整个进程带崩。#pragma pack(push, 1) struct PacketHeader { unsigned short magic; // 网络字节序例如 0x55AA unsigned char type; unsigned short length; // payload 长度网络字节序 }; #pragma pack(pop) 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) { // 非阻塞发送时要等可写阻塞 socket 一般不会走到这里 continue; } return false; } sent n; } return true; } // 收线程里的解析骨架g_recvBuf 是每连接独立缓冲区 void OnRecvBytes(const char* data, int len) { g_recvBuf.append(data, len); while (g_recvBuf.size() sizeof(PacketHeader)) { PacketHeader hdr; memcpy(hdr, g_recvBuf.data(), sizeof(hdr)); unsigned short payloadLen ntohs(hdr.length); if (payloadLen 4096) { // 最大包长按业务定超限直接断开 CloseConnSafely(); return; } int total sizeof(PacketHeader) payloadLen; if ((int)g_recvBuf.size() total) break; // 半包等下次 recv std::string payload g_recvBuf.substr(sizeof(PacketHeader), payloadLen); DispatchPacket(hdr.type, payload); g_recvBuf.erase(0, total); } }参数上magic 用固定值做二次校验type 区分心跳和业务length 限制 4096 是保守值视频或文件传输要另开通道。SendAll 必须循环 send不能假设一次 send 把 len 全发完非阻塞模式下遇到 WSAEWOULDBLOCK 要配合 select 等可写不能原地死循环。接收缓冲区按连接独立解析完立即 erase避免长时间运行后内存只涨不降。结构体加#pragma pack(1)只解决对齐不解决大小端length 和 magic 仍要 htons/ntohs。4. 断线重连与心跳弱网下把 WinCE 长连接保住4.1 心跳包格式与超时参数怎么定应用层心跳比 TCP keepalive 更适合 WinCE 设备因为超时时间、包内容、日志都能自己控制。心跳包不用带业务数据type 设为 0x01payload 放一个 4 字节时间戳或递增序号即可。发送间隔太短会浪费流量和唤醒次数太长则发现断线慢。我一般给 10 秒发送、30 秒判超时弱网现场可以调到 15 秒和 45 秒但别把超时设得比重连退避还短否则状态机会来回抖。参数建议值说明心跳发送间隔10 秒设备端定时器触发别用 Sleep 阻塞主循环心跳超时30 秒连续 3 个周期没收到任何数据再判死重连初始退避1 秒第一次断开快速重试重连最大退避30 秒避免无网时疯狂重连单包最大长度4096 字节按业务调超限直接断开接收缓冲区上限8192 字节至少容纳两个最大包防止半包堆积判断超时不要只看“最后一次发心跳”要看“最后一次收到对端任何数据”。服务端可能不回心跳但会回业务 ACK如果协议里服务端完全不回包那心跳机制要改成请求-响应式否则设备端无法区分“链路正常”和“服务端收了但不理你”。这一点在联调时经常被忽略最后日志里全是“心跳已发”实际上连接早就半死了。4.2 重连状态机指数退避与连接风暴重连不要写成 while 循环里疯狂 connect。设备断网后每秒重连一次会把 CPU 和网络栈拖垮服务端也会看到大量半开连接。我一般用四态状态机IDLE、CONNECTING、CONNECTED、CLOSING失败后按 1、2、4、8、16、30 秒退避连接成功后重置回 1 秒。状态机放在主循环里每次只推进一步方便看门狗喂狗和响应退出标志。enum ConnState { ST_IDLE, ST_CONNECTING, ST_CONNECTED, ST_CLOSING }; ConnState g_state ST_IDLE; int g_backoffMs 1000; DWORD g_lastRecvTick 0; DWORD g_lastSendTick 0; void ConnStateMachine() { DWORD now GetTickCount(); switch (g_state) { case ST_IDLE: g_state ST_CONNECTING; break; case ST_CONNECTING: { SOCKET s ConnectWithTimeout(192.168.1.10, 9000, 5); if (s ! INVALID_SOCKET) { g_sock s; g_lastRecvTick now; g_lastSendTick now; g_backoffMs 1000; // 成功后退避归零 g_state ST_CONNECTED; } else { Sleep(g_backoffMs); g_backoffMs (g_backoffMs * 2 30000) ? 30000 : g_backoffMs * 2; g_state ST_IDLE; } break; } case ST_CONNECTED: if (now - g_lastRecvTick 30000) { g_state ST_CLOSING; // 30 秒没收到任何数据判超时 } else if (now - g_lastSendTick 10000) { SendHeartbeat(g_sock); g_lastSendTick now; } break; case ST_CLOSING: CloseConnSafely(); g_state ST_IDLE; break; } }这段状态机里Sleep 只出现在连接失败后的退避不阻塞已建立连接的收发。GetTickCount 会回绕判断差值时用无符号减法不要直接比较大小。ConnectWithTimeout 里的 IP 和端口是示例现场要换成配置项别写死在代码里。退避上限 30 秒是经验值设备对实时性要求高可以降到 10 秒但要有上限不能无限翻倍。4.3 多线程退出与 socket 资源回收顺序关闭连接是最容易出死锁的地方。一个线程在 recv 上阻塞另一个线程直接 closesocket收线程可能返回错误也可能卡在驱动里如果还持有临界区主线程再等锁整个设备就像死机。我的做法是统一关闭入口先置退出标志再 shutdown(SD_BOTH) 唤醒 recv然后 closesocket最后 WaitForSingleObject 等收线程退出。不要在持锁状态下做 shutdown 和 closesocket也不要用 TerminateThread 硬杀。CRITICAL_SECTION g_cs; SOCKET g_sock INVALID_SOCKET; HANDLE g_recvThread NULL; volatile bool g_threadExit false; void CloseConnSafely() { g_threadExit true; EnterCriticalSection(g_cs); SOCKET s g_sock; g_sock INVALID_SOCKET; LeaveCriticalSection(g_cs); if (s ! INVALID_SOCKET) { shutdown(s, SD_BOTH); // 先唤醒阻塞 recv closesocket(s); // 再释放句柄 } if (g_recvThread) { DWORD wr WaitForSingleObject(g_recvThread, 3000); if (wr WAIT_TIMEOUT) { // 记录日志并检查线程是否卡在驱动或业务回调里 // 不要在这里 TerminateThread } CloseHandle(g_recvThread); g_recvThread NULL; } }关闭顺序的核心是“先让阻塞调用返回再释放资源”。shutdown 只关闭收发方向不释放 socketclosesocket 才释放句柄。WaitForSingleObject 给 3 秒是保守值超时后先记日志排查收线程是不是卡在业务回调或驱动里而不是直接杀线程。g_sock 被临界区保护其他线程发数据前先取本地句柄发现 INVALID_SOCKET 就放弃本次发送避免关闭后继续写。5. 避坑与排查WinCE TCP 通信最常见的 5 个翻车点5.1 现象WSAStartup 成功但 socket 创建失败现象是 WSAStartup 返回 0日志里也能看到初始化成功但 socket 返回 INVALID_SOCKET后面 connect 根本走不到。原因通常不是代码写错而是目标镜像裁剪了 TCP/IP 或 Winsock 组件或者工程链接了不匹配的库。桌面工程迁到 WinCE 时链接器里还留着桌面库路径编译能过运行就报协议不支持。解决方式是先看 WSAGetLastErrorWSAEPROTONOSUPPORT 查平台组件WSAEAFNOSUPPORT 查地址族支持WSAENOBUFS 查内存和句柄泄漏。然后清理中间文件确认链接的是目标 SDK 的 ws2.lib不要混用桌面库。最后用最小 demo 只做 WSAStartup 和 socket 创建把业务代码全去掉能快速判断是环境问题还是工程配置问题。5.2 现象connect 成功收第一包就断现象是客户端日志显示 connect 成功服务端也 accept 了但第一包数据一到连接立刻被 RST 或 recv 返回错误。原因常见有三种一是发送方把结构体内存直接 send接收方按字符串或固定长度解析长度字段错位二是接收缓冲区太小粘包后越界程序自己调用 closesocket三是中间网络设备有空闲超时连接看似建立实际路径已经断。解决方式是先抓包看 RST 是谁发的如果是设备端发的查解析代码的边界检查如果是服务端发的查服务端是否因为包长度非法主动断开。协议上固定 magic 和 lengthlength 超过上限直接断开并记日志不要尝试“容错解析”。接收缓冲区按最大包两倍以上分配半包时等待下一次 recv不要用一次 recv 的结果当完整包。5.3 现象关闭连接后收线程卡死现象是界面点了断开主线程等收线程退出等了几秒没反应最后看门狗复位。原因多是另一个线程直接 closesocket而收线程还阻塞在 recv 上或者关闭时持有临界区收线程返回后又要拿同一把锁形成死锁。WinCE 的网络驱动在 socket 被关闭时的行为不一定和桌面一致不能假设 closesocket 一定立刻唤醒 recv。解决方式是统一关闭入口先置退出标志再 shutdown(SD_BOTH)再 closesocket最后 WaitForSingleObject。临界区只保护句柄赋值不保护 shutdown 和 closesocket。等待超时后记录线程状态不要用 TerminateThread硬杀线程可能留下锁和驱动资源后面更难查。5.4 现象结构体发出去上位机解析错位现象是设备端明明按结构体填了字段上位机收到的数值却大得离谱或者字符串后面带乱码。原因通常不是 TCP 丢包而是大小端、结构体对齐和类型长度不一致。WinCE 设备多是小端上位机如果按网络字节序解析ushort 和 uint 必须先转换结构体默认对齐可能插入填充字节sizeof(struct)和字段实际长度对不上long 在不同编译器下长度也可能不同。解决方式是不要直接发结构体内存逐字段序列化成字节流多字节字段统一 htons/htonl接收端再 ntohs/ntohl。结构体定义加#pragma pack(1)只解决对齐不解决字节序。协议文档里把字段偏移和长度写清楚联调时先发固定测试包用抓包工具对照十六进制比猜代码快。5.5 现象日志全是超时抓包却有回包现象是设备日志不断打印“心跳超时”但抓包能看到服务端回了 ACK甚至回了业务包。原因可能是收线程被 UI 或慢业务阻塞回包到了 socket 缓冲区但没人 recv也可能是时间判断用了 GetTickCount 直接比较大小回绕后差值变成负数还可能是 select 的 timeval 单位写错把毫秒当微秒超时时间严重缩短。解决方式是把收数据和业务处理分开收线程只做 recv 和入队业务线程慢慢处理时间比较统一用无符号差值select 的 tv_sec 是秒tv_usec 是微秒别混。抓包时同时记录设备日志时间戳对照回包到达时间和日志打印时间差几百毫秒是正常的差几秒就要查线程调度和锁竞争。6. 进阶验证用抓包与压测把 WinCE TCP 通信盯死6.1 三个验证动作抓包、时间戳、压测联调时不要只看设备界面显示“在线”。我一般做三个验证动作。第一在 PC 端抓包过滤ip.addr 设备IP and tcp.port 9000看三次握手、心跳间隔、FIN/RST 是谁发的如果心跳间隔和代码里的 10 秒对不上先查定时器是不是被慢业务拖了。第二设备日志加两个时间戳GetTickCount 和收到数据的时刻排查超时误判。第三用压测脚本模拟多连接和随机断开观察设备线程数、内存和句柄数是否稳定。通过标准不是“能连上”而是连续跑 30 分钟心跳间隔稳定、无异常 RST、内存不持续上涨。6.2 一个 Python 压测脚本片段下面脚本只做连接、发心跳、随机断开重连用来压设备端的 accept 和关闭逻辑。地址换成联调环境地址不要用真实生产地址。import socket, threading, time, random HOST, PORT 192.168.1.10, 9000 # 换成联调环境地址 HEARTBEAT b\x55\xaa\x01\x00\x00 # magic type length 示例 def client(cid): while True: try: s socket.create_connection((HOST, PORT), timeout3) s.settimeout(5) for _ in range(30): s.sendall(HEARTBEAT) s.recv(1024) # 设备端应回心跳 ACK time.sleep(10) # 心跳间隔与设备端保持一致 s.close() except Exception as e: print(cid, reconnect, e) time.sleep(random.randint(1, 5)) for i in range(50): # 50 并发按设备能力调 threading.Thread(targetclient, args(i,), daemonTrue).start() time.sleep(300)脚本里连接超时 3 秒、读超时 5 秒、心跳 10 秒、并发 50都是压测起点不是固定值。设备端如果限制 MAX_CONN8多出来的连接会被服务端主动关闭这时脚本报 reconnect 是预期行为重点看设备端有没有句柄泄漏。抓包和压测一起跑才能把“连接建立”和“连接生命周期”分开看。从那以后我每次在 WinCE 上接 TCP都先把关闭顺序、心跳超时和抓包三点写成检查表再谈业务协议。如果你手上正好有 WinCE 设备要联调这份源码包适合直接拆开改先跑通最小闭环再把心跳和重连接进去比从零搭架子省时间。希望帮到你。本文还有配套的精品资源点击获取