简介这是一份面向 C 网络编程初学者与进阶开发者的入门实例文档围绕 Windows 平台下基于 VC 与 MFC 的网络应用开发展开帮助读者建立从理论模型到套接字编程的完整认知。内容涵盖网络编程概述、OSI 七层模型与 TCP/IP 四层协议对比、C/S 通信模型以及流式套接字与数据报套接字、网络字节顺序、CAsyncSocket 与 CSocket 类的使用要点并对比 MFC 封装与 Windows API 两种开发方式的差异。资源包共 1 个 pdf 文件约 114KB篇幅精炼适合作为随查随用的基础参考。目前已有 564 人学习适合需要快速梳理网络通信原理、准备课程实验或面试复习的读者可据此理解数据封装与解包过程、TCP 与 UDP 的适用场景及客户端与服务器端的连接流程。1. 从一份《C网络编程实例.pdf》说起为什么 socket 才是绕不开的那道坎很多人第一次搜「c网络编程实例.pdf」心里想的其实很朴素找一份能照着敲、敲完就能跑通的例子把 TCP 客户端和服务端连起来看到那句自己发出去又被收回来的话。这个诉求一点不丢人反而是最务实的入口。C 网络编程的门槛从来不在语法语法你早就会了难的是把「阻塞」「字节序」「粘包」「连接生命周期」这些看不见的东西一次性理顺。而 socket 网络编程恰好是那个把所有抽象都撕掉、逼你直面操作系统接口的地方。这份标题对应的内容本质上是一套以实例驱动的学习路径从最简单的 socket 创建、bind、listen、accept到客户端 connect、send、recv再到多连接下的 select、epoll 或者线程模型。它适合两类人一类是刚学完 C 基础、想找个真实项目练手的入门者另一类是写了几年业务代码、但网络这块一直是黑匣子的后端或客户端开发者。你不需要先精通操作系统但你需要愿意动手编译、抓包、看错误码。接下来我不复述某份 PDF 的目录而是按一线做项目的顺序把这条路径拆成能复现的步骤和能避开的坑。2. 先把最小可运行模型跑通TCP 回声服务的完整代码与编译命令2.1 为什么第一个例子必须是无阻塞依赖的单连接回声新手最容易翻车的地方是一上来就写多线程服务器结果连 accept 返回什么都没看清。我的习惯是先用单连接、阻塞式、一次收发把整条链路走通。这个模型里只有五个系统调用socket、bind、listen、accept、recv/send。它们各自返回什么、失败时 errno 是什么你必须亲眼看到一次。选阻塞模式不是因为它好而是因为它把并发问题暂时藏起来让你专注在连接本身。等你确认客户端能连上、数据能原样回来再去加并发心里才有底。下面这段代码是服务端监听 8888 端口收到数据后原样发回收到quit就关闭连接。它不完美但足够你跑通第一次。// echo_server.cpp // 编译: g -stdc17 -O2 -o echo_server echo_server.cpp #include arpa/inet.h #include unistd.h #include cstring #include iostream int main() { int listen_fd ::socket(AF_INET, SOCK_STREAM, 0); // 创建 TCP socket if (listen_fd 0) { perror(socket); return 1; } int opt 1; // SO_REUSEADDR 让服务器重启时不必等 TIME_WAIT 结束 ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); 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; } if (::listen(listen_fd, 128) 0) { // 128 是已完成连接队列上限 perror(listen); return 1; } std::cout listening on 8888\n; sockaddr_in client{}; socklen_t len sizeof(client); int conn_fd ::accept(listen_fd, (sockaddr*)client, len); if (conn_fd 0) { perror(accept); return 1; } char buf[1024]; while (true) { ssize_t n ::recv(conn_fd, buf, sizeof(buf) - 1, 0); if (n 0) break; // 0 表示对端关闭0 表示出错 buf[n] \0; std::cout recv: buf; if (std::strncmp(buf, quit, 4) 0) break; ::send(conn_fd, buf, n, 0); // 原样回发 } ::close(conn_fd); ::close(listen_fd); return 0; }逻辑说明socket返回的文件描述符就是后续所有操作的句柄bind把地址和端口绑到它上面listen把它变成被动套接字accept阻塞直到有客户端连上返回一个全新的描述符代表这条连接。参数上SOCK_STREAM表示 TCPINADDR_ANY表示不限定网卡htons和htonl负责主机字节序到网络字节序的转换这两个函数写反了端口就会变成另一个数是经典翻车点。2.2 客户端代码与一次完整的收发验证客户端比服务端简单核心是connect和send/recv。注意inet_pton把点分十进制字符串转成二进制地址比老的inet_addr更安全。// echo_client.cpp // 编译: g -stdc17 -O2 -o echo_client echo_client.cpp #include arpa/inet.h #include unistd.h #include cstring #include iostream int main() { int fd ::socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); return 1; } sockaddr_in serv{}; serv.sin_family AF_INET; serv.sin_port htons(8888); // 127.0.0.1 表示本机回环跨机测试时换成服务端实际 IP if (::inet_pton(AF_INET, 127.0.0.1, serv.sin_addr) 0) { perror(inet_pton); return 1; } if (::connect(fd, (sockaddr*)serv, sizeof(serv)) 0) { perror(connect); return 1; } const char* msg hello socket\n; ::send(fd, msg, std::strlen(msg), 0); char buf[1024]; ssize_t n ::recv(fd, buf, sizeof(buf) - 1, 0); if (n 0) { buf[n] \0; std::cout echo: buf; } ::close(fd); return 0; }验证步骤先在一个终端跑./echo_server再开另一个终端跑./echo_client服务端应打印recv: hello socket客户端应打印echo: hello socket。如果 connect 报Connection refused九成是服务端没起来或端口被占如果 bind 报Address already in use检查是不是没加SO_REUSEADDR或者上一个进程没退干净。这套最小模型跑通你才算真正摸到了 C 网络编程的门。3. 从单连接到并发select、epoll 与线程模型怎么选3.1 阻塞模型为什么撑不住第二个客户端上面那个服务端只能服务一个连接因为accept之后整个进程就卡在recv上了。第二个客户端连上来时它还在等第一个客户端发数据内核的已完成连接队列会堆积直到队列满然后拒绝新连接。这不是代码写错了是模型本身的限制。要同时处理多个连接常见做法有三条路多线程/多进程、I/O 多路复用select/poll/epoll、以及异步框架。选哪条取决于你的连接数和业务形态。连接数几十到几百、逻辑简单多线程最直观一个连接一个线程代码几乎不用改。但线程有栈开销默认 8MB几百个线程内存就吃紧了而且上下文切换成本随连接数上升。连接数上千甚至上万、每个连接数据量不大就该上 epoll。select 有 1024 文件描述符上限且每次调用要重新传集合poll 去掉了上限但仍是线性扫描epoll 用红黑树加就绪链表只在活跃连接上花时间。这是选型的核心依据不是哪个新就用哪个。3.2 用 epoll 改写服务端的关键代码下面这段是 epoll 版本的核心循环省略了错误处理细节重点看epoll_create1、epoll_ctl、epoll_wait三个调用怎么配合。// epoll_server.cpp (核心片段) #include sys/epoll.h #include fcntl.h int epfd ::epoll_create1(0); // 创建 epoll 实例 epoll_event ev{}; ev.events EPOLLIN; // 关心可读事件 ev.data.fd listen_fd; ::epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); // 把监听 fd 加进去 epoll_event events[1024]; while (true) { // -1 表示无限等待返回就绪的事件个数 int n ::epoll_wait(epfd, events, 1024, -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); // 新连接设为非阻塞避免 recv 卡住整个循环 int flags ::fcntl(conn, F_GETFL, 0); ::fcntl(conn, F_SETFL, flags | O_NONBLOCK); ev.events EPOLLIN | EPOLLET; // ET 边沿触发配合非阻塞 ev.data.fd conn; ::epoll_ctl(epfd, EPOLL_CTL_ADD, conn, ev); } else { char buf[1024]; ssize_t r ::recv(fd, buf, sizeof(buf), 0); if (r 0) { ::send(fd, buf, r, 0); } else if (r 0 || (r 0 errno ! EAGAIN)) { ::epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr); ::close(fd); } } } }逻辑说明epoll_wait返回的是就绪事件数组你只处理这些 fd不用遍历所有连接。EPOLLET是边沿触发意味着数据到达只通知一次你必须一次把缓冲区读干净否则剩余数据不会再触发事件这是 ET 模式最常见的坑。所以配合非阻塞 fd循环recv直到返回EAGAIN。参数上epoll_wait的第四个参数是超时毫秒数-1 表示永久阻塞0 表示立即返回实际项目里常设一个值来做定时任务。3.3 线程模型与 epoll 的混合用法纯 epoll 单线程能扛住很高的连接数但业务逻辑一旦有耗时操作查数据库、读文件就会拖慢整个事件循环。常见做法是 epoll 负责收发包把解析好的请求丢进线程池处理处理完再通过 eventfd 或管道唤醒 epoll 线程回写。这个模式就是 Reactor很多 C 网络库的骨架。如果你不想自己造轮子可以看看 muduo、libevent 这类库的设计但前提是你已经手写过一遍 epoll否则看库只会更晕。选型上没有银弹连接少、逻辑重多线程更省心连接多、逻辑轻epoll 更合适两者都有就混合。4. 避坑与排查那些让 C 网络程序半夜崩掉的细节4.1 现象客户端发出去的数据服务端收到一半就断了原因通常是 TCP 是字节流协议没有消息边界。你send了 100 字节对端可能第一次recv只收到 60 字节剩下 40 字节在下一次。很多人误以为一次 send 对应一次 recv这是粘包问题的根源。解决办法是在应用层定义消息格式比如固定长度头加变长体头部里写明 body 长度接收方先读头再按长度读体。别指望 TCP 帮你保留边界它不干这事。4.2 现象服务端重启时报 Address already in use原因是上一次的连接处于 TIME_WAIT 状态内核还占着端口。主动关闭连接的一方会进入 TIME_WAIT持续 2MSL通常是几十秒到几分钟。解决就是在 bind 之前设置SO_REUSEADDR代码里那行setsockopt就是干这个的。注意它只解决重启问题不解决端口被别的进程占用的问题后者要用lsof -i:8888或netstat查。4.3 现象recv 返回 -1errno 是 EINTR原因是系统调用被信号中断比如你用了 alarm 或者收到了 SIGCHLD。这不是错误正确处理是判断errno EINTR然后重试而不是直接关连接。很多新手看到 -1 就 close结果连接莫名其妙断掉。同理非阻塞模式下errno EAGAIN或EWOULDBLOCK表示数据读完了不是出错要区别对待。4.4 现象跨机测试连不上本机却正常原因通常是防火墙或监听地址不对。INADDR_ANY监听所有网卡但如果你写死了127.0.0.1那只有本机能连。跨机时客户端要填服务端的真实内网 IP服务端要确认防火墙放行了对应端口。另外云服务器还有安全组规则这一层经常被忽略。排查顺序先ping通不通再telnet ip port看端口通不通最后才怀疑代码。4.5 现象程序跑一段时间后文件描述符耗尽原因是连接关闭后没有close或者 accept 返回的 fd 泄漏了。每个进程的 fd 数量有上限用ulimit -n查看默认常见是 1024。长连接服务必须确保每条连接在出错或对端关闭时都走到 closeepoll 里还要先EPOLL_CTL_DEL再 close。用lsof -p 进程号 | wc -l可以观察 fd 数量是否持续增长这是最直接的泄漏信号。5. 进阶技巧用抓包和压测把「玄学」变成可观测的数据写到能跑通并发之后真正的分水岭是你有没有能力定位线上问题。我自己的习惯是两件工具不离手tcpdump 和压测脚本。tcpdump 抓包能让你看到三次握手、数据包、FIN 的全过程很多「代码没问题但就是不通」的情况抓一次包就真相大白。比如你怀疑对端没收到抓包看到对方回了 RST那就是端口没监听看到重传那就是网络丢包。命令很简单tcpdump -i any -nn port 8888 -w dump.pcap然后用 Wireshark 打开看时序图。压测方面别自己写循环 send 就以为在压测那样测不出真实并发。用wrk或ab对 HTTP 服务压对自定义 TCP 协议可以写个简单的多线程客户端每个线程建 N 条连接持续发固定大小包统计 QPS 和延迟分布。重点看三个指标连接建立成功率、平均延迟、以及服务端 CPU 和内存曲线。如果 QPS 上不去但 CPU 很低多半是阻塞在某个系统调用上如果 CPU 打满但 QPS 低可能是锁竞争或频繁拷贝。还有一个容易被忽视的点是字节序和结构体对齐。网络传输的结构体如果直接send整个 struct不同编译器对齐方式可能不一样对端解析就错位。稳妥做法是手动序列化每个字段整数一律用htonl/htons转网络序。这个坑在跨平台场景下几乎必踩早点养成手动序列化的习惯能省下大量调试时间。最后说个我自己的教训早年写一个文件传输服务本地测试一切正常上线后大文件传到 90% 就断。查了两天才发现是发送端send返回值没检查内核缓冲区满了之后send只发了一部分剩下的被丢弃。TCP 的send不保证一次发完必须循环发送直到全部写完。这个习惯我后来坚持了很多年任何send和recv的返回值都要当成「本次处理了多少」而不是「成功或失败」。网络编程里没有想当然只有返回值。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?