
最近把03.25那次在Linux上用C做socket网络通信的完整过程整理了出来。当时的需求不算复杂就是写一个TCP服务端和客户端让两台机器能稳定地互发消息中间踩了几个很典型的坑包括端口被占用、客户端连接被拒、数据读到一半就断开之类。今天把整个思路、代码、调试过程和排查经验都写成文章给准备上手Linux下C网络编程的朋友做参考。先说这个内容适合谁看。如果你是刚学完C语言基础想搞清楚socket()、bind()、listen()这些函数到底怎么串起来的本文可以直接抄作业。如果你已经在写简单的socket程序但老遇到莫名其妙的问题后面几节排查思路也会有帮助。文章里的代码都是完整可编译的工具链只要一台Linux环境加gcc就够了。1.1 什么是socket以及为什么推荐LinuxC的组合很多初学者第一次看到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 gccCentOS/RHEL系用yum install gcc。装好之后写代码用vim、nano都可以。我这里使用vim因为习惯问题你完全可以用任何顺手编辑器。还有一件容易忽略的事如果你在云服务器上跑服务端不仅本地要开端口防火墙也要放行对应端口。比如服务端监听8888端口在CentOS上要执行firewall-cmd --permanent --add-port8888/tcp firewall-cmd --reload如果是在本机做实验Linux默认防火墙大概率不会拦截localhost上的socket但养成检查防火墙的习惯能省去后面联调时“代码没报错但客户端死活连不上”的困扰。2.2 核心API速查从socket到close的调用链条这组函数是TCP通信的骨干。我整理成表格你可以先存着后面看代码时对照理解函数作用关键参数/返回值socket()创建套接字AF_INET指定IPv4SOCK_STREAM指定TCP返回文件描述符bind()给服务端套接字绑定IP与端口地址结构体指针失败返回-1常见原因是端口被占用listen()服务端进入监听状态第二个参数是连接队列长度常见写法5或128accept()从队列里取一个客户端连接返回新的套接字描述符后续收数据用这个新fdconnect()客户端发起连接需填写服务器IP、端口的sockaddr_insend()/recv()TCP可靠收发数据相比write/read多了flags参数通常传0read()/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而且每次调用都要重新设置集合效率不高。适合教学和中等规模连接。第三种是epollLinux下高性能网络的标配。但epoll编程复杂度高涉及epoll_create、epoll_ctl、epoll_wait状态管理需要很清晰。如果只是想学基础先弄明白select的理念再升级到epoll会更平滑。我给一个select的简化思路把server_fd和一堆client_fd放进fd_set调用select(max_fd1, ...)阻塞等待。返回后遍历所有fd发现哪个fd可读就读哪个。这样单线程也能同时维护多个连接而且不会互相阻塞。面试题里经常让对比这三种方案你只要记住多进程最直观select最简单epoll性能最强。4.2 三大高频错误Address already in use、Connection refused、Broken pipe写socket代码下面这几个报错我可以说每个人都见过至少一次。第一个是Address already in use。调试时我经常CtrlC杀掉服务端然后立刻重启结果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通信有多一层理解。