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

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

目录

  1. 网络编程基础概念
    • OSI 七层模型与 TCP/IP 四层模型
    • IP 地址与端口号
    • 协议:TCP vs UDP
    • 套接字:Socket
  2. 核心 Socket API
    • 头文件
    • 创建套接字:socket()
    • 绑定地址:bind()
    • 监听连接:listen()
    • 接受连接:accept()
    • 连接服务器:connect()
    • 数据传输:send() / recv() (TCP), sendto() / recvfrom() (UDP)
    • 关闭套接字:close()
  3. 一个完整的 TCP 服务器/客户端示例
    • TCP 服务器代码
    • TCP 客户端代码
    • 编译与运行
  4. 一个简单的 UDP 示例
    • UDP 服务器/客户端代码
    • 编译与运行
  5. 高级主题
    • 多路复用:select(), poll(), epoll()
    • 非阻塞 I/O
    • 套接字选项
  6. 调试与常见问题

网络编程基础概念

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

网络通信遵循分层模型,每一层都建立在下一层之上。

linux c语言网络编程
(图片来源网络,侵删)
OSI 七层模型 TCP/IP 四层模型 主要功能 协议示例
应用层 应用层 为应用程序提供网络服务 HTTP, FTP, SMTP, DNS
表示层 数据格式转换、加密解密 SSL/TLS
会话层 建立、管理和终止会话 NetBIOS, RPC
传输层 传输层 提供端到端的可靠或不可靠数据传输 TCP, UDP
网络层 网络层 负责数据包的路由和转发 IP, ICMP, ARP
数据链路层 网络接口层 在物理网络(如以太网)上传输数据 Ethernet, Wi-Fi
物理层 传输二进制比特流 网线、光纤、无线电波

核心思想:我们作为程序员,主要工作在 应用层,我们通过 套接字 这个 API,与底层的 传输层 进行交互,从而实现网络通信。

IP 地址与端口号

  • IP 地址:网络中设备的唯一标识,就像你家的地址。168.1.100
  • 端口号:同一台设备上,不同应用程序的标识,就像你家的房间号,取值范围是 0-65535。
    • 0-1023:熟知端口,被系统或特定服务占用(如 HTTP 80, FTP 21)。
    • 1024-49151:注册端口,用户程序可以使用。
    • 49152-65535:动态/私有端口,客户端通常使用这个范围作为临时端口。

一个网络连接的唯一标识是 五元组{源IP, 源端口, 目的IP, 目的端口, 协议}

协议:TCP vs UDP

  • TCP (Transmission Control Protocol - 传输控制协议)

    • 特点:面向连接、可靠、字节流。
    • 过程:通信前需要通过“三次握手”建立连接,通信结束后需要“四次挥手”断开连接。
    • 可靠性保证:通过序列号、确认应答、超时重传、流量控制和拥塞控制等机制确保数据无差错、不丢失、不重复且按序到达。
    • 应用场景:对可靠性要求高的场景,如文件传输、网页浏览、邮件发送。
  • UDP (User Datagram Protocol - 用户数据报协议)

    linux c语言网络编程
    (图片来源网络,侵删)
    • 特点:无连接、不可靠、数据报。
    • 过程:直接发送数据,无需建立连接。
    • 特点:开销小、传输速度快,但不保证数据是否到达、到达的顺序或是否重复。
    • 应用场景:对实时性要求高、能容忍少量丢包的场景,如视频会议、在线游戏、DNS查询。

套接字:Socket

套接字是操作系统提供给应用程序进行网络编程的 API,它像一个“文件描述符”,你可以对它进行读写操作,只不过读写的不是本地文件,而是网络数据。

在 Linux 中,一切皆文件,套接字在内核中也被视为一个文件,因此它也有一个文件描述符,是一个非负整数。


核心 Socket API

头文件

#include <sys/socket.h>    // 核心套接字函数
#include <netinet/in.h>     // IP 地址和端口号结构体 (AF_INET)
#include <arpa/inet.h>     // IP 地址转换函数 (inet_pton, inet_ntop)
#include <unistd.h>        // read, write, close
#include <string.h>        // memset, strerror
#include <stdio.h>         // perror, printf
#include <stdlib.h>        // exit

创建套接字:socket()

int socket(int domain, int type, int protocol);
  • domain:地址族。
    • AF_INET:IPv4。
    • AF_INET6:IPv6。
    • AF_UNIX:本地进程间通信。
  • type:套接字类型。
    • SOCK_STREAM:流式套接字,对应 TCP
    • SOCK_DGRAM:数据报套接字,对应 UDP
  • protocol:协议,通常设为 0,系统会自动根据 domaintype 选择。
  • 返回值:成功返回套接字文件描述符,失败返回 -1

绑定地址:bind()

服务器端需要调用此函数,将套接字与本机的 IP 地址和端口号绑定。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:由 socket() 返回的套接字描述符。
  • addr:指向 sockaddr 结构体的指针,包含了要绑定的 IP 和端口。
  • addrlenaddr 结构体的长度。

sockaddr 是一个通用结构体,对于 IPv4,我们通常使用 sockaddr_in 结构体,并用 memset 和类型转换来初始化它。

struct sockaddr_in {
    sa_family_t    sin_family; // 地址族, AF_INET
    in_port_t      sin_port;   // 16位端口号, 需要用htons()转换
    struct in_addr sin_addr;   // 32位IP地址
    // ... 其他字段
};
struct in_addr {
    in_addr_t s_addr; // 32位IPv4地址, 需要用inet_pton()转换
}

关键点

  1. IP 地址:如果服务器监听所有网络接口,IP 地址可以设置为 INADDR_ANY,其值为 0.0.0
  2. 端口号:端口号是网络字节序的,而我们的主机可能是小端序,需要用 htons() (host to network short) 将端口号转换为网络字节序。

监听连接:listen()

对于 TCP 服务器,在 bind() 之后,调用 listen() 将套接字从主动连接模式变为被动监听模式,等待客户端连接。

int listen(int sockfd, int backlog);
  • sockfd:已绑定地址的套接字描述符。
  • backlog:等待连接队列的最大长度,当有多个客户端同时连接时,超出此数量的连接可能会被拒绝。

接受连接:accept()

accept() 是一个阻塞函数,它会从已完成连接的队列中取出一个连接,并为这个新连接创建一个新的套接字。

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd:由 listen() 的套接字,称为“监听套接字”。
  • addr:一个 sockaddr 结构体指针,用于存放客户端的 IP 地址和端口信息,如果不需要,可以设为 NULL
  • addrlen:一个指针,指向 addr 结构体的长度,函数返回时,它会更新为实际写入的长度。
  • 返回值:成功返回一个新的套接字文件描述符(连接套接字),用于与这个特定的客户端通信,失败返回 -1

重要:服务器会一直保留这个“监听套接字”,用于接受后续的客户端连接,而 accept() 返回的“连接套接字”则专门用于与当前已连接的客户端进行数据传输。

连接服务器:connect()

TCP 客户端调用此函数来主动连接服务器。

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:由 socket() 创建的套接字描述符。
  • addr:指向服务器 sockaddr 结构体的指针,包含服务器的 IP 和端口。
  • addrlenaddr 结构体的长度。
  • 返回值:成功返回 0,失败返回 -1connect() 是一个阻塞函数,直到连接成功或失败才返回。

数据传输

  • TCP (流式)

    • 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);
    • send()recv()write()read() 功能非常相似,flags 通常设为 0
  • UDP (数据报)

    • ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
    • ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
    • sendto() 需要指定目标地址。recvfrom() 在接收数据的同时,可以获取到数据发送方的地址。

关闭套接字:close()

int close(int fd);

关闭套接字,释放相关资源,对于 TCP,它会发送 FIN 包来终止连接。


一个完整的 TCP 服务器/客户端示例

TCP 服务器代码 (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>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
    int server_fd, new_socket;
    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);
    }
    // 2. 设置套接字选项,允许地址重用
    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);       // 将端口号转换为网络字节序
    // 3. 绑定套接字到指定端口
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    printf("Server bound to port %d\n", PORT);
    // 4. 开始监听连接
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }
    printf("Server is listening for connections...\n");
    // 5. 接受客户端连接
    if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
        perror("accept");
        exit(EXIT_FAILURE);
    }
    printf("Connection accepted from %s:%d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
    // 6. 与客户端通信
    int valread;
    while ((valread = read(new_socket, buffer, BUFFER_SIZE)) > 0) {
        printf("Client: %s", buffer);
        send(new_socket, buffer, valread, 0); // 将收到的消息回显给客户端
    }
    // 7. 关闭套接字
    close(new_socket);
    close(server_fd);
    return 0;
}

TCP 客户端代码 (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>
#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};
    // 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);
    // 2. 将IP地址从文本转换为网络字节序
    if (inet_pton(AF_INET, SERVER_IP, &serv_addr.sin_addr) <= 0) {
        printf("\nInvalid address/ Address not supported \n");
        return -1;
    }
    // 3. 连接服务器
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        printf("\nConnection Failed \n");
        return -1;
    }
    printf("Connected to server at %s:%d\n", SERVER_IP, PORT);
    // 4. 与服务器通信
    while(1) {
        printf("Enter message to send (or 'quit' to exit): ");
        fgets(buffer, BUFFER_SIZE, stdin);
        if (strncmp(buffer, "quit", 4) == 0) {
            break;
        }
        send(sock, buffer, strlen(buffer), 0);
        printf("Message sent.\n");
        int valread = read(sock, buffer, BUFFER_SIZE);
        if (valread > 0) {
            printf("Server echo: %s", buffer);
        }
    }
    // 5. 关闭套接字
    close(sock);
    return 0;
}

编译与运行

  1. 编译

    # 分别编译服务器和客户端
    gcc server.c -o server
    gcc client.c -o client
  2. 运行

    • 打开一个终端,运行服务器:
      ./server
      # 输出:
      # Server bound to port 8080
      # Server is listening for connections...
    • 打开另一个终端,运行客户端:
      ./client
      # 输出:
      # Connected to server at 127.0.0.1:8080
      # Enter message to send (or 'quit' to exit):
    • 在客户端输入消息,服务器会收到并回显。
      • 客户端输入: Hello, Server!
      • 服务器输出: Client: Hello, Server!
      • 客户端输出: Server echo: Hello, Server!

一个简单的 UDP 示例

UDP 无需连接,代码更简单,服务器和客户端的代码结构很相似,都可以直接发送和接收数据。

UDP 代码 (udp_server_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>
#define PORT 8081
#define BUFFER_SIZE 1024
int main() {
    int sockfd;
    char buffer[BUFFER_SIZE];
    struct sockaddr_in servaddr, cliaddr;
    // 1. 创建UDP套接字
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0 ) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }
    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));
    // 2. 绑定服务器地址
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(PORT);
    if ( bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0 ) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    printf("UDP Server is listening on port %d\n", PORT);
    // 3. 循环接收和发送数据
    int len, n;
    len = sizeof(cliaddr);
    while(1) {
        // 4. 接收客户端数据
        n = recvfrom(sockfd, (char *)buffer, BUFFER_SIZE, 0, (struct sockaddr *)&cliaddr, &len);
        buffer[n] = '\0';
        printf("Client : %s\n", buffer);
        // 5. 回显数据给客户端
        sendto(sockfd, (const char *)buffer, strlen(buffer), 0, (const struct sockaddr *)&cliaddr, len);
        printf("Echo message sent.\n");
    }
    close(sockfd);
    return 0;
}

编译与运行

  • 编译:gcc udp_server_client.c -o udp_server
  • 运行:./udp_server
  • 你可以使用另一个终端,用 netcat 工具来测试:nc -u 127.0.0.1 8081,然后输入任意文本,服务器会回显。

高级主题

多路复用:select(), poll(), epoll()

当服务器需要同时处理多个客户端连接时,不能为每个连接都开一个线程(会消耗大量资源),这时就需要 I/O 多路复用 技术。

  • select()

    • 原理:创建一个文件描述符集合(fd_set),通过 select() 函数阻塞,直到集合中任何一个描述符准备好 I/O(可读、可写、异常)。
    • 缺点:单个进程能监视的文件描述符数量有限(通常为 1024);每次调用都需要将 fd_set 从用户空间拷贝到内核空间,并且需要遍历所有描述符来找出哪些就绪了,效率低。
  • poll()

    • 原理:用 struct pollfd 数组代替 fd_set,解决了文件描述符数量限制的问题。
    • 缺点:和 select() 一样,每次都需要将整个数组拷贝到内核,并且需要遍历。
  • epoll() (Linux 特有,推荐使用)

    • 原理:在内核中维护一个事件表。epoll_ctl() 用于向内核注册/修改/删除要监控的文件描述符。epoll_wait() 只返回那些已经就绪的文件描述符。
    • 优点:
      1. 没有数量限制:取决于系统内存。
      2. 效率高epoll_wait 只返回就绪的描述符,无需遍历所有描述符。
      3. 支持边缘触发ET (Edge-Triggered) 模式下,只有状态发生变化(如从不可读变为可读)时才会通知,效率更高,但编程也更复杂。

非阻塞 I/O

将套接字设置为非阻塞模式后,如果请求的操作不能立即完成(如 accept 没有连接,read 没有数据),函数会立即返回一个错误码(EAGAINEWOULDBLOCK),而不是阻塞等待。

这通常与 I/O 多路复用(如 epoll)结合使用,通过轮询或事件通知来处理 I/O 操作。

套接字选项

可以使用 setsockopt()getsockopt() 来获取或设置套接字的属性。

  • SO_REUSEADDR:避免 TIME_WAIT 状态导致的地址占用问题,服务器重启时常用。
  • SO_RCVBUF / SO_SNDBUF:设置接收/发送缓冲区大小。
  • SO_KEEPALIVE:启用 TCP 保活机制,检测长时间无数据传输的连接是否还活着。

调试与常见问题

  1. Address already in use

    • 原因:服务器程序异常退出后,套接字处于 TIME_WAIT 状态,端口被暂时占用。
    • 解决:在 bind() 之前,使用 setsockopt() 设置 SO_REUSEADDR 选项。
  2. Permission denied

    • 原因:尝试绑定小于 1024 的端口号,但没有 root 权限。
    • 解决:使用大于 1024 的端口号,或使用 sudo 运行程序。
  3. Connection refused

    • 原因:客户端连接时,目标 IP 和端口上没有服务器在监听。
    • 解决:检查服务器是否已启动,IP 地址和端口号是否正确。
  4. Broken pipe

    • 原因:客户端在服务器关闭连接前关闭了连接,服务器尝试向一个已关闭的连接写入数据。
    • 解决:在 send()write() 调用后检查返回值,处理 SIGPIPE 信号(或忽略它)。
  5. 数据粘包/拆包问题 (TCP)

    • 原因:TCP 是面向字节流的,没有消息边界,发送方多次 send() 的数据可能会被合并成一个数据包到达;一次 send() 的大数据也可能被拆分成多个数据包到达。
    • 解决
      • 应用层定义协议:在数据包前加上固定长度的头部,声明数据包的长度。
      • 使用特殊分隔符:在消息末尾加上特殊字符(如 \n),但消息内容中不能包含该字符。
      • 使用成熟框架:如 Protobuf, gRPC 等,它们已经处理了这些问题。

希望这份详细的指南能帮助你入门 Linux C 语言网络编程!从 TCP 开始,理解了客户端/服务器的基本模型后,再学习 UDP 和高级主题会更容易。

-- 展开阅读全文 --
头像
织梦安装后为何不显示?
« 上一篇 今天
dede手机图片自适应怎么做?
下一篇 » 今天

相关文章

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

目录[+]