Linux下 Socket编程 —— TCP网络编程

欢迎来到我的频道!【点击跳转专栏】

本文所有代码已托管至码云:【点此转跳】

文章目录

  • [1. Tcp套接字接口](#1. Tcp套接字接口)
    • [1.1 listen()](#1.1 listen())
    • [1.2 accept()](#1.2 accept())
    • [13. connect()](#13. connect())
  • [2. TCP Echo Server 的演进](#2. TCP Echo Server 的演进)
    • [2.1 整体结构](#2.1 整体结构)
    • [2.2 服务端初始化:socket、bind、listen](#2.2 服务端初始化:socket、bind、listen)
    • [2.3 公共业务处理:serviceIO](#2.3 公共业务处理:serviceIO)
    • [2.4 Version 0:单线程/进程 版本](#2.4 Version 0:单线程/进程 版本)
    • [2.5 Version 1:多进程版本](#2.5 Version 1:多进程版本)
    • [2.6 Version 2:一连接一线程版本](#2.6 Version 2:一连接一线程版本)
    • [2.7 Version 3:线程池版本](#2.7 Version 3:线程池版本)
    • [2.8 客户端流程](#2.8 客户端流程)
    • [2.9 四个版本的比较](#2.9 四个版本的比较)
    • [2.10 最终效果](#2.10 最终效果)
    • [2.11 线程池版本完整流程图](#2.11 线程池版本完整流程图)
  • [3. 简易远程命令系统的实现](#3. 简易远程命令系统的实现)
    • [3.1 模块职责](#3.1 模块职责)
    • [3.2 服务端启动流程](#3.2 服务端启动流程)
    • [3.3 客户端连接服务端](#3.3 客户端连接服务端)
    • [4.4 线程池如何处理连接](#4.4 线程池如何处理连接)
    • [4.5 command 业务层模块](#4.5 command 业务层模块)
    • [4.6 线程池与长连接的关系](#4.6 线程池与长连接的关系)
    • [4.7 不足](#4.7 不足)
    • [4.8 最终效果](#4.8 最终效果)
    • [4.9 总结](#4.9 总结)
  • [4. 一些补充内容](#4. 一些补充内容)

1. Tcp套接字接口

部分接口都与udp一样,只是里面部分的参数发生了改变!

【点击转跳】

1.1 listen()

listen() 用于把 TCP Socket 设置成监听状态 ,让它可以等待客户端连接。它只适用于面向连接的 Socket,例如 SOCK_STREAM

c 复制代码
#include <sys/socket.h>

int listen(int sockfd, int backlog);

参数

  • sockfd:由 socket() 创建、并且已经 bind() 的 TCP Socket。
  • backlog:内核连接队列的容量,表示允许暂时排队等待服务器处理的连接数量(以后会讲,一般设置成16、32即可 )
  • 成功:返回 0
  • 失败:返回 -1,并设置 errno

基本流程

c 复制代码
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

bind(sockfd, ...);

listen(sockfd, 16);

int clientfd = accept(sockfd, ...);

完整过程:

text 复制代码
socket()
    ↓
bind()
    ↓
listen()
    ↓
等待客户端连接
    ↓
accept()
    ↓
得到与客户端通信的新 Socket

listen()做了什么?

调用:

c 复制代码
listen(sockfd, 16);

之后,内核会把 sockfd 从普通 TCP Socket 变成监听 Socket

text 复制代码
服务器监听 Socket
        ↓
等待客户端连接

客户端执行:

c 复制代码
connect(server_ip, server_port);

连接请求到达后,内核会先把连接放入队列中,服务器再通过:

c 复制代码
accept(sockfd, ...);

取出一个已经建立的连接。

重要区别

listen() 不会接收客户端连接,它只是让 Socket 进入监听状态。

真正取出连接的是:

c 复制代码
accept()

1.2 accept()

accept() 用于 TCP 服务端从监听队列中取出一个已经建立好的客户端连接,并创建一个专门通信的新 Socket。

c 复制代码
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

典型位置:

text 复制代码
socket -> bind -> listen -> accept -> recv/send
  • sockfd:调用过 listen() 的监听 Socket。
  • addr:输出参数,内核把客户端的 IP、端口写入这里。
  • addrlen:传入 addr 的大小,返回实际写入长度。
  • 返回值: 成功返回新的通信 Socket(如 clientfd);失败返回 -1
cpp 复制代码
struct sockaddr_in client;
socklen_t len = sizeof(client);

int clientfd = accept(listenfd, (struct sockaddr *)&client, &len);
if (clientfd < 0)
{
    perror("accept");
    return;
}

此后分工是:

text 复制代码
listenfd:持续等待新的客户端连接,称为:监听套接字
clientfd:只和这一个客户端收发数据,称为:服务套接字 --> 代表一条连接

例如:

cpp 复制代码
char buffer[1024];
ssize_t n = recv(clientfd, buffer, sizeof(buffer) - 1, 0);

send(clientfd, "hello", 5, 0);

close(clientfd);  // 客户端断开后关闭通信 Socket

accept() 默认会阻塞:没有客户端完成连接时,服务端会停在这里等待。客户端执行 connect(),三次握手完成后,该连接进入监听队列,accept() 才会返回。

注意:不要用 listenfd 和客户端直接 recv/send;应使用 accept() 返回的 clientfd

趣味类别:

可以这么理解 你和你朋友去餐厅吃饭 张三(原本的fd)招呼你们进去(相当于调用accept()) 然后招呼李四(新的套接字fd)来给你们点餐端茶送水,你们吃饭与餐厅的联系都是和李四一对一发生的,而张三呢?接着招揽新客人去了。

13. connect()

connect() 用于客户端自动bind本地套接字地址,主动连接服务器,最常用于 TCP。它会向服务器发起 TCP 三次握手。

c 复制代码
#include <sys/socket.h>

int connect(int sockfd,
            const struct sockaddr *addr,
            socklen_t addrlen);

参数

  • sockfd:通过 socket() 创建的套接字。
  • addr:服务器的 IP 和端口。
  • addrlen:地址结构大小,通常是 sizeof(struct sockaddr_in)
  • connect() 成功返回 0,失败返回 -1,并设置 errno

TCP 连接流程

text 复制代码
客户端 socket()
    ↓
connect()
    ↓  发起三次握手
服务器 listen()
    ↓
服务器 accept()
    ↓
连接建立
    ↓
双方 send() / recv()

默认情况下,connect() 是阻塞的: 如果服务器不存在或网络不通,客户端可能等待一段时间后才失败。

另外,客户端通常不需要提前 bind()。调用 connect() 时,操作系统会自动为客户端选择本地 IP 和临时端口。

UDP 也可以调用 connect(),但它不进行 TCP 三次握手,主要作用是指定默认通信对象,之后可以使用 send()recv(),而不是每次都使用 sendto()recvfrom()

2. TCP Echo Server 的演进

这个服务的业务很简单:客户端发来一段文本,服务端在前面加上 server echo# 后返回。虽然功能简单,但它包含 TCP 服务端最核心的流程:socket、bind、listen、accept、read、write 和 close。

完整代码以放置于gitte中的/lesson61/4.EchoTcpServerMain.cc路径下! 【点击转跳】

2.1 整体结构

服务端入口负责读取端口、创建 TcpServer 对象,然后依次调用 InitServer 和 Start:

复制代码
uint16_t server_port = std::stoi(argv[1]);
std::unique_ptr<TcpServer> tsvr =
    std::make_unique<TcpServer>(server_port);

tsvr->InitServer();
tsvr->Start();

服务端的完整主流程如下:

复制代码
启动服务端
    ↓
socket(AF_INET, SOCK_STREAM, 0)
    ↓
bind(0.0.0.0:端口)
    ↓
listen(监听 Socket, backlog)
    ↓
循环 accept()
    ↓
得到一个客户端通信 Socket
    ↓
read / 处理业务 / write / close

其中必须区分两个文件描述符:

复制代码
_listensockfd:监听 Socket,只负责等待新连接
sockfd:accept 返回的通信 Socket,只负责一个客户端

监听 Socket 不直接和客户端收发数据。客户端完成 TCP 三次握手后,accept 才会返回一个新的通信 Socket,后续 read 和 write 都应该针对这个新的 sockfd。

2.2 服务端初始化:socket、bind、listen

InitServer 的核心逻辑如下:

复制代码
_listensockfd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = htonl(INADDR_ANY);

bind(_listensockfd, (struct sockaddr *)&local, sizeof(local));
listen(_listensockfd, gbacklog);

INADDR_ANY 表示绑定本机所有可用网卡地址。只要网络请求到达本机,并且目标端口匹配,内核就可以将连接交给该服务。

listen 会把 TCP Socket 设置为监听状态。backlog(一般设置成16) 表示内核中等待应用层 accept 处理的连接容量参考值,不等于服务器最多只能有多少在线用户。

2.3 公共业务处理:serviceIO

无论采用哪一种并发模型,最终处理客户端请求的逻辑都在 serviceIO 中:

复制代码
void serviceIO(int sockfd, const InetAddr &address)
{
    char inbuffer[1024] = {0};
    ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);

    if (n > 0)
    {
        inbuffer[n] = 0;
        LOG(LogLevel::INFO) << address.ToString()
                            << "say# " << inbuffer;

        std::string echo_string = "server echo# ";
        echo_string += inbuffer;
        write(sockfd, echo_string.c_str(), echo_string.size());
    }
    else if (n == 0)
    {
        LOG(LogLevel::INFO) << "client quit,address: "
                            << address.ToString();
    }
    else
    {
        LOG(LogLevel::ERROR) << "client read error,address: "
                             << address.ToString();
    }

    close(sockfd);
}

这段实现是短连接模型: 每个连接最多读取一次、回显一次,然后关闭通信 Socket。

read 的返回值含义如下:

复制代码
n > 0:成功读取 n 个字节
n == 0:TCP 对端正常关闭,连接可以结束
n < 0:读取出错,应进一步检查 errno

如果要做聊天、持续命令交互等长连接服务,需要将 read、业务处理和 write 放入循环中,直到对端断开或发生错误。(不过在多线程与多线程版本中一般不适合这么做

2.4 Version 0:单线程/进程 版本

单线程版本直接在 accept 返回后调用 serviceIO:

复制代码
InetAddr clientaddress(clientaddr);
serviceIO(sockfd, clientaddress);

它的执行顺序是:

复制代码
accept 第一个客户端
    ↓
主线程执行 serviceIO
    ↓
read 等待客户端数据
    ↓
write 回显并关闭连接
    ↓
回到 accept,等待下一个客户端

优点是: 代码直观、资源管理简单,非常适合学习 TCP 服务端的基本结构。

缺点是: 主线程会被单个客户端阻塞。例如客户端 A 连接后迟迟不发数据,服务端就会阻塞在 read(A);客户端 B、C 即使已经发起连接,也不能被服务端及时处理。

复制代码
客户端 A 连接但不发送数据
    ↓
服务端阻塞在 read(A)
    ↓
后续客户端无法得到及时服务

因此单线程同步模型通常只适用于教学、连接很少或请求能快速结束的场景。

2.5 Version 1:多进程版本

多进程版本的思路是: 父进程持续 accept,每接收一个客户端连接,就 fork 一个子进程处理。

复制代码
父进程 accept()
    ↓
fork()
    ├── 父进程:关闭当前 sockfd,继续 accept()
    └── 子进程:关闭 _listensockfd,执行 serviceIO()

关闭不需要的文件描述符非常重要:

复制代码
父进程不需要当前客户端 sockfd,因此关闭它
子进程不需要继续接收新连接,因此关闭 _listensockfd

进程之间的地址空间隔离较强,一个子进程出错通常不会直接破坏其他客户端的处理过程。但频繁创建进程的成本较高,并且必须处理子进程退出后留下的僵尸进程。

我们给出了两种回收思路。

第一种是忽略 SIGCHLD

复制代码
signal(SIGCHLD, SIG_IGN);

子进程退出后由内核自动回收。工程项目中通常更推荐使用 sigaction 明确设置处理方式。

第二种是双 fork : 第一个子进程再 fork 出孙子进程后立即退出。父进程只等待快速退出的子进程,真正处理客户端的孙子进程会成为孤儿进程并由系统接管,从而避免父进程被业务处理长期阻塞。

⚠️: 多进程模型适合理解并发隔离和文件描述符继承,但连接量很大时,频繁 fork 的开销会限制吞吐量。

2.6 Version 2:一连接一线程版本

多线程版本不再为每个连接创建进程,而是创建一个线程:

复制代码
pthread_t tid;
InetAddr clientaddress(clientaddr);
ThreadData *td = new ThreadData(this, sockfd, clientaddress);
pthread_create(&tid, nullptr, thread_routine, td);

线程入口函数:

复制代码
static void *thread_routine(void *args)
{
    ThreadData *td = static_cast<ThreadData *>(args);
    pthread_detach(pthread_self());
    td->_this->serviceIO(td->_sockfd, td->_addr);
    delete td;
    return nullptr;
}

ThreadData是一个类 的作用是把线程需要的信息打包起来:

复制代码
TcpServer*:用于调用 serviceIO
sockfd:当前客户端的通信 Socket
InetAddr:当前客户端的 IP 和端口

整体流程变为:

复制代码
主线程 accept()
    ↓
创建 ThreadData
    ↓
创建工作线程
    ↓
主线程立刻回到 accept()
    ↓
工作线程独立执行 serviceIO()

pthread_detach 表示线程结束后自动释放线程资源,因此主线程不需要 pthread_join

优点: 线程比进程通常更轻量,创建和切换成本也较低。

缺: 但它的问题是线程数量可能失控,每来一个连接都创建一个线程,高并发时会产生大量线程,带来内存消耗、调度成本和上下文切换压力。

所以,一连接一线程适合连接数有限、任务较短的服务,不适合没有并发上限的网络场景。

2.7 Version 3:线程池版本

线程池版本不再为每个连接临时创建线程,而是在程序启动或首次使用时创建固定数量的工作线程。主线程只负责接收连接,再把处理连接的工作提交到任务队列。

当前提交任务的代码是:

复制代码
InetAddr clientaddress(clientaddr);
ThreadPool<task_t>::Instance()->Enqueue(
    [this, sockfd, clientaddress]() -> void
    {
        this->serviceIO(sockfd, clientaddress);
    });

任务类型定义为:

复制代码
using task_t = std::function<void()>;

也就是说,每个客户端连接都会被封装成一个无参任务,任务的内容是执行:

复制代码
serviceIO(sockfd, clientaddress)

完整流程如下:

复制代码
客户端 connect()
    ↓
服务端 accept()
    ↓
主线程构造 Lambda 任务
    ↓
Enqueue() 将任务放入共享队列
    ↓
空闲工作线程被唤醒
    ↓
工作线程执行 read / 回显 / close
    ↓
工作线程回到任务队列前等待下一个任务

线程池的核心价值是线程复用:

复制代码
一连接一线程:连接到来 → 创建线程 → 执行任务 → 销毁线程
线程池:程序启动 → 创建固定线程 → 重复执行多个任务

这样既减少了频繁创建和销毁线程的成本,也可以通过限制工作线程数量,控制资源使用的上限。

2.8 客户端流程

客户端创建 TCP Socket 后通常不需要显式 bind 本地 IP 和端口:

复制代码
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
InetAddr serveraddress(server_port, server_ip);

connect(sockfd,
        (struct sockaddr *)serveraddress.GetNetAddress(),
        serveraddress.Len());

connect 时,操作系统通常会自动选择本地 IP 和临时端口,然后发起 TCP 三次握手。

通信过程:

复制代码
客户端 write()
    ↓
服务端工作线程 read()
    ↓
服务端 write() 回显
    ↓
客户端 read()

需要注意,TCP 是字节流协议,不天然保留消息边界。一次 write 不保证对端一次 read 就能完整读取;真正的应用协议需要通过长度字段、分隔符或固定长度报文解决消息边界问题。

创建连接后,后面就是简单的读写问题了!

2.9 四个版本的比较

复制代码
Version 0:单线程
优点:最简单,适合理解 TCP 基础流程
缺点:慢客户端会阻塞全部客户端

Version 1:多进程
优点:隔离性强
缺点:创建成本高,需要回收子进程

Version 2:一连接一线程
优点:能并发处理连接,通常比进程轻量
缺点:高并发时线程数可能失控

Version 3:线程池
优点:复用线程,限制并发执行数量,吞吐更稳定
缺点:需要正确处理任务队列、锁、条件变量和过载策略

2.10 最终效果

2.11 线程池版本完整流程图

最终线程池版本的流程如下:
#mermaid-svg-FFESRKM1RQKMM27u{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FFESRKM1RQKMM27u .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FFESRKM1RQKMM27u .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FFESRKM1RQKMM27u .error-icon{fill:#552222;}#mermaid-svg-FFESRKM1RQKMM27u .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FFESRKM1RQKMM27u .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FFESRKM1RQKMM27u .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FFESRKM1RQKMM27u .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FFESRKM1RQKMM27u .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FFESRKM1RQKMM27u .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FFESRKM1RQKMM27u .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FFESRKM1RQKMM27u .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FFESRKM1RQKMM27u .marker.cross{stroke:#333333;}#mermaid-svg-FFESRKM1RQKMM27u svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FFESRKM1RQKMM27u p{margin:0;}#mermaid-svg-FFESRKM1RQKMM27u .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FFESRKM1RQKMM27u .cluster-label text{fill:#333;}#mermaid-svg-FFESRKM1RQKMM27u .cluster-label span{color:#333;}#mermaid-svg-FFESRKM1RQKMM27u .cluster-label span p{background-color:transparent;}#mermaid-svg-FFESRKM1RQKMM27u .label text,#mermaid-svg-FFESRKM1RQKMM27u span{fill:#333;color:#333;}#mermaid-svg-FFESRKM1RQKMM27u .node rect,#mermaid-svg-FFESRKM1RQKMM27u .node circle,#mermaid-svg-FFESRKM1RQKMM27u .node ellipse,#mermaid-svg-FFESRKM1RQKMM27u .node polygon,#mermaid-svg-FFESRKM1RQKMM27u .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FFESRKM1RQKMM27u .rough-node .label text,#mermaid-svg-FFESRKM1RQKMM27u .node .label text,#mermaid-svg-FFESRKM1RQKMM27u .image-shape .label,#mermaid-svg-FFESRKM1RQKMM27u .icon-shape .label{text-anchor:middle;}#mermaid-svg-FFESRKM1RQKMM27u .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FFESRKM1RQKMM27u .rough-node .label,#mermaid-svg-FFESRKM1RQKMM27u .node .label,#mermaid-svg-FFESRKM1RQKMM27u .image-shape .label,#mermaid-svg-FFESRKM1RQKMM27u .icon-shape .label{text-align:center;}#mermaid-svg-FFESRKM1RQKMM27u .node.clickable{cursor:pointer;}#mermaid-svg-FFESRKM1RQKMM27u .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FFESRKM1RQKMM27u .arrowheadPath{fill:#333333;}#mermaid-svg-FFESRKM1RQKMM27u .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FFESRKM1RQKMM27u .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FFESRKM1RQKMM27u .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FFESRKM1RQKMM27u .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FFESRKM1RQKMM27u .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FFESRKM1RQKMM27u .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FFESRKM1RQKMM27u .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FFESRKM1RQKMM27u .cluster text{fill:#333;}#mermaid-svg-FFESRKM1RQKMM27u .cluster span{color:#333;}#mermaid-svg-FFESRKM1RQKMM27u div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FFESRKM1RQKMM27u .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FFESRKM1RQKMM27u rect.text{fill:none;stroke-width:0;}#mermaid-svg-FFESRKM1RQKMM27u .icon-shape,#mermaid-svg-FFESRKM1RQKMM27u .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FFESRKM1RQKMM27u .icon-shape p,#mermaid-svg-FFESRKM1RQKMM27u .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FFESRKM1RQKMM27u .icon-shape .label rect,#mermaid-svg-FFESRKM1RQKMM27u .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FFESRKM1RQKMM27u .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FFESRKM1RQKMM27u .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FFESRKM1RQKMM27u :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否





大于0
等于0
小于0
服务端启动
创建 TcpServer
InitServer
socket 创建监听 Socket
bind 绑定端口
listen 进入监听状态
Start 循环调用 accept
是否有客户端连接
accept 返回通信 sockfd
获取客户端 IP 和端口
构造 InetAddr clientaddress
封装 Lambda 任务
ThreadPool::Instance
线程池是否已创建
创建线程池
创建 5 个工作线程
启动工作线程
直接获取线程池
Enqueue 放入任务队列
主线程继续 accept
工作线程等待任务
任务队列是否为空
条件变量等待
加锁并取出任务
从队列删除任务
解锁
执行 serviceIO
read 读取客户端数据
读取结果
拼接 server echo
write 返回数据
close 关闭通信 Socket
客户端已断开
处理读取错误

3. 简易远程命令系统的实现

这个项目实现了一个基于 TCP 的"远程命令执行"演示程序: 客户端连接服务端,服务端显示类似 Shell 的提示符;客户端输入允许执行的命令后,服务端执行命令并把标准输出返回给客户端。

完整代码以放置于gitte中的/lesson61/RemoteSSH路径下! 【点击转跳】

3.1 模块职责

项目可分为四个核心部分:

复制代码
SSH_Client.cc
    客户端:连接服务端、显示提示符、读取用户命令、显示执行结果。

SSH_Server.cc
    服务端启动入口:创建 Command 和 TcpServer,并注册业务回调。

TcpServer.hpp
    网络层:socket、bind、listen、accept;把每个客户端连接交给线程池处理。

Command.hpp
    业务层:维护命令白名单、执行允许的命令、生成提示符字符串。

整体职责划分如下:

复制代码
客户端
    ↓ TCP 连接
TcpServer 网络层
    ↓ 回调
Command 命令业务层
    ↓ popen
Linux Shell 命令
    ↓
返回给业务层 业务层再返回给网络层
    ↓
由网络层将内容传输给客户

3.2 服务端启动流程

服务端入口先创建 Command 对象和 TcpServer 对象:

cpp 复制代码
   std::unique_ptr<Command> command = std::make_unique<Command>();
    std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(server_port);

随后通过 Register 注册两个回调:

cpp 复制代码
 tsvr->Register(
        [&command](std::string cmd) -> std::string
        {
            return command->Excute(cmd);
        },
        [&command]() -> std::string
        {
            return command->GetCommandString();
        });

这一步的核心思想是"网络层不直接知道如何执行命令"。TcpServer 只负责收发 TCP 数据,真正的命令处理交给外部注册的回调函数。

两个回调分别负责:

复制代码
_handler
    输入:客户端发来的命令字符串。
    输出:命令执行结果。

_Handlertips
    输入:无。
    输出:Shell 风格提示符,例如 [fcy@server /home/fcy]# 。

完成回调注册后,服务端初始化网络:

复制代码
tsvr->InitServer();
tsvr->Start();

InitServer 内部的 TCP 建立过程:

复制代码
socket(AF_INET, SOCK_STREAM, 0)
    ↓
bind(0.0.0.0:服务器端口)
    ↓
listen(监听 Socket, backlog)
    ↓
服务端进入监听状态

3.3 客户端连接服务端

客户端首先创建 TCP Socket:

复制代码
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

然后构造服务端地址并调用 connect:

cpp 复制代码
InetAddr serveraddress(server_port, server_ip);
connect(sockfd,
        (struct sockaddr *)serveraddress.GetNetAddress(),
        serveraddress.Len());

客户端一般不需要显式 bind 本地 IP 和端口。调用 connect 时,操作系统会自动挑选本地可用 IP 和临时端口,再向服务端发起 TCP 三次握手。

连接建立后,服务端的 accept 返回一个新的通信 Socket:

cpp 复制代码
 int sockfd = accept(_listensockfd,
                         (struct sockaddr *)&clientaddr,
                         &len);

两个 Socket 的职责不同:

复制代码
_listensockfd
    只负责持续接收新的连接。

accept 返回的 sockfd
    只负责和当前这个客户端 read、write 和 close。

4.4 线程池如何处理连接

服务端成功 accept 后,并不直接在主线程处理客户端,而是将连接封装成任务提交给线程池:

复制代码
InetAddr clientaddress(clientaddr);
ThreadPool<task_t>::Instance()->Enqueue(
    [this, sockfd, clientaddress]()
    {
        this->service(sockfd, clientaddress);
    });

这里的 Lambda 表示一个无参任务:将来执行 service(sockfd, clientaddress)。

线程池处理流程:

复制代码
主线程 accept 新连接
    ↓
构造客户端地址和 Lambda 任务
    ↓
Enqueue 将任务放入共享任务队列
    ↓
空闲工作线程被条件变量唤醒
    ↓
工作线程取出任务
    ↓
执行 service(sockfd, clientaddress)
    ↓
工作线程继续等待下一个任务

主线程因此可以继续 accept 后续客户端,而不会被单个客户端的 read 操作阻塞。

Lambda 中的 sockfd 和 clientaddress 都按值捕获

cpp 复制代码
[this, sockfd, clientaddress]

⚠️:这样任务即使稍后才执行,也拥有自己的文件描述符整数副本和地址对象副本。clientaddress 如果按引用捕获,Start 循环进入下一次迭代后,异步任务可能访问已失效的局部变量,造成悬空引用。

4.5 command 业务层模块

Command 构造时加载命令白名单:

cpp 复制代码
 _whitelist.push_back("ls -a -l");
    _whitelist.push_back("ls -l");
    _whitelist.push_back("pwd");
    _whitelist.push_back("whoami");
    _whitelist.push_back("who");
    _whitelist.push_back("ps -al");
    _whitelist.push_back("netstat -nltp");

客户端命令到达后,Excute 会先检查是否精确匹配:

复制代码
if (!IsSafe(cmd))
{
    return "bad man!\\n";
}

只有白名单中的命令才会被交给 popen:

复制代码
FILE *fp = popen(cmd.c_str(), "r");

while (fgets(buffer, sizeof(buffer), fp))
{
    result += buffer;
}

pclose(fp);

popen 的作用可以理解为: 创建一个子进程执行 Shell 命令,再通过管道让父进程读取该命令的标准输出。

复制代码
服务端进程
    ↓ popen("pwd", "r")
创建管道和子进程
    ↓
子进程执行 pwd
    ↓
pwd 的标准输出进入管道
    ↓
服务端使用 fgets 读出结果
    ↓
服务端通过 TCP write 返回客户端

白名单是本项目最基本的安全措施。绝对不能直接对客户端传来的任意字符串执行 popen,例如:

cpp 复制代码
popen(client_input.c_str(), "r");

因为 popen 通常会通过 Shell 解释命令,攻击者可以构造带分号、重定向、管道等特殊字符的输入,导致执行非预期命令。


提示符的如何生成的

GetCommandString 使用系统接口获取当前服务端进程的信息:

复制代码
getpwuid(getuid())
    获取当前用户名称。

gethostname(...)
    获取主机名。

getcwd(...)
    获取当前工作目录。

最终拼接出:

复制代码
[用户名@主机名 当前目录]# 

注意, 这个提示符反映的是服务端进程自身的用户、主机名和工作目录,不是客户端机器的信息。

4.6 线程池与长连接的关系

这个项目使用线程池,但 service 是一个长连接循环: 一个工作线程一旦拿到某客户端任务,会一直服务这个客户端,直到客户端断开。

因此,如果线程池默认只有 5 个工作线程:

复制代码
前 5 个保持连接的客户端
    ↓
分别长期占用 5 个工作线程
    ↓
第 6 个及之后的客户端任务
    ↓
只能停留在任务队列中等待

这并不表示第 6 个客户端一定无法完成 TCP 连接。它可能已经被 accept,通信 Socket 也已创建,但暂时没有工作线程来执行 service 和发送提示符。

所以当前线程池模型适合并发用户数量较少、每个连接持续时间有限的演示项目。

4.7 不足

由于TCP 没有消息边界。当前代码逻辑默认"服务端一次 write 的提示符,客户端一次 read 就能读完整""客户端一次 write 的命令,服务端一次 read 就能读完整"。TCP 只保证有序字节流,不保证 read/write 一一对应。

所以可能会导致 客户端里面出现一些bug!

cpp 复制代码
正常流程:
read命令行 -> write写命令 -> 读取结果 -> read命令行 -> write写命令 -> 读取结果 循环....
异常流程:
read命令行 -> write写命令 -> 读取结果同时将命令行一起读取 -> read 阻塞卡死 

解决方案:可以客户端服务端 每次read write 都发送个确认符 当然博主并没有写!

4.8 最终效果

4.9 总结

该项目展示了一个完整的应用层远程命令请求/响应链路:

复制代码
客户端 connect
    ↓
服务端 socket / bind / listen / accept
    ↓
线程池接管客户端长连接
    ↓
服务端发送提示符
    ↓
客户端输入并发送命令
    ↓
服务端白名单校验
    ↓
popen 执行允许的命令
    ↓
服务端将标准输出写回客户端
    ↓
客户端显示结果并开始下一轮交互

Linux Shell Command 命令模块 线程池工作线程 服务端主线程 RemoteSSH 客户端 用户 Linux Shell Command 命令模块 线程池工作线程 服务端主线程 RemoteSSH 客户端 用户 #mermaid-svg-3PFsCxtOF1YGI6MO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3PFsCxtOF1YGI6MO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3PFsCxtOF1YGI6MO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3PFsCxtOF1YGI6MO .error-icon{fill:#552222;}#mermaid-svg-3PFsCxtOF1YGI6MO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3PFsCxtOF1YGI6MO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3PFsCxtOF1YGI6MO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3PFsCxtOF1YGI6MO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3PFsCxtOF1YGI6MO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3PFsCxtOF1YGI6MO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3PFsCxtOF1YGI6MO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3PFsCxtOF1YGI6MO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3PFsCxtOF1YGI6MO .marker.cross{stroke:#333333;}#mermaid-svg-3PFsCxtOF1YGI6MO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3PFsCxtOF1YGI6MO p{margin:0;}#mermaid-svg-3PFsCxtOF1YGI6MO .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3PFsCxtOF1YGI6MO text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3PFsCxtOF1YGI6MO .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3PFsCxtOF1YGI6MO .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3PFsCxtOF1YGI6MO .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3PFsCxtOF1YGI6MO .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3PFsCxtOF1YGI6MO #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3PFsCxtOF1YGI6MO .sequenceNumber{fill:white;}#mermaid-svg-3PFsCxtOF1YGI6MO #sequencenumber{fill:#333;}#mermaid-svg-3PFsCxtOF1YGI6MO #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3PFsCxtOF1YGI6MO .messageText{fill:#333;stroke:none;}#mermaid-svg-3PFsCxtOF1YGI6MO .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3PFsCxtOF1YGI6MO .labelText,#mermaid-svg-3PFsCxtOF1YGI6MO .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3PFsCxtOF1YGI6MO .loopText,#mermaid-svg-3PFsCxtOF1YGI6MO .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3PFsCxtOF1YGI6MO .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3PFsCxtOF1YGI6MO .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3PFsCxtOF1YGI6MO .noteText,#mermaid-svg-3PFsCxtOF1YGI6MO .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3PFsCxtOF1YGI6MO .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3PFsCxtOF1YGI6MO .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3PFsCxtOF1YGI6MO .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3PFsCxtOF1YGI6MO .actorPopupMenu{position:absolute;}#mermaid-svg-3PFsCxtOF1YGI6MO .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3PFsCxtOF1YGI6MO .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3PFsCxtOF1YGI6MO .actor-man circle,#mermaid-svg-3PFsCxtOF1YGI6MO line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3PFsCxtOF1YGI6MO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 命令不在白名单 命令在白名单 loop 客户端保持连接 启动客户端并输入服务器 IP、端口 connect() 发起 TCP 三次握手 accept() 返回通信 sockfd Enqueue(service(sockfd, clientaddress)) 从任务队列取出连接任务 GetCommandString() 用户名@主机名 当前目录 write() 发送提示符 显示提示符,等待输入命令 输入命令,例如 pwd write() 发送命令字符串 read() 读取命令 Excute(command) 检查命令是否在白名单 bad man!\n popen(command, "r") 命令标准输出 fgets() 读取命令结果 pclose() 等待命令结束 返回命令输出 write() 返回执行结果 显示命令结果 退出客户端 关闭 TCP 连接 read() 返回 0 close(sockfd) 回到线程池等待下一个任务

注意:这个项目是"TCP 远程命令演示程序",数据没有加密,也没有身份认证,不能当作真正的 SSH 使用。

4. 一些补充内容

4.1 telnet

telnet 是一个基于 TCP 的命令行工具,可以连接指定 IP 和端口。学习网络编程时,常用它来测试服务器端口是否能连通。

bash 复制代码
telnet IP地址 端口号

例如:

bash 复制代码
telnet 127.0.0.1 8080
telnet 47.108.228.148 8888

连接流程:

text 复制代码
telnet
  ↓
向目标 IP 和端口发起 TCP 连接
  ↓
服务器 listen()
  ↓
服务器 accept()
  ↓
连接建立,可以发送数据

可用于测试 TCP 服务器


假设你的 TCP 服务端监听 8888 端口:

bash 复制代码
telnet 127.0.0.1 8888

如果显示:

text 复制代码
Connected to 127.0.0.1.

说明:

  • IP 地址可以访问
  • 端口是开放的
  • 服务器可能已经调用了 listen()
  • TCP 三次握手成功

如果显示:

text 复制代码
Connection refused

通常表示:

  • 目标机器可以到达
  • 但该端口没有服务监听
  • 或服务端主动拒绝连接

如果一直连接不上或超时,可能是:

  • IP 地址错误
  • 云服务器安全组没有放行端口
  • 防火墙拦截
  • 网络不可达

发送数据:

连接成功后,直接输入文本并按回车:

text 复制代码
hello

如果服务器调用了:

cpp 复制代码
read(clientfd, buffer, sizeof(buffer));

就可以读取到 hello,如果服务端把数据返回,telnet 窗口会显示返回内容。

退出 telnet:

先按:

text 复制代码
Ctrl + ]

进入 telnet 控制界面,再输入:

text 复制代码
quit

也可以输入:

text 复制代码
close

演示:

4.2 popen() 和 pclose()

popen()pclose() 用于执行外部命令,并通过管道读写命令的输入或输出。

函数原型:

c 复制代码
#include <stdio.h>

FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);

popen() 的参数:

cpp 复制代码
popen("ls -l", "r");
  • "r":程序读取命令的标准输出。
  • "w":程序向命令的标准输入写数据。

读取命令输出:

cpp 复制代码
#include <cstdio>
#include <iostream>

int main()
{
    FILE *fp = popen("ls -l", "r");
    if (fp == nullptr)
    {
        perror("popen");
        return 1;
    }

    char buffer[1024];

    while (fgets(buffer, sizeof(buffer), fp) != nullptr)
    {
        std::cout << buffer;
    }

    int status = pclose(fp);
    return 0;
}

执行过程:

text 复制代码
父进程调用 popen()
        ↓
创建管道和子进程
        ↓
子进程执行 ls -l
        ↓
命令输出进入管道
        ↓
父进程使用 fgets() 读取
        ↓
pclose() 关闭管道并等待子进程结束

向命令输入数据:

cpp 复制代码
FILE *fp = popen("wc -c", "w");//wc -c 是 Linux 中用于统计输入内容字节数的命令。
if (fp == nullptr)
{
    perror("popen");
    return 1;
}

fputs("hello world\n", fp);

int status = pclose(fp);

数据流方向:

text 复制代码
程序 fputs()
    ↓
管道
    ↓
wc -c 的标准输入

pclose()注意事项

pclose() 不仅关闭文件流,还会等待子进程结束。它返回的是子进程的等待状态,可以这样获取退出码:

cpp 复制代码
#include <sys/wait.h>

if (status == -1)
{
    perror("pclose");
}
else if (WIFEXITED(status))
{
    std::cout << "退出码:"
              << WEXITSTATUS(status)
              << std::endl;
}

注意

  • popen() 通常通过 Shell 执行命令,因此不要直接拼接用户输入,避免命令注入。
  • 一次 popen() 通常只能单向通信,"r" 是读,"w" 是写。
  • popen() 返回的流必须使用 pclose() 关闭,不能使用普通的 fclose()
  • fgets()pclose() 都可能阻塞。

4.3 再谈inet_ntoa

inet_ntoa这个函数返回了一个char*,很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果. 那么是否需要调用者手动释放呢?

man⼿册上说, inet_ntoa函数, 是把这个返回结果放到了静态存储区. 这个时候不需要我们⼿动进⾏释放。

那么问题来了, 如果我们调⽤多次这个函数, 会有什么样的效果呢? 参⻅如下代码:

运⾏结果如下:

因为inet_ntoa把结果放到⾃⼰内部的⼀个静态存储区, 这样第⼆次调⽤时的结果会覆盖掉上⼀次的结果。

当然在部分服务器中测试并没有出现这种 线程不安全的情况! 可能内部的实现加了互斥锁。

但是 对于这种疑似危险的我们都不使用 而是使用线程安全的inet_ntop, 这个函数由调⽤者提供⼀个缓冲区保存结果, 可以规避线程安全问题。

4.4 inet_ntop()&&inet_pton()

inet_ntop() 用于把二进制格式的 IP 地址转换成字符串格式。

例如:

text 复制代码
二进制 IP
    ↓ inet_ntop()
"192.168.1.10"

函数原型:

c 复制代码
#include <arpa/inet.h>

const char *inet_ntop(int af,
                      const void *src,
                      char *dst,
                      socklen_t size);

参数含义:

  • af:地址类型,常用 AF_INET,也可以是 AF_INET6
  • src:二进制 IP 地址。
  • dst:保存转换后字符串的缓冲区。
  • size:缓冲区大小。

IPv4 示例:

cpp 复制代码
struct sockaddr_in addr;
char ip[100];

inet_ntop(AF_INET,
          &addr.sin_addr,
          ip,
          sizeof(ip));

std::cout << ip << std::endl;

inet_pton() 用于把字符串形式的 IP 地址转换成网络字节序的二进制地址。

text 复制代码
"192.168.1.10"
        ↓ inet_pton()
二进制 IP 地址

函数原型:

c 复制代码
#include <arpa/inet.h>

int inet_pton(int af,
              const char *src,
              void *dst);

参数含义:

  • af:地址类型,常用 AF_INETAF_INET6
  • src:字符串格式的 IP 地址。
  • dst:保存转换结果的地址结构。

返回值:

text 复制代码
1:转换成功
0:IP 字符串格式错误
-1:地址类型 af 不支持,并设置 errno

IPv4 示例:

cpp 复制代码
#include <arpa/inet.h>
#include <iostream>

int main()
{
    struct in_addr address;

    int ret = inet_pton(AF_INET,
                        "192.168.1.10",
                        &address);

    if (ret == 1)
    {
        std::cout << "IP 转换成功" << std::endl;
    }
    else if (ret == 0)
    {
        std::cout << "IP 地址格式错误" << std::endl;
    }
    else
    {
        perror("inet_pton");
    }

    return 0;
}
相关推荐
IPdodo_25 分钟前
静态 IP 访问异常排查:403/429 归因与迁移验收
服务器·网络·数据库·python·网络协议·php·性能测试
ouynagda34 分钟前
HTTP协议与Socket网络编程笔记
网络·笔记·http
haerapi1 小时前
流式对话接口怎么选:SSE 与 WebSocket 的原理、实现和工程边界
网络·websocket·网络协议
砚凝霜1 小时前
【软考信息安全】第二章 网络攻击基础与常见技术方法
网络·安全·php
Raas1001 小时前
AI网关在架构中的位置?MAI Gateway(魔芋企业级AI网关)给出企业级答案
大数据·网络·人工智能·架构·gateway·ai网关·mai gateway
2401_868534781 小时前
安全管理策略和制度
网络·安全
LongRunning1 小时前
【Linux】RK3568(三)操作-视频流
linux
Discipline~Hai2 小时前
Linux网络编程02-TCP协议
linux·网络·tcp/ip·嵌入式·linux网络编程
johnny2332 小时前
网络扫描工具Nmap和Zenmap:简介、命令行解读、实战
网络