news 2026/10/23 8:48:25

手写TCP局域网大文件传输服务器:协议、零拷贝与断点续传

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写TCP局域网大文件传输服务器:协议、零拷贝与断点续传

简介: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_BITS=64,否则 32 位环境下off_t只有 4 字节,sendfile和lseek处理大文件会静默截断,这个坑后面还会提。

CC := gcc CFLAGS := -O2 -Wall -Wextra -D_FILE_OFFSET_BITS=64 -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/ntohl,64 位字段 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里字段是 4+1+1+2+8,编译器默认按 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不是为了性能,而是解决「刚 Ctrl+C 停掉服务端,立刻重启却报 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和真错误。

现象:客户端传到一半按 Ctrl+C,服务端进程直接消失,没有 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_BITS=64,off_t是 32 位,sendfile的偏移量在第 2^31 字节处回绕成负数。 解决:协议字段用uint64_t,本地变量用uint64_t,编译选项补上-D_FILE_OFFSET_BITS=64,lseek用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直接回status=6并断开。密码不要明文存,配置文件里存 salted hash,校验时用相同的 salt 现算一遍比对。

并发模型方面,当前「一连接一线程」在几百连接时没问题,超过一千就会出现线程栈占用过大(默认 8MB 虚拟内存)和上下文切换开销。往上升级的路径是把accept之后的 socket 设为非阻塞,注册到epoll,用固定数量的 worker 线程加任务队列处理就绪事件。改造时要注意:sendfile在非阻塞 socket 上会返回EAGAIN,必须配合EPOLLOUT事件重试,不能像现在这样continue死循环。

验证改造是否生效,我固定跑这几步:先用dd if=/dev/urandom of=test.bin bs=1M count=2048造一个 2GB 的随机文件,避免全零文件被文件系统压缩或稀疏化影响结果;传完两边md5sum比对;中途kill -9客户端验证服务端不会崩;限速开启后用iftop或nload看实际带宽是否压在设定值附近;最后用strace -f -e trace=sendfile,write确认大文件下载真的走了sendfile而不是退回read+write。

有一次我图省事,改完协议头没同步客户端就上线了,结果传上去的文件全都少了前 16 个字节,因为老客户端把新头里的status当成了内容。从那以后我每次动protocol.h,都会先跑一遍「造 2GB 文件 → 上传 → md5 比对 → 断点续传再比一次」这套固定流程,宁可贵两分钟,也不想再对着一堆大小对不上的文件挨个猜。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 8:39:09

slub track_item

定义分配/释放跟踪的枚举&#xff0c;以及 sysfs / debugfs 接口的编译期桩函数。一、跟踪项枚举enum track_item { TRACK_ALLOC, TRACK_FREE };配合上一段的 struct track 使用。SLUB 在开启 SLAB_STORE_USER 调试标志后&#xff0c;会在每个对象关联一个 struct track 数组&a…

作者头像 李华
网站建设 2026/10/23 8:32:16

Caelestia效率工具完全指南:截图选区、OCR文字识别与剪贴板历史

【免费下载链接】caelestia-kde Caelestia ported to KDE Plasma 6 项目地址&#xff1a; https://gitcode.com/gh_mirrors/ca/caelestia-kde 点击查看 免费下载 Caelestia 是一个将 macOS 风格界面移植到 KDE Plasma 6 的桌面增强项目&#xff0c;它为日常操作提供了三大效率…

作者头像 李华
网站建设 2026/10/23 8:31:14

Linux 之 cgroup

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 8:26:22

STM32C5与新一代CubeMX2实测:架构亮点与踩坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华