简介:这份《C++网络编程实例》PDF面向具备一定C++基础、希望入门Windows网络编程的开发者与在校学生,帮助其理清网络通信的基本原理与编程模型。内容从网络编程概述切入,系统讲解OSI七层模型、TCP/IP协议簇与C/S编程模型,并延伸至Socket套接字、网络字节顺序以及MFC中CAsyncSocket、CSocket类的使用,兼顾Windows API与MFC两种开发方式,便于读者理解底层通信机制。资源包共1个PDF文件,大小约114KB,轻量便携,适合随时查阅与对照练习。目前已有564人学习下载,可作为网络编程入门阶段的参考资料,帮助读者建立从协议分层到套接字编程的完整认知框架。
1. 从一份 PDF 说起:C++ 网络编程实例到底在解决什么问题
很多人第一次搜「c++网络编程实例.pdf」,场景其实很具体:手头有个 Linux 服务器要写个能收发的服务端,或者课程设计要求做一个 C/S 通信程序,翻了几本教材发现全是socket()函数原型罗列,看完还是不知道一个能跑的程序长什么样。这份标题指向的东西,本质就是一批可编译、可运行的 C++ 网络程序样例集合,覆盖 TCP/UDP、阻塞/非阻塞、多线程/多进程这几条主线。它解决的不是「什么是 socket」这种概念问题,而是「给我一份能直接改的骨架」。适合两类人:刚学完 C++ 语法、要拿网络编程做课设或练手的新手,以及写了几年业务代码、突然要接手一个 C++ 后端通信模块的熟手。下面我按自己带人做这类实例的顺序,把选型、代码、参数和坑一次讲透。
2. 动手前先把地基打牢:环境、头文件与编译链路
2.1 为什么 Windows 和 Linux 的实例不能直接互抄
C++ 网络编程实例最容易翻车的地方,不是逻辑,是平台差异。Linux 下 socket 就是一个int文件描述符,头文件是<sys/socket.h>、<netinet/in.h>、<arpa/inet.h>、<unistd.h>,关闭用close()。Windows 下 socket 是SOCKET类型(本质是无符号整型句柄),必须先WSAStartup()初始化 Winsock 库,关闭用closesocket(),清理用WSACleanup(),头文件是<winsock2.h>和<ws2tcpip.h>,链接时还要加-lws2_32。很多 PDF 实例默认读者在 Linux 下,直接抄到 Visual Studio 里会报一堆「未定义标识符」。常见做法是用条件编译把两套 API 隔开,或者干脆先锁定一个平台跑通再移植。我一般建议新手先在 Linux 或 WSL 下跑,因为报错信息更直白,strace、netstat这些排查工具也顺手。
2.2 用 CMake 把编译链路固定下来
实例能不能复现,一半取决于编译命令是否写死。手工敲g++容易漏链接库,用 CMake 最省心。下面是一个最小可用的CMakeLists.txt,同时兼容 Linux 和 Windows。
cmake_minimum_required(VERSION 3.10) project(net_demo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 打开常用警告,网络代码里类型转换错误很常见 add_compile_options(-Wall -Wextra) add_executable(tcp_server tcp_server.cpp) add_executable(tcp_client tcp_client.cpp) # Windows 下必须链接 Winsock 库,Linux 下不需要 if(WIN32) target_link_libraries(tcp_server ws2_32) target_link_libraries(tcp_client ws2_32) endif()逻辑说明:CMAKE_CXX_STANDARD 17保证能用上结构化绑定、std::thread这些现代写法;-Wall -Wextra打开警告,网络编程里socklen_t和int混用、send返回值没接住这类问题,编译器能提前帮你抓一部分。参数说明:如果你的实例用了 C++20 的协程或std::jthread,把标准改成 20;ws2_32是 Windows 下 Winsock 的导入库名,写错会报undefined reference to WSAStartup。编译时执行cmake -B build && cmake --build build,产物在build/目录下,别在源码目录里乱放可执行文件。
2.3 头文件顺序里藏着的坑
Windows 下有个经典血泪经验:<winsock2.h>必须写在<windows.h>前面,否则会引入旧版winsock.h,导致sockaddr_in重定义、WSAStartup找不到。正确顺序是先#include <winsock2.h>,再#include <ws2tcpip.h>,最后才是其他 Windows 头。Linux 下则要注意<netinet/in.h>和<arpa/inet.h>的分工:前者定义sockaddr_in结构体,后者提供inet_pton、inet_ntop这类地址转换函数。少一个头文件,报错信息往往指向函数未声明,而不是头文件缺失,新手容易在这里卡半天。
3. TCP 实例拆解:从三次握手到收发闭环
3.1 服务端骨架:bind、listen、accept 三步不能乱
TCP 服务端的核心流程是socket → bind → listen → accept → recv/send → close。下面这份代码是能直接编译运行的最小实例,监听 8888 端口,收到客户端消息后回显。
#include <iostream> #include <cstring> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> int main() { // 1. 创建 TCP socket,SOCK_STREAM 表示面向连接的字节流 int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return 1; } // 2. 设置端口复用,避免重启时 "Address already in use" int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 3. 绑定地址和端口,INADDR_ANY 表示监听所有网卡 sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(8888); if (bind(listen_fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } // 4. 开始监听,backlog 设为 128 if (listen(listen_fd, 128) < 0) { perror("listen"); return 1; } std::cout << "server listening on 8888\n"; // 5. 循环接受连接 while (true) { sockaddr_in cli{}; socklen_t len = sizeof(cli); int conn_fd = accept(listen_fd, (sockaddr*)&cli, &len); if (conn_fd < 0) { perror("accept"); continue; } char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip)); std::cout << "client " << ip << ":" << ntohs(cli.sin_port) << " connected\n"; // 6. 收发循环 char buf[1024]; ssize_t n; while ((n = recv(conn_fd, buf, sizeof(buf) - 1, 0)) > 0) { buf[n] = '\0'; std::cout << "recv: " << buf; send(conn_fd, buf, n, 0); // 回显 } close(conn_fd); } close(listen_fd); return 0; }逻辑说明:socket()第三个参数传 0 表示用默认协议(TCP);SO_REUSEADDR是调试期必加项,否则每次重启都要等 TIME_WAIT 超时;htonl/htons负责主机字节序转网络字节序,漏掉会导致端口号错乱。参数说明:backlog传 128 是经验值,表示已完成三次握手但还没被accept取走的连接队列长度,太小会丢连接,太大意义有限;recv的缓冲区留一个字节给'\0',否则打印会越界。失败时先看perror输出,bind报Permission denied通常是端口小于 1024 需要 root,accept返回 -1 且errno是EINTR属于被信号打断,continue即可。
3.2 客户端骨架与连接超时处理
客户端流程是socket → connect → send/recv → close。难点在connect可能阻塞很久,生产代码要设超时。
#include <iostream> #include <cstring> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <fcntl.h> #include <sys/select.h> int connect_with_timeout(const char* ip, int port, int timeout_sec) { int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) return -1; // 设为非阻塞,connect 会立即返回 EINPROGRESS int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port); inet_pton(AF_INET, ip, &addr.sin_addr); int ret = connect(fd, (sockaddr*)&addr, sizeof(addr)); if (ret < 0 && errno != EINPROGRESS) { close(fd); return -1; } // 用 select 等待可写,可写即连接完成 fd_set wfds; FD_ZERO(&wfds); FD_SET(fd, &wfds); timeval tv{timeout_sec, 0}; ret = select(fd + 1, nullptr, &wfds, nullptr, &tv); if (ret <= 0) { close(fd); return -1; } // 超时或出错 // 检查 socket 上是否有错误 int err = 0; socklen_t len = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); if (err != 0) { close(fd); return -1; } // 恢复阻塞模式 fcntl(fd, F_SETFL, flags); return fd; }逻辑说明:非阻塞connect返回EINPROGRESS是正常现象,表示握手在进行中;select监听可写事件,可写意味着连接建立或失败,必须再用getsockopt(SO_ERROR)区分。参数说明:timeout_sec建议设 3 到 5 秒,内网可以更短;select第一个参数是最大 fd 加一,这是它和poll的主要区别。失败时看errno,ECONNREFUSED说明对端没监听,ETIMEDOUT说明网络不通或被防火墙拦了。
3.3 粘包问题:为什么你的消息总是被拼在一起
TCP 是字节流,没有消息边界。客户端连续send两次,服务端一次recv可能全收到,这就是粘包。解决方式常见三种:固定长度、分隔符、长度前缀。长度前缀最通用,下面是一个读取完整包的函数。
// 协议格式:[4字节网络序长度][payload] bool read_packet(int fd, std::string& out) { uint32_t net_len = 0; // 先读满 4 字节长度头 size_t got = 0; while (got < sizeof(net_len)) { ssize_t n = recv(fd, (char*)&net_len + got, sizeof(net_len) - got, 0); if (n <= 0) return false; got += n; } uint32_t len = ntohl(net_len); if (len > 10 * 1024 * 1024) return false; // 防御超大包 out.resize(len); got = 0; while (got < len) { ssize_t n = recv(fd, &out[got], len - got, 0); if (n <= 0) return false; got += n; } return true; }逻辑说明:recv不保证一次读满,必须循环读到指定字节数,这是新手最常忽略的点。参数说明:长度上限设 10MB 是防御性编程,防止恶意包导致内存暴涨;ntohl把网络序转回主机序。如果对端用send发长度头,也要保证发满,写一个对称的write_all函数。
4. UDP 与并发模型:什么时候该换掉阻塞式写法
4.1 UDP 实例:无连接不等于不用 bind
UDP 用SOCK_DGRAM,服务端依然要bind,客户端可以不bind(系统自动分配端口)。下面是最小回显服务端。
#include <iostream> #include <cstring> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> int main() { int fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) { perror("socket"); return 1; } sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(9999); if (bind(fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } char buf[2048]; while (true) { sockaddr_in cli{}; socklen_t len = sizeof(cli); // recvfrom 会带回发送方地址,回显时原样发回去 ssize_t n = recvfrom(fd, buf, sizeof(buf), 0, (sockaddr*)&cli, &len); if (n < 0) { perror("recvfrom"); continue; } sendto(fd, buf, n, 0, (sockaddr*)&cli, len); } close(fd); return 0; }逻辑说明:UDP 没有连接状态,recvfrom每次都要带出对端地址,sendto再指定目标。参数说明:UDP 单包理论上限 65507 字节,实际建议控制在 1400 字节以内避免 IP 分片;recvfrom的len是值结果参数,传入缓冲区大小,返回实际地址长度。UDP 不保证顺序和到达,做文件传输这类场景要自己在应用层加序号和重传。
4.2 多线程并发:每连接一线程的边界在哪
最直观的并发模型是accept后开一个线程处理该连接。下面用std::thread实现,注意detach和资源回收。
#include <thread> #include <string> #include <unistd.h> #include <sys/socket.h> void handle_client(int conn_fd) { char buf[1024]; ssize_t n; while ((n = recv(conn_fd, buf, sizeof(buf) - 1, 0)) > 0) { buf[n] = '\0'; send(conn_fd, buf, n, 0); } close(conn_fd); // 线程结束前关闭连接 } // 在 accept 循环里: // int conn_fd = accept(...); // std::thread(handle_client, conn_fd).detach();逻辑说明:detach让线程独立运行,主线程继续accept;conn_fd按值传给线程,避免主循环变量被覆盖。参数说明:这个模型每连接一个线程,线程栈默认 8MB,几百连接就会吃掉几 GB 虚拟内存,所以适合连接数在几百以内的场景。连接数上千要换epoll(Linux)或 IOCP(Windows)。另外detach后线程无法被join,程序退出时可能丢数据,生产代码要用线程池加优雅关闭。
4.3 epoll 实例:高并发下的正确打开方式
epoll是 Linux 下高并发网络编程的核心。下面是一个能同时处理多连接的 echo 服务端骨架。
#include <sys/epoll.h> #include <fcntl.h> #include <unistd.h> #include <vector> #include <iostream> void set_nonblock(int fd) { int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); // ... bind / listen 同上,略 ... set_nonblock(listen_fd); int epfd = epoll_create1(0); epoll_event ev{}; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); std::vector<epoll_event> events(1024); while (true) { int n = epoll_wait(epfd, events.data(), events.size(), -1); for (int i = 0; i < n; ++i) { int fd = events[i].data.fd; if (fd == listen_fd) { int conn = accept(listen_fd, nullptr, nullptr); set_nonblock(conn); ev.events = EPOLLIN | EPOLLET; // 边缘触发 ev.data.fd = conn; epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &ev); } else { char buf[1024]; // 边缘触发必须循环读到 EAGAIN while (true) { ssize_t r = recv(fd, buf, sizeof(buf), 0); if (r > 0) { send(fd, buf, r, 0); } else if (r == 0) { close(fd); break; } else { if (errno == EAGAIN || errno == EWOULDBLOCK) break; close(fd); break; } } } } } }逻辑说明:EPOLLET是边缘触发,只在状态变化时通知一次,所以必须循环读到EAGAIN,否则会丢事件。参数说明:epoll_wait超时传 -1 表示永久阻塞,传 0 表示立即返回;events数组大小按预期并发量设,1024 是常见起点。失败时看errno,EINTR要重试,EBADF说明 fd 已关闭还在监听。
5. 避坑与排查:那些让实例跑不起来的真实原因
5.1 端口占用与 TIME_WAIT 堆积
现象:服务端重启时报bind: Address already in use,netstat -anp | grep 8888看到大量TIME_WAIT。原因:主动关闭方会进入 TIME_WAIT,持续 2MSL(Linux 默认 60 秒),期间端口不能被复用。解决:服务端bind前加SO_REUSEADDR,代码见 3.1;如果已经堆积,等一分钟或调小net.ipv4.tcp_fin_timeout。注意SO_REUSEPORT是另一个选项,允许多进程绑同一端口,但负载均衡行为依赖内核,别和SO_REUSEADDR混用。
5.2 recv 返回 0 被当成错误
现象:客户端断开后,服务端日志疯狂打印「recv error」。原因:recv返回 0 表示对端正常关闭连接(收到 FIN),不是错误,errno此时无意义。解决:判断顺序改成n > 0处理数据,n == 0关闭连接并退出循环,n < 0再看errno决定重试还是关闭。这个坑在 PDF 实例里经常被简化掉,抄的时候要自己补上。
5.3 信号打断导致 accept 失败
现象:服务端按 Ctrl+C 后没退出,或者accept偶尔返回 -1。原因:信号(如 SIGCHLD、SIGINT)会打断阻塞的系统调用,errno置为EINTR。解决:对accept、recv、send、select、epoll_wait的返回值判断里,遇到EINTR直接continue重试。更彻底的做法是用sigaction设置SA_RESTART,但并非所有调用都支持自动重启,显式重试最稳。
5.4 忘记字节序转换导致端口错乱
现象:客户端连127.0.0.1:8888却连不上,抓包看到目标端口是 34835 之类的怪数字。原因:sin_port没做htons,小端机器上 8888(0x22B8)被当成 0xB822 解析。解决:所有写入sockaddr_in的端口用htons,所有从sockaddr_in读出的端口用ntohs,IP 地址用inet_pton/inet_ntop而不是inet_addr(后者已废弃且不支持 IPv6)。
5.5 多线程下 close 与 recv 的竞态
现象:一个线程在recv,另一个线程close同一个 fd,程序偶发崩溃或recv返回奇怪错误。原因:fd 被关闭后,正在阻塞的recv行为未定义,且 fd 号可能被新连接复用。解决:用引用计数管理连接生命周期,或者用shutdown(fd, SHUT_RDWR)先唤醒阻塞的recv,等线程退出后再close。这是高并发实例里最容易埋的雷,单连接测试根本发现不了。
6. 把实例变成自己的:协议设计与压测验证
跑通 echo 只是起点,真正要落地得把实例改成自己的协议。我一般先定一个简单的二进制头:魔数 2 字节、版本 1 字节、类型 1 字节、长度 4 字节,后面跟 payload。魔数用来快速丢弃非法包,版本方便以后升级,类型区分请求和响应。定完头再写编解码函数,收发两侧共用,避免各写各的导致对不上。
验证环节别只靠手动敲。用nc或telnet能测基本连通,但压测要用工具。Linux 下wrk主要压 HTTP,纯 TCP 可以用iperf3测吞吐,或者自己写个多线程客户端循环发固定包。下面这个压测客户端骨架能帮你快速看服务端在并发下的表现。
#include <thread> #include <vector> #include <atomic> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> std::atomic<long> success{0}; void worker(const char* ip, int port, int rounds) { for (int i = 0; i < rounds; ++i) { int fd = socket(AF_INET, SOCK_STREAM, 0); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port); inet_pton(AF_INET, ip, &addr.sin_addr); if (connect(fd, (sockaddr*)&addr, sizeof(addr)) == 0) { const char* msg = "ping"; send(fd, msg, 4, 0); char buf[16]; if (recv(fd, buf, sizeof(buf), 0) > 0) success++; } close(fd); } } int main() { std::vector<std::thread> ts; for (int i = 0; i < 50; ++i) ts.emplace_back(worker, "127.0.0.1", 8888, 200); for (auto& t : ts) t.join(); // success 就是成功往返次数 return 0; }逻辑说明:50 线程各发 200 次,总共 1 万次连接,观察success和服务端内存、CPU。参数说明:线程数别超过ulimit -u限制,连接数别超过ulimit -n,否则socket直接返回 -1。压测时用ss -s看连接状态分布,TIME_WAIT过多说明短连接太频繁,考虑改长连接或连接池。
最后说个我自己的习惯:每份实例代码跑通后,我会立刻做三件事——把perror换成带errno和文件行号的日志宏,把硬编码的端口和 IP 抽成配置文件,把单连接逻辑和并发逻辑分文件。这样下次要改协议或换并发模型,不用从头翻代码。网络编程的坑大多不在语法,在边界条件和资源生命周期,实例只是脚手架,真正值钱的是你排查EINTR、EAGAIN、TIME_WAIT这些老朋友时攒下的手感。希望帮到你。
本文还有配套的精品资源,点击获取