简介这是一份基于LabWindows/CVI的TCP/IP网络编程实例面向需要掌握CVI环境下网络通信能力的测控与自动化软件开发者。压缩包共45个文件以C源码、头文件、UIR界面文件、PRJ工程与CWS工作区为主另附可执行文件与调试文件整体约370KB目录按客户端、服务器、消息处理等模块清晰划分便于按需查阅。已有216人参与学习/下载。内容覆盖多线程TCP服务器与客户端实现、消息读写与界面交互设计涉及套接字创建、连接建立、数据收发、超时与异常处理等关键环节工程内附可运行程序可直接运行以观察通信流程。借助这套实例读者能理解从socket初始化到多连接并发处理的完整思路不仅适合入门也可作为CVI网络编程的日常参考快速迁移至真实测控项目中。1. CVI做TCP通信为什么这个“老环境”仍然值得折腾一条测试台半夜停了排查半天发现是上位机的CVI程序连不上新换的仪器。打开源码一看网络部分还是二十年前的模板。其实那块板的通讯协议很简单TCP/IP四层模型走到底应用层丢一行ASCII命令到网口就行。真正麻烦的是在LabWindows/CVI里写TCP/IP通信和你在Linux、Windows控制台里写socket不是一回事它有自己的一套同步和回调模型参数、超时、线程全要重新适应。下面专门把CVI环境下的socket编程、客户端服务器收发、粘包排查和断线重连讲透。适合正在维护CVI上位机、或者打算用LabWindows做远程数据采集的工程师读。2. 开发环境与两种socket模型选错模型后面全是翻车现场先把环境确认一遍再决定用哪条路写TCP。很多人上来就复制一份老代码结果连库都没链上卡在编译阶段。其实CVI支持两种完全不同的socket编程路径一种是用CVI自带的TCP库函数封装得比较“仪器化”另一种是直接用Winsock2标准API跟C网络编程里的做法几乎一样。两条路各有拥趸选错了后面全是翻车现场。2.1 环境准备CVI安装、库路径与“最小工程”验证安装CVI时记得勾选完整组件TCP的支持库包含在主程序里不需要额外许可。装完后建议先建一个空工程把winsock2头文件引进来确认链接层没问题再开始写业务代码。CVI的编译器对Windows SDK的支持比较直接只要在源文件里写一行预处理指令就能让链接器自动带上WS2_32库不需要在IDE里翻菜单找依赖项。#include ansi_c.h #include winsock2.h #include windows.h #pragma comment(lib, ws2_32.lib) static WSADATA wsa_ctx; int network_init(void) { int err; err WSAStartup(MAKEWORD(2, 2), wsa_ctx); if (err ! 0) { printf(WSAStartup failed, err%d\n, err); return -1; } printf(Winsock2 initialized OK\n); return 0; }这段代码先初始化Winsock2环境。MAKEWORD(2, 2)表示请求使用2.2版本的socket实现绝大多数Windows系统都支持。WSAStartup返回0才表示成功返回值是错误码时直接换算成十六进制去查系统网络错误表就行。CVI的工程目标类型默认是Windows GUI程序这不会影响Winsock2的使用。需要注意的一点是头文件的包含顺序winsock2.h一定要放在windows.h之前。CVI的头文件环境严格遵循C标准如果顺序反了编译器会报一堆“redefinition”的提示这个错误信息看起来像玄学实际就是头文件冲突。2.2 CVI自带TCP库与Winsock2同步、回调、事件三种模型的取舍CVI自带TCP库的函数封装得比较“上头”。它提供了一组以TCP开头的函数例如连接设备用ConnectTCP收发数据用ReadTCP和WriteTCP服务端监听用ServerTCPListen。这些函数的共同点是都带一个超时参数单位是毫秒。如果不填超时调用就会一直阻塞到有数据为止。CVI的同步socket在等待期间不会让出CPU界面会卡死所以很多老代码会选择把收发逻辑放到独立线程里跑。#include tcpupp.h int handle, err, rbytes; char buffer[512]; err ConnectTCP(192.168.1.20, 5025, handle, 2000); if (err 0) { printf(connect failed, code%d\n, err); return -1; } err WriteTCP(handle, *IDN?\n, 6, rbytes, 1000); err ReadTCP(handle, buffer, 511, rbytes, 2000); if (err 0) printf(read timeout or error\n); else printf(recv %d bytes: %s\n, rbytes, buffer);上面这段是CVI自带库的典型写法。注意ConnectTCP的第三个参数传的是int变量的地址函数会把已建立连接的句柄值写进去后面所有收发操作都拿这个句柄干活。超时参数是最后一个整数单位毫秒设成2000就是最多等2秒。ReadTCP读到的数据长度会写进rbytes如果读超时函数返回负数。用Winsock2则是另一套风格。结构体、系统调用、错误码全跟Linux socket编程对齐移植代码方便网上资料多到刷不完。缺点是没有超时参数默认的recv在没数据时阻塞到天荒地老必须自己用select或者设置socket选项来控超时。对比项CVI自带TCP库Winsock2 API头文件tcpupp.h等winsock2.h超时控制函数参数直接给毫秒需select或SO_RCVTIMEO回调机制支持注册事件回调需自己封装跨平台可移植性只限CVI环境可平移到VS、MinGW学习成本低仪器风格中需懂socket我给个选型建议如果你手头只有一台仪器要连协议固定图省事就直接用CVI自带库。如果要做服务器、多客户端、自定义协议帧或者后续想把代码移植到其他平台老老实实上Winsock2。下面的章节全部围绕Winsock2写因为客户端服务器都能跑踩坑经验也通用。3. 从零写CVI网络编程客户端连接设备、收发数据与超时控制客户端是所有CVI网络编程的基础。你要连的可能是示波器、电源、数据采集卡也可能是自己写的一个服务器端程序。核心流程很简单建socket、connect、send、recv、close。难的是把超时、半包、断线重连这些细节处理好。这一章给出一套能直接放进CVI工程里跑的完整代码。3.1 一个可编译的CVI客户端代码connect、send、recv参数全解释下面这段代码放在main.c里就能编译。它连接一个IP为192.168.1.20、端口为5025的TCP服务端发送一行ASCII命令然后读取响应并打印。代码里加了三处资源清理closesocket关socketWSACleanup销毁Winsock环境避免长期运行后句柄泄漏。#include ansi_c.h #include winsock2.h #include windows.h #include string.h #pragma comment(lib, ws2_32.lib) int tcp_ask(const char *ip, unsigned short port) { WSADATA wsa; SOCKET s; struct sockaddr_in addr; char req[] SYST:MEAS?\n; char rsp[256]; int n; if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) return -1; s socket(AF_INET, SOCK_STREAM, 0); if (s INVALID_SOCKET) { WSACleanup(); return -1; } memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.s_addr inet_addr(ip); if (connect(s, (struct sockaddr *)addr, sizeof(addr)) SOCKET_ERROR) { printf(connect failed, err%d\n, WSAGetLastError()); closesocket(s); WSACleanup(); return -1; } if (send(s, req, (int)strlen(req), 0) SOCKET_ERROR) { printf(send failed\n); closesocket(s); WSACleanup(); return -1; } memset(rsp, 0, sizeof(rsp)); n recv(s, rsp, sizeof(rsp) - 1, 0); if (n 0) { printf(recv %d bytes: %s\n, n, rsp); closesocket(s); WSACleanup(); return 0; } printf(recv returned %d, err%d\n, n, WSAGetLastError()); closesocket(s); WSACleanup(); return -1; } int main(void) { return tcp_ask(192.168.1.20, 5025); }AF_INET指定IPv4地址族SOCK_STREAM表示流式字节流对应TCP协议。htons(port)是把端口号从主机字节序转换成网络字节序这一步漏了会出现“端口永远不对”的毛病。inet_addr(ip)把点分十进制的IP字符串转成32位整数。send的第四个参数flags绝大多数场景填0即可recv同理。有个细节recv一次未必能收完整个响应。TCP是字节流不保证一次交付可能分两三次到达。上面代码为了演示简化成一次recv真正干活时要循环接收直到收到预期长度或遇到结束标志。这个坑后面避坑章节详细说。3.2 阻塞读改造成select超时控制与断线重连上面代码里recv在没数据时会一直阻塞界面线程会卡死。CVI的界面消息循环很敏感一旦主线程被阻塞超过几秒整个界面会变成“黑匣子”鼠标点哪里都没反应。解决思路有两个要么把收发丢进独立线程要么用select给recv加超时。fd_set read_fds; struct timeval timeout; int rc; FD_ZERO(read_fds); FD_SET(s, read_fds); timeout.tv_sec 3; timeout.tv_usec 0; rc select(0, read_fds, NULL, NULL, timeout); if (rc 0) { printf(read timeout after 3s\n); } else if (rc SOCKET_ERROR) { printf(select error, err%d\n, WSAGetLastError()); } else { n recv(s, rsp, sizeof(rsp) - 1, 0); if (n 0) printf(peer closed connection\n); else printf(recv %d bytes\n, n); }select的第一个参数在Windows平台会被忽略习惯性传0就行。fd_set是socket集合FD_SET(s, read_fds)把要监视的socket放进去。timeval结构体里tv_sec秒、tv_usec微秒加起来就是总超时时间。select返回0表示超时没数据返回正整数表示有socket可读返回SOCKET_ERROR表示出错。注意每次调用select前都要重新给timeval赋值因为select会修改这个结构体的值。断线重连的逻辑也别写在recv后面单独封装一个连接函数做成“失败重试”的循环重试间隔用Sleep(500)控制避免疯狂重连把设备打死。4. 在CVI里搭服务器监听、多连接与线程化接收客户端写完服务器是另一座山头。CVI上位机经常要承担“网关”角色下位机主动连接上来上报数据上位机接收后显示、存储、转发。这种场景对服务器端的要求是长期稳定监听、能同时接入多个设备、任意一路断线不能影响其他人。这一章给出一套最小服务器代码再讲多连接管理的取舍。4.1 用bind、listen、accept在CVI里搭一个最小服务器服务器端流程比客户端多了三步bind绑定端口、listen进入监听状态、accept等待客户端接入。下面的代码在9000端口监听每接入一个客户端就循环打印它发来的数据并回复“OK”。static void handle_client(SOCKET c) { char buf[512]; int n; while ((n recv(c, buf, sizeof(buf) - 1, 0)) 0) { buf[n] 0; printf(received: %s\n, buf); send(c, OK\n, 3, 0); } printf(client disconnected\n); closesocket(c); } int run_server(unsigned short port) { WSADATA wsa; SOCKET srv, cli; struct sockaddr_in addr; int addr_len sizeof(addr); if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) return -1; srv socket(AF_INET, SOCK_STREAM, 0); memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(port); if (bind(srv, (struct sockaddr *)addr, sizeof(addr)) SOCKET_ERROR) { printf(bind failed, err%d\n, WSAGetLastError()); return -1; } if (listen(srv, 4) SOCKET_ERROR) { printf(listen failed\n); return -1; } printf(server listening on port %d\n, port); while (1) { cli accept(srv, (struct sockaddr *)addr, addr_len); if (cli ! INVALID_SOCKET) { printf(client connected\n); handle_client(cli); } } closesocket(srv); WSACleanup(); return 0; }htonl(INADDR_ANY)表示绑定本机所有网卡地址这样无论设备从哪个网卡连进来都能被监听到。listen的第二个参数是backlog控制未完成accept的连接排队数。仪器类应用并发量不高设成4就够了。accept会阻塞直到有一个客户端发起TCP握手。握手完成后返回一个新的socket句柄后续收发都走这个新句柄监听socket继续等着接下一个客户端。这个版本的缺陷很明显handle_client内部是死循环如果客户端不断开accept永远不会执行第二次只有第一个设备能连上。真实场景必须引入多连接处理。4.2 多连接管理每连接一线程 vs 单线程select vs CVI回调处理多客户端有三种常见做法复杂度递增但能力也在递增。第一种是每连接一个线程。CVI里用CmtNewThread开线程函数签名简单线程函数只是一个void *参数的入口。每个客户端接入后创建新线程线程里跑收发循环。#include utility.h int CVICALLBACK client_thread(void *args) { SOCKET c (SOCKET)(int)args; handle_client(c); return 0; } /* 在accept返回cli后调用 */ int thread_handle; CmtNewThread(client_thread, (void *)cli, thread_handle);CmtNewThread的第一个参数是线程函数指针第二个参数是传给线程的任意指针第三个参数是输出线程句柄。把socket值通过参数传进线程线程内部独立收发。这种方式代码最简单适合连接数不超过二三十个的场景。缺点是每连接一个线程资源占用高上百个连接时系统调度会吃力。第二种是单线程select轮询。把服务端socket和所有客户端socket放进一个fd_set循环select哪个socket可读就处理哪个。好处是只用一个线程连接数量可以到几百坏处是代码复杂度明显上升每个socket都要维护独立收包缓冲区。第三种是CVI自带库的回调模式。可以注册一个网络事件回调CVI的消息循环收到网络事件后自动调用你的回调函数。这个方案对CVI工程结构最友好不需要自己管理线程但调试的时候你会发现它不太直观回调触发时机、数据切分都由运行环境控制出了故障很难离线复现。方案连接上限代码复杂度适合场景每连接一线程30左右低仪器数量少协议简单select轮询数百中大量设备上报数据CVI自带回调中等低但难调试GUI上位机、实时性要求不高我个人习惯少于10台设备用每连接一线程省心好排查设备超过20台就用select轮询至少你能控制每个socket的读超时。5. CVI网络编程避坑清单连不上、读不到、数据错乱的三类血泪网络编程里十次故障有八次不是协议问题而是被细节绊住。我把CVI网络编程里最常踩的坑按阶段拆开每一条都按“现象、原因、解决”写清楚。遇到问题直接对号入座能省下一整晚排查时间。5.1 连接建立阶段防火墙、监听状态与端口字节序坑一connect超时但设备在同一网段能ping通。这是CVI网络编程里最经典的问题九成是Windows防火墙拦住进程。CVI编译出来的EXE在第一次监听端口或连接外部端口时系统会弹一个防火墙提示框如果开发机上没人点“允许”程序就会被静默拦截。现象就是connect一直卡到超时程序不崩溃但就是连不上。排查时先在命令行执行netstat -ano | findstr 9000如果看到LISTENING状态说明端口已经起来了问题出在路径上的设备。如果什么输出都没有检查程序是否调用了listen。提示在开发机上把CVI生成的EXE加入防火墙白名单再把专用网络设为允许。测试通了之后再按最小权限原则收窄规则。坑二指定端口连接服务端看到的却是不相干端口。这个坑出现在服务端打印客户端地址时如果客户端addr.sin_port没做字节序转换打印出的端口会跟实际不符。TCP/IP四层模型的传输层要求端口在网络字节序下传输忘了htons就会出现“看着对其实不对”的诡异现象。解决方法是每次构建struct sockaddr_in都用memset清零然后固定写三行sin_family、sin_port、sin_addr不要图省事只赋值其中两个。5.2 数据传输阶段空读、半包、回调重复读坑三recv返回0程序误认为故障退出。recv返回0的含义是“对端优雅关闭了连接”不是“没有数据”。很多CVI程序把返回0当成错误处理结果设备端主动睡了或关了连接上位机就打印一堆错误日志。解决方法是判断返回值0走连接关闭分支-1才去查WSAGetLastError。如果设置了socket的SO_RCVTIMEO超时返回-1且错误码是WSAETIMEDOUT这种超时属于正常现象业务逻辑要跟“连接断开”严格区分。坑四收到的数据是半包或者多帧粘在一起。TCP是字节流只保证发送顺序不保证消息边界。设备一次发送100字节recv可能先收到60字节再收到40字节。很多新手把recv返回值当完整消息长度直接解析结果解析出来的第一个字段就对不上。解决思路是在应用层定义帧格式前4字节放数据长度后面跟具体内容接收方先读足4字节知道长度再循环读取直到收满。int recv_exact(SOCKET s, char *buf, int need) { int got 0; int n; while (got need) { n recv(s, buf got, need - got, 0); if (n 0) return 0; /* 连接断开或出错 */ got n; } return 1; }recv_exact的要点在于每次recv只收剩余部分的数据buf got指针偏移让新数据紧跟着旧数据排布直到凑够need个字节。这样收发双方只要约定好“前四个字节是长度”就能彻底解决半包和粘包。坑五CVI自带库的回调和手动读数据混用导致数据错乱。如果你用了CVI的事件回调方式收发TCP又在同一连接上调用ReadTCP或recv逻辑两边的内部缓冲会打架运行环境已经把socket数据读走了手动读只能读到剩余零头甚至返回超时。解决办法是二选一要么全走回调要么全走阻塞式收发。别在一个socket上混用两套读出机制。6. 把CVI的TCP模块做成可复用组件自动重连与iperf验证代码写多了你会发现客户端设备的连接逻辑永远是同一套骨架连接、收发、判活、重连。与其每个工程复制粘贴不如封装成一个小组件放到CVI的User Library里长期复用。6.1 TcpClient组件骨架封装的重点是“先把socket句柄托管起来”所有操作都走组件接口避免业务代码里散落一堆closesocket和WSACleanup。下面这个结构体是组件的最核心部分typedef struct { SOCKET fd; int connected; char ip[32]; unsigned short port; } TcpClient;组件接口就四个函数tcp_client_connect负责建连失败返回错误码tcp_client_send和tcp_client_recv对应用层收发内部处理日志tcp_client_close做统一清理并把connected置0。断线重连放在业务线程的循环里每次调用tcp_client_recv返回0就触发重建连接重连前先调tcp_client_close确保旧句柄被释放。6.2 用iperf做端到端验证与吞吐观测组件写完先别急着接真实设备先用iperf确认链路通不通。在开发机上把CVI服务器程序跑起来另一台电脑执行iperf3 -c 开发机IP -p 9000 -t 10这样的命令观察是否有持续流量。如果iperf能跑到接近网卡速率说明TCP链路稳定、端口放行、系统网络栈正常再接入CVI业务代码就能排除底层网络因素。提示iperf测试时关掉防火墙和杀毒软件否则测出来的吞吐量会忽高忽低干扰判断。最后说一个我自己的教训。早些年做设备数据采集上位机要24小时连续运行。程序跑几天后界面像黑匣子一样卡死排查发现每次断线重连后旧socket句柄没关新连接又创建一个句柄数量越积越多系统资源耗尽。从那以后我定了个规矩所有TCP连接的出口都走统一的close函数且close之后把socket值改成INVALID_SOCKET让“重新连接”的代码必须创建全新句柄这样才能彻底释放资源。这个习惯一直用到现在线上系统再没出现过句柄泄漏。希望帮到你。本文还有配套的精品资源点击获取
