简介:一套基于赛灵思ZYNQ-7010平台与SDK开发的LWIP TFTP服务器驱动工程资源包,面向嵌入式网络开发者,用于在ZYNQ上快速搭建轻量级文件传输服务,解决固件更新、配置下发等常见需求。资源共1080个文件,以H头文件与C源代码为主,支撑驱动和协议栈实现;makefile、TCL等工程脚本与约束文件辅助构建和硬件配置;SDK项目文件便于直接导入,压缩包约11.93MB。已有210人学习该资源,可作为ZYNQ网络驱动开发的有效参考。包内包含完整的SDK工程与LWIP协议栈配置,涵盖TFTP服务器驱动源码、预编译库以及硬件设计相关文件,可导入SDK后直接编译运行;实现过程注重线程安全与错误处理,并给出多客户端请求的同步示例,适合需要深入理解嵌入式协议栈集成、驱动开发流程和调试方法的开发者学习借鉴。
1. 为什么在 ZYNQ-7010 上把 TFTP 服务器做成 SDK 库而不是应用
拿到 ZYNQ-7010 板子之后,很多人的第一个验证动作不是测网速,而是能不能通过 TFTP 把镜像拉进 DDR 直接跑,省掉反复插拔 SD 卡。这个 zip 里没有一整套应用层 tftp 源码,而是libxil.a、liblwip4.a、libxilffs.a三个预编译库,外加ps7_init.c、system.bd和Makefile.adapter。看到liblwip4.a就应该明白,它已经把 lwip 协议栈按裸机方式编进了驱动库,libxilffs.a则让文件读写直接走 FATFS 接口。也就是说,驱动库把“以太网链路”和“文件系统”这两块最容易出问题的基础设施先定死了,剩下要写的只是 TFTP 收发状态机。这套方案适合做在线升级、配置文件下发、bootloader 网络加载的工程师,而不是只想跑一个 hello world 的人。
2. 先拆 libxil.a、liblwip4.a 与 ps7_init.c:这套 SDK 驱动库怎么配合
2.1 库和文件的职责边界
解压后看到一堆名字很抽象的文件,先别急着丢进工程。207d...、a062...是两个生成目录,里面装的是 BSP 依赖,真正影响链接结果的是下面这几个文件:
libxil.a:Standalone BSP 的基础库,包含 UART、中断控制器XScuGic、xil_printf、cache 操作等。只要是 standalone 工程,几乎所有功能都挂在它上面。liblwip4.a:lwip 4.x 协议栈的预编译产物。它不只是 TCP/IP 协议栈,还包含了 Zynq PS 侧 GEM 以太网控制器的适配层,所以你才能直接调udp_new、netif_add,不用自己对着XEmacPs寄存器写驱动。libxilffs.a:FAT 文件系统的封装。TFTP 服务端收到上传文件后要落盘,或者下载文件时要读盘,都用它导出的f_open、f_read、f_write。ps7_init.c:上电后第一段要执行的初始化代码,配置 DDR、PLL、MIO 和以太网引脚,必须最先运行。system.bd和system.bxml:Vivado 里的 Block Design 和 SDK 的硬件描述。前者告诉你 ZYNQ-7010 的 PS 侧配置了哪些外设,后者是 SDK 读取硬件信息的依据。
把这几个文件放在同一层目录,说明硬件设计、BSP 初始化代码和协议栈库是一起导出的。这种方式的最大好处是驱动库和硬件描述绑定,换板子时只要替换ps7_init.c和system.hdf,应用层 tftp 代码不用动。
2.2 为什么先看 ps7_init.c 的 MIO 与 PLL,而不是直接调 udp_new
ZYNQ-7010 的以太网控制器挂在 PS 侧,通过 GEM0/GEM1 和 MIO 引脚输出。liblwip4.a里的驱动是通过寄存器访问外设的,但如果ps7_init.c没有把 PLL 配置出外设时钟,或者 MIO 没接到 GEM,那网络驱动初始化时大概率读到全 0,表现为 lwip 起不来、ping 不通。
我拿到资源后会先执行一个很土但有效的检查:
# 在解压目录里看以太网相关配置是否真的存在 grep -niE "eth|gem|mio" ps7_init.c | head -30如果输出里有ETH0、MIO_PIN这类符号,说明硬件初始化代码覆盖了网络引脚。如果完全没有,就要回去看system.bd里 GEM 是不是被勾选掉了。ps7_init.c不是给你读着玩的,它决定 lwip 驱动面对的外设时序是否合法。很多人调了一晚上网口,最后发现是 MIO 没有拉出来。
2.3 用 XSCT 把三份库重新生成进 BSP
这个 zip 已经带了编译好的库,但实际开发中一定会碰到改 lwip 配置、换 BSP 版本、或者团队里对不上库版本的问题。与其手工点 Vivado SDK 界面,不如用 XSCT 脚本一次性把 BSP 重建出来。
先确认你从system.bd生成了system.hdf,然后在 XSCT 命令行里执行:
setws /home/tftp_ws repo -set /home/tftp_ws/embeddedsw hsi open_hw_design /home/tftp_ws/system.hdf hsi set_repo_path /home/tftp_ws/embeddedsw hsi create_sw_design tftp_server -os standalone -proc ps7_cortexa9_0 hsi add_library lwip hsi add_library xilffs hsi generate_bsp -dir ./bsp这段脚本的作用是:打开硬件描述文件、指定 lwip 和 xilffs 库、最后生成一个完整的 BSP 目录。hsi generate_bsp生成出来的bsp/lib下面会有libxil.a、liblwip4.a、libxilffs.a,和你 zip 里的三份库同源。
如果不需要重新生成,直接把解压出来的三个.a文件加入链接器路径,并让ps7_init.c参与编译即可。BSP 里常见的 lwip 参数可以按这样设置:
| 参数 | 推荐值 | 影响 |
|---|---|---|
lwip_dhcp | 0 | 固定 IP,tftp 调试更稳定 |
lwip_pbuf_pool_size | 128 | 收发包缓冲区数量,太小会丢包 |
lwip_checksum_lowmem | 0 | 使用硬件校验,省 CPU |
lwip_udp_rmem | 4096 | UDP 接收窗口,直接影响 TFTP 吞吐 |
lwip_dhcp设为 0 后,lwip 不会在启动时发 DHCP discover,避免上电后等超时。裸机调试阶段固定 IP 是最省心的,等后面要量产再恢复到 DHCP 也不迟。
3. 在裸机 lwip4 上实现 tftp_server 驱动:状态机与 UDP 回调
3.1 raw API 和 netconn API 的取舍
lwip 4 提供了两套网络接口,netconn偏上层,带阻塞语义,适合有 RTOS 的环境;rawAPI 是基于回调的,事件到哪个 PCB 就直接调哪个函数。ZYNQ-7010 上默认的 SDK standalone 环境没有线程调度器,如果用netconn,就额外要 sys_arch 层,还要维护一个 idle 循环。而 TFTP 本身只有 RRQ、WRQ、DATA、ACK、ERROR 五种报文,用 raw UDP 回调写起来最直接,也没有上下文切换开销。
另一个考虑是这套驱动会用在 FSBL 或裸机 bootloader 里,那里对代码体积和入口函数数量都敏感。raw API 编译出来比 netconn 小,函数调用关系清晰,出问题时断点也好打。所以我建议直接基于udp_pcb写,不走 socket 抽象。
3.2 TFTP 协议状态机梳理
TFTP 是建立在 UDP 上的简单协议,初始请求发给固定端口 69,之后通信会切换到新的端口。对于服务器驱动,需要先把状态机理清:
| opcode | 类型 | 方向 | 包内容 |
|---|---|---|---|
| 1 | RRQ | client -> server | 文件名 + 0 + 模式 + 0 |
| 2 | WRQ | client -> server | 文件名 + 0 + 模式 + 0 |
| 3 | DATA | server -> client | 块号(2B) + 数据(0-512B) |
| 4 | ACK | client -> server | 块号(2B) |
| 5 | ERROR | 双向 | 错误码(2B) + 错误信息 + 0 |
读文件时,服务器每次发一个 DATA 包,块号从 1 开始;收到客户端 ACK 后,再发下一块。如果 DATA 数据小于 512 字节,说明文件结束,整个会话自动终止。写文件时相反,客户端发 DATA,服务器回 ACK,最后一个 ACK 确认短包。
这里最容易踩的坑是块号溢出和重传超时。块号只有 16 位,大于 65535 时要回绕到 1,FATFS 里几百 MB 的文件完全可能触发。TFTP 超时没有内建重传,客户端超时后会重发请求,伺服端要保证状态一致,不能因为收到了新的 DATA 就重新f_open。
3.3 注册 UDP PCB 并处理 RRQ/WRQ
下面是一个精简但可以跑的 tftp_server 驱动核心。为了控制篇幅,我先把接收回调和对 RRQ 的响应写出来,文件句柄和端口都放到全局变量里,方便断点观察:
#include "lwip/udp.h" #include "lwip/pbuf.h" #include "lwip/inet.h" #include "ff.h" #include "xil_printf.h" #include "string.h" static struct udp_pcb *tftp_pcb; static FIL g_tftp_file; static u16_t g_tftp_block; static ip_addr_t g_peer_addr; static u16_t g_peer_port; static void tftp_send_error(u8_t code, const char *msg) { struct pbuf *p; u8_t *q; p = pbuf_alloc(PBUF_TRANSPORT, 4 + strlen(msg) + 1, PBUF_RAM); if (!p) { return; } q = (u8_t *)p->payload; q[0] = 0; q[1] = 5; q[2] = 0; q[3] = code; strcpy((char *)q + 4, msg); udp_sendto(tftp_pcb, p, &g_peer_addr, g_peer_port); pbuf_free(p); } static void tftp_send_data(void) { struct pbuf *p; u16_t len; p = pbuf_alloc(PBUF_TRANSPORT, 516, PBUF_RAM); if (!p) { return; } ((u8_t *)p->payload)[0] = 0; ((u8_t *)p->payload)[1] = 3; /* DATA */ ((u8_t *)p->payload)[2] = g_tftp_block >> 8; ((u8_t *)p->payload)[3] = g_tftp_block & 0xff; f_read(&g_tftp_file, ((u8_t *)p->payload) + 4, 512, &len); p->len = len + 4; udp_sendto(tftp_pcb, p, &g_peer_addr, g_peer_port); pbuf_free(p); } static void tftp_recv(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { char *filename; char *mode; u16_t opcode; if (p->len < 8) { pbuf_free(p); return; } opcode = ntohs(((u16_t *)p->payload)[0]); filename = (char *)p->payload + 2; mode = filename + strlen(filename) + 1; g_peer_addr = *addr; g_peer_port = port; if (opcode == 1) { /* RRQ */ if (f_open(&g_tftp_file, filename, FA_READ) != FR_OK) { tftp_send_error(1, "file not found"); } else { g_tftp_block = 1; tftp_send_data(); } } else if (opcode == 2) { /* WRQ */ if (f_open(&g_tftp_file, filename, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) { tftp_send_error(2, "access violation"); } else { g_tftp_block = 0; /* 等待客户端发第一个 DATA 块 */ } } else { tftp_send_error(4, "illegal opcode"); } pbuf_free(p); } int tftp_server_init(u16_t port) { tftp_pcb = udp_new(); if (!tftp_pcb) { xil_printf("udp_new failed\r\n"); return -1; } udp_bind(tftp_pcb, IP_ADDR_ANY, port); udp_recv(tftp_pcb, tftp_recv, NULL); return 0; }tftp_send_data里先分配 516 字节的 pbuf,因为 TFTP 最大包是 2 字节 opcode、2 字节块号、512 字节数据。f_read读到 512 字节以内时,修改p->len为实际长度,这样udp_sendto就不会多发尾部垃圾。tftp_recv里的filename和mode直接指向 pbuf payload,在pbuf_free之前必须完成字符串解析,因此后面打开文件和组包都在同一个回调里做。
WRQ 分支这里只打开了文件,真正接收数据还需要在tftp_recv里处理 opcode 3(DATA)和 opcode 4(ACK),把收到的 DATA 用f_write落盘。裸机环境下没有多线程竞争,但是注意 TFTP 客户端超时后会处于重试状态,如果上一个会话的g_tftp_file还没关闭,新的 RRQ 会覆盖句柄,可能造成 FATFS 文件描述符泄漏。
3.4 lwip 初始化与 tftp 服务启动顺序
协议栈要先启动,网络接口要 up,然后才能绑定 69 端口。把下面这段放在 main 函数前面:
#include "lwip/init.h" #include "lwip/netif.h" #include "lwip/ip4_addr.h" static struct netif g_netif; void tftp_platform_init(void) { ip4_addr_t ip, mask, gw; IP4_ADDR(&ip, 192, 168, 1, 100); IP4_ADDR(&mask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); lwip_init(); netif_add(&g_netif, &ip, &mask, &gw, NULL, xemacpsif_init, xemacpsif_input); netif_set_default(&g_netif); netif_set_up(&g_netif); tftp_server_init(69); }xemacpsif_init和xemacpsif_input是 Xilinx SDK 里 lwip BSP 导出的函数,前者初始化 GEM 并启动 DMA,后者把网卡收到的报文喂给 lwip。代码里的 IP、掩码、网关都用IP4_ADDR宏硬编码,这样板卡每次启动都在已知地址上,方便 tftp 客户端连。
裸机主循环里需要周期性调用xemacpsif_input,否则网络接收回调永远不触发:
while (1) { xemacpsif_input(&g_netif); Xil_DCacheFlush(); }Xil_DCacheFlush是为了保证 DMA 写入的数据能被 CPU 读到,Zynq-7010 的 C-A9 有 cache 一致性风险。如果发现 TFTP 传输速度异常低,或者偶发数据错位,多半是这里 flush/invalidate 没配对。
4. 从 TFTP 下载到 ZYNQ Flash:BOOT.bin 与 image.ub 的升级流程
4.1 为什么 TFTP 驱动不要直接写 Flash
把 TFTP 驱动和 Flash 编程耦合在一起是新手最容易犯的错误。TFTP 是锁步协议,客户端发一个 DATA,服务器必须在超时窗口内回 ACK,否则客户端会重发。擦除和编程 QSPI Flash 往往要几十到几百毫秒,直接放在 TFTP 回调里写,很容易超时,而且 TFTP 重传会带着相同的块号再次进来,导致同一块数据写两次,文件系统目录项会错乱。
更稳妥的架构是:TFTP 驱动只负责把文件搬到 DDR 缓冲区,传完后再做完整性校验,最后按分区写入 Flash。一个典型的 QSPI 分区表如下:
| 起始偏移 | 分区内容 | 长度建议 |
|---|---|---|
| 0x0000000 | FSBL + bitstream | 1 MB |
| 0x0100000 | 裸机应用或 image.ub | 4 MB 以上 |
| 0x0300000 | 回滚备份区 | 4 MB 以上 |
| 0x0500000 | 配置文件 | 256 KB |
把 bootloader、内核、根文件系统分开,升级时只覆盖 app 或 image.ub,避免整个 Flash 被写穿。
4.2 在 FSBL 阶段挂 TFTP 还是在应用阶段再挂
如果做在线升级,我会建议把 tftp_server 驱动同时编译进 FSBL。FSBL 上电先初始化 DDR,然后检查一个 GPIO 或者启动原因寄存器。如果是普通启动,直接跳转到 Flash 里的 app;如果是升级模式,则初始化 lwip 并启动 TFTP 服务器,等上位机传新的 image.ub 到 DDR 的固定地址。
这样做的好处是:即使当前 app 已经跑飞了,只要硬件没坏,依然能进入升级模式。FSBL 里挂 lwip 会增加约 100 KB 左右代码体积,但 ZYNQ-7010 的 DDR 和 QSPI 都装得下,换来的是稳定的恢复入口。
4.3 主机侧触发传输的三种命令
传 BOOT.bin 或 image.ub 时,最常用的是 Windows 自带 tftp,但它只支持单条命令,而且不能指定 blksize:
tftp -i 192.168.1.100 PUT BOOT.binLinux 下的 tftp-hpa 支持交互模式,适合手工调试:
tftp 192.168.1.100 binary put BOOT.bin quit如果是写自动化升级脚本,建议用 curl,它能设置 TFTP blksize 并返回非零退出码,方便 CI 捕获错误:
curl -T app.bin tftp://192.168.1.100/app.bin --tftp-blksize 1468--tftp-blksize 1468是协商更大的 UDP payload,减少 ACK 交互次数,实测吞吐比默认 512 字节快很多。但这个参数需要两头都支持,如果板卡上的 tftp_server 驱动没有解析 option,curl 会回退到 512,不影响正确性。
4.4 Flash 写入顺序和回滚策略
收到完整文件后,先别急着擦 Flash。校验文件长度,或者在 TFTP 包外自定义一个校验包头,比如前 32 字节放 magic number 和长度的 SHA-256 值。确认无误后再执行:
- 按目标分区起始地址逐 sector 擦除。
- 从 DDR 缓冲区按 page 写入 QSPI。
- 读回整段数据,和 DDR 缓冲区逐字节比对。
- 写一个 boot flag 到独立 sector,记录当前版本号。
- 软复位,FSBL 读取新的 boot flag,从新分区启动。
回滚就是把 Flash 里预留两个 app 分区,始终保留上一个可用版本。每次升级只覆盖非活跃分区,校验失败时 boot flag 不变,下次启动继续用旧版。这套方法不依赖 Linux,在裸机 SDK 工程里同样成立。
5. tftp_server 调试到稳定的三个细节:IP、端口与 DMA 描述符
5.1 固定 IP 和防火墙里的 ephemeral 端口
TFTP 最反直觉的地方是服务器收到客户端的 RRQ 后,会从一个新的本地端口发 DATA,而不是继续用 69。所以调试时别只放行 UDP 69,很多防火墙默认拦截高位端口,表现出来就是put卡在发送第一个 DATA 包之后。
先用固定 IP 把环境跑通。BSP 里lwip_dhcp设为 0,然后在板卡串口打印出实际 IP,本地ping 192.168.1.100通了再测 TFTP。如果 ping 通但 tftp 超时,在主机侧用tcpdump -nn -vvv -i eth0 port 69看交互包,重点确认服务器回包是不是从一个 10000+ 端口出来。
5.2 pbuf 池大小和 UDP checksum
lwip 在 BSP 里默认的PBUF_POOL_SIZE是 32,对 TFTP 来说勉强够用。如果传输大文件时传到一半挂住,先看是不是报pbuf_alloc失败。我一般会把lwip_pbuf_pool_size调到 128,同时把lwip_checksum_lowmem设为 0,让 Zynq 的 GEM 硬件计算 UDP checksum,CPU 只负责协议处理,吞吐能提升将近一倍。
注意,如果板卡和主机之间经过了交换机或路由器,UDP checksum 错误可能有伪装来源。若出现大量 checksum error,先换一根网线直连,排除中间链路改动包头的可能。
5.3 DMA cache 一致性与描述符中断
Zynq-7010 上跑裸机 lwip,最容易翻车的是 RX DMA 描述符。GEM 把数据写到 DDR 后,如果 CPU 读到的是 cache 里的旧数据,那整个 TFTP 流程会表现出随机丢包。Xilinx 自带的xemacpsif驱动内部做了Xil_DCacheInvalidate,但如果你改过中断优先级,或者把网络中断和 TFTP 处理放在不同优先级下,就容易漏掉tx完成中断。
调试时可以先不再调optimize -O2,在udp_sendto返回处加一个Xil_DCacheFlush(),再看是否反复断在同一个位置。要是问题消失,基本可以确认是 DMA 描述符的 cache 一致性没保住。最终修复建议是把xemacpsif的 tx/rx ring 和 pbuf pool 都放到非 cacheable 内存段,或者统一在收发路径上显式 flush 和 invalidate。
把 tftp_server 的回调代码、XSCT 脚本和ps7_init.c一起放进版本管理。下次换板子或换 BSP 版本,直接执行脚本重新生成 lib,再对比三个.a文件的构建时间,就能判断是库不匹配还是硬件描述变了,十分钟内可以重新产出完全一致的 SDK 驱动工程。
本文还有配套的精品资源,点击获取