简介这是一份面向C初学者与网络编程入门者的轻量级Socket封装类实现资源聚焦于TCP通信基础能力构建适用于课程设计、实验开发及小型网络工具原型开发。资源包含一个头文件MySocket.h和一个实现文件MySocket.cpp共2个核心源码文件总大小仅2KB结构简洁、接口清晰便于快速理解Socket底层调用逻辑与面向对象封装思路。已有339人学习下载反映出其在教学实践中的实用价值。读者可直接复用该类完成客户端/服务器端基础通信功能代码注释规范覆盖socket创建、绑定、监听、连接、收发数据等关键流程并隐含了错误处理与跨平台兼容性考量是理解C网络编程原理与动手实践的理想起点。1. C Socket 类不是封装个 connect 就叫“类”它得扛住断线重连、超时控制、跨平台收发和多线程安全很多人写完socket()、connect()、send()、recv()四行就敢叫“C Socket 类”——结果一上生产环境遇到网络抖动就卡死客户端断开服务端不感知recv()返回 0 不知道是正常关闭还是半关闭send()阻塞在十万字节里彻底拖垮整个线程。这不是“能通”这是黑匣子式裸奔。真正的 C Socket 类必须把底层 socket API 的所有坑都兜住SO_RCVTIMEO和SO_SNDTIMEO怎么设才不被 Linux 和 Windows 吃掉select()/poll()/epoll()/kqueue()如何抽象成统一接口EINTR、EAGAIN、EWOULDBLOCK这些 errno 是重试还是报错shutdown(SHUT_RDWR)和close()的顺序差一秒连接状态就不可逆。它不是教学 demo而是你写 TCP 服务、做设备通信、搭边缘网关时第一层稳如磐石的基石。适合正在用 C 做嵌入式通信、工业协议对接、自研中间件或高性能网关的工程师——别再用裸 socket 写业务逻辑了先让这个类替你扛住网络世界的混沌。2. 从零设计一个可落地的 C Socket 类为什么选 RAII 非阻塞 分离收发缓冲区2.1 为什么不能直接继承 std::basic_streambuf 或封装 boost::asioBoost.Asio 功能完整但体积大、编译慢、依赖重嵌入式或资源受限场景根本吃不下std::basic_streambuf是为文件/内存流设计的对 socket 的errno处理、连接状态机、超时重试完全无感。我们真正需要的是轻量头文件为主、可控每个 syscall 可 trace、可调试错误码直出、可嵌入无第三方依赖。所以本方案采用纯 C11 标准库 系统 API 的组合用 RAII 管理 socket fd 生命周期用fcntl()/ioctlsocket()统一设非阻塞模式用独立读写缓冲区解耦应用层吞吐与系统调用频率——这比“一行asio::ip::tcp::socket”更能让你看清数据怎么进、怎么出、卡在哪。2.2 核心类结构TcpSocket 与 Buffer 的职责切分// TcpSocket.h class TcpSocket { public: explicit TcpSocket(int family AF_INET); // 支持 IPv4/IPv6 ~TcpSocket(); bool connect(const std::string host, uint16_t port, int timeout_ms 5000); bool bind_and_listen(const std::string addr, uint16_t port, int backlog SOMAXCONN); std::unique_ptrTcpSocket accept(int timeout_ms 1000); ssize_t send(const void* data, size_t len, int flags 0); ssize_t recv(void* buf, size_t len, int flags 0); bool set_send_timeout(int ms); bool set_recv_timeout(int ms); bool set_keepalive(bool enable, int idle 60, int interval 5, int count 3); int get_fd() const { return fd_; } bool is_connected() const { return state_ CONNECTED; } private: enum State { UNINITIALIZED, CONNECTING, CONNECTED, CLOSED }; int fd_; State state_; struct sockaddr_storage remote_addr_; socklen_t addr_len_; void close_socket(); bool set_nonblocking(); };提示remote_addr_用sockaddr_storage而非sockaddr_in是为了天然支持 IPv6state_枚举强制约束状态流转避免send()在未连接状态下静默失败。2.3 缓冲区设计为什么 recv_buffer 不是 vector 而 send_buffer 必须带 offset裸recv(fd, buf, len, 0)最大问题是一次调用最多只收len字节但 TCP 是字节流应用层协议如 HTTP、自定义二进制包往往需要按帧解析。如果每次recv()都直接往业务 buffer 里塞就会出现“半包”或“粘包”。解决方案是维护一个内部recv_buffer_持续recv()直到缓冲区满或返回EAGAIN再由上层按协议拆包。同理send_buffer_必须记录已发送偏移量sent_offset_因为send()可能只发出部分数据尤其在高负载或 Nagle 关闭时下次send()必须从上次中断处继续否则丢数据。// Buffer.h class SocketBuffer { public: static constexpr size_t DEFAULT_CAPACITY 8192; SocketBuffer(size_t capacity DEFAULT_CAPACITY) : capacity_(capacity), size_(0), offset_(0) { buffer_.resize(capacity_); } // 从 socket fd 持续接收直到缓冲区满或 EAGAIN ssize_t fill_from_fd(int fd, int timeout_ms 0); // 从缓冲区提取最多 len 字节到 dst移动 offset_ size_t read_to(void* dst, size_t len); // 向缓冲区追加数据用于 send 场景 void append(const void* data, size_t len); // 从缓冲区发送数据返回实际发出字节数更新 offset_ ssize_t flush_to_fd(int fd, int flags 0); bool empty() const { return size_ 0; } size_t available() const { return capacity_ - size_; } size_t readable_size() const { return size_ - offset_; } private: std::vectorchar buffer_; size_t capacity_; size_t size_; // 当前 buffer 中有效字节数 size_t offset_; // 下次 read_to 的起始位置用于 send 场景的已发送偏移 };fill_from_fd()内部会循环recv()自动处理EINTR重试和EAGAIN/EWOULDBLOCK退出并把recv()返回值为 0对端关闭或负数错误如实返回——这才是真实网络世界的反馈不是教科书里的“成功/失败”二元逻辑。3. 跨平台非阻塞设置与超时控制Windows 与 Linux 的 syscall 差异必须抹平3.1 非阻塞模式fcntl()vsioctlsocket()为什么不能只写一套Linux 下fcntl(fd, F_SETFL, O_NONBLOCK)是标准做法Windows 下ioctlsocket(fd, FIONBIO, arg)才生效且arg是u_long类型0 或 1。更关键的是Windows 的connect()在非阻塞模式下立即返回WSAEWOULDBLOCK对应 Linux 的EINPROGRESS但select()判断可写才是连接成功而非可读。很多跨平台代码在这里翻车——误把select()可读当成连接建立结果send()时触发WSAECONNRESET。// TcpSocket.cpp bool TcpSocket::set_nonblocking() { #ifdef _WIN32 u_long arg 1; return ioctlsocket(fd_, FIONBIO, arg) 0; #else int flags fcntl(fd_, F_GETFL, 0); return fcntl(fd_, F_SETFL, flags | O_NONBLOCK) 0; #endif } bool TcpSocket::connect(const std::string host, uint16_t port, int timeout_ms) { // ... 解析地址、填充 sockaddr ... if (::connect(fd_, (struct sockaddr*)addr, addr_len_) 0) { #ifdef _WIN32 if (WSAGetLastError() ! WSAEWOULDBLOCK) { #else if (errno ! EINPROGRESS) { #endif return false; // 真正失败 } } // 使用 select 等待连接完成 fd_set write_fds, except_fds; FD_ZERO(write_fds); FD_ZERO(except_fds); FD_SET(fd_, write_fds); FD_SET(fd_, except_fds); struct timeval tv{ timeout_ms / 1000, (timeout_ms % 1000) * 1000 }; int ret select(fd_ 1, nullptr, write_fds, except_fds, tv); if (ret 0) return false; // 超时或错误 if (FD_ISSET(fd_, except_fds)) return false; // 连接被拒绝等 // 检查 socket 错误选项Linux/Windows 通用 int error 0; socklen_t len sizeof(error); if (getsockopt(fd_, SOL_SOCKET, SO_ERROR, (char*)error, len) 0 || error ! 0) { return false; } state_ CONNECTED; return true; }注意getsockopt(..., SO_ERROR, ...)是唯一跨平台确认非阻塞connect()是否成功的办法。select()可写只是“可能成功”必须二次验证SO_ERROR否则在某些内核版本或 Windows 版本下会误判。3.2 超时控制SO_RCVTIMEO/SO_SNDTIMEO的陷阱与 fallback 方案setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, ...)看似完美但实测发现Linux 下recv()超时后 errno 为EAGAIN或EWOULDBLOCK符合预期Windows 下recv()超时后 errno 为WSAETIMEDOUT但send()超时却可能返回WSAENOTCONN连接已断而非超时更致命的是SO_RCVTIMEO对accept()无效accept()超时必须用select()或poll()控制。因此本方案采用分层超时connect()用select()控制见上accept()用select()timevalrecv()/send()优先用SO_RCVTIMEO/SO_SNDTIMEO若失败如 Windows send 超时异常则 fallback 到select()recv()/send()组合。bool TcpSocket::set_recv_timeout(int ms) { struct timeval tv{ ms / 1000, (ms % 1000) * 1000 }; #ifdef _WIN32 return setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, (const char*)tv, sizeof(tv)) 0; #else return setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)) 0; #endif } ssize_t TcpSocket::recv(void* buf, size_t len, int flags) { if (state_ ! CONNECTED) return -1; ssize_t n ::recv(fd_, buf, len, flags); if (n 0) { #ifdef _WIN32 int err WSAGetLastError(); if (err WSAETIMEDOUT) { errno EAGAIN; // 统一转为 POSIX errno } #else if (errno EAGAIN || errno EWOULDBLOCK) { // 非阻塞模式下的正常现象 } #endif } return n; }4. 断线检测与连接保活SO_KEEPALIVE不是银弹必须配合应用层心跳4.1SO_KEEPALIVE的真实行为它只探测“链路层是否存活”不保证业务可用开启SO_KEEPALIVE后内核会在连接空闲时发送探测包默认 2 小时后开始75 秒间隔9 次失败后断开。问题在于它无法感知 NAT 超时家用路由器通常 300 秒无流量就回收映射无法感知防火墙静默丢包探测包发出去响应被拦但 keepalive 不报错更严重的是recv()返回 0 表示对端close()但recv()返回 -1 errno ECONNRESET才表示异常断开——而 keepalive 探测失败时recv()可能永远不触发连接就挂在那。所以必须叠加应用层心跳每 30 秒发一个PING包对方回PONG连续 3 次无响应则主动close()。SO_KEEPALIVE只作为最后一道防线。bool TcpSocket::set_keepalive(bool enable, int idle, int interval, int count) { if (!enable) { return setsockopt(fd_, SOL_SOCKET, SO_KEEPALIVE, enable, sizeof(enable)) 0; } #ifdef _WIN32 // Windows: TCP_KEEPALIVE struct struct tcp_keepalive ka; ka.onoff 1; ka.keepalivetime idle * 1000; // ms ka.keepaliveinterval interval * 1000; return WSAIoctl(fd_, SIO_KEEPALIVE_VALS, ka, sizeof(ka), nullptr, 0, nullptr, nullptr, nullptr) 0; #else // Linux: TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT int opt idle; if (setsockopt(fd_, IPPROTO_TCP, TCP_KEEPIDLE, opt, sizeof(opt)) 0) return false; opt interval; if (setsockopt(fd_, IPPROTO_TCP, TCP_KEEPINTVL, opt, sizeof(opt)) 0) return false; opt count; return setsockopt(fd_, IPPROTO_TCP, TCP_KEEPCNT, opt, sizeof(opt)) 0; #endif }注意Linux 下TCP_KEEPIDLE默认是 7200 秒2 小时远长于常见 NAT 超时必须显式设为 300 秒以内Windows 下SIO_KEEPALIVE_VALS的keepalivetime单位是毫秒别漏乘 1000。4.2 应用层心跳实现如何避免阻塞主线程心跳不能用sleep()send()否则整个 socket 被锁死。正确做法是在recv()循环中用select()设置最大等待时间如 1000ms每次select()返回后检查心跳计时器超时则发PING。// 示例带心跳的 recv 循环 void handle_connection(TcpSocket sock) { SocketBuffer recv_buf; time_t last_heartbeat time(nullptr); const int HEARTBEAT_INTERVAL 30; while (sock.is_connected()) { // select 等待数据或超时 fd_set read_fds; FD_ZERO(read_fds); FD_SET(sock.get_fd(), read_fds); struct timeval tv{ 1, 0 }; // 1 秒超时 int ret select(sock.get_fd() 1, read_fds, nullptr, nullptr, tv); if (ret 0) break; if (ret 0) { // 超时检查心跳 if (time(nullptr) - last_heartbeat HEARTBEAT_INTERVAL) { const char ping[] PING\n; sock.send(ping, strlen(ping)); last_heartbeat time(nullptr); } continue; } // 有数据可读 if (FD_ISSET(sock.get_fd(), read_fds)) { ssize_t n recv_buf.fill_from_fd(sock.get_fd()); if (n 0) { // 解析 recv_buf 中的完整包... } else if (n 0) { // 对端关闭 break; } else { // 错误 break; } } } }5. 多线程安全与资源泄漏避坑close()时机、fd复制、errno重入陷阱5.1close()的血泪经验为什么shared_ptrTcpSocket不能解决所有问题std::shared_ptrTcpSocket确保对象生命周期但close()是系统调用fd是进程级资源shared_ptr的use_count无法反映其他线程是否还在用这个fd。典型翻车场景线程 A 调用sock-close()fd_被置为 -1线程 B 此时正执行send()传入已关闭的fd_触发Bad file descriptor更隐蔽的是fd被内核回收后新 socket 可能复用同一fd号线程 B 实际向错误连接发数据。解决方案close()必须加锁且send()/recv()前必须检查state_ CONNECTED。shared_ptr只管对象不管fd状态。class TcpSocket { mutable std::mutex close_mutex_; // ... void close_socket() { std::lock_guardstd::mutex lock(close_mutex_); if (fd_ ! -1) { #ifdef _WIN32 closesocket(fd_); #else ::close(fd_); #endif fd_ -1; state_ CLOSED; } } ssize_t send(const void* data, size_t len, int flags 0) { if (state_ ! CONNECTED) return -1; // ... 实际 send ... } };5.2errno是线程局部存储但WSAGetLastError()不是errno的简单替代Linux 下errno是__errno_location()返回的 TLS 变量每个线程独立Windows 下WSAGetLastError()也是线程安全的但它和errno不互通。如果你混用printf(err%d\n, errno)和WSAGetLastError()在 Windows 上errno可能是 0未被 Winsock 设置。必须严格区分所有 socket API 错误用#ifdef _WIN32分支调用WSAGetLastError()所有非 socket API如malloc,open错误用errno绝不跨平台混用否则调试时看到errno0却实际失败怀疑人生。5.3fork()后的fd复制子进程必须close()否则父进程close()不释放连接这是 C/C 网络编程最经典陷阱之一fork()后父子进程共享fd表项内核引用计数为 2父进程close()只减 1连接仍存在直到子进程也close()。若子进程忘记close()连接永不释放端口耗尽。解决方案fork()后子进程立即close()所有非必要fd父进程close()子进程fd。// fork 服务器示例 int listen_fd server_socket.bind_and_listen(0.0.0.0, 8080); while (true) { auto client server_socket.accept(); if (!client) continue; pid_t pid fork(); if (pid 0) { // 子进程 server_socket.close_socket(); // 关闭监听 fd handle_client(*client); exit(0); } else if (pid 0) { client-close_socket(); // 父进程关闭 client fd由子进程持有 } }6. 实战验证用这个 Socket 类跑通 Modbus TCP 协议解析暴露并修复三个真实 bug6.1 Modbus TCP 报文结构与收发挑战Modbus TCP 报文固定 7 字节头事务ID、协议ID、长度、单元ID 功能码 数据。挑战在于设备可能一次发多个请求粘包设备响应可能分片半包某些国产 PLC 在recv()时只返回 1 字节反复调用才能凑齐头send()时若一次发不完 12 字节后续send()必须续传否则设备超时。我们用本 Socket 类封装ModbusTcpClientclass ModbusTcpClient { TcpSocket sock_; SocketBuffer recv_buf_; SocketBuffer send_buf_; public: bool connect(const std::string ip, uint16_t port) { return sock_.connect(ip, port, 3000); } // 发送 Modbus 请求自动处理分片 bool send_request(const std::vectoruint8_t req) { send_buf_.append(req.data(), req.size()); while (send_buf_.readable_size() 0) { ssize_t sent send_buf_.flush_to_fd(sock_.get_fd()); if (sent 0) return false; } return true; } // 接收完整响应自动处理半包/粘包 bool recv_response(std::vectoruint8_t resp, int timeout_ms 5000) { // 先确保收到至少 7 字节头 while (recv_buf_.readable_size() 7) { if (recv_buf_.fill_from_fd(sock_.get_fd(), timeout_ms) 0) { return false; } } // 解析头获取总长度 uint8_t header[7]; recv_buf_.read_to(header, 7); uint16_t len ntohs(*(uint16_t*)(header 4)); // 长度字段在 offset 4 size_t total_len 7 len; // 等待剩余字节 while (recv_buf_.readable_size() total_len - 7) { if (recv_buf_.fill_from_fd(sock_.get_fd(), timeout_ms) 0) { return false; } } // 提取完整响应 resp.resize(total_len); memcpy(resp.data(), header, 7); recv_buf_.read_to(resp.data() 7, len); return true; } };6.2 三个真实 bug 及修复过程现象原因解决Modbus 响应总是超时recv_buf_.fill_from_fd()在recv()返回EAGAIN时直接退出但 Modbus 设备发包慢需多次recv()才凑够 7 字节头修改fill_from_fd()EAGAIN时不退出继续循环加usleep(1000)防忙等连续请求时第二条失败send_buf_.flush_to_fd()发送完后未清空offset_第二次send()从上次偏移开始导致数据错位在flush_to_fd()成功后重置offset_ 0并size_ 0Windows 下偶尔 recv() 返回 -1 且 errno0Windowsrecv()在连接重置时可能不设WSAGetLastError()需额外检查getpeername()是否失败在recv()返回 -1 后调用getpeername()若失败则判定连接断开我的习惯每次交付 Socket 类给新项目前必跑三遍 Modbus TCP 测试——它比 HTTP 更苛刻能暴露所有底层细节缺陷。现在我本地有个test_modbus.cpp里面模拟 1000 次连接/断开/发包/收包CI 里跑通才敢合并。这比任何文档都管用。希望帮到你。本文还有配套的精品资源点击获取