简介面向需要快速搭建WiFi数据收发场景的C语言开发者这套工程以电脑为服务端实现与手机等终端间的无线数据传输。代码逻辑简洁核心功能集中在少量C源文件中便于理解socket监听、数据接收与应答的基础流程也方便修改IP、端口或数据格式进行二次开发适合网络通信入门、原型验证及课程设计参考。压缩包共40个文件约44.36MB主体为Visual Studio解决方案sln/vcxproj/cpp及其编译调试产物exe/pdb/obj/tlog等构建记录与中间文件齐全可直接打开调试遇到环境问题也能从日志中定位。资源中还附有一款Android端EasyTCP.apk可安装到手机配合服务端完成真实WiFi联调省去另写客户端的步骤从而快速验证电脑与移动端之间的数据通路。目前已有330人学习下载整体属于轻量级可运行工程对希望快速上手C语言网络编程的嵌入式开发者与学生来说是个不错的参考样板。1. 为什么用C语言写一个WiFi数据收发服务器不少做嵌入式或者上位机开发的工程师第一次接触WiFi通信时都会先用网络调试助手验证硬件等要集成到自己的工具链里才发现现成工具改写成本很高。这个C语言服务器的价值在于它只用标准Winsock API就实现了电脑作为TCP服务端的完整骨架手机通过同一局域网直接连接并双向收发数据。整个工程代码量不大但把socket生命周期、阻塞收发、客户端管理等关键问题都暴露出来了适合拿来理解TCP通信的底层行为也能直接作为后期加协议、加业务逻辑的底座。源码包里的Server.cpp配合Visual Studio工程文件编译即可运行Debug目录下也有现成exe省去环境折腾的时间。对于想搞清“WiFi数据收发到底是怎么一步步走通的”的人这份代码比那些封装好的通信库更适合拆开看。2. Winsock初始化与监听套接字把电脑变成TCP服务器2.1 WSAStartup与socket网络栈的启动前提Winsock不是C语言标准库的一部分所以使用前必须先加载Windows sockets服务。代码里第一步通常是调用WSAStartup这个函数需要传入要求的版本号一般是2.2。很多初学者忽略返回值检查结果后面莫名其妙报10093错误就是没走到这步或者版本协商失败。示例初始化逻辑如下WSADATA wsaData; int result WSAStartup(MAKEWORD(2, 2), wsaData); if (result ! 0) { printf(WSAStartup failed: %d\n, result); return 1; }MAKEWORD(2,2)表示请求2.2版本wsaData会回传实际的Winsock实现信息。返回值0代表成功非零值需要查WSAGetLastError。随后创建套接字时要用AF_INET表示IPv4SOCK_STREAM表示TCPIPPROTO_TCP则是明确指定传输协议。这三个参数的组合决定了这是一个面向连接的可靠字节流套接字而不是UDP那种无连接的报文套接字。2.2 bind与listenIP、端口与等待队列套接字创建后还不具备服务能力需要绑定一个端口。bind接受一个sockaddr_in结构体其中sin_family必须与创建套接字时的AF_INET一致sin_port用htons转换成网络字节序sin_addr.s_addr通常设为INADDR_ANY也就是监听所有本地网卡地址。这一步很多人容易搞错的是端口范围小于1024的端口可能需要管理员权限建议选一个10000以上的端口比如8080或者9000。sockaddr_in serverAddr; serverAddr.sin_family AF_INET; serverAddr.sin_port htons(8080); serverAddr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(serverSocket, (SOCKADDR*)serverAddr, sizeof(serverAddr)) SOCKET_ERROR) { printf(bind failed: %d\n, WSAGetLastError()); closesocket(serverSocket); WSACleanup(); return 1; } if (listen(serverSocket, 5) SOCKET_ERROR) { printf(listen failed: %d\n, WSAGetLastError()); closesocket(serverSocket); WSACleanup(); return 1; }listen的第二个参数是等待队列长度这里填5表示内核最多缓存5个尚未被accept的连接请求。如果客户端连接速度超过你处理的速度多余请求会被拒绝。队列长度不是越大越好因为每个等待连接都占用系统资源对简单场景5到10足够了。绑定和监听失败时除了打印错误码还要记得关闭套接字并调用WSACleanup否则下次运行可能碰到端口被残留进程占用的情况。2.3 accept与多线程同时服务多个客户端listen之后套接字进入监听状态accept会从等待队列里取出一个客户端连接返回一个全新的套接字描述符专门用于和该客户端通信。原始监听套接字仍然可以继续接受新连接。这个模型决定了服务器天然具备多客户端潜力每accept到一个新连接就创建一个线程去处理收发。Server工程里的做法通常是一个无限循环配合accept然后对每个客户端套接字分派处理。while (1) { SOCKET clientSocket accept(serverSocket, (SOCKADDR*)clientAddr, clientAddrLen); if (clientSocket INVALID_SOCKET) { printf(accept failed: %d\n, WSAGetLastError()); break; } // 每个客户端一个线程避免阻塞后续accept HANDLE hThread CreateThread(NULL, 0, ClientThread, (LPVOID)clientSocket, 0, NULL); if (hThread NULL) { printf(CreateThread failed, socket will be closed\n); closesocket(clientSocket); } else { CloseHandle(hThread); } }accept默认是阻塞行为如果没有客户端连接它会一直卡在这里。多线程模式下主线程只负责接受连接收发操作放到ClientThread里独立执行。用CreateThread时注意把clientSocket转换成LPVOID传入线程函数内部再转回SOCKET句柄。这里有个容易踩的坑如果不保留线程句柄直接CloseHandle线程对象引用计数减一但线程本身还在运行这种用法是合法的如果忘记关闭句柄每个连接都会泄漏一个线程句柄长时间运行会导致句柄耗尽。3. 数据收发实现recv/send的阻塞与非阻塞边界3.1 recv的返回值与粘包判断TCP是字节流协议recv返回的是当前内核缓冲区内可读的字节数不保证一次返回完整的一包数据。很多从这个工程起步的人第一次遇到的现象是手机端发送十几个字节电脑只收到几个字节或者两包数据合并成一次返回。这不是代码写错而是TCP的流特性决定的。来看一段典型的接收循环char recvBuf[4096]; int totalRecv 0; while (1) { int bytesRecv recv(clientSocket, recvBuf totalRecv, sizeof(recvBuf) - totalRecv, 0); if (bytesRecv 0) { // 连接关闭或出错 break; } totalRecv bytesRecv; if (totalRecv 4) { int packetLen (recvBuf[0] 24) | (recvBuf[1] 16) | (recvBuf[2] 8) | recvBuf[3]; if (totalRecv 4 packetLen) { // 取出完整一包处理 // 移动剩余数据到缓冲区头部 memmove(recvBuf, recvBuf 4 packetLen, totalRecv - 4 - packetLen); totalRecv - 4 packetLen; } } }这段代码做了一个最朴素的带长度头的拆包框架。前4字节约定为网络字节序的包体长度packetLen表示后续数据长度。memmove必须用memmove而不是memcpy因为源和目的区域可能重叠。recv的返回值为0表示对端正常关闭SOCKET_ERROR表示出错这两种情况都要退出循环并关闭套接字。阻塞模式下recv会一直等到有数据到来才返回所以这个循环天然不会空转占满CPU。3.2 send与发送缓冲区send的行为比recv直观但同样受缓冲区限制。如果发送的数据量超过系统发送缓冲区剩余空间send会阻塞直到缓冲区有空位。对于小数据量通信直接send即可但大包发送时可能只发出部分字节。完整写法应该循环发送bool SendAll(SOCKET sock, const char* data, int length) { int sent 0; while (sent length) { int result send(sock, data sent, length - sent, 0); if (result SOCKET_ERROR) { printf(send failed: %d\n, WSAGetLastError()); return false; } sent result; } return true; }这里的data sent是C语言指针算术表示从已发送位置继续发送。send返回的是实际写入内核缓冲区的字节数可能小于你请求的长度所以必须循环。很多网络调试工具发送小包时看不出问题一旦传文件或者图片不循环发送就会产生数据缺失。另外send成功只代表数据进入了本机协议栈不代表对端已经收到TCP的确认机制由内核处理应用层如果想要确认对端处理完成需要业务层回包。3.3 简易协议拆包让收发的数据不串台裸字节流上直接解析业务数据非常容易出问题。比如手机端连续发送三条指令电脑端可能在一次recv里同时拿到三条指令的全部字节也可能一条指令被拆成两次返回。没有协议约束就无法判断数据边界。常见做法是固定包头或者长度前缀。下面是一个适合嵌入这个工程的缓冲区管理方式#pragma pack(push, 1) typedef struct { uint16_t magic; // 0x5A5A 用于校验 uint16_t version; // 协议版本 uint32_t length; // payload长度 } PacketHeader; #pragma pack(pop)#pragma pack(push, 1)让结构体按1字节对齐避免因为内存对齐导致结构体大小不等于实际字节数。解析时先用recv读满sizeof(PacketHeader)字节再用ntohs和ntohl把网络字节序转为主机字节序。定义magic字段可以快速判断当前字节流是否对齐到包头位置如果magic不匹配丢弃一个字节继续扫描实现简单可靠的流同步。在此基础上后续的二次开发只需要约定payload的格式比如JSON字符串或者二进制指令服务器端的解析框架就不用再动了。4. 用EasyTCP实测手机连电脑的完整流程4.1 EasyTCP连接配置工程里附带的com.shenyaocn.android.EasyTCP.apk是一个Android上的TCP测试工具正好用来充当客户端验证电脑服务器。手机和电脑必须处于同一个WiFi网络手机使用APK建立TCP客户端连接时需要三样信息电脑的IP、服务器的端口、连接模式。电脑的IP在命令行输入ipconfig查看无线局域网适配器的IPv4地址服务器端口则要和你代码里htons(...)设置的一致。EasyTCP里通常有TCP Server和TCP Client等模式这里选Client模式目标IP填电脑IP目标端口填8080。连接成功后EasyTCP会显示连接状态可以输入文本发送服务器端ClientThread里收到的字节就是这些文本的ASCII码。反过来服务器调用SendAll往客户端套接字发数据手机端也能直接看到。这里需要注意的是EasyTCP发送时可能带有换行符\r\n如果服务器端按行解析记得在逻辑里做过滤。4.2 服务器日志与状态码服务器端每次事件最好都打日志方便判断问题在哪个环节。收不到数据时先看服务器控制台是否打印了accept成功的提示再看客户端状态。Winsock的错误码能帮助定位问题常见状态码如下错误码含义出现场景10061目标主机积极拒绝端口没监听或防火墙拦截10060连接超时手机和服务器不在同一网段或IP填错10048地址已被占用上次程序没退出端口还被持有10049地址不可用bind的IP配置错误10054连接被重置对端强制关闭比如手机杀掉应用把WSAGetLastError()打印出来对照这张表就能快速缩小范围。最常见的问题是电脑防火墙拦截了监听的TCP端口导致手机端一直报10060超时。解决方案是在Windows防火墙高级设置里新增入站规则放行对应TCP端口或者临时关闭防火墙测试。安全起见建议只放行指定端口不要整个关闭防火墙。4.3 常见坑防火墙、IP配置与端口占用运行Server.exe时报10048错说明上一次的进程没有完全释放端口。用netstat -ano | findstr 8080查看占用PID然后到任务管理器结束对应进程或者改用另一个端口。另一种坑是同一台电脑有多个网卡比如同时启用虚拟机和物理网卡INADDR_ANY会监听所有地址对手机会话本身没问题但accept返回的客户端地址可能来自虚拟网卡网段容易让人困惑实际上连接是通的。手机连不上服务器时先做一个本机回环测试手机热点可以不开用电脑上的调试助手连接127.0.0.1:8080如果能连上说明监听逻辑没问题问题出在局域网或防火墙层面。然后再ping一下手机IP确认物理链路通畅。很多人忽略的是WiFi路由器如果开启了AP隔离手机和电脑之间会禁止互访这种情况下任何服务器代码都救不了需要在路由器后台关闭AP隔离或者换个网络。5. 二次开发把收发引擎改造成业务消息路由器5.1 增加心跳与断线重连TCP断开有时不会立刻通知应用层尤其是手机突然切到移动网络或者WiFi信号丢失服务器端要等很久才会发现对端已消失。常规做法是服务器每N秒发送一次心跳包连续几次没有收到客户端回应就认定连接失效并关闭套接字。在ClientThread里用select控制超时是一种有效方式fd_set readSet; FD_ZERO(readSet); FD_SET(clientSocket, readSet); timeval tv; tv.tv_sec 10; tv.tv_usec 0; int ret select(0, readSet, NULL, NULL, tv); if (ret 0) { // 10秒没有数据主动ping SendAll(clientSocket, ping, 4); } else if (ret 0) { // 有数据可读 // 调用recv读取 }select第一个参数在Windows上会被忽略传0即可。tv结构是超时时间这里设置10秒。如果select返回0表示超时没有任何可读数据此时发送心跳。连续两次心跳没有响应再关闭连接避免误杀正在处理长任务的客户端。这个模式比单纯依赖recv阻塞要健壮得多也不会额外创建定时器线程。5.2 把接收数据转发到其他模块当服务器同时连接多个设备时可能希望把手机A的数据转发给手机B或者把收到的数据通过串口下发给单片机。改造点在于ClientThread收到完整一包后不要直接处理而是放进一个全局消息队列由一个独立工作线程统一调度。这样收发线程只负责IO业务线程负责执行命令互不阻塞。队列可以用简单的环形缓冲加上互斥锁实现#define QUEUE_SIZE 256 typedef struct { int head; int tail; int count; char data[QUEUE_SIZE][512]; CRITICAL_SECTION cs; } MessageQueue; void QueueInit(MessageQueue* q) { q-head q-tail q-count 0; InitializeCriticalSection(q-cs); } int QueuePush(MessageQueue* q, const char* msg) { EnterCriticalSection(q-cs); if (q-count QUEUE_SIZE) { LeaveCriticalSection(q-cs); return -1; } strcpy_s(q-data[q-tail], sizeof(q-data[q-tail]), msg); q-tail (q-tail 1) % QUEUE_SIZE; q-count; LeaveCriticalSection(q-cs); return 0; }这个队列使用CRITICAL_SECTION保护读写的临界区。注意q-count的判断要放在锁内否则两个线程同时入队会覆盖同一个槽位。工作线程从队头取出消息执行对应的指令解析函数。这种解耦方式让收发引擎保持干净业务逻辑可以单独测试。后续增加数据库记录或者外发HTTP请求都只要在工作线程里扩展分支。5.3 性能验证方法完成基本收发后建议做一次流量压测确认服务器瓶颈。用手机端EasyTCP连续发送大数据包观察服务器CPU占用和内存变化。正常情况下一台普通电脑处理几千个并发连接没有问题但这个工程的多线程模型每客户端一线程连接数上去之后线程切换开销会明显增长。测量方法是在ClientThread里记录每秒钟收发的字节总数volatile long totalBytes 0; // 每次recv成功后累加 InterlockedExchangeAdd(totalBytes, bytesRecv);使用InterlockedExchangeAdd保证多线程环境下的原子加操作。每隔一秒在监控线程里打印一次totalBytes就能算出当前吞吐量。如果发现吞吐量上不去优先检查socket缓冲区大小默认8K不一定满足大批量传输需求可以用setsockopt调整int sendBufSize 64 * 1024; setsockopt(clientSocket, SOL_SOCKET, SO_SNDBUF, (const char*)sendBufSize, sizeof(sendBufSize));SO_SNDBUF设置发送缓冲区大小SO_RCVBUF同理。这个调整不能超过系统上限具体上限可以用同样的接口先查一遍。压测时注意观察是不是发送窗口满导致的阻塞如果是说明接收端消费速度跟不上优化方向就在手机端的recv循环频率和协议包的大小设计上。本文还有配套的精品资源点击获取
