TCP 文件传输这个题目,看起来像是课程设计或者面试前突击的项目,但真把它写好,比你想象中要挖得深。我在实际开发里被 TCP 的“字节流”特性坑过不止一次,也见过不少把send和recv当成“一次发完、一次收全”导致文件损坏的代码。这篇文章我会从协议设计、代码实现到疑难排查,把基于 TCP 的 C 语言文件传输完整讲一遍,不光是能跑,还要能解释清楚每一步为什么这么做。
适合正在学网络编程的学生、想补 socket 基础的后端开发,以及准备把手头小工具做成稳定文件传输功能的读者。看完你会知道:三次握手和accept返回的关系、粘包半包怎么根治、为什么send会“卡死”、以及大文件在 4GB 边界崩溃的字节序坑。
1. 整体设计与协议拆解
1.1 为什么选 TCP 而不选 UDP
文件传输最怕数据丢,一个字节丢了,整个文件就废了。TCP 在这个场景下的价值是:它帮你把“分片排序、丢包重传、流量控制、拥塞控制”这些脏活累活全包了。我在 Fixture 里对比过:UDP 如果要传文件,还得自己实现序号、ACK、超时重传、滑动窗口,这一套写完基本等于重造半个 TCP 轮子,而且 99% 的初学者写出来的重传逻辑会把自己绕晕。
让我用一个生活化的类比:TCP 像是寄 EMS,虽然贵一点、慢一点,但你只需要把包裹交给快递员,中途转运、丢件理赔都不用操心。UDP 像是自己开车送信,速度快,但路上要自己看导航、躲交警、防爆胎,任何一环出问题,文件就缺一块。所以 TCP 正好的是文件传输的默认选择。
从代码层面也要确认:connect()返回成功,意味着三次握手已经完成,这时候你往 socket 里写数据,对端只要accept()过就能收到。第三次握手(ACK)其实在操作系统协议栈里已经发出去了,应用层不需要管,但理解这个过程有助于你后续调试“为什么 connect 很快,但对方没收到数据”之类的问题。
1.2 文件传输协议该长什么样
TCP 只给字节流,不像 UDP 那样自带消息边界。所以我们需要自己设计一个“应用层协议”来切分消息。我常用的最小可用方案是:
- 客户端先发一条“请求头”,告诉服务端要哪个文件。
- 服务端校验后,先回“文件信息头”,包含文件名长度、文件名、文件大小。
- 然后服务端把文件分块(比如 32KB 一块)连续发出去。
- 客户端循环接收,写到本地磁盘,直到收满文件大小。
这里有个最容易踩的坑:接收方怎么知道文件结束了?不能靠recv返回 0,因为那表示对端关闭,不代表消息边界。所以我们固定用文件大小来判定,收满file_size字节就结束。这是文件传输协议设计里最重要的一条规则。
同样,文件名是变长字段,所以头部里必须有长度字段。我习惯在固定头里放一个magic(魔数),用来快速判断这个连接是不是我们协议的数据。魔数选一个不太常见的值,比如0x46494C45(ASCII 是 "FILE")。这样万一连到一个无关服务,第一包数据就校验不过,直接断开完事。
1.3 完整交互流程梳理
一个标准的“客户端请求下载”流程是这样的:
- 客户端
socket()创建套接字,connect()到服务端,这里发生了三次握手:SYN → SYN+ACK → ACK。 - 客户端
send()发送请求包:命令号(1 表示下载)+ 文件名长度 + 文件名。 - 服务端
accept()返回新连接 fd,打开目标文件,先发file_size(8 字节),再发数据块。 - 客户端先收 8 字节的
file_size,换算成主机字节序,得知文件多大。 - 双方循环
send/recv,直到传输量达到file_size。 - 客户端关闭 socket,写文件完成,服务端
close()子连接 fd。
为了更可靠,我还会在文件头加一个name_len字段并包含文件名,这样即使客户端发来的请求里没带文件名,服务端也能在响应头里告诉客户端“真实文件名”。后面断点续传扩展时,这个头还能继续加字段,比如偏移量offset、校验值checksum,设计上有扩展余地。
2. 核心接口与 TCP 原理映射
2.1 socket 全流程与内核对象
C 语言做网络编程绕不开这几个接口:socket()、bind()、listen()、accept()、connect()、send()、recv()、close()。很多人背下来了,但不理解内部关系。我把它们映射到“文件操作”上:socket 本质就是一个文件描述符,内核里对应两个缓冲区,一个发送缓冲区、一个接收缓冲区。
send()做的事情是:把用户 buffer 的数据拷贝到内核发送缓冲区,真正发出去的时机由内核的 TCP 栈决定。recv()则是从内核接收缓冲区拷贝数据到用户 buffer。所以send返回成功,只代表数据进了内核缓冲区,不代表对端已经收到;这也解释了为什么后面要讨论“对端断开时send返回 EPIPE”的现象。
服务端还有一个容易忽视的点:listen(fd, backlog)的 backlog 参数。它表示内核中“已完成三次握手、等待accept()取走”的连接队列长度。如果同时来很多个客户端,队列满了,新的连接可能被丢弃。做文件传输服务器,即使只是演示,建议 backlog 至少设 8 或 16。
2.2 为什么必须写 recv_full 和 send_full 辅助函数
直接调用recv(fd, buf, 1024, 0)能保证一次收到 1024 字节吗?不能。TCP 是字节流,它会根据接收缓冲区剩余量、MTU、对端发送时机分多次送达。可能你第一次只收到 571 字节,第二次收到剩下 453 字节。
我见过最多的新手错误就是这段代码:
void *buf = malloc(file_size); recv(fd, buf, file_size, 0); // 大错特错如果数据被拆成两包到达,第二次recv调用还没发生,写入文件的就是不完整内容。所以必须写一个循环收满指定长度的辅助函数:
ssize_t recv_full(int fd, void *buf, size_t len) { size_t got = 0; while (got < len) { ssize_t n = recv(fd, (char*)buf + got, len - got, 0); if (n == 0) break; // 对端关闭 if (n < 0) { if (errno == EINTR) continue; // 被信号打断 return -1; } got += n; } return (ssize_t)got; }send_full同理,因为send在非阻塞或部分写入时也可能只发一半数据。这两个辅助函数是整个传输稳定性的基石,后面的代码全部建立在它们之上。
2.3 网络字节序的坑
TCP 传输的数据链路是“大端优先”的,也就是网络字节序。C 语言里把整数塞进 send 之前,要调htonl/htons转成大端;收到后要调ntohl/ntohs转回本地字节序。
但 64 位整数就尴尬了:标准库只保证 16、32 位转换函数,htonll在多数 Linux 上其实有,但 glibc 版本和平台差异很大,Windows 上就没有。我在项目里直接写了一个版本,避开平台差异:
uint64_t htonll(uint64_t v) { #if __BYTE_ORDER == __LITTLE_ENDIAN return ((uint64_t)htonl(v & 0xFFFFFFFFULL) << 32) | htonl((uint32_t)(v >> 32)); #else return v; #endif }这个坑尤其体现在“文件大小大于 4GB”的时候:如果你把off_t(off_t 通常是 64 位有符号)先隐式转成 32 位再发,接收端拿到的就是负数或者截断值。别问我为什么知道,我只能说第一次用fseek定位大文件时被坑得很惨。
3. 服务端与客户端的实操实现
3.1 服务端:监听、接收请求、推文件
先看服务端整体骨架。重点是 accept 之后 fork 子进程处理每一个连接,或者用线程池,避免只服务一个客户端:
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(8899); addr.sin_addr.s_addr = INADDR_ANY; bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)); listen(listen_fd, 16); signal(SIGPIPE, SIG_IGN); while (1) { int conn_fd = accept(listen_fd, NULL, NULL); if (conn_fd < 0) continue; // fork 或 pthread_create 处理 }子进程处理请求的逻辑分三步:先收“请求头”里的文件名长度(4 字节),再收对应长度的文件名,然后open()文件,成功则发送头部。如果文件不存在,发送一个大小为0xFFFFFFFFFFFFFFFF的错误标志,客户端根据这个值判断。
发送文件头时,我会一次把结构体里的字段分别序列化发送,而不是直接send(&header, sizeof(header))。为什么?因为直接发结构体会引入对齐填充(padding)和字节序问题,有的编译器在结构体里插填充字节,服务端和客户端如果编译器版本不一致,大小都不一样。所以二进制协议尽量逐字段序列化。
uint64_t file_size = stat_buf.st_size; uint64_t net_size = htonll(file_size); send_full(conn_fd, &net_size, sizeof(net_size)); char buf[32768]; while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { send_full(conn_fd, buf, n); }3.2 客户端:连接、发请求、收文件、写盘
客户端更简单,连接之后先发请求头:命令号 + 文件名长度 + 文件名。
struct sockaddr_in server; server.sin_family = AF_INET; server.sin_port = htons(8899); inet_pton(AF_INET, "127.0.0.1", &server.sin_addr); connect(sock, (struct sockaddr*)&server, sizeof(server)); uint32_t name_len = strlen(filename); uint32_t net_len = htonl(name_len); send_full(sock, &net_len, 4); send_full(sock, filename, name_len);然后接收 8 字节文件大小,转成本地字节序,循环接收并写入文件。这里有个比较优雅的细节:接收进来的数据直接装进 32KB 缓冲区,然后fwrite落盘,而不是一次性malloc(file_size)。大文件(比如 10GB)一次性载入内存会直接 OOM,分块写天然解决了内存问题。
写入时我用的是FILE*加fwrite,因为它带用户态缓冲,整体 I/O 效率比write()高不少。但如果追求极致性能,可以考虑open+write,不过对 90% 的场景来说没差别。
3.3 进度显示与断线识别
接收循环里可以顺手打印进度。压缩在一个终端行,用\r覆盖刷新,不要每收一块就换行打印,否则一个大文件会刷屏刷到怀疑人生:
uint64_t received = 0; while (received < file_size) { n = recv_full(sock, buf, (file_size - received) > sizeof(buf) ? sizeof(buf) : (file_size - received)); received += n; fwrite(buf, 1, n, fp); printf("\r%llu / %llu (%.2f%%)", received, file_size, 100.0 * (double)received / file_size); fflush(stdout); }断线识别的关键点:当服务端意外退出时,客户端最终会recv返回 0,此时说明连接关闭但文件没收满,要报错。我在recv_full里已经区分了“返回 0 表示 EOF”和“返回 -1 表示错误”,所以在收文件时的判断逻辑是:如果收满,成功;如果提前返回,直接提示“传输中断”。
4. 效率与稳定性细节优化
4.1 收发缓冲区与分块大小怎么选
分块大小不是拍脑袋定的,它和内核缓冲区、磁盘扇区、MTU 都有关系。我实测过的几个选择:
| 块大小 | 表现 | 适用场景 |
|---|---|---|
| 1KB ~ 4KB | CPU 占用高、系统调用次数爆炸 | 不适合传大文件 |
| 8KB ~ 32KB | 效率与内存平衡较好 | 绝大多数局域网/公网传输 |
| 64KB ~ 256KB | 系统调用次数更少,但占内存、失败重发成本高 | 高速局域网、万兆网 |
我默认用 32KB。原因是这个值远大于常见 MTU(1500 字节),能减少系统调用次数,又不会像 1MB 那样导致单次send失败时整块重发的概率变大。如果你传的是小文件,块大小没那么关键,头部解析反而更影响体验。
4.2 Nagle 算法、延迟确认与 TCP_NODELAY
Nagle 算法会把小数据包合并成大包发送,减少网络拥塞。但如果你在连续小段send(比如先发 4 字节长度,再发文件名),Nagle 会把第二个小包推迟发送,等对端 ACK 再发,而接收方的延迟 ACK(Delayed ACK)算法又会拖住 ACK 不急着回,这就形成经典的“Nagle + Delayed ACK”死锁,传输延迟白白增加几百毫秒。
解决办法是在连接建立后立即设置:
int flag = 1; setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));这样每个小包都立刻发出,不收 Nagle 合并。要注意,TCP_NODELAY 不是禁用了 TCP 的可靠性,只是禁用“合并小包”的优化。对于头部 + 数据块的传输模型,开启它通常让交互更快。
4.3 SO_REUSEADDR、SIGPIPE 与超时配置
服务端重启时,如果上一个进程还停留在TIME_WAIT状态,bind 会报Address already in use。设置SO_REUSEADDR可以允许绑定这个端口。我每次写服务端第一件事就是设置它,这已经是刻在 DNA 里的习惯了。
另一个致命问题是 SIGPIPE。当对端已经关闭,你继续send(),内核会发 SIGPIPE 信号给进程,默认行为是直接终止进程。文件传输遇到对端断开非常正常,你不希望整个服务器因为一个客户端退出而崩溃。所以要在 main 里加:
signal(SIGPIPE, SIG_IGN);忽略后,send()会正常返回 -1,errno为 EPIPE,代码里就能优雅地清理连接资源。
超时配置也值得列一张表:
| 选项 | 作用 | 我用的值 |
|---|---|---|
| SO_RCVTIMEO | 接收超时,防止一直卡在 recv | 30 秒 |
| SO_SNDTIMEO | 发送超时,防止对端不读导致发送阻塞 | 30 秒 |
| TCP_KEEPALIVE | 探测死连接 | 开启,间隔 60 秒 |
值得一提的是SO_RCVTIMEO设置后,recv()超时会返回 -1,errno是EAGAIN/EWOULDBLOCK,不要和真正的错误混淆。这个特性很适合做“无人接收就断开”的机制。
5. 常见问题与排查实录
5.1 对端已经 close,我这边还在 send,为什么进程没了
症状:服务端 send 到一半,客户端直接关闭了连接,服务端进程中崩了,日志都来不及打。
原因就是前文说的 SIGPIPE。内核发现你向“已关闭的 socket”写入数据,发送 RST 或 FIN,然后你继续 write 会触发 SIGPIPE。破解很简单,忽略信号,并且把send_full的返回值检查做好,一旦返回 -1 就终止当前文件的传输循环。这里有个次生坑:如果close(fd)时数据还没发完,系统会继续发完才关闭吗?不会,close后内核丢弃未发送数据。需要优雅关闭应该调shutdown(fd, SHUT_WR)先关闭写方向,再等对端关闭读方向。
5.2 粘包与半包症状与对策
粘包现象:客户端一次recv拿到了好几个数据块拼在一起,导致文件内容顺序错乱。半包现象:一次recv只拿到了部分数据块,导致写文件时断断续续。
这两个都是字节流协议的正常表现,不是 bug,只要协议设计正确就不会有问题。我的对策是“三层确认”:
- 所有变长字段先发长度,再发内容,接收方严格按长度收。
- 文件大小作为总收包计数,用
recv_full保证每次收满一块。 - 传完整个文件后,客户端可以再发一个“确认完成”的消息,服务端再关连接。
如果实在担心传输内容被误判,可以加一个简单的校验字段:发送方计算文件内容的简单哈希(不必 MD5,CRC32 甚至累加和都行),放在文件头里一并发送。接收方写完文件后重算对比,不一致就报“文件校验失败”。TCP 保证不丢字节,但应用层错误(比如你fread读错了文件)依然可能导致最终内容不对,加校验是最后的兜底。
5.3 拿到的是乱码或文件名错乱?看字节序和结构体对齐
如果你发现收到的文件名明显不对,第一个要怀疑htonl和ntohl没配对。第二个要怀疑直接发送了struct而没考虑对齐。第三个怀疑是长度字段读取时就错了,比如你把name_len读成了字节序没转换的原始值会变成几百万,分配缓冲区直接崩。
我建议场景化的排查步骤是:先用 tcpdump 或 wireshark 抓包,看十六进制流。头部前 4 字节如果显示45 4C 49 46(小端机器上htonl(0x46494C45)的结果),说明魔数发送正确;如果显示46 49 4C 45,说明你发送时忘了htonl。抓包看这种问题比猜代码快得多。
5.4 大文件接近 4GB 就出问题 / 断点续传怎么做
大文件出问题,90% 是字节序或类型宽度。fseek和ftell在传统 32 位平台(比如 Windows 上未启用大文件支持)是 32 位 offset,文件超过 2GB 就直接溢出。Linux 上off_t需要定义_FILE_OFFSET_BITS=64。我看到的大坑多半是发送大小用的 unsigned int(32 位),传输 4GB+ 文件时发送的file_size被截断成 0,客户端直接误判为空文件。
正确做法:用off_t或uint64_t,编译时显式加-D_FILE_OFFSET_BITS=64,或者用ftello/fseeko。接收端也是统一的 64 位变量。
断点续传可以在协议头里加一个offset字段:客户端传之前自己看一下本地已有文件大小,把这个值传给服务端,服务端fseeko跳过对应偏移,再从上一次位置开始发。这个扩展不难,但一定要在协议设计初就留好扩展位,否则后面改头部格式会让新旧版本不兼容。
另一个大文件相关的点:进度条刷新次数太多会拖慢速度。我建议每收 1MB 刷一次进度,而不是每 32KB 刷一次,实际速度提升能看得到。
写在最后,一些实在的感受
这个项目我前前后后写过三版。第一版只跑通了小文件,第二版加了断点续传和校验,第三版才把TIME_WAIT、SIGPIPE、字节序这些边角料处理干净。说实话,真正难的不是把代码跑通,而是把“TCP 为什么会有这些现象”想明白,比如为什么一停就崩、为什么要写循环收包函数、为什么要管网络字节序。
如果你正在做类似的练习,我的建议是:先把协议文档写清楚再动代码,哪怕只有十行。把报文格式、每个字段的字节数、字序、边界条件全列出来,后面调 bug 的时间能省一半。这个项目后续还可以扩展的方向包括:多线程并发加速、TLS 加密传输、断点续传、文件列表服务,甚至把协议改成 protobuf 编码。每一样都够再写一篇长文,但最核心的 TCP 基本功,就是这个项目里这堆看似琐碎的细节。