用C语言和UDP协议在命令行实现局域网聊天工具
2026/9/14 19:50:44 网站建设 项目流程

我们屋里两台电脑连在同一个交换机上,日常交流全靠吼,或者在共享文件夹里丢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窗口默认代码页是GBK(936),现代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 name="UDPChat" dir=in action=allow protocol=UDP localport=8888,这条命令在管理员权限的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 API:SetConsoleCursorPosition定位光标位置,滚动缓冲区。比较硬核,但能达到接近聊天软件的界面效果。

不过这里要提醒一句:如果追求复杂的界面效果,直接用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调用方式的不同。如果你现在也正卡在书看了不少、代码写不出来的阶段,这个项目值得花一个下午老老实实敲一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询