编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

  • iOS开发和进阶

  • Web开发和进阶

  • Linux应用开发

    • Linux应用开发
    • QML基础入门

    • QT核心库实践

    • Linux系统编程

      • Linux系统编程基础
      • 系统调用原理精讲
      • 文件IO深度解析
      • 进程管理深度解析
      • 线程并发深度解读
      • 信号处理深度解读
      • 进程间通信IPC
      • 网络IO多路复用
        • 内存管理深度解读
        • 动态链接与共享库
        • Linux与定时器
      • 综合项目实战

    • IoT智能硬件开发

    • Apps
    • Linux应用开发
    • Linux系统编程
    杨充
    2025-07-02
    目录

    网络IO多路复用

    # 07.网络IO多路复用

    嵌入式设备不是孤岛——OTA、MQTT、Modbus TCP、远程调试都依赖网络。本节从 socket API 到 epoll Reactor 模式,打通嵌入式网络编程的全路径。

    # 目录介绍

    • 7.1 案例引入
      • 7.1.1 并发 50 路 Modbus TCP 连接 CPU 占用 100%
      • 7.1.2 核心问题
    • 7.2 Socket 基础回顾
      • 7.2.1 socket/bind/listen/accept/connect 全景
      • 7.2.2 TCP 三次握手与 Socket API 的对应关系
      • 7.2.3 SO_REUSEADDR 与 TIME_WAIT
      • 7.2.4 TCP_NODELAY 与 Nagle 算法
    • 7.3 I/O 多路复用演进
      • 7.3.1 select 的 fd_set 天花板(1024)
      • 7.3.2 poll 的无上限但 O(N) 扫描
      • 7.3.3 epoll_create/epoll_ctl/epoll_wait 详解
      • 7.3.4 epoll 的 LT 水平触发 vs ET 边缘触发
      • 7.3.5 EPOLLONESHOT 的正确使用
    • 7.4 Reactor 模式实现
      • 7.4.1 单线程 Reactor 骨架
      • 7.4.2 主从 Reactor 多线程
      • 7.4.3 epoll + timerfd 定时器集成
    • 7.5 嵌入式网络调优
      • 7.5.1 小内存设备上的接收缓冲区设置
      • 7.5.2 tcpdump 抓包分析实战
      • 7.5.3 网络断线重连策略(指数退避)
      • 7.5.4 蜂窝网络 4G/LTE 下的 socket 特性
    • 7.6 关键结论与速查表

    # 7.1 案例引入

    # 7.1.1 并发 50 路 Modbus TCP 连接 CPU 占用 100%

    某工业网关需要同时采集 50 台 PLC 数据,开发用 select + 每个连接一个线程。CPU 单核 100% 满载,且 select 的 1024 fd 上限导致无法扩展。

    切换到 单线程 epoll + ET 模式 + 非阻塞 socket:CPU 从 100% 降到 8%,连接数无上限。核心差异:select 每次要传入整个 fd_set 并 O(N) 扫描,epoll 用红黑树+就绪队列只需 O(1) 获取就绪 fd。

    select 版的开销拆分(50 个连接):

    select(52, &rset, NULL, NULL, NULL)    // 每次调用:
      ├─ 内核:遍历 fd 0~52 → 检查每个 fd 是否可读 → 53 次检查
      ├─ 用户态→内核态:拷贝整个 fd_set(128 字节)
      └─ 返回后:应用层遍历 fd 0~52 → 找哪些 fd 在 rset 中 → 53 次 FD_ISSET
    每轮 epoll_wait 后:
      └─ 内核:从就绪队列直接取(O(1))→ 只返回已就绪的 fd
      └─ 应用层:遍历返回的 events 数组 → 只处理就绪的那几个
    

    # 7.1.2 核心问题

    1. epoll ET 模式为什么必须配合非阻塞 socket?
    2. select → poll → epoll 三代进化的核心差异是什么?为什么 epoll 用红黑树 + 就绪队列?
    3. 嵌入式 4G 网络下的 socket 有什么特殊行为(NAT 超时、IP 切换)?

    # 7.2 Socket 基础回顾

    # 7.2.1 socket/bind/listen/accept/connect 全景

    TCP 服务端标准六步:

    int fd = socket(AF_INET, SOCK_STREAM, 0);          // 1. 创建 socket
    
    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));  // 2. 端口复用
    
    struct sockaddr_in addr = {                         // 3. bind
        .sin_family = AF_INET,
        .sin_port = htons(502),                        // Modbus TCP 默认端口
        .sin_addr.s_addr = INADDR_ANY
    };
    bind(fd, (struct sockaddr *)&addr, sizeof(addr));
    
    listen(fd, 128);                                   // 4. listen(backlog=128)
    
    while (1) {
        int client = accept(fd, NULL, NULL);            // 5. accept
        handle_client(client);                          // 6. 处理
        close(client);
    }
    

    TCP 客户端三步:

    int fd = socket(AF_INET, SOCK_STREAM, 0);
    
    struct sockaddr_in addr = {.sin_family = AF_INET, .sin_port = htons(502)};
    inet_pton(AF_INET, "192.168.1.100", &addr.sin_addr);
    
    if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("connect");
    }
    

    listen 的 backlog 参数——不是连接上限! 它是已完成三次握手但尚未 accept 的连接队列长度。Linux 上实际上限是 min(backlog, /proc/sys/net/core/somaxconn)。

    # 7.2.2 TCP 三次握手与 Socket API 的对应关系

    客户端                         服务端
      │                             │
      │ socket()                    │ socket() + bind() + listen()
      │                             │
      │ connect() ─── SYN ──────→  │ 进入 SYN_RECV 队列(半连接)
      │                             │
      │          ←── SYN+ACK ────  │
      │                             │
      │ connect()返回 ── ACK ───→  │ 移入 accept 队列(全连接)
      │                             │
      │                             │ accept() 从全连接队列取一个
    
    accept 成功前,连接已在"全连接队列"中等待!
    这就是为什么 fast-open (TFO) 能在第三个包就带数据——
    内核在收到 SYN 时就把连接放入队列,accept 后第一个 read 能立刻拿到数据。
    

    SYN Flood 攻击防护——半连接队列满后,新 SYN 被丢弃或采 SYN cookies 替代保存状态。

    # 7.2.3 SO_REUSEADDR 与 TIME_WAIT

    SO_REUSEADDR 解决的问题——服务端重启后端口仍被占用:

    # 没有 SO_REUSEADDR 的后果:
    $ ./server &
    $ killall server
    $ ./server           # 立即重启 → bind 失败:Address already in use
    

    TIME_WAIT 状态:主动关闭连接的一方进入 TIME_WAIT,持续 2 × MSL(Linux 上 MSL=30s,所以 TIME_WAIT=60s)。这期间端口被占用。

    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    // bind 允许重用处于 TIME_WAIT 的端口
    

    SO_REUSEPORT(Linux 3.9+)——更强的端口复用,允许多个 socket 同时 bind 同一端口,内核在各 socket 间负载均衡分发连接:

    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
    // 多个进程/线程各自 accept,内核自动分发——无需手动多线程 accept
    

    这在多进程 nginx 中实现 "多个 worker 同时 accept 同一端口" 的关键选项。

    # 7.2.4 TCP_NODELAY 与 Nagle 算法

    Nagle 算法——在收到对端 ACK 之前,积攒小包直到凑满一个 MSS(或超时 200ms),减少网络上的小包。对 Modbus / MQTT 等实时协议是灾难。

    // Nagle 算法(默认开启):
    write(fd, buf, 10);          // 立即发
    write(fd, buf, 10);          // 等第一个包的 ACK 或攒到 MSS → 延迟 40ms!
    write(fd, buf, 10);          // 同上
    
    // 关闭 Nagle:
    int opt = 1;
    setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
    // 每次 write 立即发
    

    TCP_NODELAY 对嵌入式场景的影响:

    协议 是否开 TCP_NODELAY 原因
    Modbus TCP ✅ 必须开 请求-响应模式,每个包都是独立的
    MQTT QoS 0/1 ✅ 强烈建议 低延迟发布
    HTTP REST ⚠️ 视情况 请求通常已够大
    OTA 固件下载 ❌ 不用开 大数据块,Nagle 自动攒满 MSS
    WebSocket ✅ 建议开 双向实时通信

    # 7.3 I/O 多路复用演进

    # 7.3.1 select 的 fd_set 天花板(1024)

    fd_set rset;
    FD_ZERO(&rset);
    FD_SET(fd1, &rset);
    FD_SET(fd2, &rset);
    
    struct timeval tv = {.tv_sec = 1};
    int n = select(max_fd + 1, &rset, NULL, NULL, &tv);
    
    for (int i = 0; i <= max_fd; i++) {
        if (FD_ISSET(i, &rset)) process_fd(i);   // O(N) 扫描!
    }
    

    select 的四大缺陷:

    缺陷 说明
    fd 上限 1024 fd_set 是位图,默认 FD_SETSIZE=1024(改头文件可突破,但不好)
    每次传入整个集合 内核态要遍历 0~max_fd 检查每个 fd
    用户态复制开销 每次 select 都要把 fd_set 从用户态拷贝到内核态
    返回后 O(N) 扫描 必须遍历 0~max_fd 找哪些 fd 在结果集中

    # 7.3.2 poll 的无上限但 O(N) 扫描

    struct pollfd fds[64];
    fds[0].fd = fd1;   fds[0].events = POLLIN;
    fds[1].fd = fd2;   fds[1].events = POLLIN;
    // ... 不受 1024 限制
    
    int n = poll(fds, nfds, 1000);
    
    for (int i = 0; i < nfds; i++) {
        if (fds[i].revents & POLLIN) process(fds[i].fd);  // 仍是 O(N) 扫描
    }
    

    poll vs select:

    特性 select poll
    fd 上限 1024 无(受内存限制)
    数据结构 位图 fd_set 数组 struct pollfd
    事件输入输出 混在同一位图(每次重设) 分离(events 输入,revents 输出)
    底层实现 同 poll(内核都用 do_poll) 同 select
    性能 O(max_fd) O(nfds)

    poll 比 select 好在:① 无 1024 限制 ② 事件与结果分离,不用每次 FD_ZERO+FD_SET。但依然 O(N) 扫描。

    # 7.3.3 epoll_create/epoll_ctl/epoll_wait 详解

    epoll 是 Linux 的终极多路复用——O(1) 获取就绪 fd:

    int epfd = epoll_create1(EPOLL_CLOEXEC);                    // 创建 epoll 实例
    
    struct epoll_event ev = {.events = EPOLLIN, .data.fd = fd1};
    epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev);                   // 注册 fd
    epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev);
    
    struct epoll_event events[64];
    int n = epoll_wait(epfd, events, 64, 1000);                 // 等待
    
    for (int i = 0; i < n; i++) {                               // 只遍历 n 个就绪 fd
        if (events[i].events & EPOLLIN) process(events[i].data.fd);
    }
    

    epoll 内核数据结构——为什么是 O(1):

    epoll_create → 创建 eventpoll 对象
        ├── rbr: 红黑树(管理所有被监控的 fd)    ← epoll_ctl
        │     ├── fd1 (READ)
        │     ├── fd2 (READ | WRITE)
        │     └── fd3 (READ)
        └── rdllist: 就绪链表                     ← epoll_wait
              └── 数据到达 → fd 挂入 rdllist → 唤醒 epoll_wait
    

    epoll_ctl:向红黑树插入/删除/修改 fd 的监控事件(O(log N))

    epoll_wait:从就绪链表取已就绪的 fd(O(1)),直接返回给用户态

    为什么 select/poll 必须 O(N) 扫描而 epoll 不需要? select/poll 是无状态的——每次调用都要传入完整 fd 列表,内核不知道"上次哪些 fd 我关心";epoll 是有状态的——红黑树记住了全部 fd,事件驱动地把就绪 fd 挂在链表中。

    # 7.3.4 epoll 的 LT 水平触发 vs ET 边缘触发

    LT(默认)——只要 fd 可读,每次 epoll_wait 都通知你:

    fd 收到 4 KB 数据
      → epoll_wait 返回(第1次通知)
        → read(fd, buf, 1024)  # 读了 1 KB,还剩 3 KB
      → epoll_wait 返回(第2次通知!)  ← 因为还没读完
        → read(fd, buf, 1024)  # 又读 1 KB
      → epoll_wait 返回(第3次通知!)
        → read(fd, buf, 2048)  # 读完
      → epoll_wait 阻塞(无数据)
    

    ET——只在 fd 从"不可读"变为"可读"时通知一次:

    fd 收到 4 KB 数据
      → epoll_wait 返回(唯一一次通知!)
        → read(fd, buf, 4096)  ← 必须循环读直到返回 EAGAIN
    

    ET 必须配合非阻塞 I/O 的原因——如果在 ET 模式下只 read 一次就停下,剩余数据永远不会再触发通知(因为 fd 状态没变——它一直是"可读")。

    // ET 模式的正确写法——循环读到 EAGAIN
    while (1) {
        ssize_t n = read(fd, buf, sizeof(buf));
        if (n > 0) { process(buf, n); continue; }
        if (n == 0) { close(fd); break; }          // 对端关闭
        if (errno == EAGAIN || errno == EWOULDBLOCK) break;  // 读完
        perror("read"); break;                      // 错误
    }
    

    LT vs ET 选择:

    场景 推荐 原因
    简单网络程序 LT 不容易丢数据,默认就是 LT
    高性能服务器 ET 减少 epoll_wait 被唤醒次数
    流式数据(HTTP body) ET 一次读完省通知
    消息协议(Modbus) ET 每帧一次通知刚好
    初学者 / 调试 LT 容错高,不会因忘循环读而丢数据

    # 7.3.5 EPOLLONESHOT 的正确使用

    ET 模式仍有缺陷——一个 fd 被读出后,在 epoll_wait 返回下一个通知之前,另一线程又开始处理它 → 同一 fd 被两个线程同时处理。

    EPOLLONESHOT 保证一个 fd 在任何时刻只被一个线程处理:

    struct epoll_event ev = {.events = EPOLLIN | EPOLLONESHOT, .data.fd = fd};
    epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
    
    // 线程中处理完毕后,重新激活 fd 的监听
    ev.events = EPOLLIN | EPOLLONESHOT;
    epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
    

    为什么 EPOLLONESHOT 对多线程 Reactor 至关重要?

    无 EPOLLONESHOT:
      线程 A: epoll_wait → fd1 就绪 → 读一半
      线程 B: epoll_wait → fd1 仍就绪 → 也读!→ 读到的数据交叉/不完整
    
    有 EPOLLONESHOT:
      线程 A: epoll_wait → fd1 就绪 → fd1 被自动暂停监控
      线程 B: epoll_wait → fd1 不再出现在就绪队列 → 安全
      线程 A: 处理完 → epoll_ctl MOD 重新激活 → 等待下一批数据
    

    # 7.4 Reactor 模式实现

    # 7.4.1 单线程 Reactor 骨架

    typedef void (*event_handler)(int fd, void *arg);
    
    typedef struct {
        int epfd;
        event_handler accept_handler;
        event_handler read_handler;
        event_handler write_handler;
        void *handler_arg;
    } reactor_t;
    
    void reactor_run(reactor_t *r) {
        struct epoll_event events[MAX_EVENTS];
        while (1) {
            int n = epoll_wait(r->epfd, events, MAX_EVENTS, -1);
            for (int i = 0; i < n; i++) {
                int fd = events[i].data.fd;
                uint32_t ev = events[i].events;
    
                if ((ev & EPOLLIN) && r->read_handler)
                    r->read_handler(fd, r->handler_arg);
    
                if ((ev & EPOLLOUT) && r->write_handler)
                    r->write_handler(fd, r->handler_arg);
            }
        }
    }
    
    // 注册监听 fd
    void reactor_add_fd(reactor_t *r, int fd) {
        set_nonblock(fd);
        struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.fd = fd};
        epoll_ctl(r->epfd, EPOLL_CTL_ADD, fd, &ev);
    }
    

    单线程 Reactor 的典型应用——Redis、Node.js 的事件循环本质。

    # 7.4.2 主从 Reactor 多线程

    主 Reactor (main thread)
      ├─ accept 新连接
      └─ 分发给从 Reactor
           ├─ 从 Reactor 0 (worker thread 0)
           │    ├─ fd 1 → 读 Modbus 请求 → 处理 → 写响应
           │    └─ fd 2 → 同理
           └─ 从 Reactor 1 (worker thread 1)
                └─ ...
    
    每个从 Reactor 有独立的 epoll 实例。
    主 Reactor 把 accept 到的 fd 用 epoll_ctl 加到某个从 Reactor 的 epfd 中。
    

    嵌入式简化版——固定 N 个 worker + EPOLLONESHOT:

    void *worker_thread(void *arg) {
        int epfd = *(int *)arg;
        struct epoll_event events[32];
        while (1) {
            int n = epoll_wait(epfd, events, 32, -1);
            for (int i = 0; i < n; i++) {
                int fd = events[i].data.fd;
                handle_modbus_request(fd);               // 业务处理
                // 重新激活 fd 监听
                struct epoll_event ev = {
                    .events = EPOLLIN | EPOLLET | EPOLLONESHOT, .data.fd = fd
                };
                epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
            }
        }
    }
    

    主 Reactor 分发策略——轮询:

    int round_robin = 0;
    while (1) {
        int fd = accept(listen_fd, NULL, NULL);
        int worker_idx = round_robin++ % NUM_WORKERS;
        struct epoll_event ev = {
            .events = EPOLLIN | EPOLLET | EPOLLONESHOT, .data.fd = fd
        };
        epoll_ctl(worker_epfds[worker_idx], EPOLL_CTL_ADD, fd, &ev);
    }
    

    # 7.4.3 epoll + timerfd 定时器集成

    timerfd 把定时器变成文件描述符——与 epoll 天然融合:

    #include <sys/timerfd.h>
    
    // 创建每 100ms 触发一次的定时器
    int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
    struct itimerspec its = {
        .it_value = {.tv_sec = 0, .tv_nsec = 100000000},    // 第一次:100ms 后
        .it_interval = {.tv_sec = 0, .tv_nsec = 100000000}  // 之后:每 100ms
    };
    timerfd_settime(tfd, 0, &its, NULL);
    
    // 加入 epoll
    struct epoll_event ev = {.events = EPOLLIN, .data.fd = tfd};
    epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &ev);
    
    // epoll_wait 循环中
    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == tfd) {
            uint64_t expirations;
            read(tfd, &expirations, sizeof(expirations));  // 消费定时器事件
            periodic_task();                               // 执行定时任务
        }
    }
    

    嵌入式定时器应用场景:

    定时器 周期 用途
    Modbus 超时检测 500 ms 请求未何时重发
    设备心跳 10 s MQTT PINGREQ
    内存/CPU 监控 5 s 嵌入式资源监控日志
    断线重连 指数 见 7.5.3

    epoll 统一五种事件源(socket / signal / timer / pipe / eventfd)——这就是"事件驱动"的本质。


    # 7.5 嵌入式网络调优

    # 7.5.1 小内存设备上的接收缓冲区设置

    Linux 默认 socket 缓冲区偏大——嵌入式须缩小:

    # 查看系统默认值
    sysctl net.core.rmem_default    # 接收缓冲默认值(通常 212992 字节 ≈ 208 KB)
    sysctl net.core.wmem_default    # 发送缓冲默认值(相同)
    

    每个 TCP 连接占用的内存 = rmem + wmem + tcp 控制块。默认 208 KB × 2 × 50 连接 = 20.8 MB——在 128 MB 设备上约 16% 的总内存。

    嵌入式建议配置:

    // 为每条连接设置较小的缓冲区
    int rcvbuf = 16384;       // 16 KB 接收缓冲
    int sndbuf = 16384;       // 16 KB 发送缓冲
    setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
    setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf));
    
    // 注意:内核实际分配的值是设置值的 2 倍(用于 TCP 协议开销)
    // 设 16384 → 内核实际分配 32768
    

    嵌入式 socket 内存规划速算:

    每个连接内存 ≈ rmem × 2 + wmem × 2 + 4 KB (tcp 控制块)
    50 连接 × (16384×2 + 16384×2 + 4096) = 50 × 70 KB ≈ 3.5 MB
    vs 默认 50 × (212992×2 + 212992×2 + 4096) ≈ 42 MB
    
    缩小到原来的 1/12
    

    TCP keepalive——探测死连接:

    int keepalive = 1;
    int keepidle = 60;         // 60 秒无数据后开始探测
    int keepintvl = 10;        // 探测间隔 10 秒
    int keepcnt = 3;           // 3 次探测无应答则断开
    setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));
    

    # 7.5.2 tcpdump 抓包分析实战

    嵌入式设备抓包——不占本地磁盘的远程分析:

    # 方案 A:抓包直接发到 PC
    ssh root@192.168.1.200 "tcpdump -i eth0 -w - not port 22" | wireshark -k -i -
    
    # 方案 B:限制抓包大小(嵌入式保护)
    tcpdump -i eth0 -c 1000 -w /tmp/cap.pcap port 502
    
    # 方案 C:只抓头部(节省空间)
    tcpdump -i eth0 -s 96 -w /tmp/cap.pcap   # 只抓前 96 字节
    

    tcpdump 排查经典问题:

    # Modbus TCP 重传过多 → 网络质量差
    tcpdump -i eth0 'tcp port 502 and (tcp[tcpflags] & tcp-syn != 0)'
    
    # 连接被 RST 异常关闭
    tcpdump -i eth0 'tcp[tcpflags] & tcp-rst != 0'
    
    # SYN 未收到 SYN+ACK → 对端服务未启动
    tcpdump -i eth0 'tcp port 502 and tcp[tcpflags] == tcp-syn'
    
    # TIME_WAIT 过多 → 需要 SO_REUSEADDR
    ss -tan state time-wait | wc -l
    

    # 7.5.3 网络断线重连策略(指数退避)

    嵌入式网络中,socket 断开是常态。重连策略做对与否直接影响平均恢复时间。

    #define MAX_BACKOFF_MS 60000     // 最长的退避间隔(60 秒)
    #define BASE_BACKOFF_MS 1000     // 初始退避(1 秒)
    
    int reconnect_with_backoff() {
        int backoff = BASE_BACKOFF_MS;
        int attempt = 0;
    
        while (1) {
            int fd = socket(AF_INET, SOCK_STREAM, 0);
            if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) {
                return fd;  // 重连成功
            }
    
            attempt++;
            int delay = backoff;
            if (delay > MAX_BACKOFF_MS) delay = MAX_BACKOFF_MS;
    
            printf("reconnect attempt %d failed, retry in %d ms\n", attempt, delay);
            usleep(delay * 1000);
    
            // 指数退避 + 随机抖动(防止多个客户端同时重连)
            backoff = backoff * 2;
            backoff += (rand() % 1000);  // 加 0-1000ms 随机抖动
        }
    }
    

    指数退避参数建议:

    场景 base max 原因
    本地 PLC(有线) 100 ms 5000 ms 网络可靠,快恢复
    Wi-Fi 传感器 500 ms 30000 ms 干扰多,但非广域网
    4G 蜂窝连接 2000 ms 120000 ms 信号不稳定,频繁重连会过度耗电
    卫星/MQTT 5000 ms 300000 ms 极高延迟 + 极不可靠

    # 7.5.4 蜂窝网络 4G/LTE 下的 socket 特性

    4G 网络给 socket 编程带来三大特殊约束:

    1. NAT 超时——运营商 NAT 网关有空闲连接超时(通常 60-600 秒)。长时间不发数据的 TCP 连接会被 NAT 网关静默丢弃(不发 RST,对端不知道)。

    // 必须开 TCP keepalive,且 idle 时间 < NAT 超时
    int keepidle = 30;     // 30 秒(远小于 NAT 超时)
    setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
    

    2. IP 地址切换——设备在基站间移动、信号弱后重连 → IP 地址突然改变。已建立的 TCP 连接直接断掉(旧的 IP 对端无法路由)。必须依赖应用层重连,不能假设 IP 地址稳定。

    // 连接恢复后重新查询当前本端 IP 并告知服务器
    struct sockaddr_in local_addr;
    socklen_t len = sizeof(local_addr);
    getsockname(fd, (struct sockaddr *)&local_addr, &len);
    send_device_ip_update(fd, &local_addr);
    

    3. 长肥管道(LFN)——4G 延迟 30-200ms + 高带宽 → BDP(Bandwidth-Delay Product)= 高 RTT × 高带宽 = 大缓冲区需求。TCP 默认缓冲区(208 KB)在高 BDP 下不够——但嵌入式增加缓冲区又吃内存。

    // 根据 RTT 动态调整接收缓冲区(BDP = RTT × 带宽)
    // 例:RTT 100ms, 带宽 10 Mbps → BDP = 125 KB
    // 设置缓冲区至少为 BDP
    int rcvbuf = 256 * 1024;  // 256 KB → 够 100ms × 20 Mbps
    setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
    

    # 7.6 关键结论与速查表

    核心结论五条:

    1. epoll 是 select/poll 的有状态替代——红黑树 O(log N) 管理 fd,就绪链表 O(1) 获取事件。select/poll 每次都要 O(N) 扫描是因为它们是无状态的
    2. ET 必须配合非阻塞 I/O + 循环读到 EAGAIN——否则丢数据无后续通知
    3. SO_REUSEADDR 是服务端标配——防止 TIME_WAIT 导致的 bind 失败;SO_REUSEPORT 实现内核级负载均衡
    4. 嵌入式网络 = socket + epoll + timerfd——三者统一在事件循环中,不依赖多线程也能处理并发
    5. 蜂窝网络下 TCP keepalive < NAT 超时——否则连接被运营商网关静默丢弃,应用层永远不知道

    epoll 三 API 速查:

    API 内核操作 时间复杂度 用途
    epoll_create1 创建 eventpoll 对象(红黑树 + 就绪链表) O(1) 初始化
    epoll_ctl ADD/MOD/DEL 红黑树插入/更新/删除 O(log N) 注册/修改/取消监听
    epoll_wait 从就绪链表取事件 O(1) 等待就绪 fd

    select/poll/epoll 对比:

    机制 fd 上限 内核数据结构 入参扫描 返回扫描 适用场景
    select 1024 无状态(fd_set) O(N) O(N) 少量 fd(< 100)
    poll 无 无状态(pollfd 数组) O(N) O(N) 中量 fd(< 1000)
    epoll 无 有状态(红黑树 + 链表) O(log N) O(1) 大量 fd,高性能

    Socket 选项速查:

    选项 含义 嵌入式建议
    SO_REUSEADDR 端口复用 ✅ 服务端标配
    SO_REUSEPORT 多进程负载均衡 ✅ 多 worker accept
    SO_KEEPALIVE TCP 保活探测 ✅ 4G/NAT 必开
    TCP_NODELAY 禁用 Nagle ✅ Modbus/MQTT 必开
    SO_RCVBUF 接收缓冲 16-32 KB(小嵌入式)
    SO_SNDBUF 发送缓冲 16-32 KB
    TCP_QUICKACK 立即 ACK ✅ 请求-响应协议

    常见陷阱速查:

    陷阱 根因 解决
    ET 模式丢数据 只 read 一次就停 循环 read 到 EAGAIN
    TIME_WAIT bind 失败 未设 SO_REUSEADDR setsockopt SO_REUSEADDR
    4G 连接"假死" NAT 超时静默丢弃 TCP keepalive < NAT 超时
    Modbus 响应延迟高 Nagle 算法攒包 TCP_NODELAY = 1
    epoll 事件被多线程乱抢 无 EPOLLONESHOT EPOLLONESHOT + 处理完 MOD
    select fd 超过 1024 崩溃 FD_SETSIZE 限制 换成 epoll

    下一步:把你的 Modbus 网关从 select 改为 epoll ET 模式——先用 LT 模式验证功能,再切 ET 调优。用 strace -e epoll_wait 看就绪事件数确认 ET 已生效(每次只返回真正有事的那几个 fd,而非全部 50 个)。


    延伸阅读:

    • man 7 epoll —— epoll API 详解
    • man 2 timerfd_create —— 定时器 fd
    • man 7 tcp —— TCP 协议 socket 选项
    • man 7 socket —— socket 选项概览
    • 《The Linux Programming Interface》(Kerrisk) —— 第 56、58-61、63 章
    上次更新: 2026/07/12, 19:39:51
    进程间通信IPC
    内存管理深度解读

    ← 进程间通信IPC 内存管理深度解读→

    最近更新
    01
    audit
    07-27
    02
    C++入门教程全章思考题汇编
    07-24
    03
    12.技术团队建设能力
    07-21
    更多文章>
    Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式