简介:这是一份面向网络编程初学者与课程实验学习者的C语言Socket FTP实现资料,围绕FTP协议与Socket通信展开,适合正在做“实验4 socket编程”类课程作业、需要理解客户端与服务器交互流程的开发者参考。压缩包共6个文件,以2个cpp源码、2个exe可执行程序为主,另附1个txt说明与1个doc文档,整体约258KB,体积轻便,便于快速查阅与本地运行验证。源码分别对应FTP客户端与服务器两端,覆盖套接字创建、连接建立、FTP命令交互、主动与被动模式数据连接处理、文件上传下载及错误处理等关键环节,文档则补充了Easy FTP相关说明与运行提示。已有203人学习下载,读者可借此理清控制连接与数据连接的分工,掌握send/recv收发数据、accept与connect建立数据通道的写法,并对照可执行程序排查环境问题,是理解FTP协议细节与Socket编程落地的不错练手材料。
1. 从一份 C 语言 Socket FTP 实验包说起:谁需要它,能跑出什么
很多人第一次接触网络编程,都是从 FTP 客户端这个场景切入的。原因很直接:它同时踩中了 TCP 连接管理、应用层协议解析、阻塞 IO 读写、文件流操作这几个核心知识点,而且行为可观测——连没连上、命令发没发出去、文件传没传完,都能在终端里看到。这份FTP_socket.rar就是典型的课程实验产物:src目录下是ftp_server.cpp和ftp_client.cpp,bin目录里放了编译好的ftp_client.exe和ftp_server.exe,外加一份Easy FTP 相关文档.doc和一份无法运行的请看说明.txt。它解决的不是生产级文件传输需求,而是让你在一个可控的代码量里,把 socket 编程从socket()到recv()的完整链路亲手走一遍。适合正在做 socket 网络编程实验、想对照一份能编译运行的 C 语言 FTP 实现来理解控制连接与数据连接分离的人。如果你只是想要一个能用的 FTP 软件,这份资源不是最优解;但如果你想看懂 FTP 协议在代码层面到底怎么落地,它值得拆开。
2. 控制连接与数据连接:FTP 协议在 Socket 层面的真实分工
2.1 为什么 FTP 要开两条 TCP 连接
FTP 和 HTTP 最大的结构差异在于:HTTP 在一个连接里完成请求和响应,而 FTP 把「发命令」和「传数据」拆成了两条独立的 TCP 连接。控制连接贯穿整个会话周期,客户端通过它发送USER、PASS、PWD、CWD、TYPE、PASV、RETR、STOR、QUIT等命令,服务器用三位数字状态码加描述文本回应。数据连接只在需要传文件或列目录时临时建立,传完就关。
这个设计带来的直接后果是:你在写客户端时,必须同时管理两个 socket 描述符,而且它们的生命周期不同步。控制连接在connect()成功后一直保持,数据连接则是在收到PASV或PORT响应后才创建。很多初学者写 FTP 客户端时翻车,就是因为把两条连接混在一起处理,或者在数据连接关闭后误关了控制连接。
从代码结构上看,ftp_client.cpp里通常会有两个核心函数:一个负责控制连接上的命令发送与响应读取,另一个负责数据连接上的文件流读写。ftp_server.cpp则需要在accept()控制连接之后,根据客户端发来的PASV或PORT命令,动态创建监听 socket 或主动连接客户端的数据端口。
2.2 主动模式与被动模式的代码差异
主动模式(PORT)和被动模式(PASV)的区别,本质上是「谁先发起数据连接」的问题。主动模式下,客户端通过控制连接发送PORT h1,h2,h3,h4,p1,p2,告诉服务器「你来连我的某个端口」;服务器收到后,用自己的 20 端口主动connect()到客户端指定的地址和端口。被动模式下,客户端发送PASV,服务器开一个临时端口监听,把地址和端口通过227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)返回给客户端,客户端再主动connect()过去。
在 NAT 和防火墙普遍存在的今天,被动模式几乎是唯一能稳定工作的选择。因为主动模式要求服务器主动连客户端,客户端的防火墙通常会拦掉这个入站连接。这份实验包里的客户端如果默认走 PORT 模式,在校园网或家用路由器后面大概率会卡在数据连接建立阶段。常见做法是优先实现 PASV,把 PORT 作为备选。
解析227响应时有个容易忽略的细节:返回的端口号是两段数字,需要按p1 * 256 + p2还原。比如227 Entering Passive Mode (127,0,0,1,19,136),端口就是19 * 256 + 136 = 5000。这个计算如果写错,数据连接会连到一个完全无关的端口上,表现为connect()超时或拒绝。
2.3 用代码把 PASV 数据连接跑通
下面这段代码演示了客户端在收到227响应后,如何解析地址端口并建立数据连接。它假设你已经有一个已连接的控制 socketctrl_sock,并且已经发送了PASV命令、读到了服务器的响应字符串。
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <sys/socket.h> #include <arpa/inet.h> #include <unistd.h> // 从 227 响应中解析出数据连接的 IP 和端口 // 输入示例: "227 Entering Passive Mode (127,0,0,1,19,136)." // 输出: 填充 data_ip 和 data_port int parse_pasv_response(const char *resp, char *data_ip, int *data_port) { int h1, h2, h3, h4, p1, p2; // 定位到左括号后的六个数字 const char *p = strchr(resp, '('); if (!p) return -1; if (sscanf(p, "(%d,%d,%d,%d,%d,%d)", &h1, &h2, &h3, &h4, &p1, &p2) != 6) { return -1; } sprintf(data_ip, "%d.%d.%d.%d", h1, h2, h3, h4); *data_port = p1 * 256 + p2; // 端口还原公式 return 0; } // 建立数据连接,返回数据 socket 描述符,失败返回 -1 int open_data_connection(const char *data_ip, int data_port) { int data_sock = socket(AF_INET, SOCK_STREAM, 0); if (data_sock < 0) return -1; struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(data_port); inet_pton(AF_INET, data_ip, &addr.sin_addr); if (connect(data_sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) { close(data_sock); return -1; } return data_sock; }parse_pasv_response的关键在于sscanf的格式串必须和服务器返回的括号内格式严格对应,多一个空格或少一个逗号都会解析失败。p1 * 256 + p2是 FTP 协议规定的端口还原方式,不能写成p1 * 100 + p2。open_data_connection里用inet_pton而不是inet_addr,是因为前者能正确处理返回值,后者在地址非法时返回INADDR_NONE,和广播地址冲突。
建立数据连接后,客户端发送RETR filename或LIST,然后从data_sock上recv()读取数据,读完后close(data_sock),再回到控制连接读取226 Transfer complete之类的完成响应。这个顺序不能乱:如果先关控制连接再读数据,服务器可能还没把数据发完就收到连接断开,导致传输中断。
3. 把实验包跑起来:编译、启动与一次完整传输
3.1 编译环境与依赖确认
这份包里的src是 C++ 源码,但核心逻辑是 C 风格的 socket 调用。在 Linux 下编译需要链接 socket 库,Windows 下则需要 Winsock2。先确认你的环境:
| 平台 | 编译器 | 需要链接的库 | 头文件差异 |
|---|---|---|---|
| Linux | g++ / gcc | -lpthread(如果用了线程) | sys/socket.h、netinet/in.h、arpa/inet.h |
| Windows + MinGW | g++ | -lws2_32 | winsock2.h、ws2tcpip.h |
| Windows + MSVC | cl.exe | ws2_32.lib | 同上,且需WSAStartup |
Linux 下编译服务端和客户端:
# 编译服务端 g++ -o ftp_server src/ftp_server.cpp -lpthread # 编译客户端 g++ -o ftp_client src/ftp_client.cpp # 如果源码里用了 pthread,服务端需要加 -lpthread # 如果报错 "undefined reference to socket",检查是否漏了链接库Windows 下用 MinGW 编译:
g++ -o ftp_server.exe src/ftp_server.cpp -lws2_32 g++ -o ftp_client.exe src/ftp_client.cpp -lws2_32编译通过后,bin目录里已有的 exe 可以作为对照。如果 exe 无法运行,先看无法运行的请看说明.txt,常见原因是缺少运行时库或者端口被占用。
3.2 启动服务端并确认监听状态
服务端启动前,先确认它监听的端口没有被其他程序占用。FTP 控制连接默认是 21,但实验代码里经常改成 2121 或 8021 来避开权限问题。假设服务端监听 2121:
# Linux 下查看端口占用 ss -tlnp | grep 2121 # Windows 下查看端口占用 netstat -ano | findstr 2121 # 启动服务端,假设它接受一个端口参数 ./ftp_server 2121如果服务端启动后立刻退出,常见原因是bind()返回了EADDRINUSE,也就是端口被占。另一个可能是listen()的 backlog 设成了 0,导致连接排队失败。服务端正常启动后,应该看到类似Server listening on port 2121...的输出,此时用telnet 127.0.0.1 2121应该能看到220欢迎信息。
3.3 客户端登录与文件下载的完整命令序列
客户端连接成功后,一次典型的下载流程在控制连接上的命令序列如下:
C: USER anonymous S: 331 Guest login ok, send your email as password C: PASS guest@example.com S: 230 Login successful C: TYPE I S: 200 Type set to I C: PASV S: 227 Entering Passive Mode (127,0,0,1,19,136) C: RETR test.txt S: 150 Opening BINARY mode data connection [数据连接上接收文件内容] S: 226 Transfer complete C: QUIT S: 221 GoodbyeTYPE I是二进制模式,传可执行文件或压缩包必须设,否则换行符会被转换导致文件损坏。TYPE A是 ASCII 模式,只适合纯文本。很多实验代码默认用 ASCII 模式,传二进制文件时会出现「文件大小对但打不开」的玄学问题,血泪经验是:不确定就一律TYPE I。
客户端代码里,发送命令和读取响应通常封装成两个函数:
// 发送命令并读取一行响应 int ftp_command(int ctrl_sock, const char *cmd, char *resp, int resp_len) { send(ctrl_sock, cmd, strlen(cmd), 0); send(ctrl_sock, "\r\n", 2, 0); // FTP 命令必须以 CRLF 结尾 memset(resp, 0, resp_len); int n = recv(ctrl_sock, resp, resp_len - 1, 0); if (n <= 0) return -1; resp[n] = '\0'; return atoi(resp); // 返回三位状态码 }注意\r\n不能省成\n。FTP 协议明确规定命令行以 CRLF 结束,有些服务器对\n宽容,有些直接不响应。atoi(resp)取前三位数字作为状态码,用于判断命令是否成功。如果服务器返回多行响应(比如LIST的结果),需要循环读取直到遇到状态码 + 空格开头的行。
4. 避坑与排查:这份实验包最容易卡住的五个地方
4.1 客户端连不上服务端,报 Connection refused
现象:客户端connect()返回 -1,errno是ECONNREFUSED。原因通常是服务端没启动,或者监听的地址是127.0.0.1而客户端连的是局域网 IP。解决:先用ss -tlnp确认服务端在监听,再检查服务端bind()时用的地址。如果服务端绑的是INADDR_ANY,客户端连本机 IP 和127.0.0.1都可以;如果绑的是127.0.0.1,外部机器连不上。
4.2 PASV 响应解析后数据连接超时
现象:控制连接正常,PASV也返回了227,但connect()数据端口时卡住直到超时。原因多半是服务器返回的 IP 是内网地址,而客户端在另一个网段。解决:在客户端加一个选项,忽略227响应里的 IP,直接用控制连接的对端 IP 作为数据连接 IP。这是很多 FTP 客户端的标准做法,因为服务器在 NAT 后面时返回的往往是内网地址。
4.3 文件传输到一半中断,收到 426 错误
现象:下载大文件时,传输到中途控制连接返回426 Connection closed; transfer aborted。原因通常是数据连接被提前关闭,或者客户端在recv()返回 0 之前就close()了数据 socket。解决:确保在数据连接上循环recv()直到返回 0,再关闭数据 socket,最后读控制连接的226响应。顺序反了就会触发 426。
4.4 Windows 下编译报 winsock 相关未定义
现象:MinGW 编译时提示undefined reference to WSAStartup@8或类似错误。原因是没链接ws2_32。解决:编译命令加-lws2_32。另外,Windows 下使用 socket 前必须调用WSAStartup(MAKEWORD(2,2), &wsaData),程序结束前调用WSACleanup()。Linux 下不需要这两个调用,跨平台代码里要用#ifdef _WIN32包起来。
4.5 服务端只能处理一个客户端,第二个连接被拒
现象:第一个客户端连上后,第二个客户端connect()一直排队或直接被拒。原因是服务端accept()之后没有 fork 或创建线程,主循环被第一个连接的处理逻辑占住了。解决:在accept()返回后,用fork()或std::thread为每个客户端创建独立处理流程。如果实验代码是单线程的,这就是设计边界,不是 bug,但你要知道它只能用于单人测试。
5. 从能跑到能改:给这份实验包加一个断点续传的验证方法
把实验包跑通只是第一步,真正能让你理解 FTP 协议边界的,是给它加一个REST命令的支持。REST offset用于告诉服务器从文件的第 offset 字节开始传输,这是断点续传的基础。在客户端下载中断后,记录已接收的字节数,重新连接后发送REST <已接收字节数>,再发RETR,服务器就会从指定位置继续发。
验证方法很简单:先完整下载一个文件,记录大小;然后手动在传输到一半时杀掉客户端进程;重新运行客户端,在RETR之前插入REST命令,观察最终文件大小是否和原文件一致。如果服务器不支持REST,会返回500或502,此时只能重新完整下载。
// 断点续传的核心:发送 REST 命令后再 RETR int resume_download(int ctrl_sock, const char *filename, long offset) { char cmd[512], resp[1024]; // 设置二进制模式 ftp_command(ctrl_sock, "TYPE I", resp, sizeof(resp)); // 发送 REST 命令,指定起始偏移 snprintf(cmd, sizeof(cmd), "REST %ld", offset); int code = ftp_command(ctrl_sock, cmd, resp, sizeof(resp)); if (code != 350) { // 350 表示服务器接受偏移,其他状态码说明不支持 return -1; } // 进入被动模式并建立数据连接 ftp_command(ctrl_sock, "PASV", resp, sizeof(resp)); char data_ip[32]; int data_port; if (parse_pasv_response(resp, data_ip, &data_port) != 0) { return -1; } int data_sock = open_data_connection(data_ip, data_port); if (data_sock < 0) return -1; // 发送 RETR,服务器会从 offset 处开始发送 snprintf(cmd, sizeof(cmd), "RETR %s", filename); ftp_command(ctrl_sock, cmd, resp, sizeof(resp)); // 以追加模式打开本地文件,从 offset 处继续写入 FILE *fp = fopen(filename, "ab"); if (!fp) { close(data_sock); return -1; } char buf[4096]; int n; while ((n = recv(data_sock, buf, sizeof(buf), 0)) > 0) { fwrite(buf, 1, n, fp); } fclose(fp); close(data_sock); // 读取传输完成响应 ftp_command(ctrl_sock, "NOOP", resp, sizeof(resp)); return 0; }REST命令的状态码是350,不是200。如果服务器返回500,说明它不支持断点续传,这时候只能退回到完整下载。fopen用"ab"而不是"wb",因为要从 offset 处追加,用"wb"会把已有内容清掉。recv循环结束后不要立刻关控制连接,先发一个NOOP把226响应读出来,避免响应残留在缓冲区里影响后续命令。
从那以后我每次拿到这类实验包,都会先确认三件事:服务端监听端口、客户端默认走 PASV 还是 PORT、二进制模式有没有设对。这三件事决定了它能不能在真实网络环境里跑起来,而不是只在127.0.0.1上自娱自乐。希望帮到你。
本文还有配套的精品资源,点击获取