Linux C网络编程如何实现高效通信?

99ANYc3cd6
预计阅读时长 36 分钟
位置: 首页 C语言 正文

目录

  1. 网络编程基础
    • OSI 七层模型与 TCP/IP 四层模型
    • 套接字:网络编程的基石
    • 网络字节序与主机字节序
  2. 核心 Socket API
    • 创建套接字 (socket)
    • 绑定地址 (bind)
    • 监听连接 (listen)
    • 接受连接 (accept)
    • 连接服务器 (connect)
    • 数据传输 (send, recv)
    • 关闭套接字 (close)
  3. 一个简单的 TCP 服务器/客户端示例
    • 回显服务器
    • 回显客户端
  4. 进阶主题
    • 多路复用:select, poll, epoll
    • 阻塞 vs. 非阻塞 I/O
    • 高级 I/O 模型简介
  5. 常用工具与调试
    • netstat, ss
    • telnet, nc (netcat)
    • wireshark

网络编程基础

OSI 七层模型与 TCP/IP 四层模型

理解网络模型有助于我们明白数据是如何封装和传输的。

linux c语言 网络编程
(图片来源网络,侵删)
OSI 七层模型 TCP/IP 四层模型 主要功能和协议
应用层 应用层 为应用程序提供服务 (HTTP, FTP, SMTP, DNS)
表示层 数据格式转换、加密解密、压缩
会话层 建立、管理和终止会话
传输层 传输层 提供端到端的、可靠的或不可靠的数据传输 (TCP, UDP)
网络层 网络层 负责逻辑寻址和路由选择 (IP, ICMP)
数据链路层 网络接口层 负责物理寻址(MAC地址)和错误检测 (Ethernet, Wi-Fi)
物理层 传输二进制比特流 (网线、光纤、无线电波)

Linux C 网络编程主要关注传输层和应用层。

套接字

套接字是网络编程的“文件描述符”,是操作系统提供给应用程序进行网络 I/O 操作的接口,它就像一个“门”,应用程序通过这个门发送和接收数据。

  • 流式套接字 (SOCK_STREAM):使用 TCP 协议。
    • 特点:面向连接、可靠、有序、不丢包。
    • 场景:文件传输、网页浏览、邮件发送等对数据准确性要求高的场景。
  • 数据报套接字 (SOCK_DGRAM):使用 UDP 协议。
    • 特点:无连接、不可靠、可能丢包、可能重复。
    • 场景:视频会议、在线游戏、DNS查询等对实时性要求高、能容忍少量丢包的场景。

网络字节序与主机字节序

  • 主机字节序:CPU 存储多字节数据的顺序。
    • 大端序 (Big-Endian):高位字节存储在低地址,低位字节存储在高地址。(网络标准)
    • 小端序:低位字节存储在低地址,高位字节存储在高地址。(x86/x64 架构)
  • 网络字节序:网络传输中统一使用 大端序

关键:在发送数据前,必须将主机字节序转换为网络字节序;在接收数据后,必须将网络字节序转换回主机字节序。

常用函数

linux c语言 网络编程
(图片来源网络,侵删)
  • htons() (host to network short): 16位短整型转换
  • htonl() (host to network long): 32位长整型转换
  • ntohs() (network to host short): 16位短整型转换
  • ntohl() (network to host long): 32位长整型转换

核心 Socket API

以下是 TCP 编程中最常用的一些函数。

TCP 服务器端流程

socket() -> bind() -> listen() -> accept() -> send()/recv() -> close()

TCP 客户端流程

socket() -> connect() -> send()/recv() -> close()


函数 原型 描述
socket() int socket(int domain, int type, int protocol); 创建一个套接字。domain 通常是 AF_INET (IPv4) 或 AF_INET6 (IPv6)。typeSOCK_STREAM (TCP) 或 SOCK_DGRAM (UDP),成功返回套接字文件描述符,失败返回 -1。
bind() int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); 将套接字与一个特定的 IP 地址和端口号绑定。sockaddr 是一个通用地址结构,实际使用时会用 sockaddr_in (IPv4) 或 sockaddr_in6 (IPv6) 并进行类型转换。
listen() int listen(int sockfd, int backlog); 将套接字转换为被动套接字,用于接受连接请求。backlog 是等待连接队列的最大长度。
accept() int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); 从已完成连接的队列中取出一个连接,创建一个新的套接字用于与客户端通信。sockfd 是监听套接字,返回的是新的通信套接字。addraddrlen 用来获取客户端的地址信息。
connect() int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); 客户端调用,用于主动连接到服务器。sockfd 是客户端的套接字,addr 是服务器的地址。
send() ssize_t send(int sockfd, const void *buf, size_t len, int flags); 通过已连接的套接字发送数据。flags 通常为 0。
recv() ssize_t recv(int sockfd, void *buf, size_t len, int flags); 通过已连接的套接字接收数据。flags 通常为 0。
close() int close(int fd); 关闭套接字,释放资源。

struct sockaddr_in 结构体 (IPv4)

linux c语言 网络编程
(图片来源网络,侵删)

这是 bind, connect 等函数最常用的地址结构体。

#include <netinet/in.h>
struct sockaddr_in {
    short            sin_family;   // 地址族,必须是 AF_INET
    unsigned short   sin_port;     // 端口号,必须用 htons() 转换
    struct in_addr  sin_addr;     // IP 地址
    unsigned char    sin_zero[8];  // 填充字节,必须为 0
};
// in_addr 结构体
struct in_addr {
    unsigned long s_addr; // IP 地址,必须用 inet_addr() 或 inet_pton() 转换
};

一个简单的 TCP 服务器/客户端示例

这个例子实现了一个“回显服务器”:客户端发送什么,服务器就原样返回什么。

头文件

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>

回显服务器代码 (server.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
    int server_fd, client_fd;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[BUFFER_SIZE] = {0};
    // 1. 创建套接字
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }
    // 设置套接字选项,允许地址重用
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY; // 监听所有可用网络接口
    address.sin_port = htons(PORT);
    // 2. 绑定地址和端口
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    // 3. 开始监听
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }
    printf("Server listening on port %d...\n", PORT);
    // 4. 接受客户端连接
    if ((client_fd = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
        perror("accept");
        exit(EXIT_FAILURE);
    }
    printf("Client connected: %s:%d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
    // 5. 循环接收和发送数据
    while (1) {
        int valread = read(client_fd, buffer, BUFFER_SIZE);
        if (valread <= 0) {
            // valread == 0 表示客户端关闭了连接
            // valread < 0 表示发生错误
            if (valread == 0) {
                printf("Client disconnected.\n");
            } else {
                perror("read");
            }
            break;
        }
        printf("Received: %s", buffer);
        // 6. 回显数据
        send(client_fd, buffer, valread, 0);
        memset(buffer, 0, BUFFER_SIZE); // 清空缓冲区
    }
    // 7. 关闭套接字
    close(client_fd);
    close(server_fd);
    return 0;
}

回显客户端代码 (client.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#define PORT 8080
#define SERVER_IP "127.0.0.1" // 本地回环地址
#define BUFFER_SIZE 1024
int main() {
    int sock = 0;
    struct sockaddr_in serv_addr;
    char buffer[BUFFER_SIZE] = {0};
    char message[BUFFER_SIZE];
    // 1. 创建套接字
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        printf("\n Socket creation error \n");
        return -1;
    }
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    // 将 IP 地址从文本转换为二进制形式
    if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0) {
        printf("\nInvalid address/ Address not supported \n");
        return -1;
    }
    // 2. 连接服务器
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        printf("\nConnection Failed \n");
        return -1;
    }
    printf("Connected to server %s:%d\n", SERVER_IP, PORT);
    // 3. 循环发送和接收数据
    while (1) {
        printf("Enter message to send (or 'exit' to quit): ");
        fgets(message, BUFFER_SIZE, stdin);
        if (strncmp(message, "exit", 4) == 0) {
            break;
        }
        send(sock, message, strlen(message), 0);
        int valread = read(sock, buffer, BUFFER_SIZE);
        if (valread <= 0) {
            printf("Server disconnected or error occurred.\n");
            break;
        }
        printf("Server echoed: %s", buffer);
        memset(buffer, 0, BUFFER_SIZE);
        memset(message, 0, BUFFER_SIZE);
    }
    // 4. 关闭套接字
    close(sock);
    return 0;
}

如何编译和运行

  1. 编译:

    gcc server.c -o server
    gcc client.c -o client
  2. 运行:

    • 终端1 (启动服务器):
      ./server
      # 输出: Server listening on port 8080...
    • 终端2 (启动客户端):
      ./client
      # 输出: Connected to server 127.0.0.1:8080
      # 然后输入任意消息,如 "hello"
      # 输出: Enter message to send (or 'exit' to quit): hello
      # 服务器终端会显示: Received: hello
      # 客户端终端会显示: Server echoed: hello
    • 在客户端输入 exit 即可退出程序。

进阶主题

阻塞 vs. 非阻塞 I/O

  • 阻塞 I/O (Blocking I/O):默认模式,当一个进程调用 recv() 但没有数据可读时,该进程会被挂起(进入睡眠状态),直到数据到达或发生错误,同样,accept() 在没有连接请求时也会阻塞。

    • 优点:编程简单。
    • 缺点:效率低,一个进程只能同时处理一个客户端连接。
  • 非阻塞 I/O (Non-blocking I/O):通过 fcntl()ioctl() 设置套接字为非阻塞模式,当调用 recv() 但没有数据时,它会立即返回一个错误码 EWOULDBLOCKEAGAIN,而不会让进程挂起。

    • 优点:进程不会被挂起,可以去执行其他任务。
    • 缺点:需要不断轮询检查,消耗大量 CPU 资源,效率也不高。

多路复用

为了解决非阻塞 I/O 轮询效率低的问题,引入了 I/O 多路复用技术,它允许你同时监视多个套接字,任何一个套接字就绪(可读、可写、出错)时,select/poll/epoll 函数才会返回。

  • select()

    • 原理:创建一个文件描述符集合(fd_set),通过位图来表示哪些 fd 需要监视,调用 select 会阻塞,直到集合中任何一个 fd 就绪。
    • 缺点
      1. 单个进程能监视的 fd 数量有限(通常为 1024)。
      2. 每次调用都需要从用户空间将 fd_set 复制到内核空间,返回时再复制回来,开销大。
      3. select 返回后,需要遍历整个 fd_set 来找到就绪的 fd。
  • poll()

    • 原理:使用 pollfd 结构体数组代替 fd_set,没有 fd 数量限制。
    • 缺点:和 select 一样,每次都需要大量数据在内核和用户空间之间复制,效率仍然不高。
  • epoll() (Linux 特有,性能最高)

    • 原理:它将文件描述符的管理放在内核中,你只需要在 epoll_ctl 时向内核注册 fd,后续只需调用 epoll_wait 即可。
    • 优点
      1. 没有 fd 数量限制。
      2. 只需在 epoll_ctl 时复制数据,epoll_wait 时几乎不复制。
      3. epoll_wait 返回的是就绪 fd 的列表,无需遍历,效率极高。
      4. 支持 EPOLLET (边缘触发) 模式,性能比 select/poll 的水平触发模式更高。

epoll 是构建高性能网络服务器的首选。


常用工具与调试

  • netstat / ss:查看网络连接状态。

    • netstat -tulpn:显示所有 TCP/UDP 端口以及进程ID。
    • ss -tulpnnetstat 的现代替代品,速度更快,信息更全。
  • telnet / nc (netcat):测试网络服务的简单工具。

    • telnet <ip> <port>:尝试连接到指定 IP 和端口,如果成功,说明服务正在监听。
    • nc <ip> <port>:功能更强大的网络工具,可以连接、监听、发送和接收数据,常用于快速调试。
  • wireshark / tcpdump:网络抓包分析工具。

    当你的程序出现奇怪的网络问题时(如数据包丢失、顺序错乱),使用它们可以捕获底层的网络数据包,查看 TCP 握手、挥手过程,以及数据传输的详细情况,是定位问题的利器。

Linux C 语言网络编程的核心是 Socket API,从简单的 TCP 回显服务器开始,理解 socket, bind, listen, accept, connect, send, recv 的流程和用法是第一步。

为了构建能够处理成千上万个并发连接的高性能服务器,你必须深入理解 I/O 多路复用,特别是 epoll 的工作原理和编程模型。

希望这份详细的指南能帮助你顺利入门 Linux C 网络编程!

-- 展开阅读全文 --
头像
怎么设置?
« 上一篇 今天
没有更多啦!
下一篇 »

相关文章

取消
微信二维码
支付宝二维码

目录[+]