从零实现Linux C语言FTP云盘:Socket编程与文件传输实战 1. 项目概述与核心价值最近在整理个人服务器上的文件时总感觉用传统的SFTP或者SCP命令传输文件虽然稳定但少了点“云”的便捷感。尤其是在需要临时分享文件给朋友或者在不同设备间同步少量数据时总希望有个更轻量、更直观的界面。于是一个念头冒了出来能不能自己动手用最基础的C语言在Linux系统上搓一个简易的FTP云盘呢这个“基于Linux用C语言实现简易FTP云盘”的项目本质上就是构建一个私有、轻量的文件共享服务。它不像Nextcloud或Seafile那样功能庞大也不依赖复杂的Web界面而是回归到FTP协议本身通过实现一个服务端和一个命令行客户端让你能像访问一个远程文件夹一样在本地和服务器之间自由地上传、下载、浏览文件。对于C语言学习者、嵌入式开发者或者任何想深入理解网络编程和文件系统交互的朋友来说这都是一次绝佳的实战机会。它能让你亲手触摸到Socket通信、多进程/多线程、文件I/O、协议解析等核心知识最终产出一个真正可用的工具成就感直接拉满。2. 整体架构设计与技术选型2.1 为什么选择FTP协议与C语言组合首先为什么是FTPFTP文件传输协议是一个古老但经典的协议其设计清晰地将控制连接默认端口21和数据连接分开。控制连接用于发送命令和接收响应而数据连接则专门用于传输文件内容或目录列表。这种分离使得协议逻辑相对简单非常适合作为学习网络编程的入门项目。虽然FTP明文传输存在安全隐患但在内网环境或学习场景下我们可以先专注于实现核心功能。其次为什么是C语言在Linux环境下C语言是“系统级”编程的首选。它允许我们直接调用系统调用如socket,bind,listen,accept,fork,open,read,write等对网络和文件操作进行最精细的控制。用C语言实现意味着你需要手动管理内存、处理错误、解析协议这个过程虽然繁琐但能让你对底层机制有刻骨铭心的理解。相比之下用Python或Go可能几十行代码就能搭个简易服务但也就错过了深入内核的乐趣。2.2 核心架构拆解服务端与客户端我们的简易FTP云盘将采用经典的C/S客户端/服务器架构。服务端是整个系统的核心它需要监听与控制创建一个Socket绑定到21端口持续监听来自客户端的连接请求。会话管理每当有客户端连接服务端需要创建一个新的进程或线程来处理这个会话保证多个客户端可以同时访问。命令解析与响应接收客户端发送的FTP命令如USER,PASS,LIST,RETR,STOR等并按照RFC 959协议规范进行解析和响应。数据连接管理根据PASV或PORT命令建立数据连接用于传输文件或目录列表。文件系统操作根据命令在服务器指定的根目录下执行切换目录、列出文件、读取文件内容用于下载、写入文件内容用于上传等操作。客户端则相对简单它是一个命令行工具需要连接服务器根据用户输入的IP和端口发起连接到服务端的控制端口。命令发送与结果展示将用户输入的命令如ls,get file.txt,put image.jpg转换为标准的FTP命令格式发送给服务端并将服务端的响应状态码和消息以及数据文件内容或列表展示给用户。本地文件操作执行上传时读取本地文件下载时写入本地文件。技术栈选择Socket编程使用sys/socket.h等头文件这是网络通信的基石。多进程/多线程使用fork()创建子进程或pthread_create()创建线程来处理并发客户端。初学者更推荐fork()因为它隔离性好一个客户端崩溃不会影响服务器主进程和其他客户端。文件I/O使用fcntl.h或stdio.h中的文件操作函数。目录操作使用dirent.h来遍历目录生成文件列表。字符串处理大量使用string.h中的函数来解析命令和路径。注意在实际动手前务必规划好服务器的文件根目录。绝对不要让客户端可以访问到系统根目录/或其他敏感路径。通常的做法是在服务器启动时通过chroot或严格的文件路径检查将客户端限制在某个特定目录如/home/ftpuser/share下。3. 服务端核心实现详解3.1 主循环与并发处理模型服务端的启动入口是一个无限循环核心任务是接受新的客户端连接。这里我选择了fork()多进程模型因为它概念清晰进程间资源隔离对初学者调试更友好。int main() { int server_fd, new_socket; struct sockaddr_in address; int opt 1; int addrlen sizeof(address); // 1. 创建Socket文件描述符 if ((server_fd socket(AF_INET, SOCK_STREAM, 0)) 0) { perror(socket failed); exit(EXIT_FAILURE); } // 2. 设置Socket选项避免“Address already in use”错误 if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, opt, sizeof(opt))) { perror(setsockopt); exit(EXIT_FAILURE); } // 3. 绑定地址和端口 address.sin_family AF_INET; address.sin_addr.s_addr INADDR_ANY; // 监听所有网卡 address.sin_port htons(FTP_PORT); // FTP_PORT定义为21 if (bind(server_fd, (struct sockaddr *)address, sizeof(address)) 0) { perror(bind failed); exit(EXIT_FAILURE); } // 4. 开始监听设置最大等待连接数 if (listen(server_fd, 3) 0) { perror(listen); exit(EXIT_FAILURE); } printf(FTP Server listening on port %d\n, FTP_PORT); // 5. 主循环接受连接并fork子进程处理 while (1) { if ((new_socket accept(server_fd, (struct sockaddr *)address, (socklen_t*)addrlen)) 0) { perror(accept); continue; // 接受失败继续循环 } // 创建子进程处理这个客户端 pid_t pid fork(); if (pid 0) { perror(fork failed); close(new_socket); } else if (pid 0) { // 子进程 close(server_fd); // 子进程不需要监听socket handle_client(new_socket); // 处理客户端会话的核心函数 close(new_socket); exit(0); // 会话结束子进程退出 } else { // 父进程 close(new_socket); // 父进程不需要与客户端通信的socket // 注意这里需要处理僵尸进程可以使用signal(SIGCHLD, SIG_IGN)或waitpid } } return 0; }关键点解析SO_REUSEADDR选项这非常重要。它允许服务器在重启后立即绑定到同一个端口而无需等待之前的连接完全释放TIME_WAIT状态结束。没有这个选项调试时会非常痛苦。fork()后的资源管理子进程会复制父进程的文件描述符表。因此在子进程中要关闭不需要的server_fd在父进程中要关闭new_socket避免文件描述符泄漏。僵尸进程处理上面代码省略了僵尸进程的回收。一个简单的办法是在main函数开始处添加signal(SIGCHLD, SIG_IGN);让内核自动回收已终止的子进程。更严谨的做法是使用waitpid()在循环中非阻塞地回收。3.2 客户端会话处理与命令解析handle_client函数是每个客户端会话的核心。它首先需要按照FTP协议向客户端发送欢迎消息220然后进入一个命令处理循环。void handle_client(int client_socket) { char buffer[BUFFER_SIZE] {0}; char current_working_dir[PATH_MAX] /home/ftp/share; // 预设的服务器根目录 int is_authenticated 0; // 简单的认证标志 char username[50] {0}; // 发送欢迎消息 send_response(client_socket, 220, Welcome to My Simple FTP Server); while (1) { memset(buffer, 0, BUFFER_SIZE); int valread read(client_socket, buffer, BUFFER_SIZE - 1); if (valread 0) { break; // 客户端断开连接 } buffer[valread] \0; printf(Received from client: %s, buffer); // 调试用 // 解析命令和参数 char command[10] {0}; char argument[BUFFER_SIZE] {0}; sscanf(buffer, %s %[^\n], command, argument); // 简单分割命令和参数 // 转换为大写便于比较FTP命令通常不区分大小写但规范是大写 for (int i 0; command[i]; i) { command[i] toupper(command[i]); } // 命令路由 if (strcmp(command, USER) 0) { handle_user(client_socket, argument, username); } else if (strcmp(command, PASS) 0) { is_authenticated handle_pass(client_socket, argument, username); } else if (!is_authenticated) { send_response(client_socket, 530, Please login with USER and PASS.); } else if (strcmp(command, SYST) 0) { send_response(client_socket, 215, UNIX Type: L8); } else if (strcmp(command, FEAT) 0) { send_response(client_socket, 211, No features.); } else if (strcmp(command, PWD) 0 || strcmp(command, XPWD) 0) { handle_pwd(client_socket, current_working_dir); } else if (strcmp(command, CWD) 0) { handle_cwd(client_socket, argument, current_working_dir, sizeof(current_working_dir)); } else if (strcmp(command, PASV) 0) { handle_pasv(client_socket); // 进入被动模式准备数据连接 } else if (strcmp(command, LIST) 0 || strcmp(command, NLST) 0) { handle_list(client_socket, current_working_dir); } else if (strcmp(command, RETR) 0) { handle_retr(client_socket, argument, current_working_dir); } else if (strcmp(command, STOR) 0) { handle_stor(client_socket, argument, current_working_dir); } else if (strcmp(command, QUIT) 0) { send_response(client_socket, 221, Goodbye.); break; } else { send_response(client_socket, 502, Command not implemented.); } } close(client_socket); }命令解析要点状态码FTP协议依赖状态码进行交互。例如220是服务就绪331需要密码230登录成功150打开数据连接226传输完成550文件操作失败等。正确发送状态码是客户端能正常工作的前提。认证简化为了简化我们可以实现一个固定的用户名和密码如user: ftpuser, pass: ftppass或者在代码中硬编码一个列表。生产环境则需要连接数据库或读取配置文件。路径安全这是重中之重在handle_cwd切换目录、handle_retr下载、handle_stor上传等函数中必须对客户端传入的路径进行安全检查防止目录遍历攻击如../../../etc/passwd。一个常见的做法是将客户端输入的路径与服务器根目录进行拼接然后使用realpath()函数解析出绝对路径再检查这个绝对路径是否以服务器根目录开头。如果不是则拒绝访问。3.3 被动模式PASV与数据连接的建立FTP的数据连接建立方式有两种主动模式PORT和被动模式PASV。由于现代网络环境客户端通常在NAT或防火墙后的限制被动模式更为通用。这里我们重点实现PASV模式。当客户端发送PASV命令时服务端需要随机创建一个新的Socket绑定到一个临时端口端口0让系统自动分配。监听这个新的Socket准备接受来自客户端的数据连接。将这个Socket绑定的IP和端口以特定格式h1,h2,h3,h4,p1,p2告诉客户端。void handle_pasv(int ctrl_sock) { int pasv_sock; struct sockaddr_in pasv_addr; socklen_t addr_len sizeof(pasv_addr); char ip_str[INET_ADDRSTRLEN]; int port; // 创建数据Socket if ((pasv_sock socket(AF_INET, SOCK_STREAM, 0)) 0) { send_response(ctrl_sock, 425, Cant open data connection.); return; } // 绑定到任意地址和随机端口 memset(pasv_addr, 0, sizeof(pasv_addr)); pasv_addr.sin_family AF_INET; pasv_addr.sin_addr.s_addr htonl(INADDR_ANY); pasv_addr.sin_port htons(0); // 端口0表示由系统分配 if (bind(pasv_sock, (struct sockaddr*)pasv_addr, sizeof(pasv_addr)) 0) { close(pasv_sock); send_response(ctrl_sock, 425, Cant open data connection.); return; } // 开始监听 if (listen(pasv_sock, 1) 0) { close(pasv_sock); send_response(ctrl_sock, 425, Cant open data connection.); return; } // 获取实际绑定的IP和端口 getsockname(pasv_sock, (struct sockaddr*)pasv_addr, addr_len); port ntohs(pasv_addr.sin_port); inet_ntop(AF_INET, (pasv_addr.sin_addr), ip_str, INET_ADDRSTRLEN); // 解析IP为逗号分隔格式并计算端口的高8位和低8位 unsigned char ip_bytes[4]; inet_pton(AF_INET, ip_str, ip_bytes); int p1 port / 256; int p2 port % 256; char response[256]; snprintf(response, sizeof(response), 227 Entering Passive Mode (%d,%d,%d,%d,%d,%d), ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3], p1, p2); send_response(ctrl_sock, 227, response); // 关键需要将pasv_sock保存到客户端会话上下文中供后续LIST/RETR/STOR使用 // 这里简化处理可以使用全局变量或结构体传递。实际中应与会话绑定。 // 例如set_client_data_socket(client_id, pasv_sock); // 然后在handle_list等函数中accept这个pasv_sock来建立数据连接。 }数据连接的使用当服务端需要发送目录列表LIST或文件内容RETR时它会accept()在pasv_sock上等待的客户端连接获得一个新的数据Socketdata_sock然后通过这个data_sock发送数据发送完毕后关闭data_sock和pasv_sock。3.4 文件列表LIST与文件传输RETR/STOR实现LIST命令核心是遍历服务器当前工作目录生成一个格式化的列表字符串通过数据连接发送。可以使用opendir,readdir,stat等函数。void handle_list(int ctrl_sock, const char* path) { // 1. 检查之前是否成功建立了PASV数据连接获取数据socket (data_sock) int data_sock get_client_data_socket(); // 假设这个函数能获取到 if (data_sock 0) { send_response(ctrl_sock, 425, Use PORT or PASV first.); return; } // 2. 告诉客户端“数据连接已打开开始传输” send_response(ctrl_sock, 150, Here comes the directory listing.); DIR* dir opendir(path); if (dir NULL) { send_response(ctrl_sock, 550, Failed to open directory.); close(data_sock); return; } struct dirent* entry; struct stat file_stat; char list_buffer[4096] {0}; char line[512]; while ((entry readdir(dir)) ! NULL) { // 跳过 . 和 .. if (strcmp(entry-d_name, .) 0 || strcmp(entry-d_name, ..) 0) { continue; } char full_path[PATH_MAX]; snprintf(full_path, sizeof(full_path), %s/%s, path, entry-d_name); if (lstat(full_path, file_stat) 0) { // 模拟 ls -l 的简单格式 char type S_ISDIR(file_stat.st_mode) ? d : -; char perm[10]; snprintf(perm, sizeof(perm), %c%c%c%c%c%c%c%c%c, type, (file_stat.st_mode S_IRUSR) ? r : -, (file_stat.st_mode S_IWUSR) ? w : -, (file_stat.st_mode S_IXUSR) ? x : -, (file_stat.st_mode S_IRGRP) ? r : -, (file_stat.st_mode S_IWGRP) ? w : -, (file_stat.st_mode S_IXGRP) ? x : -, (file_stat.st_mode S_IROTH) ? r : -, (file_stat.st_mode S_IWOTH) ? w : -, (file_stat.st_mode S_IXOTH) ? x : -); // 格式化一行信息 snprintf(line, sizeof(line), %s %ld %s %s %10ld %s %s\n, perm, (long)file_stat.st_nlink, user, // 这里应获取实际用户名简化处理 group, // 这里应获取实际组名 (long)file_stat.st_size, Mon DD HH:MM, // 这里应格式化时间简化处理 entry-d_name); strcat(list_buffer, line); } } closedir(dir); // 3. 通过数据连接发送列表 send(data_sock, list_buffer, strlen(list_buffer), 0); close(data_sock); // 关闭数据连接 // 4. 告诉客户端“传输完成” send_response(ctrl_sock, 226, Directory send OK.); }RETR下载与STOR上传命令这两个命令的核心是二进制数据流的传输。需要以二进制模式打开文件循环读取/写入数据块。void handle_retr(int ctrl_sock, const char* filename, const char* base_path) { // 1. 安全检查拼接路径防止目录遍历 char safe_path[PATH_MAX]; if (!resolve_safe_path(base_path, filename, safe_path, sizeof(safe_path))) { send_response(ctrl_sock, 550, Failed to resolve path or access denied.); return; } // 2. 打开文件 FILE* file fopen(safe_path, rb); if (!file) { send_response(ctrl_sock, 550, Failed to open file.); return; } // 3. 获取数据socket并发送150响应 int data_sock get_client_data_socket(); if (data_sock 0) { fclose(file); send_response(ctrl_sock, 425, Use PORT or PASV first.); return; } send_response(ctrl_sock, 150, Opening BINARY mode data connection for file transfer.); // 4. 传输文件内容 char buffer[4096]; size_t bytes_read; while ((bytes_read fread(buffer, 1, sizeof(buffer), file)) 0) { if (send(data_sock, buffer, bytes_read, 0) 0) { // 发送失败 break; } } fclose(file); close(data_sock); // 5. 判断传输是否成功完成 if (feof(file)) { send_response(ctrl_sock, 226, Transfer complete.); } else { send_response(ctrl_sock, 451, Local error in processing.); } } void handle_stor(int ctrl_sock, const char* filename, const char* base_path) { // 1. 路径安全检查同上 char safe_path[PATH_MAX]; if (!resolve_safe_path(base_path, filename, safe_path, sizeof(safe_path))) { send_response(ctrl_sock, 550, Failed to resolve path or access denied.); return; } // 2. 打开文件准备写入 FILE* file fopen(safe_path, wb); if (!file) { send_response(ctrl_sock, 550, Could not create file.); return; } // 3. 获取数据socket并发送150响应 int data_sock get_client_data_socket(); if (data_sock 0) { fclose(file); send_response(ctrl_sock, 425, Use PORT or PASV first.); return; } send_response(ctrl_sock, 150, Opening BINARY mode data connection for file transfer.); // 4. 接收并写入文件内容 char buffer[4096]; int bytes_received; while ((bytes_received recv(data_sock, buffer, sizeof(buffer), 0)) 0) { size_t bytes_written fwrite(buffer, 1, bytes_received, file); if (bytes_written ! bytes_received) { // 写入失败磁盘可能满了 break; } } fclose(file); close(data_sock); if (bytes_received 0) { // 连接正常关闭 send_response(ctrl_sock, 226, Transfer complete.); } else { send_response(ctrl_sock, 451, Local error in processing.); // 可以考虑删除不完整的文件remove(safe_path); } }重要提示二进制模式在打开文件进行FTP传输时必须使用二进制模式rb和wb。如果使用文本模式r和w在Windows系统或某些特定环境下可能会对换行符\n进行转换导致传输的文件损坏。这是FTP文件传输中最常见的坑之一。4. 命令行客户端实现要点服务端完成后我们需要一个客户端来测试和交互。一个简易的命令行客户端可以这样设计// 客户端主循环伪代码 int main(int argc, char* argv[]) { // 连接服务器控制端口 ctrl_sock connect_to_server(server_ip, 21); // 循环读取用户输入 while (1) { printf(ftp ); fgets(user_input, sizeof(user_input), stdin); user_input[strcspn(user_input, \n)] 0; // 去掉换行符 // 解析本地命令转换为FTP命令 if (strncmp(user_input, ls, 2) 0) { // 1. 发送PASV命令获取数据端口 // 2. 解析227响应连接到数据端口 // 3. 发送LIST命令 // 4. 从数据连接读取目录列表并打印 // 5. 关闭数据连接 } else if (strncmp(user_input, get , 4) 0) { char* filename user_input 4; // 1. 发送PASV命令 // 2. 发送RETR filename命令 // 3. 从数据连接接收文件并写入本地 } else if (strncmp(user_input, put , 4) 0) { char* filename user_input 4; // 1. 检查本地文件是否存在 // 2. 发送PASV命令 // 3. 发送STOR filename命令 // 4. 读取本地文件并通过数据连接发送 } else if (strcmp(user_input, quit) 0) { send_command(ctrl_sock, QUIT); break; } else { // 其他命令如USER, PASS, CWD, PWD直接发送 send_command(ctrl_sock, user_input); } // 读取并打印服务器的响应 read_and_print_response(ctrl_sock); } close(ctrl_sock); return 0; }客户端的难点在于被动模式PASV的解析。你需要正确解析服务端返回的227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)响应提取出IP地址h1.h2.h3.h4和端口p1*256 p2然后主动向这个地址和端口发起数据连接。5. 编译、部署与测试实战5.1 编译与运行假设你的项目文件结构如下simple_ftp/ ├── server.c ├── client.c ├── common.h (包含一些共享的定义和函数声明) └── Makefile一个简单的Makefile可以这样写CC gcc CFLAGS -Wall -Wextra -g # 开启所有警告和调试信息 all: ftp_server ftp_client ftp_server: server.c common.h $(CC) $(CFLAGS) -o ftp_server server.c ftp_client: client.c common.h $(CC) $(CFLAGS) -o ftp_client client.c clean: rm -f ftp_server ftp_client在终端中执行make即可编译。首先运行服务端# 可能需要sudo权限绑定21端口或者使用1024以上的端口如2121进行测试 sudo ./ftp_server # 或者使用非特权端口 ./ftp_server # 并修改代码中的FTP_PORT为2121然后在另一个终端运行客户端进行测试./ftp_client 127.0.0.1 2121 # 连接后输入用户名密码根据你服务端的实现 ftp USER ftpuser ftp PASS ftppass ftp PASV ftp ls ftp get test.txt ftp put local_file.jpg ftp quit5.2 常见问题与调试技巧在开发过程中你几乎一定会遇到下面这些问题“Address already in use” (绑定失败)原因之前的服务器进程没有完全释放端口处于TIME_WAIT状态。解决在服务器Socket创建后立即设置SO_REUSEADDR选项见3.1节代码。或者换一个端口号测试。客户端连接后立即断开或命令无响应原因服务端发送的响应格式不符合FTP协议规范。FTP响应必须以状态码空格文本\r\n结束。很多初学者忘记发送\r\n。调试使用netcat或telnet手动连接服务器端口直接发送命令观察服务器返回的原始数据。确保每行响应都以\r\n结尾。telnet 127.0.0.1 2121 220 Welcome... # 你应该看到这个 USER ftpuser # 你输入 331 Please specify the password. # 服务器应该这样回PASV模式失败无法列出目录或传输文件原因1服务端PASV命令返回的IP是内网IP如192.168.1.x但客户端在外网自然无法连接。这是FTP在复杂网络环境下的经典问题。解决1仅在局域网内测试或服务端配置公网IP并正确设置PASV地址需要解析外网IP。原因2客户端防火墙或服务端防火墙阻止了数据端口一个随机的高位端口。解决2在测试时可以暂时关闭防火墙sudo ufw disable谨慎操作或为FTP服务端配置固定的PASV端口范围并在防火墙中开放。调试在服务端handle_pasv函数中打印出它监听的IP和端口。在客户端尝试用telnet手动连接这个IP和端口看是否能通。传输的文件损坏尤其是文本文件原因没有以二进制模式rb,wb打开文件。在文本模式下\n和\r\n会被转换。解决检查handle_retr和handle_stor中的fopen调用确保模式字符串中包含b。目录遍历安全漏洞现象客户端可以通过输入../../../etc/passwd下载系统文件。解决实现严格的resolve_safe_path函数。逻辑是将客户端提供的相对路径与服务器根目录拼接使用realpath()获取绝对路径然后检查这个绝对路径是否以服务器根目录的绝对路径开头。示例int resolve_safe_path(const char* base, const char* user_path, char* resolved, size_t size) { char combined[PATH_MAX]; snprintf(combined, sizeof(combined), %s/%s, base, user_path); char real_combined[PATH_MAX]; char real_base[PATH_MAX]; if (realpath(combined, real_combined) NULL) return 0; if (realpath(base, real_base) NULL) return 0; // 检查解析后的路径是否在根目录之下 if (strncmp(real_combined, real_base, strlen(real_base)) ! 0) { return 0; // 路径越界不安全 } strncpy(resolved, real_combined, size); resolved[size - 1] \0; return 1; }内存泄漏与文件描述符泄漏原因malloc的内存没有free打开的文件或Socket没有close。解决养成良好习惯。对于每个malloc想好在哪里free。对于每个open/fopen/socket想好在哪里close/fclose。使用valgrind工具进行检测valgrind --leak-checkfull ./ftp_server6. 功能扩展与优化方向一个基础版本完成后你可以考虑以下方向进行扩展让它更像一个真正的“云盘”用户管理与权限实现多用户系统每个用户有独立的根目录。可以读取/etc/passwd进行系统认证或者使用简单的配置文件。进一步可以为不同用户设置只读、读写等权限。断点续传实现REST设置文件偏移量命令结合RETR/STOR支持上传下载中断后从中断点继续。加密与安全实现基于SSL/TLS的FTPS。这涉及openssl库的使用难度较大但能极大提升安全性。日志记录将用户登录、命令执行、文件传输等操作记录到日志文件中便于审计和排查问题。配置文件将服务器监听的端口、根目录、允许的用户等信息从代码中抽离到配置文件如json或ini格式提高灵活性。守护进程化使用daemon()函数或将程序配置为systemd服务让服务器在后台稳定运行。简易Web界面用Python Flask或Node.js写一个简单的Web前端通过调用后端FTP服务或直接操作文件系统提供浏览器上传下载功能这才是“云盘”的形态。实现这个简易FTP云盘的过程就像在搭积木。从最基础的Socket通信开始一块块加上认证、命令解析、文件操作、并发处理。每解决一个bug每实现一个功能你对网络、对系统、对C语言的理解就会加深一层。最终当你用自己的客户端成功从服务器拉下一个文件时那种感觉比直接用现成的软件爽太多了。这不仅仅是完成了一个项目更是给自己构建了一套理解计算机如何工作的底层地图。