【Linux网络】从以太网碰撞到 Socket 套接字与网络字节序的深度解析 个人主页Cx330❄️个人专栏《C语言》《LeetCode刷题集》《数据结构-初阶》《C知识分享》《优选算法指南-必刷经典100题》《Linux操作系统》:从入门到入魔《Git深度解析》:版本管理实战全解 《Qt 极境架构》心向往之行必能Cx330的简介目录前言一、 同一个局域网内主机是如何通信的1.1 局域网通信的基石MAC 地址1.2 共享资源与“碰撞检测”机制二. 协议分层的本质为什么会有“以太网”2.1 数据的封装与解封装2.2 每一个接收方必须做的两件事三. 网络层协同为什么同时需要 IP 地址与 MAC 地址3.1 路由与路径选择的本质3.2 IP 地址的结构与网络层意义3.3 Port 地址的结构与网络层意义3.3.1 端口号的范围划分四. 传输层核心把数据交付给进程4.1 IP Port Socket4.2 PID 与 Port 的区别4.3 主机如何把报文交给进程五. Socket网络通信的核心基石5.1 Socket 的核心定义5.2 四元组与五元组唯一标识网络通信5.3 Linux 中 Socket 的本质六. 传输层两大核心协议TCP 与 UDP6.1 TCP 协议传输控制协议6.2 UDP 协议用户数据报协议七. 避坑指南网络字节序与大端、小端问题7.1 什么是大小端7.2 网络字节序的强制规定7.3 常用字节序转换函数详解(1) 函数族命名规则记忆密码(2) 函数原型与声明(3) 现代 IP 地址转换函数隐含字节序转换(4) 转换示例代码八. 进阶C Socket 核心 API 详解8.1 socket() —— 创建网卡抽象文件8.2 bind() —— 关联物理地址IP Port8.3 listen() —— 让套接字进入监听状态8.4 accept() —— 阻塞获取客户端连接8.5 connect() —— 客户端发起连接8.6 send() recv() —— 数据收发结语前言在现代软件开发中网络编程是每一位 C 程序员必须掌握的核心技能。然而许多人在编写socket代码时往往只停留在 API 的调用层面忽略了底层网络协议栈的流转本质。今天我将结合一份极具实战沉淀的网络协议底层笔记带大家从最基础的局域网物理碰撞出发一层层剥离网络封装的真相彻底理清IP 与 MAC 的本质区别再到传输层端口分发与网络字节序。最重要的是我们将延伸至Linux C/C Socket 核心 API 的完整调用链、衔接设计、注意事项以及经典面试常见问题。读完这篇你对网络通信的理解将更上一层楼一、 同一个局域网内主机是如何通信的我们首先将视角拉到最底层的物理世界局域网LAN以太网。1.1 局域网通信的基石MAC 地址问题两台主机在同一个局域网能直接通信吗答案能。原理要实现直接通信必须有一种方式能够对主机进行唯一性标识。这个标识就是MAC 地址烧录在网卡中的物理地址。MAC 地址在出厂时就已确定全球唯一。主要应用场景就是局域网内部。当我们从主机 A 向主机 E 发送数据时物理媒介通过以太网传输。数据包中携带源 MACA与目的 MACE。网卡在数据链路层决定要不要接收该数据如果是发给自己的就接收否则丢弃。1.2 共享资源与“碰撞检测”机制以太网在物理本质上是一个共享资源。在任意时刻只允许一台主机发送数据。碰撞数据碰撞如果多台主机同时发送信息无线电或电信号就会相互干扰发生数据碰撞导致相关主机都无法识别数据。碰撞检测与避免为了解决这一互斥与同步问题以太网引入了碰撞检测机制。一旦检测到碰撞主机会等待随机时间稍后再发。黑客思维——如何黑掉一个局域网如果我们想要瘫痪一个局域网原理其实非常简单只需要写一个脚本源源不断地向该局域网中塞入大量垃圾数据。这会导致正常的网络数据与垃圾数据不断发生碰撞从而让整个局域网的主机都在“等待、重发”中死锁。二. 协议分层的本质为什么会有“以太网”网络通信的一般“动力”是用户。逻辑上我们认为同层协议在直接通信如应用层 A 与应用层 B 对话。但实际上数据是必须向下流转、经过包装再向上传递的。2.1 数据的封装与解封装当用户 A 向用户 B 发送一句你好时数据在各层协议中的流转过程如下[ 发送端 A ] [ 接收端 B ] --------------- --------------- | 应用层 (你好)| 逻辑上直接通信 (等同) | 应用层 (你好)| -------------- -------------- | 封装 (加头) ^ 解封装 (拆头) v | -------------- -------------- | 传输层 | | 传输层 | -------------- -------------- | ^ v | -------------- -------------- | 网络层 | | 网络层 | -------------- -------------- | | v | -------------- -------------- | 数据链路层 | | 数据链路层 | -------------- -------------- | ^ v [ 物理网卡 ] | [ 物理网卡 ] A 的网卡 B 的网卡数据在链路层发送出去前最终会被打包成一个数据帧。其物理结构有效载荷封装如下{[ 数据链路层报头 ]} {[ 网络层报头 ]} {[ 传输层报头 ]} {[ 有效载荷 (应用层数据) ]}2.2 每一个接收方必须做的两件事任何一个网络接收端网卡/协议栈在收到数据包后必须无条件执行以下两个动作这也是所有网络协议的共性表征其为填表结构与变量报头与有效载荷分离将当前层的报头剥离提取出有效载荷。分发给上层对应的协议每一层的协议报头中都必须有一个特殊字段如 IP 报头中的协议字段以太网帧中的类型字段用以表明应当将解包后的有效载荷交付给上层的哪一个协议例如分发给 TCP 还是 UDP 。三. 网络层协同为什么同时需要 IP 地址与 MAC 地址在网络中每层处理的数据单元名称不同应用层 请求与响应传输层 数据段Segment网络层 数据报Packet数据链路层 数据帧Frame大家常问的一个经典面试题既然已经有了 MAC 地址为什么还需要 IP 地址3.1 路由与路径选择的本质我们可以用一个非常通俗的“旅游路线”来解释这两者的根本区别IP 地址解决的是“从哪里来到哪里去”的终极目标。它是永远不变的长远目标如我要从北京去往拉萨。用于在全网内唯一定位一台主机。MAC 地址解决的是“上一步从哪来下一步到哪去”的路径衔接。它是一直在变的短期目标如先坐飞机到成都再坐大巴到雅安。每经过一个路由器进行转发数据帧的源 MAC 和目的 MAC 就会被重写一次。[ 源主机 ] -------- [ 路由器 1 ] -------- [ 路由器 2 ] -------- [ 目标主机 ] IP 始终不变 (源:源主机IP - 目:目标主机IP) MAC 每一跳都在变 [源MAC-R1_MAC] [R1_MAC-R2_MAC] [R2_MAC-目标MAC]3.2 IP 地址的结构与网络层意义IP 地址以 IPv4 为例共 4 字节32 位通常用点分十进制表示如193.168.100.9而 IPv6 则是 128 位16 字节由于 IPv4 耗尽而推行{IP 地址} {网络号} {主机号}网络号标识主机所连接的局域网网段。主机号标识在该网段内的具体主机。3.3 Port 地址的结构与网络层意义数据类型与范围端口号是一个2 字节 16 位的无符号整数取值范围是0 ~ 65535核心作用唯一标识主机内的一个网络进程告诉操作系统当前收到的网络数据应该交给哪一个进程来处理核心结论IP 地址 端口号能够唯一标识互联网中某一台主机上的某一个进程。3.3.1 端口号的范围划分操作系统对端口号的使用做了明确的范围划分主要分为两大类划分区间端口范围分配原则与管理方式典型协议应用举例知名端口 / 系统端口(Well-Known Ports)0 \ 1023由IANA互联网地址分配机构统一分配和管理绑定了固定的应用层标准协议在类 Unix 系统中绑定这些端口通常需要系统管理员root权限21: FTP22: SSH80: HTTP443: HTTPS注册端口 动态/私有端口(Registered Dynamic Ports)1024 \ 65535注册端口($1024 \sim 49151$): 可向 IANA 注册以避免冲突普通用户进程常用动态/客户端临时端口($49152 \sim 65535$): 操作系统在客户端发起连接调用connect时动态随机分配的临时端口3306: MySQL6379: Redis8080: Tomcat 默认端口客户端发起请求时生成的临时大端口网络层存在的意义它提供了一个网络虚拟层。在网络层看来向上看到的报文格式都是高度统一的都是 IP 报文它屏蔽了底层物理介质和链路层协议的差异向下看时不同的物理链路报文格式各不相同从而让世界上的所有网络都能连通成一个统一的“IP 网络”。四. 传输层核心把数据交付给进程把数据从主机A发送到主机B并不是终点这只是手段。真正的终点是把数据交付给主机$B$上的某一个具体进程。4.1 IP Port SocketIP 地址在全网内唯一标识一台主机。端口号Port在当前主机内唯一标识一个进程。套接字Socket将两者结合IP Port就能在全网内唯一标识一个进程。网络通信的本质就是两个进程通过 Socket跨网络进行进程间通信。4.2 PID 与 Port 的区别既然操作系统里已经有了进程 PID为什么网络还要单独搞一套端口号 Port 呢解耦系统层面的 PID 经常会发生变化进程重启后 PID 就会变且不同的操作系统 PID 实现机制不同。网络协议栈属于系统底层的独立模块使用端口号 Port 可以实现网络与系统底层进程管理机制的解耦。绑定不是所有进程都需要网络通信它们只有 PID。只有需要网络通信的进程才会去申请并绑定一个端口号。4.3 主机如何把报文交给进程在目标主机内部当传输层收到数据后会通过一个哈希表Hash Map进行检索。通过报文中的“目的端口号”例如8080快速检索到对应的进程控制块PCB进而把数据推入该进程对应的接收缓冲区中。[ 传输层收到报文 (目的端口号: 8080) ] | v 查哈希表 (Port - PCB) ------------- | Port | PCB | ------------- | 8080 | 进程A | 塞入进程 A 的缓冲区 | 8888 | 进程B | -------------inet_hashinfo内部细分了三个核心哈希表分别应对不同的场景// Linux内核中inet_hashinfo核心结构精简版 struct inet_hashinfo { // 1. 已建立连接哈希表核心表存储所有ESTABLISHED状态的socket struct inet_ehash_bucket *ehash; // 2. 绑定哈希表以本地端口为索引存储绑定到特定端口的socket检查端口是否占用 struct inet_bind_hashbucket *bhash; // 3. 监听哈希表以本地IP端口为索引存储LISTEN状态的socket处理新连接请求 struct inet_listen_hashbucket *lhash2; };内核从报文到进程的完整查找流程网卡收到数据报文经过数据链路层、网络层校验后交付给传输层传输层提取报文的五元组源 IP、源端口、目的 IP、目的端口、协议调用__inet_lookup函数先在ehash已建立连接表中通过五元组计算哈希值快速找到对应的 Socket 结构体如果是新的连接请求SYN 包则在lhash2监听表中通过目的端口查找对应的监听 Socket找到 Socket 后内核通过它关联的文件结构体最终找到拥有这个 Socket 的进程task_struct完成数据交付。五. Socket网络通信的核心基石理解了 IP 和端口号我们就能彻底搞懂 Socket 的本质。5.1 Socket 的核心定义IP 地址标识互联网中唯一的一台主机端口号标识该主机上唯一的一个网络进程因此「IP 地址 端口号」就能唯一标识互联网中的一个进程我们把这个组合叫做套接字Socket。Socket 的英文原意是「插座」这个命名极其形象网络通信就像电器通电IP 地址是你家的地址端口号是你家墙上的插座编号电器进程插上插座绑定 Socket就能通过电网互联网和远端的电器通信。5.2 四元组与五元组唯一标识网络通信四元组{源IP, 源端口, 目的IP, 目的端口}能够唯一标识互联网中唯二的两个通信进程定义了通信的两个端点五元组在四元组的基础上增加了传输层协议TCP/UDP内核通过五元组唯一标识一个完整的网络双向连接。5.3 Linux 中 Socket 的本质在 Linux 系统中有一个核心哲学一切皆文件Socket 也不例外。从用户态视角Socket 本质是一个文件描述符fd我们可以通过read/write系统调用对这个 fd 进行读写实现数据的收发从内核态视角Socket 是一个复杂的结构体里面绑定了 IP、端口、协议类型、收发缓冲区、连接状态等所有网络通信相关的信息是内核网络协议栈与用户态进程之间的桥梁。六. 传输层两大核心协议TCP 与 UDPSocket 编程基于传输层协议我们需要先对两大核心协议建立直观的认知为后续编程打下基础。6.1 TCP 协议传输控制协议TCPTransmission Control Protocol是面向连接的、可靠的、面向字节流的传输层协议核心特性有连接通信前必须先通过「三次握手」建立连接断开时通过「四次挥手」释放连接就像打电话必须先拨通电话、对方接听才能正常对话(三次握手,四次挥手——以后会见到)可靠传输TCP 会通过确认应答、超时重传、序列号、校验和等机制保证数据不丢失、不重复、按序到达不会出现数据错乱、丢包的情况面向字节流数据以无边界的字节流形式传输就像自来水发送端一次发 1000 字节接收端可以分 10 次每次收 100 字节收发次数没有严格的对应关系上层需要自己处理数据边界。6.2 UDP 协议用户数据报协议UDPUser Datagram Protocol是无连接的、不可靠的、面向数据报的传输层协议核心特性无连接通信前不需要建立连接知道对方的 IP 和端口就可以直接发送数据就像发邮件不需要提前和对方确认直接投递即可不可靠传输UDP 不提供确认应答、重传机制只保证把数据发出去不保证数据能到达对方也不保证数据按序到达面向数据报数据以报文为单位传输收发次数严格匹配发送端一次发一个 100 字节的报文接收端必须一次完整接收 100 字节不能分多次读取天然保留了数据边界。重要提醒可靠和不可靠是协议的特性不是优缺点。TCP 的可靠是有代价的它需要额外的开销维护连接、保证可靠性传输效率更低UDP虽然不可靠但它头部开销小、传输效率高、延迟低。对数据完整性要求高的场景文件传输、银行转账、HTTP 通信用 TCP对实时性要求高、能容忍少量丢包的场景直播、视频通话、游戏用 UDP。七. 避坑指南网络字节序与大端、小端问题在 C/C 进行套接字编程时有一个新手必踩的雷区字节序。7.1 什么是大小端假设我们在内存中存储一个 32 位的整型数值0x1234abcd大端模式Big-endian低地址存高字节。小端模式Little-endian低地址存低字节目前我们常用的 x86 / ARM 个人电脑大多是小端模式。以地址由低到高0x0000~0x0003存储0x1234abcd为例内存地址大端模式 (Big-endian)小端模式 (Little-endian)0x0000(低地址)0x12(高字节)0xcd(低字节)0x00010x340xab0x00020xab0x340x0003(高地址)0xcd(低字节)0x12(高字节)大端序高位字节0x12存储在低地址低位字节0xCD存储在高地址。小端序低位字节0xCD存储在低地址高位字节0x12存储在高地址。7.2 网络字节序的强制规定TCP/IP 协议明确规定网络数据流必须采用大端字节序即低地址高字节。无论发送端主机是大端还是小端发送数据前必须将多字节数据从主机字节序转换为网络字节序大端无论接收端主机是大端还是小端收到数据后必须将多字节数据从网络字节序转换为主机字节序再进行处理。为什么选择大端序作为网络字节序因为网络数据的发送规则是「内存低地址的数据先发送」大端序的存储方式让先发送的是数据的高字节抓包分析时更符合人类的阅读习惯可读性更好。7.3 常用字节序转换函数详解在 Linux C/C 网络编程中系统提供了一组专门用于在“主机字节序”与“网络字节序”之间进行转换的底层函数。(1) 函数族命名规则记忆密码这组函数非常容易记错但只要理清其命名的字母含义就能过目不忘hHost主机字节序多为小端nNetwork网络字节序大端to转换到sShort16位无符号整型多用于端口号转换lLong32位无符号整型多用于IP地址转换(2) 函数原型与声明这些函数声明在arpa/inet.h头文件中#include arpa/inet.h // 1. 16位无符号整数主机字节序 - 网络字节序主要用于 Port uint16_t htons(uint16_t hostshort); // 2. 32位无符号整数主机字节序 - 网络字节序主要用于 IPv4 地址 uint32_t htonl(uint32_t hostlong); // 3. 16位无符号整数网络字节序 - 主机字节序解析收到的 Port uint16_t ntohs(uint16_t netshort); // 4. 32位无符号整数网络字节序 - 主机字节序解析收到的 IP uint32_t ntohl(uint32_t netlong);(3) 现代 IP 地址转换函数隐含字节序转换在网络编程中我们很少直接对 32 位的 IP 整型值手动调用htonl()而是使用更高级的IP 字符串与网络字节序整数相互转换的 API。这些现代接口已经把“字节序转换”的工作在底层封装好了#include arpa/inet.h // 1. 字符串IP127.0.0.1 - 网络字节序的二进制IP P: Presentation, N: Network int inet_pton(int af, const char *src, void *dst); // 示例inet_pton(AF_INET, 192.168.1.1, server_addr.sin_addr); // 2. 网络字节序的二进制IP - 字符串IP const char *inet_ntop(int af, const void *src, char *dst, socklen_t size); // 示例char ip_str[INET_ADDRSTRLEN]; // inet_ntop(AF_INET, client_addr.sin_addr, ip_str, sizeof(ip_str));(4) 转换示例代码#include iostream #include arpa/inet.h int main() { uint16_t host_port 8888; // 转换为网络字节序 uint16_t net_port htons(host_port); std::cout 主机端口: host_port - 网络字节序(十六进制): 0x std::hex net_port std::endl; // 再转换回来 uint16_t decoded_port ntohs(net_port); std::cout std::dec; // 恢复十进制打印 std::cout 还原后主机端口: decoded_port std::endl; return 0; }八. 进阶C Socket 核心 API 详解有了前面的理论支撑我们接下来看看在 C 中操作系统是如何用一组 API 来完成这些操作的。8.1socket()—— 创建网卡抽象文件int socket(int domain, int type, int protocol);作用向操作系统申请一个网络通信端点底层其实是打开了一个特殊文件返回文件描述符fd。参数domain地址族。常用AF_INET(IPv4) 或AF_INET6(IPv6)。type套接字类型。SOCK_STREAM(面向字节流即 TCP)SOCK_DGRAM(面向数据报即 UDP)。protocol协议一般填0系统会根据type自动匹配。8.2bind()—— 关联物理地址IP Portint bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);作用将创建好的套接字与当前主机的 IP 和端口进行绑定。参数sockfdsocket()函数返回的文件描述符。addr指向sockaddr结构体的指针实际使用中常用struct sockaddr_in强转用于填充具体的 IP 和端口。8.3listen()—— 让套接字进入监听状态int listen(int sockfd, int backlog);作用仅用于TCP 服务器告诉操作系统“我已经准备好接收连接请求了请帮我建立起连接队列”。backlog底层未完成连接队列SYN_RCVD与已完成连接队列ESTABLISHED的最大长度 and 上限。8.4accept()—— 阻塞获取客户端连接int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);作用从已完成三次握手的连接队列中取出一个连接。返回值成功时会返回一个全新的文件描述符专门负责与该客户端进行数据收发。而原本的sockfd继续保留专门用来监听新的客户端连接分工明确。8.5connect()—— 客户端发起连接int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);作用仅用于客户端。向指定的服务器发起 TCP 三次握手连接。8.6send()recv()—— 数据收发ssize_t send(int sockfd, const void *buf, size_t len, int flags); ssize_t recv(int sockfd, void *buf, size_t len, int flags);作用在建立连接的套接字上收发数据。实质是将数据在“用户缓冲区”与“操作系统内核套接字缓冲区”之间进行拷贝。结语从局域网内网卡的碰撞避让到 IP/MAC 的接力运载再到传输层通过 Port 精准投递给应用进程最终在内存中通过大端字节序落带——网络通信的每一层设计都充满了计算机科学家们智慧。而掌握好 Socket 核心 API 的流转机制并熟悉它在面对高并发、网络抖动等特殊复杂场景下的边界处理是你从 C 初学者蜕变成为后端专家的必经之路。