Linux:TCP协议的socket套接字

📅 2026/6/16 20:06:04
Linux:TCP协议的socket套接字
目录服务端init()初始化方法start()运行方法收发数据main()客户端init()初始化方法start()运行方法main()完善日志打印多进程版多线程版守护进程Deamon()实现关于套接字的介绍可以移步到下面这篇文章LinuxUDP协议的socket套接字-CSDN博客https://blog.csdn.net/suimingtao/article/details/161145821对于TCP套接字和UDP一样需要创建socket需要bind绑定ip和port但除此之外都有或多或少的区别下面就边实现边介绍声明后续代码都在有此文件的基础上进行log.hpp#pragma once // 颜色控制 #define BLACK \033[0;30;1m // 黑色 #define RED \033[0;31;1m // 红色 #define GREEN \033[0;32;1m // 绿色 #define YELLOW \033[0;33;1m // 黄色 #define BLUE \033[0;34;1m // 蓝色 #define PURPLE \033[0;35;1m // 紫色 #define CYAN \033[0;36;1m // 青色 #define WHITE \033[0;37;1m // 白色 #define BLACK_BL \033[40;1m // 背景黑色 #define RED_BL \033[41;1m // 背景红色 #define GREEN_BL \033[42;1m // 背景绿色 #define YELLOW_BL \033[43;1m // 背景黄色 #define BLUE_BL \033[44;1m // 背景蓝色 #define PURPLE_BL \033[45;1m // 背景紫色 #define CYAN_BL \033[46;1m // 背景青色 #define WHITE_BL \033[47;1m // 背景白色 #define ED \033[0m // 结束颜色控制 enum SevEx // 错误码 { USAGE_ERR 1, // 传入命令行参数错误 SOCKET_ERR 2, // socket()失败 BIND_ERR, // bind()失败 INETPN_ERR, // inet_pton/inet_ntop失败 OPENFILE_ERR, // ifstream打开文件失败 SENDTO_ERR, // sendto()失败 LISTEN_ERR, // listen()失败 ACCEPT_ERR, // accept()失败 CONNECT_ERR, // connect()失败 }; enum ERR_LEVEL // 错误等级/类型 { DEBUG 0, // 调试 NORMAL, // 正常 WARNING, // 警告 ERROR, // 非致命错误 FATAL // 致命错误 }; void LogMessage(ERR_LEVEL error, std::string message) { //[错误等级/类型] [时间戳/时间] [pid] [message] std::string color; if(error FATAL) color RED_BL; else if(error NORMAL) color GREEN_BL; std::cout color message ED std::endl; }服务端服务端的成员变量和UDP基本一致都需要套接字// typedef functionvoid (std::string) func_t;//回调函数类型 class TcpServer { public: TcpServer(uint16_t port) : _port(port) {} TcpServer(std::string ip, uint16_t port) : _ip(ip), _port(port) { } ~TcpServer() { } private: int _ListenSock; // listen监听套接字 std::string _ip 0.0.0.0; // 默认接收所有ip uint16_t _port; // 服务器端口号 // func_t _callback; };但服务端的套接字成员变量不是直接用来通信的下面会细说由于现在先不涉及对数据的再处理因此先把_callback注释掉init()初始化方法在UDP的初始化中需要socket()创建套接字bind()绑定套接字而TCP也是如此但TCP在这之后还需要进行一步listen()开启监听sockfd即为我们的监听套接字成员变量backlog为全连接队列长度这里就暂时设为5具体含义会在介绍TCP时说明只有设置了监听状态才可以接收与客户端们的连接const int gbacklog 5; // 全连接队列长度 void init() { // 创建监听套接字 _ListenSock socket(AF_INET, SOCK_STREAM, 0); if (_ListenSock -1) { LogMessage(FATAL, socket创建监听套接字失败); exit(SOCKET_ERR); } LogMessage(NORMAL, socket创建监听套接字成功); // bind绑定ipport struct sockaddr_in ServerAddr; memset(ServerAddr, 0, sizeof(ServerAddr)); ServerAddr.sin_family AF_INET; ServerAddr.sin_port htons(_port); if (inet_pton(AF_INET, _ip.c_str(), ServerAddr.sin_addr) ! 1) { LogMessage(FATAL, 点分十进制ip转网络序列失败); exit(INETPN_ERR); } LogMessage(NORMAL, 点分十进制ip转网络序列成功); if (bind(_ListenSock, (struct sockaddr *)ServerAddr, sizeof(ServerAddr)) ! 0) { LogMessage(FATAL, bind绑定失败); exit(BIND_ERR); } LogMessage(NORMAL, bind绑定成功); // 开启监听状态 if (listen(_ListenSock, gbacklog) ! 0) { LogMessage(FATAL, listen监听状态开启失败); exit(LISTEN_ERR); } LogMessage(NORMAL, listen监听状态开启成功); }start()运行方法在TCP中接收来自客户端的数据前需要先跟目标客户端建立连接accept()就可以取出已经建立好的连接并返回一个套接字描述符用于通信sockfd需要传入监听套接字文件描述符addr和addrlen类似于recvfrom中的addr和addrlen都是输入输出型参数用于获取客户端的addr信息void start() { // 建立连接 struct sockaddr_in ClientAddr; memset(ClientAddr, 0, sizeof(ClientAddr)); socklen_t socklen sizeof(ClientAddr); int sockfd accept(_ListenSock, (struct sockaddr *)ClientAddr, socklen); if (sockfd -1) { LogMessage(FATAL, accept建立新连接失败); exit(ACCEPT_ERR); } LogMessage(NORMAL, accept建立新连接成功); linkone(sockfd, ClientAddr);//持续接收客户端的消息 }获取新连接后就要从accpet的返回值的套接字中读取数据并处理收发数据TCP收发数据的方式有很多其中read/write就是一种因为sockfd本质上也是文件描述符而read/write是从文件中读写数据void linkone(int sockfd, sockaddr_in addr) { uint16_t port ntohs(addr.sin_port); char buffer[65] {0}; const char *ip inet_ntop(AF_INET, addr.sin_addr, buffer, sizeof(buffer)); if(ip nullptr) { LogMessage(FATAL, 网络序列ip转点分十进制失败); exit(INETPN_ERR); } LogMessage(NORMAL, 网络序列ip转点分十进制成功); //收发数据 while (true) { char message[1024] {0}; //读取数据 int n read(sockfd, message, sizeof(message)); //数据处理并返回 if (n 0) { message[n] 0; std::cout BLUE [ ip - port ]# ED PURPLE message ED std::endl; write(sockfd, message, sizeof(message)); } else//如果read返回值为0代表对方进程结束 { std::cout RED_BL 客户端退出... ED std::endl; close(sockfd); break; } } }main()对于主函数逻辑和UDP时一样#include iostream #include TcpServer.hpp using namespace std; using namespace Server; void usage(string proc) // 使用手册 { cout GREEN \nUsage: \n\t ED RED proc [port]\n\n ED; } int main(int argc, char *argv[]) { if (argc ! 2) { usage(argv[0]); exit(USAGE_ERR); } uint16_t port atoi(argv[1]);//字符串port转整数 TcpServer server(port); server.init(); server.start(); return 0; }客户端对于客户端而言成员变量和UDP时完全一样class TcpClient { public: TcpClient(std::string ip, uint16_t port) : _ip(ip), _port(port) { } ~TcpClient() { } private: int _SocketFd; // 通信的套接字 std::string _ip; // 服务端的ip uint16_t _port; // 服务端的端口号 struct sockaddr_in _ServerAddr; // 服务端的addr };init()初始化方法TCP的初始化与UDP时完全一样创建套接字后无需显式绑定ipport在第一次connect()时会由OS自动绑定void init() { // 创建套接字 _SocketFd socket(AF_INET, SOCK_STREAM, 0); if (_SocketFd -1) { LogMessage(FATAL, socket创建套接字失败); exit(SOCKET_ERR); } LogMessage(NORMAL, socket创建套接字成功); // 无需显式bind绑定 // 初始化服务端的addr memset(_ServerAddr, 0, sizeof(_ServerAddr)); _ServerAddr.sin_family AF_INET; _ServerAddr.sin_port htons(_port); if (inet_pton(AF_INET, _ip.c_str(), _ServerAddr.sin_addr) ! 1) { LogMessage(FATAL, 点分十进制ip转网络序列失败); exit(INETPN_ERR); } LogMessage(NORMAL, 点分十进制ip转网络序列成功); }start()运行方法TCP中要想服务器发送数据需要先建立连接建立连接就需要用到connect()sockfd传入套接字描述符addr和addrlen类似于sendto时的addr和addrlen用于指定要连接的服务端的addrvoid start() { // 向服务器申请建立连接 if (connect(_SocketFd, (struct sockaddr *)_ServerAddr, sizeof(_ServerAddr)) ! 0) { LogMessage(FATAL, connect申请建立连接失败); exit(CONNECT_ERR); } LogMessage(NORMAL, connect申请建立连接成功); // 收发消息 std::string message; while (true) { // 写入 std::cout RED 请输入文本# ED; std::getline(std::cin, message); write(_SocketFd, message.c_str(), message.size()); // 读取 char buffer[1024] {0}; read(_SocketFd, buffer, sizeof(buffer)); message std::string(buffer) [Server Echo]; std::cout PURPLE message ED std::endl; } }main()对于TCP的main与UDP一样#include iostream #include TcpClient.hpp using namespace std; using namespace Client; void usage(string proc) // 使用手册 { cout GREEN \nUsage:\n\t ED RED proc [ip] [port]\n\n ED; } int main(int argc, char *argv[]) { if (argc ! 3) { usage(argv[0]); exit(USAGE_ERR); } uint16_t port atoi(argv[2]); TcpClient client(argv[1], port); client.init(); client.start(); return 0; }当启动客户端与服务端后用netstat命令查看当前连接就可以看到8080端口的TcpClient和向8080端口发连接的TcpClient了完善日志打印现有的log.hpp仅完成了打印消息这一个功能而实际中日志还应该包含日期错误等级等信息关于日期时间的打印time(nullptr)可以拿到当前时间的时间戳再通过localtime()将对应时间戳转换为带有日期时间字段的结构体struct tm结构体的字段如下之后再通过该结构体的字段将年月日时分秒拼接成一个字符串即为时间打印std::string DateTime(time_t timesatamp) // 获取对应时间戳的日期-时间 { struct tm *t localtime(timesatamp); // 2026/5/28-20:39:01 std::string year std::to_string(t-tm_year 1900); // 除了年份必须要保持始终为两位数不够用0补齐 std::string month (std::to_string(t-tm_mon).size() 1) ? (0 std::to_string(t-tm_mon)) : (std::to_string(t-tm_mon)); // 月 始终是两位数 std::string day (std::to_string(t-tm_mday).size() 1) ? (0 std::to_string(t-tm_mday)) : (std::to_string(t-tm_mday)); // 日 始终是两位数 std::string hour (std::to_string(t-tm_hour).size() 1) ? (0 std::to_string(t-tm_hour)) : (std::to_string(t-tm_hour)); // 时 始终是两位数 std::string min (std::to_string(t-tm_min).size() 1) ? (0 std::to_string(t-tm_min)) : (std::to_string(t-tm_min)); // 分 始终是两位数 std::string sec (std::to_string(t-tm_sec).size() 1) ? (0 std::to_string(t-tm_sec)) : (std::to_string(t-tm_sec)); // 秒 始终是两位数 std::string now year / month / day - hour : min : sec; return now; }void LogMessage(ERR_LEVEL error, char *format, ...) // 可变参数列表 { //[错误等级/类型] [时间戳/时间] [pid] [message] // 取得错误等级字符串/颜色 std::string errLevel, color; switch (error) { case DEBUG: errLevel DEBUG; color CYAN_BL; break; case WARNING: errLevel WARNING; color YELLOW_BL; break; case ERROR: errLevel ERROR; color RED_BL; break; case FATAL: errLevel FATAL; color PURPLE_BL; break; default: errLevel DEBUG; color CYAN_BL; } // 日志类型 char logprefix[1024] {0}; snprintf(logprefix, sizeof(logprefix), [%s] [%s] [pid: %d], errLevel.c_str(), DateTime(time(nullptr)).c_str(), getpid()); // TODO }现在对于日志的类型字段就打印完成了对于日志的消息原来只能完成固定字符串的打印如果想要支持类似printf的格式化打印就需要用到可变参数列表void LogMessage(ERR_LEVEL error, char *format, ...) // 可变参数列表 { // ...... }C 语言函数调用时参数通常从右向左压入栈中对于上面的LogMessage函数栈顶固定为error参数函数内部就可以通过error的位置来确定栈顶。但如果从左向右压栈栈顶为可变参数的最后一个参数因为可变参数的数量和类型在编译时是未知的函数内部就无法确定栈顶在哪里。可以说C设计成从右向左压栈正是为了支持可变参数C语言提供了va_list、va_start()、va_end()、va_arg()等宏来支持可变参数va_list用于定义一个可变参数的指针该宏本质上就是char*va_start()用于初始化一个va_list类型通过传入可变参数的上一个参数从而找到可变参数的第一个参数在这里就是传入va_start(va_list类型, format)因为format是可变参数列表前的最后一个参数va_end()用于清理va_list类型变量LogMessage最终实现void LogMessage(ERR_LEVEL error, char *format, ...) // 可变参数列表 { //[错误等级/类型] [日期时间] [pid] [message] // 取得错误等级字符串/颜色 std::string errLevel, color; switch (error) { case DEBUG: errLevel DEBUG; color CYAN_BL; break; case WARNING: errLevel WARNING; color YELLOW_BL; break; case ERROR: errLevel ERROR; color RED_BL; break; case FATAL: errLevel FATAL; color PURPLE_BL; break; default: errLevel DEBUG; color CYAN_BL; } // 日志类型 char logprefix[1024] {0}; snprintf(logprefix, sizeof(logprefix), [%s] [%s] [pid: %d], errLevel.c_str(), DateTime(time(nullptr)).c_str(), getpid()); // 日志信息 char logcontent[1024] {0}; va_list arg; va_start(arg, format); // 将 arg 定位到 format 参数之后的位置 vsnprintf(logcontent, sizeof(logcontent), format, arg); // va_list充当可变参数列表 va_end(arg); std::cout color logprefix # logcontent ED std::endl; }多进程版在上面实现的服务端中同时有且只能有一个客户端同时进行通信而在实际应用中往往有多个客户端同时连接着服务端要实现多进程版就需要fork创建子进程。当accept获取到新连接后需要fork让子进程去执行该客户端套接字描述符的收发数据工作但如果仅让子进程运行而不处理其终止状态父进程仍需要通过wait回收子进程资源否则会产生僵尸进程。若采用阻塞式等待则与之前情况相同——必须等待当前客户端断开连接后才能建立新连接。若采用非阻塞式等待当多个客户端连接服务端时由于非阻塞等待会立即返回父进程将直接阻塞在accept调用处。此时若不再有新连接先前未成功回收的子进程将永远无法被等待最终导致僵尸进程堆积的问题。这里采用一种很巧妙的方法让子进程再fork创建孙子进程再让子进程直接退出由孙子进程执行客户端的收发数据任务。由于孙子进程变成了孤儿进程被OS领养当退出时由OS回收资源就不会出现僵尸进程了void start() { signal(SIGCHLD, SIG_IGN);// OS自动回收子进程资源 while (true) { // 建立连接 struct sockaddr_in ClientAddr; memset(ClientAddr, 0, sizeof(ClientAddr)); socklen_t socklen sizeof(ClientAddr); int sockfd accept(_ListenSock, (struct sockaddr *)ClientAddr, socklen); if (sockfd -1) { LogMessage(FATAL, (char *)accept建立新连接失败, 错误码: %d, 错误描述%s, errno, strerror(errno)); exit(ACCEPT_ERR); } LogMessage(DEBUG, (char *)accept建立新连接成功,sockfd %d, sockfd); pid_t pid fork(); if (pid 0) // 子进程 { close(_ListenSock); // 关掉无用文件描述符 if (fork() 0) // 子进程本身 exit(0); // 孙子进程,被OS领养,不等待也不会变成僵尸进程 linkone(sockfd, ClientAddr); } close(sockfd); // 父进程关掉该文件描述符防止文件描述符被用完 } }这里有个细节几乎每次accpet返回的套接字描述符都是4这是因为父进程每次都会关闭新接收的套接字描述符交给孙子进程为什么是4因为0/1/2被标准输入/输出/错误占用3被监听套接字占用多线程版在多进程版中每有一个新客户端都要fork创建子进程这开销还是太大了因此下面用多线程实现一波实际业务中多线程只适用于可以一瞬间完成的任务这种需要持续存在的任务其实并不适合...多线程部分用本篇文章实现的线程池demoLinux生产者消费者模型-CSDN博客https://blog.csdn.net/suimingtao/article/details/160381695把Task.hpp改为处理的客户端通信任务#pragma once #include functional #include iostream #include string #include sys/types.h #include sys/socket.h #include arpa/inet.h #include netinet/in.h #include log.hpp void linkone(int sockfd, struct sockaddr_in addr) { uint16_t port ntohs(addr.sin_port); char buffer[65] {0}; const char *ip inet_ntop(AF_INET, addr.sin_addr, buffer, sizeof(buffer)); if (ip nullptr) { LogMessage(FATAL, (char *)网络序列ip转点分十进制失败, 错误码: %d, 错误描述%s, errno, strerror(errno)); exit(INETPN_ERR); } LogMessage(DEBUG, (char *)网络序列ip转点分十进制成功); // 收发数据 while (true) { char message[1024] {0}; // 读取数据 int n read(sockfd, message, sizeof(message)); // 数据处理并返回 if (n 0) { message[n] 0; std::cout BLUE [ ip - port ]# ED PURPLE message ED std::endl; write(sockfd, message, sizeof(message)); } else // 如果read返回值为0代表对方进程结束 { std::cout RED_BL 客户端退出... ED std::endl; close(sockfd); break; } } } class Task // 计算任务类型 { using func_t std::functionvoid(int, struct sockaddr_in); public: Task(int sockfd, struct sockaddr_in addr, func_t fun) : _sockfd(sockfd), _addr(addr), _callback(fun) { } Task() { } void operator()() // 仿函数返回结果描述 { _callback(_sockfd, _addr); } // std::string toop() // 返回要处理的任务描述 // { // char buffer[64]; // snprintf(buffer, sizeof(buffer), %d %c %d ?, _x, _op, _y); // return buffer; // } private: int _sockfd; struct sockaddr_in _addr; func_t _callback; // 回调函数 };在TcpServer.hpp中添加对线程池的初始化并且在每次accept获取新连接成功后往线程池中push一个任务void start() { //初始化线程池 ThreadPoolTask::getInstance().runc(); signal(SIGCHLD, SIG_IGN);// OS自动回收子进程资源 while (true) { // 建立连接 struct sockaddr_in ClientAddr; memset(ClientAddr, 0, sizeof(ClientAddr)); socklen_t socklen sizeof(ClientAddr); int sockfd accept(_ListenSock, (struct sockaddr *)ClientAddr, socklen); if (sockfd -1) { LogMessage(FATAL, (char *)accept建立新连接失败, 错误码: %d, 错误描述%s, errno, strerror(errno)); exit(ACCEPT_ERR); } LogMessage(DEBUG, (char *)accept建立新连接成功,sockfd %d, sockfd); Task t(sockfd, ClientAddr, linkone); ThreadPoolTask::getInstance().push(t); //close(sockfd); // 父进程关掉该文件描述符防止文件描述符被用完 } }在TcpServer启动后用ps -aL就可以看到已经在运行的线程线程池内设置成了默认启动10个线程守护进程在实际业务中的服务器启动后即使关闭远程的ssh连接服务器进程也不会退出。但我们上面实现的服务器如果启动后关闭xshell窗口服务器进程就会一起退出。为了让进程不会随ssh连接而退出就要将该进程变为守护进程每个进程都有自己的组ID例如sleep 1000 | sleep 2000 | slepp 3000运行起来后用ps命令查看时会发现他们的PGID一样并且为sleep 1000 的PID代表让命令在后台运行它们三个进程要共同完成这一个任务这里的PGID就是该作业的组IDsleep 1000是该组第一个被启动的进程因此它就是组长PGID 就是组长的 PIDSID为会话号Session ID下面会介绍会话概念除了可以这么查看之外还可以用jobs命令查看当前终端会话中所有正在后台运行或已暂停的作业每个作业都有作业号最前面的[ ]内的数字号分配给最近一次被挂起按 CtrlZ或放入后台加 的作业-号分配给前一个/次当前作业即倒数第二近被操作的作业。如果想将在后台的作业先放回前台可以用fg命令若直接输入fg会调回默认作业带号的作业或输入fg %[作业号]调回指定作业在fg命令中可以省略%kill %[作业号]可以终止指定作业对于前台任务按下CtrlZ可以暂停该任务若想对暂停的任务继续运行可以用bg命令将一个已经暂停Stopped的作业放到后台去继续运行例如下面程序在前台运行时我用Ctrl Z暂停该作业再用bg命令让该作业在后台继续运行用法和fg类似拿xshell来举例每一个窗口都是一个会话每个会话都有一个bash进程用于解释命令行对于在终端输入的命令它的 PPID 即为bash且每个会话有且只能有一个前台进程默认为bash当有进程要在前台启动时bash会自动去后台这也就是为什么进程启动后不能再输入命令当xshell窗口关闭时该会话也会自动关闭里面的任务自然也会关闭若想不受ssh登录注销的影响可以让该进程自成会话自成进程组此时这个进程就叫作守护进程虽然Linux也提供用于创建守护进程的接口daemon()但这个接口实在太老旧了因此一般都选择自己手写一个daemon接口nochdir守护进程更改后的工作目录noclose若为0则关闭0/1/2文件描述符并重定向到/dev/null下面会介绍否则不关闭Deamon()实现创建守护进程必不可少的接口setsid()接口可以让调用它的进程离开当前所在的作业独自成立会话成为该作业进程组组长需要注意的是调用setsid()的接口的进程不能是该作业进程组的组长#pragma once #include string #include unistd.h #include fcntl.h #include cstdlib #include csignal #include log.hpp #define DUP_PATH /dev/null // 黑洞文件相当于垃圾桶 void DaemonSelf(std::string nochdir std::string() /*要更改为的工作目录*/, bool noclose false /*是否要重定向std in/out/err*/) { // 让调用进程忽略掉异常的信号 signal(SIGPIPE, SIG_IGN); // 让进程不是组长从而调用setsid() if (fork() 0) exit(0); // 子进程 int pid setsid(); if (pid -1) { LogMessage(FATAL, setsid()创建会话失败); exit(SETSID_ERR); } // 此时该进程是新会话的Session Leader首进程需要再次fork脱离Session Leader角色 if (fork() 0) exit(0); // 孙子进程此时彻底与终端绝缘 //如果设置了工作目录就更改 if(!nochdir.empty()) if(!chdir(nochdir.c_str()))//若chdir失败报错 LogMessage(ERROR, chdir修改工作目录失败); // 如果没有设定不关闭就重定向进程的标准输出/输出/错误 if(!noclose) { int fd open(DUP_PATH, O_RDWR); if(fd -1) { LogMessage(ERROR, 打开重定向文件失败); } else { //重定向std in/out/err到该文件中 dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); if(fd STDERR_FILENO) // fd不是0/1/2其中一个才可以关闭 close(fd); } } }/dev/null被称为黑洞文件不管是读取还是写入都无视掉是Linux的安全垃圾桶在启动服务端时让进程变为守护进程这样即使退出ssh终端也不会因此关闭服务端进程int main(int argc, char *argv[]) { //...... server.init(); DaemonSelf(); //使该进程变为守护进程 server.start(); return 0; }