Linux TcpSocket编程

一.TCP套接字基础与echo系统

1.什么是TCP套接字

TCP 套接字(TCP Socket)是基于 TCP(传输控制协议)的网络通信接口,用于在网络中实现可靠的、面向连接的双向数据传输。它屏蔽了底层网络细节,让应用程序能通过简单接口进行跨网络通信。它的工作方式如下:

1.1创建套接字socket()

创建套接字对象,返回文件描述符

cpp 复制代码
int socket(int domain, int type, int protocol);
  • domain:协议族,TCP 用 AF_INET(IPv4)或 AF_INET6(IPv6)。
  • type:套接字类型,TCP 用 SOCK_STREAM(流式套接字,保证有序、可靠)。
  • protocol:指定协议,TCP 填 0(自动匹配 SOCK_STREAM 对应的 TCP)。

1.2绑定bind()

绑定本地 IP 地址和端口(服务器端必需,客户端可选)

cpp 复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfdsocket() 返回的套接字文件描述符。
  • addr:结构体,包含本地 IP 和端口(如 struct sockaddr_in 用于 IPv4)。

1.3服务端监听listen()

服务器端监听端口,将套接字转为被动模式,等待客户端连接。

cpp 复制代码
int listen(int sockfd, int backlog);
  • backlog:最大等待连接队列长度(超过则新连接被拒绝)。
  • 当一个TCP服务器处于Listen状态,这个服务器就可以被连接(例如用telnet连接,或者用客户端连接).

1.4服务端接收客户端连接accept()

服务器端接收客户端连接,返回新的文件描述符(用于与该客户端通信)。

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

注意:accept获取现有的连接,并且连接是从内核直接获取的,建立连接的过程与accept无关

对于accept的参数:当接收连接时,我们要知道连接来自哪(sockaddr)以及创建套接字的返回值sockfd。

最重要的是:accept的返回值,如果获取连接成功,该系统调用会返回一个合法整数------它也是一个文件描述符。

问题:为什么TCP在获取连接成功后,还会生成一个新的文件描述符?它和创建socket返回的文件描述符有什么区别?

这就好比:张三是在饭店外拉客的前台人员,当路上有路人经过时他就开始拉客,张三就属于被动连接的套接字;当拉客这一行为(accept)成功时,就创建新的服务员(新套接字)为连接服务。总的来说,监听套接字sockfd是连接入口 ,而accept返回的fd是通信通道

注意:当accept获取连接失败,不会阻塞或退出,他会立马等待下一个连接。

1.5客户端主动连接服务器connect()

cpp 复制代码
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • addr:服务器的 IP 和端口。

1.6收发数据**send()/recv()write()/read()**

cpp 复制代码
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

1.7关闭套接字close()

cpp 复制代码
int close(int sockfd);

1.8使用过程

  1. 服务器端步骤

    • 调用 socket() 创建套接字。
    • 调用 bind() 绑定本地 IP 和端口。
    • 调用 listen() 开始监听。
    • 循环调用 accept() 接收客户端连接(返回新套接字)。
    • 通过新套接字用 recv()/send() 与客户端通信。
    • 通信结束后用 close() 关闭连接。
  2. 客户端步骤

    • 调用 socket() 创建套接字。
    • 调用 connect() 连接服务器的 IP 和端口。
    • 通过套接字用 send()/recv() 与服务器通信。
    • 通信结束后用 close() 关闭连接。

特点 :TCP 套接字通过三次握手建立连接,保证数据可靠传输(重传、排序、流量控制),适合需要可靠通信的场景(如 HTTP、文件传输)。

1.9三次握手

1. 服务器端准备(触发握手的前提)

服务器需先通过 socket()bind()listen() 完成初始化,处于 "监听状态",等待客户端连接:

cpp 复制代码
// 1. 创建监听套接字(未涉及握手,仅初始化资源)
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

// 2. 绑定本地 IP 和端口(确定握手的"目标端口")
struct sockaddr_in server_addr;
// 初始化 server_addr(设置 IP、端口等)
bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));

// 3. 进入监听状态(关键:此时套接字转为被动模式,允许接收连接请求)
listen(listen_fd, 5);  // 第二个参数为等待队列长度
  • 此时:服务器的 TCP 协议栈已准备好,可接收客户端的连接请求(三次握手的 "入口" 已打开)。
2. 客户端发起连接(第一次握手)

客户端通过 connect() 接口主动向服务器发起连接,触发第一次握手:

cpp 复制代码
// 1. 创建客户端套接字
int client_fd = socket(AF_INET, SOCK_STREAM, 0);

// 2. 初始化服务器地址(目标 IP 和端口)
struct sockaddr_in server_addr;
// 设置 server_addr 为服务器的 IP 和端口

// 3. 发起连接(触发第一次握手)
connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
  • connect() 调用后 :客户端 TCP 协议栈自动向服务器发送 SYN 报文 (第一次握手),包含客户端的初始序列号(seq = x)。
  • 此时客户端进入 SYN_SENT 状态,等待服务器响应。
3. 服务器接收请求并响应(第二次握手)

服务器通过 accept() 接口等待客户端连接,在底层收到 SYN 后自动完成第二次握手:

cpp 复制代码
// 服务器阻塞等待客户端连接(底层处理握手)
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);
  • accept() 阻塞期间 :服务器 TCP 协议栈收到客户端的 SYN 报文后,会自动发送 SYN + ACK 报文 (第二次握手):
    • 包含服务器的初始序列号(seq = y);
    • 包含对客户端 SYN 的确认(ack = x + 1)。
  • 此时服务器进入 SYN_RCVD 状态,等待客户端的最终确认。
4. 客户端确认(第三次握手)

客户端收到服务器的 SYN + ACK 后,由 TCP 协议栈自动完成第三次握手,随后 connect() 返回:

  • 客户端 TCP 协议栈自动发送 ACK 报文 (第三次握手),包含对服务器 SYN 的确认(ack = y + 1)。
  • 此时客户端进入 ESTABLISHED 状态,connect() 调用成功返回(不再阻塞)。
5. 服务器完成握手(accept() 返回)

服务器收到客户端的 ACK 报文后:

  • 服务器 TCP 协议栈进入 ESTABLISHED 状态,完成三次握手。
  • 此时 accept() 调用成功返回,返回一个新的套接字(client_fd),用于与该客户端通信。

2.多版本的echo系统

2.1服务器端流程(TcpServer.cc

1. 初始化阶段
cpp 复制代码
// 创建监听socket
_listensockfd = socket(AF_INET, SOCK_STREAM, 0);

// 绑定地址
InetAddr local(_port);
bind(_listensockfd, local.NetAddrPtr(), local.NetAddrLen());

// 开始监听
listen(_listensockfd, backlog);
2. 运行阶段 - 三种并发模型

多进程版本

cpp 复制代码
            pid_t id = fork(); // 父进程
            if(id < 0)
            {
                LOG(LogLevel::FATAL) << "fork error";
                exit(FORK_ERR);
            }
            else if(id == 0)
            {
                // 子进程,子进程除了看到sockfd,能看到listensockfd吗??
                // 我们不想让子进程访问listensock!
                close(_listensockfd);
                if(fork() > 0) // 再次fork,子进程退出
                    exit(OK);

                Service(sockfd, addr); // 孙子进程,孤儿进程,1, 系统回收我
                exit(OK);
            }
            else
            {
                //父进程
                close(sockfd);

                //父进程是不是要等待子进程啊,要不然僵尸了
                pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?不会,因为子进程立马退出了
                (void)rid;
            }

进程如果退出,文件默认会被自动释放,fd会自动关闭

该进程的子进程会继承父进程的资源,是可以拿fd进行访问的

换句话说,子进程可以看到父进程的sockfd和listensockfd。

当父子进程并发,子进程执行Service,父进程获取链接,需要各自关闭各自不需要的sockfd。

一个问题:子进程退出,父进程要等待,否则会变成僵尸进程,这样就不是串行了。

我们知道,子进程退出时会向父进程发送一个信号,只需要将这个信号的处理方式改为忽略即可,这样就实现了并发。

第二种解决方式:让子进程fork孙进程,然后自己马上退出。这样父进程就不会被阻塞,而孙进程变成了孤儿进程,会被系统回收。

多线程版本

两个问题:如果进程打开了一个文件,得到了一个fd,线程可以看到吗?

线程能关闭自己不需要的fd吗?

可以看到fd。但是他不能关闭!因为线程只是新增了pcb,还是共享一个地址空间,关闭fd会影响其他线程。

cpp 复制代码
ThreadData *td = new ThreadData(sockfd, addr, this);
            pthread_t tid;
            pthread_create(&tid, nullptr, Routine, td);

//子进程执行Routine方法
//Routine回调Service
 static void *Routine(void *args)
    {
        pthread_detach(pthread_self());
        ThreadData *td = static_cast<ThreadData *>(args);
        td->tsvr->Service(td->sockfd, td->addr);
        delete td;
        return nullptr;
    }

线程池版本

将Service作为任务入队列

cpp 复制代码
ThreadPool<task_t>::GetInstance()->Enqueue([this, sockfd, &addr](){
    this->Service(sockfd, addr);
});
3.服务处理
cpp 复制代码
void Service(int sockfd, InetAddr &peer) {
    while (true) {
        ssize_t n = read(sockfd, buffer, sizeof(buffer)-1);  // 读取请求
        std::string echo_string = _func(buffer, peer);       // 业务处理
        write(sockfd, echo_string.c_str(), echo_string.size()); // 返回响应
    }
}

2.2客户端流程(TcpClient.cc)

cpp 复制代码
// 创建socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

// 连接服务器
InetAddr serveraddr(serverip, serverport);
connect(sockfd, serveraddr.NetAddrPtr(), serveraddr.NetAddrLen());

// 通信循环
while (true) {
    write(sockfd, line.c_str(), line.size());    // 发送请求
    read(sockfd, buffer, sizeof(buffer)-1);      // 接收响应
}

2.3设计细节

根据服务器无法拷贝的特性,让TcpServer继承NoCopy,为了达到无法拷贝,无法赋值的目的。

cpp 复制代码
class NoCopy
{
public:
    NoCopy(){}
    ~NoCopy(){}
    NoCopy(const NoCopy &) = delete;
    const NoCopy &operator = (const NoCopy&) = delete;
};

二.翻译系统与远程执行命令系统

切换不同的模块,只需要改变服务端的执行方法即可。

1.翻译系统

我们只要将上一章写的Dict.hpp模块导入,然后在服务端做如下改变即可:创建字典对象d,加载字典LoadDict,创建TcpServer对象,并用lambda传参,将消息的处理方法改为字典中的Translate方法。

cpp 复制代码
Dict d;
d.LoadDict();

 std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port, [&d](const std::string &word, InetAddr &addr){
         return d.Translate(word, addr);
     });
tsvr->Init();
tsvr->Run();

2.远程执行命令系统

编写远程执行命令的模块:Command.hpp。

按照白名单的方式限制用户的命令。最核心的功能实现在于popen接口:

cpp 复制代码
FILE *popen(const char *command, const char *mode);
  • command:字符串,指定要执行的外部命令(如 "ls -l""grep hello")。
  • mode:字符串,指定管道的方向,只能是 "r""w"
    • "r":父进程从管道读取子进程的输出(子进程的 stdout 被重定向到管道)。
    • "w":父进程通过管道向子进程写入数据(子进程的 stdin 被重定向到管道)。

简单来说,popen的原理就是一个精简版的myshell:

  1. 创建管道(pipe) :调用 pipe() 系统调用创建一个匿名管道,包含两个文件描述符:fd[0](读端)和 fd[1](写端)。管道是内核中的一块缓冲区,用于进程间单向通信。

  2. 创建子进程(fork) :调用 fork() 创建子进程,子进程复制父进程的资源(包括管道的文件描述符)。

  3. 重定向子进程的 IO :根据 mode 参数修改子进程的文件描述符,将其标准输入 / 输出与管道绑定:

    • mode"r":子进程关闭管道的读端(fd[0]),将自己的标准输出(stdout,文件描述符 1)通过 dup2() 重定向到管道的写端(fd[1])。此后,子进程的输出会写入管道。
    • mode"w":子进程关闭管道的写端(fd[1]),将自己的标准输入(stdin,文件描述符 0)通过 dup2() 重定向到管道的读端(fd[0])。此后,子进程会从管道读取输入。
  4. 子进程执行命令(exec) :子进程调用 execl("/bin/sh", "sh", "-c", command, (char*)NULL) 执行外部命令:通过 shell 解析 command 字符串(支持管道、重定向等 shell 语法)。

  5. 父进程处理管道 :父进程关闭管道中未使用的一端(与子进程相反),并将另一端封装为 FILE* 流返回。例如:

    • mode"r":父进程关闭写端(fd[1]),用读端(fd[0])创建读流。
    • mode"w":父进程关闭读端(fd[0]),用写端(fd[1])创建写流。
  6. 通信与关闭 :父进程通过返回的 FILE* 流与子进程通信(读 / 写数据)。完成后需调用 pclose() 关闭流,pclose() 会等待子进程结束并释放资源。

执行命令模块:

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstdio>
#include <set>
#include "Command.hpp"
#include "InetAddr.hpp"
#include "Log.hpp"

using namespace LogModule;

class Command
{
public:
    // ls -a && rm -rf
    // ls -a; rm -rf
    Command()
    {
        // 严格匹配
        _WhiteListCommands.insert("ls");
        _WhiteListCommands.insert("pwd");
        _WhiteListCommands.insert("ls -l");
        _WhiteListCommands.insert("touch haha.txt");
        _WhiteListCommands.insert("who");
        _WhiteListCommands.insert("whoami");
    }
    bool IsSafeCommand(const std::string &cmd)
    {
        auto iter = _WhiteListCommands.find(cmd);
        return iter != _WhiteListCommands.end();
    }
    std::string Execute(const std::string &cmd, InetAddr &addr)
    {
        // 1. 属于白名单命令
        if(!IsSafeCommand(cmd))
        {
            return std::string("坏人");
        }

        std::string who = addr.StringAddr();

        // 2. 执行命令
        FILE *fp = popen(cmd.c_str(), "r");
        if(nullptr == fp)
        {
            return std::string("你要执行的命令不存在: ") + cmd;
        }
        std::string res;
        char line[1024];
        while(fgets(line, sizeof(line), fp))
        {
            res += line;
        }
        pclose(fp);
        std::string result = who + "execute done, result is: \n" + res;
        LOG(LogLevel::DEBUG) << result;
        return result;
    }
    ~Command()
    {}
private:
    // 受限制的远程执行
    std::set<std::string> _WhiteListCommands;
};

TcpServer需要做的改变:

cpp 复制代码
Command cmd;
  
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port,
                                                                  std::bind(&Command::Execute, &cmd, std::placeholders::_1, std::placeholders::_2));
相关推荐
A小辣椒2 天前
TShark:Wireshark CLI 功能
linux
A小辣椒2 天前
TShark:基础知识
linux
AlfredZhao2 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao3 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334663 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪3 天前
linux 拷贝文件或目录到指定的位置
linux
大树883 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠3 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush43 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5203 天前
Linux 11 动态监控指令top
linux