基于 GD32 与 LwIP 的 TCP OTA 固件升级实现

在物联网产品开发中,OTA(Over-The-Air)远程升级功能已成为标配。相比于 HTTP 升级,TCP 升级协议更加轻量、灵活,且易于在资源受限的单片机上实现。本文将详细介绍如何在 GD32 单片机上,利用 LwIP 协议栈和 FatFs 文件系统,实现一套稳定可靠的 TCP OTA 升级方案,并配套提供 Python 上位机源码。

一、 方案设计

1.1 系统架构

本方案采用 C/S(客户端/服务端) 架构:

  • 下位机:运行 TCP Server,监听连接,接收固件数据并写入 Flash/SD 卡,校验通过后跳转 Bootloader。
  • 上位机:运行 TCP Client,选择固件文件,分片发送,并处理重传逻辑(通过 ACK 机制)。

1.2 协议定义

为了保证数据传输的可靠性,我们自定义了一套简单的应用层协议,支持分片传输和CRC32 校验。

命令字 含义 数据格式 (小端模式)
0x01 (CMD_START) 开始传输 Cmd(1) + FileSize(4) + NameLen(1) + FileName(N)
0x02 (CMD_DATA) 数据传输 Cmd(1) + DataLen(2) + Data(N)
0x03 (CMD_END) 传输结束 Cmd(1) + CRC32(4)
0x79 (OTA_ACK) 成功响应 1 Byte
0x1F (OTA_NACK) 失败响应 1 Byte

二、 下位机实现 (GD32 + LwIP)

下位机核心逻辑分为三个部分:TCP 流解析、状态机处理、文件系统操作。

2.1 核心难点:TCP 粘包处理 (Stream Read)

TCP 是流式协议,一次 recv 可能收到多个数据包,或者半个数据包。为此,我们封装了一个 OTA_StreamRead 函数,确保读取到指定长度的数据。

c 复制代码
/* ota_tcp.c */
static err_t OTA_StreamRead(struct netconn* conn, uint8_t* dest, uint16_t len)
{
    struct netbuf* buf;
    static struct netbuf* curr_buf = NULL; // 静态变量保存未读完的数据
    static u16_t curr_offset = 0;
    u16_t copied = 0;

    while (copied < len)
    {
        if (curr_buf == NULL)
        {
            // 当前缓冲区空了,从网络接收新数据
            if (netconn_recv(conn, &curr_buf) != ERR_OK) return ERR_CLSD;
            curr_offset = 0;
        }
        
        u16_t remaining_in_buf = netbuf_len(curr_buf) - curr_offset;
        u16_t can_copy = (remaining_in_buf < (len - copied)) ? remaining_in_buf : (len - copied);

        // 从 netbuf 中拷贝数据到目标地址
        netbuf_copy_partial(curr_buf, dest + copied, can_copy, curr_offset);
        copied += can_copy;
        curr_offset += can_copy;

        if (curr_offset >= netbuf_len(curr_buf))
        {
            // 这个 netbuf 读完了,释放内存
            netbuf_delete(curr_buf);
            curr_buf = NULL;
        }
    }
    return ERR_OK;
}

2.2 状态机处理逻辑

为了避免逻辑混乱,我们引入状态机 OTA_StateTypeDef。只有在接收到 CMD_START 成功后,才进入 OTA_STATE_RECEIVING 状态,防止意外数据写入 Flash。

c 复制代码
void OTA_TcpTask(void* arg)
{
    // ... 初始化代码 ...
    
    while (1)
    {
        if (netconn_accept(server, &client) == ERR_OK)
        {
            OTA_StateTypeDef ota_state = OTA_STATE_IDLE;
            uint32_t calculated_crc = 0;
            
            while (1)
            {
                if (OTA_StreamRead(client, &cmd, 1) != ERR_OK) break;

                switch (cmd)
                {
                    case CMD_START:
                    {
                        // 读取文件大小和文件名
                        // 调用 OTA_StartDownload 准备写入环境
                        if (OTA_StartDownload(...) == 0) {
                            ota_state = OTA_STATE_RECEIVING;
                            OTA_SendAck(client);
                        } else {
                            OTA_SendNAck(client);
                        }
                    }
                    break;

                    case CMD_DATA:
                    {
                        if (ota_state != OTA_STATE_RECEIVING) break;
                        
                        // 读取数据长度 -> 读取数据体
                        // 累加 CRC32
                        // 调用 OTA_WriteData 写入 Flash/SD
                        if (OTA_WriteData(...) == 0) {
                            OTA_SendAck(client);
                        } else {
                            // 写入失败,致命错误
                            OTA_SendNAck(client);
                            break; 
                        }
                    }
                    break;

                    case CMD_END:
                    {
                        // 读取上位机发来的 CRC32
                        // 比对累计计算的 CRC32
                        if (calculated_crc == received_crc) {
                            OTA_FinishDownload(); // 设置更新标志
                            OTA_SendAck(client);
                            // 延时后直接重启,无需优雅关闭 Socket
                            OSTimeDlyHMSM(0, 0, 0, 500);
                            OTA_RequestReboot();
                        } else {
                            OTA_SendNAck(client);
                        }
                    }
                    break;
                }
            }
            // 异常断开处理
            netconn_close(client);
            netconn_delete(client);
        }
    }
}

2.3 文件系统与安全写入

文件操作层增加了边界检查,防止写入数据超过预期大小,并在结束时校验文件长度。

c 复制代码
/* ota_ctrl.c */
int OTA_WriteData(uint8_t *data, uint16_t len)
{
    // 1. 边界检查:防止溢出
    if (ota_receivedSize + len > ota_expectedSize) {
        return -1;
    }

    // 2. 写入文件
    FRESULT fr = f_write(&ota_file, data, len, &bw);
    if (fr != FR_OK || bw != len) {
        return -1;
    }

    // 3. 更新进度
    ota_receivedSize += len;
    return 0;
}

int OTA_FinishDownload(void)
{
    f_close(&ota_file);
    
    // 完整性校验:大小是否一致
    if (ota_receivedSize != ota_expectedSize) {
        f_unlink(OTA_fileName); // 删除损坏文件
        return -1;
    }

    // 设置标志位,通知 Bootloader 下次重启进行升级
    SetUpdateFlag(ota_receivedSize, OTA_fileName);
    return 0;
}

三、 上位机实现

上位机使用 Python 的 socket 和 tkinter 库,实现了 GUI 界面和完整的传输逻辑。

3.1 协议打包发送

利用 struct 库按照小端模式打包数据,确保与单片机解析一致。

python 复制代码
import struct
import zlib

# 1. 发送 START 包
# < : 小端, B: 1字节命令, I: 4字节大小, B: 1字节文件名长度
pkt_start = struct.pack("<B I B", CMD_START, total_size, len(name_bytes)) + name_bytes
sock.sendall(pkt_start)

# 2. 发送 DATA 包
# < : 小端, B: 1字节命令, H: 2字节数据长度
header = struct.pack("<BH", CMD_DATA, len(chunk))
sock.sendall(header + chunk)

# 3. 发送 END 包 (包含 CRC32)
pkt_end = struct.pack("<B I", CMD_END, file_crc)
sock.sendall(pkt_end)

3.2 应答等待机制

每一包发送后,必须等待 MCU 返回 ACK,否则视为失败,确保数据链路可靠。

python 复制代码
def wait_for_ack(sock, context=""):
    try:
        res = sock.recv(1)
        if not res: raise Exception("连接关闭")
        if res[0] == OTA_ACK: return True
        raise Exception("NACK Received")
    except socket.timeout:
        raise Exception("Timeout")

四、 测试与验证

连接测试:上位机输入 IP 和端口,点击"开始升级",观察串口日志输出 Connection Accepted。

传输测试:选择 .bin 文件,观察进度条。MCU 端日志应输出文件名、大小及写入进度。

校验测试:

传输完成后,上位机计算 CRC32 并发送。

MCU 比对 CRC32,输出 CRC Check Passed。

MCU 设置更新标志并自动重启。

五、 避坑指南

在实际开发中,我遇到了以下几个关键问题,分享给大家:

  • 大小端问题:

GD32 是小端模式,网络传输通常是大端。Python 打包时使用了 < 符号强制小端,MCU 端直接 memcpy 或强制指针转换读取即可。如果不一致,会导致文件大小解析错误。

  • 静态变量残留:

OTA_StreamRead 中的 curr_buf 必须是 static 的,用于处理 TCP 粘包。但如果连接异常断开重连,记得要有一个 Reset 机制清理这个静态变量,否则新连接会读到旧数据(本文代码中已优化处理逻辑)。

  • 重启时机:

在发送最后一个 ACK 后,不要尝试 netconn_close。因为 close 需要握手时间,MCU 直接复位更简单直接,上位机收到 ACK 后检测到连接断开是正常现象。

六、 总结

本文介绍了一种基于 TCP 的轻量级 OTA 方案。通过自定义协议、CRC32 校验和状态机管理,实现了嵌入式设备的可靠升级。该方案代码结构清晰,易于移植到 STM32、ESP32 等其他平台。

相关推荐
zhang133830890751 小时前
QY-18DL-1倾斜位移裂缝计:智能地质安全监测先锋
运维·网络·安全
你觉得脆皮鸡好吃吗1 小时前
SQL注入 高权限注入(引入概念)
网络·数据库·sql·oracle·网络安全学习
RisunJan2 小时前
Linux命令-nmap(网络探测和安全审计工具)
linux·网络·安全
王的宝库2 小时前
【K8s】集群安全机制(三):准入控制
网络·安全·kubernetes
志栋智能2 小时前
超自动化巡检的核心价值:效率、质量与洞察
运维·服务器·网络·人工智能·自动化
恶猫2 小时前
自动拨号换ip软件简单实现。aardio版。
java·网络·aardio·adsl·换ip·rasphone.exe·rasdial.exe
Hello_Embed2 小时前
嵌入式上位机开发入门(二十八):JSON 与 JsonRPC 入门
网络·笔记·网络协议·tcp/ip·嵌入式
代码羊羊2 小时前
Rust-特征trait和特征对象
服务器·网络·rust
minji...2 小时前
Linux 网络套接字编程(一)端口号port,socket套接字,socket,bind,socket 通用结构体
linux·运维·服务器·开发语言·网络