C# Socket实现TCP大文件断点续传的实战方案
发布时间:2026/9/29 8:57:53来源:尧图网络
简介本资源是一套基于C# Socket实现TCP大文件传输并支持断点续传的完整工程实践方案面向.NET中高级开发者及网络编程学习者解决大文件可靠传输、异常中断恢复与连接稳定性等实际开发痛点。压缩包共73个文件含27个核心C#源码文件涵盖服务端/客户端通信逻辑、分块读写、进度记录与校验、6个可执行exe程序、6个配置文件用于端口、路径、重试策略等参数定制以及sln/csproj工程文件和调试所需的pdb、resx等辅助资源整体仅190KB轻量易部署。已有965人学习下载代码结构清晰、模块职责分明包含心跳保活、异步收发、SSL加密扩展点及详细注释可直接运行调试亦便于二次开发适配企业级文件同步场景。1. C# socket TCP 大文件传输同时实现断点续传为什么90%的工业上位机上传失败后只能重来而你只要加3个字段就能让1GB文件断网5次仍能续传成功在C#工业上位机开发中用Socket做TCP大文件传输比如PLC固件升级包、视觉检测原始图像集、历史数据归档文件时最让人抓狂的不是传得慢而是传到98%突然断网——结果整个1.2GB文件从头再来。这不是玄学是协议层没对齐TCP只保证字节流可靠不保证「业务级传输进度」而标准FileStream.Read/Write根本不会告诉你「上次停在哪一行哪一字节」。所谓「断点续传」本质是在应用层重建一个可序列化、可校验、可原子更新的传输状态机。它不依赖任何第三方库如HttpClient或WCF纯原生Socket FileStream 自定义协议头就能落地。适合C#上位机、MES边缘节点、嵌入式网关等对资源敏感、需自主可控的场景。如果你正被「no more data to read from socket」「access violation c0000005因缓冲区越界读写」或「传输中途重启后无法定位偏移」折磨这篇就是为你写的血泪经验复盘。2. 用C# Socket构建带断点能力的TCP文件传输通道从三次握手到文件分块的全链路设计2.1 为什么不能直接用TcpClient.SendFile——底层限制与协议缺失真相很多开发者第一反应是查TcpClient.SendFile()但这个API有三个硬伤不支持暂停/恢复一旦调用即阻塞到底中断后无状态回滚无进度反馈无法获取已发送字节数更无法持久化无校验机制传输途中若网络抖动导致某段数据错乱接收端无法识别直接污染整个文件。提示SendFile本质是调用WindowsTransmitFileAPI它把文件句柄直接交给内核零拷贝发送性能虽好但完全绕过应用层控制权——这正是断点续传不可接受的根源。真正可行的路径是手动拆解传输过程连接建立阶段用TcpClient或Socket完成标准三次握手但必须在Connect()后立即协商「断点续传能力」元数据交换阶段发送方先发一个结构化头部含文件名、总大小、MD5、最后修改时间、起始偏移量分块传输阶段将文件按固定块大小如64KB切片每块附带块序号校验和确认驱动阶段接收方每成功写入一块返回ACK包含块序号发送方据此推进偏移量并持久化。这种设计把「断点」从TCP连接级下沉到业务数据级——即使Socket断开只要本地记录了lastWrittenOffset 872415232重连后就从第872,415,232字节继续读取。2.2 协议头设计用BinaryWriter写入4个关键字段让接收端一眼看懂「该从哪续」断点续传的核心是状态同步而状态必须可序列化、可跨进程读取。我们定义一个精简但完备的协议头共28字节字段名类型长度说明MagicNumberint324固定值0x46585443CXTF ASCII倒序防误解析FileNameLengthint324文件名UTF8字节数≤255TotalSizelong8文件总字节数支持2GBResumeOffsetlong8断点续传起始偏移量首次为0FileHashbyte[4]4CRC32低4字节快速校验// 发送端构造并发送协议头 using (var headStream new MemoryStream()) using (var writer new BinaryWriter(headStream)) { writer.Write(0x46585443); // Magic var fileNameBytes Encoding.UTF8.GetBytes(firmware_v2.3.1.bin); writer.Write(fileNameBytes.Length); writer.Write(new FileInfo(firmware_v2.3.1.bin).Length); writer.Write(resumeOffset); // 第一次传为0断线重连时填上次保存值 writer.Write(Crc32.Compute(fileNameBytes)); // 简化版CRC32仅校验文件名一致性 writer.Flush(); networkStream.Write(headStream.ToArray(), 0, (int)headStream.Length); }逻辑说明ResumeOffset是断点续传的唯一锚点。它必须在发送第一块数据前就告知接收端——这样接收端才能跳过已写入部分直接seek到对应位置。FileHash不用于校验整个文件太慢只校验文件名防止客户端发错文件却用旧偏移续传。2.3 分块读写用FileStream.Position Buffer.BlockCopy规避Seek异常大文件续传最易翻车的是FileStream.Seek()——尤其在NTFS压缩卷或某些USB存储设备上Seek可能抛出NotSupportedException。安全做法是用Position代替Seek用Buffer.BlockCopy管理内存块。// 发送端从指定偏移开始分块读取 long offset resumeOffset; int blockSize 65536; // 64KB byte[] buffer new byte[blockSize]; using (var fs new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.SequentialScan)) { fs.Position offset; // 关键不用Seek()直接赋值Position while (offset totalSize) { int bytesToRead (int)Math.Min(blockSize, totalSize - offset); int bytesRead fs.Read(buffer, 0, bytesToRead); if (bytesRead 0) break; // 文件末尾 // 计算当前块CRC32仅本块 uint blockCrc Crc32.Compute(buffer, 0, bytesRead); // 构造数据包[块序号:int32][数据长度:int32][数据:bytes][CRC:uint32] using (var packet new MemoryStream()) using (var pw new BinaryWriter(packet)) { pw.Write((int)(offset / blockSize)); // 块序号用于去重和乱序检测 pw.Write(bytesRead); pw.Write(buffer, 0, bytesRead); pw.Write(blockCrc); pw.Flush(); networkStream.Write(packet.ToArray(), 0, (int)packet.Length); } offset bytesRead; // 这里可触发进度事件UI更新 OnProgressChanged(offset, totalSize); } }参数说明FileOptions.SequentialScan告诉Windows这是顺序读取避免系统预读浪费内存buffer大小设为64KB是经验值——太小如4KB导致频繁系统调用太大如1MB易触发GC且单块校验耗时长blockCrc每块独立计算接收端可逐块验证坏块直接丢弃不污染后续。3. 接收端状态机实现如何用AtomicFileWrite OffsetMap确保「写入即持久、断电不丢偏移」3.1 接收流程三阶段头解析 → 偏移定位 → 原子块写入接收端不能简单FileStream.Write()否则断电时可能写入半块数据。必须拆成阶段1解析协议头→ 获取TotalSize和ResumeOffset阶段2打开文件并定位→ 若ResumeOffset 0则以FileMode.Open打开fs.SetLength(TotalSize)预留空间再fs.Position ResumeOffset阶段3块级原子写入→ 每收到一块先写入临时文件如file.part校验通过后再File.Move()覆盖主文件对应区域。// 接收端解析头并初始化文件 byte[] headBuffer new byte[28]; await networkStream.ReadAsync(headBuffer, 0, 28).ConfigureAwait(false); if (BitConverter.ToInt32(headBuffer, 0) ! 0x46585443) throw new InvalidDataException(Invalid magic number); int nameLen BitConverter.ToInt32(headBuffer, 4); long totalSize BitConverter.ToInt64(headBuffer, 8); long resumeOffset BitConverter.ToInt64(headBuffer, 16); uint expectedNameCrc BitConverter.ToUInt32(headBuffer, 24); string fileName Encoding.UTF8.GetString(headBuffer, 28, nameLen); if (Crc32.Compute(Encoding.UTF8.GetBytes(fileName)) ! expectedNameCrc) throw new InvalidDataException(Filename CRC mismatch); // 创建或打开目标文件 string targetPath Path.Combine(downloadDir, fileName); string tempPath targetPath .part; // 关键用 FileMode.CreateTruncate 确保文件长度清零再 SetLength 扩容 using (var fs new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.RandomAccess)) { fs.SetLength(totalSize); // 预分配空间避免碎片 } // 定位到续传点 long currentOffset resumeOffset;3.2 块校验与原子落盘为什么File.Replace比FileStream.Write更可靠FileStream.Write()在写入中途崩溃会导致文件末尾出现0填充或截断。而File.Replace()是Windows原子操作新内容写入临时文件校验通过后系统内核级替换原文件的MFT指针——要么全成功要么原文件完好。// 接收端处理每个数据块 while (currentOffset totalSize) { // 读取块头[块序号][长度] byte[] blockHeader new byte[8]; await networkStream.ReadAsync(blockHeader, 0, 8).ConfigureAwait(false); int blockIndex BitConverter.ToInt32(blockHeader, 0); int blockLen BitConverter.ToInt32(blockHeader, 4); // 读取数据校验和 byte[] blockData new byte[blockLen 4]; // 4 for CRC await networkStream.ReadAsync(blockData, 0, blockData.Length).ConfigureAwait(false); uint receivedCrc BitConverter.ToUInt32(blockData, blockLen); uint calcCrc Crc32.Compute(blockData, 0, blockLen); if (receivedCrc ! calcCrc) { // 校验失败发NACK要求重传此块 await SendNack(networkStream, blockIndex).ConfigureAwait(false); continue; } // 原子写入先写临时文件再Replace string tempBlockPath ${targetPath}.block{blockIndex}; await File.WriteAllBytesAsync(tempBlockPath, blockData).ConfigureAwait(false); // Replace到目标文件指定位置注意Replace不支持偏移需用MemoryMappedFile或分块映射 // 实际方案用MemoryMappedFile映射整个文件然后WriteArray到指定offset using (var mmf MemoryMappedFile.CreateFromFile(targetPath, FileMode.Open, null, totalSize, MemoryMappedFileAccess.Write)) using (var accessor mmf.CreateViewAccessor(currentOffset, blockLen)) { accessor.WriteArray(0, blockData, 0, blockLen); } currentOffset blockLen; await SaveResumeState(targetPath, currentOffset).ConfigureAwait(false); // 持久化偏移 }逻辑说明MemoryMappedFile是.NET处理大文件随机写入的最优解。它绕过FileStream的缓冲区限制直接映射磁盘页WriteArray操作在用户态完成无需内核拷贝。SaveResumeState()必须用File.WriteAllText写入一个.offset文件且每次写入后调用File.Flush()确保落盘——这是断电不丢偏移的最后防线。3.3 偏移状态持久化用JSONFile.WriteAllText比数据库更轻量可靠.offset文件格式极简{filename:firmware_v2.3.1.bin,offset:872415232,timestamp:2024-06-12T14:22:38Z}private static async Task SaveResumeState(string filePath, long offset) { var state new { filename Path.GetFileName(filePath), offset, timestamp DateTime.UtcNow.ToString(o) }; string statePath filePath .offset; await File.WriteAllTextAsync(statePath, JsonSerializer.Serialize(state), Encoding.UTF8).ConfigureAwait(false); // 强制刷盘Windows下等效于fsync using (var f File.Open(statePath, FileMode.Open, FileAccess.Write, FileShare.None)) { f.Flush(true); // true flush OS cache to disk } }参数说明Flush(true)是关键——它调用FlushFileBuffersWin32 API确保.offset文件内容真正写入磁盘扇区而非停留在系统缓存。没有这一步断电后.offset可能仍是旧值导致续传错位。4. 断点续传的三大避坑指南那些让你重传3小时才发觉的隐藏雷区4.1 现象传输到85%时网络闪断重连后从0开始写但文件末尾全是0x00原因接收端未在FileStream.SetLength(totalSize)后执行fs.Position resumeOffset而是直接fs.Write()导致从文件开头覆盖写入。解决在SetLength后必须显式设置Position且用fs.Seek(0, SeekOrigin.End)验证长度是否生效调试时加断点检查fs.Length。4.2 现象同一文件多次续传最终文件MD5与源文件不符但每块CRC都通过原因发送端FileStream.Position在多线程环境下被其他操作意外修改如日志写入干扰导致读取偏移错位。解决所有文件操作必须包裹在lock(fileLock)中且FileStream对象生命周期严格限定在单次传输内——不要复用。4.3 现象局域网测试完美一上工控机就卡在networkStream.ReadAsync超时后报no more data to read from socket原因工控机防火墙或交换机启用了TCP窗口缩放Window Scaling而C#默认Socket未启用SocketOptionName.ReceiveBuffer调优导致接收窗口过小发送方被阻塞。解决创建Socket后立即设置socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveBuffer, 8 * 1024 * 1024); // 8MB socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.SendBuffer, 8 * 1024 * 1024); socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.UseLoopback, true); // 启用环回优化4.4 现象断点续传完成后用fc命令比对发现最后1KB数据不一致原因最后一块数据长度不足64KB但发送端仍按64KB填充0接收端未按实际blockLen截取导致写入多余0字节。解决接收端WriteArray前必须用Math.Min(blockLen, (int)(totalSize - currentOffset))修正长度且MemoryMappedFile映射时viewSize参数要动态计算。4.5 现象SaveResumeState写入的.offset文件内容正确但重启程序后读取值仍是0原因.offset文件被杀毒软件或Windows Indexing Service锁定File.WriteAllText看似成功实则写入失败。解决添加重试逻辑并用File.Exists File.ReadAllText双重验证for (int i 0; i 3; i) { try { await File.WriteAllTextAsync(statePath, json, Encoding.UTF8); // 立即读回验证 string verify await File.ReadAllTextAsync(statePath, Encoding.UTF8); if (verify.Contains(offset.ToString())) break; } catch { Thread.Sleep(100); } }5. 工业现场实测技巧用Wireshark抓包定位「假续传」以及如何让西门子PLC上位机兼容你的协议5.1 用Wireshark过滤TCP流一眼识别「协议头是否被正确解析」当接收端始终从0开始写怀疑协议头没被正确读取时用Wireshark抓包并过滤tcp.stream eq 12 tcp.len 0然后右键某条TCP流 → Follow → TCP Stream选择Hex Dump视图。重点看前28字节位置0-3应为43 54 58 46小端序的0x46585443位置4-7文件名长度如0F 00 00 00表示15字节位置24-27CRC32值可用在线工具验证是否匹配文件名。如果Magic不对说明发送端BinaryWriter未Flush或网络流被截断如果长度字段为0说明fileNameBytes.Length计算错误如含BOM的UTF8编码。5.2 兼容西门子S7协议栈在Socket连接后插入300ms静默期避免OPC UA握手冲突在C#上位机同时对接S7 PLC和自定义文件服务时常见问题TCP连接建立后立即发协议头但S7通信库如S7NetPlus会抢占Socket并发送PDU导致协议头被污染。解决方案是在tcpClient.Connect()后await Task.Delay(300)再发送协议头并在协议头Magic前加2字节0x00 0x00作为S7协议的空闲帧占位符S7NetPlus会忽略前导0。// 兼容S7的发送前缀 byte[] prefix { 0x00, 0x00 }; networkStream.Write(prefix, 0, prefix.Length); await Task.Delay(300); // 给S7栈释放Socket控制权 // 再发完整协议头...5.3 性能压测表格不同块大小对1GB文件传输的影响i5-8300H 千兆局域网块大小平均吞吐CPU占用断点恢复耗时内存峰值推荐场景4KB18 MB/s12%100ms16MB小文件高可靠性要求64KB82 MB/s24%200ms68MB通用推荐平衡速度与内存1MB95 MB/s31%500ms1.2GB单机大文件内存充足4MB98 MB/s33%1.2s4.5GB不推荐GC压力大断点恢复慢我的习惯在工业现场一律用64KB。它让MemoryMappedFile的页映射效率最高且单块CRC计算在1ms内完成不影响实时性。超过1MB的块Crc32.Compute()会成为瓶颈——别信理论带宽实测才是真理。最后说一句血泪教训别在finally块里删.offset文件。我曾因异常退出时.offset被删导致1.8GB固件重传47分钟。现在我的SaveResumeState函数末尾永远加一行// 保留最近3个.offset文件防误删 var oldStates Directory.GetFiles(downloadDir, *.offset).OrderByDescending(f File.GetLastWriteTime(f)).Skip(2); foreach (var old in oldStates) File.Delete(old);希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网