【Linux网络】Socket 编程前要搞懂什么?从 IP、端口到字节序与 sockaddr,一次理清网络通信基础

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》

🐶学习方向:C++方向学习爱好者

⭐人生格言:得之坦然,失之淡然


🏠博主简介

文章目录

  • 前言
  • 一、网络通信最后要把数据交给谁
    • [1.1 IP 地址先解决主机定位](#1.1 IP 地址先解决主机定位)
    • [1.2 只有 IP 还不够](#1.2 只有 IP 还不够)
  • [二、为什么不能直接使用 PID](#二、为什么不能直接使用 PID)
    • [2.1 PID 只在本机进程管理中有效](#2.1 PID 只在本机进程管理中有效)
    • [2.2 端口号怎样找到进程](#2.2 端口号怎样找到进程)
    • [2.3 服务器端口和客户端端口](#2.3 服务器端口和客户端端口)
  • 三、端口范围与网络通信四元组
    • [3.1 端口号的范围](#3.1 端口号的范围)
    • [3.2 一个网络端点与一次通信](#3.2 一个网络端点与一次通信)
    • [3.3 Socket 为什么仍然表现为文件](#3.3 Socket 为什么仍然表现为文件)
  • [四、先认识 TCP 和 UDP](#四、先认识 TCP 和 UDP)
    • [4.1 TCP 的直观特点](#4.1 TCP 的直观特点)
    • [4.2 UDP 的直观特点](#4.2 UDP 的直观特点)
  • 五、网络字节序
    • [5.1 大端和小端在讨论什么](#5.1 大端和小端在讨论什么)
    • [5.2 网络统一使用大端](#5.2 网络统一使用大端)
  • [六、Socket 编程常见接口](#六、Socket 编程常见接口)
    • [6.1 一组接口为什么能支持多种协议](#6.1 一组接口为什么能支持多种协议)
    • [6.2 `sockaddr` 只是通用接口类型](#6.2 sockaddr 只是通用接口类型)
    • [6.3 IPv4 使用 `sockaddr_in`](#6.3 IPv4 使用 sockaddr_in)
    • [6.4 `in_addr` 保存 IPv4 地址](#6.4 in_addr 保存 IPv4 地址)
  • 七、把预备知识串起来
  • 总结

前言

前面已经梳理了网络协议的整体框架,但真正开始写网络程序时,还会遇到几个非常具体的问题:数据到达目标主机以后,操作系统怎么知道该交给哪个进程?为什么网络报文里使用端口号,而不是直接填写进程 PID?不同主机的字节序可能不同,双方又怎样正确解释同一段数据?

这些问题不解决,后面直接调用 socket、bind、recvfrom,很容易只记住接口形式,却不明白参数为什么这样设计。

这一篇先不急着写完整服务器,而是把 Socket 编程需要的预备知识串起来。重点包括:

  • IP 地址和端口号分别解决什么问题;
  • PID 为什么不能替代端口号;
  • 一次网络通信怎样由四元组标识;
  • TCP 和 UDP 在这一阶段应该怎样理解;
  • 网络字节序为什么统一采用大端;
  • sockaddr、sockaddr_in 和 in_addr 之间是什么关系。

把这些内容理清,下一篇再写 UDP 服务器时,socket 接口里的每个参数就不再是需要死记的东西。


一、网络通信最后要把数据交给谁

1.1 IP 地址先解决主机定位

在网络通信中,IP 地址用来标识通信端点所在的主机或网络接口。初学阶段可以先把它理解为:IP 负责帮助报文找到目标主机。

但数据到达主机并不是最终目的。刷视频时,网络数据最终要交给视频应用;浏览网页时,网页数据要交给浏览器;聊天时,消息也要交给正在运行的聊天程序。

这些应用运行起来以后,本质上都是进程。于是网络通信可以继续向下理解:

  1. IP 地址先把报文送到目标主机;
  2. 操作系统再把报文交给目标主机中的某个网络进程;
  3. 进程读取数据并完成后续处理。

日常上网看起来有很多行为,站在网络程序的角度,无非是两类:从远端服务器获取数据,或者把本地数据发送到远端服务器。网卡负责收发,进程负责处理,二者之间要经过操作系统完成 I/O。

因此可以得到一个很重要的认识:

网络通信表面上是两台主机在通信,本质上是两台主机中的两个进程在交换数据。

在本地进程间通信中,我们会想办法让两个进程看到同一份资源;到了网络环境中,通信双方共同使用的资源就是网络,而操作系统协议栈负责把这份资源抽象成进程可以读写的接口。

1.2 只有 IP 还不够

一台主机上通常同时运行许多进程。假设报文已经送到服务器,但是报文中只有服务器 IP,内核仍然不知道应该交给 Web 服务、数据库服务,还是某个正在运行的测试程序。

所以还需要第二个编号,用来在主机内部标识网络服务,这就是端口号。

端口号属于传输层协议字段,是一个 16 位无符号整数,取值范围是 0~65535。它不是进程自身的编号,而是进程通过 Socket 绑定和使用的网络编号。

可以暂时这样记:

  • IP 地址负责找到主机;
  • 端口号负责在目标主机中找到对应的网络服务;
  • IP 地址和端口号组合起来,得到一个网络端点。

二、为什么不能直接使用 PID

2.1 PID 只在本机进程管理中有效

学习进程时,PID 已经能够唯一标识本机中的一个进程。既然如此,为什么网络协议还要额外设计端口号?

第一个原因是作用范围不同。PID 是操作系统内部管理进程的编号,只要求在当前主机、当前运行周期内有效。两台主机完全可能同时存在 PID 为 1234 的进程,而且它们可能是两个毫无关系的程序。

例如:

  • 主机 A 上 PID 为 1234 的进程是浏览器;
  • 主机 B 上 PID 为 1234 的进程恰好是游戏;
  • 如果报文只写"交给 PID 1234",主机 B 根本无法由此判断发送方想访问什么服务。

第二个原因是 PID 会变化。程序重新启动后,操作系统很可能为它分配新的 PID。服务器需要给客户端提供相对稳定的访问入口,而不能要求每个客户端在通信前先询问服务进程当前的 PID。

第三个原因是并非所有进程都需要联网。每个进程都有 PID,但只有需要网络通信的程序才需要创建 Socket、绑定或使用端口。把进程管理编号直接塞进网络协议,会使两个本来可以独立变化的模块产生不必要的耦合。

程序通常不能要求操作系统为自己分配某个固定 PID,却可以为网络服务绑定约定好的端口。 这正是服务器能够长期对外提供稳定入口的基础。

2.2 端口号怎样找到进程

端口并不是独立飘在内核中的一个数字。应用程序创建 Socket 后,内核会建立相应的套接字对象;服务器执行 bind 后,本地 IP、端口、协议等信息会记录到这个对象中,并进入内核用于查找网络端点的数据结构。

网络报文到达传输层时,TCP 或 UDP 首部中带有端口号。内核根据协议、地址和端口等字段查找对应 Socket,再把有效载荷放进该 Socket 的接收队列,最终由进程通过文件描述符读走。

这里需要把一句常见的初学结论说严谨些:同一个端口能否重复绑定,实际还要看传输层协议、本地地址和 Socket 选项。当前先按照最常见的服务器场景理解:在同一种协议、同一个本地地址上,一个端口不能被两个普通 Socket 随意重复占用。

2.3 服务器端口和客户端端口

服务器要对外提供服务,因此端口通常是提前约定的。例如 HTTP 常用 80,HTTPS 常用 443,DNS 常用 53。客户端只有知道目标服务器的 IP 和端口,才能构造目标地址。

客户端自身也需要一个端口,但它通常不必固定。以浏览器访问服务器为例:

text 复制代码
客户端:192.168.1.10:60123
服务器:203.0.113.20:443

请求报文可以描述为:

text 复制代码
源端点 192.168.1.10:60123  ->  目的端点 203.0.113.20:443

服务器收到报文后,报文里已经带着客户端的源 IP 和源端口。回复时把方向反过来即可:

text 复制代码
源端点 203.0.113.20:443  ->  目的端点 192.168.1.10:60123

因此,服务器的端口要稳定,客户端端口只要在本机当前通信中能够区分对应 Socket 即可,通常由操作系统临时分配。

三、端口范围与网络通信四元组

3.1 端口号的范围

端口号占 16 位,所以一共有 2^16 个取值,也就是 0~65535。实际使用时可以先按下面的范围理解:

  • 0~1023:知名端口,常见基础服务会使用这些约定端口;
  • 1024~65535:可用于注册服务或动态分配,客户端临时端口通常从系统配置的动态范围中选择。

这里不要把"1024 以上全部都是客户端随机端口"理解得太死。某个业务服务器完全可以主动绑定 8080、8888 等高位端口,只是它们不属于前面的知名端口范围。

3.2 一个网络端点与一次通信

单独的 IP + port 描述的是一个网络端点。一段完整通信还需要同时知道两端信息:

text 复制代码
{ 源IP,源端口,目的IP,目的端口 }

这四项通常称为四元组。对于一个已经到达传输层的报文,四元组回答了两个问题:数据是谁发来的,要交给谁。

例如客户端访问 Web 服务时:

text 复制代码
{ 192.168.1.10,60123,203.0.113.20,443 }

响应报文中的源和目的会交换。内核正是根据这些信息以及协议类型,确定报文属于哪个 Socket。

IP 加端口描述网络端点;源 IP、源端口、目的 IP、目的端口共同描述一次通信的两端。Socket 则是内核中承载通信状态、缓冲区和地址信息的对象,不能简单等同于"IP 加端口"这个地址。

3.3 Socket 为什么仍然表现为文件

在 Linux 中,应用程序调用 socket() 后会得到一个文件描述符。这个描述符指向内核创建的 Socket 对象,进程随后通过它调用 recvfrom、sendto、read、write 等接口。

"一切皆文件"在这里体现为统一的访问方式,而不是说网卡、磁盘和普通文件在底层结构上完全相同。内核为不同对象提供不同操作,但向用户层暴露统一的文件描述符入口。

于是应用程序不需要直接操纵网卡:

  1. 进程把数据交给 Socket;
  2. 内核协议栈添加各层报头;
  3. 网卡驱动把最终数据发送出去;
  4. 接收端按相反方向处理,再把有效载荷交给目标 Socket。

四、先认识 TCP 和 UDP

传输层最常见的两个协议是 TCP 和 UDP。它们都能把数据交给目标端口,但提供的通信语义不同。

4.1 TCP 的直观特点

TCP 是传输控制协议,现阶段先记住四点:

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

"面向连接"表示正式传输数据前,双方要先建立连接。它有点像打电话:不能拿起电话就默认对方已经在听,而要先完成拨号和接通。

"可靠"并不表示物理网络永远不丢包,而是 TCP 会通过确认、序号、超时重传、流量控制等机制,尽量向上层提供可靠、有序的字节流。

"面向字节流"表示应用层交给 TCP 的数据,在传输层看来是一串连续字节。发送方调用两次 send,接收方不一定恰好用两次 recv 按原边界取出。应用层需要自行设计消息边界,这也是后面学习粘包问题的原因。

4.2 UDP 的直观特点

UDP 是用户数据报协议,现阶段同样先记住四点:

  • 无连接;
  • 不保证可靠交付;
  • 面向数据报;
  • 属于传输层协议。

UDP 发送数据前不需要先建立类似 TCP 的连接。应用程序准备好目标地址后,就可以直接通过 sendto 发送数据。

"不可靠"是协议能力描述,不是贬义。UDP 本身不负责重传、排序和流量控制,因此实现更简单、报头开销也更小。应用层如果需要,可以根据业务自行增加确认和重传机制。

"面向数据报"表示每次发送都有明确边界。发送方调用一次 sendto 交给 UDP 的一份数据,在接收方仍以一份数据报的形式被读取,不会像 TCP 字节流那样天然失去发送边界。当然,如果接收缓冲区太小,超出部分可能被截断,所以缓冲区大小仍要合理设置。

直播、语音、部分实时游戏可能允许少量丢包,更关心及时性;文件传输、网页访问、转账等场景通常更关心完整性。选择哪种协议,取决于业务需要,而不是简单判断哪个协议"更高级"。

五、网络字节序

5.1 大端和小端在讨论什么

一个多字节整数存入内存时,要决定高权值字节和低权值字节分别放在哪个地址。

假设一个 32 位整数的十六进制形式是:

text 复制代码
0x12 34 56 78
  • 大端:高权值字节 0x12 放在低地址;
  • 小端:低权值字节 0x78 放在低地址。

大小端本身没有对错,它只是不同体系结构对多字节数据的存储约定。但网络中的两台机器可能采用不同主机字节序。如果直接把内存里的 16 位端口原样发送,接收方就可能按相反顺序解释它。

5.2 网络统一使用大端

为了让不同主机能够正确通信,TCP/IP 规定多字节整数在网络中统一使用大端字节序,也叫网络字节序。

发送方在发送前,把主机字节序转换为网络字节序;接收方拿到数据后,再根据本机情况转换回主机字节序。若主机本来就是大端,相应转换函数通常不会改变数据;若主机是小端,则会交换字节顺序。

常用接口如下:

cpp 复制代码
#include <arpa/inet.h>

uint16_t htons(uint16_t hostshort); // 16位:主机序 -> 网络序
uint16_t ntohs(uint16_t netshort);  // 16位:网络序 -> 主机序
uint32_t htonl(uint32_t hostlong);  // 32位:主机序 -> 网络序
uint32_t ntohl(uint32_t netlong);   // 32位:网络序 -> 主机序

函数名可以拆开记:

  • h:host,主机;
  • n:network,网络;
  • s:short,16 位;
  • l:long,这组接口中表示 32 位。

端口号是 16 位,所以设置 sockaddr_in::sin_port 时使用 htons;读取对端端口时使用 ntohs。

凡是放进网络报文的多字节整数,都要按接口要求转换为网络字节序;从报文中取出后,再转换为主机字节序。

六、Socket 编程常见接口

6.1 一组接口为什么能支持多种协议

Linux 提供的 Socket API 不只服务于 IPv4,还要支持 IPv6、UNIX 域套接字等不同地址类型。如果为每种协议各设计一套完全不同的函数,接口会非常零散。

因此,常见函数统一接收 struct sockaddr*,真正使用时再把具体地址结构转换成这个通用指针。典型接口如下:

cpp 复制代码
#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

其中:

  • socket 创建套接字,TCP 和 UDP、客户端和服务器都要使用;
  • bind 把本地地址绑定到 Socket,服务器最常见;
  • listen、accept 用于 TCP 服务器;
  • connect 常用于 TCP 客户端,UDP 也可以调用,但语义与 TCP 不同。

6.2 sockaddr 只是通用接口类型

sockaddr 中最重要的字段是地址族。内核读取地址族后,才知道后面的数据应该按 IPv4、IPv6 还是 UNIX 域地址来解释。

它看起来有点像 C++ 中"统一基类指针接收不同派生类型"的效果,但这里并不存在真正的 C++ 继承和虚函数。它只是 C 接口采用的一种统一传参约定。

调用 bind 时经常看到这样的强制类型转换:

cpp 复制代码
struct sockaddr_in local = {};

int ret = bind(sockfd,
               reinterpret_cast<struct sockaddr*>(&local),
               sizeof(local));

应用程序实际填写的是 sockaddr_in,传给通用接口时才转成 sockaddr*。内核再根据开头的地址族字段解释具体结构。

6.3 IPv4 使用 sockaddr_in

IPv4 网络编程实际使用的地址结构是 sockaddr_in:

常用成员可以整理为:

cpp 复制代码
struct sockaddr_in {
    sa_family_t    sin_family; // 地址族,IPv4填写AF_INET
    in_port_t      sin_port;   // 16位端口,使用网络字节序
    struct in_addr sin_addr;   // 32位IPv4地址
};

初始化一个 IPv4 地址时,通常先清零,再分别填写三个关键字段:

cpp 复制代码
struct sockaddr_in local;
memset(&local, 0, sizeof(local));

local.sin_family = AF_INET;
local.sin_port = htons(8080);
local.sin_addr.s_addr = htonl(INADDR_ANY);

AF_INET 表示 IPv4 地址族;端口要通过 htons 转成网络字节序;INADDR_ANY 表示接受到达本机任意 IPv4 接口、对应端口的数据。

6.4 in_addr 保存 IPv4 地址

sockaddr_in 中的 sin_addr 类型是 struct in_addr,它本质上保存一个 32 位 IPv4 地址。

人更习惯写 192.168.1.10 这样的点分十进制字符串,内核接口却需要 32 位二进制形式。因此后面的 UDP 程序还会使用地址转换函数,例如 inet_pton、inet_ntop,把字符串和网络地址结构互相转换。

七、把预备知识串起来

现在从客户端发送一份 UDP 数据,可以把整个过程连起来:

  1. 客户端调用 socket(AF_INET, SOCK_DGRAM, 0) 创建 UDP Socket;
  2. 应用程序准备服务器的 sockaddr_in,填写地址族、端口和 IP;
  3. 端口通过 htons 转成网络字节序,IP 字符串转换为 32 位网络地址;
  4. 客户端调用 sendto,数据进入内核协议栈;
  5. UDP 首部记录源端口和目的端口,IP 首部记录源 IP 和目的 IP;
  6. 报文到达服务器后,内核根据协议、地址和端口找到对应 Socket;
  7. 服务器进程调用 recvfrom,从 Socket 接收队列中取走数据。

这个过程中,IP、端口、文件描述符和 Socket 各自承担不同职责:

  • IP 负责主机或接口定位;
  • 端口负责定位网络服务;
  • 文件描述符是进程访问内核 Socket 的入口;
  • Socket 是内核管理网络通信状态和缓冲区的对象;
  • sockaddr_in 是应用程序描述 IPv4 地址的结构。

这些概念如果混在一起,后面的代码会越写越乱;分清以后,接口参数反而很好理解。

总结

本文先完成了 Socket 编程前的准备:

  1. 网络通信最终要把数据交给进程,IP 找主机,端口找服务;
  2. PID 只服务于本机进程管理,动态变化,不能代替网络端口;
  3. IP + port 描述网络端点,通信双方由四元组共同标识;
  4. TCP 提供面向连接、可靠的字节流,UDP 提供无连接的数据报服务;
  5. 网络统一使用大端字节序,端口常用 htons 和 ntohs 转换;
  6. IPv4 编程实际填写 sockaddr_in,再转换成通用的 sockaddr* 传给接口。

下一篇在这些概念之上实现 UDP 回显服务器和网络字典,把 socket、bind、recvfrom、sendto 真正串成一条完整链路。

往期博客:

【Linux网络】网络为什么要分层?从协议、OSI 到 TCP/IP,一次搭起网络通信整体框架

【Linux网络】网络基础下篇:数据到底怎么从进程 A 到进程 B?从 MAC、IP、端口到 Socket 一次理清

【Linux】线程安全进阶:单例模式、可重入函数与死锁排查

相关推荐
晚风叙码1 小时前
Linux 进程概念(上):从冯诺依曼到进程调度
linux·运维·计算机外设
Z21-X1 小时前
云原生运维笔记:硬核拆解OpenStack计算与存储——从主机聚合到Cinder挂载全流程
运维·笔记·学习·云原生·openstack
weixin_417396831 小时前
酒店IPTV需要什么资质?内容授权与工程资质两条线要分开看
网络·技术·网络运维·酒店iptv·组播排查
邪修king1 小时前
Re:Linux系统篇(三十八):进程间通信ChapterSystem V 信号量深度解析|同步互斥、内核源码、C 指针多态、实战代码
java·linux·c语言
开开心心就好1 小时前
电脑语音实时转文字工具,会议记录很省事
运维·服务器·前端·c#·ocr·perl·1024程序员节
坤坤子吖1 小时前
Linux网络编程基础:从 MAC 到 IP,理解网络数据是怎么传输的
linux·网络·笔记·学习
明天也要努力学习1 小时前
深入理解数据链路层:以太网、MAC 地址与交换机
网络·网络协议
一只鹿鹿鹿2 小时前
医院绩效管理体系方案
大数据·运维·安全·web安全·制造
Eloudy2 小时前
`ibv_get_device_list` 实现分析
网络·gpu·rdma