Linux 下基于 TCP 的 C 语言客户端/服务器通信详解(三个示例逐步进阶)

Linux 下基于 TCP 的 C 语言客户端/服务器通信详解(三个示例逐步进阶)

在 Linux 下,基于 TCP 协议 的客户端/服务器通信可以通过 socket API 来实现。

很多初学者觉得"socket"晦涩又难懂,其实它就是一个"文件描述符",和 open/read/write/close 一样,可以进行读写,只不过这次数据流是通过网络传输的。

本文通过 三个示例,从最简单的单向通信到双向循环通信,逐步带你理解 TCP 编程的完整流程:

  1. 客户端发送一次消息到服务器(单向通信)
  2. 客户端键盘循环发送消息到服务器
  3. 客户端和服务器双向键盘循环通信(多线程收发分离)

一、客户端发送一次消息到服务器(单向通信)

这是最简单的 TCP 应用场景:客户端发一句话,服务器接收并打印。

可以理解为:打电话 → 说一句话 → 挂电话

客户端代码

c 复制代码
/*
客户端流程:
1. socket()    创建套接字
2. connect()   连接服务器
3. send()      发送数据
4. close()     关闭套接字
*/

#include "net.h"

int main(int argc, char const *argv[])
{
    int fd;
    int ret;

    // 1. 买电话:创建套接字
    // AF_INET  -> 使用 IPv4 协议族
    // SOCK_STREAM -> 使用面向连接的 TCP
    // 0 -> 协议自动选择(一般就是 TCP)
    fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1)
    {
        perror("socket"); // 打印错误原因
        exit(1);          // 创建失败直接退出
    }

    /*
     2. 绑卡(客户端通常省略)
     bind() 是给 socket 强行指定"本机的 IP/端口"。
     - 一般情况下,客户端只需要关心对方(服务器)的 IP/端口;
       自己的 IP/端口由操作系统自动分配即可。
     - 如果客户端调用 bind(),可能会因为端口占用/冲突而导致 connect() 失败。
     所以常规客户端 **直接跳过 bind**。
    */

    // 3. 打电话:connect 连接到服务器
    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr("192.168.107.146"); // 服务器 IP
    server.sin_port = htons(10001); // 服务器端口(主机字节序 → 网络字节序)

    ret = connect(fd, (struct sockaddr *)&server, sizeof(server));
    if (ret == -1)
    {
        perror("connect");
        exit(1);
    }

    // 4. 发数据
    char buf[128] = "hello,world";
    ret = send(fd, buf, strlen(buf), 0);
    /*
     send 参数说明:
     - 第二个参数:要发送的数据
     - 第三个参数:要发送的字节数

     注意这里传 strlen(buf):
     - strlen(buf) 表示字符串的实际有效长度(不包含 '\0')。
     - 如果传 sizeof(buf)=128,就会把数组后面的 '\0' 也发过去,
       导致浪费带宽,甚至可能让对方收到一堆没用的空字符。

     总结:
       - 发数据时 → strlen(告诉内核"我实际要发多少")
       - 收数据时 → sizeof(告诉内核"我最多能装多少")
    */
    if (ret == -1)
    {
        perror("send");
    }

    // 5. 挂电话:释放套接字
    close(fd);

    return 0;
}

服务端代码

c 复制代码
/*
服务端流程:
1. socket()    创建套接字
2. bind()      绑定 IP+端口
3. listen()    监听端口
4. accept()    等待客户端连接
5. recv()      接收数据
6. close()     关闭套接字
*/

#include "net.h"

int main(int argc, char const *argv[])
{
    int fd;
    int ret;
    int new_fd;

    // 1. 买电话:创建套接字
    fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1)
    {
        perror("socket");
        exit(1);
    }

    // 防止端口被占用:
    // 如果服务器异常退出,内核会在几分钟内保留端口(TIME_WAIT 状态),
    // 重新 bind() 会报"Address already in use"。
    // 所以要设置 SO_REUSEADDR,允许端口快速复用。
    // 客户端用处不大,一般是服务器才需要
    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 2. 绑卡:指定服务器的 IP 和端口
    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr("192.168.107.146"); // 服务器 IP
    server.sin_port = htons(10001); // 服务器端口

    ret = bind(fd, (struct sockaddr *)&server, sizeof(server));
    if (ret == -1)
    {
        perror("bind");
        exit(1);
    }

    // 3. 监听:进入"接听模式"
    // backlog=5 表示内核允许排队的最大连接数为 5
    listen(fd, 5);
    printf("tcp server have started...\n");

    // 4. 接听:等待客户端呼入
    struct sockaddr_in client;
    socklen_t len = sizeof(client); // socklen_t 通常等于 unsigned int,(u32)

    new_fd = accept(fd, (struct sockaddr *)&client, &len);
    if (new_fd == -1)
    {
        perror("accept");
        exit(1);
    }

    // 打印客户端信息
    printf("client connected: %s:%d\n",
           inet_ntoa(client.sin_addr), // IP 地址转字符串
           ntohs(client.sin_port));   // 端口转主机字节序

    // 5. 收数据
    char buf[128] = {0};
    ret = recv(new_fd, buf, sizeof(buf), 0);
    /*
     recv 参数说明:
     - 第二个参数:缓冲区
     - 第三个参数:最大能接收的字节数

     这里必须用 sizeof(buf),因为缓冲区还没有内容,
     没法用 strlen 来判断"能接收多少"。
     (strlen 只能用在已有字符串的场景)

     总结:
       - 收数据时 → sizeof("最多能装多少")
       - 发数据时 → strlen("实际要发多少")
    */
    if (ret <= 0)
    {
        perror("recv");
        close(new_fd);
        close(fd);
        return -1;
    }
    else
    {
        printf("receive from cli: %s\n", buf);
    }

    // 6. 挂电话
    // 先关通信套接字 new_fd,再关监听套接字 fd
    // 因为 监听套接字 fd 负责"接电话",通信套接字 new_fd 负责"通话"
    // 先关 new_fd 表示先结束这次通话,再关 fd 表示整个电话服务不再提供
    // 如果反过来,先关 fd,监听功能就没了,但当前通话还在,逻辑上不合理
    close(new_fd);
    close(fd);

    return 0;
}

知识点:

  • 客户端 通常不需要 bind(),操作系统会自动分配端口。
  • 发送 时用 strlen()接收 时用 sizeof()
  • 服务端记得设置 SO_REUSEADDR,避免异常退出后端口占用。

二、客户端键盘循环发送消息到服务器

上一个例子只能发一次消息,不够灵活。

在实际聊天场景中,我们需要循环发送数据。这里加入 循环+fgets 实现多次通信。

客户端代码

c 复制代码
/*
1. 买电话(创建套接字)            fd = socket()
2. 绑卡(可选,一般不需要)         bind()
3. 打电话(连接服务器)            connect()
4. 通话(循环发送数据)            send()
5. 挂电话(释放套接字)            close()

本程序:客户端从键盘输入字符串,循环发送给服务器,直到输入 "quit"。
*/

#include "net.h"

int main(int argc, char *argv[])
{
    int fd;  // 客户端套接字
    int ret; // 系统调用返回值

    // 1. 买电话:创建 TCP 套接字
    fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1)
    {
        perror("socket"); // 创建失败,打印错误原因
        exit(1);
    }

    // 允许重用端口(防止上次异常退出端口未释放)
    int on = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

    /*
        2. 绑卡(客户端通常不写)
        bind() 用于指定本机 IP/端口。
        客户端不必写,操作系统会自动分配可用端口。
        写了 bind 可能与服务器端冲突,导致 connect 失败。
    */

    // 3. 打电话:连接服务器
    struct sockaddr_in server;
    server.sin_family = AF_INET;                // IPv4 协议族
    server.sin_addr.s_addr = inet_addr("192.168.107.146"); // 服务器 IP
    server.sin_port = htons(10001);            // 服务器端口(主机字节序→网络字节序)

    ret = connect(fd, (struct sockaddr *)&server, sizeof(server));
    if (ret == -1)
    {
        perror("connect"); // 连接失败
        exit(1);
    }

    // 4. 通话:循环发送数据
    char buf[50] = {0};
    while (1)
    {
        memset(buf, 0, 50); // 清空缓冲区,保证上次残留数据不会影响本次发送
        fgets(buf, 50, stdin); // 从键盘读取字符串

        // send 参数:
        // buf      -> 发送内容
        // strlen(buf) -> 实际要发的字节数(不含多余 '\0')
        ret = send(fd, buf, strlen(buf), 0);
        if (ret == -1)
        {
            perror("send");
        }
        printf("send %d bytes\n", ret);

        // 输入 quit 即退出循环
        if (strncmp(buf, "quit\n", 5) == 0)
            break;
    }

    // 5. 挂电话:关闭套接字
    close(fd);

    return 0;
}

服务端代码

c 复制代码
/*
1. 买电话(创建套接字)          fd = socket()
2. 绑卡(绑定服务器 IP/端口)     bind()
3. 开机监听(监听端口)          listen()
4. 等待来电(接受连接)          accept()
5. 通话(循环接收数据)          recv()
6. 挂电话(关闭套接字)          close()

本程序:服务端接收客户端发送的字符串并打印,直到客户端发送 "quit" 或断开。
*/

#include "net.h"

int main(int argc, char *argv[])
{
    int fd;       // 监听套接字
    int ret;      // 系统调用返回值
    int new_fd;   // 通信套接字(每个客户端对应一个 new_fd)

    // 1. 买电话:创建 TCP 套接字
    fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1)
    {
        perror("socket");
        exit(1);
    }

    // 防止端口被占用(上次异常退出可能导致 TIME_WAIT)
    int on = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

    // 2. 绑卡:指定服务器 IP 和端口
    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr("192.168.107.146"); // 本机 IP
    server.sin_port = htons(10001);                         // 端口

    ret = bind(fd, (struct sockaddr *)&server, sizeof(server));
    if (ret == -1)
    {
        perror("bind");
        exit(1);
    }

    // 3. 监听:进入监听模式
    // backlog = 5 表示内核排队的最大连接数
    listen(fd, 5);
    printf("tcp server have started...\n");

    // 4. 接听:等待客户端连接
    struct sockaddr_in client;
    socklen_t len = sizeof(client);

    new_fd = accept(fd, (struct sockaddr *)&client, &len);
    if (new_fd == -1)
    {
        perror("accept");
        exit(1);
    }

    // 打印客户端信息
    printf("client connected:\nIP = %s\nPort = %d\n",
           inet_ntoa(client.sin_addr),
           ntohs(client.sin_port));

    // 5. 通话:循环接收客户端数据
    char buf[50] = {0};
    while (1)
    {
        memset(buf, 0, 50); // 清空缓冲区
        ret = recv(new_fd, buf, sizeof(buf), 0);

        // recv 参数:
        // buf      -> 接收缓冲区
        // sizeof(buf) -> 最大能接收的字节数
        printf("recv %d bytes: %s", ret, buf);

        // 客户端输入 quit 则退出
        if (strncmp(buf, "quit\n", 5) == 0)
        {
            printf("client quit\n");
            break;
        }

        // 如果 recv 返回 0,表示客户端已经断开连接
        if (ret == 0)
        {
            printf("client offline\n");
            break;
        }
    }

    // 6. 挂电话
    // 先关闭通信套接字 new_fd,再关闭监听套接字 fd
    close(new_fd);
    close(fd);

    return 0;
}

知识点:

  • 客户端输入 "quit" → 服务端退出
  • memset 保证缓冲区干净,避免上一次数据残留。
  • recv() 返回 0 表示客户端关闭连接。

三、客户端和服务器双向键盘循环通信(多线程)

到这里,如果要实现"真正的聊天",必须支持 收发同时进行

单线程只能阻塞在 send()recv(),所以需要 多线程分离收发

客户端代码

c 复制代码
/*
1.买电话				fd=socket()
2.绑卡				bind()
3.打电话				connect()
4.收发数据			send/recv()
5.挂电话				close()

客户端键盘循环发送一个字符串给服务器端接收并打印
服务端键盘循环发送一个字符串给客户端接收并打印
*/

#include "net.h" // 包含网络相关的头文件,如 <sys/socket.h>, <arpa/inet.h> 等

int fd; // 全局变量,存储客户端的 socket 文件描述符

// 接收消息的线程函数,负责从服务器接收数据并打印
void *recv_mess(void *arg)
{
    int ret;                        // 存储接收操作的返回值
    char buf[50] = {0};             // 接收数据的缓冲区,初始化为 0
    pthread_detach(pthread_self()); // 设置线程为分离状态,线程结束时自动回收资源
    while (1)                       // 循环接收消息
    {
        bzero(buf, 50);                      // 清空缓冲区
        ret = recv(fd, buf, sizeof(buf), 0); // 从 socket 接收数据
        printf("recv srv %d: %s", ret, buf); // 打印接收到的数据和字节数
        if (strncmp(buf, "quit\n", 5) == 0)  // 如果接收到 "quit" 字符串
        {
            printf("srv quit\n"); // 打印服务器退出信息
            break;                // 退出循环
        }
        if (ret == 0) // 如果返回值是 0,表示服务器断开连接
        {
            printf("srv offline\n"); // 打印服务器下线信息
            break;                   // 退出循环
        }
    }
    return NULL; // 线程函数返回,因为用了pthread_detach,所以这句话可以省略
}

int main(int argc, char *argv[])
{
    int ret; // 存储函数调用的返回值

    // 1. 创建 socket(买电话)
    // AF_INET 表示使用 IPv4,SOCK_STREAM 表示 TCP 协议
    fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1) // 如果创建 socket 失败
    {
        perror("socket"); // 打印错误信息
        exit(1);          // 退出程序
    }

    // 2. 绑定地址(绑卡)
    // 作为客户端,不需要绑定地址

    // 设置 socket 选项,允许重用端口
    int on = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); // 防止端口被占用

    // 3. 连接服务器(打电话)
    struct sockaddr_in server;                                     // 定义服务器地址结构
    server.sin_family = AF_INET;                                   // 设置地址族为 IPv4
    server.sin_addr.s_addr = inet_addr("192.168.107.146");         // 服务器 IP 地址
    server.sin_port = htons(10001);                                // 服务器端口号(网络字节序)
    ret = connect(fd, (struct sockaddr *)&server, sizeof(server)); // 连接到服务器
    if (ret == -1)                                                 // 如果连接失败
    {
        perror("connect"); // 打印错误信息
        exit(1);           // 退出程序
    }

    // 创建接收消息的线程
    pthread_t tid;                               // 线程 ID
    pthread_create(&tid, NULL, recv_mess, NULL); // 创建线程,运行 recv_mess 函数

    // 4. 发送数据(收发数据)
    char buf[50]; // 发送数据的缓冲区
    while (1)     // 循环发送数据
    {
        bzero(buf, 50);                      // 清空缓冲区
        fgets(buf, 50, stdin);               // 从标准输入(键盘)读取一行数据
        ret = send(fd, buf, strlen(buf), 0); // 发送数据到服务器
        printf("send %d\n", ret);            // 打印发送的字节数
        if (strncmp(buf, "quit\n", 5) == 0)  // 如果输入 "quit"
            break;                           // 退出循环
    }

    // 5. 关闭 socket(挂电话)
    close(fd); // 关闭 socket 连接
    return 0;  // 程序正常退出
}

服务端代码(双向循环)

c 复制代码
/*
1.买电话				fd=socket()
2.绑卡					bind()
3.监听					listen()
4.接听					accept()
5.收发数据				send/recv
6.挂电话				close()

客户端键盘循环发送一个字符串给服务器端接收并打印
服务端键盘循环发送一个字符串给客户端接收并打印
*/

#include "net.h" // 包含网络相关的头文件,如 <sys/socket.h>, <arpa/inet.h> 等

// 发送消息的线程函数,负责从键盘读取数据并发送给客户端
void *send_mess(void *arg)
{
    int ret; // 存储发送操作的返回值
    int newfd = *((int *)arg);
    // arg 是一个 void * 类型的指针,*((int *)arg) 的作用是将这个指针转换为 int 指针并解引用
    // 这样可以获取其指向的整数值(在这里是客户端连接的文件描述符 newfd)

    char buf[50];                   // 发送数据的缓冲区
    pthread_detach(pthread_self()); // 设置线程为分离状态,线程结束时自动回收资源
    while (1)                       // 循环发送数据
    {
        bzero(buf, 50);                         // 清空缓冲区
        fgets(buf, 50, stdin);                  // 从标准输入(键盘)读取一行数据
        ret = send(newfd, buf, strlen(buf), 0); // 发送数据到客户端
        printf("send %d\n", ret);               // 打印发送的字节数
        if (strncmp(buf, "quit\n", 5) == 0)     // 如果输入 "quit"
            break;                              // 退出循环
    }
    return NULL; // 线程函数返回,因为用了pthread_detach,所以这句话可以省略
}

int main(int argc, char *argv[])
{
    int fd;  // 服务器监听 socket 的文件描述符
    int ret; // 存储函数调用的返回值

    // 1. 创建 socket(买电话)
    // AF_INET 表示使用 IPv4,SOCK_STREAM 表示 TCP 协议
    fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1) // 如果创建 socket 失败
    {
        perror("socket"); // 打印错误信息
        exit(1);          // 退出程序
    }
    int on = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); // 设置 socket 选项,允许重用端口

    // 2. 绑定地址(绑卡)
    struct sockaddr_in server;                                  // 定义服务器地址结构
    server.sin_family = AF_INET;                                // 设置地址族为 IPv4
    server.sin_addr.s_addr = inet_addr("192.168.107.164");      // 服务器 IP 地址
    server.sin_port = htons(10001);                             // 服务器端口号(网络字节序)
    ret = bind(fd, (struct sockaddr *)&server, sizeof(server)); // 绑定 socket 和地址
    if (ret == -1)                                              // 如果绑定失败
    {
        perror("bind"); // 打印错误信息
        exit(1);        // 退出程序
    }

    // 3. 监听连接(监听)
    listen(fd, 5);                          // 设置 socket 为监听状态,最大排队连接数为 5
    printf("tcp server have started...\n"); // 打印服务器启动信息

    // 4. 接受客户端连接(接听)
    struct sockaddr_in client;                                // 客户端地址结构
    socklen_t len = sizeof(client);                           // 客户端地址结构的大小
    int newfd = accept(fd, (struct sockaddr *)&client, &len); // 接受客户端连接
    if (newfd == -1)                                          // 如果接受连接失败
    {
        perror("accept"); // 打印错误信息
        exit(1);          // 退出程序
    }
    printf("ip=%s\n", inet_ntoa(client.sin_addr)); // 打印客户端 IP 地址
    printf("port=%d\n", ntohs(client.sin_port));   // 打印客户端端口号
    system("netstat -na | grep 10001");            // 显示当前网络连接状态(调试用)

    // 创建发送消息的线程
    pthread_t tid;                                 // 线程 ID
    pthread_create(&tid, NULL, send_mess, &newfd); // 创建线程,运行 send_mess 函数

    // 5. 接收数据(收发数据)
    char buf[50] = {0}; // 接收数据的缓冲区,初始化为 0
    while (1)           // 循环接收数据
    {
        bzero(buf, 50);                         // 清空缓冲区
        ret = recv(newfd, buf, sizeof(buf), 0); // 从客户端接收数据
        printf("recv cli %d: %s", ret, buf);    // 打印接收到的数据和字节数
        if (strncmp(buf, "quit\n", 5) == 0)     // 如果接收到 "quit"
        {
            printf("client quit\n"); // 打印客户端退出信息
            break;                   // 退出循环
        }
        if (ret == 0) // 如果返回值是 0,表示客户端断开连接
        {
            printf("client offline\n"); // 打印客户端下线信息
            break;                      // 退出循环
        }
    }

    // 6. 关闭 socket(挂电话)
    close(newfd); // 关闭与客户端的连接
    close(fd);    // 关闭服务器监听 socket
    return 0;     // 程序正常退出
}

知识点:

  • 双向通信需要多线程(一个线程收,一个线程发)。
  • pthread_detach 避免线程资源泄漏。
  • 输入 "quit" 即可退出循环。

总结

  • 单向通信:客户端发一次 → 服务器收一次。

  • 单向循环:客户端可多次发送,服务器循环接收。

  • 双向循环:客户端 & 服务端都能收发 → 需多线程。

  • 编程技巧

    • strlen() 用于发送数据长度
    • sizeof() 用于接收缓冲区大小
    • setsockopt(SO_REUSEADDR) 避免端口占用
    • pthread_detach() 防止线程僵死

通过这三步,就能写出一个简易 Linux C 版聊天室

后续可以扩展成 支持多个客户端 (用 forkepoll 实现)。


(完)

相关推荐
七七&5561 小时前
2024年08月13日 Go生态洞察:Go 1.23 发布与全面深度解读
开发语言·网络·golang
元清加油1 小时前
【Golang】:函数和包
服务器·开发语言·网络·后端·网络协议·golang
No0d1es2 小时前
电子学会青少年软件编程(C/C++)5级等级考试真题试卷(2024年6月)
c语言·c++·算法·青少年编程·电子学会·五级
向日葵.3 小时前
fastdds.ignore_local_endpoints 属性
服务器·网络·php
athink_cn5 小时前
HTTP/2新型漏洞“MadeYouReset“曝光:可发动大规模DoS攻击
网络·网络协议·安全·http·网络安全
昵称为空C5 小时前
SpringBoot接口限流的常用方案
服务器·spring boot
zzc9216 小时前
TLSv1.2协议与TCP/UDP协议传输数据内容差异
网络·测试工具·安全·wireshark·ssl·密钥·tlsv1.2
wxy3197 小时前
嵌入式LINUX——————TCP并发服务器
java·linux·网络
蒋星熠7 小时前
C++零拷贝网络编程实战:从理论到生产环境的性能优化之路
网络·c++·人工智能·深度学习·性能优化·系统架构