简介:这是一份面向STM32F4开发者的完整FTP服务器工程,整合了FreeRTOS、FATFS与LWIP,解决MCU与Linux/Windows主机之间通过FTP协议传输文件、实现双机系统通信的需求。工程以socket方式建立FTP连接,涵盖操作系统任务调度、文件系统挂载以及网络协议栈适配,适合需要快速搭建嵌入式文件传输通道的中高级开发工程师参考。压缩包共322个文件,大小仅1.87MB,以150个h头文件和130个c源码文件为主,内容覆盖FATFS、LWIP、STM32F4以太网驱动、SDIO/SD卡驱动等,另含txt说明、PDF文档及汇编启动文件,便于按模块查阅和二次移植。目前已有3178人学习下载。资源提供可直接编译的工程骨架,从FreeRTOS任务创建、FATFS挂载、FTP命令解析到中文编码页均有实现,其中cc936等编码文件可支持FATFS中文文件名,有助于深入理解嵌入式FTP服务器整体架构与协议栈移植细节。 在 STM32F4 + FreeRTOS + FATFS + LWIP 这套组合工程里实现一个 FTP 服务器,简单说就是让单片机通过网口对外提供文件读写服务。你在电脑上打开资源管理器、FileZilla 或 WinSCP,输入板子的 IP 地址,就能像操作一台小 NAS 一样浏览 SD 卡里的文件、上传升级包、拉取运行日志。这个能力在产线调试、设备日志回传、批量配置导入这些场景里非常有用,也是把 RTOS、文件系统和网络协议栈三个领域串起来的一次很典型的综合实践。
这个项目的受众其实很明确:已经在用 CubeMX 做过 FreeRTOS 或 LWIP 的嵌入式工程师,刚入门想找一份完整参考例程的同学,以及正在被“板子上的数据怎么方便取出来”这个问题折磨的开发者。我不会只把代码贴出来就完事,而是把选型原因、任务划分、协议细节和踩过的坑都拆开讲,看完可以直接在你的板子上复现,也能应付大部分面试里关于 RTOS 和网络栈的追问。
1. 需求拆解与方案构成:这四个组件是怎么各司其职的
1.1 嵌入式 FTP 服务器的真实使用场景
很多人一听到“单片机做 FTP 服务器”会先愣一下,觉得这就是拿核弹打蚊子。但放到实际项目里,这个需求一点都不奇葩。比如设备在客户现场跑着,客户不会给你开串口调试线,更不会碰任何 IDE,可他需要每天把设备的运行日志导出来审查,或者把新的配置文件放进去。如果设备自带 FTP 服务,那客户只需要在 Windows 资源管理器地址栏输入 ftp://192.168.1.88,回车就能看到内置 SD 卡里的一切,跟操作局域网文件夹一样,培训成本几乎为零。
如果不用 FTP,你当然也能用私有 TCP 协议,但客户端得单独写一个上位机;也可以用 HTTP 服务器,但得同时处理网页资源和动态接口。相比之下,FTP 客户端生态是现成的,Windows、Linux、手机文件管理器全都内置支持。对嵌入式设备来说,FTP 更适合那些“需要被人用现成工具高频访问文件”的场景,而不是替代 HTTP 去做网页交互。
1.2 为什么是 FreeRTOS + FATFS + LWIP,而不是其他方案
这套组合的合理性在于它们各管一摊,互不越界。FreeRTOS 负责多任务调度和任务间同步,LWIP 负责 TCP/IP 协议栈和网络接口,FATFS 负责文件系统的挂载、读写和目录管理,STM32F4 提供外设和算力。换句话说,FTP 服务器本身不是一个第三方库,而是基于这三个组件,在应用层写出来的一个具体业务功能。
最常见的替代方案是在裸机上跑 LWIP 的 RAW API,然后自己用一个 while 循环去轮询多个连接。这样写小 demo 没问题,但一旦牵扯到 SD 卡长时间读盘、客户端连接超时、多客户端并发,裸机轮询的代码会越来越难维护。用 FreeRTOS 把这些阻塞操作放进独立任务里,用信号量和互斥锁去处理资源竞争,代码结构会清晰很多。LWIP 在带 OS 的环境下既可以用 Socket API,也可以用 Netconn API。我的建议是用 Netconn API,它比 Socket 更底层一点,但比 RAW API 友好得多,函数是阻塞式调用的,出错位置很好定位。
1.3 开发前的硬件准备与 CubeMX 配置要点
硬件上以最常见的 STM32F407 或 F429 开发板为例,需要跑 100M 以太网,推荐用带 RMII 接口和 PHY 芯片的板子,比如 LAN8720A。SD 卡走 SDIO 接口,速度比 SPI 模式快很多。你别指望用一个 SPI 接口的 SD 卡模块去跑 FTP,瓶颈会卡在文件读取速度上,体验非常差。
CubeMX 里要打开的组件比较多:RCC 时钟、ETH、SDIO、FATFS、FreeRTOS、LWIP。容易踩坑的有三个地方,我按优先级列在这里:
- ETH 的 RMII 模式需要 PHY 参考时钟,不同 PHY 芯片要求不一样,LAN8720A 通常需要 50MHz 的时钟输入,确认 CubeMX 的时钟树配置和 PHY 芯片手册一致,否则PHY初始化起不来。
- SDIO 建议打开 DMA,并把分频系数调到一个稳定的值,关掉 DMA 后 FATFS 读写速度会掉得非常明显,而且 CPU 占用率暴涨。
- LWIP 启用时建议直接选静态 IP,DHCP 在纯局域网里虽然也能用,但调试时你要多一步查 IP 的流程,不如先固定成 192.168.1.88 这种地址,跑通后再改。
Keil 这边如果你的 CubeMX 版本较新,默认会生成 AC6 工程,老工程用 AC5 编译有时会报一堆语义错误的兼容性警告,建议直接统一到 AC6。装好 Keil 的 STM32F4 器件支持包,确认识别芯片型号,再开始下一步。
2. 基础工程搭建:CubeMX 生成后的三条验证路线
2.1 先把 SD 卡交给 FATFS:挂载、查询容量、写一个字
CubeMX 生成工程后,不要急着写 FTP。第一步先验证 SD 卡文件系统是否真的可用。你需要在用户代码区调用 FATFS 的挂载和打开接口,例如:
FATFS fs; FRESULT res = f_mount(&fs, "0:", 1); if (res != FR_OK) { printf("SD mount failed, error=%d\r\n", res); } DIR dir; res = f_opendir(&dir, "0:/"); if (res != FR_OK) { printf("open root dir failed, error=%d\r\n", res); } FIL fp; res = f_open(&fp, "0:/test.txt", FA_WRITE | FA_CREATE_ALWAYS); if (res == FR_OK) { f_printf(&fp, "hello ftp fs\r\n"); f_close(&fp); }这段代码的作用是确认三件事:SD 卡能被识别、FATFS 能挂载根目录、文件写入后断电不会丢。盘符名称以 CubeMX 自动生成的SDPath宏定义为准,有的是空字符串,有的是"0:"。这一步如果失败,后面 FTP 里所有文件操作都会跟着失败,而且你很难定位是网络问题还是文件系统问题。
2.2 先把网络打通:静态 IP 和最简单的 Ping
文件系统验完,接下来验证 LWIP 网络链路。CubeMX 的 LWIP 配置里直接填好静态 IP、掩码和网关,然后在用户代码初始化完成后调用MX_LWIP_Init()。电脑端把网卡 IP 改成同一个网段,比如板子用 192.168.1.88,电脑用 192.168.1.10,互相 ping 通就说明 PHY 芯片、RMII 接口、LWIP 协议栈的底三层已经没问题了。
如果你 ping 不通,优先检查 PHY 地址和时钟配置。LAN8720A 的 PHY 地址一般是 0,但也有的板子通过外部引脚拉高拉到 1,需要看板子原理图。CubeMX 里 PHY Address 填错,表现就是HAL_ETH_Init能过,但HAL_ETH_ReadPHYRegister读出来全是 0xFFFF。
2.3 验证通过之后再叠加 FTP 应用,不要一口气全做完
我见过很多新手拿到 CubeMX 生成的工程,直接就开始写 FTP,最后出问题了根本不知道是哪一层挂了。正确顺序一定是一层一层验证:先 SD 卡,再网络,然后把两者在 FTP 应用里合起来。这样每一个模块的问题都是单独暴露的,排查范围小得多。
做完这两个基础验证后,可以顺手写一个最简单的 TCP Echo Server 放到板子上,电脑用网络调试助手连上去收发几轮,确认 Netconn API 在多任务环境下的建立连接、发送、接收、断开流程都正常。这时候再进入 FTP 服务器代码的开发,心里就很有底了。
3. FTP 服务器核心实现:协议解析与数据通道
3.1 需要实现的最小命令集
FTP 完整规范命令非常多,但嵌入式设备没必要全部支持。做产品的话,命令集可以砍到十个以内。下面这个表是我实际项目里保留的命令,完全够用:
| 命令 | 作用 | 典型响应 |
|---|---|---|
| USER/PASS | 用户名密码校验 | 230 User logged in |
| SYST | 查询系统类型 | 215 UNIX Type: L8 |
| TYPE | 设置传输模式,I 表示二进制 | 200 Type set to I |
| PWD | 显示当前目录 | 257 "/" is current directory |
| CWD | 切换目录 | 250 Directory changed |
| PASV | 进入被动模式 | 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2) |
| PORT | 进入主动模式 | 200 Port command successful |
| LIST | 列出目录文件 | 150 / 226 |
| RETR | 下载文件 | 150 / 226 |
| STOR | 上传文件 | 150 / 226 |
| DELE | 删除文件 | 250 File deleted |
| QUIT | 断开连接 | 221 Goodbye |
一个关键的格式细节:所有 FTP 控制连接的响应统一以\r\n结尾,不能用\n代替,否则 FileZilla 这类客户端会一直等数据,表现就是连接后没反应、命令超时。
3.2 服务端主循环:监听、接入、命令分发
FTP 使用 TCP 21 号端口作为控制连接。服务端初始化就是一个标准 Netconn 监听流程:
struct netconn *server_conn = netconn_new(NETCONN_TCP); netconn_bind(server_conn, IP_ADDR_ANY, 21); netconn_listen(server_conn); while (1) { struct netconn *client_conn; err_t err = netconn_accept(server_conn, &client_conn); if (err != ERR_OK) { continue; } // 简单做法:单客户端串行处理 ftp_session_handle(client_conn); netconn_delete(client_conn); }ftp_session_handle内部是一个状态机:接收命令行、按命令分发、返回响应。伪代码结构如下:
static void ftp_session_handle(struct netconn *conn) { struct netbuf *inbuf; char *cmd; u16_t len; netconn_write(conn, "220 STM32 FTP Server ready\r\n", 28, NETCONN_COPY); while (netconn_recv(conn, &inbuf) == ERR_OK) { netbuf_data(inbuf, (void **)&cmd, &len); // 从 cmd 里按行解析命令,比如 "RETR 1.txt" // 根据命令调用对应处理函数 netbuf_delete(inbuf); } }这段代码里netconn_recv是阻塞接收,所以ftp_session_handle必须跑在一个独立的 FreeRTOS 任务里,否则它会卡住整个系统调度。对于大多数只支持单客户端的嵌入式设备,直接在 main 或一个任务里循环调用ftp_session_handle就够了。真正产品化以后,每个客户端连接可以创建一个任务,但任务栈开销大,F407 的 RAM 也要仔细权衡。
3.3 被动模式 PASV:端口计算和 227 响应
FTP 有两种数据连接模式,主动模式(PORT)由服务器主动连接客户端指定的端口,被动模式(PASV)由服务器开启一个临时监听端口,等待客户端来连。现在大部分客户端默认都走 PASV,所以我优先把被动模式做好。
PASV 处理流程分三步:创建新的 TCP netconn、绑定任意空闲端口、进入监听状态,然后把这个空闲端口通过 227 响应告诉客户端。端口号需要拆成两个字节,IP 地址也要拆成四个字节,中间用逗号分隔。以开发板本地 IP192.168.1.88,获得的临时端口50000为例,227 响应应该是227 Entering Passive Mode (192,168,1,88,195,80)。这里端口 50000 对应50000 / 256 = 195,50000 % 256 = 80。
获取本地临时端口的典型代码如下:
struct netconn *data_conn = netconn_new(NETCONN_TCP); netconn_bind(data_conn, IP_ADDR_ANY, 0); netconn_listen(data_conn); ip_addr_t local_ip; u16_t local_port; netconn_getaddr(data_conn, &local_ip, &local_port, 0); sprintf(resp, "227 Entering Passive Mode (%u,%u,%u,%u,%u,%u)\r\n", ip4_addr1(&local_ip), ip4_addr2(&local_ip), ip4_addr3(&local_ip), ip4_addr4(&local_ip), (local_port >> 8) & 0xff, local_port & 0xff); netconn_write(conn, resp, strlen(resp), NETCONN_COPY);不同 LWIP 版本的netconn_getaddr签名略有差异,有的版本需要第四个参数指定本地还是远端连接,有的版本更早不需要,写的时候看一下你工程里的lwip/sockets.h或netconn.h声明。
一个很容易踩的坑:这里的local_port是无符号 16 位整型,如果直接用%d格式化,会把值当成有符号数,端口大于 32768 时出现负数,算出来的两个字节完全是乱的。务必要用%u或强制转成uint16_t。
3.4 LIST、RETR、STOR 三个命令的数据流
这三个命令是 FTP 的核心操作,它们都依赖 PASV 建立的数据连接。LIST 命令在数据连接上输出目录列表,RETR 从 SD 卡读文件并通过数据连接发给客户端,STOR 从数据连接接收数据写进 SD 卡。
LIST 的核心实现是遍历 FATFS 目录,拼接字符串后通过netconn_write发送。注意 FATFS 的文件名默认是短文件名格式(比如TEST~1.TXT),如果你需要显示真实的长文件名,必须在ffconf.h里打开FF_USE_LFN。拼接时用全局缓冲区而不是任务栈里的局部数组,否则栈很容易溢出。
RETR 的伪代码如下:
FIL file; if (f_open(&file, path, FA_READ) != FR_OK) { netconn_write(conn, "550 File not found\r\n", 20, NETCONN_COPY); return; } netconn_write(conn, "150 Opening data connection\r\n", 29, NETCONN_COPY); struct netconn *data_conn = netconn_accept(pasv_conn, NULL); uint8_t buf[4096]; UINT br; while (f_read(&file, buf, sizeof(buf), &br) == FR_OK && br > 0) { netconn_write(data_conn, buf, (u16_t)br, NETCONN_COPY); } f_close(&file); netconn_delete(data_conn); netconn_write(conn, "226 Transfer complete\r\n", 23, NETCONN_COPY);STOR 的过程正好相反:先打开文件准备写入,然后netconn_recv循环接收数据,调用f_write落盘,最后关闭文件。这里有个重要细节:整个传输过程中控制连接和数据连接是并行的,你必须先在控制连接上用netconn_accept等数据连接建立,再开始数据收发。如果netconn_accept没有设置超时,客户端一直不建立数据连接,任务就会永久卡死。解决办法是给数据连接设置超时:
netconn_set_recvtimeout(data_conn, 5000); // 5秒超时后netconn_recv会返回ERR_TIMEOUT,你要主动清理连接并返回错误响应。
4. FreeRTOS 多任务协同与稳定性设计
4.1 任务划分与栈大小估算
FTP 服务器并不需要开一大堆乱七八糟的任务,任务太多反而增加上下文切换成本和内存压力。下面是 F407 上比较合理的任务规划:
| 任务名 | 优先级 | 建议栈大小 | 说明 |
|---|---|---|---|
| lwip tcpip_thread | 3 | 1024 字 | LWIP 协议栈专用 |
| ftp_server_task | 5 | 4096 字 | FTP 会话处理、文件读写 |
| eth_link_monitor | 4 | 256 字 | 检测 PHY 连接状态 |
FTP 任务的栈一定要给足,这不是保守,是因为netconn_write、f_read、snprintf这些函数都会叠多层调用,局部缓冲区稍微大一点就可能超过 2048 字。我在调试时遇到过一次很隐蔽的死机,现象是上传文件到一半直接 hardfault,最终定位出来就是 FTP 任务栈溢出,把任务栈从 2048 字改成 4096 字后问题消失。
4.2 开启堆栈溢出检测
FreeRTOS 的栈溢出检测是内建的,但默认是关闭状态。你可以在FreeRTOSConfig.h里打开:
#define configCHECK_FOR_STACK_OVERFLOW 2然后实现钩子函数:
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { printf("stack overflow: %s\r\n", pcTaskName); configASSERT(0); }检测方式建议用 2,它除了检查栈指针越界,还会验证栈末尾的填充模式是否被破坏,对定位稀疏的溢出更有效。这个钩子开着会有少量性能损耗,但开发阶段值得保留。等项目稳定后,再根据运行日志决定是否去掉。
4.3 文件访问的互斥与优先级反转
FATFS 默认并不是线程安全的。如果两个任务同时调用f_open操作同一个文件,可能破坏文件系统结构。CubeMX 生成 FATFS 时,如果你启用_FS_REENTRANT并配置好互斥锁同步函数,文件系统内部会自动加锁。在没有这个配置的情况下,你得自己在 FTP 任务外部包一层互斥锁。
用 FreeRTOS 互斥量还会遇到一个经典问题:优先级反转。如果 FTP 任务优先级是 5,另一个持有文件锁的低优先级任务优先级是 1,而系统里还有一个优先级为 3 的中等优先级任务在疯狂跑,低优先级任务可能一直得不到 CPU,高优先级 FTP 任务反而被阻塞。FreeRTOS 的互斥量默认带优先级继承机制,也就是说持有互斥量的线程会临时提升到等待者的优先级,这能有效缓解这个问题。实际开发中,我的建议是文件系统操作尽量固定在同一个任务里串行执行,别把文件读写接口散落到多个任务中,这是从根源上规避优先级反转的思路。
4.4 LWIP 和 FreeRTOS 的内存配额
LWIP 在带 OS 的模式下会从 FreeRTOS 的堆里分配内存。如果你的heap_4.c的堆总大小不够,FTP 数据传输过程中会出现 pbuf 分配失败,表现为传输卡死、重传不断。F407 的片内 RAM 有 192KB 到 256KB 不等,建议把 FreeRTOS 的configTOTAL_HEAP_SIZE配置在 120KB 以上,然后 LWIP 的MEM_SIZE和PBUF_POOL_SIZE不要太小。MEM_SIZE决定内存池大小,建议至少 64KB;PBUF_POOL_SIZE建议 32 到 64 个 pbuf,每个默认 1512 字节。这样 TCP 接收窗口才有足够的缓冲去吸收网络突发流量。
调这些参数时注意,改完lwipopts.h后要 clean 再 rebuild,否则编译器可能仍沿用旧的配置宏,导致你改了参数但运行效果完全没变化。
5. 实测问题与性能调优实录
5.1 第一张排查表:最常见的六类 FTP 故障
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| PC 无法连接 21 端口 | PHY 初始化失败 / 静态 IP 未生效 / 防火墙拦截 | 先在板子端确认 ping 通,再用资源管理器改成 ftp://IP |
| 连上后卡在登录阶段 | 用户名密码校验逻辑未通 / 响应缺少 \r\n | 串口打印收到原始命令,逐条检查响应格式 |
| 能登录但 LIST 不刷新 | PASV 数据连接没有 accept / 数据通道没建 | 打印 227 响应里的端口,用网络抓包确认客户端是否连过来 |
| 传小文件正常,大文件中断 | 任务栈溢出 / LWIP内存不足 / SD卡写坏块 | 查看栈溢出钩子是否触发,增大任务栈和 PBUF_POOL_SIZE |
| 第二次传文件开始失败 | 上一次数据连接没有关闭,端口被占用 | 每次传输完成后统一 netconn_delete,收尾代码放到公共路径 |
| 中文文件名乱码 | FATFS LFN 未开启或编码格式不匹配 | ffconf.h 里打开 FF_USE_LFN,确认客户端编码和文件名编码一致 |
这些现象里,前两个是最容易被新手误判的。很多“连不上”到最后都是 PHY 层没起来,并不是 FTP 应用代码的问题,所以排查顺序一定是从底层往上层走。我在实际调这个项目时,习惯在 FTP 主循环入口加一句串口日志:FTP client connected, IP: xxx,这样客户端一握手就能从日志里判断 TCP 连接是否建立成功,非常省时间。
5.2 两个让我印象最深的现场问题
第一个是 FileZilla 报“服务器发回了不可路由的地址”。这个问题的根源是我在 227 响应里填的 IP 是0,0,0,0,客户端拿到后尝试连接 0.0.0.0 当然失败。227 的前四个字节必须是服务器监听的本地 IP,你用netconn_getaddr拿到的正是这个值,别再想当然地写死成0,0,0,0,除非你的客户端和服务器在一个特殊网络里配备了额外处理。
第二个是“第二次 LIST 卡死”。第一次下载文件后建立了 PASV 数据连接,传输结束后我只关闭了data_conn,但没有在 PASV 监听上做完整清理,下一次 LIST 时netconn_accept等不到新连接,整个 FTP 任务就阻塞在那了。后面我加了数据连接的超时设置,并且把所有 PASV 监听资源的释放统一放在一个ftp_data_cleanup函数里调用,问题就消失了。这段教训让我意识到,FTP 最要命的不是命令解析,而是每个分支都能正确释放资源,收尾路径必须写严谨。
5.3 FTP 传输性能能跑到多少,怎么继续优化
STM32F407 跑 FTP,受限于 SD 卡读速度和 TCP 窗口大小,传输速度通常落在 300KB/s 到 1MB/s 这个区间。如果你发现速度只有几十 KB/s,先检查 SDIO 是否开了 DMA、分频系数是不是太保守,再看串口调试的printf是不是还在满天飞。之前我犯过一个低级错误:调试日志通过串口轮询打印,打印本身占了大量 CPU,导致 FTP 明明资源充足却跑不满,关掉部分日志后速度直接翻倍。
如果要往更高速度优化,可以从三个方向入手:提高TCP_SND_BUF和TCP_WND配置,让 TCP 窗口能容纳更多在途数据;加大发送缓冲区到 4KB 以上,减少netconn_write的调用次数;把 FATFS 的读缓冲配置调整到与 SD 卡扇区大小匹配,减少读盘次数。这个组合拳实测能让速度从 300KB/s 涨到 800KB/s 以上。但也要认清一个现实:F407 的 RAM 就这么大,传输速度和并发能力不可能无限提升,稳定才是第一位。
这套 FTP 服务器代码最早是在一块 F407 开发板上调通的,后来在产线设备上跑了大半年,让我印象最深的其实不是功能本身,而是排查问题的顺序。凡是客户报“连不上”,我第一反应不是看 FTP 任务,而是看 PHY 的 link 状态和 SD 卡挂载日志;只要这两步正常,绝大部分连接问题都能排除。最后再分享一个很便宜的经验:给 FATFS 的挂载和打开文件失败统一加一个错误码日志函数,打印FR_OK之外的错误码和文件名,排查起来会轻松很多。
本文还有配套的精品资源,点击获取