English Version | 中文版本
本项目旨在从代码实现的角度,探讨网络编程的底层原理与并发架构的演进。所有的示例代码均去繁就简,剥离复杂的业务逻辑,仅保留网络通信与并发调度的核心机制,作为理解底层系统设计的客观参照。
在第一部分(Part 1)中,项目基于 C 语言构建基础网络模型。内容涵盖大小端字节序转换、UDP/TCP 通信机制、TFTP 协议二进制报文的手动构造与解析,以及广播与多播的实现。更重要的是,本部分代码直观演示了服务端并发模型的演进过程:从多进程(fork)、多线程(pthread),最终过渡到基于 Linux
epoll的 I/O 多路复用模型,为后续理解现代高并发架构建立必要的操作系统级认知。在第二部分(Part 2)中,项目视角转向 Go 语言的 Runtime 源码,全面解构其
netpoll网络多路复用体系。结合前半部分epoll的基础,详细拆解 Go 如何将底层的异步事件轮询机制内化。内容包括穿透internal与runtime包的调用边界、解析pollDesc结构体的跨层级指针映射,以及探究gopark如何解绑系统线程(M)与挂起协程(G),以此揭示 Go 语言“上层同步调用,底层异步执行”的实现本质。实践建议:强烈建议在 Linux/WSL 环境下实际编译并调试本项目代码。虽然本项目并非自顶向下的网络全景概述,但对于希望夯实系统级网络基础的初学者,或致力于研究高级语言底层黑盒的进阶开发者而言,这是一份客观且直观的源码级分析起点。
本项目按照学习路线分为以下几个模块:
- 01 Basic (基础概念)
- 02 UDP Socket (UDP 通信)
- 03 TFTP Implementation (TFTP 协议实现)
- 04 Broadcast & Multicast (广播与多播)
- 05 TCP Socket (TCP 通信)
- 01 The Goal of This Part(本节目标)
- 02 Sample Code Dissection & netpoll Internals Overview(示例代码具体拆解与底层
netpoll机制概览) - 03 listen Function Internals(listen函数的内部调用)
- 04 The Netpoll Architecture(netpoll的网络体系)
- 05 Underlying implementation of Accept(accept-的底层实现)
- 06 Data transmission and I/O models(数据的传输和io模型)
- 07 Summary(总结))
- 01 Preparation for a Deep Dive into the Protocol Stack(深入协议栈的准备)4
- 02 Pointer Casting and Decapsulation(指针强转与解封装)
网络通信的基石,主要解决不同层次上的数据表示差异。
-
01_endian (字节序)
- 展示了计算机 小端存储 (Little-Endian) 与 大端存储 (Big-Endian) 的区别。
- 为什么会有这种存储的差异性:这纯粹是CPU架构的历史遗留问题(比如 Intel x86 选了小端,而早期的 Motorola 选了大端)。但为了防止乱套,网络协议强行规定了必须用大端作为网络字节序。所以我们在发包前必须老老实实把主机的小端序转过去。
-
02_htol_htons (字节序转换)
- 基于
<arpa/inet.h>头文件。extern uint16_t htons (uint16_t __hostshort) __THROW __attribute__ ((__const__));
- 实现 主机字节序 (Host) 向 网络字节序 (Network) 的转换 (如
htonl,htons)。 - 解释一下
__THROW和__attribute__:这其实是写给编译器看的tip。__THROW告诉编译器这函数绝不抛出异常,__const__告诉编译器这函数是“纯函数”(只依赖输入,没副作用)。这样编译器就能大胆地做优化,把多余的调用给省掉。
- 基于
-
03_inet_pton (IP地址转换)
- 全称 Presentation to Numeric。
- 将点分十进制字符串 (如 "192.168.1.1") 转换为网络传输用的 32位无符号整数。
int inet_pton (int __af, const char *__restrict __cp, void *__restrict __buf) __THROW;
- 为什么需要一个 void 类型:这里设计得很巧妙,因为 IPv4 用
struct in_addr(4字节),IPv6 用struct in6_addr(16字节)。用void*就能像万能插头一样,不管你是哪种协议,都能把转换后的二进制数据填进去。
-
04_inet_ntop(IP地址还原)
- 全称 Numeric to Presentation。
- 将 32位网络字节序整数还原为人类可读的 IP 字符串。
extern const char *inet_ntop (int __af, const void *__restrict __cp, char *__restrict __buf, socklen_t __len) __THROW;
extern意味着这是个外部引用,__len则是为了防止缓冲区溢出(C语言老生常谈的内存安全问题),这一部分被认为是add部分单独开一行。
无连接的、不可靠的数据传输协议。
-
- 展示了socket套接字创建的函数
int socket (int __domain, int __type, int __protocol)
- domain决定IP类型,type则是决定tcp还是udp的传输类型。protocol是具体的协议格式
- socket是一个int类型,靠文件描述符的抽象在系统层面上实现调用,展现了linux中一切皆文件的设计思想
- 作为文件描述符,一定要在程序最后对其进行关闭,close在通信过程中就意味着断开连接,在tcp中,这一点变得更为复杂
- 展示了socket套接字创建的函数
-
-
展示了udp传输类型的数据发送
ssize_t sendto (int __fd, const void *__buf, size_t __n,int __flags, __CONST_SOCKADDR_ARG __addr,socklen_t __addr_len);
-
ssize_t 是个啥:其实就是
signed int。因为这函数成功时返回发送字节数(正数),失败要返回 -1。如果用普通的size_t(无符号),就没法表示 -1 这个错误状态了。 -
adding
-
在这里进行数据传输的时候通过addrsocket_in记录和写入ip和port
struct sockaddr_in { __SOCKADDR_COMMON (sin_); in_port_t sin_port;/* Port number. */ struct in_addr sin_addr;/* Internet address. */ /* Pad to size of `struct sockaddr'. */ // ... padding ... };
-
需要注意的是,尽管我们写入的是sockaddr_in但是封装函数写入的确实sockaddr结构体
struct sockaddr { __SOCKADDR_COMMON (sa_); /* Common data: address family and length. */ char sa_data[14]; /* Address data. */ };
-
可以看到,这实际上是进行了一个数据压缩的过程,这样的设计展现了编程最核心的问题,自然语言编程和机器二进制构成的矛盾
-
-
-
bind这个函数主要是为了固定ip和端口号,根据这个函数的面向我们可以很容易理解信息的接收方更加需要这个需求。
int bind (int __fd, __CONST_SOCKADDR_ARG __addr, socklen_t __len) __THROW;
-
在tcp/udp编程时我们一般简单的就把server称为需要绑定的一方,但这实际上是由于信息接受和发送的相对关系所决定的,在多播和组播中我们能够看到这一点的进一步体现
-
-
-
recvform是udp接受函数
recvfrom (int __fd, void *__restrict __buf, size_t __n, int __flags, __SOCKADDR_ARG __addr, socklen_t *__restrict __addr_len)
-
这里需要注意的是recvfrom是接受别的主机的数据,所以我们需要预先创建空的结构体供其填入,并且还改变addrlen来作为接受到的信息长度输出,这种设计使得udp可以很简单的实现多线程工作,代价就是每一个client都需要相应的结构体来对应,而我们将会在后续看到,因为tcp要求的三次握手,导致tcp的设计走向了截然不同的道路
-
-
-
这一段是具体的udp的客户端和服务端的运行代码。本质上就是调用上述函数具体实现通信过程
if(argc<3){ fprintf(stderr,"Usage : %s<IP> <PORT>\n",argv[0]); exit(1); }
-
这一段保证了运行程序时输入了ip和port,这里需要注意的是,client里面输入的也是server的ip和port,因为client根本不需要在乎自己,他只需要保证数据交互
if(recvfrom(sockfd,buf,sizeof(buf),0,(struct sockaddr*)&clientaddr,&addrlen)==-1){ perror("fail to recvfrom"); //即时接受失败也可以继续进行 continue; }
-
这里体现了recvfrom的核心用处,是udp传输中的核心所在,通过接受数据,将数据发送主机的ip记录下来,用来进行sendto操作,这种设计使得udp的多client能极为容易实现,尽管在实际执行写起来的时候稍微有点冗余。
-
-
udp通信交互流程简图:
[Client] [Server]
| |
|--- sendto(Data, ServerIP) ----->|
| | recvfrom (获取 ClientIP)
| |
|<-- sendto(Echo, ClientIP) ------|
| |
recvfrom(Echo)
- adding
-
我们这里的输入输出主要使用fgets和printf方法
extern char *fgets (char *__restrict __s, int __n, FILE *__restrict __stream) __wur __fortified_attr_access (__write_only__, 1, 2) __nonnull ((3));
-
缓冲区的大坑:在c中string以'/0'作为在内存中的数据界限,而fgets则会将换行符计入,如果为了数据的纯洁性应该将换行符去掉,但是对于printf中来说只有遇到换行符才会将数据从缓冲区中打印出。所以这两个函数搭配时不需要做什么处理。
-
Trivial File Transfer Protocol (简单文件传输协议)。
-
begin
- 在开始之前我想简单的介绍一下理解tftp的核心所在,作为基于udp协议的小文件传输协议,在c中实现tftp最让人恼火也最为关键的就是手动构建和分析二进制报文,你需要去拼凑每一个字节。
-
Part 1: 报文构造区
-
明确下载文件名
scanf("%s",filename); -
第一个难点是去构造数据报文,这玩意不是字符串,是紧凑的二进制。
-
TFTP 二进制报文结构图:
2 bytes string 1 byte string 1 byte ------------------------------------------------ | Opcode | Filename | 0 | Mode | 0 | ------------------------------------------------ -
代码里用了一个很巧妙的操作
sprintf来拼接:packet_buf_len = sprintf((char*)packet_buf,"%c%c%s%c%s%c",0,1,filename,0,"octet",0);
-
解释一下字节序:这里为什么不需要在意大端存储和小端存储的转换?因为
sprintf是按顺序写入单字节的。写入 0 再写入 1,内存里就是00 01,这恰好符合网络字节序的大端要求。
-
-
Part 2: 接收与解析循环 (State Machine)
-
packet_buf 用来接受server发送来的数据,这里发送数据全部使用unsigned char 类型来进行发送,而通过数据来储存这个数据,意味者可以简单直接的通过使用这个数组来对数据包头进行解析
//这里是数据报文的传输层级 unsigned char packet_buf[1024]= "";
//错误信息 if(packet_buf[1]== 5){...exit(1)} //收到server正确的反馈请求 if(packet_buf[1]==3){//进行下一步处理}
-
需要注意的是首先要去判断是否存在相应文件,可以用bool或int类型数据来标识,如果没有则需要先创建相应文件
-
-
Part 3: 验证与ACK (手动可靠性)
-
另一个难点就是接受核对数据保重的区块编号,因为udp是不可靠的连接,所以说需要手动去验证是否存在数据丢失。我们需要从数据报头中读取并和本地记录的进行比对。
-
数据验证流程:
if((num +1) == ntohs(*(packet_buf+2))) //success -> 发送ack报文 //fail -> 数据丢失,推出
-
如果当前数据包没问题,需要构建ack报文发送给server,来让他来发送下一块数据。这里必须用
ntohs,因为包头里的序号是网络序,得转成本地序才能对比。packet_buf[1]= 4;
-
这里尽管发送了文件块数据,但是server只需要对数据包头进行验证
-
如果数据块小于516,即文件数据小于512,那么说明写入结束,但在这里没有去考虑文件大小刚好是512倍数的情况
-
总结:这样一个客户端主要的难题就是二进制报文的处理,因为udp本身是不可靠的,所以我们就需要手工做数据包做数据包头进行验证,我们可以看到的是,这样一个验证思路实际上和tcp的三次握手非常相像,所以一般由tcp承担文件传输的工作
-
-
-
服务端逻辑相对被动,主要是解析和反馈:
-
验证数据报文和是否由相关文件
-
自定义区块num并写入包头,用缓冲区作为文件数据中转
-
等待并解析ack数据包
-
-
构造错误包 (辅助函数) 这里需要注意的是一个构建错误数据包的函数,为了代码整洁抽出来的:
void senderr(int sockfd,struct sockaddr* clientaddr,char* err,int errcode,socklen_t addrlen){ unsigned char buf[516] = ""; // 构造错误包: [00] [05] [00] [ErrCode] [ErrMsg] [00] int buf_len = sprintf((char*)buf, "%c%c%c%c%s%c", 0, 5, 0, errcode, err, 0); sendto(sockfd,buf,buf_len,0,clientaddr,addrlen); }
-
本身这样的函数使得我们可以迅速的在逻辑末节点(比如文件打不开)发送相关信息。
-
总结:这里服务端展现的设计思想基本与客户端一致,这里需要注意的是两边都在本地储存了区块号,双方都对数据报文进行了解析
-
-
background * 在这里我们首先要去介绍一个函数
setsockopt。extern int setsockopt (int __fd, int __level, int __optname, const void *__optval, socklen_t __optlen) __THROW;
-
这个函数的作用是在文件描述符的基础上对其做进一步的限制说明。
-
参数详解:
-
__fd:socket 的文件描述符。 -
__level:选项定义的层次。通常设为SOL_SOCKET(通用套接字选项) 或IPPROTO_IP(IP层选项)。 -
__optname:具体要设置的选项名。例如SO_BROADCAST(允许广播)、SO_REUSEADDR(端口复用)。 -
__optval:指向存放选项值的缓冲区的指针。通常是一个int类型的指针,1表示开启,0表示关闭。 -
__optlen:optval缓冲区的长度。 -
在server中,我们也可以将其设置为非端口复用模式来方便调试,但为了代码的简便性,我在代码实例中并没有添加这部分内容。
-
端口复用代码实例:
int opt = 1; // 允许重用本地地址和端口,解决 "Address already in use" 错误 setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
-
-
- 这个文件展现的是 broadcast 的发送方。和 tcp,udp 编程不同,在广播和多播里并没有传统意义上的 cs 框架,而是信息发送和接受的相对关系。
- 这里采用 sendto 函数,除了需要额外对 socket 做功能添加外,基本上和 udp 的 client 思路一致。
-
- 这个文件的结构甚至比 udp_server 的结构还更加简单,因为这里广播地址是确定的,只需要监听是否有对应的数据包即可。
- 这里有意思的是 recv 里面并不需要设置对应权限,这也和广播的设计思路相一致,广播的发送方需要额外的检验,而接收方只需要判断这个数据包是不是找自己的。
-
summary
- 广播的实现是基于 udp 完成的,因为广播本身就是一个一对多的单向过程,在实际网络过程中常常伴随着多次广播,所以说在这里数据的快速发送的重要性要远大于数据的稳定传输。
- 广播的设计哲学:广播类似于“大喇叭喊话”。因为这种行为会占用整个子网的带宽,可能造成扰民(网络风暴),所以内核设计上要求发送者必须显式调用
setsockopt(SO_BROADCAST)来申请权限(打开开关)。而接收者是被动的,不需要特殊权限就能听到。
-
adding (IP Class Knowledge)
- 理解多播需要先学习 IP 分类知识:
- A/B/C 类:用于单播 (Unicast),即一对一通信。
- D 类 (224.0.0.0 ~ 239.255.255.255):专用于多播 (Multicast)。这部分 IP 不属于任何一台具体的主机,而是代表一个“组”。向这个 IP 发送数据,所有加入了这个组的主机都能收到。
- E 类:保留科研用。
-
- 在这里组播的发送方甚至连
setsockopt都不用使用,这是因为本身有 D 类 IP 段被划分成专用于组播。所以 send 只需要向这些 ip 段里面发送数据,当它进行发送的时候,实际上就已经在对应 ip 设置了对应的广播组。
- 在这里组播的发送方甚至连
-
adding
- INADDR_ANY 是什么:在代码中常常见到
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);。它的数值其实是0.0.0.0。它的意思是“绑定到本地所有可用的网络接口”。如果你既有 Wifi 又有网线,使用INADDR_ANY可以让你从两个网卡都能接收到数据,而不需要把程序绑定死在某一个具体的 IP 上。
- INADDR_ANY 是什么:在代码中常常见到
-
-
recv 中需要使用
setsockopt进行设置。在之前所说,setsockopt 中的_optval是void*类型,这也意味着我们可以构造结构体进行数据传参,这也是我们在 c 中常用的方法。而这里我们需要采用的是专门为了多播组设置的结构体ip_mreq进行参数设置:struct ip_mreq { /* IP multicast address of group. */ struct in_addr imr_multiaddr; // 多播组的IP (比如 224.0.0.88) /* Local IP address of interface. */ struct in_addr imr_interface; // 自己加入该组的接口IP (通常用 INADDR_ANY) };
-
这里
imr_interface是本地接口,imr_multiaddr是组播 IP,其下都有s_addr成员,和sockaddr_in的设计一样,都是因为历史原因导致。 -
summary (Broadcast vs Multicast Philosophy)
-
这里和广播需要做出明确划分,这也体现了两者底层逻辑的截然相反:
-
广播 (Broadcast):是发送方需要
setsockopt。因为广播是暴力的,默认禁止,发送者必须主动申请“我要喊话”的权限。 -
多播 (Multicast):是接收方需要
setsockopt(加入组IP_ADD_MEMBERSHIP)。因为多播是精准的,发送方只是往一个 D 类 IP 发数据(谁都可以发),关键在于接收方必须显式地声明“我订阅了这个频道”,内核才会把对应的数据包捞上来给你。
-
-
background
- 尽管在这里 tcp 和 udp 的最大区别是 tcp 有了三次握手四次挥手来保证数据传输,但我们在调用函数进行编程时,这些复杂的状态流转大多已被内核封装。换句话说,我们在这里更多是从应用层的角度去考虑 socket 的生命周期管理。
- 设计哲学的转变:UDP 是无状态的,一个 socket 可以给任意 IP 发包;但 TCP 是面向连接的,就像打电话,必须先接通才能说话。这种设计要求服务端必须维持一个“监听 socket”专门用来接客,每来一个客人(客户端),就得新建一个“服务 socket”专门负责聊天。
- 并发的核心矛盾:如何高效地管理这些成百上千的“服务 socket”?这就派生出了两条技术路线:
- 多进程/多线程:通过增加人手(CPU调度单元)来解决,一个连接对应一个线程/进程。
- IO 多路复用 (Non-blocking):通过非阻塞 IO + 事件轮询(如 epoll),让一个服务员(单线程)就能看管所有桌子。
-
-
这里展现的是 tcp 的客户端,在创建好 socket 和封装好 server 结构体后,我们首先要调用封装好的函数去建立底层连接。
extern int connect (int __fd, __CONST_SOCKADDR_ARG __addr, socklen_t __len);
-
Connect 的底层机制 (三次握手触发器):
- 当调用
connect时,内核会向 Server 发送一个 SYN 包。 - 此时函数处于阻塞状态,等待 Server 回复 SYN+ACK。
- 收到回复后,Client 再发送一个 ACK,此时连接建立 (ESTABLISHED),函数返回 0。
- 当调用
-
在 client 这一方通常只需要维护一个 socket,建立连接后,内核已经把这个 socket 绑定到了特定的远端 IP 和端口,所以
send函数不需要像sendto那样重复指定目标地址。extern ssize_t send (int __fd, const void *__buf, size_t __n, int __flags);
-
-
adding (Buffer Trap)
-
strlen vs sizeof 的大坑:发送字符串时,**千万不要用
sizeof(buf),要用strlen(buf)。 -
原因:
sizeof计算的是数组申请的总内存(比如 1024),而strlen计算的是实际字符长度(比如 "hello" 是 5)。如果你用sizeof,你会把缓冲区里后面几百个没用的乱码(垃圾数据)也发给对方,这在处理协议时是灾难性的。
-
-
-
这里是 tcp 服务器的实例。在创建好 socket 和填充绑定好结构体后,首先要将 socket 设置为监听状态。
extern int listen (int __fd, int __n) __THROW;
-
__fd: 之前创建的套接字文件描述符。 -
__n: Backlog (积压队列长度) -
为什么需要 Listen:
-
内核为监听套接字维护了两个队列:半连接队列 (收到 SYN 但没收到最终 ACK) 和 全连接队列 (三次握手完成等待 Accept 取走)。
-
__n实际上决定了这些队列(通常是全连接队列)的大小。如果队列满了,新的连接请求就会被直接丢弃或拒绝(SYN Flood 攻击也是针对这里)。 -
设置好监听状态后,通过
accept从全连接队列中取出一个已完成的连接。extern int accept (int __fd, __SOCKADDR_ARG __addr, socklen_t *__restrict __addr_len);
-
两个 FD 的故事:
-
accept返回的int是一个全新的文件描述符 (Connected Socket)。 -
设计哲学:原来的
sockfd只负责把人领进门;accept返回的fd专门负责这一桌的通信。这种分离设计使得 TCP Server 可以同时处理握手请求和数据传输。 -
Recv 的返回值判断:
extern ssize_t recv (int __fd, void *__buf, size_t __n, int __flags);
-
> 0: 接收到的字节数。 -
= 0: 重要! 这代表对端关闭了连接 (FIN 包)。TCP 是全双工的,0 字节读意味着 Read 通道关闭。 -
< 0: 出错 (Error),需要检查 errno。 -
而在dup中可以直接发送长度为0的数据包
-
summary (CS Framework)
-
-
TCP C/S 交互流程图:
[Server] [Client] socket() socket() | | bind() | | | listen() | | | accept() <---(3-Way)---> connect() (Block...) Handshake | | | recv() <----(Data)----- send() | | send() ----(Data)----> recv() | | close() <----(4-Way)---> close() Wavehand -
-
这里是通过多进程的方式来实现并发。
extern __pid_t fork (void) __THROWNL;
-
Fork 的魔法:调用一次,返回两次。
-
返回
> 0(子进程 PID):当前是父进程,任务是继续accept等待新人。 -
返回
0:当前是子进程,继承了父进程的所有资源(包括 socket),任务是处理刚刚那个连接的send/recv。 -
COW (Copy On Write):Linux 这里的效率很高,并不会真的立马把父进程所有内存复制一份,只有当子进程尝试修改数据时,才会真正复制内存页。
-
僵尸进程与信号回收:
-
子进程结束时如果父进程不管,它会变成“僵尸进程”占用 PID 资源。
-
我们利用
signal机制来异步回收。// 注册信号处理函数 signal(SIGCHLD, handler); void handler(int sig){ // 循环回收所有已结束的子进程 while((waitpid(-1, NULL, WNOHANG)) > 0){} }
-
Waitpid 参数详解:
-
-1: 等待任意子进程。 -
NULL: 不关心子进程具体的退出状态码 (exit code)。 -
WNOHANG: 非阻塞关键。如果当前没有子进程结束,立刻返回 0,不要卡在这里傻等。这保证了 Server 不会因为回收垃圾而停止响应新请求。
-
-
-
使用多线程处理。进程是资源分配的单位(重),线程是 CPU 调度的单位(轻)。
extern int pthread_create (pthread_t *__restrict __newthread, const pthread_attr_t *__restrict __attr, void *(*__start_routine) (void *), void *__restrict __arg) __THROWNL __nonnull ((1, 3));
-
参数详解:
-
__newthread: 指向线程 ID 的指针,用于接收新线程 ID。 -
__attr: 线程属性,通常传NULL使用默认值。 -
__start_routine: 线程启动后要执行的函数指针。 -
__arg: 传给启动函数的唯一参数。由于只能传一个,所以通常需要把 socket、IP 等信息打包成结构体,转为void*传入。 -
编译指令:
gcc server_thread.c -o server -lpthread
-
自动垃圾回收 (Detach):
pthread_detach(pthread_self());
-
原理:默认情况下线程是
joinable的,退出后需要主线程调用pthread_join来“收尸”。调用detach是告诉内核:“这个线程也是个普通打工人,死了直接埋了就行”,内核会在线程退出时自动释放其栈空间和资源,无需主线程操心。
-
-
-
在这个文件里面我们尝试将 socket 设置为非阻塞 (Non-blocking)。这是迈向高性能 IO (Epoll/IOCP) 的第一步。
// 获取当前 flag int flag = fcntl(sockfd, F_GETFL, 0); // 设置新 flag = 旧 flag + 非阻塞位 fcntl(sockfd, F_SETFL, flag | O_NONBLOCK, 0);
-
位运算图解:
-
fcntl通过位掩码来管理状态。 -
flag(假设):0000 0010(代表已有的属性) -
O_NONBLOCK:0000 0100(非阻塞属性) -
|(OR) 操作:0000 0110(同时拥有两种属性) -
非阻塞的代价 (Errno):
-
当 socket 非阻塞时,如果
recv缓冲区里没数据,它不会卡住,而是立刻返回-1。 -
此时必须检查
errno。如果errno == EAGAIN(Try again) 或EWOULDBLOCK,说明**“现在没数据,不是出错了,待会再来”**。这使得程序可以在没数据时去干别的事。
-
-
-
Epoll: Linux 下最高效的 IO 多路复用器。它解决了
select/poll轮询所有 socket 效率低下的问题。extern int epoll_create1 (int __flags) __THROW;
-
创建一个 epoll 实例(红黑树根节点),返回句柄
epfd。struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ } __EPOLL_PACKED;
-
核心参数:
-
events: 感兴趣的事件。 -
EPOLLIN: 有数据可读 (包括新连接)。 -
EPOLLET: 边缘触发 (Edge Triggered)。数据这就只有一次通知,没读完下次不提醒(高效但难写)。默认是 LT (Level Triggered),没读完一直提醒。 -
data:data里面有多种数据结构,这里我们使用文件描述符 -
data.fd: 记录是哪个 socket 发生了事件。extern int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event) __THROW;
-
操作类型 (
__op): -
EPOLL_CTL_ADD: 注册新的 socket。 -
EPOLL_CTL_MOD: 修改监听事件。 -
EPOLL_CTL_DEL: 移除 socket。extern int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout)
-
Event Loop 逻辑:
-
epoll_wait阻塞等待,一旦有 socket 就绪,它会将这就绪的 socket 填入__events数组并返回数量n。 -
我们只需要遍历这
n个活跃的 socket,而不需要遍历所有 10000 个 socket。 -
分流处理:
-
如果
events[i].data.fd == listen_fd: 说明有新连接 -> 调用accept->epoll_ctl(ADD)加入监控。 -
否则: 说明是已连接的客户端发数据了 -> 调用
recv/send处理业务。
-
-
adding
- 总结:Epoll 用单线程实现了高并发,避免了多线程频繁切换上下文的开销 (Context Switch)。但如果业务逻辑非常耗时(比如计算密集型),单线程会被卡死。
- Go 的伏笔:Go 语言的 Goroutine 实际上就是将“多线程的易用性”和“Epoll 的高性能”结合了起来——底层用 Epoll 监听,上层用轻量级协程伪装成阻塞 IO,我们将在后续部分看到这种天才般的设计。
-
在上一节中,我们探讨了 C 语言中
netpoll(网络轮询)的底层实现。而在本节,我们将把目光转向 Go 语言,深入剖析 Go 语言中netpoll的实际应用与巧妙设计。在此之前,如果你对“程序”与“进程”等基础概念还不够熟悉,我强烈推荐你观看 [Core Dumped 的这期科普视频](https://www.youtube.com/watch?v=7ge7u5VUSbE [00:46]),以此来巩固必要的计算机底层知识。 -
下面是一段典型的 Go 语言网络编程代码。通过剖析这段代码的运行机制,我们将逐步揭开 Go 语言底层网络模型的实现原理。
package main import ( "fmt" "net" ) func main() { // 1. 监听本地的 8080 端口 listener, err := net.Listen("tcp", ":8080") if err != nil { panic(err) } defer listener.Close() fmt.Println("Server is running on :8080...") for { // 2. 阻塞等待新的客户端连接 conn, err := listener.Accept() if err != nil { fmt.Println("Accept error:", err) continue } // 3. 为每个连接开启一个独立的 Goroutine 进行处理 go handleConnection(conn) } } func handleConnection(conn net.Conn) { defer conn.Close() buf := make([]byte, 1024) for { // 4. 读取客户端发送的数据 n, err := conn.Read(buf) if err != nil { fmt.Println("Connection closed or read error") return } // 5. 将读取到的数据原样写回(Echo Server) _, err = conn.Write(buf[:n]) if err != nil { fmt.Println("Write error:", err) return } } }
这段代码虽然看起来是非常简单的“同步阻塞”风格,但得益于 Go 语言运行时的封装,它在底层其实是非常高效的异步非阻塞 I/O。下面我们结合 netpoll 来逐一拆解:
- 表面逻辑:创建一个 TCP 监听器,绑定在 8080 端口。
- 底层机制:在这个阶段,Go 运行时不仅仅是调用了系统底层的
socket()和bind()函数。更重要的是,它会将这个监听的 Socket 设置为**非阻塞(Non-blocking)**模式,并将其文件描述符(FD)注册到操作系统的事件轮询器中(例如 Linux 的epoll,macOS 的kqueue)。这就是 Go 中netpoll机制的入口。
- 表面逻辑:程序运行到这里会“卡住”(阻塞),直到有新的客户端连接进来。
- 底层机制:由于底层的 Socket 是非阻塞的,如果没有新连接,底层的
accept系统调用会直接返回错误(如EAGAIN)。此时,Go 的netpoll机制就会介入:它会将当前的 Goroutine 挂起(Park),释放 CPU 线程去执行其他任务。直到底层的epoll监听到该端口有新的连接到达时,netpoll才会**唤醒(Ready)**这个挂起的 Goroutine 继续向下执行。这种设计让单核 CPU 也能支撑极高的并发等待。
- 表面逻辑:获取到新连接后,启动一个新的协程去专门服务这个客户端,主循环继续回去执行
Accept()等待下一个人。 - 底层机制:这是 Go 网络编程最核心的设计模式。相比于 C/C++ 中需要手动编写复杂的回调函数或状态机来处理并发,Go 通过极轻量级的 Goroutine(初始只占 2KB 内存)实现了简单的并发。成千上万个连接对应的就是成千上万个 Goroutine,由 Go Scheduler(调度器)高效调度。
- 表面逻辑:在独立的协程中,持续循环读取客户端发来的数据。如果没有数据发来,
Read就会阻塞。 - 底层机制:这里的阻塞逻辑与
Accept()完全一致。当缓冲区没有数据可读时,该读操作会触发 Go 调度器将当前的handleConnectionGoroutine 挂起,并将这个 Socket 注册到netpoll中。当客户端真正发来网络数据,操作系统网络栈接收完毕后,epoll触发事件,Go 的后台网络轮询线程就会把这个 Goroutine 重新放入可运行队列中,代码随即从conn.Read处“苏醒”并继续执行。
总结:这段代码完美展示了 Go 语言设计的巧妙之处——用最简单的同步代码逻辑,写出了底层由 epoll + Goroutine 驱动的高性能异步非阻塞服务器。开发者无需关心复杂的文件描述符轮询和状态机切换,所有的“脏活累活”都被封装在了 Go 的运行时网络多路复用器(netpoll)中。而在接下来的步骤中,我们将自上而下去拆解这整个逻辑架构。
-
如果我们沿着
listen函数一路深入,会发现核心入口是 socket 函数。这个函数是 Go 底层创建 socket 的实例,和我们之前在 C 语言中调用的socket()系统调用一样,它也会返回一个相应的文件描述符(File Descriptor)。// 核心逻辑简述 s, err := sysSocket(family, sotype, proto) // ...... err = setDefaultSockopts(s, family, sotype, ipv6only) // ...... // 根据 socket 类型分发逻辑 switch sotype { case syscall.SOCK_STREAM, syscall.SOCK_SEQPACKET: // 如果是流式套接字(TCP),进入 listenStream if err := fd.listenStream(ctx, laddr, listenerBacklog(), ctrlCtxFn); // ...... }
-
底层交互与自举:在这里我抽出了
sysSocket这一核心逻辑。它调用的是底层封装的汇编命令。值得一提的是,Go 区别于 PHP 等解释型语言的一个显著特征在于 Go 是自举(Self-hosted)的——Go 语言本身也是由 Go 编写的。这意味着 Go 拥有自己的汇编代码和对应的cmd编译目录。当我们追溯 Go 底层时,可以清晰地看到 Go 代码与汇编代码的交界处。 -
参数配置:宏观上看,这个函数相当于我们在 C 中配合使用的
socket()和setsockopt()。sysSocket负责创建,而setDefaultSockopts负责设置基础属性。 -
角色定型:完全体的 Socket(是作为客户端还是服务器?是流式传输还是数据包?)最终通过上层传参确定。在本例中,区别于客户端的
Dial,Listen操作通过绑定本地端口(如代码中的8080),明确了当前机器作为服务端的角色。
-
在函数内部,创建流式套接字(TCP)和数据包套接字(UDP)被封装成了不同的路径。以我们关注的流式套接字为例,看看 listenStream 做了什么:
// 1. 设置监听器的默认 Socket 选项 setDefaultListenerSockopts(fd.pfd.Sysfd) // ... // 2. 将地址转化为系统识别的 sockaddr 结构体 // ... // 3. 应用用户自定义的 Socket 属性 // ... // 4. 绑定端口 (对应 C 中的 bind) syscall.Bind(fd.pfd.Sysfd, lsa) // 5. 开始监听 (对应 C 中的 listen) listenFunc(fd.pfd.Sysfd, backlog) // 6. 初始化文件描述符(关键步骤!) fd.init()
-
C 语言的影子:数据包套接字的创建逻辑类似,只是多了对多播地址的判断。可以明显看到,Go 的这一套流程完美复刻了我们在 C 代码中执行的
socket->bind->listen标准三部曲。这揭示了 Go 的网络底层依然是基于标准 OS 套接字机制的封装。
-
之前的步骤和我们在 C 中实现的逻辑别无二致,但从 fd.init 开始,我们将进入 Go 独有的魔法领域——Netpoll(网络轮询器)。
-
这个函数的主要功能是判断当前文件描述符是否属于网络文件(即非普通文件),如果是,则为它初始化网络轮询机制:
// 初始化 pollDesc (poll descriptor) fd.pd.init(fd)
-
init 函数是 Go
net标准库与runtime运行时包的关键交汇点,也是“同步代码、异步执行”的基石。func (pd *pollDesc) init(fd *FD) error { // 1. 保证全局网络轮询器只被初始化一次 serverInit.Do(runtime_pollServerInit) // 2. 将文件描述符注册到轮询器中 (底层对应 epoll_ctl/kqueue 等) ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd)) if errno != 0 { return errnoErr(syscall.Errno(errno)) } // 3. 保存上下文 pd.runtimeCtx = ctx return nil }
-
serverInit.Do:利用sync.Once机制,确保在整个程序生命周期中,全局的网络轮询器(Poller)只会被初始化一次。 -
runtime_pollOpen:这是最关键的一步。它将我们之前创建的 Socket 文件描述符(fd)注册到底层的 IO 多路复用器中(在 Linux 下即调用epoll_ctl添加EPOLLIN等事件)。如果注册失败,通常意味着系统资源耗尽或环境异常。
小结:
在这一部分,我们验证了 Go 底层的 Socket 封装在前半程与我们 C 语言实现 (socket-underlying-c) 的逻辑高度一致。
然而,分水岭出现在 fd.init 之后——Go 并没有止步于创建 Socket,而是通过 runtime 将其无缝接入了 Netpoll 体系。在接下来的部分中,我们将进一步探索这个围绕 Netpoll 建立起来的庞大网络处理体系,以及它是如何通过调度器与 Go 的多协程(Goroutine)完美结合的。
延续之前的思路,我们已经梳理了 internal/poll 包中的高层逻辑。现在,我们将视线由表及里,正式下沉到 Go 核心的 runtime 包中。
在跨越这个边界之前,必须重申一个核心机制,即我们在前文中提及的 pd.runtimeCtx = ctx 这一行看似不起眼的代码。这实际上是 Go 网络轮询器(Netpoller)设计的点睛之笔:
-
双面一体的结构:
internal包与runtime包中各有一个pollDesc结构体。它们在逻辑上是一一对应的,宛如一个实体的“两面”。 -
internal/poll.pollDesc:面向用户层,处理文件描述符的生命周期、读写超时等通用逻辑。 -
runtime.pollDesc:面向底层,承载着与操作系统内核交互(如 epoll/kqueue)的具体状态。 -
指针的“偷渡”与桥接:
pd.runtimeCtx = ctx这一操作,实质上是将runtime层级下的结构体指针(以uintptr这种通用且不被 GC 追踪的形式)“走私”并封装到了internal层级的结构体中。 -
层级解耦与业务偶联:这种设计使得上层
internal包在进行底层操作时,只需将这个“句柄”传回,runtime就能瞬间找回其对应的内核态上下文。Go 正是以这种非侵入式的方式,实现了不同层级业务的严格封装与必要时刻的高效偶联。
当我们追踪 netpoll 的初始化流程时,在 netpollGenericInit 中,我们可以看到一段非常经典的并发控制代码。为了确保在多线程高并发场景下 netpoll 只被初始化一次,Go 采用了 Double-Checked Locking(双重检查锁定) 模式:
- First Check(无锁检查):首先利用原子操作(Atomic Load)快速判断
netpollInited标志位。如果已初始化,直接返回,避免了昂贵的锁开销。 - Lock(加锁):若未初始化,则获取全局锁,进入临界区。
- Second Check(有锁检查):再次检查标志位。这是为了防止在“第一步检查”和“第二步加锁”的微小时间窗口内,已有其他线程抢先完成了初始化。
- Init(执行初始化):只有通过了这两道防线,才会真正调用底层特定平台的
netpollinit()。
这种严谨的逻辑闭环,保证了 Netpoller 在高并发启动时的绝对线程安全。
继续深入,代码将把我们带入汇编语言的领域。虽然我们不需要逐行深究汇编指令,但这里有两个设计哲学值得我们特别关注:
- 多路复用器的“自动驾驶”与屏蔽差异
Go 语言遵循“编写一次,到处编译”的哲学。在源码层面,Go 通过 Build Tags(构建标签)为不同的操作系统提供了不同的实现文件(例如 Linux 下的
netpoll_epoll.go,macOS 下的netpoll_kqueue.go)。 上层逻辑无需关心底层是epoll、kqueue还是IOCP,Go Runtime 会根据编译目标平台自动链接对应的底层操作集。通过这种屏蔽差异的封装,用户层感受到的是统一的异步 I/O 体验,而netpoll这个名称本身,就是对所有这些多路复用技术的一个高度抽象。 - 直面内核:Syscall6 与寄存器操作
在 Go 1.19 及后续版本中,特别是在
internal/syscall/unix路径下,Go 暴露了如Syscall6这样的底层接口。这样一个通用的汇编调用接口展示了go区别与java,php的自实现特点。 - 寄存器级操作:这实际上是 Go 语言从“用户态”跃迁至“内核态”的跳板。正如之前提供的视频中讨论的([Core Dumped 的这期科普视频](https://www.youtube.com/watch?v=7ge7u5VUSbE [00:46]))中所讨论的,系统调用(System Call)本质上是将参数装入特定的 CPU 寄存器(如 RAX, RDI, RSI 等),然后触发软中断(Trap)请求操作系统内核介入。
- 零成本封装:Go 语言在这里并没有依赖庞大的 C 标准库(libc),而是直接通过汇编代码封装了这些操作码(OpCode)。这不仅减小了二进制体积,更重要的是帮助开发者规避了复杂的寄存器管理,提供了一个既接近硬件极限速度、又具备类型安全保障的系统调用接口。
-
核心接入点:poll_runtime_pollOpen 在 poll_runtime_pollOpen 中,我们可以看到
netpoll是如何将底层的网络描述符(FD)纳入到 Runtime 的监管之下的。这个函数起到了承上启下的作用:// 伪代码逻辑概览 func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) { // 1. 从缓存池中获取或分配一个新的 pollDesc pd := pollcache.alloc() // 2. 初始化 pollDesc,这一步非常关键 // 必须确保此时没有其他 goroutine 对该 pd 进行读写或等待 // 这里会设置 pd.fd = fd,并生成新的序列号 fdseq lock(&pd.lock) if pd.wg != 0 && pd.wg != pdReady { throw("runtime: blocked write on free polldesc") } ... unlock(&pd.lock) // 3. 调用特定平台的实现(如 epoll/kqueue),将 fd 注册到内核 errno := netpollopen(fd, pd) return pd, errno }
-
状态检查与版本控制:这里的初始化不仅仅是赋值。
pollDesc是会被复用的(后文会详细讲解缓存机制),因此必须确保拿到的pd是“干净”的,且没有残留的 Goroutine 在等待它。 -
竞争与版本号:为了防止竞争,这里使用了加锁操作。更重要的是,这里引入了
fdseq(文件描述符序列号)。这是一个极其重要的设计,用于解决 ABA 问题:防止一个 Socket 关闭后,新的 Socket 复用了同一个 FD 和同一个pollDesc,导致旧的事件错误地唤醒了新的连接。
继续深入 netpollopen(以 Linux Epoll 为例),这里有两处极具 Go 特色的底层优化:
1.Edge Triggered (ET) 模式:
- 代码中设置了
ev.Events = syscall.EPOLLIN | syscall.EPOLLOUT | syscall.EPOLLRDHUP | syscall.EPOLLET。 - Go 毫不犹豫地选择了
EPOLLET(边缘触发),这与我们在 C 语言网络编程中的高阶实践一致。 - 原因:ET 模式仅在状态变化时通知一次,减少了
epoll_wait返回的次数,极大地降低了系统调用的频率,是构建高性能网络库的基石。
Tagged Pointer(指针压缩技术):
-
这是一个非常精妙的技巧。在
epoll_ctl的epoll_data联合体中,我们只有一个 64 位的空间来存储上下文。如果我们只存pd指针,就无法携带fdseq版本号;如果我们只存 FD,就无法快速找到pd对象。 Go 的解决方案是将 指针地址 和 版本号 压缩进同一个uintptr中: -
低位利用:由于 64 位机器上的内存对齐(通常是 8 字节对齐),指针的最后 3 位()始终为 0。
-
高位利用:虽然指针是 64 位的,但现代 CPU(如 AMD64)通常只使用低 48 位进行寻址(虚拟地址空间限制)。
-
打包逻辑:Go 利用这些“无用”的位(高位和通过位运算挤出的空间凑齐 10 位容纳 1023 个版本号),将
fdseq嵌入其中。 -
校验:在使用时,重新拆解这个 Tagged Pointer,既能还原出
pd的内存地址,又能取出版本号与当前pd的版本号比对,从而完美检测出数据是否过期或泄露。
最后,通过 syscall.EpollCtl 完成向内核的注册。
回到 poll_runtime_pollOpen 的开头,我们来探讨 pollDesc 是如何被创建和管理的。
- 批量申请与链表缓存:
Go Runtime 极度厌恶频繁的小对象内存分配。因此,
pollDesc采用了一个全局的pollCache链表进行管理:// 伪代码 lock(&c.lock) if c.first == nil { // 缓存为空,调用 persistentalloc 一次性申请一批(例如 4KB 大小) // 并将它们串成链表 } pd := c.first c.first = pd.link unlock(&c.lock)
获取和归还(Free)仅仅是简单的链表指针操作,开销几乎可以忽略不计。
- PersistentAlloc 与 GC 隔离:
这里使用
persistentalloc申请内存,而非普通的new。 - 非 GC 内存:这部分内存被标记为“持久”的,Go 的垃圾回收器(GC)不会扫描这块内存区域。
- 性能考量:
pollDesc是 Runtime 内部使用的结构体,不包含指向 Go 堆对象的指针(除了弱引用),且生命周期由 Runtime 手动管理。将其排除在 GC 扫描之外,极大地减少了 GC 的工作量(Mark 阶段的开销),这是 Go 能支撑百万级并发连接的隐形功臣之一。 - 地址稳定性:这也保证了
pollDesc的物理内存地址不会移动,这对于将其地址传递给操作系统内核(如 Epoll)是至关重要的。
1. pollCache:链表式内存池:
-
首先,我们回顾 pollcache 结构体。它在实际的网络业务逻辑中并不直接参与数据传输,而是扮演着 Memory Pool(内存池) 或 Free List(空闲链表) 的角色。
-
结构定义:它本质上是一个受锁保护的单向链表头。
type pollCache struct { lock mutex first *pollDesc // 指向空闲链表的第一个节点 }
-
架构意义:
-
复用机制:当一个网络连接关闭时,其对应的
pollDesc不会被立即释放(free)回操作系统,而是被回收到这个链表中。 -
性能优化:在处理高频短连接场景时,这种设计避免了频繁调用
persistentallo和 GC 压力。获取一个pollDesc只是简单的指针操作,耗时极低。
2. pollDesc:Netpoll 的心脏
-
pollDesc (Polling Descriptor) 是整个
netpoll体系中最为复杂的结构体。它是 Go Runtime 层面对应底层网络文件描述符的“影子对象”。 -
为了清晰地理解它的职责,我们可以将其字段划分为四大功能模块:
1. 身份与链路 (Identity & Linkage)
link *pollDesc:链表指针。当该对象处于pollcache中时,它指向下一个空闲节点;当处于活跃状态时,该字段通常为 nil。fd uintptr:核心身份标识。这是操作系统分配的原始 Socket 文件描述符(File Descriptor)。正是这个值被注册到了 epoll/kqueue 中。- 注:在之前的
netpollopen中,我们将pollDesc的指针地址(经过 Tagged Pointer 封装)写入了epoll_event.data,实现了内核事件到 Go Runtime 对象的反向映射。
2. 数据保护 (Concurrency Control)
lock mutex:互斥锁。用于保护pollDesc自身状态的原子性,防止多个 Goroutine 同时操作同一个 FD(例如并发读写或并发关闭)。atomicInfo atomic.Uint32:原子状态位。用于快速判断当前 FD 的状态(如是否已关闭、是否被中断),实现无锁的快速检查。
3. 调度器耦合 (Scheduler Integration) —— 最关键的设计 这是 Go 实现“同步语义,异步底层”的核心所在。
pollDesc包含两个关键字段:-
rg uintptr(Read Group / Read G) -
wg uintptr(Write Group / Write G) -
这两个字段是一个轻量级的状态机,它们的值不仅仅是简单的 0 或 1,而是包含以下三种状态:
-
0(pdNil):空闲状态。当前没有 Goroutine 在等待该 FD 的读/写事件。 -
pdReady(1):就绪状态。表示 Epoll 已经通知 Runtime 该 FD 可读或可写。此时 Goroutine 调用 Read/Write 不会阻塞,而是直接进行系统调用。 -
pdWait(2):等待状态。表示 Goroutine 准备挂起。 -
> 2(G 指针):这是真正的魔法。当一个 Goroutine 因为 I/O 未就绪而需要阻塞时,它会将 自己的地址(*g) 写入这里。当 Epoll 唤醒时,Netpoll 会读取这个地址,直接将对应的 Goroutine 扔回调度器的运行队列(Run Queue)。
4. 超时控制 (Deadline Management)
rt timer(Read Timer) /wt timer(Write Timer):Go 语言层面的定时器。seq uintptr(Sequence):全局唯一的序列号。- 机制:当我们调用
SetReadDeadline时,实际上是向 Go 的堆定时器中注册了一个事件。如果定时器触发时 I/O 仍未完成,Runtime 会通过比对seq来确保这是当前操作的超时,然后强制唤醒阻塞在rg/wg上的 Goroutine,并返回i/o timeout错误。
总结:
pollDesc 巧妙地将底层的 IO 资源(FD)、中间层的 IO 状态(rg/wg 状态机)以及上层的 调度实体(Goroutine 地址)通过一个结构体紧密耦合在一起。这使得 Go 能够在内核通知事件到来时,以 O(1) 的复杂度瞬间找到并唤醒正确的 Goroutine。
在上两节中,我们深入探讨了 listen 的底层系统调用实现。在正式进入 accept 之前,我们先自下而上地回顾一下这个函数的返回值封装,理清 Go 是如何将底层资源暴露给用户层的。
首先,对接到我们 C 语言视角的部分是 socket 文件描述符。这个原始的文件描述符经由上层 listenTCPProto 封装,最终成为了 TCPlistener 结构体。这个结构体不仅仅是文件描述符的容器,它还内嵌了 listenConfig 结构体。listenConfig 包含了 control 钩子函数、KeepAlive 探测周期等配置,它的意义在于将操作系统底层的网络参数设置向用户层敞开,允许开发者在 Go 语言层面通过配置字段来对底层的 socket 行为做进一步的限制和明确。
最终,通过结构体定义和对 tcplistener 的方法封装,Go 将底层的 listen 系统调用和上层的配置结构体紧密“偶联”在一起。这种关系在我们调用 net.Listen 时,通过传入的 network 和 address 字段就已经决定好了。
值得注意的是,listen 这个接口主要用于处理流式和面向连接的协议(如 tcp, ssl 等);与其平行的功能接口还有 PacketConn,用于处理数据包协议(如 udp, dns 等)。这种设计完美体现了 Go 语言面向接口编程的思想,将不同协议的实现细节隐藏在统一的接口之下,实现了代码的模块化与组件化。
现在我们理解了 Listener 的构建过程。我们在业务代码中调用的 Accept 方法,本质上是通过接口与 func (ln *TCPListener) accept() 关联起来的。接下来我们将从 accept 的上层入口出发,自上而下地剖析其底层实现原理。
-
首先,代码逻辑会来到
internal/poll包下的 accept 函数。这个函数是网络轮询器(Netpoller)与 socket 交互的关键枢纽,主要完成了以下四个核心功能:- 调用系统调用:封装并执行底层的
accept系统调用。 - 对象包装:将返回的新文件描述符包装成
netFD(Network File Descriptor) 对象。 - 注册轮询:将新的
netFD注册到 epoll(或对应平台的 IO 多路复用器)中,以便监听后续的读写事件。 - 完善信息:填充对端(Remote)和本地(Local)的地址信息。
- 调用系统调用:封装并执行底层的
-
我们重点关注
internal/poll/fd_unix.go中的核心逻辑 accept:func (fd *FD) Accept() (int, syscall.Sockaddr, string, error) { // ... 准备工作与加锁 ... // 循环尝试 Accept for { // 执行底层系统调用 accept s, rsa, errcall, err := accept(fd.Sysfd) // 1. 如果成功,直接返回 if err == nil { return s, rsa, "", err } // 2. 处理系统调用返回的错误 switch err { case syscall.EINTR: // 信号中断,重试 continue case syscall.EAGAIN: // 核心逻辑:EAGAIN 表示当前 socket 接收缓冲区为空(无新连接) // 如果 fd 是可轮询的 (pollable),则挂起当前 Goroutine 等待读事件 if fd.pd.pollable() { if err = fd.pd.waitRead(fd.isFile); err == nil { continue // 被唤醒后,继续循环尝试 accept } } case syscall.ECONNABORTED: // 这种错误通常意味着连接在建立过程中被对端复位,忽略并重试 continue } return -1, nil, errcall, err } }
-
这里的非阻塞 I/O 思路与我们在 C 语言中 netpoll 实现一节的思路完全一致:当
accept返回EAGAIN时,不让线程睡眠,而是让出 CPU。 -
关键点:Go 的强大之处在于,当遇到
EAGAIN时,它调用fd.pd.waitRead将当前 Goroutine 挂起,而不是阻塞操作系统线程。
-
在系统调用
accept成功返回拿到文件描述符后,Go 并没有直接使用这个裸露的 int 类型句柄,而是通过 newFD 函数将其包装成了一个功能丰富的对象。 -
这个函数不仅仅是简单的内存分配,它确定了 Go 运行时如何看待这个网络连接:
-
poll.FD:这是核心中的核心,它充当了 Go 语言 IO 层(用户代码)和 Runtime 层(调度器)之间的桥梁。
-
Sysfd:保存了底层的 socket 句柄,一切操作最终都落实在这个整数上。
-
IsStream:标记是否为流式套接字。
-
如果是 TCP,此值为
true。 -
如果是 UDP,此值为
false。 -
ZeroReadIsEOF:这是结束判定的关键规则。
-
正如我们在 udp 章节中提到的,UDP 允许发送 0 字节的数据包,这在 UDP 中不代表连接结束。
-
-
而在 TCP 中,
read返回 0 字节通常意味着对端发送了 FIN 包(EOF),连接需要关闭。 -
newFD会根据IsStream的值自动设置这个字段,确保上层业务逻辑能正确处理“读到 0 字节”的含义。 -
并发安全:
newFD初始化的对象内部维护了读写锁(fdMutex),这是 Go 能够让多个 Goroutine 安全地对同一个 socket 进行并发操作(尽管通常不建议这样做)的底层保障。
-
包装完成后,紧接着就是“激活”这个连接,这一步由 init 完成。
-
这个函数的逻辑与我们之前在 listen 中看到的思路完全一致,可谓是殊途同归:
-
Listen 阶段将监听 socket (
listener) 注册到 epoll 中,为了监听“新连接到来”的事件。 -
Accept 阶段将新建立的连接 socket (
conn) 注册到 epoll 中,为了监听“数据可读/可写”的事件。 -
底层最终都调用了
poll.runtime_pollOpen,将Sysfd添加到 epoll 实例的红黑树中。一旦这一步完成,这个连接就正式进入了 Go 的网络轮询器(Netpoller)的管理范围,为后续的异步 I/O 奠定了基础。
-
进一步深入
waitRead函数,我们最终通过//go:linkname链接机制,从internal/poll包跨越到了runtime包的 poll_runtime_pollWait。 -
注意,此时我们的业务结构体已经从
poll.FD转化为了 runtime 内部的polldesc(poll descriptor),这种转化依赖于结构体的映射关系。func poll_runtime_pollWait(pd *pollDesc, mode int) int { // 1. 检查连接是否已经出错或关闭 errcode := netpollcheckerr(pd, int32(mode)) if errcode != pollNoError { return errcode } // 2. 循环等待,直到 netpollblock 返回 true (表示 IO 就绪) for !netpollblock(pd, int32(mode), false) { // 被唤醒后再次检查错误 errcode = netpollcheckerr(pd, int32(mode)) if errcode != pollNoError { return errcode } // 如果是因为超时被唤醒,但还没来得及运行超时就被重置了, // 则假装没发生,继续重试。 } return pollNoError }
-
在真正挂起之前,runtime 先通过 netpollcheckerr 检查是否有超时或关闭错误。
-
这里的
for循环逻辑是为了处理一些边缘情况(例如超时触发后又被重置),防止并发修改导致状态不一致,确保每一次挂起都是有效的。
-
接下来我们深入 netpollbloack,这是 Goroutine 挂起的决策中心。
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool { // 根据 mode 选择是操作读通道(rg) 还是 写通道(wg) gpp := &pd.rg if mode == 'w' { gpp = &pd.wg } for { // CAS 操作:原子性地判断状态 // Case 1: 如果状态已经是 pdReady (IO 就绪),则将状态重置为 pdNil 并返回 true // 表示不需要等待,直接去读/写数据 if gpp.CompareAndSwap(pdReady, pdNil) { return true } // Case 2: 如果状态是 pdNil (初始状态),则将其设置为 pdWait (等待中) // 只有设置成功,才能跳出循环去执行 gopark 挂起 if gpp.CompareAndSwap(pdNil, pdWait) { break } // Case 3: 如果既不是 Ready 也不是 Nil,说明出现了并发等待,抛出异常 if v := gpp.Load(); v != pdReady && v != pdNil { throw("runtime: double wait") } } // 执行挂起操作 // 传入 netpollblockcommit 作为回调,它会在 g0 栈上执行 if waitio || netpollcheckerr(pd, mode) == pollNoError { gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceBlockNet, 5) } // 被唤醒后的逻辑 old := gpp.Swap(pdNil) if old > pdWait { throw("runtime: corrupted polldesc") } return old == pdReady }
-
状态机管理:
pd.rg和wg是原子操作的 uintptr,它们在前面 polldesc 一节中提到,默认值为pdNil(0)。 -
CAS (CompareAndSwap):这里利用 CAS 实现无锁状态流转。将“值检测”和“值交换”融为一体,确保了多线程下的安全性。
-
挂起与唤醒:
gopark是分水岭。执行gopark前,当前 Goroutine 正在运行;gopark返回后,说明 Goroutine 已经被 epoll 事件唤醒,此时检查返回值是否为pdReady,确认是由数据包唤醒而非超时。
-
最后,我们解析最底层的调度器接口 gopark。这是 Goroutine 让出 CPU 控制权的关键。
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) { // ... mp := acquirem() // 1. 禁止当前 M 被抢占 gp := mp.curg // ... 状态检查 ... // 2. 保存当前 Goroutine 的现场 (SP, PC 等) // 3. 切换到 g0 栈执行 park_m 函数 mcall(fastrand) // 注意:mcall 会调用 park_m,代码逻辑跳转到 park_m // ... }
-
acquirem/release:
acquirem本质是增加当前 M (系统线程) 的锁计数,防止在保存现场这种敏感操作期间,M 被调度器抢占或被 GC 扫描干扰。 -
mcall 与 g0:Goroutine 的栈是动态伸缩的,而挂起操作涉及到栈的切换。为了安全,必须切换到 g0 栈(系统栈,大小固定且较大)来执行调度逻辑。
-
回调函数:这里的
unlockf就是我们在netpollblock中传入的 netpollblockcommit。 -
在 g0 栈中,系统会执行 park_m 函数:
func park_m(gp *g) { // ... // 1. 修改 Goroutine 状态:从 _Grunning 变为 _Gwaiting casgstatus(gp, _Grunning, _Gwaiting) // 2. 解除 M 和 G 的绑定 dropg() // 3. 执行回调函数 netpollblockcommit if fn := mp.waitunlockf; fn != nil { ok := fn(gp, mp.waitlock) // 在这里,gp 的地址被写入了 pollDesc 中 // ... } // 4. M 寻找下一个可运行的 Goroutine schedule() }
-
casgstatus:原子地将协程状态从运行中(Running)标记为等待中(Waiting)。
-
dropg:彻底解绑
mp.curg = nil和gp.m = nil。此时 G 已经“睡”在堆上了,而 M 获得了自由。 -
关键回调:执行 netpollblockcommit,通过
atomic.Store(gpp, gp)将当前 Goroutine 的内存地址填入pd.rg中。这一步至关重要,它相当于告诉 netpoller:“当这个 socket 有数据来时,请唤醒地址为gp的这个协程”。 -
schedule:M 并没有休息,它立即执行
schedule()去全局队列或本地队列寻找下一个待执行的 G。这就是 Go 高并发的核心秘密——IO 阻塞的是 Goroutine,而不是系统线程。
至此,我们完整解构了 Accept 从用户层 API 到 Runtime 调度器的全过程。这一复杂的调用链完美诠释了 Go 语言同步的代码逻辑,异步的底层实现的核心设计哲学:
- 表象与本质的统一:
- 对开发者:
Accept表现为标准的阻塞式 I/O。代码线性执行,逻辑清晰,符合人类直觉,不需要像 C 语言 epoll 那样编写复杂的回调或状态机。 - 对操作系统:
Accept实际上是非阻塞 I/O。底层通过EAGAIN错误码和epoll机制,确保了系统线程永远不会因为等待网络数据而阻塞。
- M 与 G 的接力:
整个流程的关键点在于
gopark和schedule的配合。当 I/O 未就绪时:
- Goroutine (G) 选择“让出”:它保存现场,进入
_Gwaiting状态,乖乖在堆上等待数据到来。 - 系统线程 (M) 选择“复用”:它通过切换到
g0栈,迅速摆脱了当前 G 的纠缠,立即执行schedule()寻找下一个需要 CPU 的 G。
- 高性能的秘密: 这就解释了为什么 Go 服务端可以用少量的系统线程支撑数万计的并发连接——因为所有的“等待”成本都由极度廉价的 Goroutine 承担了,而昂贵的系统线程(M)始终处于高负载的有效计算状态,从未真正休息。 这正是 Go 网络模型区别于传统多线程模型的最大护城河。
在上两节中,我们阐释了 listen 和 accept 的底层机制。在开始对 read 和 write 的底层剖析之前,我们先来关注一个极其重要的细节:go handleConnection(conn)。
当我们在代码中使用 go 关键字时,它在编译阶段会被自动翻译为对 newproc 的调用。此时,对应目标函数的指针和父协程的 PC(程序计数器,作为出生地溯源)会被保存。随后,程序调用 systemstack 函数切换到 g0 系统栈上,并调用 newproc1 来创建新协程的运行栈。 在这个函数中,有一个极其精妙的“伪造栈”设计:新栈的返回地址被硬编码为 goexit 函数的地址,以便协程执行完毕后能自动回收资源;同时,目标执行函数的地址也被压入栈中。
因此,在这里去执行 handleConnection 的是一个全新诞生并被唤醒的协程;而原本的父协程则会继续它的使命,在死循环中不断调用 accept,直到下一轮代码跑通并返回对应值。注意,这时候新连接的文件描述符(FD)已经被安全地包装在 accept 的返回值中了。
回到 accept 函数,之前没有特别提到的是,这个函数的具体返回值类型是 TCPConn。但在这里,我们所要研究的重点并不是独属于 TCP 协议的特有机制,而是基于所有网络连接的通用底层机制,即实现了抽象接口 Conn 的 write 和 read 方法。此时,这个返回值已经完美封装了该连接所需的全部底层信息。
-
首先,我们来到上层
netFD包装的方法 read。这样一个方法除了继续向下层委派调用外,还调用了一个至关重要的生命周期防线runtime.KeepAlive(fd)。它的作用是作为一道“免死金牌”,防止垃圾回收器(GC)进行错误回收——严格防止在协程因为无数据而被挂起等待的时候,对应的底层netFD被 GC 当作孤儿对象自动触发close方法关闭。 -
继续向下,来到
internal/poll.FD的方法 read。这个方法是网络读取功能的最终底层实现,其核心轮询思路和我们在 C 语言中提到的 recvfrom 操作高度一致。 代码骨架如下:业务加锁 ... 0字节返回 ... 准备检查 ... for { n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p) if err != nil { n = 0 if err == syscall.EAGAIN && fd.pd.pollable() { if err = fd.pd.waitRead(fd.isFile); err == nil { continue } } } err = fd.eofError(n, err) return n, err }
-
补充说明:需要特别注意的是这里的“业务加锁”(
readLock)。之前提到的KeepAlive是为了防止 GC 偷偷回收关闭连接;而这里的加锁,则是为了防止另一个业务协程(例如用户在其他地方手动调用了conn.Close())错误地关闭当前文件描述符,导致并发读写串号。 -
在这里,Go 同样设置了通用的系统调用包装接口
ignoringEINTRIO来屏蔽底层操作系统发出的EINTR中断信号。这一点和我们在 C 语言中实现的 while 循环容错思路一致。如果没有发生阻塞,那么read就会直接通过系统调用读出数据并返回;如果内核缓冲区为空导致阻塞(触发EAGAIN),那么就会调用waitRead去进行挂起休眠。 -
我们最终看到的是,无论是读取数据的
read方法,还是等待新连接的accept方法,当它们遇到阻塞时,都殊途同归地汇合到了 runtime 层的 poll_runtime_pollWait,并且两者的等待模式(mode)完全一致(均视为等待可读事件'r')。
-
在
poll.FD层面,write方法和read方法的错误封装和抛出逻辑基本一致,不同点在于底层系统调用本身,以及对应的分块传输机理不同。 -
下面我们来看到 FD 层级的具体方法实现 write:
加锁 ... 写入准备 for{ 保证数据成功写入 如果数据过大切分 }
-
这里的加锁原因和
read中完全一致。在这里,[]byte切片从用户业务层一路实现零拷贝传到这里。为了保护操作系统的内核态,Go 内部维护了一个单次系统调用的最大字节限额(maxRW),如果传入数据超出了这个限额,则进行切分。同时,内部通过维护变量nn进行已写进度记录。在这里,Go 巧妙地通过切片偏移(p[nn:max])的机制进行了数据切分;同时通过ignoringEINTRIO防止系统信号抖动,并且严格依赖这个for循环来保障所有数据最终被一滴不剩地成功发出。如果此时操作系统的发送缓冲区已经满了(返回EAGAIN),则当前写协程会被暂时挂起。 -
现在我们再来对比看到
write协程挂起的逻辑。相较于accept和read挂起的差别,就在于底层传入的等待模式mode变成了写模式('w')。对应的后续唯一的差别就是:在底层 netpollblock 执行时,会根据这个'w'模式,将对应正在休眠的协程地址压入pollDesc结构的写协程字段(wg)中,而不是读协程字段(rg)。
在这一节中,我们一步一步自上而下地对示例代码的原理进行了深挖,并由此深刻理解了 Go 网络传输的模型搭建及其实现高性能的根本原因。同时,我们也对照了与之前 C 代码逻辑相一致的部分,完成了从传统的 C 语言底层调用到现代 Web 网络架构搭建的认知跨越。我们通篇在探讨网络,剖析 Go 的通信底层实现,不过在最终对这部分网络架构进行全局梳理之前,让我们先切换视角,审视一下计算机语言发展的宏观背景。
-
在我糟糕的大学课本上有一句话叫“C生万物”。这句话基于这样一个幻想:整个计算机世界的底层是固定不变的,C、汇编等语言作为绝对的基石,上层语言百花齐放、各有千秋,但它们最终都要回归到最底层的 C 逻辑。但是,通过这次自上而下对 Go 语言底层机理的研究,以及结合 YouTube [Core Dumped 的科普视频](https://www.youtube.com/watch?v=7ge7u5VUSbE [00:46])中的知识,我们知道,这些所谓的底层语言的“固定”,不过只是计算机发展历史惯性影响下的产物(正如 IPv6 尽管已经推行多年,但先行的大多数网站依旧采用 IPv4 一样)。
-
C 语言不是一种低级语言 这篇文章深刻地向我们揭示了 C 语言与现代处理器架构之间的深层矛盾,以及现代处理器为了适应 C 语言的遗留机器模型而被迫做出的一系列硬件让步。对于现代 CPU 来说,多线程、矢量运算(SIMD)和多级缓存结构已经成为最基本的设计基石,而 C 语言依旧死守着扁平的内存模型、单线程的执行视角和传统的编程思想。这不仅无法充分发挥现代硬件的性能,反而还衍生出了大量难以排查的内存安全与并发问题。
-
不同的现代编程语言都在尝试以各自的哲学去解决这一核心矛盾。Rust 通过引入严格的所有权(Ownership)机制来解决内存管理问题,通过生命周期检查器(Borrow Checker)来消灭悬空指针,并直接在语言层面上取消了 Null。这些举措本质上是通过更严格的编译期语言规范来彻底解决安全问题;尽管 Rust 在提升性能方面(例如通过设计规避对传统链表的滥用以迎合 CPU 缓存)也有所建树,但其出发点和设计思路决定了这门语言更多是对 C 语言在安全层面的极致“修补”,而非从底层并发思想上的完全替换。
-
而 Go 语言则走向了另一个截然不同的方向。Go 通过引入强大的 Runtime(运行时)机制,接管了内存维护工作,使得开发者无需再手动处理繁琐的内存分配与释放;更核心的是,它通过轻量级的 Goroutine(协程)结合调度器,极其优雅地实现了极高并发与高性能。当然,这种 Runtime 机制并非没有代价:其微秒级别的 GC 停顿(STW)和调度时间损失,让 Go 语言在应对极其严苛的底层低延迟需求时显得力不从心;而高度自动化的内存管理,在带来开发便利的同时,也剥夺了开发者对内存布局进行精准控制的能力。
-
与此同时,Zig 语言则试图寻找另一种平衡:它通过显式分配器(Explicit Allocator)将内存管理权交还给开发者,利用强大的
comptime机制来取代传统的 C 语言宏(Macro),并通过无色函数(Colorless Functions)巧妙处理协程调用,试图从系统编程的根本上取代 C 语言的生态位。 -
但是,我们仍然不可否认,C 语言仍旧是如今占据计算机科学市场教育主流的核心语言,它依然是我们理解计算机底层世界运行规律最好的入口之一。比起根本无法实现的“完全放弃”,或者对这门语言产生过度的“宗教级迷信”,通过 C 语言来透视计算机的基础面貌,并在这个探索的过程中,去深刻理解当代计算机架构的发展脉络与面临的性能困境,才是更为明智与科学的学习路径。
-
我们这里的总结讨论,建立在之前用 C 语言实现 epoll 原理理解的基础之上。
-
宏观来看,Go 的网络体系可以清晰地划分为底层的
runtime层和封装层的internal层:前者与 Go 的垃圾回收器(GC)和调度器深度绑定,直接处理汇编级别的系统调用和多协程并发状态;后者则是提供给上层(如net包)用户使用的通用 I/O 接口封装。 -
在服务启动阶段,
listen函数首先会创建并显式配置对应的网络连接接口(Listener),该接口内部封装了针对不同协议的具体方法实现。这一创建过程在底层隐式地初始化了netpoll网络轮询器对象以及对应的底层的 socket 文件描述符,最终将该 socket 注册(写入)到全局的netpoll监听红黑树当中。同时,它会在runtime层去执行诸如bind等核心系统调用。 -
而
accept函数在底层逻辑上,完全可以被视作是对新连接到来这一特殊事件的独特read操作。在这里,我们看到了之前所剖析的所有 I/O 函数的一个命运交汇点:init。对于listen而言,这个init函数就是将其注册进多路复用器的业务逻辑终点;而对于accept、read和write函数而言,这个init只是上层封装函数接入异步 I/O 体系的先决调用。Go 巧妙地将底层环境的初始化与 socket 写入轮询器的操作封装在一起(利用双重检查锁),从机制上保证了netpoll在未完全初始化之前,绝对不会有非法的读写事件错误写入。在net包的源码层面,上层的net.netFD和内部的poll.FD的严格功能边界区分并非我们死磕的焦点;我们只需要深刻明白:后者(poll.FD)是执行实际网络调用的通用底层业务对象,直接向下对接runtime层的状态机;而前者(net.netFD)则是提供给更上层用户的独立网络协议封装以及业务级错误检查的抽象外壳。 -
当通过
go关键字处理新连接时,调度器会直接创建一个全新的 Goroutine,并将需要执行的业务函数压入该协程的运行栈中。此时,我们让这个新诞生的协程去专注处理对应网络连接的收发逻辑,而原本的旧协程(父协程)则会在一个无限循环中继续阻塞调用accept,以探测下一个网络新连接的到来。 -
随着代码执行深度的下沉,控制流进入
runtime层。此时,业务层级的结构体蜕变(映射)为底层的pollDesc结构。这两个跨越层级的结构体之间,正是通过在internal侧储存runtime侧底层的地址,以及利用特殊技术嵌入的内存版本号,实现了安全且高效的间接偶联。 -
追踪到网络阻塞的最深处,
accept、read和write的执行路径在此汇合到了同一条单行道:poll_runtime_pollWait。该函数负责执行前置的错误检查,并启动一个关键的for循环机制以确保重试的可靠性。在这里,这三个操作的唯一底层区别显现了出来:它们会根据调用方传入的mode参数的不同,精准选择是将当前协程的地址存入读等待字段rg(Read Goroutine,用于 accept 和 read),还是存入写等待字段wg(Write Goroutine,用于 write)。 -
随后,netpollblock 函数出场,它利用 CAS(Compare-And-Swap)原子指令,无锁且安全地交换并确认对应
pollDesc的内部状态。一切就绪后,终极调用 gopark 发生。该函数会极其关键地将执行流切换至g0系统栈,安全地保存当前用户协程的现场,彻底解除该协程(G)与底层操作系统线程(M)的绑定关系,最终将该协程挂起放入堆内存中沉睡,从而让出线程资源去调度其他的任务。
在这里我们看到了 Go 设计的核心哲学:在最底层,它坚定地设计和抽象出统一的通用系统调用接口;而在最上层,它又针对具体的协议与功能,实现了独立、优雅的面向对象封装。不同架构层级的结构体,分别严格贯彻落实着不同层级的业务功能。它们彼此之间通过指针传递或版本号校验,直接或间接地紧密偶联在一起。
最伟大的创举在于,Go 并没有把网络轮询作为一个外挂的扩展库,而是将网络连接的异步多路复用机制直接内化为了底层 Runtime 架构的核心驱动力,并将其与 GC 垃圾回收机制深度绑定,作为维持 netpoll 运转的强大动力。正是这种跨越语言层级边界的紧密协同,最终造就了 Go 语言在现代 Web 开发与高并发应用领域无可撼动的巨大优势。
-
从“流”到“帧”的认知降维: 在 Part 1 的 C 语言 TCP 通信和 Part 2 的 Go 语言
netpoll解析中,我们调用send()或conn.Write()时,面对的始终是一个平滑、连续的数据流(Stream)。我们之所以能把网络通信当成读写本地文件一样简单,完全得益于操作系统内核与 Go Runtime 在底层的重重封装。 但真实的物理网络中不存在“流”。在网线中穿梭的,是一个个被严格切割、包裹的离散数据帧(Frame)。[5. 应用层] (Application) --- 消息 (Message) [HTTP, FTP] | [4. 传输层] (Transport) --- 数据段 (Segment) [TCP, UDP] <-- Part 1 & 2 的主要操作边界 | [3. 网络层] (Network) --- 数据包 (Packet) [IP, ICMP] | [2. 链路层] (Data Link) --- 帧 (Frame) [Ethernet, MAC] <-- 本节 Raw Socket 的接管边界 | [1. 物理层] (Physical) --- 比特 (Bit) [网线, 光纤] -
为什么需要 Raw Socket? 当我们使用常规的
AF_INET创建套接字时,操作系统全权代劳了 ARP 解析、路由查找、以及 MAC/IP/TCP 包头的拼接。为了亲眼观察甚至手工拼接这些底层的二进制字节,常规的 Socket API 已经无法满足需求。因此,在 01_rawsocket.c中,我们转而使用极具破坏力与掌控力的原始套接字(Raw Socket):int raw_sock = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
-
**参数 1: 从
AF_INET到AF_PACKET**:这是一个底层深入。AF_INET局限于网络层及以上,而AF_PACKET直接作用于数据链路层。OS 不再为你剥离以太网帧头,而是将网卡收到的原始电信号转化为字节流后,原封不动地甩给你。 -
**参数 2:
SOCK_RAW**:明确声明我们需要绕过协议栈的原始报文数据。 -
**参数 3:
htons(ETH_P_ALL)**:在之前的章节中,我们将此参数设为0,意为“让 OS 自动推导协议”。但在链路层,网卡只认识二进制比特的,没有默认协议可言。ETH_P_ALL是一道终极指令,不加过滤的处理所有协议的网络帧 (注意:网络中传输的是大端序,必须套上htons转换,否则网卡硬件匹配时会因字节序错乱而拦截失败。同时还是因为这里是多个字节进行比对。如果是单个字节则不需要进行这种转化,在之后的示例中你会看到这一点) -
混杂模式(Promiscuous Mode) 拿到原始套接字权限后,我们还面临一个物理障碍:网卡硬件默认是极度自私的,它会自动丢弃目的 MAC 地址不是自己的单播包。为了能截获局域网内的广播、多播甚至其他设备的包(拓展我们的数据样本),我们必须剥夺网卡的过滤机制,强迫其进入混杂模式。 在 02_promiscuous.c 中,我们展现了如何通过经典的“读-改-写”机制调用底层硬件:
extern int ioctl (int __fd, unsigned long int __request, ...) __THROW;
-
__fd:Linux 贯彻“一切皆文件”的哲学。你不能直接对字符串"eth0"发号施令,你必须传入一个合法的文件描述符。当我们把刚才创建的raw_sock传进去时,内核一看是网络句柄,就会立刻将这个ioctl请求转交给网络设备子系统(Network Device Subsystem)。*(实际上,传入任何一个普通的 TCP/UDP socket 都能达到相同的路由效果,这里用 raw_sock 纯粹是就地取材。)*更进一步,iotcl的作用并非局限于网络子系统,而是linux开发者设计的)一个绕过常规数据流、直接向硬件设备驱动下发专属命令(Magic Codes)的后门通道,通过传入其他类型的文件描述符我们也能操作其他子系统 -
__request:SIOCGIFFLAGS(Get) 用于将网卡当前状态读取出来;SIOCSIFFLAGS(Set) 用于将我们修改后的状态硬性写入硬件。 -
**极致的内存复用:
struct ifreq** -
ioctl的最后一个参数是可变参数。在网络子系统中,我们使用ifreq结构体作为与内核通信的“标准公文包”://这里是简化版本 struct ifreq { char ifr_name[IFNAMSIZ]; /* Interface name, e.g. "eth0" */ union { struct sockaddr ifru_addr; // 用来装 IP 地址 (16字节) struct sockaddr ifru_hwaddr; // 用来装 MAC 地址 (16字节) short ifru_flags; // 用来装 状态标志位 (2字节) int ifru_mtu; // 用来装 MTU (4字节) } ifr_ifru; }; #define ifr_flags ifr_ifru.ifru_flags #define ifr_hwaddr ifr_ifru.ifru_hwaddr
-
这完美展现了内核开发者对内存的极致压榨:通过
union(联合体),让 IP、MAC、标志位等几十种截然不同的参数,强行共享同一块 16 字节的内存。内核如何区分这块内存里现在装的是什么?靠的就是你传给ioctl的命令宏。同时,底层的#define语法糖巧妙隐藏了内部复杂的联合体嵌套,为应用层提供了清爽近似结构体的调用体验。(这个文件是ifreq的全面实现ifreq) -
现在我们成功的开启了混杂模式,但是在实际运行过程中我们还面对一个问题:我们创建的
raw_socket默认绑定了这台机器的所有网卡,例如eth0、wlan0,以及 lo 回环网卡。但是这里的 lo 网卡仅用于计算机内部的通信(例如本地环回测试),并不会通过真实的物理网卡进行数据包发送。所以,在这里为了从抓取到的数据包中将那些不具备真实物理 MAC 地址(通常表现为全 0 的伪造 MAC 地址)的幽灵数据包丢弃,我们同样需要配合ioctl操作,将套接字死死绑定到存在具体物理映射的网卡(如 eth0)上。 -
bind文件展示了是如何通过
ioctl操作去精确绑定到 eth0 网卡上的。在 Linux 系统中,不只是文件,硬件也是依靠一个底层的句柄(Index)进行抽象的。因此我们先通过ioctl拿到eth0网卡映射的真实物理网卡 index,然后通过底层的 bind 操作进行硬绑定。需要特别注意的是,在这里我们使用数据链路层专用的sockaddr_ll(Link-Layer)结构体进行数据包装,这一点和我们在之前使用网络层的sockaddr_in对 IP 和端口号进行绑定的操作逻辑是完全一致的,只不过维度下沉到了物理链路层。(需要注意的是,由于bind函数签名的限制,我们需要将sockaddr_ll转换成sockaddr进行传入) -
至此,我们已经成功越过了操作系统协议栈的层层封装,并拿到了底层网卡的最高物理控制权。在接下来的小节中,我们将直面网卡抛上来的原始字节流,体验 C 语言最暴力的“指针强转”解析法。
在开始之前我们需要对网络数据包做一个更深入的解析。在之前的示例中,只需要简单的使用 sendto, recvfrom 就可以进行网络通信,这个时候我们直接发送和得到了自己想要的数据。
但是如果你有一定的 web 开发经验的话,你会知道数据包的传递不只有数据内容,还有数据头,比如 token 就是通过数据包头传递并进行校验的。尽管在这里我们所要讨论的不是在成熟协议栈基础上的网络应用,但是在上述操作的时候,操作系统和 web 开发人员的操作目的极为相似:让用户感受不到有所谓的数据头。而在拥有 raw_socket 之后,我们处理的数据包是一串包含数据头和数据内容的裸字符串(或者更准确地说,是原始字节流)。我们首先要去认识的就是,这些裸字符串是如何精细的组合在一起,并在网络五层结构体系流转的同时进行逐级封装的。
+-----------------------------------------------------------------------+
| 物理层 (Physical Layer) 网线中的电信号/光信号 |
+-----------------------------------------------------------------------+
| (网卡接收并转化为二进制字节流)
V
+=======================================================================+
| L2 链路层头 | L3 网络层头 | L4 传输层头 | L5 应用层数据 (Payload) |
| (Ethernet) | (IP) | (TCP/UDP) | (HTTP, FTP, 或你的一段纯文本) |
+=======================================================================+
|<- 14 Bytes->|<-20 Bytes->|<-20 Bytes->|<- 剩下的全都是应用层数据区 ------->|
| | | |
|---(我们当前所在的位置) | | |
我们在这里采用了最容易理解的方式来表示数据头的构成。在我们第一部分的例子中,应用层数据进行发送的时候,会向下逐渐进行封装。下面这张图展示了应用层数据是如何一步步被包裹成一个以太网物理帧(Ethernet Frame)的:
================================================================================
数据包的“俄罗斯套娃”封装过程 (The Encapsulation Process)
================================================================================
1. 应用层 (Application Layer)
产生纯粹的业务数据。
+-------------------------------------------------------------------------+
| Application Data (HTTP / FTP / 自定义报文) |
+-------------------------------------------------------------------------+
|
V (向下传递)
2. 传输层 (Transport Layer - L4)
加上源/目的端口号,确定要交给哪个进程。
+-------------------+-----------------------------------------------------+
| TCP/UDP Header | Application Data |
+-------------------+-----------------------------------------------------+
|
V (向下传递)
3. 网络层 (Network Layer - L3)
加上源/目的 IP 地址,确定要交给世界上的哪台主机。
+-------------------+-------------------+---------------------------------+
| IP Header | TCP/UDP Header | Application Data |
+-------------------+-------------------+---------------------------------+
|
V (向下传递)
4. 链路层 (Data Link Layer - L2)
加上源/目的 MAC 地址,确定要交给局域网内的哪块网卡。
+-------------------+-------------------+-------------------+-------------+
| Ethernet Header | IP Header | TCP/UDP Header | Payload ... |
+-------------------+-------------------+-------------------+-------------+
现在我们理解了,网络五层模型并非完全学术化的抽象,而是在网络发送过程中的实际运行抽象。每一层都对应实现相关功能的协议,给上层提供设计基础(也是上层的设计基础),最终最顶层构成计算机网络面向实际功能的接口,这些接口互相联系共同构建了计算机网络的大体系。
在本节中,我们主要是一步一步对获取到的完全体数据包,通过指针强转和解封装的方式进行解析。
-
Ethernet 是数据链路层的头部,是以太网中所有数据流转都带且位于最开头部分有的报头。首先来看这个包的构成:
+---------------------------------------------------------------+ | Ethernet II Header (以太网帧头) - 14 Bytes | +-----------------------+-----------------------+---------------+ | Destination MAC | Source MAC | EtherType | | (6 Bytes) | (6 Bytes) | (2 Bytes) | +-----------------------+-----------------------+---------------+ -
当数据在物理网线中以光电信号传输时,底层的交换机和网卡根本不懂什么是 IP 地址,它们只认识物理硬件的唯一烧录标识——MAC 地址(媒体访问控制地址)。IP 地址负责跨越全球网络进行“逻辑寻址”,而 MAC 地址则负责在同一个局域网(子网)内,将数据精准地从一个物理端口点对点“搬运”到另一个物理端口。以太网帧头作为网络包的最外层防御和“快递盒”,理所应当贴着物理网卡的硬件地址。
-
MAC地址的前后设计是一个极具硬件底层思维的绝妙设计。在计算机网络设计的早期,网卡和交换机的硬件缓存(Buffer)极其昂贵且有限。当一个数据帧转化为电信号,像流水一样按顺序涌入网卡时,网卡需要在最短的时间内决定是接收还是丢弃这个包。将目标 MAC 地址放在最开头的 0~5 字节,网卡只需要读取这最先到达的 6 个字节,就能立刻与自己的硬件 MAC 地址进行比对。如果发现不是发给自己的(且不是广播包),网卡就可以当机立断,直接在硬件层面丢弃后续涌入的所有信号,极大地节省了宝贵的内存缓存和 CPU 中断资源。
-
在解析包头时为了方便学习理解,我们将关键部分打印出来。而在进行数据包监听时,我们创建一个 65536 大小的储存数组,这个大小能容纳所有的 IP 包,具体大小原因我们将会在 IP 报头的一节中详细说明。
-
在处理这个报头的时候,
<linux/if_ether.h>头文件给我们提供了对应结构体来简便操作。#if __UAPI_DEF_ETHHDR struct ethhdr { unsigned char h_dest[ETH_ALEN]; /* destination eth addr */ unsigned char h_source[ETH_ALEN]; /* source ether addr */ __be16 h_proto; /* packet type ID field */ } __attribute__((packed)); #endif
-
事实上,使用这个结构体和我们通过数组下标取出对应位置的数据效果是一样的。采用这个报头仅仅只是为了方便,在实际连续空间内存中通过这种方式对对应大小的物理片段取了名字,两者在本质上并没有差别。这也是 C 语言系统编程的魅力所在——我们没有分配新内存去复制数据,只是给这块原始字节流盖上了一个 14 字节的透明“模具”。
-
在实际代码示例中,可以通过以下方式去对 MAC 地址进行打印:
printf("Dest Mac : %02x %02x %02x %02x %02x %02x\n", eth->h_dest[0], eth->h_dest[1], eth->h_dest[2],eth->h_dest[3], eth->h_dest[4], eth->h_dest[5]);
-
在这里由于我们是单字节进行操作的,因此并不需要在意字节序的问题。但是,如果你想要多字节的打印或者处理 EtherType 信息(即
h_proto字段),都需要进行字节序转化。因为网络线缆中传输的默认是大端序(网络字节序),而我们的主机通常是小端序,必须使用ntohs(eth->h_proto)进行翻译,才能将其转换为本机语序并正确判断协议类型。 -
具体的代码示例在 parse_Ethernet 中可以看到。
-
在本节中,我们成功解析了 Ethernet 头的信息,了解了它充满硬件智慧的字段排布,并利用结构体指针将对应的 MAC 地址和底层协议类型精准地拆解了出来。这宣告着在这条长长的内存字节带上,前 14 个字节已经被我们彻底接管。接下来,我们要做的就是让指针在内存空间中向前推移 14 个物理刻度(
buffer + sizeof(struct ethhdr)),正式越过数据链路层的边界,踏入结构更加复杂、掌管着全球路由寻址的 L3 网络层(IP Header)
- IP(网际协议)是当今世界互联网的基石,承载了现代互联网中的主要数据传递。IP 地址的引入,让数据包的流转从局域网(LAN)层面正式跨越到了全球互联网(WAN)的广阔天地。将 IP 地址简单理解为 MAC 地址在网络上的映射是略有偏颇的:MAC 地址是底层物理硬件(准确地说是网卡)的全球唯一固化标识;而 IP 地址本质上是一个逻辑寻址点位,通常由路由器在局域网中动态或静态分配。路由器通过划分具体的**IP网段(子网)**来决定数据的路由走向。在这个局域网中,数据包向外通信必须先经过路由器(网关),再由其接力转发给目标局域网中的主机。
- 关于 IP 网段(Subnet)的核心功能总结:
- 划分广播域与路由边界: 通过子网掩码(Subnet Mask),IP 地址被逻辑划分为“网络号”和“主机号”。路由器依靠网络号来判断目标是否在同一局域网,如果不在,则交由网关转发,这有效隔离了局域网内的广播风暴。
- 特定网段的特殊用途(防环与内网): 我们极力避免(或在某些场景下刻意利用)的回环数据包传递,正是基于特定的 IP 网段实现的。例如
127.0.0.0/8是专门保留的本地回环(Loopback)网段,发往该网段的数据包根本不会触及物理网卡,而是直接在系统内核的网络栈中“折返”,专用于本机进程间通信;此外像192.168.x.x等私有网段专用于局域网内部,结合 NAT 技术,不直接参与公网路由。
-
在数据链路层,以太网(Ethernet)帧结构是绝大多数现代计算机网络通信必有的底层载体。而当数据解包进入网络层(Network Layer)后,根据不同的应用场景和需求,协议头开始出现丰富的分化。我们在这里介绍的这 5 种网络层协议族,共同构筑了计算机网络世界的基本骨架:
- IP (Internet Protocol): 底层网络的基础,也是最核心、最主要的通信方式。它负责将数据包从源路由到目标,提供无连接、尽力而为(Best-effort)的数据报交付服务。
- ICMP (Internet Control Message Protocol): 负责 IP 协议通信过程中的诊断、错误报告和网络状态探测。它直接封装在 IP 数据包的 payload 中,不需要更上层的传输层协议支持,而是通过 IP 头部的“协议号”字段(值为 1)进行标识。我们日常排查网络用的
ping命令(Echo Request/Reply),就是基于此协议实现的。 - ARP (Address Resolution Protocol): 严格来说,ARP 是一个介于网络层和数据链路层之间(或称 2.5 层)的关键协议。它的唯一使命就是为了满足 IP 和 MAC 地址间的映射翻译:在已知目标 IP 的情况下,通过局域网广播查询其物理 MAC 地址,从而将逻辑地址和物理地址绑定在一起。
- IGMP (Internet Group Management Protocol): 组播管理协议。这是一种一对多的高效数据包分发机制,允许路由器知道局域网内有哪些主机加入了一个特定的“组播组”。它常用于 IPTV、视频直播或在线会议,极大地节省了网络带宽。
- IPsec (Internet Protocol Security): 在网络层直接对 IP 数据包进行加密和来源验证。它确保了数据在公共网络传输过程中的机密性和完整性,常用于企业级 VPN(虚拟专用网)的安全隧道搭建。
-
为了保证文档逻辑的连贯性和聚焦度,我们在本节中将重点解析 ARP 和 IP 这两个协议。前者负责将 IP 地址和 MAC 地址映射起来,打通了逻辑与物理的桥梁;后者则真正推开了广域网络通信的大门。由于随着层级的上升,协议包头变得越来越复杂,为了便于观看,后续我们展示数据包报头时都将采用 RFC 规范的 ASCII 图形格式,并且只去关注其中最核心的字段。
1. ARP (地址解析协议)
```text
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Hardware Type (硬件类型, 2位) | Protocol Type (协议类型, 2位) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| HW Size (1位) |Proto Size(1位)| Opcode (操作码, 2位) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sender MAC Address (发送端 MAC, 前 4 字节) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sender MAC (后 2 字节) | Sender IP Address (前 2 字节) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sender IP (后 2 字节) | Target MAC Address (前 2 字节) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Target MAC Address (接收端 MAC, 后 4 字节) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Target IP Address (接收端 IP, 4 字节) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
```
-
Hardware Type: 通常值为 1,代表 Ethernet (以太网) 头部。
-
Protocol Type: 作为 MAC 地址和 IP 地址的翻译官,这里指代需要映射的协议类型。例如
0x0800代表 IPv4 标识。 -
HW / Proto Size: 分别定义了物理硬件地址(MAC,通常 6 字节)和逻辑协议地址(IP,通常 4 字节)的长度。
-
Opcode: 代表当前的 ARP 报文操作类型。值为
1代表 ARP Request(广播请求),值为2代表对应 IP 地址的主机进行的 ARP Reply(单播回应)。 -
Sender / Target Addresses: 发送端和接收端的地址组合(MAC + IP)。
-
在 parse_ARP 中,我们展示了如何将 ARP 的核心参数拆解出来。我们在这里处理协议解析时,会完全基于底层的内存布局来进行。这一方面是为了向你直观展示底层网络封包的连续性,另一方面更是为了凸显 C 语言通过**指针偏移(Pointer Casting & Arithmetic)**进行数据锚定的强大操作。
// buf 是我们通过 Raw Socket 抓取到的完整以太网帧数据 // 将指针向后移动一个以太网头部的大小,精准定位到 ARP 协议头部的起始内存位置 struct ether_arp *arp = (struct ether_arp*)(buf + sizeof(struct ethhdr));
-
和上一节一样,并且在之后的代码中也将一直使用的是:我们通过定义好的包装结构体(如
struct ether_arp)来对特定的协议头部进行直接映射解析。从本节最开头的报文图形展示可以看到,网络协议的各层头部在实际的物理内存(或发送缓冲区)中是严格连续排列的。因此,我们只需要将指向数据包起始位置的字符数组指针(退化前的数组buf),向后偏移前一个网络层头部的大小即可。 -
这恰恰体现了采用**封装结构体转换(Struct Casting)**的最大好处:我们可以直接利用
sizeof(struct ethhdr)这样的语法来计算偏移量,而不必去硬编码去位移具体的字节数(在这个示例中以太网头是 14 字节)。这一点不仅大大降低了计算偏移时出错的概率,也极大地提高了底层网络 C 代码的可阅读性和可维护性。 -
关于后续对 ARP 头内部数据的提取,逻辑上其实非常直接(甚至可以说有些乏善可陈),核心就是将结构体中的封装数据取出来进行格式化打印。但在 C 语言的底层网络编程中,提取数据时有一个极易踩坑的关键点需要注意:网络字节序转换。为了解决这个问题,在提取ARP
opcode的时候使用网络字节序转化,而ipmac地址则逐字节的打印(和处理Ethernet II数据头的思路一致),正如你在parse_ARP中看到的那样。
2. IP (网络通信协议)
-
现在我们解决了 MAC 地址和 IP 地址间的映射关系(ARP 协议)。从这一刻起,计算机世界中孤立的局域网被连接了起来,两台跨越公网的主机终于能互相定位对方的位置并进行通信了。而承担这种跨网段通信最核心的协议,正是 IP 协议(Internet Protocol)。
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |Version| IHL |Type of Service| Total Length | | (4位) | (4位) | (8位) | (16位) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Identification |Flags| Fragment Offset | | (16位) |(3位)| (13位) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Time to Live | Protocol | Header Checksum | | (8位) | (8位) | (16位) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Source IP Address | | (32位) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Destination IP Address | | (32位) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Options | Padding | | (可选,通常不存在) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ -
IHL (Internet Header Length,网络报头长度):占据了第一个字节的后四位,用于标识整个 IP 头的长度。因为 IP 头存在 Options(可选)等字段,其实际长度并非固定(通常为 20 字节,最长可达 60 字节)。由于 4 位二进制最大只能表示 15,所以这里的计量单位是 4 字节(32-bit words)。也就是说,实际头部长度 =
IHL 的值 * 4。这个动态计算出的长度,也是我们后续跨过 IP 头、进行指针位移以寻找上层载荷起点的绝对依据。 -
Total Length (总长度):这个单元记载的是整个 IP 数据包的总长度(包含 IP 头 + L4 传输层头 + 业务数据)。因为我们在系统发包时是自上而下封装的,在这个阶段数据包尚未到达数据链路层,所以这一字段仅包含网络层及以上的长度,不过这一点也无伤大雅,毕竟以太网帧头的大小总是固定的。这个单元共有 16 位,2 的 16 次方是 65536。这也是我们在本章代码中将接收缓冲区大小设置为 65536 的根本原因:为了确保能在内存中一次性完全容纳下最大可能的 IP 数据包。
-
Time to Live (TTL,生存时间):尽管名字叫“时间”,但它描述的其实不是物理意义上的时间,而是数据包的“跳数”限制。为了防止路由配置错误导致幽灵数据包在网络中陷入死循环,数据包每经过一个路由器,这个 8 位的值就会自动减去 1。当它减到 0 时,路由器会直接丢弃该数据包,并向源主机回传一个 ICMP 超时数据包(这就是平时使用
traceroute追踪路由节点的底层原理)。 -
Protocol (上层协议类型):与以太网头中的
EtherType异曲同工,它是 L3(网络层)向 L4(传输层)交接的分界线。它决定了 IP 头后面的载荷到底是谁的数据:1代表 ICMP,6代表 TCP,17代表 UDP。由于这里只用单字节表示(不同于以太网的 2 字节,且此处的上层核心协议数量较少),所以不需要进行网络字节序的转换。 -
Source / Destination IP Address (源/目的 IP):位于第 12 到 19 个字节。存放着 32 位二进制的机器码 IP 地址,分别对应源 IP 和目的 IP。
-
在代码 parse_ip 中,我们使用 Linux 内核提供的
struct iphdr结构体来映射 IP 头。同解析 ARP 协议一样,我们在解封装时需要向后进行指针位移。在这里特别需要注意的是,我们必须取出ihl字段并乘以 4 来计算偏移量。虽然struct iphdr在 C 语言里的基础大小是 20 字节,但实际发来的 IP 包可能带有 Options,只有依靠动态计算出的ihl * 4长度跨过整个 IP 头部,指针才能精准地落到下一阶段(如 TCP 头)的起始位置。 -
你在代码中可能会注意到一个有趣的细节:我们在打印 IP 地址时,针对 IP 协议和 ARP 协议的处理方式全然不一致。这归根结底是因为两者的封包结构在底层 C 语言设计上的差异:
-
在
struct ether_arp中,IP 由unsigned char arp_spa[4](单字节字符数组)存储。 -
在
struct iphdr中,IP 由__be32 saddr(32 位无符号大端整数)存储。 虽然两者在底层物理内存中占据的都是 4 个字节,但这种 C 语言数据结构封装上的差异导致了我们调用的 API 不同。对于字符数组,只需简单遍历打印即可;但对于 32 位整数,由于历史 API 的限制,如果想调用inet_ntoa()将其翻译为人能看懂的字符串,必须将其包装成struct in_addr结构体后才能传入进行打印。 -
产生这种设计差异的根本原因,在于两种协议的设计初衷与用途不同。ARP 协议不仅要处理 IP 地址,还要处理长度动态变化的硬件地址(如 MAC),面对这种动态变化的数据,最通用的做法是使用单字节数组。而 IP 头部结构是专门针对 IP 协议高度定制的,直接使用 32 位整数(
__be32),不仅内存结构紧凑,更重要的是:这种设计使得 CPU 能够仅用一个时钟周期的位运算,就能将 IP 地址与子网掩码进行快速比对。 在追求极致效率的路由寻址过程中,这种整数运算的效率远超数组遍历。// 1. 拿到 32 位整数的起始物理内存地址 // 2. 强行给它套上一个 "单字节指针" 的包装 unsigned char *src_ip_bytes = (unsigned char *)&iph->saddr; unsigned char *dst_ip_bytes = (unsigned char *)&iph->daddr; // 3. 避开大小端转换和静态缓冲区陷阱,直接按字节物理直读 printf(" Source IP : %d.%d.%d.%d\n", src_ip_bytes[0], src_ip_bytes[1], src_ip_bytes[2], src_ip_bytes[3]); printf(" Destination IP : %d.%d.%d.%d\n", dst_ip_bytes[0], dst_ip_bytes[1], dst_ip_bytes[2], dst_ip_bytes[3]);
-
当然,正如我之前所强调的,在最底层的物理内存上两者并没有什么区别。因此,我们完全可以采用上述这种“指针强转”的方式,像读取数组一样去直读这个 32 位整数,完成 IP 的打印。
通过解析 struct iphdr,我们揭开了互联网“快递投递系统”的面纱。IP 协议本质上是一个**无连接、尽力而为(Best-Effort)**的数据报服务。它不关心数据包是否丢失,也不负责数据到达的顺序,它的唯一使命就是根据目标 IP 地址,在错综复杂的全球路由节点中,尽最大努力把数据包扔到对方的局域网大门口。至于数据丢了怎么办?顺序乱了怎么办?这就全权交给下一层的 TCP 协议去头疼了。
到这里,我们成功实现了网络层(Network Layer)的核心探秘,视野也从单纯的局域网内物理 MAC 寻址,走向了广阔的全球互联网 IP 路由体系。在后续的章节中,我们将站在 IP 协议的肩膀上,深入探究具体的端到端通讯建立过程和数据包发送机制,去亲眼见证在第一部分我们习以为常的高级 API(如 send、recvfrom)在底层究竟是如何被组装和调度的。