嵌入式Linux应用开发系列⑦:网络编程——Socket、select/poll/epoll与高并发服务器实战

上一篇我们掌握了多线程编程与线程池。当设备需要对外提供网络服务时,网络编程就是绕不开的课题。本篇将从TCP Socket基础开始,逐步深入到I/O多路复用技术(select/poll/epoll),最终实现一个支持高并发的回声服务器。所有代码均交叉编译在ARM开发板上验证通过。


一、引言

嵌入式设备最常见的网络场景:传感器数据上报、远程控制指令接收、Web配置界面。这些都需要网络通信。Linux下网络编程的核心是Socket API,而要应对成千上万个并发连接,就需要掌握I/O多路复用。

本文采用循序渐进的方式:先写一个最简单的TCP回声服务器,再逐渐优化为多进程、多线程版本,最后用epoll实现单线程高并发,让你彻底理解网络编程的演进脉络。


二、网络编程基础

2.1 TCP/IP协议栈简图

复制代码
应用层    (HTTP, MQTT, 自定义协议)
传输层    (TCP, UDP)
网络层    (IP)
链路层    (Ethernet, Wi-Fi)

我们编写的Socket程序工作在应用层,使用传输层提供的TCP或UDP服务。

2.2 Socket是什么?

Socket是操作系统提供的一个编程接口 ,用于访问网络协议栈。对应用层来说,Socket就是一个文件描述符,可以用read/write(或专用的send/recv)进行数据传输。

2.3 TCP通信流程

复制代码
服务器端                        客户端
socket()                       socket()
  ↓                              ↓
bind()                          (可选的bind)
  ↓                              ↓
listen()                        connect()
  ↓                              ↓
accept() ←--------- 三次握手 ------------→       ↓
  ↓                              ↓
read()/write() ←------ 数据传输 ------→ read()/write()
  ↓                              ↓
close()                        close()

三、核心Socket API

3.1 socket() ------ 创建套接字

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

int socket(int domain, int type, int protocol);
  • domainAF_INET(IPv4)、AF_INET6(IPv6)
  • typeSOCK_STREAM(TCP,字节流)、SOCK_DGRAM(UDP,数据报)
  • protocol:通常设为0,自动选择协议
  • 返回值:文件描述符,失败返回-1

3.2 bind() ------ 绑定地址

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

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

用于服务器将Socket绑定到特定IP和端口。地址结构体:

c 复制代码
struct sockaddr_in {
    sa_family_t    sin_family; // AF_INET
    in_port_t      sin_port;   // 端口号(网络字节序)
    struct in_addr sin_addr;   // IP地址
};

3.3 listen() ------ 开始监听

c 复制代码
int listen(int sockfd, int backlog);
  • backlog:未完成连接队列(SYN队列)与已完成连接队列(accept队列)之和的最大值。Linux 5.4+默认4096。

3.4 accept() ------ 接受连接

c 复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • 默认阻塞,直到有客户端连接
  • 返回新的套接字(用于与该客户端通信),失败返回-1

3.5 connect() ------ 客户端连接服务器

c 复制代码
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

3.6 数据收发函数

c 复制代码
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t read(int sockfd, void *buf, size_t count);  // 也可用
ssize_t write(int sockfd, const void *buf, size_t count);

3.7 字节序转换函数

网络字节序使用大端(Big-Endian),而主机可能是小端(如x86、ARM)。需要转换:

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

uint32_t htonl(uint32_t hostlong);   // Host to Network Long
uint16_t htons(uint16_t hostshort);  // Host to Network Short
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

// IP地址转换
int inet_pton(int af, const char *src, void *dst);   // 字符串 → 二进制
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size); // 二进制 → 字符串

四、实战案例演进

4.1 最简TCP回声服务器(单连接,阻塞)

只能处理一个客户端,处理完才接受下一个。

c 复制代码
/**
 * tcp_echo_basic.c ------ 最简TCP回声服务器
 * 编译: arm-linux-gnueabihf-gcc -Wall -g -o tcp_echo_basic tcp_echo_basic.c
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define PORT 8080
#define BUFFER_SIZE 1024

int main(void)
{
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];
    ssize_t n;

    /* 1. 创建socket */
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    /* 设置地址复用,避免重启时TIME_WAIT导致bind失败 */
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    /* 2. 绑定地址和端口 */
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;  // 监听所有网卡
    server_addr.sin_port = htons(PORT);

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    /* 3. 监听 */
    if (listen(server_fd, 5) == -1) {
        perror("listen");
        close(server_fd);
        exit(EXIT_FAILURE);
    }
    printf("服务器启动,监听端口 %d...\n", PORT);

    /* 4. 接受连接(循环) */
    while (1) {
        client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
        if (client_fd == -1) {
            perror("accept");
            continue;
        }
        printf("客户端连接: %s:%d\n",
               inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

        /* 5. 回声服务:读多少,回多少 */
        while ((n = recv(client_fd, buffer, BUFFER_SIZE - 1, 0)) > 0) {
            buffer[n] = '\0';
            printf("收到: %s", buffer);
            send(client_fd, buffer, n, 0);
        }
        printf("客户端断开\n");
        close(client_fd);
    }

    close(server_fd);
    return 0;
}

4.2 多进程版本(每个连接fork一个子进程)

解决了并发问题,但每次fork开销大,进程数有限。

c 复制代码
/**
 * tcp_echo_fork.c ------ 多进程TCP回声服务器
 * 编译: arm-linux-gnueabihf-gcc -Wall -g -o tcp_echo_fork tcp_echo_fork.c
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <signal.h>

#define PORT 8080
#define BUFFER_SIZE 1024

/* SIGCHLD处理:回收僵尸进程 */
void sigchld_handler(int sig) {
    (void)sig;
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

int main(void)
{
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len = sizeof(client_addr);
    struct sigaction sa;

    /* 注册SIGCHLD处理 */
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGCHLD, &sa, NULL);

    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    listen(server_fd, 5);

    printf("多进程服务器启动,端口 %d\n", PORT);

    while (1) {
        client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
        if (client_fd == -1) {
            perror("accept");
            continue;
        }

        pid_t pid = fork();
        if (pid == 0) {
            /* 子进程 */
            close(server_fd); // 不需要监听socket
            char buffer[BUFFER_SIZE];
            ssize_t n;
            while ((n = recv(client_fd, buffer, BUFFER_SIZE - 1, 0)) > 0) {
                buffer[n] = '\0';
                send(client_fd, buffer, n, 0);
            }
            close(client_fd);
            _exit(0);
        } else {
            close(client_fd); // 父进程关闭已accept的socket,继续监听
        }
    }

    close(server_fd);
    return 0;
}

4.3 I/O多路复用:select版本

select允许一个线程同时监听多个文件描述符的可读、可写、异常事件。

c 复制代码
/**
 * tcp_echo_select.c ------ select实现多客户端回声
 * 编译: arm-linux-gnueabihf-gcc -Wall -g -o tcp_echo_select tcp_echo_select.c
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define PORT 8080
#define MAX_CLIENTS FD_SETSIZE  // 通常1024

int main(void)
{
    int server_fd, client_fd, max_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len = sizeof(client_addr);
    fd_set readfds, master_fds;
    char buffer[1024];

    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    listen(server_fd, 5);

    printf("select服务器启动,端口 %d\n", PORT);

    FD_ZERO(&master_fds);
    FD_SET(server_fd, &master_fds);
    max_fd = server_fd;

    while (1) {
        readfds = master_fds;  // 每次都要复制,因为select会修改
        if (select(max_fd + 1, &readfds, NULL, NULL, NULL) == -1) {
            perror("select");
            break;
        }

        /* 遍历所有fd */
        for (int fd = 0; fd <= max_fd; fd++) {
            if (!FD_ISSET(fd, &readfds)) continue;

            if (fd == server_fd) {
                /* 新连接 */
                client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
                if (client_fd != -1) {
                    FD_SET(client_fd, &master_fds);
                    if (client_fd > max_fd) max_fd = client_fd;
                    printf("新连接 fd=%d\n", client_fd);
                }
            } else {
                /* 客户端数据 */
                ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0);
                if (n <= 0) {
                    if (n == 0) printf("客户端断开 fd=%d\n", fd);
                    else perror("recv");
                    close(fd);
                    FD_CLR(fd, &master_fds);
                    if (fd == max_fd) {
                        /* 更新max_fd */
                        while (max_fd >= 0 && !FD_ISSET(max_fd, &master_fds))
                            max_fd--;
                    }
                } else {
                    buffer[n] = '\0';
                    send(fd, buffer, n, 0);
                }
            }
        }
    }

    close(server_fd);
    return 0;
}

4.4 I/O多路复用:poll版本

poll使用可变长度的pollfd数组,突破了1024限制。

c 复制代码
/**
 * tcp_echo_poll.c ------ poll实现多客户端回声
 * 编译: arm-linux-gnueabihf-gcc -Wall -g -o tcp_echo_poll tcp_echo_poll.c
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <poll.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define PORT 8080
#define MAX_CLIENTS 1024

int main(void)
{
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len = sizeof(client_addr);
    struct pollfd fds[MAX_CLIENTS + 1];  // +1 for server_fd
    int nfds = 0;
    char buffer[1024];

    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    listen(server_fd, 5);

    printf("poll服务器启动,端口 %d\n", PORT);

    /* 初始化:添加监听socket */
    memset(fds, 0, sizeof(fds));
    fds[0].fd = server_fd;
    fds[0].events = POLLIN;
    nfds = 1;

    while (1) {
        int ret = poll(fds, nfds, -1);
        if (ret == -1) {
            perror("poll");
            break;
        }

        /* 遍历所有fd */
        for (int i = 0; i < nfds; i++) {
            if (!(fds[i].revents & POLLIN)) continue;

            if (fds[i].fd == server_fd) {
                /* 新连接 */
                client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
                if (client_fd != -1) {
                    fds[nfds].fd = client_fd;
                    fds[nfds].events = POLLIN;
                    nfds++;
                    printf("新连接 fd=%d\n", client_fd);
                }
            } else {
                /* 客户端数据 */
                int fd = fds[i].fd;
                ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0);
                if (n <= 0) {
                    if (n == 0) printf("客户端断开 fd=%d\n", fd);
                    else perror("recv");
                    close(fd);
                    /* 删除该fd:与最后一个交换后nfds-- */
                    fds[i] = fds[nfds - 1];
                    nfds--;
                    i--;  // 重新检查当前位置
                } else {
                    buffer[n] = '\0';
                    send(fd, buffer, n, 0);
                }
            }
        }
    }

    close(server_fd);
    return 0;
}

4.5 I/O多路复用:epoll版本(高并发核心)

epoll是Linux特有的高性能I/O多路复用机制,复杂度O(1)。

c 复制代码
/**
 * tcp_echo_epoll.c ------ epoll实现高并发回声服务器
 * 编译: arm-linux-gnueabihf-gcc -Wall -g -o tcp_echo_epoll tcp_echo_epoll.c
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/epoll.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <fcntl.h>

#define PORT 8080
#define MAX_EVENTS 1024
#define BUFFER_SIZE 4096

/* 设置非阻塞 */
int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main(void)
{
    int server_fd, client_fd, epoll_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len = sizeof(client_addr);
    struct epoll_event ev, events[MAX_EVENTS];
    char buffer[BUFFER_SIZE];

    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    set_nonblocking(server_fd);

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    listen(server_fd, SOMAXCONN);

    /* 创建epoll实例 */
    epoll_fd = epoll_create1(0);
    ev.events = EPOLLIN;
    ev.data.fd = server_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);

    printf("epoll服务器启动,端口 %d (ET模式)\n", PORT);

    while (1) {
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (nfds == -1) {
            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == server_fd) {
                /* 边缘触发:循环accept直到EAGAIN */
                while (1) {
                    client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
                    if (client_fd == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK)
                            break;  // 已全部accept
                        perror("accept");
                        break;
                    }
                    printf("新连接: %s:%d fd=%d\n",
                           inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), client_fd);
                    set_nonblocking(client_fd);
                    ev.events = EPOLLIN | EPOLLET;  // 边缘触发
                    ev.data.fd = client_fd;
                    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
                }
            } else if (events[i].events & EPOLLIN) {
                /* 处理客户端数据(边缘触发,需循环读) */
                int fd = events[i].data.fd;
                while (1) {
                    ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
                    if (n > 0) {
                        /* 回声:原样返回 */
                        send(fd, buffer, n, 0);
                    } else if (n == 0) {
                        printf("客户端断开 fd=%d\n", fd);
                        epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                        break;
                    } else {
                        if (errno == EAGAIN)
                            break;  // 数据读完
                        perror("recv");
                        epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                        break;
                    }
                }
            }
        }
    }

    close(server_fd);
    close(epoll_fd);
    return 0;
}

epoll核心优势

  • 只返回就绪事件,不需要遍历所有fd
  • 红黑树管理监听的fd,增删改查O(logN)
  • 支持ET(边缘触发)和LT(水平触发)两种模式

五、select/poll/epoll对比

特性 select poll epoll
最大并发数 1024 (FD_SETSIZE) 无限制 无限制
效率 O(n)遍历 O(n)遍历 O(1)回调
内存复制 每次wait复制整个集合 每次poll复制整个数组 只返回就绪事件
触发模式 水平触发 水平触发 水平触发(LT) + 边缘触发(ET)
适用场景 少量连接 中量连接 大量并发连接

六、最佳实践与常见陷阱

陷阱 对策
ET模式下只读一次,导致数据残留 循环读取直到EAGAIN
忘记设置SO_REUSEADDR,重启报错 监听socket务必设置
send/recv返回值<期望值 封装send_all/recv_all
客户端突然断开(拔网线) 定期心跳检测或设置SO_KEEPALIVE
多线程处理同一fd 使用EPOLLONESHOT
惊群问题(多进程/线程epoll同一fd) 内核4.5+使用EPOLLEXCLUSIVE

七、总结与思考题

本篇从最简TCP回声服务器开始,逐步引入多进程、select/poll/epoll,完整呈现了高并发服务器的演进路径。epoll版本的代码已经可以作为嵌入式网络服务的基础框架。

核心要点

  1. Socket就是文件描述符,可用read/write操作
  2. 网络编程务必处理字节序转换
  3. select有1024限制和O(n)效率问题,poll突破限制但效率仍为O(n)
  4. epoll采用回调机制,复杂度O(1),支持ET/LT两种模式
  5. ET模式下必须循环读写直到EAGAIN

思考题

  1. ET模式下,如果一个客户端分两次发送了"Hello"和"World",在服务器端可能一次读到"HelloWorld",这说明了什么问题?接收端应如何处理?
  2. 如果要在epoll服务器上实现给所有客户端广播消息,应该如何修改代码?
  3. 结合上一篇文章的线程池,如何设计一个epoll + 线程池的高性能服务器?画出架构简图。

欢迎在评论区留下你的思考。下一篇我们将进入信号处理进阶------sigaction、signalfd与定时器的深度探索。


参考资料

  • 《UNIX网络编程 卷1:套接字联网API》
  • man 7 socket, man 7 epoll
  • Linux内核源码 fs/eventpoll.c