资讯详情

用C语言和UDP协议在命令行实现局域网聊天工具

📅 2026/9/14 19:50:53 | 华诺云谱 👁 阅读
用C语言和UDP协议在命令行实现局域网聊天工具
我们屋里两台电脑连在同一个交换机上日常交流全靠吼或者在共享文件夹里丢txt。后来实在觉得别扭就想写个小工具要求特别简单双击能跑命令行界面能互相喊话就行。选型的时候先排除TCP因为聊天场景没有那么强的可靠性诉求双方在线时消息递过去就行反而是UDP这种无连接、收发一体的模型更贴合“喊话”这个动作。所以标题里“UDP协议”和“dos命令行”两个词就锁定了方向基于C语言socket编程在Windows的cmd窗口里跑一个UDP聊天程序。这篇文章我会把从原理、代码到实测踩坑的全部过程展开直接给你一份能照抄的完整实现。适合刚学完socket编程想找小项目练手的同学也适合想在局域网内快速搭一个通讯工具、但不想装现成软件的人。1. 为什么是UDP无连接模型天然适合“喊话”场景先说结论UDP不保证消息一定到达、不保证按序到达但在局域网聊天场景里这些“缺点”几乎不影响使用。更重要的是UDP的收发模型比TCP简单一个量级。1.1 UDP和TCP在聊天场景里的本质区别TCP是面向连接的字节流协议你得先三次握手建立连接然后才能收发数据数据被抽象成“流”应用层要自己处理粘包、拆包。UDP是面向无连接的数据报协议每个sendto调用就是一个完整的数据报对方recvfrom一次拿到的就是完整的一条消息天然有“消息边界”。聊天程序要的就是“一句话是一条消息”的语义。用TCP你还需要自己设计协议来切分消息比如加\r\n分隔符、追加长度头而用UDP这些全免了。打个比方TCP像是两个人先约好一条电话专线接通后你说一句我应一句线路一直占着UDP更像是站在楼道里喊一嗓子声音传出去对方听见了就回应没听见下次再喊你不需要先“接通”什么。命令行聊天室里用户就是想要这种“喊话”的感觉。1.2 局域网环境下UDP的可靠性其实没那么差很多人一听到UDP就觉得丢包要命。实测下来同一台交换机下的两台机器UDP丢包率在极低负荷时基本接近零。聊天数据量本身就小一条消息几百字节交换机转发毫无压力。当然如果要在公网环境跑或者跨路由器传输UDP丢包问题就需要考虑了。但“dos命令行局域网聊天”这个场景恰恰是UDP的舒适区。你甚至可以加一个最简单的应用层确认机制——收到消息后回一个“ACK”数据报没收到就重发。这篇文章我先把纯粹的基础版做出来ACK扩展放在后面讲。1.3 socket编程里UDP的API轮廓在Windows环境(Winsock)里UDP聊天涉及的核心API就六个WSAStartup(); // 初始化Winsock环境 socket(AF_INET, SOCK_DGRAM, 0); // 创建UDP套接字 bind(); // 绑定本地端口否则收不到消息 sendto(); // 发送数据报到指定地址 recvfrom(); // 从任意地址接收数据报 closesocket(); // 关闭套接字比起TCP少了一大堆listen、accept、connect、send、recv的环节。整个程序的骨架其实就两步一个死循环读键盘输入并sendto另一个线程死循环recvfrom并打印到屏幕。2. 环境准备与选型在Windows命令行里能用的开发栈标题里写的是“dos命令行”严格说指的是Windows的cmd.exe或Windows Terminal。不是FreeDOS那种真DOS环境因为真DOS下没有Winsock。这个前提得先说清楚免得有人拿着MS-DOS 6.22来试。2.1 编译器选择MinGW-w64 vs Visual Studio命令行工具写C语言的socket程序Windows下有两个常见选择工具链编译命令示例适合人群MinGW-w64的gccgcc udp_chat.c -o udp_chat.exe -lws2_32习惯GCC和Makefile的开发者Visual Studio Build Toolscl udp_chat.c /link ws2_32.lib已经在用VS生态的人我习惯用MinGW-w64因为命令行编译器更贴近“dos命令行”这个主题而且gcc的报错信息比cl友好。安装完MinGW后把bin目录加进系统PATH打开cmd输入gcc --version能输出版本号就说明环境OK。2.2 头文件和链接库的坑Windows下socket编程和其他平台最大的不同要显式引入winsock2.h和ws2_32.lib。#include winsock2.h #include windows.h #pragma comment(lib, ws2_32.lib)这里有一个历史坑winsock2.h和windows.h的包含顺序必须winsock2在前否则会报一堆莫名其妙的重复定义错误。因为windows.h会隐式包含旧版的winsock.h两个头文件里的结构体定义冲突。我最开始写代码时先包含了windows.h再包含winsock2.h编译直接三十多个error。如果用的是gcc命令行编译记住链接时必须加-lws2_32否则即使代码全对也会报一堆“未定义引用”的错误。2.3 命令行中文显示的编码问题cmd窗口默认代码页是GBK936现代Windows Terminal可以切UTF-8。你写代码时选择的编码要和运行窗口的代码页匹配否则中文会乱码。我的做法用UTF-8编码保存.c源文件同时在main函数开头调用SetConsoleOutputCP(CP_UTF8)把输出代码页切到UTF-8。这是实测最稳的方案比去改系统区域设置省事得多。如果你是Windows 10以下老系统不支持这个API那就只能把源文件保存成GBK编码。3. 核心代码拆解两个线索、一个套接字、三个参数这一节我把完整代码拆成几块讲明白每一块都说清楚“为什么这么写”。3.1 初始化Winsock环境每台Windows机器上socket编程的第一步首先Windows下用socket前必须调用WSAStartup让系统告诉你的程序“Winsock服务已经就绪了”。注意这个函数不只是初始化还能让你确认系统支持的Winsock版本。WSADATA wsaData; int err WSAStartup(MAKEWORD(2, 2), wsaData); if (err ! 0) { printf(WSAStartup failed: %d\n, err); return 1; }MAKEWORD(2, 2)表示请求Winsock 2.2版本。用完记得WSACleanup()虽然程序退出时系统会回收资源但养成成对调用的习惯在写更大型的程序时很重要。3.2 创建套接字SOCK_DGRAM是UDP的灵魂SOCKET sock socket(AF_INET, SOCK_DGRAM, 0); if (sock INVALID_SOCKET) { printf(socket() failed: %d\n, WSAGetLastError()); WSACleanup(); return 1; }AF_INET是IPv4地址族保证地址格式SOCK_DGRAM是数据报套接字正是UDP第三个参数协议填0让系统默认选择UDP。这段代码里我加了一个判断socket失败时用WSAGetLastError()打印错误码这个函数在你后面遇到各种网络错误时非常有用任何Winsock API报错都可以用它查原因。3.3 绑定端口不bind就收不到消息struct sockaddr_in local_addr; memset(local_addr, 0, sizeof(local_addr)); local_addr.sin_family AF_INET; local_addr.sin_addr.s_addr htonl(INADDR_ANY); // 绑定本机所有IP local_addr.sin_port htons(8888); // 本地端口8888 if (bind(sock, (struct sockaddr*)local_addr, sizeof(local_addr)) SOCKET_ERROR) { printf(bind() failed: %d\n, WSAGetLastError()); closesocket(sock); WSACleanup(); return 1; }bind的意义是让操作系统知道“这个端口归我这个进程管”凡是发到这个端口的数据报都交给你处理。注意INADDR_ANY会绑定本机所有网卡IP这样无论对方是往你的有线网卡IP还是无线网卡IP发消息都能收到。如果你只想监听某一个IP可以改成inet_addr(192.168.1.100)。这里涉及一个网络字节序的概念x86机器是小端存储而网络传输统一用大端网络字节序。htonl和htons分别是把32位和16位的“主机字节序”转成“网络字节序”不经过这个转换直接填入端口和IP你会看到端口号完全是错的。同样后面从sockaddr_in里读取对方端口时要用ntohs()转回来。3.4 发送数据报sendto出门右转char send_buf[1024]; struct sockaddr_in remote_addr; memset(remote_addr, 0, sizeof(remote_addr)); remote_addr.sin_family AF_INET; remote_addr.sin_addr.s_addr inet_addr(remote_ip); // 对方IP remote_addr.sin_port htons(remote_port); // 对方端口 sendto(sock, send_buf, strlen(send_buf), 0, (struct sockaddr*)remote_addr, sizeof(remote_addr));sendto比send多了两个参数目标地址结构体和它的长度。这是UDP和TCP最关键的区别——TCP要先把连接建立起来发送时系统知道发去哪UDP无连接所以每次发送都要带上完整的“快递单”。3.5 接收数据报recvfrom阻塞在哪里char recv_buf[1024]; struct sockaddr_in sender_addr; int sender_len sizeof(sender_addr); int len recvfrom(sock, recv_buf, sizeof(recv_buf) - 1, 0, (struct sockaddr*)sender_addr, sender_len); if (len 0) { recv_buf[len] \0; printf(收到来自 %s:%d 的消息: %s\n, inet_ntoa(sender_addr.sin_addr), ntohs(sender_addr.sin_port), recv_buf); }recvfrom是阻塞函数没有数据报到达时它就一直卡在原地不返回不占用CPU。所以它必须放在单独的线程里否则主线程卡在这里你键盘输入就没法处理了。这让整个程序的线程设计变得必然。3.6 完整代码双线程UDP命令行聊天工具把上面几块拼起来加上一个收数据线程就得到了完整程序。#include stdio.h #include string.h #include winsock2.h #include windows.h #pragma comment(lib, ws2_32.lib) #define LOCAL_PORT 8888 #define BUFFER_SIZE 1024 DWORD WINAPI receive_thread(LPVOID param) { SOCKET sock (SOCKET)param; char recv_buf[BUFFER_SIZE]; struct sockaddr_in sender_addr; int sender_len sizeof(sender_addr); int len; while (1) { len recvfrom(sock, recv_buf, BUFFER_SIZE - 1, 0, (struct sockaddr*)sender_addr, sender_len); if (len 0) { recv_buf[len] \0; printf(\n[%s:%d] 说: %s\n, inet_ntoa(sender_addr.sin_addr), ntohs(sender_addr.sin_port), recv_buf); printf(你 ); fflush(stdout); } } return 0; } int main() { WSADATA wsaData; SOCKET sock; struct sockaddr_in local_addr, remote_addr; char send_buf[BUFFER_SIZE]; char remote_ip[64]; int remote_port; HANDLE hThread; SetConsoleOutputCP(CP_UTF8); WSAStartup(MAKEWORD(2, 2), wsaData); sock socket(AF_INET, SOCK_DGRAM, 0); if (sock INVALID_SOCKET) { printf(socket() failed: %d\n, WSAGetLastError()); WSACleanup(); return 1; } memset(local_addr, 0, sizeof(local_addr)); local_addr.sin_family AF_INET; local_addr.sin_addr.s_addr htonl(INADDR_ANY); local_addr.sin_port htons(LOCAL_PORT); if (bind(sock, (struct sockaddr*)local_addr, sizeof(local_addr)) SOCKET_ERROR) { printf(bind() failed: %d\n, WSAGetLastError()); closesocket(sock); WSACleanup(); return 1; } printf( UDP 命令行聊天室 \n); printf(本地监听端口: %d\n, LOCAL_PORT); printf(请输入对方的IP地址: ); scanf(%s, remote_ip); printf(请输入对方的端口: ); scanf(%d, remote_port); memset(remote_addr, 0, sizeof(remote_addr)); remote_addr.sin_family AF_INET; remote_addr.sin_addr.s_addr inet_addr(remote_ip); remote_addr.sin_port htons(remote_port); hThread CreateThread(NULL, 0, receive_thread, (LPVOID)sock, 0, NULL); if (hThread NULL) { printf(CreateThread() failed: %d\n, GetLastError()); closesocket(sock); WSACleanup(); return 1; } CloseHandle(hThread); printf(开始聊天吧输入内容后按回车发送输入 exit 退出。\n); while (1) { printf(你 ); fflush(stdout); if (fgets(send_buf, BUFFER_SIZE, stdin) NULL) { break; } send_buf[strcspn(send_buf, \n)] \0; if (strcmp(send_buf, exit) 0) { break; } if (strlen(send_buf) 0) { sendto(sock, send_buf, strlen(send_buf), 0, (struct sockaddr*)remote_addr, sizeof(remote_addr)); } } closesocket(sock); WSACleanup(); return 0; }这段代码有两个细节值得说明。第一recvfrom线程在收到消息后会先打印消息然后重新打印“你”提示符这是为了不让对方的消息把你正在输入的内容“冲掉”。第二fgets会把回车也读进缓冲区我用strcspn找到换行符位置并截断否则回车符会被发送到对方屏幕上。4. 编译运行与两人联调这些坑我替你踩过了4.1 编译和基础测试假设代码保存为udp_chat.c用MinGW编译gcc udp_chat.c -o udp_chat.exe -lws2_32 -O2编译没有输出就是成功。先别急着找另一台机器本机就能测打开两个cmd窗口都启动udp_chat.exe两边都填127.0.0.1和同一个端口8888。因为bind用的是INADDR_ANY同一个程序本机可以同时跑两个实例默认端口都是8888时消息会通过回环地址在进程间传送。这时候你会在窗口A输入文字窗口B收到消息。如果这一步通了说明socket创建、bind、sendto、recvfrom整个链路是完整的。4.2 局域网联调前要查的三件事本机回环测试通过后才是真正的局域网联调。这一步踩坑率极高每个人都至少遇到过其中一个问题。第一件事确认两台机器在同一网段。cmd里执行ipconfig看两台机器的IPv4地址比如192.168.1.101和192.168.1.102这种就是在同网段。这里要注意有的企业网会把“AP隔离”打开连同一个WiFi的设备之间互相不可见这种网络环境你怎么调都通不了。第二件事防火墙必须放行端口。Windows防火墙默认会拦截外部机器发来的UDP数据包。有三种解决办法在第一次运行程序时Windows会弹窗询问“是否允许访问网络”点允许命令行执行netsh advfirewall firewall add rule nameUDPChat dirin actionallow protocolUDP localport8888这条命令在管理员权限的cmd里执行临时关闭防火墙测试完马上打开只适合自己完全可控的实验环境第三件事如果对方机器收到的消息来自你的物理IP而非127.0.0.1那就说明整条链路是通的。这是最简单直接的判断标准。4.3 实测过程中的迷惑现象对方能发出来我这边收不到我自己在联调时遇到过一个非常隐蔽的坑A机器能发送B机器能收到A的消息但B发给A的消息A永远收不到。检查A的防火墙UDP8888端口确实放行了IP也能ping通但就是收不到。最后发现问题出在A机器的防火墙规则上——之前放行的规则是“TCP端口8888”而不是“UDP端口8888”。TCP和UDP在防火墙规则里是分开的系统默认创建的规则只对TCP生效。把规则改成UDP问题立刻解决。你排查时一定要看一眼防火墙规则的协议列。4.4 收消息线程的机房皇帝问题打印刷屏程序跑起来之后如果对方连续快速发送多行消息接收线程会一次性把这些消息全部打印出来可能会把当前正在输入的内容挤开。基础版里我加了提示符重打印但在快速消息刷屏时仍然会乱。这是命令行聊天程序的固有缺陷不是UDP的问题。想彻底解决需要引入类似fgets中断后清屏重绘的逻辑或者使用curses库但这个就偏离“dos命令行”简洁性的初衷了。我的建议是做个简单限制接收线程打印时先判断是否超过30秒没有键盘输入如果正在打字就不打印提示符避免视觉干扰。5. 从“能聊”到“好用”可靠性和体验的进阶改造5.1 加上应用层ACK确认用UDP实现“准可靠”很多刚上手UDP的人都想知道“UDP能不能做可靠传输”当然能。最简单的办法是在UDP之上加ACK确认。A发送消息后把这条消息存进一个待确认队列B收到消息后主动回发一个“ACK数据报”内容可以是这条消息的序号A收到ACK就在队列里删掉这条消息发送时启动一个定时器超时没收到ACK就重新发送一次这本质上就是简化版的TCP重传机制。在聊天场景里你甚至不需要自动重发只需在发送后几秒没收到ACK时提示“对方可能没收到”让用户决定要不要再发一次远比重发一切靠谱。5.2 群聊改造从点对点到一对多一个点对点的UDP聊天只能两个人用如果想做群聊需要引入“组播”或“广播”。广播最简单用SO_BROADCAST选项后把发送地址设为255.255.255.255网络上同网段的所有机器都能收到。但广播会被限制在子网内而且会打扰所有主机很多路由器会默认丢弃。组播(IGMP)更优雅它允许特定组的主机接收发往该组地址的数据。代码改动也不大socket加SO_REUSEADDR加入组播组地址如239.0.0.1。这样这个聊天工具就能秒变一个小型对讲机系统。5.3 命令行体验优化分线渲染输入区你不需要一直忍受“提示符被刷掉”的体验还有一个可行方案是把收消息和发消息的显示区域用两行分隔开。接收线程打印到屏幕顶部输入提示符固定在底部。核心原理是使用Windows的console APISetConsoleCursorPosition定位光标位置滚动缓冲区。比较硬核但能达到接近聊天软件的界面效果。不过这里要提醒一句如果追求复杂的界面效果直接用Electron、Qt或者Python的tkinter写更合适。非要留在cmd里折腾体验提升的边际成本很高。这个项目最大的价值在于让人彻底搞懂UDP协议、socket API和线程模型界面保持“能读就行”就好。5.4 双人同时输入时的半双工问题最终方案聊到这里你会发现命令行聊天真正的瓶颈不在UDP而在于“键盘输入”和“屏幕输出”如何通过终端来协调。终端可以说天然是半双工的。最佳的纯命令行解法是接收线程只负责把消息存入环形缓冲区不直接打印。主线程在每次fgets之前检查缓冲区如果有待打印的消息就先打印出来再重新显示输入提示符。这样即使对方在你打字过程中发来消息也只会积压到缓冲区等你敲完回车这次输入结束下一轮输入前一次性刷出来。这算是一个足够优雅、又能保持在cmd能力范围内的折中方案。如果你想再进一步做实时性就需要像前面说的动用console API或者干脆放弃命令行做一个带窗口的小工具。6. 我把这个项目整理成一条学习路线如果是想通过这个小项目学习网络编程我建议严格按照下面的顺序走每一步都能积累对应的能力第一步先把基础版代码敲一遍编译通过、本机回环测试通过。这一步帮你掌握WSAStartup到bind的完整套路。第二步把sendto和recvfrom的逻辑背下来写到不需要查文档也能写出来的程度。这两个函数是UDP编程的“左膀右臂”参数含义必须烂熟于心。第三步加入ACK确认机制体会“不可靠传输之上构建可靠性”的完整思路。这是通往TCP实现机制的绝佳跳板。第四步改成广播或组播群聊模式理解网络通信中单播、广播、组播三者的坐标关系。第五步反向改造把SOCK_DGRAM换成SOCK_STREAM对比TCP版本改动多少代码。这个对比会给你极深的体感——TCP把连接管理、可靠性、顺序保证都收进内核里了代价是更多环节需要配置和更多的状态管理。我在第一遍做这个项目时最大的收获不是会写sendto和recvfrom而是彻底看懂了“UDP为什么无连接”、以及“为什么TCP是字节流而UDP是数据报”这些概念在实际编码中的投影。教科书上几句话的差异落到代码上就是几十个API调用方式的不同。如果你现在也正卡在书看了不少、代码写不出来的阶段这个项目值得花一个下午老老实实敲一遍。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。