简介:这是一份面向C++网络编程初学者与进阶开发者的轻量级socket通信封装资源,针对原生socket接口调用繁琐、错误处理分散的问题,提供了一套可直接复用的类实现,帮助读者快速在项目中搭建TCP/UDP通信能力。压缩包共2个文件,包含1个h头文件与1个cpp源文件,分别承担类接口声明与具体实现,整体仅2KB,结构精简、依赖极少,便于嵌入现有工程或作为教学示例逐行研读。资源围绕socket套接字编程展开,涵盖地址绑定、连接建立、数据收发等核心环节,适合用于课程设计、小型服务端客户端实验以及网络通信原理的动手验证。目前已有340人学习下载,说明其在同类入门素材中具备一定参考价值。读者可借此理解C++中面向对象方式封装系统调用的思路,掌握将底层API抽象为易用类的实践方法,并在此基础上扩展多线程、异常处理或跨平台适配,为后续深入网络编程打下基础。
1. 从一段裸 socket 代码说起:C++ 网络通信到底难在哪
很多人第一次写 C++ socket,都是从一段几十行的 TCP echo 服务端开始的:socket()建套接字、bind()绑端口、listen()监听、accept()拿连接、recv()/send()收发。跑通那一刻很爽,但把它塞进真实项目,问题立刻冒出来——连接一多就卡死、客户端断开后服务端 CPU 飙满、发一半的数据对端只收到半截、Windows 上编译报windows socket error: 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。这些不是玄学,是裸 socket 编程绕不开的边界。
这篇笔记要讲的,就是把「c++ socket 类」这件事做扎实:用 C++ 封装一个能复用的 socket 类,屏蔽掉sockaddr_in、字节序、WSAStartup、close/closesocket这些平台差异,让上层只关心「连、发、收、断」。适合两类人:刚学完 C++ 基础、想动手写网络通信的新手;以及写过 socket 但代码散落各处、想整理成类的中级开发者。下面从设计、实现、参数、踩坑一路讲到进阶,代码可直接抄。
2. 封装前先想清楚:C++ socket 类该切哪几刀
2.1 为什么裸 socket 不适合直接进业务代码
裸 socket 的问题不在功能,而在「散」。一个int fd到处传,谁负责关、关了之后别人还用不用、错误码怎么统一、Windows 和 Linux 头文件不一样,这些全压在业务逻辑里。常见做法是抽三层:最底层是平台适配(socket/closesocket、WSAStartup/WSACleanup),中间层是Socket类管生命周期和收发,上层才是协议解析和业务。这样切的好处是,换平台只动底层,业务代码一行不改。
另一个必须提前定的是「阻塞还是非阻塞」。新手项目用阻塞 socket 最省心,recv没数据就停在那;但一旦要同时处理多个连接,阻塞模型就得配多线程,线程一多上下文切换开销上来。非阻塞配select/epoll/IOCP才是高并发正路,但复杂度陡增。我的建议是:先把阻塞版封装稳,接口留好非阻塞的扩展位,别一上来就上 epoll,容易翻车。
2.2 类的接口设计:连接、收发、关闭四件事
一个够用的Socket类,公开接口控制在四类:connect(客户端连服务端)、bind+listen+accept(服务端)、send/recv(收发)、close(关闭)。构造函数只做初始化,不建连接;析构函数负责兜底关闭,防止忘记close导致句柄泄漏。这里有个关键决策:拷贝构造和赋值要不要禁掉?必须禁。socket 句柄是独占资源,拷贝一份就会出现两个对象关同一个 fd,第二个close直接踩空。用= delete显式禁用拷贝,需要转移就实现移动构造。
class Socket { public: Socket(); // 只初始化,不建连接 ~Socket(); // 析构兜底关闭 Socket(const Socket&) = delete; // 禁止拷贝,句柄独占 Socket& operator=(const Socket&) = delete; Socket(Socket&& other) noexcept; // 允许移动转移所有权 bool connectTo(const std::string& ip, uint16_t port); bool bindAndListen(const std::string& ip, uint16_t port, int backlog = 128); Socket acceptClient(); int sendAll(const void* data, size_t len); int recvSome(void* buf, size_t len); void close(); private: int fd_ = -1; // Linux 是 fd,Windows 是 SOCKET };逻辑说明:fd_初始化为 -1,代表「未持有有效句柄」,析构和close都先判断再操作,避免重复关闭。移动构造把other.fd_拿过来后置 -1,保证同一时刻只有一个对象持有句柄。参数上backlog给 128 是常见经验值,表示内核已完成三次握手但还没被accept取走的连接队列长度,太小会在高并发下丢连接。
2.3 跨平台适配:Windows 和 Linux 的差异清单
跨平台是 C++ socket 最容易埋雷的地方。差异集中在四处:头文件(Windows 用winsock2.h,Linux 用sys/socket.h等)、初始化(Windows 必须WSAStartup,Linux 不用)、关闭函数(closesocketvsclose)、错误码(WSAGetLastErrorvserrno)。用宏把这些包起来,业务层就干净了。
| 项目 | Windows | Linux |
|---|---|---|
| 头文件 | winsock2.h、ws2tcpip.h | sys/socket.h、netinet/in.h、arpa/inet.h |
| 初始化 | WSAStartup(MAKEWORD(2,2),...) | 无需 |
| 关闭 | closesocket(fd) | close(fd) |
| 错误码 | WSAGetLastError() | errno |
| 链接库 | ws2_32.lib | 无 |
#ifdef _WIN32 #include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib") using socklen_t = int; #define CLOSE_SOCKET(fd) closesocket(fd) #else #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <cerrno> #define CLOSE_SOCKET(fd) ::close(fd) #endif逻辑说明:#pragma comment(lib, "ws2_32.lib")是 MSVC 特有的自动链接,用 GCC/MinGW 就得在编译命令里加-lws2_32。socklen_t在 Windows 上默认没定义,用using补一个,否则accept的第三个参数编译不过。这套宏是后面所有实现的基础,先落地再往下写。
3. 把 Socket 类写出来:连接、收发、关闭的完整实现
3.1 初始化与析构:WSAStartup 和资源释放
Windows 上不调WSAStartup,后面所有 socket 调用都会失败,返回WSANOTINITIALISED。这个初始化进程级只需一次,放在类里做要注意别重复调用。简单做法是用一个静态标志或std::once_flag保证只跑一次。析构里对应WSACleanup,但同样只能调一次,所以更稳的方式是交给一个全局的 RAII 对象管,而不是每个 Socket 对象都调。
Socket::Socket() { #ifdef _WIN32 static std::once_flag wsaFlag; std::call_once(wsaFlag, []{ WSADATA wsa; if (WSAStartup(MAKEWORD(2,2), &wsa) != 0) { throw std::runtime_error("WSAStartup failed"); } }); #endif } Socket::~Socket() { close(); } void Socket::close() { if (fd_ != -1) { CLOSE_SOCKET(fd_); fd_ = -1; // 置无效,防止二次关闭 } }逻辑说明:std::call_once保证WSAStartup在多线程下也只执行一次,比手写if判断更安全。close()里先判断fd_ != -1再关,关完立刻置 -1,这样析构、显式 close、移动后的源对象都不会重复关同一个句柄。参数上MAKEWORD(2,2)请求 Winsock 2.2 版本,这是目前通用版本,别用 1.1。
3.2 connect 与 bind/listen/accept 的实现细节
客户端connectTo要做三件事:建 socket、填sockaddr_in、调connect。服务端bindAndListen多一步bind和listen。这里最容易错的是地址填充:sin_family必须是AF_INET,sin_port必须用htons转网络字节序,sin_addr用inet_pton而不是老掉牙的inet_addr(后者不支持错误区分)。
bool Socket::connectTo(const std::string& ip, uint16_t port) { fd_ = ::socket(AF_INET, SOCK_STREAM, 0); if (fd_ == -1) return false; sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port); // 主机序转网络序 if (inet_pton(AF_INET, ip.c_str(), &addr.sin_addr) <= 0) { close(); return false; } if (::connect(fd_, (sockaddr*)&addr, sizeof(addr)) == -1) { close(); return false; } return true; } bool Socket::bindAndListen(const std::string& ip, uint16_t port, int backlog) { fd_ = ::socket(AF_INET, SOCK_STREAM, 0); if (fd_ == -1) return false; int opt = 1; setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, (char*)&opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = (ip == "0.0.0.0") ? INADDR_ANY : inet_addr(ip.c_str()); if (::bind(fd_, (sockaddr*)&addr, sizeof(addr)) == -1) { close(); return false; } if (::listen(fd_, backlog) == -1) { close(); return false; } return true; }逻辑说明:SO_REUSEADDR是必须设的,否则服务端重启时端口处于TIME_WAIT状态,bind会直接失败,报的就是热词里那个「通常每个套接字地址只允许使用一次」。htons把端口从主机字节序转成网络字节序,忘了这步端口号会变成另一个值。INADDR_ANY表示监听本机所有网卡,写0.0.0.0是常见约定。inet_addr这里用于服务端绑定,简单够用;客户端解析域名场景建议换getaddrinfo。
3.3 send/recv 的返回值处理:半包和粘包从哪来
send和recv的返回值是新手最大的坑。send返回实际发出的字节数,可能小于你请求的长度(内核发送缓冲区满了);recv返回实际读到的字节数,可能小于你期望的(TCP 是字节流,没有消息边界)。这就是「半包」和「粘包」的根源——不是 bug,是 TCP 的设计。所以sendAll必须循环发到发完,recvSome只保证读到「一些」数据,边界要靠应用层协议自己划。
int Socket::sendAll(const void* data, size_t len) { const char* p = static_cast<const char*>(data); size_t sent = 0; while (sent < len) { int n = ::send(fd_, p + sent, len - sent, 0); if (n <= 0) return -1; // 出错或对端关闭 sent += n; } return (int)sent; } int Socket::recvSome(void* buf, size_t len) { int n = ::recv(fd_, buf, len, 0); if (n == 0) return 0; // 对端正常关闭 if (n < 0) return -1; // 出错 return n; // 可能小于 len,正常 }逻辑说明:sendAll用while循环,每次从p + sent继续发剩余部分,直到sent == len,这样才保证「全部发出」。recvSome故意不循环,因为阻塞模式下循环读会一直等到缓冲区满或对端关闭,反而卡住;正确做法是读一次拿到多少算多少,交给上层按协议长度字段判断。参数上len建议不超过 64KB,太大单次系统调用效率反而下降。
3.4 一个能跑的最小客户端和服务端
把上面的类拼起来,写一个 echo 服务端和客户端验证。服务端accept后循环recvSome,收到就sendAll回去;客户端连上后发一句,读回显。
// 服务端 int main() { Socket srv; if (!srv.bindAndListen("0.0.0.0", 9000)) return 1; while (true) { Socket cli = srv.acceptClient(); char buf[1024]; int n = cli.recvSome(buf, sizeof(buf)); if (n > 0) cli.sendAll(buf, n); } } // 客户端 int main() { Socket cli; if (!cli.connectTo("127.0.0.1", 9000)) return 1; const char* msg = "hello socket"; cli.sendAll(msg, strlen(msg)); char buf[1024]; int n = cli.recvSome(buf, sizeof(buf)); if (n > 0) printf("echo: %.*s\n", n, buf); }逻辑说明:acceptClient内部调accept返回一个新的Socket对象,代表这条连接,和监听 socket 分开。服务端这个版本一次只处理一个连接,是教学最小版;要并发就得把cli丢给线程或改成非阻塞。编译命令 Linux 下g++ main.cpp -o srv,Windows MSVC 下cl main.cpp ws2_32.lib,MinGW 下g++ main.cpp -lws2_32。
4. 避坑与排查:socket 类最容易翻车的五个地方
4.1 端口被占用,bind 直接失败
现象:服务端启动报bind failed,Windows 上错误信息是「通常每个套接字地址(协议/网络地址/端口)只允许使用一次」。原因:上一次进程退出后端口处于TIME_WAIT,或者另一个进程还占着这个端口。解决:setsockopt设SO_REUSEADDR(见 3.2),这是标准解法;如果还不行,用netstat -ano | findstr 9000(Windows)或lsof -i:9000(Linux)查是谁占着,换端口或杀掉进程。
4.2 客户端断开后服务端 CPU 跑满
现象:客户端一关,服务端recv返回 0,但代码没处理,继续循环调用,CPU 直接 100%。原因:recv返回 0 表示对端正常关闭,返回 -1 表示出错,这两种情况都必须跳出循环并关闭连接。解决:recvSome返回<= 0时立即close并结束该连接的循环,别把 0 当成「暂时没数据」。
4.3 只发出去一半,对端收到截断数据
现象:发 1000 字节,对端只收到 600 字节。原因:用了裸send没检查返回值,或者用了sendAll但中途send返回 -1 没处理。解决:统一走sendAll循环发送;如果send返回 -1,先看错误码是不是EINTR(被信号打断),是就重试,其他错误才断开。别假设一次send能发完。
4.4 忘记 WSAStartup 或链接 ws2_32
现象:Windows 上所有 socket 调用返回失败,错误码WSANOTINITIALISED;或者编译报一堆undefined reference to socket。原因:没调WSAStartup,或者没链接ws2_32.lib。解决:初始化放构造函数用call_once保证执行;链接在 MSVC 用#pragma comment,MinGW/GCC 在编译命令加-lws2_32,CMake 里写target_link_libraries(xxx ws2_32)。
4.5 拷贝 Socket 对象导致双重关闭
现象:程序随机崩溃,或者报「无效的句柄」。原因:Socket 对象被拷贝,两个对象持有同一个 fd,析构时都去close,第二次关的是已经被关的句柄,行为未定义。解决:拷贝构造和赋值= delete(见 2.2),需要传递就用移动语义或传引用/指针。这条是血泪经验,早期我图省事没禁拷贝,线上偶发崩溃查了两天才定位到。
5. 进阶:把阻塞 socket 改成能扛并发的版本
阻塞版跑通后,下一步就是并发。最省事的做法是「一连接一线程」:accept拿到cli后std::thread丢出去处理,主线程继续accept。这个模型代码改动小,几十上百连接够用,但连接上千时线程开销就压不住了。再往上就是select/poll/epoll(Linux)或IOCP(Windows),把「等哪个 fd 可读」交给内核,单线程管上万连接。
我一般会先给Socket类加一个setNonBlocking(),内部用fcntl(fd_, F_SETFL, O_NONBLOCK)(Linux)或ioctlsocket(fd_, FIONBIO, &mode)(Windows)切换。然后配epoll写一个事件循环:注册监听 fd 的EPOLLIN,有事件就accept,把新连接也注册进去;已连接 fd 可读就recvSome,返回 0 或 -1 就注销并关闭。这套结构比多线程复杂,但扩展性好得多。
验证并发是否真的生效,别只看「能连上」。用ab或自己写脚本开 500 个连接持续发小包,观察 CPU 和内存曲线:一连接一线程的版本线程数会飙到几百,epoll版本线程数稳定在个位数。另外记得给recv加超时(SO_RCVTIMEO),否则慢连接会一直占着不放。我踩过的坑是:非阻塞模式下recv返回 -1 且errno是EAGAIN/EWOULDBLOCK,这不是错误,是「暂时没数据」,必须和真正的错误区分开,否则会把正常连接误杀。这个判断写错,高并发下连接会莫名其妙断,排查起来很折磨。
从裸 socket 到封装成类,再到并发模型,每一步都有它的边界。我的习惯是:先把阻塞版写稳、把错误处理写全,再谈性能。希望帮到你。
本文还有配套的精品资源,点击获取