OTA 固件升级是指设备在不拆机的情况下,通过网络、蓝牙、串口或其他通信方式更新内部固件。
在实际项目中,OTA 并不是简单地把固件文件发送给设备。一个相对完整的升级流程通常还需要处理设备状态检查、固件校验、分包发送、超时重试、升级进度以及失败恢复。
下面以 C# 上位机程序为例,实现一个简单的 OTA 固件升级流程。
一、梳理 OTA 升级的基本流程
一个常见的 OTA 升级过程可以分为以下几个阶段。
第一步,读取固件文件,并检查文件是否存在。
第二步,计算固件大小和哈希值,确认文件没有损坏。
第三步,通知设备进入升级模式,并发送固件基本信息。
第四步,将固件拆分成多个数据块,按照顺序发送给设备。
第五步,每发送一个数据块,都要等待设备确认。
第六步,全部发送完成后,通知设备校验完整固件。
第七步,设备确认升级成功后重启,并运行新版本固件。
整个过程中,设备和 C# 程序之间需要有一套明确的升级协议。例如,可以约定以下几类指令:
进入升级模式。
发送固件信息。
发送固件数据块。
结束固件传输。
查询升级结果。
重启设备。
实际的指令编号、数据格式和校验方式,需要根据具体设备协议确定。
二、设计固件分包格式
固件文件通常比较大,不能一次性发送给设备,因此需要分成多个数据块。
假设每个数据块最大为 1024 字节,数据包中可以包含以下内容:
指令类型。
数据块编号。
数据块长度。
数据内容。
CRC 或其他校验值。
数据块编号非常重要。设备可以根据编号判断是否存在重复数据、数据丢失或者顺序错误。
例如,一个 5000 字节的固件文件,按照每块 1024 字节拆分,需要发送 5 个数据块。
前 4 个数据块都是 1024 字节,最后一个数据块是 904 字节。
除了数据块校验,还应该对完整固件计算哈希值:
byte[] firmware =
await File.ReadAllBytesAsync(
firmwarePath,
cancellationToken);
byte[] firmwareHash =
SHA256.HashData(firmware);
升级开始前,C# 程序将固件大小和哈希值发送给设备。
全部数据发送完成后,设备再次计算固件哈希值,并与升级开始时收到的值进行比较。只有两者一致,才能说明固件传输完整。
三、定义设备升级通信接口
不同设备使用的通信方式可能不同。为了让升级流程不直接依赖 TCP、串口或蓝牙,可以定义一个统一接口:
public interface IOtaTransport
{
Task EnterUpgradeModeAsync(
int firmwareSize,
byte[] firmwareHash,
CancellationToken cancellationToken);
Task SendBlockAsync(
int blockIndex,
byte[] blockData,
CancellationToken cancellationToken);
Task CompleteUpgradeAsync(
byte[] firmwareHash,
CancellationToken cancellationToken);
Task RebootDeviceAsync(
CancellationToken cancellationToken);
}
EnterUpgradeModeAsync 用于通知设备准备接收固件。
SendBlockAsync 用于发送一个固件数据块,并等待设备确认。
CompleteUpgradeAsync 用于通知设备固件传输完成,同时执行完整性校验。
RebootDeviceAsync 用于通知设备重启。
具体项目中,可以分别实现 TcpOtaTransport、SerialOtaTransport 或 BluetoothOtaTransport。
这些类负责数据包组装、发送、接收和响应解析,而 OTA 服务只负责控制整个升级流程。
四、实现 OTA 固件升级服务
下面实现一个简单的升级服务:
public sealed class OtaUpgradeService
{
private const int BlockSize = 1024;
private const int MaxRetries = 3;
private readonly IOtaTransport _transport;
public event Action<int>? ProgressChanged;
public OtaUpgradeService(IOtaTransport transport)
{
_transport = transport;
}
public async Task UpgradeAsync(
string firmwarePath,
CancellationToken cancellationToken)
{
if (!File.Exists(firmwarePath))
{
throw new FileNotFoundException(
"没有找到固件文件。",
firmwarePath);
}
byte[] firmware =
await File.ReadAllBytesAsync(
firmwarePath,
cancellationToken);
if (firmware.Length == 0)
{
throw new InvalidDataException(
"固件文件内容为空。");
}
byte[] firmwareHash =
SHA256.HashData(firmware);
await _transport.EnterUpgradeModeAsync(
firmware.Length,
firmwareHash,
cancellationToken);
int blockCount =
(firmware.Length + BlockSize - 1)
/ BlockSize;
for (int blockIndex = 0;
blockIndex < blockCount;
blockIndex++)
{
cancellationToken
.ThrowIfCancellationRequested();
int offset = blockIndex * BlockSize;
int currentSize = Math.Min(
BlockSize,
firmware.Length - offset);
byte[] blockData =
new byte[currentSize];
Buffer.BlockCopy(
firmware,
offset,
blockData,
0,
currentSize);
await SendBlockWithRetryAsync(
blockIndex,
blockData,
cancellationToken);
int progress =
(blockIndex + 1) * 100
/ blockCount;
ProgressChanged?.Invoke(progress);
}
await _transport.CompleteUpgradeAsync(
firmwareHash,
cancellationToken);
await _transport.RebootDeviceAsync(
cancellationToken);
}
private async Task SendBlockWithRetryAsync(
int blockIndex,
byte[] blockData,
CancellationToken cancellationToken)
{
Exception? lastException = null;
for (int attempt = 1;
attempt <= MaxRetries;
attempt++)
{
try
{
await _transport.SendBlockAsync(
blockIndex,
blockData,
cancellationToken);
return;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
lastException = ex;
if (attempt < MaxRetries)
{
await Task.Delay(
200,
cancellationToken);
}
}
}
throw new InvalidOperationException(
$"数据块 {blockIndex} 发送失败。",
lastException);
}
}
这个服务按照数据块编号依次发送固件。
只有当前数据块得到设备确认后,才会继续发送下一块。如果发送失败,会进行有限次数的重试。
当所有数据块发送完成后,再通知设备校验固件并重启。
五、显示升级进度和处理取消
升级过程中,通常需要在界面上显示进度。
可以订阅 ProgressChanged 事件:
var upgradeService =
new OtaUpgradeService(otaTransport);
upgradeService.ProgressChanged += progress =>
{
Console.WriteLine(
$"当前升级进度:{progress}%");
};
开始升级:
var cancelSource =
new CancellationTokenSource();
try
{
await upgradeService.UpgradeAsync(
firmwarePath,
cancelSource.Token);
Console.WriteLine("固件升级完成。");
}
catch (OperationCanceledException)
{
Console.WriteLine("用户取消了固件升级。");
}
catch (Exception ex)
{
Console.WriteLine(
$"固件升级失败:{ex.Message}");
}
如果是桌面程序,可以把 progress 更新到进度条。
取消升级时,不能只停止 C# 程序中的发送任务。最好再向设备发送取消升级指令,让设备退出升级模式并清理临时固件。
如果设备正在执行固件写入,是否允许取消需要根据设备设计决定。有些阶段可以安全取消,有些阶段必须等待写入完成,否则可能破坏固件分区。
六、实际项目中需要注意的问题
首先,固件版本必须进行检查。
升级前应该读取设备当前版本,并判断新固件是否适用于这个设备。不同型号或不同硬件版本使用错误固件,可能导致设备无法启动。
其次,不能只依赖普通哈希值保证安全。
SHA256 可以检查文件是否完整,但不能证明固件来自可信来源。对安全要求较高的设备,还应该使用数字签名,并在设备端验证签名。
再次,要考虑断电和网络中断。
设备不应该直接覆盖当前正在运行的固件。比较可靠的方案是准备两个固件分区,新固件校验成功后再切换启动分区。如果升级失败,设备仍然可以回退到旧版本。
对于较大的固件,可以增加断点续传功能。设备记录最后一个成功写入的数据块编号,重新连接后,C# 程序从该编号继续发送,不必重新传输整个文件。
升级完成并重启后,C# 程序还应该重新连接设备,读取实际运行版本。只有设备成功启动并返回新版本号,整个 OTA 流程才算真正完成。