资讯动态

手写跨平台C++ TcpSocket类:RAII封装与超时控制

发布时间:2026/10/9 3:21:43 来源:尧图企业网站定制
简介这是一份面向C初学者与网络编程入门者的轻量级Socket封装实践资源聚焦TCP通信基础能力构建帮助开发者快速掌握跨平台网络编程核心逻辑。资源以简洁的类封装形式实现socket连接、数据收发与错误处理等关键功能适用于局域网聊天工具、客户端-服务器模型实验等典型学习场景。压缩包仅2KB含2个核心文件头文件MySocket.h定义接口与成员变量源文件MySocket.cpp实现具体通信逻辑结构清晰、注释充分便于理解底层API调用与面向对象设计结合方式。目前已有340人学习下载读者可直接复用该类快速搭建简易网络应用原型无需从零编写原始socket代码同时通过阅读源码深入理解connect/send/recv等系统调用的封装思路与异常处理机制。1. C Socket 类不是封装个 connect() 就叫“类”而是让网络通信像 std::vector 一样可复用、可调试、可嵌入生产环境你写过socket()、bind()、listen()、accept()这一串裸系统调用吗写完发现每次新项目都要重抄一遍错误处理逻辑超时控制靠select()硬套但 timeout 值总设错客户端断连后服务端recv()返回 0 却没触发清理更别说多线程下 fd 被重复 close 导致EBADF—— 这不是“会 socket 编程”这是在维护一个随时可能崩塌的黑匣子。真正的 C Socket 类核心不在“把系统调用包一层”而在于把网络通信的生命周期、错误语义、资源边界、线程安全全部收束进 RAII 容器里。它要能直接放进std::vectorSocket能被std::unique_ptr管理能在 RAII 析构时自动closesocket()Windows或close()Linux能用operator bool()判断连接状态能抛出带 errno 映射的std::system_error而非裸int返回码。本文不讲 Winsock API 手册也不堆砌SO_REUSEADDR参数说明我们从零手写一个跨平台、可调试、带超时、支持阻塞/非阻塞切换、且能无缝集成进现代 C 项目的TcpSocket类——它不是玩具是我在三个工业级设备通信中间件中实际落地的最小可行骨架。如果你正卡在“C 怎么优雅地做网络通信”这个坎上这篇就是你该抄的第一份作业。2. 从裸系统调用到 RAII 封装为什么必须自己写而不是用 Boost.Asio 或 Poco2.1 为什么不用 Boost.Asio——不是它不好而是它太重、太抽象、太难 debugBoost.Asio 是工业级标杆但它默认启用asio::io_contextasync_read的异步模型对初学者而言post()、dispatch()、strand的调度语义像玄学。更现实的问题是当你在嵌入式设备上跑一个只用 3 个 TCP 连接的 Modbus TCP 代理时引入整个 Boost 依赖编译时间 静态库体积 8MB只为调用connect()和read_some()性价比极低。我曾在一个 ARM Cortex-A9 平台上实测仅链接libboost_system.a就让最终固件体积增加 1.2MB而整个业务逻辑才 400KB。Asio 的error_code机制虽好但asio::error::make_error_code(asio::error::connection_refused)这种写法在 gdb 里单步时根本看不到底层WSAETIMEDOUT对应的 Windows 错误码 10060。你调试的不是网络问题是在调试 Asio 的错误映射表。这不是设计缺陷而是为通用性付出的代价。2.2 为什么不用 Poco——它的 Socket 类看似简洁但隐藏了太多平台细节Poco 的Poco::Net::StreamSocket看起来很美connect()、send()、receive()一行搞定。但它的connect()默认不设超时底层用select()模拟但 timeout 参数藏在connectTimeout()方法里且 Linux 下connect()本身不支持SO_SNDTIMEO导致你在高丢包环境下等 30 秒才返回Connection refused。更致命的是Poco 在 Windows 上强制依赖Winsock2.h但它的SocketImpl内部用HANDLE封装 socket导致你无法直接用WSAEventSelect()注册 I/O 事件——这在需要与现有 Windows GUI 消息循环如PeekMessage()WSACreateEvent()集成时成了死结。我曾为某 HMI 设备移植 Poco结果发现其receiveBytes()在MSG_WAITALL模式下当对方发来 1024 字节但网络只到达 512 字节时会无限阻塞因为 Poco 没正确处理recv()返回值小于请求长度的场景。封装不是掩盖问题而是把问题显式暴露给使用者。2.3 我们自己写的 Socket 类要守住哪三条底线RAII 必须严格构造函数只做socket()析构函数必须closesocket()/close()且保证多次析构不 crashfd -1后再 close 安全错误必须可追溯每个系统调用失败立即throw std::system_error(errno, std::generic_category(), connect failed)绝不返回-1让上层自己查errno超时必须可控connect()、recv()、send()全部支持毫秒级超时且超时后 socket 状态可恢复非 fatal error允许重试。提示本文实现完全基于 POSIX socket APILinux/macOS和 Winsock2Windows不依赖任何第三方库。所有代码均可直接粘贴进 VS2019/VSCode CMake 项目无需额外配置。3. 跨平台 Socket 类骨架头文件定义、平台宏隔离、基础 RAII 结构3.1 头文件tcp_socket.h用#ifdef _WIN32隔离平台差异接口保持一致#pragma once #include string #include system_error #include chrono #include cstdint #ifdef _WIN32 #include winsock2.h #include ws2tcpip.h #else #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include unistd.h #include fcntl.h #include errno.h #endif namespace net { class TcpSocket { public: // 构造创建 socket fd失败则 throw explicit TcpSocket(int domain AF_INET, int type SOCK_STREAM, int protocol 0); // 析构自动 closesocket/close ~TcpSocket(); // 禁用拷贝支持移动 TcpSocket(const TcpSocket) delete; TcpSocket operator(const TcpSocket) delete; TcpSocket(TcpSocket other) noexcept; TcpSocket operator(TcpSocket other) noexcept; // 连接远程地址支持超时 void connect(const std::string host, uint16_t port, std::chrono::milliseconds timeout std::chrono::milliseconds(5000)); // 发送数据支持超时 size_t send(const void* data, size_t len, std::chrono::milliseconds timeout std::chrono::milliseconds(-1)); // 接收数据支持超时 size_t recv(void* data, size_t len, std::chrono::milliseconds timeout std::chrono::milliseconds(-1)); // 获取底层 fd / SOCKET供高级用法如 select/poll int fd() const { return fd_; } // 显式判断是否有效连接 explicit operator bool() const { return fd_ ! -1; } private: int fd_; bool is_windows_; // 私有工具函数设置 socket 为非阻塞 void set_nonblocking(); // 私有工具函数设置 SO_RCVTIMEO / SO_SNDTIMEOWindows/Linux 差异在此 void set_timeout(int optname, std::chrono::milliseconds timeout); // 私有工具函数解析 host 字符串为 sockaddr_in struct sockaddr_in resolve_address(const std::string host, uint16_t port); }; } // namespace net关键点说明#pragma once#ifdef _WIN32是跨平台基石Winsock2.h 和 sys/socket.h 不能共存fd_是核心成员Linux 为intWindows 为SOCKET本质也是u_int故统一用int存储避免类型污染is_windows_标志位用于运行时分支比宏更灵活例如后续支持 Unix Domain Socket 时可扩展std::chrono::milliseconds作为超时参数-1 表示阻塞模式即不设超时这是现代 C 的惯用法explicit operator bool()让if (sock)可读性远超if (sock.fd() ! -1)。3.2 实现文件tcp_socket.cpp平台差异收敛在 3 个私有函数里#include tcp_socket.h #include stdexcept #include cstring #include iostream #ifdef _WIN32 // Windows 下初始化 Winsock全局一次 static bool winsock_initialized false; static void init_winsock() { if (!winsock_initialized) { WSADATA wsaData; int result WSAStartup(MAKEWORD(2,2), wsaData); if (result ! 0) { throw std::system_error(result, std::system_category(), WSAStartup failed); } winsock_initialized true; } } #endif namespace net { TcpSocket::TcpSocket(int domain, int type, int protocol) : fd_(-1), is_windows_(false) { #ifdef _WIN32 init_winsock(); is_windows_ true; fd_ ::socket(domain, type, protocol); if (fd_ INVALID_SOCKET) { throw std::system_error(WSAGetLastError(), std::system_category(), socket() failed); } #else fd_ ::socket(domain, type, protocol); if (fd_ -1) { throw std::system_error(errno, std::generic_category(), socket() failed); } #endif } TcpSocket::~TcpSocket() { if (fd_ ! -1) { #ifdef _WIN32 ::closesocket(fd_); #else ::close(fd_); #endif fd_ -1; } } TcpSocket::TcpSocket(TcpSocket other) noexcept : fd_(other.fd_), is_windows_(other.is_windows_) { other.fd_ -1; } TcpSocket TcpSocket::operator(TcpSocket other) noexcept { if (this ! other) { if (fd_ ! -1) { #ifdef _WIN32 ::closesocket(fd_); #else ::close(fd_); #endif } fd_ other.fd_; is_windows_ other.is_windows_; other.fd_ -1; } return *this; } void TcpSocket::set_nonblocking() { #ifdef _WIN32 u_long mode 1; if (ioctlsocket(fd_, FIONBIO, mode) SOCKET_ERROR) { throw std::system_error(WSAGetLastError(), std::system_category(), ioctlsocket FIONBIO failed); } #else int flags fcntl(fd_, F_GETFL, 0); if (flags -1 || fcntl(fd_, F_SETFL, flags | O_NONBLOCK) -1) { throw std::system_error(errno, std::generic_category(), fcntl O_NONBLOCK failed); } #endif } void TcpSocket::set_timeout(int optname, std::chrono::milliseconds timeout) { if (timeout.count() -1) return; // -1 表示阻塞不设 timeout int timeout_ms static_castint(timeout.count()); #ifdef _WIN32 if (setsockopt(fd_, SOL_SOCKET, optname, reinterpret_castconst char*(timeout_ms), sizeof(timeout_ms)) SOCKET_ERROR) { throw std::system_error(WSAGetLastError(), std::system_category(), setsockopt timeout failed); } #else struct timeval tv; tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; if (setsockopt(fd_, SOL_SOCKET, optname, tv, sizeof(tv)) -1) { throw std::system_error(errno, std::generic_category(), setsockopt timeout failed); } #endif } struct sockaddr_in TcpSocket::resolve_address(const std::string host, uint16_t port) { struct sockaddr_in addr; std::memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); // 支持 127.0.0.1 和 localhost 两种格式 if (inet_pton(AF_INET, host.c_str(), addr.sin_addr) 1) { return addr; } // DNS 解析仅 IPv4 struct hostent* he gethostbyname(host.c_str()); if (!he || he-h_addr_list[0] nullptr) { throw std::system_error(EINVAL, std::generic_category(), gethostbyname failed for host); } std::memcpy(addr.sin_addr, he-h_addr_list[0], he-h_length); return addr; } } // namespace net参数与逻辑说明init_winsock()是 Windows 特有初始化必须在socket()前调用且全局只执行一次set_nonblocking()中Windows 用ioctlsocket(FIONBIO)Linux 用fcntl(O_NONBLOCK)这是最常翻车的点有人直接在 Windows 上用fcntl结果静默失败set_timeout()的optname参数传SO_RCVTIMEO或SO_SNDTIMEOWindows 和 Linux 对timeval结构体的解释一致但 Linux 要求struct timevalWindows 接受int毫秒值更简单这里统一用int传参Windows 直接用Linux 转成timevalresolve_address()不依赖getaddrinfo()因需 C11 且复杂用inet_pton()gethostbyname()组合覆盖 IP 字符串和域名两种输入足够工业现场使用。4. 核心功能实现connect()、send()、recv() 的超时与错误处理细节4.1 connect()为什么不能直接用阻塞 connect()——必须用非阻塞 select() 模拟超时阻塞connect()在 Linux/Windows 上默认无超时直到路由不可达或 SYN 重传耗尽可能数分钟这在工业现场是灾难。正确做法是先set_nonblocking()调用connect()若返回EINPROGRESSLinux或WSAEWOULDBLOCKWindows则用select()等待可写事件writefds超时后检查SO_ERROR获取真实错误。void TcpSocket::connect(const std::string host, uint16_t port, std::chrono::milliseconds timeout) { auto addr resolve_address(host, port); // 设置非阻塞 set_nonblocking(); // 调用 connect int result ::connect(fd_, reinterpret_castconst struct sockaddr*(addr), sizeof(addr)); if (result 0) { // 立即成功罕见本地回环或已建立连接 return; } #ifdef _WIN32 if (WSAGetLastError() ! WSAEWOULDBLOCK) { throw std::system_error(WSAGetLastError(), std::system_category(), connect immediate failed); } #else if (errno ! EINPROGRESS) { throw std::system_error(errno, std::generic_category(), connect immediate failed); } #endif // 使用 select 等待连接完成 fd_set writefds; FD_ZERO(writefds); FD_SET(fd_, writefds); struct timeval tv; tv.tv_sec timeout.count() / 1000; tv.tv_usec (timeout.count() % 1000) * 1000; int ret select(fd_ 1, nullptr, writefds, nullptr, tv); if (ret 0) { throw std::system_error(ETIMEDOUT, std::generic_category(), connect timeout); } if (ret -1) { #ifdef _WIN32 throw std::system_error(WSAGetLastError(), std::system_category(), select failed); #else throw std::system_error(errno, std::generic_category(), select failed); #endif } // 检查连接是否真正成功 int error 0; socklen_t len sizeof(error); #ifdef _WIN32 if (getsockopt(fd_, SOL_SOCKET, SO_ERROR, reinterpret_castchar*(error), len) SOCKET_ERROR) { throw std::system_error(WSAGetLastError(), std::system_category(), getsockopt SO_ERROR failed); } #else if (getsockopt(fd_, SOL_SOCKET, SO_ERROR, error, len) -1) { throw std::system_error(errno, std::generic_category(), getsockopt SO_ERROR failed); } #endif if (error ! 0) { #ifdef _WIN32 throw std::system_error(error, std::system_category(), connect failed with error); #else throw std::system_error(error, std::generic_category(), connect failed with error); #endif } }为什么必须这样写connect()在非阻塞模式下成功返回 0失败返回 -1 且errno为EINPROGRESS表示正在连接这是 POSIX 标准select()等待writefds是关键TCP 连接建立后socket 变为“可写”此时SO_ERROR才能读出真实错误如ECONNREFUSED直接recv()或send()在连接未完成时会返回EAGAIN但无法区分是连接失败还是单纯无数据——必须通过SO_ERROR确认。4.2 send()如何处理 partial write——必须循环直到发完或出错TCP 不保证一次send()发出所有数据。内核发送缓冲区满时send()可能只发出部分字节返回值 len必须循环调用。但注意send()在非阻塞模式下若缓冲区满返回EAGAIN/WSAEWOULDBLOCK此时应等待可写事件而非立即重试。size_t TcpSocket::send(const void* data, size_t len, std::chrono::milliseconds timeout) { if (!*this) { throw std::runtime_error(socket is invalid); } const char* ptr static_castconst char*(data); size_t total_sent 0; while (total_sent len) { ssize_t sent ::send(fd_, ptr total_sent, len - total_sent, 0); if (sent 0) { total_sent static_castsize_t(sent); continue; } if (sent 0) { // 对端关闭连接 throw std::system_error(EPIPE, std::generic_category(), peer closed connection); } #ifdef _WIN32 int last_error WSAGetLastError(); if (last_error WSAEWOULDBLOCK || last_error WSAEINTR) { #else if (errno EAGAIN || errno EWOULDBLOCK || errno EINTR) { #endif // 非阻塞需等待可写 if (timeout.count() -1) { // 阻塞模式继续重试实际不会发生因我们设了非阻塞 continue; } // 超时等待可写 fd_set writefds; FD_ZERO(writefds); FD_SET(fd_, writefds); struct timeval tv; tv.tv_sec timeout.count() / 1000; tv.tv_usec (timeout.count() % 1000) * 1000; int ret select(fd_ 1, nullptr, writefds, nullptr, tv); if (ret 0) { throw std::system_error(ETIMEDOUT, std::generic_category(), send timeout); } if (ret -1) { #ifdef _WIN32 throw std::system_error(WSAGetLastError(), std::system_category(), select write failed); #else throw std::system_error(errno, std::generic_category(), select write failed); #endif } continue; // 可写了重试 send } // 真实错误 #ifdef _WIN32 throw std::system_error(last_error, std::system_category(), send failed); #else throw std::system_error(errno, std::generic_category(), send failed); #endif } return total_sent; }关键逻辑total_sent累加ptr total_sent移动指针确保不重复发送已发数据sent 0表示对端 shutdown这是合法终止信号抛EPIPEEAGAIN/WSAEWOULDBLOCK是正常流控必须select()等待而非忙等EINTR被信号中断需重试这是 POSIX 要求。4.3 recv()如何避免 recv() 返回 0 却误判为错误——0 表示对端关闭不是错误这是新手最大坑recv()返回 0 表示对端close()或shutdown(SHUT_WR)是正常 EOF应由业务逻辑处理如结束会话而非抛异常。size_t TcpSocket::recv(void* data, size_t len, std::chrono::milliseconds timeout) { if (!*this) { throw std::runtime_error(socket is invalid); } // 设置接收超时仅对阻塞 recv 生效非阻塞下用 select if (timeout.count() ! -1) { set_timeout(SO_RCVTIMEO, timeout); } ssize_t received ::recv(fd_, data, len, 0); if (received 0) { return static_castsize_t(received); } if (received 0) { // 对端关闭连接正常情况 return 0; } #ifdef _WIN32 int last_error WSAGetLastError(); if (last_error WSAEWOULDBLOCK || last_error WSAEINTR) { #else if (errno EAGAIN || errno EWOULDBLOCK || errno EINTR) { #endif // 非阻塞无数据返回 0 表示超时或无数据 if (timeout.count() -1) { // 阻塞模式应一直等到数据但这里返回 0 不合理 —— 实际中应 retry // 为简化我们约定阻塞模式下 recv() 不返回 0除非对端关闭 throw std::system_error(EAGAIN, std::generic_category(), recv would block in blocking mode); } return 0; // 超时无数据 } // 真实错误 #ifdef _WIN32 throw std::system_error(last_error, std::system_category(), recv failed); #else throw std::system_error(errno, std::generic_category(), recv failed); #endif }注意received 0直接返回0调用方需判断if (n 0) { /* connection closed */ }EAGAIN在超时后返回我们返回0表示“本次无数据”符合recv()语义阻塞模式下recv()不应返回EAGAIN所以timeout.count() -1时若遇到EAGAIN说明 socket 状态异常抛错。5. 避坑指南Windows/Linux 下 5 个血泪经验总结5.1 现象bind()失败报错Address already in useWindows: 10048, Linux: 98原因socket 关闭后端口进入TIME_WAIT状态Linux 默认 60 秒此时bind()会失败。常见于服务端快速重启。解决设置SO_REUSEADDR选项必须在bind()之前调用int reuse 1; setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, reinterpret_castconst char*(reuse), sizeof(reuse));注意Windows 下SO_EXCLUSIVEADDRUSE会阻止SO_REUSEADDR若你看到WSAENOTEMPTY检查是否误设了该选项。5.2 现象客户端connect()成功但send()立即返回ECONNRESETWindows: 10054原因服务端在accept()后未及时recv()客户端发数据时服务端 socket 已关闭TCP 层发送 RST 包。解决服务端accept()后立即启动recv()循环或用setsockopt(fd_, IPPROTO_TCP, TCP_NODELAY, ...)关闭 Nagle 算法减少小包延迟。5.3 现象recv()在 Linux 上返回EAGAIN但在 Windows 上返回WSAEWOULDBLOCK代码编译不过原因errno是全局变量Windows 下WSAGetLastError()才是正确方式且errno在 Windows 下不反映 socket 错误。解决永远不要在 socket 操作后直接读errno必须用平台适配的错误获取函数如本文send()/recv()中所示。5.4 现象程序退出后netstat -an | grep :port显示TIME_WAIT状态持续很久影响测试原因TIME_WAIT是 TCP 规范要求防止旧包干扰新连接。但开发时频繁重启可用SO_LINGER强制关闭struct linger ling {1, 0}; // l_onoff1, l_linger0 setsockopt(fd_, SOL_SOCKET, SO_LINGER, reinterpret_castconst char*(ling), sizeof(ling));警告l_linger0会发送 RST对端可能收到不完整数据仅用于开发测试禁止上线。5.5 现象select()返回可读但recv()返回 0程序崩溃原因recv()返回 0 表示对端关闭此时 socket 不再可读但select()可能因 socket 关闭事件仍报告可读边缘情况。若未检查recv()返回值直接 memcpy 会越界。解决recv()后必须检查返回值 0正常接收 0对端关闭应关闭本端 socket 0错误按前述逻辑处理。提示select()的可读事件包含“连接关闭”和“有数据”recv()是唯一权威判断方式。6. 进阶技巧如何用这个 Socket 类构建一个健壮的 TCP 客户端/服务端骨架6.1 客户端带重连机制的TcpClient封装class TcpClient { public: explicit TcpClient(const std::string host, uint16_t port, int max_retries 3, std::chrono::milliseconds retry_delay std::chrono::seconds(1)) : host_(host), port_(port), max_retries_(max_retries), retry_delay_(retry_delay) {} bool connect() { for (int i 0; i max_retries_; i) { try { sock_.reset(new net::TcpSocket()); sock_-connect(host_, port_, std::chrono::seconds(3)); return true; } catch (const std::system_error e) { if (i max_retries_) throw; std::this_thread::sleep_for(retry_delay_); } } return false; } size_t send(const void* data, size_t len) { return sock_-send(data, len, std::chrono::seconds(5)); } size_t recv(void* data, size_t len) { return sock_-recv(data, len, std::chrono::seconds(5)); } private: std::string host_; uint16_t port_; int max_retries_; std::chrono::milliseconds retry_delay_; std::unique_ptrnet::TcpSocket sock_; };为什么用std::unique_ptr避免TcpSocket对象在重连失败时残留reset()确保旧 socket 析构connect()失败时sock_为空下次reset()创建新实例。6.2 服务端单线程 accept 多线程处理的最小骨架class TcpServer { public: TcpServer(uint16_t port) : port_(port) {} void run() { net::TcpSocket listener; listener.bind(0.0.0.0, port_); listener.listen(5); std::cout Server listening on port port_ std::endl; while (true) { try { auto client_sock listener.accept(); // 自定义 accept() 方法略 std::thread([client std::move(client_sock)]() mutable { handle_client(std::move(client)); }).detach(); } catch (const std::exception e) { std::cerr Accept failed: e.what() std::endl; break; } } } private: uint16_t port_; void handle_client(net::TcpSocket client) { try { char buf[1024]; while (true) { size_t n client.recv(buf, sizeof(buf), std::chrono::seconds(30)); if (n 0) break; // client closed // echo back client.send(buf, n, std::chrono::seconds(5)); } } catch (const std::exception e) { std::cerr Client error: e.what() std::endl; } } };关键点listener.accept()需自行实现调用::accept()并用TcpSocket包装返回的fdstd::thread(...).detach()是简化写法实际项目应管理线程生命周期如std::vectorstd::threadjoin_all()handle_client()中client是移动参数确保每个线程独占 socket避免数据竞争。6.3 调试技巧如何快速定位connect()卡住是 DNS 问题还是网络问题在resolve_address()中加入日志std::cout [DEBUG] Resolving host ... std::endl; struct hostent* he gethostbyname(host.c_str()); if (!he) { std::cout [DEBUG] gethostbyname failed for host , errno h_errno std::endl; throw std::system_error(EINVAL, std::generic_category(), ...); }然后用ping host和telnet host port交叉验证若ping通但telnet不通是防火墙或服务未启若ping不通但nslookup host成功是路由问题若nslookup失败才是 DNS 问题。我过去三年写 C 网络模块最大的后悔药就是没早把errno/WSAGetLastError()的错误日志打全。现在每行 socket 调用后都加std::cerr [SOCKET] __func__ failed: e.what() std::endl;线上问题 5 分钟内定位。这个TcpSocket类不是终点而是你掌控网络通信的第一块砖——它不炫技但每行代码都踩过坑、验过真。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑