🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然
🏠博主简介
文章目录
- 前言
- 一、局域网通信的基本过程
- 1.1 认识 MAC 地址
- 1.2 以太网是共享资源
- 1.3 局域网内怎样找到目标主机
- 二、数据封装、解包与分用
- 2.1 报头和有效载荷
- 2.2 封装为什么是自顶向下
- 2.3 接收端为什么要解包
- 2.4 分用:数据应该交给谁
- 三、跨网络传输:IP 地址与 MAC 地址
- 3.1 为什么只靠 MAC 地址不够
- 3.2 IP 是长期目标,MAC 是下一站
- 3.3 路由器怎样连接不同网络
- 3.4 IP 层屏蔽底层网络差异
- 四、网络通信为什么最终是进程通信
- 4.1 IP 地址只能找到主机
- 4.2 端口号负责标识进程
- 4.3 端口号与进程 ID 的区别
- 4.4 Socket 是什么
- 五、TCP 与 UDP 的直观认识
- 5.1 TCP
- 5.2 UDP
- 六、网络字节序
- 6.1 大端与小端
- 6.2 常用转换函数
- 七、Socket 编程中的核心接口
- 7.1 socket:创建通信端点
- 7.2 bind:绑定本地地址
- 7.3 listen:把 TCP Socket 设为监听状态
- 7.4 accept:获取已经建立的连接
- 7.5 connect:客户端主动连接服务器
- 八、为什么接口统一使用 sockaddr
- 8.1 不同协议的地址结构不同
- 8.2 sockaddr_in 的主要字段
- 8.3 字符串 IP 与二进制地址
- 九、把完整流程串起来
- 总结
前言
上一篇建立了协议分层和 TCP/IP 模型的整体认识,本篇继续追踪一份数据从发送主机到接收主机的完整过程。
我们先看局域网中的数据怎样通过 MAC 地址找到目标网卡,再看数据跨网络时 IP 地址和路由器分别做什么。最后把网络编程前经常混在一起的几个概念讲清楚:端口号、进程 ID、Socket、TCP/UDP、网络字节序,以及sockaddr_in地址结构。
这些内容看起来很多,其实围绕的是同一个问题:数据怎样从一台主机中的某个进程,准确交付到另一台主机中的某个进程。
一、局域网通信的基本过程
1.1 认识 MAC 地址
MAC 地址用于局域网内部识别网络接口。常见 MAC 地址长度为 48 位,一般写成六组十六进制数,例如:
08:00:27:03:fb:19物理网卡通常在出厂时写入 MAC 地址。虚拟机、容器和部分网卡也允许软件配置,因此学习时更准确的说法是:MAC 地址用于链路层标识网络接口,通常具有唯一性。
在 Windows 中可以使用下面的命令查看网卡信息:
ipconfig /allIP 地址回答“最终去哪里”,MAC 地址回答“当前这一跳交给谁”。两者都和地址有关,但工作的范围不同。
1.2 以太网是共享资源
早期共享式以太网中,多台主机使用同一传输介质。某一时刻如果有多台主机同时发送数据,信号会互相干扰,形成数据碰撞。
这和教室里多人同时说话很像。一个人讲话时大家能听清,多人同时喊,接收者就难以分辨。共享介质因此需要碰撞检测和避免机制。
从并发角度看,以太网传输介质也是一种共享资源。多台主机都想发送数据,但同一时刻只能让一方成功占用。交换机可以把大的碰撞域拆开,让不同端口之间独立转发,现代交换式以太网已经大幅减少了传统共享介质中的碰撞问题。
无线局域网虽然介质不同,但同样存在多个设备竞争信道的问题。设备越多,冲突和等待越明显,这也是高密度环境下无线网络可能变慢的原因之一。
1.3 局域网内怎样找到目标主机
同一局域网中的数据帧会携带源 MAC 地址和目的 MAC 地址。网卡收到帧后,会判断目的 MAC 是否与自己匹配:
- 是发给自己的,继续向上交付;
- 不是发给自己的,通常直接丢弃;
- 广播帧则由局域网内符合条件的主机接收处理。
站在用户角度,主机 A 像是在直接给主机 B 发消息。站在系统角度,应用数据先从 A 的协议栈自顶向下交给网卡,经过局域网到达 B 的网卡,再由 B 的协议栈自底向上交给应用程序。
主机之间通信,就是两套协议栈按照相同规则处理数据。
局域网通信时,数据先穿过本机协议栈,再由对端协议栈逐层接收,应用程序不会直接控制远端网卡。
二、数据封装、解包与分用
2.1 报头和有效载荷
用户只关心自己要发送的内容,但网络中的每层协议还需要携带控制信息。当前层添加的控制信息称为报头,报头之外的数据称为有效载荷。
可以把它看成寄快递。商品是我们关心的内容,包装和快递单记录运输所需的信息。没有这些信息,包裹无法被正确处理。
对某一层协议来说:
完整报文 = 当前层报头 + 当前层有效载荷应用层数据交给传输层后,传输层添加自己的报头;传输层报文交给网络层后,网络层继续添加 IP 报头;到数据链路层时,还会添加链路层首部和尾部。
2.2 封装为什么是自顶向下
发送动作由应用程序发起,而真正把比特送上介质的是底层网卡。应用程序不能直接控制远端网卡,因此数据必须穿过本机协议栈,逐层向下交付。
数据每经过一层,就会增加该层需要的报头。这个过程称为封装。
发送端逐层添加报头叫封装;接收端逐层分离报头和有效载荷叫解包。
不同层对完整报文的称呼也不同:
- 应用层通常称为应用数据或消息;
- 传输层中,TCP 报文常称为段;
- 网络层称为 IP 数据报;
- 数据链路层称为帧;
- 到物理层后表现为比特流或信号。
2.3 接收端为什么要解包
目标网卡收到数据帧后,链路层先读取自己的报头,判断帧是否应该接收。确认无误后,链路层去掉自己的首部和尾部,把剩余有效载荷交给网络层。
网络层继续解析 IP 报头,再把有效载荷交给传输层;传输层解析端口等信息,最终把数据交给应用进程。
逐层把报头和有效载荷分开的过程叫作解包。
一层协议要完成解包,报文格式中就必须有办法确定报头长度和有效载荷边界。固定长度报头可以直接按字节数切分,可变长度报头则需要专门字段记录长度。
2.4 分用:数据应该交给谁
每一层的上方都可能存在多个协议。例如网络层上方可能是 TCP,也可能是 UDP;传输层上方又有很多应用进程。
协议在解包之后,还要根据报头中的字段判断有效载荷应该交给谁,这个过程称为分用。
后面学习任何协议,都可以先问两个问题:
- 这个协议怎样区分报头和有效载荷?
- 这个协议怎样把有效载荷交给正确的上层对象?
把这两个问题弄清楚,协议的报文格式就不再只是需要背诵的字段表。
三、跨网络传输:IP 地址与 MAC 地址
3.1 为什么只靠 MAC 地址不够
MAC 地址适合局域网内寻址,但互联网由大量不同局域网组成。数据从一个网络到另一个网络,需要一种能描述整体网络位置的地址,这就是 IP 地址。
IPv4 地址长度为 32 位,通常使用点分十进制表示。
192.168.1.10四个十进制数字分别表示一个字节,每个数字的范围是 0~255。
公网 IPv4 地址在互联网范围内需要保持唯一。由于 IPv4 地址数量有限,现实网络大量使用NAT(网络地址转换),把多个内网地址映射到一个或少量公网地址。内网 IP 可以在不同局域网中重复,公网侧则通过转换后的地址完成通信。
3.2 IP 是长期目标,MAC 是下一站
跨网络发送数据时,目的 IP 表示最终目标,目的 MAC 表示当前链路上的下一跳。
例如主机 A 和主机 B 不在同一局域网。A 无法直接把帧交给 B 的网卡,它会先把帧交给本地路由器:
- IP 报头中的目的 IP 仍然是主机 B;
- 当前以太网帧中的目的 MAC 是本地路由器网卡。
路由器收到帧后,去掉原来的链路层报头,查看目的 IP 并选择下一跳。准备从另一个接口发出时,路由器会重新封装链路层报头,因此 MAC 地址会随链路变化。
可以先记住这个结论:
在不考虑 NAT 等情况时,源 IP 和目的 IP 在转发过程中保持不变;每经过一个新的链路,源 MAC 和目的 MAC 都要重新填写。
3.3 路由器怎样连接不同网络
路由器至少要有两个网络接口,每个接口接入一个网络。不同接口具有各自的 IP 和 MAC 地址。
主机发送数据前,会根据目的 IP 和本机网络配置判断目标是否在同一子网:
- 在同一子网,直接寻找目标主机的链路层地址;
- 不在同一子网,把数据交给默认网关,由路由器继续转发。
路由器在接收接口上解开链路层帧,保留其中的 IP 数据报;根据路由表选出发送接口和下一跳后,再为新的链路重新封装数据帧。
路由器的转发过程可以概括为:解开旧链路封装,依据目的 IP 路由,再建立新链路封装。它不会把收到的以太网帧原样搬到另一个网络。
3.4 IP 层屏蔽底层网络差异
路由器两侧不一定使用相同的链路层技术。一侧可以是以太网,另一侧也可以是其他网络。只要它们都能承载 IP 数据报,上层就不必关心底层链路的具体差异。
这就是 IP 层的重要价值:它在各种底层网络之上提供统一的网络层视图。对传输层来说,下面无论经过以太网、无线局域网还是其他链路,看到的都是 IP 网络。
IP 地址和 MAC 地址不能简单互相替代。把全局寻址与局部链路寻址分开,可以让网络层和不同链路技术各自演进,避免所有网卡和链路协议都被某一种网络层地址绑定。
目的 IP 指向最终主机,目的 MAC 指向当前链路的下一跳。
四、网络通信为什么最终是进程通信
4.1 IP 地址只能找到主机
一台服务器上可能同时运行 Web 服务、数据库、SSH 服务和其他程序。数据到达主机以后,如果操作系统只知道目标 IP,还无法判断数据属于哪个进程。
网络通信的两个端点是进程。网卡和协议栈负责传输,最终仍要让发送进程和接收进程交换数据。
4.2 端口号负责标识进程
传输层使用端口号区分主机中的通信端点。端口号是一个 16 位无符号整数,范围为 0~65535。
数据到达传输层后,操作系统根据报头中的目标端口号,把数据放入对应 Socket 的接收队列,再唤醒等待数据的进程。
常见端口范围可以这样理解:
- 0~1023:知名端口,通常分配给常用服务,例如 SSH 默认使用 22,HTTP 默认使用 80;
- 1024~49151:注册端口,很多应用服务会在这个范围选择固定端口;
- 49152~65535:动态或临时端口,客户端建立连接时常由操作系统临时分配。
不同系统对临时端口范围的具体配置可能不同,上面的划分用于建立基本认识。
4.3 端口号与进程 ID 的区别
进程 ID 和端口号都能在某种意义上标识进程,但它们服务于不同场景。
- PID 用于操作系统内部管理进程;
- 端口号用于网络协议栈定位通信端点;
- PID 由操作系统的进程管理模块分配;
- 端口号由网络程序主动绑定,或由内核临时分配。
不能直接用 PID 替代端口号。网络报文要在不同操作系统之间传输,而远端并不认识本机的进程 ID;PID 生命周期也短,数值会被重复使用。端口号是 TCP/UDP 协议的一部分,通信双方都能按照相同规则解析。
通常情况下,一个确定的 IP、传输层协议和端口组合,只能由一个监听端点占用。不同协议可以在同一主机上使用相同数字端口,例如 TCP 8080 和 UDP 8080 属于不同的通信端点。
IP 地址负责找到主机,端口号负责找到主机中的通信端点。两者缺少任何一个,都不能完成准确的进程投递。
4.4 Socket 是什么
从网络地址角度看,IP + port可以确定一个通信端点。Socket 则是操作系统提供给进程的网络通信对象和编程接口。
一个 TCP 连接通常由下面的四元组唯一描述:
源 IP + 源端口 + 目的 IP + 目的端口服务器可以在一个固定端口上接受多个客户端连接,因为每个客户端的源 IP 和源端口不同,最终形成的四元组也不同。
五、TCP 与 UDP 的直观认识
5.1 TCP
TCP 是面向连接的传输层协议。通信前要先建立连接,通信过程中会进行确认、重传、排序、流量控制和拥塞控制,向应用层提供可靠的字节流。
“可靠”并不代表数据永远不会丢。TCP 会发现丢失、重复和乱序,并通过协议机制处理。应用程序看到的是一条连续字节流,不需要自己为每个 IP 数据报处理重传。
TCP 适合对完整性和顺序要求高的场景,例如网页传输、远程登录和文件传输。
5.2 UDP
UDP 是无连接的传输层协议。发送数据前不建立连接,也不保证数据一定到达、不重复或按顺序到达。
它的报头更简单,发送开销较小,同时保留消息边界。实时音视频、局域网发现、DNS 查询等场景常使用 UDP,应用层可以根据需要自己补充可靠性机制。
初学时可以先这样区分:
- TCP 更关注可靠、有序和连接状态;
- UDP 更关注简单、直接和较低开销。
具体选择不能只看“谁更快”,还要看业务是否允许丢包、是否需要顺序、是否需要广播或多播,以及应用层愿意承担多少控制逻辑。
六、网络字节序
6.1 大端与小端
多字节整数在内存中有两种常见存放方式:
- 大端序:高位字节放在低地址;
- 小端序:低位字节放在低地址。
不同主机可能采用不同的主机字节序。如果发送方直接把内存中的整数原样发出,接收方按照另一种顺序解释,就会得到错误数值。
为了让不同主机能够统一解析,网络协议规定使用大端序,也叫网络字节序。程序在填写端口和地址字段时,需要在主机字节序与网络字节序之间转换。
网络字节序统一采用大端序;主机中的整数进入网络报文前,必须按接口要求完成转换。
6.2 常用转换函数
Linux 中常用四个函数:
#include<arpa/inet.h>uint32_thtonl(uint32_thostlong);uint16_thtons(uint16_thostshort);uint32_tntohl(uint32_tnetlong);uint16_tntohs(uint16_tnetshort);函数名可以直接拆开记:
h:host,主机;n:network,网络;l:long,32 位整数;s:short,16 位整数。
例如端口号是 16 位整数,填写sockaddr_in时通常使用htons:
server.sin_port=htons(8080);从网络报文中取出端口号后,如果需要按主机整数使用,可以调用ntohs。
七、Socket 编程中的核心接口
7.1 socket:创建通信端点
#include<sys/types.h>#include<sys/socket.h>intsocket(intdomain,inttype,intprotocol);常见参数组合:
intsockfd=socket(AF_INET,SOCK_STREAM,0);// IPv4 TCPintsockfd=socket(AF_INET,SOCK_DGRAM,0);// IPv4 UDP调用成功返回文件描述符,失败返回-1。Linux 把 Socket 也纳入文件描述符体系,后续可以使用read、write,也可以使用网络专用的send、recv等接口。
7.2 bind:绑定本地地址
intbind(intsockfd,conststructsockaddr*addr,socklen_taddrlen);服务器通常需要把 Socket 绑定到固定 IP 和端口。客户端一般不必手动绑定端口,连接时由内核分配临时端口即可。
7.3 listen:把 TCP Socket 设为监听状态
intlisten(intsockfd,intbacklog);listen只用于面向连接的 TCP Socket。调用后,Socket 开始接收连接请求。backlog与等待连接队列有关,但它不是简单等于“最多只能连接多少客户端”,具体语义还受到内核实现和系统配置影响。
7.4 accept:获取已经建立的连接
intaccept(intsockfd,structsockaddr*addr,socklen_t*addrlen);监听 Socket 负责等待连接,accept成功后会返回一个新的文件描述符,专门用于和某个客户端通信。
这里要区分两个描述符:
- 监听描述符继续等待新的客户端;
- 已连接描述符负责当前客户端的数据收发。
服务器能够同时服务多个客户端,正是因为每次成功accept都会得到独立的连接 Socket。
7.5 connect:客户端主动连接服务器
intconnect(intsockfd,conststructsockaddr*addr,socklen_taddrlen);TCP 客户端创建 Socket 后,使用connect指定服务器 IP 和端口。连接建立成功后,客户端就可以通过该描述符收发数据。
UDP 没有建立连接的握手过程,但也允许调用connect固定默认对端。此时并不会变成 TCP,只是内核记录默认目标并对收发接口做相应限制。
八、为什么接口统一使用 sockaddr
8.1 不同协议的地址结构不同
IPv4 地址需要保存 IP 和端口,本地 Unix 域 Socket 需要保存文件路径。它们的地址格式不同,但bind、connect等函数希望使用统一接口。
为了解决这个问题,Socket API 使用通用结构struct sockaddr作为函数参数,再通过开头的地址族字段判断实际类型。
写 IPv4 程序时,我们实际准备的是struct sockaddr_in,调用函数时再强制转换成struct sockaddr *。
8.2 sockaddr_in 的主要字段
#include<netinet/in.h>structsockaddr_in{sa_family_tsin_family;in_port_tsin_port;structin_addrsin_addr;};主要字段含义如下:
sin_family:地址族,IPv4 填写AF_INET;sin_port:16 位端口号,必须使用网络字节序;sin_addr:IPv4 地址,内部保存 32 位地址值。
准备服务器地址时可以这样写:
structsockaddr_inlocal;memset(&local,0,sizeof(local));local.sin_family=AF_INET;local.sin_port=htons(8080);local.sin_addr.s_addr=htonl(INADDR_ANY);INADDR_ANY表示绑定本机所有可用 IPv4 地址。服务器可能有环回地址、局域网地址或其他网卡地址,使用它以后,这些本地地址上的 8080 端口都可以接收连接。
调用bind时需要转换指针类型:
intret=bind(sockfd,(conststructsockaddr*)&local,sizeof(local));这种转换看起来麻烦,但它换来了接口统一。IPv4、IPv6 和 Unix 域 Socket 可以复用同一组系统调用。
8.3 字符串 IP 与二进制地址
人习惯使用192.168.1.10这样的字符串,协议栈内部使用的是二进制地址。可以使用inet_pton完成转换:
#include<arpa/inet.h>structsockaddr_inserver;memset(&server,0,sizeof(server));server.sin_family=AF_INET;server.sin_port=htons(8080);intret=inet_pton(AF_INET,"192.168.1.10",&server.sin_addr);返回值含义:
- 返回
1:转换成功; - 返回
0:字符串不是合法地址; - 返回
-1:地址族不受支持或发生错误。
反向转换可以使用inet_ntop:
charip[INET_ADDRSTRLEN];inet_ntop(AF_INET,&server.sin_addr,ip,sizeof(ip));相比旧接口,inet_pton和inet_ntop同时支持 IPv4、IPv6,含义也更清楚。
九、把完整流程串起来
前面的概念可以放进一次 TCP 访问中再看一遍。
假设客户端进程要访问另一台主机上的 TCP 服务器:
- 客户端调用
socket创建通信端点; - 客户端准备服务器 IP 和端口,调用
connect; - 应用数据交给 TCP,TCP 添加源端口和目的端口等信息;
- IP 层添加源 IP 和目的 IP,并根据路由决定下一跳;
- 链路层填写当前链路的源 MAC 和目的 MAC;
- 网卡把数据帧发送到网络;
- 路由器逐跳转发,每一跳都会重新建立链路层封装;
- 服务器网卡收到数据后,协议栈逐层解包和分用;
- TCP 根据目标端口找到监听端点或已建立连接;
- 服务器进程通过
accept获得连接,再从 Socket 中读取应用数据。
这里同时出现了三类定位信息:
- MAC 地址定位当前链路中的下一跳;
- IP 地址定位网络中的目标主机;
- 端口号定位主机中的通信进程。
三者解决的问题不同,组合起来才完成从一个进程到另一个进程的准确交付。
总结
本文的重点如下:
- 局域网与跨网络寻址:MAC 地址服务于当前链路,IP 地址服务于跨网络寻址和路由;
- 数据处理方向:发送端自顶向下封装,接收端自底向上解包并分用;
- IP 与 MAC 的变化:跨路由转发时,IP 提供长期方向,MAC 地址随当前链路变化;
- 端口号的作用:网络通信最终是进程间通信,端口号负责把数据交给正确通信端点;
- TCP 与 UDP:TCP 提供可靠字节流,UDP 提供更轻量的数据报服务;
- 网络字节序:网络统一使用大端字节序,端口常用
htons和ntohs转换; - Socket 基本接口:
socket、bind、listen、accept、connect构成 TCP 网络编程的基本接口; - IPv4 地址结构:程序使用
sockaddr_in保存地址,调用通用接口时转换成sockaddr *。
把这些基础概念理顺以后,再开始写 TCP 或 UDP 代码,就能知道每个结构和接口为什么存在,而不是只照着模板填写参数。
往期资源分享
【Linux网络】网络为什么要分层?从协议、OSI 到 TCP/IP,一次搭起网络通信整体框架
【Linux】线程安全进阶:单例模式、可重入函数与死锁排查
【Linux】手写日志与固定线程池:任务队列、工作线程和安全退出