【Linux网络】TCP 服务器怎么写?从 listen、accept 到 read/write,手写完整 Echo Server

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录

  • 前言
  • [一、TCP 与 UDP 的第一处区别](#一、TCP 与 UDP 的第一处区别)
  • [二、TCP 服务器需要哪些接口](#二、TCP 服务器需要哪些接口)
    • [2.1 `socket`:创建流式套接字](#2.1 socket:创建流式套接字)
    • [2.2 `bind`:绑定固定服务器端口](#2.2 bind:绑定固定服务器端口)
    • [2.3 `listen`:把 Socket 设置成监听状态](#2.3 listen:把 Socket 设置成监听状态)
    • [2.4 使用 `telnet` 验证监听状态](#2.4 使用 telnet 验证监听状态)
  • [三、`accept` 到底做了什么](#三、accept 到底做了什么)
    • [3.1 连接并不是由 `accept` 建立的](#3.1 连接并不是由 accept 建立的)
    • [3.2 三个参数的输入输出方向](#3.2 三个参数的输入输出方向)
    • [3.3 为什么返回一个新的文件描述符](#3.3 为什么返回一个新的文件描述符)
  • [四、TCP 字节流怎样读取](#四、TCP 字节流怎样读取)
    • [4.1 `read` 的三种返回情况](#4.1 read 的三种返回情况)
    • [4.2 `write` 不保证一次写完](#4.2 write 不保证一次写完)
  • [五、完整的单进程 Echo Server](#五、完整的单进程 Echo Server)
    • [5.1 工程结构](#5.1 工程结构)
    • [5.2 `Common.hpp`](#5.2 Common.hpp)
    • [5.3 `InetAddr.hpp`](#5.3 InetAddr.hpp)
    • [5.4 `TcpServer.hpp`](#5.4 TcpServer.hpp)
    • [5.5 `TcpServer.cc`](#5.5 TcpServer.cc)
  • [六、TCP 客户端](#六、TCP 客户端)
    • [6.1 `connect` 的目标是服务器地址](#6.1 connect 的目标是服务器地址)
    • [6.2 `TcpClient.cc`](#6.2 TcpClient.cc)
    • [6.3 Makefile 与运行](#6.3 Makefile 与运行)
  • 七、单进程版本的问题
  • 总结

前言

前面已经用 UDP 写过回显、字典和聊天室。UDP 创建好套接字以后,可以直接通过 sendto 和 recvfrom 收发数据;TCP 不一样,它是面向连接的字节流协议,正式通信前必须先建立连接。

因此,TCP 服务器比 UDP 多出两个关键步骤:

text 复制代码
socket -> bind -> listen -> accept -> read/write

客户端则是:

text 复制代码
socket -> connect -> read/write

本文先完成最基础的单进程 Echo Server。客户端发送什么,服务器就回显什么。代码虽然不复杂,但会把监听套接字、通信套接字、连接队列以及 accept 的输入输出参数全部串起来。


一、TCP 与 UDP 的第一处区别

UDP 是无连接、面向数据报的。应用程序创建 UDP Socket,填写目标地址以后就可以发送数据,每次发送都有自己的数据报边界。

TCP 是面向连接、面向字节流的。服务器不能创建 Socket 后立刻调用 read 等待任意客户端数据,而要先把 Socket 设置成监听状态,等待客户端发起连接。连接建立以后,服务器再取得一个专门与该客户端通信的文件描述符。

现阶段可以先把 TCP 服务端理解为两个阶段:

  1. **建立连接:**监听客户端,取出内核已经建立好的连接;
  2. **通信:**通过新的文件描述符与某个客户端进行一对一读写。

监听和通信不是同一件事。监听套接字负责获取新连接,accept 返回的通信套接字才负责收发业务数据。

二、TCP 服务器需要哪些接口

2.1 socket:创建流式套接字

TCP 和 UDP 都使用 socket 创建网络文件描述符,区别主要在第二个参数:

cpp 复制代码
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
  • AF_INET:IPv4 地址族;
  • SOCK_STREAM:面向字节流的 Socket,对应 TCP;
  • 0:根据地址族和类型选择默认协议。

调用成功返回非负文件描述符,失败返回 -1 并设置 errno。

从应用层看,Socket 仍然通过文件描述符访问,所以建立连接后可以使用 read、write、recv、send 等接口收发数据。

2.2 bind:绑定固定服务器端口

服务器必须提供稳定入口,因此要把监听 Socket 绑定到本地端口:

cpp 复制代码
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = htonl(INADDR_ANY);

int ret = bind(listen_sock,
               reinterpret_cast<sockaddr*>(&local),
               sizeof(local));

绑定 INADDR_ANY 后,凡是发往本机任意 IPv4 接口、目标端口匹配的连接请求,都可以由该监听 Socket 处理。

端口要使用 htons 转成网络字节序。地址结构必须先清零,避免未赋值字段中残留随机数据。

2.3 listen:把 Socket 设置成监听状态

cpp 复制代码
int listen(int sockfd, int backlog);

listen 不负责接收业务数据,它只是把已经绑定地址的 Socket 转成监听 Socket。

可以把监听状态理解为餐厅门口一直有人接待新客人。客户进店后,门口的人仍然要继续等待下一位客人,真正点餐和上菜由其他服务人员完成。

backlog 和连接等待队列有关,但不能简单理解为"服务器最多只能连接 backlog 个客户端"。它描述的是内核连接队列长度相关参数,最终行为还会受到系统配置影响。学习阶段先使用 8 或 16 即可。

监听成功以后,可以查看 TCP 监听状态:

bash 复制代码
netstat -ntapl
  • -n:直接显示数字地址和端口;
  • -t:只看 TCP;
  • -a:显示全部 Socket;
  • -p:显示进程信息;
  • -l:只看监听状态。

2.4 使用 telnet 验证监听状态

只要 TCP 服务器已经进入监听状态,客户端就可以尝试建立连接。telnet 可以用来做最基础的连通性测试:

bash 复制代码
telnet www.baidu.com 80

连接成功后,可以手动输入一份最简单的 HTTP 请求:

text 复制代码
GET / HTTP/1.1
Host: www.baidu.com

同样,也可以使用 telnet 127.0.0.1 8888 测试自己写的服务器。

这里能证明的是目标地址和端口可以建立 TCP 连接,并不能证明应用层协议一定正确。若服务端要求特定报文格式,连接成功以后仍可能因为请求格式错误而主动断开。

三、accept 到底做了什么

3.1 连接并不是由 accept 建立的

函数原型如下:

cpp 复制代码
int accept(int sockfd,
           struct sockaddr* addr,
           socklen_t* addrlen);

客户端发起连接后,TCP 三次握手主要由内核协议栈完成。即使应用程序暂时没有调用 accept,只要连接队列仍能容纳这条连接,客户端也可能看到连接已经建立。

accept 的作用是:从监听 Socket 对应的已完成连接队列中取出一条连接,为应用程序返回一个新的通信文件描述符。

所以不能把 accept 理解成"三次握手函数"。三次握手完成后,连接先由内核管理;应用程序调用 accept 后,才拿到可以读写这条连接的 fd。

3.2 三个参数的输入输出方向

第一个参数是监听 Socket,属于输入参数:

cpp 复制代码
accept(listen_sock, ...);

第二个参数是客户端地址缓冲区,属于输出参数。调用者先准备空间,accept 成功后把对端 IP 和端口写进去:

cpp 复制代码
sockaddr_in peer{};

第三个参数是值---结果参数:

cpp 复制代码
socklen_t len = sizeof(peer);
  • 调用前,len 表示调用者提供的地址缓冲区大小;
  • 调用后,内核把实际写入的地址长度返回到 len 中。

完整调用:

cpp 复制代码
int sockfd = accept(
    listen_sock,
    reinterpret_cast<sockaddr*>(&peer),
    &len);

3.3 为什么返回一个新的文件描述符

监听 Socket 只负责接收新连接。如果它既负责等待连接,又负责和某个客户端长期通信,那么服务器处理当前客户端时就无法及时接待其他客户端。

所以内核保留监听 fd 的职责不变,每成功取出一条连接,就返回一个新的通信 fd:

text 复制代码
listen_sock:等待并获取新连接
sockfd:与某个客户端进行read/write

餐厅门口的接待人员仍然站在门口,新客人进入以后,由店内服务员负责后续服务。接待和服务的角色分开,服务器才能继续扩展并发能力。

一个 TCP 服务器通常只有少量监听 Socket,却可能同时拥有大量通信 Socket。每个 accept 返回值对应一条已经建立的连接。

四、TCP 字节流怎样读取

4.1 read 的三种返回情况

连接建立以后,可以直接调用 read:

cpp 复制代码
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);

返回值需要分三种情况处理:

  • n > 0:本次读到 n 个字节;
  • n == 0:对端已经正常关闭连接,当前方向读到"文件结尾";
  • n < 0:读取失败,需要检查 errno。

当 n > 0 且内容按文本处理时,可以补字符串结束符:

cpp 复制代码
buffer[n] = '\0';

前提是读取长度传入 sizeof(buffer) - 1,确保还留有一个字节。

read 返回 0 不代表"暂时没数据",而是对端已关闭连接。暂时没有数据时,阻塞 Socket 会让线程睡眠等待。

4.2 write 不保证一次写完

基础回显示例经常直接调用:

cpp 复制代码
write(sockfd, message.data(), message.size());

但 write 的返回值表示本次实际写入的字节数,不承诺一定等于请求长度。更稳妥的做法是封装循环发送:

cpp 复制代码
bool WriteAll(int sockfd, const std::string& message) {
    std::size_t offset = 0;

    while (offset < message.size()) {
        ssize_t n = write(sockfd,
                          message.data() + offset,
                          message.size() - offset);
        if (n > 0) {
            offset += static_cast<std::size_t>(n);
            continue;
        }
        if (n < 0 && errno == EINTR) {
            continue;
        }
        return false;
    }
    return true;
}

这里还没有处理 TCP 消息边界。TCP 是字节流,一次 write 不一定对应接收端的一次 read。当前回显程序只用于观察基本接口,真正的业务协议要在后面的应用层协议部分解决粘包、拆包和报文边界。

五、完整的单进程 Echo Server

5.1 工程结构

text 复制代码
tcp_echo_v1/
├── Common.hpp
├── InetAddr.hpp
├── TcpServer.hpp
├── TcpServer.cc
├── TcpClient.cc
└── makefile

5.2 Common.hpp

cpp 复制代码
#pragma once

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#include <cerrno>
#include <cstdint>
#include <cstring>
#include <stdexcept>
#include <string>

enum ExitCode {
    OK = 0,
    USAGE_ERR,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR,
    FORK_ERR
};

class NoCopy {
public:
    NoCopy() = default;
    NoCopy(const NoCopy&) = delete;
    NoCopy& operator=(const NoCopy&) = delete;
    virtual ~NoCopy() = default;
};

服务器对象持有文件描述符,不希望被随意复制。删除拷贝构造和赋值,可以避免两个对象误持有同一个监听 fd。

5.3 InetAddr.hpp

cpp 复制代码
#pragma once

#include "Common.hpp"

class InetAddr {
public:
    explicit InetAddr(uint16_t port) {
        std::memset(&addr_, 0, sizeof(addr_));
        addr_.sin_family = AF_INET;
        addr_.sin_port = htons(port);
        addr_.sin_addr.s_addr = htonl(INADDR_ANY);
    }

    InetAddr(const std::string& ip, uint16_t port) {
        std::memset(&addr_, 0, sizeof(addr_));
        addr_.sin_family = AF_INET;
        addr_.sin_port = htons(port);

        if (inet_pton(AF_INET, ip.c_str(), &addr_.sin_addr) != 1) {
            throw std::invalid_argument("invalid IPv4 address");
        }
    }

    explicit InetAddr(const sockaddr_in& addr)
        : addr_(addr) {}

    const sockaddr* AddrPtr() const {
        return reinterpret_cast<const sockaddr*>(&addr_);
    }

    sockaddr* AddrPtr() {
        return reinterpret_cast<sockaddr*>(&addr_);
    }

    socklen_t Length() const {
        return sizeof(addr_);
    }

    std::string Ip() const {
        char buffer[INET_ADDRSTRLEN] = {0};
        inet_ntop(AF_INET, &addr_.sin_addr, buffer, sizeof(buffer));
        return buffer;
    }

    uint16_t Port() const {
        return ntohs(addr_.sin_port);
    }

    std::string StringAddr() const {
        return Ip() + ":" + std::to_string(Port());
    }

private:
    sockaddr_in addr_{};
};

inet_ntop 的第四个参数必须是目标字符数组的大小。不要误写成 sizeof(sockaddr_in),否则缓冲区长度与实际对象不对应。

5.4 TcpServer.hpp

cpp 复制代码
#pragma once

#include "Common.hpp"
#include "InetAddr.hpp"

#include <iostream>

class TcpServer : public NoCopy {
public:
    explicit TcpServer(uint16_t port)
        : port_(port) {}

    bool Init() {
        listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listen_sock_ < 0) {
            std::cerr << "socket error: " << strerror(errno) << std::endl;
            return false;
        }

        InetAddr local(port_);
        if (bind(listen_sock_, local.AddrPtr(), local.Length()) < 0) {
            std::cerr << "bind error: " << strerror(errno) << std::endl;
            return false;
        }

        if (listen(listen_sock_, 8) < 0) {
            std::cerr << "listen error: " << strerror(errno) << std::endl;
            return false;
        }
        return true;
    }

    void Run() {
        for (;;) {
            sockaddr_in peer_addr{};
            socklen_t len = sizeof(peer_addr);

            int sockfd = accept(
                listen_sock_,
                reinterpret_cast<sockaddr*>(&peer_addr),
                &len);

            if (sockfd < 0) {
                if (errno == EINTR) {
                    continue;
                }
                std::cerr << "accept error: " << strerror(errno) << std::endl;
                continue;
            }

            InetAddr peer(peer_addr);
            std::cout << "new connection: "
                      << peer.StringAddr() << std::endl;

            Service(sockfd, peer);
            close(sockfd);
        }
    }

    ~TcpServer() override {
        if (listen_sock_ >= 0) {
            close(listen_sock_);
        }
    }

private:
    static bool WriteAll(int sockfd, const std::string& message) {
        std::size_t offset = 0;
        while (offset < message.size()) {
            ssize_t n = write(sockfd,
                              message.data() + offset,
                              message.size() - offset);
            if (n > 0) {
                offset += static_cast<std::size_t>(n);
            } else if (n < 0 && errno == EINTR) {
                continue;
            } else {
                return false;
            }
        }
        return true;
    }

    static void Service(int sockfd, const InetAddr& peer) {
        char buffer[1024];

        for (;;) {
            ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
            if (n > 0) {
                buffer[n] = '\0';
                std::cout << peer.StringAddr()
                          << " say# " << buffer << std::endl;

                std::string response = "server echo# ";
                response.append(buffer, static_cast<std::size_t>(n));
                if (!WriteAll(sockfd, response)) {
                    break;
                }
            } else if (n == 0) {
                std::cout << peer.StringAddr()
                          << " quit" << std::endl;
                break;
            } else if (errno != EINTR) {
                std::cerr << "read error: "
                          << strerror(errno) << std::endl;
                break;
            }
        }
    }

private:
    uint16_t port_;
    int listen_sock_ = -1;
};

这就是最基础的单进程版本。它一次只处理一个通信 fd,当前客户端不断开,程序就不会回到下一轮 accept。这个问题留到下一篇解决。

5.5 TcpServer.cc

cpp 复制代码
#include "TcpServer.hpp"

#include <iostream>

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        return USAGE_ERR;
    }

    uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
    TcpServer server(port);

    if (!server.Init()) {
        return SOCKET_ERR;
    }

    server.Run();
    return OK;
}

六、TCP 客户端

6.1 connect 的目标是服务器地址

客户端不需要 listen 和 accept。它要做的是填写服务器地址,然后主动发起连接:

cpp 复制代码
int connect(int sockfd,
            const struct sockaddr* addr,
            socklen_t addrlen);

bind 和 connect 都接收地址结构,但含义不同:

  • bind 绑定自己的本地地址;
  • connect 指定准备连接的远端地址。

客户端也需要本地 IP 和端口,但通常不显式调用 bind。第一次 connect 时,操作系统根据路由选择本地地址,并分配临时端口。

6.2 TcpClient.cc

cpp 复制代码
#include "Common.hpp"
#include "InetAddr.hpp"

#include <iostream>

bool WriteAll(int sockfd, const std::string& message) {
    std::size_t offset = 0;
    while (offset < message.size()) {
        ssize_t n = write(sockfd,
                          message.data() + offset,
                          message.size() - offset);
        if (n > 0) {
            offset += static_cast<std::size_t>(n);
        } else if (n < 0 && errno == EINTR) {
            continue;
        } else {
            return false;
        }
    }
    return true;
}

int main(int argc, char* argv[]) {
    if (argc != 3) {
        std::cerr << "Usage: " << argv[0]
                  << " server_ip server_port" << std::endl;
        return USAGE_ERR;
    }

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        std::cerr << "socket error: " << strerror(errno) << std::endl;
        return SOCKET_ERR;
    }

    InetAddr server(argv[1],
                    static_cast<uint16_t>(std::stoi(argv[2])));

    if (connect(sockfd, server.AddrPtr(), server.Length()) < 0) {
        std::cerr << "connect error: " << strerror(errno) << std::endl;
        close(sockfd);
        return CONNECT_ERR;
    }

    for (;;) {
        std::string request;
        std::cout << "Please Enter# ";
        if (!std::getline(std::cin, request)) {
            break;
        }

        if (!WriteAll(sockfd, request)) {
            break;
        }

        char buffer[1024];
        ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
        if (n > 0) {
            buffer[n] = '\0';
            std::cout << buffer << std::endl;
        } else {
            break;
        }
    }

    close(sockfd);
    return OK;
}

6.3 Makefile 与运行

makefile 复制代码
.PHONY: all clean

all: tcpclient tcpserver

tcpclient: TcpClient.cc
	g++ -o $@ $^ -std=c++17

tcpserver: TcpServer.cc
	g++ -o $@ $^ -std=c++17

clean:
	rm -f tcpclient tcpserver

编译并启动:

bash 复制代码
make
./tcpserver 8888

另一个终端运行客户端:

bash 复制代码
./tcpclient 127.0.0.1 8888

七、单进程版本的问题

程序执行顺序如下:

  1. 服务器阻塞在 accept;
  2. 客户端 A 连接后,accept 返回 A 的通信 fd;
  3. 主线程进入 Service(A),不断读取 A;
  4. A 不退出,Service 就不返回;
  5. 客户端 B 即使完成握手,也只能留在内核队列中等待;
  6. A 退出后,主线程才回到下一轮 accept。

这不是 accept 只能连接一个客户端,而是应用程序把"接收新连接"和"服务当前连接"放在同一条执行流中,自己把主线程堵住了。

下一篇会让主进程只负责 accept,把每个通信 fd 交给独立子进程,从而同时服务多个客户端。

总结

本文完成了 TCP 最基础的一条链路:

  1. 服务器通过 socket、bind、listen 建立监听 Socket;
  2. 三次握手主要由内核完成,accept 负责取出已建立连接;
  3. 监听 fd 负责获取连接,通信 fd 负责 read/write;
  4. read == 0 表示对端已经关闭连接;
  5. 客户端使用 connect 主动连接服务器;
  6. 单进程版本一次只能服务一个长连接,结构正确但没有并发能力。

完整系列代码可在仓库中查看:Socket 编程代码。

往期博客分享

【Linux网络】UDP 服务器怎么写?从 socket、bind 到 recvfrom/sendto,手写完整回显服务器

【Linux网络】UDP 程序怎么继续封装?从 UdpSocket、回调到网络字典服务器

【Linux网络】UDP 聊天室怎么实现?从在线用户管理、消息广播到双线程收发与线程池

相关推荐
鲁邦通物联网42 分钟前
工业装备OEM远程服务架构:边缘计算网关与售后运维系统设计
运维·边缘计算·数据采集·工业数据采集·边缘计算网关·plc数采·厂内数采
冯小鸟的探戈1 小时前
电脑重生之安装xp系统
运维·服务器·电脑
分布式存储与RustFS1 小时前
Longhorn 的卷备份别堆本地盘:用 RustFS 给它当 S3 备份目标
运维·云原生·开源·对象存储·分布式存储·s3·性能基准
全栈陈序员3 小时前
【Git】从入门到精通(十)—— 撤销与恢复:git checkout与git restore
运维·网络·数据库·git
瑞克-桑切斯10 小时前
7、MySQL 慢查询排查与优化实战指南
java·服务器·数据库
天远Date Lab11 小时前
零信任架构实战:基于天远租凭分期报告构建自动化汽车以租代购免押风控网关
网络·人工智能·架构·自动化
vibecode12 小时前
网站服务器失去响应怎么解决?我把连接超时和读超时这两段计时拆开跑了一遍
运维·服务器
工业一体机老司机12 小时前
Docker部署工控机采集服务 Compose编排与开机自启
运维·docker·容器