在物联网产品开发中,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 等其他平台。