从零开始:C++ TCP 服务器实战教程

文章目录

  • 引言
  • [1. Server 端:TCP 服务器核心实现](#1. Server 端:TCP 服务器核心实现)
    • [1.1 TcpServer 类的结构](#1.1 TcpServer 类的结构)
    • [1.2 TcpServer 的接口实现](#1.2 TcpServer 的接口实现)
      • [1.2.1 构造函数和析构函数](#1.2.1 构造函数和析构函数)
      • [1.2.2 Init 函数:初始化核心流程](#1.2.2 Init 函数:初始化核心流程)
        • [步骤 1:创建 TCP 套接字](#步骤 1:创建 TCP 套接字)
        • [步骤 2:绑定地址(bind)](#步骤 2:绑定地址(bind))
        • [步骤 3:监听连接(listen)](#步骤 3:监听连接(listen))
        • [Init 函数完整实现:](#Init 函数完整实现:)
      • [1.2.3 Start 函数:启动服务器并处理通信](#1.2.3 Start 函数:启动服务器并处理通信)
        • [步骤 1:接收连接(accept)](#步骤 1:接收连接(accept))
        • [步骤 2:与客户端收发数据(recv/send)](#步骤 2:与客户端收发数据(recv/send))
        • [Start 函数完整实现(基础版:单客户端处理):](#Start 函数完整实现(基础版:单客户端处理):)
      • [1.2.4 自定义数据处理示例](#1.2.4 自定义数据处理示例)
    • [1.3 TcpServer.cc:服务器入口](#1.3 TcpServer.cc:服务器入口)
  • [2. Client 端:TCP 客户端实现](#2. Client 端:TCP 客户端实现)
    • [2.1 客户端核心流程](#2.1 客户端核心流程)
    • [2.2 TcpClient.cc:客户端完整代码](#2.2 TcpClient.cc:客户端完整代码)
  • [3. 编译与运行示例](#3. 编译与运行示例)
    • [3.1 Makefile](#3.1 Makefile)
    • [3.2 运行步骤](#3.2 运行步骤)
  • [4. 可扩展的地方](#4. 可扩展的地方)
    • [4.1 多客户端并发处理](#4.1 多客户端并发处理)
    • [4.2 解决 TCP 粘包问题](#4.2 解决 TCP 粘包问题)
    • [4.3 心跳机制](#4.3 心跳机制)
    • [4.4 线程池优化](#4.4 线程池优化)
  • [5. 总结](#5. 总结)

引言

在网络编程中,TCP(Transmission Control Protocol,传输控制协议)是一种面向连接、可靠的字节流协议。与 UDP 的 "无连接、轻量但不可靠" 不同,TCP 通过三次握手建立连接、四次挥手关闭连接,提供了数据的有序传输、重传机制、流量控制和拥塞控制 ------ 这些特性让 TCP 成为对可靠性要求较高的场景(如文件传输、HTTP/HTTPS 通信、即时通讯)的核心协议。

本教程将以 C++ 为载体,从零实现一个基础 TCP 服务器与客户端,完整覆盖 TCP 通信的核心流程(创建套接字、绑定地址、监听连接、建立通信、收发数据),并深入解析关键接口的用法与原理。无论你是网络编程新手,还是希望巩固 TCP 基础的开发者,都能通过本教程掌握实战技能,并了解后续扩展方向(如多客户端并发、粘包解决)。

1. Server 端:TCP 服务器核心实现

TCP 服务器的核心流程比 UDP 更复杂,需经历 "创建监听套接字→绑定地址→监听连接→接收连接→与客户端通信" 五个关键步骤。我们将通过TcpServer类封装这些逻辑,保证代码的模块化与可扩展性。

1.1 TcpServer 类的结构

首先定义TcpServer类的私有成员变量,明确核心资源与状态:

cpp 复制代码
class TcpServer {
public:
    TcpServer(uint16_t port, func_t handler);
    ~TcpServer();
    bool Init();    // 初始化:创建套接字、绑定、监听
    void Start();   // 启动:循环接收连接,并进行数据的收发和处理
    void Stop();    // 停止服务器
private:
    int _listen_fd;         // 监听套接字
    uint16_t _listen_port;  // 服务器监听端口
    bool _is_running;       // 服务器运行状态标志
    func_t _data_handler;   // 数据处理回调函数
};

私有成员变量解析:

  • _listen_fd:TCP 特有的 "监听套接字",作用是监听指定端口的连接请求(因为 TCP 是面向连接的,所以需要通过监听套接字专门去监听连接请求)。而具体数据的收发则是通过 accept 返回的服务套接字来完成。
  • _listen_port:服务器监听的端口,客户端需要通过该端口发起连接。
  • _is_running:服务器运行状态的标识
  • _data_handler:因为我是通过头源分离的方式来写的服务器,所以通过 std::functional 去包装数据处理回调函数,到时候在使用的时候可以直接在 main 函数中传入这么个回调函数,提高代码的可维护性。

1.2 TcpServer 的接口实现

服务器的接口其实就是初始化、启动和停止。

1.2.1 构造函数和析构函数

构造函数初始化端口和数据处理函数,析构函数确保资源释放:

cpp 复制代码
    TcpServer(uint16_t port, func_t handler)
        : _listen_fd(-1), _listen_port(port), _is_running(false), _data_handler(handler) {}
    ~TcpServer() {
        if (_listen_fd != -1) {
            close(_listen_fd);
            std::cout << "监听套接字已关闭" << std::endl;
        }
    }

1.2.2 Init 函数:初始化核心流程

Init 是服务器的 "启动准备",需要完成创建套接字绑定地址监听连接三个关键操作。

步骤 1:创建 TCP 套接字

TCP 使用的是 SOCK_STREAM (流式套接字),与 UDP 的 SOCK_DGRAM (数据报套接字)区分:

cpp 复制代码
// 创建套接字
int socket(int domain, int type, int protocol);
  • 参数
    • domainAF_INET(IPv4 协议);
    • typeSOCK_STREAM(TCP 流式套接字);
    • protocol0(默认协议,TCP)。
  • 返回值 :成功返回非负套接字描述符,失败返回 -1 并设置 errno
步骤 2:绑定地址(bind)

与 UDP 类似,需要将套接字与 "IP + 端口" 绑定,但是 TCP 绑定的是 "监听地址":

cpp 复制代码
// 绑定套接字到指定IP和端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 关键注意 :IP 通常设为 INADDR_ANY(监听所有网卡的指定端口,如服务器有多个网卡时无需指定具体 IP)。
步骤 3:监听连接(listen)

TCP 特有操作,将套接字转为 "监听状态",等待客户端连接:

cpp 复制代码
// 开始监听套接字,等待客户端连接
int listen(int sockfd, int backlog);
  • 参数 backlog:未完成连接队列(SYN 队列)的最大长度,通常设为 5~10(表示最多同时处理 5 个未完成连接)。
Init 函数完整实现:
cpp 复制代码
    bool Init() {
        // 1. 创建套接字
        _listen_fd = socket(AF_INET, SOCK_STREAM, 0);
        if (_listen_fd == -1) {
            perror("socket 创建失败!");
            return false;
        }        
        std::cout << "套接字创建成功,listen_fd: " << _listen_fd << std::endl;

        // 2. 填充服务器地址结构
        struct sockaddr_in server_addr;
        memset(&server_addr, 0, sizeof(server_addr));       // 清空内存避免随机值
        server_addr.sin_family = AF_INET;                   // IPV4 协议
        server_addr.sin_port = htons(_listen_port);           // 本地字节序 -> 网络字节序
        server_addr.sin_addr.s_addr = htonl(INADDR_ANY);    // 监听所有网卡

        // 3. 绑定套接字与地址
        int bind_ret = bind(_listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
        if (bind_ret == -1) {
            perror("绑定失败");
            close(_listen_fd);
            _listen_fd = -1;
            return false;
        }
        std::cout << "绑定成功,成功监听端口:" << _listen_port << std::endl;

        // 4. 开始监听连接
        int listen_ret = listen(_listen_fd, 5);    // backlog=5
        if (listen_ret == -1) {
            perror("listen 失败");
            close(_listen_fd);
            _listen_fd = -1;
            return false;
        }
        std::cout << "监听中,等待客户端连接..." << std::endl;

        _is_running = true;
        return true;
    }    

1.2.3 Start 函数:启动服务器并处理通信

Start 是服务器的主循环,核心是接收客户端连接(accept)→与客户端收发数据(recv/send)。

步骤 1:接收连接(accept)

TCP 特有操作,阻塞等待客户端连接,成功后返回 "通信套接字":

cpp 复制代码
// 接收客户端连接,返回与该客户端通信的套接字
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • 参数
    • sockfd:监听套接字(_listen_fd);
    • addr:输出参数,存储客户端的地址信息(IP + 端口);
    • addrlen:输入输出参数,地址结构的长度。
  • 返回值 :成功返回 "通信套接字"(与该客户端专用),失败返回 -1
步骤 2:与客户端收发数据(recv/send)

TCP 是面向连接的,一旦建立连接,后续收发无需指定客户端地址(与 UDP 的recvfrom/sendto不同):

cpp 复制代码
// 从通信套接字接收数据
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

// 向通信套接字发送数据
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
  • 参数 flags :通常设为 0(阻塞模式);
  • 返回值
    • recv:成功返回接收的字节数,0表示客户端关闭连接,-1表示错误;
    • send:成功返回发送的字节数,-1表示错误。
Start 函数完整实现(基础版:单客户端处理):
cpp 复制代码
    void Start() {
        if (!_is_running || _listen_fd == -1) {
            perror("服务器未初始化,无法启动");
            return;
        }

        // 主循环:持续接收客户端连接
        while (_is_running) {
            struct sockaddr_in client_addr; // 存储客户端地址
            socklen_t client_addr_len = sizeof(client_addr);

            // 1. 接收客户端连接(阻塞)
            int client_fd = accept(_listen_fd, (struct sockaddr*)&client_addr, &client_addr_len);
            if (client_fd == -1) {
                perror("accept 失败!");
                continue;   // 接收连接失败肯定不可能让整个服务器都退出啊,跳过该次循环就可以了
            }

            // 解析客户端地址(网络字节序 -> 本地字节序)
            std::string client_ip = inet_ntoa(client_addr.sin_addr);    // IP:网络字节序 -> 点分十进制
            uint16_t client_port = ntohs(client_addr.sin_port);         // 端口:网络字节序 -> 本地字节序
            std::cout << "\n客户端连接成功:[" << client_ip << ":" << client_port << "],client_fd: " << client_fd << std::endl;

            // 2. 与客户端通信(循环收发数据)
            char recv_buf[1024] = {0};  // 接收缓冲区
            while (true) {
                // 接收客户端数据
                ssize_t recv_len = recv(client_fd, recv_buf, sizeof(recv_buf)-1, 0);
                if (recv_len == -1) {
                    perror("recv 失败");
                    break;  // 接收错误,直接断开与该客户端的连接
                } else if (recv_len == 0) {
                    std::cout << "客户端[" << client_ip << ":" << client_port << "] 主动断开连接" << std::endl;
                    break;  // 客户端断开,退出通信循环
                }

                // 处理接收的数据(调用自定义回调函数)
                recv_buf[recv_len] = '\0';  // 添加字符串结束符
                std::cout << "收到[" << client_ip << ":" << client_port << "] 的数据:" << recv_buf << std::endl;
                std::string response = _data_handler(recv_buf); // 自定义处理

                // 向客户端发送响应
                ssize_t send_len = send(client_fd, response.c_str(), response.size(), 0);
                if (send_len == -1) {
                    perror("send 失败");
                    break;
                }
                std::cout << "向客户端发送响应:" << response << std::endl;

                memset(recv_buf, 0, sizeof(recv_buf));  // 清空缓冲区
            }

            // 3. 关闭与该客户端的通信套接字
            close(client_fd);
            std::cout << "与客户端[" << client_ip << ":" << client_port << "]的连接已关闭" << std::endl;
        }
    }

1.2.4 自定义数据处理示例

用户可通过 func_t 自定义数据处理逻辑,例如给客户端消息加前缀:

cpp 复制代码
// 示例:给客户端消息加"TCP Server Response: "前缀
std::string DefaultDataHandler(const std::string& client_data) {
    return "TCP Server Response: " + client_data;
}

1.3 TcpServer.cc:服务器入口

通过命令行参数传入监听端口,创建 TcpServer 对象并启动:

cpp 复制代码
#include <memory>
#include "TcpServer.hpp"

void Usage(std::string proc) {
    std::cerr << "Usage: " << proc << "port" << std::endl;
}

// 示例:给客户端消息加"TCP Server Response: "前缀
std::string DefaultDataHandler(const std::string& client_data) {
    return "TCP Server Response: " + client_data;
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        Usage(argv[2]);
        return 1;
    }

    // 解析端口号(字符串→整数)
    uint16_t listen_port = std::stoi(argv[1]);
    if (listen_port < 1024 || listen_port > 65535) {
        std::cerr << "端口号无效(需在1024~65535之间)" << std::endl;
        return 2;
    }

    // 创建TCP服务器对象(使用智能指针自动释放资源)
    std::unique_ptr<TcpServer> tcp_server = 
        std::make_unique<TcpServer>(listen_port, DefaultDataHandler);

    // 初始化并启动服务器
    if (!tcp_server->Init()) {
        std::cerr << "服务器初始化失败" << std::endl;
        return 3;
    }
    tcp_server->Start();

    return 0;
}

2. Client 端:TCP 客户端实现

TCP 客户端流程比服务器简单,核心是 "创建套接字→连接服务器(connect)→收发数据",无需绑定固定端口(系统自动分配临时端口)。

2.1 客户端核心流程

  1. 创建 TCP 套接字 :与服务器一致,使用 SOCK_STREAM
  2. 连接服务器(connect):TCP 特有操作,向服务器发起连接请求(三次握手);
  3. 收发数据 :使用 send/recv 与服务器通信;
  4. 关闭连接:通信结束后关闭套接字。

2.2 TcpClient.cc:客户端完整代码

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstdlib>

void Usage(std::string proc) {
    std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}

// 主函数:./tcpclient 服务器IP 服务器端口
int main(int argc, char* argv[]) {
    if (argc != 3) {
        Usage(argv[0]);
        return 1;
    }

    // 解析服务器地址和端口
    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    // 1. 创建 TCP 套接字
    int client_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (client_fd == -1) {
        perror("socket 创建失败");
        return 2;
    }
    std::cout << "客户端创建套接字成功,client_fd: " << client_fd << std::endl;

    // 2. 填充服务器地址结构
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(server_port); // 端口:主机→网络字节序
    // IP:字符串→网络字节序(inet_addr支持点分十进制IP)
    server_addr.sin_addr.s_addr = inet_addr(server_ip.c_str());

    // 3. 连接服务器(三次握手)
    int connect_ret = connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
    if (connect_ret == -1) {
        perror("connect 失败(请检查服务器IP和端口是否正确)");
        close(client_fd);
        return 3;
    }
    std::cout << "已成功连接到服务器[" << server_ip << ":" << server_port << "]" << std::endl;

    // 4. 循环收发数据
    std::string input_data;
    char recv_buf[1024] = {0};
    while (true) {
        std::cout << "\n请输入发送给服务器的数据(输入\"exit\"退出)";
        std::getline(std::cin, input_data);

        // 退出条件
        if (input_data == "exit") {
            std::cout << "客户端退出" << std::endl;
            break;
        }

        // 发送数据给服务器
        ssize_t send_len = send(client_fd, input_data.c_str(), input_data.size(), 0);
        if (send_len == -1) {
            perror("send 失败");
            break;
        }
        std::cout << "已发送数据" << input_data << "(字节数:" << send_len << ")" << std::endl;

        // 接收服务器响应
        memset(recv_buf, 0, sizeof(recv_buf));
        ssize_t recv_len = recv(client_fd, recv_buf, sizeof(recv_buf)-1, 0);
        if (recv_len == -1) {
            perror("recv 失败");
            break;
        } else if (recv_len == 0) {
            std::cout << "服务器已关闭连接" << std::endl;
            break;
        }

        // 打印服务器响应
        std::cout << "收到服务器响应:" << recv_buf << std::endl;
    }

    // 5. 关闭客户端套接字
    close(client_fd);
    std::cout << "客户端套接字已关闭,退出程序" << std::endl;

    return 0;
}

3. 编译与运行示例

3.1 Makefile

需要注意的一点是,我在管理服务器资源的时候,使用的是 make_unique 创建服务器实例,这是 C++14 的语法。

bash 复制代码
.PHONY:all
all:tcpserver tcpclient

tcpserver:TcpServer.cc
	g++ -o $@ $^ -std=c++14
tcpclient:TcpClient.cc
	g++ -o $@ $^ -std=c++14

.PHONY:clean
clean:
	rm -f tcpserver tcpclient

3.2 运行步骤

  1. 启动服务器 (监听 8888 端口):

    bash 复制代码
    ./tcpserver 8080

    服务器输出:

    bash 复制代码
    套接字创建成功,listen_fd: 3
    绑定成功,成功监听端口:8888
    监听中,等待客户端连接...
  2. 启动客户端 (连接服务器,127.0.0.1 为本地换回地址):

    bash 复制代码
    ./tcpclient 127.0.0.1 8888

    客户端输出:

    bash 复制代码
    客户端创建套接字成功,client_fd: 3
    已成功连接到服务器[127.0.0.1:8888]
    
    请输入发送给服务器的数据(输入"exit"退出)
  3. 测试通信
    客户端输入:Hello Zkp!
    客户端输出:

    bash 复制代码
    已发送数据Hello Zkp!(字节数:10)
    收到服务器响应:TCP Server Response: Hello Zkp!
    
    请输入发送给服务器的数据(输入"exit"退出)

    服务端输出:

    bash 复制代码
    客户端连接成功:[127.0.0.1:36164],client_fd: 4
    收到[127.0.0.1:36164] 的数据:Hello Zkp!
    向客户端发送响应:TCP Server Response: Hello Zkp!

4. 可扩展的地方

基础版 TCP 服务器仅支持单客户端(处理完一个客户端才能接收下一个),实际应用中需针对高并发、可靠性等需求扩展:

4.1 多客户端并发处理

  • 方案 1多线程 / 多进程 :每接收一个客户端连接(accept),创建一个线程 / 进程处理该客户端的通信,避免主循环阻塞。
    • 缺点:频繁创建线程 / 进程开销大,适合客户端数量较少的场景。
  • 方案 2IO 多路复用 :使用 select/poll/epoll(Linux)管理多个套接字(监听套接字 + 所有客户端通信套接字),单线程处理多客户端请求,适合高并发场景(如 thousands of clients)。

4.2 解决 TCP 粘包问题

TCP 是字节流协议,数据会被缓冲,可能出现 "粘包"(多个发送操作的数据被合并接收)。解决方式:

  • 定长数据包:约定每次发送固定长度的数据(如 1024 字节),不足补 0,接收端每次按固定长度读取。
  • 分隔符 :在数据末尾添加分隔符(如 \n),接收端按分隔符拆分数据。
  • 头部带长度:数据包分为 "头部(4 字节,存储数据长度)+ 数据",接收端先读头部,再按长度读数据。

4.3 心跳机制

TCP 连接可能因网络异常(如断网)变成 "死连接",需通过心跳包 检测:

服务器定期向客户端发送心跳包(如ping);

客户端收到心跳包后回复pong

若服务器多次未收到pong,则关闭该客户端连接。

4.4 线程池优化

针对多线程方案,使用线程池预先创建固定数量的线程,避免频繁创建 / 销毁线程的开销:

  • 主线程:accept客户端连接,将通信套接字加入任务队列;
    线程池中的工作线程:从任务队列获取套接字,处理通信逻辑。

5. 总结

本教程从零实现了 C++ TCP 服务器与客户端,核心知识点回顾:

  1. TCP 核心流程:服务器(创建→绑定→监听→accept→收发),客户端(创建→connect→收发);
  2. 关键接口socket(创建套接字)、bind(绑定)、listen(监听)、accept(接收连接)、connect(发起连接)、recv/send(收发数据);
  3. TCP 与 UDP 的核心区别:TCP 面向连接、可靠、字节流;UDP 无连接、不可靠、数据报;
  4. 扩展方向:多并发、粘包解决、心跳机制、线程池,这些是工业级 TCP 应用的必备特性。

通过本教程的代码实践,你可以掌握 TCP 通信的基础原理,并基于此扩展更复杂的网络应用(如文件传输服务器、简易聊天系统)。

相关推荐
老王熬夜敲代码4 小时前
C++继承回顾
c++·笔记
qq_310658514 小时前
webrtc代码走读(六)-QOS-FEC冗余度配置
网络·c++·webrtc
Aevget5 小时前
从复杂到高效:QtitanNavigation助力金融系统界面优化升级
c++·qt·金融·界面控件·ui开发
jf加菲猫5 小时前
条款20:对于类似std::shared_ptr但有可能空悬的指针使用std::weak_ptr
开发语言·c++
TG_yunshuguoji5 小时前
亚马逊云渠道商:本地SSD缓存如何保障数据安全?
运维·服务器·安全·云计算·aws
tft36405 小时前
An attempt was made to access a socket in a way forbidden by its access
服务器·网络·tcp/ip
jf加菲猫6 小时前
条款21:优先选用std::make_unique、std::make_shared,而非直接new
开发语言·c++
scx201310046 小时前
20251019状压DP总结
c++
着迷不白6 小时前
华为堡垒机
linux·运维·服务器·centos