简介这份资源面向在Windows CE嵌入式平台上进行网络开发的工程师与学习者提供基于Visual Studio 2005环境的TCP通信样例工程帮助理解并掌握WinCE设备上的Winsock网络编程。包内共29个文件以h头文件、cpp源文件、vcproj工程文件、rc资源脚本及sln解决方案为主另含编译产物与调试文件压缩包约4.45MB可直接在VS2005中打开调试。样例围绕socket创建、connect连接、send与recv收发数据以及closesocket关闭套接字等核心流程展开并涉及ws2_32.lib链接依赖与_WIN32_WCE预处理器配置便于对照理解客户端与服务器端的实现差异。目前已有313人学习下载适合需要快速上手WinCE TCP通信、排查连接与收发异常、并将样例移植到实际嵌入式项目的开发者参考。1. 别被“老古董”吓退WinCE 上的 TCP 通信到底在传什么接手一台跑了十几年的工业触摸屏系统是 WinCE老板让你把数据传到后台服务器你打开 IDE 发现连个像样的网络调试工具都跑不起来——这是很多工控现场的真实开局。WinCE 实现 TCP 通信这件事核心不是“能不能连”而是“在内存紧、库不全、调试手段匮乏的环境里怎么把一条稳定的 socket 链路搭出来”。它适合两类人一类是维护老旧产线设备、必须让 WinCE 终端把采集数据吐出来的现场工程师另一类是想理解 Windows Socket 在精简系统上如何裁剪的嵌入式开发者。和桌面 Windows 不同WinCE 的网络栈是模块化裁剪的Winsock 的可用函数集取决于系统镜像里选了哪些组件这意味着你在 PC 上写惯的代码直接搬过来可能连编译都过不了。所以第一步不是写代码而是搞清楚你手上这台设备的网络能力边界在哪。2. 先摸清 WinCE 的网络家底Winsock 版本与组件裁剪在动手写第一行 socket 代码之前必须确认目标设备的 Winsock 版本和可用 API 集合。WinCE 的模块化设计决定了不同定制镜像之间差异极大同一段代码在 A 设备上跑通换到 B 设备可能连WSAStartup都返回失败。常见做法是先写一个极简的探测程序把版本信息和关键 API 的可用性打印出来再决定后续用同步还是异步模型。2.1 Winsock 1.1 与 2.2 在 WinCE 上的实际差异WinCE 从 3.0 开始提供 Winsock早期版本只支持 1.1后来逐步支持 2.2。两者最关键的差异在于Winsock 1.1 只支持select模型和阻塞 socket而 2.2 才引入WSAEventSelect、WSAAsyncSelect、重叠 I/O 等异步机制。很多老设备出厂镜像里只带了 1.1 的组件你在代码里调用WSACreateEvent会直接链接失败。判断方法很直接调用WSAStartup时传入版本号看返回的wHighVersion。#include winsock.h #include stdio.h int CheckWinsockVersion(void) { WSADATA wsaData; int ret; /* 请求 2.2 版本若系统只支持 1.1 会返回较低版本 */ ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup failed: %d\n, ret); return -1; } printf(Requested: 2.2\n); printf(Actual wVersion: %d.%d\n, LOBYTE(wsaData.wVersion), HIBYTE(wsaData.wVersion)); printf(wHighVersion: %d.%d\n, LOBYTE(wsaData.wHighVersion), HIBYTE(wsaData.wHighVersion)); printf(Description: %s\n, wsaData.szDescription); printf(MaxSockets: %d\n, wsaData.iMaxSockets); WSACleanup(); return 0; }这段代码的逻辑是请求 2.2 版本系统会返回它实际能提供的最高版本。如果wVersion返回的是 1.1说明镜像里没有包含 Winsock 2.2 组件后续代码必须退回select模型。iMaxSockets这个字段也值得关注它告诉你当前系统允许的最大 socket 数量在资源紧张的设备上这个值可能只有几十。参数说明MAKEWORD(2,2)把主版本和次版本打包成一个 WORD高位字节是次版本低位字节是主版本。WSADATA结构体里的szDescription在 WinCE 上通常是一段简短的描述文本可以用来确认加载的是哪个网络实现。2.2 用 Platform Builder 确认镜像里的网络组件如果你有系统镜像的构建权限最可靠的方式是在 Platform Builder 里检查.bib和.reg文件确认以下组件是否被包含组件名作用缺失后果Winsock Support提供基础 socket API所有网络调用失败TCP/IP Protocol StackTCP 协议实现只能 UDP不能 TCPNDIS Miniport Driver网卡驱动接口无物理链路DHCP Client自动获取 IP只能手动配静态 IPDNS Resolver域名解析只能用 IP 直连没有构建权限的话可以在设备上跑ipconfig或查看注册表HKLM\Comm\下的网卡条目来间接判断。我一般会先确认三件事网卡驱动加载了没有、IP 配上了没有、ping能不能通。这三步过了才轮到写 socket 代码。2.3 最小可用的 TCP 客户端骨架确认完环境先搭一个最小可用的 TCP 客户端。不要一上来就搞多线程和心跳先把“连上—发一条—收一条—断开”这条链路跑通。#include winsock.h #include stdio.h #define SERVER_IP 192.168.1.100 #define SERVER_PORT 9000 int TcpClientDemo(void) { WSADATA wsaData; SOCKET sock INVALID_SOCKET; struct sockaddr_in serverAddr; char sendBuf[] HELLO; char recvBuf[256]; int ret; if (WSAStartup(MAKEWORD(1, 1), wsaData) ! 0) { printf(WSAStartup failed\n); return -1; } sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock INVALID_SOCKET) { printf(socket() failed: %d\n, WSAGetLastError()); WSACleanup(); return -1; } memset(serverAddr, 0, sizeof(serverAddr)); serverAddr.sin_family AF_INET; serverAddr.sin_port htons(SERVER_PORT); serverAddr.sin_addr.s_addr inet_addr(SERVER_IP); ret connect(sock, (struct sockaddr *)serverAddr, sizeof(serverAddr)); if (ret SOCKET_ERROR) { printf(connect() failed: %d\n, WSAGetLastError()); closesocket(sock); WSACleanup(); return -1; } ret send(sock, sendBuf, strlen(sendBuf), 0); printf(send %d bytes\n, ret); ret recv(sock, recvBuf, sizeof(recvBuf) - 1, 0); if (ret 0) { recvBuf[ret] \0; printf(recv: %s\n, recvBuf); } closesocket(sock); WSACleanup(); return 0; }逻辑说明先WSAStartup初始化 Winsock 库然后创建 TCP socket填充服务器地址结构体调用connect建立连接。send和recv是最基本的收发函数最后依次关闭 socket 和清理 Winsock。参数说明htons把主机字节序的端口号转成网络字节序inet_addr把点分十进制的 IP 字符串转成 32 位网络字节序整数。recv的最后一个参数是标志位通常填 0。注意recv返回 0 表示对端关闭了连接返回SOCKET_ERROR才是出错。这个骨架在 Winsock 1.1 和 2.2 上都能编译运行区别只在于WSAStartup请求的版本号。如果你的设备只支持 1.1把MAKEWORD(2,2)改成MAKEWORD(1,1)即可。3. 阻塞还是异步WinCE 下三种通信模型的选型与落地最小骨架跑通之后马上会遇到一个现实问题阻塞式recv会把整个线程卡死界面不响应看门狗可能直接复位。WinCE 上可用的通信模型比桌面 Windows 少选错了要么代码复杂度爆炸要么性能上不去。这一章把三种常见模型的实际落地方式讲清楚。3.1 阻塞模型加独立线程最简单也最容易翻车最常见的做法是开一个独立线程专门跑 socket 收发主线程继续处理界面和逻辑。WinCE 支持CreateThread用法和桌面 Windows 基本一致。#include windows.h #include winsock.h static volatile int g_bRunning 1; DWORD WINAPI RecvThread(LPVOID param) { SOCKET sock (SOCKET)param; char buf[512]; int ret; while (g_bRunning) { ret recv(sock, buf, sizeof(buf) - 1, 0); if (ret 0) { buf[ret] \0; /* 处理收到的数据注意不要在这里做耗时操作 */ printf(recv: %s\n, buf); } else if (ret 0) { printf(peer closed\n); break; } else { int err WSAGetLastError(); if (err WSAETIMEDOUT) { continue; /* 超时不算错误继续循环 */ } printf(recv error: %d\n, err); break; } } return 0; }逻辑说明线程函数里循环调用recv收到数据就处理对端关闭就退出循环。g_bRunning是一个全局标志主线程想停止接收时把它置 0但注意阻塞在recv上的线程不会立刻醒来需要配合shutdown或closesocket来打断。参数说明CreateThread的第四个参数是线程函数指针第五个参数传 0 表示立即开始执行。线程栈大小在 WinCE 上默认比较小如果线程函数里用了大数组建议显式指定栈大小比如传64*1024。这个模型最大的坑是closesocket之后线程可能还在recv里没出来导致访问已释放的 socket 句柄。正确做法是先调用shutdown(sock, SD_BOTH)等线程退出后再closesocket。3.2 select 模型单线程管多路连接的正确姿势当客户端需要同时维护多条连接或者既要收又要发还要处理超时select是最经济的选择。WinCE 的select和桌面版本行为基本一致但FD_SETSIZE默认值可能更小。int SelectLoop(SOCKET listenSock) { fd_set readSet, writeSet; struct timeval tv; SOCKET clientSocks[8]; int clientCount 0; int i, ret; while (1) { FD_ZERO(readSet); FD_ZERO(writeSet); FD_SET(listenSock, readSet); for (i 0; i clientCount; i) { FD_SET(clientSocks[i], readSet); } tv.tv_sec 1; tv.tv_usec 0; ret select(0, readSet, writeSet, NULL, tv); if (ret SOCKET_ERROR) { printf(select error: %d\n, WSAGetLastError()); break; } if (ret 0) { continue; /* 超时处理定时任务 */ } if (FD_ISSET(listenSock, readSet)) { /* 有新连接进来 */ SOCKET newSock accept(listenSock, NULL, NULL); if (newSock ! INVALID_SOCKET clientCount 8) { clientSocks[clientCount] newSock; } } for (i 0; i clientCount; i) { if (FD_ISSET(clientSocks[i], readSet)) { char buf[256]; int n recv(clientSocks[i], buf, sizeof(buf) - 1, 0); if (n 0) { closesocket(clientSocks[i]); clientSocks[i] clientSocks[--clientCount]; i--; } else { buf[n] \0; printf(data: %s\n, buf); } } } } return 0; }逻辑说明每轮循环先把所有需要监听的 socket 加入readSet调用select等待可读事件。select返回后用FD_ISSET逐个检查哪个 socket 有数据。监听 socket 可读意味着有新连接客户端 socket 可读意味着有数据到达或对端关闭。参数说明select的第一个参数在 WinCE 上被忽略填 0 即可。timeval结构体控制超时时间填 NULL 表示无限等待。FD_SETSIZE决定了fd_set能容纳的最大 socket 数WinCE 上默认可能是 64如果连接数多需要重新编译库或者改用其他模型。3.3 WSAAsyncSelect把网络事件映射到窗口消息如果程序本身有窗口消息循环WSAAsyncSelect可以把 socket 事件变成窗口消息省去自己管理线程的麻烦。这个模型在 WinCE 上可用但前提是系统包含了 Winsock 2.2 组件。#define WM_SOCKET (WM_USER 1) int SetupAsyncSocket(HWND hWnd, SOCKET sock) { int ret; ret WSAAsyncSelect(sock, hWnd, WM_SOCKET, FD_READ | FD_WRITE | FD_CLOSE | FD_CONNECT); if (ret SOCKET_ERROR) { printf(WSAAsyncSelect failed: %d\n, WSAGetLastError()); return -1; } return 0; } /* 在窗口过程中处理 */ LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_SOCKET: { SOCKET sock (SOCKET)wParam; int event WSAGETSELECTEVENT(lParam); int error WSAGETSELECTERROR(lParam); if (error) { printf(socket error: %d\n, error); break; } switch (event) { case FD_READ: { char buf[256]; int n recv(sock, buf, sizeof(buf) - 1, 0); if (n 0) { buf[n] \0; printf(async recv: %s\n, buf); } } break; case FD_CLOSE: printf(connection closed\n); closesocket(sock); break; case FD_CONNECT: printf(connected\n); break; } } break; default: return DefWindowProc(hWnd, msg, wParam, lParam); } return 0; }逻辑说明WSAAsyncSelect把 socket 注册到指定窗口当FD_READ、FD_CLOSE等事件发生时系统会向窗口发送自定义消息WM_SOCKET。wParam携带 socket 句柄lParam的低字是事件类型高字是错误码。参数说明WM_USER 1是自定义消息的起始值确保不和系统消息冲突。FD_CONNECT事件在非阻塞connect完成后触发如果不需要异步连接可以不注册。注意WSAAsyncSelect会自动把 socket 设为非阻塞模式后续的recv调用不会阻塞。三种模型的选择建议连接数少、逻辑简单用阻塞加线程需要同时管多路连接用select程序本身是窗口架构、不想额外开线程用WSAAsyncSelect。我一般会先看设备上 Winsock 是哪个版本1.1 的话直接排除第三种。4. 避坑指南WinCE TCP 通信里那些让人抓狂的翻车现场这一章记录几个我在实际项目里踩过的坑每个都按“现象—原因—解决”的结构写清楚。这些问题的共同特点是在桌面 Windows 上不会出现或者表现完全不同很容易让人误判方向。4.1 connect 返回成功但 send 立刻失败现象connect返回 0 表示连接建立成功但紧接着调用send就返回SOCKET_ERROR错误码是WSAECONNRESET。原因WinCE 的 TCP 栈在某些定制镜像里对三次握手的处理比较激进connect返回成功只代表本地协议栈接受了请求并不保证对端真的可达。如果对端服务器在收到 SYN 后回了 RST本地connect可能已经返回但后续send才会暴露问题。解决不要信任connect的返回值发第一条数据后必须等对端确认。常见做法是设计一个应用层握手客户端发一条探测消息收到服务端回复后才认为链路真正可用。另外可以在connect之后设置SO_KEEPALIVE让协议栈帮忙检测死连接。4.2 recv 返回 WSAETIMEDOUT 而不是阻塞现象明明 socket 是阻塞模式recv却返回SOCKET_ERROR错误码WSAETIMEDOUT。原因WinCE 的 TCP 栈有默认的接收超时这个值可能比桌面 Windows 短很多。在某些镜像里如果一段时间没有数据到达协议栈会主动让recv超时返回而不是一直阻塞。解决用setsockopt显式设置SO_RCVTIMEO把它设成你期望的值。如果确实需要无限等待可以设一个很大的超时值然后在循环里判断WSAETIMEDOUT并继续调用recv。int timeout 5000; /* 5 秒 */ setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (char *)timeout, sizeof(timeout));参数说明SO_RCVTIMEO的单位是毫秒。设成 0 表示不超时但在 WinCE 上设 0 的行为不一定和桌面一致建议设一个明确的正值。4.3 多线程下 closesocket 导致程序崩溃现象主线程调用closesocket关闭连接后接收线程还在跑访问已释放的 socket 句柄程序直接挂掉。原因closesocket只是递减 socket 的引用计数如果有其他线程阻塞在这个 socket 上句柄不会立即失效但后续操作会返回错误。更危险的是如果线程在closesocket之后才进入recv可能访问到已经被复用的句柄。解决关闭连接的标准流程是三步——先shutdown(sock, SD_BOTH)通知协议栈不再收发然后等待接收线程退出可以用事件或标志位同步最后才closesocket。绝对不要在接收线程还在运行时直接closesocket。4.4 大数据量发送时 send 只发了一半现象调用send发送 10KB 数据返回值却是 4096剩下的数据不见了。原因TCP 是流式协议send返回的是实际拷贝进内核发送缓冲区的字节数不保证等于请求发送的字节数。在 WinCE 上发送缓冲区通常比桌面小这个问题更突出。解决写一个循环发送函数确保所有数据都发出去。int SendAll(SOCKET sock, const char *buf, int len) { int sent 0; int ret; while (sent len) { ret send(sock, buf sent, len - sent, 0); if (ret SOCKET_ERROR) { return SOCKET_ERROR; } sent ret; } return sent; }逻辑说明循环调用send每次发送剩余部分直到全部发完或出错。参数说明buf sent是剩余数据的起始地址len - sent是剩余长度。这个函数在阻塞 socket 上会一直等到发完在非阻塞 socket 上可能返回WSAEWOULDBLOCK需要配合select的可写事件使用。4.5 设备休眠唤醒后 socket 失效现象设备进入休眠再唤醒原来的 TCP 连接看起来还在但收发数据全部失败。原因WinCE 的电源管理会在休眠时关闭网络设备唤醒后网卡重新初始化但已有的 socket 不会自动恢复。协议栈可能已经悄悄释放了连接资源。解决监听系统的电源状态变化通知在唤醒后主动关闭旧连接并重新建立。如果业务允许也可以设计一个心跳机制定期发探测包发现连续多次无响应就重连。这个问题没有银弹只能靠应用层兜底。5. 让链路真正可靠心跳、重连与数据完整性的进阶处理把基本通信跑通只是第一步现场设备要连续跑几个月甚至几年链路可靠性才是真正考验功力的地方。这一章讲几个我在实际项目里验证过的进阶技巧都是围绕“怎么知道连接还活着”和“断了怎么自动恢复”这两个核心问题展开的。5.1 心跳包的设计间隔、内容和超时判定心跳包不是随便发个空包就行。间隔太短浪费带宽和电量太长则故障发现不及时。我的经验值是局域网内 10 到 30 秒发一次广域网或无线链路 5 到 10 秒发一次。心跳包内容建议带一个递增的序列号和时间戳方便排查是哪个环节丢了包。超时判定不能只看“有没有收到回复”还要考虑网络抖动。常见做法是连续 3 次心跳无响应才判定断线每次心跳等待回复的超时设为心跳间隔的一半。这样既能快速发现故障又不会因为偶发丢包误判。typedef struct { unsigned int seq; unsigned int timestamp; unsigned short cmd; unsigned short payloadLen; } HeartbeatPacket; int SendHeartbeat(SOCKET sock, unsigned int *seq) { HeartbeatPacket pkt; pkt.seq (*seq); pkt.timestamp GetTickCount(); pkt.cmd 0x0001; pkt.payloadLen 0; return SendAll(sock, (char *)pkt, sizeof(pkt)); }逻辑说明心跳包结构体固定长度方便对端解析。seq每次递增对端回复时把这个字段原样带回发送方就能算出往返时延。GetTickCount在 WinCE 上返回系统启动以来的毫秒数精度足够心跳使用。参数说明cmd字段用来区分心跳和其他业务消息payloadLen为 0 表示没有附加数据。结构体直接发送时要注意字节序问题如果两端平台不同需要统一转成网络字节序。5.2 自动重连的状态机实现重连逻辑最忌讳写成“断了就立刻重连”的死循环那样在网络持续不可用时会把 CPU 占满。正确做法是用状态机管理连接生命周期重连间隔采用退避策略。状态触发条件动作下一状态空闲启动初始化 socket连接中连接中connect 成功启动心跳已连接连接中connect 失败等待退避时间等待重连已连接心跳超时关闭 socket等待重连等待重连退避时间到重新 connect连接中退避策略建议从 1 秒开始每次失败翻倍上限 60 秒。连接成功后重置退避计数。这样网络短暂抖动时能快速恢复网络长时间故障时也不会疯狂重试。static int g_retryDelay 1; /* 初始 1 秒 */ void OnConnectFailed(void) { Sleep(g_retryDelay * 1000); g_retryDelay * 2; if (g_retryDelay 60) { g_retryDelay 60; } } void OnConnectSuccess(void) { g_retryDelay 1; /* 重置退避 */ }逻辑说明Sleep在 WinCE 上会让当前线程挂起指定毫秒数注意不要在 UI 线程里调用。退避上限设为 60 秒是为了在长时间断网时仍然保持一定的恢复能力同时不给系统造成负担。5.3 粘包与半包应用层协议怎么定TCP 是字节流协议没有消息边界。发送方连续调用两次send接收方可能一次recv就把两条消息都收到了这叫粘包也可能一条消息分两次才收完这叫半包。解决方式只有一种在应用层定义消息边界。常见做法有两种定长消息和长度前缀。定长消息适合消息类型固定的场景实现简单但浪费带宽。长度前缀更灵活在每条消息前面加一个固定字节数的长度字段接收方先读长度再读内容。/* 发送带长度前缀的消息 */ int SendMessage(SOCKET sock, const char *data, int len) { unsigned short netLen htons((unsigned short)len); if (SendAll(sock, (char *)netLen, 2) SOCKET_ERROR) { return SOCKET_ERROR; } return SendAll(sock, data, len); } /* 接收带长度前缀的消息返回实际消息长度 */ int RecvMessage(SOCKET sock, char *buf, int bufSize) { unsigned short netLen; int ret; ret RecvAll(sock, (char *)netLen, 2); if (ret ! 2) { return -1; } int len ntohs(netLen); if (len bufSize) { return -1; /* 消息太长缓冲区不够 */ } return RecvAll(sock, buf, len); }逻辑说明发送时先发 2 字节的长度字段再发实际数据。接收时先收 2 字节拿到长度再按长度收数据。RecvAll是SendAll的对应实现循环接收直到收满指定字节数。参数说明长度字段用unsigned short表示最大支持 65535 字节的消息对大多数工控场景够用。如果消息可能更大改用 4 字节长度字段。htons和ntohs负责字节序转换确保两端解析一致。5.4 用 Wireshark 抓包验证协议栈行为当通信出现玄学问题时抓包是最可靠的排查手段。WinCE 设备本身通常没有抓包工具常见做法是在同一网段的 PC 上跑 Wireshark把设备和服务器的流量镜像过来。抓包时重点看几个地方三次握手是否正常完成、有没有 RST 包、重传次数是否异常、窗口大小是否频繁归零。如果看到大量重传说明链路质量差或者发送方发得太快。如果看到窗口归零说明接收方处理不过来需要降低发送速率或者增大接收缓冲区。WinCE 的 TCP 栈在窗口管理上和桌面版本有差异默认接收窗口可能偏小。如果抓包发现窗口经常很小可以在代码里用setsockopt设置SO_RCVBUF来增大接收缓冲区。int rcvBuf 8192; setsockopt(sock, SOL_SOCKET, SO_RCVBUF, (char *)rcvBuf, sizeof(rcvBuf));参数说明SO_RCVBUF的单位是字节具体能设多大取决于系统配置。设置之后可以用getsockopt读回来确认实际生效的值系统可能会做调整。这套心跳加重连加长度前缀的组合我在多个现场设备上跑过最长的连续运行了两年多没出过链路问题。关键是要把异常路径都考虑到并且用抓包工具验证过协议栈的实际行为。希望帮到你。本文还有配套的精品资源点击获取