简介:基于LabWindows/CVI的TCP/IP网络编程示例压缩包,面向需要在本集成环境下构建测控与自动化通信应用的开发者,解决在CVI中实现可靠TCP连接、数据收发与并发处理的核心问题。压缩包共45个文件,包含C源代码、头文件、UIR界面文件、工程与工作区配置、调试可执行文件及依赖数据库等,整体约370KB,目录结构清晰,既适合初学者对照学习套接字API,也适合工程师直接移植参考。资源已获得220人次浏览学习。示例内容覆盖多线程服务器与客户端、消息读写模块,并详细展示了socket创建、bind监听、连接请求、send/recv数据交换、错误处理以及多线程并发管理等网络编程关键环节。通过研读源码和界面工程配置,读者可快速掌握在LabWindows/CVI环境下搭建稳定TCP通信应用的完整流程,同时获得可直接扩展的项目框架与排错思路。
1. 一套 LabWindows/CVI 的 TCP/IP 工程包:让 socket 网络编程从函数片段变成能跑的工程
在 LabWindows/CVI 里做 TCP/IP 网络编程,最尴尬的不是不会写 socket 代码,而是缺一套能直接编译、直接跑起来的参考工程。tcp.rar 这份资源正好补上这个缺口:压缩包里是一整套 CVI 工作区,包含多线程 TCP 服务端、多线程客户端、最小 client/server 对子,以及 MessageReader、MessageWriter 两个消息读写模块,全部是 C 语言源码加 .uir 界面。搞仪器数据采集、远程控制、上位机联调的人,拿这份代码当模板改起来最省事;它同时适合刚接触 LabWindows/CVI 网络编程的新手,和需要一套多线程 TCP 通信底座的熟手。
2. 拆开 tcp.rar 的文件结构:三组文件对应三种网络工程形态
2.1 文件清单:服务端、客户端、公共模块是这么分组的
tcp.rar 解压之后不是一坨散文件,按职责能清楚分成三组,看后缀也基本能猜出各自干什么。
| 分组 | 文件 | 对应工程 | 职责 |
|---|---|---|---|
| 服务端 | server.c / server.h | server.prj / server.cws | 最小 TCP 服务端,单连接监听骨架 |
| 服务端 | MultiThreadingServer.c / .h | MultiThreadingServer.prj | 多线程服务端,每连接一个工作线程 |
| 服务端 | MultiClientServer.c / .h | MultiClientServer.prj / .cws | 多客户端接入的服务端变体 |
| 客户端 | client.c / client.h | client.prj / client.cws | 最小 TCP 客户端 |
| 客户端 | MultiThreadingClient.c / .h | MultiThreadingClient.prj | 多线程客户端,并发发送/接收 |
| 公共模块 | MessageReader.c / .h | MessageReader.prj | 消息读取与解析 |
| 公共模块 | MessageWriter.c / .h | MessageWriter.prj | 消息封装与发送 |
每个工程都带了自己的 .uir 界面文件和 .prj 工程文件,这意味着它不是单纯的控制台 demo,而是带控件的完整 CVI 程序。压缩包里还有一批以_dbg结尾的产物:client_dbg.exe、server_dbg.exe、client_dbg.cdb、server_dbg.cdb,加上 dependencies.bri、resources.res 和 cvibuild 目录下的 build.ini。cdb 是 CVI 的调试数据库文件,exe 带_dbg后缀说明这套源码被完整编译并且调试过,不是随手贴的片段——这一点对新手很重要,遇到编译报错时先怀疑自己的环境,不要怀疑代码本身。
2.2 加载顺序:先开 .cws 工作区,再开 .prj 工程
LabWindows/CVI 的工程组织分两层,很多人刚拿到压缩包会直接去双击 .c,然后发现找不到界面文件,一脸懵。CVI 的 .cws 是工作区(Workspace),可以挂多个 .prj;.prj 才是真正的编译单元,引用 .c、.h、.uir 和外部库。
我一般按这个顺序走:
- 先打开 MultiClientServer.cws 或 MultiThreading.cws,工作区会把你需要的几个 .prj 一起加载进来。
- 在 Workspace 窗口里确认当前活动工程是哪一个,CVI 的 Build 菜单只编译活动工程。
- 右键工程名选 Properties,检查 Target Name 和输出目录,cvibuild 下的 build.ini 就是这一层的配置记录。
- 确认 Source Files 列表里 .c 文件都在,User Interface Files 里 .uir 都在,再按 F7 编译。
不要从 MessageReader.prj 这种公共模块工程开始编译,它通常被其他工程引用,单独编译没有意义。正确路径是先从 server.cws 或者 MultiThreading.cws 进,让 CVI 自己把依赖关系梳理出来。
2.3 三条上手路径:按你的目标选起点
这套包里的工程不是平行关系,而是三种学习深度,我建议按目标选起点:
- 只想看最小闭环:开 server.cws + client.cws,两个工程各起一个调试器,先跑通一次连接再说。
- 想学多线程服务端:开 MultiThreading.cws,重点看 MultiThreadingServer.c 里 accept 之后怎么把连接派发到工作线程。
- 想研究消息边界:开 Message.cws,把 MessageReader 和 MessageWriter 的工程加载进来,看它们怎么处理粘包和半包。
三条路不冲突,但别想一口吃成胖子。这个压缩包的价值恰恰在于它把不同复杂度的网络工程拆成了独立的 .cws,你可以只带其中一个工程走,不会被迫面对一堆无关文件。
3. 把 Berkeley Socket API 接进 CVI 界面:socket 网络编程的最小闭环
3.1 函数名映射:从 socket/bind/listen 到 sock_socket/sock_bind/sock_listen
LabWindows/CVI 的 NI Socket Library 在 socket.h 里提供了以sock_开头的函数集,它和 Berkeley Socket API 是逐一一对应对应的。也就是说你在大学课本上学的 C 语言 socket 网络编程骨架,在这里只需要换一层皮就能用。
| Berkeley Socket API | LabWindows/CVI 对应函数 | 作用 |
|---|---|---|
| socket() | sock_socket() | 创建套接字 |
| bind() | sock_bind() | 绑定本机地址和端口 |
| listen() | sock_listen() | 进入监听状态 |
| accept() | sock_accept() | 接受客户端连接 |
| connect() | sock_connect() | 发起连接请求 |
| send() | sock_write() | 发送数据 |
| recv() | sock_read() | 接收数据 |
| close() | sock_close() | 关闭套接字 |
为什么 CVI 要包一层而不是直接用 BSD 函数?因为 CVI 的函数要兼顾它自己的错误处理机制和跨平台编译——同一套源码在 Windows 的 WinSock 和 PharLap 实时系统上都能跑。对应用开发者来说,记住一个规律就行:读用sock_read,写用sock_write,别混成 read/write,CVI 的标准库函数操作的是文件句柄,操作套接字时行为不完全一致,踩过的人都知道这坑多疼。
3.2 服务端最小实现:bind、listen、accept 的参数怎么填
从最小工程的 server.c 里能提炼出服务端的固定套路,我拆出来大概是这个样子:
#include <socket.h> #include <ansi_c.h> #include <utility.h> #define SERVER_PORT 5000 int main(int argc, char *argv[]) { int serverSock, clientSock; struct sockaddr_in addr = {0}; int opt = 1; // 1. 创建 TCP 套接字 serverSock = sock_socket(AF_INET, SOCK_STREAM, 0); if (serverSock < 0) { printf("socket 创建失败\n"); return -1; } // 2. 端口复用,跳过 TIME_WAIT 的坑 setsockopt(serverSock, SOL_SOCKET, SO_REUSEADDR, (char *)&opt, sizeof(opt)); // 3. 绑定本机端口 addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 addr.sin_port = htons(SERVER_PORT); if (sock_bind(serverSock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { printf("bind 失败\n"); return -1; } // 4. 进入监听,backlog 取 8 足够 sock_listen(serverSock, 8); while (1) { clientSock = sock_accept(serverSock, NULL, NULL); if (clientSock < 0) continue; // 这里把 clientSock 交给消息处理逻辑 sock_close(clientSock); } }这段逻辑是固定的,几个参数值得说明一下。INADDR_ANY表示监听本机所有网卡,如果你的仪器有多个网口,用inet_addr("192.168.1.10")显式指定监听哪一个,可以让其他网卡上的请求直接被内核拒掉,省一层业务判断。backlog参数填 8 是这套工程里的风格,它定义的是内核里等待 accept 的队列长度,不是最大连接数。sock_accept的第二个和第三个参数可以传入对端地址结构来拿到客户端 IP 和端口,做日志和权限控制时要用到,最小工程里填NULL就行。
提示:
sock_accept失败时返回的套接字值小于 0,这时候直接 continue 继续循环,不要对它做读写。
3.3 客户端最小实现:connect 是阻塞的,超时要自己做
客户端的骨架比服务端简单,但有一个点几乎所有新手都会忽略——sock_connect在默认情况下是阻塞的,对端不可达时会卡在那里很久。
#include <socket.h> #include <ansi_c.h> #define SERVER_IP "127.0.0.1" #define SERVER_PORT 5000 int ConnectAndSend(const char *msg) { int sock = -1; struct sockaddr_in addr = {0}; sock = sock_socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) return -1; addr.sin_family = AF_INET; addr.sin_addr.s_addr = inet_addr(SERVER_IP); addr.sin_port = htons(SERVER_PORT); if (sock_connect(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { sock_close(sock); return -1; } sock_write(sock, msg, (unsigned int)strlen(msg), 0); sock_close(sock); return 0; }inet_addr把点分十进制字符串转成网络字节序整数,这是 C 语言网络编程里最常见的写法。ntohl、htons这些字节序转换函数在 CVI 里一样能用,老版本 CVI 对它们的声明放在ws2defs.h里,新版直接包含 socket.h 就行。要加超时的话,sock_connect之前用setsockopt设置SO_SNDTIMEO,或者把套接字设成非阻塞再配合 select 判断,CVI 的sock_ioctl也提供非阻塞模式开关——这套工程里默认是阻塞写法,做上位机时强烈建议改成超时可控的版本,不然用户界面会跟着一起卡死。
3.4 MessageReader 与 MessageWriter:把粘包问题关在公共模块里
TCP 是字节流协议,它不知道什么是"一条完整消息",后果就是数据粘包:你 send 了三次,对方一次 recv 全收到;或者反过来,一次 recv 只收到半条。这套包里单独拆出 MessageReader 和 MessageWriter 两个模块,意图很明显——消息边界问题不该散落在业务代码里,而应该集中在公共层解决。
最常见的消息封装是「4 字节长度头 + 数据体」,读写两侧约定好这个格式:
#define MSG_HDR_LEN 4 // 发送侧:先写 4 字节大端长度,再写数据体 int MessageWrite(int sock, const char *data, unsigned int len) { unsigned char hdr[MSG_HDR_LEN]; hdr[0] = (unsigned char)(len >> 24); hdr[1] = (unsigned char)(len >> 16); hdr[2] = (unsigned char)(len >> 8); hdr[3] = (unsigned char)len; if (sock_write(sock, hdr, MSG_HDR_LEN, 0) != MSG_HDR_LEN) return -1; if (len > 0 && sock_write(sock, data, len, 0) != (int)len) return -1; return 0; }对应的读取侧要做的就是先 recv 4 个字节,解析出长度,再循环 recv 直到收满长度字节。注意sock_write不保证一次写完全部数据,返回值和传入长度不相等时要记录偏移量继续写,这是 socket 网络编程里最容易翻车的细节之一。MessageReader 和 MessageWriter 把这段逻辑封装好后,业务层永远只处理完整的一帧,粘包半包就成为上层看不见的问题。
4. MultiThreadingServer 的多线程模型:从 accept 到工作线程的生命周期管理
4.1 为什么 accept 之后必须开线程
单线程服务端有一个硬伤:处理完一个连接之前,sock_accept不会再返回下一个连接。如果第一个客户端连上之后迟迟不发数据,服务端就卡在sock_read上,后面的客户端全部挂在 backlog 队列里排队,这在仪器联调场景里是不能接受的——一个探针误连就能把整个系统堵死。
MultiThreadingServer 的解法是典型的 one-thread-per-connection:accept 拿到新连接就开一个线程专门伺候它,主循环立即回到 accept 继续接客。CVI 的多线程 API 头文件是 utility.h,核心函数是CmtNewThread,它要求线程函数签名是int CVI_CALLBACK func(void *arg)。这里有个关键点:CmtNewThread的第二个参数是void *,不是整数,直接传 socket 句柄属于类型强转的偷懒写法,我建议用一个结构体包一层:
typedef struct { int clientSock; struct sockaddr_in clientAddr; // accept 时拿到的对端信息 } ConnArg; int CVI_CALLBACK ConnectionHandler(void *arg) { ConnArg *pArg = (ConnArg *)arg; char buf[1024]; int n; while ((n = sock_read(pArg->clientSock, buf, sizeof(buf) - 1, 0)) > 0) { buf[n] = '\0'; // 收满一帧后交给业务处理,见 MessageReader MessageProcess(pArg->clientSock, buf, n); } // 对端关闭或出错,清理资源 sock_close(pArg->clientSock); free(pArg); // 线程函数返回前释放 return 0; } void main(void) { ConnArg *pArg; int threadID; int clientSock; // ... socket 创建、bind、listen ... while (1) { clientSock = sock_accept(serverSock, NULL, NULL); if (clientSock < 0) continue; pArg = malloc(sizeof(ConnArg)); pArg->clientSock = clientSock; if (CmtNewThread(ConnectionHandler, pArg, 0, &threadID) < 0) { sock_close(clientSock); free(pArg); } } }CmtNewThread的参数里,第三个threadOptions填 0 表示常规线程,第四个参数threadID留作以后CmtWaitForThreadPoolFunctionCompletion或CmtDiscardThreadID用。注意pArg的释放责任在哪个线程——在主线程 malloc,在工作线程 free,这个约定必须写清楚。如果偷懒直接传(void *)clientSock,线程函数里再转回 int 用,多半能跑,但一旦你想额外传客户端 IP 或者超时开关进去,这个方案立刻崩盘,老老实实上结构体才是稳定的做法。
4.2 MultiThreadingServer 与 MultiClientServer 的取舍
压缩包里同时出现了 MultiThreadingServer 和 MultiClientServer 两套服务端,很多人会疑惑该看哪个。我的理解是它们代表了两种资源策略:
| 维度 | MultiThreadingServer | MultiClientServer |
|---|---|---|
| 并发模型 | 每连接一个线程 | 单线程内轮询多连接(select 风格) |
| 线程数 | 随连接数线性增长 | 固定为一个 |
| CPU 开销 | 有线程切换成本 | 低 |
| 单连接阻塞影响 | 互不影响 | 一个连接卡读整盘卡住 |
| 适用连接数 | 几十以内 | 连接少、IO 快的场景 |
实际工程里我的习惯是:连接数稳定在个位数、每帧数据又短又频繁,用 MultiClientServer 的轮询模型更省心;连接数是动态的、可能有客户端长时间不发数据,必须上 MultiThreadingServer。作为学习模板,MultiThreadingServer 的参考价值更高,因为它演示了上面那整套线程参数传递和资源释放的完整链路。
4.3 CVI 的线程红线:工作线程里动控件会直接翻车
CVI 和普通 C 编程最大的区别就在这:它的 UI 控件有线程亲和性,后台线程直接调SetCtrlVal改界面,轻则控件不刷新,重则整个 CVI 运行时直接抛异常。这套包里的客户端工程带了 .uir 界面,处理收包后刷新文本框时必然撞上这个问题。
官方推荐的做法是把数据先复制出来,然后通过CmtScheduleCallbackFunction调度一段代码回到 UI 线程执行:
int CVI_CALLBACK UIUpdateCallback(void *callbackData) { // 这里已经在 UI 线程,可以安全操作控件 SetCtrlVal(panelHandle, PANEL_EDIT, (char *)callbackData); free(callbackData); return 0; } // 在工作线程里这样调用: char *copy = malloc(n + 1); memcpy(copy, buf, n); copy[n] = '\0'; CmtScheduleCallbackFunction(UIUpdateCallback, copy);CmtScheduleCallbackFunction的本质是把回调挂到 CVI 的消息队列里,等 UI 线程空闲时执行,所以它不会抢占、不会死锁。记得callbackData在回调里负责释放,谁使用谁释放。另一个选择是用保护队列(CmtWriteLock加锁的那种线程安全队列)把数据从工作线程搬到 UI 线程,但为了同步刷新逻辑,CmtScheduleCallbackFunction更直接。
5. 避坑与常见问题:CVI TCP 编程里五个真实的翻车现场
5.1 现象:连上之后拖拽窗体,界面变白
原因:sock_read在 UI 线程里阻塞了。很多人在按钮回调里直接写接收循环,对端一秒钟不发数据,回调就卡在 read 上,消息循环转不动,整个窗体失去响应。
解决:把收发逻辑全部挪到独立线程,UI 线程只负责点击响应和经CmtScheduleCallbackFunction接收刷新消息,这个原则在这套包的设计里其实已经体现了——每个带 .uir 的工程都配了多线程或异步处理。
5.2 现象:服务端重启时报 bind 失败,Address already in use
原因:TCP 的 TIME_WAIT 状态。上一次连接关闭后,端口在 2 倍 MSL 时间内处于 TIME_WAIT,默认不允许重新绑定。做嵌入式上位机联调时,服务端程序一天重启几十次,几乎必遇。
解决:bind 之前用setsockopt(serverSock, SOL_SOCKET, SO_REUSEADDR, ...)打开端口复用,我在 3.2 节的代码里已经放了这个设置。如果加了还报错,检查是不是有别的进程占了端口,用netstat -ano | findstr 5000查一下 PID。
5.3 现象:客户端发两帧数据,服务端一次读到了两帧
原因:粘包。TCP 是字节流,不保"帧边界",两次sock_write的数据完全可能被合并成一次sock_read返回。反之,一次大写入也可能被拆成多次读。
解决:用 MessageWriter 在发送前加长度头,用 MessageReader 在接收侧先拼长度再拼数据,凑满一帧才返回给业务层。这套包单独拆出这两个模块,就是让你把这条规则当成铁的约定——消息边界永远由应用层协议负责,不指望 TCP 帮你分帧。
5.4 现象:client 和 server 两个工程同时调试,一个连接就崩
原因:端口冲突或资源表冲突。两个 CVI 调试器同时跑,如果 client 和 server 用了同一个端口号自连,也会出现连接被拒。另外 CVI 的调试器同时在跑两个工程时,resources.res里的资源 ID 如果冲突,控件回调会串。
解决:把 server 固定在一个端口(如 5000),client 用另一个端口(如 5001),并在客户端代码里指定 server 的地址为 127.0.0.1。先启动 server 再启动 client,顺序反了 client 的 connect 会直接失败,日志里也会留下清晰的错误码。
5.5 现象:多线程服务端偶发访问冲突,一会儿好一会儿崩
原因:工作线程的ConnArg被提前释放,或者主线程 accept 返回的 socket 变量被后续循环覆盖。偷懒用全局变量传 socket 的做法,在第二个连接进来时第一个线程读到的句柄已经变了。
解决:每个连接独立malloc一份ConnArg,工作线程退出前负责free,主线程拿到线程 ID 后不再碰这份内存。我在 4.1 节的代码里就是这么写的——谁创建谁负责销毁的边界一旦打破,后面崩得毫无规律,这是多线程网络编程里最典型的血泪教训。
6. 从编译通过到通信可靠:验证 TCP 闭环的三个技巧
代码编译通过只算成功了一半,网络程序必须有手段验证"数据真的从 A 到了 B"。我通常按三个层次过一遍,这套 tcp.rar 自带的调试产物正好够用。
第一个技巧是先用现成的_dbg版本做冒烟。压缩包里带了 client_dbg.exe 和 server_dbg.exe,把 server_dbg.exe 丢到一台机器上跑起来,client_dbg.exe 在另一台跑,如果界面上的连接指示灯和收发字节数对得上,说明基础网络路径是通的。这一步能把你自己的代码问题从环境问题里剥离开——如果连官方编译好的 exe 都连不通,问题在网络配置,不在代码。
第二个技巧是用netstat盯端口状态。服务端起来之后执行netstat -ano | findstr 5000,看到的监听状态应该是LISTENING,有个客户端连上来之后会出现一对ESTABLISHED,等客户端主动断开,端口会短暂进入TIME_WAIT——这时候如果立刻重启服务端报Address already in use,说明 5.2 小节那个SO_REUSEADDR没有生效,回去检查setsockopt是不是在bind之前调用。
第三个技巧是主动制造一次异常:把客户端程序强行关掉,看服务端线程的sock_read返回值。正常情况它会返回 0 或负值,线程据此退出并清理资源;如果线程卡死或资源泄露,跑几轮就能看出来。验证时我会在MessageProcess入口放一个断点,确认每次进来的buf长度都等于发送侧写入的长度——如果长度对不上,说明粘包处理还有漏洞。
从最小闭环到多线程再到消息边界,这套工程把 CVI 网络编程的三个层次都摆出来了。从那以后我每次接手 CVI 网络工程,都会强制走一遍这个流程:先拿_dbg版验证环境,再用 netstat 确认端口状态,最后故意杀进程看资源回收。三步都用不上时,才轮到真正排查业务逻辑。这套手脚虽然朴素,但确实帮我少踩了很多坑,希望帮到你。
本文还有配套的精品资源,点击获取