C语言TCP socket通信如何实现?

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

我将从基础概念开始,逐步深入,并提供一个完整的、可运行的客户端/服务器端示例代码。

tcpsocket通信c语言
(图片来源网络,侵删)

目录

  1. 核心概念:什么是 Socket?
  2. TCP 通信流程概览
  3. 核心 API 函数详解
  4. 完整代码示例:回显服务器
    • 服务器端代码
    • 客户端代码
    • 如何编译和运行
  5. 关键注意事项
    • 字节序
    • 地址复用
    • 错误处理
    • 阻塞与非阻塞 I/O

核心概念:什么是 Socket?

Socket(套接字)是操作系统提供的一组 API,它充当了应用程序和网络协议栈之间的接口,你可以把它想象成一个“网络上的文件描述符”,应用程序通过读写这个“文件”来发送和接收数据。

Socket 主要分为三种类型:

  • 流式套接字:使用 TCP 协议,提供面向连接、可靠的数据传输服务,数据无差错、不丢失、不重复,且按序到达。
  • 数据报套接字:使用 UDP 协议,提供无连接、尽最大努力的数据传输服务,可能丢失、重复或乱序。
  • 原始套接字:允许直接访问底层协议(如 IP、ICMP),通常用于网络编程和诊断工具。

本教程专注于 TCP 流式套接字

TCP 通信流程概览

TCP 是面向连接的协议,通信前必须先建立连接。

tcpsocket通信c语言
(图片来源网络,侵删)

服务器端 流程:

  1. 创建 Socket:使用 socket() 函数创建一个套接字描述符。
  2. 绑定地址和端口:使用 bind() 函数将套接字与一个特定的 IP 地址和端口号绑定,这样客户端才能找到它。
  3. 监听连接:使用 listen() 函数将套接字设置为“监听”状态,等待客户端的连接请求。
  4. 接受连接:使用 accept() 函数阻塞等待,直到有客户端连接上来。accept() 会返回一个新的套接字描述符,专门用于与这个客户端通信。
  5. 收发数据:使用 send()recv() (或 read()/write()) 函数通过新建立的套接字与客户端进行数据交换。
  6. 关闭连接:通信结束后,使用 close() 关闭套接字。

客户端 流程:

  1. 创建 Socket:同样使用 socket() 函数创建一个套接字描述符。
  2. 连接服务器:使用 connect() 函数主动向服务器的 IP 地址和端口发起连接请求。
  3. 收发数据:连接成功后,使用 send()recv() (或 read()/write()) 函数与服务器交换数据。
  4. 关闭连接:通信结束后,使用 close() 关闭套接字。

流程图解:

      客户端                                  服务器
        |                                      |
   1. socket()                           1. socket()
        |                                      |
        |                                      | 2. bind()
        |                                      |
        |                                      | 3. listen()
        |                                      |
   2. connect() ------------------------------> 4. accept()
        |                                      | (返回新socket)
        |<-------------------------------------|
   5. send() / recv() <---------------------> 5. send() / recv()
        |                                      |
   6. close() -------------------------------> 6. close()
        |                                      |

核心 API 函数详解

所有 Socket 函数的头文件是 <sys/socket.h>,网络地址相关的头文件是 <netinet/in.h>,字符串转换函数的头文件是 <arpa/inet.h>

服务器端 API

  1. int socket(int domain, int type, int protocol);

    • domain: 地址域。AF_INET 用于 IPv4,AF_INET6 用于 IPv6。
    • type: 套接字类型。SOCK_STREAM 用于 TCP,SOCK_DGRAM 用于 UDP。
    • protocol: 协议,通常设为 0,系统会自动选择 type 对应的协议。
    • 返回值:成功返回一个新的套接字文件描述符(一个非负整数),失败返回 -1。
  2. int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

    • sockfd: socket() 返回的套接字描述符。
    • addr: 指向 sockaddr 结构体的指针,包含了要绑定的 IP 地址和端口号。
    • addrlen: addr 结构体的大小。
    • 注意:我们通常使用 struct sockaddr_in (IPv4) 来设置地址,因为它更方便,但在 bind() 时,需要将其强制转换为 struct sockaddr * 类型。
    • 返回值:成功返回 0,失败返回 -1。
  3. int listen(int sockfd, int backlog);

    • sockfd: 已绑定地址的套接字描述符。
    • backlog: 请求队列的最大长度,表示在调用 accept() 之前,系统可以缓存多少个待处理的连接请求。
    • 返回值:成功返回 0,失败返回 -1。
  4. int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

    • sockfd: 处于监听状态的套接字描述符。
    • addr: 一个 struct sockaddr 指针,用于保存客户端的地址信息,如果不需要,可以设为 NULL
    • addrlen: 一个指向 socklen_t 类型的指针,用于传入 addr 的大小,并在返回时保存实际写入的地址大小。
    • 返回值:成功返回一个新的套接字描述符,专门用于与这个已连接的客户端通信,失败返回 -1。

客户端 API

  1. int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
    • sockfd: 客户端创建的套接字描述符。
    • addr: 指向服务器 sockaddr 结构体的指针,包含了服务器的 IP 地址和端口号。
    • addrlen: addr 结构体的大小。
    • 返回值:成功返回 0,失败返回 -1。

通用 API

  1. ssize_t send(int sockfd, const void *buf, size_t len, int flags);

    • sockfd: 已连接的套接字描述符(服务器端是 accept() 返回的,客户端是 connect() 使用的)。
    • buf: 要发送数据的缓冲区。
    • len: 要发送数据的字节数。
    • flags: 通常设为 0。
    • 返回值:成功返回实际发送的字节数,失败返回 -1。
  2. ssize_t recv(int sockfd, void *buf, size_t len, int flags);

    • sockfd: 已连接的套接字描述符。
    • buf: 用于接收数据的缓冲区。
    • len: 缓冲区的大小。
    • flags: 通常设为 0。
    • 返回值:成功返回实际接收到的字节数,如果连接对端已关闭,返回 0,失败返回 -1。
  3. int close(int sockfd);

    • 关闭套接字,释放相关资源。
    • 返回值:成功返回 0,失败返回 -1。

完整代码示例:回显服务器

这个例子中,服务器会接收客户端发来的任何消息,然后原封不动地发送回去。

服务器端代码 (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. 创建 socket 文件描述符
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }
    // 2. 设置 socket 选项,允许地址重用
    // 防止 "Address already in use" 错误
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }
    // 3. 绑定地址和端口
    address.sin_family = AF_INET; // IPv4
    address.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口
    address.sin_port = htons(PORT); // 将端口号从主机字节序转换为网络字节序
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }
    // 4. 开始监听
    if (listen(server_fd, 3) < 0) {
        perror("listen");
        exit(EXIT_FAILURE);
    }
    printf("Server listening on port %d...\n", PORT);
    // 5. 接受客户端连接
    if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
        perror("accept");
        exit(EXIT_FAILURE);
    }
    printf("Client connected from %s:%d\n", inet_ntoa(address.sin_addr), ntohs(address.sin_port));
    // 6. 循环收发数据
    while (1) {
        // 接收客户端数据
        int valread = recv(new_socket, buffer, BUFFER_SIZE, 0);
        if (valread == 0) {
            // recv 返回 0 表示客户端已关闭连接
            printf("Client disconnected.\n");
            break;
        } else if (valread < 0) {
            perror("recv");
            break;
        }
        printf("Received: %s", buffer);
        // 将接收到的数据回显给客户端
        send(new_socket, buffer, valread, 0);
        printf("Echoed back to client.\n");
        // 清空缓冲区
        memset(buffer, 0, BUFFER_SIZE);
    }
    // 7. 关闭套接字
    close(new_socket);
    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>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
    int sock = 0;
    struct sockaddr_in serv_addr;
    char *hello = "Hello from client";
    char buffer[BUFFER_SIZE] = {0};
    // 1. 创建 socket
    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);
    // 将 IPv4 地址从文本转换为二进制形式
    if (inet_pton(AF_INET, "127.0.0.1", &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.\n");
    // 3. 发送数据
    send(sock, hello, strlen(hello), 0);
    printf("Hello message sent\n");
    // 4. 接收服务器回显
    int valread = read(sock, buffer, BUFFER_SIZE);
    printf("%s\n", buffer);
    // 5. 关闭套接字
    close(sock);
    return 0;
}

如何编译和运行

  1. 保存代码:将上面的代码分别保存为 server.cclient.c

  2. 编译:打开终端,使用 gcc 进行编译,需要链接 pthread 库(虽然这个例子没用,但服务器编程常需要,加上也无妨)。

    # 编译服务器
    gcc server.c -o server
    # 编译客户端
    gcc client.c -o client
  3. 运行

    • 第一步:在一个终端窗口中运行服务器,它会启动并等待连接。
      ./server
      # 输出: Server listening on port 8080...
    • 第二步:在另一个终端窗口中运行客户端,它会连接到服务器。
      ./client
      # 客户端输出:
      # Connected to server.
      # Hello message sent
      # Hello from client
    • 第三步:观察服务器的终端窗口,它会显示接收和回显的消息。
      # 服务器端输出:
      # Server listening on port 8080...
      # Client connected from 127.0.0.1:xxxx
      # Received: Hello from client
      # Echoed back to client.

关键注意事项

字节序

  • 主机字节序:CPU 存储多字节数据的顺序,Intel x86 架构是小端序(低位字节在前)。
  • 网络字节序:网络数据传输的标准顺序,是大端序(高位字节在前)。

在进行网络编程时,所有涉及到的端口号和 IP 地址都需要在发送前从主机字节序转换为网络字节序,在接收后再转换回来。

  • htons(): host to short (16位)
  • htonl(): host to long (32位)
  • ntohs(): network to short
  • ntohl(): network to long

地址复用

服务器程序在重启时,常常会提示 "Address already in use",这是因为操作系统会保持一段时间(TIME_WAIT 状态)的端口占用,使用 setsockopt() 设置 SO_REUSEADDR 选项可以立即重用该地址,避免这个错误。

错误处理

始终检查所有 Socket 函数的返回值! 它们可能会失败,使用 perror() 函数可以打印出有意义的错误信息,方便调试。

阻塞与非阻塞 I/O

默认情况下,socket() 创建的套接字是阻塞的。

  • 阻塞accept() 会一直等待,直到有客户端连接;recv() 会一直等待,直到有数据到达,这会使程序在等待时卡住。
  • 非阻塞:调用会立即返回,如果没有数据或连接,会返回一个错误(如 EWOULDBLOCKEAGAIN),非阻塞 I/O 通常与 I/O 多路复用技术(如 select, poll, epoll)结合使用,以实现高性能的服务器,能够同时处理成千上万的连接。

对于初学者,从阻塞式 I/O 开始是最简单和最容易理解的。


使用 C 语言进行 TCP Socket 通信是网络编程的基础,记住服务器和客户端的核心流程,熟练掌握 socket, bind, listen, accept, connect, send, recv, close 这几个关键函数,并注意字节序和错误处理,你就可以编写出基本的网络应用程序了。

这个回显服务器是学习网络编程的 "Hello, World!",理解了它,你就可以在此基础上构建更复杂的应用,如文件传输、聊天室、HTTP 服务器等。

-- 展开阅读全文 --
头像
为何需登录才能下载资料?
« 上一篇 01-23
dede栏目调用如何获取当前栏目ID?
下一篇 » 01-23

相关文章

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

目录[+]