Linux网络编程实战:从TCP/IP协议到UDP与TCP Socket编程
把网络编程整理成一套能直接上手的知识比单纯背API要重要得多。这篇是我自己的DAY26学习记录核心是Linux环境下的网络通信原理、IP与协议的关系、网络配置方式以及UDP、TCP两套socket编程的实际应用。如果你正好学到Linux网络编程这块或者面试前想把三次握手、socket流程这些概念串起来直接跟着这篇文章的思路走就行代码和命令我都会贴出来。我先把话放在前头网络编程里面绕不开的就三样——协议、IP、端口。把这三样在脑子里形成一个清晰模型后面看什么TCP、UDP、socket都不会懵。今天我尽量用大白话把这些概念讲透再配合能直接编译运行的C代码带你把UDP和TCP从建连到收发全都过一遍。1. 网络通信的底层逻辑协议、IP与端口先建模型1.1 协议分层为什么需要TCP/IP模型很多人一开始学网络编程上来就看send、recv、bind这些API觉得反正填个IP和端口就能用了。一旦遇到问题就抓瞎因为底层数据包是怎么走的、为什么握手、为什么断连脑子里全是模糊的。先补一个最底层的模型。两台机器之间要通信硬件上靠网卡、网线、交换机、路由器数据最终是以电信号/光信号在介质里传递的。问题是信号本身没有“意义”——它不知道自己是网页请求还是视频流。所以需要一套规则把原始比特流一步步包装成有意义的数据这就是“协议分层”。TCP/IP模型一般分成四层我习惯这样记网络接口层管网卡驱动、ARP、以太网帧解决“数据在同一个局域网里怎么传到对方网卡”的问题。网络层IP协议在这一层解决“数据怎么跨网络找到对方主机”的问题。传输层TCP/UDP在这一层解决“数据交给这台主机上的哪个程序”的问题。应用层HTTP、FTP、SSH还有你自己写的socket程序都在这一层。这个分层模型最大的价值是解耦。应用层不用管数据包怎么被拆成帧、走哪条路由网络层也不用管上层是HTTP还是自定义协议。我记得刚学的时候拿它类比快递系统应用层是“你要寄的物品”传输层是“快递单上填的收件人端口”网络层是“收件地址IP”链路层是“货车司机实际走的道路”。这个类比很好用面试被问“为什么分层”的时候从这个角度说逻辑也通顺。1.2 IP地址、端口与MAC地址的分工有了分层的概念我们再拆开看IP和端口。IP地址是网络层的标识负责在“整个互联网范围”内定位一台主机。IPv4是32位通常写成点分十进制比如192.168.1.100。IPv6是128位写成16进制段比如2408:8207:4800:2::3。写程序时IPv4的地址可以放到一个32位整数里习惯上我们在代码里先填字符串再通过inet_pton转成二进制这样比较稳。只是IP还不行一台机器上可能同时跑着SSH、Nginx、MySQL数据包到了这台机器内核怎么知道要交给哪个进程这就靠端口。传输层的TCP和UDP头部都有源端口、目的端口各占16位范围0到65535。0到1023是知名端口HTTP用80HTTPS用443SSH用22自己写测试服务就尽量用5000以上免得撞车。还有一个概念容易混淆MAC地址。MAC是数据链路层的标识理论上全球唯一但它只在局域网传输时有用。跨网段转发时靠的是IP路由到了目标局域网内才通过ARP协议把IP解析成MAC然后封装成以太网帧送过去。所以可以简单理解成IP负责“找到哪台机器”端口负责“找到机器上的哪个进程”MAC负责“局域网内最后一跳的物理投递”。1.3 socket本质把网络通信抽象成文件读写Linux里面有个经典思想一切皆文件。普通文件可以open/read/write/close网络连接也可以这样操作这个抽象就是socket套接字。socket()函数返回一个文件描述符内核在内部维护这个socket对应的发送缓冲区、接收缓冲区以及一系列状态。你往socket里写数据内核负责按TCP或UDP协议封装成报文发出去对方有数据过来内核收下放进接收缓冲区你的程序用read或recv取出来。这么设计的直接好处应用层代码不需要跟网卡驱动、协议栈细节打交道。把socket当做一个“管道”一边是你一边是远端程序非常顺。我学的时候一度想自己构造IP包后来发现完全没必要socket已经是操作系统给你封装好的“标准接口”99%的业务场景用原生socket就足够了。既然模型清晰了下一步就进入Linux环境里实际看网络配置也就是常说的“配IP、查端口”。2. 环境配置与网络排查从看IP到tcpdump抓包2.1 用ip命令替代老旧的ifconfig早年玩Linux的人习惯用ifconfig查IP但这个工具在新系统里默认不装了功能也不够现代。现在推荐用ip命令它是iproute2包里的几乎所有发行版都自带。# 查看所有网卡信息 ip addr show # 精简输出只看IPv4地址 ip -4 addr show # 查看路由表确认默认网关 ip route show输出里重点看几个字段lo是回环接口IP固定是127.0.0.1代表本机自身eth0或ens33这种是真实网卡后面跟的inet字段就是这台机器的IP。路由表里的default via一行代表默认网关访问外网时要靠它把包转发出去。ifconfig虽然还能用但有些老的发行版上它看不到CIDR格式的子网掩码信息不完整。建议尽早养成用ip命令的习惯。2.2 修改IP地址与永久配置临时改IP用ip addr add就够了适合做实验# 给eth0添加一个临时IP sudo ip addr add 192.168.1.50/24 dev eth0 # 删除 sudo ip addr del 192.168.1.50/24 dev eth0但重启网络服务或者重启机器后临时配置就丢了。想永久生效不同的发行版配置方式不一样我用过的两种主流方式Debian/Ubuntu新版编辑/etc/netplan/*.yaml改完执行sudo netplan apply。CentOS/RHEL编辑/etc/sysconfig/network-scripts/ifcfg-eth0改完执行sudo systemctl restart network。Ubuntu的netplan配置长这样network: version: 2 ethernets: eth0: dhcp4: true addresses: - 192.168.1.100/24 routes: - to: default via: 192.168.1.1 nameservers: addresses: [223.5.5.5, 8.8.8.8]这里注意一个常见坑云服务器或虚拟机的网卡名可能是ens33、ens160不是eth0写配置之前一定要ip addr看清楚接口名否则netplan apply会直接报错。2.3 连通性与监听端口排查三板斧配置完网络第一件事就是验证。我自己常用的三板斧# 1. 测连通性 ping 8.8.8.8 ping -c 4 baidu.com # 2. 查看本机监听的端口 ss -lntp # 3. 测试远端端口是否开放 nc -vz 192.168.1.1 22ss -lntp里面-l表示只显示监听中的socket-n不做域名解析-t只看TCP-p显示进程名和PID。如果看到0.0.0.0:5000说明服务在监听所有网卡的5000端口如果看到127.0.0.1:5000说明只监听本机回环外部机器连不上——很多新手排查半天发现服务起在127.0.0.1上。nc -vz测端口特别好用不会真的建立应用层连接只测试TCP握手能否成功。UDP端口想测连通性nc -u也可以但UDP没有握手概念能不能通得靠业务层数据回应这点后面UDP编程章节再细说。2.4 tcpdump看一眼真实的数据包排查网络问题只用ping和ss是不够的有时候协议层面就是不对得抓包看。tcpdump是Linux下最基础的抓包工具我常用的几个命令# 抓取主机192.168.1.100收到的所有ping包 sudo tcpdump -i eth0 icmp and host 192.168.1.100 # 抓取HTTP 80端口的数据包并显示内容 sudo tcpdump -i eth0 -A tcp port 80 # 抓取TCP三次握手过程数据包层面 sudo tcpdump -i eth0 tcp port 5000比如抓TCP握手会看到SYN、SYN-ACK、ACK三种标记这比看任何文字描述都直观。后面我会专门讲如何用抓包结果反推连接异常。3. UDP编程实战无连接、速度快也需要细致处理3.1 UDP协议特点简单、轻量、有边界UDPUser Datagram Protocol的定位是“尽力而为”的传输。它不需要建立连接发数据直接封装成数据报扔出去至于对方收没收到UDP协议本身不管。这带来两个鲜明的特点快没有握手和确认环节时延低适合DNS查询、视频通话、在线游戏这类能容忍偶发丢包的场景。有消息边界每次sendto发出去的一个数据报对方一次recvfrom收到的就是一个完整的数据报。这个特性跟TCP不一样TCP是字节流没有边界UDP天然帮你“分包”了。代价是可靠性差网络拥塞时会丢包、乱序数据也可能在中间被路由器丢弃。重要业务要自己做重传和确认机制或者直接用上层协议比如QUIC。3.2 UDP编程核心流程UDP编程比TCP简单核心API就五个socket(AF_INET, SOCK_DGRAM, 0)创建UDP socket注意第二个参数是SOCK_DGRAM。bind(fd, addr, len)绑定本地地址和端口。服务端必须bind客户端如果想固定端口也可以bind不bind则内核自动分配。sendto(fd, buf, len, 0, dest_addr, addr_len)向指定地址发数据报。recvfrom(fd, buf, len, 0, src_addr, addr_len)接收数据报同时能拿到发送方的地址和端口。close(fd)关闭socket。流程上服务端和客户端其实是对称的两边都要创建socket服务端bind固定端口客户端可选bind然后两边互相用sendto/recvfrom收发。不需要listen也不需要accept这就是“无连接”的体现。3.3 完整的UDP收发代码我写了一个最小可跑通的例子服务端绑定127.0.0.1的8888端口收到客户端消息后原样回一句“server received: xxx”。服务端代码udp_server.c#include stdio.h #include string.h #include arpa/inet.h #include sys/socket.h #include unistd.h int main() { int fd socket(AF_INET, SOCK_DGRAM, 0); if (fd 0) { perror(socket); return 1; } struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8888); addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡 if (bind(fd, (struct sockaddr*)addr, sizeof(addr)) 0) { perror(bind); close(fd); return 1; } char buf[1024]; struct sockaddr_in client_addr; socklen_t len sizeof(client_addr); while (1) { memset(buf, 0, sizeof(buf)); int n recvfrom(fd, buf, sizeof(buf) - 1, 0, (struct sockaddr*)client_addr, len); if (n 0) { perror(recvfrom); break; } char ip_str[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, ip_str, sizeof(ip_str)); printf(recv %d bytes from %s:%d: %s\n, n, ip_str, ntohs(client_addr.sin_port), buf); // 原样回给客户端 sendto(fd, buf, n, 0, (struct sockaddr*)client_addr, len); } close(fd); return 0; }客户端代码udp_client.c#include stdio.h #include string.h #include arpa/inet.h #include sys/socket.h #include unistd.h int main() { int fd socket(AF_INET, SOCK_DGRAM, 0); if (fd 0) { perror(socket); return 1; } struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(8888); inet_pton(AF_INET, 127.0.0.1, server_addr.sin_addr); char buf[1024] hello udp; sendto(fd, buf, strlen(buf), 0, (struct sockaddr*)server_addr, sizeof(server_addr)); struct sockaddr_in from_addr; socklen_t from_len sizeof(from_addr); int n recvfrom(fd, buf, sizeof(buf) - 1, 0, (struct sockaddr*)from_addr, from_len); if (n 0) { buf[n] \0; printf(server reply: %s\n, buf); } close(fd); return 0; }编译运行gcc udp_server.c -o udp_server gcc udp_client.c -o udp_client # 终端1 ./udp_server # 终端2 ./udp_client运行后服务端打印recv 10 bytes from 127.0.0.1:xxxxx: hello udp客户端打印server reply: hello udp。3.4 用nc和iperf3验证UDP如果不想写客户端用nc也能快速发UDP数据# 往本机8888端口发一条UDP消息 echo test from nc | nc -u 127.0.0.1 8888服务端同样能收到。这个命令在调试时特别省事不用反复编译客户端。想压测UDP带宽可以用iperf3。UDP模式下它默认以一定速率发包能统计丢包率、抖动、带宽# 服务端 iperf3 -s # 客户端UDP模式打流10秒 iperf3 -u -c 127.0.0.1 -b 100M -t 10输出里会有一行Lost/Total Datagrams丢包率如果很高说明网络链路或者服务端处理不过来需要调优或者换TCP。3.5 UDP编程的四个坑结合我实际调试经验UDP有几个很容易踩的坑接收缓冲区太小导致丢包。接收方recvfrom的buf如果小于数据报长度多余部分会被内核直接丢弃UDP不像TCP那样能把多出来的留在缓冲区下次读。所以接收buffer尽量大一些或者双方约定好单个数据报的最大长度。bind不检查返回值。很多时候bind失败是因为端口被占用不判断返回值的话后面收不到任何数据排查起来很困惑。防火墙拦UDP。UDP没有握手防火墙检测难度更大很多安全策略默认丢弃UDP。在自己机器上测试没问题跨机器就收不到多半是防火墙。sendto不报错不代表对方收到。UDP的sendto只是把数据交给内核就返回了中间丢了谁也不知道。要确认对方有没有收到必须靠应用层回包。4. TCP编程实战可靠连接状态机是核心4.1 三次握手与四次挥手TCP是面向连接的、可靠的字节流协议。可靠性怎么来靠确认、重传、排序、流量控制这些机制。而在一切开始之前双方要先“握手”建立连接。三次握手的全过程客户端发送SYN请求建立连接这时候带上初始序列号seqx。服务端收到后回复SYNACK表示“同意建立并确认收到你的SYN”带上seqy和ackx1。客户端回复ACKacky1连接进入ESTABLISHED状态。为什么必须是三次不是两次核心原因是防止已经失效的旧连接请求突然到达服务端导致错误建立连接。两次握手的话服务端收到SYN就建立连接万一这个SYN是网络延迟导致的陈旧数据包服务端会白等一个根本不存在的请求。加了第三次ACK客户端如果没有真正发起连接就不会回应服务端自然超时关闭。四次挥手跟握手的思路完全不一样主动关闭方发送FIN表示“我的数据发完了”。被动关闭方回复ACK表示“收到你的FIN”。被动关闭方继续发完剩余数据然后发送FIN表示“我的数据也发完了”。主动关闭方回复ACK连接彻底关闭。之所以是四次是因为TCP允许半关闭一端不发了另一端还可以继续发数据。服务端的‘ACK’和‘FIN’可能合并成一个包发送但严格来说概念上是四次。这个部分面试点特别密集我在learn笔记里专门画过状态迁移表核心状态记这几个就行状态含义常见场景LISTEN服务端等待客户端连接服务端调用listen后SYN_SENT客户端已发SYN等待服务端响应connect调用后SYN_RCVD服务端收到SYN已回SYNACK握手中间态ESTABLISHED连接建立可以收发数据正常通信中FIN_WAIT_1主动关闭方已发FIN第一次挥手后FIN_WAIT_2主动关闭方已收ACK等待对方FIN第二次挥手后TIME_WAIT主动关闭方收到FIN并回ACK后等待四次挥手完成后CLOSE_WAIT被动关闭方收到FIN等待自己发起关闭对方close后本进程未close4.2 TCP编程核心API流程TCP编程和服务端的API比UDP多几个关键步骤服务端socket(AF_INET, SOCK_STREAM, 0)第二个参数换成了SOCK_STREAM。bind()绑定地址端口。listen(fd, backlog)把socket变为被动监听状态backlog表示内核中等待accept的连接队列长度。accept(fd, client_addr, len)从连接队列里取一个已完成握手的连接返回一个新的socket用于通信。客户端socket()创建TCP socket。connect(fd, server_addr, len)发起三次握手握手成功返回后连接就建立了。之后收发数据用read/write或者recv/send都可以因为TCP是字节流接口跟文件读写几乎一样。4.3 完整的TCP示例fork实现并发echo我写一个经典例子TCP echo服务端每来一个客户端就fork一个子进程处理子进程里循环接收数据原样返回。这是理解TCP并发最简单的方式。服务端代码tcp_server.c#include stdio.h #include string.h #include arpa/inet.h #include sys/socket.h #include unistd.h #include signal.h #include sys/wait.h void handle_client(int cfd) { char buf[1024]; while (1) { int n read(cfd, buf, sizeof(buf) - 1); if (n 0) break; // 客户端关闭或出错 buf[n] \0; printf(echo: %s\n, buf); write(cfd, buf, n); } close(cfd); _exit(0); } int main() { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); return 1; } // 关键避免TIME_WAIT状态下端口不可用 int opt 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(8888); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(fd, (struct sockaddr*)addr, sizeof(addr)) 0) { perror(bind); close(fd); return 1; } if (listen(fd, 128) 0) { perror(listen); close(fd); return 1; } signal(SIGCHLD, SIG_IGN); // 自动回收子进程避免僵尸 printf(TCP server listening on 0.0.0.0:8888\n); while (1) { struct sockaddr_in client_addr; socklen_t len sizeof(client_addr); int cfd accept(fd, (struct sockaddr*)client_addr, len); if (cfd 0) { perror(accept); continue; } pid_t pid fork(); if (pid 0) { close(fd); // 子进程不需要监听fd handle_client(cfd); } else { close(cfd); // 父进程不需要连接fd } } close(fd); return 0; }客户端代码tcp_client.c#include stdio.h #include string.h #include arpa/inet.h #include sys/socket.h #include unistd.h int main() { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); return 1; } struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(8888); inet_pton(AF_INET, 127.0.0.1, server_addr.sin_addr); if (connect(fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(connect); close(fd); return 1; } char buf[1024]; while (1) { printf( ); fflush(stdout); if (fgets(buf, sizeof(buf), stdin) NULL) break; write(fd, buf, strlen(buf)); int n read(fd, buf, sizeof(buf) - 1); if (n 0) { printf(server closed\n); break; } buf[n] \0; printf(server reply: %s\n, buf); } close(fd); return 0; }编译运行gcc tcp_server.c -o tcp_server gcc tcp_client.c -o tcp_client ./tcp_server ./tcp_client运行后在客户端输入任意字符串服务端原样回显。这个例子虽然简单但展示了TCP服务端最标准的生命周期socket→bind→listen→accept→read/write→close。4.4 TIME_WAIT与SO_REUSEADDRTCP编程中有一个非常常见的坑服务端重启时报bind: Address already in use。原因就是主动关闭连接的一方通常是服务端如果你直接CtrlC停服务会进入TIME_WAIT状态持续2MSL大约1到4分钟Linux默认60秒左右。这段时间内同样的四元组源IP、源端口、目的IP、目的端口不能被复用。解决办法就是setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt))在bind之前设置。这个选项允许内核在TIME_WAIT状态下重新绑定同一端口。代码里我已经加上了。另外要注意SIGCHLD信号的忽略。fork出来的子进程如果结束了父进程不处理waitpid子进程会变成僵尸进程。简单粗暴的办法是signal(SIGCHLD, SIG_IGN)让内核自动回收。生产环境里更规范的做法是用sigactionwaitpid循环回收。4.5 面向高并发select、poll与epoll上面的fork模型能解决“同时处理多个客户端”的问题但每个连接一个进程代价太高。连接数几百上千还行上万就不现实了。这里引出Linux下高性能网络编程的核心IO多路复用。select内核帮你监听一堆fd有事件就返回。缺点是fd数量有限制默认1024而且每次调用都要重新传入整个fd集合效率不高。poll用链表替代了fd_set突破了数量限制但性能模型和select一样都是“线性扫描”。epollLinux 2.6以后提供是当前高性能网络服务的标配。它有两个核心优势事件驱动只返回就绪的fd可扩展性好百万连接也能支撑。Nginx、Redis、Node.js底层都是epoll。一个最小化的epoll服务端思路是epoll_create - epoll_ctl(ADD, listen_fd) - epoll_wait 循环每次epoll_wait返回一批就绪fd如果是监听fd就accept并把新连接加入epoll如果是通信fd就直接读写。这个模型彻底摆脱了“一连接一线程”的尴尬也是我从学TCP编程到真正理解高并发服务器的一个重要转折点。4.6 TCP粘包问题字节流模型决定的TCP是字节流协议read读到的数据不一定对应对方一次write写入的数据。比如客户端连续发两次write内容分别是“hello”和“world”服务端一次read可能读到“helloworld”也可能先读到“hel”下一次读到“loworld”。这就是粘包/拆包问题。解决办法不是靠TCP它没有消息边界而是应用层自己定义消息格式。常见的方案固定长度每个消息固定N字节不够就补位读满N字节处理一次。分隔符消息以\n或特殊字符结尾读到分隔符才算一条完整消息。长度前缀每条消息前面加4字节长度字段先读长度再按长度读正文。这是最通用的做法。我知道有人用Modbus TCP这类工业协议里面也是固定字节头长度字段的格式本质思路是一样的。搞懂了应用层要自己划分“消息边界”这个道理粘包问题就迎刃而解。5. 实战排查连接类问题的定位思路5.1 常见问题速查表在这个阶段我会把排查经验整理成一张速查表遇到问题直接对照现象可能原因常用排查命令connect超时对端防火墙拦截、网段不通、服务未监听ping、nc -vz、tcpdumpconnect返回Connection refused目标端口没有服务在监听ss -lntpbind报Address already in use端口被占用或TIME_WAIT状态ss -lntp、lsof -i:8888服务端accept后马上收到EOF客户端连接后立即closetcpdump看是否只有SYN没有数据UDP收不到数据防火墙、缓冲区太小、bind失败tcpdump -i eth0 udp port 8888客户端报Broken pipe写一个已经关闭的socket捕获SIGPIPE或使用MSG_NOSIGNAL网卡配置丢失netplan或ifcfg文件写错接口名ip addr show5.2 用抓包定位“谁先断开连接”有一次联调时服务端A给客户端B发完数据后B迟迟没有响应。用tcpdump抓包后发现A发了PUSH-ACK数据包B回了ACK然后A发送了FIN。也就是说是A自己主动关闭了连接不是B不响应。程序里的原因是一个不需要的close被提前触发。抓包怎么读几个关键标志位SYN发起连接、ACK确认、FIN关闭连接、RST异常重置。如果看到RST说明对端直接丢弃或拒绝了连接而不是正常挥手关闭。RST出现的原因很多端口没监听、进程崩溃、超时重传被取消等。以后排查连接异常我先抓包看有没有RST这比猜快得多。5.3 网络编程学习路线建议学到这儿Linux网络编程的基础链路已经通了。后面想深入的话我的建议是顺着这条路走反复推敲TCP状态机每次抓包都会看到SYN、FIN、TIME_WAIT看多了就自然记住。读unpUnix Network Programming关键章节不用全读把socket API、IO复用、信号处理这几块啃透。上手epoll写一个简单的并发回显服务器体验从阻塞IO到事件驱动的区别。看内核参数比如/proc/sys/net/ipv4/tcp_fin_timeout、tcp_tw_reuse结合实际问题调优。有余力再看现代协议栈比如QUIC、io_uring这些是建立在基础之上理解才快。我自己在学的时候最大的体会是别在API层面钻牛角尖要把注意力放在“数据包到底怎么流动”上。理解了包是怎么发的、怎么确认的、怎么重传的写起代码来反而很自然。最后再分享一个小技巧调试TCP服务时把/proc/net/tcp打开看一下每一行是一个TCP连接状态。第六列是连接状态比如01代表ESTABLISHED06代表TIME_WAIT。当你怀疑服务端有大量TIME_WAIT堆积时数一数这个文件的行数就知道了比一个个ss去看心里更有底。网络编程这个领域经验都是从一次次抓包和排查里攒出来的。