ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

计算机网络篇4:socket 编程接口与 sockaddr 通用结构的设计

计算机网络篇4:socket 编程接口与 sockaddr 通用结构的设计


📚 本文收录于「流浪」的系列专栏

🐧Linux系统⚙️C++
📊数据结构与算法🐍Python
🔗LangChain & LangGraph🗄️MySQL 数据库
🌿Git 工具🌐计算机网络
🤖LLM💯大厂面试、八股
📚学习筑基专栏

🏠 博客主页:流浪 | 📝 原创首发于 CSDN


前言:计算机网络篇3把「数据找到进程」的最后一段路修通了,端口号加 IP 拼成 socket,全网唯一定位一个进程。但 socket 不只是个定位坐标,它还是一组编程接口的名字——socket、bind、listen、accept、connect 五个接口共用一套参数规范,统一的入口是 sockaddr。本篇讲清四件事:五个接口各自干什么、为什么一种接口要同时管网络、本地、原始三种通信、sockaddr 靠前两字节怎么把不同结构区分开、填地址前还要过的字节序这一关。


一、传输层的两个典型协议 TCP 与 UDP

1.1 TCP 与 UDP 在连接、可靠性、数据形态上正好相反

1. TCP 是传输层有连接、可靠传输、面向字节流的协议
  1. TCP 是传输层协议,通信前要先建立连接,建好连接再传数据
  2. 它保证可靠传输,发出去的数据对方收不到会重传,顺序也不会乱
  3. 数据形态是面向字节流,发方写进去的是一串字节,收方按流的方式读
2. UDP 是传输层无连接、不可靠传输、面向数据报的协议
  1. UDP 也是传输层协议,但不建立连接,发方直接把数据报扔出去就完事
  2. 它不保证可靠传输,丢了不重传、乱了不重排,发出去就不管了
  3. 数据形态是面向数据报,发方发一个完整的报文,收方也收一个完整的报文
3. TCP 可靠要做更多事,复杂、占有资源多;UDP 不可靠简单、用资源少
  1. TCP 要维持连接、要确认重传、要管顺序,做的事情多,协议就复杂,占有的系统资源也多
  2. UDP 什么都不保证,协议头短、逻辑简单,用的资源自然少
  3. 可靠和简单是一组取舍,要可靠就得付出复杂度和资源的代价
4. 业务场景按可靠性需求选用 TCP 或 UDP
  1. 对可靠性要求高的场景,比如网页、文件传输、支付,选 TCP
  2. 对实时性要求高、能容忍少量丢包的场景,比如直播、语音、游戏,选 UDP
  3. 选型的判据就一条:业务能不能容忍丢包,不能容忍选 TCP,能容忍选 UDP

TCP 可靠但复杂、占资源多,UDP 简单但不可靠、用资源少,选谁由业务对可靠性的需求决定。


二、socket 编程接口的五个常见 API

2.1 socket、bind、listen、accept、connect 是网络编程的五件套

#include<sys/socket.h>
1. socket 创建一个套接字,返回一个文件描述符
// 创建套接字:domain 填地址族(AF_INET 即 IPv4),type 填类型(SOCK_STREAM 即 TCP / SOCK_DGRAM 即 UDP),// protocol 填协议(0 表示按前两个参数选默认协议)// 返回值:成功返回套接字的文件描述符,失败返回 -1intsocket(intdomain,inttype,intprotocol);
  1. socket 这个接口干的事就是创建一个套接字,返回值是一个文件描述符
  2. 套接字在 Linux 里也是一种文件,往后对它的读写就和对普通文件读写一样
  3. 创建时得告诉它要哪种地址族、哪种类型、用哪个协议,比如 AF_INET 加 SOCK_STREAM 加 0 就是 IPv4 的 TCP 套接字
2. bind 把套接字和本地的 IP 加端口号绑在一起
// 绑定地址:sockfd 是 socket 返回的描述符,addr 是填好的地址结构(要强转成 sockaddr*),addrlen 是地址结构的大小// 返回值:成功返回 0,失败返回 -1intbind(intsockfd,conststructsockaddr*addr,socklen_t addrlen);
  1. socket 刚创建出来还没绑定地址,bind 干的就是把一个 IP 和端口号绑到这个套接字上
  2. 服务器必须 bind,因为它要在某个固定端口上等客户端来连;客户端一般不显式 bind,让系统自动分配端口
  3. 绑定之后,发到这个 IP 加端口的报文就会被内核交给这个套接字
3. listen 把套接字置为监听状态,准备接收连接
// 开启监听:sockfd 是已绑定的套接字描述符,backlog 是等待连接队列的最大长度// 返回值:成功返回 0,失败返回 -1intlisten(intsockfd,intbacklog);
  1. bind 只是占了地址,还没进入「能接连接」的状态,listen 干的就是把这步走完
  2. 调用 listen 后,套接字从普通的未连接状态变成被动套接字,开始监听客户端的连接请求
  3. 它还有一个参数指定等待连接队列的长度,超过这个数的连接请求会被拒绝
4. accept 从已完成连接队列里取一个连接,返回新的文件描述符
// 接受连接:sockfd 是监听描述符,addr 用来存客户端的地址(不关心可传 NULL),addrlen 是地址结构大小的指针// 返回值:成功返回新连接的文件描述符(用它和客户端通信,原监听描述符继续接新连接),失败返回 -1intaccept(intsockfd,structsockaddr*addr,socklen_t*addrlen);
  1. accept 是服务器专用的接口,它从已完成连接的队列里取出一个连接
  2. 取出来后返回的是一个新的文件描述符,这个新 fd 才是用来和客户端真正通信的,原来的监听 fd 继续接新连接
  3. 如果没有已完成连接,accept 会阻塞,直到有客户端连进来
5. connect 是客户端用来向服务器发起连接的接口
// 发起连接:sockfd 是客户端套接字描述符,addr 是服务器的地址结构(要强转成 sockaddr*),addrlen 是地址结构的大小// 返回值:成功返回 0(TCP 下内部走完三次握手),失败返回 -1intconnect(intsockfd,conststructsockaddr*addr,socklen_t addrlen);
  1. connect 是客户端用的,它拿着服务器的 IP 和端口号,主动向服务器发起连接
  2. 对 TCP 来说,connect 内部会触发三次握手,握手成功连接才算建立
  3. 连接建立后,客户端就可以用这个套接字发数据了

五个接口各管一段:socket 建描述符、bind 占地址、listen 开监听、accept 取连接、connect 发起连接,串起来就是一次通信的完整流程。

2.2 五个接口的参数里都出现了 const struct sockaddr* addr,它就是接口规范的统一入口

1. 五个接口的入参里都有一段 const struct sockaddr* addr
  1. 看上面的函数签名,bind、connect、accept 的参数里都有一段 const struct sockaddr* addr
  2. 不管是绑定本地地址、连接远端地址、还是取出客户端地址,传进去的地址指针都是 sockaddr*
  3. 但 sockaddr 这个结构本身又很简单,只有一个地址族加一段 14 字节的原始数据,根本看不出 IP 和端口在哪
2. sockaddr 是接口规范的统一入口,五个接口都靠它收地址
  1. 既然真正要填的是 IP 和端口号,为什么接口不直接收 sockaddr_in,偏偏要收 sockaddr
  2. 这个问题的答案就是 socket 接口的设计规范:设计者只想做一种通信接口,让它能同时管网络、本地、原始三种通信
  3. sockaddr 就是这套规范的统一入口,它的设计动机和区分机制是本篇接下来的主线

五个接口共用一套参数规范,sockaddr 是这套规范的统一入口。


三、网络通信的本质是进程间通信,socket 要管三种通信

3.1 网络通信沿用进程间通信的老前提,POSIX 统一了网络与本地

1. 网络的本质是进程间通信,socket 就是这层通信的入口
  1. 篇3已经讲过,网络通信的本质就是跨主机的进程间通信,两个进程借着网络读写数据
  2. socket 就是这层进程间通信的编程入口,进程通过它接进网络
2. System V 是本地进程间通信的标准,管不了跨主机
  1. 在 socket 出现之前,进程间通信走的是 System V 那一套,比如消息队列、共享内存、信号量
  2. System V 是本地进程间通信标准,它只能管同一台主机上的进程,管不了跨主机
  3. 这一套在 Linux 系统系列里已经讲过,本篇只做技术层面的对照
3. POSIX 标准是网络通信标准,但它也能进行本地通信
  1. socket 接口走的是 POSIX 标准,它一开始就是为网络通信设计的
  2. 但 POSIX 的 socket 不只能做网络通信,它也能做本地通信,也就是Unix 域间通信
  3. POSIX 既管网络又管本地,一套接口把两件事都覆盖了
4. POSIX 既管网络又管本地,统一了标准,System V 因此被淘汰
  1. System V 只能做本地通信,要做网络通信得另学一套,标准割裂
  2. POSIX 的 socket 一套接口既能做网络又能做本地,标准统一,学一套就够
  3. 标准统一带来的便利让 POSIX 成了主流,System V 在网络时代就逐渐被淘汰了

POSIX 一套接口把网络和本地都收编了,标准统一是 System V 退场的根本原因。

3.2 三种 socket:网络 socket、本地 socket、原始 socket

1. 网络 socket 用来做跨主机的进程间通信
  1. 网络 socket 是最常见的,两台主机上的进程隔着网络通信就靠它
  2. 它的地址结构里填的是 IP 加端口号,前面篇3讲的就是这一种
2. 本地 socket 用来做同主机的进程间通信
  1. 本地 socket 也叫 Unix 域间 socket,它只管同一台主机上两个进程的通信
  2. 它不走网络,地址结构里填的是文件系统里的一个路径,靠这个路径文件把两个进程关联起来
3. 原始 socket 用来直接操作底层协议
  1. 原始 socket 绕过传输层,直接操作底层的协议报文
  2. 它一般用来抓包、自定义构造报文、做协议分析,普通业务开发几乎用不到
4. 不同应用场景导致通信特性不同,所以分三种
  1. 跨主机要寻址就要 IP 加端口,同主机不跨网络用文件路径就够,抓包要绕过传输层就得直接操作底层报文
  2. 三种场景对地址结构的要求完全不同,所以 socket 分成网络、本地、原始三种
  3. 但接口只有一套,这一套接口要同时管这三种通信,这就引出了 sockaddr 的设计

一套 socket 接口要同时管网络、本地、原始三种通信,这是 sockaddr 出现的根本原因。


四、sockaddr 通用结构:一种接口支持三种通信

4.1 设计者只想做一种通信接口,于是设计出 sockaddr 通用结构

1. socket 设计者不想为每种通信各做一套接口
  1. 如果为每种通信各做一套接口,网络通信一套 bind_in、本地通信一套 bind_un、原始通信一套 bind_raw,接口数量就爆炸了
  2. 而且这三套接口的流程其实都一样:创建套接字、绑定地址、监听、接连接,只是地址结构不同
  3. 设计者不想重复造接口,只想做一种通信接口,让它能兼容所有场景
2. 为了让一种接口支持不同场景的通信,设计出 sockaddr 通用结构
  1. 解决思路是抽出一个通用结构 sockaddr,作为所有接口的统一参数类型
  2. sockaddr 只定义所有地址结构都有的公共部分,也就是地址族加一段原始字节
  3. 具体的地址信息留给各自的专用结构去填,接口只认 sockaddr 这一层

sockaddr 用「只定义公共部分、具体留给派生结构」的思路,让一种接口兼容了所有通信场景。

4.2 sockaddr_in 是网络通信结构,要端口号和 IP

1. struct sockaddr_in 是网络通信专用的地址结构
  1. 做网络通信时,真正填地址用的是 struct sockaddr_in 这个结构
  2. 它是 sockaddr 在网络场景下的具体化,把 sockaddr 里那段原始字节拆成了有名字的字段
2. sockaddr_in 里要填端口号和 IP 地址
  1. sockaddr_in 里有一个 16 位的 sin_port 字段填端口号
  2. 还有一个 32 位的 sin_addr 字段填 IPv4 地址
  3. 这两个字段正好对应篇3讲的「IP 定位主机、端口定位进程」,拼起来就是网络进程的身份

sockaddr_in 把 sockaddr 里的原始字节拆成了有名字的端口和 IP 字段,这是网络场景下对通用结构的具体化。

4.3 sockaddr_un 是本地通信结构,要文件路径

1. struct sockaddr_un 是本地通信专用的地址结构
  1. 做本地通信时,真正填地址用的是 struct sockaddr_un 这个结构
  2. 它是 sockaddr 在本地场景下的具体化,字段和 sockaddr_in 完全不同
2. 本地通信用文件路径当地址,不需要像普通文件那样 open 和 read
  1. sockaddr_un 里有一个 sun_path 字段,填的是文件系统里的一个路径
  2. 这个路径文件只是个占位的地址标记,不像普通文件那样要 open、read、write 去读写内容
  3. 内核靠这个路径把两个进程的套接字关联起来,通信走的是内核缓冲区,不落盘

sockaddr_un 用文件路径当地址,本地通信不跨网络,地址结构和网络的 sockaddr_in 完全不同。

4.4 传参时按场景填 sockaddr_in 或 sockaddr_un,接口统一收 sockaddr

1. 网络通信就填 sockaddr_in
  1. 做网络通信时,先填好一个 sockaddr_in 结构,把地址族设成 AF_INET,端口和 IP 填进去
  2. 传给 bind 或 connect 时,把这个 sockaddr_in 的指针传进去
2. 本地通信就填 sockaddr_un
  1. 做本地通信时,先填好一个 sockaddr_un 结构,把地址族设成 AF_UNIX,路径填进去
  2. 传给 bind 或 connect 时,把这个 sockaddr_un 的指针传进去
3. 接口形参统一是 sockaddr*,调用时强转传入
  1. 不管传的是 sockaddr_in 还是 sockaddr_un,接口的形参统一都是 const struct sockaddr*
  2. 调用时要把具体结构的指针强制转换成 sockaddr* 再传入,比如 (struct sockaddr*)&serv
  3. 接口拿到这个指针后,再靠地址族字段判断真实结构,这就是 sockaddr 这套规范的核心
//网络通信// 1. 定义并填充 sockaddr_instructsockaddr_inserver_addr;server_addr.sin_family=AF_INET;// IPv4地址族server_addr.sin_port=htons(8888);// 端口,转网络字节序server_addr.sin_addr.s_addr=inet_addr("127.0.0.1");//IP地址memset(&server_addr.sin_zero,0,8);// 置0,对齐intfd=socket(AF_INET,SOCK_STREAM,0);// 2. 强制转成 struct sockaddr* 传给bindbind(fd,(structsockaddr*)&server_addr,sizeof(server_addr));//本地通信//1. 定义填充 sockaddr_unstructsockaddr_unun_addr;un_addr.sun_family=AF_UNIX;// UNIX域地址族strcpy(un_addr.sun_path,"/tmp/mysock");// 本地套接字文件路径intfd=socket(AF_UNIX,SOCK_STREAM,0);//2. 强制转成 struct sockaddr* 传给bindbind(fd,(structsockaddr*)&un_addr,sizeof(un_addr));

sockaddr 是接口规范的统一入口,一种形参收三种实参结构,靠强转和地址族字段把两边对接起来。


五、前两字节的地址类型:sockaddr 怎么做多态分发

5.1 sockaddr_in 和 sockaddr_un 的前两字段都是地址类型

1. sockaddr_in 的前两字段是 sin_family,存的是地址类型
  1. sockaddr_in 的第一个字段是 sin_family,类型是 sa_family_t,占 16 位
  2. 这个字段存的就是地址类型,告诉内核这个结构是哪种地址族
2. sockaddr_un 的前两字段是 sun_family,存的也是地址类型
  1. sockaddr_un 的第一个字段是 sun_family,类型同样是 sa_family_t,占 16 位
  2. 不管是网络结构还是本地结构,第一个字段都是地址类型,只是名字不同
3. 网络通信填 AF_INET,本地通信填 AF_UNIX,用来区分类型
  1. 做 IPv4 网络通信时,sin_family 填 AF_INET
  2. 做本地通信时,sun_family 填 AF_UNIX
  3. 内核就是靠这个字段的值,区分传进来的到底按哪种结构解析

三个具体结构的首字段都是 16 位地址类型,填的值不同就代表不同的通信场景。

5.2 sockaddr 头部也有 16 位地址类型字段,靠它区分按哪种结构解析

1. sockaddr 头部也有同样的 16 位地址类型字段
  1. sockaddr 这个通用结构的第一个字段是 sa_family,也是 sa_family_t,占 16 位
  2. 它和 sockaddr_in 的 sin_family、sockaddr_un 的 sun_family 在内存里是同一个位置
  3. 因为强转只是换了指针类型,内存布局没变,所以三个结构的第一个字段永远重叠
2. 传参统一传 sockaddr,内核靠前两字节判断真实结构
  1. 应用层传进来的是 sockaddr*,内核拿到后先读前两个字节的 sa_family
  2. 读出来是 AF_INET,就知道真实结构是 sockaddr_in,按网络结构的字段去解析
  3. 读出来是 AF_UNIX,就知道真实结构是 sockaddr_un,按本地结构的字段去解析
3. 一种形参、多种实参,这就是 socket 接口的多态分发
  1. C 语言没有面向对象的多态,但 socket 接口靠「公共首字段加强转」模拟出了多态
  2. sockaddr 相当于基类,sockaddr_in、sockaddr_un 相当于派生类,sa_family 相当于类型标签
  3. 这是一种形参收多种实参的经典设计,用 C 的内存布局规则做到了运行时按类型分发

前两字节的地址类型是 sockaddr 多态的钥匙,内核靠它判断该按哪种结构解析。


六、文末面试题

6.1 推导题

1. 为什么 socket 接口要设计成一种通用接口管三种通信,而不是每种通信各做一套?

答(推导):如果每种通信各做一套接口,网络通信一套 bind_in、本地通信一套 bind_un、原始通信一套 bind_raw,接口数量会随地址族数量线性膨胀。但这三套接口的调用流程其实完全一样——创建套接字、绑定地址、监听、接连接,只是地址结构不同。设计者抽出一个通用结构 sockaddr 作为统一参数类型,让具体地址结构强转成 sockaddr* 传入,接口只认这一层,新增地址族时接口签名不用改。这是一种接口数量收敛、扩展性强的设计,比起各做一套更合理。

2. socket 为什么要分成网络、本地、原始三种?分流的依据是什么?

答(推导):三种通信场景对地址结构的要求完全不同——跨主机要 IP 加端口寻址、同主机不跨网络用文件路径就够、抓包要绕过传输层直接操作底层报文,地址结构填的字段不一样,所以得分三种。但分流依据不是协议层而是应用场景:跨主机场景用网络 socket、同主机场景用本地 socket、底层操作场景用原始 socket,每种场景对应一种地址结构和一种通信特性。按场景分流让每种通信各得其所,又能共用同一套接口流程,是「接口统一、结构分流」的设计。

3. sockaddr 和 sockaddr_in 是什么关系?传参时为什么要强转?

答(推导):sockaddr 是通用地址结构,相当于基类,只定义公共的地址族字段加一段原始字节;sockaddr_in 是 IPv4 网络通信的专用结构,相当于派生类,把原始字节拆成了有名字的端口和 IP 字段。两者的关系是「通用容器」和「具体填充」的关系。接口形参统一收 sockaddr,但应用层真正填的是 sockaddr_in,所以传参时要强转成 sockaddr。强转能成立的前提是 sockaddr_in 的第一个字段 sin_family 和 sockaddr 的第一个字段 sa_family 在内存里同一位置,C 语言保证结构体首成员地址等于结构体地址,所以强转后内核读前两字节拿到的就是地址族,能安全判断真实结构。

4. 为什么靠前两字节的地址类型做多态分发,而不是靠长度或额外标志位?

答(推导):靠前两字节的地址类型,是因为结构体的首成员地址等于结构体地址,C 语言保证这一点,强转后首字段位置不变,内核读前两字节一定能拿到正确的地址族。靠长度不行,因为长度要靠额外的 addrlen 参数传,内核没法只看指针就判断类型;靠额外标志位也不行,等于在结构体里再加一个字段,还是回到首字段分发的路子。地址族字段本来就是地址结构必须有的(要标识自己属于哪种地址),把它放在首字段,既满足多态分发的需要,又不增加额外开销,是复用已有字段的最优解。

6.2 真题

1. socket 编程有哪些原语?说一下各自的作用。

答(推导 · 已对照解析,转述):socket 创建一个套接字,返回文件描述符作为后续操作的句柄;bind 把套接字和本地地址(IP 加端口)绑定,服务器必须调用以在固定端口上等待连接;listen 把套接字置为监听状态,并指定等待连接队列的长度;accept 由服务器调用,从已完成连接队列取出一个连接,返回新的文件描述符用于和客户端通信,原监听 fd 继续接新连接;connect 由客户端调用,向服务器发起连接,对 TCP 来说内部触发三次握手。这五个原语串起来就是一次完整的 socket 通信流程:服务器端 socket → bind → listen → accept,客户端 socket → connect。
【真题·转述自 腾讯面经整理:Socket 原语和通信原理】

2. sockaddr 和 sockaddr_in 的区别是什么?为什么调用 bind 时要强制类型转换?

答(推导 · 已对照解析,转述):sockaddr 是通用地址结构,只含地址族 sa_family 和一段 14 字节的原始数据 sa_data;sockaddr_in 是 IPv4 专用结构,把原始数据拆成了具体的 sin_port(端口)、sin_addr(IP)、sin_zero(填充位)。区别在于 sockaddr 是通用容器、sockaddr_in 是具体填充。bind 等接口的形参统一是 sockaddr*,所以传 sockaddr_in 时要强转。强转能成立是因为两者的首字段都是地址族,C 语言保证结构体首成员地址等于结构体地址,强转后首字段位置重叠,内核读前两个字节的地址族就能判断真实结构再向下转型。这是 C 语言用结构体内存布局模拟面向对象多态的经典设计。
【真题·转述自 CSDN 博客:sockaddr 和 sockaddr_in 的区别与联系】


这一篇把 socket 的接口规范从头到尾串了一遍:五个接口共用一套参数,一种形参收网络、本地、原始三种实参,靠前两字节的地址类型做多态分发,最后用四个字节序函数把字段转成网络大端。评论区聊聊:你第一次看到 (struct sockaddr*)&serv 这种强转时,有没有想过它凭什么不会转出错?

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进