C++文件上传下载服务器实战:分片、断点续传与socket协议设计
简介这是一份面向C网络编程初学者与进阶开发者的实践项目源码基于MFC与Socket实现文件上传下载服务器涵盖TCP可靠传输、多线程并发处理、断点续传、异常捕获及图形界面交互等核心知识点适合用于课程设计、毕业设计或自学网络通信的练手场景。压缩包共48个文件约3.66MB包含cpp与h源码、dsp与dsw工程文件、exe可执行程序、pdb与ilk调试符号、obj与sbr编译中间文件以及rc资源脚本和ReadMe说明服务器与客户端两套工程结构完整便于直接编译运行与二次修改。目前已有2995人学习下载读者可从中掌握监听套接字建立、三次握手连接、文件流读写、线程分配与断点进度保存等实现思路并借助MFC控件搭建可视化操作界面是理解TCP/IP通信与Windows桌面应用结合的不错参考。1. 从一次内网传包翻车说起这套 C 文件服务器到底能干嘛上周帮一个做嵌入式的朋友排查问题他们产线上一台工控机要把 2GB 的固件包分发给十几台设备用现成的脚本传传到一半断了重传还得从头来折腾一下午。后来我给他换了一套自己常用的 C 文件上传下载服务器带分片和断点续传同样的包十分钟推完中途断了一次也自动接上了。这就是这类资源最真实的价值场景——不是玩具 demo而是能扛住大文件、弱网、多客户端并发的实际传输需求。这套 C 实现的文件上传下载服务器核心就是两件事服务端监听端口接收客户端上传的文件并落盘客户端能连上服务端把本地文件推上去也能把服务端已有的文件拉下来。技术栈是标准 C 加 socket 网络编程服务端通常用多线程或 IO 多路复用处理并发连接协议层自己定义一套简单的应用层报文用来区分上传、下载、心跳、断点查询这些指令。适合谁一是想学网络编程但不想一上来就啃 muduo 这类重框架的开发者二是手头有内网文件分发、设备固件推送、日志回传这类轻量需求、又不想引入 HTTP 全家桶的工程师。它不解决公网穿透也不做权限体系定位就是内网可信环境下的高效文件搬运。2. 协议设计与 socket 选型为什么不用 HTTP 自己造轮子2.1 自定义二进制协议 vs 直接套 HTTP很多人第一反应是「文件传输用 HTTP 不就行了现成的库一堆」。这话对但要看场景。如果只是浏览器上传下载HTTP 确实省事。可这套资源的目标是 C 客户端到 C 服务端的点对点传输套 HTTP 意味着你要么引入 libcurl 这类依赖要么自己手写 HTTP 解析前者增加部署体积后者纯属给自己找活干。自定义二进制协议的好处是报文紧凑、解析快、字段完全可控尤其是做分片续传时你需要在报文里塞分片序号、偏移量、校验值HTTP 的 header 塞这些反而别扭。常见做法是定义一个固定长度的包头里面放魔数、命令字、数据长度后面跟变长负载。魔数用来做粘包校验命令字区分操作类型长度字段告诉接收方该读多少字节。这样接收端先读固定包头解析出长度再按长度读负载粘包问题自然解决。// 协议包头定义固定 16 字节避免结构体对齐带来的跨平台差异 #pragma pack(push, 1) struct PacketHeader { uint32_t magic; // 魔数 0x46494C45用于校验包合法性 uint16_t cmd; // 命令字1上传请求 2下载请求 3数据块 4传输完成 uint32_t bodyLen; // 负载长度接收端据此读取后续字节 uint32_t seq; // 分片序号断点续传时用来定位 uint16_t reserved; // 预留字段方便后续扩展 }; #pragma pack(pop)这里用#pragma pack(push,1)强制一字节对齐是因为不同编译器默认对齐方式不一样结构体直接发出去两端解析出来的字段偏移可能对不上这是跨平台传输最经典的翻车点。magic选一个不太可能出现在正常数据里的值接收端读到不匹配就说明流错位了直接断开重连比硬解析安全。seq字段是断点续传的关键客户端重连后带上已完成的序号服务端就知道从哪继续。2.2 多线程模型还是 epoll服务端并发模型的选择直接决定能扛多少连接。最朴素的是「一连接一线程」代码简单但连接数上百后线程切换开销就上来了而且每个线程默认栈空间不小内存吃不消。进阶一点用线程池主线程 accept 后把 fd 丢给工作线程处理控制住了线程数量。再往上就是 epoll 这类 IO 多路复用单线程或少量线程就能管理成千上万连接适合高并发场景。这套资源我一般建议如果是内网几十台设备的量级线程池足够代码可读性好调试也方便如果目标是做成通用文件服务、连接数可能上千那就上 epoll。选型没有绝对优劣关键是别过度设计。我见过有人给一个只有五台设备的内网环境硬上 epoll结果回调逻辑写得一团乱排查问题比线程模型费劲十倍。// 线程池版服务端 accept 循环核心逻辑 while (running) { sockaddr_in clientAddr; socklen_t addrLen sizeof(clientAddr); int clientFd accept(listenFd, (sockaddr*)clientAddr, addrLen); if (clientFd 0) { if (errno EINTR) continue; // 被信号打断继续 accept break; // 其他错误退出循环 } // 将新连接交给线程池处理避免主线程阻塞 threadPool.enqueue([clientFd]() { handleClient(clientFd); // 每个连接独立会话 }); }accept返回的clientFd是内核分配的新套接字和监听套接字分开后续读写都走它。EINTR判断不能省信号打断时accept会返回 -1如果不处理直接 break服务就莫名其妙退出了这个坑我在早期项目里踩过查了半天才发现是信号问题。把clientFd按值捕获进 lambda 交给线程池注意别传引用否则循环下一轮变量就变了。3. 上传下载核心链路分片、校验与断点续传怎么落地3.1 文件分片与偏移量计算大文件一次性读进内存再发几百 MB 就能把内存打爆所以必须分片。常见分片大小是 64KB 到 1MB太小则系统调用次数多、效率低太大则单次传输失败重传代价高。我一般取 256KB 作为默认值兼顾吞吐和重传成本。客户端打开文件后用seekg定位到指定偏移读固定长度封装成数据包发出去。// 客户端分片读取并发送chunkSize 默认 256KB const size_t chunkSize 256 * 1024; std::ifstream file(localPath, std::ios::binary); file.seekg(offset, std::ios::beg); // 从断点偏移开始读 std::vectorchar buffer(chunkSize); uint32_t seq offset / chunkSize; // 根据偏移反推分片序号 while (file.read(buffer.data(), chunkSize) || file.gcount() 0) { size_t bytesRead file.gcount(); // 实际读到的字节数最后一片可能不足 PacketHeader hdr{0x46494C45, 3, (uint32_t)bytesRead, seq, 0}; sendAll(sockFd, hdr, sizeof(hdr)); // 先发包头 sendAll(sockFd, buffer.data(), bytesRead); // 再发负载 }file.gcount()是关键最后一次读取往往不满一个分片必须用实际字节数不能固定发chunkSize否则会把缓冲区里的脏数据也发出去接收端文件末尾就多出一段垃圾。sendAll是自己封装的函数因为send系统调用不保证一次把数据全发完弱网下尤其明显必须循环发送直到指定字节数全部写出。// 确保指定长度数据全部发送处理部分写问题 bool sendAll(int fd, const void* data, size_t len) { const char* ptr static_castconst char*(data); size_t sent 0; while (sent len) { ssize_t n send(fd, ptr sent, len - sent, 0); if (n 0) { if (errno EINTR) continue; // 信号打断重试 return false; // 连接异常上层处理重连 } sent n; } return true; }3.2 断点续传的状态记录断点续传的本质是「记住传到哪了」。服务端每收到一个分片就把该文件已完成的偏移量写进一个状态表可以用内存 map也可以落盘成.meta文件。客户端重连后先发一个查询包带上文件名服务端返回已完成的最大偏移客户端seekg到那个位置继续。这里有个细节状态记录必须和实际落盘同步不能先更新状态再写文件否则写文件失败时状态就骗人了续传会丢数据。字段类型作用注意事项fileNamestring文件唯一标识需做路径过滤防目录穿越totalSizeuint64文件总大小用于校验最终完整性receivedOffsetuint64已接收字节数每次落盘后更新md5string整文件校验值传输完成后比对路径过滤这条必须强调客户端传上来的文件名如果带../服务端直接拼接路径就会写到预期目录之外这是文件服务器最典型的安全漏洞。常见做法是对文件名做白名单字符校验或者只取 basename把路径部分全部丢掉。3.3 完整性校验与收尾传输完成后客户端发一个「完成」命令带上本地算好的 MD5 或 CRC32服务端对落盘文件重新算一遍两者一致才算成功。校验这步别省弱网环境下 TCP 校验和只能保证链路层没错但应用层逻辑错误、分片错位导致的文件损坏只有端到端校验能发现。算 MD5 可以用 OpenSSL也可以自己实现一个轻量 CRC32后者速度快对文件传输场景足够用。4. 避坑与排查那些让我加班到凌晨的传输问题4.1 大文件传输到 2GB 附近必断现象文件传到 2GB 左右连接就断小文件完全正常。原因很多人在协议里用int或uint32_t存文件大小和偏移2GB 正好是int32的上限溢出后变成负数后续seekg或长度计算全乱。解决所有和文件大小、偏移相关的字段一律用uint64_t包括包头里的长度字段别图省事用 int。4.2 接收端文件末尾多出一段重复数据现象下载下来的文件比原文件大末尾多出几百 KB 到几 MB 的重复内容。原因发送端最后一次read不满分片但代码里固定按chunkSize发送把缓冲区残留的上一片数据又发了一遍。解决严格用gcount()返回的实际字节数作为发送长度包头里的bodyLen也要同步改成实际值。4.3 多客户端同时上传同名文件互相覆盖现象两台设备同时上传同名固件最终文件内容错乱。原因服务端用文件名直接作为落盘路径没有做并发隔离两个会话同时写同一个文件句柄。解决给每个上传会话分配临时文件名如文件名.会话ID.tmp传输完成校验通过后再原子重命名成正式文件名rename在同一文件系统内是原子操作。4.4 客户端断线重连后续传位置不对现象断线重连后从错误位置继续文件中间缺一段。原因服务端状态表更新和文件写入顺序反了或者状态只存在内存里服务端重启后丢失。解决先write落盘并fsync再更新状态状态表定期持久化到.meta文件服务端启动时加载恢复。4.5 传输速度远低于网卡带宽现象千兆内网实测只有十几 MB/s。原因分片太小导致系统调用过于频繁或者每次send后没做缓冲频繁小包发送。解决分片调到 256KB 以上发送端用setsockopt调大SO_SNDBUF接收端调大SO_RCVBUF减少内核缓冲区不足导致的阻塞。5. 进阶技巧把传输吞吐再压榨一截想让这套服务器跑得更快有几个我实测有效的点。第一是零拷贝Linux 下用sendfile系统调用数据直接从内核文件缓冲区送到 socket 缓冲区省掉用户态和内核态之间的两次拷贝大文件下载场景提升明显。不过sendfile要求源是文件描述符上传方向用不上下载方向收益最大。// 下载方向用 sendfile 零拷贝offset 由内核维护 off_t offset resumeOffset; size_t remaining fileSize - resumeOffset; while (remaining 0) { ssize_t n sendfile(clientFd, fileFd, offset, remaining); if (n 0) break; // 出错或对端关闭 remaining - n; // 内核自动推进 offset }sendfile的offset是传入指针内核会修改它所以循环里不用手动加这点和普通read/write不一样第一次用容易写错。第二是开启 TCP_NODELAY禁用 Nagle 算法小包场景下能降低延迟但大文件传输时反而可能增加小包数量建议只在控制指令通道开数据通道关。第三是接收端预分配文件空间用posix_fallocate一次性把磁盘空间占好避免边写边扩导致的碎片和性能抖动。验证传输是否可靠我习惯用一个笨办法但很有效传完后两端各算一次 SHA256比对一致才算过。别只信「传输完成」的返回码逻辑 bug 往往就藏在你以为没问题的地方。从那以后我每次改完传输逻辑都强制走一遍「大文件 中途断网 重连续传 校验比对」的完整流程少一步都可能在生产环境还回来。希望帮到你。本文还有配套的精品资源点击获取