LV045-WiFi-TFTP文件下载
本文接续 LV040-WiFi连接与ping探测,在 flash-app 工程里实现 wifi tftp 命令:MCU 通过 ESP8266 以 TFTP(RFC 1350)从局域网内的 Windows 服务器下载 app.img,整帧校验通过后暂存到 SPI Flash,为后续 bootloader 在线安装做准备。
一、 AT 指令:从联网到 TFTP 通道
1. TFTP 协议与 UDP 前提
TFTP 基于 UDP,服务器守在 UDP 69 端口等待请求,但回包改用临时端口发送——这个特性决定了后面所有指令参数的选择。协议本身只有 4 种包:
| 操作码 | 包类型 | 方向 | 说明 |
|---|---|---|---|
| 1 | RRQ | 客户端 → 服务器 | 读请求:文件名 + 模式串(octet) |
| 3 | DATA | 服务器 → 客户端 | 数据块,固定 512 B,块号从 1 递增 |
| 4 | ACK | 客户端 → 服务器 | 确认收到的块号 |
| 5 | ERROR | 双向 | 出错(文件不存在、访问违规等) |
末块数据不足 512 B 即为传输结束,无需额外结束包。
2. 多连接模式(AT+CIPMUX)
AT+CIPMUX=1
OK本方案固定使用多连接模式(MUX=1),原因有二:(1)只有 MUX=1 才能显式指定 UDP 本地端口,客户端端口稳定便于服务器回包与抓包对照;(2)透传排错时每条链路有 link id,日志更清晰。
代价是:MUX=1 之后,AT+CIPSTART、AT+CIPSEND、AT+CIPCLOSE 全部必须带 link id 前缀,漏掉就直接 ERROR——这是本链路最大的坑,实测复现见第三节。
3. 建立 UDP 链路(AT+CIPSTART)
AT+CIPSTART=0,"UDP","192.168.10.103",69,8266,1
CONNECT
OK六个参数依次是:link id(0)、类型(UDP)、远端 IP、远端端口(69 = TFTP)、本地端口(8266,任意未占用值)、mode。
关键是最后一个 mode 参数:
- mode = 0:对端固定,来自其它地址/端口的数据被丢弃;
- mode = 1:变对端——收到新对端的数据后自动切换远端。
TFTP 服务器的 DATA 包来自临时端口而非 69 端口,因此必须以 mode = 1 建链,否则第一块数据就收不到。
反例:MUX=1 下写成 AT+CIPSTART="UDP",...(不带 link id),5 参数与 4 参数两种形式实测都直接回 ERROR,且失败极快(9 ms)——秒失败本身就是"指令格式被拒"而非"网络不通"的信号。
4. 数据收发(AT+CIPSEND 与 +IPD)
发送方向:先声明长度,等提示符,再发原始字节:
AT+CIPSEND=0,21
OK
>
(21 字节原始数据)
Recv 21 bytes
SEND OKlen 是字节数,包内可以含 0x00;应答以 > 为准,数据发出后模块回 Recv <n> bytes 与 SEND OK。
接收方向:模块主动上报 +IPD 帧:
+IPD,<id>,<len>:<data>data 是二进制,只能按 <len> 解析,绝不能用 strstr 定位内容——TFTP 包里充满 0x00。另一个时序细节:服务器的回包常在 SEND OK 之前、或与它合并成同一 UART 帧到达,在应用层等 SEND OK 会把 +IPD 一并吞掉,这是调试期实测踩到的坑(第三节 3)。
5. 关闭链路(AT+CIPCLOSE)
AT+CIPCLOSE=0
OK传输完成或出错后都应关闭链路,避免残留 UDP 链路占用本地端口、干扰下次建链。
二、 程序设计
1. 总体流程
设计决策:先下载到外部 SRAM,整帧收完先校验、后落盘。外部 SRAM 有 1 MB(基址 0x68000000,FSMC Bank1 NOR/SRAM3),装得下 128 KB 的最大镜像;校验通过才写 Flash,坏数据永远不会污染存储区。
流程分四步:
(1)建链:AT+CIPMUX=1 → AT+CIPSTART=0,"UDP",<host>,69,8266,1。
(2)传输:发 RRQ,循环收 DATA(写入 SRAM)回 ACK,直到某块数据不足 512 B;3 s 无帧重发上一包,最多 3 次。
(3)整帧校验:魔数 IAPBIN02、头部声明的载荷长度 N + 28 必须与实际收到的字节数完全相等、尾标 IAPEOF02、逐位 CRC32、目标地址白名单(0x08020000 / 0x20007000)。
(4)暂存:写入 SPI Flash 0x002000(W25Q128 扇区 2),供后续 bootloader 校验安装。
镜像帧格式与 tools/pack_iap.py 打包工具严格对应(详见《LV040-image镜像文件打包原理与生成》):
| 字段 | 长度 | 内容 |
|---|---|---|
| 魔数 | 8 B | IAPBIN02 |
| 载荷长度 | 4 B | N,小端 uint32 |
| CRC32 | 4 B | 载荷的 zlib 参数 CRC32,小端 |
| 目标地址 | 4 B | 小端 uint32 |
| 载荷 | N B | app 本体 |
| 尾标 | 8 B | IAPEOF02 |
整帧总长恒为 N + 28——这就是"头部声明长度与实际字节数硬校验"的依据。
2. 代码结构与命令入口
atk_mw8266d_net.h 集中定义全部常量:
| 宏 | 值 | 含义 |
|---|---|---|
NET_TFTP_BLOCK_SIZE | 512 | RFC 1350 固定块大小 |
NET_TFTP_TIMEOUT_MS | 3000 | 一块的等待上限,超时重发 |
NET_TFTP_MAX_RETRY | 3 | 当前块最大重发次数 |
NET_IMG_PAYLOAD_MAX | 128 KB | Flash APP 容器上限 |
NET_STAGE_ADDR | 0x002000 | SPI Flash 暂存区起始偏移 |
UART 层配套两处改动:(1)新增 atk_mw8266d_uart_send(),阻塞式发送原始字节(≤ 2048,可含 0x00)——printf 按 %s 处理会遇 0x00 截断,无法发送 TFTP 包;(2)RX 缓冲从 256 扩到 1024,一条 +IPD 帧携带 512 B 数据加头部约 531 B,256 必然截断。
命令入口 wifi tftp <host> <file>:先经 atk_mw8266d_get_ap_info() 确认已联网,再依次执行下载 → 校验 → 暂存,每步失败即打印 net_res_name() 对应的错误名并返回。
3. TFTP 会话实现
3.1 atk_mw8266d_net_tftp_get()
TFTP 下载主函数,该函数在 Drivers/BSP/ATK_MW8266D/atk_mw8266d_net.h 文件中声明:
net_res_t atk_mw8266d_net_tftp_get(const char *host, const char *filename,
uint8_t *dst, uint32_t dst_size,
uint32_t *img_len);【函数作用】
建立 UDP 链路并发起 TFTP 读请求,把服务器返回的数据块逐块写入 dst,直到末块;无论成败都关闭链路后返回。内部按"单缓冲帧"处理 +IPD:一次 +IPD 恰好落在一条 UART 帧里,解析按长度取数据。
【参数含义】
host:TFTP 服务器 IP 字符串,不能为空filename:请求的文件名,最长 200 字节dst:下载缓冲区首地址(本方案传外部 SRAM 基址0x68000000)dst_size:缓冲区容量,至少 516 B;超限立即返回NET_ERR_TOOLARGEimg_len:实际下载字节数的落点,可为 NULL
【返回值】
NET_OK:下载完成,*img_len为实际字节数NET_ERR_CONNECT:建链或 RRQ 发送失败NET_ERR_NOTFOUND:服务器回 ERROR 1(文件不存在)NET_ERR_TFTP:其它协议层错误(链路被关、ACK 发送失败等)NET_ERR_TIMEOUT:3 s × 4 轮无任何回包NET_ERR_TOOLARGE:数据超出缓冲区容量
重发策略与 TFTP 标准一致但更保守:还没收到块 1 时超时重发 RRQ;已经收过块 n 时超时重发对 n 的 ACK;收到块 n 的重复 DATA(服务器没收到上一个 ACK)就再 ACK 一次。乱序块直接丢弃,等服务器超时重传补齐。
4. 镜像校验与暂存
4.1 atk_mw8266d_net_img_verify()
整帧校验,该函数在 Drivers/BSP/ATK_MW8266D/atk_mw8266d_net.h 文件中声明:
net_res_t atk_mw8266d_net_img_verify(const uint8_t *img, uint32_t img_len,
uint32_t *payload_len, uint32_t *target_addr);【函数作用】
对 SRAM 里的整帧镜像做五道检查,全部通过才允许落盘。CRC32 用逐位实现(zlib 参数:0xEDB88320 反射,初值与终值异或 0xFFFFFFFF),与 pack_iap.py 的 zlib.crc32 严格一致。
【参数含义】
img:整帧镜像首地址img_len:实际收到的字节数payload_len:载荷长度落点,可为 NULLtarget_addr:目标地址落点,可为 NULL
【返回值】
NET_OK:五道校验全部通过NET_ERR_HDR:任一校验失败(魔数、长度、尾标、CRC、目标地址白名单)
五道校验里最关键的是长度硬校验:img_len == 20 + N + 8 必须精确成立,头里声明的长度与实际收到的字节数差一字节都算失败——这把"下载被截断/多传"这类问题挡在了落盘之前。
4.2 atk_mw8266d_net_stage()
写入 SPI Flash 暂存区,该函数在 Drivers/BSP/ATK_MW8266D/atk_mw8266d_net.h 文件中声明:
net_res_t atk_mw8266d_net_stage(const uint8_t *img, uint32_t img_len);【函数作用】
把校验通过的整帧镜像写入 SPI Flash NET_STAGE_ADDR(0x002000)起的暂存区,走 spiflash_write():跨扇区自动先擦除,写完自动回读比对。
【参数含义】
img:整帧镜像首地址img_len:整帧字节数
【返回值】
NET_OK:写入且回读比对通过NET_ERR_FLASH:写入或比对失败
5. 实测结果
局域网实测(Windows Tftpd64 + 手机热点):
[ESP8266] tftp get app.img from 192.168.10.103 ...
[ESP8266] recv 8192 bytes
[ESP8266] recv 16384 bytes
[ESP8266] recv 24576 bytes
[ESP8266] recv 32768 bytes
[ESP8266] downloaded 34472 bytes in 21350 ms
[ESP8266] verify ok: payload 34444 bytes, target 0x08020000
[ESP8266] staging to spi flash @ 0x00002000 ...
[ESP8266] staged 34472 bytes to flash @ 0x0000200034472 B 用时 21.3 s,约 1.6 KB/s。瓶颈不在 WiFi 而在 AT 指令流程:每 512 B 块都要走一遍 AT+CIPSEND 应答(OK → > → SEND OK),115200 波特率下往返开销占绝对大头。对一次性的固件下载而言这个速度完全可接受,没有必要为它上透传模式。
三、 常见问题排查
1. 排查思路与工具
(1)透传优先。MCU 侧逻辑复杂,先用 at 命令逐条验证 AT 指令层(at AT+CIPMUX=1、at AT+CIPSTART=0,...),指令层通了再看应用层。
(2)对照服务器日志。Tftpd64 的「Tftp 客户端」页每个请求一行:总数 列等于文件大小说明文件找到了;字节 列为 0 说明一块都没发出去或发出后没被 ACK;具体错误原因看「日志查看」页。
(3)分段定位。失败发生在哪一段决定了根因范围:秒失败是指令格式问题;请求到达但 0 字节是回包被吞;传一半超时是防火墙或丢包;下载成功但校验失败是镜像内容问题。
2. connect failed(9 ms 即失败)
现象:wifi tftp 建链即报 connect failed,耗时仅几毫秒;透传下 AT+CIPSTART="UDP",... 回 ERROR,而 AT+CIPMUX=1 正常。
根因:MUX=1 模式下所有连接指令必须带 link id,AT+CIPSTART 漏掉 0, 前缀直接被指令层拒绝——失败快恰恰是格式错误的特征,真正的网络故障要等超时。
修复:AT+CIPSTART=0,"UDP",<host>,69,<port>,1。
3. 服务器收到请求但 0 字节,MCU 报 no reply
现象:Tftpd64 日志显示请求已到达、ERR、传输 0 字节;MCU 侧重试 4 轮后报 no reply from server。
根因:应用层在发出 RRQ 原始字节后还在等模块回 SEND OK,而服务器的 DATA/ERROR 帧就在这几毫秒内到达,被等待循环当作无关帧丢弃;之后每次超时重发 RRQ 都重复同样的竞态。Tftpd64 侧每次请求都重发 DATA 直到自己的超时,最终记 ERR、0 字节——两侧日志合起来才是完整证据链。
修复:(1)发完原始字节立即返回,不等 SEND OK,未送达由外层超时重发兜底;(2)+IPD 解析改为在帧内搜索 +IPD, 头,兼容它与 SEND OK 合并成一帧的情况。
4. 第一块数据就报 image too large for buffer
现象:下载刚开始(第一块 512 B)就报 image too large for buffer。
根因:容量比较写成了 (uint16_t)(dst_size - total),容量 131100 被截断成 28,512 B 的第一块必然超限。
修复:按 32 位比较,不缩窄类型。教训:容量/长度运算不要为了省事强转到窄类型。
5. 超时时间比配置值长一倍
现象:配置的超时是 3 s × 4 轮 = 12 s,实测却接近 24 s。
根因:收包循环用 quiet_ms++ 计数,每圈实际约 2 ms(HAL_Delay(1) 加处理开销),标称毫秒数被放大一倍。
修复:改用 HAL_GetTick() 差值计时。教训:轮询计数当毫秒用,前提是每圈严格 1 ms,否则一律用 tick。
6. 现象速查表
| 现象 | 根因 | 修复 |
|---|---|---|
建链秒失败(connect failed) | MUX=1 下 CIPSTART 缺 link id | AT+CIPSTART=0,"UDP",... |
| 服务器 ERR、0 字节,MCU no reply | 等 SEND OK 吞掉 +IPD | 发完即返回 + 帧内搜索 IPD 头 |
| 收得到 IPD 但内容残缺 | RX 缓冲小于 512 B + IPD 头 | 缓冲扩到 1024 |
| 建链正常但收不到 DATA | CIPSTART 少传 mode 或 mode=0 | 末参传 1(变对端) |
| PC 端抓不到回包方向流量 | Windows 防火墙拦截 Tftpd64 | 放行 Tftpd64 或临时关闭防火墙 |
| 第一块就报 too large | 容量比较被 uint16 截断 | 按 32 位比较 |
| 超时比配置值长一倍 | 轮询计数当毫秒用 | 改用 HAL_GetTick() |
本文档由 markdowncli 技能辅助生成