Linux --- Socket 用户进程与内核网络协议栈之间的统一接口

一、Socket:内核中的通信端点

1.1 Socket定义

socket() 定义:creates an endpoint for communication ,Socket 是操作系统内核提供的通信端点抽象。

调用 socket() 成功后返回一个整数文件描述符(fd),但这个 fd 只是用户态的一个句柄,其背后对应内核中一个完整的 Socket 对象(struct sock),包含:

  • 协议族与套接字类型标识
  • TCP 连接状态机(ESTABLISHED、LISTEN、TIME_WAIT 等)
  • 本地/远端 IP 地址与端口号
  • 接收缓冲区(sk_receive_queue)、发送缓冲区(sk_sndbuf)
  • 连接等待队列、超时参数、拥塞控制参数、所有权信息等

Socket 是用户进程与内核网络协议栈之间的统一接口。

协议栈调用层次是:

应用层协议 → Socket API 系统调用 → 内核 TCP 层 → 内核 IP 层 → 数据链路层(以太网) → NIC 网卡硬件

对应到 FANUC 工业通信场景:

FANUC 私有应用协议 Request/Response → send()/recv() 函数调用 → TCP Socket 句柄 → TCP 协议传输 → IP 路由 → 以太网帧 → RJ45 网线 → 机器人控制器

1.2 socket() 三个参数

标准调用形式:

cpp 复制代码
int fd = socket(AF_INET, SOCK_STREAM, 0);

三个参数分别对应 domain(协议族)、type(套接字类型)、protocol(具体协议):

1.2.1 domain:决定地址格式与协议族
  • AF_INET:IPv4 网络协议,地址结构为 sockaddr_in
  • AF_INET6:IPv6 网络协议,地址结构为 sockaddr_in6
  • AF_UNIX / AF_LOCAL:UNIX 域套接字,用于同一主机内进程间通信(IPC),不经过 IP 网络栈,性能远高于 TCP,地址结构为 sockaddr_un,绑定文件系统路径或抽象命名空间
1.2.2 type:决定通信语义
  • SOCK_STREAM:流式套接字,对应 TCP 协议
    • 特性:可靠交付、按序到达、全双工、面向字节流
    • 保证:数据不丢失、不重复、按顺序到达
    • 不保证:应用层消息边界
  • SOCK_DGRAM:数据报套接字,对应 UDP 协议
    • 特性:无连接、不可靠、保留消息边界
    • 适合:广播、多播、实时性优先的场景
  • SOCK_SEQPACKET:有序分组套接字
    • 特性:面向连接、可靠、按序交付、保留消息边界
    • 常用于 UNIX 域套接字,TCP 也支持但使用较少
1.2.3 protocol:具体协议编号

通常填 0,由系统根据 domain 和 type 自动选择对应协议;特殊场景可指定具体协议,如 IPPROTO_TCP、IPPROTO_UDP。

IPPROTO = IP Protocol,IP 协议头里的protocol字段。

IPv4 头部有一个 1 字节的protocol字段,标记上层是什么协议:

  • IPPROTO_TCP:值 = 6
  • IPPROTO_UDP:值 = 17

抓包看 IP 头:Protocol=6 → TCP;Protocol=17 → UDP。

和 socket 第二个参数(SOCK_*)的关系

SOCK_STREAM:流式套接字,语义就是 TCP。此时第三个参数写0等价于IPPROTO_TCP

SOCK_DGRAM:数据报套接字,语义就是 UDP。第三个参数写0等价于IPPROTO_UDP

1.3 内核视角:数据流转模型

1.3.1 发送路径(用户态 → 网络)
  1. 用户态线程调用 send() / write(),触发系统调用进入内核
  2. 内核根据 fd 找到对应的 struct sock 对象
  3. 将用户数据拷贝到内核 Socket 的发送缓冲区(sk_sndbuf)
  4. TCP 层进行封装:添加 TCP 头、进行拥塞控制、流量控制、Nagle 算法判断
  5. 下发到 IP 层:添加 IP 头、路由选择、分片处理
  6. 数据链路层封装成以太网帧,通过网卡驱动发送到 NIC
  7. NIC 将数据串行化发送到物理网络

Nagle 是 TCP 的发送端优化算法,减少小包数量,合并多个小数据包一起发,降低网络报文开销

NIC = Network Interface Card,网卡,主机和物理网络之间的硬件接口,负责把内存里的数据包变成电信号 / 光信号发到网线,同时把线上收到的信号转回数据包交给内核。

1.3.2 接收路径(网络 → 用户态)
  1. 网络数据包从 RJ45 进入网卡,NIC 进行帧校验
  2. 网卡产生硬件中断,通知内核有数据到达
  3. NAPI(New API)机制:关闭硬中断,切换到软中断上下文批量处理数据包
  4. 内核协议栈逐层解析:以太网 → IP → TCP,校验和验证、去重、排序
  5. 根据「源IP+源端口+目的IP+目的端口」四元组,找到对应 Socket 对象
  6. 将数据放入该 Socket 的接收队列(sk_receive_queue)
  7. 如果有线程阻塞在 recv() 上,唤醒该线程,将数据从内核拷贝到用户缓冲区

主要内核数据结构:

  • struct sk_buff:内核网络数据包的载体,贯穿整个协议栈,包含数据指针、协议头、长度、接口等信息
  • struct sock:Socket 内核对象,管理连接状态、缓冲区、队列、回调等全部资源
  • NAPI:网络包处理机制,通过软中断批量处理,避免频繁硬中断,大幅提升网络吞吐量

1.4 注意

  • Socket 是接口抽象,TCP 是其中一种承载协议;Socket 也可以承载 UDP、UNIX 域协议等
  • fd 是用户态的整数句柄,真正的 Socket 实体在内核里,一个 Socket 可以被多个 fd 引用

二、TCP 连接建立与字节流语义

2.1 服务端连接建立流程

服务端经典四步走:socket → bind → listen → accept,每一步的内核行为都需要理解透彻。

2.1.1 bind():绑定本地地址与端口
cpp 复制代码
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
	int fd = socket(AF_INET, SOCK_STREAM, 0);
	struct sockaddr_in addr{};					//定义 IPv4 地址变量addr
	addr.sin_family = AF_INET;
	addr.sin_port = htons(8000);          // 把主机序的端口8000,转换成网络大端,存入sin_port
	//错误写法:addr.sin_port = 8000; 直接赋值,大小端颠倒,监听的不是 8000 端口。
	addr.sin_addr.s_addr = INADDR_ANY;    // 监听所有本地接口
	bind(fd, (struct sockaddr*)&addr, sizeof(addr));		//把 socket 文件描述符 fd 和一个本地地址 (IP + 端口) 绑定
	listen(fd, 5);
	return 0;
}

内核行为:

  • 校验地址合法性、端口权限(<1024 端口需要 root)
  • 检查端口是否被占用(受 SO_REUSEADDR 选项影响)
  • 将 Socket 与指定的 IP+端口绑定,建立四元组的本地半边

关键细节:

  • INADDR_ANY(0.0.0.0)表示监听所有网卡接口,只要目的地址是本机任意 IP 都能命中该 Socket
  • 不主动调用 bind() 时,connect() 或 listen() 会自动绑定临时端口
  • SO_REUSEADDR 选项允许 TIME_WAIT 状态的端口被重新绑定,服务端开发必设
cpp 复制代码
struct sockaddr_in {
    sa_family_t    sin_family; /* 地址族 */
    in_port_t      sin_port;   /* 端口号,网络字节序 */
    struct in_addr sin_addr;   /* IPv4地址 */
    unsigned char  sin_zero[8];// 填充,必须置0,对齐用,兼容sockaddr
};
struct in_addr {
    uint32_t s_addr; // IPv4地址,网络字节序,uint32
};

htons = Host TO Network Short

  • Host:本机字节序(PC 一般小端)
  • Network:网络字节序(固定大端)
  • Short:16 位,端口就是 16 位无符号整数,范围 0~65535
2.1.2 listen():转为被动监听套接字
cpp 复制代码
listen(fd, 128);

内核行为:

  • 将 Socket 从主动状态转为 LISTEN 状态
  • 分配两个队列:
    1. 半连接队列(SYN 队列):存放已收到 SYN、未完成三次握手的连接
    2. 全连接队列(accept 队列):存放已完成三次握手、等待应用层 accept() 的连接
  • backlog 参数:表示全连接队列的最大长度

关键细节:

  • 半连接队列的最大长度由内核参数 /proc/sys/net/ipv4/tcp_max_syn_backlog 控制
  • 全连接队列实际长度 = min(backlog, /proc/sys/net/core/somaxconn),Linux 5.4 默认 somaxconn 为 4096
  • 队列满时,新的 SYN 会被丢弃或回复 RST,客户端表现为连接超时或被拒绝
2.1.3 accept():提取已建立的连接

accept 是阻塞系统调用,从监听 socket 的内核连接队列取出一个已完成 TCP 连接 ,返回新 socket 描述符专门用来和这个客户端通信 。

listen_fd 是前面 socket + bind + listen 创建的监听 fd,只负责等连接,不能收发业务数据

accept会创建一个全新的已连接 Socket,并返回新的 fd。

cpp 复制代码
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);		//告诉内核最多可以往 client_addr 里写多少字节
int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &len);

内核行为:

  • 从全连接队列头部取出一个已完成握手的连接
  • 创建新的 Socket 对象和新的文件描述符
  • 填充客户端地址信息到 client_addr
  • 原监听 Socket 不受任何影响,继续等待下一个连接

关键细节:

  • 新返回的 conn_fd 不继承监听 socket 的非阻塞、超时等标志(Linux 与 BSD 行为不同),需要显式设置
  • 队列为空时,阻塞模式下 accept() 会阻塞,非阻塞模式下返回 EAGAIN
  • 每调用一次 accept 只处理一个连接,高并发场景需要循环调用

2.2 客户端连接建立流程

客户端两步走:socket → connect

cpp 复制代码
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr{};
// ... 填充服务器地址
connect(fd, (struct sockaddr*)&server_addr, sizeof(server_addr));

内核行为:

  • 未绑定的 Socket 自动分配一个临时端口(ephemeral port)
  • 发送 SYN 包,发起 TCP 三次握手
  • 阻塞模式下,直到握手完成或超时才返回
  • 连接建立成功后,fd 就可以直接用于收发数据

2.3 TCP 连接的唯一标识:四元组

一个 TCP 连接由 <本地IP, 本地端口, 远端IP, 远端端口> 四元组唯一标识。

一个服务端端口可以同时承载多少连接 ?

答案:理论上无上限(受限于系统资源)。因为服务端所有连接的本地端口都相同,但每个连接的远端 IP/端口不同,四元组仍然唯一。

端口复用的真相:

  • 服务端:成千上万的连接可以共享同一个监听端口(如 80、8080)
  • 客户端:每个 outbound 连接通常占用不同的临时端口
  • 只要四元组不重复,连接就可以唯一区分

2.4 TCP 字节流语义的四大坑

坑1:send() 不是「发一条消息」

连续两次 send 分别发送 "ABC" 和 "DEF":

cpp 复制代码
send(fd, "ABC", 3, 0);
send(fd, "DEF", 3, 0);

对端可能的接收情况:

  1. 分两次收到 "ABC" 和 "DEF"
  2. 一次收到 "ABCDEF"
  3. 分多次收到,如 "A" + "BCDEF"、"AB" + "CDEF" 等

TCP 只负责按顺序交付字节流,完全不认识应用层的「消息」边界。

坑2:「粘包拆包」不是 TCP 的 bug

所谓「粘包」本质是应用层没有定义消息帧格式,TCP 本身就没有消息边界的概念,不存在「bug」一说。

工业协议常用的帧格式方案:

  1. 固定长度帧:每条消息固定 N 字节,简单但浪费带宽

  2. 长度前缀帧:前 2/4 字节表示后续载荷长度,工业控制最常用

    ┌──────────┬──────────────────┐
    │ Length │ Payload │
    │ 4 bytes │ N bytes │
    └──────────┴──────────────────┘

  3. 特殊分隔符 :如 \r\n、特定 magic 字节,适合文本协议

  4. 标准工业帧:Magic + Type + Length + Payload + CRC,如 Modbus TCP、FANUC 私有协议

坑3:recv() 不保证读满指定长度
cpp 复制代码
char buf[1024];
ssize_t n = recv(fd, buf, 1024, 0);

recv() 返回值是当前内核缓冲区中可读取的字节数,最多不超过请求长度,绝不会等满 1024 字节再返回。

正确的定长读取封装 :

recv_all:保证从 socket fd 完整读取指定len字节数据 。

原生recv不保证一次读完你想要的全部字节(TCP 流式,内核缓冲区数据可能分段到达),这个循环封装就是解决TCP 粘包 / 分段读取问题,读到满len字节才返回成功。

cpp 复制代码
bool recv_all(int fd, void* buf, size_t len) {
    char* p = static_cast<char*>(buf);
    size_t total = 0;      //已经成功收到的字节总数。初始 0。循环目标:total == len
    while (total < len) {
        ssize_t n = recv(fd, p + total, len - total, 0);    //返回值ssize_t:有符号整数,正数 = 读到字节,0 = 连接关闭,-1 = 出错
        if (n > 0) {
            total += n;
        } else if (n == 0) {
            // 对端正常关闭连接
            return false;
        } else {
            if (errno == EINTR) continue; // 被信号中断,重试
            return false; // 真正错误
        }
    }
    return true;
}
坑4:recv() == 0 的真正含义

对于 TCP 流式套接字,recv() 返回 0 表示对端执行了正常的有序关闭(FIN),即到达字节流的结束(EOF),不是错误。

返回值判断规范:

  • > 0:成功读取到字节
  • == 0:对端关闭写端,流结束
  • < 0:发生错误,需检查 errno

三、I/O 模型:阻塞、非阻塞与 epoll 多路复用

3.1 阻塞 I/O(Blocking I/O)

默认创建的 Socket 都是阻塞模式。

行为特点:

  • 调用 recv() 时如果接收队列为空,线程进入睡眠,直到数据到达、连接关闭、超时或被信号中断
  • 调用 send() 时如果发送缓冲区满,线程阻塞直到有空闲空间
  • 阻塞期间 CPU 占用为 0,由内核调度唤醒

适用场景:

  • 单连接、简单客户端程序
  • 每个连接一个线程的简单服务端
  • 优点:编程简单、逻辑直观
  • 缺点:高并发下线程过多,调度开销大,内存占用高

3.2 非阻塞 I/O(Non-blocking I/O)

通过 fcntl() 设置 O_NONBLOCK 标志:

cpp 复制代码
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

行为特点:

  • 无数据时 recv() 立即返回 -1,errno = EAGAIN 或 EWOULDBLOCK
  • 缓冲区满时 send() 也返回 -1,errno = EAGAIN
  • 调用立即返回,不会阻塞线程

绝对禁忌 :不能用 while 循环持续轮询,会占满 100% CPU。

非阻塞 Socket 必须配合 I/O 多路复用使用。

3.3 I/O 多路复用:select / poll / epoll 对比

I/O 多路复用的核心是:用一个线程同时监听多个 fd,内核告诉你哪些 fd 就绪了,再去操作。

模型 最大连接数 性能 触发模式 移植性
select 一般 1024 O(n),遍历检查 水平触发 跨平台
poll 无限制 O(n),遍历检查 水平触发 跨平台
epoll 无限制 O(1),事件回调 水平/边缘触发 Linux 专属

3.4 epoll

epoll 是 Linux 高性能网络编程的基石,nginx、redis 等都基于 epoll 构建。

3.4.1 数据结构
  • interest list(兴趣集合):内核维护的需要监听的 fd 红黑树
  • ready list(就绪集合):已就绪的 fd 链表
3.4.2 两种触发模式

1. 水平触发(Level Triggered, LT)

  • 行为:只要 fd 还有数据可读/可写,每次 epoll_wait 都返回就绪
  • 特点:编程简单,不容易漏事件
  • 类似 select/poll 的行为

2. 边缘触发(Edge Triggered, ET)

  • 行为:只在状态变化时通知一次
  • 要求:必须配合非阻塞 Socket,循环读写直到返回 EAGAIN
  • 优点:减少 epoll_wait 调用次数,高并发下性能更高
  • 坑点:如果一次没读完,剩下的数据不会再通知,会造成数据积压

ET 模式标准读取范式:

cpp 复制代码
while (true) {
    ssize_t n = recv(fd, buf, sizeof(buf), 0);
    if (n > 0) {
        process_data(buf, n);
    } else if (n == 0) {
        close(fd);
        break;
    } else {
        if (errno == EAGAIN) break; // 读完了
        if (errno == EINTR) continue;
        close(fd);
        break;
    }
}

3.5 常见误区

  • ❌ 错误:epoll 一定比 select 快
  • ✅ 正确:连接数少时差别不大,连接数多且活跃连接稀疏时 epoll 优势才明显
  • ❌ 错误:ET 模式一定比 LT 好
  • ✅ 正确:ET 编程复杂容易出 bug,大部分场景 LT 足够稳定可靠

四、多线程 Socket 工程实践:FANUC 案例

这是实际工业开发中最容易踩坑的部分,也是笔者在 FANUC 机器人通信项目中踩过的。

4.1 问题背景

FANUC 机器人控制器通过 TCP 提供指令接口,采用标准的 Request-Response 模式:

  • 客户端发送一条指令请求(如读取位置、设置寄存器)
  • 控制器执行后返回对应响应
  • 每条请求对应一条响应,顺序一一对应

项目初期架构:两个业务线程共享同一个 TCP Socket

  • 线程 A:位置设置与控制指令
  • 线程 B:周期缓存数据更新

4.2 错误与问题

错误架构示意图
text 复制代码
Thread A (控制指令)        Thread B (缓存更新)
       │ send Req A                │ send Req B
       ▼                           ▼
            同一个 Socket fd
            同一个 TCP 字节流
            同一个接收队列
问题根源
  1. 发送端交错:两个线程同时 send,虽然 TCP 保证字节有序,但两个请求的字节可能被拼接或拆分
  2. 接收端竞争:两个线程同时调用 recv,会竞争接收队列中的数据
  3. 响应错位:Thread A 可能收到 Response B,Thread B 可能收到 Response A
  4. 协议破坏:应用层的 Request-Response 对应关系完全混乱

很多人以为「只锁 send」就能解决,实际上只能保证发送操作不交错,接收端的竞争依然存在,响应还是会被对方线程抢走。

4.3 方案一:事务级串行化

最直接可靠的方案:锁整个 Request-Response 事务,而不是只锁 send。

cpp 复制代码
class FanucClient {
    int fd_;
    std::mutex tx_mutex_;

    bool send_frame(const Frame& req) {
        // ... 帧封装
        return send_all(fd_, req.data(), req.size());
    }

    bool recv_frame(Frame& resp) {
        // ... 先读长度,再读载荷,校验
        return true;
    }

public:
    Response execute(const Request& req) {
        std::lock_guard<std::mutex> lock(tx_mutex_);
        
        send_frame(req.serialize());
        
        Frame resp_frame;
        if (!recv_frame(resp_frame)) {
            throw std::runtime_error("通信失败");
        }
        
        return Response::parse(resp_frame);
    }
};

执行时序 :

请求A → 响应A → 请求B → 响应B

  • ✅ 优点:实现简单,严格保证请求-响应一一对应
  • ❌ 缺点:串行执行,并发度低,缓存更新会阻塞控制指令
  • 适用场景:FANUC 控制器只允许单 Session、不支持多连接的情况

4.4 方案二:多连接隔离

如果控制器支持多会话,为每个业务线程分配独立的 Socket 和 TCP 连接。

text 复制代码
Thread A (控制)        Thread B (缓存)
   │                     │
Socket A              Socket B
   │                     │
TCP 连接 1           TCP 连接 2
   │                     │
独立接收队列        独立接收队列

客户端端口说明:

  • 两个客户端 Socket 不需要手动 bind,connect() 时内核自动分配不同的临时端口
  • 两个连接的服务端端口相同(都是控制器的 6008)
  • 四元组不同,连接可以正常区分
  • ✅ 优点:业务完全隔离,互不阻塞,并发度高
  • ❌ 缺点:占用更多连接资源,需要控制器支持多会话
  • 适用场景:控制器允许多连接、业务之间独立性要求高

4.5 方案三:单 IO 线程 + 请求 ID 分发

高并发工业场景的标准架构:

架构设计:

  1. 单个 IO 线程专门负责 Socket 的读写
  2. 业务线程提交请求时带上唯一 Request ID
  3. IO 线程收到响应后,根据 ID 分发到对应业务线程的等待队列
  4. 业务线程阻塞等待自己的响应
  • ✅ 优点:单连接、高吞吐、线程安全、并发度高
  • ❌ 缺点:实现复杂,需要应用协议支持 Request ID 字段
  • 适用场景:高性能网关、多设备集中管控系统

五、概念辨析

5.1 close() 与 shutdown() 的区别

5.1.1 close():文件描述符层面
  • 作用:减少文件描述符的引用计数
  • 当引用计数归零时,释放 Socket 内核对象
  • 只是释放用户态句柄,不一定立即断开 TCP 连接
  • 如果还有其他 fd 指向同一个 Socket,连接不受影响
5.1.2 shutdown():TCP 协议层面
  • 作用:直接操作 TCP 连接,可单独关闭读、写或双向
    • SHUT_RD:关闭读端,后续接收的数据会被丢弃
    • SHUT_WR:关闭写端,发送 FIN 包,对端会读到 EOF
    • SHUT_RDWR:双向关闭
  • 不影响文件描述符的引用计数,fd 仍然有效

典型应用:半关闭

一端发送完数据后调用 shutdown(SHUT_WR),告诉对端数据发完了,但仍然可以接收对端的数据。

5.2 监听 Socket 与连接 Socket 的区别

维度 监听 Socket(listen_fd) 连接 Socket(conn_fd)
状态 LISTEN ESTABLISHED
数量 服务端通常只有 1 个 每个客户端连接 1 个
作用 只负责接受新连接 负责具体客户端的数据收发
生命周期 服务启动创建,退出销毁 连接建立创建,连接关闭销毁
四元组 只有本地半边完整 四元组完整

5.3 send() 返回值与内核发送缓冲区

  • send() 成功返回只代表数据被拷贝到内核发送缓冲区,不代表已经发送到网络
  • 内核会根据拥塞控制、Nagle 算法、TCP_CORK 等决定何时发送
  • 非阻塞模式下,发送缓冲区满时返回 EAGAIN

总结

  1. 内核层:Socket 是内核对象,fd 只是句柄,数据走内核网络栈和接收队列,NAPI 软中断处理数据包
  2. 协议层:TCP 是字节流,没有消息边界,粘包是应用层问题,必须自己做帧定界
  3. 工程层:多线程共享 Socket 会有响应错位问题,需要事务串行化、多连接隔离或 ID 分发
相关推荐
一只小鱼儿吖1 小时前
代理 IP 实战指南:住宅/机房代理选型策略
网络·网络协议·tcp/ip
重生之小比特1 小时前
【Linux系统】07 进程状态
linux·运维·服务器
上海云盾-小余2 小时前
反射放大攻击底层拆解:UDP 反射攻击的识别与防御思路
网络·网络协议·udp
张洛闻Eren2 小时前
Ansible【第二课】:实施 Ansible Playbook
linux·运维·服务器·云原生·kubernetes·k8s·ansible
Zhu7582 小时前
【开源脚本】Ubuntu环境下载任意软件
linux·运维·ubuntu
XiaoMaqqqq2 小时前
目前正规的IP驱动产业新场景新工具哪个好
网络·python·网络协议·tcp/ip
0+1113 小时前
Linux --传输层协议 TCP
linux·网络·tcp/ip
Sylvia-girl3 小时前
【二】:Linux基础指令
linux·运维·服务器