简介:提供一套在Linux命令行环境下可直接编译运行的C语言网络聊天程序源码,覆盖TCP和UDP两种通信方式。TCP部分包含完整的服务端(tcpserver.c、socketA.c等)与多个客户端实现(tcpclient.c、chat.c、chatClient1.c等),支持多用户连接、消息广播与基础会话管理;UDP部分含udpA.c至udpF.c等不同交互模式的收发示例,适合对比理解无连接通信特点。配套有rad.c(简易绘图)、keypad.c(键盘输入模拟)、1draw.c(字符画)等辅助功能代码,帮助拓展学习场景。所有源文件基于标准POSIX Socket API编写,使用gcc即可编译(如gcc -o server tcpserver.c -lpthread),无需额外依赖。附带udp.txt说明文档,部分文件含.swp临时备份需手动清理。代码结构清晰、注释适量,聚焦bind、listen、accept、connect、send、recv等核心系统调用实践,适用于网络编程入门、课堂实验或自学调试。
1. 这不是玩具代码,是能真正“说话”的Linux聊天系统
你有没有试过在终端里敲下 gcc -o server tcpserver.c && ./server,然后看着另一个窗口里 ./client 成功连上、发消息、被所有人看到?不是那种“Hello World”式的回显,而是真正在两个甚至五个终端之间实时传递文字、广播通知、显示在线人数——消息一发出去,0.1秒内就出现在所有人的屏幕上。这套代码就是干这个的。它不依赖任何图形界面,不调用第三方库,不包装成黑盒服务,从 socket() 创建套接字开始,到 bind() 绑定地址、listen() 等待连接、accept() 接收客户端、recv() 收消息、send() 广播出去,每一步都裸露在你眼前,像一本摊开的网络协议解剖图。
我带过十几届嵌入式和后端方向的学生做网络编程实训,最常听到的抱怨是:“书上讲三次握手,可我连一个能连上的socket都写不出来。”问题不在概念,而在缺失“可触摸的中间态”——你不知道 listen() 的第二个参数 backlog 设成3和10到底差在哪,也不知道 recv() 返回0意味着对方优雅关闭还是网络中断,更不清楚为什么UDP客户端不用 connect() 却也能用 sendto() 发消息。这套源码合集,就是专为填平这个鸿沟而生的。它把TCP的“连接建立—数据传输—连接释放”全生命周期,和UDP的“无状态发送—尽力交付—无确认接收”本质,拆解成一个个独立可编译、可单步调试、可修改对比的 .c 文件。udpA.c 是最简UDP回显;udpC.c 加了多客户端地址缓存;tcpserver.c 用 select() 实现单线程多路复用;chat.c 引入环形缓冲区防阻塞;socketBB.c 则演示了如何用 setsockopt() 开启 SO_REUSEADDR 避免 Address already in use 错误——这些都不是教科书里的抽象描述,而是你 gcc -g -o udpA udpA.c 后,用 gdb ./udpA 下断点就能亲眼看到变量变化的真实现场。
它适合谁?如果你刚学完《UNIX环境高级编程》第14章,对着 man 2 socket 手册页发懵;如果你在写毕设时卡在客户端断开后服务端没感知,导致僵尸连接堆积;如果你想搞懂 epoll 前先亲手撸一遍 select 的轮询逻辑;甚至如果你只是好奇“QQ聊天室底层第一行代码长什么样”——这套代码就是你的起点。它不承诺高并发百万在线,但保证每一行 send() 调用背后,你都能在Wireshark里抓到对应的TCP segment;每一个 recvfrom() 返回的字节数,都对应着UDP datagram的实际载荷长度。这不是工业级产品,但它是比任何PPT都扎实的入门脚手架。
2. 整体设计思路:为什么TCP和UDP要分开实现?又为什么要混搭?
2.1 协议选型不是技术炫技,而是教学意图的精准落地
很多人初学时会疑惑:既然TCP更可靠,为什么还要费劲写一堆UDP示例?答案很简单——可靠性本身是有代价的,而代价必须被看见。这套代码把TCP和UDP彻底分开,不是为了堆砌文件数量,而是强制你在编译、运行、调试的每个环节,直面两种协议的根本差异。
TCP部分(tcpserver.c、tcpclient.c、chat.c等)的核心目标是构建一个有状态、可管理、带上下文的会话环境。你看 tcpserver.c 里维护了一个 client_list[] 数组,每个元素记录客户端的 sockfd、IP地址、昵称、最后活跃时间;chat.c 中引入了 message_queue 结构体,用链表实现消息广播队列,避免 send() 阻塞主线程;socketBB.c 特意演示了 shutdown() 和 close() 的区别——前者只关闭写端,让对端还能发完剩余数据,后者直接切断双向通道。这些设计,全是为了模拟真实聊天室中“用户上线/下线/掉线重连/消息不丢失”的业务逻辑。它逼你思考:如果50个客户端同时发消息,服务端怎么不被阻塞?如果某个客户端网络抖动,是踢出还是等待?这些问题是TCP协议栈帮你兜底之后,应用层必须自己回答的。
UDP部分(udpA.c 到 udpF.c)则走向另一个极端:剥离一切状态,回归数据报本源。udpA.c 只有不到30行,socket() + bind() + 死循环 recvfrom(),收到啥打啥;udpD.c 开始记录每个客户端的 struct sockaddr_in 地址,实现“点对点转发”;udpF.c 更进一步,用 struct client_info 数组缓存最近通信的5个客户端,支持 list 命令查看在线者——但它依然没有“连接”概念,所有交互都靠 sendto() 指定目标地址完成。这里的关键教学点在于:UDP的“在线”完全是应用层自己定义的。udpF.c 里那个 last_seen_time 时间戳,不是协议告诉你的,是你每次 recvfrom() 后手动更新的。这种“协议不保证,应用自己扛”的体验,是任何TCP代码都无法替代的。
提示:别急着合并TCP和UDP。先分别编译运行
udpA.c和tcpserver.c,用netstat -tuln | grep :8080观察端口状态——UDP监听显示为udp,TCP显示为tcp,且TCP有LISTEN状态而UDP没有。这就是协议栈最直观的指纹。
2.2 文件命名体系:不是随意编号,而是学习路径的隐形地图
资源包里 udpA.c、udpB.c、socketA.c、socketB.c 这类命名,绝非开发者偷懒。它是一条精心设计的学习进阶路线:
-
udpA.c→udpB.c→udpC.c:这条线聚焦“单机多客户端模拟”。udpA.c是纯服务端,udpB.c是纯客户端,两者配合就能跑通基础通信;udpC.c则把服务端逻辑和客户端逻辑揉在一个文件里,用fork()创建子进程,一个收一个发——这让你第一次体会到“同一个进程里如何同时扮演两端角色”,为后续理解select()多路复用打下直觉基础。 -
socketA.c→socketB.c→socketBB.c→socketAA.c:这条线专攻TCP服务端演进。socketA.c是最简accept()循环,一次只处理一个客户端;socketB.c引入fork(),每来一个连接就fork()一个子进程,实现真正的并发;socketBB.c解决fork()的经典坑:子进程close(sockfd)后父进程继续accept(),但忘记close(client_fd)导致文件描述符泄漏,代码里特意加了printf("child pid: %d, client_fd: %d\n", getpid(), client_fd)日志,方便你用lsof -p <pid>验证;socketAA.c则转向select(),用单线程监控多个sockfd,避免fork()的内存开销和进程调度成本。 -
chat.c和chatClient1.c:这是整套代码的“集成终点”。chat.c不再是玩具服务端,它实现了昵称注册(/nick xxx)、私聊(/msg user xxx)、群聊广播、在线列表(/list)、退出(/quit)等完整功能;chatClient1.c对应的客户端,用pthread_create()开两个线程:一个recv()收消息并打印,一个fgets()读键盘输入并send(),彻底解决“收消息时无法打字”的阻塞问题。它告诉你:当单个文件变得复杂时,多线程/多进程不是银弹,而是权衡后的必然选择。
这种命名法,本质上是在用文件名代替学习大纲。你不需要看文档就知道:先跑 udpA.c 和 udpB.c 理解UDP收发,再跑 socketA.c 看TCP阻塞模型,接着 socketB.c 体会 fork() 并发,最后 chat.c 把所有知识点串起来。它把“知识图谱”转化成了“文件系统图谱”。
2.3 辅助代码的价值:不是凑数,而是拓展认知边界的锚点
rad.c、keypad.c、1draw.c 这些看似无关的文件,恰恰是这套代码合集最具匠心的部分。它们不是网络编程核心,却是帮你跳出“纯文本通信”思维定式的破壁器。
-
rad.c(简易绘图):它用ASCII字符在终端画圆、直线、矩形。关键在于,它把绘图指令(如draw_line(x1,y1,x2,y2))封装成字符串,通过send()发送给服务端,服务端解析后调用本地rad.c的绘图函数渲染。这让你第一次意识到:网络传输的可以不是聊天文字,而是任意结构化指令。后续你可以轻松扩展成“远程控制LED灯”或“下发传感器配置命令”。 -
keypad.c(键盘输入模拟):它不依赖getchar(),而是用termios.h设置终端为非规范模式(ICANON=0),实现按一个键立即响应(比如按‘w’向上移动光标)。当你把它的输入逻辑集成到chatClient1.c中,就能做出类似ncurses的快捷键操作(Ctrl+L清屏,Ctrl+P上翻历史)。这打破了“网络客户端只能等用户输完回车才发消息”的惯性思维。 -
1draw.c(字符画):它预置了几十个用@、#、*拼成的小图标(笑脸、警告、箭头)。服务端收到/draw smile就广播对应字符画。这教会你:协议设计的第一步不是定义字段,而是约定“语义词汇表”。后续做IM协议,你可以定义MSG_TYPE_TEXT=1,MSG_TYPE_IMAGE=2,MSG_TYPE_VOICE=3,而1draw.c就是你的第一个MSG_TYPE_DRAW实践。
这些辅助代码的存在,暗示了一个重要事实:网络编程的终点不是“连上”,而是“创造新交互范式”。它们是你从“学会socket API”迈向“设计自己的网络协议”的第一块跳板。
3. 核心细节解析与实操要点:从编译到调试的避坑指南
3.1 编译环节:为什么 -lpthread 和 -g 不是可选项?
几乎所有TCP客户端和服务端文件(如 tcpserver.c, chat.c, chatClient1.c)的编译命令都要求加 -lpthread 参数,例如 gcc -o server tcpserver.c -lpthread。新手常忽略这点,编译时报错 undefined reference to 'pthread_create' 才去查。这背后是Linux线程实现的底层逻辑:glibc的POSIX线程(pthreads)函数(pthread_create, pthread_join等)并不在默认链接的 libc.so 中,而是在独立的 libpthread.so 库里。不加 -lpthread,链接器根本找不到这些符号。
更关键的是,即使你的代码没显式调用 pthread_* 函数,某些标准库函数内部也可能使用线程。比如 gethostbyname() 在旧版glibc中是线程不安全的,调用它可能隐式触发线程初始化。所以,只要代码涉及网络DNS解析(如 getaddrinfo() 的某些实现),或者你用了 malloc()/free()(现代glibc的内存分配器是多线程优化的),加上 -lpthread 都是稳妥做法。这不是过度配置,而是遵循POSIX标准的必要步骤。
至于 -g 参数(gcc -g -o server tcpserver.c -lpthread),它生成的调试信息(debug info)是调试网络程序的生命线。想象一下:服务端卡死,ps aux | grep server 显示进程还在,但 netstat -tuln 看不到端口监听。这时 gdb ./server 附加进程,执行 bt(backtrace)命令,你能立刻看到卡在 accept() 还是 recv(),是阻塞在系统调用里,还是死锁在某个互斥锁上。没有 -g,gdb 只能显示汇编指令,你得对着反汇编代码猜逻辑,效率暴跌90%。我建议把 -g 当作编译标配,就像写C必须加 -Wall 一样自然。
注意:
-lpthread必须放在源文件之后!正确写法是gcc -g -o server tcpserver.c -lpthread,错误写法gcc -g -lpthread -o server tcpserver.c在某些老版本gcc上会链接失败。因为链接器是从左到右扫描,需要先看到引用符号的源文件,再遇到提供符号的库。
3.2 地址绑定与端口复用:SO_REUSEADDR 的真实作用
打开 socketBB.c,你会看到这几行关键代码:
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
注释里写着“允许端口快速重用”。很多教程只说“避免 Address already in use 错误”,但没说清为什么需要它,以及它到底解决了什么。
根本原因在于TCP连接的四次挥手和 TIME_WAIT 状态。当服务端主动关闭连接(如 close(sockfd)),最后发送 FIN 包后,会进入 TIME_WAIT 状态,持续2MSL(Maximum Segment Lifetime,通常为60秒)。在此期间,该 socket 的四元组(源IP、源端口、目的IP、目的端口)被内核锁定,不允许新连接复用。如果你的服务端频繁重启(比如改一行代码就 Ctrl+C 再 ./server),大概率撞上 TIME_WAIT,bind() 就会失败。
SO_REUSEADDR 的作用,是告诉内核:“如果我要 bind() 的地址正处于 TIME_WAIT,且新连接的本地端口相同,但远端地址不同(即不是同一个客户端重连),那就允许复用吧。” 它不破坏TCP可靠性,只是放宽了端口复用的限制。socketBB.c 里启用它,就是为了让你能无痛地反复启动/停止服务端,而不必等一分钟。
但要注意:SO_REUSEADDR 对UDP无效(UDP无连接状态),对已处于 ESTABLISHED 状态的连接也无效(防止新连接抢走老连接的数据)。它只对 TIME_WAIT 和 CLOSE_WAIT 状态有效。这也是为什么 udpA.c 里看不到这行代码——UDP压根没有 TIME_WAIT。
3.3 UDP通信的“伪连接”陷阱:connect() 的妙用
udpB.c 和 udpC.c 都没调用 connect(),直接用 sendto() 发送。而 udpD.c 却出现了:
connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
// 后续直接 send(),不用 sendto()
这看起来很奇怪:UDP不是无连接协议吗?为什么能 connect()?
真相是:connect() 对UDP套接字的作用,不是建立连接,而是设置一个默认的目的地址。调用后,该UDP socket就被“绑定”到一个特定远端地址,后续 send() 就无需再传 dest_addr 参数,内核自动发给那个地址;recv() 也只接收来自该地址的数据包,其他地址发来的包会被内核丢弃(返回 ECONNREFUSED 错误)。这本质上是一种应用层过滤机制。
udpD.c 用它,是为了简化客户端逻辑——它只跟一个服务端通信,connect() 后代码更干净。但这也带来风险:如果服务端IP变了,客户端不 connect() 新地址,send() 就会失败。所以 udpF.c 选择不用 connect(),坚持 sendto(),就是为了保持灵活性,随时可发给任意地址。
实操心得:在调试UDP程序时,如果
recvfrom()总是收不到包,先检查是否误用了connect()锁定了地址;如果sendto()返回Invalid argument,大概率是serv_addr结构体里的sin_port没用htons()转换字节序。
3.4 多线程客户端的输入/输出竞态:为什么 chatClient1.c 必须双线程?
打开 chatClient1.c,你会发现它创建了两个线程:
- 主线程:负责 recv() 从服务端收消息,并 printf() 打印;
- 子线程:负责 fgets() 读取键盘输入,并 send() 发送给服务端。
新手常试图用单线程实现:“先 recv(),再 fgets(),循环往复”。这会导致严重问题:recv() 是阻塞的,如果服务端没发消息,客户端就卡在 recv(),键盘输入永远没机会读取;反之,如果用户正在疯狂打字,fgets() 卡住,recv() 就收不到新消息。这就是典型的I/O阻塞竞态。
双线程方案用操作系统调度解决此问题:两个线程各自阻塞在不同的I/O上,互不干扰。但随之而来的是共享资源竞争——stdin 和 stdout 是全局的,两个线程同时 printf() 可能导致输出乱序(比如一条消息被截成两半)。chatClient1.c 的处理很朴素:它用 pthread_mutex_t 互斥锁保护 printf(),确保同一时刻只有一个线程能向屏幕输出。虽然简单,却直击要害。
更深层的启示是:网络客户端的UI响应性,本质是I/O解耦问题。chatClient1.c 的双线程是入门解法,进阶方案是用 select() 或 epoll() 监控 stdin 和 sockfd 两个文件描述符,单线程轮询——但这要求你理解 FD_SET、FD_ISSET 等宏,对初学者门槛更高。所以这套代码明智地选择了“易懂优先”的双线程,把复杂度控制在可消化范围内。
4. 实操过程与核心环节实现:手把手带你跑通第一个TCP聊天
4.1 从零开始:编译并运行最简TCP服务端(socketA.c)
我们以 socketA.c 为起点,这是整个TCP系列的基石。它只有约50行,实现了最朴素的“一次一客”模型。让我们一步步走通:
第一步:查看代码结构
# 先用 less 看一眼,重点关注 socket()、bind()、listen()、accept()、recv()/send() 调用
less socketA.c
你会看到:
- 第22行:sockfd = socket(AF_INET, SOCK_STREAM, 0); 创建TCP套接字
- 第30行:bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)); 绑定到 INADDR_ANY:8080
- 第34行:listen(sockfd, 5); 开始监听,backlog=5 表示等待队列最多5个连接
- 第40行:client_fd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len); 阻塞等待客户端连接
- 第47行:recv(client_fd, buffer, sizeof(buffer)-1, 0); 收消息
- 第51行:send(client_fd, "Echo: ", 6, 0); 回复
第二步:编译
gcc -g -o server socketA.c -lpthread
# 检查是否成功
ls -l server
# 输出应类似:-rwxr-xr-x 1 user user 16728 Aug 10 10:00 server
第三步:运行服务端
./server
# 终端会卡住(阻塞在 accept()),这是正常的!说明它正在等待连接
# 记下当前终端的PID,方便后续调试:echo $$
第四步:启动客户端(用 telnet 最快)
新开一个终端:
telnet 127.0.0.1 8080
# 如果提示 "telnet: command not found",先安装:sudo apt install telnet
# 连接成功后,你会看到光标闪烁,此时输入任意文字(如 hello)并回车
# 服务端终端应立刻打印收到的消息,客户端会收到 "Echo: hello"
第五步:调试验证
回到服务端终端,按 Ctrl+C 中断。现在用 gdb 深挖:
gdb ./server
(gdb) break 40 # 在 accept() 前设断点
(gdb) run
# 此时服务端停住,新开 telnet 连接
# gdb 会停在第40行,执行 (gdb) print serv_addr.sin_port 显示端口号(已转为网络字节序)
# (gdb) continue 让它继续运行,观察 recv() 时 buffer 的内容
这个过程的价值,在于你亲手见证了TCP连接建立的完整握手:telnet 发起 SYN,服务端回 SYN-ACK,telnet 再发 ACK,accept() 才返回。Wireshark里抓包,你能清晰看到这三个包。这才是“理解”的开始,而不是“记住”。
4.2 进阶实战:用 socketB.c 实现并发,观察 fork() 的父子分工
socketB.c 在 socketA.c 基础上增加了 fork(),让服务端能同时服务多个客户端。关键改动在 accept() 之后:
if ((pid = fork()) == 0) {
// 子进程:处理这个客户端
close(sockfd); // 关闭监听套接字,只留 client_fd
handle_client(client_fd);
exit(0);
} else {
// 父进程:继续 accept 新连接
close(client_fd); // 关闭已交给子进程的 client_fd
}
编译运行:
gcc -g -o server_b socketB.c -lpthread
./server_b
测试并发:
开三个终端:
- 终端1:telnet 127.0.0.1 8080
- 终端2:telnet 127.0.0.1 8080
- 终端3:telnet 127.0.0.1 8080
你会发现,三个连接互不干扰。终端1发 hello,只有终端1收到回显;终端2发 world,只有终端2收到。这是因为每个子进程只操作自己的 client_fd,彼此隔离。
验证 fork() 效果:
在 socketB.c 的 handle_client() 函数开头加一句:
printf("Child process PID: %d, handling client %s:%d\n",
getpid(), inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
重新编译运行,三个 telnet 连接时,服务端终端会打印三行不同的PID,证明确实是三个独立进程在工作。
注意事项:
socketB.c有个经典隐患——父进程close(client_fd)后,子进程仍持有该文件描述符副本。如果子进程不close(client_fd)就exit(),内核不会立即释放连接,导致TIME_WAIT状态残留。socketBB.c就专门修复了这点,它在子进程中明确close(client_fd)。这是你必须亲手踩过的坑,才能真正理解文件描述符的生命周期。
4.3 集成演练:跑通 chat.c + chatClient1.c 的完整聊天室
chat.c 是这套代码的集大成者,它实现了昵称、广播、私聊、在线列表等完整功能。我们来部署一个最小可用聊天室。
服务端编译与启动:
gcc -g -o chat_server chat.c -lpthread
./chat_server
# 默认监听 8080 端口,支持最多 10 个客户端
客户端编译与启动:
gcc -g -o chat_client chatClient1.c -lpthread
# 启动第一个客户端(昵称设为 Alice)
./chat_client 127.0.0.1 8080 Alice
# 启动第二个客户端(昵称设为 Bob)
./chat_client 127.0.0.1 8080 Bob
功能测试:
- 在Alice窗口输入 /list,应看到 Online users: Alice, Bob
- Alice输入 /msg Bob Hello from Alice!,Bob窗口应收到消息
- Bob输入 /nick Charlie,Alice窗口应提示 Bob is now known as Charlie
- Charlie(原Bob)输入 /quit,Alice窗口应提示 Charlie has left the chat
协议分析:
chat.c 定义了一套极简文本协议:
- /nick <name>:注册昵称,服务端校验唯一性
- /msg <user> <text>:私聊,服务端查找 <user> 的 sockfd 并 send()
- /list:广播在线用户列表
- /quit:服务端从 client_list[] 移除该用户,并通知他人
所有命令都以 / 开头,服务端用 strtok(buffer, " \n") 切分空格,识别指令类型。这种设计简单粗暴,但极其适合教学——你一眼就能看懂协议格式,修改一个 if (strcmp(cmd, "nick") == 0) 就能增加新命令。
关键参数计算:
chat.c 中 MAX_CLIENTS 定义为10,这是基于 select() 的 FD_SETSIZE 限制(通常是1024,但为安全设小)。如果你想支持更多用户,必须改用 poll() 或 epoll()。而 BUFFER_SIZE 设为1024字节,足够容纳大多数聊天消息,但如果你要发长文本,需增大此值并确保 recv() 循环读取(chat.c 已实现)。
4.4 UDP实战:用 udpF.c 搭建轻量级广播聊天
udpF.c 展示了UDP在局域网内的高效广播能力。它不维护连接,只靠 recvfrom() 记录客户端地址,用 sendto() 点对点转发。
编译与运行:
gcc -g -o udp_server udpF.c
./udp_server
# 监听 8080 端口
客户端(用 udpB.c 或自写):
gcc -g -o udp_client udpB.c
./udp_client 127.0.0.1 8080
特色功能:
- 客户端发送 list,服务端回复当前缓存的客户端IP列表(最多5个)
- 发送 broadcast hello,服务端会 sendto() 给所有已知客户端
- 发送 msg 192.168.1.100:8080 hi there,服务端解析IP和端口,单独 sendto()
为什么UDP适合这种场景?
- 低延迟:没有TCP握手和确认,消息发出即送达(尽最大努力)
- 低开销:一个UDP包就是一个IP包,头部仅8字节,而TCP包头部至少20字节
- 广播友好:UDP天然支持 255.255.255.255 广播地址,udpF.c 可轻松扩展为局域网发现服务
但代价是:udpF.c 里 last_seen_time 超过30秒未更新的客户端会被自动清理,这完全是应用层逻辑。TCP的 keepalive 是内核提供的,UDP的“心跳”必须你自己实现(比如客户端定时发 ping 包)。
5. 常见问题与排查技巧实录:那些年我们踩过的坑
5.1 “Connection refused” 错误的七种可能及定位方法
当你执行 ./tcpclient 或 telnet 时,报错 Connection refused,这是新手最高频问题。它表面是客户端错误,根源却在服务端。以下是七种典型场景及排查步骤:
| 场景 | 现象 | 快速定位命令 | 根本原因 | 解决方案 |
|---|---|---|---|---|
| 服务端未运行 | netstat -tuln \| grep :8080 无输出 | ps aux \| grep server | 服务端进程根本没启动 | ./server 启动服务端 |
| 端口不匹配 | netstat 显示监听 :9090,客户端连 :8080 | netstat -tuln \| grep -E "(8080\|9090)" | 服务端 bind() 的端口与客户端 connect() 不一致 | 修改服务端 serv_addr.sin_port = htons(8080) |
| 地址绑定错误 | netstat 显示监听 127.0.0.1:8080,客户端连 192.168.1.100:8080 | netstat -tuln \| grep 8080 | serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1") 只绑定回环地址 | 改为 INADDR_ANY(即 0) |
| 防火墙拦截 | netstat 显示监听,但外网无法连 | sudo ufw status(Ubuntu)或 sudo firewall-cmd --state(CentOS) | 系统防火墙阻止了端口 | sudo ufw allow 8080 或临时关闭防火墙 |
| SELinux限制 | netstat 正常,但 telnet 仍拒连 | sudo sestatus | SELinux策略禁止网络绑定 | sudo setsebool -P httpd_can_network_connect 1(谨慎)或临时 setenforce 0 |
| 服务端崩溃 | ps 显示进程存在,但 netstat 无监听 | ps aux \| grep server 后 kill -3 <pid>(发送SIGQUIT) | 服务端在 bind() 或 listen() 后崩溃,进程僵死 | 查看 gdb backtrace 或添加 perror() 日志 |
| 端口被占用 | bind() 返回 -1,perror() 输出 Address already in use | sudo lsof -i :8080 或 sudo netstat -tulnp \| grep :8080 | 其他进程占用了8080端口 | sudo kill -9 <PID> 或改用其他端口 |
独家技巧:用 strace 追踪系统调用
当常规方法失效,直接看内核反馈:
strace -e trace=socket,bind,listen,accept,connect,send,recv ./server 2>&1 | grep -E "(socket|bind|listen|accept)"
它会逐行打印服务端执行的每个socket相关系统调用及返回值,bind() 返回 -1 时,紧接着的 write(2, "bind error: ..." 就是错误原因,比 perror() 更底层。
5.2 UDP收不到包?先查这四个致命疏漏
UDP的“不可靠”特性,让调试比TCP更棘手。recvfrom() 无声无息不返回,往往是因为以下四个低级错误:
-
客户端没指定目标端口
udpB.c中serv_addr.sin_port = htons(8080);忘了htons(),导致端口是主机字节序(如8080十进制 =0x1F90,但网络字节序要求高位在前,应为0x901F)。结果服务端bind()到8080,客户端却发到36896(0x901F十进制),包被内核丢弃。
验证:sudo tcpdump -i lo port 8080,如果看不到客户端发包,八成是字节序问题。 -
服务端
recvfrom()缓冲区太小
udpA.c中char buffer[32],但客户端发了50字节消息。UDP是数据报协议,超出缓冲区的部分被直接截断,recvfrom()返回32,但你只处理了前32字节,后18字节永久丢失。
修复:将buffer设为char buffer[1024],并检查recvfrom()返回值是否等于预期长度。 -
客户端
sendto()地址结构体未清零
udpB.c中struct sockaddr_in serv_addr;声明后,必须memset(&serv_addr, 0, sizeof(serv_addr));。否则serv_addr.sin_zero字段(填充用)是随机垃圾值,sendto()可能因地址结构体长度错误而失败。
现象:sendto()返回-1,errno=EINVAL(Invalid argument)。 -
防火墙或路由问题(尤其跨主机)
UDP比TCP更易被防火墙拦截。tcpdump在服务端机器抓包,如果tcpdump -i eth0 udp port 8080看不到包,说明包没到机器;如果能看到包但recvfrom()不返回,说明内核接收队列满了或应用层bug。
终极验证:在同一台机器上,客户端和服务端都用127.0.0.1,排除网络因素。
5.3 select() 轮询失效?检查 fd_set 的三大陷阱
socketBB.c 和 chat.c 用 select() 实现单线程多路复用,但新手常遇到“select() 永远返回0,不触发任何事件”。这通常源于 fd_set 使用的三个经典错误:
-
陷阱1:
fd_set未重置
select()会修改fd_set,把就绪的fd位置1,其余置0。如果下次循环不重新FD_ZERO()和FD_SET(),select()就永远在监控一个空集合。
正确模式:
c FD_ZERO(&read_fds); FD_SET(sockfd, &read_fds); // 监听套接字 FD_SET(client_fd1, &read_fds); // 已连接客户端 max_fd = (sockfd > client_fd1) ? sockfd : client_fd1; if (select(max_fd+1, &read_fds, NULL, NULL, &timeout) > 0) { if (FD_ISSET(sockfd, &read_fds)) { /* 新连接 */ } if (FD_ISSET(client_fd1, &read_fds)) { /* 收消息 */ } } -
陷阱2:
max_fd计算错误
select()第一个参数是nfds,表示要监控的最大fd值加1。如果sockfd=3,client_fd1=5,client_fd2=7,max_fd必须是7+1=8。设小了(如5),client_fd2就永远不会被检测。
技巧:每次accept()后,用max_fd = (max_fd > new_fd) ? max_fd : new_fd; -
陷阱3:超时时间未重置
struct timeval timeout = {5, 0};定义后,select()会修改它,减去已等待的时间。第二次调用前必须重新赋值,否则timeout可能变成{0, 0}(立即返回)或负值(行为未定义)。
修复:每次循环开始前timeout.tv_sec = 5; timeout.tv_usec = 0;
5.4 内存与文件描述符泄漏:valgrind 一键诊断
长期运行的服务端(如 chat.c)容易因 malloc() 后忘记 free(),或 socket()/accept() 后忘记 close(),导致内存耗尽或 Too many open files 错误。用 valgrind 可精准定位:
# 编译时加 -g
gcc -g -o chat_server chat.c -lpthread
# 运行 valgrind 检测内存泄漏
valgrind --leak-check=full --show-leak-kinds=all ./chat_server
# 检测文件描述符泄漏(需额外工具)
sudo apt install lsof
# 启动服务端后,定期执行:
lsof -p $(pgrep chat_server) \| wc -l
# 如果数字持续增长,说明有 fd 泄漏
valgrind 报告中重点关注:
- definitely lost:malloc() 分配但从未 free(),必须修复
- still reachable:程序退出时仍有指针指向内存,通常是全局变量,可接受
- open file descriptors:报告哪些fd未关闭,结合代码检查 close() 调用点
实操心得:我在调试
chat.c时,曾发现add_client()中malloc()了client_node,但在remove_client()中只free()了节点本身,忘了free()节点里的nickname字符串。valgrind直接指出definitely lost的字节数,让我5分钟内定位到那行缺失的free(node->nickname)。工具不能替代思考,但能让你思考得更准。
6. 代码二次开发与扩展建议:从学习到创造的跃迁路径
这套代码的终极价值,不在于它能做什么,而在于它为你铺好了“下一步做什么”的路。以下是经过验证的、切实可行的扩展方向,按难度递进:
6.1 协议升级:为TCP聊天添加JSON消息格式
当前 chat.c 使用纯文本协议(/nick Alice),简单但难扩展。升级为JSON,能轻松支持富文本、表情、图片URL等:
第一步:定义消息结构体
typedef struct {
char type[16]; // "login", "message", "private", "logout"
char sender[32]; // 发送者昵称
char receiver[32]; // 接收者昵称(私聊时用)
char content[512]; // 消息内容
long timestamp; // 时间戳
} chat_message_t;
第二步:序列化/反序列化
用 cJSON 库(轻量,头文件即可):
// 发送时
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "type", "message");
cJSON_AddStringToObject(root, "sender", "Alice");
cJSON_AddStringToObject(root, "content", "Hello JSON!");
char *json_str = cJSON_PrintUnformatted(root);
send(client_fd, json_str, strlen(json_str), 0);
cJSON_Delete(root);
free(json_str);
第三步:服务端解析
收到数据后,用 cJSON_Parse() 解析,根据 type 字段分发处理。这样,后续加“撤回消息”只需新增 "type":"recall" 和对应逻辑,无需改通信框架。
优势:JSON是通用格式,客户端可无缝对接Web前端(WebSocket)或移动端(Android/iOS),你的C服务端瞬间成为微服务架构的一环。
6.2 功能增强:为UDP聊天加入心跳保活与自动重连
udpF.c 的“在线列表”依赖客户端主动发包。网络抖动时,服务端会误判用户离线。加入心跳机制:
客户端改造:
// 启动一个心跳线程,每15秒发一次 ping
void* heartbeat_thread(void* arg) {
int sockfd = *(int*)arg;
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
while (1) {
sendto(sockfd, "ping", 4, 0, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
sleep(15);
}
return NULL;
}
服务端改造:
在 udpF.c 的客户端结构体中加 time_t last_heartbeat;,每次收到 ping 就更新。主循环中定时扫描 client_list[],time(NULL) - client.last_heartbeat > 30 则移除。
自动重连:
客户端 recvfrom() 超时(setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)))后,自动重建UDP socket并重发上次消息。这能让UDP聊天在弱网环境下保持“伪可靠”。
6.3 架构演进:用 epoll 替代 select,支撑千级并发
select() 的 FD_SETSIZE 限制(通常1024)和遍历开销,使其无法胜任高并发。epoll 是Linux专属高性能方案:
核心替换:
// 创建 epoll 实例
int epfd = epoll_create1(0);
// 添加监听 socket 到 epoll
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
// 主循环:epoll_wait 替代 select
struct epoll_event events[1024];
int nfds = epoll_wait(epfd, events, 1024, -1); // -1 表示阻塞
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == sockfd) {
// 新连接
int client_fd = accept(sockfd, ...);
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
} else {
// 收消息
recv(events[i].data.fd, buffer, ...);
}
}
收益:
- 时间复杂度从 O(n)(select 遍历所有fd)降到 O(1)(epoll 只返回就绪fd)
- 支持百万级连接(理论),实际受限于内存和CPU
- epoll 是生产环境标准,学会它等于拿到后端开发入场券
我的建议:先用
select版本(chat.c)吃透逻辑,再动手重写epoll版。不要试图一步到位,理解是渐进的过程。当你把epoll版本跑通,再回头去看nginx或redis的源码,那些epoll_ctl调用就不再是天书,而是你亲手写过的熟悉朋友。
这套代码合集,从来就不是终点。它是一把钥匙,一把打开Linux网络编程世界大门的钥匙。门后是什么?是你用 epoll 写的千万并发IM,是你用 cJSON 封装的物联网设备管理平台,是你在树莓派上跑的UDP智能家居中枢。而这一切的起点,就是你现在终端里 gcc -o server socketA.c && ./server 那行命令敲下去时,屏幕上闪过的那一行 Server listening on port 8080...。它微小,但真实;它简单,却蕴含无限可能。
简介:提供一套在Linux命令行环境下可直接编译运行的C语言网络聊天程序源码,覆盖TCP和UDP两种通信方式。TCP部分包含完整的服务端(tcpserver.c、socketA.c等)与多个客户端实现(tcpclient.c、chat.c、chatClient1.c等),支持多用户连接、消息广播与基础会话管理;UDP部分含udpA.c至udpF.c等不同交互模式的收发示例,适合对比理解无连接通信特点。配套有rad.c(简易绘图)、keypad.c(键盘输入模拟)、1draw.c(字符画)等辅助功能代码,帮助拓展学习场景。所有源文件基于标准POSIX Socket API编写,使用gcc即可编译(如gcc -o server tcpserver.c -lpthread),无需额外依赖。附带udp.txt说明文档,部分文件含.swp临时备份需手动清理。代码结构清晰、注释适量,聚焦bind、listen、accept、connect、send、recv等核心系统调用实践,适用于网络编程入门、课堂实验或自学调试。
400



被折叠的 条评论
为什么被折叠?



