封面


文章目录


引言

刚开始学网络的时候,我一直觉得“网络通信”就是两台电脑互相发数据,比如浏览器访问网页、QQ 发消息、服务器返回结果。表面上看,这些操作都很自然,好像只要电脑连上网线或者 WiFi,数据就能自动到达对方。
但是后来继续往底层看,我才发现网络通信其实没有想象中那么简单。两台主机之间要想真正完成通信,必须先解决很多问题:数据怎么表示?双方怎么约定格式?不同厂商、不同操作系统、不同网络设备之间为什么能互相通信?数据从应用程序出发之后,为什么要一层一层往下交付?到达对方主机之后,又是怎么找到目标进程的?
学到这里我才意识到,网络不是单纯的“连起来就能发”,而是一整套协议体系在背后支撑。协议、分层、封装、解包、IP 地址、MAC 地址、端口号、socket,这些概念看起来分散,但其实都围绕一个核心问题:如何让分布在不同位置的进程,能够按照统一规则完成数据通信。


1. 计算机网络的产生背景

1.1 从独立模式到网络互联

最开始的计算机可以理解成“各自独立工作”的状态,也就是所谓的独立模式。每台计算机都有自己的数据、自己的程序、自己的用户,彼此之间没有直接联系。
但是人是需要协同工作的。比如一个公司的不同部门需要共享数据,一个实验室中的多台电脑需要访问同一份文件,一个服务器需要同时给很多用户提供服务。如果所有计算机都完全独立,那么数据交换就会非常麻烦。
于是,网络互联就出现了。

简单来说:

  • 独立模式:计算机之间相互独立;
  • 网络互联:多台计算机连接在一起,实现数据共享;
  • 局域网 LAN:较小范围内的网络,比如宿舍、实验室、公司内部网络;
  • 广域网 WAN:跨越更大地理范围的网络,比如城市之间、国家之间、全球互联网。

💡我一开始理解网络的时候,只觉得它像“电脑之间拉了一根线”。后来才发现,更准确地说,网络其实是在解决“多台主机如何共享信息、协同工作”的问题。就像几个人写同一个项目,如果每个人都只在自己电脑上改代码,不共享版本,协作就很难进行。

从独立计算机到网络互联的发展过程

1.2 局域网和广域网只是相对概念

学习局域网和广域网时,我一开始以为它们是非常严格的概念,比如局域网就是小网络,广域网就是大网络。后来才发现,它们更多是一种相对概念。

比如:

  • 宿舍里的几台电脑可以组成一个小局域网;
  • 学校内部的多个机房、办公楼之间也可以组成一个更大的局域网;
  • 一个城市、一个国家甚至全球范围内的网络,可以看作广域网;
  • 但如果从更大的视角看,一个很大的广域网也可以被理解成“更大范围的局域网”。

也就是说,局域网和广域网的区别主要体现在网络规模、覆盖范围、管理方式和连接设备上,而不是绝对固定的边界。

💡这就像“近”和“远”一样。对宿舍来说,教学楼很远;但对一个城市来说,宿舍和教学楼又很近。所以局域网和广域网也是相对视角下的概念。


2. 初识协议:通信为什么需要约定

2.1 协议的本质是约定

网络通信中最重要的概念之一就是协议。
刚开始我看到“协议”这个词,会觉得它很抽象,好像只属于 TCP、IP、HTTP 这种专业名词。但后来我发现,协议其实并不神秘,它本质上就是一种约定。

💡比如红绿灯就是一种约定:

  • 红灯停;
  • 绿灯行;
  • 黄灯等待。

灯光本身只是不同颜色的光,但因为所有人都遵守同样的规则,所以它能够传递明确的信息。

计算机之间通信也是一样。计算机传输的数据本质上是光信号、电信号或者电磁波信号,这些信号通过频率、强弱等方式表示 0 和 1。但是只靠 0 和 1 还不够,还必须约定:

  • 哪些 0 和 1 表示数据开始;
  • 哪些 0 和 1 表示数据结束;
  • 数据中哪一部分是地址;
  • 哪一部分是真正要传输的内容;
  • 出错之后应该怎么办;
  • 对方收到之后如何解析。

所以,协议就是通信双方共同遵守的数据格式和行为规则

💡协议就像两个人约定的“暗号”。如果我们都知道敲门三下表示“我来了”,那这个动作就有意义;但如果只有我知道这个规则,别人不知道,那敲门就只是普通声音。

2.2 只约定协议还不够,还要统一标准

一个一开始很容易想错问题:是不是只要通信双方约定好协议,就一定能通信?
当然不是!!!

比如我约定:

  • 数字 1 表示“同意”;
  • 数字 0 表示“拒绝”。

而你约定:

  • 数字 1 表示“拒绝”;
  • 数字 0 表示“同意”。

那么即使收到完全相同的数据,我们得出的结论也会完全相反。

因此,真正完善的网络协议必须具备两个特点:

  1. 规则足够细;
  2. 参与通信的所有设备都遵守统一标准。

这也是为什么网络协议需要标准化组织来制定和维护。常见的标准组织包括:

  • IEEE:制定了很多网络相关标准,比如 IEEE 802 系列;
  • ISO:提出了 OSI 七层模型;
  • ITU:负责电信领域标准;
  • IETF:负责互联网协议的制定和推广,很多协议通过 RFC 发布;
  • FCC:负责通信技术相关的管理和审查。
    这里我最大的感受是:协议不是某一台电脑自己说了算,而是整个网络世界为了互通而形成的共同规则。

3. 协议为什么要分层

3.1 分层的目的

协议分层是网络里面非常核心的思想。
协议通常被拆分成:物理层、数据链路层、网络层、传输层、应用层。协议分层的真正目的不是了方便记忆,而是为了解耦

假设两个人打电话通信,其实可以分成两个层次:

  • 语言层:两个人用中文、英文还是其他语言交流;
  • 通信设备层:使用手机、电话机还是对讲机传递声音。

如果通信设备从电话换成了手机,只要语言层不变,两个人仍然可以交流;如果语言从中文换成英文,只要通信设备正常,声音仍然可以传过去。
这就是分层的价值:每一层只关心自己的职责,并通过接口和上下层协作。

💡分层就像学校食堂:学生只负责点餐,窗口阿姨负责打饭,后厨负责做饭,采购负责买菜。每一层只做好自己的事,整个系统就能运转。如果后厨换了锅,只要菜还能正常做出来,学生其实不需要关心。

3.2 软件分层带来的好处

协议本质上也可以看作软件设计的一部分。既然是软件,就会面临复杂度问题。

分层的好处主要有:

  1. 降低复杂度
    每一层只处理一部分问题,不需要把所有细节都塞在一起。
  2. 提高可维护性
    某一层修改实现时,只要接口不变,就不会影响其他层。
  3. 便于标准化
    每一层都可以制定相对独立的协议标准。
  4. 便于替换底层技术
    比如应用层的 HTTP 不需要关心底层到底是网线、光纤还是 WiFi。

学到这里不难发现,网络协议分层其实和我们平时写代码时的模块化思想非常像。好的系统设计,都在尽量减少不同模块之间的强耦合。


4. OSI 七层模型和 TCP/IP 模型

4.1 OSI 七层模型

OSI 是 Open System Interconnection 的缩写,也就是开放系统互连参考模型。

OSI 七层模型把网络通信从逻辑上分成七层:

  1. 应用层
  2. 表示层
  3. 会话层
  4. 传输层
  5. 网络层
  6. 数据链路层
  7. 物理层

它最大的优点是把服务、接口和协议这些概念区分得比较清楚,理论上非常完整。
但是在实际工程中,OSI 七层模型并没有完全落地。原因是它比较复杂,而且会话层、表示层在很多实际系统中并没有非常独立地实现。
所以学习网络时,我们更多会结合 TCP/IP 模型来理解。

OSI 七层模型与 TCP/IP 模型对比图

4.2 TCP/IP 五层模型

TCP/IP 不是单独一个协议,而是一组协议的统称,也可以理解成 TCP/IP 协议簇。

TCP/IP 五层模型一般包括:

  1. 物理层
  2. 数据链路层
  3. 网络层
  4. 传输层
  5. 应用层

4.2.1 物理层

物理层负责光信号、电信号、电磁波等传输方式。

比如:

  • 网线;
  • 光纤;
  • WiFi;
  • 早期同轴电缆;
  • 集线器 Hub。

物理层决定了最大传输速率、传输距离、抗干扰能力等。

4.2.2 数据链路层

数据链路层负责相邻设备之间的数据帧传输和识别。

典型内容包括:

  • 网卡驱动;
  • MAC 地址;
  • 以太网帧;
  • 冲突检测;
  • 数据差错校验;
  • 交换机 Switch。

这一层解决的是“在同一个局域网内部,数据怎么交给下一跳设备”。

4.2.3 网络层

网络层负责地址管理和路由选择。
典型协议是 IP 协议。
网络层通过 IP 地址标识网络中的主机,并通过路由表规划数据传输路径。路由器 Router 工作在网络层。

这一层解决的是“跨网络传输时,数据应该往哪里走”。:

4.2.4 传输层

传输层负责两台主机之间的数据传输。

典型协议有:

  • TCP;
  • UDP。

传输层不仅关心主机之间的数据传递,还会进一步关注数据应该交给主机中的哪个进程。

4.2.5 应用层

应用层负责应用程序之间的通信。

常见协议包括:

  • HTTP;
  • HTTPS;
  • FTP;
  • SMTP;
  • Telnet;
  • DNS。

我们平时写网络程序,大多数时候直接面对的是应用层和 socket 编程接口。

4.3 TCP/IP 四层模型

在很多场景下,我们也会说 TCP/IP 四层模型,因为物理层通常更偏硬件,软件开发中考虑较少。

四层模型可以理解为:

  1. 网络接口层
  2. 网络层
  3. 传输层
  4. 应用层

一般来说:

  • 主机操作系统内核实现了传输层到物理层的大量内容;
  • 路由器主要实现网络层到物理层;
  • 交换机主要实现数据链路层到物理层;
  • 集线器只工作在物理层。
    当然,这不是绝对的。现在很多交换机也支持三层转发,很多路由器也能做端口转发等传输层相关功能。

5. 再识协议:协议到底是什么

5.1 TCP/IP 协议为什么存在

为什么要有 TCP/IP 协议?
刚开始我可能会回答:“因为网络通信需要规则。”
这个回答没错,但还不够深入。
真正理解之后,我觉得 TCP/IP 协议出现的根本原因是:通信双方的距离变远了,问题变复杂了。

在单机内部,其实也存在很多协议。例如:

  • CPU 和内存之间有内存访问相关协议;
  • 主机和磁盘之间有 SATA、IDE、SCSI 等协议;
  • 各种硬件设备和总线之间也有自己的通信规则。

只不过这些通信都发生在本地主机内部,距离短、环境可控、参与设备相对固定,所以我们平时感知不强。

但是网络通信不一样。主机之间可能隔着交换机、路由器、不同网络运营商,甚至跨越城市、国家。距离一变远,就会产生新的问题:

  • 数据怎么找到目标主机?
  • 中间经过多个网络设备怎么办?
  • 数据丢了怎么办?
  • 数据乱序怎么办?
  • 不同操作系统如何互通?
  • 不同厂商的硬件如何互通?

所以 TCP/IP 协议本质上是一整套网络通信问题的解决方案。

💡本地通信像宿舍里喊一声室友就能听见;网络通信像给另一个城市的人寄快递,需要地址、路线、中转站、快递单号、收件人信息等一整套规则。

5.2 协议可以理解成结构化数据类型

我觉得理解协议时,有一个非常朴素但很重要的角度:协议就是通信双方都认识的结构化数据类型。
假设主机 A 和主机 B 都使用同样的结构体:

// 这是一个非常简化的协议结构体示例
// 真实网络协议会更复杂,这里只是用来帮助理解“协议就是共同认识的数据格式”
struct protocol
{
    int a; // 字段 a:双方都知道这里存放第一个整数
    int b; // 字段 b:双方都知道这里存放第二个整数
    int c; // 字段 c:双方都知道这里存放第三个整数
};

如果主机 A 按照这个结构体组织数据:

struct protocol data;
// 按照约定好的结构体字段填充数据
// 主机 B 只要也使用同样的结构体格式,就能识别这些字段
data.a = 10;
data.b = 20;
data.c = 30;

那么主机 B 只要也知道这个结构体格式,就能按照同样的方式解析出:

// 接收方按照相同协议格式解析数据
// 因为双方对字段顺序和字段含义有共同认识,所以可以正确提取
a = 10;
b = 20;
c = 30;

这就是协议的感觉。

协议不是随便发一串二进制,而是双方都知道:

  • 哪些字段代表什么含义;
  • 每个字段占多少字节;
  • 字段顺序是什么;
  • 如何根据字段继续处理数据。

5.2.1 报头和有效载荷

在网络协议中,经常会看到两个概念:

  • 报头 header
  • 有效载荷 payload

可以这样理解:

报文 = 报头 + 有效载荷

报头 就是 当前协议层为了管理数据而添加的结构化字段有效载荷 才是 真正要交给上层或者对方的数据

💡快递单就是报头,包裹里的东西就是有效载荷。快递员看快递单决定怎么转发,但真正对收件人有价值的是包裹里面的东西。


6. 局域网通信原理

6.1 同一个局域网内主机能不能直接通信

在同一个局域网中,两台主机理论上可以直接通信。
但是这里有一个前提:每台主机在局域网内必须有唯一标识,否则数据发出去之后,其他主机不知道这份数据到底应该给谁。
在数据链路层中,这个唯一标识就是 MAC 地址。

6.2 认识 MAC 地址

MAC 地址用于识别数据链路层中相连的节点。

它的特点是:

  • 长度为 48 bit,就是 6 个字节;
  • 通常用十六进制加冒号表示,例如:08:00:27:03:fb:19
  • 一般在网卡出厂时确定;
  • 通常具有唯一性;
  • 某些虚拟机或特殊网卡可能允许修改 MAC 地址。

在 Windows 中可以通过下面命令查看:

# 查看本机网络配置信息
# /all 表示显示更详细的信息,包括 MAC 地址、IP 地址、网关、DNS 等
ipconfig /all

在 Linux 中可以使用:

# 查看网络接口信息
# ip addr 会显示网卡名称、MAC 地址、IP 地址等信息
ip addr

或者:

# ifconfig 在部分 Linux 发行版中可能需要额外安装 net-tools
# 它也可以查看网卡、IP 地址、MAC 地址等信息
ifconfig

6.3 碰撞域和以太网通信

在没有交换机的早期以太网中,多个主机共享同一条通信介质。任何时刻只允许一台机器向网络中发送数据。
如果多台主机同时发送,就可能发生数据干扰,这叫做数据碰撞。

因此,以太网需要进行:

  • 碰撞检测;
  • 碰撞避免;
  • 失败重发。

如果没有交换机,一个以太网就可以看作一个碰撞域。

💡这就像一间教室里很多人同时说话,如果大家一起喊,老师就听不清谁在说什么。所以必须有某种规则,比如一个人说完另一个人再说,否则信息就会互相干扰。

6.4 局域网中如何判断数据是不是发给自己的

在局域网通信过程中,主机收到数据之后,会根据目标 MAC 地址判断这份数据是不是发给自己的。
如果目标 MAC 地址和自己的 MAC 地址匹配,就接收并继续向上交付;如果不匹配,就丢弃。

综上,网络中很多数据并不是“只经过目标主机”,而是可能被多个设备看到,只是每个设备会根据协议字段决定是否处理。

局域网中通过 MAC 地址识别目标主机


7. 封装与解包

7.1 数据不是直接发送给对方应用程序的

在网络传输流程中,应用程序产生的数据,并不是直接发给对方应用程序的。

数据从应用层出发后,会一层一层向下交付:

应用层
 ↓
传输层
 ↓
网络层
 ↓
数据链路层
 ↓
物理层

每一层都会根据自己的协议添加对应的报头,这个过程叫做封装。

到达对方主机后,数据会从底层一层一层向上交付:

应用层
 ↑
传输层
 ↑
网络层
 ↑
数据链路层
 ↑
物理层

每一层会去掉自己对应的报头,并根据报头字段决定下一步交给谁,这个过程叫做解包和分用。

7.2 不同层的数据叫法不同

不同协议层对数据包的叫法不同:

  • 传输层:段 segment
  • 网络层:数据报 datagram
  • 数据链路层:帧 frame

应用层数据向下传递时,每一层协议都会添加一个首部 header。

例如:

应用层数据
 ↓
TCP 首部 + 应用层数据
 ↓
IP 首部 + TCP 首部 + 应用层数据
 ↓
以太网首部 + IP 首部 + TCP 首部 + 应用层数据 + 以太网尾部

这个过程就是封装。

7.3 解包时要解决两个问题

从今天开始,学习任何一个协议,我觉得都要先问两个问题:

  1. 这个协议如何做到解包?
  2. 这个协议如何把有效载荷交给上层协议?

比如 IP 协议收到一个数据报之后,它必须知道:

  • IP 首部多长;
  • 有效载荷从哪里开始;
  • 上层协议是 TCP、UDP 还是 ICMP;
  • 应该把有效载荷交给谁处理。

如果一个协议无法解包,也无法分用,那它就不能正常工作。

💡封装和解包很像寄快递。寄件时要一层层包装,外面贴快递单;收件时要先看快递单,再拆外包装,最后拿到真正的物品。

数据封装与解包流程图


8. 跨网络传输与 IP 地址

8.1 认识 IP 地址

IP 协议有 IPv4 和 IPv6 两个版本。在基础学习中,如果没有特别说明,通常默认讨论 IPv4。

IPv4 地址的特点:

  • 用来标识网络中的主机;
  • 本质是一个 4 字节、32 位的整数;
  • 通常使用点分十进制表示;
  • 例如:192.168.0.1
  • 每个点分部分表示一个字节,范围是 0 ~ 255

例如:

192.168.0.1

可以理解成 4 个字节:

192   168   0   1

8.2 为什么跨网络要经过路由器

如果两台主机不在同一个局域网中,数据通常不能直接到达目标主机,而是要经过一个或多个路由器。

既然数据最终要去目标主机,为什么不直接发过去?
跨网络传输时,当前主机只知道自己所在网络的情况,并不知道整个互联网的所有细节。路由器就像网络中的中转节点,负责根据目标 IP 地址选择下一跳路径。

所以 IP 地址的意义不仅是标识主机,还和路径选择有关。

💡跨网络通信就像从学校寄快递到另一个城市。你不需要知道每一段路怎么走,只需要写清楚最终地址,快递公司会通过中转站一步步转发过去。

8.3 IP 地址和 MAC 地址的区别

IP 地址和 MAC 地址都能表示某种“地址”,但它们的作用完全不同。

8.3.1 IP 地址是长远目标

IP 地址描述的是最终要到达的目标主机。
在一次网络传输过程中,源 IP 和目的 IP 通常保持不变。
也就是说,IP 地址更像是“最终目的地”。

💡可以把 IP 地址理解成唐僧西天取经时的目标——西天,无论途中经过多少国家、多少城池、多少驿站,唐僧的目标始终没变;网络中的 IP 地址也是如此,数据包从发送主机出发,可能经过很多路由器,但目的 IP 始终表示最终要到达的那台主机。

8.3.2 MAC 地址是下一跳目标

MAC 地址用于局域网内部转发。
当数据经过不同局域网时,每一跳的源 MAC 和目的 MAC 都可能变化。
也就是说,MAC 地址更像是“下一站交给谁”。

💡可以把 MAC 地址理解成唐僧西天取经路上每一站的带路人——虽然最终目标始终是西天,但每到一个国家、一个城池,都需要先找到下一位向导带路;网络中的 MAC 地址也是如此,数据包虽然最终要到达目标 IP,但在当前局域网中,它只关心下一步应该交给哪个设备,因此每经过一个路由器,源 MAC 和目的 MAC 都可能发生变化。

8.3.3 总结对比

对比项 IP 地址 MAC 地址
所属层次 网络层 数据链路层
作用范围 跨网络 局域网内部
主要作用 标识最终目标和路由选择 标识下一跳设备
传输过程中是否变化 通常不变 每一跳都会变化
类比 最终目的地 下一站中转点

跨网络传输中 IP 不变而 MAC 变化

8.4 IP 网络层的意义

网络层最大的意义,就是屏蔽不同底层网络的差异,为上层提供统一的 IP 通信服务。

底层可能是:

  • 以太网;
  • WiFi;
  • 光纤;
  • 令牌环;
  • 其他链路技术。

但只要上层统一使用 IP 协议,就可以屏蔽很多底层差异。

这也是网络分层带来的好处之一:下层可以变化,上层不一定需要跟着变化。


9. Socket 编程预备

9.1 数据到达主机不是最终目的

学到 IP 地址时,很容易认为数据只要到达目标主机,通信就结束了。
但后来我发现,这其实还不够。
因为数据最终不是给“主机”看的,而是给主机上的某个进程处理的。

比如:

  • 聊天消息要交给 QQ 或微信进程;
  • 网页数据要交给浏览器进程;
  • 下载数据要交给下载器进程;
  • SSH 数据要交给 sshd 进程。

所以网络通信真正的目标不是“主机到主机”,而是“进程到进程”。

💡主机就像一栋宿舍楼,IP 地址只能找到这栋楼。但真正要把快递送到谁手里,还需要房间号。端口号就有点像这个房间号。

9.2 认识端口号

端口号 port 是传输层协议中的概念。

它的特点是:

  • 端口号是一个 2 字节、16 位的整数;
  • 端口号用于标识一个网络进程;
  • 操作系统根据端口号决定数据交给哪个进程;
  • IP 地址 + 端口号可以标识某台主机上的某个进程;
  • 一个端口号同一时刻只能被一个进程占用;
  • 一个进程可以绑定多个端口号。

9.2.1 端口号范围

端口号范围可以分为:

0 ~ 1023:知名端口号
1024 ~ 65535:操作系统动态分配端口号

常见知名端口号:

协议 默认端口
HTTP 80
HTTPS 443
SSH 22
FTP 21

客户端程序通常会使用操作系统动态分配的端口号。

9.3 端口号和进程 ID 的区别

既然 PID 可以唯一标识一个进程,那为什么网络中不直接用 PID,而要再设计端口号呢?
答:PID 是操作系统进程管理中的概念,如果网络协议直接依赖 PID,就会让进程管理和网络通信强耦合;而端口号是传输层协议中的概念,它专门用于网络通信中的进程标识。

所以:

  • PID 用于操作系统内部管理进程;
  • port 用于网络通信中定位进程;
  • 两者都可以标识进程,但属于不同抽象层次。

这也是系统设计中非常典型的解耦思想。

9.4 源端口号和目的端口号

TCP 和 UDP 数据段中都有两个端口号:

  • 源端口号;
  • 目的端口号。

它们描述的是:数据是谁发的,要发给谁

结合 IP 地址,就可以得到四元组:

{srcIP, srcPort, dstIP, dstPort}

这个四元组可以唯一标识一次网络通信中的两个端点。

9.5 理解 socket

IP 地址用于标识互联网中的一台主机,端口号用于标识这台主机上的一个网络进程。
所以:

IP + Port = socket

socket 可以理解成网络通信中的端点。

再进一步说,网络通信的本质也是一种进程间通信,只不过它跨越了不同主机。

💡本地 IPC 像同一栋楼里两个房间传纸条,网络通信像两座城市里的两个人寄信。虽然距离不同,但本质都是进程之间在交换数据。


10. TCP 和 UDP 的初步认识

10.1 TCP 协议

TCP 全称是 Transmission Control Protocol,也就是 传输控制协议

它的基本特点:

  • 传输层协议;
  • 有连接;
  • 可靠传输;
  • 面向字节流。

这里先不深入 TCP 的三次握手、四次挥手、滑动窗口、拥塞控制等细节,只需要先建立直观认识:TCP 更强调可靠性

10.2 UDP 协议

UDP 全称是 User Datagram Protocol,也就是 用户数据报协议

它的基本特点:

  • 传输层协议;
  • 无连接;
  • 不可靠传输;
  • 面向数据报。

UDP 不保证数据一定到达,也不保证顺序,但它开销小、速度快,适合一些对实时性要求较高、能容忍少量丢包的场景。

10.3 TCP 和 UDP 简单对比

对比项 TCP UDP
是否连接 有连接 无连接
可靠性 可靠 不可靠
数据形式 面向字节流 面向数据报
开销 较大 较小
典型场景 文件传输、网页、登录 视频通话、直播、DNS

11. 网络字节序

11.1 为什么需要网络字节序

之前学习内存时,我知道多字节数据在内存中有大端和小端之分。
比如一个 32 位整数:

0x12345678

在不同机器上的存储顺序可能不同。

如果网络通信双方一台是大端机,一台是小端机,并且双方直接按照本机内存格式发送数据,那么接收方可能会解析错误。
所以 TCP/IP 协议规定:网络数据流必须采用大端字节序。

也就是说,所有发送到网络上的多字节数据,都要转换成网络字节序。

💡这就像不同地区的人写日期格式不同,有的人写“年-月-日”,有的人写“月-日-年”。为了避免误会,网络规定统一格式,所有人发送前都先转换成统一格式。

11.2 主机字节序和网络字节序转换函数

为了让网络程序具有可移植性,C 语言提供了一组字节序转换函数。

函数原型如下:

#include <arpa/inet.h>
// h 表示 host,代表主机字节序
// n 表示 network,代表网络字节序
// l 表示 long,通常用于 32 位整数
// s 表示 short,通常用于 16 位整数
uint32_t htonl(uint32_t hostlong);
// host to network long
// 将 32 位整数从主机字节序转换为网络字节序
// 常用于 IP 地址等 32 位数据的转换
// 参数:hostlong,主机字节序下的 32 位整数
// 返回值:网络字节序下的 32 位整数
uint16_t htons(uint16_t hostshort);
// host to network short
// 将 16 位整数从主机字节序转换为网络字节序
// 常用于端口号等 16 位数据的转换
// 参数:hostshort,主机字节序下的 16 位整数
// 返回值:网络字节序下的 16 位整数
uint32_t ntohl(uint32_t netlong);
// network to host long
// 将 32 位整数从网络字节序转换为主机字节序
// 接收网络数据后,如果要在本机使用,就需要转换回来
// 参数:netlong,网络字节序下的 32 位整数
// 返回值:主机字节序下的 32 位整数
uint16_t ntohs(uint16_t netshort);
// network to host short
// 将 16 位整数从网络字节序转换为主机字节序
// 常用于解析收到的端口号
// 参数:netshort,网络字节序下的 16 位整数
// 返回值:主机字节序下的 16 位整数

11.2.1 函数命名规律

这些函数名其实很好记:

h = host
n = network
l = long
s = short

所以:

htonl = host to network long
htons = host to network short
ntohl = network to host long
ntohs = network to host short

11.2.2 使用场景

在 socket 编程中,端口号通常需要使用 htons

server_addr.sin_port = htons(8080);
// 8080 是主机字节序下的端口号
// sin_port 要求保存网络字节序
// 所以必须使用 htons 转换

IP 地址也需要按网络字节序存储,通常使用相关函数转换。
如果忘记转换,程序可能在某些机器上正常,在另一些机器上异常,这种问题非常隐蔽。


12. socket 编程接口

12.1 socket 常见 API

网络编程中,最核心的一组接口就是 socket API。

常见函数原型如下:

// 创建 socket 文件描述符
// domain:协议域,例如 AF_INET 表示 IPv4
// type:套接字类型,例如 SOCK_STREAM 表示 TCP,SOCK_DGRAM 表示 UDP
// protocol:具体协议,一般填 0,让系统根据 domain 和 type 自动选择
// 返回值:成功返回一个文件描述符,失败返回 -1,并设置 errno
int socket(int domain, int type, int protocol);
// 绑定端口号和 IP 地址
// socket:socket 函数返回的文件描述符
// address:指向地址结构体的指针,通常需要将 sockaddr_in* 强转为 sockaddr*
// address_len:地址结构体大小
// 返回值:成功返回 0,失败返回 -1,并设置 errno
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
// 开始监听 socket
// socket:已经创建并绑定好的 socket 文件描述符
// backlog:全连接队列的最大长度
// 返回值:成功返回 0,失败返回 -1,并设置 errno
// listen 主要用于 TCP 服务器
int listen(int socket, int backlog);
// 接收客户端连接
// socket:处于监听状态的 socket
// address:用于保存客户端地址信息
// address_len:输入输出参数,传入地址结构体大小,返回实际地址长度
// 返回值:成功返回新的连接 socket,失败返回 -1
// 注意:accept 返回的新 socket 才是真正用于和客户端通信的 socket
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
// 建立连接
// sockfd:客户端 socket 文件描述符
// addr:服务器地址结构体
// addrlen:服务器地址结构体大小
// 返回值:成功返回 0,失败返回 -1
// connect 主要用于 TCP 客户端主动连接服务器
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

12.2 socket 函数理解

socket 函数本质上是在创建一个网络通信端点。

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// AF_INET 表示 IPv4
// SOCK_STREAM 表示面向字节流,通常对应 TCP
// 0 表示让系统自动选择默认协议
// sockfd 是返回的 socket 文件描述符

这句代码的含义是:

创建一个 IPv4 + TCP 类型的 socket

这里要注意,Linux 中 socket 也被抽象成文件描述符,所以后续可以通过类似文件 IO 的方式进行读写。

💡socket 文件描述符有点像“网络通信的入口编号”。它不是数据本身,而是内核帮我们维护的一份网络通信资源的索引。

12.3 bind 函数理解

服务器端通常需要绑定固定端口。

bind(sockfd, (const struct sockaddr*)&server_addr, sizeof(server_addr));
// sockfd:socket 文件描述符
// server_addr:IPv4 地址结构体
// 强制转换为 struct sockaddr* 是为了适配通用 socket API
// sizeof(server_addr):告诉内核地址结构体的实际大小

这里有一个很关键的点:我们实际使用的是 sockaddr_in,但 bind 接口要求的是 struct sockaddr*,所以需要强制类型转换。

这体现了 socket API 的 通用性它不只支持 IPv4,也可以支持 IPv6、UNIX Domain Socket 等不同地址类型

12.4 listen 和 accept 理解

TCP 服务器通常会经历:

socket → bind → listen → accept

其中:

  • listen:表示服务器开始监听客户端连接;
  • accept:从连接队列中取出一个已经建立好的连接。

容易混淆的是:监听 socket 不直接和客户端通信,accept 返回的新 socket 才用于通信

这也是很多初学者容易想错的地方。

12.5 connect 理解

客户端通常会经历:

socket → connect

connect 用于主动向服务器发起连接。

如果是 TCP,connect 背后会触发 TCP 三次握手。也就是说,应用层的一句 connect,其实会让内核协议栈做很多工作。


13. sockaddr 结构

13.1 为什么需要 sockaddr

socket API 是一套抽象接口,它需要支持不同底层协议,比如:

  • IPv4;
  • IPv6;
  • UNIX Domain Socket。

但是不同协议的地址格式并不一样,为了让接口统一,socket API 使用了通用的 struct sockaddr* 指针。
真正写 IPv4 程序时,我们通常使用 struct sockaddr_in,然后强制转换为 struct sockaddr*

13.2 sockaddr 结构

struct sockaddr
{
    sa_family_t sa_family;
    // 地址类型
    // 例如 AF_INET 表示 IPv4
    // AF_INET6 表示 IPv6
    // AF_UNIX 表示 UNIX Domain Socket
    char sa_data[14];
    // 地址数据
    // 这里只是一个通用占位
    // 不同协议族的具体地址格式不同
};

struct sockaddr 是通用地址结构,主要用于统一 socket API 的参数类型。
但是它本身并不适合直接描述 IPv4 的详细信息,所以 IPv4 编程中更常用 sockaddr_in

13.3 sockaddr_in 结构

struct sockaddr_in
{
    sa_family_t sin_family;
    // 地址类型
    // IPv4 中设置为 AF_INET
    in_port_t sin_port;
    // 端口号
    // 注意:这里必须使用网络字节序
    // 所以通常要使用 htons(port) 进行转换
    struct in_addr sin_addr;
    // IPv4 地址
    // 本质上是一个 32 位整数
    // 通常也需要按照网络字节序存储
    unsigned char sin_zero[sizeof(struct sockaddr) - 
                           sizeof(sa_family_t) - 
                           sizeof(in_port_t) - 
                           sizeof(struct in_addr)];
    // 填充字段
    // 主要是为了让 sockaddr_in 和 sockaddr 结构体大小保持一致
};

这里最重要的三部分是:

  1. 地址类型;
  2. 端口号;
  3. IP 地址。

13.4 in_addr 结构

struct in_addr
{
    in_addr_t s_addr;
    // IPv4 地址
    // 本质是一个 32 位整数
    // 一般保存的是网络字节序形式的 IP 地址
};

in_addr 用于表示 IPv4 地址。
虽然我们平时看到的是 192.168.0.1 这种点分十进制格式,但在底层,它最终还是会被转换成一个 32 位整数。

13.5 sockaddr、sockaddr_in、sockaddr_un 的区别

结构体 使用场景 特点
sockaddr 通用地址结构 socket API 统一使用
sockaddr_in IPv4 地址结构 包含地址类型、端口号、IPv4 地址
sockaddr_un UNIX Domain Socket 用于本机进程间通信,包含路径名

sockaddr、sockaddr_in、sockaddr_un 结构对比图


14. 一个简单的 TCP 服务器流程理解

14.1 简化版 TCP 服务器代码

#include <stdio.h>      // printf、perror 等标准输入输出函数
#include <stdlib.h>     // exit 函数,用于异常退出进程
#include <string.h>     // memset 函数,用于清空结构体
#include <unistd.h>     // close 函数,用于关闭文件描述符
#include <arpa/inet.h>  // sockaddr_in、htons、htonl 等网络相关函数
#include <sys/socket.h> // socket、bind、listen、accept 等 socket API
int main()
{
    // 1. 创建 socket
    // AF_INET 表示使用 IPv4
    // SOCK_STREAM 表示使用面向字节流的 TCP
    // 第三个参数填 0,表示让系统自动选择协议
    int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_sock < 0)
    {
        // socket 创建失败时打印错误原因
        perror("socket");
        exit(1);
    }
    // 2. 定义服务器地址结构体
    struct sockaddr_in server_addr;
    // 将结构体清零,避免未初始化字段带来问题
    memset(&server_addr, 0, sizeof(server_addr));
    // 设置地址类型为 IPv4
    server_addr.sin_family = AF_INET;
    // 设置端口号
    // 8080 是主机字节序,需要通过 htons 转换为网络字节序
    server_addr.sin_port = htons(8080);
    // 设置 IP 地址
    // INADDR_ANY 表示绑定本机所有网卡地址
    // htonl 用于将 32 位整数转换为网络字节序
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // 3. 绑定 socket 和地址信息
    // bind 的第二个参数要求 struct sockaddr*
    // 但 IPv4 实际使用的是 struct sockaddr_in
    // 所以这里需要进行强制类型转换
    if (bind(listen_sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0)
    {
        perror("bind");
        close(listen_sock);
        exit(2);
    }
    // 4. 开始监听
    // backlog 表示连接队列长度
    // listen 之后,这个 socket 就从普通 socket 变成监听 socket
    if (listen(listen_sock, 5) < 0)
    {
        perror("listen");
        close(listen_sock);
        exit(3);
    }
    printf("server is listening on port 8080...\n");
    while (1)
    {
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);
        // 5. 接收客户端连接
        // accept 会返回一个新的 socket
        // 这个新 socket 才是真正用于和客户端通信的 socket
        int service_sock = accept(listen_sock,
                                  (struct sockaddr*)&client_addr,
                                  &client_len);
        if (service_sock < 0)
        {
            perror("accept");
            continue;
        }
        printf("new client connected\n");
        // 这里可以使用 read/write 或 recv/send 和客户端通信
        // 为了突出流程,这里暂时直接关闭连接
        close(service_sock);
    }
    // 实际上服务器一般不会执行到这里
    close(listen_sock);
    return 0;
}

14.2 代码理解

这段代码把 TCP 服务器的基本流程串了起来:

socket → bind → listen → accept

每一步的含义是:

  1. socket:创建网络通信端点;
  2. bind:绑定 IP 地址和端口号;
  3. listen:进入监听状态;
  4. accept:接收客户端连接;
  5. close:关闭 socket 文件描述符。

这里最容易踩坑的地方有几个:

  • 端口号必须使用 htons 转换;
  • bind 参数需要强制类型转换;
  • accept 返回的新 socket 才用于通信;
  • listen_sock 是监听 socket,不是服务 socket;
  • 端口被占用时,bind 会失败。

💡监听 socket 就像前台接待窗口,负责接收新来的客人;accept 返回的新 socket 就像分配给客人的服务人员,真正和客人交流的是服务人员,不是前台窗口本身。


15. 精选技术题 / 面试题

15.1 什么是协议?为什么网络通信需要协议?

协议本质是通信双方共同遵守的约定,包括数据格式、字段含义、处理规则等。
网络通信需要协议,是因为计算机之间传输的本质是二进制数据。如果没有统一约定,接收方无法知道这些二进制数据应该如何解析。
更深一点说,网络通信中存在不同厂商、不同操作系统、不同硬件设备,如果没有统一协议标准,就无法实现互联互通。

15.2 为什么网络协议要分层?

分层的核心目的是解耦。
每一层只负责自己的功能,并通过接口和上下层协作。这样可以降低系统复杂度,提高可维护性,也方便不同层独立升级和替换。
比如应用层 HTTP 不需要关心底层是网线还是 WiFi,网络层 IP 也不需要关心应用层传输的是网页还是文件。

15.3 OSI 七层模型和 TCP/IP 模型有什么区别?

OSI 七层模型是理论参考模型,分层更细,包括应用层、表示层、会话层、传输层、网络层、数据链路层和物理层。
TCP/IP 模型更偏工程实践,常见为五层或四层。五层包括应用层、传输层、网络层、数据链路层和物理层。
实际网络通信中,TCP/IP 协议族应用更广。

15.4 IP 地址和 MAC 地址有什么区别?

IP 地址属于网络层,用于标识网络中的主机,并参与路由选择。它更像是最终目的地。
MAC 地址属于数据链路层,用于局域网内部相邻节点之间的数据转发。它更像是下一跳目标。
在跨网络传输中,源 IP 和目的 IP 通常保持不变,而源 MAC 和目的 MAC 会随着每一跳链路变化。

15.5 为什么数据到达主机还不够?

因为数据最终是给主机上的某个进程处理的,而不是给主机本身处理的。
IP 地址只能定位到某台主机,端口号才能定位到主机上的某个网络进程。
所以网络通信本质上是进程之间的通信,而不是简单的主机之间通信。

15.6 端口号和 PID 有什么区别?

PID 是操作系统内部用于管理进程的标识。
端口号是传输层协议中用于网络通信的进程标识。
虽然两者都能标识进程,但它们属于不同层次。网络通信没有直接使用 PID,是为了避免操作系统进程管理和网络协议强耦合。

15.7 什么是 socket?

socket 可以理解成网络通信中的端点。
从组成上看:

socket = IP 地址 + 端口号

在一次通信中,四元组:

{srcIP, srcPort, dstIP, dstPort}

可以唯一标识通信双方。
从 Linux 编程角度看,socket 也是一种文件描述符,用户可以通过 socket API 让应用程序使用内核中的网络协议栈完成通信。

15.8 TCP 和 UDP 有什么区别?

TCP 是有连接、可靠传输、面向字节流的传输层协议。
UDP 是无连接、不可靠传输、面向数据报的传输层协议。
TCP 更适合文件传输、网页访问等要求可靠性的场景;UDP 更适合直播、语音、视频、DNS 等对实时性要求更高的场景。

15.9 为什么网络字节序采用大端?

不同主机可能采用不同的主机字节序,如果直接按照本机格式发送多字节数据,接收方可能解析错误。
为了保证不同机器之间能够正确通信,TCP/IP 规定网络字节序统一采用大端字节序。
发送前需要将主机字节序转换成网络字节序,接收后再转换回主机字节序。

15.10 为什么 bind 的参数是 sockaddr*,但 IPv4 编程用 sockaddr_in?

因为 socket API 是通用接口,不只支持 IPv4,还支持 IPv6、UNIX Domain Socket 等不同协议族。
不同协议族的地址结构不同,所以接口统一使用 struct sockaddr* 表示通用地址。
而 IPv4 具体地址信息使用 struct sockaddr_in 保存,因此实际调用时需要强制类型转换。


结语

学完网络基础这一部分之后,我最大的感受是:很多以前觉得理所当然的网络现象,背后其实都有非常清晰的系统设计。
比如,为什么数据能从一台主机到另一台主机?因为有 IP 地址和路由选择。为什么同一个局域网内部能找到目标设备?因为有 MAC 地址。为什么数据到达主机后还能交给正确的应用程序?因为有端口号。为什么不同操作系统、不同厂商设备之间能通信?因为大家遵守统一的网络协议。

更重要的是,我慢慢意识到,网络通信并不是孤立的知识点,它和操作系统、进程、文件描述符、内核协议栈、系统调用都有联系。尤其是 socket 编程,本质上就是用户程序通过系统调用,使用内核中的网络协议栈完成进程间通信。

所以这部分内容不能只背概念,而要建立整体视角:协议解决通信规则问题,分层解决复杂度问题,封装和解包解决数据传递问题,IP 和 MAC 解决寻址与转发问题,端口号和 socket 解决进程级通信问题。

真正理解这些之后,再去学习 TCP、UDP、HTTP、ARP、路由、NAT、滑动窗口等内容,就不会只是零散记忆,而是能把它们放到整个网络体系中去理解。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐