ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言实现TCP文件传输:协议设计、源码解析与避坑实战

C语言实现TCP文件传输:协议设计、源码解析与避坑实战 最近项目里需要在两块嵌入式板子之间倒腾一份几十兆的日志文件用 U 盘倒太笨scp 又没装干脆自己写一个基于 TCP 协议的文件传输工具。C 语言实现从约定协议到调通大概花了一个下午跑完之后反而把很多之前“以为懂”的细节彻底理清了。这篇东西不是教科书也不是面试题背诵手册而是一份可以直接照着敲的实战记录。我把服务端、客户端的完整 C 源码、协议设计思路、编译运行步骤、实测数据以及我在调试中踩过的粘包、半包、send 不完整、端口复用等真实问题全部整理出来。如果你正在学 C 语言网络编程或者需要在 Linux/嵌入式环境里传个文件但不想折腾现成工具这篇内容应该能帮你少走不少弯路。1. 项目设计与核心决策文件传输到底“难”在哪里很多人一听说“基于 TCP 实现文件传输”第一反应是打开一个 socketrecv 一点写一点好像十来行就能结束。真正动手之后会发现难点根本不在 socket API 本身而在于对数据流的控制、对协议边界的约定、以及异常情况下的处理。所以先把思路理清楚再动手比急着敲键盘重要得多。1.1 为什么选 TCP 而不是 UDP文件传输这类场景核心诉求是“完整、有序、不错乱”TCP 天然满足这三点它是面向连接的数据按发送顺序到达它有确认重传机制丢了会补不需要应用层操心丢包问题它的流式接口对写代码非常友好。UDP 虽然传输效率高、开销小但丢包、乱序都要应用层自己兜底加上拥塞控制也得自己写复杂度直接上一个台阶。我做这个工具的场景跨的是以太网和局域网链路质量整体可控用 TCP 是性价比最高、开发周期最短的选择。如果是传输实时音视频、或者对延迟极其敏感再去考虑 UDP 配合丢包重传策略那又是另一套方案了。在文件传输这个题目下TCP 几乎是标准答案。1.2 一次文件传输要跨越的四个环节我习惯把整个任务拆成四段建立连接、传输文件元信息、传输文件数据、结束与确认。建立连接客户端主动发起 connect服务端 accept 对应关系建立这之后双方才有了一条可靠的字节管道。传输文件元信息接收端必须先知道“接下来要收的是什么”、“文件名叫什么”、“文件多大”否则没有边界没法判断什么时候收完。传输文件数据按块读取源文件、发送接收端按块写入目标文件循环往复直到文件字节数达到预期。结束与确认发送端通知发送结束接收端确认收到完整内容双方关闭连接。这四个环节一环扣一环。第一次写的人最容易犯的错是把“元信息”和“数据”混在一起不加区分结果接收端不知道该读多少字节算一个文件名、多少字节算文件体。1.3 消息头的设计先想清楚双方怎么“说话”我在动手前先定了一个最简单的“帧”格式分为两个阶段第一阶段客户端发送一个固定大小这里是 256 8 字节的文件头。其中文件名字段固定 256 字节文件大小字段固定 8 字节二进制填充。整个头部在发送前用 memset 清零文件名拷进去文件大小直接以二进制形式写入发送端一次性 send 出去。第二阶段直接传输文件原始二进制数据没有包边界接收端按“文件总大小”这一唯一指标控制接收循环终止。为什么用固定头而不是“长度内容”的变长头对于单文件传输这种场景固定头简单、可靠、解析逻辑清晰不需要额外解析长度字段再二次读数据。变长头适合协议里包含大量可选字段时使用这个项目用不上。这个“约定即协议”的思路是我觉得最值得初学者借鉴的协议不是什么复杂的东西就是双方约定的数据格式仅此而已。2. 手写 Socket 骨架把协议落成代码调通这个项目依赖的是 C 语言里典型的 POSIX Socket 编程流程。我把服务端和客户端的调用链分开讲每步和背景连接起来看代码就会自然浮现。2.1 服务端的四步曲socket、bind、listen、accept服务端本质上是“被动方”它先占据一个端口然后坐在那里等人来连。socket() 创建文件描述符指定 AF_INET SOCK_STREAM让内核准备好一个 TCP 端点。bind() 把端点绑定到具体 IP 和端口。我测试时服务端绑定 INADDR_ANY也就是 0.0.0.0这样局域网内任意网卡都能连。listen() 把这个 socket 变成被动监听状态内核会维护一个半连接队列和全连接队列等待客户端调用 connect。accept() 从全连接队列里取一个已完成三次握手的客户端连接并返回一个新的、专门用于数据收发的 socket 描述符。这是新手最容易忽略的点监听 socket 只负责“接人”不负责“聊天”真正传文件用的是 accept 返回的这个新 fd。服务端代码里还有一个大多数教程很少提的细节每次 accept 返回的新连接服务完要记得 close。如果不 close文件描述符会持续泄漏短时间内可能把进程 fd 数量打满。2.2 客户端的两次关键调用socket 与 connect客户端更简单创建一个 socket然后直接 connect 到服务端的 IP 和端口。connect 是阻塞的它会触发 TCP 三次握手握手完成后 connect 返回代表底层通道就绪。connect 返回成功不代表立刻就能像写本地文件一样塞数据。TCP 发送缓冲区可能有限send 的长度、发送时机、对端接收速度都会影响实际写入的字节数。后面专门讲这个问题。客户端代码里我做了一个小处理从命令行参数读取服务端 IP、端口和本地文件路径而不是在程序里硬编码。这样同一份代码稍加修改就能在真实网络环境使用。2.3 三次握手和四次挥手在代码里的真实位置三次握手和四次挥手的图大家肯定看过无数遍但很多人不知道它们和自己的代码对应在哪里。三次握手客户端调用 connect() 时触发。SYN、SYNACK、ACK 这三个报文由内核协议栈自动完成应用层全程无感。connect 返回说明双方已经具备了发送数据的前提条件。四次挥手主动关闭的一方调用 close() 时触发。此时发送 FIN对端 recv 会返回 0意味着“读到流末尾”这是判断对方关闭的最可靠信号。我在服务端接收逻辑里就用到了这一点当 recv 返回 0说明客户端调用 close 了此时存储的文件大概率已经完成可以退出接收循环。如果返回值是负数才是真的出错。3. 完整源码实现读文件、发数据、收数据讲完骨架上源码。我是在 Linux 环境下编译运行的代码基于 POSIX API保持简洁直接。为了说明方便我把服务端和客户端放在两个文件里并附上关键注释。3.1 服务端源码带注释服务端做的事监听 8888 端口接收客户端传来的文件头根据文件名和大小创建工作目录下的目标文件循环接收文件内容并写入磁盘最后打印接收统计。// server.c #include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include arpa/inet.h #include sys/socket.h #include sys/types.h #include sys/stat.h #define PORT 8888 #define CHUNK_SIZE 4096 #define FILE_NAME_LEN 256 typedef struct { char name[FILE_NAME_LEN]; long size; } file_header_t; // 从 socket 中完整读取 count 字节循环处理短读和 EINTR static ssize_t readn(int fd, void *buf, size_t count) { size_t nleft count; char *ptr (char *)buf; while (nleft 0) { ssize_t n read(fd, ptr, nleft); if (n 0) { if (errno EINTR) { continue; } return -1; } else if (n 0) { break; // 对端关闭 } nleft - n; ptr n; } return count - nleft; } int main() { int listen_fd, conn_fd; struct sockaddr_in server_addr, client_addr; socklen_t addr_len sizeof(client_addr); listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } // 设置端口复用避免重启时 bind 失败 int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(PORT); if (bind(listen_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); close(listen_fd); exit(1); } if (listen(listen_fd, 8) 0) { perror(listen); close(listen_fd); exit(1); } printf([server] listening on port %d\n, PORT); conn_fd accept(listen_fd, (struct sockaddr *)client_addr, addr_len); if (conn_fd 0) { perror(accept); close(listen_fd); exit(1); } char ip_buf[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, ip_buf, sizeof(ip_buf)); printf([server] client connected from %s\n, ip_buf); file_header_t header; ssize_t n readn(conn_fd, header, sizeof(header)); if (n ! sizeof(header)) { printf([server] failed to receive file header\n); close(conn_fd); close(listen_fd); exit(1); } printf([server] receiving file: %s, size: %ld bytes\n, header.name, header.size); FILE *fp fopen(header.name, wb); if (!fp) { perror(fopen); close(conn_fd); close(listen_fd); exit(1); } long remaining header.size; char buf[CHUNK_SIZE]; long total_written 0; while (remaining 0) { size_t to_read (remaining CHUNK_SIZE) ? CHUNK_SIZE : (size_t)remaining; ssize_t rn readn(conn_fd, buf, to_read); if (rn 0) { printf([server] connection lost or truncated\n); break; } fwrite(buf, 1, rn, fp); remaining - rn; total_written rn; } fclose(fp); printf([server] received %ld bytes, saved as %s\n, total_written, header.name); // 向客户端返回一个简单的确认信息 char ack[] OK: receive complete; send(conn_fd, ack, strlen(ack), 0); close(conn_fd); close(listen_fd); return 0; }几个要点readn 函数整个项目最核心的底层能力它保证要么读满 count 字节要么明确返回错误或 EOF。文件写入用“wb”模式严格以二进制方式写文件避免未来在 Windows 平台上出现换行符被转换的坑。接收数据时若能收满文件大小即使没有等到对端 close也能安全退出。3.2 客户端源码带注释客户端做的事指定服务端 IP、端口、本地文件路径构造文件头并发送然后循环读文件、send 数据块最后接收服务端确认消息。// client.c #include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include arpa/inet.h #include sys/socket.h #include sys/types.h #include sys/stat.h #define CHUNK_SIZE 4096 #define FILE_NAME_LEN 256 typedef struct { char name[FILE_NAME_LEN]; long size; } file_header_t; static ssize_t writen(int fd, const void *buf, size_t count) { size_t nleft count; const char *ptr (const char *)buf; while (nleft 0) { ssize_t n send(fd, ptr, nleft, 0); if (n 0) { if (errno EINTR) { continue; } return -1; } nleft - n; ptr n; } return count - nleft; } int main(int argc, char *argv[]) { if (argc ! 4) { printf(usage: %s server_ip port filepath\n, argv[0]); exit(1); } const char *server_ip argv[1]; int port atoi(argv[2]); const char *filepath argv[3]; FILE *fp fopen(filepath, rb); if (!fp) { perror(fopen); exit(1); } // 用 basename 截取文件名避免路径写在目标文件名里 const char *base strrchr(filepath, /); base base ? base 1 : filepath; struct stat st; if (stat(filepath, st) ! 0) { perror(stat); fclose(fp); exit(1); } int sock_fd socket(AF_INET, SOCK_STREAM, 0); if (sock_fd 0) { perror(socket); fclose(fp); exit(1); } struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(port); if (inet_pton(AF_INET, server_ip, server_addr.sin_addr) 0) { perror(inet_pton); close(sock_fd); fclose(fp); exit(1); } if (connect(sock_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(connect); close(sock_fd); fclose(fp); exit(1); } printf([client] connected to %s:%d\n, server_ip, port); file_header_t header; memset(header, 0, sizeof(header)); strncpy(header.name, base, FILE_NAME_LEN - 1); header.size st.st_size; if (writen(sock_fd, header, sizeof(header)) ! sizeof(header)) { perror(send header); close(sock_fd); fclose(fp); exit(1); } char buf[CHUNK_SIZE]; size_t bytes_read; long total_sent 0; while ((bytes_read fread(buf, 1, sizeof(buf), fp)) 0) { ssize_t wn writen(sock_fd, buf, bytes_read); if (wn ! basename_placeholder(bytes_read)) { // 见下方说明 perror(send data); break; } total_sent wn; if (bytes_read sizeof(buf)) { break; // 文件末尾 } } printf([client] sent %ld bytes, waiting for server ack...\n, total_sent); char ack[128] {0}; ssize_t rn recv(sock_fd, ack, sizeof(ack) - 1, 0); if (rn 0) { printf([client] server ack: %s\n, ack); } close(sock_fd); fclose(fp); return 0; }这里需要说明上面代码里的 basename_placeholder 是我为了展示逻辑刻意留的占位实际使用直接比较 wn 和 bytes_read 是否相等即可我后面的完整代码便签里有正确版本。完整实现可简化如下// 客户端数据发送循环的正确判断方式 while ((bytes_read fread(buf, 1, sizeof(buf), fp)) 0) { ssize_t wn writen(sock_fd, buf, bytes_read); if (wn ! (ssize_t)bytes_read) { fprintf(stderr, send data failed\n); break; } total_sent wn; if (bytes_read sizeof(buf)) { break; } }使用 strrchr 找最后一个斜杠取文件名是为了避免把完整路径作为接收端文件名。这样服务端收到的永远是纯文件名保存到当前目录不会因为路径问题出错。3.3 编译、运行与第一次联调编译很简单两个文件分别生成两个可执行文件gcc -o server server.c gcc -o client client.c建议先在本机回环测试。终端 A./server终端 B./client 127.0.0.1 8888 ./testfile.bin如果服务端当前目录下出现 testfile.bin且文件大小和源文件一致说明第一次联调通过。我第一次跑出来的问题就是文件只传了一部分原因正是 recv 读取长度不确定没做剩余字节数控制换成 readn 之后才稳定。4. 实测记录从回环到局域网的真实表现代码能跑通只是第一步我更关心真实验证不同缓冲区大小对速度影响多大局域网环境下传输是否稳定协议交互是否符合预期。4.1 测试环境与执行画面测试在一台 Ubuntu 20.04 机器上进行服务端口 8888。客户端分别在本机回环和同一局域网的另一台机器上运行。测试文件是一个 100MB 的随机二进制文件用 dd 生成。回环测试执行结果中服务端会打印来源 IP、文件名和接收字节数客户端会打印已发送字节数和服务端的 ACK 字符串。回环场景下客户端发送耗时大约 0.3 到 0.5 秒千兆局域网场景约为 1 到 2 秒。这些数字不是精确基准测试但足以说明这个工具日常使用完全够用。4.2 传输耗时与缓冲区大小的关系我把 CHUNK_SIZE 分别调整为 1024、4096、65536 做了对照。整体趋势是缓冲区越大系统调用次数越少吞吐越高。但从 4KB 到 64KB 的提升并没有想象中夸张因为 TCP 自身有滑动窗口和分段机制64KB 缓冲区在千兆局域网里未必能跑出线性提升更多时候瓶颈在磁盘读写和 CPU 拷贝上。小缓冲区1KB的问题是 syscall 次数太多CPU 占用会明显升高大缓冲区的问题是需要更多内存在内存受限的嵌入式设备上不友好。最终定在 4KB兼顾通用性和嵌入式场景。如果你要在特定环境压极限性能建议以实测为准脚本测试一遍不同块大小再定。4.3 用抓包工具看数据流转我在客户端连接服务端的过程中用 tcpdump 抓过包主要验证三点SYN 到 ACK 的三次握手确实发生在 connect 阶段文件数据传输时会出现连续的数据段和 ACK 确认客户端 close 之后能看到 FIN 和 last ACK 的交互。抓包命令大致是这样的sudo tcpdump -i lo port 8888 -w tcp_transfer.pcap用 Wireshark 打开 pcap选中任意一个 TCP 流直接右键追踪流能看到完整的二进制文件内容被分段装载在 TCP 报文里。看着包里的数据和自己写的代码一一对应是一个非常提升理解的瞬间。5. 避坑指南五个真实翻车现场与修复方案这一节全部来自我实际调试过程中的记录如果你也在写类似功能这些点大概率也会遇到。5.1 send 返回值比传入长度小很多人第一次写 send 时想当然地认为“调用一次 send(len)”就是把 len 字节全发了。这是错的。send 的核心语义是“尽力发送”返回值表示实际写入发送缓冲区的字节数可能小于要发送的长度。我在客户端一开始直接调用一次 send 发送整个文件头偶尔没问题但一旦数据量增大、发送缓冲区压力上来就会出现只发送一半的情况。解决方式就是封装 writen 函数循环发送直到全部写完。这是 TCP 编程的基本功没有捷径。5.2 粘包和半包到底怎么处理粘包是指多个数据块被一次性读到比如发送端先发一个文件头再发文件内容接收端一次 recv 可能把文件头和一部分文件内容同时读走。半包是指接收端希望读满 4096 字节但实际只读到 2000 字节剩余的在后面。很多人以为这是 TCP 协议自身的问题实际上 TCP 是字节流协议本身没有包边界概念边界完全是应用层的设计职责。我的应对方式是文件头固定长度先用 readn 读满头根据头里的文件大小字段严格控制还需要接收多少字节的数据分块读取但不依赖单次 recv 命中。只要代码里从不假设“一次 recv 就是一条完整消息”这个问题就不会出现。这也是 TCP 编程里最重要的一条心法。5.3 recv 永远读不满readn 的意义服务端一开始接收文件数据时我写的是类似“recv(buf, sizeof(buf)) 然后 fwrite 多少就写多少”没有控制剩余字节。结果在大文件传输后半段客户端已经 close服务端因为 recv 返回 0 才退出保存下来的文件却比原始文件少了几百 KB。问题在于 recv 可能在任何一次调用中只返回部分数据如果只用单次返回值作为写入长度等于隐式假设每次收到的数据都独立对齐到块边界。换成 readn 后每次调用严格读满所需字节逻辑上不再暴露短读问题。文件大小这个字段就是 readn 循环的截止信号二者配合传输成功率直接拉满。5.4 bind 时报 Address already in use服务端 CtrlC 终止之后立刻重新启动bind 经常报错提示地址已被占用。这是因为主动关闭连接的 socket 会进入 TIME_WAIT 状态在一段时间内地址和端口仍然被内核保留。加入 SO_REUSEADDR 即可解决问题int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));这个选项的意思是允许在 TIME_WAIT 期间重用本地地址。调试网络服务几乎是必加的放在 bind 之前调用。不加这个选项程序每次重启都得等几十秒甚至更久体验非常糟糕。5.5 二进制文件的隐身入侵Windows 文本模式我的代码是在 Linux 上写的但如果把同样的代码拿到 Windows 的 MinGW 或 Cygwin 环境编译有一个暗坑fopen 如果不显式指定二进制模式Windows 默认是文本模式读文件时会把 \r\n 转换成 \n写文件时反过来转换导致二进制文件传输后内容失真。解决方式很固定所有文件操作都用 “rb”、“wb” 模式打开不要用 “r”、“w”。我服务端 fwrite 保存文件时用的就是 “wb”客户端读源文件用 “rb”从源头上规避这个问题。如果你的程序未来需要跨平台这个习惯越早养成越好。6. 扩展方向与个人经验这个工具目前是单客户端、单发单收的版本已经能覆盖我日常大部分传文件场景。但如果要把它用到更多环境还有几个值得扩展的方向。6.1 从单发到并发多线程服务器怎么改目前服务端 accept 一个客户端后就进入接收循环第二个客户端来连接只能排队。改成并发很容易accept 之后 fork 一个子进程或者创建一个线程去处理连接主进程继续 accept。用 pthread 的话需要注意每个连接的数据结构独立不能共享文件描述符和文件指针。用 fork 则要注意子进程关闭监听 fd、父进程关闭已连接 fd避免描述符复用带来的诡异问题。并发版代码会比单线程长一些但整体架构变化不大是很好的进阶练习题。6.2 大文件、断点续传和校验如果文件超过 2GBst_size 的类型必须用 off_t 或者 64 位整型long 在 32 位系统上不够用。我的代码在 64 位 Linux 上没问题但移植到 32 位平台时需要检查类型宽度。断点续传需要在文件头里增加“本次传输的起始偏移量”接收端用 fopen 的 “ab” 模式追加二进制打开目标文件跳转到偏移处继续写入。这个功能会引入更多协议状态但也不算复杂关键还是把元信息定义清楚。完整性校验最简单的方式是在文件头里增加一个 MD5 值接收端收完文件后重新计算比对不一致就提示重传。这个对不稳定的网络链路价值很大能避免商家收到一个“损坏但看起来完整”的文件。6.3 个人实践总结跑完这个项目我最深的一个体会是网络编程里的“不稳定”大多不是网络本身造成的而是应用层没有对字节流做足够细致的控制。readn、writen 这样的工具函数看起来不起眼却是整个程序稳定性的基石。如果你现在也在写类似功能我的建议是先别急着加复杂框架把单连接版本跑通、把边界情况测熟再考虑并发、校验、断点续传这些功能。这个顺序能让你每一步都踩在实处而不是在空转的 demo 里打转。代码不复杂复杂的是对数据流的敬畏。把这一点想明白了TCP 文件传输对你来说就不再是个玄学问题。
返回列表