
简介TCP文件传输服务器项目源码面向具备一定C与网络编程基础、希望理解TCP可靠传输与Socket通信实践的开发者基于Visual Studio 2015与MFC对话框框架搭建演示客户端与服务器之间文件元数据文件名、大小预传输、目标文件创建、数据接收确认及连接关闭的完整流程。压缩包共42个文件、约67.33MB以h头文件、cpp源文件、sln解决方案与vcxproj工程配置为主辅以rc资源脚本及exe、pdb、ilk等编译输出可直接打开解决方案编译运行并对照调试。已有1521人学习。项目结构清晰便于读者梳理服务器监听循环、文件名合法性校验、缓冲接收等关键环节并在此基础上尝试断点续传、多客户端并发与传输加密等扩展适合课程设计、网络编程入门练手与Socket通信原理对照学习。1. 局域网传大文件为什么我更愿意自己写一个 TCP 文件传输服务器上个月帮朋友把一个 40GB 的素材盘从工作室机器搬到机房机器公共网盘限速到 300KB/s移动硬盘又在机箱里拆不出来。最后我翻出自己写的一个 TCP 文件传输服务器跑起来两台机器千兆内网对拷8 分钟传完md5 一比对完全一致。它做的事情很朴素服务端常驻监听客户端连上去能列目录、上传、下载底层只依赖 POSIX socket 和 pthread没有第三方库编译出来两个可执行文件扔到任何一台 Linux 机器上都能跑。这个项目适合两类人。一类是想把 socket 编程从「能 echo 一行字符串」练到「能扛住大文件、能处理半包、能断开重连」的开发者代码量不大但每个坑都真实存在。另一类是内网里确实需要一个轻量文件中转服务的人不想装一整套 HTTP 文件服务也不想配对象存储一个进程加一个 storage 目录就够了。下面按协议设计、服务端链路、客户端实现、避坑、二次开发这个顺序把整个项目拆开讲。2. 协议头与目录结构16 字节定长头怎么定下来2.1 为什么不用 HTTP而是自己定一个 16 字节头TCP 是字节流没有消息边界如果直接裸传文件内容服务端根本不知道「文件名多长、文件多大、什么时候结束」。有人会在文件内容前面加一行filename:size的文本然后按换行切分这种写法在文件名含空格或中文的时候就开始翻车。我一般会定一个定长的二进制头长度固定、字段固定读的时候先recv满 16 字节再从头部里取后续长度逻辑最干净。头部设计成下面这样16 字节不多不少/* include/protocol.h */ #ifndef PROTOCOL_H #define PROTOCOL_H #include stdint.h #define PROTO_MAGIC 0x54435046u /* TCPF用来识别是不是本协议的连接 */ #define NAME_MAX_LEN 256 #define CHUNK_SIZE (64 * 1024) /* 文件内容按 64KB 一块读写 */ /* 命令字 */ enum { CMD_LIST 1, /* 列目录 */ CMD_UPLOAD 2, /* 上传客户端 - 服务端 */ CMD_DOWNLOAD 3, /* 下载服务端 - 客户端 */ CMD_RESUME 4 /* 断点续传file_size 字段复用为已传偏移 */ }; /* 定长 16 字节协议头收发前必须逐字段做字节序转换 */ typedef struct __attribute__((packed)) { uint32_t magic; /* 魔数防止误连到别的服务上把数据写坏 */ uint8_t cmd; /* 命令字见上面的 enum */ uint8_t status; /* 服务端回包状态0 成功非 0 为错误码 */ uint16_t name_len; /* 文件名字节数不含结尾 \0 */ uint64_t file_size; /* 文件总大小断点续传时表示已传偏移 */ } proto_hdr_t; #endif字段含义和取舍说明magic是为了防御性编程客户端连错端口时服务端能立刻判断出这不是自己的协议流量并断开而不是把一堆随机字节当文件名去创建文件。name_len用 16 位够放 255 字节的文件名中文按 UTF-8 存不会超。file_size用 64 位是无符号的4GB 以上的镜像文件不会溢出这一点很多教学代码用int存大小传大文件直接变成负数。status是双向字段客户端发请求时填 0服务端回包时填错误码这样上传前的「握手确认」和下载前的「元信息回传」可以复用同一个结构。头部之后紧跟文件名字节不带\0因为长度已经在头里了。文件名之后才是文件内容服务端按file_size精确读满不多读一个字节这样同一条 TCP 连接上还能接着处理下一个命令不用每次都断开重连。2.2 目录结构与编译方式项目一共四个源文件规模控制在两千行以内方便一口气读完tcp-file-server/ ├── Makefile ├── include/ │ ├── protocol.h # 协议头、命令字、缓冲区大小 │ └── net_util.h # send_all / recv_all / tcp_connect 声明 ├── src/ │ ├── server.c # 监听、accept、起线程 │ ├── session.c # 单连接处理读头、分发、上传下载落盘 │ ├── client.c # 命令行客户端list / put / get │ └── net_util.c # 字节序、全量收发、进度打印 └── storage/ # 服务端落盘根目录启动时不存在会自动创建Makefile 只有十几行关键是带上-D_FILE_OFFSET_BITS64否则 32 位环境下off_t只有 4 字节sendfile和lseek处理大文件会静默截断这个坑后面还会提。CC : gcc CFLAGS : -O2 -Wall -Wextra -D_FILE_OFFSET_BITS64 -D_GNU_SOURCE -Iinclude LDFLAGS : -lpthread SRV : src/server.c src/session.c src/net_util.c CLI : src/client.c src/net_util.c all: srv cli srv: $(SRV) $(CC) $(CFLAGS) -o $ $^ $(LDFLAGS) cli: $(CLI) $(CC) $(CFLAGS) -o $ $^ $(LDFLAGS) clean: rm -f srv cli-lpthread放在编译命令末尾而不是开头是链接顺序的要求放前面某些链接器会报undefined reference to pthread_create。-D_GNU_SOURCE是为了让sendfile、be64toh这些 GNU 扩展在头文件里可见不加的话编译期就报隐式声明运行起来返回值是垃圾。2.3 字节序和结构体对齐两个必须一开始就定死的事网络字节序是大端x86 和 ARM 的小端机器上直接send结构体对端读出来的file_size会是反的。所以头里每个多字节字段收发前后都要过一遍htonl/ntohl64 位字段 glibc 提供了htobe64/be64toh但为了可移植我在net_util.h里自己写了一个/* src/net_util.c */ #include arpa/inet.h uint64_t hton64(uint64_t v) { #if __BYTE_ORDER__ __ORDER_LITTLE_ENDIAN__ /* 小端高低 32 位分别转序后再拼回去 */ return ((uint64_t)htonl((uint32_t)(v 0xFFFFFFFFu)) 32) | (uint64_t)htonl((uint32_t)(v 32)); #else return v; #endif } uint64_t ntoh64(uint64_t v) { return hton64(v); /* 转换是对称的小端机器上再转一次即可还原 */ }结构体对齐是另一个隐形炸弹。proto_hdr_t里字段是 41128编译器默认按 8 字节对齐实际sizeof会变成 24尾部填 8 字节 padding而收发两端都按 24 字节算看起来也能跑但一旦有人只改了字段顺序、或者换了编译器padding 位置变化两边sizeof对不上读头就错位文件名会变成乱码。__attribute__((packed))强制 16 字节紧凑排列代价是访问未对齐字段可能多次访存在 x86 上几乎无感在部分 ARM 上要配合-mno-unaligned-access或者干脆手动序列化成一个字节数组这一点在交叉编译到嵌入式板子时要留意。提示协议一旦对外发布字段只能追加不能修改顺序且必须带版本号。我在头部status字段旁边留了reserved的坑位将来加版本或压缩标志直接占位即可不用改结构体大小。3. 服务端链路从 accept 到文件落盘的完整路径3.1 监听套接字初始化与 accept 循环服务端主线程只做一件事socket → bind → listen → accept每来一个连接就malloc一个参数结构体、pthread_create起一个 detached 线程主线程立刻回去继续accept。这种「一连接一线程」模型在几百并发以内完全够用实现成本最低也不用担心 epoll 回调里写阻塞 IO 的复杂性。/* src/server.c */ int main(int argc, char *argv[]) { int port (argc 1) ? atoi(argv[1]) : 9000; const char *root (argc 2) ? argv[2] : ./storage; int lfd socket(AF_INET, SOCK_STREAM, 0); if (lfd 0) { perror(socket); return 1; } int on 1; /* 重启服务端时立刻复用端口绕开 TIME_WAIT 的 60 秒等待 */ setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons((uint16_t)port); if (bind(lfd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); return 1; } if (listen(lfd, 128) 0) { perror(listen); return 1; } /* 对端异常断开时内核会发 SIGPIPE默认行为是杀死进程必须忽略 */ signal(SIGPIPE, SIG_IGN); printf(server listening on 0.0.0.0:%d, root%s\n, port, root); for (;;) { struct sockaddr_in peer; socklen_t plen sizeof(peer); int cfd accept(lfd, (struct sockaddr *)peer, plen); if (cfd 0) { if (errno EINTR) continue; /* 被信号打断重试即可 */ perror(accept); break; } session_arg_t *arg malloc(sizeof(session_arg_t)); if (!arg) { close(cfd); continue; } arg-fd cfd; snprintf(arg-root, sizeof(arg-root), %s, root); pthread_t tid; if (pthread_create(tid, NULL, session_worker, arg) ! 0) { close(cfd); free(arg); continue; } pthread_detach(tid); /* 线程结束自动回收避免僵尸线程堆积 */ } close(lfd); return 0; }几个参数值得说清楚。SO_REUSEADDR不是为了性能而是解决「刚 CtrlC 停掉服务端立刻重启却报 Address already in use」的问题因为主动关闭方会进入 TIME_WAIT 状态端口被内核占着。listen的 backlog 给到 128是等待 accept 队列的长度并发高的时候这个值太小会让内核直接丢 SYN客户端表现为连接超时。pthread_detach一定要加否则线程跑完资源不回收长时间运行会泄漏线程栈。3.2 会话线程读头、校验、分发每个连接进来后第一件事是把 16 字节头读满。这里不能直接recv(fd, hdr, sizeof(hdr), 0)就完事TCP 是流式的一次recv可能只返回 8 字节剩下的要等下一个包所以必须封装一个recv_all循环读到指定长度为止。/* src/net_util.c */ ssize_t recv_all(int fd, void *buf, size_t len) { char *p (char *)buf; size_t left len; while (left 0) { ssize_t n recv(fd, p, left, 0); if (n 0) { if (errno EINTR) continue; /* 信号中断重来 */ return -1; /* 真错误 */ } if (n 0) return (ssize_t)(len - left); /* 对端有序关闭 */ p n; left - (size_t)n; } return (ssize_t)len; } ssize_t send_all(int fd, const void *buf, size_t len) { const char *p (const char *)buf; size_t left len; while (left 0) { ssize_t n send(fd, p, left, MSG_NOSIGNAL); /* 内核层面抑制 SIGPIPE */ if (n 0) { if (errno EINTR) continue; return -1; } p n; left - (size_t)n; } return (ssize_t)len; }send这里用了MSG_NOSIGNAL和主线程的signal(SIGPIPE, SIG_IGN)是双保险移植到没有全局信号处理的场景也不会把进程打死。拿到头之后按下面的顺序处理/* src/session.c */ void *session_worker(void *p) { session_arg_t *arg (session_arg_t *)p; proto_hdr_t hdr; if (recv_all(arg-fd, hdr, sizeof(hdr)) ! (ssize_t)sizeof(hdr)) goto done; /* 逐字段转回主机序 */ if (ntohl(hdr.magic) ! PROTO_MAGIC) { fprintf(stderr, bad magic from client, drop\n); goto done; } uint8_t cmd hdr.cmd; uint64_t size ntoh64(hdr.file_size); uint16_t nlen ntohs(hdr.name_len); if (nlen 0 || nlen NAME_MAX_LEN) goto done; char name[NAME_MAX_LEN]; if (recv_all(arg-fd, name, nlen) ! (ssize_t)nlen) goto done; name[nlen] \0; /* 关键安全检查拒绝 ../ 和绝对路径防止写到 storage 目录之外 */ if (!safe_name(name)) { reply_status(arg-fd, 1); goto done; } switch (cmd) { case CMD_UPLOAD: handle_upload(arg-fd, arg-root, name, size); break; case CMD_DOWNLOAD: handle_download(arg-fd, arg-root, name); break; case CMD_LIST: handle_list(arg-fd, arg-root); break; default: reply_status(arg-fd, 2); break; } done: close(arg-fd); free(arg); return NULL; } /* 只允许字母、数字、点、下划线、横线且不能以点开头 */ int safe_name(const char *name) { if (!name[0] || name[0] .) return 0; for (const char *p name; *p; p) { if (!isalnum((unsigned char)*p) *p ! . *p ! _ *p ! -) return 0; } return 1; }safe_name这十几行是整个服务端最容易被忽略的地方。没有它客户端发一个../../etc/passwd当文件名服务端snprintf(%s/%s, root, name)拼出来的路径直接跳出 storage 目录覆盖任意文件。做内网工具时这种漏洞看起来无关紧要但只要有一个人能连上端口就等于给了他一个任意写文件的能力。3.3 上传方向先回包再收数据上传的顺序有个细节服务端必须先回一个状态包客户端收到之后才开始发文件内容。反过来如果服务端先收数据再回包客户端在收到确认前不知道该不该发容易死锁就算不死锁客户端也没法在发送前知道服务端磁盘满或者没有写权限。/* src/session.c */ static int handle_upload(int fd, const char *root, const char *name, uint64_t size) { char path[512]; snprintf(path, sizeof(path), %s/%s, root, name); int out open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (out 0) { reply_status(fd, 3); return -1; } /* 3 服务端无法创建文件 */ reply_status(fd, 0); /* 先确认告诉客户端可以开始发了 */ char *buf malloc(CHUNK_SIZE); if (!buf) { close(out); return -1; } uint64_t left size; int ok 0; while (left 0) { size_t want (left CHUNK_SIZE) ? CHUNK_SIZE : (size_t)left; ssize_t n recv_all(fd, buf, want); if (n 0) break; /* 对端中途断开保留已写部分 */ if (write_full(out, buf, (size_t)n) 0) break; left - (uint64_t)n; } ok (left 0); free(buf); fsync(out); /* 落盘再关避免掉电后文件是空的 */ close(out); if (!ok) unlink(path); /* 传了一半的不完整文件直接删掉防止被误当完整文件 */ return ok ? 0 : -1; }fsync在机械盘上会带来几毫秒延迟但对于「传完就要用」的场景能避免「文件显示存在、内容是空的」这种玄学问题。unlink的策略有争议有人希望断点续传能接着传那就不能删得改成保留.part临时文件默认这里删掉是因为不带续传功能的版本留着半截文件只会让人误判。write_full同理要做循环write系统调用在磁盘满、信号中断、超大 buffer 的情况下都可能只写一部分直接write(out, buf, n)不检查返回值表现就是文件尾部落了一块。3.4 下载方向sendfile 零拷贝下载方向服务端只需要发头、发文件名、发内容。发内容的循环如果用read send每次都要在用户态和内核态之间倒两次数据64KB 一块两千万次系统调用CPU 全耗在拷贝上。Linux 提供sendfile直接在内核里把文件页缓存送到 socket 缓冲区省掉用户态这一趟。/* src/session.c */ static int handle_download(int fd, const char *root, const char *name) { char path[512]; snprintf(path, sizeof(path), %s/%s, root, name); int in open(path, O_RDONLY); if (in 0) { reply_status(fd, 4); return -1; } /* 4 文件不存在 */ struct stat st; if (fstat(in, st) 0) { close(in); reply_status(fd, 5); return -1; } proto_hdr_t hdr; memset(hdr, 0, sizeof(hdr)); hdr.magic htonl(PROTO_MAGIC); hdr.cmd CMD_DOWNLOAD; hdr.status 0; hdr.name_len htons((uint16_t)strlen(name)); hdr.file_size hton64((uint64_t)st.st_size); if (send_all(fd, hdr, sizeof(hdr)) 0) { close(in); return -1; } if (send_all(fd, name, strlen(name)) 0) { close(in); return -1; } off_t off 0; while (off st.st_size) { /* 每次最多 1MB返回 0 表示对端关闭-1 表示出错 */ ssize_t n sendfile(fd, in, off, 1 20); if (n 0) { if (errno EINTR) continue; if (errno EAGAIN) continue; /* 非阻塞 socket 需要配合 poll 重试 */ break; } if (n 0) break; } close(in); return 0; }sendfile的第三个参数是「输入输出」参数调用前传当前偏移调用后内核会把它更新成实际发出去的位置所以循环里不用手动加。第四个参数是单次最大字节数给 1MB 比较稳太大在某些内核版本上会让单次调用卡很久影响其他连接。注意sendfile只适用于「文件 → socket」这一个方向上传方向用不了还是老实recv write。4. 客户端实现一次上传拆成五步4.1 连接、组包、握手确认客户端命令行做成./cli host port cmd [args]cmd支持list、put、get。以put为例一次上传被拆成五步连接、发头、发文件名、等确认、发内容。/* src/client.c */ static int tcp_connect(const char *host, int port) { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) return -1; struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons((uint16_t)port); if (inet_pton(AF_INET, host, addr.sin_addr) ! 1) { close(fd); return -1; } /* 连接超时设成 5 秒避免目标机器没开时整个命令卡死 */ struct timeval tv { .tv_sec 5, .tv_usec 0 }; setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, tv, sizeof(tv)); if (connect(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { close(fd); return -1; } return fd; } int do_put(const char *host, int port, const char *path) { struct stat st; if (stat(path, st) 0) { perror(stat); return -1; } const char *name strrchr(path, /); name name ? name 1 : path; int fd tcp_connect(host, port); if (fd 0) { perror(connect); return -1; } proto_hdr_t hdr; memset(hdr, 0, sizeof(hdr)); hdr.magic htonl(PROTO_MAGIC); hdr.cmd CMD_UPLOAD; hdr.name_len htons((uint16_t)strlen(name)); hdr.file_size hton64((uint64_t)st.st_size); if (send_all(fd, hdr, sizeof(hdr)) 0 || send_all(fd, name, strlen(name)) 0) { close(fd); return -1; } proto_hdr_t ack; if (recv_all(fd, ack, sizeof(ack)) ! (ssize_t)sizeof(ack)) { fprintf(stderr, server closed before ack\n); close(fd); return -1; } if (ack.status ! 0) { fprintf(stderr, server rejected, status%u\n, ack.status); close(fd); return -1; } /* 到这里才开始真正发文件内容 */ return send_file_body(fd, path, (uint64_t)st.st_size); }SO_SNDTIMEO只对send有效对connect本身在某些系统上不生效严格的超时要么用非阻塞 connect 加select要么用SIGALRM教学版本图省事就直接阻塞连。真正部署到跨网段场景还是得补上非阻塞连接那一套。4.2 分块发送与进度显示文件内容按 64KB 一块读、一块发每发完一块刷新一次进度。进度用\r回到行首覆盖不刷屏日志重定向到文件时用isatty判断一下退化成百分比。/* src/client.c */ static int send_file_body(int fd, const char *path, uint64_t total) { int in open(path, O_RDONLY); if (in 0) { perror(open); return -1; } char *buf malloc(CHUNK_SIZE); uint64_t sent 0; ssize_t n; int ok 1; while ((n read(in, buf, CHUNK_SIZE)) 0) { if (send_all(fd, buf, (size_t)n) 0) { ok 0; break; } sent (uint64_t)n; print_progress(sent, total); } if (n 0) ok 0; printf(\n); /* 进度条最后一个 \r 之后补个换行 */ free(buf); close(in); return ok ? 0 : -1; } void print_progress(uint64_t done, uint64_t total) { if (total 0) return; double pct (double)done * 100.0 / (double)total; if (isatty(STDOUT_FILENO)) { printf(\r%6.2f%% %llu/%llu bytes, pct, (unsigned long long)done, (unsigned long long)total); fflush(stdout); } else { printf(%.2f%%\n, pct); /* 重定向到日志时换成逐行输出 */ } }read的返回值一定要判 0 0是正常读完 0才是错误且要判EINTR。很多示例代码写成while (read(...))然后拿 buffer 内容发读错误的时候会把上一次的残留数据重复发出去传到对端就是文件中间凭空多了一段重复字节md5 对不上但大小可能恰好凑得上排查起来很折磨。4.3 断点续传把已传偏移塞进 file_size断点续传本质上就是三个动作客户端记住已经传了多少、服务端以追加方式打开文件、两边把偏移对齐。协议上不用加新字段CMD_RESUME复用file_size存「已传字节数」服务端用O_APPEND打开跳过已经存在的部分。/* src/client.c 续传片段 */ static int do_resume(const char *host, int port, const char *path, uint64_t offset) { struct stat st; if (stat(path, st) 0) return -1; int fd tcp_connect(host, port); const char *name strrchr(path, /); name name ? name 1 : path; proto_hdr_t hdr; memset(hdr, 0, sizeof(hdr)); hdr.magic htonl(PROTO_MAGIC); hdr.cmd CMD_RESUME; hdr.name_len htons((uint16_t)strlen(name)); hdr.file_size hton64(offset); /* 已经是对方文件的长度 */ send_all(fd, hdr, sizeof(hdr)); send_all(fd, name, strlen(name)); proto_hdr_t ack; if (recv_all(fd, ack, sizeof(ack)) ! (ssize_t)sizeof(ack) || ack.status ! 0) return -1; /* 从本地 offset 处开始读只发剩余部分 */ int in open(path, O_RDONLY); lseek(in, (off_t)offset, SEEK_SET); char *buf malloc(CHUNK_SIZE); ssize_t n; while ((n read(in, buf, CHUNK_SIZE)) 0) { if (send_all(fd, buf, (size_t)n) 0) break; } free(buf); close(in); close(fd); return 0; }服务端对应的处理是把open的O_TRUNC去掉改成O_WRONLY | O_CREAT | O_APPEND然后先发一个状态包告知「我这边现有长度是多少」客户端拿这个长度和自己本地的 offset 比对不一致就说明中途一方被改过得从头传。注意断点续传必须有校验兜底。追加写入的前提是「已存在的部分是正确的前缀」如果上次传输在磁盘层面已经写坏了续传只会把错误固化下来。我一般在上传完成后单独跑一次 md5不一致就删掉重传。5. 避坑清单粘包、半包、SIGPIPE 和端口占用5.1 传输链路类问题现象客户端显示传了 3.2GB服务端文件只有 3.1GB差几 MB。原因发文件内容时没有用send_all循环send返回的字节数小于请求长度是常态尤其是内核发送缓冲区快满的时候。只调用一次就认为发完了剩下的数据永远留在用户态 buffer 里。 解决所有send/recv一律走send_all/recv_all返回值小于请求长度就继续发剩下的部分。同时检查read的返回值 0要区分EINTR和真错误。现象客户端传到一半按 CtrlC服务端进程直接消失没有 core 也没有错误日志。原因对端 socket 关闭后本端继续send会收到SIGPIPE这个信号的默认处理动作是终止进程而且不产生 core dump所以什么都看不到。 解决signal(SIGPIPE, SIG_IGN)兜底同时send时带MSG_NOSIGNAL标志send返回EPIPE时按「对端已断开」处理关掉本端 socket 即可。现象停掉服务端马上重启报bind: Address already in use等一分钟才恢复。原因服务端是主动关闭方连接进入TIME_WAIT状态内核为了保证最后一个 ACK 能重传会占着这个四元组 60 秒左右。 解决setsockopt加SO_REUSEADDR。如果还有EADDRINUSE用ss -tan state time-wait sport :9000确认一下是不是真的在 time-wait而不是别的进程还占着端口。5.2 数据与文件系统类问题现象改了协议结构体字段新客户端连老服务端服务端直接断开或文件名变成乱码。原因要么是结构体没加packed两边sizeof不一致要么是加了字段但没做字节序转换要么是只改了一端没同步另一端。 解决结构体固定__attribute__((packed))在protocol.h顶部写死#define PROTO_VERSION 1头部加版本字段每次改协议先手写一段 16 进制的字节序列做单元测试两端用同一份头文件编译。现象下载 5GB 的镜像客户端收到的文件大小是 0或者传到 4GB 卡住不动。原因file_size用了int或uint32_t超过 4GB 直接溢出或者编译时没带-D_FILE_OFFSET_BITS64off_t是 32 位sendfile的偏移量在第 2^31 字节处回绕成负数。 解决协议字段用uint64_t本地变量用uint64_t编译选项补上-D_FILE_OFFSET_BITS64lseek用lseek64或直接依赖宏展开。现象上传一个目录里带../的文件名服务端把 storage 外面的配置覆盖了。原因文件名直接拼接到 root 后面没有做合法性校验攻击者构造../../etc/xxx就能写出目录。 解决safe_name白名单校验只放行字母数字和. _ -且首字符不能是点更严格的做法是realpath解析之后检查前缀是否等于 storage 的绝对路径。现象传完文件立刻cat提示「文本文件忙」或者内容是空的。原因close之后数据还在页缓存里没刷到磁盘另一个进程读的时候拿到的是不完整视图如果是 NFS 挂载问题更明显。 解决关键路径上fsync(fd)再close并且在handle_upload里检查write_full的返回值磁盘满时及时回错误码给客户端而不是静默丢弃。6. 二次开发限速、鉴权和并发模型怎么改拿到能跑的版本之后真正要落地到内网环境一般还得补三样东西限速、鉴权、并发模型。限速最直接的价值是不让一个人拷镜像把整条千兆链路打满导致同网段其他人开会卡顿。用令牌桶写起来最省事粗粒度版本就是一秒钟的额度发完就睡一秒/* src/net_util.c 简易限速rate 单位字节/秒0 表示不限 */ typedef struct { uint64_t rate; /* 目标速率 */ uint64_t sent; /* 当前这一秒已发字节数 */ } bucket_t; void bucket_consume(bucket_t *b, size_t n) { if (b-rate 0) return; b-sent n; if (b-sent b-rate) return; struct timespec req { .tv_sec 1, .tv_nsec 0 }; nanosleep(req, NULL); /* 睡满一秒额度清零重新计 */ b-sent 0; }这个版本的问题是粒度粗一秒内突发流量还是会顶到线速。想平滑一点把nanosleep的间隔缩到 10ms每次补充rate/100个令牌效果接近漏桶。真正要做到精确限速令牌桶还要配一个单调时钟记录上次补充时间按实际流逝时间补令牌而不是按固定间隔硬补。鉴权我一般在协议头前加一个CMD_AUTH命令客户端连上后先发用户名加随机串的 HMAC服务端校验通过才允许发其他命令。协议头上不改字段只在session_worker里加一个authed标志位未认证的连接收到UPLOAD/DOWNLOAD直接回status6并断开。密码不要明文存配置文件里存 salted hash校验时用相同的 salt 现算一遍比对。并发模型方面当前「一连接一线程」在几百连接时没问题超过一千就会出现线程栈占用过大默认 8MB 虚拟内存和上下文切换开销。往上升级的路径是把accept之后的 socket 设为非阻塞注册到epoll用固定数量的 worker 线程加任务队列处理就绪事件。改造时要注意sendfile在非阻塞 socket 上会返回EAGAIN必须配合EPOLLOUT事件重试不能像现在这样continue死循环。验证改造是否生效我固定跑这几步先用dd if/dev/urandom oftest.bin bs1M count2048造一个 2GB 的随机文件避免全零文件被文件系统压缩或稀疏化影响结果传完两边md5sum比对中途kill -9客户端验证服务端不会崩限速开启后用iftop或nload看实际带宽是否压在设定值附近最后用strace -f -e tracesendfile,write确认大文件下载真的走了sendfile而不是退回readwrite。有一次我图省事改完协议头没同步客户端就上线了结果传上去的文件全都少了前 16 个字节因为老客户端把新头里的status当成了内容。从那以后我每次动protocol.h都会先跑一遍「造 2GB 文件 → 上传 → md5 比对 → 断点续传再比一次」这套固定流程宁可贵两分钟也不想再对着一堆大小对不上的文件挨个猜。希望帮到你。本文还有配套的精品资源点击获取