Skip to content

LV025-基于TFTP的网络升级实验 ​

一、 为什么在 TCP 之外再加一条 TFTP 通道 ​

已经有了 TCP 升级通道(LV020-网络通信实验 第三节),为什么还要再做一条 TFTP?原因写在 ota_tftp.h 的文件头里,主要是三条:

(1)PC 侧零代码。Tftpd64 / haneWIN / SolarWinds 这类现成工具,指定一个根目录就算"固件仓库",不用为升级专门写服务端;而走 TCP 就得带着那个 Python 脚本。

(2)适配紧张的内存预算。lwipopts.h 里 MEM_SIZE 只有 8 KB、PBUF_POOL_SIZE 只有 8 个,用 TCP 一次性拉 128 KB 固件得先把这些宏调大;TFTP 的停等协议自带流控,原样可用。

(3)不需要 DNS 与 HTTP 那一层。服务器地址由宏或命令行给出,省掉一整层协议。

一句话:TCP 通道是给"有上位机脚本、要回执与确认"的场景准备的;TFTP 通道是给"只想把固件扔到服务器根目录、用现成工具升级"准备的。两条并存,按场合选。

二、 实现范围:只做够用的部分 ​

1. 明确不做的三件事 ​

TFTP 客户端在 atk-f103/Drivers/BSP/Ota/ota_tftp.c,是 RFC 1350 的精简版。明确不做的三件事:

不做理由
只做 RRQ(读),不做 WRQ(写)升级只需要"往下拉"
不协商 blksize / tsize / timeout 选项512 字节块对 128 KB 固件是 256 块,直连链路上就 1 秒量级;换来"任何 TFTP 服务器都能用"
不做断点续传失败整份重来,128 KB 重来的代价很小

用到的报文操作码只有四个:RRQ(1)、DATA(3)、ACK(4)、ERROR(5)。收到 OACK(6) 视为异常——因为本模块没协商过选项,服务器不该回它。

2. 关键参数 ​

参数取值
服务器端口69(标准)
块大小512 字节
单次等待上限1000 ms
最大连续重传5 次(一旦有进展就清零)
固定文件名app.img

"一旦有进展就清零"这一条值得留意:重传次数统计的是"连续失败",不是"总失败"。否则一条慢链路跑上几百个块时,累计计数很容易先到上限而误判为失败。

三、 PC 侧与板侧的准备 ​

1. PC 侧:TFTP 服务器与固件帧 ​

这一条是最容易踩的坑:TFTP 服务器根目录下的 app.img 必须是 tools/pack_iap.py 打好的帧,不是随便一个裸 bin。帧里的魔数、长度与 CRC32 是板侧唯一的校验依据,格式细节见 LV030-网络升级帧协议与三条通道 第二节。

打包命令(flash-app 工程在编译后会自动跑这一步,也可以手工执行):

bash
python tools/pack_iap.py -p flash-app.bin -o app.img -f

准备步骤:

(1)装一个 TFTP 服务器(Tftpd64 / haneWIN / SolarWinds 均可),把根目录指到一个空目录。

(2)把打好包的 app.img 放进这个根目录。

(3)把服务器所在网卡的 IP 设成板子默认要找的地址:

参数默认值
TFTP 服务器 IP192.168.16.100
板子 IP(LV020 第一节)192.168.16.30

两者同网段即可,用不到网关。若服务器地址不方便改,可以在板子上用命令行临时指定(下一节)。

2. 板侧:update 命令与注册时机 ​

命令作用
update从默认地址(192.168.16.100)拉取 app.img
update 192.168.16.1临时指定 TFTP 服务器地址

命令在串口终端里输入,也可以走 TCP 通道——因为 TCP 收到的字节同样会先过一遍串口命令表。

关于注册时机:update 命令在 ota_update_init() 里注册,而它在 iap_service_init() 中排在 usart1_rx_start() 之前。这不是随便排的:串口命令表只在启动阶段接受注册,注册晚了命令就不存在。同一条规矩也适用于 go / reboot,见 LV010-lwIP协议栈移植 第五节。

四、 一次升级的实测输出与常见失败 ​

1. 串口日志 ​

text
OTA: fetching app.img from 192.168.16.100:69 ...
OTA: the file must be the IAPBIN01 frame from pack_iap.py (build output app.img)
OTA: progress: '#' = one 512-byte block, 64 per line
################################################################
################################################################
################################################################
################################################################
#
OTA: app.img transferred, 131096 bytes in 257 block(s)
OTA: frame handed to the IAP state machine
IAP: header OK, payload 131072 bytes, expected CRC32 0xXXXXXXXX, receiving...
IAP: frame received, CRC32 OK (0xXXXXXXXX), payload 131072 bytes
IAP: send 'go' + Enter to run the APP

2. 进度条的含义 ​

进度条每个 # 代表一个 512 字节块,一行 64 个(32 KB)。因为没协商 tsize,事先不知道文件多大,所以它报的是活动量而不是百分比——131096 字节是 257 块,所以是 4 整行再加末尾一个单独的 #。

再核对完成行的字节数:131096 = 16 + 131072 + 8,即"帧头 16 字节 + 载荷 128 KB + 尾部 8 字节"。pack_iap.py 组帧时不做任何填充(build_packet 就是"头 + 载荷 + 尾"直接拼接,parse_packet 也要求文件长度与头部声明严格相等),所以文件字节数减去 24 就是载荷字节数——这也是判断"传的是帧而不是裸 bin"最快的办法。

块数也可以顺手核对一遍:257 = 256 个整块 + 一个只有 24 字节的尾块(16 + 8 = 24),与完成行完全吻合。

3. 常见失败现象对照 ​

现象可能原因
OTA: cannot start: ...UDP PCB 创建/绑定失败,通常是内存池被占满
OTA: transfer failed: timeout服务器没起、IP 不在同网段、防火墙拦了 69 端口
OTA: server has no 'app.img' (tftp error 1)根目录里没有这个文件名,或根目录指错了
进度条走完但报帧错误传的是裸 bin 而不是帧——这是最常见的一种错
卡在 go 之后起不来检查第六节那三步关网络的代码是否都执行了;也可回看 APP 的链接地址是否与 IAP_APP_BASE 一致
想省事只发一次TFTP 通道不提供"发完自动跳转",跳转始终要显式 go

三条通道(串口 / TCP / TFTP)喂进同一个帧状态机的对照关系,见 LV030-网络升级帧协议与三条通道。

五、 下载与帧状态机怎么衔接 ​

1. sink 回调的统一套路 ​

这条通道的分工非常清楚:TFTP 模块只负责"把一个文件按字节搬下来",不解释内容。

text
PC(TFTP 服务器) --UDP--> ota_tftp.c --sink 回调--> iap_rx_byte()
                        --> 主循环 iap_rx_poll() 报事件
                        --> CRC32 校验 --> 写 APP 区 --> 等 'go' 跳转
TFTP 停等传输与 IAP 帧状态机的衔接

数据出口用的是"注册 sink 回调"这套路,和串口通道的 usart1_set_rx_sink() 完全一致:

阶段动作
传输期间usart1_set_rx_sink(0) 摘掉串口消费器,只让 TFTP 喂状态机
传输成功sink 保留给帧状态机,ota_update_task() 不再抢这一步
传输失败iap_rx_init() 丢弃半截帧,回到扫描态
失败但帧已报错提前 ota_tftp_abort(),不白等整份下载

这里有个设计取舍值得记:帧校验与写 Flash 全部复用 iap.c,本模块一行不重复实现。所以三条通道(串口 / TCP / TFTP)的帧语义永远一致,不会出现"TFTP 升级的校验规则和串口不一样"这种事。

2. 两个必须守住的协议细节 ​

ota_tftp.c 的文件头专门写明了两个坑,都属于"不这么做就一定出错"的类型:

(1)服务器收到 RRQ 后会换一个端口(TID)发数据。所以客户端只能先 udp_sendto() 到 69 端口,收到第一个应答后再锁定对端;一上来就 udp_connect() 到 69,后续所有 DATA 都收不到。

(2)重复块只补 ACK,绝不重复交给上层。ACK 丢了服务器会重发同一块,如果把这块数据再喂一次状态机,上层立刻错位——同一段固件被写两遍。

3. 一次传输的报文往来与结束判据 ​

客户端只实现 RRQ(读文件),一次完整传输的报文序列是固定的:

方向报文说明
板子 → PC:69RRQ app.img octet目标端口固定 69,模式固定 octet(不做换行转换)
PC → 板子DATA #1服务器换用自己挑的端口回数据
板子 → PCACK #1停等:收到一块确认一块
PC → 板子DATA #2 … DATA #n载荷依次交给上层状态机
板子 → PCACK #n第 n 块载荷不足 512 字节,传输结束

结束判据只有一条——收到一个载荷不足 512 字节的块:

c
// atk-f103/Drivers/BSP/Ota/ota_tftp.c
if (dlen < OTA_TFTP_BLKSIZE)
{
    /* 不足一块就是最后一块; 文件长度恰好是块大小整数倍时, 服务器会补一个
     * 0 字节块收尾, 同样落进这个分支 */
    s_final_block = block;
    tftp_finish(OTA_TFTP_OK);
}

两个容易误读的地方:

(1)文件长度恰好是 512 的整数倍时,服务器会补一个 0 字节 DATA 块收尾,同样落进这个分支。这个尾块不经过 sink(dlen > 0U 才喂),但会计入块数——进度条按块数差补画时能把它一起画上。

(2)这个 OTA_TFTP_OK 只说明"不再有新字节了",不代表数据完整。TFTP 层不知道文件应该有多大,也从不假设自己知道。真正的完整性判据在下一节,由帧头承担。

4. 落盘:校验通过才写,写完回读比对 ​

从字节到可执行的固件,一共过了四道闸门,各自防不同的错:

层判据防的是什么失败表现
TFTP 传输块号严格递增 + 停等确认 + 丢包重传丢包、乱序、重复块重传;跳块/回退块立即报协议错
帧长度头部声明的 N,收满才转尾部少收(截断)卡在载荷阶段 → 3 s 帧超时
帧尾部IAPEOF01 8 字节长度错、截断、错位IAP_ERR_TRAILER
帧内容CRC32 与头部声明比对任意一位错、截断IAP_ERR_CRC
写 Flash写完回读再算 CRC32 比对Flash 擦写失败IAP_WR_ERR_VERIFY

关键不在"能发现错误",而在"错误的东西不会被写进去":写 Flash 的唯一入口只在 CRC32 通过后才被触发,机制不是"先写进去再校验",而是校验通过才写。链路怎么抖、文件怎么截断,都不可能把半份固件落进 App 区。

IAP_EVT_FRAME_OK 之后才擦写,目标是固定的 IAP_APP_BASE:

c
// atk-f103/Drivers/BSP/Iap/iap_service.c
#if IAP_FLASH_WRITE_EN
    /* 载荷已在外部 SRAM 并通过 CRC32: 烧进 APP 区。写完不自动跳转,
     * 等收到 "go" 命令再执行, 便于先确认写入结果 */
    iap_wr_res_t wr = iap_write_appbin(IAP_APP_BASE, p, iap_rx_payload_len());

iap_write_appbin() 内部按"擦页 → 逐半字编程 → 回读比对"执行,最后一步是第五道防线:对 Flash 里的副本再算一次 CRC32 与源数据比对。这一步盯的不是传输,而是写进去的和源数据是否一致——它对得上,才说明擦写过程本身没出错。

写入是同步阻塞的,128 KB 约需数秒,期间不接收新帧、网络也不轮询。

【注意】

可靠性并不依赖 CRC32。CRC 通过只证明"这段字节与打包者算的一致",不证明内容本身正确:PC 上拿错了工程产物、或拿旧 bin 重新打包,CRC 一样通过并被烧进去。它防的是传输与存储过程中的损坏,不防"源头就是错的"。

5. 主循环把这几件事串起来 ​

收包回调只在有包到达时被调用,而"没有包到达"这件事本身没有任何回调能通知你。超时重传、完成判定、失败收尾全是"没有事件发生时该做的事",所以必须有稳定节拍的执行者。主循环每 1 ms 调一次服务任务,顺序是排过的:

c
// atk-f103/Drivers/BSP/Iap/iap_service.c
void iap_service_task(void)
{
#if IAP_RX_EN
    svc_tick_iap();
#else
    svc_tick_uart();
#endif
    usart1_cmd_poll();                                      /* 文本命令: 识别在中断, 执行在这里 */
#if LWIP_APP_EN
    lwip_comm_poll();                                       /* 网络收包 + lwIP 内部定时器 */
#endif
#if OTA_UPDATE_EN
    /* 排在网络轮询之后: 先把这一拍到达的数据块收进来, 再判超时/中止,
     * 免得慢一点但还活着的链路被误判。传输结束时就地善后, 与 iap_rx_poll 同拍 */
    ota_update_task();
#endif
}

三条排序理由:

  • svc_tick_iap() 在最前,帧超时与 CRC 校验(128 KB 约百毫秒)每拍都优先处理;
  • usart1_cmd_poll() 居中,命令的执行永远在主循环上下文,不落到中断里;
  • lwip_comm_poll() 先收包、ota_update_task() 后判超时——否则一个稍慢但仍活着的链路会被误判成超时。

ota_update_task() 做四件事:补画进度条、比对帧错误计数基线(帧已判废就提前中止,不白等整份下载)、推进 ota_tftp_poll() 的超时重传与完成判定、收尾。它不做的事同样重要:不解析帧、不算 CRC、不写 Flash、不跳转。

最容易被看错的是收包路径——tftp_recv() 既不是中断也不是"另一个线程",它就嵌在 lwip_comm_poll() 的调用栈里,没有一次上下文切换:

text
主循环 iap_service_task()
└─ lwip_comm_poll()                                [lwip_comm.c]
   └─ ethernetif_input(&gnetif)                     /* 循环取包直到 DM9000 FIFO 空 */
      └─ netif->input() = ethernet_input()          /* netif_add 时注册 */
         └─ ip4_input() → udp_input()
            └─ tftp_recv()                          [ota_tftp.c]
               ├─ 取 4 字节头, 校验块号
               ├─ s_sink(s_buf, dlen) = ota_on_data()   [ota_update.c]
               │  └─ iap_rx_byte()  ×dlen           [iap.c]
               ├─ s_blocks++                        /* 进度条的唯一数据来源 */
               ├─ tftp_send_ack(block)
               └─ dlen < 512 ? tftp_finish(OTA_TFTP_OK) : return

正因为整条链都在同一个调用栈里,iap_rx_byte() 与 sink 必须保持 O(1):任何慢操作都会拖住整条链。

进度条也因此走"回调只计数、主循环消费"的路子。把 printf 放回收调里有三个问题:职责越界(TFTP 模块的契约是只搬字节);推迟 pbuf_free()(PBUF_POOL_SIZE 只有 8 个,占着接收缓冲不放会压缩队列余量);准确性(收尾的 0 字节尾块不经过 sink 却计入块数,按块数差补画才画得全)。代价也要如实说明:单块打印约 0.1 ms,离 1000 ms 的超时门限差三个数量级,所以这是一条纪律,而不是救命措施。

六、 互斥、跳转与完整流程 ​

1. 与 TCP 通道的互斥 ​

三条通道最终都喂进同一个帧状态机,所以同一时刻只能有一条在工作。互斥在两个方向上各做了一次检查:

场景检查点行为
TFTP 传输中,来了 TCP 客户端iap_tcp.c 的 accept 回调直接 tcp_abort 关连接,让 PC 侧立刻看到失败
TCP 会话中,输入 updateota_update.c 的 cmd_update()打印 OTA: refused, an IAP-TCP session is active 并返回

这样设计是为了让失败尽早暴露:与其让两路字节混进同一个状态机、收下半帧垃圾才报错,不如在入口处就拒掉。

2. 跳转 APP 前必须把网络关干净 ​

这是整个网络升级里最隐蔽的一个坑,提交 fix(iap): 跳转 APP 前关闭网络通道, 修复 APP 起步后卡死 就是在修它。

问题的成因:

  • SCB->VTOR 在跳转时会指向 APP 的向量表,但 NVIC 的 EXTI9_5 使能位不会被 APP 复位;
  • 如果跳转前还留着一个使能的 DM9000 中断,APP 起步后收到的第一个网络包就会落进 APP 自己的 Default_Handler(while(1))里卡死。

所以 iap_tcp_shutdown() 在 iap_load_app() 之前被调用,做五件事:

c
DM9000_WriteReg(DM9000_IMR, IMR_PAR);                    /* 1. 关网卡中断源 */
DM9000_WriteReg(DM9000_RCR, RCR_DIS_LONG | RCR_DIS_CRC);  /*    停接收(RXEN=0) */
HAL_NVIC_DisableIRQ(EXTI9_5_IRQn);                       /* 2. 关 EXTI 中断线 */
HAL_NVIC_ClearPendingIRQ(EXTI9_5_IRQn);
__HAL_GPIO_EXTI_CLEAR_IT(DM9000_INT_Pin);                /*    清 GPIO 侧挂起 */
session_close();                                         /* 3. 关 TCP 会话 */

必须"关三处"而不是只关一处:网卡侧的中断源、NVIC 侧的中断使能、TCP 会话。少任何一处,跳转后都留着一条能触发中断的通路。同样的处理在 reboot 命令里也有一份:软复位不会复位外置的 DM9000,不关的话重启初期 GPIO 已经使能 EXTI、而 FSMC 还没初始化,残留中断会打在那个窗口里。

3. 完整升级流程 ​

步骤谁做动作
1PC编译 APP,pack_iap.py 打包出 app.img
2PC把 app.img 放进 TFTP 服务器根目录,确认服务器 IP
3板子串口终端输入 update(或 update <服务器IP>)
4双方RRQ → DATA/ACK 停等传输,板子打进度条
5板子帧收齐 → CRC32 校验 → 写 APP 区 → 回读比对
6板子提示 send 'go' + Enter to run the APP
7板子输入 go:先 iap_tcp_shutdown() 关网络,再跳转 APP

用 TFTP 通道时不需要 TCP 会话,所以第 3 步之后直接等进度条走完即可。

七、 小结与参考资料 ​

1. 本篇小结 ​

  • TFTP 通道的价值是"PC 侧零代码 + 自带流控",代价是只能手动触发、发完要显式 go。
  • 实现上只做 RRQ,不协商选项、不做断点续传;服务器端口 69、块大小 512、单次等待 1s、连续重传上限 5 次。
  • 服务器根目录里必须放打好包的帧(app.img),裸 bin 是最常见的失败原因。
  • 板侧入口是 update 命令,它必须在 usart1_rx_start() 之前注册。
  • 传输通过 sink 回调把字节喂给统一的帧状态机;必须处理"服务器换 TID 端口"与"重复块只补 ACK"两个细节。
  • 三条通道共用同一个状态机,因此在入口处互相拒绝;跳转 APP 前必须同时关掉网卡中断源、NVIC 中断使能与 TCP 会话。

2. 参考资料 ​


本文档由 markdowncli 技能辅助生成