Linux网络编程实战1.从五层模型到Socket编程(个人学习/复习使用 有错误欢迎指正)

文章大纲

文章大纲

一、从登录游戏开始:一次网络请求的旅程

  • 游戏登录场景引入:点击按钮背后的网络旅程

  • 从物理视角看数据流向:我的电脑 → 路由器 → 骨干网 → 华纳服务器

  • 带着核心悬念出发:数据遵循什么规则流动?如何防丢?到达后交给谁?

二、Linux 网络基础概念

  • 2.1 初识协议:为什么需要统一的通信规则

    • 从本地进程通信到跨网络通信的演进

    • 协议的本质:发送方与接收方的“语言约定”

  • 2.2 数据传输的四个核心问题与五层模型

    • Q1:相邻设备间通信(数据链路层:MAC 地址与数据帧)

    • Q2:目标主机定位与路径选择(网络层:IP 地址与路由转发)

    • Q3:数据传输可靠性(传输层:TCP/UDP 与端口号封装)

    • Q4:数据语义理解(应用层:从二进制到业务逻辑的自定义协议)

  • 2.3 完整通信流程示例

    • 从点击“查看地图”到服务器响应的完整五层协作演示

    • 各层级职责与解决方案对照总结表

三、Socket(套接字):应用程序的网络门票

  • 3.1 引入:再谈端口号

    • 网络通信为什么不用 PID?解耦思想在系统底层的体现

  • 3.2 sockfd:你手里的那张门票

    • Linux “一切皆文件”思想在网络层面的映射

    • 核心链路揭秘:sockfdstruct filestruct socketstruct sock → 网卡

  • 3.3 sockaddr_in 与 bind:给 Socket 一个真正的身份

    • bind() 的作用与 sockaddr_in 结构体字段详解

  • 3.4 为什么 bind() 只认识 sockaddr?

    • 多种 Socket 地址结构与 C 语言中朴素的“多态”强转设计

  • 3.5 字节序转换:为什么网络通信需要进行数据转换?

    • 3.5.1 大端与小端的“恩怨情仇”及本地机器测试实验

    • 3.5.2 核心释疑:字节序转换究竟发生在哪个阶段?

    • 3.5.3 htonshtonl 等四个核心转换接口拆解

  • 3.6 数据终于开始流动了:sendto 与 recvfrom

    • 3.6.1 sendto():把数据交给网络(发什么、发给谁、用哪个发)

    • 3.6.2 recvfrom():从网络中取回数据(输出型参数与 UDP 无连接特性分析)

四、一封来自霍格沃兹的信:数据究竟是怎样到达程序中的?

  • 完整协议栈旅行:从 sendto() 到电信号,再到 recvfrom()

  • 第一章总结:打通应用程序与内核协议栈的通信闭环

仓库地址(开发中):GitHub - stellablack528/arcane-campus-online: A multiplayer online text RPG social platform built with C++ and Linux network programming · GitHub

一、从登录游戏开始:一次网络请求的旅程

相信读者看到专栏题目就能知道笔者是一个哈迷。当然是一个计科专业的哈迷

第一次打开《霍格沃兹之遗》的时候,大多数玩家想到的可能是:

  • 霍格沃兹城堡;
  • 飞天扫帚;
  • 魔法课程;
  • 以及那句熟悉的:

You're a wizard.

但是当我学了linux网络编程之后,我开始想到别的东西——

我电脑本地开着《霍格沃兹之遗》的游戏进程,

我点击了登录按钮。我的电脑到底是怎么找到华纳服务器的?

数据又是怎么跨越半个地球,从广州跑到美国机房的?

服务器收到消息之后,又是怎么知道:

这条数据应该交给《霍格沃兹之遗》的游戏进程,而不是交给数据库、网站或者邮件服务器?

如果去搜资料,往往会得到这些接口的信息:

socket();
bind();
listen();
accept();

但至于为什么需要这些接口,它们到底解决了什么问题,第一次接触的时候难免觉得干巴巴的,难以应用。 于是我写了这个项目,帮助自己看看互联网世界里的数据,到底是怎样一步一步流动起来的。

先从物理上来看,数据是如何从玩家所在地到达华纳服务器的(这里不是严格准确的描述,只是帮助理解):

当玩家点击按钮的时候,数据大概会经历这样的一段旅程(这里说的不是准确的,只是帮助理解):
我的电脑

家庭路由器

运营商网络

骨干网路由器

海外运营商网络

目标机房

华纳服务器

这个可以抽象成我们日常生活中拿着学生证在对应的学校里面找人一样。我捡到了一个信工学院的学生卡,要去给对应的老师,我就在路上问同学,信工学院在哪?同学给我指路了;我来到信工学院后,我要找网安专业的,又去问对应的同学给我指路,最终我找到了对应的专业办公室提交了这个失物。里面这些指路的同学们就相当于路由器,不过现实中的路程要更远更复杂路由器更多更复杂罢了。

那么,数据在这条路上到底遵循着怎样的规则流动? 每一跳之间,设备和设备是怎么互相认识的? 数据万一丢了怎么办? 到达目的地之后,又交给谁处理? 带着这几个问题,我们进入正题。

二、Linux网络基础概念

2.1初识协议

学计算机网络的时候,最先碰到的词之一就是 protocol(协议)。 接着就是它的定义、它的特点……但至少就我个人而言,这样听下来很难真正理解,因为从一个互联网使用者变成开发者,中间隔着一道很厚的墙。 所以要真正认识协议,得先知道为什么需要它。

所以要真正认识协议,得先知道为什么需要它。

最早的计算机程序,做的都是“本地进程间通信”——同一台机器上的进程互相传数据,用信号、管道、共享内存就够了。

但随着需求发展,出现了“跨网络通信”的需求:我的程序要和另一台机器上的程序说话。

问题来了:如果不同厂商、不同系统都用自己的一套通信方式,那就会非常混乱,经济成本会大幅增加。 这就像硬件领域一样——不同厂商生产的 CPU、显卡,之所以能插进同一块主板,是因为大家都遵守了同一套接口标准。

想象一下,如果硬件协议各家不同,会有多复杂? 网络通信也一样。 于是大家约定好:数据怎么打包、怎么传输、怎么解析,都按照统一的规则来。 协议,本质上是一种约定。

从程序员角度看,它可以先简单理解成:

发送方和接收方提前约定好的一套数据格式。协议描述的是规则而代码去实现规则

例如:

std::string request =
    "GET / HTTP/1.1\r\n"
    "Host: example.com\r\n"
    "\r\n";
send(sockfd, request.c_str(), request.size(), 0);

这里代码就在“实现 HTTP 协议”(仅展示现在不用深究)。

2.2数据传输的四个核心问题与五层模型

现在回到刚才那条路:数据从我的电脑出发,最终到达华纳服务器。 这条路上,有四个核心问题需要解决。 计算机网络的五层模型(tcp/ip四层模型),其实就是针对这四个问题给出的答案。 (物理层负责最底层的比特传输,我们这里不展开。)

Q1:相邻两个设备间的通信是如何实现的?(数据链路层)

在真实网络中,数据不会直接从我的电脑瞬间到达华纳服务器,中间一定会经过很多设备。

但是网络设备并不会关心完整的传输路线。

我的电脑并不知道华纳服务器中间经过多少个路由器,也不需要知道。

对于我的电脑来说,它只需要完成一件事情:把数据交给当前这个网络中的下一个设备。

因此,对于网络来说,第一个需要解决的问题其实是:​
相邻两个设备之间,数据应该如何传输?​

A1:依靠 MAC 地址(物理地址) 和 数据帧(Frame)

在同一个局域网中,设备之间通过一种叫做 MAC 地址 的地址进行标识。每一块网卡都有一个唯一的 MAC 地址,它类似于设备在当前网络中的身份标识。

例如:

我的电脑:
MAC 地址:AA-AA-AA-AA-AA-AA
家庭路由器:
MAC 地址:BB-BB-BB-BB-BB-BB

当我的电脑准备访问华纳服务器时,它并不会直接把数据发送到遥远的服务器,而是首先需要将数据交给当前网络中的下一台设备,也就是家庭路由器。

因此,在数据发送之前,数据链路层会在网络层的数据基础上添加自己的信息,封装成一个数据帧(Frame)。

其中包含:目标 MAC 地址:
家庭路由器的 MAC 地址

源 MAC 地址:
我的电脑的 MAC 地址

这样,数据就可以在当前局域网中准确找到下一台设备。

但值得注意的是:每经过一个路由器mac就会改变,因为mac地址只负责当前这一跳的通信,当目的mac对应的路由器收到数据之后,它会拆开原来的数据帧,根据下一跳设备重新封装新的数据帧。

这就是五层模型中的 数据链路层(Data Link Layer) 负责的核心任务:解决局部网络内、相邻节点间的点对点数据交付。

Q2:目标主机定位以及路径选择问题如何解决?(网络层)

通过对上一问题的探讨,我们知道了数据离开我的家庭局域网后,是如何通过 MAC 地址去找到下一跳设备,从而完成一个又一个“相邻两个设备之间通信”的过程。

新的问题来了:

MAC 地址虽然能找到相邻的设备,但它自带“局限性”——它只在同一个局域网(同一条链路)内有效,并且 MAC 地址本身没有网络层次结构。

如果整个互联网只依靠 MAC 地址通信,那么面对全球数十亿台设备,网络设备根本无法知道:

  • 哪个设备属于哪个网络?
  • 目标设备距离自己有多远?
  • 数据应该经过哪些网络才能到达目标?

A2:因此,要完成一次跨越多个网络的数据传输,我们还需要一种能够描述设备在整个互联网中的位置的地址。

这就是大名鼎鼎的:

IP 地址(Internet Protocol Address,互联网协议地址)。

MAC 地址解决的是:

当前这一跳,数据应该交给哪个设备?

而 IP 地址解决的是:

这份数据最终应该送到哪一个主机?

现在我们知道了,可以通过ip地址,来确定“寄件地址以及收件地址”,mac地址则像是包裹在途径各个中转站时,负责当前这一小段路程的“快递车牌号”。每到一个中转站(路由器),包裹都要被搬上一辆新车,换一个新车牌(重写 MAC 地址)来帮助完成运输。

但是,就像送快递一样,传输的过程对于用户来说肯定是越快越好,那么,在错综复杂的全球网络中,路由器怎么知道下一步该往哪条路走才能离华纳服务器更近?

靠的是 路由表(Routing Table)。 当路由器收到一个数据包时,它会提取出里面的目标 IP 地址,然后去查自己的“路由表”(就像是在看导航地图)。路由表经过计算后会告诉它:“要去华纳服务器所在的那于是,路由器把数据重新封装,填上 B 路由器的 MAC 地址,扔给下一跳。这个过程不断接力(专业术语叫路由转发),个网段,当前的最优路线是把数据交给前面那个出口的 B 路由器。”直到数据到达目标 IP 所在的最终局域网。

这就是五层模型中的 网络层(Network Layer) 负责的核心任务:解决全局寻址(IP地址)和路径选择(路由转发)的问题,确保数据能跨越不同的子网,从源主机顺利导航到目标主机。

现在我们终于知道从我的家庭路由器是则如何靠着ip地址,mac地址加上了路由器的努力走到每过的华纳服务器了,总算能松一口气了吧?

实则不然,这个路程这么长,要是传输过程中某个路由器突然故障了,或者网络拥塞导致数据包被丢弃了怎么办?

所以,这就引出了第三个问题——

Q3:如果传输过程中数据丢失、损坏或乱序了,该怎么办?(传输层)

A3:依靠 传输层(Transport Layer) 协议

传输层并不关心数据具体经过了哪些路由器。

它关注的是:

两个主机上的应用程序之间,如何进行可靠的数据传输。

例如:

我的电脑上运行着《霍格沃兹之遗》客户端,华纳服务器上运行着游戏服务器程序。

传输层需要解决:

  • 哪个程序发送的数据?
  • 哪个程序接收的数据?
  • 数据是否完整?
  • 数据是否按照正确顺序到达?

为了实现这些功能,传输层提供了不同的协议。

其中最重要的两个:

TCP(Transmission Control Protocol,传输控制协议):

一个追求绝对可靠的协议。在发送数据前,它必须先和对方建立连接(也就是常说的三次握手)。传输过程中,它会给每个数据包打上序号,如果发现丢包就自动重传,乱序了就重新组装。虽然步骤繁琐、速度稍慢,但能确保数据一个不漏、顺序不乱地送达。

UDP(User Datagram Protocol,用户数据报协议):

一个追求极致速度的协议。它不需要建立连接,抓起数据直接就往外扔。它不保证数据一定能送达,也不管包的先后顺序,丢了就丢了。虽然不可靠,但它没有繁琐的确认机制,传输效率极高。

在实际应用中,TCP 和 UDP 并没有绝对的优劣之分,它们只是针对不同需求做出的不同选择。

如果数据的价值在于“完整准确”,那么通常会选择 TCP。

例如:

  • 浏览网页;
  • 下载文件;
  • 登录账号;
  • 银行转账;
  • 保存游戏角色数据;
  • 发送邮件。

这些场景中,任何一部分数据丢失都可能造成严重影响。

例如发送邮件时,如果正文缺少一部分,或者附件传输失败,这封邮件就失去了意义。因此,可靠性比传输速度更加重要。

而如果数据的价值在于“实时状态”,那么 UDP 往往更加合适。

例如:

  • 语音通话;
  • 视频会议;
  • 在线游戏中的实时同步(比如王者荣耀之类的)。

在这些场景中,延迟比完整性更加重要。

例如玩一款多人在线游戏时,玩家的位置变化会不断发送给服务器:

“我现在移动到了哪里。”

如果某一次位置更新因为网络问题丢失,服务器很快还会收到下一次位置数据。

相比等待丢失的数据重新发送,导致角色出现明显延迟,快速同步最新状态更加重要。

因此,很多实时游戏会选择 UDP 来传输高频状态数据。

在回到 Arcane Campus Online 这个项目中,我们之后也会根据不同数据的特点选择不同的通信方式。

例如:

玩家在霍格沃兹校园中的聊天消息:

“有人要一起去图书馆吗?”

这类文本信息需要保证完整到达,不能出现消息缺失,因此更适合使用 TCP 长连接。

而活点地图这个功能——如果我们要实现全校同学位置的实时动态刷新,它就更适合使用 UDP

想象一下,活点地图上代表每个玩家的小墨迹在城堡里不断移动,服务器需要高频(比如每秒几十次)向客户端广播所有人的最新坐标。如果某一个坐标数据包在传输过程中不小心丢了,我们完全不需要 TCP 那套死板的重传机制。因为零点几秒后,下一个包含最新位置的坐标包就会发过来。相比于为了等一个迟到的旧坐标而让地图上的墨迹出现卡顿、瞬移,快速刷新出当前的最新状态才是最重要的。

那么,传输层除了选择 TCP 或 UDP,还要怎么实现“区分不同的程序”呢?

靠的是 传输层报头(Header)。 当你的游戏数据准备交出去时,传输层会在数据的前面垫上一块额外的信息。这个信息里最核心的就是 端口号(Port)

前面提到过跨网络通信就是两个不同机器上的两个进程进行进程间通信。这两个进程有自己的端口号。端口号+ip可以标记互联网唯一的一个进程。比如:

  • 源端口:标记是你电脑上的《霍格沃兹之遗》客户端发的。

  • 目的端口:标记要交给华纳服务器上的游戏服务端程序。

这个在数据前面“加塞”额外管理信息的操作,就叫做封装报头。不管是 TCP 还是 UDP,都会有自己专属的报头,里面装着帮它们实现可靠性、流量控制或识别程序的各种数据字段(这个我们在后面讲 Socket 编程时会详细拆解)。

这就是五层模型中的 传输层(Transport Layer) 负责的核心任务:解决进程到进程(Process-to-Process)之间的通信,并根据业务需求提供可靠(TCP)或快速(UDP)的端到端数据交付。

Q4:作为接收方,如何理解和处理到达的数据,从而达到网络通信的目的?(应用层)

经过前面三个问题,我们已经知道:

  • 数据链路层解决了相邻设备之间的数据传输;
  • 网络层解决了跨网络的数据定位和路径选择;
  • 传输层解决了不同主机之间进程通信以及数据可靠性问题

但是,这些都是网络通信的手段,并不是目的。我们做网络通信的真正目的是,服务器收到数据后,数据能够被真正理解,再做出对应的回应,这才是真正的“通信”。

所以,网络通信最后需要解决的问题是:

接收方如何理解收到的数据,并根据数据完成对应的业务操作?

A4:依靠 自定义应用层协议 或 统一的应用层标准

为什么必须要这一层?因为操作系统内核的传输层(TCP/UDP)送到应用程序手里的,本质上只是一串二进制字节流(Byte Stream),操作系统并不知道它代表什么。

例如,服务器收到:

01001000 01100101 01101100 01101100 01101111

操作系统只能知道:

有一段数据到达了。

但是服务器程序不知道:

这是玩家聊天消息?

还是玩家移动请求?

还是释放技能指令?

如果通信双方没有提前约定数据格式,那么即使数据完整到达,程序也无法理解它的含义。

这就像两个人都听到了声音,但是没有约定语言。

声音传到了耳朵里,却无法产生有效交流。

因此,在传输层之上,还需要应用层协议。

应用层协议负责规定:

  • 数据代表什么含义;
  • 数据应该按照什么格式组织;
  • 收到不同类型的数据后应该执行什么操作。

例如,在 Arcane Campus Online 中。

玩家发送一句:

“有人一起去图书馆探索吗?”

客户端不能简单发送:

有人一起去图书馆探索吗?

因为服务器收到后不知道:

  • 谁发送的?
  • 发送者当前在哪个房间?
  • 这是聊天消息,还是游戏指令?
  • 是否需要广播给其他玩家?

因此,我们需要设计自己的应用层协议。

例如:

{
    "type": "chat",
    "player_id": 10001,
    "room": "library",
    "message": "有人一起去图书馆探索吗?"
}

服务器解析这段数据后,就能够知道:

这是一个聊天事件。

然后执行对应逻辑:

  • 保存聊天记录;
  • 转发给当前场景玩家;
  • 更新相关状态。

除了自定义协议,现实中的很多网络应用也会使用已经存在的应用层标准。

例如:

访问网页时使用:

HTTP 协议

邮件传输使用:

SMTP 协议

域名解析使用:

DNS 协议

这些协议提前规定好了通信双方的数据格式,因此不同厂商开发的软件也能够互相通信。

所以,应用层解决的问题是:

让通信双方理解数据的含义,并根据数据完成具体业务。

总结:

  • 数据链路层负责“这一跳送给谁”;
  • 网络层负责“最终送到哪里”;
  • 传输层负责“送给哪个程序,并保证如何送达”;
  • 应用层负责“这份数据到底是什么意思”。

这就是五层模型中的**应用层(Application Layer)**负责的核心任务:

定义通信规则,让数据从单纯的二进制信息变成真正具有业务意义的消息。

到这里,我们终于完整走完了一次数据的旅程。

现在,让我们把前面的五层模型重新放回最开始的场景。

假设我正在电脑上启动《霍格沃兹之遗》,并且在游戏中完成了一个动作:

打开地图,查看霍格沃兹城堡的位置。

当我点击地图按钮时,游戏客户端需要向华纳服务器发送一个请求:

“玩家请求打开当前地图数据。”

这个请求首先会被游戏客户端转换成应用层能够理解的数据格式:

 
{
    "type": "map_request",
    "player_id": 10001,
    "location": "Hogwarts_Castle"
}

这一步属于:

应用层:定义数据含义。

服务器收到之后,才能知道:

“这是一个地图查询请求,而不是聊天消息或者移动指令。”


接着,这些数据会交给传输层。

传输层需要解决:

这份数据应该交给哪一个程序?

我的电脑上可能同时运行着浏览器、音乐软件、游戏客户端。

所以 TCP/UDP 会通过端口号找到对应的游戏进程。

例如:

 
源端口:
我的电脑上的游戏客户端端口
目标端口:
华纳服务器上的游戏服务端端口

如果使用 TCP,还会通过连接、确认、重传等机制保证数据可靠到达。

这一步属于:

传输层:负责进程之间的数据传输。


然后,数据进入网络层。

此时,数据需要从我的电脑前往华纳服务器。

网络层会添加 IP 信息:

 
源 IP:
我的电脑 IP
目标 IP:
华纳服务器 IP

路由器根据目标 IP 查询自己的路由表:

“前往华纳服务器所在网络,下一跳应该发送给哪个路由器?”

于是数据不断经过:

家庭路由器 → 运营商网络 → 骨干网络 → 华纳服务器所在网络。

这一步属于:

网络层:负责跨网络寻址和路径选择。


当数据到达每一个局域网时,数据链路层负责完成当前这一小段传输。

例如:

我的电脑发送给家庭路由器:

 
源 MAC:
我的电脑网卡
目标 MAC:
家庭路由器网卡

经过路由器后:

 
源 MAC:
家庭路由器网卡
目标 MAC:
下一个路由器网卡

每经过一个路由器,MAC 地址都会重新变化。

这一步属于:

数据链路层:负责相邻设备之间的数据传输。


最终,数据经过互联网来到华纳服务器。

服务器上的网络程序收到数据后:

传输层根据端口找到游戏服务器进程;

应用层协议解析数据:

“玩家 Stella 请求查看霍格沃兹城堡地图。”

于是服务器查询对应的数据,然后返回地图信息。

整个通信过程才真正完成。


从用户点击一次“查看地图”,到服务器返回结果,背后经历了:

 
玩家点击地图按钮
↓
应用层:
生成游戏请求,定义消息含义
↓
传输层:
通过 TCP/UDP 找到目标程序并传输数据
↓
网络层:
通过 IP 地址寻找华纳服务器
↓
数据链路层:
通过 MAC 地址完成每一跳传递
↓
华纳服务器解析请求
↓
返回霍格沃兹地图数据

看似只是点击了一次按钮,实际上背后是一整套计算机网络体系共同协作的结果。

核心问题对应层级解决方案
相邻设备如何通信数据链路层MAC 地址、数据帧
如何找到目标主机和路径网络层IP 地址、路由选择
数据丢失怎么办传输层TCP、UDP、端口号
接收方如何理解数据应用层应用协议、数据格式

下一节,我们就正式进入:

Socket —— 应用程序如何调用操作系统提供的网络能力。

三、Socket(套接字)

定义:ip+port=socket

严格来说:

Socket 是操作系统提供给应用程序使用网络能力的一个接口

它不是简单的 IP 加端口。

更准确:

Socket = 一个通信端点

这个通信端点里面包含:

IP地址
+
端口号
+
协议(TCP/UDP)
+
操作系统维护的通信状态

比如:

我们的Arcane Campus Online:

玩家客户端:

IP:
192.168.1.20
Port:
50000

服务器:

IP:
120.xxx.xxx.xxx
Port:
8888

双方都有一个 Socket。

因此通信本质:

客户端Socket
        |
        |
        ↓
服务器Socket
3.1引入:再谈端口号

前面说到,ip+端口号是可以用来在互联网中标识唯一进程的,它真身是一个2字节的16位整数,范围是0~65535,其中0~1023是不能随便用的。但是回想前面学习linux的时候,进程自己就有pid可以标识唯一的进程,那网络通信为什么不直接用pid,还非要再搞个端口号?

这里涉及到一个重要的思想——解耦

如果把端口和 PID 绑在一起,会导致操作系统和网络子系统高度耦合,难以维护。而且 PID 是每个进程都有的,但不是每个进程都需要进行网络通信——没必要让所有进程都被迫参与这套寻址体系。 所以操作系统单独搞了一套端口号机制,专门给"需要网络通信的进程"用。

3.2sockfd:你手里的那张门票

回忆一下前面留的问题:IP + 端口,能在互联网中唯一定位到一个进程。 但这里有个前提容易被忽略——一个普通进程,天生是没有"跨网络通信"这个能力的。

进程默认能做的事很有限:读写内存、操作自己打开的文件、和父子进程通信。它并不知道什么是 IP,也不知道怎么把一段数据丢到网线上去。

真正懂网络协议、知道怎么和网卡打交道的,是“os内核”——内核里实现了完整的 TCP/IP 协议栈,这套能力是内核的,不是进程自带的。

所以,一个进程想要具备网络通信能力,第一步不是"直接开始发数据",而是向内核申请一份网络能力。 这就是 `socket()` 在做的事:

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

来解释一下这三个参数:

`AF_INET`:地址族(Address Family),表示用 IPv4 地址

`SOCK_DGRAM`:套接字类型,数据报类型,对应 UDP(如果传 `SOCK_STREAM`,就是面向连接的字节流类型,对应 TCP)

`0`:协议号,一般填 0,表示让操作系统根据前两个参数自动选择合适的协议

返回值是一个整数sockfd。这个整数到底是什么?为什么是个整数,而不是直接给你一个"Socket对象"?

要回答这个问题,得先回到 Linux 的核心理念“一切皆文件”

先理解文件描述符

看到fd是不是有点眼熟,学过linux系统编程的读者都知道fd代表的是文件描述符。文件描述符——是用户程序用来告诉操作系统“我要操作哪个资源”的编号。

在linux中,不管你打开的是普通文件、目录、管道还是终端设备,操作系统内部都会为它维护一个数据结构(struct file),记录着这个东西的状态——读写位置、权限、缓冲区等重要信息。

这个数据结构本身很复杂,操作系统不会把它直接暴露给你,而是把它放进一张表里,你拿到的只是这张表里的一个“下标”——也就是文件描述符(fd)。

每个进程都有自己的一张文件描述符表,大概长这样:

fd    →   指向的内核对象
0     →   标准输入(stdin)
1     →   标准输出(stdout)
2     →   标准错误(stderr)
3     →   (你打开的第一个文件/套接字)
4     →   (第二个)

这是打开一个普通文件的语句:

int fd = open("test.txt", O_RDONLY);

它的背后的过程:

1.进程拿到fd,内核建立“进城记文件描述符表”的一项

当每个进程都有自己的一张fd表,‘open()’成功后,内核在这张表里找一个空位,把编号(比如 3)返回给你,这就是 fd。

2.fd指向"struct file"

进程fd表里的每一项,并不是直接存文件内容,而是指向内核维护的一个'struct file',它记录着当前的读写位置、打开模式(只读/只写)等信息。多个进程 fd 可以指向同一个 `struct file`(比如 `fork` 之后共享文件偏移量)。

3.'stcut file'指向'struct inode'

inode(索引节点)才是真正描述这个文身本身的结构——文件大小、权限、所有者、以及“数据在磁盘上的具体块位置”。`inode` 和文件名是分开的,文件名只是目录项(dentry)里的一个映射,指向某个 inode 编号。

4.inode找到磁盘块上的数据块

根据inode里记录的块地址,文件系统(比如ext4)去磁盘对应的物理位置读写真正的数据

5.接下来就是底层硬件的事情了

磁盘控制器把地址转换成具体的读写操作,落到高低电平这种物理信号层面,我们不用管。

总结:fd → 进程fd表 → struct file(系统级打开文件表) → struct inode → 磁盘数据块 → 物理读写

再看 Socket:走的是同一套外壳,内容完全不同

Socket 本质上也是 Linux 「一切皆文件」思想的一部分,因此它同样走的是文件描述符(fd)这一套机制。

前两层和普通文件几乎完全一致:

1、进程拿到 sockfd,内核建立进程级文件描述符表的一项

当我们调用:

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

内核会返回一个整数,例如:

sockfd = 3

这个数字本身并不是 Socket,而只是当前进程文件描述符表中的一个索引。

这一点和:

int fd = open("test.txt", O_RDONLY);

返回的文件描述符没有任何区别。


2、sockfd 指向 struct file

文件描述符表中的这一项,会进一步指向内核中的:

struct file

也就是系统级打开文件表中的一项。

这一层同样和普通文件完全一致。


真正的区别,从这里开始。

3、struct file 指向的不是普通 inode,而是 struct socket

对于普通文件来说:

struct file
        ↓
inode
        ↓
磁盘数据块

而 Socket 并不是磁盘文件,因此它不会对应真正的数据块。

Linux 为此专门提供了一个特殊的伪文件系统:

sockfs

Socket 同样拥有 inode,因此我们能够在:

/proc/<pid>/fd/

目录中看到类似:

socket:[12345]

这样的信息。

但是这个 inode 并不会指向磁盘,而是进一步指向:

struct socket

这是 Socket 在 VFS(Virtual File System,虚拟文件系统)层中的包装对象。

存在它的目的只有一个:

让 Socket 也能够使用:

read()
write()
close()

这一整套统一的文件操作接口。


4、struct socket 指向 struct sock

真正负责网络通信工作的,其实是:

struct sock

这里保存着协议栈真正需要维护的数据。

例如:

  • 接收缓冲区;

  • 发送缓冲区;

  • 本地 IP;

  • 本地端口;

  • 对端 IP;

  • 对端端口;

  • Socket 状态。

如果是 TCP,还会额外维护:

  • 序列号;

  • 确认号;

  • 滑动窗口;

  • 拥塞窗口;

  • 重传计时器;

  • TCP 状态机。

例如:

LISTEN
SYN_SENT
ESTABLISHED
TIME_WAIT

这些状态实际上都保存在这里。


5、最底层不再是磁盘,而是网卡

普通文件最终会落到:

磁盘

而 Socket 最终连接的是:

网卡设备

发送数据时:

应用层
    ↓
Socket
    ↓
TCP/UDP协议栈
    ↓
网卡驱动
    ↓
网线/无线信号

接收数据时则反过来:

网卡收到数据
    ↓
协议栈处理
    ↓
放入接收缓冲区
    ↓
recv()/recvfrom() 返回给用户程序

因此,Socket 的完整链路可以表示为:

sockfd
    ↓
进程文件描述符表
    ↓
struct file
    ↓
struct socket
    ↓
struct sock
    ↓
网卡驱动
    ↓
网络

而普通文件的链路则是:

fd
    ↓
进程文件描述符表
    ↓
struct file
    ↓
inode
    ↓
磁盘数据块

这就是为什么 Linux 说"一切皆文件"——不管背后连的是磁盘还是网卡,最外层套的都是同一层 `struct file` 外壳,所以你才能用同一套 `read`/`write`/`close` 接口去操作两种完全不同的资源。

而 `sockfd`,就是你在自己进程里,用来指代这整条链路起点的一个编号——一张门票,你拿着它,操作系统凭票去找到真正该处理的东西。

3.4sockaddr_in与bind:给Socket一个真正的身份

在前面调用_sockfd = socket(AF_INET, SOCK_DGRAM, 0);之后我们拿到了一个sockfd

但此时这个刚刚诞生的Socket,它拥有了网络通信的能力,却还没有属于自己的身份

此时操作系统对它的认知:它是一个套接字,它使用的是udp,用的是ipv4地址

但是真正可以让我们实现网络通信的关键信息操作系统仍然不知道:它应该监听哪个ip,它应该使用哪个端口,哪些网络数据应该交给他处理

也就是说,此时的socket还么米有一个能够被外界找到的地址。即使现在真的有一个UDP数据包到达了当前机器,内核也无法判断这个数据包应该交给哪个socket

因此,我们需要用到一个系统接口将这个socket与一个具体的网络地址关联起来

bind()

使用实例:

bind(_sockfd,
     (struct sockaddr *)&local_addr,
     sizeof(local_addr));

执行完成之后,Socket 才真正拥有了属于自己的网络身份。

此后,当网卡收到数据并进入内核协议栈时,内核就能够根据:协议类型,目标ip,目标端口

找到对应的 Socket,并将数据放入它的接收缓冲区中。

我们来研究一下bind的参数,第一个参数是我们上面刚讲过的sockfd,第二个看起来是一个地址,第三个参数是第二个参数的大小。

但是第二个参数长的有点奇怪。

struct sockaddr_in {
    sa_family_t    sin_family;  // 地址族,AF_INET
    in_port_t      sin_port;    // 端口号
    struct in_addr sin_addr;    // IP地址
};

三个字段分别对应:协议族、端口、IP,凑在一起,就是一个完整的网络地址。

Socket 这套接口,其实不只服务于网络通信。Linux 里至少还支持这几种 Socket:

网络 Socket(`AF_INET`):用于跨网络的进程间通信,对应 `sockaddr_in`

本地域 Socket(`AF_UNIX`):用于同一台机器上的进程间通信,对应 `sockaddr_un`

IPv6 Socket(`AF_INET6`):用于基于 IPv6 的网络通信,对应 `sockaddr_in6`

三种通信方式,本质完全不同,但内核不可能为每一种都单独写一套 `bind`、`connect`、`send`、`recv`(后面马上会提到的socket网络编程的其他重要接口)——那样代码会重复三倍,还极难维护。

r:#d4e9d5">小结:bind 是把 IP:端口 和这个 socket(内核里对应的 struct sock)关联起来,注册进内核维护的一张"查找表"里。之后收数据的时候,内核会拿收到的包的目标 IP:端口去这张表里查,找到匹配的 struct sock,把数据放进它的接收缓冲区。

再谈回第三个参数:sizeof(local_addr),表示第二个参数指向的地址结构体占用的内存大小。

要传它是因为Socket 并不只有 IPv4 网络通信这一种形式,它们虽然都属于 Socket 地址结构,但是具体占用的空间大小并不完全相同。因此bind()接口无法假设传入的是什么地址结构,

它只能接收一个通用地址指针:

struct sockaddr *

然后通过第三个参数去得到这个地址结构到底占用了多少字节的信息。这样内核就能知道从这个地址开始,一共有多少字节属于有效地址数据,从而能够安全地读取完整的地址信息

bind()的三个参数:

sockfd:告诉内核我要操作哪个socket

sockaddr:告诉内核我要绑定什么网络地址

addrlen:告诉内核这个地址结构占用多少空间

3.4.1为什么 bind() 只认识 sockaddr?

不过,Socket 这套接口,其实不只服务于IPV4网络通信。Linux 里至少还支持这几种 Socket:
 

网络 Socket(AF_INET):用于 IPv4 网络通信,对应 sockaddr_in;

本地域 Socket(AF_UNIX):用于同一台机器上的进程间通信,对应 sockaddr_un;

IPv6 Socket(AF_INET6):用于 IPv6 网络通信,对应 sockaddr_in6

三种通信方式,本质完全不同,但内核不可能为每一种都单独写一套 `bind`、`connect`、`send`、`recv`(后面马上会提到的socket网络编程的其他重要接口)——那样代码会重复三倍,还极难维护。等等,这个问题看起来是不是有点眼熟?

刚学习 C++ 面向对象的时候,大多数人都遇到过类似的问题:鸭子和企鹅明明都是鸟,它们都有翅膀,也都有很多相同的行为。难道每一个类都需要重新实现一套完全一样的接口吗?

作为一个面向对象的编程语言,C++ 给出的解决方案叫做多态。

定义一个统一的父类 Bird,上层代码只需要认识 Bird*,至于它最终指向的是 Duck 还是 Penguin,并不重要,具体行为交给具体类型自己实现。而 Linux 内核工程师面对 Socket 地址结构时,遇到的其实是同样的问题。只不过 Linux 内核几乎全部由 C 语言编写,没有 class、没有继承、也没有虚函数。

于是他们选择了一种更加朴素的方法:约定所有 Socket 地址结构体的开头部分必须保持一致。

例如:

通用sockaddr:

struct sockaddr:

struct sockaddr
{
    sa_family_t sa_family;
    char sa_data[14];
};

IPv4 地址结构 sockaddr_in:

struct sockaddr_in 
{   sa_family_t sin_family; 
    in_port_t sin_port; 
    struct in_addr sin_addr;
 };

本地域 Socket 的 sockaddr_un:

struct sockaddr_un
{
    sa_family_t sun_family;
    char sun_path[108];


};

虽然后面的内容完全不同,但它们的第一个成员始终都是地址族字段,并且位于结构体起始位置。

这件事情看起来似乎无关紧要,但实际上正是整个 Socket API 能够实现统一接口设计的关键。

因为 bind()connect()sendto() 等系统调用最终都需要进入内核执行,而它们的参数类型被统一设计成了:

struct sockaddr *

因此,无论用户实际使用的是 sockaddr_insockaddr_in6 还是 sockaddr_un,最终都必须先被转换成这个统一的"通用地址类型"才能传递给内核。

很多初学者第一次看到:

(struct sockaddr *)&local_addr

都会疑惑:

这里只是做了一次强制类型转换,既没有拷贝数据,也没有调用任何转换函数,内核为什么就能正确识别出真实类型?

原因就在于:

强制类型转换并不会修改内存中的任何数据,它仅仅只是告诉编译器:

"请暂时把这块内存按照另一种结构体类型去解释。"

由于所有地址结构体的开头部分完全一致,因此内核拿到这个 sockaddr * 指针以后,总能够安全地读取出最前面的地址族字段:

AF_INET
AF_INET6
AF_UNIX

随后再根据这个地址族信息,决定将这块内存重新解释成真正对应的具体类型:

AF_INET  → sockaddr_in
AF_INET6 → sockaddr_in6
AF_UNIX  → sockaddr_un

整个过程中,没有发生任何数据转换,也没有发生任何内存复制。

变化的,仅仅只是"看待同一块内存的方式"。

正因为所有地址结构体都遵守了"开头部分保持一致"这一约定,Socket API 才能够仅依靠一次简单的强制类型转换,就完成对多种地址类型的统一支持。

于是 bind()、connect()、sendto()、recvfrom() 这些接口就只需要统一接收一个struct sockaddr *

内核拿到这个指针后,首先读取结构体开头的地址族字段:

AF_INET

AF_UNIX

AF_INET6

随后再决定按照哪一种具体的地址结构进行解析和处理。

从效果上来看,它和 C++ 中 Bird* 指向 Duck 或 Penguin 的行为有几分相似:

上层接口保持统一,而底层实现负责各自的差异化逻辑。

这也是为什么几乎所有 Socket 网络编程代码中都会出现这样一个看起来有些奇怪的强制类型转换:

bind(_sockfd,
     (struct sockaddr *)&local_addr,
     sizeof(local_addr));

它并不是多余的写法,而是整个 Socket API 为了兼容多种通信方式所采用的一种统一接口设计。

3.4字节序转换:为什么网络通信需要进行数据转换?

通过上面两小节,我们知道了,通过:

sockfd=socket(AF_INET, SOCK_DGRAM, 0);

可以创建一个socket;

通过:

bind(_sockfd, (struct sockaddr *)&local_addr, sizeof(local_addr));

我们将ip地址和端口号绑定到了socket对象上。

sockaddr_in 结构体负责描述一个具体的网络地址:

struct sockaddr_in 
{ sa_family_t sin_family; // 地址族 
  in_port_t sin_port; // 端口号
  struct in_addr sin_addr; // IP地址
 };

 但在实际编写 Socket 程序时,你一定会看到类似这样的代码:

local_addr.sin_port = htons(8888); 
local_addr.sin_addr.s_addr = htonl(INADDR_ANY);

这里给端口号和 IP 地址赋值时,并没有直接使用原始数据,而是调用了 htons()htonl() 进行转换。

这两个函数看起来和 Socket 创建、地址绑定没有直接关系,但它们实际上解决的是网络通信中一个非常基础的问题:

不同计算机如何保证能够正确理解同一份二进制数据。

因为 IP 地址和端口号在程序中虽然表现为普通整数,但最终都会以二进制形式存储在内存中,并通过网络发送给另一台计算机。

然而,不同计算机的 CPU 可能采用不同的数据存储顺序。

如果发送方和接收方对于同一个数字采用不同的解析方式,那么经过网络传输之后,接收方可能得到完全错误的结果。

因此,在正式分析 htons()htonl() 这些接口之前,我们需要先理解计算机中的字节序(Byte Order)

3.4.1大端与小端的“恩怨情仇”

字节序(Byte Order),指的是:

一个多字节数据被拆分成多个字节后,这些字节在内存地址中的排列顺序。

例如,一个 int 类型通常占用 4 个字节,一个 short 类型通常占用 2 个字节。

当这些数据被存入内存时,究竟是高位字节放在低地址,还是低位字节放在低地址,这种存放规则就是字节序。

这个概念的名字(Endian)其实非常有趣,它源自经典名著《格列佛游记》(Gulliver's Travels)。书中的小人国因为“吃鸡蛋应该先打破大头(Big-End)还是小头(Little-End)”而爆发了内战。在计算机世界里,CPU 厂商们也曾为了数据存储的方向大打出手,分裂成了两大阵营:

  • 小端字节序(Little-Endian):低位字节存放在内存的低地址端,高位字节存放在高地址端。

  • 大端字节序(Big-Endian):高位字节存放在内存的低地址端,低位字节存放在高地址端。

  实验:亲眼看看自己的电脑到底是大端还是小端

步骤非常简单:

1.创建一个整数:

int num = 0x12345678;

2.将其写入二进制文件:

ofstream out("test.bin", ios::binary);
out.write((char*)&num, sizeof(num));

3.使用 HexEd.it 打开文件

如果看到:

78 56 34 12

说明你的机器是小端机器。

如果看到:

12 34 56 78

说明你的机器是大端机器。

完整过程:

在vs2022或者其他cpp编译器建一个.cpp文件写入下面的代码

#include <iostream>
#include <fstream>

using namespace std;

int main()
{
    int num = 0x12345678;

    ofstream out("test.bin", ios::binary);

    out.write((char*)&num, sizeof(num));

    out.close();

    return 0;
}

运行它,然后去你这个项目的目录里面找test(取决于你给这个文件叫啥我叫test).bin

然后打开HexEd.it - Free Browser-based Online and Offline Hex Editing,把 test.bin 拖进去

可以看到笔者的电脑是小端机器。

to读者:你去测你大概也是一个结果:),因为现在的个人电脑基本都是小端了——在早期计算机时代,大端和小端各占半壁江山。IBM 大型机、Motorola 68000、Sun SPARC 等硬核处理器都坚定地拥护大端;而 DEC、Intel(x86 架构)则选择了小端。

随着个人电脑(PC)时代的到来,Intel 的 x86 芯片凭借巨大的商业成功统治了消费级市场,导致我们今天使用的绝大多数 Windows/Linux 电脑、甚至是采用 ARM 架构的智能手机,在默认的主机字节序(Host Byte Order)上,都是小端字节序

但网络编程不一样,如果网络传输没有统一标准,就会发生灾难性后果,为了彻底消除这种混乱,TCP/IP 协议栈在制定标准时做出了硬性规定:在网络上传输的多字节数值,必须采用大端字节序。因此,大端字节序在网络世界里也被称为 网络字节序(Network Byte Order)。

3.4.2:字节序转换发生在哪个阶段?

这正是许多初学者最困惑的地方:我调用了 htons(8888),这笔数据到底是在我的 CPU 里被转换的?还是在网卡里?或者是在空中飞行的电波里?

字节序转换发生在用户程序中,发生在数据进入内核协议栈之前。

例:

local_addr.sin_port = htons(8888);

这里的 htons() 并不是一次系统调用,它不会进入内核,也不会通知网卡,更不会在数据传输过程中由路由器帮我们完成转换。它本质上只是一个普通函数,执行它的仍然是当前机器的 CPU。

它完成的工作只有一件事:把当前机器使用的主机字节序(Host Byte Order)转换成网络规定的网络字节序(Network Byte Order)。

转换完成之后,结果被写入:

local_addr.sin_port

随后,这份已经符合网络规范的数据,才会随着 bind()sendto()connect() 等系统调用进入内核协议栈,最终被网卡发送出去。

因此,整个过程实际上是:

用户程序
↓
CPU 执行 htons() / htonl()
↓
得到网络字节序的数据
↓
系统调用进入内核
↓
内核协议栈处理
↓
网卡发送
↓
网络传输

接收数据时则刚好相反:

网卡收到数据
↓
内核协议栈处理
↓
用户程序调用 recvfrom() 取出数据
↓
CPU 执行 ntohs() / ntohl()
↓
恢复为当前机器使用的主机字节序

网卡负责搬运字节,CPU负责理解字节。

3.4.3四个字节序转换接口

既然网络世界统一规定采用大端字节序,那么在实际编写 Socket 程序时,就需要一组专门负责完成字节序转换的接口。

Linux 为我们提供了四个最常见的转换函数:

//主机字节序转网络字节序
uint16_t htons(uint16_t hostshort);
uint32_t htonl(uint32_t hostlong);

//网络字节序转主机字节序
uint16_t ntohs(uint16_t netshort);
uint32_t ntohl(uint32_t netlong);

虽然名字看起来像键盘乱滚出来的一串字符,但实际上它们非常有规律:

  • h :Host(主机)
  • n :Network(网络)
  • to:转换方向
  • s :short(2字节)
  • l :long(4字节)

所以有:

htons
=
Host To Network Short

最典型的用途就是端口号转换:

local_addr.sin_port = htons(8888);

虽然在程序员眼里,8888 只是一个普通数字,但对于内核网络协议栈来说,它最终必须以二进制形式存放在内存中。因此在写入sin_port之前,需要先通过htons() 将主机字节序转换为网络字节序。

IP 地址也是同样的道理:

local_addr.sin_addr.s_addr = htonl(INADDR_ANY);

这里有个知识点:在人类世界里,我们习惯把 IP 地址写成127.0.0.1 119.165.xx.xx等这种点分十进制表示法,但在内核和网络协议眼里,IP地址本质上是一个32位整数。

因此像这种:

inet_addr("127.0.0.1")

本质上就是在

人类可读的字符串IP
↓
32位整数IP
↓
网络字节序存储

而我们上面提到的htonl()这个接口就是负责最后一步:

主机字节序->网络字节序

3.5数据终于开始流动了:sendto 与 recvfrom

前面我们做了很多工作:

socket()
↓
创建 Socket

bind()
↓
给 Socket 注册网络身份

sockaddr_in
↓
描述网络地址

htons()/htonl()
↓
统一网络中的数据表示方式

而直到这一刻,数据都还没有真正离开过我们的程序。

现在,一切准备工作终于完成。

终于轮到真正负责网络通信的两个接口登场:

sendto()
recvfrom()

对于 UDP 来说,它们几乎承担了全部的数据收发工作。

3.5.1sendto():把数据交给网络

函数原型如下:

ssize_t sendto(
    int sockfd,
    const void *buf,
    size_t len,
    int flags,
    const struct sockaddr *dest_addr,
    socklen_t addrlen
);

笔者第一次看到这个函数的时候的想法:这参数是不是有点太多了??

没关系,我们来慢慢拆解理解

其实 sendto() 最核心的东西就三块:

发什么
发给谁
用哪个 socket 发

把参数要求解释一下:

ssize_t sendto
( 
int sockfd, // 我要用哪个 Socket 发?
 const void *buf, // 我要发送什么内容?
 size_t len, // 这份内容有多大?
 int flags, // 有没有什么特殊要求?
 const struct sockaddr *dest_addr, // 我要发给谁?
 socklen_t addrlen // 对方地址结构体有多大?
 );

第一个参数 sockfd 我们已经非常熟悉了。

它并不是 Socket 本身,而是用户程序手里的一个文件描述符(File Descriptor)。

第二和第三个参数通常需要放在一起理解:

const void *buf
size_t len

很多初学者看到这里会疑惑:

为什么不能直接传一个字符串?

原因非常简单。

因为内核根本不知道你打算发送的是什么。可能是一条聊天消息,一个登录请求,或者一段JSON...

对于操作系统来说,这些东西没有任何区别。它眼里只有一串连续的字节..

所以内核就要求用户程序 自己提供一块缓冲区(Buffer),并告诉它数据放哪里,一共有多少字节?

buf:表示发送缓冲区的起始地址

len:表示从这个位置开始,一共有多少字节需要发送。

例如:

std::string msg = "有人一起去图书馆吗?";

sendto(
    sockfd,
    msg.c_str(),
    msg.size(),
    0,
    ...
);

//此时:

buf -> msg.c_str()
len -> msg.size()

接下来是:

const struct sockaddr *dest_addr

socklen_t addrlen

这一组参数负责告诉内核这份数据应该发给谁。

实际上这里传进去的通常都是:struct sockaddr_in,只是由于前面提到过得socket多态设计,最终同意强转成:(struct sockaddr *)交给内核处理。

最后就是 int flags了

这个参数用于控制一些特殊的发送行为比如:非阻塞发送;批量发送优化;路由优化,紧急数据处理等。

不过对于绝大多数socket变成场景来说 flags=0几乎永远都是正确答案。在学习阶段,我们完全可以把它简单理解成:没有特殊要求,按照默认方式发送即可。

3.5.2recvfrom():从网络中取回数据

顾名思义,sendto()负责的是从用户程序到网络,那么recvfrom()负责的就是完全相反的过程:从网络经过内核回到用户程序。

函数原型如下:

ssize_t recvfrom
( int sockfd,
  void *buf, 
  size_t len, 
  int flags, 
  struct sockaddr *src_addr,
  socklen_t *addrlen
 );

看起来和sendto()长得几乎一摸一样,而事实上,它们确实很像。因为发送和接收本身就是两个方向完全相反的过程。

对参数作解释:

ssize_t recvfrom
( 
 int sockfd, // 我要从哪个 Socket 收数据?
 void *buf, // 收到的数据放哪里?
 size_t len, // 最多允许接收多少数据?
 int flags, // 有没有什么特殊要求?
 struct sockaddr *src_addr, // 这份数据是谁发来的? 
 socklen_t *addrlen // 对方地址结构体有多大? 
);

第一个参数:

sockfd

sendto() 中的意义完全一致。

它告诉内核:

我要监听哪一个 Socket 的数据。

内核随后会通过:

sockfd
↓
进程 fd 表
↓
struct file
↓
struct socket
↓
struct sock

去找到对应的socket接收缓冲区,如果缓冲区里面已经有数据,就直接读取。

如果缓冲区为空,那么当前线程通常会进入阻塞状态,等待新的网络数据到来

第二和第三个参数同样需要放在一起理解:

void *buf

size_t len

前面发送数据的时候,我们需要给内核提供一块发送缓冲区。现在轮到内核给我们数据了,那这些数据应该放在哪里呢?

——用户程序自己准备一块接收缓冲区,然后告诉内核:把收到的数据放到这里。

因此:

buf

表示接收缓冲区的起始地址。

而:

len

表示这块缓冲区最多能够容纳多少字节。

例:

char inbuffer[1024]; 
recvfrom
( sockfd, 
  inbuffer,
  sizeof(inbuffer), 
  0, 
  ... );

这里的意思其实非常简单:

我给你准备了一个 1024 字节的缓冲区,
收到的数据全部放进来,
但不要超过这个大小。

如果收到的数据超过缓冲区容量,那么超出的部分将会被截断。

接下来是:

struct sockaddr *src_addr
socklen_t *addrlen

这一组参数和 sendto() 中的目标地址刚好相反。

它们负责告诉用户程序这份数据是谁发来的

例如:

192.168.1.100:8888

因此,src_addr 其实是一个 输出型参数: 你需要提前准备好一个空的 struct sockaddr_in 结构体,把它强制转换成 (struct sockaddr *) 后传进去。内核在把接收到的数据塞进 buf 的同时,也会顺手把发送方的 IP 和端口写进这个结构体里。

addrlen 则是一个非常经典的 输入输出型参数(Value-Result Argument)

  • 进去前(输入):它告诉内核,“我准备的结构体容量有多大”,防止内核在写入地址信息时发生内存越界。

  • 出来后(输出):内核会把它修改成“我实际写入的地址结构体大小”。

最后是: int flagssendto 一样,用于控制特殊的接收行为(比如设置非阻塞接收,或者只想偷窥一下缓冲区的数据而不把它取走)。在普通的阻塞式网络通信中,填 0 同样是永远的正确答案。

四、一封来自霍格沃兹的信:数据究竟是怎样到达程序中的?

前面我们已经学会了:

  • socket() 创建网络通信能力;
  • bind() 给 Socket 注册网络身份;
  • sendto() 把数据交给网络;
  • recvfrom() 从网络中取回数据。

那么现在终于可以回答一个贯穿整个 Socket 编程学习过程的问题:

当我们调用:

recvfrom(...)

的时候,数据究竟是从哪里来的?

答案是:

它其实经历了一次完整的协议栈旅行。

假设此刻,你正在 Arcane Campus Online 中给好友发送一条消息:

今晚八点图书馆集合,一起去禁林探险。

当你按下发送键之后,这条消息并不会瞬间出现在对方电脑上。

它会先从用户程序进入内核:

用户程序
↓
sendto()
↓
struct sock 发送缓冲区
↓
UDP层
↓
IP层
↓
数据链路层
↓
网卡驱动
↓
网卡
↓
电信号 / 光信号
↓
互联网

随后,它会穿越无数交换机、路由器以及运营商网络,最终到达目标服务器所在机器。

而对于接收方来说,这个过程又会反过来重新走一遍:

网卡
↓
网卡驱动
↓
数据链路层
↓
网络层(IP)
↓
传输层(TCP/UDP)
↓
根据:
协议类型
+
目标IP
+
目标端口

找到对应的 struct sock
↓
数据进入 Socket 接收缓冲区
↓
recvfrom()
↓
用户空间 buffer
↓
应用程序解析数据

直到这一刻,程序才终于真正看见了那条消息:

今晚八点图书馆集合,一起去禁林探险。

如果把整个过程浓缩成一句话,那么:

sendto()
负责把数据从用户空间送进网络世界;

recvfrom()
负责把数据从网络世界带回用户空间。

而 Socket,则像是横跨用户程序与内核协议栈之间的一座桥梁。

程序并不会直接操作网卡。

网卡也不会认识聊天消息、登录请求或者 JSON 数据。

双方唯一共同认识的语言,就是字节流。

至此,我们终于完成了 Linux 网络编程世界中的第一次完整闭环。

五、知识点汇总

五层模型

问题层级关键技术
相邻设备怎么通信数据链路层MAC 地址(只管“这一跳”,每经过一个路由器都会改变)
怎么找到目标主机网络层IP 地址(全局唯一,端到端不变)、路由表
数据丢了怎么办传输层TCP(可靠,握手 + 重传)/ UDP(不可靠,速度快)
接收方怎么理解数据应用层自定义协议 / HTTP、DNS 等标准协议

协议的本质

发送方和接收方提前约定好的一套数据格式,协议描述规则,代码实现规则。

Socket 的本质

Socket = IP地址 + 端口号 + 协议(TCP/UDP) + 操作系统维护的通信状态

它并不只是简单的“IP + 端口”,而是操作系统提供给应用程序使用网络能力的一个通信端点。

之所以使用端口号而不是 PID 来标识通信对象,本质上是为了解耦——并不是所有进程都需要进行网络通信。

核心内核链路

普通文件:
fd
↓
进程fd表
↓
struct file
↓
struct inode
↓
磁盘数据块

Socket:
sockfd
↓
进程fd表
↓
struct file
↓
struct socket
↓
struct sock
↓
网卡

sockaddr 的“多态”设计

所有地址结构体(sockaddr_insockaddr_unsockaddr_in6)的开头字段必须保持一致,Socket 接口统一接收 struct sockaddr *

内核通过读取结构体开头的地址族字段(AF_INETAF_UNIXAF_INET6)判断真实类型,再按照对应方式进行解析和处理。

C 语言没有继承和虚函数,因此 Linux 内核通过“统一内存布局 + 强制类型转换”的方式,模拟出了类似 C++ 多态的效果。

字节序

网络传输统一采用大端序(网络字节序),而现代个人电脑大多采用小端序(主机字节序)。

字节序转换发生在用户程序内部,由 CPU 执行,与内核协议栈和网卡无关。


核心接口速查

// 创建 Socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// AF_INET: IPv4
// SOCK_DGRAM: UDP(SOCK_STREAM 为 TCP)
// 0: 自动选择对应协议

// 绑定地址
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
// 将 IP:端口 与当前 Socket 关联
// 注册进内核维护的查找表

// 字节序转换
htons(port);   // 主机序 -> 网络序,16位端口号
htonl(ip);     // 主机序 -> 网络序,32位IP地址
ntohs();       // 网络序 -> 主机序,16位整数
ntohl();       // 网络序 -> 主机序,32位整数

// 发送数据
sendto(
    sockfd,
    buf,
    len,
    0,
    (struct sockaddr *)&dest_addr,
    sizeof(dest_addr)
);
// buf/len:发什么、发多少
// dest_addr:发给谁

// 接收数据
recvfrom(
    sockfd,
    buf,
    len,
    0,
    (struct sockaddr *)&src_addr,
    &addrlen
);
// buf/len:存哪里、最多收多少
// src_addr:输出型参数,内核会把发送方地址写进去

一个容易混淆的区别

  • sendto() 的地址参数属于输入型参数——用户程序告诉内核:“我要发给谁。”

  • recvfrom() 的地址参数属于输出型参数——内核告诉用户程序:“这份数据是谁发来的。”

一句话总结全篇

Socket 是横跨用户程序与内核协议栈之间的一座桥梁。程序不会直接操作网卡,网卡也不认识聊天消息或者 JSON 数据,双方唯一共同的语言,就是字节流。


下一篇,我们就用这些接口,搭建 Arcane Campus Online 的第一个 UDP 聊天室 Demo。

五、知识点汇总

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

stellanke

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值