套接字Socket编程这块,很多人一上来就打开编辑器写代码,结果被各种编译错误、段错误、连接失败搞得一头雾水。其实问题多半不是出在代码上,而是预备知识没补齐。这篇东西就是写给那些正准备接触Linux网络编程的人,先从预备阶段把网络分层、地址结构、字节序、端口这些底层逻辑理顺,再谈怎么写代码。我会用手头实际验证过的思路,把关键概念拆开讲清楚,顺便给出一套可以直接复现的最小场景,让新手少走几个月的弯路。
1. 先搞清楚Socket到底是个什么东西
1.1 用两层楼之间的电话线来理解Socket
很多教材爱讲“Socket是网络通信的端点”,这话没错,但对第一次接触的人来说太抽象了。我更喜欢用办公楼的内部电话来类比:你所在的三楼部门需要跟五楼部门沟通,不会直接扯着嗓子喊,而是各拿一部电话,拨通对方分机。这里的分机号码就是“IP地址+端口”的组合,电话线就是底层网络,而两个人在电话里说话用的那部话机,你可以把它想象成Socket。
也就是说,Socket并不是一个“协议”,也不是网络本身,而是操作系统提供给你的一个编程接口。它把IP地址、端口、协议类型这些底层细节封装成一个抽象对象,你的进程只需要拿着这个对象,往里写数据、从里面读数据,就能完成跨机器的通信。在Linux里,这个对象本质上是文件描述符,所以在预备阶段就要建立这个意识:Socket操作在形式上跟读写文件非常像,但背后牵涉的网络状态机、缓冲区、拥塞控制等机制,远比文件复杂。
1.2 Linux的“一切皆文件”与Socket的独特之处
Linux设计哲学里有句话叫“一切皆文件”,Socket也确实是文件描述符的一种。你可以用close(fd)关掉它,也可以用read和write读写它,这一点让很多人误以为Socket编程就是文件编程。实际上,除了read/write,Socket还有自己专属的系统调用:bind、listen、accept、connect、send、recv。这些操作在普通文件上是不存在的,它们用来处理网络连接的生命周期。
所以预备阶段的一个关键认知是:同一个文件描述符,在不同状态下行为完全不同。比如TCP的Socket,在监听状态、已连接状态、关闭状态下手里的可用操作集各有不同。如果你拿普通文件的思路去套,最容易踩的坑就是在没有listen的情况下直接accept,或者在已经关闭的fd上继续write,这些行为不会像普通文件那样给你一个友好的报错,而是可能直接让进程崩溃。
1.3 预备阶段先在心里种下这张图
我建议在学习任何API之前,先在心里建立一张Socket通信的全景图:服务端创建一个Socket文件描述符,把它Bind到一个IP地址和端口上,然后Listen进入监听状态,阻塞等待客户端的连接请求;客户端创建一个Socket,调用Connect发起连接;三次握手完成后,服务端的Accept返回一个新的文件描述符,这个新fd才是双方真正通信用的通道。原来那个监听fd继续留在原地,等待下一个连接。
这张图看起来简单,但很多资深工程师排查问题时,脑子里都是先过这张图,再决定该看哪一层的日志或抓包。预备阶段不需要把每个API背下来,而是要把这张图的先后顺序和每个阶段的状态变化记牢。后面所有代码都只是这张图的具象化。
2. 正式编码前必补的网络背景知识
2.1 不分清楚TCP/IP分层,后面全是糊涂账
Linux网络编程中90%的疑难杂症,根源都是分层概念不清晰。TCP/IP模型自上而下分为应用层、传输层、网络层、链路层,你的Socket代码跑在应用层和传输层之间——你负责构造应用层数据,内核的TCP/IP协议栈负责处理传输层和网络层的各种细节。
我见过不少新手把“Socket连不上”归因于网卡或路由器,这往往是混淆了网络层和传输层的错误。判断问题出在哪一层,是预备阶段就要建立的排查本能:如果是connect报Connection refused,说明网络层是通的,只是对端没有进程在监听那个端口;如果是Destination Host Unreachable,那才说明问题在网络层或链路层。分层意识能帮你把排错范围缩小一个数量级。
预备阶段可以做个简单实验来加深分层理解:在一台机器上同时运行两个用不同端口监听的TCP服务,然后从另一台机器分别连接这两个端口。你会发现端口通不通只取决于传输层和应用层状态,跟这台机器的网络连通性没有必然关系。
2.2 端口和IP互相配合,才能让数据找到你的程序
IP地址解决的是“数据包到达哪台机器”的问题,端口解决的是“数据包交给机器上的哪个进程”的问题。一台服务器可以同时跑着SSH(22)、Web(80)、数据库(3306),它们共用一个IP,靠端口号区分彼此。在Socket编程里,你需要熟悉端口的两类角色:
- 主动连接方(客户端):端口通常是临时分配的,由操作系统在connect时自动挑选一个空闲端口,你不需要手动指定。
- 被动监听方(服务端):端口必须显式指定,并且要提前确定不会被别的进程占用。
这里有个预备阶段最常见的错误:以为监听端口可以随意重复使用。实际上,如果没有设置SO_REUSEADDR,两个进程bind同一个端口会直接失败,报Address already in use。这部分细节我在后面的避坑章节会展开。
另外要注意端口号的范围。TCP端口号是16位无符号整数,范围是0到65535,其中0到1023是特权端口,只有root用户才能bind。如果你调试时想监听8000、8080、9000这类高端口,通常没问题,但如果你想监听80或443,就必须用root权限或通过授权机制分配能力。
2.3 三次握手和四次挥手:写代码前先看懂状态变化
TCP的可靠性建立在连接状态管理之上,而连接状态的转变直接体现在你的Socket API调用上。预备阶段不需要把每个标志位背下来,但三次握手和四次挥手的状态机一定要理解到能讲故事的程度。
三次握手:客户端发SYN,服务端回SYN+ACK,客户端再回ACK,至此连接建立。映射到编程上,客户端的connect成功返回发生在收到SYN+ACK并发出ACK之后;服务端的accept成功返回发生在连接转入ESTABLISHED状态之后。所以你经常可以看到服务端accept还没返回,客户端那边connect已经返回成功了,这是正常的。
四次挥手:主动关闭方发FIN,对端回ACK,对端再发FIN,主动关闭方再回ACK。这个过程会导致一个问题——主动关闭方在发出最后一个ACK后,要进入TIME_WAIT状态,让这个ACK有机会被重传。TIME_WAIT通常持续两倍的最大报文段生存时间,你可以用netstat看到大量TIME_WAIT状态的连接。预备阶段你只要知道:频繁重启服务端程序时,很可能遇到“端口被占用”却找不到占用进程的诡异情况,十有八九就是还有连接停留在TIME_WAIT状态。
2.4 字节序:大端小端的坑,必须现在避开
CPU内存中多字节数据的存储顺序分为大端和小端。网络传输规定使用大端字节序,也叫网络字节序。你在代码里写的端口号是主机字节序,比如8080这个整数在内存里可能是小端排列,但网络协议要求它在包头里以大端出现,所以必须通过htons、htonl这类函数转换。
这个坑的典型表现是:你明明bind的是8080,用netstat看却是54380之类的诡异数字,或者客户端连不上服务端。原因就是端口号发出去的时候没有做字节序转换,接收方解析出来的端口完全是另一个数。
预备阶段要把以下四个函数当成“肌肉记忆”:htons(主机字节序转网络字节序,16位)、htonl(32位)、ntohs(网络转主机,16位)、ntohl(32位)。对应关系简单记就行:发之前h开头的转出去,收之后n开头的转进来。我建议在第一行socket代码之前,先写个小程序验证一下本机是大端还是小端,再验证一下htons(8080)的返回值。这个过程比背十遍定义都管用。
3. 需要准备的学习环境和工具链
3.1 最小环境就三样东西:Linux系统、编译器、基础库
预备阶段不搞花哨环境,一台能跑Linux的机器就够用。可以是实体机,也可以是一台云主机,哪怕是虚拟机里的Ubuntu Server 22.04或Debian都行。好消息是Socket编程用到的头文件和库几乎在所有发行版都是标配,比如sys/socket.h、netinet/in.h、arpa/inet.h,这些都在libc库里,不需要额外安装。
编译器方面,gcc是首选,装好Linux后一般自带,没带的话直接apt install build-essential。另外我建议顺手装一下make,虽然单文件直接用gcc编译也没问题,但后续项目文件一多,有Makefile会舒服很多。
验证环境是否就绪很简单,写一个只有三行的C程序:#include <sys/socket.h>、int main(){ return 0; },然后编译。编译能过,就说明你的核心Socket头文件和链接库都没问题。这一步看似多余,但它能帮你区分“环境问题”和“代码问题”,对初学者来说价值非常大。
3.2 除了编辑器,这三类调试工具是刚需
写Socket程序的时候,你需要的不仅仅是编辑器,更重要的是一套能帮你“看穿”内核网络状态的工具。
第一类是网络状态查看工具,我优先推荐ss和netstat。ss -lntp可以列出当前所有监听TCP端口以及对应的进程PID,排查端口占用时非常管用。
第二类是抓包工具,tcpdump和Wireshark二选一。预备阶段建议先学tcpdump,它轻量、服务端也能用,一行命令就能看到SYN、SYN+ACK、ACK的往来过程。抓包能让你把三次握手的理论跟实际网络包对应起来,这是单纯看代码永远得不到的经验。
第三类是系统调用跟踪工具strace。它可以打印进程发起的所有系统调用,比如你可以看到程序到底调用了哪个socket函数、参数是什么、返回值是多少。当程序看起来“卡住”的时候,strace能帮你判断是阻塞在accept上,还是阻塞在recv上。
3.3 我自己惯用的一套预备测试环境
我个人的习惯是准备两台机器,一台当作服务端,一台当作客户端。没有两台物理机的话,用两台虚拟机或者在机器上跑两个进程也可以,只要IP地址不同即可。关键是要想清楚:同一台机器上两个进程用127.0.0.1通信,跟跨机器通信在很多细节上没有本质区别,但用真实IP可以让你顺手锻炼防火墙、网卡、路由方面的排查能力。
我通常还会用一台云主机来做公网环境下的预备测试。云主机的好处在于,天然具备一个公网IP,可以让你提前感受NAT、防火墙安全组对端口的影响。很多人在本地两台虚拟机里写Socket程序没事,一放到公网云主机上就连接超时,原因往往是安全组没放行对应端口,而这个问题在本地环境根本不会暴露。预备阶段把这个坑趟一遍,后面做真实项目时能省掉大量无头绪的排查时间。
4. 预备阶段必看的核心概念与关键结构体
4.1 地址结构体sockaddr_in:所有地址信息的载体
Linux的Socket API使用struct sockaddr_in来承载IPv4地址信息。这个结构体定义在<netinet/in.h>里,核心成员就三个:sin_family(地址族,IPv4填AF_INET)、sin_port(端口号,必须网络字节序)、sin_addr(IP地址,同样是网络字节序)。另一个老式写法是struct sockaddr,它是通用地址结构,只包含一个地址族和一个14字节的数据区。很多API的签名都要求传struct sockaddr*,所以你需要经常做强制类型转换:(struct sockaddr*)&serv_addr。
新手最蒙的就是为什么不能直接把一张IP地址字符串丢进sin_addr。因为字符串“192.168.1.100”是给人看的,内核需要的是32位无符号整数形式的二进制IP。字符串和二进制之间的转换由inet_pton完成,预备阶段建议死记这个函数:inet_pton(AF_INET, "192.168.1.100", &serv_addr.sin_addr)。同样,打印IP的时候用inet_ntop把二进制转回字符串。
4.2 文件描述符:每次socket调用都会给你一个整数
int socket(int domain, int type, int protocol)返回一个整数,这个整数就是文件描述符。Linux进程默认打开0、1、2分别对应标准输入、标准输出、标准错误,所以一般你创建的Socket会是3、4、5这些数字。这个编号背后对应的是内核里一张打开文件表,预备阶段不需要深究内核数据结构,但要建立一个意识:这个整数是有资源限制的。
你可以用ulimit -n查看当前进程能打开的最大文件描述符数量,默认通常是1024。对简单的网络编程训练来说,这个数量绰绰有余,但当你要写高并发服务器时,这就是第一个瓶颈。预备阶段把这个概念记住,以后设计连接管理、异步事件模型的时候会不断用到它。
还要注意,Socket文件描述符不是普通文件偏移量维护的对象,它的读写行为是流式的。这就意味着你调用read的时候,可能只读到了对方发来的一部分数据,而不是一次把完整消息读完。TCP是字节流协议,没有消息边界,这个特性让很多人第一次写出“收到半条消息”的bug。预备阶段先接受这个现实,后面学习粘包、拆包、应用层协议设计就顺理成章了。
4.3 API速览:预备阶段先记住这几个函数的名字和职责
严格来说,Socket编程API远不止五个,但预备阶段把下面这张图记牢,后续的细节学习就有章法可循。我用一个表格整理它们在通信流程中的位置,方便你对照前面的全景图:
| 函数 | 角色 | 核心职责 |
|---|---|---|
socket() | 创建端点 | 返回一个Socket文件描述符 |
bind() | 服务端绑定 | 把地址和端口绑定到Socket上 |
listen() | 服务端监听 | 把主动Socket转为被动监听,内核开始接受连接请求 |
accept() | 服务端接收 | 从已完成三次握手的队列里取出一个连接,返回新fd |
connect() | 客户端发起 | 向对端发起连接请求,触发三次握手 |
send()/recv() | 双向传输 | 在已建立的连接上读写应用数据 |
close() | 释放资源 | 关闭连接并释放文件描述符 |
这张表不需要背参数,但每个函数的“前后关系”必须清楚。比如,bind一定发生在listen之前,accept一定发生在listen之后。客户端不需要bind,直接connect,连接成功后也不用accept。这个顺序关系一旦记错,编译能过,程序跑起来却会报奇怪错误。
4.4 写第一行代码前,先做一套“知识点自测”
在正式动手敲代码前,我建议你花半小时做一套自测题,检验预备知识是否到位。这套自测题不是考察你背了多少函数名,而是考察你在面对具体场景时能否做出正确判断。
自测一:服务端代码里写了bind(8080),结果启动报错“Address already in use”,你应该最先检查什么?
自测二:客户端连接服务端超时,ping服务端IP却通了,问题可能出在哪一层?
自测三:你收到的端口号解析出来是12345,但发送方明明发的是8080,可能是什么原因?
这三道题的答案分别是:是不是有进程已经占用了这个端口,或者有没有TIME_WAIT残留;问题大概率出在传输层或应用层,比如服务端没监听、防火墙拦截了端口;字节序没有做转换。如果这三道题你能在10秒内说出思路,说明预备阶段知识已经足够支撑你开始写真正的Socket代码了。
5. 从预备到第一行代码的实操路径
5.1 先别急着写C,用现成工具把通信流程跑一遍
我强烈建议预备阶段先不要写C代码,而是用系统自带工具把“连接建立、数据收发、连接关闭”完整流程走一遍。这个练习的目的,是让你在真正面对API之前,先在感官上建立对网络通信过程的直觉。
第一个实验是回环ssh的替代品。机器A上执行:
nc -l 12345机器B上执行:
nc 192.168.1.100 12345两边建立连接后,任意一边输入文字,另一边就会显示出来。你会发现,这个过程的体验跟两个人拿着对讲机说话很像,但背后已经完成了一次完整的TCP连接建立和数据传输。
第二个实验是观察三次握手。在服务端执行nc -l 12345的同时,在另一终端执行tcpdump -i any port 12345,然后在客户端发起连接。你会在tcpdump的输出里看到SYN、SYN-ACK、ACK三个包,顺序和时序一目了然。这一步我称之为“让网络包可视化”,它比看任何教科书都更能建立直觉。
第三个实验是观察四次挥手。连接建立后,主动按Ctrl+C关闭任意一端,然后在tcpdump里观察FIN包的交互。你会发现主动关闭方会经历一段状态的延迟才完全释放端口,这就是前面说的TIME_WAIT。
5.2 手动“拆解”一个TCP连接:理解每一段状态
在上面的实验中,你可以用ss或netstat实时观察连接状态。客户端发起connect后,连接会依次经过SYN_SENT、ESTABLISHED;服务端连接出现在成对的状态中,监听socket保持在LISTEN,已接受连接则是ESTABLISHED。
预备阶段,我建议你配合抓包工具,给自己提三个问题:
- 如果客户端发起的SYN没有得到回应,状态会停在哪里?
- 如果服务端进程被杀掉,已建立的连接会变成什么状态?
- 如果连接建立后彼此都不发数据,它会不会一直存在?
这三个问题的答案分别是:SYN_SENT或反复重试的状态变化、连接被重置(可能看到RST)、不会,内核有超时机制最终会断开。做这个实验不是为了掌握多少运维知识,而是为了理解TCP状态机与进程生命周期之间的关系。这个理解会在你以后调试各种“连接莫名其妙断掉”的问题时反复用到。
5.3 预备验收:编译运行一个最小TCP回显程序
走到这一步,可以正式写第一段代码了。我用一个最小TCP回显程序作为“预备阶段毕业作品”,它的逻辑非常简单:服务端接收客户端的连接,把收到的数据原样发回去。这个例子只有几十行,但覆盖了socket、bind、listen、accept、recv、send这几个核心调用。
服务端代码:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define PORT 8080 #define BUFFER_SIZE 1024 int main() { int server_fd, client_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_len = sizeof(client_addr); char buffer[BUFFER_SIZE]; // 1. 创建Socket, AF_INET表示IPv4, SOCK_STREAM表示TCP server_fd = socket(AF_INET, SOCK_STREAM, 0); if (server_fd < 0) { perror("socket"); exit(1); } // 2. 绑定地址和端口 memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = INADDR_ANY; // 绑定0.0.0.0,接受任意网卡上的连接 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); } // 3. 进入监听状态, 5表示已完成握手但未accept的连接队列长度 if (listen(server_fd, 5) < 0) { perror("listen"); close(server_fd); exit(1); } printf("Server is listening on port %d\n", PORT); while (1) { // 4. 接受一个客户端的连接 client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len); if (client_fd < 0) { perror("accept"); continue; } char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip)); printf("Accepted connection from %s:%d\n", client_ip, ntohs(client_addr.sin_port)); // 5. 回显数据 while (1) { int n = recv(client_fd, buffer, sizeof(buffer), 0); if (n <= 0) { if (n == 0) { printf("Client closed connection\n"); } else { perror("recv"); } break; } send(client_fd, buffer, n, 0); } close(client_fd); } close(server_fd); return 0; }客户端代码:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define PORT 8080 #define SERVER_IP "127.0.0.1" int main() { int client_fd; struct sockaddr_in server_addr; char buffer[1024]; // 1. 创建客户端Socket client_fd = socket(AF_INET, SOCK_STREAM, 0); if (client_fd < 0) { perror("socket"); exit(1); } // 2. 构造服务器地址 memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(PORT); if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0) { perror("inet_pton"); close(client_fd); exit(1); } // 3. 发起到服务器的连接 if (connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) { perror("connect"); close(client_fd); exit(1); } printf("Connected to server\n"); while (1) { printf("Enter message (quit to exit): "); fgets(buffer, sizeof(buffer), stdin); buffer[strcspn(buffer, "\n")] = '\0'; if (strcmp(buffer, "quit") == 0) { break; } send(client_fd, buffer, strlen(buffer), 0); int n = recv(client_fd, buffer, sizeof(buffer) - 1, 0); if (n > 0) { buffer[n] = '\0'; printf("Echo: %s\n", buffer); } } close(client_fd); return 0; }编译的命令我一般直接写:
gcc -Wall -Wextra -o server server.c gcc -Wall -Wextra -o client client.c先用一个终端运行./server,再用另一个终端运行./client,输入任意字符串,你会在客户端看到服务端原样返回的消息。这个程序跑通,说明你对核心流程的理解和操作已经达到预备阶段验收标准。
5.4 需要预先设计好的边界逻辑
上面的代码里,有几个细节不是随手写的,而是刻意安排的。第一个是服务端用了INADDR_ANY而不是具体的IP地址,这样绑定的是0.0.0.0,本机所有网卡的请求都能接受。如果你写成127.0.0.1,那只有本地回环地址能连上来,跨机器访问会直接失败。预备阶段建议先把INADDR_ANY记牢,用真实IP做测试时再细究具体绑定方式。
第二个是recv的返回值处理。recv返回0表示对端正常关闭连接,返回-1表示出错,返回正数才是实际收到的字节数。我见过很多初学者不看返回值就继续用缓冲区里的数据,结果读到的是上一次残留的旧数据,数据错乱都是这么来的。
第三个是循环里的accept和后续处理都在同一线程里。这个程序一次只能处理一个连接,新连接必须等当前连接断开后才能被接受。这暴露了最原始的“单线程阻塞模型”的性能局限。预备阶段不需要立刻解决这个问题,但要有意识:后续学习多线程、非阻塞IO、epoll等高级话题,最原始的动机就是让服务器能同时服务大量客户端。
6. 常见问题与排查技巧实录
6.1 连接不上,第一步到底该查什么
很多新手在connect失败后,第一反应是检查代码逻辑,但代码逻辑反反复复看也看不出问题。这时候应该把重心从代码切换到环境。我自己的排查顺序是固定的:
- 先确认服务端进程在不在:
ps -ef | grep 服务端程序名。 - 再确认端口有没有在监听:
ss -lntp | grep 8080。 - 然后确认客户端能否到达服务端的这个端口:可以在客户端执行
nc -vz 服务端IP 8080,或者telnet 服务端IP 8080。这一步相当于用现成工具代替你的客户端程序做一次连接测试。 - 最后才回到代码层面,检查IP、端口、协议家族是否匹配。
这里面最容易被忽略的是云主机的安全组或本机防火墙。本地两台虚拟机常常没有这个问题,一旦迁移到云环境,安全组配置会直接决定连接能否建立。所以我常说,预备阶段就养成“先看环境,再看代码”的习惯,能省下80%的排查时间。
6.2 数据错乱和字节序问题怎么定位
如果你发现服务端收到的端口号、长度字段跟预期完全不符,数据看起来像“乱码”,十有八九是字节序没有转换。定位方法很简单:在发送端和接收端分别打印原始数值和经过转换后的数值,对照看看是否保持一致。
常见错误场景是:在bind时忘记htons,直接写成server_addr.sin_port = 8080。这样在大多数小端机器上,内核解析出来的端口会变成大端表示后的数值。比如8080的十六进制是0x1F90,小端序存储为0x901F,即36911,于是服务端实际上在监听36911端口,客户端连8080自然失败。
解决这种东西没有捷径,只有两个习惯:一是所有发往网络的端口和地址都显式调用转换函数,二是用ss或netstat验证实际监听的端口。看到实际端口跟预期不一致,排查方向立刻就有了。
6.3 TIME_WAIT和地址被占用是最常见的“假死”现象
当你开发阶段频繁重启服务端程序时,经常遇到bind报Address already in use,但ss -lntp又看不到监听进程。这时候十有八九是之前的连接还处于TIME_WAIT状态,端口被内核占用着没有释放。
解决的思路有两条:一条是顿悟式处理——等一会儿再试,或者换一个端口;另一条是工程式处理——在bind之前加上SO_REUSEADDR套接字选项,让内核允许重用处于TIME_WAIT状态的本地地址。这个选项需要写在bind前面:
int opt = 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));这句代码几乎是所有TCP服务端程序的标配。但要注意,它解决的是TIME_WAIT状态下的端口重用,如果真有一个进程还在listen同一个端口,即使设置了SO_REUSEADDR也是bind失败的。很多新手把这个当成“万能钥匙”,结果发现端口真被占用时依然报错,又蒙了。
6.4 预备阶段最值得养成的几个习惯
最后分享几个我踩了无数次坑之后才沉淀下来的习惯,希望对你有参考价值。
第一个是写代码前先画流程草图。不需要画得多标准,哪怕只是把“服务端:创建->绑定->监听->接受->收发”这个顺序列出来,也能避免一半以上的逻辑顺序错误。
第二个是任何时候不确定函数的参数和返回值,先查man 2 函数名,不要依赖搜索出来的二手资料。Linux的man手册虽然枯燥,但它的准确性和更新及时性远胜大部分博客。预备阶段就学会查手册,后面学习任何系统调用都会轻松很多。
第三个是保留阶段性的可运行版本。先让服务端bind成功打印一行日志,再让客户端connect成功打印一行日志,然后才进入数据收发逻辑。不要一口气写完几十行然后调试,一次性通过的概率极低。小步迭代看起来慢,实际是学网络编程最快的路径。
我之前带过一个零基础的同学,上来就想直接写一个HTTP代理,连TCP握手都讲不明白,自然到处碰壁。后来我让他先退回预备阶段,用nc跑通连接、用tcpdump看包、再把回显程序编译运行,前后只用了一个下午,后面学select和epoll就已经能自己往前推了。Socket编程就是这样,前面的预备知识一旦扎实,后面所有东西都是顺理成章的延伸。希望这篇整理能帮你少走这些弯路的开头那一段。