网络系统实战从 Socket 到网卡——和其他公司合作的完整流程本文是《趣谈 Linux 操作系统》风格的实战续作。如果你没读过前面几篇先记住一个核心比喻Linux 内核是一家超大的外包总公司我们写的程序进程是总公司的内部创业小组而网络上另一台机器是外部合作公司。本文要讲清楚一个内部小组想和外部公司说句话到底要经过多少道流程、填多少张工单、转多少次手才能把数据送到对方手里。0. 引子一次跨国合作的完整链路想象你是订单处理小组你的进程想给合作公司 B另一台主机发一封加急信你不能直接冲到街上把信塞进对方邮箱——你住在总公司大楼里所有对外通信必须经过前台/收发室。于是你到前台开一个服务窗口Socket告诉前台“我要和 B 公司通信用 TCP 这种’挂号信’协议”。前台在内核里给你建了一整套工单socket 结构体分配文件描述符fd你从此用这个 fd 说话。数据从你的程序流向内核的协议栈网络部TCP 负责打包、编号、保证可靠IP 负责写收件人地址。协议栈把信交给排队调度qdisc再交给网卡驱动物流部最后由**网卡快递车**真正发到网线上。对方回信时流程反过来网卡收包 → 驱动 → 软中断/NAPI → 协议栈 → 你的 Socket 缓冲区 → 你read()取走。这篇博客我们就把上面这条链路一行代码、一条命令、一个抓包地跑通用真实的实验输出验证内核到底做了什么。1. 实验环境项目配置机器华为云 FlexusXecs-44ec-0004系统Ubuntu 24.04 LTS内核6.8.0CPU/内存8 核 16G工具gcc 13.3.0、make 4.3、strace 6.8、tcpdump 4.99.4、iproute2-6.1.0所有命令均在 root 下于/root/netlab目录执行。下列输出全部来自真实机器未做任何修饰仅对超长输出做截断并标注。2. 实验一写一个完整的 TCP 服务器与客户端先把内部创业小组和合作公司的对接窗口真正建起来。下面是一份教科书级的最小 TCP 通信程序socket → bind → listen → accept → read → write。2.1 服务器端server.c#includestdio.h#includestdlib.h#includestring.h#includeunistd.h#includesys/socket.h#includenetinet/in.h#includearpa/inet.h#definePORT9000#defineBUF1024intmain(){intsrvsocket(AF_INET,SOCK_STREAM,0);// 1) 创建监听套接字intopt1;setsockopt(srv,SOL_SOCKET,SO_REUSEADDR,opt,sizeof(opt));structsockaddr_inaddr;memset(addr,0,sizeof(addr));addr.sin_familyAF_INET;addr.sin_addr.s_addrINADDR_ANY;// 监听所有网卡addr.sin_porthtons(PORT);bind(srv,(structsockaddr*)addr,sizeof(addr));// 2) 绑定端口listen(srv,8);// 3) 开始监听backlog8printf(server listening on 0.0.0.0:%d pid%d\n,PORT,getpid());fflush(stdout);intcliaccept(srv,NULL,NULL);// 4) 阻塞等待合作公司连接printf(client connected, fd%d\n,cli);fflush(stdout);charbuf[BUF];intnread(cli,buf,BUF-1);// 5) 收数据buf[n]0;printf(recv from client: %s,buf);fflush(stdout);constchar*replyHello client, I am the TCP server (greetings from the kernel protocol stack)!\n;write(cli,reply,strlen(reply));// 6) 发数据nread(cli,buf,BUF-1);// 7) 再收一次if(n0){buf[n]0;printf(recv from client again: %s,buf);fflush(stdout);}close(cli);close(srv);return0;}2.2 客户端client.c#includestdio.h#includestdlib.h#includestring.h#includeunistd.h#includesys/socket.h#includenetinet/in.h#includearpa/inet.h#definePORT9000#defineBUF1024intmain(){intfdsocket(AF_INET,SOCK_STREAM,0);// 创建套接字structsockaddr_inaddr;memset(addr,0,sizeof(addr));addr.sin_familyAF_INET;addr.sin_porthtons(PORT);inet_pton(AF_INET,127.0.0.1,addr.sin_addr);connect(fd,(structsockaddr*)addr,sizeof(addr));// 向服务器发起连接printf(client: connected to 127.0.0.1:%d\n,PORT);constchar*msgHello server, I am the TCP client!\n;write(fd,msg,strlen(msg));// 发printf(client: sent greeting\n);charbuf[BUF];intnread(fd,buf,BUF-1);// 收服务器回包buf[n]0;printf(client: recv from server: %s,buf);constchar*msg2second message from client, over and out.\n;write(fd,msg2,strlen(msg2));close(fd);return0;}2.3 编译并真实运行双向通信gcc-O2-oserver server.c gcc-O2-oclient client.c ./serverserver.log21# 后台启动服务器sleep0.5./client# 前台跑客户端sleep0.5catserver.log# 看服务器这边收到了啥客户端真实输出client: connected to 127.0.0.1:9000 client: sent greeting client: recv from server: Hello client, I am the TCP server (greetings from the kernel protocol stack)!服务器端server.log真实输出server listening on 0.0.0.0:9000 pid8202 client connected, fd4 recv from client: Hello server, I am the TCP client! recv from client again: second message from client, over and out.解读客户端发出两句问候服务器先回了一句公司介绍又收到了第二句收工。这正是内核协议栈在中间做了可靠字节流的保证——你write的每一个字节对方read都能按序、不丢、不重地拿到。注意服务器收到的第一句正好35 字节Hello server, I am the TCP client!\n的长度这个数字后面抓包时还会再见到。3. 实验二用ss看连接状态机——三次握手的工单流转服务器跑起来后我们用ss现代替代netstat的工具观察内核里这条连接的状态。先让服务器保持连接用了一个握完手就睡 25 秒的server_hold再让客户端连上来然后抓状态。./server_holdhold.log21# 连上后保持 ESTAB 25 秒sleep0.6./clientclient_hold.log21# 客户端连接sleep0.6ss-tlnp|grep9000# 监听态 LISTENss-tnp|grep9000# 连接态 ESTAB真实输出 ss -tlnp (LISTEN) State Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 0 8 0.0.0.0:9000 0.0.0.0:* users:((server_hold,pid8356,fd3)) ss -tnp (ESTAB) State Recv-Q Send-Q Local Address:Port Peer Address:Port Process ESTAB 0 0 127.0.0.1:33082 127.0.0.1:9000 users:((client,pid8358,fd3)) ESTAB 35 0 127.0.0.1:9000 127.0.0.1:33082 users:((server_hold,pid8356,fd4))注意看服务器那行ESTAB 35 0 ...Recv-Q 35。这正是客户端发来、正躺在内核接收缓冲区里还没被read()取走的 35 字节问候ss让我们直接透视到了内核缓冲区的积压。三次握手状态机内核视角的工单流转TCP 建立连接要三次握手对应内核里两端的状态切换客户端 (主动打开) 服务器 (被动打开) CLOSED LISTEN | 发 SYN (_ISNc) | |-----------------------------| SYN_SENT 收到SYN | 回 SYNACK (_ISNs, ackc1) |----------------------------| 收到SYNACK SYN_RCVD | 发 ACK (acks1) | |-----------------------------| ESTABLISHED 收到ACK ESTABLISHED 双方进入 ESTABLISHED可以开始传数据客户端CLOSED → SYN_SENT → ESTABLISHED服务器LISTEN → SYN_RCVD → ESTABLISHED之后ss -tan里你还能看到TIME-WAIT状态连接关闭后的一方会等 2MSL防止旧报文串门它对应着合作结束后的归档冷却期。4. 实验三用strace看透系统调用——前台小哥到底替你跑了哪些腿我们常说调一个socket()/bind()/listen()就完事了但内核里到底发生了什么strace能把进程和内核之间的每一次系统调用原样打印出来。下面让strace跟踪服务器strace-f-etracesocket,bind,listen,accept,connect,read,write\-ostrace.log ./serverserver2.log21sleep0.6./clientclient2.log21sleep0.6grep-Esocket\(|bind\(|listen\(|accept\(|read\(|write\(strace.log真实输出节选关键行8803 socket(AF_INET, SOCK_STREAM, IPPROTO_IP) 3 8803 bind(3, {sa_familyAF_INET, sin_porthtons(9000), sin_addrinet_addr(0.0.0.0)}, 16) 0 8803 listen(3, 8) 0 8803 accept(3, NULL, NULL) 4 8803 read(4, Hello server, I am the TCP clien..., 1023) 35 8803 write(4, Hello client, I am the TCP serve..., 78) 78 8803 read(4, second message from client, over..., 1023) 42解读一目了然——所谓的写个服务器内核实际替你执行了socket→bind→listen→accept四步开窗口流程之后每次收发都是一次read/write系统调用。注意read(4, ..., 1023) 35参数 4 是accept返回的新连接 fd返回值 35 正是客户端那句 35 字节的问候。这就是内部小组和前台之间最底层的工单往来。5. 实验四tcpdump抓包——把三次握手和数据包拍下来socket/read看到的是内核视角的工单那线上真实流过网卡的比特长什么样上tcpdump在回环网卡lo上抓9000端口。tcpdump-ilo-nn-c30port9000tcpdump.log21sleep0.8./serverserver3.log21sleep0.6./client# 第一次连接sleep0.8./client# 第二次连接服务器已退出看 RSTsleep1.5kill%tcpdump真实输出节选 标注13:03:54.524166 IP 127.0.0.1.52122 127.0.0.1.9000: Flags [S], seq 2143351521, ... length 0 13:03:54.524176 IP 127.0.0.1.9000 127.0.0.1.52122: Flags [S.], seq 2009536454, ack 2143351522, ... length 0 13:03:54.524188 IP 127.0.0.1.52122 127.0.0.1.9000: Flags [.], ack 1, ... length 0 13:03:54.524238 IP 127.0.0.1.52122 127.0.0.1.9000: Flags [P.], seq 1:36, ... length 35 13:03:54.524244 IP 127.0.0.1.9000 127.0.0.1.52122: Flags [.], ack 36, ... length 0 13:03:54.524261 IP 127.0.0.1.9000 127.0.0.1.52122: Flags [P.], seq 1:79, ... length 78 13:03:54.524277 IP 127.0.0.1.52122 127.0.0.1.9000: Flags [P.], seq 36:78, ... length 42 13:03:54.524286 IP 127.0.0.1.52122 127.0.0.1.9000: Flags [F.], ... length 0 13:03:54.524289 IP 127.0.0.1.9000 127.0.0.1.52122: Flags [F.], ... length 0 13:03:54.524293 IP 127.0.0.1.52122 127.0.0.1.9000: Flags [.], ack 80, ... length 0 ... 13:03:55.326244 IP 127.0.0.1.52126 127.0.0.1.9000: Flags [S], ... length 0 13:03:55.326248 IP 127.0.0.1.9000 127.0.0.1.52126: Flags [R.], ... length 0 14 packets captured逐行解读 flagsFlags含义对应阶段[S]SYN发起连接三次握手第 1 步客户端→服务器[S.]SYNACK同意并回确认三次握手第 2 步服务器→客户端[.]纯 ACKP 和 S 都没置位三次握手第 3 步的确认[P.]PSHACK携带数据真正的数据传输length即字节数[F.]FINACK正常关闭四次挥手[R.]RSTACK连接被重置端口没人监听时内核直接拒绝几个小彩蛋客户端发的第一笔数据length 35正是我们之前反复看到的 35 字节问候服务器回的length 78、客户端第二句length 42也都和代码里的字符串长度吻合——应用层、系统调用层、网卡抓包层三处数字完全对得上这就是内核如实搬运的最好证据。第二次连接抓到了[R.]RST因为第一次连接结束服务器就退出了端口 9000 已经没有进程在听tcpdump清晰地显示内核直接回了RST——相当于前台说这窗口已关闭退信。6. 实验五UDP 对比——无连接的口信式通信TCP 是挂号信UDP 就是托人带个口信。UDP 没有连接、没有握手、不保证送达但胜在轻量。下面是一个 UDP echo 服务器收到什么原样回什么// udpserver.c 核心intsrvsocket(AF_INET,SOCK_DGRAM,0);// 注意SOCK_DGRAM不是 STREAMbind(srv,...);recvfrom(srv,buf,...);// 收并记下对方地址sendto(srv,buf,n,...,cli,clen);// 原样发回客户端真实输出client recv echo: udp message one client recv echo: udp message two服务器udpserver.log真实输出udp echo server listening on :9001 pid9336 recv 16 bytes: udp message one recv 16 bytes: udp message two用ss看 UDP 套接字的状态State Recv-Q Send-Q Local Address:Port Peer Address:Port Process UNCONN 0 0 0.0.0.0:9001 0.0.0.0:* users:((udpserver,pid9488,fd3))关键对比TCP 连接是ESTAB有状态、有对方地址UDP 这里是UNCONN未连接。UDP 服务器不需要listen/accept每次recvfrom才知道谁找我sendto时再填对方地址——它根本不记住合作方纯粹来一个口信回一个口信。7. 实验六内核参数与/proc/net/tcp——合作协议里的默认条款内核为 TCP 准备了一大堆可调参数sysctl它们就是总公司和合作公司之间默认的合作条款sysctlnet.ipv4.tcp_rmem net.ipv4.tcp_wmem net.core.somaxconn\net.ipv4.tcp_max_syn_backlog net.core.rmem_max\net.ipv4.tcp_keepalive_time net.ipv4.tcp_fin_timeout真实输出net.ipv4.tcp_rmem 4096 131072 6291456 net.ipv4.tcp_wmem 4096 16384 4194304 net.core.somaxconn 4096 net.ipv4.tcp_max_syn_backlog 1024 net.core.rmax_max 212992 net.ipv4.tcp_keepalive_time 7200 net.ipv4.tcp_fin_timeout 60tcp_rmem/wmem接收/发送缓冲区的最小、默认、最大三个值一组单位字节。这就是窗口大小的弹性范围。somaxconn全连接队列accept队列上限。回忆实验三里listen(srv, 8)的 backlog8但真正上限还要和somaxconn取 min。tcp_max_syn_backlog半连接队列三次握手中收到 SYN 但还没完成的那批上限。tcp_fin_timeoutFIN 后进入TIME_WAIT的回收时间。直接读内核的连接台账/proc/net/tcpss的数据其实来自/proc/net/tcp。这份台账全是十六进制我们让服务器监听时再看一眼sl local_address rem_address st tx_queue rx_queue ... 1: 00000000:2328 00000000:0000 0A 00000000:00000000 ... # 0ALISTEN, 端口 23289000 7: 0100007F:2328 0100007F:9582 01 00000000:00000023 ... # 01ESTAB, rx_queue0x2335 字节! 11: 0100007F:9582 0100007F:2328 01 00000000:00000000 ...00000000:23282328是9000的十六进制00000000是0.0.0.0。st列0ALISTEN01ESTAB06TIME_WAIT。那行rx_queue0000002335再次印证服务器内核接收缓冲里正躺着 35 字节没被读走的数据。这就像你直接翻开总公司的内部台账看到每一笔合作都记着对方地址、当前状态、积压了多少货。8. 实验七收发包的完整路径——一份跨部门协作流程图前面都是窗口/工单层面的故事。数据真正从你的write()到网线要穿过内核的好几个部门。完整路径如下发送侧你的程序 (用户态) │ write(fd, buf, len) ← 系统调用陷入内核 ▼ ┌──────────────────────────────────────────────────────────┐ │ 内核态 │ │ TCP 协议栈 : 分段、编号、滑动窗口、超时重传 │ │ │ │ │ ▼ │ │ IP 协议栈 : 封装源/目的 IP、路由查找、分片 │ │ │ │ │ ▼ │ │ Netfilter/iptables (可选过滤/转发) │ │ │ │ │ ▼ │ │ qdisc (排队规则) : 流量整形如 pfifo_fast / fq_codel │ │ │ │ │ ▼ │ │ 网卡驱动 (NIC driver) : 把 skb 写入网卡环形缓冲区 TX ring│ │ │ │ │ ▼ │ └─────┼────────────────────────────────────────────────────┘ ▼ 网卡硬件 (NIC) : DMA 读出数据打上前导码/CRC发到物理链路接收侧则反过来且有个关键机制叫NAPI 软中断softirq网卡收到包产生硬中断CPU 随即进一个轻量的**软中断NET_RX**去把包从环形缓冲区搬进协议栈逐层解包最后放进你 Socket 的接收缓冲区等你read()来取。硬中断只负责叫醒重活儿解包放在软中断里做避免打断正在跑的程序太久。用真数据佐证中断与软中断收发包最终都落在网卡中断和网络软中断上。我们这台机器网卡是virtio云上虚拟网卡看/proc/interrupts44: 0 91769 0 0 0 0 1 0 PCI-MSIX ... 1-edge virtio0-input.0 45: 74 0 0 0 75937 0 0 41399 PCI-MSIX ... 2-edge virtio0-output.0 46: 1 0 0 17 0 0 91367 0 PCI-MSIX ... 3-edge virtio0-input.1 47: 0 1 115 37147 8911 0 0 30288 PCI-MSIX ... 4-edge virtio0-output.1 48: 0 0 1 0 49016 1274 0 42487 PCI-MSIX ... 5-edge virtio0-input.2 49: 0 0 0 1 100 76495 0 0 PCI-MSIX ... 6-edge virtio0-output.2 50: 0 0 0 92364 1 0 0 20 PCI-MSIX ... 7-edge virtio0-input.3 51: 0 0 806 0 0 1 189 0 PCI-MSIX ... 8-edge virtio0-output.3可以看到virtio0有8 对input.N/output.N队列多队列网卡8 核各有一对收发队列做中断负载均衡。再看/proc/softirqs的NET_RX/NET_TX行——这是解包/发包软中断在各 CPU 上的累计次数NET_TX: 1 0 0 0 1 0 0 0 NET_RX: 45201 126405 45984 160264 165638 115881 125962 148269NET_RX的计数远超NET_TX因为接收侧的协议处理解包、递交给 Socket几乎全在软中断里完成而发送侧很多工作在write()系统调用上下文就做了所以收包软中断明显更忙。这组数字就是物流部真实工作量的体检报告。9. 实验八netstat -s——协议栈的年度经营报表如果说/proc/net/tcp是流水账netstat -s就是汇总后的 KPI 报表netstat-s|head-30真实输出节选Ip: Forwarding: 2 369374 total packets received 2 with invalid addresses 0 forwarded 1 with unknown protocol 369371 incoming packets delivered 367676 requests sent out Icmp: 16 ICMP messages received 0 input ICMP message failed ICMP input histogram: destination unreachable: 9 echo replies: 7 139 ICMP messages sent Tcp: 171 active connection openings 140 passive connection openings 63 failed connection attemptsTcp: active/passive connection openings主动/被动建连次数——你的connect()算 active别人的accept()算 passive。failed connection attempts握手失败的次数比如对方不存在、被防火墙 RST。Ip: total packets received / delivered协议栈总共收了多少包、成功递交给上层多少包。这张报表对排查连接数暴涨重传高等问题极其有用相当于内核网络部的月度总结。10. 总结把今天这条内部小组 → 外部公司的合作链路串起来开窗口socket/bind/listen/accept四步内核在网络部建好工单strace看得一清二楚。握手TCP 三次握手把两端状态机推到ESTABLISHEDss能实时看到LISTEN/ESTAB/TIME-WAITtcpdump能抓到[S]/[S.]/[.]。传数据write/read在可靠字节流上跑[P.]包里length字段和代码字符串长度严丝合缝。走流程用户态write→ TCP/IP 栈 → qdisc → 驱动 → 网卡收包靠 NAPI NET_RX软中断。看台账/proc/net/tcp、sysctl、/proc/interrupts、/proc/softirqs、netstat -s让我们从任意维度透视内核。思考题实验三里accept返回的新 fd如 4和listen用的 fd如 3为什么要分开服务器能否用同一个 fd 既监听又收发tcpdump抓到了[R.]RST请结合netstat -s里的failed connection attempts说说什么场景下客户端会收到 RST如果客户端和服务器不在同一台机器跨物理网络你觉得本篇实验七的发包路径图里哪一步会多出邻居子系统ARP/路由查找的参与somaxconn和tcp_max_syn_backlog分别保护的是哪种队列如果突发大量连接哪一关会先撑不住实验环境华为云 FlexusXecs-44ec-0004Ubuntu 24.04内核6.8.0。所有命令与输出均在该机真实执行。