最近把03.25那次在Linux上用C做socket网络通信的完整过程整理了出来。当时的需求不算复杂,就是写一个TCP服务端和客户端,让两台机器能稳定地互发消息,中间踩了几个很典型的坑,包括端口被占用、客户端连接被拒、数据读到一半就断开之类。今天把整个思路、代码、调试过程和排查经验都写成文章,给准备上手Linux下C网络编程的朋友做参考。
先说这个内容适合谁看。如果你是刚学完C语言基础,想搞清楚socket()、bind()、listen()这些函数到底怎么串起来的,本文可以直接抄作业。如果你已经在写简单的socket程序,但老遇到莫名其妙的问题,后面几节排查思路也会有帮助。文章里的代码都是完整可编译的,工具链只要一台Linux环境加gcc就够了。
1.1 什么是socket,以及为什么推荐Linux+C的组合
很多初学者第一次看到socket这个英文单词都会懵,它本意是“插座”。网络通信里的socket,你可以理解成在一台机器上开了个“网络插座”,另一台机器通过这个插座把数据递进来、递出去。操作系统对内提供文件描述符,对外提供网络数据收发,所以你读socket和读普通文件都差不多,核心都是用read和write(或者send/recv)这套系统调用。
为什么教程和面试题都喜欢用Linux加C?因为Linux下socket几乎是最干净的系统调用接口,没有Windows那一堆初始化流程,也不用担心运行库兼容问题。C语言又能直接操作底层结构体,比如struct sockaddr_in,你清楚看到IP和端口是怎么填入内存的,这比Java、Python的封装版更容易建立正确的网络编程心智模型。
我用过不少高级语言去封装socket,最直观的感受是:用Python写网络通信很快,但一旦出现TCP半关闭、粘包、字节序这类底层问题时,还是得回到C的视角去分析。这也是为什么我强烈建议新手至少用C完整写一遍TCP通信,把三次握手怎么从代码里体现、缓冲区怎么读、关闭连接时两端状态如何变化,全都摸清楚,后面学什么框架都轻松。
1.2 网络编程要搞清楚的三个地基概念
在写代码之前,有三个概念必须先理清:IP、端口、协议类型。
IP地址相当于城市地址,端口相当于具体门牌号。一台服务器有IP,但上面可能同时跑了一堆服务,Web用80、SSH用22、数据库用3306。bind()要绑定的就是“本机IP + 端口”这个组合。客户端想连接服务器,也必须指定“目标IP + 目标端口”,缺一个都连不上。
协议类型这边,最常用的是SOCK_STREAM和SOCK_DGRAM,对应TCP和UDP。TCP像打电话:先拨号、对方接听、确认彼此都在,然后说一句听一句,挂断也知道双方结束。UDP像发快递单里的明信片:扔进邮筒就完事,对方没收到你也不知道,收件顺序也可能乱。你需要实时性还是可靠性,直接决定选哪个。
还有个很容易被忽略的基础,就是字节序。网络上统一用大端序传输数据,而x86机器是小端序,所以填端口和IP时要用htons()和htonl()转换。我第一次写服务端没加htons(),结果本机自己连自己没问题,换到另一台机器去连就完全不通,排错排了半天,查出来就是字节序没转。这个细节后面还会再展开。
2.1 环境准备:gcc安装与防火墙端口检查
写C socket不需要什么重型IDE,命令行加文本编辑器就够了。我习惯在Ubuntu或者CentOS这类发行版上操作,先确认gcc是不是装好了。
gcc --version没装的话,Ubuntu/Debian用apt install gcc,CentOS/RHEL系用yum install gcc。装好之后,写代码用vim、nano都可以。我这里使用vim,因为习惯问题,你完全可以用任何顺手编辑器。
还有一件容易忽略的事:如果你在云服务器上跑服务端,不仅本地要开端口,防火墙也要放行对应端口。比如服务端监听8888端口,在CentOS上要执行:
firewall-cmd --permanent --add-port=8888/tcp firewall-cmd --reload如果是在本机做实验,Linux默认防火墙大概率不会拦截localhost上的socket,但养成检查防火墙的习惯,能省去后面联调时“代码没报错但客户端死活连不上”的困扰。
2.2 核心API速查:从socket到close的调用链条
这组函数是TCP通信的骨干。我整理成表格,你可以先存着,后面看代码时对照理解:
| 函数 | 作用 | 关键参数/返回值 |
|---|---|---|
socket() | 创建套接字 | AF_INET指定IPv4,SOCK_STREAM指定TCP;返回文件描述符 |
bind() | 给服务端套接字绑定IP与端口 | 地址结构体指针;失败返回-1,常见原因是端口被占用 |
listen() | 服务端进入监听状态 | 第二个参数是连接队列长度,常见写法5或128 |
accept() | 从队列里取一个客户端连接 | 返回新的套接字描述符,后续收数据用这个新fd |
connect() | 客户端发起连接 | 需填写服务器IP、端口的sockaddr_in |
send()/recv() | TCP可靠收发数据 | 相比write/read多了flags参数,通常传0 |
read()/write() | 从套接字读写 | 返回值为读到/写入的字节数,0表示对端关闭 |
close() | 关闭一个套接字 | 关闭后fd不可再使用 |
这些函数之间的调用顺序很像流水线:服务端先socket(),再bind()固定身份,然后listen()表示接受外部连接,最后进入循环accept()处理客户端。客户端只需要socket()和connect(),连接成功就能收发数据了。
所有返回值为负数的系统调用都需要立刻检查,绝不能忽略。我在练习时最喜欢犯的错就是漏掉错误分支,导致程序跑到一半崩溃,回头检查代码才发现某个关键函数根本没判断失败。perror()会直接打印错误描述,一行就能定位问题。
3.1 服务端实现流程与代码解析
下面是一份最简单的单连接TCP服务端,循环接收客户端消息并固定回一句“Hello from server”。
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define PORT 8888 #define MAX_MSG 1024 int main() { int server_fd, client_fd; struct sockaddr_in server_addr, client_addr; socklen_t addr_len = sizeof(client_addr); char buf[MAX_MSG]; server_fd = socket(AF_INET, SOCK_STREAM, 0); if (server_fd < 0) { perror("socket"); exit(1); } memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(PORT); if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); close(server_fd); exit(1); } if (listen(server_fd, 5) < 0) { perror("listen"); close(server_fd); exit(1); } printf("Server listening on port %d...\n", PORT); while (1) { client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addr_len); if (client_fd < 0) { perror("accept"); continue; } printf("Client connected: %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); ssize_t n = read(client_fd, buf, MAX_MSG - 1); if (n < 0) { perror("read"); close(client_fd); continue; } buf[n] = 0; printf("Received: %s\n", buf); write(client_fd, "Hello from server\n", 18); close(client_fd); } close(server_fd); return 0; }逐段解析一下。socket(AF_INET, SOCK_STREAM, 0)创建的是IPv4的TCP套接字,第三个参数协议填0表示让系统自动选择TCP。bind里的INADDR_ANY是个宏,展开就是0.0.0.0,表示监听本机所有网卡。如果你只想让本机访问,可以改成inet_addr("127.0.0.1"),这样外部网卡就不会暴露这个服务。
listen(server_fd, 5)的第二个参数是队列长度,也就是允许有多少客户端连接排队等待accept()处理。实际项目中会更长,但做演示5就够了。
accept()一返回,client_fd就是和这个客户端绑定的新套接字。这里有个细节要强调:后面收发数据必须用client_fd,而不是server_fd。server_fd始终负责继续接收新连接,如果搞混了,通信就会出错。
inet_ntoa(client_addr.sin_addr)把二进制的IP转成点分十进制字符串,打印出来方便观察客户端来源。read()读取的是TCP流,返回字节数可能比一次请求少,也可能多粘了几条消息,这就是TCP流式协议的特点。演示场景里一次请求很小,所以直接按一次读取处理。真正的项目要处理粘包,我后面会在第4节讲。
3.2 客户端实现流程与代码解析
客户端比服务端简单很多,主要就是三步:创建套接字、发起连接、收发数据。
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define PORT 8888 #define MAX_MSG 1024 int main(int argc, char *argv[]) { int sock_fd; struct sockaddr_in server_addr; char buf[MAX_MSG]; if (argc != 2) { fprintf(stderr, "Usage: %s <server_ip>\n", argv[0]); exit(1); } sock_fd = socket(AF_INET, SOCK_STREAM, 0); if (sock_fd < 0) { perror("socket"); exit(1); } memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(PORT); if (inet_pton(AF_INET, argv[1], &server_addr.sin_addr) <= 0) { perror("inet_pton"); exit(1); } if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) { perror("connect"); exit(1); } printf("Connected to server %s:%d\n", argv[1], PORT); write(sock_fd, "Hello from client", 17); ssize_t n = read(sock_fd, buf, MAX_MSG - 1); if (n > 0) { buf[n] = 0; printf("Server replied: %s\n", buf); } close(sock_fd); return 0; }inet_pton是“点分十进制字符串转网络字节序二进制IP”的推荐函数,比老式的inet_addr更稳妥。connect()的第三个参数是服务器地址结构体的大小,别小看这个参数,填错就会返回Invalid argument。
客户端不需要bind(),因为系统会在connect()时自动分配一个临时端口给本地套接字。你甚至可以用getsockname()查看自动分配到的端口,不过演示代码用不到,不多说。
3.3 编译运行与本地联调
把服务端代码保存为server.c,客户端保存为client.c,分别编译:
gcc -o server server.c gcc -o client client.c先启动服务端:
./server正常会输出:
Server listening on port 8888...再开一个终端运行客户端:
./client 127.0.0.1客户端会打印Connected to server 127.0.0.1:8888和Server replied: Hello from server,服务端会打印收到消息和客户端IP端口。
如果不想写客户端程序,也可以用系统自带工具测试。telnet 127.0.0.1 8888连上去输入一句话,服务端照样能打印出来。或者用nc 127.0.0.1 8888,一样可以模拟TCP连接。这些工具在排错时特别方便,能帮你把问题定位在“server代码问题”还是“client代码问题”上。
4.1 从单线程到多路复用:fork、select、epoll选型
上面这份代码有个明显的瓶颈:同一时间只能服务一个客户端。它循环accept()后立刻阻塞在read()上,如果一个客户端连上就一直不给服务器发数据,后来的客户端就算连上了也排不进队列,表现就是卡死。解决思路主要有三种:
第一种是多进程或多线程。服务端每accept()到一个连接,就fork()一个子进程去处理,父进程继续accept()。代码简单直观,但连接多了以后进程/线程开销大,上下文切换也会吃掉性能。适合连接数量少、需求简单的小工具。
第二种是IO多路复用,比如select()。它一次监听多个fd,哪个fd可读就往哪个fd读。select支持数量有限制,通常和FD_SETSIZE相关,一般默认1024,而且每次调用都要重新设置集合,效率不高。适合教学和中等规模连接。
第三种是epoll,Linux下高性能网络的标配。但epoll编程复杂度高,涉及epoll_create、epoll_ctl、epoll_wait,状态管理需要很清晰。如果只是想学基础,先弄明白select的理念,再升级到epoll会更平滑。
我给一个select的简化思路:把server_fd和一堆client_fd放进fd_set,调用select(max_fd+1, ...)阻塞等待。返回后遍历所有fd,发现哪个fd可读就读哪个。这样单线程也能同时维护多个连接,而且不会互相阻塞。面试题里经常让对比这三种方案,你只要记住:多进程最直观,select最简单,epoll性能最强。
4.2 三大高频错误:Address already in use、Connection refused、Broken pipe
写socket代码,下面这几个报错我可以说每个人都见过至少一次。
第一个是Address already in use。调试时我经常Ctrl+C杀掉服务端,然后立刻重启,结果bind()就报这个错。原因是端口还处于TIME_WAIT状态,要等几十秒才能完全释放。解决办法是在socket()和bind()之间设置端口复用:
int opt = 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));加完这行,重启服务端就不会被“上一缕灵魂”挡住了。这个设置在生产环境几乎都是必加的,否则发版重启一次就得等一分钟,特别难受。
第二个是Connection refused。客户端报这个错,基本可以断定连接请求根本没到服务端进程面前。排查顺序很简单:先确认服务端是否在运行,再看端口对不对,接着检查防火墙有没有放行。三个都没问题,再看INADDR_ANY和客户端连接的IP是否匹配。有一次我把服务端绑到了127.0.0.1,然后客户端用内网IP去连,结果一直Connection refused,就是这个原因。
第三个是Broken pipe。服务端已经关闭了连接,客户端还在往这个socket上写数据,系统就会向进程发送SIGPIPE信号。这个信号的默认行为是终止进程。所以网络服务端程序常用signal(SIGPIPE, SIG_IGN)忽略它,然后靠send()/write()的返回值判断连接已经断开,再做清理工作。
还见过一种情况是客户端正常结束后,服务端继续read(),返回0。read()返回0不代表出错,而是表示对端已经正常关闭了写端。服务端代码必须处理这个分支,及时close(),否则fd会一直堆积,最终把进程的文件描述符耗尽。
4.3 C语言网络细节:字节序、返回值检查和TCP边界
前面提过字节序,这里再具体演示一下。在x86机器上整数的小端存储是这样的:数字0x1234实际内存是34 12。而网络传输要求大端,即内存顺序是12 34。所以填端口必须写:
server_addr.sin_port = htons(PORT);htons是“host to network short”的缩写。IP地址因为是32位,用htonl()。如果不转换,本机直接回环访问时因为双方都是小端,可能碰巧能通,但跨机器就会出大问题。这也是我前面说的那个坑。
返回值检查再强调一遍:所有socket相关函数几乎都返回标准,确认型函数(socket、bind、listen、connect)返回值小于0就是失败,数据型函数(read、recv、write、send)返回值是实际字节数,可能为0。我见过不少人的代码只检查了前者的负值,却忽略了后者为0的情况,导致程序进入死循环。一个健壮的通讯循环必须对n == 0和n < 0分别处理。
TCP是个字节流协议,没有消息边界。客户端发送“Hello”和“World”,服务端一次read()可能读到“HelloWorld”,也可能只读到“Hell”。这就是所谓的粘包问题。解决方案通常是在消息前加固定长度的包头,里面存消息长度;服务端先读完包头,再按长度读完整包。这是网络编程进阶会遇到的第一个硬骨头,建议你后面专门研究。
5.1 三件套调试工具:gdb、strace、netstat
写socket最容易发生的问题是“代码不报错,但行为不对”。这时候光靠printf效率太低,得用工具。
gdb是最基础的进程调试器。比如服务端卡在accept(),可以用gdb -p 进程号挂上去,执行bt看调用栈,或者info threads看线程状态。gdb还能对正在运行的程序加断点,不过实际生产环境很少这样干,因为会打断线上服务。
strace是我非常喜欢的一个系统调用跟踪工具。它能打印进程执行了哪些系统调用,以及每个调用的参数和返回值。比如怀疑accept()没收到连接,执行:
strace -f -p 服务端进程ID就能看到accept是否在阻塞等待,或者read是否返回了-1。网络问题十有八九都能从系统调用层面看穿。
netstat和更新的ss用来查看当前端口状态。我排错的第一步永远是:
ss -antlp | grep 8888输出里能看到LISTEN状态,说明服务端正常监听。能看到ESTABLISHED,说明客户端已经连上。如果服务端程序已启动但这里查不到端口,那多半就是bind()或防火墙的问题。
5.2 新手避坑速查表
我把平时答疑时遇到的高频问题整理成一张表,可以直接对照。
| 现象 | 常见原因 | 排查/解决 |
|---|---|---|
| bind报Address already in use | 端口还在TIME_WAIT | 加SO_REUSEADDR,或等一会儿再启动 |
| connect报Connection refused | 服务端没起来/端口不对/监听在别的IP | 查ss -antlp,确认监听地址是否为0.0.0.0 |
| 客户端能连上但收不到数据 | 服务端read/write逻辑错误 | strace跟踪服务端,或加日志打印 |
| 并发一高就出错 | 单线程accept串行处理 | 改用多线程或select/epoll |
| 程序莫名退出 | SIGPIPE信号触发默认终止 | signal(SIGPIPE, SIG_IGN) |
| 数据多包乱序/半包 | TCP字节流无边界 | 消息加包头,按长度接收 |
| 自己连自己能通,别人连不上 | 绑定了127.0.0.1 | 监听INADDR_ANY或0.0.0.0 |
这张表也是我自己的排查清单,碰到问题先查表,能省很多时间。
5.3 避坑之外的实操体会
最后分享一点我自己的感受。03.25那天调试时踩得最深的一个坑,是在服务器上写完服务端,本地Windows上用客户端去连,结果怎么都连不上。前后看了所有常规原因都没用,最后才发现是云平台的安全组只开了22和80端口,8888从来没放行。
所以从这个经历里我总结出一个固定顺序:代码先在同一台机器自测通,再跨机器测试;跨机器测试不通,先查防火墙和安全组,再查代码。
还有一个小技巧,调试时用nc -vz 127.0.0.1 8888测试端口是否开放。它能快速告诉你端口通不通,输出来断点进一步排查。这个小命令在面试和日常运维里也很常用,建议顺手记住。
如果你正好也在学习Linux下的C socket网络编程,可以从这份代码开始,自己试着改成多客户端并发,或者加上简单的包头协议。每改一次,你都会对TCP通信有多一层理解。