手把手搞懂TFTP:简易服务器与客户端实现全解析(C/C++代码实现)
在嵌入式开发、局域网小文件传输场景中,你大概率听过「TFTP」这个词——它不像FTP那么复杂,没有认证、没有连接管理,却能快速完成小文件的传输。今天我们就结合一份极简的TFTP服务器/客户端代码,用大白话讲透TFTP的核心原理、代码设计思路,以及背后的网络编程知识点。
一、先搞懂:TFTP到底是个啥?
TFTP的全称是「简单文件传输协议(Trivial File Transfer Protocol)」,重点在「Trivial(简易)」:
- 基于UDP协议:不用像TCP那样建立连接,省去了三次握手/四次挥手,实现简单但不可靠,所以需要自己加「超时重传」「确认应答」机制;
- 核心用途:适合传输小文件(比如嵌入式固件、配置文件),常见于局域网内的设备调试;
- 无认证/无权限:设计初衷就是轻量,所以没有用户名密码、文件权限校验这些功能;
- 传输单位:以「512字节」为一个数据块(最后一块小于512字节表示传输结束),每发一个数据块都要等对方的ACK(确认包),丢包了就重传。
TFTP的核心操作就5种(用「操作码」区分):
- RRQ(1):读请求(客户端向服务器要文件);
- WRQ(2):写请求(客户端给服务器传文件);
- DATA(3):数据块(传输实际文件内容);
- ACK(4):确认包(收到数据块后告诉对方);
- ERROR(5):错误包(传输出错时返回,比如文件不存在、磁盘满)。
二、代码整体设计思路:极简但够用
这份代码的核心设计思路是「协议拆解+模块化+基础容错」,没有过度封装,能清晰看到TFTP的本质。整体分为两大块:服务器端、客户端,核心逻辑围绕「数据包封装/解封装」「超时重传」「请求处理」展开。
1. 核心数据结构:把TFTP包「具象化」
代码里定义了3个关键结构体,对应TFTP的核心数据包,本质是把二进制的网络包转换成C语言能操作的结构体:
// 请求包(RRQ/WRQ):包含操作码、文件名、传输模式(比如octet二进制模式)
typedef struct {
unsigned short int opcode; // 操作码(RRQ=1/WRQ=2)
char filename[MAX_FILENAME];// 要传输的文件名
char zero_0; // 协议要求的分隔符(空字符)
char mode[MAX_FILENAME]; // 传输模式(固定为octet,二进制)
char zero_1; // 分隔符
} TFTP_Request;
// 数据块包(DATA):操作码+块编号+512字节数据
typedef struct {
unsigned short int opcode;
unsigned short int block; // 块编号(从1开始,逐块递增)
char data[DATA_SIZE]; // DATA_SIZE=512
} TFTP_Data;
// 确认包(ACK)/错误包(ERROR):操作码+块编号(错误包时块编号是错误码)
typedef struct {
unsigned short int opcode;
unsigned short int block;
} TFTP_Ack;
举个例子:客户端发「读请求」时,先把「操作码=1、文件名=test.bin、模式=octet」填到TFTP_Request里,再通过request_to_packet函数把结构体转换成二进制包,最后通过UDP发出去;服务器收到包后,用packet_to_request把二进制包转回结构体,就能直接拿到文件名和请求类型了。
2. 核心机制:超时重传(解决UDP不可靠问题)
UDP本身不保证数据能到,所以代码里用「信号+longjmp」实现超时重传:
- 用
alarm(TIMEOUT_SECS)设置3秒超时(TIMEOUT_SECS=3); - 注册SIGALRM信号处理函数
timer:如果3秒没收到ACK,就触发重传; - 最多重传5次(MAX_TIMEOUTS=5),还没收到就终止传输;
- 同时处理SIGINT(用户按Ctrl+C),能优雅中断传输。
这个逻辑的核心代码在timer函数里,用longjmp跳回发送数据的位置,实现「发包→等ACK→超时重传」的循环。
3. 服务器端设计:并发处理(fork子进程)
TFTP服务器的核心是「父进程监听,子进程处理」,避免一个客户端占满服务器:
- 父进程:绑定端口(默认3335),一直监听UDP包;
- 收到客户端的RRQ/WRQ请求后,
fork()一个子进程; - 子进程:专门处理这个客户端的传输(读/写文件),父进程继续监听新请求;
- 用SIGCHLD信号回收子进程,避免僵尸进程。
服务器处理RRQ(客户端读文件)流程(文字版原理图):
客户端 服务器
| |
| 发送RRQ(要文件) |
|------------------> |
| | 父进程fork子进程
| | 子进程检查文件是否存在
| | 存在:逐块读文件→发DATA包(块1)
| <------------------ |
| 收到DATA→发ACK(块1)|
|------------------> |
| | 收到ACK→发DATA包(块2)
| <------------------ |
| 发ACK(块2) |
|------------------> |
| ...(循环直到最后一块)|
| 最后一块(<512字节) |
| <------------------ |
| 发ACK(最后一块) |
|------------------> |
| | 传输结束
服务器处理WRQ(客户端写文件)流程(文字版原理图):
客户端 服务器
| |
| 发送WRQ(传文件) |
|------------------> |
| | 父进程fork子进程
| | 子进程检查文件是否已存在
| | 不存在:发ACK(块0,确认接收请求)
| <------------------ |
| 收到ACK→发DATA(块1)|
|------------------> |
| | 收到DATA→写文件→发ACK(块1)
| <------------------ |
| 发DATA(块2) |
|------------------> |
| | 发ACK(块2)
| <------------------ |
| ...(循环直到最后一块)|
| 最后一块(<512字节) |
|------------------> |
| | 写文件→发ACK→传输结束
4. 客户端设计:分读/写两种模式
客户端逻辑更简单,核心是「发请求→按协议收发数据」:
- 读模式(-r):发RRQ→收服务器的DATA包→写本地文件→发ACK;
- 写模式(-w):发WRQ→等服务器的ACK(块0)→读本地文件→发DATA包→等ACK;
- 同样带超时重传,确保数据能传完。
三、核心代码模块拆解
我们挑几个关键函数,说说它们的作用:
1. 数据包封装/解封装:request_to_packet/packet_to_request
这两个函数是「结构体↔网络包」的转换器,比如request_to_packet:
void request_to_packet(TFTP_Request *r, char *buf) {
char *pos = buf;
// 操作码转网络字节序(大端),因为网络传输用大端
*(short signed int*)pos = htons(r->opcode);
pos += sizeof(r->opcode);
// 填文件名+分隔符
strcpy(pos, r->filename);
pos += strlen(r->filename) + 1;
*pos = r->zero_0;
// 填模式+分隔符
strcpy(pos, r->mode);
pos += strlen(r->mode) + 1;
*pos = r->zero_1;
}
重点:htons函数把主机字节序(比如x86是小端)转换成网络字节序(大端),这是网络编程的基础——不同架构的机器字节序不同,必须统一成网络字节序才能通信。
2. 数据发送:send_data
不管是服务器给客户端发文件,还是客户端给服务器发文件,都用这个函数:
- 打开文件,按512字节逐块读;
- 给每个数据块编上号(从1开始);
- 发DATA包→等ACK→超时重传;
- 最后一块小于512字节时,传输结束。
3. 数据接收:recv_data
对应send_data,负责收DATA包:
- 收DATA包→检查块编号(避免重复);
- 把数据写到文件;
- 发ACK确认;
- 如果磁盘满了,发ERROR包(错误码3)。
四、这份代码用到的核心知识点总结
看似简单的TFTP代码,其实覆盖了网络编程的核心考点,也是嵌入式/后端开发的基础:
1. UDP网络编程基础
socket(AF_INET,SOCK_DGRAM,0):创建UDP套接字;bind:绑定端口(服务器必须绑定,客户端可选);sendto/recvfrom:UDP的收发函数(因为UDP无连接,每次收发都要指定对方地址);- 地址结构体
struct sockaddr_in:包含IP、端口、协议族,是网络编程的标配。
2. 进程/信号管理
fork():创建子进程实现并发,这是服务器并发的基础(虽然TFTP用UDP,也可以用多线程,但fork更简单);- 信号处理:SIGALRM(超时)、SIGINT(中断)、SIGCHLD(回收子进程);
alarm:设置定时器,配合SIGALRM实现超时逻辑。
3. TFTP协议核心规则
- 块编号从1开始,最后一块小于512字节;
- WRQ请求后,服务器先回ACK(块0),客户端再发数据;
- 传输模式固定为octet(二进制),避免文本模式的编码问题;
- 错误码规范:比如1=文件不存在、3=磁盘满、4=非法操作。
4. 字节序转换
htons(主机转网络短整型)、ntohs(网络转主机短整型):解决不同机器字节序的兼容问题,网络传输必须用大端序。
五、代码的使用方式(快速上手)
...
int main(int argc, char** argv) {
...
while (--argc > 0) {
char *str = *++argv;
if (*str != '-') {
host = (char *)malloc( sizeof(char)*(strlen(str)+1) );
strcpy(host, str);
continue;
}
str++;
if (*str == 'l') {
server = 1;
}
else if (*str == 'p') {
if (--argc > 0) {
port = get_port(*++argv);
if (port < 0) {
printf("Invalid port number: %s
", *argv);
exit(0);
}
}
}
else if (*str == 'v') {
is_debugging = 1;
printf("Verbose mode on.
");
}
else if (*str == 'r' || *str == 'w') {
mode = *str;
--argc;
filename = (char *)malloc( sizeof(char)*(strlen(*++argv)+1) );
strcpy(filename, *argv);
}
}
if (server == 1) {
run_server(port);
} else {
if (host != NULL && mode > 0 && filename != NULL) {
run_client(host, port, mode, filename);
free(host);
free(filename);
} else {
printf("Usage:
Server: mytftp -l [-p port] [-v]
Client: mytftp [-p port] [-v] [-r|w file] host
");
}
}
return 0;
}
...
这份代码编译后可直接用,参数设计很简单:
1. 启动服务器
# 基础版:监听3335端口
./mytftp -l
# 自定义端口+调试模式(-v):监听8080端口,打印传输细节
./mytftp -l -p 8080 -v
2. 客户端操作
# 从服务器下载文件(-r):从192.168.1.100下载test.bin
./mytftp -r test.bin 192.168.1.100
# 向服务器上传文件(-w):把local.bin传到192.168.1.100
./mytftp -w local.bin 192.168.1.100
# 自定义端口+调试模式
./mytftp -p 8080 -v -r test.bin 192.168.1.100
If you need the complete source code, please add the WeChat number (c17865354792)
总结
这份代码虽然简易,但完美体现了TFTP的设计核心:用最简单的机制解决UDP不可靠的问题——没有复杂的连接管理,靠「请求-应答+超时重传」保证传输完成;没有花哨的功能,只聚焦「文件传输」这个核心需求。
对于开发者来说,读懂这份代码的价值远不止学会TFTP:
- 理解「无连接协议」的容错设计思路(UDP应用的通用套路);
- 掌握网络包的封装/解封装方法(所有网络协议的基础);
- 熟悉信号、进程、字节序这些Linux系统编程的核心知识点;
- 搞懂嵌入式场景中「轻量协议」的设计逻辑(够用就好,不冗余)。
Welcome to follow WeChat official account【程序猿编码】









